diff --git a/.env.production.example b/.env.production.example index bc664accf..c5f56207c 100644 --- a/.env.production.example +++ b/.env.production.example @@ -450,6 +450,19 @@ API_TOKEN_HMAC_KEY="" # for the heaviest legitimate read (the live-aggregate analytics fallback on a # large account). Set "0" to disable it and restore unbounded behaviour. # DATABASE_STATEMENT_TIMEOUT_MS="60000" +# +# Postgres `work_mem` applied to every app session, as a Postgres size string. +# Default 16MB. Large sorts and aggregates that exceed it spill to disk; each +# sort step of each pooled connection may use this much memory, so raise it +# with the pool size in mind. +# DATABASE_WORK_MEM="16MB" +# +# Both settings above reach Postgres as the libpq `options` startup +# parameter. PgBouncer refuses that parameter ("unsupported startup +# parameter: options") unless it is listed in its ignore_startup_parameters. +# Behind such a pooler, set this to 1, true or yes to send no session +# settings at all; the pooler's or the database's own defaults then apply. +# DATABASE_SESSION_OPTIONS_DISABLED="1" # ----------------------------------------------------------------------------- @@ -592,6 +605,16 @@ API_TOKEN_HMAC_KEY="" # force the public-host guard, so an internal instance is not blocked). # OPENMETEO_BASE_URL="https://archive-api.open-meteo.com" # OPENMETEO_GEOCODING_URL="https://geocoding-api.open-meteo.com" +# +# Air quality, pollen and UV come from the Open-Meteo air-quality API, which +# serves the Copernicus Atmosphere Monitoring Service (CAMS) models. CAMS data +# is free to use with attribution, which the app shows next to the values. +# Like the URLs above this one is optional and can point at a self-hosted +# Open-Meteo instance. Set ENVIRONMENT_AIR_QUALITY_DISABLED to 1, true or yes +# to stop the air-quality requests for the whole instance; weather and +# daylight keep working, and no user can turn air quality back on. +# OPENMETEO_AIR_QUALITY_URL="https://air-quality-api.open-meteo.com" +# ENVIRONMENT_AIR_QUALITY_DISABLED="1" # ── Native canvas renderer (document thumbnails, scanned-PDF rasterization) ── # The bundled renderer's x64 build uses AVX2 CPU instructions. On x86-64 hosts diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 7d8e7f98f..22c71017d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,6 +10,19 @@ updates: applies-to: security-updates update-types: - "patch" + # One weekly pull request per dependency kind instead of a trickle of + # single bumps: patch and minor updates travel together and pass the + # same checks once. Majors stay single so each gets its own review. + production-weekly: + dependency-type: "production" + update-types: + - "patch" + - "minor" + development-weekly: + dependency-type: "development" + update-types: + - "patch" + - "minor" - package-ecosystem: "github-actions" directory: "/" schedule: diff --git a/.github/workflows/coach-eval.yml b/.github/workflows/coach-eval.yml index 8b37a5659..e7e4eb6e5 100644 --- a/.github/workflows/coach-eval.yml +++ b/.github/workflows/coach-eval.yml @@ -26,7 +26,7 @@ permissions: jobs: coach-eval: name: Coach eval — deterministic floor (always) + live judge (opt-in) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 # Map the opt-in secret to a job-level env so the live-judge steps can gate diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a14ca324b..0fee025c2 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -36,7 +36,7 @@ concurrency: jobs: analyze: name: Analyse javascript-typescript - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 30 permissions: contents: read diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml index 0323be4c6..99f71e007 100644 --- a/.github/workflows/dependabot-auto-merge.yml +++ b/.github/workflows/dependabot-auto-merge.yml @@ -11,7 +11,7 @@ permissions: jobs: auto-merge: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: github.actor == 'dependabot[bot]' permissions: contents: write diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index a34a34c9d..4274e7b3c 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -39,7 +39,7 @@ concurrency: jobs: release-policy: name: Validate release policy - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: actions: read @@ -121,7 +121,7 @@ jobs: matrix: include: - platform: linux/amd64 - runner: ubuntu-latest + runner: ubuntu-24.04 cache_scope: amd64 - platform: linux/arm64 runner: ubuntu-24.04-arm @@ -313,7 +313,7 @@ jobs: # the registry instead of on one cross-emulated runner. merge: name: Merge multi-arch manifest - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: github.event_name != 'pull_request' needs: [build] timeout-minutes: 10 @@ -449,7 +449,7 @@ jobs: promote: name: Promote verified image to Coolify needs: [merge, verify-published-image] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: contents: read diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 111624651..a3960c33d 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -24,7 +24,7 @@ jobs: # mobile route sweep landed, and a slow runner tipped them over; three # shards with a 30-minute cap leave room for both. shard: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 30 strategy: fail-fast: false @@ -128,7 +128,7 @@ jobs: # that name and answers for both shards: it fails when any shard failed or # was cancelled, so a half-green matrix cannot satisfy the gate. e2e: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: shard if: always() timeout-minutes: 5 diff --git a/.github/workflows/env-check.yml b/.github/workflows/env-check.yml index 11ec45835..a86c42a87 100644 --- a/.github/workflows/env-check.yml +++ b/.github/workflows/env-check.yml @@ -21,7 +21,7 @@ permissions: jobs: env-check: name: pnpm check-env against .env.production.example - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - name: Checkout diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index b75a9d306..9b683d0e0 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -22,7 +22,7 @@ jobs: # job timeout only needs to catch a container that never boots. Each shard # boots its own Postgres testcontainer. shard: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 40 strategy: fail-fast: false @@ -67,7 +67,7 @@ jobs: # carries that name and answers for every shard: it fails when any shard # failed or was cancelled, so a half-green matrix cannot satisfy the gate. integration: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: shard if: always() timeout-minutes: 5 diff --git a/.github/workflows/knip.yml b/.github/workflows/knip.yml index 94d7700e9..8d0e67a25 100644 --- a/.github/workflows/knip.yml +++ b/.github/workflows/knip.yml @@ -12,7 +12,7 @@ permissions: jobs: knip: name: Dead-code gate - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: diff --git a/.github/workflows/no-todo-markers.yml b/.github/workflows/no-todo-markers.yml index 501c2b7e6..d53f04fca 100644 --- a/.github/workflows/no-todo-markers.yml +++ b/.github/workflows/no-todo-markers.yml @@ -16,7 +16,7 @@ jobs: # future contributor reintroduces the pattern. grep: name: Forbidden marker grep - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/post-publish-verify.yml b/.github/workflows/post-publish-verify.yml index 330be15b5..4f4bdf70f 100644 --- a/.github/workflows/post-publish-verify.yml +++ b/.github/workflows/post-publish-verify.yml @@ -45,7 +45,7 @@ permissions: jobs: verify: name: Pull and smoke-test exact published image - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 env: IMAGE_REF: ${{ inputs.image_ref }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 9fe3950d5..a6c3518ae 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -25,7 +25,7 @@ permissions: read-all jobs: analysis: name: Score supply-chain practices - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: # Write the findings into the repository's code-scanning alerts. diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 5037c89d7..4413b31a8 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -17,7 +17,7 @@ permissions: jobs: quality: name: Lint, Typecheck & Test - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: github.event_name != 'schedule' steps: @@ -100,7 +100,7 @@ jobs: # published image daily. dependency-audit: name: Dependency Audit - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 # Runs on pull requests too, but advisory: an advisory published against a # dependency the branch did not touch must not block the branch. The push # run on main and the daily schedule stay blocking, and a tag is only ever @@ -130,7 +130,7 @@ jobs: secret-scan: name: Secret Scanning - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: github.event_name != 'schedule' steps: @@ -145,7 +145,7 @@ jobs: docker-security: name: Container Security - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: diff --git a/.github/workflows/synthetic-journey.yml b/.github/workflows/synthetic-journey.yml index 2d096f9b0..268154596 100644 --- a/.github/workflows/synthetic-journey.yml +++ b/.github/workflows/synthetic-journey.yml @@ -45,7 +45,7 @@ permissions: jobs: journey: name: Sign in, write a reading, read it back - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 289b57daf..df9949f95 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -30,7 +30,7 @@ concurrency: jobs: filesystem: name: Lockfile (including dev dependencies) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: contents: read @@ -95,7 +95,7 @@ jobs: published-image: name: Published image - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index 287df926e..818cb8a3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,259 @@ # Changelog +## [1.42.0] — 2026-10-09 + +A day view behind every dated value, an optional timeline with life events, +air quality and pollen per day, a Health Connect import, and the handover of +a managed profile to the person it describes. The measurement table drops +the samples the nightly fold used to keep, after a one-time repair of means +an earlier fold computed from part of a day. + +### Added + +- **Day view (#613).** `?day=YYYY-MM-DD` opens one local day over the + current page: docked from 1280 px beside a fixed strip at the right edge + that opens and closes it and keeps it, with the last day, across pages; a + side sheet below that and a bottom sheet on a phone. On `/coach` it sits + next to the conversation list, each with its own strip. It shows the value + it was opened from, what ran through the day (one entry per medication + with the day's dose, the cycle day and phase), eight curated readings + against the 30-day usual range (the rest behind "All values"), the day's + scores (health score, readiness, recovery, sleep score, strain) and the + day's entries in clock order. Readings and scores link to their pages, and + the date in the header opens a calendar with Today. Every chart drawn in days opens it (on touch the tooltip offers the + day first), as do data tables, the measurement, mood and intake lists, + heatmaps, lab readings, documents, workouts, symptom and illness days and + the days a Coach answer read. Back and Escape close it, Alt with an arrow + steps a day. A planned visit shows what changed since the last one from + `GET /api/day/notable`. Not offered: the dashboard's today area, tile + charts, the cycle calendar, model text, future dates, the clinician share + link and the doctor-report PDF. +- **Timeline module (#613).** Opt-in. One SVG time axis from 768 px with + lanes for life events, conditions, allergies, medications, vaccinations, + visits and procedures, labs and documents (one row per medication, dose + changes and pauses as segments of its bar), a year, month or week grid or + any range, and as many value lines as the record has types, as quarterly, + monthly, weekly or daily means by span (a short gap dashed, a longer one + left open, a mean from fewer than three readings drawn hollow, each point + with its own tooltip); a chronicle, newest first, on a phone. Switching the module on opens a readiness inventory that rates + each lane and links each gap; it stays available as Data coverage. +- **Life events.** Title, kind (family, home, work, loss, other), a start + at day, month or year precision, optional end and note; title and note + encrypted at rest (migration 0387). Owner only in this release: no share + level or MANAGE grant reads or writes them, and no model ever receives + them. They travel in both backup purposes and are wiped with the account. +- **Air quality, pollen and UV (#615).** The environment fetch adds the + Open-Meteo air-quality feed (CAMS, `domains=auto`): PM2.5, PM10, NO2, + SO2, CO, the ozone 8-hour high, European and US AQI, UV, dust, aerosol + depth and six pollen groups, folded into local days (means and maxima need + 18 of 24 hours) and stored as null where the feed has no value + (migration 0381). Pollen exists only in the European domain. A nightly + gap fill covers up to 90 days per account. PM2.5, the ozone high and the + highest pollen join the correlation channels. An account switch under + Location & weather (on by default), attributions beside the values, and a + dashboard chip on a day with high pollen, a warm night or very poor air. +- **Air quality for the past.** A background job fills air quality, pollen + and UV for earlier local days with an entry: after the update, when the + home is set and when air quality is switched on. A day inside a location + period is fetched at that place, a day from the home's effective date on + at the home; a day before it with no period stays empty rather than + borrowing a place the person was not at. The source reaches back to 2013 + in Europe and to August 2022 elsewhere, pollen from 2021 in Europe only; + older days stay empty. Nearby days share one + request of up to 90 days, a run holds itself to 300 of the account's 400 + daily calls and half of each instance window, and a long history spreads + over several nights. Location & weather shows the progress (migration + 0391). +- **`get_environment` and `get_day`** for the Coach and MCP. + `get_environment` is module-gated and carries no coordinate or place name; + `get_day` leaves out life events, notes and document names and honours + the person's Coach exclusions. +- **Health Connect import (#972).** Upload the ZIP Android's Health Connect + exports under Export & Import. It is read in the background (read-only + SQLite, schema version 9 or later, 1 GiB upload cap, one import per + account): daily totals from the top-priority app, per-minute heart rate + for 90 days and hourly means before that, sleep stages, workouts, cycle + days, water and nutrients, under the new source `HEALTH_CONNECT` + (migrations 0379, 0380). Apps already connected directly are skipped and a + second import of the same file writes nothing. +- **Managed profile handover (#959).** A guardian with a fresh second factor + creates a one-time link (1, 7 or 14 days, shown once with a QR code) and + proposes no access, view only or manage per guardian. The person opens + `/claim/`, sets a username, email and password, and decides each + guardian's access after signing in. Pending invitations are withdrawn; + disclaimer, onboarding and AI consent reset. Refused on `OIDC_ONLY` + instances (migrations 0383, 0390). +- **Custom vaccines (#1005).** A record defines its own vaccine with the + antigens it covers, series length and booster interval; its doses count + into each antigen's series and settle boosters (migration 0386). +- **Workout editing (#1162).** A hand-entered workout opens its form from + the detail page and saves as an overwrite of the same row. +- **HRV RMSSD from Apple Health (#1110).** iOS 27's + `HKQuantityTypeIdentifierHeartRateVariabilityRMSSD` is stored as + `HRV_RMSSD` (1 to 300 ms) with its own source-priority ladder, charted + apart from SDNN. +- **Apple Health arrival per type (#1173).** The Apple Health card shows + when each type last arrived, by which trigger (including the new + `manual` for Sync all) and when it last brought a new value + (migration 0384). +- **Documents layout.** Preview cards or a compact list, months flowing + side by side (the default) or stacked, per account (migration 0388). Only + a field the person picked is stored, so an untouched one follows the + default. +- **Measurement maintenance.** An admin card and + `POST /api/admin/maintenance/measurements` run `VACUUM (ANALYZE)` and a + `REINDEX INDEX CONCURRENTLY` per index, largest first. +- **Query statistics.** The bundled database loads `pg_stat_statements`; + `CREATE EXTENSION` once to read it (`docs/ops/query-statistics.md`). +- **Score history.** `/insights/health-score` charts the daily health score + with its usual band, broken where the score's recipe changed, from + `GET /api/insights/score-history`; readiness, recovery, sleep score, + stress and strain share one 7, 30, 90 days or all range picker. +- **Top card switch.** The dashboard layout settings can hide the top card + (`todayCardVisible`). +- **Keyboard shortcuts.** `g` then `d`, `m`, `l`, `t`, `i`, `c` or `s` opens + a page, `n` the add menu, `[` and `]` step the open day, and `?` (or the + account menu) lists them; ignored while typing in a field. +- **Command palette.** Cmd+K (Ctrl+K) or the search field in the top bar + finds pages, Insights pages, every Settings card by its anchor, module + switches and actions (new entry, the Coach, a backup, today, the + shortcuts), filtered by modules, AI availability, role and a shared + record's grant; typo-tolerant, with recent places when empty. + +### Changed + +- **Correlations remove season and trend.** Every daily pair is correlated + on residuals after a linear trend and one annual harmonic are taken out + of both series (trend only under 120 days), with p-values on the + Pyper-Peterman effective sample size. Weather channels compare the mean + of the day before and the day itself with the same day's outcome. Some + earlier findings no longer qualify. +- **Fold leftovers.** The nightly folds delete the raw samples they fold + instead of keeping them for 75 days. A re-sent raw sample whose hour or + day a live `stats:` row covers is a duplicate with reason `folded_window`, + in the batch route, the export import and the Health Connect import. A + background purge removes the backlog in batches of 5,000 per account; the + sync feed and a restore leave these deletions out. Health Connect heart + rate is folded like Apple Health's. +- **Duplicate batches.** A batch of samples the server already holds is + answered without a write and queues no record detection or reminder + sweep; caches are evicted only for a foreground or manual sync or a + hand-entered reading, and marked stale otherwise. +- **Locations encrypted.** The home location, travel periods and stored + environment days keep their coarse location sealed (migration 0382); a + background job seals existing rows. +- **Lighter client.** Settings sections load on their own (611 to 403 KB + gz) and the shared shell no longer carries Zod (199 to 136 KB gz). +- **Wellness scores** show their course on each tile and page; the sleep + score and readiness reads add `series`. The proxy caveat line is gone. +- **Empty states and add actions.** An empty list carries the one add + action; the Insights pages add through the header plus. +- **Touch and small screens.** 44 px targets on touch screens, a sidebar + that fits short windows without a native scrollbar, the phone shell on a + phone held sideways, 16 px fields, safe areas in the installed app and + sheets that stay above the on-screen keyboard. +- **Provider pause.** From five consecutive hard failures a provider is + skipped for a window that starts at 30 minutes and doubles, capped at a + day; a dead Codex refresh token reads as a sign-in to renew. +- **`work_mem`** is set per app connection from `DATABASE_WORK_MEM` + (16MB); `DATABASE_SESSION_OPTIONS_DISABLED` sends no session options for + hosts behind PgBouncer. +- Volume queues keep finished jobs for 96 hours instead of seven days. + +### Fixed + +- **Means folded from part of a day.** The folds cut at a fixed offset + instead of the local day boundary, so a second run replaced a mean-type + day, and the boundary hour of heart rate, HRV and SpO2, with the mean of + its later part. Folds now stop at the local day boundary, and a one-time + repair per account recomputes each mean from its samples and writes only + values that differ (migration 0389). It covers the last 75 days of daily + means and the 75 days before the 90-day raw window of hourly means, where + every sample of the day is still kept. Older days stay as they are: their + remaining samples can be a fragment of the day, and a mean over them would + be worse than the stored one. The nightly folds follow the same rule for a + day or hour that already has a mean. The purge waits for the repair. +- **Push after an update.** Reload on the new-version hint unregistered the + service worker, which ended the Web Push subscription; it now updates the + worker instead. +- **Discord and Slack webhooks.** A Discord webhook URL answered the test + and every reminder with "Cannot send an empty message", and a Slack + incoming webhook refused the body too: the generic format carried neither + Discord's `content` nor Slack's `text`. It now sends both as plain text + (title and message, cut to Discord's 2000 characters), tells Discord to + resolve no mentions, and keeps `title`, `message`, `eventType` and + `priority` unchanged for Home Assistant and n8n rules. Discreet mode + covers the new fields. +- **MCP deep links.** `/insights?metric=` and `/labs?analyte=` open the + metric's page and the marker's page. +- **Apple Health status** no longer reads "waiting for first data" after an + export import. +- **Strain** falls back to the device's day strain when no computed value + exists. +- Failed briefing runs name each account's outcome, a briefing held back + for a causal claim gets one repair attempt instead of hourly retries, and + the tiered context converts stored times to the reader's zone. +- Google Health re-reads two hours of intraday heart rate per sync, and the + first cycle of each local day a full day (#1023). +- The capture sheet opens from the dashboard's add button on phones; the + installed app follows its own theme in the status bar and toasts. +- `source-map-js` 1.2.2, `postcss-selector-parser` 7.1.6 and `smol-toml` + 1.9.0 close CVE-2026-93749, CVE-2026-104844 and GHSA-r4xh-jqrq-34v2 in the + build and development toolchain. + +### Removed + +- The "took it twice" and "skipped a dose" achievements. + +### API + +All changes are additive. + +- New routes: `/api/day/{date}`, `/api/day/index`, `/api/day/notable`, + `/api/timeline`, `/api/timeline/readiness`, `/api/life-events`, + `/api/vaccinations/custom`, `/api/environment/preferences`, + `/api/documents/inbound/layout`, `/api/import/health-connect-export`. +- The measurement source enum gains `HEALTH_CONNECT`. +- `/api/auth/me` adds `timezone`, `glucoseUnit` and `features.trackIntake`; + module availability lists `timeline`. +- `POST /api/measurements/batch` accepts `syncTrigger: "manual"`; a re-sent + sample whose hour or day is already condensed returns `duplicate` with + reason `folded_window`. +- `GET /api/integrations/healthkit` adds `lastReceivedAt`, `lastTrigger` and + `lastNewSampleAt` per type. +- The sync feed lists only deletions a person makes, not those of the + condensing step. +- `GET /api/environment` adds `airQuality` (with `history`, the progress of + the past-days backfill), `latestDay` and `attributions`. +- `GET`/`PUT /api/auth/me/source-priority` add `inUse`. +- A dose for a record-only medication is refused with 422 + `medication.intake.notTracked`; failed passkey sign-ins answer with + `passkey.*` codes instead of a 500. +- `/api/medications/compliance` adds `meta.aggregate` and `?days=`; + `/api/mood/insights` takes `?days=` and returns `stabilityWindowDays`. +- Coach steps can name the tools `get_day` and `get_environment`, the + domains `day` and `environment`, and a `day` date. +- Vaccination doses carry `customVaccineId`, correlation findings `nEff`, + workouts `storedAvgHr` and `storedMaxHr`, and the sleep score and + readiness reads a `series` of past values. +- `/api/life-events*` answer only the record's owner; a delegate gets 403, + the day's `lifeEvents` section reads `not_shared` and the timeline has no + `life` lane. +- `POST /api/environment/backfill` answers 429 + `environment.backfill_rate_limited` (three an hour per account) and 409 + `environment.backfill_pending` while one is queued. +- `GET /api/environment` may return `lat` and `lon` as `null`. +- The handover claim preview no longer carries `grantId`; a guardian's + `displayName` may be `null`. +- The restore report adds the catalog `environmentLocationCiphertext`. +- `get_day` names documents and visits by kind only. + +### Thanks + +@lutzkind for #613, #615 and #586, @CritLoren for #972 and #1005, @ckaotik +for #959, @BernhardBuckel for #1110, @mills1975 for #1173 and @NebuPookins +for #1162. + ## [1.41.2] — 2026-10-07 More room for a Coach turn, prompt caching that holds across its rounds, and diff --git a/bundle-budget.json b/bundle-budget.json index 125356595..d5c59176a 100644 --- a/bundle-budget.json +++ b/bundle-budget.json @@ -1,12 +1,13 @@ { - "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /insights and /measurements sat within a kilobyte of theirs and / and /insights/mood within one of theirs; e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the 445 KB budget` with the other two at 459 of 460, and none of it was a regression anyone had made. A ceiling a route is already touching does not measure the route, it measures whichever dependency bump landed that week. The file states the measured value per route now, plus one drift allowance over it: measured on a local arm64 build at d48bbff01 as / 447, /insights 434, /measurements 433, /insights/mood 447, with a 20 KB allowance, so the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The allowance covers the runner delta (the CI runner measures each route a little heavier than a local arm64 build) and ordinary week-to-week drift, while the step change the gate exists for — a statically imported message catalog, a duplicated chart library — is several times larger and still fails. Every run now prints the delta against the baseline, so a route that has crept 15 KB is legible in the log long before it is red; the flat ceiling could only ever say pass or fail. Re-base by re-measuring on a build with nothing else in it and writing the new numbers here with the reason, exactly as a raised ceiling was written before. Raised to 3724 at v1.39.4 on a measured delta, not a feature. The release branch alone measured 3699 KB gz (3698.9) against the 3700 cap, so the ordinary drift room the v1.37.32 raise left had been used up by the releases since; the review fixes on top of it added 1.3 KB gz across the whole build (3700.2), almost all of it new copy in the seven lazy locale catalogs (the total and reused-table labels, the step and chip wording) plus the result-panel states. The four eager routes measured the same on both builds (/ 458, /insights/mood 456, /insights 445, /measurements 442), all inside their ceilings. The aggregate cap moves from 3700 to 3724, the same 24 KB headroom as the re-measurements before it. Raised to 3749 at v1.39.9 on a measured delta. Main measured 3718 KB gz after v1.39.7 and v1.39.8 (unit-aware copy, the phone layouts, the time-zone work and the account-settings restore labels, almost all of it new strings in the seven lazy locale catalogs), leaving 6 KB of the v1.39.4 headroom; the manual workout entry (#1085: the form, its sheet, the delete action and their copy in seven locales) adds about 7 KB and measured 3725. The four eager routes stay inside their drift allowances (/ +12.5, /insights +12.1, /insights/mood +10.2 KB of 20 on main). The aggregate cap moves from 3724 to 3749, the same 24 KB headroom as the re-measurements before it. Raised to 3790 at v1.40.0 on a measured delta. With the background document reads on main (#1103: the run polling hook, the still-reading states and their copy in seven locales), the key backup step and the plain-http sign-in notice (#1104) and the v1.40 fixes (#1106: the duration reader, the marker unit lock, the chart window) each branch measured 3752 KB gz on its own against the 3749 cap; together they add a few KB more. The four eager routes stay inside their drift allowances (/ +18.4, /insights +16.5, /measurements +14.9, /insights/mood +14.7 KB of 20). The aggregate cap moves from 3749 to 3790, about 24 KB above the combined build, the same headroom as the re-measurements before it. Re-based at v1.40.0 when the quick-entry forms began loading on demand. Custom symptoms added a fifth quick-entry form, and with all five forms bundled eagerly every one of the four eager routes measured over its ceiling (/ 472, /insights 457, /insights/mood 468, /measurements 454 KB gz, each 21 to 25 KB over the baseline). Loading the forms on demand brings them to / 429, /insights 368, /insights/mood 379, /measurements 411: 43 to 90 KB less on first load. The four route baselines move down to those measured values, so the gain is protected by the same 20 KB allowance rather than spent. The aggregate moves the other way, 3774 to 4077, for the reason described at v1.37.27: the aggregate counts each emitted chunk once per route group, and on-demand form chunks land in every route group that can open a quick-entry sheet. Bundling the five forms into one shared on-demand module measured the same (4078), so the split is not the cause. The aggregate cap moves from 3790 to 4100. Bytes on disk rose; bytes a reader downloads fell on every eager route. Raised to 4155 at v1.40.2 on a measured delta. The Coach redesign (the right conversation panel, its row menu and the quick-settings overlay, all loaded on demand), the answer action row, the dashboard's Today overview, the document procedure filter and their copy in seven locales took the whole-build sum from 4080 to 4129 KB gz; most of it is new strings in the seven lazy locale catalogs and the on-demand Coach chunks, which a first paint does not load. The four eager routes stay well inside their drift allowances (/ +1.9, /measurements +1.9, /insights/mood +1.4, /insights +1.0 KB of 20). The aggregate cap moves from 4100 to 4155, about 26 KB above the build, the same headroom as before; per-route baselines are unchanged.", + "$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /insights and /measurements sat within a kilobyte of theirs and / and /insights/mood within one of theirs; e2e run 34043772954 failed on `/insights/page: 445 KB gz exceeds the 445 KB budget` with the other two at 459 of 460, and none of it was a regression anyone had made. A ceiling a route is already touching does not measure the route, it measures whichever dependency bump landed that week. The file states the measured value per route now, plus one drift allowance over it: measured on a local arm64 build at d48bbff01 as / 447, /insights 434, /measurements 433, /insights/mood 447, with a 20 KB allowance, so the effective ceilings are 467 / 454 / 453 / 467 — a raise for all four. The allowance covers the runner delta (the CI runner measures each route a little heavier than a local arm64 build) and ordinary week-to-week drift, while the step change the gate exists for — a statically imported message catalog, a duplicated chart library — is several times larger and still fails. Every run now prints the delta against the baseline, so a route that has crept 15 KB is legible in the log long before it is red; the flat ceiling could only ever say pass or fail. Re-base by re-measuring on a build with nothing else in it and writing the new numbers here with the reason, exactly as a raised ceiling was written before. Raised to 3724 at v1.39.4 on a measured delta, not a feature. The release branch alone measured 3699 KB gz (3698.9) against the 3700 cap, so the ordinary drift room the v1.37.32 raise left had been used up by the releases since; the review fixes on top of it added 1.3 KB gz across the whole build (3700.2), almost all of it new copy in the seven lazy locale catalogs (the total and reused-table labels, the step and chip wording) plus the result-panel states. The four eager routes measured the same on both builds (/ 458, /insights/mood 456, /insights 445, /measurements 442), all inside their ceilings. The aggregate cap moves from 3700 to 3724, the same 24 KB headroom as the re-measurements before it. Raised to 3749 at v1.39.9 on a measured delta. Main measured 3718 KB gz after v1.39.7 and v1.39.8 (unit-aware copy, the phone layouts, the time-zone work and the account-settings restore labels, almost all of it new strings in the seven lazy locale catalogs), leaving 6 KB of the v1.39.4 headroom; the manual workout entry (#1085: the form, its sheet, the delete action and their copy in seven locales) adds about 7 KB and measured 3725. The four eager routes stay inside their drift allowances (/ +12.5, /insights +12.1, /insights/mood +10.2 KB of 20 on main). The aggregate cap moves from 3724 to 3749, the same 24 KB headroom as the re-measurements before it. Raised to 3790 at v1.40.0 on a measured delta. With the background document reads on main (#1103: the run polling hook, the still-reading states and their copy in seven locales), the key backup step and the plain-http sign-in notice (#1104) and the v1.40 fixes (#1106: the duration reader, the marker unit lock, the chart window) each branch measured 3752 KB gz on its own against the 3749 cap; together they add a few KB more. The four eager routes stay inside their drift allowances (/ +18.4, /insights +16.5, /measurements +14.9, /insights/mood +14.7 KB of 20). The aggregate cap moves from 3749 to 3790, about 24 KB above the combined build, the same headroom as the re-measurements before it. Re-based at v1.40.0 when the quick-entry forms began loading on demand. Custom symptoms added a fifth quick-entry form, and with all five forms bundled eagerly every one of the four eager routes measured over its ceiling (/ 472, /insights 457, /insights/mood 468, /measurements 454 KB gz, each 21 to 25 KB over the baseline). Loading the forms on demand brings them to / 429, /insights 368, /insights/mood 379, /measurements 411: 43 to 90 KB less on first load. The four route baselines move down to those measured values, so the gain is protected by the same 20 KB allowance rather than spent. The aggregate moves the other way, 3774 to 4077, for the reason described at v1.37.27: the aggregate counts each emitted chunk once per route group, and on-demand form chunks land in every route group that can open a quick-entry sheet. Bundling the five forms into one shared on-demand module measured the same (4078), so the split is not the cause. The aggregate cap moves from 3790 to 4100. Bytes on disk rose; bytes a reader downloads fell on every eager route. Raised to 4155 at v1.40.2 on a measured delta. The Coach redesign (the right conversation panel, its row menu and the quick-settings overlay, all loaded on demand), the answer action row, the dashboard's Today overview, the document procedure filter and their copy in seven locales took the whole-build sum from 4080 to 4129 KB gz; most of it is new strings in the seven lazy locale catalogs and the on-demand Coach chunks, which a first paint does not load. The four eager routes stay well inside their drift allowances (/ +1.9, /measurements +1.9, /insights/mood +1.4, /insights +1.0 KB of 20). The aggregate cap moves from 4100 to 4155, about 26 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4182 at v1.42.0 on a measured delta. Main measured 4146 KB gz (lazy catalogs 1177, other chunks 2969); the release branch's shared contract change measured 4158 (lazy catalogs 1188, other chunks 2969): the whole 12 KB is new strings in the seven lazy locale catalogs (the Health Connect import card, air quality and pollen, the managed-profile handover and claim, own vaccines, the lab conversion note, the per-type Apple Health list), and no eager route moved (/ +3.6, /measurements +1.5, /insights/mood +1.1, /insights +0.7 KB of 20). The aggregate cap moves from 4155 to 4182, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Re-based at v1.42.0 when the settings sections and Zod left the shared bundles. The settings section route imported all twenty-one sections statically; each now loads on its own, and /settings/[section] measured 611 KB gz before and 403 after. The account-access parse that runs on every boot was the shell's only Zod import, about 63 KB gz on every route; it is plain TypeScript now, held to the published schema by an equivalence test, and the layout entry fell from 199 to 136 KB gz. The canonical unit table moved out of the measurement validation module, which took Zod off the routes whose only reason for it was the display transform: /insights 369 to 304, /medications 428 to 365, /labs 400 to 337. Across the 627 client routes 62 fell, none grew by more than 2 KB, and the per-route sum fell by 1844 KB gz. The /insights baseline moves down to 304 and /settings/[section] joins the watched routes at 403, so both gains are held by the same 20 KB allowance rather than spent. The aggregate moves the other way, 4167 to 4311, for the reason described at v1.37.27 and v1.40.0: the aggregate counts each emitted chunk once, and modules that used to sit in one shared chunk are now emitted into each route group that still needs them (the measurement validation module and the unit table into about twenty-five each, the settings sections into their own chunks). Bytes on disk rose; bytes a reader downloads fell on every route that moved. The aggregate cap moves from 4182 to 4335, about 24 KB above the build, the same headroom as before. Raised to 4400 at v1.42.0 on a measured delta. The release branch after its tickets merge measured 4365 KB gz (lazy catalogs 1206, other chunks 3159), already 30 KB over the 4335 cap; the day view and timeline contract adds 10 more (catalogs 1215, other 3160), almost all of it the base copy for the day, the timeline and life events in seven locales. The aggregate cap moves from 4335 to 4400, about 25 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4470 at v1.42.0 on a measured delta. The release branch with the day view, the timeline and their server measured 4386 KB gz (lazy catalogs 1228, other chunks 3158); giving every chart drawn in days the same doors to its day (one shared hook for the click, the touch tooltip's button, the open day's line, the row of day dots and the caption, a shared calendar adapter, a data table under each newly linked chart) measured 4446 (catalogs 1228, other chunks 3218). The 60 KB is the aggregate's double count again: the hook, the data table and the day link are emitted into each chart's own chunk group rather than once. The eager routes moved by a kilobyte or a few (/ +1.6, /measurements +1.7, /settings/[section] +0.8, /insights/mood +4.6, /insights +1.5 KB) and stay inside their allowances. The aggregate cap moves from 4400 to 4470, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged. Raised to 4760 at v1.42.0 on a measured delta, the other way round from a feature: the day panel and the date field's calendar left the first load. The day view (its scores, sections and calendar) now loads when a day first opens, not with the shell, and the date field loads react-day-picker and the seven date-fns locales when its popover first opens. Every watched route fell: / 460 to 420, /measurements 439 to 398, /insights/mood 438 to 395, /settings/[section] 432 to 387, /insights 360 to 317 KB gz, all five back under their unchanged ceilings. The aggregate rose from 4426 to 4736 for the reason described at v1.37.27 and v1.40.0: while the day view sat in the shell, the small primitives it shares with the pages (card, table, popover, switch, input, badge, the chart day links) were emitted once into the shared chunk; with the day view on demand, each route group that uses them gets its own copy, and the aggregate counts every copy. Bytes on disk rose; bytes a reader downloads fell on every route. The aggregate cap moves from 4470 to 4760, about 24 KB above the build, the same headroom as before; per-route baselines are unchanged.", "routeBaselineKbGz": { "/page": 429, - "/insights/page": 368, + "/insights/page": 304, "/measurements/page": 411, - "/insights/mood/page": 379 + "/insights/mood/page": 379, + "/settings/[section]/page": 403 }, "routeDriftAllowanceKbGz": 20, - "totalClientKbGz": 4155, + "totalClientKbGz": 4760, "maxRechartsChunks": 1 } diff --git a/docker-compose.yml b/docker-compose.yml index 735b282e1..02a2b3257 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -72,6 +72,11 @@ x-healthlog-env: &healthlog_env # (default 60000; 0 disables it and restores the unbounded behaviour). # Raise it if a very large account's analytics fallback is being cut off. DATABASE_STATEMENT_TIMEOUT_MS: "${DATABASE_STATEMENT_TIMEOUT_MS:-}" + # Postgres work_mem for every app session (default 16MB). + DATABASE_WORK_MEM: "${DATABASE_WORK_MEM:-}" + # 1 sends neither of the two settings above (they ride the libpq `options` + # startup parameter, which PgBouncer refuses by default). + DATABASE_SESSION_OPTIONS_DISABLED: "${DATABASE_SESSION_OPTIONS_DISABLED:-}" WITHINGS_WEBHOOK_SECRET: "${WITHINGS_WEBHOOK_SECRET:-}" # Overrides the derived `${NEXT_PUBLIC_APP_URL}/api/withings/callback`; # set it when the URL registered with Withings differs from the app's own @@ -244,6 +249,11 @@ x-healthlog-env: &healthlog_env # the coordinate lookups off a third party. OPENMETEO_BASE_URL: "${OPENMETEO_BASE_URL:-}" OPENMETEO_GEOCODING_URL: "${OPENMETEO_GEOCODING_URL:-}" + # Air quality, pollen and UV (CAMS via Open-Meteo). Same hosted default and + # self-host option as above; ENVIRONMENT_AIR_QUALITY_DISABLED=1 stops the + # air-quality requests for the whole instance. + OPENMETEO_AIR_QUALITY_URL: "${OPENMETEO_AIR_QUALITY_URL:-}" + ENVIRONMENT_AIR_QUALITY_DISABLED: "${ENVIRONMENT_AIR_QUALITY_DISABLED:-}" # OIDC SSO login (self-hosted IdP: Authentik/Keycloak/Authelia/etc). # All three of ISSUER_URL/CLIENT_ID/CLIENT_SECRET together enable the # "Sign in with SSO" button; set none to leave it disabled. OIDC_ONLY @@ -533,6 +543,17 @@ services: # (concrete pin, active watch) from being dropped. image: postgres:16.15-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 + # view appears after a one-time `CREATE EXTENSION pg_stat_statements;` + # (docs/ops/query-statistics.md). Changing this line restarts the + # database once on the next `compose up`. + command: + - postgres + - -c + - shared_preload_libraries=pg_stat_statements + - -c + - pg_stat_statements.track=top # cap_drop: [ALL] is deliberately NOT applied here (unlike `app`): the # official Postgres entrypoint starts as root, chowns the data # directory, and drops privileges to the `postgres` user itself, which diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0603ce22e..09264477d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.41.2 + version: 1.42.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -1852,6 +1852,98 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + /api/auth/claim/preview: + post: + security: [] + tags: + - Auth + summary: Preview a managed-profile handover link + description: "What a handover link would hand over: the profile's display name, the link's expiry, and each guardian's + proposal. Anonymous; the token travels in the body. An unknown, expired, used or withdrawn token, and a link + whose creator lost access, all answer the same 404. Rate-limited per source address." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ProfileClaimPreviewRequest" + responses: + "200": + description: The preview. + content: + application/json: + schema: + $ref: "#/components/schemas/ProfileClaimPreviewEnvelope" + "401": *a1 + "403": + description: "The instance allows single sign-on only (`meta.errorCode: profile_claim.oidc_only_unsupported`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "404": + description: "No usable link behind this token (`meta.errorCode: profile_claim.invalid`). Every failure class answers + identically." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: "The request carries a signed-in session (`meta.errorCode: auth.already_authenticated`); sign out first." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + /api/auth/claim: + post: + security: [] + tags: + - Auth + summary: Claim a managed profile + description: "Turns the managed profile behind a handover link into the claimant's own account, in one transaction: sets + the username, email and password on the profile's record, clears the managed marker, withdraws pending + invitations, applies each guardian's proposed access, and resets the disclaimer, onboarding and AI consent so + the new owner gives them personally. The new owner then decides each guardian's access on first sign-in + (`/api/account/handover-decision`). No data moves. Signs the new owner in. Anonymous; the token travels in the + body, every token failure answers the same 404, and five attempts per fifteen minutes per source address are + allowed." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ProfileClaimRequest" + responses: + "201": + description: Claimed; the response sets the session cookie. + content: + application/json: + schema: + $ref: "#/components/schemas/ProfileClaimEnvelope" + "401": *a1 + "403": + description: "The instance allows single sign-on only (`meta.errorCode: profile_claim.oidc_only_unsupported`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "404": + description: "No usable link behind this token (`meta.errorCode: profile_claim.invalid`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: "The request carries a signed-in session (`meta.errorCode: auth.already_authenticated`), or the username or + email is taken (`meta.errorCode: profile_claim.taken`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/auth/login: post: security: [] @@ -2552,7 +2644,15 @@ paths: application/json: schema: $ref: "#/components/schemas/PasskeyLoginVerifyResponse" - "401": *a1 + "401": + description: The sign-in did not prove anything (v1.42.0 codes; before, the first case answered 500). `meta.errorCode` = + `passkey.challenge.expired` when the challenge is gone — it expired after five minutes, was already used, or + was begun for a different account — so start a new sign-in; `passkey.verification.failed` when the assertion + was checked and refused (signature, origin, relying-party id or challenge mismatch). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "403": description: Passkey login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`. content: @@ -2560,12 +2660,20 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "404": - description: The assertion resolved to a user row that no longer exists. + description: "`meta.errorCode` = `passkey.unknown` (v1.42.0): no passkey with this credential id is registered on this + server — it was removed here, or belongs to another server. Tell the person to sign in another way. Without + a code: the assertion resolved to a user row that no longer exists." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: "`challengeId` or `credential` is missing, or (`meta.errorCode` = `passkey.response.invalid`, v1.42.0) + `credential` is not a WebAuthn authentication response." content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a4 "429": *a5 /api/auth/refresh: post: @@ -4144,6 +4252,7 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT - in: query name: valueMin schema: @@ -4398,7 +4507,12 @@ paths: (`measurement.batch.invalid`, with every issue under `details.issues`), or (`measurement.batch.source_not_permitted`) carried an entry naming a source, any source, under a narrow `measurements:write` credential. Nothing was written in any of the three cases, and all three are permanent - for the batch as sent: resending it unchanged is refused the same way." + for the batch as sent: resending it unchanged is refused the same way. Which issues belong to an entry: + every issue caused by one entry has a `path` that starts `entries.` (the entry's index in the request, + then the field, e.g. `entries.3.startDate`, or just `entries.4` when the entry is not an object). Those are + the only per-entry issues, so a client may drop the named entries and resend the rest. An issue whose `path` + is `\"\"` (the body is not an object), `entries` (missing, not an array, or empty) or `syncTrigger` is about + the batch itself, and no split recovers it." content: application/json: schema: @@ -5032,11 +5146,14 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": - description: Body validation failed (multi-issue envelope, breadcrumbed as + description: "Body validation failed (multi-issue envelope, breadcrumbed as `medications.intake.update.validation-failed`), or `meta.errorCode` = `medications.intake.injection_site.disallowed` when the site is outside the medication's effective allowed set, or `medications.intake.force_slot.invalid` when `forceSlotInstant` is not a real slot of the - medication. The latter two carry no issue list — branch on the code. + medication, or (v1.42.0) `medication.intake.notTracked` when the event is still open and its medication is + kept as a record only (`trackIntake: false`): resolving or snoozing it would record a new dose that is + counted nowhere. Correcting an event that is already taken or skipped stays open. The coded refusals carry + no issue list — branch on the code." content: application/json: schema: @@ -6298,7 +6415,16 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 - "422": *a4 + "422": + description: "Body validation failed (multi-issue envelope), or `meta.errorCode` names the refusal: + `medications.intake.injection_site.disallowed`, `medications.intake.force_slot.invalid`, or (v1.42.0) + `medication.intake.notTracked` when the medication is kept as a record only (`trackIntake: false`). Its + stored history stays; a new dose is refused rather than stored and counted nowhere. The check runs after + ownership, so a foreign medication is still a 404." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "429": *a12 get: tags: @@ -6622,13 +6748,36 @@ paths: tags: - Medications summary: Batched adherence read for every medication of the caller - description: Returns one compact row per non-PRN medication. A scheduled medication with no local schedule remains in - the array with applicable=false and reason NO_LOCAL_SCHEDULE. compliance7/compliance30 stay non-null as all-zero + description: >- + Returns one compact row per non-PRN medication. A scheduled medication with no local schedule remains in the + array with applicable=false and reason NO_LOCAL_SCHEDULE. compliance7/compliance30 stay non-null as all-zero compatibility placeholders for released clients; aware clients must ignore those percentages when applicable=false. complianceDisplay is null. + + + v1.42.0, additive: `meta.aggregate` carries one figure for the whole account (`MedicationComplianceAggregate`, + weighted by expected doses across the applicable medications; `null` when none applies), so a client never + averages the rows itself. `days` (30, 60, 90, 180 or 365) adds a `complianceN` block in the same shape to every + row and to the aggregate, for a report that covers its own window; `meta.days` echoes it. Any other `days` is a + 422 rather than a rounded window. + parameters: + - in: query + name: days + schema: + description: Report window in days. 30 is already `compliance30`; the others add `compliance60` / `compliance90` / + `compliance180` / `compliance365`. + type: string + enum: + - "30" + - "60" + - "90" + - "180" + - "365" + description: Report window in days. 30 is already `compliance30`; the others add `compliance60` / `compliance90` / + `compliance180` / `compliance365`. responses: "200": - description: One adherence row per medication. + description: One adherence row per medication, and the account aggregate in `meta`. content: application/json: schema: @@ -7578,7 +7727,7 @@ paths: type: object properties: steps: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -7598,8 +7747,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT activeEnergy: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -7619,8 +7769,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT walkingRunningDistance: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -7640,8 +7791,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT flightsClimbed: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -7661,8 +7813,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT sleep: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -7682,8 +7835,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT weight: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -7703,8 +7857,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT bloodPressure: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -7724,8 +7879,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT pulse: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -7745,8 +7901,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT bodyFat: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -7766,8 +7923,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT bodyTemperature: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -7787,8 +7945,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT spo2: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -7808,8 +7967,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT hrv: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -7829,8 +7989,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT restingHeartRate: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -7850,8 +8011,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT vo2Max: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -7871,8 +8033,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT skinTemperature: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -7892,8 +8055,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT respiratoryRate: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -7913,8 +8077,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT recovery: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -7934,8 +8099,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT stress: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -7955,8 +8121,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT sleepDebt: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -7976,11 +8143,34 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT + hrvRmssd: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT metricPriority: type: object properties: steps: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -8000,8 +8190,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT activeEnergy: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -8021,8 +8212,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT walkingRunningDistance: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -8042,8 +8234,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT flightsClimbed: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -8063,8 +8256,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT sleep: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -8084,8 +8278,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT weight: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -8105,8 +8300,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT bloodPressure: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -8126,8 +8322,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT pulse: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -8147,8 +8344,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT bodyFat: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -8168,8 +8366,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT bodyTemperature: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -8189,8 +8388,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT spo2: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -8210,8 +8410,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT hrv: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -8231,8 +8432,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT restingHeartRate: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -8252,8 +8454,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT vo2Max: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -8273,8 +8476,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT skinTemperature: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -8294,8 +8498,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT respiratoryRate: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -8315,8 +8520,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT recovery: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -8336,8 +8542,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT stress: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -8357,8 +8564,9 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT sleepDebt: - maxItems: 8 + maxItems: 16 type: array items: type: string @@ -8378,6 +8586,29 @@ paths: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT + hrvRmssd: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT deviceTypePriority: type: object properties: @@ -8592,9 +8823,10 @@ paths: summary: Update per-user notification preferences description: 'Deep-merges the supplied partial shape over the persisted row — a PATCH touching only one category leaves the siblings intact. Always returns the fully-resolved next state so clients can hard-set their optimistic - update. `medication.clientManaged: true` (or `deliveryDefault: "client"`) suppresses server-side - MEDICATION_REMINDER APNs; `medication.lowStockRunwayDays` (1–60, nullable, default 7) tunes the low-stock alert - — null switches it off. Rate-limit 60/min per user.' + update. `medication.clientManaged: true` (or `deliveryDefault: "client"`) keeps the MEDICATION_REMINDER that + announces a dose off APNs and leaves the follow-ups for a dose still open on it (v1.42.0); + `medication.lowStockRunwayDays` (1–60, nullable, default 7) tunes the low-stock alert — null switches it off. + Rate-limit 60/min per user.' requestBody: required: true content: @@ -10867,6 +11099,60 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + /api/admin/maintenance/measurements: + get: + tags: + - Admin + summary: Where measurement table maintenance stands + description: "The newest maintenance run and its state, whether the compaction-tombstone purge is still at work, and the + table's size right now. The admin card polls it while a run is queued or running. Cookie auth only: + `requireAdmin()` refuses every Bearer caller." + responses: + "200": + description: The status. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminMeasurementMaintenanceStatusResponse" + "401": *a1 + "422": *a4 + "429": *a5 + post: + tags: + - Admin + summary: Queue measurement table maintenance + description: "Queues one `VACUUM (ANALYZE)` and `REINDEX INDEX CONCURRENTLY` pass over the `measurements` table, for + after a large purge. Runs in the background worker, one statement at a time; pick a quiet window, because a + rebuild needs free disk the size of the index it rebuilds. At most one run at a time. Cookie auth only: + `requireAdmin()` refuses every Bearer caller. Runbook: `docs/ops/measurement-maintenance.md`." + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/AdminMeasurementMaintenanceRequest" + responses: + "202": + description: Queued, or already queued. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminMeasurementMaintenanceResponse" + "401": *a1 + "403": + description: Caller is not an admin. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + "503": + description: No background worker is bound, so nothing would run it. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/admin/notifications/reminder-check: post: tags: @@ -11338,30 +11624,6 @@ paths: "403": *a11 "422": *a4 "429": *a5 - /api/insights/coach/seeded-question: - get: - tags: - - Insights - summary: Today's suggested Coach opener - description: "Resolves today's single most notable derived wellness signal into a tappable opener for the Coach's - blank-chat hero, using the same confidence-gated detector the daily briefing uses. The selection happens - SERVER-side and the client renders it rather than recomputing. `signal: null` is the ordinary answer and means - the hero keeps its neutral greeting — never a fabricated opener. Two different causes produce that null and are - indistinguishable on the wire: nothing crossed the notability gate, or the account has turned proactive - suggestions off, in which case the detector does not run at all. The opener exists only to open the Coach, so it - is served only while the `coach` AI capability is available; otherwise the answer is the same neutral `signal: - null` and `ai` says why. Never refused for an AI reason. Shared analytics-read budget. Cookie or Bearer auth; - not delegable." - responses: - "200": - description: The opener, or the neutral null. - content: - application/json: - schema: - $ref: "#/components/schemas/CoachSeededQuestionEnvelope" - "401": *a1 - "422": *a4 - "429": *a5 /api/insights/narrative: get: tags: @@ -12084,6 +12346,56 @@ paths: "403": *a11 "422": *a4 "429": *a5 + /api/insights/score-history: + get: + tags: + - Insights + summary: A score's daily history + description: "v1.42 — the daily course of the health score, readiness or the sleep score over a trailing window, one + point per local day, for the history chart on the score's page. Each point is the value the day view shows for + that day: the stored health-score day, the nightly readiness blend filed on its wake day, the sleep score of the + night that ended that morning. The health score's points carry `seamBreak` where the recipe changed, and the + chart draws no line across it. `band` is the usual range behind the newest point. Readiness is gated on the + `recovery` module and the sleep score on `sleep` (403 `module.disabled`); the health score is core. Pure reads, + no AI. Delegable at MANAGE level over the whole record. Additive. Cookie or Bearer auth." + parameters: + - in: query + name: score + schema: + type: string + enum: + - HEALTH_SCORE + - READINESS + - SLEEP_SCORE + description: The score to chart. `HEALTH_SCORE` reads the stored day scores, `READINESS` the nightly readiness blend, + `SLEEP_SCORE` the per-night sleep score. The nightly recovery, stress and strain scores are stored + measurements and chart through the measurement series instead. + required: true + description: The score to chart. `HEALTH_SCORE` reads the stored day scores, `READINESS` the nightly readiness blend, + `SLEEP_SCORE` the per-night sleep score. The nightly recovery, stress and strain scores are stored + measurements and chart through the measurement series instead. + - in: query + name: days + schema: + type: integer + minimum: 1 + maximum: 3650 + description: Trailing window in local calendar days ending today (1–3650). The web chart sends 7, 30, 90, or 3650 for + its "All" tab. Out of range is a 422, never a silent clamp. + required: true + description: Trailing window in local calendar days ending today (1–3650). The web chart sends 7, 30, 90, or 3650 for + its "All" tab. Out of range is a 422, never a silent clamp. + responses: + "200": + description: The score's daily points and usual range. + content: + application/json: + schema: + $ref: "#/components/schemas/ScoreHistoryResponseEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 /api/insights/glp1-plateau: get: tags: @@ -12401,7 +12713,20 @@ paths: window the prior aggregate is served immediately while one background recompute warms a new one, so an active logger never re-pays the cold compute. Module-gated on `mood`; a disabled module answers 403 `module.disabled` even for a valid Bearer token. Cookie or Bearer auth; the caller is always resolved as themselves, so this read - cannot be delegated to a shared record — which makes it the one mood surface a delegate cannot reach." + cannot be delegated to a shared record — which makes it the one mood surface a delegate cannot reach. v1.42.0 — + `days` cuts `stability` to the period a client shows; every other block is unchanged by it." + parameters: + - in: query + name: days + schema: + description: "Trailing window for `stability`, in days. Omitted: 365. Any other value is a 422." + type: string + enum: + - "30" + - "90" + - "180" + - "365" + description: "Trailing window for `stability`, in days. Omitted: 365. Any other value is a 422." responses: "200": description: The aggregate bundle. @@ -13880,6 +14205,86 @@ paths: "401": *a1 "422": *a4 "429": *a5 + /api/import/health-connect-export: + post: + tags: + - Import + summary: Upload a Health Connect export for background import + description: "Takes the ZIP the Android Health Connect app writes (it holds `health_connect_export.db`) and hands it to + a background worker. The response is **202 with a `jobId`**; poll `GET + /api/import/health-connect-export/status`. Body is `multipart/form-data` with the file under `file`, streamed to + disk after authentication like the Apple Health upload. Re-uploading the same bytes resolves to the still-viable + job and returns `idempotent: true`. Imported rows carry `source = HEALTH_CONNECT`. Cookie or wildcard Bearer." + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + description: The Health Connect export ZIP, under the field name `file`. + required: + - file + responses: + "202": + description: Accepted and queued, or matched to a job already carrying these bytes. + content: + application/json: + schema: + $ref: "#/components/schemas/HealthConnectImportKickoffEnvelope" + "400": + description: The request carried no body at all. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "401": *a1 + "409": + description: An import for this account (Health Connect or Apple Health) is already queued or running. The upload was + discarded. `errorCode` is `import.apple_health.busy` for either kind, `jobId` names the running job. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "413": + description: The declared `Content-Length` exceeds the 1 GiB upload cap. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: "The multipart body could not be read: no `file` part, a malformed body, or more bytes than the cap. + Whether the ZIP is a readable Health Connect export is decided by the worker and reported on the job + (`status: failed`, `failureReason`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": *a5 + "503": + description: No background worker is bound, or the queue did not take the job, so nothing would run the import. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/import/health-connect-export/status: + get: + tags: + - Import + summary: The caller's latest Health Connect import + description: The most recent Health Connect import job of the caller, or `null` when there has never been one. + responses: + "200": + description: The latest job, or null. + content: + application/json: + schema: + $ref: "#/components/schemas/HealthConnectImportStatusEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 /api/import/csv: post: tags: @@ -14675,7 +15080,7 @@ paths: schema: $ref: "#/components/schemas/OcrTextExtractRequest" responses: - "202": &a36 + "202": &a38 description: Queued for the background worker. Every refusal the route can answer quickly (capability, provider, rate limit, budget, validation) has already been answered; anything that goes wrong from here is reported on the run. @@ -15432,6 +15837,118 @@ paths: "404": *a31 "422": *a4 "429": *a5 + /api/vaccinations/custom: + get: + tags: + - Records + summary: List the record's own vaccines + description: The vaccine definitions the record's owner added for products the shipped catalogue does not list, by name. + Removed definitions are excluded. Readable under a `profile` grant like the dose list. + responses: + "200": + description: The definitions, by name. + content: + application/json: + schema: + $ref: "#/components/schemas/ListCustomVaccinesEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 + post: + tags: + - Records + summary: Add an own vaccine + description: Defines a vaccine in the same shape a catalogue entry resolves to, so the series and booster logic treat + both alike. Names are unique per record, compared without case; adding a name the person removed earlier brings + that definition back with the new values (its doses were let go on removal and stay so). Audits as + `vaccination.custom.create`. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateCustomVaccineRequest" + responses: + "201": + description: Created. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateCustomVaccineEnvelope" + "401": *a1 + "403": *a11 + "409": &a32 + description: A live definition on the record already carries this name, compared without case + (`vaccination.custom.name-taken`). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a12 + /api/vaccinations/custom/{id}: + patch: + tags: + - Records + summary: Edit an own vaccine + description: Changes one definition; every dose logged against it reads the change. Owner-scoped; an unknown or removed + id 404s. Audits as `vaccination.custom.update`, naming a rename without quoting it. + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateCustomVaccineRequest" + responses: + "200": + description: Updated. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateCustomVaccineEnvelope" + "401": *a1 + "403": *a11 + "404": &a33 + description: No live definition with this id on the record (`vaccination.custom.not-found`). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": *a32 + "422": *a4 + "429": *a5 + delete: + tags: + - Records + summary: Remove an own vaccine + description: "Soft-deletes one definition and lets go of every dose that named it. A dose keeps a name: one without + wording of its own takes the definition's name as its `vaccineName` first. Removing twice succeeds. Audits as + `vaccination.custom.delete` with the number of doses let go." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Removed. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteCustomVaccineEnvelope" + "401": *a1 + "403": *a11 + "404": *a33 + "422": *a4 + "429": *a5 /api/vaccinations: get: tags: @@ -15500,6 +16017,13 @@ paths: $ref: "#/components/schemas/CreateVaccinationEnvelope" "401": *a1 "403": *a11 + "404": + description: A practice, visit or own vaccine the body names is not the record's (`vaccination.practitioner-not-found`, + `vaccination.encounter-not-found`, `vaccination.custom-vaccine-not-found`). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 "422": *a4 "429": *a12 @@ -15524,7 +16048,7 @@ paths: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 "403": *a11 - "404": &a32 + "404": &a34 description: Vaccination not found (or owned by another user). content: application/json: @@ -15559,7 +16083,7 @@ paths: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 "403": *a11 - "404": *a32 + "404": *a34 "422": *a4 "429": *a5 delete: @@ -15584,7 +16108,7 @@ paths: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 "403": *a11 - "404": *a32 + "404": *a34 "422": *a4 "429": *a5 /api/vaccinations/{id}/restore: @@ -15609,7 +16133,7 @@ paths: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 "403": *a11 - "404": *a32 + "404": *a34 "422": *a4 "429": *a5 /api/vaccinations/{id}/booster: @@ -15675,7 +16199,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": *a32 + "404": *a34 "422": *a4 "429": *a5 /api/vaccinations/suggest: @@ -15733,7 +16257,7 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a32 + "404": *a34 "422": *a4 "429": *a5 delete: @@ -15762,7 +16286,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a32 + "404": *a34 "422": *a4 "429": *a5 /api/illness/episodes: @@ -15846,7 +16370,7 @@ paths: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": &a33 + "404": &a35 description: Illness episode not found (or owned by another user). content: application/json: @@ -15881,7 +16405,7 @@ paths: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": *a33 + "404": *a35 "422": *a4 "429": *a5 delete: @@ -15905,7 +16429,7 @@ paths: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": *a33 + "404": *a35 "422": *a4 "429": *a5 /api/illness/episodes/{id}/restore: @@ -15933,7 +16457,7 @@ paths: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": *a33 + "404": *a35 "409": *a7 "422": *a4 "429": *a5 @@ -15966,7 +16490,7 @@ paths: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 "403": *a11 - "404": *a33 + "404": *a35 "422": *a4 "429": *a5 /api/illness/episodes/{id}/day-logs: @@ -16017,7 +16541,7 @@ paths: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 "403": *a11 - "404": *a33 + "404": *a35 "422": *a4 "429": *a5 post: @@ -16054,7 +16578,7 @@ paths: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 "403": *a11 - "404": *a33 + "404": *a35 "422": *a4 "429": *a5 /api/illness/episodes/{id}/correlation: @@ -16081,7 +16605,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a33 + "404": *a35 "422": *a4 "429": *a5 /api/illness/insights: @@ -16297,7 +16821,7 @@ paths: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 "403": *a11 - "404": &a34 + "404": &a36 description: Allergy not found (or owned by another user). content: application/json: @@ -16331,7 +16855,7 @@ paths: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 "403": *a11 - "404": *a34 + "404": *a36 "422": *a4 "429": *a5 delete: @@ -16355,7 +16879,7 @@ paths: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 "403": *a11 - "404": *a34 + "404": *a36 "422": *a4 "429": *a5 /api/family-history: @@ -16429,7 +16953,7 @@ paths: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 "403": *a11 - "404": &a35 + "404": &a37 description: Family history entry not found (or owned by another user). content: application/json: @@ -16463,7 +16987,7 @@ paths: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 "403": *a11 - "404": *a35 + "404": *a37 "422": *a4 "429": *a5 delete: @@ -16487,7 +17011,7 @@ paths: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 "403": *a11 - "404": *a35 + "404": *a37 "422": *a4 "429": *a5 /api/documents/inbound: @@ -16888,6 +17412,46 @@ paths: "403": *a11 "422": *a4 "429": *a5 + /api/documents/inbound/layout: + get: + tags: + - Documents + summary: Get the vault presentation + description: The resolved card/list view and month arrangement for the vault, with defaults filled in when nothing has + been chosen. Inside a shared record this answers the owner's choice. + responses: + "200": + description: The resolved presentation. + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentsLayoutEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 + put: + tags: + - Documents + summary: Change the vault presentation + description: "Preserve-when-absent: a body carrying only `view` keeps the stored arrangement and vice versa. Returns the + full resolved presentation. Refused while acting inside somebody else's record." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentsLayoutPutBody" + responses: + "200": + description: The presentation as stored. + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentsLayoutSavedEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 /api/documents/inbound/bulk: post: tags: @@ -17112,7 +17676,7 @@ paths: required: true schema: type: string - - &a37 + - &a39 name: Prefer in: header required: false @@ -17137,9 +17701,9 @@ paths: application/json: schema: $ref: "#/components/schemas/InboundExtractEnvelope" - "202": *a36 + "202": *a38 "401": *a1 - "403": &a38 + "403": &a40 description: "AI refusal: the `documentAi` capability is unavailable for this record. The envelope carries `meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). `assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master @@ -17163,7 +17727,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a39 + "503": &a41 description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. Nothing was sent. Retry later." content: @@ -17193,7 +17757,7 @@ paths: required: true schema: type: string - - *a37 + - *a39 requestBody: required: false content: @@ -17229,12 +17793,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentSuggestEnvelope" - "202": *a36 + "202": *a38 "401": *a1 - "403": *a38 + "403": *a40 "422": *a4 "429": *a5 - "503": *a39 + "503": *a41 /api/documents/inbound/{id}/summary: post: tags: @@ -17284,7 +17848,7 @@ paths: type: boolean default: false description: With `persist=true`, explicitly replace an existing stored summary. - - *a37 + - *a39 requestBody: required: false content: @@ -17320,12 +17884,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentSummaryEnvelope" - "202": *a36 + "202": *a38 "401": *a1 - "403": *a38 + "403": *a40 "422": *a4 "429": *a5 - "503": *a39 + "503": *a41 /api/documents/inbound/{id}/index: post: tags: @@ -17351,7 +17915,7 @@ paths: required: true schema: type: string - - *a37 + - *a39 requestBody: required: false content: @@ -17387,12 +17951,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentIndexEnvelope" - "202": *a36 + "202": *a38 "401": *a1 - "403": *a38 + "403": *a40 "422": *a4 "429": *a5 - "503": *a39 + "503": *a41 /api/documents/inbound/{id}/chat: post: tags: @@ -17432,10 +17996,10 @@ paths: type: string description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." "401": *a1 - "403": *a38 + "403": *a40 "422": *a4 "429": *a5 - "503": *a39 + "503": *a41 get: tags: - Documents @@ -17498,10 +18062,10 @@ paths: schema: $ref: "#/components/schemas/DocumentReindexEnvelope" "401": *a1 - "403": *a38 + "403": *a40 "422": *a4 "429": *a5 - "503": *a39 + "503": *a41 /api/documents/inbound/{id}/facts/{factId}: patch: tags: @@ -17608,7 +18172,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a41 + "409": &a43 description: A custom metric with this name already exists for the caller. content: application/json: @@ -17637,7 +18201,7 @@ paths: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 "403": *a11 - "404": &a40 + "404": &a42 description: Custom metric not found (or owned by another user). content: application/json: @@ -17671,8 +18235,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a40 - "409": *a41 + "404": *a42 + "409": *a43 "422": *a4 "429": *a5 delete: @@ -17695,7 +18259,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a40 + "404": *a42 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -17739,7 +18303,7 @@ paths: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 "403": *a11 - "404": *a40 + "404": *a42 "422": *a4 "429": *a5 post: @@ -17768,7 +18332,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a40 + "404": *a42 "409": *a7 "422": *a4 "429": *a12 @@ -17804,7 +18368,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a42 + "404": &a44 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -17837,7 +18401,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a42 + "404": *a44 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -18249,7 +18813,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a43 + "403": &a45 description: >- The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is @@ -18304,7 +18868,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a43 + "403": *a45 "422": *a4 "429": *a5 /api/nutrients/water: @@ -18333,7 +18897,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a43 + "403": *a45 "409": *a7 "422": *a4 "429": *a5 @@ -18835,26 +19399,26 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a44 + "400": &a46 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "413": &a45 + "413": &a47 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a46 + "415": &a48 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a47 + "422": &a49 description: "`clientId` and `clientSecret` are both required and each must be 1–200 characters. The envelope carries a single fixed message, not per-field issues." content: @@ -19040,11 +19604,11 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a44 + "400": *a46 "401": *a1 - "413": *a45 - "415": *a46 - "422": *a47 + "413": *a47 + "415": *a48 + "422": *a49 "429": *a5 delete: tags: @@ -19187,11 +19751,11 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a44 + "400": *a46 "401": *a1 - "413": *a45 - "415": *a46 - "422": *a47 + "413": *a47 + "415": *a48 + "422": *a49 "429": *a5 delete: tags: @@ -19344,11 +19908,11 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a44 + "400": *a46 "401": *a1 - "413": *a45 - "415": *a46 - "422": *a47 + "413": *a47 + "415": *a48 + "422": *a49 "429": *a5 delete: tags: @@ -19424,21 +19988,21 @@ paths: schema: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 - "422": &a48 + "422": &a50 description: No usable connection for this provider — nothing stored, or the stored grant could no longer be resolved into an access token. `meta.errorCode` = `not_configured`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a49 + "429": &a51 description: More than 5 requests in 60 s from this user. `meta.errorCode` = `rate_limited_self` — this is the local limiter, distinct from a `rate_limited` 502 which is the provider's. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": &a50 + "502": &a52 description: "The probe did not complete. `meta.errorCode` names the class: `credentials_rejected` (the provider answered 401/403 — the stored grant is gone), `rate_limited` (the PROVIDER answered 429, which is not this endpoint's own limiter), `upstream_error` (5xx), `timeout` (no answer within 5 s), `connection_failed` @@ -19483,11 +20047,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a44 + "400": *a46 "401": *a1 - "413": *a45 - "415": *a46 - "422": *a47 + "413": *a47 + "415": *a48 + "422": *a49 "429": *a5 delete: tags: @@ -19563,9 +20127,9 @@ paths: schema: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 - "422": *a48 - "429": *a49 - "502": *a50 + "422": *a50 + "429": *a51 + "502": *a52 /api/strava/credentials: get: tags: @@ -19601,11 +20165,11 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a44 + "400": *a46 "401": *a1 - "413": *a45 - "415": *a46 - "422": *a47 + "413": *a47 + "415": *a48 + "422": *a49 "429": *a5 delete: tags: @@ -19683,9 +20247,9 @@ paths: schema: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 - "422": *a48 - "429": *a49 - "502": *a50 + "422": *a50 + "429": *a51 + "502": *a52 /api/integrations/status: get: tags: @@ -19721,7 +20285,7 @@ paths: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 "422": *a4 - "429": *a49 + "429": *a51 /api/integrations/fitbit/test: post: tags: @@ -19739,9 +20303,9 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 - "422": *a48 - "429": *a49 - "502": *a50 + "422": *a50 + "429": *a51 + "502": *a52 /api/integrations/google-health/resume: post: tags: @@ -19758,7 +20322,7 @@ paths: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 "422": *a4 - "429": *a49 + "429": *a51 /api/integrations/google-health/test: post: tags: @@ -19791,9 +20355,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a48 - "429": *a49 - "502": *a50 + "422": *a50 + "429": *a51 + "502": *a52 /api/integrations/whoop/resume: post: tags: @@ -19810,7 +20374,7 @@ paths: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 "422": *a4 - "429": *a49 + "429": *a51 /api/integrations/whoop/test: post: tags: @@ -19827,9 +20391,9 @@ paths: schema: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 - "422": *a48 - "429": *a49 - "502": *a50 + "422": *a50 + "429": *a51 + "502": *a52 /api/integrations/withings/resume: post: tags: @@ -19847,7 +20411,7 @@ paths: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 "422": *a4 - "429": *a49 + "429": *a51 /api/integrations/withings/test: post: tags: @@ -19865,8 +20429,8 @@ paths: schema: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 - "422": *a48 - "429": *a49 + "422": *a50 + "429": *a51 "502": description: "The probe did not complete. The shared codes apply (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed`) and this path adds one of its own: @@ -20061,6 +20625,168 @@ paths: "401": *a1 "422": *a4 "429": *a5 + /api/managed-profiles/{id}/handover: + get: + tags: + - Account sharing + summary: Whether a handover link is open + description: "The profile's open handover link, if any, and whether this instance allows creating one. Never returns the + link: only its hash is stored. Cookie-only, no step-up." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The status. + content: + application/json: + schema: + $ref: "#/components/schemas/ManagedProfileHandoverStatusEnvelope" + "401": *a1 + "404": + description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: + managed_profile.not_found`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + post: + tags: + - Account sharing + summary: Create a handover link for a managed profile + description: "Mints the one-time link that lets the person a managed profile describes take it over as their own + account. No data moves: claiming sets credentials on the profile's own record. Claiming applies each guardian's + proposal at once; the new owner then confirms or changes it on first sign-in, and until they do the proposal + holds. Creating a new link withdraws an open one. Pending invitations on the profile are withdrawn when it is + claimed. Cookie-only and step-up gated, like every act on a managed profile; ten an hour per caller. Refused on + an instance that allows single sign-on only." + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateManagedProfileHandoverRequest" + responses: + "201": + description: The link, shown once. + content: + application/json: + schema: + $ref: "#/components/schemas/ManagedProfileHandoverEnvelope" + "401": *a1 + "403": + description: "The instance allows single sign-on only (`meta.errorCode: profile_claim.oidc_only_unsupported`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "404": + description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: + managed_profile.not_found`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: "The body is invalid, or a proposal names a grant that is not an active guardian of this profile + (`meta.errorCode: managed_profile.handover.unknown_guardian`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": *a5 + delete: + tags: + - Account sharing + summary: Withdraw the open handover link + description: Withdraws the profile's open handover link, if there is one; the link then answers like any unknown one. + Cookie-only; no step-up, because withdrawing only takes something away. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Withdrawn, or there was no open link. + content: + application/json: + schema: + $ref: "#/components/schemas/RevokeManagedProfileHandoverEnvelope" + "401": *a1 + "404": + description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: + managed_profile.not_found`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + /api/account/handover-decision: + get: + tags: + - Account sharing + summary: The handover decision waiting for a new owner + description: "After claiming a managed profile, the new owner decides each former guardian's access. This answers that + decision, or `pending: null` when none is waiting. Refused while acting on another account." + responses: + "200": + description: The waiting decision, or null. + content: + application/json: + schema: + $ref: "#/components/schemas/PendingHandoverDecisionEnvelope" + "401": *a1 + "422": *a4 + "429": *a5 + post: + tags: + - Account sharing + summary: Decide each former guardian's access + description: Records the new owner's final decision per former guardian. A changed level ends the guardian's grant and + writes a new one; `manage` can restore access a guardian held before the handover. After this, access changes + through the ordinary grant endpoints. Refused while acting on another account. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/HandoverDecisionRequest" + responses: + "200": + description: Decided. + content: + application/json: + schema: + $ref: "#/components/schemas/HandoverDecisionEnvelope" + "401": *a1 + "409": + description: "No decision is waiting (`meta.errorCode: managed_profile.handover.no_pending`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: "The body is invalid, or a decision names somebody who was not a guardian at the claim (`meta.errorCode: + managed_profile.handover.unknown_guardian`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": *a5 /api/auth/me: get: tags: @@ -20106,7 +20832,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a51 + "403": &a53 description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is byte-identical for an account that does not exist and one that granted @@ -20131,7 +20857,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a51 + "403": *a53 "422": *a4 "429": *a5 post: @@ -20233,7 +20959,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a51 + "403": *a53 "404": description: No such invitation for this account. content: @@ -20277,7 +21003,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a51 + "403": *a53 "404": description: No such grant on this account's record. content: @@ -20318,7 +21044,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a51 + "403": *a53 "404": description: No such grant held by this account. content: @@ -20700,7 +21426,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a51 + "403": *a53 "409": description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records — @@ -21030,7 +21756,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a52 + "403": &a54 description: The operator has disabled the API instance-wide. Every route in this module refuses while that setting is off, for a cookie session as well as for a Bearer token; it is not a per-user permission and retrying will not help. @@ -21063,7 +21789,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a52 + "403": *a54 "404": description: No live connection with this id for this caller — also the answer for an already-revoked one, so a repeat revoke is 404 rather than an idempotent 200. @@ -21090,7 +21816,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a52 + "403": *a54 "422": *a4 "429": *a5 post: @@ -21123,7 +21849,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a52 + "403": *a54 "413": description: Body exceeds 16 KiB. content: @@ -21166,7 +21892,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a52 + "403": *a54 "404": description: No `health:read` token with this id for this caller. An already-revoked token is still found, so a repeat revoke answers 200. @@ -21224,7 +21950,7 @@ paths: description: Returns the named family's resolved settings for the record the session is acting on. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - &a53 + - &a55 name: family in: path required: true @@ -21268,7 +21994,7 @@ paths: ride the audit row. Body capped at 64 KiB. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - *a53 + - *a55 requestBody: required: true content: @@ -21405,6 +22131,32 @@ paths: "403": *a13 "422": *a4 "429": *a5 + /api/environment/preferences: + patch: + tags: + - Environment + summary: Change the environment switches + description: "Turns the air-quality part of the module on or off for this account (on by default). Off means the nightly + fetch skips the air-quality feed, and the overview, the Coach, MCP and the correlations leave the air-quality + values out; days already stored keep theirs. Turning it on queues a refresh, and the nightly gap fill catches up + on older days. Module-gated. Strict body: an unknown key is a 422." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EnvironmentPreferencesRequest" + responses: + "200": + description: The stored switches. + content: + application/json: + schema: + $ref: "#/components/schemas/EnvironmentPreferencesEnvelope" + "401": *a1 + "403": *a19 + "422": *a4 + "429": *a5 /api/environment: get: tags: @@ -21496,6 +22248,12 @@ paths: The span is capped at 730 days, and the worker re-checks the cap independently. Body cap 4 KiB. Module-gated, and it draws on the shared analytics-read rate bucket. + + + Every backfill draws on the instance-wide Open-Meteo budget, so one account is held to three a rolling hour (429 + `environment.backfill_rate_limited`), at most one queued backfill per twenty-minute slot (409 + `environment.backfill_pending`), and, inside the worker, its own share of the daily request budget; days the + share does not reach are filled by the nightly run. requestBody: required: true content: @@ -21513,8 +22271,9 @@ paths: "401": *a1 "403": *a19 "409": - description: No home location is set, so there is nothing to resolve the days against. `meta.errorCode` = - `environment.no_home`. + description: No home location is set, so there is nothing to resolve the days against (`meta.errorCode` = + `environment.no_home`), or a backfill for this account is already queued in the current twenty-minute slot + (`environment.backfill_pending`). content: application/json: schema: @@ -21766,7 +22525,10 @@ paths: "422": description: "Validation failed, with every issue listed rather than the first — an integration operator should see the whole shape at once. An empty or unstable `idempotencyKey` fails here: the column is unique table-wide, so - an empty key would resolve to whichever unrelated row stored it first." + an empty key would resolve to whichever unrelated row stored it first. Also 422 with `meta.errorCode` = + `medication.intake.notTracked` (v1.42.0) when the named medication is kept as a record only (`trackIntake: + false`); checked after the token's scope, so a token without this medication's scope learns nothing about + it." content: application/json: schema: @@ -21805,13 +22567,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a54 + "403": &a56 description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": &a55 + "404": &a57 description: No stored copy with this id. content: application/json: @@ -21885,8 +22647,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a54 - "404": *a55 + "403": *a56 + "404": *a57 "409": description: A restore of the same account is already queued or running (`meta.errorCode` = `backup.restore.active`, `meta.jobId` names it), the stored copy was replaced by a newer one while it was being checked @@ -21930,7 +22692,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobListResponse" - "403": *a54 + "403": *a56 /api/admin/backups/restores/{jobId}: get: tags: @@ -21953,7 +22715,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobResponse" - "403": *a54 + "403": *a56 "404": description: No restore job with this id. content: @@ -21972,7 +22734,7 @@ paths: request leaves the server. Needs neither the Documents module nor the list: with the list unset, `available` is false and any connection still stored is listed (with `originAllowed: false`) so it can be removed. Cookie session only; not delegable." - security: &a56 + security: &a58 - cookieAuth: [] responses: "200": @@ -21982,7 +22744,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourcesStatusEnvelope" "401": *a1 - "403": &a57 + "403": &a59 description: "Refused before any request to the source: `documents.sources.browserOnly` for a Bearer caller, `sharing.not_permitted` while acting on another person's record (a delegate or guardian never uses the owner's archive), or `module.disabled` when the Documents module is off." @@ -22005,9 +22767,9 @@ paths: `documents.sources.tokenRequired`), so a stored token is never sent to an address it was not saved for. A Papra organization id is letters, digits, `_` and `-`. The token is stored encrypted (AES-256-GCM) and never returned. Ten saves and tests a minute per person. Cookie session only; not delegable." - security: *a56 + security: *a58 parameters: - - &a58 + - &a60 name: system in: path required: true @@ -22046,8 +22808,8 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceConnectionEnvelope" "401": *a1 - "403": *a57 - "404": &a59 + "403": *a59 + "404": &a61 description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. `documents.sources.notFound`: the source has no such document, or (Papra) no such organization, including @@ -22066,7 +22828,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": &a60 + "502": &a62 description: "The source could not answer usefully, with the reason in `meta.errorCode` and, when the source answered at all, its HTTP status in `meta.upstreamStatus` (its body is never passed on): `documents.sources.unreachable` (no connection, timeout, or a destination refused at dial time), `documents.sources.redirected` (the source @@ -22085,9 +22847,9 @@ paths: description: Deletes the caller's connection for this system, and its token with it. Works even after the operator removed the origin from the list, and with the Documents module switched off. `disconnected` is false when there was nothing to delete. Cookie session only; not delegable. - security: *a56 + security: *a58 parameters: - - *a58 + - *a60 responses: "200": description: Removed, or there was nothing to remove. @@ -22096,7 +22858,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceDisconnectEnvelope" "401": *a1 - "403": *a57 + "403": *a59 "422": *a4 "429": *a5 /api/documents/sources/{system}/test: @@ -22107,9 +22869,9 @@ paths: description: One request to the source with the stored token. Answers `ok` with the round-trip time and stamps `lastVerifiedAt`, or the reason in `meta.errorCode`. Shares the save's allowance of ten a minute. Cookie session only; not delegable. - security: *a56 + security: *a58 parameters: - - *a58 + - *a60 responses: "200": description: The source answered. @@ -22118,9 +22880,9 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTestEnvelope" "401": *a1 - "403": *a57 - "404": *a59 - "422": &a61 + "403": *a59 + "404": *a61 + "422": &a63 description: "`documents.sources.originNotAllowed`: the base address's origin is not on the operator's list (checked on every request, so a connection saved earlier is cut off once its origin is removed), or another validation failure." @@ -22129,7 +22891,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": *a60 + "502": *a62 /api/documents/sources/{system}/tags: get: tags: @@ -22137,9 +22899,9 @@ paths: summary: Tags of a connected archive description: The source's tags for the picker's filter, the first 250 by name, in the source's own ids. Shares the search allowance of sixty a minute. Cookie session only; not delegable. - security: *a56 + security: *a58 parameters: - - *a58 + - *a60 responses: "200": description: The tags. @@ -22148,11 +22910,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTagsEnvelope" "401": *a1 - "403": *a57 - "404": *a59 - "422": *a61 + "403": *a59 + "404": *a61 + "422": *a63 "429": *a5 - "502": *a60 + "502": *a62 /api/documents/sources/{system}/search: get: tags: @@ -22166,9 +22928,9 @@ paths: under that source key: `new`, `imported` with the vault document's `documentId`, or `deleted` (deleted in HealthLog; importing it again stores nothing). Nothing is stored. Sixty searches a minute, shared with the tag read. Cookie session only; not delegable." - security: *a56 + security: *a58 parameters: - - *a58 + - *a60 - name: q in: query required: false @@ -22209,11 +22971,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceSearchEnvelope" "401": *a1 - "403": *a57 - "404": *a59 - "422": *a61 + "403": *a59 + "404": *a61 + "422": *a63 "429": *a5 - "502": *a60 + "502": *a62 /api/documents/sources/{system}/import: post: tags: @@ -22232,9 +22994,9 @@ paths: fetched), including a duplicate. `kind` sets the document type (default OTHER). Automatic AI reading follows the person's own setting. Counts against the person's upload allowance (60 an hour) and storage quota. 201 for a new document, 200 otherwise. Cookie session only; not delegable." - security: *a56 + security: *a58 parameters: - - *a58 + - *a60 requestBody: required: true content: @@ -22292,7 +23054,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceImportEnvelope" "401": *a1 - "403": *a57 + "403": *a59 "404": description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. @@ -22323,7 +23085,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a61 + "422": *a63 "429": description: "`documents.inbound.rateLimited` (the person's hourly upload allowance, with the rate-limit headers) or `documents.inbound.uploadBusy` (too many uploads in progress; `Retry-After: 1`)." @@ -22331,7 +23093,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": *a60 + "502": *a62 /api/symptoms/definitions: get: tags: @@ -22355,7 +23117,7 @@ paths: schema: $ref: "#/components/schemas/ListSymptomDefinitionsEnvelope" "401": *a1 - "403": &a62 + "403": &a64 description: >- `illness.disabled`: the record has the illness module switched off (or the operator turned it off server-wide); symptoms ride that module. @@ -22398,7 +23160,7 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomDefinitionEnvelope" "401": *a1 - "403": *a62 + "403": *a64 "409": *a7 "422": *a4 "429": *a5 @@ -22428,8 +23190,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomDefinitionEnvelope" "401": *a1 - "403": *a62 - "404": &a63 + "403": *a64 + "404": &a65 description: "`symptoms.definition.notFound`: no definition with this id on the record." content: application/json: @@ -22463,8 +23225,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomDefinitionEnvelope" "401": *a1 - "403": *a62 - "404": *a63 + "403": *a64 + "404": *a65 "422": *a4 "429": *a5 /api/symptoms/events: @@ -22513,7 +23275,7 @@ paths: schema: $ref: "#/components/schemas/ListSymptomEventsEnvelope" "401": *a1 - "403": *a62 + "403": *a64 "422": *a4 "429": *a5 post: @@ -22539,8 +23301,8 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomEventEnvelope" "401": *a1 - "403": *a62 - "404": *a63 + "403": *a64 + "404": *a65 "409": *a7 "422": *a4 "429": *a5 @@ -22570,8 +23332,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomEventEnvelope" "401": *a1 - "403": *a62 - "404": &a64 + "403": *a64 + "404": &a66 description: "`symptoms.event.notFound`: no occurrence with this id on the record." content: application/json: @@ -22598,8 +23360,313 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomEventEnvelope" "401": *a1 - "403": *a62 - "404": *a64 + "403": *a64 + "404": *a66 + "422": *a4 + "429": *a5 + /api/day/index: + get: + tags: + - Records + summary: Days with entries + description: Which local days in a window hold anything, by section, and which carry a notable observation. At most 366 + days per call. Days are cut in the record's own time zone. + parameters: + - in: query + name: from + schema: + type: string + description: A local calendar date, `YYYY-MM-DD`. + required: true + description: A local calendar date, `YYYY-MM-DD`. + - in: query + name: to + schema: + type: string + description: A local calendar date, `YYYY-MM-DD`. + required: true + description: A local calendar date, `YYYY-MM-DD`. + responses: + "200": + description: The days in the window that hold entries. + content: + application/json: + schema: + $ref: "#/components/schemas/DayIndexEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 + /api/day/notable: + get: + tags: + - Records + summary: Since the last visit + description: "What stood out and what changed in a window, for the preparation of the next visit: deterministic + observations (a value highest or lowest for at least three months, a first reading, a regular reading that + stopped for two weeks or more) and context changes (dose changes, medication and course starts and ends, pauses, + illness onsets and recoveries, vaccinations, procedures, lab days). Without `from` the window starts at the last + completed visit the caller may see (else 90 days back); without `to` it ends today; at most 1096 days. Keys and + the record's own names only, no judgement. Days are cut in the record's own time zone. Sections the caller's + grant does not cover are left out." + parameters: + - in: query + name: from + schema: + type: string + description: A local calendar date, `YYYY-MM-DD`. + - in: query + name: to + schema: + type: string + description: A local calendar date, `YYYY-MM-DD`. + responses: + "200": + description: The window, its observations and its changes. + content: + application/json: + schema: + $ref: "#/components/schemas/DayNotableWindowEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 + /api/day/{date}: + get: + tags: + - Records + summary: One day across the record + description: "Everything on one local calendar day: what ran through it (medications, an illness, a cycle phase, a + trip), the readings in its window with the person's usual range, what happened on it, and deterministic notable + observations as keys with parameters. Sections of a switched-off module are left out; sections the caller's + grant does not cover are named in `sections`. Life events and the environment are the owner's only: for a + delegate at any level they are named `not_shared`. Read live from the record, never from the UTC rollups. A date + that is not a real calendar date answers 422." + parameters: + - in: path + name: date + schema: + type: string + description: A local calendar date, `YYYY-MM-DD`. + required: true + description: A local calendar date, `YYYY-MM-DD`. + responses: + "200": + description: The day. + content: + application/json: + schema: + $ref: "#/components/schemas/DayEnvelope" + "401": *a1 + "403": *a11 + "422": *a4 + "429": *a5 + /api/timeline: + get: + tags: + - Records + summary: The record over the years + description: Lanes of conditions, allergies, medications, vaccinations, visits, lab days, documents and life events, the + standing items without a start, and monthly (or finer, by zoom) value series. Empty lanes are not sent; lanes of + a switched-off module never are. The `life` lane (life events and travel periods) is the owner's only and is + never sent to a delegate. Requires the opt-in `timeline` module. + parameters: + - in: query + name: zoom + schema: + default: all + description: "`range` is a chosen stretch: it requires `from` and `to`, at most 15 years apart, and its value series + average quarters, months, weeks or days by its length." + type: string + enum: + - all + - year + - quarter + - range + description: "`range` is a chosen stretch: it requires `from` and `to`, at most 15 years apart, and its value series + average quarters, months, weeks or days by its length." + - in: query + name: from + schema: + type: string + description: A local calendar date, `YYYY-MM-DD`. + - in: query + name: to + schema: + type: string + description: A local calendar date, `YYYY-MM-DD`. + - in: query + name: values + schema: + description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`. A name given twice counts + once, so the list is bounded by the names that exist; an unknown name answers 422. + type: string + description: Comma-separated value series to include, as `MeasurementType` names or `MOOD`. A name given twice counts + once, so the list is bounded by the names that exist; an unknown name answers 422. + responses: + "200": + description: The timeline for the requested zoom and window. + content: + application/json: + schema: + $ref: "#/components/schemas/TimelineEnvelope" + "401": *a1 + "403": &a67 + description: >- + `module.disabled` with `meta.module = timeline`: the record has the timeline switched off (it is opt-in), or + the operator turned it off server-wide. + + + Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). + Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections + do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it + is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's + audit trail instead. On the cookie transport the same code answers one further case: a session that has + entered a shared record and sends a request without its record-context assertion, which is a client older + than the fence — leave the record and reload. A request that names no record but the caller's own never + reaches this response, so a client that never switches and never sends the per-request account selector will + not see it. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + /api/timeline/readiness: + get: + tags: + - Records + summary: What the timeline can show + description: "One row per lane: carries, thin or empty, a count, a detail as a message key with parameters, and the gaps + with one in-app link each. The verdict is one of two words, never a score. The `life` lane is reported to the + owner only. Requires the opt-in `timeline` module." + responses: + "200": + description: The readiness inventory. + content: + application/json: + schema: + $ref: "#/components/schemas/TimelineReadinessEnvelope" + "401": *a1 + "403": *a67 + "422": *a4 + "429": *a5 + /api/life-events: + get: + tags: + - Records + summary: List life events + description: "The account's own life events, oldest first; soft-deleted ones are excluded. Title and note are decrypted + for the caller. Owner-only: no delegate reads them at any share level. Life events are never sent to a model." + responses: + "200": + description: The life events. + content: + application/json: + schema: + $ref: "#/components/schemas/LifeEventListEnvelope" + "401": *a1 + "403": &a68 + description: >- + Owner-only in v1.42: no share reaches life events, not a `profile` share, a whole-record share or MANAGE, + and not a guardian acting for a managed profile. + + + Refused: the request was made while the session is acting on another account (`meta.errorCode` = + `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it + refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any + level opens it. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + post: + tags: + - Records + summary: Add a life event + description: "A dated anchor in the person's life. `precision` says how much of the date is known and applies to both + ends: at MONTH a date is the first of its month, at YEAR the first of its year. `endDate`, when given, is not + before `startDate`. Title (1-120 characters) and note (up to 2000) are encrypted at rest. Owner-only, like every + life-event route." + parameters: + - *a6 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateLifeEventRequest" + responses: + "201": + description: Created. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateLifeEventEnvelope" + "401": *a1 + "403": *a68 + "409": *a7 + "422": *a4 + "429": *a12 + /api/life-events/{id}: + patch: + tags: + - Records + summary: Edit a life event + description: A partial edit; an omitted key leaves the column untouched. The merged event must still satisfy the create + rules (dates aligned to the precision, end not before start). + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateLifeEventRequest" + responses: + "200": + description: Updated. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateLifeEventEnvelope" + "401": *a1 + "403": *a68 + "404": &a69 + description: No such life event on this account. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + delete: + tags: + - Records + summary: Delete a life event + description: Soft-deletes one life event. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteLifeEventEnvelope" + "401": *a1 + "403": *a68 + "404": *a69 "422": *a4 "429": *a5 /api/bugreport: @@ -23478,6 +24545,47 @@ components: server-side recovery. required: - passphrase + ProfileClaimPreviewRequest: + type: object + properties: + token: + type: string + minLength: 1 + maxLength: 80 + required: + - token + additionalProperties: false + description: "`token` is the `hlp_` token from the handover link. Sent in the body, never in a URL." + ProfileClaimRequest: + type: object + properties: + token: + type: string + minLength: 1 + maxLength: 80 + username: + type: string + minLength: 3 + maxLength: 30 + pattern: ^[a-zA-Z0-9_-]+$ + email: + type: string + maxLength: 254 + format: email + pattern: ^(?!\.)(?!.*\.\.)([A-Za-z0-9_'+\-\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\-]*\.)+[A-Za-z]{2,}$ + password: + type: string + minLength: 12 + maxLength: 1024 + required: + - token + - username + - email + - password + additionalProperties: false + description: "`token` is the `hlp_` token from the handover link, in the body. `username` follows the registration rules + and may not start with `managed-`; `email` is required, because the account needs a way back in; `password` + follows the registration password policy." LoginPasswordRequest: type: object properties: @@ -24307,16 +25415,19 @@ components: items: $ref: "#/components/schemas/AppleHealthBatchEntry" syncTrigger: - description: "Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, or a push wake). - Recorded on the ingest wide event and kept on the account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` - for a `background` or `push` trigger — both readable from `GET /api/integrations/healthkit`. It does not - affect dedup, attribution, or how any sample is stored. Optional and backward-compatible: pre-#66 clients - omit it." + description: 'Diagnostic-only. Names what triggered this sync (foreground app open, background refresh, a push wake, or + a user-initiated `manual` "Sync all" run, since v1.42). Recorded on the ingest wide event and kept on the + account as `lastSyncTrigger` — plus `lastBackgroundSyncAt` for a `background` or `push` trigger — both + readable from `GET /api/integrations/healthkit`. Since v1.42 it is also recorded per measurement type the + batch carried (Apple Health rows only), served as `lastReceivedAt` / `lastTrigger` / `lastNewSampleAt` on + each `metricFreshness` entry of that read. It does not affect dedup, attribution, or how any sample is + stored. Optional and backward-compatible: pre-#66 clients omit it.' type: string enum: - foreground - background - push + - manual required: - entries description: Apple Health batch ingest. ≤500 entries per call; idempotent via `Idempotency-Key`. @@ -26221,6 +27332,8 @@ components: type: boolean vaccinations: type: boolean + timeline: + type: boolean baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored @@ -26289,8 +27402,6 @@ components: enum: - weekly - biweekly - ambientSuggestions: - type: boolean nudgeAiComposed: type: boolean measurementReminder: @@ -27278,6 +28389,18 @@ components: required: - rating description: Per-message helpful/unhelpful feedback (v1.4.23 H7). Optional `reason` is free-form prose, capped at 200 chars. + AdminMeasurementMaintenanceRequest: + type: object + properties: + vacuum: + default: true + description: Run `VACUUM (ANALYZE)` on `measurements`. + type: boolean + reindex: + default: true + description: Rebuild every index of `measurements` with `REINDEX INDEX CONCURRENTLY`, largest first, one at a time. + type: boolean + additionalProperties: false InsightsPregenerateRequest: type: object properties: {} @@ -28571,9 +29694,10 @@ components: - type: string const: "" format: - description: Body shape. `generic` sends HealthLog's own JSON envelope; `gotify` sends the body Gotify's `POST /message` - expects, with an integer priority. Omitted keeps the stored choice, which is `generic` for a config saved - before the choice existed. + description: Body shape. `generic` sends HealthLog's own JSON envelope (`title`, `message`, `eventType`, `priority`, + plus `content` and `text` so a Discord or Slack webhook URL accepts it as is); `gotify` sends the body + Gotify's `POST /message` expects, with an integer priority. Omitted keeps the stored choice, which is + `generic` for a config saved before the choice existed. type: string enum: - generic @@ -29482,6 +30606,120 @@ components: additionalProperties: false description: Partial edit; an omitted key leaves the column untouched, an explicit null clears it, and a body naming nothing is a 422. + CreateCustomVaccineRequest: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 100 + components: + minItems: 1 + maxItems: 26 + type: array + items: + type: string + enum: + - tetanus + - diphtheria + - pertussis + - polio + - measles + - mumps + - rubella + - varicella + - hib + - hepatitis-a + - hepatitis-b + - influenza + - pneumococcal + - herpes-zoster + - rsv + - covid-19 + - fsme + - hpv + - meningococcal-acwy + - meningococcal-b + - rotavirus + - typhoid + - rabies + - yellow-fever + - japanese-encephalitis + - cholera + typicalSeriesDoses: + anyOf: + - type: integer + minimum: 1 + maximum: 10 + - type: "null" + boosterIntervalMonths: + anyOf: + - type: integer + minimum: 1 + maximum: 600 + - type: "null" + required: + - name + - components + additionalProperties: false + description: A new definition. `components` lists one to all of the catalogue's antigen slugs, each once — a disease + outside that list is logged as a free-text dose instead. `typicalSeriesDoses` (1–10) and `boosterIntervalMonths` + (1–600) are optional; the interval prefills the booster offer after a dose. + UpdateCustomVaccineRequest: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 100 + components: + minItems: 1 + maxItems: 26 + type: array + items: + type: string + enum: + - tetanus + - diphtheria + - pertussis + - polio + - measles + - mumps + - rubella + - varicella + - hib + - hepatitis-a + - hepatitis-b + - influenza + - pneumococcal + - herpes-zoster + - rsv + - covid-19 + - fsme + - hpv + - meningococcal-acwy + - meningococcal-b + - rotavirus + - typhoid + - rabies + - yellow-fever + - japanese-encephalitis + - cholera + typicalSeriesDoses: + anyOf: + - type: integer + minimum: 1 + maximum: 10 + - type: "null" + boosterIntervalMonths: + anyOf: + - type: integer + minimum: 1 + maximum: 600 + - type: "null" + additionalProperties: false + description: Partial edit; an omitted key is left untouched and an empty body is a 422. The change applies to every dose + logged against the definition, because the series is derived on read; it never re-runs booster satisfaction. CreateVaccinationRequest: type: object properties: @@ -29543,6 +30781,12 @@ components: minLength: 1 maxLength: 40 - type: "null" + customVaccineId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" note: anyOf: - type: string @@ -29559,12 +30803,15 @@ components: - occurredAt additionalProperties: false description: "Log one administered dose. `occurredAt` is required and must not be in the future — a vaccination is - recorded after the fact, and a planned booster is a reminder rather than a record. Exactly one identity arm is - required: either `antigenSlug` (a slug the catalogue offers) or `vaccineName` (whatever the person's own record - says, verbatim, which may be a trade name). Everything else is optional, so a decades-old paper entry with - nothing but a date and a name is loggable. `note` is encrypted at rest. `documentIds` pre-links the scanned - pages; an id naming nothing the caller owns is dropped rather than refused, so a link never blocks a save. - Logging a dose satisfies any booster reminder keyed to one of its component antigens." + recorded after the fact, and a planned booster is a reminder rather than a record. At least one identity arm is + required: `antigenSlug` (a slug the catalogue offers), `customVaccineId` (one of the record's own vaccine + definitions, see `/api/vaccinations/custom`; an id the record does not hold live is a 404 + `vaccination.custom-vaccine-not-found`) or `vaccineName` (whatever the person's own record says, verbatim, which + may be a trade name). When both a slug and a definition are given, the catalogue is the answer. Everything else + is optional, so a decades-old paper entry with nothing but a date and a name is loggable. `note` is encrypted at + rest. `documentIds` pre-links the scanned pages; an id naming nothing the caller owns is dropped rather than + refused, so a link never blocks a save. Logging a dose satisfies any booster reminder keyed to one of its + component antigens, the antigens of an own definition included." UpdateVaccinationRequest: type: object properties: @@ -29626,6 +30873,12 @@ components: minLength: 1 maxLength: 40 - type: "null" + customVaccineId: + anyOf: + - type: string + minLength: 1 + maxLength: 40 + - type: "null" note: anyOf: - type: string @@ -29639,11 +30892,11 @@ components: minLength: 1 maxLength: 40 additionalProperties: false - description: Partial edit of a dose; an omitted key leaves the column untouched, and a body naming nothing is a 422. + description: "Partial edit of a dose; an omitted key leaves the column untouched, and a body naming nothing is a 422. `occurredAt` is editable because a transcription typo is the common case — and editing it deliberately does NOT re-run the booster satisfaction, so correcting a date can never move a reminder's due date. An edit that would - leave the record with neither identity arm is refused. A present `documentIds` array replaces the links, empty - array included. + leave the record with no identity arm (slug, own definition or name) is refused. `customVaccineId: null` unlinks + the dose from its own definition. A present `documentIds` array replaces the links, empty array included." VaccinationBoosterRequest: type: object properties: @@ -30117,6 +31370,26 @@ components: additionalProperties: false description: Partial edit of a family-history entry; an omitted key leaves the column untouched. A `null` `ageAtOnset`/`note` clears that field. Rejects unknown keys. + DocumentsLayoutPutBody: + type: object + properties: + version: + type: number + const: 1 + view: + type: string + enum: + - cards + - list + arrangement: + type: string + enum: + - stacked + - flow + required: + - version + additionalProperties: false + description: Either field may be omitted; the stored value is kept for the one left out. DocumentBulkRequest: type: object properties: @@ -31281,6 +32554,12 @@ components: - painNrs - waistCircumference - waistToHeight + - scoreHealth + - scoreReadiness + - scoreRecovery + - scoreSleep + - scoreStress + - scoreStrain description: Which chart's overlays are being written. Closed enum. prefs: type: object @@ -31460,6 +32739,12 @@ components: - painNrs - waistCircumference - waistToHeight + - scoreHealth + - scoreReadiness + - scoreRecovery + - scoreSleep + - scoreStress + - scoreStrain additionalProperties: type: object properties: @@ -31535,6 +32820,11 @@ components: enum: - score - reminders + todayCardVisible: + description: v1.42 — whether the dashboard shows its top card (the Today card above the tiles). Missing resolves to + `true`; only an explicit `false` hides it. The stored default is omitted. A layout reset (DELETE) shows it + again. Additive. + type: boolean baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored @@ -31549,6 +32839,71 @@ components: - version description: PUT body for the dashboard layout — the layout fields plus the optional optimistic-concurrency base token (`baseUpdatedAt`). Omit the token for the legacy unconditional write. + CreateManagedProfileHandoverRequest: + type: object + properties: + expiresInDays: + default: 7 + anyOf: + - type: number + const: 1 + - type: number + const: 7 + - type: number + const: 14 + proposals: + default: [] + maxItems: 50 + type: array + items: + type: object + properties: + grantId: + type: string + minLength: 1 + maxLength: 64 + proposal: + type: string + enum: + - end + - read + - manage + required: + - grantId + - proposal + additionalProperties: false + additionalProperties: false + description: "`expiresInDays` is 1, 7 (default) or 14. `proposals` names at most one entry per active guardian grant; a + guardian left out is proposed `read`, and a grant that is not an active guardian of this profile is refused with + 422." + HandoverDecisionRequest: + type: object + properties: + decisions: + maxItems: 50 + type: array + items: + type: object + properties: + grantId: + type: string + minLength: 1 + maxLength: 64 + access: + type: string + enum: + - end + - read + - manage + required: + - grantId + - access + additionalProperties: false + required: + - decisions + additionalProperties: false + description: The new owner's decision per former guardian, named by the guardian's grant id at the claim. A guardian + left out keeps the access the claim gave them. AccountGrantInvite: type: object properties: @@ -31980,6 +33335,8 @@ components: type: boolean vaccinations: type: boolean + timeline: + type: boolean additionalProperties: false cycleTrackingEnabled: type: boolean @@ -32438,6 +33795,15 @@ components: - layout additionalProperties: false description: The full insights layout. Unlike the other five families this one is a replacement rather than a merge. + EnvironmentPreferencesRequest: + type: object + properties: + airQualityEnabled: + type: boolean + description: Fetch and show air quality, pollen and UV for this account. + required: + - airQualityEnabled + additionalProperties: false EnvironmentHomeRequest: type: object properties: @@ -32636,6 +34002,81 @@ components: - type: "null" description: "Partial edit of one occurrence. `note: null` clears the note, `episodeId: null` drops the link. A changed `episodeId` must name an open episode; an unchanged link to an episode that has since resolved is kept." + CreateLifeEventRequest: + type: object + properties: + category: + type: string + enum: + - FAMILY + - HOME + - WORK + - LOSS + - OTHER + startDate: + type: string + description: A local calendar date, `YYYY-MM-DD`. + endDate: + anyOf: + - type: string + description: A local calendar date, `YYYY-MM-DD`. + - type: "null" + precision: + type: string + enum: + - DAY + - MONTH + - YEAR + title: + type: string + minLength: 1 + maxLength: 120 + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + required: + - category + - startDate + - precision + - title + additionalProperties: false + UpdateLifeEventRequest: + type: object + properties: + category: + type: string + enum: + - FAMILY + - HOME + - WORK + - LOSS + - OTHER + startDate: + type: string + description: A local calendar date, `YYYY-MM-DD`. + endDate: + anyOf: + - type: string + description: A local calendar date, `YYYY-MM-DD`. + - type: "null" + precision: + type: string + enum: + - DAY + - MONTH + - YEAR + title: + type: string + minLength: 1 + maxLength: 120 + note: + anyOf: + - type: string + maxLength: 2000 + - type: "null" + additionalProperties: false MedicationTreatmentClass: type: string enum: @@ -32867,48 +34308,51 @@ components: cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json, - cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; demo — demo.readOnly; documents — - documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed, - documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded, - documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound, - documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending, - documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType, - documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate, - documents.inbound.localOcrDisabled, documents.inbound.notFound, documents.inbound.notIndexed, - documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported, - documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate, - documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy, - documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused, - documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress, - documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound, - documents.sources.organizationRequired, documents.sources.originNotAllowed, + cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; day — day.invalid; demo — + demo.readOnly; documents — documents.chat.conversationNotFound, documents.chat.invalid, + documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, + documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, + documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, + documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, + documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery, + documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notFound, + documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic, + documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited, + documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, + documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, + documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly, + documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected, + documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed, documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected, documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable, documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid, encounter.practitioner-not-found, encounter.reminder-conflict, encounter.reminder-not-found; encryption - — encryption.keyBackup.stale, encryption.key_mismatch; environment — environment.invalid, - environment.no_home, environment.range_too_large, environment.travel.not_found; export — - export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid; feedback — - feedback.body.invalid; health_score_config — health_score_config.invalid, - health_score_config.too_narrow; illness — illness.day-log.invalid, illness.disabled, - illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window, - illness.episode.parent-not-found, illness.insights.invalid; import — import.apple_health.busy, - import.write_failed; ingest — ingest.rate_limited; insights — insights.generate.budgetExceeded, - insights.generate.outboundScreened, insights.generate.profileScopeChanged, - insights.generate.scopeChanged; labs — labs.biomarker.create.invalid, labs.biomarker.duplicate, - labs.biomarker.notFound, labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, - labs.create.invalid, labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge, - labs.ocr.fileType, labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported, + — encryption.keyBackup.stale, encryption.key_mismatch; environment — environment.backfill_pending, + environment.backfill_rate_limited, environment.invalid, environment.no_home, + environment.range_too_large, environment.travel.not_found; export — export.selection.empty, + export.selection.unknown_leaf; familyHistory — familyHistory.invalid; feedback — feedback.body.invalid; + health_score_config — health_score_config.invalid, health_score_config.too_narrow; illness — + illness.day-log.invalid, illness.disabled, illness.episode.chronic-no-resolve, illness.episode.invalid, + illness.episode.invalid-window, illness.episode.parent-not-found, illness.insights.invalid; import — + import.apple_health.busy, import.write_failed; ingest — ingest.rate_limited; insights — + insights.generate.budgetExceeded, insights.generate.outboundScreened, + insights.generate.profileScopeChanged, insights.generate.scopeChanged; labs — + labs.biomarker.create.invalid, labs.biomarker.duplicate, labs.biomarker.notFound, + labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid, + labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge, labs.ocr.fileType, + labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported, labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.unit.mismatch, labs.update.invalid, labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected, labs.update.referenceRangeInvalid, - labs.update.sourceReferenceRangeInvalid; managed_profile — managed_profile.guardian.duplicate, - managed_profile.guardian.managed_invitee, managed_profile.guardian.required, managed_profile.not_found; - measurement — measurement.batch.invalid, measurement.batch.source_not_permitted, - measurement.batch.too_large, measurement.bulk-delete.invalid, measurement.create.invalid, - measurement.create.source_not_permitted, measurement.delete.too_large, measurement.duplicate_timestamp, - measurement.restore.invalid, measurement.update.server_owned_source; medication — - medication.intake.import.invalid_format; medications — medications.category.invalid, + labs.update.sourceReferenceRangeInvalid; life_event — life_event.invalid, life_event.not_found; + managed_profile — managed_profile.guardian.duplicate, managed_profile.guardian.managed_invitee, + managed_profile.guardian.required, managed_profile.handover.no_pending, + managed_profile.handover.unknown_guardian, managed_profile.not_found; measurement — + measurement.batch.invalid, measurement.batch.source_not_permitted, measurement.batch.too_large, + measurement.bulk-delete.invalid, measurement.create.invalid, measurement.create.source_not_permitted, + measurement.delete.too_large, measurement.duplicate_timestamp, measurement.restore.invalid, + measurement.update.server_owned_source; medication — medication.intake.import.invalid_format, + medication.intake.notTracked; medications — medications.category.invalid, medications.category.limitReached, medications.category.unknown, medications.course.currentOpen, medications.course.invalid, medications.course.invalidRange, medications.course.notFound, medications.course.oneShot, medications.course.overlap, medications.course.windowRequired, @@ -32926,9 +34370,11 @@ components: onboarding.answers.invalid, onboarding.answers.rateLimited, onboarding.complete.invalid, onboarding.complete.recordNotFound, onboarding.complete.recordTargetMismatch, onboarding.disclaimer.invalid, onboarding.restart.invalid, onboarding.restart.rateLimited, - onboarding.tour.invalid; personal_records — personal_records.invalid_query; practitioner — + onboarding.tour.invalid; passkey — passkey.challenge.expired, passkey.response.invalid, passkey.unknown, + passkey.verification.failed; personal_records — personal_records.invalid_query; practitioner — practitioner.invalid; profile — profile.update.emailInUse, profile.update.emailRateLimited, profile.update.emailSecondFactorRequired, profile.update.invalidBody, profile.update.nothingSaved; + profile_claim — profile_claim.invalid, profile_claim.oidc_only_unsupported, profile_claim.taken; record_write — record_write.rate_limited; report-selection — report-selection.body.invalid_json, report-selection.body.invalid_shape, report-selection.leaves.unknown; route — route.retired; share-link — share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited, @@ -32942,10 +34388,12 @@ components: sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid; - tokens — tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached, - tokens.workouts.ceiling_reached; vaccination — vaccination.booster-invalid, - vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.encounter-not-found, - vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found, + timeline — timeline.invalid; tokens — tokens.documents.ceiling_reached, + tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached; vaccination — + vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope, + vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken, + vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required, + vaccination.invalid, vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, @@ -34070,6 +35518,9 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 + nEff: + description: v1.42 — effective sample size after serial correlation, ≤ `n`; the p-value uses it. + type: number r: type: number pValue: @@ -35310,6 +36761,13 @@ components: - type: string minLength: 1 - type: "null" + homeLocationEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" + environmentAirQualityEnabled: + type: boolean timezone: type: string locale: @@ -35336,6 +36794,7 @@ components: insightsLayoutJson: {} medicationListLayoutJson: {} moodTagLayoutJson: {} + documentsLayoutJson: {} reportSelectionJson: {} globalExcludedInjectionSites: type: array @@ -35531,6 +36990,7 @@ components: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT notes: anyOf: - type: string @@ -36925,6 +38385,7 @@ components: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT externalId: anyOf: - type: string @@ -37186,6 +38647,14 @@ components: anyOf: - type: string - type: "null" + sourceValue: + anyOf: + - type: number + - type: "null" + sourceUnit: + anyOf: + - type: string + - type: "null" takenAt: type: string minLength: 1 @@ -37255,6 +38724,10 @@ components: - type: "null" hidden: type: boolean + analyteKey: + anyOf: + - type: string + - type: "null" context: anyOf: - type: string @@ -37507,6 +38980,70 @@ components: - updatedAt - events additionalProperties: {} + lifeEvents: + default: [] + type: array + items: + type: object + properties: + id: + type: string + minLength: 1 + category: + type: string + enum: + - FAMILY + - HOME + - WORK + - LOSS + - OTHER + startDate: + type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + endDate: + anyOf: + - type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + - type: "null" + precision: + type: string + enum: + - DAY + - MONTH + - YEAR + title: + type: string + minLength: 1 + titleEncrypted: + type: string + minLength: 1 + note: + anyOf: + - type: string + - type: "null" + noteEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" + createdAt: + type: string + minLength: 1 + updatedAt: + type: string + minLength: 1 + deletedAt: + anyOf: + - type: string + minLength: 1 + - type: "null" + required: + - category + - startDate + - precision + - createdAt + - updatedAt + additionalProperties: {} allergies: default: [] type: array @@ -37720,6 +39257,7 @@ components: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT externalId: anyOf: - type: string @@ -38876,6 +40414,51 @@ components: - targetId - createdAt additionalProperties: {} + customVaccines: + default: [] + type: array + items: + type: object + properties: + id: + type: string + minLength: 1 + name: + type: string + minLength: 1 + components: + type: array + items: + type: string + typicalSeriesDoses: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + boosterIntervalMonths: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + createdAt: + type: string + minLength: 1 + updatedAt: + type: string + minLength: 1 + deletedAt: + anyOf: + - type: string + minLength: 1 + - type: "null" + required: + - id + - name + - createdAt + - updatedAt + additionalProperties: {} vaccinations: default: [] type: array @@ -38936,6 +40519,10 @@ components: anyOf: - type: string - type: "null" + customVaccineId: + anyOf: + - type: string + - type: "null" noteEncrypted: anyOf: - type: string @@ -39755,6 +41342,7 @@ components: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT externalId: anyOf: - type: string @@ -39798,11 +41386,22 @@ components: type: string pattern: ^\d{4}-\d{2}-\d{2}$ lat: - type: number + anyOf: + - type: number + - type: "null" lon: - type: number + anyOf: + - type: number + - type: "null" locationLabel: - type: string + anyOf: + - type: string + - type: "null" + locationEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" source: type: string enum: @@ -39863,6 +41462,97 @@ components: minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" + apparentMax: + anyOf: + - type: number + - type: "null" + pm25Mean: + anyOf: + - type: number + - type: "null" + pm25Max: + anyOf: + - type: number + - type: "null" + pm10Mean: + anyOf: + - type: number + - type: "null" + no2Mean: + anyOf: + - type: number + - type: "null" + so2Mean: + anyOf: + - type: number + - type: "null" + coMean: + anyOf: + - type: number + - type: "null" + o3Max8h: + anyOf: + - type: number + - type: "null" + eaqiMax: + anyOf: + - type: number + - type: "null" + usaqiMax: + anyOf: + - type: number + - type: "null" + uvIndexMax: + anyOf: + - type: number + - type: "null" + dustMax: + anyOf: + - type: number + - type: "null" + aodMax: + anyOf: + - type: number + - type: "null" + pollenAlderMax: + anyOf: + - type: number + - type: "null" + pollenBirchMax: + anyOf: + - type: number + - type: "null" + pollenGrassMax: + anyOf: + - type: number + - type: "null" + pollenMugwortMax: + anyOf: + - type: number + - type: "null" + pollenOliveMax: + anyOf: + - type: number + - type: "null" + pollenRagweedMax: + anyOf: + - type: number + - type: "null" + aqDomain: + anyOf: + - type: string + - type: "null" + aqHours: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + aqFetchedAt: + anyOf: + - type: string + minLength: 1 + - type: "null" fetchedAt: type: string minLength: 1 @@ -39892,11 +41582,22 @@ components: type: string pattern: ^\d{4}-\d{2}-\d{2}$ lat: - type: number + anyOf: + - type: number + - type: "null" lon: - type: number + anyOf: + - type: number + - type: "null" label: - type: string + anyOf: + - type: string + - type: "null" + locationEncrypted: + anyOf: + - type: string + minLength: 1 + - type: "null" createdAt: type: string minLength: 1 @@ -39937,6 +41638,7 @@ components: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT externalRecordingId: type: string minLength: 1 @@ -40066,6 +41768,7 @@ components: - biomarkers - illnessEpisodes - symptomDefinitions + - lifeEvents - allergies - familyHistory - workouts @@ -40085,6 +41788,7 @@ components: - encounterDocumentLinks - encounterLabLinks - encounterConditionLinks + - customVaccines - vaccinations - vaccinationDocumentLinks - coachConversations @@ -40117,6 +41821,91 @@ components: omissions there is a real, undeclared gap: roughly thirty of the models the backup plan marks as backed up have neither a reader here nor a restore branch yet, and `src/lib/export/backup-plan.ts` is the enumeration of which. `schemaVersion` is `2` on everything written today; the parser still accepts `1`." + ProfileClaimPreviewEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ProfileClaimPreview" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ProfileClaimPreview: + type: object + properties: + displayName: + anyOf: + - type: string + - type: "null" + description: The profile's name, as its guardians set it. + expiresAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + guardians: + type: array + items: + type: object + properties: + displayName: + anyOf: + - type: string + - type: "null" + description: "The guardian's display name; null when they set none. Never a login name or an id: the holder of the link + has proved nothing yet." + proposal: + $ref: "#/components/schemas/ProfileClaimGuardianAccess" + required: + - displayName + - proposal + additionalProperties: false + description: Each guardian and the access they keep after the claim, as proposed. The new owner confirms or changes it + after signing in. + required: + - displayName + - expiresAt + - guardians + additionalProperties: false + ProfileClaimGuardianAccess: + type: string + enum: + - end + - read + - manage + ProfileClaimEnvelope: + type: object + properties: + data: + type: object + properties: + userId: + type: string + username: + type: string + required: + - userId + - username + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false LoginResponse: type: object properties: @@ -41644,19 +43433,7 @@ components: accepted once the window rolls over." type: array items: - type: object - properties: - path: - type: string - code: - type: string - message: - type: string - required: - - path - - code - - message - additionalProperties: false + $ref: "#/components/schemas/ValidationIssue" required: - id - username @@ -42239,6 +44016,7 @@ components: - GOOGLE_HEALTH - STRAVA - EXTERNAL + - HEALTH_CONNECT description: Origin of the measurement. v1.4.23 added APPLE_HEALTH for the iOS HealthKit batch ingest path. v1.38.x added EXTERNAL for rows pushed in under a narrow `measurements:write` Bearer — a scale, a watch bridge, a home-automation rule — and workouts pushed in under a narrow `workouts:write` Bearer carry it too. No client may @@ -42273,6 +44051,8 @@ components: - updatedAt additionalProperties: false description: A soft-deleted measurement. Apply tombstones BEFORE upserts within a page to avoid resurrecting a row. + Since v1.42 only deletions a person makes appear here; the nightly fold that condenses raw samples into `stats:` + rows deletes them outright and lists nothing. SyncMoodUpsert: type: object properties: @@ -43290,6 +45070,9 @@ components: an existing row. `skipped` — validation no-op; see `reason`. `failed` — retryable database failure that must not advance the entry cursor; the response is marked `Cache-Control: no-store`." reason: + description: "Why a row was `skipped` or a `duplicate`. `folded_window` (since v1.42): a raw sample of a dense or mean + type whose local day lies before the fold boundary and whose hour or day a live `stats:` row of the same + source already covers. Nothing to retry; treat it like any duplicate." type: string convertedValue: description: "Only on a `value_out_of_range` skip (since v1.39.1): the entry's value after conversion into the stored @@ -44170,6 +45953,22 @@ components: minimum: -9007199254740991 maximum: 9007199254740991 - type: "null" + storedAvgHr: + description: The row's own average heart rate (v1.42). `avgHr` may be filled in from a same-session twin by the + canonical picker; this is the stored value, which an edit of a hand-entered workout sends back so the twin's + value is not written onto it. + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + storedMaxHr: + description: The row's own maximum heart rate (v1.42), as `storedAvgHr` is to `avgHr`. + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" stepCount: anyOf: - type: integer @@ -45654,6 +47453,11 @@ components: description: "`inserted`/`updated`/`duplicate` — the row landed (advance the cursor). `skipped` — not stored; see `reason`." reason: + description: "Why a `skipped` entry was not stored. Among others: `medication_not_found`, `unstable_external_id`, + `force_slot_invalid`, `force_slot_occupied`, `injection_site_not_allowed`, and (v1.42.0) + `intake_not_tracked` for an entry against a medication kept as a record only (`trackIntake: false`), the + per-entry form of `medication.intake.notTracked`. A skip is final: resending the entry gives the same + answer." type: string id: description: The landed row id (absent on skips). @@ -47248,12 +49052,33 @@ components: meta: type: object properties: + aggregate: + anyOf: + - $ref: "#/components/schemas/MedicationComplianceAggregate" + - type: "null" + days: + description: The `days` the request asked for. + anyOf: + - type: number + const: 30 + - type: number + const: 60 + - type: number + const: 90 + - type: number + const: 180 + - type: number + const: 365 requestId: type: string + required: + - aggregate additionalProperties: false + description: Absent on servers older than v1.42.0; a client that reads it treats absence as no aggregate. required: - data - error + - meta additionalProperties: false MedicationComplianceSummaryEntry: type: object @@ -47276,6 +49101,18 @@ components: compliance30: description: Thirty-day adherence summary, or an all-zero compatibility placeholder when applicable is false. $ref: "#/components/schemas/ComplianceResult" + compliance60: + description: "Present only on `?days=60`: the trailing 60 days." + $ref: "#/components/schemas/ComplianceResult" + compliance90: + description: "Present only on `?days=90`: the trailing 90 days." + $ref: "#/components/schemas/ComplianceResult" + compliance180: + description: "Present only on `?days=180`: the trailing 180 days." + $ref: "#/components/schemas/ComplianceResult" + compliance365: + description: "Present only on `?days=365`: the trailing 365 days." + $ref: "#/components/schemas/ComplianceResult" complianceDisplay: anyOf: - $ref: "#/components/schemas/ComplianceDisplay" @@ -47501,6 +49338,45 @@ components: additionalProperties: false description: The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that. + MedicationComplianceAggregate: + type: object + properties: + compliance7: + $ref: "#/components/schemas/ComplianceResult" + compliance30: + $ref: "#/components/schemas/ComplianceResult" + compliance60: + anyOf: + - $ref: "#/components/schemas/ComplianceResult" + - type: "null" + compliance90: + anyOf: + - $ref: "#/components/schemas/ComplianceResult" + - type: "null" + compliance180: + anyOf: + - $ref: "#/components/schemas/ComplianceResult" + - type: "null" + compliance365: + anyOf: + - $ref: "#/components/schemas/ComplianceResult" + - type: "null" + medicationCount: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + description: How many applicable medications the figure is built from. + required: + - compliance7 + - compliance30 + - medicationCount + additionalProperties: false + description: "One adherence figure for the whole account in the `ComplianceResult` shape, weighted by expected doses: + `taken`, `skipped` and `missed` are summed across every medication with `applicable: true`, and `rate` is + recomputed from the sums by the per-medication rule (round(100 · taken / (taken + missed)), capped at 100, 100 + when nothing was expected). Averaging the per-medication rates would let a weekly injection weigh as much as a + three-times-daily tablet. `streak` is the shortest per-medication streak. The `complianceN` block for a + requested `days` is present when `days` was sent and is not 30." GetMedicationComplianceResponse: type: object properties: @@ -49394,6 +51270,9 @@ components: vaccinations: type: boolean description: Whether the "vaccinations" module is enabled for this account. + timeline: + type: boolean + description: Whether the "timeline" module is enabled for this account. required: - cycle - mood @@ -49414,6 +51293,7 @@ components: - mentalHealth - nutrients - vaccinations + - timeline additionalProperties: false description: Fully-resolved module enable/disable map. Every toggleable module key is present. `false` means the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; `coach` @@ -49443,840 +51323,7 @@ components: type: object properties: data: - type: object - properties: - steps: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - activeEnergy: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - walkingRunningDistance: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - flightsClimbed: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - sleep: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - weight: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - bloodPressure: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - pulse: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - bodyFat: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - bodyTemperature: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - spo2: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - hrv: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - restingHeartRate: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - vo2Max: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - skinTemperature: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - respiratoryRate: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - recovery: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - stress: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - sleepDebt: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - metricPriority: - type: object - properties: - steps: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - activeEnergy: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - walkingRunningDistance: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - flightsClimbed: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - sleep: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - weight: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - bloodPressure: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - pulse: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - bodyFat: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - bodyTemperature: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - spo2: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - hrv: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - restingHeartRate: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - vo2Max: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - skinTemperature: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - respiratoryRate: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - recovery: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - stress: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - sleepDebt: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - additionalProperties: false - deviceTypePriority: - type: object - properties: - default: - maxItems: 8 - type: array - items: - type: string - enum: - - watch - - band - - ring - - phone - - scale - - other - - unknown - additionalProperties: - maxItems: 8 - type: array - items: - type: string - enum: - - watch - - band - - ring - - phone - - scale - - other - - unknown - additionalProperties: false + $ref: "#/components/schemas/ResolvedSourcePriority" error: type: "null" meta: @@ -50289,844 +51336,953 @@ components: - data - error additionalProperties: false + ResolvedSourcePriority: + type: object + properties: + steps: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + activeEnergy: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + walkingRunningDistance: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + flightsClimbed: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + sleep: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + weight: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + bloodPressure: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + pulse: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + bodyFat: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + bodyTemperature: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + spo2: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + hrv: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + restingHeartRate: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + vo2Max: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + skinTemperature: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + respiratoryRate: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + recovery: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + stress: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + sleepDebt: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + hrvRmssd: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + metricPriority: + type: object + properties: + steps: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + activeEnergy: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + walkingRunningDistance: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + flightsClimbed: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + sleep: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + weight: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + bloodPressure: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + pulse: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + bodyFat: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + bodyTemperature: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + spo2: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + hrv: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + restingHeartRate: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + vo2Max: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + skinTemperature: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + respiratoryRate: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + recovery: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + stress: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + sleepDebt: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + hrvRmssd: + maxItems: 16 + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + additionalProperties: false + deviceTypePriority: + type: object + properties: + default: + maxItems: 8 + type: array + items: + type: string + enum: + - watch + - band + - ring + - phone + - scale + - other + - unknown + additionalProperties: + maxItems: 8 + type: array + items: + type: string + enum: + - watch + - band + - ring + - phone + - scale + - other + - unknown + inUse: + type: array + items: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + - EXTERNAL + - HEALTH_CONNECT + description: Ranked sources this account has connected or holds at least one measurement from, in ladder-catalogue + order. Read-only; an editor may list only these while the ladders keep every source. + required: + - inUse + additionalProperties: false PutSourcePriorityResponse: type: object properties: data: - type: object - properties: - steps: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - activeEnergy: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - walkingRunningDistance: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - flightsClimbed: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - sleep: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - weight: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - bloodPressure: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - pulse: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - bodyFat: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - bodyTemperature: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - spo2: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - hrv: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - restingHeartRate: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - vo2Max: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - skinTemperature: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - respiratoryRate: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - recovery: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - stress: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - sleepDebt: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - metricPriority: - type: object - properties: - steps: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - activeEnergy: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - walkingRunningDistance: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - flightsClimbed: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - sleep: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - weight: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - bloodPressure: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - pulse: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - bodyFat: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - bodyTemperature: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - spo2: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - hrv: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - restingHeartRate: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - vo2Max: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - skinTemperature: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - respiratoryRate: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - recovery: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - stress: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - sleepDebt: - maxItems: 8 - type: array - items: - type: string - enum: - - MANUAL - - WITHINGS - - IMPORT - - APPLE_HEALTH - - COMPUTED - - WHOOP - - FITBIT - - NIGHTSCOUT - - POLAR - - OURA - - TELEGRAM - - MCP - - GOOGLE_HEALTH - - STRAVA - - EXTERNAL - additionalProperties: false - deviceTypePriority: - type: object - properties: - default: - maxItems: 8 - type: array - items: - type: string - enum: - - watch - - band - - ring - - phone - - scale - - other - - unknown - additionalProperties: - maxItems: 8 - type: array - items: - type: string - enum: - - watch - - band - - ring - - phone - - scale - - other - - unknown - additionalProperties: false + $ref: "#/components/schemas/ResolvedSourcePriority" error: type: "null" meta: @@ -51389,19 +52545,7 @@ components: it was published only on PUT /api/auth/profile." type: array items: - type: object - properties: - path: - type: string - code: - type: string - message: - type: string - required: - - path - - code - - message - additionalProperties: false + $ref: "#/components/schemas/ValidationIssue" required: - username - email @@ -51447,8 +52591,10 @@ components: properties: clientManaged: type: boolean - description: True when the iOS app owns local medication reminders; the server-side MEDICATION_REMINDER APNs cron is - suppressed. + description: True when the iOS app delivers medication reminders locally. The server then keeps the reminder that + announces a dose off APNs (the phone shows its own at dose time) and still pushes the follow-ups for a + dose that stays open, which the phone does not send (v1.42.0; before, every MEDICATION_REMINDER push was + suppressed). Other channels are never affected. deliveryDefault: type: string enum: @@ -52843,6 +53989,8 @@ components: - get_cycle - get_correlations - get_metric_table + - get_environment + - get_day - show_result - snapshot labelKey: @@ -52898,6 +54046,9 @@ components: description: The table this step produced (`r1`..), when it produced one. type: string pattern: ^r[1-8]$ + day: + description: The calendar date a `get_day` step read (`YYYY-MM-DD`), so a view can offer the days an answer looked at. + type: string required: - id - tool @@ -52955,9 +54106,11 @@ components: - illness - cycle - correlations + - environment + - day - snapshot description: "The data domain a step, table or chip is about: a measurement-backed scope source, or a domain read as a - whole (labs, illness, cycle, correlations, the full snapshot)." + whole (labs, illness, cycle, correlations, the environment, one day of the record, the full snapshot)." CoachMethod: type: object properties: @@ -53041,6 +54194,8 @@ components: - get_cycle - get_correlations - get_metric_table + - get_environment + - get_day domain: $ref: "#/components/schemas/CoachStepDomain" window: @@ -53779,6 +54934,8 @@ components: - get_cycle - get_correlations - get_metric_table + - get_environment + - get_day domain: $ref: "#/components/schemas/CoachStepDomain" window: @@ -56496,6 +57653,130 @@ components: - data - error additionalProperties: false + AdminMeasurementMaintenanceStatusResponse: + type: object + properties: + data: + $ref: "#/components/schemas/AdminMeasurementMaintenanceStatus" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + AdminMeasurementMaintenanceStatus: + type: object + properties: + available: + type: boolean + description: False when there is no background queue to ask (a web-only deployment); nothing can be started then. + purgePending: + type: boolean + description: The compaction-tombstone purge is queued or running. A run started now finishes at once with + `refused_purge_running`. + run: + anyOf: + - type: object + properties: + state: + type: string + enum: + - queued + - running + - completed + - failed + requestedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + startedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + - type: "null" + finishedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + - type: "null" + outcome: + anyOf: + - type: string + enum: + - completed + - refused_purge_running + - already_running + - type: "null" + description: "How a `completed` run ended: `completed` did the work, the two refusals finished without touching the + table. Null for any other state." + required: + - state + - requestedAt + - startedAt + - finishedAt + - outcome + additionalProperties: false + - type: "null" + description: The newest run pg-boss still keeps (terminal rows live seven days), or null when there is none. + sizes: + anyOf: + - type: object + properties: + tableBytes: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: "`measurements` with its indexes and TOAST." + indexBytes: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Its indexes alone. + required: + - tableBytes + - indexBytes + additionalProperties: false + - type: "null" + required: + - available + - purgePending + - run + - sizes + additionalProperties: false + AdminMeasurementMaintenanceResponse: + type: object + properties: + data: + $ref: "#/components/schemas/AdminMeasurementMaintenanceResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + AdminMeasurementMaintenanceResult: + type: object + properties: + enqueued: + type: boolean + description: False when a maintenance run is already queued or running; nothing new was queued. + required: + - enqueued + additionalProperties: false AdminReminderCheckResponse: type: object properties: @@ -59495,57 +60776,6 @@ components: description: The two server-authoritative lines a metric sub-page renders above its chart. Pure compute over the baseline and correlation engines — no provider call, no cache table — so web and native decode the same resolved DTO. - CoachSeededQuestionEnvelope: - type: object - properties: - data: - $ref: "#/components/schemas/CoachSeededQuestion" - error: - type: "null" - meta: - type: object - properties: - requestId: - type: string - additionalProperties: false - required: - - data - - error - additionalProperties: false - CoachSeededQuestion: - type: object - properties: - signal: - anyOf: - - type: object - properties: - sourceMetric: - type: string - description: Sentinel id the client keys its localised copy on (`readiness` / `recovery`). - score: - type: number - description: The latest 0..100 score. - band: - type: string - description: "`yellow` or `red`. Green never surfaces — a good day is not notable." - required: - - sourceMetric - - score - - band - additionalProperties: false - - type: "null" - description: Null whenever nothing crossed the detector's confidence and notability gate, AND whenever the account has - turned proactive suggestions off, AND whenever the `coach` capability is unavailable (then `ai` says why) — - all mean the hero keeps its neutral greeting rather than showing a fabricated opener. - ai: - description: The `coach` capability the opener exists for. - $ref: "#/components/schemas/AiCapabilityState" - required: - - signal - - ai - additionalProperties: false - description: Today's single most notable derived wellness signal, resolved server-side into a tappable opener for the - Coach's blank-chat hero. The client renders the resolved DTO and never recomputes the selection. NarrativeEnvelope: type: object properties: @@ -60825,6 +62055,93 @@ components: description: Batched derived-metric values. One request resolves the whole dashboard grid (the wellness scores + the derived re-frames + one baseline per vital) instead of N concurrent single-metric requests sharing the Prisma pool. Pure compute over the rollup tier — no LLM, no narrative, no cache table. + ScoreHistoryResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ScoreHistoryResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ScoreHistoryResponse: + type: object + properties: + score: + type: string + enum: + - HEALTH_SCORE + - READINESS + - SLEEP_SCORE + days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: The window the request asked for. + points: + type: array + items: + type: object + properties: + day: + type: string + description: The local calendar day the value describes, `YYYY-MM-DD`; the day the day view opens for it. + value: + type: integer + minimum: 0 + maximum: 100 + seamBreak: + type: boolean + description: "True on the first day scored under a different recipe (what counts, or the algorithm version) than the day + before. Draw no line from the previous point to this one: the values either side are averages of + different things. Only the health score has seams; never true on the first point." + required: + - day + - value + - seamBreak + additionalProperties: false + description: One point per day that has a value, oldest first. A day without one is absent, never zero. + band: + anyOf: + - type: object + properties: + lo: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + hi: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + n: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Days the range was formed from. + required: + - lo + - hi + - n + additionalProperties: false + - type: "null" + description: "The person's usual range behind the newest point: median and scaled MAD of the values in the 30 days + before it, from its own side of the last seam. Null with fewer than seven such days. Describes, does not + grade." + required: + - score + - days + - points + - band + additionalProperties: false + description: One score's daily course over a trailing window, for a history chart with a day per point. InsightsGlp1PlateauResponseEnvelope: type: object properties: @@ -61311,11 +62628,17 @@ components: description: v1.22 — labs ↔ outcome associations (each draw vs the contemporaneous outcome window-mean), FDR-controlled; absent-degrading on sparse draws. $ref: "#/components/schemas/LabCorrelationResult" + findingsBeforeSeasonalAdjustment: + type: boolean + description: v1.42 — true when the record held a discovery finding before the engine began removing trend and season + from both series. Drives a one-time explanation of why fewer pairs surface; false for every account created + since. required: - discovered - pairsTested - fdrQ - minPairs + - findingsBeforeSeasonalAdjustment additionalProperties: false description: v1.10.0 — FDR-controlled correlation discovery over a curated behaviour × outcome matrix, lagged behaviour → next-day outcome. Only statistically-defensible pairs surface; descriptive, never causal. @@ -61338,13 +62661,18 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 - description: Paired-day count after the day+1 lag join (≥ 20). + description: Paired-day count after the lag join (≥ 20). + nEff: + description: v1.42 — effective sample size after serial correlation (Pyper–Peterman), ≤ `n`. The p-value, the shrinkage + and the tier use it. + type: number r: type: number - description: Pearson r over the lag-joined daily series. + description: "Pearson r over the lag-joined daily series, computed on residuals: a linear trend (and, from 120 paired + days, one annual harmonic) is removed from both series first (v1.42)." pValue: type: number - description: Two-sided exact Student-t p-value (< 0.05). + description: Two-sided exact Student-t p-value (< 0.05) at the effective sample size. qValue: type: number description: Benjamini-Hochberg FDR-adjusted q-value (≤ the surface threshold). @@ -61367,7 +62695,8 @@ components: type: integer minimum: -9007199254740991 maximum: 9007199254740991 - description: Lag in days applied (1). + description: "Lag in days applied: 1 = behaviour day, outcome the next day; 0 = the environmental channels, whose + exposure is the mean of the day before and the day itself, paired with the same day's outcome (v1.42)." window: description: v1.22 — which window surfaced the pair. Absent on the 180-day scan (retrospective default); `recent` for an emerging early-detection pair. @@ -62266,7 +63595,20 @@ components: - days additionalProperties: false - type: "null" - description: Null for a sparse logger below the day floor. + description: Day-to-day steadiness over the trailing `stabilityWindowDays` days. Null below the day floor (seven days + with an entry) inside that window, so a short window can be null where the year is not. + stabilityWindowDays: + anyOf: + - type: number + const: 30 + - type: number + const: 90 + - type: number + const: 180 + - type: number + const: 365 + description: "The window `stability` covers: the `days` query parameter, or 365 (the whole daily series) when it was not + sent. Added in v1.42.0; absent from older servers, whose score always covers the year." tags: type: array items: @@ -62722,6 +64064,7 @@ components: - weekday - timeOfDay - stability + - stabilityWindowDays - tags - contextComparison - structuredTags @@ -64998,6 +66341,124 @@ components: required: - revoked additionalProperties: false + HealthConnectImportKickoffEnvelope: + type: object + properties: + data: + type: object + properties: + jobId: + type: string + status: + type: string + enum: + - queued + - unpacking + - parsing + - upserting + - done + idempotent: + type: boolean + required: + - jobId + - status + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + HealthConnectImportStatusEnvelope: + type: object + properties: + data: + type: object + properties: + job: + anyOf: + - $ref: "#/components/schemas/HealthConnectImportJob" + - type: "null" + required: + - job + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + HealthConnectImportJob: + type: object + properties: + jobId: + type: string + status: + type: string + description: "`queued`, `unpacking`, `parsing`, `upserting`, `done` or `failed`. A plain string, like the Apple Health + status." + startedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + completedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + uploadBytes: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + progress: + type: object + propertyNames: + type: string + additionalProperties: {} + description: Free-form phase progress. Shape is not part of the contract. + result: + anyOf: + - type: object + propertyNames: + type: string + additionalProperties: {} + - type: "null" + description: "The worker's outcome summary, null until it finishes. Counts only: `perType` (read / inserted / updated / + unchanged / skipped per measurement type), `perApp` (records per Android package, `leftOut` for an app whose + integration the account has connected), `workouts`, `sleep`, `cycle`, `nutrients`, `skipped` (reason → + count), `warnings` (codes for tables or columns the importer could not read), `totals`. Never a value, a + timestamp or text from the file." + failureReason: + anyOf: + - type: string + - type: "null" + description: Why the job failed. Starts with a code the client can key on when the file was refused — + `unsupported_version` (database older than version 9), `not_health_connect` (no `health_connect_export.db`, + or not a Health Connect database), `unsafe_schema` (a record table is a view), `too_large`, + `staging_missing` — else free text. + required: + - jobId + - status + - startedAt + - completedAt + - uploadBytes + - progress + - result + - failureReason + additionalProperties: false CsvImportResponseEnvelope: type: object properties: @@ -67585,6 +69046,127 @@ components: - data - error additionalProperties: false + ListCustomVaccinesEnvelope: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/CustomVaccine" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CustomVaccine: + type: object + properties: + id: + type: string + name: + type: string + components: + type: array + items: + type: string + description: Catalogue antigen slugs the product protects against, only ones this release knows. + typicalSeriesDoses: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + boosterIntervalMonths: + anyOf: + - type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + - type: "null" + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - id + - name + - components + - typicalSeriesDoses + - boosterIntervalMonths + - createdAt + - updatedAt + additionalProperties: false + description: A vaccine the record's owner defined for a product the shipped catalogue does not list, in the shape a + catalogue entry resolves to. A dose logged against it counts into the series of each listed antigen, settles a + booster reminder keyed to one of them and offers one from `boosterIntervalMonths`, exactly as a catalogue pick + does. `name` is the person's own wording. + CreateCustomVaccineEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CustomVaccine" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateCustomVaccineEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CustomVaccine" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteCustomVaccineEnvelope: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false ListVaccinationsEnvelope: type: object properties: @@ -67671,6 +69253,17 @@ components: anyOf: - $ref: "#/components/schemas/VaccinationCatalogEntry" - type: "null" + customVaccineId: + anyOf: + - type: string + - type: "null" + description: The record's own vaccine definition the dose names (v1.42), verbatim. + customVaccine: + anyOf: + - $ref: "#/components/schemas/CustomVaccine" + - type: "null" + description: That definition, resolved, or null when the dose names none or it was removed. When `catalogEntry` is also + set, the catalogue is the answer. series: type: array items: @@ -67709,6 +69302,8 @@ components: - lotNumber - site - catalogEntry + - customVaccineId + - customVaccine - series - practitioner - encounter @@ -69835,6 +71430,65 @@ components: - assistAvailable - contentIndex additionalProperties: false + DocumentsLayoutEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DocumentsLayout" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DocumentsLayout: + type: object + properties: + version: + type: number + const: 1 + view: + type: string + enum: + - cards + - list + description: '`cards` renders the preview-tile grid, `list` one compact row per document. Default "cards".' + arrangement: + type: string + enum: + - stacked + - flow + description: '`stacked` gives every month its own block; `flow` runs the documents across the full width and wraps, with + month names inline. Default "flow"; an account that never chose keeps following the default.' + required: + - version + - view + - arrangement + additionalProperties: false + description: Per-user document vault presentation. Display-only; a client that does not render a vault ignores it. + DocumentsLayoutSavedEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DocumentsLayout" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false DocumentBulkEnvelope: type: object properties: @@ -71470,6 +73124,7 @@ components: - foreground - background - push + - manual description: What triggered a HealthKit batch. - type: "null" lastBackgroundSyncAt: @@ -71486,11 +73141,11 @@ components: description: Present on the GET read; omitted from the PATCH echo. $ref: "#/components/schemas/SyncHealth" metricFreshness: - description: Per-metric-type freshness for the `APPLE_HEALTH` source. Present on the GET read; omitted from the PATCH - echo. + description: Per-metric-type freshness for the `APPLE_HEALTH` source, with the per-type arrival facts. Present on the + GET read; omitted from the PATCH echo. type: array items: - $ref: "#/components/schemas/MetricFreshnessEntry" + $ref: "#/components/schemas/AppleHealthMetricFreshnessEntry" syncProgress: description: Backfill/sync progress summary. Present on the GET read (null when the progress read failed); omitted from the PATCH echo. @@ -71558,7 +73213,7 @@ components: additionalProperties: false description: Server-resolved sync-health verdict. The single source of liveness truth — `lastSyncedAt` alone cannot express 'this pipe stopped delivering'. - MetricFreshnessEntry: + AppleHealthMetricFreshnessEntry: type: object properties: type: @@ -71573,13 +73228,44 @@ components: type: boolean description: This type has gone quiet while the source around it reads healthy — the dead-pipe signature (e.g. a single revoked HealthKit permission). Only ever true when the verdict is `fresh`. + lastReceivedAt: + description: "When a live sync batch last carried this type (inserted, updated or duplicate entries all count). Null + when no live sync has carried it yet: a type that so far came only from the export import, or from before + v1.42. `lastSeenAt` is when the newest sample was TAKEN; this is when the server last RECEIVED the type. + Omitted on the `WORKOUTS` entry, which the ledger does not cover, and on every entry when the ledger read + failed." + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + lastTrigger: + description: The `syncTrigger` of that batch. Null when the client reported none. A type whose trigger is only ever + `manual` reaches the server only through a user-started full sync. Same omission rules as `lastReceivedAt`. + anyOf: + - type: string + enum: + - foreground + - background + - push + - manual + description: What triggered a HealthKit batch. + - type: "null" + lastNewSampleAt: + description: When a live sync batch last brought a new or updated sample of this type, as opposed to duplicates only. + Null when none has. Same omission rules as `lastReceivedAt`. + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" required: - type - lastSeenAt - stale additionalProperties: false - description: Per-metric-type last-seen timestamp with the server-computed staleness flag. Only types that have actually - delivered appear — absence is absence, never an invented row. + description: "Per-type Apple Health freshness: the newest sample's timestamp and staleness flag, plus the per-type + arrival facts recorded by `POST /api/measurements/batch` (v1.42)." AppleHealthSyncProgress: type: object properties: @@ -73447,6 +75133,28 @@ components: entry carries, plus the extras that provider has. The optional fields are not optional per request — each is present exactly for the providers named in its description and absent for the rest, so branch on `integration`, not on presence." + MetricFreshnessEntry: + type: object + properties: + type: + type: string + description: The measurement type (e.g. `RESPIRATORY_RATE`), or `WORKOUTS` for the workout leg. + lastSeenAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: Newest recorded reading for this type from this source. + stale: + type: boolean + description: This type has gone quiet while the source around it reads healthy — the dead-pipe signature (e.g. a single + revoked HealthKit permission). Only ever true when the verdict is `fresh`. + required: + - type + - lastSeenAt + - stale + additionalProperties: false + description: Per-metric-type last-seen timestamp with the server-computed staleness flag. Only types that have actually + delivered appear — absence is absence, never an invented row. FitbitResumeEnvelope: type: object properties: @@ -74490,6 +76198,12 @@ components: - painNrs - waistCircumference - waistToHeight + - scoreHealth + - scoreReadiness + - scoreRecovery + - scoreSleep + - scoreStress + - scoreStrain additionalProperties: type: object properties: @@ -74566,6 +76280,11 @@ components: enum: - score - reminders + todayCardVisible: + description: v1.42 — whether the dashboard shows its top card (the Today card above the tiles). Missing resolves to + `true`; only an explicit `false` hides it. The stored default is omitted. A layout reset (DELETE) shows it + again. Additive. + type: boolean updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque." @@ -74741,6 +76460,12 @@ components: - painNrs - waistCircumference - waistToHeight + - scoreHealth + - scoreReadiness + - scoreRecovery + - scoreSleep + - scoreStress + - scoreStrain additionalProperties: type: object properties: @@ -74817,6 +76542,11 @@ components: enum: - score - reminders + todayCardVisible: + description: v1.42 — whether the dashboard shows its top card (the Today card above the tiles). Missing resolves to + `true`; only an explicit `false` hides it. The stored default is omitted. A layout reset (DELETE) shows it + again. Additive. + type: boolean updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque." @@ -74844,6 +76574,217 @@ components: - data - error additionalProperties: false + ManagedProfileHandoverStatusEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ManagedProfileHandoverStatus" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ManagedProfileHandoverStatus: + type: object + properties: + available: + type: boolean + description: False on an instance that allows single sign-on only, where a handover cannot be created. + open: + anyOf: + - type: object + properties: + id: + type: string + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + expiresAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + createdByYou: + type: boolean + required: + - id + - createdAt + - expiresAt + - createdByYou + additionalProperties: false + - type: "null" + description: "The profile's open link, or null. Never the link itself: only its hash is stored." + required: + - available + - open + additionalProperties: false + ManagedProfileHandoverEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ManagedProfileHandover" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ManagedProfileHandover: + type: object + properties: + id: + type: string + token: + type: string + description: The one-time `hlp_` token. Returned only in this response and never again; only its hash is stored. + url: + type: string + description: "The link to hand over: `/claim/`." + expiresAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - id + - token + - url + - expiresAt + additionalProperties: false + RevokeManagedProfileHandoverEnvelope: + type: object + properties: + data: + type: object + properties: + revoked: + type: boolean + required: + - revoked + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + PendingHandoverDecisionEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/PendingHandoverDecision" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + PendingHandoverDecision: + type: object + properties: + pending: + anyOf: + - type: object + properties: + claimedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + guardians: + type: array + items: + type: object + properties: + grantId: + type: string + description: The guardian's grant at the claim; what a decision names. + displayName: + type: string + proposal: + $ref: "#/components/schemas/ManagedProfileHandoverAccess" + current: + $ref: "#/components/schemas/ManagedProfileHandoverAccess" + decidable: + type: boolean + description: False when the access moved since the claim (the guardian stepped away, or it was changed under shared + access); such a row is shown and left alone. + required: + - grantId + - displayName + - proposal + - current + - decidable + additionalProperties: false + required: + - claimedAt + - guardians + additionalProperties: false + - type: "null" + required: + - pending + additionalProperties: false + ManagedProfileHandoverAccess: + type: string + enum: + - end + - read + - manage + description: "What a guardian's access becomes after the handover: ended (`end`), view only (`read`), or still managing + (`manage`)." + HandoverDecisionEnvelope: + type: object + properties: + data: + type: object + properties: + decided: + type: boolean + const: true + changed: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: How many guardians' access this changed. + required: + - decided + - changed + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false AccountPayloadEnvelope: type: object properties: @@ -74868,6 +76809,33 @@ components: type: string username: type: string + timezone: + type: string + description: "The IANA zone the server cuts this account's days in: the stored zone, or the instance default when the + stored one is unusable. Resolved, never the raw column, so bucket day keys by this value." + glucoseUnit: + anyOf: + - type: string + enum: + - mg/dL + - mmol/L + - type: "null" + description: The account's chosen glucose display unit, as stored. `null` means the person never chose one (or the + stored value is neither unit), and the server then shows glucose in mg/dL. + features: + type: object + properties: + trackIntake: + type: boolean + const: true + description: "Medications take `trackIntake`: a medication can be kept as a record whose doses are never due, and a dose + written against one is refused with `medication.intake.notTracked`." + required: + - trackIntake + additionalProperties: false + description: What this server understands, for a client released apart from it. Constant across accounts; a flag appears + once the server supports the feature and stays `true`. Absent on servers older than v1.42.0. A client that + meets a flag it does not know ignores it. accountAccess: $ref: "#/components/schemas/AccountAccess" moduleAccess: @@ -74899,6 +76867,9 @@ components: required: - id - username + - timezone + - glucoseUnit + - features - accountAccess - moduleAccess - ai @@ -75279,6 +77250,14 @@ components: - not_granted - unavailable description: Why the "vaccinations" module is or is not available here. + timeline: + type: string + enum: + - enabled + - disabled + - not_granted + - unavailable + description: Why the "timeline" module is or is not available here. required: - cycle - mood @@ -75299,6 +77278,7 @@ components: - mentalHealth - nutrients - vaccinations + - timeline additionalProperties: false description: "Why each toggleable module is or is not available for the record this session is inside. `enabled` — the module is on and the client may paint it. `disabled` — the record has it switched off. `not_granted` — the @@ -77608,6 +79588,35 @@ components: description: "The flattened, pre-translated badge the native client renders. Note the differences from the web shape: translated strings instead of keys, `iconName` instead of `icon`, `unlockedAt` instead of `completedAt`, and `progress` as a 0–1 fraction." + EnvironmentPreferencesEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/EnvironmentPreferences" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + EnvironmentPreferences: + type: object + properties: + airQualityEnabled: + type: boolean + operatorDisabled: + type: boolean + description: True when the operator turned air quality off for the whole instance; the account switch then has no effect. + required: + - airQualityEnabled + - operatorDisabled + additionalProperties: false EnvironmentOverviewEnvelope: type: object properties: @@ -77687,12 +79696,202 @@ components: attribution: type: string description: Upstream attribution string. Display it wherever the observations are shown. + airQuality: + type: object + properties: + enabled: + type: boolean + description: The account's switch (`PATCH /api/environment/preferences`). + operatorDisabled: + type: boolean + description: True when the operator turned air quality off for the instance; the account switch then has no effect. + days: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Stored days the air-quality feed returned hourly values for. + latestDate: + anyOf: + - type: string + - type: "null" + description: Newest such day (YYYY-MM-DD). + domain: + anyOf: + - type: string + - type: "null" + description: "The model domain of that day: `cams_europe` (about 11 km, with pollen) or `cams_global` (about 45 km, no + pollen)." + history: + anyOf: + - type: object + properties: + total: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Past days with entries the source can serve at their place (from 2013 in Europe, from August 2022 + elsewhere). Older days stay without values. + done: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Of those, the days whose air quality is stored. + complete: + type: boolean + description: True when nothing the source serves is left. + checkedAt: + type: string + description: ISO instant of the run that counted them. + required: + - total + - done + - complete + - checkedAt + additionalProperties: false + - type: "null" + description: Progress of the background backfill that fills air quality for every past day with entries. Null before its + first run and while air quality is off. + required: + - enabled + - operatorDisabled + - days + - latestDate + - domain + - history + additionalProperties: false + description: v1.42 — the air-quality part of the module. + latestDay: + anyOf: + - type: object + properties: + date: + type: string + description: YYYY-MM-DD of the newest stored day. + tempMin: + anyOf: + - type: number + - type: "null" + tempMax: + anyOf: + - type: number + - type: "null" + apparentMax: + anyOf: + - type: number + - type: "null" + description: Daily feels-like maximum, °C. + airQuality: + anyOf: + - type: object + properties: + pm25Mean: + anyOf: + - type: number + - type: "null" + description: PM2.5 daily mean, µg/m³. + pm10Mean: + anyOf: + - type: number + - type: "null" + description: PM10 daily mean, µg/m³. + no2Mean: + anyOf: + - type: number + - type: "null" + description: NO2 daily mean, µg/m³. + o3Max8h: + anyOf: + - type: number + - type: "null" + description: Ozone, highest 8-hour running mean, µg/m³. + eaqiMax: + anyOf: + - type: number + - type: "null" + description: European air-quality index, daily maximum. + uvIndexMax: + anyOf: + - type: number + - type: "null" + dustMax: + anyOf: + - type: number + - type: "null" + description: Dust, µg/m³. + pollen: + type: object + properties: + alder: + anyOf: + - type: number + - type: "null" + birch: + anyOf: + - type: number + - type: "null" + grass: + anyOf: + - type: number + - type: "null" + mugwort: + anyOf: + - type: number + - type: "null" + olive: + anyOf: + - type: number + - type: "null" + ragweed: + anyOf: + - type: number + - type: "null" + required: + - alder + - birch + - grass + - mugwort + - olive + - ragweed + additionalProperties: false + description: Daily maxima, grains/m³; null where not covered (outside Europe, before mid 2022). + required: + - pm25Mean + - pm10Mean + - no2Mean + - o3Max8h + - eaqiMax + - uvIndexMax + - dustMax + - pollen + additionalProperties: false + - type: "null" + description: Null while air quality is off for the account or the instance, and for a day whose air-quality part was not + fetched yet. Null values inside mean not covered, never zero. + required: + - date + - tempMin + - tempMax + - apparentMax + - airQuality + additionalProperties: false + - type: "null" + description: v1.42 — the newest stored day, usually yesterday or the day before (the archive settles late). No forecast. + Null when nothing is stored. + attributions: + type: array + items: + type: string + description: v1.42 — every attribution line, weather first; the air-quality lines (Open-Meteo, Copernicus) while air + quality is on. Display them wherever the values are shown. required: - home - travel - context - lastFetchFailure - attribution + - airQuality + - latestDay + - attributions additionalProperties: false description: "The module overview: home, travel overrides, a count of stored observations, and whether the background fetch is healthy." @@ -78816,6 +81015,1187 @@ components: - data - error additionalProperties: false + DayIndexEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DayIndex" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DayIndex: + type: object + properties: + from: + type: string + description: A local calendar date, `YYYY-MM-DD`. + to: + type: string + description: A local calendar date, `YYYY-MM-DD`. + days: + type: object + propertyNames: + type: string + additionalProperties: + type: array + items: + type: string + enum: + - values + - sleep + - scores + - mood + - assessments + - medications + - illness + - symptoms + - allergies + - labs + - visits + - vaccinations + - checkups + - documents + - workouts + - cycle + - environment + - lifeEvents + description: Each day in the window that holds anything, keyed `YYYY-MM-DD`, with the sections it holds. Days without + entries are absent. + notable: + type: array + items: + type: string + description: Days in the window that carry a notable observation. + required: + - from + - to + - days + - notable + additionalProperties: false + DayNotableWindowEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DayNotableWindow" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DayNotableWindow: + type: object + properties: + from: + type: string + description: A local calendar date, `YYYY-MM-DD`. + to: + type: string + description: A local calendar date, `YYYY-MM-DD`. + anchor: + type: string + enum: + - lastVisit + - requested + - fallback + description: "Where `from` came from: the last completed visit, the request, or the fallback of 90 days when the record + holds no visit the caller may see." + observations: + type: array + items: + $ref: "#/components/schemas/DatedDayNotable" + changes: + type: array + items: + $ref: "#/components/schemas/DayChange" + description: "Context changes in the window, oldest first: dose changes, medication and course starts and ends, pauses, + illness onsets and recoveries, vaccinations, procedures and lab days." + required: + - from + - to + - anchor + - observations + - changes + additionalProperties: false + DatedDayNotable: + type: object + properties: + date: + type: string + description: A local calendar date, `YYYY-MM-DD`. + kind: + type: string + enum: + - extremeHigh + - extremeLow + - firstValue + - gap + type: + anyOf: + - type: string + - type: "null" + params: + type: object + propertyNames: + type: string + additionalProperties: + anyOf: + - type: string + - type: number + description: Parameters for the message the client words from its own bundle. Dates are `YYYY-MM-DD`, values are numbers + in the unit the entry names. + required: + - date + - kind + - type + - params + additionalProperties: false + DayChange: + type: object + properties: + date: + type: string + description: A local calendar date, `YYYY-MM-DD`. + kind: + type: string + enum: + - intake + - doseChange + - medicationStart + - medicationEnd + - pauseStart + - pauseEnd + - courseStart + - courseEnd + - illnessOnset + - illnessResolved + - illnessDayLog + - symptom + - allergyOnset + - labResult + - visit + - procedure + - vaccination + - checkup + - document + - mood + - assessment + - workout + - cycleDayLog + - lifeEvent + section: + type: string + enum: + - values + - sleep + - scores + - mood + - assessments + - medications + - illness + - symptoms + - allergies + - labs + - visits + - vaccinations + - checkups + - documents + - workouts + - cycle + - environment + - lifeEvents + id: + type: string + title: + type: string + description: The record's own name for it, decrypted for the caller. + count: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + description: Entries folded into this one (lab results of one day). + href: + anyOf: + - type: string + - type: "null" + required: + - date + - kind + - section + - id + - title + - count + - href + additionalProperties: false + DayEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Day" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + Day: + type: object + properties: + date: + type: string + description: A local calendar date, `YYYY-MM-DD`. + tz: + type: string + description: The IANA zone the day was cut in. + counts: + type: object + properties: + values: + type: integer + minimum: 0 + maximum: 9007199254740991 + entries: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - values + - entries + additionalProperties: false + running: + type: array + items: + $ref: "#/components/schemas/DayRunningItem" + values: + type: array + items: + $ref: "#/components/schemas/DayValue" + events: + type: array + items: + $ref: "#/components/schemas/DayEvent" + notable: + type: array + items: + $ref: "#/components/schemas/DayNotable" + scores: + type: array + items: + $ref: "#/components/schemas/DayScore" + description: The scores the record holds for this day, in `DAY_SCORE_KEYS` order. A score with no value that day is + absent; an empty list means none. + sections: + type: object + propertyNames: + type: string + enum: + - values + - sleep + - scores + - mood + - assessments + - medications + - illness + - symptoms + - allergies + - labs + - visits + - vaccinations + - checkups + - documents + - workouts + - cycle + - environment + - lifeEvents + additionalProperties: + type: object + properties: + available: + type: boolean + const: false + reason: + type: string + enum: + - not_shared + - module_disabled + required: + - available + - reason + additionalProperties: false + description: Sections the caller cannot see. A section of a switched-off module is left out entirely rather than listed. + required: + - date + - tz + - counts + - running + - values + - events + - notable + - scores + - sections + additionalProperties: false + DayRunningItem: + type: object + properties: + kind: + type: string + enum: + - medication + - medicationCourse + - medicationPause + - illness + - restMode + - cyclePhase + - travel + - lifeEvent + section: + type: string + enum: + - values + - sleep + - scores + - mood + - assessments + - medications + - illness + - symptoms + - allergies + - labs + - visits + - vaccinations + - checkups + - documents + - workouts + - cycle + - environment + - lifeEvents + id: + type: string + title: + type: string + description: Decrypted server-side for the caller's own session. + sub: + anyOf: + - type: string + - type: "null" + description: A second line, already formatted (a dose, a destination). + since: + anyOf: + - type: string + description: A local calendar date, `YYYY-MM-DD`. + - type: "null" + description: The record's own start date. Null when the record holds none (a medication filed without a start date); the + entry's creation date is never put in its place. + until: + anyOf: + - type: string + description: A local calendar date, `YYYY-MM-DD`. + - type: "null" + description: Null while it is still open. + dayIndex: + anyOf: + - type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + - type: "null" + description: Day n of the period, counting `since` as day 1; null when `since` is null. + dayCount: + anyOf: + - type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + - type: "null" + description: Length of a closed period in days; null while open. + href: + anyOf: + - type: string + - type: "null" + description: In-app path to the record this came from. + required: + - kind + - section + - id + - title + - sub + - since + - until + - dayIndex + - dayCount + - href + additionalProperties: false + DayValue: + type: object + properties: + type: + type: string + description: A `MeasurementType`. + value: + type: number + unit: + type: string + at: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + source: + type: string + description: The `MeasurementSource` of the canonical row. + band: + anyOf: + - type: object + properties: + lo: + type: number + hi: + type: number + n: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - lo + - hi + - n + additionalProperties: false + - type: "null" + description: The person's own usual range over the 30 days before this day, from their daily values. Null with too + little history. + required: + - type + - value + - unit + - at + - source + - band + additionalProperties: false + DayEvent: + type: object + properties: + at: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: The instant, or null for something that has a date only. + kind: + type: string + enum: + - intake + - doseChange + - medicationStart + - medicationEnd + - pauseStart + - pauseEnd + - courseStart + - courseEnd + - illnessOnset + - illnessResolved + - illnessDayLog + - symptom + - allergyOnset + - labResult + - visit + - procedure + - vaccination + - checkup + - document + - mood + - assessment + - workout + - cycleDayLog + - lifeEvent + section: + type: string + enum: + - values + - sleep + - scores + - mood + - assessments + - medications + - illness + - symptoms + - allergies + - labs + - visits + - vaccinations + - checkups + - documents + - workouts + - cycle + - environment + - lifeEvents + id: + type: string + title: + type: string + description: "The record's own name, or a code the client words: a mood, a screener's instrument, a workout's sport, + `cycle` for a cycle day." + meta: + anyOf: + - type: string + - type: "null" + description: "The second line: text (a dose, a lab value, a duration) or a code or number the client words (a category, + a kind, a severity, a screener's score and band, an intensity, a dose number)." + note: + anyOf: + - type: string + - type: "null" + description: The person's own note, decrypted for the caller's session. + docs: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + required: + - id + - name + additionalProperties: false + description: Documents filed against the entry. + href: + anyOf: + - type: string + - type: "null" + required: + - at + - kind + - section + - id + - title + - meta + - note + - docs + - href + additionalProperties: false + DayNotable: + type: object + properties: + kind: + type: string + enum: + - extremeHigh + - extremeLow + - firstValue + - gap + type: + anyOf: + - type: string + - type: "null" + description: The `MeasurementType` it is about. + params: + type: object + propertyNames: + type: string + additionalProperties: + anyOf: + - type: string + - type: number + description: Parameters for the message the client words from its own bundle. Dates are `YYYY-MM-DD`, values are numbers + in the unit the entry names. + required: + - kind + - type + - params + additionalProperties: false + DayScore: + type: object + properties: + key: + type: string + enum: + - healthScore + - readiness + - recovery + - sleepScore + - strain + value: + type: number + description: "The score on its own scale: whole points for a 0 to 100 score, one decimal for a device's day strain." + max: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + description: "The top of the scale: 100, or 21 for a device's day strain." + source: + type: string + description: "`COMPUTED` for a score the server derived, else the `MeasurementSource` of the device row." + band: + anyOf: + - type: object + properties: + lo: + type: number + hi: + type: number + n: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - lo + - hi + - n + additionalProperties: false + - type: "null" + description: The person's own usual range of the score over the 30 days before this day, on the same scale. Null with + too little history. + required: + - key + - value + - max + - source + - band + additionalProperties: false + TimelineEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/Timeline" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + Timeline: + type: object + properties: + zoom: + type: string + enum: + - all + - year + - quarter + - range + range: + type: object + properties: + from: + type: string + description: A local calendar date, `YYYY-MM-DD`. + to: + type: string + description: A local calendar date, `YYYY-MM-DD`. + dataFrom: + anyOf: + - type: string + description: A local calendar date, `YYYY-MM-DD`. + - type: "null" + description: The earliest date anything in the record carries. + required: + - from + - to + - dataFrom + additionalProperties: false + lanes: + type: array + items: + type: object + properties: + key: + type: string + enum: + - life + - illness + - allergies + - medications + - vaccinations + - visits + - labs + - documents + - cycle + items: + type: array + items: + $ref: "#/components/schemas/TimelineItem" + required: + - key + - items + additionalProperties: false + standing: + type: array + items: + $ref: "#/components/schemas/TimelineStandingItem" + description: "Things without a start: shown once as standing." + bucket: + type: string + enum: + - quarter + - month + - week + - day + description: "The span every series point averages: `quarter` for a multi-year `all`, `month` for `year` and an `all` + under two years, `week` for `quarter`, and for a `range` by its length: `quarter` from two years, `month` + from 120 days, `week` from 42 days, `day` below." + series: + type: array + items: + $ref: "#/components/schemas/TimelineSeries" + availableSeries: + type: array + items: + type: string + description: "Every value series this record can show, whatever the window: the `MeasurementType` names with live + readings that the caller may see (events excluded), and `MOOD` when the mood score is visible and has + entries. Each may be named in `values`." + notable: + type: array + items: + type: object + properties: + date: + type: string + description: A local calendar date, `YYYY-MM-DD`. + kind: + type: string + enum: + - extremeHigh + - extremeLow + - firstValue + - gap + required: + - date + - kind + additionalProperties: false + required: + - zoom + - range + - lanes + - standing + - bucket + - series + - availableSeries + - notable + additionalProperties: false + TimelineItem: + type: object + properties: + id: + type: string + kind: + type: string + enum: + - lifeEvent + - travel + - episode + - chronic + - allergy + - medication + - course + - doseChange + - pause + - vaccination + - visit + - procedure + - labDay + - document + - cycle + start: + type: string + description: A local calendar date, `YYYY-MM-DD`. + end: + anyOf: + - type: string + description: A local calendar date, `YYYY-MM-DD`. + - type: "null" + description: Null for a point or an open span. + open: + type: boolean + description: A span that has not ended. + precision: + type: string + enum: + - DAY + - MONTH + - YEAR + startKnown: + type: boolean + description: False when the start is a stand-in (a medication without a start date starts at its first intake). + label: + type: string + description: The item's name. A trip is labelled `travel` and a cycle `cycle`; the client words both. + sub: + anyOf: + - type: string + - type: "null" + description: "The second line, by kind: a code for a life event (its category), a visit or procedure (its encounter + kind) and a document (its kind); the analyte count for a lab day; the dose as text for a medication or a + dose change. The client words every code." + href: + anyOf: + - type: string + - type: "null" + group: + anyOf: + - type: string + - type: "null" + description: "Items that belong to one thing share a group and are drawn on one row: a medication's own span, its + courses, its dose changes and its pauses all carry the medication's id. Null for an item that stands alone." + required: + - id + - kind + - start + - end + - open + - precision + - startKnown + - label + - sub + - href + - group + additionalProperties: false + TimelineStandingItem: + type: object + properties: + lane: + type: string + enum: + - life + - illness + - allergies + - medications + - vaccinations + - visits + - labs + - documents + - cycle + id: + type: string + label: + type: string + since: + anyOf: + - type: string + description: A local calendar date, `YYYY-MM-DD`. + - type: "null" + href: + anyOf: + - type: string + - type: "null" + required: + - lane + - id + - label + - since + - href + additionalProperties: false + TimelineSeries: + type: object + properties: + key: + type: string + description: A `MeasurementType` or `MOOD`. + unit: + anyOf: + - type: string + - type: "null" + points: + type: array + items: + type: object + properties: + t: + type: string + description: The bucket's first day. + mean: + type: number + description: The mean of the bucket's day values, each day weighing one (a cumulative type's days are its day totals, a + pulse-like type's its hourly-mean day value). + count: + type: integer + minimum: 1 + maximum: 9007199254740991 + description: Readings behind the mean. Clients draw a point from fewer than three as thin. + required: + - t + - mean + - count + additionalProperties: false + description: Buckets with at least one reading, oldest first. A bucket without one is absent, never interpolated. + required: + - key + - unit + - points + additionalProperties: false + TimelineReadinessEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/TimelineReadiness" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + TimelineReadiness: + type: object + properties: + verdict: + type: string + enum: + - carries + - thin + since: + anyOf: + - type: string + description: A local calendar date, `YYYY-MM-DD`. + - type: "null" + description: The earliest date the carrying lanes reach back to. + lanes: + type: array + items: + type: object + properties: + key: + type: string + enum: + - values + - life + - illness + - allergies + - medications + - vaccinations + - visits + - labs + - documents + - cycle + - mood + - environment + status: + type: string + enum: + - carries + - thin + - empty + count: + type: integer + minimum: 0 + maximum: 9007199254740991 + detail: + anyOf: + - type: object + properties: + key: + type: string + params: + type: object + propertyNames: + type: string + additionalProperties: + anyOf: + - type: string + - type: number + description: Parameters for the message the client words from its own bundle. Dates are `YYYY-MM-DD`, values are numbers + in the unit the entry names. + required: + - key + - params + additionalProperties: false + - type: "null" + description: Worded by the client from `timeline.readiness.detail`. + gaps: + type: array + items: + type: object + properties: + key: + type: string + count: + type: integer + minimum: 0 + maximum: 9007199254740991 + href: + type: string + required: + - key + - count + - href + additionalProperties: false + required: + - key + - status + - count + - detail + - gaps + additionalProperties: false + required: + - verdict + - since + - lanes + additionalProperties: false + LifeEventListEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/LifeEventList" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + LifeEventList: + type: object + properties: + events: + type: array + items: + $ref: "#/components/schemas/LifeEvent" + required: + - events + additionalProperties: false + LifeEvent: + type: object + properties: + id: + type: string + category: + type: string + enum: + - FAMILY + - HOME + - WORK + - LOSS + - OTHER + startDate: + type: string + description: A local calendar date, `YYYY-MM-DD`. + endDate: + anyOf: + - type: string + description: A local calendar date, `YYYY-MM-DD`. + - type: "null" + precision: + type: string + enum: + - DAY + - MONTH + - YEAR + title: + anyOf: + - type: string + - type: "null" + description: Decrypted; null only when the stored title cannot be read. + note: + anyOf: + - type: string + - type: "null" + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: + - id + - category + - startDate + - endDate + - precision + - title + - note + - createdAt + - updatedAt + additionalProperties: false + description: A dated anchor in the person's life. Shared under the `profile` domain; never sent to a model. + CreateLifeEventEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/LifeEvent" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateLifeEventEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/LifeEvent" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteLifeEventEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/LifeEvent" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false __schema0: anyOf: - type: string @@ -79588,6 +82968,24 @@ components: - weight additionalProperties: false - type: "null" + device: + description: STRAIN only — present when no computed strain score exists in the window and the score is served from the + device's own DAY_STRAIN instead. `score` is then `value` as a 0–100 share of `scaleMax`. Absent when the + computed proxy is the source. + anyOf: + - type: object + properties: + value: + type: number + description: The device's latest day strain on its own scale. + scaleMax: + type: number + description: Top of the device scale (21 for WHOOP day strain). + required: + - value + - scaleMax + additionalProperties: false + - type: "null" required: - score - band diff --git a/docs/integrations/apple-health.md b/docs/integrations/apple-health.md index 456dd96c3..dc7a3d615 100644 --- a/docs/integrations/apple-health.md +++ b/docs/integrations/apple-health.md @@ -120,6 +120,14 @@ The HK quantity types that map to HealthLog metric types live in mapped set (for example workout routes) is counted as unsupported and not stored. +Heart-rate variability comes in two measures, and they stay two types. +`HeartRateVariabilitySDNN` becomes `HEART_RATE_VARIABILITY`; +`HeartRateVariabilityRMSSD`, which iOS and watchOS 27 added, becomes +`HRV_RMSSD`, the same type WHOOP, Oura and Polar write. RMSSD readings +up to 300 ms are accepted. When both arrive, the HRV page charts each on +its own and the Coach reads them apart, because one is not a stand-in for +the other. + ### ECG archive safety, privacy, and idempotency ECG support is source-agnostic: if HealthKit includes a valid @@ -284,6 +292,9 @@ Concrete consequences: source tag — the lowest rank — is reserved for legacy CSV/JSON imports that pre-date the Apple Health passthrough. +RMSSD HRV has its own ladder, **WHOOP ≻ OURA ≻ POLAR ≻ APPLE_HEALTH**, +separate from the SDNN one. + Override per-user via Settings → Sources (`/settings/sources`). ## Failure modes diff --git a/docs/integrations/health-connect.md b/docs/integrations/health-connect.md new file mode 100644 index 000000000..df370ea98 --- /dev/null +++ b/docs/integrations/health-connect.md @@ -0,0 +1,166 @@ +# Health Connect import + +HealthLog reads the export file of Android's Health Connect and folds +it into the same timeline as every other source. There is no Android +app and no live sync: you export from the phone, upload the file, and +a background job imports it. Rows from this path carry the source +`HEALTH_CONNECT`. + +## What you need + +- An Android phone with Health Connect and its export feature. The + export arrived with Android 15 and reaches Android 14 through a + system update; if you do not see it, update Health Connect first. +- A cloud storage app on the phone (Google Drive, Dropbox, Nextcloud + or any other app that offers itself as a storage location). Health + Connect refuses to save its export to the phone's own storage or the + Downloads folder, so the file always goes through a cloud first. +- A HealthLog account in a browser. The upload uses your normal + session; there is no token to create. + +## 1. Export on the phone + +1. Open Health Connect (in the system settings, or the Health Connect + app). +2. Go to **Manage data**, then **Export and import**, then + **Scheduled export** or **Export now**, depending on the version. +3. Pick the cloud storage app as the destination. You can choose how + often Health Connect exports again; HealthLog only ever reads the + file you upload. + +The result is a ZIP file named `Health Connect.zip` with a single +entry, `health_connect_export.db`. That entry is a copy of the phone's +Health Connect database (a SQLite file). Health Connect does not +encrypt it, so treat the file like any other export of your health +data. + +## 2. Upload to HealthLog + +Download the ZIP from the cloud to the computer or phone you use +HealthLog on, open **Settings → Export & Import** and drop the file +onto the **Health Connect export** card. + +The endpoint is `POST /api/import/health-connect-export` +(`multipart/form-data`, field `file`). It: + +1. Allows three uploads per minute per account. +2. Refuses anything above **1 GiB**, once against the declared + `Content-Length` and again while the body streams to disk. +3. Streams the body to a temp file and hashes it with SHA-256 on the + way. Uploading the same bytes again returns the earlier job + (`idempotent: true`) instead of importing twice; a failed job never + blocks a retry. +4. Runs one import per account at a time, Apple Health and Health + Connect together. A second upload while one runs answers `409` and + the file is discarded. +5. Records the upload's size and hash in the audit log, and nothing + from inside the file. + +`GET /api/import/health-connect-export/status` answers the account's +latest Health Connect job, or `null`. The card polls it while a job +runs. + +## 3. What the import does + +The worker extracts `health_connect_export.db` (at most 4 GiB, with +the same zip-bomb checks as the Apple Health importer) and opens it +read-only. The file is treated as untrusted input: + +- It is opened with SQLite's `immutable` flag, so a database last + written in WAL mode opens without its `-wal` and `-shm` files, and + nothing is ever written next to it. +- Views, triggers and generated columns in the file cannot call + functions with side effects (`trusted_schema=OFF`), and a view in + place of one of the record tables refuses the whole file. +- Columns are looked up by name. Health Connect adds columns as its + schema version moves, so their order differs between phones. + Database versions below 9 are refused; versions above 27 (the newest + known when this was written) are read, with a warning on the job. + A table that lacks a column the importer needs is skipped and named + in the job's `warnings`. + +### What becomes what + +| Health Connect | HealthLog | +| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| Weight, lean body mass (grams) | `WEIGHT`, `LEAN_BODY_MASS` in kg | +| Body fat | `BODY_FAT` in % | +| Blood pressure | `BLOOD_PRESSURE_SYS` and `BLOOD_PRESSURE_DIA`, one pair per reading | +| Resting heart rate, heart-rate variability (RMSSD) | `RESTING_HEART_RATE`, `HRV_RMSSD` | +| Oxygen saturation, respiratory rate, body temperature, VO2 max | the matching types | +| Blood glucose (mmol/L) | `BLOOD_GLUCOSE` in mg/dL, fasting and after-meal context kept | +| Heart rate | per minute for the last 90 days; older history as hourly means with the hour's minimum and maximum | +| Steps, active calories, distance | one total per day | +| Sleep sessions and stages | one row per stage; light sleep becomes core sleep, both awake stages awake time; a session without stages counts as asleep | +| Exercise sessions | workouts, with the Health Connect exercise type kept on the workout | +| Menstruation flow and periods | cycle day logs (needs cycle tracking switched on) | +| Hydration and vitamins and minerals | daily nutrient totals (needs the Nutrients module) | + +Left out on purpose: food energy and macronutrients, exercise routes, +planned workouts, Health Connect's access logs, the "out of bed" sleep +stage, and an "unknown" sleep stage inside a session that also has real +stages. + +### When several apps write the same thing + +The export holds every app's data side by side. Summing them would +count a step the phone and the watch both saw twice, so: + +- A day total (steps, calories, distance, water, nutrients) comes from + one app per day: the first one your Health Connect app priority + list names for that category, otherwise the app with the largest + total. +- Heart rate comes from one app per hour, by the same rule. +- Of two overlapping sleep sessions or workouts from different apps, + the one from the higher-ranked app is kept. +- Two apps writing the same type at the same instant keep the + higher-ranked app's value. + +### Apps HealthLog already connects to + +If your account has the Withings, Fitbit, Google Health, Oura, Polar +or WHOOP integration connected, that app's records in the export are +left out: HealthLog already receives them directly, and importing them +again would put every reading in twice. As a further safety net, a +reading that matches one already stored under another source (same +type and value within two seconds) is left out too. The job result +lists how many records each app contributed and which apps were left +out. + +### Importing again + +Every row is keyed by the record's Health Connect ID (`hc:`), +day totals and hourly means by their `stats:` ID. Importing the same +export again writes nothing; a newer export that overlaps an older one +adds only what is new. A value that changed on the phone is updated; +a value you deleted in HealthLog stays deleted. + +Heart rate imported per minute ages the way Apple Health heart rate +does: once a minute is older than 90 days, the nightly pass folds its +hour into one hourly mean and removes the minutes. A later import of +an export that still holds those minutes leaves them out, so an hour +is never counted twice. + +## Privacy + +- The uploaded ZIP and the extracted database are deleted as soon as + the job ends, whether it succeeded or failed. A periodic sweep + removes anything a crash left behind. +- The job result holds counts only: per measurement type and per app. + No value, no timestamp and no text from the file (notes, titles) + is stored on the job. + +## Operator notes + +- The worker uses Node's built-in `node:sqlite` module. It needs no + flag on Node 22 and prints one `ExperimentalWarning` per process. +- Memory stays bounded: the database is read in batches of 500 rows + and aggregated inside SQLite, so what the import keeps alive does not + grow with the length of the history. Measured under a 792 MB heap, + one and three years of minute-by-minute heart rate peaked at about + 160 MB of heap in total. +- Split web and worker containers need a shared staging directory, + exactly as for the Apple Health import. +- The status of a running job shows up under **Settings → Export & + Import**; a job interrupted by a restart is marked failed by the + same reconcile pass that watches Apple Health imports. diff --git a/docs/ops/measurement-maintenance.md b/docs/ops/measurement-maintenance.md new file mode 100644 index 000000000..aee952f08 --- /dev/null +++ b/docs/ops/measurement-maintenance.md @@ -0,0 +1,155 @@ +# Measurement table maintenance + +Since v1.42 the nightly folds of Apple Health data delete the raw samples they +fold into hourly or daily rows, instead of keeping them as deleted rows for 75 +days. On an instance that has synced Apple Health for a while, those leftovers +("compaction tombstones") were most of the `measurements` table and of its +indexes. Three pieces deal with them, in this order: + +1. **The fold repair** runs by itself, once per account, before anything is + deleted. Up to v1.42 the folds could fold a day in two runs, and the + second run stored the mean of the later part of the day only. The repair + recomputes those hourly and daily means from all of the day's samples, + including the compaction tombstones, and corrects a stored mean only when + it differs. It does so only where every sample of the day is provably + still in the table: the last 75 days for daily means, and for hourly + means (folded 90 days late) the 75 days before that. Older days stay as + they are, because the deleted rows of their samples have passed the + 75-day retention and what is left can be a fragment of the day. When it + has finished an account it records that in `measurement_fold_repairs`. +2. **The backlog purge** runs by itself, after the repair. Every worker boot queues one + `compaction-tombstone-purge` job. It deletes the leftovers 5,000 rows at a + time, one account at a time, pausing briefly between batches, and stops + after 40 batches; a run that stopped with work left queues the next one a + minute later. Rows a person deleted are never touched by it: they keep the + 75-day retention of the nightly tombstone cleanup. The purge leaves an + account alone until the repair has finished it, and a boot queues no purge + run while the repair has finished no account. +3. **The table maintenance** is yours to start, after the purge. It runs + `VACUUM (ANALYZE)` on `measurements` and then rebuilds each of its indexes + with `REINDEX INDEX CONCURRENTLY`, largest first. The purge leaves the + indexes full of empty pages, and only a rebuild gives that space back. + +## Repair before purge + +The order matters because the purge deletes what the repair reads. Do not +delete compaction tombstones by hand, and do not insert rows into +`measurement_fold_repairs` to hurry the purge along: a mean the repair has not +seen keeps its wrong value for good once its tombstones are gone. + +The repair reports every run on the wide event `job.measurement_fold_repair` +(`fold_repair_means_checked`, `fold_repair_means_corrected`, +`fold_repair_resting_corrected`, `fold_repair_skipped_beyond_horizon` for the +older windows it left as they are, `fold_repair_samples_absorbed` for live +samples it took into a checked mean, and `fold_repair_by_type` with the same +counts per type; counts only, never a value). A second run over an account +corrects nothing. It is finished when every account has a row: + +```sql +SELECT count(*) AS accounts_left +FROM users u +LEFT JOIN measurement_fold_repairs r ON r.user_id = u.id +WHERE r.user_id IS NULL; +``` + +Each run works on one account, a day at a time, and stops on its time budget +with a follow-up that resumes at the next day; an account under restore is +tried again five minutes later. While accounts are left, purge runs report +them as `compaction_purge_awaiting_repair_accounts`. + +A restore of a backup exported before the account's repair finished writes +the compaction tombstones back, removes the account's row and queues the +repair again; the purge follows once the repair is through. + +## Is the purge finished? + +The purge reports every run on the wide event `job.compaction_tombstone_purge` +(`compaction_purge_deleted`, `compaction_purge_drained`, +`compaction_purge_deferred_accounts`). It is finished when a run reports +`drained: true` and deletes nothing. From `psql`: + +```sql +SELECT state, count(*) +FROM pgboss.job +WHERE name = 'compaction-tombstone-purge' +GROUP BY state; +``` + +No `created`, `retry` or `active` rows means nothing is queued or running. The +maintenance refuses to start while one is. + +## Before you start + +- **Pick a quiet window.** Reads and writes keep working during the rebuild, + but each rebuild reads the whole table twice and writes a full new copy of + the index. +- **Free disk.** A concurrent rebuild needs room for one more copy of the + index it is rebuilding, plus the WAL it writes. Check the largest index: + + ```sql + SELECT c.relname, pg_size_pretty(pg_relation_size(c.oid)) + FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid + WHERE i.indrelid = 'measurements'::regclass + ORDER BY pg_relation_size(c.oid) DESC + LIMIT 3; + ``` + + 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. + +## Starting it + +In the admin area, or with a signed-in admin session: + +```sh +curl -X POST https:///api/admin/maintenance/measurements \ + -H 'content-type: application/json' \ + --cookie 'healthlog_session=' \ + -d '{"vacuum": true, "reindex": true}' +``` + +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 +answers `503`. + +Either step can be left out (`"reindex": false` runs only the vacuum). + +## What it does, and what stops it + +The run uses a connection of its own: no statement timeout (a rebuild of a +large index takes longer than the app's 60 seconds), `maintenance_work_mem` +of 128 MB for this session only, and a lock timeout of five minutes, so a +rebuild that cannot get its lock gives up instead of holding writers behind +it. + +- It refuses to start while the purge is queued or running. +- Before the first rebuild it drops any invalid `_ccnew` copy an + interrupted earlier run left behind. +- If a rebuild fails, most likely for lack of disk, it drops that rebuild's + half-built copy at once and stops. The remaining indexes keep working as + they are. Free some space and start it again; finished indexes are simply + rebuilt once more. +- One run at a time. It is never retried automatically. + +## Reading the result + +The run's wide event `job.measurement_maintenance` carries +`maintenance_outcome` (`completed`, `refused_purge_running`, `failed`), the +table and index sizes before and after, and `maintenance_steps`, one entry +per step with its target, size before and after, and duration. The same +sizes from `psql`: + +```sql +SELECT pg_size_pretty(pg_total_relation_size('measurements')) AS total, + pg_size_pretty(pg_indexes_size('measurements')) AS indexes; +``` + +## Not part of this + +`VACUUM FULL` would shrink the table file as well, but it locks the table for +reads and writes while it runs. After the purge the remaining table is small +and the space inside it is reused by new rows, so it is not needed. If you +want it anyway, run it yourself in a maintenance window with the app stopped. diff --git a/docs/ops/query-statistics.md b/docs/ops/query-statistics.md new file mode 100644 index 000000000..6689898af --- /dev/null +++ b/docs/ops/query-statistics.md @@ -0,0 +1,29 @@ +# Query statistics (pg_stat_statements) + +From v1.42 the bundled database loads the `pg_stat_statements` library, so the time spent in each query shape can be read from the database itself. Loading the library only collects statistics in shared memory; nothing is sent anywhere. + +## Enable the view + +The library is loaded through the `command:` of the `db` service in `docker-compose.yml`. The first `docker compose up` after updating restarts the database once. Then create the extension once: + +```sh +docker compose exec db psql -U healthlog -d healthlog -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;" +``` + +## Read it + +The slowest query shapes by total time: + +```sh +docker compose exec db psql -U healthlog -d healthlog -c "SELECT calls, round(total_exec_time) AS total_ms, round(mean_exec_time, 1) AS mean_ms, left(query, 120) AS query FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 15;" +``` + +The statements are stored with their parameters replaced by placeholders, so values from your records do not appear in the view. + +## Reset + +```sh +docker compose exec db psql -U healthlog -d healthlog -c "SELECT pg_stat_statements_reset();" +``` + +If you run your own PostgreSQL instead of the bundled one, add `pg_stat_statements` to `shared_preload_libraries` in its configuration and restart it before creating the extension. diff --git a/docs/releases/v1.42.0/air-quality-dark.png b/docs/releases/v1.42.0/air-quality-dark.png new file mode 100644 index 000000000..99b0d62aa Binary files /dev/null and b/docs/releases/v1.42.0/air-quality-dark.png differ diff --git a/docs/releases/v1.42.0/air-quality-light.png b/docs/releases/v1.42.0/air-quality-light.png new file mode 100644 index 000000000..68a045126 Binary files /dev/null and b/docs/releases/v1.42.0/air-quality-light.png differ diff --git a/docs/releases/v1.42.0/coach-day-dark.png b/docs/releases/v1.42.0/coach-day-dark.png new file mode 100644 index 000000000..5ee7e0484 Binary files /dev/null and b/docs/releases/v1.42.0/coach-day-dark.png differ diff --git a/docs/releases/v1.42.0/coach-day-light.png b/docs/releases/v1.42.0/coach-day-light.png new file mode 100644 index 000000000..fd55f1417 Binary files /dev/null and b/docs/releases/v1.42.0/coach-day-light.png differ diff --git a/docs/releases/v1.42.0/day-view-dark.png b/docs/releases/v1.42.0/day-view-dark.png new file mode 100644 index 000000000..f5ea90fcc Binary files /dev/null and b/docs/releases/v1.42.0/day-view-dark.png differ diff --git a/docs/releases/v1.42.0/day-view-light.png b/docs/releases/v1.42.0/day-view-light.png new file mode 100644 index 000000000..8d031c2a7 Binary files /dev/null and b/docs/releases/v1.42.0/day-view-light.png differ diff --git a/docs/releases/v1.42.0/day-view-phone-dark.png b/docs/releases/v1.42.0/day-view-phone-dark.png new file mode 100644 index 000000000..569302459 Binary files /dev/null and b/docs/releases/v1.42.0/day-view-phone-dark.png differ diff --git a/docs/releases/v1.42.0/day-view-phone-light.png b/docs/releases/v1.42.0/day-view-phone-light.png new file mode 100644 index 000000000..b38da4d23 Binary files /dev/null and b/docs/releases/v1.42.0/day-view-phone-light.png differ diff --git a/docs/releases/v1.42.0/health-score-dark.png b/docs/releases/v1.42.0/health-score-dark.png new file mode 100644 index 000000000..182c9abd9 Binary files /dev/null and b/docs/releases/v1.42.0/health-score-dark.png differ diff --git a/docs/releases/v1.42.0/health-score-light.png b/docs/releases/v1.42.0/health-score-light.png new file mode 100644 index 000000000..50a22a730 Binary files /dev/null and b/docs/releases/v1.42.0/health-score-light.png differ diff --git a/docs/releases/v1.42.0/palette-dark.png b/docs/releases/v1.42.0/palette-dark.png new file mode 100644 index 000000000..813dab5d4 Binary files /dev/null and b/docs/releases/v1.42.0/palette-dark.png differ diff --git a/docs/releases/v1.42.0/palette-light.png b/docs/releases/v1.42.0/palette-light.png new file mode 100644 index 000000000..8f0d90d25 Binary files /dev/null and b/docs/releases/v1.42.0/palette-light.png differ diff --git a/docs/releases/v1.42.0/timeline-dark.png b/docs/releases/v1.42.0/timeline-dark.png new file mode 100644 index 000000000..d7118bb20 Binary files /dev/null and b/docs/releases/v1.42.0/timeline-dark.png differ diff --git a/docs/releases/v1.42.0/timeline-light.png b/docs/releases/v1.42.0/timeline-light.png new file mode 100644 index 000000000..606d2e51f Binary files /dev/null and b/docs/releases/v1.42.0/timeline-light.png differ diff --git a/docs/releases/v1.42.0/timeline-phone-dark.png b/docs/releases/v1.42.0/timeline-phone-dark.png new file mode 100644 index 000000000..7285d4c87 Binary files /dev/null and b/docs/releases/v1.42.0/timeline-phone-dark.png differ diff --git a/docs/releases/v1.42.0/timeline-phone-light.png b/docs/releases/v1.42.0/timeline-phone-light.png new file mode 100644 index 000000000..3ca842604 Binary files /dev/null and b/docs/releases/v1.42.0/timeline-phone-light.png differ diff --git a/docs/releases/v1.42.0/timeline-readiness-dark.png b/docs/releases/v1.42.0/timeline-readiness-dark.png new file mode 100644 index 000000000..9ea78b0fc Binary files /dev/null and b/docs/releases/v1.42.0/timeline-readiness-dark.png differ diff --git a/docs/releases/v1.42.0/timeline-readiness-light.png b/docs/releases/v1.42.0/timeline-readiness-light.png new file mode 100644 index 000000000..b37f0c254 Binary files /dev/null and b/docs/releases/v1.42.0/timeline-readiness-light.png differ diff --git a/docs/self-hosting/environment.md b/docs/self-hosting/environment.md new file mode 100644 index 000000000..51dbb6d3d --- /dev/null +++ b/docs/self-hosting/environment.md @@ -0,0 +1,110 @@ +# Weather, air quality and pollen + +The environment module stores, for each day, the weather and the outdoor air +at a coarse location: temperature, sunshine, daylight, rain, pressure, +humidity, and since v1.42 fine particles, ozone, nitrogen dioxide, the +European and US air-quality indices, UV, Saharan dust and six kinds of +pollen. Those days sit beside the person's own data: the correlations look +for patterns between them and sleep, heart-rate variability, blood pressure, +mood and symptoms, the Coach and connected MCP clients can read them, and the +dashboard shows a quiet note on a day with high pollen, a warm night or very +poor air. + +The module is on by default. Nothing leaves the host until a person sets a +home location in Settings → Location & weather. + +## What leaves the host + +Three hosted Open-Meteo APIs, all keyless: + +| Host | What for | Variable to self-host it | +| -------------------------------- | ------------------------------------ | --------------------------- | +| `archive-api.open-meteo.com` | daily weather (ERA5 reanalysis) | `OPENMETEO_BASE_URL` | +| `air-quality-api.open-meteo.com` | hourly air quality and pollen (CAMS) | `OPENMETEO_AIR_QUALITY_URL` | +| `geocoding-api.open-meteo.com` | the city search in settings | `OPENMETEO_GEOCODING_URL` | + +A request carries the coordinates rounded to one decimal (about 11 km, the +size of a town), a date range and a timezone. No account id, no name, no +health value. Open-Meteo is open source; an operator who wants no egress at +all can run it and point the three variables at their own instance (a host +on the LAN is allowed). + +## The data, and what it is not + +- Weather comes from the ERA5 reanalysis on a grid of 9 to 25 kilometres. It + settles over a few days, so the newest stored day is usually yesterday or + the day before. Nothing here is a forecast. +- Air quality comes from the CAMS models: hourly on about 11 kilometres in + Europe, about 45 kilometres elsewhere. The feed starts in 2013; pollen, UV, + dust and aerosol depth only from about mid 2022, and pollen only in Europe. + A value the feed did not cover is stored as empty, never as zero. +- Hourly values become a day by fixed rules: means and maxima only for a day + with at least 18 of 24 hours, ozone as the highest 8-hour running mean, + UV only when the midday hours are all there. +- These are modelled outdoor conditions for an area, not anyone's personal + exposure. Every surface that shows them says so, and none of them says the + weather caused anything: a pattern is described as something that occurred + together. + +## Switches + +- **Per account:** Settings → Location & weather → Air quality and pollen. + On by default. Off stops the air-quality requests for that account and + leaves the air-quality values out of every surface; the days already + stored keep theirs. +- **For the whole instance:** `ENVIRONMENT_AIR_QUALITY_DISABLED=1` turns the + air-quality part off for every account (`1`, `true`, `yes` or `on`). The + weather keeps working. Settings then says the operator turned it off. +- The whole module is a normal module switch (Settings → Modules), and an + operator can make it unavailable like any other module. + +All four variables are on the compose `environment:` whitelist. + +## Request budget + +The hosted APIs are free for non-commercial use up to 600 calls a minute, +5,000 an hour and 10,000 a day per instance, where a request for many +variables or many days counts as several calls. HealthLog keeps its own +counter in Postgres, shared by the web process and the worker, and stays +below 500 a minute, 4,000 an hour and 8,000 a day. A request the counter +refuses is not sent; the nightly run ends early and continues the next night. + +For scale: a full two-year backfill of one account is about 150 calls, the +nightly refresh about 3 per account. + +## Nightly fetch and catching up + +The `environment-fetch` job runs at 02:10. For each account with a home it +refreshes the last seven days (weather and air quality together), then fills +up to 90 stored days that do not have air quality yet, newest first. An +instance that upgrades to v1.42 with two years of weather per account +catches up on air quality in about nine nights without any action. The +backfill button in settings fetches both for the range it is given. + +## Locations are encrypted + +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 +in-app rotation) covers all three. + +What the person sees is unchanged: the settings page and +`GET /api/environment` answer with the readable location, opened on the +server. The Coach and MCP read (`get_environment`) carry no coordinate and no +place name at all, only how many days were spent away from home. + +Backups follow the usual split: a portable export carries the location +readable, a disaster-recovery backup carries it sealed, and a restore of +either seals it under the receiving host's key. + +## Attribution + +Open-Meteo data is CC BY 4.0, and CAMS data asks for the notice "Contains +modified Copernicus Atmosphere Monitoring Service information" (modified, +because the hourly values are folded into days). The settings page lists +both lines under the values, and the Coach and MCP read return them with +every result. diff --git a/docs/self-hosting/scaling.md b/docs/self-hosting/scaling.md index 6776d44a3..2512ecad9 100644 --- a/docs/self-hosting/scaling.md +++ b/docs/self-hosting/scaling.md @@ -188,6 +188,17 @@ already`. Either raise `max_connections` on the Postgres side, lower `DATABASE_POOL_MAX`, or front the database with PgBouncer in transaction-pooling mode. +The app sets its statement timeout and `work_mem` on every connection +through the libpq `options` startup parameter. PgBouncer refuses that +parameter (`unsupported startup parameter: options`) unless it is listed +in `ignore_startup_parameters`. Either list it there, or set +`DATABASE_SESSION_OPTIONS_DISABLED=1` so the app sends no session +settings; set the timeout and `work_mem` on the database role instead +(`ALTER ROLE healthlog SET work_mem = '16MB'`) if you still want them. +The measurement maintenance job (Admin, System status) then applies its own +settings with `SET` after connecting. Its concurrent index rebuild needs a +session, so run it through a session-mode pool or a direct connection. + ### Overriding the per-container pool ceiling With the bundled compose, set `DB_CONNECTION_LIMIT` in `.env`; it diff --git a/e2e/coach-activity.spec.ts b/e2e/coach-activity.spec.ts index 3b7338c6d..b48b92f6b 100644 --- a/e2e/coach-activity.spec.ts +++ b/e2e/coach-activity.spec.ts @@ -33,7 +33,7 @@ import { * 3. Nothing opens by itself: the row stays closed (`aria-expanded` * false) in every state, after the answer and after a reload; a row * opened during the run closes when the answer is done. - * 4. After the answer the row reads "Thought process · 4 steps" and the + * 4. After the answer the row reads "Thought process (4 steps)" and the * table the answer points at renders as its chart. * 5. A tap opens the steps; a reasoning round shows its summary under * its title. @@ -435,7 +435,7 @@ test.describe("Coach live trail", () => { await expect(settled).toHaveAttribute("data-state", "done"); await expect( settled.locator('[data-slot="coach-turn-steps-done"]'), - ).toHaveText("Thought process · 4 steps"); + ).toHaveText("Thought process (4 steps)"); await expect(settled.locator(".text-shimmer")).toHaveCount(0); const settledToggle = settled.locator( '[data-slot="coach-turn-steps-toggle"]', @@ -506,7 +506,7 @@ test.describe("Coach live trail", () => { await expect(reloaded).toHaveAttribute("data-state", "done"); await expect( reloaded.locator('[data-slot="coach-turn-steps-done"]'), - ).toHaveText("Thought process · 4 steps"); + ).toHaveText("Thought process (4 steps)"); const reloadedToggle = reloaded.locator( '[data-slot="coach-turn-steps-toggle"]', ); diff --git a/e2e/coach-conversation-delete.spec.ts b/e2e/coach-conversation-delete.spec.ts new file mode 100644 index 000000000..03b544dce --- /dev/null +++ b/e2e/coach-conversation-delete.spec.ts @@ -0,0 +1,188 @@ +import { expect, test } from "./setup/test"; +import type { Route } from "@playwright/test"; +import { randomBytes } from "node:crypto"; +import { setTimeout as delay } from "node:timers/promises"; +import { hash } from "@node-rs/argon2"; +import pg from "pg"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; + +const KEEP_ID = "conversation-delete-keep"; +const DROP_ID = "conversation-delete-drop"; + +function row(id: string, title: string) { + return { + id, + title, + createdAt: "2026-07-20T10:00:00.000Z", + updatedAt: "2026-07-20T10:00:00.000Z", + messageCount: 2, + fenced: false, + attachments: [], + documentTitle: null, + }; +} + +/** + * A deleted conversation must stay deleted. The delete waits out an undo + * window before it is sent; a reload inside that window used to drop it, and + * once the window ran out the row showed again until the refetch landed. + */ +test.describe("Coach conversation delete", () => { + test.beforeEach(async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + }); + + test.use({ storageState: STORAGE_STATE_PATH }); + + async function serveConversations( + page: import("@playwright/test").Page, + deleteDelayMs: number, + ) { + const state = { + rows: [row(KEEP_ID, "Kept thread"), row(DROP_ID, "Dropped thread")], + deletes: [] as string[], + }; + // Context-level, so a `keepalive` request sent while the page unloads is + // still answered here. + await page + .context() + .route( + /\/api\/insights\/chat(?:\/[^?]+)?(?:\?|$)/, + async (route: Route) => { + const request = route.request(); + const url = new URL(request.url()); + if (request.method() === "DELETE") { + const id = decodeURIComponent(url.pathname.split("/").pop() ?? ""); + state.deletes.push(id); + state.rows = state.rows.filter((r) => r.id !== id); + if (deleteDelayMs > 0) await delay(deleteDelayMs); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: { deleted: true }, error: null }), + }); + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { conversations: state.rows, nextCursor: null }, + error: null, + }), + }); + }, + ); + return state; + } + + test("the row does not come back when the window runs out", async ({ + page, + }) => { + // A slow DELETE holds the refetch back, which is when the row used to + // reappear. + const state = await serveConversations(page, 2_000); + await page.goto("/coach/conversations", { waitUntil: "domcontentloaded" }); + + const items = page.locator('[data-slot="coach-conversations-item"]'); + await expect(items).toHaveCount(2, { timeout: 10_000 }); + await items + .filter({ hasText: "Dropped thread" }) + .locator('[data-slot="coach-conversations-delete"]') + .click(); + await expect(items).toHaveCount(1); + + await expect + .poll(() => state.deletes, { timeout: 15_000 }) + .toEqual([DROP_ID]); + // Sampled while the DELETE is still in flight. + for (let i = 0; i < 6; i += 1) { + expect(await items.count()).toBe(1); + await delay(250); + } + await expect(items).toContainText("Kept thread"); + }); +}); + +/** + * The real server and database, not a mock: the request that matters here is + * sent with `keepalive` while the page unloads, which Playwright's routing + * never sees. The account is the spec's own, so the conversation it seeds + * cannot surface in another spec's Coach. + */ +test.describe("Coach conversation delete against the database", () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test("a reload inside the undo window still deletes", async ({ + page, + }, testInfo) => { + const dbUrl = process.env.DATABASE_URL; + test.skip(!dbUrl, "DATABASE_URL is required to seed the conversation"); + + const suffix = `${testInfo.project.name}-${randomBytes(4).toString("hex")}`; + const username = `e2e-coach-delete-${suffix}`; + const password = "Cd5!Vq8nTz3wLp7K"; + const userId = `e2e-coach-delete-user-${suffix}`; + const conversationId = `e2e-coach-delete-${suffix}`; + const pool = new pg.Pool({ connectionString: dbUrl }); + try { + 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, 'USER', now(), now(), now(), true)`, + [ + userId, + username, + `${username}@healthlog.test`, + await hash(password, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }), + ], + ); + // A legacy readable title keeps the seed free of the encryption key. + await pool.query( + `INSERT INTO coach_conversations (id, user_id, title, updated_at) + VALUES ($1, $2, 'Dropped thread', now())`, + [conversationId, userId], + ); + + const login = await page.request.post("/api/auth/login", { + data: { email: username, password }, + }); + expect(login.status()).toBe(200); + + await page.goto("/coach/conversations", { + waitUntil: "domcontentloaded", + }); + const items = page.locator('[data-slot="coach-conversations-item"]'); + await expect(items).toHaveCount(1, { timeout: 15_000 }); + await items.locator('[data-slot="coach-conversations-delete"]').click(); + await expect(items).toHaveCount(0); + + // Well inside the undo window. + await page.reload({ waitUntil: "domcontentloaded" }); + + await expect + .poll(async () => { + const { rowCount } = await pool.query( + "SELECT 1 FROM coach_conversations WHERE id = $1", + [conversationId], + ); + return rowCount; + }) + .toBe(0); + await expect( + page.locator('[data-slot="coach-conversations-empty"]'), + ).toBeVisible({ timeout: 15_000 }); + await expect(items).toHaveCount(0); + } finally { + await pool.query("DELETE FROM users WHERE id = $1", [userId]); + await pool.end(); + } + }); +}); diff --git a/e2e/coach-layout.spec.ts b/e2e/coach-layout.spec.ts index 67b455d96..fc4b02a72 100644 --- a/e2e/coach-layout.spec.ts +++ b/e2e/coach-layout.spec.ts @@ -4,25 +4,29 @@ import type { Locator, Page, Request, Route, TestInfo } from "@playwright/test"; import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; +import { mockDay } from "./setup/day-mock"; /** - * The Coach page frame: the conversations panel on the right, its toggle in - * the top bar, the settings gear in the panel header, and New chat at the - * panel's bottom right. + * The Coach page frame: the conversation, the conversations panel right of + * it, and the day right of that; New conversation floating in the + * conversation itself. * - * Contracts under test, at 1440, 1280 and 390 px: + * Contracts under test, at 1920, 1440, 1280 and 390 px: * - * 1. From 1280 px the panel docks beside the thread, open on a first visit, - * and the reading column (thread and composer) is exactly as wide with - * the panel open as with it closed. The choice survives a reload. The - * panel is flush with the viewport's right edge (no scrollbar gutter - * beside it) and runs from the top of the window to the bottom; the - * top bar ends at its left edge and the panel's header border is the - * top bar's. The toggle is the bar's last item, within 16 px of its - * right edge, open and shut; shut, no pixel of the panel shows and the - * bar runs to the window's edge. New chat is a round 48 px plus. - * 2. Escape inside the docked panel closes it and returns focus to the - * toggle. + * 1. From 1280 px the panel docks beside the thread, open on a first visit. + * From 1440 px the reading column (thread and composer) is exactly as + * wide with the panel open as folded; at 1280 the two strips leave the + * open column below its cap, so folding widens it up to the cap. Its 40 px strip stays at the right edge, + * left of the day's, open or not, and never moves: a click opens the + * panel left of the strips, a second click shuts it; the choice + * survives a reload. The panel is flush with the strips and runs from + * the top of the window to the bottom; the top bar ends at its left + * edge and the panel's header border is the top bar's. Focus moves + * between the fold control and the strip, and Escape inside the open + * panel folds it. + * 2. New conversation is a round 56 px button in the conversation column, + * never over the panel, never over the composer's field, and absent + * from the panel's header. * 3. A row's menu renames by keyboard (Escape cancels without closing the * panel, Enter saves once) and deletes with an Undo that keeps the * DELETE from ever leaving; a delete left alone commits once. @@ -31,10 +35,13 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; * can see" and drops the param from the URL. * 5. axe finds nothing on the open panel or the open settings, in the * light and the dark theme. - * 6. At 390 px the panel is a dialog sheet with New chat 16 px from the - * right edge (24 px in the docked panel); Escape closes it and focus returns to the toggle. The gear - * opens a bottom sheet that closes on Escape back to the gear. Nothing - * scrolls sideways. + * 6. The day docks right of the panel. At 1440 only one of the two is + * open: a day folds the panel, opening the panel folds the day. At 1920 + * both stay open and the conversation keeps at least 560 px. + * 7. At 390 px the panel is a dialog sheet opened from the top bar's + * toggle; Escape closes it and focus returns to the toggle. The gear + * opens a bottom sheet that closes on Escape back to the gear. New + * conversation sits above the composer. Nothing scrolls sideways. * * Every Coach route is stubbed; the provider chain and provider config are * stubbed too, so the model section renders the same on every machine. @@ -173,9 +180,6 @@ async function mockCoach(page: Page): Promise { await page.route("**/api/coach/about-me/questions*", (route) => fulfilJson(route, { questions: [] }), ); - await page.route("**/api/insights/coach/seeded-question*", (route) => - fulfilJson(route, { signal: null }), - ); await page.route("**/api/insights/provider-chain", (route) => route.request().method() === "GET" ? fulfilJson(route, { @@ -212,6 +216,14 @@ async function mockCoach(page: Page): Promise { } const toggle = (page: Page) => page.locator('[data-slot="coach-panel-toggle"]'); +const collapse = (page: Page) => + page.locator('[data-slot="coach-panel-collapse"]'); +const expand = (page: Page) => + page.locator('[data-slot="coach-panel-strip-toggle"]'); +const strip = (page: Page) => page.locator('[data-slot="coach-panel-strip"]'); +const dayStrip = (page: Page) => page.locator('[data-slot="day-strip"]'); +const fab = (page: Page) => page.locator('[data-slot="coach-new-chat-fab"]'); +const dayPanel = (page: Page) => page.locator('[data-slot="day-panel"]'); const panel = (page: Page) => page.locator('[data-slot="coach-conversations-panel"]'); @@ -240,95 +252,106 @@ async function expectNoSidewaysScroll(page: Page) { } /** - * The docked panel is flush with the window: its right edge is the - * viewport's (no scrollbar gutter beside it), it runs from the top of the - * window to the bottom, the top bar ends at its left edge, its header row's - * bottom border is the top bar's, and its own border is the left one only. + * The docked panel is flush with its strips: the conversations' strip sits + * right of the panel and the day's right of that, against the viewport's + * right edge (no scrollbar gutter beside them). The panel runs from the top + * of the window to the bottom, the top bar ends at its left edge, an open + * panel's header row has the top bar's bottom border, and its own border + * is the left one only. Both strips start below the top bar's band. */ async function expectPanelFlush(page: Page, width: number, height: number) { const geometry = await page.evaluate(() => { - const aside = document.querySelector( + const box = (selector: string) => + document.querySelector(selector)?.getBoundingClientRect() ?? null; + const outer = document.querySelector( '[data-slot="coach-conversations-panel"]', )!; - const inner = aside.firstElementChild as HTMLElement; - const header = aside.querySelector( - '[data-slot="coach-conversations-panel-header"]', - )!; - const bar = document.querySelector('[data-slot="top-bar"]')!; + const inner = outer.querySelector("aside"); + const style = inner ? getComputedStyle(inner) : null; const main = document.getElementById("main-content")!; - const style = getComputedStyle(inner); - const box = aside.getBoundingClientRect(); - const barBox = bar.getBoundingClientRect(); + const panel = outer.getBoundingClientRect(); return { - right: box.right, - top: box.top, - bottom: box.bottom, - left: box.left, - headerBottom: header.getBoundingClientRect().bottom, - barBottom: barBox.bottom, - barRight: barBox.right, + panel: { + left: panel.left, + right: panel.right, + top: panel.top, + bottom: panel.bottom, + }, + header: + box('[data-slot="coach-conversations-panel-header"]')?.bottom ?? null, + bar: box('[data-slot="top-bar"]')!, + list: box('[data-slot="coach-panel-strip"]')!, + listButton: box('[data-slot="coach-panel-strip-toggle"]')!, + day: box('[data-slot="day-strip"]')!, + dayDock: box("#day-docked-panel")!, gutter: main.offsetWidth - main.clientWidth, - borders: [ - style.borderTopWidth, - style.borderRightWidth, - style.borderBottomWidth, - style.borderLeftWidth, - ], + borders: style + ? [ + style.borderTopWidth, + style.borderRightWidth, + style.borderBottomWidth, + style.borderLeftWidth, + ] + : null, }; }); - expect(geometry.right).toBe(width); - expect(geometry.top).toBe(0); - expect(geometry.bottom).toBe(height); - expect(geometry.barRight).toBe(geometry.left); - expect(geometry.headerBottom).toBe(geometry.barBottom); + expect(geometry.day.right).toBe(width); + expect(geometry.list.right).toBe(geometry.day.left); + expect(Math.round(geometry.list.width)).toBe(40); + expect(Math.round(geometry.day.width)).toBe(40); + // Conversations, then the day (open or slid to nothing), then the strips. + expect(geometry.panel.right).toBe(geometry.dayDock.left); + expect(geometry.dayDock.right).toBe(geometry.list.left); + expect(geometry.panel.top).toBe(0); + expect(geometry.panel.bottom).toBe(height); + expect(geometry.bar.right).toBe(geometry.panel.left); + // The strips start below the band; the palette's corner stays the bar's. + expect(geometry.listButton.top).toBe(geometry.bar.bottom); + if (geometry.header !== null) { + expect(geometry.header).toBe(geometry.bar.bottom); + expect(geometry.borders).toEqual(["0px", "0px", "0px", "1px"]); + } expect(geometry.gutter).toBe(0); - expect(geometry.borders).toEqual(["0px", "0px", "0px", "1px"]); +} + +/** Where a strip stands on screen. */ +async function stripBox(locator: Locator) { + const box = (await locator.boundingBox())!; + return { x: Math.round(box.x), width: Math.round(box.width) }; } /** - * The toggle is the last item of the top bar (the account menu that follows - * it on phones is not rendered from `md`), and its right edge is within - * 16 px of the bar's right edge. + * New conversation floats in the conversation column: a round 56 px button + * with a plus and its name, inside the column (never over a docked panel + * right of it) and clear of the composer's field. The panel's header has no + * such control. */ -async function expectToggleAtBarEnd(page: Page) { - const at = await page.evaluate(() => { - const bar = document.querySelector('[data-slot="top-bar"]')!; - const shown = [...bar.querySelectorAll("*")].filter( - (el) => el.getClientRects().length > 0, - ); - const toggle = bar.querySelector('[data-slot="coach-panel-toggle"]')!; - const rightmost = Math.max( - ...shown.map((el) => el.getBoundingClientRect().right), - ); - return { - gap: - bar.getBoundingClientRect().right - - toggle.getBoundingClientRect().right, - last: toggle.getBoundingClientRect().right >= rightmost, - lastChild: - toggle.closest('[data-slot="top-bar-actions"]')?.lastElementChild === - toggle, - }; - }); - expect(at.last, "nothing in the bar stands right of the toggle").toBe(true); - expect(at.lastChild).toBe(true); - expect(at.gap).toBeGreaterThanOrEqual(0); - expect(at.gap).toBeLessThanOrEqual(16); -} - -/** New chat: a round 48 px button with the plus glyph. */ -async function expectFab(page: Page) { - const fab = panel(page).locator('[data-slot="coach-panel-new-chat"]'); - const box = (await fab.boundingBox())!; - expect([Math.round(box.width), Math.round(box.height)]).toEqual([48, 48]); - expect( - await fab.evaluate((el) => - parseFloat(getComputedStyle(el).borderTopLeftRadius), - ), - "round", - ).toBeGreaterThanOrEqual(24); - await expect(fab.locator("svg.lucide-plus")).toHaveCount(1); - await expect(fab).toHaveAttribute("aria-label", "New chat"); +async function expectNewChatFab(page: Page) { + await expect(fab(page)).toHaveCount(1); + await expect(fab(page)).toHaveAttribute("aria-label", "New conversation"); + await expect(fab(page).locator("svg.lucide-plus")).toHaveCount(1); + await expect( + panel(page).locator('[data-slot="coach-panel-new-chat"]'), + ).toHaveCount(0); + const box = (await fab(page).boundingBox())!; + expect(Math.round(box.width)).toBe(56); + expect(Math.round(box.height)).toBe(56); + const main = (await page + .locator('[data-slot="coach-page-main"]') + .boundingBox())!; + expect(box.x).toBeGreaterThanOrEqual(main.x); + expect(box.x + box.width).toBeLessThanOrEqual(main.x + main.width); + const field = (await page + .locator( + '[data-slot="coach-page-composer"] [data-slot="coach-input-textarea"]', + ) + .boundingBox())!; + const overlaps = + box.x < field.x + field.width && + box.x + box.width > field.x && + box.y < field.y + field.height && + box.y + box.height > field.y; + expect(overlaps, "the button leaves the composer's field free").toBe(false); } /** Rendered heights of the docked panel's header buttons and first row. */ @@ -341,6 +364,7 @@ async function panelControlHeights(page: Page) { .boundingBox())!.height, ); return { + collapse: await height("coach-panel-collapse"), plans: await height("coach-panel-plans"), gear: await height("coach-settings"), row: await height("coach-history-select"), @@ -425,21 +449,24 @@ test.describe("Coach page frame", () => { // First visit: open. await expect(panel(page)).toHaveAttribute("data-state", "open"); await waitForWidth(panel(page), 288); - await expect(toggle(page)).toHaveAttribute("aria-expanded", "true"); - await expect(toggle(page)).toHaveAttribute( + // Docked, the fold control lives in the panel; the top bar has none. + await expect(toggle(page)).toHaveCount(0); + await expect(collapse(page)).toHaveAttribute("aria-expanded", "true"); + await expect(collapse(page)).toHaveAttribute( "aria-controls", "coach-conversations-panel", ); const open = await columnWidths(page); - expect(open.thread).toBeCloseTo(672, 0); + // The full 672 px column at 1440; at 1280 the two strips take 80 px + // of it and the column stays well above the 560 px floor. + if (width === 1440) expect(open.thread).toBeCloseTo(672, 0); + else expect(open.thread).toBeGreaterThanOrEqual(560); expect(Math.abs(open.composer - open.thread)).toBeLessThanOrEqual(1); // The panel sits right of the column, never over it. const aside = (await panel(page).boundingBox())!; expect(aside.x).toBeGreaterThanOrEqual(open.threadX + open.thread); await expectPanelFlush(page, width, 900); - // Open, the toggle is the top bar's last item, against the panel. - await expectToggleAtBarEnd(page); - await expectFab(page); + await expectNewChatFab(page); // The bar says where the reader is: the page, then the conversation. await expect( page.locator('[data-slot="coach-top-bar-trail"]'), @@ -447,37 +474,37 @@ test.describe("Coach page frame", () => { await expectNoSidewaysScroll(page); await shot(page, testInfo, `coach-frame-${width}-open`); - await toggle(page).click(); + // The strip stands still through a fold and an open. + const stripOpen = await stripBox(strip(page)); + await expect(expand(page)).toHaveAttribute("aria-expanded", "true"); + + // Folded: the panel slides to nothing, the strip takes the focus, and + // the top bar ends at the strips. + await collapse(page).click(); await expect(panel(page)).toHaveAttribute("data-state", "closed"); await waitForWidth(panel(page), 0); - await expect(toggle(page)).toHaveAttribute("aria-expanded", "false"); - const closed = await columnWidths(page); - // Closed, the panel is entirely off-screen, the top bar runs to the - // window's edge and the toggle is still its last item. - await expect - .poll(() => - page.evaluate( - () => - document - .querySelector('[data-slot="top-bar"]')! - .getBoundingClientRect().right, - ), - ) - .toBe(width); - expect( - await panel(page).evaluate((el) => { - const box = el.getBoundingClientRect(); - return box.width === 0 || box.left >= window.innerWidth; - }), - "no pixel of the shut panel shows", - ).toBe(true); - await expectToggleAtBarEnd(page); - expect(Math.abs(closed.thread - open.thread)).toBeLessThanOrEqual(1); - expect(Math.abs(closed.composer - open.composer)).toBeLessThanOrEqual(1); - // Closed, nothing inside the panel is reachable. + expect(await stripBox(strip(page))).toEqual(stripOpen); + await expect(expand(page)).toBeFocused(); + await expect(expand(page)).toHaveAttribute("aria-expanded", "false"); + await expect(expand(page)).toHaveAttribute( + "aria-label", + "Show conversations", + ); await expect( - panel(page).locator('[data-slot="coach-history-select"]').first(), - ).not.toBeInViewport(); + panel(page).locator('[data-slot="coach-history-select"]'), + ).toHaveCount(0); + await expectPanelFlush(page, width, 900); + const closed = await columnWidths(page); + if (width === 1440) { + expect(Math.abs(closed.thread - open.thread)).toBeLessThanOrEqual(1); + } else { + // At 1280 the strips leave the open column below its 672 px cap, so + // folding the list gives the column back what it lacked, and no more. + expect(closed.thread).toBeGreaterThanOrEqual(open.thread); + expect(closed.thread).toBeLessThanOrEqual(672 + 1); + } + expect(Math.abs(closed.composer - closed.thread)).toBeLessThanOrEqual(1); + await expectNewChatFab(page); await expectNoSidewaysScroll(page); await shot(page, testInfo, `coach-frame-${width}-closed`); @@ -485,19 +512,129 @@ test.describe("Coach page frame", () => { await openCoach(page); await expect(panel(page)).toHaveAttribute("data-state", "closed"); - // Open again, then Escape from inside closes it, focus to the toggle. - await toggle(page).click(); + // A click on the strip opens it, a second click shuts it, and the + // strip does not move either time. + await expand(page).click(); await expect(panel(page)).toHaveAttribute("data-state", "open"); + expect(await stripBox(strip(page))).toEqual(stripOpen); + await expand(page).click(); + await expect(panel(page)).toHaveAttribute("data-state", "closed"); + await expect(expand(page)).toBeFocused(); + expect(await stripBox(strip(page))).toEqual(stripOpen); + + // Opened from the keyboard, focus lands on the fold control; Escape + // from inside folds it again and focus goes back to the strip. + await expand(page).focus(); + await page.keyboard.press("Enter"); + await expect(panel(page)).toHaveAttribute("data-state", "open"); + await expect(collapse(page)).toBeFocused(); await panel(page) .locator('[data-slot="coach-history-select"]') .first() .focus(); await page.keyboard.press("Escape"); await expect(panel(page)).toHaveAttribute("data-state", "closed"); - await expect(toggle(page)).toBeFocused(); + await expect(expand(page)).toBeFocused(); }); } + test("1440: the day and the conversations take turns, focus included", async ({ + page, + }, testInfo) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await mockCoach(page); + await mockDay(page); + const day = "2026-06-01"; + await page.goto(`/coach?c=frame-bp&day=${day}`, { + waitUntil: "domcontentloaded", + }); + await expect( + page.locator('[data-slot="coach-message-thread"]').getByText(ANSWER), + ).toBeVisible({ timeout: 15_000 }); + + // The day opens docked, left of the strips, and the conversations + // close for it; both strips stay. + await expect(dayPanel(page)).toHaveAttribute("data-shell", "docked"); + await expect(panel(page)).toHaveAttribute("data-state", "closed"); + await waitForWidth(panel(page), 0); + const dayBox = (await dayPanel(page).boundingBox())!; + const listStrip = (await strip(page).boundingBox())!; + expect(Math.round(dayBox.x + dayBox.width)).toBe(Math.round(listStrip.x)); + expect(Math.round(listStrip.x + listStrip.width)).toBe(1400); + await expect(dayStrip(page)).toHaveAttribute("data-state", "open"); + await expectPanelFlush(page, 1440, 900); + await expectNewChatFab(page); + expect( + (await page.locator('[data-slot="coach-page-main"]').boundingBox())! + .width, + ).toBeGreaterThanOrEqual(540); + await expectNoSidewaysScroll(page); + await shot(page, testInfo, "coach-frame-1440-day"); + + // Opening the conversations closes the day; its strip keeps the day, + // focus goes to the conversations' fold control, the day is out of the + // URL, and the conversation stays the same. + await expand(page).click(); + await expect(panel(page)).toHaveAttribute("data-state", "open"); + await expect(dayPanel(page)).toHaveCount(0); + await expect(dayStrip(page)).toHaveAttribute("data-day", day); + await expect(dayStrip(page)).toHaveAttribute("data-state", "closed"); + await expect(collapse(page)).toBeFocused(); + await expect(page).toHaveURL(/\/coach\?c=frame-bp$/); + await expectNewChatFab(page); + await shot(page, testInfo, "coach-frame-1440-list"); + + // And back: the day's strip opens the day, the conversations close. + await page.locator('[data-slot="day-strip-toggle"]').click(); + await expect(dayPanel(page)).toHaveAttribute("data-shell", "docked"); + await expect(panel(page)).toHaveAttribute("data-state", "closed"); + await expect(page).toHaveURL(new RegExp(`c=frame-bp&day=${day}$`)); + + // Switching conversations keeps the day open. + await expand(page).click(); + await page.locator('[data-slot="day-strip-toggle"]').click(); + await expect(dayPanel(page)).toBeVisible(); + await fab(page).click(); + await expect(page).toHaveURL(new RegExp(`/coach\\?day=${day}$`)); + await expect(dayPanel(page)).toBeVisible(); + }); + + test("1920: the conversations and the day stay open side by side", async ({ + page, + }, testInfo) => { + await page.setViewportSize({ width: 1920, height: 1000 }); + await mockCoach(page); + await mockDay(page); + await page.goto("/coach?c=frame-bp&day=2026-06-01", { + waitUntil: "domcontentloaded", + }); + await expect( + page.locator('[data-slot="coach-message-thread"]').getByText(ANSWER), + ).toBeVisible({ timeout: 15_000 }); + await expect(dayPanel(page)).toHaveAttribute("data-shell", "docked"); + await expect(panel(page)).toHaveAttribute("data-state", "open"); + await waitForWidth(panel(page), 288); + const list = (await panel(page).boundingBox())!; + const dayBox = (await dayPanel(page).boundingBox())!; + expect(list.x + list.width).toBeLessThanOrEqual(dayBox.x + 1); + await expectPanelFlush(page, 1920, 1000); + expect( + (await page.locator('[data-slot="coach-page-main"]').boundingBox())! + .width, + ).toBeGreaterThanOrEqual(560); + await expectNewChatFab(page); + await expectNoSidewaysScroll(page); + await shot(page, testInfo, "coach-frame-1920-both"); + + // Folding one leaves the other alone. + await collapse(page).click(); + await expect(panel(page)).toHaveAttribute("data-state", "closed"); + await expect(dayPanel(page)).toBeVisible(); + await expand(page).click(); + await expect(panel(page)).toHaveAttribute("data-state", "open"); + await expect(dayPanel(page)).toBeVisible(); + }); + test("1280 fine pointer: compact header buttons and rows", async ({ page, }) => { @@ -505,6 +642,7 @@ test.describe("Coach page frame", () => { await mockCoach(page); await openCoach(page); expect(await panelControlHeights(page)).toEqual({ + collapse: 28, plans: 28, gear: 28, row: 36, @@ -524,6 +662,7 @@ test.describe("Coach page frame", () => { await openCoach(page); await expect(panel(page)).toHaveAttribute("data-state", "open"); expect(await panelControlHeights(page)).toEqual({ + collapse: 44, plans: 44, gear: 44, row: 44, @@ -552,18 +691,7 @@ test.describe("Coach page frame", () => { await expect(page).toHaveURL(/\/coach\?c=frame-sleep$/); await expect(rows.nth(1)).toHaveAttribute("aria-current", "true"); - const fab = panel(page).locator('[data-slot="coach-panel-new-chat"]'); - const fabBox = (await fab.boundingBox())!; - const asideBox = (await panel(page).boundingBox())!; - // 24 px in from the panel's corner from `md`, 16 px on a phone. - expect(asideBox.x + asideBox.width - (fabBox.x + fabBox.width)).toBeCloseTo( - 24, - 0, - ); - expect( - asideBox.y + asideBox.height - (fabBox.y + fabBox.height), - ).toBeCloseTo(24, 0); - await fab.click(); + await fab(page).click(); await expect(page).toHaveURL(/\/coach$/); await expect( page.locator('[data-slot="coach-input-textarea"]'), @@ -715,6 +843,7 @@ test.describe("Coach page frame", () => { new RegExp(`(^|\\s)${theme}(\\s|$)`), ); await expect(panel(page)).toHaveAttribute("data-state", "open"); + await shot(page, testInfo, `coach-frame-1440-${theme}`); const scan = async (selector: string, label: string) => { const result = await new AxeBuilder({ page }) @@ -781,6 +910,13 @@ test.describe("Coach page frame", () => { // Below 1280 px the panel waits to be asked. await expect(panel(page)).toHaveCount(0); await expect(toggle(page)).toHaveAttribute("aria-expanded", "false"); + // New conversation sits above the composer, clear of its field. + await expectNewChatFab(page); + const fabBox = (await fab(page).boundingBox())!; + const composerBox = (await page + .locator('[data-slot="coach-page-composer"]') + .boundingBox())!; + expect(fabBox.y + fabBox.height).toBeLessThanOrEqual(composerBox.y); await expectNoSidewaysScroll(page); await shot(page, testInfo, "coach-frame-390-thread"); @@ -788,16 +924,17 @@ test.describe("Coach page frame", () => { const sheet = page.getByRole("dialog", { name: "Conversations" }); await expect(sheet).toBeVisible(); await expect(toggle(page)).toHaveAttribute("aria-expanded", "true"); - const fab = sheet.locator('[data-slot="coach-panel-new-chat"]'); - // Measured once the sheet has slid in. - await expect - .poll(async () => { - const box = (await fab.boundingBox())!; - return Math.round(390 - (box.x + box.width)); - }) - .toBe(16); - expect((await fab.boundingBox())!.width).toBe(48); - await expect(fab.locator("svg.lucide-plus")).toHaveCount(1); + await expect( + sheet.locator('[data-slot="coach-panel-new-chat"]'), + ).toHaveCount(0); + // The title keeps its whole word beside four header controls. + const title = sheet.locator( + '[data-slot="coach-conversations-panel-header"] h2', + ); + expect( + await title.evaluate((el) => el.scrollWidth <= el.clientWidth), + "the sheet title is not truncated", + ).toBe(true); await shot(page, testInfo, "coach-frame-390-panel"); await page.keyboard.press("Escape"); diff --git a/e2e/coach-open-at-end.spec.ts b/e2e/coach-open-at-end.spec.ts new file mode 100644 index 000000000..ef3ef4fd1 --- /dev/null +++ b/e2e/coach-open-at-end.spec.ts @@ -0,0 +1,206 @@ +import type { Page, Route } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; + +/** + * A saved conversation opens at its last answer, and stays there after that + * answer's chart arrives. + * + * Before: on a wide screen the thread opened at the top (scroll 0), and on a + * phone it stopped about 70 px short of the end, because the answer's chart + * loaded after the jump. The conversation here is long enough to scroll on + * both widths, and the stored table under the last answer is served late on + * purpose, so the end moves after the thread first paints. + */ + +const CONVERSATION_ID = "coach-open-at-end-e2e"; +const LAST_ID = "coach-open-at-end-last"; + +const TABLE = { + ref: "r1", + source: { + tool: "get_metric_table", + domain: "pulse", + window: "last30days", + period: "current", + granularity: "day", + }, + shape: "timeSeries", + titleKey: "coach.result.title.byDay", + title: "Pulse by day", + rowCount: 14, + chartKind: "line", + displayed: true, + columns: [ + { + key: "day", + kind: "period", + labelKey: "coach.result.column.day", + label: "Day", + }, + { + key: "pulse", + kind: "number", + labelKey: "coach.result.column.value", + label: "Pulse", + unit: "bpm", + decimals: 0, + }, + ], + rows: Array.from({ length: 14 }, (_, i) => [ + new Date(Date.UTC(2026, 8, 10 + i)).toISOString().slice(0, 10), + 60 + (i % 5), + ]), + truncated: false, + chart: { kind: "line", x: "day", series: ["pulse"] }, +}; + +const PARAGRAPH = + "Your resting pulse held steady across the fortnight, with the usual small dip after the longer nights and a slight rise on the two days you logged a cold."; + +function messages() { + const out: Array> = []; + for (let i = 0; i < 8; i += 1) { + const at = `2026-09-24T09:${String(i * 2).padStart(2, "0")}:00.000Z`; + out.push({ + id: `u-${i}`, + role: "user", + content: `Question ${i + 1}: how did my pulse look?`, + createdAt: at, + metricSource: null, + providerType: null, + promptVersion: null, + tokensUsed: null, + model: null, + }); + const last = i === 7; + out.push({ + id: last ? LAST_ID : `a-${i}`, + role: "assistant", + content: `${PARAGRAPH} ${PARAGRAPH}`, + createdAt: at, + metricSource: last + ? { + windows: ["last30days"], + metrics: ["pulse"], + results: [ + { + ref: TABLE.ref, + source: TABLE.source, + shape: TABLE.shape, + titleKey: TABLE.titleKey, + title: TABLE.title, + rowCount: TABLE.rowCount, + chartKind: TABLE.chartKind, + displayed: TABLE.displayed, + }, + ], + } + : null, + providerType: "mock", + promptVersion: "e2e", + tokensUsed: 42, + model: "mock", + }); + } + return out; +} + +function fulfilJson(route: Route, data: unknown) { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data, error: null }), + }); +} + +async function mockConversation(page: Page) { + await page.route( + /\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/, + async (route) => { + const url = new URL(route.request().url()); + if ( + url.pathname === + `/api/insights/chat/${CONVERSATION_ID}/messages/${LAST_ID}/results` + ) { + // Late on purpose: the chart lands after the thread has painted. + await new Promise((resolve) => setTimeout(resolve, 900)); + return fulfilJson(route, { results: [TABLE] }); + } + if (url.pathname.startsWith(`/api/insights/chat/${CONVERSATION_ID}/`)) { + return fulfilJson(route, { results: [] }); + } + if (url.pathname === `/api/insights/chat/${CONVERSATION_ID}`) { + return fulfilJson(route, { + id: CONVERSATION_ID, + title: "Pulse over two weeks", + createdAt: "2026-09-24T09:00:00.000Z", + updatedAt: "2026-09-24T09:14:00.000Z", + messageCount: 16, + fenced: false, + attachments: [], + documentTitle: null, + attachmentCount: 0, + summary: null, + messages: messages(), + }); + } + return fulfilJson(route, { conversations: [], nextCursor: null }); + }, + ); + await page.route("**/api/insights/coach/nudge-status*", (route) => + fulfilJson(route, { nudgedAt: null, unread: false }), + ); + await page.route("**/api/coach/about-me/questions*", (route) => + fulfilJson(route, { questions: [] }), + ); +} + +/** Distance from the thread's current view to its end, in px. */ +async function distanceFromEnd(page: Page): Promise { + return page + .locator('[data-slot="coach-message-thread"]') + .evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight); +} + +test.describe("Coach: a saved conversation opens at its end", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(async ({ page }) => { + await serveAiBlock(page, aiBlockAvailable()); + await mockConversation(page); + }); + + for (const width of [390, 1440] as const) { + test(`at ${width} px it lands on the last answer and stays after its chart loads`, async ({ + page, + }) => { + await page.setViewportSize({ + width, + height: width === 390 ? 844 : 900, + }); + await page.goto(`/coach?c=${CONVERSATION_ID}`, { + waitUntil: "domcontentloaded", + }); + + const thread = page.locator('[data-slot="coach-message-thread"]'); + await expect(thread).toBeVisible({ timeout: 15_000 }); + // The thread scrolls at all: otherwise "at the end" proves nothing. + expect( + await thread.evaluate((el) => el.scrollHeight > el.clientHeight + 200), + ).toBe(true); + + const chart = page.locator( + '[data-slot="coach-result-chart"][data-ref="r1"]', + ); + await expect(chart).toBeVisible({ timeout: 15_000 }); + // The chart grew the thread after the first jump; the view followed. + await expect.poll(() => distanceFromEnd(page)).toBeLessThanOrEqual(2); + await expect( + page.locator(`[data-slot="coach-assistant-turn"]`).last(), + ).toBeInViewport(); + }); + } +}); diff --git a/e2e/command-palette.spec.ts b/e2e/command-palette.spec.ts new file mode 100644 index 000000000..0f37045a2 --- /dev/null +++ b/e2e/command-palette.spec.ts @@ -0,0 +1,157 @@ +import AxeBuilder from "@axe-core/playwright"; +import type { Page } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { expect, test } from "./setup/test"; + +/** + * v1.42 — the command palette. + * + * - Cmd/Ctrl+K, "sleep", Enter lands on the sleep page; + * - a settings card is a link with its anchor, and the page scrolls to it; + * - a module's entry is a switch: Enter sends the module write and the + * palette stays open (the write is answered by a route mock, so the + * shared account's modules never move); + * - the top bar carries the search field on a wide desktop and a + * magnifier on a phone, which opens a full-height sheet with the field + * focused; + * - the open palette passes axe. + * + * Stable hooks only: `command-palette` (+ `data-variant`), + * `command-palette-input`, `command-palette-trigger` (+ `data-variant`), + * options by `data-entry`. + */ + +test.use({ storageState: STORAGE_STATE_PATH }); + +const palette = (page: Page) => page.locator('[data-slot="command-palette"]'); +const input = (page: Page) => + page.locator('[data-slot="command-palette-input"]'); + +async function shellReady(page: Page): Promise { + await expect(page.locator("#main-content")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator('[data-slot="top-bar"]')).toBeVisible(); +} + +test.describe("command palette", () => { + test.beforeEach(async ({ context }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "palette journeys run on the desktop project (the phone case resizes)", + ); + const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000"; + await context.addCookies([ + { name: "healthlog-locale", value: "en", url: baseURL }, + ]); + }); + + test("Cmd/Ctrl+K, sleep, Enter opens the sleep page", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/"); + await shellReady(page); + await page.keyboard.press("ControlOrMeta+k"); + await expect(palette(page)).toHaveAttribute("data-variant", "dialog"); + await expect(input(page)).toBeFocused(); + await input(page).fill("sleep"); + // The best hit is the highlighted option. + const active = await input(page).getAttribute("aria-activedescendant"); + await expect(page.locator(`[id="${active}"]`)).toHaveAttribute( + "data-entry", + "insights:sleep", + ); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(/\/insights\/sleep$/); + await expect(palette(page)).toHaveCount(0); + }); + + test("a settings card opens at its anchor", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/"); + await shellReady(page); + await page.keyboard.press("ControlOrMeta+k"); + await input(page).fill("passkeys"); + await page.locator('[data-entry="settings:security#passkeys"]').click(); + await expect(page).toHaveURL(/\/settings\/security#passkeys$/); + await expect(page.locator("#passkeys")).toBeInViewport({ timeout: 10_000 }); + }); + + test("a module is a switch; Enter flips it and the palette stays", async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + const writes: unknown[] = []; + await page.route("**/api/auth/me/modules", async (route) => { + if (route.request().method() !== "PATCH") return route.fallback(); + writes.push(route.request().postDataJSON()); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + modules: { timeline: true }, + updatedAt: new Date().toISOString(), + }, + error: null, + }), + }); + }); + await page.goto("/"); + await shellReady(page); + await page.keyboard.press("ControlOrMeta+k"); + await input(page).fill("timeline"); + const option = page.locator('[data-entry="module:timeline"]'); + await expect(option).toHaveAttribute("aria-checked", "false"); + await option.hover(); + await page.keyboard.press("Enter"); + await expect.poll(() => writes.length).toBe(1); + expect(writes[0]).toMatchObject({ timeline: true }); + await expect(palette(page)).toBeVisible(); + await expect(input(page)).toHaveValue("timeline"); + }); + + test("the top bar: a search field on a wide desktop", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/"); + await shellReady(page); + const field = page.locator( + '[data-slot="command-palette-trigger"][data-variant="field"]', + ); + await expect(field).toBeVisible(); + await expect( + page.locator( + '[data-slot="command-palette-trigger"][data-variant="icon"]', + ), + ).toBeHidden(); + await field.click(); + 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(); + expect(axe.violations).toEqual([]); + + await page.keyboard.press("Escape"); + await expect(palette(page)).toHaveCount(0); + }); + + test("on a phone: the magnifier opens a full-height sheet", async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/"); + await shellReady(page); + const magnifier = page.locator( + '[data-slot="command-palette-trigger"][data-variant="icon"]', + ); + await expect(magnifier).toBeVisible(); + await magnifier.click(); + await expect(palette(page)).toHaveAttribute("data-variant", "sheet"); + await expect(input(page)).toBeFocused(); + const box = await palette(page).boundingBox(); + expect(box?.height ?? 0).toBeGreaterThanOrEqual(844 - 1); + await input(page).fill("weight"); + await expect(page.locator('[data-entry="insights:weight"]')).toBeVisible(); + }); +}); diff --git a/e2e/day-doors.spec.ts b/e2e/day-doors.spec.ts new file mode 100644 index 000000000..367e99e46 --- /dev/null +++ b/e2e/day-doors.spec.ts @@ -0,0 +1,325 @@ +import type { Locator, Page, TestInfo } from "@playwright/test"; + +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; +import { DAY_DOORS_ANALYTE } from "./setup/day-doors-fixture"; +import { mockDay } from "./setup/day-mock"; +import { + DAY_DOORS_STORAGE_STATE_PATH, + MOBILE_ROUTES_STORAGE_STATE_PATH, +} from "./setup/global-setup"; +import { expect, test } from "./setup/test"; + +/** + * v1.42 — every chart drawn in days opens its days, the same way. + * + * The day view first reached six metric pages; the recovery page beside them + * offered no day at all. These journeys walk the surfaces that came after, + * each through the same doors: on a fine pointer a click on a point (or a + * calendar cell) opens the day beside the page; on a touch screen the first + * tap shows the value and the tooltip's "View the whole day" opens it as a + * bottom sheet, the tapped value on top. A date in a list opens in one tap. + * The dashed line (or outline) marks the open day, the row of dots and the + * caption sit under every chart, and the data table's dates are the + * keyboard way to the same days. + * + * - recovery (the page the beta reported), sleep, a lab marker, a chart + * with its comparison period on and a dashboard chart: line charts; + * - the mood calendar: a heatmap; + * - a metric's "all values" list: a date in a row. + * + * The day itself is a route mock in the contract's shape, as in + * `day-view.spec.ts`: these journeys pin the client's doors, not the day. + * Stable data attributes only. + */ + +const panel = (page: Page) => page.locator('[data-slot="day-panel"]'); +const isPhone = (testInfo: TestInfo) => + testInfo.project.name === "chromium-mobile"; + +/** Open the day of the point at 80 % across a day-linked plot. */ +async function openFromPlot(page: Page, plot: Locator, testInfo: TestInfo) { + await plot.scrollIntoViewIfNeeded(); + await expect(plot).toHaveAttribute("data-day-links", "true"); + const box = (await plot.boundingBox())!; + const x = box.x + box.width * 0.8; + const y = box.y + box.height / 2; + if (isPhone(testInfo)) { + await page.touchscreen.tap(x, y); + const open = page.locator('[data-slot="chart-tooltip-open-day"]:visible'); + await expect(open).toBeVisible(); + // A tap alone never opens a sheet: a point is a small target. + await expect(panel(page)).toHaveCount(0); + // The button is a calm, full 44 px row. + expect((await open.boundingBox())!.height).toBeGreaterThanOrEqual(44); + await open.tap(); + await expect(panel(page)).toHaveAttribute("data-shell", "bottom"); + } else { + await page.mouse.move(x, y); + await page.mouse.click(x, y); + await expect(panel(page)).toHaveAttribute("data-shell", "docked"); + } + await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/); + await expect(page.locator('[data-slot="day-view"]')).toBeVisible(); +} + +test.describe("every chart drawn in days opens its days", () => { + test.use({ storageState: DAY_DOORS_STORAGE_STATE_PATH }); + + test.beforeEach(async ({ page, context }, testInfo) => { + const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000"; + await context.addCookies([ + { name: "healthlog-locale", value: "en", url: baseURL }, + ]); + await mockDay(page, { index: "full" }); + }); + + test("recovery: a point opens its day, and the open day is marked", async ({ + page, + }, testInfo) => { + if (!isPhone(testInfo)) { + await page.setViewportSize({ width: 1440, height: 900 }); + } + await page.goto("/insights/recovery"); + const plot = page + .locator('[data-slot="chart-plot"][data-day-links="true"]') + .first(); + await expect(plot).toBeVisible(); + // Under the chart: the caption and the row of day dots, which draws the + // days the index names, one of them ringed as notable. + const rug = page.locator('[data-slot="day-rug"]').first(); + await expect( + page.locator('[data-slot="chart-day-caption"]').first(), + ).toBeVisible(); + await expect(rug.locator("[data-day]").first()).toBeAttached(); + await expect(rug.locator('[data-notable="true"]')).toHaveCount(1); + + await openFromPlot(page, plot, testInfo); + const day = await page + .locator('[data-slot="day-view"]') + .getAttribute("data-day"); + // The value the person came from heads the day. + await expect(page.locator('[data-slot="day-focus"]')).toBeVisible(); + // The open day's dot is the filled one. + await expect(rug.locator(`[data-day="${day}"]`)).toHaveAttribute( + "data-open", + "true", + ); + }); + + test("recovery: the data table's dates are the keyboard way to a day", async ({ + page, + }, testInfo) => { + test.skip(isPhone(testInfo), "keyboard path"); + await page.goto("/insights/recovery"); + const toggle = page + .locator('[data-slot="chart-data-table-toggle"]') + .first(); + await toggle.scrollIntoViewIfNeeded(); + await toggle.focus(); + await page.keyboard.press("Enter"); + const link = page + .locator('[data-slot="chart-data-table-region"] [data-slot="day-link"]') + .first(); + await link.focus(); + const day = await link.getAttribute("data-day"); + await page.keyboard.press("Enter"); + await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute( + "data-day", + day!, + ); + // Escape closes the day and gives the keyboard back to the date. + await page.keyboard.press("Escape"); + await expect(panel(page)).toHaveCount(0); + }); + + test("sleep: a night's point opens its day", async ({ page }, testInfo) => { + await page.goto("/insights/sleep"); + const plot = page + .locator('[data-slot="chart-plot"][data-day-links="true"]') + .first(); + await expect(plot).toBeVisible(); + await openFromPlot(page, plot, testInfo); + }); + + test("the mood calendar: a logged day's cell opens it", async ({ + page, + }, testInfo) => { + await page.goto("/insights/mood"); + // A logged, openable cell: it carries the pointer cursor. + const cell = page.locator("rect.cursor-pointer[data-day]").last(); + await cell.scrollIntoViewIfNeeded(); + await expect(cell).toBeVisible(); + const day = await cell.getAttribute("data-day"); + if (isPhone(testInfo)) { + await cell.tap(); + const open = page.locator( + '[data-slot="heatmap-tooltip"] [data-slot="chart-tooltip-open-day"]', + ); + await expect(open).toBeVisible(); + await expect(panel(page)).toHaveCount(0); + await open.tap(); + await expect(panel(page)).toHaveAttribute("data-shell", "bottom"); + } else { + await cell.click(); + } + await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute( + "data-day", + day!, + ); + // The open day's cell is outlined. + await expect(page.locator('[data-slot="heatmap-open-day"]')).toHaveCount(1); + }); + + test("a lab marker's reading opens the day it was taken", async ({ + page, + }, testInfo) => { + await page.goto("/labs"); + await page + .locator('[data-slot="lab-list-analyte"]') + .filter({ hasText: DAY_DOORS_ANALYTE }) + .first() + .click(); + const plot = page + .locator('[data-slot="chart-plot"][data-day-links="true"]') + .first(); + await expect(plot).toBeVisible(); + await openFromPlot(page, plot, testInfo); + // The reading on top, with the one before it beside it. + await expect(page.locator('[data-slot="day-focus"]')).toBeVisible(); + }); + + test("a chart with its comparison period on still opens its days", async ({ + page, + }, testInfo) => { + await page.goto("/insights/weight"); + const plot = page + .locator('[data-slot="chart-plot"][data-day-links="true"]') + .first(); + await expect(plot).toBeVisible(); + await page + .locator('[data-slot="chart-overlay-controls-trigger"]') + .first() + .click(); + await page + .locator('[data-slot="chart-overlay-comparison-lastMonth"]') + .click(); + await page.keyboard.press("Escape"); + // The overlay menu is closed before the chart is touched, so the tap + // lands on the chart and not on the menu's outside-dismiss. + await expect( + page.locator('[data-slot="chart-overlay-controls-content"]'), + ).toHaveCount(0); + await expect( + page.locator('[data-slot="chart-compare-caption"]').first(), + ).toBeAttached(); + await openFromPlot(page, plot, testInfo); + await expect(page.locator('[data-slot="day-focus"]')).toBeVisible(); + }); +}); + +test.describe("the dashboard and the list of all values", () => { + test.use({ storageState: DAY_DOORS_STORAGE_STATE_PATH }); + + test.beforeEach(async ({ page, context }, testInfo) => { + const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000"; + await context.addCookies([ + { name: "healthlog-locale", value: "en", url: baseURL }, + ]); + await mockDay(page, { index: "full" }); + }); + + test("a dashboard chart opens its day; the today hero offers none", async ({ + page, + }, testInfo) => { + if (!isPhone(testInfo)) { + await page.setViewportSize({ width: 1440, height: 900 }); + } + await page.goto("/"); + await expect( + page.locator( + '[data-slot^="today-hero"] [data-slot="day-link"], [data-slot^="today-hero"] [data-day-links="true"]', + ), + ).toHaveCount(0); + const plot = page + .locator('[data-slot="chart-plot"][data-day-links="true"]') + .first(); + await expect(plot).toBeVisible({ timeout: 20_000 }); + await openFromPlot(page, plot, testInfo); + // Closing leaves the dashboard on today. + await page.locator('[data-slot="day-close"]').click(); + await expect(panel(page)).toHaveCount(0); + await expect(page).toHaveURL(/\/$/); + }); + + test("a date in a metric's list of all values opens its day", async ({ + page, + }, testInfo) => { + await page.goto("/insights/values/WEIGHT"); + const link = page.locator('[data-slot="day-link"]:visible').first(); + await expect(link).toBeVisible(); + const day = await link.getAttribute("data-day"); + if (isPhone(testInfo)) await link.tap(); + else await link.click(); + await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute( + "data-day", + day!, + ); + }); + + test("the docked day, open or collapsed, moves the Coach button off it", async ({ + page, + }, testInfo) => { + test.skip(isPhone(testInfo), "docked from 1280 px"); + await page.setViewportSize({ width: 1440, height: 900 }); + // The launcher follows the `coach` capability, which needs a provider + // the suite cannot reach, so the capability reads available here; this + // journey is about where the button sits, not what it opens. + await serveAiBlock(page, aiBlockAvailable()); + await page.goto("/insights/weight"); + const fab = page.locator('[data-slot="coach-fab"]'); + await expect(fab).toBeVisible(); + const plot = page + .locator('[data-slot="chart-plot"][data-day-links="true"]') + .first(); + await openFromPlot(page, plot, testInfo); + const panelBox = (await panel(page).boundingBox())!; + // Wait out the 150 ms move, then the launcher sits left of the column. + await expect + .poll(async () => { + const b = (await fab.boundingBox())!; + return b.x + b.width; + }) + .toBeLessThanOrEqual(panelBox.x); + + // Folded, the day keeps its strip; the launcher stays clear of it. + await page.locator('[data-slot="day-close"]').click(); + const edge = (await page.locator('[data-slot="day-strip"]').boundingBox())!; + await expect + .poll(async () => { + const b = (await fab.boundingBox())!; + return b.x + b.width; + }) + .toBeLessThanOrEqual(edge.x); + }); +}); + +test.describe("the readiness link into a medication's edit form", () => { + test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH }); + + test("?edit=1 opens the medication's edit form and leaves the URL", async ({ + page, + }) => { + const res = await page.request.get("/api/medications"); + const body = (await res.json()) as { + data: Array<{ id: string }> | { medications?: Array<{ id: string }> }; + }; + const list = Array.isArray(body.data) + ? body.data + : (body.data.medications ?? []); + expect(list.length).toBeGreaterThan(0); + await page.goto(`/medications/${list[0]!.id}?edit=1`); + await expect(page.getByRole("dialog")).toBeVisible(); + // A reload after closing the form must not open it again. + await expect(page).not.toHaveURL(/[?&]edit=1/); + }); +}); diff --git a/e2e/day-view.spec.ts b/e2e/day-view.spec.ts new file mode 100644 index 000000000..4337d3f33 --- /dev/null +++ b/e2e/day-view.spec.ts @@ -0,0 +1,380 @@ +import type { Page, TestInfo } from "@playwright/test"; + +import { mockDay } from "./setup/day-mock"; +import { expect, test } from "./setup/test"; +import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup"; +import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture"; + +/** + * v1.42 — the day view and its doors. + * + * The record is the phone-sweep account `globalSetup` seeds: a week of blood + * pressure, two lab readings, mood and an episode. The day itself + * (`GET /api/day/{date}`, `GET /api/day/index`) is answered by a route mock in + * the contract's shape, so the journeys pin the client: which door opens which + * day, what the history does, and where no door is. + * + * - a chart point opens the day beside the chart (desktop); + * - on a touch screen the first tap shows the value and the tooltip offers + * the day, the second opens it as a bottom sheet; + * - a date in a list opens the day while the row keeps its own target; + * - `?day=` opens the layer on any page, Back and Escape close it, a date in + * the future is dropped; + * - stepping to the neighbouring days never adds history entries; + * - docked, the day collapses to a narrow edge that brings it back on any + * page; a sheet closes for good; + * - the dashboard's today area offers no door; its charts below do. + * + * Stable data attributes only: `day-panel` (+ `data-shell`), `day-link`, + * the header's `day-date-button` and its `day-date-picker` (days keyed by + * `data-date-key`, dotted by `data-entries`, and `day-date-today`), + * `chart-plot[data-day-links]`, `chart-tooltip-open-day`, `day-prev` / + * `day-next` / `day-close`, and the docked day's strip `day-strip` + * (+ `data-day`, `data-state`) with its `day-strip-toggle`. + */ + +const panel = (page: Page) => page.locator('[data-slot="day-panel"]'); + +async function shot(page: Page, testInfo: TestInfo, name: string) { + const path = testInfo.outputPath(`${name}.png`); + await page.screenshot({ path }); + await testInfo.attach(name, { path, contentType: "image/png" }); + const dir = process.env.DAY_SHOTS_DIR; + if (dir) { + await page.screenshot({ + path: `${dir}/${name}-${testInfo.project.name}.png`, + }); + } +} + +function isoDaysAgo(days: number): string { + const d = new Date(Date.now() - days * 86_400_000); + return d.toISOString().slice(0, 10); +} + +test.describe("the day view", () => { + test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH }); + + test.beforeEach(async ({ page, context }, testInfo) => { + const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000"; + await context.addCookies([ + { name: "healthlog-locale", value: "en", url: baseURL }, + ]); + await mockDay(page); + }); + + test("a chart point opens its day beside the chart; Back closes it", async ({ + page, + }, testInfo) => { + test.skip(testInfo.project.name !== "chromium-desktop", "desktop click"); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/insights/blood-pressure"); + const plot = page.locator( + '[data-slot="chart-plot"][data-day-links="true"]', + ); + await expect(plot).toBeVisible(); + // The plot's right half holds the most recent points. + const box = (await plot.boundingBox())!; + await page.mouse.move(box.x + box.width * 0.8, box.y + box.height / 2); + await page.mouse.click(box.x + box.width * 0.8, box.y + box.height / 2); + + await expect(panel(page)).toBeVisible(); + await expect(panel(page)).toHaveAttribute("data-shell", "docked"); + // A landmark beside the page, named by its date. + await expect(panel(page)).toHaveJSProperty("tagName", "ASIDE"); + await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/); + await expect(page.locator('[data-slot="day-focus"]')).toBeVisible(); + // The chart beside it stays usable: the panel is not modal. + await expect(page.locator('[data-slot="sheet-overlay"]')).toHaveCount(0); + + await page.goBack(); + await expect(panel(page)).toHaveCount(0); + await expect(page).not.toHaveURL(/[?&]day=/); + }); + + test("a date in a list opens the day; the row keeps its own target", async ({ + page, + }) => { + await page.goto("/labs"); + await page + .locator('[data-slot="lab-list-analyte"]') + .filter({ hasText: MOBILE_ROUTES_ANALYTE }) + .first() + .click(); + // The marker's readings, each dated. + await page.locator('a[href$="/values"]').first().click(); + const link = page.locator('[data-slot="day-link"]:visible').first(); + await expect(link).toBeVisible(); + const day = await link.getAttribute("data-day"); + const before = page.url(); + await link.click(); + await expect(panel(page)).toBeVisible(); + await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute( + "data-day", + day!, + ); + // Same page underneath: the click opened the day, not the reading. + expect(new URL(page.url()).pathname).toBe(new URL(before).pathname); + // The reading the person came from sits on top, with the one before it. + await expect(page.locator('[data-slot="day-focus"]')).toBeVisible(); + }); + + test("?day= opens the layer on any page; Escape closes it", async ({ + page, + }) => { + const day = isoDaysAgo(3); + await page.goto(`/mood?day=${day}`); + await expect(panel(page)).toBeVisible(); + await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute( + "data-day", + day, + ); + await page.keyboard.press("Escape"); + await expect(panel(page)).toHaveCount(0); + await expect(page).toHaveURL(/\/mood$/); + }); + + test("the day's scores sit below its values, each opening its own page", async ({ + page, + }) => { + await page.goto(`/mood?day=${isoDaysAgo(3)}`); + await expect(panel(page)).toBeVisible(); + const scores = page.locator('[data-slot="day-scores"]'); + await expect(scores).toBeVisible(); + const tiles = scores.locator('[data-slot="day-score"]'); + await expect(tiles).toHaveCount(2); + await expect( + scores.locator('[data-slot="day-score"][data-score="healthScore"]'), + ).toHaveAttribute("href", "/insights/health-score"); + await expect( + scores.locator('[data-slot="day-score"][data-score="strain"]'), + ).toHaveAttribute("href", "/insights/scores/strain"); + // A score the day does not hold has no tile. + await expect( + scores.locator('[data-slot="day-score"][data-score="readiness"]'), + ).toHaveCount(0); + // Below the values, above what happened. + const order = await page + .locator( + '[data-slot="day-values"], [data-slot="day-scores"], [data-slot="day-events"]', + ) + .evaluateAll((nodes) => + nodes.map((node) => node.getAttribute("data-slot")), + ); + expect(order).toEqual(["day-values", "day-scores", "day-events"]); + }); + + test("the docked day folds beside its strip and comes back on another page", async ({ + page, + }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "docked from 1280 px", + ); + const day = isoDaysAgo(3); + await page.goto(`/mood?day=${day}`); + await expect(panel(page)).toHaveAttribute("data-shell", "docked"); + + await page.locator('[data-slot="day-close"]').click(); + await expect(panel(page)).toHaveCount(0); + await expect(page).toHaveURL(/\/mood$/); + // The strip stays, holding the day, and focus waits on it. + const rail = page.locator('[data-slot="day-strip"]'); + const expand = page.locator('[data-slot="day-strip-toggle"]'); + await expect(rail).toHaveAttribute("data-day", day); + await expect(rail).toHaveAttribute("data-state", "closed"); + await expect(expand).toBeFocused(); + await expect(expand).toHaveAttribute("aria-expanded", "false"); + await expect(expand).toHaveAttribute("aria-controls", "day-docked-panel"); + expect(await expand.getAttribute("aria-label")).toMatch(/^Show /); + expect((await rail.boundingBox())!.width).toBeLessThanOrEqual(40); + + // Another page: the edge is still there and brings the same day back, + // from the keyboard too. + await page.goto("/labs"); + await expect(rail).toHaveAttribute("data-day", day); + await expand.focus(); + await page.keyboard.press("Enter"); + await expect(panel(page)).toHaveAttribute("data-shell", "docked"); + await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute( + "data-day", + day, + ); + await expect(page).toHaveURL(new RegExp(`/labs\\?day=${day}$`)); + await expect(panel(page).locator("h2").first()).toBeFocused(); + // Open, the strip stays where it was and says so. + await expect(rail).toHaveAttribute("data-state", "open"); + await expect(expand).toHaveAttribute("aria-expanded", "true"); + }); + + test("the date opens a calendar; a picked day opens, Today goes to today", async ({ + page, + }, testInfo) => { + const desktop = testInfo.project.name === "chromium-desktop"; + // The days of the month that hold anything carry a dot. + await mockDay(page, { index: "full" }); + const day = isoDaysAgo(5); + await page.goto(`/mood?day=${day}`); + await expect(panel(page)).toHaveAttribute( + "data-shell", + desktop ? "docked" : "bottom", + ); + const date = panel(page).locator('[data-slot="day-date-button"]'); + const picker = page.locator('[data-slot="day-date-picker"]'); + // The date stays one line in the header, the arrows beside it. + await expect(date).toHaveAttribute("aria-expanded", "false"); + await expect(panel(page).locator('[data-slot="day-prev"]')).toBeVisible(); + await shot(page, testInfo, "day-date-closed"); + + await date.click(); + await expect(picker).toBeVisible(); + await expect(date).toHaveAttribute("aria-expanded", "true"); + await expect(picker.locator(`[data-date-key="${day}"]`)).toHaveAttribute( + "data-selected-single", + "true", + ); + await expect(picker.locator('[data-entries="true"]').first()).toBeVisible(); + // Nothing past today can be picked. + const tomorrow = picker.locator(`[data-date-key="${isoDaysAgo(-2)}"]`); + if ((await tomorrow.count()) > 0) await expect(tomorrow).toBeDisabled(); + await shot(page, testInfo, "day-date-open"); + + // Another day of the same month: the 1st, or the 2nd when the open day + // is the 1st itself. + const first = `${day.slice(0, 8)}01`; + const target = first === day ? isoDaysAgo(4) : first; + const start = await page.evaluate(() => window.history.length); + await picker.locator(`[data-date-key="${target}"]`).click(); + await expect(picker).toHaveCount(0); + await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute( + "data-day", + target, + ); + await expect(page).toHaveURL(new RegExp(`[?&]day=${target}`)); + // Like a step: no new history entry, and focus back on the date. + expect(await page.evaluate(() => window.history.length)).toBe(start); + await expect(date).toBeFocused(); + + // The keyboard: Enter opens, Escape closes the calendar only. + await page.keyboard.press("Enter"); + await expect(picker).toBeVisible(); + // The calendar has the keyboard before Escape is pressed. + await expect + .poll(() => + page.evaluate( + () => + document.activeElement?.closest( + '[role="dialog"]:not([data-slot="day-panel"])', + ) != null, + ), + ) + .toBe(true); + await page.keyboard.press("Escape"); + await expect(picker).toHaveCount(0); + await expect(panel(page)).toBeVisible(); + await expect(date).toBeFocused(); + + // Today. + await page.keyboard.press("Space"); + await expect(picker).toBeVisible(); + await picker.locator('[data-slot="day-date-today"]').click(); + await expect(picker).toHaveCount(0); + await expect(panel(page).locator('[data-slot="day-next"]')).toBeDisabled(); + }); + + test("a sheet closes for good and leaves no strip behind", async ({ + page, + }, testInfo) => { + const desktop = testInfo.project.name === "chromium-desktop"; + // Below 1280 px the day is a sheet from the right; on a phone, from the + // bottom. Neither collapses. + if (desktop) await page.setViewportSize({ width: 1024, height: 800 }); + const day = isoDaysAgo(3); + await page.goto(`/mood?day=${day}`); + await expect(panel(page)).toHaveAttribute( + "data-shell", + desktop ? "sheet" : "bottom", + ); + await expect(page.locator('[data-slot="day-close"]')).toHaveAttribute( + "aria-label", + "Close day", + ); + await page.locator('[data-slot="day-close"]').click(); + await expect(panel(page)).toHaveCount(0); + await expect(page).toHaveURL(/\/mood$/); + await expect(page.locator('[data-slot="day-strip"]')).toHaveCount(0); + }); + + test("a future or malformed ?day= is dropped without a word", async ({ + page, + }) => { + await page.goto("/mood?day=2999-01-01"); + await expect(page).toHaveURL(/\/mood$/); + await expect(panel(page)).toHaveCount(0); + await page.goto("/mood?day=2026-02-30"); + await expect(page).toHaveURL(/\/mood$/); + await expect(panel(page)).toHaveCount(0); + }); + + test("stepping between days never adds a history entry", async ({ page }) => { + await page.goto("/mood"); + await page.locator('[data-slot="day-link"]:visible').first().click(); + await expect(panel(page)).toBeVisible(); + const start = await page.evaluate(() => window.history.length); + const first = await page + .locator('[data-slot="day-view"]') + .getAttribute("data-day"); + for (let i = 0; i < 3; i += 1) { + await page.locator('[data-slot="day-prev"]').click(); + } + await expect(page.locator('[data-slot="day-view"]')).not.toHaveAttribute( + "data-day", + first!, + ); + expect(await page.evaluate(() => window.history.length)).toBe(start); + // One Back leaves the layer, however far it stepped. + await page.goBack(); + await expect(panel(page)).toHaveCount(0); + await expect(page).toHaveURL(/\/mood$/); + }); + + test("on a phone the first tap shows the value, the second opens the day", async ({ + page, + }, testInfo) => { + test.skip(testInfo.project.name !== "chromium-mobile", "touch only"); + await page.goto("/insights/blood-pressure"); + const plot = page.locator( + '[data-slot="chart-plot"][data-day-links="true"]', + ); + await plot.scrollIntoViewIfNeeded(); + await expect(plot).toBeVisible(); + const box = (await plot.boundingBox())!; + await page.touchscreen.tap(box.x + box.width * 0.8, box.y + box.height / 2); + const open = page.locator('[data-slot="chart-tooltip-open-day"]'); + await expect(open).toBeVisible(); + // A tap alone never opens a sheet. + await expect(panel(page)).toHaveCount(0); + await open.tap(); + await expect(panel(page)).toBeVisible(); + await expect(panel(page)).toHaveAttribute("data-shell", "bottom"); + // The tapped value heads the day. + await expect(page.locator('[data-slot="day-focus"]')).toBeVisible(); + }); + + test("the dashboard's today area offers no day door", async ({ page }) => { + await page.goto("/"); + await expect( + page.locator('[data-slot="main-content-wrapper"]'), + ).toBeVisible(); + // The hero is today: nothing in it opens another day. (The charts + // further down do; `day-doors.spec.ts` walks one.) + await expect( + page.locator('[data-slot^="today-hero"]').first(), + ).toBeVisible(); + await expect( + page.locator( + '[data-slot^="today-hero"] [data-slot="day-link"], [data-slot^="today-hero"] [data-day-links="true"]', + ), + ).toHaveCount(0); + }); +}); diff --git a/e2e/delegated-writes.spec.ts b/e2e/delegated-writes.spec.ts index 68150fffc..5c7cfa565 100644 --- a/e2e/delegated-writes.spec.ts +++ b/e2e/delegated-writes.spec.ts @@ -221,7 +221,14 @@ test.describe("delegated writes", () => { // reads "Add") and then for a `value` input (the form opens on blood // pressure, which has three). Both were wrong from the day they were // written and nobody found out, because the whole file was skipping. - await page.locator('[data-slot="measurement-add"]').click(); + // The owner's list may be empty, in which case its empty state carries + // the add action and the header drops its own. + await page + .locator( + '[data-slot="measurement-add"], [data-slot="measurement-add-first"]', + ) + .first() + .click(); await page.locator("#sys").fill("124"); await page.locator("#dia").fill("78"); await page.getByRole("button", { name: /^save$/i }).click(); diff --git a/e2e/dialog-horizontal-overflow.spec.ts b/e2e/dialog-horizontal-overflow.spec.ts index 54267f858..95302437e 100644 --- a/e2e/dialog-horizontal-overflow.spec.ts +++ b/e2e/dialog-horizontal-overflow.spec.ts @@ -70,13 +70,15 @@ const DIALOGS: DialogCase[] = [ { name: "measurements add", path: "/measurements", - trigger: /^add$/i, + // While a list is empty the header drops its add button and the empty + // state carries the action, so either one opens the dialog. + trigger: /^(add|add your first measurement)$/i, ready: '[data-slot="date-time-field"]', }, { name: "mood add", path: "/mood", - trigger: /^add$/i, + trigger: /^(add|log your first mood)$/i, // The mood sheet opens on the five-face quick check; the timestamp row // (and everything else) appears once a face is picked. Measuring the // quick check alone would measure a sheet that has almost nothing in it. @@ -92,7 +94,7 @@ const DIALOGS: DialogCase[] = [ { name: "labs add", path: "/labs", - trigger: /^add$/i, + trigger: /^(add|add your first result)$/i, ready: '[data-slot="date-time-field"]', }, // The labs sheet's footer holds three buttons (Cancel, Save & add another, @@ -101,7 +103,7 @@ const DIALOGS: DialogCase[] = [ { name: "labs add (German)", path: "/labs", - trigger: /^hinzufügen$/i, + trigger: /^(hinzufügen|ersten wert erfassen)$/i, ready: '[data-slot="date-time-field"]', locale: "de", }, diff --git a/e2e/documents-ai.spec.ts b/e2e/documents-ai.spec.ts index 9f2fad569..216db06ec 100644 --- a/e2e/documents-ai.spec.ts +++ b/e2e/documents-ai.spec.ts @@ -427,7 +427,7 @@ test.describe("document vault — AI assist + content search", () => { // The PDF is refused locally (tesseract reads images only) — the error // shows and the server suggest route is never called. await expect( - sheet.getByText("Local OCR reads images only — not this file."), + sheet.getByText("Local OCR reads images only, not this file."), ).toBeVisible(); await page.waitForTimeout(300); expect(suggestCalls).toBe(0); diff --git a/e2e/documents-procedure-filter.spec.ts b/e2e/documents-procedure-filter.spec.ts index f3d8a0450..28f6aa563 100644 --- a/e2e/documents-procedure-filter.spec.ts +++ b/e2e/documents-procedure-filter.spec.ts @@ -89,7 +89,7 @@ test.describe("document vault — procedure filter", () => { const knee = menu.getByRole("menuitemcheckbox", { name: /Knee arthroscopy/, }); - await expect(knee).toContainText("Knee · Left"); + await expect(knee).toContainText("Knee (Left)"); await expect( menu.getByRole("menuitemcheckbox", { name: /Appendectomy/ }), ).toBeVisible(); diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts index ac82ceebe..8e5a8f76d 100644 --- a/e2e/documents.spec.ts +++ b/e2e/documents.spec.ts @@ -151,6 +151,61 @@ test.describe("document vault", () => { await ensureVaultFixture(); }); + // ── v1.42 — the archive as a month grid of preview tiles ────────────── + + for (const [width, columns] of [ + [390, 2], + [1440, 4], + ] as const) { + test(`the archive is a month grid of preview tiles, ${columns} across at ${width} px`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: width === 390 ? 844 : 900 }); + await page.goto("/documents"); + const firstRow = page.locator('[data-slot="document-month-row"]').first(); + await expect(firstRow).toBeVisible({ timeout: 15_000 }); + + // The months run side by side by default: a month is marked above + // its first document instead of taking a heading row of its own. + await expect( + page.locator('[data-slot="document-timeline"]'), + ).toHaveAttribute("data-arrangement", "flow"); + await expect( + page.locator('[data-slot="document-flow-month"]').first(), + ).toBeVisible(); + + // Tiles with the preview on top, and at most `columns` side by side: + // the fullest mounted row holds exactly that many, on one line. + const tiles = page.locator( + '[data-slot="document-month-row"] [data-slot="document-card"]', + ); + await expect(tiles.first()).toHaveAttribute("data-variant", "tile"); + await expect( + tiles.first().locator('[data-slot="document-preview"]'), + ).toBeVisible(); + const rows = await page + .locator('[data-slot="document-month-row"]') + .evaluateAll((els) => + els.map((row) => + Array.from(row.querySelectorAll('[data-slot="document-card"]')).map( + (el) => Math.round(el.getBoundingClientRect().top), + ), + ), + ); + const fullest = rows.reduce((a, b) => (b.length > a.length ? b : a), []); + expect(fullest.length).toBe(columns); + expect(new Set(fullest).size).toBe(1); + + // Nothing on the page scrolls sideways. + const overflow = await page.evaluate( + () => + document.documentElement.scrollWidth - + document.documentElement.clientWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); + }); + } + // ── §1A — the 30-second doctor flow, three independent routes ───────── test("doctor flow A: the type filter reaches the MRT report", async ({ @@ -413,6 +468,8 @@ test.describe("document vault", () => { const sheet = page.getByRole("dialog", { name: `${title}.pdf` }); await expect(sheet).toBeVisible(); await sheet.getByRole("button", { name: "Delete" }).click(); + // v1.42 — the sheet's Delete asks first, like every vault delete. + await page.locator('[data-slot="document-detail-delete-confirm"]').click(); // Undo toast → restore → the card returns. // @@ -714,7 +771,7 @@ test.describe("document vault", () => { await uploadViaPicker(page, `${marker}-copy.pdf`, bytes); await expect( - page.getByText("Already stored — highlighting the existing document."), + page.getByText("Already stored. The existing document is highlighted."), ).toBeVisible(); // One row, not two — the copy never landed. await expect(openButton(page, `${marker}-copy.pdf`)).not.toBeVisible(); diff --git a/e2e/keyboard-shortcuts.spec.ts b/e2e/keyboard-shortcuts.spec.ts new file mode 100644 index 000000000..0c1e64031 --- /dev/null +++ b/e2e/keyboard-shortcuts.spec.ts @@ -0,0 +1,216 @@ +import AxeBuilder from "@axe-core/playwright"; +import type { Page } from "@playwright/test"; + +import { mockDay } from "./setup/day-mock"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { expect, test } from "./setup/test"; +import { mockTimeline } from "./utils/mock-timeline"; + +/** + * v1.42 — the keyboard shortcuts. + * + * - `g t` opens the timeline while its module is on, and does nothing while + * it is off; + * - keys typed into a field stay in the field; + * - `?` opens the list: a titled dialog that keeps focus inside and closes + * on Escape; the account menu opens the same list; + * - `n` opens the add menu; + * - `[` and `]` step the open day, never past today. + * + * The module map is the account's real `/api/auth/me` with only `timeline` + * rewritten, so the switch is never moved on a shared account. Desktop only: + * a phone has no keyboard to press these on. + */ + +test.use({ storageState: STORAGE_STATE_PATH }); + +const TODAY = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", +}).format(new Date()); + +function shiftDay(key: string, delta: number): string { + const d = new Date(`${key}T12:00:00Z`); + d.setUTCDate(d.getUTCDate() + delta); + return d.toISOString().slice(0, 10); +} + +async function withTimelineModule(page: Page, on: boolean): Promise { + await page.route("**/api/auth/me", async (route) => { + const res = await route.fetch(); + const json = await res.json().catch(() => null); + if (!json?.data?.modules) return route.fulfill({ response: res }); + json.data.modules.timeline = on; + return route.fulfill({ + response: res, + body: JSON.stringify(json), + headers: { ...res.headers(), "content-type": "application/json" }, + }); + }); +} + +/** The shell is up and its listener registered. */ +async function shellReady(page: Page): Promise { + await expect(page.locator("#main-content")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator('[data-slot="top-bar"]')).toBeVisible(); +} + +const helpDialog = (page: Page) => + page.getByRole("dialog", { name: "Keyboard shortcuts" }); + +test.describe("keyboard shortcuts", () => { + test.beforeEach(async ({ context }, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "keyboard journeys run on the desktop project", + ); + const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000"; + await context.addCookies([ + { name: "healthlog-locale", value: "en", url: baseURL }, + ]); + }); + + test("g t opens the timeline while its module is on", async ({ page }) => { + await withTimelineModule(page, true); + await mockTimeline(page, "ready", TODAY); + await page.goto("/"); + await shellReady(page); + await page.keyboard.press("g"); + await page.keyboard.press("t"); + await expect(page).toHaveURL(/\/timeline$/); + }); + + test("g t does nothing while the timeline module is off", async ({ + page, + }) => { + await withTimelineModule(page, false); + // Every client navigation, in order, so "nothing happened" is a fact + // rather than a wait. + await page.addInitScript(() => { + const log: string[] = []; + (window as unknown as { __pushed: string[] }).__pushed = log; + const push = history.pushState.bind(history); + history.pushState = (state, unused, url) => { + if (url != null) log.push(String(url)); + push(state, unused, url); + }; + }); + await page.goto("/insights"); + await shellReady(page); + await page.keyboard.press("g"); + await page.keyboard.press("t"); + // The positive control: the same reader opens a page that is on. + await page.keyboard.press("g"); + await page.keyboard.press("d"); + await expect(page).toHaveURL(/\/$/); + const pushed = await page.evaluate( + () => (window as unknown as { __pushed: string[] }).__pushed, + ); + expect(pushed.some((url) => url.includes("/timeline"))).toBe(false); + }); + + test("keys typed into a field stay in the field", async ({ page }) => { + await withTimelineModule(page, true); + await page.goto("/"); + const main = page.locator("#main-content"); + await expect(main).toBeVisible({ timeout: 20_000 }); + // A plain field in the page: the rule is about the focused element, not + // about which page carries it. + await page.evaluate(() => { + const field = document.createElement("input"); + field.setAttribute("data-testid", "probe-field"); + field.setAttribute("aria-label", "Probe"); + document.getElementById("main-content")?.prepend(field); + }); + const field = page.getByTestId("probe-field"); + await expect(field).toBeVisible(); + await field.focus(); + await page.keyboard.type("gt n ? ["); + await expect(field).toHaveValue("gt n ? ["); + await expect(page).toHaveURL(/\/$/); + await expect(helpDialog(page)).toHaveCount(0); + await expect(page.getByTestId("capture-picker-options")).toHaveCount(0); + }); + + test("? opens a titled list that keeps focus and closes on Escape", async ({ + page, + }) => { + await page.goto("/"); + await shellReady(page); + await page.keyboard.press("Shift+?"); + const dialog = helpDialog(page); + await expect(dialog).toBeVisible(); + await expect( + dialog.locator('[data-slot="keyboard-shortcuts"] [data-group]'), + ).toHaveCount(3); + await expect(dialog.locator('[data-shortcut="go-d"] kbd')).toHaveText([ + "g", + "d", + ]); + + // Focus trap: Tab and Shift+Tab never leave the dialog. + for (const step of ["Tab", "Tab", "Tab", "Shift+Tab", "Shift+Tab"]) { + await page.keyboard.press(step); + expect( + await dialog.evaluate((el) => el.contains(document.activeElement)), + ).toBe(true); + } + + // While the list is open, the page's shortcuts are off. + await page.keyboard.press("g"); + await page.keyboard.press("i"); + await expect(page).toHaveURL(/\/$/); + + const axe = await new AxeBuilder({ page }) + .include('[role="dialog"]') + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect(axe.violations).toEqual([]); + + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + }); + + test("the account menu opens the same list", async ({ page }) => { + await page.goto("/"); + await shellReady(page); + await page + .getByRole("button", { name: "User menu" }) + .locator("visible=true") + .first() + .click(); + await page.locator('[data-slot="open-keyboard-shortcuts"]').click(); + await expect(helpDialog(page)).toBeVisible(); + }); + + test("n opens the add menu", async ({ page }) => { + await page.goto("/insights"); + await shellReady(page); + await page.keyboard.press("n"); + await expect(page.getByTestId("capture-picker-options")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("capture-picker-options")).toHaveCount(0); + }); + + test("[ and ] step the open day, never past today", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await mockDay(page); + const yesterday = shiftDay(TODAY, -1); + await page.goto(`/insights?day=${yesterday}`); + await shellReady(page); + const panel = page.locator('[data-slot="day-panel"]'); + await expect(panel).toHaveAttribute("data-shell", "docked"); + // Focus on the page, not in the panel: docked, the keys still reach it. + await page.locator("#main-content").focus(); + + await page.keyboard.press("["); + await expect(page).toHaveURL(new RegExp(`[?&]day=${shiftDay(TODAY, -2)}`)); + await page.keyboard.press("]"); + await page.keyboard.press("]"); + await expect(page).toHaveURL(new RegExp(`[?&]day=${TODAY}`)); + await page.keyboard.press("]"); + await expect(page).toHaveURL(new RegExp(`[?&]day=${TODAY}`)); + }); +}); diff --git a/e2e/measurement-flow.spec.ts b/e2e/measurement-flow.spec.ts index 28b48c145..62e4d2137 100644 --- a/e2e/measurement-flow.spec.ts +++ b/e2e/measurement-flow.spec.ts @@ -135,7 +135,13 @@ test.describe("add measurement flow", () => { // v1.5 phase-5: the menu items now have distinct labels — the // measurement entry says "Measurement" / "Messung" instead of "Add", // so we can target it directly. - await page.getByRole("menuitem", { name: /measurement|messung/i }).click(); + // 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 + .getByRole("menuitem", { name: /measurement|messung/i }) + .or(page.getByRole("button", { name: /^(measurement|messung)\b/i })) + .first() + .click(); // Form should be visible inside the dialog. Switch type to WEIGHT // (combo-box defaults to BLOOD_PRESSURE). diff --git a/e2e/mobile-route-overflow.spec.ts b/e2e/mobile-route-overflow.spec.ts index 2529cacdf..0972d705c 100644 --- a/e2e/mobile-route-overflow.spec.ts +++ b/e2e/mobile-route-overflow.spec.ts @@ -48,6 +48,8 @@ const NOT_SIGNED_IN: Record = { "/invite/[token]": "an invitation landing page for somebody without an account", "/c/[token]": "the clinician share-link page, opened without a session", + "/claim/[token]": + "the managed-profile handover link, opened by the person without a session (covered by v142-managed-profile-handover.spec.ts)", "/confirm-access": "the re-proof page, reached only from a stale session", "/enroll-mfa": "the forced second-factor enrolment of a policy-bound account", "/onboarding": "the first-run setup flow; covered by setup-flow-*.spec.ts", diff --git a/e2e/mood-timestamp.spec.ts b/e2e/mood-timestamp.spec.ts index 28daa3df8..f157aa674 100644 --- a/e2e/mood-timestamp.spec.ts +++ b/e2e/mood-timestamp.spec.ts @@ -32,7 +32,9 @@ test.describe("mood timestamp capture", () => { }); await page.goto("/mood", { waitUntil: "domcontentloaded" }); - await page.getByRole("button", { name: "Add", exact: true }).click(); + // The list is mocked empty, so its empty state carries the add action + // (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(); const details = page.getByRole("button", { name: "Note & details" }); diff --git a/e2e/onboarding-tour-passthrough.spec.ts b/e2e/onboarding-tour-passthrough.spec.ts index 003bd5e01..433797e8b 100644 --- a/e2e/onboarding-tour-passthrough.spec.ts +++ b/e2e/onboarding-tour-passthrough.spec.ts @@ -141,7 +141,10 @@ test.describe("v1.4.33 F2 — onboarding tour passes clicks through to the page" // so a naive "dim everything outside the spotlight rect" overlay // covers it. The fix makes the entire tour layer pointer-events: // none — every dim panel and the spotlight ring are visual only. - const quickAddButton = page.locator('[data-tour-id="dashboard-quick-add"]'); + // One add button per shell carries the tour id; the other is hidden. + const quickAddButton = page + .locator('[data-tour-id="dashboard-quick-add"]') + .filter({ visible: true }); await expect(quickAddButton).toBeVisible(); // Forensic guard — at the quick-add button's centre, every element @@ -153,9 +156,11 @@ test.describe("v1.4.33 F2 — onboarding tour passes clicks through to the page" // the stack is blocking, instead of just observing the symptom // ("button click did not toggle aria-expanded"). const tourBlockers = await page.evaluate(() => { - const btn = document.querySelector( - '[data-tour-id="dashboard-quick-add"]', - ); + const btn = [ + ...document.querySelectorAll( + '[data-tour-id="dashboard-quick-add"]', + ), + ].find((el) => el.getClientRects().length > 0); if (!btn) return ["no-button"]; const rect = btn.getBoundingClientRect(); const x = rect.left + rect.width / 2; @@ -192,7 +197,14 @@ test.describe("v1.4.33 F2 — onboarding tour passes clicks through to the page" await quickAddButton.click(); await expect(quickAddButton).toHaveAttribute("aria-expanded", "true"); - await expect(page.getByRole("menuitem").first()).toBeVisible(); + // The desktop rail opens a dropdown; the phone shell opens the capture + // sheet the bottom nav's plus opens. Either proves the click landed. + await expect( + page + .getByRole("menuitem") + .or(page.locator('[role="dialog"][data-state="open"]')) + .first(), + ).toBeVisible(); // The tour stays mounted — the click went to the underlying button, // not anything in the tour layer. diff --git a/e2e/pwa-mobile.spec.ts b/e2e/pwa-mobile.spec.ts new file mode 100644 index 000000000..dc93574f1 --- /dev/null +++ b/e2e/pwa-mobile.spec.ts @@ -0,0 +1,239 @@ +import type { Page } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * The installed-app behaviours a phone depends on, measured in the Pixel 5 + * project (a touch screen: `pointer: coarse`). + * + * 1. The "new version" hint stands above the bottom bar instead of on it, + * and its Reload action reloads the page. + * 2. With the on-screen keyboard open, a bottom sheet stands on the + * keyboard (its Save button stays visible) and the bottom bar steps + * aside. Playwright has no on-screen keyboard, so the visual viewport is + * replaced by a stand-in whose height the test shrinks the way a + * keyboard does; what is asserted is the page's answer to that signal. + * 3. No form field a phone can focus is set below 16 px, in portrait or + * held sideways, because iOS Safari zooms the page into such a field. + * 4. Held sideways, the sign-in card is not centred out of reach. + * + * The account is the populated mobile-sweep account; nothing here writes. + */ + +const VISUAL_VIEWPORT_STAND_IN = () => { + const stand = new EventTarget() as EventTarget & { + height: number; + width: number; + offsetTop: number; + offsetLeft: number; + pageTop: number; + pageLeft: number; + scale: number; + }; + stand.height = window.innerHeight; + stand.width = window.innerWidth; + stand.offsetTop = 0; + stand.offsetLeft = 0; + stand.pageTop = 0; + stand.pageLeft = 0; + stand.scale = 1; + Object.defineProperty(window, "visualViewport", { + configurable: true, + get: () => stand, + }); + (window as unknown as { __viewport: typeof stand }).__viewport = stand; +}; + +async function setKeyboard(page: Page, covered: number) { + await page.evaluate((px) => { + const stand = ( + window as unknown as { + __viewport: EventTarget & { height: number }; + } + ).__viewport; + stand.height = window.innerHeight - px; + stand.dispatchEvent(new Event("resize")); + }, covered); +} + +async function openCaptureForm(page: Page) { + await page.goto("/"); + await page.getByTestId("bottom-nav-capture").click(); + const picker = page.locator('[data-slot="responsive-sheet-content"]'); + await expect(picker).toBeVisible(); + await picker + .locator('[data-slot="responsive-sheet-body"] button') + .first() + .click(); + const form = page + .locator('[data-slot="responsive-sheet-content"]') + .filter({ has: page.locator('[data-slot="responsive-sheet-footer"]') }); + await expect(form).toBeVisible(); + return form; +} + +/** Every visible field a phone can focus, with its computed font size. */ +async function fieldFontSizes(page: Page) { + return page.evaluate(() => + Array.from( + document.querySelectorAll( + "input:not([type=hidden]):not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]),textarea,select", + ), + ) + .filter((el) => { + const r = el.getBoundingClientRect(); + return r.width > 2 && r.height > 2; + }) + .map((el) => ({ + field: + el.getAttribute("name") || + el.id || + el.getAttribute("aria-label") || + el.tagName, + px: parseFloat(getComputedStyle(el).fontSize), + })), + ); +} + +test.describe("installed app on a phone", () => { + test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH }); + + test.beforeEach(({}, testInfo) => { + test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec"); + }); + + test("the new-version hint stands above the bottom bar and reloads", async ({ + page, + }) => { + await page.route("**/api/version", (route) => + route.fulfill({ + json: { + data: { version: "999.0.0", buildSha: "e2e" }, + error: null, + }, + }), + ); + await page.goto("/"); + const toast = page.locator("[data-sonner-toast]").filter({ + has: page.getByRole("button", { name: "Reload" }), + }); + // The poller's first check runs five seconds after mount. + await expect(toast).toBeVisible({ timeout: 15_000 }); + + const nav = page.locator('[data-slot="bottom-nav"]'); + const navBox = (await nav.boundingBox())!; + // Polled: the toast slides in from below, so an early sample is + // mid-animation. + await expect + .poll(async () => { + const b = (await toast.boundingBox())!; + return b.y + b.height; + }) + .toBeLessThanOrEqual(navBox.y); + // The bottom bar's controls are still reachable with the hint up. + await expect(page.getByTestId("bottom-nav-more")).toBeVisible(); + + const reloaded = page.waitForEvent("load"); + await toast.getByRole("button", { name: "Reload" }).click(); + await reloaded; + }); + + test("a bottom sheet stands on the on-screen keyboard and the bottom bar steps aside", async ({ + page, + }) => { + await page.addInitScript(VISUAL_VIEWPORT_STAND_IN); + const form = await openCaptureForm(page); + const save = form + .locator('[data-slot="responsive-sheet-footer"] button') + .last(); + const viewportHeight = page.viewportSize()!.height; + + const bottomEdge = async () => { + const b = (await form.boundingBox())!; + return Math.round(b.y + b.height); + }; + // Closed keyboard: the sheet sits on the bottom edge (polled: it slides + // in from below). + await expect.poll(bottomEdge).toBe(viewportHeight); + + const keyboard = 300; + await setKeyboard(page, keyboard); + await expect(page.locator("html")).toHaveAttribute("data-keyboard", "open"); + await expect.poll(bottomEdge).toBe(viewportHeight - keyboard); + const box = (await form.boundingBox())!; + expect(box.y).toBeGreaterThanOrEqual(0); + const saveBox = (await save.boundingBox())!; + expect(saveBox.y + saveBox.height).toBeLessThanOrEqual( + viewportHeight - keyboard, + ); + await expect(page.locator('[data-slot="bottom-nav"]')).toBeHidden(); + + // A toolbar-sized change is not a keyboard. + await setKeyboard(page, 60); + await expect(page.locator("html")).not.toHaveAttribute("data-keyboard"); + await expect.poll(bottomEdge).toBe(viewportHeight); + }); + + for (const viewport of [ + { width: 390, height: 844 }, + { width: 844, height: 390 }, + ]) { + test(`no focusable field is below 16 px at ${viewport.width}x${viewport.height} (iOS focus zoom)`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + const offenders: string[] = []; + for (const path of [ + "/settings", + "/documents", + "/settings/notifications", + ]) { + await page.goto(path); + await page.waitForLoadState("networkidle"); + const fields = await fieldFontSizes(page); + expect(fields.length, `${path} renders no field`).toBeGreaterThan(0); + for (const f of fields) + if (f.px < 16) offenders.push(`${path} ${f.field} ${f.px}px`); + } + if (viewport.width < 768) { + const form = await openCaptureForm(page); + await expect(form.locator("input").first()).toBeVisible(); + for (const f of await fieldFontSizes(page)) + if (f.px < 16) offenders.push(`capture ${f.field} ${f.px}px`); + } + expect(offenders).toEqual([]); + }); + } +}); + +test.describe("signed out, held sideways", () => { + test.use({ viewport: { width: 844, height: 390 } }); + + test.beforeEach(({}, testInfo) => { + test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec"); + }); + + test("the sign-in card stays reachable from its top edge", async ({ + page, + }) => { + await page.goto("/auth/login"); + const card = page.locator("main#main-content > *").first(); + await expect(card).toBeVisible(); + // Open the password form, the tallest state of the card. + await page + .getByRole("button", { name: /password|passwort/i }) + .first() + .click(); + await expect(page.locator("input[type=password]").first()).toBeVisible(); + const box = (await card.boundingBox())!; + expect(box.y).toBeGreaterThanOrEqual(0); + // And the document scrolls to reach whatever lies below the fold. + const scrollable = await page.evaluate( + () => + document.documentElement.scrollHeight >= window.innerHeight && + getComputedStyle(document.documentElement).overflowY !== "hidden", + ); + expect(scrollable).toBe(true); + }); +}); diff --git a/e2e/pwa-offline.spec.ts b/e2e/pwa-offline.spec.ts new file mode 100644 index 000000000..05701ce0d --- /dev/null +++ b/e2e/pwa-offline.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from "./setup/test"; +import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * What the installed app shows without a connection, with the service worker + * actually running. + * + * Runs in the `chromium-service-worker` project, the one that lets the worker + * register (every other project blocks it; see `playwright.config.ts`), and + * like the other spec there it uses no route mocks: the claim is about what + * the worker itself serves. + * + * 1. A page that is open when the connection drops stays the app and says + * so (the offline strip). + * 2. Opening a page without a connection gets the worker's offline page: + * the word "Offline" and a working retry control, never the browser's + * error. (Signed-in pages are never cached, by design: the proxy pins + * `private, no-store` on them because some carry the record in the + * HTML. So this is what an offline launch of the installed app shows.) + * 3. When the connection returns, that page reloads into the app by + * itself. + * + * The account is the populated mobile-sweep account; nothing here writes. + */ +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 ({ + page, + context, + }) => { + await page.goto("/medications"); + await page.waitForFunction( + () => navigator.serviceWorker?.controller !== null, + undefined, + { timeout: 20_000 }, + ); + await expect(page.locator("#main-content")).toBeVisible(); + await page.waitForLoadState("networkidle"); + + try { + await context.setOffline(true); + await expect(page.locator('[data-slot="offline-banner"]')).toBeVisible(); + + const response = await page.goto("/timeline"); + expect(response?.status()).toBe(503); + await expect(page.getByText("Offline", { exact: true })).toBeVisible(); + const retry = page.getByRole("link", { name: "Reload" }); + await expect(retry).toBeVisible(); + const box = (await retry.boundingBox())!; + expect(box.width).toBeGreaterThanOrEqual(44); + expect(box.height).toBeGreaterThanOrEqual(44); + + // Back online: the offline page reloads itself into the app. + const reloaded = page.waitForEvent("load"); + await context.setOffline(false); + await reloaded; + await expect(page.locator("#main-content")).toBeVisible(); + expect(new URL(page.url()).pathname).toBe("/timeline"); + } finally { + await context.setOffline(false); + } + }); +}); diff --git a/e2e/score-history.spec.ts b/e2e/score-history.spec.ts new file mode 100644 index 000000000..9fe5630f8 --- /dev/null +++ b/e2e/score-history.spec.ts @@ -0,0 +1,365 @@ +import type { Page, Route } from "@playwright/test"; + +import { mockDay } from "./setup/day-mock"; +import { mockPopulatedInsights } from "./utils/mock-populated-insights"; +import { SCORE_HISTORY_STORAGE_STATE_PATH } from "./setup/global-setup"; +import { expect, test } from "./setup/test"; +import type { DailyDigest } from "@/lib/daily/digest"; +import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures"; + +/** + * v1.42 — the score histories and the dashboard's top card switch. + * + * - The health score's page charts its daily course with the range tabs + * every chart offers; a tab asks the server for its window, the choice + * survives a reload, and a point opens its day. + * - Readiness, recovery, the sleep score and strain carry the same four + * tabs in the same component. + * - The top card goes away when the layout settings switch it off, and + * comes back when they switch it on. + * + * The score history and the derived scores are route mocks in the + * contract's shape (the server's answers are pinned by the integration + * suite); the range preference and the layout are this account's own, saved + * for real. Stable data attributes only. + */ + +test.use({ storageState: SCORE_HISTORY_STORAGE_STATE_PATH }); +// Every test writes this one account's layout blob, a Serializable +// read-modify-write; run in parallel they would abort each other's write. +test.describe.configure({ mode: "serial" }); + +const RANGES = ["7", "30", "90", "0"]; + +function dayKey(offset: number): string { + const now = new Date(); + const local = new Date( + now.toLocaleString("en-US", { timeZone: "Europe/Berlin" }), + ); + local.setDate(local.getDate() + offset); + const y = local.getFullYear(); + const m = String(local.getMonth() + 1).padStart(2, "0"); + const d = String(local.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +/** Answer the score history with one point per day of the window, max 40. */ +async function mockScoreHistory(page: Page): Promise { + const asked: number[] = []; + await page.route(/\/api\/insights\/score-history\?/, (route: Route) => { + const url = new URL(route.request().url()); + const days = Number(url.searchParams.get("days")); + asked.push(days); + const count = Math.min(days, 40); + const points = Array.from({ length: count }, (_, i) => ({ + day: dayKey(i - count + 1), + value: 60 + ((i * 7) % 25), + seamBreak: false, + })); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + score: url.searchParams.get("score"), + days, + points, + band: { lo: 64, hi: 76, n: 30 }, + }, + error: null, + }), + }); + }); + return asked; +} + +/** An `ok` derived score, so the score page draws its history under it. */ +async function mockDerived(page: Page) { + await page.route(/\/api\/insights\/derived\?/, (route: Route) => { + const metric = new URL(route.request().url()).searchParams.get("metric"); + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + metric, + status: "ok", + value: { + score: 72, + band: "green", + components: [], + subScores: [], + night: dayKey(0), + asleepMinutes: 420, + inBedMinutes: 450, + windowNights: 20, + }, + coverage: { + requiredInputs: 1, + presentInputs: 1, + historyDays: 30, + missing: [], + }, + confidence: "high", + provenance: { + inputs: [], + source: "rollup", + windowDays: 30, + computedAt: new Date().toISOString(), + }, + reason: null, + assessment: null, + }, + error: null, + }), + }); + }); +} + +async function resetRange(page: Page, chartKey: string) { + const res = await page.request.put("/api/dashboard/chart-overlay-prefs", { + data: { + chartKey, + prefs: { + showTrendIndicator: false, + showTrendArrow: false, + showTargetRange: false, + comparisonBaseline: "none", + rangePoints: 30, + }, + }, + }); + expect(res.status()).toBe(200); +} + +async function expectSharedTabs(page: Page, chart: string, pressed: string) { + const tabs = page.locator(`${chart} [data-slot="chart-range-tab"]`); + await expect(tabs).toHaveCount(4); + expect( + await tabs.evaluateAll((nodes) => + nodes.map((node) => node.getAttribute("data-range")), + ), + ).toEqual(RANGES); + await expect( + page.locator(`${chart} [data-slot="chart-range-tab"][aria-pressed="true"]`), + ).toHaveAttribute("data-range", pressed); +} + +test.describe("score histories", () => { + test.beforeEach(async ({ page, context }, testInfo) => { + const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000"; + await context.addCookies([ + { name: "healthlog-locale", value: "en", url: baseURL }, + ]); + await mockDay(page, { index: "full" }); + }); + + test("the health score's range tabs switch the window and are remembered", async ({ + page, + }) => { + await page.setViewportSize({ width: 1440, height: 900 }); + await resetRange(page, "scoreHealth"); + const asked = await mockScoreHistory(page); + + await page.goto("/insights/health-score"); + const chart = page.locator('[data-slot="score-trend-chart"]'); + await expect(chart).toHaveAttribute("data-score", "HEALTH_SCORE"); + await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30"); + await expect(chart.locator('[data-slot="chart-plot"]')).toBeVisible(); + await expect(chart.locator('[data-slot="score-trend-band"]')).toBeVisible(); + expect(asked).toContain(30); + + await chart + .locator('[data-slot="chart-range-tab"][data-range="7"]') + .click(); + await expect(chart).toHaveAttribute("data-range", "7"); + await expect.poll(() => asked.at(-1)).toBe(7); + // The table lists exactly the window's days. + await chart.locator('[data-slot="chart-data-table-toggle"]').click(); + await expect( + chart.locator('[data-slot="chart-data-table-row"]'), + ).toHaveCount(7); + + // Remembered: a reload opens on seven days again. + await page.reload(); + await expect(chart).toHaveAttribute("data-range", "7"); + + await chart + .locator('[data-slot="chart-range-tab"][data-range="0"]') + .click(); + await expect(chart).toHaveAttribute("data-range", "0"); + await expect.poll(() => asked.at(-1)).toBe(3650); + + // A point opens its day beside the page. + const plot = chart.locator('[data-slot="chart-plot"]'); + await expect(plot).toHaveAttribute("data-day-links", "true"); + const box = (await plot.boundingBox())!; + await page.mouse.click(box.x + box.width * 0.8, box.y + box.height / 2); + await expect(page.locator('[data-slot="day-panel"]')).toHaveAttribute( + "data-shell", + "docked", + ); + await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/); + + await resetRange(page, "scoreHealth"); + }); + + test("the score panel leads to the health score page and back", async ({ + page, + }) => { + await mockPopulatedInsights(page); + await mockScoreHistory(page); + await page.goto("/insights"); + const link = page.locator('[data-slot="health-score-history-link"]'); + await expect(link).toHaveAttribute("href", "/insights/health-score"); + await link.click(); + await expect(page).toHaveURL(/\/insights\/health-score$/); + await expect( + page.locator('[data-slot="health-score-back"]'), + ).toHaveAttribute("href", "/insights"); + }); + + test("readiness and the sleep score offer the same tabs, and switching asks for the window", async ({ + page, + }) => { + await resetRange(page, "scoreReadiness"); + await mockDerived(page); + const asked = await mockScoreHistory(page); + + await page.goto("/insights/scores/readiness"); + const chart = page.locator('[data-slot="score-trend-chart"]'); + await expect(chart).toHaveAttribute("data-score", "READINESS"); + await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30"); + await chart + .locator('[data-slot="chart-range-tab"][data-range="90"]') + .click(); + await expect(chart).toHaveAttribute("data-range", "90"); + await expect.poll(() => asked.at(-1)).toBe(90); + await resetRange(page, "scoreReadiness"); + + await page.goto("/insights/scores/sleep"); + await expect( + page.locator('[data-slot="score-trend-chart"]'), + ).toHaveAttribute("data-score", "SLEEP_SCORE"); + await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30"); + }); + + test("recovery and strain carry the same tabs on their stored-score chart", async ({ + page, + }) => { + await resetRange(page, "scoreRecovery"); + await mockDerived(page); + + await page.goto("/insights/scores/recovery"); + const chart = '[data-slot="score-history-chart"]'; + await expect(page.locator(chart)).toHaveAttribute( + "data-type", + "RECOVERY_SCORE", + ); + await expectSharedTabs(page, chart, "30"); + // A score history remembers its range, and mounts no overlay dropdown. + await expect( + page.locator(`${chart} [data-slot="chart-overlay-controls-trigger"]`), + ).toHaveCount(0); + await page + .locator(`${chart} [data-slot="chart-range-tab"][data-range="7"]`) + .click(); + await expectSharedTabs(page, chart, "7"); + await page.reload(); + await expectSharedTabs(page, chart, "7"); + await resetRange(page, "scoreRecovery"); + + await page.goto("/insights/scores/strain"); + await expectSharedTabs(page, chart, "30"); + }); +}); + +const DIGEST: DailyDigest = { + generatedAt: new Date().toISOString(), + ai: DIGEST_AI_AVAILABLE, + phase: "final", + sleepPending: false, + score: { + value: 82, + band: "green", + delta: null, + deltaReason: "first_eligibility_window", + steadyWeeks: 2, + }, + topSignal: null, + signalLine: null, + briefingLead: null, + lead: null, + today: [], + restMode: null, + line: "Your health score today is 82.", + worthALook: [], + justIn: null, + reactionLine: null, +}; + +test.describe("the dashboard's top card", () => { + test.afterEach(async ({ page }) => { + // Leave the account's layout at its defaults for the next run. + await page.request.delete("/api/dashboard/widgets"); + }); + + test("switches off and on from the layout settings", async ({ page }) => { + await page.request.delete("/api/dashboard/widgets"); + await page.route(/\/api\/daily\/digest(\?|$)/, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: DIGEST, error: null }), + }), + ); + + // The card's region, present whenever the card is switched on. What it + // shows depends on the day's digest, which the server renders into the + // page before any route mock can answer, so the switch is asserted on + // the region and not on its content. + const hero = page.locator('[data-slot="today-card"]'); + await page.goto("/"); + await expect(hero).toHaveCount(1); + + async function flip(expected: "checked" | "unchecked") { + await page.goto("/settings/layout/dashboard"); + const toggle = page.locator('[data-slot="today-card-switch"]'); + await expect(toggle).toBeVisible(); + await toggle.click(); + await expect(toggle).toHaveAttribute("data-state", expected); + const saved = page.waitForResponse( + (res) => + res.url().includes("/api/dashboard/widgets") && + res.request().method() === "PUT", + ); + await page + .locator('[data-slot="settings-dashboard-layout-save"]') + .click(); + const response = await saved; + expect(response.status()).toBe(200); + const body = response.request().postDataJSON() as { + todayCardVisible?: boolean; + }; + expect(body.todayCardVisible).toBe(expected === "checked"); + } + + await flip("unchecked"); + await page.goto("/"); + await expect(page.locator("main").first()).toBeVisible(); + await expect(hero).toHaveCount(0); + await expect(page.locator('[data-slot="today-hero-skeleton"]')).toHaveCount( + 0, + ); + + // A phone width shows no card either. + await page.setViewportSize({ width: 390, height: 844 }); + await page.reload(); + await expect(hero).toHaveCount(0); + await page.setViewportSize({ width: 1280, height: 720 }); + + await flip("checked"); + await page.goto("/"); + await expect(hero).toHaveCount(1); + }); +}); diff --git a/e2e/settings-mobile-consistency.spec.ts b/e2e/settings-mobile-consistency.spec.ts index b9b67519e..7294d69ba 100644 --- a/e2e/settings-mobile-consistency.spec.ts +++ b/e2e/settings-mobile-consistency.spec.ts @@ -149,38 +149,42 @@ test.describe("Settings mobile consistency (Pixel 5)", () => { } }); - test("/settings/account: Sprache select shares one grid row with date-of-birth", async ({ + test("/settings/account: the language select sits in the display card, outside the profile form", async ({ page, }) => { await page.goto("/settings/account", { waitUntil: "networkidle" }); - // Gate on the two elements this test is about, not on the network. An idle - // network says nothing about whether React has rendered the profile form, - // and the single `evaluate` below does not retry — so without this the - // test reads an empty document and reports "language + dob fields must - // exist", which sounds like a missing feature and is really a race. + // Gate on the elements this test is about, not on the network: the + // single `evaluate` below does not retry. await expect(page.locator("#language-select")).toBeAttached(); await expect(page.locator("#dob")).toBeAttached(); - // The v1.4.27 R1 settings audit pairs date-of-birth with language - // in a single `grid sm:grid-cols-2` row so the profile form keeps - // a uniform two-column rhythm and the language field no longer - // sits alone at the bottom with a `sm:max-w-xs` clamp. - const sharedGrid = await page.evaluate(() => { + // The language, unit and format selects save the moment they change, so + // they live in their own card with no save button. Inside the profile + // form they read as covered by its Save. Each card keeps a two-column + // grid: the language shares one with the units, the date of birth one + // with the timezone. + const layout = await page.evaluate(() => { const lang = document.getElementById("language-select"); const dob = document.getElementById("dob"); - if (!lang || !dob) return { found: false, sharedGrid: false }; - const langGrid = lang.closest('[class*="grid"]'); - const dobGrid = dob.closest('[class*="grid"]'); + if (!lang || !dob) return null; + const displayCard = document.querySelector( + '[data-testid="settings-display-card"]', + ); return { - found: true, - sharedGrid: langGrid !== null && langGrid === dobGrid, + langInDisplayCard: !!displayCard && displayCard.contains(lang), + langInForm: lang.closest("form") !== null, + dobInForm: dob.closest("form") !== null, + langHasGrid: lang.closest('[class*="grid"]') !== null, + dobHasGrid: dob.closest('[class*="grid"]') !== null, }; }); - expect(sharedGrid.found, "language + dob fields must exist").toBe(true); - expect(sharedGrid.sharedGrid, "language + dob must share a grid").toBe( - true, - ); + expect(layout, "language + dob fields must exist").not.toBeNull(); + expect(layout!.langInDisplayCard).toBe(true); + expect(layout!.langInForm).toBe(false); + expect(layout!.dobInForm).toBe(true); + expect(layout!.langHasGrid).toBe(true); + expect(layout!.dobHasGrid).toBe(true); }); test("/: Compare-to trigger renders at 44 px", async ({ page }) => { diff --git a/e2e/setup/day-doors-fixture.ts b/e2e/setup/day-doors-fixture.ts new file mode 100644 index 000000000..ddc7b5382 --- /dev/null +++ b/e2e/setup/day-doors-fixture.ts @@ -0,0 +1,90 @@ +/** + * The record `day-doors.spec.ts` opens days from. + * + * Every chart the journey clicks needs points to click: ten days of the + * autonomic charge (the recovery page), of sleep and of mood (the sleep + * chart, the mood line and the mood calendar), weight over the last ten days + * and the same ten days a month earlier (the comparison chart draws a prior + * period only where one exists), and two readings of one lab marker. + * + * Written through the app's own routes, once, from `globalSetup`. An account + * that already holds the lab marker is taken as seeded, so a local re-run + * against a reused database does not double every row. + */ +import { request as playwrightRequest } from "@playwright/test"; + +export const DAY_DOORS_ANALYTE = "Ferritin"; + +export async function seedDayDoorsRecord( + baseURL: string, + storageStatePath: string, +): Promise { + const ctx = await playwrightRequest.newContext({ + baseURL, + storageState: storageStatePath, + }); + try { + const labs = await ctx.get("/api/labs"); + const body = (await labs.json()) as { data: unknown }; + if (JSON.stringify(body.data ?? null).includes(DAY_DOORS_ANALYTE)) return; + + const post = async (path: string, data: unknown): Promise => { + const res = await ctx.post(path, { data }); + if (res.status() === 409 || res.ok()) return; + throw new Error( + `[day-doors-fixture] ${path} answered ${res.status()}: ${(await res.text()).slice(0, 200)}`, + ); + }; + const daysAgo = (days: number, hour = 8) => { + const at = new Date(Date.now() - days * 86_400_000); + at.setUTCHours(hour, 0, 0, 0); + return at.toISOString(); + }; + + for (let day = 1; day <= 10; day += 1) { + await post("/api/measurements", { + type: "ANS_CHARGE", + value: 5 + day, + measuredAt: daysAgo(day, 5), + }); + await post("/api/measurements", { + type: "SLEEP_DURATION", + value: 400 + day * 4, + measuredAt: daysAgo(day, 6), + }); + await post("/api/measurements", { + type: "WEIGHT", + value: 72 + day / 10, + measuredAt: daysAgo(day, 7), + }); + // The same ten days, a month before: the prior period the comparison + // draws beside them. + await post("/api/measurements", { + type: "WEIGHT", + value: 73 + day / 10, + measuredAt: daysAgo(day + 30, 7), + }); + await post("/api/mood-entries", { + mood: day % 2 ? "GUT" : "OKAY", + moodLoggedAt: daysAgo(day, 19), + }); + } + + for (const [days, value] of [ + [60, 41], + [4, 58], + ] as const) { + await post("/api/labs", { + analyte: DAY_DOORS_ANALYTE, + panel: "Eisenstoffwechsel", + value, + unit: "ng/mL", + referenceLow: 30, + referenceHigh: 300, + takenAt: daysAgo(days), + }); + } + } finally { + await ctx.dispose(); + } +} diff --git a/e2e/setup/day-mock.ts b/e2e/setup/day-mock.ts new file mode 100644 index 000000000..9a768cce9 --- /dev/null +++ b/e2e/setup/day-mock.ts @@ -0,0 +1,125 @@ +/** + * The day routes, answered in the contract's shape. + * + * The day itself (`GET /api/day/{date}`) and the index (`GET /api/day/index`) + * are route mocks, so a journey pins the client: which door opens which day, + * what the history does, where no door is. The server's own answers are + * pinned by the integration suite. + */ +import type { Page, Route } from "@playwright/test"; + +function dayFixture(date: string) { + return { + date, + tz: "Europe/Berlin", + counts: { values: 2, entries: 1 }, + running: [ + { + kind: "illness", + section: "illness", + id: "ep1", + title: "Common cold", + sub: null, + since: date, + until: null, + dayIndex: 1, + dayCount: null, + href: "/illness", + }, + ], + values: [ + { + type: "BLOOD_PRESSURE_SYS", + value: 124, + unit: "mmHg", + at: `${date}T07:00:00.000Z`, + source: "MANUAL", + band: { lo: 118, hi: 128, n: 20 }, + }, + { + type: "BLOOD_PRESSURE_DIA", + value: 79, + unit: "mmHg", + at: `${date}T07:00:00.000Z`, + source: "MANUAL", + band: { lo: 76, hi: 82, n: 20 }, + }, + ], + events: [ + { + at: `${date}T07:00:00.000Z`, + kind: "mood", + section: "mood", + id: "m1", + title: "Mood: good", + meta: null, + note: null, + docs: [], + href: "/mood", + }, + ], + notable: [], + scores: [ + { + key: "healthScore", + value: 74, + max: 100, + source: "COMPUTED", + band: { lo: 66, hi: 78, n: 30 }, + }, + { key: "strain", value: 12.4, max: 21, source: "WHOOP", band: null }, + ], + sections: {}, + }; +} + +/** + * Every day from `from` to `to`, each holding values: what the row of day + * dots under a chart reads when the whole window has entries. + */ +function everyDay(from: string, to: string): Record { + const days: Record = {}; + if (!from || !to) return days; + const at = new Date(`${from}T12:00:00.000Z`); + const end = new Date(`${to}T12:00:00.000Z`); + while (at <= end) { + days[at.toISOString().slice(0, 10)] = ["values"]; + at.setUTCDate(at.getUTCDate() + 1); + } + return days; +} + +export async function mockDay( + page: Page, + options: { index?: "empty" | "full" } = {}, +) { + await page.route("**/api/day/**", async (route: Route) => { + const url = new URL(route.request().url()); + if (url.pathname === "/api/day/index") { + const from = url.searchParams.get("from") ?? ""; + const to = url.searchParams.get("to") ?? ""; + const days = options.index === "full" ? everyDay(from, to) : {}; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + from, + to, + days, + // The first day of the window stands out: the rug draws a ring. + notable: Object.keys(days).slice(0, 1), + }, + error: null, + }), + }); + return; + } + const date = url.pathname.split("/").pop() ?? ""; + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: dayFixture(date), error: null }), + }); + }); +} diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 6f28780bb..9eeb1d669 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -33,6 +33,7 @@ import { } from "@playwright/test"; import pg from "pg"; +import { seedDayDoorsRecord } from "./day-doors-fixture"; import { seedMobileRoutesRecord } from "./mobile-routes-fixture"; // Resolve relative to the project root rather than __dirname/import.meta.url — @@ -229,6 +230,65 @@ export const E2E_AI_OPTIONAL = { role: "USER", } as const; +/** + * The timeline journey's own account (`timeline.spec.ts`). + * + * It switches the opt-in timeline module on and off, which changes the + * navigation, the add menus and Settings for the whole account; on the shared + * account a parallel spec would see them move underneath it. The module + * starts off here, the way every account starts. + */ +export const E2E_TIMELINE = { + email: "e2e-timeline@healthlog.test", + username: "e2e-timeline", + password: "Tl6!Wq3zRm8vXn2K", + role: "USER", +} as const; + +export const TIMELINE_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateTimeline.json", +); + +/** + * The day-doors journey's own account (`day-doors.spec.ts`). + * + * It turns a chart's comparison overlay on, which is a per-account + * preference; on a shared account a parallel spec would find its chart + * drawing a prior period it never asked for. Its record carries the series + * the journey clicks (recovery, sleep, mood, weight, a lab marker). + */ +export const E2E_DAY_DOORS = { + email: "e2e-day-doors@healthlog.test", + username: "e2e-day-doors", + password: "Dy4!Rk8wQm2vTz6P", + role: "USER", +} as const; + +export const DAY_DOORS_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateDayDoors.json", +); + +/** + * The score-history journey's own account (`score-history.spec.ts`). + * + * It switches the dashboard's top card off and back on and remembers a range + * tab per score page, both per-account preferences; on a shared account a + * parallel spec would find its dashboard without the card it asserts. + */ +export const E2E_SCORE_HISTORY = { + email: "e2e-score-history@healthlog.test", + username: "e2e-score-history", + password: "Sh7!Vn2qLp9xWc4R", + role: "USER", +} as const; + +export const SCORE_HISTORY_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateScoreHistory.json", +); + export const AI_OPTIONAL_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateAiOptional.json", @@ -906,6 +966,80 @@ export default async function globalSetup(config: FullConfig): Promise { ], ); + // The timeline journey's account. The module starts off; the journey + // switches it through the real Settings switch. + 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, NULL) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + module_preferences_json = NULL`, + [ + cuid(), + E2E_TIMELINE.username, + E2E_TIMELINE.email, + await hashPassword(E2E_TIMELINE.password), + now, + ], + ); + + // The day-doors journey's account. Every module the journey reaches is + // on; its record is written after the login capture. + 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, NULL) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + module_preferences_json = NULL`, + [ + cuid(), + E2E_DAY_DOORS.username, + E2E_DAY_DOORS.email, + await hashPassword(E2E_DAY_DOORS.password), + now, + ], + ); + + // The score-history journey's account. Every module on, a fresh layout: + // the journey moves its top card and range tabs, so a re-run starts from + // the defaults. + 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, NULL) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + module_preferences_json = NULL, + dashboard_widgets_json = NULL`, + [ + cuid(), + E2E_SCORE_HISTORY.username, + E2E_SCORE_HISTORY.email, + await hashPassword(E2E_SCORE_HISTORY.password), + now, + ], + ); + // The notification-dispatch journey's account. Seeded like the others; // its channels, devices, ledger rows and preferences are reset by // `e2e/setup/notification-fixture.ts` before every test, because the @@ -1509,6 +1643,16 @@ export default async function globalSetup(config: FullConfig): Promise { // The AI-optional journeys' jar — see `E2E_AI_OPTIONAL`. await capture(E2E_AI_OPTIONAL, AI_OPTIONAL_STORAGE_STATE_PATH); + // The timeline journey's jar — see `E2E_TIMELINE`. + await capture(E2E_TIMELINE, TIMELINE_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); + + // The score-history journey's jar — see `E2E_SCORE_HISTORY`. + await capture(E2E_SCORE_HISTORY, SCORE_HISTORY_STORAGE_STATE_PATH); + await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH); diff --git a/e2e/sidebar-expand-no-scrollbar.spec.ts b/e2e/sidebar-expand-no-scrollbar.spec.ts index 71b8d73e8..cb136e1df 100644 --- a/e2e/sidebar-expand-no-scrollbar.spec.ts +++ b/e2e/sidebar-expand-no-scrollbar.spec.ts @@ -133,7 +133,43 @@ test.describe("sidebar width transition", () => { }); // With room to spare there is no edge: the default rail is unchanged. + // No native scrollbar in the rail (a classic one with arrow buttons + // read as a foreign body); a fade marks the edge that has more. + expect( + await nav.evaluate((el) => getComputedStyle(el).scrollbarWidth), + ).toBe("none"); + expect( + await nav.evaluate( + (el) => (el as HTMLElement).offsetWidth - el.clientWidth, + ), + ).toBe(0); + await expect(nav).toHaveAttribute("data-fade-top", "true"); + await expect(nav).not.toHaveAttribute("data-fade-bottom", "true"); + await nav.evaluate((el) => { + el.scrollTop = 0; + }); + await expect(nav).toHaveAttribute("data-fade-bottom", "true"); + await expect(nav).not.toHaveAttribute("data-fade-top", "true"); + await page.setViewportSize({ width: 1280, height: 1400 }); await expect(footer).not.toHaveAttribute("data-nav-overflows", "true"); + await expect(nav).not.toHaveAttribute("data-fade-bottom", "true"); + }); + + test("a 1366 × 768 window fits the rail without scrolling, collapsed and expanded", async ({ + page, + }) => { + await page.setViewportSize({ width: 1366, height: 768 }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + const nav = page.locator("aside nav").first(); + const toggle = page.locator('[data-slot="sidebar-collapse-toggle"]'); + await expect(nav).toBeVisible(); + for (let i = 0; i < 2; i++) { + await expect + .poll(() => nav.evaluate((el) => el.scrollHeight - el.clientHeight)) + .toBeLessThanOrEqual(0); + await toggle.click(); + await page.waitForTimeout(300); + } }); }); diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts new file mode 100644 index 000000000..e2d09e17b --- /dev/null +++ b/e2e/timeline.spec.ts @@ -0,0 +1,879 @@ +/** + * The timeline journey (v1.42, #613), on its own account (`E2E_TIMELINE`). + * + * 1. Off by default: no navigation entry, and a deep link to /timeline + * lands on the module notice, not the page. + * 2. Switching it on in Settings opens the readiness inventory; "Open + * timeline" goes there and the navigation entry appears. + * 3. The timeline hands off to the day: "Open 3 Jan." and Enter on the + * chart both put `?day=` in the URL; an incoming `?day=` selects that + * day. + * 4. A life event is entered through the sheet and posted in the + * contract's shape. + * 5. The value lines: quarterly means over the years, a missing quarter + * left out, a short gap bridged dashed, a thin quarter hollow, and up + * to six lines. + * 6. On a phone the chronicle replaces the chart, says its empty months + * out loud, names each quarter's means once, and a row opens its day. + * + * The timeline's server routes are mocked with the contract's shapes + * (`utils/mock-timeline.ts`); the module switch is the real one. Every + * assertion is addressed to a stable `data-*` attribute. The journey moves + * its account's module switch, so it runs serially and in one project. + */ +import type { Page } from "@playwright/test"; + +import { TIMELINE_STORAGE_STATE_PATH } from "./setup/global-setup"; +import { expect, test } from "./setup/test"; +import { SYS_MISSING, SYS_THIN, mockTimeline } from "./utils/mock-timeline"; + +test.use({ storageState: TIMELINE_STORAGE_STATE_PATH }); +test.describe.configure({ mode: "serial" }); + +/** Today in the zone the browser and the account run in. */ +const TODAY = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", +}).format(new Date()); + +async function setTimelineModule(page: Page, on: boolean): Promise { + const status = await page.evaluate(async (on) => { + const res = await fetch("/api/auth/me/modules", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ timeline: on }), + }); + return res.status; + }, on); + expect(status).toBe(200); +} + +const navEntry = '[data-tour-id="nav-timeline"]'; + +test("is off by default: no navigation entry, a deep link meets the notice", async ({ + page, +}) => { + await page.goto("/settings/modules"); + await setTimelineModule(page, false); + await page.goto("/timeline"); + await expect( + page.locator( + '[data-slot="module-disabled-notice"][data-module="timeline"]', + ), + ).toBeVisible({ timeout: 20_000 }); + await expect(page.locator('[data-slot="timeline-page"]')).toHaveCount(0); + await expect(page.locator(navEntry)).toHaveCount(0); +}); + +test("switching it on opens the readiness inventory, which leads to the timeline", async ({ + page, +}) => { + await mockTimeline(page, "ready", TODAY); + await page.goto("/settings/modules"); + const toggle = page.locator("#module-toggle-timeline"); + await expect(toggle).toHaveAttribute("aria-checked", "false", { + timeout: 20_000, + }); + await toggle.click(); + + const sheet = page.locator('[data-slot="timeline-readiness-sheet"]'); + await expect(sheet).toBeVisible(); + await expect( + page.locator( + '[data-slot="timeline-readiness-verdict"][data-verdict="carries"]', + ), + ).toBeVisible(); + await expect( + page.locator('[data-slot="timeline-readiness-lane"][data-status="thin"]'), + ).toHaveCount(1); + await expect( + page.locator( + '[data-slot="timeline-readiness-gap"][data-gap="lifeEventsEmpty"]', + ), + ).toHaveAttribute("href", "/timeline?add=lifeEvent"); + + await page.locator('[data-slot="timeline-readiness-open"]').click(); + await expect(page).toHaveURL(/\/timeline$/); + await expect(page.locator('[data-slot="timeline-page"]')).toBeVisible(); + await expect(page.locator(navEntry).first()).toBeAttached(); + // The card carries the open gaps onto the page until it is closed. + await expect( + page.locator('[data-slot="timeline-readiness-card"]'), + ).toBeVisible(); + await page.locator('[data-slot="timeline-readiness-card-dismiss"]').click(); + await expect( + page.locator('[data-slot="timeline-readiness-card"]'), + ).toHaveCount(0); +}); + +test("the timeline hands the selected day to the day view through ?day=", async ({ + page, +}) => { + await setTimelineModuleOn(page); + const log = await mockTimeline(page, "full", TODAY); + await page.goto("/timeline"); + + const chart = page.locator('[data-slot="timeline-chart"]'); + await expect(chart).toBeVisible({ timeout: 20_000 }); + // Empty lanes are not drawn: the record has no labs. + await expect(page.locator('svg [data-lane="labs"]')).toHaveCount(0); + await expect(page.locator('svg [data-lane="illness"]')).toHaveCount(1); + await expect( + page.locator('[data-slot="timeline-legend-causality"]'), + ).toHaveCount(0); + const asked = log.timelineQueries.at(-1)?.get("values")?.split(",") ?? []; + expect(asked.length).toBeGreaterThan(0); + + // Before anyone picks a day the bar opens on the newest bucket it has + // something for, says so in its head, and says how to pick another. + const bar = page.locator('[data-slot="timeline-selection-bar"]'); + // It sits on the card's own surface under a hairline, not in a grey box, + // and the legend under it is set off by a hairline of its own. + const surface = await bar.evaluate((el) => { + const style = getComputedStyle(el); + return { + background: style.backgroundColor, + borderTop: style.borderTopWidth, + }; + }); + expect(surface.background).toBe("rgba(0, 0, 0, 0)"); + expect(surface.borderTop).toBe("1px"); + await expect(page.locator('[data-slot="timeline-legend"]')).toHaveCSS( + "border-top-width", + "1px", + ); + await expect(bar).toHaveAttribute("data-bucket", "quarter"); + await expect(bar).toHaveAttribute( + "data-period-from", + /^\d{4}-(01|04|07|10)-01$/, + ); + await expect( + page.locator('[data-slot="timeline-selection-empty"]'), + ).toHaveCount(0); + await expect( + page.locator('[data-slot="timeline-selection-hint"]'), + ).toBeVisible(); + + // The keyboard: one step back and Enter opens that day. + const before = await page + .locator('[data-slot="timeline-selection"]') + .getAttribute("data-date"); + await chart.focus(); + await page.keyboard.press("ArrowLeft"); + const after = await page + .locator('[data-slot="timeline-selection"]') + .getAttribute("data-date"); + expect(after).not.toBe(before); + // A day is picked now: the hint has done its job. + await expect( + page.locator('[data-slot="timeline-selection-hint"]'), + ).toHaveCount(0); + await page.keyboard.press("Enter"); + await expect(page).toHaveURL(new RegExp(`[?&]day=${after}`)); + + // An incoming ?day= selects that day; the selection bar has no separate + // "Open " link, a chip opens the day it touches. + await page.goto("/timeline?day=2026-01-03"); + await expect( + page.locator('[data-slot="timeline-selection"]'), + ).toHaveAttribute("data-date", "2026-01-03", { timeout: 20_000 }); + await expect( + page.locator('[data-slot="timeline-zoom"] [data-value="quarter"]'), + ).toHaveAttribute("aria-checked", "true"); + await expect(page.locator('[data-slot="timeline-open-day"]')).toHaveCount(0); + const chip = page.locator( + '[data-slot="timeline-selection-chip"][data-item="ill-5"]', + ); + await expect(chip).toBeVisible(); + await chip.click(); + await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/); +}); + +test("value lines: quarterly means, gaps kept, short ones bridged, as many as the record has", async ({ + page, +}) => { + await setTimelineModuleOn(page); + const log = await mockTimeline(page, "full", TODAY); + await page.goto("/timeline"); + const sys = page.locator('svg [data-series="BLOOD_PRESSURE_SYS"]'); + await expect(sys).toBeVisible({ timeout: 20_000 }); + await expect( + page.locator('[data-slot="timeline-legend-mean"]'), + ).toHaveAttribute("data-bucket", "quarter"); + + const point = (t: string) => + sys.locator(`[data-slot="timeline-series-point"][data-t="${t}"]`); + // A missing quarter stays missing: no point is drawn for it. + for (const t of SYS_MISSING) await expect(point(t)).toHaveCount(0); + // The quarters around Q2 2023 are joined by a dashed bridge; the three + // missing quarters of 2021 are not (one bridge in the whole line). + await expect(sys.locator('[data-slot="timeline-series-bridge"]')).toHaveCount( + 1, + ); + await expect(point("2023-01-01")).toHaveCount(1); + await expect(point("2023-07-01")).toHaveCount(1); + // Two readings: drawn hollow; four and more: filled. + await expect(point(SYS_THIN)).toHaveAttribute("data-thin", "true"); + await expect(point("2023-01-01")).toHaveAttribute("data-thin", "false"); + + // The value table keeps the missing quarter as a row of its own. + await expect( + page.locator( + '[data-slot="timeline-series-table"] [data-bucket="2023-04-01"]', + ), + ).toBeAttached(); + + // The legend explains the dashed stroke and the hollow point. + await expect( + page.locator('[data-slot="timeline-legend-gap"] svg'), + ).toBeVisible(); + await expect( + page.locator('[data-slot="timeline-legend-thin"] svg'), + ).toBeVisible(); + + // The picker is one button that counts the lines; the chosen ones are + // not listed beside it. + const trigger = page.locator('[data-slot="timeline-values-trigger"]'); + await expect(trigger).toHaveAttribute("data-count", "3"); + await expect(trigger).toHaveText(/\(3\)/); + const toolbarHeight = await trigger.evaluate( + (el) => el.parentElement!.getBoundingClientRect().height, + ); + + // No cap: every series the record has can be chosen, fifteen here. + await trigger.click(); + const options = page.locator('[data-slot="timeline-values-option"]'); + await expect(options.first()).toBeVisible(); + const count = await options.count(); + expect(count).toBe(15); + await expect( + page.locator('[data-slot="timeline-values-option"][data-disabled]'), + ).toHaveCount(0); + for (let i = 0; i < count; i++) { + const option = options.nth(i); + if ((await option.getAttribute("aria-checked")) === "true") continue; + await option.click(); + } + await expect( + page.locator('[data-slot="timeline-values-option"][aria-checked="true"]'), + ).toHaveCount(15); + // The menu marks each chosen line with its colour. + const dots = await page + .locator( + '[data-slot="timeline-values-option"][aria-checked="true"] [data-slot="timeline-values-dot"]', + ) + .evaluateAll((els) => + els.map((el) => getComputedStyle(el).backgroundColor), + ); + expect(dots).toHaveLength(15); + await page.keyboard.press("Escape"); + await expect + .poll(() => log.timelineQueries.at(-1)?.get("values")?.split(",").length) + .toBe(15); + await expect(page.locator("svg [data-series]")).toHaveCount(15); + await expect(trigger).toHaveAttribute("data-count", "15"); + // The toolbar did not grow with the choice. + expect( + await trigger.evaluate( + (el) => el.parentElement!.getBoundingClientRect().height, + ), + ).toBe(toolbarHeight); + // Each line in one colour, every mark of a line in that colour, the five + // data colours repeating past the fifth line. + const colours = await page + .locator("svg [data-series]") + .evaluateAll((groups) => + groups.map((g) => ({ + colour: getComputedStyle(g).color, + marks: [ + ...g.querySelectorAll( + '[data-slot="timeline-series-line"], [data-slot="timeline-series-swatch"]', + ), + ].map((m) => getComputedStyle(m).stroke), + })), + ); + expect(new Set(colours.map((c) => c.colour)).size).toBe(5); + for (const { colour, marks } of colours) { + expect(marks.length).toBeGreaterThan(0); + for (const stroke of marks) expect(stroke).toBe(colour); + } + expect(new Set(dots)).toEqual(new Set(colours.map((c) => c.colour))); + // The rows run on down the page, which scrolls; the last one is reached. + const last = page.locator("svg [data-series]").last(); + await last.scrollIntoViewIfNeeded(); + await expect(last).toBeInViewport(); +}); + +test("one medication is one row: its doses cut the bar, its pause is a gap in it", async ({ + page, +}) => { + await setTimelineModuleOn(page); + await mockTimeline(page, "full", TODAY); + await page.goto("/timeline"); + const lane = page.locator('svg [data-lane="medications"]'); + await expect(lane).toBeVisible({ timeout: 20_000 }); + // The medication's own span is not drawn beside its course. + await expect(lane.locator('[data-item="mj"]')).toHaveCount(0); + const pieces = lane.locator('[data-item="mj-course"]'); + await expect(pieces).toHaveCount(3); + expect( + await pieces.evaluateAll((els) => els.map((el) => el.dataset.dose)), + ).toEqual(["2,5 mg", "5 mg", "7,5 mg"]); + const rowOf = (selector: string) => + lane + .locator(selector) + .evaluateAll((els) => [...new Set(els.map((el) => el.dataset.row))]); + const mounjaroRow = await rowOf('[data-item="mj-course"]'); + expect(mounjaroRow).toHaveLength(1); + expect(await rowOf('[data-kind="pause"][data-item="mj-pause"]')).toEqual( + mounjaroRow, + ); + // Three winter courses of one medication share one row of their own. + const vitamin = await rowOf('[data-item^="c-"]'); + expect(vitamin).toHaveLength(1); + expect(vitamin).not.toEqual(mounjaroRow); + // Four medications, four rows. + const rows = await lane + .locator("[data-row]") + .evaluateAll((els) => new Set(els.map((el) => el.dataset.row)).size); + expect(rows).toBe(4); + // A zoom change redraws the pieces: none is left behind from the last + // window (each piece has a key of its own). + const zoom = (value: string) => + page.locator(`[data-slot="timeline-zoom"] [data-value="${value}"]`); + await zoom("year").click(); + await expect(page).toHaveURL(/zoom=year/); + await zoom("all").click(); + await expect(page).toHaveURL(/zoom=all/); + await expect(pieces).toHaveCount(3); +}); + +/** `days` calendar days before `key`. */ +function daysBefore(key: string, days: number): string { + const [y, m, d] = key.split("-").map(Number); + return new Date(Date.UTC(y, m - 1, d - days)).toISOString().slice(0, 10); +} + +test("a chosen range lives in the URL: reload keeps it, Back returns to it", async ({ + page, +}) => { + await setTimelineModuleOn(page); + const log = await mockTimeline(page, "full", TODAY); + await page.goto("/timeline"); + const chart = page.locator('[data-slot="timeline-chart"]'); + await expect(chart).toBeVisible({ timeout: 20_000 }); + const desktop = page.locator('[data-slot="timeline-desktop"]'); + const zoom = (value: string) => + page.locator(`[data-slot="timeline-zoom"] [data-value="${value}"]`); + + // Choosing "Range" opens its fields over the page, anchored to the + // segment: the chart does not move, and the URL waits for "Apply". + const before = await chart.boundingBox(); + await zoom("range").click(); + const popover = page.locator('[data-slot="timeline-range-popover"]'); + await expect(popover).toBeVisible(); + const fields = popover.locator('[data-slot="timeline-range"]'); + await expect(fields).toBeVisible(); + expect(await chart.boundingBox()).toEqual(before); + await expect(desktop.locator('[data-slot="timeline-range"]')).toHaveCount(0); + expect(page.url()).not.toContain("zoom=range"); + const anchor = await zoom("range").boundingBox(); + const box = await popover.boundingBox(); + expect(box!.y).toBeGreaterThanOrEqual(anchor!.y + anchor!.height); + expect(Math.abs(box!.x - anchor!.x)).toBeLessThan(24); + // Starts from what is on screen; "Apply" writes it down. + await popover.locator('[data-slot="timeline-range-apply"]').click(); + await expect(popover).toHaveCount(0); + await expect(page).toHaveURL( + /[?&]zoom=range&from=\d{4}-\d{2}-\d{2}&to=\d{4}-\d{2}-\d{2}/, + ); + + // Three weeks: daily means, the bar headed by one day. + const from = daysBefore(TODAY, 20); + await page.goto(`/timeline?zoom=range&from=${from}&to=${TODAY}`); + await expect(desktop).toHaveAttribute("data-from", from, { + timeout: 20_000, + }); + await expect(desktop).toHaveAttribute("data-to", TODAY); + await expect(zoom("range")).toHaveAttribute("aria-checked", "true"); + await expect( + page.locator('[data-slot="timeline-legend-mean"]'), + ).toHaveAttribute("data-bucket", "day"); + await expect( + page.locator('[data-slot="timeline-selection-bar"]'), + ).toHaveAttribute("data-bucket", "day"); + const asked = log.timelineQueries.at(-1)!; + expect(asked.get("zoom")).toBe("range"); + expect(asked.get("from")).toBe(from); + expect(asked.get("to")).toBe(TODAY); + + // A reload keeps it. + await page.reload(); + await expect(desktop).toHaveAttribute("data-from", from, { + timeout: 20_000, + }); + + // A fixed zoom replaces it; Back brings it back. + await zoom("year").click(); + await expect(page).toHaveURL(/[?&]zoom=year$/); + await expect(desktop).not.toHaveAttribute("data-from", from); + await page.goBack(); + await expect(page).toHaveURL(new RegExp(`zoom=range&from=${from}`)); + await expect(desktop).toHaveAttribute("data-from", from); + + // Typing a start in the popover moves the range once it is applied. + const later = daysBefore(TODAY, 10); + await zoom("range").click(); + const start = popover.locator('[data-testid="timeline-range-from"]'); + await start.fill(later); + await start.press("Enter"); + expect(page.url()).toContain(`from=${from}`); + await popover.locator('[data-slot="timeline-range-apply"]').click(); + await expect(page).toHaveURL(new RegExp(`from=${later}&to=${TODAY}`)); + + // A parameter it cannot read opens the whole record. + await page.goto("/timeline?zoom=range&from=nonsense&to=2026-01-01"); + await expect(zoom("all")).toHaveAttribute("aria-checked", "true", { + timeout: 20_000, + }); + await expect(desktop).toHaveAttribute("data-zoom", "all"); +}); + +async function setTimelineModuleOn(page: Page) { + await page.goto("/settings/modules"); + await setTimelineModule(page, true); +} + +test("a life event is entered through the sheet, in the contract's shape", async ({ + page, +}) => { + await setTimelineModuleOn(page); + const log = await mockTimeline(page, "full", TODAY); + await page.goto("/timeline?add=lifeEvent"); + const form = page.locator('[data-slot="life-event-form"]'); + await expect(form).toBeVisible({ timeout: 20_000 }); + + // Saving an empty form says what is missing and sends nothing. + await page.locator('[data-slot="life-event-save"]').click(); + await expect(form.locator('[aria-invalid="true"]').first()).toBeVisible(); + expect(log.lifeEventPosts).toHaveLength(0); + + await page + .locator('[data-slot="life-event-title"]') + .fill("Umzug nach Bochum"); + await page + .locator('[data-slot="life-event-category"] [data-value="HOME"]') + .click(); + await page + .locator('[data-slot="life-event-precision"] [data-value="MONTH"]') + .click(); + await expect(page.locator('[data-slot="life-event-privacy"]')).toBeVisible(); + await page.locator('[data-slot="life-event-save"]').click(); + + await expect(form).toHaveCount(0); + expect(log.lifeEventPosts).toHaveLength(1); + const posted = log.lifeEventPosts[0] as Record; + expect(posted).toMatchObject({ + title: "Umzug nach Bochum", + category: "HOME", + precision: "MONTH", + endDate: null, + note: null, + }); + expect(posted.startDate).toMatch(/^\d{4}-\d{2}-01$/); + // The parameter left the URL with the sheet. + await expect(page).toHaveURL(/\/timeline$/); +}); + +test("on a phone the chronicle replaces the chart and opens a day per row", async ({ + page, +}) => { + await setTimelineModuleOn(page); + await mockTimeline(page, "full", TODAY); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/timeline"); + + const chronicle = page.locator('[data-slot="timeline-chronicle"]'); + await expect(chronicle).toBeVisible({ timeout: 20_000 }); + await expect(page.locator('[data-slot="timeline-chart"]')).toBeHidden(); + await expect(page.locator('[data-slot="timeline-standing"]')).toBeVisible(); + await expect( + page.locator('[data-slot="timeline-chronicle-gap"]').first(), + ).toBeVisible(); + // Quarterly means, each quarter named once, at its newest listed month. + const means = page.locator('[data-slot="timeline-chronicle-means"]'); + await expect(means.first()).toBeVisible(); + const buckets = await means.evaluateAll((els) => + els.map((el) => el.getAttribute("data-bucket") ?? ""), + ); + expect(buckets.length).toBeGreaterThan(0); + expect(new Set(buckets).size).toBe(buckets.length); + for (const b of buckets) expect(b).toMatch(/^\d{4}-(01|04|07|10)-01$/); + // Each mean carries its line's colour dot. + await expect( + means.first().locator('[data-slot="timeline-series-dot"]').first(), + ).toBeVisible(); + + // One medication, one start: its course and its own span are one row, + // with the dose taken then. + const started = chronicle.locator( + '[data-slot="timeline-chronicle-row"][data-date="2025-01-06"]', + ); + await expect(started).toHaveCount(1); + await expect(started).toContainText("Mounjaro"); + await expect(started).toContainText("2,5 mg"); + await expect(chronicle).not.toContainText("·"); + + // A range is chosen in a sheet; the list underneath does not move. + const listTop = (await chronicle.boundingBox())!.y; + await page.locator('[data-slot="timeline-range-toggle"]').click(); + const sheet = page.locator('[data-slot="responsive-sheet-content"]'); + await expect(sheet.locator('[data-slot="timeline-range"]')).toBeVisible(); + expect((await chronicle.boundingBox())!.y).toBe(listTop); + await sheet.locator('[data-slot="timeline-range-apply"]').click(); + await expect(page).toHaveURL(/[?&]zoom=range&from=\d{4}-\d{2}-\d{2}/); + await page.goto("/timeline"); + await expect(chronicle).toBeVisible({ timeout: 20_000 }); + + await page + .locator( + '[data-slot="timeline-chronicle-row"][data-date="2026-01-03"] button', + ) + .first() + .click(); + await expect(page).toHaveURL(/[?&]day=2026-01-03/); +}); + +/** A calendar date `days` before today, in the account's zone. */ +function daysAgo(days: number): string { + const at = new Date(`${TODAY}T12:00:00Z`); + at.setUTCDate(at.getUTCDate() - days); + return at.toISOString().slice(0, 10); +} + +test("every medication still taken is drawn, from the account's real record", async ({ + page, +}) => { + await setTimelineModuleOn(page); + // Written through the app's own routes, once: a medication with a start + // date (it opens a course), one without (its first intake is its start). + const list = await page.request.get("/api/medications"); + const held = JSON.stringify(await list.json()); + const create = async (body: Record) => { + const res = await page.request.post("/api/medications", { data: body }); + expect(res.status()).toBeLessThan(300); + return ((await res.json()) as { data: { id: string } }).data.id; + }; + if (!held.includes("E2E Ramipril")) { + await create({ + name: "E2E Ramipril", + dose: "5 mg", + startsOn: daysAgo(40), + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }); + } + if (!held.includes("E2E Vitamin D3")) { + const id = await create({ + name: "E2E Vitamin D3", + dose: "2000 IU", + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }); + const intake = await page.request.post(`/api/medications/${id}/intake`, { + data: { takenAt: new Date(Date.now() - 20 * 86_400_000).toISOString() }, + }); + expect(intake.status()).toBeLessThan(300); + } + + await page.goto("/timeline?zoom=quarter"); + const lane = page.locator('svg [data-lane="medications"]'); + await expect(lane).toHaveCount(1, { timeout: 20_000 }); + // One row each, and every one of them reaches today. + for (const name of ["E2E Ramipril", "E2E Vitamin D3"]) { + const bars = lane.locator("g[data-row]", { + has: page.locator(`title:text-matches("^${name}")`), + }); + await expect(bars.first()).toBeAttached(); + const rows = await bars.evaluateAll((els) => + els.map((el) => el.getAttribute("data-row")), + ); + expect(new Set(rows).size).toBe(1); + } +}); + +test("an open day stays open from the timeline to another page", async ({ + page, +}) => { + await setTimelineModuleOn(page); + await mockTimeline(page, "full", TODAY); + await page.setViewportSize({ width: 1440, height: 900 }); + const day = daysAgo(3); + await page.goto(`/timeline?day=${day}`); + const panel = page.locator('[data-slot="day-panel"]'); + await expect(panel).toBeVisible({ timeout: 20_000 }); + + await page.locator('a[href="/labs"]').first().click(); + await expect(page).toHaveURL(new RegExp(`/labs\\?(.*&)?day=${day}`)); + await expect(panel).toBeVisible(); + await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute( + "data-day", + day, + ); + + // Folded by the person, it stays folded on the next page. + await page.locator('[data-slot="day-close"]').click(); + await expect(panel).toHaveCount(0); + await page.locator('a[href="/timeline"]').first().click(); + await expect(page).toHaveURL(/\/timeline$/); + await expect(page.locator('[data-slot="day-strip"]')).toHaveAttribute( + "data-state", + "closed", + ); + await expect(panel).toHaveCount(0); +}); + +test("a value point reads on its own, by pointer and by keyboard", async ({ + page, +}) => { + await setTimelineModuleOn(page); + await mockTimeline(page, "full", TODAY); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/timeline"); + const targets = page.locator('[data-slot="timeline-point-target"]'); + await expect(targets.first()).toBeAttached({ timeout: 20_000 }); + + const target = targets.last(); + const series = await target.getAttribute("data-series"); + const t = await target.getAttribute("data-t"); + await target.hover(); + const tip = page.locator('[data-slot="timeline-point-tip"]'); + await expect(tip).toBeVisible(); + await expect(tip).toHaveAttribute("data-series", series!); + await expect(tip).toHaveAttribute("data-t", t!); + // One line only, never the whole bucket. + await expect(tip.locator('[data-slot="rich-chart-tooltip-row"]')).toHaveCount( + 1, + ); + // Inside the chart, and the page does not scroll sideways for it. + const chart = (await page + .locator('[data-slot="timeline-chart"]') + .boundingBox())!; + const box = (await tip.boundingBox())!; + expect(box.x).toBeGreaterThanOrEqual(chart.x - 1); + expect(box.x + box.width).toBeLessThanOrEqual(chart.x + chart.width + 1); + + // The keyboard: focus shows the card, the arrows walk the line. + await page.mouse.move(0, 0); + await target.focus(); + await expect(tip).toHaveAttribute("data-t", t!); + await page.keyboard.press("ArrowLeft"); + await expect(tip).not.toHaveAttribute("data-t", t!); + await expect(tip).toHaveAttribute("data-series", series!); +}); + +test("the timeline page ends where its content does, at every desktop width", async ({ + page, +}) => { + await setTimelineModuleOn(page); + await mockTimeline(page, "full", TODAY); + for (const width of [1280, 1440, 1920]) { + for (const day of [null, daysAgo(3)]) { + await page.setViewportSize({ width, height: 900 }); + await page.goto(day ? `/timeline?day=${day}` : "/timeline"); + await expect( + page.locator('[data-slot="timeline-chart"] > svg'), + ).toBeVisible({ timeout: 20_000 }); + const m = await page.evaluate(() => { + const main = document.getElementById("main-content")!; + const page = document.querySelector('[data-slot="timeline-page"]')!; + const doc = document.scrollingElement!; + return { + mainSw: main.scrollWidth, + mainCw: main.clientWidth, + docSw: doc.scrollWidth, + docCw: doc.clientWidth, + docSh: doc.scrollHeight, + docCh: doc.clientHeight, + mainSh: main.scrollHeight, + contentBottom: + page.getBoundingClientRect().bottom - + main.getBoundingClientRect().top + + main.scrollTop, + }; + }); + const where = `${width}px ${day ? "with" : "without"} the day`; + 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); + } + } +}); + +test("listing all of a day's values scrolls the panel, never the page", async ({ + page, +}) => { + await setTimelineModuleOn(page); + await mockTimeline(page, "full", TODAY); + const day = daysAgo(3); + const types = [ + "WEIGHT", + "BODY_FAT", + "PULSE", + "RESTING_HEART_RATE", + "HEART_RATE_VARIABILITY", + "RESPIRATORY_RATE", + "OXYGEN_SATURATION", + "BODY_TEMPERATURE", + "MUSCLE_MASS", + "BONE_MASS", + "FAT_MASS", + "VISCERAL_FAT", + "BLOOD_GLUCOSE", + "ACTIVITY_STEPS", + ]; + await page.route(`**/api/day/${day}`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + date: day, + tz: "Europe/Berlin", + counts: { values: types.length, entries: 0 }, + running: [], + values: types.map((type, i) => ({ + type, + value: 10 + i, + unit: "u", + at: `${day}T0${i % 10}:00:00.000Z`, + source: "MANUAL", + band: null, + })), + events: [], + notable: [], + scores: [], + sections: {}, + }, + error: null, + }), + }), + ); + await page.setViewportSize({ width: 1440, height: 800 }); + await page.goto(`/timeline?day=${day}`); + const all = page.locator('[data-slot="day-values-all"]'); + await expect(all).toBeVisible({ timeout: 20_000 }); + const heights = () => + page.evaluate(() => ({ + doc: document.scrollingElement!.scrollHeight, + main: document.getElementById("main-content")!.scrollHeight, + })); + const before = await heights(); + await all.click(); + await expect(all).toHaveAttribute("aria-expanded", "true"); + const after = await heights(); + expect(Math.abs(after.doc - before.doc)).toBeLessThanOrEqual(2); + expect(Math.abs(after.main - before.main)).toBeLessThanOrEqual(2); +}); + +test("the day's strip stays at the right edge, opens and closes the day, and never moves", async ({ + page, +}, testInfo) => { + await setTimelineModuleOn(page); + await mockTimeline(page, "full", TODAY); + const strip = page.locator('[data-slot="day-strip"]'); + const toggle = page.locator('[data-slot="day-strip-toggle"]'); + const panel = page.locator('[data-slot="day-panel"]'); + const where = async () => { + const box = (await strip.boundingBox())!; + return [box.x, box.y, box.width, box.height].map(Math.round); + }; + const measure = () => + page.evaluate(() => { + const main = document.getElementById("main-content")!; + const doc = document.scrollingElement!; + return { + docSw: doc.scrollWidth - doc.clientWidth, + docSh: doc.scrollHeight - doc.clientHeight, + mainSw: main.scrollWidth - main.clientWidth, + bar: document + .querySelector('[data-slot="top-bar"]')! + .getBoundingClientRect().bottom, + }; + }); + + for (const width of [1280, 1440, 1920]) { + await page.setViewportSize({ width, height: 900 }); + await page.goto("/timeline"); + await expect( + page.locator('[data-slot="timeline-chart"] > svg'), + ).toBeVisible({ timeout: 20_000 }); + + // Closed: the strip is there, at the right edge, below the top bar's + // band, holding today. + await expect(strip).toHaveAttribute("data-state", "closed"); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + const closed = await where(); + expect(closed[0] + closed[2], `${width}px right edge`).toBe(width); + const before = await measure(); + expect( + Math.round((await toggle.boundingBox())!.y), + `${width}px below the band`, + ).toBe(Math.round(before.bar)); + expect(before.docSw).toBeLessThanOrEqual(0); + expect(before.mainSw).toBeLessThanOrEqual(0); + + // A click opens the day left of the strip; the strip stays put. + await toggle.click(); + await expect(panel).toHaveAttribute("data-shell", "docked"); + await expect(strip).toHaveAttribute("data-state", "open"); + await expect(page.locator("#day-docked-panel")).toHaveAttribute( + "data-state", + "open", + ); + await expect + .poll(async () => { + const box = (await panel.boundingBox())!; + return Math.round(box.x + box.width); + }) + .toBe(closed[0]); + expect(await where()).toEqual(closed); + await expect(panel.locator("h2").first()).toBeFocused(); + const open = await measure(); + expect(open.docSw, `${width}px open, sideways`).toBeLessThanOrEqual(0); + expect(open.mainSw, `${width}px open, sideways`).toBeLessThanOrEqual(0); + expect(open.docSh, `${width}px open, page height`).toBeLessThanOrEqual( + before.docSh, + ); + await testInfo.attach(`day-strip-${width}-open`, { + body: await page.screenshot(), + contentType: "image/png", + }); + + // A second click closes it; focus stays on the strip, which stays put. + await toggle.click(); + await expect(panel).toHaveCount(0); + await expect(strip).toHaveAttribute("data-state", "closed"); + await expect(toggle).toBeFocused(); + expect(await where()).toEqual(closed); + await expect(page).toHaveURL(/\/timeline$/); + } + + // Both themes, open at 1440. + await page.setViewportSize({ width: 1440, height: 900 }); + for (const colorScheme of ["light", "dark"] as const) { + await page.emulateMedia({ colorScheme }); + await page.goto("/timeline"); + await toggle.click(); + await expect(panel).toHaveAttribute("data-shell", "docked"); + await testInfo.attach(`day-strip-1440-${colorScheme}`, { + body: await page.screenshot(), + contentType: "image/png", + }); + await toggle.click(); + await expect(panel).toHaveCount(0); + } +}); diff --git a/e2e/today-just-in.spec.ts b/e2e/today-just-in.spec.ts index 2583b460d..6c8853330 100644 --- a/e2e/today-just-in.spec.ts +++ b/e2e/today-just-in.spec.ts @@ -162,8 +162,17 @@ test("a fresh arrival flips the day in place", async ({ page }) => { // swept by the same `e2e-just-in-` purge on the next run. const priorNight = new Date(Date.now() - 3 * 24 * 60 * 60_000); const priorWoke = new Date(priorNight.getTime() + 7 * 60 * 60_000); + // + // Tagged `foreground`, the trigger an open app sends. Since v1.42 a batch + // without it only marks the dashboard snapshot stale, and `cachedSwr` + // serves the stale cell once while it rebuilds: a snapshot an earlier + // spec left for this account (no sleep at all, so no sleep expected) + // would then paint the BEFORE state as `final`, depending on which spec + // happened to run first in the shard. `foreground` evicts, so the first + // read below is built from the rows this spec just wrote. const priorBatch = await page.request.post("/api/measurements/batch", { data: { + syncTrigger: "foreground", entries: [ { hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis", @@ -229,8 +238,11 @@ test("a fresh arrival flips the day in place", async ({ page }) => { const wokeAt = new Date(now.getTime() - 60 * 60_000); const fellAsleepAt = new Date(wokeAt.getTime() - 7 * 60 * 60_000); + // `foreground` for the same reason as the prior night above: the page + // is open while it lands. const batch = await page.request.post("/api/measurements/batch", { data: { + syncTrigger: "foreground", entries: [ { hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis", diff --git a/e2e/utils/mock-timeline.ts b/e2e/utils/mock-timeline.ts new file mode 100644 index 000000000..c99ebda10 --- /dev/null +++ b/e2e/utils/mock-timeline.ts @@ -0,0 +1,889 @@ +import type { Page, Route } from "@playwright/test"; + +/** + * Route mocks for the timeline (v1.42, #613): `GET /api/timeline`, + * `GET /api/timeline/readiness` and `/api/life-events`, answered in the + * shapes of `src/lib/day/contract.ts` from an invented record. Two records: + * `full` (years of history in every lane) and `ready` (a thin record that + * starts in May 2024, a medication without a start date, no life events, no + * vaccinations), matching the two states the design was approved in. + * + * Every value here is made up. + */ + +type Item = { + id: string; + kind: string; + start: string; + end: string | null; + open: boolean; + precision: "DAY" | "MONTH" | "YEAR"; + startKnown: boolean; + label: string; + sub: string | null; + href: string | null; + /** The medication an item belongs to; one row per group. */ + group: string | null; +}; + +function item(over: Partial & Pick): Item { + return { + end: null, + open: false, + precision: "DAY", + startKnown: true, + label: over.id, + sub: null, + href: null, + group: null, + ...over, + }; +} + +/** A small deterministic generator, so every run draws the same lines. */ +function rng(seed: number) { + let s = seed; + return () => { + s = (s * 16807) % 2147483647; + return (s - 1) / 2147483646; + }; +} + +type Bucket = "quarter" | "month" | "week" | "day"; + +/** The bucket the server picks for a zoom (`timelineBucket`). */ +export function bucketFor( + zoom: string, + from?: string | null, + to?: string | null, +): Bucket { + if (zoom === "range" && from && to) { + // `rangeBucket`: by the range's length in days. + const days = dayNum(to) - dayNum(from) + 1; + return days >= 730 + ? "quarter" + : days >= 120 + ? "month" + : days >= 42 + ? "week" + : "day"; + } + return zoom === "quarter" ? "week" : zoom === "year" ? "month" : "quarter"; +} + +function dayNum(key: string): number { + const [y, m, d] = key.split("-").map(Number); + return Math.round(Date.UTC(y, m - 1, d) / 86_400_000); +} + +function dayStr(n: number): string { + return new Date(n * 86_400_000).toISOString().slice(0, 10); +} + +/** Bucket starts from the one holding `from` to the one holding `to`. */ +function bucketStarts(from: string, to: string, bucket: Bucket): string[] { + const out: string[] = []; + if (bucket === "day") { + for (let d = dayNum(from); d <= dayNum(to); d++) out.push(dayStr(d)); + return out; + } + if (bucket === "week") { + const n = dayNum(from); + // 1970-01-01 was a Thursday: day 4 is the first Monday. + for (let d = n - ((((n - 4) % 7) + 7) % 7); d <= dayNum(to); d += 7) { + out.push(dayStr(d)); + } + return out; + } + const step = bucket === "quarter" ? 3 : 1; + let [y, m] = from.split("-").map(Number); + m -= (m - 1) % step; + const [ty, tm] = to.split("-").map(Number); + while (y < ty || (y === ty && m <= tm)) { + out.push(`${y}-${String(m).padStart(2, "0")}-01`); + m += step; + if (m > 12) { + m -= 12; + y++; + } + } + return out; +} + +/** + * Systolic pressure leaves gaps the chart has to show honestly: in the + * quarterly view one missing quarter (Q2 2023, bridged dashed) and three + * (2021, the line breaks). Q1 2022 rests on two readings in every series. + */ +export const SYS_MISSING = new Set([ + "2021-01-01", + "2021-04-01", + "2021-07-01", + "2023-04-01", +]); +export const SYS_THIN = "2022-01-01"; + +function points( + from: string, + to: string, + bucket: Bucket, + value: (i: number, key: string) => number, + skip: (key: string) => boolean = () => false, +) { + const starts = bucketStarts(from, to, bucket); + return starts + .map((key, i) => ({ + t: key, + mean: Math.round(value(i / starts.length, key) * 10) / 10, + count: key === SYS_THIN ? 2 : 4 + (i % 9), + })) + .filter((p) => !skip(p.t)); +} + +function series(dataFrom: string, today: string, bucket: Bucket) { + const r = rng(11); + return [ + { + key: "BLOOD_PRESSURE_SYS", + unit: "mmHg", + points: points( + dataFrom, + today, + bucket, + (f) => 147 - 18 * f + (r() - 0.5) * 4, + (t) => bucket === "quarter" && SYS_MISSING.has(t), + ), + }, + { + key: "BLOOD_PRESSURE_DIA", + unit: "mmHg", + points: points( + dataFrom, + today, + bucket, + (f) => 92 - 10 * f + (r() - 0.5) * 3, + ), + }, + { + key: "WEIGHT", + unit: "kg", + points: points( + dataFrom, + today, + bucket, + (f) => 89 - 6.5 * f + (r() - 0.5) * 0.6, + ), + }, + { + key: "RESTING_HEART_RATE", + unit: "bpm", + points: points(dataFrom, today, bucket, (f, k) => { + const month = Number(k.slice(5, 7)); + return ( + 68 - 5 * f + Math.sin((month / 12) * 6.28) * 1.2 + (r() - 0.5) * 1.2 + ); + }), + }, + { + key: "PULSE", + unit: "bpm", + points: points(dataFrom, today, bucket, (f) => 74 - 4 * f + r()), + }, + { + key: "BODY_FAT", + unit: "%", + points: points(dataFrom, today, bucket, (f) => 24 - 3 * f + r() * 0.4), + }, + { + key: "ACTIVITY_STEPS", + unit: "steps", + points: points( + dataFrom, + today, + bucket, + (f) => 7200 + 900 * f + r() * 400, + ), + }, + ]; +} + +/** + * Every series the `full` record can show: fifteen, past the five colours + * and past the six lines the chart used to stop at. + */ +export const FULL_AVAILABLE = [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "BODY_FAT", + "SLEEP_DURATION", + "ACTIVITY_STEPS", + "BLOOD_GLUCOSE", + "OXYGEN_SATURATION", + "HEART_RATE_VARIABILITY", + "RESTING_HEART_RATE", + "VO2_MAX", + "BODY_TEMPERATURE", + "RESPIRATORY_RATE", + "MOOD", +]; + +export function fullTimeline(today: string) { + const vaccinations = [ + "2020-06-10", + "2021-05-20", + "2021-07-01", + "2021-12-10", + "2022-10-15", + "2023-10-20", + "2024-03-20", + "2024-04-25", + "2024-10-18", + "2025-10-10", + ]; + const visits = [ + "2019-03-10", + "2020-03-05", + "2021-09-14", + "2022-09-20", + "2023-09-12", + "2024-09-17", + "2025-09-18", + "2026-01-03", + "2026-06-22", + ]; + const documents = [ + "2019-03-12", + "2020-03-06", + "2021-09-15", + "2022-09-21", + "2022-11-09", + "2023-02-20", + "2023-09-13", + "2024-09-18", + "2025-03-14", + "2025-09-19", + "2026-01-03", + "2026-06-23", + ]; + return { + zoom: "all", + range: { + from: "2019-01-01", + to: `${today.slice(0, 4)}-12-31`, + dataFrom: "2019-01-01", + }, + lanes: [ + { + key: "life", + items: [ + item({ + id: "le-1", + kind: "lifeEvent", + start: "2021-05-14", + label: "Geburt Tochter", + }), + item({ + id: "le-2", + kind: "lifeEvent", + start: "2023-09-01", + precision: "MONTH", + label: "Umzug", + }), + item({ + id: "le-3", + kind: "lifeEvent", + start: "2024-10-01", + label: "Neue Stelle", + }), + ], + }, + { + key: "illness", + items: [ + item({ + id: "ill-c", + kind: "chronic", + start: "2019-03-10", + open: true, + label: "Bluthochdruck", + sub: "chronisch", + href: "/illness", + }), + item({ + id: "ill-1", + kind: "episode", + start: "2020-09-01", + end: "2020-10-20", + label: "Schulter", + }), + item({ + id: "ill-2", + kind: "episode", + start: "2022-03-08", + end: "2022-03-24", + label: "COVID-19", + }), + item({ + id: "ill-3", + kind: "episode", + start: "2022-11-02", + end: "2023-02-15", + label: "Bandscheibe L4/L5", + }), + item({ + id: "ill-4", + kind: "episode", + start: "2024-08-10", + end: "2024-08-16", + label: "Magen-Darm", + }), + item({ + id: "ill-5", + kind: "episode", + start: "2025-12-31", + end: "2026-01-08", + label: "Erkältung", + }), + ], + }, + { + key: "allergies", + items: [ + item({ + id: "al-1", + kind: "allergy", + start: "2018-01-01", + open: true, + label: "Gräserpollen", + sub: "seit 2008", + }), + item({ + id: "al-2", + kind: "allergy", + start: "2020-07-15", + open: true, + label: "Penicillin", + }), + ], + }, + { + key: "medications", + items: [ + item({ + id: "med-1", + group: "med-1", + kind: "medication", + start: "2019-04-02", + open: true, + label: "Ramipril", + sub: "2,5 mg", + }), + item({ + id: "dose-1", + group: "med-1", + kind: "doseChange", + start: "2020-08-12", + label: "Ramipril", + sub: "5 mg", + }), + item({ + id: "med-2", + group: "med-2", + kind: "medication", + start: "2022-11-03", + end: "2022-12-10", + label: "Ibuprofen", + }), + // One medication sent as its own span, a course, three dose + // changes and a pause: the case the beta drew as two medications. + item({ + id: "mj", + group: "mj", + kind: "medication", + start: "2025-01-06", + open: true, + label: "Mounjaro", + sub: "7,5 mg", + }), + item({ + id: "mj-course", + group: "mj", + kind: "course", + start: "2025-01-06", + open: true, + label: "Mounjaro", + }), + ...( + [ + ["mj-25", "2025-01-06", "2,5 mg"], + ["mj-5", "2025-03-03", "5 mg"], + ["mj-75", "2025-06-02", "7,5 mg"], + ] as const + ).map(([id, start, sub]) => + item({ + id, + group: "mj", + kind: "doseChange", + start, + label: "Mounjaro", + sub, + }), + ), + item({ + id: "mj-pause", + group: "mj", + kind: "pause", + start: "2025-08-01", + end: "2025-08-20", + label: "Mounjaro", + }), + item({ + id: "c-1", + group: "vit-d", + kind: "course", + start: "2023-11-01", + end: "2024-03-31", + label: "Vitamin D (Winter)", + }), + item({ + id: "c-2", + group: "vit-d", + kind: "course", + start: "2024-11-01", + end: "2025-03-31", + label: "Vitamin D (Winter)", + }), + item({ + id: "c-3", + group: "vit-d", + kind: "course", + start: "2025-11-01", + end: "2026-03-31", + label: "Vitamin D (Winter)", + }), + ], + }, + { + key: "vaccinations", + items: vaccinations.map((d, i) => + item({ + id: `vac-${i}`, + kind: "vaccination", + start: d, + label: "Impfung", + }), + ), + }, + { + key: "visits", + items: [ + ...visits.map((d, i) => + item({ + id: `v-${i}`, + kind: "visit", + start: d, + label: "Hausarztpraxis", + }), + ), + item({ + id: "p-1", + kind: "procedure", + start: "2022-11-08", + label: "MRT Lendenwirbelsäule", + }), + item({ + id: "p-2", + kind: "procedure", + start: "2025-03-12", + label: "Darmspiegelung", + }), + ], + }, + { + key: "documents", + items: documents.map((d, i) => + item({ id: `doc-${i}`, kind: "document", start: d, label: "Befund" }), + ), + }, + ], + standing: [], + bucket: "quarter" as Bucket, + series: series("2019-01-01", today, "quarter"), + availableSeries: FULL_AVAILABLE, + notable: [{ date: "2026-01-04", kind: "extremeHigh" }], + }; +} + +export function readyTimeline(today: string) { + return { + zoom: "all", + range: { + from: "2024-01-01", + to: `${today.slice(0, 4)}-12-31`, + dataFrom: "2024-05-01", + }, + lanes: [ + { + key: "illness", + items: [ + item({ + id: "ill-c", + kind: "chronic", + start: "2019-03-10", + open: true, + label: "Bluthochdruck", + sub: "chronisch", + }), + item({ + id: "ill-4", + kind: "episode", + start: "2024-08-10", + end: "2024-08-16", + label: "Magen-Darm", + }), + item({ + id: "ill-5", + kind: "episode", + start: "2025-12-31", + end: "2026-01-08", + label: "Erkältung", + }), + ], + }, + { + key: "medications", + items: [ + item({ + id: "med-1", + group: "med-1", + kind: "medication", + start: "2024-05-01", + open: true, + startKnown: false, + label: "Ramipril", + sub: "5 mg", + }), + item({ + id: "c-2", + group: "vit-d", + kind: "course", + start: "2024-11-01", + end: "2025-03-31", + label: "Vitamin D (Winter)", + }), + item({ + id: "c-3", + group: "vit-d", + kind: "course", + start: "2025-11-01", + end: "2026-03-31", + label: "Vitamin D (Winter)", + }), + ], + }, + { + key: "visits", + items: [ + item({ + id: "v-1", + kind: "visit", + start: "2024-09-17", + label: "Hausarztpraxis", + }), + item({ + id: "p-2", + kind: "procedure", + start: "2025-03-12", + label: "Darmspiegelung", + }), + item({ + id: "v-2", + kind: "visit", + start: "2025-09-18", + label: "Hausarztpraxis", + }), + item({ + id: "v-3", + kind: "visit", + start: "2026-01-03", + label: "Hausarztpraxis", + }), + item({ + id: "v-4", + kind: "visit", + start: "2026-06-22", + label: "Hausarztpraxis", + }), + ], + }, + { + key: "documents", + items: [ + "2024-09-18", + "2025-03-14", + "2025-09-19", + "2026-01-03", + "2026-06-23", + ].map((d, i) => + item({ id: `doc-${i}`, kind: "document", start: d, label: "Befund" }), + ), + }, + ], + standing: [], + bucket: "quarter" as Bucket, + series: series("2024-05-01", today, "quarter").filter( + (s) => s.key !== "RESTING_HEART_RATE", + ), + availableSeries: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"], + notable: [], + }; +} + +export function fullReadiness() { + return { + verdict: "carries", + since: "2019-03-01", + lanes: [ + { + key: "values", + status: "carries", + count: 3, + detail: { key: "values", params: { count: 3, since: "2019-03-01" } }, + gaps: [], + }, + { + key: "illness", + status: "carries", + count: 6, + detail: { key: "illness", params: { count: 6, chronic: 1 } }, + gaps: [], + }, + { + key: "visits", + status: "carries", + count: 11, + detail: { key: "visits", params: { count: 11, procedures: 2 } }, + gaps: [], + }, + { + key: "documents", + status: "carries", + count: 14, + detail: { key: "documents", params: { count: 14 } }, + gaps: [], + }, + { + key: "labs", + status: "carries", + count: 5, + detail: { key: "labs", params: { count: 5 } }, + gaps: [], + }, + { + key: "medications", + status: "thin", + count: 5, + detail: { key: "medications", params: { count: 5, missingStart: 3 } }, + gaps: [ + { key: "medicationsWithoutStart", count: 3, href: "/medications" }, + ], + }, + { + key: "vaccinations", + status: "empty", + count: 0, + detail: { key: "empty", params: {} }, + gaps: [{ key: "vaccinationsEmpty", count: 0, href: "/vaccinations" }], + }, + { + key: "life", + status: "empty", + count: 0, + detail: null, + gaps: [ + { key: "lifeEventsEmpty", count: 0, href: "/timeline?add=lifeEvent" }, + ], + }, + ], + }; +} + +export function readyReadiness() { + return { + verdict: "carries", + since: "2024-05-01", + lanes: [ + { + key: "values", + status: "carries", + count: 2, + detail: { key: "values", params: { count: 2, since: "2024-05-01" } }, + gaps: [], + }, + { + key: "illness", + status: "carries", + count: 3, + detail: { key: "illness", params: { count: 3, chronic: 1 } }, + gaps: [], + }, + { + key: "visits", + status: "carries", + count: 5, + detail: { key: "visits", params: { count: 5, procedures: 1 } }, + gaps: [], + }, + { + key: "documents", + status: "carries", + count: 5, + detail: { key: "documents", params: { count: 5 } }, + gaps: [], + }, + { + key: "medications", + status: "thin", + count: 3, + detail: { key: "medications", params: { count: 3, missingStart: 3 } }, + gaps: [ + { key: "medicationsWithoutStart", count: 3, href: "/medications" }, + ], + }, + { + key: "life", + status: "empty", + count: 0, + detail: null, + gaps: [ + { key: "lifeEventsEmpty", count: 0, href: "/timeline?add=lifeEvent" }, + ], + }, + { + key: "vaccinations", + status: "empty", + count: 0, + detail: { key: "empty", params: {} }, + gaps: [{ key: "vaccinationsEmpty", count: 0, href: "/vaccinations" }], + }, + ], + }; +} + +const LIFE_EVENTS = [ + { + id: "le-1", + category: "FAMILY", + startDate: "2021-05-14", + endDate: null, + precision: "DAY", + title: "Geburt Tochter", + note: null, + }, + { + id: "le-2", + category: "HOME", + startDate: "2023-09-01", + endDate: null, + precision: "MONTH", + title: "Umzug", + note: null, + }, + { + id: "le-3", + category: "WORK", + startDate: "2024-10-01", + endDate: null, + precision: "DAY", + title: "Neue Stelle", + note: null, + }, +].map((e) => ({ + ...e, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +})); + +function json(route: Route, status: number, data: unknown) { + return route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify({ data, error: null }), + }); +} + +export interface TimelineMockLog { + timelineQueries: URLSearchParams[]; + lifeEventPosts: unknown[]; +} + +/** + * Answer the timeline routes from the `full` or `ready` record. Returns a + * log of what the page asked for and posted, for assertions. + */ +export async function mockTimeline( + page: Page, + state: "full" | "ready", + today: string, +): Promise { + const log: TimelineMockLog = { timelineQueries: [], lifeEventPosts: [] }; + await page.route(/\/api\/timeline\/readiness(\?|$)/, (route) => + json(route, 200, state === "full" ? fullReadiness() : readyReadiness()), + ); + await page.route(/\/api\/timeline(\?|$)/, (route) => { + const url = new URL(route.request().url()); + log.timelineQueries.push(url.searchParams); + const body = state === "full" ? fullTimeline(today) : readyTimeline(today); + const values = (url.searchParams.get("values") ?? "") + .split(",") + .filter(Boolean); + const zoom = url.searchParams.get("zoom") ?? "all"; + const from = url.searchParams.get("from"); + const to = url.searchParams.get("to"); + const bucket = bucketFor(zoom, from, to); + const dataFrom = state === "full" ? "2019-01-01" : "2024-05-01"; + // A chosen range answers its own window, as the server does. + const chosen = zoom === "range" && from && to; + const all = + !chosen && bucket === body.bucket + ? body.series + : series(chosen ? from : dataFrom, chosen ? to : today, bucket); + return json(route, 200, { + ...body, + zoom, + bucket, + ...(chosen ? { range: { from, to, dataFrom } } : {}), + // Any other value the menu offers answers a flat line of its own. + series: values.map( + (key) => + all.find((s) => s.key === key) ?? { + key, + unit: null, + points: points(dataFrom, today, bucket, (f) => 50 + 10 * f), + }, + ), + }); + }); + await page.route(/\/api\/life-events(\/[^/?]+)?(\?|$)/, async (route) => { + const method = route.request().method(); + if (method === "GET") { + return json(route, 200, { events: state === "full" ? LIFE_EVENTS : [] }); + } + if (method === "POST") { + const posted = route.request().postDataJSON() as Record; + log.lifeEventPosts.push(posted); + return json(route, 201, { + id: "le-new", + endDate: null, + note: null, + ...posted, + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + }); + } + return json(route, 200, { deleted: true }); + }); + return log; +} diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts index 6cf1ffbd0..f4e225f74 100644 --- a/e2e/v137-sharing-managed-profiles.spec.ts +++ b/e2e/v137-sharing-managed-profiles.spec.ts @@ -503,13 +503,20 @@ test.describe.serial("scoped sharing browser journeys", () => { await expect(banner).toHaveAttribute("data-record-kind", "shared"); await page.goto("/measurements"); - await expect(page.locator('[data-slot="measurement-add"]')).toBeVisible(); + // An empty list carries its add action in the empty state rather than + // the header, so the write grant shows whichever of the two applies. + const addControl = page + .locator( + '[data-slot="measurement-add"], [data-slot="measurement-add-first"]', + ) + .first(); + await expect(addControl).toBeVisible(); const post = page.waitForResponse( (response) => response.request().method() === "POST" && new URL(response.url()).pathname === "/api/measurements", ); - await page.locator('[data-slot="measurement-add"]').click(); + await addControl.click(); await page.locator("#sys").fill("124"); await page.locator("#dia").fill("78"); await page.getByRole("button", { name: /^save$/i }).click(); diff --git a/e2e/v138-managed-record-capabilities.spec.ts b/e2e/v138-managed-record-capabilities.spec.ts index adedc711f..2a7207059 100644 --- a/e2e/v138-managed-record-capabilities.spec.ts +++ b/e2e/v138-managed-record-capabilities.spec.ts @@ -26,6 +26,7 @@ * the same ones, for the same reasons written there. */ import type { Page } from "@playwright/test"; +import pg from "pg"; import { expect, test } from "./setup/test"; import { @@ -111,15 +112,38 @@ test.describe.serial("a guardian's controls inside a managed profile", () => { if (!managed) throw new Error("managed record fixture is missing"); test("offers the mood add control and lands the entry", async ({ page }) => { + // The profile starts every run without mood entries. The form stamps an + // entry to the minute and `(user, date, moodLoggedAt)` is unique, so a + // second run inside the same minute (a retry, a repeat) met a 409 for the + // entry the first one wrote. An empty record also fixes which control the + // page offers: the empty state's add, not the header's. + const dbUrl = process.env.DATABASE_URL; + test.skip(!dbUrl, "DATABASE_URL is required to reset the profile's mood"); + const pool = new pg.Pool({ connectionString: dbUrl }); + try { + await pool.query( + `DELETE FROM mood_entries + WHERE user_id = (SELECT id FROM users WHERE username = $1)`, + [managed.username], + ); + } finally { + await pool.end(); + } + await openRecord(page, managed.username); const banner = page.locator('[data-slot="shared-record-banner"]'); await expect(banner).toHaveAttribute("data-record-kind", "managed"); await expect(banner).toHaveAttribute("data-access-level", "manage"); await page.goto("/mood"); - const add = page.locator('[data-slot="mood-add-entry"]'); - await expect(add).toBeVisible({ timeout: 30_000 }); - await add.click(); + // Since v1.42 an empty list carries the one add control in its empty + // state and the header drops its own; the header's returns once there is + // an entry to list. Both are asserted on their data-slots. + const addFirst = page.locator('[data-slot="mood-add-first"]'); + const addHeader = page.locator('[data-slot="mood-add-entry"]'); + await expect(addFirst).toBeVisible({ timeout: 30_000 }); + await expect(addHeader).toHaveCount(0); + await addFirst.click(); const face = page.locator('[data-slot="mood-face"][data-mood="GUT"]'); await expect(face).toBeVisible(); @@ -139,6 +163,8 @@ test.describe.serial("a guardian's controls inside a managed profile", () => { await expect(page.locator('[data-slot="mood-rows"]').first()).toBeVisible({ timeout: 30_000, }); + await expect(addHeader).toBeVisible(); + await expect(addFirst).toHaveCount(0); await leaveRecord(page); }); diff --git a/e2e/v142-managed-profile-handover.spec.ts b/e2e/v142-managed-profile-handover.spec.ts new file mode 100644 index 000000000..fc722b35e --- /dev/null +++ b/e2e/v142-managed-profile-handover.spec.ts @@ -0,0 +1,258 @@ +/** + * v1.42 (#959) — a managed profile handed over to the person it describes, + * end to end in a browser: the Guardian mints the link, the person opens it in + * a browser that has never signed in, chooses a sign-in, lands on the decision + * about their former Guardian, and decides. + * + * The link is never mailed (by design: it is shown once and handed over in + * person or scanned). This journey "mails" it by reading it off the panel's + * `data-handover-url` and opening it in a second, cookie-less browser context, + * which is exactly the hop a person makes with a phone. + * + * Its own Guardian account, per project, seeded here: the shared + * `e2e-guardian` belongs to the managed-profile journey, which clears every + * profile that account looks after, and the two would delete each other's + * records mid-run. Every assertion addresses a stable `data-*` attribute or + * the database, never rendered copy. + */ +import AxeBuilder from "@axe-core/playwright"; +import { hash } from "@node-rs/argon2"; +import type { Page } from "@playwright/test"; +import pg from "pg"; + +import { expect, test } from "./setup/test"; + +const PASSWORD = "Handover!Journey-2042"; + +function connect(): pg.Pool { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[handover-e2e] DATABASE_URL is not set"); + return new pg.Pool({ connectionString: url }); +} + +/** WCAG 2.1 AA on the screen as it stands. */ +async function expectNoAxeViolations(page: Page): Promise { + const result = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect(result.violations).toEqual([]); +} + +function names(project: string) { + const slug = project + .replace(/[^a-z0-9]/gi, "") + .toLowerCase() + .slice(0, 12); + return { + guardian: `e2e-hg-${slug}`, + claimant: `e2e-hc-${slug}`, + }; +} + +/** + * A Guardian with a password, signed in through the real login route, then + * given a second factor and a session that has just proved it — the state + * `requireFreshMfa` needs and the only state in which a handover can be made. + */ +async function seedGuardian( + pool: pg.Pool, + username: string, + claimant: string, +): Promise { + // Whatever a previous run left: the claimed account (no longer a managed + // profile, so it does not cascade with the guardian) and the guardian. + await pool.query(`DELETE FROM users WHERE username = $1 OR email = $2`, [ + claimant, + `${claimant}@healthlog.test`, + ]); + await pool.query( + `DELETE FROM users WHERE managed_profile_at IS NOT NULL AND id IN ( + SELECT g.grantor_id FROM account_grants g JOIN users u ON u.id = g.grantee_id + WHERE u.username = $1)`, + [username], + ); + await pool.query(`DELETE FROM users WHERE username = $1`, [username]); + const passwordHash = await hash(PASSWORD, { + memoryCost: 19456, + timeCost: 2, + outputLen: 32, + parallelism: 1, + }); + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed, + disclaimer_acknowledged_at, disclaimer_acknowledged_version) + VALUES ($1, $2, $3, $4, 'USER', NOW(), NOW(), NOW(), true, NOW(), '1')`, + [ + `c${Date.now().toString(36)}${username}`, + username, + `${username}@healthlog.test`, + passwordHash, + ], + ); + // The claim and login buckets for this address, so a local re-run inside the + // window meets the product rather than its rate limit. + await pool.query( + `DELETE FROM rate_limits WHERE key LIKE 'auth:claim%' OR key LIKE 'managed-profile:%'`, + ); +} + +async function stampSecondFactor(pool: pg.Pool, username: string) { + await pool.query( + `UPDATE users SET totp_confirmed_at = NOW() WHERE username = $1`, + [username], + ); + await pool.query( + `UPDATE sessions SET mfa_verified_at = NOW() + WHERE user_id = (SELECT id FROM users WHERE username = $1)`, + [username], + ); +} + +test.describe("managed profile handover", () => { + test("guardian hands over, the person claims and decides", async ({ + page, + browser, + baseURL, + }, testInfo) => { + test.setTimeout(120_000); + const { guardian, claimant } = names(testInfo.project.name); + const pool = connect(); + try { + await seedGuardian(pool, guardian, claimant); + + // ── The Guardian ───────────────────────────────────────────────── + const login = await page.request.post("/api/auth/login", { + data: { email: guardian, password: PASSWORD }, + }); + expect(login.status()).toBe(200); + await stampSecondFactor(pool, guardian); + + const created = await page.request.post("/api/managed-profiles", { + data: { + displayName: "Robin", + dateOfBirth: "2012-03-04", + locale: "en", + timezone: "Europe/Berlin", + gender: null, + }, + }); + expect(created.status()).toBe(201); + const profileId = ((await created.json()) as { data: { id: string } }) + .data.id; + + await page.goto("/settings/access"); + const row = page.locator( + `[data-slot="managed-profile-row"][data-managed-profile-id="${profileId}"]`, + ); + await expect(row).toBeVisible(); + await row + .locator('[data-slot="managed-profile-handover-toggle"]') + .click(); + + const form = row.locator('[data-slot="managed-profile-handover-form"]'); + await expect(form).toBeVisible(); + // Born 2012: the minor hint is shown, the choice stays the Guardian's. + await expect( + row.locator('[data-slot="managed-profile-handover-minor"]'), + ).toBeVisible(); + // The Guardian proposes to keep managing. + await form + .locator('[data-slot="managed-profile-handover-proposal"]') + .first() + .selectOption("manage"); + await form + .locator('[data-slot="managed-profile-handover-create"]') + .click(); + + const linkPanel = row.locator( + '[data-slot="managed-profile-handover-link"]', + ); + await expect(linkPanel).toBeVisible(); + await expect( + row.locator('[data-slot="managed-profile-handover-qr"]'), + ).toBeVisible(); + const url = await linkPanel.getAttribute("data-handover-url"); + expect(url).toMatch(/\/claim\/hlp_[0-9a-f]{64}$/); + await expectNoAxeViolations(page); + + // ── The person, on a browser that never signed in ──────────────── + const person = await browser.newContext({ baseURL }); + try { + const claimPage = await person.newPage(); + const path = new URL(url as string).pathname; + await claimPage.goto(path); + await expect(claimPage).toHaveURL(/\/auth\/claim\?token=hlp_/); + await expect( + claimPage.locator('[data-testid="claim-form-card"]'), + ).toBeVisible(); + await expect( + claimPage.locator( + '[data-slot="claim-guardian"][data-proposal="manage"]', + ), + ).toHaveCount(1); + + await expectNoAxeViolations(claimPage); + await claimPage.locator("#claim-username").fill(claimant); + await claimPage + .locator("#claim-email") + .fill(`${claimant}@healthlog.test`); + await claimPage.locator("#claim-password").fill(PASSWORD); + await claimPage.locator('[data-slot="claim-submit"]').click(); + + await expect(claimPage).toHaveURL(/\/onboarding\/handover$/, { + timeout: 30_000, + }); + const decision = claimPage.locator( + '[data-slot="handover-decision-guardian"][data-decidable="true"]', + ); + await expect(decision).toHaveCount(1); + await expect( + claimPage.locator('[data-slot="handover-notification-hint"]'), + ).toBeVisible(); + await expectNoAxeViolations(claimPage); + // The person decides: view only, not managing. + await decision + .locator('[data-slot="handover-decision-choice"]') + .selectOption("read"); + await claimPage + .locator('[data-slot="handover-decision-submit"]') + .click(); + await expect(claimPage).toHaveURL(/\/onboarding(\?|$)/, { + timeout: 30_000, + }); + + // The used link is dead for anyone after. + const reused = await person.request.post("/api/auth/claim/preview", { + data: { token: (url as string).split("/claim/")[1] }, + }); + // A signed-in browser is refused before the token is read; a fresh one + // would get the uniform 404. Either way, nothing is handed over twice. + expect([404, 409]).toContain(reused.status()); + } finally { + await person.close(); + } + + // ── What the database says ─────────────────────────────────────── + const account = await pool.query( + `SELECT managed_profile_at, username, email, password_hash IS NOT NULL AS has_password + FROM users WHERE id = $1`, + [profileId], + ); + expect(account.rows[0]).toMatchObject({ + managed_profile_at: null, + username: claimant, + has_password: true, + }); + const live = await pool.query( + `SELECT g.access FROM account_grants g JOIN users u ON u.id = g.grantee_id + WHERE g.grantor_id = $1 AND u.username = $2 AND g.revoked_at IS NULL`, + [profileId, guardian], + ); + expect(live.rows).toEqual([{ access: "READ" }]); + } finally { + await pool.end(); + } + }); +}); diff --git a/e2e/v142-qol.spec.ts b/e2e/v142-qol.spec.ts new file mode 100644 index 000000000..a252891b7 --- /dev/null +++ b/e2e/v142-qol.spec.ts @@ -0,0 +1,384 @@ +/** + * v1.42 — three surfaces made easier to live with. + * + * - Documents: the display choice behind the wrench (cards or list, months + * stacked or flowing) persists per account; every delete asks first; the + * selection is cleared with an X on the count line. + * - Wellness scores: every score tile shows its course; a score's detail + * page carries its history right under the score card and no longer the + * warning-coloured "not clinical" line. + * - Mood insights: the calendar and the line chart appear together without + * moving the page, and logging is the header plus every Insights page + * uses. + * + * Everything is addressed through stable data attributes, never copy. + */ +import type { Page } from "@playwright/test"; +import { expect, test } from "./setup/test"; + +import { 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 { + const res = await page.request.put("/api/documents/inbound/layout", { + data: { version: 1, view: "cards", arrangement: "stacked" }, + }); + expect(res.status()).toBe(200); +} + +test.describe("documents", () => { + test.beforeAll(async () => { + await ensureVaultFixture(); + }); + + test("the display choice behind the wrench persists across visits", async ({ + page, + isMobile, + }) => { + // Both projects share one account; the layout is account state, so one + // project owns this journey. + test.skip(isMobile, "account-level preference; one project owns it"); + await resetDocumentsLayout(page); + try { + await page.goto("/documents"); + const timeline = page.locator('[data-slot="document-timeline"]'); + await expect(timeline).toHaveAttribute("data-view", "cards"); + await expect(timeline).toHaveAttribute("data-arrangement", "stacked"); + + await page.locator('[data-slot="documents-customize"]').click(); + await expect(page).toHaveURL(/\/settings\/layout\/documents$/); + const settings = page.locator('[data-slot="documents-layout-settings"]'); + await settings.locator('[data-slot="module-view-list"]').click(); + await expect( + 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. + const saved = page.waitForResponse( + (r) => + r.url().includes("/api/documents/inbound/layout") && + r.request().method() === "PUT", + ); + await flow.click(); + expect((await saved).status()).toBe(200); + + await page.goto("/documents"); + await expect(timeline).toHaveAttribute("data-view", "list"); + await expect(timeline).toHaveAttribute("data-arrangement", "flow"); + await expect( + timeline + .locator('[data-slot="document-card"][data-variant="row"]') + .first(), + ).toBeVisible(); + // Flowing: no month gets a row of its own, the name rides inline. + await expect( + timeline.locator('[data-slot="document-flow-month"]').first(), + ).toBeVisible(); + + // A fresh visit in a fresh page — the choice is the account's, not + // this tab's. + const second = await page.context().newPage(); + await second.goto("/documents"); + await expect( + second.locator('[data-slot="document-timeline"]'), + ).toHaveAttribute("data-arrangement", "flow"); + await second.close(); + } finally { + await resetDocumentsLayout(page); + } + }); + + test("bulk delete asks first, and cancelling deletes nothing", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "selection by checkbox is a pointer flow"); + await seedNamespaceDocs("qoldel", 2); + await page.goto("/documents?q=qoldel"); + const box = page.getByRole("checkbox", { name: "Select qoldel 002" }); + await expect(box).toBeVisible(); + await box.click(); + + let deletes = 0; + page.on("request", (req) => { + if ( + req.method() === "POST" && + req.url().includes("/api/documents/inbound/bulk") && + (req.postData() ?? "").includes('"delete"') + ) { + deletes += 1; + } + }); + + const bar = page.locator('[data-slot="document-bulk-bar"]'); + await bar.locator('[data-slot="document-bulk-delete"]').click(); + const confirm = page.locator('[data-slot="documents-delete-confirm"]'); + await expect(confirm).toBeVisible(); + await page + .getByRole("alertdialog") + .getByRole("button", { name: "Cancel" }) + .click(); + await expect(confirm).toBeHidden(); + expect(deletes).toBe(0); + await expect( + page.getByRole("button", { name: "Open qoldel 002" }), + ).toBeVisible(); + + await bar.locator('[data-slot="document-bulk-delete"]').click(); + await confirm.click(); + await expect( + page.getByRole("button", { name: "Open qoldel 002" }), + ).toBeHidden(); + expect(deletes).toBe(1); + }); + + test("the Delete key on a focused card asks first too", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "keyboard flow"); + await seedNamespaceDocs("qolkey", 1); + await page.goto("/documents?q=qolkey"); + const open = page.getByRole("button", { name: "Open qolkey 001" }); + await expect(open).toBeVisible(); + await open.focus(); + await page.keyboard.press("Delete"); + await expect( + page.locator('[data-slot="documents-delete-confirm"]'), + ).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(open).toBeVisible(); + }); + + test("the selection clears with the X on the count line", async ({ + page, + isMobile, + }) => { + test.skip(isMobile, "selection by checkbox is a pointer flow"); + await seedNamespaceDocs("qolclear", 2); + await page.goto("/documents?q=qolclear"); + await page.getByRole("checkbox", { name: "Select qolclear 001" }).click(); + + const bar = page.locator('[data-slot="document-bulk-bar"]'); + await expect(bar).toBeVisible(); + const count = bar.locator('[data-slot="document-bulk-count"]'); + const clear = bar.locator('[data-slot="document-bulk-clear"]'); + const actions = bar.locator('[data-slot="document-bulk-actions"]'); + + // The X sits on the count line, at its right edge, above the action row, + // and the action row follows the count line without a gap of its own. + const [countBox, clearBox, actionsBox, barBox] = await Promise.all([ + count.boundingBox(), + clear.boundingBox(), + actions.boundingBox(), + bar.boundingBox(), + ]); + expect(countBox && clearBox && actionsBox && barBox).toBeTruthy(); + const mid = (b: { y: number; height: number }) => b.y + b.height / 2; + expect(Math.abs(mid(clearBox!) - mid(countBox!))).toBeLessThan(6); + expect( + barBox!.x + barBox!.width - (clearBox!.x + clearBox!.width), + ).toBeLessThan(20); + expect( + actionsBox!.y - (clearBox!.y + clearBox!.height), + ).toBeLessThanOrEqual(10); + // One action row: every verb shares a top edge. + const tops = await actions + .locator("button") + .evaluateAll((els) => + els.map((el) => Math.round(el.getBoundingClientRect().top)), + ); + expect(new Set(tops).size).toBe(1); + + await clear.click(); + await expect(bar).toBeHidden(); + }); +}); + +test.describe("wellness scores", () => { + test("every score tile shows its course", async ({ page }) => { + await mockPopulatedInsights(page); + await page.goto("/insights"); + for (const metric of ["RECOVERY_SCORE", "STRESS_SCORE"]) { + const tile = page.locator( + `[data-slot="wellness-score-tile"][data-metric="${metric}"]`, + ); + await tile.scrollIntoViewIfNeeded(); + await expect( + tile.locator('[data-slot="wellness-score-history"]'), + ).toBeVisible(); + } + }); + + test("a score's detail page carries its history under the card, without the caveat line", async ({ + page, + }) => { + // The e2e account has no nightly score; serve one so the page has a + // score to draw the history under. + await page.route( + /\/api\/insights\/derived\?metric=RECOVERY_SCORE/, + (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + metric: "RECOVERY_SCORE", + status: "ok", + value: { + score: 66, + band: "yellow", + trendDelta: 2, + daysInWindow: 5, + asOf: new Date().toISOString(), + series: [58, 61, 64, 60, 66], + anchor: null, + components: null, + }, + coverage: { + requiredInputs: 1, + presentInputs: 1, + historyDays: 5, + missing: [], + }, + confidence: { score: 80, band: "high" }, + provenance: { + inputs: ["RECOVERY_SCORE"], + source: "live", + windowDays: 14, + computedAt: new Date().toISOString(), + }, + reason: null, + assessment: null, + }, + error: null, + }), + }), + ); + await page.goto("/insights/scores/recovery"); + const anatomy = page.locator('[data-slot="score-anatomy-view"]'); + await expect(anatomy).toBeVisible(); + const history = page.locator('[data-slot="score-history-chart"]'); + await expect(history).toBeAttached(); + const [cardBox, historyBox] = await Promise.all([ + anatomy.boundingBox(), + history.boundingBox(), + ]); + expect(historyBox!.y).toBeGreaterThan(cardBox!.y + cardBox!.height - 1); + await expect(anatomy.locator(".text-warning")).toHaveCount(0); + }); +}); + +test.describe("mood insights", () => { + test.beforeAll(async ({ browser }) => { + // A short, real history, so the calendar and the chart both have days. + const context = await browser.newContext({ + storageState: STORAGE_STATE_PATH, + }); + const now = Date.now(); + for (let day = 1; day <= 12; day++) { + const res = await context.request.post("/api/mood-entries", { + data: { + mood: day % 3 === 0 ? "OKAY" : "GUT", + moodLoggedAt: new Date(now - day * 86_400_000).toISOString(), + source: "MANUAL", + externalId: `e2e-qol-mood-${day}`, + }, + }); + expect([200, 201]).toContain(res.status()); + } + await context.close(); + }); + + test("the calendar and the chart appear together without moving the page", async ({ + page, + }) => { + // The calendar's read is the slower one in production; slow it down here + // so the old order (chart first, calendar pushing it down) would show. + await page.route(/\/api\/mood\/insights(\?|$)/, async (route) => { + await new Promise((resolve) => setTimeout(resolve, 1_200)); + await route.continue(); + }); + await page.addInitScript(() => { + const w = window as unknown as { + __cls: number; + __seen: Record; + }; + w.__cls = 0; + w.__seen = {}; + new PerformanceObserver((list) => { + for (const entry of list.getEntries() as Array< + PerformanceEntry & { value: number; hadRecentInput: boolean } + >) { + if (!entry.hadRecentInput) w.__cls += entry.value; + } + }).observe({ type: "layout-shift", buffered: true }); + const watch = () => { + if ( + !w.__seen.heatmap && + document.querySelector( + '[data-slot="mood-heatmap-body"] svg[role="img"]', + ) + ) + w.__seen.heatmap = performance.now(); + if ( + !w.__seen.chart && + document.querySelector( + '[data-slot="insights-mood-chart"] .recharts-surface', + ) && + !document.querySelector( + '[data-slot="insights-mood-chart"] .invisible', + ) + ) + w.__seen.chart = performance.now(); + requestAnimationFrame(watch); + }; + requestAnimationFrame(watch); + }); + + await page.goto("/insights/mood"); + await expect( + page.locator('[data-slot="mood-heatmap-body"] svg[role="img"]'), + ).toBeAttached({ + timeout: 15_000, + }); + await expect( + page + .locator('[data-slot="insights-mood-chart"] .recharts-surface') + .first(), + ).toBeVisible(); + await page.waitForTimeout(1_500); + + const { cls, seen } = await page.evaluate(() => { + const w = window as unknown as { + __cls: number; + __seen: Record; + }; + return { cls: w.__cls, seen: w.__seen }; + }); + expect(cls).toBeLessThan(0.02); + expect(seen.heatmap).toBeDefined(); + expect(seen.chart).toBeDefined(); + // Together: within a couple of frames of each other. + expect(Math.abs(seen.heatmap - seen.chart)).toBeLessThan(100); + }); + + test("logging is the header plus, not a text link", async ({ page }) => { + await page.goto("/insights/mood"); + const add = page.locator( + '[data-slot="insights-mood-add"][data-header-add]', + ); + await expect(add).toBeVisible(); + await expect( + page.locator('[data-slot="insights-mood-log-link"]'), + ).toHaveCount(0); + await expect( + page.locator('[data-slot="insights-mood-mental-wellbeing-link"]'), + ).toHaveCount(0); + await add.click(); + await expect(page.getByRole("dialog")).toBeVisible(); + }); +}); diff --git a/e2e/vaccinations.spec.ts b/e2e/vaccinations.spec.ts index fbf2f2199..40b43f27c 100644 --- a/e2e/vaccinations.spec.ts +++ b/e2e/vaccinations.spec.ts @@ -84,11 +84,19 @@ async function captureDose( booster: "confirm" | "decline" | "none"; }, ): Promise { - // The header Add renders whether or not the list is empty, so clicking it - // works in both states. click() auto-waits for the button to be actionable, - // which avoids racing an instant visibility read against the client render - // (an empty-state-only branch times out once a record already exists). - await page.locator('[data-slot="vaccination-add"]').first().click(); + // While the Impfpass is empty its empty state carries the add action and + // the header drops its own, so wait for the list to settle (a record or + // the empty state) and click whichever add control is then on screen. + await expect( + page.locator('[data-vaccination-id], [data-slot="empty-state"]').first(), + ).toBeVisible(); + await page + .locator( + '[data-slot="vaccination-add"], [data-slot="vaccination-add-empty"]', + ) + .filter({ visible: true }) + .first() + .click(); await expect(page.locator('[data-slot="vaccination-form"]')).toBeVisible(); @@ -321,7 +329,12 @@ test.describe("vaccinations", () => { ).toBeVisible({ timeout: 15_000 }); await expectNoSeriousAxe(page); - await page.locator('[data-slot="vaccination-add"]').first().click(); + await page + .locator( + '[data-slot="vaccination-add"], [data-slot="vaccination-add-empty"]', + ) + .last() + .click(); await expect(page.locator('[data-slot="vaccination-form"]')).toBeVisible(); await expectNoSeriousAxe(page); }); diff --git a/eslint.config.mjs b/eslint.config.mjs index 897750959..d1d6373ee 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -153,6 +153,7 @@ const eslintConfig = defineConfig([ "src/components/charts/workout-hr-chart.tsx", "src/components/charts/workout-elevation-chart.tsx", "src/components/insights/coach-panel/result-chart.tsx", + "src/components/insights/derived/score-trend-chart.tsx", ], rules: { "no-restricted-imports": [ diff --git a/knip.jsonc b/knip.jsonc index 560b3914b..50a7b35a4 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -121,6 +121,14 @@ "**/__mocks__/**", "e2e/setup/test-helpers.ts", "src/lib/logging/index.ts", + // v1.42 release-branch contract stubs: each is created ahead of its + // consumers so the packages built in parallel share one signature. Drop + // a line the moment its first consumer lands; the release integration + // checklist removes whatever is left before the tag. + "src/lib/environment/air-quality-contract.ts", + // The day view and timeline contract (#613): constants and wire types the + // server, the two views and the Coach/MCP tool build against. + "src/lib/day/contract.ts", ], "ignoreDependencies": [ "@types/node", diff --git a/messages/de.json b/messages/de.json index 162341741..7e8193c6c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -49,7 +49,9 @@ "dismiss": "Schließen", "moreInfo": "Mehr Informationen", "durationHoursMinutes": "{hours} Std. {minutes} Min.", - "durationMinutes": "{minutes} Min." + "durationMinutes": "{minutes} Min.", + "durationClockMinutes": "{time} Min.", + "durationClockHours": "{time} Std." }, "apiErrors": { "measurement": { @@ -165,7 +167,7 @@ "anamnesisShiftSchedule": "Arbeitszeitmodell", "anamnesisColFact": "Profilangabe", "anamnesisColValue": "Erfasster Wert", - "subtitle": "HealthLog — Persönlicher Gesundheitsbericht", + "subtitle": "HealthLog: Persönlicher Gesundheitsbericht", "patient": "Patient", "dateOfBirth": "Geburtsdatum", "gender": "Geschlecht", @@ -175,18 +177,18 @@ "height": "Körpergröße", "period": "Berichtszeitraum", "createdOn": "Erstellt am", - "vitalsTitle": "Vitalparameter — Übersicht", + "vitalsTitle": "Vitalparameter: Übersicht", "colParameter": "Parameter", "colCurrent": "Aktuell", "colAvgPeriod": "Ø Zeitraum", "colMin": "Min", "colMax": "Max", "colN": "n", - "bpClassificationTitle": "Blutdruck — ESH-Klassifikation (2023)", + "bpClassificationTitle": "Blutdruck: ESH-Klassifikation (2023)", "avgBp": "Durchschnittlicher Blutdruck", "classification": "Klassifikation", "bmiTitle": "Body-Mass-Index (BMI)", - "bmiRow": "BMI: {bmi} kg/m² — Klassifikation: {class} (WHO)", + "bmiRow": "BMI: {bmi} kg/m², Klassifikation: {class} (WHO)", "complianceTitle": "Therapietreue", "glp1Title": "GLP-1-Therapie", "glp1WeightSummary": "Gewicht im Berichtszeitraum: {start} kg → {end} kg ({delta} kg)", @@ -204,7 +206,7 @@ "colExpected": "Erwartet", "colComplianceRate": "Therapietreue", "moodTitle": "Stimmungsverlauf", - "moodSummary": "Durchschnitt: {avg} / 5,0 · Einträge: {count} · Bereich: {min}–{max}", + "moodSummary": "Durchschnitt {avg} / 5,0, {count} Einträge, Bereich {min}–{max}", "colMood": "Stimmung", "colCount": "Anzahl", "colShare": "Anteil", @@ -234,8 +236,8 @@ "typeGlucoseRandom": "Glukose (beliebig)", "typeGlucoseBedtime": "Glukose (vor dem Schlafen)", "typeGlucoseUnspecified": "Glukose (ohne Angabe)", - "glucoseClassificationTitle": "Blutzucker — Referenzbereiche (ADA 2024 / DDG)", - "glucoseRow": "{label}: Ø {avg} {unit} (Ziel {rangeMin}–{rangeMax}) — {class}", + "glucoseClassificationTitle": "Blutzucker: Referenzbereiche (ADA 2024 / DDG)", + "glucoseRow": "{label}: Ø {avg} {unit} (Ziel {rangeMin}–{rangeMax}): {class}", "glucoseInTarget": "im Ziel", "glucoseBelowTarget": "unter Ziel", "glucoseAboveTarget": "über Ziel", @@ -287,7 +289,7 @@ "gmi": "Glukose-Management-Indikator (GMI)", "eA1c": "Geschätzter HbA1c", "cv": "Variabilität (VK%)", - "spotCaveat": "Dies sind Schätzwerte aus Einzelmessungen, kein Profil eines kontinuierlichen Messgeräts — als Tendenz und nicht als klinisches Urteil zu lesen." + "spotCaveat": "Dies sind Schätzwerte aus Einzelmessungen, kein Profil eines kontinuierlichen Messgeräts. Sie sind als Tendenz und nicht als klinisches Urteil zu lesen." }, "illnessTitle": "Erkrankungen", "illnessOngoing": "Andauernd", @@ -376,12 +378,12 @@ "backToDashboard": "Zurück zum Dashboard" }, "offlineBanner": { - "message": "Keine Verbindung — Änderungen können erst gespeichert werden, wenn du wieder online bist.", - "saveFailed": "Speichern fehlgeschlagen — du bist offline. Bitte versuche es erneut, sobald du wieder online bist.", + "message": "Keine Verbindung: Änderungen können erst gespeichert werden, wenn du wieder online bist.", + "saveFailed": "Speichern fehlgeschlagen, du bist offline. Bitte versuche es erneut, sobald du wieder online bist.", "showingCached": "Zeigt deine zuletzt synchronisierten Daten." }, "demoBanner": { - "message": "Demo-Modus — Änderungen werden nicht gespeichert." + "message": "Demo-Modus: Änderungen werden nicht gespeichert." }, "nav": { "dashboard": "Dashboard", @@ -427,7 +429,9 @@ "workout": "Workout", "workoutDescription": "Eine Einheit, die kein Gerät aufgezeichnet hat", "symptom": "Symptom", - "symptomDescription": "Eines deiner eigenen Symptome, mit Stärke" + "symptomDescription": "Eines deiner eigenen Symptome, mit Stärke", + "lifeEventDescription": "Umzug, Geburt, neue Stelle", + "lifeEvent": "Lebensereignis" }, "cycle": "Zyklus", "labs": "Laborwerte", @@ -436,7 +440,8 @@ "illness": "Krankheit", "documents": "Dokumente", "vaccinations": "Impfungen", - "mentalWellbeing": "Seelisches Wohlbefinden" + "mentalWellbeing": "Seelisches Wohlbefinden", + "timeline": "Zeitleiste" }, "auth": { "login": "Anmelden", @@ -453,14 +458,13 @@ "hasAccount": "Bereits ein Konto?", "loginFailed": "Anmeldung fehlgeschlagen", "registerFailed": "Registrierung fehlgeschlagen", - "invitedBanner": "Du wurdest eingeladen — erstelle dein Konto, auch wenn die Registrierung sonst geschlossen ist.", + "invitedBanner": "Du wurdest eingeladen. Erstelle dein Konto, auch wenn die Registrierung sonst geschlossen ist.", "alreadySignedIn": { "title": "Du bist bereits angemeldet", "body": "Du bist als {username} angemeldet. Eine Einladung legt ein neues Konto an. Melde dich ab, um sie anzunehmen.", "signOut": "Abmelden und Einladung annehmen" }, "createAccount": "Konto erstellen", - "newAccount": "Neues Konto erstellen", "backToPasskey": "Zurück zu Passkey", "passkeySetupLater": "Passkeys kannst du jederzeit in den Einstellungen einrichten.", "passwordMinLength": "Min. 12 Zeichen", @@ -513,6 +517,37 @@ "fixLan": "Server im Heimnetz ohne TLS: Setze SESSION_COOKIE_SECURE=false in seinen Einstellungen und starte ihn neu.", "fixHttps": "Server hinter einem HTTPS-Proxy: Öffne stattdessen die https://-Adresse und sorge dafür, dass der Proxy X-Forwarded-Proto: https weitergibt.", "localhostNote": "Diese Seite läuft auf http://localhost, der Server setzt sein Anmelde-Cookie aber nur für https://. Die meisten Browser akzeptieren das auf localhost; wenn du nach der Anmeldung wieder hier landest, setze SESSION_COOKIE_SECURE=false." + }, + "claim": { + "title": "Übernimm deine Gesundheitsakte", + "titleNamed": "Willkommen, {name}", + "description": "Deine Gesundheitsakte wurde bisher für dich geführt. Wähle, wie du dich anmeldest, und sie wird dein eigenes Konto. Nichts wird kopiert oder verschoben.", + "loading": "Dein Link wird geprüft…", + "invalid": "Dieser Link funktioniert nicht mehr", + "invalidHint": "Er ist unbekannt, abgelaufen, zurückgezogen oder schon benutzt. Bitte die Person, die ihn dir gegeben hat, um einen neuen.", + "oidcOnly": "Dieser Server erlaubt nur Single Sign-on, deshalb lässt sich ein Profil hier noch nicht übernehmen.", + "signedInTitle": "Melde dich zuerst ab", + "signedInBody": "Du bist als {username} angemeldet. Dieser Link richtet eine Anmeldung für jemand anderen ein, melde dich also ab, bevor du ihn benutzt.", + "signOut": "Abmelden und weiter", + "guardiansTitle": "Wer vorerst Zugriff behält", + "guardiansHint": "Vorgeschlagen von den Personen, die deine Akte geführt haben. Du entscheidest direkt nach der Anmeldung.", + "guardianUnnamed": "Eine betreuende Person", + "usernameHint": "3 bis 30 Buchstaben, Ziffern, _ oder -.", + "usernameReserved": "Benutzernamen, die mit managed- beginnen, sind reserviert.", + "emailHint": "Pflicht, damit du wieder hineinkommst, wenn du dein Passwort vergisst.", + "consentNote": "Den Hinweis und jede KI-Nutzung bestätigst du selbst. Bis dahin geht nichts aus deiner Akte an einen KI-Anbieter.", + "expires": "Dieser Link gilt bis {date}.", + "submit": "Übernehmen", + "submitting": "Wird übernommen…", + "errorTaken": "Dieser Benutzername oder diese E-Mail-Adresse ist schon vergeben.", + "errorRateLimit": "Zu viele Versuche. Warte ein paar Minuten und versuche es erneut.", + "errorFailed": "Das hat nicht geklappt. Versuche es gleich noch einmal.", + "errorOffline": "Das hat den Server nicht erreicht. Prüfe deine Verbindung und versuche es erneut." + }, + "inviteInvalid": { + "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" } }, "dashboard": { @@ -544,17 +579,17 @@ "sleepShort": "Schlaf", "stepsShort": "Schritte", "vo2MaxShort": "VO₂ max", - "bpInTargetWindow90": "BD-Ziel · 90 T", - "bpInTargetWindowDynamic": "BD-Ziel · {days} T", + "bpInTargetWindow90": "BD-Ziel (90 T)", + "bpInTargetWindowDynamic": "BD-Ziel ({days} T)", "bpInTargetCollecting": "sammelt noch Daten", "medications": "Medikamente", "quickAddMeasurement": "Messung erfassen", "quickAddMood": "Stimmung erfassen", "quickAddMedicationIntake": "Einnahme erfassen", "quickAddWorkout": "Workout erfassen", + "quickAddLifeEvent": "Lebensereignis erfassen", "customizeDashboard": "Dashboard anpassen", "medicationIntakeQuickAdd": { - "sheetTitle": "Medikamenteneinnahme erfassen", "sheetDescription": "Dosis, Zeitpunkt und Medikament in wenigen Tipps protokollieren.", "medicationLabel": "Medikament", "medicationPlaceholder": "Medikament auswählen", @@ -611,7 +646,7 @@ "layoutTileColumn": "Kachel", "layoutChartColumn": "Chart", "layoutNativeOnlyTitle": "In der mobilen App sichtbar", - "layoutNativeOnlyHint": "Diese Widgets zeichnet die mobile App, nicht diese Seite. Sie liegen im selben gespeicherten Layout — hier wird ihre Sichtbarkeit gesetzt.", + "layoutNativeOnlyHint": "Diese Widgets zeichnet die mobile App, nicht diese Seite. Sie liegen im selben gespeicherten Layout, hier wird ihre Sichtbarkeit gesetzt.", "dragHandle": "Zum Verschieben ziehen", "dragHandleHint": "Pfeiltasten Hoch/Runter, oder Leertaste plus Pfeiltasten am fokussierten Griff.", "emptyTitle": "Dein Dashboard ist leer", @@ -667,7 +702,8 @@ "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" + "quickAddSymptom": "Symptom erfassen", + "todayCardVisible": "Obere Karte anzeigen" }, "measurements": { "title": "Messungen", @@ -735,11 +771,11 @@ "valueWithUnit": "Wert ({unit})", "timestamp": "Zeitpunkt", "timestampBackdateHint": "Datum und Uhrzeit anpassen, um einen vergangenen Wert zu erfassen.", - "notesPlaceholder": "z.B. nach dem Essen", + "notesPlaceholder": "z. B. nach dem Essen", "formReset": "Formular zurücksetzen", "saveError": "Fehler beim Speichern", "duplicateTimestamp": "Zum gewählten Zeitpunkt existiert bereits ein Messwert dieses Typs.", - "serverOwnedSource": "Werte aus einer verbundenen Quelle lassen sich nicht bearbeiten — Zeitpunkt und Notiz bleiben änderbar.", + "serverOwnedSource": "Werte aus einer verbundenen Quelle lassen sich nicht bearbeiten. Zeitpunkt und Notiz bleiben änderbar.", "deleteError": "Fehler beim Löschen", "deletedToast": "Messwert gelöscht", "restoredToast": "Messwert wiederhergestellt", @@ -834,11 +870,12 @@ "durationHoursOnly": "{hours} Std.", "durationMinutesOnly": "{minutes} Min.", "durationInvalid": "Gib eine Dauer ein, zum Beispiel 7,5, 7:30 oder 7h 30m.", - "durationTooLong": "Ein Eintrag kann nicht länger als 24 Stunden sein." + "durationTooLong": "Ein Eintrag kann nicht länger als 24 Stunden sein.", + "sourceHealthConnect": "Health Connect" }, "metricCatalog": { "title": "Was HealthLog erfassen kann", - "description": "Alle Messwert-Bereiche, die HealthLog unterstützt — auch die, für die noch keine Daten vorliegen — und was sie liefert.", + "description": "Alle Messwert-Bereiche, die HealthLog unterstützt (auch die, für die noch keine Daten vorliegen), und was sie liefert.", "statusPresent": "Erfasst", "statusAbsent": "Noch nicht erfasst", "statusModuleOff": "Aus", @@ -846,11 +883,11 @@ "connectCta": "Quelle verbinden", "viewCta": "Ansehen", "ecg": { - "source": "Einkanal-EKG-Streifen von einer kompatiblen Uhr — aktuell Withings ScanWatch — werden automatisch synchronisiert." + "source": "Einkanal-EKG-Streifen von einer kompatiblen Uhr (aktuell Withings ScanWatch) werden automatisch synchronisiert." }, "watchdog": { "title": "Automatische Überwachung", - "description": "HealthLog beobachtet außerdem still im Hintergrund — Benachrichtigungen zu unregelmäßigem Rhythmus und Atemstörungen von einer verbundenen Uhr, Warnungen bei Abweichungen von deiner Baseline sowie auffällige Veränderungen zwischen deinen letzten beiden Laborwerten — und zeigt eine Karte nur, wenn etwas auffällt. Ist mindestens eine dieser Quellen verbunden, ist keine Karte ein gutes Zeichen, kein fehlendes Feature; ist noch keine verbunden, wird schlicht noch nichts überwacht." + "description": "HealthLog beobachtet außerdem still im Hintergrund: Benachrichtigungen zu unregelmäßigem Rhythmus und Atemstörungen von einer verbundenen Uhr, Warnungen bei Abweichungen von deiner Baseline sowie auffällige Veränderungen zwischen deinen letzten beiden Laborwerten. Eine Karte erscheint nur, wenn etwas auffällt. Ist mindestens eine dieser Quellen verbunden, ist keine Karte ein gutes Zeichen, kein fehlendes Feature; ist noch keine verbunden, wird schlicht noch nichts überwacht." } }, "mood": { @@ -865,7 +902,7 @@ "factorNo": "Nein", "factorRatingOption": "{label}: {value} von {max}", "tags": "Tags", - "tagsPlaceholder": "z.B. Sport, Arbeit, Familie", + "tagsPlaceholder": "z. B. Sport, Arbeit, Familie", "tagsHelp": "Kommagetrennt eingeben", "timestamp": "Zeitpunkt", "date": "Datum", @@ -1007,7 +1044,7 @@ "archive": "Archivieren", "restore": "Wiederherstellen", "archivedTitle": "Archivierte Tags", - "archivedDescription": "Archivierte Tags bleiben in deinen bisherigen Einträgen erhalten — sie verschwinden nur aus der Auswahl.", + "archivedDescription": "Archivierte Tags bleiben in deinen bisherigen Einträgen erhalten. Sie verschwinden nur aus der Auswahl.", "archivedEmpty": "Nichts archiviert.", "purgeTitle": "Tag endgültig löschen?", "purgeBody": "Der Tag wird aus {count} bisherigen Einträgen entfernt. Das lässt sich nicht rückgängig machen.", @@ -1032,7 +1069,7 @@ }, "sectionMoreTags": "Weitere Tags", "sectionNoteDetails": "Notiz & Details", - "quickTagsHelp": "Häufig genutzte Tags — ein Tipp genügt", + "quickTagsHelp": "Häufig genutzte Tags: ein Tipp genügt", "quickTagsExpand": "Weitere", "loadError": "Stimmungseinträge konnten nicht geladen werden.", "sectionLevelA": "Gefühlslage", @@ -1210,8 +1247,8 @@ "dialogDescription": "Diese Medikamente sind jetzt fällig. Bestätige, um sie alle als genommen zu erfassen.", "confirm": "Alle einnehmen ({count})", "successToast": "{count} Einnahmen erfasst", - "partialToast": "{taken} erfasst, {failed} fehlgeschlagen — bitte erneut versuchen", - "failedToast": "Keine Einnahme erfasst — bitte erneut versuchen" + "partialToast": "{taken} erfasst, {failed} fehlgeschlagen. Bitte erneut versuchen", + "failedToast": "Keine Einnahme erfasst. Bitte erneut versuchen" }, "logIntake": { "dialogTitle": "Einnahme erfassen", @@ -1300,7 +1337,7 @@ "complianceInfoLabel": "Was bedeutet Therapietreue?", "lastIntake": "Letzte Einnahme:", "nextIntake": "Nächste Einnahme:", - "nextIntakeOverdue": "Überfällig · {time} — noch nachholbar", + "nextIntakeOverdue": "Überfällig seit {time}, noch nachholbar", "dayStreak": "Tage Serie", "dayStreakOne": "Tag Serie", "cycleDueToday": "Heute fällig", @@ -1309,10 +1346,10 @@ "skipped": "Übersprungen", "intakeToastTaken": "{name} als genommen erfasst", "intakeToastSkipped": "{name} übersprungen", - "intakeToastFailed": "{name} konnte nicht erfasst werden — bitte erneut versuchen", + "intakeToastFailed": "{name} konnte nicht erfasst werden. Bitte erneut versuchen", "intakeUndo": "Rückgängig", "intakeUndone": "Eintrag zurückgenommen", - "intakeUndoFailed": "Rückgängig fehlgeschlagen — im Verlauf korrigieren", + "intakeUndoFailed": "Rückgängig fehlgeschlagen. Im Verlauf korrigieren", "glp1LastInjection": "{label}", "glp1NextInjectionDays": "{label} (in {days} Tagen)", "glp1NextInjectionToday": "heute", @@ -1380,7 +1417,7 @@ "scheduling": { "naturalLanguage": { "title": "Medikament beschreiben", - "description": "Beschreibe das Medikament in eigenen Worten — das Formular wird automatisch vorausgefüllt.", + "description": "Beschreibe das Medikament in eigenen Worten, das Formular wird automatisch vorausgefüllt.", "placeholder": "z. B. Mounjaro 5 mg wöchentlich am Mittwochmorgen ab nächstem Montag", "submit": "Extrahieren", "submitBusy": "Wird verarbeitet…", @@ -1394,10 +1431,10 @@ "error": { "empty": "Gib zuerst eine Beschreibung ein.", "tooLong": "Beschreibung muss unter 2000 Zeichen bleiben.", - "rateLimit": "Zu viele Anfragen — bitte kurz warten und erneut versuchen.", - "noProvider": "Kein KI-Anbieter konfiguriert — bitte in den Einstellungen einrichten.", - "budget": "Tägliches KI-Budget erreicht — bitte morgen erneut versuchen.", - "network": "KI-Anbieter nicht erreichbar — bitte erneut versuchen.", + "rateLimit": "Zu viele Anfragen. Bitte kurz warten und erneut versuchen.", + "noProvider": "Kein KI-Anbieter konfiguriert. Bitte in den Einstellungen einrichten.", + "budget": "Tägliches KI-Budget erreicht. Bitte morgen erneut versuchen.", + "network": "KI-Anbieter nicht erreichbar. Bitte erneut versuchen.", "consent": "Für das Lesen deiner Beschreibung mit KI brauchst du zuerst deine Einwilligung.", "unavailable": "Das Lesen von Beschreibungen mit KI ist hier gerade nicht verfügbar." } @@ -1413,7 +1450,7 @@ "everyNMonths": "Alle N Monate an einem bestimmten Tag", "yearly": "Einmal im Jahr", "rolling": "Alle N Tage seit der letzten Einnahme (flexibel)", - "rollingExplainer": "Zählt ab der letzten erfassten Einnahme — pausiert, wenn du eine Dosis auslässt.", + "rollingExplainer": "Zählt ab der letzten erfassten Einnahme und pausiert, wenn du eine Dosis auslässt.", "oneShot": "Einmaldosis" }, "weekdays": { @@ -1471,7 +1508,7 @@ "night": "Nachts" }, "max": { - "reached": "Maximum erreicht — bitte zuerst eine Uhrzeit entfernen." + "reached": "Maximum erreicht. Bitte zuerst eine Uhrzeit entfernen." } }, "courseWindow": { @@ -1521,7 +1558,7 @@ }, "step3": { "title": "Wie hoch ist die Dosis?", - "subline": "Eine Tablette, ein Hub, ein Tropfen — je nach Form.", + "subline": "Eine Tablette, ein Hub, ein Tropfen, je nach Form.", "amountLabel": "Menge", "amountPlaceholder": "z. B. 5", "unitLabel": "Einheit", @@ -1547,10 +1584,10 @@ }, "dosesPerUnitLabel": "Dosen pro Einheit", "dosesPerUnitPlaceholder": "z. B. 4", - "dosesPerUnitHint": "Dosen pro Pen / Ampulle — leer lassen, wenn kein Bestand getrackt wird.", + "dosesPerUnitHint": "Dosen pro Pen / Ampulle. Leer lassen, wenn kein Bestand getrackt wird.", "short": "Dosis", "unitsPerDoseLabel": "Einheiten pro Dosis", - "unitsPerDoseHint": "z. B. 2 Tabletten à 2 mg für eine 4-mg-Dosis — oder ½ Tablette bei geteilter Dosis" + "unitsPerDoseHint": "z. B. 2 Tabletten à 2 mg für eine 4-mg-Dosis oder ½ Tablette bei geteilter Dosis" }, "step4": { "title": "Über welchen Zeitraum nimmst du es?", @@ -1559,7 +1596,7 @@ }, "step5": { "title": "Wie oft nimmst du es?", - "subline": "Wähle die Regelmäßigkeit — die Details kommen im nächsten Schritt.", + "subline": "Wähle die Regelmäßigkeit, die Details kommen im nächsten Schritt.", "short": "Takt" }, "step6": { @@ -1581,7 +1618,7 @@ }, "step7": { "title": "Wann nimmst du es?", - "subline": "Eine oder mehrere Uhrzeiten — die Erinnerungen richten sich danach.", + "subline": "Eine oder mehrere Uhrzeiten. Die Erinnerungen richten sich danach.", "presetsLabel": "Tagespresets", "presets": { "morning": "Morgens", @@ -1589,7 +1626,7 @@ "evening": "Abends", "night": "Nachts" }, - "overrideHint": "Optional — für diese Uhrzeit eine abweichende Dosis oder Stückzahl festlegen. Leer lassen, um den Standard des Medikaments zu verwenden.", + "overrideHint": "Optional: für diese Uhrzeit eine abweichende Dosis oder Stückzahl festlegen. Leer lassen, um den Standard des Medikaments zu verwenden.", "doseOverrideLabel": "Dosis für diese Uhrzeit (optional)", "doseOverridePlaceholder": "z. B. eine halbe Tablette", "unitsOverrideLabel": "Verbrauchte Einheiten zu dieser Uhrzeit", @@ -1597,11 +1634,11 @@ "short": "Zeiten" }, "step8": { - "title": "Fertig — soll HealthLog dich erinnern?", + "title": "Fertig! Soll HealthLog dich erinnern?", "subline": "Du kannst alles unten nochmal prüfen, bevor du speicherst.", "remindersLabel": "Erinnerungen aktiv", - "remindersDescription": "Push, Telegram oder ntfy — je nach deinen Einstellungen.", - "noChannelHint": "Erinnerungen brauchen einen Benachrichtigungskanal — aktuell ist keiner aktiv.", + "remindersDescription": "Push, Telegram oder ntfy, je nach deinen Einstellungen.", + "noChannelHint": "Erinnerungen brauchen einen Benachrichtigungskanal, aktuell ist keiner aktiv.", "noChannelHintLink": "Benachrichtigungen einrichten", "short": "Fertig" } @@ -1642,7 +1679,7 @@ }, "rolling": { "label": "Flexibel ab letzter Einnahme", - "description": "Du lässt den nächsten Termin offen, drückst 'genommen' wenn du sie genommen hast — ab dem Zeitpunkt zählt der Counter wieder." + "description": "Du lässt den nächsten Termin offen, drückst 'genommen', wenn du sie genommen hast. Ab dem Zeitpunkt zählt der Counter wieder." }, "oneShot": { "label": "Einmalig", @@ -1650,7 +1687,7 @@ }, "asNeeded": { "label": "Bei Bedarf", - "description": "Kein fester Plan — du erfasst eine Einnahme, wenn du sie brauchst (z. B. Schmerzmittel)." + "description": "Kein fester Plan: Du erfasst eine Einnahme, wenn du sie brauchst (z. B. Schmerzmittel)." } }, "summary": { @@ -1685,7 +1722,7 @@ } }, "errors": { - "submitFailed": "Medikament konnte nicht gespeichert werden — bitte erneut versuchen." + "submitFailed": "Medikament konnte nicht gespeichert werden. Bitte erneut versuchen." } }, "doseStrength": { @@ -1701,11 +1738,11 @@ "empty": "Noch keine Dosis-Steigerungsschritte erfasst. Datierte Dosisänderungen hinzufügen, um den Plan abzubilden.", "youAreHere": "Du bist hier", "doseStep": "{dose} {unit}", - "plannedFor": "Geplant · {date}" + "plannedFor": "Geplant für {date}" }, "injectionSiteBodyOutlineAriaLabel": "Körperumriss", "injectionSitePickerAriaLabel": "Injektionsstelle auswählen", - "injectionSiteRecommendedAriaLabel": "{site} — empfohlene nächste Stelle", + "injectionSiteRecommendedAriaLabel": "{site}: empfohlene nächste Stelle", "injectionSiteLegendAriaLabel": "Legende der Markierungen", "injectionSiteLegendRecommended": "Empfohlene nächste Stelle", "injectionSiteLegendLastUsed": "Zuletzt verwendete Stelle", @@ -1752,7 +1789,7 @@ "intakeHistoryStatusSkipped": "Übersprungen", "intakeHistorySortByTaken": "Nach Einnahme-Zeitpunkt sortieren", "intakeHistorySortByScheduled": "Nach geplantem Zeitpunkt sortieren", - "intakeHistoryPageInfo": "Seite {page} von {total} · {count} Einträge", + "intakeHistoryPageInfo": "Seite {page} von {total}, {count} Einträge", "intakeHistoryPrev": "Zurück", "intakeHistoryNext": "Weiter", "glp1": { @@ -1793,7 +1830,7 @@ "cadence": { "oneShotOn": "Einmalige Dosis am {date}.", "oneShotPending": "Einmalige Dosis ausstehend.", - "asNeeded": "Bei Bedarf — kein fester Plan." + "asNeeded": "Bei Bedarf, kein fester Plan." }, "intake": { "title": "Einnahmeverlauf", @@ -1820,7 +1857,7 @@ "dialogTitle": "Einnahme bearbeiten", "takenAtLabel": "Eingenommen um", "scheduledForHint": "Geplant: {dateTime}", - "farFromScheduledWarning": "Liegt weit vom geplanten Zeitpunkt entfernt — Datum prüfen.", + "farFromScheduledWarning": "Liegt weit vom geplanten Zeitpunkt entfernt. Datum prüfen.", "skippedLabel": "Übersprungen", "save": "Speichern", "cancel": "Abbrechen", @@ -1852,7 +1889,7 @@ "markAsTaken": "Als genommen markieren", "markAsSkipped": "Als übersprungen markieren", "adHocTag": "außerplanmäßig", - "takenDetail": "geplant {planned} · genommen {taken}", + "takenDetail": "geplant {planned}, genommen {taken}", "doseTaken": "Dosis: {dose}", "status": { "taken_on_time": "pünktlich", @@ -1864,10 +1901,10 @@ }, "addDialog": { "title": "Einnahme hinzufügen", - "description": "Dosis nachtragen — auch rückwirkend oder außerplanmäßig.", + "description": "Dosis nachtragen, auch rückwirkend oder außerplanmäßig.", "takenAtLabel": "Genommen um", "skippedLabel": "Übersprungen", - "nudgeHint": "Liegt nah am Slot {slot} — beim Speichern fragen wir, ob zugeordnet werden soll.", + "nudgeHint": "Liegt nah am Slot {slot}. Beim Speichern fragen wir, ob zugeordnet werden soll.", "cancel": "Abbrechen", "submit": "Speichern" }, @@ -1916,7 +1953,7 @@ }, "codes": { "label": "Klinische Codes (ATC / RxNorm)", - "note": "Optional. Werden beim FHIR-Export der Gesundheitsakte verwendet. Codes werden genau wie eingegeben übernommen — nie geraten.", + "note": "Optional. Werden beim FHIR-Export der Gesundheitsakte verwendet. Codes werden genau wie eingegeben übernommen, nie geraten.", "atcLabel": "ATC", "rxNormLabel": "RxNorm", "saved": "Klinische Codes gespeichert", @@ -1936,7 +1973,7 @@ "title": "Medikament beenden", "helper": "Erinnerungen stoppen und das Medikament als beendet markieren. Verlauf bleibt sichtbar.", "button": "Beenden", - "dialogTitle": "{name} beenden — Erinnerungen stoppen, Verlauf bleibt sichtbar", + "dialogTitle": "{name} beenden: Erinnerungen stoppen, Verlauf bleibt sichtbar", "dialogBody": "Keine weiteren Erinnerungen. Alte Einträge bleiben in der Übersicht.", "toast": "Medikament beendet", "failed": "Medikament konnte nicht beendet werden." @@ -1974,7 +2011,7 @@ "copyFailed": "Kopieren fehlgeschlagen.", "tokenCopied": "Token kopiert", "copyToken": "Token kopieren", - "mintedHint": "Token jetzt kopieren — es wird nur einmal angezeigt." + "mintedHint": "Token jetzt kopieren, es wird nur einmal angezeigt." }, "advanced": { "dataPortability": { @@ -2003,7 +2040,6 @@ "bestand": "Bestand", "verlauf": "Verlauf", "injektion": "Injektion", - "api": "API", "erweitert": "Erweitert", "wirkung": "Wirkung" }, @@ -2015,9 +2051,9 @@ "statusTitle": "Status", "nextDoseLabel": "Nächste Dosis", "nextDoseNone": "Keine anstehende Dosis.", - "nextDoseAsNeeded": "Bei Bedarf — keine geplante Dosis.", + "nextDoseAsNeeded": "Bei Bedarf, keine geplante Dosis.", "nextDoseNotTracked": "Nicht erfasst, nur dokumentiert.", - "pausedHint": "Pausiert — keine Erinnerungen.", + "pausedHint": "Pausiert, keine Erinnerungen.", "reminderLabel": "Erinnerung", "reminderOn": "Aktiv", "reminderOff": "Aus", @@ -2088,7 +2124,7 @@ "packagingDefaultPack": "Standard-Packungsgröße: {units} Einheiten", "packagingNoDefaultPack": "Keine Standard-Packungsgröße hinterlegt", "packagingEdit": "Bearbeiten", - "packagingDescription": "Lege fest, wie viele Einheiten eine Dosis verbraucht und wie groß eine neue Packung üblicherweise ist — z. B. nach einem Herstellerwechsel mit anderer Blistergröße.", + "packagingDescription": "Lege fest, wie viele Einheiten eine Dosis verbraucht und wie groß eine neue Packung üblicherweise ist, z. B. nach einem Herstellerwechsel mit anderer Blistergröße.", "packagingUnitsPerDoseLabel": "Einheiten pro Dosis", "packagingUnitsPerDoseHelper": "Z. B. 2 Tabletten à 2 mg für eine 4-mg-Dosis oder 1,5 für anderthalb (über 0, bis 100). Bereits erfasste Einnahmen bleiben unverändert.", "packagingDefaultPackLabel": "Standard-Packungsgröße (Einheiten)", @@ -2139,11 +2175,11 @@ "hideEras": "Frühere Ären ausblenden", "addEra": "Frühere Ära ergänzen", "addEraTitle": "Frühere Ära ergänzen", - "addEraDescription": "Halte fest, zu welchen Uhrzeiten dieses Medikament früher eingenommen wurde — etwa bevor du es hier angepasst hast.", + "addEraDescription": "Halte fest, zu welchen Uhrzeiten dieses Medikament früher eingenommen wurde, etwa bevor du es hier angepasst hast.", "editAria": "Ära bearbeiten", "editEraTitle": "Ära bearbeiten", "editEraDescription": "Passe die Einnahmezeiten und den Zeitraum dieser Ära an.", - "archivedEditHint": "Diese Ära wurde automatisch aufgezeichnet — deine Änderung korrigiert die aufgezeichnete Historie; das Original bleibt intern als Beleg erhalten.", + "archivedEditHint": "Diese Ära wurde automatisch aufgezeichnet. Deine Änderung korrigiert die aufgezeichnete Historie; das Original bleibt intern als Beleg erhalten.", "updatedToast": "Ära aktualisiert", "updateError": "Die Ära konnte nicht aktualisiert werden. Sie darf sich nicht mit anderen Ären überschneiden und muss vor dem aktuellen Plan enden.", "timesLabel": "Einnahmezeiten", @@ -2169,7 +2205,7 @@ "saved": "Zeitplan gespeichert", "failed": "Zeitplan konnte nicht gespeichert werden", "window": { - "intro": "Lege je Dosis fest, wann eine Einnahme als pünktlich gilt — ein genauer Zeitpunkt (±1 Std.) oder ein eigenes Fenster wie 07:00–09:00.", + "intro": "Lege je Dosis fest, wann eine Einnahme als pünktlich gilt: ein genauer Zeitpunkt (±1 Std.) oder ein eigenes Fenster wie 07:00–09:00.", "explicitToggle": "Eigenes Fenster", "startLabel": "Fensterbeginn", "endLabel": "Fensterende", @@ -2182,10 +2218,10 @@ } }, "logInjectionSiteTitle": "Injektionsstelle erfassen", - "logInjectionSiteDescription": "Wo hast du {name} gespritzt? Optional — du kannst dies überspringen.", + "logInjectionSiteDescription": "Wo hast du {name} gespritzt? Optional, du kannst dies überspringen.", "logInjectionSiteSkip": "Überspringen", "logInjectionSiteConfirm": "Stelle speichern", - "logInjectionSiteNoneAvailable": "Keine Injektionsstelle verfügbar — alle Stellen sind in deinen Einstellungen ausgeschlossen.", + "logInjectionSiteNoneAvailable": "Keine Injektionsstelle verfügbar. Alle Stellen sind in deinen Einstellungen ausgeschlossen.", "logInjectionSiteSaveFailed": "Injektionsstelle konnte nicht gespeichert werden. Bitte versuche es erneut.", "perSlotUnits": "{units} pro Dosis", "trackInjectionSitesToggle": "Injektionsstellen erfassen", @@ -2210,16 +2246,15 @@ "tableStockDoses": "{count} Dosen", "tableStockRunway": "≈ {days} Tage übrig", "cardLowStockRunway": "Vorrat: ≈ {days} Tage", - "takeActionFor": "Einnehmen — {name}", - "skipActionFor": "Überspringen — {name}", - "reorderTitle": "Reihenfolge anpassen", - "reorderDescription": "Bestimme die Reihenfolge deiner Medikamente — sie gilt für die Karten- und die Tabellenansicht.", + "takeActionFor": "Einnehmen: {name}", + "skipActionFor": "Überspringen: {name}", + "reorderDescription": "Bestimme die Reihenfolge deiner Medikamente für die Karten- und die Tabellenansicht.", "reorderSaved": "Reihenfolge gespeichert", "reorderSaveFailed": "Reihenfolge konnte nicht gespeichert werden", "customize": "Medikamente anpassen", "efficacy": { "title": "Wirkung", - "intro": "Wie sich die Messwerte, die dieses Medikament beeinflussen soll, rund um den Beginn der Einnahme entwickelt haben. Es zeigt den zeitlichen Zusammenhang — eine Assoziation, keine Ursache — und keine Empfehlung zu Behandlung oder Dosis.", + "intro": "Wie sich die Messwerte, die dieses Medikament beeinflussen soll, rund um den Beginn der Einnahme entwickelt haben. Es zeigt den zeitlichen Zusammenhang (eine Assoziation, keine Ursache) und keine Empfehlung zu Behandlung oder Dosis.", "notEligible": "Für dieses Medikament gibt es keinen zugehörigen Messwert.", "startFallback": "Es ist kein Startdatum hinterlegt, daher dient der erste erfasste Wert als Bezugspunkt.", "noStart": "Hinterlege ein Startdatum, um die Werte vor und nach Beginn zu vergleichen.", @@ -2232,7 +2267,7 @@ "levelShift": "Um {date} wurde eine Verschiebung des Niveaus erkannt.", "plateau": { "title": "Gewichts-Plateau auf aktueller Dosis", - "evidence": "Gewichtsveränderung von {delta} {unit} in den letzten {window} Tagen ({readings} Messungen) — seit {days} Tagen auf {dose}." + "evidence": "Gewichtsveränderung von {delta} {unit} in den letzten {window} Tagen ({readings} Messungen), seit {days} Tagen auf {dose}." }, "retarget": { "label": "Nicht der richtige Messwert?", @@ -2242,16 +2277,16 @@ "error": "Diese Änderung konnte nicht gespeichert werden. Bitte erneut versuchen." }, "beforeAfter": { - "summary": "Ø {before} davor → Ø {after} seitdem — eine Veränderung von {delta}.", + "summary": "Ø {before} davor → Ø {after} seitdem, eine Veränderung von {delta}.", "counts": "Basierend auf {beforeCount} Werten davor und {afterCount} seitdem.", - "needBefore": "Noch zu wenige Werte vor dem Beginn — für einen Vergleich werden etwa {weeks} Wochen davor und danach benötigt.", - "needAfter": "Noch zu wenige Werte seit dem Beginn — für einen Vergleich werden etwa {weeks} Wochen davor und danach benötigt.", + "needBefore": "Noch zu wenige Werte vor dem Beginn. Für einen Vergleich werden etwa {weeks} Wochen davor und danach benötigt.", + "needAfter": "Noch zu wenige Werte seit dem Beginn. Für einen Vergleich werden etwa {weeks} Wochen davor und danach benötigt.", "needStart": "Hinterlege ein Startdatum, um die Werte vor und nach Beginn zu vergleichen.", - "needData": "Noch zu wenige Werte — für einen Vergleich werden etwa {weeks} Wochen vor und nach dem Beginn benötigt." + "needData": "Noch zu wenige Werte. Für einen Vergleich werden etwa {weeks} Wochen vor und nach dem Beginn benötigt." }, "insights": { "title": "Medikamenten-Wirkung", - "subtitle": "Was mit den Zielwerten deiner Medikamente zeitlich zusammenfällt — nur ein Zusammenhang, kein Urteil.", + "subtitle": "Was mit den Zielwerten deiner Medikamente zeitlich zusammenfällt: nur ein Zusammenhang, kein Urteil.", "link": "Wirkung öffnen", "empty": "Noch nicht genug Daten, um deine Medikamente mit ihren Zielwerten in Beziehung zu setzen." }, @@ -2285,9 +2320,9 @@ "reasonMissingMedication": "die Zeile nennt kein Medikament", "reasonUnreadableDosage": "die Dosis ist keine Zahl", "reasonUnreadableRow": "die Zeile hat weniger Spalten als die Kopfzeile", - "reasonStatusNoDoseInformation": "die Datei hält zu dieser Dosis nichts fest — niemand hat reagiert und nichts wurde eingetragen", + "reasonStatusNoDoseInformation": "die Datei hält zu dieser Dosis nichts fest; niemand hat reagiert und nichts wurde eingetragen", "reasonStatusReminderEvent": "die Zeile betrifft eine verschobene Erinnerung und sagt nichts über die Dosis", - "reasonStatusNotificationNotSent": "die Zeile hält fest, dass keine Benachrichtigung gesendet wurde — das betrifft die App, nicht die Dosis" + "reasonStatusNotificationNotSent": "die Zeile hält fest, dass keine Benachrichtigung gesendet wurde; das betrifft die App, nicht die Dosis" }, "unitsPerDoseField": { "other": "Andere…", @@ -2357,7 +2392,7 @@ }, "charts": { "loadingLabel": "Diagramm wird geladen…", - "loadingSlowHint": "Auswertungen werden berechnet — das kann einen Moment dauern.", + "loadingSlowHint": "Auswertungen werden berechnet. Das kann einen Moment dauern.", "weight": "Gewicht", "bloodPressure": "Blutdruck", "pulse": "Puls", @@ -2379,6 +2414,7 @@ "days7Label": "7T", "days7Title": "Letzte 7 Tage", "days30Label": "30T", + "daysNLabel": "{days}T", "days30Title": "Letzte 30 Tage", "days90Label": "90T", "days90Title": "Letzte 90 Tage", @@ -2443,9 +2479,9 @@ "toggle": "Datenpunkte ({count})", "columnDate": "Datum", "noValue": "Kein Wert", - "captionDay": "Eine Zeile pro Tag für {metric} — genau die Punkte, die das Diagramm zeichnet.", - "captionWeek": "Eine Zeile pro Woche für {metric}, der Mittelwert der Tage dieser Woche — genau die Punkte, die das Diagramm zeichnet.", - "captionMonth": "Eine Zeile pro Monat für {metric}, der Mittelwert der Tage dieses Monats — genau die Punkte, die das Diagramm zeichnet.", + "captionDay": "Eine Zeile pro Tag für {metric}, genau die Punkte, die das Diagramm zeichnet.", + "captionWeek": "Eine Zeile pro Woche für {metric}, der Mittelwert der Tage dieser Woche, genau die Punkte, die das Diagramm zeichnet.", + "captionMonth": "Eine Zeile pro Monat für {metric}, der Mittelwert der Tage dieses Monats, genau die Punkte, die das Diagramm zeichnet.", "hourlyMeanNote": "{metric} ist der Tagesdurchschnitt, bei dem jede Stunde einmal zählt, damit ein Training nicht den Rest des Tages überwiegt." }, "personalBaseline": "Dein Mittel", @@ -2474,7 +2510,8 @@ "moodHeatmapEmpty": "Stimmungs-Heatmap, noch keine Tage erfasst.", "complianceHeatmap": "Heatmap zur Medikamenteneinnahme von {start} bis {end}, {days} aktive Tage.", "complianceHeatmapEmpty": "Heatmap zur Medikamenteneinnahme, noch keine aktiven Tage." - } + }, + "medicationsEmptyAction": "Medikament anlegen" }, "chart": { "overlay": { @@ -2505,8 +2542,8 @@ }, "tooltipPrior": "Vorperiode", "unavailable": { - "lastMonth": "Vergleich nicht verfügbar — noch keine Daten aus dem Vormonat", - "lastYear": "Vergleich nicht verfügbar — noch keine Daten aus dem Vorjahr" + "lastMonth": "Vergleich nicht verfügbar: noch keine Daten aus dem Vormonat", + "lastYear": "Vergleich nicht verfügbar: noch keine Daten aus dem Vorjahr" }, "notAvailableForCompliance": "Vergleich für Therapietreue nicht verfügbar" }, @@ -2518,7 +2555,9 @@ "dismissError": "Dieses Muster konnte nicht ausgeblendet werden.", "restoreError": "Dieses Muster konnte nicht wiederhergestellt werden.", "loadError": "Muster konnten nicht geladen werden.", - "empty": "Kein aktuelles Muster hat die Evidenzschwelle erreicht." + "empty": "Kein aktuelles Muster hat die Evidenzschwelle erreicht.", + "seasonalNote": "Seit Version 1.42 rechnet HealthLog Jahreszeit und Langzeittrend heraus, bevor es Werte vergleicht. Du siehst dadurch weniger Zusammenhänge, aber die verbliebenen halten besser. Viele der früheren Muster waren der Kalender.", + "seasonalNoteDismiss": "Hinweis schließen" }, "correlation": { "direction": { @@ -2550,29 +2589,37 @@ "ENV_DAYLIGHT": "das Tageslicht", "ENV_PRECIP": "der Niederschlag", "ENV_PRESSURE_MEAN": "der Luftdruck", - "ENV_PRESSURE_DELTA": "die Luftdruckschwankung am Tag" + "ENV_PRESSURE_DELTA": "die Luftdruckschwankung am Tag", + "ENV_PM25": "der Feinstaub (PM2,5)", + "ENV_OZONE_8H": "das Ozon", + "ENV_POLLEN_MAX": "der Pollenflug" }, "daily": { - "high": "Wenn {behaviour} höher liegt, fällt {outcome} am Folgetag in deinen Daten tendenziell {direction} aus — ein Muster, das Beachtung verdient, keine Ursache.", - "moderate": "Wenn {behaviour} höher liegt, scheint {outcome} am Folgetag nach bisheriger Datenlage eher {direction} auszufallen — ein Muster, das Beachtung verdient, keine Ursache.", - "faint": "Wenn {behaviour} höher liegt, gibt es in deinen Daten allenfalls einen schwachen Hinweis darauf, dass {outcome} am Folgetag {direction} ausfällt — zu gering, um sich darauf zu verlassen, niemals eine Ursache." + "high": "Wenn {behaviour} höher liegt, fällt {outcome} am Folgetag in deinen Daten tendenziell {direction} aus. Das ist ein Muster, das Beachtung verdient, keine Ursache.", + "moderate": "Wenn {behaviour} höher liegt, scheint {outcome} am Folgetag nach bisheriger Datenlage eher {direction} auszufallen. Das ist ein Muster, das Beachtung verdient, keine Ursache.", + "faint": "Wenn {behaviour} höher liegt, gibt es in deinen Daten allenfalls einen schwachen Hinweis darauf, dass {outcome} am Folgetag {direction} ausfällt. Das ist zu gering, um sich darauf zu verlassen, und niemals eine Ursache." }, "lab": { - "default": "Höhere {lab}-Werte gehen in deinen Daten damit einher, dass {outcome} im selben Zeitraum {direction} ausfällt — ein Zusammenhang, den du ärztlich im Blick behalten solltest, niemals eine Ursache.", - "faint": "Höhere {lab}-Werte deuten in deinen Daten allenfalls schwach darauf hin, dass {outcome} im selben Zeitraum {direction} ausfällt — zu gering, um sich darauf zu verlassen, ein Zusammenhang, den du ärztlich abklären lassen solltest, niemals eine Ursache." + "default": "Höhere {lab}-Werte gehen in deinen Daten damit einher, dass {outcome} im selben Zeitraum {direction} ausfällt, ein Zusammenhang, den du ärztlich im Blick behalten solltest, niemals eine Ursache.", + "faint": "Höhere {lab}-Werte deuten in deinen Daten allenfalls schwach darauf hin, dass {outcome} im selben Zeitraum {direction} ausfällt. Das ist zu gering, um sich darauf zu verlassen, und niemals eine Ursache, aber ärztlich abklärenswert." }, "hypothesis": { "bpCompliance": { - "support": "Höhere Medikamententreue geht mit niedrigeren systolischen Werten einher — ein Muster, das Beachtung verdient.", - "surprising": "Medikamententreue und systolischer Wert bewegen sich in deinen Daten gemeinsam — überraschend; sprich mit deiner Ärztin oder deinem Arzt, bevor du etwas änderst.", + "support": "Höhere Medikamententreue geht mit niedrigeren systolischen Werten einher, ein Muster, das Beachtung verdient.", + "surprising": "Medikamententreue und systolischer Wert bewegen sich in deinen Daten gemeinsam, das ist überraschend. Sprich mit deiner Ärztin oder deinem Arzt, bevor du etwas änderst.", "none": "Medikamententreue und systolischer Wert bewegen sich in diesem Zeitraum nicht deutlich gemeinsam." }, "moodPulse": { - "support": "Tage mit schlechterer Stimmung gehen mit höherem Ruhepuls einher — ein Muster, das Beachtung verdient.", - "surprising": "Tage mit besserer Stimmung gehen in deinen Daten mit höherem Ruhepuls einher — überraschend.", + "support": "Tage mit schlechterer Stimmung gehen mit höherem Ruhepuls einher, ein Muster, das Beachtung verdient.", + "surprising": "Tage mit besserer Stimmung gehen in deinen Daten mit höherem Ruhepuls einher, das ist überraschend.", "none": "Stimmung und Ruhepuls bewegen sich in diesem Zeitraum nicht deutlich gemeinsam." }, - "weightWeekday": "Das Gewicht am {weekday} liegt {delta} kg {direction} deinem Durchschnitt der übrigen Tage — ein Muster, das Beachtung verdient." + "weightWeekday": "Das Gewicht am {weekday} liegt {delta} kg {direction} deinem Durchschnitt der übrigen Tage, ein Muster, das Beachtung verdient." + }, + "dailySameDay": { + "high": "Mehr {behaviour} am selben Tag und am Vortag geht in deinen Daten tendenziell mit {direction} {outcome} einher. Das ist ein Muster, das sich zu beobachten lohnt, keine Ursache.", + "moderate": "Mehr {behaviour} am selben Tag und am Vortag scheint in deinen Daten nach bisheriger Lage mit {direction} {outcome} einherzugehen. Das ist ein Muster, das sich zu beobachten lohnt, keine Ursache.", + "faint": "Mehr {behaviour} am selben Tag und am Vortag zeigt in deinen Daten allenfalls einen schwachen Hinweis auf {direction} {outcome}. Das ist zu klein, um sich darauf zu stützen, und nie eine Ursache." } }, "emptyTitle": "Noch nicht genug Daten", @@ -2619,7 +2666,7 @@ "narrativeWeek": "Diese Woche", "narrativeMonth": "Dieser Monat", "narrativeUpdating": "Wird aktualisiert…", - "heroFallbackSubtitle": "Täglicher Blick auf deine Trends — direkt aus den Zahlen, die du protokollierst.", + "heroFallbackSubtitle": "Täglicher Blick auf deine Trends, direkt aus den Zahlen, die du protokollierst.", "heroGreetingMorning": "Guten Morgen", "heroGreetingAfternoon": "Guten Tag", "heroGreetingEvening": "Guten Abend", @@ -2684,7 +2731,7 @@ "referenceSleep": "Mindestens {low} Minuten pro Nacht; längerer Schlaf wird nicht abgewertet", "referenceAdiposity": "Taille-zu-Körpergröße-Verhältnis unter {high}", "referenceHba1c": "HbA1c {low} bis {high} %", - "referenceDetail": "{label} · {source} · {kind} · {low} bis {high}", + "referenceDetail": "{label}: {low} bis {high} ({kind}, {source})", "openBound": "offen", "laboratoryReferenceSource": "Referenzbereiche des auswertenden Labors", "personalTargetSource": "Persönliches Ziel", @@ -2719,7 +2766,8 @@ "coverage": { "recommendedMet": "Deckt die drei Felder ab, die der Score empfiehlt", "areas": "{present} von {required} Feldern der Gesundheit" - } + }, + "historyLink": "Verlauf" }, "dailyBriefing": { "title": "Tagesbriefing", @@ -2729,7 +2777,7 @@ "emptyDescription": "Erstelle das heutige Briefing, um eine Zusammenfassung deiner Trends zu bekommen.", "emptyAction": "Briefing erstellen", "loadingLabel": "Briefing wird zusammengestellt", - "refreshFailedHint": "Briefing konnte nicht aktualisiert werden — es wird das letzte angezeigt.", + "refreshFailedHint": "Briefing konnte nicht aktualisiert werden. Es wird das letzte angezeigt.", "retryAction": "Erneut versuchen", "failedTitle": "Briefing konnte nicht erstellt werden", "failedDescription": "Der KI-Anbieter hat das Zeitlimit überschritten oder einen Fehler zurückgegeben. Versuche es erneut oder erhöhe in den KI-Einstellungen das Antwort-Zeitlimit für ein langsames lokales Modell.", @@ -2778,7 +2826,7 @@ "experimentCta": "7-Tage-Experiment starten", "experimentTooltip": "Bald verfügbar", "emptyTitle": "Mehr Daten nötig, um diesen Zusammenhang zu zeigen", - "sourceChip": "basierend auf {n} gepaarten Messungen · {window}", + "sourceChip": "basierend auf {n} gepaarten Messungen ({window})", "sourceWindowLast30": "letzte 30 Tage", "confidenceHigh": "Hohe Konfidenz", "confidenceModerate": "Mittlere Konfidenz", @@ -2793,14 +2841,14 @@ } }, "coach": { - "refusalOutOfScope": "Ich kann nur bei den Gesundheitsdaten in deinem Log helfen — Messwerte, Medikamente, Stimmung, Trends. Fragen außerhalb dieses Themas beantworte ich nicht.", - "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.", + "refusalOutOfScope": "Ich kann nur bei den Gesundheitsdaten in deinem Log helfen: Messwerte, Medikamente, Stimmung, Trends. Fragen außerhalb dieses Themas beantworte ich nicht.", + "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 (UTC) zurück.", "errorProvider": "Der Coach konnte gerade keinen Auswertungs-Anbieter erreichen. Bitte versuche es in einem Moment erneut.", "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.", + "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", - "errorNetwork": "Keine Internetverbindung — versuche es erneut, sobald du online bist.", + "errorNetwork": "Keine Internetverbindung. Versuche es erneut, sobald du online bist.", "prefsSaveError": "Coach-Einstellungen konnten nicht gespeichert werden. Bitte versuche es erneut.", "errorCredentialExpired": "Deine Verbindung zum KI-Anbieter ist abgelaufen. Stelle sie in den Einstellungen wieder her, damit der Coach weiterläuft.", "tagline": "Persönlicher Gesundheitscoach, auf deine Daten gestützt.", @@ -2819,7 +2867,7 @@ "thinking": "Denke nach…", "unverifiedFiguresNotice": "Einige Zahlen ließen sich nicht mit deinen Daten abgleichen und wurden weggelassen.", "composerPlaceholder": "Frag mich etwas…", - "nudgeBubbleLabel": "Neue Nachricht vom Coach — Chat öffnen", + "nudgeBubbleLabel": "Neue Nachricht vom Coach: Chat öffnen", "fabLabel": "Coach öffnen", "readAloud": "Vorlesen", "readAloudStop": "Vorlesen beenden", @@ -2843,7 +2891,7 @@ "guided": { "offerTitle": "Der Coach hat {count} Rückfragen an dich", "offerTitleOne": "Der Coach hat eine Rückfrage an dich", - "offerBody": "Kurze Antworten schärfen deine Selbstauskunft — eine Frage nach der anderen, direkt hier im Chat.", + "offerBody": "Kurze Antworten schärfen deine Selbstauskunft: eine Frage nach der anderen, direkt hier im Chat.", "offerStart": "Jetzt beantworten", "offerLater": "Später", "offerDismiss": "Nicht mehr fragen", @@ -2853,7 +2901,7 @@ "later": "Später", "dismissRemaining": "Nicht mehr fragen", "answerPlaceholder": "Deine Antwort…", - "summaryTitle": "Geschafft — danke für deine Antworten!", + "summaryTitle": "Geschafft, danke für deine Antworten!", "summaryAnswered": "{answered} von {total} beantwortet", "summaryAdopted": "{adopted} in die Selbstauskunft übernommen", "summaryAdoptedNone": "nichts Neues in die Selbstauskunft übernommen", @@ -2865,6 +2913,7 @@ "historyEmpty": "Deine Unterhaltungen erscheinen hier. Stell eine Frage, um zu starten.", "historyDeleteAria": "Unterhaltung löschen", "historyDeleted": "Unterhaltung gelöscht", + "historyDeleteFailed": "Die Unterhaltung konnte nicht gelöscht werden.", "rename": { "action": "Unterhaltung umbenennen", "input": "Titel der Unterhaltung", @@ -2934,7 +2983,7 @@ "settingsVerbosityBrief": "Kurz", "settingsVerbosityDefault": "Standard", "settingsVerbosityDetailed": "Ausführlich", - "settingsVerbosityConciseHint": "Der knappe Tonfall hält Antworten ohnehin kurz — die Ausführlichkeit ist dabei fest auf „Kurz“ gesetzt.", + "settingsVerbosityConciseHint": "Der knappe Tonfall hält Antworten ohnehin kurz, die Ausführlichkeit ist dabei fest auf „Kurz“ gesetzt.", "settingsSourcesSummary": "Der Coach sieht {count} von {total} Datenbereichen und blickt {window} zurück.", "settingsSourcesSummaryAll": "Der Coach sieht {count} von {total} Datenbereichen und deinen ganzen Verlauf.", "settingsSourcesPointerLink": "Coach-Einstellungen öffnen", @@ -2956,23 +3005,23 @@ "cluster": { "cardio": { "label": "Herz-Kreislauf", - "hint": "Blutdruck · Puls · HRV · Ruhe- & Geh-Herzfrequenz" + "hint": "Blutdruck, Puls, HRV, Ruhe- und Geh-Herzfrequenz" }, "body": { "label": "Körperzusammensetzung", - "hint": "Gewicht · Körperfett · Muskeln · BMI" + "hint": "Gewicht, Körperfett, Muskeln und BMI" }, "activity": { "label": "Aktivität", - "hint": "Schritte · Energie · Distanz · VO₂max" + "hint": "Schritte, Energie, Distanz und VO₂max" }, "workouts": { "label": "Workouts", - "hint": "Läufe · Radtouren · Einheiten" + "hint": "Läufe, Radtouren und Einheiten" }, "sleep": { "label": "Schlaf", - "hint": "Dauer · Phasen" + "hint": "Dauer und Phasen" }, "mood": { "label": "Stimmung", @@ -2984,15 +3033,15 @@ }, "medication": { "label": "Medikation", - "hint": "Therapietreue · GLP-1-Kontext" + "hint": "Therapietreue und GLP-1-Kontext" }, "mobility": { "label": "Mobilität & Gang", - "hint": "Stabilität · Asymmetrie · Gangbild" + "hint": "Stabilität, Asymmetrie und Gangbild" }, "environment": { "label": "Umgebung", - "hint": "Lärm · Tageslicht · Hauttemperatur" + "hint": "Lärm, Tageslicht und Hauttemperatur" } }, "settingsClustersLabel": "Daten, die der Coach sieht", @@ -3041,25 +3090,11 @@ "steadyLatestWithin": "Deine Werte liegen meist bei {low} {unit}; dein letzter Wert vom {date} lag mit {value} genau dort.", "steadyLatestAbove": "Deine Werte liegen meist bei {low} {unit}; dein letzter Wert vom {date} lag mit {value} darüber.", "steadyLatestBelow": "Deine Werte liegen meist bei {low} {unit}; dein letzter Wert vom {date} lag mit {value} darunter.", - "insufficient": "Lerne noch deinen üblichen Bereich — protokolliere noch ein paar Tage weiter.", + "insufficient": "Lerne noch deinen üblichen Bereich. Protokolliere noch ein paar Tage weiter.", "driver": "{note}" }, "scope": { - "prefix": "Der Coach ist schon bei {metric}", - "tap": "Zum Fragen tippen", - "question": "Erkläre mir diese Kennzahl — worauf sollte ich achten?" - }, - "seeded": { - "prefix": "Heute einen Blick wert: {signal}", - "signal": { - "readiness": "Bereitschaft", - "recovery": "Erholung" - }, - "question": { - "readiness": "Meine Bereitschaft ist heute auffällig — woran liegt das?", - "recovery": "Meine Erholung hat sich verändert — was steckt dahinter?" - }, - "dismiss": "Ausblenden" + "prefix": "Der Coach ist schon bei {metric}" }, "settings": "Einstellungen", "history": { @@ -3069,39 +3104,39 @@ "groupEarlier": "Früher" }, "seed": { - "bloodPressure": "Geh mit mir meinen Blutdruckverlauf der letzten 30 Tage durch — gibt es etwas, das ich im Auge behalten sollte?", + "bloodPressure": "Geh mit mir meinen Blutdruckverlauf der letzten 30 Tage durch. Gibt es etwas, das ich im Auge behalten sollte?", "weight": "Wie hat sich mein Gewicht zuletzt entwickelt, und ist die Richtung etwas, worauf ich reagieren sollte?", "bmi": "Was sagt mir mein BMI-Verlauf, und wie sollte ich ihn lesen?", - "pulse": "Geh mit mir meine Pulswerte durch — ist irgendetwas ungewöhnlich?", + "pulse": "Geh mit mir meine Pulswerte durch. Ist irgendetwas ungewöhnlich?", "restingHr": "Wie hat sich mein Ruhepuls entwickelt, und was sagt er über meine Fitness?", "hrv": "Was sagt mir meine Herzfrequenzvariabilität zuletzt über Erholung und Stress?", - "sleep": "Geh mit mir meinen letzten Schlaf durch — Dauer, Regelmäßigkeit und was sich zu ändern lohnt.", + "sleep": "Geh mit mir meinen letzten Schlaf durch: Dauer, Regelmäßigkeit und was sich zu ändern lohnt.", "mood": "Welche Muster siehst du in meiner Stimmung über die letzten Wochen?", "medications": "Wie sieht meine Therapietreue aus, und passt sie dazu, wie ich mich gefühlt habe?", "steps": "Wie aktiv war ich zuletzt gemessen an meinen Schritten, und wie ist das im Vergleich zu einem gesunden Richtwert?", "activeEnergy": "Was sagt mein Aktivenergie-Verlauf über mein Aktivitätsniveau zuletzt?", "cardioFitness": "Was bedeutet mein Cardio-Fitness-Verlauf (VO₂max), und wie bewege ich ihn in die richtige Richtung?", - "bloodGlucose": "Geh mit mir meine letzten Glukosewerte durch — liegen sie in einem gesunden Bereich?", + "bloodGlucose": "Geh mit mir meine letzten Glukosewerte durch. Liegen sie in einem gesunden Bereich?", "oxygenSaturation": "Was sagt mir mein Sauerstoffsättigungs-Verlauf (SpO₂)?", "respiratoryRate": "Was sagt mein Atemfrequenz-Verlauf über meine Gesundheit?", - "workouts": "Geh mit mir meine letzten Workouts durch — Belastung, Häufigkeit und wie ich mich dazwischen erhole.", + "workouts": "Geh mit mir meine letzten Workouts durch: Belastung, Häufigkeit und wie ich mich dazwischen erhole.", "recoveryPage": "Warum steht meine Erholung gerade dort, wo sie steht, und was treibt das?", - "briefing": "Geh mit mir das heutige Briefing durch — worauf sollte ich mich konzentrieren, und gibt es etwas, das einen Schritt wert ist?", + "briefing": "Geh mit mir das heutige Briefing durch. Worauf sollte ich mich konzentrieren, und gibt es etwas, das einen Schritt wert ist?", "healthScore": "Warum liegt mein Gesundheits-Score bei {score} von 100, und was würde ihn am stärksten bewegen?", - "periodWeek": "Geh mit mir durch, wie meine letzte Woche über meine Gesundheitswerte hinweg lief — was sticht heraus?", - "periodMonth": "Geh mit mir durch, wie mein letzter Monat über meine Gesundheitswerte hinweg lief — was sticht heraus?", + "periodWeek": "Geh mit mir durch, wie meine letzte Woche über meine Gesundheitswerte hinweg lief. Was sticht heraus?", + "periodMonth": "Geh mit mir durch, wie mein letzter Monat über meine Gesundheitswerte hinweg lief. Was sticht heraus?", "correlationBpCompliance": "Wie hängt meine Therapietreue mit meinem Blutdruck zusammen? Geh mit mir durch, was dieser Zusammenhang für mich bedeutet.", "correlationMoodPulse": "Gibt es einen echten Zusammenhang zwischen meiner Stimmung und meinem Puls? Hilf mir, diesen Zusammenhang zu lesen.", "correlationWeightWeekday": "Folgt mein Gewicht einem Wochenmuster? Was sagt mir dieser Wochentags-Zusammenhang?", - "recommendation": "Erzähl mir mehr zu dieser Empfehlung: „{text}“ — warum trifft sie auf mich zu, und was sollte ich tun?" + "recommendation": "Erzähl mir mehr zu dieser Empfehlung: „{text}“ Warum trifft sie auf mich zu, und was sollte ich tun?" }, - "assessmentPrompt": "Erkläre mir meine {metric}-Einschätzung — was bedeutet sie und was sollte ich tun?", + "assessmentPrompt": "Erkläre mir meine {metric}-Einschätzung. Was bedeutet sie und was sollte ich tun?", "attach": { "menuChooseFromDocuments": "Aus Dokumenten wählen", "menuUploadNew": "Neues Dokument hochladen", "trigger": "Dokumente anhängen", "pickerTitle": "Dokumente anhängen", - "pickerDescription": "Der Coach liest nur die angehängten Dokumente — nicht deine Gesundheitsdaten.", + "pickerDescription": "Der Coach liest nur die angehängten Dokumente, nicht deine Gesundheitsdaten.", "pickerSearchPlaceholder": "Dokumente durchsuchen…", "pickerEmpty": "Noch keine Dokumente in deinem Tresor.", "pickerNotIndexed": "Noch nicht indexiert", @@ -3109,13 +3144,13 @@ "pickerConfirm": "Anhängen ({count})", "pillRemove": "{title} entfernen", "indexing": "Wird indexiert…", - "indexingHint": "Ein Dokument wird noch indexiert — du kannst senden, sobald es bereit ist.", - "fencedHint": "Diese Unterhaltung läuft ohne Zugriff auf deine Gesundheitsdaten — der Coach liest nur die angehängten Dokumente.", + "indexingHint": "Ein Dokument wird noch indexiert. Du kannst senden, sobald es bereit ist.", + "fencedHint": "Diese Unterhaltung läuft ohne Zugriff auf deine Gesundheitsdaten. Der Coach liest nur die angehängten Dokumente.", "documentsAttachedOne": "1 Dokument angehängt", "documentsAttachedOther": "{count} Dokumente angehängt", "railBadge": "Dokument-Chat (abgeschottet)", "uploadError": "Upload fehlgeschlagen. Bitte versuche es erneut.", - "notFoundError": "Dieser Dokumentlink ist nicht mehr gültig — es wird ein normaler Chat gestartet." + "notFoundError": "Dieser Dokumentlink ist nicht mehr gültig. Es wird ein normaler Chat gestartet." }, "interrupted": { "note": "Diese Antwort wurde unterbrochen, bevor sie fertig war.", @@ -3180,9 +3215,9 @@ "cap": "Viele Schritte, fasst jetzt zusammen", "noProgress": "Keine neuen Daten mehr, fasst zusammen" }, - "thoughtProcessOne": "Denkprozess · {count} Schritt", - "thoughtProcessFew": "Denkprozess · {count} Schritte", - "thoughtProcessOther": "Denkprozess · {count} Schritte", + "thoughtProcessOne": "Denkprozess ({count} Schritt)", + "thoughtProcessFew": "Denkprozess ({count} Schritte)", + "thoughtProcessOther": "Denkprozess ({count} Schritte)", "awaitingAnswer": "Antwort nötig" }, "memory": { @@ -3222,7 +3257,8 @@ "capped": "(vom Admin begrenzt)", "disabled": "Der Admin hat gründliches Denken abgeschaltet." }, - "memoryLink": "Gedächtnis ({count})" + "memoryLink": "Gedächtnis ({count})", + "newConversation": "Neue Unterhaltung" }, "relativeJustNow": "gerade eben", "relativeMinutesAgoOne": "vor {count} Minute", @@ -3285,7 +3321,7 @@ }, "hrv": { "title": "HRV", - "description": "Herzratenvariabilität im Verlauf — dein Erholungssignal des autonomen Nervensystems, nächtlich von Apple Watch und kompatiblen Wearables erfasst.", + "description": "Herzratenvariabilität im Verlauf: dein Erholungssignal des autonomen Nervensystems, nächtlich von Apple Watch und kompatiblen Wearables erfasst.", "chartTitle": "Herzratenvariabilität", "emptyState": { "title": "Noch keine HRV-Werte", @@ -3294,17 +3330,17 @@ }, "restingHr": { "title": "Ruhepuls", - "description": "Die tägliche Grundlinie deines Herzschlags — unterschieden vom Spot-Puls. Apple Health liefert einen Wert pro Tag.", + "description": "Die tägliche Grundlinie deines Herzschlags, unterschieden vom Spot-Puls. Apple Health liefert einen Wert pro Tag.", "chartTitle": "Ruhepuls", "emptyState": { "title": "Noch kein Ruhepuls", "description": "Der Ruhepuls erscheint, sobald die Apple Watch oder ein kompatibles Withings-Gerät einen ruhigen Tag durchgetragen hat." }, - "coachPrefill": "Ich habe noch keinen Ruhepuls erfasst — warum unterscheidet sich der tägliche Ruhewert von einem punktuellen Puls, und wie verbessere ich ihn?" + "coachPrefill": "Ich habe noch keinen Ruhepuls erfasst. Warum unterscheidet sich der tägliche Ruhewert von einem punktuellen Puls, und wie verbessere ich ihn?" }, "oxygenSaturation": { "title": "Sauerstoffsättigung", - "description": "Blutsauerstoff (SpO₂) — klinischer Normalbereich 95–100 %; Zielzonen sind am Chart hinterlegt.", + "description": "Blutsauerstoff (SpO₂), klinischer Normalbereich 95–100 %; Zielzonen sind am Chart hinterlegt.", "chartTitle": "Sauerstoffsättigung", "emptyState": { "title": "Noch keine SpO₂-Werte", @@ -3317,13 +3353,13 @@ "chartTitle": "Körpertemperatur", "emptyState": { "title": "Noch keine Körpertemperatur", - "description": "Erfasse eine Körpertemperatur oder lass dein Wearable einen Wert synchronisieren — der Verlauf erscheint dann hier.", + "description": "Erfasse eine Körpertemperatur oder lass dein Wearable einen Wert synchronisieren. Der Verlauf erscheint dann hier.", "cta": "Körpertemperatur erfassen" } }, "steps": { "title": "Schritte", - "description": "Tägliche Schrittzahl — eine Zeile pro Tag, aus Apple Health oder einem verbundenen Aktivitätsgerät.", + "description": "Tägliche Schrittzahl, eine Zeile pro Tag, aus Apple Health oder einem verbundenen Aktivitätsgerät.", "chartTitle": "Schritte pro Tag", "emptyState": { "title": "Noch keine Schrittdaten", @@ -3332,7 +3368,7 @@ }, "activeEnergy": { "title": "Aktive Energie", - "description": "Tägliche aktive-Energie-Summe in Kilokalorien — eine Zeile pro Tag, aus Apple Health oder Withings-Aktivität.", + "description": "Tägliche aktive-Energie-Summe in Kilokalorien, eine Zeile pro Tag, aus Apple Health oder Withings-Aktivität.", "chartTitle": "Aktive Energie", "emptyState": { "title": "Noch keine Aktive-Energie-Werte", @@ -3352,11 +3388,11 @@ }, "explainer": { "bloodPressureBody": "Der Blutdruck ist die Kraft, die das Blut auf die Gefäßwände ausübt, angegeben als systolisch zu diastolisch (Spitzenwert beim Herzschlag über dem Ruhewert dazwischen). Dauerhaft hohe Werte belasten Herz und Gefäße auf Dauer.", - "pulseBody": "Der Puls gibt an, wie oft das Herz pro Minute schlägt. Eine Einzelmessung spiegelt den Moment wider — in Ruhe liegt er meist bei 60–100 Schlägen pro Minute und steigt natürlicherweise bei Bewegung, Stress oder Koffein.", + "pulseBody": "Der Puls gibt an, wie oft das Herz pro Minute schlägt. Eine Einzelmessung spiegelt den Moment wider. In Ruhe liegt er meist bei 60–100 Schlägen pro Minute und steigt natürlicherweise bei Bewegung, Stress oder Koffein.", "weightBody": "Das Körpergewicht ist eines der einfachsten Signale für die langfristige Energiebilanz und die allgemeine Gesundheit. Der Trend über Wochen zählt weit mehr als ein einzelner Wert, der mit Flüssigkeit, Ernährung und Tageszeit schwankt.", "bmiBody": "Der BMI ist dein Gewicht geteilt durch deine Körpergröße zum Quadrat und ordnet dich einer groben Gewichtskategorie zu. Er ist ein Orientierungswert, keine Diagnose, und unterscheidet nicht zwischen Muskeln und Fett.", "sleepBody": "Die Schlafdauer ist die tatsächlich geschlafene Zeit pro Nacht, aufgeteilt in Leicht-, Tief- und REM-Schlaf. Die meisten Erwachsenen brauchen etwa sieben bis neun Stunden; gleichmäßige Menge und Qualität unterstützen Erholung, Stimmung und Stoffwechsel.", - "moodBody": "Die Stimmungserfassung hält auf einer einfachen Skala fest, wie du dich fühlst. Regelmäßiges Protokollieren zeigt Muster — wie Schlaf, Aktivität oder Medikamente mit dem Befinden zusammenhängen –, die ein einzelner Tag nie offenbart.", + "moodBody": "Die Stimmungserfassung hält auf einer einfachen Skala fest, wie du dich fühlst. Regelmäßiges Protokollieren zeigt Muster (wie Schlaf, Aktivität oder Medikamente mit dem Befinden zusammenhängen), die ein einzelner Tag nie offenbart.", "medicationsBody": "Die Therapietreue zeigt, wie zuverlässig du deine Medikamente wie verordnet einnimmst, hier als Anteil der erwarteten Dosen, die du erfasst hast. Eine gleichmäßige Einnahme entscheidet oft darüber, ob eine Therapie wie beabsichtigt wirkt.", "restingHrBody": "Der Ruhepuls ist die Herzfrequenz in völliger Ruhe, meist über Nacht gemessen. Ein niedriger Ruhepuls deutet in der Regel auf eine bessere Herz-Kreislauf-Fitness hin, ein steigender Trend kann ein frühes Zeichen für Stress oder Krankheit sein.", "hrvBody": "Die HRV ist die kleine Schwankung der Zeit zwischen aufeinanderfolgenden Herzschlägen. Eine höhere Variabilität spiegelt meist ein gut erholtes, anpassungsfähiges Nervensystem wider, ein anhaltender Rückgang kann auf Erschöpfung, Stress oder mangelnde Erholung hinweisen.", @@ -3367,12 +3403,12 @@ "respiratoryRateBody": "Die Atemfrequenz ist die Anzahl der Atemzüge pro Minute. In Ruhe atmen die meisten Erwachsenen etwa zwölf- bis zwanzigmal pro Minute; ein anhaltender Anstieg kann mit Krankheit, Anstrengung oder Stress einhergehen.", "bodyWaterBody": "Das Gesamtkörperwasser ist der Anteil deines Körpergewichts, der aus Flüssigkeit in und um die Zellen besteht. Es schwankt mit Flüssigkeitshaushalt, Ernährung und Aktivität, daher sagt der Trend über Wochen mehr aus als ein einzelner Wert.", "boneMassBody": "Die Knochenmasse ist das geschätzte Gewicht der Mineralien in deinem Skelett. Waagengestützte Schätzungen sind ungefähr, doch ein gleichbleibender oder steigender Trend unterstützt die langfristige Knochenstärke.", - "fatFreeMassBody": "Die fettfreie Masse ist alles in deinem Körper, das kein Fett ist — Muskeln, Knochen, Organe und Wasser. Sie zu halten oder aufzubauen, während das Gewicht sinkt, deutet meist auf eine gesündere Körperzusammensetzung hin.", - "fatMassBody": "Die Fettmasse ist das Gesamtgewicht des Körperfetts. Etwas Fett ist für Hormone und Energiespeicher unverzichtbar, doch zu viel — besonders am Bauch — erhöht das langfristige Gesundheitsrisiko (WHO 2024).", + "fatFreeMassBody": "Die fettfreie Masse ist alles in deinem Körper, das kein Fett ist: Muskeln, Knochen, Organe und Wasser. Sie zu halten oder aufzubauen, während das Gewicht sinkt, deutet meist auf eine gesündere Körperzusammensetzung hin.", + "fatMassBody": "Die Fettmasse ist das Gesamtgewicht des Körperfetts. Etwas Fett ist für Hormone und Energiespeicher unverzichtbar, doch zu viel (besonders am Bauch) erhöht das langfristige Gesundheitsrisiko (WHO 2024).", "bodyFatBody": "Der Körperfettanteil ist der Teil deines Gewichts, der aus Fett besteht. Gesunde Bereiche hängen von Geschlecht und Alter ab, und Waagen schätzen den Wert per Bioimpedanz, deshalb sagt der Verlauf über mehrere Wochen mehr als eine einzelne Messung.", "muscleMassBody": "Die Muskelmasse ist das geschätzte Gewicht der Muskeln in deinem Körper. Mehr Muskeln unterstützen Kraft, Stoffwechsel und Beweglichkeit und müssen mit zunehmendem Alter aktiv erhalten werden.", "visceralFatBody": "Viszeralfett ist das Fett, das tief im Bauchraum um die Organe gespeichert wird. Anders als das Unterhautfett ist ein hoher Wert eng mit Herzkrankheiten und Typ-2-Diabetes verbunden, daher ist niedriger meist besser.", - "leanBodyMassBody": "Die Magermasse ist dein Gesamtgewicht abzüglich des Körperfetts — Muskeln, Knochen, Organe und Wasser zusammen. Sie neben dem Gewicht zu verfolgen zeigt, ob Veränderungen aus Muskeln oder aus Fett stammen.", + "leanBodyMassBody": "Die Magermasse ist dein Gesamtgewicht abzüglich des Körperfetts, also Muskeln, Knochen, Organe und Wasser zusammen. Sie neben dem Gewicht zu verfolgen zeigt, ob Veränderungen aus Muskeln oder aus Fett stammen.", "flightsClimbedBody": "Die gestiegenen Etagen zählen die Stockwerke, die du täglich nach oben gehst, wobei eine Etage etwa drei Metern Höhe entspricht. Es ist ein einfacher Marker für Alltagsaktivität und Beinarbeit.", "walkingDistanceBody": "Die Geh- und Laufstrecke ist die Strecke, die du täglich zu Fuß zurücklegst. Sie ergänzt die Schrittzahl, indem sie zeigt, wie weit du dich bewegt hast, nicht nur wie oft du getreten bist.", "walkingSteadinessBody": "Die Gehstabilität schätzt, wie stabil und ausgewogen dein Gangbild ist, eingeteilt von OK bis niedrig. Ein niedriger Wert deutet auf ein höheres Sturzrisiko hin und sollte ärztlich besprochen werden.", @@ -3389,28 +3425,28 @@ "daylightBody": "Die Zeit im Tageslicht ist, wie lange du dich täglich im Freien im Licht aufhältst. Regelmäßiges Tageslicht unterstützt einen gesunden Schlaf-Wach-Rhythmus und die Stimmung (CDC 2024), und Zeit im Freien ist mit der Augengesundheit verbunden.", "bloodGlucoseBody": "Der Blutzucker ist die Zuckermenge in deinem Blut, die wichtigste Energiequelle des Körpers. Er steigt nach dem Essen und pendelt sich zwischen den Mahlzeiten ein; dauerhaft hohe Werte können auf Diabetes oder Prädiabetes hinweisen.", "skinTemperatureBody": "Die Hauttemperatur ist hier die kleine nächtliche Abweichung deiner Handgelenkstemperatur von deinem eigenen Ausgangswert, keine absolute Fiebermessung. Veränderungen können Schlaf, Umgebung, Krankheit oder Zyklusphase widerspiegeln.", - "cardioFitnessBody": "VO₂max gibt an, wie viel Sauerstoff dein Körper bei maximaler Anstrengung verwerten kann — der beste Laborindex der Ausdauerfitness, den Wearables indirekt schätzen. Ein höherer oder steigender Trend ist ein starkes Zeichen für eine gute Herz-Kreislauf-Gesundheit.", - "cardioRecoveryBody": "Der Erholungspuls gibt an, um wie viele Schläge pro Minute dein Puls in der ersten Minute nach dem Ende einer anstrengenden Belastung fällt. Ein größerer Abfall bedeutet, dass sich dein Herz schneller beruhigt — ein Zeichen guter Fitness.", + "cardioFitnessBody": "VO₂max gibt an, wie viel Sauerstoff dein Körper bei maximaler Anstrengung verwerten kann. Es ist der beste Laborindex der Ausdauerfitness, den Wearables indirekt schätzen. Ein höherer oder steigender Trend ist ein starkes Zeichen für eine gute Herz-Kreislauf-Gesundheit.", + "cardioRecoveryBody": "Der Erholungspuls gibt an, um wie viele Schläge pro Minute dein Puls in der ersten Minute nach dem Ende einer anstrengenden Belastung fällt. Ein größerer Abfall bedeutet, dass sich dein Herz schneller beruhigt, ein Zeichen guter Fitness.", "wristTemperatureBody": "Die Handgelenktemperatur wird im Schlaf gemessen und als Abweichung von deinem persönlichen Ausgangswert angegeben, nicht als absoluter Wert. Ein gleichmäßiger Wert ist normal, ein anhaltender Anstieg kann auf Krankheit, intensives Training oder eine Zyklusveränderung hindeuten.", - "fallsBody": "Hier werden die von deinem Wearable erkannten schweren Stürze gezählt. Null ist das Ziel; eine steigende Zahl ist ein Hinweis auf ein erhöhtes Sturzrisiko, das du — besonders im höheren Alter — mit einer Ärztin oder einem Arzt besprechen solltest (CDC STEADI 2024).", - "sixMinuteWalkBody": "Dies ist eine Schätzung, wie weit du in sechs Minuten gehen könntest — ein etablierter klinischer Test für Mobilität und kardiopulmonale Ausdauer. Der Wearable-Wert ist ein Näherungswert, nicht der überwachte klinische Test; aussagekräftig ist der gleichbleibende oder steigende Trend.", - "stairAscentSpeedBody": "Dies gibt an, wie schnell du eine Treppe hinaufgehst. Es ist ein empfindlicher Marker für Beinkraft und Mobilität — ein gleichbleibendes oder steigendes Tempo ist gut, ein sinkendes solltest du im Blick behalten.", - "stairDescentSpeedBody": "Dies gibt an, wie schnell du eine Treppe hinabgehst. Zusammen mit dem Steig-Tempo zeigt es Beinkontrolle und Gleichgewicht — ein gleichmäßiges Tempo ist gut, ein sinkendes solltest du im Blick behalten.", - "breathingDisturbancesBody": "Ein Wearable kann schätzen, wie oft deine Atmung im Schlaf unterbrochen war. Ein niedriger Index ist besser; ein als erhöht eingestufter Wert — vor allem über viele Nächte — sollte mit einer Ärztin oder einem Arzt besprochen werden. Es ist ein Screening-Näherungswert, keine Schlafapnoe-Diagnose.", - "stepsBody": "Die Schrittzahl ist die Gesamtzahl der Schritte, die du täglich gehst — das einfachste Maß für Alltagsbewegung. Eine stetige Gewohnheit zählt mehr als ein einzelner großer Tag, und das runde Ziel von 10.000 Schritten ist ein Werbeslogan, keine Forschung.", - "readinessBody": "Die Bereitschaft ist ein einzelner Tageswert dafür, wie bereit dein Körper für den Tag ist — er verbindet Ruhepuls, Herzfrequenzvariabilität und Schlaf. Es ist ein Geräte-Näherungswert an deinem eigenen Ausgangswert, keine klinische Messung: ein höherer Wert heißt erholt, ein niedrigerer ist ein sanfter Hinweis, einen Gang zurückzuschalten.", - "sleepScoreBody": "Dein Schlafscore fasst eine ganze Nacht in einer Zahl zusammen — aus Dauer, Effizienz, Regelmäßigkeit und der Verteilung von Leicht-, Tief- und REM-Schlaf. Achte eher auf das Muster über Wochen als auf einen perfekten Wert in einer einzelnen Nacht.", - "recoveryBody": "Die Erholung zeigt, wie gut sich dein Körper regeneriert hat — gestützt auf nächtliche Signale wie Herzfrequenzvariabilität und Ruhepuls. Es ist ein geräteabgeleiteter Näherungswert an deinem eigenen Ausgangswert, keine klinische Messung, und zwischen Geräten nicht vergleichbar. Ein hoher Wert ist grünes Licht, mehr zu geben; ein niedrigerer deutet an, dass dein System noch aufbaut.", - "strainBody": "Die Belastung erfasst, wie viel kardiovaskuläre und körperliche Last du deinem Körper zugemutet hast — von Workouts bis zu einem aktiven Tag auf den Beinen. Es ist ein geräteabgeleiteter Index, gemessen an deinem eigenen üblichen Aufwand, keine klinische Messung, und der Kennwert ist zwischen Geräten nicht vergleichbar.", - "stressBody": "Stress ist hier eine Geräte-Heuristik aus Mustern der Herzfrequenzvariabilität — keine Cortisol-Messung und nicht, wie du dich bewusst fühlst, und auch kein klinischer Stress-Score. Lies ihn an deinem eigenen Ausgangswert; zwischen Geräten ist er nicht vergleichbar. Ein niedrigerer Wert deutet auf einen ruhigen, gut regulierten Zustand hin; ein höherer ist eine Einladung, Erholung einzuplanen.", - "recoveryPageBody": "Die Erholung fasst die nächtlichen Signale deines Wearables zusammen — Recharge, Belastung, Load sowie deine Herzfrequenz- und Energiewerte des Tages — und zeigt, wie gut sich dein Körper erholt hat. Achte eher auf das Muster über mehrere Tage als auf einen einzelnen Wert; ein schwächerer Wert ist eine Einladung, kürzerzutreten, kein Alarm.", + "fallsBody": "Hier werden die von deinem Wearable erkannten schweren Stürze gezählt. Null ist das Ziel; eine steigende Zahl ist ein Hinweis auf ein erhöhtes Sturzrisiko, das du (besonders im höheren Alter) mit einer Ärztin oder einem Arzt besprechen solltest (CDC STEADI 2024).", + "sixMinuteWalkBody": "Dies ist eine Schätzung, wie weit du in sechs Minuten gehen könntest, ein etablierter klinischer Test für Mobilität und kardiopulmonale Ausdauer. Der Wearable-Wert ist ein Näherungswert, nicht der überwachte klinische Test; aussagekräftig ist der gleichbleibende oder steigende Trend.", + "stairAscentSpeedBody": "Dies gibt an, wie schnell du eine Treppe hinaufgehst. Es ist ein empfindlicher Marker für Beinkraft und Mobilität. Ein gleichbleibendes oder steigendes Tempo ist gut, ein sinkendes solltest du im Blick behalten.", + "stairDescentSpeedBody": "Dies gibt an, wie schnell du eine Treppe hinabgehst. Zusammen mit dem Steig-Tempo zeigt es Beinkontrolle und Gleichgewicht. Ein gleichmäßiges Tempo ist gut, ein sinkendes solltest du im Blick behalten.", + "breathingDisturbancesBody": "Ein Wearable kann schätzen, wie oft deine Atmung im Schlaf unterbrochen war. Ein niedriger Index ist besser; ein als erhöht eingestufter Wert (vor allem über viele Nächte) sollte mit einer Ärztin oder einem Arzt besprochen werden. Es ist ein Screening-Näherungswert, keine Schlafapnoe-Diagnose.", + "stepsBody": "Die Schrittzahl ist die Gesamtzahl der Schritte, die du täglich gehst, das einfachste Maß für Alltagsbewegung. Eine stetige Gewohnheit zählt mehr als ein einzelner großer Tag, und das runde Ziel von 10.000 Schritten ist ein Werbeslogan, keine Forschung.", + "readinessBody": "Die Bereitschaft ist ein einzelner Tageswert dafür, wie bereit dein Körper für den Tag ist. Er verbindet Ruhepuls, Herzfrequenzvariabilität und Schlaf. Es ist ein Geräte-Näherungswert an deinem eigenen Ausgangswert, keine klinische Messung: ein höherer Wert heißt erholt, ein niedrigerer ist ein sanfter Hinweis, einen Gang zurückzuschalten.", + "sleepScoreBody": "Dein Schlafscore fasst eine ganze Nacht in einer Zahl zusammen, aus Dauer, Effizienz, Regelmäßigkeit und der Verteilung von Leicht-, Tief- und REM-Schlaf. Achte eher auf das Muster über Wochen als auf einen perfekten Wert in einer einzelnen Nacht.", + "recoveryBody": "Die Erholung zeigt, wie gut sich dein Körper regeneriert hat, gestützt auf nächtliche Signale wie Herzfrequenzvariabilität und Ruhepuls. Es ist ein geräteabgeleiteter Näherungswert an deinem eigenen Ausgangswert, keine klinische Messung, und zwischen Geräten nicht vergleichbar. Ein hoher Wert ist grünes Licht, mehr zu geben; ein niedrigerer deutet an, dass dein System noch aufbaut.", + "strainBody": "Die Belastung erfasst, wie viel kardiovaskuläre und körperliche Last du deinem Körper zugemutet hast, von Workouts bis zu einem aktiven Tag auf den Beinen. Es ist ein geräteabgeleiteter Index, gemessen an deinem eigenen üblichen Aufwand, keine klinische Messung, und der Kennwert ist zwischen Geräten nicht vergleichbar.", + "stressBody": "Stress ist hier eine Geräte-Heuristik aus Mustern der Herzfrequenzvariabilität. Sie ist keine Cortisol-Messung und nicht, wie du dich bewusst fühlst, und auch kein klinischer Stress-Score. Lies ihn an deinem eigenen Ausgangswert; zwischen Geräten ist er nicht vergleichbar. Ein niedrigerer Wert deutet auf einen ruhigen, gut regulierten Zustand hin; ein höherer ist eine Einladung, Erholung einzuplanen.", + "recoveryPageBody": "Die Erholung fasst die nächtlichen Signale deines Wearables zusammen (Recharge, Belastung, Load sowie deine Herzfrequenz- und Energiewerte des Tages) und zeigt, wie gut sich dein Körper erholt hat. Achte eher auf das Muster über mehrere Tage als auf einen einzelnen Wert; ein schwächerer Wert ist eine Einladung, kürzerzutreten, kein Alarm.", "pulsePressureBody": "Der Pulsdruck ist die Differenz zwischen deinem systolischen und diastolischen Wert (systolisch minus diastolisch). Als allgemeine Orientierung sind etwa 40 mmHg in Ruhe typisch; eine große Differenz über rund 60 mmHg im mittleren Alter kann auf versteifte Arterien hinweisen, eine kleine Differenz unter etwa 25 mmHg kann mit niedrigem Schlagvolumen einhergehen (StatPearls/NIH 2023). Ein Gesprächsanlass, keine Diagnose.", - "meanArterialPressureBody": "Der mittlere arterielle Druck ist der durchschnittliche Druck, den deine Organe über einen Herzschlag sehen — grob der diastolische Wert plus ein Drittel der Differenz zum systolischen. Als allgemeine Orientierung liegt ein Ruhewert bei etwa 70–100 mmHg (StatPearls/NIH 2023). Die oft genannte Grenze von ~60 mmHg ist ein Konzept der Akutmedizin, kein Alarm für die Selbstmessung.", - "cycleEducationBody": "Dein Zyklus durchläuft vier hormongesteuerte Phasen, und seine Länge und Regelmäßigkeit sind ein gut zugängliches Fenster auf die allgemeine Gesundheit. Als allgemeine Orientierung dauern Zyklen bei Erwachsenen typischerweise 21–35 Tage (klinische Referenzen nennen 24–38), mit einer Blutung von rund fünf Tagen (ACOG 2015). Die Lutealphase ist der stabile Anker, daher schätzt man den Eisprung am besten durch Rückrechnen von der nächsten erwarteten Periode — nie über einen festen „Tag 14“. Ein vorhergesagtes fruchtbares Fenster ist eine Schätzung, die mehrere erfasste Zyklen braucht und ungefähr bleibt, und die Basaltemperatur bestätigt den Eisprung im Nachhinein, statt ihn vorherzusagen. Dies ist keine Verhütungsmethode.", + "meanArterialPressureBody": "Der mittlere arterielle Druck ist der durchschnittliche Druck, den deine Organe über einen Herzschlag sehen, grob der diastolische Wert plus ein Drittel der Differenz zum systolischen. Als allgemeine Orientierung liegt ein Ruhewert bei etwa 70–100 mmHg (StatPearls/NIH 2023). Die oft genannte Grenze von ~60 mmHg ist ein Konzept der Akutmedizin, kein Alarm für die Selbstmessung.", + "cycleEducationBody": "Dein Zyklus durchläuft vier hormongesteuerte Phasen, und seine Länge und Regelmäßigkeit sind ein gut zugängliches Fenster auf die allgemeine Gesundheit. Als allgemeine Orientierung dauern Zyklen bei Erwachsenen typischerweise 21–35 Tage (klinische Referenzen nennen 24–38), mit einer Blutung von rund fünf Tagen (ACOG 2015). Die Lutealphase ist der stabile Anker, daher schätzt man den Eisprung am besten durch Rückrechnen von der nächsten erwarteten Periode, nie über einen festen „Tag 14“. Ein vorhergesagtes fruchtbares Fenster ist eine Schätzung, die mehrere erfasste Zyklen braucht und ungefähr bleibt, und die Basaltemperatur bestätigt den Eisprung im Nachhinein, statt ihn vorherzusagen. Dies ist keine Verhütungsmethode.", "sleepHygieneBody": "Einige evidenzbasierte Gewohnheiten unterstützen den Schlaf: gleichbleibende Zubett- und Aufstehzeiten auch am Wochenende, helles Morgenlicht und gedämpfte Abende, ein kühles, dunkles und ruhiges Zimmer sowie der Verzicht auf Koffein am Nachmittag, späten Alkohol und große späte Mahlzeiten (CDC 2024). Regelmäßigkeit sagt die Gesundheit mindestens so stark voraus wie die Dauer, und die Gesamtschlafzeit eines Wearables ist weit verlässlicher als seine Aufteilung in Tief-, Leicht- und REM-Schlaf." }, "bpDerived": { - "pulsePressure": "Der Pulsdruck (die Differenz zwischen deinen Werten) beträgt etwa {value} mmHg — rund 40 ist in Ruhe typisch.", + "pulsePressure": "Der Pulsdruck (die Differenz zwischen deinen Werten) beträgt etwa {value} mmHg; rund 40 ist in Ruhe typisch.", "meanArterialPressure": "Der mittlere arterielle Druck (der Durchschnitt, den deine Organe sehen) liegt bei etwa {value} mmHg, normalerweise um 70–100.", "caveat": "Diese Werte werden als allgemeine Orientierung aus deiner letzten Messung abgeleitet, nicht als Diagnose." }, @@ -3422,7 +3458,7 @@ "mean": "Mittelwert" }, "showAllValues": "Alle Werte anzeigen", - "valuesPageTitle": "{metric} — alle Werte", + "valuesPageTitle": "{metric}: alle Werte", "valuesPageDescription": "Jeder einzelne Wert hinter dieser Kennzahl. Einträge lassen sich direkt bearbeiten oder entfernen.", "diversity": { "trigger": "Messhinweis", @@ -3444,7 +3480,7 @@ }, "sleep": { "title": "Schlaf", - "description": "Phasen-Aufschlüsselung und nächtliche Dauer — gezogen aus Apple Health.", + "description": "Phasen-Aufschlüsselung und nächtliche Dauer, gezogen aus Apple Health.", "mainSleep": "Hauptschlaf", "totalSleep": "Schlaf gesamt", "compositionTitle": "Phasen-Verteilung", @@ -3462,11 +3498,11 @@ "inBed": "Im Bett", "nap": "Nickerchen", "napCount": "{count} Nickerchen", - "unavailable": "Noch keine Phasen-Daten — Schlafphasen erscheinen hier, sobald eine verbundene Quelle sie liefert." + "unavailable": "Noch keine Phasen-Daten. Schlafphasen erscheinen hier, sobald eine verbundene Quelle sie liefert." }, "empty": { "title": "Noch keine Schlafdaten", - "description": "Verbinde eine Quelle wie Apple Health oder ein Wearable — oder erfasse Schlaf manuell — und diese Ansicht füllt sich automatisch.", + "description": "Verbinde eine Quelle wie Apple Health oder ein Wearable (oder erfasse Schlaf manuell), und diese Ansicht füllt sich automatisch.", "action": "Geräte verwalten" }, "debt": { @@ -3475,21 +3511,21 @@ "caption": "Offene Bilanz der letzten {nights} Nächte", "clearValue": "Ausgeglichen", "clearCaption": "Nichts offen über die letzten {nights} Nächte", - "learning": "Dein Schlaf wird noch erfasst — {counted} Nächte bisher, noch {remaining} für eine verlässliche Bilanz.", + "learning": "Dein Schlaf wird noch erfasst: {counted} Nächte bisher, noch {remaining} für eine verlässliche Bilanz.", "computedInfo": "Das ist HealthLogs eigene Schätzung: eine gleitende Bilanz aus Bedarf minus Schlaf über die letzten Nächte, die Aufholschlaf wieder abbaut. Sie kann vom nativen Schlafdefizit eines Wearables abweichen, das ein eigenes Bedarfsmodell nutzt." }, "average": { "title": "Durchschnitt pro Nacht", "value": "{hours} Std. {minutes} Min.", "caption": "Mittlerer Schlaf über die letzten {nights} Nächte", - "learning": "Dein Schlaf wird noch erfasst — {counted} Nächte bisher, noch {remaining} für einen verlässlichen Durchschnitt." + "learning": "Dein Schlaf wird noch erfasst: {counted} Nächte bisher, noch {remaining} für einen verlässlichen Durchschnitt." }, "chronotype": { "title": "Chronotyp", - "learning": "Dein Rhythmus wird noch erfasst — {counted} von {total} freien Nächten. Ein Chronotyp braucht ein paar weckerfreie Morgen, um aussagekräftig zu sein.", + "learning": "Dein Rhythmus wird noch erfasst: {counted} von {total} freien Nächten. Ein Chronotyp braucht ein paar weckerfreie Morgen, um aussagekräftig zu sein.", "advancedToggle": "Mehr Details", "socialJetlagValue": "{hours} Std. {minutes} Min. sozialer Jetlag", - "socialJetlagCaption": "Der Unterschied zwischen deinem Schlaf an Arbeits- und freien Tagen — wie eine kleine wöchentliche Zeitzonenverschiebung.", + "socialJetlagCaption": "Der Unterschied zwischen deinem Schlaf an Arbeits- und freien Tagen, wie eine kleine wöchentliche Zeitzonenverschiebung.", "msfScValue": "Korrigierte Schlafmitte {clock}", "msfScCaption": "Bereinigt um den Schlaf, den du am Wochenende nachholst (MSFsc).", "dayTypeNote": "Freie Tage werden als Wochenenden angenommen, bis eine Tagestyp-Erfassung ergänzt wird.", @@ -3568,7 +3604,7 @@ "navRecovery": "Erholung", "workouts": { "title": "Workouts", - "description": "Letzte Läufe, Touren, Spaziergänge und Krafteinheiten — quellenübergreifend entdoppelt zwischen Apple Watch und Withings.", + "description": "Letzte Läufe, Touren, Spaziergänge und Krafteinheiten, quellenübergreifend entdoppelt zwischen Apple Watch und Withings.", "emptyState": { "title": "Noch keine Workouts", "description": "Workouts kommen per Apple Health vom iPhone und von Withings. Du kannst eins auch von Hand erfassen." @@ -3678,7 +3714,10 @@ "endInFuture": "Das Workout würde in der Zukunft enden. Stell den Beginn auf die Uhrzeit, zu der es angefangen hat.", "distanceInvalid": "Gib die Distanz als Zahl in einem realistischen Bereich ein.", "energyInvalid": "Gib die aktive Energie als Zahl bis 20.000 kcal ein." - } + }, + "editTitle": "Training bearbeiten", + "editAction": "Bearbeiten", + "updated": "Training aktualisiert" } }, "bloodGlucose": { @@ -3693,11 +3732,11 @@ "clinical": { "title": "Glukose-Überblick", "subtitle": "Ein Blick auf deine letzten {days} Tage, aus {count} Messungen.", - "spotCaveat": "Das sind Schätzungen aus einzelnen Stichproben-Messungen, kein Profil eines kontinuierlichen Sensors — lies sie als Richtung, nicht als klinisches Urteil.", + "spotCaveat": "Das sind Schätzungen aus einzelnen Stichproben-Messungen, kein Profil eines kontinuierlichen Sensors. Lies sie als Richtung, nicht als klinisches Urteil.", "labsLink": "Laborergebnisse? HbA1c und Nüchternglukose aus einer Blutabnahme trägst du unter Labor ein.", "learningTitle": "Lerne deine Glukose noch kennen", - "learningBody": "Bisher sind das {count} über {days}. Mit etwas mehr Verlauf wird das Bild klarer — mess weiter oder lass es ein verbundenes Gerät ergänzen.", - "learningBodyEmpty": "Sobald ein paar Messungen vorliegen — von dir erfasst oder von einem Gerät synchronisiert — nehmen Zeit-im-Zielbereich, Durchschnitt und Schwankung hier Gestalt an.", + "learningBody": "Bisher sind das {count} über {days}. Mit etwas mehr Verlauf wird das Bild klarer. Mess weiter oder lass es ein verbundenes Gerät ergänzen.", + "learningBodyEmpty": "Sobald ein paar Messungen vorliegen (von dir erfasst oder von einem Gerät synchronisiert), nehmen Zeit-im-Zielbereich, Durchschnitt und Schwankung hier Gestalt an.", "readingsCount": "{count} Messungen", "readingsCountOne": "1 Messung", "daysCount": "{days} Tage", @@ -3735,7 +3774,7 @@ "intro": "Forschungsnahe Kennzahlen für den genaueren Blick. Jede fasst deine Messungen zu einer risikogewichteten Zahl zusammen.", "jIndex": { "label": "J-Index", - "help": "Ein einzelner Wert aus Durchschnitt und Streuung — niedriger steht für engere Kontrolle." + "help": "Ein einzelner Wert aus Durchschnitt und Streuung; niedriger steht für engere Kontrolle." }, "lbgi": { "label": "Risiko für Tiefwerte (LBGI)", @@ -3852,7 +3891,7 @@ "navSkinTemperature": "Hauttemperatur", "pulseWaveVelocity": { "title": "Pulswellengeschwindigkeit", - "description": "Deine arterielle Pulswellengeschwindigkeit im Zeitverlauf, in m/s — höhere Werte deuten auf steifere Arterien hin.", + "description": "Deine arterielle Pulswellengeschwindigkeit im Zeitverlauf, in m/s. Höhere Werte deuten auf steifere Arterien hin.", "chartTitle": "Pulswellengeschwindigkeit", "emptyState": { "title": "Noch keine Daten", @@ -4002,7 +4041,6 @@ "navWalkingSpeed": "Gehgeschwindigkeit", "mood": { "logLink": "Stimmung eintragen", - "mentalWellbeingLink": "Check-in zum seelischen Wohlbefinden machen", "heatmapTitle": "Stimmungskalender", "heatmapNoEntry": "Kein Eintrag", "legendGreat": "Super", @@ -4013,7 +4051,7 @@ "distributionDaysUnit": "Tage", "weekdayTitle": "Durchschnitt nach Wochentag", "weekdayNoData": "Keine Daten", - "weekdayBestWorst": "Bester Tag: {best} · Schwerster Tag: {worst}", + "weekdayBestWorst": "Bester Tag: {best}, schwerster Tag: {worst}", "tagsTitle": "Tags und Auslöser", "tagAvgMood": "Durchschnittliche Stimmung mit diesem Tag", "correlationsTitle": "Stimmung und andere Werte", @@ -4029,7 +4067,7 @@ "strengthModerate": "Moderat", "strengthWeak": "Schwach", "strengthNone": "Keine", - "source": "{n} gepaarte Tage · r = {r}", + "source": "{n} gepaarte Tage, r = {r}", "emptyTitle": "Noch nicht genug gepaarte Daten", "emptyDescription": "Erfasse Stimmung und diesen Wert an denselben Tagen, um einen Zusammenhang zu sehen.", "pendingDescription": "{n} von {needed} gepaarten Tagen. Ein Zusammenhang erscheint, sobald genug Tage da sind, um Muster von Zufall zu unterscheiden.", @@ -4046,7 +4084,7 @@ "inTargetCaption": "der letzten Tage im guten Stimmungsbereich", "narrative": { "title": "Das fällt auf", - "weekdayDip": "Deine Stimmung sinkt am stärksten am {weekday} — {delta} unter deinem Wochenschnitt.", + "weekdayDip": "Deine Stimmung sinkt am stärksten am {weekday}, {delta} unter deinem Wochenschnitt.", "weekdayPeak": "{weekday} ist durchgehend dein bester Tag mit einem Schnitt von {value}.", "trendUp": "Deine Stimmung steigt über die letzten {days} Tage (+{delta} gegenüber dem Vorzeitraum).", "trendDown": "Deine Stimmung sinkt über die letzten {days} Tage (-{delta} gegenüber dem Vorzeitraum).", @@ -4055,7 +4093,7 @@ "tagLift": "Tage mit „{tag}“ liegen im Schnitt {delta} über deiner allgemeinen Stimmung.", "tagDrop": "Tage mit „{tag}“ liegen {delta} unter deinem Durchschnitt.", "inTarget": "Du warst an {pct} % der letzten 30 Tage in deinem guten Stimmungsbereich.", - "streak": "Du hast deine Stimmung {days} Tage in Folge erfasst — weiter so.", + "streak": "Du hast deine Stimmung {days} Tage in Folge erfasst. Weiter so.", "timeOfDay": "Am {bucket} fühlst du dich am besten, im Schnitt {value}." }, "timeOfDay": { @@ -4088,7 +4126,7 @@ "crosstab": { "title": "Tags und deine Gesundheitswerte", "description": "Wie sich ein Gesundheitswert an Tagen mit einem Tag von Tagen ohne diesen Tag unterscheidet.", - "pairLabel": "{tag} · {metric}", + "pairLabel": "{metric} an Tagen mit {tag}", "metricActiveEnergy": "Aktive Energie", "metricSleepDuration": "Schlaf", "metricNextDayRecovery": "Erholung am Folgetag", @@ -4105,7 +4143,7 @@ "factorCrosstab": { "title": "Deine bewerteten Faktoren und deine Vitalwerte", "description": "Wie sich ein Vitalwert an den Tagen unterscheidet, an denen du einen Faktor niedrig bewertet hast, verglichen mit den Tagen mit hoher Bewertung.", - "pairLabel": "{factor} · {metric}", + "pairLabel": "{metric} an Tagen mit {factor}", "metricSleepDuration": "Schlaf", "metricSteps": "Schritte", "metricRestingHeartRate": "Ruhepuls", @@ -4126,12 +4164,12 @@ "title": "Was auffällt", "subheading": "Entdeckte Zusammenhänge", "footer": "Herausgefiltert aus {tested} Tag-zu-Tag-Paaren, die über all deine Werte getestet wurden, nach der Korrektur für Fehlentdeckungen.", - "disclaimer": "Statistisch gefiltert, aber weiterhin beobachtend — ein Muster, das einen Blick wert ist, keine Ursache.", + "disclaimer": "Statistisch gefiltert, aber weiterhin beobachtend: ein Muster, das einen Blick wert ist, keine Ursache.", "moodUp": "Höheres {factor} geht in deinen Daten tendenziell mit besserer Stimmung am Folgetag einher.", "moodDown": "Höheres {factor} geht in deinen Daten tendenziell mit schlechterer Stimmung am Folgetag einher.", "outcomeUp": "Bessere Stimmung geht in deinen Daten tendenziell mit höherem {factor} am Folgetag einher.", "outcomeDown": "Bessere Stimmung geht in deinen Daten tendenziell mit niedrigerem {factor} am Folgetag einher.", - "stat": "{n} gepaarte Tage · r = {r} · q = {q}", + "stat": "{n} gepaarte Tage, r = {r}, q = {q}", "statLabel": "Wie das berechnet wurde", "explainerLabel": "Wie das berechnet wurde" }, @@ -4165,8 +4203,8 @@ "deviationAbove": "Deine eigene Einschätzung liegt {amount} Punkte über diesem Bereich.", "deviationBelow": "Deine eigene Einschätzung liegt {amount} Punkte unter diesem Bereich.", "weightedTitle": "Was an diesem Tag am stärksten gewichtet wurde", - "weightedUp": "{label} — zählte in Richtung eines höheren erwarteten Werts", - "weightedDown": "{label} — zählte in Richtung eines niedrigeren erwarteten Werts", + "weightedUp": "{label}: zählte in Richtung eines höheren erwarteten Werts", + "weightedDown": "{label}: zählte in Richtung eines niedrigeren erwarteten Werts", "caveat": "Das ist ein Vergleich mit deinen eigenen bisherigen Tagen, keine Aussage darüber, wie ein Tag zustande gekommen ist.", "learning": "Lernphase: {entries} von {threshold} Tagen erfasst. Ein Vergleich erscheint, sobald genug davon zusammengekommen sind.", "noPattern": "In den bisher erfassten {entries} Tagen ist noch nichts gleichmäßig genug, um einen Tag damit zu vergleichen. Wenn du auch den Kontext eines Tages festhältst, wird mehr davon vergleichbar.", @@ -4175,15 +4213,15 @@ }, "derived": { "coverage": { - "summary": "{present} von {required} Eingaben · {percent}%", + "summary": "{present} von {required} Eingaben ({percent}%)", "ratioLabel": "{present}/{required}", "historyDays": "Basiert auf {count} Tagen Verlauf", "missing": "Erfasse diese, um den Wert zu schärfen: {list}", - "summaryLabelled": "{label} · {percent}%" + "summaryLabelled": "{label} ({percent}%)" }, "scoreRing": { "aria": "Wert {score} von 100, {band}", - "ariaProvisional": "Wert noch nicht verfügbar — zu wenig Daten", + "ariaProvisional": "Wert noch nicht verfügbar, zu wenig Daten", "provisionalCaption": "Noch zu wenig Daten", "band": { "green": "gut", @@ -4197,7 +4235,7 @@ "inputsLabel": "Eingaben", "noInputs": "Noch keine Eingaben für diesen Wert vorhanden.", "methodLabel": "Methode", - "window": "Aus deinen letzten {days} Tagen · {source}-Daten", + "window": "Aus deinen letzten {days} Tagen mit {source}-Daten", "asOf": "Stand {date}", "standardLink": "Standard: {name}" }, @@ -4207,7 +4245,7 @@ "loadingLabel": "Vitalwerte werden geladen", "loadError": "Deine Vitalwerte konnten gerade nicht geladen werden.", "infoLabel": "Wie wird das berechnet?", - "building": "Typischer Bereich entsteht — {count} von {target} Tagen", + "building": "Typischer Bereich entsteht: {count} von {target} Tagen", "typicalRange": "Typisch für dich: {low}–{high}", "fitnessYounger": "Ausdauer wie ~{years} J. jünger", "fitnessOlder": "Ausdauer ~{years} J. über deinem Alter", @@ -4239,9 +4277,8 @@ "anatomy": { "outOf": "/100", "contributorsLabel": "Faktoren", - "contributorValue": "{value} · {weight}% Gewicht", + "contributorValue": "{value}, Gewichtung {weight}%", "contributorMissing": "keine Daten", - "disclaimer": "Richtwert — keine klinische Bewertung.", "insufficient": "Noch nicht genug Daten für eine Bewertung. Tracke weiter, dann erscheint sie hier." }, "composite": { @@ -4260,77 +4297,75 @@ "READINESS": { "title": "Bereitschaft", "description": "Ein täglicher Wohlbefindens-Indikator daraus, wie deine Werte zu deiner eigenen Baseline stehen.", - "method": "Eine 0–100-Mischung aus Ruhepuls-Abweichung, HRV-(SDNN-)Abweichung, Schlaf, Atemfrequenz-Stabilität und Stimmungsstabilität — jeweils gegen deine persönliche Baseline gemessen. Gewichte werden über die von deinen Daten gestützten Komponenten neu verteilt. Ein Wohlbefindens-Indikator, kein Erholungs- oder klinischer Score.", + "method": "Eine 0–100-Mischung aus Ruhepuls-Abweichung, HRV-(SDNN-)Abweichung, Schlaf, Atemfrequenz-Stabilität und Stimmungsstabilität, jeweils gegen deine persönliche Baseline gemessen. Gewichte werden über die von deinen Daten gestützten Komponenten neu verteilt. Ein Wohlbefindens-Indikator, kein Erholungs- oder klinischer Score.", "component": { "rhr": "Ruhepuls", "hrv": "HRV (SDNN)", "sleep": "Schlaf", "respiratory": "Atemfrequenz", "mood": "Stimmungsstabilität" - }, - "caveat": "Ein Wellness-Proxy, kein Erholungs- oder klinischer Wert." + } }, "VITALS_BASELINE": { - "method": "Dein persönlicher Normalbereich ist der Median deiner Tageswerte über den Zeitraum, ± eine robuste Streuung (skalierte mittlere absolute Abweichung). Er folgt DEINEN Werten, keiner Bevölkerungsnorm — ein Wert im Band ist also typisch für dich." + "method": "Dein persönlicher Normalbereich ist der Median deiner Tageswerte über den Zeitraum, ± eine robuste Streuung (skalierte mittlere absolute Abweichung). Er folgt DEINEN Werten, keiner Bevölkerungsnorm. Ein Wert im Band ist also typisch für dich." }, "HRV_BALANCE": { "method": "Dein jüngster HRV-(SDNN-)Durchschnitt im Vergleich zu deiner eigenen gleitenden Baseline (Median ± robuste Streuung). Ausgeglichen heißt: er liegt in deinem persönlichen Bereich; das Band stammt aus deinen Daten, nie aus einem Bevölkerungswert." }, "BMI": { - "method": "Gewicht ÷ Größe², eingeordnet nach den WHO-Kategorien für Erwachsene (Untergewicht / Normal / Übergewicht / Adipositas). Nur ein Screening-Verhältnis — es unterscheidet nicht Muskel von Fett." + "method": "Gewicht ÷ Größe², eingeordnet nach den WHO-Kategorien für Erwachsene (Untergewicht / Normal / Übergewicht / Adipositas). Nur ein Screening-Verhältnis, es unterscheidet nicht Muskel von Fett." }, "FITNESS_AGE": { - "method": "Dein VO₂max im Vergleich zu alters- und geschlechtsbezogenen Perzentilen der kardiorespiratorischen Fitness, als Fitnessalter-Differenz. Das VO₂max ist die Schätzung deines Geräts — hier nie neu berechnet." + "method": "Dein VO₂max im Vergleich zu alters- und geschlechtsbezogenen Perzentilen der kardiorespiratorischen Fitness, als Fitnessalter-Differenz. Das VO₂max ist die Schätzung deines Geräts, hier nie neu berechnet." }, "VASCULAR_AGE_DELTA": { "method": "Die Gefäßalter- bzw. Pulswellengeschwindigkeits-Schätzung deines Geräts im Vergleich zu deinem chronologischen Alter, als Differenz in Jahren. Die Schätzung stammt vom Gerät; HealthLog ordnet sie nur ein.", - "caveat": "Die eigene Schätzung deines Geräts, nur eingeordnet — beschreibend, keine klinische Gefäßbewertung.", + "caveat": "Die eigene Schätzung deines Geräts, nur eingeordnet: beschreibend, keine klinische Gefäßbewertung.", "description": "Wie die Gefäßalter-Schätzung deines Geräts zu deinem chronologischen Alter steht." }, "COINCIDENT_DEVIATION": { "method": "Ein Hinweis, wenn zwei oder mehr deiner Vitalwerte am selben Tag außerhalb ihres persönlichen Normalbereichs liegen. Jedes Band ist der robuste Median ± k·MAD deiner eigenen Verlaufsdaten und passt sich an dich an, nicht an einen Bevölkerungswert.", - "caveat": "Ein Hinweis aus deinen eigenen Baselines — beschreibend, keine Diagnose und kein klinischer Alarm." + "caveat": "Ein Hinweis aus deinen eigenen Baselines: beschreibend, keine Diagnose und kein klinischer Alarm." }, "RECOVERY_SCORE": { - "method": "Ein nächtlicher 0–100-Erholungs-Proxy aus HRV, Ruhepuls und Schlaf gegenüber deiner Baseline. Höher heißt besser erholt. Einmal pro Nacht berechnet und hier nur ausgelesen — nie spontan neu berechnet.", - "caveat": "Ein beschreibender Wellness-Proxy, kein trainings- oder klinikgerechtes Erholungsmaß.", + "method": "Ein nächtlicher 0–100-Erholungs-Proxy aus HRV, Ruhepuls und Schlaf gegenüber deiner Baseline. Höher heißt besser erholt. Einmal pro Nacht berechnet und hier nur ausgelesen, nie spontan neu berechnet.", "description": "Ein nächtlicher Erholungs-Proxy aus HRV, Ruhepuls und Schlaf." }, "STRESS_SCORE": { "method": "Ein nächtlicher 0–100-Stress-Proxy aus dem Verlauf deiner HRV über den Tag gegenüber deiner Baseline. Höher heißt mehr physiologische Belastung.", - "caveat": "Ein aus der HRV abgeleiteter Proxy, KEINE EDA-/Hautleitwert-Stressmessung — deine Uhr hat keinen EDA-Sensor. Beschreibend, nicht klinisch.", "description": "Ein nächtlicher Stress-Proxy aus dem Tagesverlauf deiner HRV." }, "STRAIN_SCORE": { "method": "Ein nächtlicher 0–100-Belastungs-Proxy aus deiner herzfrequenzgewichteten Aktivitätslast (ein TRIMP-ähnliches Trainingsimpuls-Modell). Höher heißt ein anstrengenderer Tag.", - "anchorPersonal": "An deine eigene jüngste Trainingstagslast angelehnt — bewertet relativ zu deiner typischen Anstrengung, sodass ein für dich wirklich harter Tag hoch ausfällt.", + "anchorPersonal": "An deine eigene jüngste Trainingstagslast angelehnt und bewertet relativ zu deiner typischen Anstrengung, sodass ein für dich wirklich harter Tag hoch ausfällt.", "anchorPopulation": "Gegen einen allgemeinen Referenzwert bewertet, während du noch Trainingsverlauf aufbaust; sobald genug vorhanden ist, wechselt die Bewertung auf deine eigene typische Anstrengung.", - "caveat": "Ein beschreibender Aktivitätslast-Proxy, kein klinisches oder trainingsgerechtes Lastmaß.", - "description": "Ein nächtlicher Aktivitätslast-Proxy aus deiner herzfrequenzgewichteten Anstrengung, relativ zu deiner typischen Anstrengung." + "description": "Ein nächtlicher Aktivitätslast-Proxy aus deiner herzfrequenzgewichteten Anstrengung, relativ zu deiner typischen Anstrengung.", + "deviceCaption": "Tagesbelastung deines Geräts: {value} von {max}", + "deviceSource": "Bis HealthLog nachts einen eigenen Wert berechnet hat, zeigt die Karte die Tagesbelastung deines Geräts, umgerechnet auf 0–100." }, "WRIST_TEMPERATURE_BASELINE": { - "method": "Deine typische nächtliche Handgelenkstemperatur ist der Median deiner nächtlichen Messwerte im Zeitraum, ± einer robusten Streuung (skalierte mittlere absolute Abweichung). Sie folgt DEINEN Werten — letzte Nacht wird als Abweichung von deiner eigenen Baseline gezeigt, nicht von einer Bevölkerungsnorm.", - "caveat": "Nur ein persönliches Abweichungsband — beschreibend, kein Anzeichen für Krankheit, Fieber oder Zyklusphase. Du interpretierst es selbst." + "method": "Deine typische nächtliche Handgelenkstemperatur ist der Median deiner nächtlichen Messwerte im Zeitraum, ± einer robusten Streuung (skalierte mittlere absolute Abweichung). Sie folgt DEINEN Werten: Letzte Nacht wird als Abweichung von deiner eigenen Baseline gezeigt, nicht von einer Bevölkerungsnorm.", + "caveat": "Nur ein persönliches Abweichungsband: beschreibend, kein Anzeichen für Krankheit, Fieber oder Zyklusphase. Du interpretierst es selbst." }, "STAIR_ASCENT_SPEED_BASELINE": { - "method": "Dein typisches Treppensteig-Tempo ist der Median deiner Tageswerte im Zeitraum, ± einer robusten Streuung (skalierte mittlere absolute Abweichung). Das Band wird aus deinen Daten gebildet — es gibt keinen Bevölkerungs-Grenzwert für Treppentempo, da es von Treppengeometrie und Beinlänge abhängt.", - "caveat": "Ein persönliches Mobilitäts-Trendband — beschreibend, keine Gebrechlichkeits- oder Sturzrisiko-Bewertung." + "method": "Dein typisches Treppensteig-Tempo ist der Median deiner Tageswerte im Zeitraum, ± einer robusten Streuung (skalierte mittlere absolute Abweichung). Das Band wird aus deinen Daten gebildet. Es gibt keinen Bevölkerungs-Grenzwert für Treppentempo, da es von Treppengeometrie und Beinlänge abhängt.", + "caveat": "Ein persönliches Mobilitäts-Trendband: beschreibend, keine Gebrechlichkeits- oder Sturzrisiko-Bewertung." }, "STAIR_DESCENT_SPEED_BASELINE": { - "method": "Dein typisches Treppen-Abstiegstempo ist der Median deiner Tageswerte im Zeitraum, ± einer robusten Streuung (skalierte mittlere absolute Abweichung). Das Band wird aus deinen Daten gebildet — es gibt keinen Bevölkerungs-Grenzwert für Treppentempo, da es von Treppengeometrie und Beinlänge abhängt.", - "caveat": "Ein persönliches Mobilitäts-Trendband — beschreibend, keine Gebrechlichkeits- oder Sturzrisiko-Bewertung." + "method": "Dein typisches Treppen-Abstiegstempo ist der Median deiner Tageswerte im Zeitraum, ± einer robusten Streuung (skalierte mittlere absolute Abweichung). Das Band wird aus deinen Daten gebildet. Es gibt keinen Bevölkerungs-Grenzwert für Treppentempo, da es von Treppengeometrie und Beinlänge abhängt.", + "caveat": "Ein persönliches Mobilitäts-Trendband: beschreibend, keine Gebrechlichkeits- oder Sturzrisiko-Bewertung." }, "SIX_MINUTE_WALK_BAND": { - "method": "Die von deinem Gerät GESCHÄTZTE 6-Minuten-Gehstrecke, eingeordnet gegen die Referenz von Enright & Sherrill für Alter, Größe, Gewicht und Geschlecht, als Prozent des Vorhersagewerts. Die Strecke ist die Schätzung des Geräts — hier nie neu berechnet. Ohne Alter, Größe, Gewicht und Geschlecht wird der Prozentwert ausgeblendet und nur Strecke und Trend gezeigt.", - "caveat": "Eine gegen eine veröffentlichte Referenz neu eingeordnete Schätzung — ein Hinweis zur funktionellen Kapazität, kein klinischer 6-Minuten-Gehtest und keine Diagnose. Die Referenz wurde für Alter 40–80 erstellt; außerhalb dieses Bereichs ist der Vorhersagewert eine Extrapolation." + "method": "Die von deinem Gerät GESCHÄTZTE 6-Minuten-Gehstrecke, eingeordnet gegen die Referenz von Enright & Sherrill für Alter, Größe, Gewicht und Geschlecht, als Prozent des Vorhersagewerts. Die Strecke ist die Schätzung des Geräts, hier nie neu berechnet. Ohne Alter, Größe, Gewicht und Geschlecht wird der Prozentwert ausgeblendet und nur Strecke und Trend gezeigt.", + "caveat": "Eine gegen eine veröffentlichte Referenz neu eingeordnete Schätzung: ein Hinweis zur funktionellen Kapazität, kein klinischer 6-Minuten-Gehtest und keine Diagnose. Die Referenz wurde für Alter 40–80 erstellt; außerhalb dieses Bereichs ist der Vorhersagewert eine Extrapolation." }, "TRAJECTORY": { - "method": "Eine Ausgleichsgerade (kleinste Quadrate), an deine jüngsten Tageswerte angepasst und über einen kurzen Zeitraum fortgeschrieben — mit dem klassischen Vorhersageintervall, das umso breiter wird, je weiter es reicht. Wird nur berechnet, wenn der jüngste Trend deutlich genug und deine Datenreihe lang und aktuell genug ist; sonst wird keine Linie gezeichnet.", - "caveat": "Eine Fortschreibung deines eigenen jüngsten Trends — keine Vorhersage, kein Ziel und keine klinische Prognose. Sie gilt nur, solange das jüngste Muster anhält; das schattierte Band zeigt, wie schnell diese Sicherheit nachlässt." + "method": "Eine Ausgleichsgerade (kleinste Quadrate), an deine jüngsten Tageswerte angepasst und über einen kurzen Zeitraum fortgeschrieben, mit dem klassischen Vorhersageintervall, das umso breiter wird, je weiter es reicht. Wird nur berechnet, wenn der jüngste Trend deutlich genug und deine Datenreihe lang und aktuell genug ist; sonst wird keine Linie gezeichnet.", + "caveat": "Eine Fortschreibung deines eigenen jüngsten Trends, keine Vorhersage, kein Ziel und keine klinische Prognose. Sie gilt nur, solange das jüngste Muster anhält; das schattierte Band zeigt, wie schnell diese Sicherheit nachlässt." }, "SAME_TIME_BASELINE": { "method": "Dein heutiger laufender Gesamtwert im Vergleich zum Median derselben Ortszeit-Stunde aus deinen letzten vier Wochen, mit einem robusten Median-±-MAD-Band darum. Nur Tage mit ausreichend aufgezeichneten Details zählen, und es wird kein Vergleich gezeigt, bevor mindestens vierzehn davon vorliegen.", - "caveat": "Ein Vergleich mit deiner eigenen jüngeren Vergangenheit, kein Ziel. Er sagt, wo der heutige Tag zu dieser Stunde gegenüber deinem üblichen Muster steht — und nichts darüber, ob dieses Muster für dich das richtige ist." + "caveat": "Ein Vergleich mit deiner eigenen jüngeren Vergangenheit, kein Ziel. Er sagt, wo der heutige Tag zu dieser Stunde gegenüber deinem üblichen Muster steht, und nichts darüber, ob dieses Muster für dich das richtige ist." } }, "scores": { @@ -4341,7 +4376,9 @@ "recovery": "Erholungs-Score", "recoveryCrossLink": "Erholungs- und Belastungsdaten deines Wearables ansehen", "stress": "Stress", - "strain": "Belastung" + "strain": "Belastung", + "historyTitle": "Verlauf", + "historyWindow": "Letzte {days} Tage" }, "coincident": { "label": "Mehrere Vitalwerte außerhalb der Baseline", @@ -4351,31 +4388,31 @@ "allClear": "Alle deine Vitalwerte liegen heute in ihrem persönlichen Bereich.", "allClearMeta": "{count} deiner Vitalwerte gegen deinen eigenen typischen Bereich geprüft.", "watch": "Ein Vitalwert liegt heute außerhalb seines üblichen Bereichs.", - "watchVital": "Außerhalb des Bereichs: {vital}. Oft nur einmalig — behalte es in den nächsten Tagen im Blick.", + "watchVital": "Außerhalb des Bereichs: {vital}. Oft nur einmalig. Behalte es in den nächsten Tagen im Blick.", "firedHeadline": "{count} deiner Vitalwerte liegen heute außerhalb ihres persönlichen Bereichs.", - "factors": "Mögliche Faktoren — nie eine Ursache: ein hartes Training, schlechter Schlaf, Alkohol, Höhe, Stress oder Krankheit. Das ist ein Hinweis aus deinen eigenen Baselines, keine Diagnose.", - "building": "Deine persönlichen Baselines entstehen — erfasse ein paar Tage mehr, dann erscheint dies.", + "factors": "Mögliche Faktoren (nie eine Ursache): ein hartes Training, schlechter Schlaf, Alkohol, Höhe, Stress oder Krankheit. Das ist ein Hinweis aus deinen eigenen Baselines, keine Diagnose.", + "building": "Deine persönlichen Baselines entstehen. Erfasse ein paar Tage mehr, dann erscheint dies.", "illnessExplained": "Diese Abweichungen hängen wahrscheinlich mit der erfassten Erkrankung zusammen." }, "trajectory": { "cardTitle": "Kurzfrist-Projektion", - "headline": "Wenn dieses Muster anhält — die nächsten {days} Tage", + "headline": "Wenn dieses Muster anhält: die nächsten {days} Tage", "range": "Projiziert auf rund {low}–{high} {unit} in {days} Tagen.", "caveat": "Eine Fortschreibung deines eigenen jüngsten Trends, keine Vorhersage. Das schattierte Band wird breiter und zeigt, wie schnell die Unsicherheit wächst.", - "insufficient": "Noch kein klar genuger Trend für eine Projektion — erfasse weiter, dann erscheint eine Projektion, sobald das jüngste Muster deutlich genug ist.", + "insufficient": "Noch kein klar genuger Trend für eine Projektion. Erfasse weiter, dann erscheint eine Projektion, sobald das jüngste Muster deutlich genug ist.", "daysNeeded": "Noch etwa {count} Tage Erfassung nötig, bis eine Projektion erscheinen kann." }, "sameTime": { "sectionTitle": "Heute bisher", "sectionSubtitle": "Deine laufenden Summen im Vergleich zu dem, was zu dieser Stunde für dich üblich ist", - "learning": "Lernt deinen üblichen Tag — {count} von {target} Tagen", + "learning": "Lernt deinen üblichen Tag: {count} von {target} Tagen", "framing": "Üblich bis {time}: {typical} {unit}", "comparisonLabel": "Dein üblicher Tag" } }, "cardioFitness": { "title": "Fitness", - "description": "Deine VO₂max im Zeitverlauf — das wichtigste Maß für die Ausdauerfitness.", + "description": "Deine VO₂max im Zeitverlauf, das wichtigste Maß für die Ausdauerfitness.", "chartTitle": "VO₂max (mL/(kg·min))", "emptyState": { "title": "Noch keine Fitnessdaten", @@ -4455,7 +4492,7 @@ "navBreathingDisturbances": "Atmung", "rhythmEvents": { "sectionTitle": "Gesundheitshinweise des Geräts", - "sectionIntro": "Hinweise, die deine Uhr eigenständig gesendet hat. HealthLog zeigt diese nur an — es erkennt, bestätigt oder interpretiert sie nicht.", + "sectionIntro": "Hinweise, die deine Uhr eigenständig gesendet hat. HealthLog zeigt diese nur an. Es erkennt, bestätigt oder interpretiert sie nicht.", "loadError": "Die Gerätehinweise konnten nicht geladen werden.", "disclaimer": "Dies sind Hinweise, die dein Gerät mit seiner eigenen integrierten Erkennung erzeugt hat. Sie werden hier nur zu deiner Information angezeigt. Dies ist keine medizinische Bewertung durch HealthLog, und HealthLog stellt keine Diagnose. Wenn dich ein Hinweis beunruhigt, sprich mit deiner Ärztin oder deinem Arzt.", "event": { @@ -4500,13 +4537,13 @@ "detail": "Ausgeblendete Detailseiten verschwinden auch aus der oberen Navigation." }, "customize": "Insights anpassen", - "regenerateRateLimited": "Aktualisierungslimit erreicht — bitte später erneut versuchen.", - "regenerateUnavailable": "Keine neue Analyse erhalten — bitte später erneut versuchen.", - "regenerateError": "Aktualisierung läuft noch — bitte gleich erneut versuchen.", + "regenerateRateLimited": "Aktualisierungslimit erreicht. Bitte später erneut versuchen.", + "regenerateUnavailable": "Keine neue Analyse erhalten. Bitte später erneut versuchen.", + "regenerateError": "Aktualisierung läuft noch. Bitte gleich erneut versuchen.", "regenerateSettling": "Die Einschätzung wird noch erstellt", "regenerateSettleFailed": "Die Aktualisierung konnte nicht abgeschlossen werden. Bitte später erneut versuchen.", "deviceScore": { - "learning": "Deine Basislinie wird noch gelernt — ein paar weitere Nächte vervollständigen den Verlauf.", + "learning": "Deine Basislinie wird noch gelernt. Ein paar weitere Nächte vervollständigen den Verlauf.", "average": "30-Tage-Durchschnitt {value}", "unitScore": "Punkte", "unitMinutes": "Min.", @@ -4519,14 +4556,14 @@ }, "recovery": { "title": "Erholung", - "description": "Wie gut sich dein Körper erholt hat — die Erholungs-, Belastungs- und Load-Signale, die dein Wearable über Nacht aufzeichnet.", + "description": "Wie gut sich dein Körper erholt hat: die Erholungs-, Belastungs- und Load-Signale, die dein Wearable über Nacht aufzeichnet.", "scoreCrossLink": "Den zusammengesetzten Erholungs-Score ansehen", "emptyTitle": "Noch keine Erholungsdaten", "empty": "Noch keine Erholungsdaten. Verbinde ein WHOOP-, Polar- oder Oura-Konto, dann füllen sich diese Werte, sobald dein Gerät synchronisiert.", "block": { - "ansChargeExplainer": "Wie aufgeladen dein autonomes Nervensystem in den Tag startet — ein Recharge-Wert aus deinen nächtlichen Herz- und Atemsignalen.", + "ansChargeExplainer": "Wie aufgeladen dein autonomes Nervensystem in den Tag startet, ein Recharge-Wert aus deinen nächtlichen Herz- und Atemsignalen.", "dayStrainExplainer": "Die kardiovaskuläre Tagesbelastung auf der Belastungsskala deines Geräts. Lies sie im Verhältnis zu deiner Erholung, nicht als Ziel für sich.", - "workoutStrainExplainer": "Wie fordernd ein einzelnes Training auf derselben Belastungsskala war — die kardiovaskuläre Last dieser Einheit.", + "workoutStrainExplainer": "Wie fordernd ein einzelnes Training auf derselben Belastungsskala war: die kardiovaskuläre Last dieser Einheit.", "cardioLoadExplainer": "Ein Trainingslast-Näherungswert, der die akut aufgebaute kardiovaskuläre Belastung abbildet. Vergleiche ihn mit deinen eigenen letzten Tagen.", "averageHeartRateExplainer": "Deine durchschnittliche Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.", "maxHeartRateExplainer": "Deine höchste Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.", @@ -4538,8 +4575,8 @@ "subtitle": "Nächtliche Abweichung von deiner Basislinie" }, "resilience": { - "explainer": "Wie gut dein Körper die tägliche Belastung mit der Erholung ausbalanciert — aus deinen nächtlichen Signalen der letzten Tage abgelesen.", - "learning": "Deine Resilienz wird noch erlernt — ein paar Tage mehr festigen das Niveau.", + "explainer": "Wie gut dein Körper die tägliche Belastung mit der Erholung ausbalanciert, aus deinen nächtlichen Signalen der letzten Tage abgelesen.", + "learning": "Deine Resilienz wird noch erlernt. Ein paar Tage mehr festigen das Niveau.", "band": { "limited": "Eingeschränkt", "adequate": "Ausreichend", @@ -4584,7 +4621,7 @@ "nights": "{count} Nächte erfasst", "indexLine": "jüngster Durchschnittsindex {value}", "eventsFlagged": "{count} vom Gerät gemeldete Ereignisse", - "disclaimer": "Dies ist ein Screening-Signal, keine Diagnose — bitte mit einer Ärztin oder einem Arzt besprechen." + "disclaimer": "Dies ist ein Screening-Signal, keine Diagnose. Bitte mit einer Ärztin oder einem Arzt besprechen." }, "labsChanges": { "sectionTitle": "Seit deinem letzten Laborbefund", @@ -4600,14 +4637,14 @@ "navWaistToHeight": "Taille/Größe", "gripStrength": { "title": "Griffkraft", - "description": "Maximale Handgriffkraft — ein robuster Stellvertreter für die Ganzkörperkraft. Unter dem geschlechtsspezifischen Grenzwert (Männer 27 kg, Frauen 16 kg) deutet sie auf eine mögliche Sarkopenie hin; dein eigener Verlauf führt die Einschätzung.", + "description": "Maximale Handgriffkraft, ein robuster Stellvertreter für die Ganzkörperkraft. Unter dem geschlechtsspezifischen Grenzwert (Männer 27 kg, Frauen 16 kg) deutet sie auf eine mögliche Sarkopenie hin; dein eigener Verlauf führt die Einschätzung.", "chartTitle": "Griffkraft", "emptyState": { "title": "Noch keine Griffkraft", - "description": "Erfasse einen Wert mit einem Handdynamometer — notiere den besten von mehreren Versuchen — und der Verlauf erscheint hier.", + "description": "Erfasse einen Wert mit einem Handdynamometer (notiere den besten von mehreren Versuchen), und der Verlauf erscheint hier.", "cta": "Griffkraft erfassen" }, - "coachPrefill": "Ich habe noch keine Griffkraft erfasst — was sagt sie über meine Muskelgesundheit aus, und wie messe und verbessere ich sie?" + "coachPrefill": "Ich habe noch keine Griffkraft erfasst. Was sagt sie über meine Muskelgesundheit aus, und wie messe und verbessere ich sie?" }, "pain": { "title": "Schmerz (0–10)", @@ -4618,29 +4655,29 @@ "description": "Erfasse auf der Skala 0–10, wie sich dein Schmerz gerade anfühlt, und der Verlauf erscheint hier.", "cta": "Schmerzwert erfassen" }, - "coachPrefill": "Ich habe noch keine Schmerzwerte erfasst — wie sollte ich Schmerz auf der Skala 0–10 verfolgen, und was gilt als bedeutsame Veränderung?" + "coachPrefill": "Ich habe noch keine Schmerzwerte erfasst. Wie sollte ich Schmerz auf der Skala 0–10 verfolgen, und was gilt als bedeutsame Veränderung?" }, "waist": { "title": "Taillenumfang", - "description": "Taillenumfang in Zentimetern — ein Marker für Bauchfett. Der WHO-Grenzwert für erhöhtes Risiko ist geschlechtsspezifisch (Männer 94 cm, Frauen 80 cm); dein eigener Verlauf führt die Einschätzung.", + "description": "Taillenumfang in Zentimetern, ein Marker für Bauchfett. Der WHO-Grenzwert für erhöhtes Risiko ist geschlechtsspezifisch (Männer 94 cm, Frauen 80 cm); dein eigener Verlauf führt die Einschätzung.", "chartTitle": "Taillenumfang", "emptyState": { "title": "Noch keine Taillenwerte", "description": "Miss deine Taille mittig zwischen der untersten Rippe und dem Hüftknochen am Ende einer normalen Ausatmung und erfasse den Wert hier.", "cta": "Taillenumfang erfassen" }, - "coachPrefill": "Ich habe noch keine Taillenwerte erfasst — wie messe ich meine Taille richtig, und was bedeutet der Wert für meine Gesundheit?" + "coachPrefill": "Ich habe noch keine Taillenwerte erfasst. Wie messe ich meine Taille richtig, und was bedeutet der Wert für meine Gesundheit?" }, "waistToHeight": { "title": "Taille-zu-Größe-Verhältnis", - "description": "Deine Taille geteilt durch deine Größe — die ethnienunabhängige Einzelzahl. Sie unter 0,5 zu halten (Taille unter der halben Körpergröße) ist das einfache Ziel.", + "description": "Deine Taille geteilt durch deine Größe, die ethnienunabhängige Einzelzahl. Sie unter 0,5 zu halten (Taille unter der halben Körpergröße) ist das einfache Ziel.", "chartTitle": "Taille-zu-Größe-Verhältnis", "emptyState": { "title": "Noch kein Taille-zu-Größe-Verhältnis", "description": "Erfasse ein Taille-zu-Größe-Verhältnis, und der Verlauf erscheint hier. Faustregel: Halte deine Taille unter der halben Körpergröße.", "cta": "Taille-zu-Größe-Verhältnis erfassen" }, - "coachPrefill": "Ich habe noch kein Taille-zu-Größe-Verhältnis erfasst — warum ist es eine nützliche Zahl, und wie halte ich es unter 0,5?" + "coachPrefill": "Ich habe noch kein Taille-zu-Größe-Verhältnis erfasst. Warum ist es eine nützliche Zahl, und wie halte ich es unter 0,5?" }, "ecg": { "sectionTitle": "EKG-Aufzeichnungen", @@ -4681,9 +4718,9 @@ "emptyCta": "Gerät verbinden" }, "intradayPulse": { - "title": "Der Verlauf deines Tages", - "caption": "Dein Puls über den Tag, gemittelt in 10-Minuten-Schritten.", - "hourlyNote": "Stündlicher Durchschnitt für diesen Tag — aktuelle Tage zeigen das volle 10-Minuten-Detail.", + "title": "Puls über den Tag", + "caption": "Gemittelt in 10-Minuten-Schritten.", + "hourlyNote": "Stündlicher Durchschnitt für diesen Tag; aktuelle Tage zeigen das volle 10-Minuten-Detail.", "empty": "Für diesen Tag noch nicht genug Pulswerte, um den Tagesverlauf zu zeigen.", "coverageOne": "Basierend auf 1 Messung über {hours}h.", "coverageMany": "Basierend auf {count} Messungen über {hours}h.", @@ -4692,10 +4729,10 @@ "previousDay": "Vorheriger Tag", "nextDay": "Nächster Tag", "tension": { - "morning": "Eine anhaltend erhöhte Ruhephase heute Vormittag — möglicherweise etwas Anspannung.", - "afternoon": "Eine anhaltend erhöhte Ruhephase heute Nachmittag — möglicherweise etwas Anspannung.", - "evening": "Eine anhaltend erhöhte Ruhephase heute Abend — möglicherweise etwas Anspannung.", - "night": "Eine anhaltend erhöhte Ruhephase in der Nacht — möglicherweise etwas Anspannung." + "morning": "Eine anhaltend erhöhte Ruhephase heute Vormittag, möglicherweise etwas Anspannung.", + "afternoon": "Eine anhaltend erhöhte Ruhephase heute Nachmittag, möglicherweise etwas Anspannung.", + "evening": "Eine anhaltend erhöhte Ruhephase heute Abend, möglicherweise etwas Anspannung.", + "night": "Eine anhaltend erhöhte Ruhephase in der Nacht, möglicherweise etwas Anspannung." } }, "navEcg": "EKG", @@ -4706,6 +4743,19 @@ "settingsBody": "HealthLog funktioniert vollständig ohne KI. Verbinde unten einen Anbieter, wenn du ein tägliches Briefing, kurze Notizen zu deinen Werten und den Coach möchtest.", "action": "KI einrichten", "dismiss": "Nicht jetzt" + }, + "bmiHeight": { + "coachPrefill": "Ich habe meine Körpergröße noch nicht eingetragen. Warum ist der BMI wichtig, und was sollte ich wissen, bevor ich sie eintrage?" + }, + "medicationsEmpty": { + "coachPrefill": "Ich habe noch keine Medikamente angelegt. Was sollte ich wissen, bevor ich hier anfange, meine Therapietreue zu verfolgen?" + }, + "scoreHistory": { + "healthTitle": "Verlauf des Gesundheitsscores", + "healthDescription": "Dein Gesundheitsscore Tag für Tag, so wie er an jedem Tag angezeigt wurde.", + "emptyDescription": "In diesem Zeitraum wurde noch kein Score erfasst.", + "usualRange": "Schattiert: dein üblicher Bereich, {lo} bis {hi}.", + "seam": "Die Linie bricht dort ab, wo sich geändert hat, was in den Score einfließt." } }, "thresholds": { @@ -4735,14 +4785,15 @@ "metricBodyFat": "Körperfett", "metricSleep": "Schlafdauer", "metricSteps": "Schritte/Tag", - "metricGlucoseFasting": "Glukose — nüchtern", - "metricGlucosePostprandial": "Glukose — postprandial", - "metricGlucoseRandom": "Glukose — beliebig", - "metricGlucoseBedtime": "Glukose — vor dem Schlafen", + "metricGlucoseFasting": "Glukose, nüchtern", + "metricGlucosePostprandial": "Glukose, postprandial", + "metricGlucoseRandom": "Glukose, beliebig", + "metricGlucoseBedtime": "Glukose, vor dem Schlafen", "metricBodyWater": "Körperwasser", "metricBoneMass": "Knochenmasse", "metricOxygenSaturation": "Sauerstoffsättigung (SpO₂)", - "unsetExplanation": "Kein eigener Wert — berechneter Default aktiv." + "unsetExplanation": "Kein eigener Wert, berechneter Default aktiv.", + "cardTitle": "Bereiche je Messgröße" }, "onboarding": { "errorGeneric": "Beim Abschluss des Setups ist etwas schiefgelaufen. Bitte versuche es erneut.", @@ -4755,11 +4806,11 @@ "steps": { "dashboardOverview": { "title": "Dein Dashboard", - "body": "Das Dashboard bündelt alle Module auf einen Blick. Halte eine Kachel lange gedrückt, um sie neu anzuordnen oder auszublenden (Einstellungen → Dashboard-Layout). Jede Kachel ist nur so aussagekräftig wie die Daten dahinter — die folgenden Module füllen sie." + "body": "Das Dashboard bündelt alle Module auf einen Blick. Halte eine Kachel lange gedrückt, um sie neu anzuordnen oder auszublenden (Einstellungen → Dashboard-Layout). Jede Kachel ist nur so aussagekräftig wie die Daten dahinter. Die folgenden Module füllen sie." }, "quickAdd": { "title": "In Sekunden erfasst", - "body": "Über „+ Hinzufügen“ trägst du jeden Messwert in Sekunden ein — Gewicht, Blutdruck, Puls, Glukose. Versteckte Stärke: Werte lassen sich nachträglich datieren. Alles, was du hier einträgst, fließt in Trends, Health-Score und die KI-Insights." + "body": "Über „+ Hinzufügen“ trägst du jeden Messwert in Sekunden ein: Gewicht, Blutdruck, Puls, Glukose. Versteckte Stärke: Werte lassen sich nachträglich datieren. Alles, was du hier einträgst, fließt in Trends, Health-Score und die KI-Insights." }, "measurements": { "title": "Messwerte & Zielbereiche", @@ -4771,7 +4822,7 @@ }, "labs": { "title": "Laborwerte", - "body": "Labore erfasst Biomarker mit Referenzbereichen — Blutbild, Cholesterin, HbA1c. Stärke: Import und FHIR-Export. Auffällige Laborwerte tauchen als Belege in den Insights und im Arztbericht wieder auf." + "body": "Labore erfasst Biomarker mit Referenzbereichen: Blutbild, Cholesterin, HbA1c. Stärke: Import und FHIR-Export. Auffällige Laborwerte tauchen als Belege in den Insights und im Arztbericht wieder auf." }, "illness": { "title": "Krankheits-Journal", @@ -4787,31 +4838,31 @@ }, "mood": { "title": "Stimmung", - "body": "Stimmung erfasst dein Befinden und Tags in zwei Sekunden. Stärke: eigene Tags. Stimmungsverläufe korrelieren mit Schlaf, Medikamenten und Zyklus — der Coach erkennt Muster." + "body": "Stimmung erfasst dein Befinden und Tags in zwei Sekunden. Stärke: eigene Tags. Stimmungsverläufe korrelieren mit Schlaf, Medikamenten und Zyklus, der Coach erkennt Muster." }, "insights": { "title": "Insights", - "body": "Insights ist das Herzstück: belegbasierte, dynamische Auswertungen statt generischer Tipps — jede Aussage verweist auf deine echten Daten. Versteckt: das Layout ist anpassbar. Insights zieht alles zusammen, was die Module oben liefern." + "body": "Insights ist das Herzstück: belegbasierte, dynamische Auswertungen statt generischer Tipps. Jede Aussage verweist auf deine echten Daten. Versteckt: das Layout ist anpassbar. Insights zieht alles zusammen, was die Module oben liefern." }, "coach": { "title": "Coach", - "body": "Der Coach beantwortet Fragen zu deinen Daten im Dialog und nutzt Anamnese, Medikamente und Laborwerte als Kontext. Den KI-Anbieter wählst du selbst (eigener Schlüssel oder lokal). Er rechnet nichts neu — er liest dieselben Zahlen wie das Dashboard." + "body": "Der Coach beantwortet Fragen zu deinen Daten im Dialog und nutzt Anamnese, Medikamente und Laborwerte als Kontext. Den KI-Anbieter wählst du selbst (eigener Schlüssel oder lokal). Er rechnet nichts neu, er liest dieselben Zahlen wie das Dashboard." }, "integrations": { "title": "Integrationen", - "body": "Integrationen synchronisieren Withings, WHOOP, Polar, Oura und Apple Health automatisch. So füllen sich Gewicht, Schlaf und Erholung ohne manuelles Tippen — und nähren wiederum Insights und Coach." + "body": "Integrationen synchronisieren Withings, WHOOP, Polar, Oura und Apple Health automatisch. So füllen sich Gewicht, Schlaf und Erholung ohne manuelles Tippen und nähren wiederum Insights und Coach." }, "export": { "title": "Arztbericht", - "body": "Der Arztbericht bündelt Messwerte, Medikamente, Laborwerte und Vorsorge als PDF oder FHIR für den Termin. Hier laufen alle Module zusammen — ein vollständiger Bericht entsteht nur bei gepflegten Daten." + "body": "Der Arztbericht bündelt Messwerte, Medikamente, Laborwerte und Vorsorge als PDF oder FHIR für den Termin. Hier laufen alle Module zusammen. Ein vollständiger Bericht entsteht nur bei gepflegten Daten." }, "achievements": { "title": "Erfolge", - "body": "Erfolge belohnen Konsistenz — Streaks und Meilensteine. Ein kleiner Motivations-Loop, der das Dranbleiben leichter macht." + "body": "Erfolge belohnen Konsistenz: Streaks und Meilensteine. Ein kleiner Motivations-Loop, der das Dranbleiben leichter macht." }, "wrapUp": { "title": "Das war die Tour", - "body": "Diese Führung läuft einmalig — sie öffnet sich nicht von selbst erneut. Alles Gezeigte bleibt erhalten, und du kannst Funktionen jederzeit unter Einstellungen → Module ein- oder ausschalten." + "body": "Diese Führung läuft einmalig und öffnet sich nicht von selbst erneut. Alles Gezeigte bleibt erhalten, und du kannst Funktionen jederzeit unter Einstellungen → Module ein- oder ausschalten." } } }, @@ -4842,7 +4893,7 @@ "title": "Alles bereit", "body": "Willkommen an Bord. Dein Dashboard wartet.", "returnCta": "Zum Dashboard", - "learning": "Manche Einblicke — Glukose, Schlafrhythmus, Blutdrucktrends, Zyklus — schärfen sich in den ersten ein bis zwei Wochen, sobald sich deine Daten ansammeln. Leere Kacheln heißen jetzt nur, dass wir deine Basislinie noch lernen.", + "learning": "Manche Einblicke (Glukose, Schlafrhythmus, Blutdrucktrends, Zyklus) schärfen sich in den ersten ein bis zwei Wochen, sobald sich deine Daten ansammeln. Leere Kacheln heißen jetzt nur, dass wir deine Basislinie noch lernen.", "connectCta": "Gerät verbinden", "logCta": "Ersten Wert eintragen", "importCta": "Bestehende Daten importieren", @@ -4859,33 +4910,33 @@ "anamnesis": { "title": "Zu deiner Gesundheit (optional)", "subtitle": "Etwas Kontext, damit HealthLog deine Werte fairer einordnet.", - "intro": "Teile so viel oder so wenig, wie du möchtest — jedes Feld ist optional und jederzeit in den Einstellungen änderbar.", + "intro": "Teile so viel oder so wenig, wie du möchtest. Jedes Feld ist optional und jederzeit in den Einstellungen änderbar.", "conditionsLabel": "Bestehende Erkrankungen", "conditionsPlaceholder": "z. B. Bluthochdruck, Typ-2-Diabetes", "conditionsHint": "Hilft HealthLog, deine Werte im Kontext einzuordnen, statt Erwartbares zu melden.", "allergiesLabel": "Allergien oder Unverträglichkeiten", "allergiesPlaceholder": "z. B. Penicillin, Laktose", - "privacyNote": "Verschlüsselt gespeichert und nur für besseren Kontext genutzt. Medikamente fragen wir hier nicht ab — die kannst du jederzeit auf der Medikamenten-Seite ergänzen." + "privacyNote": "Verschlüsselt gespeichert und nur für besseren Kontext genutzt. Medikamente fragen wir hier nicht ab, die kannst du jederzeit auf der Medikamenten-Seite ergänzen." }, "ai": { "panelTitle": "So sehen Insights aus", - "panelIntro": "Coach, das tägliche Briefing und die Einschätzungen je Messwert übersetzen deine eigenen Zahlen in Klartext. So sieht das aus — bevor du irgendetwas verbindest.", + "panelIntro": "Coach, das tägliche Briefing und die Einschätzungen je Messwert übersetzen deine eigenen Zahlen in Klartext. So sieht das aus, bevor du irgendetwas verbindest.", "showSample": "Beispiel-Briefing ansehen", "sampleTitle": "Tägliches Briefing", "sampleTag": "Beispiel", "sampleAriaLabel": "Beispielhaftes tägliches Briefing", - "sampleBody": "Gewicht in zwei Wochen um 1,2 kg gesunken. Ruhepuls stabil bei 58 bpm. Blutdruck an 6 von 7 Tagen im Zielbereich — der einzige hohe Wert folgte auf ein spätes, salziges Abendessen.", - "sampleCaption": "Nur ein Beispiel — nicht deine eigenen Daten. So liest sich ein Briefing, sobald Insights aktiv sind.", + "sampleBody": "Gewicht in zwei Wochen um 1,2 kg gesunken. Ruhepuls stabil bei 58 bpm. Blutdruck an 6 von 7 Tagen im Zielbereich; der einzige hohe Wert folgte auf ein spätes, salziges Abendessen.", + "sampleCaption": "Nur ein Beispiel, nicht deine eigenen Daten. So liest sich ein Briefing, sobald Insights aktiv sind.", "ladderTitle": "Drei Wege, es zu aktivieren", - "ladderLocalTitle": "Lokales Modell — KI-Anfragen bleiben in deinem Netzwerk", - "ladderLocalBody": "Richte HealthLog auf Ollama oder LM Studio auf deinem eigenen Rechner aus. Kostenlos und vollständig privat — der ruhige Standard.", + "ladderLocalTitle": "Lokales Modell: KI-Anfragen bleiben in deinem Netzwerk", + "ladderLocalBody": "Richte HealthLog auf Ollama oder LM Studio auf deinem eigenen Rechner aus. Kostenlos und vollständig privat, der ruhige Standard.", "ladderByokTitle": "Eigenen Schlüssel verwenden", "ladderByokBody": "Nutze deinen eigenen OpenAI- oder Anthropic-Schlüssel. Er läuft über dein Konto und deine Abrechnung, und die API-Stufe hält deine Daten standardmäßig aus dem Training heraus.", "ladderOauthTitle": "Mit einem Abo anmelden", - "ladderOauthBody": "Verbinde ein ChatGPT-Konto — keine Kosten pro Token, aber die Bedingungen der Verbraucherstufe können Inhalte fürs Training nutzen. Deshalb eine bewusste Wahl, nie der Standard für deine Gesundheitsdaten.", - "keylessLine": "HealthLog ist auch ohne KI vollständig nutzbar. Deine Kacheln, Diagramme, Trends und Exporte funktionieren alle — KI ergänzt nur den Klartext obendrauf.", + "ladderOauthBody": "Verbinde ein ChatGPT-Konto: keine Kosten pro Token, aber die Bedingungen der Verbraucherstufe können Inhalte fürs Training nutzen. Deshalb eine bewusste Wahl, nie der Standard für deine Gesundheitsdaten.", + "keylessLine": "HealthLog ist auch ohne KI vollständig nutzbar. Deine Kacheln, Diagramme, Trends und Exporte funktionieren alle. KI ergänzt nur den Klartext obendrauf.", "setupCta": "Einrichten (etwa eine Minute)", - "sharedKeyNote": "Diese Instanz stellt bereits einen gemeinsamen KI-Anbieter bereit, deshalb funktionieren Insights für dich sofort — keine Einrichtung nötig.", + "sharedKeyNote": "Diese Instanz stellt bereits einen gemeinsamen KI-Anbieter bereit, deshalb funktionieren Insights für dich sofort, keine Einrichtung nötig.", "offer": { "title": "Die Zusammenfassung dieser Instanz ist bereit.", "body": "Der Betreiber stellt einen Anbieter bereit, der zuletzt innerhalb der letzten 24 Stunden funktioniert hat. Mit einem Tipp schaltest du den Klartext ein; deine Daten gehen dann an diesen einen Anbieter, an niemanden sonst.", @@ -5102,7 +5153,7 @@ "profileDescription": "Größe, Geburtsdatum und Geschlecht schalten personalisierte Zielwerte frei.", "profileCta": "Profil öffnen", "measurementTitle": "Erste Messung hinzufügen", - "measurementDescription": "Ein Messpunkt reicht, um die Kachel zu aktivieren — der Trend zeichnet sich ab dem 5. Eintrag.", + "measurementDescription": "Ein Messpunkt reicht, um die Kachel zu aktivieren. Der Trend zeichnet sich ab dem 5. Eintrag.", "measurementCta": "Messung hinzufügen", "medicationTitle": "Medikament hinzufügen", "medicationDescription": "Verfolge die Therapietreue und erhalte Erinnerungen genau dann, wenn du sie brauchst.", @@ -5111,7 +5162,7 @@ "dataSourceDescription": "Gewicht, Blutdruck, Schlaf und mehr automatisch aus Withings, WHOOP, Oura, Polar, Nightscout, Fitbit oder Apple Health.", "dataSourceCta": "Verbinden", "notificationsTitle": "Benachrichtigungen einrichten", - "notificationsDescription": "Wähle, wie HealthLog dich erinnert — Telegram, ntfy oder Web Push.", + "notificationsDescription": "Wähle, wie HealthLog dich erinnert: Telegram, ntfy oder Web Push.", "notificationsCta": "Benachrichtigungen öffnen", "insightsTitle": "Insights aktivieren (optional)", "insightsDescription": "Füge ein lokales Modell oder deinen eigenen KI-Schlüssel hinzu, um das tägliche Briefing und Coach freizuschalten.", @@ -5259,12 +5310,10 @@ "sections": { "account": { "title": "Konto", - "description": "Profil, Passwort, Passkeys und deine Onboarding-Tour.", "subtitle": "Dein Profil, deine Identität und deine Anmeldedaten." }, "integrations": { "title": "Integrationen", - "description": "Verbundene Dienste, Zustellkanäle und die Quellen-Priorität an einem Ort.", "subtitle": "Datenquellen und Geräte verbinden und verwalten.", "connectionsHeading": "Verbindungen", "channelsHeading": "Zustellkanäle", @@ -5273,16 +5322,14 @@ }, "notifications": { "title": "Benachrichtigungen", - "description": "Alle Erinnerungen an einem Ort — was wann erinnert wird und wohin es zugestellt wird.", "subtitle": "Wähle, welche Erinnerungen du erhältst und wohin sie zugestellt werden.", "remindersHeading": "Erinnerungen", "matrixLink": "Benachrichtigungen je Kanal", "channelsLink": "Zustellkanäle" }, "layout": { - "title": "Darstellung", - "description": "Lege fest, wie Dashboard, Auswertungen und deine Tracking-Module dargestellt und angeordnet sind.", - "backToHub": "Zurück zu Darstellung", + "title": "Layout", + "backToHub": "Zurück zu Layout", "dashboard": { "title": "Dashboard", "description": "Kachel-Layout und Reihenfolge." @@ -5291,7 +5338,7 @@ "title": "Insights", "description": "Reihenfolge der Übersicht und der Navigations-Pills." }, - "subtitle": "Lege fest, wie Dashboard, Auswertungen und Tracking-Module dargestellt werden.", + "subtitle": "Lege fest, was Dashboard, Auswertungen und Tracking-Module zeigen und in welcher Reihenfolge.", "medications": { "title": "Medikamente", "description": "Listenansicht und manuelle Reihenfolge." @@ -5311,6 +5358,10 @@ "mood": { "title": "Stimmungs-Tags", "description": "Gruppen, Tags und archivierte Tags der Stimmungs-Auswahl." + }, + "documents": { + "title": "Dokumente", + "description": "Ansicht und Anordnung der Monate." } }, "dashboard": { @@ -5318,15 +5369,13 @@ }, "thresholds": { "title": "Zielwerte", - "description": "Pro Metrik dein eigener Zielbereich für gesunde Werte.", "subtitle": "Lege deine persönlichen Zielbereiche je Messgröße fest." }, "sources": { "title": "Quellen-Priorität", - "description": "Lege fest, welche Quelle führt, wenn mehrere am selben Tag dieselbe Metrik liefern.", "integrationsHint": "Neue Datenquellen verbindest oder trennst du auf der Integrationen-Seite.", "integrationsHintLink": "Integrationen öffnen", - "cardTitle": "Quellen-Priorität pro Metrik", + "cardTitle": "Rangfolge je Messgröße", "help": "Liefern mehrere Quellen denselben Wert am selben Tag, zählt die oberste Quelle als kanonisch.", "resetDefaults": "Auf Standard zurücksetzen", "resetDefaultsTitle": "Quellen-Reihenfolge zurücksetzen?", @@ -5339,7 +5388,7 @@ "resetSuccess": "Quellen-Priorität zurückgesetzt", "perMetricToggle": "Pro Metrik anpassen ({count})", "perMetricHelp": "Metriken mit individueller Reihenfolge. Über diese Liste lässt sich eine einzelne Metrik mit einem Klick auf den Standard zurücksetzen, ohne die anderen anzufassen.", - "perMetricEmpty": "Keine individuellen Anpassungen — jede Metrik nutzt die globale Reihenfolge oben.", + "perMetricEmpty": "Keine individuellen Anpassungen. Jede Metrik nutzt die globale Reihenfolge oben.", "resetMetric": "Zurücksetzen", "deviceTypeToggle": "Geräte-Typ-Reihenfolge ({count})", "deviceTypeHelp": "Innerhalb einer Quelle entscheidet die Geräte-Typ-Reihenfolge. Wenn Apple Watch und iPhone beide Schritte aufzeichnen, zählt der höher gereihte Geräte-Typ.", @@ -5364,7 +5413,8 @@ "respiratoryRate": "Atemfrequenz", "recovery": "Erholung", "stress": "Stress", - "sleepDebt": "Schlafdefizit" + "sleepDebt": "Schlafdefizit", + "hrvRmssd": "HRV (RMSSD)" }, "sourceLabels": { "WITHINGS": "Withings", @@ -5381,7 +5431,8 @@ "MCP": "MCP", "GOOGLE_HEALTH": "Google Health", "STRAVA": "Strava", - "EXTERNAL": "Externes Gerät" + "EXTERNAL": "Externes Gerät", + "HEALTH_CONNECT": "Health Connect" }, "deviceLabels": { "watch": "Uhr", @@ -5401,28 +5452,25 @@ "daysWithData": "{count} von {days} Tagen", "moduleHint": "Die Synchronisierung steuert das Modul „Nährstoffzufuhr“.", "moduleHintLink": "Module verwalten", - "detail": "Nur lesend — die Quell-App besitzt die Werte." + "detail": "Nur lesend, die Quell-App besitzt die Werte." }, - "helpDetail": "Die übrigen bleiben für die Audit-Spur gespeichert." + "helpDetail": "Die übrigen bleiben für die Audit-Spur gespeichert. Aufgeführt sind nur Quellen, die du verbunden hast oder von denen Daten vorliegen.", + "noneInUse": "Noch keine Quelle zum Sortieren. Verbinde eine unter Integrationen oder trage einen Wert ein." }, "ai": { "title": "KI-Anbieter", - "description": "Provider, Modell und API-Key für die Auswertungen — dazu dein „Über mich“-Kontext.", "subtitle": "Wähle deinen KI-Anbieter und hinterlege deinen eigenen Schlüssel." }, "api": { "title": "API & Tokens", - "description": "Bearer-Tokens für eigene Skripte und Apps — erfasse Messwerte oder Einnahmen von überall.", "subtitle": "API-Endpunkte und Zugriffstoken verwalten." }, "advanced": { "title": "Erweitert", - "description": "Der Notausgang: alle Gesundheitsdaten unwiderruflich löschen oder das Konto auflösen.", "subtitle": "Daten zurücksetzen und Konto löschen." }, "export": { "title": "Export & Import", - "description": "Lade deine Daten als CSV oder vollständiges JSON-Backup herunter — oder importiere aus Apple Health, JSON oder CSV. Den Arztbericht findest du jetzt unter Gesundheitsakte.", "otherOptionsHeading": "Weitere Export-Optionen", "downloadFailed": "Der Export konnte nicht heruntergeladen werden. Bitte versuche es erneut.", "actions": { @@ -5449,12 +5497,12 @@ "fullBackup": { "title": "Voll-Backup", "description": "Deine gesamte Akte in einer JSON-Datei.", - "scopeNote": "Dokumentmetadaten und die KI-Zusammenfassung, falls vorhanden, sind als Klartext enthalten; die Originaldateien der Dokumente sowie GPS-Routen und Einzelmesswerte von Workouts sind es nicht — lade diese separat aus dem Dokumententresor bzw. dem Workout-Export herunter.", + "scopeNote": "Dokumentmetadaten und die KI-Zusammenfassung, falls vorhanden, sind als Klartext enthalten; die Originaldateien der Dokumente sowie GPS-Routen und Einzelmesswerte von Workouts sind es nicht. Lade diese separat aus dem Dokumententresor bzw. dem Workout-Export herunter.", "encryptToggle": "Mit einer Passphrase verschlüsseln", "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.", + "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.", "contents": "Deine Messwerte, Medikamente, Stimmung, Zyklus, Laborwerte, Krankheitsepisoden, Allergien, Familienanamnese, Nährstoffe, Workouts, dein Gesundheitsprofil und deine eigenen Messgrößen in einer JSON-Datei." } }, @@ -5469,7 +5517,7 @@ "dropHint": "oder zum Auswählen klicken", "fileInputLabel": "Eine Apple-Health-export.zip-Datei auswählen", "choose": "Datei auswählen", - "idempotencyNote": "Ein erneuter Upload desselben Archivs führt zusammen — es entstehen keine Duplikate. Große Archive werden im Hintergrund verarbeitet.", + "idempotencyNote": "Ein erneuter Upload desselben Archivs führt die Daten zusammen, ohne Duplikate. Große Archive werden im Hintergrund verarbeitet.", "uploading": "Wird hochgeladen…", "uploadFailed": "Upload fehlgeschlagen. Bitte erneut versuchen.", "rateLimited": "Zu viele Uploads. Bitte kurz warten und erneut versuchen.", @@ -5478,7 +5526,7 @@ "failureInterrupted": "Der Import wurde unterbrochen, bevor er fertig war, meist durch einen Server-Neustart oder Speichermangel. Die Daten sind sicher; das Archiv einfach erneut hochladen.", "failureInsufficientMemory": "Das Speicherlimit des Servers ist für ein Archiv dieser Größe zu niedrig. Bitte die Betreiberin oder den Betreiber das Node.js-Heap-Limit erhöhen lassen (NODE_OPTIONS=--max-old-space-size) und das Archiv danach erneut hochladen.", "rowsImported": "Bisher {count} Einträge importiert", - "doneSummary": "Fertig — {imported} von {read} Datensätzen importiert, {skipped} klinische Datensätze übersprungen.", + "doneSummary": "Fertig: {imported} von {read} Datensätzen importiert, {skipped} klinische Datensätze übersprungen.", "doneNothing": "Aus diesem Archiv wurde nichts importiert: 0 von {read} Datensätzen sind angekommen.", "writtenByHealthLogOne": "{count} Eintrag, den HealthLog selbst in Apple Health geschrieben hat, wurde ausgelassen, weil er schon in deinen Daten ist.", "writtenByHealthLogOther": "{count} Einträge, die HealthLog selbst in Apple Health geschrieben hat, wurden ausgelassen, weil sie schon in deinen Daten sind.", @@ -5491,12 +5539,12 @@ "parsing": "Export wird gelesen…", "upserting": "Datensätze werden importiert…" }, - "skipUnsupported": "{count} Samples übersprungen — Datentypen, die HealthLog nicht importiert.", + "skipUnsupported": "{count} Samples übersprungen: Datentypen, die HealthLog nicht importiert.", "skipRefused": "{count} Samples konnten nicht geschrieben werden (unlesbar, außerhalb des Wertebereichs oder Speichern fehlgeschlagen).", "skipBreakdown": "Aufschlüsselung anzeigen ({count} Typen)", "skipBreakdownMore": "+ {count} weitere Typen", "cycleSummary": "{samples} Zyklus-Samples zu {days} Tagesprotokollen zusammengeführt.", - "cycleModuleOff": "{count} Zyklus-Samples übersprungen — Zyklus-Tracking ist für dieses Konto deaktiviert.", + "cycleModuleOff": "{count} Zyklus-Samples übersprungen, weil Zyklus-Tracking für dieses Konto deaktiviert ist.", "cycleDaysFailed": "{count} Zyklustage konnten nicht geschrieben werden: {reason}" }, "json": { @@ -5581,8 +5629,8 @@ "failed": "Der Import ist fehlgeschlagen. Es wurde nichts geschrieben.", "columnsHeading": "Was mit jeder Spalte passiert", "jsonOnly": "nur JSON", - "previewLine": "Zeilen gelesen: {rows} · würde importieren: {queued} · abgewiesen: {refused}", - "submittedLine": "Zeilen gelesen: {rows} · zum Import eingereiht: {queued}", + "previewLine": "{rows} Zeilen gelesen, {queued} würden importiert, {refused} abgewiesen", + "submittedLine": "{rows} Zeilen gelesen, {queued} zum Import eingereiht", "unmatched": "Kein Medikament im Bestand passt zu: {names}", "ambiguous": "Passt auf mehr als ein Medikament: {names}", "mirrored": "Aus einer anderen App gespiegelt, daher keine Dosis ergänzbar: {names}", @@ -5618,18 +5666,59 @@ "tooManyRows": "Diese Datei enthält mehr Zeilen, als ein Import aufnehmen kann. Teilen und in Teilen importieren." }, "detail": "Eine Datei deckt alle Medikamente darin ab." + }, + "healthConnect": { + "title": "Health-Connect-Export", + "description": "Importiere die ZIP-Datei, die die Health-Connect-App auf Android beim Export deiner Daten schreibt.", + "choose": "ZIP-Datei wählen", + "uploading": "Wird hochgeladen …", + "uploadFailed": "Das Hochladen ist fehlgeschlagen. Bitte versuch es noch einmal.", + "tooLarge": "Die Datei ist größer, als dieser Server annimmt.", + "doneSummary": "{count} Werte importiert.", + "failed": "Der Import ist fehlgeschlagen: {reason}", + "phase": { + "queued": "Wartet auf den Start", + "unpacking": "Datei wird entpackt", + "parsing": "Einträge werden gelesen", + "upserting": "Werte werden gespeichert", + "done": "Fertig", + "failed": "Fehlgeschlagen" + }, + "steps": { + "export": "Öffne auf deinem Android-Handy Health Connect, geh zu „Daten verwalten“, dann „Exportieren und importieren“, und starte einen Export.", + "cloud": "Health Connect speichert den Export nur in einer Cloud-App wie Google Drive, Dropbox oder Nextcloud, nie auf dem Handy selbst. Wähl dort eine aus.", + "upload": "Lade die ZIP-Datei aus dieser Cloud auf dieses Gerät herunter und lade sie hier hoch." + }, + "dropLabel": "Health-Connect-ZIP hier ablegen", + "dropHint": "oder klicken, um sie auszuwählen. Bis 1 GB.", + "fileInputLabel": "ZIP-Datei des Health-Connect-Exports", + "privacyNote": "Die Datei wird vom Server gelöscht, sobald der Import endet. Einträge von Apps, die du schon direkt verbunden hast, etwa Withings oder Fitbit, werden ausgelassen, damit nichts doppelt ankommt. Dieselbe Datei noch einmal hochzuladen ändert nichts.", + "rateLimited": "Zu viele Uploads in kurzer Zeit. Warte eine Minute und versuch es noch einmal.", + "busy": "Für dein Konto läuft schon ein Import. Warte, bis er fertig ist.", + "rowsSoFar": "Bisher {count} Werte gespeichert", + "doneNothing": "Der Import ist fertig, es gab aber nichts Neues.", + "workouts": "Trainings", + "leftOutConnected": "{count} Einträge von Apps, die du schon direkt verbunden hast, wurden ausgelassen.", + "nutrientsModuleOff": "Wasser- und Nährstoffsummen wurden nicht importiert, weil das Modul Nährstoffe ausgeschaltet ist.", + "cycleModuleOff": "Periodendaten wurden nicht importiert, weil das Zyklus-Tracking ausgeschaltet ist.", + "failure": { + "unsupportedVersion": "Dieser Health-Connect-Export ist zu alt, um ihn zu lesen. Aktualisiere Health Connect auf dem Handy und exportiere noch einmal.", + "notHealthConnect": "Diese Datei ist kein Health-Connect-Export. Wähl die ZIP-Datei, die Health Connect gespeichert hat.", + "unsafeSchema": "Diese Datei hat nicht den Aufbau eines Health-Connect-Exports und wurde nicht gelesen.", + "tooLarge": "Der Export ist größer, als dieser Server annimmt.", + "stagingMissing": "Die hochgeladene Datei war nicht mehr auf dem Server. Lade sie noch einmal hoch.", + "interrupted": "Der Import wurde durch einen Neustart des Servers unterbrochen. Lade die Datei noch einmal hoch." + } } }, "subtitle": "Exportiere deine Daten oder importiere aus einer Sicherung." }, "about": { "title": "Über diese App", - "description": "Version, Lizenz, Links.", "subtitle": "Version, Lizenz und Projektlinks." }, "sharing": { "title": "Freigabe", - "description": "Erstelle einen schreibgeschützten Link, um deine Gesundheitsakte mit einer Ärztin oder einem Arzt zu teilen.", "subtitle": "Teile schreibgeschützten Zugriff auf deine Daten." }, "insights": { @@ -5656,7 +5745,7 @@ "error": "Konnte nicht gespeichert werden. Bitte erneut versuchen.", "toggleable": { "title": "Was du trackst", - "description": "Deaktivierte Module verschwinden aus Navigation, Kacheln und Auswertungen — deine Daten bleiben erhalten." + "description": "Deaktivierte Module verschwinden aus Navigation, Kacheln und Auswertungen, deine Daten bleiben erhalten." }, "manageIn": "In {section} verwalten", "operatorDisabled": "Serverweit deaktiviert", @@ -5664,12 +5753,10 @@ }, "gesundheitsakte": { "title": "Gesundheitsakte", - "description": "Vollständige Gesundheitsakte als PDF, FHIR R4 oder kombiniertes ZIP-Paket exportieren.", "subtitle": "Exportiere eine vollständige, arzttaugliche Gesundheitsakte." }, "coach": { "title": "Coach", - "description": "Coach aktivieren, Ton und Umfang, gespeicherte Erinnerungen.", "subtitle": "Stelle den Coach, seine proaktiven Hinweise und sein Gedächtnis ein." }, "security": { @@ -5678,8 +5765,7 @@ }, "privacy": { "title": "Daten & Datenschutz", - "subtitle": "Deine Daten, Aufbewahrung, Verschlüsselung und Kontosicherheit an einem Ort.", - "description": "Export, Löschung, Aufbewahrung, Verschlüsselungsstatus, Sitzungen und Sicherheitsaktivität." + "subtitle": "Deine Daten, Aufbewahrung, Verschlüsselung und Kontosicherheit an einem Ort." }, "mcp": { "title": "MCP-Connector", @@ -5687,8 +5773,7 @@ }, "anamnesis": { "title": "Anamnese", - "subtitle": "Verwalte deine Allergien und deine Familienanamnese.", - "description": "Strukturierte Datensätze zu Allergien und Familienanamnese." + "subtitle": "Deine Vorgeschichte, Lebensumstände, Notfalldaten und was die KI lesen darf." }, "environment": { "title": "Standort & Wetter", @@ -5710,19 +5795,20 @@ "storedDays": "{count} Tage Wetter gespeichert.", "home": { "title": "Heimatort", - "description": "Ein grober Standort auf Stadtebene — niemals eine genaue Spur. Wetter und Tageslicht werden für diesen Ort an Tagen ohne Ortszeitraum abgerufen, ab dem Tag der Festlegung.", + "description": "Ein grober Standort auf Stadtebene, niemals eine genaue Spur. Wetter und Tageslicht werden für diesen Ort an Tagen ohne Ortszeitraum abgerufen, ab dem Tag der Festlegung.", "current": "Aktuell: {label}", "none": "Noch kein Heimatort festgelegt.", "since": "Wetter wird ab {date} erfasst. Frühere Tage bleiben leer, bis du einen Ortszeitraum hinzufügst." }, "travel": { "title": "Ortszeiträume", - "description": "Füge einen Ort und einen Zeitraum hinzu — eine Reise oder einen früheren Abschnitt deiner Verlaufsdaten. Der Zeitraum überschreibt für seine Tage deinen Wohnort, damit das Wetter zu deinem tatsächlichen Aufenthalt passt. So korrigierst du die Vergangenheit.", + "description": "Füge einen Ort und einen Zeitraum hinzu, etwa eine Reise oder einen früheren Abschnitt deiner Verlaufsdaten. Der Zeitraum überschreibt für seine Tage deinen Wohnort, damit das Wetter zu deinem tatsächlichen Aufenthalt passt. So korrigierst du die Vergangenheit.", "none": "Noch keine Ortszeiträume.", "add": "{label} hinzufügen", "remove": "Ortszeitraum entfernen", "removeConfirmTitle": "Diese Reise entfernen?", - "removeConfirmBody": "Messwerte aus diesem Zeitraum bleiben erhalten; nur der Reise-Eintrag verschwindet." + "removeConfirmBody": "Messwerte aus diesem Zeitraum bleiben erhalten; nur der Reise-Eintrag verschwindet.", + "range": "{start} bis {end}" }, "backfill": { "title": "Vergangene Tage nacherfassen", @@ -5732,7 +5818,21 @@ "noResults": "Keine passenden Orte gefunden.", "fetchFailedOne": "Die letzte Wetteraktualisierung im Hintergrund ist fehlgeschlagen ({when}).", "fetchFailedFew": "Die letzten {count} Wetteraktualisierungen im Hintergrund sind fehlgeschlagen, zuletzt {when}.", - "fetchFailedOther": "Die letzten {count} Wetteraktualisierungen im Hintergrund sind fehlgeschlagen, zuletzt {when}." + "fetchFailedOther": "Die letzten {count} Wetteraktualisierungen im Hintergrund sind fehlgeschlagen, zuletzt {when}.", + "airQuality": { + "title": "Luftqualität und Pollen", + "description": "Tägliche Luftqualität, Pollen und UV für deinen Ort, aus dem CAMS-Modell über Open-Meteo.", + "toggle": "Luftqualität und Pollen einbeziehen", + "operatorDisabled": "Luftqualität ist auf diesem Server abgeschaltet.", + "coverage": "{count} Tage mit Luftqualitätsdaten", + "pollenEuropeOnly": "Pollendaten gibt es nur für Europa.", + "historyProgress": "Luftqualität wird nachgeladen: {done} von {total} Tagen", + "historyComplete": "Luftqualität ist für alle früheren Tage mit Einträgen nachgeladen, die die Quelle abdeckt ({total} Tage). Ältere Tage bleiben leer.", + "attribution": "Datenquellen und Lizenzen", + "saveError": "Die Einstellung konnte nicht gespeichert werden.", + "latest": "Jüngster Tag ({date})", + "modelNote": "Modellierte Außenwerte für deine Gegend, nicht deine persönliche Belastung." + } }, "score": { "title": "Gesundheitsscore", @@ -5765,7 +5865,7 @@ "omitted": "{count} Bereiche stehen nicht in der Liste, weil ihre Erfassung abgeschaltet ist. Schalte ein Modul wieder ein, dann kommt sein Bereich zurück.", "omittedLink": "Module öffnen", "refusal": { - "empty": "Ein Health Score braucht mindestens ein Feld der Gesundheit, das er bewerten kann. Behalte mindestens eines ausgewählt — der Score nennt selbst, worauf er beruht." + "empty": "Ein Health Score braucht mindestens ein Feld der Gesundheit, das er bewerten kann. Behalte mindestens eines ausgewählt. Der Score nennt selbst, worauf er beruht." }, "notice": { "upgrade": { @@ -5859,7 +5959,6 @@ }, "changePassword": "Passwort ändern", "changePasswordDescription": "Ersetze dein aktuelles Passwort durch ein neues.", - "passwordReset": "Passwort-Reset", "currentPassword": "Aktuelles Passwort", "newPassword": "Neues Passwort", "confirmNewPassword": "Neues Passwort bestätigen", @@ -5904,9 +6003,9 @@ "botToken": "Bot-Token", "chatId": "Chat-ID", "enableNotifications": "Benachrichtigungen aktivieren", - "telegramStep1": "1. Erstelle einen Bot über @BotFather in Telegram und kopiere den Token.", - "telegramStep2": "2. Sende /start an deinen Bot, um den Chat zu aktivieren.", - "telegramStep3": "3. Finde deine Chat-ID über @userinfobot oder die Bot-API.", + "telegramStep1": "Erstelle einen Bot über @BotFather in Telegram und kopiere den Token.", + "telegramStep2": "Sende /start an deinen Bot, um den Chat zu aktivieren.", + "telegramStep3": "Finde deine Chat-ID über @userinfobot oder die Bot-API.", "ntfy": "ntfy", "ntfyDescription": "Benachrichtigungen über ntfy (self-hosted oder ntfy.sh).", "ntfyEnable": "ntfy aktivieren", @@ -5915,7 +6014,7 @@ "ntfyAuthToken": "Auth-Token (optional)", "ntfyAuthTokenHint": "Nur nötig bei privaten Topics mit Zugangsschutz.", "webhook": "Webhook", - "webhookDescription": "Benachrichtigungen per POST an eine beliebige URL — Gotify, Discord, Slack, Matrix oder Home Assistant.", + "webhookDescription": "Benachrichtigungen per POST an eine URL: Discord, Slack, Gotify, Home Assistant, n8n oder jedes Relay, das JSON annimmt.", "webhookEnable": "Webhook aktivieren", "webhookUrl": "Webhook-URL", "webhookUrlHint": "Die vollständige URL für den POST. Eine Adresse im privaten Netz muss der Betreiber des Servers freigeben.", @@ -5923,6 +6022,7 @@ "webhookHeaderValue": "Header-Wert (optional)", "webhookFormat": "Nachrichtenformat", "webhookFormatGeneric": "Generisches JSON", + "webhookFormatGenericHint": "Eine Discord- oder Slack-Webhook-URL einfach so einfügen. Home Assistant und n8n lesen die Felder title, message, eventType und priority.", "webhookFormatGotify": "Gotify", "webhookFormatGotifyHint": "Nutze die URL, die auf /message endet, und trage das App-Token als Header X-Gotify-Key ein.", "email": "E-Mail", @@ -5939,7 +6039,6 @@ "webPushSubscribed": "Push-Benachrichtigungen aktiviert!", "webPushUnsubscribed": "Push-Benachrichtigungen deaktiviert.", "webPushSubscribeFailed": "Aktivierung fehlgeschlagen", - "kiInsightsDescription": "Optional: Hinterlege deinen OpenAI-Key für tägliche, automatische Auswertungen in den Insights.", "codexConnected": "ChatGPT erfolgreich verbunden! Insights sind jetzt aktiv.", "codexDisconnected": "ChatGPT-Verbindung getrennt.", "codexConnectionFailed": "ChatGPT-Verbindung fehlgeschlagen. Bitte versuche es erneut.", @@ -5949,7 +6048,7 @@ "rawDataWarning": "Ausführlicher Verlauf ist an: Jede Auswertung sendet zusätzlich Tagesdurchschnitte der letzten 90 Tage, Wochendurchschnitte für das Jahr davor und Monatsdurchschnitte bis zu 5 Jahre zurück.", "regenerateInsights": "Reports neu generieren", "regenerateSuccess": "Reports wurden neu generiert", - "regenerateRateLimit": "Bitte warte — du hast das stündliche Limit für Analysen erreicht.", + "regenerateRateLimit": "Bitte warte, du hast das stündliche Limit für Analysen erreicht.", "lastGeneratedAt": "Zuletzt generiert", "ai": { "chatgptConnectedBadge": "ChatGPT verbunden", @@ -5959,11 +6058,11 @@ "deviceCodeHeading": "Verbindung auf chatgpt.com abschließen", "deviceCodeStep1": "Öffne diesen Link auf einem beliebigen Gerät:", "deviceCodeStep2": "Gib diesen Einmalcode ein:", - "deviceCodeStep3": "Bestätige die Verbindung — diese Seite aktualisiert sich automatisch.", + "deviceCodeStep3": "Bestätige die Verbindung. Diese Seite aktualisiert sich automatisch.", "deviceCodeCopy": "kopieren", "deviceCodeWaiting": "Warte auf Bestätigung…", "deviceCodeCancel": "Abbrechen", - "oauthNotConfigured": "ChatGPT-OAuth ist auf dieser Instanz nicht konfiguriert — nutze stattdessen einen eigenen API-Key unten.", + "oauthNotConfigured": "ChatGPT-OAuth ist auf dieser Instanz nicht konfiguriert. Nutze stattdessen einen eigenen API-Key unten.", "modelLabel": "Modell", "modelOptionDefault": "— Standard —", "modelOptionCustom": "Eigenes…", @@ -5971,7 +6070,7 @@ "anthropicKeyLabel": "Anthropic API-Key", "baseUrlLabel": "Base URL", "localKeyLabel": "API-Key (optional)", - "localDescription": "Hier funktioniert jeder OpenAI-kompatible Endpunkt — ein lokales Modell (Ollama, LM Studio) genauso wie ein Gateway wie LiteLLM, OpenRouter oder vLLM. Die Basis-URL endet auf /v1; der API-Schlüssel ist optional.", + "localDescription": "Hier funktioniert jeder OpenAI-kompatible Endpunkt: ein lokales Modell (Ollama, LM Studio) genauso wie ein Gateway wie LiteLLM, OpenRouter oder vLLM. Die Basis-URL endet auf /v1; der API-Schlüssel ist optional.", "savedPreview": "(gespeichert {preview})", "savedShort": "(gespeichert)", "saveCta": "Speichern", @@ -5987,7 +6086,7 @@ "admin-openai": "Admin-OpenAI" }, "title": "Fallback-Kette", - "description": "Wenn der primäre Provider ausfällt, läuft HealthLog die Kette der Reihe nach durch. Reihenfolge per Pfeile ändern, Schalter deaktiviert einen Eintrag ohne ihn zu entfernen.", + "description": "Fällt der aktive Anbieter aus, versucht HealthLog den nächsten eingeschalteten in dieser Reihenfolge.", "moveUp": "Nach oben", "moveDown": "Nach unten", "removeFromChain": "Aus Kette entfernen", @@ -6000,11 +6099,8 @@ "resetConfirmTitle": "Kette auf Standard zurücksetzen?", "resetConfirmBody": "Die Kette wird auf Codex → OpenAI → Anthropic → Lokal → Admin-OpenAI zurückgesetzt. Gespeicherte Zugangsdaten bleiben erhalten." }, - "providerCardTitle": "KI-Anbieter", "activeProviderHeading": "Aktiver Provider", - "activeProviderBody": "Wähle den Provider, der zuerst genutzt wird. Das Formular darunter konfiguriert ausschließlich den ausgewählten Provider; die Fallback-Kette unten regelt, was passiert, wenn er ausfällt.", "activeProviderLabel": "Primärer Provider", - "providerConfigTitle": "Provider-Konfiguration", "providerSelect": { "codex": "ChatGPT-Account (Codex)", "openai": "OpenAI (eigener API-Key)", @@ -6014,7 +6110,7 @@ "admin-openai": "Admin-OpenAI" }, "compat": { - "description": "Ein vollwertiger Anbieter für jeden Endpunkt, der die OpenAI-API /v1/chat/completions spricht: LiteLLM, OpenRouter, vLLM oder ein anderes Gateway. Die Basis-URL endet auf /v1, der API-Key ist optional, und das Modell ist das, was dein Gateway weiterleitet. Dein OpenAI-Key erreicht diesen Endpunkt nie — dieser Anbieter hat einen eigenen.", + "description": "Ein vollwertiger Anbieter für jeden Endpunkt, der die OpenAI-API /v1/chat/completions spricht: LiteLLM, OpenRouter, vLLM oder ein anderes Gateway. Die Basis-URL endet auf /v1, der API-Key ist optional, und das Modell ist das, was dein Gateway weiterleitet. Dein OpenAI-Key erreicht diesen Endpunkt nie, dieser Anbieter hat einen eigenen.", "keyLabel": "API-Key (optional)", "modelHint": "Der Modellname, den dein Gateway erwartet. Bleibt das Feld leer, wird das für deine anderen Anbieter gespeicherte Modell verwendet; ist auch das leer, wird dieser Anbieter übersprungen." }, @@ -6050,18 +6146,18 @@ "notConfigured": "Der Betreiber hat keinen geteilten OpenAI-Key auf dieser Instanz konfiguriert." }, "testProvider": "Aktiven Provider testen", - "testSuccess": "OK — {provider} ({model})", + "testSuccess": "OK: {provider} ({model})", "testFailedShort": "Test fehlgeschlagen: {message}", - "testUnexpectedResponse": "Verbindung zum KI-Anbieter fehlgeschlagen — unerwartete Antwort vom Server.", - "testReasonCredentials": "Der Anbieter hat die Anmeldedaten abgelehnt — melde dich in den KI-Einstellungen erneut an.", - "testReasonRateLimited": "Der Anbieter hat die Anfrage gedrosselt — versuche es in Kürze erneut.", + "testUnexpectedResponse": "Verbindung zum KI-Anbieter fehlgeschlagen: unerwartete Antwort vom Server.", + "testReasonCredentials": "Der Anbieter hat die Anmeldedaten abgelehnt. Melde dich in den KI-Einstellungen erneut an.", + "testReasonRateLimited": "Der Anbieter hat die Anfrage gedrosselt. Versuche es in Kürze erneut.", "testReasonServerError": "Der KI-Anbieter hat einen Serverfehler zurückgegeben.", - "testReasonBadRequest": "Der Anbieter hat die Anfrage abgelehnt (HTTP {status}) — der Endpunkt hat geantwortet; das Problem liegt also am Anfrageformat oder Modellnamen, nicht an der Verbindung.", + "testReasonBadRequest": "Der Anbieter hat die Anfrage abgelehnt (HTTP {status}). Der Endpunkt hat geantwortet; das Problem liegt also am Anfrageformat oder Modellnamen, nicht an der Verbindung.", "testReasonUnreachable": "Der KI-Anbieter war nicht erreichbar.", "coachMemory": { "title": "Woran sich der Coach erinnert", "description": "Dauerhafte Angaben, die du dem Coach mitgeteilt hast.", - "empty": "Der Coach hat noch keine Angaben gespeichert. Im Gespräch merkt er sich dauerhafte Details, die du teilst — deine Ziele, Beschwerden, Vorlieben — und du kannst sie hier jederzeit löschen.", + "empty": "Der Coach hat noch keine Angaben gespeichert. Im Gespräch merkt er sich dauerhafte Details, die du teilst (deine Ziele, Beschwerden, Vorlieben), und du kannst sie hier jederzeit löschen.", "learnedPrefix": "gelernt {when}", "categoryPreference": "Vorlieben", "categoryCondition": "Beschwerden", @@ -6077,7 +6173,7 @@ "forgetAllConfirmBody": "Dadurch werden alle gespeicherten Angaben entfernt. Der Coach beginnt beim nächsten Gespräch von vorn. Das lässt sich nicht rückgängig machen.", "forgetAllConfirmAction": "Alles vergessen", "cancel": "Abbrechen", - "summaryNote": "Der Coach behält außerdem eine kurze, fortlaufende Zusammenfassung jedes Gesprächs. Dafür gibt es keine Einzelsteuerung — wenn du ein Gespräch löschst, wird auch dessen Zusammenfassung entfernt.", + "summaryNote": "Der Coach behält außerdem eine kurze, fortlaufende Zusammenfassung jedes Gesprächs. Dafür gibt es keine Einzelsteuerung. Wenn du ein Gespräch löschst, wird auch dessen Zusammenfassung entfernt.", "loadError": "Konnte nicht laden, woran sich der Coach erinnert.", "forgotToast": "Angabe vergessen.", "forgotError": "Konnte diese Angabe nicht vergessen. Bitte erneut versuchen.", @@ -6089,7 +6185,7 @@ "coachReminders": { "title": "Deine Erinnerungen", "description": "Dinge, um die du den Coach gebeten hast, später darauf zurückzukommen.", - "empty": "Noch keine Erinnerungen. Bitte den Coach, dich an etwas zu erinnern — dann erscheint es hier.", + "empty": "Noch keine Erinnerungen. Bitte den Coach, dich an etwas zu erinnern, dann erscheint es hier.", "loadError": "Konnte deine Erinnerungen nicht laden.", "dueHeading": "Jetzt fällig", "allHeading": "Gespeichert", @@ -6098,7 +6194,7 @@ "markDone": "Erledigt", "dismiss": "Verwerfen", "deleteAria": "Diese Erinnerung löschen", - "note": "Erinnerungen werden verschlüsselt gespeichert. Lösche eine jederzeit — sie wird aus dem entfernt, was der Coach sehen kann.", + "note": "Erinnerungen werden verschlüsselt gespeichert. Lösche eine jederzeit, sie wird aus dem entfernt, was der Coach sehen kann.", "status": { "proposed": "Vorgeschlagen", "active": "Gespeichert", @@ -6143,21 +6239,21 @@ "responseTimeoutInvalid": "Gib eine Zahl zwischen 10 und 600 Sekunden ein.", "autoRead": { "title": "Dokumente automatisch mit KI lesen", - "subLabel": "Dokumente werden von deinem konfigurierten KI-Anbieter gelesen und indiziert, ohne dass du jedes Dokument einzeln bestätigen musst.", - "honesty": "Wenn aktiviert, wird jedes hochgeladene Dokument an deinen konfigurierten KI-Anbieter gesendet, um gelesen, beschrieben und für die Suche indiziert zu werden — ohne Bestätigung pro Dokument. Das Einschalten wirkt außerdem rückwirkend: Bereits hochgeladene Dokumente ohne Zusammenfassung werden ebenfalls gesendet, bis zu 200 pro Einschaltvorgang. Das geschieht auf dem Server, und der Inhalt des Dokuments verlässt dabei diese Maschine in Richtung dieses Anbieters. Wenn dein konfigurierter Anbieter eine Abo-Verbindung ist (ein angemeldetes KI-Konto statt eines API-Schlüssels), gelten dessen eigene Datenschutzeinstellungen — Abo-Anbieter dürfen den Inhalt zur Verbesserung ihrer Modelle verwenden, und kein Auftragsverarbeitungsvertrag deckt das ab. Dokumente, die von einem selbst gehosteten lokalen Modell gelesen werden, verlassen deinen Server nie. Standardmäßig aus; aktiviere es nur, wenn du diesen Kompromiss für deine Dokumente akzeptierst.", - "confirm": "Verstanden — aktivieren", + "subLabel": "Dein KI-Anbieter liest und indiziert neue Dokumente, ohne dass du jedes einzeln bestätigst.", + "honesty": "Wenn aktiviert, wird jedes hochgeladene Dokument an deinen konfigurierten KI-Anbieter gesendet, um gelesen, beschrieben und für die Suche indiziert zu werden, ohne Bestätigung pro Dokument. Das Einschalten wirkt außerdem rückwirkend: Bereits hochgeladene Dokumente ohne Zusammenfassung werden ebenfalls gesendet, bis zu 200 pro Einschaltvorgang. Das geschieht auf dem Server, und der Inhalt des Dokuments verlässt dabei diese Maschine in Richtung dieses Anbieters. Wenn dein konfigurierter Anbieter eine Abo-Verbindung ist (ein angemeldetes KI-Konto statt eines API-Schlüssels), gelten dessen eigene Datenschutzeinstellungen: Abo-Anbieter dürfen den Inhalt zur Verbesserung ihrer Modelle verwenden, und kein Auftragsverarbeitungsvertrag deckt das ab. Dokumente, die von einem selbst gehosteten lokalen Modell gelesen werden, verlassen deinen Server nie. Standardmäßig aus; aktiviere es nur, wenn du diesen Kompromiss für deine Dokumente akzeptierst.", + "confirm": "Verstanden, aktivieren", "cancel": "Abbrechen" }, "centralCodex": { "title": "Gemeinsamen KI-Zugang des Servers nutzen", - "subLabel": "Leite KI-Anfragen über die vom Betreiber für diesen Server eingerichtete Verbindung — zusätzlich zu deinen eigenen Anbietern als Rückfallebene.", - "honesty": "Der gemeinsame Zugang ist ein einzelnes angemeldetes KI-Konto, das der Betreiber für alle auf diesem Server verbunden hat — nicht dein eigener Schlüssel. Anfragen darüber werden vom Anbieter standardmäßig zum Training genutzt und von keiner Auftragsverarbeitungsvereinbarung gedeckt, die Stunden- und Wochenlimits des Kontos teilst du dir mit allen anderen, die zugestimmt haben, und deine eigenen Anbieter werden weiterhin zuerst versucht. Aktiviere es nur, wenn du diesen Kompromiss akzeptierst.", - "confirm": "Verstanden — Verbindung nutzen", + "subLabel": "KI-Anfragen laufen über die gemeinsame Verbindung des Servers, deine eigenen Anbieter dienen als Rückfall.", + "honesty": "Der gemeinsame Zugang ist ein einzelnes angemeldetes KI-Konto, das der Betreiber für alle auf diesem Server verbunden hat, nicht dein eigener Schlüssel. Anfragen darüber werden vom Anbieter standardmäßig zum Training genutzt und von keiner Auftragsverarbeitungsvereinbarung gedeckt, die Stunden- und Wochenlimits des Kontos teilst du dir mit allen anderen, die zugestimmt haben, und deine eigenen Anbieter werden weiterhin zuerst versucht. Aktiviere es nur, wenn du diesen Kompromiss akzeptierst.", + "confirm": "Verstanden, Verbindung nutzen", "cancel": "Abbrechen" }, "consent": { "title": "KI-Einwilligung", - "activeSince": "Erteilt am {date}. Deine Gesundheitsdaten dürfen für die KI-Funktionen an den eingestellten Anbieter gesendet werden.", + "activeSince": "Erteilt am {date}: Deine Gesundheitsdaten dürfen für die KI-Funktionen an den eingestellten Anbieter gehen.", "withdrawn": "Widerrufen. Sofern keine andere Einwilligung sie abdeckt, nutzen Coach und Insights den KI-Zugang dieses Servers nicht mehr, und kein Online-Modell liest deine Dokumente. Mit deinem eigenen Schlüssel oder einem lokalen Modell laufen Coach und Insights weiter.", "withdraw": "Einwilligung widerrufen", "confirmBody": "Sofern keine andere Einwilligung sie noch abdeckt, endet sofort: der Coach und die Insights, wo sie den KI-Zugang dieses Servers nutzen, und das Lesen von Dokumenten mit einem Online-Modell. Mit deinem eigenen Schlüssel oder einem lokalen Modell laufen Coach und Insights weiter. Sofern keine andere Einwilligung die Insights noch abdeckt, werden die Notizen gelöscht, die ein Modell für dich geschrieben hat: Statusnotizen, das tägliche Briefing, von einem Modell geschriebene Zeitraum-Zusammenfassungen, Ankunftszeilen und Trainingsnotizen. Deine Coach-Gespräche, was sich der Coach gemerkt hat, deine Pläne und deine Dokument-Zusammenfassungen bleiben, und du kannst sie selbst lesen oder löschen.", @@ -6168,7 +6264,7 @@ "error": "Der Widerruf konnte nicht gespeichert werden. Bitte versuche es erneut.", "documentReading": { "title": "Einwilligung zum Lesen von Dokumenten", - "activeSince": "Erteilt am {date}. Dokumente, gescannte Laborbefunde und Medikamentenbeschreibungen dürfen zum Lesen an deinen KI-Dienst gesendet werden.", + "activeSince": "Erteilt am {date}: Dokumente, Laborscans und Medikamententexte dürfen zum Lesen an deinen KI-Dienst gehen.", "confirmBody": "Dokumente, gescannte Laborbefunde und Medikamentenbeschreibungen werden nicht mehr zum Lesen gesendet. Nichts, was du bereits hast, wird gelöscht." } }, @@ -6202,7 +6298,9 @@ "high": "Hoch" }, "coachNote": "Im Coach gilt die Denktiefe aus den Coach-Einstellungen." - } + }, + "providerCardDescription": "Der Anbieter, den HealthLog zuerst fragt, mit Schlüssel und Modell.", + "runtimeTitle": "Prüfen und aktualisieren" }, "dashboard": { "units": { @@ -6226,8 +6324,8 @@ "withingsClientId": "Client-ID", "withingsClientSecret": "Client-Secret", "withingsCredentialsSaved": "Zugangsdaten gespeichert", - "withingsCredentialsSavedPlaceholder": "Gespeichert — neue eingeben zum Ersetzen", - "withingsCredentialsSavedPlaceholderSecret": "Gespeichert — neues eingeben zum Ersetzen", + "withingsCredentialsSavedPlaceholder": "Gespeichert. Neue eingeben zum Ersetzen", + "withingsCredentialsSavedPlaceholderSecret": "Gespeichert. Neues eingeben zum Ersetzen", "withingsSaveCredentials": "Speichern", "configured": "Konfiguriert", "withingsSync": "Jetzt synchronisieren", @@ -6242,7 +6340,7 @@ }, "withingsFullSyncResult": "{count} Messwerte vollständig synchronisiert", "withingsSyncFailed": "Sync fehlgeschlagen", - "withingsOauthConnected": "Withings verbunden — der erste Sync startet im Hintergrund.", + "withingsOauthConnected": "Withings verbunden. Der erste Sync startet im Hintergrund.", "withingsOauthFailed": "Withings-Verbindung fehlgeschlagen", "withingsOauthError": { "csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Starte die Verbindung bitte erneut im selben Browser.", @@ -6264,11 +6362,11 @@ "whoop": "WHOOP", "whoopDescription": "Verbinde dein WHOOP-Band, um Erholung, Schlaf, Belastung und Workouts direkt in HealthLog zu synchronisieren.", "whoopCredentials": "API-Zugangsdaten", - "whoopClientId": "Client ID", - "whoopClientSecret": "Client Secret", + "whoopClientId": "Client-ID", + "whoopClientSecret": "Client-Secret", "whoopCredentialsSaved": "Zugangsdaten gespeichert", - "whoopCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen", - "whoopCredentialsSavedPlaceholderSecret": "Gespeichert — neu eingeben zum Ersetzen", + "whoopCredentialsSavedPlaceholder": "Gespeichert. Neu eingeben zum Ersetzen", + "whoopCredentialsSavedPlaceholderSecret": "Gespeichert. Neu eingeben zum Ersetzen", "whoopSaveCredentials": "Speichern", "whoopSync": "Jetzt synchronisieren", "whoopFullSync": "Alle Daten synchronisieren", @@ -6283,7 +6381,7 @@ "whoopConnect": "Mit WHOOP verbinden", "whoopNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um WHOOP zu verbinden.", "whoopBackfillInProgress": "Dein WHOOP-Verlauf wird im Hintergrund importiert…", - "whoopOauthConnected": "WHOOP verbunden — die erste Synchronisierung startet im Hintergrund.", + "whoopOauthConnected": "WHOOP verbunden. Die erste Synchronisierung startet im Hintergrund.", "whoopOauthFailed": "WHOOP-Verbindung fehlgeschlagen", "whoopOauthError": { "csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.", @@ -6297,14 +6395,14 @@ }, "fitbit": "Fitbit", "fitbitTag": "Fitbit & Pixel", - "fitbitDescription": "Verbinde Fitbit, um deine Fitbit- und Pixel-Watch-Werte zu synchronisieren — Gewicht, Herzfrequenz und mehr.", + "fitbitDescription": "Verbinde Fitbit, um deine Fitbit- und Pixel-Watch-Werte zu synchronisieren: Gewicht, Herzfrequenz und mehr.", "fitbitExperimentalBadge": "Experimentell", "fitbitCredentials": "API-Zugangsdaten", "fitbitClientId": "Client-ID", "fitbitClientSecret": "Client-Secret", "fitbitCredentialsSaved": "Zugangsdaten gespeichert", - "fitbitCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen", - "fitbitCredentialsSavedPlaceholderSecret": "Gespeichert — neu eingeben zum Ersetzen", + "fitbitCredentialsSavedPlaceholder": "Gespeichert. Neu eingeben zum Ersetzen", + "fitbitCredentialsSavedPlaceholderSecret": "Gespeichert. Neu eingeben zum Ersetzen", "fitbitSaveCredentials": "Speichern", "fitbitSync": "Jetzt synchronisieren", "fitbitFullSync": "Alle Daten synchronisieren", @@ -6319,18 +6417,18 @@ "fitbitConnect": "Mit Fitbit verbinden", "fitbitNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Fitbit zu verbinden.", "fitbitBackfillInProgress": "Dein Fitbit-Verlauf wird im Hintergrund importiert…", - "fitbitOauthConnected": "Fitbit verbunden — die erste Synchronisierung startet im Hintergrund.", + "fitbitOauthConnected": "Fitbit verbunden. Die erste Synchronisierung startet im Hintergrund.", "fitbitOauthFailed": "Fitbit-Verbindung fehlgeschlagen", "googleHealth": "Google Health", "googleHealthTag": "Fitbit & Pixel", "googleHealthBetaBadge": "Beta", - "googleHealthDescription": "Verbinde Fitbit, Pixel Watch und Fitbit Air über dein Google-Konto — Gewicht, Herzfrequenz, Schlaf und Aktivität. Das ersetzt die klassische Fitbit-Verbindung, die Google im September 2026 abschaltet.", + "googleHealthDescription": "Verbinde Fitbit, Pixel Watch und Fitbit Air über dein Google-Konto: Gewicht, Herzfrequenz, Schlaf und Aktivität. Das ersetzt die klassische Fitbit-Verbindung, die Google im September 2026 abschaltet.", "googleHealthCredentials": "API-Zugangsdaten", "googleHealthClientId": "Client-ID", "googleHealthClientSecret": "Client-Secret", "googleHealthCredentialsSaved": "Zugangsdaten gespeichert", - "googleHealthCredentialsSavedPlaceholder": "Gespeichert — zum Ersetzen neu eingeben", - "googleHealthCredentialsSavedPlaceholderSecret": "Gespeichert — zum Ersetzen neu eingeben", + "googleHealthCredentialsSavedPlaceholder": "Gespeichert. Zum Ersetzen neu eingeben", + "googleHealthCredentialsSavedPlaceholderSecret": "Gespeichert. Zum Ersetzen neu eingeben", "googleHealthSaveCredentials": "Speichern", "googleHealthSync": "Jetzt synchronisieren", "googleHealthSyncCheckStatus": "Status aktualisieren", @@ -6364,9 +6462,9 @@ "googleHealthConnect": "Mit Google verbinden", "googleHealthNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Google Health zu verbinden.", "googleHealthBackfillInProgress": "Dein Google-Health-Verlauf wird im Hintergrund importiert…", - "googleHealthReauthBanner": "Google hat die Verbindung ablaufen lassen — das passiert regelmäßig, solange deine OAuth-App im Testmodus bleibt. Verbinde neu, um die Synchronisierung fortzusetzen.", + "googleHealthReauthBanner": "Google hat die Verbindung ablaufen lassen. Das passiert regelmäßig, solange deine OAuth-App im Testmodus bleibt. Verbinde neu, um die Synchronisierung fortzusetzen.", "googleHealthReconnect": "Neu verbinden", - "googleHealthOauthConnected": "Google Health verbunden — die erste Synchronisierung startet im Hintergrund.", + "googleHealthOauthConnected": "Google Health verbunden. Die erste Synchronisierung startet im Hintergrund.", "googleHealthOauthFailed": "Verbindung zu Google Health fehlgeschlagen", "googleHealthOauthError": { "csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.", @@ -6401,10 +6499,10 @@ }, "integrationPill": { "connected": "Verbunden", - "errorReconnect": "Fehler — neu verbinden", + "errorReconnect": "Fehler: neu verbinden", "warningServerError": "Sync fehlgeschlagen", "failureCount": "{count}/{threshold}", - "parkedReconnect": "Pausiert — neu verbinden", + "parkedReconnect": "Pausiert: neu verbinden", "notConnected": "Nicht verbunden", "justNow": "gerade eben", "minutesAgo": "vor {count} min", @@ -6419,7 +6517,7 @@ }, "integrationFreshness": { "title": "Empfangene Daten", - "summary": "{count} Datentypen · neuestes {relative}", + "summary": "{count} Datentypen, neuestes {relative}", "quiet": "{count} still", "workouts": "Workouts" }, @@ -6433,31 +6531,33 @@ "fitbitViewData": "Schlaf ansehen", "googleHealthViewData": "Schritte ansehen", "apiTokens": "API-Tokens", - "apiTokensDescription": "API-Tokens für die externe Medikamenten-Einnahme prüfen und widerrufen.", + "apiTokensDescription": "Tokens für Messwerte, Trainings, Dokumente und Medikamenten-Einnahme prüfen und widerrufen.", "measurementsToken": { "title": "Messwert-Tokens", "description": "Erzeuge ein Token, damit eine Waage, eine Uhr oder deine Hausautomatisierung Werte eintragen kann.", "detail": "Das Token wird nur einmal angezeigt.", - "scopeNote": "Es kann ausschließlich neue Werte in deine eigene Akte eintragen — 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." + "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" }, "workoutsToken": { "title": "Training-Tokens", "description": "Erzeuge ein Token, damit eine Uhren-Brücke oder ein Sync-Skript deine Trainings eintragen kann.", "detail": "Das Token wird nur einmal angezeigt.", "scopeNote": "Es kann ausschließlich neue Trainings in deine eigene Akte eintragen. Es kann deine Daten nicht lesen, nichts ändern oder löschen und kein weiteres Token erstellen. Trainings, die darüber kommen, werden als von einem externen Gerät gekennzeichnet. Hat dein Telefon dieselbe Einheit aufgezeichnet, siehst du die Aufzeichnung des Telefons.", - "createFailed": "Das Token konnte nicht erstellt werden. Bitte versuche es erneut." + "createFailed": "Das Token konnte nicht erstellt werden. Bitte versuche es erneut.", + "namePlaceholder": "Name, z. B. Garmin" }, "documentsToken": { "title": "Dokument-Tokens", "description": "Erzeuge ein Token, damit Paperless-ngx, Papra oder das Import-Skript Dokumente in deine Ablage legen können.", "detail": "Das Token wird nur einmal angezeigt.", "scopeNote": "Es kann ausschließlich neue Dokumente in deine eigene Ablage legen. Es kann nichts auflisten, öffnen, herunterladen, ändern oder löschen und kein weiteres Token erstellen. Zurück bekommt es nur, ob eine Datei gespeichert wurde, nicht wie du sie abgelegt hast.", - "createFailed": "Das Token konnte nicht erstellt werden. Bitte versuche es erneut." + "createFailed": "Das Token konnte nicht erstellt werden. Bitte versuche es erneut.", + "namePlaceholder": "Name, z. B. Paperless-ngx" }, - "tokenMintMovedDescription": "Tokens werden dort ausgestellt, wo sie gelten: oben für Messwerte und Dokumente, oder im API-Endpoint eines Medikaments für genau dieses Medikament.", - "tokenNamePlaceholder": "Token-Name (z.B. iPhone Shortcut)", - "tokenCreated": "Token erstellt — kopiere ihn jetzt! Er wird nicht erneut angezeigt.", + "tokenMintMovedDescription": "Tokens werden dort ausgestellt, wo sie gelten: oben für Messwerte, Trainings und Dokumente, oder im API-Endpoint eines Medikaments für genau dieses Medikament.", + "tokenCreated": "Token erstellt. Kopiere ihn jetzt! Er wird nicht erneut angezeigt.", "tokenCopy": "Token kopieren", "tokenCopied": "Token in die Zwischenablage kopiert", "tokenCopyFailed": "Token konnte nicht kopiert werden", @@ -6496,7 +6596,7 @@ "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.", - "deleteAccountSuccess": "Konto wurde gelöscht — du wirst abgemeldet.", + "deleteAccountSuccess": "Konto wurde gelöscht. Du wirst abgemeldet.", "deleteAccountFailed": "Konto konnte nicht gelöscht werden", "deleteAccountFinal": "Endgültig löschen", "saved": "Gespeichert", @@ -6519,7 +6619,7 @@ "testing": "Teste…", "ok": "Verbunden (Latenz {latency} ms)", "errors": { - "credentials_rejected": "Zugangsdaten abgelehnt — Token prüfen", + "credentials_rejected": "Zugangsdaten abgelehnt. Token prüfen", "rate_limited": "Vom Upstream rate-limitiert", "timeout": "Request hat das Zeitlimit überschritten", "upstream_error": "Upstream hat einen Fehler gemeldet", @@ -6530,7 +6630,7 @@ "private_origin_not_approved": "Ziel im privaten Netz. Der Betreiber muss diesen Origin in NOTIFICATION_PRIVATE_ORIGINS eintragen", "private_origin_not_grantable": "Diese Adresse ist Link-local, eine Metadaten- oder eine unspezifizierte Adresse; keine Betreiber-Freigabe kann sie öffnen. Bitte die Adresse verwenden, auf der das Relay lauscht", "url_invalid": "URL ist ungültig", - "redirected": "Endpunkt leitet weiter — URL prüfen", + "redirected": "Endpunkt leitet weiter. URL prüfen", "endpoint_not_found": "Endpunkt unter der URL nicht gefunden", "credentials_unreadable": "Zugangsdaten nicht entschlüsselbar", "upstream_invalid_json": "Upstream-Antwort ist kein JSON", @@ -6541,7 +6641,7 @@ }, "notificationStatus": { "title": "Zustellstatus", - "description": "Status je Kanal — „Automatisch deaktiviert“ heißt: nach dauerhaften Fehlern gestoppt.", + "description": "Status je Kanal; „Automatisch deaktiviert“ heißt, dass nach dauerhaften Fehlern gestoppt wurde.", "emptyDescription": "Richte unter Einstellungen › Integrationen › Zustellkanäle einen Kanal ein.", "stateActive": "Aktiv", "stateAutoDisabled": "Automatisch deaktiviert", @@ -6567,7 +6667,7 @@ }, "healthRecord": { "title": "Gesundheitsakte exportieren", - "description": "Eine vollständige Akte für die ärztliche Vorlage — als PDF, als FHIR oder beides.", + "description": "Eine vollständige Akte für die ärztliche Vorlage, als PDF, als FHIR oder beides.", "format": "Format", "format_pdf": "PDF", "format_fhir": "Strukturiert (FHIR)", @@ -6599,7 +6699,7 @@ "createTitle": "Neuer Freigabe-Link", "createDescription": "Der Link ist schreibgeschützt und zeitlich begrenzt.", "label": "Bezeichnung", - "labelPlaceholder": "z. B. Dr. Schmidt — Jahres-Check-up", + "labelPlaceholder": "z. B. Dr. Schmidt, Jahres-Check-up", "range": "Tage Verlauf", "rangeHint": "Wie weit das geteilte Zeitfenster zurückreicht.", "expiry": "Läuft ab in (Tagen)", @@ -6619,7 +6719,7 @@ "statusRevoked": "Widerrufen", "statusExpired": "Abgelaufen", "inactiveTitle": "Widerrufen und abgelaufen ({count})", - "shownOnce": "Nur einmal sichtbar. Erfasse QR-Code und Passphrase jetzt — beide lassen sich später nicht wiederherstellen.", + "shownOnce": "Nur einmal sichtbar. Erfasse QR-Code und Passphrase jetzt. Beide lassen sich später nicht wiederherstellen.", "qrAlt": "QR-Code mit Freigabelink und Passphrase", "linkLabel": "Link", "passphraseLabel": "Passphrase", @@ -6630,12 +6730,12 @@ "attachTitle": "Dokumente anhängen", "attachButton": "Dokumente auswählen", "attachCount": "{count} von {max} ausgewählt", - "attachFrozen": "Die Dokumentenauswahl ist nach dem Erstellen des Links fest — Dokumente lassen sich danach weder hinzufügen noch entfernen.", + "attachFrozen": "Die Dokumentenauswahl ist nach dem Erstellen des Links fest. Dokumente lassen sich danach weder hinzufügen noch entfernen.", "attachRemove": "{title} entfernen", "exifNote": "Kamera-Metadaten (EXIF/GPS) werden beim Teilen aus JPEG-, PNG- und WebP-Bildern entfernt. PDF-, TIFF-, HEIC- und Office-Dateien werden unverändert übernommen.", "documentCount": "Angehängte Dokumente: {count}", "qrScanTitle": "Zum Öffnen auf einem anderen Gerät scannen", - "qrScanHint": "Richte eine Handykamera auf den Code, um die geteilte Akte zu öffnen — die Passphrase ist bereits enthalten.", + "qrScanHint": "Richte eine Handykamera auf den Code, um die geteilte Akte zu öffnen. Die Passphrase ist bereits enthalten.", "pickerTitle": "Dokumente anhängen", "pickerDescription": "Durchsuche deinen Tresor und wähle Dokumente zum Teilen. Bis zu {max}.", "pickerSearch": "Dokumente suchen", @@ -6648,7 +6748,8 @@ "statusNeedsReselect": "Inhalt neu wählen", "reselectReason": "Dieser Link lieferte einen Umfang, den niemand gewählt hatte, und wurde deshalb geschlossen. Lege einen neuen an und wähle den Inhalt.", "reselectAction": "Ersatz anlegen", - "createDetail": "Der Token wird nur einmal bei der Erstellung angezeigt — sichere ihn dann; er lässt sich nicht wiederherstellen." + "createDetail": "Der Token wird nur einmal bei der Erstellung angezeigt. Sichere ihn dann; er lässt sich nicht wiederherstellen.", + "activeDescription": "Deine geteilten Links, wann sie ablaufen und wie oft sie geöffnet wurden." }, "genderOther": "Divers", "nightscout": "Nightscout", @@ -6656,10 +6757,10 @@ "nightscoutDescription": "Verbinde deine eigene Nightscout-Seite, um kontinuierliche Glukosewerte ins Glukose-Panel zu synchronisieren.", "nightscoutViewData": "Glukose ansehen", "nightscoutUrl": "Nightscout-URL", - "nightscoutUrlSavedPlaceholder": "Gespeichert — neue URL eingeben zum Ersetzen", + "nightscoutUrlSavedPlaceholder": "Gespeichert. Neue URL eingeben zum Ersetzen", "nightscoutToken": "API-Token", "nightscoutTokenOptional": "Optional bei einer öffentlichen Seite", - "nightscoutTokenSavedPlaceholder": "Gespeichert — neuen Token eingeben zum Ersetzen", + "nightscoutTokenSavedPlaceholder": "Gespeichert. Neuen Token eingeben zum Ersetzen", "nightscoutTokenHelp": "Füge einen Zugriffstoken aus deinen Nightscout-Admin-Tools ein. Leer lassen, wenn deine Seite öffentlich lesbar ist.", "nightscoutPrivateOperatorTitle": "Der Zugriff auf ein privates Nightscout benötigt die Freigabe der Serveradministration.", "nightscoutPrivateOperatorDescription": "Bitte die Serveradministration, genau diesen Nightscout-Ursprung freizugeben.", @@ -6677,10 +6778,10 @@ "polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen, tägliche Aktivität und Trainingseinheiten zu synchronisieren.", "polarViewData": "Schlaf & Erholung ansehen", "polarCredentials": "API-Zugangsdaten", - "polarClientId": "Client ID", - "polarClientSecret": "Client Secret", + "polarClientId": "Client-ID", + "polarClientSecret": "Client-Secret", "polarCredentialsSaved": "Zugangsdaten gespeichert", - "polarCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen", + "polarCredentialsSavedPlaceholder": "Gespeichert. Neu eingeben zum Ersetzen", "polarSaveCredentials": "Speichern", "polarUnavailable": "Polar ist auf diesem Server nicht konfiguriert. Füge oben deine eigenen Polar-OAuth-Zugangsdaten hinzu oder bitte deinen Administrator, die gemeinsame App zu konfigurieren.", "polarConnect": "Polar verbinden", @@ -6690,7 +6791,7 @@ "polarDisconnect": "Trennen", "polarDisconnectTitle": "Polar trennen?", "polarDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Werte bleiben in deinem Verlauf.", - "polarOauthConnected": "Polar verbunden — die erste Synchronisierung startet im Hintergrund.", + "polarOauthConnected": "Polar verbunden. Die erste Synchronisierung startet im Hintergrund.", "polarOauthFailed": "Polar-Verbindung fehlgeschlagen", "polarOauthError": { "csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.", @@ -6706,10 +6807,10 @@ "ouraDescription": "Verbinde dein Oura-Konto, um Readiness, Schlafphasen, Herzfrequenzvariabilität und tägliche Aktivität zu synchronisieren.", "ouraViewData": "Schlaf & Erholung ansehen", "ouraCredentials": "API-Zugangsdaten", - "ouraClientId": "Client ID", - "ouraClientSecret": "Client Secret", + "ouraClientId": "Client-ID", + "ouraClientSecret": "Client-Secret", "ouraCredentialsSaved": "Zugangsdaten gespeichert", - "ouraCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen", + "ouraCredentialsSavedPlaceholder": "Gespeichert. Neu eingeben zum Ersetzen", "ouraSaveCredentials": "Speichern", "ouraUnavailable": "Oura ist auf diesem Server nicht konfiguriert. Füge oben deine eigenen Oura-OAuth-Zugangsdaten hinzu oder bitte deinen Administrator, die gemeinsame App zu konfigurieren.", "ouraConnect": "Oura verbinden", @@ -6719,7 +6820,7 @@ "ouraDisconnect": "Trennen", "ouraDisconnectTitle": "Oura trennen?", "ouraDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Werte bleiben in deinem Verlauf.", - "ouraOauthConnected": "Oura verbunden — die erste Synchronisierung startet im Hintergrund.", + "ouraOauthConnected": "Oura verbunden. Die erste Synchronisierung startet im Hintergrund.", "ouraOauthFailed": "Oura-Verbindung fehlgeschlagen", "ouraOauthError": { "csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.", @@ -6750,7 +6851,7 @@ } }, "integrationRedirectGuide": { - "intro": "Die Verbindung läuft über deine eigene {provider}-Entwickler-App — etwa 5 Minuten Einrichtung:", + "intro": "Die Verbindung läuft über deine eigene {provider}-Entwickler-App. Die Einrichtung dauert etwa 5 Minuten:", "step1": "Erstelle eine App im {provider}-Entwicklerportal.", "step2": "Setze deren Weiterleitungs-/Callback-URL auf:", "step3": "Trage die dort erhaltene Client-ID und das Client-Secret oben ein." @@ -6810,7 +6911,7 @@ }, "recovery": { "saveNow": "Speichere diese Wiederherstellungscodes jetzt", - "saveNowHint": "Jeder Code funktioniert einmal. Sie werden nur dieses eine Mal angezeigt — bewahre sie sicher auf. Wenn du deine Authenticator-App verlierst, ist ein Wiederherstellungscode der einzige Weg zurück.", + "saveNowHint": "Jeder Code funktioniert einmal. Sie werden nur dieses eine Mal angezeigt, bewahre sie sicher auf. Wenn du deine Authenticator-App verlierst, ist ein Wiederherstellungscode der einzige Weg zurück.", "download": "Herunterladen", "remainingOne": "{count} Wiederherstellungscode übrig.", "remainingOther": "{count} Wiederherstellungscodes übrig.", @@ -6838,7 +6939,7 @@ }, "passkeyNudge": { "title": "Füge einen Passkey für schnellere Anmeldung hinzu", - "body": "Mit Passkeys meldest du dich per Fingerabdruck, Gesicht oder Geräte-PIN an — ohne Passwort zum Merken." + "body": "Mit Passkeys meldest du dich per Fingerabdruck, Gesicht oder Geräte-PIN an, ohne Passwort zum Merken." }, "trustedDevices": { "title": "Vertrauenswürdige Geräte", @@ -6869,17 +6970,16 @@ "privacy": { "encryption": { "title": "Verschlüsselt gespeichert", - "summary": "Deine sensiblen Daten werden mit {algorithm} verschlüsselt gespeichert — über {columns} Felder in {models} Datensatztypen.", + "summary": "Deine sensiblen Daten werden mit {algorithm} verschlüsselt gespeichert: über {columns} Felder in {models} Datensatztypen.", "statement": "Deine sensiblen Daten werden mit AES-256-GCM verschlüsselt gespeichert." }, "stored": { "title": "Was gespeichert wird", - "measurements": "Messwerte (Gewicht, Blutdruck, Puls, Glukose, Schlaf, Körperzusammensetzung)", - "medications": "Medikamente und Einnahmeverlauf", - "moodLabs": "Stimmungseinträge und Laborwerte", - "coach": "Coach-Unterhaltungen", + "measurements": "Messwerte: Gewicht, Blutdruck, Puls und jeder weitere Wert, den du oder ein Gerät erfasst", "integrations": "Integrations-Tokens (verschlüsselt)", - "security": "Konto- und Sicherheitsaktivität (Sitzungen, Anmeldeprotokoll)" + "security": "Konto- und Sicherheitsaktivität (Sitzungen, Anmeldeprotokoll)", + "profile": "Dein Profil, deine Anamnese und die Angaben für die Gesundheitsakte", + "modules": "Einträge der Module: {list}" }, "retention": { "title": "Aufbewahrung", @@ -6891,7 +6991,7 @@ }, "export": { "title": "Meine Daten herunterladen", - "description": "Deine Datensätze als CSV oder JSON — oder eine vollständige Akte für die Praxis.", + "description": "Deine Datensätze als CSV oder JSON oder eine vollständige Akte für die Praxis.", "openExport": "Export & Backup", "openHealthRecord": "Gesundheitsakte" }, @@ -6910,13 +7010,11 @@ "mcp": { "enableTitle": "Remote-MCP-Endpunkt", "enableDescription": "Erlaube einem MCP-fähigen Assistenten (Claude.ai, ChatGPT), deine Gesundheitsdaten über ein begrenztes Token zu lesen.", - "enabled": "Aktiv", - "disabled": "Inaktiv", "enableToggleLabel": "MCP-Endpunkt aktivieren", "endpointLabel": "Connector-URL", "connectDescription": "Füge diese URL in die Einrichtung des eigenen Connectors deines Assistenten ein und autorisiere dich mit dem Token unten oder melde dich bei Aufforderung an.", "tokensTitle": "Connector-Tokens", - "tokensDescription": "Erzeuge ein Token für einen Assistenten — nur Lesezugriff, außer du erlaubst unten das Eintragen.", + "tokensDescription": "Erzeuge ein Token für einen Assistenten, nur mit Lesezugriff, außer du erlaubst unten das Eintragen.", "scopeNote": "Neue Tokens können nur deine eigenen Daten lesen. Schreibzugriff (health:write) vergibst du pro Token über den Schalter unten. Admin-Funktionen erreicht ein Token nie.", "writeScopeToggleLabel": "Eintragen erlauben (health:write)", "writeScopeNote": "Fügt den Scope health:write hinzu, damit der Assistent neue Messwerte und Stimmungseinträge in dein Konto eintragen kann. Jeder Schreibvorgang wird zuvor bestätigt. Das Token kann weiterhin keine vorhandenen Einträge löschen oder ändern, keine Medikamente bearbeiten und keine REST- oder Admin-Funktionen erreichen. Für reinen Lesezugriff ausgeschaltet lassen.", @@ -6930,7 +7028,8 @@ "connectionRevokeDescription": "Dies beendet den Zugriff des Assistenten sofort und verhindert eine Erneuerung. Für eine erneute Verbindung ist eine neue Autorisierung nötig.", "connectionRevokeFailed": "Assistent konnte nicht getrennt werden.", "tokensDetail": "Das Token wird nur einmal angezeigt.", - "connectionsDetail": "Das Widerrufen einer Verbindung beendet den Zugriff sofort, einschließlich der Möglichkeit zur Erneuerung." + "connectionsDetail": "Das Widerrufen einer Verbindung beendet den Zugriff sofort, einschließlich der Möglichkeit zur Erneuerung.", + "tokenNamePlaceholder": "Name, z. B. Claude Desktop" }, "strava": "Strava", "stravaDescription": "Verbinde dein Strava-Konto, um deine Aktivitäten in deinen Trainingsverlauf zu übernehmen.", @@ -6939,7 +7038,7 @@ "stravaClientId": "Client-ID", "stravaClientSecret": "Client-Secret", "stravaCredentialsSaved": "Zugangsdaten gespeichert", - "stravaCredentialsSavedPlaceholder": "Gespeichert — neue eingeben zum Ersetzen", + "stravaCredentialsSavedPlaceholder": "Gespeichert. Neue eingeben zum Ersetzen", "stravaSaveCredentials": "Speichern", "stravaUnavailable": "Strava ist auf diesem Server nicht konfiguriert. Füge oben deine eigenen Strava-API-Zugangsdaten hinzu oder bitte deinen Administrator, die gemeinsame App zu konfigurieren.", "stravaConnect": "Strava verbinden", @@ -6949,7 +7048,7 @@ "stravaDisconnect": "Trennen", "stravaDisconnectTitle": "Strava trennen?", "stravaDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Aktivitäten bleiben in deinem Verlauf.", - "stravaOauthConnected": "Strava verbunden — die erste Synchronisierung startet im Hintergrund.", + "stravaOauthConnected": "Strava verbunden. Die erste Synchronisierung startet im Hintergrund.", "stravaOauthFailed": "Strava-Verbindung fehlgeschlagen", "stravaOauthError": { "csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.", @@ -6963,7 +7062,7 @@ }, "appleHealth": { "title": "Apple Health", - "description": "Die Live-Synchronisierung erfolgt über die HealthLog-iOS-App — nicht über eine Web- oder OAuth-Verbindung.", + "description": "Die Live-Synchronisierung erfolgt über die HealthLog-iOS-App, nicht über eine Web- oder OAuth-Verbindung.", "status": { "unavailable": "Apple-Health-Status nicht verfügbar" }, @@ -6977,26 +7076,33 @@ "title": "Sync-Fortschritt", "receivedLabel": "Empfangene Datensätze", "oldestLabel": "Älteste erreichte Messung", - "flowing": "In der letzten Stunde sind Daten angekommen — die iPhone-App liefert weiter.", - "waiting": "Warten auf weitere Daten von der iPhone-App. Die Übertragung läuft weiter, wenn die App geöffnet ist oder iOS ihr Hintergrundzeit gewährt — eine ruhige Phase während des ersten großen Abgleichs ist normal, kein Fehler." + "flowing": "In der letzten Stunde sind Daten angekommen. Die iPhone-App liefert weiter.", + "waiting": "Warten auf weitere Daten von der iPhone-App. Die Übertragung läuft weiter, wenn die App geöffnet ist oder iOS ihr Hintergrundzeit gewährt. Eine ruhige Phase während des ersten großen Abgleichs ist normal, kein Fehler." }, "delivery": { "title": "Übermittlung", "lastTriggerLabel": "Letzte Synchronisierung kam", "lastBackgroundLabel": "Letzte Hintergrund-Synchronisierung", - "backgroundNever": "Nie — bisher kam jede Synchronisierung bei geöffneter App an", + "backgroundNever": "Nie: bisher kam jede Synchronisierung bei geöffneter App an", "noSyncYet": "Es ist noch keine Synchronisierung angekommen, daher gibt es hier nichts zu berichten.", "trigger": { "foreground": "bei geöffneter App", "background": "im Hintergrund", "push": "durch einen Push-Weckruf", - "unknown": "Auslöser von der App nicht gemeldet" + "unknown": "Auslöser von der App nicht gemeldet", + "manual": "über „Alles synchronisieren“" } + }, + "freshness": { + "title": "Nach Datentyp", + "lastReceived": "Zuletzt empfangen {relative}", + "lastNewValue": "Letzter neuer Wert {relative}", + "never": "Noch nicht empfangen" } }, "garminInfo": { "title": "Garmin", - "body": "Garmin hat hier keine direkte Verbindung. Verbinde über Apple Health (iOS) oder Google Health Connect (Android). Garmins proprietäre Werte — Body Battery, Training Load, HRV-Status, VO₂max — werden von Garmin an keine der beiden Plattformen weitergegeben.", + "body": "Garmin hat hier keine direkte Verbindung. Verbinde über Apple Health (iOS) oder Google Health Connect (Android). Garmins proprietäre Werte (Body Battery, Training Load, HRV-Status, VO₂max) werden von Garmin an keine der beiden Plattformen weitergegeben.", "docsLink": "Zur Anleitung" }, "glucoseReference": { @@ -7052,6 +7158,12 @@ "description": "Damit werden deine Gesundheitsdaten weitergegeben oder exportiert. Bestätige, dass du es bist, um fortzufahren.", "signInAgain": "Melde dich neu an und versuche es dann noch einmal. Dein Konto hat kein Passwort und keinen Passkey, den du hier bestätigen könntest.", "failed": "Das hat nicht geklappt. Prüfe deine Eingabe und versuche es noch einmal." + }, + "passwordTitle": "Passwort", + "tokenNameLabel": "Token-Name", + "displayCard": { + "title": "Darstellung", + "description": "Sprache, Einheiten und Formate, gespeichert, sobald du sie änderst." } }, "admin": { @@ -7071,33 +7183,33 @@ "loadingStatus": "Status wird geladen…", "systemStatusLoadError": "System-Status konnte nicht geladen werden.", "systemStatusRetry": "Erneut versuchen", - "adminSettingsLoadError": "Admin-Einstellungen konnten nicht geladen werden. Schalter unten zeigen evtl. nicht den Live-Zustand — Seite neu laden.", + "adminSettingsLoadError": "Admin-Einstellungen konnten nicht geladen werden. Schalter unten zeigen evtl. nicht den Live-Zustand. Seite neu laden.", "hostMetrics": { "title": "Host-Auslastung", "load1": "Last (1m)", "memUsedPercent": "Speicher", "diskBusyPercent": "Disk-I/O", "last2hours": "Letzte 2 Stunden", - "empty": "Erfassung läuft — Daten erscheinen in 1–2 Minuten.", + "empty": "Erfassung läuft. Daten erscheinen in 1–2 Minuten.", "loadError": "Host-Metriken konnten nicht geladen werden. Bitte die Seite neu laden." }, "aiServerKey": { "title": "KI-Server-Schlüssel", "description": "Ein Betreiber-Schlüssel für Konten, die keinen eigenen hinterlegt haben.", - "byokHint": "Der eigene Schlüssel (BYOK, ChatGPT-Login oder lokaler Provider) hat immer Vorrang — der Server-Schlüssel ist die letzte Stufe der Provider-Kette.", + "byokHint": "Der eigene Schlüssel (BYOK, ChatGPT-Login oder lokaler Provider) hat immer Vorrang. Der Server-Schlüssel ist die letzte Stufe der Provider-Kette.", "consentHint": "Anfragen über den Server-Schlüssel erfordern die aktive KI-Einwilligung der Person; ohne Einwilligungsbeleg wird nichts gesendet.", "baseUrlHint": "Die Basis-URL bestimmt die API: eine Anthropic-URL wie https://api.anthropic.com/v1 wird erkannt, jeder andere Host wird über die OpenAI-kompatible API angesprochen.", "statusConfigured": "Eingerichtet", "statusNotConfigured": "Nicht eingerichtet", "keyLabel": "API-Schlüssel", "keyPlaceholder": "sk-...", - "keyPlaceholderSet": "Hinterlegt ({preview}) — neuen Schlüssel eingeben, um ihn zu ersetzen", + "keyPlaceholderSet": "Hinterlegt ({preview}). Neuen Schlüssel eingeben, um ihn zu ersetzen", "modelLabel": "Modell", "baseUrlLabel": "Basis-URL", "save": "Speichern", "remove": "Schlüssel entfernen", "removeTitle": "Server-Schlüssel entfernen?", - "removeBody": "Konten ohne eigenen Schlüssel verlieren die KI-Funktionen sofort. Der Schlüssel wird nach dem Speichern nirgends mehr angezeigt — zum Wiederherstellen brauchst du das Original.", + "removeBody": "Konten ohne eigenen Schlüssel verlieren die KI-Funktionen sofort. Der Schlüssel wird nach dem Speichern nirgends mehr angezeigt. Zum Wiederherstellen brauchst du das Original.", "removeConfirm": "Schlüssel entfernen", "saved": "KI-Server-Schlüssel gespeichert", "error": "Speichern des KI-Server-Schlüssels fehlgeschlagen", @@ -7125,13 +7237,16 @@ "typeCodex": "ChatGPT-Anmeldung (persönlich)", "typeLocal": "Lokales Modell (persönlich)", "typeGateway": "OpenAI-kompatibles Gateway (persönlich)", - "spendToday": "Heute ({date}): {total} Tokens, davon {operator} auf dem Schlüssel des Betreibers." + "spendToday": "Heute ({date}): {total} Tokens, davon {operator} auf dem Schlüssel des Betreibers.", + "statusPaused": "Pausiert", + "pausedHint": "Nach wiederholten Fehlern für {count} Nutzer pausiert bis {at}. Danach wird er automatisch wieder versucht.", + "modelNotServed": "Der Endpunkt bietet das Modell {model} nicht mehr an. Wähle in den KI-Einstellungen ein Modell, das er anbietet." }, "aiQuality": { "title": "Auswertungs-Qualität", "loading": "Feedback-Zusammenfassung wird geladen…", "loadError": "Qualitätszusammenfassung konnte nicht geladen werden. Bitte die Seite neu laden.", - "empty": "Noch kein Feedback. Der Aggregator läuft täglich um 04:00 — sobald Nutzer Empfehlungen bewerten, erscheinen hier die Hilfreich-Quoten.", + "empty": "Noch kein Feedback. Der Aggregator läuft täglich um 04:00. Sobald Nutzer Empfehlungen bewerten, erscheinen hier die Hilfreich-Quoten.", "windowLabel": "Letzte {days} Tage", "generatedAtLabel": "Erstellt", "colSeverity": "Schweregrad", @@ -7146,7 +7261,7 @@ "title": "Coach-Feedback", "loading": "Coach-Feedback-Zusammenfassung wird geladen…", "loadError": "Coach-Feedback-Zusammenfassung konnte nicht geladen werden. Bitte die Seite neu laden.", - "empty": "Noch kein Coach-Feedback. Der Aggregator läuft täglich um 04:00 — sobald Nutzer Coach-Antworten bewerten, erscheinen hier die Hilfreich-Quoten.", + "empty": "Noch kein Coach-Feedback. Der Aggregator läuft täglich um 04:00. Sobald Nutzer Coach-Antworten bewerten, erscheinen hier die Hilfreich-Quoten.", "windowLabel": "Letzte {days} Tage", "generatedAtLabel": "Erstellt", "colPromptVersion": "Prompt-Version", @@ -7165,13 +7280,13 @@ "appSettingsDescription": "Globale Standardwerte für Registrierung, Sprache und Zeitzone neuer Konten.", "registrationEnabled": "Registrierung erlauben", "registrationEnabledDescription": "Wenn deaktiviert, können sich keine neuen Nutzer registrieren.", - "registrationOpenHint": "Die Registrierung ist offen — jeder, der diese Instanz erreichen kann, kann ein Konto anlegen. Schließe sie hier, sobald alle ein Konto haben.", + "registrationOpenHint": "Die Registrierung ist offen: Jeder, der diese Instanz erreichen kann, kann ein Konto anlegen. Schließe sie hier, sobald alle ein Konto haben.", "defaultLanguage": "Standard-Sprache", "defaultLanguageDescription": "Wird für neue Nutzer und als Fallback verwendet.", "defaultUserTimezone": "Standard-Zeitzone für neue Konten", - "defaultUserTimezoneDescription": "Wird für neue Konten ohne erkannte Browser-Zeitzone verwendet. Bestehende Nutzer sind nicht betroffen.", + "defaultUserTimezoneDescription": "Gilt für neue Konten ohne erkannte Browser-Zeitzone; bestehende Konten behalten ihre.", "defaultUserTimezoneFallback": "Europe/Berlin (Standard)", - "servicesGlobal": "Dienste (global)", + "servicesGlobal": "Kanäle und API", "servicesGlobalDescription": "Deaktivierte Dienste stehen keinem Nutzer zur Verfügung.", "telegramGlobal": "Telegram-Benachrichtigungen erlauben.", "ntfyGlobal": "ntfy-Benachrichtigungen erlauben.", @@ -7179,19 +7294,21 @@ "integrationWebPush": "Web Push", "offlineGeoLabel": "Geodaten", "offlineGeoEnabled": "Offline (GeoLite2)", - "offlineGeoFallback": "Online ({host}) — GeoLite2-Datenbanken einbinden und GEOLITE2_DIR setzen", + "offlineGeoFallback": "Online ({host}). GeoLite2-Datenbanken einbinden und GEOLITE2_DIR setzen", + "offlineGeoOff": "Aus", + "offlineGeoChosen": "Online ({host})", "apiGlobal": "API-Endpunkte und API-Tokens erlauben.", "umamiTitle": "Umami", - "umamiDescription": "Web-Analyse mit Umami", + "umamiDescription": "Web-Analyse mit Umami.", "glitchtipTitle": "GlitchTip", - "glitchtipDescription": "Fehler-Tracking mit GlitchTip", + "glitchtipDescription": "Fehler-Tracking mit GlitchTip.", "webPushVapidTitle": "Web Push VAPID", - "umamiEnabled": "Umami-Tracking aktivieren.", + "umamiEnabled": "Umami-Tracking aktivieren", "umamiScriptUrl": "Umami Script-URL", "umamiScriptUrlPlaceholder": "https://analytics.example.com/script.js", "umamiWebsiteId": "Umami Website-ID", "umamiWebsiteIdPlaceholder": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "glitchtipEnabled": "GlitchTip Fehler-Tracking aktivieren.", + "glitchtipEnabled": "GlitchTip-Fehler-Tracking aktivieren", "glitchtipDsn": "GlitchTip DSN", "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "GlitchTip Environment", @@ -7214,7 +7331,7 @@ "webPushVapidGenerating": "Wird erzeugt…", "webPushVapidGenerated": "VAPID-Schlüssel erzeugt. Betreff speichern, um abzuschließen.", "webPushVapidGenerateFailed": "VAPID-Schlüssel konnten nicht erzeugt werden.", - "webPushVapidGenerateConfirm": "VAPID-Schlüssel sind bereits konfiguriert. Ein neues Schlüsselpaar macht alle Browser-Push-Abos ungültig — jedes Gerät muss sich neu anmelden. Fortfahren?", + "webPushVapidGenerateConfirm": "VAPID-Schlüssel sind bereits konfiguriert. Ein neues Schlüsselpaar macht alle Browser-Push-Abos ungültig, jedes Gerät muss sich neu anmelden. Fortfahren?", "webPushVapidGenerateConfirmTitle": "VAPID-Schlüssel neu erzeugen?", "webPushVapidRegenerate": "Neu erzeugen", "configured": "Konfiguriert", @@ -7253,12 +7370,12 @@ "title": "Modulverfügbarkeit", "description": "Schalte ein Modul für den gesamten Server ab.", "saveError": "Modulverfügbarkeit konnte nicht aktualisiert werden.", - "detail": "Ein hier deaktiviertes Modul ist für jedes Konto aus — unabhängig von den persönlichen Einstellungen. Kerndaten (Gewicht, Blutdruck, Puls, Medikamente) sind immer aktiv und lassen sich nicht abschalten.", - "coachFollowsSwitch": "Folgt dem Coach-Schalter unter Assistenz." + "detail": "Gewicht, Blutdruck und Puls sind Kerndaten und bleiben immer aktiv.", + "coachFollowsSwitch": "Folgt dem Coach-Schalter im Bereich Coach." }, "invites": { - "title": "Einladungen", - "description": "Einladungslinks für die Registrierung — funktionieren auch bei geschlossener Registrierung.", + "title": "Einladungslinks", + "description": "Jeder Link lässt eine Registrierung zu, auch wenn sie geschlossen ist.", "create": "Einladung erstellen", "createTitle": "Einladung erstellen", "createDescription": "Der Link erlaubt eine Registrierung auch bei geschlossener Registrierung.", @@ -7270,8 +7387,8 @@ "createdToast": "Einladung erstellt", "createError": "Einladung konnte nicht erstellt werden.", "tokenTitle": "Einladungslink", - "tokenOnceHint": "Nur jetzt kopierbar — kopiere den Link oder lass den QR-Code scannen.", - "hashExplainer": "Gespeichert wird nur ein Hash des Links — er kann später nicht erneut angezeigt werden. Geht er verloren: Einladung widerrufen und neu erstellen.", + "tokenOnceHint": "Nur jetzt kopierbar. Kopiere den Link oder lass den QR-Code scannen.", + "hashExplainer": "Gespeichert wird nur ein Hash des Links, er kann später nicht erneut angezeigt werden. Geht er verloren: Einladung widerrufen und neu erstellen.", "copyLink": "Link kopieren", "copiedToast": "Link kopiert", "copyError": "Kopieren fehlgeschlagen.", @@ -7304,8 +7421,8 @@ "revokedToast": "Einladung widerrufen.", "revokeError": "Einladung konnte nicht widerrufen werden." }, - "userManagement": "Nutzerverwaltung", - "userManagementDescription": "Konten verwalten: Rollen, Passwort-Resets und erzwungene Abmeldungen.", + "userManagement": "Konten", + "userManagementDescription": "Rollen, Passwort-Resets und erzwungene Abmeldungen für jedes Konto.", "userName": "Name", "userEmail": "E-Mail", "userRole": "Rolle", @@ -7332,14 +7449,13 @@ "tokenName": "Name", "tokenPermissions": "Berechtigungen", "tokenPermissionAll": "Alle", - "tokenPermissionAllTooltip": "Wildcard-Berechtigung — dieses Token kann jede API-Route ansprechen.", + "tokenPermissionAllTooltip": "Wildcard-Berechtigung: Dieses Token kann jede API-Route ansprechen.", "tokenStatus": "Status", "tokenLastUsed": "Zuletzt benutzt", "tokenCreated": "Erstellt", "tokenNeverUsed": "Nie", "tokensEmptyTitle": "Noch keine API-Tokens", "tokensEmptyDescription": "Tokens werden bei der Anmeldung nativer Clients oder über Einstellungen → Account ausgestellt.", - "loginOverview": "Anmeldeübersicht", "loginOverviewDescription": "Anmelde- und Auth-Ereignisse aller Konten, filterbar und als CSV exportierbar.", "allAuthEvents": "Alle Auth-Ereignisse", "failedOnly": "Nur fehlgeschlagene", @@ -7372,6 +7488,7 @@ "finalDelete": "Endgültig löschen", "deletedResult": "Gelöscht: {rows} Zeilen in {tables} Tabellen. Nutzerkonten und Anmelde-Zugangsdaten bleiben erhalten.", "authRegister": "Registrierung", + "authClaim": "Profil übernommen", "authLogin": "Anmeldung", "authLoginPasskey": "Passkey-Anmeldung", "authLoginPassword": "Passwort-Anmeldung", @@ -7446,7 +7563,7 @@ }, "services": { "title": "Dienste", - "subtitle": "Benachrichtigungskanäle und Integrationen systemweit aktivieren oder deaktivieren." + "subtitle": "Benachrichtigungskanäle und die API für den ganzen Server ein- oder ausschalten." }, "integrations": { "title": "Integrationen", @@ -7510,7 +7627,7 @@ "app-logs": { "title": "App-Logs", "subtitle": "Letzte strukturierte Wide-Events dieses Prozesses anzeigen.", - "processNote": "Es werden nur App-Prozess-Logs angezeigt — Worker-Logs landen in Loki, sofern die Integration konfiguriert ist.", + "processNote": "Es werden nur App-Prozess-Logs angezeigt; Worker-Logs landen in Loki, sofern die Integration konfiguriert ist.", "filterTraceId": "Trace-ID", "filterAction": "Aktion enthält…", "filterLevel": "Level", @@ -7532,7 +7649,8 @@ "refresh": "Aktualisieren", "eventDetails": "Event-Details", "closeDetails": "Details schließen", - "processDetail": "Der Puffer ist flüchtig und auf 500 Einträge begrenzt." + "processDetail": "Der Puffer ist flüchtig und auf 500 Einträge begrenzt.", + "cardTitle": "Letzte Ereignisse" }, "backups": { "title": "Backups", @@ -7544,7 +7662,7 @@ "loading": "Backups werden geladen…", "loadError": "Backups konnten nicht geladen werden.", "emptyTitle": "Noch keine Backups", - "emptyDescription": "Backups laufen automatisch jeden Sonntag um 03:00 Uhr — oder erstelle jetzt einen ersten Snapshot.", + "emptyDescription": "Backups laufen automatisch jeden Sonntag um 03:00 Uhr, oder erstelle jetzt einen ersten Snapshot.", "colUser": "Nutzer", "colType": "Typ", "typeWeeklyAuto": "Wöchentlich (auto)", @@ -7580,11 +7698,11 @@ "restoreTitle": "Aus Backup wiederherstellen", "restoreDescription": "Dies ersetzt ALLE aktuellen Daten von {username} mit dem Snapshot von {when}. Vorhandene Messungen, Medikamente, Einnahmen, Stimmungseinträge, Benachrichtigungs-Kanäle, Push-Abos und Telegram-Lösch-Aufträge werden entfernt.", "restoreInstanceSettingsLabel": "Instanzweite Einstellungen mitwiederherstellen", - "restoreInstanceSettingsHint": "Registrierung, MFA-Pflicht, Standardsprache, Modulverfügbarkeit, Benachrichtigungs- und KI-Konfiguration — aus dem Snapshot, gültig für alle auf dieser Installation. Leer lassen, um nur dieses Konto wiederherzustellen.", + "restoreInstanceSettingsHint": "Registrierung, MFA-Pflicht, Standardsprache, Modulverfügbarkeit, Benachrichtigungs- und KI-Konfiguration aus dem Snapshot, gültig für alle auf dieser Installation. Leer lassen, um nur dieses Konto wiederherzustellen.", "restorePromptLabel": "Tippe RESTORE zum Fortfahren", "previewTitle": "Inhalt der Sicherung", "previewLoading": "Inhalt wird gelesen…", - "previewUnavailable": "Inhalt konnte nicht gelesen werden — die Wiederherstellung bleibt möglich.", + "previewUnavailable": "Inhalt konnte nicht gelesen werden, die Wiederherstellung bleibt möglich.", "previewStillReading": "Diese Sicherung stammt aus einer früheren Version, deshalb wird ihr Inhalt vollständig gelesen. Bei einem großen Datenbestand dauert das eine Weile. Öffne diesen Dialog in ein paar Minuten noch einmal, dann steht er hier. Die Wiederherstellung hängt nicht davon ab.", "previewEmpty": "Die Datei enthält keine Datensätze.", "previewOther": "Weitere Datensätze", @@ -7627,7 +7745,7 @@ "restoreSkippedMedicationTarget": "Zielwert-Analyt einer Medikation", "restoreSkippedScheduleRevisionLink": "Abfolge der Schema-Zeiträume", "restoreSkippedAccountSetting": "Kontoeinstellung", - "restoreMissingTitle": "Wiederherstellung abgelehnt — die Datei ist unvollständig", + "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.", "restoreMissingDocuments": "Dokumente", @@ -7699,7 +7817,8 @@ "restoreSkippedCycleCiphertext": "Zyklus-Notiz oder -Bezeichnung mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Tag oder Symptom ohne sie behalten)", "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)" + "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)" }, "danger-zone": { "title": "Gefahrenzone", @@ -7716,7 +7835,6 @@ "encryption": { "title": "Verschlüsselung", "subtitle": "Verschlüsselungsabdeckung und Schlüsselrotations-Status für ruhende Daten.", - "loading": "Verschlüsselungsstatus wird geladen…", "loadError": "Verschlüsselungsstatus konnte nicht geladen werden.", "coverageTitle": "Abdeckung", "coverageDescription": "Wie viele verschlüsselte Zeilen unter welcher Schlüssel-ID liegen.", @@ -7751,7 +7869,7 @@ "completed": "Abgeschlossen", "failed": "Fehlgeschlagen" }, - "coverageDetail": "Es werden nur Schlüssel-IDs und Anzahlen gezeigt — niemals Schlüsselmaterial.", + "coverageDetail": "Es werden nur Schlüssel-IDs und Anzahlen gezeigt, niemals Schlüsselmaterial.", "backupsTitle": "Schlüssel, die die Sicherungen noch brauchen", "backupsDescription": "Ein alter Schlüssel bleibt nötig, solange eine Sicherung unten ihn noch aufführt.", "backupsStoredLine": "Schlüssel {key}: {count} gespeicherte Kopien, die älteste vom {when}", @@ -7761,12 +7879,45 @@ "backupsNone": "Noch keine Sicherung hat die Schlüssel festgehalten, die sie braucht.", "backupsStillNeed": "Alle Zeilen sind rotiert, aber Sicherungen brauchen noch {keys}", "backupsUnrecordedBadge": "Alle Zeilen sind rotiert, aber ältere Sicherungen können noch einen ausgemusterten Schlüssel brauchen", - "backupsDetail": "Die Rotation verschlüsselt jede Zeile neu, aber eine Sicherung behält den Schlüssel, unter dem ihr Inhalt geschrieben wurde, und ihre Wiederherstellung braucht ihn. Behalte einen ausgemusterten Schlüssel in ENCRYPTION_KEYS, bis er unten nicht mehr aufgeführt ist." + "backupsDetail": "Die Rotation verschlüsselt jede Zeile neu, aber eine Sicherung behält den Schlüssel, unter dem ihr Inhalt geschrieben wurde, und ihre Wiederherstellung braucht ihn. Behalte einen ausgemusterten Schlüssel in ENCRYPTION_KEYS, bis er unten nicht mehr aufgeführt ist.", + "columnsShow": "Tabelle anzeigen ({count} mit Zeilen)", + "columnsHide": "Tabelle ausblenden", + "columnsShowAll": "Alle {count} Spalten anzeigen", + "columnsShowWithRows": "Nur Spalten mit Zeilen", + "columnsNoneWithRows": "Noch keine verschlüsselte Spalte enthält Zeilen." + }, + "maintenance": { + "title": "Pflege der Messwert-Tabelle", + "description": "Gibt den Platz frei, den alte Sync-Reste belegt haben, sobald die Bereinigung gelaufen ist.", + "body": "Sie räumt die Messwert-Tabelle auf und baut danach ihre Indizes neu auf, einen nach dem anderen. Lesen und Speichern funktionieren währenddessen weiter. Wähle eine ruhige Stunde und halte freien Speicher für eine weitere Kopie des größten Index bereit.", + "tableSize": "Tabelle mit Indizes", + "indexSize": "Indizes", + "loading": "Stand der Pflege wird geprüft…", + "loadError": "Der Stand der Pflege konnte nicht gelesen werden.", + "unavailable": "Es ist kein Hintergrunddienst verbunden, deshalb kann die Pflege hier nicht laufen.", + "purgePending": "Die Bereinigung alter Sync-Reste läuft noch. Die Pflege kann starten, sobald sie fertig ist.", + "state": { + "idle": "Noch nicht gelaufen.", + "queued": "Wartet auf den Hintergrunddienst.", + "running": "Läuft seit {since}. Auf einer großen Instanz kann das eine Weile dauern.", + "completed": "Fertig am {at}.", + "refusedPurge": "Nicht gestartet, weil die Bereinigung noch lief. Starte sie erneut, sobald die Bereinigung fertig ist.", + "alreadyRunning": "Nicht gestartet, weil bereits ein anderer Lauf arbeitete.", + "failed": "Angehalten am {at}. Das App-Protokoll nennt den Schritt, das Runbook erklärt, was zu prüfen ist." + }, + "start": "Pflege starten", + "confirmTitle": "Pflege jetzt starten?", + "confirmBody": "Sie läuft im Hintergrund, Schritt für Schritt, und kann eine Weile dauern. Wenn du heute noch keine Sicherung erstellt hast, erstelle zuerst eine.", + "confirm": "Starten", + "cancel": "Abbrechen", + "started": "Pflege gestartet.", + "alreadyQueued": "Ein Pflegelauf ist bereits eingeplant oder läuft.", + "startFailed": "Die Pflege konnte nicht gestartet werden." } }, "carrier": "Netzbetreiber", "mfaRequired": "Zweiten Faktor verlangen", - "mfaRequiredDescription": "Jedes Konto ohne aktiven zweiten Faktor wird nach der Anmeldung zur Einrichtung geleitet. Das fordert Web-Nutzer zur Einrichtung auf — es blockiert jedoch keinen direkten API- oder nativen (Bearer-)Zugriff für noch nicht eingerichtete Konten.", + "mfaRequiredDescription": "Konten ohne zweiten Faktor werden nach der Web-Anmeldung zur Einrichtung geleitet; API- und nativer (Bearer-)Zugriff bleiben offen.", "mfaEnforcedOn": "MFA an", "mfaEnforcedOff": "MFA aus", "mfaEnforcedOnHint": "Für diesen Nutzer ist ein zweiter Faktor erforderlich. Klicken, um die Anforderung aufzuheben.", @@ -7784,7 +7935,7 @@ "centralCodex": { "title": "Gemeinsame KI-Verbindung", "description": "Verbinde ein angemeldetes ChatGPT-Konto, das jede Nutzerin und jeder Nutzer dieses Servers für KI-Funktionen aktivieren kann.", - "honesty": "Dies ist ein einzelnes gemeinsames, angemeldetes KI-Konto — kein API-Schlüssel. Alle, die zustimmen, senden Anfragen darüber, sodass die Stunden- und Wochenlimits des Kontos unter ihnen geteilt werden; dieselben Limits speisen bereits den Coach. Abo-Anbieter dürfen die Inhalte zur Verbesserung ihrer Modelle verwenden, und keine Auftragsverarbeitungsvereinbarung deckt dies ab — daher ist es nie Standard und steht beim Lesen von Dokumenten hinter Anbietern ohne Training. Verbinde nur ein Konto, das du auf diese Weise teilen möchtest.", + "honesty": "Dies ist ein einzelnes gemeinsames, angemeldetes KI-Konto, kein API-Schlüssel. Alle, die zustimmen, senden Anfragen darüber, sodass die Stunden- und Wochenlimits des Kontos unter ihnen geteilt werden; dieselben Limits speisen bereits den Coach. Abo-Anbieter dürfen die Inhalte zur Verbesserung ihrer Modelle verwenden, und keine Auftragsverarbeitungsvereinbarung deckt dies ab. Daher ist es nie Standard und steht beim Lesen von Dokumenten hinter Anbietern ohne Training. Verbinde nur ein Konto, das du auf diese Weise teilen möchtest.", "statusConnected": "Verbunden", "statusDisconnected": "Nicht verbunden", "statusExpired": "Abgelaufen", @@ -7796,13 +7947,13 @@ "connectedSince": "Verbunden seit {when}", "connected": "Gemeinsame KI-Verbindung verknüpft", "disconnected": "Gemeinsame KI-Verbindung entfernt", - "error": "Etwas ist schiefgelaufen — bitte erneut versuchen" + "error": "Etwas ist schiefgelaufen. Bitte erneut versuchen" }, "failingJobs": { "title": "Hintergrundjobs", "description": "Queues, deren Jobs nach allen Wiederholungen aufgegeben haben.", "none": "In den letzten {hours} Stunden ist kein Job fehlgeschlagen.", - "unavailable": "Keine Job-Queue lesbar — dieses Deployment läuft ohne Hintergrund-Worker.", + "unavailable": "Keine Job-Queue lesbar. Dieses Deployment läuft ohne Hintergrund-Worker.", "countOne": "{count} Fehlschlag", "countFew": "{count} Fehlschläge", "countOther": "{count} Fehlschläge", @@ -7864,7 +8015,9 @@ "restartNote": "Starte HealthLog nach der Änderung neu.", "lastResort": "Letzter Ausweg, nur wenn du sicher bist, dass der Schlüssel stimmt, und keiner der drei Wege passt: setze {setting} und starte neu. HealthLog liefert dann trotzdem aus und schreibt den Befund nur ins Log. Damit ist eine Sicherheitsprüfung abgeschaltet: Mit einem falschen Schlüssel scheitert jeder verschlüsselte Wert, und neue Daten werden unter diesem Schlüssel geschrieben.", "codes": "Für den Betrieb: API-Anfragen antworten mit 503 und dem Fehlercode {code}, /api/health meldet den Grund {reason}." - } + }, + "loginOverviewCardTitle": "Anmeldeereignisse", + "userColumn": "Nutzer" }, "achievements": { "title": "Erfolge", @@ -7880,7 +8033,9 @@ "noneUnlockedYet": "Noch keine Erfolge freigeschaltet.", "pointsValue": "{points} Punkte", "metricCount": "{count}", - "metricDays": "{count} Tage", + "metricDaysOne": "{count} Tag", + "metricDaysFew": "{count} Tage", + "metricDaysOther": "{count} Tage", "metricPercent": "{count}%", "badges": { "intakeTotal1": { @@ -7903,14 +8058,6 @@ "title": "Einnahme-Legende", "description": "Dokumentiere 300 eingenommene Medikamente." }, - "overIntake1": { - "title": "Doppelt genommen", - "description": "Nimm mindestens einmal mehr ein als vorgesehen." - }, - "skippedIntake1": { - "title": "Pause eingelegt", - "description": "Überspringe mindestens eine geplante Einnahme." - }, "passkeyCreated1": { "title": "Passkey gesetzt", "description": "Erstelle deinen ersten Passkey." @@ -7936,19 +8083,19 @@ "description": "Nimm an einem Tag alle Medikamente im Zeitfenster ein." }, "compliance801": { - "title": "Treue 80% · 1T", + "title": "80% Treue für 1 Tag", "description": "Erreiche an einem Tag eine 30-Tage-Therapietreue von mindestens 80%." }, "bmiGreen1": { - "title": "BMI im Grün · 1T", + "title": "BMI 1 Tag im Grün", "description": "Halte deinen BMI an einem Tag im Normalbereich." }, "bpGreen1": { - "title": "Blutdruck im Grün · 1T", + "title": "Blutdruck 1 Tag im Grün", "description": "Halte deinen Blutdruck an einem Tag im Normalbereich." }, "pulseGreen1": { - "title": "Puls im Grün · 1T", + "title": "Puls 1 Tag im Grün", "description": "Halte deinen Ruhepuls an einem Tag im Normalbereich." }, "onTimePerfect7": { @@ -7956,19 +8103,19 @@ "description": "Nimm 7 Tage in Folge alle Medikamente im Zeitfenster ein." }, "compliance807": { - "title": "Treue 80% · 7T", + "title": "80% Treue für 7 Tage", "description": "Erreiche 7 Tage in Folge eine 30-Tage-Therapietreue von mindestens 80%." }, "bmiGreen7": { - "title": "BMI im Grün · 7T", + "title": "BMI 7 Tage im Grün", "description": "Halte deinen BMI 7 Tage in Folge im Normalbereich." }, "bpGreen7": { - "title": "Blutdruck im Grün · 7T", + "title": "Blutdruck 7 Tage im Grün", "description": "Halte deinen Blutdruck 7 Tage in Folge im Normalbereich." }, "pulseGreen7": { - "title": "Puls im Grün · 7T", + "title": "Puls 7 Tage im Grün", "description": "Halte deinen Ruhepuls 7 Tage in Folge im Normalbereich." }, "onTimePerfect30": { @@ -7976,19 +8123,19 @@ "description": "Nimm 30 Tage in Folge alle Medikamente im Zeitfenster ein." }, "compliance8030": { - "title": "Treue 80% · 30T", + "title": "80% Treue für 30 Tage", "description": "Erreiche 30 Tage in Folge eine 30-Tage-Therapietreue von mindestens 80%." }, "bmiGreen30": { - "title": "BMI im Grün · 30T", + "title": "BMI 30 Tage im Grün", "description": "Halte deinen BMI 30 Tage in Folge im Normalbereich." }, "bpGreen30": { - "title": "Blutdruck im Grün · 30T", + "title": "Blutdruck 30 Tage im Grün", "description": "Halte deinen Blutdruck 30 Tage in Folge im Normalbereich." }, "pulseGreen30": { - "title": "Puls im Grün · 30T", + "title": "Puls 30 Tage im Grün", "description": "Halte deinen Ruhepuls 30 Tage in Folge im Normalbereich." }, "onTimePerfect180": { @@ -7996,19 +8143,19 @@ "description": "Nimm 180 Tage in Folge alle Medikamente im Zeitfenster ein." }, "compliance80180": { - "title": "Treue 80% · 180T", + "title": "80% Treue für 180 Tage", "description": "Erreiche 180 Tage in Folge eine 30-Tage-Therapietreue von mindestens 80%." }, "bmiGreen180": { - "title": "BMI im Grün · 180T", + "title": "BMI 180 Tage im Grün", "description": "Halte deinen BMI 180 Tage in Folge im Normalbereich." }, "bpGreen180": { - "title": "Blutdruck im Grün · 180T", + "title": "Blutdruck 180 Tage im Grün", "description": "Halte deinen Blutdruck 180 Tage in Folge im Normalbereich." }, "pulseGreen180": { - "title": "Puls im Grün · 180T", + "title": "Puls 180 Tage im Grün", "description": "Halte deinen Ruhepuls 180 Tage in Folge im Normalbereich." }, "onTimePerfect360": { @@ -8016,19 +8163,19 @@ "description": "Nimm 360 Tage in Folge alle Medikamente im Zeitfenster ein." }, "compliance80360": { - "title": "Treue 80% · 360T", + "title": "80% Treue für 360 Tage", "description": "Erreiche 360 Tage in Folge eine 30-Tage-Therapietreue von mindestens 80%." }, "bmiGreen360": { - "title": "BMI im Grün · 360T", + "title": "BMI 360 Tage im Grün", "description": "Halte deinen BMI 360 Tage in Folge im Normalbereich." }, "bpGreen360": { - "title": "Blutdruck im Grün · 360T", + "title": "Blutdruck 360 Tage im Grün", "description": "Halte deinen Blutdruck 360 Tage in Folge im Normalbereich." }, "pulseGreen360": { - "title": "Puls im Grün · 360T", + "title": "Puls 360 Tage im Grün", "description": "Halte deinen Ruhepuls 360 Tage in Folge im Normalbereich." }, "moodFirst": { @@ -8112,15 +8259,15 @@ "description": "Du hast die App-Sprache mindestens einmal gewechselt." }, "missFree7": { - "title": "Lückenlos · 7T", + "title": "7 Tage lückenlos", "description": "7 Tage in Folge ohne eine verpasste Dosis." }, "missFree30": { - "title": "Lückenlos · 30T", + "title": "30 Tage lückenlos", "description": "30 Tage in Folge ohne eine verpasste Dosis." }, "missFree90": { - "title": "Lückenlos · 90T", + "title": "90 Tage lückenlos", "description": "90 Tage in Folge ohne eine verpasste Dosis." }, "measurementWeeks4": { @@ -8129,10 +8276,10 @@ }, "selfContextComplete": { "title": "Selbstauskunft gepflegt", - "description": "Alle vier Felder der Selbstauskunft ausgefüllt — der Coach kennt deinen Kontext." + "description": "Alle vier Felder der Selbstauskunft ausgefüllt. Der Coach kennt deinen Kontext." }, "sleepLog7": { - "title": "Schlafprotokoll · 7T", + "title": "7 Tage Schlafprotokoll", "description": "An 7 Tagen in Folge Schlafdaten erfasst." } }, @@ -8151,7 +8298,7 @@ }, "hiddenCard": { "title": "Versteckter Erfolg", - "description": "Tracke fleißig weiter — vielleicht stolperst du darüber.", + "description": "Tracke fleißig weiter, vielleicht stolperst du darüber.", "ariaLabel": "Versteckter, gesperrter Erfolg" }, "hiddenUnlockToast": { @@ -8160,7 +8307,7 @@ "dashboardCard": { "title": "Zuletzt freigeschaltet", "viewAll": "Alle anzeigen", - "empty": "Noch keine Erfolge — trage weiter ein, um deinen ersten freizuschalten." + "empty": "Noch keine Erfolge. Trage weiter ein, um deinen ersten freizuschalten." } }, "notifications": { @@ -8222,9 +8369,6 @@ "enabledToast": "Proaktive Coach-Hinweise aktiviert.", "disabledToast": "Proaktive Coach-Hinweise deaktiviert.", "saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen.", - "suggestionsLabel": "Beispielvorschläge", - "suggestionsDesc": "Zeigt auf dem Coach-Bildschirm gelegentlich eine fertige Beispielfrage.", - "suggestionsAria": "Beispiel-Coach-Vorschläge umschalten", "aiComposedLabel": "Den Coach den Anstoß formulieren lassen", "aiComposedDesc": "Wenn ein Anbieter verfügbar ist, formuliert das Modell den proaktiven Anstoß statt der festen Vorlage. Standardmäßig aus.", "aiComposedAria": "KI-formulierten proaktiven Anstoß umschalten" @@ -8295,7 +8439,27 @@ "loginRequired": "Bitte melde dich an, um Benachrichtigungen zu verwalten.", "someChannelsGloballyDisabled": "Ein oder mehrere Kanäle sind in den Einstellungen deaktiviert und erhalten keine Benachrichtigungen.", "reminderSettingsLink": "Erinnerungseinstellungen", - "channelsLink": "Kanäle" + "channelsLink": "Kanäle", + "handover": { + "claimedTitle": "{name} hat jetzt ein eigenes Konto", + "claimedEnd": "{name} hat die eigene Gesundheitsakte übernommen. Dein Zugriff ist beendet.", + "claimedRead": "{name} hat die eigene Gesundheitsakte übernommen. Du kannst sie weiter ansehen, aber nicht mehr ändern.", + "claimedManage": "{name} hat die eigene Gesundheitsakte übernommen. Du kannst sie weiter verwalten, bis {name} anders entscheidet.", + "changedTitle": "{name} hat deinen Zugriff geändert", + "changedEnd": "Dein Zugriff auf die Gesundheitsakte von {name} ist beendet.", + "changedRead": "Du kannst die Gesundheitsakte von {name} jetzt ansehen, aber nicht ändern.", + "changedManage": "Du kannst die Gesundheitsakte von {name} verwalten.", + "unnamed": { + "claimedTitle": "Die Person, die du betreut hast, hat jetzt ein eigenes Konto", + "claimedEnd": "Die Person, die du betreut hast, hat die eigene Gesundheitsakte übernommen. Dein Zugriff ist beendet.", + "claimedRead": "Die Person, die du betreut hast, hat die eigene Gesundheitsakte übernommen. Du kannst sie weiter ansehen, aber nicht mehr ändern.", + "claimedManage": "Die Person, die du betreut hast, hat die eigene Gesundheitsakte übernommen. Du kannst sie weiter verwalten, bis sie anders entscheidet.", + "changedTitle": "Dein Zugriff auf eine Gesundheitsakte hat sich geändert", + "changedEnd": "Dein Zugriff auf die Gesundheitsakte der Person, die du betreut hast, ist beendet.", + "changedRead": "Du kannst die Gesundheitsakte der Person, die du betreut hast, jetzt ansehen, aber nicht ändern.", + "changedManage": "Du kannst die Gesundheitsakte der Person, die du betreut hast, verwalten." + } + } }, "i18n": { "maintainershipBanner": { @@ -8326,7 +8490,7 @@ } }, "edit": { - "title": "Zielbereich bearbeiten — {metric}", + "title": "Zielbereich bearbeiten: {metric}", "description": "Passt den Zielbereich an, der in Charts, Insights und im Arztbericht verwendet wird. Werte in {unit}.", "derivedHint": "Diese Karte wird aus anderen Metriken abgeleitet. Bearbeite stattdessen die zugrundeliegenden Ziele (z. B. Blutdruck oder Gewicht).", "minLabel": "Min", @@ -8350,15 +8514,19 @@ "currentValue": "Aktuell: {value} {unit}", "targetRangeValue": "Zielbereich: {min}–{max} {unit}", "sourceLabel": "Quelle: {source}", + "sourceNames": { + "moodEntries": "Stimmungseinträge", + "sevenDays": "7 Tage" + }, "inTarget": "Im Zielbereich", "aboveTarget": "{delta} {unit} über Zielbereich", "belowTarget": "{delta} {unit} unter Zielbereich", "diastolic": "Diastolisch", - "glucoseFasting": "Glukose — nüchtern", - "glucosePostprandial": "Glukose — postprandial", - "glucoseRandom": "Glukose — beliebig", - "glucoseBedtime": "Glukose — vor dem Schlafen", - "glucoseUnspecified": "Glukose — ohne Angabe", + "glucoseFasting": "Glukose, nüchtern", + "glucosePostprandial": "Glukose, postprandial", + "glucoseRandom": "Glukose, beliebig", + "glucoseBedtime": "Glukose, vor dem Schlafen", + "glucoseUnspecified": "Glukose, ohne Angabe", "status": { "underweight": "Untergewicht", "normal": "Normal", @@ -8441,10 +8609,10 @@ "noActiveMedications": "Keine aktiven Medikamente gefunden.", "whichMedication": "Welches Medikament eingenommen?", "cancelButton": "Abbrechen", - "askMedicationName": "Bitte Medikament angeben, z.B. genommen Ramipril.", + "askMedicationName": "Bitte Medikament angeben, z. B. genommen Ramipril.", "medicationNotFoundExact": "Medikament nicht gefunden. Bitte Namen exakt wie in HealthLog senden.", "helpHeader": "Verfügbare Befehle:", - "helpBody": "/help — Diese Hilfe anzeigen\n/add — Einnahme erfassen\n/start — Bot starten\n\nTextbefehle:\ngenommen <Name> — Einnahme bestätigen\n\nÜber die Buttons in Erinnerungen:\n• Genommen — Einnahme bestätigen\n• 🕐 1h / 🕐 3h — Erinnerung verschieben\n• ⏭ Überspringen — Einnahme überspringen\n• ✓ Bestätigen — Verpasste Einnahme bestätigen", + "helpBody": "/help: Diese Hilfe anzeigen\n/add: Einnahme erfassen\n/start: Bot starten\n\nTextbefehle:\ngenommen <Name>: Einnahme bestätigen\n\nÜber die Buttons in Erinnerungen:\n• Genommen: Einnahme bestätigen\n• 🕐 1h / 🕐 3h: Erinnerung verschieben\n• ⏭ Überspringen: Einnahme überspringen\n• ✓ Bestätigen: Verpasste Einnahme bestätigen", "measureValuePrompt": "Antworte mit dem Wert, um ihn zu speichern (optional).", "buttonMeasureValue": "Wert…", "measureValueSaved": "Messwert gespeichert.", @@ -8470,7 +8638,7 @@ "lowStockReminders": { "title": "Vorrat niedrig: {medName}", "bodyToday": "Dein Vorrat an {medName} reicht keinen ganzen Tag mehr ({units} Einheiten übrig).", - "bodyReorder": "Dein Vorrat an {medName} ({units} Einheiten) ist um den {runsOutOn} aufgebraucht — bestelle bis zum {reorderBy} nach.", + "bodyReorder": "Dein Vorrat an {medName} ({units} Einheiten) ist um den {runsOutOn} aufgebraucht. Bestelle bis zum {reorderBy} nach.", "bodyLastDose": "Dein Vorrat an {medName} ({units} Einheiten) reicht noch etwa für die letzte Dosis. Aufgebraucht um den {runsOutOn}.", "bodyExpired": "{medName}: kein nutzbarer Bestand mehr. {units} Einheiten sind als abgelaufen markiert.", "bodyAsNeeded": "Von {medName} sind nur noch {units} Einheiten übrig. Bestelle nach, bevor du die nächste Dosis brauchst." @@ -8498,11 +8666,11 @@ "questionConditions": "Hast du chronische Erkrankungen, die der Coach kennen sollte?", "questionFocus": "Gibt es etwas Bestimmtes, worauf der Coach achten soll?", "questionAllergies": "Hast du Allergien oder Unverträglichkeiten?", - "questionAboutMe": "Magst du dem Coach etwas über dich erzählen — Alltag, Ziele, Kontext?" + "questionAboutMe": "Magst du dem Coach etwas über dich erzählen? Alltag, Ziele, Kontext?" }, "clinicianView": { "title": "Geteilte Gesundheitsakte", - "period": "Berichtszeitraum: {start} – {end}", + "period": "Berichtszeitraum: {start}–{end}", "expires": "Dieser Link läuft am {date} ab.", "provenance": "Dies ist eine schreibgeschützte Zusammenfassung, die von der betreffenden Person geteilt wurde, exportiert aus ihren selbst erfassten HealthLog-Daten. Die Werte sind selbst erhoben und können geräteübertragene Messwerte enthalten; behandeln Sie sie als von der Patientin/dem Patienten berichtet, nicht als klinische Untersuchung.", "labs": "Laborwerte", @@ -8526,7 +8694,7 @@ "strain": "Belastungswert", "score": "Wellness-Wert" }, - "footer": "Geteilt aus HealthLog — einem selbst gehosteten Gesundheits-Tracker.", + "footer": "Geteilt aus HealthLog, einem selbst gehosteten Gesundheits-Tracker.", "unlock": { "title": "Passphrase eingeben", "description": "Diese geteilte Gesundheitsakte ist geschützt. Geben Sie die erhaltene Passphrase ein, um sie anzuzeigen.", @@ -8561,7 +8729,11 @@ }, "course": "Phase {n}", "courseRange": "{start} bis {end}", - "courseSince": "seit {start}" + "courseSince": "seit {start}", + "unavailable": { + "title": "Link abgelaufen oder ungültig", + "description": "Dieser Freigabelink ist abgelaufen, wurde zurückgezogen oder ist unvollständig. Bitte die Person, die ihn geteilt hat, um einen neuen Link." + } }, "cycle": { "symptomCategory": { @@ -8610,11 +8782,11 @@ "generic": "Dein/e {metric} liegt in der Lutealphase etwa {delta} {unit} {dir} als in der Follikelphase.", "dirHigher": "höher", "dirLower": "niedriger", - "empty": "Noch nicht genug Zyklen und Messwerte für ein Phasenmuster. Logge weiter — das füllt sich, je länger deine Historie wird." + "empty": "Noch nicht genug Zyklen und Messwerte für ein Phasenmuster. Logge weiter, das füllt sich, je länger deine Historie wird." }, "crosstab": { - "description": "Wie sich jeder Messwert zwischen deiner Luteal- und Follikelphase vergleicht — auf deinen eigenen Daten.", - "pairLabel": "{metric} · Luteal vs. Follikel", + "description": "Wie sich jeder Messwert zwischen deiner Luteal- und Follikelphase vergleicht, auf deinen eigenen Daten.", + "pairLabel": "{metric}: Luteal- vs. Follikelphase", "detail": "Luteal {lutealAvg} {unit} ({lutealDays} Tage) vs. Follikel {follicularAvg} {unit} ({follicularDays} Tage).", "metricRestingHeartRate": "Ruhepuls", "metricHrv": "Herzfrequenzvariabilität", @@ -8650,7 +8822,7 @@ }, "context": { "MENSTRUAL": "Die Hormonspiegel sind jetzt am niedrigsten, weshalb sich diese Tage wie ein ruhiger Neustart anfühlen können. Manche bemerken Krämpfe, leichteren Schlaf oder den Wunsch, einen Gang zurückzuschalten; andere kommen fast unverändert durch. All das liegt im normalen Rahmen, und wie es sich anfühlt, kann von Zyklus zu Zyklus wechseln.", - "FOLLICULAR": "Mit steigendem Östrogen beschreiben viele diese Tage als hell und offen — der Schlaf fällt oft leichter und der Kopf fühlt sich klarer an. Die Basaltemperatur bleibt meist im niedrigeren Bereich, bis kurz vor dem Eisprung. Die Länge dieser Phase schwankt am stärksten — von Person zu Person und von Zyklus zu Zyklus.", + "FOLLICULAR": "Mit steigendem Östrogen beschreiben viele diese Tage als hell und offen: Der Schlaf fällt oft leichter und der Kopf fühlt sich klarer an. Die Basaltemperatur bleibt meist im niedrigeren Bereich, bis kurz vor dem Eisprung. Die Länge dieser Phase schwankt am stärksten, von Person zu Person und von Zyklus zu Zyklus.", "OVULATORY": "Das Östrogen erreicht kurz vor dem Eisprung seinen Höhepunkt, oft begleitet von einem kurzen Stimmungshoch und einem leichten Anstieg der Basaltemperatur in den ein, zwei Tagen danach. Der Zervixschleim wird in diesem Fenster meist klarer und dehnbarer. Ein kurzes Ziehen auf einer Seite bemerken manche; viele spüren gar nichts, und beides ist völlig normal.", "LUTEAL": "Das Progesteron führt durch die zweite Zyklushälfte, und die Basaltemperatur liegt meist etwas höher als zuvor im Zyklus. Manche bemerken im Lauf der Tage Veränderungen bei Schlaf, Appetit, Stimmung oder Energie, während andere durchgehend ausgeglichen bleiben. Das Muster ist persönlich und sieht von Monat zu Monat oft anders aus." }, @@ -8737,8 +8909,8 @@ "ariaUnknown": "Noch kein aktiver Zyklus", "caption": "deines Zyklus", "firstPeriodCta": "Erste Periode erfassen", - "periodOverdue": "Deine Periode ist später als üblich — die Tageszählung pausiert, bis die nächste Periode erfasst ist.", - "periodOverdueDays": "Deine Periode ist {days} Tage später als üblich — die Tageszählung pausiert, bis die nächste Periode erfasst ist.", + "periodOverdue": "Deine Periode ist später als üblich. Die Tageszählung pausiert, bis die nächste Periode erfasst ist.", + "periodOverdueDays": "Deine Periode ist {days} Tage später als üblich. Die Tageszählung pausiert, bis die nächste Periode erfasst ist.", "logPeriodCta": "Periode erfassen" }, "calendar": { @@ -8750,7 +8922,7 @@ "prevMonth": "Vorheriger Monat", "nextMonth": "Nächster Monat", "loading": "Kalender wird geladen…", - "learning": "Wir lernen deinen Zyklus — erfasse mindestens drei volle Zyklen, dann füllen sich fruchtbares Fenster, Eisprung und Phasen. Bis dahin zeigt der Kalender nur, was du erfasst hast.", + "learning": "Wir lernen deinen Zyklus. Erfasse mindestens drei volle Zyklen, dann füllen sich fruchtbares Fenster, Eisprung und Phasen. Bis dahin zeigt der Kalender nur, was du erfasst hast.", "legendSymptom": "Symptome", "legendOvulationConfirmed": "Bestätigter Eisprung", "flowSpotting": "Schmierblutung", @@ -8763,7 +8935,7 @@ "predictions": { "title": "Was als Nächstes kommt", "nextPeriod": "Nächste Periode", - "nextPeriodWindow": "{low} – {high}", + "nextPeriodWindow": "{low}–{high}", "fertileWindow": "Fruchtbares Fenster", "ovulation": "Geschätzter Eisprung", "confidenceHigh": "Hoch", @@ -8772,7 +8944,7 @@ "stillLearning": "Noch am Lernen", "stillLearningBody": "Erfasse mindestens drei vollständige Zyklen, dann werden deine Prognosen genauer. Bis dahin ist dies eine grobe Schätzung.", "rawMode": "Prognose ist aus", - "rawModeBody": "Du hast Prognosen ausgeschaltet — es werden nur deine erfassten Daten gezeigt, ohne Vorhersage.", + "rawModeBody": "Du hast Prognosen ausgeschaltet. Es werden nur deine erfassten Daten gezeigt, ohne Vorhersage.", "none": "Noch keine Prognose. Erfasse eine Periode, um zu starten.", "ovulationConfirmed": "Eisprung (bestätigt)" }, @@ -8805,7 +8977,7 @@ "symptoms": "Symptome", "temperature": "Basaltemperatur", "temperatureDisturbed": "Messwert als gestört markieren", - "temperatureDisturbedHint": "Fieber, Krankheit, Alkohol oder eine späte Messung — wird von der Temperaturauswertung ausgeschlossen.", + "temperatureDisturbedHint": "Fieber, Krankheit, Alkohol oder eine späte Messung: wird von der Temperaturauswertung ausgeschlossen.", "ovulationTest": "Ovulationstest", "mucus": "Zervixschleim", "cervix": "Gebärmutterhals", @@ -8834,7 +9006,7 @@ "temperatureSection": "Temperatur & Eisprung", "intimacySection": "Intimität & Verhütung", "testsSection": "Tests", - "phaseDayHeader": "Tag {day} · {phase}", + "phaseDayHeader": "Tag {day}, {phase}", "bbtHint": "Miss sie morgens als Erstes, noch vor dem Aufstehen.", "titleForDate": "Eintrag für {date}", "descriptionToday": "Heute. Stärke, Symptome und Notizen.", @@ -8868,7 +9040,7 @@ "remindersDescription": "Perioden-Erinnerungen pro Kanal in den Benachrichtigungseinstellungen aktivieren.", "remindersLink": "Benachrichtigungseinstellungen", "fertileReminder": "Erinnerung an das fruchtbare Fenster", - "fertileReminderDescription": "Erhalte einige Tage vor deinem geschätzten fruchtbaren Fenster einen Hinweis. Standardmäßig aus — aktiviere sie pro Kanal in den Benachrichtigungseinstellungen." + "fertileReminderDescription": "Erhalte einige Tage vor deinem geschätzten fruchtbaren Fenster einen Hinweis. Standardmäßig aus. Aktiviere sie pro Kanal in den Benachrichtigungseinstellungen." }, "testResult": { "POSITIVE": "Positiv", @@ -8901,15 +9073,15 @@ "fieldInfo": { "bbt": "Deine Ruhetemperatur, morgens als Erstes vor dem Aufstehen gemessen. Nach dem Eisprung steigt sie leicht an (etwa 0,2–0,3 °C).", "bbtLabel": "Was ist die Basaltemperatur?", - "mucus": "Der Ausfluss verändert sich im Zyklus — von trocken oder klebrig zu cremig, dann wässrig und spinnbar rund um die fruchtbarsten Tage.", + "mucus": "Der Ausfluss verändert sich im Zyklus: von trocken oder klebrig zu cremig, dann wässrig und spinnbar rund um die fruchtbarsten Tage.", "mucusLabel": "Was ist Zervixschleim?", - "ovulationTest": "Ein Urintest für zu Hause, der den LH-Anstieg erkennt — den Hormonschub etwa 24–36 h vor dem Eisprung. Ein positives Ergebnis markiert die fruchtbarste Phase.", + "ovulationTest": "Ein Urintest für zu Hause, der den LH-Anstieg erkennt, also den Hormonschub etwa 24–36 h vor dem Eisprung. Ein positives Ergebnis markiert die fruchtbarste Phase.", "ovulationTestLabel": "Was ist ein Ovulationstest?", "spotting": "Leichte Blutung außerhalb der Periode.", "spottingLabel": "Was ist Schmierblutung zwischen den Perioden?", "pregnancyTest": "Ergebnis eines Schwangerschaftstests (hCG) für zu Hause.", "pregnancyTestLabel": "Was ist ein Schwangerschaftstest?", - "progesteroneTest": "Ein Test, der bestätigt, dass der Eisprung stattgefunden hat — das Progesteron steigt danach an.", + "progesteroneTest": "Ein Test, der bestätigt, dass der Eisprung stattgefunden hat. Das Progesteron steigt danach an.", "progesteroneTestLabel": "Was ist ein Progesterontest?", "intercourse": "Ob an diesem Tag Geschlechtsverkehr stattgefunden hat.", "intercourseLabel": "Was erfasst Geschlechtsverkehr?", @@ -8962,14 +9134,14 @@ "postpone": { "menuItem": "Verschieben…", "title": "Fälligkeit verschieben", - "description": "Verschiebt nur die aktuelle Fälligkeit — der reguläre Rhythmus bleibt unverändert.", + "description": "Verschiebt nur die aktuelle Fälligkeit, der reguläre Rhythmus bleibt unverändert.", "chip": "+{days} Tage", "dateLabel": "Neues Datum", "confirm": "Verschieben" }, "skip": { "heading": "Diese Fälligkeit überspringen", - "explainer": "Wird ehrlich als übersprungen festgehalten — nicht als erledigt. Das Intervall startet ab heute neu.", + "explainer": "Wird ehrlich als übersprungen festgehalten, nicht als erledigt. Das Intervall startet ab heute neu.", "action": "Überspringen", "confirmTitle": "Fälligkeit überspringen?" }, @@ -9011,10 +9183,10 @@ "VISCERAL_FAT": "Viszeralfett", "BODY_MASS_INDEX": "Body-Mass-Index (BMI)", "WAIST_CIRCUMFERENCE": "Taillenumfang", - "PHQ9_SCORE": "PHQ-9 · Stimmung", - "GAD7_SCORE": "GAD-7 · Sorgen", - "WHO5_SCORE": "WHO-5 · Wohlbefinden", - "SCI_SCORE": "SCI · Schlaf" + "PHQ9_SCORE": "PHQ-9 (Stimmung)", + "GAD7_SCORE": "GAD-7 (Sorgen)", + "WHO5_SCORE": "WHO-5 (Wohlbefinden)", + "SCI_SCORE": "SCI (Schlaf)" }, "typeGroups": { "vitals": "Vitalwerte", @@ -9110,7 +9282,7 @@ "localOcr": { "heading": "Scans auf diesem Gerät lesen", "description": "Liest das Foto auf deinem Gerät und sendet nur den erkannten Text an deinen Anbieter.", - "detail": "Nützlich, wenn dein KI-Anbieter keine Bilder lesen kann. Weniger genau — prüfe jeden Wert vor dem Speichern." + "detail": "Nützlich, wenn dein KI-Anbieter keine Bilder lesen kann. Weniger genau, prüfe jeden Wert vor dem Speichern." }, "sort": { "heading": "Sortierung", @@ -9154,7 +9326,7 @@ "sourceRangePlaceholder": "z. B. 3,5 - 5,0 oder < 5", "sourceRangeAdd": "+ Bereich aus dem Befund ergänzen", "sourceRangeHint": "Optional. Gilt nur für diesen Wert; dein gespeicherter Bereich bleibt unverändert.", - "sourceRangeUnparsed": "Wird so übernommen — daraus ließ sich kein Zahlenbereich lesen." + "sourceRangeUnparsed": "Wird so übernommen, daraus ließ sich kein Zahlenbereich lesen." }, "backToLabs": "Zurück zu den Laborwerten", "hasNote": "Notiz", @@ -9172,7 +9344,7 @@ "emptyDescription": "Füge einen Wert hinzu, um diesen Biomarker über die Zeit zu verfolgen.", "title": "Biomarker", "genericDescription": "Ein Laborwert, den du verfolgst. Erfasse Messwerte, um den Verlauf gegenüber dem Referenzbereich zu sehen.", - "valuesTitle": "{marker} — alle Werte", + "valuesTitle": "{marker}: alle Werte", "valuesDescription": "Jeder Messwert für diesen Biomarker. Bearbeite oder entferne einen Eintrag direkt.", "backToMarker": "Zurück zu {marker}", "latestResult": "Letztes Ergebnis" @@ -9192,7 +9364,7 @@ }, "biomarker": { "manageTitle": "Biomarker", - "managerDescription": "Marker einmal definieren — Einheit und Bereich gelten für jede Messung.", + "managerDescription": "Marker einmal definieren; Einheit und Bereich gelten dann für jede Messung.", "define": "Hinzufügen", "defineFirst": "Ersten Biomarker hinzufügen", "defineTitle": "Biomarker hinzufügen", @@ -9211,7 +9383,7 @@ "form": { "seedLabel": "Von einem gängigen Marker starten", "seedPlaceholder": "Marker wählen (optional)", - "seedHint": "Eine Auswahl füllt die Felder unten — passe den Bereich an dein Labor an.", + "seedHint": "Eine Auswahl füllt die Felder unten. Passe den Bereich an dein Labor an.", "name": "Name", "namePlaceholder": "z. B. LDL-Cholesterin", "unit": "Einheit", @@ -9225,7 +9397,7 @@ "upperPlaceholder": "Obere Grenze", "context": "Kontext", "contextPlaceholder": "Optionale Notiz zu diesem Marker", - "rangeHint": "Beide Grenzen sind optional — eine leer lassen für einen offenen Bereich (z. B. LDL < 116).", + "rangeHint": "Beide Grenzen sind optional. Eine leer lassen für einen offenen Bereich (z. B. LDL < 116).", "requiredError": "Name und Einheit sind erforderlich.", "rangeOrderError": "Die untere Grenze darf die obere nicht überschreiten.", "save": "Biomarker speichern", @@ -9281,36 +9453,36 @@ "bloodCount": "Blutbild" }, "desc": { - "total-cholesterol": "Das Gesamtcholesterin ist die Summe des Cholesterins über alle Lipoprotein-Partikel im Blut, einschließlich der LDL-, HDL- und Remnant-Fraktion. Cholesterin selbst ist ein unverzichtbarer Baustein für Zellmembranen, Hormone und Gallensäuren. Als einzelner Wert bleibt es ein grobes Maß, denn sowohl ein hoher HDL- als auch ein hoher LDL-Anteil heben die Summe — deshalb liest man es meist gemeinsam mit den Einzelfraktionen. Erfasst wird es vor allem im Rahmen der langfristigen kardiovaskulären Risikoeinschätzung.", - "ldl": "Das LDL-Cholesterin ist das von Low-Density-Lipoprotein-Partikeln transportierte Cholesterin — die Fraktion, die Cholesterin zu den Geweben bringt, es aber auch in den Gefäßwänden ablagern kann. Über Jahre trägt diese Ablagerung zur arteriosklerotischen Plaque bei, weshalb LDL einer der am genauesten beobachteten Lipidmarker ist. Eine geringere langfristige Belastung geht in der Regel mit einem niedrigeren kardiovaskulären Risiko einher. Bewertet wird es nach Risikokategorie, nicht gegen einen festen Normwert.", + "total-cholesterol": "Das Gesamtcholesterin ist die Summe des Cholesterins über alle Lipoprotein-Partikel im Blut, einschließlich der LDL-, HDL- und Remnant-Fraktion. Cholesterin selbst ist ein unverzichtbarer Baustein für Zellmembranen, Hormone und Gallensäuren. Als einzelner Wert bleibt es ein grobes Maß, denn sowohl ein hoher HDL- als auch ein hoher LDL-Anteil heben die Summe. Deshalb liest man es meist gemeinsam mit den Einzelfraktionen. Erfasst wird es vor allem im Rahmen der langfristigen kardiovaskulären Risikoeinschätzung.", + "ldl": "Das LDL-Cholesterin ist das von Low-Density-Lipoprotein-Partikeln transportierte Cholesterin, also die Fraktion, die Cholesterin zu den Geweben bringt, es aber auch in den Gefäßwänden ablagern kann. Über Jahre trägt diese Ablagerung zur arteriosklerotischen Plaque bei, weshalb LDL einer der am genauesten beobachteten Lipidmarker ist. Eine geringere langfristige Belastung geht in der Regel mit einem niedrigeren kardiovaskulären Risiko einher. Bewertet wird es nach Risikokategorie, nicht gegen einen festen Normwert.", "hdl": "Das HDL-Cholesterin ist das von High-Density-Lipoprotein-Partikeln transportierte Cholesterin, die überschüssiges Cholesterin aus den Geweben aufnehmen und zur Wiederverwertung oder Ausscheidung zur Leber zurückführen. Wegen dieser Transportfunktion wird es oft als die schützende Fraktion bezeichnet. Sehr niedrige Werte gehen tendenziell mit höherem kardiovaskulärem Risiko einher; ein künstliches Anheben hat das Risiko jedoch nicht zuverlässig gesenkt, weshalb man es als Marker und nicht als Zielgröße liest. In der Regel wird es zusammen mit LDL und Triglyzeriden beurteilt.", "triglycerides": "Triglyzeride sind die wichtigste Form von Fett im Blut, die sowohl aus der Nahrung stammt als auch von der Leber gebildet wird, und sie dienen als zentraler Energiespeicher. Nach Mahlzeiten steigen die Werte, daher wird die Probe für die Vergleichbarkeit meist nüchtern entnommen. Dauerhaft hohe Triglyzeride werden mit metabolischem und kardiovaskulärem Risiko in Verbindung gebracht und treten häufig gemeinsam mit Insulinresistenz und niedrigem HDL auf. Sie werden als ein Bestandteil des gesamten Lipidbilds gelesen.", "fasting-glucose": "Der Nüchternblutzucker ist die Zuckerkonzentration im Blut nach einer nächtlichen Nüchternphase und spiegelt wider, wie der Körper seinen Grundblutzucker ohne kürzliche Nahrungsaufnahme hält. Glukose ist der wichtigste unmittelbare Brennstoff des Körpers, und Insulin hält sie normalerweise in einem engen Bereich. Eine Nüchternmessung schließt den Effekt kürzlicher Mahlzeiten aus und ist damit ein Standardscreening dafür, wie die Glukoseregulation funktioniert. Er gehört zu den Markern, mit denen die Stoffwechselgesundheit über die Zeit beurteilt wird.", "hba1c": "Der HbA1c ist der Anteil des Hämoglobins in den roten Blutkörperchen, der an Glukose gebunden ist und sich im Verhältnis zur durchschnittlichen Blutzuckerbelastung anreichert. Da rote Blutkörperchen etwa drei Monate leben, gibt er den durchschnittlichen Blutzucker der vorangegangenen zwei bis drei Monate wieder und nicht einen einzelnen Augenblick. Das macht ihn zu einem stabilen, mahlzeitenunabhängigen Maß der Blutzuckereinstellung. Seine Aussagekraft kann durch Erkrankungen beeinträchtigt sein, die die Lebensdauer der roten Blutkörperchen verändern.", "tsh": "Das TSH (Thyreoidea-stimulierendes Hormon) wird von der Hirnanhangdrüse ausgeschüttet, um die Schilddrüse zur Hormonproduktion anzuregen, und es passt sich nach oben oder unten an, um diese im Gleichgewicht zu halten. Aufgrund dieses Regelkreises ist das TSH das empfindlichste Erstlinien-Screening der Schilddrüsenfunktion: Es bewegt sich meist, bevor die Schilddrüsenhormone selbst abweichen. Ein erhöhter oder erniedrigter Wert deutet auf eine Unter- oder Überfunktion hin und führt in der Regel zur direkten Bestimmung der Schilddrüsenhormone. Schwangerschaft und Alter verschieben den erwarteten Bereich.", - "ft3": "Das freie T3 (Trijodthyronin) ist die ungebundene, biologisch aktive Form des wirksameren Schilddrüsenhormons — der Anteil, der den Geweben zur Verfügung steht. Der größte Teil des zirkulierenden T3 ist an Trägerproteine gebunden und inaktiv, sodass der freie Anteil die tatsächliche Hormonverfügbarkeit widerspiegelt. Gemessen wird es zur Beurteilung der Schilddrüsenfunktion, besonders gemeinsam mit TSH und freiem T4, wenn Befunde unklar sind. Die Werte sind stark assayabhängig und werden im klinischen Zusammenhang gelesen.", + "ft3": "Das freie T3 (Trijodthyronin) ist die ungebundene, biologisch aktive Form des wirksameren Schilddrüsenhormons, also der Anteil, der den Geweben zur Verfügung steht. Der größte Teil des zirkulierenden T3 ist an Trägerproteine gebunden und inaktiv, sodass der freie Anteil die tatsächliche Hormonverfügbarkeit widerspiegelt. Gemessen wird es zur Beurteilung der Schilddrüsenfunktion, besonders gemeinsam mit TSH und freiem T4, wenn Befunde unklar sind. Die Werte sind stark assayabhängig und werden im klinischen Zusammenhang gelesen.", "ft4": "Das freie T4 (Thyroxin) ist die ungebundene Form des wichtigsten Hormons, das die Schilddrüse ins Blut abgibt; es dient als Reservoir, das die Gewebe bei Bedarf in das aktivere T3 umwandeln. Der freie Anteil spiegelt wider, was dem Körper tatsächlich zur Verfügung steht, unabhängig von der Menge der Trägerproteine. In der Regel wird es zusammen mit dem TSH bestimmt, um Probleme der Schilddrüse von Störungen der hypophysären Steuerung zu unterscheiden. Schwangerschaft und bestimmte Medikamente können es verschieben.", - "ferritin": "Ferritin ist das wichtigste Eisenspeicherprotein des Körpers, und sein Blutwert spiegelt grob die gesamten Eisenreserven wider. Ein niedriger Wert deutet auf erschöpfte Speicher hin, ein hoher Wert kann eine Eisenüberladung anzeigen — oder schlicht eine Entzündung, denn Ferritin steigt auch als Akute-Phase-Protein. Aus diesem Grund wird es oft zusammen mit anderen Eisenmarkern und Entzündungszeichen beurteilt. Es ist eines der zentralen Maße zur Einschätzung des Eisenstatus.", - "transferrin-saturation": "Die Transferrinsättigung ist der Prozentsatz des Transferrins — des Proteins, das Eisen durch das Blut transportiert –, der aktuell mit Eisen beladen ist. Sie verknüpft die Menge des zirkulierenden Eisens mit der Eisentransportkapazität und liefert damit eine Momentaufnahme der Eisenverfügbarkeit, die Ferritins Blick auf die Speicher ergänzt. Eine niedrige Sättigung deutet auf einen Eisenmangel hin, ein hoher Wert kann eine Überladung anzeigen. Sie wird zusammen mit Ferritin und dem gesamten Eisenpanel gelesen.", + "ferritin": "Ferritin ist das wichtigste Eisenspeicherprotein des Körpers, und sein Blutwert spiegelt grob die gesamten Eisenreserven wider. Ein niedriger Wert deutet auf erschöpfte Speicher hin, ein hoher Wert kann eine Eisenüberladung anzeigen oder schlicht eine Entzündung, denn Ferritin steigt auch als Akute-Phase-Protein. Aus diesem Grund wird es oft zusammen mit anderen Eisenmarkern und Entzündungszeichen beurteilt. Es ist eines der zentralen Maße zur Einschätzung des Eisenstatus.", + "transferrin-saturation": "Die Transferrinsättigung ist der Prozentsatz des Transferrins (des Proteins, das Eisen durch das Blut transportiert), der aktuell mit Eisen beladen ist. Sie verknüpft die Menge des zirkulierenden Eisens mit der Eisentransportkapazität und liefert damit eine Momentaufnahme der Eisenverfügbarkeit, die Ferritins Blick auf die Speicher ergänzt. Eine niedrige Sättigung deutet auf einen Eisenmangel hin, ein hoher Wert kann eine Überladung anzeigen. Sie wird zusammen mit Ferritin und dem gesamten Eisenpanel gelesen.", "vitamin-d": "Vitamin D wird als 25-Hydroxy-Vitamin-D gemessen, der zirkulierenden Speicherform und dem Standardmarker des gesamten Vitamin-D-Status. Der Körper bildet es in der dem Sonnenlicht ausgesetzten Haut und nimmt einen Teil über die Nahrung auf; es unterstützt den Kalziumhaushalt, die Knochengesundheit und die Immunfunktion. Die Werte schwanken mit Jahreszeit, geografischer Breite, Hauttyp und Nahrungsergänzung. Erfasst wird es, um über die Zeit einzuschätzen, ob die Speicher ausreichend gefüllt sind.", - "vitamin-b12": "Vitamin B12 (Cobalamin) ist ein essenzielles Vitamin, das vor allem aus tierischen Lebensmitteln stammt und größtenteils in der Leber gespeichert wird. Es wird für die Bildung roter Blutkörperchen, die DNA-Synthese und die Gesundheit des Nervensystems benötigt. Ein länger anhaltender Mangel — durch geringe Zufuhr oder gestörte Aufnahme — kann zu einer Blutarmut und nervenbedingten Beschwerden führen. Gemessen wird es zur Beurteilung des Ernährungsstatus, insbesondere dort, wo Zufuhr oder Aufnahme eingeschränkt sein können.", + "vitamin-b12": "Vitamin B12 (Cobalamin) ist ein essenzielles Vitamin, das vor allem aus tierischen Lebensmitteln stammt und größtenteils in der Leber gespeichert wird. Es wird für die Bildung roter Blutkörperchen, die DNA-Synthese und die Gesundheit des Nervensystems benötigt. Ein länger anhaltender Mangel (durch geringe Zufuhr oder gestörte Aufnahme) kann zu einer Blutarmut und nervenbedingten Beschwerden führen. Gemessen wird es zur Beurteilung des Ernährungsstatus, insbesondere dort, wo Zufuhr oder Aufnahme eingeschränkt sein können.", "folate": "Folat (Vitamin B9) ist ein B-Vitamin, das in grünem Blattgemüse, Hülsenfrüchten und angereicherten Lebensmitteln vorkommt und für die Bildung roter Blutkörperchen und die DNA-Synthese zentral ist. Es arbeitet eng mit Vitamin B12 zusammen, und ein Mangel an einem von beiden kann eine ähnliche Form der Blutarmut hervorrufen. Eine ausreichende Folatversorgung ist besonders rund um die frühe Schwangerschaft für die gesunde Entwicklung des Kindes wichtig. Gemessen wird es zur Beurteilung des Ernährungsstatus und der Ursache bestimmter Anämien.", "hs-crp": "Das hochsensitive CRP misst das C-reaktive Protein, einen Marker, den die Leber als Reaktion auf Entzündungen ausschüttet, mit einem Verfahren, das auf die für das kardiovaskuläre Risiko relevanten niedrigen Konzentrationen abgestimmt ist. Anders als das Standard-CRP für akute Infektionen erfasst die hochsensitive Variante die feine, chronische Entzündung, die mit Gefäßerkrankungen verbunden ist. Da jede kürzliche Infektion oder Verletzung den Wert anhebt, wird er außerhalb akuter Erkrankung beurteilt und oft mit einer Wiederholungsprobe bestätigt. Er wird als ein Beitrag zum gesamten kardiovaskulären Risiko gelesen.", "creatinine": "Kreatinin ist ein Abfallprodukt des normalen Muskelstoffwechsels, das die Nieren mit recht gleichbleibender Rate aus dem Blut filtern. Da die Produktion verhältnismäßig konstant ist, dient der Blutwert als praktisches Maß dafür, wie gut die Nieren Abfallstoffe ausscheiden. Die Werte werden auch von Muskelmasse, Flüssigkeitshaushalt und Ernährung beeinflusst, weshalb sie meist gemeinsam mit der geschätzten Filtrationsrate beurteilt werden. Es ist ein zentraler Marker der Nierenfunktion.", "egfr": "Die eGFR (geschätzte glomeruläre Filtrationsrate) schätzt, wie viel Blut die Nieren pro Minute filtern, berechnet aus dem Kreatinin zusammen mit Alter und Geschlecht. Sie übersetzt einen rohen Kreatininwert in ein unmittelbarer verständliches Maß der Nierenfunktion. Eine über die Zeit sinkende eGFR ist der wichtigste Weg, um chronische Nierenveränderungen einzustufen und zu verfolgen. Als Schätzwert wird sie als Trend und nicht als einzelner exakter Zahlenwert gelesen.", "alt": "Die ALT (Alanin-Aminotransferase) ist ein vorwiegend in den Leberzellen konzentriertes Enzym, das ins Blut freigesetzt wird, wenn diese Zellen belastet oder geschädigt sind. Da sie verhältnismäßig leberspezifisch ist, gehört sie zu den wichtigsten Markern, um eine Leberschädigung oder -entzündung zu erkennen. Die Werte können bei Fettleber, Virushepatitis, Alkohol oder bestimmten Medikamenten steigen. In der Regel wird sie zusammen mit der AST und den übrigen Markern des Leberpanels gelesen.", - "ast": "Die AST (Aspartat-Aminotransferase) ist ein Enzym, das in der Leber, aber auch in Muskel, Herz und roten Blutkörperchen vorkommt und bei einer Schädigung dieser Gewebe freigesetzt wird. Da sie weniger leberspezifisch ist als die ALT, werden beide gemeinsam beurteilt — ihr Verhältnis kann Hinweise auf Quelle und Art des Problems geben. Intensive körperliche Belastung oder eine Muskelverletzung können sie unabhängig von der Leber anheben. Sie ist eines der Standardenzyme des Leberpanels.", - "ggt": "Die GGT (Gamma-Glutamyltransferase) ist ein Enzym in der Leber und den Gallenwegen, das besonders bei einem gestörten Gallenfluss oder bei Alkoholexposition ansteigt. Häufig wird sie genutzt, um zu klären, warum ein anderer Lebermarker erhöht ist — etwa um eine Quelle in der Leber oder den Gallenwegen zu unterscheiden. Sie ist empfindlich, aber wenig spezifisch und wird daher im Zusammenhang mit dem übrigen Leberpanel gelesen. Sie ist einer von mehreren Markern zur Beurteilung der Leber- und Gallengesundheit.", + "ast": "Die AST (Aspartat-Aminotransferase) ist ein Enzym, das in der Leber, aber auch in Muskel, Herz und roten Blutkörperchen vorkommt und bei einer Schädigung dieser Gewebe freigesetzt wird. Da sie weniger leberspezifisch ist als die ALT, werden beide gemeinsam beurteilt; ihr Verhältnis kann Hinweise auf Quelle und Art des Problems geben. Intensive körperliche Belastung oder eine Muskelverletzung können sie unabhängig von der Leber anheben. Sie ist eines der Standardenzyme des Leberpanels.", + "ggt": "Die GGT (Gamma-Glutamyltransferase) ist ein Enzym in der Leber und den Gallenwegen, das besonders bei einem gestörten Gallenfluss oder bei Alkoholexposition ansteigt. Häufig wird sie genutzt, um zu klären, warum ein anderer Lebermarker erhöht ist, etwa um eine Quelle in der Leber oder den Gallenwegen zu unterscheiden. Sie ist empfindlich, aber wenig spezifisch und wird daher im Zusammenhang mit dem übrigen Leberpanel gelesen. Sie ist einer von mehreren Markern zur Beurteilung der Leber- und Gallengesundheit.", "sodium": "Natrium ist der wichtigste Elektrolyt in der Flüssigkeit außerhalb der Körperzellen und der zentrale Regler von Flüssigkeitshaushalt und Blutvolumen. Der Körper steuert es eng über Durst, Hormone und die Nieren, sodass der Blutwert eher das Gleichgewicht zwischen Wasser und Salz widerspiegelt als die Zufuhr allein. Abweichende Werte signalisieren meist eine Störung des Wasserhaushalts und nicht des Natriums selbst. Es ist ein Kernbestandteil des grundlegenden Stoffwechsel- und Elektrolytpanels.", "potassium": "Kalium ist der wichtigste Elektrolyt innerhalb der Körperzellen und für die Nervenleitung und den gleichmäßigen Rhythmus des Herzmuskels unverzichtbar. Sein Blutwert wird durch die Nieren und hormonelle Signale in einem engen Bereich gehalten, da sowohl hohe als auch niedrige Werte die elektrische Aktivität des Herzens stören können. Die Werte können durch Nierenfunktion, Flüssigkeitshaushalt und mehrere gängige Medikamente beeinflusst werden. Es ist ein Kernmaß des Elektrolytpanels.", "hemoglobin": "Hämoglobin ist das eisenhaltige Protein in den roten Blutkörperchen, das Sauerstoff in der Lunge bindet und zu den Geweben transportiert. Seine Konzentration ist das wichtigste Maß für die Sauerstofftransportkapazität des Blutes und der zentrale Marker zum Erkennen einer Blutarmut. Niedrige Werte können auf Blutverlust, Eisen- oder Vitaminmangel oder eine verringerte Bildung hinweisen, hohe Werte können auf Flüssigkeitsmangel oder sauerstoffarme Bedingungen folgen. Es ist ein Eckpfeiler des großen Blutbilds.", "hematocrit": "Der Hämatokrit ist der Anteil der roten Blutkörperchen am Blutvolumen, ausgedrückt als Prozentwert. Er bewegt sich eng mit dem Hämoglobin und gibt einen ergänzenden Blick auf die Erythrozytenmasse und die Sauerstofftransportkapazität des Blutes. Der Flüssigkeitshaushalt beeinflusst ihn, da Veränderungen des Plasmavolumens den Anteil verschieben, selbst wenn die Zahl der roten Blutkörperchen stabil bleibt. Er wird zusammen mit dem Hämoglobin und dem übrigen Blutbild gelesen.", "wbc": "Die Leukozytenzahl ist die Gesamtzahl der im Blut zirkulierenden Immunzellen, der mobilen Abwehr des Körpers gegen Infektionen. Sie steigt als Reaktion auf Infektion, Entzündung oder Stress und kann fallen, wenn das Knochenmark unterdrückt ist oder bei manchen Immunerkrankungen. Da sie mehrere unterschiedliche Zelltypen zusammenfasst, wird eine deutliche Veränderung oft durch ein Differenzialblutbild aufgeschlüsselt. Sie ist ein Kernbestandteil des großen Blutbilds.", "platelets": "Thrombozyten sind kleine Zellfragmente im Blut, die sich zusammenlagern und den ersten Pfropf bilden, der eine Blutung aus einem geschädigten Gefäß stoppt. Ihre Zahl spiegelt das Gleichgewicht zwischen Bildung im Knochenmark und Verbrauch oder Abbau wider. Eine niedrige Zahl kann die Blutungsneigung erhöhen, eine hohe Zahl kann auf eine Entzündung oder andere Reize folgen. Sie sind Teil des großen Blutbilds und ein grundlegendes Maß der Gerinnungsfähigkeit.", - "apob": "Apolipoprotein B ist das Strukturprotein, das jedes der atherogenen Lipoprotein-Partikel trägt; seine Messung zählt daher diese Partikel direkt und nicht das von ihnen transportierte Cholesterin. Da auf jedem LDL-, VLDL- und verwandten Partikel genau ein ApoB-Molekül sitzt, kann es kardiovaskuläres Risiko erfassen, das ein LDL-Cholesterinwert übersieht — besonders wenn Partikelzahl und Cholesteringehalt auseinandergehen. Aus diesem Grund wird es zunehmend genutzt, um die lipidbasierte Risikoeinschätzung zu verfeinern. Beurteilt wird es zusammen mit dem üblichen Lipidpanel.", + "apob": "Apolipoprotein B ist das Strukturprotein, das jedes der atherogenen Lipoprotein-Partikel trägt; seine Messung zählt daher diese Partikel direkt und nicht das von ihnen transportierte Cholesterin. Da auf jedem LDL-, VLDL- und verwandten Partikel genau ein ApoB-Molekül sitzt, kann es kardiovaskuläres Risiko erfassen, das ein LDL-Cholesterinwert übersieht, besonders wenn Partikelzahl und Cholesteringehalt auseinandergehen. Aus diesem Grund wird es zunehmend genutzt, um die lipidbasierte Risikoeinschätzung zu verfeinern. Beurteilt wird es zusammen mit dem üblichen Lipidpanel.", "lp-a": "Lipoprotein(a) ist ein LDL-ähnliches Partikel mit einem zusätzlich angehefteten Protein, dessen Spiegel weitgehend genetisch festgelegt ist und ein Leben lang recht stabil bleibt. Ein erhöhtes Lp(a) ist ein unabhängiger, vererbter Risikofaktor für Herz-Kreislauf-Erkrankungen und eine Verengung der Aortenklappe. Da es sich kaum verändert, wird es meist einmal bestimmt, um das lebenslange Risiko einer Person festzulegen, statt es wiederholt zu verfolgen. Es wird in molaren Einheiten angegeben und als Teil des gesamten kardiovaskulären Risikos gelesen.", - "fasting-insulin": "Das Nüchterninsulin ist der Spiegel des Insulins — des Hormons, das Glukose aus dem Blut in die Zellen schleust –, gemessen nach einer nächtlichen Nüchternphase. Zusammen mit dem Nüchternblutzucker hilft es, die Insulinresistenz abzuschätzen, also den Zustand, in dem der Körper mehr Insulin braucht, um den Blutzucker im Bereich zu halten. Ein hohes Nüchterninsulin bei normalem Blutzucker kann ein frühes Zeichen dafür sein, dass dieser Ausgleich im Gange ist. Gelesen wird es als Teil eines Stoffwechselbilds und nicht für sich allein.", - "omega-3-index": "Der Omega-3-Index ist der Anteil von EPA und DHA — den beiden langkettigen marinen Omega-3-Fettsäuren — an den Fettsäuren in den Membranen der roten Blutkörperchen. Da rote Blutkörperchen sich langsam erneuern, spiegelt er die langfristige Omega-3-Zufuhr wider und nicht eine kürzliche Mahlzeit. Ein höherer Index wurde in Beobachtungsstudien mit einem niedrigeren kardiovaskulären Risiko in Verbindung gebracht. Er wird als stabiler Marker des Omega-3-Status verfolgt, der über Monate auf die Ernährung reagiert." + "fasting-insulin": "Das Nüchterninsulin ist der Spiegel des Insulins (des Hormons, das Glukose aus dem Blut in die Zellen schleust), gemessen nach einer nächtlichen Nüchternphase. Zusammen mit dem Nüchternblutzucker hilft es, die Insulinresistenz abzuschätzen, also den Zustand, in dem der Körper mehr Insulin braucht, um den Blutzucker im Bereich zu halten. Ein hohes Nüchterninsulin bei normalem Blutzucker kann ein frühes Zeichen dafür sein, dass dieser Ausgleich im Gange ist. Gelesen wird es als Teil eines Stoffwechselbilds und nicht für sich allein.", + "omega-3-index": "Der Omega-3-Index ist der Anteil von EPA und DHA (den beiden langkettigen marinen Omega-3-Fettsäuren) an den Fettsäuren in den Membranen der roten Blutkörperchen. Da rote Blutkörperchen sich langsam erneuern, spiegelt er die langfristige Omega-3-Zufuhr wider und nicht eine kürzliche Mahlzeit. Ein höherer Index wurde in Beobachtungsstudien mit einem niedrigeren kardiovaskulären Risiko in Verbindung gebracht. Er wird als stabiler Marker des Omega-3-Status verfolgt, der über Monate auf die Ernährung reagiert." }, "apob": "Apolipoprotein B (ApoB)", "lp-a": "Lipoprotein(a)", @@ -9328,7 +9500,7 @@ "acceptImageOnly": "JPEG, PNG oder WebP", "extracting": "Befund wird gelesen…", "readingOnDevice": "Wird auf deinem Gerät gelesen…", - "localModeHint": "Wird auf deinem Gerät gelesen und als Text gesendet. Weniger genau — prüfe jeden Wert.", + "localModeHint": "Wird auf deinem Gerät gelesen und als Text gesendet. Weniger genau, prüfe jeden Wert.", "rangeFromScan": "Referenzbereich aus dem Scan übernommen", "rangeVerify": "Referenzbereich prüfen", "reviewTitle": "Vor dem Speichern prüfen", @@ -9343,12 +9515,12 @@ "dateLabel": "Datum", "refLowLabel": "Referenz unten", "refHighLabel": "Referenz oben", - "newBiomarker": "Neuer Biomarker — wird angelegt", + "newBiomarker": "Neuer Biomarker, wird angelegt", "linksExisting": "Verknüpft mit vorhandenem {name}", "duplicateWarning": "Ein passender Wert existiert bereits", "lowConfidence": "Prüfe diesen Wert", "lowConfidenceBadge": "Geringe Sicherheit", - "valueUnreadable": "Wert unleserlich — bitte manuell eingeben", + "valueUnreadable": "Wert unleserlich, bitte manuell eingeben", "saveSelected": "{count} ausgewählte speichern", "discardAll": "Alle verwerfen", "savedToast": "{count} Laborwerte gespeichert", @@ -9464,6 +9636,10 @@ "vaccinations": { "label": "Impfungen", "description": "Der Impfpass in strukturierter Form: Dosis, Serie, nächste Auffrischung." + }, + "timeline": { + "label": "Zeitleiste", + "description": "Jahre auf einen Blick: Erkrankungen, Medikamente und Arztbesuche neben deinen Werten, dazu deine Lebensereignisse." } }, "moduleOff": { @@ -9495,8 +9671,8 @@ }, "form": { "date": "Datum", - "catalogLabel": "Impfstoff (Katalog)", - "identityHint": "Katalog wählen oder unten frei eintragen — eins genügt.", + "catalogLabel": "Impfstoff", + "identityHint": "Aus der Liste wählen oder unten frei eintragen. Eins genügt.", "catalogNone": "Impfstoff wählen", "catalogSearch": "Impfstoff suchen", "catalogNoMatch": "Kein Katalogeintrag gefunden", @@ -9573,7 +9749,7 @@ }, "empty": { "title": "Noch keine Impfung erfasst", - "description": "Übertrage deinen Impfpass Zeile für Zeile — Datum und Impfstoff genügen." + "description": "Übertrage deinen Impfpass Zeile für Zeile. Datum und Impfstoff genügen." }, "catalog": { "tetanus": "Tetanus (Wundstarrkrampf)", @@ -9616,10 +9792,31 @@ "overdue": "Auffrischung fällig seit {date}", "dueSoon": "Auffrischung fällig am {date}", "current": "Nächste Auffrischung {date}" + }, + "custom": { + "title": "Eigene Impfstoffe", + "add": "Eigenen Impfstoff anlegen", + "name": "Name", + "components": "Schützt vor", + "seriesDoses": "Dosen der Grundimmunisierung", + "boosterMonths": "Auffrischung alle (Monate)", + "saved": "Impfstoff gespeichert", + "deleted": "Impfstoff entfernt", + "deleteConfirm": "Diesen Impfstoff entfernen? Bereits eingetragene Impfungen behalten ihren Namen.", + "saveFailed": "Der Impfstoff konnte nicht gespeichert werden.", + "nameTaken": "Du hast schon einen Impfstoff mit diesem Namen.", + "editTitle": "Eigenen Impfstoff bearbeiten", + "description": "Ein Impfstoff, den der Katalog nicht kennt, gezählt wie jeder Katalogeintrag.", + "componentsHint": "Alle Krankheiten wählen, vor denen er schützt.", + "boosterHint": "Leer lassen, wenn keine Auffrischung nötig ist.", + "protectsAgainst": "Schützt vor {list}.", + "deleteTitle": "Eigenen Impfstoff entfernen?", + "sectionDescription": "Selbst angelegte Impfstoffe, die der Katalog nicht kennt.", + "catalogGroup": "Katalog" } }, "illness": { - "title": "Krankheitstagebuch", + "title": "Krankheit", "subtitle": "Ein rückblickendes Protokoll deiner Krankheits- und Beschwerde-Episoden.", "settings": { "viewDescription": "Wähle, ob Episoden als Karten oder als kompakte Liste erscheinen.", @@ -9706,7 +9903,7 @@ }, "correlation": { "title": "Wie es verlief", - "learning": "Noch am Lernen — erfasse ein paar weitere Tage, um hier Muster zu sehen.", + "learning": "Noch am Lernen. Erfasse ein paar weitere Tage, um hier Muster zu sehen.", "preOnsetTitle": "Wie es sich ankündigte", "preOnsetBody": "Werte, die vor dem Beginn von deiner Baseline abwichen.", "nadirTitle": "Was sich änderte", @@ -9733,7 +9930,7 @@ "countOne": "Du hast in diesem Zeitraum 1 Episode erfasst.", "countOther": "Du hast in diesem Zeitraum {count} Episoden erfasst.", "typicalGap": "Typische Erholungslücke: {days} Tage zwischen Besserung und normalisierten Werten.", - "typicalGapWithDriver": "Typische Erholungslücke: {days} Tage — du fühlst dich meist besser, bevor sich {vital} normalisiert.", + "typicalGapWithDriver": "Typische Erholungslücke: {days} Tage. Du fühlst dich meist besser, bevor sich {vital} normalisiert.", "typicalGapLearning": "Noch ein paar abgeschlossene Episoden und hier erscheint eine typische Erholungslücke.", "none": "In diesem Zeitraum keine Episoden erfasst.", "analyze": "Erholungslücke analysieren" @@ -9758,7 +9955,7 @@ "loadError": "Diese Episode konnte nicht geladen werden.", "backToList": "Zurück zum Journal", "detail": { - "note": "Note" + "note": "Notiz" }, "edit": { "title": "Episode bearbeiten", @@ -9794,8 +9991,8 @@ "stop": "Ich messe genug", "accepted": "Erinnerung gesetzt", "dismissed": "Okay, das schlage ich nicht noch einmal vor", - "stopped": "Verstanden — ich schlage keine Erinnerungen mehr vor", - "failed": "Hat nicht geklappt — bitte erneut versuchen", + "stopped": "Verstanden, ich schlage keine Erinnerungen mehr vor", + "failed": "Hat nicht geklappt. Bitte erneut versuchen", "cadence": { "weightDaily": "Wieg dich täglich, ungefähr zur selben Zeit", "bp722": "Miss deinen Blutdruck eine Woche lang zweimal täglich", @@ -9807,7 +10004,7 @@ "dismiss": "Jetzt nicht", "applied": "Hinzugefügt", "dismissed": "Verworfen", - "failed": "Konnte das nicht hinzufügen — bitte erneut versuchen", + "failed": "Konnte das nicht hinzufügen. Bitte erneut versuchen", "checkup": { "title": "Vorsorge-Erinnerung hinzufügen?" }, @@ -9835,9 +10032,9 @@ "reviewPrefix": "Check-in: {when}", "accept": "Plan übernehmen", "decline": "Ablehnen", - "accepted": "Plan übernommen — der Coach behält ihn im Blick.", + "accepted": "Plan übernommen. Der Coach behält ihn im Blick.", "declined": "Vorschlag abgelehnt.", - "failed": "Das hat nicht geklappt — bitte erneut versuchen.", + "failed": "Das hat nicht geklappt. Bitte erneut versuchen.", "markMet": "Ziel erreicht", "abandon": "Plan beenden", "remove": "Entfernen", @@ -9858,11 +10055,11 @@ } }, "refusal": { - "outOfScope": "Ich kann nur bei den Gesundheitsdaten in deinem Log helfen — Messwerte, Medikamente, Stimmung, Trends. Fragen außerhalb dieses Themas beantworte ich nicht.", - "promptInjection": "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.", + "outOfScope": "Ich kann nur bei den Gesundheitsdaten in deinem Log helfen: Messwerte, Medikamente, Stimmung, Trends. Fragen außerhalb dieses Themas beantworte ich nicht.", + "promptInjection": "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.", "conversationPoisoned": "Eine frühere Nachricht in dieser Unterhaltung enthält Anweisungen, die meine Vorgaben überschreiben sollen. Beginne bitte eine neue Unterhaltung." }, - "noRecordedData": "Zu deiner Frage sind bei dir keine Messwerte erfasst, also kann ich dir dazu keine Zahl nennen. Sobald du etwas eingetragen hast, frag mich noch einmal — dann rechne ich mit deinen eigenen Werten.", + "noRecordedData": "Zu deiner Frage sind bei dir keine Messwerte erfasst, also kann ich dir dazu keine Zahl nennen. Sobald du etwas eingetragen hast, frag mich noch einmal, dann rechne ich mit deinen eigenen Werten.", "step": { "read": "Prüfe: {domain}", "readWindow": "Prüfe: {domain}, {window}", @@ -9895,7 +10092,9 @@ "illness": "Krankheit und Erholung", "cycle": "Zyklus", "correlations": "Zusammenhänge zwischen Messgrößen", - "snapshot": "Datenübersicht" + "snapshot": "Datenübersicht", + "environment": "Wetter und Luft", + "day": "Ein Tag" }, "window": { "last7days": "letzte 7 Tage", @@ -10073,7 +10272,7 @@ }, "mentalHealth": { "pageTitle": "Seelisches Wohlbefinden", - "pageDescription": "Vier validierte, freiwillige Selbsttests — aussagekräftig ist der Verlauf, nicht ein einzelner Wert.", + "pageDescription": "Vier validierte, freiwillige Selbsttests. Aussagekräftig ist der Verlauf, nicht ein einzelner Wert.", "attributionLabel": "Quelle", "attributionShow": "Quelle und Lizenz für {instrument}", "choosePrompt": "Check-in auswählen", @@ -10084,10 +10283,10 @@ "saved": "Gespeichert", "error": "Etwas ist schiefgelaufen. Bitte versuche es erneut.", "instrument": { - "phq9": "PHQ-9 · Stimmung", - "gad7": "GAD-7 · Sorgen", - "who5": "WHO-5 · Wohlbefinden", - "sci": "SCI · Schlaf" + "phq9": "PHQ-9 (Stimmung)", + "gad7": "GAD-7 (Sorgen)", + "who5": "WHO-5 (Wohlbefinden)", + "sci": "SCI (Schlaf)" }, "instrumentSub": { "phq9": "Depressions-Screening, 9 Fragen", @@ -10108,7 +10307,7 @@ "2": "Sehr erschwert", "3": "Extrem erschwert" }, - "functionalOptionalHint": "Optional — du kannst auch ohne Antwort auf diese Frage absenden.", + "functionalOptionalHint": "Optional: Du kannst auch ohne Antwort auf diese Frage absenden.", "result": { "title": "Dein Ergebnis", "totalLabel": "Gesamtwert", @@ -10150,7 +10349,7 @@ }, "crisis": { "title": "Du musst das nicht allein durchstehen", - "intro": "Du hast Gedanken angegeben, lieber tot zu sein oder dir etwas anzutun. Es gibt jetzt sofort Unterstützung — sich zu melden ist ein Zeichen von Stärke.", + "intro": "Du hast Gedanken angegeben, lieber tot zu sein oder dir etwas anzutun. Es gibt jetzt sofort Unterstützung. Sich zu melden ist ein Zeichen von Stärke.", "ifDanger": "Wenn du in unmittelbarer Gefahr bist, ruf jetzt {emergency} an.", "resourcesTitle": "Menschen, die helfen können" }, @@ -10298,7 +10497,7 @@ }, "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.", + "historyLoadErrorHint": "Das heißt nicht, dass keine Ergebnisse vorliegen. Die Liste konnte nicht abgerufen werden. Versuche es erneut.", "privacyNote": "Deine Antworten sind verschlüsselt gespeichert und erreichen den KI-Coach nicht." }, "records": { @@ -10528,12 +10727,34 @@ "daylight": "Tageslicht", "precip": "Niederschlag", "pressureMean": "Luftdruck", - "pressureDelta": "Druckschwankung" + "pressureDelta": "Druckschwankung", + "pm25": "Feinstaub (PM2,5)", + "pm10": "Feinstaub (PM10)", + "no2": "Stickstoffdioxid", + "ozone8h": "Ozon (höchster 8-Stunden-Wert)", + "europeanAqi": "Europäischer Luftqualitätsindex", + "uvIndexMax": "UV-Index (Höchstwert)", + "dustMax": "Saharastaub", + "pollenMax": "Pollen (höchster Wert)" + }, + "chip": { + "day": "Wetter am {date}", + "pollenHigh": "Viel Pollen: {kinds}", + "hotNight": "Warme Nacht", + "veryPoorAir": "Sehr schlechte Luft" + }, + "pollen": { + "alder": "Erle", + "birch": "Birke", + "grass": "Gräser", + "mugwort": "Beifuß", + "olive": "Olive", + "ragweed": "Ambrosia" } }, "documents": { "title": "Dokumente", - "subtitle": "Jeder Brief, Befund und Scan an einem durchsuchbaren Ort — verschlüsselt gespeichert.", + "subtitle": "Jeder Brief, Befund und Scan an einem durchsuchbaren Ort, verschlüsselt gespeichert.", "pageUpload": "Hochladen", "ownerOnlyUpload": "Dokumente kann nur die Inhaberin oder der Inhaber der Akte hinzufügen.", "kind": { @@ -10550,7 +10771,7 @@ }, "filter": { "searchLabel": "Dokumente durchsuchen", - "searchPlaceholder": "Titel oder Dateiname suchen…", + "searchPlaceholder": "Titel, Dateiname", "typeGroup": "Dokumenttyp", "conditionGroup": "Erkrankung", "procedureGroup": "Eingriffe und Operationen", @@ -10575,15 +10796,15 @@ }, "error": { "fileTooLarge": "Diese Datei ist größer als das Limit von {maxSize}.", - "quotaExceeded": "Der Speicher ist voll — {used} von {quota} belegt.", + "quotaExceeded": "Der Speicher ist voll: {used} von {quota} belegt.", "unsupportedType": "Dieser Dateityp wird nicht unterstützt.", "purged": "Dieses Dokument wurde endgültig entfernt und kann nicht wiederhergestellt werden.", "duplicateExists": "Ein identisches Dokument ist bereits gespeichert.", - "rateLimited": "Zu viele Uploads — versuche es in ein paar Minuten erneut.", + "rateLimited": "Zu viele Uploads. Versuche es in ein paar Minuten erneut.", "generic": "Upload fehlgeschlagen. Bitte erneut versuchen." }, "toast": { - "duplicate": "Bereits gespeichert — das vorhandene Dokument wird hervorgehoben.", + "duplicate": "Bereits gespeichert. Das vorhandene Dokument ist hervorgehoben.", "deleted": "Dokument gelöscht", "restoreFailed": "Das Dokument konnte nicht wiederhergestellt werden." }, @@ -10606,7 +10827,7 @@ }, "empty": { "title": "Dein Dokumenten-Tresor ist leer", - "description": "Bewahre Arztbriefe, Laborbefunde, Bildgebung, Rezepte und Impfnachweise auf — verschlüsselt und durchsuchbar nach Typ, Erkrankung und Jahr.", + "description": "Bewahre Arztbriefe, Laborbefunde, Bildgebung, Rezepte und Impfnachweise auf. Sie werden verschlüsselt gespeichert und sind nach Typ, Erkrankung und Jahr durchsuchbar.", "action": "Erstes Dokument hochladen", "noMatchesTitle": "Keine passenden Dokumente", "noMatchesDescription": "Nichts entspricht der aktuellen Suche und den Filtern." @@ -10646,9 +10867,9 @@ "linkCondition": "Verknüpfen", "noConditions": "Mit keiner Erkrankung verknüpft", "previewUnavailable": "Keine Inline-Vorschau für diesen Dateityp", - "uploadedMeta": "Hinzugefügt am {date} · {size}", + "uploadedMeta": "Hinzugefügt am {date}, {size}", "importedFrom": "Importiert aus {system}", - "importedFromWithId": "Importiert aus {system} · ID {id}", + "importedFromWithId": "Importiert aus {system}, ID {id}", "sourceOther": "einem anderen System", "share": "Teilen", "labValuesLink": "{count} Laborwerte ansehen", @@ -10715,7 +10936,7 @@ "errorNotIndexed": "Lies das Dokument zuerst aus, dann extrahiere.", "errorLocalOcrDisabled": "Aktiviere zuerst die lokale Texterkennung in den KI-Einstellungen.", "errorProvider": "Dafür ist kein KI-Anbieter eingerichtet.", - "errorTextImageOnly": "Lokale Texterkennung liest nur Bilder — nicht diese Datei.", + "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." }, @@ -10748,7 +10969,7 @@ "CONDITION": "Erkrankung", "MEDICATION_STATEMENT": "Medikament" }, - "statedRange": "angegebener Bereich {range}", + "statedRange": "Angegebener Bereich {range}", "needsCheck": "Prüfe diesen Wert gegen das Dokument und speichere ihn dann.", "verify": "Prüfen", "verifySave": "Wert speichern", @@ -10864,6 +11085,21 @@ "finishedHint": "Suche erneut oder ändere einen Filter, um weitere Dokumente auszuwählen.", "maxReached": "Du kannst höchstens {max} Dokumente auf einmal importieren.", "noMatchHint": "Versuche einen Teil des Namens oder setze Tag und Datum zurück." + }, + "customize": "Darstellung anpassen", + "layout": { + "viewDescription": "Vorschaukarten mit Miniaturansicht oder eine kompakte Liste.", + "arrangementHeading": "Anordnung", + "arrangementDescription": "Jeder Monat als eigener Block oder alles fortlaufend über die ganze Breite.", + "stacked": "Monate untereinander", + "flow": "Fortlaufend", + "saveFailed": "Die Darstellung konnte nicht gespeichert werden." + }, + "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.", + "action": "Löschen" } }, "selfDescription": { @@ -10950,7 +11186,7 @@ "genericDescription": "Ein Messwert, den du verfolgst. Erfasse Werte, um ihn über die Zeit zu beobachten.", "emptyTitle": "Noch keine Werte", "emptyDescription": "Erfasse deinen ersten Wert, um diese Metrik darzustellen.", - "valuesTitle": "{metric} — alle Werte", + "valuesTitle": "{metric}: alle Werte", "valuesDescription": "Alle erfassten Werte dieser Metrik.", "backToMetric": "Zurück zu {metric}" } @@ -10997,26 +11233,26 @@ "lastAttemptReason": { "unitMismatch": "Die übermittelten Werte entsprachen nicht den Einheiten, die diese App für diese Nährstoffe erwartet, daher konnten sie nicht gespeichert werden.", "valueOutOfRange": "Die übermittelten Mengen lagen höher, als diese App plausibel speichern kann, daher konnten sie nicht gesichert werden.", - "dayInvalid": "Die Einträge trugen ein Datum, das der Server nicht akzeptieren konnte. Das kann passieren, wenn Uhrzeit oder Zeitzone auf der sendenden Seite falsch eingestellt sind — das lohnt sich zu prüfen.", + "dayInvalid": "Die Einträge trugen ein Datum, das der Server nicht akzeptieren konnte. Das kann passieren, wenn Uhrzeit oder Zeitzone auf der sendenden Seite falsch eingestellt sind. Das lohnt sich zu prüfen.", "upsertFailed": "Diese Einträge haben den Server erreicht, aber ein Problem auf unserer Seite hat verhindert, dass sie gespeichert wurden. Du musst nichts weiter tun.", "unknown": "Diese Einträge sind angekommen, konnten aber nicht gespeichert werden." } }, "hydration": { - "referenceMeta": "Richtwert {value} {unit}/Tag · EFSA" + "referenceMeta": "Richtwert {value} {unit}/Tag (EFSA)" }, "caffeine": { - "ceilingMeta": "Sichere Obergrenze {value} {unit}/Tag · EFSA" + "ceilingMeta": "Sichere Obergrenze {value} {unit}/Tag (EFSA)" }, "micronutrients": { "title": "Vitamine & Mineralstoffe", - "rowMeta": "{days} von {window} Tagen · Richtwert {value} {unit} (EFSA)", + "rowMeta": "{days} von {window} Tagen, Richtwert {value} {unit} (EFSA)", "rowMetaNoRef": "{days} von {window} Tagen", "footer": "{tracked} von {total} erfassten Mikronährstoffen haben Daten in diesem Zeitraum.", "emptyTitle": "Noch keine Mikronährstoffdaten synchronisiert", "loadError": "Deine Nährstoffdaten konnten nicht geladen werden" }, - "sparseNote": "Ein niedriger Wert bedeutet hier meist lückenhafte Erfassung, keinen Mangel — die Tagessummen aus Apple Health spiegeln wider, was deine Apps erfasst haben, nicht die gesamte Nahrungsaufnahme." + "sparseNote": "Ein niedriger Wert bedeutet hier meist lückenhafte Erfassung, keinen Mangel. Die Tagessummen aus Apple Health spiegeln wider, was deine Apps erfasst haben, nicht die gesamte Nahrungsaufnahme." }, "daily": { "line": { @@ -11025,7 +11261,7 @@ }, "push": { "title": "Dein Morgenüberblick", - "bodyProvisional": "{line} · Der Schlaf von letzter Nacht fehlt noch." + "bodyProvisional": "{line}\nDer Schlaf von letzter Nacht fehlt noch." }, "item": { "doseWindow": { @@ -11043,27 +11279,27 @@ }, "syncIssue": { "title": "Synchronisierung braucht Aufmerksamkeit", - "body": "{integration} synchronisiert nicht — verbinde neu, um deine Daten aktuell zu halten." + "body": "{integration} synchronisiert nicht. Verbinde neu, um deine Daten aktuell zu halten." }, "coachCheckin": { "title": "Zeit, einen Plan anzuschauen", - "body": "Es ist etwa eine Woche her, seit du diesen Plan gesetzt hast — behalte ihn, passe ihn an oder lass ihn los. Ganz ohne Druck.", - "bodyPlan": "Vor etwa einer Woche hast du dir vorgenommen: „{plan}“. Behalte es, passe es an oder lass es los — ganz ohne Druck.", + "body": "Es ist etwa eine Woche her, seit du diesen Plan gesetzt hast. Behalte ihn, passe ihn an oder lass ihn los. Ganz ohne Druck.", + "bodyPlan": "Vor etwa einer Woche hast du dir vorgenommen: „{plan}“. Behalte es, passe es an oder lass es los, ganz ohne Druck.", "adjustPrompt": "Ich möchte meinen Check-in-Plan anpassen.", "adjustPromptPlan": "Ich möchte diesen Plan anpassen: „{plan}“" }, "tensionWindow": { "title": "Vielleicht eine angespannte Phase", "body": { - "morning": "Dein Puls lag heute Vormittag über längere Zeit über deinem Ruhewert, während du ruhig warst — möglicherweise etwas Anspannung.", - "afternoon": "Dein Puls lag heute Nachmittag über längere Zeit über deinem Ruhewert, während du ruhig warst — möglicherweise etwas Anspannung.", - "evening": "Dein Puls lag heute Abend über längere Zeit über deinem Ruhewert, während du ruhig warst — möglicherweise etwas Anspannung.", - "night": "Dein Puls lag in der Nacht über längere Zeit über deinem Ruhewert, während du ruhig warst — möglicherweise etwas Anspannung." + "morning": "Dein Puls lag heute Vormittag über längere Zeit über deinem Ruhewert, während du ruhig warst, möglicherweise etwas Anspannung.", + "afternoon": "Dein Puls lag heute Nachmittag über längere Zeit über deinem Ruhewert, während du ruhig warst, möglicherweise etwas Anspannung.", + "evening": "Dein Puls lag heute Abend über längere Zeit über deinem Ruhewert, während du ruhig warst, möglicherweise etwas Anspannung.", + "night": "Dein Puls lag in der Nacht über längere Zeit über deinem Ruhewert, während du ruhig warst, möglicherweise etwas Anspannung." } }, "ecgNewRecording": { "title": "Neue EKG-Aufzeichnung", - "body": "Dein Gerät hat ein neues EKG aufgezeichnet — bereit zum Ansehen.", + "body": "Dein Gerät hat ein neues EKG aufgezeichnet, bereit zum Ansehen.", "bodyVerdict": "Dein Gerät hat ein neues EKG aufgezeichnet und meldet: {verdict}.", "verdict": { "irregular": "einen möglichen unregelmäßigen Rhythmus", @@ -11078,14 +11314,14 @@ }, "body": { "below": "Bis {time} stehst du bei {today} Schritten. An einem üblichen Tag sind es um diese Zeit etwa {typical}.", - "above": "Bis {time} stehst du bei {today} Schritten — mehr als die {typical}, die um diese Zeit für dich üblich sind." + "above": "Bis {time} stehst du bei {today} Schritten, mehr als die {typical}, die um diese Zeit für dich üblich sind." } }, "upcomingVisit": { "title": "Termin steht an", - "bodyToday": "{what} — heute", - "bodyTomorrow": "{what} — morgen", - "bodyDayAfterTomorrow": "{what} — übermorgen" + "bodyToday": "{what}, heute", + "bodyTomorrow": "{what}, morgen", + "bodyDayAfterTomorrow": "{what}, übermorgen" } }, "action": { @@ -11104,7 +11340,7 @@ "scoreLabel": "Gesundheitsscore", "deltaVsBaseline": "{delta} ggü. deinem Ausgangswert", "deltaFlat": "Im Bereich deines Ausgangswerts", - "sleepPending": "Der Schlaf von letzter Nacht fehlt noch — der heutige Wert ist vorläufig.", + "sleepPending": "Der Schlaf von letzter Nacht fehlt noch, der heutige Wert ist vorläufig.", "worthALook": "Einen Blick wert", "allClear": "Heute braucht nichts deine Aufmerksamkeit.", "ringLink": "{metric}-Details öffnen", @@ -11143,11 +11379,11 @@ }, "record": { "title": "Neue Bestmarke: {metric}", - "body": "Heute ein persönlicher Bestwert für {metric} — ganz ruhig festgehalten, du musst nichts tun." + "body": "Heute ein persönlicher Bestwert für {metric}, ganz ruhig festgehalten, du musst nichts tun." }, "returnToBaseline": { "title": "Wieder im Bereich: {metric}", - "body": "{metric} liegt wieder in deinem gewohnten Bereich. Was auch immer es war — es ist vorbei." + "body": "{metric} liegt wieder in deinem gewohnten Bereich. Was auch immer es war, es ist vorbei." }, "sustainedInRange": { "week1": { @@ -11358,7 +11594,7 @@ "errorScope": "So lassen sich diese Bereiche nicht teilen. Wähle mindestens einen Bereich aus oder teile die gesamte Akte.", "errorManageBrowserOnly": "Verwaltenden Zugriff kannst du nur im Browser vergeben.", "errorStepUp": "Bestätige deinen zweiten Faktor und versuche es erneut.", - "errorOffline": "Das hat den Server nicht erreicht. Deine Einladung steht noch hier — prüfe deine Verbindung und versuche es erneut." + "errorOffline": "Das hat den Server nicht erreicht. Deine Einladung steht noch hier. Prüfe deine Verbindung und versuche es erneut." }, "given": { "title": "Wer meine Akte öffnen kann", @@ -11413,7 +11649,7 @@ "managed": { "title": "Profile, die du betreust", "description": "Gesundheitsakten ohne eigenen Login.", - "explainer": "Ein verwaltetes Profil ist eine Gesundheitsakte für jemanden, der sich nicht selbst anmeldet — ein Kind oder eine Person, um die du dich kümmerst. Es hat keinen Login und keine E-Mail-Adresse, und du wirst die erste betreuende Person. Später kannst du weitere hinzufügen, und ihr alle könnt in die Akte wechseln, um sie zu pflegen.", + "explainer": "Ein verwaltetes Profil ist eine Gesundheitsakte für jemanden, der sich nicht selbst anmeldet, etwa ein Kind oder eine Person, um die du dich kümmerst. Es hat keinen Login und keine E-Mail-Adresse, und du wirst die erste betreuende Person. Später kannst du weitere hinzufügen, und ihr alle könnt in die Akte wechseln, um sie zu pflegen.", "nameLabel": "Name", "namePlaceholder": "Der Name, an dem du diese Akte erkennst", "nameHint": "Bis zu 80 Zeichen. Wird überall dort angezeigt, wo diese Akte auftaucht.", @@ -11435,7 +11671,7 @@ "creating": "Wird angelegt…", "created": "{name} ist eingerichtet. Über das Kontomenü kannst du in diese Akte wechseln.", "errorStepUp": "Bestätige deinen zweiten Faktor und versuche es erneut.", - "errorOffline": "Das hat den Server nicht erreicht. Es hat sich nichts geändert — prüfe deine Verbindung und versuche es erneut.", + "errorOffline": "Das hat den Server nicht erreicht. Es hat sich nichts geändert. Prüfe deine Verbindung und versuche es erneut.", "errorInvalid": "Der Server hat diese Angaben abgelehnt. Prüfe Name, Datum und Zeitzone.", "errorFailed": "Das Profil konnte nicht angelegt werden.", "edit": "Bearbeiten", @@ -11446,7 +11682,7 @@ "editErrorFailed": "Die Änderungen konnten nicht gespeichert werden.", "delete": "Profil löschen", "deleteTitle": "{name} löschen?", - "deleteBody": "Alles, was für {name} erfasst wurde, verschwindet mit dem Profil — Messwerte, Medikamente, Dokumente und Notizen. Das lässt sich nicht rückgängig machen.", + "deleteBody": "Alles, was für {name} erfasst wurde, verschwindet mit dem Profil: Messwerte, Medikamente, Dokumente und Notizen. Das lässt sich nicht rückgängig machen.", "deleteGuardiansOne": "{count} Person betreut diese Akte derzeit und verliert sie.", "deleteGuardiansFew": "{count} Personen betreuen diese Akte derzeit und verlieren sie.", "deleteGuardiansOther": "{count} Personen betreuen diese Akte derzeit und verlieren sie.", @@ -11461,7 +11697,7 @@ "guardianRemoveBody": "{name} kann {profile} dann nicht mehr öffnen. Was diese Person eingetragen hat, bleibt in der Akte.", "guardianWithdrawBody": "Die Einladung an {name} wird zurückgezogen. Die Person hat {profile} nie geöffnet, in der Akte ändert sich nichts.", "guardianRemoveConfirm": "Zugriff beenden", - "soleGuardian": "Du bist die einzige Person, die sich um {name} kümmert — hier gibt es also keinen Zugriff, den du beenden könntest. Lade jemanden ein und warte auf die Annahme, gib deinen eigenen Zugriff unter „Akten, die ich öffnen kann“ zurück, oder lösche das Profil.", + "soleGuardian": "Du bist die einzige Person, die sich um {name} kümmert. Hier gibt es also keinen Zugriff, den du beenden könntest. Lade jemanden ein und warte auf die Annahme, gib deinen eigenen Zugriff unter „Akten, die ich öffnen kann“ zurück, oder lösche das Profil.", "guardianInviteLabel": "Weitere betreuende Person einladen", "guardianInvite": "Einladung senden", "guardianInviting": "Wird gesendet…", @@ -11475,7 +11711,47 @@ "guardianErrorFailed": "Die Einladung konnte nicht gesendet werden.", "errorMfaRequired": "Dafür ist ein zweiter Faktor nötig, und dein Konto hat noch keinen. Richte unter Einstellungen → Sicherheit einen ein und versuche es dann erneut.", "rosterLoadError": "Es ließ sich nicht laden, wer sich um diese Akte kümmert.", - "errorRateLimit": "Zu viele Profile in der letzten Stunde angelegt. Warte etwas und versuche es erneut." + "errorRateLimit": "Zu viele Profile in der letzten Stunde angelegt. Warte etwas und versuche es erneut.", + "handover": { + "action": "Übergeben", + "title": "An {name} übergeben", + "description": "Gib {name} eine eigene Anmeldung. Nichts wird kopiert oder verschoben: Die Akte wird zum eigenen Konto.", + "guardiansLabel": "Zugriff nach der Übergabe", + "guardiansHint": "Dein Vorschlag. {name} sieht ihn und entscheidet nach der Anmeldung.", + "access": { + "end": "Kein Zugriff", + "read": "Nur ansehen", + "manage": "Verwalten" + }, + "expiryLabel": "Link gültig für", + "expiry1": "1 Tag", + "expiry7": "7 Tage", + "expiry14": "14 Tage", + "minorHint": "{name} ist unter 16. Es lohnt sich, den Lesezugriff zu behalten und den Schritt gemeinsam zu besprechen.", + "irreversible": "Sobald {name} den Link benutzt, lässt sich die Übergabe nicht rückgängig machen. Die Akte gehört dann {name}, und {name} entscheidet, wer Zugriff behält.", + "afterwards": "{name} bestätigt den Hinweis und jede KI-Einwilligung selbst. Erinnerungen für diese Akte erreichen dich nicht mehr.", + "replaceNote": "Ein neuer Link zieht den offenen zurück.", + "create": "Link erstellen", + "creating": "Wird erstellt…", + "linkOnce": "Dieser Link wird nur jetzt angezeigt. Gib ihn {name} persönlich oder über einen Weg, dem du vertraust, oder lass den Code scannen.", + "qrAlt": "QR-Code des Übergabelinks für {name}", + "urlLabel": "Übergabelink", + "copy": "Kopieren", + "copied": "Link kopiert", + "copyError": "Der Link ließ sich nicht kopieren. Markiere ihn und kopiere ihn von Hand.", + "linkExpires": "Gültig bis {date}.", + "done": "Fertig", + "pending": "Ein Übergabelink ist bis {date} offen.", + "replace": "Neuer Link", + "revoke": "Link zurückziehen", + "revoked": "Link zurückgezogen", + "revokeFailed": "Der Link ließ sich nicht zurückziehen. Versuche es erneut.", + "errorFailed": "Der Übergabelink ließ sich nicht erstellen.", + "errorRosterChanged": "Inzwischen hat sich geändert, wer die Akte betreut. Schließe das hier und öffne es erneut.", + "oidcOnly": "Ein Profil zu übergeben ist nicht möglich, solange dieser Server nur Single Sign-on erlaubt.", + "loadError": "Der Stand der Übergabe ließ sich nicht laden.", + "entryHint": "Wenn {name} die Akte selbst führen möchte." + } }, "toast": { "savedTo": "In der Akte von {name} gespeichert" @@ -11594,7 +11870,13 @@ "symptomEventDelete": "{name} hat einen Symptom-Eintrag gelöscht", "symptomDefinitionCreate": "{name} hat ein Symptom angelegt", "symptomDefinitionUpdate": "{name} hat ein Symptom geändert", - "symptomDefinitionDelete": "{name} hat ein Symptom entfernt" + "symptomDefinitionDelete": "{name} hat ein Symptom entfernt", + "customVaccineCreate": "{name} hat einen eigenen Impfstoff angelegt", + "customVaccineUpdate": "{name} hat einen eigenen Impfstoff bearbeitet", + "customVaccineDelete": "{name} hat einen eigenen Impfstoff entfernt", + "lifeEventCreate": "{name} hat ein Lebensereignis hinzugefügt", + "lifeEventUpdate": "{name} hat ein Lebensereignis geändert", + "lifeEventDelete": "{name} hat ein Lebensereignis entfernt" }, "section": { "measurements": { @@ -11632,7 +11914,7 @@ }, "actionError": { "retry": "Erneut versuchen", - "offline": "Das hat den Server nicht erreicht. Es hat sich nichts geändert — prüfe deine Verbindung und versuche es erneut.", + "offline": "Das hat den Server nicht erreicht. Es hat sich nichts geändert. Prüfe deine Verbindung und versuche es erneut.", "rateLimited": "Zu viele Versuche hintereinander. Warte eine Minute und versuche es erneut.", "acceptExpired": "Diese Einladung ist abgelaufen. Bitte um eine neue.", "acceptWithdrawn": "Diese Einladung wurde zurückgezogen.", @@ -11644,12 +11926,28 @@ "notFound": "Diesen Zugriff gibt es nicht mehr.", "revokeFailed": "Der Zugriff konnte nicht beendet werden.", "renounceFailed": "Der Zugriff konnte nicht zurückgegeben werden.", - "profileNotFound": "Dieses Profil gibt es nicht mehr — es wurde vielleicht schon gelöscht, oder dein Zugriff darauf ist beendet.", + "profileNotFound": "Dieses Profil gibt es nicht mehr. Es wurde vielleicht schon gelöscht, oder dein Zugriff darauf ist beendet.", "deleteProfileFailed": "Das Profil konnte nicht gelöscht werden.", "removeGuardianFailed": "Dieser Zugriff konnte nicht beendet werden." }, "anamnesis": { "relativeNote": "Ein Eintrag zur Familienanamnese beschreibt die Angehörigen dieser Person. Trage ihn nur ein, wenn sie das hier festgehalten haben möchte." + }, + "handoverDecision": { + "title": "Wer Zugriff auf deine Akte behält", + "description": "Entscheide, was die Personen, die deine Akte geführt haben, ab jetzt dürfen.", + "proposed": "Vorgeschlagen: {access}", + "settled": "Bereits unter Geteilter Zugriff geändert", + "empty": "Niemand sonst hatte Zugriff auf deine Akte.", + "later": "Du kannst das jederzeit unter Einstellungen, Geteilter Zugriff ändern.", + "save": "Bestätigen", + "saving": "Wird gespeichert…", + "decideLater": "Später", + "errorFailed": "Deine Entscheidung ließ sich nicht speichern. Versuche es erneut.", + "loadError": "Wer deine Akte bisher betreut hat, ließ sich nicht laden.", + "notificationsTitle": "Richte deine eigenen Erinnerungen ein", + "notificationsBody": "Erinnerungen für diese Akte gingen bisher an die Personen, die sie geführt haben. Ab jetzt erreichen sie dich, sobald du einen Benachrichtigungskanal einrichtest.", + "notificationsAction": "Benachrichtigungen öffnen" } }, "encounters": { @@ -11670,7 +11968,7 @@ "both": "Beidseitig", "none": "Keine Angabe" }, - "bodySiteWithSide": "{site} · {side}", + "bodySiteWithSide": "{site} ({side})", "sectionTitle": "Termine", "sectionDescription": "Arztbesuche und gebuchte Termine an einem Ort.", "add": "Termin", @@ -11685,7 +11983,7 @@ "created": "Termin gespeichert", "updated": "Termin aktualisiert", "saveFailed": "Termin konnte nicht gespeichert werden.", - "checkupNotClosed": "Termin gespeichert. Die Vorsorge bleibt offen — dafür reicht deine Freigabe nicht.", + "checkupNotClosed": "Termin gespeichert. Die Vorsorge bleibt offen, dafür reicht deine Freigabe nicht.", "status": { "planned": "Geplant", "done": "Stattgefunden", @@ -11702,7 +12000,7 @@ "dateDerivesStatusHint": "Ein Datum in der Zukunft ist ein gebuchter Termin.", "status": "Status", "practitioner": "Praxis oder Arzt", - "practitionerHint": "Optional — du kannst den Namen später nachtragen.", + "practitionerHint": "Optional, du kannst den Namen später nachtragen.", "practitionerNone": "Keine Angabe", "practitionerSearch": "Name oder Praxis suchen", "practitionerNoMatch": "Kein Eintrag gefunden.", @@ -11760,10 +12058,9 @@ }, "practitioners": { "title": "Praxen und Ärzte", - "description": "Dein eigenes Adressbuch — einmal tippen, überall auswählen.", + "description": "Dein eigenes Adressbuch: einmal tippen, überall auswählen.", "linkLabel": "Adressbuch", - "add": "Eintrag", - "searchPlaceholder": "Name, Praxis oder Fachrichtung suchen", + "searchPlaceholder": "Name, Praxis oder Fach suchen", "loadError": "Adressbuch konnte nicht geladen werden.", "emptyTitle": "Noch kein Eintrag", "emptyDescription": "Lege eine Praxis an, damit du sie beim Termin auswählen kannst.", @@ -11859,7 +12156,6 @@ "unreadableLabel": "Unlesbares Symptom", "intensityPill": "{value}/10", "entry": { - "sheetTitle": "Symptom erfassen", "sheetDescription": "Eines deiner eigenen Symptome, wie stark und wann.", "symptom": "Symptom", "symptomRequired": "Wähle ein Symptom.", @@ -11908,5 +12204,411 @@ "title": "Erfasste Symptome", "filed": "zu dieser Erkrankung erfasst" } + }, + "day": { + "scoreOfMax": "von {max}", + "cycleDay": "Zyklustag {n}", + "cyclePhaseLine": { + "MENSTRUAL": "Zyklustag {n}, Menstruation", + "FOLLICULAR": "Zyklustag {n}, Follikelphase", + "OVULATORY": "Zyklustag {n}, Ovulation", + "LUTEAL": "Zyklustag {n}, Lutealphase" + }, + "previousDay": "Vortag", + "nextDay": "Folgetag", + "close": "Tag schließen", + "hidePanel": "Tag ausblenden", + "showPanel": "{date} einblenden", + "openDay": "Tag öffnen", + "viewWholeDay": "Ganzen Tag ansehen", + "captureForDay": "Für diesen Tag erfassen", + "empty": "Für diesen Tag gibt es keine Einträge.", + "notShared": "Einige Bereiche sind für dich nicht freigegeben.", + "inTimeline": "In der Zeitleiste", + "askCoach": "Im Coach nach diesem Tag fragen", + "chartHint": "Klick auf einen Punkt öffnet den Tag.", + "loadFailed": "Dieser Tag konnte nicht geladen werden.", + "dayOf": "Tag {n} von {m}", + "dayN": "Tag {n}", + "usualRange": "Üblich {lo} bis {hi}", + "groups": { + "scores": "Scores", + "running": "Laufend an diesem Tag", + "values": "Werte", + "happened": "Was war" + }, + "notable": { + "extremeHigh": "Höchster Tageswert seit {since}", + "extremeLow": "Niedrigster Tageswert seit {since}", + "firstValue": "Erster Wert dieser Art", + "gap": "{days} Tage ohne Wert" + }, + "previousDayShortcut": "Vortag (Alt + Pfeil links)", + "nextDayShortcut": "Folgetag (Alt + Pfeil rechts)", + "captureShort": "Erfassen", + "inTimelineShort": "Zeitleiste", + "askCoachPrompt": "Was war am {date}?", + "chartHintTouch": "Antippen zeigt den Wert.", + "chartHintBar": "Klick auf einen Balken öffnet den Tag.", + "chartHintTouchBar": "Antippen zeigt die Werte.", + "chartHintCell": "Klick auf einen Tag öffnet ihn.", + "chartHintTouchCell": "Antippen zeigt den Wert des Tages.", + "coachChips": "Tage in dieser Antwort", + "description": "Alles, was deine Einträge für diesen Tag enthalten.", + "openDocument": "Dokument öffnen", + "previousReading": "Vorher, {date}", + "rugLegend": "Punkte unter der Achse markieren Tage mit Einträgen, ein Ring einen auffälligen Tag.", + "sheetExpand": "Auf volle Höhe aufziehen", + "sheetShrink": "Verkleinern", + "valuesLegend": "Punkt für diesen Tag, Strich für die 30 Tage davor", + "valuesAllOne": "Alle Werte", + "valuesAllFew": "Alle {count} Werte", + "valuesAllOther": "Alle {count} Werte", + "valuesFewer": "Weniger Werte", + "countEntriesOne": "1 Eintrag", + "countEntriesFew": "{count} Einträge", + "countEntriesOther": "{count} Einträge", + "meanOfReadingsOne": "Schnitt aus 1 Messung", + "meanOfReadingsFew": "Schnitt aus {count} Messungen", + "meanOfReadingsOther": "Schnitt aus {count} Messungen", + "sinceLastVisit": { + "title": "Seit dem letzten Besuch am {date}", + "countOne": "1 Eintrag", + "countFew": "{count} Einträge", + "countOther": "{count} Einträge", + "allOne": "Alle anzeigen", + "allFew": "Alle {count} anzeigen", + "allOther": "Alle {count} anzeigen", + "fewer": "Weniger anzeigen", + "note": "Zusammengestellt aus deinen Einträgen, ohne Bewertung. Jedes Datum öffnet den ganzen Tag.", + "report": "Arztbericht {date} bis heute" + }, + "travel": "Unterwegs", + "event": { + "started": "{label} begonnen", + "ended": "{label} beendet", + "paused": "{label} pausiert", + "resumed": "{label} wieder aufgenommen", + "illnessBegan": "{label} begann", + "illnessResolved": "{label} abgeklungen" + }, + "pickDate": "Datum wählen", + "pickerTitle": "Zu einem Tag springen", + "today": "Heute", + "hasEntries": "mit Einträgen", + "dayNSince": "Tag {n}, seit {date}", + "sinceDate": "Seit {date}" + }, + "timeline": { + "title": "Zeitleiste", + "subtitle": "Was wann war, neben deinen Werten.", + "standing": "Dauerhaft", + "startUnknown": "Beginn unbekannt", + "zoom": { + "all": "Alles", + "year": "12 Monate", + "quarter": "3 Monate", + "range": "Zeitraum" + }, + "lanes": { + "life": "Leben", + "illness": "Erkrankungen", + "allergies": "Allergien", + "medications": "Medikamente", + "vaccinations": "Impfungen", + "visits": "Arzt und Eingriffe", + "labs": "Labor", + "documents": "Dokumente", + "cycle": "Zyklus" + }, + "readiness": { + "title": "Was deine Zeitleiste schon trägt", + "subtitle": "Eine ehrliche Bestandsaufnahme, bevor du sie öffnest.", + "menuEntry": "Datenlage", + "hint": "Leere Spuren bleiben ausgeblendet, bis etwas da ist. Abgeschaltete Module erscheinen nie. Du findest diese Übersicht später unter Datenlage in der Zeitleiste.", + "later": "Später", + "open": "Zeitleiste öffnen", + "verdict": { + "carries": "Trägt schon.", + "thin": "Noch dünn. Die Zeitleiste zeigt vorerst vor allem deine Werte." + }, + "status": { + "carries": "trägt", + "thin": "dünn", + "empty": "leer" + }, + "lanes": { + "values": "Werte", + "mood": "Stimmung", + "environment": "Umwelt", + "life": "Lebensereignisse" + }, + "meter": "{carrying} von {total} Spuren tragen", + "carriesDetail": "{carrying} von {total} Spuren reichen für einen Überblick ab {since}.", + "loadFailed": "Die Bestandsaufnahme konnte nicht geladen werden.", + "card": { + "title": "Was die Zeitleiste schon trägt", + "summary": "Sie reicht bis {since} zurück, {carrying} von {total} Spuren tragen schon.", + "dismiss": "Ausblenden" + }, + "detail": { + "valuesOne": "{count} Messgröße seit {since}", + "valuesFew": "{count} Messgrößen seit {since}", + "valuesOther": "{count} Messgrößen seit {since}", + "illnessOne": "{count} Episode, davon {chronic} chronisch", + "illnessFew": "{count} Episoden, davon {chronic} chronisch", + "illnessOther": "{count} Episoden, davon {chronic} chronisch", + "allergiesOne": "{count} Allergie", + "allergiesFew": "{count} Allergien", + "allergiesOther": "{count} Allergien", + "medicationsOne": "{count} Medikament, {missingStart} ohne Startdatum", + "medicationsFew": "{count} Medikamente, {missingStart} ohne Startdatum", + "medicationsOther": "{count} Medikamente, {missingStart} ohne Startdatum", + "medicationsDatedOne": "{count} Medikament mit Startdatum", + "medicationsDatedFew": "{count} Medikamente mit Startdatum", + "medicationsDatedOther": "{count} Medikamente mit Startdatum", + "vaccinationsOne": "{count} Impfung", + "vaccinationsFew": "{count} Impfungen", + "vaccinationsOther": "{count} Impfungen", + "visitsOne": "{count} Besuch, {procedures} mit Eingriff", + "visitsFew": "{count} Besuche, {procedures} mit Eingriff", + "visitsOther": "{count} Besuche, {procedures} mit Eingriff", + "visitsPlannedOne": "{count} Besuch, nur geplant", + "visitsPlannedFew": "{count} Besuche, nur geplant", + "visitsPlannedOther": "{count} Besuche, nur geplant", + "labsOne": "{count} Befundtag", + "labsFew": "{count} Befundtage", + "labsOther": "{count} Befundtage", + "documentsOne": "{count} Dokument mit Datum", + "documentsFew": "{count} Dokumente mit Datum", + "documentsOther": "{count} Dokumente mit Datum", + "lifeOne": "{count} Lebensereignis", + "lifeFew": "{count} Lebensereignisse", + "lifeOther": "{count} Lebensereignisse", + "cycleOne": "{count} Zyklus", + "cycleFew": "{count} Zyklen", + "cycleOther": "{count} Zyklen", + "moodOne": "{count} Eintrag seit {since}", + "moodFew": "{count} Einträge seit {since}", + "moodOther": "{count} Einträge seit {since}", + "environmentOne": "{count} Tag mit Umweltdaten", + "environmentFew": "{count} Tage mit Umweltdaten", + "environmentOther": "{count} Tage mit Umweltdaten", + "empty": "Noch keine" + }, + "gap": { + "valuesEmpty": "Quellen verbinden", + "illnessEmpty": "Episode anlegen", + "medicationsWithoutStart": "Ergänzen", + "visitsEmpty": "Besuch nachtragen", + "documentsUndated": "Ansehen", + "labsEmpty": "Befund erfassen", + "vaccinationsEmpty": "Impfpass erfassen", + "lifeEventsEmpty": "Hinzufügen" + }, + "gapLine": { + "medicationsWithoutStartOne": "{count} Medikament ohne Startdatum", + "medicationsWithoutStartFew": "{count} Medikamente ohne Startdatum", + "medicationsWithoutStartOther": "{count} Medikamente ohne Startdatum", + "documentsUndatedOne": "{count} Dokument ohne Datum", + "documentsUndatedFew": "{count} Dokumente ohne Datum", + "documentsUndatedOther": "{count} Dokumente ohne Datum", + "valuesEmpty": "Noch keine Werte über längere Zeit", + "illnessEmpty": "Noch keine Erkrankungen mit Beginn", + "visitsEmpty": "Noch keine erledigten Arztbesuche", + "labsEmpty": "Noch keine Laborbefunde", + "vaccinationsEmpty": "Noch keine Impfungen", + "lifeEventsEmpty": "Noch keine Lebensereignisse" + } + }, + "legendSpan": "Zeitraum", + "legendEvent": "Ereignis", + "legendMeanQuarter": "Quartalsmittel", + "legendMeanMonth": "Monatsmittel", + "legendMeanWeek": "Wochenmittel", + "legendThin": "Weniger als 3 Messungen", + "legendGap": "Kurze Lücke ohne Messungen", + "today": "Heute", + "noDataYet": "Noch keine Daten", + "layers": "Ebenen", + "layersLanes": "Spuren", + "zoomLabel": "Zeitraum der Ansicht", + "chartLabel": "Zeitleiste von {from} bis {to}", + "keyboardHint": "Pfeiltasten verschieben die Auswahl, Pos1 und Ende springen an die Enden, Eingabe öffnet den Tag.", + "loadFailed": "Die Zeitleiste konnte nicht geladen werden.", + "empty": { + "title": "Noch nichts auf der Zeitleiste", + "description": "Sobald es Einträge mit Datum gibt, erscheinen sie hier." + }, + "values": { + "menuTitle": "Werte unter den Spuren", + "trigger": "Werte ({count})", + "noneInWindow": "Keine Werte in diesem Zeitraum", + "series": { + "BLOOD_PRESSURE_SYS": "Blutdruck sys.", + "BLOOD_PRESSURE_DIA": "Blutdruck dia." + }, + "noValue": "kein Wert", + "bloodPressure": "Blutdruck", + "readingsOne": "1 Messung", + "readingsFew": "Schnitt aus {count} Messungen", + "readingsOther": "Schnitt aus {count} Messungen" + }, + "table": { + "caption": "Einträge der Zeitleiste", + "lane": "Spur", + "entry": "Eintrag", + "start": "Beginn", + "end": "Ende", + "seriesCaption": "Mittelwerte nach Zeitraum", + "period": "Zeitraum" + }, + "selection": { + "empty": "Keine Einträge in diesem Zeitraum", + "since": "seit {date}", + "range": "{from} bis {to}", + "mean": "Ø {values}", + "week": "Woche vom {date}", + "hintClick": "Klicke auf einen Zeitpunkt, um ihn hier zu sehen", + "hintTap": "Tippe auf einen Zeitpunkt, um ihn hier zu sehen" + }, + "chronicle": { + "months": "Monate", + "years": "Jahre", + "groupingLabel": "Gruppierung", + "empty": "Keine Einträge von {from} bis {to}", + "emptySingle": "{period}: keine Einträge", + "ended": "{label} vorbei", + "afterDaysOne": "nach einem Tag", + "afterDaysFew": "nach {count} Tagen", + "afterDaysOther": "nach {count} Tagen", + "notable": { + "extremeHigh": "Höchster Wert seit Langem", + "extremeLow": "Niedrigster Wert seit Langem", + "firstValue": "Erster Wert einer Messgröße", + "gap": "Längere Lücke in den Werten" + }, + "notableMeta": "Tag öffnen für Einzelheiten" + }, + "standingAllergiesOne": "{count} Allergie", + "standingAllergiesFew": "{count} Allergien", + "standingAllergiesOther": "{count} Allergien", + "legendMeanDay": "Tagesmittel", + "range": { + "from": "Von", + "to": "Bis", + "label": "Gewählter Zeitraum", + "open": "Zeitraum wählen", + "clear": "Alles zeigen", + "apply": "Übernehmen" + }, + "item": { + "labValuesOne": "1 Laborwert", + "labValuesFew": "{count} Laborwerte", + "labValuesOther": "{count} Laborwerte" + } + }, + "lifeEvents": { + "title": "Lebensereignis", + "add": "Lebensereignis", + "privacy": "Titel und Notiz liegen verschlüsselt. Sie gehen an keine KI.", + "emptyHint": "Umzug, Geburt oder eine neue Stelle geben den Jahren einen Anker.", + "save": "Speichern", + "cancel": "Abbrechen", + "delete": "Lebensereignis löschen", + "saved": "Lebensereignis gespeichert", + "deleted": "Lebensereignis gelöscht", + "fields": { + "title": "Was war?", + "category": "Art", + "when": "Wann", + "precision": "Genauigkeit", + "addEnd": "Enddatum, wenn es ein Zeitraum war", + "endDate": "Bis", + "note": "Notiz", + "optionalTag": "optional", + "notePlaceholder": "Etwas, das du später wissen willst", + "removeEnd": "Enddatum entfernen" + }, + "category": { + "FAMILY": "Familie", + "HOME": "Wohnen", + "WORK": "Arbeit", + "LOSS": "Verlust", + "OTHER": "Sonstiges" + }, + "precision": { + "DAY": "Tag", + "MONTH": "Monat", + "YEAR": "Jahr" + }, + "edit": "Lebensereignis bearbeiten", + "errors": { + "titleRequired": "Gib dem Ereignis einen Namen.", + "titleTooLong": "Höchstens 120 Zeichen.", + "categoryRequired": "Wähle eine Art.", + "dateRequired": "Gib ein gültiges Datum an.", + "endBeforeStart": "Das Ende liegt vor dem Beginn.", + "noteTooLong": "Höchstens 2000 Zeichen." + }, + "saveFailed": "Speichern hat nicht geklappt.", + "deleteFailed": "Löschen hat nicht geklappt.", + "deleteConfirm": { + "title": "Lebensereignis löschen?", + "body": "Titel und Notiz sind danach nicht mehr da." + } + }, + "palette": { + "open": "Suchen…", + "openLabel": "Seiten, Einstellungen und Aktionen suchen", + "title": "Suche", + "description": "Finde eine Seite, eine Einstellung, ein Modul oder eine Aktion.", + "placeholder": "Seiten, Einstellungen, Module suchen…", + "results": "Treffer", + "noResults": "Nichts gefunden für „{query}“.", + "moduleOn": "An", + "moduleOff": "Aus", + "hintMove": "Wählen", + "hintOpen": "Öffnen", + "hintClose": "Schließen", + "groups": { + "recent": "Zuletzt", + "pages": "Seiten", + "insights": "Insights", + "settings": "Einstellungen", + "modules": "Module", + "actions": "Aktionen" + }, + "actions": { + "capture": "Neuer Eintrag", + "coach": "Coach fragen", + "backup": "Backup erstellen", + "today": "Heute öffnen", + "shortcuts": "Tastenkürzel zeigen" + } + }, + "shortcuts": { + "title": "Tastenkürzel", + "description": "Tasten, die überall in der App funktionieren, außer während du in ein Feld tippst.", + "menuEntry": "Tastenkürzel", + "then": "dann", + "search": "Suchen", + "help": "Diese Liste zeigen", + "close": "Dialog oder Bereich schließen", + "previousDay": "Vorheriger Tag", + "nextDay": "Nächster Tag", + "dayHint": "Während ein Tag geöffnet ist.", + "groups": { + "navigation": "Navigation", + "actions": "Aktionen", + "day": "Tag" + } + }, + "aboutPage": { + "eyebrow": "Info", + "title": "Über HealthLog", + "lastUpdated": "Zuletzt aktualisiert: {date}", + "project": "Projekt", + "credits": "Danksagungen" } } diff --git a/messages/en.json b/messages/en.json index d784d03e7..f128039c2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -49,7 +49,9 @@ "dismiss": "Dismiss", "moreInfo": "More information", "durationHoursMinutes": "{hours}h {minutes}m", - "durationMinutes": "{minutes}m" + "durationMinutes": "{minutes}m", + "durationClockMinutes": "{time} min", + "durationClockHours": "{time} h" }, "apiErrors": { "measurement": { @@ -165,7 +167,7 @@ "anamnesisShiftSchedule": "Work schedule", "anamnesisColFact": "Profile item", "anamnesisColValue": "Recorded value", - "subtitle": "HealthLog — Personal Health Report", + "subtitle": "HealthLog: Personal Health Report", "patient": "Patient", "dateOfBirth": "Date of birth", "gender": "Gender", @@ -211,18 +213,18 @@ "contacts": "Emergency contacts", "notes": "Additional notes" }, - "vitalsTitle": "Vital signs — Summary", + "vitalsTitle": "Vital signs: summary", "colParameter": "Parameter", "colCurrent": "Current", "colAvgPeriod": "Avg. period", "colMin": "Min", "colMax": "Max", "colN": "n", - "bpClassificationTitle": "Blood pressure — ESH classification (2023)", + "bpClassificationTitle": "Blood pressure: ESH classification (2023)", "avgBp": "Average blood pressure", "classification": "Classification", "bmiTitle": "Body Mass Index (BMI)", - "bmiRow": "BMI: {bmi} kg/m² — Classification: {class} (WHO)", + "bmiRow": "BMI: {bmi} kg/m², Classification: {class} (WHO)", "complianceTitle": "Medication adherence", "glp1Title": "GLP-1 therapy", "glp1WeightSummary": "Weight over report period: {start} kg → {end} kg ({delta} kg)", @@ -240,7 +242,7 @@ "colExpected": "Expected", "colComplianceRate": "Adherence rate", "moodTitle": "Mood trajectory", - "moodSummary": "Average: {avg} / 5.0 · Entries: {count} · Range: {min}–{max}", + "moodSummary": "Average {avg} / 5.0, {count} entries, range {min}–{max}", "colMood": "Mood", "colCount": "Count", "colShare": "Share", @@ -270,8 +272,8 @@ "typeGlucoseRandom": "Glucose (random)", "typeGlucoseBedtime": "Glucose (bedtime)", "typeGlucoseUnspecified": "Glucose (untagged)", - "glucoseClassificationTitle": "Blood glucose — reference ranges (ADA 2024 / DDG)", - "glucoseRow": "{label}: avg {avg} {unit} (target {rangeMin}–{rangeMax}) — {class}", + "glucoseClassificationTitle": "Blood glucose: reference ranges (ADA 2024 / DDG)", + "glucoseRow": "{label}: avg {avg} {unit} (target {rangeMin}–{rangeMax}), {class}", "glucoseInTarget": "in target", "glucoseBelowTarget": "below target", "glucoseAboveTarget": "above target", @@ -323,7 +325,7 @@ "gmi": "Glucose Management Indicator (GMI)", "eA1c": "Estimated A1C", "cv": "Variability (CV%)", - "spotCaveat": "These are spot-reading estimates from individual measurements, not a continuous-monitor profile — read them as a direction of travel rather than a clinical verdict." + "spotCaveat": "These are spot-reading estimates from individual measurements, not a continuous-monitor profile. Read them as a direction of travel rather than a clinical verdict." }, "illnessTitle": "Conditions & illnesses", "illnessOngoing": "Ongoing", @@ -376,12 +378,12 @@ "backToDashboard": "Back to dashboard" }, "offlineBanner": { - "message": "No connection — changes can't be saved until you're back online.", - "saveFailed": "Couldn't save — you're offline. Please try again once you're back online.", + "message": "No connection: changes can't be saved until you're back online.", + "saveFailed": "Couldn't save, you're offline. Please try again once you're back online.", "showingCached": "Showing your last synced data." }, "demoBanner": { - "message": "Demo mode — changes are not saved." + "message": "Demo mode: changes are not saved." }, "nav": { "dashboard": "Dashboard", @@ -390,7 +392,7 @@ "insights": "Insights", "achievements": "Achievements", "settings": "Settings", - "admin": "Admin Console", + "admin": "Admin console", "logout": "Sign out", "login": "Sign in", "notifications": "Notifications", @@ -427,7 +429,9 @@ "workout": "Workout", "workoutDescription": "A session your devices did not record", "symptom": "Symptom", - "symptomDescription": "One of your own symptoms, with how strong" + "symptomDescription": "One of your own symptoms, with how strong", + "lifeEventDescription": "A move, a birth, a new job", + "lifeEvent": "Life event" }, "cycle": "Cycle", "labs": "Labs", @@ -436,7 +440,8 @@ "illness": "Illness", "documents": "Documents", "vaccinations": "Vaccinations", - "mentalWellbeing": "Mental wellbeing" + "mentalWellbeing": "Mental wellbeing", + "timeline": "Timeline" }, "auth": { "login": "Sign in", @@ -453,14 +458,13 @@ "hasAccount": "Already have an account?", "loginFailed": "Login failed", "registerFailed": "Registration failed", - "invitedBanner": "You have been invited — create your account even while registration is otherwise closed.", + "invitedBanner": "You have been invited. Create your account even while registration is otherwise closed.", "alreadySignedIn": { "title": "You are already signed in", - "body": "You are signed in as {username}. An invitation creates a new account — sign out to accept it.", + "body": "You are signed in as {username}. An invitation creates a new account. Sign out to accept it.", "signOut": "Sign out and accept the invitation" }, "createAccount": "Create account", - "newAccount": "Create new account", "backToPasskey": "Back to Passkey", "passkeySetupLater": "You can set up passkeys at any time in Settings.", "passwordMinLength": "Min. 12 characters", @@ -513,6 +517,37 @@ "fixLan": "Server on a home network without TLS: set SESSION_COOKIE_SECURE=false in its settings and restart it.", "fixHttps": "Server behind an HTTPS proxy: open the https:// address instead, and make sure the proxy forwards X-Forwarded-Proto: https.", "localhostNote": "This page is on http://localhost while the server sets its sign-in cookie for https:// only. Most browsers accept that on localhost; if signing in keeps returning here, set SESSION_COOKIE_SECURE=false." + }, + "claim": { + "title": "Take over your health record", + "titleNamed": "Welcome, {name}", + "description": "Your health record has been looked after for you until now. Choose how you sign in and it becomes your own account. Nothing is copied or moved.", + "loading": "Checking your link…", + "invalid": "This link no longer works", + "invalidHint": "It is unknown, expired, withdrawn or already used. Ask the person who sent it for a new one.", + "oidcOnly": "This server only allows single sign-on, so a profile cannot be taken over here yet.", + "signedInTitle": "Sign out first", + "signedInBody": "You are signed in as {username}. This link sets up a sign-in for someone else, so sign out before you use it.", + "signOut": "Sign out and continue", + "guardiansTitle": "Who keeps access for now", + "guardiansHint": "Proposed by the people who looked after your record. You decide right after signing in.", + "guardianUnnamed": "A guardian", + "usernameHint": "3 to 30 letters, numbers, _ or -.", + "usernameReserved": "Usernames starting with managed- are reserved.", + "emailHint": "Required, so you can get back in if you forget your password.", + "consentNote": "You confirm the disclaimer and any use of AI yourself. Until you do, nothing from your record is sent to an AI provider.", + "expires": "This link is valid until {date}.", + "submit": "Take over", + "submitting": "Taking over…", + "errorTaken": "That username or email address is already in use.", + "errorRateLimit": "Too many attempts. Wait a few minutes and try again.", + "errorFailed": "That did not work. Try again in a moment.", + "errorOffline": "That did not reach the server. Check your connection and try again." + }, + "inviteInvalid": { + "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" } }, "dashboard": { @@ -544,17 +579,17 @@ "sleepShort": "Sleep", "stepsShort": "Steps", "vo2MaxShort": "VO₂ max", - "bpInTargetWindow90": "BP in Target · 90d", - "bpInTargetWindowDynamic": "BP in Target · {days}d", + "bpInTargetWindow90": "BP in Target (90d)", + "bpInTargetWindowDynamic": "BP in Target ({days}d)", "bpInTargetCollecting": "collecting data", "medications": "Medications", "quickAddMeasurement": "Log measurement", "quickAddMood": "Log mood", "quickAddMedicationIntake": "Log medication intake", "quickAddWorkout": "Log workout", + "quickAddLifeEvent": "Log life event", "customizeDashboard": "Customize dashboard", "medicationIntakeQuickAdd": { - "sheetTitle": "Log medication intake", "sheetDescription": "Record dose, time, and medication in a few taps.", "medicationLabel": "Medication", "medicationPlaceholder": "Select a medication", @@ -667,7 +702,8 @@ "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" + "quickAddSymptom": "Log symptom", + "todayCardVisible": "Show the top card" }, "measurements": { "title": "Measurements", @@ -739,7 +775,7 @@ "formReset": "Reset form", "saveError": "Error saving", "duplicateTimestamp": "A measurement of this type already exists at the selected timestamp.", - "serverOwnedSource": "Values from a connected source can't be edited — the timestamp and note stay editable.", + "serverOwnedSource": "Values from a connected source can't be edited, but the timestamp and note stay editable.", "deleteError": "Error deleting", "deletedToast": "Measurement deleted", "restoredToast": "Measurement restored", @@ -834,11 +870,12 @@ "durationHoursOnly": "{hours} h", "durationMinutesOnly": "{minutes} min", "durationInvalid": "Enter a duration like 7.5, 7:30 or 7h 30m.", - "durationTooLong": "One entry can't be longer than 24 hours." + "durationTooLong": "One entry can't be longer than 24 hours.", + "sourceHealthConnect": "Health Connect" }, "metricCatalog": { "title": "What HealthLog can track", - "description": "Every metric domain HealthLog supports, whether or not you have data for it yet — and what would supply it.", + "description": "Every metric domain HealthLog supports, whether or not you have data for it yet, and what would supply it.", "statusPresent": "Tracked", "statusAbsent": "Not tracked yet", "statusModuleOff": "Off", @@ -846,11 +883,11 @@ "connectCta": "Connect a source", "viewCta": "View", "ecg": { - "source": "Single-lead ECG strips from a compatible watch — Withings ScanWatch today — synced automatically." + "source": "Single-lead ECG strips from a compatible watch (Withings ScanWatch today), synced automatically." }, "watchdog": { "title": "Automatic screening", - "description": "HealthLog also watches quietly in the background — irregular-rhythm and breathing-disturbance notifications from a connected watch, baseline-drift health-status alerts, and notable shifts between your two most recent lab panels — and surfaces a card only when something stands out. With at least one of those sources connected, no card showing is a good sign, not a missing feature; with none connected yet, there's simply nothing being watched." + "description": "HealthLog also watches quietly in the background: irregular-rhythm and breathing-disturbance notifications from a connected watch, baseline-drift health-status alerts, and notable shifts between your two most recent lab panels. It surfaces a card only when something stands out. With at least one of those sources connected, no card showing is a good sign, not a missing feature; with none connected yet, there's simply nothing being watched." } }, "mood": { @@ -1007,7 +1044,7 @@ "archive": "Archive", "restore": "Restore", "archivedTitle": "Archived tags", - "archivedDescription": "Archived tags stay in your past entries — they only disappear from the picker.", + "archivedDescription": "Archived tags stay in your past entries. They only disappear from the picker.", "archivedEmpty": "Nothing archived.", "purgeTitle": "Delete tag permanently?", "purgeBody": "This removes the tag from {count} past entries. This cannot be undone.", @@ -1032,7 +1069,7 @@ }, "sectionMoreTags": "More tags", "sectionNoteDetails": "Note & details", - "quickTagsHelp": "Your frequent tags — one tap", + "quickTagsHelp": "Your frequent tags, one tap", "quickTagsExpand": "More", "loadError": "Could not load your mood entries.", "sectionLevelA": "State of mind", @@ -1210,8 +1247,8 @@ "dialogDescription": "These medications are due now. Confirm to record all of them as taken.", "confirm": "Take all ({count})", "successToast": "{count} intakes recorded", - "partialToast": "{taken} recorded, {failed} failed – please try again", - "failedToast": "No intake recorded – please try again" + "partialToast": "{taken} recorded, {failed} failed. Please try again", + "failedToast": "No intake recorded. Please try again" }, "logIntake": { "dialogTitle": "Log an intake", @@ -1300,7 +1337,7 @@ "complianceInfoLabel": "What does adherence mean?", "lastIntake": "Last intake:", "nextIntake": "Next intake:", - "nextIntakeOverdue": "Overdue · {time} — can still be taken", + "nextIntakeOverdue": "Overdue since {time}, can still be taken", "dayStreak": "day streak", "dayStreakOne": "day streak", "cycleDueToday": "Due today", @@ -1309,10 +1346,10 @@ "skipped": "Skipped", "intakeToastTaken": "{name} recorded as taken", "intakeToastSkipped": "{name} skipped", - "intakeToastFailed": "Could not record {name} — try again", + "intakeToastFailed": "Could not record {name}. Try again", "intakeUndo": "Undo", "intakeUndone": "Entry reverted", - "intakeUndoFailed": "Could not undo — open the history to fix it", + "intakeUndoFailed": "Could not undo. Open the history to fix it", "glp1LastInjection": "{label}", "glp1NextInjectionDays": "{label} (in {days} days)", "glp1NextInjectionToday": "today", @@ -1394,10 +1431,10 @@ "error": { "empty": "Type a description first.", "tooLong": "Keep the description under 2000 characters.", - "rateLimit": "Too many requests — wait a moment and try again.", - "noProvider": "No AI provider configured — set one up in Settings.", - "budget": "Daily AI budget reached — try again tomorrow.", - "network": "Could not reach the AI provider — please retry.", + "rateLimit": "Too many requests. Wait a moment and try again.", + "noProvider": "No AI provider configured. Set one up in Settings.", + "budget": "Daily AI budget reached. Try again tomorrow.", + "network": "Could not reach the AI provider. Please retry.", "consent": "Reading your description with AI needs your consent first.", "unavailable": "Reading descriptions with AI is not available here right now." } @@ -1413,7 +1450,7 @@ "everyNMonths": "Every N months on a specific day", "yearly": "Once a year", "rolling": "Every N days from when I last took it (flexible)", - "rollingExplainer": "Counts from your last logged intake — pauses if you skip a dose.", + "rollingExplainer": "Counts from your last logged intake; pauses if you skip a dose.", "oneShot": "One-time dose" }, "weekdays": { @@ -1471,7 +1508,7 @@ "night": "Night" }, "max": { - "reached": "Maximum reached — remove a time before adding another." + "reached": "Maximum reached. Remove a time before adding another." } }, "courseWindow": { @@ -1521,7 +1558,7 @@ }, "step3": { "title": "What's the dose?", - "subline": "A tablet, a puff, a drop — depending on the form.", + "subline": "A tablet, a puff, a drop, depending on the form.", "amountLabel": "Amount", "amountPlaceholder": "e.g. 5", "unitLabel": "Unit", @@ -1547,7 +1584,7 @@ }, "dosesPerUnitLabel": "Doses per unit", "dosesPerUnitPlaceholder": "e.g. 4", - "dosesPerUnitHint": "Doses per pen / vial — leave empty for no inventory tracking.", + "dosesPerUnitHint": "Doses per pen / vial. Leave empty for no inventory tracking.", "short": "Dose", "unitsPerDoseLabel": "Units per dose", "unitsPerDoseHint": "e.g. 2 tablets of 2 mg for a 4 mg dose, or ½ a tablet for a split dose" @@ -1559,7 +1596,7 @@ }, "step5": { "title": "How often do you take it?", - "subline": "Pick the cadence — the details follow on the next step.", + "subline": "Pick the cadence. The details follow on the next step.", "short": "Cadence" }, "step6": { @@ -1581,7 +1618,7 @@ }, "step7": { "title": "When do you take it?", - "subline": "One or more times of day — reminders follow this list.", + "subline": "One or more times of day. Reminders follow this list.", "presetsLabel": "Time-of-day presets", "presets": { "morning": "Morning", @@ -1589,7 +1626,7 @@ "evening": "Evening", "night": "Night" }, - "overrideHint": "Optional — set a different dose or unit count just for this time of day. Leave blank to use the medication's default.", + "overrideHint": "Optional: set a different dose or unit count just for this time of day. Leave blank to use the medication's default.", "doseOverrideLabel": "Dose for this time (optional)", "doseOverridePlaceholder": "e.g. half a tablet", "unitsOverrideLabel": "Units consumed at this time", @@ -1597,11 +1634,11 @@ "short": "Times" }, "step8": { - "title": "All done — should HealthLog remind you?", + "title": "All done. Should HealthLog remind you?", "subline": "Review everything below before you save.", "remindersLabel": "Reminders on", - "remindersDescription": "Push, Telegram or ntfy — based on your settings.", - "noChannelHint": "Reminders need a notification channel — none is active right now.", + "remindersDescription": "Push, Telegram or ntfy, based on your settings.", + "noChannelHint": "Reminders need a notification channel, and none is active right now.", "noChannelHintLink": "Set up notifications", "short": "Done" } @@ -1650,7 +1687,7 @@ }, "asNeeded": { "label": "As needed", - "description": "No fixed plan — log an intake whenever you need one (e.g. pain relief)." + "description": "No fixed plan. Log an intake whenever you need one (e.g. pain relief)." } }, "summary": { @@ -1685,7 +1722,7 @@ } }, "errors": { - "submitFailed": "Could not save the medication — please retry." + "submitFailed": "Could not save the medication. Please retry." } }, "doseStrength": { @@ -1701,11 +1738,11 @@ "empty": "No dose-escalation steps recorded yet. Add dated dose changes to plot the plan.", "youAreHere": "You are here", "doseStep": "{dose} {unit}", - "plannedFor": "Planned · {date}" + "plannedFor": "Planned for {date}" }, "injectionSiteBodyOutlineAriaLabel": "Body outline", "injectionSitePickerAriaLabel": "Injection site picker", - "injectionSiteRecommendedAriaLabel": "{site} — recommended next site", + "injectionSiteRecommendedAriaLabel": "{site}: recommended next site", "injectionSiteLegendAriaLabel": "Marker legend", "injectionSiteLegendRecommended": "Recommended next site", "injectionSiteLegendLastUsed": "Last used site", @@ -1752,7 +1789,7 @@ "intakeHistoryStatusSkipped": "Skipped", "intakeHistorySortByTaken": "Sort by taken time", "intakeHistorySortByScheduled": "Sort by scheduled time", - "intakeHistoryPageInfo": "Page {page} of {total} · {count} entries", + "intakeHistoryPageInfo": "Page {page} of {total}, {count} entries", "intakeHistoryPrev": "Previous", "intakeHistoryNext": "Next", "glp1": { @@ -1793,7 +1830,7 @@ "cadence": { "oneShotOn": "One-time dose on {date}.", "oneShotPending": "One-time dose pending.", - "asNeeded": "As needed — no fixed plan." + "asNeeded": "As needed, no fixed plan." }, "intake": { "title": "Intake history", @@ -1820,7 +1857,7 @@ "dialogTitle": "Edit intake", "takenAtLabel": "Taken at", "scheduledForHint": "Scheduled: {dateTime}", - "farFromScheduledWarning": "This is far from the scheduled time — check the date.", + "farFromScheduledWarning": "This is far from the scheduled time. Check the date.", "skippedLabel": "Skipped", "save": "Save", "cancel": "Cancel", @@ -1852,7 +1889,7 @@ "markAsTaken": "Mark as taken", "markAsSkipped": "Mark as skipped", "adHocTag": "off-schedule", - "takenDetail": "planned {planned} · taken {taken}", + "takenDetail": "planned {planned}, taken {taken}", "doseTaken": "Dose: {dose}", "status": { "taken_on_time": "on time", @@ -1864,10 +1901,10 @@ }, "addDialog": { "title": "Add intake", - "description": "Record a dose — including a backdated or off-schedule one.", + "description": "Record a dose, including a backdated or off-schedule one.", "takenAtLabel": "Taken at", "skippedLabel": "Skipped", - "nudgeHint": "Close to the {slot} slot — on save we ask whether to attribute it.", + "nudgeHint": "Close to the {slot} slot. On save we ask whether to attribute it.", "cancel": "Cancel", "submit": "Save" }, @@ -1916,7 +1953,7 @@ }, "codes": { "label": "Clinical codes (ATC / RxNorm)", - "note": "Optional. Used on the FHIR health-record export. Codes are taken exactly as entered — never guessed.", + "note": "Optional. Used on the FHIR health-record export. Codes are taken exactly as entered, never guessed.", "atcLabel": "ATC", "rxNormLabel": "RxNorm", "saved": "Clinical codes saved", @@ -1936,7 +1973,7 @@ "title": "End medication", "helper": "Stop reminders and mark the medication as ended. History stays visible.", "button": "End", - "dialogTitle": "End {name} — stop reminders, history stays visible", + "dialogTitle": "End {name}? Reminders stop, history stays visible", "dialogBody": "No more reminders. Old entries remain in the timeline.", "toast": "Medication ended", "failed": "Could not end the medication." @@ -1974,7 +2011,7 @@ "copyFailed": "Could not copy.", "tokenCopied": "Token copied", "copyToken": "Copy token", - "mintedHint": "Copy this token now — it is shown only once." + "mintedHint": "Copy this token now. It is shown only once." }, "advanced": { "dataPortability": { @@ -2003,7 +2040,6 @@ "bestand": "Supply", "verlauf": "History", "injektion": "Injection", - "api": "API", "erweitert": "Advanced", "wirkung": "Effect" }, @@ -2015,9 +2051,9 @@ "statusTitle": "Status", "nextDoseLabel": "Next dose", "nextDoseNone": "No upcoming dose.", - "nextDoseAsNeeded": "As needed — no planned dose.", + "nextDoseAsNeeded": "As needed, no planned dose.", "nextDoseNotTracked": "Not tracked, kept as a record.", - "pausedHint": "Paused — no reminders.", + "pausedHint": "Paused: no reminders.", "reminderLabel": "Reminder", "reminderOn": "On", "reminderOff": "Off", @@ -2088,7 +2124,7 @@ "packagingDefaultPack": "Default pack size: {units} units", "packagingNoDefaultPack": "No default pack size set", "packagingEdit": "Edit", - "packagingDescription": "Set how many units one dose consumes and how large a new pack usually is — e.g. after a manufacturer switch with a different blister size.", + "packagingDescription": "Set how many units one dose consumes and how large a new pack usually is, e.g. after a manufacturer switch with a different blister size.", "packagingUnitsPerDoseLabel": "Units per dose", "packagingUnitsPerDoseHelper": "E.g. two 2 mg tablets for a 4 mg dose, or 1.5 for one and a half (above 0, up to 100). Doses already recorded stay unchanged.", "packagingDefaultPackLabel": "Default pack size (units)", @@ -2139,11 +2175,11 @@ "hideEras": "Hide earlier eras", "addEra": "Add earlier era", "addEraTitle": "Add earlier era", - "addEraDescription": "Record the times this medication used to be taken at — for example before you adjusted it here.", + "addEraDescription": "Record the times this medication used to be taken at, for example before you adjusted it here.", "editAria": "Edit era", "editEraTitle": "Edit era", "editEraDescription": "Adjust the dose times and the date range of this era.", - "archivedEditHint": "This era was recorded automatically — your change corrects the recorded history; the original is kept internally as a record.", + "archivedEditHint": "This era was recorded automatically. Your change corrects the recorded history; the original is kept internally as a record.", "updatedToast": "Era updated", "updateError": "Could not update the era. It must not overlap other eras and must end before the current plan.", "timesLabel": "Dose times", @@ -2169,7 +2205,7 @@ "saved": "Schedule saved", "failed": "Could not save the schedule", "window": { - "intro": "For each dose, set when an intake still counts as on time — a precise time (±1h) or your own window such as 07:00–09:00.", + "intro": "For each dose, set when an intake still counts as on time: a precise time (±1h) or your own window such as 07:00–09:00.", "explicitToggle": "Custom window", "startLabel": "Window start", "endLabel": "Window end", @@ -2182,10 +2218,10 @@ } }, "logInjectionSiteTitle": "Log injection site", - "logInjectionSiteDescription": "Where did you inject {name}? Optional — you can skip this.", + "logInjectionSiteDescription": "Where did you inject {name}? Optional: you can skip this.", "logInjectionSiteSkip": "Skip", "logInjectionSiteConfirm": "Save site", - "logInjectionSiteNoneAvailable": "No injection site is available — every site is excluded in your settings.", + "logInjectionSiteNoneAvailable": "No injection site is available. Every site is excluded in your settings.", "logInjectionSiteSaveFailed": "Could not save the injection site. Please try again.", "perSlotUnits": "{units} per dose", "trackInjectionSitesToggle": "Track injection sites", @@ -2210,16 +2246,15 @@ "tableStockDoses": "{count} doses", "tableStockRunway": "≈ {days} days left", "cardLowStockRunway": "Supply: ≈ {days} days", - "takeActionFor": "Take – {name}", - "skipActionFor": "Skip – {name}", - "reorderTitle": "Adjust order", - "reorderDescription": "Set the order of your medications – it applies to both the card and the table view.", + "takeActionFor": "Take {name}", + "skipActionFor": "Skip {name}", + "reorderDescription": "Set the order of your medications for both the card and the table view.", "reorderSaved": "Order saved", "reorderSaveFailed": "Order could not be saved", "customize": "Customize medications", "efficacy": { "title": "Effect", - "intro": "How the measurements this medication is meant to influence have moved around the time you started it. It shows what lines up in time — an association, not a cause — and never advice about your treatment or dose.", + "intro": "How the measurements this medication is meant to influence have moved around the time you started it. It shows what lines up in time (an association, not a cause) and never advice about your treatment or dose.", "notEligible": "There is no outcome to relate this medication to.", "startFallback": "No start date is set, so the comparison uses your first recorded reading as the reference point.", "noStart": "Set a start date for this medication to compare the readings before and after it began.", @@ -2232,7 +2267,7 @@ "levelShift": "A shift in the level was detected around {date}.", "plateau": { "title": "Weight plateau on the current dose", - "evidence": "Weight moved by {delta} {unit} over the last {window} days ({readings} readings) — {days} days on {dose}." + "evidence": "Weight moved by {delta} {unit} over the last {window} days ({readings} readings), {days} days on {dose}." }, "retarget": { "label": "Not the right measurement?", @@ -2242,16 +2277,16 @@ "error": "Could not save that change. Try again." }, "beforeAfter": { - "summary": "Ø {before} before → Ø {after} since — a change of {delta}.", + "summary": "Ø {before} before → Ø {after} since, a change of {delta}.", "counts": "Based on {beforeCount} readings before and {afterCount} since.", - "needBefore": "Not enough readings before the start yet — about {weeks} weeks are needed before and after to compare.", - "needAfter": "Not enough readings since the start yet — about {weeks} weeks are needed before and after to compare.", + "needBefore": "Not enough readings before the start yet. About {weeks} weeks are needed before and after to compare.", + "needAfter": "Not enough readings since the start yet. About {weeks} weeks are needed before and after to compare.", "needStart": "Set a start date to compare the readings before and after it began.", - "needData": "Not enough readings yet — about {weeks} weeks are needed before and after the start to compare." + "needData": "Not enough readings yet. About {weeks} weeks are needed before and after the start to compare." }, "insights": { "title": "Medication effect", - "subtitle": "What lines up with the measurements your medications target — association only, never a verdict.", + "subtitle": "What lines up with the measurements your medications target: association only, never a verdict.", "link": "Open the effect view", "empty": "Not enough data yet to relate your medications to their target measurements." }, @@ -2285,7 +2320,7 @@ "reasonMissingMedication": "the row names no medication", "reasonUnreadableDosage": "the dosage is not a number", "reasonUnreadableRow": "the row has fewer columns than the header", - "reasonStatusNoDoseInformation": "the file records nothing about that dose — nobody answered and nothing was logged", + "reasonStatusNoDoseInformation": "the file records nothing about that dose; nobody answered and nothing was logged", "reasonStatusReminderEvent": "the row is about a snoozed reminder, which says nothing about the dose", "reasonStatusNotificationNotSent": "the row records that no notification was sent, which is about the app rather than the dose" }, @@ -2357,7 +2392,7 @@ }, "charts": { "loadingLabel": "Loading chart…", - "loadingSlowHint": "Computing analytics — this can take a moment.", + "loadingSlowHint": "Computing analytics. This can take a moment.", "weight": "Weight", "bloodPressure": "Blood Pressure", "pulse": "Pulse", @@ -2379,6 +2414,7 @@ "days7Label": "7d", "days7Title": "Last 7 days", "days30Label": "30d", + "daysNLabel": "{days}d", "days30Title": "Last 30 days", "days90Label": "90d", "days90Title": "Last 90 days", @@ -2444,8 +2480,8 @@ "columnDate": "Date", "noValue": "No value", "captionDay": "One row per day of {metric}, the same points the chart draws.", - "captionWeek": "One row per week of {metric}, the average of that week's days — the same points the chart draws.", - "captionMonth": "One row per month of {metric}, the average of that month's days — the same points the chart draws.", + "captionWeek": "One row per week of {metric}, the average of that week's days, the same points the chart draws.", + "captionMonth": "One row per month of {metric}, the average of that month's days, the same points the chart draws.", "hourlyMeanNote": "{metric} is each day's average, with every hour counting once, so a workout does not outweigh the rest of the day." }, "personalBaseline": "Your normal", @@ -2474,7 +2510,8 @@ "moodHeatmapEmpty": "Mood heatmap, no days logged yet.", "complianceHeatmap": "Medication compliance heatmap from {start} to {end}, {days} active days.", "complianceHeatmapEmpty": "Medication compliance heatmap, no active days yet." - } + }, + "medicationsEmptyAction": "Add a medication" }, "chart": { "overlay": { @@ -2505,8 +2542,8 @@ }, "tooltipPrior": "Prior period", "unavailable": { - "lastMonth": "Comparison unavailable — no data from last month yet", - "lastYear": "Comparison unavailable — no data from last year yet" + "lastMonth": "Comparison unavailable: no data from last month yet", + "lastYear": "Comparison unavailable: no data from last year yet" }, "notAvailableForCompliance": "Comparison N/A for adherence" }, @@ -2518,7 +2555,9 @@ "dismissError": "Could not dismiss this pattern.", "restoreError": "Could not restore this pattern.", "loadError": "Patterns could not be loaded.", - "empty": "No current pattern cleared the evidence threshold." + "empty": "No current pattern cleared the evidence threshold.", + "seasonalNote": "Since version 1.42, HealthLog removes season and long-term trend before it compares values. You see fewer relationships, but the ones that remain hold up better. Many of the earlier patterns were just the calendar.", + "seasonalNoteDismiss": "Dismiss note" }, "correlation": { "direction": { @@ -2550,29 +2589,37 @@ "ENV_DAYLIGHT": "daylight", "ENV_PRECIP": "precipitation", "ENV_PRESSURE_MEAN": "barometric pressure", - "ENV_PRESSURE_DELTA": "intraday pressure swing" + "ENV_PRESSURE_DELTA": "intraday pressure swing", + "ENV_PM25": "fine particles (PM2.5)", + "ENV_OZONE_8H": "ozone", + "ENV_POLLEN_MAX": "pollen" }, "daily": { - "high": "Higher {behaviour} tends to go with {direction} next-day {outcome} in your data — a pattern worth watching, not a cause.", - "moderate": "Higher {behaviour} looks like it goes with {direction} next-day {outcome} in your data, on the evidence so far — a pattern worth watching, not a cause.", - "faint": "Higher {behaviour} shows a faint hint, if anything, of {direction} next-day {outcome} in your data — too small to lean on, never a cause." + "high": "Higher {behaviour} tends to go with {direction} next-day {outcome} in your data. It is a pattern to watch, not a cause.", + "moderate": "Higher {behaviour} looks like it goes with {direction} next-day {outcome} in your data, on the evidence so far. It is a pattern to watch, not a cause.", + "faint": "Higher {behaviour} shows at most a faint hint of {direction} next-day {outcome} in your data. It is too small to lean on and never a cause." }, "lab": { - "default": "Higher {lab} readings line up with {direction} {outcome} over the same periods in your data — an association worth watching with your clinician, never a cause.", - "faint": "Higher {lab} readings show a faint hint, if anything, of {direction} {outcome} over the same periods in your data — too small to lean on, an association to raise with your clinician, never a cause." + "default": "Higher {lab} readings line up with {direction} {outcome} over the same periods in your data, an association worth watching with your clinician, never a cause.", + "faint": "Higher {lab} readings show at most a faint hint of {direction} {outcome} over the same periods in your data. It is too small to lean on and never a cause, but worth raising with your clinician." }, "hypothesis": { "bpCompliance": { - "support": "Higher medication compliance lines up with lower systolic readings — a pattern worth watching.", - "surprising": "Compliance and systolic move together in your data — surprising; talk to your doctor before adjusting anything.", + "support": "Higher medication compliance lines up with lower systolic readings, a pattern worth watching.", + "surprising": "Compliance and systolic move together in your data, which is surprising. Talk to your doctor before adjusting anything.", "none": "Compliance and systolic do not move together strongly in this window." }, "moodPulse": { - "support": "Lower-mood days line up with higher resting pulse — a pattern worth watching.", - "surprising": "Higher-mood days line up with higher resting pulse in your data — surprising.", + "support": "Lower-mood days line up with higher resting pulse, a pattern worth watching.", + "surprising": "Higher-mood days line up with higher resting pulse in your data, which is surprising.", "none": "Mood and resting pulse do not move together strongly in this window." }, - "weightWeekday": "{weekday} weights run {delta} kg {direction} your other-day average — a pattern worth watching." + "weightWeekday": "{weekday} weights run {delta} kg {direction} your other-day average, a pattern worth watching." + }, + "dailySameDay": { + "high": "Higher {behaviour} on the day and the day before tends to go with {direction} {outcome} in your data. It is a pattern to watch, not a cause.", + "moderate": "Higher {behaviour} on the day and the day before looks like it goes with {direction} {outcome} in your data, on the evidence so far. It is a pattern to watch, not a cause.", + "faint": "Higher {behaviour} on the day and the day before shows a faint hint, if anything, of {direction} {outcome} in your data. It is too small to lean on and never a cause." } }, "noAnalysisYet": "No assessment yet.", @@ -2684,7 +2731,7 @@ "referenceSleep": "At least {low} minutes per night; longer sleep is not penalised", "referenceAdiposity": "Waist-to-height ratio below {high}", "referenceHba1c": "HbA1c {low} to {high}%", - "referenceDetail": "{label} · {source} · {kind} · {low} to {high}", + "referenceDetail": "{label}: {low} to {high} ({kind}, {source})", "openBound": "open", "laboratoryReferenceSource": "Reporting laboratory reference ranges", "personalTargetSource": "Personal target", @@ -2719,7 +2766,8 @@ "coverage": { "recommendedMet": "Covers the three areas the score recommends", "areas": "{present} of {required} areas of health" - } + }, + "historyLink": "History" }, "dailyBriefing": { "title": "Daily Briefing", @@ -2729,7 +2777,7 @@ "emptyDescription": "Generate today's briefing to get a narrative read of your trends.", "emptyAction": "Generate briefing", "loadingLabel": "Synthesising today's briefing", - "refreshFailedHint": "Couldn't refresh the briefing — showing the last one.", + "refreshFailedHint": "Couldn't refresh the briefing. Showing the last one.", "retryAction": "Retry", "failedTitle": "Couldn't generate the briefing", "failedDescription": "The AI provider timed out or returned an error. Try again, or raise the response timeout in AI settings for a slow local model.", @@ -2778,7 +2826,7 @@ "experimentCta": "Try a 7-day experiment", "experimentTooltip": "Coming soon", "emptyTitle": "Need more data to see this relationship", - "sourceChip": "based on {n} paired readings · {window}", + "sourceChip": "based on {n} paired readings ({window})", "sourceWindowLast30": "last 30 days", "confidenceHigh": "High confidence", "confidenceModerate": "Moderate confidence", @@ -2793,14 +2841,14 @@ } }, "coach": { - "refusalOutOfScope": "I can only help with the health metrics in your log — measurements, medications, mood, trends. I cannot answer questions outside that scope.", - "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.", + "refusalOutOfScope": "I can only help with the health metrics in your log: measurements, medications, mood, trends. I cannot answer questions outside that scope.", + "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 UTC.", "errorProvider": "The Coach could not reach an Insights provider just now. Please try again in a moment.", "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.", + "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", - "errorNetwork": "No internet connection — try again once you're back online.", + "errorNetwork": "No internet connection. Try again once you're back online.", "prefsSaveError": "Couldn't save your Coach preferences. Please try again.", "errorCredentialExpired": "Your AI provider connection has expired. Reconnect it in Settings to keep the Coach running.", "tagline": "Personal health coach, grounded in your data.", @@ -2819,7 +2867,7 @@ "thinking": "Thinking…", "unverifiedFiguresNotice": "Some figures couldn't be checked against your data and were left out.", "composerPlaceholder": "Ask me anything…", - "nudgeBubbleLabel": "New message from the Coach — open chat", + "nudgeBubbleLabel": "New message from the Coach. Open chat", "fabLabel": "Open the Coach", "readAloud": "Read aloud", "readAloudStop": "Stop reading", @@ -2843,7 +2891,7 @@ "guided": { "offerTitle": "The Coach has {count} follow-up questions for you", "offerTitleOne": "The Coach has a follow-up question for you", - "offerBody": "Short answers sharpen your self-context — one question at a time, right here in the chat.", + "offerBody": "Short answers sharpen your self-context, one question at a time, right here in the chat.", "offerStart": "Answer now", "offerLater": "Later", "offerDismiss": "Don't ask again", @@ -2853,7 +2901,7 @@ "later": "Later", "dismissRemaining": "Don't ask again", "answerPlaceholder": "Type your answer…", - "summaryTitle": "All set — thanks for your answers!", + "summaryTitle": "All set, thanks for your answers!", "summaryAnswered": "{answered} of {total} answered", "summaryAdopted": "{adopted} added to your self-context", "summaryAdoptedNone": "nothing new added to your self-context", @@ -2865,6 +2913,7 @@ "historyEmpty": "Your conversations will appear here. Ask anything to start.", "historyDeleteAria": "Delete conversation", "historyDeleted": "Conversation deleted", + "historyDeleteFailed": "The conversation could not be deleted.", "rename": { "action": "Rename conversation", "input": "Conversation title", @@ -2934,7 +2983,7 @@ "settingsVerbosityBrief": "Brief", "settingsVerbosityDefault": "Default", "settingsVerbosityDetailed": "Detailed", - "settingsVerbosityConciseHint": "The concise tone already keeps replies short — verbosity is pinned to Brief while it is active.", + "settingsVerbosityConciseHint": "The concise tone already keeps replies short, so verbosity is pinned to Brief while it is active.", "settingsSourcesSummary": "The Coach sees {count} of {total} data areas and looks back {window}.", "settingsSourcesSummaryAll": "The Coach sees {count} of {total} data areas and your whole history.", "settingsSourcesPointerLink": "Open Coach settings", @@ -2956,23 +3005,23 @@ "cluster": { "cardio": { "label": "Cardiovascular", - "hint": "BP · pulse · HRV · resting & walking HR" + "hint": "BP, pulse, HRV, resting and walking HR" }, "body": { "label": "Body composition", - "hint": "Weight · body fat · muscle · BMI" + "hint": "Weight, body fat, muscle and BMI" }, "activity": { "label": "Activity", - "hint": "Steps · energy · distance · VO₂ max" + "hint": "Steps, energy, distance and VO₂ max" }, "workouts": { "label": "Workouts", - "hint": "Runs · rides · sessions" + "hint": "Runs, rides and sessions" }, "sleep": { "label": "Sleep", - "hint": "Duration · stages" + "hint": "Duration and stages" }, "mood": { "label": "Mood", @@ -2980,19 +3029,19 @@ }, "glucose": { "label": "Glucose", - "hint": "Fasting · postprandial readings" + "hint": "Fasting and postprandial readings" }, "medication": { "label": "Medication", - "hint": "Adherence · GLP-1 context" + "hint": "Adherence and GLP-1 context" }, "mobility": { "label": "Mobility & gait", - "hint": "Steadiness · asymmetry · gait" + "hint": "Steadiness, asymmetry and gait" }, "environment": { "label": "Environment", - "hint": "Noise · daylight · skin temp" + "hint": "Noise, daylight and skin temperature" } }, "settingsClustersLabel": "Data the Coach sees", @@ -3041,25 +3090,11 @@ "steadyLatestWithin": "Your readings usually sit at {low} {unit}; your latest reading, {date}, was {value} and matches.", "steadyLatestAbove": "Your readings usually sit at {low} {unit}; your latest reading, {date}, was {value}, above that.", "steadyLatestBelow": "Your readings usually sit at {low} {unit}; your latest reading, {date}, was {value}, below that.", - "insufficient": "Still learning your usual range — keep logging for a few more days.", + "insufficient": "Still learning your usual range. Keep logging for a few more days.", "driver": "{note}" }, "scope": { - "prefix": "The Coach is already on {metric}", - "tap": "Tap to ask", - "question": "Walk me through this metric — anything I should watch?" - }, - "seeded": { - "prefix": "Worth a look today: {signal}", - "signal": { - "readiness": "readiness", - "recovery": "recovery" - }, - "question": { - "readiness": "My readiness is off today — what's driving it?", - "recovery": "My recovery shifted — what's behind it?" - }, - "dismiss": "Dismiss" + "prefix": "The Coach is already on {metric}" }, "settings": "Settings", "history": { @@ -3069,39 +3104,39 @@ "groupEarlier": "Earlier" }, "seed": { - "bloodPressure": "Walk me through my blood pressure trend over the last 30 days — anything I should keep an eye on?", + "bloodPressure": "Walk me through my blood pressure trend over the last 30 days. Anything I should keep an eye on?", "weight": "How has my weight been trending lately, and is the direction something to act on?", "bmi": "What does my BMI trend tell me, and how should I read it?", - "pulse": "Walk me through my pulse readings — is anything out of the ordinary?", + "pulse": "Walk me through my pulse readings. Is anything out of the ordinary?", "restingHr": "How has my resting heart rate been trending, and what does it say about my fitness?", "hrv": "What is my heart-rate variability telling me about recovery and stress lately?", - "sleep": "Walk me through my recent sleep — duration, consistency, and anything worth changing.", + "sleep": "Walk me through my recent sleep: duration, consistency, and anything worth changing.", "mood": "What patterns do you see in my mood over the last few weeks?", "medications": "How is my medication adherence looking, and is it lined up with how I've been feeling?", "steps": "How active have I been lately based on my steps, and how does it compare to a healthy baseline?", "activeEnergy": "What does my active-energy trend say about my activity level lately?", "cardioFitness": "What does my cardio fitness (VO₂ max) trend mean, and how do I move it in the right direction?", - "bloodGlucose": "Walk me through my recent glucose readings — are they in a healthy range?", + "bloodGlucose": "Walk me through my recent glucose readings. Are they in a healthy range?", "oxygenSaturation": "What does my blood-oxygen (SpO₂) trend tell me?", "respiratoryRate": "What does my respiratory-rate trend say about my health?", - "workouts": "Walk me through my recent workouts — load, frequency, and how I'm recovering between them.", + "workouts": "Walk me through my recent workouts: load, frequency, and how I'm recovering between them.", "recoveryPage": "Why is my recovery where it is right now, and what's driving it?", - "briefing": "Walk me through today's briefing — what should I focus on, and is anything worth acting on?", + "briefing": "Walk me through today's briefing. What should I focus on, and is anything worth acting on?", "healthScore": "Why is my health score {score} out of 100, and what would move it the most?", - "periodWeek": "Walk me through how my last week went across my health metrics — what stands out?", - "periodMonth": "Walk me through how my last month went across my health metrics — what stands out?", + "periodWeek": "Walk me through how my last week went across my health metrics. What stands out?", + "periodMonth": "Walk me through how my last month went across my health metrics. What stands out?", "correlationBpCompliance": "How does my medication adherence relate to my blood pressure? Walk me through what this correlation means for me.", "correlationMoodPulse": "Is there a real link between my mood and my pulse? Help me read this correlation.", "correlationWeightWeekday": "Does my weight follow a weekly pattern? What does this day-of-week correlation tell me?", - "recommendation": "Tell me more about this recommendation: \"{text}\" — why does it apply to me, and what should I do about it?" + "recommendation": "Tell me more about this recommendation: \"{text}\" Why does it apply to me, and what should I do about it?" }, - "assessmentPrompt": "Walk me through my {metric} assessment — what does it mean and what should I do about it?", + "assessmentPrompt": "Walk me through my {metric} assessment. What does it mean and what should I do about it?", "attach": { "menuChooseFromDocuments": "Choose from documents", "menuUploadNew": "Upload new document", "trigger": "Attach documents", "pickerTitle": "Attach documents", - "pickerDescription": "The coach reads only the documents you attach — not your health data.", + "pickerDescription": "The coach reads only the documents you attach, not your health data.", "pickerSearchPlaceholder": "Search documents…", "pickerEmpty": "No documents in your vault yet.", "pickerNotIndexed": "Not yet indexed", @@ -3109,13 +3144,13 @@ "pickerConfirm": "Attach ({count})", "pillRemove": "Remove {title}", "indexing": "Indexing…", - "indexingHint": "A document is still being indexed — you can send once it's ready.", - "fencedHint": "This conversation runs without access to your health data — the coach only reads the attached documents.", + "indexingHint": "A document is still being indexed. You can send once it's ready.", + "fencedHint": "This conversation runs without access to your health data. The coach only reads the attached documents.", "documentsAttachedOne": "1 document attached", "documentsAttachedOther": "{count} documents attached", "railBadge": "Fenced document chat", "uploadError": "Upload failed. Please try again.", - "notFoundError": "That document link is no longer valid — starting a normal chat instead." + "notFoundError": "That document link is no longer valid. Starting a normal chat instead." }, "interrupted": { "note": "This answer was interrupted before it finished.", @@ -3180,9 +3215,9 @@ "cap": "Many steps, wrapping up", "noProgress": "No new data, wrapping up" }, - "thoughtProcessOne": "Thought process · {count} step", - "thoughtProcessFew": "Thought process · {count} steps", - "thoughtProcessOther": "Thought process · {count} steps", + "thoughtProcessOne": "Thought process ({count} step)", + "thoughtProcessFew": "Thought process ({count} steps)", + "thoughtProcessOther": "Thought process ({count} steps)", "awaitingAnswer": "Answer needed" }, "memory": { @@ -3222,7 +3257,8 @@ "capped": "(limited by the admin)", "disabled": "The admin has turned deeper thinking off." }, - "memoryLink": "Memory ({count})" + "memoryLink": "Memory ({count})", + "newConversation": "New conversation" }, "relativeJustNow": "just now", "relativeMinutesAgoOne": "{count} minute ago", @@ -3236,7 +3272,7 @@ "relativeDaysAgoFew": "{count} days ago", "navOverview": "Overview", "navCatalog": "All metrics", - "navBloodPressure": "Blood Pressure", + "navBloodPressure": "Blood pressure", "navWeight": "Weight", "navPulse": "Pulse", "navMood": "Mood", @@ -3285,7 +3321,7 @@ }, "hrv": { "title": "HRV", - "description": "Heart-rate variability over time — your nervous-system recovery signal, captured during sleep by Apple Watch and other compatible wearables.", + "description": "Heart-rate variability over time: your nervous-system recovery signal, captured during sleep by Apple Watch and other compatible wearables.", "chartTitle": "Heart-rate variability", "emptyState": { "title": "No HRV samples yet", @@ -3294,17 +3330,17 @@ }, "restingHr": { "title": "Resting heart rate", - "description": "The daily floor your heart settles at — distinct from the spot pulse samples on the pulse page. Apple Health surfaces one value per day.", + "description": "The daily floor your heart settles at, distinct from the spot pulse samples on the pulse page. Apple Health surfaces one value per day.", "chartTitle": "Resting heart rate", "emptyState": { "title": "No resting heart rate yet", "description": "Resting heart rate arrives once Apple Watch or a compatible Withings device has worn through one quiet day." }, - "coachPrefill": "I haven't logged any resting heart rate yet — why is the daily resting value different from a spot pulse, and how do I improve it?" + "coachPrefill": "I haven't logged any resting heart rate yet. Why is the daily resting value different from a spot pulse, and how do I improve it?" }, "oxygenSaturation": { "title": "Oxygen saturation", - "description": "Blood oxygen (SpO₂) — clinical normal is 95–100 %, with target zones shaded on the chart.", + "description": "Blood oxygen (SpO₂): clinical normal is 95–100 %, with target zones shaded on the chart.", "chartTitle": "Oxygen saturation", "emptyState": { "title": "No SpO₂ samples yet", @@ -3313,7 +3349,7 @@ }, "bodyTemperature": { "title": "Body temperature", - "description": "Core body temperature trend — Celsius. Skin-temperature readings from Withings live on a separate metric.", + "description": "Core body temperature trend in Celsius. Skin-temperature readings from Withings live on a separate metric.", "chartTitle": "Body temperature", "emptyState": { "title": "No body temperature yet", @@ -3323,7 +3359,7 @@ }, "steps": { "title": "Steps", - "description": "Daily step count — one row per day, sourced from Apple Health or a connected activity device.", + "description": "Daily step count, one row per day, sourced from Apple Health or a connected activity device.", "chartTitle": "Steps per day", "emptyState": { "title": "No step data yet", @@ -3332,7 +3368,7 @@ }, "activeEnergy": { "title": "Active energy", - "description": "Daily active-energy total in kilocalories — one row per day, sourced from Apple Health or Withings activity.", + "description": "Daily active-energy total in kilocalories, one row per day, sourced from Apple Health or Withings activity.", "chartTitle": "Active energy", "emptyState": { "title": "No active-energy data yet", @@ -3352,27 +3388,27 @@ }, "explainer": { "bloodPressureBody": "Blood pressure is the force your blood exerts on the artery walls, written as systolic over diastolic (the peak during a heartbeat over the resting value between beats). Consistently high readings strain the heart and blood vessels over time.", - "pulseBody": "Your pulse is how many times your heart beats per minute. A spot reading reflects what you were doing in the moment — at rest it is typically 60–100 beats per minute, and it rises naturally with movement, stress, or caffeine.", + "pulseBody": "Your pulse is how many times your heart beats per minute. A spot reading reflects what you were doing in the moment. At rest it is typically 60–100 beats per minute, and it rises naturally with movement, stress, or caffeine.", "weightBody": "Body weight is one of the simplest signals of long-term energy balance and overall health. The trend over weeks matters far more than any single reading, which fluctuates with hydration, food, and time of day.", "bmiBody": "BMI is your weight divided by your height squared, giving a single number that places you in a broad weight category. It is a population-level screen, not a diagnosis, and does not distinguish muscle from fat.", "sleepBody": "Sleep duration is how long you actually slept across the night, split into light, deep, and REM stages. Most adults need roughly seven to nine hours; both consistent quantity and quality support recovery, mood, and metabolism.", - "moodBody": "Mood tracking records how you feel over time on a simple scale. Logging it regularly reveals patterns — how sleep, activity, or medication line up with how you feel — that a single day never shows.", + "moodBody": "Mood tracking records how you feel over time on a simple scale. Logging it regularly reveals patterns (how sleep, activity, or medication line up with how you feel) that a single day never shows.", "medicationsBody": "Adherence is how reliably you take your medication as prescribed, shown here as the share of expected doses you actually logged. Steady intake is often what makes a treatment work as intended.", - "restingHrBody": "Resting heart rate is your heart rate when you are completely at rest, usually measured overnight — distinct from your pulse during activity. A lower resting rate generally points to better cardiovascular fitness, and a rising trend can be an early sign of stress or illness. As general guidance, a normal adult resting rate is 60–100 beats per minute (AHA 2024); a trained heart can sit benignly at 40–60.", + "restingHrBody": "Resting heart rate is your heart rate when you are completely at rest, usually measured overnight, distinct from your pulse during activity. A lower resting rate generally points to better cardiovascular fitness, and a rising trend can be an early sign of stress or illness. As general guidance, a normal adult resting rate is 60–100 beats per minute (AHA 2024); a trained heart can sit benignly at 40–60.", "hrvBody": "HRV is the tiny variation in time between consecutive heartbeats. Higher variability usually reflects a well-recovered, adaptable nervous system, while a sustained drop can signal fatigue, stress, or insufficient recovery.", "oxygenSaturationBody": "SpO₂ is the percentage of your red blood cells carrying oxygen. Readings of 95–100% are normal at rest; values that stay below 90% warrant attention, though brief nightly dips are common.", - "bodyTemperatureBody": "Body temperature reflects your core thermal state. As general guidance, the population mean is about 36.6 °C — not 37.0 °C — with a normal oral range of roughly 35.7–37.4 °C and a fever from 38.0 °C (J Gen Intern Med 2019). It swings about 0.5 °C across the day, and sites differ by up to ~1 °C, so tag where you measured. Overnight wrist readings track relative change, not a clinical fever value.", + "bodyTemperatureBody": "Body temperature reflects your core thermal state. As general guidance, the population mean is about 36.6 °C (not 37.0 °C), with a normal oral range of roughly 35.7–37.4 °C and a fever from 38.0 °C (J Gen Intern Med 2019). It swings about 0.5 °C across the day, and sites differ by up to ~1 °C, so tag where you measured. Overnight wrist readings track relative change, not a clinical fever value.", "activeEnergyBody": "Active energy is the calories you burn through movement, on top of the baseline your body uses at rest. It is a direct measure of how physically active your day was.", "workoutsBody": "Workouts are the structured exercise sessions your devices record, with type, duration, and energy. Tracking them shows how regular training accumulates over weeks and how it relates to your other health signals.", - "respiratoryRateBody": "Respiratory rate is the number of breaths you take per minute. As general guidance, at rest most adults breathe 12–20 times a minute (American Lung Association); it is meaningful only truly at rest or asleep. A sustained rise of about +3 above your own nightly baseline can be an early illness signal — clinical escalation cutoffs are hospital tools, not self-alarms.", + "respiratoryRateBody": "Respiratory rate is the number of breaths you take per minute. As general guidance, at rest most adults breathe 12–20 times a minute (American Lung Association); it is meaningful only truly at rest or asleep. A sustained rise of about +3 above your own nightly baseline can be an early illness signal; clinical escalation cutoffs are hospital tools, not self-alarms.", "bodyWaterBody": "Total body water is the share of your body weight made up of fluid, inside and around your cells. It shifts with hydration, food, and activity, so the trend over weeks tells you more than any single reading.", "boneMassBody": "Bone mass is the estimated weight of the mineral in your skeleton. Scale-based estimates are approximate, but a steady or rising trend supports long-term bone strength.", - "fatFreeMassBody": "Fat-free mass is everything in your body that is not fat — muscle, bone, organs, and water. Holding or building it while losing weight usually signals healthier body composition.", - "fatMassBody": "Fat mass is the total weight of fat in your body. Some fat is essential for hormones and energy storage, but too much — especially around the abdomen — raises long-term health risk (WHO 2024).", + "fatFreeMassBody": "Fat-free mass is everything in your body that is not fat: muscle, bone, organs, and water. Holding or building it while losing weight usually signals healthier body composition.", + "fatMassBody": "Fat mass is the total weight of fat in your body. Some fat is essential for hormones and energy storage, but too much, especially around the abdomen, raises long-term health risk (WHO 2024).", "bodyFatBody": "Body fat percentage is the share of your weight that is fat. Healthy ranges depend on sex and age, and home scales estimate it from bioimpedance, so the trend over several weeks says more than a single reading.", "muscleMassBody": "Muscle mass is the estimated weight of the muscle in your body. More muscle supports strength, metabolism, and mobility, and it tends to need active effort to preserve as you age.", "visceralFatBody": "Visceral fat is the fat stored deep in the abdomen around your organs. Unlike fat under the skin, a high level is closely linked to heart disease and type 2 diabetes, so lower is generally better.", - "leanBodyMassBody": "Lean body mass is your total weight minus body fat — muscle, bone, organs, and water combined. Tracking it alongside weight shows whether changes come from muscle or from fat.", + "leanBodyMassBody": "Lean body mass is your total weight minus body fat: muscle, bone, organs, and water combined. Tracking it alongside weight shows whether changes come from muscle or from fat.", "flightsClimbedBody": "Flights climbed counts the floors you go up each day, with one flight roughly ten feet of elevation. It is a simple marker of incidental activity and lower-body effort.", "walkingDistanceBody": "Walking and running distance is the ground you cover on foot each day. It complements step count by reflecting how far you moved, not just how often you stepped.", "walkingSteadinessBody": "Walking steadiness estimates how stable and balanced your gait is, classed from OK to low. A lower level points to a higher chance of a fall and is worth discussing with a clinician.", @@ -3381,36 +3417,36 @@ "doubleSupportTimeBody": "Double support time is the share of each walking cycle when both feet touch the ground at once. A higher percentage usually means a more cautious, less stable gait.", "stepLengthBody": "Step length is the average distance from one footfall to the next. A shorter step over time can reflect fatigue, caution, or a change in mobility.", "walkingSpeedBody": "Walking speed is how fast you move on a level walk. It is a well-studied marker of overall health and mobility, and a steady or rising pace is a good sign.", - "pulseWaveVelocityBody": "Pulse wave velocity measures how fast the pressure wave from each heartbeat travels along your arteries. A faster wave indicates stiffer arteries, which is linked to higher cardiovascular risk. As general guidance, European clinical reference flags carotid-femoral velocity above 10 m/s (ESC/ESH 2018) — but a consumer estimate is a proxy, not interchangeable with that clinical measurement.", + "pulseWaveVelocityBody": "Pulse wave velocity measures how fast the pressure wave from each heartbeat travels along your arteries. A faster wave indicates stiffer arteries, which is linked to higher cardiovascular risk. As general guidance, European clinical reference flags carotid-femoral velocity above 10 m/s (ESC/ESH 2018), but a consumer estimate is a proxy, not interchangeable with that clinical measurement.", "vascularAgeBody": "Vascular age estimates how old your arteries behave compared with your actual age, based on arterial stiffness. A vascular age below your real age suggests healthier blood vessels.", "environmentalAudioBody": "Environmental sound levels track the loudness of the world around you in decibels. Prolonged exposure above about 70 dB can damage hearing over time, so lower daily averages are safer.", "headphoneAudioBody": "Headphone audio levels track how loudly you listen through earbuds or headphones, in decibels. Keeping the volume down and taking breaks protects your hearing over the long run.", "audioEventsBody": "Loud sound events flag moments when noise around you reached a level that can harm hearing with enough exposure. Frequent events are a cue to seek quieter surroundings or hearing protection.", "daylightBody": "Time in daylight is how long you spend in outdoor light each day. Regular daylight supports a healthy sleep-wake rhythm and mood (CDC 2024), and time outdoors is linked to eye health.", - "bloodGlucoseBody": "Blood glucose is the amount of sugar in your blood, the body's main energy source. It rises after eating and settles between meals. As general guidance for adults without diabetes, a fasting reading under {prediabetesFrom} {unit} is normal, {prediabetesFrom}–{prediabetesTo} is prediabetes, and {diabetesFrom} or above falls in the diabetes range (ADA 2024). A single high reading does not diagnose — a clinician confirms with a repeat test, and people managing diabetes use different, clinician-set targets.", + "bloodGlucoseBody": "Blood glucose is the amount of sugar in your blood, the body's main energy source. It rises after eating and settles between meals. As general guidance for adults without diabetes, a fasting reading under {prediabetesFrom} {unit} is normal, {prediabetesFrom}–{prediabetesTo} is prediabetes, and {diabetesFrom} or above falls in the diabetes range (ADA 2024). A single high reading does not diagnose; a clinician confirms with a repeat test, and people managing diabetes use different, clinician-set targets.", "skinTemperatureBody": "Skin temperature here is the small nightly change in your wrist temperature from your own baseline, not an absolute fever reading. Shifts can track sleep, environment, illness, or menstrual-cycle phase.", - "cardioFitnessBody": "VO₂ max is how much oxygen your body can use at peak effort — the best lab index of aerobic fitness, which wearables estimate indirectly. A higher or rising trend is a strong sign of cardiovascular health.", + "cardioFitnessBody": "VO₂ max is how much oxygen your body can use at peak effort, the best lab index of aerobic fitness, which wearables estimate indirectly. A higher or rising trend is a strong sign of cardiovascular health.", "cardioRecoveryBody": "Cardio recovery is how many beats per minute your heart rate falls in the first minute after you stop hard exercise. A bigger drop means your heart settles down faster, which is a sign of good fitness.", "wristTemperatureBody": "Wrist temperature is measured while you sleep and reported as the difference from your personal baseline, not an absolute value. A steady reading is normal, while a sustained rise can hint at illness, intense training, or a menstrual-cycle change.", "fallsBody": "This counts the hard falls your wearable detects. Zero is the goal; a rising count is a mobility-risk signal worth raising with a clinician, especially for older adults (CDC STEADI 2024).", - "sixMinuteWalkBody": "This is an estimate of how far you could walk in six minutes — a long-standing clinical test of mobility and cardiopulmonary endurance. The wearable figure is a proxy, not the supervised clinical test; a steady or rising trend is the useful read.", - "stairAscentSpeedBody": "This is how fast you climb a flight of stairs. It is a sensitive marker of leg strength and mobility — a steady or rising pace is a good sign, and a falling one is worth watching.", - "stairDescentSpeedBody": "This is how fast you go down a flight of stairs. Together with ascent speed it tracks leg control and balance — a steady pace is a good sign, and a falling one is worth watching.", + "sixMinuteWalkBody": "This is an estimate of how far you could walk in six minutes, a long-standing clinical test of mobility and cardiopulmonary endurance. The wearable figure is a proxy, not the supervised clinical test; a steady or rising trend is the useful read.", + "stairAscentSpeedBody": "This is how fast you climb a flight of stairs. It is a sensitive marker of leg strength and mobility. A steady or rising pace is a good sign, and a falling one is worth watching.", + "stairDescentSpeedBody": "This is how fast you go down a flight of stairs. Together with ascent speed it tracks leg control and balance. A steady pace is a good sign, and a falling one is worth watching.", "breathingDisturbancesBody": "A wearable can estimate how often your breathing was interrupted during sleep. A lower index is better; a level flagged as elevated, especially across many nights, is worth raising with a clinician. It is a screening proxy, not a sleep-apnea diagnosis.", - "stepsBody": "Step count is the total number of steps you take each day — the simplest measure of everyday movement. A steady habit matters more than any single big day, and the round 10,000 target is a marketing slogan, not research.", - "readinessBody": "Readiness is a single daily read on how prepared your body is to take on the day, blending resting heart rate, heart-rate variability, and sleep. It is a device proxy benchmarked against your own baseline, not a clinical measure — a higher score means recovered, a lower one is a gentle cue to ease off.", - "sleepScoreBody": "Your sleep score distils a whole night into one number — duration, efficiency, regularity, and the balance of light, deep, and REM. Watch the pattern across weeks rather than chasing a perfect score on any single night.", + "stepsBody": "Step count is the total number of steps you take each day, the simplest measure of everyday movement. A steady habit matters more than any single big day, and the round 10,000 target is a marketing slogan, not research.", + "readinessBody": "Readiness is a single daily read on how prepared your body is to take on the day, blending resting heart rate, heart-rate variability, and sleep. It is a device proxy benchmarked against your own baseline, not a clinical measure. A higher score means recovered, a lower one is a gentle cue to ease off.", + "sleepScoreBody": "Your sleep score distils a whole night into one number: duration, efficiency, regularity, and the balance of light, deep, and REM. Watch the pattern across weeks rather than chasing a perfect score on any single night.", "recoveryBody": "Recovery reflects how well your body has bounced back, drawing on overnight signals like heart-rate variability and resting heart rate. It is a device-derived proxy read against your own baseline, not a clinical measure, and is not comparable across devices. A strong score is a green light to push; a softer one suggests your system is still rebuilding.", "strainBody": "Strain captures how much cardiovascular and physical load you have put on your body, from workouts to an active day on your feet. It is a device-derived index judged against your own typical effort, not a clinical measure, and the headline number is not comparable across devices.", - "stressBody": "Stress here is a device heuristic derived from heart-rate-variability patterns, not a cortisol measurement or how you consciously feel — and not a clinical stress score. Read it against your own baseline; it is not comparable across devices. A lower score points to a calm, well-regulated state; a higher one is an invitation to build in recovery.", - "recoveryPageBody": "Recovery brings together the overnight signals your wearable records — recharge, strain, load and your day's heart-rate and energy totals — to show how well your body has bounced back. Read the pattern across days rather than any single number; a softer reading is an invitation to ease off, not an alarm.", + "stressBody": "Stress here is a device heuristic derived from heart-rate-variability patterns, not a cortisol measurement or how you consciously feel, and not a clinical stress score. Read it against your own baseline; it is not comparable across devices. A lower score points to a calm, well-regulated state; a higher one is an invitation to build in recovery.", + "recoveryPageBody": "Recovery brings together the overnight signals your wearable records (recharge, strain, load and your day's heart-rate and energy totals) to show how well your body has bounced back. Read the pattern across days rather than any single number; a softer reading is an invitation to ease off, not an alarm.", "pulsePressureBody": "Pulse pressure is the gap between your systolic and diastolic readings (systolic minus diastolic). As general guidance, about 40 mmHg is typical at rest; a wide gap above roughly 60 mmHg in middle age can reflect stiffening arteries, while a narrow gap under about 25 mmHg can accompany low stroke volume (StatPearls/NIH 2023). It is a discussion prompt, not a diagnosis.", "meanArterialPressureBody": "Mean arterial pressure is the average pressure your organs see across a heartbeat, roughly the diastolic plus a third of the gap to systolic. As general guidance, a resting value sits around 70–100 mmHg (StatPearls/NIH 2023). The often-cited ~60 mmHg floor is an acute-care concept, not a consumer self-alarm.", - "cycleEducationBody": "Your cycle runs through four phases driven by shifting hormones, and its length and regularity are an accessible window on overall health. As general guidance, adult cycles typically run 21–35 days (clinical references cite 24–38), with bleeding around five days (ACOG 2015). The luteal phase is the steady anchor, so ovulation is best estimated by counting back from your next expected period — never a fixed 'day 14'. A predicted fertile window is an estimate that needs several recorded cycles and stays approximate, and basal body temperature confirms ovulation after the fact rather than predicting it. This is not a contraceptive method.", + "cycleEducationBody": "Your cycle runs through four phases driven by shifting hormones, and its length and regularity are an accessible window on overall health. As general guidance, adult cycles typically run 21–35 days (clinical references cite 24–38), with bleeding around five days (ACOG 2015). The luteal phase is the steady anchor, so ovulation is best estimated by counting back from your next expected period, never a fixed 'day 14'. A predicted fertile window is an estimate that needs several recorded cycles and stays approximate, and basal body temperature confirms ovulation after the fact rather than predicting it. This is not a contraceptive method.", "sleepHygieneBody": "A few evidence-based habits support sleep: keep consistent bed and wake times including weekends, get bright morning daylight and dim your evenings, keep the room cool, dark and quiet, and avoid afternoon caffeine, late alcohol and large late meals (CDC 2024). Regularity predicts health at least as strongly as duration, and a wearable's total sleep time is far more trustworthy than its deep, light and REM split." }, "bpDerived": { - "pulsePressure": "Pulse pressure (the gap between your readings) is about {value} mmHg — roughly 40 is typical at rest.", + "pulsePressure": "Pulse pressure (the gap between your readings) is about {value} mmHg; roughly 40 is typical at rest.", "meanArterialPressure": "Mean arterial pressure (the average your organs see) is about {value} mmHg, normally around 70–100.", "caveat": "These are derived from your latest reading as general guidance, not a diagnosis." }, @@ -3422,7 +3458,7 @@ "mean": "Mean" }, "showAllValues": "Show all readings", - "valuesPageTitle": "{metric} — all readings", + "valuesPageTitle": "{metric}: all readings", "valuesPageDescription": "Every reading behind this metric. Edit or remove an entry directly.", "diversity": { "trigger": "Measurement tip", @@ -3462,11 +3498,11 @@ "inBed": "In bed", "nap": "Nap", "napCount": "{count} naps", - "unavailable": "No stage data yet — sleep stages appear here as soon as a connected source delivers them." + "unavailable": "No stage data yet. Sleep stages appear here as soon as a connected source delivers them." }, "empty": { "title": "No sleep data yet", - "description": "Connect a source like Apple Health or a wearable — or log sleep manually — and this view fills in automatically.", + "description": "Connect a source like Apple Health or a wearable, or log sleep manually, and this view fills in automatically.", "action": "Manage devices" }, "debt": { @@ -3475,21 +3511,21 @@ "caption": "Outstanding balance over the last {nights} nights", "clearValue": "All caught up", "clearCaption": "Nothing outstanding over the last {nights} nights", - "learning": "Still learning your sleep — {counted} nights so far, {remaining} more to read a reliable balance.", + "learning": "Still learning your sleep: {counted} nights so far, {remaining} more to read a reliable balance.", "computedInfo": "This is HealthLog's own estimate: a rolling balance of need minus sleep over recent nights, with catch-up sleep paying it down. It can differ from a wearable's native sleep-debt figure, which uses its own need model." }, "average": { "title": "Average per night", "value": "{hours}h {minutes}m", "caption": "Mean sleep across the last {nights} nights", - "learning": "Still learning your sleep — {counted} nights so far, {remaining} more for a reliable average." + "learning": "Still learning your sleep: {counted} nights so far, {remaining} more for a reliable average." }, "chronotype": { "title": "Chronotype", - "learning": "Still learning your rhythm — {counted} of {total} free nights. A chronotype needs a few alarm-free mornings before it means anything.", + "learning": "Still learning your rhythm: {counted} of {total} free nights. A chronotype needs a few alarm-free mornings before it means anything.", "advancedToggle": "Advanced", "socialJetlagValue": "{hours}h {minutes}m social jetlag", - "socialJetlagCaption": "The gap between your work-day and free-day sleep timing — like a small weekly time-zone shift.", + "socialJetlagCaption": "The gap between your work-day and free-day sleep timing, like a small weekly time-zone shift.", "msfScValue": "Corrected mid-sleep {clock}", "msfScCaption": "Adjusted for the sleep you catch up on at the weekend (MSFsc).", "dayTypeNote": "Free days are assumed to be weekends until day-type tracking is added.", @@ -3678,7 +3714,10 @@ "endInFuture": "It would end in the future. Set the start to when the workout began.", "distanceInvalid": "Enter the distance as a number within a realistic range.", "energyInvalid": "Enter active energy as a number up to 20,000 kcal." - } + }, + "editTitle": "Edit workout", + "editAction": "Edit", + "updated": "Workout updated" } }, "bloodGlucose": { @@ -3693,11 +3732,11 @@ "clinical": { "title": "Glucose overview", "subtitle": "A read on your last {days} days, from {count} readings.", - "spotCaveat": "These are spot-reading estimates from individual measurements, not a continuous-monitor profile — read them as a direction of travel rather than a clinical verdict.", + "spotCaveat": "These are spot-reading estimates from individual measurements, not a continuous-monitor profile. Read them as a direction of travel rather than a clinical verdict.", "labsLink": "Lab results? Log a blood-draw HbA1c or fasting glucose under Labs.", "learningTitle": "Still learning your glucose", - "learningBody": "So far that's {count} over {days}. A little more history and a fuller picture comes into focus — keep logging, or let a connected device fill it in.", - "learningBodyEmpty": "Once a handful of readings come in — logged by you or synced from a device — your time-in-range, average, and variability take shape here.", + "learningBody": "So far that's {count} over {days}. A little more history and a fuller picture comes into focus. Keep logging, or let a connected device fill it in.", + "learningBodyEmpty": "Once a handful of readings come in (logged by you or synced from a device), your time-in-range, average, and variability take shape here.", "readingsCount": "{count} readings", "readingsCountOne": "1 reading", "daysCount": "{days} days", @@ -3735,7 +3774,7 @@ "intro": "Research-grade composites for a closer look. Each folds your readings into a single risk-weighted number.", "jIndex": { "label": "J-index", - "help": "A single score blending your average and its spread — lower reads as tighter control." + "help": "A single score blending your average and its spread; lower reads as tighter control." }, "lbgi": { "label": "Low-glucose risk (LBGI)", @@ -3768,7 +3807,7 @@ "description": "Body-water readings appear here once a connected body-composition scale syncs." } }, - "navTotalBodyWater": "Body Water", + "navTotalBodyWater": "Body water", "boneMass": { "title": "Bone Mass", "description": "Your bone mass over time, in kilograms.", @@ -3778,7 +3817,7 @@ "description": "Bone-mass readings appear here once a connected body-composition scale syncs." } }, - "navBoneMass": "Bone Mass", + "navBoneMass": "Bone mass", "flightsClimbed": { "title": "Flights Climbed", "description": "Flights of stairs you climbed each day.", @@ -3808,7 +3847,7 @@ "description": "Fat-free-mass readings appear here once a connected body-composition scale syncs." } }, - "navFatFreeMass": "Fat-Free Mass", + "navFatFreeMass": "Fat-free mass", "fatMass": { "title": "Fat Mass", "description": "Your fat mass over time, in kilograms.", @@ -3828,8 +3867,8 @@ "cta": "Log body fat" } }, - "navFatMass": "Fat Mass", - "navBodyFat": "Body Fat", + "navFatMass": "Fat mass", + "navBodyFat": "Body fat", "muscleMass": { "title": "Muscle Mass", "description": "Your muscle mass over time, in kilograms.", @@ -3839,7 +3878,7 @@ "description": "Muscle-mass readings appear here once a connected body-composition scale syncs." } }, - "navMuscleMass": "Muscle Mass", + "navMuscleMass": "Muscle mass", "skinTemperature": { "title": "Skin Temperature", "description": "Your wrist skin temperature over time, in degrees Celsius.", @@ -3849,17 +3888,17 @@ "description": "Skin-temperature readings appear here once a wearable syncs overnight wrist temperature." } }, - "navSkinTemperature": "Skin Temp", + "navSkinTemperature": "Skin temp", "pulseWaveVelocity": { "title": "Pulse Wave Velocity", - "description": "Your arterial pulse-wave velocity over time, in m/s — higher values suggest stiffer arteries.", + "description": "Your arterial pulse-wave velocity over time, in m/s. Higher values suggest stiffer arteries.", "chartTitle": "Pulse wave velocity", "emptyState": { "title": "No pulse-wave-velocity data yet", "description": "Pulse-wave-velocity readings appear here once a compatible Withings device syncs." } }, - "navPulseWaveVelocity": "Pulse Wave Velocity", + "navPulseWaveVelocity": "Pulse wave velocity", "vascularAge": { "title": "Vascular Age", "description": "Your estimated vascular age over time, in years.", @@ -3869,7 +3908,7 @@ "description": "Vascular-age estimates appear here once a compatible Withings device syncs." } }, - "navVascularAge": "Vascular Age", + "navVascularAge": "Vascular age", "visceralFat": { "title": "Visceral Fat", "description": "Your visceral-fat rating over time.", @@ -3879,7 +3918,7 @@ "description": "Visceral-fat ratings appear here once a compatible body-composition scale syncs." } }, - "navVisceralFat": "Visceral Fat", + "navVisceralFat": "Visceral fat", "audioExposureEnv": { "title": "Environmental Audio", "description": "Your daily average environmental sound exposure, in dBA.", @@ -3889,7 +3928,7 @@ "description": "Environmental sound-level readings appear here once Apple Health syncs." } }, - "navAudioExposureEnv": "Environmental Audio", + "navAudioExposureEnv": "Environmental audio", "audioExposureHeadphone": { "title": "Headphone Audio", "description": "Your daily average headphone audio exposure, in dBA.", @@ -3899,7 +3938,7 @@ "description": "Headphone audio-level readings appear here once Apple Health syncs." } }, - "navAudioExposureHeadphone": "Headphone Audio", + "navAudioExposureHeadphone": "Headphone audio", "timeInDaylight": { "title": "Time in Daylight", "description": "Minutes spent in daylight each day.", @@ -3919,7 +3958,7 @@ "description": "Walking-steadiness readings appear here once Apple Health syncs." } }, - "navWalkingSteadiness": "Walking Steadiness", + "navWalkingSteadiness": "Walking steadiness", "audioExposureEvent": { "title": "Loud Audio Events", "description": "Days with a loud-environment exposure event.", @@ -3929,7 +3968,7 @@ "description": "Loud-environment events appear here once Apple Health syncs." } }, - "navAudioExposureEvent": "Loud Events", + "navAudioExposureEvent": "Loud events", "respiratoryRate": { "title": "Respiratory Rate", "description": "Your respiratory rate over time, in breaths per minute.", @@ -3939,7 +3978,7 @@ "description": "Respiratory-rate readings appear here once Apple Health syncs sleep or workout breathing." } }, - "navRespiratoryRate": "Respiratory Rate", + "navRespiratoryRate": "Respiratory rate", "leanBodyMass": { "title": "Lean Body Mass", "description": "Your lean body mass over time, in kilograms.", @@ -3949,7 +3988,7 @@ "description": "Lean-body-mass readings appear here once a connected body-composition scale syncs." } }, - "navLeanBodyMass": "Lean Body Mass", + "navLeanBodyMass": "Lean body mass", "walkingHeartRateAverage": { "title": "Walking Heart Rate", "description": "Your average heart rate while walking, in bpm.", @@ -3959,7 +3998,7 @@ "description": "Walking-heart-rate readings appear here once Apple Health syncs." } }, - "navWalkingHeartRateAverage": "Walking Heart Rate", + "navWalkingHeartRateAverage": "Walking heart rate", "walkingAsymmetry": { "title": "Walking Asymmetry", "description": "Your walking asymmetry over time, as a percentage.", @@ -3969,7 +4008,7 @@ "description": "Walking-asymmetry readings appear here once Apple Health syncs." } }, - "navWalkingAsymmetry": "Walking Asymmetry", + "navWalkingAsymmetry": "Walking asymmetry", "walkingDoubleSupport": { "title": "Double Support Time", "description": "The percentage of each stride spent with both feet on the ground.", @@ -3979,7 +4018,7 @@ "description": "Double-support readings appear here once Apple Health syncs." } }, - "navWalkingDoubleSupport": "Double Support", + "navWalkingDoubleSupport": "Double support", "walkingStepLength": { "title": "Walking Step Length", "description": "Your average walking step length over time, in metres.", @@ -3989,7 +4028,7 @@ "description": "Step-length readings appear here once Apple Health syncs." } }, - "navWalkingStepLength": "Step Length", + "navWalkingStepLength": "Step length", "walkingSpeed": { "title": "Walking Speed", "description": "Your average walking speed over time, in km/h.", @@ -3999,10 +4038,9 @@ "description": "Walking-speed readings appear here once Apple Health syncs." } }, - "navWalkingSpeed": "Walking Speed", + "navWalkingSpeed": "Walking speed", "mood": { "logLink": "Log a mood entry", - "mentalWellbeingLink": "Take a mental-wellbeing check-in", "heatmapTitle": "Mood calendar", "heatmapNoEntry": "No entry", "legendGreat": "Great", @@ -4013,7 +4051,7 @@ "distributionDaysUnit": "days", "weekdayTitle": "Average by weekday", "weekdayNoData": "No data", - "weekdayBestWorst": "Best day: {best} · Toughest day: {worst}", + "weekdayBestWorst": "Best day: {best}, toughest day: {worst}", "tagsTitle": "Tags and triggers", "tagAvgMood": "Average mood when tagged", "correlationsTitle": "Mood and other metrics", @@ -4029,7 +4067,7 @@ "strengthModerate": "Moderate", "strengthWeak": "Weak", "strengthNone": "None", - "source": "{n} paired days · r = {r}", + "source": "{n} paired days, r = {r}", "sourceLabel": "How this was computed", "emptyTitle": "Not enough paired data yet", "emptyDescription": "Log mood and this metric on the same days to see a correlation.", @@ -4046,7 +4084,7 @@ "inTargetCaption": "of recent days in your good-mood range", "narrative": { "title": "What stands out", - "weekdayDip": "Your mood dips most on {weekday} — {delta} below your week.", + "weekdayDip": "Your mood dips most on {weekday}, {delta} below your week.", "weekdayPeak": "{weekday} is consistently your best day, averaging {value}.", "trendUp": "Your mood is trending up over the last {days} days (+{delta} vs the prior period).", "trendDown": "Your mood is trending down over the last {days} days (-{delta} vs the prior period).", @@ -4055,7 +4093,7 @@ "tagLift": "Days tagged \"{tag}\" average {delta} above your overall mood.", "tagDrop": "Days tagged \"{tag}\" run {delta} below your average.", "inTarget": "You've been in your good-mood range {pct}% of the last 30 days.", - "streak": "You've logged your mood {days} days in a row — keep it up.", + "streak": "You've logged your mood {days} days in a row. Keep it up.", "timeOfDay": "You feel best in the {bucket}, averaging {value}." }, "timeOfDay": { @@ -4088,7 +4126,7 @@ "crosstab": { "title": "Tags and your health metrics", "description": "How a health metric differs on the days you tagged each activity, compared with the days you did not.", - "pairLabel": "{tag} · {metric}", + "pairLabel": "{metric} on days with {tag}", "metricActiveEnergy": "Active energy", "metricSleepDuration": "Sleep", "metricNextDayRecovery": "Next-day recovery", @@ -4105,7 +4143,7 @@ "factorCrosstab": { "title": "Your rated factors and your vitals", "description": "How a vital differs on the days you rated each factor low compared with the days you rated it high.", - "pairLabel": "{factor} · {metric}", + "pairLabel": "{metric} on days with {factor}", "metricSleepDuration": "Sleep", "metricSteps": "Steps", "metricRestingHeartRate": "Resting heart rate", @@ -4126,12 +4164,12 @@ "title": "What stands out", "subheading": "Discovered relationships", "footer": "Surfaced from {tested} day-to-day pairs tested across all your metrics, after the false-discovery correction.", - "disclaimer": "Statistically filtered, but still observational — a pattern worth watching, not a cause.", + "disclaimer": "Statistically filtered, but still observational: a pattern worth watching, not a cause.", "moodUp": "Higher {factor} tends to go with higher next-day mood in your data.", "moodDown": "Higher {factor} tends to go with lower next-day mood in your data.", "outcomeUp": "Higher mood tends to go with higher next-day {factor} in your data.", "outcomeDown": "Higher mood tends to go with lower next-day {factor} in your data.", - "stat": "{n} paired days · r = {r} · q = {q}", + "stat": "{n} paired days, r = {r}, q = {q}", "statLabel": "How this was computed", "explainerLabel": "How this was computed" }, @@ -4165,8 +4203,8 @@ "deviationAbove": "Your own rating sits {amount} points above that range.", "deviationBelow": "Your own rating sits {amount} points below that range.", "weightedTitle": "What weighed most in this day's comparison", - "weightedUp": "{label} — counted towards a higher expected value", - "weightedDown": "{label} — counted towards a lower expected value", + "weightedUp": "{label}: counted towards a higher expected value", + "weightedDown": "{label}: counted towards a lower expected value", "caveat": "This is a comparison with your own past days, not a statement about how a day came to go the way it did.", "learning": "Learning phase: {entries} of {threshold} days recorded. A comparison appears once there are enough of them.", "noPattern": "Nothing in the {entries} days recorded so far is steady enough to compare a day against. When you also capture a day's context, more of it becomes comparable.", @@ -4175,15 +4213,15 @@ }, "derived": { "coverage": { - "summary": "{present} of {required} inputs · {percent}%", + "summary": "{present} of {required} inputs ({percent}%)", "ratioLabel": "{present}/{required}", "historyDays": "Based on {count} days of history", "missing": "Track these to sharpen this: {list}", - "summaryLabelled": "{label} · {percent}%" + "summaryLabelled": "{label} ({percent}%)" }, "scoreRing": { "aria": "Score {score} out of 100, {band}", - "ariaProvisional": "Score not available yet — not enough data", + "ariaProvisional": "Score not available yet, not enough data", "provisionalCaption": "Not enough data yet", "band": { "green": "good", @@ -4197,7 +4235,7 @@ "inputsLabel": "Inputs", "noInputs": "No inputs backed this value yet.", "methodLabel": "Method", - "window": "From your last {days} days · {source} data", + "window": "From your last {days} days of {source} data", "asOf": "as of {date}", "standardLink": "Standard: {name}" }, @@ -4207,7 +4245,7 @@ "loadingLabel": "Loading your vitals", "loadError": "Could not load your vitals right now.", "infoLabel": "How is this calculated?", - "building": "Building your typical range — {count} of {target} days", + "building": "Building your typical range: {count} of {target} days", "typicalRange": "Typical for you: {low}–{high}", "fitnessYounger": "Cardio fitness of someone ~{years} yr younger", "fitnessOlder": "Cardio fitness ~{years} yr above your age", @@ -4239,9 +4277,8 @@ "anatomy": { "outOf": "/100", "contributorsLabel": "Contributors", - "contributorValue": "{value} · {weight}% weight", + "contributorValue": "{value}, weighted {weight}%", "contributorMissing": "no data", - "disclaimer": "Indicative — not a clinical assessment.", "insufficient": "Not enough data yet to score this. Keep tracking and it will appear here." }, "composite": { @@ -4260,73 +4297,71 @@ "READINESS": { "title": "Readiness", "description": "A daily wellness proxy from how your signals sit against your own baseline.", - "method": "A 0–100 blend of resting-HR deviation, HRV (SDNN) deviation, sleep adequacy, respiratory-rate stability and mood stability — each measured against your personal baseline. Weights renormalise over the components your data supports. A wellness proxy, not a recovery or clinical score.", + "method": "A 0–100 blend of resting-HR deviation, HRV (SDNN) deviation, sleep adequacy, respiratory-rate stability and mood stability, each measured against your personal baseline. Weights renormalise over the components your data supports. A wellness proxy, not a recovery or clinical score.", "component": { "rhr": "Resting heart rate", "hrv": "HRV (SDNN)", "sleep": "Sleep", "respiratory": "Respiratory rate", "mood": "Mood stability" - }, - "caveat": "A wellness proxy, not a recovery or clinical score." + } }, "VITALS_BASELINE": { - "method": "Your personal typical range is the median of your daily values over the window, ± a robust spread (median absolute deviation, scaled). It tracks YOUR readings, not a population norm — so a value inside the band is typical for you." + "method": "Your personal typical range is the median of your daily values over the window, ± a robust spread (median absolute deviation, scaled). It tracks YOUR readings, not a population norm, so a value inside the band is typical for you." }, "HRV_BALANCE": { "method": "Your recent HRV (SDNN) average placed against your own rolling baseline (median ± a robust spread). Balanced means it sits inside your personal range; the band is built from your data, never a population cut-off." }, "BMI": { - "method": "Weight ÷ height², classified against the WHO adult categories (underweight / normal / overweight / obese). A screening ratio only — it does not distinguish muscle from fat." + "method": "Weight ÷ height², classified against the WHO adult categories (underweight / normal / overweight / obese). A screening ratio only; it does not distinguish muscle from fat." }, "FITNESS_AGE": { - "method": "Your VO₂max placed against age- and sex-referenced cardiorespiratory-fitness percentiles, expressed as a fitness-age delta. The VO₂max is your device's estimate — never recomputed here." + "method": "Your VO₂max placed against age- and sex-referenced cardiorespiratory-fitness percentiles, expressed as a fitness-age delta. The VO₂max is your device's estimate, never recomputed here." }, "VASCULAR_AGE_DELTA": { "method": "Your device's vascular-age / pulse-wave-velocity estimate compared with your chronological age, expressed as a delta in years. The estimate is the device's; HealthLog only re-frames it.", - "caveat": "Your device's own estimate, re-framed — descriptive, not a clinical vascular assessment.", + "caveat": "Your device's own estimate, re-framed: descriptive, not a clinical vascular assessment.", "description": "How your device's vascular-age estimate sits against your chronological age." }, "COINCIDENT_DEVIATION": { "method": "A flag raised when two or more of your vitals sit outside their personal typical range on the same day. Each band is the robust median ± k·MAD of your own history, so it adapts to you rather than to a population cut-off.", - "caveat": "An awareness flag from your own baselines — descriptive, not a diagnosis or a clinical alert." + "caveat": "An awareness flag from your own baselines: descriptive, not a diagnosis or a clinical alert." }, "RECOVERY_SCORE": { - "method": "A nightly 0–100 recovery proxy blended from your HRV, resting heart rate and sleep against your own baseline. Higher means better-recovered. Computed once each night and read back here — never recomputed on the fly.", - "caveat": "A descriptive wellness proxy, not a training-grade or clinical recovery measure.", + "method": "A nightly 0–100 recovery proxy blended from your HRV, resting heart rate and sleep against your own baseline. Higher means better-recovered. Computed once each night and read back here, never recomputed on the fly.", "description": "A nightly recovery proxy from your HRV, resting heart rate and sleep." }, "STRESS_SCORE": { "method": "A nightly 0–100 stress proxy from the shape of your intra-day HRV against your baseline. Higher means more physiological load.", - "caveat": "An HRV-derived proxy, NOT an EDA / galvanic-skin stress measurement — your watch has no EDA sensor. Descriptive, not clinical.", "description": "A nightly stress proxy from the shape of your intra-day HRV." }, "STRAIN_SCORE": { "method": "A nightly 0–100 strain proxy from your heart-rate-weighted activity load (a TRIMP-style training-impulse model). Higher means a harder day.", - "anchorPersonal": "Anchored to your own recent training-day load — scored relative to your typical effort, so a genuinely hard day for you reads high.", + "anchorPersonal": "Anchored to your own recent training-day load, scored relative to your typical effort, so a genuinely hard day for you reads high.", "anchorPopulation": "Scored against a general reference while you build training history; once you have enough it switches to your own typical effort.", - "caveat": "A descriptive activity-load proxy, not a clinical or training-grade load measure.", - "description": "A nightly activity-load proxy from your heart-rate-weighted effort, relative to your typical effort." + "description": "A nightly activity-load proxy from your heart-rate-weighted effort, relative to your typical effort.", + "deviceCaption": "Your device's day strain: {value} of {max}", + "deviceSource": "Until HealthLog has computed its own value overnight, the card shows your device's day strain, rescaled to 0–100." }, "WRIST_TEMPERATURE_BASELINE": { - "method": "Your typical overnight wrist temperature is the median of your nightly readings over the window, ± a robust spread (median absolute deviation, scaled). It tracks YOUR readings — last night is shown as a deviation from your own baseline, not a population norm.", - "caveat": "A personal-deviation band only — descriptive, not an illness, fever or cycle-phase signal. Interpret it yourself." + "method": "Your typical overnight wrist temperature is the median of your nightly readings over the window, ± a robust spread (median absolute deviation, scaled). It tracks YOUR readings: last night is shown as a deviation from your own baseline, not a population norm.", + "caveat": "A personal-deviation band only: descriptive, not an illness, fever or cycle-phase signal. Interpret it yourself." }, "STAIR_ASCENT_SPEED_BASELINE": { - "method": "Your typical stair-climbing pace is the median of your daily values over the window, ± a robust spread (median absolute deviation, scaled). The band is built from your data — there is no population stair-speed cut-off, because pace depends on stair geometry and leg length.", - "caveat": "A personal mobility-trend band — descriptive, not a frailty or fall-risk assessment." + "method": "Your typical stair-climbing pace is the median of your daily values over the window, ± a robust spread (median absolute deviation, scaled). The band is built from your data. There is no population stair-speed cut-off, because pace depends on stair geometry and leg length.", + "caveat": "A personal mobility-trend band: descriptive, not a frailty or fall-risk assessment." }, "STAIR_DESCENT_SPEED_BASELINE": { - "method": "Your typical stair-descent pace is the median of your daily values over the window, ± a robust spread (median absolute deviation, scaled). The band is built from your data — there is no population stair-speed cut-off, because pace depends on stair geometry and leg length.", - "caveat": "A personal mobility-trend band — descriptive, not a frailty or fall-risk assessment." + "method": "Your typical stair-descent pace is the median of your daily values over the window, ± a robust spread (median absolute deviation, scaled). The band is built from your data. There is no population stair-speed cut-off, because pace depends on stair geometry and leg length.", + "caveat": "A personal mobility-trend band: descriptive, not a frailty or fall-risk assessment." }, "SIX_MINUTE_WALK_BAND": { - "method": "Your device's ESTIMATED six-minute-walk distance placed against the Enright & Sherrill reference for your age, height, weight and sex, as a percent of predicted. The distance is the device's estimate — never recomputed here. Without your age, height, weight and sex the percent is hidden and only the distance and trend are shown.", - "caveat": "An estimate re-framed against a published reference — a functional-capacity awareness signal, not a clinical 6-minute-walk test or a diagnosis. The reference was established for ages 40–80; outside that range the predicted value is an extrapolation." + "method": "Your device's ESTIMATED six-minute-walk distance placed against the Enright & Sherrill reference for your age, height, weight and sex, as a percent of predicted. The distance is the device's estimate, never recomputed here. Without your age, height, weight and sex the percent is hidden and only the distance and trend are shown.", + "caveat": "An estimate re-framed against a published reference: a functional-capacity awareness signal, not a clinical 6-minute-walk test or a diagnosis. The reference was established for ages 40–80; outside that range the predicted value is an extrapolation." }, "TRAJECTORY": { "method": "A least-squares line fitted to your recent daily values and projected forward over a short horizon, with the textbook prediction interval that widens the further it reaches. Computed only when the recent trend is strong enough and your history is long and current enough; otherwise no line is drawn.", - "caveat": "A projection of your own recent trend — not a prediction, a target, or a clinical forecast. It holds only if the recent pattern continues; the shaded band shows how quickly that certainty fades." + "caveat": "A projection of your own recent trend, not a prediction, a target, or a clinical forecast. It holds only if the recent pattern continues; the shaded band shows how quickly that certainty fades." }, "SAME_TIME_BASELINE": { "method": "Your running total for today set against the median of the same local hour across your last four weeks, with a robust median ± MAD band around it. Only days with enough recorded detail count towards it, and no comparison is shown until at least fourteen of them are in hand.", @@ -4341,7 +4376,9 @@ "recovery": "Recovery score", "recoveryCrossLink": "See your wearable's recovery & strain data", "stress": "Stress", - "strain": "Strain" + "strain": "Strain", + "historyTitle": "History", + "historyWindow": "Last {days} days" }, "coincident": { "label": "Several vitals off baseline", @@ -4351,38 +4388,38 @@ "allClear": "All your vitals are within their personal range today.", "allClearMeta": "Checked {count} of your vitals against your own typical range.", "watch": "One vital is outside its usual range today.", - "watchVital": "Outside range: {vital}. Often just a one-off — worth a glance over the next few days.", + "watchVital": "Outside range: {vital}. Often just a one-off, worth a glance over the next few days.", "firedHeadline": "{count} of your vitals are outside their personal range today.", - "factors": "Possible factors — never a cause: a hard workout, poor sleep, alcohol, altitude, stress, or illness. This is awareness from your own baselines, not a diagnosis.", - "building": "Building your personal baselines — track a few more days and this will appear.", + "factors": "Possible factors (never a cause): a hard workout, poor sleep, alcohol, altitude, stress, or illness. This is awareness from your own baselines, not a diagnosis.", + "building": "Building your personal baselines. Track a few more days and this will appear.", "illnessExplained": "These shifts likely reflect the condition you logged." }, "trajectory": { "cardTitle": "Short-horizon projection", - "headline": "If this pattern continues — the next {days} days", + "headline": "If this pattern continues: the next {days} days", "range": "Projected around {low}–{high} {unit} in {days} days.", "caveat": "A projection of your own recent trend, not a prediction. The shaded band widens to show how quickly the uncertainty grows.", - "insufficient": "Not enough of a clear trend to project yet — keep tracking and a projection will appear once the recent pattern is strong enough.", + "insufficient": "Not enough of a clear trend to project yet. Keep tracking and a projection will appear once the recent pattern is strong enough.", "daysNeeded": "About {count} more days of tracking needed before a projection can appear." }, "sameTime": { "sectionTitle": "Today so far", "sectionSubtitle": "Your running totals against what is usual for you at this hour", - "learning": "Learning your usual day — {count} of {target} days", + "learning": "Learning your usual day: {count} of {target} days", "framing": "Typical by {time}: {typical} {unit}", "comparisonLabel": "Your typical day" } }, "cardioFitness": { "title": "Cardio Fitness", - "description": "Your VO₂ max over time — the headline measure of aerobic fitness.", + "description": "Your VO₂ max over time, the headline measure of aerobic fitness.", "chartTitle": "VO₂ max (mL/(kg·min))", "emptyState": { "title": "No cardio-fitness data yet", "description": "VO₂ max readings appear here once Apple Health syncs from your workouts." } }, - "navCardioFitness": "Cardio Fitness", + "navCardioFitness": "Cardio fitness", "cardioRecovery": { "title": "Cardio Recovery", "description": "How far your heart rate drops one minute after peak exercise.", @@ -4392,7 +4429,7 @@ "description": "Cardio-recovery readings appear here once Apple Health syncs from a workout." } }, - "navCardioRecovery": "Cardio Recovery", + "navCardioRecovery": "Cardio recovery", "wristTemperature": { "title": "Wrist Temperature", "description": "Your overnight wrist temperature, tracked against your own baseline.", @@ -4402,7 +4439,7 @@ "description": "Wrist-temperature readings appear here once Apple Health syncs overnight wear." } }, - "navWristTemperature": "Wrist Temp", + "navWristTemperature": "Wrist temp", "falls": { "title": "Falls", "description": "Hard falls your Apple Watch detected, counted per day.", @@ -4422,7 +4459,7 @@ "description": "Six-minute-walk estimates appear here once Apple Health syncs." } }, - "navSixMinuteWalk": "6-Min Walk", + "navSixMinuteWalk": "6-min walk", "stairAscentSpeed": { "title": "Stair Ascent Speed", "description": "How fast you climb stairs, measured by your iPhone or Watch.", @@ -4432,7 +4469,7 @@ "description": "Stair-ascent-speed readings appear here once Apple Health syncs." } }, - "navStairAscentSpeed": "Stair Ascent", + "navStairAscentSpeed": "Stair ascent", "stairDescentSpeed": { "title": "Stair Descent Speed", "description": "How fast you go down stairs, measured by your iPhone or Watch.", @@ -4442,7 +4479,7 @@ "description": "Stair-descent-speed readings appear here once Apple Health syncs." } }, - "navStairDescentSpeed": "Stair Descent", + "navStairDescentSpeed": "Stair descent", "breathingDisturbances": { "title": "Breathing Disturbances", "description": "How often your breathing was disturbed during sleep.", @@ -4455,7 +4492,7 @@ "navBreathingDisturbances": "Breathing", "rhythmEvents": { "sectionTitle": "Device health notifications", - "sectionIntro": "Notifications your watch sent on its own. HealthLog only shows these — it does not detect, confirm, or interpret them.", + "sectionIntro": "Notifications your watch sent on its own. HealthLog only shows these. It does not detect, confirm, or interpret them.", "loadError": "Could not load your device notifications.", "disclaimer": "These are alerts your device produced using its own built-in detection. They are shown here for your awareness only. This is not a medical assessment by HealthLog, and HealthLog does not diagnose any condition. If a notification concerns you, talk to your doctor.", "event": { @@ -4500,13 +4537,13 @@ "detail": "Hidden detail pages are also removed from the top navigation." }, "customize": "Customize Insights", - "regenerateRateLimited": "Refresh limit reached — please try again later.", - "regenerateUnavailable": "No fresh analysis arrived — please try again later.", - "regenerateError": "Still refreshing — please try again in a moment.", + "regenerateRateLimited": "Refresh limit reached. Please try again later.", + "regenerateUnavailable": "No fresh analysis arrived. Please try again later.", + "regenerateError": "Still refreshing. Please try again in a moment.", "regenerateSettling": "Your assessment is still being prepared", "regenerateSettleFailed": "The refresh could not be completed. Please try again later.", "deviceScore": { - "learning": "Still learning your baseline — a few more nights will fill in the trend.", + "learning": "Still learning your baseline. A few more nights will fill in the trend.", "average": "30-day average {value}", "unitScore": "score", "unitMinutes": "min", @@ -4519,14 +4556,14 @@ }, "recovery": { "title": "Recovery", - "description": "How well your body has bounced back — the recovery, strain and load signals your wearable records overnight.", + "description": "How well your body has bounced back: the recovery, strain and load signals your wearable records overnight.", "scoreCrossLink": "See the composite recovery score", "emptyTitle": "No recovery data yet", "empty": "No recovery data yet. Connect a WHOOP, Polar or Oura account and these scores fill in once your device syncs.", "block": { - "ansChargeExplainer": "How charged your autonomic nervous system is for the day ahead — a recharge reading drawn from your overnight heart and breathing signals.", + "ansChargeExplainer": "How charged your autonomic nervous system is for the day ahead, a recharge reading drawn from your overnight heart and breathing signals.", "dayStrainExplainer": "The day's whole-body cardiovascular load on your device's strain scale. Read it against your recovery, not as a goal on its own.", - "workoutStrainExplainer": "How demanding a single workout was on the same strain scale — the cardiovascular cost of that session.", + "workoutStrainExplainer": "How demanding a single workout was on the same strain scale: the cardiovascular cost of that session.", "cardioLoadExplainer": "A training-load proxy that tracks the acute cardiovascular load you have built up. Compare it with your own recent days.", "averageHeartRateExplainer": "Your average heart rate across the whole day or cycle, as reported by your wearable.", "maxHeartRateExplainer": "Your peak heart rate across the whole day or cycle, as reported by your wearable.", @@ -4539,7 +4576,7 @@ }, "resilience": { "explainer": "How well your body is balancing daily strain with recovery, read from your overnight signals across recent days.", - "learning": "Still learning your resilience — a few more days will settle the band.", + "learning": "Still learning your resilience. A few more days will settle the band.", "band": { "limited": "Limited", "adequate": "Adequate", @@ -4584,7 +4621,7 @@ "nights": "{count} nights tracked", "indexLine": "recent average index {value}", "eventsFlagged": "{count} events flagged by your device", - "disclaimer": "This is a screening signal, not a diagnosis — discuss with a clinician." + "disclaimer": "This is a screening signal, not a diagnosis. Discuss with a clinician." }, "labsChanges": { "sectionTitle": "Since your last panel", @@ -4600,14 +4637,14 @@ "navWaistToHeight": "Waist-to-height", "gripStrength": { "title": "Grip strength", - "description": "Maximal handgrip force — a robust proxy for whole-body strength. Below the sex-specific cut-off (men 27 kg, women 16 kg) flags probable sarcopenia; your own trend leads the read.", + "description": "Maximal handgrip force, a robust proxy for whole-body strength. Below the sex-specific cut-off (men 27 kg, women 16 kg) flags probable sarcopenia; your own trend leads the read.", "chartTitle": "Grip strength", "emptyState": { "title": "No grip strength yet", - "description": "Log a reading from a hand dynamometer — record the best of a few squeezes — and the trend will appear here.", + "description": "Log a reading from a hand dynamometer (record the best of a few squeezes) and the trend will appear here.", "cta": "Log grip strength" }, - "coachPrefill": "I haven't logged any grip strength yet — what does it tell me about my muscle health, and how do I measure and improve it?" + "coachPrefill": "I haven't logged any grip strength yet. What does it tell me about my muscle health, and how do I measure and improve it?" }, "pain": { "title": "Pain (0–10)", @@ -4618,29 +4655,29 @@ "description": "Log how your pain feels right now on the 0–10 scale and the trend will appear here.", "cta": "Log a pain score" }, - "coachPrefill": "I haven't logged any pain scores yet — how should I track pain on the 0–10 scale, and what counts as a meaningful change?" + "coachPrefill": "I haven't logged any pain scores yet. How should I track pain on the 0–10 scale, and what counts as a meaningful change?" }, "waist": { "title": "Waist circumference", - "description": "Waist girth in centimetres — a marker of central fat. The WHO increased-risk threshold is sex-specific (men 94 cm, women 80 cm); your own trend leads the read.", + "description": "Waist girth in centimetres, a marker of central fat. The WHO increased-risk threshold is sex-specific (men 94 cm, women 80 cm); your own trend leads the read.", "chartTitle": "Waist circumference", "emptyState": { "title": "No waist measurements yet", "description": "Measure your waist at the midpoint between the lowest rib and the hip bone, at the end of a normal breath, and log it here.", "cta": "Log waist circumference" }, - "coachPrefill": "I haven't logged any waist measurements yet — how do I measure my waist correctly, and what does the number mean for my health?" + "coachPrefill": "I haven't logged any waist measurements yet. How do I measure my waist correctly, and what does the number mean for my health?" }, "waistToHeight": { "title": "Waist-to-height ratio", - "description": "Your waist divided by your height — the ancestry-robust single number. Keeping it under 0.5 (waist below half your height) is the simple target.", + "description": "Your waist divided by your height, the ancestry-robust single number. Keeping it under 0.5 (waist below half your height) is the simple target.", "chartTitle": "Waist-to-height ratio", "emptyState": { "title": "No waist-to-height ratio yet", "description": "Log a waist-to-height ratio and the trend will appear here. The rule of thumb: keep your waist under half your height.", "cta": "Log waist-to-height ratio" }, - "coachPrefill": "I haven't logged a waist-to-height ratio yet — why is it a useful number, and how do I keep it under 0.5?" + "coachPrefill": "I haven't logged a waist-to-height ratio yet. Why is it a useful number, and how do I keep it under 0.5?" }, "ecg": { "sectionTitle": "ECG recordings", @@ -4681,9 +4718,9 @@ "emptyCta": "Connect a device" }, "intradayPulse": { - "title": "Your day's shape", - "caption": "Your heart rate through the day, averaged in 10-minute steps.", - "hourlyNote": "Showing this day's hourly average — recent days show the full 10-minute detail.", + "title": "Pulse through the day", + "caption": "Averaged in 10-minute steps.", + "hourlyNote": "Showing this day's hourly average; recent days show the full 10-minute detail.", "empty": "Not enough heart-rate readings for this day to show its shape yet.", "coverageOne": "Based on 1 reading across {hours}h.", "coverageMany": "Based on {count} readings across {hours}h.", @@ -4692,10 +4729,10 @@ "previousDay": "Previous day", "nextDay": "Next day", "tension": { - "morning": "A sustained elevated-at-rest stretch this morning — possibly some tension.", - "afternoon": "A sustained elevated-at-rest stretch this afternoon — possibly some tension.", - "evening": "A sustained elevated-at-rest stretch this evening — possibly some tension.", - "night": "A sustained elevated-at-rest stretch overnight — possibly some tension." + "morning": "A sustained elevated-at-rest stretch this morning, possibly some tension.", + "afternoon": "A sustained elevated-at-rest stretch this afternoon, possibly some tension.", + "evening": "A sustained elevated-at-rest stretch this evening, possibly some tension.", + "night": "A sustained elevated-at-rest stretch overnight, possibly some tension." } }, "navEcg": "ECG", @@ -4706,6 +4743,19 @@ "settingsBody": "HealthLog works fully without AI. Connect a provider below if you want a daily briefing, short notes on your readings and the Coach.", "action": "Set up AI", "dismiss": "Not now" + }, + "bmiHeight": { + "coachPrefill": "I haven’t set my height yet. Why does BMI matter, and what should I know before I configure it?" + }, + "medicationsEmpty": { + "coachPrefill": "I haven’t added any medications yet. What should I know before I start tracking medication compliance here?" + }, + "scoreHistory": { + "healthTitle": "Health Score history", + "healthDescription": "Your Health Score day by day, as it was shown on each day.", + "emptyDescription": "No score was recorded in this range yet.", + "usualRange": "Shaded: your usual range, {lo} to {hi}.", + "seam": "The line breaks where what counts toward the score changed." } }, "thresholds": { @@ -4735,14 +4785,15 @@ "metricBodyFat": "Body fat", "metricSleep": "Sleep duration", "metricSteps": "Daily steps", - "metricGlucoseFasting": "Glucose — fasting", - "metricGlucosePostprandial": "Glucose — post-meal", - "metricGlucoseRandom": "Glucose — random", - "metricGlucoseBedtime": "Glucose — bedtime", + "metricGlucoseFasting": "Glucose (fasting)", + "metricGlucosePostprandial": "Glucose (post-meal)", + "metricGlucoseRandom": "Glucose (random)", + "metricGlucoseBedtime": "Glucose (bedtime)", "metricBodyWater": "Body water", "metricBoneMass": "Bone mass", "metricOxygenSaturation": "Oxygen saturation (SpO₂)", - "unsetExplanation": "No override — using computed default." + "unsetExplanation": "No override, using computed default.", + "cardTitle": "Ranges per metric" }, "onboarding": { "errorGeneric": "Something went wrong while finishing setup. Please try again.", @@ -4755,11 +4806,11 @@ "steps": { "dashboardOverview": { "title": "Your dashboard", - "body": "The dashboard pulls every module together at a glance. Long-press a tile to reorder or hide it (Settings → Dashboard layout). Each tile is only as telling as the data behind it — the modules ahead fill them." + "body": "The dashboard pulls every module together at a glance. Long-press a tile to reorder or hide it (Settings → Dashboard layout). Each tile is only as telling as the data behind it, and the modules ahead fill them." }, "quickAdd": { "title": "Logged in seconds", - "body": "Use “+ Add” to log any reading in seconds — weight, blood pressure, pulse, glucose. Hidden strength: backdate readings you forgot to enter. Everything you log here feeds trends, the health score, and the AI insights." + "body": "Use “+ Add” to log any reading in seconds: weight, blood pressure, pulse, glucose. Hidden strength: backdate readings you forgot to enter. Everything you log here feeds trends, the health score, and the AI insights." }, "measurements": { "title": "Readings & target ranges", @@ -4771,7 +4822,7 @@ }, "labs": { "title": "Lab values", - "body": "Labs captures biomarkers with reference ranges — blood count, cholesterol, HbA1c. Strength: import and FHIR export. Out-of-range lab values resurface as evidence in the insights and the doctor report." + "body": "Labs captures biomarkers with reference ranges: blood count, cholesterol, HbA1c. Strength: import and FHIR export. Out-of-range lab values resurface as evidence in the insights and the doctor report." }, "illness": { "title": "Illness journal", @@ -4787,31 +4838,31 @@ }, "mood": { "title": "Mood", - "body": "Mood captures how you feel plus tags in two seconds. Strength: your own tags. Mood trends correlate with sleep, medications, and cycle — the Coach spots the patterns." + "body": "Mood captures how you feel plus tags in two seconds. Strength: your own tags. Mood trends correlate with sleep, medications, and cycle, and the Coach spots the patterns." }, "insights": { "title": "Insights", - "body": "Insights is the heart of it: evidence-based, dynamic analysis instead of generic tips — every statement points at your real data. Hidden: the layout is customizable. Insights pulls together everything the modules above provide." + "body": "Insights is the heart of it: evidence-based, dynamic analysis instead of generic tips. Every statement points at your real data. Hidden: the layout is customizable. Insights pulls together everything the modules above provide." }, "coach": { "title": "Coach", - "body": "The Coach answers questions about your data in a dialogue, using your history, medications, and lab values as context. Pick your AI provider yourself (your own key or local). It recomputes nothing — it reads the same numbers as the dashboard." + "body": "The Coach answers questions about your data in a dialogue, using your history, medications, and lab values as context. Pick your AI provider yourself (your own key or local). It recomputes nothing; it reads the same numbers as the dashboard." }, "integrations": { "title": "Integrations", - "body": "Integrations sync Withings, WHOOP, Polar, Oura, and Apple Health automatically. Weight, sleep, and recovery fill in without manual typing — and feed the insights and Coach in turn." + "body": "Integrations sync Withings, WHOOP, Polar, Oura, and Apple Health automatically. Weight, sleep, and recovery fill in without manual typing and feed the insights and Coach in turn." }, "export": { "title": "Doctor report", - "body": "The doctor report bundles readings, medications, lab values, and checkups as PDF or FHIR for the appointment. This is where every module converges — a complete report only comes from well-kept data." + "body": "The doctor report bundles readings, medications, lab values, and checkups as PDF or FHIR for the appointment. This is where every module converges: a complete report only comes from well-kept data." }, "achievements": { "title": "Achievements", - "body": "Achievements reward consistency — streaks and milestones. A small motivation loop that makes sticking with it easier." + "body": "Achievements reward consistency: streaks and milestones. A small motivation loop that makes sticking with it easier." }, "wrapUp": { "title": "That’s the tour", - "body": "This walkthrough runs once — it won't reopen on its own. Everything it covered stays put, and you can turn features on or off any time under Settings → Modules." + "body": "This walkthrough runs once and won't reopen on its own. Everything it covered stays put, and you can turn features on or off any time under Settings → Modules." } } }, @@ -4842,7 +4893,7 @@ "title": "You're all set", "body": "Welcome aboard. Your dashboard is ready.", "returnCta": "Open dashboard", - "learning": "Some insights — glucose, sleep rhythm, blood-pressure trends, cycle — sharpen over the first week or two as your data builds up. Empty tiles now just mean we're still learning your baseline.", + "learning": "Some insights (glucose, sleep rhythm, blood-pressure trends, cycle) sharpen over the first week or two as your data builds up. Empty tiles now just mean we're still learning your baseline.", "connectCta": "Connect a device", "logCta": "Log your first measurement", "importCta": "Import existing history", @@ -4859,33 +4910,33 @@ "anamnesis": { "title": "About your health (optional)", "subtitle": "A little context so HealthLog reads your numbers more fairly.", - "intro": "Share as much or as little as you like — every field is optional and you can edit it anytime in Settings.", + "intro": "Share as much or as little as you like. Every field is optional and you can edit it anytime in Settings.", "conditionsLabel": "Ongoing conditions", "conditionsPlaceholder": "e.g. high blood pressure, type 2 diabetes", "conditionsHint": "Helps HealthLog frame your readings in context rather than flagging the expected.", "allergiesLabel": "Allergies or intolerances", "allergiesPlaceholder": "e.g. penicillin, lactose", - "privacyNote": "Stored encrypted and used only to give you better context. We don't ask for your medications here — add those anytime from the Medications page." + "privacyNote": "Stored encrypted and used only to give you better context. We don't ask for your medications here. Add those anytime from the Medications page." }, "ai": { "panelTitle": "See what insights look like", - "panelIntro": "Coach, the daily briefing and the per-metric reads turn your own numbers into plain language. Here's the shape of it — before you connect anything.", + "panelIntro": "Coach, the daily briefing and the per-metric reads turn your own numbers into plain language. Here's the shape of it, before you connect anything.", "showSample": "See a sample briefing", "sampleTitle": "Daily Briefing", "sampleTag": "Example", "sampleAriaLabel": "Example daily briefing", - "sampleBody": "Weight down 1.2 kg over two weeks. Resting pulse steady at 58 bpm. Blood pressure in range on 6 of 7 days — the one high reading followed a late, salty dinner.", - "sampleCaption": "Example only — not your own data. This is what a briefing reads like once insights are on.", + "sampleBody": "Weight down 1.2 kg over two weeks. Resting pulse steady at 58 bpm. Blood pressure in range on 6 of 7 days; the one high reading followed a late, salty dinner.", + "sampleCaption": "Example only, not your own data. This is what a briefing reads like once insights are on.", "ladderTitle": "Three ways to turn it on", - "ladderLocalTitle": "Local model — AI requests stay on your network", - "ladderLocalBody": "Point HealthLog at Ollama or LM Studio on your own machine. Free and fully private — the calm default.", + "ladderLocalTitle": "Local model: AI requests stay on your network", + "ladderLocalBody": "Point HealthLog at Ollama or LM Studio on your own machine. Free and fully private, the calm default.", "ladderByokTitle": "Bring your own key", "ladderByokBody": "Use your own OpenAI or Anthropic key. It runs under your account and billing, and the API tier keeps your data out of training by default.", "ladderOauthTitle": "Sign in with a subscription", - "ladderOauthBody": "Connect a ChatGPT account — no per-token cost, but the consumer tier's terms may use content for training, so it's a deliberate choice, never the default for your health data.", - "keylessLine": "HealthLog is fully useful without AI. Your tiles, charts, trends and exports all work — AI only adds the plain-language read on top.", + "ladderOauthBody": "Connect a ChatGPT account. No per-token cost, but the consumer tier's terms may use content for training, so it's a deliberate choice, never the default for your health data.", + "keylessLine": "HealthLog is fully useful without AI. Your tiles, charts, trends and exports all work. AI only adds the plain-language read on top.", "setupCta": "Set it up (about a minute)", - "sharedKeyNote": "This instance already provides a shared AI provider, so insights work for you right now — no setup needed.", + "sharedKeyNote": "This instance already provides a shared AI provider, so insights work for you right now, no setup needed.", "offer": { "title": "This instance can write your summary in plain language.", "body": "The operator provides a provider that last worked within the past 24 hours. One tap turns the plain-language read on; your data then goes to that one provider and to nobody else.", @@ -5102,7 +5153,7 @@ "profileDescription": "Height, date of birth and gender unlock personalised targets.", "profileCta": "Open profile", "measurementTitle": "Log your first measurement", - "measurementDescription": "One reading unlocks the tile — the trend itself draws from your 5th reading.", + "measurementDescription": "One reading unlocks the tile; the trend itself draws from your 5th reading.", "measurementCta": "Add measurement", "medicationTitle": "Add a medication", "medicationDescription": "Track adherence and get reminders right when you need them.", @@ -5111,7 +5162,7 @@ "dataSourceDescription": "Auto-sync weight, BP, sleep and more from Withings, WHOOP, Oura, Polar, Nightscout, Fitbit or Apple Health.", "dataSourceCta": "Connect", "notificationsTitle": "Configure notifications", - "notificationsDescription": "Choose how HealthLog should remind you — Telegram, ntfy or Web Push.", + "notificationsDescription": "Choose how HealthLog should remind you: Telegram, ntfy or Web Push.", "notificationsCta": "Open notifications", "insightsTitle": "Turn on insights (optional)", "insightsDescription": "Add a local model or your own AI key to unlock the daily briefing and Coach.", @@ -5259,12 +5310,10 @@ "sections": { "account": { "title": "Account", - "description": "Profile, password, passkeys, and your onboarding tour.", "subtitle": "Your profile, identity, and sign-in details." }, "integrations": { "title": "Integrations", - "description": "Connected services, delivery channels, and source priority in one place.", "subtitle": "Connect and manage your data sources and devices.", "connectionsHeading": "Connections", "channelsHeading": "Delivery channels", @@ -5273,16 +5322,14 @@ }, "notifications": { "title": "Notifications", - "description": "Every reminder in one place — what gets reminded and when, and where it's delivered.", "subtitle": "Choose which reminders you receive and where they're delivered.", "remindersHeading": "Reminders", "matrixLink": "Per-channel notifications", "channelsLink": "Delivery channels" }, "layout": { - "title": "Appearance", - "description": "Choose how the dashboard, insights, and your tracking modules look and are arranged.", - "backToHub": "Back to Appearance", + "title": "Layout", + "backToHub": "Back to Layout", "dashboard": { "title": "Dashboard", "description": "Tile layout and order." @@ -5291,7 +5338,7 @@ "title": "Insights", "description": "Overview sections and navigation-pill order." }, - "subtitle": "Personalize how your dashboard, insights, and tracking modules look.", + "subtitle": "Choose what your dashboard, insights and tracking modules show, and in which order.", "medications": { "title": "Medications", "description": "List view and manual order." @@ -5311,6 +5358,10 @@ "mood": { "title": "Mood tags", "description": "Groups, tags, and archived tags for the mood picker." + }, + "documents": { + "title": "Documents", + "description": "View and month arrangement." } }, "dashboard": { @@ -5318,15 +5369,13 @@ }, "thresholds": { "title": "Targets", - "description": "Per metric: your own target range for healthy values.", "subtitle": "Set your personal target ranges per metric." }, "sources": { "title": "Source priority", - "description": "Pick which ingest source wins when more than one records the same metric for the same day.", "integrationsHint": "Connect or disconnect data sources on the Integrations page.", "integrationsHintLink": "Open integrations", - "cardTitle": "Source priority by metric", + "cardTitle": "Ranking per metric", "help": "When several sources deliver the same value on the same day, the top source counts as canonical.", "resetDefaults": "Reset to defaults", "resetDefaultsTitle": "Reset the source order?", @@ -5339,7 +5388,7 @@ "resetSuccess": "Source priority reset to defaults", "perMetricToggle": "Per-metric overrides ({count})", "perMetricHelp": "Metrics you have customised away from the default ladder. Use this list to roll any single metric back to its constant default without touching the rest.", - "perMetricEmpty": "No per-metric overrides set — every metric uses the global ladder above.", + "perMetricEmpty": "No per-metric overrides set. Every metric uses the global ladder above.", "resetMetric": "Reset", "deviceTypeToggle": "Device-type priority ({count})", "deviceTypeHelp": "Within a source, which device wins. When an Apple Watch and an iPhone both record steps, the higher-ranked device-type counts as the canonical reading.", @@ -5364,7 +5413,8 @@ "respiratoryRate": "Respiratory rate", "recovery": "Recovery", "stress": "Stress", - "sleepDebt": "Sleep debt" + "sleepDebt": "Sleep debt", + "hrvRmssd": "HRV (RMSSD)" }, "sourceLabels": { "WITHINGS": "Withings", @@ -5381,7 +5431,8 @@ "MCP": "MCP", "GOOGLE_HEALTH": "Google Health", "STRAVA": "Strava", - "EXTERNAL": "External device" + "EXTERNAL": "External device", + "HEALTH_CONNECT": "Health Connect" }, "deviceLabels": { "watch": "Watch", @@ -5401,28 +5452,25 @@ "daysWithData": "{count} of {days} days", "moduleHint": "Syncing is controlled by the Nutrient intake module.", "moduleHintLink": "Manage modules", - "detail": "Read-only — the source app owns the numbers." + "detail": "Read-only: the source app owns the numbers." }, - "helpDetail": "The others stay stored for the audit trail." + "helpDetail": "The others stay stored for the audit trail. Only sources you have connected or hold data from are listed.", + "noneInUse": "No source to rank yet. Connect one under Integrations or record a value." }, "ai": { "title": "AI provider", - "description": "Provider, model, and API key for the insights — plus your “about me” context.", "subtitle": "Choose your AI provider and bring your own key." }, "api": { - "title": "API & Tokens", - "description": "Bearer tokens for your own scripts and apps — log measurements or medication intake from anywhere.", + "title": "API & tokens", "subtitle": "Manage API endpoints and access tokens." }, "advanced": { "title": "Advanced", - "description": "The way out: erase every health record for good, or close the account entirely.", "subtitle": "Data reset and account deletion." }, "export": { - "title": "Export & Import", - "description": "Download your data as CSV or a full JSON backup — or import from Apple Health, JSON, or CSV. The doctor report now lives under Health Record.", + "title": "Export & import", "otherOptionsHeading": "Other export options", "downloadFailed": "Couldn't download the export. Please try again.", "actions": { @@ -5449,12 +5497,12 @@ "fullBackup": { "title": "Full backup", "description": "Your whole record in one JSON file.", - "scopeNote": "Document metadata and the AI-generated summary, if one exists, are included as plain text; original document files and workout GPS/sample data are not — download those separately from the vault or the workout export.", + "scopeNote": "Document metadata and the AI-generated summary, if one exists, are included as plain text; original document files and workout GPS/sample data are not. Download those separately from the vault or the workout export.", "encryptToggle": "Encrypt with a passphrase", "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.", + "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.", "contents": "Your measurements, medications, mood, cycle, labs, illness episodes, allergies, family history, nutrients, workouts, health profile and custom metrics in one JSON file." } }, @@ -5469,7 +5517,7 @@ "dropHint": "or click to choose a file", "fileInputLabel": "Choose an Apple Health export.zip file", "choose": "Choose file", - "idempotencyNote": "Re-uploading the same archive merges — it won't create duplicates. Large archives are processed in the background.", + "idempotencyNote": "Re-uploading the same archive merges it and creates no duplicates. Large archives are processed in the background.", "uploading": "Uploading…", "uploadFailed": "Upload failed. Please try again.", "rateLimited": "Too many uploads. Please wait a moment and try again.", @@ -5478,7 +5526,7 @@ "failureInterrupted": "The import was interrupted before it finished, usually by a server restart or an out-of-memory stop. Your data is safe; upload the export again to retry.", "failureInsufficientMemory": "The server's memory limit is too low for an archive this size. Ask whoever runs the server to raise the Node.js heap limit (NODE_OPTIONS=--max-old-space-size), then upload the export again.", "rowsImported": "{count} rows imported so far", - "doneSummary": "Done — {imported} of {read} records imported, {skipped} clinical records skipped.", + "doneSummary": "Done: {imported} of {read} records imported, {skipped} clinical records skipped.", "doneNothing": "Nothing was imported from that archive: 0 of {read} records landed.", "writtenByHealthLogOne": "{count} record that HealthLog wrote into Apple Health itself was left out, because it is already in your data.", "writtenByHealthLogOther": "{count} records that HealthLog wrote into Apple Health itself were left out, because they are already in your data.", @@ -5491,12 +5539,12 @@ "parsing": "Reading the export…", "upserting": "Importing records…" }, - "skipUnsupported": "{count} samples skipped — data types HealthLog doesn't import.", + "skipUnsupported": "{count} samples skipped: data types HealthLog doesn't import.", "skipRefused": "{count} samples couldn't be written (unreadable, out of range, or the save failed).", "skipBreakdown": "Show breakdown ({count} types)", "skipBreakdownMore": "+ {count} more types", "cycleSummary": "{samples} cycle samples merged into {days} day logs.", - "cycleModuleOff": "{count} cycle samples skipped — cycle tracking is off for this account.", + "cycleModuleOff": "{count} cycle samples skipped because cycle tracking is off for this account.", "cycleDaysFailed": "{count} cycle days couldn't be written: {reason}" }, "json": { @@ -5581,8 +5629,8 @@ "failed": "The import failed. Nothing was written.", "columnsHeading": "What each column does", "jsonOnly": "JSON only", - "previewLine": "Rows read: {rows} · would import: {queued} · refused: {refused}", - "submittedLine": "Rows read: {rows} · queued for import: {queued}", + "previewLine": "{rows} rows read, {queued} would be imported, {refused} refused", + "submittedLine": "{rows} rows read, {queued} queued for import", "unmatched": "No medication on record matches: {names}", "ambiguous": "Matches more than one medication: {names}", "mirrored": "Mirrored from another app, so no dose can be added: {names}", @@ -5618,18 +5666,59 @@ "tooManyRows": "That file holds more rows than one import can take. Split it and import the parts." }, "detail": "One file covers every medication in it." + }, + "healthConnect": { + "title": "Health Connect export", + "description": "Import the ZIP file the Health Connect app on Android writes when you export your data.", + "choose": "Choose ZIP file", + "uploading": "Uploading…", + "uploadFailed": "The upload failed. Please try again.", + "tooLarge": "The file is larger than this server accepts.", + "doneSummary": "{count} values imported.", + "failed": "The import failed: {reason}", + "phase": { + "queued": "Waiting to start", + "unpacking": "Unpacking the file", + "parsing": "Reading the records", + "upserting": "Saving the values", + "done": "Finished", + "failed": "Failed" + }, + "steps": { + "export": "On your Android phone, open Health Connect, go to Manage data, then Export and import, and start an export.", + "cloud": "Health Connect only saves the export to a cloud storage app such as Google Drive, Dropbox or Nextcloud, never to the phone itself. Pick one there.", + "upload": "Download the ZIP file from that cloud to this device and upload it here." + }, + "dropLabel": "Drop the Health Connect ZIP here", + "dropHint": "or click to choose it. Up to 1 GB.", + "fileInputLabel": "Health Connect export ZIP file", + "privacyNote": "The file is deleted from the server as soon as the import ends. Records from apps you already connect directly, such as Withings or Fitbit, are left out so nothing arrives twice. Uploading the same file again changes nothing.", + "rateLimited": "Too many uploads in a short time. Wait a minute and try again.", + "busy": "An import is already running for your account. Wait until it has finished.", + "rowsSoFar": "{count} values saved so far", + "doneNothing": "The import finished, but there was nothing new to add.", + "workouts": "Workouts", + "leftOutConnected": "{count} records from apps you already connect directly were left out.", + "nutrientsModuleOff": "Water and nutrient totals were not imported because the Nutrients module is switched off.", + "cycleModuleOff": "Period data was not imported because cycle tracking is switched off.", + "failure": { + "unsupportedVersion": "This Health Connect export is too old to read. Update Health Connect on the phone and export again.", + "notHealthConnect": "This file is not a Health Connect export. Choose the ZIP file Health Connect saved.", + "unsafeSchema": "This file does not have the structure of a Health Connect export and was not read.", + "tooLarge": "The export is larger than this server accepts.", + "stagingMissing": "The uploaded file was no longer on the server. Upload it again.", + "interrupted": "The import was interrupted by a server restart. Upload the file again." + } } }, "subtitle": "Export your data or import from a backup." }, "about": { "title": "About this app", - "description": "Version, license, links.", "subtitle": "Version, license, and project links." }, "sharing": { "title": "Sharing", - "description": "Create a read-only link to share your health record with a clinician.", "subtitle": "Share read-only access to your data." }, "insights": { @@ -5656,7 +5745,7 @@ "error": "Couldn't save. Please try again.", "toggleable": { "title": "What you track", - "description": "Disabled modules drop out of the navigation, tiles, and insights — your data stays untouched." + "description": "Disabled modules drop out of the navigation, tiles and insights, while your data stays untouched." }, "manageIn": "Manage in {section}", "operatorDisabled": "Disabled server-wide", @@ -5664,12 +5753,10 @@ }, "gesundheitsakte": { "title": "Health record", - "description": "Export your full health record as PDF, FHIR R4, or a combined ZIP package.", "subtitle": "Export a complete, clinician-ready health record." }, "coach": { "title": "Coach", - "description": "Enable the Coach, tone and scope, stored memories.", "subtitle": "Tune the Coach, its proactive nudges, and its memory." }, "security": { @@ -5677,18 +5764,16 @@ "subtitle": "Two-factor authentication, recovery codes, and passkeys." }, "privacy": { - "title": "Data & Privacy", - "subtitle": "Your data, retention, encryption, and account security in one place.", - "description": "Export, deletion, retention, encryption status, sessions, and security activity." + "title": "Data & privacy", + "subtitle": "Your data, retention, encryption, and account security in one place." }, "mcp": { - "title": "MCP Connector", + "title": "MCP connector", "subtitle": "Connect an external assistant to your own health data." }, "anamnesis": { "title": "Medical history", - "subtitle": "Manage your allergies and family history.", - "description": "Structured allergies and family history records." + "subtitle": "Your medical background, lifestyle, emergency details and what the AI may read." }, "environment": { "title": "Location & weather", @@ -5710,19 +5795,20 @@ "storedDays": "{count} days of weather stored.", "home": { "title": "Home location", - "description": "A coarse, city-level location — never a precise track. Weather and daylight are fetched for this place on days no location period applies, from the date you set it onward.", + "description": "A coarse, city-level location, never a precise track. Weather and daylight are fetched for this place on days no location period applies, from the date you set it onward.", "current": "Current: {label}", "none": "No home location set yet.", "since": "Weather tracked from {date} onward. Earlier days stay empty until you add a location period." }, "travel": { "title": "Location periods", - "description": "Add a place and a date range — a trip, or a stretch of earlier history. The period overrides your home for the days it covers, so weather matches where you actually were. This is how you correct the past.", + "description": "Add a place and a date range, such as a trip or a stretch of earlier history. The period overrides your home for the days it covers, so weather matches where you actually were. This is how you correct the past.", "none": "No location periods yet.", "add": "Add {label}", "remove": "Remove location period", "removeConfirmTitle": "Remove this trip?", - "removeConfirmBody": "Readings taken during it stay in your record; only the trip entry goes." + "removeConfirmBody": "Readings taken during it stay in your record; only the trip entry goes.", + "range": "{start} to {end}" }, "backfill": { "title": "Backfill past days", @@ -5732,10 +5818,24 @@ "noResults": "No matching places found.", "fetchFailedOne": "The last background weather update failed ({when}).", "fetchFailedFew": "The last {count} background weather updates failed, most recently {when}.", - "fetchFailedOther": "The last {count} background weather updates failed, most recently {when}." + "fetchFailedOther": "The last {count} background weather updates failed, most recently {when}.", + "airQuality": { + "title": "Air quality and pollen", + "description": "Daily air quality, pollen and UV for your location, from the CAMS model via Open-Meteo.", + "toggle": "Include air quality and pollen", + "operatorDisabled": "Air quality is turned off on this server.", + "coverage": "{count} days with air-quality data", + "pollenEuropeOnly": "Pollen data covers Europe only.", + "historyProgress": "Backfilling air quality: {done} of {total} days", + "historyComplete": "Air quality is filled in for every earlier day with entries that the source covers ({total} days). Older days stay empty.", + "attribution": "Data sources and licences", + "saveError": "The setting could not be saved.", + "latest": "Newest day ({date})", + "modelNote": "Modelled outdoor values for your area, not your personal exposure." + } }, "score": { - "title": "Health Score", + "title": "Health score", "subtitle": "Choose which pillars count toward your score.", "card": { "title": "What counts toward your score", @@ -5765,7 +5865,7 @@ "omitted": "{count} pillars are not listed because their tracking is switched off. Turn a module back on and its pillar comes back.", "omittedLink": "Open modules", "refusal": { - "empty": "A health score needs at least one area of health to score. Keep one selected — the score will say which areas it is built from." + "empty": "A health score needs at least one area of health to score. Keep one selected; the score will say which areas it is built from." }, "notice": { "upgrade": { @@ -5859,7 +5959,6 @@ }, "changePassword": "Change password", "changePasswordDescription": "Replace your current password with a new one.", - "passwordReset": "Password reset", "currentPassword": "Current password", "newPassword": "New password", "confirmNewPassword": "Confirm new password", @@ -5904,9 +6003,9 @@ "botToken": "Bot Token", "chatId": "Chat ID", "enableNotifications": "Enable notifications", - "telegramStep1": "1. Create a bot via @BotFather in Telegram and copy the token.", - "telegramStep2": "2. Send /start to your bot to activate the chat.", - "telegramStep3": "3. Find your Chat ID via @userinfobot or the Bot API.", + "telegramStep1": "Create a bot via @BotFather in Telegram and copy the token.", + "telegramStep2": "Send /start to your bot to activate the chat.", + "telegramStep3": "Find your Chat ID via @userinfobot or the Bot API.", "ntfy": "ntfy", "ntfyDescription": "Notifications via ntfy (self-hosted or ntfy.sh).", "ntfyEnable": "Enable ntfy", @@ -5915,7 +6014,7 @@ "ntfyAuthToken": "Auth Token (optional)", "ntfyAuthTokenHint": "Only needed for private topics with access control.", "webhook": "Webhook", - "webhookDescription": "POST notifications to any URL — Gotify, Discord, Slack, Matrix, or Home Assistant.", + "webhookDescription": "POST notifications to any URL: Discord, Slack, Gotify, Home Assistant, n8n, or any relay that takes JSON.", "webhookEnable": "Enable webhook", "webhookUrl": "Webhook URL", "webhookUrlHint": "The full URL to POST to. An address on a private network needs approval from the server operator.", @@ -5923,6 +6022,7 @@ "webhookHeaderValue": "Header value (optional)", "webhookFormat": "Payload format", "webhookFormatGeneric": "Generic JSON", + "webhookFormatGenericHint": "Paste a Discord or Slack webhook URL as it is. Home Assistant and n8n read the fields title, message, eventType and priority.", "webhookFormatGotify": "Gotify", "webhookFormatGotifyHint": "Use the URL ending in /message and put the application token in the header X-Gotify-Key.", "email": "Email", @@ -5939,7 +6039,6 @@ "webPushSubscribed": "Push notifications enabled!", "webPushUnsubscribed": "Push notifications disabled.", "webPushSubscribeFailed": "Activation failed", - "kiInsightsDescription": "Optional: Save your OpenAI key for daily, automatic evaluations in Insights.", "codexConnected": "ChatGPT connected successfully! Insights are now active.", "codexDisconnected": "ChatGPT connection removed.", "codexConnectionFailed": "ChatGPT connection failed. Please try again.", @@ -5949,7 +6048,7 @@ "rawDataWarning": "Detailed history is on: every analysis also sends daily averages for the last 90 days, weekly averages for the year before, and monthly averages back to 5 years.", "regenerateInsights": "Regenerate reports", "regenerateSuccess": "Reports regenerated successfully", - "regenerateRateLimit": "Please wait — you've hit the hourly limit for analyses.", + "regenerateRateLimit": "Please wait: you've hit the hourly limit for analyses.", "lastGeneratedAt": "Last generated", "ai": { "chatgptConnectedBadge": "ChatGPT connected", @@ -5959,11 +6058,11 @@ "deviceCodeHeading": "Finish connecting on chatgpt.com", "deviceCodeStep1": "Open this link on any device:", "deviceCodeStep2": "Enter this one-time code:", - "deviceCodeStep3": "Approve the connection — this page updates automatically.", + "deviceCodeStep3": "Approve the connection. This page updates automatically.", "deviceCodeCopy": "copy", "deviceCodeWaiting": "Waiting for approval…", "deviceCodeCancel": "Cancel", - "oauthNotConfigured": "ChatGPT OAuth is not configured on this instance — use your own API key below instead.", + "oauthNotConfigured": "ChatGPT OAuth is not configured on this instance. Use your own API key below instead.", "modelLabel": "Model", "modelOptionDefault": "— Default —", "modelOptionCustom": "Custom…", @@ -5971,7 +6070,7 @@ "anthropicKeyLabel": "Anthropic API key", "baseUrlLabel": "Base URL", "localKeyLabel": "API key (optional)", - "localDescription": "Any OpenAI-compatible endpoint works here — a local model (Ollama, LM Studio) as well as a gateway such as LiteLLM, OpenRouter or vLLM. The base URL ends with /v1; the API key is optional.", + "localDescription": "Any OpenAI-compatible endpoint works here: a local model (Ollama, LM Studio) as well as a gateway such as LiteLLM, OpenRouter or vLLM. The base URL ends with /v1; the API key is optional.", "savedPreview": "(saved {preview})", "savedShort": "(saved)", "saveCta": "Save", @@ -5987,7 +6086,7 @@ "admin-openai": "Admin OpenAI" }, "title": "Fallback chain", - "description": "If the primary provider fails, HealthLog walks the chain in order. Drag the rows to reorder, toggle the switch to disable a provider without removing it.", + "description": "If the active provider fails, HealthLog tries the next enabled one in this order.", "moveUp": "Move up", "moveDown": "Move down", "removeFromChain": "Remove from chain", @@ -6000,11 +6099,8 @@ "resetConfirmTitle": "Reset chain to defaults?", "resetConfirmBody": "The chain reverts to Codex → OpenAI → Anthropic → Local → Admin OpenAI. Your saved credentials are not touched." }, - "providerCardTitle": "AI provider", "activeProviderHeading": "Active provider", - "activeProviderBody": "Pick the provider you want to use first. The form below configures only the selected provider; the fallback chain at the bottom decides what happens if it fails.", "activeProviderLabel": "Primary provider", - "providerConfigTitle": "Provider configuration", "providerSelect": { "codex": "ChatGPT account (Codex)", "openai": "OpenAI (your API key)", @@ -6014,7 +6110,7 @@ "admin-openai": "Admin-provided OpenAI" }, "compat": { - "description": "A first-class provider for any endpoint that speaks the OpenAI /v1/chat/completions API: LiteLLM, OpenRouter, vLLM or another gateway. The base URL ends with /v1, the API key is optional, and the model is whatever your gateway routes. Your OpenAI key never reaches this endpoint — this provider has its own.", + "description": "A first-class provider for any endpoint that speaks the OpenAI /v1/chat/completions API: LiteLLM, OpenRouter, vLLM or another gateway. The base URL ends with /v1, the API key is optional, and the model is whatever your gateway routes. Your OpenAI key never reaches this endpoint; this provider has its own.", "keyLabel": "API key (optional)", "modelHint": "The model name your gateway expects. Left blank, the model saved for your other providers is used; with neither set, this provider is skipped." }, @@ -6050,18 +6146,18 @@ "notConfigured": "The operator has not configured a shared OpenAI key on this instance." }, "testProvider": "Test active provider", - "testSuccess": "OK — {provider} ({model})", + "testSuccess": "OK: {provider} ({model})", "testFailedShort": "Test failed: {message}", - "testUnexpectedResponse": "AI provider connection failed — unexpected response from the server.", - "testReasonCredentials": "Provider rejected the credentials — re-authenticate in AI settings.", - "testReasonRateLimited": "Provider rate-limited the request — try again shortly.", + "testUnexpectedResponse": "AI provider connection failed: unexpected response from the server.", + "testReasonCredentials": "Provider rejected the credentials. Re-authenticate in AI settings.", + "testReasonRateLimited": "Provider rate-limited the request. Try again shortly.", "testReasonServerError": "The AI provider returned a server error.", - "testReasonBadRequest": "The provider rejected the request (HTTP {status}) — the endpoint answered, so this is a request-shape or model-name problem, not connectivity.", + "testReasonBadRequest": "The provider rejected the request (HTTP {status}). The endpoint answered, so this is a request-shape or model-name problem, not connectivity.", "testReasonUnreachable": "Could not reach the AI provider.", "coachMemory": { "title": "What the Coach remembers", "description": "Durable facts you have told the Coach.", - "empty": "The Coach has not stored any facts yet. As you chat, it remembers durable details you share — your goals, conditions, preferences — and you can clear them here at any time.", + "empty": "The Coach has not stored any facts yet. As you chat, it remembers durable details you share (your goals, conditions, preferences), and you can clear them here at any time.", "learnedPrefix": "learned {when}", "categoryPreference": "Preferences", "categoryCondition": "Conditions", @@ -6077,7 +6173,7 @@ "forgetAllConfirmBody": "This removes every stored fact. The Coach will start fresh the next time you chat. This cannot be undone.", "forgetAllConfirmAction": "Forget everything", "cancel": "Cancel", - "summaryNote": "The Coach may also keep a short rolling summary of each conversation. There is no per-item control for it — deleting a conversation removes its summary.", + "summaryNote": "The Coach may also keep a short rolling summary of each conversation. There is no per-item control for it; deleting a conversation removes its summary.", "loadError": "Could not load what the Coach remembers.", "forgotToast": "Fact forgotten.", "forgotError": "Could not forget that fact. Please try again.", @@ -6098,7 +6194,7 @@ "markDone": "Done", "dismiss": "Dismiss", "deleteAria": "Delete this reminder", - "note": "Reminders are stored encrypted. Delete one any time — it is removed from what the Coach can see.", + "note": "Reminders are stored encrypted. Delete one any time and it is removed from what the Coach can see.", "status": { "proposed": "Suggested", "active": "Saved", @@ -6143,21 +6239,21 @@ "responseTimeoutInvalid": "Enter a number between 10 and 600 seconds.", "autoRead": { "title": "Read documents automatically with AI", - "subLabel": "Documents are read and indexed by your configured AI provider without a per-document tap.", - "honesty": "When on, each document you upload is sent to your configured AI provider to be read, described, and indexed for search — with no per-document confirmation. Turning it on also reaches backwards: documents you have already uploaded that carry no summary yet are sent too, up to 200 each time you switch it on. This happens on the server and the document's contents leave this machine to that provider. If your configured provider is a subscription connection (a signed-in AI account rather than an API key), that provider's own data settings apply — subscription providers may use the content to improve their models, and no data-processing agreement covers it. Documents read by a self-hosted local model never leave your server. Off by default; turn it on only if you accept this trade for your documents.", - "confirm": "I understand — turn on", + "subLabel": "Your AI provider reads and indexes each new document without a tap per document.", + "honesty": "When on, each document you upload is sent to your configured AI provider to be read, described, and indexed for search, with no per-document confirmation. Turning it on also reaches backwards: documents you have already uploaded that carry no summary yet are sent too, up to 200 each time you switch it on. This happens on the server and the document's contents leave this machine to that provider. If your configured provider is a subscription connection (a signed-in AI account rather than an API key), that provider's own data settings apply: subscription providers may use the content to improve their models, and no data-processing agreement covers it. Documents read by a self-hosted local model never leave your server. Off by default; turn it on only if you accept this trade for your documents.", + "confirm": "I understand, turn on", "cancel": "Cancel" }, "centralCodex": { "title": "Use the server's shared AI access", - "subLabel": "Route AI requests through the connection the operator set up for this server, alongside your own providers as a fallback.", - "honesty": "The shared access is a single signed-in AI account the operator connected for everyone on this server — not your own key. Requests you make on it are trained on by the provider by default and no data-processing agreement covers them, the account's hourly and weekly limits are shared with every other user who opted in, and your own configured providers are still tried first. Turn it on only if you accept this trade.", - "confirm": "I understand — use connection", + "subLabel": "Send AI requests through the server's shared connection, with your own providers as the fallback.", + "honesty": "The shared access is a single signed-in AI account the operator connected for everyone on this server, not your own key. Requests you make on it are trained on by the provider by default and no data-processing agreement covers them, the account's hourly and weekly limits are shared with every other user who opted in, and your own configured providers are still tried first. Turn it on only if you accept this trade.", + "confirm": "I understand, use connection", "cancel": "Cancel" }, "consent": { "title": "AI consent", - "activeSince": "Given on {date}. Your health data may be sent to the configured provider for the AI features.", + "activeSince": "Given on {date}: your health data may be sent to the configured provider for the AI features.", "withdrawn": "Withdrawn. Unless another consent covers them, the Coach and Insights no longer use the AI access this server provides, and no online model reads your documents. On your own key or a local model, the Coach and Insights keep working.", "withdraw": "Withdraw consent", "confirmBody": "Unless another consent you gave still covers them, these stop straight away: the Coach and Insights where they use the AI access this server provides, and reading documents with an online model. On your own key or a local model, the Coach and Insights keep working. Unless another consent still covers Insights, the notes a model wrote for you are deleted: status notes, the daily briefing, model-written period summaries, arrival lines and workout notes. Your Coach conversations, what the Coach remembered, your plans and your document summaries stay, and you can read or delete them yourself.", @@ -6168,7 +6264,7 @@ "error": "The withdrawal could not be saved. Please try again.", "documentReading": { "title": "Consent to reading documents", - "activeSince": "Given on {date}. Documents, lab report scans and medication descriptions may be sent to your AI service for reading.", + "activeSince": "Given on {date}: documents, lab scans and medication text may go to your AI service for reading.", "confirmBody": "Documents, lab report scans and medication descriptions are no longer sent for reading. Nothing you already have is deleted." } }, @@ -6202,7 +6298,9 @@ "high": "High" }, "coachNote": "In the Coach, the thinking depth from the Coach settings applies." - } + }, + "providerCardDescription": "The provider HealthLog asks first, and its key and model.", + "runtimeTitle": "Check and refresh" }, "dashboard": { "units": { @@ -6226,8 +6324,8 @@ "withingsClientId": "Client ID", "withingsClientSecret": "Client Secret", "withingsCredentialsSaved": "Credentials saved", - "withingsCredentialsSavedPlaceholder": "Saved — enter new to replace", - "withingsCredentialsSavedPlaceholderSecret": "Saved — enter new to replace", + "withingsCredentialsSavedPlaceholder": "Saved. Enter new to replace", + "withingsCredentialsSavedPlaceholderSecret": "Saved. Enter new to replace", "withingsSaveCredentials": "Save", "configured": "Configured", "withingsSync": "Sync now", @@ -6242,7 +6340,7 @@ }, "withingsFullSyncResult": "{count} measurements fully synchronized", "withingsSyncFailed": "Sync failed", - "withingsOauthConnected": "Withings connected — the first sync starts in the background.", + "withingsOauthConnected": "Withings connected. The first sync starts in the background.", "withingsOauthFailed": "Withings connection failed", "withingsOauthError": { "csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.", @@ -6267,8 +6365,8 @@ "whoopClientId": "Client ID", "whoopClientSecret": "Client Secret", "whoopCredentialsSaved": "Credentials saved", - "whoopCredentialsSavedPlaceholder": "Saved — enter new to replace", - "whoopCredentialsSavedPlaceholderSecret": "Saved — enter new to replace", + "whoopCredentialsSavedPlaceholder": "Saved. Enter new to replace", + "whoopCredentialsSavedPlaceholderSecret": "Saved. Enter new to replace", "whoopSaveCredentials": "Save", "whoopSync": "Sync now", "whoopFullSync": "Sync all data", @@ -6283,7 +6381,7 @@ "whoopConnect": "Connect with WHOOP", "whoopNoCredentials": "Please enter your API credentials above to connect WHOOP.", "whoopBackfillInProgress": "Importing your WHOOP history in the background…", - "whoopOauthConnected": "WHOOP connected — the first sync starts in the background.", + "whoopOauthConnected": "WHOOP connected. The first sync starts in the background.", "whoopOauthFailed": "WHOOP connection failed", "whoopOauthError": { "csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.", @@ -6297,14 +6395,14 @@ }, "fitbit": "Fitbit", "fitbitTag": "Fitbit & Pixel", - "fitbitDescription": "Connect Fitbit to sync your Fitbit and Pixel Watch vitals — weight, heart rate, and more.", + "fitbitDescription": "Connect Fitbit to sync your Fitbit and Pixel Watch vitals: weight, heart rate, and more.", "fitbitExperimentalBadge": "Experimental", "fitbitCredentials": "API Credentials", "fitbitClientId": "Client ID", "fitbitClientSecret": "Client Secret", "fitbitCredentialsSaved": "Credentials saved", - "fitbitCredentialsSavedPlaceholder": "Saved — enter new to replace", - "fitbitCredentialsSavedPlaceholderSecret": "Saved — enter new to replace", + "fitbitCredentialsSavedPlaceholder": "Saved. Enter new to replace", + "fitbitCredentialsSavedPlaceholderSecret": "Saved. Enter new to replace", "fitbitSaveCredentials": "Save", "fitbitSync": "Sync now", "fitbitFullSync": "Sync all data", @@ -6319,18 +6417,18 @@ "fitbitConnect": "Connect with Fitbit", "fitbitNoCredentials": "Please enter your API credentials above to connect Fitbit.", "fitbitBackfillInProgress": "Importing your Fitbit history in the background…", - "fitbitOauthConnected": "Fitbit connected — the first sync starts in the background.", + "fitbitOauthConnected": "Fitbit connected. The first sync starts in the background.", "fitbitOauthFailed": "Fitbit connection failed", "googleHealth": "Google Health", "googleHealthTag": "Fitbit & Pixel", "googleHealthBetaBadge": "Beta", - "googleHealthDescription": "Connect Fitbit, Pixel Watch and Fitbit Air through your Google account — weight, heart rate, sleep and activity. This replaces the classic Fitbit connection, which Google retires in September 2026.", + "googleHealthDescription": "Connect Fitbit, Pixel Watch and Fitbit Air through your Google account: weight, heart rate, sleep and activity. This replaces the classic Fitbit connection, which Google retires in September 2026.", "googleHealthCredentials": "API Credentials", "googleHealthClientId": "Client ID", "googleHealthClientSecret": "Client Secret", "googleHealthCredentialsSaved": "Credentials saved", - "googleHealthCredentialsSavedPlaceholder": "Saved — enter new to replace", - "googleHealthCredentialsSavedPlaceholderSecret": "Saved — enter new to replace", + "googleHealthCredentialsSavedPlaceholder": "Saved. Enter new to replace", + "googleHealthCredentialsSavedPlaceholderSecret": "Saved. Enter new to replace", "googleHealthSaveCredentials": "Save", "googleHealthSync": "Sync now", "googleHealthSyncCheckStatus": "Refresh status", @@ -6364,9 +6462,9 @@ "googleHealthConnect": "Connect with Google", "googleHealthNoCredentials": "Please enter your API credentials above to connect Google Health.", "googleHealthBackfillInProgress": "Importing your Google Health history in the background…", - "googleHealthReauthBanner": "Google has expired the connection — this happens periodically while your OAuth app stays in Testing mode. Reconnect to resume syncing.", + "googleHealthReauthBanner": "Google has expired the connection. This happens periodically while your OAuth app stays in Testing mode. Reconnect to resume syncing.", "googleHealthReconnect": "Reconnect", - "googleHealthOauthConnected": "Google Health connected — the first sync starts in the background.", + "googleHealthOauthConnected": "Google Health connected. The first sync starts in the background.", "googleHealthOauthFailed": "Google Health connection failed", "googleHealthOauthError": { "csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.", @@ -6401,10 +6499,10 @@ }, "integrationPill": { "connected": "Connected", - "errorReconnect": "Error — reconnect", + "errorReconnect": "Error: reconnect", "warningServerError": "Sync failing", "failureCount": "{count}/{threshold}", - "parkedReconnect": "Paused — reconnect", + "parkedReconnect": "Paused: reconnect", "notConnected": "Not connected", "justNow": "just now", "minutesAgo": "{count} min ago", @@ -6419,7 +6517,7 @@ }, "integrationFreshness": { "title": "Data received", - "summary": "{count} data types · newest {relative}", + "summary": "{count} data types, newest {relative}", "quiet": "{count} quiet", "workouts": "Workouts" }, @@ -6432,32 +6530,34 @@ "whoopViewData": "View your recovery", "fitbitViewData": "View your sleep", "googleHealthViewData": "View your steps", - "apiTokens": "API Tokens", - "apiTokensDescription": "Review and revoke API tokens for external medication intake.", + "apiTokens": "API tokens", + "apiTokensDescription": "Review and revoke tokens for measurements, workouts, documents and medication intake.", "measurementsToken": { "title": "Measurement tokens", "description": "Mint a token so a scale, a watch bridge or your home automation can add readings.", "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." + "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" }, "workoutsToken": { "title": "Workout tokens", "description": "Mint a token so a watch bridge or a sync script can add your workouts.", "detail": "The token is shown once.", "scopeNote": "It can only add new workouts to your own record. It cannot read your data, change or delete anything, or create another token. Workouts it adds are marked as coming from an external device, and when your phone recorded the same session, you see the phone's copy.", - "createFailed": "The token could not be created. Please try again." + "createFailed": "The token could not be created. Please try again.", + "namePlaceholder": "Name, e.g. Garmin" }, "documentsToken": { "title": "Document tokens", "description": "Mint a token so Paperless-ngx, Papra or the import script can add documents to your vault.", "detail": "The token is shown once.", "scopeNote": "It can only add new documents to your own vault. It cannot list, open, download, change or delete anything, and it cannot create another token. What it gets back is whether a file was stored, not how you filed it.", - "createFailed": "The token could not be created. Please try again." + "createFailed": "The token could not be created. Please try again.", + "namePlaceholder": "Name, e.g. Paperless-ngx" }, - "tokenMintMovedDescription": "Tokens are issued where they apply: above for measurements and documents, or in a medication's own API endpoint for that one medication.", - "tokenNamePlaceholder": "Token name (e.g., iPhone Shortcut)", - "tokenCreated": "Token created — copy it now! It won't be shown again.", + "tokenMintMovedDescription": "Tokens are issued where they apply: above for measurements, workouts and documents, or in a medication's own API endpoint for that one medication.", + "tokenCreated": "Token created. Copy it now! It won't be shown again.", "tokenCopy": "Copy token", "tokenCopied": "Token copied to clipboard", "tokenCopyFailed": "Could not copy the token", @@ -6484,7 +6584,7 @@ "apiEndpointPath": "Path", "apiEndpointAuth": "Authentication", "apiEndpointExample": "Body example", - "dangerZone": "Delete All Data", + "dangerZone": "Delete all data", "dangerZoneDescription": "Erases your whole health record and every integration connection.", "dangerZoneConfirm": "Delete all data?", "dangerZoneConfirmDescription": "This permanently 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. This cannot be undone.", @@ -6496,7 +6596,7 @@ "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.", - "deleteAccountSuccess": "Account deleted — signing you out.", + "deleteAccountSuccess": "Account deleted. Signing you out.", "deleteAccountFailed": "Account could not be deleted", "deleteAccountFinal": "Delete permanently", "saved": "Saved", @@ -6519,7 +6619,7 @@ "testing": "Testing…", "ok": "Connected (latency {latency} ms)", "errors": { - "credentials_rejected": "Credentials rejected — check your token", + "credentials_rejected": "Credentials rejected. Check your token", "rate_limited": "Rate-limited by the upstream", "timeout": "Request timed out", "upstream_error": "Upstream returned an error", @@ -6530,7 +6630,7 @@ "private_origin_not_approved": "Private network target. The operator has to list this origin in NOTIFICATION_PRIVATE_ORIGINS", "private_origin_not_grantable": "This address is link-local, a metadata or an unspecified address; no operator grant can open it. Use the address the relay listens on", "url_invalid": "URL is invalid", - "redirected": "Endpoint redirects — check the URL", + "redirected": "Endpoint redirects. Check the URL", "endpoint_not_found": "Endpoint not found at the URL", "credentials_unreadable": "Credentials cannot be decrypted", "upstream_invalid_json": "Upstream sent invalid JSON", @@ -6541,7 +6641,7 @@ }, "notificationStatus": { "title": "Delivery status", - "description": "State of each channel — “Auto-disabled” means retries stopped after permanent errors.", + "description": "State of each channel; “Auto-disabled” means retries stopped after permanent errors.", "emptyDescription": "Set up a channel under Settings › Integrations › Delivery channels.", "stateActive": "Active", "stateAutoDisabled": "Auto-disabled", @@ -6567,7 +6667,7 @@ }, "healthRecord": { "title": "Export health record", - "description": "A complete record for your doctor — as a PDF, as FHIR, or both.", + "description": "A complete record for your doctor: as a PDF, as FHIR, or both.", "format": "Format", "format_pdf": "PDF", "format_fhir": "Structured (FHIR)", @@ -6599,7 +6699,7 @@ "createTitle": "New share link", "createDescription": "The link is read-only and time-boxed.", "label": "Label", - "labelPlaceholder": "e.g. Dr. Smith — annual check-up", + "labelPlaceholder": "e.g. Dr. Smith, annual check-up", "range": "Days of history", "rangeHint": "How far back the shared window reaches.", "expiry": "Expires in (days)", @@ -6619,7 +6719,7 @@ "statusRevoked": "Revoked", "statusExpired": "Expired", "inactiveTitle": "Revoked and expired ({count})", - "shownOnce": "Shown once only. Capture the QR code and passphrase now — neither can be recovered later.", + "shownOnce": "Shown once only. Capture the QR code and passphrase now; neither can be recovered later.", "qrAlt": "QR code containing the share link and passphrase", "linkLabel": "Link", "passphraseLabel": "Passphrase", @@ -6630,12 +6730,12 @@ "attachTitle": "Attach documents", "attachButton": "Choose documents", "attachCount": "{count} of {max} selected", - "attachFrozen": "The document set is fixed once you create the link — documents can't be added or removed afterwards.", + "attachFrozen": "The document set is fixed once you create the link. Documents can't be added or removed afterwards.", "attachRemove": "Remove {title}", "exifNote": "Camera metadata (EXIF/GPS) is removed from JPEG, PNG and WebP images when shared. PDF, TIFF, HEIC and Office files pass through unchanged.", "documentCount": "Attached documents: {count}", "qrScanTitle": "Scan to open on another device", - "qrScanHint": "Point a phone camera at the code to open the shared record — the passphrase is already included.", + "qrScanHint": "Point a phone camera at the code to open the shared record. The passphrase is already included.", "pickerTitle": "Attach documents", "pickerDescription": "Search your vault and select documents to share. Up to {max}.", "pickerSearch": "Search documents", @@ -6648,7 +6748,8 @@ "statusNeedsReselect": "Pick contents again", "reselectReason": "This link served a scope that was never chosen, so it was closed. Create a new one and pick what it should contain.", "reselectAction": "Create a replacement", - "createDetail": "The token is shown once on creation — store it then; it cannot be recovered." + "createDetail": "The token is shown once on creation. Store it then; it cannot be recovered.", + "activeDescription": "Links you have shared, when they expire and how often they were opened." }, "genderOther": "Other", "nightscout": "Nightscout", @@ -6656,10 +6757,10 @@ "nightscoutDescription": "Connect your own Nightscout site to sync continuous glucose readings into the glucose panel.", "nightscoutViewData": "View your glucose", "nightscoutUrl": "Nightscout URL", - "nightscoutUrlSavedPlaceholder": "Saved — enter a new URL to replace it", + "nightscoutUrlSavedPlaceholder": "Saved. Enter a new URL to replace it", "nightscoutToken": "API token", "nightscoutTokenOptional": "Optional for a public site", - "nightscoutTokenSavedPlaceholder": "Saved — enter a new token to replace it", + "nightscoutTokenSavedPlaceholder": "Saved. Enter a new token to replace it", "nightscoutTokenHelp": "Paste an access token from your Nightscout admin tools. Leave empty if your site is publicly readable.", "nightscoutPrivateOperatorTitle": "Private Nightscout access requires server operator approval.", "nightscoutPrivateOperatorDescription": "Ask your server operator to approve this exact Nightscout origin.", @@ -6680,7 +6781,7 @@ "polarClientId": "Client ID", "polarClientSecret": "Client Secret", "polarCredentialsSaved": "Credentials saved", - "polarCredentialsSavedPlaceholder": "Saved — enter new to replace", + "polarCredentialsSavedPlaceholder": "Saved. Enter new to replace", "polarSaveCredentials": "Save", "polarUnavailable": "Polar is not configured on this server. Add your own Polar OAuth credentials above, or ask your administrator to configure the shared app.", "polarConnect": "Connect Polar", @@ -6690,7 +6791,7 @@ "polarDisconnect": "Disconnect", "polarDisconnectTitle": "Disconnect Polar?", "polarDisconnectDescription": "Future syncs stop. Readings already imported stay in your history.", - "polarOauthConnected": "Polar connected — the first sync starts in the background.", + "polarOauthConnected": "Polar connected. The first sync starts in the background.", "polarOauthFailed": "Polar connection failed", "polarOauthError": { "csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.", @@ -6709,7 +6810,7 @@ "ouraClientId": "Client ID", "ouraClientSecret": "Client Secret", "ouraCredentialsSaved": "Credentials saved", - "ouraCredentialsSavedPlaceholder": "Saved — enter new to replace", + "ouraCredentialsSavedPlaceholder": "Saved. Enter new to replace", "ouraSaveCredentials": "Save", "ouraUnavailable": "Oura is not configured on this server. Add your own Oura OAuth credentials above, or ask your administrator to configure the shared app.", "ouraConnect": "Connect Oura", @@ -6719,7 +6820,7 @@ "ouraDisconnect": "Disconnect", "ouraDisconnectTitle": "Disconnect Oura?", "ouraDisconnectDescription": "Future syncs stop. Readings already imported stay in your history.", - "ouraOauthConnected": "Oura connected — the first sync starts in the background.", + "ouraOauthConnected": "Oura connected. The first sync starts in the background.", "ouraOauthFailed": "Oura connection failed", "ouraOauthError": { "csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.", @@ -6750,7 +6851,7 @@ } }, "integrationRedirectGuide": { - "intro": "This connects through your own {provider} developer app — about 5 minutes to set up:", + "intro": "This connects through your own {provider} developer app, about 5 minutes to set up:", "step1": "Create an app in the {provider} developer portal.", "step2": "Set its redirect / callback URL to:", "step3": "Paste the client ID and client secret it gives you above." @@ -6810,7 +6911,7 @@ }, "recovery": { "saveNow": "Save these recovery codes now", - "saveNowHint": "Each code works once. They are shown only this time — store them somewhere safe. If you lose your authenticator, a recovery code is the only way back in.", + "saveNowHint": "Each code works once. They are shown only this time. Store them somewhere safe. If you lose your authenticator, a recovery code is the only way back in.", "download": "Download", "remainingOne": "{count} recovery code remaining.", "remainingOther": "{count} recovery codes remaining.", @@ -6838,7 +6939,7 @@ }, "passkeyNudge": { "title": "Add a passkey for faster sign-in", - "body": "Passkeys let you sign in with your fingerprint, face, or device PIN — no password to remember." + "body": "Passkeys let you sign in with your fingerprint, face, or device PIN, no password to remember." }, "trustedDevices": { "title": "Trusted devices", @@ -6874,12 +6975,11 @@ }, "stored": { "title": "What's stored", - "measurements": "Measurements (weight, blood pressure, pulse, glucose, sleep, body composition)", - "medications": "Medications and intake history", - "moodLabs": "Mood entries and lab results", - "coach": "Coach conversations", + "measurements": "Measurements: weight, blood pressure, pulse and every other value you or a device record", "integrations": "Integration tokens (encrypted)", - "security": "Account and security activity (sessions, sign-in log)" + "security": "Account and security activity (sessions, sign-in log)", + "profile": "Your profile, medical history and health-record details", + "modules": "Records of each module: {list}" }, "retention": { "title": "Retention", @@ -6910,13 +7010,11 @@ "mcp": { "enableTitle": "Remote MCP endpoint", "enableDescription": "Let an MCP-capable assistant (Claude.ai, ChatGPT) read your health data over a scoped token.", - "enabled": "Enabled", - "disabled": "Disabled", "enableToggleLabel": "Enable the MCP endpoint", "endpointLabel": "Connector URL", "connectDescription": "Paste this URL into your assistant's custom-connector setup, then authorize with the token below or sign in when prompted.", "tokensTitle": "Connector tokens", - "tokensDescription": "Mint a token to connect an assistant — read-only unless you allow logging below.", + "tokensDescription": "Mint a token to connect an assistant, read-only unless you allow logging below.", "scopeNote": "New tokens can only read your own records. Write access (health:write) is granted per token with the toggle below. A token can never reach admin functions.", "writeScopeToggleLabel": "Allow logging (health:write)", "writeScopeNote": "Adds the health:write scope so the assistant can log new measurements and mood entries to your account. Every write is confirmed first. The token still can't delete or edit existing entries, change medications, or reach REST or admin functions. Leave off for read-only access.", @@ -6930,7 +7028,8 @@ "connectionRevokeDescription": "This ends the assistant's access immediately and stops it from renewing. It will need to be re-authorized to reconnect.", "connectionRevokeFailed": "Could not disconnect the assistant.", "connectionsDetail": "Revoking a connection ends its access immediately, including its ability to refresh.", - "tokensDetail": "The token is shown once." + "tokensDetail": "The token is shown once.", + "tokenNamePlaceholder": "Name, e.g. Claude Desktop" }, "strava": "Strava", "stravaDescription": "Connect your Strava account to sync your activities into your workout history.", @@ -6939,7 +7038,7 @@ "stravaClientId": "Client ID", "stravaClientSecret": "Client Secret", "stravaCredentialsSaved": "Credentials saved", - "stravaCredentialsSavedPlaceholder": "Saved — enter new to replace", + "stravaCredentialsSavedPlaceholder": "Saved. Enter new to replace", "stravaSaveCredentials": "Save", "stravaUnavailable": "Strava is not configured on this server. Add your own Strava API credentials above, or ask your administrator to configure the shared app.", "stravaConnect": "Connect Strava", @@ -6949,7 +7048,7 @@ "stravaDisconnect": "Disconnect", "stravaDisconnectTitle": "Disconnect Strava?", "stravaDisconnectDescription": "Future syncs stop. Activities already imported stay in your history.", - "stravaOauthConnected": "Strava connected — the first sync starts in the background.", + "stravaOauthConnected": "Strava connected. The first sync starts in the background.", "stravaOauthFailed": "Strava connection failed", "stravaOauthError": { "csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.", @@ -6977,26 +7076,33 @@ "title": "Sync progress", "receivedLabel": "Records received", "oldestLabel": "Oldest reading reached", - "flowing": "Data arrived within the last hour — the iPhone app is still delivering.", - "waiting": "Waiting for the iPhone app to send more data. Delivery continues when the app is open or when iOS grants it background time — a quiet stretch during the first big backfill is normal, not an error." + "flowing": "Data arrived within the last hour. The iPhone app is still delivering.", + "waiting": "Waiting for the iPhone app to send more data. Delivery continues when the app is open or when iOS grants it background time. A quiet stretch during the first big backfill is normal, not an error." }, "delivery": { "title": "Delivery", "lastTriggerLabel": "Latest sync arrived", "lastBackgroundLabel": "Last background sync", - "backgroundNever": "Never — every sync so far arrived with the app open", + "backgroundNever": "Never: every sync so far arrived with the app open", "noSyncYet": "No sync has arrived yet, so there is nothing to report here.", "trigger": { "foreground": "with the app open", "background": "in the background", "push": "from a push wake", - "unknown": "trigger not reported by the app" + "unknown": "trigger not reported by the app", + "manual": "from Sync all" } + }, + "freshness": { + "title": "By data type", + "lastReceived": "Last received {relative}", + "lastNewValue": "Last new value {relative}", + "never": "Not received yet" } }, "garminInfo": { "title": "Garmin", - "body": "Garmin has no direct connection here. Connect via Apple Health (iOS) or Google Health Connect (Android). Garmin's proprietary scores — Body Battery, Training Load, HRV Status, VO₂ max — are not shared by Garmin to either platform.", + "body": "Garmin has no direct connection here. Connect via Apple Health (iOS) or Google Health Connect (Android). Garmin's proprietary scores (Body Battery, Training Load, HRV Status, VO₂ max) are not shared by Garmin to either platform.", "docsLink": "Read the guide" }, "glucoseReference": { @@ -7052,6 +7158,12 @@ "description": "This shares or exports your health data. Confirm it's you to continue.", "signInAgain": "Sign in again, then try once more. Your account has no password or passkey to confirm here.", "failed": "That did not work. Check what you entered and try again." + }, + "passwordTitle": "Password", + "tokenNameLabel": "Token name", + "displayCard": { + "title": "Display", + "description": "Language, units and formats, saved as soon as you change them." } }, "admin": { @@ -7071,27 +7183,27 @@ "loadingStatus": "Loading status…", "systemStatusLoadError": "Failed to load system status.", "systemStatusRetry": "Retry", - "adminSettingsLoadError": "Failed to load admin settings. Toggles below may not reflect the live state — refresh the page.", + "adminSettingsLoadError": "Failed to load admin settings. Toggles below may not reflect the live state. Refresh the page.", "hostMetrics": { "title": "Host load", "load1": "Load (1m)", "memUsedPercent": "Memory", "diskBusyPercent": "Disk I/O", "last2hours": "Last 2 hours", - "empty": "Sampling started — data appears in 1–2 minutes.", + "empty": "Sampling started. Data appears in 1–2 minutes.", "loadError": "Failed to load host metrics. Try refreshing the page." }, "aiServerKey": { "title": "AI server key", "description": "An operator key for accounts that have not configured one of their own.", - "byokHint": "A user's own key (BYOK, ChatGPT login or local provider) always takes precedence — the server key is the last step in the provider chain.", + "byokHint": "A user's own key (BYOK, ChatGPT login or local provider) always takes precedence; the server key is the last step in the provider chain.", "consentHint": "Requests over the server key require the user's active AI consent; without a consent receipt nothing is sent.", "baseUrlHint": "The base URL selects the API: an Anthropic URL such as https://api.anthropic.com/v1 is understood, any other host is called on the OpenAI-compatible API.", "statusConfigured": "Configured", "statusNotConfigured": "Not configured", "keyLabel": "API key", "keyPlaceholder": "sk-...", - "keyPlaceholderSet": "Stored ({preview}) — enter a new key to replace it", + "keyPlaceholderSet": "Stored ({preview}). Enter a new key to replace it", "modelLabel": "Model", "baseUrlLabel": "Base URL", "save": "Save", @@ -7125,13 +7237,16 @@ "typeCodex": "ChatGPT sign-in (personal)", "typeLocal": "Local model (personal)", "typeGateway": "OpenAI-compatible gateway (personal)", - "spendToday": "Today ({date}): {total} tokens, {operator} of them on the operator's own key." + "spendToday": "Today ({date}): {total} tokens, {operator} of them on the operator's own key.", + "statusPaused": "Paused", + "pausedHint": "Paused after repeated failures for {count} users until {at}. It is tried again automatically then.", + "modelNotServed": "The endpoint no longer offers the model {model}. Choose a model it lists in the AI settings." }, "aiQuality": { "title": "Insights Quality", "loading": "Loading feedback summary…", "loadError": "Failed to load Insights quality summary. Try refreshing the page.", - "empty": "No feedback yet. The aggregator runs daily at 04:00 — once users start rating recommendations, helpful-rate bands appear here.", + "empty": "No feedback yet. The aggregator runs daily at 04:00. Once users start rating recommendations, helpful-rate bands appear here.", "windowLabel": "Last {days} days", "generatedAtLabel": "Generated", "colSeverity": "Severity", @@ -7146,7 +7261,7 @@ "title": "Coach Feedback", "loading": "Loading Coach feedback summary…", "loadError": "Failed to load Coach feedback summary. Try refreshing the page.", - "empty": "No Coach feedback yet. The aggregator runs daily at 04:00 — once users rate Coach replies, helpful-rate bands appear here.", + "empty": "No Coach feedback yet. The aggregator runs daily at 04:00. Once users rate Coach replies, helpful-rate bands appear here.", "windowLabel": "Last {days} days", "generatedAtLabel": "Generated", "colPromptVersion": "Prompt version", @@ -7161,17 +7276,17 @@ "workerRunning": "Running", "workerStopped": "Stopped", "lastReminderCheck": "Last Reminder Check", - "appSettings": "Application Settings", + "appSettings": "Application settings", "appSettingsDescription": "Global defaults for registration, language, and the timezone of new accounts.", "registrationEnabled": "Allow registration", "registrationEnabledDescription": "When disabled, new users cannot register.", - "registrationOpenHint": "Registration is open — anyone who can reach this instance can create an account. Close it here once everyone who needs an account has one.", + "registrationOpenHint": "Registration is open: anyone who can reach this instance can create an account. Close it here once everyone who needs an account has one.", "defaultLanguage": "Default language", "defaultLanguageDescription": "Used for new users and as fallback.", "defaultUserTimezone": "Default timezone for new accounts", - "defaultUserTimezoneDescription": "Used for new accounts that don't carry a browser-detected zone. Does not affect existing users.", + "defaultUserTimezoneDescription": "Used for new accounts without a browser-detected zone; existing accounts keep theirs.", "defaultUserTimezoneFallback": "Europe/Berlin (default)", - "servicesGlobal": "Services (global)", + "servicesGlobal": "Channels and API", "servicesGlobalDescription": "Disabled services are not available to any user.", "telegramGlobal": "Allow Telegram notifications.", "ntfyGlobal": "Allow ntfy notifications.", @@ -7179,19 +7294,21 @@ "integrationWebPush": "Web Push", "offlineGeoLabel": "Geo data", "offlineGeoEnabled": "Offline (GeoLite2)", - "offlineGeoFallback": "Online ({host}) — mount GeoLite2 databases and set GEOLITE2_DIR", + "offlineGeoFallback": "Online ({host}). Mount GeoLite2 databases and set GEOLITE2_DIR", + "offlineGeoOff": "Off", + "offlineGeoChosen": "Online ({host})", "apiGlobal": "Allow API endpoints and API tokens.", "umamiTitle": "Umami", - "umamiDescription": "Web analytics with Umami", + "umamiDescription": "Web analytics with Umami.", "glitchtipTitle": "GlitchTip", - "glitchtipDescription": "Error tracking with GlitchTip", + "glitchtipDescription": "Error tracking with GlitchTip.", "webPushVapidTitle": "Web Push VAPID", - "umamiEnabled": "Enable Umami tracking.", + "umamiEnabled": "Enable Umami tracking", "umamiScriptUrl": "Umami script URL", "umamiScriptUrlPlaceholder": "https://analytics.example.com/script.js", "umamiWebsiteId": "Umami website ID", "umamiWebsiteIdPlaceholder": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "glitchtipEnabled": "Enable GlitchTip error tracking.", + "glitchtipEnabled": "Enable GlitchTip error tracking", "glitchtipDsn": "GlitchTip DSN", "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "GlitchTip environment", @@ -7214,7 +7331,7 @@ "webPushVapidGenerating": "Generating…", "webPushVapidGenerated": "VAPID keys generated. Save the subject to finish.", "webPushVapidGenerateFailed": "Could not generate VAPID keys.", - "webPushVapidGenerateConfirm": "VAPID keys are already configured. Generating a new pair invalidates every browser push subscription — each device must re-subscribe. Continue?", + "webPushVapidGenerateConfirm": "VAPID keys are already configured. Generating a new pair invalidates every browser push subscription, and each device must re-subscribe. Continue?", "webPushVapidGenerateConfirmTitle": "Regenerate VAPID keys?", "webPushVapidRegenerate": "Regenerate", "configured": "Configured", @@ -7253,12 +7370,12 @@ "title": "Module availability", "description": "Turn a module off for the whole server.", "saveError": "Could not update module availability.", - "detail": "A module disabled here is off for every account regardless of personal preference. Core domains (weight, blood pressure, pulse, medications) are always on and cannot be disabled.", - "coachFollowsSwitch": "Follows the Coach switch under Assistant." + "detail": "Weight, blood pressure and pulse are core readings and always stay on.", + "coachFollowsSwitch": "Follows the Coach switch in the Coach section." }, "invites": { - "title": "Invites", - "description": "Registration invite links — they work even while registration is closed.", + "title": "Invite links", + "description": "Each link admits a signup even while registration is closed.", "create": "Create invite", "createTitle": "Create invite", "createDescription": "The link admits a signup even while registration is closed.", @@ -7270,7 +7387,7 @@ "createdToast": "Invite created", "createError": "Could not create the invite.", "tokenTitle": "Invite link", - "tokenOnceHint": "Copyable only now — copy the link or have the QR code scanned.", + "tokenOnceHint": "Copyable only now. Copy the link or have the QR code scanned.", "hashExplainer": "Only a hash of the link is stored, so it cannot be shown again later. If it gets lost, revoke the invite and create a new one.", "copyLink": "Copy link", "copiedToast": "Link copied", @@ -7304,8 +7421,8 @@ "revokedToast": "Invite revoked.", "revokeError": "Could not revoke the invite." }, - "userManagement": "User Management", - "userManagementDescription": "Manage accounts: roles, password resets, and forced logouts.", + "userManagement": "Accounts", + "userManagementDescription": "Roles, password resets and forced logouts for every account.", "userName": "Name", "userEmail": "Email", "userRole": "Role", @@ -7326,20 +7443,19 @@ "passwordReset": "Password has been reset", "reset": "Reset", "loadingUsers": "Loading users…", - "apiTokens": "API Tokens", + "apiTokens": "API tokens", "apiTokensDescription": "All active and revoked API tokens across all accounts.", "tokenUser": "User", "tokenName": "Name", "tokenPermissions": "Permissions", "tokenPermissionAll": "All", - "tokenPermissionAllTooltip": "Wildcard permission — this token can call every API route.", + "tokenPermissionAllTooltip": "Wildcard permission: this token can call every API route.", "tokenStatus": "Status", "tokenLastUsed": "Last used", "tokenCreated": "Created", "tokenNeverUsed": "Never", "tokensEmptyTitle": "No API tokens yet", "tokensEmptyDescription": "Tokens are issued when a native client signs in or via Settings → Account.", - "loginOverview": "Login Overview", "loginOverviewDescription": "Sign-in and auth events across all accounts, filterable and exportable as CSV.", "allAuthEvents": "All auth events", "failedOnly": "Failed only", @@ -7372,6 +7488,7 @@ "finalDelete": "Delete permanently", "deletedResult": "Deleted {rows} rows across {tables} tables. User accounts and sign-in credentials were kept.", "authRegister": "Registration", + "authClaim": "Profile taken over", "authLogin": "Login", "authLoginPasskey": "Passkey login", "authLoginPassword": "Password login", @@ -7382,7 +7499,7 @@ "authTokenAutoissueNative": "Native client token issued", "authTokenRefresh": "Token refreshed", "authTokenRevoke": "Token revoked", - "medicationReminders": "Medication Reminders", + "medicationReminders": "Medication reminders", "medicationRemindersDescription": "Thresholds for reminder badge color transitions on medication cards.", "notificationHealth": { "title": "Notification health", @@ -7437,7 +7554,7 @@ }, "section": { "system-status": { - "title": "System Status", + "title": "System status", "subtitle": "Worker, database, and integration health at a glance." }, "general": { @@ -7446,7 +7563,7 @@ }, "services": { "title": "Services", - "subtitle": "Enable or disable notification channels and integrations system-wide." + "subtitle": "Turn notification channels and the API on or off for the whole server." }, "integrations": { "title": "Integrations", @@ -7483,11 +7600,11 @@ "subtitle": "Mint, track, and revoke registration invite links." }, "api-tokens": { - "title": "API Tokens", + "title": "API tokens", "subtitle": "Active bearer tokens issued to native clients and integrations." }, "login-overview": { - "title": "Login Overview", + "title": "Login overview", "subtitle": "Audit trail of authentication and admin events." }, "auditLog": { @@ -7508,9 +7625,9 @@ "empty": "No audit entries match the current filters." }, "app-logs": { - "title": "App Logs", + "title": "App logs", "subtitle": "Tail the most recent structured wide-events emitted by this process.", - "processNote": "Showing app-process logs only — worker-process logs go to Loki when that integration is configured.", + "processNote": "Showing app-process logs only; worker-process logs go to Loki when that integration is configured.", "filterTraceId": "Trace ID", "filterAction": "Action contains…", "filterLevel": "Level", @@ -7532,7 +7649,8 @@ "refresh": "Refresh", "eventDetails": "Event details", "closeDetails": "Close details", - "processDetail": "Buffer is volatile and capped at 500 entries." + "processDetail": "Buffer is volatile and capped at 500 entries.", + "cardTitle": "Recent events" }, "backups": { "title": "Backups", @@ -7544,7 +7662,7 @@ "loading": "Loading backups…", "loadError": "Failed to load backups.", "emptyTitle": "No backups yet", - "emptyDescription": "Backups run automatically every Sunday at 03:00 — or trigger one now to create the first snapshot.", + "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)", @@ -7580,11 +7698,11 @@ "restoreTitle": "Restore from backup", "restoreDescription": "This will replace ALL current data for {username} with the snapshot from {when}. Existing measurements, medications, intake events, mood entries, notification channels, push subscriptions and queued Telegram deletions will be deleted.", "restoreInstanceSettingsLabel": "Also restore instance settings", - "restoreInstanceSettingsHint": "Registration, MFA requirement, default locale, module availability, notification and AI configuration — taken from the snapshot and applied to everybody on this installation. Leave it clear to restore this account alone.", + "restoreInstanceSettingsHint": "Registration, MFA requirement, default locale, module availability, notification and AI configuration, taken from the snapshot and applied to everybody on this installation. Leave it clear to restore this account alone.", "restorePromptLabel": "Type RESTORE to continue", "previewTitle": "What this backup contains", "previewLoading": "Reading contents…", - "previewUnavailable": "Contents could not be read — restoring is still possible.", + "previewUnavailable": "Contents could not be read. Restoring is still possible.", "previewStillReading": "This backup was made by an earlier version, so its contents are being read in full, which takes a while for a large record. Open this dialog again in a few minutes to see them. Restoring does not depend on it.", "previewEmpty": "The file contains no records.", "previewOther": "Other records", @@ -7627,7 +7745,7 @@ "restoreSkippedMedicationTarget": "Medication target analyte", "restoreSkippedScheduleRevisionLink": "Schedule era succession", "restoreSkippedAccountSetting": "Account setting", - "restoreMissingTitle": "Restore refused — the file is incomplete", + "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.", "restoreMissingDocuments": "Documents", @@ -7699,10 +7817,11 @@ "restoreSkippedCycleCiphertext": "Cycle note or label sealed with a key this server does not hold (day or symptom kept without it)", "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)" + "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)" }, "danger-zone": { - "title": "Danger Zone", + "title": "Danger zone", "subtitle": "Destructive system-wide operations." }, "about": { @@ -7716,7 +7835,6 @@ "encryption": { "title": "Encryption", "subtitle": "Encryption coverage and key-rotation status for data at rest.", - "loading": "Loading encryption status…", "loadError": "Failed to load encryption status.", "coverageTitle": "Coverage", "coverageDescription": "How many encrypted rows sit under each key id.", @@ -7751,7 +7869,7 @@ "completed": "Completed", "failed": "Failed" }, - "coverageDetail": "Only key ids and counts are shown — never key material.", + "coverageDetail": "Only key ids and counts are shown, never key material.", "backupsTitle": "Keys the backups still need", "backupsDescription": "A retired key stays needed while any backup below still lists it.", "backupsStoredLine": "Key {key}: {count} stored copies, the oldest from {when}", @@ -7761,12 +7879,45 @@ "backupsNone": "No backup has recorded the keys it needs yet.", "backupsStillNeed": "Every row is rotated, but backups still need {keys}", "backupsUnrecordedBadge": "Every row is rotated, but older backups may still need a retired key", - "backupsDetail": "Rotation re-encrypts every row, but a backup keeps the key its content was written under, and restoring it needs that key. Keep a retired key in ENCRYPTION_KEYS until nothing below lists it." + "backupsDetail": "Rotation re-encrypts every row, but a backup keeps the key its content was written under, and restoring it needs that key. Keep a retired key in ENCRYPTION_KEYS until nothing below lists it.", + "columnsShow": "Show the table ({count} with rows)", + "columnsHide": "Hide the table", + "columnsShowAll": "Show all {count} columns", + "columnsShowWithRows": "Only columns with rows", + "columnsNoneWithRows": "No encrypted column holds rows yet." + }, + "maintenance": { + "title": "Measurement table maintenance", + "description": "Gives back the space old sync leftovers held, once the cleanup has run.", + "body": "It vacuums the measurements table and then rebuilds its indexes, one at a time. Reading and saving keep working while it runs. Pick a quiet hour, and have free disk for one more copy of the largest index.", + "tableSize": "Table with indexes", + "indexSize": "Indexes", + "loading": "Checking where the maintenance stands…", + "loadError": "Could not read the maintenance status.", + "unavailable": "No background worker is connected, so the maintenance cannot run here.", + "purgePending": "The cleanup of old sync leftovers is still running. The maintenance can start once it has finished.", + "state": { + "idle": "Not run yet.", + "queued": "Waiting for the background worker.", + "running": "Running since {since}. This can take a while on a large instance.", + "completed": "Finished on {at}.", + "refusedPurge": "Did not start, because the cleanup was still running. Start it again once the cleanup has finished.", + "alreadyRunning": "Did not start, because another run was already working.", + "failed": "Stopped on {at}. The app log names the step, and the runbook explains what to check." + }, + "start": "Start maintenance", + "confirmTitle": "Start the maintenance now?", + "confirmBody": "It runs in the background, one step at a time, and can take a while. If you have not taken a backup today, take one first.", + "confirm": "Start", + "cancel": "Cancel", + "started": "Maintenance started.", + "alreadyQueued": "A maintenance run is already queued or running.", + "startFailed": "Could not start the maintenance." } }, "carrier": "Carrier", "mfaRequired": "Require a second factor", - "mfaRequiredDescription": "Every account without an active second factor is sent to enrollment after sign-in. This prompts web users to enroll — it does not block direct API or native (Bearer) access for accounts not yet enrolled.", + "mfaRequiredDescription": "Accounts without a second factor are sent to set one up after a web sign-in; API and native (Bearer) access stays open.", "mfaEnforcedOn": "MFA on", "mfaEnforcedOff": "MFA off", "mfaEnforcedOnHint": "A second factor is required for this user. Click to stop requiring it.", @@ -7784,7 +7935,7 @@ "centralCodex": { "title": "Shared AI connection", "description": "Connect one signed-in ChatGPT account that any user on this server can opt into for AI features.", - "honesty": "This is a single shared, signed-in AI account — not an API key. Every user who opts in sends requests through it, so the account's hourly and weekly limits are shared across all of them, and the same limits already feed the Coach. Subscription providers may use the content to improve their models and no data-processing agreement covers it, so it is never the default and stays below no-training providers for document reads. Only connect an account you are comfortable sharing this way.", + "honesty": "This is a single shared, signed-in AI account, not an API key. Every user who opts in sends requests through it, so the account's hourly and weekly limits are shared across all of them, and the same limits already feed the Coach. Subscription providers may use the content to improve their models and no data-processing agreement covers it, so it is never the default and stays below no-training providers for document reads. Only connect an account you are comfortable sharing this way.", "statusConnected": "Connected", "statusDisconnected": "Not connected", "statusExpired": "Expired", @@ -7796,13 +7947,13 @@ "connectedSince": "Connected since {when}", "connected": "Shared AI connection linked", "disconnected": "Shared AI connection removed", - "error": "Something went wrong — please try again" + "error": "Something went wrong. Please try again" }, "failingJobs": { "title": "Background jobs", "description": "Queues whose jobs gave up after exhausting their retries.", "none": "No job has failed in the last {hours} hours.", - "unavailable": "No job queue to read — this deployment runs without the background worker.", + "unavailable": "No job queue to read. This deployment runs without the background worker.", "countOne": "{count} failure", "countFew": "{count} failures", "countOther": "{count} failures", @@ -7864,7 +8015,9 @@ "restartNote": "Restart HealthLog after the change.", "lastResort": "Last resort, only if you are certain the key is right and none of the three applies: set {setting} and restart. HealthLog then serves anyway and only logs the finding. This disables a safety check: with a wrong key every encrypted value fails and new data is written under that key.", "codes": "For the operator: API requests answer 503 with error code {code}, and /api/health reports reason {reason}." - } + }, + "loginOverviewCardTitle": "Sign-in events", + "userColumn": "User" }, "achievements": { "title": "Achievements", @@ -7880,7 +8033,9 @@ "noneUnlockedYet": "No achievements unlocked yet.", "pointsValue": "{points} points", "metricCount": "{count}", - "metricDays": "{count} days", + "metricDaysOne": "{count} day", + "metricDaysFew": "{count} days", + "metricDaysOther": "{count} days", "metricPercent": "{count}%", "badges": { "intakeTotal1": { @@ -7903,14 +8058,6 @@ "title": "Intake legend", "description": "Record 300 taken medication events." }, - "overIntake1": { - "title": "Double take", - "description": "Take medication at least once more than planned." - }, - "skippedIntake1": { - "title": "Stepped back", - "description": "Skip at least one planned intake." - }, "passkeyCreated1": { "title": "Passkey set up", "description": "Create your first passkey." @@ -7936,19 +8083,19 @@ "description": "Take all medications on time for 1 day." }, "compliance801": { - "title": "80% adherence · 1d", + "title": "80% adherence for 1 day", "description": "Reach at least 80% 30-day adherence for 1 day." }, "bmiGreen1": { - "title": "BMI green · 1d", + "title": "BMI green for 1 day", "description": "Keep your BMI in the normal range for 1 day." }, "bpGreen1": { - "title": "BP green · 1d", + "title": "BP green for 1 day", "description": "Keep your blood pressure in the normal range for 1 day." }, "pulseGreen1": { - "title": "Pulse green · 1d", + "title": "Pulse green for 1 day", "description": "Keep your resting pulse in the normal range for 1 day." }, "onTimePerfect7": { @@ -7956,19 +8103,19 @@ "description": "Take all medications on time for 7 consecutive days." }, "compliance807": { - "title": "80% adherence · 7d", + "title": "80% adherence for 7 days", "description": "Reach at least 80% 30-day adherence for 7 consecutive days." }, "bmiGreen7": { - "title": "BMI green · 7d", + "title": "BMI green for 7 days", "description": "Keep your BMI in the normal range for 7 consecutive days." }, "bpGreen7": { - "title": "BP green · 7d", + "title": "BP green for 7 days", "description": "Keep your blood pressure in the normal range for 7 consecutive days." }, "pulseGreen7": { - "title": "Pulse green · 7d", + "title": "Pulse green for 7 days", "description": "Keep your resting pulse in the normal range for 7 consecutive days." }, "onTimePerfect30": { @@ -7976,19 +8123,19 @@ "description": "Take all medications on time for 30 consecutive days." }, "compliance8030": { - "title": "80% adherence · 30d", + "title": "80% adherence for 30 days", "description": "Reach at least 80% 30-day adherence for 30 consecutive days." }, "bmiGreen30": { - "title": "BMI green · 30d", + "title": "BMI green for 30 days", "description": "Keep your BMI in the normal range for 30 consecutive days." }, "bpGreen30": { - "title": "BP green · 30d", + "title": "BP green for 30 days", "description": "Keep your blood pressure in the normal range for 30 consecutive days." }, "pulseGreen30": { - "title": "Pulse green · 30d", + "title": "Pulse green for 30 days", "description": "Keep your resting pulse in the normal range for 30 consecutive days." }, "onTimePerfect180": { @@ -7996,19 +8143,19 @@ "description": "Take all medications on time for 180 consecutive days." }, "compliance80180": { - "title": "80% adherence · 180d", + "title": "80% adherence for 180 days", "description": "Reach at least 80% 30-day adherence for 180 consecutive days." }, "bmiGreen180": { - "title": "BMI green · 180d", + "title": "BMI green for 180 days", "description": "Keep your BMI in the normal range for 180 consecutive days." }, "bpGreen180": { - "title": "BP green · 180d", + "title": "BP green for 180 days", "description": "Keep your blood pressure in the normal range for 180 consecutive days." }, "pulseGreen180": { - "title": "Pulse green · 180d", + "title": "Pulse green for 180 days", "description": "Keep your resting pulse in the normal range for 180 consecutive days." }, "onTimePerfect360": { @@ -8016,19 +8163,19 @@ "description": "Take all medications on time for 360 consecutive days." }, "compliance80360": { - "title": "80% adherence · 360d", + "title": "80% adherence for 360 days", "description": "Reach at least 80% 30-day adherence for 360 consecutive days." }, "bmiGreen360": { - "title": "BMI green · 360d", + "title": "BMI green for 360 days", "description": "Keep your BMI in the normal range for 360 consecutive days." }, "bpGreen360": { - "title": "BP green · 360d", + "title": "BP green for 360 days", "description": "Keep your blood pressure in the normal range for 360 consecutive days." }, "pulseGreen360": { - "title": "Pulse green · 360d", + "title": "Pulse green for 360 days", "description": "Keep your resting pulse in the normal range for 360 consecutive days." }, "moodFirst": { @@ -8112,15 +8259,15 @@ "description": "Switched the app language at least once." }, "missFree7": { - "title": "No dose missed · 7d", + "title": "No dose missed in 7 days", "description": "7 consecutive days without a missed dose." }, "missFree30": { - "title": "No dose missed · 30d", + "title": "No dose missed in 30 days", "description": "30 consecutive days without a missed dose." }, "missFree90": { - "title": "No dose missed · 90d", + "title": "No dose missed in 90 days", "description": "90 consecutive days without a missed dose." }, "measurementWeeks4": { @@ -8129,10 +8276,10 @@ }, "selfContextComplete": { "title": "Self-report maintained", - "description": "All four self-report fields filled in — the Coach knows your context." + "description": "All four self-report fields filled in. The Coach knows your context." }, "sleepLog7": { - "title": "Sleep log · 7d", + "title": "Sleep log for 7 days", "description": "Sleep data recorded on 7 consecutive days." } }, @@ -8151,7 +8298,7 @@ }, "hiddenCard": { "title": "Hidden achievement", - "description": "Keep tracking — you might just stumble across it.", + "description": "Keep tracking. You might just stumble across it.", "ariaLabel": "Hidden locked achievement" }, "hiddenUnlockToast": { @@ -8160,7 +8307,7 @@ "dashboardCard": { "title": "Recent unlocks", "viewAll": "View all", - "empty": "No achievements yet — keep logging to unlock your first." + "empty": "No achievements yet. Keep logging to unlock your first." } }, "notifications": { @@ -8222,9 +8369,6 @@ "enabledToast": "Proactive Coach nudges enabled.", "disabledToast": "Proactive Coach nudges disabled.", "saveError": "Could not save. Please try again later.", - "suggestionsLabel": "Example suggestions", - "suggestionsDesc": "Show the occasional ready-made example question on the Coach screen.", - "suggestionsAria": "Toggle example Coach suggestions", "aiComposedLabel": "Let the Coach phrase the nudge", "aiComposedDesc": "When a provider is available, the proactive nudge is written by the model instead of the fixed template. Off by default.", "aiComposedAria": "Toggle AI-composed proactive nudge" @@ -8295,7 +8439,27 @@ "loginRequired": "Please sign in to manage notifications.", "someChannelsGloballyDisabled": "One or more channels are turned off in Settings and won’t receive notifications.", "reminderSettingsLink": "Reminder settings", - "channelsLink": "Channels" + "channelsLink": "Channels", + "handover": { + "claimedTitle": "{name} now has their own account", + "claimedEnd": "{name} took over their health record. Your access has ended.", + "claimedRead": "{name} took over their health record. You can still view it, but no longer change it.", + "claimedManage": "{name} took over their health record. You can still manage it until they decide otherwise.", + "changedTitle": "{name} changed your access", + "changedEnd": "Your access to the health record of {name} has ended.", + "changedRead": "You can now view the health record of {name}, but not change it.", + "changedManage": "You can manage the health record of {name}.", + "unnamed": { + "claimedTitle": "The person you looked after now has their own account", + "claimedEnd": "The person you looked after took over their health record. Your access has ended.", + "claimedRead": "The person you looked after took over their health record. You can still view it, but no longer change it.", + "claimedManage": "The person you looked after took over their health record. You can still manage it until they decide otherwise.", + "changedTitle": "Your access to a health record changed", + "changedEnd": "Your access to the health record of the person you looked after has ended.", + "changedRead": "You can now view the health record of the person you looked after, but not change it.", + "changedManage": "You can manage the health record of the person you looked after." + } + } }, "i18n": { "maintainershipBanner": { @@ -8326,7 +8490,7 @@ } }, "edit": { - "title": "Edit target range — {metric}", + "title": "Edit target range: {metric}", "description": "Adjust the in-range band used across charts, insights and the doctor report. Values are in {unit}.", "derivedHint": "This card is derived from other metrics. Edit the underlying targets (e.g. blood pressure or weight) instead.", "minLabel": "Min", @@ -8350,15 +8514,19 @@ "currentValue": "Current: {value} {unit}", "targetRangeValue": "Target: {min}–{max} {unit}", "sourceLabel": "Source: {source}", + "sourceNames": { + "moodEntries": "Mood entries", + "sevenDays": "7 days" + }, "inTarget": "Within target range", "aboveTarget": "{delta} {unit} above target", "belowTarget": "{delta} {unit} below target", "diastolic": "Diastolic", - "glucoseFasting": "Glucose — fasting", - "glucosePostprandial": "Glucose — post-meal", - "glucoseRandom": "Glucose — random", - "glucoseBedtime": "Glucose — bedtime", - "glucoseUnspecified": "Glucose — untagged", + "glucoseFasting": "Glucose (fasting)", + "glucosePostprandial": "Glucose (post-meal)", + "glucoseRandom": "Glucose (random)", + "glucoseBedtime": "Glucose (bedtime)", + "glucoseUnspecified": "Glucose (untagged)", "status": { "underweight": "Underweight", "normal": "Normal", @@ -8444,7 +8612,7 @@ "askMedicationName": "Please specify a medication, e.g. taken Ramipril.", "medicationNotFoundExact": "Medication not found. Please send the name exactly as in HealthLog.", "helpHeader": "Available commands:", - "helpBody": "/help — show this help\n/add — record an intake\n/start — start the bot\n\nText commands:\ntaken <name> — confirm an intake\n\nButtons in reminders:\n• Taken — confirm intake\n• 🕐 1h / 🕐 3h — postpone reminder\n• ⏭ Skip — skip intake\n• ✓ Confirm — confirm a missed intake", + "helpBody": "/help: show this help\n/add: record an intake\n/start: start the bot\n\nText commands:\ntaken <name>: confirm an intake\n\nButtons in reminders:\n• Taken: confirm intake\n• 🕐 1h / 🕐 3h: postpone reminder\n• ⏭ Skip: skip intake\n• ✓ Confirm: confirm a missed intake", "measureValuePrompt": "Reply with the value to log it (optional).", "buttonMeasureValue": "Value…", "measureValueSaved": "Reading saved.", @@ -8469,9 +8637,9 @@ }, "lowStockReminders": { "title": "Low supply: {medName}", - "bodyToday": "{medName}: {units} units left — less than one day of supply.", - "bodyReorder": "{medName}: {units} units left. Supply runs out around {runsOutOn} — reorder by {reorderBy}.", - "bodyLastDose": "{medName}: {units} units left — about your final dose. Supply runs out around {runsOutOn}.", + "bodyToday": "{medName}: {units} units left, less than one day of supply.", + "bodyReorder": "{medName}: {units} units left. Supply runs out around {runsOutOn}. Reorder by {reorderBy}.", + "bodyLastDose": "{medName}: {units} units left, about your final dose. Supply runs out around {runsOutOn}.", "bodyExpired": "{medName}: no usable supply left. {units} units are marked expired.", "bodyAsNeeded": "{medName}: only {units} units left. Reorder before you need the next dose." }, @@ -8498,11 +8666,11 @@ "questionConditions": "Do you have any chronic conditions the Coach should know about?", "questionFocus": "Is there anything specific the Coach should pay attention to?", "questionAllergies": "Do you have any allergies or intolerances?", - "questionAboutMe": "Want to tell the Coach a bit about yourself — routines, goals, context?" + "questionAboutMe": "Want to tell the Coach a bit about yourself: routines, goals, context?" }, "clinicianView": { "title": "Shared health record", - "period": "Reporting period: {start} – {end}", + "period": "Reporting period: {start}–{end}", "expires": "This link expires on {date}.", "provenance": "This is a read-only summary shared by the person it belongs to, exported from their self-tracked HealthLog data. Values are self-recorded and may include device-synced readings; treat them as patient-reported, not as a clinical examination.", "labs": "Lab values", @@ -8526,7 +8694,7 @@ "strain": "Strain score", "score": "Wellness score" }, - "footer": "Shared from HealthLog — a self-hosted personal health tracker.", + "footer": "Shared from HealthLog, a self-hosted personal health tracker.", "unlock": { "title": "Enter passphrase", "description": "This shared health record is protected. Enter the passphrase you were given to view it.", @@ -8561,7 +8729,11 @@ }, "course": "Course {n}", "courseRange": "{start} to {end}", - "courseSince": "since {start}" + "courseSince": "since {start}", + "unavailable": { + "title": "Link expired or invalid", + "description": "This share link has expired, was revoked, or is incomplete. Ask the person who shared it for a new link." + } }, "cycle": { "symptomCategory": { @@ -8610,11 +8782,11 @@ "generic": "Your {metric} runs about {delta} {unit} {dir} in your luteal phase than your follicular phase.", "dirHigher": "higher", "dirLower": "lower", - "empty": "Not enough cycles and readings yet to surface a phase pattern. Keep logging — this fills in as your history grows." + "empty": "Not enough cycles and readings yet to surface a phase pattern. Keep logging; this fills in as your history grows." }, "crosstab": { "description": "How each measurement compares between your luteal and follicular phases, on your own data.", - "pairLabel": "{metric} · luteal vs follicular", + "pairLabel": "{metric}: luteal vs follicular", "detail": "Luteal {lutealAvg} {unit} ({lutealDays} days) vs follicular {follicularAvg} {unit} ({follicularDays} days).", "metricRestingHeartRate": "Resting heart rate", "metricHrv": "Heart-rate variability", @@ -8650,7 +8822,7 @@ }, "context": { "MENSTRUAL": "Hormone levels are at their lowest, which is why these days can feel like a quiet reset. Some people notice cramps, lighter sleep or a wish to slow down; others sail through with little change. All of that sits within the normal range, and how it feels can shift from one cycle to the next.", - "FOLLICULAR": "With oestrogen on the rise, many people describe these days as bright and open — sleep often comes more easily and the mind feels clearer. Basal body temperature usually stays in its lower band until just before ovulation. The length of this phase varies most from person to person, and from cycle to cycle.", + "FOLLICULAR": "With oestrogen on the rise, many people describe these days as bright and open: sleep often comes more easily and the mind feels clearer. Basal body temperature usually stays in its lower band until just before ovulation. The length of this phase varies most from person to person, and from cycle to cycle.", "OVULATORY": "Oestrogen peaks just before the egg is released, often alongside a brief lift in mood and a small rise in basal body temperature in the day or two that follow. Cervical fluid tends to become clearer and more stretchy around this window. A short twinge on one side is something some people notice; many feel nothing at all, and both are perfectly normal.", "LUTEAL": "Progesterone leads the second half and basal body temperature usually settles a little higher than earlier in the cycle. Some people notice shifts in sleep, appetite, mood or energy as the days go on, while others feel steady throughout. The pattern is personal, and it often looks different from one month to the next." }, @@ -8737,8 +8909,8 @@ "ariaUnknown": "No active cycle yet", "caption": "of your cycle", "firstPeriodCta": "Log my first period", - "periodOverdue": "Your period is later than usual — the day count pauses until the next period is logged.", - "periodOverdueDays": "Your period is {days} days later than usual — the day count pauses until the next period is logged.", + "periodOverdue": "Your period is later than usual. The day count pauses until the next period is logged.", + "periodOverdueDays": "Your period is {days} days later than usual. The day count pauses until the next period is logged.", "logPeriodCta": "Log period" }, "calendar": { @@ -8750,7 +8922,7 @@ "prevMonth": "Previous month", "nextMonth": "Next month", "loading": "Loading your calendar…", - "learning": "Learning your cycle — log at least three full cycles and the fertile window, ovulation and phases fill in. Until then the calendar shows only what you've logged.", + "learning": "Learning your cycle. Log at least three full cycles and the fertile window, ovulation and phases fill in. Until then the calendar shows only what you've logged.", "legendSymptom": "Symptoms", "legendOvulationConfirmed": "Confirmed ovulation", "flowSpotting": "spotting", @@ -8763,7 +8935,7 @@ "predictions": { "title": "What comes next", "nextPeriod": "Next period", - "nextPeriodWindow": "{low} – {high}", + "nextPeriodWindow": "{low}–{high}", "fertileWindow": "Fertile window", "ovulation": "Estimated ovulation", "confidenceHigh": "High", @@ -8772,7 +8944,7 @@ "stillLearning": "Still learning", "stillLearningBody": "Log at least three full cycles and your predictions sharpen. Until then this is a rough estimate.", "rawMode": "Prediction is off", - "rawModeBody": "You turned predictions off — only your logged data is shown, with no forecast.", + "rawModeBody": "You turned predictions off. Only your logged data is shown, with no forecast.", "none": "No prediction yet. Log a period to get started.", "ovulationConfirmed": "Ovulation (confirmed)" }, @@ -8805,7 +8977,7 @@ "symptoms": "Symptoms", "temperature": "Basal body temperature", "temperatureDisturbed": "Mark reading as disturbed", - "temperatureDisturbedHint": "Fever, illness, alcohol or a late measurement — excluded from the temperature evaluation.", + "temperatureDisturbedHint": "Fever, illness, alcohol or a late measurement: excluded from the temperature evaluation.", "ovulationTest": "Ovulation test", "mucus": "Cervical mucus", "cervix": "Cervix", @@ -8834,7 +9006,7 @@ "temperatureSection": "Temperature & ovulation", "intimacySection": "Intimacy & contraception", "testsSection": "Tests", - "phaseDayHeader": "Day {day} · {phase}", + "phaseDayHeader": "Day {day}, {phase}", "bbtHint": "Take it first thing in the morning, before getting up.", "titleForDate": "Log for {date}", "descriptionToday": "Today. Flow, symptoms and notes.", @@ -8873,7 +9045,7 @@ "remindersDescription": "Turn period reminders on per channel in notification settings.", "remindersLink": "Notification settings", "fertileReminder": "Fertile-window reminder", - "fertileReminderDescription": "Get a heads-up a few days before your estimated fertile window. Off by default — turn it on per channel in notification settings." + "fertileReminderDescription": "Get a heads-up a few days before your estimated fertile window. Off by default; turn it on per channel in notification settings." }, "contraceptive": { "NONE": "None", @@ -8901,15 +9073,15 @@ "fieldInfo": { "bbt": "Your resting temperature, taken first thing in the morning before getting up. It rises slightly (about 0.2–0.3 °C) after ovulation.", "bbtLabel": "What is basal body temperature?", - "mucus": "Vaginal discharge changes across the cycle — from dry or sticky to creamy, then watery and egg-white-like around the most fertile days.", + "mucus": "Vaginal discharge changes across the cycle, from dry or sticky to creamy, then watery and egg-white-like around the most fertile days.", "mucusLabel": "What is cervical mucus?", - "ovulationTest": "A home urine test that detects the LH surge — the hormone spike about 24–36 h before ovulation. A positive flags the most fertile window.", + "ovulationTest": "A home urine test that detects the LH surge, the hormone spike about 24–36 h before ovulation. A positive flags the most fertile window.", "ovulationTestLabel": "What is an ovulation test?", "spotting": "Light bleeding outside the period.", "spottingLabel": "What is spotting between periods?", "pregnancyTest": "Result of a home pregnancy (hCG) test.", "pregnancyTestLabel": "What is a pregnancy test?", - "progesteroneTest": "A test confirming ovulation has occurred — progesterone rises afterward.", + "progesteroneTest": "A test confirming ovulation has occurred; progesterone rises afterward.", "progesteroneTestLabel": "What is a progesterone test?", "intercourse": "Whether sexual intercourse took place on this day.", "intercourseLabel": "What does intercourse track?", @@ -8962,14 +9134,14 @@ "postpone": { "menuItem": "Postpone…", "title": "Postpone due date", - "description": "Only moves the current due date — the regular rhythm stays unchanged.", + "description": "Only moves the current due date; the regular rhythm stays unchanged.", "chip": "+{days} days", "dateLabel": "New date", "confirm": "Postpone" }, "skip": { "heading": "Skip this due date", - "explainer": "Recorded honestly as skipped — not as done. The interval restarts from today.", + "explainer": "Recorded honestly as skipped, not as done. The interval restarts from today.", "action": "Skip", "confirmTitle": "Skip this due date?" }, @@ -9011,10 +9183,10 @@ "VISCERAL_FAT": "Visceral fat", "BODY_MASS_INDEX": "Body mass index (BMI)", "WAIST_CIRCUMFERENCE": "Waist circumference", - "PHQ9_SCORE": "PHQ-9 · mood", - "GAD7_SCORE": "GAD-7 · worry", - "WHO5_SCORE": "WHO-5 · wellbeing", - "SCI_SCORE": "SCI · sleep" + "PHQ9_SCORE": "PHQ-9 (mood)", + "GAD7_SCORE": "GAD-7 (worry)", + "WHO5_SCORE": "WHO-5 (wellbeing)", + "SCI_SCORE": "SCI (sleep)" }, "typeGroups": { "vitals": "Vitals", @@ -9110,7 +9282,7 @@ "localOcr": { "heading": "Read scans on this device", "description": "Reads the photo on your device and sends only the extracted text to your provider.", - "detail": "Useful when your AI provider cannot read images. Less accurate — review every value before saving." + "detail": "Useful when your AI provider cannot read images. Less accurate: review every value before saving." }, "sort": { "heading": "Sort order", @@ -9154,7 +9326,7 @@ "sourceRangePlaceholder": "e.g. 3.5 - 5.0 or < 5", "sourceRangeAdd": "+ Add the range from the report", "sourceRangeHint": "Optional. Applies to this reading only; your saved range stays as it is.", - "sourceRangeUnparsed": "Kept as written — no numeric range read from it." + "sourceRangeUnparsed": "Kept as written, no numeric range read from it." }, "backToLabs": "Back to lab results", "hasNote": "Note", @@ -9172,7 +9344,7 @@ "emptyDescription": "Add a value to start tracking this biomarker over time.", "title": "Biomarker", "genericDescription": "A lab biomarker you track. Add readings to follow how it trends against its reference range.", - "valuesTitle": "{marker} — all readings", + "valuesTitle": "{marker}: all readings", "valuesDescription": "Every reading for this biomarker. Edit or remove an entry directly.", "backToMarker": "Back to {marker}", "latestResult": "Latest result" @@ -9192,7 +9364,7 @@ }, "biomarker": { "manageTitle": "Biomarkers", - "managerDescription": "Define a marker once — its unit and range are reused for every reading.", + "managerDescription": "Define a marker once; its unit and range are reused for every reading.", "define": "Define", "defineFirst": "Define your first biomarker", "defineTitle": "Define biomarker", @@ -9211,7 +9383,7 @@ "form": { "seedLabel": "Start from a common marker", "seedPlaceholder": "Choose a marker (optional)", - "seedHint": "Picking one fills the fields below — adjust the range to match your lab.", + "seedHint": "Picking one fills the fields below. Adjust the range to match your lab.", "name": "Name", "namePlaceholder": "e.g. LDL cholesterol", "unit": "Unit", @@ -9225,7 +9397,7 @@ "upperPlaceholder": "Upper bound", "context": "Context", "contextPlaceholder": "Optional note about this marker", - "rangeHint": "Either bound is optional — leave one blank for an open-ended range (e.g. LDL < 116).", + "rangeHint": "Either bound is optional. Leave one blank for an open-ended range (e.g. LDL < 116).", "requiredError": "Name and unit are required.", "rangeOrderError": "The lower bound must not exceed the upper bound.", "save": "Save biomarker", @@ -9281,7 +9453,7 @@ "bloodCount": "Blood count" }, "desc": { - "total-cholesterol": "Total cholesterol is the sum of the cholesterol carried across all lipoprotein particles in the blood, including the LDL, HDL, and remnant fractions. Cholesterol itself is an essential building block for cell membranes, hormones, and bile acids. As a single number it is a coarse summary, because both a healthy HDL share and a high LDL share lift the total — so it is usually read alongside the individual fractions. It is tracked mainly as part of long-term cardiovascular risk assessment.", + "total-cholesterol": "Total cholesterol is the sum of the cholesterol carried across all lipoprotein particles in the blood, including the LDL, HDL, and remnant fractions. Cholesterol itself is an essential building block for cell membranes, hormones, and bile acids. As a single number it is a coarse summary, because both a healthy HDL share and a high LDL share lift the total, so it is usually read alongside the individual fractions. It is tracked mainly as part of long-term cardiovascular risk assessment.", "ldl": "LDL cholesterol is the cholesterol carried by low-density lipoprotein particles, the fraction that delivers cholesterol to tissues but can also deposit it in artery walls. Over years this deposition contributes to atherosclerotic plaque, which is why LDL is one of the most closely watched lipid markers. Lower long-term exposure generally tracks with lower cardiovascular risk. It is interpreted by risk category rather than against one fixed normal value.", "hdl": "HDL cholesterol is the cholesterol carried by high-density lipoprotein particles, which collect surplus cholesterol from tissues and return it to the liver for recycling or removal. Because of this transport role it is often described as the protective fraction. Very low levels tend to accompany higher cardiovascular risk, though raising it artificially has not reliably reduced risk, so it is read as a marker rather than a target. It is usually interpreted together with LDL and triglycerides.", "triglycerides": "Triglycerides are the main form of fat circulating in the blood, supplied both by the diet and produced by the liver, and they serve as a key energy store. Levels rise after meals, so the sample is usually taken fasting for comparability. Persistently high triglycerides are associated with metabolic and cardiovascular risk and often travel with insulin resistance and low HDL. They are read as one component of the overall lipid picture.", @@ -9290,27 +9462,27 @@ "tsh": "TSH (thyroid-stimulating hormone) is released by the pituitary gland to signal the thyroid to produce its hormones, and it adjusts up or down to keep them in balance. Because of this feedback loop, TSH is the most sensitive first-line screen of thyroid function: it tends to move before the thyroid hormones themselves drift. A raised or lowered TSH points toward an under- or over-active thyroid and usually prompts measurement of the thyroid hormones directly. Pregnancy and age shift the expected range.", "ft3": "Free T3 (triiodothyronine) is the unbound, biologically active form of the more potent thyroid hormone, the fraction available to act on tissues. Most circulating T3 is bound to carrier proteins and inactive, so the free portion is what reflects true hormone availability. It is measured to characterise thyroid function, especially alongside TSH and free T4 when results are unclear. Levels are strongly assay-dependent and read in clinical context.", "ft4": "Free T4 (thyroxine) is the unbound form of the main hormone the thyroid releases into the blood, serving as a reservoir that tissues convert to the more active T3 as needed. The free fraction reflects what is actually available to the body, independent of carrier-protein levels. It is usually measured together with TSH to separate thyroid-gland problems from pituitary signalling. Pregnancy and certain medications can shift it.", - "ferritin": "Ferritin is the body's main iron-storage protein, and its blood level broadly mirrors total iron reserves. A low value points toward depleted stores, while a high value can reflect iron overload — or simply inflammation, because ferritin also rises as an acute-phase reactant. For that reason it is often interpreted alongside other iron markers and signs of inflammation. It is one of the core measures used to assess iron status.", - "transferrin-saturation": "Transferrin saturation is the percentage of transferrin — the protein that ferries iron through the blood — that is currently loaded with iron. It links the amount of iron in transit to the body's iron-carrying capacity, giving a snapshot of iron availability that complements ferritin's view of storage. A low saturation suggests iron is in short supply, while a high value can indicate overload. It is read together with ferritin and the full iron panel.", + "ferritin": "Ferritin is the body's main iron-storage protein, and its blood level broadly mirrors total iron reserves. A low value points toward depleted stores, while a high value can reflect iron overload, or simply inflammation, because ferritin also rises as an acute-phase reactant. For that reason it is often interpreted alongside other iron markers and signs of inflammation. It is one of the core measures used to assess iron status.", + "transferrin-saturation": "Transferrin saturation is the percentage of transferrin (the protein that ferries iron through the blood) that is currently loaded with iron. It links the amount of iron in transit to the body's iron-carrying capacity, giving a snapshot of iron availability that complements ferritin's view of storage. A low saturation suggests iron is in short supply, while a high value can indicate overload. It is read together with ferritin and the full iron panel.", "vitamin-d": "Vitamin D is measured as 25-hydroxyvitamin D, the circulating storage form and the standard marker of overall vitamin D status. The body makes it in skin exposed to sunlight and absorbs some from food, and it supports calcium balance, bone health, and immune function. Levels vary with season, latitude, skin tone, and supplementation. It is tracked to gauge whether stores are adequate over time.", - "vitamin-b12": "Vitamin B12 (cobalamin) is an essential vitamin obtained mainly from animal-source foods and stored largely in the liver. It is required for red-blood-cell formation, DNA synthesis, and the health of the nervous system. Prolonged shortfall — from low intake or impaired absorption — can lead to anaemia and nerve-related symptoms. It is measured to assess nutritional status, particularly where intake or absorption may be limited.", + "vitamin-b12": "Vitamin B12 (cobalamin) is an essential vitamin obtained mainly from animal-source foods and stored largely in the liver. It is required for red-blood-cell formation, DNA synthesis, and the health of the nervous system. Prolonged shortfall, from low intake or impaired absorption, can lead to anaemia and nerve-related symptoms. It is measured to assess nutritional status, particularly where intake or absorption may be limited.", "folate": "Folate (vitamin B9) is a B-vitamin found in leafy greens, legumes, and fortified foods, central to red-blood-cell formation and DNA synthesis. It works closely with vitamin B12, and a shortfall in either can produce a similar type of anaemia. Adequate folate is especially important around early pregnancy for healthy fetal development. It is measured to assess nutritional status and the cause of certain anaemias.", "hs-crp": "High-sensitivity CRP measures C-reactive protein, a marker the liver releases in response to inflammation, using an assay refined to detect the low concentrations relevant to cardiovascular risk. Unlike standard CRP used for acute infection, the high-sensitivity version captures the subtle, chronic inflammation linked to vascular disease. Because any recent infection or injury can raise it, it is interpreted away from acute illness and often confirmed with a repeat sample. It is read as one contributor to overall cardiovascular risk.", "creatinine": "Creatinine is a waste product of normal muscle metabolism that the kidneys filter out of the blood at a fairly steady rate. Because production is relatively constant, its blood level serves as a practical gauge of how well the kidneys are clearing waste. Values are also influenced by muscle mass, hydration, and diet, so it is usually interpreted alongside the estimated filtration rate. It is a core marker of kidney function.", "egfr": "eGFR (estimated glomerular filtration rate) estimates how much blood the kidneys filter each minute, calculated from creatinine together with age and sex. It translates a raw creatinine value into a more directly interpretable measure of kidney function. A declining eGFR over time is the main way chronic kidney changes are staged and tracked. As an estimate it is read as a trend rather than a single exact figure.", "alt": "ALT (alanine aminotransferase) is an enzyme concentrated in liver cells, released into the blood when those cells are stressed or damaged. Because it is relatively liver-specific, it is one of the primary markers used to detect liver injury or inflammation. Levels can rise with fatty liver, viral hepatitis, alcohol, or certain medications. It is usually read together with AST and the other liver-panel markers.", - "ast": "AST (aspartate aminotransferase) is an enzyme found in the liver but also in muscle, heart, and red blood cells, released when any of those tissues is injured. Because it is less liver-specific than ALT, the two are interpreted together — their ratio can hint at the source and nature of the problem. Vigorous exercise or muscle injury can raise it independently of the liver. It is one of the standard liver-panel enzymes.", - "ggt": "GGT (gamma-glutamyl transferase) is an enzyme found in the liver and the bile ducts that rises particularly when bile flow is obstructed or with alcohol exposure. It is often used to clarify why another liver marker is elevated — for example, to distinguish a liver or bile-duct source. It is sensitive but not very specific, so it is read in context with the rest of the liver panel. It is one of several markers used to assess liver and biliary health.", + "ast": "AST (aspartate aminotransferase) is an enzyme found in the liver but also in muscle, heart, and red blood cells, released when any of those tissues is injured. Because it is less liver-specific than ALT, the two are interpreted together; their ratio can hint at the source and nature of the problem. Vigorous exercise or muscle injury can raise it independently of the liver. It is one of the standard liver-panel enzymes.", + "ggt": "GGT (gamma-glutamyl transferase) is an enzyme found in the liver and the bile ducts that rises particularly when bile flow is obstructed or with alcohol exposure. It is often used to clarify why another liver marker is elevated, for example, to distinguish a liver or bile-duct source. It is sensitive but not very specific, so it is read in context with the rest of the liver panel. It is one of several markers used to assess liver and biliary health.", "sodium": "Sodium is the principal electrolyte in the fluid outside the body's cells and the main driver of fluid balance and blood volume. The body regulates it tightly through thirst, hormones, and the kidneys, so its blood level reflects the balance between water and salt more than dietary intake alone. Abnormal values usually signal a disturbance of water balance rather than of sodium itself. It is a core part of the basic metabolic and electrolyte panel.", "potassium": "Potassium is the main electrolyte inside the body's cells and is essential for nerve signalling and the steady rhythm of the heart muscle. Its blood level is held within a narrow range by the kidneys and hormonal signals, because both high and low values can disturb the heart's electrical activity. Levels can be affected by kidney function, hydration, and several common medications. It is a core measure in the electrolyte panel.", "hemoglobin": "Haemoglobin is the iron-containing protein in red blood cells that binds oxygen in the lungs and carries it to the tissues. Its concentration is the main measure of the blood's oxygen-carrying capacity and the central marker for detecting anaemia. Low values can reflect blood loss, iron or vitamin shortfalls, or reduced production, while high values can follow dehydration or low-oxygen conditions. It is a cornerstone of the complete blood count.", "hematocrit": "Haematocrit is the proportion of blood volume made up of red blood cells, expressed as a percentage. It moves closely with haemoglobin and gives a complementary view of red-cell mass and the blood's oxygen-carrying capacity. Hydration affects it, since plasma-volume changes shift the proportion even when red-cell numbers are stable. It is read alongside haemoglobin and the rest of the blood count.", "wbc": "The white blood cell count is the total number of immune cells circulating in the blood, the body's mobile defence against infection. It rises in response to infection, inflammation, or stress and can fall when the bone marrow is suppressed or in some immune conditions. Because it sums several distinct cell types, a notable change is often followed up with a differential that breaks it down. It is a core component of the complete blood count.", "platelets": "Platelets are small cell fragments in the blood that clump together and form the initial plug that stops bleeding from a damaged vessel. Their count reflects the balance between production in the bone marrow and consumption or breakdown. A low count can raise bleeding tendency, while a high count can follow inflammation or other stimuli. It is part of the complete blood count and a basic gauge of clotting capacity.", - "apob": "Apolipoprotein B is the structural protein carried by each of the atherogenic lipoprotein particles, so measuring it counts those particles directly rather than the cholesterol they carry. Because one ApoB molecule sits on each LDL, VLDL, and related particle, it can capture cardiovascular risk that an LDL-cholesterol value misses — particularly when particle number and cholesterol content diverge. For this reason it is increasingly used to refine lipid-based risk assessment. It is interpreted alongside the standard lipid panel.", + "apob": "Apolipoprotein B is the structural protein carried by each of the atherogenic lipoprotein particles, so measuring it counts those particles directly rather than the cholesterol they carry. Because one ApoB molecule sits on each LDL, VLDL, and related particle, it can capture cardiovascular risk that an LDL-cholesterol value misses, particularly when particle number and cholesterol content diverge. For this reason it is increasingly used to refine lipid-based risk assessment. It is interpreted alongside the standard lipid panel.", "lp-a": "Lipoprotein(a) is an LDL-like particle with an extra attached protein, and its level is largely set by genetics and stays fairly stable through life. Elevated Lp(a) is an independent, inherited risk factor for cardiovascular disease and aortic-valve narrowing. Because it changes little, it is usually measured once to establish a person's lifelong risk rather than tracked repeatedly. It is reported in molar units and read as part of overall cardiovascular risk.", - "fasting-insulin": "Fasting insulin is the level of insulin — the hormone that moves glucose from the blood into cells — measured after an overnight fast. Paired with fasting glucose, it helps estimate insulin resistance, the state in which the body needs more insulin to keep glucose in range. A high fasting insulin alongside normal glucose can be an early sign that this compensation is under way. It is read as part of a metabolic-health picture rather than in isolation.", - "omega-3-index": "The omega-3 index is the share of EPA and DHA — the two long-chain marine omega-3 fatty acids — among the fatty acids in red-blood-cell membranes. Because red cells turn over slowly, it reflects long-term omega-3 intake rather than a recent meal. A higher index has been associated in observational studies with lower cardiovascular risk. It is tracked as a stable marker of omega-3 status that responds to diet over months." + "fasting-insulin": "Fasting insulin is the level of insulin (the hormone that moves glucose from the blood into cells) measured after an overnight fast. Paired with fasting glucose, it helps estimate insulin resistance, the state in which the body needs more insulin to keep glucose in range. A high fasting insulin alongside normal glucose can be an early sign that this compensation is under way. It is read as part of a metabolic-health picture rather than in isolation.", + "omega-3-index": "The omega-3 index is the share of EPA and DHA (the two long-chain marine omega-3 fatty acids) among the fatty acids in red-blood-cell membranes. Because red cells turn over slowly, it reflects long-term omega-3 intake rather than a recent meal. A higher index has been associated in observational studies with lower cardiovascular risk. It is tracked as a stable marker of omega-3 status that responds to diet over months." }, "apob": "Apolipoprotein B (ApoB)", "lp-a": "Lipoprotein(a)", @@ -9328,7 +9500,7 @@ "acceptImageOnly": "JPEG, PNG, or WebP", "extracting": "Reading your report…", "readingOnDevice": "Reading on your device…", - "localModeHint": "Read on your device, then sent as text. Less accurate — review every value.", + "localModeHint": "Read on your device, then sent as text. Less accurate: review every value.", "rangeFromScan": "Reference range taken from the scan", "rangeVerify": "Verify the reference range", "reviewTitle": "Review before saving", @@ -9343,12 +9515,12 @@ "dateLabel": "Date", "refLowLabel": "Reference low", "refHighLabel": "Reference high", - "newBiomarker": "New biomarker — will be created", + "newBiomarker": "New biomarker, will be created", "linksExisting": "Links to existing {name}", "duplicateWarning": "A matching reading already exists", "lowConfidence": "Please double-check this value", "lowConfidenceBadge": "Low confidence", - "valueUnreadable": "Value unreadable — enter it manually", + "valueUnreadable": "Value unreadable. Enter it manually", "saveSelected": "Save {count} selected", "discardAll": "Discard all", "savedToast": "{count} lab results saved", @@ -9464,6 +9636,10 @@ "vaccinations": { "label": "Vaccinations", "description": "The vaccination record, structured: dose, series, next booster." + }, + "timeline": { + "label": "Timeline", + "description": "Years at a glance: conditions, medications and visits beside your readings, plus your own life events." } }, "moduleOff": { @@ -9495,8 +9671,8 @@ }, "form": { "date": "Date", - "catalogLabel": "Vaccine (catalogue)", - "identityHint": "Pick from the catalogue or type it in below — either is enough.", + "catalogLabel": "Vaccine", + "identityHint": "Pick a vaccine from the list or type it in below. Either is enough.", "catalogNone": "Choose a vaccine", "catalogSearch": "Search vaccines", "catalogNoMatch": "No catalogue entry found", @@ -9573,7 +9749,7 @@ }, "empty": { "title": "No vaccination logged yet", - "description": "Transcribe your vaccination card line by line — a date and the vaccine are enough." + "description": "Transcribe your vaccination card line by line. A date and the vaccine are enough." }, "catalog": { "tetanus": "Tetanus", @@ -9616,10 +9792,31 @@ "overdue": "Booster overdue since {date}", "dueSoon": "Booster due {date}", "current": "Next booster {date}" + }, + "custom": { + "title": "Own vaccines", + "add": "Add own vaccine", + "name": "Name", + "components": "Protects against", + "seriesDoses": "Doses in the primary series", + "boosterMonths": "Booster every (months)", + "saved": "Vaccine saved", + "deleted": "Vaccine removed", + "deleteConfirm": "Remove this vaccine? Doses already logged keep their name.", + "saveFailed": "The vaccine could not be saved.", + "nameTaken": "You already have a vaccine with this name.", + "editTitle": "Edit own vaccine", + "description": "A vaccine the catalogue does not list, counted like any catalogue entry.", + "componentsHint": "Choose every disease it protects against.", + "boosterHint": "Leave empty if there is no booster.", + "protectsAgainst": "Protects against {list}.", + "deleteTitle": "Remove own vaccine?", + "sectionDescription": "Vaccines you added yourself because the catalogue does not list them.", + "catalogGroup": "Catalogue" } }, "illness": { - "title": "Illness journal", + "title": "Illness", "subtitle": "A retrospective log of your illness and condition episodes.", "settings": { "viewDescription": "Choose whether episodes show as cards or as a compact list.", @@ -9706,7 +9903,7 @@ }, "correlation": { "title": "How it unfolded", - "learning": "Still learning — log a few more days to see patterns here.", + "learning": "Still learning. Log a few more days to see patterns here.", "preOnsetTitle": "How it announced itself", "preOnsetBody": "Signals that drifted from your baseline before you marked the onset.", "nadirTitle": "What changed", @@ -9733,7 +9930,7 @@ "countOne": "You logged 1 episode in this period.", "countOther": "You logged {count} episodes in this period.", "typicalGap": "Typical recovery gap: {days} days between feeling better and your numbers settling.", - "typicalGapWithDriver": "Typical recovery gap: {days} days — you tend to feel better before your {vital} settles.", + "typicalGapWithDriver": "Typical recovery gap: {days} days; you tend to feel better before your {vital} settles.", "typicalGapLearning": "A few more resolved episodes and a typical recovery gap will appear here.", "none": "No episodes logged in this period.", "analyze": "Analyse the recovery gap" @@ -9794,8 +9991,8 @@ "stop": "I measure enough", "accepted": "Reminder set", "dismissed": "Okay, I won't suggest this again", - "stopped": "Got it — I'll stop suggesting reminders", - "failed": "Couldn't set that up — please try again", + "stopped": "Got it, I'll stop suggesting reminders", + "failed": "Couldn't set that up. Please try again", "cadence": { "weightDaily": "Weigh yourself daily, around the same time", "bp722": "Measure your blood pressure twice a day for a week", @@ -9807,7 +10004,7 @@ "dismiss": "Not now", "applied": "Added", "dismissed": "Dismissed", - "failed": "Couldn't add that — please try again", + "failed": "Couldn't add that. Please try again", "checkup": { "title": "Add a check-up reminder?" }, @@ -9835,9 +10032,9 @@ "reviewPrefix": "Check-in: {when}", "accept": "Accept plan", "decline": "Decline", - "accepted": "Plan accepted — the Coach will keep it in mind.", + "accepted": "Plan accepted. The Coach will keep it in mind.", "declined": "Proposal declined.", - "failed": "That didn't work — please try again.", + "failed": "That didn't work. Please try again.", "markMet": "Goal met", "abandon": "End plan", "remove": "Remove", @@ -9858,8 +10055,8 @@ } }, "refusal": { - "outOfScope": "I can only help with the health metrics in your log — measurements, medications, mood, trends. I cannot answer questions outside that scope.", - "promptInjection": "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.", + "outOfScope": "I can only help with the health metrics in your log: measurements, medications, mood, trends. I cannot answer questions outside that scope.", + "promptInjection": "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.", "conversationPoisoned": "An earlier message in this conversation contains wording that overrides my instructions. Please start a new conversation." }, "noRecordedData": "I don't have any recorded readings for what you asked about, so there is nothing I can give you a figure from. Once you log some, ask me again and I'll work from your own numbers.", @@ -9895,7 +10092,9 @@ "illness": "Illness and recovery", "cycle": "Cycle", "correlations": "Connections between metrics", - "snapshot": "Record summary" + "snapshot": "Record summary", + "environment": "Weather and air", + "day": "One day" }, "window": { "last7days": "last 7 days", @@ -10073,7 +10272,7 @@ }, "mentalHealth": { "pageTitle": "Mental wellbeing", - "pageDescription": "Four validated, voluntary self-checks — the trend over time says more than any single score.", + "pageDescription": "Four validated, voluntary self-checks. The trend over time says more than any single score.", "attributionLabel": "Attribution", "attributionShow": "Source and licence for {instrument}", "choosePrompt": "Choose a check-in", @@ -10084,10 +10283,10 @@ "saved": "Saved", "error": "Something went wrong. Please try again.", "instrument": { - "phq9": "PHQ-9 · mood", - "gad7": "GAD-7 · worry", - "who5": "WHO-5 · well-being", - "sci": "SCI · sleep" + "phq9": "PHQ-9 (mood)", + "gad7": "GAD-7 (worry)", + "who5": "WHO-5 (well-being)", + "sci": "SCI (sleep)" }, "instrumentSub": { "phq9": "Depression screen, 9 questions", @@ -10108,7 +10307,7 @@ "2": "Very difficult", "3": "Extremely difficult" }, - "functionalOptionalHint": "Optional — you can submit without answering this question.", + "functionalOptionalHint": "Optional: you can submit without answering this question.", "result": { "title": "Your result", "totalLabel": "Total score", @@ -10298,7 +10497,7 @@ }, "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.", + "historyLoadErrorHint": "This does not mean you have no results; the list could not be fetched. Try again.", "privacyNote": "Your answers are stored encrypted and never reach the AI Coach." }, "records": { @@ -10511,7 +10710,7 @@ "UNKNOWN": "Not stated" }, "contactsPlaceholder": "Name and phone number of who to call", - "implantsPlaceholder": "e.g. pacemaker, insulin pump, metal implant", + "implantsPlaceholder": "E.g. pacemaker, insulin pump, metal implant", "notePlaceholder": "Anything a clinician should know in an emergency", "reportHint": "This appears on the first page of your doctor report.", "save": "Save", @@ -10528,12 +10727,34 @@ "daylight": "Daylight", "precip": "Precipitation", "pressureMean": "Barometric pressure", - "pressureDelta": "Pressure swing" + "pressureDelta": "Pressure swing", + "pm25": "Fine particles (PM2.5)", + "pm10": "Particles (PM10)", + "no2": "Nitrogen dioxide", + "ozone8h": "Ozone (8-hour high)", + "europeanAqi": "European air quality index", + "uvIndexMax": "UV index (high)", + "dustMax": "Saharan dust", + "pollenMax": "Pollen (highest)" + }, + "chip": { + "day": "Weather on {date}", + "pollenHigh": "High pollen: {kinds}", + "hotNight": "Warm night", + "veryPoorAir": "Very poor air" + }, + "pollen": { + "alder": "alder", + "birch": "birch", + "grass": "grass", + "mugwort": "mugwort", + "olive": "olive", + "ragweed": "ragweed" } }, "documents": { "title": "Documents", - "subtitle": "Every letter, report, and scan in one searchable place — encrypted at rest.", + "subtitle": "Every letter, report and scan in one searchable place, encrypted at rest.", "pageUpload": "Upload", "ownerOnlyUpload": "Documents can only be added by the record owner.", "kind": { @@ -10575,15 +10796,15 @@ }, "error": { "fileTooLarge": "This file is larger than the {maxSize} limit.", - "quotaExceeded": "Storage is full — {used} of {quota} used.", + "quotaExceeded": "Storage is full: {used} of {quota} used.", "unsupportedType": "This file type is not supported.", "purged": "This document was permanently removed and cannot be restored.", "duplicateExists": "An identical document is already stored.", - "rateLimited": "Too many uploads right now — try again in a few minutes.", + "rateLimited": "Too many uploads right now. Try again in a few minutes.", "generic": "Upload failed. Please try again." }, "toast": { - "duplicate": "Already stored — highlighting the existing document.", + "duplicate": "Already stored. The existing document is highlighted.", "deleted": "Document deleted", "restoreFailed": "Couldn't restore the document." }, @@ -10606,7 +10827,7 @@ }, "empty": { "title": "Your document vault is empty", - "description": "Store doctor letters, lab reports, imaging, prescriptions, and vaccination records — encrypted, and searchable by type, condition, and year.", + "description": "Store doctor letters, lab reports, imaging, prescriptions and vaccination records. They are encrypted and searchable by type, condition and year.", "action": "Upload a first document", "noMatchesTitle": "No matching documents", "noMatchesDescription": "Nothing matches the current search and filters." @@ -10646,9 +10867,9 @@ "linkCondition": "Link", "noConditions": "Not linked to a condition", "previewUnavailable": "No inline preview for this file type", - "uploadedMeta": "Added {date} · {size}", + "uploadedMeta": "Added {date}, {size}", "importedFrom": "Imported from {system}", - "importedFromWithId": "Imported from {system} · ID {id}", + "importedFromWithId": "Imported from {system}, ID {id}", "sourceOther": "another system", "share": "Share", "labValuesLink": "View {count} lab values", @@ -10715,7 +10936,7 @@ "errorNotIndexed": "Read the document first, then extract.", "errorLocalOcrDisabled": "Turn on local OCR in the AI settings first.", "errorProvider": "No AI provider is set up for this.", - "errorTextImageOnly": "Local OCR reads images only — not this file.", + "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." }, @@ -10748,7 +10969,7 @@ "CONDITION": "Condition", "MEDICATION_STATEMENT": "Medication" }, - "statedRange": "stated range {range}", + "statedRange": "Stated range {range}", "needsCheck": "Check this value against the document, then save it.", "verify": "Check", "verifySave": "Save value", @@ -10864,6 +11085,21 @@ "finishedHint": "Search again or change a filter to pick more documents.", "maxReached": "You can import up to {max} documents at a time.", "noMatchHint": "Try part of the name, or clear the tag and dates." + }, + "customize": "Customize view", + "layout": { + "viewDescription": "Preview cards with thumbnails, or a compact list.", + "arrangementHeading": "Arrangement", + "arrangementDescription": "Each month as its own block, or everything flowing across the full width.", + "stacked": "Months stacked", + "flow": "Continuous", + "saveFailed": "Couldn't save the view." + }, + "deleteConfirm": { + "titleOne": "Delete this document?", + "titleMany": "Delete {count} documents?", + "body": "You can undo this right afterwards. After 30 days it is removed for good.", + "action": "Delete" } }, "selfDescription": { @@ -10950,7 +11186,7 @@ "genericDescription": "A measurement you track. Add values to follow it over time.", "emptyTitle": "No values yet", "emptyDescription": "Add your first value to start charting this metric.", - "valuesTitle": "{metric} — all values", + "valuesTitle": "{metric}: all values", "valuesDescription": "Every logged value for this metric.", "backToMetric": "Back to {metric}" } @@ -10997,26 +11233,26 @@ "lastAttemptReason": { "unitMismatch": "The values that arrived didn't match the units this app expects for these nutrients, so they couldn't be stored.", "valueOutOfRange": "The amounts that arrived were higher than this app can plausibly store, so they couldn't be saved.", - "dayInvalid": "The entries carried a date the server couldn't accept. This can happen when the clock or time zone on the sending side is wrong — it's worth checking.", + "dayInvalid": "The entries carried a date the server couldn't accept. This can happen when the clock or time zone on the sending side is wrong, so it's worth checking.", "upsertFailed": "These entries reached the server, but a problem on this end kept them from being saved. There's nothing you need to do.", "unknown": "These entries arrived but couldn't be stored." } }, "hydration": { - "referenceMeta": "Reference intake {value} {unit}/day · EFSA" + "referenceMeta": "Reference intake {value} {unit}/day (EFSA)" }, "caffeine": { - "ceilingMeta": "Safe-level ceiling {value} {unit}/day · EFSA" + "ceilingMeta": "Safe-level ceiling {value} {unit}/day (EFSA)" }, "micronutrients": { "title": "Vitamins & minerals", - "rowMeta": "{days} of {window} days · ref {value} {unit} (EFSA)", + "rowMeta": "{days} of {window} days, ref {value} {unit} (EFSA)", "rowMetaNoRef": "{days} of {window} days", "footer": "{tracked} of {total} tracked micronutrients have data in this window.", "emptyTitle": "No micronutrient data synced yet", "loadError": "Your nutrient data could not be loaded" }, - "sparseNote": "A low number here usually means sparse logging, not a deficiency — Apple Health day totals reflect what your apps logged, not total dietary intake." + "sparseNote": "A low number here usually means sparse logging, not a deficiency. Apple Health day totals reflect what your apps logged, not total dietary intake." }, "daily": { "line": { @@ -11025,7 +11261,7 @@ }, "push": { "title": "Your morning briefing", - "bodyProvisional": "{line} · Last night's sleep isn't in yet." + "bodyProvisional": "{line}\nLast night's sleep isn't in yet." }, "item": { "doseWindow": { @@ -11043,27 +11279,27 @@ }, "syncIssue": { "title": "Sync needs attention", - "body": "{integration} isn't syncing — reconnect to keep your data current." + "body": "{integration} isn't syncing. Reconnect to keep your data current." }, "coachCheckin": { "title": "Time to check in on a plan", - "body": "It's been about a week since you set this plan — keep it, adjust it, or let it go. No pressure either way.", - "bodyPlan": "About a week ago you set: “{plan}”. Keep it, adjust it, or let it go — no pressure either way.", + "body": "It's been about a week since you set this plan. Keep it, adjust it, or let it go. No pressure either way.", + "bodyPlan": "About a week ago you set: “{plan}”. Keep it, adjust it, or let it go. No pressure either way.", "adjustPrompt": "I'd like to adjust my check-in plan.", "adjustPromptPlan": "I'd like to adjust this plan: “{plan}”" }, "tensionWindow": { "title": "Possible tension today", "body": { - "morning": "Your heart rate sat above its resting baseline for a sustained stretch this morning while you were still — possibly some tension.", - "afternoon": "Your heart rate sat above its resting baseline for a sustained stretch this afternoon while you were still — possibly some tension.", - "evening": "Your heart rate sat above its resting baseline for a sustained stretch this evening while you were still — possibly some tension.", - "night": "Your heart rate sat above its resting baseline for a sustained stretch overnight while you were still — possibly some tension." + "morning": "Your heart rate sat above its resting baseline for a sustained stretch this morning while you were still, possibly some tension.", + "afternoon": "Your heart rate sat above its resting baseline for a sustained stretch this afternoon while you were still, possibly some tension.", + "evening": "Your heart rate sat above its resting baseline for a sustained stretch this evening while you were still, possibly some tension.", + "night": "Your heart rate sat above its resting baseline for a sustained stretch overnight while you were still, possibly some tension." } }, "ecgNewRecording": { "title": "New ECG recording", - "body": "Your device recorded a new ECG — it's ready to view.", + "body": "Your device recorded a new ECG. It's ready to view.", "bodyVerdict": "Your device recorded a new ECG and reported: {verdict}.", "verdict": { "irregular": "a possible irregular rhythm", @@ -11083,9 +11319,9 @@ }, "upcomingVisit": { "title": "Appointment coming up", - "bodyToday": "{what} — today", - "bodyTomorrow": "{what} — tomorrow", - "bodyDayAfterTomorrow": "{what} — the day after tomorrow" + "bodyToday": "{what} today", + "bodyTomorrow": "{what} tomorrow", + "bodyDayAfterTomorrow": "{what} the day after tomorrow" } }, "action": { @@ -11104,7 +11340,7 @@ "scoreLabel": "Health score", "deltaVsBaseline": "{delta} vs your baseline", "deltaFlat": "In line with your baseline", - "sleepPending": "Last night's sleep isn't in yet — today's read is provisional.", + "sleepPending": "Last night's sleep isn't in yet, so today's read is provisional.", "worthALook": "Worth a look", "allClear": "Nothing needs your attention today.", "ringLink": "Open {metric} details", @@ -11143,7 +11379,7 @@ }, "record": { "title": "A new {metric} best", - "body": "A personal best for your {metric} today — quietly noted, nothing to do." + "body": "A personal best for your {metric} today. Quietly noted, nothing to do." }, "returnToBaseline": { "title": "Back in range: {metric}", @@ -11305,7 +11541,7 @@ "canManage": "Can read the record, add to it, and change or remove what is in it", "entireRecord": "Covers the entire record", "sections": "Covers: {sections}", - "noSections": "Covers nothing — this access opens no part of the record", + "noSections": "Covers nothing: this access opens no part of the record", "invited": "Invited", "accepted": "Accepted", "expires": "Lapses", @@ -11358,7 +11594,7 @@ "errorScope": "Those sections cannot be shared like that. Pick at least one section, or share the entire record.", "errorManageBrowserOnly": "Manage access can only be offered in a browser.", "errorStepUp": "Confirm your second factor, then try again.", - "errorOffline": "That did not reach the server. Your invitation is still here — check your connection and try again." + "errorOffline": "That did not reach the server. Your invitation is still here. Check your connection and try again." }, "given": { "title": "Who can open my record", @@ -11413,7 +11649,7 @@ "managed": { "title": "Records you look after", "description": "Health records with no login of their own.", - "explainer": "A managed profile is a health record for somebody who does not sign in themselves — a child, or a person you care for. It has no login and no e-mail address, and you become its first guardian. You can add more guardians later, and any of you can switch into the record to keep it up to date.", + "explainer": "A managed profile is a health record for somebody who does not sign in themselves, such as a child or a person you care for. It has no login and no e-mail address, and you become its first guardian. You can add more guardians later, and any of you can switch into the record to keep it up to date.", "nameLabel": "Name", "namePlaceholder": "The name you will recognise this record by", "nameHint": "Up to 80 characters. Shown wherever this record appears.", @@ -11435,7 +11671,7 @@ "creating": "Creating…", "created": "{name} is set up. You can switch into that record from the account menu.", "errorStepUp": "Confirm your second factor, then try again.", - "errorOffline": "That did not reach the server. Nothing has changed — check your connection and try again.", + "errorOffline": "That did not reach the server. Nothing has changed. Check your connection and try again.", "errorInvalid": "The server refused those details. Check the name, the date and the timezone.", "errorFailed": "The profile could not be created.", "edit": "Edit", @@ -11446,7 +11682,7 @@ "editErrorFailed": "The changes could not be saved.", "delete": "Delete profile", "deleteTitle": "Delete {name}?", - "deleteBody": "Everything recorded for {name} goes with the profile — readings, medications, documents and notes. This cannot be undone.", + "deleteBody": "Everything recorded for {name} goes with the profile: readings, medications, documents and notes. This cannot be undone.", "deleteGuardiansOne": "{count} person looks after this record today and will lose it.", "deleteGuardiansFew": "{count} people look after this record today and will lose it.", "deleteGuardiansOther": "{count} people look after this record today and will lose it.", @@ -11475,7 +11711,47 @@ "guardianErrorFailed": "The invitation could not be sent.", "errorMfaRequired": "This needs a second factor, and your account does not have one yet. Set one up under Settings → Security, then try again.", "rosterLoadError": "Could not load who looks after this record.", - "errorRateLimit": "Too many profiles created in the last hour. Wait a while and try again." + "errorRateLimit": "Too many profiles created in the last hour. Wait a while and try again.", + "handover": { + "action": "Hand over", + "title": "Hand over to {name}", + "description": "Give {name} a sign-in of their own. Nothing is copied or moved: the record becomes their account.", + "guardiansLabel": "Access after the handover", + "guardiansHint": "Your proposal. {name} sees it and decides after signing in.", + "access": { + "end": "No access", + "read": "View only", + "manage": "Manage" + }, + "expiryLabel": "Link valid for", + "expiry1": "1 day", + "expiry7": "7 days", + "expiry14": "14 days", + "minorHint": "{name} is under 16. Keeping view access is worth considering, and so is talking the step through together.", + "irreversible": "Once {name} uses the link, the handover cannot be undone. The record is theirs, and they decide who keeps access.", + "afterwards": "{name} confirms the disclaimer and any AI consent personally. Reminders for this record stop reaching you.", + "replaceNote": "Creating a new link withdraws the open one.", + "create": "Create link", + "creating": "Creating…", + "linkOnce": "This link is shown only now. Give it to {name} in person or through a channel you trust, or let them scan the code.", + "qrAlt": "QR code of the handover link for {name}", + "urlLabel": "Handover link", + "copy": "Copy", + "copied": "Link copied", + "copyError": "The link could not be copied. Select it and copy it by hand.", + "linkExpires": "Valid until {date}.", + "done": "Done", + "pending": "A handover link is open until {date}.", + "replace": "New link", + "revoke": "Withdraw link", + "revoked": "Link withdrawn", + "revokeFailed": "The link could not be withdrawn. Try again.", + "errorFailed": "The handover link could not be created.", + "errorRosterChanged": "The list of guardians changed in the meantime. Close this and open it again.", + "oidcOnly": "Handing over a profile is not available while this server allows single sign-on only.", + "loadError": "The handover status could not be loaded.", + "entryHint": "When {name} is ready to look after this record themselves." + } }, "toast": { "savedTo": "Saved to {name}'s record" @@ -11594,7 +11870,13 @@ "symptomEventDelete": "{name} deleted a symptom entry", "symptomDefinitionCreate": "{name} added a symptom", "symptomDefinitionUpdate": "{name} changed a symptom", - "symptomDefinitionDelete": "{name} removed a symptom" + "symptomDefinitionDelete": "{name} removed a symptom", + "customVaccineCreate": "{name} added an own vaccine", + "customVaccineUpdate": "{name} edited an own vaccine", + "customVaccineDelete": "{name} removed an own vaccine", + "lifeEventCreate": "{name} added a life event", + "lifeEventUpdate": "{name} changed a life event", + "lifeEventDelete": "{name} removed a life event" }, "section": { "measurements": { @@ -11632,7 +11914,7 @@ }, "actionError": { "retry": "Try again", - "offline": "That did not reach the server. Nothing has changed — check your connection and try again.", + "offline": "That did not reach the server. Nothing has changed. Check your connection and try again.", "rateLimited": "Too many attempts in a row. Wait a minute and try again.", "acceptExpired": "That invitation has lapsed. Ask for a new one.", "acceptWithdrawn": "That invitation was withdrawn.", @@ -11644,12 +11926,28 @@ "notFound": "That access is no longer there.", "revokeFailed": "The access could not be ended.", "renounceFailed": "The access could not be handed back.", - "profileNotFound": "That profile is not there any more — it may already have been deleted, or your access to it may have ended.", + "profileNotFound": "That profile is not there any more. It may already have been deleted, or your access to it may have ended.", "deleteProfileFailed": "The profile could not be deleted.", "removeGuardianFailed": "That access could not be ended." }, "anamnesis": { "relativeNote": "A family history entry describes this person's relatives. Add it only when they would want it recorded here." + }, + "handoverDecision": { + "title": "Who keeps access to your record", + "description": "Decide what the people who looked after your record may do from now on.", + "proposed": "Proposed: {access}", + "settled": "Already changed under Shared access", + "empty": "Nobody else had access to your record.", + "later": "You can change this any time under Settings, Shared access.", + "save": "Confirm", + "saving": "Saving…", + "decideLater": "Later", + "errorFailed": "Your decision could not be saved. Try again.", + "loadError": "Your former guardians could not be loaded.", + "notificationsTitle": "Set up your own reminders", + "notificationsBody": "Reminders for this record used to reach the people looking after it. From now on they reach you, as soon as you add a notification channel.", + "notificationsAction": "Open notification settings" } }, "encounters": { @@ -11670,7 +11968,7 @@ "both": "Both sides", "none": "Not stated" }, - "bodySiteWithSide": "{site} · {side}", + "bodySiteWithSide": "{site} ({side})", "sectionTitle": "Visits", "sectionDescription": "Doctor's visits and booked appointments in one place.", "add": "Visit", @@ -11685,7 +11983,7 @@ "created": "Visit saved", "updated": "Visit updated", "saveFailed": "The visit could not be saved.", - "checkupNotClosed": "Visit saved. The checkup stays open — your access does not cover that.", + "checkupNotClosed": "Visit saved. The checkup stays open; your access does not cover that.", "status": { "planned": "Planned", "done": "Attended", @@ -11702,7 +12000,7 @@ "dateDerivesStatusHint": "A date in the future is a booked appointment.", "status": "Status", "practitioner": "Practice or doctor", - "practitionerHint": "Optional — you can add the name later.", + "practitionerHint": "Optional: you can add the name later.", "practitionerNone": "Not specified", "practitionerSearch": "Search name or practice", "practitionerNoMatch": "No entry found.", @@ -11760,9 +12058,8 @@ }, "practitioners": { "title": "Practices and doctors", - "description": "Your own address book — type a practice once, pick it everywhere.", + "description": "Your own address book: type a practice once, pick it everywhere.", "linkLabel": "Address book", - "add": "Entry", "searchPlaceholder": "Search name, practice or specialty", "loadError": "The address book could not be loaded.", "emptyTitle": "No entry yet", @@ -11859,7 +12156,6 @@ "unreadableLabel": "Unreadable symptom", "intensityPill": "{value}/10", "entry": { - "sheetTitle": "Log symptom", "sheetDescription": "One of your own symptoms, how strong and when.", "symptom": "Symptom", "symptomRequired": "Choose a symptom.", @@ -11908,5 +12204,411 @@ "title": "Symptoms logged", "filed": "logged with this illness" } + }, + "day": { + "scoreOfMax": "of {max}", + "cycleDay": "Cycle day {n}", + "cyclePhaseLine": { + "MENSTRUAL": "Cycle day {n}, menstrual phase", + "FOLLICULAR": "Cycle day {n}, follicular phase", + "OVULATORY": "Cycle day {n}, ovulatory phase", + "LUTEAL": "Cycle day {n}, luteal phase" + }, + "previousDay": "Previous day", + "nextDay": "Next day", + "close": "Close day", + "hidePanel": "Hide day", + "showPanel": "Show {date}", + "openDay": "Open day", + "viewWholeDay": "View the whole day", + "captureForDay": "Add for this day", + "empty": "There are no entries for this day.", + "notShared": "Some areas are not shared with you.", + "inTimeline": "In the timeline", + "askCoach": "Ask the Coach about this day", + "chartHint": "Click a point to open its day.", + "loadFailed": "This day could not be loaded.", + "dayOf": "Day {n} of {m}", + "dayN": "Day {n}", + "usualRange": "Usually {lo} to {hi}", + "groups": { + "scores": "Scores", + "running": "Running that day", + "values": "Values", + "happened": "What happened" + }, + "notable": { + "extremeHigh": "Highest daily value since {since}", + "extremeLow": "Lowest daily value since {since}", + "firstValue": "First reading of this kind", + "gap": "No reading for {days} days" + }, + "previousDayShortcut": "Previous day (Alt + Left arrow)", + "nextDayShortcut": "Next day (Alt + Right arrow)", + "captureShort": "Log", + "inTimelineShort": "Timeline", + "askCoachPrompt": "What happened on {date}?", + "chartHintTouch": "Tap a point to see its value.", + "chartHintBar": "Click a bar to open its day.", + "chartHintTouchBar": "Tap a bar to see its values.", + "chartHintCell": "Click a day to open it.", + "chartHintTouchCell": "Tap a day to see its value.", + "coachChips": "Days in this answer", + "description": "Everything your records hold for this day.", + "openDocument": "Open document", + "previousReading": "Before, {date}", + "rugLegend": "Dots under the axis mark days with entries, a ring marks a notable day.", + "sheetExpand": "Expand to full height", + "sheetShrink": "Shrink", + "valuesLegend": "Dot for this day, bar for the 30 days before", + "valuesAllOne": "All values", + "valuesAllFew": "All {count} values", + "valuesAllOther": "All {count} values", + "valuesFewer": "Fewer values", + "countEntriesOne": "1 entry", + "countEntriesFew": "{count} entries", + "countEntriesOther": "{count} entries", + "meanOfReadingsOne": "Average of 1 reading", + "meanOfReadingsFew": "Average of {count} readings", + "meanOfReadingsOther": "Average of {count} readings", + "sinceLastVisit": { + "title": "Since the last visit on {date}", + "countOne": "1 entry", + "countFew": "{count} entries", + "countOther": "{count} entries", + "allOne": "Show all", + "allFew": "Show all {count}", + "allOther": "Show all {count}", + "fewer": "Show fewer", + "note": "Compiled from your entries, without judgement. Every date opens the whole day.", + "report": "Doctor report from {date} to today" + }, + "travel": "Away", + "event": { + "started": "{label} started", + "ended": "{label} ended", + "paused": "{label} paused", + "resumed": "{label} resumed", + "illnessBegan": "{label} began", + "illnessResolved": "{label} resolved" + }, + "pickDate": "Choose a date", + "pickerTitle": "Go to a day", + "today": "Today", + "hasEntries": "has entries", + "dayNSince": "Day {n}, since {date}", + "sinceDate": "Since {date}" + }, + "timeline": { + "title": "Timeline", + "subtitle": "What happened when, beside your values.", + "standing": "Ongoing", + "startUnknown": "start unknown", + "zoom": { + "all": "All", + "year": "12 months", + "quarter": "3 months", + "range": "Range" + }, + "lanes": { + "life": "Life", + "illness": "Conditions", + "allergies": "Allergies", + "medications": "Medications", + "vaccinations": "Vaccinations", + "visits": "Doctor and procedures", + "labs": "Labs", + "documents": "Documents", + "cycle": "Cycle" + }, + "readiness": { + "title": "What your timeline already carries", + "subtitle": "An honest inventory before you open it.", + "menuEntry": "Data coverage", + "hint": "Empty lanes stay hidden until something is there. Modules you switched off never appear. You find this overview later under Data coverage in the timeline.", + "later": "Later", + "open": "Open timeline", + "verdict": { + "carries": "It carries already.", + "thin": "Still thin. For now the timeline shows mostly your values." + }, + "status": { + "carries": "carries", + "thin": "thin", + "empty": "empty" + }, + "lanes": { + "values": "Values", + "mood": "Mood", + "environment": "Environment", + "life": "Life events" + }, + "meter": "{carrying} of {total} lanes carry", + "carriesDetail": "{carrying} of {total} lanes give an overview from {since}.", + "loadFailed": "The inventory could not be loaded.", + "card": { + "title": "What the timeline already carries", + "summary": "It reaches back to {since}; {carrying} of {total} lanes already carry.", + "dismiss": "Hide" + }, + "detail": { + "valuesOne": "{count} measure since {since}", + "valuesFew": "{count} measures since {since}", + "valuesOther": "{count} measures since {since}", + "illnessOne": "{count} episode, {chronic} of them chronic", + "illnessFew": "{count} episodes, {chronic} of them chronic", + "illnessOther": "{count} episodes, {chronic} of them chronic", + "allergiesOne": "{count} allergy", + "allergiesFew": "{count} allergies", + "allergiesOther": "{count} allergies", + "medicationsOne": "{count} medication, {missingStart} without a start date", + "medicationsFew": "{count} medications, {missingStart} without a start date", + "medicationsOther": "{count} medications, {missingStart} without a start date", + "medicationsDatedOne": "{count} medication with a start date", + "medicationsDatedFew": "{count} medications with a start date", + "medicationsDatedOther": "{count} medications with a start date", + "vaccinationsOne": "{count} vaccination", + "vaccinationsFew": "{count} vaccinations", + "vaccinationsOther": "{count} vaccinations", + "visitsOne": "{count} visit, {procedures} with a procedure", + "visitsFew": "{count} visits, {procedures} with a procedure", + "visitsOther": "{count} visits, {procedures} with a procedure", + "visitsPlannedOne": "{count} visit, planned only", + "visitsPlannedFew": "{count} visits, planned only", + "visitsPlannedOther": "{count} visits, planned only", + "labsOne": "{count} day with results", + "labsFew": "{count} days with results", + "labsOther": "{count} days with results", + "documentsOne": "{count} dated document", + "documentsFew": "{count} dated documents", + "documentsOther": "{count} dated documents", + "lifeOne": "{count} life event", + "lifeFew": "{count} life events", + "lifeOther": "{count} life events", + "cycleOne": "{count} cycle", + "cycleFew": "{count} cycles", + "cycleOther": "{count} cycles", + "moodOne": "{count} entry since {since}", + "moodFew": "{count} entries since {since}", + "moodOther": "{count} entries since {since}", + "environmentOne": "{count} day with environment data", + "environmentFew": "{count} days with environment data", + "environmentOther": "{count} days with environment data", + "empty": "None yet" + }, + "gap": { + "valuesEmpty": "Connect sources", + "illnessEmpty": "Add an episode", + "medicationsWithoutStart": "Complete", + "visitsEmpty": "Add a visit", + "documentsUndated": "Review", + "labsEmpty": "Add a result", + "vaccinationsEmpty": "Add vaccinations", + "lifeEventsEmpty": "Add" + }, + "gapLine": { + "medicationsWithoutStartOne": "{count} medication without a start date", + "medicationsWithoutStartFew": "{count} medications without a start date", + "medicationsWithoutStartOther": "{count} medications without a start date", + "documentsUndatedOne": "{count} document without a date", + "documentsUndatedFew": "{count} documents without a date", + "documentsUndatedOther": "{count} documents without a date", + "valuesEmpty": "No values over a longer period yet", + "illnessEmpty": "No conditions with a start date yet", + "visitsEmpty": "No completed doctor visits yet", + "labsEmpty": "No lab results yet", + "vaccinationsEmpty": "No vaccinations yet", + "lifeEventsEmpty": "No life events yet" + } + }, + "legendSpan": "Period", + "legendEvent": "Event", + "legendMeanQuarter": "Quarterly mean", + "legendMeanMonth": "Monthly mean", + "legendMeanWeek": "Weekly mean", + "legendThin": "Fewer than 3 readings", + "legendGap": "Short gap without measurements", + "today": "Today", + "noDataYet": "No data yet", + "layers": "Layers", + "layersLanes": "Lanes", + "zoomLabel": "View range", + "chartLabel": "Timeline from {from} to {to}", + "keyboardHint": "Arrow keys move the selection, Home and End jump to either end, Enter opens the day.", + "loadFailed": "The timeline could not be loaded.", + "empty": { + "title": "Nothing on the timeline yet", + "description": "Dated entries appear here as soon as there are some." + }, + "values": { + "menuTitle": "Values under the lanes", + "trigger": "Values ({count})", + "noneInWindow": "No values in this range", + "series": { + "BLOOD_PRESSURE_SYS": "Blood pressure sys.", + "BLOOD_PRESSURE_DIA": "Blood pressure dia." + }, + "noValue": "no value", + "bloodPressure": "Blood pressure", + "readingsOne": "1 reading", + "readingsFew": "mean of {count} readings", + "readingsOther": "mean of {count} readings" + }, + "table": { + "caption": "Timeline entries", + "lane": "Lane", + "entry": "Entry", + "start": "Start", + "end": "End", + "seriesCaption": "Value means by period", + "period": "Period" + }, + "selection": { + "empty": "No entries in this period", + "since": "since {date}", + "range": "{from} to {to}", + "mean": "Ø {values}", + "week": "Week of {date}", + "hintClick": "Click a point in time to see it here", + "hintTap": "Tap a point in time to see it here" + }, + "chronicle": { + "months": "Months", + "years": "Years", + "groupingLabel": "Grouping", + "empty": "No entries from {from} to {to}", + "emptySingle": "No entries in {period}", + "ended": "{label} ended", + "afterDaysOne": "after 1 day", + "afterDaysFew": "after {count} days", + "afterDaysOther": "after {count} days", + "notable": { + "extremeHigh": "Highest value in a long while", + "extremeLow": "Lowest value in a long while", + "firstValue": "First value of a measure", + "gap": "A longer gap in the values" + }, + "notableMeta": "Open the day for details" + }, + "standingAllergiesOne": "{count} allergy", + "standingAllergiesFew": "{count} allergies", + "standingAllergiesOther": "{count} allergies", + "legendMeanDay": "Daily mean", + "range": { + "from": "From", + "to": "To", + "label": "Chosen range", + "open": "Choose a range", + "clear": "Show everything", + "apply": "Apply" + }, + "item": { + "labValuesOne": "1 lab value", + "labValuesFew": "{count} lab values", + "labValuesOther": "{count} lab values" + } + }, + "lifeEvents": { + "title": "Life event", + "add": "Life event", + "privacy": "Title and note are stored encrypted. They go to no AI.", + "emptyHint": "A move, a birth or a new job gives the years an anchor.", + "save": "Save", + "cancel": "Cancel", + "delete": "Delete life event", + "saved": "Life event saved", + "deleted": "Life event deleted", + "fields": { + "title": "What happened?", + "category": "Kind", + "when": "When", + "precision": "Precision", + "addEnd": "End date, if it was a period", + "endDate": "Until", + "note": "Note", + "optionalTag": "optional", + "notePlaceholder": "Something you want to know later", + "removeEnd": "Remove end date" + }, + "category": { + "FAMILY": "Family", + "HOME": "Home", + "WORK": "Work", + "LOSS": "Loss", + "OTHER": "Other" + }, + "precision": { + "DAY": "Day", + "MONTH": "Month", + "YEAR": "Year" + }, + "edit": "Edit life event", + "errors": { + "titleRequired": "Give the event a name.", + "titleTooLong": "At most 120 characters.", + "categoryRequired": "Choose a kind.", + "dateRequired": "Enter a valid date.", + "endBeforeStart": "The end is before the start.", + "noteTooLong": "At most 2000 characters." + }, + "saveFailed": "Saving did not work.", + "deleteFailed": "Deleting did not work.", + "deleteConfirm": { + "title": "Delete this life event?", + "body": "Its title and note will be gone." + } + }, + "palette": { + "open": "Search…", + "openLabel": "Search pages, settings and actions", + "title": "Search", + "description": "Find a page, a setting, a module or an action.", + "placeholder": "Search pages, settings, modules…", + "results": "Results", + "noResults": "Nothing found for “{query}”.", + "moduleOn": "On", + "moduleOff": "Off", + "hintMove": "Move", + "hintOpen": "Open", + "hintClose": "Close", + "groups": { + "recent": "Recent", + "pages": "Pages", + "insights": "Insights", + "settings": "Settings", + "modules": "Modules", + "actions": "Actions" + }, + "actions": { + "capture": "New entry", + "coach": "Ask the Coach", + "backup": "Create a backup", + "today": "Open today", + "shortcuts": "Show keyboard shortcuts" + } + }, + "shortcuts": { + "title": "Keyboard shortcuts", + "description": "Keys that work anywhere in the app, except while you type in a field.", + "menuEntry": "Keyboard shortcuts", + "then": "then", + "search": "Search", + "help": "Show this list", + "close": "Close a dialog or panel", + "previousDay": "Previous day", + "nextDay": "Next day", + "dayHint": "While a day is open.", + "groups": { + "navigation": "Navigation", + "actions": "Actions", + "day": "Day" + } + }, + "aboutPage": { + "eyebrow": "About", + "title": "About HealthLog", + "lastUpdated": "Last updated: {date}", + "project": "Project", + "credits": "Credits" } } diff --git a/messages/es.json b/messages/es.json index 53a75e898..f28a25543 100644 --- a/messages/es.json +++ b/messages/es.json @@ -49,7 +49,9 @@ "dismiss": "Descartar", "moreInfo": "Más información", "durationHoursMinutes": "{hours} h {minutes} min", - "durationMinutes": "{minutes} min" + "durationMinutes": "{minutes} min", + "durationClockMinutes": "{time} min", + "durationClockHours": "{time} h" }, "apiErrors": { "measurement": { @@ -165,7 +167,7 @@ "anamnesisShiftSchedule": "Horario de trabajo", "anamnesisColFact": "Dato del perfil", "anamnesisColValue": "Valor registrado", - "subtitle": "HealthLog — Informe personal de salud", + "subtitle": "HealthLog: informe personal de salud", "patient": "Paciente", "dateOfBirth": "Fecha de nacimiento", "gender": "Sexo", @@ -175,18 +177,18 @@ "height": "Altura", "period": "Período del informe", "createdOn": "Creado el", - "vitalsTitle": "Signos vitales — Resumen", + "vitalsTitle": "Signos vitales: resumen", "colParameter": "Parámetro", "colCurrent": "Actual", "colAvgPeriod": "Med. periodo", "colMin": "Mín", "colMax": "Máx", "colN": "n", - "bpClassificationTitle": "Tensión arterial — Clasificación ESH (2023)", + "bpClassificationTitle": "Tensión arterial: clasificación ESH (2023)", "avgBp": "Tensión arterial media", "classification": "Clasificación", "bmiTitle": "Índice de masa corporal (IMC)", - "bmiRow": "IMC:{bmi} kg/m² — Classification: {class} (WHO)", + "bmiRow": "IMC:{bmi} kg/m², Classification: {class} (WHO)", "complianceTitle": "Cumplimiento de medicación", "glp1Title": "Terapia GLP-1", "glp1WeightSummary": "Peso en el periodo del informe: {start} kg → {end} kg ({delta} kg)", @@ -204,7 +206,7 @@ "colExpected": "Esperadas", "colComplianceRate": "Tasa de cumplimiento", "moodTitle": "Evolución del estado de ánimo", - "moodSummary": "Promedio:{avg} / 5.0 · Entries: {count} · Range: {min}–{max}", + "moodSummary": "Promedio {avg} / 5.0, {count} entradas, rango {min}–{max}", "colMood": "Estado de ánimo", "colCount": "Cantidad", "colShare": "Porcentaje", @@ -234,8 +236,8 @@ "typeGlucoseRandom": "Glucosa (aleatoria)", "typeGlucoseBedtime": "Glucosa (antes de dormir)", "typeGlucoseUnspecified": "Glucosa (sin indicar)", - "glucoseClassificationTitle": "Glucemia — Rangos de referencia (ADA 2024 / DDG)", - "glucoseRow": "{label}: med. {avg} {unit} (objetivo {rangeMin}–{rangeMax}) — {class}", + "glucoseClassificationTitle": "Glucemia: rangos de referencia (ADA 2024 / DDG)", + "glucoseRow": "{label}: med. {avg} {unit} (objetivo {rangeMin}–{rangeMax}), {class}", "glucoseInTarget": "en objetivo", "glucoseBelowTarget": "por debajo del objetivo", "glucoseAboveTarget": "por encima del objetivo", @@ -287,7 +289,7 @@ "gmi": "Indicador de gestión de la glucosa (GMI)", "eA1c": "A1C estimada", "cv": "Variabilidad (CV%)", - "spotCaveat": "Son estimaciones a partir de mediciones puntuales, no un perfil de monitor continuo — léelas como una tendencia, no como un veredicto clínico." + "spotCaveat": "Son estimaciones a partir de mediciones puntuales, no un perfil de monitor continuo. Léelas como una tendencia, no como un veredicto clínico." }, "illnessTitle": "Afecciones y enfermedades", "illnessOngoing": "En curso", @@ -376,12 +378,12 @@ "backToDashboard": "Volver al panel" }, "offlineBanner": { - "message": "Sin conexión — los cambios no se pueden guardar hasta que vuelvas a estar en línea.", - "saveFailed": "No se pudo guardar — estás sin conexión. Inténtalo de nuevo cuando vuelvas a estar en línea.", + "message": "Sin conexión: los cambios no se pueden guardar hasta que vuelvas a estar en línea.", + "saveFailed": "No se pudo guardar porque estás sin conexión. Inténtalo de nuevo cuando vuelvas a estar en línea.", "showingCached": "Mostrando tus últimos datos sincronizados." }, "demoBanner": { - "message": "Modo demo — los cambios no se guardan." + "message": "Modo demo: los cambios no se guardan." }, "nav": { "dashboard": "Panel", @@ -427,7 +429,9 @@ "workout": "Entrenamiento", "workoutDescription": "Una sesión que tus dispositivos no registraron", "symptom": "Síntoma", - "symptomDescription": "Uno de tus propios síntomas, con su intensidad" + "symptomDescription": "Uno de tus propios síntomas, con su intensidad", + "lifeEventDescription": "Una mudanza, un nacimiento, un nuevo trabajo", + "lifeEvent": "Acontecimiento vital" }, "cycle": "Ciclo", "labs": "Análisis", @@ -436,7 +440,8 @@ "illness": "Enfermedad", "documents": "Documentos", "vaccinations": "Vacunas", - "mentalWellbeing": "Bienestar mental" + "mentalWellbeing": "Bienestar mental", + "timeline": "Cronología" }, "auth": { "login": "Iniciar sesión", @@ -460,7 +465,6 @@ "signOut": "Cerrar sesión y aceptar la invitación" }, "createAccount": "Crear cuenta", - "newAccount": "Crear cuenta nueva", "backToPasskey": "Volver a passkey", "passkeySetupLater": "Puedes configurar passkeys más tarde en los ajustes.", "passwordMinLength": "Mín. 12 caracteres", @@ -513,6 +517,37 @@ "fixLan": "Servidor en una red doméstica sin TLS: pon SESSION_COOKIE_SECURE=false en su configuración y reinícialo.", "fixHttps": "Servidor detrás de un proxy HTTPS: abre la dirección https:// y asegúrate de que el proxy reenvía X-Forwarded-Proto: https.", "localhostNote": "Esta página está en http://localhost y el servidor solo establece su cookie de sesión para https://. La mayoría de los navegadores lo aceptan en localhost; si vuelves aquí tras iniciar sesión, pon SESSION_COOKIE_SECURE=false." + }, + "claim": { + "title": "Asume tu historial de salud", + "titleNamed": "Te damos la bienvenida, {name}", + "description": "Hasta ahora otras personas han llevado tu historial de salud por ti. Elige cómo iniciar sesión y pasará a ser tu propia cuenta. No se copia ni se mueve nada.", + "loading": "Comprobando tu enlace…", + "invalid": "Este enlace ya no funciona", + "invalidHint": "Es desconocido, ha caducado, se ha retirado o ya se ha usado. Pide uno nuevo a quien te lo envió.", + "oidcOnly": "Este servidor solo permite el inicio de sesión único, así que aquí todavía no se puede asumir un perfil.", + "signedInTitle": "Cierra la sesión primero", + "signedInBody": "Has iniciado sesión como {username}. Este enlace crea un acceso para otra persona, así que cierra la sesión antes de usarlo.", + "signOut": "Cerrar sesión y continuar", + "guardiansTitle": "Quién conserva el acceso por ahora", + "guardiansHint": "Lo proponen las personas que llevaron tu historial. Tú decides justo después de iniciar sesión.", + "guardianUnnamed": "Una persona tutora", + "usernameHint": "De 3 a 30 letras, números, _ o -.", + "usernameReserved": "Los nombres de usuario que empiezan por managed- están reservados.", + "emailHint": "Obligatorio, para que puedas volver a entrar si olvidas tu contraseña.", + "consentNote": "Confirmas tú mismo el aviso y cualquier uso de IA. Hasta entonces, nada de tu historial se envía a un proveedor de IA.", + "expires": "Este enlace es válido hasta el {date}.", + "submit": "Asumir", + "submitting": "Asumiendo…", + "errorTaken": "Ese nombre de usuario o correo electrónico ya está en uso.", + "errorRateLimit": "Demasiados intentos. Espera unos minutos y vuelve a intentarlo.", + "errorFailed": "No ha funcionado. Vuelve a intentarlo en un momento.", + "errorOffline": "No se llegó al servidor. Comprueba tu conexión y vuelve a intentarlo." + }, + "inviteInvalid": { + "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" } }, "dashboard": { @@ -544,17 +579,17 @@ "sleepShort": "Sueño", "stepsShort": "Pasos", "vo2MaxShort": "VO₂ máx", - "bpInTargetWindow90": "PA-objetivo · 90 d", - "bpInTargetWindowDynamic": "PA-objetivo · {days} d", + "bpInTargetWindow90": "PA-objetivo (90 d)", + "bpInTargetWindowDynamic": "PA-objetivo ({days} d)", "bpInTargetCollecting": "recopilando datos", "medications": "Medicamentos", "quickAddMeasurement": "Registrar medición", "quickAddMood": "Registrar estado de ánimo", "quickAddMedicationIntake": "Registrar toma", "quickAddWorkout": "Registrar entrenamiento", + "quickAddLifeEvent": "Registrar acontecimiento vital", "customizeDashboard": "Personalizar panel", "medicationIntakeQuickAdd": { - "sheetTitle": "Registrar toma de medicación", "sheetDescription": "Registra dosis, hora y medicamento en unos pocos toques.", "medicationLabel": "Medicamento", "medicationPlaceholder": "Selecciona un medicamento", @@ -667,7 +702,8 @@ "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" + "quickAddSymptom": "Registrar síntoma", + "todayCardVisible": "Mostrar la tarjeta superior" }, "measurements": { "title": "Mediciones", @@ -739,7 +775,7 @@ "formReset": "Restablecer formulario", "saveError": "Error al guardar", "duplicateTimestamp": "Ya existe una medición de este tipo en la marca de tiempo seleccionada.", - "serverOwnedSource": "Los valores de una fuente conectada no se pueden editar — la fecha y la nota siguen siendo editables.", + "serverOwnedSource": "Los valores de una fuente conectada no se pueden editar, pero la fecha y la nota siguen siendo editables.", "deleteError": "Error al eliminar", "deletedToast": "Medición eliminada", "restoredToast": "Medición restaurada", @@ -834,7 +870,8 @@ "durationHoursOnly": "{hours} h", "durationMinutesOnly": "{minutes} min", "durationInvalid": "Escribe una duración como 7,5, 7:30 o 7h 30m.", - "durationTooLong": "Una entrada no puede durar más de 24 horas." + "durationTooLong": "Una entrada no puede durar más de 24 horas.", + "sourceHealthConnect": "Health Connect" }, "metricCatalog": { "title": "Lo que HealthLog puede registrar", @@ -846,11 +883,11 @@ "connectCta": "Conectar una fuente", "viewCta": "Ver", "ecg": { - "source": "Tiras de ECG de una derivación desde un reloj compatible — hoy, Withings ScanWatch — sincronizadas automáticamente." + "source": "Tiras de ECG de una derivación desde un reloj compatible (hoy, Withings ScanWatch), sincronizadas automáticamente." }, "watchdog": { "title": "Supervisión automática", - "description": "HealthLog también vigila en segundo plano — notificaciones de ritmo irregular y alteraciones respiratorias de un reloj conectado, alertas de desviación de tu valor de referencia, y cambios destacables entre tus dos últimos paneles de laboratorio — y solo muestra una tarjeta cuando algo destaca. Si al menos una de esas fuentes está conectada, que no aparezca ninguna tarjeta es una buena señal, no una función ausente; si todavía no hay ninguna conectada, sencillamente no se está vigilando nada." + "description": "HealthLog también vigila en segundo plano (notificaciones de ritmo irregular y alteraciones respiratorias de un reloj conectado, alertas de desviación de tu valor de referencia, y cambios destacables entre tus dos últimos paneles de laboratorio) y solo muestra una tarjeta cuando algo destaca. Si al menos una de esas fuentes está conectada, que no aparezca ninguna tarjeta es una buena señal, no una función ausente; si todavía no hay ninguna conectada, sencillamente no se está vigilando nada." } }, "mood": { @@ -1210,8 +1247,8 @@ "dialogDescription": "Estos medicamentos están pendientes ahora. Confirma para registrarlos todos como tomados.", "confirm": "Tomar todos ({count})", "successToast": "{count} tomas registradas", - "partialToast": "{taken} registradas, {failed} fallidas – inténtalo de nuevo", - "failedToast": "No se registró ninguna toma – inténtalo de nuevo" + "partialToast": "{taken} registradas, {failed} fallidas. Inténtalo de nuevo", + "failedToast": "No se registró ninguna toma. Inténtalo de nuevo" }, "logIntake": { "dialogTitle": "Registrar una toma", @@ -1300,7 +1337,7 @@ "complianceInfoLabel": "¿Qué significa el cumplimiento?", "lastIntake": "Última toma:", "nextIntake": "Próxima toma:", - "nextIntakeOverdue": "Atrasada · {time} — aún se puede tomar", + "nextIntakeOverdue": "Atrasada desde {time}, aún se puede tomar", "dayStreak": "días de racha", "dayStreakOne": "día de racha", "cycleDueToday": "Pendiente hoy", @@ -1394,10 +1431,10 @@ "error": { "empty": "Escribe una descripción primero.", "tooLong": "Mantén la descripción por debajo de 2000 caracteres.", - "rateLimit": "Demasiadas solicitudes — espera un momento e inténtalo de nuevo.", - "noProvider": "Sin proveedor de IA configurado — configúralo en Ajustes.", - "budget": "Se alcanzó el presupuesto diario de IA — inténtalo de nuevo mañana.", - "network": "No se pudo contactar con el proveedor de IA — inténtalo de nuevo.", + "rateLimit": "Demasiadas solicitudes. Espera un momento e inténtalo de nuevo.", + "noProvider": "Sin proveedor de IA configurado. Configúralo en Ajustes.", + "budget": "Se alcanzó el presupuesto diario de IA. Inténtalo de nuevo mañana.", + "network": "No se pudo contactar con el proveedor de IA. Inténtalo de nuevo.", "consent": "Leer tu descripción con IA necesita primero tu consentimiento.", "unavailable": "Leer descripciones con IA no está disponible aquí ahora mismo." } @@ -1413,7 +1450,7 @@ "everyNMonths": "Cada N meses un día concreto", "yearly": "Una vez al año", "rolling": "Cada N días desde la última toma (flexible)", - "rollingExplainer": "Cuenta desde tu última toma registrada — se pausa si te saltas una dosis.", + "rollingExplainer": "Cuenta desde tu última toma registrada y se pausa si te saltas una dosis.", "oneShot": "Dosis única" }, "weekdays": { @@ -1471,7 +1508,7 @@ "night": "Noche" }, "max": { - "reached": "Máximo alcanzado — quita una hora antes de añadir otra." + "reached": "Máximo alcanzado: quita una hora antes de añadir otra." } }, "courseWindow": { @@ -1521,7 +1558,7 @@ }, "step3": { "title": "¿Cuál es la dosis?", - "subline": "Una pastilla, una inhalación, una gota — según la forma.", + "subline": "Una pastilla, una inhalación, una gota, según la forma.", "amountLabel": "Cantidad", "amountPlaceholder": "p. ej. 5", "unitLabel": "Unidad", @@ -1547,7 +1584,7 @@ }, "dosesPerUnitLabel": "Dosis por unidad", "dosesPerUnitPlaceholder": "p. ej. 4", - "dosesPerUnitHint": "Dosis por pluma / vial — déjalo vacío para no controlar el inventario.", + "dosesPerUnitHint": "Dosis por pluma / vial. Déjalo vacío para no controlar el inventario.", "short": "Dosis", "unitsPerDoseLabel": "Unidades por dosis", "unitsPerDoseHint": "p. ej., 2 comprimidos de 2 mg para una dosis de 4 mg, o ½ comprimido para una dosis partida" @@ -1559,7 +1596,7 @@ }, "step5": { "title": "¿Con qué frecuencia lo tomas?", - "subline": "Elige la frecuencia — los detalles vienen en el siguiente paso.", + "subline": "Elige la frecuencia. Los detalles vienen en el siguiente paso.", "short": "Frecuencia" }, "step6": { @@ -1581,7 +1618,7 @@ }, "step7": { "title": "¿Cuándo lo tomas?", - "subline": "Una o varias horas del día — los recordatorios siguen esta lista.", + "subline": "Una o varias horas del día. Los recordatorios siguen esta lista.", "presetsLabel": "Preajustes por hora del día", "presets": { "morning": "Mañana", @@ -1597,11 +1634,11 @@ "short": "Horas" }, "step8": { - "title": "Listo — ¿quieres que HealthLog te lo recuerde?", + "title": "Listo. ¿Quieres que HealthLog te lo recuerde?", "subline": "Revisa todo abajo antes de guardar.", "remindersLabel": "Recordatorios activados", - "remindersDescription": "Push, Telegram o ntfy — según tus ajustes.", - "noChannelHint": "Los recordatorios necesitan un canal de notificaciones — ahora mismo no hay ninguno activo.", + "remindersDescription": "Push, Telegram o ntfy, según tus ajustes.", + "noChannelHint": "Los recordatorios necesitan un canal de notificaciones, pero ahora mismo no hay ninguno activo.", "noChannelHintLink": "Configurar notificaciones", "short": "Listo" } @@ -1685,7 +1722,7 @@ } }, "errors": { - "submitFailed": "No se pudo guardar el medicamento — inténtalo de nuevo." + "submitFailed": "No se pudo guardar el medicamento. Inténtalo de nuevo." } }, "doseStrength": { @@ -1701,7 +1738,7 @@ "empty": "Aún no hay pasos de escalada de dosis registrados. Añade cambios de dosis con fecha para trazar el plan.", "youAreHere": "Estás aquí", "doseStep": "{dose} {unit}", - "plannedFor": "Previsto · {date}" + "plannedFor": "Previsto para {date}" }, "injectionSiteBodyOutlineAriaLabel": "Silueta del cuerpo", "injectionSitePickerAriaLabel": "Selector de sitio de inyección", @@ -1752,7 +1789,7 @@ "intakeHistoryStatusSkipped": "Omitida", "intakeHistorySortByTaken": "Ordenar por hora de toma", "intakeHistorySortByScheduled": "Ordenar por hora programada", - "intakeHistoryPageInfo": "Página {page} de {total} · {count} entradas", + "intakeHistoryPageInfo": "Página {page} de {total}, {count} entradas", "intakeHistoryPrev": "Anterior", "intakeHistoryNext": "Siguiente", "glp1": { @@ -1793,7 +1830,7 @@ "cadence": { "oneShotOn": "Dosis única el {date}.", "oneShotPending": "Dosis única pendiente.", - "asNeeded": "Según necesidad — sin plan fijo." + "asNeeded": "Según necesidad, sin plan fijo." }, "intake": { "title": "Historial de tomas", @@ -1852,7 +1889,7 @@ "markAsTaken": "Marcar como tomada", "markAsSkipped": "Marcar como omitida", "adHocTag": "fuera de horario", - "takenDetail": "prevista {planned} · tomada {taken}", + "takenDetail": "prevista {planned}, tomada {taken}", "doseTaken": "Dosis: {dose}", "status": { "taken_on_time": "a tiempo", @@ -1916,7 +1953,7 @@ }, "codes": { "label": "Códigos clínicos (ATC / RxNorm)", - "note": "Opcional. Se usan en la exportación FHIR del historial de salud. Los códigos se toman tal cual se introducen — nunca se adivinan.", + "note": "Opcional. Se usan en la exportación FHIR del historial de salud. Los códigos se toman tal cual se introducen; nunca se adivinan.", "atcLabel": "ATC", "rxNormLabel": "RxNorm", "saved": "Códigos clínicos guardados", @@ -1936,7 +1973,7 @@ "title": "Finalizar medicamento", "helper": "Detiene los recordatorios y marca el medicamento como finalizado. El historial sigue siendo visible.", "button": "Finalizar", - "dialogTitle": "Finalizar {name} — detiene los recordatorios, el historial sigue visible", + "dialogTitle": "Finalizar {name}: detiene los recordatorios, el historial sigue visible", "dialogBody": "No habrá más recordatorios. Las entradas antiguas permanecen en la línea de tiempo.", "toast": "Medicamento finalizado", "failed": "No se pudo finalizar el medicamento." @@ -1974,7 +2011,7 @@ "copyFailed": "No se pudo copiar.", "tokenCopied": "Token copiado", "copyToken": "Copiar token", - "mintedHint": "Copia este token ahora — solo se muestra una vez." + "mintedHint": "Copia este token ahora. Solo se muestra una vez." }, "advanced": { "dataPortability": { @@ -2003,7 +2040,6 @@ "bestand": "Existencias", "verlauf": "Historial", "injektion": "Inyección", - "api": "API", "erweitert": "Avanzado", "wirkung": "Efecto" }, @@ -2015,9 +2051,9 @@ "statusTitle": "Estado", "nextDoseLabel": "Próxima dosis", "nextDoseNone": "No hay dosis pendiente.", - "nextDoseAsNeeded": "Según necesidad — sin dosis programada.", + "nextDoseAsNeeded": "Según necesidad, sin dosis programada.", "nextDoseNotTracked": "Sin registro de tomas, se guarda como registro.", - "pausedHint": "En pausa — sin recordatorios.", + "pausedHint": "En pausa, sin recordatorios.", "reminderLabel": "Recordatorio", "reminderOn": "Activado", "reminderOff": "Desactivado", @@ -2210,9 +2246,8 @@ "tableStockDoses": "{count} dosis", "tableStockRunway": "≈ {days} días restantes", "cardLowStockRunway": "Reserva: ≈ {days} días", - "takeActionFor": "Tomar – {name}", - "skipActionFor": "Omitir – {name}", - "reorderTitle": "Ajustar orden", + "takeActionFor": "Tomar: {name}", + "skipActionFor": "Omitir: {name}", "reorderDescription": "Define el orden de tus medicamentos: se aplica a la vista de tarjetas y a la de tabla.", "reorderSaved": "Orden guardado", "reorderSaveFailed": "No se pudo guardar el orden", @@ -2232,7 +2267,7 @@ "levelShift": "Se detectó un cambio de nivel en torno a {date}.", "plateau": { "title": "Meseta de peso con la dosis actual", - "evidence": "Variación de peso de {delta} {unit} en los últimos {window} días ({readings} mediciones) — {days} días con {dose}." + "evidence": "Variación de peso de {delta} {unit} en los últimos {window} días ({readings} mediciones), {days} días con {dose}." }, "retarget": { "label": "¿No es la medición correcta?", @@ -2357,7 +2392,7 @@ }, "charts": { "loadingLabel": "Cargando la gráfica…", - "loadingSlowHint": "Calculando los análisis — puede tardar un momento.", + "loadingSlowHint": "Calculando los análisis. Puede tardar un momento.", "weight": "Peso", "bloodPressure": "Tensión arterial", "pulse": "Pulso", @@ -2379,6 +2414,7 @@ "days7Label": "7 d", "days7Title": "Últimos 7 días", "days30Label": "30 d", + "daysNLabel": "{days} d", "days30Title": "Últimos 30 días", "days90Label": "90 d", "days90Title": "Últimos 90 días", @@ -2474,7 +2510,8 @@ "moodHeatmapEmpty": "Mapa de calor de estado de ánimo, aún no hay días registrados.", "complianceHeatmap": "Mapa de calor de cumplimiento de medicación de {start} a {end}, {days} días activos.", "complianceHeatmapEmpty": "Mapa de calor de cumplimiento de medicación, aún no hay días activos." - } + }, + "medicationsEmptyAction": "Añadir un medicamento" }, "chart": { "overlay": { @@ -2505,8 +2542,8 @@ }, "tooltipPrior": "Periodo anterior", "unavailable": { - "lastMonth": "Comparación no disponible — sin datos del mes anterior", - "lastYear": "Comparación no disponible — sin datos del año anterior" + "lastMonth": "Comparación no disponible: sin datos del mes anterior", + "lastYear": "Comparación no disponible: sin datos del año anterior" }, "notAvailableForCompliance": "Comparación no disponible para el cumplimiento" }, @@ -2518,7 +2555,9 @@ "dismissError": "No se pudo descartar este patrón.", "restoreError": "No se pudo restaurar este patrón.", "loadError": "No se pudieron cargar los patrones.", - "empty": "Ningún patrón actual superó el umbral de evidencia." + "empty": "Ningún patrón actual superó el umbral de evidencia.", + "seasonalNote": "Desde la versión 1.42, HealthLog descuenta la estación del año y la tendencia a largo plazo antes de comparar valores. Verás menos relaciones, pero las que quedan son más sólidas. Muchos de los patrones anteriores eran solo el calendario.", + "seasonalNoteDismiss": "Cerrar aviso" }, "correlation": { "direction": { @@ -2550,29 +2589,37 @@ "ENV_DAYLIGHT": "la luz natural", "ENV_PRECIP": "las precipitaciones", "ENV_PRESSURE_MEAN": "la presión atmosférica", - "ENV_PRESSURE_DELTA": "la variación de presión del día" + "ENV_PRESSURE_DELTA": "la variación de presión del día", + "ENV_PM25": "las partículas finas (PM2,5)", + "ENV_OZONE_8H": "el ozono", + "ENV_POLLEN_MAX": "el polen" }, "daily": { - "high": "Cuando {behaviour} es mayor, {outcome} del día siguiente tiende a {direction} en tus datos — un patrón que conviene observar, no una causa.", - "moderate": "Cuando {behaviour} es mayor, {outcome} del día siguiente parece tender a {direction}, según los datos disponibles — un patrón que conviene observar, no una causa.", - "faint": "Cuando {behaviour} es mayor, apenas se insinúa —si acaso— que {outcome} del día siguiente tienda a {direction} en tus datos — demasiado leve para fiarse, nunca una causa." + "high": "Cuando {behaviour} es mayor, {outcome} del día siguiente tiende a {direction} en tus datos. Es un patrón que conviene observar, no una causa.", + "moderate": "Cuando {behaviour} es mayor, {outcome} del día siguiente parece tender a {direction}, según los datos disponibles. Es un patrón que conviene observar, no una causa.", + "faint": "Cuando {behaviour} es mayor, como mucho se insinúa en tus datos que {outcome} del día siguiente tienda a {direction}. Es demasiado leve para fiarse y nunca una causa." }, "lab": { - "default": "Unos valores de {lab} más altos coinciden con que {outcome} tienda a {direction} en los mismos periodos de tus datos — una asociación que conviene vigilar con tu médico, nunca una causa.", - "faint": "Unos valores de {lab} más altos apenas insinúan —si acaso— que {outcome} tienda a {direction} en los mismos periodos de tus datos — demasiado leve para fiarse, una asociación que conviene comentar con tu médico, nunca una causa." + "default": "Unos valores de {lab} más altos coinciden con que {outcome} tienda a {direction} en los mismos periodos de tus datos, una asociación que conviene vigilar con tu médico, nunca una causa.", + "faint": "Unos valores de {lab} más altos apenas insinúan que {outcome} tienda a {direction} en los mismos periodos de tus datos. Es demasiado leve para fiarse y nunca una causa, pero conviene comentarlo con tu médico." }, "hypothesis": { "bpCompliance": { - "support": "Una mayor adherencia a la medicación coincide con valores sistólicos más bajos — un patrón que conviene observar.", - "surprising": "La adherencia y la sistólica se mueven juntas en tus datos — sorprendente; consulta con tu médico antes de cambiar nada.", + "support": "Una mayor adherencia a la medicación coincide con valores sistólicos más bajos, un patrón que conviene observar.", + "surprising": "La adherencia y la sistólica se mueven juntas en tus datos, algo sorprendente. Consulta con tu médico antes de cambiar nada.", "none": "La adherencia y la sistólica no se mueven juntas de forma marcada en este periodo." }, "moodPulse": { - "support": "Los días de peor ánimo coinciden con un pulso en reposo más alto — un patrón que conviene observar.", - "surprising": "Los días de mejor ánimo coinciden con un pulso en reposo más alto en tus datos — sorprendente.", + "support": "Los días de peor ánimo coinciden con un pulso en reposo más alto, un patrón que conviene observar.", + "surprising": "Los días de mejor ánimo coinciden con un pulso en reposo más alto en tus datos, algo sorprendente.", "none": "El ánimo y el pulso en reposo no se mueven juntos de forma marcada en este periodo." }, - "weightWeekday": "El peso de los {weekday} está {delta} kg {direction} tu media de los demás días — un patrón que conviene observar." + "weightWeekday": "El peso de los {weekday} está {delta} kg {direction} tu media de los demás días, un patrón que conviene observar." + }, + "dailySameDay": { + "high": "Más {behaviour} el mismo día y el día anterior tiende a ir con {direction} {outcome} en tus datos: un patrón que conviene observar, no una causa.", + "moderate": "Más {behaviour} el mismo día y el día anterior parece ir con {direction} {outcome} en tus datos, según lo visto hasta ahora: un patrón que conviene observar, no una causa.", + "faint": "Más {behaviour} el mismo día y el día anterior muestra, como mucho, un leve indicio de {direction} {outcome} en tus datos: demasiado pequeño para fiarse, nunca una causa." } }, "noAnalysisYet": "Aún no hay valoración.", @@ -2619,7 +2666,7 @@ "narrativeWeek": "Esta semana", "narrativeMonth": "Este mes", "narrativeUpdating": "Actualizando…", - "heroFallbackSubtitle": "Una mirada diaria a tus tendencias — directamente de los datos que registras.", + "heroFallbackSubtitle": "Una mirada diaria a tus tendencias, directamente de los datos que registras.", "heroGreetingMorning": "Buenos días", "heroGreetingAfternoon": "Buenas tardes", "heroGreetingEvening": "Buenas noches", @@ -2684,7 +2731,7 @@ "referenceSleep": "Al menos {low} minutos por noche; el sueño más largo no se penaliza", "referenceAdiposity": "Relación cintura-altura inferior a {high}", "referenceHba1c": "HbA1c de {low} a {high} %", - "referenceDetail": "{label} · {source} · {kind} · {low} a {high}", + "referenceDetail": "{label}: de {low} a {high} ({kind}, {source})", "openBound": "abierto", "laboratoryReferenceSource": "Intervalos de referencia del laboratorio", "personalTargetSource": "Objetivo personal", @@ -2719,7 +2766,8 @@ "coverage": { "recommendedMet": "Cubre los tres ámbitos que la puntuación recomienda", "areas": "{present} de {required} ámbitos de salud" - } + }, + "historyLink": "Evolución" }, "dailyBriefing": { "title": "Resumen del día", @@ -2778,7 +2826,7 @@ "experimentCta": "Iniciar experimento de 7 días", "experimentTooltip": "Próximamente", "emptyTitle": "Necesitamos más datos para mostrar esta correlación", - "sourceChip": "basado en {n} mediciones pareadas · {window}", + "sourceChip": "basado en {n} mediciones pareadas ({window})", "sourceWindowLast30": "Últimos 30 días", "confidenceHigh": "Confianza alta", "confidenceModerate": "Confianza media", @@ -2793,14 +2841,14 @@ } }, "coach": { - "refusalOutOfScope": "Solo puedo ayudarte con los datos de salud de tu registro — mediciones, medicamentos, estado de ánimo, tendencias. Preguntas fuera de ese ámbito no las respondo.", - "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.", + "refusalOutOfScope": "Solo puedo ayudarte con los datos de salud de tu registro: mediciones, medicamentos, estado de ánimo, tendencias. Preguntas fuera de ese ámbito no las respondo.", + "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 (UTC).", "errorProvider": "El coach no ha podido contactar con un proveedor de análisis ahora mismo. Inténtalo en un momento.", "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", - "errorNetwork": "Sin conexión a Internet — inténtalo de nuevo cuando vuelvas a estar en línea.", + "errorNetwork": "Sin conexión a Internet. Inténtalo de nuevo cuando vuelvas a estar en línea.", "prefsSaveError": "No se pudieron guardar tus preferencias del Coach. Inténtalo de nuevo.", "errorCredentialExpired": "Tu conexión con el proveedor de IA ha caducado. Vuelve a conectarla en Ajustes para que el Coach siga funcionando.", "tagline": "Coach personal de salud, apoyado en tus datos.", @@ -2819,7 +2867,7 @@ "thinking": "Pensando…", "unverifiedFiguresNotice": "Algunas cifras no se pudieron contrastar con tus datos y se omitieron.", "composerPlaceholder": "Pregúntame algo …", - "nudgeBubbleLabel": "Nuevo mensaje del coach — abrir el chat", + "nudgeBubbleLabel": "Nuevo mensaje del coach: abrir el chat", "fabLabel": "Abrir el coach", "readAloud": "Leer en voz alta", "readAloudStop": "Detener lectura", @@ -2843,7 +2891,7 @@ "guided": { "offerTitle": "El Coach tiene {count} preguntas para ti", "offerTitleOne": "El Coach tiene una pregunta para ti", - "offerBody": "Respuestas breves afinan tu información personal — una pregunta a la vez, aquí en el chat.", + "offerBody": "Respuestas breves afinan tu información personal: una pregunta a la vez, aquí en el chat.", "offerStart": "Responder ahora", "offerLater": "Más tarde", "offerDismiss": "No volver a preguntar", @@ -2853,7 +2901,7 @@ "later": "Más tarde", "dismissRemaining": "No volver a preguntar", "answerPlaceholder": "Tu respuesta…", - "summaryTitle": "¡Listo — gracias por tus respuestas!", + "summaryTitle": "¡Listo, gracias por tus respuestas!", "summaryAnswered": "{answered} de {total} respondidas", "summaryAdopted": "{adopted} añadidas a tu información personal", "summaryAdoptedNone": "no se añadió nada nuevo a tu información personal", @@ -2865,6 +2913,7 @@ "historyEmpty": "Tus conversaciones aparecen aquí. Lanza una pregunta para empezar.", "historyDeleteAria": "Eliminar conversación", "historyDeleted": "Conversación eliminada", + "historyDeleteFailed": "No se pudo eliminar la conversación.", "rename": { "action": "Renombrar conversación", "input": "Título de la conversación", @@ -2956,23 +3005,23 @@ "cluster": { "cardio": { "label": "Cardiovascular", - "hint": "Presión · pulso · VFC · FC en reposo y al caminar" + "hint": "Presión, pulso, VFC, FC en reposo y al caminar" }, "body": { "label": "Composición corporal", - "hint": "Peso · grasa · músculo · IMC" + "hint": "Peso, grasa, músculo e IMC" }, "activity": { "label": "Actividad", - "hint": "Pasos · energía · distancia · VO₂ máx" + "hint": "Pasos, energía, distancia y VO₂ máx" }, "workouts": { "label": "Entrenamientos", - "hint": "Carreras · paseos · sesiones" + "hint": "Carreras, paseos y sesiones" }, "sleep": { "label": "Sueño", - "hint": "Duración · fases" + "hint": "Duración y fases" }, "mood": { "label": "Estado de ánimo", @@ -2984,15 +3033,15 @@ }, "medication": { "label": "Medicación", - "hint": "Adherencia · contexto GLP-1" + "hint": "Adherencia y contexto GLP-1" }, "mobility": { "label": "Movilidad y marcha", - "hint": "Estabilidad · asimetría · marcha" + "hint": "Estabilidad, asimetría y marcha" }, "environment": { "label": "Entorno", - "hint": "Ruido · luz del día · temp. piel" + "hint": "Ruido, luz del día y temperatura de la piel" } }, "settingsClustersLabel": "Datos que ve el Coach", @@ -3041,25 +3090,11 @@ "steadyLatestWithin": "Tus valores suelen estar en {low} {unit}; tu última medición, del {date}, fue {value} y coincide.", "steadyLatestAbove": "Tus valores suelen estar en {low} {unit}; tu última medición, del {date}, fue {value}, por encima.", "steadyLatestBelow": "Tus valores suelen estar en {low} {unit}; tu última medición, del {date}, fue {value}, por debajo.", - "insufficient": "Todavía estoy aprendiendo tu rango habitual — sigue registrando unos días más.", + "insufficient": "Todavía estoy aprendiendo tu rango habitual. Sigue registrando unos días más.", "driver": "{note}" }, "scope": { - "prefix": "El Coach ya está en {metric}", - "tap": "Toca para preguntar", - "question": "Explícame esta métrica — ¿hay algo que deba vigilar?" - }, - "seeded": { - "prefix": "Hoy merece un vistazo: {signal}", - "signal": { - "readiness": "disposición", - "recovery": "recuperación" - }, - "question": { - "readiness": "Mi disposición está rara hoy — ¿a qué se debe?", - "recovery": "Mi recuperación ha cambiado — ¿qué hay detrás?" - }, - "dismiss": "Descartar" + "prefix": "El Coach ya está en {metric}" }, "settings": "Ajustes", "history": { @@ -3069,31 +3104,31 @@ "groupEarlier": "Anteriores" }, "seed": { - "bloodPressure": "Repasa conmigo la tendencia de mi presión arterial de los últimos 30 días — ¿hay algo que deba vigilar?", + "bloodPressure": "Repasa conmigo la tendencia de mi presión arterial de los últimos 30 días. ¿Hay algo que deba vigilar?", "weight": "¿Cómo ha evolucionado mi peso últimamente y la dirección es algo sobre lo que actuar?", "bmi": "¿Qué me dice mi tendencia de IMC y cómo debería interpretarla?", - "pulse": "Repasa conmigo mis lecturas de pulso — ¿hay algo fuera de lo normal?", + "pulse": "Repasa conmigo mis lecturas de pulso. ¿Hay algo fuera de lo normal?", "restingHr": "¿Cómo ha evolucionado mi frecuencia cardíaca en reposo y qué dice sobre mi forma física?", "hrv": "¿Qué me dice mi variabilidad de la frecuencia cardíaca sobre la recuperación y el estrés últimamente?", - "sleep": "Repasa conmigo mi sueño reciente — duración, regularidad y cualquier cosa que valga la pena cambiar.", + "sleep": "Repasa conmigo mi sueño reciente: duración, regularidad y cualquier cosa que valga la pena cambiar.", "mood": "¿Qué patrones ves en mi estado de ánimo durante las últimas semanas?", "medications": "¿Cómo va mi adherencia a la medicación y coincide con cómo me he sentido?", "steps": "¿Qué tan activo he estado últimamente según mis pasos y cómo se compara con un nivel de referencia saludable?", "activeEnergy": "¿Qué dice mi tendencia de energía activa sobre mi nivel de actividad últimamente?", "cardioFitness": "¿Qué significa la tendencia de mi capacidad cardiorrespiratoria (VO₂ máx.) y cómo la llevo en la dirección correcta?", - "bloodGlucose": "Repasa conmigo mis últimas lecturas de glucosa — ¿están en un rango saludable?", + "bloodGlucose": "Repasa conmigo mis últimas lecturas de glucosa. ¿Están en un rango saludable?", "oxygenSaturation": "¿Qué me dice mi tendencia de oxígeno en sangre (SpO₂)?", "respiratoryRate": "¿Qué dice mi tendencia de frecuencia respiratoria sobre mi salud?", - "workouts": "Repasa conmigo mis entrenamientos recientes — carga, frecuencia y cómo me recupero entre ellos.", + "workouts": "Repasa conmigo mis entrenamientos recientes: carga, frecuencia y cómo me recupero entre ellos.", "recoveryPage": "¿Por qué mi recuperación está donde está ahora mismo y qué la está provocando?", - "briefing": "Repasa conmigo el resumen de hoy — ¿en qué debería centrarme y hay algo en lo que valga la pena actuar?", + "briefing": "Repasa conmigo el resumen de hoy. ¿En qué debería centrarme y hay algo en lo que valga la pena actuar?", "healthScore": "¿Por qué mi puntuación de salud es {score} de 100 y qué la mejoraría más?", - "periodWeek": "Repasa conmigo cómo fue mi última semana en mis métricas de salud — ¿qué destaca?", - "periodMonth": "Repasa conmigo cómo fue mi último mes en mis métricas de salud — ¿qué destaca?", + "periodWeek": "Repasa conmigo cómo fue mi última semana en mis métricas de salud. ¿Qué destaca?", + "periodMonth": "Repasa conmigo cómo fue mi último mes en mis métricas de salud. ¿Qué destaca?", "correlationBpCompliance": "¿Cómo se relaciona mi adherencia a la medicación con mi presión arterial? Explícame qué significa esta correlación para mí.", "correlationMoodPulse": "¿Existe una relación real entre mi estado de ánimo y mi pulso? Ayúdame a interpretar esta correlación.", "correlationWeightWeekday": "¿Mi peso sigue un patrón semanal? ¿Qué me dice esta correlación por día de la semana?", - "recommendation": "Cuéntame más sobre esta recomendación: \"{text}\" — ¿por qué se aplica a mí y qué debería hacer al respecto?" + "recommendation": "Cuéntame más sobre esta recomendación: \"{text}\". ¿Por qué se aplica a mí y qué debería hacer al respecto?" }, "assessmentPrompt": "Explícame mi evaluación de {metric}: ¿qué significa y qué debería hacer al respecto?", "attach": { @@ -3180,9 +3215,9 @@ "cap": "Muchos pasos, ahora lo resume", "noProgress": "No hay datos nuevos, lo resume" }, - "thoughtProcessOne": "Razonamiento · {count} paso", - "thoughtProcessFew": "Razonamiento · {count} pasos", - "thoughtProcessOther": "Razonamiento · {count} pasos", + "thoughtProcessOne": "Razonamiento ({count} paso)", + "thoughtProcessFew": "Razonamiento ({count} pasos)", + "thoughtProcessOther": "Razonamiento ({count} pasos)", "awaitingAnswer": "Se necesita tu respuesta" }, "memory": { @@ -3222,7 +3257,8 @@ "capped": "(limitado por el administrador)", "disabled": "El administrador ha desactivado el razonamiento profundo." }, - "memoryLink": "Memoria ({count})" + "memoryLink": "Memoria ({count})", + "newConversation": "Nueva conversación" }, "relativeJustNow": "ahora mismo", "relativeMinutesAgoOne": "hace {count} minuto", @@ -3285,7 +3321,7 @@ }, "hrv": { "title": "VFC", - "description": "Variabilidad de la frecuencia cardíaca a lo largo del tiempo — señal de recuperación del sistema nervioso, captada por la noche por Apple Watch y otros wearables compatibles.", + "description": "Variabilidad de la frecuencia cardíaca a lo largo del tiempo: señal de recuperación del sistema nervioso, captada por la noche por Apple Watch y otros wearables compatibles.", "chartTitle": "Variabilidad de la frecuencia cardíaca", "emptyState": { "title": "Aún no hay valores de VFC", @@ -3294,7 +3330,7 @@ }, "restingHr": { "title": "FC en reposo", - "description": "El nivel basal diario del corazón — distinto del pulso puntual de la página de pulso. Apple Salud aporta un valor al día.", + "description": "El nivel basal diario del corazón, distinto del pulso puntual de la página de pulso. Apple Salud aporta un valor al día.", "chartTitle": "Frecuencia cardíaca en reposo", "emptyState": { "title": "Aún no hay FC en reposo", @@ -3304,7 +3340,7 @@ }, "oxygenSaturation": { "title": "Saturación de oxígeno", - "description": "Oxígeno en sangre (SpO₂) — rango clínico normal 95–100 %, con zonas objetivo sombreadas en el gráfico.", + "description": "Oxígeno en sangre (SpO₂): rango clínico normal 95–100 %, con zonas objetivo sombreadas en el gráfico.", "chartTitle": "Saturación de oxígeno", "emptyState": { "title": "Aún no hay muestras de SpO₂", @@ -3317,13 +3353,13 @@ "chartTitle": "Temperatura corporal", "emptyState": { "title": "Aún no hay temperatura corporal", - "description": "Registra una lectura o deja que tu wearable sincronice una — la tendencia aparecerá aquí.", + "description": "Registra una lectura o deja que tu wearable sincronice una, y la tendencia aparecerá aquí.", "cta": "Registrar temperatura corporal" } }, "steps": { "title": "Pasos", - "description": "Recuento diario de pasos — una fila por día, desde Apple Salud o un dispositivo de actividad conectado.", + "description": "Recuento diario de pasos: una fila por día, desde Apple Salud o un dispositivo de actividad conectado.", "chartTitle": "Pasos por día", "emptyState": { "title": "Aún no hay datos de pasos", @@ -3332,7 +3368,7 @@ }, "activeEnergy": { "title": "Energía activa", - "description": "Total diario de energía activa en kilocalorías — una fila por día, desde Apple Salud o la actividad de Withings.", + "description": "Total diario de energía activa en kilocalorías: una fila por día, desde Apple Salud o la actividad de Withings.", "chartTitle": "Energía activa", "emptyState": { "title": "Aún no hay datos de energía activa", @@ -3399,7 +3435,7 @@ "breathingDisturbancesBody": "Un wearable puede estimar con qué frecuencia se interrumpió tu respiración mientras dormías. Un índice más bajo es mejor; un nivel marcado como elevado, sobre todo durante muchas noches, conviene comentarlo con un profesional. Es una aproximación de cribado, no un diagnóstico de apnea del sueño.", "stepsBody": "El recuento de pasos es el número total de pasos que das cada día: la medida más sencilla del movimiento cotidiano. Un hábito constante importa más que un único día destacado, y la meta redonda de 10.000 pasos es un eslogan publicitario, no una conclusión de la investigación.", "readinessBody": "La disposición es una lectura diaria de lo listo que está tu cuerpo para afrontar el día, combinando la frecuencia cardíaca en reposo, la variabilidad de la frecuencia cardíaca y el sueño. Es una aproximación del dispositivo respecto a tu propia base, no una medida clínica: una puntuación más alta indica recuperación, una más baja es un aviso suave para bajar el ritmo.", - "sleepScoreBody": "Tu puntuación de sueño resume toda una noche en un solo número — duración, eficiencia, regularidad y el equilibrio entre sueño ligero, profundo y REM. Fíjate en el patrón a lo largo de las semanas en lugar de perseguir una puntuación perfecta una sola noche.", + "sleepScoreBody": "Tu puntuación de sueño resume toda una noche en un solo número: duración, eficiencia, regularidad y el equilibrio entre sueño ligero, profundo y REM. Fíjate en el patrón a lo largo de las semanas en lugar de perseguir una puntuación perfecta una sola noche.", "recoveryBody": "La recuperación refleja lo bien que tu cuerpo se ha repuesto, a partir de señales nocturnas como la variabilidad de la frecuencia cardíaca y la frecuencia en reposo. Una puntuación alta es luz verde para exigirte; una más baja sugiere que tu organismo aún se está reconstruyendo.", "strainBody": "El esfuerzo capta cuánta carga cardiovascular y física le has impuesto a tu cuerpo, desde los entrenamientos hasta un día activo de pie. Cuando ya hay suficiente historial, se valora frente a tu propio esfuerzo habitual, no frente a un estándar ajeno.", "stressBody": "El estrés aquí es una estimación de la carga fisiológica sobre tu sistema nervioso, leída sobre todo a partir de patrones de variabilidad de la frecuencia cardíaca y no de cómo te sientes conscientemente. Una puntuación más baja apunta a un estado calmado y bien regulado; una más alta es una invitación a incorporar recuperación.", @@ -3422,7 +3458,7 @@ "mean": "Media" }, "showAllValues": "Mostrar todas las mediciones", - "valuesPageTitle": "{metric} — todas las mediciones", + "valuesPageTitle": "{metric}: todas las mediciones", "valuesPageDescription": "Cada medición detrás de esta métrica. Edita o elimina una entrada directamente.", "diversity": { "trigger": "Consejo de medición", @@ -3462,11 +3498,11 @@ "inBed": "En la cama", "nap": "Siesta", "napCount": "{count} siestas", - "unavailable": "Aún no hay datos de fases — las fases de sueño aparecerán aquí en cuanto una fuente conectada las proporcione." + "unavailable": "Aún no hay datos de fases. Las fases de sueño aparecerán aquí en cuanto una fuente conectada las proporcione." }, "empty": { "title": "Aún no hay datos de sueño", - "description": "Conecta una fuente como Apple Health o un wearable — o registra el sueño manualmente — y esta vista se llenará automáticamente.", + "description": "Conecta una fuente como Apple Health o un wearable (o registra el sueño manualmente) y esta vista se llenará automáticamente.", "action": "Gestionar dispositivos" }, "debt": { @@ -3475,21 +3511,21 @@ "caption": "Balance pendiente de las últimas {nights} noches", "clearValue": "Al día", "clearCaption": "Nada pendiente en las últimas {nights} noches", - "learning": "Aún estamos midiendo tu sueño — {counted} noches hasta ahora, faltan {remaining} para un balance fiable.", + "learning": "Aún estamos midiendo tu sueño: {counted} noches hasta ahora, faltan {remaining} para un balance fiable.", "computedInfo": "Esta es la estimación propia de HealthLog: un balance móvil de la necesidad menos el sueño de las últimas noches, que el sueño de recuperación va reduciendo. Puede diferir de la cifra de deuda de sueño nativa de un wearable, que usa su propio modelo de necesidad." }, "average": { "title": "Media por noche", "value": "{hours} h {minutes} min", "caption": "Sueño medio en las últimas {nights} noches", - "learning": "Aún estamos midiendo tu sueño — {counted} noches hasta ahora, faltan {remaining} para una media fiable." + "learning": "Aún estamos midiendo tu sueño: {counted} noches hasta ahora, faltan {remaining} para una media fiable." }, "chronotype": { "title": "Cronotipo", - "learning": "Aún estamos aprendiendo tu ritmo — {counted} de {total} noches libres. Un cronotipo necesita varias mañanas sin despertador para tener sentido.", + "learning": "Aún estamos aprendiendo tu ritmo: {counted} de {total} noches libres. Un cronotipo necesita varias mañanas sin despertador para tener sentido.", "advancedToggle": "Avanzado", "socialJetlagValue": "{hours} h {minutes} min de jet lag social", - "socialJetlagCaption": "La diferencia entre el horario de sueño laboral y el de los días libres — como un pequeño cambio de huso horario semanal.", + "socialJetlagCaption": "La diferencia entre el horario de sueño laboral y el de los días libres, como un pequeño cambio de huso horario semanal.", "msfScValue": "Punto medio corregido {clock}", "msfScCaption": "Ajustado por el sueño que recuperas el fin de semana (MSFsc).", "dayTypeNote": "Se asume que los días libres son los fines de semana hasta que se añada el registro del tipo de día.", @@ -3678,7 +3714,10 @@ "endInFuture": "Terminaría en el futuro. Pon como inicio la hora en que empezó el entrenamiento.", "distanceInvalid": "Introduce la distancia como un número dentro de un rango realista.", "energyInvalid": "Introduce la energía activa como un número de hasta 20.000 kcal." - } + }, + "editTitle": "Editar entrenamiento", + "editAction": "Editar", + "updated": "Entrenamiento actualizado" } }, "bloodGlucose": { @@ -3852,7 +3891,7 @@ "navSkinTemperature": "Temp. piel", "pulseWaveVelocity": { "title": "Velocidad de onda de pulso", - "description": "Tu velocidad de onda de pulso arterial en el tiempo, en m/s — valores más altos sugieren arterias más rígidas.", + "description": "Tu velocidad de onda de pulso arterial en el tiempo, en m/s. Valores más altos sugieren arterias más rígidas.", "chartTitle": "Velocidad de onda de pulso", "emptyState": { "title": "Aún no hay datos", @@ -4002,7 +4041,6 @@ "navWalkingSpeed": "Velocidad al andar", "mood": { "logLink": "Registrar un estado de ánimo", - "mentalWellbeingLink": "Hacer un chequeo de bienestar mental", "heatmapTitle": "Calendario de ánimo", "heatmapNoEntry": "Sin registro", "legendGreat": "Genial", @@ -4013,7 +4051,7 @@ "distributionDaysUnit": "días", "weekdayTitle": "Promedio por día de la semana", "weekdayNoData": "Sin datos", - "weekdayBestWorst": "Mejor día: {best} · Día más difícil: {worst}", + "weekdayBestWorst": "Mejor día: {best}, día más difícil: {worst}", "tagsTitle": "Etiquetas y desencadenantes", "tagAvgMood": "Ánimo promedio con esta etiqueta", "correlationsTitle": "Ánimo y otras métricas", @@ -4029,7 +4067,7 @@ "strengthModerate": "Moderada", "strengthWeak": "Débil", "strengthNone": "Ninguna", - "source": "{n} días emparejados · r = {r}", + "source": "{n} días emparejados, r = {r}", "emptyTitle": "Aún no hay suficientes datos emparejados", "emptyDescription": "Registra el ánimo y esta métrica los mismos días para ver una correlación.", "pendingDescription": "{n} de {needed} días emparejados. La correlación aparece cuando hay días suficientes para distinguir un patrón del azar.", @@ -4088,7 +4126,7 @@ "crosstab": { "title": "Etiquetas y tus métricas de salud", "description": "Cómo difiere una métrica de salud en los días que etiquetaste cada actividad, frente a los días que no.", - "pairLabel": "{tag} · {metric}", + "pairLabel": "{metric} en días con {tag}", "metricActiveEnergy": "Energía activa", "metricSleepDuration": "Sueño", "metricNextDayRecovery": "Recuperación al día siguiente", @@ -4105,7 +4143,7 @@ "factorCrosstab": { "title": "Tus factores valorados y tus constantes", "description": "Cómo difiere una constante en los días en que valoraste bajo cada factor frente a los días en que lo valoraste alto.", - "pairLabel": "{factor} · {metric}", + "pairLabel": "{metric} en días con {factor}", "metricSleepDuration": "Sueño", "metricSteps": "Pasos", "metricRestingHeartRate": "Frecuencia cardíaca en reposo", @@ -4131,7 +4169,7 @@ "moodDown": "Un mayor {factor} suele coincidir con peor ánimo al día siguiente en tus datos.", "outcomeUp": "Mejor ánimo suele coincidir con un {factor} más alto al día siguiente en tus datos.", "outcomeDown": "Mejor ánimo suele coincidir con un {factor} más bajo al día siguiente en tus datos.", - "stat": "{n} días emparejados · r = {r} · q = {q}", + "stat": "{n} días emparejados, r = {r}, q = {q}", "statLabel": "Cómo se calculó esto", "explainerLabel": "Cómo se calculó esto" }, @@ -4175,15 +4213,15 @@ }, "derived": { "coverage": { - "summary": "{present} de {required} entradas · {percent}%", + "summary": "{present} de {required} entradas ({percent}%)", "ratioLabel": "{present}/{required}", "historyDays": "Basado en {count} días de historial", "missing": "Registra estos para afinar el valor: {list}", - "summaryLabelled": "{label} · {percent}%" + "summaryLabelled": "{label} ({percent}%)" }, "scoreRing": { "aria": "Puntuación {score} de 100, {band}", - "ariaProvisional": "Puntuación aún no disponible — datos insuficientes", + "ariaProvisional": "Puntuación aún no disponible: datos insuficientes", "provisionalCaption": "Datos aún insuficientes", "band": { "green": "bueno", @@ -4197,7 +4235,7 @@ "inputsLabel": "Entradas", "noInputs": "Aún no hay entradas que respalden este valor.", "methodLabel": "Método", - "window": "De tus últimos {days} días · datos de {source}", + "window": "De tus últimos {days} días de datos de {source}", "asOf": "a fecha de {date}", "standardLink": "Estándar: {name}" }, @@ -4207,7 +4245,7 @@ "loadingLabel": "Cargando tus constantes", "loadError": "No se pudieron cargar tus constantes en este momento.", "infoLabel": "¿Cómo se calcula esto?", - "building": "Creando tu rango habitual — {count} de {target} días", + "building": "Creando tu rango habitual: {count} de {target} días", "typicalRange": "Habitual para ti: {low}–{high}", "fitnessYounger": "Forma cardiovascular de alguien ~{years} años más joven", "fitnessOlder": "Forma cardiovascular ~{years} años por encima de tu edad", @@ -4239,9 +4277,8 @@ "anatomy": { "outOf": "/100", "contributorsLabel": "Factores", - "contributorValue": "{value} · {weight}% de peso", + "contributorValue": "{value}, peso {weight}%", "contributorMissing": "sin datos", - "disclaimer": "Indicativo — no es una evaluación clínica.", "insufficient": "Aún no hay datos suficientes para puntuarlo. Sigue registrando y aparecerá aquí." }, "composite": { @@ -4260,15 +4297,14 @@ "READINESS": { "title": "Disposición", "description": "Un indicador diario de bienestar según cómo se sitúan tus señales frente a tu propia base.", - "method": "Una mezcla de 0–100 de desviación de la frecuencia cardíaca en reposo, desviación de la VFC (SDNN), calidad del sueño, estabilidad de la frecuencia respiratoria y estabilidad del estado de ánimo — cada una medida frente a tu base personal. Los pesos se renormalizan según los componentes que tus datos sustentan. Un indicador de bienestar, no una puntuación de recuperación ni clínica.", + "method": "Una mezcla de 0–100 de desviación de la frecuencia cardíaca en reposo, desviación de la VFC (SDNN), calidad del sueño, estabilidad de la frecuencia respiratoria y estabilidad del estado de ánimo, cada una medida frente a tu base personal. Los pesos se renormalizan según los componentes que tus datos sustentan. Un indicador de bienestar, no una puntuación de recuperación ni clínica.", "component": { "rhr": "Frecuencia cardíaca en reposo", "hrv": "VFC (SDNN)", "sleep": "Sueño", "respiratory": "Frecuencia respiratoria", "mood": "Estabilidad del ánimo" - }, - "caveat": "Un indicador de bienestar, no una puntuación de recuperación ni clínica." + } }, "VITALS_BASELINE": { "method": "Tu rango típico personal es la mediana de tus valores diarios en el periodo, ± una dispersión robusta (desviación absoluta mediana, escalada). Sigue TUS lecturas, no una norma poblacional, así que un valor dentro de la banda es típico para ti." @@ -4293,20 +4329,19 @@ }, "RECOVERY_SCORE": { "method": "Un indicador nocturno de recuperación de 0 a 100 que combina tu VFC, frecuencia cardíaca en reposo y sueño frente a tu línea base. Más alto significa mejor recuperado. Se calcula una vez por noche y aquí solo se lee, nunca se recalcula al vuelo.", - "caveat": "Un indicador de bienestar descriptivo, no una medida de recuperación clínica ni de nivel de entrenamiento.", "description": "Un indicador nocturno de recuperación a partir de tu VFC, frecuencia en reposo y sueño." }, "STRESS_SCORE": { "method": "Un indicador nocturno de estrés de 0 a 100 a partir de la forma de tu VFC a lo largo del día frente a tu línea base. Más alto significa más carga fisiológica.", - "caveat": "Un indicador derivado de la VFC, NO una medición de estrés por EDA / actividad electrodérmica: tu reloj no tiene sensor EDA. Descriptivo, no clínico.", "description": "Un indicador nocturno de estrés a partir de la forma de tu VFC durante el día." }, "STRAIN_SCORE": { "method": "Un indicador nocturno de esfuerzo de 0 a 100 a partir de tu carga de actividad ponderada por frecuencia cardíaca (un modelo de impulso de entrenamiento tipo TRIMP). Más alto significa un día más exigente.", "anchorPersonal": "Anclado a tu propia carga reciente de días de entrenamiento: se evalúa en relación con tu esfuerzo habitual, de modo que un día realmente duro para ti aparece alto.", "anchorPopulation": "Se evalúa frente a una referencia general mientras acumulas historial de entrenamiento; cuando tengas suficiente, pasa a tu propio esfuerzo habitual.", - "caveat": "Un indicador descriptivo de carga de actividad, no una medida de carga clínica ni de nivel de entrenamiento.", - "description": "Un indicador nocturno de carga de actividad a partir de tu esfuerzo ponderado por frecuencia cardíaca, relativo a tu esfuerzo habitual." + "description": "Un indicador nocturno de carga de actividad a partir de tu esfuerzo ponderado por frecuencia cardíaca, relativo a tu esfuerzo habitual.", + "deviceCaption": "Esfuerzo diario de tu dispositivo: {value} de {max}", + "deviceSource": "Hasta que HealthLog calcule su propio valor durante la noche, la tarjeta muestra el esfuerzo diario de tu dispositivo, convertido a 0–100." }, "WRIST_TEMPERATURE_BASELINE": { "method": "Tu temperatura típica de muñeca durante la noche es la mediana de tus lecturas nocturnas en el periodo, ± una dispersión robusta (desviación absoluta de la mediana, escalada). Sigue TUS lecturas: la noche pasada se muestra como una desviación respecto a tu propia base, no a una norma poblacional.", @@ -4341,7 +4376,9 @@ "recovery": "Puntuación de recuperación", "recoveryCrossLink": "Ver los datos de recuperación y esfuerzo de tu wearable", "stress": "Estrés", - "strain": "Esfuerzo" + "strain": "Esfuerzo", + "historyTitle": "Evolución", + "historyWindow": "Últimos {days} días" }, "coincident": { "label": "Varias constantes fuera de la base", @@ -4368,7 +4405,7 @@ "sameTime": { "sectionTitle": "Hoy hasta ahora", "sectionSubtitle": "Tus totales acumulados frente a lo que es habitual en ti a esta hora", - "learning": "Aprendiendo tu día habitual — {count} de {target} días", + "learning": "Aprendiendo tu día habitual: {count} de {target} días", "framing": "Habitual a las {time}: {typical} {unit}", "comparisonLabel": "Tu día habitual" } @@ -4455,7 +4492,7 @@ "navBreathingDisturbances": "Respiración", "rhythmEvents": { "sectionTitle": "Notificaciones de salud del dispositivo", - "sectionIntro": "Notificaciones que tu reloj envió por su cuenta. HealthLog solo las muestra — no las detecta, confirma ni interpreta.", + "sectionIntro": "Notificaciones que tu reloj envió por su cuenta. HealthLog solo las muestra; no las detecta, confirma ni interpreta.", "loadError": "No se pudieron cargar las notificaciones de tu dispositivo.", "disclaimer": "Estas son alertas que tu dispositivo generó con su propia detección integrada. Se muestran aquí solo para tu información. No es una evaluación médica de HealthLog, y HealthLog no diagnostica ninguna afección. Si una notificación te preocupa, habla con tu médico.", "event": { @@ -4524,9 +4561,9 @@ "emptyTitle": "Aún no hay datos de recuperación", "empty": "Aún no hay datos de recuperación. Conecta una cuenta de WHOOP, Polar u Oura y estas puntuaciones se completarán cuando tu dispositivo sincronice.", "block": { - "ansChargeExplainer": "Cuán cargado está tu sistema nervioso autónomo para el día — una lectura de recarga obtenida de tus señales nocturnas de corazón y respiración.", + "ansChargeExplainer": "Cuán cargado está tu sistema nervioso autónomo para el día: una lectura de recarga obtenida de tus señales nocturnas de corazón y respiración.", "dayStrainExplainer": "La carga cardiovascular del día en la escala de esfuerzo de tu dispositivo. Léela en relación con tu recuperación, no como una meta en sí.", - "workoutStrainExplainer": "Cuán exigente fue un entrenamiento en la misma escala de esfuerzo — el coste cardiovascular de esa sesión.", + "workoutStrainExplainer": "Cuán exigente fue un entrenamiento en la misma escala de esfuerzo: el coste cardiovascular de esa sesión.", "cardioLoadExplainer": "Una aproximación de la carga de entrenamiento que sigue la carga cardiovascular acumulada. Compárala con tus propios días recientes.", "averageHeartRateExplainer": "Tu frecuencia cardíaca media a lo largo de todo el día o ciclo, según informa tu wearable.", "maxHeartRateExplainer": "Tu frecuencia cardíaca máxima a lo largo de todo el día o ciclo, según informa tu wearable.", @@ -4539,7 +4576,7 @@ }, "resilience": { "explainer": "Cómo equilibra tu cuerpo la carga diaria con la recuperación, leído a partir de tus señales nocturnas de los últimos días.", - "learning": "Aún estamos aprendiendo tu resiliencia — unos días más asentarán el nivel.", + "learning": "Aún estamos aprendiendo tu resiliencia. Unos días más asentarán el nivel.", "band": { "limited": "Limitada", "adequate": "Adecuada", @@ -4681,9 +4718,9 @@ "emptyCta": "Conectar un dispositivo" }, "intradayPulse": { - "title": "La forma de tu día", - "caption": "Tu frecuencia cardíaca a lo largo del día, promediada en tramos de 10 minutos.", - "hourlyNote": "Mostrando el promedio por hora de este día — los días recientes muestran el detalle completo de 10 minutos.", + "title": "Pulso a lo largo del día", + "caption": "Promediado en intervalos de 10 minutos.", + "hourlyNote": "Mostrando el promedio por hora de este día. Los días recientes muestran el detalle completo de 10 minutos.", "empty": "Aún no hay suficientes lecturas para este día para mostrar su forma.", "coverageOne": "Basado en 1 lectura a lo largo de {hours}h.", "coverageMany": "Basado en {count} lecturas a lo largo de {hours}h.", @@ -4706,6 +4743,19 @@ "settingsBody": "HealthLog funciona por completo sin IA. Conecta un proveedor abajo si quieres un resumen diario, notas breves sobre tus mediciones y el Coach.", "action": "Configurar IA", "dismiss": "Ahora no" + }, + "bmiHeight": { + "coachPrefill": "Todavía no he indicado mi altura: ¿por qué importa el IMC y qué debería saber antes de configurarla?" + }, + "medicationsEmpty": { + "coachPrefill": "Todavía no he añadido medicamentos: ¿qué debería saber antes de empezar a seguir aquí la adherencia?" + }, + "scoreHistory": { + "healthTitle": "Evolución de la puntuación de salud", + "healthDescription": "Tu puntuación de salud día a día, tal como se mostró cada día.", + "emptyDescription": "Todavía no hay ninguna puntuación registrada en este periodo.", + "usualRange": "Sombreado: tu rango habitual, de {lo} a {hi}.", + "seam": "La línea se interrumpe donde cambió lo que cuenta para la puntuación." } }, "thresholds": { @@ -4735,14 +4785,15 @@ "metricBodyFat": "Grasa corporal", "metricSleep": "Duración del sueño", "metricSteps": "Pasos/día", - "metricGlucoseFasting": "Glucemia — en ayunas", - "metricGlucosePostprandial": "Glucemia — posprandial", - "metricGlucoseRandom": "Glucemia — cualquiera", - "metricGlucoseBedtime": "Glucemia — antes de dormir", + "metricGlucoseFasting": "Glucemia (en ayunas)", + "metricGlucosePostprandial": "Glucemia (posprandial)", + "metricGlucoseRandom": "Glucemia (cualquiera)", + "metricGlucoseBedtime": "Glucemia (antes de dormir)", "metricBodyWater": "Agua corporal", "metricBoneMass": "Masa ósea", "metricOxygenSaturation": "Saturación de oxígeno (SpO₂)", - "unsetExplanation": "Sin valor propio — se aplica el valor por defecto calculado." + "unsetExplanation": "Sin valor propio: se aplica el valor por defecto calculado.", + "cardTitle": "Rangos por métrica" }, "onboarding": { "errorGeneric": "Algo ha salido mal al finalizar la configuración. Inténtalo de nuevo.", @@ -4874,18 +4925,18 @@ "sampleTitle": "Resumen diario", "sampleTag": "Ejemplo", "sampleAriaLabel": "Resumen diario de ejemplo", - "sampleBody": "Peso 1,2 kg menos en dos semanas. Pulso en reposo estable en 58 lpm. Tensión dentro del rango 6 de 7 días — la única lectura alta llegó tras una cena tardía y salada.", - "sampleCaption": "Solo un ejemplo — no son tus datos. Así se lee un resumen cuando los insights están activos.", + "sampleBody": "Peso 1,2 kg menos en dos semanas. Pulso en reposo estable en 58 lpm. Tensión dentro del rango 6 de 7 días; la única lectura alta llegó tras una cena tardía y salada.", + "sampleCaption": "Solo un ejemplo, no son tus datos. Así se lee un resumen cuando los insights están activos.", "ladderTitle": "Tres formas de activarlo", - "ladderLocalTitle": "Modelo local — las solicitudes de IA no salen de tu red", - "ladderLocalBody": "Apunta HealthLog a Ollama o LM Studio en tu propia máquina. Gratis y totalmente privado — la opción tranquila por defecto.", + "ladderLocalTitle": "Modelo local: las solicitudes de IA no salen de tu red", + "ladderLocalBody": "Apunta HealthLog a Ollama o LM Studio en tu propia máquina. Gratis y totalmente privado: la opción tranquila por defecto.", "ladderByokTitle": "Usa tu propia clave", "ladderByokBody": "Usa tu propia clave de OpenAI o Anthropic. Funciona con tu cuenta y facturación, y el nivel de API mantiene tus datos fuera del entrenamiento por defecto.", "ladderOauthTitle": "Inicia sesión con una suscripción", - "ladderOauthBody": "Conecta una cuenta de ChatGPT — sin coste por token, pero los términos del nivel de consumo pueden usar el contenido para entrenamiento, así que es una elección deliberada, nunca la opción por defecto para tus datos de salud.", - "keylessLine": "HealthLog es plenamente útil sin IA. Tus paneles, gráficos, tendencias y exportaciones funcionan — la IA solo añade la lectura en lenguaje claro por encima.", + "ladderOauthBody": "Conecta una cuenta de ChatGPT. Sin coste por token, pero los términos del nivel de consumo pueden usar el contenido para entrenamiento, así que es una elección deliberada, nunca la opción por defecto para tus datos de salud.", + "keylessLine": "HealthLog es plenamente útil sin IA. Tus paneles, gráficos, tendencias y exportaciones funcionan; la IA solo añade la lectura en lenguaje claro por encima.", "setupCta": "Configúralo (un minuto aprox.)", - "sharedKeyNote": "Esta instancia ya ofrece un proveedor de IA compartido, así que los insights funcionan para ti ahora mismo — sin configuración.", + "sharedKeyNote": "Esta instancia ya ofrece un proveedor de IA compartido, así que los insights funcionan para ti ahora mismo, sin configuración.", "offer": { "title": "Esta instancia ya puede escribir tu resumen en lenguaje claro.", "body": "El operador ofrece un proveedor que funcionó por última vez en las últimas 24 horas. Con un toque activas el lenguaje claro; tus datos irán entonces a ese único proveedor y a nadie más.", @@ -5102,7 +5153,7 @@ "profileDescription": "Altura, fecha de nacimiento y sexo desbloquean objetivos personalizados.", "profileCta": "Abrir perfil", "measurementTitle": "Registrar la primera medición", - "measurementDescription": "Una medición activa la tarjeta — la tendencia se dibuja a partir de la 5.ª medición.", + "measurementDescription": "Una medición activa la tarjeta; la tendencia se dibuja a partir de la 5.ª medición.", "measurementCta": "Añadir medición", "medicationTitle": "Añadir medicamento", "medicationDescription": "Sigue el cumplimiento y recibe recordatorios justo cuando los necesites.", @@ -5111,7 +5162,7 @@ "dataSourceDescription": "Sincroniza peso, presión, sueño y más desde Withings, WHOOP, Oura, Polar, Nightscout, Fitbit o Apple Health.", "dataSourceCta": "Conectar", "notificationsTitle": "Configurar notificaciones", - "notificationsDescription": "Elige cómo te recuerda HealthLog — Telegram, ntfy o Web Push.", + "notificationsDescription": "Elige cómo te recuerda HealthLog: Telegram, ntfy o Web Push.", "notificationsCta": "Abrir notificaciones", "insightsTitle": "Activar insights (opcional)", "insightsDescription": "Añade un modelo local o tu propia clave de IA para desbloquear el resumen diario y Coach.", @@ -5259,12 +5310,10 @@ "sections": { "account": { "title": "Cuenta", - "description": "Perfil, contraseña, claves de acceso y tu recorrido de incorporación.", "subtitle": "Tu perfil, identidad y datos de acceso." }, "integrations": { "title": "Integraciones", - "description": "Servicios conectados, canales de entrega y prioridad de fuentes en un solo lugar.", "subtitle": "Conecta y gestiona tus fuentes de datos y dispositivos.", "connectionsHeading": "Conexiones", "channelsHeading": "Canales de entrega", @@ -5273,16 +5322,14 @@ }, "notifications": { "title": "Notificaciones", - "description": "Todos los recordatorios en un solo lugar: qué se recuerda y cuándo, y dónde se entrega.", "subtitle": "Elige qué recordatorios recibes y dónde se entregan.", "remindersHeading": "Recordatorios", "matrixLink": "Notificaciones por canal", "channelsLink": "Canales de entrega" }, "layout": { - "title": "Apariencia", - "description": "Elige cómo se ven y se organizan el panel, los análisis y tus módulos de seguimiento.", - "backToHub": "Volver a Apariencia", + "title": "Diseño", + "backToHub": "Volver a Diseño", "dashboard": { "title": "Panel", "description": "Diseño y orden de los mosaicos." @@ -5291,7 +5338,7 @@ "title": "Análisis", "description": "Orden de la vista general y de las pestañas de navegación." }, - "subtitle": "Personaliza cómo se ven tu panel, los análisis y los módulos de seguimiento.", + "subtitle": "Elige qué muestran tu panel, los análisis y los módulos de seguimiento, y en qué orden.", "medications": { "title": "Medicamentos", "description": "Vista de lista y orden manual." @@ -5311,6 +5358,10 @@ "mood": { "title": "Etiquetas de ánimo", "description": "Grupos, etiquetas y etiquetas archivadas del selector de ánimo." + }, + "documents": { + "title": "Documentos", + "description": "Vista y disposición de los meses." } }, "dashboard": { @@ -5318,15 +5369,13 @@ }, "thresholds": { "title": "Objetivos", - "description": "Por métrica: tu propio rango objetivo de valores saludables.", "subtitle": "Define tus rangos objetivo personales por métrica." }, "sources": { "title": "Prioridad de fuentes", - "description": "Elige qué fuente prevalece cuando varias registran la misma métrica el mismo día.", "integrationsHint": "Conecta o desconecta fuentes de datos en la página de integraciones.", "integrationsHintLink": "Abrir integraciones", - "cardTitle": "Prioridad de fuentes por métrica", + "cardTitle": "Orden por métrica", "help": "Si varias fuentes entregan el mismo valor el mismo día, la fuente superior cuenta como canónica.", "resetDefaults": "Restablecer valores predeterminados", "resetDefaultsTitle": "¿Restablecer el orden de fuentes?", @@ -5339,7 +5388,7 @@ "resetSuccess": "Prioridad de fuentes restablecida a los valores predeterminados", "perMetricToggle": "Ajustes por métrica ({count})", "perMetricHelp": "Métricas que has personalizado respecto al orden predeterminado. Usa esta lista para devolver cualquier métrica concreta a su valor predeterminado sin tocar las demás.", - "perMetricEmpty": "No hay ajustes por métrica — todas las métricas usan el orden global de arriba.", + "perMetricEmpty": "No hay ajustes por métrica. Todas las métricas usan el orden global de arriba.", "resetMetric": "Restablecer", "deviceTypeToggle": "Prioridad por tipo de dispositivo ({count})", "deviceTypeHelp": "Dentro de una fuente, qué dispositivo prevalece. Cuando un Apple Watch y un iPhone registran ambos los pasos, el tipo de dispositivo mejor clasificado cuenta como la lectura canónica.", @@ -5364,7 +5413,8 @@ "respiratoryRate": "Frecuencia respiratoria", "recovery": "Recuperación", "stress": "Estrés", - "sleepDebt": "Deuda de sueño" + "sleepDebt": "Deuda de sueño", + "hrvRmssd": "VFC (RMSSD)" }, "sourceLabels": { "WITHINGS": "Withings", @@ -5381,7 +5431,8 @@ "MCP": "MCP", "GOOGLE_HEALTH": "Google Health", "STRAVA": "Strava", - "EXTERNAL": "Dispositivo externo" + "EXTERNAL": "Dispositivo externo", + "HEALTH_CONNECT": "Health Connect" }, "deviceLabels": { "watch": "Reloj", @@ -5403,26 +5454,23 @@ "moduleHintLink": "Gestionar módulos", "detail": "Solo lectura: la app de origen es la dueña de los valores." }, - "helpDetail": "Las demás se conservan para la traza de auditoría." + "helpDetail": "Las demás se conservan para la traza de auditoría. Solo aparecen las fuentes que has conectado o de las que hay datos.", + "noneInUse": "Aún no hay fuentes que ordenar. Conecta una en Integraciones o registra un valor." }, "ai": { "title": "Proveedor de IA", - "description": "Proveedor, modelo y clave API para los análisis, además de tu contexto «sobre mí».", "subtitle": "Elige tu proveedor de IA y usa tu propia clave." }, "api": { - "title": "API & Tokens", - "description": "Tokens Bearer para tus propios scripts y apps — registra mediciones o tomas de medicación desde cualquier lugar.", + "title": "API y tokens", "subtitle": "Gestiona los endpoints de la API y los tokens de acceso." }, "advanced": { "title": "Avanzado", - "description": "La salida: borra todos los registros de salud para siempre o cierra la cuenta.", "subtitle": "Restablecer datos y eliminar cuenta." }, "export": { "title": "Exportar e importar", - "description": "Descarga tus datos como CSV o una copia de seguridad JSON completa, o importa desde Apple Health, JSON o CSV. El informe médico ahora está en Historia de salud.", "otherOptionsHeading": "Otras opciones de exportación", "downloadFailed": "No se pudo descargar la exportación. Inténtalo de nuevo.", "actions": { @@ -5449,7 +5497,7 @@ "fullBackup": { "title": "Copia completa", "description": "Todo tu historial en un archivo JSON.", - "scopeNote": "Los metadatos de los documentos y el resumen de IA, si existe, se incluyen en texto plano; los archivos originales de los documentos y los datos de rutas GPS/muestras de los entrenamientos no están incluidos — descárgalos por separado desde el archivo de documentos o la exportación de entrenamientos.", + "scopeNote": "Los metadatos de los documentos y el resumen de IA, si existe, se incluyen en texto plano; los archivos originales de los documentos y los datos de rutas GPS/muestras de los entrenamientos no están incluidos. Descárgalos por separado desde el archivo de documentos o la exportación de entrenamientos.", "encryptToggle": "Cifrar con una contraseña", "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.", @@ -5581,8 +5629,8 @@ "failed": "La importación falló. No se escribió nada.", "columnsHeading": "Qué hace cada columna", "jsonOnly": "solo JSON", - "previewLine": "Filas leídas: {rows} · se importarían: {queued} · rechazadas: {refused}", - "submittedLine": "Filas leídas: {rows} · en cola para importar: {queued}", + "previewLine": "{rows} filas leídas, {queued} se importarían, {refused} rechazadas", + "submittedLine": "{rows} filas leídas, {queued} en cola para importar", "unmatched": "Ningún medicamento registrado coincide con: {names}", "ambiguous": "Coincide con más de un medicamento: {names}", "mirrored": "Copiado de otra aplicación, así que no se puede añadir ninguna dosis: {names}", @@ -5618,18 +5666,59 @@ "tooManyRows": "Ese archivo tiene más filas de las que admite una importación. Divídelo e importa las partes." }, "detail": "Un archivo abarca todos los medicamentos que contiene." + }, + "healthConnect": { + "title": "Exportación de Health Connect", + "description": "Importa el archivo ZIP que crea la app Health Connect en Android al exportar tus datos.", + "choose": "Elegir archivo ZIP", + "uploading": "Subiendo…", + "uploadFailed": "La subida ha fallado. Inténtalo de nuevo.", + "tooLarge": "El archivo supera el tamaño que acepta este servidor.", + "doneSummary": "{count} valores importados.", + "failed": "La importación ha fallado: {reason}", + "phase": { + "queued": "Esperando para empezar", + "unpacking": "Descomprimiendo el archivo", + "parsing": "Leyendo los registros", + "upserting": "Guardando los valores", + "done": "Terminado", + "failed": "Fallido" + }, + "steps": { + "export": "En tu teléfono Android, abre Health Connect, ve a Gestionar datos, luego a Exportar e importar, e inicia una exportación.", + "cloud": "Health Connect solo guarda la exportación en una app de almacenamiento en la nube como Google Drive, Dropbox o Nextcloud, nunca en el propio teléfono. Elige una allí.", + "upload": "Descarga el archivo ZIP de esa nube a este dispositivo y súbelo aquí." + }, + "dropLabel": "Suelta aquí el ZIP de Health Connect", + "dropHint": "o haz clic para elegirlo. Hasta 1 GB.", + "fileInputLabel": "Archivo ZIP de la exportación de Health Connect", + "privacyNote": "El archivo se borra del servidor en cuanto termina la importación. Los registros de apps que ya conectas directamente, como Withings o Fitbit, se omiten para que nada llegue dos veces. Subir de nuevo el mismo archivo no cambia nada.", + "rateLimited": "Demasiadas subidas en poco tiempo. Espera un minuto y vuelve a intentarlo.", + "busy": "Ya hay una importación en curso para tu cuenta. Espera a que termine.", + "rowsSoFar": "{count} valores guardados hasta ahora", + "doneNothing": "La importación terminó, pero no había nada nuevo que añadir.", + "workouts": "Entrenamientos", + "leftOutConnected": "Se omitieron {count} registros de apps que ya conectas directamente.", + "nutrientsModuleOff": "Los totales de agua y nutrientes no se importaron porque el módulo Nutrientes está desactivado.", + "cycleModuleOff": "Los datos del periodo no se importaron porque el seguimiento del ciclo está desactivado.", + "failure": { + "unsupportedVersion": "Esta exportación de Health Connect es demasiado antigua para leerla. Actualiza Health Connect en el teléfono y vuelve a exportar.", + "notHealthConnect": "Este archivo no es una exportación de Health Connect. Elige el archivo ZIP que guardó Health Connect.", + "unsafeSchema": "Este archivo no tiene la estructura de una exportación de Health Connect y no se leyó.", + "tooLarge": "La exportación es mayor de lo que acepta este servidor.", + "stagingMissing": "El archivo subido ya no estaba en el servidor. Súbelo de nuevo.", + "interrupted": "Un reinicio del servidor interrumpió la importación. Sube el archivo de nuevo." + } } }, "subtitle": "Exporta tus datos o importa desde una copia de seguridad." }, "about": { "title": "Acerca de esta app", - "description": "Versión, licencia, enlaces.", "subtitle": "Versión, licencia y enlaces del proyecto." }, "sharing": { "title": "Compartir", - "description": "Crea un enlace de solo lectura para compartir tu historial de salud con un profesional médico.", "subtitle": "Comparte acceso de solo lectura a tus datos." }, "insights": { @@ -5664,12 +5753,10 @@ }, "gesundheitsakte": { "title": "Historial médico", - "description": "Exporta tu historial médico completo como PDF, FHIR R4 o un paquete ZIP combinado.", "subtitle": "Exporta un historial de salud completo, listo para el médico." }, "coach": { "title": "Coach", - "description": "Activa el Coach, tono y alcance, recuerdos guardados.", "subtitle": "Ajusta el Coach, sus avisos proactivos y su memoria." }, "security": { @@ -5678,8 +5765,7 @@ }, "privacy": { "title": "Datos y privacidad", - "subtitle": "Tus datos, su retención, el cifrado y la seguridad de la cuenta en un solo lugar.", - "description": "Exportación, eliminación, retención, estado de cifrado, sesiones y actividad de seguridad." + "subtitle": "Tus datos, su retención, el cifrado y la seguridad de la cuenta en un solo lugar." }, "mcp": { "title": "Conector MCP", @@ -5687,8 +5773,7 @@ }, "anamnesis": { "title": "Antecedentes médicos", - "subtitle": "Gestiona tus alergias y tus antecedentes familiares.", - "description": "Registros estructurados de alergias y antecedentes familiares." + "subtitle": "Tus antecedentes, tu estilo de vida, tus datos de emergencia y lo que la IA puede leer." }, "environment": { "title": "Ubicación y clima", @@ -5722,7 +5807,8 @@ "add": "Añadir {label}", "remove": "Eliminar periodo de ubicación", "removeConfirmTitle": "¿Quitar este viaje?", - "removeConfirmBody": "Las mediciones de ese periodo permanecen; solo desaparece la entrada del viaje." + "removeConfirmBody": "Las mediciones de ese periodo permanecen; solo desaparece la entrada del viaje.", + "range": "Del {start} al {end}" }, "backfill": { "title": "Rellenar días pasados", @@ -5732,7 +5818,21 @@ "noResults": "No se encontraron lugares coincidentes.", "fetchFailedOne": "La última actualización meteorológica en segundo plano falló ({when}).", "fetchFailedFew": "Las últimas {count} actualizaciones meteorológicas en segundo plano fallaron, la más reciente {when}.", - "fetchFailedOther": "Las últimas {count} actualizaciones meteorológicas en segundo plano fallaron, la más reciente {when}." + "fetchFailedOther": "Las últimas {count} actualizaciones meteorológicas en segundo plano fallaron, la más reciente {when}.", + "airQuality": { + "title": "Calidad del aire y polen", + "description": "Calidad del aire, polen y UV diarios para tu ubicación, del modelo CAMS a través de Open-Meteo.", + "toggle": "Incluir calidad del aire y polen", + "operatorDisabled": "La calidad del aire está desactivada en este servidor.", + "coverage": "{count} días con datos de calidad del aire", + "pollenEuropeOnly": "Los datos de polen solo cubren Europa.", + "historyProgress": "Completando la calidad del aire: {done} de {total} días", + "historyComplete": "La calidad del aire está completada para todos los días anteriores con registros que cubre la fuente ({total} días). Los días más antiguos quedan vacíos.", + "attribution": "Fuentes de datos y licencias", + "saveError": "No se pudo guardar el ajuste.", + "latest": "Día más reciente ({date})", + "modelNote": "Valores exteriores modelados para tu zona, no tu exposición personal." + } }, "score": { "title": "Puntuación de salud", @@ -5859,7 +5959,6 @@ }, "changePassword": "Cambiar contraseña", "changePasswordDescription": "Sustituye tu contraseña actual por una nueva.", - "passwordReset": "Restablecer contraseña", "currentPassword": "Contraseña actual", "newPassword": "Nueva contraseña", "confirmNewPassword": "Confirmar nueva contraseña", @@ -5904,9 +6003,9 @@ "botToken": "Token del bot", "chatId": "ID del chat", "enableNotifications": "Activar notificaciones", - "telegramStep1": "1. Crea un bot con @BotFather en Telegram y copia el token.", - "telegramStep2": "2. Envía /start a tu bot para activar el chat.", - "telegramStep3": "3. Obtén tu ID del chat con @userinfobot o la API del bot.", + "telegramStep1": "Crea un bot con @BotFather en Telegram y copia el token.", + "telegramStep2": "Envía /start a tu bot para activar el chat.", + "telegramStep3": "Obtén tu ID del chat con @userinfobot o la API del bot.", "ntfy": "ntfy", "ntfyDescription": "Notificaciones a través de ntfy (autoalojado o ntfy.sh).", "ntfyEnable": "Activar ntfy", @@ -5915,7 +6014,7 @@ "ntfyAuthToken": "Token de autenticación (opcional)", "ntfyAuthTokenHint": "Solo necesario para temas privados con control de acceso.", "webhook": "Webhook", - "webhookDescription": "Envía notificaciones por POST a cualquier URL: Gotify, Discord, Slack, Matrix o Home Assistant.", + "webhookDescription": "Envía notificaciones por POST a una URL: Discord, Slack, Gotify, Home Assistant, n8n o un relé que acepte JSON.", "webhookEnable": "Activar webhook", "webhookUrl": "URL del webhook", "webhookUrlHint": "La URL completa para el POST. Una dirección de una red privada necesita la aprobación del administrador del servidor.", @@ -5923,6 +6022,7 @@ "webhookHeaderValue": "Valor de cabecera (opcional)", "webhookFormat": "Formato del mensaje", "webhookFormatGeneric": "JSON genérico", + "webhookFormatGenericHint": "Pega una URL de webhook de Discord o Slack tal cual. Home Assistant y n8n leen los campos title, message, eventType y priority.", "webhookFormatGotify": "Gotify", "webhookFormatGotifyHint": "Usa la URL que termina en /message y pon el token de la aplicación en la cabecera X-Gotify-Key.", "email": "Correo electrónico", @@ -5939,7 +6039,6 @@ "webPushSubscribed": "¡Notificaciones push activadas!", "webPushUnsubscribed": "Notificaciones push desactivadas.", "webPushSubscribeFailed": "Falló la activación", - "kiInsightsDescription": "Opcional: guarda tu clave de OpenAI para análisis diarios y automáticos en Insights.", "codexConnected": "¡ChatGPT conectado correctamente! Insights ya está activo.", "codexDisconnected": "Conexión con ChatGPT eliminada.", "codexConnectionFailed": "Falló la conexión con ChatGPT. Inténtalo de nuevo.", @@ -5949,7 +6048,7 @@ "rawDataWarning": "Historial detallado activado: cada análisis envía además promedios diarios de los últimos 90 días, promedios semanales del año anterior y promedios mensuales hasta 5 años atrás.", "regenerateInsights": "Regenerar informes", "regenerateSuccess": "Informes regenerados correctamente", - "regenerateRateLimit": "Espera un momento — has alcanzado el límite por hora de análisis.", + "regenerateRateLimit": "Espera un momento: has alcanzado el límite por hora de análisis.", "lastGeneratedAt": "Generado por última vez", "ai": { "chatgptConnectedBadge": "ChatGPT conectado", @@ -5959,11 +6058,11 @@ "deviceCodeHeading": "Termina de conectar en chatgpt.com", "deviceCodeStep1": "Abre este enlace en cualquier dispositivo:", "deviceCodeStep2": "Introduce este código de un solo uso:", - "deviceCodeStep3": "Aprueba la conexión — esta página se actualiza automáticamente.", + "deviceCodeStep3": "Aprueba la conexión. Esta página se actualiza automáticamente.", "deviceCodeCopy": "Copiar", "deviceCodeWaiting": "Esperando aprobación…", "deviceCodeCancel": "Cancelar", - "oauthNotConfigured": "El OAuth de ChatGPT no está configurado en esta instancia — usa tu propia clave de API a continuación.", + "oauthNotConfigured": "El OAuth de ChatGPT no está configurado en esta instancia. Usa tu propia clave de API a continuación.", "modelLabel": "Modelo", "modelOptionDefault": "— Predeterminado —", "modelOptionCustom": "Personalizado…", @@ -5987,7 +6086,7 @@ "admin-openai": "OpenAI de administrador" }, "title": "Cadena de respaldo", - "description": "Si el proveedor principal falla, HealthLog recorre la cadena en orden. Arrastra las filas para reordenarlas; usa el interruptor para desactivar un proveedor sin eliminarlo.", + "description": "Si el proveedor activo falla, HealthLog prueba el siguiente activado en este orden.", "moveUp": "Subir", "moveDown": "Bajar", "removeFromChain": "Quitar de la cadena", @@ -6000,11 +6099,8 @@ "resetConfirmTitle": "¿Restablecer la cadena a los valores predeterminados?", "resetConfirmBody": "La cadena vuelve a Codex → OpenAI → Anthropic → Local → OpenAI de administrador. Tus credenciales guardadas no se modifican." }, - "providerCardTitle": "Proveedor de IA", "activeProviderHeading": "Proveedor activo", - "activeProviderBody": "Elige el proveedor que quieres usar primero. El formulario de abajo configura únicamente el proveedor seleccionado; la cadena de respaldo del final decide qué ocurre si falla.", "activeProviderLabel": "Proveedor principal", - "providerConfigTitle": "Configuración del proveedor", "providerSelect": { "codex": "Cuenta de ChatGPT (Codex)", "openai": "OpenAI (tu clave de API)", @@ -6050,11 +6146,11 @@ "notConfigured": "El operador no ha configurado una clave de OpenAI compartida en esta instancia." }, "testProvider": "Probar el proveedor activo", - "testSuccess": "OK — {provider} ({model})", + "testSuccess": "OK: {provider} ({model})", "testFailedShort": "Prueba fallida: {message}", - "testUnexpectedResponse": "Falló la conexión con el proveedor de IA — respuesta inesperada del servidor.", - "testReasonCredentials": "El proveedor rechazó las credenciales — vuelve a autenticarte en los ajustes de IA.", - "testReasonRateLimited": "El proveedor limitó la solicitud — inténtalo de nuevo en breve.", + "testUnexpectedResponse": "Falló la conexión con el proveedor de IA: respuesta inesperada del servidor.", + "testReasonCredentials": "El proveedor rechazó las credenciales. Vuelve a autenticarte en los ajustes de IA.", + "testReasonRateLimited": "El proveedor limitó la solicitud. Inténtalo de nuevo en breve.", "testReasonServerError": "El proveedor de IA devolvió un error del servidor.", "testReasonBadRequest": "El proveedor rechazó la solicitud (HTTP {status}): el endpoint respondió, así que el problema está en el formato de la solicitud o en el nombre del modelo, no en la conexión.", "testReasonUnreachable": "No se pudo contactar con el proveedor de IA.", @@ -6098,7 +6194,7 @@ "markDone": "Hecho", "dismiss": "Descartar", "deleteAria": "Eliminar este recordatorio", - "note": "Los recordatorios se guardan cifrados. Elimina uno cuando quieras — desaparece de lo que el Coach puede ver.", + "note": "Los recordatorios se guardan cifrados. Elimina uno cuando quieras y desaparece de lo que el Coach puede ver.", "status": { "proposed": "Sugerido", "active": "Guardado", @@ -6150,14 +6246,14 @@ }, "centralCodex": { "title": "Usar el acceso de IA compartido del servidor", - "subLabel": "Enruta las solicitudes de IA a través de la conexión que el operador configuró para este servidor, junto a tus propios proveedores como alternativa.", + "subLabel": "Las solicitudes de IA pasan por la conexión compartida del servidor, con tus propios proveedores como respaldo.", "honesty": "El acceso compartido es una única cuenta de IA con sesión iniciada que el operador conectó para todos en este servidor, no tu propia clave. Las solicitudes que hagas se usan de forma predeterminada para entrenar los modelos del proveedor y ningún acuerdo de tratamiento de datos las cubre, los límites por hora y semanales de la cuenta se comparten con todos los demás que se han unido, y tus propios proveedores se prueban primero. Actívalo solo si aceptas este compromiso.", "confirm": "Entendido: usar la conexión", "cancel": "Cancelar" }, "consent": { "title": "Consentimiento de IA", - "activeSince": "Otorgado el {date}. Tus datos de salud pueden enviarse al proveedor configurado para las funciones de IA.", + "activeSince": "Otorgado el {date}: tus datos de salud pueden enviarse al proveedor configurado para las funciones de IA.", "withdrawn": "Retirado. Salvo que otro consentimiento lo cubra, el Coach e Insights ya no usan el acceso de IA de este servidor y ningún modelo en línea lee tus documentos. Con tu propia clave o un modelo local, el Coach e Insights siguen funcionando.", "withdraw": "Retirar el consentimiento", "confirmBody": "Salvo que otro consentimiento tuyo los siga cubriendo, se detienen de inmediato: el Coach e Insights cuando usan el acceso de IA de este servidor, y la lectura de documentos con un modelo en línea. Con tu propia clave o un modelo local, el Coach e Insights siguen funcionando. Salvo que otro consentimiento siga cubriendo Insights, se eliminan las notas que un modelo escribió para ti: notas de estado, el resumen diario, los resúmenes de periodo escritos por un modelo, las líneas de llegada y las notas de entrenamiento. Tus conversaciones con el Coach, lo que el Coach recordaba, tus planes y los resúmenes de tus documentos se conservan, y puedes leerlos o eliminarlos tú mismo.", @@ -6168,7 +6264,7 @@ "error": "No se pudo guardar la retirada. Inténtalo de nuevo.", "documentReading": { "title": "Consentimiento para leer documentos", - "activeSince": "Dado el {date}. Los documentos, los informes de laboratorio escaneados y las descripciones de medicamentos pueden enviarse a tu servicio de IA para leerlos.", + "activeSince": "Dado el {date}: documentos, análisis escaneados y textos de medicamentos pueden ir a tu servicio de IA para leerlos.", "confirmBody": "Los documentos, los informes de laboratorio escaneados y las descripciones de medicamentos dejan de enviarse para leerlos. No se elimina nada de lo que ya tienes." } }, @@ -6202,7 +6298,9 @@ "high": "Alto" }, "coachNote": "En el Coach se aplica la profundidad de pensamiento de los ajustes del Coach." - } + }, + "providerCardDescription": "El proveedor al que HealthLog pregunta primero, con su clave y su modelo.", + "runtimeTitle": "Comprobar y actualizar" }, "dashboard": { "units": { @@ -6226,8 +6324,8 @@ "withingsClientId": "ID de cliente", "withingsClientSecret": "Secreto de cliente", "withingsCredentialsSaved": "Credenciales guardadas", - "withingsCredentialsSavedPlaceholder": "Guardado — introduce uno nuevo para reemplazarlo", - "withingsCredentialsSavedPlaceholderSecret": "Guardado — introduce uno nuevo para reemplazarlo", + "withingsCredentialsSavedPlaceholder": "Guardado. Introduce uno nuevo para reemplazarlo", + "withingsCredentialsSavedPlaceholderSecret": "Guardado. Introduce uno nuevo para reemplazarlo", "withingsSaveCredentials": "Guardar", "configured": "Configurado", "withingsSync": "Sincronizar ahora", @@ -6242,7 +6340,7 @@ }, "withingsFullSyncResult": "{count} mediciones sincronizadas por completo", "withingsSyncFailed": "Falló la sincronización", - "withingsOauthConnected": "Withings conectado — la primera sincronización comienza en segundo plano.", + "withingsOauthConnected": "Withings conectado. La primera sincronización comienza en segundo plano.", "withingsOauthFailed": "Error al conectar con Withings", "withingsOauthError": { "csrf1": "La comprobación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.", @@ -6267,8 +6365,8 @@ "whoopClientId": "Client ID", "whoopClientSecret": "Client Secret", "whoopCredentialsSaved": "Credenciales guardadas", - "whoopCredentialsSavedPlaceholder": "Guardado — introduce nuevas para reemplazar", - "whoopCredentialsSavedPlaceholderSecret": "Guardado — introduce nuevas para reemplazar", + "whoopCredentialsSavedPlaceholder": "Guardado. Introduce nuevas para reemplazar", + "whoopCredentialsSavedPlaceholderSecret": "Guardado. Introduce nuevas para reemplazar", "whoopSaveCredentials": "Guardar", "whoopSync": "Sincronizar ahora", "whoopFullSync": "Sincronizar todos los datos", @@ -6297,7 +6395,7 @@ }, "fitbit": "Fitbit", "fitbitTag": "Fitbit y Pixel", - "fitbitDescription": "Conecta Fitbit para sincronizar tus datos de Fitbit y Pixel Watch — peso, frecuencia cardíaca y más.", + "fitbitDescription": "Conecta Fitbit para sincronizar tus datos de Fitbit y Pixel Watch: peso, frecuencia cardíaca y más.", "fitbitExperimentalBadge": "Experimental", "fitbitCredentials": "Credenciales de API", "fitbitClientId": "Client ID", @@ -6329,8 +6427,8 @@ "googleHealthClientId": "ID de cliente", "googleHealthClientSecret": "Secreto de cliente", "googleHealthCredentialsSaved": "Credenciales guardadas", - "googleHealthCredentialsSavedPlaceholder": "Guardado — introduce nuevas para reemplazar", - "googleHealthCredentialsSavedPlaceholderSecret": "Guardado — introduce nuevas para reemplazar", + "googleHealthCredentialsSavedPlaceholder": "Guardado. Introduce nuevas para reemplazar", + "googleHealthCredentialsSavedPlaceholderSecret": "Guardado. Introduce nuevas para reemplazar", "googleHealthSaveCredentials": "Guardar", "googleHealthSync": "Sincronizar ahora", "googleHealthSyncCheckStatus": "Actualizar estado", @@ -6401,10 +6499,10 @@ }, "integrationPill": { "connected": "Conectado", - "errorReconnect": "Error — reconectar", + "errorReconnect": "Error: reconectar", "warningServerError": "Sincronización con errores", "failureCount": "{count}/{threshold}", - "parkedReconnect": "Pausado — reconectar", + "parkedReconnect": "Pausado: reconectar", "notConnected": "No conectado", "justNow": "ahora mismo", "minutesAgo": "hace {count} min", @@ -6419,7 +6517,7 @@ }, "integrationFreshness": { "title": "Datos recibidos", - "summary": "{count} tipos de datos · más reciente {relative}", + "summary": "{count} tipos de datos, más reciente {relative}", "quiet": "{count} en silencio", "workouts": "Entrenamientos" }, @@ -6433,31 +6531,33 @@ "fitbitViewData": "Ver tu sueño", "googleHealthViewData": "Ver tus pasos", "apiTokens": "Tokens de API", - "apiTokensDescription": "Revisa y revoca tokens de API para el registro externo de tomas de medicación.", + "apiTokensDescription": "Revisa y revoca los tokens de mediciones, entrenamientos, documentos y tomas de medicación.", "measurementsToken": { "title": "Tokens de mediciones", "description": "Crea un token para que una báscula, un reloj o tu domótica añadan mediciones.", "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." + "createFailed": "No se pudo crear el token. Inténtalo de nuevo.", + "namePlaceholder": "Nombre, p. ej. Home Assistant" }, "workoutsToken": { "title": "Tokens de entrenamientos", "description": "Crea un token para que un puente de tu reloj o un script de sincronización añadan tus entrenamientos.", "detail": "El token se muestra una sola vez.", "scopeNote": "Solo puede añadir entrenamientos nuevos a tu propio historial. No puede leer tus datos, ni modificar o borrar nada, ni crear otro token. Los entrenamientos que añade se marcan como procedentes de un dispositivo externo, y si tu teléfono registró la misma sesión, ves la copia del teléfono.", - "createFailed": "No se pudo crear el token. Inténtalo de nuevo." + "createFailed": "No se pudo crear el token. Inténtalo de nuevo.", + "namePlaceholder": "Nombre, p. ej. Garmin" }, "documentsToken": { "title": "Tokens de documentos", "description": "Crea un token para que Paperless-ngx, Papra o el script de importación añadan documentos a tu archivo.", "detail": "El token se muestra una sola vez.", "scopeNote": "Solo puede añadir documentos nuevos a tu propio archivo. No puede listar, abrir, descargar, modificar ni borrar nada, ni crear otro token. Lo único que recibe es si un archivo se guardó, no cómo lo archivaste.", - "createFailed": "No se pudo crear el token. Inténtalo de nuevo." + "createFailed": "No se pudo crear el token. Inténtalo de nuevo.", + "namePlaceholder": "Nombre, p. ej. Paperless-ngx" }, - "tokenMintMovedDescription": "Los tokens se emiten donde se aplican: arriba para las mediciones y los documentos, o en el endpoint de API de un medicamento para ese medicamento concreto.", - "tokenNamePlaceholder": "Nombre del token (p. ej., Atajo de iPhone)", - "tokenCreated": "Token creado — ¡cópialo ahora! No se mostrará de nuevo.", + "tokenMintMovedDescription": "Los tokens se emiten donde se aplican: arriba para las mediciones, los entrenamientos y los documentos, o en el endpoint de API de un medicamento para ese medicamento concreto.", + "tokenCreated": "Token creado. ¡Cópialo ahora! No se mostrará de nuevo.", "tokenCopy": "Copiar token", "tokenCopied": "Token copiado al portapapeles", "tokenCopyFailed": "No se pudo copiar el token", @@ -6496,7 +6596,7 @@ "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.", - "deleteAccountSuccess": "Cuenta eliminada — cerrando tu sesión.", + "deleteAccountSuccess": "Cuenta eliminada. Cerrando tu sesión.", "deleteAccountFailed": "No se pudo eliminar la cuenta", "deleteAccountFinal": "Eliminar permanentemente", "saved": "Guardado", @@ -6519,7 +6619,7 @@ "testing": "Probando…", "ok": "Conectado (latencia {latency} ms)", "errors": { - "credentials_rejected": "Credenciales rechazadas — comprueba tu token", + "credentials_rejected": "Credenciales rechazadas: comprueba tu token", "rate_limited": "Limitado por el servidor remoto", "timeout": "La solicitud agotó el tiempo de espera", "upstream_error": "El servidor remoto devolvió un error", @@ -6530,7 +6630,7 @@ "private_origin_not_approved": "Destino en una red privada. El operador debe añadir este origen a NOTIFICATION_PRIVATE_ORIGINS", "private_origin_not_grantable": "Esta dirección es de enlace local, de metadatos o no especificada; ninguna autorización del operador puede abrirla. Usa la dirección en la que escucha el relay", "url_invalid": "La URL no es válida", - "redirected": "El endpoint redirige — comprueba la URL", + "redirected": "El endpoint redirige: comprueba la URL", "endpoint_not_found": "No se encontró el endpoint en la URL", "credentials_unreadable": "Las credenciales no se pueden descifrar", "upstream_invalid_json": "El servidor remoto envió un JSON no válido", @@ -6541,7 +6641,7 @@ }, "notificationStatus": { "title": "Fiabilidad de los canales", - "description": "Estado de cada canal — «Desactivado automáticamente» significa detenido tras errores permanentes.", + "description": "Estado de cada canal; «Desactivado automáticamente» significa detenido tras errores permanentes.", "emptyDescription": "Configura un canal en Ajustes › Integraciones › Canales de entrega.", "stateActive": "Activo", "stateAutoDisabled": "Desactivado automáticamente", @@ -6599,7 +6699,7 @@ "createTitle": "Nuevo enlace para compartir", "createDescription": "El enlace es de solo lectura y limitado en el tiempo.", "label": "Etiqueta", - "labelPlaceholder": "p. ej. Dra. García — revisión anual", + "labelPlaceholder": "p. ej. Dra. García, revisión anual", "range": "Días de historial", "rangeHint": "Hasta cuándo se remonta la ventana compartida.", "expiry": "Caduca en (días)", @@ -6619,7 +6719,7 @@ "statusRevoked": "Revocado", "statusExpired": "Caducado", "inactiveTitle": "Revocados y caducados ({count})", - "shownOnce": "Se muestra una sola vez. Guarde ahora el código QR y la frase de acceso — no se pueden recuperar después.", + "shownOnce": "Se muestra una sola vez. Guarde ahora el código QR y la frase de acceso: no se pueden recuperar después.", "qrAlt": "Código QR con el enlace para compartir y la frase de acceso", "linkLabel": "Enlace", "passphraseLabel": "Frase de acceso", @@ -6648,7 +6748,8 @@ "statusNeedsReselect": "Elegir contenido de nuevo", "reselectReason": "Este enlace mostraba un alcance que nadie eligió, por eso se cerró. Crea uno nuevo y elige su contenido.", "reselectAction": "Crear un reemplazo", - "createDetail": "El token se muestra una sola vez al crearlo — guárdalo entonces; no se puede recuperar." + "createDetail": "El token se muestra una sola vez al crearlo. Guárdalo entonces; no se puede recuperar.", + "activeDescription": "Los enlaces que has compartido, cuándo caducan y cuántas veces se abrieron." }, "genderOther": "Otro", "nightscout": "Nightscout", @@ -6680,7 +6781,7 @@ "polarClientId": "Client ID", "polarClientSecret": "Client Secret", "polarCredentialsSaved": "Credenciales guardadas", - "polarCredentialsSavedPlaceholder": "Guardado — introduce nuevas para reemplazar", + "polarCredentialsSavedPlaceholder": "Guardado. Introduce nuevas para reemplazar", "polarSaveCredentials": "Guardar", "polarUnavailable": "Polar no está configurado en este servidor. Añade arriba tus propias credenciales OAuth de Polar, o pide a tu administrador que configure la app compartida.", "polarConnect": "Conectar Polar", @@ -6709,7 +6810,7 @@ "ouraClientId": "Client ID", "ouraClientSecret": "Client Secret", "ouraCredentialsSaved": "Credenciales guardadas", - "ouraCredentialsSavedPlaceholder": "Guardado — introduce nuevas para reemplazar", + "ouraCredentialsSavedPlaceholder": "Guardado. Introduce nuevas para reemplazar", "ouraSaveCredentials": "Guardar", "ouraUnavailable": "Oura no está configurado en este servidor. Añade arriba tus propias credenciales OAuth de Oura, o pide a tu administrador que configure la app compartida.", "ouraConnect": "Conectar Oura", @@ -6750,7 +6851,7 @@ } }, "integrationRedirectGuide": { - "intro": "Esto se conecta mediante tu propia app de desarrollador de {provider} — unos 5 minutos de configuración:", + "intro": "Esto se conecta mediante tu propia app de desarrollador de {provider}, con unos 5 minutos de configuración:", "step1": "Crea una app en el portal de desarrolladores de {provider}.", "step2": "Configura su URL de redirección/callback en:", "step3": "Pega arriba el ID de cliente y el secreto de cliente que te da." @@ -6874,12 +6975,11 @@ }, "stored": { "title": "Qué se almacena", - "measurements": "Mediciones (peso, presión arterial, pulso, glucosa, sueño, composición corporal)", - "medications": "Medicamentos e historial de tomas", - "moodLabs": "Entradas de estado de ánimo y resultados de laboratorio", - "coach": "Conversaciones con el asistente", + "measurements": "Mediciones: peso, presión arterial, pulso y cualquier otro valor que registres tú o un dispositivo", "integrations": "Tokens de integración (cifrados)", - "security": "Actividad de la cuenta y de seguridad (sesiones, registro de inicio de sesión)" + "security": "Actividad de la cuenta y de seguridad (sesiones, registro de inicio de sesión)", + "profile": "Tu perfil, tus antecedentes médicos y los datos del historial de salud", + "modules": "Registros de cada módulo: {list}" }, "retention": { "title": "Retención", @@ -6910,8 +7010,6 @@ "mcp": { "enableTitle": "Endpoint MCP remoto", "enableDescription": "Permite que un asistente compatible con MCP (Claude.ai, ChatGPT) lea tus datos de salud mediante un token limitado.", - "enabled": "Activado", - "disabled": "Desactivado", "enableToggleLabel": "Activar el endpoint MCP", "endpointLabel": "URL del conector", "connectDescription": "Pega esta URL en la configuración del conector personalizado de tu asistente y autoriza con el token de abajo o inicia sesión cuando se te solicite.", @@ -6930,7 +7028,8 @@ "connectionRevokeDescription": "Esto finaliza el acceso del asistente de inmediato e impide su renovación. Será necesario autorizarlo de nuevo para volver a conectarlo.", "connectionRevokeFailed": "No se pudo desconectar el asistente.", "tokensDetail": "El token se muestra una sola vez.", - "connectionsDetail": "Revocar una conexión finaliza su acceso de inmediato, incluida su capacidad de renovación." + "connectionsDetail": "Revocar una conexión finaliza su acceso de inmediato, incluida su capacidad de renovación.", + "tokenNamePlaceholder": "Nombre, p. ej. Claude Desktop" }, "strava": "Strava", "stravaDescription": "Conecta tu cuenta de Strava para sincronizar tus actividades con tu historial de entrenamientos.", @@ -6939,7 +7038,7 @@ "stravaClientId": "ID de cliente", "stravaClientSecret": "Secreto de cliente", "stravaCredentialsSaved": "Credenciales guardadas", - "stravaCredentialsSavedPlaceholder": "Guardadas — introduce nuevas para reemplazar", + "stravaCredentialsSavedPlaceholder": "Guardadas. Introduce nuevas para reemplazar", "stravaSaveCredentials": "Guardar", "stravaUnavailable": "Strava no está configurado en este servidor. Añade arriba tus propias credenciales de API de Strava o pide a tu administrador que configure la app compartida.", "stravaConnect": "Conectar Strava", @@ -6977,26 +7076,33 @@ "title": "Progreso de sincronización", "receivedLabel": "Registros recibidos", "oldestLabel": "Lectura más antigua alcanzada", - "flowing": "Han llegado datos en la última hora — la app del iPhone sigue enviando.", - "waiting": "Esperando a que la app del iPhone envíe más datos. La transferencia continúa cuando la app está abierta o cuando iOS le concede tiempo en segundo plano — una pausa durante la primera sincronización grande es normal, no un error." + "flowing": "Han llegado datos en la última hora. La app del iPhone sigue enviando.", + "waiting": "Esperando a que la app del iPhone envíe más datos. La transferencia continúa cuando la app está abierta o cuando iOS le concede tiempo en segundo plano. Una pausa durante la primera sincronización grande es normal, no un error." }, "delivery": { "title": "Entrega", "lastTriggerLabel": "La última sincronización llegó", "lastBackgroundLabel": "Última sincronización en segundo plano", - "backgroundNever": "Nunca — hasta ahora todas las sincronizaciones llegaron con la app abierta", + "backgroundNever": "Nunca: hasta ahora todas las sincronizaciones llegaron con la app abierta", "noSyncYet": "Aún no ha llegado ninguna sincronización, así que no hay nada que informar aquí.", "trigger": { "foreground": "con la app abierta", "background": "en segundo plano", "push": "desde un aviso push", - "unknown": "la app no informó del desencadenante" + "unknown": "la app no informó del desencadenante", + "manual": "desde «Sincronizar todo»" } + }, + "freshness": { + "title": "Por tipo de dato", + "lastReceived": "Última recepción {relative}", + "lastNewValue": "Último valor nuevo {relative}", + "never": "Aún no recibido" } }, "garminInfo": { "title": "Garmin", - "body": "Garmin no tiene conexión directa aquí. Conéctalo a través de Apple Health (iOS) o Google Health Connect (Android). Las métricas propias de Garmin — Body Battery, Training Load, estado de HRV, VO₂ máx.— no las comparte Garmin con ninguna de las dos plataformas.", + "body": "Garmin no tiene conexión directa aquí. Conéctalo a través de Apple Health (iOS) o Google Health Connect (Android). Las métricas propias de Garmin (Body Battery, Training Load, estado de HRV, VO₂ máx.) no las comparte Garmin con ninguna de las dos plataformas.", "docsLink": "Leer la guía" }, "glucoseReference": { @@ -7052,6 +7158,12 @@ "description": "Esto comparte o exporta tus datos de salud. Confirma que eres tú para continuar.", "signInAgain": "Vuelve a iniciar sesión y inténtalo de nuevo. Tu cuenta no tiene contraseña ni llave de acceso que confirmar aquí.", "failed": "No ha funcionado. Revisa lo que has introducido e inténtalo de nuevo." + }, + "passwordTitle": "Contraseña", + "tokenNameLabel": "Nombre del token", + "displayCard": { + "title": "Visualización", + "description": "Idioma, unidades y formatos; se guardan en cuanto los cambias." } }, "admin": { @@ -7084,14 +7196,14 @@ "aiServerKey": { "title": "Clave de IA del servidor", "description": "Una clave del operador para las cuentas que no tienen una propia.", - "byokHint": "La clave propia del usuario (BYOK, inicio de sesión de ChatGPT o proveedor local) siempre tiene prioridad — la clave del servidor es el último paso de la cadena de proveedores.", + "byokHint": "La clave propia del usuario (BYOK, inicio de sesión de ChatGPT o proveedor local) siempre tiene prioridad; la clave del servidor es el último paso de la cadena de proveedores.", "consentHint": "Las solicitudes con la clave del servidor requieren el consentimiento de IA activo del usuario; sin un recibo de consentimiento no se envía nada.", "baseUrlHint": "La URL base determina la API: se reconoce una URL de Anthropic como https://api.anthropic.com/v1, cualquier otro host se llama a través de la API compatible con OpenAI.", "statusConfigured": "Configurada", "statusNotConfigured": "Sin configurar", "keyLabel": "Clave de API", "keyPlaceholder": "sk-...", - "keyPlaceholderSet": "Guardada ({preview}) — introduce una clave nueva para reemplazarla", + "keyPlaceholderSet": "Guardada ({preview}). Introduce una clave nueva para reemplazarla", "modelLabel": "Modelo", "baseUrlLabel": "URL base", "save": "Guardar", @@ -7125,7 +7237,10 @@ "typeCodex": "Inicio de sesión de ChatGPT (personal)", "typeLocal": "Modelo local (personal)", "typeGateway": "Pasarela compatible con OpenAI (personal)", - "spendToday": "Hoy ({date}): {total} tokens, {operator} de ellos en la clave del operador." + "spendToday": "Hoy ({date}): {total} tokens, {operator} de ellos en la clave del operador.", + "statusPaused": "En pausa", + "pausedHint": "En pausa tras fallos repetidos para {count} usuarios hasta {at}. Después se vuelve a intentar automáticamente.", + "modelNotServed": "El endpoint ya no ofrece el modelo {model}. Elige en los ajustes de IA un modelo que ofrezca." }, "aiQuality": { "title": "Calidad de los análisis", @@ -7169,9 +7284,9 @@ "defaultLanguage": "Idioma predeterminado", "defaultLanguageDescription": "Se usa para los nuevos usuarios y como reserva.", "defaultUserTimezone": "Zona horaria predeterminada para cuentas nuevas", - "defaultUserTimezoneDescription": "Se usa para cuentas nuevas que no tienen una zona detectada por el navegador. No afecta a los usuarios existentes.", + "defaultUserTimezoneDescription": "Se usa en cuentas nuevas sin zona detectada por el navegador; las cuentas existentes conservan la suya.", "defaultUserTimezoneFallback": "Europe/Berlin (predeterminada)", - "servicesGlobal": "Servicios (global)", + "servicesGlobal": "Canales y API", "servicesGlobalDescription": "Los servicios desactivados no están disponibles para ningún usuario.", "telegramGlobal": "Permitir notificaciones de Telegram.", "ntfyGlobal": "Permitir notificaciones de ntfy.", @@ -7179,19 +7294,21 @@ "integrationWebPush": "Web Push", "offlineGeoLabel": "Datos geográficos", "offlineGeoEnabled": "Sin conexión (GeoLite2)", - "offlineGeoFallback": "En línea ({host}) — monta las bases GeoLite2 y define GEOLITE2_DIR", + "offlineGeoFallback": "En línea ({host}): monta las bases GeoLite2 y define GEOLITE2_DIR", + "offlineGeoOff": "Desactivado", + "offlineGeoChosen": "En línea ({host})", "apiGlobal": "Permitir endpoints de API y tokens de API.", "umamiTitle": "Umami", - "umamiDescription": "Analítica web con Umami", + "umamiDescription": "Analítica web con Umami.", "glitchtipTitle": "GlitchTip", - "glitchtipDescription": "Seguimiento de errores con GlitchTip", + "glitchtipDescription": "Seguimiento de errores con GlitchTip.", "webPushVapidTitle": "Web Push VAPID", - "umamiEnabled": "Activar el seguimiento de Umami.", + "umamiEnabled": "Activar el seguimiento de Umami", "umamiScriptUrl": "URL del script de Umami", "umamiScriptUrlPlaceholder": "https://analytics.example.com/script.js", "umamiWebsiteId": "ID del sitio web de Umami", "umamiWebsiteIdPlaceholder": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "glitchtipEnabled": "Activar el seguimiento de errores de GlitchTip.", + "glitchtipEnabled": "Activar el seguimiento de errores de GlitchTip", "glitchtipDsn": "GlitchTip DSN", "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "Entorno de GlitchTip", @@ -7253,12 +7370,12 @@ "title": "Disponibilidad de módulos", "description": "Desactiva un módulo para todo el servidor.", "saveError": "No se pudo actualizar la disponibilidad de módulos.", - "detail": "Un módulo desactivado aquí queda apagado para todas las cuentas, sin importar la preferencia personal. Los dominios principales (peso, presión arterial, pulso, medicación) están siempre activos y no se pueden desactivar.", - "coachFollowsSwitch": "Sigue el interruptor del Coach en Asistente." + "detail": "El peso, la presión arterial y el pulso son datos básicos y siempre están activos.", + "coachFollowsSwitch": "Sigue el interruptor del Coach en la sección Coach." }, "invites": { - "title": "Invitaciones", - "description": "Enlaces de invitación para el registro: funcionan incluso con el registro cerrado.", + "title": "Enlaces de invitación", + "description": "Cada enlace permite un registro aunque el registro esté cerrado.", "create": "Crear invitación", "createTitle": "Crear invitación", "createDescription": "El enlace permite registrarse incluso con el registro cerrado.", @@ -7304,8 +7421,8 @@ "revokedToast": "Invitación revocada.", "revokeError": "No se pudo revocar la invitación." }, - "userManagement": "Gestión de usuarios", - "userManagementDescription": "Gestiona las cuentas: roles, restablecimientos de contraseña y cierres de sesión forzados.", + "userManagement": "Cuentas", + "userManagementDescription": "Roles, restablecimientos de contraseña y cierres de sesión forzados de cada cuenta.", "userName": "Nombre", "userEmail": "Correo electrónico", "userRole": "Rol", @@ -7339,7 +7456,6 @@ "tokenNeverUsed": "Nunca", "tokensEmptyTitle": "Aún no hay tokens de API", "tokensEmptyDescription": "Los tokens se emiten cuando un cliente nativo inicia sesión o desde Ajustes → Cuenta.", - "loginOverview": "Resumen de inicios de sesión", "loginOverviewDescription": "Eventos de inicio de sesión y autenticación de todas las cuentas, filtrables y exportables como CSV.", "allAuthEvents": "Todos los eventos de autenticación", "failedOnly": "Solo fallidos", @@ -7372,6 +7488,7 @@ "finalDelete": "Eliminar permanentemente", "deletedResult": "Eliminadas {rows} filas en {tables} tablas. Se conservaron las cuentas de usuario y las credenciales de inicio de sesión.", "authRegister": "Registro", + "authClaim": "Perfil asumido", "authLogin": "Iniciar sesión", "authLoginPasskey": "Inicio de sesión con passkey", "authLoginPassword": "Inicio de sesión con contraseña", @@ -7446,7 +7563,7 @@ }, "services": { "title": "Servicios", - "subtitle": "Activa o desactiva los canales de notificación y las integraciones en todo el sistema." + "subtitle": "Activa o desactiva los canales de notificación y la API en todo el servidor." }, "integrations": { "title": "Integraciones", @@ -7532,7 +7649,8 @@ "refresh": "Actualizar", "eventDetails": "Detalles del evento", "closeDetails": "Cerrar detalles", - "processDetail": "El búfer es volátil y está limitado a 500 entradas." + "processDetail": "El búfer es volátil y está limitado a 500 entradas.", + "cardTitle": "Eventos recientes" }, "backups": { "title": "Copias de seguridad", @@ -7584,7 +7702,7 @@ "restorePromptLabel": "Escribe RESTORE para continuar", "previewTitle": "Contenido de la copia", "previewLoading": "Leyendo el contenido…", - "previewUnavailable": "No se pudo leer el contenido — restaurar sigue siendo posible.", + "previewUnavailable": "No se pudo leer el contenido, pero restaurar sigue siendo posible.", "previewStillReading": "Esta copia se hizo con una versión anterior, así que su contenido se está leyendo completo, y con un historial grande eso lleva un rato. Vuelve a abrir este diálogo en unos minutos para verlo. Restaurar no depende de ello.", "previewEmpty": "El archivo no contiene registros.", "previewOther": "Otros registros", @@ -7699,7 +7817,8 @@ "restoreSkippedCycleCiphertext": "Nota o etiqueta del ciclo cifrada con una clave que este servidor no tiene (el día o el síntoma se conserva sin ella)", "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)" + "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)" }, "danger-zone": { "title": "Zona de peligro", @@ -7716,7 +7835,6 @@ "encryption": { "title": "Cifrado", "subtitle": "Cobertura de cifrado y estado de rotación de claves para los datos en reposo.", - "loading": "Cargando el estado de cifrado...", "loadError": "No se pudo cargar el estado de cifrado.", "coverageTitle": "Cobertura", "coverageDescription": "Cuántas filas cifradas hay bajo cada id de clave.", @@ -7761,12 +7879,45 @@ "backupsNone": "Ninguna copia ha registrado todavía las claves que necesita.", "backupsStillNeed": "Todas las filas están rotadas, pero las copias aún necesitan {keys}", "backupsUnrecordedBadge": "Todas las filas están rotadas, pero copias antiguas pueden necesitar aún una clave retirada", - "backupsDetail": "La rotación vuelve a cifrar cada fila, pero una copia conserva la clave con la que se escribió su contenido, y restaurarla necesita esa clave. Mantén una clave retirada en ENCRYPTION_KEYS hasta que no aparezca abajo." + "backupsDetail": "La rotación vuelve a cifrar cada fila, pero una copia conserva la clave con la que se escribió su contenido, y restaurarla necesita esa clave. Mantén una clave retirada en ENCRYPTION_KEYS hasta que no aparezca abajo.", + "columnsShow": "Mostrar la tabla ({count} con filas)", + "columnsHide": "Ocultar la tabla", + "columnsShowAll": "Mostrar las {count} columnas", + "columnsShowWithRows": "Solo columnas con filas", + "columnsNoneWithRows": "Ninguna columna cifrada contiene filas todavía." + }, + "maintenance": { + "title": "Mantenimiento de la tabla de mediciones", + "description": "Libera el espacio que ocupaban los restos antiguos de sincronización, cuando la limpieza ya ha terminado.", + "body": "Ejecuta un vacuum de la tabla de mediciones y después reconstruye sus índices, uno a uno. Leer y guardar siguen funcionando mientras se ejecuta. Elige una hora tranquila y deja espacio libre en disco para una copia más del índice más grande.", + "tableSize": "Tabla con índices", + "indexSize": "Índices", + "loading": "Comprobando el estado del mantenimiento…", + "loadError": "No se pudo leer el estado del mantenimiento.", + "unavailable": "No hay ningún proceso en segundo plano conectado, así que el mantenimiento no puede ejecutarse aquí.", + "purgePending": "La limpieza de restos antiguos de sincronización sigue en marcha. El mantenimiento podrá empezar cuando termine.", + "state": { + "idle": "Aún no se ha ejecutado.", + "queued": "Esperando al proceso en segundo plano.", + "running": "En marcha desde las {since}. En una instancia grande puede tardar un rato.", + "completed": "Terminado el {at}.", + "refusedPurge": "No empezó porque la limpieza seguía en marcha. Vuelve a iniciarlo cuando la limpieza haya terminado.", + "alreadyRunning": "No empezó porque ya había otra ejecución trabajando.", + "failed": "Detenido el {at}. El registro de la aplicación indica el paso y el runbook explica qué revisar." + }, + "start": "Iniciar mantenimiento", + "confirmTitle": "¿Iniciar el mantenimiento ahora?", + "confirmBody": "Se ejecuta en segundo plano, paso a paso, y puede tardar un rato. Si hoy aún no has hecho una copia de seguridad, hazla antes.", + "confirm": "Iniciar", + "cancel": "Cancelar", + "started": "Mantenimiento iniciado.", + "alreadyQueued": "Ya hay un mantenimiento en cola o en marcha.", + "startFailed": "No se pudo iniciar el mantenimiento." } }, "carrier": "Operador", "mfaRequired": "Exigir un segundo factor", - "mfaRequiredDescription": "Cada cuenta sin un segundo factor activo se dirige a la configuración tras iniciar sesión. Esto pide a los usuarios web que lo configuren, pero no bloquea el acceso directo por API o nativo (Bearer) de las cuentas que aún no lo tienen.", + "mfaRequiredDescription": "Las cuentas sin segundo factor van a configurarlo tras iniciar sesión en la web; el acceso por API o nativo (Bearer) sigue abierto.", "mfaEnforcedOn": "MFA activado", "mfaEnforcedOff": "MFA desactivado", "mfaEnforcedOnHint": "Este usuario debe usar un segundo factor. Haz clic para dejar de exigirlo.", @@ -7864,7 +8015,9 @@ "restartNote": "Reinicia HealthLog después del cambio.", "lastResort": "Último recurso, solo si estás seguro de que la clave es correcta y ninguna de las tres opciones sirve: define {setting} y reinicia. HealthLog sirve igualmente y solo registra el hallazgo. Esto desactiva una comprobación de seguridad: con una clave incorrecta falla cada valor cifrado y los datos nuevos se escriben con esa clave.", "codes": "Para el administrador: las peticiones a la API responden 503 con el código de error {code}, y /api/health indica el motivo {reason}." - } + }, + "loginOverviewCardTitle": "Eventos de inicio de sesión", + "userColumn": "Usuario" }, "achievements": { "title": "Logros", @@ -7880,7 +8033,9 @@ "noneUnlockedYet": "Aún no has desbloqueado ningún logro.", "pointsValue": "{points} puntos", "metricCount": "{count}", - "metricDays": "{count} días", + "metricDaysOne": "{count} día", + "metricDaysFew": "{count} días", + "metricDaysOther": "{count} días", "metricPercent": "{count}%", "badges": { "intakeTotal1": { @@ -7903,14 +8058,6 @@ "title": "Leyenda de las tomas", "description": "Registra 300 tomas de medicación." }, - "overIntake1": { - "title": "Dosis doble", - "description": "Toma la medicación al menos una vez más de lo previsto." - }, - "skippedIntake1": { - "title": "Un paso atrás", - "description": "Omite al menos una toma planificada." - }, "passkeyCreated1": { "title": "Clave de acceso creada", "description": "Crea tu primera clave de acceso." @@ -7936,19 +8083,19 @@ "description": "Toma todos los medicamentos a tiempo durante 1 día." }, "compliance801": { - "title": "Adherencia 80% · 1d", + "title": "Adherencia del 80% durante 1 día", "description": "Alcanza al menos un 80% de adherencia de 30 días durante 1 día." }, "bmiGreen1": { - "title": "IMC en verde · 1d", + "title": "IMC en verde durante 1 día", "description": "Mantén tu IMC en el rango normal durante 1 día." }, "bpGreen1": { - "title": "Presión en verde · 1d", + "title": "Presión en verde durante 1 día", "description": "Mantén tu presión arterial en el rango normal durante 1 día." }, "pulseGreen1": { - "title": "Pulso en verde · 1d", + "title": "Pulso en verde durante 1 día", "description": "Mantén tu pulso en reposo en el rango normal durante 1 día." }, "onTimePerfect7": { @@ -7956,19 +8103,19 @@ "description": "Toma todos los medicamentos a tiempo durante 7 días seguidos." }, "compliance807": { - "title": "Adherencia 80% · 7d", + "title": "Adherencia del 80% durante 7 días", "description": "Alcanza al menos un 80% de adherencia de 30 días durante 7 días seguidos." }, "bmiGreen7": { - "title": "IMC en verde · 7d", + "title": "IMC en verde durante 7 días", "description": "Mantén tu IMC en el rango normal durante 7 días seguidos." }, "bpGreen7": { - "title": "Presión en verde · 7d", + "title": "Presión en verde durante 7 días", "description": "Mantén tu presión arterial en el rango normal durante 7 días seguidos." }, "pulseGreen7": { - "title": "Pulso en verde · 7d", + "title": "Pulso en verde durante 7 días", "description": "Mantén tu pulso en reposo en el rango normal durante 7 días seguidos." }, "onTimePerfect30": { @@ -7976,19 +8123,19 @@ "description": "Toma todos los medicamentos a tiempo durante 30 días seguidos." }, "compliance8030": { - "title": "Adherencia 80% · 30d", + "title": "Adherencia del 80% durante 30 días", "description": "Alcanza al menos un 80% de adherencia de 30 días durante 30 días seguidos." }, "bmiGreen30": { - "title": "IMC en verde · 30d", + "title": "IMC en verde durante 30 días", "description": "Mantén tu IMC en el rango normal durante 30 días seguidos." }, "bpGreen30": { - "title": "Presión en verde · 30d", + "title": "Presión en verde durante 30 días", "description": "Mantén tu presión arterial en el rango normal durante 30 días seguidos." }, "pulseGreen30": { - "title": "Pulso en verde · 30d", + "title": "Pulso en verde durante 30 días", "description": "Mantén tu pulso en reposo en el rango normal durante 30 días seguidos." }, "onTimePerfect180": { @@ -7996,19 +8143,19 @@ "description": "Toma todos los medicamentos a tiempo durante 180 días seguidos." }, "compliance80180": { - "title": "Adherencia 80% · 180d", + "title": "Adherencia del 80% durante 180 días", "description": "Alcanza al menos un 80% de adherencia de 30 días durante 180 días seguidos." }, "bmiGreen180": { - "title": "IMC en verde · 180d", + "title": "IMC en verde durante 180 días", "description": "Mantén tu IMC en el rango normal durante 180 días seguidos." }, "bpGreen180": { - "title": "Presión en verde · 180d", + "title": "Presión en verde durante 180 días", "description": "Mantén tu presión arterial en el rango normal durante 180 días seguidos." }, "pulseGreen180": { - "title": "Pulso en verde · 180d", + "title": "Pulso en verde durante 180 días", "description": "Mantén tu pulso en reposo en el rango normal durante 180 días seguidos." }, "onTimePerfect360": { @@ -8016,19 +8163,19 @@ "description": "Toma todos los medicamentos a tiempo durante 360 días seguidos." }, "compliance80360": { - "title": "Adherencia 80% · 360d", + "title": "Adherencia del 80% durante 360 días", "description": "Alcanza al menos un 80% de adherencia de 30 días durante 360 días seguidos." }, "bmiGreen360": { - "title": "IMC en verde · 360d", + "title": "IMC en verde durante 360 días", "description": "Mantén tu IMC en el rango normal durante 360 días seguidos." }, "bpGreen360": { - "title": "Presión en verde · 360d", + "title": "Presión en verde durante 360 días", "description": "Mantén tu presión arterial en el rango normal durante 360 días seguidos." }, "pulseGreen360": { - "title": "Pulso en verde · 360d", + "title": "Pulso en verde durante 360 días", "description": "Mantén tu pulso en reposo en el rango normal durante 360 días seguidos." }, "moodFirst": { @@ -8112,15 +8259,15 @@ "description": "Cambiaste el idioma de la app al menos una vez." }, "missFree7": { - "title": "Sin dosis perdidas · 7d", + "title": "Sin dosis perdidas en 7 días", "description": "7 días consecutivos sin una dosis perdida." }, "missFree30": { - "title": "Sin dosis perdidas · 30d", + "title": "Sin dosis perdidas en 30 días", "description": "30 días consecutivos sin una dosis perdida." }, "missFree90": { - "title": "Sin dosis perdidas · 90d", + "title": "Sin dosis perdidas en 90 días", "description": "90 días consecutivos sin una dosis perdida." }, "measurementWeeks4": { @@ -8129,10 +8276,10 @@ }, "selfContextComplete": { "title": "Autoinforme al día", - "description": "Los cuatro campos del autoinforme completados — el Coach conoce tu contexto." + "description": "Los cuatro campos del autoinforme completados: el Coach conoce tu contexto." }, "sleepLog7": { - "title": "Registro de sueño · 7d", + "title": "Registro de sueño durante 7 días", "description": "Datos de sueño registrados durante 7 días consecutivos." } }, @@ -8151,7 +8298,7 @@ }, "hiddenCard": { "title": "Logro oculto", - "description": "Sigue registrando — quizá te topes con él.", + "description": "Sigue registrando y quizá te topes con él.", "ariaLabel": "Logro oculto bloqueado" }, "hiddenUnlockToast": { @@ -8160,7 +8307,7 @@ "dashboardCard": { "title": "Desbloqueos recientes", "viewAll": "Ver todo", - "empty": "Aún no hay logros — sigue registrando para desbloquear el primero." + "empty": "Aún no hay logros. Sigue registrando para desbloquear el primero." } }, "notifications": { @@ -8222,9 +8369,6 @@ "enabledToast": "Avisos proactivos del Coach activados.", "disabledToast": "Avisos proactivos del Coach desactivados.", "saveError": "No se pudo guardar. Inténtalo de nuevo más tarde.", - "suggestionsLabel": "Sugerencias de ejemplo", - "suggestionsDesc": "Muestra de vez en cuando una pregunta de ejemplo en la pantalla del Coach.", - "suggestionsAria": "Activar o desactivar las sugerencias de ejemplo del Coach", "aiComposedLabel": "Deja que el Coach redacte el aviso", "aiComposedDesc": "Cuando hay un proveedor disponible, el aviso proactivo lo redacta el modelo en lugar de la plantilla fija. Desactivado por defecto.", "aiComposedAria": "Activar o desactivar el aviso proactivo redactado por IA" @@ -8295,7 +8439,27 @@ "loginRequired": "Inicia sesión para gestionar las notificaciones.", "someChannelsGloballyDisabled": "Uno o más canales están desactivados en los ajustes y no recibirán notificaciones.", "reminderSettingsLink": "Ajustes de recordatorios", - "channelsLink": "Canales" + "channelsLink": "Canales", + "handover": { + "claimedTitle": "{name} ya tiene su propia cuenta", + "claimedEnd": "{name} ha asumido su historial de salud. Tu acceso ha terminado.", + "claimedRead": "{name} ha asumido su historial de salud. Puedes seguir viéndolo, pero ya no cambiarlo.", + "claimedManage": "{name} ha asumido su historial de salud. Puedes seguir gestionándolo hasta que decida otra cosa.", + "changedTitle": "{name} ha cambiado tu acceso", + "changedEnd": "Tu acceso al historial de salud de {name} ha terminado.", + "changedRead": "Ahora puedes ver el historial de salud de {name}, pero no cambiarlo.", + "changedManage": "Puedes gestionar el historial de salud de {name}.", + "unnamed": { + "claimedTitle": "La persona a la que cuidabas ya tiene su propia cuenta", + "claimedEnd": "La persona a la que cuidabas ha asumido su historial de salud. Tu acceso ha terminado.", + "claimedRead": "La persona a la que cuidabas ha asumido su historial de salud. Puedes seguir viéndolo, pero ya no modificarlo.", + "claimedManage": "La persona a la que cuidabas ha asumido su historial de salud. Puedes seguir gestionándolo hasta que decida otra cosa.", + "changedTitle": "Tu acceso a un historial de salud ha cambiado", + "changedEnd": "Tu acceso al historial de salud de la persona a la que cuidabas ha terminado.", + "changedRead": "Ahora puedes ver el historial de salud de la persona a la que cuidabas, pero no modificarlo.", + "changedManage": "Puedes gestionar el historial de salud de la persona a la que cuidabas." + } + } }, "i18n": { "maintainershipBanner": { @@ -8326,7 +8490,7 @@ } }, "edit": { - "title": "Editar rango objetivo — {metric}", + "title": "Editar rango objetivo: {metric}", "description": "Ajusta el rango objetivo usado en gráficas, insights y el informe médico. Valores en {unit}.", "derivedHint": "Esta tarjeta se calcula a partir de otras métricas. Edita en su lugar los objetivos subyacentes (p. ej. tensión arterial o peso).", "minLabel": "Mín", @@ -8350,15 +8514,19 @@ "currentValue": "Actual: {value} {unit}", "targetRangeValue": "Rango objetivo: {min}–{max} {unit}", "sourceLabel": "Fuente: {source}", + "sourceNames": { + "moodEntries": "Entradas de ánimo", + "sevenDays": "7 días" + }, "inTarget": "En rango objetivo", "aboveTarget": "{delta} {unit} por encima del rango", "belowTarget": "{delta} {unit} por debajo del rango", "diastolic": "Diastólica", - "glucoseFasting": "Glucemia — en ayunas", - "glucosePostprandial": "Glucemia — posprandial", - "glucoseRandom": "Glucemia — cualquiera", - "glucoseBedtime": "Glucemia — antes de dormir", - "glucoseUnspecified": "Glucemia — sin indicar", + "glucoseFasting": "Glucemia (en ayunas)", + "glucosePostprandial": "Glucemia (posprandial)", + "glucoseRandom": "Glucemia (cualquiera)", + "glucoseBedtime": "Glucemia (antes de dormir)", + "glucoseUnspecified": "Glucemia (sin indicar)", "status": { "underweight": "Peso insuficiente", "normal": "Normal", @@ -8444,7 +8612,7 @@ "askMedicationName": "Indica el medicamento, p. ej. tomado Ramipril.", "medicationNotFoundExact": "Medicamento no encontrado. Envía el nombre exactamente como aparece en HealthLog.", "helpHeader": "Comandos disponibles:", - "helpBody": "/help — mostrar esta ayuda\n/add — registrar una toma\n/start — iniciar el bot\n\nComandos de texto:\ntaken <nombre> — confirmar una toma\n\nBotones en los recordatorios:\n• Tomado — confirmar la toma\n• 🕐 1h / 🕐 3h — posponer el recordatorio\n• ⏭ Omitir — omitir la toma\n• ✓ Confirmar — confirmar una toma omitida", + "helpBody": "/help: mostrar esta ayuda\n/add: registrar una toma\n/start: iniciar el bot\n\nComandos de texto:\ntaken <nombre>: confirmar una toma\n\nBotones en los recordatorios:\n• Tomado: confirmar la toma\n• 🕐 1h / 🕐 3h: posponer el recordatorio\n• ⏭ Omitir: omitir la toma\n• ✓ Confirmar: confirmar una toma omitida", "measureValuePrompt": "Responde con el valor para registrarlo (opcional).", "buttonMeasureValue": "Valor…", "measureValueSaved": "Lectura guardada.", @@ -8469,9 +8637,9 @@ }, "lowStockReminders": { "title": "Quedan pocas existencias: {medName}", - "bodyToday": "{medName}: quedan {units} unidades — menos de un día de suministro.", - "bodyReorder": "{medName}: quedan {units} unidades. Las existencias se agotan hacia el {runsOutOn} — vuelve a pedir antes del {reorderBy}.", - "bodyLastDose": "{medName}: quedan {units} unidades — alrededor de tu última dosis. Las existencias se agotan hacia el {runsOutOn}.", + "bodyToday": "{medName}: quedan {units} unidades, menos de un día de suministro.", + "bodyReorder": "{medName}: quedan {units} unidades. Las existencias se agotan hacia el {runsOutOn}. Vuelve a pedir antes del {reorderBy}.", + "bodyLastDose": "{medName}: quedan {units} unidades, alrededor de tu última dosis. Las existencias se agotan hacia el {runsOutOn}.", "bodyExpired": "{medName}: no quedan existencias utilizables. {units} unidades están marcadas como caducadas.", "bodyAsNeeded": "{medName}: solo quedan {units} unidades. Vuelve a pedir antes de necesitar la próxima dosis." }, @@ -8502,7 +8670,7 @@ }, "clinicianView": { "title": "Registro de salud compartido", - "period": "Período del informe: {start} – {end}", + "period": "Período del informe: {start}–{end}", "expires": "Este enlace caduca el {date}.", "provenance": "Este es un resumen de solo lectura compartido por la persona a la que pertenece, exportado de sus datos de HealthLog autorregistrados. Los valores son autoinformados y pueden incluir lecturas sincronizadas desde dispositivos; trátelos como informados por el paciente, no como un examen clínico.", "labs": "Valores de laboratorio", @@ -8526,7 +8694,7 @@ "strain": "Puntuación de esfuerzo", "score": "Puntuación de bienestar" }, - "footer": "Compartido desde HealthLog — un registro de salud autoalojado.", + "footer": "Compartido desde HealthLog, un registro de salud autoalojado.", "unlock": { "title": "Introduzca la frase de acceso", "description": "Este registro de salud compartido está protegido. Introduzca la frase de acceso que recibió para verlo.", @@ -8561,7 +8729,11 @@ }, "course": "Tratamiento {n}", "courseRange": "del {start} al {end}", - "courseSince": "desde el {start}" + "courseSince": "desde el {start}", + "unavailable": { + "title": "Enlace caducado o no válido", + "description": "Este enlace para compartir ha caducado, se ha revocado o está incompleto. Pide un enlace nuevo a la persona que lo compartió." + } }, "cycle": { "symptomCategory": { @@ -8614,7 +8786,7 @@ }, "crosstab": { "description": "Cómo se compara cada medición entre tus fases lútea y folicular, con tus propios datos.", - "pairLabel": "{metric} · lútea vs. folicular", + "pairLabel": "{metric}: fase lútea vs. folicular", "detail": "Lútea {lutealAvg} {unit} ({lutealDays} días) vs. folicular {follicularAvg} {unit} ({follicularDays} días).", "metricRestingHeartRate": "Frecuencia cardíaca en reposo", "metricHrv": "Variabilidad de la frecuencia cardíaca", @@ -8737,8 +8909,8 @@ "ariaUnknown": "Aún no hay ciclo activo", "caption": "de tu ciclo", "firstPeriodCta": "Registrar mi primer periodo", - "periodOverdue": "Tu período llega más tarde de lo habitual — el conteo de días se pausa hasta que registres el próximo período.", - "periodOverdueDays": "Tu período llega {days} días más tarde de lo habitual — el conteo de días se pausa hasta que registres el próximo período.", + "periodOverdue": "Tu período llega más tarde de lo habitual. El conteo de días se pausa hasta que registres el próximo período.", + "periodOverdueDays": "Tu período llega {days} días más tarde de lo habitual. El conteo de días se pausa hasta que registres el próximo período.", "logPeriodCta": "Registrar período" }, "calendar": { @@ -8763,7 +8935,7 @@ "predictions": { "title": "Qué viene después", "nextPeriod": "Próximo periodo", - "nextPeriodWindow": "{low} – {high}", + "nextPeriodWindow": "{low}–{high}", "fertileWindow": "Ventana fértil", "ovulation": "Ovulación estimada", "confidenceHigh": "Alta", @@ -8772,7 +8944,7 @@ "stillLearning": "Aún aprendiendo", "stillLearningBody": "Registra al menos tres ciclos completos y tus predicciones mejorarán. Hasta entonces es una estimación aproximada.", "rawMode": "Predicción desactivada", - "rawModeBody": "Has desactivado las predicciones — solo se muestran tus datos registrados, sin pronóstico.", + "rawModeBody": "Has desactivado las predicciones: solo se muestran tus datos registrados, sin pronóstico.", "none": "Aún no hay predicción. Registra un periodo para empezar.", "ovulationConfirmed": "Ovulación (confirmada)" }, @@ -8834,7 +9006,7 @@ "temperatureSection": "Temperatura y ovulación", "intimacySection": "Intimidad y anticoncepción", "testsSection": "Pruebas", - "phaseDayHeader": "Día {day} · {phase}", + "phaseDayHeader": "Día {day}, {phase}", "bbtHint": "Mídela a primera hora de la mañana, antes de levantarte.", "titleForDate": "Registro del {date}", "descriptionToday": "Hoy. Flujo, síntomas y notas.", @@ -8962,14 +9134,14 @@ "postpone": { "menuItem": "Aplazar…", "title": "Aplazar vencimiento", - "description": "Solo mueve el vencimiento actual — el ritmo regular no cambia.", + "description": "Solo mueve el vencimiento actual; el ritmo regular no cambia.", "chip": "+{days} días", "dateLabel": "Nueva fecha", "confirm": "Aplazar" }, "skip": { "heading": "Omitir este vencimiento", - "explainer": "Se registra honestamente como omitido — no como hecho. El intervalo se reinicia desde hoy.", + "explainer": "Se registra honestamente como omitido, no como hecho. El intervalo se reinicia desde hoy.", "action": "Omitir", "confirmTitle": "¿Omitir este vencimiento?" }, @@ -9011,10 +9183,10 @@ "VISCERAL_FAT": "Grasa visceral", "BODY_MASS_INDEX": "Índice de masa corporal (IMC)", "WAIST_CIRCUMFERENCE": "Perímetro de cintura", - "PHQ9_SCORE": "PHQ-9 · ánimo", - "GAD7_SCORE": "GAD-7 · preocupación", - "WHO5_SCORE": "WHO-5 · bienestar", - "SCI_SCORE": "SCI · sueño" + "PHQ9_SCORE": "PHQ-9 (ánimo)", + "GAD7_SCORE": "GAD-7 (preocupación)", + "WHO5_SCORE": "WHO-5 (bienestar)", + "SCI_SCORE": "SCI (sueño)" }, "typeGroups": { "vitals": "Constantes vitales", @@ -9172,7 +9344,7 @@ "emptyDescription": "Añade un valor para seguir este biomarcador con el tiempo.", "title": "Biomarcador", "genericDescription": "Un biomarcador de laboratorio que sigues. Añade lecturas para ver su evolución frente al rango de referencia.", - "valuesTitle": "{marker} — todas las lecturas", + "valuesTitle": "{marker}: todas las lecturas", "valuesDescription": "Cada lectura de este biomarcador. Edita o elimina una entrada directamente.", "backToMarker": "Volver a {marker}", "latestResult": "Último resultado" @@ -9343,12 +9515,12 @@ "dateLabel": "Fecha", "refLowLabel": "Referencia inferior", "refHighLabel": "Referencia superior", - "newBiomarker": "Nuevo biomarcador — se creará", + "newBiomarker": "Nuevo biomarcador: se creará", "linksExisting": "Se vincula a {name} existente", "duplicateWarning": "Ya existe un valor coincidente", "lowConfidence": "Verifica este valor", "lowConfidenceBadge": "Confianza baja", - "valueUnreadable": "Valor ilegible — introdúcelo manualmente", + "valueUnreadable": "Valor ilegible: introdúcelo manualmente", "saveSelected": "Guardar {count} seleccionados", "discardAll": "Descartar todo", "savedToast": "{count} resultados de laboratorio guardados", @@ -9464,6 +9636,10 @@ "vaccinations": { "label": "Vacunas", "description": "La cartilla de vacunación estructurada: dosis, serie, próximo refuerzo." + }, + "timeline": { + "label": "Cronología", + "description": "Años de un vistazo: enfermedades, medicamentos y visitas junto a tus valores, además de tus propios acontecimientos vitales." } }, "moduleOff": { @@ -9495,8 +9671,8 @@ }, "form": { "date": "Fecha", - "catalogLabel": "Vacuna (catálogo)", - "identityHint": "Elige del catálogo o escríbela abajo: basta con una.", + "catalogLabel": "Vacuna", + "identityHint": "Elige una vacuna de la lista o escríbela abajo. Basta con una.", "catalogNone": "Elegir una vacuna", "catalogSearch": "Buscar vacunas", "catalogNoMatch": "No se encontró ninguna entrada del catálogo", @@ -9616,10 +9792,31 @@ "overdue": "Refuerzo pendiente desde el {date}", "dueSoon": "Refuerzo previsto el {date}", "current": "Próximo refuerzo el {date}" + }, + "custom": { + "title": "Vacunas propias", + "add": "Añadir vacuna propia", + "name": "Nombre", + "components": "Protege contra", + "seriesDoses": "Dosis de la serie primaria", + "boosterMonths": "Refuerzo cada (meses)", + "saved": "Vacuna guardada", + "deleted": "Vacuna eliminada", + "deleteConfirm": "¿Eliminar esta vacuna? Las dosis ya registradas conservan su nombre.", + "saveFailed": "No se pudo guardar la vacuna.", + "nameTaken": "Ya tienes una vacuna con este nombre.", + "editTitle": "Editar vacuna propia", + "description": "Una vacuna que el catálogo no recoge, contada como cualquier entrada del catálogo.", + "componentsHint": "Elige todas las enfermedades de las que protege.", + "boosterHint": "Déjalo vacío si no hay refuerzo.", + "protectsAgainst": "Protege contra {list}.", + "deleteTitle": "¿Quitar la vacuna propia?", + "sectionDescription": "Vacunas que añadiste tú porque el catálogo no las recoge.", + "catalogGroup": "Catálogo" } }, "illness": { - "title": "Diario de enfermedad", + "title": "Enfermedad", "subtitle": "Un registro retrospectivo de tus episodios de enfermedad y dolencias.", "settings": { "viewDescription": "Elige si los episodios se muestran como tarjetas o como lista compacta.", @@ -9758,7 +9955,7 @@ "loadError": "No se pudo cargar este episodio.", "backToList": "Volver al diario", "detail": { - "note": "Note" + "note": "Nota" }, "edit": { "title": "Editar episodio", @@ -9807,7 +10004,7 @@ "dismiss": "Ahora no", "applied": "Añadido", "dismissed": "Descartado", - "failed": "No se pudo añadir — inténtalo de nuevo", + "failed": "No se pudo añadir. Inténtalo de nuevo", "checkup": { "title": "¿Añadir un recordatorio de revisión?" }, @@ -9895,7 +10092,9 @@ "illness": "Enfermedad y recuperación", "cycle": "Ciclo", "correlations": "Relaciones entre métricas", - "snapshot": "Resumen de datos" + "snapshot": "Resumen de datos", + "environment": "Tiempo y aire", + "day": "Un día" }, "window": { "last7days": "últimos 7 días", @@ -10084,10 +10283,10 @@ "saved": "Guardado", "error": "Algo salió mal. Inténtalo de nuevo.", "instrument": { - "phq9": "PHQ-9 · ánimo", - "gad7": "GAD-7 · preocupación", - "who5": "WHO-5 · bienestar", - "sci": "SCI · sueño" + "phq9": "PHQ-9 (ánimo)", + "gad7": "GAD-7 (preocupación)", + "who5": "WHO-5 (bienestar)", + "sci": "SCI (sueño)" }, "instrumentSub": { "phq9": "Cribado de depresión, 9 preguntas", @@ -10528,12 +10727,34 @@ "daylight": "Luz diurna", "precip": "Precipitación", "pressureMean": "Presión atmosférica", - "pressureDelta": "Variación de presión" + "pressureDelta": "Variación de presión", + "pm25": "Partículas finas (PM2,5)", + "pm10": "Partículas (PM10)", + "no2": "Dióxido de nitrógeno", + "ozone8h": "Ozono (máximo de 8 horas)", + "europeanAqi": "Índice europeo de calidad del aire", + "uvIndexMax": "Índice UV (máximo)", + "dustMax": "Polvo del Sáhara", + "pollenMax": "Polen (valor más alto)" + }, + "chip": { + "day": "Tiempo del {date}", + "pollenHigh": "Mucho polen: {kinds}", + "hotNight": "Noche cálida", + "veryPoorAir": "Aire muy malo" + }, + "pollen": { + "alder": "aliso", + "birch": "abedul", + "grass": "gramíneas", + "mugwort": "artemisa", + "olive": "olivo", + "ragweed": "ambrosía" } }, "documents": { "title": "Documentos", - "subtitle": "Cada carta, informe y escaneo en un solo lugar con búsqueda — cifrado en reposo.", + "subtitle": "Cada carta, informe y escaneo en un solo lugar con búsqueda, cifrado en reposo.", "pageUpload": "Subir", "ownerOnlyUpload": "Solo el titular del historial puede añadir documentos.", "kind": { @@ -10575,15 +10796,15 @@ }, "error": { "fileTooLarge": "Este archivo supera el límite de {maxSize}.", - "quotaExceeded": "El almacenamiento está lleno — {used} de {quota} usados.", + "quotaExceeded": "El almacenamiento está lleno: {used} de {quota} usados.", "unsupportedType": "Este tipo de archivo no es compatible.", "purged": "Este documento se eliminó definitivamente y no se puede restaurar.", "duplicateExists": "Ya hay guardado un documento idéntico.", - "rateLimited": "Demasiadas subidas — inténtalo de nuevo en unos minutos.", + "rateLimited": "Demasiadas subidas. Inténtalo de nuevo en unos minutos.", "generic": "La subida falló. Inténtalo de nuevo." }, "toast": { - "duplicate": "Ya estaba guardado — se resalta el documento existente.", + "duplicate": "Ya estaba guardado. Se resalta el documento existente.", "deleted": "Documento eliminado", "restoreFailed": "No se pudo restaurar el documento." }, @@ -10606,7 +10827,7 @@ }, "empty": { "title": "Tu archivo de documentos está vacío", - "description": "Guarda cartas médicas, resultados de laboratorio, imágenes, recetas y certificados de vacunación — cifrados y con búsqueda por tipo, afección y año.", + "description": "Guarda cartas médicas, resultados de laboratorio, imágenes, recetas y certificados de vacunación. Se guardan cifrados y puedes buscarlos por tipo, afección y año.", "action": "Subir un primer documento", "noMatchesTitle": "No hay documentos coincidentes", "noMatchesDescription": "Nada coincide con la búsqueda y los filtros actuales." @@ -10646,9 +10867,9 @@ "linkCondition": "Vincular", "noConditions": "Sin vincular a una afección", "previewUnavailable": "Sin vista previa integrada para este tipo de archivo", - "uploadedMeta": "Añadido el {date} · {size}", + "uploadedMeta": "Añadido el {date}, {size}", "importedFrom": "Importado de {system}", - "importedFromWithId": "Importado de {system} · ID {id}", + "importedFromWithId": "Importado de {system}, ID {id}", "sourceOther": "otro sistema", "share": "Compartir", "labValuesLink": "Ver {count} valores de laboratorio", @@ -10748,7 +10969,7 @@ "CONDITION": "Afección", "MEDICATION_STATEMENT": "Medicamento" }, - "statedRange": "rango indicado {range}", + "statedRange": "Rango indicado {range}", "needsCheck": "Comprueba este valor contra el documento y luego guárdalo.", "verify": "Comprobar", "verifySave": "Guardar valor", @@ -10864,6 +11085,21 @@ "finishedHint": "Busca de nuevo o cambia un filtro para elegir más documentos.", "maxReached": "Puedes importar como máximo {max} documentos a la vez.", "noMatchHint": "Prueba con parte del nombre o quita la etiqueta y las fechas." + }, + "customize": "Personalizar vista", + "layout": { + "viewDescription": "Tarjetas con miniatura o una lista compacta.", + "arrangementHeading": "Disposición", + "arrangementDescription": "Cada mes en su propio bloque o todo seguido a lo ancho.", + "stacked": "Meses apilados", + "flow": "Continuo", + "saveFailed": "No se pudo guardar la vista." + }, + "deleteConfirm": { + "titleOne": "¿Eliminar este documento?", + "titleMany": "¿Eliminar {count} documentos?", + "body": "Puedes deshacerlo justo después. A los 30 días se elimina definitivamente.", + "action": "Eliminar" } }, "selfDescription": { @@ -10950,7 +11186,7 @@ "genericDescription": "Una medición que registras. Añade valores para seguirla con el tiempo.", "emptyTitle": "Aún sin valores", "emptyDescription": "Añade tu primer valor para empezar a representar esta métrica.", - "valuesTitle": "{metric} — todos los valores", + "valuesTitle": "{metric}: todos los valores", "valuesDescription": "Todos los valores registrados de esta métrica.", "backToMetric": "Volver a {metric}" } @@ -11003,14 +11239,14 @@ } }, "hydration": { - "referenceMeta": "Ingesta de referencia {value} {unit}/día · EFSA" + "referenceMeta": "Ingesta de referencia {value} {unit}/día (EFSA)" }, "caffeine": { - "ceilingMeta": "Límite de seguridad {value} {unit}/día · EFSA" + "ceilingMeta": "Límite de seguridad {value} {unit}/día (EFSA)" }, "micronutrients": { "title": "Vitaminas y minerales", - "rowMeta": "{days} de {window} días · ref. {value} {unit} (EFSA)", + "rowMeta": "{days} de {window} días, ref. {value} {unit} (EFSA)", "rowMetaNoRef": "{days} de {window} días", "footer": "{tracked} de {total} micronutrientes registrados tienen datos en este periodo.", "emptyTitle": "Aún no hay datos de micronutrientes sincronizados", @@ -11025,7 +11261,7 @@ }, "push": { "title": "Tu resumen matinal", - "bodyProvisional": "{line} · El sueño de anoche aún no está disponible." + "bodyProvisional": "{line}\nEl sueño de anoche aún no está disponible." }, "item": { "doseWindow": { @@ -11413,7 +11649,7 @@ "managed": { "title": "Perfiles de los que te ocupas", "description": "Historiales de salud sin inicio de sesión propio.", - "explainer": "Un perfil administrado es un historial de salud para alguien que no inicia sesión — una criatura, o una persona a la que cuidas. No tiene inicio de sesión ni dirección de correo, y tú pasas a ser su primera persona responsable. Más adelante puedes añadir otras, y cualquiera de vosotras puede entrar en el historial para mantenerlo al día.", + "explainer": "Un perfil administrado es un historial de salud para alguien que no inicia sesión, como una criatura o una persona a la que cuidas. No tiene inicio de sesión ni dirección de correo, y tú pasas a ser su primera persona responsable. Más adelante puedes añadir otras, y cualquiera de vosotras puede entrar en el historial para mantenerlo al día.", "nameLabel": "Nombre", "namePlaceholder": "El nombre con el que reconocerás este historial", "nameHint": "Hasta 80 caracteres. Se muestra allá donde aparezca este historial.", @@ -11435,7 +11671,7 @@ "creating": "Creando…", "created": "{name} ya está listo. Puedes entrar en ese historial desde el menú de la cuenta.", "errorStepUp": "Confirma tu segundo factor y vuelve a intentarlo.", - "errorOffline": "Esto no llegó al servidor. No ha cambiado nada — revisa tu conexión y vuelve a intentarlo.", + "errorOffline": "Esto no llegó al servidor. No ha cambiado nada. Revisa tu conexión y vuelve a intentarlo.", "errorInvalid": "El servidor rechazó esos datos. Revisa el nombre, la fecha y la zona horaria.", "errorFailed": "No se pudo crear el perfil.", "edit": "Editar", @@ -11446,7 +11682,7 @@ "editErrorFailed": "No se han podido guardar los cambios.", "delete": "Eliminar perfil", "deleteTitle": "¿Eliminar {name}?", - "deleteBody": "Todo lo registrado para {name} desaparece con el perfil — mediciones, medicamentos, documentos y notas. Esto no se puede deshacer.", + "deleteBody": "Todo lo registrado para {name} desaparece con el perfil: mediciones, medicamentos, documentos y notas. Esto no se puede deshacer.", "deleteGuardiansOne": "{count} persona se ocupa hoy de este historial y lo perderá.", "deleteGuardiansFew": "{count} personas se ocupan hoy de este historial y lo perderán.", "deleteGuardiansOther": "{count} personas se ocupan hoy de este historial y lo perderán.", @@ -11475,7 +11711,47 @@ "guardianErrorFailed": "No se pudo enviar la invitación.", "errorMfaRequired": "Esto necesita un segundo factor y tu cuenta aún no tiene ninguno. Configura uno en Ajustes → Seguridad y vuelve a intentarlo.", "rosterLoadError": "No se pudo cargar quién se ocupa de este historial.", - "errorRateLimit": "Demasiados perfiles creados en la última hora. Espera un poco y vuelve a intentarlo." + "errorRateLimit": "Demasiados perfiles creados en la última hora. Espera un poco y vuelve a intentarlo.", + "handover": { + "action": "Entregar", + "title": "Entregar a {name}", + "description": "Da a {name} un acceso propio. No se copia ni se mueve nada: el historial pasa a ser su cuenta.", + "guardiansLabel": "Acceso después de la entrega", + "guardiansHint": "Tu propuesta. {name} la ve y decide después de iniciar sesión.", + "access": { + "end": "Sin acceso", + "read": "Solo ver", + "manage": "Gestionar" + }, + "expiryLabel": "Enlace válido durante", + "expiry1": "1 día", + "expiry7": "7 días", + "expiry14": "14 días", + "minorHint": "{name} tiene menos de 16 años. Vale la pena conservar el acceso de lectura y hablar el paso juntos.", + "irreversible": "En cuanto {name} use el enlace, la entrega no se puede deshacer. El historial será suyo y decidirá quién conserva el acceso.", + "afterwards": "{name} confirma personalmente el aviso y cualquier consentimiento de IA. Los recordatorios de este historial dejarán de llegarte.", + "replaceNote": "Crear un enlace nuevo retira el que está abierto.", + "create": "Crear enlace", + "creating": "Creando…", + "linkOnce": "Este enlace solo se muestra ahora. Dáselo a {name} en persona o por un canal de confianza, o deja que escanee el código.", + "qrAlt": "Código QR del enlace de entrega para {name}", + "urlLabel": "Enlace de entrega", + "copy": "Copiar", + "copied": "Enlace copiado", + "copyError": "No se pudo copiar el enlace. Selecciónalo y cópialo a mano.", + "linkExpires": "Válido hasta el {date}.", + "done": "Listo", + "pending": "Hay un enlace de entrega abierto hasta el {date}.", + "replace": "Nuevo enlace", + "revoke": "Retirar enlace", + "revoked": "Enlace retirado", + "revokeFailed": "No se pudo retirar el enlace. Vuelve a intentarlo.", + "errorFailed": "No se pudo crear el enlace de entrega.", + "errorRosterChanged": "Mientras tanto ha cambiado quién se ocupa del historial. Cierra esto y vuelve a abrirlo.", + "oidcOnly": "No se puede entregar un perfil mientras este servidor solo permita el inicio de sesión único.", + "loadError": "No se pudo cargar el estado de la entrega.", + "entryHint": "Cuando {name} quiera llevar este historial por su cuenta." + } }, "toast": { "savedTo": "Guardado en el historial de {name}" @@ -11594,7 +11870,13 @@ "symptomEventDelete": "{name} eliminó una entrada de síntoma", "symptomDefinitionCreate": "{name} añadió un síntoma", "symptomDefinitionUpdate": "{name} cambió un síntoma", - "symptomDefinitionDelete": "{name} quitó un síntoma" + "symptomDefinitionDelete": "{name} quitó un síntoma", + "customVaccineCreate": "{name} añadió una vacuna propia", + "customVaccineUpdate": "{name} editó una vacuna propia", + "customVaccineDelete": "{name} quitó una vacuna propia", + "lifeEventCreate": "{name} añadió un acontecimiento vital", + "lifeEventUpdate": "{name} cambió un acontecimiento vital", + "lifeEventDelete": "{name} eliminó un acontecimiento vital" }, "section": { "measurements": { @@ -11644,12 +11926,28 @@ "notFound": "Ese acceso ya no existe.", "revokeFailed": "No se pudo terminar el acceso.", "renounceFailed": "No se pudo devolver el acceso.", - "profileNotFound": "Ese perfil ya no está — puede que se haya eliminado, o que tu acceso haya terminado.", + "profileNotFound": "Ese perfil ya no está. Puede que se haya eliminado, o que tu acceso haya terminado.", "deleteProfileFailed": "No se pudo eliminar el perfil.", "removeGuardianFailed": "No se pudo terminar ese acceso." }, "anamnesis": { "relativeNote": "Una entrada de antecedentes familiares describe a los familiares de esta persona. Añádela solo si querría que quedara registrada aquí." + }, + "handoverDecision": { + "title": "Quién conserva el acceso a tu historial", + "description": "Decide qué pueden hacer a partir de ahora las personas que llevaron tu historial.", + "proposed": "Propuesto: {access}", + "settled": "Ya cambiado en Acceso compartido", + "empty": "Nadie más tenía acceso a tu historial.", + "later": "Puedes cambiarlo cuando quieras en Ajustes, Acceso compartido.", + "save": "Confirmar", + "saving": "Guardando…", + "decideLater": "Más tarde", + "errorFailed": "No se pudo guardar tu decisión. Vuelve a intentarlo.", + "loadError": "No se pudo cargar quién llevaba tu historial.", + "notificationsTitle": "Configura tus propios recordatorios", + "notificationsBody": "Hasta ahora, los recordatorios de este historial llegaban a las personas que lo llevaban. A partir de ahora te llegan a ti, en cuanto añadas un canal de notificaciones.", + "notificationsAction": "Abrir notificaciones" } }, "encounters": { @@ -11670,7 +11968,7 @@ "both": "Ambos lados", "none": "Sin indicar" }, - "bodySiteWithSide": "{site} · {side}", + "bodySiteWithSide": "{site} ({side})", "sectionTitle": "Citas", "sectionDescription": "Visitas médicas y citas reservadas en un solo sitio.", "add": "Cita", @@ -11762,7 +12060,6 @@ "title": "Consultas y médicos", "description": "Tu propia agenda: escribe una consulta una vez y elígela en todas partes.", "linkLabel": "Agenda", - "add": "Entrada", "searchPlaceholder": "Buscar nombre, consulta o especialidad", "loadError": "No se pudo cargar la agenda.", "emptyTitle": "Todavía no hay entradas", @@ -11859,7 +12156,6 @@ "unreadableLabel": "Síntoma ilegible", "intensityPill": "{value}/10", "entry": { - "sheetTitle": "Registrar síntoma", "sheetDescription": "Uno de tus propios síntomas, su intensidad y cuándo.", "symptom": "Síntoma", "symptomRequired": "Elige un síntoma.", @@ -11908,5 +12204,411 @@ "title": "Síntomas registrados", "filed": "registrado con esta enfermedad" } + }, + "day": { + "scoreOfMax": "de {max}", + "cycleDay": "Día {n} del ciclo", + "cyclePhaseLine": { + "MENSTRUAL": "Día {n} del ciclo, fase menstrual", + "FOLLICULAR": "Día {n} del ciclo, fase folicular", + "OVULATORY": "Día {n} del ciclo, fase ovulatoria", + "LUTEAL": "Día {n} del ciclo, fase lútea" + }, + "previousDay": "Día anterior", + "nextDay": "Día siguiente", + "close": "Cerrar día", + "hidePanel": "Ocultar día", + "showPanel": "Mostrar {date}", + "openDay": "Abrir día", + "viewWholeDay": "Ver el día completo", + "captureForDay": "Registrar para este día", + "empty": "No hay entradas para este día.", + "notShared": "Algunas áreas no están compartidas contigo.", + "inTimeline": "En la cronología", + "askCoach": "Preguntar al Coach por este día", + "chartHint": "Haz clic en un punto para abrir su día.", + "loadFailed": "No se pudo cargar este día.", + "dayOf": "Día {n} de {m}", + "dayN": "Día {n}", + "usualRange": "Habitual de {lo} a {hi}", + "groups": { + "scores": "Puntuaciones", + "running": "En curso ese día", + "values": "Valores", + "happened": "Qué pasó" + }, + "notable": { + "extremeHigh": "Valor diario más alto desde {since}", + "extremeLow": "Valor diario más bajo desde {since}", + "firstValue": "Primera medición de este tipo", + "gap": "{days} días sin medición" + }, + "previousDayShortcut": "Día anterior (Alt + flecha izquierda)", + "nextDayShortcut": "Día siguiente (Alt + flecha derecha)", + "captureShort": "Registrar", + "inTimelineShort": "Cronología", + "askCoachPrompt": "¿Qué pasó el {date}?", + "chartHintTouch": "Toca un punto para ver su valor.", + "chartHintBar": "Haz clic en una barra para abrir su día.", + "chartHintTouchBar": "Toca una barra para ver sus valores.", + "chartHintCell": "Haz clic en un día para abrirlo.", + "chartHintTouchCell": "Toca un día para ver su valor.", + "coachChips": "Días en esta respuesta", + "description": "Todo lo que tus registros contienen para este día.", + "openDocument": "Abrir documento", + "previousReading": "Antes, {date}", + "rugLegend": "Los puntos bajo el eje marcan días con entradas, y un anillo, un día destacado.", + "sheetExpand": "Ampliar a toda la altura", + "sheetShrink": "Reducir", + "valuesLegend": "Punto para este día, barra para los 30 días anteriores", + "valuesAllOne": "Todos los valores", + "valuesAllFew": "Los {count} valores", + "valuesAllOther": "Los {count} valores", + "valuesFewer": "Menos valores", + "countEntriesOne": "1 entrada", + "countEntriesFew": "{count} entradas", + "countEntriesOther": "{count} entradas", + "meanOfReadingsOne": "Media de 1 medición", + "meanOfReadingsFew": "Media de {count} mediciones", + "meanOfReadingsOther": "Media de {count} mediciones", + "sinceLastVisit": { + "title": "Desde la última visita el {date}", + "countOne": "1 entrada", + "countFew": "{count} entradas", + "countOther": "{count} entradas", + "allOne": "Mostrar todo", + "allFew": "Mostrar las {count}", + "allOther": "Mostrar las {count}", + "fewer": "Mostrar menos", + "note": "Reunido a partir de tus entradas, sin valoración. Cada fecha abre el día completo.", + "report": "Informe médico del {date} hasta hoy" + }, + "travel": "De viaje", + "event": { + "started": "Inicio: {label}", + "ended": "Fin: {label}", + "paused": "Pausa: {label}", + "resumed": "Reanudación: {label}", + "illnessBegan": "Inicio: {label}", + "illnessResolved": "Recuperación: {label}" + }, + "pickDate": "Elegir una fecha", + "pickerTitle": "Ir a un día", + "today": "Hoy", + "hasEntries": "con entradas", + "dayNSince": "Día {n}, desde el {date}", + "sinceDate": "Desde el {date}" + }, + "timeline": { + "title": "Cronología", + "subtitle": "Qué pasó y cuándo, junto a tus valores.", + "standing": "Permanente", + "startUnknown": "inicio desconocido", + "zoom": { + "all": "Todo", + "year": "12 meses", + "quarter": "3 meses", + "range": "Periodo" + }, + "lanes": { + "life": "Vida", + "illness": "Enfermedades", + "allergies": "Alergias", + "medications": "Medicamentos", + "vaccinations": "Vacunas", + "visits": "Médico e intervenciones", + "labs": "Laboratorio", + "documents": "Documentos", + "cycle": "Ciclo" + }, + "readiness": { + "title": "Lo que tu cronología ya muestra", + "subtitle": "Un inventario honesto antes de abrirla.", + "menuEntry": "Cobertura de datos", + "hint": "Las pistas vacías permanecen ocultas hasta que haya algo. Los módulos desactivados nunca aparecen. Encontrarás este resumen más adelante en Cobertura de datos, dentro de la cronología.", + "later": "Más tarde", + "open": "Abrir cronología", + "verdict": { + "carries": "Ya tiene contenido.", + "thin": "Aún escasa. Por ahora la cronología muestra sobre todo tus valores." + }, + "status": { + "carries": "completa", + "thin": "escasa", + "empty": "vacía" + }, + "lanes": { + "values": "Valores", + "mood": "Estado de ánimo", + "environment": "Entorno", + "life": "Acontecimientos vitales" + }, + "meter": "{carrying} de {total} pistas tienen contenido", + "carriesDetail": "{carrying} de {total} pistas dan una visión general desde {since}.", + "loadFailed": "No se pudo cargar el inventario.", + "card": { + "title": "Lo que la cronología ya muestra", + "summary": "Llega hasta {since}; {carrying} de {total} pistas ya tienen contenido.", + "dismiss": "Ocultar" + }, + "detail": { + "valuesOne": "{count} medida desde {since}", + "valuesFew": "{count} medidas desde {since}", + "valuesOther": "{count} medidas desde {since}", + "illnessOne": "{count} episodio; crónicos: {chronic}", + "illnessFew": "{count} episodios; crónicos: {chronic}", + "illnessOther": "{count} episodios; crónicos: {chronic}", + "allergiesOne": "{count} alergia", + "allergiesFew": "{count} alergias", + "allergiesOther": "{count} alergias", + "medicationsOne": "{count} medicamento; sin fecha de inicio: {missingStart}", + "medicationsFew": "{count} medicamentos; sin fecha de inicio: {missingStart}", + "medicationsOther": "{count} medicamentos; sin fecha de inicio: {missingStart}", + "medicationsDatedOne": "{count} medicamento con fecha de inicio", + "medicationsDatedFew": "{count} medicamentos con fecha de inicio", + "medicationsDatedOther": "{count} medicamentos con fecha de inicio", + "vaccinationsOne": "{count} vacuna", + "vaccinationsFew": "{count} vacunas", + "vaccinationsOther": "{count} vacunas", + "visitsOne": "{count} visita, {procedures} con intervención", + "visitsFew": "{count} visitas, {procedures} con intervención", + "visitsOther": "{count} visitas, {procedures} con intervención", + "visitsPlannedOne": "{count} visita, solo planificada", + "visitsPlannedFew": "{count} visitas, solo planificadas", + "visitsPlannedOther": "{count} visitas, solo planificadas", + "labsOne": "{count} día con resultados", + "labsFew": "{count} días con resultados", + "labsOther": "{count} días con resultados", + "documentsOne": "{count} documento con fecha", + "documentsFew": "{count} documentos con fecha", + "documentsOther": "{count} documentos con fecha", + "lifeOne": "{count} acontecimiento vital", + "lifeFew": "{count} acontecimientos vitales", + "lifeOther": "{count} acontecimientos vitales", + "cycleOne": "{count} ciclo", + "cycleFew": "{count} ciclos", + "cycleOther": "{count} ciclos", + "moodOne": "{count} entrada desde {since}", + "moodFew": "{count} entradas desde {since}", + "moodOther": "{count} entradas desde {since}", + "environmentOne": "{count} día con datos ambientales", + "environmentFew": "{count} días con datos ambientales", + "environmentOther": "{count} días con datos ambientales", + "empty": "Aún ninguno" + }, + "gap": { + "valuesEmpty": "Conectar fuentes", + "illnessEmpty": "Añadir episodio", + "medicationsWithoutStart": "Completar", + "visitsEmpty": "Añadir visita", + "documentsUndated": "Revisar", + "labsEmpty": "Añadir resultado", + "vaccinationsEmpty": "Añadir vacunas", + "lifeEventsEmpty": "Añadir" + }, + "gapLine": { + "medicationsWithoutStartOne": "{count} medicamento sin fecha de inicio", + "medicationsWithoutStartFew": "{count} medicamentos sin fecha de inicio", + "medicationsWithoutStartOther": "{count} medicamentos sin fecha de inicio", + "documentsUndatedOne": "{count} documento sin fecha", + "documentsUndatedFew": "{count} documentos sin fecha", + "documentsUndatedOther": "{count} documentos sin fecha", + "valuesEmpty": "Aún no hay valores durante un periodo largo", + "illnessEmpty": "Aún no hay enfermedades con fecha de inicio", + "visitsEmpty": "Aún no hay visitas médicas realizadas", + "labsEmpty": "Aún no hay resultados de laboratorio", + "vaccinationsEmpty": "Aún no hay vacunas", + "lifeEventsEmpty": "Aún no hay acontecimientos vitales" + } + }, + "legendSpan": "Periodo", + "legendEvent": "Evento", + "legendMeanQuarter": "Media trimestral", + "legendMeanMonth": "Media mensual", + "legendMeanWeek": "Media semanal", + "legendThin": "Menos de 3 mediciones", + "legendGap": "Hueco corto sin mediciones", + "today": "Hoy", + "noDataYet": "Aún sin datos", + "layers": "Capas", + "layersLanes": "Pistas", + "zoomLabel": "Rango de la vista", + "chartLabel": "Cronología de {from} a {to}", + "keyboardHint": "Las flechas mueven la selección, Inicio y Fin saltan a los extremos, Intro abre el día.", + "loadFailed": "No se pudo cargar la cronología.", + "empty": { + "title": "Aún no hay nada en la cronología", + "description": "Las entradas con fecha aparecerán aquí en cuanto haya alguna." + }, + "values": { + "menuTitle": "Valores bajo los carriles", + "trigger": "Valores ({count})", + "noneInWindow": "Sin valores en este periodo", + "series": { + "BLOOD_PRESSURE_SYS": "Presión sistólica", + "BLOOD_PRESSURE_DIA": "Presión diastólica" + }, + "noValue": "sin valor", + "bloodPressure": "Presión arterial", + "readingsOne": "1 medición", + "readingsFew": "media de {count} mediciones", + "readingsOther": "media de {count} mediciones" + }, + "table": { + "caption": "Entradas de la cronología", + "lane": "Pista", + "entry": "Entrada", + "start": "Inicio", + "end": "Fin", + "seriesCaption": "Medias por periodo", + "period": "Periodo" + }, + "selection": { + "empty": "No hay entradas en este periodo", + "since": "desde {date}", + "range": "{from} a {to}", + "mean": "Ø {values}", + "week": "Semana del {date}", + "hintClick": "Haz clic en un momento para verlo aquí", + "hintTap": "Toca un momento para verlo aquí" + }, + "chronicle": { + "months": "Meses", + "years": "Años", + "groupingLabel": "Agrupación", + "empty": "Sin entradas de {from} a {to}", + "emptySingle": "Sin entradas en {period}", + "ended": "Fin: {label}", + "afterDaysOne": "después de 1 día", + "afterDaysFew": "después de {count} días", + "afterDaysOther": "después de {count} días", + "notable": { + "extremeHigh": "El valor más alto en mucho tiempo", + "extremeLow": "El valor más bajo en mucho tiempo", + "firstValue": "Primer valor de una medida", + "gap": "Un hueco largo en los valores" + }, + "notableMeta": "Abre el día para ver detalles" + }, + "standingAllergiesOne": "{count} alergia", + "standingAllergiesFew": "{count} alergias", + "standingAllergiesOther": "{count} alergias", + "legendMeanDay": "Media diaria", + "range": { + "from": "Desde", + "to": "Hasta", + "label": "Periodo elegido", + "open": "Elegir un periodo", + "clear": "Mostrar todo", + "apply": "Aplicar" + }, + "item": { + "labValuesOne": "1 valor de laboratorio", + "labValuesFew": "{count} valores de laboratorio", + "labValuesOther": "{count} valores de laboratorio" + } + }, + "lifeEvents": { + "title": "Acontecimiento vital", + "add": "Acontecimiento vital", + "privacy": "El título y la nota se guardan cifrados. No se envían a ninguna IA.", + "emptyHint": "Una mudanza, un nacimiento o un nuevo trabajo dan un ancla a los años.", + "save": "Guardar", + "cancel": "Cancelar", + "delete": "Eliminar acontecimiento vital", + "saved": "Acontecimiento vital guardado", + "deleted": "Acontecimiento vital eliminado", + "fields": { + "title": "¿Qué pasó?", + "category": "Tipo", + "when": "Cuándo", + "precision": "Precisión", + "addEnd": "Fecha de fin, si fue un periodo", + "endDate": "Hasta", + "note": "Nota", + "optionalTag": "opcional", + "notePlaceholder": "Algo que quieras saber más adelante", + "removeEnd": "Quitar fecha de fin" + }, + "category": { + "FAMILY": "Familia", + "HOME": "Vivienda", + "WORK": "Trabajo", + "LOSS": "Pérdida", + "OTHER": "Otro" + }, + "precision": { + "DAY": "Día", + "MONTH": "Mes", + "YEAR": "Año" + }, + "edit": "Editar acontecimiento vital", + "errors": { + "titleRequired": "Ponle un nombre al acontecimiento.", + "titleTooLong": "Máximo 120 caracteres.", + "categoryRequired": "Elige un tipo.", + "dateRequired": "Introduce una fecha válida.", + "endBeforeStart": "El final es anterior al inicio.", + "noteTooLong": "Máximo 2000 caracteres." + }, + "saveFailed": "No se pudo guardar.", + "deleteFailed": "No se pudo eliminar.", + "deleteConfirm": { + "title": "¿Eliminar este acontecimiento vital?", + "body": "Su título y su nota desaparecerán." + } + }, + "palette": { + "open": "Buscar…", + "openLabel": "Buscar páginas, ajustes y acciones", + "title": "Buscar", + "description": "Encuentra una página, un ajuste, un módulo o una acción.", + "placeholder": "Buscar páginas, ajustes, módulos…", + "results": "Resultados", + "noResults": "No hay resultados para «{query}».", + "moduleOn": "Activado", + "moduleOff": "Desactivado", + "hintMove": "Mover", + "hintOpen": "Abrir", + "hintClose": "Cerrar", + "groups": { + "recent": "Recientes", + "pages": "Páginas", + "insights": "Insights", + "settings": "Ajustes", + "modules": "Módulos", + "actions": "Acciones" + }, + "actions": { + "capture": "Nueva entrada", + "coach": "Preguntar al Coach", + "backup": "Crear una copia de seguridad", + "today": "Abrir hoy", + "shortcuts": "Mostrar atajos de teclado" + } + }, + "shortcuts": { + "title": "Atajos de teclado", + "description": "Teclas que funcionan en toda la aplicación, salvo mientras escribes en un campo.", + "menuEntry": "Atajos de teclado", + "then": "luego", + "search": "Buscar", + "help": "Mostrar esta lista", + "close": "Cerrar un diálogo o panel", + "previousDay": "Día anterior", + "nextDay": "Día siguiente", + "dayHint": "Mientras hay un día abierto.", + "groups": { + "navigation": "Navegación", + "actions": "Acciones", + "day": "Día" + } + }, + "aboutPage": { + "eyebrow": "Información", + "title": "Acerca de HealthLog", + "lastUpdated": "Última actualización: {date}", + "project": "Proyecto", + "credits": "Créditos" } } diff --git a/messages/fr.json b/messages/fr.json index a194c1d85..6dee0c114 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -49,7 +49,9 @@ "dismiss": "Ignorer", "moreInfo": "Plus d'informations", "durationHoursMinutes": "{hours} h {minutes} min", - "durationMinutes": "{minutes} min" + "durationMinutes": "{minutes} min", + "durationClockMinutes": "{time} min", + "durationClockHours": "{time} h" }, "apiErrors": { "measurement": { @@ -165,7 +167,7 @@ "anamnesisShiftSchedule": "Organisation du travail", "anamnesisColFact": "Élément du profil", "anamnesisColValue": "Valeur enregistrée", - "subtitle": "HealthLog — Rapport personnel de santé", + "subtitle": "HealthLog : rapport personnel de santé", "patient": "Patient", "dateOfBirth": "Date de naissance", "gender": "Sexe", @@ -175,18 +177,18 @@ "height": "Taille", "period": "Période du rapport", "createdOn": "Créé le", - "vitalsTitle": "Signes vitaux — Résumé", + "vitalsTitle": "Signes vitaux : résumé", "colParameter": "Paramètre", "colCurrent": "Actuel", "colAvgPeriod": "Moy. période", "colMin": "Min", "colMax": "Max", "colN": "n", - "bpClassificationTitle": "Tension artérielle — Classification ESH (2023)", + "bpClassificationTitle": "Tension artérielle : classification ESH (2023)", "avgBp": "Tension artérielle moyenne", "classification": "Classification", "bmiTitle": "Indice de masse corporelle (IMC)", - "bmiRow": "IMC:{bmi} kg/m² — Classification: {class} (WHO)", + "bmiRow": "IMC:{bmi} kg/m², classification: {class} (WHO)", "complianceTitle": "Observance des médicaments", "glp1Title": "Traitement GLP-1", "glp1WeightSummary": "Poids sur la période du rapport : {start} kg → {end} kg ({delta} kg)", @@ -204,7 +206,7 @@ "colExpected": "Attendues", "colComplianceRate": "Taux d'observance", "moodTitle": "Évolution de l’humeur", - "moodSummary": "Moyenne:{avg} / 5.0 · Entries: {count} · Range: {min}–{max}", + "moodSummary": "Moyenne {avg} / 5.0, {count} entrées, plage {min}–{max}", "colMood": "Humeur", "colCount": "Nombre", "colShare": "Part", @@ -234,8 +236,8 @@ "typeGlucoseRandom": "Glycémie (indifférente)", "typeGlucoseBedtime": "Glycémie (avant le coucher)", "typeGlucoseUnspecified": "Glycémie (non précisée)", - "glucoseClassificationTitle": "Glycémie — Plages de référence (ADA 2024 / DDG)", - "glucoseRow": "{label} : moy. {avg} {unit} (cible {rangeMin}–{rangeMax}) — {class}", + "glucoseClassificationTitle": "Glycémie : plages de référence (ADA 2024 / DDG)", + "glucoseRow": "{label} : moy. {avg} {unit} (cible {rangeMin}–{rangeMax}), {class}", "glucoseInTarget": "dans la cible", "glucoseBelowTarget": "sous la cible", "glucoseAboveTarget": "au-dessus de la cible", @@ -287,7 +289,7 @@ "gmi": "Indicateur de gestion du glucose (GMI)", "eA1c": "HbA1c estimée", "cv": "Variabilité (CV%)", - "spotCaveat": "Ce sont des estimations issues de mesures ponctuelles, et non un profil de capteur continu — à lire comme une tendance, pas comme un verdict clinique." + "spotCaveat": "Ce sont des estimations issues de mesures ponctuelles, et non un profil de capteur continu. À lire comme une tendance, pas comme un verdict clinique." }, "illnessTitle": "Affections et maladies", "illnessOngoing": "En cours", @@ -376,12 +378,12 @@ "backToDashboard": "Retour au tableau de bord" }, "offlineBanner": { - "message": "Pas de connexion — les modifications ne peuvent pas être enregistrées tant que tu n'es pas de nouveau en ligne.", - "saveFailed": "Enregistrement impossible — tu es hors ligne. Réessaie une fois de nouveau en ligne.", + "message": "Pas de connexion : les modifications ne peuvent pas être enregistrées tant que tu n'es pas de nouveau en ligne.", + "saveFailed": "Enregistrement impossible : tu es hors ligne. Réessaie une fois de nouveau en ligne.", "showingCached": "Affichage de tes dernières données synchronisées." }, "demoBanner": { - "message": "Mode démo — les modifications ne sont pas enregistrées." + "message": "Mode démo : les modifications ne sont pas enregistrées." }, "nav": { "dashboard": "Tableau de bord", @@ -427,7 +429,9 @@ "workout": "Entraînement", "workoutDescription": "Une séance que vos appareils n’ont pas enregistrée", "symptom": "Symptôme", - "symptomDescription": "L'un de tes propres symptômes, avec son intensité" + "symptomDescription": "L'un de tes propres symptômes, avec son intensité", + "lifeEventDescription": "Un déménagement, une naissance, un nouvel emploi", + "lifeEvent": "Événement de vie" }, "cycle": "Cycle", "labs": "Analyses", @@ -436,7 +440,8 @@ "illness": "Maladie", "documents": "Documents", "vaccinations": "Vaccinations", - "mentalWellbeing": "Bien-être mental" + "mentalWellbeing": "Bien-être mental", + "timeline": "Chronologie" }, "auth": { "login": "Se connecter", @@ -453,14 +458,13 @@ "hasAccount": "Déjà un compte ?", "loginFailed": "Échec de la connexion", "registerFailed": "Échec de l’inscription", - "invitedBanner": "Vous avez été invité — créez votre compte même si les inscriptions sont fermées.", + "invitedBanner": "Vous avez été invité. Créez votre compte même si les inscriptions sont fermées.", "alreadySignedIn": { "title": "Tu es déjà connecté", "body": "Tu es connecté en tant que {username}. Une invitation crée un nouveau compte. Déconnecte-toi pour l'accepter.", "signOut": "Se déconnecter et accepter l'invitation" }, "createAccount": "Créer un compte", - "newAccount": "Créer un nouveau compte", "backToPasskey": "Revenir à la passkey", "passkeySetupLater": "Tu peux configurer les passkeys plus tard dans les paramètres.", "passwordMinLength": "Min. 12 caractères", @@ -513,6 +517,37 @@ "fixLan": "Serveur sur un réseau domestique sans TLS : mets SESSION_COOKIE_SECURE=false dans ses paramètres et redémarre-le.", "fixHttps": "Serveur derrière un proxy HTTPS : ouvre plutôt l'adresse https:// et vérifie que le proxy transmet X-Forwarded-Proto: https.", "localhostNote": "Cette page est sur http://localhost alors que le serveur ne pose son cookie de connexion qu'en https://. La plupart des navigateurs l'acceptent sur localhost ; si tu reviens ici après la connexion, mets SESSION_COOKIE_SECURE=false." + }, + "claim": { + "title": "Reprenez votre dossier de santé", + "titleNamed": "Bienvenue, {name}", + "description": "Jusqu'ici, votre dossier de santé a été tenu pour vous. Choisissez comment vous connecter et il deviendra votre propre compte. Rien n'est copié ni déplacé.", + "loading": "Vérification de votre lien…", + "invalid": "Ce lien ne fonctionne plus", + "invalidHint": "Il est inconnu, expiré, retiré ou déjà utilisé. Demandez-en un nouveau à la personne qui vous l'a envoyé.", + "oidcOnly": "Ce serveur n'autorise que l'authentification unique, un profil ne peut donc pas encore être repris ici.", + "signedInTitle": "Déconnectez-vous d'abord", + "signedInBody": "Vous êtes connecté en tant que {username}. Ce lien crée une connexion pour quelqu'un d'autre, déconnectez-vous donc avant de l'utiliser.", + "signOut": "Se déconnecter et continuer", + "guardiansTitle": "Qui garde l'accès pour l'instant", + "guardiansHint": "Proposé par les personnes qui ont tenu votre dossier. Vous décidez juste après la connexion.", + "guardianUnnamed": "Un tuteur", + "usernameHint": "3 à 30 lettres, chiffres, _ ou -.", + "usernameReserved": "Les noms d'utilisateur commençant par managed- sont réservés.", + "emailHint": "Obligatoire, pour pouvoir revenir si vous oubliez votre mot de passe.", + "consentNote": "Vous confirmez vous-même l'avertissement et toute utilisation de l'IA. D'ici là, rien de votre dossier n'est envoyé à un fournisseur d'IA.", + "expires": "Ce lien est valable jusqu'au {date}.", + "submit": "Reprendre", + "submitting": "Reprise en cours…", + "errorTaken": "Ce nom d'utilisateur ou cette adresse e-mail est déjà utilisé.", + "errorRateLimit": "Trop de tentatives. Attendez quelques minutes puis réessayez.", + "errorFailed": "Cela n'a pas fonctionné. Réessayez dans un instant.", + "errorOffline": "Le serveur n'a pas été atteint. Vérifiez votre connexion et réessayez." + }, + "inviteInvalid": { + "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" } }, "dashboard": { @@ -544,17 +579,17 @@ "sleepShort": "Sommeil", "stepsShort": "Pas", "vo2MaxShort": "VO₂ max", - "bpInTargetWindow90": "TA-cible · 90 j", - "bpInTargetWindowDynamic": "TA-cible · {days} j", + "bpInTargetWindow90": "TA-cible (90 j)", + "bpInTargetWindowDynamic": "TA-cible ({days} j)", "bpInTargetCollecting": "collecte de données", "medications": "Médicaments", "quickAddMeasurement": "Saisir une mesure", "quickAddMood": "Saisir une humeur", "quickAddMedicationIntake": "Saisir une prise", "quickAddWorkout": "Saisir un entraînement", + "quickAddLifeEvent": "Enregistrer un événement de vie", "customizeDashboard": "Personnaliser le tableau de bord", "medicationIntakeQuickAdd": { - "sheetTitle": "Saisir une prise de médicament", "sheetDescription": "Enregistre la dose, l’heure et le médicament en quelques touches.", "medicationLabel": "Médicament", "medicationPlaceholder": "Sélectionner un médicament", @@ -667,7 +702,8 @@ "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" + "quickAddSymptom": "Noter un symptôme", + "todayCardVisible": "Afficher la carte du haut" }, "measurements": { "title": "Mesures", @@ -739,7 +775,7 @@ "formReset": "Réinitialiser le formulaire", "saveError": "Erreur lors de l’enregistrement", "duplicateTimestamp": "Une mesure de ce type existe déjà à cet horodatage.", - "serverOwnedSource": "Les valeurs provenant d’une source connectée ne peuvent pas être modifiées — l’horodatage et la note restent modifiables.", + "serverOwnedSource": "Les valeurs provenant d’une source connectée ne peuvent pas être modifiées, mais l’horodatage et la note restent modifiables.", "deleteError": "Erreur lors de la suppression", "deletedToast": "Mesure supprimée", "restoredToast": "Mesure restaurée", @@ -834,11 +870,12 @@ "durationHoursOnly": "{hours} h", "durationMinutesOnly": "{minutes} min", "durationInvalid": "Saisis une durée comme 7,5, 7:30 ou 7h 30m.", - "durationTooLong": "Une saisie ne peut pas dépasser 24 heures." + "durationTooLong": "Une saisie ne peut pas dépasser 24 heures.", + "sourceHealthConnect": "Health Connect" }, "metricCatalog": { "title": "Ce que HealthLog peut suivre", - "description": "Tous les domaines de mesures pris en charge par HealthLog, que tu aies déjà des données ou non — et ce qui les fournirait.", + "description": "Tous les domaines de mesures pris en charge par HealthLog, que tu aies déjà des données ou non, et ce qui les fournirait.", "statusPresent": "Suivi", "statusAbsent": "Pas encore suivi", "statusModuleOff": "Désactivé", @@ -846,11 +883,11 @@ "connectCta": "Connecter une source", "viewCta": "Voir", "ecg": { - "source": "Tracés ECG à une dérivation depuis une montre compatible — Withings ScanWatch aujourd'hui — synchronisés automatiquement." + "source": "Tracés ECG à une dérivation depuis une montre compatible (Withings ScanWatch aujourd'hui), synchronisés automatiquement." }, "watchdog": { "title": "Surveillance automatique", - "description": "HealthLog surveille aussi discrètement en arrière-plan — notifications de rythme irrégulier et de perturbations respiratoires envoyées par une montre connectée, alertes d'écart par rapport à ta valeur de référence, ainsi que des changements notables entre tes deux derniers bilans de laboratoire — et n'affiche une carte que si quelque chose se démarque. Si au moins une de ces sources est connectée, l'absence de carte est bon signe, pas une fonctionnalité manquante ; si aucune ne l'est encore, c'est simplement qu'il n'y a rien à surveiller." + "description": "HealthLog surveille aussi discrètement en arrière-plan (notifications de rythme irrégulier et de perturbations respiratoires envoyées par une montre connectée, alertes d'écart par rapport à ta valeur de référence, ainsi que des changements notables entre tes deux derniers bilans de laboratoire) et n'affiche une carte que si quelque chose se démarque. Si au moins une de ces sources est connectée, l'absence de carte est bon signe, pas une fonctionnalité manquante ; si aucune ne l'est encore, c'est simplement qu'il n'y a rien à surveiller." } }, "mood": { @@ -1007,7 +1044,7 @@ "archive": "Archiver", "restore": "Restaurer", "archivedTitle": "Étiquettes archivées", - "archivedDescription": "Les étiquettes archivées restent dans tes saisies passées — elles disparaissent seulement du sélecteur.", + "archivedDescription": "Les étiquettes archivées restent dans tes saisies passées. Elles disparaissent seulement du sélecteur.", "archivedEmpty": "Rien d’archivé.", "purgeTitle": "Supprimer l’étiquette définitivement ?", "purgeBody": "L’étiquette sera retirée de {count} saisies passées. Cette action est irréversible.", @@ -1032,7 +1069,7 @@ }, "sectionMoreTags": "Plus de tags", "sectionNoteDetails": "Note et détails", - "quickTagsHelp": "Vos tags fréquents — en un geste", + "quickTagsHelp": "Vos tags fréquents, en un geste", "quickTagsExpand": "Plus", "loadError": "Impossible de charger vos entrées d'humeur.", "sectionLevelA": "État d'esprit", @@ -1210,8 +1247,8 @@ "dialogDescription": "Ces médicaments sont à prendre maintenant. Confirmez pour les enregistrer tous comme pris.", "confirm": "Tout prendre ({count})", "successToast": "{count} prises enregistrées", - "partialToast": "{taken} enregistrées, {failed} échouées – veuillez réessayer", - "failedToast": "Aucune prise enregistrée – veuillez réessayer" + "partialToast": "{taken} enregistrées, {failed} échouées. Veuillez réessayer", + "failedToast": "Aucune prise enregistrée. Veuillez réessayer" }, "logIntake": { "dialogTitle": "Enregistrer une prise", @@ -1300,7 +1337,7 @@ "complianceInfoLabel": "Que signifie l'observance ?", "lastIntake": "Dernière prise :", "nextIntake": "Prochaine prise :", - "nextIntakeOverdue": "En retard · {time} — encore rattrapable", + "nextIntakeOverdue": "En retard depuis {time}, encore rattrapable", "dayStreak": "jours de série", "dayStreakOne": "jour de série", "cycleDueToday": "À prendre aujourd'hui", @@ -1309,10 +1346,10 @@ "skipped": "Sauté", "intakeToastTaken": "{name} enregistré comme pris", "intakeToastSkipped": "{name} sauté", - "intakeToastFailed": "Impossible d'enregistrer {name} — réessayez", + "intakeToastFailed": "Impossible d'enregistrer {name}. Réessayez", "intakeUndo": "Annuler", "intakeUndone": "Entrée annulée", - "intakeUndoFailed": "Annulation impossible — corrigez dans l'historique", + "intakeUndoFailed": "Annulation impossible. Corrigez dans l'historique", "glp1LastInjection": "{label}", "glp1NextInjectionDays": "{label} (dans {days} jours)", "glp1NextInjectionToday": "aujourd’hui", @@ -1394,10 +1431,10 @@ "error": { "empty": "Saisis d'abord une description.", "tooLong": "Garde la description sous 2000 caractères.", - "rateLimit": "Trop de requêtes — attends un instant et réessaie.", - "noProvider": "Aucun fournisseur d'IA configuré — configure-le dans les Paramètres.", - "budget": "Budget IA quotidien atteint — réessaie demain.", - "network": "Impossible de joindre le fournisseur d'IA — réessaie.", + "rateLimit": "Trop de requêtes. Attends un instant et réessaie.", + "noProvider": "Aucun fournisseur d'IA configuré. Configure-le dans les Paramètres.", + "budget": "Budget IA quotidien atteint. Réessaie demain.", + "network": "Impossible de joindre le fournisseur d'IA. Réessaie.", "consent": "La lecture de votre description par IA nécessite d'abord votre consentement.", "unavailable": "La lecture de descriptions par IA n'est pas disponible ici pour le moment." } @@ -1413,7 +1450,7 @@ "everyNMonths": "Tous les N mois un jour précis", "yearly": "Une fois par an", "rolling": "Tous les N jours depuis la dernière prise (flexible)", - "rollingExplainer": "Compte à partir de votre dernière prise enregistrée — se met en pause si vous sautez une dose.", + "rollingExplainer": "Compte à partir de votre dernière prise enregistrée ; se met en pause si vous sautez une dose.", "oneShot": "Dose unique" }, "weekdays": { @@ -1471,7 +1508,7 @@ "night": "Nuit" }, "max": { - "reached": "Maximum atteint — retirez une heure avant d’en ajouter une autre." + "reached": "Maximum atteint : retirez une heure avant d’en ajouter une autre." } }, "courseWindow": { @@ -1521,7 +1558,7 @@ }, "step3": { "title": "Quelle est la dose ?", - "subline": "Un comprimé, une bouffée, une goutte — selon la forme.", + "subline": "Un comprimé, une bouffée, une goutte, selon la forme.", "amountLabel": "Quantité", "amountPlaceholder": "par exemple 5", "unitLabel": "Unité", @@ -1547,7 +1584,7 @@ }, "dosesPerUnitLabel": "Doses par unité", "dosesPerUnitPlaceholder": "p. ex. 4", - "dosesPerUnitHint": "Doses par stylo / flacon — laisser vide pour ne pas suivre le stock.", + "dosesPerUnitHint": "Doses par stylo / flacon. Laisser vide pour ne pas suivre le stock.", "short": "Dose", "unitsPerDoseLabel": "Unités par dose", "unitsPerDoseHint": "p. ex. 2 comprimés de 2 mg pour une dose de 4 mg, ou ½ comprimé pour une dose fractionnée" @@ -1559,7 +1596,7 @@ }, "step5": { "title": "À quelle fréquence le prenez-vous ?", - "subline": "Choisissez la cadence — les détails suivent à l’étape suivante.", + "subline": "Choisissez la cadence. Les détails suivent à l’étape suivante.", "short": "Fréquence" }, "step6": { @@ -1581,7 +1618,7 @@ }, "step7": { "title": "Quand le prenez-vous ?", - "subline": "Une ou plusieurs heures de la journée — les rappels suivent cette liste.", + "subline": "Une ou plusieurs heures de la journée. Les rappels suivent cette liste.", "presetsLabel": "Préréglages par moment de la journée", "presets": { "morning": "Matin", @@ -1589,7 +1626,7 @@ "evening": "Soir", "night": "Nuit" }, - "overrideHint": "Facultatif — définissez une dose ou un nombre d'unités différent pour cet horaire uniquement. Laissez vide pour utiliser la valeur par défaut du médicament.", + "overrideHint": "Facultatif : définissez une dose ou un nombre d'unités différent pour cet horaire uniquement. Laissez vide pour utiliser la valeur par défaut du médicament.", "doseOverrideLabel": "Dose pour cet horaire (facultatif)", "doseOverridePlaceholder": "ex. un demi-comprimé", "unitsOverrideLabel": "Unités consommées à cet horaire", @@ -1597,11 +1634,11 @@ "short": "Heures" }, "step8": { - "title": "Terminé — HealthLog doit-il vous le rappeler ?", + "title": "Terminé ! HealthLog doit-il vous le rappeler ?", "subline": "Vérifiez tout ci-dessous avant d’enregistrer.", "remindersLabel": "Rappels activés", - "remindersDescription": "Push, Telegram ou ntfy — selon vos réglages.", - "noChannelHint": "Les rappels nécessitent un canal de notification — aucun n'est actif pour le moment.", + "remindersDescription": "Push, Telegram ou ntfy, selon vos réglages.", + "noChannelHint": "Les rappels nécessitent un canal de notification, et aucun n'est actif pour le moment.", "noChannelHintLink": "Configurer les notifications", "short": "Fini" } @@ -1650,7 +1687,7 @@ }, "asNeeded": { "label": "Au besoin", - "description": "Pas de plan fixe — enregistrez une prise quand vous en avez besoin (p. ex. antidouleur)." + "description": "Pas de plan fixe : enregistrez une prise quand vous en avez besoin (p. ex. antidouleur)." } }, "summary": { @@ -1685,7 +1722,7 @@ } }, "errors": { - "submitFailed": "Impossible d’enregistrer le médicament — veuillez réessayer." + "submitFailed": "Impossible d’enregistrer le médicament. Veuillez réessayer." } }, "doseStrength": { @@ -1701,11 +1738,11 @@ "empty": "Aucune étape d'escalade de dose enregistrée pour le moment. Ajoutez des changements de dose datés pour tracer le plan.", "youAreHere": "Vous êtes ici", "doseStep": "{dose} {unit}", - "plannedFor": "Prévu · {date}" + "plannedFor": "Prévu pour {date}" }, "injectionSiteBodyOutlineAriaLabel": "Silhouette du corps", "injectionSitePickerAriaLabel": "Sélecteur de site d'injection", - "injectionSiteRecommendedAriaLabel": "{site} — prochain site recommandé", + "injectionSiteRecommendedAriaLabel": "{site}, prochain site recommandé", "injectionSiteLegendAriaLabel": "Légende des repères", "injectionSiteLegendRecommended": "Prochain site recommandé", "injectionSiteLegendLastUsed": "Dernier site utilisé", @@ -1752,7 +1789,7 @@ "intakeHistoryStatusSkipped": "Ignorée", "intakeHistorySortByTaken": "Trier par heure de prise", "intakeHistorySortByScheduled": "Trier par heure programmée", - "intakeHistoryPageInfo": "Page {page} sur {total} · {count} entrées", + "intakeHistoryPageInfo": "Page {page} sur {total}, {count} entrées", "intakeHistoryPrev": "Précédent", "intakeHistoryNext": "Suivant", "glp1": { @@ -1793,7 +1830,7 @@ "cadence": { "oneShotOn": "Dose unique le {date}.", "oneShotPending": "Dose unique en attente.", - "asNeeded": "Au besoin — pas de plan fixe." + "asNeeded": "Au besoin, pas de plan fixe." }, "intake": { "title": "Historique des prises", @@ -1820,7 +1857,7 @@ "dialogTitle": "Modifier la prise", "takenAtLabel": "Prise à", "scheduledForHint": "Prévue : {dateTime}", - "farFromScheduledWarning": "Très éloignée de l’heure prévue — vérifiez la date.", + "farFromScheduledWarning": "Très éloignée de l’heure prévue. Vérifiez la date.", "skippedLabel": "Sautée", "save": "Enregistrer", "cancel": "Annuler", @@ -1852,7 +1889,7 @@ "markAsTaken": "Marquer comme prise", "markAsSkipped": "Marquer comme sautée", "adHocTag": "hors planning", - "takenDetail": "prévue {planned} · prise {taken}", + "takenDetail": "prévue {planned}, prise {taken}", "doseTaken": "Dose : {dose}", "status": { "taken_on_time": "à l'heure", @@ -1867,7 +1904,7 @@ "description": "Enregistrez une dose, même rétroactive ou hors planning.", "takenAtLabel": "Prise à", "skippedLabel": "Sautée", - "nudgeHint": "Proche du créneau {slot} — à l'enregistrement, nous demandons s'il faut l'y rattacher.", + "nudgeHint": "Proche du créneau {slot} : à l'enregistrement, nous demandons s'il faut l'y rattacher.", "cancel": "Annuler", "submit": "Enregistrer" }, @@ -1916,7 +1953,7 @@ }, "codes": { "label": "Codes cliniques (ATC / RxNorm)", - "note": "Facultatif. Utilisés lors de l'export FHIR du dossier de santé. Les codes sont repris tels qu'ils sont saisis — jamais devinés.", + "note": "Facultatif. Utilisés lors de l'export FHIR du dossier de santé. Les codes sont repris tels qu'ils sont saisis, jamais devinés.", "atcLabel": "ATC", "rxNormLabel": "RxNorm", "saved": "Codes cliniques enregistrés", @@ -1936,7 +1973,7 @@ "title": "Terminer le médicament", "helper": "Arrête les rappels et marque le médicament comme terminé. L’historique reste visible.", "button": "Terminer", - "dialogTitle": "Terminer {name} — arrête les rappels, l’historique reste visible", + "dialogTitle": "Terminer {name} : arrête les rappels, l’historique reste visible", "dialogBody": "Plus de rappels. Les anciennes entrées restent dans la chronologie.", "toast": "Médicament terminé", "failed": "Impossible de terminer le médicament." @@ -1974,7 +2011,7 @@ "copyFailed": "Impossible de copier.", "tokenCopied": "Jeton copié", "copyToken": "Copier le jeton", - "mintedHint": "Copiez ce jeton maintenant — il n’est affiché qu’une seule fois." + "mintedHint": "Copiez ce jeton maintenant : il n’est affiché qu’une seule fois." }, "advanced": { "dataPortability": { @@ -2003,7 +2040,6 @@ "bestand": "Stock", "verlauf": "Historique", "injektion": "Injection", - "api": "API", "erweitert": "Avancé", "wirkung": "Effet" }, @@ -2015,9 +2051,9 @@ "statusTitle": "Statut", "nextDoseLabel": "Prochaine dose", "nextDoseNone": "Aucune dose à venir.", - "nextDoseAsNeeded": "Au besoin — aucune dose planifiée.", + "nextDoseAsNeeded": "Au besoin, aucune dose planifiée.", "nextDoseNotTracked": "Prises non suivies, conservé pour mémoire.", - "pausedHint": "En pause — aucun rappel.", + "pausedHint": "En pause, aucun rappel.", "reminderLabel": "Rappel", "reminderOn": "Activé", "reminderOff": "Désactivé", @@ -2088,7 +2124,7 @@ "packagingDefaultPack": "Taille de boîte par défaut : {units} unités", "packagingNoDefaultPack": "Aucune taille de boîte par défaut", "packagingEdit": "Modifier", - "packagingDescription": "Définis combien d’unités consomme une dose et la taille habituelle d’une nouvelle boîte — p. ex. après un changement de fabricant avec une autre taille de plaquette.", + "packagingDescription": "Définis combien d’unités consomme une dose et la taille habituelle d’une nouvelle boîte, p. ex. après un changement de fabricant avec une autre taille de plaquette.", "packagingUnitsPerDoseLabel": "Unités par dose", "packagingUnitsPerDoseHelper": "P. ex. deux comprimés de 2 mg pour une dose de 4 mg, ou 1,5 pour un et demi (plus de 0, jusqu’à 100). Les prises déjà enregistrées restent inchangées.", "packagingDefaultPackLabel": "Taille de boîte par défaut (unités)", @@ -2139,11 +2175,11 @@ "hideEras": "Masquer les périodes antérieures", "addEra": "Ajouter une période antérieure", "addEraTitle": "Ajouter une période antérieure", - "addEraDescription": "Notez à quelles heures ce médicament était pris auparavant — par exemple avant de l'ajuster ici.", + "addEraDescription": "Notez à quelles heures ce médicament était pris auparavant, par exemple avant de l'ajuster ici.", "editAria": "Modifier la période", "editEraTitle": "Modifier la période", "editEraDescription": "Ajustez les heures de prise et la plage de dates de cette période.", - "archivedEditHint": "Cette période a été enregistrée automatiquement — votre modification corrige l'historique enregistré ; l'original est conservé en interne comme trace.", + "archivedEditHint": "Cette période a été enregistrée automatiquement. Votre modification corrige l'historique enregistré ; l'original est conservé en interne comme trace.", "updatedToast": "Période mise à jour", "updateError": "Impossible de mettre à jour la période. Elle ne doit pas chevaucher d'autres périodes et doit se terminer avant le plan actuel.", "timesLabel": "Heures de prise", @@ -2182,10 +2218,10 @@ } }, "logInjectionSiteTitle": "Enregistrer le site d'injection", - "logInjectionSiteDescription": "Où avez-vous injecté {name} ? Facultatif — vous pouvez l'ignorer.", + "logInjectionSiteDescription": "Où avez-vous injecté {name} ? Facultatif, vous pouvez l'ignorer.", "logInjectionSiteSkip": "Ignorer", "logInjectionSiteConfirm": "Enregistrer le site", - "logInjectionSiteNoneAvailable": "Aucun site d'injection disponible — tous les sites sont exclus dans vos réglages.", + "logInjectionSiteNoneAvailable": "Aucun site d'injection disponible : tous les sites sont exclus dans vos réglages.", "logInjectionSiteSaveFailed": "Impossible d’enregistrer le site d’injection. Veuillez réessayer.", "perSlotUnits": "{units} par dose", "trackInjectionSitesToggle": "Suivre les sites d'injection", @@ -2210,10 +2246,9 @@ "tableStockDoses": "{count} doses", "tableStockRunway": "≈ {days} jours restants", "cardLowStockRunway": "Réserve : ≈ {days} jours", - "takeActionFor": "Prendre – {name}", - "skipActionFor": "Passer – {name}", - "reorderTitle": "Modifier l'ordre", - "reorderDescription": "Définis l'ordre de tes médicaments – il s'applique à la vue cartes et à la vue tableau.", + "takeActionFor": "Prendre : {name}", + "skipActionFor": "Passer : {name}", + "reorderDescription": "Définis l'ordre de tes médicaments pour la vue cartes et la vue tableau.", "reorderSaved": "Ordre enregistré", "reorderSaveFailed": "L'ordre n'a pas pu être enregistré", "customize": "Personnaliser les médicaments", @@ -2232,7 +2267,7 @@ "levelShift": "Un changement de niveau a été détecté autour du {date}.", "plateau": { "title": "Plateau de poids à la dose actuelle", - "evidence": "Variation de poids de {delta} {unit} sur les {window} derniers jours ({readings} mesures) — {days} jours sous {dose}." + "evidence": "Variation de poids de {delta} {unit} sur les {window} derniers jours ({readings} mesures), {days} jours sous {dose}." }, "retarget": { "label": "Ce n'est pas la bonne mesure ?", @@ -2242,16 +2277,16 @@ "error": "Impossible d'enregistrer cette modification. Réessaie." }, "beforeAfter": { - "summary": "Ø {before} avant → Ø {after} depuis — un écart de {delta}.", + "summary": "Ø {before} avant → Ø {after} depuis, soit un écart de {delta}.", "counts": "D'après {beforeCount} mesures avant et {afterCount} depuis.", - "needBefore": "Pas encore assez de mesures avant le début — il faut environ {weeks} semaines avant et après pour comparer.", - "needAfter": "Pas encore assez de mesures depuis le début — il faut environ {weeks} semaines avant et après pour comparer.", + "needBefore": "Pas encore assez de mesures avant le début. Il faut environ {weeks} semaines avant et après pour comparer.", + "needAfter": "Pas encore assez de mesures depuis le début. Il faut environ {weeks} semaines avant et après pour comparer.", "needStart": "Renseignez une date de début pour comparer les mesures avant et après le début.", - "needData": "Pas encore assez de mesures — il faut environ {weeks} semaines avant et après le début pour comparer." + "needData": "Pas encore assez de mesures. Il faut environ {weeks} semaines avant et après le début pour comparer." }, "insights": { "title": "Effet des médicaments", - "subtitle": "Ce qui coïncide avec les mesures visées par vos médicaments — association seulement, jamais un verdict.", + "subtitle": "Ce qui coïncide avec les mesures visées par vos médicaments : association seulement, jamais un verdict.", "link": "Ouvrir la vue Effet", "empty": "Pas encore assez de données pour relier vos médicaments à leurs mesures cibles." }, @@ -2357,7 +2392,7 @@ }, "charts": { "loadingLabel": "Chargement du graphique…", - "loadingSlowHint": "Calcul des analyses — cela peut prendre un instant.", + "loadingSlowHint": "Calcul des analyses, cela peut prendre un instant.", "weight": "Poids", "bloodPressure": "Tension artérielle", "pulse": "Pouls", @@ -2379,6 +2414,7 @@ "days7Label": "7 j", "days7Title": "7 derniers jours", "days30Label": "30 j", + "daysNLabel": "{days} j", "days30Title": "30 derniers jours", "days90Label": "90 j", "days90Title": "90 derniers jours", @@ -2474,7 +2510,8 @@ "moodHeatmapEmpty": "Carte thermique de l'humeur, aucun jour enregistré pour l'instant.", "complianceHeatmap": "Carte thermique d'observance des médicaments du {start} au {end}, {days} jours actifs.", "complianceHeatmapEmpty": "Carte thermique d'observance des médicaments, aucun jour actif pour l'instant." - } + }, + "medicationsEmptyAction": "Ajouter un médicament" }, "chart": { "overlay": { @@ -2505,8 +2542,8 @@ }, "tooltipPrior": "Période précédente", "unavailable": { - "lastMonth": "Comparaison indisponible — pas de données du mois précédent", - "lastYear": "Comparaison indisponible — pas de données de l’année précédente" + "lastMonth": "Comparaison indisponible : pas de données du mois précédent", + "lastYear": "Comparaison indisponible : pas de données de l’année précédente" }, "notAvailableForCompliance": "Comparaison indisponible pour l’observance" }, @@ -2518,7 +2555,9 @@ "dismissError": "Impossible d’ignorer ce schéma.", "restoreError": "Impossible de restaurer ce schéma.", "loadError": "Impossible de charger les schémas.", - "empty": "Aucun schéma actuel n’a franchi le seuil de preuve." + "empty": "Aucun schéma actuel n’a franchi le seuil de preuve.", + "seasonalNote": "Depuis la version 1.42, HealthLog retire la saison et la tendance de fond avant de comparer les valeurs. Vous verrez moins de liens, mais ceux qui restent sont plus solides. Beaucoup des schémas d’avant n’étaient que le calendrier.", + "seasonalNoteDismiss": "Fermer la remarque" }, "correlation": { "direction": { @@ -2550,29 +2589,37 @@ "ENV_DAYLIGHT": "la lumière du jour", "ENV_PRECIP": "les précipitations", "ENV_PRESSURE_MEAN": "la pression atmosphérique", - "ENV_PRESSURE_DELTA": "la variation de pression sur la journée" + "ENV_PRESSURE_DELTA": "la variation de pression sur la journée", + "ENV_PM25": "les particules fines (PM2,5)", + "ENV_OZONE_8H": "l’ozone", + "ENV_POLLEN_MAX": "le pollen" }, "daily": { - "high": "Quand {behaviour} monte, {outcome} du lendemain tend à {direction} dans tes données — une tendance à surveiller, pas une cause.", - "moderate": "Quand {behaviour} monte, {outcome} du lendemain semble avoir tendance à {direction}, au vu des données actuelles — une tendance à surveiller, pas une cause.", - "faint": "Quand {behaviour} monte, tout au plus se dessine un faible indice que {outcome} du lendemain tende à {direction} dans tes données — trop ténu pour s'y fier, jamais une cause." + "high": "Quand {behaviour} monte, {outcome} du lendemain tend à {direction} dans tes données. C'est une tendance à surveiller, pas une cause.", + "moderate": "Quand {behaviour} monte, {outcome} du lendemain semble avoir tendance à {direction}, au vu des données actuelles. C'est une tendance à surveiller, pas une cause.", + "faint": "Quand {behaviour} monte, tout au plus se dessine un faible indice que {outcome} du lendemain tende à {direction} dans tes données. C'est trop ténu pour s'y fier et jamais une cause." }, "lab": { - "default": "Des valeurs de {lab} plus élevées vont de pair avec {outcome} qui tend à {direction} sur les mêmes périodes dans tes données — une association à surveiller avec ton médecin, jamais une cause.", - "faint": "Des valeurs de {lab} plus élevées laissent tout au plus entrevoir que {outcome} tende à {direction} sur les mêmes périodes dans tes données — trop ténu pour s'y fier, une association à évoquer avec ton médecin, jamais une cause." + "default": "Des valeurs de {lab} plus élevées vont de pair avec {outcome} qui tend à {direction} sur les mêmes périodes dans tes données, une association à surveiller avec ton médecin, jamais une cause.", + "faint": "Des valeurs de {lab} plus élevées laissent tout au plus entrevoir que {outcome} tende à {direction} sur les mêmes périodes dans tes données. C'est trop ténu pour s'y fier et jamais une cause, mais à évoquer avec ton médecin." }, "hypothesis": { "bpCompliance": { - "support": "Une meilleure observance des médicaments va de pair avec des valeurs systoliques plus basses — une tendance à surveiller.", - "surprising": "L'observance et la systolique évoluent ensemble dans tes données — surprenant ; parles-en à ton médecin avant de changer quoi que ce soit.", + "support": "Une meilleure observance des médicaments va de pair avec des valeurs systoliques plus basses, une tendance à surveiller.", + "surprising": "L'observance et la systolique évoluent ensemble dans tes données, ce qui est surprenant. Parles-en à ton médecin avant de changer quoi que ce soit.", "none": "L'observance et la systolique n'évoluent pas nettement ensemble sur cette période." }, "moodPulse": { - "support": "Les jours de moral plus bas vont de pair avec un pouls au repos plus élevé — une tendance à surveiller.", - "surprising": "Les jours de meilleur moral vont de pair avec un pouls au repos plus élevé dans tes données — surprenant.", + "support": "Les jours de moral plus bas vont de pair avec un pouls au repos plus élevé, une tendance à surveiller.", + "surprising": "Les jours de meilleur moral vont de pair avec un pouls au repos plus élevé dans tes données, ce qui est surprenant.", "none": "L'humeur et le pouls au repos n'évoluent pas nettement ensemble sur cette période." }, - "weightWeekday": "Le poids du {weekday} est {delta} kg {direction} ta moyenne des autres jours — une tendance à surveiller." + "weightWeekday": "Le poids du {weekday} est {delta} kg {direction} ta moyenne des autres jours, une tendance à surveiller." + }, + "dailySameDay": { + "high": "Plus de {behaviour} le jour même et la veille va souvent de pair avec {direction} {outcome} dans tes données : un schéma à surveiller, pas une cause.", + "moderate": "Plus de {behaviour} le jour même et la veille semble aller de pair avec {direction} {outcome} dans tes données, d’après ce qu’on voit jusqu’ici : un schéma à surveiller, pas une cause.", + "faint": "Plus de {behaviour} le jour même et la veille ne montre, au mieux, qu’un faible indice de {direction} {outcome} dans tes données : trop peu pour s’y fier, jamais une cause." } }, "noAnalysisYet": "Pas encore d’évaluation.", @@ -2619,7 +2666,7 @@ "narrativeWeek": "Cette semaine", "narrativeMonth": "Ce mois-ci", "narrativeUpdating": "Mise à jour…", - "heroFallbackSubtitle": "Un coup d’œil quotidien sur tes tendances — directement issu des chiffres que tu saisis.", + "heroFallbackSubtitle": "Un coup d’œil quotidien sur tes tendances, directement issu des chiffres que tu saisis.", "heroGreetingMorning": "Bonjour", "heroGreetingAfternoon": "Bonjour", "heroGreetingEvening": "Bonsoir", @@ -2684,7 +2731,7 @@ "referenceSleep": "Au moins {low} minutes par nuit ; un sommeil plus long n'est pas pénalisé", "referenceAdiposity": "Rapport taille-hauteur inférieur à {high}", "referenceHba1c": "HbA1c de {low} à {high} %", - "referenceDetail": "{label} · {source} · {kind} · {low} à {high}", + "referenceDetail": "{label} : de {low} à {high} ({kind}, {source})", "openBound": "ouvert", "laboratoryReferenceSource": "Plages de référence du laboratoire", "personalTargetSource": "Objectif personnel", @@ -2719,7 +2766,8 @@ "coverage": { "recommendedMet": "Couvre les trois domaines recommandés par le score", "areas": "{present} domaines de santé sur {required}" - } + }, + "historyLink": "Évolution" }, "dailyBriefing": { "title": "Briefing du jour", @@ -2729,7 +2777,7 @@ "emptyDescription": "Génère le briefing du jour pour obtenir un récap de tes tendances.", "emptyAction": "Générer le briefing", "loadingLabel": "Préparation du briefing", - "refreshFailedHint": "Impossible d'actualiser le briefing — affichage du précédent.", + "refreshFailedHint": "Impossible d'actualiser le briefing. Affichage du précédent.", "retryAction": "Réessayer", "failedTitle": "Impossible de générer le briefing", "failedDescription": "Le fournisseur d'IA a dépassé le délai ou renvoyé une erreur. Réessayez ou augmentez le délai de réponse dans les paramètres d'IA pour un modèle local lent.", @@ -2778,7 +2826,7 @@ "experimentCta": "Lancer une expérience de 7 jours", "experimentTooltip": "Bientôt disponible", "emptyTitle": "Il faut plus de données pour afficher cette corrélation", - "sourceChip": "sur la base de {n} mesures appariées · {window}", + "sourceChip": "sur la base de {n} mesures appariées ({window})", "sourceWindowLast30": "30 derniers jours", "confidenceHigh": "Confiance élevée", "confidenceModerate": "Confiance moyenne", @@ -2793,14 +2841,14 @@ } }, "coach": { - "refusalOutOfScope": "Je ne peux aider que pour les données de santé de ton journal — mesures, médicaments, humeur, tendances. Les questions hors de ce périmètre ne sont pas traitées.", - "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.", + "refusalOutOfScope": "Je ne peux aider que pour les données de santé de ton journal : mesures, médicaments, humeur, tendances. Les questions hors de ce périmètre ne sont pas traitées.", + "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 (UTC).", "errorProvider": "Le coach n’a pas pu joindre un fournisseur d’analyse maintenant. Réessaie dans un instant.", "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", - "errorNetwork": "Pas de connexion Internet — réessaie une fois reconnecté.", + "errorNetwork": "Pas de connexion Internet. Réessaie une fois reconnecté.", "prefsSaveError": "Impossible d’enregistrer vos préférences du Coach. Réessayez.", "errorCredentialExpired": "Ta connexion au fournisseur d'IA a expiré. Reconnecte-la dans les Réglages pour que le Coach continue de fonctionner.", "tagline": "Coach personnel de santé, ancré sur tes données.", @@ -2819,7 +2867,7 @@ "thinking": "Réflexion en cours…", "unverifiedFiguresNotice": "Certains chiffres n'ont pas pu être vérifiés avec tes données et ont été omis.", "composerPlaceholder": "Pose ta question …", - "nudgeBubbleLabel": "Nouveau message du coach — ouvrir le chat", + "nudgeBubbleLabel": "Nouveau message du coach : ouvrir le chat", "fabLabel": "Ouvrir le coach", "readAloud": "Lire à voix haute", "readAloudStop": "Arrêter la lecture", @@ -2843,7 +2891,7 @@ "guided": { "offerTitle": "Le Coach a {count} questions pour vous", "offerTitleOne": "Le Coach a une question pour vous", - "offerBody": "De courtes réponses affinent vos informations personnelles — une question à la fois, ici dans le chat.", + "offerBody": "De courtes réponses affinent vos informations personnelles : une question à la fois, ici dans le chat.", "offerStart": "Répondre maintenant", "offerLater": "Plus tard", "offerDismiss": "Ne plus demander", @@ -2853,7 +2901,7 @@ "later": "Plus tard", "dismissRemaining": "Ne plus demander", "answerPlaceholder": "Votre réponse…", - "summaryTitle": "C’est fait — merci pour vos réponses !", + "summaryTitle": "C’est fait, merci pour vos réponses !", "summaryAnswered": "{answered} sur {total} répondues", "summaryAdopted": "{adopted} ajoutées à vos informations personnelles", "summaryAdoptedNone": "rien de nouveau ajouté à vos informations personnelles", @@ -2865,6 +2913,7 @@ "historyEmpty": "Tes conversations s’affichent ici. Lance une question pour commencer.", "historyDeleteAria": "Supprimer la conversation", "historyDeleted": "Conversation supprimée", + "historyDeleteFailed": "La conversation n’a pas pu être supprimée.", "rename": { "action": "Renommer la conversation", "input": "Titre de la conversation", @@ -2934,7 +2983,7 @@ "settingsVerbosityBrief": "Bref", "settingsVerbosityDefault": "Standard", "settingsVerbosityDetailed": "Détaillé", - "settingsVerbosityConciseHint": "Le ton concis garde déjà les réponses courtes — la longueur reste fixée sur « Bref » tant qu'il est actif.", + "settingsVerbosityConciseHint": "Le ton concis garde déjà les réponses courtes : la longueur reste fixée sur « Bref » tant qu'il est actif.", "settingsSourcesSummary": "Le coach voit {count} domaines de données sur {total} et remonte sur {window}.", "settingsSourcesSummaryAll": "Le coach voit {count} domaines de données sur {total} et tout l’historique.", "settingsSourcesPointerLink": "Ouvrir les paramètres du coach", @@ -2956,23 +3005,23 @@ "cluster": { "cardio": { "label": "Cardiovasculaire", - "hint": "TA · pouls · VFC · FC repos & marche" + "hint": "TA, pouls, VFC, FC au repos et à la marche" }, "body": { "label": "Composition corporelle", - "hint": "Poids · masse grasse · muscle · IMC" + "hint": "Poids, masse grasse, muscle et IMC" }, "activity": { "label": "Activité", - "hint": "Pas · énergie · distance · VO₂ max" + "hint": "Pas, énergie, distance et VO₂ max" }, "workouts": { "label": "Entraînements", - "hint": "Courses · sorties · séances" + "hint": "Courses, sorties et séances" }, "sleep": { "label": "Sommeil", - "hint": "Durée · phases" + "hint": "Durée et phases" }, "mood": { "label": "Humeur", @@ -2984,15 +3033,15 @@ }, "medication": { "label": "Médication", - "hint": "Observance · contexte GLP-1" + "hint": "Observance et contexte GLP-1" }, "mobility": { "label": "Mobilité & démarche", - "hint": "Stabilité · asymétrie · démarche" + "hint": "Stabilité, asymétrie et démarche" }, "environment": { "label": "Environnement", - "hint": "Bruit · lumière · temp. cutanée" + "hint": "Bruit, lumière du jour et température cutanée" } }, "settingsClustersLabel": "Données vues par le Coach", @@ -3041,25 +3090,11 @@ "steadyLatestWithin": "Tes valeurs sont habituellement à {low} {unit} ; ta dernière mesure, du {date}, était de {value}, comme d'habitude.", "steadyLatestAbove": "Tes valeurs sont habituellement à {low} {unit} ; ta dernière mesure, du {date}, était de {value}, au-dessus.", "steadyLatestBelow": "Tes valeurs sont habituellement à {low} {unit} ; ta dernière mesure, du {date}, était de {value}, en dessous.", - "insufficient": "J'apprends encore ta plage habituelle — continue à enregistrer quelques jours de plus.", + "insufficient": "J'apprends encore ta plage habituelle. Continue à enregistrer quelques jours de plus.", "driver": "{note}" }, "scope": { - "prefix": "Le Coach est déjà sur {metric}", - "tap": "Touche pour demander", - "question": "Explique-moi cette mesure — y a-t-il quelque chose à surveiller ?" - }, - "seeded": { - "prefix": "À regarder aujourd'hui : {signal}", - "signal": { - "readiness": "disponibilité", - "recovery": "récupération" - }, - "question": { - "readiness": "Ma disponibilité est inhabituelle aujourd'hui — qu'est-ce qui l'explique ?", - "recovery": "Ma récupération a changé — qu'est-ce qui se cache derrière ?" - }, - "dismiss": "Ignorer" + "prefix": "Le Coach est déjà sur {metric}" }, "settings": "Paramètres", "history": { @@ -3069,31 +3104,31 @@ "groupEarlier": "Plus tôt" }, "seed": { - "bloodPressure": "Passe en revue avec moi la tendance de ma tension artérielle sur les 30 derniers jours — y a-t-il quelque chose à surveiller ?", + "bloodPressure": "Passe en revue avec moi la tendance de ma tension artérielle sur les 30 derniers jours. Y a-t-il quelque chose à surveiller ?", "weight": "Comment mon poids a-t-il évolué ces derniers temps, et la tendance mérite-t-elle d'agir ?", "bmi": "Que m'indique la tendance de mon IMC, et comment dois-je l'interpréter ?", - "pulse": "Passe en revue avec moi mes mesures de pouls — y a-t-il quelque chose d'inhabituel ?", + "pulse": "Passe en revue avec moi mes mesures de pouls. Y a-t-il quelque chose d'inhabituel ?", "restingHr": "Comment ma fréquence cardiaque au repos a-t-elle évolué, et que dit-elle de ma condition physique ?", "hrv": "Que m'indique ma variabilité de la fréquence cardiaque sur ma récupération et mon stress ces derniers temps ?", - "sleep": "Passe en revue avec moi mon sommeil récent — durée, régularité et tout ce qui mériterait d'être changé.", + "sleep": "Passe en revue avec moi mon sommeil récent : durée, régularité et tout ce qui mériterait d'être changé.", "mood": "Quels schémas observes-tu dans mon humeur ces dernières semaines ?", "medications": "Où en est mon observance médicamenteuse, et correspond-elle à la façon dont je me sens ?", "steps": "À quel point ai-je été actif ces derniers temps d'après mes pas, et comment cela se compare-t-il à un niveau de référence sain ?", "activeEnergy": "Que dit ma tendance de dépense énergétique active sur mon niveau d'activité ces derniers temps ?", "cardioFitness": "Que signifie la tendance de ma condition cardio (VO₂ max), et comment la faire évoluer dans le bon sens ?", - "bloodGlucose": "Passe en revue avec moi mes dernières mesures de glycémie — sont-elles dans une fourchette saine ?", + "bloodGlucose": "Passe en revue avec moi mes dernières mesures de glycémie. Sont-elles dans une fourchette saine ?", "oxygenSaturation": "Que m'indique ma tendance de saturation en oxygène (SpO₂) ?", "respiratoryRate": "Que dit ma tendance de fréquence respiratoire sur ma santé ?", - "workouts": "Passe en revue avec moi mes séances d'entraînement récentes — charge, fréquence et comment je récupère entre elles.", + "workouts": "Passe en revue avec moi mes séances d'entraînement récentes : charge, fréquence et comment je récupère entre elles.", "recoveryPage": "Pourquoi ma récupération en est-elle là en ce moment, et qu'est-ce qui l'explique ?", - "briefing": "Passe en revue avec moi le briefing d'aujourd'hui — sur quoi devrais-je me concentrer, et y a-t-il quelque chose à entreprendre ?", + "briefing": "Passe en revue avec moi le briefing d'aujourd'hui. Sur quoi devrais-je me concentrer, et y a-t-il quelque chose à entreprendre ?", "healthScore": "Pourquoi mon score de santé est-il de {score} sur 100, et qu'est-ce qui le ferait le plus progresser ?", - "periodWeek": "Passe en revue avec moi comment s'est déroulée ma dernière semaine sur mes indicateurs de santé — qu'est-ce qui ressort ?", - "periodMonth": "Passe en revue avec moi comment s'est déroulé mon dernier mois sur mes indicateurs de santé — qu'est-ce qui ressort ?", + "periodWeek": "Passe en revue avec moi comment s'est déroulée ma dernière semaine sur mes indicateurs de santé. Qu'est-ce qui ressort ?", + "periodMonth": "Passe en revue avec moi comment s'est déroulé mon dernier mois sur mes indicateurs de santé. Qu'est-ce qui ressort ?", "correlationBpCompliance": "Quel est le lien entre mon observance médicamenteuse et ma tension artérielle ? Explique-moi ce que cette corrélation signifie pour moi.", "correlationMoodPulse": "Y a-t-il un lien réel entre mon humeur et mon pouls ? Aide-moi à interpréter cette corrélation.", "correlationWeightWeekday": "Mon poids suit-il un schéma hebdomadaire ? Que m'indique cette corrélation selon le jour de la semaine ?", - "recommendation": "Parle-moi davantage de cette recommandation : \"{text}\" — pourquoi s'applique-t-elle à moi, et que devrais-je faire ?" + "recommendation": "Parle-moi davantage de cette recommandation : \"{text}\". Pourquoi s'applique-t-elle à moi, et que devrais-je faire ?" }, "assessmentPrompt": "Explique-moi mon évaluation de {metric} : qu'est-ce que cela signifie et que dois-je faire ?", "attach": { @@ -3180,9 +3215,9 @@ "cap": "Beaucoup d'étapes, conclut maintenant", "noProgress": "Plus de nouvelles données, conclut" }, - "thoughtProcessOne": "Raisonnement · {count} étape", - "thoughtProcessFew": "Raisonnement · {count} étapes", - "thoughtProcessOther": "Raisonnement · {count} étapes", + "thoughtProcessOne": "Raisonnement ({count} étape)", + "thoughtProcessFew": "Raisonnement ({count} étapes)", + "thoughtProcessOther": "Raisonnement ({count} étapes)", "awaitingAnswer": "Réponse attendue" }, "memory": { @@ -3222,7 +3257,8 @@ "capped": "(limité par l'administrateur)", "disabled": "L'administrateur a désactivé la réflexion approfondie." }, - "memoryLink": "Mémoire ({count})" + "memoryLink": "Mémoire ({count})", + "newConversation": "Nouvelle conversation" }, "relativeJustNow": "à l’instant", "relativeMinutesAgoOne": "il y a {count} minute", @@ -3285,7 +3321,7 @@ }, "hrv": { "title": "VFC", - "description": "Variabilité de la fréquence cardiaque dans le temps — signal de récupération du système nerveux, capté la nuit par l'Apple Watch et d'autres wearables compatibles.", + "description": "Variabilité de la fréquence cardiaque dans le temps : signal de récupération du système nerveux, capté la nuit par l'Apple Watch et d'autres wearables compatibles.", "chartTitle": "Variabilité de la fréquence cardiaque", "emptyState": { "title": "Aucune mesure de VFC", @@ -3294,17 +3330,17 @@ }, "restingHr": { "title": "FC au repos", - "description": "La fréquence cardiaque de base sur la journée — distincte des pointages ponctuels de la page Pulse. Apple Santé fournit une valeur par jour.", + "description": "La fréquence cardiaque de base sur la journée, distincte des pointages ponctuels de la page Pulse. Apple Santé fournit une valeur par jour.", "chartTitle": "Fréquence cardiaque au repos", "emptyState": { "title": "Aucune FC au repos", "description": "La FC au repos arrive dès que l'Apple Watch ou un appareil Withings compatible a porté une journée calme." }, - "coachPrefill": "Je n'ai pas encore enregistré de fréquence cardiaque au repos — pourquoi la valeur quotidienne au repos diffère-t-elle d'un pouls ponctuel, et comment l'améliorer ?" + "coachPrefill": "Je n'ai pas encore enregistré de fréquence cardiaque au repos. Pourquoi la valeur quotidienne au repos diffère-t-elle d'un pouls ponctuel, et comment l'améliorer ?" }, "oxygenSaturation": { "title": "Saturation en oxygène", - "description": "Oxygène sanguin (SpO₂) — plage clinique normale 95–100 %, avec zones cibles ombrées sur le graphique.", + "description": "Oxygène sanguin (SpO₂) : plage clinique normale 95–100 %, avec zones cibles ombrées sur le graphique.", "chartTitle": "Saturation en oxygène", "emptyState": { "title": "Aucune mesure SpO₂", @@ -3317,13 +3353,13 @@ "chartTitle": "Température corporelle", "emptyState": { "title": "Aucune température corporelle", - "description": "Enregistre une mesure ou laisse ton wearable en synchroniser une — la tendance apparaîtra ici.", + "description": "Enregistre une mesure ou laisse ton wearable en synchroniser une, et la tendance apparaîtra ici.", "cta": "Enregistrer la température" } }, "steps": { "title": "Pas", - "description": "Nombre de pas quotidien — une ligne par jour, depuis Apple Santé ou un appareil d'activité connecté.", + "description": "Nombre de pas quotidien : une ligne par jour, depuis Apple Santé ou un appareil d'activité connecté.", "chartTitle": "Pas par jour", "emptyState": { "title": "Aucune donnée de pas", @@ -3332,7 +3368,7 @@ }, "activeEnergy": { "title": "Énergie active", - "description": "Total quotidien d'énergie active en kilocalories — une ligne par jour, depuis Apple Santé ou Withings activité.", + "description": "Total quotidien d'énergie active en kilocalories : une ligne par jour, depuis Apple Santé ou Withings activité.", "chartTitle": "Énergie active", "emptyState": { "title": "Aucune donnée d'énergie active", @@ -3356,7 +3392,7 @@ "weightBody": "Le poids corporel est l'un des signaux les plus simples de l'équilibre énergétique à long terme et de la santé globale. La tendance sur plusieurs semaines compte bien plus qu'une mesure isolée, qui varie avec l'hydratation, l'alimentation et l'heure de la journée.", "bmiBody": "L'IMC correspond à votre poids divisé par votre taille au carré, un seul chiffre qui vous situe dans une large catégorie de poids. C'est un dépistage de population, pas un diagnostic, et il ne distingue pas le muscle de la graisse.", "sleepBody": "La durée du sommeil est le temps réellement dormi au cours de la nuit, réparti entre sommeil léger, profond et paradoxal. La plupart des adultes ont besoin d'environ sept à neuf heures ; une quantité et une qualité régulières soutiennent la récupération, l'humeur et le métabolisme.", - "moodBody": "Le suivi de l'humeur consigne ce que vous ressentez au fil du temps sur une échelle simple. Le noter régulièrement révèle des tendances — comment le sommeil, l'activité ou les médicaments s'alignent sur votre ressenti — qu'une seule journée ne montre jamais.", + "moodBody": "Le suivi de l'humeur consigne ce que vous ressentez au fil du temps sur une échelle simple. Le noter régulièrement révèle des tendances (comment le sommeil, l'activité ou les médicaments s'alignent sur votre ressenti) qu'une seule journée ne montre jamais.", "medicationsBody": "L'observance mesure la fiabilité avec laquelle vous prenez votre traitement comme prescrit, ici sous forme de la part des doses attendues que vous avez enregistrées. Une prise régulière est souvent ce qui permet à un traitement d'agir comme prévu.", "restingHrBody": "La fréquence cardiaque au repos est votre rythme cardiaque lorsque vous êtes totalement au repos, généralement mesuré la nuit. Une fréquence plus basse indique souvent une meilleure forme cardiovasculaire, et une tendance à la hausse peut être un signe précoce de stress ou de maladie.", "hrvBody": "La VFC est la petite variation du temps entre des battements consécutifs. Une variabilité plus élevée reflète généralement un système nerveux bien récupéré et adaptable, tandis qu'une baisse durable peut signaler de la fatigue, du stress ou une récupération insuffisante.", @@ -3368,7 +3404,7 @@ "bodyWaterBody": "L'eau corporelle totale est la part de votre poids constituée de liquide, à l'intérieur et autour des cellules. Elle varie avec l'hydratation, l'alimentation et l'activité, si bien que la tendance sur plusieurs semaines en dit plus qu'une seule mesure.", "boneMassBody": "La masse osseuse est le poids estimé des minéraux de votre squelette. Les estimations sur balance sont approximatives, mais une tendance stable ou en hausse soutient la solidité osseuse à long terme.", "fatFreeMassBody": "La masse non grasse est tout ce qui, dans votre corps, n'est pas de la graisse : muscle, os, organes et eau. La maintenir ou la développer tout en perdant du poids signale en général une composition corporelle plus saine.", - "fatMassBody": "La masse grasse est le poids total de la graisse de votre corps. Une part de graisse est indispensable aux hormones et au stockage d'énergie, mais un excès — surtout au niveau de l'abdomen — augmente le risque pour la santé à long terme (WHO 2024).", + "fatMassBody": "La masse grasse est le poids total de la graisse de votre corps. Une part de graisse est indispensable aux hormones et au stockage d'énergie, mais un excès, surtout au niveau de l'abdomen, augmente le risque pour la santé à long terme (WHO 2024).", "bodyFatBody": "Le taux de masse grasse est la part de votre poids constituée de graisse. Les plages saines dépendent du sexe et de l'âge, et les balances l'estiment par bio-impédance : la tendance sur plusieurs semaines en dit plus qu'une mesure isolée.", "muscleMassBody": "La masse musculaire est le poids estimé des muscles de votre corps. Plus de muscle soutient la force, le métabolisme et la mobilité, et demande généralement un effort actif pour être préservé avec l'âge.", "visceralFatBody": "La graisse viscérale est la graisse stockée au plus profond de l'abdomen, autour des organes. Contrairement à la graisse sous la peau, un niveau élevé est étroitement lié aux maladies cardiaques et au diabète de type 2 : plus elle est basse, mieux c'est.", @@ -3389,7 +3425,7 @@ "daylightBody": "Le temps à la lumière du jour correspond à la durée passée en lumière extérieure chaque jour. Une lumière naturelle régulière soutient un rythme veille-sommeil et une humeur sains (CDC 2024), et le temps passé dehors est lié à la santé oculaire.", "bloodGlucoseBody": "La glycémie est la quantité de sucre dans votre sang, principale source d'énergie de l'organisme. Elle augmente après les repas et se stabilise entre eux ; des valeurs durablement élevées peuvent signaler un diabète ou un prédiabète.", "skinTemperatureBody": "La température cutanée correspond ici à la petite variation nocturne de la température de votre poignet par rapport à votre propre référence, et non à une mesure absolue de fièvre. Les variations peuvent suivre le sommeil, l'environnement, une maladie ou la phase du cycle menstruel.", - "cardioFitnessBody": "Le VO₂ max correspond à la quantité d'oxygène que votre corps peut utiliser à l'effort maximal — le meilleur indice de laboratoire de la forme aérobie, que les wearables estiment de façon indirecte. Une tendance plus élevée ou en hausse est un fort signe de santé cardiovasculaire.", + "cardioFitnessBody": "Le VO₂ max correspond à la quantité d'oxygène que votre corps peut utiliser à l'effort maximal. C'est le meilleur indice de laboratoire de la forme aérobie, que les wearables estiment de façon indirecte. Une tendance plus élevée ou en hausse est un fort signe de santé cardiovasculaire.", "cardioRecoveryBody": "La récupération cardiaque correspond au nombre de battements par minute dont votre fréquence cardiaque diminue dans la première minute après l'arrêt d'un effort intense. Une baisse plus forte signifie que votre cœur se calme plus vite, signe d'une bonne condition physique.", "wristTemperatureBody": "La température du poignet est mesurée pendant votre sommeil et indiquée comme l'écart par rapport à votre valeur de référence, et non comme une valeur absolue. Une valeur stable est normale, tandis qu'une hausse durable peut signaler une maladie, un entraînement intense ou un changement de cycle.", "fallsBody": "Ceci compte les chutes lourdes détectées par votre wearable. L'objectif est zéro ; un nombre en hausse est un signal de risque de mobilité à évoquer avec un professionnel, surtout pour les personnes âgées (CDC STEADI 2024).", @@ -3397,13 +3433,13 @@ "stairAscentSpeedBody": "C'est la vitesse à laquelle vous montez une volée de marches. C'est un marqueur sensible de la force des jambes et de la mobilité : un rythme stable ou en hausse est bon signe, un rythme en baisse mérite attention.", "stairDescentSpeedBody": "C'est la vitesse à laquelle vous descendez une volée de marches. Avec la vitesse de montée, elle reflète le contrôle des jambes et l'équilibre : un rythme stable est bon signe, un rythme en baisse mérite attention.", "breathingDisturbancesBody": "Un wearable peut estimer à quelle fréquence votre respiration a été interrompue pendant le sommeil. Un indice plus bas est préférable ; un niveau signalé comme élevé, surtout sur plusieurs nuits, mérite d'être évoqué avec un professionnel. C'est une approximation de dépistage, pas un diagnostic d'apnée du sommeil.", - "stepsBody": "Le nombre de pas correspond au total de pas que vous faites chaque jour — la mesure la plus simple de l'activité quotidienne. Une habitude régulière compte plus qu'une seule grosse journée, et l'objectif rond de 10 000 pas est un slogan publicitaire, pas un résultat de recherche.", + "stepsBody": "Le nombre de pas correspond au total de pas que vous faites chaque jour : la mesure la plus simple de l'activité quotidienne. Une habitude régulière compte plus qu'une seule grosse journée, et l'objectif rond de 10 000 pas est un slogan publicitaire, pas un résultat de recherche.", "readinessBody": "La disponibilité est une lecture quotidienne de la disposition de votre corps à affronter la journée, mêlant fréquence cardiaque au repos, variabilité de la fréquence cardiaque et sommeil. C'est une approximation de l'appareil rapportée à votre propre référence, pas une mesure clinique : un score élevé signifie récupéré, un score plus bas invite à lever le pied.", - "sleepScoreBody": "Votre score de sommeil résume toute une nuit en un seul chiffre — durée, efficacité, régularité des horaires et équilibre entre sommeil léger, profond et paradoxal. Observez la tendance sur plusieurs semaines plutôt que de courir après un score parfait sur une seule nuit.", + "sleepScoreBody": "Votre score de sommeil résume toute une nuit en un seul chiffre : durée, efficacité, régularité des horaires et équilibre entre sommeil léger, profond et paradoxal. Observez la tendance sur plusieurs semaines plutôt que de courir après un score parfait sur une seule nuit.", "recoveryBody": "La récupération reflète la qualité du rétablissement de votre corps, à partir de signaux nocturnes comme la variabilité de la fréquence cardiaque et la fréquence au repos. Un score élevé est un feu vert pour pousser ; un score plus bas suggère que votre organisme se reconstruit encore.", "strainBody": "La charge mesure l'effort cardiovasculaire et physique que vous avez imposé à votre corps, des entraînements à une journée active debout. Dès qu'il y a assez d'historique, elle est évaluée par rapport à votre propre effort habituel, pas à un standard extérieur.", "stressBody": "Le stress est ici une estimation de la charge physiologique sur votre système nerveux, lue surtout à partir des schémas de variabilité de la fréquence cardiaque et non de ce que vous ressentez consciemment. Un score plus bas indique un état calme et bien régulé ; un score plus élevé invite à prévoir de la récupération.", - "recoveryPageBody": "La récupération rassemble les signaux nocturnes enregistrés par votre wearable — recharge, effort, charge ainsi que vos totaux de fréquence cardiaque et d'énergie du jour — pour montrer à quel point votre corps a récupéré. Observez la tendance sur plusieurs jours plutôt qu'un seul chiffre ; une valeur plus faible invite à lever le pied, ce n'est pas une alarme.", + "recoveryPageBody": "La récupération rassemble les signaux nocturnes enregistrés par votre wearable (recharge, effort, charge ainsi que vos totaux de fréquence cardiaque et d'énergie du jour) pour montrer à quel point votre corps a récupéré. Observez la tendance sur plusieurs jours plutôt qu'un seul chiffre ; une valeur plus faible invite à lever le pied, ce n'est pas une alarme.", "pulsePressureBody": "La pression pulsée est l'écart entre votre valeur systolique et diastolique (systolique moins diastolique). À titre indicatif général, environ 40 mmHg est typique au repos ; un écart large au-dessus d'environ 60 mmHg à l'âge moyen peut refléter des artères rigidifiées, tandis qu'un écart étroit en dessous d'environ 25 mmHg peut accompagner un faible volume d'éjection (StatPearls/NIH 2023). C'est un sujet de discussion, pas un diagnostic.", "meanArterialPressureBody": "La pression artérielle moyenne est la pression moyenne que voient vos organes sur un battement, soit grosso modo la diastolique plus un tiers de l'écart avec la systolique. À titre indicatif général, une valeur au repos se situe autour de 70–100 mmHg (StatPearls/NIH 2023). Le seuil de ~60 mmHg souvent cité est un concept de soins aigus, pas une alarme pour l'autosurveillance.", "cycleEducationBody": "Votre cycle traverse quatre phases pilotées par les hormones, et sa durée et sa régularité sont une fenêtre accessible sur la santé globale. À titre indicatif général, les cycles chez l'adulte durent typiquement 21–35 jours (les références cliniques citent 24–38), avec des règles d'environ cinq jours (ACOG 2015). La phase lutéale est l'ancrage stable, donc l'ovulation s'estime au mieux en comptant à rebours depuis vos prochaines règles prévues, jamais à un « jour 14 » fixe. Une fenêtre fertile prédite est une estimation qui demande plusieurs cycles enregistrés et reste approximative, et la température basale confirme l'ovulation après coup plutôt qu'elle ne la prédit. Ce n'est pas une méthode de contraception.", @@ -3422,7 +3458,7 @@ "mean": "Moyenne" }, "showAllValues": "Afficher toutes les mesures", - "valuesPageTitle": "{metric} — toutes les mesures", + "valuesPageTitle": "{metric} : toutes les mesures", "valuesPageDescription": "Chaque mesure derrière cette métrique. Modifiez ou supprimez une entrée directement.", "diversity": { "trigger": "Conseil de mesure", @@ -3462,11 +3498,11 @@ "inBed": "Au lit", "nap": "Sieste", "napCount": "{count} siestes", - "unavailable": "Pas encore de données de phases — les phases de sommeil apparaîtront ici dès qu’une source connectée les fournira." + "unavailable": "Pas encore de données de phases. Les phases de sommeil apparaîtront ici dès qu’une source connectée les fournira." }, "empty": { "title": "Pas encore de données de sommeil", - "description": "Connecte une source comme Apple Health ou un objet connecté — ou enregistre ton sommeil manuellement — et cette vue se remplira automatiquement.", + "description": "Connecte une source comme Apple Health ou un objet connecté (ou enregistre ton sommeil manuellement) et cette vue se remplira automatiquement.", "action": "Gérer les appareils" }, "debt": { @@ -3475,21 +3511,21 @@ "caption": "Solde restant sur les {nights} dernières nuits", "clearValue": "À jour", "clearCaption": "Rien en attente sur les {nights} dernières nuits", - "learning": "Votre sommeil est encore en cours d'analyse — {counted} nuits jusqu'ici, encore {remaining} pour un bilan fiable.", + "learning": "Votre sommeil est encore en cours d'analyse : {counted} nuits jusqu'ici, encore {remaining} pour un bilan fiable.", "computedInfo": "Il s'agit de l'estimation propre à HealthLog : un solde glissant entre le besoin et le sommeil des dernières nuits, que le sommeil de récupération réduit. Elle peut différer de la dette de sommeil native d'un wearable, qui utilise son propre modèle de besoin." }, "average": { "title": "Moyenne par nuit", "value": "{hours} h {minutes} min", "caption": "Sommeil moyen sur les {nights} dernières nuits", - "learning": "Votre sommeil est encore en cours d'analyse — {counted} nuits jusqu'ici, encore {remaining} pour une moyenne fiable." + "learning": "Votre sommeil est encore en cours d'analyse : {counted} nuits jusqu'ici, encore {remaining} pour une moyenne fiable." }, "chronotype": { "title": "Chronotype", - "learning": "Votre rythme est encore en cours d'analyse — {counted} sur {total} nuits libres. Un chronotype a besoin de quelques matins sans réveil pour avoir du sens.", + "learning": "Votre rythme est encore en cours d'analyse : {counted} sur {total} nuits libres. Un chronotype a besoin de quelques matins sans réveil pour avoir du sens.", "advancedToggle": "Avancé", "socialJetlagValue": "{hours} h {minutes} min de jet lag social", - "socialJetlagCaption": "L'écart entre vos horaires de sommeil les jours travaillés et les jours libres — comme un petit décalage horaire hebdomadaire.", + "socialJetlagCaption": "L'écart entre vos horaires de sommeil les jours travaillés et les jours libres, comme un petit décalage horaire hebdomadaire.", "msfScValue": "Milieu de sommeil corrigé {clock}", "msfScCaption": "Ajusté pour le sommeil rattrapé le week-end (MSFsc).", "dayTypeNote": "Les jours libres sont supposés être les week-ends jusqu'à l'ajout du suivi du type de jour.", @@ -3678,7 +3714,10 @@ "endInFuture": "Il se terminerait dans le futur. Indiquez comme début l’heure à laquelle l’entraînement a commencé.", "distanceInvalid": "Saisissez la distance sous forme de nombre, dans une plage réaliste.", "energyInvalid": "Saisissez l’énergie active sous forme de nombre, jusqu’à 20 000 kcal." - } + }, + "editTitle": "Modifier l’entraînement", + "editAction": "Modifier", + "updated": "Entraînement mis à jour" } }, "bloodGlucose": { @@ -3697,7 +3736,7 @@ "labsLink": "Résultats de laboratoire ? Saisis une HbA1c ou une glycémie à jeun issue d'une prise de sang dans Laboratoire.", "learningTitle": "Votre glucose se découvre encore", "learningBody": "Pour l’instant, cela fait {count} sur {days}. Avec un peu plus d’historique, le tableau se précise : continuez à enregistrer ou laissez un appareil connecté compléter.", - "learningBodyEmpty": "Dès que quelques mesures arrivent — saisies par vous ou synchronisées depuis un appareil — votre temps dans la cible, votre moyenne et votre variabilité prennent forme ici.", + "learningBodyEmpty": "Dès que quelques mesures arrivent (saisies par vous ou synchronisées depuis un appareil), votre temps dans la cible, votre moyenne et votre variabilité prennent forme ici.", "readingsCount": "{count} mesures", "readingsCountOne": "1 mesure", "daysCount": "{days} jours", @@ -3735,7 +3774,7 @@ "intro": "Des indicateurs de niveau recherche pour regarder de plus près. Chacun condense vos mesures en un seul chiffre pondéré par le risque.", "jIndex": { "label": "Indice J", - "help": "Un score unique mêlant votre moyenne et sa dispersion — plus il est bas, plus le contrôle est serré." + "help": "Un score unique mêlant votre moyenne et sa dispersion : plus il est bas, plus le contrôle est serré." }, "lbgi": { "label": "Risque d’hypoglycémie (LBGI)", @@ -3852,7 +3891,7 @@ "navSkinTemperature": "Temp. peau", "pulseWaveVelocity": { "title": "Vitesse de l’onde de pouls", - "description": "Votre vitesse d’onde de pouls artérielle au fil du temps, en m/s — des valeurs plus élevées suggèrent des artères plus rigides.", + "description": "Votre vitesse d’onde de pouls artérielle au fil du temps, en m/s. Des valeurs plus élevées suggèrent des artères plus rigides.", "chartTitle": "Vitesse de l’onde de pouls", "emptyState": { "title": "Aucune donnée", @@ -4002,7 +4041,6 @@ "navWalkingSpeed": "Vitesse de marche", "mood": { "logLink": "Enregistrer une humeur", - "mentalWellbeingLink": "Faire un bilan de bien-être mental", "heatmapTitle": "Calendrier d'humeur", "heatmapNoEntry": "Aucune entrée", "legendGreat": "Excellent", @@ -4013,7 +4051,7 @@ "distributionDaysUnit": "jours", "weekdayTitle": "Moyenne par jour de la semaine", "weekdayNoData": "Aucune donnée", - "weekdayBestWorst": "Meilleur jour : {best} · Jour le plus difficile : {worst}", + "weekdayBestWorst": "Meilleur jour : {best}, jour le plus difficile : {worst}", "tagsTitle": "Étiquettes et déclencheurs", "tagAvgMood": "Humeur moyenne avec cette étiquette", "correlationsTitle": "Humeur et autres mesures", @@ -4029,7 +4067,7 @@ "strengthModerate": "Modérée", "strengthWeak": "Faible", "strengthNone": "Aucune", - "source": "{n} jours appariés · r = {r}", + "source": "{n} jours appariés, r = {r}", "emptyTitle": "Pas encore assez de données appariées", "emptyDescription": "Enregistrez l'humeur et cette mesure les mêmes jours pour voir une corrélation.", "pendingDescription": "{n} jours appariés sur {needed}. Une corrélation apparaît dès qu'il y a assez de jours pour distinguer un motif du hasard.", @@ -4046,7 +4084,7 @@ "inTargetCaption": "des derniers jours dans votre plage de bonne humeur", "narrative": { "title": "Ce qui ressort", - "weekdayDip": "Votre humeur baisse surtout le {weekday} — {delta} sous votre semaine.", + "weekdayDip": "Votre humeur baisse surtout le {weekday}, {delta} sous votre semaine.", "weekdayPeak": "Le {weekday} est toujours votre meilleur jour, avec une moyenne de {value}.", "trendUp": "Votre humeur progresse sur les {days} derniers jours (+{delta} par rapport à la période précédente).", "trendDown": "Votre humeur recule sur les {days} derniers jours (-{delta} par rapport à la période précédente).", @@ -4055,7 +4093,7 @@ "tagLift": "Les jours marqués « {tag} » dépassent votre humeur générale de {delta} en moyenne.", "tagDrop": "Les jours marqués « {tag} » sont {delta} sous votre moyenne.", "inTarget": "Vous avez été dans votre plage de bonne humeur {pct} % des 30 derniers jours.", - "streak": "Vous avez noté votre humeur {days} jours d'affilée — continuez.", + "streak": "Vous avez noté votre humeur {days} jours d'affilée. Continuez.", "timeOfDay": "Vous vous sentez mieux le {bucket}, en moyenne {value}." }, "timeOfDay": { @@ -4088,7 +4126,7 @@ "crosstab": { "title": "Tags et vos métriques de santé", "description": "Comment une métrique de santé diffère les jours où vous avez ajouté chaque tag, par rapport aux jours sans.", - "pairLabel": "{tag} · {metric}", + "pairLabel": "{metric} les jours avec {tag}", "metricActiveEnergy": "Énergie active", "metricSleepDuration": "Sommeil", "metricNextDayRecovery": "Récupération le lendemain", @@ -4105,7 +4143,7 @@ "factorCrosstab": { "title": "Vos facteurs notés et vos constantes", "description": "Comment une constante diffère les jours où vous avez noté un facteur bas par rapport aux jours où vous l'avez noté haut.", - "pairLabel": "{factor} · {metric}", + "pairLabel": "{metric} les jours avec {factor}", "metricSleepDuration": "Sommeil", "metricSteps": "Pas", "metricRestingHeartRate": "Fréquence cardiaque au repos", @@ -4131,7 +4169,7 @@ "moodDown": "Un {factor} plus élevé tend à coïncider avec une moins bonne humeur le lendemain dans vos données.", "outcomeUp": "Une meilleure humeur tend à coïncider avec un {factor} plus élevé le lendemain dans vos données.", "outcomeDown": "Une meilleure humeur tend à coïncider avec un {factor} plus bas le lendemain dans vos données.", - "stat": "{n} jours appariés · r = {r} · q = {q}", + "stat": "{n} jours appariés, r = {r}, q = {q}", "statLabel": "Comment ceci est calculé", "explainerLabel": "Comment ceci est calculé" }, @@ -4165,8 +4203,8 @@ "deviationAbove": "Ta propre évaluation se situe {amount} points au-dessus de cette plage.", "deviationBelow": "Ta propre évaluation se situe {amount} points en dessous de cette plage.", "weightedTitle": "Ce qui a le plus pesé dans la comparaison de cette journée", - "weightedUp": "{label} — comptait vers une valeur attendue plus élevée", - "weightedDown": "{label} — comptait vers une valeur attendue plus basse", + "weightedUp": "{label} : comptait vers une valeur attendue plus élevée", + "weightedDown": "{label} : comptait vers une valeur attendue plus basse", "caveat": "C'est une comparaison avec tes propres journées passées, pas une affirmation sur la manière dont une journée s'est déroulée.", "learning": "Phase d'apprentissage : {entries} journées sur {threshold} enregistrées. La comparaison apparaît une fois qu'il y en a assez.", "noPattern": "Dans les {entries} journées enregistrées jusqu'ici, rien n'est assez régulier pour comparer une journée. Quand tu notes aussi le contexte d'une journée, tu en rends davantage comparable.", @@ -4175,15 +4213,15 @@ }, "derived": { "coverage": { - "summary": "{present} entrées sur {required} · {percent}%", + "summary": "{present} entrées sur {required} ({percent}%)", "ratioLabel": "{present}/{required}", "historyDays": "Basé sur {count} jours d'historique", "missing": "Enregistre ceci pour affiner la valeur : {list}", - "summaryLabelled": "{label} · {percent}%" + "summaryLabelled": "{label} ({percent}%)" }, "scoreRing": { "aria": "Score {score} sur 100, {band}", - "ariaProvisional": "Score pas encore disponible — données insuffisantes", + "ariaProvisional": "Score pas encore disponible : données insuffisantes", "provisionalCaption": "Données encore insuffisantes", "band": { "green": "bon", @@ -4197,7 +4235,7 @@ "inputsLabel": "Entrées", "noInputs": "Aucune entrée ne soutient encore cette valeur.", "methodLabel": "Méthode", - "window": "Sur tes {days} derniers jours · données {source}", + "window": "Sur tes {days} derniers jours de données {source}", "asOf": "au {date}", "standardLink": "Norme : {name}" }, @@ -4207,7 +4245,7 @@ "loadingLabel": "Chargement de vos constantes", "loadError": "Impossible de charger vos constantes pour le moment.", "infoLabel": "Comment ce résultat est-il calculé ?", - "building": "Construction de votre plage habituelle — {count} sur {target} jours", + "building": "Construction de votre plage habituelle : {count} sur {target} jours", "typicalRange": "Habituel pour vous : {low}–{high}", "fitnessYounger": "Forme cardio d'une personne ~{years} ans plus jeune", "fitnessOlder": "Forme cardio ~{years} ans au-dessus de votre âge", @@ -4239,9 +4277,8 @@ "anatomy": { "outOf": "/100", "contributorsLabel": "Facteurs", - "contributorValue": "{value} · {weight}% du poids", + "contributorValue": "{value}, pondération {weight}%", "contributorMissing": "aucune donnée", - "disclaimer": "Indicatif — pas une évaluation clinique.", "insufficient": "Pas encore assez de données pour l’évaluer. Continuez à enregistrer et cela apparaîtra ici." }, "composite": { @@ -4260,15 +4297,14 @@ "READINESS": { "title": "Disponibilité", "description": "Un indicateur quotidien de bien-être selon la position de vos signaux par rapport à votre propre référence.", - "method": "Un mélange de 0–100 entre l’écart de la fréquence cardiaque au repos, l’écart de la VFC (SDNN), la qualité du sommeil, la stabilité de la fréquence respiratoire et la stabilité de l’humeur — chacun mesuré par rapport à votre référence personnelle. Les poids sont renormalisés sur les composantes que vos données étayent. Un indicateur de bien-être, pas un score de récupération ni clinique.", + "method": "Un mélange de 0–100 entre l’écart de la fréquence cardiaque au repos, l’écart de la VFC (SDNN), la qualité du sommeil, la stabilité de la fréquence respiratoire et la stabilité de l’humeur, chacun mesuré par rapport à votre référence personnelle. Les poids sont renormalisés sur les composantes que vos données étayent. Un indicateur de bien-être, pas un score de récupération ni clinique.", "component": { "rhr": "Fréquence cardiaque au repos", "hrv": "VFC (SDNN)", "sleep": "Sommeil", "respiratory": "Fréquence respiratoire", "mood": "Stabilité de l’humeur" - }, - "caveat": "Un indicateur de bien-être, pas un score de récupération ni clinique." + } }, "VITALS_BASELINE": { "method": "Votre plage typique personnelle est la médiane de vos valeurs quotidiennes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). Elle suit VOS mesures, pas une norme de population : une valeur dans la bande est donc typique pour vous." @@ -4293,40 +4329,39 @@ }, "RECOVERY_SCORE": { "method": "Un indice de récupération nocturne de 0 à 100 combinant votre VFC, votre fréquence cardiaque au repos et votre sommeil par rapport à votre référence. Plus élevé signifie mieux récupéré. Calculé une fois par nuit et seulement relu ici, jamais recalculé à la volée.", - "caveat": "Un indicateur de bien-être descriptif, pas une mesure de récupération clinique ou de niveau d’entraînement.", "description": "Un indice de récupération nocturne à partir de votre VFC, fréquence au repos et sommeil." }, "STRESS_SCORE": { "method": "Un indice de stress nocturne de 0 à 100 à partir de la forme de votre VFC au cours de la journée par rapport à votre référence. Plus élevé signifie une charge physiologique plus forte.", - "caveat": "Un indicateur dérivé de la VFC, PAS une mesure de stress par EDA / activité électrodermale : votre montre n’a pas de capteur EDA. Descriptif, non clinique.", "description": "Un indice de stress nocturne à partir de la forme de votre VFC sur la journée." }, "STRAIN_SCORE": { "method": "Un indice d’effort nocturne de 0 à 100 à partir de votre charge d’activité pondérée par la fréquence cardiaque (un modèle d’impulsion d’entraînement de type TRIMP). Plus élevé signifie une journée plus exigeante.", - "anchorPersonal": "Calé sur votre propre charge récente des jours d’entraînement — évalué par rapport à votre effort habituel, de sorte qu’une journée vraiment difficile pour vous ressort élevée.", + "anchorPersonal": "Calé sur votre propre charge récente des jours d’entraînement : évalué par rapport à votre effort habituel, de sorte qu’une journée vraiment difficile pour vous ressort élevée.", "anchorPopulation": "Évalué par rapport à une référence générale pendant que vous constituez votre historique d’entraînement ; dès que vous en avez assez, le calcul passe à votre propre effort habituel.", - "caveat": "Un indicateur descriptif de charge d’activité, pas une mesure de charge clinique ou de niveau d’entraînement.", - "description": "Un indice de charge d’activité nocturne à partir de votre effort pondéré par la fréquence cardiaque, relatif à votre effort habituel." + "description": "Un indice de charge d’activité nocturne à partir de votre effort pondéré par la fréquence cardiaque, relatif à votre effort habituel.", + "deviceCaption": "Effort du jour de votre appareil : {value} sur {max}", + "deviceSource": "Tant que HealthLog n’a pas calculé sa propre valeur pendant la nuit, la carte affiche l’effort du jour de votre appareil, ramené sur 0–100." }, "WRIST_TEMPERATURE_BASELINE": { - "method": "Votre température nocturne typique au poignet est la médiane de vos relevés nocturnes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). Elle suit VOS relevés — la nuit dernière est affichée comme un écart par rapport à votre propre référence, pas à une norme de population.", - "caveat": "Une simple bande d’écart personnel — descriptive, ni un signe de maladie, de fièvre ou de phase du cycle. À vous de l’interpréter." + "method": "Votre température nocturne typique au poignet est la médiane de vos relevés nocturnes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). Elle suit VOS relevés : la nuit dernière est affichée comme un écart par rapport à votre propre référence, pas à une norme de population.", + "caveat": "Une simple bande d’écart personnel, descriptive, ni un signe de maladie, de fièvre ou de phase du cycle. À vous de l’interpréter." }, "STAIR_ASCENT_SPEED_BASELINE": { - "method": "Votre allure typique en montant les escaliers est la médiane de vos valeurs quotidiennes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). La bande est construite à partir de vos données — il n’existe pas de seuil de population pour l’allure dans les escaliers, car elle dépend de la géométrie des marches et de la longueur des jambes.", - "caveat": "Une bande personnelle de tendance de mobilité — descriptive, ni une évaluation de fragilité ni de risque de chute." + "method": "Votre allure typique en montant les escaliers est la médiane de vos valeurs quotidiennes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). La bande est construite à partir de vos données : il n’existe pas de seuil de population pour l’allure dans les escaliers, car elle dépend de la géométrie des marches et de la longueur des jambes.", + "caveat": "Une bande personnelle de tendance de mobilité, descriptive, ni une évaluation de fragilité ni de risque de chute." }, "STAIR_DESCENT_SPEED_BASELINE": { - "method": "Votre allure typique en descendant les escaliers est la médiane de vos valeurs quotidiennes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). La bande est construite à partir de vos données — il n’existe pas de seuil de population pour l’allure dans les escaliers, car elle dépend de la géométrie des marches et de la longueur des jambes.", - "caveat": "Une bande personnelle de tendance de mobilité — descriptive, ni une évaluation de fragilité ni de risque de chute." + "method": "Votre allure typique en descendant les escaliers est la médiane de vos valeurs quotidiennes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). La bande est construite à partir de vos données : il n’existe pas de seuil de population pour l’allure dans les escaliers, car elle dépend de la géométrie des marches et de la longueur des jambes.", + "caveat": "Une bande personnelle de tendance de mobilité, descriptive, ni une évaluation de fragilité ni de risque de chute." }, "SIX_MINUTE_WALK_BAND": { - "method": "La distance de marche de six minutes ESTIMÉE par votre appareil, située par rapport à la référence d’Enright et Sherrill pour votre âge, votre taille, votre poids et votre sexe, en pourcentage de la valeur prédite. La distance est l’estimation de l’appareil — jamais recalculée ici. Sans votre âge, taille, poids et sexe, le pourcentage est masqué et seules la distance et la tendance sont affichées.", - "caveat": "Une estimation reformulée par rapport à une référence publiée — un indicateur de capacité fonctionnelle, ni un test de marche de six minutes clinique ni un diagnostic. La référence a été établie pour les âges de 40 à 80 ans ; en dehors de cette plage, la valeur prédite est une extrapolation." + "method": "La distance de marche de six minutes ESTIMÉE par votre appareil, située par rapport à la référence d’Enright et Sherrill pour votre âge, votre taille, votre poids et votre sexe, en pourcentage de la valeur prédite. La distance est l’estimation de l’appareil, jamais recalculée ici. Sans votre âge, taille, poids et sexe, le pourcentage est masqué et seules la distance et la tendance sont affichées.", + "caveat": "Une estimation reformulée par rapport à une référence publiée : un indicateur de capacité fonctionnelle, ni un test de marche de six minutes clinique ni un diagnostic. La référence a été établie pour les âges de 40 à 80 ans ; en dehors de cette plage, la valeur prédite est une extrapolation." }, "TRAJECTORY": { "method": "Une droite des moindres carrés ajustée à vos valeurs journalières récentes et projetée sur un court horizon, avec l’intervalle de prédiction classique qui s’élargit à mesure qu’il s’éloigne. Calculée uniquement lorsque la tendance récente est suffisamment marquée et que votre historique est assez long et récent ; sinon aucune ligne n’est tracée.", - "caveat": "Une projection de votre propre tendance récente — ni une prédiction, ni un objectif, ni un pronostic clinique. Elle ne tient que si le schéma récent se poursuit ; la bande ombrée montre la vitesse à laquelle cette certitude s’estompe." + "caveat": "Une projection de votre propre tendance récente, ni une prédiction, ni un objectif, ni un pronostic clinique. Elle ne tient que si le schéma récent se poursuit ; la bande ombrée montre la vitesse à laquelle cette certitude s’estompe." }, "SAME_TIME_BASELINE": { "method": "Votre total cumulé du jour comparé à la médiane de cette même heure locale sur vos quatre dernières semaines, avec une bande robuste médiane ± MAD autour. Seuls les jours suffisamment détaillés comptent, et aucune comparaison n'apparaît avant d'en avoir au moins quatorze.", @@ -4341,7 +4376,9 @@ "recovery": "Score de récupération", "recoveryCrossLink": "Voir les données de récupération et d'effort de votre wearable", "stress": "Stress", - "strain": "Effort" + "strain": "Effort", + "historyTitle": "Évolution", + "historyWindow": "{days} derniers jours" }, "coincident": { "label": "Plusieurs constantes hors référence", @@ -4351,31 +4388,31 @@ "allClear": "Aujourd’hui, toutes vos constantes sont dans leur plage personnelle.", "allClearMeta": "{count} de vos constantes vérifiées par rapport à votre propre plage habituelle.", "watch": "Aujourd’hui, une constante est hors de sa plage habituelle.", - "watchVital": "Hors plage : {vital}. Souvent ponctuel — à surveiller ces prochains jours.", + "watchVital": "Hors plage : {vital}. Souvent ponctuel, à surveiller ces prochains jours.", "firedHeadline": "Aujourd’hui, {count} de vos constantes sont hors de leur plage personnelle.", - "factors": "Facteurs possibles — jamais une cause : un entraînement intense, un mauvais sommeil, l’alcool, l’altitude, le stress ou une maladie. C’est un repère issu de vos propres références, pas un diagnostic.", - "building": "Construction de vos références personnelles — enregistrez quelques jours de plus et cela apparaîtra.", + "factors": "Facteurs possibles, jamais une cause : un entraînement intense, un mauvais sommeil, l’alcool, l’altitude, le stress ou une maladie. C’est un repère issu de vos propres références, pas un diagnostic.", + "building": "Construction de vos références personnelles. Enregistrez quelques jours de plus et cela apparaîtra.", "illnessExplained": "Ces écarts reflètent probablement l’affection que vous avez consignée." }, "trajectory": { "cardTitle": "Projection à court terme", - "headline": "Si ce schéma se poursuit — les {days} prochains jours", + "headline": "Si ce schéma se poursuit : les {days} prochains jours", "range": "Projeté autour de {low}–{high} {unit} dans {days} jours.", "caveat": "Une projection de votre propre tendance récente, pas une prédiction. La bande ombrée s’élargit pour montrer la vitesse à laquelle l’incertitude augmente.", - "insufficient": "Pas encore de tendance assez nette pour projeter — continuez à enregistrer et une projection apparaîtra dès que le schéma récent sera suffisamment marqué.", + "insufficient": "Pas encore de tendance assez nette pour projeter. Continuez à enregistrer et une projection apparaîtra dès que le schéma récent sera suffisamment marqué.", "daysNeeded": "Encore environ {count} jours d’enregistrement nécessaires avant qu’une projection puisse apparaître." }, "sameTime": { "sectionTitle": "Aujourd'hui jusqu'ici", "sectionSubtitle": "Vos totaux cumulés face à ce qui est habituel chez vous à cette heure", - "learning": "Apprentissage de votre journée habituelle — {count} jours sur {target}", + "learning": "Apprentissage de votre journée habituelle : {count} jours sur {target}", "framing": "Habituel à {time} : {typical} {unit}", "comparisonLabel": "Votre journée habituelle" } }, "cardioFitness": { "title": "Condition physique", - "description": "Votre VO₂ max au fil du temps — la principale mesure de la forme aérobie.", + "description": "Votre VO₂ max au fil du temps, la principale mesure de la forme aérobie.", "chartTitle": "VO₂ max (mL/(kg·min))", "emptyState": { "title": "Pas encore de données de condition physique", @@ -4455,7 +4492,7 @@ "navBreathingDisturbances": "Respiration", "rhythmEvents": { "sectionTitle": "Notifications de santé de l’appareil", - "sectionIntro": "Notifications que votre montre a envoyées d’elle-même. HealthLog se contente de les afficher — il ne les détecte, ne les confirme ni ne les interprète.", + "sectionIntro": "Notifications que votre montre a envoyées d’elle-même. HealthLog se contente de les afficher : il ne les détecte, ne les confirme ni ne les interprète.", "loadError": "Impossible de charger les notifications de votre appareil.", "disclaimer": "Ce sont des alertes que votre appareil a produites avec sa propre détection intégrée. Elles sont affichées ici uniquement à titre informatif. Il ne s’agit pas d’une évaluation médicale de HealthLog, et HealthLog ne pose aucun diagnostic. Si une notification vous inquiète, parlez-en à votre médecin.", "event": { @@ -4500,9 +4537,9 @@ "detail": "Les pages de détail masquées disparaissent aussi de la navigation supérieure." }, "customize": "Personnaliser les Analyses", - "regenerateRateLimited": "Limite d'actualisation atteinte — réessayez plus tard.", - "regenerateUnavailable": "Aucune nouvelle analyse reçue — réessayez plus tard.", - "regenerateError": "Actualisation en cours — réessayez dans un instant.", + "regenerateRateLimited": "Limite d'actualisation atteinte. Réessayez plus tard.", + "regenerateUnavailable": "Aucune nouvelle analyse reçue. Réessayez plus tard.", + "regenerateError": "Actualisation en cours. Réessayez dans un instant.", "regenerateSettling": "L’évaluation est encore en cours de préparation", "regenerateSettleFailed": "L’actualisation n’a pas pu aboutir. Réessayez plus tard.", "deviceScore": { @@ -4524,9 +4561,9 @@ "emptyTitle": "Pas encore de données de récupération", "empty": "Pas encore de données de récupération. Connectez un compte WHOOP, Polar ou Oura et ces scores se rempliront dès que votre appareil se synchronise.", "block": { - "ansChargeExplainer": "À quel point votre système nerveux autonome est rechargé pour la journée — une mesure de recharge issue de vos signaux nocturnes de cœur et de respiration.", + "ansChargeExplainer": "À quel point votre système nerveux autonome est rechargé pour la journée : une mesure de recharge issue de vos signaux nocturnes de cœur et de respiration.", "dayStrainExplainer": "La charge cardiovasculaire de la journée sur l'échelle d'effort de votre appareil. Lisez-la au regard de votre récupération, pas comme un objectif en soi.", - "workoutStrainExplainer": "À quel point une séance a été exigeante sur la même échelle d'effort — le coût cardiovasculaire de cette séance.", + "workoutStrainExplainer": "À quel point une séance a été exigeante sur la même échelle d'effort : le coût cardiovasculaire de cette séance.", "cardioLoadExplainer": "Une estimation de la charge d'entraînement qui suit la charge cardiovasculaire accumulée. Comparez-la à vos propres journées récentes.", "averageHeartRateExplainer": "Votre fréquence cardiaque moyenne sur toute la journée ou le cycle, telle que rapportée par votre wearable.", "maxHeartRateExplainer": "Votre fréquence cardiaque maximale sur toute la journée ou le cycle, telle que rapportée par votre wearable.", @@ -4539,7 +4576,7 @@ }, "resilience": { "explainer": "Comment votre corps équilibre la charge quotidienne et la récupération, d'après vos signaux nocturnes des derniers jours.", - "learning": "Votre résilience est encore en cours d'apprentissage — quelques jours de plus stabiliseront le niveau.", + "learning": "Votre résilience est encore en cours d'apprentissage. Quelques jours de plus stabiliseront le niveau.", "band": { "limited": "Limitée", "adequate": "Adéquate", @@ -4584,7 +4621,7 @@ "nights": "{count} nuits suivies", "indexLine": "indice moyen récent {value}", "eventsFlagged": "{count} événements signalés par votre appareil", - "disclaimer": "Il s'agit d'un signal de dépistage, pas d'un diagnostic — parlez-en à un professionnel de santé." + "disclaimer": "Il s'agit d'un signal de dépistage, pas d'un diagnostic. Parlez-en à un professionnel de santé." }, "labsChanges": { "sectionTitle": "Depuis votre dernier bilan", @@ -4607,7 +4644,7 @@ "description": "Enregistrez une mesure avec un dynamomètre à main (notez le meilleur de plusieurs serrages) et la tendance apparaîtra ici.", "cta": "Enregistrer la force de préhension" }, - "coachPrefill": "Je n'ai pas encore enregistré de force de préhension — que dit-elle sur ma santé musculaire, et comment la mesurer et l'améliorer ?" + "coachPrefill": "Je n'ai pas encore enregistré de force de préhension. Que dit-elle sur ma santé musculaire, et comment la mesurer et l'améliorer ?" }, "pain": { "title": "Douleur (0–10)", @@ -4618,7 +4655,7 @@ "description": "Enregistrez votre douleur actuelle sur l'échelle 0–10 et la tendance apparaîtra ici.", "cta": "Enregistrer un score de douleur" }, - "coachPrefill": "Je n'ai pas encore enregistré de score de douleur — comment suivre la douleur sur l'échelle 0–10, et qu'est-ce qu'un changement significatif ?" + "coachPrefill": "Je n'ai pas encore enregistré de score de douleur. Comment suivre la douleur sur l'échelle 0–10, et qu'est-ce qu'un changement significatif ?" }, "waist": { "title": "Tour de taille", @@ -4629,7 +4666,7 @@ "description": "Mesurez votre tour de taille à mi-chemin entre la dernière côte et l'os de la hanche, en fin d'expiration normale, et enregistrez-le ici.", "cta": "Enregistrer le tour de taille" }, - "coachPrefill": "Je n'ai pas encore enregistré de tour de taille — comment le mesurer correctement, et que signifie ce chiffre pour ma santé ?" + "coachPrefill": "Je n'ai pas encore enregistré de tour de taille. Comment le mesurer correctement, et que signifie ce chiffre pour ma santé ?" }, "waistToHeight": { "title": "Rapport taille-hauteur", @@ -4640,7 +4677,7 @@ "description": "Enregistrez un rapport taille-hauteur et la tendance apparaîtra ici. Règle simple : gardez votre tour de taille sous la moitié de votre taille.", "cta": "Enregistrer le rapport taille-hauteur" }, - "coachPrefill": "Je n'ai pas encore enregistré de rapport taille-hauteur — pourquoi est-ce un chiffre utile, et comment le maintenir sous 0,5 ?" + "coachPrefill": "Je n'ai pas encore enregistré de rapport taille-hauteur. Pourquoi est-ce un chiffre utile, et comment le maintenir sous 0,5 ?" }, "ecg": { "sectionTitle": "Enregistrements ECG", @@ -4681,9 +4718,9 @@ "emptyCta": "Connecter un appareil" }, "intradayPulse": { - "title": "Le rythme de votre journée", - "caption": "Votre fréquence cardiaque au fil de la journée, moyennée par tranches de 10 minutes.", - "hourlyNote": "Moyenne horaire affichée pour cette journée — les jours récents affichent le détail complet par tranches de 10 minutes.", + "title": "Pouls au fil de la journée", + "caption": "Moyenné par tranches de 10 minutes.", + "hourlyNote": "Moyenne horaire affichée pour cette journée. Les jours récents affichent le détail complet par tranches de 10 minutes.", "empty": "Pas encore assez de mesures pour cette journée pour en afficher le rythme.", "coverageOne": "Basé sur 1 mesure sur {hours}h.", "coverageMany": "Basé sur {count} mesures sur {hours}h.", @@ -4692,10 +4729,10 @@ "previousDay": "Jour précédent", "nextDay": "Jour suivant", "tension": { - "morning": "Une période élevée au repos ce matin — peut-être un peu de tension.", - "afternoon": "Une période élevée au repos cet après-midi — peut-être un peu de tension.", - "evening": "Une période élevée au repos ce soir — peut-être un peu de tension.", - "night": "Une période élevée au repos cette nuit — peut-être un peu de tension." + "morning": "Une période élevée au repos ce matin, peut-être un peu de tension.", + "afternoon": "Une période élevée au repos cet après-midi, peut-être un peu de tension.", + "evening": "Une période élevée au repos ce soir, peut-être un peu de tension.", + "night": "Une période élevée au repos cette nuit, peut-être un peu de tension." } }, "navEcg": "ECG", @@ -4706,6 +4743,19 @@ "settingsBody": "HealthLog fonctionne entièrement sans IA. Connectez un fournisseur ci-dessous si vous voulez un briefing quotidien, de courtes notes sur vos mesures et le Coach.", "action": "Configurer l'IA", "dismiss": "Pas maintenant" + }, + "bmiHeight": { + "coachPrefill": "Je n’ai pas encore indiqué ma taille. Pourquoi l’IMC compte-t-il, et que dois-je savoir avant de la renseigner ?" + }, + "medicationsEmpty": { + "coachPrefill": "Je n’ai encore ajouté aucun médicament. Que dois-je savoir avant de suivre ici l’observance ?" + }, + "scoreHistory": { + "healthTitle": "Évolution du score de santé", + "healthDescription": "Ton score de santé jour après jour, tel qu’il s’affichait chaque jour.", + "emptyDescription": "Aucun score n’a encore été enregistré sur cette période.", + "usualRange": "En grisé : ta plage habituelle, de {lo} à {hi}.", + "seam": "La ligne s’interrompt là où ce qui compte pour le score a changé." } }, "thresholds": { @@ -4735,14 +4785,15 @@ "metricBodyFat": "Masse grasse", "metricSleep": "Durée du sommeil", "metricSteps": "Pas/jour", - "metricGlucoseFasting": "Glycémie — à jeun", - "metricGlucosePostprandial": "Glycémie — postprandiale", - "metricGlucoseRandom": "Glycémie — indifférente", - "metricGlucoseBedtime": "Glycémie — avant le coucher", + "metricGlucoseFasting": "Glycémie (à jeun)", + "metricGlucosePostprandial": "Glycémie (postprandiale)", + "metricGlucoseRandom": "Glycémie (indifférente)", + "metricGlucoseBedtime": "Glycémie (avant le coucher)", "metricBodyWater": "Eau corporelle", "metricBoneMass": "Masse osseuse", "metricOxygenSaturation": "Saturation en oxygène (SpO₂)", - "unsetExplanation": "Pas de valeur personnelle — la valeur par défaut calculée s’applique." + "unsetExplanation": "Pas de valeur personnelle : la valeur par défaut calculée s’applique.", + "cardTitle": "Plages par métrique" }, "onboarding": { "errorGeneric": "Une erreur est survenue à la fin de la configuration. Réessaie.", @@ -4755,11 +4806,11 @@ "steps": { "dashboardOverview": { "title": "Votre tableau de bord", - "body": "Le tableau de bord rassemble tous les modules d’un coup d’œil. Appuyez longuement sur une tuile pour la réorganiser ou la masquer (Réglages → Disposition du tableau de bord). Une tuile ne vaut que par les données derrière elle — les modules suivants les remplissent." + "body": "Le tableau de bord rassemble tous les modules d’un coup d’œil. Appuyez longuement sur une tuile pour la réorganiser ou la masquer (Réglages → Disposition du tableau de bord). Une tuile ne vaut que par les données derrière elle, et les modules suivants les remplissent." }, "quickAdd": { "title": "Saisi en quelques secondes", - "body": "Avec « + Ajouter », saisissez n’importe quelle mesure en quelques secondes — poids, tension, pouls, glycémie. Atout caché : antidater les valeurs oubliées. Tout ce que vous saisissez ici alimente les tendances, le score santé et les analyses IA." + "body": "Avec « + Ajouter », saisissez n’importe quelle mesure en quelques secondes : poids, tension, pouls, glycémie. Atout caché : antidater les valeurs oubliées. Tout ce que vous saisissez ici alimente les tendances, le score santé et les analyses IA." }, "measurements": { "title": "Mesures et plages cibles", @@ -4771,7 +4822,7 @@ }, "labs": { "title": "Valeurs de laboratoire", - "body": "Laboratoire recense les biomarqueurs avec plages de référence — hémogramme, cholestérol, HbA1c. Atout : import et export FHIR. Les valeurs hors plage réapparaissent comme preuves dans les analyses et le rapport médical." + "body": "Laboratoire recense les biomarqueurs avec plages de référence : hémogramme, cholestérol, HbA1c. Atout : import et export FHIR. Les valeurs hors plage réapparaissent comme preuves dans les analyses et le rapport médical." }, "illness": { "title": "Journal des maladies", @@ -4787,15 +4838,15 @@ }, "mood": { "title": "Humeur", - "body": "Humeur saisit votre ressenti et des tags en deux secondes. Atout : vos propres tags. Les tendances d’humeur se corrèlent au sommeil, aux médicaments et au cycle — le Coach repère les schémas." + "body": "Humeur saisit votre ressenti et des tags en deux secondes. Atout : vos propres tags. Les tendances d’humeur se corrèlent au sommeil, aux médicaments et au cycle, et le Coach repère les schémas." }, "insights": { "title": "Analyses", - "body": "Analyses, c’est le cœur : une analyse dynamique et fondée sur des preuves plutôt que des conseils génériques — chaque affirmation pointe vos données réelles. Caché : la disposition est personnalisable. Analyses réunit tout ce que les modules ci-dessus fournissent." + "body": "Analyses, c’est le cœur : une analyse dynamique et fondée sur des preuves plutôt que des conseils génériques, où chaque affirmation pointe vos données réelles. Caché : la disposition est personnalisable. Analyses réunit tout ce que les modules ci-dessus fournissent." }, "coach": { "title": "Coach", - "body": "Le Coach répond à vos questions sur vos données en dialogue, en utilisant antécédents, médicaments et résultats comme contexte. Vous choisissez le fournisseur IA (votre clé ou local). Il ne recalcule rien — il lit les mêmes chiffres que le tableau de bord." + "body": "Le Coach répond à vos questions sur vos données en dialogue, en utilisant antécédents, médicaments et résultats comme contexte. Vous choisissez le fournisseur IA (votre clé ou local). Il ne recalcule rien : il lit les mêmes chiffres que le tableau de bord." }, "integrations": { "title": "Intégrations", @@ -4803,15 +4854,15 @@ }, "export": { "title": "Rapport médical", - "body": "Le rapport médical regroupe mesures, médicaments, valeurs de laboratoire et examens en PDF ou FHIR pour le rendez-vous. Tous les modules convergent ici — un rapport complet ne vient que de données bien tenues." + "body": "Le rapport médical regroupe mesures, médicaments, valeurs de laboratoire et examens en PDF ou FHIR pour le rendez-vous. Tous les modules convergent ici : un rapport complet ne vient que de données bien tenues." }, "achievements": { "title": "Réussites", - "body": "Les réussites récompensent la régularité — séries et jalons. Une petite boucle de motivation qui aide à tenir bon." + "body": "Les réussites récompensent la régularité : séries et jalons. Une petite boucle de motivation qui aide à tenir bon." }, "wrapUp": { "title": "C’est la fin de la visite", - "body": "Cette visite guidée ne s'affiche qu'une fois — elle ne se rouvrira pas d'elle-même. Tout ce qu'elle a montré reste en place, et vous pouvez activer ou désactiver des fonctionnalités à tout moment dans Réglages → Modules." + "body": "Cette visite guidée ne s'affiche qu'une fois et ne se rouvrira pas d'elle-même. Tout ce qu'elle a montré reste en place, et vous pouvez activer ou désactiver des fonctionnalités à tout moment dans Réglages → Modules." } } }, @@ -4842,7 +4893,7 @@ "title": "Tout est prêt", "body": "Bienvenue à bord. Votre tableau de bord vous attend.", "returnCta": "Ouvrir le tableau de bord", - "learning": "Certaines analyses — glycémie, rythme de sommeil, tendances de tension, cycle — s'affinent au cours de la première semaine ou deux à mesure que vos données s'accumulent. Des tuiles vides signifient simplement que nous apprenons encore votre référence.", + "learning": "Certaines analyses (glycémie, rythme de sommeil, tendances de tension, cycle) s'affinent au cours de la première semaine ou deux à mesure que vos données s'accumulent. Des tuiles vides signifient simplement que nous apprenons encore votre référence.", "connectCta": "Connecter un appareil", "logCta": "Enregistrez votre première mesure", "importCta": "Importer un historique existant", @@ -4865,7 +4916,7 @@ "conditionsHint": "Aide HealthLog à replacer vos mesures dans leur contexte plutôt que de signaler l'attendu.", "allergiesLabel": "Allergies ou intolérances", "allergiesPlaceholder": "p. ex. pénicilline, lactose", - "privacyNote": "Stocké chiffré et utilisé uniquement pour un meilleur contexte. Nous ne demandons pas vos médicaments ici — ajoutez-les quand vous voulez depuis la page Médicaments." + "privacyNote": "Stocké chiffré et utilisé uniquement pour un meilleur contexte. Nous ne demandons pas vos médicaments ici : ajoutez-les quand vous voulez depuis la page Médicaments." }, "ai": { "panelTitle": "Voyez à quoi ressemblent les insights", @@ -4874,18 +4925,18 @@ "sampleTitle": "Briefing quotidien", "sampleTag": "Exemple", "sampleAriaLabel": "Exemple de briefing quotidien", - "sampleBody": "Poids en baisse de 1,2 kg en deux semaines. Pouls au repos stable à 58 bpm. Tension dans la plage 6 jours sur 7 — la seule valeur élevée a suivi un dîner tardif et salé.", - "sampleCaption": "Exemple uniquement — pas vos données. Voilà à quoi ressemble un briefing une fois les insights activés.", + "sampleBody": "Poids en baisse de 1,2 kg en deux semaines. Pouls au repos stable à 58 bpm. Tension dans la plage 6 jours sur 7 ; la seule valeur élevée a suivi un dîner tardif et salé.", + "sampleCaption": "Exemple uniquement, pas vos données. Voilà à quoi ressemble un briefing une fois les insights activés.", "ladderTitle": "Trois façons de l'activer", - "ladderLocalTitle": "Modèle local — les requêtes d’IA restent sur votre réseau", - "ladderLocalBody": "Pointez HealthLog vers Ollama ou LM Studio sur votre propre machine. Gratuit et totalement privé — le choix calme par défaut.", + "ladderLocalTitle": "Modèle local : les requêtes d’IA restent sur votre réseau", + "ladderLocalBody": "Pointez HealthLog vers Ollama ou LM Studio sur votre propre machine. Gratuit et totalement privé, le choix calme par défaut.", "ladderByokTitle": "Apportez votre clé", "ladderByokBody": "Utilisez votre propre clé OpenAI ou Anthropic. Elle fonctionne avec votre compte et votre facturation, et le niveau API exclut vos données de l'entraînement par défaut.", "ladderOauthTitle": "Connectez-vous avec un abonnement", - "ladderOauthBody": "Reliez un compte ChatGPT — sans coût par jeton, mais les conditions du niveau grand public peuvent utiliser le contenu pour l'entraînement : c'est donc un choix délibéré, jamais l'option par défaut pour vos données de santé.", - "keylessLine": "HealthLog est pleinement utile sans IA. Vos tuiles, graphiques, tendances et exports fonctionnent tous — l'IA n'ajoute que la lecture en langage clair par-dessus.", + "ladderOauthBody": "Reliez un compte ChatGPT, sans coût par jeton, mais les conditions du niveau grand public peuvent utiliser le contenu pour l'entraînement : c'est donc un choix délibéré, jamais l'option par défaut pour vos données de santé.", + "keylessLine": "HealthLog est pleinement utile sans IA. Vos tuiles, graphiques, tendances et exports fonctionnent tous ; l'IA n'ajoute que la lecture en langage clair par-dessus.", "setupCta": "Configurez-le (environ une minute)", - "sharedKeyNote": "Cette instance fournit déjà un fournisseur d'IA partagé : les insights fonctionnent pour vous dès maintenant — aucune configuration nécessaire.", + "sharedKeyNote": "Cette instance fournit déjà un fournisseur d'IA partagé : les insights fonctionnent pour vous dès maintenant, aucune configuration nécessaire.", "offer": { "title": "Cette instance peut rédiger votre résumé en langage clair.", "body": "L’exploitant met à disposition un fournisseur qui a fonctionné au cours des dernières 24 heures. Une seule touche active le langage clair ; vos données partent alors vers ce seul fournisseur et vers personne d’autre.", @@ -5102,7 +5153,7 @@ "profileDescription": "Taille, date de naissance et sexe débloquent des objectifs personnalisés.", "profileCta": "Ouvrir le profil", "measurementTitle": "Saisir la première mesure", - "measurementDescription": "Une mesure suffit à activer la tuile — la tendance se dessine à partir de la 5ᵉ mesure.", + "measurementDescription": "Une mesure suffit à activer la tuile ; la tendance se dessine à partir de la 5ᵉ mesure.", "measurementCta": "Ajouter une mesure", "medicationTitle": "Ajouter un médicament", "medicationDescription": "Suis l’observance et reçois des rappels au moment où tu en as besoin.", @@ -5111,7 +5162,7 @@ "dataSourceDescription": "Synchronisez poids, tension, sommeil et plus depuis Withings, WHOOP, Oura, Polar, Nightscout, Fitbit ou Apple Health.", "dataSourceCta": "Connecter", "notificationsTitle": "Configurer les notifications", - "notificationsDescription": "Choisis comment HealthLog te prévient — Telegram, ntfy ou Web Push.", + "notificationsDescription": "Choisis comment HealthLog te prévient : Telegram, ntfy ou Web Push.", "notificationsCta": "Ouvrir les notifications", "insightsTitle": "Activer les insights (facultatif)", "insightsDescription": "Ajoutez un modèle local ou votre propre clé IA pour débloquer le briefing quotidien et Coach.", @@ -5259,12 +5310,10 @@ "sections": { "account": { "title": "Compte", - "description": "Profil, mot de passe, clés d’accès et votre visite guidée.", "subtitle": "Votre profil, votre identité et vos identifiants." }, "integrations": { "title": "Intégrations", - "description": "Services connectés, canaux de diffusion et priorité des sources au même endroit.", "subtitle": "Connectez et gérez vos sources de données et appareils.", "connectionsHeading": "Connexions", "channelsHeading": "Canaux de diffusion", @@ -5273,16 +5322,14 @@ }, "notifications": { "title": "Notifications", - "description": "Tous les rappels au même endroit — quoi et quand, et où c'est envoyé.", "subtitle": "Choisissez les rappels que vous recevez et où ils sont envoyés.", "remindersHeading": "Rappels", "matrixLink": "Notifications par canal", "channelsLink": "Canaux de diffusion" }, "layout": { - "title": "Apparence", - "description": "Choisissez l'apparence et l'agencement du tableau de bord, des analyses et de vos modules de suivi.", - "backToHub": "Retour à Apparence", + "title": "Disposition", + "backToHub": "Retour à Disposition", "dashboard": { "title": "Tableau de bord", "description": "Disposition et ordre des tuiles." @@ -5291,7 +5338,7 @@ "title": "Analyses", "description": "Ordre de la vue d'ensemble et des onglets de navigation." }, - "subtitle": "Personnalisez l'apparence du tableau de bord, des analyses et des modules de suivi.", + "subtitle": "Choisissez ce qu'affichent le tableau de bord, les analyses et les modules de suivi, et dans quel ordre.", "medications": { "title": "Médicaments", "description": "Vue en liste et ordre manuel." @@ -5311,6 +5358,10 @@ "mood": { "title": "Étiquettes d’humeur", "description": "Groupes, étiquettes et étiquettes archivées du sélecteur d’humeur." + }, + "documents": { + "title": "Documents", + "description": "Affichage et disposition des mois." } }, "dashboard": { @@ -5318,15 +5369,13 @@ }, "thresholds": { "title": "Objectifs", - "description": "Par métrique : votre propre plage cible pour des valeurs saines.", "subtitle": "Définissez vos plages cibles personnelles par mesure." }, "sources": { "title": "Priorité des sources", - "description": "Choisissez quelle source prévaut quand plusieurs enregistrent la même métrique le même jour.", "integrationsHint": "Connectez ou déconnectez des sources de données sur la page Intégrations.", "integrationsHintLink": "Ouvrir les intégrations", - "cardTitle": "Priorité des sources par métrique", + "cardTitle": "Classement par métrique", "help": "Si plusieurs sources fournissent la même valeur le même jour, la source en tête compte comme canonique.", "resetDefaults": "Rétablir les valeurs par défaut", "resetDefaultsTitle": "Réinitialiser l’ordre des sources ?", @@ -5339,7 +5388,7 @@ "resetSuccess": "Priorité des sources rétablie aux valeurs par défaut", "perMetricToggle": "Réglages par métrique ({count})", "perMetricHelp": "Métriques que vous avez personnalisées par rapport à l’ordre par défaut. Utilisez cette liste pour rétablir une métrique donnée à sa valeur par défaut sans toucher aux autres.", - "perMetricEmpty": "Aucun réglage par métrique — chaque métrique utilise l’ordre global ci-dessus.", + "perMetricEmpty": "Aucun réglage par métrique : chaque métrique utilise l’ordre global ci-dessus.", "resetMetric": "Réinitialiser", "deviceTypeToggle": "Priorité par type d’appareil ({count})", "deviceTypeHelp": "Au sein d’une source, quel appareil l’emporte. Lorsqu’une Apple Watch et un iPhone enregistrent tous deux les pas, le type d’appareil le mieux classé compte comme la lecture canonique.", @@ -5364,7 +5413,8 @@ "respiratoryRate": "Fréquence respiratoire", "recovery": "Récupération", "stress": "Stress", - "sleepDebt": "Dette de sommeil" + "sleepDebt": "Dette de sommeil", + "hrvRmssd": "VFC (RMSSD)" }, "sourceLabels": { "WITHINGS": "Withings", @@ -5381,7 +5431,8 @@ "MCP": "MCP", "GOOGLE_HEALTH": "Google Health", "STRAVA": "Strava", - "EXTERNAL": "Appareil externe" + "EXTERNAL": "Appareil externe", + "HEALTH_CONNECT": "Health Connect" }, "deviceLabels": { "watch": "Montre", @@ -5403,26 +5454,23 @@ "moduleHintLink": "Gérer les modules", "detail": "Lecture seule : l'app source reste propriétaire des valeurs." }, - "helpDetail": "Les autres restent enregistrées pour la piste d’audit." + "helpDetail": "Les autres restent enregistrées pour la piste d’audit. Seules les sources connectées ou ayant fourni des données sont listées.", + "noneInUse": "Aucune source à classer pour l'instant. Connectez-en une dans Intégrations ou saisissez une valeur." }, "ai": { "title": "Fournisseur d'IA", - "description": "Fournisseur, modèle et clé API pour les analyses, ainsi que votre contexte « à propos de moi ».", "subtitle": "Choisissez votre fournisseur d'IA et utilisez votre propre clé." }, "api": { - "title": "API & Tokens", - "description": "Jetons Bearer pour vos propres scripts et applis — enregistrez des mesures ou des prises de médicaments depuis n’importe où.", + "title": "API et jetons", "subtitle": "Gérez les points d'accès API et les jetons d'accès." }, "advanced": { "title": "Avancé", - "description": "La sortie : effacer définitivement toutes les données de santé ou fermer le compte.", "subtitle": "Réinitialisation des données et suppression du compte." }, "export": { "title": "Export et import", - "description": "Téléchargez vos données en CSV ou en sauvegarde JSON complète, ou importez depuis Apple Santé, JSON ou CSV. Le compte rendu médical se trouve désormais dans Dossier de santé.", "otherOptionsHeading": "Autres options d'export", "downloadFailed": "Échec du téléchargement de l'export. Veuillez réessayer.", "actions": { @@ -5449,7 +5497,7 @@ "fullBackup": { "title": "Sauvegarde complète", "description": "L'ensemble de votre dossier dans un fichier JSON.", - "scopeNote": "Les métadonnées des documents et le résumé IA, s'il existe, sont inclus en texte clair ; les fichiers originaux des documents ainsi que les tracés GPS/données d'échantillons des entraînements ne le sont pas — téléchargez-les séparément depuis le coffre de documents ou l'export des entraînements.", + "scopeNote": "Les métadonnées des documents et le résumé IA, s'il existe, sont inclus en texte clair ; les fichiers originaux des documents ainsi que les tracés GPS/données d'échantillons des entraînements ne le sont pas. Téléchargez-les séparément depuis le coffre de documents ou l'export des entraînements.", "encryptToggle": "Chiffrer avec une phrase secrète", "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.", @@ -5478,7 +5526,7 @@ "failureInterrupted": "L'import a été interrompu avant la fin, généralement par un redémarrage du serveur ou un manque de mémoire. Vos données sont en sécurité ; renvoyez l'archive pour réessayer.", "failureInsufficientMemory": "La limite de mémoire du serveur est trop basse pour une archive de cette taille. Demandez à la personne qui administre le serveur d'augmenter la limite de mémoire de Node.js (NODE_OPTIONS=--max-old-space-size), puis renvoyez l'archive.", "rowsImported": "{count} lignes importées jusqu'ici", - "doneSummary": "Terminé — {imported} enregistrements sur {read} importés, {skipped} enregistrements cliniques ignorés.", + "doneSummary": "Terminé : {imported} enregistrements sur {read} importés, {skipped} enregistrements cliniques ignorés.", "doneNothing": "Rien n'a été importé de cette archive : 0 enregistrement sur {read} est arrivé.", "writtenByHealthLogOne": "{count} enregistrement que HealthLog a écrit lui-même dans Santé a été ignoré, car il figure déjà dans vos données.", "writtenByHealthLogOther": "{count} enregistrements que HealthLog a écrits lui-même dans Santé ont été ignorés, car ils figurent déjà dans vos données.", @@ -5491,12 +5539,12 @@ "parsing": "Lecture de l'export…", "upserting": "Import des enregistrements…" }, - "skipUnsupported": "{count} échantillons ignorés — des types de données que HealthLog n'importe pas.", + "skipUnsupported": "{count} échantillons ignorés : des types de données que HealthLog n'importe pas.", "skipRefused": "{count} échantillons n'ont pas pu être écrits (illisibles, hors plage ou échec de l'enregistrement).", "skipBreakdown": "Afficher le détail ({count} types)", "skipBreakdownMore": "+ {count} autres types", "cycleSummary": "{samples} échantillons de cycle fusionnés en {days} journaux quotidiens.", - "cycleModuleOff": "{count} échantillons de cycle ignorés — le suivi du cycle est désactivé pour ce compte.", + "cycleModuleOff": "{count} échantillons de cycle ignorés, car le suivi du cycle est désactivé pour ce compte.", "cycleDaysFailed": "{count} jours de cycle n'ont pas pu être écrits : {reason}" }, "json": { @@ -5581,8 +5629,8 @@ "failed": "L'import a échoué. Rien n'a été écrit.", "columnsHeading": "Ce que devient chaque colonne", "jsonOnly": "JSON uniquement", - "previewLine": "Lignes lues : {rows} · seraient importées : {queued} · refusées : {refused}", - "submittedLine": "Lignes lues : {rows} · en file d'import : {queued}", + "previewLine": "{rows} lignes lues, {queued} seraient importées, {refused} refusées", + "submittedLine": "{rows} lignes lues, {queued} en file d'import", "unmatched": "Aucun médicament enregistré ne correspond à : {names}", "ambiguous": "Correspond à plusieurs médicaments : {names}", "mirrored": "Copié depuis une autre application, aucune prise ne peut y être ajoutée : {names}", @@ -5618,18 +5666,59 @@ "tooManyRows": "Ce fichier contient plus de lignes qu'un import peut en prendre. Découpez-le et importez les parties." }, "detail": "Un fichier couvre tous les médicaments qu'il contient." + }, + "healthConnect": { + "title": "Export Health Connect", + "description": "Importe le fichier ZIP que l’app Health Connect sur Android crée lors de l’export de tes données.", + "choose": "Choisir le fichier ZIP", + "uploading": "Envoi en cours…", + "uploadFailed": "L’envoi a échoué. Réessaie.", + "tooLarge": "Le fichier dépasse la taille acceptée par ce serveur.", + "doneSummary": "{count} valeurs importées.", + "failed": "L’import a échoué : {reason}", + "phase": { + "queued": "En attente de démarrage", + "unpacking": "Décompression du fichier", + "parsing": "Lecture des enregistrements", + "upserting": "Enregistrement des valeurs", + "done": "Terminé", + "failed": "Échec" + }, + "steps": { + "export": "Sur ton téléphone Android, ouvre Health Connect, va dans Gérer les données, puis Exporter et importer, et lance une exportation.", + "cloud": "Health Connect enregistre l’exportation uniquement dans une app de stockage cloud comme Google Drive, Dropbox ou Nextcloud, jamais sur le téléphone lui-même. Choisis-en une.", + "upload": "Télécharge le fichier ZIP de ce cloud sur cet appareil et importe-le ici." + }, + "dropLabel": "Dépose ici le ZIP de Health Connect", + "dropHint": "ou clique pour le choisir. Jusqu’à 1 Go.", + "fileInputLabel": "Fichier ZIP de l’exportation Health Connect", + "privacyNote": "Le fichier est supprimé du serveur dès la fin de l’importation. Les enregistrements des apps que tu connectes déjà directement, comme Withings ou Fitbit, sont ignorés pour que rien n’arrive en double. Importer à nouveau le même fichier ne change rien.", + "rateLimited": "Trop d’envois en peu de temps. Attends une minute et réessaie.", + "busy": "Une importation est déjà en cours pour ton compte. Attends qu’elle soit terminée.", + "rowsSoFar": "{count} valeurs enregistrées jusqu’ici", + "doneNothing": "L’importation est terminée, mais il n’y avait rien de nouveau à ajouter.", + "workouts": "Entraînements", + "leftOutConnected": "{count} enregistrements d’apps que tu connectes déjà directement ont été ignorés.", + "nutrientsModuleOff": "Les totaux d’eau et de nutriments n’ont pas été importés, car le module Nutriments est désactivé.", + "cycleModuleOff": "Les données de règles n’ont pas été importées, car le suivi du cycle est désactivé.", + "failure": { + "unsupportedVersion": "Cette exportation Health Connect est trop ancienne pour être lue. Mets à jour Health Connect sur le téléphone et exporte à nouveau.", + "notHealthConnect": "Ce fichier n’est pas une exportation Health Connect. Choisis le fichier ZIP enregistré par Health Connect.", + "unsafeSchema": "Ce fichier n’a pas la structure d’une exportation Health Connect et n’a pas été lu.", + "tooLarge": "L’exportation est plus volumineuse que ce que ce serveur accepte.", + "stagingMissing": "Le fichier envoyé n’était plus sur le serveur. Envoie-le à nouveau.", + "interrupted": "Un redémarrage du serveur a interrompu l’importation. Envoie le fichier à nouveau." + } } }, "subtitle": "Exportez vos données ou importez depuis une sauvegarde." }, "about": { "title": "À propos de cette app", - "description": "Version, licence, liens.", "subtitle": "Version, licence et liens du projet." }, "sharing": { "title": "Partage", - "description": "Créez un lien en lecture seule pour partager votre dossier de santé avec un professionnel de santé.", "subtitle": "Partagez un accès en lecture seule à vos données." }, "insights": { @@ -5664,12 +5753,10 @@ }, "gesundheitsakte": { "title": "Dossier médical", - "description": "Exportez votre dossier médical complet au format PDF, FHIR R4 ou en paquet ZIP combiné.", "subtitle": "Exportez un dossier de santé complet, prêt pour le médecin." }, "coach": { "title": "Coach", - "description": "Activez le Coach, le ton et la portée, les souvenirs enregistrés.", "subtitle": "Réglez le Coach, ses suggestions proactives et sa mémoire." }, "security": { @@ -5678,8 +5765,7 @@ }, "privacy": { "title": "Données et confidentialité", - "subtitle": "Vos données, leur conservation, le chiffrement et la sécurité du compte au même endroit.", - "description": "Export, suppression, conservation, état du chiffrement, sessions et activité de sécurité." + "subtitle": "Vos données, leur conservation, le chiffrement et la sécurité du compte au même endroit." }, "mcp": { "title": "Connecteur MCP", @@ -5687,8 +5773,7 @@ }, "anamnesis": { "title": "Antécédents médicaux", - "subtitle": "Gérez vos allergies et vos antécédents familiaux.", - "description": "Dossiers structurés des allergies et des antécédents familiaux." + "subtitle": "Vos antécédents, votre mode de vie, vos informations d’urgence et ce que l’IA peut lire." }, "environment": { "title": "Localisation et météo", @@ -5722,7 +5807,8 @@ "add": "Ajouter {label}", "remove": "Supprimer la période de localisation", "removeConfirmTitle": "Retirer ce voyage ?", - "removeConfirmBody": "Les mesures de cette période restent dans ton dossier ; seule l'entrée du voyage disparaît." + "removeConfirmBody": "Les mesures de cette période restent dans ton dossier ; seule l'entrée du voyage disparaît.", + "range": "Du {start} au {end}" }, "backfill": { "title": "Récupérer les jours passés", @@ -5732,7 +5818,21 @@ "noResults": "Aucun lieu correspondant trouvé.", "fetchFailedOne": "La dernière mise à jour météo en arrière-plan a échoué ({when}).", "fetchFailedFew": "Les {count} dernières mises à jour météo en arrière-plan ont échoué, la plus récente {when}.", - "fetchFailedOther": "Les {count} dernières mises à jour météo en arrière-plan ont échoué, la plus récente {when}." + "fetchFailedOther": "Les {count} dernières mises à jour météo en arrière-plan ont échoué, la plus récente {when}.", + "airQuality": { + "title": "Qualité de l’air et pollen", + "description": "Qualité de l’air, pollen et UV quotidiens pour ton lieu, issus du modèle CAMS via Open-Meteo.", + "toggle": "Inclure la qualité de l’air et le pollen", + "operatorDisabled": "La qualité de l’air est désactivée sur ce serveur.", + "coverage": "{count} jours avec des données de qualité de l’air", + "pollenEuropeOnly": "Les données de pollen ne couvrent que l’Europe.", + "historyProgress": "Récupération de la qualité de l’air : {done} jours sur {total}", + "historyComplete": "La qualité de l’air est complétée pour tous les jours passés avec des saisies que la source couvre ({total} jours). Les jours plus anciens restent vides.", + "attribution": "Sources de données et licences", + "saveError": "Le réglage n’a pas pu être enregistré.", + "latest": "Jour le plus récent ({date})", + "modelNote": "Valeurs extérieures modélisées pour ta zone, pas ton exposition personnelle." + } }, "score": { "title": "Score de santé", @@ -5859,7 +5959,6 @@ }, "changePassword": "Changer le mot de passe", "changePasswordDescription": "Remplacez votre mot de passe actuel par un nouveau.", - "passwordReset": "Réinitialisation du mot de passe", "currentPassword": "Mot de passe actuel", "newPassword": "Nouveau mot de passe", "confirmNewPassword": "Confirmer le nouveau mot de passe", @@ -5904,9 +6003,9 @@ "botToken": "Jeton du bot", "chatId": "ID du chat", "enableNotifications": "Activer les notifications", - "telegramStep1": "1. Créez un bot via @BotFather dans Telegram et copiez le jeton.", - "telegramStep2": "2. Envoyez /start à votre bot pour activer le chat.", - "telegramStep3": "3. Trouvez votre ID de chat via @userinfobot ou l’API du bot.", + "telegramStep1": "Créez un bot via @BotFather dans Telegram et copiez le jeton.", + "telegramStep2": "Envoyez /start à votre bot pour activer le chat.", + "telegramStep3": "Trouvez votre ID de chat via @userinfobot ou l’API du bot.", "ntfy": "ntfy", "ntfyDescription": "Notifications via ntfy (auto-hébergé ou ntfy.sh).", "ntfyEnable": "Activer ntfy", @@ -5915,7 +6014,7 @@ "ntfyAuthToken": "Jeton d’authentification (facultatif)", "ntfyAuthTokenHint": "Nécessaire uniquement pour les sujets privés avec contrôle d’accès.", "webhook": "Webhook", - "webhookDescription": "Envoyer les notifications par POST vers n’importe quelle URL — Gotify, Discord, Slack, Matrix ou Home Assistant.", + "webhookDescription": "Notifications par POST vers une URL : Discord, Slack, Gotify, Home Assistant, n8n ou tout relais acceptant du JSON.", "webhookEnable": "Activer le webhook", "webhookUrl": "URL du webhook", "webhookUrlHint": "L’URL complète pour le POST. Une adresse sur un réseau privé doit être autorisée par l’administrateur du serveur.", @@ -5923,6 +6022,7 @@ "webhookHeaderValue": "Valeur d’en-tête (facultatif)", "webhookFormat": "Format du message", "webhookFormatGeneric": "JSON générique", + "webhookFormatGenericHint": "Collez telle quelle une URL de webhook Discord ou Slack. Home Assistant et n8n lisent les champs title, message, eventType et priority.", "webhookFormatGotify": "Gotify", "webhookFormatGotifyHint": "Utilisez l’URL se terminant par /message et placez le jeton de l’application dans l’en-tête X-Gotify-Key.", "email": "E-mail", @@ -5939,7 +6039,6 @@ "webPushSubscribed": "Notifications push activées !", "webPushUnsubscribed": "Notifications push désactivées.", "webPushSubscribeFailed": "Échec de l’activation", - "kiInsightsDescription": "Facultatif : enregistrez votre clé OpenAI pour des analyses quotidiennes et automatiques dans Insights.", "codexConnected": "ChatGPT connecté ! Insights est maintenant actif.", "codexDisconnected": "Connexion à ChatGPT supprimée.", "codexConnectionFailed": "Échec de la connexion à ChatGPT. Veuillez réessayer.", @@ -5949,7 +6048,7 @@ "rawDataWarning": "Historique détaillé activé : chaque analyse envoie aussi les moyennes journalières des 90 derniers jours, les moyennes hebdomadaires de l’année précédente et les moyennes mensuelles jusqu’à 5 ans en arrière.", "regenerateInsights": "Régénérer les rapports", "regenerateSuccess": "Rapports régénérés", - "regenerateRateLimit": "Veuillez patienter — vous avez atteint la limite horaire d’analyses.", + "regenerateRateLimit": "Veuillez patienter : vous avez atteint la limite horaire d’analyses.", "lastGeneratedAt": "Dernière génération", "ai": { "chatgptConnectedBadge": "ChatGPT connecté", @@ -5959,11 +6058,11 @@ "deviceCodeHeading": "Terminez la connexion sur chatgpt.com", "deviceCodeStep1": "Ouvrez ce lien sur n’importe quel appareil :", "deviceCodeStep2": "Saisissez ce code à usage unique :", - "deviceCodeStep3": "Approuvez la connexion — cette page se met à jour automatiquement.", + "deviceCodeStep3": "Approuvez la connexion. Cette page se met à jour automatiquement.", "deviceCodeCopy": "Copier", "deviceCodeWaiting": "En attente d’approbation…", "deviceCodeCancel": "Annuler", - "oauthNotConfigured": "L’OAuth ChatGPT n’est pas configuré sur cette instance — utilisez plutôt votre propre clé API ci-dessous.", + "oauthNotConfigured": "L’OAuth ChatGPT n’est pas configuré sur cette instance. Utilisez plutôt votre propre clé API ci-dessous.", "modelLabel": "Modèle", "modelOptionDefault": "— Par défaut —", "modelOptionCustom": "Personnalisé…", @@ -5971,7 +6070,7 @@ "anthropicKeyLabel": "Clé API Anthropic", "baseUrlLabel": "Base URL", "localKeyLabel": "Clé API (facultative)", - "localDescription": "Tout point de terminaison compatible OpenAI fonctionne ici — un modèle local (Ollama, LM Studio) comme une passerelle telle que LiteLLM, OpenRouter ou vLLM. L'URL de base se termine par /v1 ; la clé API est facultative.", + "localDescription": "Tout point de terminaison compatible OpenAI fonctionne ici : un modèle local (Ollama, LM Studio) comme une passerelle telle que LiteLLM, OpenRouter ou vLLM. L'URL de base se termine par /v1 ; la clé API est facultative.", "savedPreview": "(enregistrée {preview})", "savedShort": "(enregistrée)", "saveCta": "Enregistrer", @@ -5987,7 +6086,7 @@ "admin-openai": "OpenAI administrateur" }, "title": "Chaîne de secours", - "description": "Si le fournisseur principal échoue, HealthLog parcourt la chaîne dans l’ordre. Faites glisser les lignes pour les réorganiser ; utilisez l’interrupteur pour désactiver un fournisseur sans le supprimer.", + "description": "Si le fournisseur actif échoue, HealthLog essaie le suivant activé, dans cet ordre.", "moveUp": "Monter", "moveDown": "Descendre", "removeFromChain": "Retirer de la chaîne", @@ -6000,11 +6099,8 @@ "resetConfirmTitle": "Réinitialiser la chaîne aux valeurs par défaut ?", "resetConfirmBody": "La chaîne revient à Codex → OpenAI → Anthropic → Local → OpenAI administrateur. Vos identifiants enregistrés ne sont pas affectés." }, - "providerCardTitle": "Fournisseur d'IA", "activeProviderHeading": "Fournisseur actif", - "activeProviderBody": "Choisissez le fournisseur à utiliser en premier. Le formulaire ci-dessous configure uniquement le fournisseur sélectionné ; la chaîne de secours en bas détermine ce qui se passe en cas d’échec.", "activeProviderLabel": "Fournisseur principal", - "providerConfigTitle": "Configuration du fournisseur", "providerSelect": { "codex": "Compte ChatGPT (Codex)", "openai": "OpenAI (votre clé API)", @@ -6014,7 +6110,7 @@ "admin-openai": "OpenAI fourni par l’administrateur" }, "compat": { - "description": "Un fournisseur à part entière pour tout point de terminaison qui parle l'API /v1/chat/completions d'OpenAI : LiteLLM, OpenRouter, vLLM ou une autre passerelle. L'URL de base se termine par /v1, la clé API est facultative et le modèle est celui que votre passerelle achemine. Votre clé OpenAI n'atteint jamais ce point de terminaison — ce fournisseur a la sienne.", + "description": "Un fournisseur à part entière pour tout point de terminaison qui parle l'API /v1/chat/completions d'OpenAI : LiteLLM, OpenRouter, vLLM ou une autre passerelle. L'URL de base se termine par /v1, la clé API est facultative et le modèle est celui que votre passerelle achemine. Votre clé OpenAI n'atteint jamais ce point de terminaison : ce fournisseur a la sienne.", "keyLabel": "Clé API (facultative)", "modelHint": "Le nom de modèle attendu par votre passerelle. Laissé vide, le modèle enregistré pour vos autres fournisseurs est utilisé ; si aucun n'est défini, ce fournisseur est ignoré." }, @@ -6050,18 +6146,18 @@ "notConfigured": "L’opérateur n’a pas configuré de clé OpenAI partagée sur cette instance." }, "testProvider": "Tester le fournisseur actif", - "testSuccess": "OK — {provider} ({model})", + "testSuccess": "OK : {provider} ({model})", "testFailedShort": "Échec du test : {message}", - "testUnexpectedResponse": "Échec de la connexion au fournisseur d’IA — réponse inattendue du serveur.", - "testReasonCredentials": "Le fournisseur a rejeté les identifiants — réauthentifiez-vous dans les réglages IA.", - "testReasonRateLimited": "Le fournisseur a limité la requête — réessayez sous peu.", + "testUnexpectedResponse": "Échec de la connexion au fournisseur d’IA : réponse inattendue du serveur.", + "testReasonCredentials": "Le fournisseur a rejeté les identifiants. Réauthentifiez-vous dans les réglages IA.", + "testReasonRateLimited": "Le fournisseur a limité la requête. Réessayez sous peu.", "testReasonServerError": "Le fournisseur d’IA a renvoyé une erreur serveur.", - "testReasonBadRequest": "Le fournisseur a rejeté la requête (HTTP {status}) — le point de terminaison a répondu ; le problème vient donc du format de la requête ou du nom du modèle, pas de la connexion.", + "testReasonBadRequest": "Le fournisseur a rejeté la requête (HTTP {status}). Le point de terminaison a répondu ; le problème vient donc du format de la requête ou du nom du modèle, pas de la connexion.", "testReasonUnreachable": "Impossible de joindre le fournisseur d’IA.", "coachMemory": { "title": "Ce dont le Coach se souvient", "description": "Les informations durables que vous avez confiées au Coach.", - "empty": "Le Coach n'a encore enregistré aucune information. Au fil de vos échanges, il retient les détails durables que vous partagez — vos objectifs, vos affections, vos préférences — et vous pouvez les effacer ici à tout moment.", + "empty": "Le Coach n'a encore enregistré aucune information. Au fil de vos échanges, il retient les détails durables que vous partagez (vos objectifs, vos affections, vos préférences) et vous pouvez les effacer ici à tout moment.", "learnedPrefix": "appris {when}", "categoryPreference": "Préférences", "categoryCondition": "Affections", @@ -6077,7 +6173,7 @@ "forgetAllConfirmBody": "Cela supprime toutes les informations enregistrées. Le Coach repartira de zéro lors de votre prochaine conversation. Cette action est irréversible.", "forgetAllConfirmAction": "Tout oublier", "cancel": "Annuler", - "summaryNote": "Le Coach peut aussi conserver un bref résumé évolutif de chaque conversation. Il n'existe pas de commande individuelle pour cela — supprimer une conversation supprime son résumé.", + "summaryNote": "Le Coach peut aussi conserver un bref résumé évolutif de chaque conversation. Il n'existe pas de commande individuelle pour cela : supprimer une conversation supprime son résumé.", "loadError": "Impossible de charger ce dont le Coach se souvient.", "forgotToast": "Information oubliée.", "forgotError": "Impossible d'oublier cette information. Veuillez réessayer.", @@ -6098,7 +6194,7 @@ "markDone": "Terminé", "dismiss": "Ignorer", "deleteAria": "Supprimer ce rappel", - "note": "Les rappels sont stockés chiffrés. Supprime-en un à tout moment — il disparaît de ce que le Coach peut voir.", + "note": "Les rappels sont stockés chiffrés. Supprime-en un à tout moment : il disparaît de ce que le Coach peut voir.", "status": { "proposed": "Suggéré", "active": "Enregistré", @@ -6145,19 +6241,19 @@ "title": "Lire les documents automatiquement avec l'IA", "subLabel": "Les documents sont lus et indexés par votre fournisseur d'IA configuré, sans validation document par document.", "honesty": "Lorsque cette option est activée, chaque document que vous téléversez est envoyé à votre fournisseur d'IA configuré pour être lu, décrit et indexé pour la recherche, sans confirmation par document. L'activation agit aussi rétroactivement : les documents que vous avez déjà téléversés et qui n'ont pas encore de résumé sont envoyés eux aussi, jusqu'à 200 à chaque activation. Cela se produit sur le serveur et le contenu du document quitte cette machine vers ce fournisseur. Si votre fournisseur configuré est une connexion par abonnement (un compte d'IA connecté plutôt qu'une clé API), ses propres paramètres de données s'appliquent : les fournisseurs par abonnement peuvent utiliser le contenu pour améliorer leurs modèles, et aucun accord de traitement des données ne le couvre. Les documents lus par un modèle local auto-hébergé ne quittent jamais votre serveur. Désactivé par défaut ; activez-le uniquement si vous acceptez ce compromis pour vos documents.", - "confirm": "Compris — activer", + "confirm": "Compris, activer", "cancel": "Annuler" }, "centralCodex": { "title": "Utiliser l'accès IA partagé du serveur", - "subLabel": "Acheminez les requêtes IA via la connexion que l'opérateur a configurée pour ce serveur, en complément de vos propres fournisseurs comme solution de repli.", + "subLabel": "Les requêtes IA passent par la connexion partagée du serveur, vos propres fournisseurs servant de repli.", "honesty": "L'accès partagé est un unique compte IA connecté que l'opérateur a relié pour tout le monde sur ce serveur, et non votre propre clé. Les requêtes que vous y faites servent par défaut à entraîner les modèles du fournisseur et aucun accord de traitement des données ne les couvre, les limites horaires et hebdomadaires du compte sont partagées avec tous les autres qui ont accepté, et vos propres fournisseurs sont essayés en premier. Ne l'activez que si vous acceptez ce compromis.", - "confirm": "Compris — utiliser la connexion", + "confirm": "Compris, utiliser la connexion", "cancel": "Annuler" }, "consent": { "title": "Consentement IA", - "activeSince": "Donné le {date}. Tes données de santé peuvent être transmises au fournisseur configuré pour les fonctions IA.", + "activeSince": "Donné le {date} : vos données de santé peuvent être transmises au fournisseur configuré pour les fonctions IA.", "withdrawn": "Retiré. Sauf si un autre consentement le couvre, le Coach et Insights n'utilisent plus l'accès IA fourni par ce serveur, et aucun modèle en ligne ne lit vos documents. Avec votre propre clé ou un modèle local, le Coach et Insights continuent de fonctionner.", "withdraw": "Retirer le consentement", "confirmBody": "Sauf si un autre consentement les couvre encore, ceci s'arrête immédiatement : le Coach et Insights lorsqu'ils utilisent l'accès IA fourni par ce serveur, et la lecture de documents par un modèle en ligne. Avec votre propre clé ou un modèle local, le Coach et Insights continuent de fonctionner. Sauf si un autre consentement couvre encore Insights, les notes qu'un modèle a écrites pour vous sont supprimées : notes d'état, briefing quotidien, résumés de période écrits par un modèle, lignes d'arrivée et notes d'entraînement. Vos conversations avec le Coach, ce que le Coach a retenu, vos plans et les résumés de vos documents sont conservés, et vous pouvez les lire ou les supprimer vous-même.", @@ -6168,7 +6264,7 @@ "error": "Le retrait n'a pas pu être enregistré. Réessaie.", "documentReading": { "title": "Consentement à la lecture de documents", - "activeSince": "Donné le {date}. Les documents, les comptes rendus d'analyses numérisés et les descriptions de médicaments peuvent être envoyés à votre service d'IA pour lecture.", + "activeSince": "Donné le {date} : documents, analyses numérisées et textes de médicaments peuvent être lus par votre service d'IA.", "confirmBody": "Les documents, les comptes rendus d'analyses numérisés et les descriptions de médicaments ne sont plus envoyés pour lecture. Rien de ce que vous avez déjà n'est supprimé." } }, @@ -6202,7 +6298,9 @@ "high": "Élevé" }, "coachNote": "Dans le Coach, c'est la profondeur de réflexion des réglages du Coach qui s'applique." - } + }, + "providerCardDescription": "Le fournisseur que HealthLog sollicite en premier, avec sa clé et son modèle.", + "runtimeTitle": "Vérifier et actualiser" }, "dashboard": { "units": { @@ -6226,8 +6324,8 @@ "withingsClientId": "ID client", "withingsClientSecret": "Secret client", "withingsCredentialsSaved": "Identifiants enregistrés", - "withingsCredentialsSavedPlaceholder": "Enregistré — saisissez-en un nouveau pour remplacer", - "withingsCredentialsSavedPlaceholderSecret": "Enregistré — saisissez-en un nouveau pour remplacer", + "withingsCredentialsSavedPlaceholder": "Enregistré. Saisissez-en un nouveau pour remplacer", + "withingsCredentialsSavedPlaceholderSecret": "Enregistré. Saisissez-en un nouveau pour remplacer", "withingsSaveCredentials": "Enregistrer", "configured": "Configuré", "withingsSync": "Synchroniser maintenant", @@ -6242,7 +6340,7 @@ }, "withingsFullSyncResult": "{count} mesures entièrement synchronisées", "withingsSyncFailed": "Échec de la synchronisation", - "withingsOauthConnected": "Withings connecté — la première synchronisation démarre en arrière-plan.", + "withingsOauthConnected": "Withings connecté. La première synchronisation démarre en arrière-plan.", "withingsOauthFailed": "Échec de la connexion Withings", "withingsOauthError": { "csrf1": "Le contrôle de sécurité a échoué car le cookie du navigateur manquait. Relancez la connexion dans le même navigateur.", @@ -6267,8 +6365,8 @@ "whoopClientId": "Client ID", "whoopClientSecret": "Client Secret", "whoopCredentialsSaved": "Identifiants enregistrés", - "whoopCredentialsSavedPlaceholder": "Enregistré — saisir de nouveaux pour remplacer", - "whoopCredentialsSavedPlaceholderSecret": "Enregistré — saisir de nouveaux pour remplacer", + "whoopCredentialsSavedPlaceholder": "Enregistré. Saisir de nouveaux pour remplacer", + "whoopCredentialsSavedPlaceholderSecret": "Enregistré. Saisir de nouveaux pour remplacer", "whoopSaveCredentials": "Enregistrer", "whoopSync": "Synchroniser", "whoopFullSync": "Synchroniser toutes les données", @@ -6283,7 +6381,7 @@ "whoopConnect": "Se connecter avec WHOOP", "whoopNoCredentials": "Veuillez saisir vos identifiants API ci-dessus pour connecter WHOOP.", "whoopBackfillInProgress": "Importation de votre historique WHOOP en arrière-plan…", - "whoopOauthConnected": "WHOOP connecté — la première synchronisation démarre en arrière-plan.", + "whoopOauthConnected": "WHOOP connecté. La première synchronisation démarre en arrière-plan.", "whoopOauthFailed": "Échec de la connexion à WHOOP", "whoopOauthError": { "csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était absent. Veuillez relancer la connexion dans le même navigateur.", @@ -6297,14 +6395,14 @@ }, "fitbit": "Fitbit", "fitbitTag": "Fitbit et Pixel", - "fitbitDescription": "Connecte Fitbit pour synchroniser tes données Fitbit et Pixel Watch — poids, fréquence cardiaque et plus.", + "fitbitDescription": "Connecte Fitbit pour synchroniser tes données Fitbit et Pixel Watch : poids, fréquence cardiaque et plus.", "fitbitExperimentalBadge": "Expérimental", "fitbitCredentials": "Identifiants d'API", "fitbitClientId": "Client ID", "fitbitClientSecret": "Client Secret", "fitbitCredentialsSaved": "Identifiants enregistrés", - "fitbitCredentialsSavedPlaceholder": "Enregistré — saisissez-en de nouveaux pour remplacer", - "fitbitCredentialsSavedPlaceholderSecret": "Enregistré — saisissez-en de nouveaux pour remplacer", + "fitbitCredentialsSavedPlaceholder": "Enregistré. Saisissez-en de nouveaux pour remplacer", + "fitbitCredentialsSavedPlaceholderSecret": "Enregistré. Saisissez-en de nouveaux pour remplacer", "fitbitSaveCredentials": "Enregistrer", "fitbitSync": "Synchroniser maintenant", "fitbitFullSync": "Tout synchroniser", @@ -6319,18 +6417,18 @@ "fitbitConnect": "Se connecter à Fitbit", "fitbitNoCredentials": "Veuillez saisir vos identifiants d'API ci-dessus pour connecter Fitbit.", "fitbitBackfillInProgress": "Importation de votre historique Fitbit en arrière-plan…", - "fitbitOauthConnected": "Fitbit connecté — la première synchronisation démarre en arrière-plan.", + "fitbitOauthConnected": "Fitbit connecté. La première synchronisation démarre en arrière-plan.", "fitbitOauthFailed": "Échec de la connexion à Fitbit", "googleHealth": "Google Health", "googleHealthTag": "Fitbit et Pixel", "googleHealthBetaBadge": "Bêta", - "googleHealthDescription": "Connecte Fitbit, Pixel Watch et Fitbit Air via ton compte Google — poids, fréquence cardiaque, sommeil et activité. Cela remplace la connexion Fitbit classique, que Google arrête en septembre 2026.", + "googleHealthDescription": "Connecte Fitbit, Pixel Watch et Fitbit Air via ton compte Google : poids, fréquence cardiaque, sommeil et activité. Cela remplace la connexion Fitbit classique, que Google arrête en septembre 2026.", "googleHealthCredentials": "Identifiants d'API", "googleHealthClientId": "ID client", "googleHealthClientSecret": "Secret client", "googleHealthCredentialsSaved": "Identifiants enregistrés", - "googleHealthCredentialsSavedPlaceholder": "Enregistré — saisir de nouveaux pour remplacer", - "googleHealthCredentialsSavedPlaceholderSecret": "Enregistré — saisir de nouveaux pour remplacer", + "googleHealthCredentialsSavedPlaceholder": "Enregistré. Saisir de nouveaux pour remplacer", + "googleHealthCredentialsSavedPlaceholderSecret": "Enregistré. Saisir de nouveaux pour remplacer", "googleHealthSaveCredentials": "Enregistrer", "googleHealthSync": "Synchroniser maintenant", "googleHealthSyncCheckStatus": "Actualiser le statut", @@ -6364,9 +6462,9 @@ "googleHealthConnect": "Se connecter avec Google", "googleHealthNoCredentials": "Saisis tes identifiants d'API ci-dessus pour connecter Google Health.", "googleHealthBackfillInProgress": "Importation de ton historique Google Health en arrière-plan…", - "googleHealthReauthBanner": "Google a expiré la connexion — cela arrive régulièrement tant que ton application OAuth reste en mode Test. Reconnecte-toi pour reprendre la synchronisation.", + "googleHealthReauthBanner": "Google a expiré la connexion. Cela arrive régulièrement tant que ton application OAuth reste en mode Test. Reconnecte-toi pour reprendre la synchronisation.", "googleHealthReconnect": "Se reconnecter", - "googleHealthOauthConnected": "Google Health connecté — la première synchronisation démarre en arrière-plan.", + "googleHealthOauthConnected": "Google Health connecté. La première synchronisation démarre en arrière-plan.", "googleHealthOauthFailed": "Échec de la connexion à Google Health", "googleHealthOauthError": { "csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était manquant. Relance la connexion dans le même navigateur.", @@ -6401,10 +6499,10 @@ }, "integrationPill": { "connected": "Connecté", - "errorReconnect": "Erreur — reconnecter", + "errorReconnect": "Erreur : reconnecter", "warningServerError": "Échec de synchronisation", "failureCount": "{count}/{threshold}", - "parkedReconnect": "En pause — reconnecter", + "parkedReconnect": "En pause : reconnecter", "notConnected": "Non connecté", "justNow": "à l’instant", "minutesAgo": "il y a {count} min", @@ -6419,7 +6517,7 @@ }, "integrationFreshness": { "title": "Données reçues", - "summary": "{count} types de données · plus récent {relative}", + "summary": "{count} types de données, plus récent {relative}", "quiet": "{count} silencieux", "workouts": "Séances" }, @@ -6433,31 +6531,33 @@ "fitbitViewData": "Voir votre sommeil", "googleHealthViewData": "Voir vos pas", "apiTokens": "Jetons API", - "apiTokensDescription": "Consultez et révoquez les jetons API pour l’enregistrement externe des prises de médicaments.", + "apiTokensDescription": "Consultez et révoquez les jetons des mesures, entraînements, documents et prises de médicaments.", "measurementsToken": { "title": "Jetons de mesures", "description": "Créez un jeton pour qu'une balance, une montre ou votre domotique ajoutent des mesures.", "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." + "createFailed": "Le jeton n'a pas pu être créé. Veuillez réessayer.", + "namePlaceholder": "Nom, p. ex. Home Assistant" }, "workoutsToken": { "title": "Jetons d'entraînements", "description": "Créez un jeton pour qu'une passerelle de montre ou un script de synchronisation ajoutent vos entraînements.", "detail": "Le jeton n'est affiché qu'une fois.", "scopeNote": "Il peut uniquement ajouter de nouveaux entraînements à 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 entraînements qu'il ajoute sont signalés comme provenant d'un appareil externe, et si votre téléphone a enregistré la même séance, c'est la copie du téléphone que vous voyez.", - "createFailed": "Le jeton n'a pas pu être créé. Veuillez réessayer." + "createFailed": "Le jeton n'a pas pu être créé. Veuillez réessayer.", + "namePlaceholder": "Nom, p. ex. Garmin" }, "documentsToken": { "title": "Jetons de documents", "description": "Créez un jeton pour que Paperless-ngx, Papra ou le script d'import ajoutent des documents à votre coffre.", "detail": "Le jeton n'est affiché qu'une fois.", "scopeNote": "Il peut uniquement ajouter de nouveaux documents à votre propre coffre. Il ne peut rien lister, ouvrir, télécharger, modifier ou supprimer, ni créer un autre jeton. Il apprend seulement si un fichier a été enregistré, pas comment vous l'avez classé.", - "createFailed": "Le jeton n'a pas pu être créé. Veuillez réessayer." + "createFailed": "Le jeton n'a pas pu être créé. Veuillez réessayer.", + "namePlaceholder": "Nom, p. ex. Paperless-ngx" }, - "tokenMintMovedDescription": "Les jetons sont émis là où ils s'appliquent : ci-dessus pour les mesures et les documents, ou dans le point de terminaison API d'un médicament pour ce seul médicament.", - "tokenNamePlaceholder": "Nom du jeton (par exemple Raccourci iPhone)", - "tokenCreated": "Jeton créé — copiez-le maintenant ! Il ne sera plus affiché.", + "tokenMintMovedDescription": "Les jetons sont émis là où ils s'appliquent : ci-dessus pour les mesures, les entraînements et les documents, ou dans le point de terminaison API d'un médicament pour ce seul médicament.", + "tokenCreated": "Jeton créé, copiez-le maintenant ! Il ne sera plus affiché.", "tokenCopy": "Copier le jeton", "tokenCopied": "Jeton copié dans le presse-papiers", "tokenCopyFailed": "Impossible de copier le jeton", @@ -6496,7 +6596,7 @@ "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.", - "deleteAccountSuccess": "Compte supprimé — déconnexion en cours.", + "deleteAccountSuccess": "Compte supprimé, déconnexion en cours.", "deleteAccountFailed": "Le compte n’a pas pu être supprimé", "deleteAccountFinal": "Supprimer définitivement", "saved": "Enregistré", @@ -6519,7 +6619,7 @@ "testing": "Test en cours…", "ok": "Connecté (latence {latency} ms)", "errors": { - "credentials_rejected": "Identifiants rejetés — vérifiez votre jeton", + "credentials_rejected": "Identifiants rejetés : vérifiez votre jeton", "rate_limited": "Limité par le service en amont", "timeout": "Délai de la requête dépassé", "upstream_error": "Le service en amont a renvoyé une erreur", @@ -6530,7 +6630,7 @@ "private_origin_not_approved": "Cible sur un réseau privé. L’opérateur doit ajouter cette origine à NOTIFICATION_PRIVATE_ORIGINS", "private_origin_not_grantable": "Cette adresse est une adresse de lien local, de métadonnées ou non spécifiée ; aucune autorisation de l’opérateur ne peut l’ouvrir. Utilisez l’adresse sur laquelle le relais écoute", "url_invalid": "L’URL n’est pas valide", - "redirected": "Le point de terminaison redirige — vérifiez l’URL", + "redirected": "Le point de terminaison redirige : vérifiez l’URL", "endpoint_not_found": "Point de terminaison introuvable à l’URL", "credentials_unreadable": "Les identifiants ne peuvent pas être déchiffrés", "upstream_invalid_json": "Le service en amont a envoyé un JSON non valide", @@ -6541,7 +6641,7 @@ }, "notificationStatus": { "title": "Fiabilité des canaux", - "description": "État de chaque canal — « Désactivé automatiquement » signifie arrêté après des erreurs permanentes.", + "description": "État de chaque canal ; « Désactivé automatiquement » signifie arrêté après des erreurs permanentes.", "emptyDescription": "Ajoutez un canal dans Paramètres › Intégrations › Canaux de diffusion.", "stateActive": "Actif", "stateAutoDisabled": "Désactivé automatiquement", @@ -6567,7 +6667,7 @@ }, "healthRecord": { "title": "Exporter le dossier de santé", - "description": "Un dossier complet pour votre médecin — en PDF, en FHIR ou les deux.", + "description": "Un dossier complet pour votre médecin, en PDF, en FHIR ou les deux.", "format": "Format", "format_pdf": "PDF", "format_fhir": "Structuré (FHIR)", @@ -6599,7 +6699,7 @@ "createTitle": "Nouveau lien de partage", "createDescription": "Le lien est en lecture seule et limité dans le temps.", "label": "Libellé", - "labelPlaceholder": "ex. Dr Martin — bilan annuel", + "labelPlaceholder": "ex. Dr Martin, bilan annuel", "range": "Jours d'historique", "rangeHint": "Jusqu'où remonte la fenêtre partagée.", "expiry": "Expire dans (jours)", @@ -6619,7 +6719,7 @@ "statusRevoked": "Révoqué", "statusExpired": "Expiré", "inactiveTitle": "Révoqués et expirés ({count})", - "shownOnce": "Affiché une seule fois. Enregistrez le code QR et la phrase secrète maintenant — ils ne pourront pas être récupérés ensuite.", + "shownOnce": "Affiché une seule fois. Enregistrez le code QR et la phrase secrète maintenant : ils ne pourront pas être récupérés ensuite.", "qrAlt": "Code QR contenant le lien de partage et la phrase secrète", "linkLabel": "Lien", "passphraseLabel": "Phrase secrète", @@ -6630,12 +6730,12 @@ "attachTitle": "Joindre des documents", "attachButton": "Choisir des documents", "attachCount": "{count} sur {max} sélectionnés", - "attachFrozen": "La sélection de documents est figée à la création du lien — impossible d'ajouter ou de retirer des documents ensuite.", + "attachFrozen": "La sélection de documents est figée à la création du lien : impossible d'ajouter ou de retirer des documents ensuite.", "attachRemove": "Retirer {title}", "exifNote": "Les métadonnées de l'appareil photo (EXIF/GPS) sont supprimées des images JPEG, PNG et WebP lors du partage. Les fichiers PDF, TIFF, HEIC et Office sont transmis tels quels.", "documentCount": "Documents joints : {count}", "qrScanTitle": "Scannez pour ouvrir sur un autre appareil", - "qrScanHint": "Pointez l'appareil photo d'un téléphone vers le code pour ouvrir le dossier partagé — la phrase secrète est déjà incluse.", + "qrScanHint": "Pointez l'appareil photo d'un téléphone vers le code pour ouvrir le dossier partagé. La phrase secrète est déjà incluse.", "pickerTitle": "Joindre des documents", "pickerDescription": "Recherchez dans votre coffre et sélectionnez les documents à partager. Jusqu'à {max}.", "pickerSearch": "Rechercher des documents", @@ -6648,7 +6748,8 @@ "statusNeedsReselect": "Choisir à nouveau le contenu", "reselectReason": "Ce lien diffusait un périmètre que personne n'avait choisi ; il a donc été fermé. Créez-en un nouveau et choisissez son contenu.", "reselectAction": "Créer un remplacement", - "createDetail": "Le jeton n'est affiché qu'une fois à la création — conservez-le alors ; il ne peut pas être récupéré." + "createDetail": "Le jeton n'est affiché qu'une fois à la création. Conservez-le alors ; il ne peut pas être récupéré.", + "activeDescription": "Les liens que vous avez partagés, leur expiration et le nombre d'ouvertures." }, "genderOther": "Autre", "nightscout": "Nightscout", @@ -6656,10 +6757,10 @@ "nightscoutDescription": "Connecte ton propre site Nightscout pour synchroniser les mesures de glucose en continu dans le panneau glucose.", "nightscoutViewData": "Voir votre glycémie", "nightscoutUrl": "URL Nightscout", - "nightscoutUrlSavedPlaceholder": "Enregistré — saisissez une nouvelle URL pour la remplacer", + "nightscoutUrlSavedPlaceholder": "Enregistré. Saisissez une nouvelle URL pour la remplacer", "nightscoutToken": "Jeton d'API", "nightscoutTokenOptional": "Facultatif pour un site public", - "nightscoutTokenSavedPlaceholder": "Enregistré — saisissez un nouveau jeton pour le remplacer", + "nightscoutTokenSavedPlaceholder": "Enregistré. Saisissez un nouveau jeton pour le remplacer", "nightscoutTokenHelp": "Collez un jeton d'accès depuis les outils d'administration de Nightscout. Laissez vide si votre site est lisible publiquement.", "nightscoutPrivateOperatorTitle": "L’accès à un Nightscout privé nécessite l’autorisation de l’administrateur du serveur.", "nightscoutPrivateOperatorDescription": "Demandez à l’administrateur du serveur d’autoriser précisément cette origine Nightscout.", @@ -6680,7 +6781,7 @@ "polarClientId": "Client ID", "polarClientSecret": "Client Secret", "polarCredentialsSaved": "Identifiants enregistrés", - "polarCredentialsSavedPlaceholder": "Enregistré — saisir de nouveaux pour remplacer", + "polarCredentialsSavedPlaceholder": "Enregistré. Saisir de nouveaux pour remplacer", "polarSaveCredentials": "Enregistrer", "polarUnavailable": "Polar n'est pas configuré sur ce serveur. Ajoutez vos propres identifiants OAuth Polar ci-dessus, ou demandez à votre administrateur de configurer l'application partagée.", "polarConnect": "Connecter Polar", @@ -6690,7 +6791,7 @@ "polarDisconnect": "Déconnecter", "polarDisconnectTitle": "Déconnecter Polar ?", "polarDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures déjà importées restent dans votre historique.", - "polarOauthConnected": "Polar connecté — la première synchronisation démarre en arrière-plan.", + "polarOauthConnected": "Polar connecté. La première synchronisation démarre en arrière-plan.", "polarOauthFailed": "Échec de la connexion à Polar", "polarOauthError": { "csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était absent. Veuillez relancer la connexion dans le même navigateur.", @@ -6709,7 +6810,7 @@ "ouraClientId": "Client ID", "ouraClientSecret": "Client Secret", "ouraCredentialsSaved": "Identifiants enregistrés", - "ouraCredentialsSavedPlaceholder": "Enregistré — saisir de nouveaux pour remplacer", + "ouraCredentialsSavedPlaceholder": "Enregistré. Saisir de nouveaux pour remplacer", "ouraSaveCredentials": "Enregistrer", "ouraUnavailable": "Oura n'est pas configuré sur ce serveur. Ajoutez vos propres identifiants OAuth Oura ci-dessus, ou demandez à votre administrateur de configurer l'application partagée.", "ouraConnect": "Connecter Oura", @@ -6719,7 +6820,7 @@ "ouraDisconnect": "Déconnecter", "ouraDisconnectTitle": "Déconnecter Oura ?", "ouraDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures déjà importées restent dans votre historique.", - "ouraOauthConnected": "Oura connecté — la première synchronisation démarre en arrière-plan.", + "ouraOauthConnected": "Oura connecté. La première synchronisation démarre en arrière-plan.", "ouraOauthFailed": "Échec de la connexion à Oura", "ouraOauthError": { "csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était absent. Veuillez relancer la connexion dans le même navigateur.", @@ -6750,7 +6851,7 @@ } }, "integrationRedirectGuide": { - "intro": "Cette connexion passe par votre propre application développeur {provider} — environ 5 minutes de configuration :", + "intro": "Cette connexion passe par votre propre application développeur {provider}, environ 5 minutes de configuration :", "step1": "Créez une application dans le portail développeur {provider}.", "step2": "Réglez son URL de redirection/callback sur :", "step3": "Collez ci-dessus l'ID client et le secret client fournis." @@ -6810,7 +6911,7 @@ }, "recovery": { "saveNow": "Enregistrez ces codes de récupération maintenant", - "saveNowHint": "Chaque code ne fonctionne qu'une fois. Ils ne sont affichés que cette fois — conservez-les en lieu sûr. Si vous perdez votre authentificateur, un code de récupération est le seul moyen de revenir.", + "saveNowHint": "Chaque code ne fonctionne qu'une fois. Ils ne sont affichés que cette fois : conservez-les en lieu sûr. Si vous perdez votre authentificateur, un code de récupération est le seul moyen de revenir.", "download": "Télécharger", "remainingOne": "{count} code de récupération restant.", "remainingOther": "{count} codes de récupération restants.", @@ -6874,12 +6975,11 @@ }, "stored": { "title": "Ce qui est stocké", - "measurements": "Mesures (poids, tension, pouls, glycémie, sommeil, composition corporelle)", - "medications": "Médicaments et historique des prises", - "moodLabs": "Entrées d'humeur et résultats de laboratoire", - "coach": "Conversations avec le coach", + "measurements": "Mesures : poids, tension, pouls et toute autre valeur enregistrée par vous ou un appareil", "integrations": "Jetons d'intégration (chiffrés)", - "security": "Activité du compte et de sécurité (sessions, journal de connexion)" + "security": "Activité du compte et de sécurité (sessions, journal de connexion)", + "profile": "Votre profil, vos antécédents médicaux et les informations du dossier de santé", + "modules": "Données de chaque module : {list}" }, "retention": { "title": "Conservation", @@ -6910,13 +7010,11 @@ "mcp": { "enableTitle": "Point de terminaison MCP distant", "enableDescription": "Autorisez un assistant compatible MCP (Claude.ai, ChatGPT) à lire vos données de santé via un jeton restreint.", - "enabled": "Activé", - "disabled": "Désactivé", "enableToggleLabel": "Activer le point de terminaison MCP", "endpointLabel": "URL du connecteur", "connectDescription": "Collez cette URL dans la configuration du connecteur personnalisé de votre assistant, puis autorisez avec le jeton ci-dessous ou connectez-vous lorsque vous y êtes invité.", "tokensTitle": "Jetons du connecteur", - "tokensDescription": "Générez un jeton pour connecter un assistant — lecture seule, sauf si vous autorisez l'écriture ci-dessous.", + "tokensDescription": "Générez un jeton pour connecter un assistant, en lecture seule sauf si vous autorisez l'écriture ci-dessous.", "scopeNote": "Les nouveaux jetons ne peuvent que lire vos propres données. L'accès en écriture (health:write) s'accorde par jeton avec le commutateur ci-dessous. Un jeton ne peut jamais atteindre les fonctions d'administration.", "writeScopeToggleLabel": "Autoriser l'enregistrement (health:write)", "writeScopeNote": "Ajoute la portée health:write pour que l'assistant puisse enregistrer de nouvelles mesures et entrées d'humeur dans votre compte. Chaque écriture est d'abord confirmée. Le jeton ne peut toujours pas supprimer ni modifier des entrées existantes, changer les médicaments, ni atteindre les fonctions REST ou d'administration. Laissez désactivé pour un accès en lecture seule.", @@ -6930,7 +7028,8 @@ "connectionRevokeDescription": "Cela met fin immédiatement à l'accès de l'assistant et l'empêche de se renouveler. Une nouvelle autorisation sera nécessaire pour le reconnecter.", "connectionRevokeFailed": "Impossible de déconnecter l'assistant.", "tokensDetail": "Le jeton n'est affiché qu'une seule fois.", - "connectionsDetail": "Révoquer une connexion met fin à son accès immédiatement, y compris sa capacité à se renouveler." + "connectionsDetail": "Révoquer une connexion met fin à son accès immédiatement, y compris sa capacité à se renouveler.", + "tokenNamePlaceholder": "Nom, p. ex. Claude Desktop" }, "strava": "Strava", "stravaDescription": "Connecte ton compte Strava pour synchroniser tes activités avec ton historique d'entraînement.", @@ -6939,7 +7038,7 @@ "stravaClientId": "ID client", "stravaClientSecret": "Secret client", "stravaCredentialsSaved": "Identifiants enregistrés", - "stravaCredentialsSavedPlaceholder": "Enregistrés — saisis-en de nouveaux pour remplacer", + "stravaCredentialsSavedPlaceholder": "Enregistrés. Saisis-en de nouveaux pour remplacer", "stravaSaveCredentials": "Enregistrer", "stravaUnavailable": "Strava n'est pas configuré sur ce serveur. Ajoute tes propres identifiants API Strava ci-dessus ou demande à ton administrateur de configurer l'application partagée.", "stravaConnect": "Connecter Strava", @@ -6949,7 +7048,7 @@ "stravaDisconnect": "Déconnecter", "stravaDisconnectTitle": "Déconnecter Strava ?", "stravaDisconnectDescription": "Les synchronisations futures s'arrêtent. Les activités déjà importées restent dans ton historique.", - "stravaOauthConnected": "Strava connecté — la première synchronisation démarre en arrière-plan.", + "stravaOauthConnected": "Strava connecté. La première synchronisation démarre en arrière-plan.", "stravaOauthFailed": "La connexion à Strava a échoué", "stravaOauthError": { "csrf1": "Le contrôle de sécurité a échoué car le cookie du navigateur était manquant. Relance la connexion dans le même navigateur.", @@ -6977,26 +7076,33 @@ "title": "Progression de la synchronisation", "receivedLabel": "Enregistrements reçus", "oldestLabel": "Mesure la plus ancienne atteinte", - "flowing": "Des données sont arrivées au cours de la dernière heure — l'app iPhone continue d'envoyer.", - "waiting": "En attente de nouvelles données de l'app iPhone. Le transfert reprend quand l'app est ouverte ou quand iOS lui accorde du temps en arrière-plan — une accalmie pendant le premier grand rattrapage est normale, pas une erreur." + "flowing": "Des données sont arrivées au cours de la dernière heure. L'app iPhone continue d'envoyer.", + "waiting": "En attente de nouvelles données de l'app iPhone. Le transfert reprend quand l'app est ouverte ou quand iOS lui accorde du temps en arrière-plan. Une accalmie pendant le premier grand rattrapage est normale, pas une erreur." }, "delivery": { "title": "Livraison", "lastTriggerLabel": "La dernière synchronisation est arrivée", "lastBackgroundLabel": "Dernière synchronisation en arrière-plan", - "backgroundNever": "Jamais — jusqu'ici chaque synchronisation est arrivée avec l'app ouverte", + "backgroundNever": "Jamais : jusqu'ici chaque synchronisation est arrivée avec l'app ouverte", "noSyncYet": "Aucune synchronisation n'est encore arrivée, il n'y a donc rien à signaler ici.", "trigger": { "foreground": "avec l'app ouverte", "background": "en arrière-plan", "push": "depuis un réveil push", - "unknown": "déclencheur non signalé par l'app" + "unknown": "déclencheur non signalé par l'app", + "manual": "via « Tout synchroniser »" } + }, + "freshness": { + "title": "Par type de donnée", + "lastReceived": "Dernière réception {relative}", + "lastNewValue": "Dernière nouvelle valeur {relative}", + "never": "Pas encore reçu" } }, "garminInfo": { "title": "Garmin", - "body": "Garmin n'a pas de connexion directe ici. Connecte-le via Apple Health (iOS) ou Google Health Connect (Android). Les scores propriétaires de Garmin — Body Battery, Training Load, statut VFC, VO₂ max — ne sont partagés par Garmin avec aucune des deux plateformes.", + "body": "Garmin n'a pas de connexion directe ici. Connecte-le via Apple Health (iOS) ou Google Health Connect (Android). Les scores propriétaires de Garmin (Body Battery, Training Load, statut VFC, VO₂ max) ne sont partagés par Garmin avec aucune des deux plateformes.", "docsLink": "Lire le guide" }, "glucoseReference": { @@ -7052,6 +7158,12 @@ "description": "Cette action partage ou exporte vos données de santé. Confirmez qu'il s'agit bien de vous pour continuer.", "signInAgain": "Reconnectez-vous, puis réessayez. Votre compte n'a ni mot de passe ni clé d'accès à confirmer ici.", "failed": "Cela n'a pas fonctionné. Vérifiez votre saisie et réessayez." + }, + "passwordTitle": "Mot de passe", + "tokenNameLabel": "Nom du jeton", + "displayCard": { + "title": "Affichage", + "description": "Langue, unités et formats, enregistrés dès que vous les modifiez." } }, "admin": { @@ -7071,27 +7183,27 @@ "loadingStatus": "Chargement de l'état...", "systemStatusLoadError": "Échec du chargement de l'état du système.", "systemStatusRetry": "Réessayer", - "adminSettingsLoadError": "Échec du chargement des paramètres d'administration. Les commutateurs ci-dessous peuvent ne pas refléter l'état actuel — actualisez la page.", + "adminSettingsLoadError": "Échec du chargement des paramètres d'administration. Les commutateurs ci-dessous peuvent ne pas refléter l'état actuel : actualisez la page.", "hostMetrics": { "title": "Charge de l'hôte", "load1": "Charge (1 min)", "memUsedPercent": "Mémoire", "diskBusyPercent": "Disk I/O", "last2hours": "2 dernières heures", - "empty": "Échantillonnage démarré — les données apparaissent dans 1 à 2 minutes.", + "empty": "Échantillonnage démarré. Les données apparaissent dans 1 à 2 minutes.", "loadError": "Échec du chargement des métriques de l'hôte. Essayez d'actualiser la page." }, "aiServerKey": { "title": "Clé IA du serveur", "description": "Une clé de l'opérateur pour les comptes qui n'en ont pas configuré une.", - "byokHint": "La clé personnelle de l'utilisateur (BYOK, connexion ChatGPT ou fournisseur local) a toujours la priorité — la clé du serveur est la dernière étape de la chaîne de fournisseurs.", + "byokHint": "La clé personnelle de l'utilisateur (BYOK, connexion ChatGPT ou fournisseur local) a toujours la priorité ; la clé du serveur est la dernière étape de la chaîne de fournisseurs.", "consentHint": "Les requêtes via la clé du serveur nécessitent le consentement IA actif de l'utilisateur ; sans justificatif de consentement, rien n'est envoyé.", "baseUrlHint": "L'URL de base détermine l'API : une URL Anthropic telle que https://api.anthropic.com/v1 est reconnue, tout autre hôte est appelé via l'API compatible OpenAI.", "statusConfigured": "Configurée", "statusNotConfigured": "Non configurée", "keyLabel": "Clé API", "keyPlaceholder": "sk-...", - "keyPlaceholderSet": "Enregistrée ({preview}) — saisissez une nouvelle clé pour la remplacer", + "keyPlaceholderSet": "Enregistrée ({preview}). Saisissez une nouvelle clé pour la remplacer", "modelLabel": "Modèle", "baseUrlLabel": "URL de base", "save": "Enregistrer", @@ -7125,13 +7237,16 @@ "typeCodex": "Connexion ChatGPT (personnelle)", "typeLocal": "Modèle local (personnel)", "typeGateway": "Passerelle compatible OpenAI (personnelle)", - "spendToday": "Aujourd'hui ({date}) : {total} jetons, dont {operator} sur la clé de l'opérateur." + "spendToday": "Aujourd'hui ({date}) : {total} jetons, dont {operator} sur la clé de l'opérateur.", + "statusPaused": "En pause", + "pausedHint": "En pause après des échecs répétés pour {count} utilisateurs jusqu’au {at}. Il sera ensuite réessayé automatiquement.", + "modelNotServed": "Le point d’accès ne propose plus le modèle {model}. Choisissez dans les réglages IA un modèle qu’il propose." }, "aiQuality": { "title": "Qualité des analyses", "loading": "Chargement du résumé des retours...", "loadError": "Échec du chargement du résumé de qualité des analyses. Essayez d'actualiser la page.", - "empty": "Aucun retour pour l'instant. L'agrégateur s'exécute chaque jour à 04:00 — dès que les utilisateurs évaluent les recommandations, les taux d'utilité apparaissent ici.", + "empty": "Aucun retour pour l'instant. L'agrégateur s'exécute chaque jour à 04:00 ; dès que les utilisateurs évaluent les recommandations, les taux d'utilité apparaissent ici.", "windowLabel": "{days} derniers jours", "generatedAtLabel": "Généré", "colSeverity": "Gravité", @@ -7146,7 +7261,7 @@ "title": "Retours sur le Coach", "loading": "Chargement du résumé des retours sur le Coach...", "loadError": "Échec du chargement du résumé des retours sur le Coach. Essayez d'actualiser la page.", - "empty": "Aucun retour sur le Coach pour l'instant. L'agrégateur s'exécute chaque jour à 04:00 — dès que les utilisateurs évaluent les réponses du Coach, les taux d'utilité apparaissent ici.", + "empty": "Aucun retour sur le Coach pour l'instant. L'agrégateur s'exécute chaque jour à 04:00 ; dès que les utilisateurs évaluent les réponses du Coach, les taux d'utilité apparaissent ici.", "windowLabel": "{days} derniers jours", "generatedAtLabel": "Généré", "colPromptVersion": "Version du prompt", @@ -7169,9 +7284,9 @@ "defaultLanguage": "Langue par défaut", "defaultLanguageDescription": "Utilisé pour les nouveaux utilisateurs et comme solution de repli.", "defaultUserTimezone": "Fuseau horaire par défaut pour les nouveaux comptes", - "defaultUserTimezoneDescription": "Utilisé pour les nouveaux comptes sans fuseau détecté par le navigateur. N'affecte pas les utilisateurs existants.", + "defaultUserTimezoneDescription": "Utilisé pour les nouveaux comptes sans fuseau détecté par le navigateur ; les comptes existants gardent le leur.", "defaultUserTimezoneFallback": "Europe/Berlin (par défaut)", - "servicesGlobal": "Services (global)", + "servicesGlobal": "Canaux et API", "servicesGlobalDescription": "Les services désactivés ne sont disponibles pour aucun utilisateur.", "telegramGlobal": "Autoriser les notifications Telegram.", "ntfyGlobal": "Autoriser les notifications ntfy.", @@ -7179,19 +7294,21 @@ "integrationWebPush": "Web Push", "offlineGeoLabel": "Données géographiques", "offlineGeoEnabled": "Hors ligne (GeoLite2)", - "offlineGeoFallback": "En ligne ({host}) — montez les bases GeoLite2 et définissez GEOLITE2_DIR", + "offlineGeoFallback": "En ligne ({host}) : montez les bases GeoLite2 et définissez GEOLITE2_DIR", + "offlineGeoOff": "Désactivé", + "offlineGeoChosen": "En ligne ({host})", "apiGlobal": "Autoriser les points de terminaison d'API et les jetons d'API.", "umamiTitle": "Umami", - "umamiDescription": "Analyse web avec Umami", + "umamiDescription": "Analyse web avec Umami.", "glitchtipTitle": "GlitchTip", - "glitchtipDescription": "Suivi des erreurs avec GlitchTip", + "glitchtipDescription": "Suivi des erreurs avec GlitchTip.", "webPushVapidTitle": "Web Push VAPID", - "umamiEnabled": "Activer le suivi Umami.", + "umamiEnabled": "Activer le suivi Umami", "umamiScriptUrl": "URL du script Umami", "umamiScriptUrlPlaceholder": "https://analytics.example.com/script.js", "umamiWebsiteId": "ID du site web Umami", "umamiWebsiteIdPlaceholder": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "glitchtipEnabled": "Activer le suivi des erreurs GlitchTip.", + "glitchtipEnabled": "Activer le suivi des erreurs GlitchTip", "glitchtipDsn": "GlitchTip DSN", "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "Environnement GlitchTip", @@ -7214,7 +7331,7 @@ "webPushVapidGenerating": "Génération…", "webPushVapidGenerated": "Clés VAPID générées. Enregistrez le sujet pour terminer.", "webPushVapidGenerateFailed": "Impossible de générer les clés VAPID.", - "webPushVapidGenerateConfirm": "Les clés VAPID sont déjà configurées. Générer une nouvelle paire invalide tous les abonnements push du navigateur — chaque appareil doit se réabonner. Continuer ?", + "webPushVapidGenerateConfirm": "Les clés VAPID sont déjà configurées. Générer une nouvelle paire invalide tous les abonnements push du navigateur : chaque appareil doit se réabonner. Continuer ?", "webPushVapidGenerateConfirmTitle": "Régénérer les clés VAPID ?", "webPushVapidRegenerate": "Régénérer", "configured": "Configuré", @@ -7253,12 +7370,12 @@ "title": "Disponibilité des modules", "description": "Désactivez un module pour tout le serveur.", "saveError": "Impossible de mettre à jour la disponibilité des modules.", - "detail": "Un module désactivé ici est coupé pour chaque compte, quelle que soit la préférence personnelle. Les domaines essentiels (poids, tension, pouls, médicaments) restent toujours actifs et ne peuvent pas être désactivés.", - "coachFollowsSwitch": "Suit l'interrupteur Coach dans Assistant." + "detail": "Le poids, la tension et le pouls sont des mesures essentielles et restent toujours actifs.", + "coachFollowsSwitch": "Suit l'interrupteur Coach de la section Coach." }, "invites": { - "title": "Invitations", - "description": "Liens d'invitation pour l'inscription — ils fonctionnent même lorsque les inscriptions sont fermées.", + "title": "Liens d'invitation", + "description": "Chaque lien permet une inscription même lorsque les inscriptions sont fermées.", "create": "Créer une invitation", "createTitle": "Créer une invitation", "createDescription": "Le lien permet une inscription même lorsque les inscriptions sont fermées.", @@ -7270,7 +7387,7 @@ "createdToast": "Invitation créée", "createError": "Impossible de créer l'invitation.", "tokenTitle": "Lien d'invitation", - "tokenOnceHint": "Copiable uniquement maintenant — copiez le lien ou faites scanner le code QR.", + "tokenOnceHint": "Copiable uniquement maintenant : copiez le lien ou faites scanner le code QR.", "hashExplainer": "Seul un hachage du lien est stocké : il ne pourra plus être affiché. En cas de perte, révoquez l'invitation et créez-en une nouvelle.", "copyLink": "Copier le lien", "copiedToast": "Lien copié", @@ -7304,8 +7421,8 @@ "revokedToast": "Invitation révoquée.", "revokeError": "Impossible de révoquer l'invitation." }, - "userManagement": "Gestion des utilisateurs", - "userManagementDescription": "Gérez les comptes : rôles, réinitialisations de mot de passe et déconnexions forcées.", + "userManagement": "Comptes", + "userManagementDescription": "Rôles, réinitialisations de mot de passe et déconnexions forcées pour chaque compte.", "userName": "Nom", "userEmail": "E-mail", "userRole": "Rôle", @@ -7332,14 +7449,13 @@ "tokenName": "Nom", "tokenPermissions": "Autorisations", "tokenPermissionAll": "Tout", - "tokenPermissionAllTooltip": "Autorisation joker — ce jeton peut appeler toutes les routes de l'API.", + "tokenPermissionAllTooltip": "Autorisation joker : ce jeton peut appeler toutes les routes de l'API.", "tokenStatus": "Statut", "tokenLastUsed": "Dernière utilisation", "tokenCreated": "Créé", "tokenNeverUsed": "Jamais", "tokensEmptyTitle": "Aucun jeton d'API pour l'instant", "tokensEmptyDescription": "Les jetons sont émis lorsqu'un client natif se connecte ou via Paramètres → Compte.", - "loginOverview": "Aperçu des connexions", "loginOverviewDescription": "Événements de connexion et d’authentification de tous les comptes, filtrables et exportables en CSV.", "allAuthEvents": "Tous les événements d'authentification", "failedOnly": "Échecs uniquement", @@ -7372,6 +7488,7 @@ "finalDelete": "Supprimer définitivement", "deletedResult": "{rows} lignes supprimées dans {tables} tables. Les comptes utilisateurs et les identifiants de connexion ont été conservés.", "authRegister": "Inscription", + "authClaim": "Profil repris", "authLogin": "Connexion", "authLoginPasskey": "Connexion par passkey", "authLoginPassword": "Connexion par mot de passe", @@ -7446,7 +7563,7 @@ }, "services": { "title": "Services", - "subtitle": "Activez ou désactivez les canaux de notification et les intégrations à l'échelle du système." + "subtitle": "Activez ou désactivez les canaux de notification et l'API pour tout le serveur." }, "integrations": { "title": "Intégrations", @@ -7510,7 +7627,7 @@ "app-logs": { "title": "Journaux de l'app", "subtitle": "Affichez les wide-events structurés les plus récents émis par ce processus.", - "processNote": "Seuls les journaux du processus de l'app sont affichés — ceux du processus worker vont vers Loki lorsque cette intégration est configurée.", + "processNote": "Seuls les journaux du processus de l'app sont affichés ; ceux du processus worker vont vers Loki lorsque cette intégration est configurée.", "filterTraceId": "ID de trace", "filterAction": "L'action contient…", "filterLevel": "Niveau", @@ -7532,7 +7649,8 @@ "refresh": "Actualiser", "eventDetails": "Détails de l'événement", "closeDetails": "Fermer les détails", - "processDetail": "Le tampon est volatil et limité à 500 entrées." + "processDetail": "Le tampon est volatil et limité à 500 entrées.", + "cardTitle": "Événements récents" }, "backups": { "title": "Sauvegardes", @@ -7544,7 +7662,7 @@ "loading": "Chargement des sauvegardes...", "loadError": "Échec du chargement des sauvegardes.", "emptyTitle": "Aucune sauvegarde pour l'instant", - "emptyDescription": "Les sauvegardes s'exécutent automatiquement chaque dimanche à 03:00 — ou lancez-en une maintenant pour créer le premier instantané.", + "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)", @@ -7580,11 +7698,11 @@ "restoreTitle": "Restaurer à partir d'une sauvegarde", "restoreDescription": "Cette action remplacera TOUTES les données actuelles de {username} par l'instantané du {when}. Les mesures, médicaments, événements de prise, entrées d'humeur, canaux de notification, abonnements push et suppressions Telegram en file d'attente existants seront supprimés.", "restoreInstanceSettingsLabel": "Restaurer aussi les paramètres de l'instance", - "restoreInstanceSettingsHint": "Inscription, exigence MFA, langue par défaut, disponibilité des modules, configuration des notifications et de l'IA — repris de l'instantané et appliqués à tout le monde sur cette installation. Laissez décoché pour ne restaurer que ce compte.", + "restoreInstanceSettingsHint": "Inscription, exigence MFA, langue par défaut, disponibilité des modules, configuration des notifications et de l'IA : repris de l'instantané et appliqués à tout le monde sur cette installation. Laissez décoché pour ne restaurer que ce compte.", "restorePromptLabel": "Tapez RESTORE pour continuer", "previewTitle": "Contenu de la sauvegarde", "previewLoading": "Lecture du contenu…", - "previewUnavailable": "Le contenu n'a pas pu être lu — la restauration reste possible.", + "previewUnavailable": "Le contenu n'a pas pu être lu, mais la restauration reste possible.", "previewStillReading": "Cette sauvegarde a été créée par une version antérieure, son contenu est donc lu en entier, ce qui prend un moment pour un gros dossier. Rouvrez cette fenêtre dans quelques minutes pour le voir. La restauration n'en dépend pas.", "previewEmpty": "Le fichier ne contient aucun enregistrement.", "previewOther": "Autres enregistrements", @@ -7627,7 +7745,7 @@ "restoreSkippedMedicationTarget": "Analyte cible d'un médicament", "restoreSkippedScheduleRevisionLink": "Succession des périodes de schéma", "restoreSkippedAccountSetting": "Réglage du compte", - "restoreMissingTitle": "Restauration refusée — le fichier est incomplet", + "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.", "restoreMissingDocuments": "Documents", @@ -7699,7 +7817,8 @@ "restoreSkippedCycleCiphertext": "Note ou libellé du cycle chiffré avec une clé que ce serveur ne possède pas (jour ou symptôme conservé sans)", "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)" + "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)" }, "danger-zone": { "title": "Zone de danger", @@ -7716,7 +7835,6 @@ "encryption": { "title": "Chiffrement", "subtitle": "Couverture du chiffrement et état de la rotation des clés pour les données au repos.", - "loading": "Chargement de l'état du chiffrement...", "loadError": "Échec du chargement de l'état du chiffrement.", "coverageTitle": "Couverture", "coverageDescription": "Combien de lignes chiffrées relèvent de chaque id de clé.", @@ -7761,12 +7879,45 @@ "backupsNone": "Aucune sauvegarde n'a encore enregistré les clés dont elle a besoin.", "backupsStillNeed": "Toutes les lignes sont renouvelées, mais des sauvegardes ont encore besoin de {keys}", "backupsUnrecordedBadge": "Toutes les lignes sont renouvelées, mais d'anciennes sauvegardes peuvent encore avoir besoin d'une clé retirée", - "backupsDetail": "La rotation rechiffre chaque ligne, mais une sauvegarde garde la clé sous laquelle son contenu a été écrit, et sa restauration en a besoin. Gardez une clé retirée dans ENCRYPTION_KEYS tant qu'elle figure ci-dessous." + "backupsDetail": "La rotation rechiffre chaque ligne, mais une sauvegarde garde la clé sous laquelle son contenu a été écrit, et sa restauration en a besoin. Gardez une clé retirée dans ENCRYPTION_KEYS tant qu'elle figure ci-dessous.", + "columnsShow": "Afficher le tableau ({count} avec des lignes)", + "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." + }, + "maintenance": { + "title": "Maintenance de la table des mesures", + "description": "Libère la place qu'occupaient les anciens restes de synchronisation, une fois le nettoyage terminé.", + "body": "Elle exécute un vacuum de la table des mesures puis reconstruit ses index, un par un. La lecture et l'enregistrement continuent de fonctionner pendant ce temps. Choisissez une heure calme et prévoyez de l'espace disque libre pour une copie supplémentaire du plus grand index.", + "tableSize": "Table avec index", + "indexSize": "Index", + "loading": "Vérification de l'état de la maintenance…", + "loadError": "Impossible de lire l'état de la maintenance.", + "unavailable": "Aucun processus d'arrière-plan n'est connecté, la maintenance ne peut donc pas s'exécuter ici.", + "purgePending": "Le nettoyage des anciens restes de synchronisation est toujours en cours. La maintenance pourra démarrer une fois qu'il sera terminé.", + "state": { + "idle": "Pas encore exécutée.", + "queued": "En attente du processus d'arrière-plan.", + "running": "En cours depuis {since}. Sur une grande instance, cela peut prendre un moment.", + "completed": "Terminée le {at}.", + "refusedPurge": "Pas démarrée, car le nettoyage était encore en cours. Relancez-la une fois le nettoyage terminé.", + "alreadyRunning": "Pas démarrée, car une autre exécution était déjà en cours.", + "failed": "Arrêtée le {at}. Le journal de l'application indique l'étape et le runbook explique quoi vérifier." + }, + "start": "Lancer la maintenance", + "confirmTitle": "Lancer la maintenance maintenant ?", + "confirmBody": "Elle s'exécute en arrière-plan, étape par étape, et peut prendre un moment. Si vous n'avez pas encore fait de sauvegarde aujourd'hui, faites-en une d'abord.", + "confirm": "Lancer", + "cancel": "Annuler", + "started": "Maintenance lancée.", + "alreadyQueued": "Une maintenance est déjà en file d'attente ou en cours.", + "startFailed": "Impossible de lancer la maintenance." } }, "carrier": "Opérateur", "mfaRequired": "Exiger un second facteur", - "mfaRequiredDescription": "Chaque compte sans deuxième facteur actif est dirigé vers la configuration après la connexion. Cela invite les utilisateurs web à l'activer, mais ne bloque pas l'accès direct par API ou natif (Bearer) pour les comptes pas encore configurés.", + "mfaRequiredDescription": "Les comptes sans deuxième facteur sont dirigés vers sa configuration après une connexion web ; l'accès par API ou natif (Bearer) reste ouvert.", "mfaEnforcedOn": "MFA activé", "mfaEnforcedOff": "MFA désactivé", "mfaEnforcedOnHint": "Un second facteur est requis pour cet utilisateur. Cliquez pour ne plus l'exiger.", @@ -7784,7 +7935,7 @@ "centralCodex": { "title": "Connexion IA partagée", "description": "Reliez un compte ChatGPT connecté que chaque utilisateur de ce serveur peut activer pour les fonctions IA.", - "honesty": "Il s'agit d'un unique compte IA partagé et connecté, pas d'une clé d'API. Chaque personne qui l'active envoie ses requêtes par ce biais : les limites horaires et hebdomadaires du compte sont donc partagées entre elles, et ces mêmes limites alimentent déjà le Coach. Les fournisseurs par abonnement peuvent utiliser le contenu pour améliorer leurs modèles et aucun accord de traitement des données ne le couvre — ce n'est donc jamais l'option par défaut et cela reste après les fournisseurs sans entraînement pour la lecture de documents. Ne reliez qu'un compte que vous êtes prêt à partager ainsi.", + "honesty": "Il s'agit d'un unique compte IA partagé et connecté, pas d'une clé d'API. Chaque personne qui l'active envoie ses requêtes par ce biais : les limites horaires et hebdomadaires du compte sont donc partagées entre elles, et ces mêmes limites alimentent déjà le Coach. Les fournisseurs par abonnement peuvent utiliser le contenu pour améliorer leurs modèles et aucun accord de traitement des données ne le couvre. Ce n'est donc jamais l'option par défaut et cela reste après les fournisseurs sans entraînement pour la lecture de documents. Ne reliez qu'un compte que vous êtes prêt à partager ainsi.", "statusConnected": "Connecté", "statusDisconnected": "Non connecté", "statusExpired": "Expiré", @@ -7796,7 +7947,7 @@ "connectedSince": "Connecté depuis le {when}", "connected": "Connexion IA partagée reliée", "disconnected": "Connexion IA partagée supprimée", - "error": "Une erreur s'est produite — veuillez réessayer" + "error": "Une erreur s'est produite. Veuillez réessayer" }, "failingJobs": { "title": "Tâches en arrière-plan", @@ -7864,7 +8015,9 @@ "restartNote": "Redémarre HealthLog après la modification.", "lastResort": "Dernier recours, seulement si tu es sûr que la clé est la bonne et qu'aucune des trois solutions ne convient : définis {setting} et redémarre. HealthLog sert alors quand même et se contente de journaliser le constat. Cela désactive une vérification de sécurité : avec une mauvaise clé, chaque valeur chiffrée échoue et les nouvelles données sont écrites sous cette clé.", "codes": "Pour l'administrateur : les requêtes API répondent 503 avec le code d'erreur {code}, et /api/health indique la raison {reason}." - } + }, + "loginOverviewCardTitle": "Événements de connexion", + "userColumn": "Utilisateur" }, "achievements": { "title": "Réussites", @@ -7880,7 +8033,9 @@ "noneUnlockedYet": "Aucune réussite débloquée pour l’instant.", "pointsValue": "{points} points", "metricCount": "{count}", - "metricDays": "{count} jours", + "metricDaysOne": "{count} jour", + "metricDaysFew": "{count} jours", + "metricDaysOther": "{count} jours", "metricPercent": "{count}%", "badges": { "intakeTotal1": { @@ -7903,14 +8058,6 @@ "title": "Légende des prises", "description": "Enregistrez 300 prises de médicaments." }, - "overIntake1": { - "title": "Double dose", - "description": "Prenez votre médicament au moins une fois de plus que prévu." - }, - "skippedIntake1": { - "title": "Pause prise", - "description": "Sautez au moins une prise prévue." - }, "passkeyCreated1": { "title": "Clé d’accès configurée", "description": "Créez votre première clé d’accès." @@ -7936,19 +8083,19 @@ "description": "Prenez tous vos médicaments à l’heure pendant 1 jour." }, "compliance801": { - "title": "Observance 80% · 1j", + "title": "Observance de 80% pendant 1 jour", "description": "Atteignez au moins 80% d’observance sur 30 jours pendant 1 jour." }, "bmiGreen1": { - "title": "IMC au vert · 1j", + "title": "IMC au vert pendant 1 jour", "description": "Maintenez votre IMC dans la plage normale pendant 1 jour." }, "bpGreen1": { - "title": "Tension au vert · 1j", + "title": "Tension au vert pendant 1 jour", "description": "Maintenez votre tension dans la plage normale pendant 1 jour." }, "pulseGreen1": { - "title": "Pouls au vert · 1j", + "title": "Pouls au vert pendant 1 jour", "description": "Maintenez votre pouls au repos dans la plage normale pendant 1 jour." }, "onTimePerfect7": { @@ -7956,19 +8103,19 @@ "description": "Prenez tous vos médicaments à l’heure pendant 7 jours d’affilée." }, "compliance807": { - "title": "Observance 80% · 7j", + "title": "Observance de 80% pendant 7 jours", "description": "Atteignez au moins 80% d’observance sur 30 jours pendant 7 jours d’affilée." }, "bmiGreen7": { - "title": "IMC au vert · 7j", + "title": "IMC au vert pendant 7 jours", "description": "Maintenez votre IMC dans la plage normale pendant 7 jours d’affilée." }, "bpGreen7": { - "title": "Tension au vert · 7j", + "title": "Tension au vert pendant 7 jours", "description": "Maintenez votre tension dans la plage normale pendant 7 jours d’affilée." }, "pulseGreen7": { - "title": "Pouls au vert · 7j", + "title": "Pouls au vert pendant 7 jours", "description": "Maintenez votre pouls au repos dans la plage normale pendant 7 jours d’affilée." }, "onTimePerfect30": { @@ -7976,19 +8123,19 @@ "description": "Prenez tous vos médicaments à l’heure pendant 30 jours d’affilée." }, "compliance8030": { - "title": "Observance 80% · 30j", + "title": "Observance de 80% pendant 30 jours", "description": "Atteignez au moins 80% d’observance sur 30 jours pendant 30 jours d’affilée." }, "bmiGreen30": { - "title": "IMC au vert · 30j", + "title": "IMC au vert pendant 30 jours", "description": "Maintenez votre IMC dans la plage normale pendant 30 jours d’affilée." }, "bpGreen30": { - "title": "Tension au vert · 30j", + "title": "Tension au vert pendant 30 jours", "description": "Maintenez votre tension dans la plage normale pendant 30 jours d’affilée." }, "pulseGreen30": { - "title": "Pouls au vert · 30j", + "title": "Pouls au vert pendant 30 jours", "description": "Maintenez votre pouls au repos dans la plage normale pendant 30 jours d’affilée." }, "onTimePerfect180": { @@ -7996,19 +8143,19 @@ "description": "Prenez tous vos médicaments à l’heure pendant 180 jours d’affilée." }, "compliance80180": { - "title": "Observance 80% · 180j", + "title": "Observance de 80% pendant 180 jours", "description": "Atteignez au moins 80% d’observance sur 30 jours pendant 180 jours d’affilée." }, "bmiGreen180": { - "title": "IMC au vert · 180j", + "title": "IMC au vert pendant 180 jours", "description": "Maintenez votre IMC dans la plage normale pendant 180 jours d’affilée." }, "bpGreen180": { - "title": "Tension au vert · 180j", + "title": "Tension au vert pendant 180 jours", "description": "Maintenez votre tension dans la plage normale pendant 180 jours d’affilée." }, "pulseGreen180": { - "title": "Pouls au vert · 180j", + "title": "Pouls au vert pendant 180 jours", "description": "Maintenez votre pouls au repos dans la plage normale pendant 180 jours d’affilée." }, "onTimePerfect360": { @@ -8016,19 +8163,19 @@ "description": "Prenez tous vos médicaments à l’heure pendant 360 jours d’affilée." }, "compliance80360": { - "title": "Observance 80% · 360j", + "title": "Observance de 80% pendant 360 jours", "description": "Atteignez au moins 80% d’observance sur 30 jours pendant 360 jours d’affilée." }, "bmiGreen360": { - "title": "IMC au vert · 360j", + "title": "IMC au vert pendant 360 jours", "description": "Maintenez votre IMC dans la plage normale pendant 360 jours d’affilée." }, "bpGreen360": { - "title": "Tension au vert · 360j", + "title": "Tension au vert pendant 360 jours", "description": "Maintenez votre tension dans la plage normale pendant 360 jours d’affilée." }, "pulseGreen360": { - "title": "Pouls au vert · 360j", + "title": "Pouls au vert pendant 360 jours", "description": "Maintenez votre pouls au repos dans la plage normale pendant 360 jours d’affilée." }, "moodFirst": { @@ -8112,15 +8259,15 @@ "description": "Vous avez changé la langue de l’application au moins une fois." }, "missFree7": { - "title": "Aucune dose oubliée · 7j", + "title": "Aucune dose oubliée en 7 jours", "description": "7 jours consécutifs sans dose oubliée." }, "missFree30": { - "title": "Aucune dose oubliée · 30j", + "title": "Aucune dose oubliée en 30 jours", "description": "30 jours consécutifs sans dose oubliée." }, "missFree90": { - "title": "Aucune dose oubliée · 90j", + "title": "Aucune dose oubliée en 90 jours", "description": "90 jours consécutifs sans dose oubliée." }, "measurementWeeks4": { @@ -8129,10 +8276,10 @@ }, "selfContextComplete": { "title": "Auto-déclaration à jour", - "description": "Les quatre champs de l’auto-déclaration sont remplis — le Coach connaît votre contexte." + "description": "Les quatre champs de l’auto-déclaration sont remplis : le Coach connaît votre contexte." }, "sleepLog7": { - "title": "Journal de sommeil · 7j", + "title": "Journal de sommeil pendant 7 jours", "description": "Données de sommeil enregistrées pendant 7 jours consécutifs." } }, @@ -8151,7 +8298,7 @@ }, "hiddenCard": { "title": "Réussite cachée", - "description": "Continuez à suivre — vous pourriez bien tomber dessus.", + "description": "Continuez à suivre, vous pourriez bien tomber dessus.", "ariaLabel": "Réussite cachée verrouillée" }, "hiddenUnlockToast": { @@ -8160,7 +8307,7 @@ "dashboardCard": { "title": "Débloqués récemment", "viewAll": "Tout voir", - "empty": "Pas encore de réussites — continuez à suivre pour débloquer la première." + "empty": "Pas encore de réussites. Continuez à suivre pour débloquer la première." } }, "notifications": { @@ -8222,9 +8369,6 @@ "enabledToast": "Rappels proactifs du Coach activés.", "disabledToast": "Rappels proactifs du Coach désactivés.", "saveError": "Impossible d'enregistrer. Veuillez réessayer plus tard.", - "suggestionsLabel": "Suggestions d'exemples", - "suggestionsDesc": "Affiche de temps en temps une question d'exemple prête à l'emploi sur l'écran du Coach.", - "suggestionsAria": "Activer ou désactiver les suggestions d'exemples du Coach", "aiComposedLabel": "Laisser le Coach rédiger le message", "aiComposedDesc": "Quand un fournisseur est disponible, le message proactif est rédigé par le modèle plutôt que par le modèle de texte fixe. Désactivé par défaut.", "aiComposedAria": "Activer ou désactiver le message proactif rédigé par l'IA" @@ -8295,7 +8439,27 @@ "loginRequired": "Connecte-toi pour gérer les notifications.", "someChannelsGloballyDisabled": "Un ou plusieurs canaux sont désactivés dans les paramètres et ne recevront pas de notifications.", "reminderSettingsLink": "Réglages des rappels", - "channelsLink": "Canaux" + "channelsLink": "Canaux", + "handover": { + "claimedTitle": "{name} a maintenant son propre compte", + "claimedEnd": "{name} a repris son dossier de santé. Votre accès a pris fin.", + "claimedRead": "{name} a repris son dossier de santé. Vous pouvez encore le consulter, mais plus le modifier.", + "claimedManage": "{name} a repris son dossier de santé. Vous pouvez encore le gérer jusqu'à nouvelle décision.", + "changedTitle": "{name} a modifié votre accès", + "changedEnd": "Votre accès au dossier de santé de {name} a pris fin.", + "changedRead": "Vous pouvez désormais consulter le dossier de santé de {name}, sans le modifier.", + "changedManage": "Vous pouvez gérer le dossier de santé de {name}.", + "unnamed": { + "claimedTitle": "La personne dont vous vous occupiez a maintenant son propre compte", + "claimedEnd": "La personne dont vous vous occupiez a repris son dossier de santé. Votre accès a pris fin.", + "claimedRead": "La personne dont vous vous occupiez a repris son dossier de santé. Vous pouvez toujours le consulter, mais plus le modifier.", + "claimedManage": "La personne dont vous vous occupiez a repris son dossier de santé. Vous pouvez toujours le gérer jusqu'à ce qu'elle en décide autrement.", + "changedTitle": "Votre accès à un dossier de santé a changé", + "changedEnd": "Votre accès au dossier de santé de la personne dont vous vous occupiez a pris fin.", + "changedRead": "Vous pouvez désormais consulter le dossier de santé de la personne dont vous vous occupiez, mais pas le modifier.", + "changedManage": "Vous pouvez gérer le dossier de santé de la personne dont vous vous occupiez." + } + } }, "i18n": { "maintainershipBanner": { @@ -8326,7 +8490,7 @@ } }, "edit": { - "title": "Modifier la plage cible — {metric}", + "title": "Modifier la plage cible : {metric}", "description": "Ajuste la plage cible utilisée dans les graphiques, les insights et le rapport médical. Valeurs en {unit}.", "derivedHint": "Cette carte est dérivée d’autres métriques. Modifie plutôt les objectifs sous-jacents (p. ex. tension ou poids).", "minLabel": "Min", @@ -8350,15 +8514,19 @@ "currentValue": "Actuel : {value} {unit}", "targetRangeValue": "Plage cible : {min}–{max} {unit}", "sourceLabel": "Source : {source}", + "sourceNames": { + "moodEntries": "Entrées d’humeur", + "sevenDays": "7 jours" + }, "inTarget": "Dans la plage cible", "aboveTarget": "{delta} {unit} au-dessus de la plage", "belowTarget": "{delta} {unit} en dessous de la plage", "diastolic": "Diastolique", - "glucoseFasting": "Glycémie — à jeun", - "glucosePostprandial": "Glycémie — postprandiale", - "glucoseRandom": "Glycémie — indifférente", - "glucoseBedtime": "Glycémie — avant le coucher", - "glucoseUnspecified": "Glycémie — non précisée", + "glucoseFasting": "Glycémie (à jeun)", + "glucosePostprandial": "Glycémie (postprandiale)", + "glucoseRandom": "Glycémie (indifférente)", + "glucoseBedtime": "Glycémie (avant le coucher)", + "glucoseUnspecified": "Glycémie (non précisée)", "status": { "underweight": "Insuffisance pondérale", "normal": "Normal", @@ -8444,7 +8612,7 @@ "askMedicationName": "Indique le médicament, p. ex. pris Ramipril.", "medicationNotFoundExact": "Médicament introuvable. Envoie le nom exactement tel qu’il apparaît dans HealthLog.", "helpHeader": "Commandes disponibles :", - "helpBody": "/help — afficher cette aide\n/add — enregistrer une prise\n/start — démarrer le bot\n\nCommandes texte :\ntaken <nom> — confirmer une prise\n\nBoutons dans les rappels :\n• Pris — confirmer la prise\n• 🕐 1h / 🕐 3h — reporter le rappel\n• ⏭ Ignorer — ignorer la prise\n• ✓ Confirmer — confirmer une prise manquée", + "helpBody": "/help : afficher cette aide\n/add : enregistrer une prise\n/start : démarrer le bot\n\nCommandes texte :\ntaken <nom> : confirmer une prise\n\nBoutons dans les rappels :\n• Pris : confirmer la prise\n• 🕐 1h / 🕐 3h : reporter le rappel\n• ⏭ Ignorer : ignorer la prise\n• ✓ Confirmer : confirmer une prise manquée", "measureValuePrompt": "Répondez avec la valeur pour l’enregistrer (facultatif).", "buttonMeasureValue": "Valeur…", "measureValueSaved": "Mesure enregistrée.", @@ -8469,9 +8637,9 @@ }, "lowStockReminders": { "title": "Stock faible : {medName}", - "bodyToday": "{medName} : {units} unités restantes — moins d’un jour de réserve.", - "bodyReorder": "{medName} : {units} unités restantes. Le stock sera épuisé vers le {runsOutOn} — recommandez avant le {reorderBy}.", - "bodyLastDose": "{medName} : {units} unités restantes — environ votre dernière dose. Le stock sera épuisé vers le {runsOutOn}.", + "bodyToday": "{medName} : {units} unités restantes, moins d’un jour de réserve.", + "bodyReorder": "{medName} : {units} unités restantes. Le stock sera épuisé vers le {runsOutOn}, recommandez avant le {reorderBy}.", + "bodyLastDose": "{medName} : {units} unités restantes, environ votre dernière dose. Le stock sera épuisé vers le {runsOutOn}.", "bodyExpired": "{medName} : plus de stock utilisable. {units} unités sont marquées périmées.", "bodyAsNeeded": "{medName} : plus que {units} unités. Recommandez avant d’avoir besoin de la prochaine dose." }, @@ -8498,11 +8666,11 @@ "questionConditions": "Avez-vous des maladies chroniques que le Coach devrait connaître ?", "questionFocus": "Y a-t-il quelque chose de précis auquel le Coach devrait veiller ?", "questionAllergies": "Avez-vous des allergies ou des intolérances ?", - "questionAboutMe": "Envie de parler un peu de vous au Coach — habitudes, objectifs, contexte ?" + "questionAboutMe": "Envie de parler un peu de vous au Coach (habitudes, objectifs, contexte) ?" }, "clinicianView": { "title": "Dossier de santé partagé", - "period": "Période couverte : {start} – {end}", + "period": "Période couverte : {start}–{end}", "expires": "Ce lien expire le {date}.", "provenance": "Il s'agit d'un résumé en lecture seule partagé par la personne concernée, exporté de ses données HealthLog auto-enregistrées. Les valeurs sont auto-déclarées et peuvent inclure des mesures synchronisées depuis des appareils ; à considérer comme rapportées par le patient, non comme un examen clinique.", "labs": "Valeurs de laboratoire", @@ -8526,7 +8694,7 @@ "strain": "Score de charge", "score": "Score de bien-être" }, - "footer": "Partagé depuis HealthLog — un suivi de santé auto-hébergé.", + "footer": "Partagé depuis HealthLog, un suivi de santé auto-hébergé.", "unlock": { "title": "Saisir la phrase secrète", "description": "Ce dossier de santé partagé est protégé. Saisissez la phrase secrète qui vous a été communiquée pour le consulter.", @@ -8561,7 +8729,11 @@ }, "course": "Traitement {n}", "courseRange": "du {start} au {end}", - "courseSince": "depuis le {start}" + "courseSince": "depuis le {start}", + "unavailable": { + "title": "Lien expiré ou invalide", + "description": "Ce lien de partage a expiré, a été révoqué ou est incomplet. Demandez un nouveau lien à la personne qui l’a partagé." + } }, "cycle": { "symptomCategory": { @@ -8614,7 +8786,7 @@ }, "crosstab": { "description": "Comment chaque mesure se compare entre tes phases lutéale et folliculaire, sur tes propres données.", - "pairLabel": "{metric} · lutéale vs folliculaire", + "pairLabel": "{metric} : phase lutéale vs folliculaire", "detail": "Lutéale {lutealAvg} {unit} ({lutealDays} jours) vs folliculaire {follicularAvg} {unit} ({follicularDays} jours).", "metricRestingHeartRate": "Fréquence cardiaque au repos", "metricHrv": "Variabilité de la fréquence cardiaque", @@ -8737,8 +8909,8 @@ "ariaUnknown": "Pas encore de cycle actif", "caption": "de ton cycle", "firstPeriodCta": "Enregistrer mes premières règles", - "periodOverdue": "Tes règles arrivent plus tard que d’habitude — le compteur de jours est en pause jusqu’à ce que les prochaines règles soient enregistrées.", - "periodOverdueDays": "Tes règles arrivent {days} jours plus tard que d’habitude — le compteur de jours est en pause jusqu’à ce que les prochaines règles soient enregistrées.", + "periodOverdue": "Tes règles arrivent plus tard que d’habitude. Le compteur de jours est en pause jusqu’à ce que les prochaines règles soient enregistrées.", + "periodOverdueDays": "Tes règles arrivent {days} jours plus tard que d’habitude. Le compteur de jours est en pause jusqu’à ce que les prochaines règles soient enregistrées.", "logPeriodCta": "Enregistrer les règles" }, "calendar": { @@ -8750,7 +8922,7 @@ "prevMonth": "Mois précédent", "nextMonth": "Mois suivant", "loading": "Chargement de ton calendrier…", - "learning": "Nous apprenons ton cycle — enregistre au moins trois cycles complets et la fenêtre fertile, l'ovulation et les phases se compléteront. D'ici là, le calendrier n'affiche que ce que tu as enregistré.", + "learning": "Nous apprenons ton cycle : enregistre au moins trois cycles complets et la fenêtre fertile, l'ovulation et les phases se compléteront. D'ici là, le calendrier n'affiche que ce que tu as enregistré.", "legendSymptom": "Symptômes", "legendOvulationConfirmed": "Ovulation confirmée", "flowSpotting": "spotting", @@ -8763,7 +8935,7 @@ "predictions": { "title": "Ce qui arrive ensuite", "nextPeriod": "Prochaines règles", - "nextPeriodWindow": "{low} – {high}", + "nextPeriodWindow": "{low}–{high}", "fertileWindow": "Fenêtre fertile", "ovulation": "Ovulation estimée", "confidenceHigh": "Élevée", @@ -8772,7 +8944,7 @@ "stillLearning": "Encore en apprentissage", "stillLearningBody": "Enregistre au moins trois cycles complets et tes prévisions s'affineront. D'ici là, ceci reste une estimation approximative.", "rawMode": "Prévision désactivée", - "rawModeBody": "Tu as désactivé les prévisions — seules tes données enregistrées sont affichées, sans prévision.", + "rawModeBody": "Tu as désactivé les prévisions : seules tes données enregistrées sont affichées, sans prévision.", "none": "Pas encore de prévision. Enregistre des règles pour commencer.", "ovulationConfirmed": "Ovulation (confirmée)" }, @@ -8805,7 +8977,7 @@ "symptoms": "Symptômes", "temperature": "Température basale", "temperatureDisturbed": "Marquer la mesure comme perturbée", - "temperatureDisturbedHint": "Fièvre, maladie, alcool ou mesure tardive — exclue de l'évaluation de la température.", + "temperatureDisturbedHint": "Fièvre, maladie, alcool ou mesure tardive : exclue de l'évaluation de la température.", "ovulationTest": "Test d'ovulation", "mucus": "Glaire cervicale", "cervix": "Col de l'utérus", @@ -8834,7 +9006,7 @@ "temperatureSection": "Température et ovulation", "intimacySection": "Intimité et contraception", "testsSection": "Tests", - "phaseDayHeader": "Jour {day} · {phase}", + "phaseDayHeader": "Jour {day}, {phase}", "bbtHint": "Prends-la dès le matin, avant de te lever.", "titleForDate": "Saisie du {date}", "descriptionToday": "Aujourd'hui. Flux, symptômes et notes.", @@ -8901,15 +9073,15 @@ "fieldInfo": { "bbt": "Ta température au repos, prise dès le matin avant de te lever. Elle augmente légèrement (environ 0,2–0,3 °C) après l'ovulation.", "bbtLabel": "Qu'est-ce que la température basale ?", - "mucus": "Les pertes vaginales évoluent au fil du cycle — de sèches ou collantes à crémeuses, puis aqueuses et comme du blanc d'œuf autour des jours les plus fertiles.", + "mucus": "Les pertes vaginales évoluent au fil du cycle, de sèches ou collantes à crémeuses, puis aqueuses et comme du blanc d'œuf autour des jours les plus fertiles.", "mucusLabel": "Qu'est-ce que la glaire cervicale ?", - "ovulationTest": "Un test urinaire à domicile qui détecte le pic de LH — la montée hormonale environ 24–36 h avant l'ovulation. Un test positif signale la période la plus fertile.", + "ovulationTest": "Un test urinaire à domicile qui détecte le pic de LH, la montée hormonale environ 24–36 h avant l'ovulation. Un test positif signale la période la plus fertile.", "ovulationTestLabel": "Qu'est-ce qu'un test d'ovulation ?", "spotting": "Léger saignement en dehors des règles.", "spottingLabel": "Qu'est-ce que le spotting entre les règles ?", "pregnancyTest": "Résultat d'un test de grossesse (hCG) à domicile.", "pregnancyTestLabel": "Qu'est-ce qu'un test de grossesse ?", - "progesteroneTest": "Un test confirmant que l'ovulation a eu lieu — la progestérone augmente ensuite.", + "progesteroneTest": "Un test confirmant que l'ovulation a eu lieu : la progestérone augmente ensuite.", "progesteroneTestLabel": "Qu'est-ce qu'un test de progestérone ?", "intercourse": "Si un rapport sexuel a eu lieu ce jour-là.", "intercourseLabel": "Que suit le rapport ?", @@ -8962,14 +9134,14 @@ "postpone": { "menuItem": "Reporter…", "title": "Reporter l'échéance", - "description": "Ne déplace que l'échéance actuelle — le rythme régulier reste inchangé.", + "description": "Ne déplace que l'échéance actuelle ; le rythme régulier reste inchangé.", "chip": "+{days} jours", "dateLabel": "Nouvelle date", "confirm": "Reporter" }, "skip": { "heading": "Passer cette échéance", - "explainer": "Enregistré honnêtement comme passé — pas comme fait. L'intervalle redémarre à partir d'aujourd'hui.", + "explainer": "Enregistré honnêtement comme passé, pas comme fait. L'intervalle redémarre à partir d'aujourd'hui.", "action": "Passer", "confirmTitle": "Passer cette échéance ?" }, @@ -9011,10 +9183,10 @@ "VISCERAL_FAT": "Graisse viscérale", "BODY_MASS_INDEX": "Indice de masse corporelle (IMC)", "WAIST_CIRCUMFERENCE": "Tour de taille", - "PHQ9_SCORE": "PHQ-9 · humeur", - "GAD7_SCORE": "GAD-7 · inquiétude", - "WHO5_SCORE": "WHO-5 · bien-être", - "SCI_SCORE": "SCI · sommeil" + "PHQ9_SCORE": "PHQ-9 (humeur)", + "GAD7_SCORE": "GAD-7 (inquiétude)", + "WHO5_SCORE": "WHO-5 (bien-être)", + "SCI_SCORE": "SCI (sommeil)" }, "typeGroups": { "vitals": "Constantes vitales", @@ -9110,7 +9282,7 @@ "localOcr": { "heading": "Lire les scans sur cet appareil", "description": "Lit la photo sur votre appareil et n'envoie que le texte extrait à votre fournisseur.", - "detail": "Utile quand votre fournisseur d'IA ne lit pas les images. Moins précis — vérifiez chaque valeur avant d'enregistrer." + "detail": "Utile quand votre fournisseur d'IA ne lit pas les images. Moins précis : vérifiez chaque valeur avant d'enregistrer." }, "sort": { "heading": "Tri", @@ -9172,7 +9344,7 @@ "emptyDescription": "Ajoutez une valeur pour suivre ce biomarqueur dans le temps.", "title": "Biomarqueur", "genericDescription": "Un biomarqueur biologique que vous suivez. Ajoutez des mesures pour observer son évolution par rapport à la plage de référence.", - "valuesTitle": "{marker} — toutes les valeurs", + "valuesTitle": "{marker} : toutes les valeurs", "valuesDescription": "Chaque mesure de ce biomarqueur. Modifiez ou supprimez une entrée directement.", "backToMarker": "Retour à {marker}", "latestResult": "Dernier résultat" @@ -9192,7 +9364,7 @@ }, "biomarker": { "manageTitle": "Biomarqueurs", - "managerDescription": "Définissez un marqueur une fois — son unité et sa plage sont réutilisées pour chaque mesure.", + "managerDescription": "Définissez un marqueur une fois : son unité et sa plage sont réutilisées pour chaque mesure.", "define": "Définir", "defineFirst": "Définir votre premier biomarqueur", "defineTitle": "Définir un biomarqueur", @@ -9211,7 +9383,7 @@ "form": { "seedLabel": "Partir d'un marqueur courant", "seedPlaceholder": "Choisir un marqueur (facultatif)", - "seedHint": "En choisir un remplit les champs — ajustez la plage à votre laboratoire.", + "seedHint": "En choisir un remplit les champs. Ajustez la plage à votre laboratoire.", "name": "Nom", "namePlaceholder": "ex. Cholestérol LDL", "unit": "Unité", @@ -9225,7 +9397,7 @@ "upperPlaceholder": "Borne supérieure", "context": "Contexte", "contextPlaceholder": "Note facultative sur ce marqueur", - "rangeHint": "Les deux bornes sont facultatives — laissez-en une vide pour une plage ouverte (ex. LDL < 116).", + "rangeHint": "Les deux bornes sont facultatives. Laissez-en une vide pour une plage ouverte (ex. LDL < 116).", "requiredError": "Le nom et l'unité sont requis.", "rangeOrderError": "La borne inférieure ne doit pas dépasser la borne supérieure.", "save": "Enregistrer le biomarqueur", @@ -9290,17 +9462,17 @@ "tsh": "La TSH (hormone thyréostimulante) est libérée par l'hypophyse pour signaler à la thyroïde de produire ses hormones, et elle s'ajuste à la hausse ou à la baisse pour les maintenir en équilibre. Grâce à cette boucle de rétroaction, la TSH est le dépistage de première intention le plus sensible de la fonction thyroïdienne : elle bouge généralement avant que les hormones thyroïdiennes elles-mêmes ne dévient. Une valeur élevée ou abaissée oriente vers une thyroïde hypo ou hyperactive et conduit habituellement à doser directement les hormones thyroïdiennes. La grossesse et l'âge décalent la plage attendue.", "ft3": "La T3 libre (triiodothyronine) est la forme non liée et biologiquement active de l'hormone thyroïdienne la plus puissante, la fraction disponible pour agir sur les tissus. La majeure partie de la T3 circulante est liée à des protéines de transport et inactive ; la part libre reflète donc la disponibilité réelle de l'hormone. Elle est mesurée pour caractériser la fonction thyroïdienne, surtout avec la TSH et la T4 libre lorsque les résultats sont incertains. Les taux dépendent fortement de la technique de dosage et se lisent dans leur contexte clinique.", "ft4": "La T4 libre (thyroxine) est la forme non liée de la principale hormone que la thyroïde libère dans le sang ; elle sert de réservoir que les tissus convertissent en T3 plus active selon les besoins. La fraction libre reflète ce qui est réellement disponible pour le corps, indépendamment du taux de protéines de transport. On la mesure généralement avec la TSH pour distinguer un problème de la thyroïde d'un trouble de la signalisation hypophysaire. La grossesse et certains médicaments peuvent la décaler.", - "ferritin": "La ferritine est la principale protéine de stockage du fer de l'organisme, et son taux sanguin reflète globalement les réserves totales en fer. Une valeur basse oriente vers des réserves épuisées, tandis qu'une valeur élevée peut traduire une surcharge en fer — ou simplement une inflammation, car la ferritine monte aussi comme protéine de la phase aiguë. Pour cette raison, elle s'interprète souvent avec d'autres marqueurs du fer et des signes d'inflammation. C'est l'une des mesures centrales pour évaluer le statut en fer.", - "transferrin-saturation": "Le coefficient de saturation de la transferrine est le pourcentage de transferrine — la protéine qui transporte le fer dans le sang — actuellement chargé en fer. Il relie la quantité de fer en circulation à la capacité de transport de l'organisme, offrant un instantané de la disponibilité du fer qui complète la vue des réserves donnée par la ferritine. Une saturation basse suggère un fer en quantité insuffisante, tandis qu'une valeur élevée peut indiquer une surcharge. Elle se lit avec la ferritine et le bilan martial complet.", + "ferritin": "La ferritine est la principale protéine de stockage du fer de l'organisme, et son taux sanguin reflète globalement les réserves totales en fer. Une valeur basse oriente vers des réserves épuisées, tandis qu'une valeur élevée peut traduire une surcharge en fer, ou simplement une inflammation, car la ferritine monte aussi comme protéine de la phase aiguë. Pour cette raison, elle s'interprète souvent avec d'autres marqueurs du fer et des signes d'inflammation. C'est l'une des mesures centrales pour évaluer le statut en fer.", + "transferrin-saturation": "Le coefficient de saturation de la transferrine est le pourcentage de transferrine (la protéine qui transporte le fer dans le sang) actuellement chargé en fer. Il relie la quantité de fer en circulation à la capacité de transport de l'organisme, offrant un instantané de la disponibilité du fer qui complète la vue des réserves donnée par la ferritine. Une saturation basse suggère un fer en quantité insuffisante, tandis qu'une valeur élevée peut indiquer une surcharge. Elle se lit avec la ferritine et le bilan martial complet.", "vitamin-d": "La vitamine D se mesure sous forme de 25-hydroxyvitamine D, la forme de stockage circulante et le marqueur standard du statut global en vitamine D. Le corps la fabrique dans la peau exposée à la lumière du soleil et en absorbe une partie par l'alimentation ; elle soutient l'équilibre du calcium, la santé osseuse et la fonction immunitaire. Les taux varient selon la saison, la latitude, le teint de peau et la supplémentation. Elle est suivie pour estimer dans le temps si les réserves sont suffisantes.", - "vitamin-b12": "La vitamine B12 (cobalamine) est une vitamine essentielle, obtenue surtout à partir d'aliments d'origine animale et stockée en grande partie dans le foie. Elle est nécessaire à la formation des globules rouges, à la synthèse de l'ADN et à la santé du système nerveux. Une carence prolongée — par faible apport ou absorption altérée — peut entraîner une anémie et des symptômes neurologiques. Elle est mesurée pour évaluer le statut nutritionnel, en particulier lorsque l'apport ou l'absorption peuvent être limités.", + "vitamin-b12": "La vitamine B12 (cobalamine) est une vitamine essentielle, obtenue surtout à partir d'aliments d'origine animale et stockée en grande partie dans le foie. Elle est nécessaire à la formation des globules rouges, à la synthèse de l'ADN et à la santé du système nerveux. Une carence prolongée, par faible apport ou absorption altérée, peut entraîner une anémie et des symptômes neurologiques. Elle est mesurée pour évaluer le statut nutritionnel, en particulier lorsque l'apport ou l'absorption peuvent être limités.", "folate": "Le folate (vitamine B9) est une vitamine du groupe B présente dans les légumes à feuilles vertes, les légumineuses et les aliments enrichis, centrale pour la formation des globules rouges et la synthèse de l'ADN. Il travaille en étroite collaboration avec la vitamine B12, et une carence de l'un ou de l'autre peut produire un type d'anémie similaire. Un apport suffisant en folate est particulièrement important autour du début de la grossesse pour un développement sain du fœtus. Il est mesuré pour évaluer le statut nutritionnel et la cause de certaines anémies.", "hs-crp": "La CRP ultrasensible mesure la protéine C-réactive, un marqueur que le foie libère en réponse à l'inflammation, avec une technique affinée pour détecter les faibles concentrations pertinentes pour le risque cardiovasculaire. Contrairement à la CRP standard utilisée pour les infections aiguës, la version ultrasensible capte l'inflammation discrète et chronique liée à la maladie vasculaire. Comme toute infection ou blessure récente peut l'élever, elle s'interprète à distance d'une affection aiguë et se confirme souvent par un prélèvement répété. Elle se lit comme un facteur parmi d'autres du risque cardiovasculaire global.", "creatinine": "La créatinine est un déchet du métabolisme musculaire normal que les reins filtrent du sang à un rythme assez constant. Comme la production est relativement stable, son taux sanguin sert de jauge pratique de la capacité des reins à éliminer les déchets. Les valeurs dépendent aussi de la masse musculaire, de l'hydratation et de l'alimentation ; on les interprète donc généralement avec le débit de filtration estimé. C'est un marqueur central de la fonction rénale.", "egfr": "Le DFG estimé (débit de filtration glomérulaire) estime la quantité de sang que les reins filtrent chaque minute, calculée à partir de la créatinine, de l'âge et du sexe. Il traduit une valeur brute de créatinine en une mesure plus directement interprétable de la fonction rénale. Un DFG qui baisse au fil du temps est le principal moyen de classer et de suivre les atteintes rénales chroniques. En tant qu'estimation, il se lit comme une tendance plutôt que comme un chiffre exact unique.", "alt": "L'ALAT (alanine aminotransférase) est une enzyme concentrée dans les cellules du foie, libérée dans le sang lorsque ces cellules sont stressées ou endommagées. Comme elle est relativement spécifique du foie, c'est l'un des principaux marqueurs pour détecter une atteinte ou une inflammation hépatique. Les valeurs peuvent monter avec la stéatose hépatique, une hépatite virale, l'alcool ou certains médicaments. On la lit généralement avec l'ASAT et les autres marqueurs du bilan hépatique.", "ast": "L'ASAT (aspartate aminotransférase) est une enzyme présente dans le foie, mais aussi dans le muscle, le cœur et les globules rouges, libérée lorsque l'un de ces tissus est lésé. Comme elle est moins spécifique du foie que l'ALAT, les deux s'interprètent ensemble, et leur rapport peut orienter sur l'origine et la nature du problème. Un exercice intense ou une lésion musculaire peuvent l'élever indépendamment du foie. C'est l'une des enzymes standard du bilan hépatique.", - "ggt": "La GGT (gamma-glutamyl transférase) est une enzyme du foie et des voies biliaires qui monte en particulier lorsque l'écoulement de la bile est entravé ou en cas d'exposition à l'alcool. Elle sert souvent à clarifier pourquoi un autre marqueur hépatique est élevé — par exemple, pour distinguer une origine hépatique ou biliaire. Elle est sensible mais peu spécifique ; on la lit donc dans le contexte du reste du bilan hépatique. C'est l'un des marqueurs utilisés pour évaluer la santé du foie et des voies biliaires.", + "ggt": "La GGT (gamma-glutamyl transférase) est une enzyme du foie et des voies biliaires qui monte en particulier lorsque l'écoulement de la bile est entravé ou en cas d'exposition à l'alcool. Elle sert souvent à clarifier pourquoi un autre marqueur hépatique est élevé, par exemple pour distinguer une origine hépatique ou biliaire. Elle est sensible mais peu spécifique ; on la lit donc dans le contexte du reste du bilan hépatique. C'est l'un des marqueurs utilisés pour évaluer la santé du foie et des voies biliaires.", "sodium": "Le sodium est le principal électrolyte du liquide situé à l'extérieur des cellules et le moteur essentiel de l'équilibre hydrique et du volume sanguin. Le corps le régule étroitement par la soif, les hormones et les reins, de sorte que son taux sanguin reflète davantage l'équilibre entre l'eau et le sel que le seul apport alimentaire. Des valeurs anormales signalent généralement un trouble de l'équilibre de l'eau plutôt que du sodium lui-même. C'est un élément central du bilan métabolique de base et des électrolytes.", "potassium": "Le potassium est le principal électrolyte à l'intérieur des cellules et il est essentiel à la transmission nerveuse et au rythme régulier du muscle cardiaque. Son taux sanguin est maintenu dans une plage étroite par les reins et les signaux hormonaux, car des valeurs hautes comme basses peuvent perturber l'activité électrique du cœur. Les taux peuvent être influencés par la fonction rénale, l'hydratation et plusieurs médicaments courants. C'est une mesure centrale du bilan des électrolytes.", "hemoglobin": "L'hémoglobine est la protéine contenant du fer des globules rouges, qui fixe l'oxygène dans les poumons et le transporte vers les tissus. Sa concentration est la principale mesure de la capacité du sang à transporter l'oxygène et le marqueur central pour détecter l'anémie. Des valeurs basses peuvent refléter une perte de sang, un manque de fer ou de vitamines, ou une production réduite, tandis que des valeurs élevées peuvent suivre une déshydratation ou des conditions pauvres en oxygène. C'est une pierre angulaire de la numération formule sanguine.", @@ -9309,8 +9481,8 @@ "platelets": "Les plaquettes sont de petits fragments cellulaires du sang qui s'agrègent et forment le bouchon initial arrêtant le saignement d'un vaisseau lésé. Leur nombre reflète l'équilibre entre la production dans la moelle osseuse et la consommation ou la destruction. Un nombre bas peut accroître la tendance au saignement, tandis qu'un nombre élevé peut suivre une inflammation ou d'autres stimuli. Elles font partie de la numération formule sanguine et constituent une jauge de base de la capacité de coagulation.", "apob": "L'apolipoprotéine B est la protéine structurale portée par chacune des particules de lipoprotéines athérogènes ; la doser revient donc à compter directement ces particules plutôt que le cholestérol qu'elles transportent. Comme il y a une molécule d'ApoB sur chaque particule de LDL, de VLDL et apparentée, elle peut capter un risque cardiovasculaire qu'une valeur de cholestérol LDL manque, en particulier lorsque le nombre de particules et leur teneur en cholestérol divergent. C'est pourquoi elle est de plus en plus utilisée pour affiner l'évaluation du risque fondée sur les lipides. Elle s'interprète avec le bilan lipidique habituel.", "lp-a": "La lipoprotéine(a) est une particule semblable au LDL avec une protéine supplémentaire attachée, dont le taux est en grande partie déterminé par la génétique et reste assez stable toute la vie. Une Lp(a) élevée est un facteur de risque indépendant et héréditaire de maladie cardiovasculaire et de rétrécissement de la valve aortique. Comme elle varie peu, on la mesure généralement une seule fois pour établir le risque à vie d'une personne, plutôt que de la suivre de façon répétée. Elle s'exprime en unités molaires et se lit dans le cadre du risque cardiovasculaire global.", - "fasting-insulin": "L'insuline à jeun est le taux d'insuline — l'hormone qui fait passer le glucose du sang vers les cellules — mesuré après un jeûne nocturne. Associée à la glycémie à jeun, elle aide à estimer la résistance à l'insuline, l'état dans lequel le corps a besoin de plus d'insuline pour maintenir le glucose dans sa plage. Une insuline à jeun élevée avec une glycémie normale peut être un signe précoce que cette compensation est en cours. Elle se lit dans le cadre d'un bilan de santé métabolique et non isolément.", - "omega-3-index": "L'indice oméga-3 est la part d'EPA et de DHA — les deux acides gras oméga-3 marins à longue chaîne — parmi les acides gras des membranes des globules rouges. Comme les globules rouges se renouvellent lentement, il reflète l'apport en oméga-3 sur le long terme plutôt qu'un repas récent. Un indice plus élevé a été associé, dans des études d'observation, à un risque cardiovasculaire moindre. Il est suivi comme un marqueur stable du statut en oméga-3, qui répond à l'alimentation sur plusieurs mois." + "fasting-insulin": "L'insuline à jeun est le taux d'insuline (l'hormone qui fait passer le glucose du sang vers les cellules) mesuré après un jeûne nocturne. Associée à la glycémie à jeun, elle aide à estimer la résistance à l'insuline, l'état dans lequel le corps a besoin de plus d'insuline pour maintenir le glucose dans sa plage. Une insuline à jeun élevée avec une glycémie normale peut être un signe précoce que cette compensation est en cours. Elle se lit dans le cadre d'un bilan de santé métabolique et non isolément.", + "omega-3-index": "L'indice oméga-3 est la part d'EPA et de DHA (les deux acides gras oméga-3 marins à longue chaîne) parmi les acides gras des membranes des globules rouges. Comme les globules rouges se renouvellent lentement, il reflète l'apport en oméga-3 sur le long terme plutôt qu'un repas récent. Un indice plus élevé a été associé, dans des études d'observation, à un risque cardiovasculaire moindre. Il est suivi comme un marqueur stable du statut en oméga-3, qui répond à l'alimentation sur plusieurs mois." }, "apob": "Apolipoprotéine B (ApoB)", "lp-a": "Lipoprotéine(a)", @@ -9328,7 +9500,7 @@ "acceptImageOnly": "JPEG, PNG ou WebP", "extracting": "Lecture de votre compte rendu…", "readingOnDevice": "Lecture sur votre appareil…", - "localModeHint": "Lu sur votre appareil, puis envoyé sous forme de texte. Moins précis — vérifiez chaque valeur.", + "localModeHint": "Lu sur votre appareil, puis envoyé sous forme de texte. Moins précis : vérifiez chaque valeur.", "rangeFromScan": "Valeurs de référence issues du scan", "rangeVerify": "Vérifiez les valeurs de référence", "reviewTitle": "Vérifier avant d'enregistrer", @@ -9343,12 +9515,12 @@ "dateLabel": "Date", "refLowLabel": "Référence basse", "refHighLabel": "Référence haute", - "newBiomarker": "Nouveau biomarqueur — sera créé", + "newBiomarker": "Nouveau biomarqueur, sera créé", "linksExisting": "Lié au {name} existant", "duplicateWarning": "Une valeur correspondante existe déjà", "lowConfidence": "Veuillez vérifier cette valeur", "lowConfidenceBadge": "Confiance faible", - "valueUnreadable": "Valeur illisible — saisissez-la manuellement", + "valueUnreadable": "Valeur illisible : saisissez-la manuellement", "saveSelected": "Enregistrer {count} sélectionnées", "discardAll": "Tout abandonner", "savedToast": "{count} résultats de laboratoire enregistrés", @@ -9464,6 +9636,10 @@ "vaccinations": { "label": "Vaccinations", "description": "Le carnet de vaccination structuré : dose, série, prochain rappel." + }, + "timeline": { + "label": "Chronologie", + "description": "Des années d’un coup d’œil : maladies, médicaments et consultations à côté de vos valeurs, ainsi que vos propres événements de vie." } }, "moduleOff": { @@ -9495,8 +9671,8 @@ }, "form": { "date": "Date", - "catalogLabel": "Vaccin (catalogue)", - "identityHint": "Choisissez dans le catalogue ou saisissez ci-dessous : l'un suffit.", + "catalogLabel": "Vaccin", + "identityHint": "Choisissez un vaccin dans la liste ou saisissez-le ci-dessous. L'un suffit.", "catalogNone": "Choisir un vaccin", "catalogSearch": "Rechercher des vaccins", "catalogNoMatch": "Aucune entrée du catalogue trouvée", @@ -9616,10 +9792,31 @@ "overdue": "Rappel en retard depuis le {date}", "dueSoon": "Rappel prévu le {date}", "current": "Prochain rappel le {date}" + }, + "custom": { + "title": "Vaccins personnels", + "add": "Ajouter un vaccin personnel", + "name": "Nom", + "components": "Protège contre", + "seriesDoses": "Doses de la primovaccination", + "boosterMonths": "Rappel tous les (mois)", + "saved": "Vaccin enregistré", + "deleted": "Vaccin supprimé", + "deleteConfirm": "Supprimer ce vaccin ? Les doses déjà saisies gardent leur nom.", + "saveFailed": "Le vaccin n’a pas pu être enregistré.", + "nameTaken": "Tu as déjà un vaccin portant ce nom.", + "editTitle": "Modifier le vaccin personnel", + "description": "Un vaccin absent du catalogue, compté comme n'importe quelle entrée du catalogue.", + "componentsHint": "Choisissez chaque maladie contre laquelle il protège.", + "boosterHint": "Laissez vide s'il n'y a pas de rappel.", + "protectsAgainst": "Protège contre {list}.", + "deleteTitle": "Retirer ce vaccin personnel ?", + "sectionDescription": "Les vaccins que vous avez ajoutés parce que le catalogue ne les connaît pas.", + "catalogGroup": "Catalogue" } }, "illness": { - "title": "Journal de maladie", + "title": "Maladie", "subtitle": "Un suivi rétrospectif de vos épisodes de maladie et d'affections.", "settings": { "viewDescription": "Choisis si les épisodes s'affichent en cartes ou en liste compacte.", @@ -9706,7 +9903,7 @@ }, "correlation": { "title": "Comment cela s'est déroulé", - "learning": "Encore en apprentissage — enregistre quelques jours de plus pour voir des tendances ici.", + "learning": "Encore en apprentissage. Enregistre quelques jours de plus pour voir des tendances ici.", "preOnsetTitle": "Comment cela s'est annoncé", "preOnsetBody": "Signaux qui se sont écartés de ta base avant le début que tu as noté.", "nadirTitle": "Ce qui a changé", @@ -9733,7 +9930,7 @@ "countOne": "Tu as enregistré 1 épisode sur cette période.", "countOther": "Tu as enregistré {count} épisodes sur cette période.", "typicalGap": "Écart de récupération typique : {days} jours entre le mieux-être et la stabilisation de tes valeurs.", - "typicalGapWithDriver": "Écart de récupération typique : {days} jours – tu te sens généralement mieux avant que {vital} ne se stabilise.", + "typicalGapWithDriver": "Écart de récupération typique : {days} jours. Tu te sens généralement mieux avant que {vital} ne se stabilise.", "typicalGapLearning": "Encore quelques épisodes résolus et un écart de récupération typique apparaîtra ici.", "none": "Aucun épisode enregistré sur cette période.", "analyze": "Analyser l'écart de récupération" @@ -9807,7 +10004,7 @@ "dismiss": "Pas maintenant", "applied": "Ajouté", "dismissed": "Ignoré", - "failed": "Impossible d'ajouter — réessaie", + "failed": "Impossible d'ajouter. Réessaie", "checkup": { "title": "Ajouter un rappel d'examen ?" }, @@ -9835,9 +10032,9 @@ "reviewPrefix": "Point d'étape : {when}", "accept": "Adopter le plan", "decline": "Refuser", - "accepted": "Plan adopté — le Coach le gardera en tête.", + "accepted": "Plan adopté : le Coach le gardera en tête.", "declined": "Proposition refusée.", - "failed": "Échec de l'action — veuillez réessayer.", + "failed": "Échec de l'action. Veuillez réessayer.", "markMet": "Objectif atteint", "abandon": "Terminer le plan", "remove": "Supprimer", @@ -9859,7 +10056,7 @@ }, "refusal": { "outOfScope": "Je peux uniquement t'aider avec les données de santé de ton journal : mesures, médicaments, humeur, tendances. Je ne réponds pas aux questions en dehors de ce cadre.", - "promptInjection": "J'ai repéré des instructions qui cherchent à contourner mes consignes. Je suis le Coach HealthLog et je résume uniquement tes propres données de santé — reformule ta question dans ce cadre, s'il te plaît.", + "promptInjection": "J'ai repéré des instructions qui cherchent à contourner mes consignes. Je suis le Coach HealthLog et je résume uniquement tes propres données de santé. Reformule ta question dans ce cadre, s'il te plaît.", "conversationPoisoned": "Un message précédent de cette conversation contient des instructions qui cherchent à contourner mes consignes. Démarre une nouvelle conversation, s'il te plaît." }, "noRecordedData": "Aucune mesure n'est enregistrée pour ce que tu demandes, je ne peux donc te donner aucun chiffre. Dès que tu en auras saisi, repose-moi la question et je partirai de tes propres valeurs.", @@ -9895,7 +10092,9 @@ "illness": "Maladie et récupération", "cycle": "Cycle", "correlations": "Liens entre indicateurs", - "snapshot": "Résumé des données" + "snapshot": "Résumé des données", + "environment": "Météo et air", + "day": "Une journée" }, "window": { "last7days": "7 derniers jours", @@ -10084,10 +10283,10 @@ "saved": "Enregistré", "error": "Une erreur est survenue. Réessaie.", "instrument": { - "phq9": "PHQ-9 · humeur", - "gad7": "GAD-7 · inquiétude", - "who5": "WHO-5 · bien-être", - "sci": "SCI · sommeil" + "phq9": "PHQ-9 (humeur)", + "gad7": "GAD-7 (inquiétude)", + "who5": "WHO-5 (bien-être)", + "sci": "SCI (sommeil)" }, "instrumentSub": { "phq9": "Dépistage de la dépression, 9 questions", @@ -10108,7 +10307,7 @@ "2": "Très gênant", "3": "Extrêmement gênant" }, - "functionalOptionalHint": "Facultatif — tu peux envoyer sans répondre à cette question.", + "functionalOptionalHint": "Facultatif : tu peux envoyer sans répondre à cette question.", "result": { "title": "Ton résultat", "totalLabel": "Score total", @@ -10528,12 +10727,34 @@ "daylight": "Durée du jour", "precip": "Précipitations", "pressureMean": "Pression atmosphérique", - "pressureDelta": "Variation de pression" + "pressureDelta": "Variation de pression", + "pm25": "Particules fines (PM2,5)", + "pm10": "Particules (PM10)", + "no2": "Dioxyde d’azote", + "ozone8h": "Ozone (maximum sur 8 heures)", + "europeanAqi": "Indice européen de qualité de l’air", + "uvIndexMax": "Indice UV (maximum)", + "dustMax": "Poussières du Sahara", + "pollenMax": "Pollen (valeur la plus élevée)" + }, + "chip": { + "day": "Météo du {date}", + "pollenHigh": "Beaucoup de pollen : {kinds}", + "hotNight": "Nuit chaude", + "veryPoorAir": "Air très mauvais" + }, + "pollen": { + "alder": "aulne", + "birch": "bouleau", + "grass": "graminées", + "mugwort": "armoise", + "olive": "olivier", + "ragweed": "ambroisie" } }, "documents": { "title": "Documents", - "subtitle": "Chaque courrier, compte rendu et scan au même endroit, consultable — chiffré au repos.", + "subtitle": "Chaque courrier, compte rendu et scan au même endroit, consultable et chiffré au repos.", "pageUpload": "Téléverser", "ownerOnlyUpload": "Seul le titulaire du dossier peut ajouter des documents.", "kind": { @@ -10575,15 +10796,15 @@ }, "error": { "fileTooLarge": "Ce fichier dépasse la limite de {maxSize}.", - "quotaExceeded": "Le stockage est plein — {used} sur {quota} utilisés.", + "quotaExceeded": "Le stockage est plein : {used} sur {quota} utilisés.", "unsupportedType": "Ce type de fichier n'est pas pris en charge.", "purged": "Ce document a été définitivement supprimé et ne peut pas être restauré.", "duplicateExists": "Un document identique est déjà enregistré.", - "rateLimited": "Trop de téléversements — réessayez dans quelques minutes.", + "rateLimited": "Trop de téléversements. Réessayez dans quelques minutes.", "generic": "Le téléversement a échoué. Veuillez réessayer." }, "toast": { - "duplicate": "Déjà enregistré — le document existant est mis en évidence.", + "duplicate": "Déjà enregistré. Le document existant est mis en évidence.", "deleted": "Document supprimé", "restoreFailed": "Impossible de restaurer le document." }, @@ -10606,7 +10827,7 @@ }, "empty": { "title": "Votre coffre à documents est vide", - "description": "Conservez courriers médicaux, résultats de laboratoire, imagerie, ordonnances et carnets de vaccination — chiffrés et consultables par type, affection et année.", + "description": "Conservez courriers médicaux, résultats de laboratoire, imagerie, ordonnances et carnets de vaccination. Ils sont chiffrés et consultables par type, affection et année.", "action": "Téléverser un premier document", "noMatchesTitle": "Aucun document correspondant", "noMatchesDescription": "Rien ne correspond à la recherche et aux filtres actuels." @@ -10646,9 +10867,9 @@ "linkCondition": "Lier", "noConditions": "Lié à aucune affection", "previewUnavailable": "Pas d'aperçu intégré pour ce type de fichier", - "uploadedMeta": "Ajouté le {date} · {size}", + "uploadedMeta": "Ajouté le {date}, {size}", "importedFrom": "Importé depuis {system}", - "importedFromWithId": "Importé depuis {system} · ID {id}", + "importedFromWithId": "Importé depuis {system}, ID {id}", "sourceOther": "un autre système", "share": "Partager", "labValuesLink": "Voir {count} valeurs de laboratoire", @@ -10748,7 +10969,7 @@ "CONDITION": "Affection", "MEDICATION_STATEMENT": "Médicament" }, - "statedRange": "plage indiquée {range}", + "statedRange": "Plage indiquée {range}", "needsCheck": "Vérifiez cette valeur par rapport au document, puis enregistrez-la.", "verify": "Vérifier", "verifySave": "Enregistrer la valeur", @@ -10864,6 +11085,21 @@ "finishedHint": "Cherchez à nouveau ou changez un filtre pour choisir d'autres documents.", "maxReached": "Vous pouvez importer au plus {max} documents à la fois.", "noMatchHint": "Essayez une partie du nom, ou effacez l'étiquette et les dates." + }, + "customize": "Personnaliser l'affichage", + "layout": { + "viewDescription": "Des cartes avec aperçu ou une liste compacte.", + "arrangementHeading": "Disposition", + "arrangementDescription": "Chaque mois dans son propre bloc, ou tout à la suite sur toute la largeur.", + "stacked": "Mois empilés", + "flow": "En continu", + "saveFailed": "Impossible d'enregistrer l'affichage." + }, + "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.", + "action": "Supprimer" } }, "selfDescription": { @@ -10950,7 +11186,7 @@ "genericDescription": "Une mesure que vous suivez. Ajoutez des valeurs pour la suivre dans le temps.", "emptyTitle": "Aucune valeur pour l'instant", "emptyDescription": "Ajoutez votre première valeur pour commencer à représenter cette mesure.", - "valuesTitle": "{metric} — toutes les valeurs", + "valuesTitle": "{metric} : toutes les valeurs", "valuesDescription": "Toutes les valeurs enregistrées pour cette mesure.", "backToMetric": "Retour à {metric}" } @@ -10997,26 +11233,26 @@ "lastAttemptReason": { "unitMismatch": "Les valeurs reçues ne correspondaient pas aux unités attendues par l'appli pour ces nutriments, elles n'ont donc pas pu être enregistrées.", "valueOutOfRange": "Les quantités reçues étaient supérieures à ce que l'appli peut raisonnablement enregistrer, elles n'ont donc pas pu être sauvegardées.", - "dayInvalid": "Les entrées portaient une date que le serveur n'a pas pu accepter. Cela peut arriver quand l'horloge ou le fuseau horaire du côté qui envoie les données est incorrect — cela vaut la peine de vérifier.", + "dayInvalid": "Les entrées portaient une date que le serveur n'a pas pu accepter. Cela peut arriver quand l'horloge ou le fuseau horaire du côté qui envoie les données est incorrect ; cela vaut la peine de vérifier.", "upsertFailed": "Ces entrées sont arrivées jusqu'au serveur, mais un problème de notre côté a empêché leur enregistrement. Tu n'as rien à faire.", "unknown": "Ces entrées sont arrivées mais n'ont pas pu être enregistrées." } }, "hydration": { - "referenceMeta": "Apport de référence {value} {unit}/jour · EFSA" + "referenceMeta": "Apport de référence {value} {unit}/jour (EFSA)" }, "caffeine": { - "ceilingMeta": "Plafond de sécurité {value} {unit}/jour · EFSA" + "ceilingMeta": "Plafond de sécurité {value} {unit}/jour (EFSA)" }, "micronutrients": { "title": "Vitamines et minéraux", - "rowMeta": "{days} sur {window} jours · réf. {value} {unit} (EFSA)", + "rowMeta": "{days} sur {window} jours, réf. {value} {unit} (EFSA)", "rowMetaNoRef": "{days} sur {window} jours", "footer": "{tracked} des {total} micronutriments suivis ont des données sur cette période.", "emptyTitle": "Aucune donnée de micronutriments synchronisée pour l'instant", "loadError": "Tes données de nutriments n'ont pas pu être chargées" }, - "sparseNote": "Une valeur basse ici signifie généralement un suivi partiel, pas une carence — les totaux journaliers Apple Health reflètent ce que vos applis ont enregistré, pas l'apport alimentaire total." + "sparseNote": "Une valeur basse ici signifie généralement un suivi partiel, pas une carence : les totaux journaliers Apple Health reflètent ce que vos applis ont enregistré, pas l'apport alimentaire total." }, "daily": { "line": { @@ -11025,7 +11261,7 @@ }, "push": { "title": "Votre résumé du matin", - "bodyProvisional": "{line} · Le sommeil de la nuit dernière n'est pas encore disponible." + "bodyProvisional": "{line}\nLe sommeil de la nuit dernière n'est pas encore disponible." }, "item": { "doseWindow": { @@ -11043,7 +11279,7 @@ }, "syncIssue": { "title": "La synchronisation demande votre attention", - "body": "{integration} ne se synchronise pas — reconnecte-le pour garder tes données à jour." + "body": "{integration} ne se synchronise pas. Reconnecte-le pour garder tes données à jour." }, "coachCheckin": { "title": "Il est temps de revoir un plan", @@ -11055,10 +11291,10 @@ "tensionWindow": { "title": "Peut-être un peu de tension", "body": { - "morning": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment ce matin alors que vous étiez au calme — peut-être un peu de tension.", - "afternoon": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment cet après-midi alors que vous étiez au calme — peut-être un peu de tension.", - "evening": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment ce soir alors que vous étiez au calme — peut-être un peu de tension.", - "night": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment cette nuit alors que vous étiez au calme — peut-être un peu de tension." + "morning": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment ce matin alors que vous étiez au calme, peut-être un peu de tension.", + "afternoon": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment cet après-midi alors que vous étiez au calme, peut-être un peu de tension.", + "evening": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment ce soir alors que vous étiez au calme, peut-être un peu de tension.", + "night": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment cette nuit alors que vous étiez au calme, peut-être un peu de tension." } }, "ecgNewRecording": { @@ -11083,9 +11319,9 @@ }, "upcomingVisit": { "title": "Rendez-vous à venir", - "bodyToday": "{what} — aujourd'hui", - "bodyTomorrow": "{what} — demain", - "bodyDayAfterTomorrow": "{what} — après-demain" + "bodyToday": "{what}, aujourd'hui", + "bodyTomorrow": "{what}, demain", + "bodyDayAfterTomorrow": "{what}, après-demain" } }, "action": { @@ -11104,7 +11340,7 @@ "scoreLabel": "Score de santé", "deltaVsBaseline": "{delta} par rapport à ta référence", "deltaFlat": "Conforme à ta référence", - "sleepPending": "Le sommeil de la nuit dernière n'est pas encore arrivé — la lecture du jour est provisoire.", + "sleepPending": "Le sommeil de la nuit dernière n'est pas encore arrivé : la lecture du jour est provisoire.", "worthALook": "À regarder", "allClear": "Rien ne requiert ton attention aujourd'hui.", "ringLink": "Ouvrir les détails de {metric}", @@ -11358,7 +11594,7 @@ "errorScope": "Ces rubriques ne peuvent pas être partagées ainsi. Choisissez au moins une rubrique, ou partagez le dossier complet.", "errorManageBrowserOnly": "Un accès de gestion ne peut être proposé que depuis un navigateur.", "errorStepUp": "Confirmez votre deuxième facteur, puis réessayez.", - "errorOffline": "Cela n'a pas atteint le serveur. Votre invitation est toujours là — vérifiez votre connexion et réessayez." + "errorOffline": "Cela n'a pas atteint le serveur. Votre invitation est toujours là : vérifiez votre connexion et réessayez." }, "given": { "title": "Qui peut ouvrir mon dossier", @@ -11413,7 +11649,7 @@ "managed": { "title": "Profils dont vous vous occupez", "description": "Dossiers de santé sans identifiants propres.", - "explainer": "Un profil géré est un dossier de santé pour quelqu'un qui ne se connecte pas lui-même — un enfant, ou une personne dont vous vous occupez. Il n'a ni identifiants ni adresse e-mail, et vous en devenez la première personne responsable. Vous pourrez en ajouter d'autres plus tard, et chacune pourra entrer dans le dossier pour le tenir à jour.", + "explainer": "Un profil géré est un dossier de santé pour quelqu'un qui ne se connecte pas lui-même, comme un enfant ou une personne dont vous vous occupez. Il n'a ni identifiants ni adresse e-mail, et vous en devenez la première personne responsable. Vous pourrez en ajouter d'autres plus tard, et chacune pourra entrer dans le dossier pour le tenir à jour.", "nameLabel": "Nom", "namePlaceholder": "Le nom sous lequel vous reconnaîtrez ce dossier", "nameHint": "Jusqu'à 80 caractères. Affiché partout où ce dossier apparaît.", @@ -11435,7 +11671,7 @@ "creating": "Création…", "created": "{name} est en place. Vous pouvez entrer dans ce dossier depuis le menu du compte.", "errorStepUp": "Confirmez votre deuxième facteur, puis réessayez.", - "errorOffline": "Cela n'a pas atteint le serveur. Rien n'a changé — vérifiez votre connexion et réessayez.", + "errorOffline": "Cela n'a pas atteint le serveur. Rien n'a changé. Vérifiez votre connexion et réessayez.", "errorInvalid": "Le serveur a refusé ces informations. Vérifiez le nom, la date et le fuseau horaire.", "errorFailed": "Le profil n'a pas pu être créé.", "edit": "Modifier", @@ -11446,7 +11682,7 @@ "editErrorFailed": "Les modifications n'ont pas pu être enregistrées.", "delete": "Supprimer le profil", "deleteTitle": "Supprimer {name} ?", - "deleteBody": "Tout ce qui a été enregistré pour {name} disparaît avec le profil — mesures, médicaments, documents et notes. Cette action est irréversible.", + "deleteBody": "Tout ce qui a été enregistré pour {name} disparaît avec le profil : mesures, médicaments, documents et notes. Cette action est irréversible.", "deleteGuardiansOne": "{count} personne s'occupe aujourd'hui de ce dossier et le perdra.", "deleteGuardiansFew": "{count} personnes s'occupent aujourd'hui de ce dossier et le perdront.", "deleteGuardiansOther": "{count} personnes s'occupent aujourd'hui de ce dossier et le perdront.", @@ -11475,7 +11711,47 @@ "guardianErrorFailed": "L'invitation n'a pas pu être envoyée.", "errorMfaRequired": "Cela demande un deuxième facteur, et votre compte n'en a pas encore. Configurez-en un dans Réglages → Sécurité, puis réessayez.", "rosterLoadError": "Impossible de charger qui s'occupe de ce dossier.", - "errorRateLimit": "Trop de profils créés dans la dernière heure. Patientez un peu et réessayez." + "errorRateLimit": "Trop de profils créés dans la dernière heure. Patientez un peu et réessayez.", + "handover": { + "action": "Transmettre", + "title": "Transmettre à {name}", + "description": "Donnez à {name} sa propre connexion. Rien n'est copié ni déplacé : le dossier devient son compte.", + "guardiansLabel": "Accès après la transmission", + "guardiansHint": "Votre proposition. {name} la voit et décide après la connexion.", + "access": { + "end": "Aucun accès", + "read": "Lecture seule", + "manage": "Gérer" + }, + "expiryLabel": "Lien valable pendant", + "expiry1": "1 jour", + "expiry7": "7 jours", + "expiry14": "14 jours", + "minorHint": "{name} a moins de 16 ans. Garder l'accès en lecture et parler de cette étape ensemble vaut la peine.", + "irreversible": "Dès que {name} utilise le lien, la transmission ne peut plus être annulée. Le dossier lui appartient et c'est à elle ou lui de décider qui garde l'accès.", + "afterwards": "{name} confirme personnellement l'avertissement et tout consentement à l'IA. Les rappels de ce dossier ne vous parviennent plus.", + "replaceNote": "Créer un nouveau lien retire celui qui est ouvert.", + "create": "Créer le lien", + "creating": "Création…", + "linkOnce": "Ce lien n'est affiché que maintenant. Donnez-le à {name} en personne ou par un canal de confiance, ou laissez scanner le code.", + "qrAlt": "Code QR du lien de transmission pour {name}", + "urlLabel": "Lien de transmission", + "copy": "Copier", + "copied": "Lien copié", + "copyError": "Le lien n'a pas pu être copié. Sélectionnez-le et copiez-le à la main.", + "linkExpires": "Valable jusqu'au {date}.", + "done": "Terminé", + "pending": "Un lien de transmission est ouvert jusqu'au {date}.", + "replace": "Nouveau lien", + "revoke": "Retirer le lien", + "revoked": "Lien retiré", + "revokeFailed": "Le lien n'a pas pu être retiré. Réessayez.", + "errorFailed": "Le lien de transmission n'a pas pu être créé.", + "errorRosterChanged": "Entre-temps, les personnes qui s'occupent du dossier ont changé. Fermez ceci et rouvrez-le.", + "oidcOnly": "Transmettre un profil n'est pas possible tant que ce serveur n'autorise que l'authentification unique.", + "loadError": "L'état de la transmission n'a pas pu être chargé.", + "entryHint": "Quand {name} souhaite tenir ce dossier de façon autonome." + } }, "toast": { "savedTo": "Enregistré dans le dossier de {name}" @@ -11594,7 +11870,13 @@ "symptomEventDelete": "{name} a supprimé une entrée de symptôme", "symptomDefinitionCreate": "{name} a ajouté un symptôme", "symptomDefinitionUpdate": "{name} a modifié un symptôme", - "symptomDefinitionDelete": "{name} a retiré un symptôme" + "symptomDefinitionDelete": "{name} a retiré un symptôme", + "customVaccineCreate": "{name} a ajouté un vaccin personnel", + "customVaccineUpdate": "{name} a modifié un vaccin personnel", + "customVaccineDelete": "{name} a retiré un vaccin personnel", + "lifeEventCreate": "{name} a ajouté un événement de vie", + "lifeEventUpdate": "{name} a modifié un événement de vie", + "lifeEventDelete": "{name} a supprimé un événement de vie" }, "section": { "measurements": { @@ -11611,7 +11893,7 @@ }, "profile": { "label": "Antécédents de santé", - "detail": "Allergies, antécédents familiaux, les informations de votre profil de santé, vos consultations et cabinets, ainsi que votre carnet de vaccination." + "detail": "Allergies, antécédents familiaux, les informations de votre profil de santé, vos consultations et cabinets ainsi que votre carnet de vaccination." }, "illness": { "label": "Maladies", @@ -11632,7 +11914,7 @@ }, "actionError": { "retry": "Réessayer", - "offline": "Cela n'a pas atteint le serveur. Rien n'a changé — vérifiez votre connexion et réessayez.", + "offline": "Cela n'a pas atteint le serveur. Rien n'a changé : vérifiez votre connexion et réessayez.", "rateLimited": "Trop de tentatives d'affilée. Attendez une minute et réessayez.", "acceptExpired": "Cette invitation a expiré. Demandez-en une nouvelle.", "acceptWithdrawn": "Cette invitation a été retirée.", @@ -11644,12 +11926,28 @@ "notFound": "Cet accès n'existe plus.", "revokeFailed": "L'accès n'a pas pu être terminé.", "renounceFailed": "L'accès n'a pas pu être rendu.", - "profileNotFound": "Ce profil n'existe plus — il a peut-être été supprimé, ou votre accès a pris fin.", + "profileNotFound": "Ce profil n'existe plus. Il a peut-être été supprimé, ou votre accès a pris fin.", "deleteProfileFailed": "Le profil n'a pas pu être supprimé.", "removeGuardianFailed": "Cet accès n'a pas pu prendre fin." }, "anamnesis": { "relativeNote": "Un antécédent familial décrit les proches de cette personne. Ne l'ajoutez que si elle voudrait le voir consigné ici." + }, + "handoverDecision": { + "title": "Qui garde l'accès à votre dossier", + "description": "Décidez ce que les personnes qui ont tenu votre dossier peuvent faire désormais.", + "proposed": "Proposé : {access}", + "settled": "Déjà modifié dans Accès partagé", + "empty": "Personne d'autre n'avait accès à votre dossier.", + "later": "Vous pouvez le modifier à tout moment dans Paramètres, Accès partagé.", + "save": "Confirmer", + "saving": "Enregistrement…", + "decideLater": "Plus tard", + "errorFailed": "Votre décision n'a pas pu être enregistrée. Réessayez.", + "loadError": "Les personnes qui tenaient votre dossier n'ont pas pu être chargées.", + "notificationsTitle": "Configurez vos propres rappels", + "notificationsBody": "Jusqu'ici, les rappels de ce dossier parvenaient aux personnes qui le tenaient. Désormais, ils vous parviennent dès que vous ajoutez un canal de notification.", + "notificationsAction": "Ouvrir les notifications" } }, "encounters": { @@ -11670,7 +11968,7 @@ "both": "Des deux côtés", "none": "Non précisé" }, - "bodySiteWithSide": "{site} · {side}", + "bodySiteWithSide": "{site} ({side})", "sectionTitle": "Rendez-vous", "sectionDescription": "Consultations et rendez-vous pris, au même endroit.", "add": "Rendez-vous", @@ -11762,7 +12060,6 @@ "title": "Cabinets et médecins", "description": "Votre carnet d'adresses : saisissez un cabinet une fois, choisissez-le partout.", "linkLabel": "Carnet d'adresses", - "add": "Entrée", "searchPlaceholder": "Rechercher un nom, un cabinet ou une spécialité", "loadError": "Impossible de charger le carnet d'adresses.", "emptyTitle": "Aucune entrée", @@ -11859,7 +12156,6 @@ "unreadableLabel": "Symptôme illisible", "intensityPill": "{value}/10", "entry": { - "sheetTitle": "Noter un symptôme", "sheetDescription": "L'un de tes propres symptômes, son intensité et quand.", "symptom": "Symptôme", "symptomRequired": "Choisis un symptôme.", @@ -11908,5 +12204,411 @@ "title": "Symptômes notés", "filed": "noté avec cette maladie" } + }, + "day": { + "scoreOfMax": "sur {max}", + "cycleDay": "Jour {n} du cycle", + "cyclePhaseLine": { + "MENSTRUAL": "Jour {n} du cycle, phase menstruelle", + "FOLLICULAR": "Jour {n} du cycle, phase folliculaire", + "OVULATORY": "Jour {n} du cycle, phase ovulatoire", + "LUTEAL": "Jour {n} du cycle, phase lutéale" + }, + "previousDay": "Jour précédent", + "nextDay": "Jour suivant", + "close": "Fermer le jour", + "hidePanel": "Masquer le jour", + "showPanel": "Afficher {date}", + "openDay": "Ouvrir le jour", + "viewWholeDay": "Voir toute la journée", + "captureForDay": "Saisir pour ce jour", + "empty": "Aucune entrée pour ce jour.", + "notShared": "Certaines rubriques ne sont pas partagées avec vous.", + "inTimeline": "Dans la chronologie", + "askCoach": "Interroger le Coach sur ce jour", + "chartHint": "Cliquez sur un point pour ouvrir son jour.", + "loadFailed": "Impossible de charger ce jour.", + "dayOf": "Jour {n} sur {m}", + "dayN": "Jour {n}", + "usualRange": "Habituellement de {lo} à {hi}", + "groups": { + "scores": "Scores", + "running": "En cours ce jour-là", + "values": "Valeurs", + "happened": "Ce qui s’est passé" + }, + "notable": { + "extremeHigh": "Valeur journalière la plus haute depuis {since}", + "extremeLow": "Valeur journalière la plus basse depuis {since}", + "firstValue": "Première mesure de ce type", + "gap": "{days} jours sans mesure" + }, + "previousDayShortcut": "Jour précédent (Alt + flèche gauche)", + "nextDayShortcut": "Jour suivant (Alt + flèche droite)", + "captureShort": "Enregistrer", + "inTimelineShort": "Chronologie", + "askCoachPrompt": "Que s’est-il passé le {date} ?", + "chartHintTouch": "Touchez un point pour voir sa valeur.", + "chartHintBar": "Cliquez sur une barre pour ouvrir son jour.", + "chartHintTouchBar": "Touchez une barre pour voir ses valeurs.", + "chartHintCell": "Cliquez sur un jour pour l’ouvrir.", + "chartHintTouchCell": "Touchez un jour pour voir sa valeur.", + "coachChips": "Jours dans cette réponse", + "description": "Tout ce que vos entrées contiennent pour ce jour.", + "openDocument": "Ouvrir le document", + "previousReading": "Avant, {date}", + "rugLegend": "Les points sous l’axe marquent les jours avec entrées, un anneau un jour remarquable.", + "sheetExpand": "Agrandir en pleine hauteur", + "sheetShrink": "Réduire", + "valuesLegend": "Point pour ce jour, barre pour les 30 jours précédents", + "valuesAllOne": "Toutes les valeurs", + "valuesAllFew": "Les {count} valeurs", + "valuesAllOther": "Les {count} valeurs", + "valuesFewer": "Moins de valeurs", + "countEntriesOne": "1 entrée", + "countEntriesFew": "{count} entrées", + "countEntriesOther": "{count} entrées", + "meanOfReadingsOne": "Moyenne de 1 mesure", + "meanOfReadingsFew": "Moyenne de {count} mesures", + "meanOfReadingsOther": "Moyenne de {count} mesures", + "sinceLastVisit": { + "title": "Depuis la dernière visite le {date}", + "countOne": "1 entrée", + "countFew": "{count} entrées", + "countOther": "{count} entrées", + "allOne": "Tout afficher", + "allFew": "Afficher les {count}", + "allOther": "Afficher les {count}", + "fewer": "Afficher moins", + "note": "Établi à partir de vos entrées, sans évaluation. Chaque date ouvre la journée entière.", + "report": "Rapport médical du {date} à aujourd’hui" + }, + "travel": "En déplacement", + "event": { + "started": "Début : {label}", + "ended": "Fin : {label}", + "paused": "Pause : {label}", + "resumed": "Reprise : {label}", + "illnessBegan": "Début : {label}", + "illnessResolved": "Guérison : {label}" + }, + "pickDate": "Choisir une date", + "pickerTitle": "Aller à un jour", + "today": "Aujourd’hui", + "hasEntries": "avec des entrées", + "dayNSince": "Jour {n}, depuis le {date}", + "sinceDate": "Depuis le {date}" + }, + "timeline": { + "title": "Chronologie", + "subtitle": "Ce qui s’est passé et quand, à côté de vos valeurs.", + "standing": "Permanent", + "startUnknown": "début inconnu", + "zoom": { + "all": "Tout", + "year": "12 mois", + "quarter": "3 mois", + "range": "Période" + }, + "lanes": { + "life": "Vie", + "illness": "Maladies", + "allergies": "Allergies", + "medications": "Médicaments", + "vaccinations": "Vaccinations", + "visits": "Médecin et interventions", + "labs": "Analyses", + "documents": "Documents", + "cycle": "Cycle" + }, + "readiness": { + "title": "Ce que votre chronologie contient déjà", + "subtitle": "Un état des lieux honnête avant de l’ouvrir.", + "menuEntry": "Couverture des données", + "hint": "Les pistes vides restent masquées tant qu’elles ne contiennent rien. Les modules désactivés n’apparaissent jamais. Vous retrouverez cet aperçu plus tard sous Couverture des données, dans la chronologie.", + "later": "Plus tard", + "open": "Ouvrir la chronologie", + "verdict": { + "carries": "Elle est déjà bien remplie.", + "thin": "Encore maigre. Pour l’instant, la chronologie montre surtout vos valeurs." + }, + "status": { + "carries": "remplie", + "thin": "maigre", + "empty": "vide" + }, + "lanes": { + "values": "Valeurs", + "mood": "Humeur", + "environment": "Environnement", + "life": "Événements de vie" + }, + "meter": "{carrying} pistes sur {total} sont remplies", + "carriesDetail": "{carrying} pistes sur {total} donnent une vue d’ensemble depuis {since}.", + "loadFailed": "L’inventaire n’a pas pu être chargé.", + "card": { + "title": "Ce que la chronologie contient déjà", + "summary": "Elle remonte à {since} ; {carrying} pistes sur {total} sont déjà remplies.", + "dismiss": "Masquer" + }, + "detail": { + "valuesOne": "{count} mesure depuis {since}", + "valuesFew": "{count} mesures depuis {since}", + "valuesOther": "{count} mesures depuis {since}", + "illnessOne": "{count} épisode ; chroniques : {chronic}", + "illnessFew": "{count} épisodes ; chroniques : {chronic}", + "illnessOther": "{count} épisodes ; chroniques : {chronic}", + "allergiesOne": "{count} allergie", + "allergiesFew": "{count} allergies", + "allergiesOther": "{count} allergies", + "medicationsOne": "{count} médicament ; sans date de début : {missingStart}", + "medicationsFew": "{count} médicaments ; sans date de début : {missingStart}", + "medicationsOther": "{count} médicaments ; sans date de début : {missingStart}", + "medicationsDatedOne": "{count} médicament avec date de début", + "medicationsDatedFew": "{count} médicaments avec date de début", + "medicationsDatedOther": "{count} médicaments avec date de début", + "vaccinationsOne": "{count} vaccin", + "vaccinationsFew": "{count} vaccins", + "vaccinationsOther": "{count} vaccins", + "visitsOne": "{count} consultation, dont {procedures} avec intervention", + "visitsFew": "{count} consultations, dont {procedures} avec intervention", + "visitsOther": "{count} consultations, dont {procedures} avec intervention", + "visitsPlannedOne": "{count} consultation, seulement prévue", + "visitsPlannedFew": "{count} consultations, seulement prévues", + "visitsPlannedOther": "{count} consultations, seulement prévues", + "labsOne": "{count} jour avec résultats", + "labsFew": "{count} jours avec résultats", + "labsOther": "{count} jours avec résultats", + "documentsOne": "{count} document daté", + "documentsFew": "{count} documents datés", + "documentsOther": "{count} documents datés", + "lifeOne": "{count} événement de vie", + "lifeFew": "{count} événements de vie", + "lifeOther": "{count} événements de vie", + "cycleOne": "{count} cycle", + "cycleFew": "{count} cycles", + "cycleOther": "{count} cycles", + "moodOne": "{count} entrée depuis {since}", + "moodFew": "{count} entrées depuis {since}", + "moodOther": "{count} entrées depuis {since}", + "environmentOne": "{count} jour avec données d’environnement", + "environmentFew": "{count} jours avec données d’environnement", + "environmentOther": "{count} jours avec données d’environnement", + "empty": "Rien pour l’instant" + }, + "gap": { + "valuesEmpty": "Connecter des sources", + "illnessEmpty": "Ajouter un épisode", + "medicationsWithoutStart": "Compléter", + "visitsEmpty": "Ajouter une visite", + "documentsUndated": "Vérifier", + "labsEmpty": "Ajouter un résultat", + "vaccinationsEmpty": "Ajouter des vaccins", + "lifeEventsEmpty": "Ajouter" + }, + "gapLine": { + "medicationsWithoutStartOne": "{count} médicament sans date de début", + "medicationsWithoutStartFew": "{count} médicaments sans date de début", + "medicationsWithoutStartOther": "{count} médicaments sans date de début", + "documentsUndatedOne": "{count} document sans date", + "documentsUndatedFew": "{count} documents sans date", + "documentsUndatedOther": "{count} documents sans date", + "valuesEmpty": "Pas encore de valeurs sur une longue période", + "illnessEmpty": "Pas encore de maladies avec une date de début", + "visitsEmpty": "Pas encore de consultations effectuées", + "labsEmpty": "Pas encore de résultats d’analyses", + "vaccinationsEmpty": "Pas encore de vaccins", + "lifeEventsEmpty": "Aucun événement de vie pour l’instant" + } + }, + "legendSpan": "Période", + "legendEvent": "Événement", + "legendMeanQuarter": "Moyenne trimestrielle", + "legendMeanMonth": "Moyenne mensuelle", + "legendMeanWeek": "Moyenne hebdomadaire", + "legendThin": "Moins de 3 mesures", + "legendGap": "Courte période sans mesures", + "today": "Aujourd’hui", + "noDataYet": "Pas encore de données", + "layers": "Calques", + "layersLanes": "Pistes", + "zoomLabel": "Période affichée", + "chartLabel": "Chronologie de {from} à {to}", + "keyboardHint": "Les flèches déplacent la sélection, Début et Fin vont aux extrémités, Entrée ouvre le jour.", + "loadFailed": "La chronologie n’a pas pu être chargée.", + "empty": { + "title": "Rien dans la chronologie pour l’instant", + "description": "Les entrées datées apparaîtront ici dès qu’il y en aura." + }, + "values": { + "menuTitle": "Valeurs sous les lignes", + "trigger": "Valeurs ({count})", + "noneInWindow": "Aucune valeur sur cette période", + "series": { + "BLOOD_PRESSURE_SYS": "Tension systolique", + "BLOOD_PRESSURE_DIA": "Tension diastolique" + }, + "noValue": "aucune valeur", + "bloodPressure": "Tension artérielle", + "readingsOne": "1 mesure", + "readingsFew": "moyenne de {count} mesures", + "readingsOther": "moyenne de {count} mesures" + }, + "table": { + "caption": "Entrées de la chronologie", + "lane": "Piste", + "entry": "Entrée", + "start": "Début", + "end": "Fin", + "seriesCaption": "Moyennes par période", + "period": "Période" + }, + "selection": { + "empty": "Aucune entrée sur cette période", + "since": "depuis le {date}", + "range": "{from} au {to}", + "mean": "Ø {values}", + "week": "Semaine du {date}", + "hintClick": "Cliquez sur un moment pour le voir ici", + "hintTap": "Touchez un moment pour le voir ici" + }, + "chronicle": { + "months": "Mois", + "years": "Années", + "groupingLabel": "Regroupement", + "empty": "Aucune entrée de {from} à {to}", + "emptySingle": "Aucune entrée en {period}", + "ended": "Fin : {label}", + "afterDaysOne": "après 1 jour", + "afterDaysFew": "après {count} jours", + "afterDaysOther": "après {count} jours", + "notable": { + "extremeHigh": "Valeur la plus haute depuis longtemps", + "extremeLow": "Valeur la plus basse depuis longtemps", + "firstValue": "Première valeur d’une mesure", + "gap": "Une longue interruption des valeurs" + }, + "notableMeta": "Ouvrez le jour pour les détails" + }, + "standingAllergiesOne": "{count} allergie", + "standingAllergiesFew": "{count} allergies", + "standingAllergiesOther": "{count} allergies", + "legendMeanDay": "Moyenne journalière", + "range": { + "from": "Du", + "to": "Au", + "label": "Période choisie", + "open": "Choisir une période", + "clear": "Tout afficher", + "apply": "Appliquer" + }, + "item": { + "labValuesOne": "1 valeur de laboratoire", + "labValuesFew": "{count} valeurs de laboratoire", + "labValuesOther": "{count} valeurs de laboratoire" + } + }, + "lifeEvents": { + "title": "Événement de vie", + "add": "Événement de vie", + "privacy": "Le titre et la note sont chiffrés. Ils ne sont envoyés à aucune IA.", + "emptyHint": "Un déménagement, une naissance ou un nouvel emploi donnent un repère aux années.", + "save": "Enregistrer", + "cancel": "Annuler", + "delete": "Supprimer l’événement de vie", + "saved": "Événement de vie enregistré", + "deleted": "Événement de vie supprimé", + "fields": { + "title": "Que s’est-il passé ?", + "category": "Type", + "when": "Quand", + "precision": "Précision", + "addEnd": "Date de fin, s’il s’agissait d’une période", + "endDate": "Jusqu’au", + "note": "Note", + "optionalTag": "facultatif", + "notePlaceholder": "Quelque chose que vous voudrez savoir plus tard", + "removeEnd": "Retirer la date de fin" + }, + "category": { + "FAMILY": "Famille", + "HOME": "Logement", + "WORK": "Travail", + "LOSS": "Deuil", + "OTHER": "Autre" + }, + "precision": { + "DAY": "Jour", + "MONTH": "Mois", + "YEAR": "Année" + }, + "edit": "Modifier l’événement de vie", + "errors": { + "titleRequired": "Donnez un nom à l’événement.", + "titleTooLong": "120 caractères au maximum.", + "categoryRequired": "Choisissez un type.", + "dateRequired": "Indiquez une date valide.", + "endBeforeStart": "La fin précède le début.", + "noteTooLong": "2000 caractères au maximum." + }, + "saveFailed": "L’enregistrement a échoué.", + "deleteFailed": "La suppression a échoué.", + "deleteConfirm": { + "title": "Supprimer cet événement de vie ?", + "body": "Son titre et sa note disparaîtront." + } + }, + "palette": { + "open": "Rechercher…", + "openLabel": "Rechercher des pages, réglages et actions", + "title": "Recherche", + "description": "Trouver une page, un réglage, un module ou une action.", + "placeholder": "Rechercher des pages, réglages, modules…", + "results": "Résultats", + "noResults": "Aucun résultat pour « {query} ».", + "moduleOn": "Activé", + "moduleOff": "Désactivé", + "hintMove": "Naviguer", + "hintOpen": "Ouvrir", + "hintClose": "Fermer", + "groups": { + "recent": "Récents", + "pages": "Pages", + "insights": "Insights", + "settings": "Paramètres", + "modules": "Modules", + "actions": "Actions" + }, + "actions": { + "capture": "Nouvelle saisie", + "coach": "Demander au Coach", + "backup": "Créer une sauvegarde", + "today": "Ouvrir aujourd’hui", + "shortcuts": "Afficher les raccourcis clavier" + } + }, + "shortcuts": { + "title": "Raccourcis clavier", + "description": "Des touches qui fonctionnent partout dans l’application, sauf pendant la saisie dans un champ.", + "menuEntry": "Raccourcis clavier", + "then": "puis", + "search": "Rechercher", + "help": "Afficher cette liste", + "close": "Fermer une boîte de dialogue ou un panneau", + "previousDay": "Jour précédent", + "nextDay": "Jour suivant", + "dayHint": "Lorsqu’un jour est ouvert.", + "groups": { + "navigation": "Navigation", + "actions": "Actions", + "day": "Jour" + } + }, + "aboutPage": { + "eyebrow": "À propos", + "title": "À propos de HealthLog", + "lastUpdated": "Dernière mise à jour : {date}", + "project": "Projet", + "credits": "Crédits" } } diff --git a/messages/it.json b/messages/it.json index fe2c91b79..fece67e6c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -49,7 +49,9 @@ "dismiss": "Ignora", "moreInfo": "Maggiori informazioni", "durationHoursMinutes": "{hours} h {minutes} min", - "durationMinutes": "{minutes} min" + "durationMinutes": "{minutes} min", + "durationClockMinutes": "{time} min", + "durationClockHours": "{time} h" }, "apiErrors": { "measurement": { @@ -165,7 +167,7 @@ "anamnesisShiftSchedule": "Orario di lavoro", "anamnesisColFact": "Voce del profilo", "anamnesisColValue": "Valore registrato", - "subtitle": "HealthLog — Rapporto personale di salute", + "subtitle": "HealthLog: rapporto personale di salute", "patient": "Paziente", "dateOfBirth": "Data di nascita", "gender": "Sesso", @@ -175,18 +177,18 @@ "height": "Altezza", "period": "Periodo del rapporto", "createdOn": "Creato il", - "vitalsTitle": "Parametri vitali — Riepilogo", + "vitalsTitle": "Parametri vitali: riepilogo", "colParameter": "Parametro", "colCurrent": "Attuale", "colAvgPeriod": "Media periodo", "colMin": "Min", "colMax": "Max", "colN": "n", - "bpClassificationTitle": "Pressione arteriosa — Classificazione ESH (2023)", + "bpClassificationTitle": "Pressione arteriosa: classificazione ESH (2023)", "avgBp": "Pressione arteriosa media", "classification": "Classificazione", "bmiTitle": "Indice di massa corporea (BMI)", - "bmiRow": "IMC:{bmi} kg/m² — Classification: {class} (WHO)", + "bmiRow": "IMC:{bmi} kg/m², Classification: {class} (WHO)", "complianceTitle": "Aderenza ai farmaci", "glp1Title": "Terapia GLP-1", "glp1WeightSummary": "Peso nel periodo del referto: {start} kg → {end} kg ({delta} kg)", @@ -204,7 +206,7 @@ "colExpected": "Attese", "colComplianceRate": "Tasso di aderenza", "moodTitle": "Andamento dell’umore", - "moodSummary": "Media:{avg} / 5.0 · Entries: {count} · Range: {min}–{max}", + "moodSummary": "Media {avg} / 5.0, {count} voci, intervallo {min}–{max}", "colMood": "Umore", "colCount": "Quantità", "colShare": "Quota", @@ -234,8 +236,8 @@ "typeGlucoseRandom": "Glicemia (qualsiasi)", "typeGlucoseBedtime": "Glicemia (prima di coricarsi)", "typeGlucoseUnspecified": "Glicemia (non indicata)", - "glucoseClassificationTitle": "Glicemia — Intervalli di riferimento (ADA 2024 / DDG)", - "glucoseRow": "{label}: media {avg} {unit} (obiettivo {rangeMin}–{rangeMax}) — {class}", + "glucoseClassificationTitle": "Glicemia: intervalli di riferimento (ADA 2024 / DDG)", + "glucoseRow": "{label}: media {avg} {unit} (obiettivo {rangeMin}–{rangeMax}), {class}", "glucoseInTarget": "in obiettivo", "glucoseBelowTarget": "sotto obiettivo", "glucoseAboveTarget": "sopra obiettivo", @@ -287,7 +289,7 @@ "gmi": "Indicatore di gestione del glucosio (GMI)", "eA1c": "A1C stimata", "cv": "Variabilità (CV%)", - "spotCaveat": "Sono stime da misurazioni occasionali, non un profilo da monitor continuo — vanno lette come una tendenza, non come un verdetto clinico." + "spotCaveat": "Sono stime da misurazioni occasionali, non un profilo da monitor continuo: vanno lette come una tendenza, non come un verdetto clinico." }, "illnessTitle": "Condizioni e malattie", "illnessOngoing": "In corso", @@ -376,12 +378,12 @@ "backToDashboard": "Torna alla dashboard" }, "offlineBanner": { - "message": "Nessuna connessione — le modifiche non possono essere salvate finché non torni online.", - "saveFailed": "Impossibile salvare — sei offline. Riprova quando torni online.", + "message": "Nessuna connessione: le modifiche non possono essere salvate finché non torni online.", + "saveFailed": "Impossibile salvare: sei offline. Riprova quando torni online.", "showingCached": "Vengono mostrati i tuoi ultimi dati sincronizzati." }, "demoBanner": { - "message": "Modalità demo — le modifiche non vengono salvate." + "message": "Modalità demo: le modifiche non vengono salvate." }, "nav": { "dashboard": "Dashboard", @@ -427,7 +429,9 @@ "workout": "Allenamento", "workoutDescription": "Una sessione che i tuoi dispositivi non hanno registrato", "symptom": "Sintomo", - "symptomDescription": "Uno dei tuoi sintomi, con la sua intensità" + "symptomDescription": "Uno dei tuoi sintomi, con la sua intensità", + "lifeEventDescription": "Un trasloco, una nascita, un nuovo lavoro", + "lifeEvent": "Evento di vita" }, "cycle": "Ciclo", "labs": "Esami", @@ -436,7 +440,8 @@ "illness": "Malattia", "documents": "Documenti", "vaccinations": "Vaccinazioni", - "mentalWellbeing": "Benessere mentale" + "mentalWellbeing": "Benessere mentale", + "timeline": "Cronologia" }, "auth": { "login": "Accedi", @@ -453,14 +458,13 @@ "hasAccount": "Hai già un account?", "loginFailed": "Accesso non riuscito", "registerFailed": "Registrazione non riuscita", - "invitedBanner": "Sei stato invitato — crea il tuo account anche se la registrazione è chiusa.", + "invitedBanner": "Sei stato invitato: crea il tuo account anche se la registrazione è chiusa.", "alreadySignedIn": { "title": "Hai già effettuato l'accesso", "body": "Hai effettuato l'accesso come {username}. Un invito crea un nuovo account. Esci per accettarlo.", "signOut": "Esci e accetta l'invito" }, "createAccount": "Crea account", - "newAccount": "Crea un nuovo account", "backToPasskey": "Torna a passkey", "passkeySetupLater": "Puoi configurare le passkey più tardi dalle impostazioni.", "passwordMinLength": "Min. 12 caratteri", @@ -513,6 +517,37 @@ "fixLan": "Server in una rete domestica senza TLS: imposta SESSION_COOKIE_SECURE=false nelle sue impostazioni e riavvialo.", "fixHttps": "Server dietro un proxy HTTPS: apri invece l'indirizzo https:// e assicurati che il proxy inoltri X-Forwarded-Proto: https.", "localhostNote": "Questa pagina è su http://localhost mentre il server imposta il cookie di accesso solo per https://. La maggior parte dei browser lo accetta su localhost; se dopo l'accesso torni qui, imposta SESSION_COOKIE_SECURE=false." + }, + "claim": { + "title": "Prendi in mano la tua cartella sanitaria", + "titleNamed": "Ti diamo il benvenuto, {name}", + "description": "Finora la tua cartella sanitaria è stata tenuta per te. Scegli come accedere e diventerà il tuo account. Nulla viene copiato o spostato.", + "loading": "Controllo del link…", + "invalid": "Questo link non funziona più", + "invalidHint": "È sconosciuto, scaduto, ritirato o già usato. Chiedine uno nuovo a chi te l'ha inviato.", + "oidcOnly": "Questo server consente solo il single sign-on, quindi qui non è ancora possibile prendere in carico un profilo.", + "signedInTitle": "Prima esci", + "signedInBody": "Hai effettuato l'accesso come {username}. Questo link crea un accesso per un'altra persona, quindi esci prima di usarlo.", + "signOut": "Esci e continua", + "guardiansTitle": "Chi mantiene l'accesso per ora", + "guardiansHint": "Proposto dalle persone che hanno tenuto la tua cartella. Decidi subito dopo l'accesso.", + "guardianUnnamed": "Un tutore", + "usernameHint": "Da 3 a 30 lettere, numeri, _ o -.", + "usernameReserved": "I nomi utente che iniziano con managed- sono riservati.", + "emailHint": "Obbligatorio, così puoi rientrare se dimentichi la password.", + "consentNote": "L'avviso e qualsiasi uso dell'IA li confermi tu. Fino ad allora nulla della tua cartella viene inviato a un fornitore di IA.", + "expires": "Questo link è valido fino al {date}.", + "submit": "Prendi in carico", + "submitting": "Presa in carico…", + "errorTaken": "Questo nome utente o indirizzo email è già in uso.", + "errorRateLimit": "Troppi tentativi. Attendi qualche minuto e riprova.", + "errorFailed": "Non ha funzionato. Riprova tra un momento.", + "errorOffline": "Il server non è stato raggiunto. Controlla la connessione e riprova." + }, + "inviteInvalid": { + "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" } }, "dashboard": { @@ -544,17 +579,17 @@ "sleepShort": "Sonno", "stepsShort": "Passi", "vo2MaxShort": "VO₂ max", - "bpInTargetWindow90": "PA-target · 90 gg", - "bpInTargetWindowDynamic": "PA-target · {days} gg", + "bpInTargetWindow90": "PA-target (90 gg)", + "bpInTargetWindowDynamic": "PA-target ({days} gg)", "bpInTargetCollecting": "raccolta dati in corso", "medications": "Farmaci", "quickAddMeasurement": "Registra misurazione", "quickAddMood": "Registra umore", "quickAddMedicationIntake": "Registra assunzione", "quickAddWorkout": "Registra allenamento", + "quickAddLifeEvent": "Registra evento di vita", "customizeDashboard": "Personalizza dashboard", "medicationIntakeQuickAdd": { - "sheetTitle": "Registra assunzione del farmaco", "sheetDescription": "Registra dose, orario e farmaco con pochi tap.", "medicationLabel": "Farmaco", "medicationPlaceholder": "Seleziona un farmaco", @@ -667,7 +702,8 @@ "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" + "quickAddSymptom": "Registra sintomo", + "todayCardVisible": "Mostra la scheda in alto" }, "measurements": { "title": "Misurazioni", @@ -739,7 +775,7 @@ "formReset": "Reimposta modulo", "saveError": "Errore durante il salvataggio", "duplicateTimestamp": "Esiste già una misurazione di questo tipo a questa marca temporale.", - "serverOwnedSource": "I valori provenienti da una fonte collegata non possono essere modificati — data e nota restano modificabili.", + "serverOwnedSource": "I valori provenienti da una fonte collegata non possono essere modificati, ma data e nota restano modificabili.", "deleteError": "Errore durante l’eliminazione", "deletedToast": "Misurazione eliminata", "restoredToast": "Misurazione ripristinata", @@ -834,11 +870,12 @@ "durationHoursOnly": "{hours} h", "durationMinutesOnly": "{minutes} min", "durationInvalid": "Inserisci una durata come 7,5, 7:30 o 7h 30m.", - "durationTooLong": "Una voce non può superare le 24 ore." + "durationTooLong": "Una voce non può superare le 24 ore.", + "sourceHealthConnect": "Health Connect" }, "metricCatalog": { "title": "Cosa può monitorare HealthLog", - "description": "Tutti gli ambiti di metriche supportati da HealthLog, che tu abbia già dei dati oppure no — e cosa li fornirebbe.", + "description": "Tutti gli ambiti di metriche supportati da HealthLog, che tu abbia già dei dati oppure no, e cosa li fornirebbe.", "statusPresent": "Monitorato", "statusAbsent": "Non ancora monitorato", "statusModuleOff": "Disattivato", @@ -846,11 +883,11 @@ "connectCta": "Collega una fonte", "viewCta": "Visualizza", "ecg": { - "source": "Tracciati ECG a una derivazione da un orologio compatibile — oggi Withings ScanWatch — sincronizzati automaticamente." + "source": "Tracciati ECG a una derivazione da un orologio compatibile (oggi Withings ScanWatch), sincronizzati automaticamente." }, "watchdog": { "title": "Monitoraggio automatico", - "description": "HealthLog osserva anche silenziosamente in background — notifiche di ritmo irregolare e disturbi respiratori da un orologio connesso, avvisi di scostamento dalla tua base di riferimento, e variazioni degne di nota tra i tuoi due ultimi pannelli di laboratorio — mostrando una scheda solo quando qualcosa emerge. Se almeno una di queste fonti è collegata, nessuna scheda visibile è un buon segno, non una funzione mancante; se non ne è ancora collegata nessuna, semplicemente non c'è ancora nulla sotto controllo." + "description": "HealthLog osserva anche silenziosamente in background (notifiche di ritmo irregolare e disturbi respiratori da un orologio connesso, avvisi di scostamento dalla tua base di riferimento, e variazioni degne di nota tra i tuoi due ultimi pannelli di laboratorio), mostrando una scheda solo quando qualcosa emerge. Se almeno una di queste fonti è collegata, nessuna scheda visibile è un buon segno, non una funzione mancante; se non ne è ancora collegata nessuna, semplicemente non c'è ancora nulla sotto controllo." } }, "mood": { @@ -1007,7 +1044,7 @@ "archive": "Archivia", "restore": "Ripristina", "archivedTitle": "Tag archiviati", - "archivedDescription": "I tag archiviati restano nelle tue voci passate — scompaiono solo dal selettore.", + "archivedDescription": "I tag archiviati restano nelle tue voci passate: scompaiono solo dal selettore.", "archivedEmpty": "Niente in archivio.", "purgeTitle": "Eliminare il tag definitivamente?", "purgeBody": "Il tag verrà rimosso da {count} voci passate. L’operazione non può essere annullata.", @@ -1210,8 +1247,8 @@ "dialogDescription": "Questi farmaci sono da assumere adesso. Conferma per registrarli tutti come assunti.", "confirm": "Assumi tutti ({count})", "successToast": "{count} assunzioni registrate", - "partialToast": "{taken} registrate, {failed} non riuscite – riprova", - "failedToast": "Nessuna assunzione registrata – riprova" + "partialToast": "{taken} registrate, {failed} non riuscite. Riprova", + "failedToast": "Nessuna assunzione registrata. Riprova" }, "logIntake": { "dialogTitle": "Registra un'assunzione", @@ -1300,7 +1337,7 @@ "complianceInfoLabel": "Cosa significa aderenza?", "lastIntake": "Ultima assunzione:", "nextIntake": "Prossima assunzione:", - "nextIntakeOverdue": "In ritardo · {time} — ancora recuperabile", + "nextIntakeOverdue": "In ritardo da {time}, ancora recuperabile", "dayStreak": "giorni di serie", "dayStreakOne": "giorno di serie", "cycleDueToday": "In scadenza oggi", @@ -1394,10 +1431,10 @@ "error": { "empty": "Scrivi prima una descrizione.", "tooLong": "Mantieni la descrizione entro 2000 caratteri.", - "rateLimit": "Troppe richieste — attendi un momento e riprova.", - "noProvider": "Nessun provider IA configurato — configurane uno in Impostazioni.", - "budget": "Budget IA giornaliero raggiunto — riprova domani.", - "network": "Impossibile contattare il provider IA — riprova.", + "rateLimit": "Troppe richieste. Attendi un momento e riprova.", + "noProvider": "Nessun provider IA configurato: configurane uno in Impostazioni.", + "budget": "Budget IA giornaliero raggiunto. Riprova domani.", + "network": "Impossibile contattare il provider IA. Riprova.", "consent": "La lettura della tua descrizione con IA richiede prima il tuo consenso.", "unavailable": "La lettura delle descrizioni con IA non è disponibile qui al momento." } @@ -1413,7 +1450,7 @@ "everyNMonths": "Ogni N mesi in un giorno specifico", "yearly": "Una volta all’anno", "rolling": "Ogni N giorni dall’ultima assunzione (flessibile)", - "rollingExplainer": "Conta dall’ultima assunzione registrata — si mette in pausa se salti una dose.", + "rollingExplainer": "Conta dall’ultima assunzione registrata e si mette in pausa se salti una dose.", "oneShot": "Dose singola" }, "weekdays": { @@ -1471,7 +1508,7 @@ "night": "Notte" }, "max": { - "reached": "Massimo raggiunto — rimuovi un orario prima di aggiungerne un altro." + "reached": "Massimo raggiunto: rimuovi un orario prima di aggiungerne un altro." } }, "courseWindow": { @@ -1521,7 +1558,7 @@ }, "step3": { "title": "Qual è la dose?", - "subline": "Una compressa, uno spruzzo, una goccia — a seconda della forma.", + "subline": "Una compressa, uno spruzzo, una goccia, a seconda della forma.", "amountLabel": "Quantità", "amountPlaceholder": "ad es. 5", "unitLabel": "Unità", @@ -1547,7 +1584,7 @@ }, "dosesPerUnitLabel": "Dosi per unità", "dosesPerUnitPlaceholder": "es. 4", - "dosesPerUnitHint": "Dosi per penna / fiala — lascia vuoto per non tracciare le scorte.", + "dosesPerUnitHint": "Dosi per penna / fiala. Lascia vuoto per non tracciare le scorte.", "short": "Dose", "unitsPerDoseLabel": "Unità per dose", "unitsPerDoseHint": "ad es. 2 compresse da 2 mg per una dose da 4 mg, oppure ½ compressa per una dose divisa" @@ -1559,7 +1596,7 @@ }, "step5": { "title": "Con quale frequenza lo assumi?", - "subline": "Scegli la cadenza — i dettagli arrivano al passo successivo.", + "subline": "Scegli la cadenza: i dettagli arrivano al passo successivo.", "short": "Frequenza" }, "step6": { @@ -1581,7 +1618,7 @@ }, "step7": { "title": "Quando lo assumi?", - "subline": "Uno o più orari del giorno — i promemoria seguono questo elenco.", + "subline": "Uno o più orari del giorno: i promemoria seguono questo elenco.", "presetsLabel": "Preimpostazioni per orario del giorno", "presets": { "morning": "Mattina", @@ -1597,11 +1634,11 @@ "short": "Orari" }, "step8": { - "title": "Fatto — vuoi che HealthLog te lo ricordi?", + "title": "Fatto. Vuoi che HealthLog te lo ricordi?", "subline": "Controlla tutto qui sotto prima di salvare.", "remindersLabel": "Promemoria attivi", - "remindersDescription": "Push, Telegram o ntfy — in base alle tue impostazioni.", - "noChannelHint": "I promemoria richiedono un canale di notifica — al momento nessuno è attivo.", + "remindersDescription": "Push, Telegram o ntfy, in base alle tue impostazioni.", + "noChannelHint": "I promemoria richiedono un canale di notifica, ma al momento nessuno è attivo.", "noChannelHintLink": "Configura le notifiche", "short": "Fatto" } @@ -1685,7 +1722,7 @@ } }, "errors": { - "submitFailed": "Impossibile salvare il farmaco — riprova." + "submitFailed": "Impossibile salvare il farmaco. Riprova." } }, "doseStrength": { @@ -1701,11 +1738,11 @@ "empty": "Nessuna fase di aumento della dose registrata. Aggiungi variazioni di dose con data per tracciare il piano.", "youAreHere": "Sei qui", "doseStep": "{dose} {unit}", - "plannedFor": "Previsto · {date}" + "plannedFor": "Previsto per {date}" }, "injectionSiteBodyOutlineAriaLabel": "Sagoma del corpo", "injectionSitePickerAriaLabel": "Selettore del sito di iniezione", - "injectionSiteRecommendedAriaLabel": "{site} — prossimo sito consigliato", + "injectionSiteRecommendedAriaLabel": "{site}, prossimo sito consigliato", "injectionSiteLegendAriaLabel": "Legenda dei segnaposto", "injectionSiteLegendRecommended": "Prossimo sito consigliato", "injectionSiteLegendLastUsed": "Ultimo sito utilizzato", @@ -1752,7 +1789,7 @@ "intakeHistoryStatusSkipped": "Saltata", "intakeHistorySortByTaken": "Ordina per ora di assunzione", "intakeHistorySortByScheduled": "Ordina per orario programmato", - "intakeHistoryPageInfo": "Pagina {page} di {total} · {count} voci", + "intakeHistoryPageInfo": "Pagina {page} di {total}, {count} voci", "intakeHistoryPrev": "Precedente", "intakeHistoryNext": "Successiva", "glp1": { @@ -1793,7 +1830,7 @@ "cadence": { "oneShotOn": "Dose singola il {date}.", "oneShotPending": "Dose singola in sospeso.", - "asNeeded": "Al bisogno — nessun piano fisso." + "asNeeded": "Al bisogno, nessun piano fisso." }, "intake": { "title": "Storico delle assunzioni", @@ -1820,7 +1857,7 @@ "dialogTitle": "Modifica assunzione", "takenAtLabel": "Assunta alle", "scheduledForHint": "Programmata: {dateTime}", - "farFromScheduledWarning": "Molto distante dall’orario programmato — controlla la data.", + "farFromScheduledWarning": "Molto distante dall’orario programmato: controlla la data.", "skippedLabel": "Saltata", "save": "Salva", "cancel": "Annulla", @@ -1852,7 +1889,7 @@ "markAsTaken": "Segna come assunta", "markAsSkipped": "Segna come saltata", "adHocTag": "fuori programma", - "takenDetail": "prevista {planned} · assunta {taken}", + "takenDetail": "prevista {planned}, assunta {taken}", "doseTaken": "Dose: {dose}", "status": { "taken_on_time": "puntuale", @@ -1916,7 +1953,7 @@ }, "codes": { "label": "Codici clinici (ATC / RxNorm)", - "note": "Facoltativo. Usati nell'esportazione FHIR della cartella sanitaria. I codici vengono presi esattamente come inseriti — mai indovinati.", + "note": "Facoltativo. Usati nell'esportazione FHIR della cartella sanitaria. I codici vengono presi esattamente come inseriti, mai indovinati.", "atcLabel": "ATC", "rxNormLabel": "RxNorm", "saved": "Codici clinici salvati", @@ -1936,7 +1973,7 @@ "title": "Termina il farmaco", "helper": "Interrompe i promemoria e segna il farmaco come terminato. Lo storico resta visibile.", "button": "Termina", - "dialogTitle": "Termina {name} — interrompe i promemoria, lo storico resta visibile", + "dialogTitle": "Termina {name}: interrompe i promemoria, lo storico resta visibile", "dialogBody": "Nessun altro promemoria. Le voci precedenti restano nella cronologia.", "toast": "Farmaco terminato", "failed": "Impossibile terminare il farmaco." @@ -1974,7 +2011,7 @@ "copyFailed": "Impossibile copiare.", "tokenCopied": "Token copiato", "copyToken": "Copia token", - "mintedHint": "Copia questo token ora — viene mostrato una sola volta." + "mintedHint": "Copia questo token ora: viene mostrato una sola volta." }, "advanced": { "dataPortability": { @@ -2003,7 +2040,6 @@ "bestand": "Scorta", "verlauf": "Cronologia", "injektion": "Iniezione", - "api": "API", "erweitert": "Avanzate", "wirkung": "Effetto" }, @@ -2015,9 +2051,9 @@ "statusTitle": "Stato", "nextDoseLabel": "Prossima dose", "nextDoseNone": "Nessuna dose in programma.", - "nextDoseAsNeeded": "Al bisogno — nessuna dose programmata.", + "nextDoseAsNeeded": "Al bisogno, nessuna dose programmata.", "nextDoseNotTracked": "Assunzioni non registrate, solo annotato.", - "pausedHint": "In pausa — nessun promemoria.", + "pausedHint": "In pausa, nessun promemoria.", "reminderLabel": "Promemoria", "reminderOn": "Attivo", "reminderOff": "Disattivo", @@ -2182,7 +2218,7 @@ } }, "logInjectionSiteTitle": "Registra il sito di iniezione", - "logInjectionSiteDescription": "Dove hai iniettato {name}? Facoltativo — puoi saltarlo.", + "logInjectionSiteDescription": "Dove hai iniettato {name}? Facoltativo, puoi saltarlo.", "logInjectionSiteSkip": "Salta", "logInjectionSiteConfirm": "Salva il sito", "logInjectionSiteNoneAvailable": "Nessun sito di iniezione disponibile: tutti i siti sono esclusi nelle tue impostazioni.", @@ -2210,9 +2246,8 @@ "tableStockDoses": "{count} dosi", "tableStockRunway": "≈ {days} giorni rimanenti", "cardLowStockRunway": "Scorta: ≈ {days} giorni", - "takeActionFor": "Assumi – {name}", - "skipActionFor": "Salta – {name}", - "reorderTitle": "Modifica ordine", + "takeActionFor": "Assumi: {name}", + "skipActionFor": "Salta: {name}", "reorderDescription": "Imposta l'ordine dei tuoi farmaci: vale per la vista a schede e per la tabella.", "reorderSaved": "Ordine salvato", "reorderSaveFailed": "Impossibile salvare l’ordine", @@ -2232,7 +2267,7 @@ "levelShift": "È stato rilevato un cambio di livello intorno al {date}.", "plateau": { "title": "Plateau del peso alla dose attuale", - "evidence": "Variazione di peso di {delta} {unit} negli ultimi {window} giorni ({readings} misurazioni) — {days} giorni con {dose}." + "evidence": "Variazione di peso di {delta} {unit} negli ultimi {window} giorni ({readings} misurazioni), {days} giorni con {dose}." }, "retarget": { "label": "Non è la misurazione giusta?", @@ -2242,7 +2277,7 @@ "error": "Impossibile salvare questa modifica. Riprova." }, "beforeAfter": { - "summary": "Ø {before} prima → Ø {after} da allora — una variazione di {delta}.", + "summary": "Ø {before} prima → Ø {after} da allora, una variazione di {delta}.", "counts": "In base a {beforeCount} misurazioni prima e {afterCount} da allora.", "needBefore": "Ancora troppe poche misurazioni prima dell'inizio: servono circa {weeks} settimane prima e dopo per confrontare.", "needAfter": "Ancora troppe poche misurazioni dall'inizio: servono circa {weeks} settimane prima e dopo per confrontare.", @@ -2357,7 +2392,7 @@ }, "charts": { "loadingLabel": "Caricamento del grafico…", - "loadingSlowHint": "Calcolo delle analisi — può richiedere un momento.", + "loadingSlowHint": "Calcolo delle analisi. Può richiedere un momento.", "weight": "Peso", "bloodPressure": "Pressione arteriosa", "pulse": "Pulsazioni", @@ -2379,6 +2414,7 @@ "days7Label": "7 g", "days7Title": "Ultimi 7 giorni", "days30Label": "30 g", + "daysNLabel": "{days} g", "days30Title": "Ultimi 30 giorni", "days90Label": "90 g", "days90Title": "Ultimi 90 giorni", @@ -2474,7 +2510,8 @@ "moodHeatmapEmpty": "Mappa di calore dell'umore, nessun giorno ancora registrato.", "complianceHeatmap": "Mappa di calore dell'aderenza ai farmaci dal {start} al {end}, {days} giorni attivi.", "complianceHeatmapEmpty": "Mappa di calore dell'aderenza ai farmaci, nessun giorno attivo." - } + }, + "medicationsEmptyAction": "Aggiungi un farmaco" }, "chart": { "overlay": { @@ -2505,8 +2542,8 @@ }, "tooltipPrior": "Periodo precedente", "unavailable": { - "lastMonth": "Confronto non disponibile — nessun dato del mese precedente", - "lastYear": "Confronto non disponibile — nessun dato dell’anno precedente" + "lastMonth": "Confronto non disponibile: nessun dato del mese precedente", + "lastYear": "Confronto non disponibile: nessun dato dell’anno precedente" }, "notAvailableForCompliance": "Confronto non disponibile per l’aderenza" }, @@ -2518,7 +2555,9 @@ "dismissError": "Impossibile ignorare questo schema.", "restoreError": "Impossibile ripristinare questo schema.", "loadError": "Impossibile caricare gli schemi.", - "empty": "Nessuno schema attuale ha superato la soglia di evidenza." + "empty": "Nessuno schema attuale ha superato la soglia di evidenza.", + "seasonalNote": "Dalla versione 1.42 HealthLog toglie stagione e tendenza di lungo periodo prima di confrontare i valori. Vedrai meno relazioni, ma quelle rimaste reggono meglio. Molti degli schemi di prima erano solo il calendario.", + "seasonalNoteDismiss": "Chiudi l’avviso" }, "correlation": { "direction": { @@ -2550,29 +2589,37 @@ "ENV_DAYLIGHT": "la luce del giorno", "ENV_PRECIP": "le precipitazioni", "ENV_PRESSURE_MEAN": "la pressione atmosferica", - "ENV_PRESSURE_DELTA": "l'oscillazione di pressione nella giornata" + "ENV_PRESSURE_DELTA": "l'oscillazione di pressione nella giornata", + "ENV_PM25": "il particolato fine (PM2,5)", + "ENV_OZONE_8H": "l’ozono", + "ENV_POLLEN_MAX": "i pollini" }, "daily": { - "high": "Quando {behaviour} sale, {outcome} del giorno dopo tende a {direction} nei tuoi dati — un andamento da osservare, non una causa.", - "moderate": "Quando {behaviour} sale, {outcome} del giorno dopo sembra tendere a {direction}, per quanto emerge finora — un andamento da osservare, non una causa.", - "faint": "Quando {behaviour} sale, al più si intravede un debole indizio che {outcome} del giorno dopo tenda a {direction} nei tuoi dati — troppo lieve per farci affidamento, mai una causa." + "high": "Quando {behaviour} sale, {outcome} del giorno dopo tende a {direction} nei tuoi dati. È un andamento da osservare, non una causa.", + "moderate": "Quando {behaviour} sale, {outcome} del giorno dopo sembra tendere a {direction}, per quanto emerge finora. È un andamento da osservare, non una causa.", + "faint": "Quando {behaviour} sale, al più si intravede nei tuoi dati un debole indizio che {outcome} del giorno dopo tenda a {direction}. È troppo lieve per farci affidamento e mai una causa." }, "lab": { - "default": "Valori di {lab} più alti si accompagnano al fatto che {outcome} tende a {direction} negli stessi periodi dei tuoi dati — un'associazione da tenere d'occhio con il tuo medico, mai una causa.", - "faint": "Valori di {lab} più alti lasciano al più intravedere che {outcome} tenda a {direction} negli stessi periodi dei tuoi dati — troppo lieve per farci affidamento, un'associazione da discutere con il tuo medico, mai una causa." + "default": "Valori di {lab} più alti si accompagnano al fatto che {outcome} tende a {direction} negli stessi periodi dei tuoi dati, un'associazione da tenere d'occhio con il tuo medico, mai una causa.", + "faint": "Valori di {lab} più alti lasciano al più intravedere che {outcome} tenda a {direction} negli stessi periodi dei tuoi dati. È troppo lieve per farci affidamento e mai una causa, ma da discutere con il tuo medico." }, "hypothesis": { "bpCompliance": { - "support": "Una maggiore aderenza ai farmaci si accompagna a valori sistolici più bassi — un andamento da osservare.", - "surprising": "L'aderenza e la sistolica si muovono insieme nei tuoi dati — sorprendente; parlane con il tuo medico prima di modificare qualcosa.", + "support": "Una maggiore aderenza ai farmaci si accompagna a valori sistolici più bassi, un andamento da osservare.", + "surprising": "L'aderenza e la sistolica si muovono insieme nei tuoi dati, il che è sorprendente. Parlane con il tuo medico prima di modificare qualcosa.", "none": "L'aderenza e la sistolica non si muovono insieme in modo marcato in questo periodo." }, "moodPulse": { - "support": "I giorni di umore più basso si accompagnano a un polso a riposo più alto — un andamento da osservare.", - "surprising": "I giorni di umore migliore si accompagnano a un polso a riposo più alto nei tuoi dati — sorprendente.", + "support": "I giorni di umore più basso si accompagnano a un polso a riposo più alto, un andamento da osservare.", + "surprising": "I giorni di umore migliore si accompagnano a un polso a riposo più alto nei tuoi dati, il che è sorprendente.", "none": "L'umore e il polso a riposo non si muovono insieme in modo marcato in questo periodo." }, - "weightWeekday": "Il peso del {weekday} è {delta} kg {direction} la tua media degli altri giorni — un andamento da osservare." + "weightWeekday": "Il peso del {weekday} è {delta} kg {direction} la tua media degli altri giorni, un andamento da osservare." + }, + "dailySameDay": { + "high": "Più {behaviour} nello stesso giorno e in quello prima tende ad accompagnarsi a {direction} {outcome} nei tuoi dati: uno schema da osservare, non una causa.", + "moderate": "Più {behaviour} nello stesso giorno e in quello prima sembra accompagnarsi a {direction} {outcome} nei tuoi dati, per quanto visto finora: uno schema da osservare, non una causa.", + "faint": "Più {behaviour} nello stesso giorno e in quello prima mostra al massimo un debole indizio di {direction} {outcome} nei tuoi dati: troppo poco per contarci, mai una causa." } }, "noAnalysisYet": "Ancora nessuna valutazione.", @@ -2619,7 +2666,7 @@ "narrativeWeek": "Questa settimana", "narrativeMonth": "Questo mese", "narrativeUpdating": "Aggiornamento…", - "heroFallbackSubtitle": "Uno sguardo quotidiano ai tuoi trend — direttamente dai numeri che registri.", + "heroFallbackSubtitle": "Uno sguardo quotidiano ai tuoi trend, direttamente dai numeri che registri.", "heroGreetingMorning": "Buongiorno", "heroGreetingAfternoon": "Buon pomeriggio", "heroGreetingEvening": "Buonasera", @@ -2684,7 +2731,7 @@ "referenceSleep": "Almeno {low} minuti per notte; il sonno più lungo non viene penalizzato", "referenceAdiposity": "Rapporto vita-altezza inferiore a {high}", "referenceHba1c": "HbA1c da {low} a {high}%", - "referenceDetail": "{label} · {source} · {kind} · da {low} a {high}", + "referenceDetail": "{label}: da {low} a {high} ({kind}, {source})", "openBound": "aperto", "laboratoryReferenceSource": "Intervalli di riferimento del laboratorio", "personalTargetSource": "Obiettivo personale", @@ -2719,7 +2766,8 @@ "coverage": { "recommendedMet": "Copre i tre ambiti consigliati dal punteggio", "areas": "{present} ambiti di salute su {required}" - } + }, + "historyLink": "Andamento" }, "dailyBriefing": { "title": "Briefing del giorno", @@ -2778,7 +2826,7 @@ "experimentCta": "Avvia esperimento di 7 giorni", "experimentTooltip": "In arrivo", "emptyTitle": "Servono più dati per mostrare questa correlazione", - "sourceChip": "su {n} misurazioni appaiate · {window}", + "sourceChip": "su {n} misurazioni appaiate ({window})", "sourceWindowLast30": "Ultimi 30 giorni", "confidenceHigh": "Confidenza alta", "confidenceModerate": "Confidenza media", @@ -2793,14 +2841,14 @@ } }, "coach": { - "refusalOutOfScope": "Posso aiutarti solo con i dati di salute del tuo log — misurazioni, farmaci, umore, trend. Domande fuori da questo ambito non le rispondo.", - "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.", + "refusalOutOfScope": "Posso aiutarti solo con i dati di salute del tuo log: misurazioni, farmaci, umore, trend. Domande fuori da questo ambito non le rispondo.", + "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 (UTC).", "errorProvider": "Il coach non è riuscito a contattare un provider di analisi ora. Riprova fra poco.", "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", - "errorNetwork": "Nessuna connessione Internet — riprova quando sei di nuovo online.", + "errorNetwork": "Nessuna connessione Internet. Riprova quando sei di nuovo online.", "prefsSaveError": "Impossibile salvare le preferenze del Coach. Riprova.", "errorCredentialExpired": "La connessione al provider di IA è scaduta. Riconnettila nelle Impostazioni per mantenere attivo il Coach.", "tagline": "Coach personale di salute, basato sui tuoi dati.", @@ -2819,7 +2867,7 @@ "thinking": "Sto pensando…", "unverifiedFiguresNotice": "Alcuni numeri non è stato possibile verificarli con i tuoi dati e sono stati omessi.", "composerPlaceholder": "Chiedimi pure …", - "nudgeBubbleLabel": "Nuovo messaggio dal coach — apri la chat", + "nudgeBubbleLabel": "Nuovo messaggio dal coach: apri la chat", "fabLabel": "Apri il coach", "readAloud": "Leggi ad alta voce", "readAloudStop": "Interrompi lettura", @@ -2843,7 +2891,7 @@ "guided": { "offerTitle": "Il Coach ha {count} domande per te", "offerTitleOne": "Il Coach ha una domanda per te", - "offerBody": "Risposte brevi affinano le tue informazioni personali — una domanda alla volta, qui in chat.", + "offerBody": "Risposte brevi affinano le tue informazioni personali, una domanda alla volta, qui in chat.", "offerStart": "Rispondi ora", "offerLater": "Più tardi", "offerDismiss": "Non chiedere più", @@ -2853,7 +2901,7 @@ "later": "Più tardi", "dismissRemaining": "Non chiedere più", "answerPlaceholder": "La tua risposta…", - "summaryTitle": "Fatto — grazie per le tue risposte!", + "summaryTitle": "Fatto, grazie per le tue risposte!", "summaryAnswered": "{answered} di {total} risposte", "summaryAdopted": "{adopted} aggiunte alle tue informazioni personali", "summaryAdoptedNone": "nulla di nuovo aggiunto alle tue informazioni personali", @@ -2865,6 +2913,7 @@ "historyEmpty": "Le tue conversazioni compaiono qui. Fai una domanda per iniziare.", "historyDeleteAria": "Elimina conversazione", "historyDeleted": "Conversazione eliminata", + "historyDeleteFailed": "Non è stato possibile eliminare la conversazione.", "rename": { "action": "Rinomina conversazione", "input": "Titolo della conversazione", @@ -2956,23 +3005,23 @@ "cluster": { "cardio": { "label": "Cardiovascolare", - "hint": "Pressione · polso · HRV · FC riposo e cammino" + "hint": "Pressione, polso, HRV, FC a riposo e in cammino" }, "body": { "label": "Composizione corporea", - "hint": "Peso · grasso · muscoli · IMC" + "hint": "Peso, grasso, muscoli e IMC" }, "activity": { "label": "Attività", - "hint": "Passi · energia · distanza · VO₂ max" + "hint": "Passi, energia, distanza e VO₂ max" }, "workouts": { "label": "Allenamenti", - "hint": "Corse · uscite · sessioni" + "hint": "Corse, uscite e sessioni" }, "sleep": { "label": "Sonno", - "hint": "Durata · fasi" + "hint": "Durata e fasi" }, "mood": { "label": "Umore", @@ -2984,15 +3033,15 @@ }, "medication": { "label": "Farmaci", - "hint": "Aderenza · contesto GLP-1" + "hint": "Aderenza e contesto GLP-1" }, "mobility": { "label": "Mobilità e andatura", - "hint": "Stabilità · asimmetria · andatura" + "hint": "Stabilità, asimmetria e andatura" }, "environment": { "label": "Ambiente", - "hint": "Rumore · luce · temp. cutanea" + "hint": "Rumore, luce del giorno e temperatura cutanea" } }, "settingsClustersLabel": "Dati che il Coach vede", @@ -3041,25 +3090,11 @@ "steadyLatestWithin": "I tuoi valori sono di solito {low} {unit}; la tua ultima misurazione, del {date}, era {value}, in linea.", "steadyLatestAbove": "I tuoi valori sono di solito {low} {unit}; la tua ultima misurazione, del {date}, era {value}, al di sopra.", "steadyLatestBelow": "I tuoi valori sono di solito {low} {unit}; la tua ultima misurazione, del {date}, era {value}, al di sotto.", - "insufficient": "Sto ancora imparando il tuo intervallo abituale — continua a registrare per qualche giorno in più.", + "insufficient": "Sto ancora imparando il tuo intervallo abituale. Continua a registrare per qualche giorno in più.", "driver": "{note}" }, "scope": { - "prefix": "Il Coach è già su {metric}", - "tap": "Tocca per chiedere", - "question": "Spiegami questo parametro — c'è qualcosa da tenere d'occhio?" - }, - "seeded": { - "prefix": "Oggi vale uno sguardo: {signal}", - "signal": { - "readiness": "prontezza", - "recovery": "recupero" - }, - "question": { - "readiness": "La mia prontezza è anomala oggi — da cosa dipende?", - "recovery": "Il mio recupero è cambiato — cosa c'è dietro?" - }, - "dismiss": "Ignora" + "prefix": "Il Coach è già su {metric}" }, "settings": "Impostazioni", "history": { @@ -3069,31 +3104,31 @@ "groupEarlier": "Precedenti" }, "seed": { - "bloodPressure": "Esamina con me l'andamento della mia pressione arteriosa negli ultimi 30 giorni — c'è qualcosa che dovrei tenere d'occhio?", + "bloodPressure": "Esamina con me l'andamento della mia pressione arteriosa negli ultimi 30 giorni: c'è qualcosa che dovrei tenere d'occhio?", "weight": "Come si è evoluto il mio peso ultimamente e la direzione è qualcosa su cui intervenire?", "bmi": "Cosa mi dice l'andamento del mio BMI e come dovrei interpretarlo?", - "pulse": "Esamina con me le mie letture del battito — c'è qualcosa fuori dal comune?", + "pulse": "Esamina con me le mie letture del battito: c'è qualcosa fuori dal comune?", "restingHr": "Come si è evoluta la mia frequenza cardiaca a riposo e cosa dice sulla mia forma fisica?", "hrv": "Cosa mi dice la mia variabilità della frequenza cardiaca su recupero e stress ultimamente?", - "sleep": "Esamina con me il mio sonno recente — durata, regolarità e qualsiasi cosa valga la pena cambiare.", + "sleep": "Esamina con me il mio sonno recente: durata, regolarità e qualsiasi cosa valga la pena cambiare.", "mood": "Quali schemi noti nel mio umore nelle ultime settimane?", "medications": "Com'è la mia aderenza alla terapia e corrisponde a come mi sono sentito?", "steps": "Quanto sono stato attivo ultimamente in base ai miei passi e come si confronta con un valore di riferimento sano?", "activeEnergy": "Cosa dice il mio andamento dell'energia attiva sul mio livello di attività ultimamente?", "cardioFitness": "Cosa significa l'andamento della mia forma cardiorespiratoria (VO₂ max) e come la indirizzo nella direzione giusta?", - "bloodGlucose": "Esamina con me le mie ultime letture di glicemia — sono in un intervallo sano?", + "bloodGlucose": "Esamina con me le mie ultime letture di glicemia: sono in un intervallo sano?", "oxygenSaturation": "Cosa mi dice l'andamento della mia saturazione di ossigeno (SpO₂)?", "respiratoryRate": "Cosa dice il mio andamento della frequenza respiratoria sulla mia salute?", - "workouts": "Esamina con me i miei allenamenti recenti — carico, frequenza e come recupero tra uno e l'altro.", + "workouts": "Esamina con me i miei allenamenti recenti: carico, frequenza e come recupero tra uno e l'altro.", "recoveryPage": "Perché il mio recupero è dov'è in questo momento e cosa lo determina?", - "briefing": "Esamina con me il riepilogo di oggi — su cosa dovrei concentrarmi e c'è qualcosa su cui vale la pena agire?", + "briefing": "Esamina con me il riepilogo di oggi: su cosa dovrei concentrarmi e c'è qualcosa su cui vale la pena agire?", "healthScore": "Perché il mio punteggio di salute è {score} su 100 e cosa lo migliorerebbe di più?", - "periodWeek": "Esamina con me com'è andata la mia ultima settimana sui miei parametri di salute — cosa spicca?", - "periodMonth": "Esamina con me com'è andato il mio ultimo mese sui miei parametri di salute — cosa spicca?", + "periodWeek": "Esamina con me com'è andata la mia ultima settimana sui miei parametri di salute: cosa spicca?", + "periodMonth": "Esamina con me com'è andato il mio ultimo mese sui miei parametri di salute: cosa spicca?", "correlationBpCompliance": "Che relazione c'è tra la mia aderenza alla terapia e la mia pressione arteriosa? Spiegami cosa significa per me questa correlazione.", "correlationMoodPulse": "C'è un legame reale tra il mio umore e il mio battito? Aiutami a interpretare questa correlazione.", "correlationWeightWeekday": "Il mio peso segue uno schema settimanale? Cosa mi dice questa correlazione per giorno della settimana?", - "recommendation": "Dimmi di più su questa raccomandazione: \"{text}\" — perché si applica a me e cosa dovrei fare?" + "recommendation": "Dimmi di più su questa raccomandazione: \"{text}\". Perché si applica a me e cosa dovrei fare?" }, "assessmentPrompt": "Spiegami la mia valutazione di {metric}: cosa significa e cosa dovrei fare?", "attach": { @@ -3180,9 +3215,9 @@ "cap": "Molti passaggi, ora riassume", "noProgress": "Nessun dato nuovo, riassume" }, - "thoughtProcessOne": "Ragionamento · {count} passaggio", - "thoughtProcessFew": "Ragionamento · {count} passaggi", - "thoughtProcessOther": "Ragionamento · {count} passaggi", + "thoughtProcessOne": "Ragionamento ({count} passaggio)", + "thoughtProcessFew": "Ragionamento ({count} passaggi)", + "thoughtProcessOther": "Ragionamento ({count} passaggi)", "awaitingAnswer": "Serve una tua risposta" }, "memory": { @@ -3222,7 +3257,8 @@ "capped": "(limitato dall'amministratore)", "disabled": "L'amministratore ha disattivato il ragionamento approfondito." }, - "memoryLink": "Memoria ({count})" + "memoryLink": "Memoria ({count})", + "newConversation": "Nuova conversazione" }, "relativeJustNow": "proprio ora", "relativeMinutesAgoOne": "{count} minuto fa", @@ -3285,7 +3321,7 @@ }, "hrv": { "title": "HRV", - "description": "Variabilità della frequenza cardiaca nel tempo — segnale di recupero del sistema nervoso, captato di notte da Apple Watch e altri wearable compatibili.", + "description": "Variabilità della frequenza cardiaca nel tempo: segnale di recupero del sistema nervoso, captato di notte da Apple Watch e altri wearable compatibili.", "chartTitle": "Variabilità della frequenza cardiaca", "emptyState": { "title": "Ancora nessun valore HRV", @@ -3294,7 +3330,7 @@ }, "restingHr": { "title": "FC a riposo", - "description": "Il livello cardiaco di base sulla giornata — distinto dalle frequenze puntuali della pagina Polso. Apple Salute fornisce un valore al giorno.", + "description": "Il livello cardiaco di base sulla giornata, distinto dalle frequenze puntuali della pagina Polso. Apple Salute fornisce un valore al giorno.", "chartTitle": "Frequenza cardiaca a riposo", "emptyState": { "title": "Ancora nessuna FC a riposo", @@ -3304,7 +3340,7 @@ }, "oxygenSaturation": { "title": "Saturazione di ossigeno", - "description": "Ossigeno nel sangue (SpO₂) — intervallo clinico normale 95–100 %, con zone obiettivo ombreggiate sul grafico.", + "description": "Ossigeno nel sangue (SpO₂): intervallo clinico normale 95–100 %, con zone obiettivo ombreggiate sul grafico.", "chartTitle": "Saturazione di ossigeno", "emptyState": { "title": "Ancora nessun campione SpO₂", @@ -3317,13 +3353,13 @@ "chartTitle": "Temperatura corporea", "emptyState": { "title": "Ancora nessuna temperatura corporea", - "description": "Registra una misura o lascia che il tuo wearable ne sincronizzi una — l'andamento apparirà qui.", + "description": "Registra una misura o lascia che il tuo wearable ne sincronizzi una: l'andamento apparirà qui.", "cta": "Registra temperatura corporea" } }, "steps": { "title": "Passi", - "description": "Conteggio giornaliero dei passi — una riga al giorno, da Apple Salute o da un dispositivo di attività collegato.", + "description": "Conteggio giornaliero dei passi: una riga al giorno, da Apple Salute o da un dispositivo di attività collegato.", "chartTitle": "Passi al giorno", "emptyState": { "title": "Ancora nessun dato sui passi", @@ -3332,7 +3368,7 @@ }, "activeEnergy": { "title": "Energia attiva", - "description": "Totale giornaliero di energia attiva in kilocalorie — una riga al giorno, da Apple Salute o dall'attività Withings.", + "description": "Totale giornaliero di energia attiva in kilocalorie: una riga al giorno, da Apple Salute o dall'attività Withings.", "chartTitle": "Energia attiva", "emptyState": { "title": "Ancora nessun dato di energia attiva", @@ -3356,7 +3392,7 @@ "weightBody": "Il peso corporeo è uno dei segnali più semplici dell'equilibrio energetico a lungo termine e della salute generale. L'andamento nelle settimane conta molto più di una singola misurazione, che varia con l'idratazione, il cibo e l'ora del giorno.", "bmiBody": "L'IMC è il peso diviso per l'altezza al quadrato, un singolo numero che ti colloca in un'ampia categoria di peso. È uno screening di popolazione, non una diagnosi, e non distingue il muscolo dal grasso.", "sleepBody": "La durata del sonno è quanto hai effettivamente dormito nella notte, suddivisa in fasi leggera, profonda e REM. La maggior parte degli adulti necessita di circa sette-nove ore; quantità e qualità costanti favoriscono recupero, umore e metabolismo.", - "moodBody": "Il monitoraggio dell'umore registra come ti senti nel tempo su una scala semplice. Annotarlo con regolarità rivela schemi — come sonno, attività o farmaci si allineano a come ti senti — che un singolo giorno non mostra mai.", + "moodBody": "Il monitoraggio dell'umore registra come ti senti nel tempo su una scala semplice. Annotarlo con regolarità rivela schemi (come sonno, attività o farmaci si allineano a come ti senti) che un singolo giorno non mostra mai.", "medicationsBody": "L'aderenza misura quanto regolarmente assumi i farmaci come prescritto, qui come quota delle dosi previste che hai registrato. Un'assunzione costante è spesso ciò che fa funzionare una terapia come previsto.", "restingHrBody": "La frequenza cardiaca a riposo è il battito quando sei completamente a riposo, di solito misurato durante la notte. Una frequenza più bassa indica in genere una migliore forma cardiovascolare, mentre un andamento in aumento può essere un segnale precoce di stress o malattia.", "hrvBody": "L'HRV è la piccola variazione del tempo tra battiti consecutivi. Una variabilità più alta riflette di solito un sistema nervoso ben recuperato e adattabile, mentre un calo prolungato può segnalare affaticamento, stress o recupero insufficiente.", @@ -3368,7 +3404,7 @@ "bodyWaterBody": "L'acqua corporea totale è la quota del tuo peso costituita da liquido, dentro e intorno alle cellule. Varia con l'idratazione, l'alimentazione e l'attività, quindi l'andamento nelle settimane dice più di una singola misura.", "boneMassBody": "La massa ossea è il peso stimato dei minerali del tuo scheletro. Le stime da bilancia sono approssimative, ma un andamento stabile o in aumento sostiene la robustezza ossea a lungo termine.", "fatFreeMassBody": "La massa magra è tutto ciò che nel corpo non è grasso: muscoli, ossa, organi e acqua. Mantenerla o aumentarla mentre si perde peso indica di solito una composizione corporea più sana.", - "fatMassBody": "La massa grassa è il peso totale del grasso nel corpo. Un po' di grasso è essenziale per gli ormoni e le riserve di energia, ma un eccesso — soprattutto sull'addome — aumenta il rischio per la salute a lungo termine (WHO 2024).", + "fatMassBody": "La massa grassa è il peso totale del grasso nel corpo. Un po' di grasso è essenziale per gli ormoni e le riserve di energia, ma un eccesso, soprattutto sull'addome, aumenta il rischio per la salute a lungo termine (WHO 2024).", "bodyFatBody": "La percentuale di grasso corporeo è la parte del tuo peso costituita da grasso. Gli intervalli sani dipendono da sesso ed età, e le bilance la stimano con la bioimpedenza, quindi l'andamento su più settimane dice più di una singola misura.", "muscleMassBody": "La massa muscolare è il peso stimato dei muscoli del corpo. Più muscolo sostiene forza, metabolismo e mobilità, e di solito richiede impegno attivo per essere mantenuto con l'età.", "visceralFatBody": "Il grasso viscerale è il grasso depositato in profondità nell'addome, intorno agli organi. A differenza del grasso sottocutaneo, un livello elevato è strettamente legato a malattie cardiache e diabete di tipo 2, quindi più basso è in genere meglio.", @@ -3399,11 +3435,11 @@ "breathingDisturbancesBody": "Un wearable può stimare quanto spesso la respirazione si è interrotta durante il sonno. Un indice più basso è migliore; un livello segnalato come elevato, soprattutto su molte notti, andrebbe segnalato a un medico. È un'approssimazione di screening, non una diagnosi di apnea del sonno.", "stepsBody": "Il conteggio dei passi è il numero totale di passi che fai ogni giorno: la misura più semplice del movimento quotidiano. Un'abitudine costante conta più di un singolo giorno intenso, e l'obiettivo tondo di 10.000 passi è uno slogan pubblicitario, non un dato della ricerca.", "readinessBody": "La prontezza è una lettura giornaliera di quanto il tuo corpo sia pronto ad affrontare la giornata, combinando frequenza cardiaca a riposo, variabilità della frequenza cardiaca e sonno. È un'approssimazione del dispositivo rispetto alla tua baseline, non una misura clinica: un punteggio più alto indica recupero, uno più basso è un invito gentile a rallentare.", - "sleepScoreBody": "Il tuo punteggio del sonno riassume un'intera notte in un solo numero — durata, efficienza, regolarità degli orari ed equilibrio tra sonno leggero, profondo e REM. Osserva l'andamento nell'arco delle settimane invece di rincorrere un punteggio perfetto in una singola notte.", + "sleepScoreBody": "Il tuo punteggio del sonno riassume un'intera notte in un solo numero: durata, efficienza, regolarità degli orari ed equilibrio tra sonno leggero, profondo e REM. Osserva l'andamento nell'arco delle settimane invece di rincorrere un punteggio perfetto in una singola notte.", "recoveryBody": "Il recupero riflette quanto bene il tuo corpo si è ripreso, basandosi su segnali notturni come la variabilità della frequenza cardiaca e la frequenza a riposo. Un punteggio alto è il via libera per spingere; uno più basso suggerisce che il tuo organismo si sta ancora ricostruendo.", "strainBody": "Lo sforzo misura quanto carico cardiovascolare e fisico hai imposto al tuo corpo, dagli allenamenti a una giornata attiva in piedi. Quando c'è abbastanza storico, viene valutato rispetto al tuo sforzo abituale, non rispetto a uno standard esterno.", "stressBody": "Lo stress qui è una stima del carico fisiologico sul tuo sistema nervoso, letto soprattutto dagli schemi di variabilità della frequenza cardiaca e non da come ti senti consapevolmente. Un punteggio più basso indica uno stato calmo e ben regolato; uno più alto è un invito a inserire del recupero.", - "recoveryPageBody": "Il recupero riunisce i segnali notturni registrati dal tuo wearable — ricarica, sforzo, carico e i totali di frequenza cardiaca ed energia della giornata — per mostrare quanto bene il tuo corpo si è ripreso. Guarda l'andamento su più giorni piuttosto che un singolo numero; un valore più basso è un invito a rallentare, non un allarme.", + "recoveryPageBody": "Il recupero riunisce i segnali notturni registrati dal tuo wearable (ricarica, sforzo, carico e i totali di frequenza cardiaca ed energia della giornata) per mostrare quanto bene il tuo corpo si è ripreso. Guarda l'andamento su più giorni piuttosto che un singolo numero; un valore più basso è un invito a rallentare, non un allarme.", "pulsePressureBody": "La pressione differenziale è la differenza tra il valore sistolico e quello diastolico (sistolica meno diastolica). Come orientamento generale, circa 40 mmHg è tipico a riposo; un divario ampio sopra i 60 mmHg circa nella mezza età può riflettere arterie più rigide, mentre un divario stretto sotto i 25 mmHg circa può accompagnare una bassa gittata sistolica (StatPearls/NIH 2023). È uno spunto di discussione, non una diagnosi.", "meanArterialPressureBody": "La pressione arteriosa media è la pressione media che i tuoi organi vedono in un battito, all'incirca la diastolica più un terzo del divario con la sistolica. Come orientamento generale, un valore a riposo si aggira sui 70–100 mmHg (StatPearls/NIH 2023). La soglia di ~60 mmHg spesso citata è un concetto di terapia intensiva, non un allarme per l'automisurazione.", "cycleEducationBody": "Il tuo ciclo attraversa quattro fasi guidate dagli ormoni, e la sua durata e regolarità sono una finestra accessibile sulla salute generale. Come orientamento generale, nei adulti i cicli durano tipicamente 21–35 giorni (i riferimenti clinici citano 24–38), con un sanguinamento di circa cinque giorni (ACOG 2015). La fase luteale è l'ancora stabile, quindi l'ovulazione si stima meglio contando a ritroso dalla prossima mestruazione attesa, mai con un «giorno 14» fisso. Una finestra fertile prevista è una stima che richiede più cicli registrati e resta approssimativa, e la temperatura basale conferma l'ovulazione a posteriori anziché prevederla. Non è un metodo contraccettivo.", @@ -3422,7 +3458,7 @@ "mean": "Media" }, "showAllValues": "Mostra tutte le misurazioni", - "valuesPageTitle": "{metric} — tutte le misurazioni", + "valuesPageTitle": "{metric}: tutte le misurazioni", "valuesPageDescription": "Ogni misurazione dietro questa metrica. Modifica o rimuovi una voce direttamente.", "diversity": { "trigger": "Suggerimento di misurazione", @@ -3462,11 +3498,11 @@ "inBed": "A letto", "nap": "Pisolino", "napCount": "{count} pisolini", - "unavailable": "Ancora nessun dato sulle fasi — le fasi del sonno appariranno qui non appena una fonte collegata le fornirà." + "unavailable": "Ancora nessun dato sulle fasi. Le fasi del sonno appariranno qui non appena una fonte collegata le fornirà." }, "empty": { "title": "Ancora nessun dato sul sonno", - "description": "Collega una fonte come Apple Health o un wearable — oppure registra il sonno manualmente — e questa vista si riempirà in automatico.", + "description": "Collega una fonte come Apple Health o un wearable, oppure registra il sonno manualmente, e questa vista si riempirà in automatico.", "action": "Gestisci dispositivi" }, "debt": { @@ -3475,21 +3511,21 @@ "caption": "Bilancio in sospeso nelle ultime {nights} notti", "clearValue": "In pari", "clearCaption": "Niente in sospeso nelle ultime {nights} notti", - "learning": "Stiamo ancora misurando il tuo sonno — {counted} notti finora, ne mancano {remaining} per un bilancio affidabile.", + "learning": "Stiamo ancora misurando il tuo sonno: {counted} notti finora, ne mancano {remaining} per un bilancio affidabile.", "computedInfo": "Questa è la stima propria di HealthLog: un bilancio mobile tra fabbisogno e sonno delle ultime notti, che il sonno di recupero riduce. Può differire dal dato di debito di sonno nativo di un dispositivo indossabile, che usa un proprio modello di fabbisogno." }, "average": { "title": "Media per notte", "value": "{hours} h {minutes} min", "caption": "Sonno medio nelle ultime {nights} notti", - "learning": "Stiamo ancora misurando il tuo sonno — {counted} notti finora, ne mancano {remaining} per una media affidabile." + "learning": "Stiamo ancora misurando il tuo sonno: {counted} notti finora, ne mancano {remaining} per una media affidabile." }, "chronotype": { "title": "Cronotipo", - "learning": "Stiamo ancora imparando il tuo ritmo — {counted} di {total} notti libere. Un cronotipo richiede alcune mattine senza sveglia per avere significato.", + "learning": "Stiamo ancora imparando il tuo ritmo: {counted} di {total} notti libere. Un cronotipo richiede alcune mattine senza sveglia per avere significato.", "advancedToggle": "Avanzate", "socialJetlagValue": "{hours} h {minutes} min di jet lag sociale", - "socialJetlagCaption": "La differenza tra gli orari del sonno nei giorni lavorativi e in quelli liberi — come un piccolo cambio di fuso orario settimanale.", + "socialJetlagCaption": "La differenza tra gli orari del sonno nei giorni lavorativi e in quelli liberi, come un piccolo cambio di fuso orario settimanale.", "msfScValue": "Metà del sonno corretta {clock}", "msfScCaption": "Corretta per il sonno recuperato nel fine settimana (MSFsc).", "dayTypeNote": "Si presume che i giorni liberi siano i fine settimana finché non verrà aggiunto il tracciamento del tipo di giorno.", @@ -3678,7 +3714,10 @@ "endInFuture": "Finirebbe nel futuro. Imposta come inizio l’ora in cui è cominciato l’allenamento.", "distanceInvalid": "Inserisci la distanza come numero in un intervallo realistico.", "energyInvalid": "Inserisci l’energia attiva come numero fino a 20.000 kcal." - } + }, + "editTitle": "Modifica allenamento", + "editAction": "Modifica", + "updated": "Allenamento aggiornato" } }, "bloodGlucose": { @@ -3697,7 +3736,7 @@ "labsLink": "Risultati di laboratorio? Registra una HbA1c o la glicemia a digiuno da un prelievo in Laboratorio.", "learningTitle": "Stiamo ancora imparando la tua glicemia", "learningBody": "Per ora sono {count} su {days}. Con un po’ più di storico il quadro si chiarisce: continua a registrare o lascia che lo completi un dispositivo connesso.", - "learningBodyEmpty": "Appena arrivano alcune misurazioni — registrate da te o sincronizzate da un dispositivo — il tempo nel range, la media e la variabilità prendono forma qui.", + "learningBodyEmpty": "Appena arrivano alcune misurazioni (registrate da te o sincronizzate da un dispositivo), il tempo nel range, la media e la variabilità prendono forma qui.", "readingsCount": "{count} misurazioni", "readingsCountOne": "1 misurazione", "daysCount": "{days} giorni", @@ -3852,7 +3891,7 @@ "navSkinTemperature": "Temp. pelle", "pulseWaveVelocity": { "title": "Velocità dell’onda di polso", - "description": "La tua velocità dell’onda di polso arteriosa nel tempo, in m/s — valori più alti indicano arterie più rigide.", + "description": "La tua velocità dell’onda di polso arteriosa nel tempo, in m/s. Valori più alti indicano arterie più rigide.", "chartTitle": "Velocità dell’onda di polso", "emptyState": { "title": "Nessun dato", @@ -4002,7 +4041,6 @@ "navWalkingSpeed": "Velocità di cammino", "mood": { "logLink": "Registra un umore", - "mentalWellbeingLink": "Fai un check-in sul benessere mentale", "heatmapTitle": "Calendario dell'umore", "heatmapNoEntry": "Nessun dato", "legendGreat": "Ottimo", @@ -4013,7 +4051,7 @@ "distributionDaysUnit": "giorni", "weekdayTitle": "Media per giorno della settimana", "weekdayNoData": "Nessun dato", - "weekdayBestWorst": "Giorno migliore: {best} · Giorno più difficile: {worst}", + "weekdayBestWorst": "Giorno migliore: {best}, giorno più difficile: {worst}", "tagsTitle": "Tag e fattori scatenanti", "tagAvgMood": "Umore medio con questo tag", "correlationsTitle": "Umore e altre metriche", @@ -4029,7 +4067,7 @@ "strengthModerate": "Moderata", "strengthWeak": "Debole", "strengthNone": "Nessuna", - "source": "{n} giorni appaiati · r = {r}", + "source": "{n} giorni appaiati, r = {r}", "emptyTitle": "Non ci sono ancora abbastanza dati appaiati", "emptyDescription": "Registra umore e questa metrica negli stessi giorni per vedere una correlazione.", "pendingDescription": "{n} di {needed} giorni appaiati. La correlazione compare quando i giorni bastano a distinguere un pattern dal caso.", @@ -4055,7 +4093,7 @@ "tagLift": "I giorni con l'etichetta «{tag}» superano in media il tuo umore generale di {delta}.", "tagDrop": "I giorni con l'etichetta «{tag}» restano {delta} sotto la tua media.", "inTarget": "Sei stato nel tuo intervallo di buon umore il {pct} % degli ultimi 30 giorni.", - "streak": "Hai registrato il tuo umore per {days} giorni di fila — continua così.", + "streak": "Hai registrato il tuo umore per {days} giorni di fila. Continua così.", "timeOfDay": "Ti senti meglio di {bucket}, con una media di {value}." }, "timeOfDay": { @@ -4088,7 +4126,7 @@ "crosstab": { "title": "Tag e le tue metriche di salute", "description": "Come una metrica di salute cambia nei giorni in cui hai aggiunto un tag rispetto ai giorni senza.", - "pairLabel": "{tag} · {metric}", + "pairLabel": "{metric} nei giorni con {tag}", "metricActiveEnergy": "Energia attiva", "metricSleepDuration": "Sonno", "metricNextDayRecovery": "Recupero il giorno dopo", @@ -4105,7 +4143,7 @@ "factorCrosstab": { "title": "I tuoi fattori valutati e i tuoi parametri vitali", "description": "Come cambia un parametro vitale nei giorni in cui hai valutato basso un fattore rispetto ai giorni in cui lo hai valutato alto.", - "pairLabel": "{factor} · {metric}", + "pairLabel": "{metric} nei giorni con {factor}", "metricSleepDuration": "Sonno", "metricSteps": "Passi", "metricRestingHeartRate": "Frequenza cardiaca a riposo", @@ -4131,7 +4169,7 @@ "moodDown": "Un {factor} più alto tende a coincidere con un umore peggiore il giorno dopo nei tuoi dati.", "outcomeUp": "Un umore migliore tende a coincidere con un {factor} più alto il giorno dopo nei tuoi dati.", "outcomeDown": "Un umore migliore tende a coincidere con un {factor} più basso il giorno dopo nei tuoi dati.", - "stat": "{n} giorni appaiati · r = {r} · q = {q}", + "stat": "{n} giorni appaiati, r = {r}, q = {q}", "statLabel": "Come è stato calcolato", "explainerLabel": "Come è stato calcolato" }, @@ -4165,8 +4203,8 @@ "deviationAbove": "La tua valutazione è {amount} punti sopra questo intervallo.", "deviationBelow": "La tua valutazione è {amount} punti sotto questo intervallo.", "weightedTitle": "Che cosa ha pesato di più nel confronto di questa giornata", - "weightedUp": "{label} — contava verso un valore atteso più alto", - "weightedDown": "{label} — contava verso un valore atteso più basso", + "weightedUp": "{label}: contava verso un valore atteso più alto", + "weightedDown": "{label}: contava verso un valore atteso più basso", "caveat": "È un confronto con le tue giornate passate, non un'affermazione su come una giornata sia andata a finire così.", "learning": "Fase di apprendimento: {entries} giornate su {threshold} registrate. Il confronto compare quando ce ne sono abbastanza.", "noPattern": "Nelle {entries} giornate registrate finora non c'è nulla di abbastanza costante per confrontare una giornata. Quando registri anche il contesto della giornata, ne rendi confrontabile una parte maggiore.", @@ -4175,15 +4213,15 @@ }, "derived": { "coverage": { - "summary": "{present} di {required} input · {percent}%", + "summary": "{present} di {required} input ({percent}%)", "ratioLabel": "{present}/{required}", "historyDays": "Basato su {count} giorni di cronologia", "missing": "Registra questi per affinare il valore: {list}", - "summaryLabelled": "{label} · {percent}%" + "summaryLabelled": "{label} ({percent}%)" }, "scoreRing": { "aria": "Punteggio {score} su 100, {band}", - "ariaProvisional": "Punteggio non ancora disponibile — dati insufficienti", + "ariaProvisional": "Punteggio non ancora disponibile: dati insufficienti", "provisionalCaption": "Dati ancora insufficienti", "band": { "green": "buono", @@ -4197,7 +4235,7 @@ "inputsLabel": "Input", "noInputs": "Nessun input supporta ancora questo valore.", "methodLabel": "Metodo", - "window": "Dai tuoi ultimi {days} giorni · dati {source}", + "window": "Dai tuoi ultimi {days} giorni di dati {source}", "asOf": "al {date}", "standardLink": "Standard: {name}" }, @@ -4207,7 +4245,7 @@ "loadingLabel": "Caricamento dei tuoi parametri vitali", "loadError": "Al momento non è stato possibile caricare i tuoi parametri vitali.", "infoLabel": "Come viene calcolato questo dato?", - "building": "Creazione del tuo intervallo abituale — {count} di {target} giorni", + "building": "Creazione del tuo intervallo abituale: {count} di {target} giorni", "typicalRange": "Abituale per te: {low}–{high}", "fitnessYounger": "Forma cardio di chi ha ~{years} anni in meno", "fitnessOlder": "Forma cardio ~{years} anni oltre la tua età", @@ -4239,9 +4277,8 @@ "anatomy": { "outOf": "/100", "contributorsLabel": "Fattori", - "contributorValue": "{value} · {weight}% di peso", + "contributorValue": "{value}, peso {weight}%", "contributorMissing": "nessun dato", - "disclaimer": "Indicativo — non è una valutazione clinica.", "insufficient": "Dati ancora insufficienti per assegnare un punteggio. Continua a registrare e comparirà qui." }, "composite": { @@ -4260,15 +4297,14 @@ "READINESS": { "title": "Prontezza", "description": "Un indicatore quotidiano di benessere in base a come i tuoi segnali si collocano rispetto alla tua baseline.", - "method": "Una combinazione 0–100 di deviazione della frequenza cardiaca a riposo, deviazione della HRV (SDNN), qualità del sonno, stabilità della frequenza respiratoria e stabilità dell’umore — ciascuna misurata rispetto alla tua baseline personale. I pesi vengono rinormalizzati sui componenti supportati dai tuoi dati. Un indicatore di benessere, non un punteggio di recupero né clinico.", + "method": "Una combinazione 0–100 di deviazione della frequenza cardiaca a riposo, deviazione della HRV (SDNN), qualità del sonno, stabilità della frequenza respiratoria e stabilità dell’umore, ciascuna misurata rispetto alla tua baseline personale. I pesi vengono rinormalizzati sui componenti supportati dai tuoi dati. Un indicatore di benessere, non un punteggio di recupero né clinico.", "component": { "rhr": "Frequenza cardiaca a riposo", "hrv": "HRV (SDNN)", "sleep": "Sonno", "respiratory": "Frequenza respiratoria", "mood": "Stabilità dell’umore" - }, - "caveat": "Un indicatore di benessere, non un punteggio di recupero né clinico." + } }, "VITALS_BASELINE": { "method": "Il tuo intervallo tipico personale è la mediana dei tuoi valori giornalieri nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). Segue le TUE letture, non una norma di popolazione: un valore dentro la banda è quindi tipico per te." @@ -4293,40 +4329,39 @@ }, "RECOVERY_SCORE": { "method": "Un indicatore notturno di recupero da 0 a 100 che combina VFC, frequenza cardiaca a riposo e sonno rispetto alla tua linea di base. Più alto significa più recuperato. Calcolato una volta a notte e qui solo riletto, mai ricalcolato al volo.", - "caveat": "Un indicatore di benessere descrittivo, non una misura di recupero clinica o da allenamento.", "description": "Un indicatore notturno di recupero da VFC, frequenza a riposo e sonno." }, "STRESS_SCORE": { "method": "Un indicatore notturno di stress da 0 a 100 dalla forma della tua VFC nel corso della giornata rispetto alla tua linea di base. Più alto significa maggiore carico fisiologico.", - "caveat": "Un indicatore derivato dalla VFC, NON una misura di stress da EDA / attività elettrodermica: il tuo orologio non ha un sensore EDA. Descrittivo, non clinico.", "description": "Un indicatore notturno di stress dalla forma della tua VFC nel corso della giornata." }, "STRAIN_SCORE": { "method": "Un indicatore notturno di sforzo da 0 a 100 dal tuo carico di attività pesato sulla frequenza cardiaca (un modello di impulso d’allenamento tipo TRIMP). Più alto significa una giornata più impegnativa.", - "anchorPersonal": "Ancorato al tuo carico recente nei giorni di allenamento — valutato rispetto al tuo sforzo abituale, così che una giornata davvero dura per te risulti alta.", + "anchorPersonal": "Ancorato al tuo carico recente nei giorni di allenamento: valutato rispetto al tuo sforzo abituale, così che una giornata davvero dura per te risulti alta.", "anchorPopulation": "Valutato rispetto a un riferimento generale mentre costruisci lo storico di allenamento; quando ne hai abbastanza passa al tuo sforzo abituale.", - "caveat": "Un indicatore descrittivo di carico di attività, non una misura di carico clinica o da allenamento.", - "description": "Un indicatore notturno di carico di attività dal tuo sforzo pesato sulla frequenza cardiaca, relativo al tuo sforzo abituale." + "description": "Un indicatore notturno di carico di attività dal tuo sforzo pesato sulla frequenza cardiaca, relativo al tuo sforzo abituale.", + "deviceCaption": "Sforzo giornaliero del tuo dispositivo: {value} su {max}", + "deviceSource": "Finché HealthLog non ha calcolato un proprio valore durante la notte, la scheda mostra lo sforzo giornaliero del tuo dispositivo, riportato su 0–100." }, "WRIST_TEMPERATURE_BASELINE": { - "method": "La tua tipica temperatura notturna al polso è la mediana delle tue letture notturne nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). Segue le TUE letture — la notte scorsa è mostrata come scostamento dalla tua baseline, non da una norma di popolazione.", - "caveat": "Solo una banda di scostamento personale — descrittiva, non un segnale di malattia, febbre o fase del ciclo. Sei tu a interpretarla." + "method": "La tua tipica temperatura notturna al polso è la mediana delle tue letture notturne nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). Segue le TUE letture: la notte scorsa è mostrata come scostamento dalla tua baseline, non da una norma di popolazione.", + "caveat": "Solo una banda di scostamento personale, descrittiva, non un segnale di malattia, febbre o fase del ciclo. Sei tu a interpretarla." }, "STAIR_ASCENT_SPEED_BASELINE": { - "method": "Il tuo passo tipico in salita sulle scale è la mediana dei tuoi valori giornalieri nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). La banda è costruita sui tuoi dati — non esiste una soglia di popolazione per la velocità sulle scale, perché il passo dipende dalla geometria dei gradini e dalla lunghezza delle gambe.", - "caveat": "Una banda personale di tendenza della mobilità — descrittiva, non una valutazione di fragilità o di rischio di caduta." + "method": "Il tuo passo tipico in salita sulle scale è la mediana dei tuoi valori giornalieri nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). La banda è costruita sui tuoi dati: non esiste una soglia di popolazione per la velocità sulle scale, perché il passo dipende dalla geometria dei gradini e dalla lunghezza delle gambe.", + "caveat": "Una banda personale di tendenza della mobilità, descrittiva, non una valutazione di fragilità o di rischio di caduta." }, "STAIR_DESCENT_SPEED_BASELINE": { - "method": "Il tuo passo tipico in discesa sulle scale è la mediana dei tuoi valori giornalieri nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). La banda è costruita sui tuoi dati — non esiste una soglia di popolazione per la velocità sulle scale, perché il passo dipende dalla geometria dei gradini e dalla lunghezza delle gambe.", - "caveat": "Una banda personale di tendenza della mobilità — descrittiva, non una valutazione di fragilità o di rischio di caduta." + "method": "Il tuo passo tipico in discesa sulle scale è la mediana dei tuoi valori giornalieri nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). La banda è costruita sui tuoi dati: non esiste una soglia di popolazione per la velocità sulle scale, perché il passo dipende dalla geometria dei gradini e dalla lunghezza delle gambe.", + "caveat": "Una banda personale di tendenza della mobilità, descrittiva, non una valutazione di fragilità o di rischio di caduta." }, "SIX_MINUTE_WALK_BAND": { - "method": "La distanza di cammino di sei minuti STIMATA dal tuo dispositivo, collocata rispetto al riferimento di Enright e Sherrill per la tua età, altezza, peso e sesso, come percentuale del previsto. La distanza è la stima del dispositivo — mai ricalcolata qui. Senza età, altezza, peso e sesso la percentuale è nascosta e vengono mostrate solo distanza e tendenza.", - "caveat": "Una stima riformulata rispetto a un riferimento pubblicato — un segnale di capacità funzionale, non un test del cammino di sei minuti clinico né una diagnosi. Il riferimento è stato stabilito per le età 40–80; al di fuori di questo intervallo il valore previsto è un’estrapolazione." + "method": "La distanza di cammino di sei minuti STIMATA dal tuo dispositivo, collocata rispetto al riferimento di Enright e Sherrill per la tua età, altezza, peso e sesso, come percentuale del previsto. La distanza è la stima del dispositivo, mai ricalcolata qui. Senza età, altezza, peso e sesso la percentuale è nascosta e vengono mostrate solo distanza e tendenza.", + "caveat": "Una stima riformulata rispetto a un riferimento pubblicato: un segnale di capacità funzionale, non un test del cammino di sei minuti clinico né una diagnosi. Il riferimento è stato stabilito per le età 40–80; al di fuori di questo intervallo il valore previsto è un’estrapolazione." }, "TRAJECTORY": { "method": "Una retta ai minimi quadrati adattata ai tuoi valori giornalieri recenti e proiettata in avanti su un breve orizzonte, con l’intervallo di previsione classico che si allarga man mano che si estende. Calcolata solo quando la tendenza recente è abbastanza marcata e la tua cronologia è abbastanza lunga e aggiornata; altrimenti non viene tracciata alcuna linea.", - "caveat": "Una proiezione della tua tendenza recente — non una previsione, un obiettivo o una prognosi clinica. Vale solo se lo schema recente continua; la banda ombreggiata mostra quanto rapidamente quella certezza svanisce." + "caveat": "Una proiezione della tua tendenza recente, non una previsione, un obiettivo o una prognosi clinica. Vale solo se lo schema recente continua; la banda ombreggiata mostra quanto rapidamente quella certezza svanisce." }, "SAME_TIME_BASELINE": { "method": "Il tuo totale progressivo di oggi confrontato con la mediana della stessa ora locale nelle ultime quattro settimane, con una banda robusta mediana ± MAD attorno. Contano solo i giorni con abbastanza dettaglio registrato, e nessun confronto viene mostrato finché non ce ne sono almeno quattordici.", @@ -4341,7 +4376,9 @@ "recovery": "Punteggio di recupero", "recoveryCrossLink": "Vedi i dati di recupero e sforzo del tuo wearable", "stress": "Stress", - "strain": "Sforzo" + "strain": "Sforzo", + "historyTitle": "Andamento", + "historyWindow": "Ultimi {days} giorni" }, "coincident": { "label": "Più parametri fuori dalla base", @@ -4351,24 +4388,24 @@ "allClear": "Oggi tutti i tuoi parametri vitali sono nel loro intervallo personale.", "allClearMeta": "Controllati {count} dei tuoi parametri rispetto al tuo intervallo abituale.", "watch": "Oggi un parametro è fuori dal suo intervallo abituale.", - "watchVital": "Fuori intervallo: {vital}. Spesso è un episodio isolato — tienilo d’occhio nei prossimi giorni.", + "watchVital": "Fuori intervallo: {vital}. Spesso è un episodio isolato: tienilo d’occhio nei prossimi giorni.", "firedHeadline": "Oggi {count} dei tuoi parametri vitali sono fuori dal loro intervallo personale.", - "factors": "Possibili fattori — mai una causa: un allenamento intenso, poco sonno, alcol, altitudine, stress o una malattia. È un avviso basato sui tuoi parametri, non una diagnosi.", - "building": "Stiamo costruendo i tuoi riferimenti personali — registra ancora qualche giorno e comparirà.", + "factors": "Possibili fattori, mai una causa: un allenamento intenso, poco sonno, alcol, altitudine, stress o una malattia. È un avviso basato sui tuoi parametri, non una diagnosi.", + "building": "Stiamo costruendo i tuoi riferimenti personali. Registra ancora qualche giorno e comparirà.", "illnessExplained": "Questi scostamenti riflettono probabilmente la condizione che hai registrato." }, "trajectory": { "cardTitle": "Proiezione a breve termine", - "headline": "Se questo schema continua — i prossimi {days} giorni", + "headline": "Se questo schema continua: i prossimi {days} giorni", "range": "Proiettato intorno a {low}–{high} {unit} tra {days} giorni.", "caveat": "Una proiezione della tua tendenza recente, non una previsione. La banda ombreggiata si allarga per mostrare quanto rapidamente cresce l’incertezza.", - "insufficient": "Non c’è ancora una tendenza abbastanza chiara da proiettare — continua a registrare e comparirà una proiezione quando lo schema recente sarà abbastanza marcato.", + "insufficient": "Non c’è ancora una tendenza abbastanza chiara da proiettare. Continua a registrare e comparirà una proiezione quando lo schema recente sarà abbastanza marcato.", "daysNeeded": "Servono ancora circa {count} giorni di registrazione prima che possa comparire una proiezione." }, "sameTime": { "sectionTitle": "Oggi finora", "sectionSubtitle": "I tuoi totali progressivi rispetto a ciò che per te è abituale a quest'ora", - "learning": "Sto imparando la tua giornata tipo — {count} di {target} giorni", + "learning": "Sto imparando la tua giornata tipo: {count} di {target} giorni", "framing": "Abituale entro le {time}: {typical} {unit}", "comparisonLabel": "La tua giornata tipo" } @@ -4455,7 +4492,7 @@ "navBreathingDisturbances": "Respirazione", "rhythmEvents": { "sectionTitle": "Notifiche sulla salute del dispositivo", - "sectionIntro": "Notifiche che il tuo orologio ha inviato autonomamente. HealthLog si limita a mostrarle — non le rileva, conferma né interpreta.", + "sectionIntro": "Notifiche che il tuo orologio ha inviato autonomamente. HealthLog si limita a mostrarle: non le rileva, conferma né interpreta.", "loadError": "Impossibile caricare le notifiche del dispositivo.", "disclaimer": "Questi sono avvisi generati dal tuo dispositivo con il proprio rilevamento integrato. Sono mostrati qui solo a scopo informativo. Non è una valutazione medica da parte di HealthLog, e HealthLog non diagnostica alcuna condizione. Se una notifica ti preoccupa, parla con il tuo medico.", "event": { @@ -4500,9 +4537,9 @@ "detail": "Le pagine di dettaglio nascoste vengono rimosse anche dalla navigazione in alto." }, "customize": "Personalizza gli Approfondimenti", - "regenerateRateLimited": "Limite di aggiornamento raggiunto — riprova più tardi.", - "regenerateUnavailable": "Nessuna nuova analisi ricevuta — riprova più tardi.", - "regenerateError": "Aggiornamento ancora in corso — riprova tra un momento.", + "regenerateRateLimited": "Limite di aggiornamento raggiunto. Riprova più tardi.", + "regenerateUnavailable": "Nessuna nuova analisi ricevuta. Riprova più tardi.", + "regenerateError": "Aggiornamento ancora in corso. Riprova tra un momento.", "regenerateSettling": "La valutazione è ancora in preparazione", "regenerateSettleFailed": "Non è stato possibile completare l’aggiornamento. Riprova più tardi.", "deviceScore": { @@ -4524,9 +4561,9 @@ "emptyTitle": "Ancora nessun dato di recupero", "empty": "Ancora nessun dato di recupero. Collega un account WHOOP, Polar o Oura e questi punteggi si popoleranno appena il dispositivo si sincronizza.", "block": { - "ansChargeExplainer": "Quanto è carico il tuo sistema nervoso autonomo per la giornata — una lettura di ricarica ricavata dai segnali notturni di cuore e respiro.", + "ansChargeExplainer": "Quanto è carico il tuo sistema nervoso autonomo per la giornata: una lettura di ricarica ricavata dai segnali notturni di cuore e respiro.", "dayStrainExplainer": "Il carico cardiovascolare della giornata sulla scala di sforzo del tuo dispositivo. Leggilo in rapporto al tuo recupero, non come obiettivo a sé.", - "workoutStrainExplainer": "Quanto è stato impegnativo un singolo allenamento sulla stessa scala di sforzo — il costo cardiovascolare di quella sessione.", + "workoutStrainExplainer": "Quanto è stato impegnativo un singolo allenamento sulla stessa scala di sforzo: il costo cardiovascolare di quella sessione.", "cardioLoadExplainer": "Una stima del carico di allenamento che traccia il carico cardiovascolare accumulato. Confrontala con i tuoi giorni recenti.", "averageHeartRateExplainer": "La tua frequenza cardiaca media nell'arco dell'intera giornata o ciclo, come riportata dal tuo wearable.", "maxHeartRateExplainer": "La tua frequenza cardiaca massima nell'arco dell'intera giornata o ciclo, come riportata dal tuo wearable.", @@ -4539,7 +4576,7 @@ }, "resilience": { "explainer": "Quanto bene il tuo corpo bilancia il carico quotidiano con il recupero, letto dai tuoi segnali notturni degli ultimi giorni.", - "learning": "Stiamo ancora imparando la tua resilienza — qualche giorno in più stabilizzerà il livello.", + "learning": "Stiamo ancora imparando la tua resilienza. Qualche giorno in più stabilizzerà il livello.", "band": { "limited": "Limitata", "adequate": "Adeguata", @@ -4607,7 +4644,7 @@ "description": "Registra una misura con un dinamometro manuale (annota la migliore di alcune strette) e l'andamento apparirà qui.", "cta": "Registra la forza di presa" }, - "coachPrefill": "Non ho ancora registrato la forza di presa — cosa dice sulla salute dei miei muscoli e come la misuro e la miglioro?" + "coachPrefill": "Non ho ancora registrato la forza di presa. Cosa dice sulla salute dei miei muscoli e come la misuro e la miglioro?" }, "pain": { "title": "Dolore (0–10)", @@ -4618,7 +4655,7 @@ "description": "Registra come senti il dolore in questo momento sulla scala 0–10 e l'andamento apparirà qui.", "cta": "Registra un punteggio del dolore" }, - "coachPrefill": "Non ho ancora registrato punteggi del dolore — come dovrei monitorare il dolore sulla scala 0–10 e cosa conta come variazione significativa?" + "coachPrefill": "Non ho ancora registrato punteggi del dolore. Come dovrei monitorare il dolore sulla scala 0–10 e cosa conta come variazione significativa?" }, "waist": { "title": "Circonferenza vita", @@ -4629,7 +4666,7 @@ "description": "Misura la vita nel punto medio tra l'ultima costola e l'osso dell'anca, a fine espirazione normale, e registrala qui.", "cta": "Registra la circonferenza vita" }, - "coachPrefill": "Non ho ancora registrato misure della vita — come misuro la vita correttamente e cosa significa il numero per la mia salute?" + "coachPrefill": "Non ho ancora registrato misure della vita. Come misuro la vita correttamente e cosa significa il numero per la mia salute?" }, "waistToHeight": { "title": "Rapporto vita-altezza", @@ -4640,7 +4677,7 @@ "description": "Registra un rapporto vita-altezza e l'andamento apparirà qui. Regola pratica: tieni la vita sotto la metà della tua altezza.", "cta": "Registra il rapporto vita-altezza" }, - "coachPrefill": "Non ho ancora registrato un rapporto vita-altezza — perché è un numero utile e come lo tengo sotto 0,5?" + "coachPrefill": "Non ho ancora registrato un rapporto vita-altezza. Perché è un numero utile e come lo tengo sotto 0,5?" }, "ecg": { "sectionTitle": "Registrazioni ECG", @@ -4681,9 +4718,9 @@ "emptyCta": "Collega un dispositivo" }, "intradayPulse": { - "title": "L'andamento della tua giornata", - "caption": "La tua frequenza cardiaca durante il giorno, mediata in intervalli di 10 minuti.", - "hourlyNote": "Media oraria per questo giorno — i giorni recenti mostrano il dettaglio completo a 10 minuti.", + "title": "Battito nel corso della giornata", + "caption": "Media su intervalli di 10 minuti.", + "hourlyNote": "Media oraria per questo giorno. I giorni recenti mostrano il dettaglio completo a 10 minuti.", "empty": "Non ci sono ancora abbastanza misurazioni per questo giorno per mostrarne l'andamento.", "coverageOne": "Basato su 1 misurazione in {hours}h.", "coverageMany": "Basato su {count} misurazioni in {hours}h.", @@ -4692,10 +4729,10 @@ "previousDay": "Giorno precedente", "nextDay": "Giorno successivo", "tension": { - "morning": "Un tratto elevato a riposo stamattina — forse un po' di tensione.", - "afternoon": "Un tratto elevato a riposo questo pomeriggio — forse un po' di tensione.", - "evening": "Un tratto elevato a riposo stasera — forse un po' di tensione.", - "night": "Un tratto elevato a riposo durante la notte — forse un po' di tensione." + "morning": "Un tratto elevato a riposo stamattina, forse un po' di tensione.", + "afternoon": "Un tratto elevato a riposo questo pomeriggio, forse un po' di tensione.", + "evening": "Un tratto elevato a riposo stasera, forse un po' di tensione.", + "night": "Un tratto elevato a riposo durante la notte, forse un po' di tensione." } }, "navEcg": "ECG", @@ -4706,6 +4743,19 @@ "settingsBody": "HealthLog funziona completamente senza IA. Collega un fornitore qui sotto se vuoi un riepilogo giornaliero, brevi note sulle tue misurazioni e il Coach.", "action": "Configura IA", "dismiss": "Non ora" + }, + "bmiHeight": { + "coachPrefill": "Non ho ancora inserito la mia altezza: perché conta il BMI e cosa dovrei sapere prima di impostarla?" + }, + "medicationsEmpty": { + "coachPrefill": "Non ho ancora aggiunto farmaci: cosa dovrei sapere prima di iniziare a monitorare qui l’aderenza?" + }, + "scoreHistory": { + "healthTitle": "Andamento del punteggio di salute", + "healthDescription": "Il tuo punteggio di salute giorno per giorno, come veniva mostrato ogni giorno.", + "emptyDescription": "In questo periodo non è ancora stato registrato alcun punteggio.", + "usualRange": "In ombra: il tuo intervallo abituale, da {lo} a {hi}.", + "seam": "La linea si interrompe dove è cambiato ciò che conta per il punteggio." } }, "thresholds": { @@ -4735,14 +4785,15 @@ "metricBodyFat": "Massa grassa", "metricSleep": "Durata del sonno", "metricSteps": "Passi/giorno", - "metricGlucoseFasting": "Glicemia — a digiuno", - "metricGlucosePostprandial": "Glicemia — postprandiale", - "metricGlucoseRandom": "Glicemia — qualsiasi", - "metricGlucoseBedtime": "Glicemia — prima di coricarsi", + "metricGlucoseFasting": "Glicemia (a digiuno)", + "metricGlucosePostprandial": "Glicemia (postprandiale)", + "metricGlucoseRandom": "Glicemia (qualsiasi)", + "metricGlucoseBedtime": "Glicemia (prima di coricarsi)", "metricBodyWater": "Acqua corporea", "metricBoneMass": "Massa ossea", "metricOxygenSaturation": "Saturazione di ossigeno (SpO₂)", - "unsetExplanation": "Nessun valore personale — è attivo il predefinito calcolato." + "unsetExplanation": "Nessun valore personale: è attivo il predefinito calcolato.", + "cardTitle": "Intervalli per metrica" }, "onboarding": { "errorGeneric": "Qualcosa è andato storto durante il completamento della configurazione. Riprova.", @@ -4759,7 +4810,7 @@ }, "quickAdd": { "title": "Registrato in pochi secondi", - "body": "Con « + Aggiungi » registri qualsiasi misurazione in pochi secondi — peso, pressione, polso, glicemia. Forza nascosta: retrodatare i valori dimenticati. Tutto ciò che inserisci qui alimenta tendenze, punteggio salute e analisi IA." + "body": "Con « + Aggiungi » registri qualsiasi misurazione in pochi secondi: peso, pressione, polso, glicemia. Forza nascosta: retrodatare i valori dimenticati. Tutto ciò che inserisci qui alimenta tendenze, punteggio salute e analisi IA." }, "measurements": { "title": "Misurazioni e intervalli obiettivo", @@ -4771,7 +4822,7 @@ }, "labs": { "title": "Valori di laboratorio", - "body": "Laboratorio raccoglie i biomarcatori con intervalli di riferimento — emocromo, colesterolo, HbA1c. Forza: importazione ed esportazione FHIR. I valori fuori intervallo riemergono come prove nelle analisi e nel referto medico." + "body": "Laboratorio raccoglie i biomarcatori con intervalli di riferimento: emocromo, colesterolo, HbA1c. Forza: importazione ed esportazione FHIR. I valori fuori intervallo riemergono come prove nelle analisi e nel referto medico." }, "illness": { "title": "Diario delle malattie", @@ -4787,11 +4838,11 @@ }, "mood": { "title": "Umore", - "body": "Umore cattura come ti senti e dei tag in due secondi. Forza: i tuoi tag personalizzati. Gli andamenti dell’umore si correlano con sonno, farmaci e ciclo — il Coach coglie gli schemi." + "body": "Umore cattura come ti senti e dei tag in due secondi. Forza: i tuoi tag personalizzati. Gli andamenti dell’umore si correlano con sonno, farmaci e ciclo, e il Coach coglie gli schemi." }, "insights": { "title": "Analisi", - "body": "Analisi è il cuore: analisi dinamica e basata su prove invece di consigli generici — ogni affermazione punta ai tuoi dati reali. Nascosto: il layout è personalizzabile. Analisi raccoglie tutto ciò che forniscono i moduli sopra." + "body": "Analisi è il cuore: analisi dinamica e basata su prove invece di consigli generici, dove ogni affermazione punta ai tuoi dati reali. Nascosto: il layout è personalizzabile. Analisi raccoglie tutto ciò che forniscono i moduli sopra." }, "coach": { "title": "Coach", @@ -4807,7 +4858,7 @@ }, "achievements": { "title": "Traguardi", - "body": "I traguardi premiano la costanza — serie e tappe. Un piccolo ciclo di motivazione che rende più facile non mollare." + "body": "I traguardi premiano la costanza: serie e tappe. Un piccolo ciclo di motivazione che rende più facile non mollare." }, "wrapUp": { "title": "Questa è la visita guidata", @@ -4842,7 +4893,7 @@ "title": "Tutto pronto", "body": "Benvenuto a bordo. La tua dashboard è pronta.", "returnCta": "Apri la dashboard", - "learning": "Alcune analisi — glicemia, ritmo del sonno, tendenze della pressione, ciclo — si affinano nella prima settimana o due man mano che i tuoi dati si accumulano. Le caselle vuote ora indicano solo che stiamo ancora imparando il tuo riferimento.", + "learning": "Alcune analisi (glicemia, ritmo del sonno, tendenze della pressione, ciclo) si affinano nella prima settimana o due man mano che i tuoi dati si accumulano. Le caselle vuote ora indicano solo che stiamo ancora imparando il tuo riferimento.", "connectCta": "Collega un dispositivo", "logCta": "Registra la tua prima misurazione", "importCta": "Importa la cronologia esistente", @@ -4874,18 +4925,18 @@ "sampleTitle": "Briefing quotidiano", "sampleTag": "Esempio", "sampleAriaLabel": "Briefing quotidiano di esempio", - "sampleBody": "Peso in calo di 1,2 kg in due settimane. Battito a riposo stabile a 58 bpm. Pressione nel range in 6 giorni su 7 — l'unico valore alto è arrivato dopo una cena tardiva e salata.", - "sampleCaption": "Solo un esempio — non i tuoi dati. Ecco come si legge un briefing quando gli insight sono attivi.", + "sampleBody": "Peso in calo di 1,2 kg in due settimane. Battito a riposo stabile a 58 bpm. Pressione nel range in 6 giorni su 7: l'unico valore alto è arrivato dopo una cena tardiva e salata.", + "sampleCaption": "Solo un esempio, non i tuoi dati. Ecco come si legge un briefing quando gli insight sono attivi.", "ladderTitle": "Tre modi per attivarlo", - "ladderLocalTitle": "Modello locale — le richieste all’IA restano nella tua rete", - "ladderLocalBody": "Punta HealthLog su Ollama o LM Studio sulla tua macchina. Gratis e del tutto privato — la scelta tranquilla di default.", + "ladderLocalTitle": "Modello locale: le richieste all’IA restano nella tua rete", + "ladderLocalBody": "Punta HealthLog su Ollama o LM Studio sulla tua macchina. Gratis e del tutto privato: la scelta tranquilla di default.", "ladderByokTitle": "Usa la tua chiave", "ladderByokBody": "Usa la tua chiave OpenAI o Anthropic. Gira sul tuo account e sulla tua fatturazione, e il piano API tiene i tuoi dati fuori dall'addestramento in modo predefinito.", "ladderOauthTitle": "Accedi con un abbonamento", - "ladderOauthBody": "Collega un account ChatGPT — nessun costo per token, ma i termini del piano consumer possono usare i contenuti per l'addestramento, quindi è una scelta deliberata, mai l'impostazione predefinita per i tuoi dati sanitari.", - "keylessLine": "HealthLog è pienamente utile anche senza IA. Le tue schede, i grafici, i trend e le esportazioni funzionano tutti — l'IA aggiunge solo la lettura in linguaggio chiaro.", + "ladderOauthBody": "Collega un account ChatGPT. Nessun costo per token, ma i termini del piano consumer possono usare i contenuti per l'addestramento, quindi è una scelta deliberata, mai l'impostazione predefinita per i tuoi dati sanitari.", + "keylessLine": "HealthLog è pienamente utile anche senza IA. Le tue schede, i grafici, i trend e le esportazioni funzionano tutti; l'IA aggiunge solo la lettura in linguaggio chiaro.", "setupCta": "Configuralo (circa un minuto)", - "sharedKeyNote": "Questa istanza fornisce già un provider IA condiviso, quindi gli insight funzionano per te fin da subito — nessuna configurazione necessaria.", + "sharedKeyNote": "Questa istanza fornisce già un provider IA condiviso, quindi gli insight funzionano per te fin da subito, senza alcuna configurazione.", "offer": { "title": "Questa istanza può già scrivere il tuo riepilogo in parole semplici.", "body": "Il gestore mette a disposizione un fornitore che ha funzionato nelle ultime 24 ore. Con un tocco attivi le parole semplici; i tuoi dati andranno allora a quell’unico fornitore e a nessun altro.", @@ -5102,7 +5153,7 @@ "profileDescription": "Altezza, data di nascita e sesso sbloccano obiettivi personalizzati.", "profileCta": "Apri profilo", "measurementTitle": "Registra la prima misurazione", - "measurementDescription": "Una misurazione attiva il tile — il trend si disegna dalla 5ª misurazione.", + "measurementDescription": "Una misurazione attiva il tile; il trend si disegna dalla 5ª misurazione.", "measurementCta": "Aggiungi misurazione", "medicationTitle": "Aggiungi un farmaco", "medicationDescription": "Monitora l’aderenza e ricevi promemoria proprio quando ti servono.", @@ -5111,7 +5162,7 @@ "dataSourceDescription": "Sincronizza peso, pressione, sonno e altro da Withings, WHOOP, Oura, Polar, Nightscout, Fitbit o Apple Health.", "dataSourceCta": "Collega", "notificationsTitle": "Configura le notifiche", - "notificationsDescription": "Scegli come HealthLog ti ricorda — Telegram, ntfy o Web Push.", + "notificationsDescription": "Scegli come HealthLog ti ricorda: Telegram, ntfy o Web Push.", "notificationsCta": "Apri notifiche", "insightsTitle": "Attiva gli insight (facoltativo)", "insightsDescription": "Aggiungi un modello locale o la tua chiave IA per sbloccare il briefing quotidiano e Coach.", @@ -5259,12 +5310,10 @@ "sections": { "account": { "title": "Account", - "description": "Profilo, password, passkey e il tour di benvenuto.", "subtitle": "Il tuo profilo, la tua identità e i dati di accesso." }, "integrations": { "title": "Integrazioni", - "description": "Servizi collegati, canali di consegna e priorità delle fonti in un unico posto.", "subtitle": "Collega e gestisci le tue fonti di dati e i dispositivi.", "connectionsHeading": "Connessioni", "channelsHeading": "Canali di recapito", @@ -5273,16 +5322,14 @@ }, "notifications": { "title": "Notifiche", - "description": "Tutti i promemoria in un unico posto — cosa e quando, e dove vengono recapitati.", "subtitle": "Scegli quali promemoria ricevere e dove vengono recapitati.", "remindersHeading": "Promemoria", "matrixLink": "Notifiche per canale", "channelsLink": "Canali di recapito" }, "layout": { - "title": "Aspetto", - "description": "Scegli l'aspetto e la disposizione di dashboard, approfondimenti e dei tuoi moduli di monitoraggio.", - "backToHub": "Torna ad Aspetto", + "title": "Layout", + "backToHub": "Torna a Layout", "dashboard": { "title": "Cruscotto", "description": "Layout e ordine dei riquadri." @@ -5291,7 +5338,7 @@ "title": "Approfondimenti", "description": "Ordine della panoramica e delle schede di navigazione." }, - "subtitle": "Personalizza l'aspetto di dashboard, approfondimenti e moduli di monitoraggio.", + "subtitle": "Scegli cosa mostrano dashboard, approfondimenti e moduli di monitoraggio, e in quale ordine.", "medications": { "title": "Farmaci", "description": "Vista elenco e ordine manuale." @@ -5311,6 +5358,10 @@ "mood": { "title": "Tag dell’umore", "description": "Gruppi, tag e tag archiviati del selettore dell’umore." + }, + "documents": { + "title": "Documenti", + "description": "Visualizzazione e disposizione dei mesi." } }, "dashboard": { @@ -5318,15 +5369,13 @@ }, "thresholds": { "title": "Obiettivi", - "description": "Per metrica: il tuo intervallo target per valori sani.", "subtitle": "Imposta i tuoi intervalli target personali per metrica." }, "sources": { "title": "Priorità delle fonti", - "description": "Scegli quale fonte prevale quando più di una registra la stessa metrica lo stesso giorno.", "integrationsHint": "Collega o scollega le fonti dati nella pagina Integrazioni.", "integrationsHintLink": "Apri le integrazioni", - "cardTitle": "Priorità delle fonti per metrica", + "cardTitle": "Ordine per metrica", "help": "Se più fonti forniscono lo stesso valore nello stesso giorno, la fonte in cima conta come canonica.", "resetDefaults": "Ripristina i valori predefiniti", "resetDefaultsTitle": "Reimpostare l’ordine delle fonti?", @@ -5339,7 +5388,7 @@ "resetSuccess": "Priorità delle fonti ripristinata ai valori predefiniti", "perMetricToggle": "Personalizzazioni per metrica ({count})", "perMetricHelp": "Metriche che hai personalizzato rispetto all’ordine predefinito. Usa questo elenco per riportare una singola metrica al suo valore predefinito senza toccare le altre.", - "perMetricEmpty": "Nessuna personalizzazione per metrica — ogni metrica usa l’ordine globale qui sopra.", + "perMetricEmpty": "Nessuna personalizzazione per metrica: ogni metrica usa l’ordine globale qui sopra.", "resetMetric": "Reimposta", "deviceTypeToggle": "Priorità per tipo di dispositivo ({count})", "deviceTypeHelp": "All’interno di una fonte, quale dispositivo prevale. Quando un Apple Watch e un iPhone registrano entrambi i passi, il tipo di dispositivo più in alto conta come lettura canonica.", @@ -5364,7 +5413,8 @@ "respiratoryRate": "Frequenza respiratoria", "recovery": "Recupero", "stress": "Stress", - "sleepDebt": "Debito di sonno" + "sleepDebt": "Debito di sonno", + "hrvRmssd": "HRV (RMSSD)" }, "sourceLabels": { "WITHINGS": "Withings", @@ -5381,7 +5431,8 @@ "MCP": "MCP", "GOOGLE_HEALTH": "Google Health", "STRAVA": "Strava", - "EXTERNAL": "Dispositivo esterno" + "EXTERNAL": "Dispositivo esterno", + "HEALTH_CONNECT": "Health Connect" }, "deviceLabels": { "watch": "Orologio", @@ -5403,26 +5454,23 @@ "moduleHintLink": "Gestisci moduli", "detail": "Sola lettura: i valori appartengono all'app di origine." }, - "helpDetail": "Le altre restano memorizzate per la traccia di controllo." + "helpDetail": "Le altre restano memorizzate per la traccia di controllo. Sono elencate solo le fonti collegate o con dati.", + "noneInUse": "Ancora nessuna fonte da ordinare. Collegane una in Integrazioni o registra un valore." }, "ai": { "title": "Provider IA", - "description": "Provider, modello e chiave API per gli approfondimenti, oltre al tuo contesto «su di me».", "subtitle": "Scegli il tuo provider IA e usa la tua chiave." }, "api": { - "title": "API & Tokens", - "description": "Token Bearer per i tuoi script e le tue app — registra misurazioni o assunzioni di farmaci da ovunque.", + "title": "API e token", "subtitle": "Gestisci gli endpoint API e i token di accesso." }, "advanced": { "title": "Avanzate", - "description": "La via d'uscita: cancella per sempre tutti i dati di salute o chiudi l'account.", "subtitle": "Ripristino dati ed eliminazione account." }, "export": { "title": "Esporta e importa", - "description": "Scarica i tuoi dati come CSV o backup JSON completo, oppure importa da Apple Salute, JSON o CSV. Il referto medico ora si trova in Cartella sanitaria.", "otherOptionsHeading": "Altre opzioni di esportazione", "downloadFailed": "Impossibile scaricare l'esportazione. Riprova.", "actions": { @@ -5449,7 +5497,7 @@ "fullBackup": { "title": "Backup completo", "description": "L'intera cartella in un unico file JSON.", - "scopeNote": "I metadati dei documenti e il riepilogo IA, se presente, sono inclusi in chiaro; i file originali dei documenti e i dati di percorso GPS/campionamento degli allenamenti non lo sono — scaricali separatamente dall'archivio documenti o dall'esportazione degli allenamenti.", + "scopeNote": "I metadati dei documenti e il riepilogo IA, se presente, sono inclusi in chiaro; i file originali dei documenti e i dati di percorso GPS/campionamento degli allenamenti non lo sono. Scaricali separatamente dall'archivio documenti o dall'esportazione degli allenamenti.", "encryptToggle": "Cifra con una passphrase", "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.", @@ -5491,12 +5539,12 @@ "parsing": "Lettura dell'esportazione…", "upserting": "Importazione dei record…" }, - "skipUnsupported": "{count} campioni saltati — tipi di dati che HealthLog non importa.", + "skipUnsupported": "{count} campioni saltati: tipi di dati che HealthLog non importa.", "skipRefused": "{count} campioni non sono stati scritti (illeggibili, fuori intervallo o salvataggio non riuscito).", "skipBreakdown": "Mostra dettaglio ({count} tipi)", "skipBreakdownMore": "+ {count} altri tipi", "cycleSummary": "{samples} campioni del ciclo uniti in {days} registri giornalieri.", - "cycleModuleOff": "{count} campioni del ciclo saltati — il monitoraggio del ciclo è disattivato per questo account.", + "cycleModuleOff": "{count} campioni del ciclo saltati perché il monitoraggio del ciclo è disattivato per questo account.", "cycleDaysFailed": "{count} giorni del ciclo non sono stati scritti: {reason}" }, "json": { @@ -5581,8 +5629,8 @@ "failed": "L'importazione è fallita. Non è stato scritto nulla.", "columnsHeading": "Cosa fa ogni colonna", "jsonOnly": "solo JSON", - "previewLine": "Righe lette: {rows} · da importare: {queued} · rifiutate: {refused}", - "submittedLine": "Righe lette: {rows} · in coda per l'importazione: {queued}", + "previewLine": "{rows} righe lette, {queued} da importare, {refused} rifiutate", + "submittedLine": "{rows} righe lette, {queued} in coda per l'importazione", "unmatched": "Nessun farmaco registrato corrisponde a: {names}", "ambiguous": "Corrisponde a più di un farmaco: {names}", "mirrored": "Copiato da un'altra app, quindi non si può aggiungere alcuna dose: {names}", @@ -5618,18 +5666,59 @@ "tooManyRows": "Quel file contiene più righe di quante un'importazione possa accoglierne. Dividilo e importa le parti." }, "detail": "Un file copre tutti i farmaci che contiene." + }, + "healthConnect": { + "title": "Esportazione di Health Connect", + "description": "Importa il file ZIP che l’app Health Connect su Android crea quando esporti i tuoi dati.", + "choose": "Scegli file ZIP", + "uploading": "Caricamento…", + "uploadFailed": "Il caricamento non è riuscito. Riprova.", + "tooLarge": "Il file supera la dimensione accettata da questo server.", + "doneSummary": "{count} valori importati.", + "failed": "L’importazione non è riuscita: {reason}", + "phase": { + "queued": "In attesa di avvio", + "unpacking": "Estrazione del file", + "parsing": "Lettura dei record", + "upserting": "Salvataggio dei valori", + "done": "Completato", + "failed": "Non riuscito" + }, + "steps": { + "export": "Sul tuo telefono Android apri Health Connect, vai su Gestisci dati, poi Esporta e importa, e avvia un'esportazione.", + "cloud": "Health Connect salva l'esportazione solo in un'app di archiviazione cloud come Google Drive, Dropbox o Nextcloud, mai sul telefono stesso. Scegline una.", + "upload": "Scarica il file ZIP da quel cloud su questo dispositivo e caricalo qui." + }, + "dropLabel": "Trascina qui lo ZIP di Health Connect", + "dropHint": "oppure fai clic per sceglierlo. Fino a 1 GB.", + "fileInputLabel": "File ZIP dell'esportazione di Health Connect", + "privacyNote": "Il file viene eliminato dal server appena l'importazione termina. I dati delle app che colleghi già direttamente, come Withings o Fitbit, vengono esclusi perché nulla arrivi due volte. Caricare di nuovo lo stesso file non cambia nulla.", + "rateLimited": "Troppi caricamenti in poco tempo. Attendi un minuto e riprova.", + "busy": "Per il tuo account è già in corso un'importazione. Attendi che finisca.", + "rowsSoFar": "{count} valori salvati finora", + "doneNothing": "L'importazione è terminata, ma non c'era nulla di nuovo da aggiungere.", + "workouts": "Allenamenti", + "leftOutConnected": "{count} dati da app che colleghi già direttamente sono stati esclusi.", + "nutrientsModuleOff": "I totali di acqua e nutrienti non sono stati importati perché il modulo Nutrienti è disattivato.", + "cycleModuleOff": "I dati del ciclo mestruale non sono stati importati perché il monitoraggio del ciclo è disattivato.", + "failure": { + "unsupportedVersion": "Questa esportazione di Health Connect è troppo vecchia per essere letta. Aggiorna Health Connect sul telefono ed esporta di nuovo.", + "notHealthConnect": "Questo file non è un'esportazione di Health Connect. Scegli il file ZIP salvato da Health Connect.", + "unsafeSchema": "Questo file non ha la struttura di un'esportazione di Health Connect e non è stato letto.", + "tooLarge": "L'esportazione è più grande di quanto accetti questo server.", + "stagingMissing": "Il file caricato non era più sul server. Caricalo di nuovo.", + "interrupted": "Un riavvio del server ha interrotto l'importazione. Carica di nuovo il file." + } } }, "subtitle": "Esporta i tuoi dati o importa da un backup." }, "about": { "title": "Informazioni su questa app", - "description": "Versione, licenza, link.", "subtitle": "Versione, licenza e link al progetto." }, "sharing": { "title": "Condivisione", - "description": "Crea un link di sola lettura per condividere la tua cartella clinica con un medico.", "subtitle": "Condividi l'accesso in sola lettura ai tuoi dati." }, "insights": { @@ -5664,12 +5753,10 @@ }, "gesundheitsakte": { "title": "Cartella clinica", - "description": "Esporta la tua cartella clinica completa in PDF, FHIR R4 o in un pacchetto ZIP combinato.", "subtitle": "Esporta una cartella clinica completa, pronta per il medico." }, "coach": { "title": "Coach", - "description": "Attiva il Coach, tono e ambito, ricordi salvati.", "subtitle": "Regola il Coach, i suoi suggerimenti proattivi e la sua memoria." }, "security": { @@ -5678,8 +5765,7 @@ }, "privacy": { "title": "Dati e privacy", - "subtitle": "I tuoi dati, la conservazione, la cifratura e la sicurezza dell'account in un unico posto.", - "description": "Esportazione, eliminazione, conservazione, stato della cifratura, sessioni e attività di sicurezza." + "subtitle": "I tuoi dati, la conservazione, la cifratura e la sicurezza dell'account in un unico posto." }, "mcp": { "title": "Connettore MCP", @@ -5687,8 +5773,7 @@ }, "anamnesis": { "title": "Anamnesi", - "subtitle": "Gestisci le tue allergie e la tua anamnesi familiare.", - "description": "Record strutturati di allergie e anamnesi familiare." + "subtitle": "La tua anamnesi, lo stile di vita, i dati di emergenza e ciò che l’IA può leggere." }, "environment": { "title": "Posizione e meteo", @@ -5722,7 +5807,8 @@ "add": "Aggiungi {label}", "remove": "Rimuovi periodo di posizione", "removeConfirmTitle": "Rimuovere questo viaggio?", - "removeConfirmBody": "Le misurazioni di quel periodo restano nel tuo archivio; sparisce solo la voce del viaggio." + "removeConfirmBody": "Le misurazioni di quel periodo restano nel tuo archivio; sparisce solo la voce del viaggio.", + "range": "Dal {start} al {end}" }, "backfill": { "title": "Recupera i giorni passati", @@ -5732,7 +5818,21 @@ "noResults": "Nessun luogo corrispondente trovato.", "fetchFailedOne": "L’ultimo aggiornamento meteo in background non è riuscito ({when}).", "fetchFailedFew": "Gli ultimi {count} aggiornamenti meteo in background non sono riusciti, il più recente {when}.", - "fetchFailedOther": "Gli ultimi {count} aggiornamenti meteo in background non sono riusciti, il più recente {when}." + "fetchFailedOther": "Gli ultimi {count} aggiornamenti meteo in background non sono riusciti, il più recente {when}.", + "airQuality": { + "title": "Qualità dell’aria e pollini", + "description": "Qualità dell’aria, pollini e UV giornalieri per la tua località, dal modello CAMS tramite Open-Meteo.", + "toggle": "Includi qualità dell’aria e pollini", + "operatorDisabled": "La qualità dell’aria è disattivata su questo server.", + "coverage": "{count} giorni con dati sulla qualità dell’aria", + "pollenEuropeOnly": "I dati sui pollini coprono solo l’Europa.", + "historyProgress": "Recupero della qualità dell’aria: {done} di {total} giorni", + "historyComplete": "La qualità dell’aria è completata per tutti i giorni passati con registrazioni coperti dalla fonte ({total} giorni). I giorni più vecchi restano vuoti.", + "attribution": "Fonti dei dati e licenze", + "saveError": "Impossibile salvare l’impostazione.", + "latest": "Giorno più recente ({date})", + "modelNote": "Valori esterni modellati per la tua zona, non la tua esposizione personale." + } }, "score": { "title": "Punteggio di salute", @@ -5859,7 +5959,6 @@ }, "changePassword": "Cambia password", "changePasswordDescription": "Sostituisci la password attuale con una nuova.", - "passwordReset": "Reimposta password", "currentPassword": "Password attuale", "newPassword": "Nuova password", "confirmNewPassword": "Conferma nuova password", @@ -5904,9 +6003,9 @@ "botToken": "Token del bot", "chatId": "ID chat", "enableNotifications": "Attiva le notifiche", - "telegramStep1": "1. Crea un bot tramite @BotFather su Telegram e copia il token.", - "telegramStep2": "2. Invia /start al tuo bot per attivare la chat.", - "telegramStep3": "3. Trova il tuo ID chat tramite @userinfobot o l’API del bot.", + "telegramStep1": "Crea un bot tramite @BotFather su Telegram e copia il token.", + "telegramStep2": "Invia /start al tuo bot per attivare la chat.", + "telegramStep3": "Trova il tuo ID chat tramite @userinfobot o l’API del bot.", "ntfy": "ntfy", "ntfyDescription": "Notifiche tramite ntfy (self-hosted o ntfy.sh).", "ntfyEnable": "Attiva ntfy", @@ -5915,7 +6014,7 @@ "ntfyAuthToken": "Token di autenticazione (facoltativo)", "ntfyAuthTokenHint": "Necessario solo per topic privati con controllo degli accessi.", "webhook": "Webhook", - "webhookDescription": "Invia le notifiche via POST a qualsiasi URL — Gotify, Discord, Slack, Matrix o Home Assistant.", + "webhookDescription": "Invia le notifiche via POST a un URL: Discord, Slack, Gotify, Home Assistant, n8n o un relay che accetti JSON.", "webhookEnable": "Attiva webhook", "webhookUrl": "URL del webhook", "webhookUrlHint": "L’URL completo per il POST. Un indirizzo su una rete privata deve essere autorizzato dall’amministratore del server.", @@ -5923,6 +6022,7 @@ "webhookHeaderValue": "Valore intestazione (opzionale)", "webhookFormat": "Formato del messaggio", "webhookFormatGeneric": "JSON generico", + "webhookFormatGenericHint": "Incolla così com’è un URL webhook di Discord o Slack. Home Assistant e n8n leggono i campi title, message, eventType e priority.", "webhookFormatGotify": "Gotify", "webhookFormatGotifyHint": "Usa l’URL che termina con /message e inserisci il token dell’applicazione nell’intestazione X-Gotify-Key.", "email": "Email", @@ -5939,7 +6039,6 @@ "webPushSubscribed": "Notifiche push attivate!", "webPushUnsubscribed": "Notifiche push disattivate.", "webPushSubscribeFailed": "Attivazione non riuscita", - "kiInsightsDescription": "Facoltativo: salva la tua chiave OpenAI per analisi giornaliere e automatiche in Insights.", "codexConnected": "ChatGPT connesso correttamente! Insights è ora attivo.", "codexDisconnected": "Connessione a ChatGPT rimossa.", "codexConnectionFailed": "Connessione a ChatGPT non riuscita. Riprova.", @@ -5949,7 +6048,7 @@ "rawDataWarning": "Cronologia dettagliata attiva: ogni analisi invia anche le medie giornaliere degli ultimi 90 giorni, le medie settimanali dell’anno precedente e le medie mensili fino a 5 anni fa.", "regenerateInsights": "Rigenera i report", "regenerateSuccess": "Report rigenerati correttamente", - "regenerateRateLimit": "Attendi — hai raggiunto il limite orario di analisi.", + "regenerateRateLimit": "Attendi: hai raggiunto il limite orario di analisi.", "lastGeneratedAt": "Ultima generazione", "ai": { "chatgptConnectedBadge": "ChatGPT connesso", @@ -5959,11 +6058,11 @@ "deviceCodeHeading": "Completa la connessione su chatgpt.com", "deviceCodeStep1": "Apri questo link su qualsiasi dispositivo:", "deviceCodeStep2": "Inserisci questo codice monouso:", - "deviceCodeStep3": "Approva la connessione — questa pagina si aggiorna automaticamente.", + "deviceCodeStep3": "Approva la connessione. Questa pagina si aggiorna automaticamente.", "deviceCodeCopy": "Copia", "deviceCodeWaiting": "In attesa di approvazione…", "deviceCodeCancel": "Annulla", - "oauthNotConfigured": "L’OAuth di ChatGPT non è configurato su questa istanza — usa invece la tua chiave API qui sotto.", + "oauthNotConfigured": "L’OAuth di ChatGPT non è configurato su questa istanza. Usa invece la tua chiave API qui sotto.", "modelLabel": "Modello", "modelOptionDefault": "— Predefinito —", "modelOptionCustom": "Personalizzato…", @@ -5987,7 +6086,7 @@ "admin-openai": "OpenAI amministratore" }, "title": "Catena di fallback", - "description": "Se il provider principale fallisce, HealthLog percorre la catena in ordine. Trascina le righe per riordinarle; usa l’interruttore per disattivare un provider senza rimuoverlo.", + "description": "Se il fornitore attivo non risponde, HealthLog prova il successivo attivo in quest’ordine.", "moveUp": "Sposta su", "moveDown": "Sposta giù", "removeFromChain": "Rimuovi dalla catena", @@ -6000,11 +6099,8 @@ "resetConfirmTitle": "Ripristinare la catena ai valori predefiniti?", "resetConfirmBody": "La catena torna a Codex → OpenAI → Anthropic → Locale → OpenAI amministratore. Le credenziali salvate non vengono toccate." }, - "providerCardTitle": "Provider IA", "activeProviderHeading": "Provider attivo", - "activeProviderBody": "Scegli il provider da usare per primo. Il modulo qui sotto configura solo il provider selezionato; la catena di fallback in fondo decide cosa accade se fallisce.", "activeProviderLabel": "Provider principale", - "providerConfigTitle": "Configurazione del provider", "providerSelect": { "codex": "Account ChatGPT (Codex)", "openai": "OpenAI (la tua chiave API)", @@ -6050,18 +6146,18 @@ "notConfigured": "L’operatore non ha configurato una chiave OpenAI condivisa su questa istanza." }, "testProvider": "Prova il provider attivo", - "testSuccess": "OK — {provider} ({model})", + "testSuccess": "OK: {provider} ({model})", "testFailedShort": "Prova non riuscita: {message}", - "testUnexpectedResponse": "Connessione al provider IA non riuscita — risposta inattesa dal server.", - "testReasonCredentials": "Il provider ha rifiutato le credenziali — autenticati di nuovo nelle impostazioni IA.", - "testReasonRateLimited": "Il provider ha limitato la richiesta — riprova a breve.", + "testUnexpectedResponse": "Connessione al provider IA non riuscita: risposta inattesa dal server.", + "testReasonCredentials": "Il provider ha rifiutato le credenziali. Autenticati di nuovo nelle impostazioni IA.", + "testReasonRateLimited": "Il provider ha limitato la richiesta. Riprova a breve.", "testReasonServerError": "Il provider IA ha restituito un errore del server.", "testReasonBadRequest": "Il provider ha rifiutato la richiesta (HTTP {status}): l'endpoint ha risposto, quindi il problema riguarda il formato della richiesta o il nome del modello, non la connessione.", "testReasonUnreachable": "Impossibile raggiungere il provider IA.", "coachMemory": { "title": "Ciò che il Coach ricorda", "description": "Informazioni durature che hai comunicato al Coach.", - "empty": "Il Coach non ha ancora memorizzato alcuna informazione. Mentre chatti, ricorda i dettagli duraturi che condividi — i tuoi obiettivi, le condizioni, le preferenze — e puoi cancellarli qui in qualsiasi momento.", + "empty": "Il Coach non ha ancora memorizzato alcuna informazione. Mentre chatti, ricorda i dettagli duraturi che condividi (i tuoi obiettivi, le condizioni, le preferenze) e puoi cancellarli qui in qualsiasi momento.", "learnedPrefix": "appreso {when}", "categoryPreference": "Preferenze", "categoryCondition": "Condizioni", @@ -6077,7 +6173,7 @@ "forgetAllConfirmBody": "Questo rimuove tutte le informazioni memorizzate. Il Coach ripartirà da zero alla prossima chat. L'operazione non può essere annullata.", "forgetAllConfirmAction": "Dimentica tutto", "cancel": "Annulla", - "summaryNote": "Il Coach può anche conservare un breve riassunto progressivo di ogni conversazione. Non esiste un controllo per ogni voce — eliminando una conversazione si elimina anche il suo riassunto.", + "summaryNote": "Il Coach può anche conservare un breve riassunto progressivo di ogni conversazione. Non esiste un controllo per ogni voce: eliminando una conversazione si elimina anche il suo riassunto.", "loadError": "Impossibile caricare ciò che il Coach ricorda.", "forgotToast": "Informazione dimenticata.", "forgotError": "Impossibile dimenticare questa informazione. Riprova.", @@ -6098,7 +6194,7 @@ "markDone": "Fatto", "dismiss": "Ignora", "deleteAria": "Elimina questo promemoria", - "note": "I promemoria sono memorizzati cifrati. Eliminane uno in qualsiasi momento — viene rimosso da ciò che il Coach può vedere.", + "note": "I promemoria sono memorizzati cifrati. Eliminane uno in qualsiasi momento e viene rimosso da ciò che il Coach può vedere.", "status": { "proposed": "Suggerito", "active": "Salvato", @@ -6145,19 +6241,19 @@ "title": "Leggi i documenti automaticamente con l'IA", "subLabel": "I documenti vengono letti e indicizzati dal provider di IA configurato, senza confermare ogni documento.", "honesty": "Quando è attivo, ogni documento che carichi viene inviato al provider di IA configurato per essere letto, descritto e indicizzato per la ricerca, senza conferma per singolo documento. Attivarlo agisce anche retroattivamente: i documenti che hai già caricato e che non hanno ancora un riassunto vengono inviati anch'essi, fino a 200 per ogni attivazione. Questo avviene sul server e il contenuto del documento lascia questa macchina verso quel provider. Se il provider configurato è una connessione in abbonamento (un account IA con accesso effettuato anziché una chiave API), si applicano le sue impostazioni sui dati: i provider in abbonamento possono usare il contenuto per migliorare i propri modelli e nessun accordo sul trattamento dei dati lo copre. I documenti letti da un modello locale self-hosted non lasciano mai il tuo server. Disattivato per impostazione predefinita; attivalo solo se accetti questo compromesso per i tuoi documenti.", - "confirm": "Ho capito — attiva", + "confirm": "Ho capito, attiva", "cancel": "Annulla" }, "centralCodex": { "title": "Usa l'accesso IA condiviso del server", - "subLabel": "Instrada le richieste IA attraverso la connessione che l'operatore ha configurato per questo server, insieme ai tuoi provider come ripiego.", + "subLabel": "Le richieste IA passano dalla connessione condivisa del server, con i tuoi provider come ripiego.", "honesty": "L'accesso condiviso è un unico account IA con accesso eseguito che l'operatore ha collegato per tutti su questo server, non la tua chiave. Le richieste che vi effettui vengono usate per impostazione predefinita per addestrare i modelli del provider e nessun accordo sul trattamento dei dati le copre, i limiti orari e settimanali dell'account sono condivisi con tutti gli altri che hanno aderito, e i tuoi provider vengono comunque provati per primi. Attivalo solo se accetti questo compromesso.", - "confirm": "Ho capito — usa la connessione", + "confirm": "Ho capito, usa la connessione", "cancel": "Annulla" }, "consent": { "title": "Consenso IA", - "activeSince": "Concesso il {date}. I tuoi dati sanitari possono essere inviati al provider configurato per le funzioni IA.", + "activeSince": "Concesso il {date}: i tuoi dati sanitari possono essere inviati al provider configurato per le funzioni IA.", "withdrawn": "Revocato. Salvo che un altro consenso lo copra, il Coach e Insights non usano più l'accesso IA fornito da questo server e nessun modello online legge i tuoi documenti. Con la tua chiave o un modello locale, il Coach e Insights continuano a funzionare.", "withdraw": "Revoca il consenso", "confirmBody": "Salvo che un altro consenso li copra ancora, si fermano subito: il Coach e Insights quando usano l'accesso IA fornito da questo server, e la lettura dei documenti con un modello online. Con la tua chiave o un modello locale, il Coach e Insights continuano a funzionare. Salvo che un altro consenso copra ancora Insights, le note che un modello ha scritto per te vengono eliminate: note di stato, il riepilogo giornaliero, i riepiloghi di periodo scritti da un modello, le righe di arrivo e le note sugli allenamenti. Le tue conversazioni con il Coach, ciò che il Coach ricordava, i tuoi piani e i riepiloghi dei tuoi documenti restano, e puoi leggerli o eliminarli tu stesso.", @@ -6168,7 +6264,7 @@ "error": "Non è stato possibile salvare la revoca. Riprova.", "documentReading": { "title": "Consenso alla lettura dei documenti", - "activeSince": "Dato il {date}. Documenti, referti di laboratorio scansionati e descrizioni di farmaci possono essere inviati al tuo servizio di IA per la lettura.", + "activeSince": "Dato il {date}: documenti, referti scansionati e testi dei farmaci possono andare al tuo servizio di IA per la lettura.", "confirmBody": "Documenti, referti di laboratorio scansionati e descrizioni di farmaci non vengono più inviati per la lettura. Nulla di ciò che hai già viene eliminato." } }, @@ -6202,7 +6298,9 @@ "high": "Alto" }, "coachNote": "Nel Coach vale la profondità di ragionamento delle impostazioni del Coach." - } + }, + "providerCardDescription": "Il fornitore che HealthLog interpella per primo, con chiave e modello.", + "runtimeTitle": "Verifica e aggiorna" }, "dashboard": { "units": { @@ -6226,8 +6324,8 @@ "withingsClientId": "ID client", "withingsClientSecret": "Secret client", "withingsCredentialsSaved": "Credenziali salvate", - "withingsCredentialsSavedPlaceholder": "Salvato — inseriscine uno nuovo per sostituirlo", - "withingsCredentialsSavedPlaceholderSecret": "Salvato — inseriscine uno nuovo per sostituirlo", + "withingsCredentialsSavedPlaceholder": "Salvato. Inseriscine uno nuovo per sostituirlo", + "withingsCredentialsSavedPlaceholderSecret": "Salvato. Inseriscine uno nuovo per sostituirlo", "withingsSaveCredentials": "Salva", "configured": "Configurato", "withingsSync": "Sincronizza ora", @@ -6242,7 +6340,7 @@ }, "withingsFullSyncResult": "{count} misurazioni sincronizzate completamente", "withingsSyncFailed": "Sincronizzazione non riuscita", - "withingsOauthConnected": "Withings collegato — la prima sincronizzazione parte in background.", + "withingsOauthConnected": "Withings collegato: la prima sincronizzazione parte in background.", "withingsOauthFailed": "Collegamento a Withings non riuscito", "withingsOauthError": { "csrf1": "Il controllo di sicurezza non è riuscito perché mancava il cookie del browser. Avvia di nuovo il collegamento nello stesso browser.", @@ -6267,8 +6365,8 @@ "whoopClientId": "Client ID", "whoopClientSecret": "Client Secret", "whoopCredentialsSaved": "Credenziali salvate", - "whoopCredentialsSavedPlaceholder": "Salvato — inserisci nuove per sostituire", - "whoopCredentialsSavedPlaceholderSecret": "Salvato — inserisci nuove per sostituire", + "whoopCredentialsSavedPlaceholder": "Salvato. Inserisci nuove per sostituire", + "whoopCredentialsSavedPlaceholderSecret": "Salvato. Inserisci nuove per sostituire", "whoopSaveCredentials": "Salva", "whoopSync": "Sincronizza ora", "whoopFullSync": "Sincronizza tutti i dati", @@ -6283,7 +6381,7 @@ "whoopConnect": "Connetti con WHOOP", "whoopNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere WHOOP.", "whoopBackfillInProgress": "Importazione dello storico WHOOP in background…", - "whoopOauthConnected": "WHOOP collegato — la prima sincronizzazione parte in background.", + "whoopOauthConnected": "WHOOP collegato: la prima sincronizzazione parte in background.", "whoopOauthFailed": "Connessione a WHOOP non riuscita", "whoopOauthError": { "csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.", @@ -6297,14 +6395,14 @@ }, "fitbit": "Fitbit", "fitbitTag": "Fitbit e Pixel", - "fitbitDescription": "Collega Fitbit per sincronizzare i dati di Fitbit e Pixel Watch — peso, frequenza cardiaca e altro.", + "fitbitDescription": "Collega Fitbit per sincronizzare i dati di Fitbit e Pixel Watch: peso, frequenza cardiaca e altro.", "fitbitExperimentalBadge": "Sperimentale", "fitbitCredentials": "Credenziali API", "fitbitClientId": "Client ID", "fitbitClientSecret": "Client Secret", "fitbitCredentialsSaved": "Credenziali salvate", - "fitbitCredentialsSavedPlaceholder": "Salvato — inserisci nuove per sostituire", - "fitbitCredentialsSavedPlaceholderSecret": "Salvato — inserisci nuove per sostituire", + "fitbitCredentialsSavedPlaceholder": "Salvato. Inserisci nuove per sostituire", + "fitbitCredentialsSavedPlaceholderSecret": "Salvato. Inserisci nuove per sostituire", "fitbitSaveCredentials": "Salva", "fitbitSync": "Sincronizza ora", "fitbitFullSync": "Sincronizza tutti i dati", @@ -6319,18 +6417,18 @@ "fitbitConnect": "Connetti con Fitbit", "fitbitNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Fitbit.", "fitbitBackfillInProgress": "Importazione dello storico Fitbit in background…", - "fitbitOauthConnected": "Fitbit collegato — la prima sincronizzazione parte in background.", + "fitbitOauthConnected": "Fitbit collegato: la prima sincronizzazione parte in background.", "fitbitOauthFailed": "Connessione a Fitbit non riuscita", "googleHealth": "Google Health", "googleHealthTag": "Fitbit e Pixel", "googleHealthBetaBadge": "Beta", - "googleHealthDescription": "Collega Fitbit, Pixel Watch e Fitbit Air tramite il tuo account Google — peso, frequenza cardiaca, sonno e attività. Sostituisce la connessione Fitbit classica, che Google ritira a settembre 2026.", + "googleHealthDescription": "Collega Fitbit, Pixel Watch e Fitbit Air tramite il tuo account Google: peso, frequenza cardiaca, sonno e attività. Sostituisce la connessione Fitbit classica, che Google ritira a settembre 2026.", "googleHealthCredentials": "Credenziali API", "googleHealthClientId": "ID client", "googleHealthClientSecret": "Client secret", "googleHealthCredentialsSaved": "Credenziali salvate", - "googleHealthCredentialsSavedPlaceholder": "Salvate — inseriscine di nuove per sostituirle", - "googleHealthCredentialsSavedPlaceholderSecret": "Salvate — inseriscine di nuove per sostituirle", + "googleHealthCredentialsSavedPlaceholder": "Salvate. Inseriscine di nuove per sostituirle", + "googleHealthCredentialsSavedPlaceholderSecret": "Salvate. Inseriscine di nuove per sostituirle", "googleHealthSaveCredentials": "Salva", "googleHealthSync": "Sincronizza ora", "googleHealthSyncCheckStatus": "Aggiorna stato", @@ -6364,9 +6462,9 @@ "googleHealthConnect": "Connetti con Google", "googleHealthNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Google Health.", "googleHealthBackfillInProgress": "Importazione della cronologia di Google Health in background…", - "googleHealthReauthBanner": "Google ha fatto scadere la connessione — accade periodicamente finché la tua app OAuth resta in modalità Test. Riconnettiti per riprendere la sincronizzazione.", + "googleHealthReauthBanner": "Google ha fatto scadere la connessione. Accade periodicamente finché la tua app OAuth resta in modalità Test. Riconnettiti per riprendere la sincronizzazione.", "googleHealthReconnect": "Riconnetti", - "googleHealthOauthConnected": "Google Health connesso — la prima sincronizzazione parte in background.", + "googleHealthOauthConnected": "Google Health connesso: la prima sincronizzazione parte in background.", "googleHealthOauthFailed": "Connessione a Google Health non riuscita", "googleHealthOauthError": { "csrf1": "Il controllo di sicurezza non è riuscito perché mancava il cookie del browser. Riavvia la connessione nello stesso browser.", @@ -6401,10 +6499,10 @@ }, "integrationPill": { "connected": "Connesso", - "errorReconnect": "Errore — riconnetti", + "errorReconnect": "Errore, riconnetti", "warningServerError": "Sincronizzazione in errore", "failureCount": "{count}/{threshold}", - "parkedReconnect": "In pausa — riconnetti", + "parkedReconnect": "In pausa, riconnetti", "notConnected": "Non connesso", "justNow": "proprio ora", "minutesAgo": "{count} min fa", @@ -6419,7 +6517,7 @@ }, "integrationFreshness": { "title": "Dati ricevuti", - "summary": "{count} tipi di dati · più recente {relative}", + "summary": "{count} tipi di dati, più recente {relative}", "quiet": "{count} in silenzio", "workouts": "Allenamenti" }, @@ -6433,31 +6531,33 @@ "fitbitViewData": "Vedi il tuo sonno", "googleHealthViewData": "Vedi i tuoi passi", "apiTokens": "Token API", - "apiTokensDescription": "Rivedi e revoca i token API per la registrazione esterna delle assunzioni di farmaci.", + "apiTokensDescription": "Rivedi e revoca i token per misurazioni, allenamenti, documenti e assunzioni di farmaci.", "measurementsToken": { "title": "Token per misurazioni", "description": "Crea un token perché una bilancia, un orologio o la tua domotica aggiungano misurazioni.", "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." + "createFailed": "Non è stato possibile creare il token. Riprova.", + "namePlaceholder": "Nome, ad es. Home Assistant" }, "workoutsToken": { "title": "Token per allenamenti", "description": "Crea un token perché un ponte dell'orologio o uno script di sincronizzazione aggiungano i tuoi allenamenti.", "detail": "Il token viene mostrato una sola volta.", "scopeNote": "Può soltanto aggiungere nuovi allenamenti alla tua cartella. Non può leggere i tuoi dati, modificare o eliminare nulla, né creare un altro token. Gli allenamenti che aggiunge vengono contrassegnati come provenienti da un dispositivo esterno e, se il telefono ha registrato la stessa sessione, vedi la copia del telefono.", - "createFailed": "Non è stato possibile creare il token. Riprova." + "createFailed": "Non è stato possibile creare il token. Riprova.", + "namePlaceholder": "Nome, ad es. Garmin" }, "documentsToken": { "title": "Token per documenti", "description": "Crea un token perché Paperless-ngx, Papra o lo script di importazione aggiungano documenti al tuo archivio.", "detail": "Il token viene mostrato una sola volta.", "scopeNote": "Può soltanto aggiungere nuovi documenti al tuo archivio. Non può elencare, aprire, scaricare, modificare o eliminare nulla, né creare un altro token. Riceve solo l'informazione se un file è stato salvato, non come l'hai archiviato.", - "createFailed": "Non è stato possibile creare il token. Riprova." + "createFailed": "Non è stato possibile creare il token. Riprova.", + "namePlaceholder": "Nome, ad es. Paperless-ngx" }, - "tokenMintMovedDescription": "I token vengono emessi dove si applicano: qui sopra per le misurazioni e i documenti, oppure nell'endpoint API di un farmaco per quel singolo farmaco.", - "tokenNamePlaceholder": "Nome del token (ad es. Comando rapido iPhone)", - "tokenCreated": "Token creato — copialo ora! Non verrà mostrato di nuovo.", + "tokenMintMovedDescription": "I token vengono emessi dove si applicano: qui sopra per le misurazioni, gli allenamenti e i documenti, oppure nell'endpoint API di un farmaco per quel singolo farmaco.", + "tokenCreated": "Token creato: copialo ora! Non verrà mostrato di nuovo.", "tokenCopy": "Copia token", "tokenCopied": "Token copiato negli appunti", "tokenCopyFailed": "Impossibile copiare il token", @@ -6496,7 +6596,7 @@ "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.", - "deleteAccountSuccess": "Account eliminato — disconnessione in corso.", + "deleteAccountSuccess": "Account eliminato, disconnessione in corso.", "deleteAccountFailed": "Impossibile eliminare l’account", "deleteAccountFinal": "Elimina definitivamente", "saved": "Salvato", @@ -6519,7 +6619,7 @@ "testing": "Test in corso…", "ok": "Connesso (latenza {latency} ms)", "errors": { - "credentials_rejected": "Credenziali rifiutate — controlla il token", + "credentials_rejected": "Credenziali rifiutate: controlla il token", "rate_limited": "Limitato dal servizio upstream", "timeout": "Richiesta scaduta", "upstream_error": "Il servizio upstream ha restituito un errore", @@ -6530,7 +6630,7 @@ "private_origin_not_approved": "Destinazione su una rete privata. L’operatore deve aggiungere questa origine a NOTIFICATION_PRIVATE_ORIGINS", "private_origin_not_grantable": "Questo indirizzo è link-local, dei metadati o non specificato; nessuna autorizzazione dell’operatore può aprirlo. Usa l’indirizzo su cui il relay è in ascolto", "url_invalid": "L’URL non è valido", - "redirected": "L’endpoint reindirizza — controlla l’URL", + "redirected": "L’endpoint reindirizza: controlla l’URL", "endpoint_not_found": "Endpoint non trovato all’URL", "credentials_unreadable": "Impossibile decifrare le credenziali", "upstream_invalid_json": "Il servizio upstream ha inviato un JSON non valido", @@ -6541,7 +6641,7 @@ }, "notificationStatus": { "title": "Affidabilità dei canali", - "description": "Stato di ogni canale — «Disattivato automaticamente» significa fermato dopo errori permanenti.", + "description": "Stato di ogni canale; «Disattivato automaticamente» significa fermato dopo errori permanenti.", "emptyDescription": "Aggiungi un canale in Impostazioni › Integrazioni › Canali di recapito.", "stateActive": "Attivo", "stateAutoDisabled": "Disattivato automaticamente", @@ -6567,7 +6667,7 @@ }, "healthRecord": { "title": "Esporta cartella clinica", - "description": "Una cartella completa per il medico — in PDF, in FHIR o entrambi.", + "description": "Una cartella completa per il medico, in PDF, in FHIR o entrambi.", "format": "Formato", "format_pdf": "PDF", "format_fhir": "Strutturato (FHIR)", @@ -6599,7 +6699,7 @@ "createTitle": "Nuovo link di condivisione", "createDescription": "Il link è di sola lettura e a tempo.", "label": "Etichetta", - "labelPlaceholder": "es. Dott. Rossi — controllo annuale", + "labelPlaceholder": "es. Dott. Rossi, controllo annuale", "range": "Giorni di cronologia", "rangeHint": "Fin dove si estende la finestra condivisa.", "expiry": "Scade tra (giorni)", @@ -6619,7 +6719,7 @@ "statusRevoked": "Revocato", "statusExpired": "Scaduto", "inactiveTitle": "Revocati e scaduti ({count})", - "shownOnce": "Mostrato una sola volta. Salva ora il codice QR e la passphrase — non potranno essere recuperati in seguito.", + "shownOnce": "Mostrato una sola volta. Salva ora il codice QR e la passphrase: non potranno essere recuperati in seguito.", "qrAlt": "Codice QR contenente il link di condivisione e la passphrase", "linkLabel": "Link", "passphraseLabel": "Passphrase", @@ -6648,7 +6748,8 @@ "statusNeedsReselect": "Scegli di nuovo il contenuto", "reselectReason": "Questo link mostrava un ambito che nessuno aveva scelto, perciò è stato chiuso. Creane uno nuovo e scegli cosa deve contenere.", "reselectAction": "Crea un sostituto", - "createDetail": "Il token viene mostrato una sola volta alla creazione — salvalo subito; non può essere recuperato." + "createDetail": "Il token viene mostrato una sola volta alla creazione: salvalo subito, non può essere recuperato.", + "activeDescription": "I link che hai condiviso, quando scadono e quante volte sono stati aperti." }, "genderOther": "Altro", "nightscout": "Nightscout", @@ -6656,10 +6757,10 @@ "nightscoutDescription": "Collega il tuo sito Nightscout per sincronizzare le letture continue del glucosio nel pannello glucosio.", "nightscoutViewData": "Vedi la tua glicemia", "nightscoutUrl": "URL Nightscout", - "nightscoutUrlSavedPlaceholder": "Salvato — inserisci un nuovo URL per sostituirlo", + "nightscoutUrlSavedPlaceholder": "Salvato. Inserisci un nuovo URL per sostituirlo", "nightscoutToken": "Token API", "nightscoutTokenOptional": "Facoltativo per un sito pubblico", - "nightscoutTokenSavedPlaceholder": "Salvato — inserisci un nuovo token per sostituirlo", + "nightscoutTokenSavedPlaceholder": "Salvato. Inserisci un nuovo token per sostituirlo", "nightscoutTokenHelp": "Incolla un token di accesso dagli strumenti di amministrazione di Nightscout. Lascia vuoto se il tuo sito è leggibile pubblicamente.", "nightscoutPrivateOperatorTitle": "L’accesso a un Nightscout privato richiede l’approvazione dell’amministratore del server.", "nightscoutPrivateOperatorDescription": "Chiedi all’amministratore del server di approvare esattamente questa origine Nightscout.", @@ -6680,7 +6781,7 @@ "polarClientId": "Client ID", "polarClientSecret": "Client Secret", "polarCredentialsSaved": "Credenziali salvate", - "polarCredentialsSavedPlaceholder": "Salvato — inserisci nuove per sostituire", + "polarCredentialsSavedPlaceholder": "Salvato. Inserisci nuove per sostituire", "polarSaveCredentials": "Salva", "polarUnavailable": "Polar non è configurato su questo server. Aggiungi sopra le tue credenziali OAuth di Polar oppure chiedi al tuo amministratore di configurare l'app condivisa.", "polarConnect": "Collega Polar", @@ -6690,7 +6791,7 @@ "polarDisconnect": "Disconnetti", "polarDisconnectTitle": "Disconnettere Polar?", "polarDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture già importate restano nella tua cronologia.", - "polarOauthConnected": "Polar collegato — la prima sincronizzazione parte in background.", + "polarOauthConnected": "Polar collegato: la prima sincronizzazione parte in background.", "polarOauthFailed": "Connessione a Polar non riuscita", "polarOauthError": { "csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.", @@ -6709,7 +6810,7 @@ "ouraClientId": "Client ID", "ouraClientSecret": "Client Secret", "ouraCredentialsSaved": "Credenziali salvate", - "ouraCredentialsSavedPlaceholder": "Salvato — inserisci nuove per sostituire", + "ouraCredentialsSavedPlaceholder": "Salvato. Inserisci nuove per sostituire", "ouraSaveCredentials": "Salva", "ouraUnavailable": "Oura non è configurato su questo server. Aggiungi sopra le tue credenziali OAuth di Oura oppure chiedi al tuo amministratore di configurare l'app condivisa.", "ouraConnect": "Collega Oura", @@ -6719,7 +6820,7 @@ "ouraDisconnect": "Disconnetti", "ouraDisconnectTitle": "Disconnettere Oura?", "ouraDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture già importate restano nella tua cronologia.", - "ouraOauthConnected": "Oura collegato — la prima sincronizzazione parte in background.", + "ouraOauthConnected": "Oura collegato: la prima sincronizzazione parte in background.", "ouraOauthFailed": "Connessione a Oura non riuscita", "ouraOauthError": { "csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.", @@ -6750,7 +6851,7 @@ } }, "integrationRedirectGuide": { - "intro": "Questa connessione passa dalla tua app sviluppatore {provider} — circa 5 minuti di configurazione:", + "intro": "Questa connessione passa dalla tua app sviluppatore {provider} (circa 5 minuti di configurazione):", "step1": "Crea un'app nel portale sviluppatori {provider}.", "step2": "Imposta il suo URL di reindirizzamento/callback su:", "step3": "Incolla qui sopra l'ID client e il client secret ricevuti." @@ -6874,12 +6975,11 @@ }, "stored": { "title": "Cosa viene memorizzato", - "measurements": "Misurazioni (peso, pressione, polso, glicemia, sonno, composizione corporea)", - "medications": "Farmaci e cronologia delle assunzioni", - "moodLabs": "Voci dell'umore e referti di laboratorio", - "coach": "Conversazioni con il coach", + "measurements": "Misurazioni: peso, pressione, battito e ogni altro valore registrato da te o da un dispositivo", "integrations": "Token di integrazione (cifrati)", - "security": "Attività dell'account e di sicurezza (sessioni, registro degli accessi)" + "security": "Attività dell'account e di sicurezza (sessioni, registro degli accessi)", + "profile": "Il tuo profilo, l'anamnesi e i dati della cartella sanitaria", + "modules": "Dati di ogni modulo: {list}" }, "retention": { "title": "Conservazione", @@ -6910,8 +7010,6 @@ "mcp": { "enableTitle": "Endpoint MCP remoto", "enableDescription": "Consenti a un assistente compatibile con MCP (Claude.ai, ChatGPT) di leggere i tuoi dati sulla salute tramite un token limitato.", - "enabled": "Attivo", - "disabled": "Inattivo", "enableToggleLabel": "Attiva l'endpoint MCP", "endpointLabel": "URL del connettore", "connectDescription": "Incolla questo URL nella configurazione del connettore personalizzato del tuo assistente e autorizza con il token qui sotto o accedi quando richiesto.", @@ -6930,7 +7028,8 @@ "connectionRevokeDescription": "Questo termina immediatamente l'accesso dell'assistente e ne impedisce il rinnovo. Per riconnetterlo sarà necessaria una nuova autorizzazione.", "connectionRevokeFailed": "Impossibile disconnettere l'assistente.", "tokensDetail": "Il token viene mostrato una sola volta.", - "connectionsDetail": "Revocare una connessione ne termina subito l'accesso, inclusa la capacità di rinnovarsi." + "connectionsDetail": "Revocare una connessione ne termina subito l'accesso, inclusa la capacità di rinnovarsi.", + "tokenNamePlaceholder": "Nome, ad es. Claude Desktop" }, "strava": "Strava", "stravaDescription": "Collega il tuo account Strava per sincronizzare le tue attività con la cronologia degli allenamenti.", @@ -6939,7 +7038,7 @@ "stravaClientId": "ID client", "stravaClientSecret": "Secret client", "stravaCredentialsSaved": "Credenziali salvate", - "stravaCredentialsSavedPlaceholder": "Salvate — inseriscine di nuove per sostituirle", + "stravaCredentialsSavedPlaceholder": "Salvate. Inseriscine di nuove per sostituirle", "stravaSaveCredentials": "Salva", "stravaUnavailable": "Strava non è configurato su questo server. Aggiungi sopra le tue credenziali API di Strava o chiedi al tuo amministratore di configurare l'app condivisa.", "stravaConnect": "Collega Strava", @@ -6949,7 +7048,7 @@ "stravaDisconnect": "Disconnetti", "stravaDisconnectTitle": "Disconnettere Strava?", "stravaDisconnectDescription": "Le sincronizzazioni future si interrompono. Le attività già importate restano nella cronologia.", - "stravaOauthConnected": "Strava collegato — la prima sincronizzazione parte in background.", + "stravaOauthConnected": "Strava collegato: la prima sincronizzazione parte in background.", "stravaOauthFailed": "Connessione a Strava non riuscita", "stravaOauthError": { "csrf1": "Il controllo di sicurezza non è riuscito perché mancava il cookie del browser. Riavvia la connessione nello stesso browser.", @@ -6977,26 +7076,33 @@ "title": "Avanzamento della sincronizzazione", "receivedLabel": "Record ricevuti", "oldestLabel": "Lettura più vecchia raggiunta", - "flowing": "Nell'ultima ora sono arrivati dati — l'app per iPhone sta ancora inviando.", - "waiting": "In attesa che l'app per iPhone invii altri dati. Il trasferimento continua quando l'app è aperta o quando iOS le concede tempo in background — una pausa durante il primo grande recupero è normale, non un errore." + "flowing": "Nell'ultima ora sono arrivati dati. L'app per iPhone sta ancora inviando.", + "waiting": "In attesa che l'app per iPhone invii altri dati. Il trasferimento continua quando l'app è aperta o quando iOS le concede tempo in background. Una pausa durante il primo grande recupero è normale, non un errore." }, "delivery": { "title": "Consegna", "lastTriggerLabel": "L'ultima sincronizzazione è arrivata", "lastBackgroundLabel": "Ultima sincronizzazione in background", - "backgroundNever": "Mai — finora ogni sincronizzazione è arrivata con l'app aperta", + "backgroundNever": "Mai: finora ogni sincronizzazione è arrivata con l'app aperta", "noSyncYet": "Non è ancora arrivata alcuna sincronizzazione, quindi qui non c'è nulla da riferire.", "trigger": { "foreground": "con l'app aperta", "background": "in background", "push": "da una sveglia push", - "unknown": "trigger non segnalato dall'app" + "unknown": "trigger non segnalato dall'app", + "manual": "da «Sincronizza tutto»" } + }, + "freshness": { + "title": "Per tipo di dato", + "lastReceived": "Ultima ricezione {relative}", + "lastNewValue": "Ultimo nuovo valore {relative}", + "never": "Non ancora ricevuto" } }, "garminInfo": { "title": "Garmin", - "body": "Garmin non ha una connessione diretta qui. Collegalo tramite Apple Health (iOS) o Google Health Connect (Android). I punteggi proprietari di Garmin — Body Battery, Training Load, stato HRV, VO₂ max — non vengono condivisi da Garmin con nessuna delle due piattaforme.", + "body": "Garmin non ha una connessione diretta qui. Collegalo tramite Apple Health (iOS) o Google Health Connect (Android). I punteggi proprietari di Garmin (Body Battery, Training Load, stato HRV, VO₂ max) non vengono condivisi da Garmin con nessuna delle due piattaforme.", "docsLink": "Leggi la guida" }, "glucoseReference": { @@ -7052,6 +7158,12 @@ "description": "Questa azione condivide o esporta i tuoi dati sanitari. Conferma che sei tu per continuare.", "signInAgain": "Accedi di nuovo, poi riprova. Il tuo account non ha una password o una passkey da confermare qui.", "failed": "Non ha funzionato. Controlla quanto inserito e riprova." + }, + "passwordTitle": "Password", + "tokenNameLabel": "Nome del token", + "displayCard": { + "title": "Visualizzazione", + "description": "Lingua, unità e formati, salvati appena li modifichi." } }, "admin": { @@ -7084,14 +7196,14 @@ "aiServerKey": { "title": "Chiave IA del server", "description": "Una chiave dell'operatore per gli account che non ne hanno una propria.", - "byokHint": "La chiave personale dell'utente (BYOK, accesso ChatGPT o provider locale) ha sempre la precedenza — la chiave del server è l'ultimo passo della catena dei provider.", + "byokHint": "La chiave personale dell'utente (BYOK, accesso ChatGPT o provider locale) ha sempre la precedenza: la chiave del server è l'ultimo passo della catena dei provider.", "consentHint": "Le richieste tramite la chiave del server richiedono il consenso IA attivo dell'utente; senza una ricevuta di consenso non viene inviato nulla.", "baseUrlHint": "L'URL di base determina l'API: un URL Anthropic come https://api.anthropic.com/v1 viene riconosciuto, qualsiasi altro host viene chiamato tramite l'API compatibile con OpenAI.", "statusConfigured": "Configurata", "statusNotConfigured": "Non configurata", "keyLabel": "Chiave API", "keyPlaceholder": "sk-...", - "keyPlaceholderSet": "Salvata ({preview}) — inserisci una nuova chiave per sostituirla", + "keyPlaceholderSet": "Salvata ({preview}). Inserisci una nuova chiave per sostituirla", "modelLabel": "Modello", "baseUrlLabel": "URL di base", "save": "Salva", @@ -7125,7 +7237,10 @@ "typeCodex": "Accesso ChatGPT (personale)", "typeLocal": "Modello locale (personale)", "typeGateway": "Gateway compatibile OpenAI (personale)", - "spendToday": "Oggi ({date}): {total} token, di cui {operator} sulla chiave dell'operatore." + "spendToday": "Oggi ({date}): {total} token, di cui {operator} sulla chiave dell'operatore.", + "statusPaused": "In pausa", + "pausedHint": "In pausa dopo errori ripetuti per {count} utenti fino al {at}. Poi viene ritentato automaticamente.", + "modelNotServed": "L’endpoint non offre più il modello {model}. Scegli nelle impostazioni IA un modello che offre." }, "aiQuality": { "title": "Qualità delle analisi", @@ -7169,9 +7284,9 @@ "defaultLanguage": "Lingua predefinita", "defaultLanguageDescription": "Usata per i nuovi utenti e come ripiego.", "defaultUserTimezone": "Fuso orario predefinito per i nuovi account", - "defaultUserTimezoneDescription": "Usato per i nuovi account privi di un fuso rilevato dal browser. Non influisce sugli utenti esistenti.", + "defaultUserTimezoneDescription": "Usato per i nuovi account senza fuso rilevato dal browser; gli account esistenti mantengono il proprio.", "defaultUserTimezoneFallback": "Europe/Berlin (predefinito)", - "servicesGlobal": "Servizi (globale)", + "servicesGlobal": "Canali e API", "servicesGlobalDescription": "I servizi disattivati non sono disponibili per alcun utente.", "telegramGlobal": "Consentire le notifiche Telegram.", "ntfyGlobal": "Consentire le notifiche ntfy.", @@ -7179,19 +7294,21 @@ "integrationWebPush": "Web Push", "offlineGeoLabel": "Dati geografici", "offlineGeoEnabled": "Offline (GeoLite2)", - "offlineGeoFallback": "Online ({host}) — monta i database GeoLite2 e imposta GEOLITE2_DIR", + "offlineGeoFallback": "Online ({host}): monta i database GeoLite2 e imposta GEOLITE2_DIR", + "offlineGeoOff": "Disattivato", + "offlineGeoChosen": "Online ({host})", "apiGlobal": "Consentire endpoint API e token API.", "umamiTitle": "Umami", - "umamiDescription": "Analisi web con Umami", + "umamiDescription": "Analisi web con Umami.", "glitchtipTitle": "GlitchTip", - "glitchtipDescription": "Tracciamento degli errori con GlitchTip", + "glitchtipDescription": "Tracciamento degli errori con GlitchTip.", "webPushVapidTitle": "Web Push VAPID", - "umamiEnabled": "Abilitare il tracciamento Umami.", + "umamiEnabled": "Abilita il tracciamento Umami", "umamiScriptUrl": "URL dello script Umami", "umamiScriptUrlPlaceholder": "https://analytics.example.com/script.js", "umamiWebsiteId": "ID del sito web Umami", "umamiWebsiteIdPlaceholder": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "glitchtipEnabled": "Abilitare il tracciamento degli errori GlitchTip.", + "glitchtipEnabled": "Abilita il tracciamento degli errori GlitchTip", "glitchtipDsn": "GlitchTip DSN", "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "Ambiente GlitchTip", @@ -7253,12 +7370,12 @@ "title": "Disponibilità dei moduli", "description": "Disattiva un modulo per l'intero server.", "saveError": "Impossibile aggiornare la disponibilità dei moduli.", - "detail": "Un modulo disattivato qui è spento per ogni account, indipendentemente dalle preferenze personali. I domini principali (peso, pressione, polso, farmaci) sono sempre attivi e non possono essere disattivati.", - "coachFollowsSwitch": "Segue l'interruttore Coach in Assistente." + "detail": "Peso, pressione e polso sono misure di base e restano sempre attivi.", + "coachFollowsSwitch": "Segue l'interruttore Coach nella sezione Coach." }, "invites": { - "title": "Inviti", - "description": "Link di invito per la registrazione: funzionano anche quando la registrazione è chiusa.", + "title": "Link di invito", + "description": "Ogni link consente una registrazione anche quando la registrazione è chiusa.", "create": "Crea invito", "createTitle": "Crea invito", "createDescription": "Il link consente una registrazione anche quando la registrazione è chiusa.", @@ -7304,8 +7421,8 @@ "revokedToast": "Invito revocato.", "revokeError": "Impossibile revocare l'invito." }, - "userManagement": "Gestione utenti", - "userManagementDescription": "Gestisci gli account: ruoli, reset delle password e disconnessioni forzate.", + "userManagement": "Account", + "userManagementDescription": "Ruoli, reset delle password e disconnessioni forzate per ogni account.", "userName": "Nome", "userEmail": "E-mail", "userRole": "Ruolo", @@ -7339,7 +7456,6 @@ "tokenNeverUsed": "Mai", "tokensEmptyTitle": "Ancora nessun token API", "tokensEmptyDescription": "I token vengono emessi quando un client nativo accede o tramite Impostazioni → Account.", - "loginOverview": "Panoramica degli accessi", "loginOverviewDescription": "Eventi di accesso e autenticazione di tutti gli account, filtrabili ed esportabili in CSV.", "allAuthEvents": "Tutti gli eventi di autenticazione", "failedOnly": "Solo non riusciti", @@ -7372,6 +7488,7 @@ "finalDelete": "Elimina definitivamente", "deletedResult": "Eliminate {rows} righe in {tables} tabelle. Account utente e credenziali di accesso conservati.", "authRegister": "Registrazione", + "authClaim": "Profilo preso in carico", "authLogin": "Accedi", "authLoginPasskey": "Accesso con passkey", "authLoginPassword": "Accesso con password", @@ -7446,7 +7563,7 @@ }, "services": { "title": "Servizi", - "subtitle": "Abilita o disabilita i canali di notifica e le integrazioni a livello di sistema." + "subtitle": "Attiva o disattiva i canali di notifica e l'API per l'intero server." }, "integrations": { "title": "Integrazioni", @@ -7532,7 +7649,8 @@ "refresh": "Aggiorna", "eventDetails": "Dettagli dell'evento", "closeDetails": "Chiudi dettagli", - "processDetail": "Il buffer è volatile e limitato a 500 voci." + "processDetail": "Il buffer è volatile e limitato a 500 voci.", + "cardTitle": "Eventi recenti" }, "backups": { "title": "Backup", @@ -7584,7 +7702,7 @@ "restorePromptLabel": "Digita RESTORE per continuare", "previewTitle": "Contenuto del backup", "previewLoading": "Lettura del contenuto…", - "previewUnavailable": "Impossibile leggere il contenuto — il ripristino resta possibile.", + "previewUnavailable": "Impossibile leggere il contenuto, ma il ripristino resta possibile.", "previewStillReading": "Questo backup è stato creato da una versione precedente, quindi il suo contenuto viene letto per intero, e con un archivio grande ci vuole un po'. Riapri questa finestra tra qualche minuto per vederlo. Il ripristino non dipende da questo.", "previewEmpty": "Il file non contiene record.", "previewOther": "Altri record", @@ -7627,7 +7745,7 @@ "restoreSkippedMedicationTarget": "Analita obiettivo di un farmaco", "restoreSkippedScheduleRevisionLink": "Successione dei periodi di schema", "restoreSkippedAccountSetting": "Impostazione dell'account", - "restoreMissingTitle": "Ripristino rifiutato — il file è incompleto", + "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.", "restoreMissingDocuments": "Documenti", @@ -7699,7 +7817,8 @@ "restoreSkippedCycleCiphertext": "Nota o etichetta del ciclo cifrata con una chiave che questo server non possiede (giorno o sintomo mantenuto senza)", "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)" + "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)" }, "danger-zone": { "title": "Zona pericolosa", @@ -7716,7 +7835,6 @@ "encryption": { "title": "Crittografia", "subtitle": "Copertura della crittografia e stato della rotazione delle chiavi per i dati a riposo.", - "loading": "Caricamento dello stato della crittografia...", "loadError": "Impossibile caricare lo stato della crittografia.", "coverageTitle": "Copertura", "coverageDescription": "Quante righe cifrate stanno sotto ogni id di chiave.", @@ -7761,12 +7879,45 @@ "backupsNone": "Nessun backup ha ancora registrato le chiavi di cui ha bisogno.", "backupsStillNeed": "Tutte le righe sono ruotate, ma i backup hanno ancora bisogno di {keys}", "backupsUnrecordedBadge": "Tutte le righe sono ruotate, ma backup più vecchi potrebbero ancora richiedere una chiave ritirata", - "backupsDetail": "La rotazione cifra di nuovo ogni riga, ma un backup conserva la chiave con cui è stato scritto il suo contenuto, e ripristinarlo richiede quella chiave. Tieni una chiave ritirata in ENCRYPTION_KEYS finché compare qui sotto." + "backupsDetail": "La rotazione cifra di nuovo ogni riga, ma un backup conserva la chiave con cui è stato scritto il suo contenuto, e ripristinarlo richiede quella chiave. Tieni una chiave ritirata in ENCRYPTION_KEYS finché compare qui sotto.", + "columnsShow": "Mostra la tabella ({count} con righe)", + "columnsHide": "Nascondi la tabella", + "columnsShowAll": "Mostra tutte le {count} colonne", + "columnsShowWithRows": "Solo colonne con righe", + "columnsNoneWithRows": "Nessuna colonna cifrata contiene ancora righe." + }, + "maintenance": { + "title": "Manutenzione della tabella delle misurazioni", + "description": "Libera lo spazio occupato dai vecchi residui di sincronizzazione, una volta terminata la pulizia.", + "body": "Esegue un vacuum della tabella delle misurazioni e poi ricostruisce i suoi indici, uno alla volta. Lettura e salvataggio continuano a funzionare durante l'esecuzione. Scegli un'ora tranquilla e lascia spazio libero su disco per un'altra copia dell'indice più grande.", + "tableSize": "Tabella con indici", + "indexSize": "Indici", + "loading": "Verifica dello stato della manutenzione…", + "loadError": "Impossibile leggere lo stato della manutenzione.", + "unavailable": "Nessun processo in background è collegato, quindi la manutenzione non può essere eseguita qui.", + "purgePending": "La pulizia dei vecchi residui di sincronizzazione è ancora in corso. La manutenzione potrà partire quando sarà terminata.", + "state": { + "idle": "Non ancora eseguita.", + "queued": "In attesa del processo in background.", + "running": "In corso dalle {since}. Su un'istanza grande può richiedere un po' di tempo.", + "completed": "Terminata il {at}.", + "refusedPurge": "Non avviata, perché la pulizia era ancora in corso. Riavviala quando la pulizia sarà terminata.", + "alreadyRunning": "Non avviata, perché un'altra esecuzione era già in corso.", + "failed": "Interrotta il {at}. Il registro dell'app indica il passaggio e il runbook spiega cosa controllare." + }, + "start": "Avvia manutenzione", + "confirmTitle": "Avviare la manutenzione adesso?", + "confirmBody": "Viene eseguita in background, un passaggio alla volta, e può richiedere un po' di tempo. Se oggi non hai ancora creato un backup, creane uno prima.", + "confirm": "Avvia", + "cancel": "Annulla", + "started": "Manutenzione avviata.", + "alreadyQueued": "Una manutenzione è già in coda o in corso.", + "startFailed": "Impossibile avviare la manutenzione." } }, "carrier": "Operatore", "mfaRequired": "Richiedi un secondo fattore", - "mfaRequiredDescription": "Ogni account senza un secondo fattore attivo viene indirizzato alla configurazione dopo l'accesso. Questo invita gli utenti web ad attivarlo, ma non blocca l'accesso diretto via API o nativo (Bearer) per gli account non ancora configurati.", + "mfaRequiredDescription": "Gli account senza secondo fattore vengono indirizzati alla configurazione dopo un accesso web; l'accesso via API o nativo (Bearer) resta aperto.", "mfaEnforcedOn": "MFA attivo", "mfaEnforcedOff": "MFA disattivato", "mfaEnforcedOnHint": "Per questo utente è richiesto un secondo fattore. Clicca per non richiederlo più.", @@ -7796,7 +7947,7 @@ "connectedSince": "Connesso dal {when}", "connected": "Connessione IA condivisa collegata", "disconnected": "Connessione IA condivisa rimossa", - "error": "Qualcosa è andato storto — riprova" + "error": "Qualcosa è andato storto. Riprova" }, "failingJobs": { "title": "Processi in background", @@ -7864,7 +8015,9 @@ "restartNote": "Riavvia HealthLog dopo la modifica.", "lastResort": "Ultima risorsa, solo se sei certo che la chiave sia giusta e nessuna delle tre soluzioni vale: imposta {setting} e riavvia. HealthLog serve comunque e si limita a registrare il problema nel log. Così si disattiva un controllo di sicurezza: con una chiave sbagliata ogni valore cifrato fallisce e i nuovi dati vengono scritti con quella chiave.", "codes": "Per chi gestisce il server: le richieste API rispondono 503 con il codice di errore {code}, e /api/health indica il motivo {reason}." - } + }, + "loginOverviewCardTitle": "Eventi di accesso", + "userColumn": "Utente" }, "achievements": { "title": "Obiettivi", @@ -7880,7 +8033,9 @@ "noneUnlockedYet": "Nessun obiettivo ancora sbloccato.", "pointsValue": "{points} punti", "metricCount": "{count}", - "metricDays": "{count} giorni", + "metricDaysOne": "{count} giorno", + "metricDaysFew": "{count} giorni", + "metricDaysOther": "{count} giorni", "metricPercent": "{count}%", "badges": { "intakeTotal1": { @@ -7903,14 +8058,6 @@ "title": "Leggenda delle assunzioni", "description": "Registra 300 assunzioni di farmaci." }, - "overIntake1": { - "title": "Doppia dose", - "description": "Assumi il farmaco almeno una volta in più del previsto." - }, - "skippedIntake1": { - "title": "Pausa presa", - "description": "Salta almeno un’assunzione pianificata." - }, "passkeyCreated1": { "title": "Passkey configurata", "description": "Crea la tua prima passkey." @@ -7936,19 +8083,19 @@ "description": "Assumi tutti i farmaci in orario per 1 giorno." }, "compliance801": { - "title": "Aderenza 80% · 1g", + "title": "Aderenza all'80% per 1 giorno", "description": "Raggiungi almeno l’80% di aderenza su 30 giorni per 1 giorno." }, "bmiGreen1": { - "title": "IMC nel verde · 1g", + "title": "IMC nel verde per 1 giorno", "description": "Mantieni l’IMC nell’intervallo normale per 1 giorno." }, "bpGreen1": { - "title": "Pressione nel verde · 1g", + "title": "Pressione nel verde per 1 giorno", "description": "Mantieni la pressione nell’intervallo normale per 1 giorno." }, "pulseGreen1": { - "title": "Polso nel verde · 1g", + "title": "Polso nel verde per 1 giorno", "description": "Mantieni il polso a riposo nell’intervallo normale per 1 giorno." }, "onTimePerfect7": { @@ -7956,19 +8103,19 @@ "description": "Assumi tutti i farmaci in orario per 7 giorni consecutivi." }, "compliance807": { - "title": "Aderenza 80% · 7g", + "title": "Aderenza all'80% per 7 giorni", "description": "Raggiungi almeno l’80% di aderenza su 30 giorni per 7 giorni consecutivi." }, "bmiGreen7": { - "title": "IMC nel verde · 7g", + "title": "IMC nel verde per 7 giorni", "description": "Mantieni l’IMC nell’intervallo normale per 7 giorni consecutivi." }, "bpGreen7": { - "title": "Pressione nel verde · 7g", + "title": "Pressione nel verde per 7 giorni", "description": "Mantieni la pressione nell’intervallo normale per 7 giorni consecutivi." }, "pulseGreen7": { - "title": "Polso nel verde · 7g", + "title": "Polso nel verde per 7 giorni", "description": "Mantieni il polso a riposo nell’intervallo normale per 7 giorni consecutivi." }, "onTimePerfect30": { @@ -7976,19 +8123,19 @@ "description": "Assumi tutti i farmaci in orario per 30 giorni consecutivi." }, "compliance8030": { - "title": "Aderenza 80% · 30g", + "title": "Aderenza all'80% per 30 giorni", "description": "Raggiungi almeno l’80% di aderenza su 30 giorni per 30 giorni consecutivi." }, "bmiGreen30": { - "title": "IMC nel verde · 30g", + "title": "IMC nel verde per 30 giorni", "description": "Mantieni l’IMC nell’intervallo normale per 30 giorni consecutivi." }, "bpGreen30": { - "title": "Pressione nel verde · 30g", + "title": "Pressione nel verde per 30 giorni", "description": "Mantieni la pressione nell’intervallo normale per 30 giorni consecutivi." }, "pulseGreen30": { - "title": "Polso nel verde · 30g", + "title": "Polso nel verde per 30 giorni", "description": "Mantieni il polso a riposo nell’intervallo normale per 30 giorni consecutivi." }, "onTimePerfect180": { @@ -7996,19 +8143,19 @@ "description": "Assumi tutti i farmaci in orario per 180 giorni consecutivi." }, "compliance80180": { - "title": "Aderenza 80% · 180g", + "title": "Aderenza all'80% per 180 giorni", "description": "Raggiungi almeno l’80% di aderenza su 30 giorni per 180 giorni consecutivi." }, "bmiGreen180": { - "title": "IMC nel verde · 180g", + "title": "IMC nel verde per 180 giorni", "description": "Mantieni l’IMC nell’intervallo normale per 180 giorni consecutivi." }, "bpGreen180": { - "title": "Pressione nel verde · 180g", + "title": "Pressione nel verde per 180 giorni", "description": "Mantieni la pressione nell’intervallo normale per 180 giorni consecutivi." }, "pulseGreen180": { - "title": "Polso nel verde · 180g", + "title": "Polso nel verde per 180 giorni", "description": "Mantieni il polso a riposo nell’intervallo normale per 180 giorni consecutivi." }, "onTimePerfect360": { @@ -8016,19 +8163,19 @@ "description": "Assumi tutti i farmaci in orario per 360 giorni consecutivi." }, "compliance80360": { - "title": "Aderenza 80% · 360g", + "title": "Aderenza all'80% per 360 giorni", "description": "Raggiungi almeno l’80% di aderenza su 30 giorni per 360 giorni consecutivi." }, "bmiGreen360": { - "title": "IMC nel verde · 360g", + "title": "IMC nel verde per 360 giorni", "description": "Mantieni l’IMC nell’intervallo normale per 360 giorni consecutivi." }, "bpGreen360": { - "title": "Pressione nel verde · 360g", + "title": "Pressione nel verde per 360 giorni", "description": "Mantieni la pressione nell’intervallo normale per 360 giorni consecutivi." }, "pulseGreen360": { - "title": "Polso nel verde · 360g", + "title": "Polso nel verde per 360 giorni", "description": "Mantieni il polso a riposo nell’intervallo normale per 360 giorni consecutivi." }, "moodFirst": { @@ -8112,15 +8259,15 @@ "description": "Hai cambiato la lingua dell’app almeno una volta." }, "missFree7": { - "title": "Nessuna dose persa · 7g", + "title": "Nessuna dose persa in 7 giorni", "description": "7 giorni consecutivi senza una dose persa." }, "missFree30": { - "title": "Nessuna dose persa · 30g", + "title": "Nessuna dose persa in 30 giorni", "description": "30 giorni consecutivi senza una dose persa." }, "missFree90": { - "title": "Nessuna dose persa · 90g", + "title": "Nessuna dose persa in 90 giorni", "description": "90 giorni consecutivi senza una dose persa." }, "measurementWeeks4": { @@ -8129,10 +8276,10 @@ }, "selfContextComplete": { "title": "Autodichiarazione aggiornata", - "description": "Tutti e quattro i campi dell’autodichiarazione compilati — il Coach conosce il tuo contesto." + "description": "Tutti e quattro i campi dell’autodichiarazione compilati: il Coach conosce il tuo contesto." }, "sleepLog7": { - "title": "Diario del sonno · 7g", + "title": "Diario del sonno per 7 giorni", "description": "Dati del sonno registrati per 7 giorni consecutivi." } }, @@ -8151,7 +8298,7 @@ }, "hiddenCard": { "title": "Obiettivo nascosto", - "description": "Continua a monitorare — potresti imbatterti in esso.", + "description": "Continua a monitorare: potresti imbatterti in esso.", "ariaLabel": "Obiettivo nascosto bloccato" }, "hiddenUnlockToast": { @@ -8160,7 +8307,7 @@ "dashboardCard": { "title": "Sbloccati di recente", "viewAll": "Vedi tutti", - "empty": "Ancora nessun obiettivo — continua a registrare per sbloccare il primo." + "empty": "Ancora nessun obiettivo. Continua a registrare per sbloccare il primo." } }, "notifications": { @@ -8222,9 +8369,6 @@ "enabledToast": "Avvisi proattivi del Coach attivati.", "disabledToast": "Avvisi proattivi del Coach disattivati.", "saveError": "Impossibile salvare. Riprova più tardi.", - "suggestionsLabel": "Suggerimenti di esempio", - "suggestionsDesc": "Mostra di tanto in tanto una domanda di esempio pronta all'uso nella schermata del Coach.", - "suggestionsAria": "Attiva o disattiva i suggerimenti di esempio del Coach", "aiComposedLabel": "Lascia che sia il Coach a formulare il promemoria", "aiComposedDesc": "Quando è disponibile un provider, il promemoria proattivo viene scritto dal modello anziché dal modello di testo fisso. Disattivato per impostazione predefinita.", "aiComposedAria": "Attiva o disattiva il promemoria proattivo scritto dall'IA" @@ -8295,7 +8439,27 @@ "loginRequired": "Accedi per gestire le notifiche.", "someChannelsGloballyDisabled": "Uno o più canali sono disattivati nelle impostazioni e non riceveranno notifiche.", "reminderSettingsLink": "Impostazioni promemoria", - "channelsLink": "Canali" + "channelsLink": "Canali", + "handover": { + "claimedTitle": "{name} ora ha un proprio account", + "claimedEnd": "{name} ha preso in carico la propria cartella sanitaria. Il tuo accesso è terminato.", + "claimedRead": "{name} ha preso in carico la propria cartella sanitaria. Puoi ancora vederla, ma non più modificarla.", + "claimedManage": "{name} ha preso in carico la propria cartella sanitaria. Puoi ancora gestirla finché non deciderà altrimenti.", + "changedTitle": "{name} ha cambiato il tuo accesso", + "changedEnd": "Il tuo accesso alla cartella sanitaria di {name} è terminato.", + "changedRead": "Ora puoi vedere la cartella sanitaria di {name}, ma non modificarla.", + "changedManage": "Puoi gestire la cartella sanitaria di {name}.", + "unnamed": { + "claimedTitle": "La persona di cui ti occupavi ora ha un proprio account", + "claimedEnd": "La persona di cui ti occupavi ha preso in carico la propria cartella sanitaria. Il tuo accesso è terminato.", + "claimedRead": "La persona di cui ti occupavi ha preso in carico la propria cartella sanitaria. Puoi ancora consultarla, ma non più modificarla.", + "claimedManage": "La persona di cui ti occupavi ha preso in carico la propria cartella sanitaria. Puoi ancora gestirla finché non deciderà diversamente.", + "changedTitle": "Il tuo accesso a una cartella sanitaria è cambiato", + "changedEnd": "Il tuo accesso alla cartella sanitaria della persona di cui ti occupavi è terminato.", + "changedRead": "Ora puoi consultare la cartella sanitaria della persona di cui ti occupavi, ma non modificarla.", + "changedManage": "Puoi gestire la cartella sanitaria della persona di cui ti occupavi." + } + } }, "i18n": { "maintainershipBanner": { @@ -8326,7 +8490,7 @@ } }, "edit": { - "title": "Modifica range obiettivo — {metric}", + "title": "Modifica range obiettivo: {metric}", "description": "Regola il range obiettivo usato in grafici, insights e referto medico. Valori in {unit}.", "derivedHint": "Questa card è derivata da altre metriche. Modifica gli obiettivi sottostanti (es. pressione o peso).", "minLabel": "Min", @@ -8350,15 +8514,19 @@ "currentValue": "Attuale: {value} {unit}", "targetRangeValue": "Range obiettivo: {min}–{max} {unit}", "sourceLabel": "Fonte: {source}", + "sourceNames": { + "moodEntries": "Voci dell’umore", + "sevenDays": "7 giorni" + }, "inTarget": "Nel range obiettivo", "aboveTarget": "{delta} {unit} sopra il range", "belowTarget": "{delta} {unit} sotto il range", "diastolic": "Diastolica", - "glucoseFasting": "Glicemia — a digiuno", - "glucosePostprandial": "Glicemia — postprandiale", - "glucoseRandom": "Glicemia — qualsiasi", - "glucoseBedtime": "Glicemia — prima di coricarsi", - "glucoseUnspecified": "Glicemia — non indicata", + "glucoseFasting": "Glicemia (a digiuno)", + "glucosePostprandial": "Glicemia (postprandiale)", + "glucoseRandom": "Glicemia (qualsiasi)", + "glucoseBedtime": "Glicemia (prima di coricarsi)", + "glucoseUnspecified": "Glicemia (non indicata)", "status": { "underweight": "Sottopeso", "normal": "Normal", @@ -8444,7 +8612,7 @@ "askMedicationName": "Indica il farmaco, es. preso Ramipril.", "medicationNotFoundExact": "Farmaco non trovato. Invia il nome esattamente come appare in HealthLog.", "helpHeader": "Comandi disponibili:", - "helpBody": "/help — mostra questa guida\n/add — registra un'assunzione\n/start — avvia il bot\n\nComandi testuali:\ntaken <nome> — conferma un'assunzione\n\nPulsanti nei promemoria:\n• Assunto — conferma l'assunzione\n• 🕐 1h / 🕐 3h — posticipa il promemoria\n• ⏭ Salta — salta l'assunzione\n• ✓ Conferma — conferma un'assunzione saltata", + "helpBody": "/help: mostra questa guida\n/add: registra un'assunzione\n/start: avvia il bot\n\nComandi testuali:\ntaken <nome>: conferma un'assunzione\n\nPulsanti nei promemoria:\n• Assunto: conferma l'assunzione\n• 🕐 1h / 🕐 3h: posticipa il promemoria\n• ⏭ Salta: salta l'assunzione\n• ✓ Conferma: conferma un'assunzione saltata", "measureValuePrompt": "Rispondi con il valore per registrarlo (facoltativo).", "buttonMeasureValue": "Valore…", "measureValueSaved": "Misurazione salvata.", @@ -8469,9 +8637,9 @@ }, "lowStockReminders": { "title": "Scorte in esaurimento: {medName}", - "bodyToday": "{medName}: {units} unità rimaste — meno di un giorno di scorta.", - "bodyReorder": "{medName}: {units} unità rimaste. Le scorte finiscono intorno al {runsOutOn} — riordina entro il {reorderBy}.", - "bodyLastDose": "{medName}: {units} unità rimaste — circa la tua ultima dose. Le scorte finiscono intorno al {runsOutOn}.", + "bodyToday": "{medName}: {units} unità rimaste, meno di un giorno di scorta.", + "bodyReorder": "{medName}: {units} unità rimaste. Le scorte finiscono intorno al {runsOutOn}: riordina entro il {reorderBy}.", + "bodyLastDose": "{medName}: {units} unità rimaste, circa la tua ultima dose. Le scorte finiscono intorno al {runsOutOn}.", "bodyExpired": "{medName}: nessuna scorta utilizzabile. {units} unità sono contrassegnate come scadute.", "bodyAsNeeded": "{medName}: restano solo {units} unità. Riordina prima di aver bisogno della prossima dose." }, @@ -8498,11 +8666,11 @@ "questionConditions": "Hai malattie croniche che il Coach dovrebbe conoscere?", "questionFocus": "C'è qualcosa di specifico a cui il Coach dovrebbe fare attenzione?", "questionAllergies": "Hai allergie o intolleranze?", - "questionAboutMe": "Vuoi raccontare al Coach qualcosa di te — abitudini, obiettivi, contesto?" + "questionAboutMe": "Vuoi raccontare al Coach qualcosa di te? Abitudini, obiettivi, contesto?" }, "clinicianView": { "title": "Cartella sanitaria condivisa", - "period": "Periodo del report: {start} – {end}", + "period": "Periodo del report: {start}–{end}", "expires": "Questo link scade il {date}.", "provenance": "Questo è un riepilogo di sola lettura condiviso dalla persona a cui appartiene, esportato dai suoi dati HealthLog autoregistrati. I valori sono autodichiarati e possono includere letture sincronizzate da dispositivi; vanno considerati come riferiti dal paziente, non come un esame clinico.", "labs": "Valori di laboratorio", @@ -8526,7 +8694,7 @@ "strain": "Punteggio di sforzo", "score": "Punteggio di benessere" }, - "footer": "Condiviso da HealthLog — un tracker della salute self-hosted.", + "footer": "Condiviso da HealthLog, un tracker della salute self-hosted.", "unlock": { "title": "Inserisci la passphrase", "description": "Questo referto sanitario condiviso è protetto. Inserisci la passphrase che hai ricevuto per visualizzarlo.", @@ -8561,7 +8729,11 @@ }, "course": "Ciclo {n}", "courseRange": "dal {start} al {end}", - "courseSince": "dal {start}" + "courseSince": "dal {start}", + "unavailable": { + "title": "Link scaduto o non valido", + "description": "Questo link di condivisione è scaduto, è stato revocato o è incompleto. Chiedi un nuovo link alla persona che lo ha condiviso." + } }, "cycle": { "symptomCategory": { @@ -8614,7 +8786,7 @@ }, "crosstab": { "description": "Come si confronta ogni misurazione tra la fase luteale e quella follicolare, sui tuoi dati.", - "pairLabel": "{metric} · luteale vs follicolare", + "pairLabel": "{metric}: fase luteale vs follicolare", "detail": "Luteale {lutealAvg} {unit} ({lutealDays} giorni) vs follicolare {follicularAvg} {unit} ({follicularDays} giorni).", "metricRestingHeartRate": "Frequenza cardiaca a riposo", "metricHrv": "Variabilità della frequenza cardiaca", @@ -8737,8 +8909,8 @@ "ariaUnknown": "Nessun ciclo attivo ancora", "caption": "del tuo ciclo", "firstPeriodCta": "Registra il mio primo ciclo", - "periodOverdue": "Il ciclo è in ritardo rispetto al solito — il conteggio dei giorni resta in pausa finché non registri la prossima mestruazione.", - "periodOverdueDays": "Il ciclo è in ritardo di {days} giorni rispetto al solito — il conteggio dei giorni resta in pausa finché non registri la prossima mestruazione.", + "periodOverdue": "Il ciclo è in ritardo rispetto al solito: il conteggio dei giorni resta in pausa finché non registri la prossima mestruazione.", + "periodOverdueDays": "Il ciclo è in ritardo di {days} giorni rispetto al solito: il conteggio dei giorni resta in pausa finché non registri la prossima mestruazione.", "logPeriodCta": "Registra mestruazione" }, "calendar": { @@ -8763,7 +8935,7 @@ "predictions": { "title": "Cosa arriva dopo", "nextPeriod": "Prossimo ciclo", - "nextPeriodWindow": "{low} – {high}", + "nextPeriodWindow": "{low}–{high}", "fertileWindow": "Finestra fertile", "ovulation": "Ovulazione stimata", "confidenceHigh": "Alta", @@ -8772,7 +8944,7 @@ "stillLearning": "Sto ancora imparando", "stillLearningBody": "Registra almeno tre cicli completi e le previsioni miglioreranno. Fino ad allora è una stima approssimativa.", "rawMode": "Previsione disattivata", - "rawModeBody": "Hai disattivato le previsioni — vengono mostrati solo i tuoi dati registrati, senza previsione.", + "rawModeBody": "Hai disattivato le previsioni: vengono mostrati solo i tuoi dati registrati, senza previsione.", "none": "Nessuna previsione ancora. Registra un ciclo per iniziare.", "ovulationConfirmed": "Ovulazione (confermata)" }, @@ -8834,7 +9006,7 @@ "temperatureSection": "Temperatura e ovulazione", "intimacySection": "Intimità e contraccezione", "testsSection": "Test", - "phaseDayHeader": "Giorno {day} · {phase}", + "phaseDayHeader": "Giorno {day}, {phase}", "bbtHint": "Misurala appena sveglia, prima di alzarti.", "titleForDate": "Registrazione del {date}", "descriptionToday": "Oggi. Flusso, sintomi e note.", @@ -8901,15 +9073,15 @@ "fieldInfo": { "bbt": "La tua temperatura a riposo, misurata appena sveglia prima di alzarti. Dopo l'ovulazione sale leggermente (circa 0,2–0,3 °C).", "bbtLabel": "Cos'è la temperatura basale?", - "mucus": "Le perdite vaginali cambiano durante il ciclo — da secche o appiccicose a cremose, poi acquose e simili ad albume nei giorni più fertili.", + "mucus": "Le perdite vaginali cambiano durante il ciclo: da secche o appiccicose a cremose, poi acquose e simili ad albume nei giorni più fertili.", "mucusLabel": "Cos'è il muco cervicale?", - "ovulationTest": "Un test delle urine domestico che rileva il picco di LH — l'aumento ormonale circa 24–36 h prima dell'ovulazione. Un positivo segnala la finestra più fertile.", + "ovulationTest": "Un test delle urine domestico che rileva il picco di LH, l'aumento ormonale circa 24–36 h prima dell'ovulazione. Un positivo segnala la finestra più fertile.", "ovulationTestLabel": "Cos'è un test di ovulazione?", "spotting": "Lieve sanguinamento al di fuori delle mestruazioni.", "spottingLabel": "Cos'è lo spotting tra le mestruazioni?", "pregnancyTest": "Risultato di un test di gravidanza (hCG) domestico.", "pregnancyTestLabel": "Cos'è un test di gravidanza?", - "progesteroneTest": "Un test che conferma l'avvenuta ovulazione — il progesterone sale dopo.", + "progesteroneTest": "Un test che conferma l'avvenuta ovulazione: il progesterone sale dopo.", "progesteroneTestLabel": "Cos'è un test del progesterone?", "intercourse": "Se in questo giorno ci sono stati rapporti sessuali.", "intercourseLabel": "Cosa registra i rapporti?", @@ -8962,14 +9134,14 @@ "postpone": { "menuItem": "Rimanda…", "title": "Rimanda la scadenza", - "description": "Sposta solo la scadenza attuale — il ritmo regolare resta invariato.", + "description": "Sposta solo la scadenza attuale. Il ritmo regolare resta invariato.", "chip": "+{days} giorni", "dateLabel": "Nuova data", "confirm": "Rimanda" }, "skip": { "heading": "Salta questa scadenza", - "explainer": "Registrato onestamente come saltato — non come fatto. L'intervallo riparte da oggi.", + "explainer": "Registrato onestamente come saltato, non come fatto. L'intervallo riparte da oggi.", "action": "Salta", "confirmTitle": "Saltare questa scadenza?" }, @@ -9011,10 +9183,10 @@ "VISCERAL_FAT": "Grasso viscerale", "BODY_MASS_INDEX": "Indice di massa corporea (IMC)", "WAIST_CIRCUMFERENCE": "Circonferenza vita", - "PHQ9_SCORE": "PHQ-9 · umore", - "GAD7_SCORE": "GAD-7 · preoccupazione", - "WHO5_SCORE": "WHO-5 · benessere", - "SCI_SCORE": "SCI · sonno" + "PHQ9_SCORE": "PHQ-9 (umore)", + "GAD7_SCORE": "GAD-7 (preoccupazione)", + "WHO5_SCORE": "WHO-5 (benessere)", + "SCI_SCORE": "SCI (sonno)" }, "typeGroups": { "vitals": "Parametri vitali", @@ -9110,7 +9282,7 @@ "localOcr": { "heading": "Leggi le scansioni su questo dispositivo", "description": "Legge la foto sul tuo dispositivo e invia al provider solo il testo estratto.", - "detail": "Utile quando il tuo provider IA non legge le immagini. Meno preciso — controlla ogni valore prima di salvare." + "detail": "Utile quando il tuo provider IA non legge le immagini. Meno preciso: controlla ogni valore prima di salvare." }, "sort": { "heading": "Ordinamento", @@ -9172,7 +9344,7 @@ "emptyDescription": "Aggiungi un valore per seguire questo biomarcatore nel tempo.", "title": "Biomarcatore", "genericDescription": "Un biomarcatore di laboratorio che monitori. Aggiungi misurazioni per seguirne l'andamento rispetto all'intervallo di riferimento.", - "valuesTitle": "{marker} — tutte le letture", + "valuesTitle": "{marker}: tutte le letture", "valuesDescription": "Ogni lettura di questo biomarcatore. Modifica o rimuovi una voce direttamente.", "backToMarker": "Torna a {marker}", "latestResult": "Ultimo risultato" @@ -9192,7 +9364,7 @@ }, "biomarker": { "manageTitle": "Biomarcatori", - "managerDescription": "Definisci un marcatore una volta — unità e intervallo valgono per ogni misurazione.", + "managerDescription": "Definisci un marcatore una volta: unità e intervallo valgono per ogni misurazione.", "define": "Definisci", "defineFirst": "Definisci il tuo primo biomarcatore", "defineTitle": "Definisci biomarcatore", @@ -9211,7 +9383,7 @@ "form": { "seedLabel": "Parti da un marcatore comune", "seedPlaceholder": "Scegli un marcatore (facoltativo)", - "seedHint": "Sceglierne uno compila i campi — adatta l'intervallo al tuo laboratorio.", + "seedHint": "Sceglierne uno compila i campi. Adatta l'intervallo al tuo laboratorio.", "name": "Nome", "namePlaceholder": "es. Colesterolo LDL", "unit": "Unità", @@ -9225,7 +9397,7 @@ "upperPlaceholder": "Limite superiore", "context": "Contesto", "contextPlaceholder": "Nota facoltativa su questo marcatore", - "rangeHint": "Entrambi i limiti sono facoltativi — lasciane uno vuoto per un intervallo aperto (es. LDL < 116).", + "rangeHint": "Entrambi i limiti sono facoltativi: lasciane uno vuoto per un intervallo aperto (es. LDL < 116).", "requiredError": "Nome e unità sono obbligatori.", "rangeOrderError": "Il limite inferiore non deve superare quello superiore.", "save": "Salva biomarcatore", @@ -9291,16 +9463,16 @@ "ft3": "Il T3 libero (triiodotironina) è la forma non legata e biologicamente attiva dell'ormone tiroideo più potente, la frazione disponibile ad agire sui tessuti. La maggior parte del T3 circolante è legata a proteine di trasporto e inattiva, perciò la quota libera riflette la reale disponibilità dell'ormone. Si misura per caratterizzare la funzione tiroidea, soprattutto insieme a TSH e T4 libero quando i risultati non sono chiari. I valori dipendono molto dal metodo di analisi e si leggono nel loro contesto clinico.", "ft4": "Il T4 libero (tiroxina) è la forma non legata del principale ormone che la tiroide rilascia nel sangue e funge da riserva che i tessuti convertono nel T3 più attivo secondo necessità. La frazione libera riflette ciò che è realmente disponibile per il corpo, indipendentemente dal livello delle proteine di trasporto. Di solito si misura insieme al TSH per distinguere i problemi della tiroide da quelli della segnalazione ipofisaria. La gravidanza e alcuni farmaci possono spostarlo.", "ferritin": "La ferritina è la principale proteina di deposito del ferro dell'organismo e il suo valore nel sangue rispecchia a grandi linee le riserve totali di ferro. Un valore basso indica riserve esaurite, mentre uno alto può riflettere un sovraccarico di ferro, oppure semplicemente un'infiammazione, perché la ferritina sale anche come reattante della fase acuta. Per questo si interpreta spesso insieme ad altri marcatori del ferro e a segni di infiammazione. È una delle misure centrali per valutare lo stato del ferro.", - "transferrin-saturation": "La saturazione della transferrina è la percentuale di transferrina — la proteina che trasporta il ferro nel sangue — attualmente carica di ferro. Collega la quantità di ferro in transito alla capacità di trasporto dell'organismo, offrendo un'istantanea della disponibilità di ferro che completa la visione delle riserve data dalla ferritina. Una saturazione bassa suggerisce un ferro scarso, mentre un valore alto può indicare un sovraccarico. Si legge insieme alla ferritina e all'assetto marziale completo.", + "transferrin-saturation": "La saturazione della transferrina è la percentuale di transferrina (la proteina che trasporta il ferro nel sangue) attualmente carica di ferro. Collega la quantità di ferro in transito alla capacità di trasporto dell'organismo, offrendo un'istantanea della disponibilità di ferro che completa la visione delle riserve data dalla ferritina. Una saturazione bassa suggerisce un ferro scarso, mentre un valore alto può indicare un sovraccarico. Si legge insieme alla ferritina e all'assetto marziale completo.", "vitamin-d": "La vitamina D si misura come 25-idrossivitamina D, la forma di deposito circolante e il marcatore standard dello stato complessivo di vitamina D. Il corpo la produce nella pelle esposta alla luce solare e ne assorbe una parte dagli alimenti; sostiene l'equilibrio del calcio, la salute delle ossa e la funzione immunitaria. I valori variano con la stagione, la latitudine, il tono della pelle e l'integrazione. Viene rilevata per stimare nel tempo se le riserve sono adeguate.", - "vitamin-b12": "La vitamina B12 (cobalamina) è una vitamina essenziale, ottenuta soprattutto da alimenti di origine animale e immagazzinata in gran parte nel fegato. È necessaria per la formazione dei globuli rossi, la sintesi del DNA e la salute del sistema nervoso. Una carenza prolungata — da scarso apporto o assorbimento alterato — può portare ad anemia e a sintomi neurologici. Si misura per valutare lo stato nutrizionale, in particolare quando apporto o assorbimento possono essere limitati.", + "vitamin-b12": "La vitamina B12 (cobalamina) è una vitamina essenziale, ottenuta soprattutto da alimenti di origine animale e immagazzinata in gran parte nel fegato. È necessaria per la formazione dei globuli rossi, la sintesi del DNA e la salute del sistema nervoso. Una carenza prolungata, da scarso apporto o assorbimento alterato, può portare ad anemia e a sintomi neurologici. Si misura per valutare lo stato nutrizionale, in particolare quando apporto o assorbimento possono essere limitati.", "folate": "Il folato (vitamina B9) è una vitamina del gruppo B presente nelle verdure a foglia verde, nei legumi e negli alimenti fortificati, centrale per la formazione dei globuli rossi e la sintesi del DNA. Lavora a stretto contatto con la vitamina B12 e una carenza dell'uno o dell'altra può produrre un tipo simile di anemia. Un folato adeguato è particolarmente importante intorno all'inizio della gravidanza per uno sviluppo sano del feto. Si misura per valutare lo stato nutrizionale e la causa di alcune anemie.", "hs-crp": "La PCR ad alta sensibilità misura la proteina C-reattiva, un marcatore che il fegato rilascia in risposta all'infiammazione, con una tecnica affinata per rilevare le basse concentrazioni rilevanti per il rischio cardiovascolare. A differenza della PCR standard usata per le infezioni acute, la versione ad alta sensibilità coglie l'infiammazione lieve e cronica legata alla malattia vascolare. Poiché qualsiasi infezione o lesione recente può aumentarla, si interpreta lontano da una malattia acuta e spesso si conferma con un prelievo ripetuto. Si legge come uno dei fattori del rischio cardiovascolare complessivo.", "creatinine": "La creatinina è un prodotto di scarto del normale metabolismo muscolare che i reni filtrano dal sangue a un ritmo abbastanza costante. Poiché la produzione è relativamente stabile, il suo valore nel sangue funge da misura pratica di quanto bene i reni eliminano gli scarti. I valori dipendono anche dalla massa muscolare, dall'idratazione e dalla dieta, perciò si interpretano di solito insieme alla velocità di filtrazione stimata. È un marcatore centrale della funzione renale.", "egfr": "L'eGFR (velocità di filtrazione glomerulare stimata) stima quanto sangue i reni filtrano ogni minuto, calcolata dalla creatinina insieme a età e sesso. Traduce un valore grezzo di creatinina in una misura della funzione renale più immediatamente interpretabile. Un eGFR in calo nel tempo è il modo principale per classificare e seguire le alterazioni renali croniche. Essendo una stima, si legge come una tendenza e non come un'unica cifra esatta.", "alt": "L'ALT (alanina aminotransferasi) è un enzima concentrato nelle cellule del fegato, rilasciato nel sangue quando queste cellule sono sotto stress o danneggiate. Essendo relativamente specifico del fegato, è uno dei principali marcatori per rilevare un danno o un'infiammazione epatica. I valori possono salire con il fegato grasso, l'epatite virale, l'alcol o alcuni farmaci. Di solito si legge insieme all'AST e agli altri marcatori del profilo epatico.", "ast": "L'AST (aspartato aminotransferasi) è un enzima presente nel fegato ma anche nel muscolo, nel cuore e nei globuli rossi, rilasciato quando uno di questi tessuti subisce un danno. Essendo meno specifico del fegato rispetto all'ALT, i due si interpretano insieme e il loro rapporto può orientare sull'origine e sulla natura del problema. Un esercizio intenso o una lesione muscolare possono aumentarlo indipendentemente dal fegato. È uno degli enzimi standard del profilo epatico.", - "ggt": "La GGT (gamma-glutamil transferasi) è un enzima del fegato e delle vie biliari che sale in particolare quando il flusso della bile è ostruito o in caso di esposizione all'alcol. Spesso si usa per chiarire perché un altro marcatore epatico è elevato — per esempio, per distinguere un'origine epatica o biliare. È sensibile ma poco specifica, perciò si legge nel contesto del resto del profilo epatico. È uno dei vari marcatori usati per valutare la salute del fegato e delle vie biliari.", + "ggt": "La GGT (gamma-glutamil transferasi) è un enzima del fegato e delle vie biliari che sale in particolare quando il flusso della bile è ostruito o in caso di esposizione all'alcol. Spesso si usa per chiarire perché un altro marcatore epatico è elevato, per esempio per distinguere un'origine epatica o biliare. È sensibile ma poco specifica, perciò si legge nel contesto del resto del profilo epatico. È uno dei vari marcatori usati per valutare la salute del fegato e delle vie biliari.", "sodium": "Il sodio è il principale elettrolita del liquido al di fuori delle cellule del corpo e il motore fondamentale dell'equilibrio dei liquidi e del volume del sangue. L'organismo lo regola in modo stretto tramite la sete, gli ormoni e i reni, perciò il suo valore nel sangue riflette più l'equilibrio tra acqua e sale che il solo apporto alimentare. Valori anomali segnalano di solito un disturbo dell'equilibrio idrico più che del sodio stesso. È una parte centrale del pannello metabolico di base e degli elettroliti.", "potassium": "Il potassio è il principale elettrolita all'interno delle cellule del corpo ed è essenziale per la trasmissione nervosa e per il ritmo regolare del muscolo cardiaco. Il suo valore nel sangue è mantenuto entro un intervallo ristretto dai reni e dai segnali ormonali, perché sia i valori alti sia quelli bassi possono disturbare l'attività elettrica del cuore. I valori possono essere influenzati dalla funzione renale, dall'idratazione e da diversi farmaci comuni. È una misura centrale del pannello degli elettroliti.", "hemoglobin": "L'emoglobina è la proteina contenente ferro dei globuli rossi che lega l'ossigeno nei polmoni e lo trasporta ai tessuti. La sua concentrazione è la principale misura della capacità del sangue di trasportare ossigeno e il marcatore centrale per rilevare l'anemia. Valori bassi possono riflettere perdita di sangue, carenza di ferro o di vitamine, o una produzione ridotta, mentre valori alti possono seguire una disidratazione o condizioni di scarso ossigeno. È un pilastro dell'emocromo completo.", @@ -9309,8 +9481,8 @@ "platelets": "Le piastrine sono piccoli frammenti cellulari del sangue che si aggregano e formano il tappo iniziale che arresta il sanguinamento da un vaso danneggiato. Il loro numero riflette l'equilibrio tra la produzione nel midollo osseo e il consumo o la distruzione. Un numero basso può aumentare la tendenza al sanguinamento, mentre uno alto può seguire un'infiammazione o altri stimoli. Fanno parte dell'emocromo completo e sono una misura di base della capacità di coagulazione.", "apob": "L'apolipoproteina B è la proteina strutturale presente su ciascuna delle particelle di lipoproteine aterogene, quindi misurarla significa contare direttamente quelle particelle anziché il colesterolo che trasportano. Poiché c'è una molecola di ApoB su ogni particella di LDL, VLDL e affini, può cogliere un rischio cardiovascolare che un valore di colesterolo LDL non rileva, soprattutto quando il numero di particelle e il loro contenuto di colesterolo divergono. Per questo è sempre più usata per affinare la valutazione del rischio basata sui lipidi. Si interpreta insieme al consueto profilo lipidico.", "lp-a": "La lipoproteina(a) è una particella simile all'LDL con una proteina aggiuntiva attaccata, il cui livello è in gran parte determinato dalla genetica e resta piuttosto stabile per tutta la vita. Una Lp(a) elevata è un fattore di rischio indipendente ed ereditario di malattia cardiovascolare e di restringimento della valvola aortica. Poiché varia poco, di solito si misura una sola volta per stabilire il rischio di una persona lungo l'arco della vita, anziché seguirla ripetutamente. Si esprime in unità molari e si legge nell'ambito del rischio cardiovascolare complessivo.", - "fasting-insulin": "L'insulina a digiuno è il livello di insulina — l'ormone che fa passare il glucosio dal sangue alle cellule — misurato dopo un digiuno notturno. Insieme alla glicemia a digiuno, aiuta a stimare la resistenza all'insulina, lo stato in cui il corpo ha bisogno di più insulina per mantenere il glucosio nel suo intervallo. Un'insulina a digiuno alta con una glicemia normale può essere un segno precoce che questa compensazione è in atto. Si legge nell'ambito di un quadro di salute metabolica e non isolatamente.", - "omega-3-index": "L'indice omega-3 è la quota di EPA e DHA — i due acidi grassi omega-3 marini a catena lunga — tra gli acidi grassi delle membrane dei globuli rossi. Poiché i globuli rossi si rinnovano lentamente, riflette l'apporto di omega-3 a lungo termine anziché un pasto recente. Un indice più alto è stato associato, in studi osservazionali, a un rischio cardiovascolare più basso. Si segue come un marcatore stabile dello stato di omega-3 che risponde alla dieta nell'arco di mesi." + "fasting-insulin": "L'insulina a digiuno è il livello di insulina (l'ormone che fa passare il glucosio dal sangue alle cellule) misurato dopo un digiuno notturno. Insieme alla glicemia a digiuno, aiuta a stimare la resistenza all'insulina, lo stato in cui il corpo ha bisogno di più insulina per mantenere il glucosio nel suo intervallo. Un'insulina a digiuno alta con una glicemia normale può essere un segno precoce che questa compensazione è in atto. Si legge nell'ambito di un quadro di salute metabolica e non isolatamente.", + "omega-3-index": "L'indice omega-3 è la quota di EPA e DHA (i due acidi grassi omega-3 marini a catena lunga) tra gli acidi grassi delle membrane dei globuli rossi. Poiché i globuli rossi si rinnovano lentamente, riflette l'apporto di omega-3 a lungo termine anziché un pasto recente. Un indice più alto è stato associato, in studi osservazionali, a un rischio cardiovascolare più basso. Si segue come un marcatore stabile dello stato di omega-3 che risponde alla dieta nell'arco di mesi." }, "apob": "Apolipoproteina B (ApoB)", "lp-a": "Lipoproteina(a)", @@ -9343,12 +9515,12 @@ "dateLabel": "Data", "refLowLabel": "Riferimento inferiore", "refHighLabel": "Riferimento superiore", - "newBiomarker": "Nuovo biomarcatore — verrà creato", + "newBiomarker": "Nuovo biomarcatore, verrà creato", "linksExisting": "Collegato a {name} esistente", "duplicateWarning": "Esiste già un valore corrispondente", "lowConfidence": "Controlla questo valore", "lowConfidenceBadge": "Bassa attendibilità", - "valueUnreadable": "Valore illeggibile — inseriscilo manualmente", + "valueUnreadable": "Valore illeggibile: inseriscilo manualmente", "saveSelected": "Salva {count} selezionati", "discardAll": "Scarta tutto", "savedToast": "{count} risultati di laboratorio salvati", @@ -9464,6 +9636,10 @@ "vaccinations": { "label": "Vaccinazioni", "description": "Il libretto vaccinale strutturato: dose, serie, prossimo richiamo." + }, + "timeline": { + "label": "Cronologia", + "description": "Anni a colpo d’occhio: malattie, farmaci e visite accanto ai tuoi valori, più i tuoi eventi di vita." } }, "moduleOff": { @@ -9495,8 +9671,8 @@ }, "form": { "date": "Data", - "catalogLabel": "Vaccino (catalogo)", - "identityHint": "Scegli dal catalogo o scrivilo sotto: ne basta uno.", + "catalogLabel": "Vaccino", + "identityHint": "Scegli un vaccino dall'elenco o scrivilo sotto. Ne basta uno.", "catalogNone": "Scegli un vaccino", "catalogSearch": "Cerca vaccini", "catalogNoMatch": "Nessuna voce del catalogo trovata", @@ -9616,10 +9792,31 @@ "overdue": "Richiamo scaduto dal {date}", "dueSoon": "Richiamo previsto il {date}", "current": "Prossimo richiamo il {date}" + }, + "custom": { + "title": "Vaccini personali", + "add": "Aggiungi vaccino personale", + "name": "Nome", + "components": "Protegge da", + "seriesDoses": "Dosi del ciclo primario", + "boosterMonths": "Richiamo ogni (mesi)", + "saved": "Vaccino salvato", + "deleted": "Vaccino rimosso", + "deleteConfirm": "Rimuovere questo vaccino? Le dosi già registrate mantengono il loro nome.", + "saveFailed": "Impossibile salvare il vaccino.", + "nameTaken": "Hai già un vaccino con questo nome.", + "editTitle": "Modifica vaccino personale", + "description": "Un vaccino assente dal catalogo, contato come qualsiasi voce del catalogo.", + "componentsHint": "Scegli tutte le malattie da cui protegge.", + "boosterHint": "Lascia vuoto se non serve un richiamo.", + "protectsAgainst": "Protegge da {list}.", + "deleteTitle": "Rimuovere il vaccino personale?", + "sectionDescription": "Vaccini aggiunti da te perché il catalogo non li elenca.", + "catalogGroup": "Catalogo" } }, "illness": { - "title": "Diario delle malattie", + "title": "Malattia", "subtitle": "Un registro retrospettivo dei tuoi episodi di malattia e disturbi.", "settings": { "viewDescription": "Scegli se gli episodi appaiono come schede o come elenco compatto.", @@ -9758,7 +9955,7 @@ "loadError": "Impossibile caricare questo episodio.", "backToList": "Torna al diario", "detail": { - "note": "Note" + "note": "Nota" }, "edit": { "title": "Modifica episodio", @@ -9807,7 +10004,7 @@ "dismiss": "Non ora", "applied": "Aggiunto", "dismissed": "Ignorato", - "failed": "Impossibile aggiungere — riprova", + "failed": "Impossibile aggiungere. Riprova", "checkup": { "title": "Aggiungere un promemoria di controllo?" }, @@ -9859,7 +10056,7 @@ }, "refusal": { "outOfScope": "Posso aiutarti solo con i dati di salute del tuo registro: misurazioni, farmaci, umore, tendenze. Non rispondo a domande al di fuori di questo ambito.", - "promptInjection": "Ho notato istruzioni che cercano di aggirare le mie direttive. Sono il Coach di HealthLog e riassumo soltanto i tuoi dati di salute — riformula la domanda in questi termini, per favore.", + "promptInjection": "Ho notato istruzioni che cercano di aggirare le mie direttive. Sono il Coach di HealthLog e riassumo soltanto i tuoi dati di salute: riformula la domanda in questi termini, per favore.", "conversationPoisoned": "Un messaggio precedente di questa conversazione contiene istruzioni che cercano di aggirare le mie direttive. Inizia una nuova conversazione, per favore." }, "noRecordedData": "Non hai misurazioni registrate su quello che chiedi, quindi non posso darti alcun valore. Appena ne inserisci qualcuna, richiedimelo e userò i tuoi dati.", @@ -9895,7 +10092,9 @@ "illness": "Malattia e recupero", "cycle": "Ciclo", "correlations": "Relazioni tra parametri", - "snapshot": "Riepilogo dei dati" + "snapshot": "Riepilogo dei dati", + "environment": "Meteo e aria", + "day": "Una giornata" }, "window": { "last7days": "ultimi 7 giorni", @@ -10084,10 +10283,10 @@ "saved": "Salvato", "error": "Qualcosa è andato storto. Riprova.", "instrument": { - "phq9": "PHQ-9 · umore", - "gad7": "GAD-7 · preoccupazione", - "who5": "WHO-5 · benessere", - "sci": "SCI · sonno" + "phq9": "PHQ-9 (umore)", + "gad7": "GAD-7 (preoccupazione)", + "who5": "WHO-5 (benessere)", + "sci": "SCI (sonno)" }, "instrumentSub": { "phq9": "Screening della depressione, 9 domande", @@ -10108,7 +10307,7 @@ "2": "Molto difficile", "3": "Estremamente difficile" }, - "functionalOptionalHint": "Facoltativo — puoi inviare anche senza rispondere a questa domanda.", + "functionalOptionalHint": "Facoltativo: puoi inviare anche senza rispondere a questa domanda.", "result": { "title": "Il tuo risultato", "totalLabel": "Punteggio totale", @@ -10528,12 +10727,34 @@ "daylight": "Ore di luce", "precip": "Precipitazioni", "pressureMean": "Pressione atmosferica", - "pressureDelta": "Variazione di pressione" + "pressureDelta": "Variazione di pressione", + "pm25": "Particolato fine (PM2,5)", + "pm10": "Particolato (PM10)", + "no2": "Biossido di azoto", + "ozone8h": "Ozono (massimo su 8 ore)", + "europeanAqi": "Indice europeo di qualità dell’aria", + "uvIndexMax": "Indice UV (massimo)", + "dustMax": "Polvere del Sahara", + "pollenMax": "Pollini (valore più alto)" + }, + "chip": { + "day": "Meteo del {date}", + "pollenHigh": "Molti pollini: {kinds}", + "hotNight": "Notte calda", + "veryPoorAir": "Aria molto scadente" + }, + "pollen": { + "alder": "ontano", + "birch": "betulla", + "grass": "graminacee", + "mugwort": "artemisia", + "olive": "olivo", + "ragweed": "ambrosia" } }, "documents": { "title": "Documenti", - "subtitle": "Ogni lettera, referto e scansione in un unico posto ricercabile — cifrato a riposo.", + "subtitle": "Ogni lettera, referto e scansione in un unico posto ricercabile, cifrato a riposo.", "pageUpload": "Carica", "ownerOnlyUpload": "Solo il titolare della cartella può aggiungere documenti.", "kind": { @@ -10575,15 +10796,15 @@ }, "error": { "fileTooLarge": "Questo file supera il limite di {maxSize}.", - "quotaExceeded": "Lo spazio è pieno — {used} di {quota} usati.", + "quotaExceeded": "Lo spazio è pieno: {used} di {quota} usati.", "unsupportedType": "Questo tipo di file non è supportato.", "purged": "Questo documento è stato rimosso definitivamente e non può essere ripristinato.", "duplicateExists": "Un documento identico è già archiviato.", - "rateLimited": "Troppi caricamenti — riprova tra qualche minuto.", + "rateLimited": "Troppi caricamenti. Riprova tra qualche minuto.", "generic": "Caricamento non riuscito. Riprova." }, "toast": { - "duplicate": "Già archiviato — il documento esistente viene evidenziato.", + "duplicate": "Già archiviato. Il documento esistente è evidenziato.", "deleted": "Documento eliminato", "restoreFailed": "Impossibile ripristinare il documento." }, @@ -10606,7 +10827,7 @@ }, "empty": { "title": "Il tuo archivio documenti è vuoto", - "description": "Conserva lettere mediche, referti di laboratorio, imaging, prescrizioni e certificati vaccinali — cifrati e ricercabili per tipo, patologia e anno.", + "description": "Conserva lettere mediche, referti di laboratorio, imaging, prescrizioni e certificati vaccinali. Sono cifrati e ricercabili per tipo, patologia e anno.", "action": "Carica un primo documento", "noMatchesTitle": "Nessun documento corrispondente", "noMatchesDescription": "Nulla corrisponde alla ricerca e ai filtri attuali." @@ -10646,9 +10867,9 @@ "linkCondition": "Collega", "noConditions": "Non collegato a una patologia", "previewUnavailable": "Nessuna anteprima integrata per questo tipo di file", - "uploadedMeta": "Aggiunto il {date} · {size}", + "uploadedMeta": "Aggiunto il {date}, {size}", "importedFrom": "Importato da {system}", - "importedFromWithId": "Importato da {system} · ID {id}", + "importedFromWithId": "Importato da {system}, ID {id}", "sourceOther": "un altro sistema", "share": "Condividi", "labValuesLink": "Vedi {count} valori di laboratorio", @@ -10748,7 +10969,7 @@ "CONDITION": "Condizione", "MEDICATION_STATEMENT": "Farmaco" }, - "statedRange": "intervallo indicato {range}", + "statedRange": "Intervallo indicato {range}", "needsCheck": "Controlla questo valore rispetto al documento, poi salvalo.", "verify": "Controlla", "verifySave": "Salva valore", @@ -10864,6 +11085,21 @@ "finishedHint": "Cerca di nuovo o cambia un filtro per scegliere altri documenti.", "maxReached": "Puoi importare al massimo {max} documenti alla volta.", "noMatchHint": "Prova con parte del nome, oppure azzera etichetta e date." + }, + "customize": "Personalizza visualizzazione", + "layout": { + "viewDescription": "Schede con anteprima oppure un elenco compatto.", + "arrangementHeading": "Disposizione", + "arrangementDescription": "Ogni mese in un blocco a sé oppure tutto di seguito su tutta la larghezza.", + "stacked": "Mesi impilati", + "flow": "Continuo", + "saveFailed": "Impossibile salvare la visualizzazione." + }, + "deleteConfirm": { + "titleOne": "Eliminare questo documento?", + "titleMany": "Eliminare {count} documenti?", + "body": "Puoi annullare subito dopo. Dopo 30 giorni viene rimosso definitivamente.", + "action": "Elimina" } }, "selfDescription": { @@ -10950,7 +11186,7 @@ "genericDescription": "Una misurazione che monitori. Aggiungi valori per seguirla nel tempo.", "emptyTitle": "Ancora nessun valore", "emptyDescription": "Aggiungi il primo valore per iniziare a rappresentare questa metrica.", - "valuesTitle": "{metric} — tutti i valori", + "valuesTitle": "{metric}: tutti i valori", "valuesDescription": "Tutti i valori registrati per questa metrica.", "backToMetric": "Torna a {metric}" } @@ -11003,20 +11239,20 @@ } }, "hydration": { - "referenceMeta": "Apporto di riferimento {value} {unit}/giorno · EFSA" + "referenceMeta": "Apporto di riferimento {value} {unit}/giorno (EFSA)" }, "caffeine": { - "ceilingMeta": "Limite di sicurezza {value} {unit}/giorno · EFSA" + "ceilingMeta": "Limite di sicurezza {value} {unit}/giorno (EFSA)" }, "micronutrients": { "title": "Vitamine e minerali", - "rowMeta": "{days} di {window} giorni · rif. {value} {unit} (EFSA)", + "rowMeta": "{days} di {window} giorni, rif. {value} {unit} (EFSA)", "rowMetaNoRef": "{days} di {window} giorni", "footer": "{tracked} di {total} micronutrienti monitorati hanno dati in questo periodo.", "emptyTitle": "Nessun dato sui micronutrienti ancora sincronizzato", "loadError": "Non è stato possibile caricare i tuoi dati sui nutrienti" }, - "sparseNote": "Un valore basso qui di solito indica una registrazione poco frequente, non una carenza — i totali giornalieri di Apple Health riflettono ciò che le tue app hanno registrato, non l'apporto alimentare totale." + "sparseNote": "Un valore basso qui di solito indica una registrazione poco frequente, non una carenza: i totali giornalieri di Apple Health riflettono ciò che le tue app hanno registrato, non l'apporto alimentare totale." }, "daily": { "line": { @@ -11025,7 +11261,7 @@ }, "push": { "title": "Il tuo riepilogo mattutino", - "bodyProvisional": "{line} · Il sonno della scorsa notte non è ancora disponibile." + "bodyProvisional": "{line}\nIl sonno della scorsa notte non è ancora disponibile." }, "item": { "doseWindow": { @@ -11043,7 +11279,7 @@ }, "syncIssue": { "title": "La sincronizzazione richiede attenzione", - "body": "{integration} non si sincronizza — riconnettilo per mantenere aggiornati i tuoi dati." + "body": "{integration} non si sincronizza. Riconnettilo per mantenere aggiornati i tuoi dati." }, "coachCheckin": { "title": "È il momento di rivedere un piano", @@ -11055,10 +11291,10 @@ "tensionWindow": { "title": "Forse un po' di tensione oggi", "body": { - "morning": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato stamattina mentre eri fermo — forse un po' di tensione.", - "afternoon": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato questo pomeriggio mentre eri fermo — forse un po' di tensione.", - "evening": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato stasera mentre eri fermo — forse un po' di tensione.", - "night": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato durante la notte mentre eri fermo — forse un po' di tensione." + "morning": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato stamattina mentre eri fermo: forse un po' di tensione.", + "afternoon": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato questo pomeriggio mentre eri fermo: forse un po' di tensione.", + "evening": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato stasera mentre eri fermo: forse un po' di tensione.", + "night": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato durante la notte mentre eri fermo: forse un po' di tensione." } }, "ecgNewRecording": { @@ -11083,9 +11319,9 @@ }, "upcomingVisit": { "title": "Appuntamento in arrivo", - "bodyToday": "{what} — oggi", - "bodyTomorrow": "{what} — domani", - "bodyDayAfterTomorrow": "{what} — dopodomani" + "bodyToday": "{what}, oggi", + "bodyTomorrow": "{what}, domani", + "bodyDayAfterTomorrow": "{what}, dopodomani" } }, "action": { @@ -11413,7 +11649,7 @@ "managed": { "title": "Profili di cui ti occupi", "description": "Cartelle sanitarie senza credenziali proprie.", - "explainer": "Un profilo gestito è una cartella sanitaria per qualcuno che non accede da sé — un bambino, o una persona di cui ti prendi cura. Non ha credenziali né indirizzo e-mail, e tu ne diventi la prima persona responsabile. Più avanti puoi aggiungerne altre, e ognuna può entrare nella cartella per tenerla aggiornata.", + "explainer": "Un profilo gestito è una cartella sanitaria per qualcuno che non accede da sé, come un bambino o una persona di cui ti prendi cura. Non ha credenziali né indirizzo e-mail, e tu ne diventi la prima persona responsabile. Più avanti puoi aggiungerne altre, e ognuna può entrare nella cartella per tenerla aggiornata.", "nameLabel": "Nome", "namePlaceholder": "Il nome con cui riconoscerai questa cartella", "nameHint": "Fino a 80 caratteri. Compare ovunque appaia questa cartella.", @@ -11435,7 +11671,7 @@ "creating": "Creazione in corso…", "created": "{name} è pronto. Puoi entrare in quella cartella dal menu dell'account.", "errorStepUp": "Conferma il tuo secondo fattore e riprova.", - "errorOffline": "Non è arrivato al server. Non è cambiato nulla — controlla la connessione e riprova.", + "errorOffline": "Non è arrivato al server. Non è cambiato nulla. Controlla la connessione e riprova.", "errorInvalid": "Il server ha rifiutato questi dati. Controlla nome, data e fuso orario.", "errorFailed": "Non è stato possibile creare il profilo.", "edit": "Modifica", @@ -11446,7 +11682,7 @@ "editErrorFailed": "Non è stato possibile salvare le modifiche.", "delete": "Elimina profilo", "deleteTitle": "Eliminare {name}?", - "deleteBody": "Tutto ciò che è stato registrato per {name} sparisce insieme al profilo — misurazioni, farmaci, documenti e note. Non si può annullare.", + "deleteBody": "Tutto ciò che è stato registrato per {name} sparisce insieme al profilo: misurazioni, farmaci, documenti e note. Non si può annullare.", "deleteGuardiansOne": "{count} persona si occupa oggi di questa cartella e la perderà.", "deleteGuardiansFew": "{count} persone si occupano oggi di questa cartella e la perderanno.", "deleteGuardiansOther": "{count} persone si occupano oggi di questa cartella e la perderanno.", @@ -11475,7 +11711,47 @@ "guardianErrorFailed": "Non è stato possibile inviare l'invito.", "errorMfaRequired": "Serve un secondo fattore e il tuo account non ne ha ancora uno. Impostane uno in Impostazioni → Sicurezza e riprova.", "rosterLoadError": "Non è stato possibile caricare chi si occupa di questa cartella.", - "errorRateLimit": "Troppi profili creati nell'ultima ora. Aspetta un po' e riprova." + "errorRateLimit": "Troppi profili creati nell'ultima ora. Aspetta un po' e riprova.", + "handover": { + "action": "Consegna", + "title": "Consegna a {name}", + "description": "Dai a {name} un accesso proprio. Nulla viene copiato o spostato: la cartella diventa il suo account.", + "guardiansLabel": "Accesso dopo la consegna", + "guardiansHint": "La tua proposta. {name} la vede e decide dopo l'accesso.", + "access": { + "end": "Nessun accesso", + "read": "Solo lettura", + "manage": "Gestire" + }, + "expiryLabel": "Link valido per", + "expiry1": "1 giorno", + "expiry7": "7 giorni", + "expiry14": "14 giorni", + "minorHint": "{name} ha meno di 16 anni. Vale la pena mantenere l'accesso in lettura e parlare insieme di questo passo.", + "irreversible": "Appena {name} usa il link, la consegna non si può annullare. La cartella sarà sua e deciderà chi mantiene l'accesso.", + "afterwards": "{name} conferma di persona l'avviso e ogni consenso all'IA. I promemoria di questa cartella non ti raggiungeranno più.", + "replaceNote": "Creare un nuovo link ritira quello aperto.", + "create": "Crea link", + "creating": "Creazione…", + "linkOnce": "Questo link viene mostrato solo ora. Consegnalo a {name} di persona o tramite un canale di fiducia, oppure fai scansionare il codice.", + "qrAlt": "Codice QR del link di consegna per {name}", + "urlLabel": "Link di consegna", + "copy": "Copia", + "copied": "Link copiato", + "copyError": "Impossibile copiare il link. Selezionalo e copialo a mano.", + "linkExpires": "Valido fino al {date}.", + "done": "Fatto", + "pending": "Un link di consegna è aperto fino al {date}.", + "replace": "Nuovo link", + "revoke": "Ritira link", + "revoked": "Link ritirato", + "revokeFailed": "Impossibile ritirare il link. Riprova.", + "errorFailed": "Impossibile creare il link di consegna.", + "errorRosterChanged": "Nel frattempo è cambiato chi si occupa della cartella. Chiudi e riapri.", + "oidcOnly": "Non è possibile consegnare un profilo finché questo server consente solo il single sign-on.", + "loadError": "Impossibile caricare lo stato della consegna.", + "entryHint": "Quando {name} vuole tenere questa cartella in autonomia." + } }, "toast": { "savedTo": "Salvato nella cartella di {name}" @@ -11594,7 +11870,13 @@ "symptomEventDelete": "{name} ha eliminato una voce di sintomo", "symptomDefinitionCreate": "{name} ha aggiunto un sintomo", "symptomDefinitionUpdate": "{name} ha modificato un sintomo", - "symptomDefinitionDelete": "{name} ha rimosso un sintomo" + "symptomDefinitionDelete": "{name} ha rimosso un sintomo", + "customVaccineCreate": "{name} ha aggiunto un vaccino personale", + "customVaccineUpdate": "{name} ha modificato un vaccino personale", + "customVaccineDelete": "{name} ha rimosso un vaccino personale", + "lifeEventCreate": "{name} ha aggiunto un evento della vita", + "lifeEventUpdate": "{name} ha modificato un evento della vita", + "lifeEventDelete": "{name} ha rimosso un evento della vita" }, "section": { "measurements": { @@ -11611,7 +11893,7 @@ }, "profile": { "label": "Anamnesi", - "detail": "Allergie, anamnesi familiare, i dati del tuo profilo sanitario, le tue visite mediche e studi, e il tuo libretto vaccinale." + "detail": "Allergie, anamnesi familiare, i dati del tuo profilo sanitario, le tue visite mediche e studi e il tuo libretto vaccinale." }, "illness": { "label": "Malattie", @@ -11644,12 +11926,28 @@ "notFound": "Questo accesso non esiste più.", "revokeFailed": "Non è stato possibile terminare l'accesso.", "renounceFailed": "Non è stato possibile restituire l'accesso.", - "profileNotFound": "Quel profilo non c'è più — potrebbe essere già stato eliminato, o il tuo accesso è terminato.", + "profileNotFound": "Quel profilo non c'è più: potrebbe essere già stato eliminato, o il tuo accesso è terminato.", "deleteProfileFailed": "Non è stato possibile eliminare il profilo.", "removeGuardianFailed": "Non è stato possibile terminare quell'accesso." }, "anamnesis": { "relativeNote": "Una voce di anamnesi familiare descrive i parenti di questa persona. Aggiungila solo se vorrebbe vederla registrata qui." + }, + "handoverDecision": { + "title": "Chi mantiene l'accesso alla tua cartella", + "description": "Decidi cosa possono fare d'ora in poi le persone che hanno tenuto la tua cartella.", + "proposed": "Proposto: {access}", + "settled": "Già modificato in Accesso condiviso", + "empty": "Nessun altro aveva accesso alla tua cartella.", + "later": "Puoi cambiarlo in qualsiasi momento in Impostazioni, Accesso condiviso.", + "save": "Conferma", + "saving": "Salvataggio…", + "decideLater": "Più tardi", + "errorFailed": "Impossibile salvare la tua decisione. Riprova.", + "loadError": "Impossibile caricare chi teneva la tua cartella.", + "notificationsTitle": "Imposta i tuoi promemoria", + "notificationsBody": "Finora i promemoria di questa cartella arrivavano alle persone che la tenevano. D'ora in poi arrivano a te, appena aggiungi un canale di notifica.", + "notificationsAction": "Apri le notifiche" } }, "encounters": { @@ -11670,7 +11968,7 @@ "both": "Entrambi i lati", "none": "Non indicato" }, - "bodySiteWithSide": "{site} · {side}", + "bodySiteWithSide": "{site} ({side})", "sectionTitle": "Appuntamenti", "sectionDescription": "Visite mediche e appuntamenti fissati in un unico posto.", "add": "Appuntamento", @@ -11762,7 +12060,6 @@ "title": "Studi e medici", "description": "La tua rubrica: scrivi uno studio una volta e selezionalo ovunque.", "linkLabel": "Rubrica", - "add": "Voce", "searchPlaceholder": "Cerca nome, studio o specializzazione", "loadError": "Impossibile caricare la rubrica.", "emptyTitle": "Nessuna voce", @@ -11859,7 +12156,6 @@ "unreadableLabel": "Sintomo illeggibile", "intensityPill": "{value}/10", "entry": { - "sheetTitle": "Registra sintomo", "sheetDescription": "Uno dei tuoi sintomi, quanto forte e quando.", "symptom": "Sintomo", "symptomRequired": "Scegli un sintomo.", @@ -11908,5 +12204,411 @@ "title": "Sintomi registrati", "filed": "registrato con questa malattia" } + }, + "day": { + "scoreOfMax": "su {max}", + "cycleDay": "Giorno {n} del ciclo", + "cyclePhaseLine": { + "MENSTRUAL": "Giorno {n} del ciclo, fase mestruale", + "FOLLICULAR": "Giorno {n} del ciclo, fase follicolare", + "OVULATORY": "Giorno {n} del ciclo, fase ovulatoria", + "LUTEAL": "Giorno {n} del ciclo, fase luteale" + }, + "previousDay": "Giorno precedente", + "nextDay": "Giorno successivo", + "close": "Chiudi giorno", + "hidePanel": "Nascondi giorno", + "showPanel": "Mostra {date}", + "openDay": "Apri giorno", + "viewWholeDay": "Vedi l’intera giornata", + "captureForDay": "Registra per questo giorno", + "empty": "Non ci sono voci per questo giorno.", + "notShared": "Alcune aree non sono condivise con te.", + "inTimeline": "Nella cronologia", + "askCoach": "Chiedi al Coach di questo giorno", + "chartHint": "Fai clic su un punto per aprire il suo giorno.", + "loadFailed": "Impossibile caricare questo giorno.", + "dayOf": "Giorno {n} di {m}", + "dayN": "Giorno {n}", + "usualRange": "Di solito da {lo} a {hi}", + "groups": { + "scores": "Punteggi", + "running": "In corso quel giorno", + "values": "Valori", + "happened": "Cosa è successo" + }, + "notable": { + "extremeHigh": "Valore giornaliero più alto dal {since}", + "extremeLow": "Valore giornaliero più basso dal {since}", + "firstValue": "Prima misurazione di questo tipo", + "gap": "{days} giorni senza misurazione" + }, + "previousDayShortcut": "Giorno precedente (Alt + freccia sinistra)", + "nextDayShortcut": "Giorno successivo (Alt + freccia destra)", + "captureShort": "Registra", + "inTimelineShort": "Cronologia", + "askCoachPrompt": "Cosa è successo il {date}?", + "chartHintTouch": "Tocca un punto per vederne il valore.", + "chartHintBar": "Fai clic su una barra per aprire il suo giorno.", + "chartHintTouchBar": "Tocca una barra per vederne i valori.", + "chartHintCell": "Fai clic su un giorno per aprirlo.", + "chartHintTouchCell": "Tocca un giorno per vederne il valore.", + "coachChips": "Giorni in questa risposta", + "description": "Tutto ciò che le tue voci contengono per questo giorno.", + "openDocument": "Apri documento", + "previousReading": "Prima, {date}", + "rugLegend": "I punti sotto l’asse indicano i giorni con voci, un anello un giorno rilevante.", + "sheetExpand": "Espandi a tutta altezza", + "sheetShrink": "Riduci", + "valuesLegend": "Punto per questo giorno, barra per i 30 giorni precedenti", + "valuesAllOne": "Tutti i valori", + "valuesAllFew": "Tutti i {count} valori", + "valuesAllOther": "Tutti i {count} valori", + "valuesFewer": "Meno valori", + "countEntriesOne": "1 voce", + "countEntriesFew": "{count} voci", + "countEntriesOther": "{count} voci", + "meanOfReadingsOne": "Media di 1 misurazione", + "meanOfReadingsFew": "Media di {count} misurazioni", + "meanOfReadingsOther": "Media di {count} misurazioni", + "sinceLastVisit": { + "title": "Dall’ultima visita del {date}", + "countOne": "1 voce", + "countFew": "{count} voci", + "countOther": "{count} voci", + "allOne": "Mostra tutto", + "allFew": "Mostra tutte le {count}", + "allOther": "Mostra tutte le {count}", + "fewer": "Mostra meno", + "note": "Raccolto dalle tue voci, senza valutazioni. Ogni data apre l’intera giornata.", + "report": "Referto medico dal {date} a oggi" + }, + "travel": "In viaggio", + "event": { + "started": "Inizio: {label}", + "ended": "Fine: {label}", + "paused": "Pausa: {label}", + "resumed": "Ripresa: {label}", + "illnessBegan": "Inizio: {label}", + "illnessResolved": "Guarigione: {label}" + }, + "pickDate": "Scegli una data", + "pickerTitle": "Vai a un giorno", + "today": "Oggi", + "hasEntries": "con voci", + "dayNSince": "Giorno {n}, dal {date}", + "sinceDate": "Dal {date}" + }, + "timeline": { + "title": "Cronologia", + "subtitle": "Cosa è successo e quando, accanto ai tuoi valori.", + "standing": "Permanente", + "startUnknown": "inizio sconosciuto", + "zoom": { + "all": "Tutto", + "year": "12 mesi", + "quarter": "3 mesi", + "range": "Periodo" + }, + "lanes": { + "life": "Vita", + "illness": "Malattie", + "allergies": "Allergie", + "medications": "Farmaci", + "vaccinations": "Vaccinazioni", + "visits": "Medico e interventi", + "labs": "Esami", + "documents": "Documenti", + "cycle": "Ciclo" + }, + "readiness": { + "title": "Cosa contiene già la tua cronologia", + "subtitle": "Un inventario onesto prima di aprirla.", + "menuEntry": "Copertura dei dati", + "hint": "Le corsie vuote restano nascoste finché non c’è qualcosa. I moduli disattivati non compaiono mai. Ritroverai questa panoramica più avanti in Copertura dei dati, nella cronologia.", + "later": "Più tardi", + "open": "Apri cronologia", + "verdict": { + "carries": "È già ben fornita.", + "thin": "Ancora scarna. Per ora la cronologia mostra soprattutto i tuoi valori." + }, + "status": { + "carries": "fornita", + "thin": "scarna", + "empty": "vuota" + }, + "lanes": { + "values": "Valori", + "mood": "Umore", + "environment": "Ambiente", + "life": "Eventi di vita" + }, + "meter": "{carrying} corsie su {total} hanno contenuto", + "carriesDetail": "{carrying} corsie su {total} danno una panoramica da {since}.", + "loadFailed": "Impossibile caricare l’inventario.", + "card": { + "title": "Cosa contiene già la cronologia", + "summary": "Risale fino a {since}; {carrying} corsie su {total} hanno già contenuto.", + "dismiss": "Nascondi" + }, + "detail": { + "valuesOne": "{count} misura da {since}", + "valuesFew": "{count} misure da {since}", + "valuesOther": "{count} misure da {since}", + "illnessOne": "{count} episodio; cronici: {chronic}", + "illnessFew": "{count} episodi; cronici: {chronic}", + "illnessOther": "{count} episodi; cronici: {chronic}", + "allergiesOne": "{count} allergia", + "allergiesFew": "{count} allergie", + "allergiesOther": "{count} allergie", + "medicationsOne": "{count} farmaco; senza data di inizio: {missingStart}", + "medicationsFew": "{count} farmaci; senza data di inizio: {missingStart}", + "medicationsOther": "{count} farmaci; senza data di inizio: {missingStart}", + "medicationsDatedOne": "{count} farmaco con data di inizio", + "medicationsDatedFew": "{count} farmaci con data di inizio", + "medicationsDatedOther": "{count} farmaci con data di inizio", + "vaccinationsOne": "{count} vaccinazione", + "vaccinationsFew": "{count} vaccinazioni", + "vaccinationsOther": "{count} vaccinazioni", + "visitsOne": "{count} visita, {procedures} con intervento", + "visitsFew": "{count} visite, {procedures} con intervento", + "visitsOther": "{count} visite, {procedures} con intervento", + "visitsPlannedOne": "{count} visita, solo pianificata", + "visitsPlannedFew": "{count} visite, solo pianificate", + "visitsPlannedOther": "{count} visite, solo pianificate", + "labsOne": "{count} giorno con referti", + "labsFew": "{count} giorni con referti", + "labsOther": "{count} giorni con referti", + "documentsOne": "{count} documento con data", + "documentsFew": "{count} documenti con data", + "documentsOther": "{count} documenti con data", + "lifeOne": "{count} evento di vita", + "lifeFew": "{count} eventi di vita", + "lifeOther": "{count} eventi di vita", + "cycleOne": "{count} ciclo", + "cycleFew": "{count} cicli", + "cycleOther": "{count} cicli", + "moodOne": "{count} voce da {since}", + "moodFew": "{count} voci da {since}", + "moodOther": "{count} voci da {since}", + "environmentOne": "{count} giorno con dati ambientali", + "environmentFew": "{count} giorni con dati ambientali", + "environmentOther": "{count} giorni con dati ambientali", + "empty": "Ancora nulla" + }, + "gap": { + "valuesEmpty": "Collega le fonti", + "illnessEmpty": "Aggiungi episodio", + "medicationsWithoutStart": "Completa", + "visitsEmpty": "Aggiungi visita", + "documentsUndated": "Controlla", + "labsEmpty": "Aggiungi referto", + "vaccinationsEmpty": "Aggiungi vaccinazioni", + "lifeEventsEmpty": "Aggiungi" + }, + "gapLine": { + "medicationsWithoutStartOne": "{count} farmaco senza data di inizio", + "medicationsWithoutStartFew": "{count} farmaci senza data di inizio", + "medicationsWithoutStartOther": "{count} farmaci senza data di inizio", + "documentsUndatedOne": "{count} documento senza data", + "documentsUndatedFew": "{count} documenti senza data", + "documentsUndatedOther": "{count} documenti senza data", + "valuesEmpty": "Ancora nessun valore su un periodo lungo", + "illnessEmpty": "Ancora nessuna malattia con data di inizio", + "visitsEmpty": "Ancora nessuna visita medica svolta", + "labsEmpty": "Ancora nessun referto di laboratorio", + "vaccinationsEmpty": "Ancora nessuna vaccinazione", + "lifeEventsEmpty": "Ancora nessun evento di vita" + } + }, + "legendSpan": "Periodo", + "legendEvent": "Evento", + "legendMeanQuarter": "Media trimestrale", + "legendMeanMonth": "Media mensile", + "legendMeanWeek": "Media settimanale", + "legendThin": "Meno di 3 misurazioni", + "legendGap": "Breve pausa senza misurazioni", + "today": "Oggi", + "noDataYet": "Ancora nessun dato", + "layers": "Livelli", + "layersLanes": "Corsie", + "zoomLabel": "Intervallo visualizzato", + "chartLabel": "Cronologia da {from} a {to}", + "keyboardHint": "Le frecce spostano la selezione, Inizio e Fine vanno agli estremi, Invio apre il giorno.", + "loadFailed": "Impossibile caricare la cronologia.", + "empty": { + "title": "Ancora niente nella cronologia", + "description": "Le voci con data compariranno qui appena ce ne saranno." + }, + "values": { + "menuTitle": "Valori sotto le corsie", + "trigger": "Valori ({count})", + "noneInWindow": "Nessun valore in questo periodo", + "series": { + "BLOOD_PRESSURE_SYS": "Pressione sistolica", + "BLOOD_PRESSURE_DIA": "Pressione diastolica" + }, + "noValue": "nessun valore", + "bloodPressure": "Pressione arteriosa", + "readingsOne": "1 misurazione", + "readingsFew": "media di {count} misurazioni", + "readingsOther": "media di {count} misurazioni" + }, + "table": { + "caption": "Voci della cronologia", + "lane": "Corsia", + "entry": "Voce", + "start": "Inizio", + "end": "Fine", + "seriesCaption": "Medie per periodo", + "period": "Periodo" + }, + "selection": { + "empty": "Nessuna voce in questo periodo", + "since": "dal {date}", + "range": "dal {from} al {to}", + "mean": "Ø {values}", + "week": "Settimana del {date}", + "hintClick": "Fai clic su un momento per vederlo qui", + "hintTap": "Tocca un momento per vederlo qui" + }, + "chronicle": { + "months": "Mesi", + "years": "Anni", + "groupingLabel": "Raggruppamento", + "empty": "Nessuna voce da {from} a {to}", + "emptySingle": "{period}: nessuna voce", + "ended": "Fine: {label}", + "afterDaysOne": "dopo 1 giorno", + "afterDaysFew": "dopo {count} giorni", + "afterDaysOther": "dopo {count} giorni", + "notable": { + "extremeHigh": "Valore più alto da molto tempo", + "extremeLow": "Valore più basso da molto tempo", + "firstValue": "Primo valore di una misura", + "gap": "Una lunga pausa nei valori" + }, + "notableMeta": "Apri il giorno per i dettagli" + }, + "standingAllergiesOne": "{count} allergia", + "standingAllergiesFew": "{count} allergie", + "standingAllergiesOther": "{count} allergie", + "legendMeanDay": "Media giornaliera", + "range": { + "from": "Dal", + "to": "Al", + "label": "Periodo scelto", + "open": "Scegli un periodo", + "clear": "Mostra tutto", + "apply": "Applica" + }, + "item": { + "labValuesOne": "1 valore di laboratorio", + "labValuesFew": "{count} valori di laboratorio", + "labValuesOther": "{count} valori di laboratorio" + } + }, + "lifeEvents": { + "title": "Evento di vita", + "add": "Evento di vita", + "privacy": "Titolo e nota sono salvati cifrati. Non vanno a nessuna IA.", + "emptyHint": "Un trasloco, una nascita o un nuovo lavoro danno agli anni un punto di riferimento.", + "save": "Salva", + "cancel": "Annulla", + "delete": "Elimina evento di vita", + "saved": "Evento di vita salvato", + "deleted": "Evento di vita eliminato", + "fields": { + "title": "Cosa è successo?", + "category": "Tipo", + "when": "Quando", + "precision": "Precisione", + "addEnd": "Data di fine, se è stato un periodo", + "endDate": "Fino al", + "note": "Nota", + "optionalTag": "facoltativo", + "notePlaceholder": "Qualcosa che vorrai sapere più avanti", + "removeEnd": "Rimuovi data di fine" + }, + "category": { + "FAMILY": "Famiglia", + "HOME": "Casa", + "WORK": "Lavoro", + "LOSS": "Lutto", + "OTHER": "Altro" + }, + "precision": { + "DAY": "Giorno", + "MONTH": "Mese", + "YEAR": "Anno" + }, + "edit": "Modifica evento di vita", + "errors": { + "titleRequired": "Dai un nome all’evento.", + "titleTooLong": "Al massimo 120 caratteri.", + "categoryRequired": "Scegli un tipo.", + "dateRequired": "Inserisci una data valida.", + "endBeforeStart": "La fine è prima dell’inizio.", + "noteTooLong": "Al massimo 2000 caratteri." + }, + "saveFailed": "Salvataggio non riuscito.", + "deleteFailed": "Eliminazione non riuscita.", + "deleteConfirm": { + "title": "Eliminare questo evento di vita?", + "body": "Titolo e nota non ci saranno più." + } + }, + "palette": { + "open": "Cerca…", + "openLabel": "Cerca pagine, impostazioni e azioni", + "title": "Cerca", + "description": "Trova una pagina, un’impostazione, un modulo o un’azione.", + "placeholder": "Cerca pagine, impostazioni, moduli…", + "results": "Risultati", + "noResults": "Nessun risultato per «{query}».", + "moduleOn": "Attivo", + "moduleOff": "Disattivo", + "hintMove": "Sposta", + "hintOpen": "Apri", + "hintClose": "Chiudi", + "groups": { + "recent": "Recenti", + "pages": "Pagine", + "insights": "Insights", + "settings": "Impostazioni", + "modules": "Moduli", + "actions": "Azioni" + }, + "actions": { + "capture": "Nuova voce", + "coach": "Chiedi al Coach", + "backup": "Crea un backup", + "today": "Apri oggi", + "shortcuts": "Mostra scorciatoie da tastiera" + } + }, + "shortcuts": { + "title": "Scorciatoie da tastiera", + "description": "Tasti che funzionano ovunque nell’app, tranne mentre scrivi in un campo.", + "menuEntry": "Scorciatoie da tastiera", + "then": "poi", + "search": "Cerca", + "help": "Mostra questo elenco", + "close": "Chiudi una finestra o un pannello", + "previousDay": "Giorno precedente", + "nextDay": "Giorno successivo", + "dayHint": "Quando un giorno è aperto.", + "groups": { + "navigation": "Navigazione", + "actions": "Azioni", + "day": "Giorno" + } + }, + "aboutPage": { + "eyebrow": "Informazioni", + "title": "Informazioni su HealthLog", + "lastUpdated": "Ultimo aggiornamento: {date}", + "project": "Progetto", + "credits": "Riconoscimenti" } } diff --git a/messages/ko.json b/messages/ko.json index 6ad5fb71d..649bb7763 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -49,7 +49,9 @@ "dismiss": "닫기", "moreInfo": "추가 정보", "durationHoursMinutes": "{hours}시간 {minutes}분", - "durationMinutes": "{minutes}분" + "durationMinutes": "{minutes}분", + "durationClockMinutes": "{time}분", + "durationClockHours": "{time}시간" }, "apiErrors": { "measurement": { @@ -165,7 +167,7 @@ "anamnesisShiftSchedule": "근무 일정", "anamnesisColFact": "프로필 항목", "anamnesisColValue": "기록된 값", - "subtitle": "HealthLog — 개인 건강 리포트", + "subtitle": "HealthLog: 개인 건강 리포트", "patient": "환자", "dateOfBirth": "생년월일", "gender": "성별", @@ -211,18 +213,18 @@ "contacts": "비상 연락처", "notes": "추가 메모" }, - "vitalsTitle": "활력징후 — 요약", + "vitalsTitle": "활력징후: 요약", "colParameter": "항목", "colCurrent": "현재", "colAvgPeriod": "기간 평균", "colMin": "최소", "colMax": "최대", "colN": "건수", - "bpClassificationTitle": "혈압 — ESH 분류 (2023)", + "bpClassificationTitle": "혈압: ESH 분류 (2023)", "avgBp": "평균 혈압", "classification": "분류", "bmiTitle": "체질량지수 (BMI)", - "bmiRow": "BMI: {bmi} kg/m² — 분류: {class} (WHO)", + "bmiRow": "BMI: {bmi} kg/m², 분류: {class} (WHO)", "complianceTitle": "복약 순응도", "glp1Title": "GLP-1 치료", "glp1WeightSummary": "리포트 기간 체중: {start} kg → {end} kg ({delta} kg)", @@ -240,7 +242,7 @@ "colExpected": "예정", "colComplianceRate": "순응률", "moodTitle": "기분 추이", - "moodSummary": "평균: {avg} / 5.0 · 기록: {count}건 · 범위: {min}–{max}", + "moodSummary": "평균 {avg} / 5.0, 기록 {count}건, 범위 {min}–{max}", "colMood": "기분", "colCount": "횟수", "colShare": "비율", @@ -270,8 +272,8 @@ "typeGlucoseRandom": "혈당 (임의)", "typeGlucoseBedtime": "혈당 (취침 전)", "typeGlucoseUnspecified": "혈당 (미지정)", - "glucoseClassificationTitle": "혈당 — 참고 범위 (ADA 2024 / DDG)", - "glucoseRow": "{label}: 평균 {avg} {unit} (목표 {rangeMin}–{rangeMax}) — {class}", + "glucoseClassificationTitle": "혈당: 참고 범위 (ADA 2024 / DDG)", + "glucoseRow": "{label}: 평균 {avg} {unit} (목표 {rangeMin}–{rangeMax}), {class}", "glucoseInTarget": "목표 범위 내", "glucoseBelowTarget": "목표 미만", "glucoseAboveTarget": "목표 초과", @@ -376,12 +378,12 @@ "backToDashboard": "대시보드로 돌아가기" }, "offlineBanner": { - "message": "연결 없음 — 온라인으로 돌아올 때까지 저장할 수 없어요.", - "saveFailed": "저장하지 못했어요 — 오프라인 상태예요. 온라인으로 돌아온 뒤 다시 시도해 주세요.", + "message": "연결이 없어요. 온라인으로 돌아올 때까지 저장할 수 없어요.", + "saveFailed": "저장하지 못했어요. 오프라인 상태예요. 온라인으로 돌아온 뒤 다시 시도해 주세요.", "showingCached": "마지막으로 동기화한 데이터를 보여주고 있어요." }, "demoBanner": { - "message": "데모 모드 — 변경 내용은 저장되지 않아요." + "message": "데모 모드: 변경 내용은 저장되지 않아요." }, "nav": { "dashboard": "대시보드", @@ -427,7 +429,9 @@ "workout": "운동", "workoutDescription": "기기가 기록하지 못한 운동", "symptom": "증상", - "symptomDescription": "직접 정한 증상과 강도" + "symptomDescription": "직접 정한 증상과 강도", + "lifeEventDescription": "이사, 출산, 새 직장", + "lifeEvent": "삶의 사건" }, "cycle": "생리 주기", "labs": "검사", @@ -436,7 +440,8 @@ "illness": "아픈 날", "documents": "문서", "vaccinations": "예방접종", - "mentalWellbeing": "정신 건강" + "mentalWellbeing": "정신 건강", + "timeline": "타임라인" }, "auth": { "login": "로그인", @@ -453,14 +458,13 @@ "hasAccount": "이미 계정이 있나요?", "loginFailed": "로그인 실패", "registerFailed": "회원가입 실패", - "invitedBanner": "초대를 받았어요 — 회원가입이 닫혀 있어도 계정을 만들 수 있어요.", + "invitedBanner": "초대를 받았어요. 회원가입이 닫혀 있어도 계정을 만들 수 있어요.", "alreadySignedIn": { "title": "이미 로그인되어 있습니다", "body": "{username} 계정으로 로그인되어 있습니다. 초대는 새 계정을 만듭니다. 초대를 수락하려면 로그아웃하세요.", "signOut": "로그아웃하고 초대 수락하기" }, "createAccount": "계정 만들기", - "newAccount": "새 계정 만들기", "backToPasskey": "패스키로 돌아가기", "passkeySetupLater": "패스키는 설정에서 언제든 등록할 수 있어요.", "passwordMinLength": "최소 12자", @@ -513,6 +517,37 @@ "fixLan": "TLS 없이 홈 네트워크에서 쓰는 서버: 설정에서 SESSION_COOKIE_SECURE=false로 지정하고 다시 시작하세요.", "fixHttps": "HTTPS 프록시 뒤의 서버: 대신 https:// 주소를 열고, 프록시가 X-Forwarded-Proto: https를 전달하는지 확인하세요.", "localhostNote": "이 페이지는 http://localhost에 있지만 서버는 로그인 쿠키를 https://에만 설정합니다. 대부분의 브라우저는 localhost에서 이를 허용합니다. 로그인 후 계속 여기로 돌아온다면 SESSION_COOKIE_SECURE=false로 지정하세요." + }, + "claim": { + "title": "내 건강 기록 넘겨받기", + "titleNamed": "{name} 님, 환영합니다", + "description": "지금까지는 다른 사람이 건강 기록을 대신 관리했습니다. 로그인 방법을 정하면 이 기록이 내 계정이 됩니다. 아무것도 복사되거나 옮겨지지 않습니다.", + "loading": "링크를 확인하는 중…", + "invalid": "이 링크는 더 이상 작동하지 않습니다", + "invalidHint": "알 수 없거나, 만료되었거나, 철회되었거나, 이미 사용된 링크입니다. 보낸 사람에게 새 링크를 요청하세요.", + "oidcOnly": "이 서버는 통합 로그인(SSO)만 허용하므로 아직 여기서 프로필을 넘겨받을 수 없습니다.", + "signedInTitle": "먼저 로그아웃하세요", + "signedInBody": "{username} 계정으로 로그인되어 있습니다. 이 링크는 다른 사람의 로그인을 만드는 링크이므로 사용하기 전에 로그아웃하세요.", + "signOut": "로그아웃하고 계속", + "guardiansTitle": "당분간 접근 권한을 유지하는 사람", + "guardiansHint": "기록을 관리해 온 사람들이 제안한 내용입니다. 로그인한 직후에 직접 결정합니다.", + "guardianUnnamed": "보호자", + "usernameHint": "영문자, 숫자, _ 또는 - 로 3~30자.", + "usernameReserved": "managed- 로 시작하는 사용자 이름은 예약되어 있습니다.", + "emailHint": "비밀번호를 잊었을 때 다시 들어올 수 있도록 필요합니다.", + "consentNote": "안내 사항과 AI 사용은 직접 확인합니다. 그 전까지는 기록의 어떤 내용도 AI 제공자에게 전송되지 않습니다.", + "expires": "이 링크는 {date}까지 유효합니다.", + "submit": "넘겨받기", + "submitting": "넘겨받는 중…", + "errorTaken": "이미 사용 중인 사용자 이름 또는 이메일 주소입니다.", + "errorRateLimit": "시도 횟수가 너무 많습니다. 몇 분 후 다시 시도하세요.", + "errorFailed": "처리하지 못했습니다. 잠시 후 다시 시도하세요.", + "errorOffline": "서버에 연결하지 못했습니다. 연결을 확인하고 다시 시도하세요." + }, + "inviteInvalid": { + "title": "유효하지 않은 초대예요", + "description": "이 초대 링크가 불완전하거나 잘못 복사되었어요. 초대한 분께 새 링크를 요청해 주세요.", + "toLogin": "로그인으로 이동" } }, "dashboard": { @@ -544,17 +579,17 @@ "sleepShort": "수면", "stepsShort": "걸음 수", "vo2MaxShort": "VO₂ max", - "bpInTargetWindow90": "목표 내 혈압 · 90일", - "bpInTargetWindowDynamic": "목표 내 혈압 · {days}일", + "bpInTargetWindow90": "목표 내 혈압 (90일)", + "bpInTargetWindowDynamic": "목표 내 혈압 ({days}일)", "bpInTargetCollecting": "데이터 모으는 중", "medications": "복약", "quickAddMeasurement": "측정 기록", "quickAddMood": "기분 기록", "quickAddMedicationIntake": "복용 기록", "quickAddWorkout": "운동 기록", + "quickAddLifeEvent": "삶의 사건 기록", "customizeDashboard": "대시보드 맞춤 설정", "medicationIntakeQuickAdd": { - "sheetTitle": "복용 기록", "sheetDescription": "몇 번의 탭으로 약과 용량, 시각을 기록해요.", "medicationLabel": "약", "medicationPlaceholder": "약 선택", @@ -667,7 +702,8 @@ "body": "키 사본이 없으면 다시 설치한 뒤 데이터를 복구할 수 없습니다. 1분이면 됩니다.", "action": "단계 열기" }, - "quickAddSymptom": "증상 기록" + "quickAddSymptom": "증상 기록", + "todayCardVisible": "상단 카드 표시" }, "measurements": { "title": "측정", @@ -739,7 +775,7 @@ "formReset": "양식 초기화", "saveError": "저장하지 못했어요", "duplicateTimestamp": "선택한 시각에 같은 유형의 측정이 이미 있어요.", - "serverOwnedSource": "연동된 출처의 값은 수정할 수 없어요 — 시간과 메모는 수정할 수 있어요.", + "serverOwnedSource": "연동된 출처의 값은 수정할 수 없어요. 시간과 메모는 수정할 수 있어요.", "deleteError": "삭제하지 못했어요", "deletedToast": "측정을 삭제했어요", "restoredToast": "측정을 복원했어요", @@ -834,7 +870,8 @@ "durationHoursOnly": "{hours}시간", "durationMinutesOnly": "{minutes}분", "durationInvalid": "7.5, 7:30, 7h 30m처럼 시간을 입력해 주세요.", - "durationTooLong": "한 번에 24시간보다 길게 입력할 수 없어요." + "durationTooLong": "한 번에 24시간보다 길게 입력할 수 없어요.", + "sourceHealthConnect": "Health Connect" }, "metricCatalog": { "title": "HealthLog가 기록할 수 있는 것", @@ -850,7 +887,7 @@ }, "watchdog": { "title": "자동 감시", - "description": "HealthLog는 백그라운드에서도 조용히 지켜봐요 — 연결된 워치의 불규칙 리듬·호흡 장애 알림, 기준값 이탈 건강 상태 경고, 최근 두 번의 검사 패널 사이의 눈에 띄는 변화를 보고, 특별한 게 있을 때만 카드를 보여줘요. 이런 원본이 하나라도 연결돼 있다면 카드가 없는 건 기능 누락이 아니라 좋은 신호예요. 아직 아무것도 연결하지 않았다면 감시할 대상 자체가 없어요." + "description": "HealthLog는 백그라운드에서도 조용히 지켜봐요. 연결된 워치의 불규칙 리듬·호흡 장애 알림, 기준값 이탈 건강 상태 경고, 최근 두 번의 검사 패널 사이의 눈에 띄는 변화를 보고, 특별한 게 있을 때만 카드를 보여줘요. 이런 원본이 하나라도 연결돼 있다면 카드가 없는 건 기능 누락이 아니라 좋은 신호예요. 아직 아무것도 연결하지 않았다면 감시할 대상 자체가 없어요." } }, "mood": { @@ -1032,7 +1069,7 @@ }, "sectionMoreTags": "태그 더 보기", "sectionNoteDetails": "메모·상세", - "quickTagsHelp": "자주 쓰는 태그 — 한 번만 누르면 돼요", + "quickTagsHelp": "자주 쓰는 태그: 한 번만 누르면 돼요", "quickTagsExpand": "더 보기", "loadError": "기분 기록을 불러오지 못했어요.", "sectionLevelA": "마음 상태", @@ -1210,8 +1247,8 @@ "dialogDescription": "지금 복용할 약이에요. 확인하면 모두 복용으로 기록해요.", "confirm": "모두 복용 ({count})", "successToast": "{count}건 복용을 기록했어요", - "partialToast": "{taken}건 기록, {failed}건 실패 – 다시 시도해 주세요", - "failedToast": "복용을 기록하지 못했어요 – 다시 시도해 주세요" + "partialToast": "{taken}건 기록, {failed}건 실패. 다시 시도해 주세요", + "failedToast": "복용을 기록하지 못했어요. 다시 시도해 주세요" }, "logIntake": { "dialogTitle": "복용 기록", @@ -1300,7 +1337,7 @@ "complianceInfoLabel": "복약 순응도란?", "lastIntake": "마지막 복용:", "nextIntake": "다음 복용:", - "nextIntakeOverdue": "시간 지남 · {time} — 지금도 복용할 수 있어요", + "nextIntakeOverdue": "{time}부터 지남, 지금도 복용할 수 있어요", "dayStreak": "일 연속 기록", "dayStreakOne": "일 연속 기록", "cycleDueToday": "오늘 예정", @@ -1309,10 +1346,10 @@ "skipped": "건너뜀", "intakeToastTaken": "복용으로 기록함: {name}", "intakeToastSkipped": "건너뜀: {name}", - "intakeToastFailed": "기록하지 못했어요: {name} — 다시 시도해 주세요", + "intakeToastFailed": "기록하지 못했어요: {name}. 다시 시도해 주세요", "intakeUndo": "실행 취소", "intakeUndone": "기록을 되돌렸어요", - "intakeUndoFailed": "되돌리지 못했어요 — 기록에서 수정해 주세요", + "intakeUndoFailed": "되돌리지 못했어요. 기록에서 수정해 주세요", "glp1LastInjection": "{label}", "glp1NextInjectionDays": "{label} ({days}일 후)", "glp1NextInjectionToday": "오늘", @@ -1394,10 +1431,10 @@ "error": { "empty": "설명을 먼저 입력해 주세요.", "tooLong": "설명은 2000자 이내로 적어 주세요.", - "rateLimit": "요청이 너무 많아요 — 잠시 후 다시 시도해 주세요.", - "noProvider": "AI 제공자가 설정되지 않았어요 — 설정에서 등록해 주세요.", - "budget": "오늘 AI 사용 한도를 다 썼어요 — 내일 다시 시도해 주세요.", - "network": "AI 제공자에 연결하지 못했어요 — 다시 시도해 주세요.", + "rateLimit": "요청이 너무 많아요. 잠시 후 다시 시도해 주세요.", + "noProvider": "AI 제공자가 설정되지 않았어요. 설정에서 등록해 주세요.", + "budget": "오늘 AI 사용 한도를 다 썼어요. 내일 다시 시도해 주세요.", + "network": "AI 제공자에 연결하지 못했어요. 다시 시도해 주세요.", "consent": "AI로 설명을 읽으려면 먼저 동의가 필요합니다.", "unavailable": "지금 여기에서는 AI로 설명을 읽을 수 없습니다." } @@ -1471,7 +1508,7 @@ "night": "밤" }, "max": { - "reached": "최대 개수에 도달했어요 — 다른 시각을 삭제한 뒤 추가하세요." + "reached": "최대 개수에 도달했어요. 다른 시각을 삭제한 뒤 추가하세요." } }, "courseWindow": { @@ -1547,7 +1584,7 @@ }, "dosesPerUnitLabel": "단위당 투여 횟수", "dosesPerUnitPlaceholder": "예: 4", - "dosesPerUnitHint": "펜/바이알당 투여 횟수 — 재고 추적이 필요 없으면 비워 두세요.", + "dosesPerUnitHint": "펜/바이알당 투여 횟수예요. 재고 추적이 필요 없으면 비워 두세요.", "short": "용량", "unitsPerDoseLabel": "1회 복용 단위 수", "unitsPerDoseHint": "예: 4mg 용량이면 2mg 2정, 반으로 나눠 먹으면 ½정" @@ -1559,7 +1596,7 @@ }, "step5": { "title": "얼마나 자주 복용하나요?", - "subline": "주기를 고르세요 — 세부 설정은 다음 단계에서 해요.", + "subline": "주기를 고르세요. 세부 설정은 다음 단계에서 해요.", "short": "주기" }, "step6": { @@ -1581,7 +1618,7 @@ }, "step7": { "title": "하루 중 언제 복용하나요?", - "subline": "하루 한 번 이상 — 알림은 이 목록을 따라요.", + "subline": "하루 한 번 이상이에요. 알림은 이 목록을 따라요.", "presetsLabel": "시간대 프리셋", "presets": { "morning": "아침", @@ -1589,7 +1626,7 @@ "evening": "저녁", "night": "밤" }, - "overrideHint": "선택 사항 — 이 시간대에만 다른 용량이나 단위 수를 지정해요. 비워 두면 약의 기본값을 써요.", + "overrideHint": "선택 사항: 이 시간대에만 다른 용량이나 단위 수를 지정해요. 비워 두면 약의 기본값을 써요.", "doseOverrideLabel": "이 시간대 용량 (선택)", "doseOverridePlaceholder": "예: 반 알", "unitsOverrideLabel": "이 시간대에 복용하는 단위 수", @@ -1597,10 +1634,10 @@ "short": "시간" }, "step8": { - "title": "다 됐어요 — HealthLog가 알림을 보낼까요?", + "title": "다 됐어요. HealthLog가 알림을 보낼까요?", "subline": "저장하기 전에 아래 내용을 확인해 주세요.", "remindersLabel": "알림 켜기", - "remindersDescription": "푸시, Telegram, ntfy — 설정에 따라 보내요.", + "remindersDescription": "푸시, Telegram, ntfy 중 설정에 따라 보내요.", "noChannelHint": "알림을 보내려면 알림 채널이 필요한데, 지금은 켜진 채널이 없어요.", "noChannelHintLink": "알림 설정하기", "short": "완료" @@ -1685,7 +1722,7 @@ } }, "errors": { - "submitFailed": "약을 저장하지 못했어요 — 다시 시도해 주세요." + "submitFailed": "약을 저장하지 못했어요. 다시 시도해 주세요." } }, "doseStrength": { @@ -1701,11 +1738,11 @@ "empty": "아직 증량 단계가 기록되지 않았어요. 날짜가 있는 용량 변경을 추가하면 계획을 그려 줘요.", "youAreHere": "현재 위치", "doseStep": "{dose} {unit}", - "plannedFor": "예정 · {date}" + "plannedFor": "{date} 예정" }, "injectionSiteBodyOutlineAriaLabel": "신체 윤곽", "injectionSitePickerAriaLabel": "주사 부위 선택기", - "injectionSiteRecommendedAriaLabel": "{site} — 다음 권장 부위", + "injectionSiteRecommendedAriaLabel": "{site}: 다음 권장 부위", "injectionSiteLegendAriaLabel": "마커 범례", "injectionSiteLegendRecommended": "다음 권장 부위", "injectionSiteLegendLastUsed": "마지막 사용 부위", @@ -1752,7 +1789,7 @@ "intakeHistoryStatusSkipped": "건너뜀", "intakeHistorySortByTaken": "복용 시각순 정렬", "intakeHistorySortByScheduled": "예정 시각순 정렬", - "intakeHistoryPageInfo": "{page}/{total}페이지 · {count}개 기록", + "intakeHistoryPageInfo": "{page}/{total}페이지, 기록 {count}개", "intakeHistoryPrev": "이전", "intakeHistoryNext": "다음", "glp1": { @@ -1781,7 +1818,7 @@ "emptyState": "아직 복용 기록이 없어요.", "emptyStateCta": "용량을 기록하면 예상 농도 곡선이 그려져요.", "estimateNote": "EMA가 공개한 집단 약동학 자료를 바탕으로 한 참고용 추정치예요. 실제 측정값이 아니에요.", - "unknownDrug": "{name} — EMA 승인 GLP-1 목록에 없어서 HealthLog가 집단 약동학 곡선을 그릴 수 없어요." + "unknownDrug": "{name}: EMA 승인 GLP-1 목록에 없어서 HealthLog가 집단 약동학 곡선을 그릴 수 없어요." } }, "detail": { @@ -1793,7 +1830,7 @@ "cadence": { "oneShotOn": "{date}에 1회 복용해요.", "oneShotPending": "1회 복용 예정이에요.", - "asNeeded": "필요시 복용 — 정해진 일정 없어요." + "asNeeded": "필요시 복용: 정해진 일정 없어요." }, "intake": { "title": "복용 기록", @@ -1820,7 +1857,7 @@ "dialogTitle": "복용 기록 편집", "takenAtLabel": "복용 시각", "scheduledForHint": "예정: {dateTime}", - "farFromScheduledWarning": "예정 시각과 많이 떨어져 있어요 — 날짜를 확인해 주세요.", + "farFromScheduledWarning": "예정 시각과 많이 떨어져 있어요. 날짜를 확인해 주세요.", "skippedLabel": "건너뜀", "save": "저장", "cancel": "취소", @@ -1852,7 +1889,7 @@ "markAsTaken": "복용함으로 표시", "markAsSkipped": "건너뜀으로 표시", "adHocTag": "일정 외", - "takenDetail": "예정 {planned} · 복용 {taken}", + "takenDetail": "예정 {planned}, 복용 {taken}", "doseTaken": "용량: {dose}", "status": { "taken_on_time": "정시", @@ -1864,10 +1901,10 @@ }, "addDialog": { "title": "복용 기록 추가", - "description": "복용을 기록해요 — 지난 날짜나 일정 외 복용도 괜찮아요.", + "description": "복용을 기록해요. 지난 날짜나 일정 외 복용도 괜찮아요.", "takenAtLabel": "복용 시각", "skippedLabel": "건너뜀", - "nudgeHint": "{slot} 시간대와 가까워요 — 저장할 때 그 시간대에 배정할지 물어볼게요.", + "nudgeHint": "{slot} 시간대와 가까워요. 저장할 때 그 시간대에 배정할지 물어볼게요.", "cancel": "취소", "submit": "저장" }, @@ -1936,7 +1973,7 @@ "title": "복약 종료", "helper": "알림을 끄고 이 약을 종료 상태로 표시해요. 기록은 계속 볼 수 있어요.", "button": "종료", - "dialogTitle": "종료: {name} — 알림을 끄고, 기록은 계속 볼 수 있어요", + "dialogTitle": "종료: {name} (알림을 끄고, 기록은 계속 볼 수 있어요)", "dialogBody": "알림이 더 이상 오지 않아요. 이전 기록은 타임라인에 남아요.", "toast": "복약을 종료했어요", "failed": "복약을 종료하지 못했어요." @@ -1954,7 +1991,7 @@ "title": "약 삭제", "helper": "약과 관련된 모든 기록을 삭제해요.", "button": "삭제", - "dialogTitle": "{name} — 삭제할까요?", + "dialogTitle": "{name}: 삭제할까요?", "dialogBody": "약과 일정, 복용 기록, API 토큰, 단계 설정, 알림이 모두 영구히 삭제돼요.", "toast": "약을 삭제했어요", "failed": "약을 삭제하지 못했어요." @@ -1974,7 +2011,7 @@ "copyFailed": "복사하지 못했어요.", "tokenCopied": "토큰을 복사했어요", "copyToken": "토큰 복사", - "mintedHint": "지금 토큰을 복사해 주세요 — 한 번만 보여줘요." + "mintedHint": "지금 토큰을 복사해 주세요. 한 번만 보여줘요." }, "advanced": { "dataPortability": { @@ -2003,7 +2040,6 @@ "bestand": "재고", "verlauf": "기록", "injektion": "주사", - "api": "API", "erweitert": "고급", "wirkung": "효과" }, @@ -2015,9 +2051,9 @@ "statusTitle": "상태", "nextDoseLabel": "다음 복용", "nextDoseNone": "예정된 복용이 없어요.", - "nextDoseAsNeeded": "필요시 복용 — 예정된 복용이 없어요.", + "nextDoseAsNeeded": "필요시 복용: 예정된 복용이 없어요.", "nextDoseNotTracked": "복용을 기록하지 않아요. 기록으로만 남겨요.", - "pausedHint": "일시중지 — 알림이 오지 않아요.", + "pausedHint": "일시중지: 알림이 오지 않아요.", "reminderLabel": "알림", "reminderOn": "켜짐", "reminderOff": "꺼짐", @@ -2088,7 +2124,7 @@ "packagingDefaultPack": "기본 포장 크기: {units}개", "packagingNoDefaultPack": "기본 포장 크기가 설정되지 않았어요", "packagingEdit": "편집", - "packagingDescription": "1회분에 몇 개를 쓰는지, 새 포장이 보통 몇 개인지 설정해요 — 예를 들어 제조사가 바뀌어 포장 크기가 달라졌을 때요.", + "packagingDescription": "1회분에 몇 개를 쓰는지, 새 포장이 보통 몇 개인지 설정해요. 예를 들어 제조사가 바뀌어 포장 크기가 달라졌을 때요.", "packagingUnitsPerDoseLabel": "1회분당 개수", "packagingUnitsPerDoseHelper": "예: 4mg 복용에 2mg 정제 2개, 한 알 반이면 1.5 (0 초과, 100 이하). 이미 기록된 복용은 그대로예요.", "packagingDefaultPackLabel": "기본 포장 크기 (개)", @@ -2139,11 +2175,11 @@ "hideEras": "이전 구간 숨기기", "addEra": "이전 구간 추가", "addEraTitle": "이전 구간 추가", - "addEraDescription": "예전에 이 약을 복용하던 시각을 기록해요 — 예를 들어 여기서 일정을 바꾸기 전 시각이요.", + "addEraDescription": "예전에 이 약을 복용하던 시각을 기록해요. 예를 들어 여기서 일정을 바꾸기 전 시각이요.", "editAria": "구간 편집", "editEraTitle": "구간 편집", "editEraDescription": "이 구간의 복용 시각과 기간을 조정해요.", - "archivedEditHint": "이 구간은 자동으로 기록됐어요 — 수정하면 기록된 이력이 바로잡히고, 원본은 내부에 기록으로 남아요.", + "archivedEditHint": "이 구간은 자동으로 기록됐어요. 수정하면 기록된 이력이 바로잡히고, 원본은 내부에 기록으로 남아요.", "updatedToast": "구간을 수정했어요", "updateError": "구간을 수정하지 못했어요. 다른 구간과 겹치지 않고, 현재 일정보다 먼저 끝나야 해요.", "timesLabel": "복용 시각", @@ -2169,7 +2205,7 @@ "saved": "일정을 저장했어요", "failed": "일정을 저장하지 못했어요", "window": { - "intro": "복용마다 정시로 인정되는 범위를 정해요 — 정확한 시각(±1h)이나 07:00–09:00 같은 직접 지정 범위로요.", + "intro": "복용마다 정시로 인정되는 범위를 정해요. 정확한 시각(±1h)이나 07:00–09:00 같은 직접 지정 범위로요.", "explicitToggle": "직접 범위 지정", "startLabel": "범위 시작", "endLabel": "범위 종료", @@ -2182,10 +2218,10 @@ } }, "logInjectionSiteTitle": "주사 부위 기록", - "logInjectionSiteDescription": "{name} — 어디에 주사했나요? 선택 사항이라 건너뛰어도 돼요.", + "logInjectionSiteDescription": "{name}: 어디에 주사했나요? 선택 사항이라 건너뛰어도 돼요.", "logInjectionSiteSkip": "건너뛰기", "logInjectionSiteConfirm": "부위 저장", - "logInjectionSiteNoneAvailable": "쓸 수 있는 주사 부위가 없어요 — 설정에서 모든 부위를 제외했어요.", + "logInjectionSiteNoneAvailable": "쓸 수 있는 주사 부위가 없어요. 설정에서 모든 부위를 제외했어요.", "logInjectionSiteSaveFailed": "주사 부위를 저장하지 못했어요. 다시 시도해 주세요.", "perSlotUnits": "1회분당 {units}", "trackInjectionSitesToggle": "주사 부위 기록하기", @@ -2210,10 +2246,9 @@ "tableStockDoses": "{count}회분", "tableStockRunway": "약 {days}일 남음", "cardLowStockRunway": "재고: 약 {days}일", - "takeActionFor": "복용 – {name}", - "skipActionFor": "건너뛰기 – {name}", - "reorderTitle": "순서 조정", - "reorderDescription": "약의 순서를 정하세요 – 카드 보기와 표 보기 모두에 적용돼요.", + "takeActionFor": "복용: {name}", + "skipActionFor": "건너뛰기: {name}", + "reorderDescription": "카드 보기와 표 보기 모두에 적용되는 약의 순서를 정하세요.", "reorderSaved": "순서를 저장했어요", "reorderSaveFailed": "순서를 저장하지 못했어요", "customize": "복약 맞춤 설정", @@ -2232,7 +2267,7 @@ "levelShift": "{date} 무렵에 수준 변화가 감지됐어요.", "plateau": { "title": "현재 용량에서 체중 정체", - "evidence": "최근 {window}일 동안 체중이 {delta}{unit} 움직였어요({readings}개 측정값) — {dose}로 {days}일." + "evidence": "최근 {window}일 동안 체중이 {delta}{unit} 움직였어요({readings}개 측정값). {dose}로 {days}일째예요." }, "retarget": { "label": "맞는 측정값이 아닌가요?", @@ -2242,16 +2277,16 @@ "error": "변경 내용을 저장하지 못했어요. 다시 시도해 주세요." }, "beforeAfter": { - "summary": "시작 전 평균 {before} → 이후 평균 {after} — {delta} 변화.", + "summary": "시작 전 평균 {before} → 이후 평균 {after}, {delta} 변화.", "counts": "시작 전 {beforeCount}개, 이후 {afterCount}개 측정값을 기준으로 했어요.", - "needBefore": "시작 전 측정값이 아직 부족해요 — 비교하려면 전후로 약 {weeks}주씩 필요해요.", - "needAfter": "시작 이후 측정값이 아직 부족해요 — 비교하려면 전후로 약 {weeks}주씩 필요해요.", + "needBefore": "시작 전 측정값이 아직 부족해요. 비교하려면 전후로 약 {weeks}주씩 필요해요.", + "needAfter": "시작 이후 측정값이 아직 부족해요. 비교하려면 전후로 약 {weeks}주씩 필요해요.", "needStart": "시작일을 설정하면 복용 전후 측정값을 비교할 수 있어요.", - "needData": "측정값이 아직 부족해요 — 비교하려면 시작 전후로 약 {weeks}주씩 필요해요." + "needData": "측정값이 아직 부족해요. 비교하려면 시작 전후로 약 {weeks}주씩 필요해요." }, "insights": { "title": "약 효과", - "subtitle": "약이 목표로 하는 측정값과 무엇이 함께 움직였는지 보여줘요 — 연관성일 뿐 판단이 아니에요.", + "subtitle": "약이 목표로 하는 측정값과 무엇이 함께 움직였는지 보여줘요. 연관성일 뿐 판단이 아니에요.", "link": "효과 보기 열기", "empty": "약과 목표 측정값을 연결하기에는 데이터가 아직 부족해요." }, @@ -2285,7 +2320,7 @@ "reasonMissingMedication": "행에 약 이름이 없어요", "reasonUnreadableDosage": "복용량이 숫자가 아니에요", "reasonUnreadableRow": "행의 열 개수가 머리글보다 적어요", - "reasonStatusNoDoseInformation": "파일에 그 복용에 대한 기록이 없어요 — 응답도 없었고 기록된 것도 없어요", + "reasonStatusNoDoseInformation": "파일에 그 복용에 대한 기록이 없어요. 응답도 없었고 기록된 것도 없어요", "reasonStatusReminderEvent": "행이 미뤄 둔 알림에 관한 내용이라 복용에 대해 알려주는 게 없어요", "reasonStatusNotificationNotSent": "행은 알림이 발송되지 않았다는 기록이라 복용이 아니라 앱에 관한 내용이에요" }, @@ -2357,7 +2392,7 @@ }, "charts": { "loadingLabel": "차트 불러오는 중…", - "loadingSlowHint": "분석을 계산하는 중이에요 — 잠시 걸릴 수 있어요.", + "loadingSlowHint": "분석을 계산하는 중이에요. 잠시 걸릴 수 있어요.", "weight": "체중", "bloodPressure": "혈압", "pulse": "맥박", @@ -2436,8 +2471,8 @@ "columnDate": "날짜", "noValue": "값 없음", "captionDay": "{metric} 하루당 한 행, 차트가 그리는 값과 같아요.", - "captionWeek": "{metric} 한 주당 한 행, 그 주 일별 평균 — 차트가 그리는 값과 같아요.", - "captionMonth": "{metric} 한 달당 한 행, 그달 일별 평균 — 차트가 그리는 값과 같아요.", + "captionWeek": "{metric} 한 주당 한 행, 그 주 일별 평균이에요. 차트가 그리는 값과 같아요.", + "captionMonth": "{metric} 한 달당 한 행, 그달 일별 평균이에요. 차트가 그리는 값과 같아요.", "hourlyMeanNote": "{metric}은(는) 하루의 평균이며 각 시간을 한 번씩만 반영하므로, 운동이 하루의 나머지보다 더 크게 반영되지 않습니다." }, "personalBaseline": "내 평소값", @@ -2470,11 +2505,13 @@ "days7Label": "7일", "days7Title": "최근 7일", "days30Label": "30일", + "daysNLabel": "{days}일", "days30Title": "최근 30일", "days90Label": "90일", "days90Title": "최근 90일", "daysAllLabel": "전체", - "daysAllTitle": "전체 기간" + "daysAllTitle": "전체 기간", + "medicationsEmptyAction": "약 추가하기" }, "chart": { "overlay": { @@ -2505,8 +2542,8 @@ }, "tooltipPrior": "이전 기간", "unavailable": { - "lastMonth": "비교 불가 — 지난달 데이터가 아직 없어요", - "lastYear": "비교 불가 — 작년 데이터가 아직 없어요" + "lastMonth": "비교 불가: 지난달 데이터가 아직 없어요", + "lastYear": "비교 불가: 작년 데이터가 아직 없어요" }, "notAvailableForCompliance": "복약 순응도는 비교 불가" }, @@ -2518,7 +2555,9 @@ "dismissError": "이 패턴을 숨기지 못했어요.", "restoreError": "이 패턴을 다시 표시하지 못했어요.", "loadError": "패턴을 불러오지 못했어요.", - "empty": "근거 기준선을 넘은 패턴이 아직 없어요." + "empty": "근거 기준선을 넘은 패턴이 아직 없어요.", + "seasonalNote": "버전 1.42부터 HealthLog는 값을 비교하기 전에 계절과 장기 추세를 먼저 걸러 냅니다. 보이는 관계는 줄어들지만, 남은 관계는 더 믿을 만합니다. 예전에 보이던 패턴 중 상당수는 그저 달력이었습니다.", + "seasonalNoteDismiss": "안내 닫기" }, "correlation": { "direction": { @@ -2550,29 +2589,37 @@ "ENV_DAYLIGHT": "낮 길이", "ENV_PRECIP": "강수량", "ENV_PRESSURE_MEAN": "기압", - "ENV_PRESSURE_DELTA": "하루 중 기압 변화" + "ENV_PRESSURE_DELTA": "하루 중 기압 변화", + "ENV_PM25": "초미세먼지(PM2.5)", + "ENV_OZONE_8H": "오존", + "ENV_POLLEN_MAX": "꽃가루" }, "daily": { - "high": "{behaviour} 수치가 높은 날에는 다음 날 {outcome} 수치가 {direction} 경향이 있어요 — 원인이 아니라 지켜볼 만한 패턴이에요.", - "moderate": "지금까지의 근거로는 {behaviour} 수치가 높은 날에 다음 날 {outcome} 수치가 {direction} 것처럼 보여요 — 원인이 아니라 지켜볼 만한 패턴이에요.", - "faint": "{behaviour} 수치가 높은 날에 다음 날 {outcome} 수치가 {direction} 아주 희미한 기미만 보여요 — 기대기엔 너무 작고, 원인은 더더욱 아니에요." + "high": "{behaviour} 수치가 높은 날에는 다음 날 {outcome} 수치가 {direction} 경향이 있어요. 원인이 아니라 지켜볼 만한 패턴이에요.", + "moderate": "지금까지의 근거로는 {behaviour} 수치가 높은 날에 다음 날 {outcome} 수치가 {direction} 것처럼 보여요. 원인이 아니라 지켜볼 만한 패턴이에요.", + "faint": "{behaviour} 수치가 높은 날에 다음 날 {outcome} 수치가 {direction} 아주 희미한 기미만 보여요. 기대기엔 너무 작고, 원인은 더더욱 아니에요." }, "lab": { - "default": "{lab} 측정값이 높은 시기에는 같은 기간의 {outcome} 수치도 {direction} 편이에요 — 원인이 아니라 임상의와 함께 지켜볼 만한 연관이에요.", - "faint": "{lab} 측정값이 높은 시기에 같은 기간의 {outcome} 수치가 {direction} 아주 희미한 기미만 보여요 — 기대기엔 너무 작고, 원인은 아니지만 임상의에게 이야기해 볼 만한 연관이에요." + "default": "{lab} 측정값이 높은 시기에는 같은 기간의 {outcome} 수치도 {direction} 편이에요. 원인이 아니라 임상의와 함께 지켜볼 만한 연관이에요.", + "faint": "{lab} 측정값이 높은 시기에 같은 기간의 {outcome} 수치가 {direction} 아주 희미한 기미만 보여요. 기대기엔 너무 작고 원인도 아니지만, 임상의에게 이야기해 볼 만해요." }, "hypothesis": { "bpCompliance": { - "support": "복약 순응도가 높은 날에 수축기 측정값이 낮은 편이에요 — 지켜볼 만한 패턴이에요.", - "surprising": "기록에서는 순응도와 수축기 혈압이 같은 방향으로 움직여요 — 뜻밖이에요. 무언가 조정하기 전에 의사와 상의해 주세요.", + "support": "복약 순응도가 높은 날에 수축기 측정값이 낮은 편이에요. 지켜볼 만한 패턴이에요.", + "surprising": "기록에서는 순응도와 수축기 혈압이 같은 방향으로 움직여요. 뜻밖이에요. 무언가 조정하기 전에 의사와 상의해 주세요.", "none": "이 기간에는 순응도와 수축기 혈압이 뚜렷하게 함께 움직이지 않아요." }, "moodPulse": { - "support": "기분이 낮은 날에 안정 시 맥박이 높은 편이에요 — 지켜볼 만한 패턴이에요.", - "surprising": "기록에서는 기분이 좋은 날에 안정 시 맥박도 높은 편이에요 — 뜻밖이에요.", + "support": "기분이 낮은 날에 안정 시 맥박이 높은 편이에요. 지켜볼 만한 패턴이에요.", + "surprising": "기록에서는 기분이 좋은 날에 안정 시 맥박도 높은 편이에요. 뜻밖이에요.", "none": "이 기간에는 기분과 안정 시 맥박이 뚜렷하게 함께 움직이지 않아요." }, - "weightWeekday": "{weekday} 체중이 다른 요일 평균보다 {delta}kg {direction} 편이에요 — 지켜볼 만한 패턴이에요." + "weightWeekday": "{weekday} 체중이 다른 요일 평균보다 {delta}kg {direction} 편이에요. 지켜볼 만한 패턴이에요." + }, + "dailySameDay": { + "high": "당일과 전날의 {behaviour}이(가) 높을수록 내 데이터에서는 {outcome}이(가) {direction} 경향이 있습니다. 지켜볼 만한 패턴이지 원인은 아닙니다.", + "moderate": "지금까지의 데이터로 보면 당일과 전날의 {behaviour}이(가) 높을수록 {outcome}이(가) {direction} 것으로 보입니다. 지켜볼 만한 패턴이지 원인은 아닙니다.", + "faint": "당일과 전날의 {behaviour}이(가) 높을 때 내 데이터에서 {outcome}이(가) {direction} 기미가 아주 약하게 보일 뿐입니다. 의지하기엔 너무 작고, 원인은 아닙니다." } }, "noAnalysisYet": "아직 평가가 없어요.", @@ -2684,7 +2731,7 @@ "referenceSleep": "하룻밤 최소 {low}분, 더 자는 건 감점하지 않아요", "referenceAdiposity": "허리-키 비율 {high} 미만", "referenceHba1c": "HbA1c {low}~{high}%", - "referenceDetail": "{label} · {source} · {kind} · {low}~{high}", + "referenceDetail": "{label}: {low}~{high} ({kind}, {source})", "openBound": "제한 없음", "laboratoryReferenceSource": "검사 기관 참고 범위", "personalTargetSource": "개인 목표", @@ -2704,7 +2751,7 @@ "unavailable": "이 항목은 지금 반영 대상이 아니에요" }, "tension": "컨디션 신호가 엇갈려요. 좋은 쪽: {positive}. 부담이 큰 쪽: {negative}.", - "returnToBand": "{metric} — {days}일째 평소 범위 안으로 돌아와 있어요.", + "returnToBand": "{metric}: {days}일째 평소 범위 안으로 돌아와 있어요.", "weightGoal": { "title": "개인 체중 목표", "value": "지금 {current}{unit}. 목표 {min}~{max}{unit}. 목표까지 {distance}{unit}.", @@ -2719,7 +2766,8 @@ "coverage": { "recommendedMet": "점수가 권장하는 세 영역을 모두 채웠어요", "areas": "건강 영역 {required}개 중 {present}개" - } + }, + "historyLink": "추이" }, "dailyBriefing": { "title": "데일리 브리핑", @@ -2778,7 +2826,7 @@ "experimentCta": "7일 실험 해보기", "experimentTooltip": "곧 제공돼요", "emptyTitle": "이 관계를 보려면 데이터가 더 필요해요", - "sourceChip": "짝지은 측정값 {n}개 기준 · {window}", + "sourceChip": "짝지은 측정값 {n}개 기준 ({window})", "sourceWindowLast30": "최근 30일", "confidenceHigh": "신뢰도 높음", "confidenceModerate": "신뢰도 보통", @@ -2793,14 +2841,14 @@ } }, "coach": { - "refusalOutOfScope": "기록에 있는 건강 지표 — 측정값, 약, 기분, 추이 — 만 도와드릴 수 있어요. 그 밖의 질문에는 답할 수 없어요.", - "refusalInjection": "제 지시를 덮어쓰려는 표현이 보였어요. 저는 HealthLog 코치이고 회원님의 건강 데이터만 정리해 드려요 — 그 범위로 질문을 다시 적어 주세요.", + "refusalOutOfScope": "기록에 있는 건강 지표(측정값, 약, 기분, 추이)만 도와드릴 수 있어요. 그 밖의 질문에는 답할 수 없어요.", + "refusalInjection": "제 지시를 덮어쓰려는 표현이 보였어요. 저는 HealthLog 코치이고 회원님의 건강 데이터만 정리해 드려요. 그 범위로 질문을 다시 적어 주세요.", "errorBudget": "오늘 코치 사용 한도에 도달했어요. 사용량은 UTC 자정에 초기화돼요.", "errorProvider": "지금은 코치가 인사이트 제공자에 연결하지 못했어요. 잠시 후 다시 시도해 주세요.", "errorDemo": "데모에서는 코치가 답하지 않아요.", - "errorNoProvider": "코치는 기록된 건강 데이터를 읽고 그에 대한 질문에 답해요(의학적 조언은 아니에요). 그러려면 HealthLog에 AI 제공자가 필요해요 — 직접 발급한 키나 로컬 모델을 연결해 주세요.", + "errorNoProvider": "코치는 기록된 건강 데이터를 읽고 그에 대한 질문에 답해요(의학적 조언은 아니에요). 그러려면 HealthLog에 AI 제공자가 필요해요. 직접 발급한 키나 로컬 모델을 연결해 주세요.", "errorNoProviderAction": "AI 제공자 연결", - "errorNetwork": "인터넷에 연결되어 있지 않아요 — 다시 연결되면 시도해 주세요.", + "errorNetwork": "인터넷에 연결되어 있지 않아요. 다시 연결되면 시도해 주세요.", "prefsSaveError": "코치 설정을 저장하지 못했어요. 다시 시도해 주세요.", "errorCredentialExpired": "AI 제공자 연결이 만료됐어요. 코치를 계속 쓰려면 설정에서 다시 연결해 주세요.", "tagline": "내 데이터에 기반한 개인 건강 코치.", @@ -2819,7 +2867,7 @@ "thinking": "생각 중…", "unverifiedFiguresNotice": "일부 수치는 데이터와 대조하지 못해 제외했어요.", "composerPlaceholder": "무엇이든 물어보세요…", - "nudgeBubbleLabel": "코치의 새 메시지 — 대화 열기", + "nudgeBubbleLabel": "코치의 새 메시지: 대화 열기", "fabLabel": "코치 열기", "readAloud": "소리내어 읽기", "readAloudStop": "읽기 중지", @@ -2843,7 +2891,7 @@ "guided": { "offerTitle": "코치가 물어볼 후속 질문이 {count}개 있어요", "offerTitleOne": "코치가 물어볼 후속 질문이 있어요", - "offerBody": "짧은 답변이 셀프 컨텍스트를 더 정확하게 만들어요 — 여기 대화에서 한 번에 하나씩 물어볼게요.", + "offerBody": "짧은 답변이 셀프 컨텍스트를 더 정확하게 만들어요. 여기 대화에서 한 번에 하나씩 물어볼게요.", "offerStart": "지금 답하기", "offerLater": "나중에", "offerDismiss": "다시 묻지 않기", @@ -2853,7 +2901,7 @@ "later": "나중에", "dismissRemaining": "다시 묻지 않기", "answerPlaceholder": "답변을 입력하세요…", - "summaryTitle": "다 됐어요 — 답변 고마워요!", + "summaryTitle": "다 됐어요. 답변 고마워요!", "summaryAnswered": "{total}개 중 {answered}개 답변", "summaryAdopted": "셀프 컨텍스트에 {adopted}개 추가", "summaryAdoptedNone": "셀프 컨텍스트에 새로 추가된 건 없어요", @@ -2865,6 +2913,7 @@ "historyEmpty": "대화가 여기에 표시돼요. 무엇이든 물어보며 시작하세요.", "historyDeleteAria": "대화 삭제", "historyDeleted": "대화를 삭제했어요", + "historyDeleteFailed": "대화를 삭제하지 못했어요.", "rename": { "action": "대화 이름 바꾸기", "input": "대화 제목", @@ -2934,7 +2983,7 @@ "settingsVerbosityBrief": "짧게", "settingsVerbosityDefault": "기본", "settingsVerbosityDetailed": "자세히", - "settingsVerbosityConciseHint": "간결한 말투는 이미 답변을 짧게 유지해요 — 켜 두는 동안 답변 길이는 '짧게'로 고정돼요.", + "settingsVerbosityConciseHint": "간결한 말투는 이미 답변을 짧게 유지해요. 켜 두는 동안 답변 길이는 '짧게'로 고정돼요.", "settingsSourcesSummary": "코치는 데이터 영역 {total}개 중 {count}개를 {window} 전까지 봐요.", "settingsSourcesSummaryAll": "코치는 데이터 영역 {total}개 중 {count}개를 처음 기록부터 봐요.", "settingsSourcesPointerLink": "코치 설정 열기", @@ -2956,23 +3005,23 @@ "cluster": { "cardio": { "label": "심혈관", - "hint": "혈압 · 맥박 · 심박변이도 · 안정 시와 보행 심박수" + "hint": "혈압, 맥박, 심박변이도, 안정 시와 보행 심박수" }, "body": { "label": "체성분", - "hint": "체중 · 체지방 · 근육 · BMI" + "hint": "체중, 체지방, 근육, BMI" }, "activity": { "label": "활동", - "hint": "걸음 수 · 에너지 · 거리 · 최대산소섭취량" + "hint": "걸음 수, 에너지, 거리, 최대산소섭취량" }, "workouts": { "label": "운동", - "hint": "러닝 · 라이딩 · 세션" + "hint": "러닝, 라이딩, 세션" }, "sleep": { "label": "수면", - "hint": "수면 시간 · 단계" + "hint": "수면 시간과 단계" }, "mood": { "label": "기분", @@ -2980,19 +3029,19 @@ }, "glucose": { "label": "혈당", - "hint": "공복 · 식후 측정값" + "hint": "공복 및 식후 측정값" }, "medication": { "label": "복약", - "hint": "복약 순응도 · GLP-1 맥락" + "hint": "복약 순응도와 GLP-1 맥락" }, "mobility": { "label": "이동성과 보행", - "hint": "안정성 · 비대칭 · 보행" + "hint": "안정성, 비대칭, 보행" }, "environment": { "label": "환경", - "hint": "소음 · 햇빛 · 피부 온도" + "hint": "소음, 햇빛, 피부 온도" } }, "settingsClustersLabel": "코치가 보는 데이터", @@ -3026,40 +3075,26 @@ "askAboutThis": "코치에게 물어보기", "readStrip": { "label": "평소 범위", - "baselineWithin": "평소 범위: {low}–{high} {unit}. 오늘 값 {value} — 범위 안이에요.", - "baselineAbove": "평소 범위: {low}–{high} {unit}. 오늘 값 {value} — 범위 위예요.", - "baselineBelow": "평소 범위: {low}–{high} {unit}. 오늘 값 {value} — 범위 아래예요.", - "sameHoursWithin": "이 시간대까지 평소 범위: {low}–{high} {unit}. 오늘 지금까지 {value} — 범위 안이에요.", - "sameHoursAbove": "이 시간대까지 평소 범위: {low}–{high} {unit}. 오늘 지금까지 {value} — 범위 위예요.", - "sameHoursBelow": "이 시간대까지 평소 범위: {low}–{high} {unit}. 오늘 지금까지 {value} — 범위 아래예요.", - "steadyWithin": "평소 값: {low} {unit}. 오늘 값 {value} — 평소와 같아요.", - "steadyAbove": "평소 값: {low} {unit}. 오늘 값 {value} — 평소보다 높아요.", - "steadyBelow": "평소 값: {low} {unit}. 오늘 값 {value} — 평소보다 낮아요.", - "latestWithin": "평소 범위: {low}–{high} {unit}. 마지막 측정({date}) 값 {value} — 범위 안이었어요.", - "latestAbove": "평소 범위: {low}–{high} {unit}. 마지막 측정({date}) 값 {value} — 범위보다 높았어요.", - "latestBelow": "평소 범위: {low}–{high} {unit}. 마지막 측정({date}) 값 {value} — 범위 아래였어요.", - "steadyLatestWithin": "평소 값: {low} {unit}. 마지막 측정({date}) 값 {value} — 평소와 같았어요.", - "steadyLatestAbove": "평소 값: {low} {unit}. 마지막 측정({date}) 값 {value} — 평소보다 높았어요.", - "steadyLatestBelow": "평소 값: {low} {unit}. 마지막 측정({date}) 값 {value} — 평소보다 낮았어요.", - "insufficient": "아직 평소 범위를 배우는 중이에요 — 며칠 더 기록해 주세요.", + "baselineWithin": "평소 범위: {low}–{high} {unit}. 오늘 값 {value}, 범위 안이에요.", + "baselineAbove": "평소 범위: {low}–{high} {unit}. 오늘 값 {value}, 범위 위예요.", + "baselineBelow": "평소 범위: {low}–{high} {unit}. 오늘 값 {value}, 범위 아래예요.", + "sameHoursWithin": "이 시간대까지 평소 범위: {low}–{high} {unit}. 오늘 지금까지 {value}, 범위 안이에요.", + "sameHoursAbove": "이 시간대까지 평소 범위: {low}–{high} {unit}. 오늘 지금까지 {value}, 범위 위예요.", + "sameHoursBelow": "이 시간대까지 평소 범위: {low}–{high} {unit}. 오늘 지금까지 {value}, 범위 아래예요.", + "steadyWithin": "평소 값: {low} {unit}. 오늘 값 {value}, 평소와 같아요.", + "steadyAbove": "평소 값: {low} {unit}. 오늘 값 {value}, 평소보다 높아요.", + "steadyBelow": "평소 값: {low} {unit}. 오늘 값 {value}, 평소보다 낮아요.", + "latestWithin": "평소 범위: {low}–{high} {unit}. 마지막 측정({date}) 값 {value}, 범위 안이었어요.", + "latestAbove": "평소 범위: {low}–{high} {unit}. 마지막 측정({date}) 값 {value}, 범위보다 높았어요.", + "latestBelow": "평소 범위: {low}–{high} {unit}. 마지막 측정({date}) 값 {value}, 범위 아래였어요.", + "steadyLatestWithin": "평소 값: {low} {unit}. 마지막 측정({date}) 값 {value}, 평소와 같았어요.", + "steadyLatestAbove": "평소 값: {low} {unit}. 마지막 측정({date}) 값 {value}, 평소보다 높았어요.", + "steadyLatestBelow": "평소 값: {low} {unit}. 마지막 측정({date}) 값 {value}, 평소보다 낮았어요.", + "insufficient": "아직 평소 범위를 배우는 중이에요. 며칠 더 기록해 주세요.", "driver": "{note}" }, "scope": { - "prefix": "코치가 이미 보고 있어요: {metric}", - "tap": "탭해서 물어보기", - "question": "이 지표를 설명해 주세요 — 눈여겨볼 게 있나요?" - }, - "seeded": { - "prefix": "오늘 살펴볼 만한 것: {signal}", - "signal": { - "readiness": "준비도", - "recovery": "회복" - }, - "question": { - "readiness": "오늘 준비도가 평소와 달라요 — 무엇 때문일까요?", - "recovery": "회복 상태가 달라졌어요 — 이유가 뭘까요?" - }, - "dismiss": "닫기" + "prefix": "코치가 이미 보고 있어요: {metric}" }, "settings": "설정", "history": { @@ -3069,39 +3104,39 @@ "groupEarlier": "이전" }, "seed": { - "bloodPressure": "최근 30일 혈압 추이를 설명해 주세요 — 눈여겨볼 게 있나요?", + "bloodPressure": "최근 30일 혈압 추이를 설명해 주세요. 눈여겨볼 게 있나요?", "weight": "요즘 체중 추이가 어떤가요? 그 방향이 신경 쓸 만한가요?", "bmi": "제 BMI 추이는 무엇을 말해 주나요? 어떻게 읽어야 하나요?", - "pulse": "제 맥박 측정값을 설명해 주세요 — 평소와 다른 점이 있나요?", + "pulse": "제 맥박 측정값을 설명해 주세요. 평소와 다른 점이 있나요?", "restingHr": "안정 시 심박수 추이는 어떤가요? 제 체력에 대해 뭘 알려 주나요?", "hrv": "요즘 제 심박변이도는 회복과 스트레스에 대해 뭘 말해 주나요?", - "sleep": "최근 수면을 설명해 주세요 — 수면 시간, 규칙성, 바꿀 만한 점까지.", + "sleep": "최근 수면을 설명해 주세요. 수면 시간, 규칙성, 바꿀 만한 점까지.", "mood": "최근 몇 주 동안 제 기분에서 어떤 패턴이 보이나요?", "medications": "제 복약 순응도는 어떤가요? 요즘 컨디션과 맞물리나요?", "steps": "걸음 수로 볼 때 요즘 얼마나 활동적이었나요? 건강한 기준값과 비교하면 어떤가요?", "activeEnergy": "활동 에너지 추이는 요즘 제 활동량에 대해 뭘 말해 주나요?", "cardioFitness": "심폐 체력(VO₂ max) 추이는 무슨 뜻인가요? 어떻게 하면 좋은 방향으로 올릴 수 있나요?", - "bloodGlucose": "최근 혈당 측정값을 설명해 주세요 — 건강한 범위 안인가요?", + "bloodGlucose": "최근 혈당 측정값을 설명해 주세요. 건강한 범위 안인가요?", "oxygenSaturation": "제 산소포화도(SpO₂) 추이는 무엇을 말해 주나요?", "respiratoryRate": "제 호흡수 추이는 건강에 대해 뭘 말해 주나요?", - "workouts": "최근 운동을 설명해 주세요 — 운동량, 빈도, 사이사이 회복까지.", + "workouts": "최근 운동을 설명해 주세요. 운동량, 빈도, 사이사이 회복까지.", "recoveryPage": "지금 제 회복 상태가 이런 이유는 뭔가요? 무엇이 영향을 주고 있나요?", - "briefing": "오늘 브리핑을 설명해 주세요 — 무엇에 집중해야 하고, 지금 할 만한 일이 있나요?", + "briefing": "오늘 브리핑을 설명해 주세요. 무엇에 집중해야 하고, 지금 할 만한 일이 있나요?", "healthScore": "제 건강 점수가 100점 중 {score}점인 이유는 뭔가요? 무엇을 바꾸면 가장 크게 오를까요?", - "periodWeek": "지난주 건강 지표들이 어땠는지 설명해 주세요 — 눈에 띄는 게 있나요?", - "periodMonth": "지난달 건강 지표들이 어땠는지 설명해 주세요 — 눈에 띄는 게 있나요?", + "periodWeek": "지난주 건강 지표들이 어땠는지 설명해 주세요. 눈에 띄는 게 있나요?", + "periodMonth": "지난달 건강 지표들이 어땠는지 설명해 주세요. 눈에 띄는 게 있나요?", "correlationBpCompliance": "제 복약 순응도가 혈압과 어떤 관계인가요? 이 상관관계가 저에게 어떤 의미인지 설명해 주세요.", "correlationMoodPulse": "제 기분과 맥박 사이에 실제로 연관이 있나요? 이 상관관계를 읽는 법을 알려 주세요.", "correlationWeightWeekday": "제 체중이 주간 패턴을 따르나요? 이 요일별 상관관계는 무엇을 말해 주나요?", - "recommendation": "이 추천에 대해 더 알려 주세요: '{text}' — 왜 저에게 해당하고, 무엇을 하면 되나요?" + "recommendation": "이 추천에 대해 더 알려 주세요: '{text}'. 왜 저에게 해당하고, 무엇을 하면 되나요?" }, - "assessmentPrompt": "제 {metric} 평가를 설명해 주세요 — 무슨 뜻이고 무엇을 하면 되나요?", + "assessmentPrompt": "제 {metric} 평가를 설명해 주세요. 무슨 뜻이고 무엇을 하면 되나요?", "attach": { "menuChooseFromDocuments": "문서에서 선택", "menuUploadNew": "새 문서 업로드", "trigger": "문서 첨부", "pickerTitle": "문서 첨부", - "pickerDescription": "코치는 첨부한 문서만 읽어요 — 건강 데이터는 읽지 않아요.", + "pickerDescription": "코치는 첨부한 문서만 읽어요. 건강 데이터는 읽지 않아요.", "pickerSearchPlaceholder": "문서 검색…", "pickerEmpty": "보관함에 문서가 아직 없어요.", "pickerNotIndexed": "아직 색인되지 않음", @@ -3109,13 +3144,13 @@ "pickerConfirm": "첨부 ({count})", "pillRemove": "제거: {title}", "indexing": "색인 중…", - "indexingHint": "아직 색인 중인 문서가 있어요 — 준비되면 보낼 수 있어요.", - "fencedHint": "이 대화는 건강 데이터에 접근하지 않아요 — 코치는 첨부한 문서만 읽어요.", + "indexingHint": "아직 색인 중인 문서가 있어요. 준비되면 보낼 수 있어요.", + "fencedHint": "이 대화는 건강 데이터에 접근하지 않아요. 코치는 첨부한 문서만 읽어요.", "documentsAttachedOne": "문서 1개 첨부됨", "documentsAttachedOther": "문서 {count}개 첨부됨", "railBadge": "문서 전용 대화", "uploadError": "업로드하지 못했어요. 다시 시도해 주세요.", - "notFoundError": "그 문서 링크는 더 이상 유효하지 않아요 — 일반 대화로 시작할게요." + "notFoundError": "그 문서 링크는 더 이상 유효하지 않아요. 일반 대화로 시작할게요." }, "interrupted": { "note": "이 답변은 끝나기 전에 중단됐어요.", @@ -3180,9 +3215,9 @@ "cap": "단계가 많아 이제 정리하는 중", "noProgress": "새 데이터가 없어 정리하는 중" }, - "thoughtProcessOne": "사고 과정 · {count}단계", - "thoughtProcessFew": "사고 과정 · {count}단계", - "thoughtProcessOther": "사고 과정 · {count}단계", + "thoughtProcessOne": "사고 과정 ({count}단계)", + "thoughtProcessFew": "사고 과정 ({count}단계)", + "thoughtProcessOther": "사고 과정 ({count}단계)", "awaitingAnswer": "답변이 필요해요" }, "memory": { @@ -3222,7 +3257,8 @@ "capped": "(관리자가 제한함)", "disabled": "관리자가 깊은 생각을 꺼 두었어요." }, - "memoryLink": "기억 ({count})" + "memoryLink": "기억 ({count})", + "newConversation": "새 대화" }, "relativeJustNow": "방금", "relativeMinutesAgoOne": "{count}분 전", @@ -3285,7 +3321,7 @@ }, "hrv": { "title": "심박변이도", - "description": "시간에 따른 심박변이도 — 신경계 회복 신호로, Apple Watch 등 호환 웨어러블이 수면 중에 측정해요.", + "description": "시간에 따른 심박변이도예요. 신경계 회복 신호로, Apple Watch 등 호환 웨어러블이 수면 중에 측정해요.", "chartTitle": "심박변이도", "emptyState": { "title": "아직 심박변이도 기록이 없어요", @@ -3294,17 +3330,17 @@ }, "restingHr": { "title": "안정 시 심박수", - "description": "심장이 하루 중 내려앉는 최저치예요 — 맥박 페이지의 단발 측정값과는 달라요. Apple Health는 하루에 한 값을 제공해요.", + "description": "심장이 하루 중 내려앉는 최저치예요. 맥박 페이지의 단발 측정값과는 달라요. Apple Health는 하루에 한 값을 제공해요.", "chartTitle": "안정 시 심박수", "emptyState": { "title": "아직 안정 시 심박수가 없어요", "description": "Apple Watch나 호환 Withings 기기를 조용한 하루 동안 착용하면 안정 시 심박수가 들어와요." }, - "coachPrefill": "아직 안정 시 심박수를 기록한 적이 없어요 — 하루 안정 시 값이 단발 맥박과 왜 다른가요? 어떻게 개선하나요?" + "coachPrefill": "아직 안정 시 심박수를 기록한 적이 없어요. 하루 안정 시 값이 단발 맥박과 왜 다른가요? 어떻게 개선하나요?" }, "oxygenSaturation": { "title": "산소포화도", - "description": "혈중 산소(SpO₂) — 임상 정상 범위는 95–100 %이고, 목표 구간을 차트에 음영으로 표시해요.", + "description": "혈중 산소(SpO₂)예요. 임상 정상 범위는 95–100 %이고, 목표 구간을 차트에 음영으로 표시해요.", "chartTitle": "산소포화도", "emptyState": { "title": "아직 SpO₂ 기록이 없어요", @@ -3313,7 +3349,7 @@ }, "bodyTemperature": { "title": "체온", - "description": "심부 체온 추이 — 섭씨. Withings의 피부 온도 측정값은 별도 지표로 따로 있어요.", + "description": "심부 체온 추이(섭씨)예요. Withings의 피부 온도 측정값은 별도 지표로 따로 있어요.", "chartTitle": "체온", "emptyState": { "title": "아직 체온 기록이 없어요", @@ -3323,7 +3359,7 @@ }, "steps": { "title": "걸음 수", - "description": "하루 걸음 수 — 하루에 한 줄씩, Apple Health나 연결된 활동 기기에서 가져와요.", + "description": "하루 걸음 수예요. 하루에 한 줄씩, Apple Health나 연결된 활동 기기에서 가져와요.", "chartTitle": "하루 걸음 수", "emptyState": { "title": "아직 걸음 수 데이터가 없어요", @@ -3332,7 +3368,7 @@ }, "activeEnergy": { "title": "활동 에너지", - "description": "하루 활동 에너지 합계(kcal) — 하루에 한 줄씩, Apple Health나 Withings 활동에서 가져와요.", + "description": "하루 활동 에너지 합계(kcal)예요. 하루에 한 줄씩, Apple Health나 Withings 활동에서 가져와요.", "chartTitle": "활동 에너지", "emptyState": { "title": "아직 활동 에너지 데이터가 없어요", @@ -3352,27 +3388,27 @@ }, "explainer": { "bloodPressureBody": "혈압은 혈액이 동맥 벽에 가하는 힘이고, 수축기 분의 이완기로 적어요(심박 중 최고치와 박동 사이의 안정값). 높은 측정값이 계속되면 시간이 지나며 심장과 혈관에 부담이 쌓여요.", - "pulseBody": "맥박은 심장이 1분에 몇 번 뛰는지예요. 한 번의 측정값은 그 순간 무엇을 하고 있었는지를 반영해요 — 안정 시에는 보통 분당 60~100회이고, 움직임·스트레스·카페인에 따라 자연스럽게 올라가요.", + "pulseBody": "맥박은 심장이 1분에 몇 번 뛰는지예요. 한 번의 측정값은 그 순간 무엇을 하고 있었는지를 반영해요. 안정 시에는 보통 분당 60~100회이고, 움직임·스트레스·카페인에 따라 자연스럽게 올라가요.", "weightBody": "체중은 장기적인 에너지 균형과 전반적인 건강을 보여주는 가장 단순한 신호 중 하나예요. 수분·음식·시간대에 따라 흔들리는 한 번의 측정값보다 몇 주에 걸친 추이가 훨씬 중요해요.", "bmiBody": "BMI는 체중을 키의 제곱으로 나눈 값으로, 넓은 체중 범주를 알려주는 하나의 숫자예요. 진단이 아니라 인구 집단 수준의 선별 지표이고, 근육과 지방을 구분하지 못해요.", "sleepBody": "수면 시간은 밤새 실제로 잠든 시간이고, 얕은 수면·깊은 수면·REM 단계로 나뉘어요. 대부분의 성인은 대략 7~9시간이 필요하고, 꾸준한 양과 질 모두가 회복·기분·대사를 뒷받침해요.", "moodBody": "기분 기록은 간단한 척도로 시간에 따른 기분을 남겨요. 꾸준히 기록하면 수면·활동·약이 기분과 어떻게 맞물리는지, 하루만 봐서는 절대 보이지 않는 패턴이 드러나요.", "medicationsBody": "복약 순응도는 처방대로 약을 얼마나 꾸준히 복용하는지이고, 여기서는 예정된 복용 중 실제로 기록한 비율로 보여줘요. 치료가 의도대로 효과를 내려면 꾸준한 복용이 관건인 경우가 많아요.", - "restingHrBody": "안정 시 심박수는 완전히 쉬고 있을 때의 심박수로, 보통 밤사이에 측정해요 — 활동 중 맥박과는 달라요. 안정 시 심박수가 낮을수록 대체로 심폐 체력이 좋다는 뜻이고, 올라가는 추이는 스트레스나 질병의 초기 신호일 수 있어요. 일반적인 기준으로 성인의 정상 안정 시 심박수는 분당 60~100회예요(AHA 2024). 단련된 심장은 40~60회에 머물러도 문제없어요.", + "restingHrBody": "안정 시 심박수는 완전히 쉬고 있을 때의 심박수로, 보통 밤사이에 측정해요. 활동 중 맥박과는 달라요. 안정 시 심박수가 낮을수록 대체로 심폐 체력이 좋다는 뜻이고, 올라가는 추이는 스트레스나 질병의 초기 신호일 수 있어요. 일반적인 기준으로 성인의 정상 안정 시 심박수는 분당 60~100회예요(AHA 2024). 단련된 심장은 40~60회에 머물러도 문제없어요.", "hrvBody": "심박변이도는 연속된 심장 박동 사이 시간의 미세한 변동이에요. 변동이 클수록 대체로 잘 회복되고 적응력 있는 신경계를 뜻하고, 지속적으로 떨어지면 피로·스트레스·회복 부족의 신호일 수 있어요.", "oxygenSaturationBody": "SpO₂는 산소를 실어 나르는 적혈구의 비율이에요. 안정 시 95~100%는 정상이고, 90% 아래에 계속 머무르면 살펴볼 필요가 있어요. 다만 밤중에 잠깐 떨어지는 건 흔해요.", "bodyTemperatureBody": "체온은 몸의 심부 열 상태를 반영해요. 일반적인 기준으로 인구 평균은 37.0°C가 아니라 약 36.6°C이고, 구강 정상 범위는 대략 35.7~37.4°C, 발열은 38.0°C부터예요(J Gen Intern Med 2019). 하루 사이 약 0.5°C 오르내리고 측정 부위에 따라 최대 1°C가량 차이 나니 어디서 쟀는지 태그해 두세요. 밤사이 손목 측정값은 임상적 발열 수치가 아니라 상대적인 변화를 따라가요.", "activeEnergyBody": "활동 에너지는 몸이 쉴 때 쓰는 기본량 위에, 움직임으로 태운 칼로리예요. 하루를 얼마나 몸을 움직였는지 직접 보여주는 지표예요.", "workoutsBody": "운동은 기기가 기록한 구조화된 운동 세션으로, 종류·시간·에너지가 함께 남아요. 기록해 두면 꾸준한 훈련이 몇 주에 걸쳐 어떻게 쌓이는지, 다른 건강 신호와 어떻게 연결되는지 보여요.", - "respiratoryRateBody": "호흡수는 1분에 숨을 몇 번 쉬는지예요. 일반적인 기준으로 안정 시 대부분의 성인은 분당 12~20회 호흡해요(American Lung Association). 정말 쉬고 있거나 잠든 상태에서만 의미가 있어요. 본인의 밤사이 기준값보다 +3 정도 지속해서 올라가면 질병의 초기 신호일 수 있어요 — 임상 경보 기준치는 병원용 도구이지 자가 경보용이 아니에요.", + "respiratoryRateBody": "호흡수는 1분에 숨을 몇 번 쉬는지예요. 일반적인 기준으로 안정 시 대부분의 성인은 분당 12~20회 호흡해요(American Lung Association). 정말 쉬고 있거나 잠든 상태에서만 의미가 있어요. 본인의 밤사이 기준값보다 +3 정도 지속해서 올라가면 질병의 초기 신호일 수 있어요. 임상 경보 기준치는 병원용 도구이지 자가 경보용이 아니에요.", "bodyWaterBody": "체수분 총량은 세포 안팎의 수분이 체중에서 차지하는 비율이에요. 수분 섭취·음식·활동에 따라 움직이니 한 번의 측정값보다 몇 주 추이가 더 많은 걸 알려줘요.", "boneMassBody": "골량은 뼈에 들어 있는 무기질의 추정 무게예요. 체중계 기반 추정은 대략적이지만, 유지되거나 올라가는 추이는 장기적인 뼈 건강에 도움이 돼요.", - "fatFreeMassBody": "제지방량은 몸에서 지방이 아닌 전부 — 근육·뼈·장기·수분이에요. 체중을 줄이면서도 이걸 유지하거나 늘리면 대체로 더 건강한 체성분을 뜻해요.", + "fatFreeMassBody": "제지방량은 몸에서 지방이 아닌 전부(근육·뼈·장기·수분)예요. 체중을 줄이면서도 이걸 유지하거나 늘리면 대체로 더 건강한 체성분을 뜻해요.", "fatMassBody": "체지방량은 몸속 지방의 총 무게예요. 지방은 호르몬과 에너지 저장에 어느 정도 꼭 필요하지만, 특히 복부에 너무 많으면 장기적인 건강 위험이 올라가요(WHO 2024).", "bodyFatBody": "체지방률은 체중 중 지방이 차지하는 비율이에요. 건강한 범위는 성별과 나이에 따라 다르고, 체중계는 생체 전기 임피던스로 추정하므로 한 번의 측정값보다 몇 주간의 추이가 더 많은 걸 알려줘요.", "muscleMassBody": "근육량은 몸속 근육의 추정 무게예요. 근육이 많을수록 근력·대사·활동성에 도움이 되고, 나이가 들수록 유지하려면 적극적인 노력이 필요해요.", "visceralFatBody": "내장지방은 복부 깊숙이 장기 주변에 쌓이는 지방이에요. 피부 밑 지방과 달리 수치가 높으면 심장병과 2형 당뇨병에 밀접하게 연결되니 대체로 낮을수록 좋아요.", - "leanBodyMassBody": "제지방 체중은 전체 체중에서 체지방을 뺀 값 — 근육·뼈·장기·수분을 합친 거예요. 체중과 함께 보면 변화가 근육에서 왔는지 지방에서 왔는지 알 수 있어요.", + "leanBodyMassBody": "제지방 체중은 전체 체중에서 체지방을 뺀 값으로, 근육·뼈·장기·수분을 합친 거예요. 체중과 함께 보면 변화가 근육에서 왔는지 지방에서 왔는지 알 수 있어요.", "flightsClimbedBody": "오른 층수는 하루에 올라간 층을 세고, 한 층은 대략 3m의 고도 상승이에요. 일상 속 활동량과 하체 사용을 보여주는 단순한 지표예요.", "walkingDistanceBody": "걷기·달리기 거리는 하루에 두 발로 이동한 거리예요. 얼마나 자주 걸었는지뿐 아니라 얼마나 멀리 움직였는지를 보여줘서 걸음 수를 보완해요.", "walkingSteadinessBody": "보행 안정성은 걸음걸이가 얼마나 안정적이고 균형 잡혔는지를 양호부터 낮음까지로 추정해요. 등급이 낮을수록 낙상 가능성이 높다는 뜻이니 임상의와 상의해 볼 만해요.", @@ -3387,30 +3423,30 @@ "headphoneAudioBody": "헤드폰 음량은 이어폰이나 헤드폰으로 얼마나 크게 듣는지를 dB로 기록해요. 볼륨을 낮추고 중간중간 쉬어 주면 오래도록 청력을 지킬 수 있어요.", "audioEventsBody": "큰 소리 이벤트는 주변 소음이 충분히 노출될 경우 청력을 해칠 수 있는 수준에 도달한 순간을 표시해요. 자주 발생한다면 더 조용한 환경을 찾거나 청력 보호구를 쓰라는 신호예요.", "daylightBody": "햇빛 노출 시간은 하루에 야외 빛 속에서 보낸 시간이에요. 규칙적인 햇빛은 건강한 수면-각성 리듬과 기분에 도움이 되고(CDC 2024), 야외에서 보내는 시간은 눈 건강과도 연결돼요.", - "bloodGlucoseBody": "혈당은 몸의 주된 에너지원인 혈액 속 당의 양이에요. 식후에 올라가고 식사 사이에 가라앉아요. 당뇨가 없는 성인의 일반적인 기준으로 공복 혈당 {prediabetesFrom} {unit} 미만이 정상, {prediabetesFrom}~{prediabetesTo}는 당뇨 전 단계, {diabetesFrom} 이상은 당뇨 범위예요(ADA 2024). 한 번 높게 나왔다고 진단되지는 않아요 — 임상의가 재검으로 확인하고, 당뇨를 관리하는 사람은 임상의가 정한 다른 목표를 써요.", + "bloodGlucoseBody": "혈당은 몸의 주된 에너지원인 혈액 속 당의 양이에요. 식후에 올라가고 식사 사이에 가라앉아요. 당뇨가 없는 성인의 일반적인 기준으로 공복 혈당 {prediabetesFrom} {unit} 미만이 정상, {prediabetesFrom}~{prediabetesTo}는 당뇨 전 단계, {diabetesFrom} 이상은 당뇨 범위예요(ADA 2024). 한 번 높게 나왔다고 진단되지는 않아요. 임상의가 재검으로 확인하고, 당뇨를 관리하는 사람은 임상의가 정한 다른 목표를 써요.", "skinTemperatureBody": "여기서 피부 온도는 절대적인 발열 수치가 아니라 본인 기준값 대비 밤사이 손목 온도의 작은 변화예요. 이 변화는 수면·환경·질병·생리 주기 단계를 반영할 수 있어요.", "cardioFitnessBody": "VO₂ max는 최대 노력에서 몸이 쓸 수 있는 산소량으로, 유산소 체력의 가장 좋은 실험실 지표이고 웨어러블은 이를 간접 추정해요. 값이 높거나 올라가는 추이는 심혈관 건강의 강력한 신호예요.", "cardioRecoveryBody": "심박 회복은 격한 운동을 멈춘 뒤 1분 동안 심박수가 분당 몇 회 떨어지는지예요. 많이 떨어질수록 심장이 더 빨리 안정된다는 뜻이고, 체력이 좋다는 신호예요.", "wristTemperatureBody": "손목 온도는 자는 동안 측정하고 절대값이 아니라 본인 기준값과의 차이로 보여줘요. 값이 일정하면 정상이고, 지속적으로 올라가면 질병·고강도 훈련·생리 주기 변화를 암시할 수 있어요.", "fallsBody": "웨어러블이 감지한 심한 낙상 횟수를 세요. 0이 목표이고, 횟수가 늘어나면 활동성 위험 신호이니 특히 고령자라면 임상의와 이야기해 볼 만해요(CDC STEADI 2024).", - "sixMinuteWalkBody": "6분 동안 걸을 수 있는 거리의 추정치예요 — 활동성과 심폐 지구력을 보는 오래된 임상 검사죠. 웨어러블 수치는 감독하에 진행하는 임상 검사가 아니라 대리 지표이니, 유지되거나 올라가는 추이를 보는 게 쓸모 있어요.", + "sixMinuteWalkBody": "6분 동안 걸을 수 있는 거리의 추정치예요. 활동성과 심폐 지구력을 보는 오래된 임상 검사죠. 웨어러블 수치는 감독하에 진행하는 임상 검사가 아니라 대리 지표이니, 유지되거나 올라가는 추이를 보는 게 쓸모 있어요.", "stairAscentSpeedBody": "계단 한 층을 얼마나 빨리 올라가는지예요. 다리 근력과 활동성을 민감하게 보여주는 지표라, 속도가 유지되거나 빨라지면 좋은 신호이고 느려지면 지켜볼 만해요.", - "stairDescentSpeedBody": "계단 한 층을 얼마나 빨리 내려가는지예요. 오르는 속도와 함께 보면 다리 조절력과 균형을 알 수 있어요 — 속도가 일정하면 좋은 신호이고 느려지면 지켜볼 만해요.", + "stairDescentSpeedBody": "계단 한 층을 얼마나 빨리 내려가는지예요. 오르는 속도와 함께 보면 다리 조절력과 균형을 알 수 있어요. 속도가 일정하면 좋은 신호이고 느려지면 지켜볼 만해요.", "breathingDisturbancesBody": "웨어러블은 자는 동안 호흡이 얼마나 자주 끊겼는지 추정할 수 있어요. 지수가 낮을수록 좋고, 여러 밤에 걸쳐 높게 표시되면 임상의와 이야기해 볼 만해요. 수면무호흡 진단이 아니라 선별용 대리 지표예요.", "stepsBody": "걸음 수는 하루에 걷는 총 걸음으로, 일상 활동량을 보는 가장 단순한 지표예요. 하루 크게 걷는 것보다 꾸준한 습관이 더 중요하고, 딱 떨어지는 1만 걸음 목표는 연구가 아니라 마케팅 문구예요.", - "readinessBody": "준비도는 안정 시 심박수·심박변이도·수면을 묶어 하루를 감당할 몸 상태가 얼마나 준비됐는지 하나로 보여줘요. 임상 지표가 아니라 본인 기준값에 맞춘 기기 대리 지표예요 — 점수가 높으면 회복됐다는 뜻이고, 낮으면 조금 쉬어 가라는 부드러운 신호예요.", - "sleepScoreBody": "수면 점수는 하룻밤 전체를 하나의 숫자로 압축해요 — 시간·효율·규칙성, 그리고 얕은 수면·깊은 수면·REM의 균형이에요. 하룻밤의 완벽한 점수를 쫓기보다 몇 주에 걸친 패턴을 보세요.", + "readinessBody": "준비도는 안정 시 심박수·심박변이도·수면을 묶어 하루를 감당할 몸 상태가 얼마나 준비됐는지 하나로 보여줘요. 임상 지표가 아니라 본인 기준값에 맞춘 기기 대리 지표예요. 점수가 높으면 회복됐다는 뜻이고, 낮으면 조금 쉬어 가라는 부드러운 신호예요.", + "sleepScoreBody": "수면 점수는 하룻밤 전체를 하나의 숫자로 압축해요: 시간·효율·규칙성, 그리고 얕은 수면·깊은 수면·REM의 균형이에요. 하룻밤의 완벽한 점수를 쫓기보다 몇 주에 걸친 패턴을 보세요.", "recoveryBody": "회복도는 심박변이도와 안정 시 심박수 같은 밤사이 신호를 바탕으로 몸이 얼마나 잘 회복했는지 보여줘요. 임상 지표가 아니라 본인 기준값에 견줘 읽는 기기 유래 대리 지표이고, 기기끼리 비교할 수 없어요. 점수가 높으면 밀어붙여도 좋다는 신호이고, 낮으면 몸이 아직 회복 중이라는 뜻이에요.", "strainBody": "부하는 운동부터 종일 서서 활동한 하루까지, 몸에 실린 심혈관·신체 부담이 얼마나 되는지 담아요. 임상 지표가 아니라 본인의 평소 노력에 견줘 판단하는 기기 유래 지수이고, 대표 숫자는 기기끼리 비교할 수 없어요.", "stressBody": "여기서 스트레스는 코르티솔 측정이나 스스로 느끼는 감정이 아니라 심박변이도 패턴에서 끌어낸 기기 추정값이고, 임상 스트레스 점수도 아니에요. 본인 기준값에 견줘 읽으세요. 기기끼리 비교할 수 없어요. 점수가 낮으면 차분하고 잘 조절된 상태이고, 높으면 회복 시간을 넣으라는 신호예요.", "recoveryPageBody": "회복도는 웨어러블이 기록한 밤사이 신호(재충전, 부하, 누적 부담, 그날의 심박·에너지 합계)를 모아 몸이 얼마나 잘 회복했는지 보여줘요. 하나의 숫자보다 며칠에 걸친 패턴을 보세요. 낮은 값은 경보가 아니라 조금 쉬어 가라는 신호예요.", "pulsePressureBody": "맥압은 수축기와 이완기 측정값의 차이예요(수축기 빼기 이완기). 일반적인 기준으로 안정 시 40mmHg 정도가 보통이고, 중년에서 약 60mmHg가 넘게 벌어지면 동맥이 뻣뻣해지고 있음을 반영할 수 있어요. 반대로 약 25mmHg 아래로 좁으면 심박출량이 적은 상태와 함께 나타날 수 있어요(StatPearls/NIH 2023). 진단이 아니라 이야기를 시작해 볼 거리예요.", "meanArterialPressureBody": "평균 동맥압은 심장 박동 한 주기 동안 장기가 받는 평균 압력으로, 대략 이완기에 수축기까지 차이의 3분의 1을 더한 값이에요. 일반적인 기준으로 안정 시 값은 70~100mmHg 정도예요(StatPearls/NIH 2023). 흔히 인용되는 60mmHg 하한선은 급성기 진료 개념이지 소비자용 자가 경보가 아니에요.", - "cycleEducationBody": "생리 주기는 호르몬 변화가 이끄는 네 단계로 흘러가고, 주기 길이와 규칙성은 전반적인 건강을 들여다보는 손쉬운 창이에요. 일반적인 기준으로 성인의 주기는 보통 21~35일(임상 자료는 24~38일)이고 출혈은 5일 안팎이에요(ACOG 2015). 황체기가 일정한 기준점이라 배란은 다음 예정 생리에서 거꾸로 세어 추정하는 게 가장 좋아요 — 고정된 '14일째'는 절대 아니에요. 예측된 가임기는 여러 주기가 기록돼야 나오는 추정치이고 여전히 대략적이에요. 기초 체온은 배란을 예측하는 게 아니라 지나간 뒤에 확인해 줘요. 이건 피임 방법이 아니에요.", + "cycleEducationBody": "생리 주기는 호르몬 변화가 이끄는 네 단계로 흘러가고, 주기 길이와 규칙성은 전반적인 건강을 들여다보는 손쉬운 창이에요. 일반적인 기준으로 성인의 주기는 보통 21~35일(임상 자료는 24~38일)이고 출혈은 5일 안팎이에요(ACOG 2015). 황체기가 일정한 기준점이라 배란은 다음 예정 생리에서 거꾸로 세어 추정하는 게 가장 좋아요. 고정된 '14일째'는 절대 아니에요. 예측된 가임기는 여러 주기가 기록돼야 나오는 추정치이고 여전히 대략적이에요. 기초 체온은 배란을 예측하는 게 아니라 지나간 뒤에 확인해 줘요. 이건 피임 방법이 아니에요.", "sleepHygieneBody": "수면에 도움이 되는 근거 기반 습관 몇 가지예요. 주말을 포함해 잠자리에 드는 시각과 일어나는 시각을 일정하게 유지하고, 아침에는 밝은 햇빛을 쬐고 저녁에는 조명을 낮추고, 방은 시원하고 어둡고 조용하게 하고, 오후 카페인·늦은 술·늦은 과식을 피하세요(CDC 2024). 규칙성은 수면 시간만큼이나 건강을 잘 예측하고, 웨어러블의 총 수면 시간은 깊은 수면·얕은 수면·REM 구분보다 훨씬 믿을 만해요." }, "bpDerived": { - "pulsePressure": "맥압(측정값 사이의 차이)은 약 {value}mmHg예요 — 안정 시에는 40 정도가 보통이에요.", + "pulsePressure": "맥압(측정값 사이의 차이)은 약 {value}mmHg예요. 안정 시에는 40 정도가 보통이에요.", "meanArterialPressure": "평균 동맥압(장기가 받는 평균)은 약 {value}mmHg로, 보통 70~100 정도예요.", "caveat": "이 값들은 가장 최근 측정값에서 계산한 일반적인 참고 정보이고, 진단이 아니에요." }, @@ -3422,7 +3458,7 @@ "mean": "평균" }, "showAllValues": "모든 측정값 보기", - "valuesPageTitle": "{metric} — 전체 측정값", + "valuesPageTitle": "{metric}: 전체 측정값", "valuesPageDescription": "이 지표를 이루는 모든 측정값이에요. 기록을 바로 편집하거나 삭제할 수 있어요.", "diversity": { "trigger": "측정 팁", @@ -3462,7 +3498,7 @@ "inBed": "침대에 있음", "nap": "낮잠", "napCount": "낮잠 {count}회", - "unavailable": "아직 단계 데이터가 없어요 — 연결된 소스가 보내주는 대로 수면 단계가 여기에 나타나요." + "unavailable": "아직 단계 데이터가 없어요. 연결된 소스가 보내주는 대로 수면 단계가 여기에 나타나요." }, "empty": { "title": "아직 수면 데이터가 없어요", @@ -3475,21 +3511,21 @@ "caption": "최근 {nights}일 밤 동안 부족한 수면", "clearValue": "모두 채웠어요", "clearCaption": "최근 {nights}일 밤 동안 부족한 수면이 없어요", - "learning": "아직 수면을 익히는 중이에요 — 지금까지 {counted}일 밤, 믿을 만한 부족분을 읽으려면 {remaining}일 밤이 더 필요해요.", + "learning": "아직 수면을 익히는 중이에요. 지금까지 {counted}일 밤, 믿을 만한 부족분을 읽으려면 {remaining}일 밤이 더 필요해요.", "computedInfo": "이건 HealthLog가 자체적으로 낸 추정치예요. 최근 밤들의 필요 수면에서 실제 수면을 뺀 누적 잔액이고, 보충 수면이 이를 갚아 나가요. 자체 필요 수면 모델을 쓰는 웨어러블의 수면 부채 수치와는 다를 수 있어요." }, "average": { "title": "밤 평균", "value": "{hours}시간 {minutes}분", "caption": "최근 {nights}일 밤의 평균 수면", - "learning": "아직 수면을 익히는 중이에요 — 지금까지 {counted}일 밤, 믿을 만한 평균을 내려면 {remaining}일 밤이 더 필요해요." + "learning": "아직 수면을 익히는 중이에요. 지금까지 {counted}일 밤, 믿을 만한 평균을 내려면 {remaining}일 밤이 더 필요해요." }, "chronotype": { "title": "크로노타입", - "learning": "아직 리듬을 익히는 중이에요 — 자유로운 밤 {total}일 중 {counted}일이에요. 크로노타입은 알람 없이 맞은 아침이 몇 번은 쌓여야 의미가 생겨요.", + "learning": "아직 리듬을 익히는 중이에요. 자유로운 밤 {total}일 중 {counted}일이에요. 크로노타입은 알람 없이 맞은 아침이 몇 번은 쌓여야 의미가 생겨요.", "advancedToggle": "고급", "socialJetlagValue": "사회적 시차 {hours}시간 {minutes}분", - "socialJetlagCaption": "일하는 날과 쉬는 날의 수면 시각 차이예요 — 매주 작은 시차를 겪는 셈이죠.", + "socialJetlagCaption": "일하는 날과 쉬는 날의 수면 시각 차이예요. 매주 작은 시차를 겪는 셈이죠.", "msfScValue": "보정 수면 중간 시각 {clock}", "msfScCaption": "주말에 보충한 수면을 반영해 보정한 값이에요(MSFsc).", "dayTypeNote": "날짜 유형 기록이 추가되기 전까지는 쉬는 날을 주말로 가정해요.", @@ -3678,7 +3714,10 @@ "endInFuture": "종료 시간이 미래가 됩니다. 시작 시간을 운동을 시작한 시각으로 설정하세요.", "distanceInvalid": "거리를 현실적인 범위의 숫자로 입력하세요.", "energyInvalid": "활동 에너지는 20,000kcal 이하의 숫자로 입력하세요." - } + }, + "editTitle": "운동 편집", + "editAction": "편집", + "updated": "운동을 업데이트했습니다" } }, "bloodGlucose": { @@ -3693,10 +3732,10 @@ "clinical": { "title": "혈당 개요", "subtitle": "최근 {days}일, 측정값 {count}개를 바탕으로 읽은 결과예요.", - "spotCaveat": "연속 혈당 측정 프로필이 아니라 개별 측정에서 낸 단발 추정치예요 — 임상적 판정이 아니라 흐름의 방향으로 읽어 주세요.", + "spotCaveat": "연속 혈당 측정 프로필이 아니라 개별 측정에서 낸 단발 추정치예요. 임상적 판정이 아니라 흐름의 방향으로 읽어 주세요.", "labsLink": "검사 결과가 있나요? 채혈 HbA1c나 공복 혈당은 검사에 기록해 주세요.", "learningTitle": "아직 혈당을 익히는 중이에요", - "learningBody": "지금까지 {days} 동안 {count} — 기록이 조금만 더 쌓이면 더 온전한 그림이 잡혀요. 계속 기록하거나 연결된 기기가 채우게 두세요.", + "learningBody": "지금까지 {days} 동안 {count}. 기록이 조금만 더 쌓이면 더 온전한 그림이 잡혀요. 계속 기록하거나 연결된 기기가 채우게 두세요.", "learningBodyEmpty": "직접 기록하든 기기에서 동기화되든 측정값이 몇 개 들어오면, 목표 범위 내 시간·평균·변동성이 여기에 자리를 잡아요.", "readingsCount": "측정값 {count}개", "readingsCountOne": "측정값 1개", @@ -3735,7 +3774,7 @@ "intro": "더 깊이 들여다보는 연구 수준의 복합 지표예요. 각각 측정값을 위험 가중치를 매긴 하나의 숫자로 압축해요.", "jIndex": { "label": "J 지수", - "help": "평균과 그 퍼짐 정도를 합친 하나의 점수예요 — 낮을수록 더 촘촘한 관리로 읽혀요." + "help": "평균과 그 퍼짐 정도를 합친 하나의 점수예요. 낮을수록 더 촘촘한 관리로 읽혀요." }, "lbgi": { "label": "저혈당 위험(LBGI)", @@ -3852,7 +3891,7 @@ "navSkinTemperature": "피부 온도", "pulseWaveVelocity": { "title": "맥파 전파 속도", - "description": "시간에 따른 동맥 맥파 전파 속도예요(m/s) — 값이 높을수록 동맥이 뻣뻣하다는 뜻이에요.", + "description": "시간에 따른 동맥 맥파 전파 속도예요(m/s), 값이 높을수록 동맥이 뻣뻣하다는 뜻이에요.", "chartTitle": "맥파 전파 속도", "emptyState": { "title": "아직 맥파 전파 속도 데이터가 없어요", @@ -4002,7 +4041,6 @@ "navWalkingSpeed": "보행 속도", "mood": { "logLink": "기분 기록하기", - "mentalWellbeingLink": "정신 건강 체크인 하기", "heatmapTitle": "기분 캘린더", "heatmapNoEntry": "기록 없음", "legendGreat": "아주 좋음", @@ -4013,7 +4051,7 @@ "distributionDaysUnit": "일", "weekdayTitle": "요일별 평균", "weekdayNoData": "데이터 없음", - "weekdayBestWorst": "가장 좋은 요일: {best} · 가장 힘든 요일: {worst}", + "weekdayBestWorst": "가장 좋은 요일: {best}, 가장 힘든 요일: {worst}", "tagsTitle": "태그와 유발 요인", "tagAvgMood": "태그가 있는 날의 평균 기분", "correlationsTitle": "기분과 다른 지표", @@ -4029,7 +4067,7 @@ "strengthModerate": "보통", "strengthWeak": "약함", "strengthNone": "없음", - "source": "짝지은 {n}일 · r = {r}", + "source": "짝지은 {n}일, r = {r}", "sourceLabel": "계산 방법", "emptyTitle": "아직 짝지을 데이터가 부족해요", "emptyDescription": "같은 날에 기분과 이 지표를 함께 기록하면 상관관계를 볼 수 있어요.", @@ -4046,7 +4084,7 @@ "inTargetCaption": "최근 날들 중 기분 좋음 범위에 든 비율", "narrative": { "title": "눈에 띄는 점", - "weekdayDip": "{weekday}에 기분이 가장 많이 떨어져요 — 한 주 평균보다 {delta} 낮아요.", + "weekdayDip": "{weekday}에 기분이 가장 많이 떨어져요. 한 주 평균보다 {delta} 낮아요.", "weekdayPeak": "꾸준히 가장 좋은 요일은 {weekday}, 평균 {value}.", "trendUp": "최근 {days}일 동안 기분이 올라가는 추세예요(이전 기간 대비 +{delta}).", "trendDown": "최근 {days}일 동안 기분이 내려가는 추세예요(이전 기간 대비 -{delta}).", @@ -4055,8 +4093,8 @@ "tagLift": "'{tag}' 태그가 붙은 날은 전체 평균보다 {delta} 높아요.", "tagDrop": "'{tag}' 태그가 붙은 날은 평균보다 {delta} 낮아요.", "inTarget": "최근 30일 중 {pct}%는 기분 좋음 범위에 있었어요.", - "streak": "{days}일 연속으로 기분을 기록했어요 — 계속 이어가 보세요.", - "timeOfDay": "기분이 가장 좋은 시간대는 {bucket} — 평균 {value}." + "streak": "{days}일 연속으로 기분을 기록했어요. 계속 이어가 보세요.", + "timeOfDay": "기분이 가장 좋은 시간대는 {bucket}, 평균 {value}." }, "timeOfDay": { "title": "시간대별 기분", @@ -4088,7 +4126,7 @@ "crosstab": { "title": "태그와 건강 지표", "description": "각 활동을 태그한 날과 그렇지 않은 날에 건강 지표가 어떻게 다른지 보여줘요.", - "pairLabel": "{tag} · {metric}", + "pairLabel": "{tag} 있는 날의 {metric}", "metricActiveEnergy": "활동 에너지", "metricSleepDuration": "수면", "metricNextDayRecovery": "다음 날 회복도", @@ -4105,7 +4143,7 @@ "factorCrosstab": { "title": "직접 평가한 요인과 생체 지표", "description": "각 요인을 낮게 평가한 날과 높게 평가한 날에 생체 지표가 어떻게 다른지 보여줘요.", - "pairLabel": "{factor} · {metric}", + "pairLabel": "{factor} 있는 날의 {metric}", "metricSleepDuration": "수면", "metricSteps": "걸음 수", "metricRestingHeartRate": "안정 시 심박수", @@ -4126,12 +4164,12 @@ "title": "눈에 띄는 점", "subheading": "발견된 관계", "footer": "모든 지표에서 검정한 일자별 조합 {tested}개 중 허위 발견 보정을 거쳐 추려낸 결과예요.", - "disclaimer": "통계로 걸러냈지만 여전히 관찰 결과예요 — 원인이 아니라 지켜볼 만한 패턴이에요.", + "disclaimer": "통계로 걸러냈지만 여전히 관찰 결과예요. 원인이 아니라 지켜볼 만한 패턴이에요.", "moodUp": "{factor} 값이 높으면 다음 날 기분도 높은 편이에요.", "moodDown": "{factor} 값이 높으면 다음 날 기분은 낮은 편이에요.", "outcomeUp": "기분이 좋으면 다음 날 {factor} 값도 높은 편이에요.", "outcomeDown": "기분이 좋으면 다음 날 {factor} 값은 낮은 편이에요.", - "stat": "짝지은 {n}일 · r = {r} · q = {q}", + "stat": "짝지은 {n}일, r = {r}, q = {q}", "statLabel": "계산 방법", "explainerLabel": "계산 방법" }, @@ -4165,8 +4203,8 @@ "deviationAbove": "내 평가는 그 범위보다 {amount}점 높아요.", "deviationBelow": "내 평가는 그 범위보다 {amount}점 낮아요.", "weightedTitle": "이날 비교에서 가장 크게 작용한 것", - "weightedUp": "{label} — 예상값을 높이는 쪽으로 작용했어요", - "weightedDown": "{label} — 예상값을 낮추는 쪽으로 작용했어요", + "weightedUp": "{label}: 예상값을 높이는 쪽으로 작용했어요", + "weightedDown": "{label}: 예상값을 낮추는 쪽으로 작용했어요", "caveat": "이건 내 지난 날들과의 비교일 뿐, 그날이 왜 그렇게 흘러갔는지에 대한 설명은 아니에요.", "learning": "학습 중이에요: {threshold}일 중 {entries}일 기록했어요. 충분히 모이면 비교가 나타나요.", "noPattern": "지금까지 기록한 {entries}일 중에는 하루를 견줄 만큼 일정한 패턴이 없어요. 하루의 맥락까지 함께 남기면 비교할 수 있는 게 늘어나요.", @@ -4175,15 +4213,15 @@ }, "derived": { "coverage": { - "summary": "입력 {required}개 중 {present}개 · {percent}%", + "summary": "입력 {required}개 중 {present}개 ({percent}%)", "ratioLabel": "{present}/{required}", "historyDays": "최근 기록 {count}일 기준", "missing": "더 정확해지려면 기록해 보세요: {list}", - "summaryLabelled": "{label} · {percent}%" + "summaryLabelled": "{label} ({percent}%)" }, "scoreRing": { "aria": "점수 100점 만점에 {score}점, {band}", - "ariaProvisional": "아직 점수를 낼 수 없어요 — 데이터가 부족해요", + "ariaProvisional": "아직 점수를 낼 수 없어요. 데이터가 부족해요", "provisionalCaption": "아직 데이터가 부족해요", "band": { "green": "좋음", @@ -4197,7 +4235,7 @@ "inputsLabel": "입력값", "noInputs": "아직 이 값을 뒷받침한 입력값이 없어요.", "methodLabel": "방법", - "window": "최근 {days}일 기준 · {source} 데이터", + "window": "최근 {days}일간의 {source} 데이터 기준", "asOf": "{date} 기준", "standardLink": "기준: {name}" }, @@ -4207,7 +4245,7 @@ "loadingLabel": "생체 지표 불러오는 중", "loadError": "지금은 생체 지표를 불러오지 못했어요.", "infoLabel": "어떻게 계산하나요?", - "building": "내 일반 범위를 만드는 중 — {target}일 중 {count}일", + "building": "내 일반 범위를 만드는 중: {target}일 중 {count}일", "typicalRange": "내 일반 범위: {low}–{high}", "fitnessYounger": "약 {years}세 젊은 사람 수준의 심폐 체력", "fitnessOlder": "실제 나이보다 약 {years}세 높은 심폐 체력", @@ -4239,9 +4277,8 @@ "anatomy": { "outOf": "/100", "contributorsLabel": "구성 요소", - "contributorValue": "{value} · 가중치 {weight}%", + "contributorValue": "{value}, 가중치 {weight}%", "contributorMissing": "데이터 없음", - "disclaimer": "참고용이에요 — 임상 평가가 아니에요.", "insufficient": "아직 점수를 낼 데이터가 부족해요. 계속 기록하면 여기에 표시돼요." }, "composite": { @@ -4267,11 +4304,10 @@ "sleep": "수면", "respiratory": "호흡수", "mood": "기분 안정성" - }, - "caveat": "회복도나 임상 점수가 아니라 컨디션을 가늠하는 지표예요." + } }, "VITALS_BASELINE": { - "method": "개인 일반 범위는 해당 기간 일별 값의 중앙값에 견고한 산포(중앙절대편차를 보정한 값)를 더하고 뺀 구간이에요. 인구 평균이 아니라 내 측정값을 따라가요 — 그래서 이 범위 안이면 나에게는 평소 수준이에요." + "method": "개인 일반 범위는 해당 기간 일별 값의 중앙값에 견고한 산포(중앙절대편차를 보정한 값)를 더하고 뺀 구간이에요. 인구 평균이 아니라 내 측정값을 따라가요. 그래서 이 범위 안이면 나에게는 평소 수준이에요." }, "HRV_BALANCE": { "method": "최근 심박변이도(SDNN) 평균을 내 이동 기준값(중앙값 ± 견고한 산포)과 비교해요. 균형은 개인 범위 안에 있다는 뜻이고, 이 범위는 인구 기준선이 아니라 내 데이터로 만들어요." @@ -4284,49 +4320,48 @@ }, "VASCULAR_AGE_DELTA": { "method": "기기가 추정한 혈관 나이 / 맥파 속도를 실제 나이와 비교해 연 단위 차이로 보여줘요. 추정은 기기가 하고, HealthLog는 다시 보여줄 뿐이에요.", - "caveat": "기기가 추정한 값을 다시 보여주는 것뿐이에요 — 설명용이고 임상 혈관 평가가 아니에요.", + "caveat": "기기가 추정한 값을 다시 보여주는 것뿐이에요. 설명용이고 임상 혈관 평가가 아니에요.", "description": "기기가 추정한 혈관 나이가 실제 나이와 어떻게 비교되는지 보여줘요." }, "COINCIDENT_DEVIATION": { "method": "같은 날 생체 지표 두 개 이상이 개인 일반 범위를 벗어나면 표시해요. 각 범위는 내 기록의 중앙값 ± k·MAD로 만들어서 인구 기준선이 아니라 나에게 맞춰져요.", - "caveat": "내 기준값에서 나온 알아차림 신호예요 — 설명용이고 진단이나 임상 경고가 아니에요." + "caveat": "내 기준값에서 나온 알아차림 신호예요. 설명용이고 진단이나 임상 경고가 아니에요." }, "RECOVERY_SCORE": { "method": "심박변이도, 안정 시 심박수, 수면을 내 기준값과 비교해 섞은 밤마다의 0–100 회복 지표예요. 높을수록 더 잘 회복된 거예요. 매일 밤 한 번 계산해 두고 여기서 그대로 읽어와요.", - "caveat": "설명용 컨디션 지표예요. 훈련용이나 임상용 회복 측정값이 아니에요.", "description": "심박변이도, 안정 시 심박수, 수면으로 매일 밤 계산하는 회복 지표예요." }, "STRESS_SCORE": { "method": "하루 중 심박변이도의 흐름을 기준값과 비교해 매일 밤 내는 0–100 스트레스 지표예요. 높을수록 생리적 부담이 큰 거예요.", - "caveat": "심박변이도에서 뽑은 지표이고 EDA / 피부 전기 반응 측정이 아니에요 — 시계에 EDA 센서가 없어요. 설명용이고 임상용이 아니에요.", "description": "하루 중 심박변이도의 흐름으로 매일 밤 계산하는 스트레스 지표예요." }, "STRAIN_SCORE": { "method": "심박수로 가중한 활동 부하(TRIMP 방식 훈련 자극 모델)로 매일 밤 내는 0–100 부하 지표예요. 높을수록 힘든 하루였다는 뜻이에요.", - "anchorPersonal": "최근 운동한 날의 부하를 기준으로 삼아요 — 평소 강도와 비교해 점수를 매기니 나에게 정말 힘든 날이 높게 나와요.", + "anchorPersonal": "최근 운동한 날의 부하를 기준으로 삼아요. 평소 강도와 비교해 점수를 매기니 나에게 정말 힘든 날이 높게 나와요.", "anchorPopulation": "운동 기록이 쌓이는 동안에는 일반 기준으로 점수를 매기고, 충분히 모이면 내 평소 강도 기준으로 바뀌어요.", - "caveat": "설명용 활동 부하 지표예요. 임상용이나 훈련용 부하 측정값이 아니에요.", - "description": "심박수로 가중한 운동 강도를 평소 강도와 비교해 매일 밤 계산하는 활동 부하 지표예요." + "description": "심박수로 가중한 운동 강도를 평소 강도와 비교해 매일 밤 계산하는 활동 부하 지표예요.", + "deviceCaption": "기기의 일일 부하: {max} 중 {value}", + "deviceSource": "HealthLog가 밤사이 자체 값을 계산하기 전까지는 기기의 일일 부하를 0–100으로 환산해 보여 줘요." }, "WRIST_TEMPERATURE_BASELINE": { "method": "밤사이 손목 온도의 일반 범위는 해당 기간 야간 측정값의 중앙값에 견고한 산포(중앙절대편차를 보정한 값)를 더하고 뺀 구간이에요. 내 측정값을 따라가기 때문에, 지난밤은 인구 평균이 아니라 내 기준값에서 얼마나 벗어났는지로 보여줘요.", - "caveat": "개인 편차 범위일 뿐이에요 — 설명용이고 질병, 발열, 생리 주기 신호가 아니에요. 해석은 직접 해 주세요." + "caveat": "개인 편차 범위일 뿐이에요. 설명용이고 질병, 발열, 생리 주기 신호가 아니에요. 해석은 직접 해 주세요." }, "STAIR_ASCENT_SPEED_BASELINE": { "method": "계단 오르는 속도의 일반 범위는 해당 기간 일별 값의 중앙값에 견고한 산포(중앙절대편차를 보정한 값)를 더하고 뺀 구간이에요. 계단 모양과 다리 길이에 따라 속도가 달라져 인구 기준선이 없기 때문에, 범위는 내 데이터로만 만들어요.", - "caveat": "개인 이동성 추이 범위예요 — 설명용이고 노쇠나 낙상 위험 평가가 아니에요." + "caveat": "개인 이동성 추이 범위예요. 설명용이고 노쇠나 낙상 위험 평가가 아니에요." }, "STAIR_DESCENT_SPEED_BASELINE": { "method": "계단 내려가는 속도의 일반 범위는 해당 기간 일별 값의 중앙값에 견고한 산포(중앙절대편차를 보정한 값)를 더하고 뺀 구간이에요. 계단 모양과 다리 길이에 따라 속도가 달라져 인구 기준선이 없기 때문에, 범위는 내 데이터로만 만들어요.", - "caveat": "개인 이동성 추이 범위예요 — 설명용이고 노쇠나 낙상 위험 평가가 아니에요." + "caveat": "개인 이동성 추이 범위예요. 설명용이고 노쇠나 낙상 위험 평가가 아니에요." }, "SIX_MINUTE_WALK_BAND": { "method": "기기가 추정한 6분 보행 거리를 나이, 키, 체중, 성별에 맞춘 Enright & Sherrill 기준과 비교해 예측치 대비 백분율로 보여줘요. 거리는 기기의 추정값이고 여기서 다시 계산하지 않아요. 나이, 키, 체중, 성별이 없으면 백분율은 숨기고 거리와 추이만 보여줘요.", - "caveat": "발표된 기준과 비교해 다시 보여준 추정값이에요 — 신체 기능을 알아차리기 위한 신호이고 임상 6분 보행 검사나 진단이 아니에요. 이 기준은 40–80세를 대상으로 만들어졌고, 그 범위를 벗어나면 예측치는 외삽한 값이에요." + "caveat": "발표된 기준과 비교해 다시 보여준 추정값이에요. 신체 기능을 알아차리기 위한 신호이고 임상 6분 보행 검사나 진단이 아니에요. 이 기준은 40–80세를 대상으로 만들어졌고, 그 범위를 벗어나면 예측치는 외삽한 값이에요." }, "TRAJECTORY": { "method": "최근 일별 값에 최소제곱 직선을 맞춰 짧은 기간 앞으로 이어 그리고, 멀어질수록 넓어지는 표준 예측 구간을 함께 보여줘요. 최근 추이가 충분히 뚜렷하고 기록이 길고 최신일 때만 계산하고, 그렇지 않으면 선을 그리지 않아요.", - "caveat": "최근 추이를 그대로 이어 그린 것이에요 — 예측이나 목표, 임상 전망이 아니에요. 최근 패턴이 이어질 때만 맞고, 음영 구간은 그 확신이 얼마나 빨리 흐려지는지 보여줘요." + "caveat": "최근 추이를 그대로 이어 그린 것이에요. 예측이나 목표, 임상 전망이 아니에요. 최근 패턴이 이어질 때만 맞고, 음영 구간은 그 확신이 얼마나 빨리 흐려지는지 보여줘요." }, "SAME_TIME_BASELINE": { "method": "오늘 누적값을 최근 4주 동안 같은 시각의 중앙값과 비교하고, 그 주위에 중앙값 ± MAD 범위를 함께 보여줘요. 기록이 충분한 날만 계산에 넣고, 그런 날이 14일 이상 모이기 전에는 비교를 보여주지 않아요.", @@ -4341,7 +4376,9 @@ "recovery": "회복 점수", "recoveryCrossLink": "웨어러블의 회복·부하 데이터 보기", "stress": "스트레스", - "strain": "부하" + "strain": "부하", + "historyTitle": "추이", + "historyWindow": "최근 {days}일" }, "coincident": { "label": "여러 지표가 기준값 밖", @@ -4353,29 +4390,29 @@ "watch": "오늘 지표 하나가 평소 범위를 벗어났어요.", "watchVital": "범위 밖: {vital}. 대개는 한 번뿐이지만 며칠 지켜볼 만해요.", "firedHeadline": "오늘 생체 지표 {count}개가 개인 범위를 벗어났어요.", - "factors": "가능한 요인 — 원인은 아니에요: 힘든 운동, 부족한 수면, 음주, 고도, 스트레스, 아픈 날. 내 기준값에서 나온 알아차림 신호이고 진단이 아니에요.", - "building": "개인 기준값을 만드는 중이에요 — 며칠 더 기록하면 여기에 표시돼요.", + "factors": "가능한 요인(원인은 아니에요): 힘든 운동, 부족한 수면, 음주, 고도, 스트레스, 아픈 날. 내 기준값에서 나온 알아차림 신호이고 진단이 아니에요.", + "building": "개인 기준값을 만드는 중이에요. 며칠 더 기록하면 여기에 표시돼요.", "illnessExplained": "이 변화는 기록해 둔 상태 때문일 가능성이 커요." }, "trajectory": { "cardTitle": "단기 전망", - "headline": "이 패턴이 이어진다면 — 앞으로 {days}일", + "headline": "이 패턴이 이어진다면: 앞으로 {days}일", "range": "{days}일 뒤 예상 범위: 약 {low}–{high} {unit}.", "caveat": "최근 추이를 그대로 이어 그린 것이고 예측이 아니에요. 음영 구간은 불확실성이 얼마나 빨리 커지는지 보여줘요.", - "insufficient": "아직 전망을 그릴 만큼 추이가 뚜렷하지 않아요 — 계속 기록해서 최근 패턴이 뚜렷해지면 전망이 표시돼요.", + "insufficient": "아직 전망을 그릴 만큼 추이가 뚜렷하지 않아요. 계속 기록해서 최근 패턴이 뚜렷해지면 전망이 표시돼요.", "daysNeeded": "전망이 나오려면 {count}일쯤 더 기록해야 해요." }, "sameTime": { "sectionTitle": "오늘 지금까지", "sectionSubtitle": "이 시각의 평소 수준과 비교한 오늘 누적값", - "learning": "평소 하루를 익히는 중 — {target}일 중 {count}일", + "learning": "평소 하루를 익히는 중: {target}일 중 {count}일", "framing": "{time}까지 평소: {typical} {unit}", "comparisonLabel": "평소 하루" } }, "cardioFitness": { "title": "심폐 체력", - "description": "시간에 따른 VO₂ max — 유산소 체력을 대표하는 지표예요.", + "description": "시간에 따른 VO₂ max로, 유산소 체력을 대표하는 지표예요.", "chartTitle": "VO₂ max (mL/(kg·min))", "emptyState": { "title": "아직 심폐 체력 데이터가 없어요", @@ -4500,13 +4537,13 @@ "detail": "숨긴 상세 페이지는 상단 탐색에서도 사라져요." }, "customize": "인사이트 맞춤 설정", - "regenerateRateLimited": "새로고침 한도에 도달했어요 — 잠시 후 다시 시도해 주세요.", - "regenerateUnavailable": "새 분석이 도착하지 않았어요 — 잠시 후 다시 시도해 주세요.", - "regenerateError": "아직 새로고침 중이에요 — 잠시 후 다시 시도해 주세요.", + "regenerateRateLimited": "새로고침 한도에 도달했어요. 잠시 후 다시 시도해 주세요.", + "regenerateUnavailable": "새 분석이 도착하지 않았어요. 잠시 후 다시 시도해 주세요.", + "regenerateError": "아직 새로고침 중이에요. 잠시 후 다시 시도해 주세요.", "regenerateSettling": "평가를 아직 준비하고 있어요", "regenerateSettleFailed": "새로고침을 마치지 못했어요. 잠시 후 다시 시도해 주세요.", "deviceScore": { - "learning": "아직 기준값을 익히는 중이에요 — 며칠 밤 더 지나면 추이가 채워져요.", + "learning": "아직 기준값을 익히는 중이에요. 며칠 밤 더 지나면 추이가 채워져요.", "average": "30일 평균 {value}", "unitScore": "점", "unitMinutes": "min", @@ -4519,14 +4556,14 @@ }, "recovery": { "title": "회복", - "description": "몸이 얼마나 회복했는지 — 웨어러블이 밤사이 기록한 회복·부하 신호예요.", + "description": "몸이 얼마나 회복했는지: 웨어러블이 밤사이 기록한 회복·부하 신호예요.", "scoreCrossLink": "종합 회복 점수 보기", "emptyTitle": "아직 회복 데이터가 없어요", "empty": "아직 회복 데이터가 없어요. WHOOP, Polar, Oura 계정을 연결하면 기기가 동기화될 때 점수가 채워져요.", "block": { - "ansChargeExplainer": "오늘 하루를 맞을 자율신경계의 충전 상태예요 — 밤사이 심장·호흡 신호에서 읽어낸 재충전 지표예요.", + "ansChargeExplainer": "오늘 하루를 맞을 자율신경계의 충전 상태예요. 밤사이 심장·호흡 신호에서 읽어낸 재충전 지표예요.", "dayStrainExplainer": "기기의 부하 척도로 본 하루 전체의 심혈관 부하예요. 그 자체를 목표로 삼지 말고 회복과 견줘 보세요.", - "workoutStrainExplainer": "같은 부하 척도에서 운동 하나가 얼마나 힘들었는지 — 그 세션의 심혈관 비용이에요.", + "workoutStrainExplainer": "같은 부하 척도에서 운동 하나가 얼마나 힘들었는지: 그 세션의 심혈관 비용이에요.", "cardioLoadExplainer": "쌓인 급성 심혈관 부하를 따라가는 훈련 부하 지표예요. 내 최근 며칠과 비교해 보세요.", "averageHeartRateExplainer": "웨어러블이 보고한 하루 또는 주기 전체의 평균 심박수예요.", "maxHeartRateExplainer": "웨어러블이 보고한 하루 또는 주기 전체의 최고 심박수예요.", @@ -4539,7 +4576,7 @@ }, "resilience": { "explainer": "최근 며칠간의 밤사이 신호로 읽은, 몸이 하루의 부하와 회복을 얼마나 잘 맞추고 있는지예요.", - "learning": "아직 회복탄력성을 익히는 중이에요 — 며칠 더 지나면 범위가 자리 잡아요.", + "learning": "아직 회복탄력성을 익히는 중이에요. 며칠 더 지나면 범위가 자리 잡아요.", "band": { "limited": "제한적", "adequate": "충분", @@ -4565,10 +4602,10 @@ "healthStatus": { "sectionTitle": "달라진 점", "subtitle": "평소 범위에서 벗어나고 있는 신호예요.", - "deviationAbove": "{vital} — 일반 범위보다 높아요.", - "deviationBelow": "{vital} — 일반 범위보다 낮아요.", - "shiftUp": "{vital} — 최근 평소 수준보다 올라갔어요.", - "shiftDown": "{vital} — 최근 평소 수준보다 내려갔어요.", + "deviationAbove": "{vital}: 일반 범위보다 높아요.", + "deviationBelow": "{vital}: 일반 범위보다 낮아요.", + "shiftUp": "{vital}: 최근 평소 수준보다 올라갔어요.", + "shiftDown": "{vital}: 최근 평소 수준보다 내려갔어요.", "rangeContext": "지금 {value}, 평소 {low}–{high}.", "rangeContextTimeOfDay": "지금 {value}, 이 시간대 평소 {low}–{high}.", "shiftContext": "이전엔 약 {before}, 이후엔 {after}." @@ -4584,7 +4621,7 @@ "nights": "{count}일 밤 기록", "indexLine": "최근 평균 지수 {value}", "eventsFlagged": "기기가 표시한 이벤트 {count}건", - "disclaimer": "진단이 아니라 선별 신호예요 — 임상의와 상의해 주세요." + "disclaimer": "진단이 아니라 선별 신호예요. 임상의와 상의해 주세요." }, "labsChanges": { "sectionTitle": "지난 검사 이후", @@ -4600,14 +4637,14 @@ "navWaistToHeight": "허리-키 비율", "gripStrength": { "title": "악력", - "description": "최대 악력 — 전신 근력을 잘 대변하는 지표예요. 성별 기준선(남성 27kg, 여성 16kg) 아래면 근감소증 가능성을 뜻해요. 해석은 내 추이가 우선이에요.", + "description": "최대 악력으로, 전신 근력을 잘 대변하는 지표예요. 성별 기준선(남성 27kg, 여성 16kg) 아래면 근감소증 가능성을 뜻해요. 해석은 내 추이가 우선이에요.", "chartTitle": "악력", "emptyState": { "title": "아직 악력 기록이 없어요", "description": "악력계로 잰 값을 기록해 보세요. 몇 번 쥐어 본 값 중 가장 좋은 값을 남기면 추이가 여기에 표시돼요.", "cta": "악력 기록" }, - "coachPrefill": "아직 악력을 기록한 적이 없어요 — 악력은 내 근육 건강에 대해 무엇을 알려주고, 어떻게 재고 어떻게 키우나요?" + "coachPrefill": "아직 악력을 기록한 적이 없어요. 악력은 내 근육 건강에 대해 무엇을 알려주고, 어떻게 재고 어떻게 키우나요?" }, "pain": { "title": "통증 (0–10)", @@ -4618,36 +4655,36 @@ "description": "지금 느끼는 통증을 0–10 척도로 기록하면 추이가 여기에 표시돼요.", "cta": "통증 점수 기록" }, - "coachPrefill": "아직 통증 점수를 기록한 적이 없어요 — 0–10 척도로 통증을 어떻게 기록해야 하고, 어느 정도가 의미 있는 변화인가요?" + "coachPrefill": "아직 통증 점수를 기록한 적이 없어요. 0–10 척도로 통증을 어떻게 기록해야 하고, 어느 정도가 의미 있는 변화인가요?" }, "waist": { "title": "허리둘레", - "description": "센티미터로 잰 허리둘레 — 복부 지방의 지표예요. WHO 위험 증가 기준선은 성별에 따라 달라요(남성 94cm, 여성 80cm). 해석은 내 추이가 우선이에요.", + "description": "센티미터로 잰 허리둘레로, 복부 지방의 지표예요. WHO 위험 증가 기준선은 성별에 따라 달라요(남성 94cm, 여성 80cm). 해석은 내 추이가 우선이에요.", "chartTitle": "허리둘레", "emptyState": { "title": "아직 허리둘레 측정값이 없어요", "description": "가장 아래 갈비뼈와 골반뼈의 중간 지점에서, 숨을 편히 내쉰 끝에 허리를 재서 기록해 주세요.", "cta": "허리둘레 기록" }, - "coachPrefill": "아직 허리둘레를 기록한 적이 없어요 — 허리둘레는 어떻게 정확히 재고, 그 숫자가 건강에 어떤 의미인가요?" + "coachPrefill": "아직 허리둘레를 기록한 적이 없어요. 허리둘레는 어떻게 정확히 재고, 그 숫자가 건강에 어떤 의미인가요?" }, "waistToHeight": { "title": "허리-키 비율", - "description": "허리둘레를 키로 나눈 값 — 인종에 관계없이 통하는 단일 지표예요. 0.5 미만(허리둘레가 키의 절반 아래)으로 유지하는 게 간단한 목표예요.", + "description": "허리둘레를 키로 나눈 값으로, 인종에 관계없이 통하는 단일 지표예요. 0.5 미만(허리둘레가 키의 절반 아래)으로 유지하는 게 간단한 목표예요.", "chartTitle": "허리-키 비율", "emptyState": { "title": "아직 허리-키 비율이 없어요", "description": "허리-키 비율을 기록하면 추이가 여기에 표시돼요. 요령은 간단해요. 허리둘레를 키의 절반 아래로 유지하세요.", "cta": "허리-키 비율 기록" }, - "coachPrefill": "아직 허리-키 비율을 기록한 적이 없어요 — 이 숫자가 왜 유용하고, 어떻게 0.5 미만으로 유지하나요?" + "coachPrefill": "아직 허리-키 비율을 기록한 적이 없어요. 이 숫자가 왜 유용하고, 어떻게 0.5 미만으로 유지하나요?" }, "ecg": { "sectionTitle": "심전도 기록", "loadError": "심전도 기록을 불러오지 못했어요.", "backToList": "기록 목록으로", "clinicianNote": "결과가 걱정된다면 이 기록을 임상의와 상의해 보세요.", - "resultAttribution": "기록된 결과: {result} — 측정 기기가 보고한 값이에요.", + "resultAttribution": "기록된 결과: {result}. 측정 기기가 보고한 값이에요.", "result": { "irregular": "심방세동 감지", "notDetected": "심방세동 징후 없음", @@ -4681,9 +4718,9 @@ "emptyCta": "기기 연결" }, "intradayPulse": { - "title": "하루의 흐름", - "caption": "10분 단위로 평균 낸 하루 동안의 심박수예요.", - "hourlyNote": "이 날짜는 시간별 평균으로 보여줘요 — 최근 며칠은 10분 단위까지 자세히 볼 수 있어요.", + "title": "하루 동안의 맥박", + "caption": "10분 단위로 평균했어요.", + "hourlyNote": "이 날짜는 시간별 평균으로 보여줘요. 최근 며칠은 10분 단위까지 자세히 볼 수 있어요.", "empty": "이 날짜는 심박수 측정값이 적어서 아직 흐름을 보여줄 수 없어요.", "coverageOne": "{hours}h 동안 모은 측정값 1개를 기준으로 했어요.", "coverageMany": "{hours}h 동안 모은 측정값 {count}개를 기준으로 했어요.", @@ -4692,10 +4729,10 @@ "previousDay": "이전 날짜", "nextDay": "다음 날짜", "tension": { - "morning": "오늘 아침에 안정 시 심박수가 높은 구간이 이어졌어요 — 긴장이 있었을 수 있어요.", - "afternoon": "오늘 오후에 안정 시 심박수가 높은 구간이 이어졌어요 — 긴장이 있었을 수 있어요.", - "evening": "오늘 저녁에 안정 시 심박수가 높은 구간이 이어졌어요 — 긴장이 있었을 수 있어요.", - "night": "밤사이 안정 시 심박수가 높은 구간이 이어졌어요 — 긴장이 있었을 수 있어요." + "morning": "오늘 아침에 안정 시 심박수가 높은 구간이 이어졌어요. 긴장이 있었을 수 있어요.", + "afternoon": "오늘 오후에 안정 시 심박수가 높은 구간이 이어졌어요. 긴장이 있었을 수 있어요.", + "evening": "오늘 저녁에 안정 시 심박수가 높은 구간이 이어졌어요. 긴장이 있었을 수 있어요.", + "night": "밤사이 안정 시 심박수가 높은 구간이 이어졌어요. 긴장이 있었을 수 있어요." } }, "navEcg": "심전도", @@ -4706,6 +4743,19 @@ "settingsBody": "HealthLog는 AI 없이도 모든 기능이 작동합니다. 일일 브리핑, 측정값에 대한 짧은 메모, 코치를 원하시면 아래에서 제공자를 연결하세요.", "action": "AI 설정", "dismiss": "나중에" + }, + "bmiHeight": { + "coachPrefill": "아직 키를 입력하지 않았어요. BMI가 왜 중요하고, 입력하기 전에 무엇을 알아야 하나요?" + }, + "medicationsEmpty": { + "coachPrefill": "아직 약을 추가하지 않았어요. 여기서 복약 순응도를 기록하기 전에 무엇을 알아야 하나요?" + }, + "scoreHistory": { + "healthTitle": "건강 점수 추이", + "healthDescription": "날마다 표시된 그대로의 건강 점수입니다.", + "emptyDescription": "이 기간에는 아직 기록된 점수가 없습니다.", + "usualRange": "음영: 평소 범위 {lo}에서 {hi}까지.", + "seam": "점수에 반영되는 항목이 바뀐 지점에서 선이 끊깁니다." } }, "thresholds": { @@ -4735,14 +4785,15 @@ "metricBodyFat": "체지방", "metricSleep": "수면 시간", "metricSteps": "하루 걸음 수", - "metricGlucoseFasting": "혈당 — 공복", - "metricGlucosePostprandial": "혈당 — 식후", - "metricGlucoseRandom": "혈당 — 임의", - "metricGlucoseBedtime": "혈당 — 취침 전", + "metricGlucoseFasting": "혈당 (공복)", + "metricGlucosePostprandial": "혈당 (식후)", + "metricGlucoseRandom": "혈당 (임의)", + "metricGlucoseBedtime": "혈당 (취침 전)", "metricBodyWater": "체수분", "metricBoneMass": "골량", "metricOxygenSaturation": "산소포화도(SpO₂)", - "unsetExplanation": "사용자 설정 없음 — 계산된 기본값을 써요." + "unsetExplanation": "사용자 설정 없음: 계산된 기본값을 써요.", + "cardTitle": "지표별 범위" }, "onboarding": { "errorGeneric": "설정을 마치는 중 문제가 생겼어요. 다시 시도해 주세요.", @@ -4755,7 +4806,7 @@ "steps": { "dashboardOverview": { "title": "내 대시보드", - "body": "대시보드는 모든 모듈을 한눈에 모아 보여줘요. 타일을 길게 누르면 순서를 바꾸거나 숨길 수 있어요(설정 → 대시보드 레이아웃). 타일은 그 뒤에 쌓인 데이터만큼만 말해 줘요 — 앞으로 나올 모듈들이 그 데이터를 채워요." + "body": "대시보드는 모든 모듈을 한눈에 모아 보여줘요. 타일을 길게 누르면 순서를 바꾸거나 숨길 수 있어요(설정 → 대시보드 레이아웃). 타일은 그 뒤에 쌓인 데이터만큼만 말해 줘요. 앞으로 나올 모듈들이 그 데이터를 채워요." }, "quickAdd": { "title": "몇 초면 기록 끝", @@ -4787,7 +4838,7 @@ }, "mood": { "title": "기분", - "body": "기분은 지금 느낌과 태그를 2초 만에 남겨요. 강점: 나만의 태그. 기분 추이는 수면, 약, 주기와 맞물리고 — 코치가 그 패턴을 찾아 줘요." + "body": "기분은 지금 느낌과 태그를 2초 만에 남겨요. 강점: 나만의 태그. 기분 추이는 수면, 약, 주기와 맞물리고, 코치가 그 패턴을 찾아 줘요." }, "insights": { "title": "인사이트", @@ -4799,15 +4850,15 @@ }, "integrations": { "title": "연동", - "body": "연동하면 Withings, WHOOP, Polar, Oura, Apple Health가 자동으로 동기화돼요. 체중, 수면, 회복이 직접 입력 없이 채워지고 — 그게 다시 인사이트와 코치로 이어져요." + "body": "연동하면 Withings, WHOOP, Polar, Oura, Apple Health가 자동으로 동기화돼요. 체중, 수면, 회복이 직접 입력 없이 채워지고, 그게 다시 인사이트와 코치로 이어져요." }, "export": { "title": "진료용 리포트", - "body": "진료용 리포트는 측정값, 약, 검사 수치, 건강검진을 PDF나 FHIR로 묶어 진료에 가져갈 수 있게 해 줘요. 모든 모듈이 여기서 만나요 — 잘 쌓인 데이터라야 온전한 리포트가 나와요." + "body": "진료용 리포트는 측정값, 약, 검사 수치, 건강검진을 PDF나 FHIR로 묶어 진료에 가져갈 수 있게 해 줘요. 모든 모듈이 여기서 만나요. 잘 쌓인 데이터라야 온전한 리포트가 나와요." }, "achievements": { "title": "업적", - "body": "업적은 꾸준함에 보상을 줘요 — 연속 기록과 이정표. 계속하기 쉽게 만드는 작은 동기 장치예요." + "body": "업적은 꾸준함에 보상을 줘요: 연속 기록과 이정표. 계속하기 쉽게 만드는 작은 동기 장치예요." }, "wrapUp": { "title": "둘러보기 끝", @@ -4859,13 +4910,13 @@ "anamnesis": { "title": "건강에 대해 (선택)", "subtitle": "HealthLog가 내 숫자를 더 공정하게 읽도록 약간의 맥락을 알려 주세요.", - "intro": "원하는 만큼만 알려 주세요 — 모든 항목은 선택이고, 설정에서 언제든 바꿀 수 있어요.", + "intro": "원하는 만큼만 알려 주세요. 모든 항목은 선택이고, 설정에서 언제든 바꿀 수 있어요.", "conditionsLabel": "앓고 있는 질환", "conditionsPlaceholder": "예: 고혈압, 2형 당뇨", "conditionsHint": "당연한 값을 경고로 띄우는 대신, 맥락 속에서 측정값을 보게 해 줘요.", "allergiesLabel": "알레르기나 불내성", "allergiesPlaceholder": "예: 페니실린, 유당", - "privacyNote": "암호화해 저장하고, 더 나은 맥락을 주는 데만 써요. 여기서 약은 묻지 않아요 — 복약 페이지에서 언제든 추가하면 돼요." + "privacyNote": "암호화해 저장하고, 더 나은 맥락을 주는 데만 써요. 여기서 약은 묻지 않아요. 복약 페이지에서 언제든 추가하면 돼요." }, "ai": { "panelTitle": "인사이트가 어떤 모습인지 보기", @@ -4874,18 +4925,18 @@ "sampleTitle": "데일리 브리핑", "sampleTag": "예시", "sampleAriaLabel": "데일리 브리핑 예시", - "sampleBody": "2주 동안 체중 1.2kg 감소. 안정 시 맥박은 58bpm으로 꾸준해요. 혈압은 7일 중 6일 범위 안 — 높았던 하루는 늦은 시간의 짠 저녁 식사 다음이었어요.", + "sampleBody": "2주 동안 체중 1.2kg 감소. 안정 시 맥박은 58bpm으로 꾸준해요. 혈압은 7일 중 6일 범위 안, 높았던 하루는 늦은 시간의 짠 저녁 식사 다음이었어요.", "sampleCaption": "예시일 뿐 내 데이터가 아니에요. 인사이트를 켜면 브리핑이 이런 식으로 읽혀요.", "ladderTitle": "켜는 방법 세 가지", - "ladderLocalTitle": "로컬 모델 — AI 요청이 내 네트워크 안에 머물러요", - "ladderLocalBody": "내 컴퓨터의 Ollama나 LM Studio를 HealthLog에 연결해요. 무료이고 완전히 비공개 — 가장 마음 편한 기본값이에요.", + "ladderLocalTitle": "로컬 모델: AI 요청이 내 네트워크 안에 머물러요", + "ladderLocalBody": "내 컴퓨터의 Ollama나 LM Studio를 HealthLog에 연결해요. 무료이고 완전히 비공개라서 가장 마음 편한 기본값이에요.", "ladderByokTitle": "내 API 키 사용", "ladderByokBody": "본인의 OpenAI나 Anthropic 키를 쓰면 돼요. 내 계정과 결제로 돌아가고, API 등급은 기본적으로 데이터를 학습에 쓰지 않아요.", "ladderOauthTitle": "구독 계정으로 로그인", "ladderOauthBody": "ChatGPT 계정을 연결해요. 토큰당 비용은 없지만 소비자 등급 약관은 내용을 학습에 쓸 수 있어서, 건강 데이터에는 기본값이 아니라 신중히 고를 선택이에요.", - "keylessLine": "AI 없이도 HealthLog는 충분히 쓸모 있어요. 타일, 차트, 추이, 내보내기 모두 그대로 작동하고 — AI는 그 위에 쉬운 말 해설을 더할 뿐이에요.", + "keylessLine": "AI 없이도 HealthLog는 충분히 쓸모 있어요. 타일, 차트, 추이, 내보내기 모두 그대로 작동하고, AI는 그 위에 쉬운 말 해설을 더할 뿐이에요.", "setupCta": "설정하기 (1분 정도)", - "sharedKeyNote": "이 인스턴스에는 이미 공용 AI 제공자가 있어서 지금 바로 인사이트를 쓸 수 있어요 — 따로 설정할 게 없어요.", + "sharedKeyNote": "이 인스턴스에는 이미 공용 AI 제공자가 있어서 지금 바로 인사이트를 쓸 수 있어요. 따로 설정할 게 없어요.", "offer": { "title": "이 인스턴스가 요약을 쉬운 말로 써 줄 수 있습니다.", "body": "운영자가 제공하는 공급자가 지난 24시간 안에 마지막으로 정상 동작했습니다. 한 번만 누르면 쉬운 말 요약이 켜지며, 그러면 데이터는 그 하나의 공급자에게만 전달되고 다른 어느 곳에도 가지 않습니다.", @@ -5111,7 +5162,7 @@ "dataSourceDescription": "Withings, WHOOP, Oura, Polar, Nightscout, Fitbit, Apple Health에서 체중·혈압·수면 등을 자동으로 동기화해요.", "dataSourceCta": "연결", "notificationsTitle": "알림 설정하기", - "notificationsDescription": "HealthLog가 어떻게 알려줄지 고르세요 — Telegram, ntfy, 웹 푸시.", + "notificationsDescription": "HealthLog가 어떻게 알려줄지 고르세요: Telegram, ntfy, 웹 푸시.", "notificationsCta": "알림 열기", "insightsTitle": "인사이트 켜기(선택)", "insightsDescription": "로컬 모델이나 내 AI 키를 넣으면 일일 브리핑과 코치가 열려요.", @@ -5259,12 +5310,10 @@ "sections": { "account": { "title": "계정", - "description": "프로필, 비밀번호, 패스키, 시작 설정 둘러보기.", "subtitle": "프로필과 신원, 로그인 정보예요." }, "integrations": { "title": "연동", - "description": "연결된 서비스, 전송 채널, 소스 우선순위를 한곳에서.", "subtitle": "데이터 소스와 기기를 연결하고 관리해요.", "connectionsHeading": "연결", "channelsHeading": "전송 채널", @@ -5273,7 +5322,6 @@ }, "notifications": { "title": "알림", - "description": "모든 알림을 한곳에서 — 무엇을 언제 알릴지, 어디로 보낼지.", "subtitle": "받을 알림과 전달될 곳을 골라요.", "remindersHeading": "알림", "matrixLink": "채널별 알림", @@ -5281,7 +5329,6 @@ }, "layout": { "title": "화면 구성", - "description": "대시보드, 인사이트, 기록 모듈의 모습과 배치를 정해요.", "backToHub": "화면 구성으로 돌아가기", "dashboard": { "title": "대시보드", @@ -5291,7 +5338,7 @@ "title": "인사이트", "description": "개요 섹션과 내비게이션 칩 순서." }, - "subtitle": "대시보드, 인사이트, 기록 모듈의 모습을 원하는 대로 바꿔요.", + "subtitle": "대시보드, 인사이트, 기록 모듈에 무엇을 어떤 순서로 보여줄지 정해요.", "medications": { "title": "복약", "description": "목록 보기와 수동 순서." @@ -5311,6 +5358,10 @@ "mood": { "title": "기분 태그", "description": "기분 선택기의 그룹, 태그, 보관된 태그." + }, + "documents": { + "title": "문서", + "description": "보기 방식과 월 배치." } }, "dashboard": { @@ -5318,15 +5369,13 @@ }, "thresholds": { "title": "목표", - "description": "지표별로 건강한 값의 목표 범위를 정해요.", "subtitle": "지표별 목표 범위를 직접 정해요." }, "sources": { "title": "소스 우선순위", - "description": "같은 날 같은 지표를 여러 소스가 기록했을 때 어느 소스를 쓸지 골라요.", "integrationsHint": "데이터 소스 연결과 해제는 연동 페이지에서 해요.", "integrationsHintLink": "연동 열기", - "cardTitle": "지표별 소스 우선순위", + "cardTitle": "지표별 순위", "help": "같은 날 여러 소스가 같은 값을 보내면 맨 위 소스를 대표값으로 써요.", "resetDefaults": "기본값으로 되돌리기", "resetDefaultsTitle": "소스 순서를 되돌릴까요?", @@ -5339,7 +5388,7 @@ "resetSuccess": "소스 우선순위를 기본값으로 되돌렸어요", "perMetricToggle": "지표별 재정의 ({count})", "perMetricHelp": "기본 순서에서 직접 바꾼 지표예요. 다른 지표는 그대로 두고 하나만 기본값으로 되돌릴 때 이 목록을 쓰세요.", - "perMetricEmpty": "지표별 재정의가 없어요 — 모든 지표가 위의 전체 순서를 써요.", + "perMetricEmpty": "지표별 재정의가 없어요. 모든 지표가 위의 전체 순서를 써요.", "resetMetric": "되돌리기", "deviceTypeToggle": "기기 유형 우선순위 ({count})", "deviceTypeHelp": "한 소스 안에서 어느 기기를 쓸지예요. Apple Watch와 iPhone이 모두 걸음 수를 기록하면 순위가 높은 기기 유형의 측정값을 대표값으로 써요.", @@ -5364,7 +5413,8 @@ "respiratoryRate": "호흡수", "recovery": "회복", "stress": "스트레스", - "sleepDebt": "수면 부채" + "sleepDebt": "수면 부채", + "hrvRmssd": "심박변이도 (RMSSD)" }, "sourceLabels": { "WITHINGS": "Withings", @@ -5381,7 +5431,8 @@ "MCP": "MCP", "GOOGLE_HEALTH": "Google Health", "STRAVA": "Strava", - "EXTERNAL": "외부 기기" + "EXTERNAL": "외부 기기", + "HEALTH_CONNECT": "Health Connect" }, "deviceLabels": { "watch": "워치", @@ -5397,32 +5448,29 @@ "title": "동기화된 영양 섭취", "description": "최근 {days}일간 Apple Health에서 동기화한 하루 합계예요.", "empty": "아직 동기화된 영양 데이터가 없어요.", - "latest": "{date} · {amount} {unit}", + "latest": "{date}에 {amount} {unit}", "daysWithData": "{days}일 중 {count}일", "moduleHint": "동기화는 영양 섭취 모듈에서 조절해요.", "moduleHintLink": "모듈 관리", - "detail": "읽기 전용 — 숫자는 원본 앱이 관리해요." + "detail": "읽기 전용이에요. 숫자는 원본 앱이 관리해요." }, - "helpDetail": "나머지는 감사 기록용으로 그대로 보관해요." + "helpDetail": "나머지는 감사 기록용으로 그대로 보관해요. 연결했거나 데이터가 있는 소스만 보여요.", + "noneInUse": "아직 순위를 정할 소스가 없어요. 연동에서 연결하거나 값을 기록해 보세요." }, "ai": { "title": "AI 제공자", - "description": "인사이트에 쓸 제공자, 모델, API 키 — 그리고 '나에 대해' 컨텍스트.", "subtitle": "AI 제공자를 고르고 직접 키를 등록해요." }, "api": { "title": "API와 토큰", - "description": "내 스크립트와 앱에서 쓸 베어러 토큰 — 어디서든 측정값이나 약 복용을 기록해요.", "subtitle": "API 엔드포인트와 액세스 토큰을 관리해요." }, "advanced": { "title": "고급", - "description": "마지막 출구: 모든 건강 기록을 영구히 지우거나 계정을 완전히 닫아요.", "subtitle": "데이터 초기화와 계정 삭제." }, "export": { "title": "내보내기와 불러오기", - "description": "데이터를 CSV나 전체 JSON 백업으로 내려받고, Apple Health·JSON·CSV에서 불러와요. 진료용 리포트는 이제 건강 기록에 있어요.", "otherOptionsHeading": "다른 내보내기 옵션", "downloadFailed": "내보내기 파일을 내려받지 못했어요. 다시 시도해 주세요.", "actions": { @@ -5453,7 +5501,7 @@ "encryptToggle": "암호문구로 암호화", "passphrasePlaceholder": "암호문구 (12자 이상)", "passphraseHelp": "Argon2id로 보관 파일 키를 만들고 AES-256-GCM으로 봉인해요. 파일은 .hlx 확장자로 저장해 주세요.", - "noRecoveryWarning": "복구 수단이 없어요 — 이 암호문구를 잃어버리면 보관 파일을 열 수 없어요. 안전한 곳에 보관해 주세요.", + "noRecoveryWarning": "복구 수단이 없어요. 이 암호문구를 잃어버리면 보관 파일을 열 수 없어요. 안전한 곳에 보관해 주세요.", "detail": "주간 자동 백업과 같은 형식이에요. 아직 담기지 않는 기록 유형이 있고, 파일의 매니페스트에 무엇이 들었는지 적혀 있어요.", "contents": "측정, 복약, 기분, 생리 주기, 검사, 아픈 날 에피소드, 알레르기, 가족력, 영양, 운동, 건강 프로필, 사용자 지표를 JSON 파일 하나에." } @@ -5469,7 +5517,7 @@ "dropHint": "또는 눌러서 파일 선택", "fileInputLabel": "Apple Health export.zip 파일 선택", "choose": "파일 선택", - "idempotencyNote": "같은 보관 파일을 다시 올리면 병합돼요 — 중복이 생기지 않아요. 큰 파일은 백그라운드에서 처리해요.", + "idempotencyNote": "같은 보관 파일을 다시 올리면 병합돼요. 중복은 생기지 않아요. 큰 파일은 백그라운드에서 처리해요.", "uploading": "올리는 중…", "uploadFailed": "올리지 못했어요. 다시 시도해 주세요.", "rateLimited": "업로드가 너무 잦아요. 잠시 뒤에 다시 시도해 주세요.", @@ -5478,7 +5526,7 @@ "failureInterrupted": "불러오기가 끝나기 전에 중단됐어요. 보통 서버 재시작이나 메모리 부족 때문이에요. 데이터는 안전하니 내보내기 파일을 다시 올려 주세요.", "failureInsufficientMemory": "이 크기의 보관 파일을 처리하기엔 서버 메모리 한도가 낮아요. 서버 운영자에게 Node.js 힙 한도(NODE_OPTIONS=--max-old-space-size)를 올려 달라고 한 뒤 내보내기 파일을 다시 올려 주세요.", "rowsImported": "지금까지 {count}개 행을 불러왔어요", - "doneSummary": "완료 — {read}개 중 {imported}개 기록을 불러왔고, 진료 기록 {skipped}개는 건너뛰었어요.", + "doneSummary": "완료: {read}개 중 {imported}개 기록을 불러왔고, 진료 기록 {skipped}개는 건너뛰었어요.", "doneNothing": "그 보관 파일에서 불러온 게 없어요: {read}개 중 0개만 들어왔어요.", "writtenByHealthLogOne": "HealthLog가 직접 건강 앱에 기록한 {count}개 항목은 이미 데이터에 있으므로 제외했습니다.", "writtenByHealthLogOther": "HealthLog가 직접 건강 앱에 기록한 {count}개 항목은 이미 데이터에 있으므로 제외했습니다.", @@ -5491,12 +5539,12 @@ "parsing": "내보내기 파일 읽는 중…", "upserting": "기록 불러오는 중…" }, - "skipUnsupported": "{count}개 샘플을 건너뛰었어요 — HealthLog가 불러오지 않는 데이터 유형이에요.", + "skipUnsupported": "{count}개 샘플을 건너뛰었어요. HealthLog가 불러오지 않는 데이터 유형이에요.", "skipRefused": "{count}개 샘플을 쓰지 못했어요 (읽을 수 없거나, 범위를 벗어났거나, 저장에 실패했어요).", "skipBreakdown": "자세히 보기 ({count}개 유형)", "skipBreakdownMore": "+ {count}개 유형 더", "cycleSummary": "생리 주기 샘플 {samples}개를 {days}일치 기록으로 합쳤어요.", - "cycleModuleOff": "생리 주기 샘플 {count}개를 건너뛰었어요 — 이 계정은 주기 기록이 꺼져 있어요.", + "cycleModuleOff": "이 계정은 주기 기록이 꺼져 있어서 생리 주기 샘플 {count}개를 건너뛰었어요.", "cycleDaysFailed": "생리 주기 {count}일치를 쓰지 못했어요: {reason}" }, "json": { @@ -5581,8 +5629,8 @@ "failed": "불러오지 못했어요. 아무것도 저장되지 않았어요.", "columnsHeading": "각 열의 역할", "jsonOnly": "JSON 전용", - "previewLine": "읽은 행: {rows} · 불러올 행: {queued} · 거부된 행: {refused}", - "submittedLine": "읽은 행: {rows} · 불러오기 대기: {queued}", + "previewLine": "읽은 행 {rows}개, 불러올 행 {queued}개, 거부된 행 {refused}개", + "submittedLine": "읽은 행 {rows}개, 불러오기 대기 {queued}개", "unmatched": "기록에서 일치하는 약이 없어요: {names}", "ambiguous": "약이 둘 이상 일치해요: {names}", "mirrored": "다른 앱에서 미러링한 약이라 복용을 추가할 수 없어요: {names}", @@ -5618,18 +5666,59 @@ "tooManyRows": "한 번에 불러올 수 있는 행 수를 넘었어요. 파일을 나눠서 불러와 주세요." }, "detail": "파일 하나에 담긴 모든 약을 함께 처리해요." + }, + "healthConnect": { + "title": "Health Connect 내보내기", + "description": "Android의 Health Connect 앱에서 데이터를 내보낼 때 만들어지는 ZIP 파일을 가져옵니다.", + "choose": "ZIP 파일 선택", + "uploading": "업로드 중…", + "uploadFailed": "업로드에 실패했습니다. 다시 시도해 주세요.", + "tooLarge": "파일이 이 서버에서 허용하는 크기보다 큽니다.", + "doneSummary": "값 {count}개를 가져왔습니다.", + "failed": "가져오기에 실패했습니다: {reason}", + "phase": { + "queued": "시작 대기 중", + "unpacking": "파일 압축 해제 중", + "parsing": "기록 읽는 중", + "upserting": "값 저장 중", + "done": "완료", + "failed": "실패" + }, + "steps": { + "export": "Android 휴대폰에서 Health Connect를 열고 데이터 관리, 내보내기 및 가져오기로 이동해 내보내기를 시작하세요.", + "cloud": "Health Connect는 내보내기를 Google Drive, Dropbox, Nextcloud 같은 클라우드 저장소 앱에만 저장하고 휴대폰 자체에는 저장하지 않습니다. 그중 하나를 고르세요.", + "upload": "그 클라우드에서 ZIP 파일을 이 기기로 내려받아 여기에 업로드하세요." + }, + "dropLabel": "Health Connect ZIP 파일을 여기에 놓으세요", + "dropHint": "또는 클릭해서 선택하세요. 최대 1GB.", + "fileInputLabel": "Health Connect 내보내기 ZIP 파일", + "privacyNote": "가져오기가 끝나면 파일은 서버에서 바로 삭제됩니다. Withings나 Fitbit처럼 이미 직접 연결한 앱의 기록은 중복되지 않도록 제외됩니다. 같은 파일을 다시 올려도 바뀌는 것은 없습니다.", + "rateLimited": "짧은 시간에 업로드가 너무 많습니다. 1분 후 다시 시도하세요.", + "busy": "계정에서 이미 가져오기가 진행 중입니다. 끝날 때까지 기다려 주세요.", + "rowsSoFar": "지금까지 {count}개 값 저장됨", + "doneNothing": "가져오기가 끝났지만 새로 추가할 내용이 없었습니다.", + "workouts": "운동", + "leftOutConnected": "이미 직접 연결한 앱의 기록 {count}개가 제외되었습니다.", + "nutrientsModuleOff": "영양소 모듈이 꺼져 있어 수분 및 영양소 합계는 가져오지 않았습니다.", + "cycleModuleOff": "생리 주기 추적이 꺼져 있어 생리 데이터는 가져오지 않았습니다.", + "failure": { + "unsupportedVersion": "이 Health Connect 내보내기는 너무 오래되어 읽을 수 없습니다. 휴대폰에서 Health Connect를 업데이트한 뒤 다시 내보내세요.", + "notHealthConnect": "이 파일은 Health Connect 내보내기가 아닙니다. Health Connect가 저장한 ZIP 파일을 선택하세요.", + "unsafeSchema": "이 파일은 Health Connect 내보내기 구조가 아니어서 읽지 않았습니다.", + "tooLarge": "내보내기 파일이 이 서버가 받을 수 있는 크기보다 큽니다.", + "stagingMissing": "업로드한 파일이 더 이상 서버에 없습니다. 다시 업로드하세요.", + "interrupted": "서버 재시작으로 가져오기가 중단되었습니다. 파일을 다시 업로드하세요." + } } }, "subtitle": "데이터를 내보내거나 백업에서 불러와요." }, "about": { "title": "앱 정보", - "description": "버전, 라이선스, 링크.", "subtitle": "버전, 라이선스, 프로젝트 링크." }, "sharing": { "title": "공유", - "description": "임상의와 건강 기록을 나눌 읽기 전용 링크를 만들어요.", "subtitle": "데이터를 읽기 전용으로 공유해요." }, "insights": { @@ -5656,7 +5745,7 @@ "error": "저장하지 못했어요. 다시 시도해 주세요.", "toggleable": { "title": "기록할 항목", - "description": "끈 모듈은 내비게이션, 타일, 인사이트에서 빠져요 — 데이터는 그대로 남아요." + "description": "끈 모듈은 내비게이션, 타일, 인사이트에서 빠지지만 데이터는 그대로 남아요." }, "manageIn": "{section}에서 관리", "operatorDisabled": "서버 전체에서 꺼짐", @@ -5664,12 +5753,10 @@ }, "gesundheitsakte": { "title": "건강 기록", - "description": "전체 건강 기록을 PDF, FHIR R4, 또는 하나로 묶은 ZIP으로 내보내요.", "subtitle": "임상의에게 바로 건넬 수 있는 완전한 건강 기록을 내보내요." }, "coach": { "title": "코치", - "description": "코치 켜기, 말투와 범위, 저장된 기억.", "subtitle": "코치의 말투, 먼저 건네는 알림, 기억을 조정해요." }, "security": { @@ -5678,8 +5765,7 @@ }, "privacy": { "title": "데이터와 개인정보", - "subtitle": "데이터, 보관 기간, 암호화, 계정 보안을 한곳에서.", - "description": "내보내기, 삭제, 보관 기간, 암호화 상태, 세션, 보안 활동." + "subtitle": "데이터, 보관 기간, 암호화, 계정 보안을 한곳에서." }, "mcp": { "title": "MCP 커넥터", @@ -5687,8 +5773,7 @@ }, "anamnesis": { "title": "병력", - "subtitle": "알레르기와 가족력을 관리해요.", - "description": "구조화된 알레르기·가족력 기록." + "subtitle": "병력, 생활 습관, 응급 정보와 AI가 읽을 수 있는 범위를 관리해요." }, "environment": { "title": "위치 및 날씨", @@ -5710,19 +5795,20 @@ "storedDays": "날씨 {count}일치를 저장했어요.", "home": { "title": "기본 위치", - "description": "도시 단위의 대략적인 위치예요 — 정밀한 이동 경로는 저장하지 않아요. 설정한 날짜부터, 위치 기간이 적용되지 않는 날에는 이곳의 날씨와 일조 시간을 가져와요.", + "description": "도시 단위의 대략적인 위치예요. 정밀한 이동 경로는 저장하지 않아요. 설정한 날짜부터, 위치 기간이 적용되지 않는 날에는 이곳의 날씨와 일조 시간을 가져와요.", "current": "현재: {label}", "none": "아직 설정한 기본 위치가 없어요.", "since": "{date}부터 날씨를 기록해요. 그 이전 날짜는 위치 기간을 추가하기 전까지 비어 있어요." }, "travel": { "title": "위치 기간", - "description": "장소와 기간을 추가하세요 — 여행일 수도, 지난 기록의 한 구간일 수도 있어요. 해당 기간에는 기본 위치 대신 이 위치가 쓰여서 실제로 있던 곳의 날씨가 반영돼요. 지난 기록을 바로잡는 방법이에요.", + "description": "장소와 기간을 추가하세요. 여행일 수도, 지난 기록의 한 구간일 수도 있어요. 해당 기간에는 기본 위치 대신 이 위치가 쓰여서 실제로 있던 곳의 날씨가 반영돼요. 지난 기록을 바로잡는 방법이에요.", "none": "아직 위치 기간이 없어요.", "add": "{label} 추가", "remove": "위치 기간 삭제", "removeConfirmTitle": "이 여행을 삭제할까요?", - "removeConfirmBody": "그 기간에 측정한 값은 기록에 그대로 남고, 여행 항목만 사라져요." + "removeConfirmBody": "그 기간에 측정한 값은 기록에 그대로 남고, 여행 항목만 사라져요.", + "range": "{start}~{end}" }, "backfill": { "title": "지난 날짜 채우기", @@ -5731,8 +5817,22 @@ }, "noResults": "일치하는 장소가 없어요.", "fetchFailedOne": "마지막 백그라운드 날씨 업데이트가 실패했어요 ({when}).", - "fetchFailedFew": "최근 백그라운드 날씨 업데이트 {count}번이 실패했어요 — 마지막은 {when}.", - "fetchFailedOther": "최근 백그라운드 날씨 업데이트 {count}번이 실패했어요 — 마지막은 {when}." + "fetchFailedFew": "최근 백그라운드 날씨 업데이트 {count}번이 실패했어요. 마지막 실패: {when}.", + "fetchFailedOther": "최근 백그라운드 날씨 업데이트 {count}번이 실패했어요. 마지막 실패: {when}.", + "airQuality": { + "title": "대기질과 꽃가루", + "description": "Open-Meteo를 통해 CAMS 모델에서 가져온 내 위치의 일별 대기질, 꽃가루, 자외선 정보입니다.", + "toggle": "대기질과 꽃가루 포함", + "operatorDisabled": "이 서버에서는 대기질 기능이 꺼져 있습니다.", + "coverage": "대기질 데이터가 있는 날 {count}일", + "pollenEuropeOnly": "꽃가루 데이터는 유럽만 제공됩니다.", + "historyProgress": "대기질 과거 데이터 채우는 중: {total}일 중 {done}일", + "historyComplete": "출처가 제공하는 기록이 있는 이전 날짜 모두에 대기질이 채워졌습니다({total}일). 그보다 오래된 날짜는 비어 있습니다.", + "attribution": "데이터 출처와 라이선스", + "saveError": "설정을 저장하지 못했습니다.", + "latest": "가장 최근 날짜 ({date})", + "modelNote": "내 지역의 실외 모델값이며, 개인 노출량이 아닙니다." + } }, "score": { "title": "건강 점수", @@ -5765,7 +5865,7 @@ "omitted": "{count}개 항목은 기록이 꺼져 있어서 목록에 없어요. 모듈을 다시 켜면 해당 항목도 돌아와요.", "omittedLink": "모듈 열기", "refusal": { - "empty": "건강 점수에는 점수를 매길 건강 영역이 최소 하나 필요해요. 하나는 선택해 두세요 — 점수가 어떤 영역을 바탕으로 하는지 알려 줘요." + "empty": "건강 점수에는 점수를 매길 건강 영역이 최소 하나 필요해요. 하나는 선택해 두세요. 점수가 어떤 영역을 바탕으로 하는지 알려 줘요." }, "notice": { "upgrade": { @@ -5859,7 +5959,6 @@ }, "changePassword": "비밀번호 변경", "changePasswordDescription": "지금 쓰는 비밀번호를 새 비밀번호로 바꿔요.", - "passwordReset": "비밀번호 재설정", "currentPassword": "현재 비밀번호", "newPassword": "새 비밀번호", "confirmNewPassword": "새 비밀번호 확인", @@ -5904,9 +6003,9 @@ "botToken": "봇 토큰", "chatId": "채팅 ID", "enableNotifications": "알림 켜기", - "telegramStep1": "1. Telegram에서 @BotFather로 봇을 만들고 토큰을 복사하세요.", - "telegramStep2": "2. 봇에게 /start를 보내 채팅을 활성화하세요.", - "telegramStep3": "3. @userinfobot이나 Bot API로 채팅 ID를 확인하세요.", + "telegramStep1": "Telegram에서 @BotFather로 봇을 만들고 토큰을 복사하세요.", + "telegramStep2": "봇에게 /start를 보내 채팅을 활성화하세요.", + "telegramStep3": "@userinfobot이나 Bot API로 채팅 ID를 확인하세요.", "ntfy": "ntfy", "ntfyDescription": "ntfy(셀프 호스팅 또는 ntfy.sh)로 보내는 알림이에요.", "ntfyEnable": "ntfy 켜기", @@ -5915,7 +6014,7 @@ "ntfyAuthToken": "인증 토큰 (선택)", "ntfyAuthTokenHint": "접근 제어가 걸린 비공개 토픽에만 필요해요.", "webhook": "웹훅", - "webhookDescription": "Gotify, Discord, Slack, Matrix, Home Assistant 등 어떤 URL로든 알림을 POST해요.", + "webhookDescription": "Discord, Slack, Gotify, Home Assistant, n8n 등 JSON을 받는 어떤 URL로든 알림을 POST해요.", "webhookEnable": "웹훅 켜기", "webhookUrl": "웹훅 URL", "webhookUrlHint": "POST를 보낼 전체 URL이에요. 사설 네트워크 주소는 서버 운영자가 허용해야 해요.", @@ -5923,6 +6022,7 @@ "webhookHeaderValue": "헤더 값 (선택)", "webhookFormat": "메시지 형식", "webhookFormatGeneric": "일반 JSON", + "webhookFormatGenericHint": "Discord나 Slack 웹후크 URL은 그대로 붙여 넣으면 돼요. Home Assistant와 n8n은 title, message, eventType, priority 필드를 읽어요.", "webhookFormatGotify": "Gotify", "webhookFormatGotifyHint": "/message로 끝나는 URL을 쓰고, 앱 토큰은 X-Gotify-Key 헤더에 넣어 주세요.", "email": "이메일", @@ -5939,7 +6039,6 @@ "webPushSubscribed": "푸시 알림을 켰어요!", "webPushUnsubscribed": "푸시 알림을 껐어요.", "webPushSubscribeFailed": "켜지 못했어요", - "kiInsightsDescription": "선택 사항: OpenAI 키를 저장하면 인사이트에서 매일 자동으로 분석해요.", "codexConnected": "ChatGPT를 연결했어요! 이제 인사이트를 쓸 수 있어요.", "codexDisconnected": "ChatGPT 연결을 해제했어요.", "codexConnectionFailed": "ChatGPT 연결에 실패했어요. 다시 시도해 주세요.", @@ -5949,7 +6048,7 @@ "rawDataWarning": "자세한 기록이 켜져 있어요. 분석할 때마다 최근 90일의 일별 평균, 그 이전 1년의 주별 평균, 최대 5년 전까지의 월별 평균이 추가로 전송돼요.", "regenerateInsights": "리포트 다시 생성", "regenerateSuccess": "리포트를 다시 생성했어요", - "regenerateRateLimit": "잠시만 기다려 주세요 — 시간당 분석 한도에 도달했어요.", + "regenerateRateLimit": "잠시만 기다려 주세요. 시간당 분석 한도에 도달했어요.", "lastGeneratedAt": "마지막 생성", "ai": { "chatgptConnectedBadge": "ChatGPT 연결됨", @@ -5959,11 +6058,11 @@ "deviceCodeHeading": "chatgpt.com에서 연결을 마무리하세요", "deviceCodeStep1": "아무 기기에서나 이 링크를 여세요:", "deviceCodeStep2": "이 일회용 코드를 입력하세요:", - "deviceCodeStep3": "연결을 승인하세요 — 이 페이지는 자동으로 갱신돼요.", + "deviceCodeStep3": "연결을 승인하세요. 이 페이지는 자동으로 갱신돼요.", "deviceCodeCopy": "복사", "deviceCodeWaiting": "승인을 기다리는 중…", "deviceCodeCancel": "취소", - "oauthNotConfigured": "이 인스턴스에는 ChatGPT OAuth가 설정돼 있지 않아요 — 아래에서 직접 발급한 API 키를 쓰세요.", + "oauthNotConfigured": "이 인스턴스에는 ChatGPT OAuth가 설정돼 있지 않아요. 아래에서 직접 발급한 API 키를 쓰세요.", "modelLabel": "모델", "modelOptionDefault": "— 기본값 —", "modelOptionCustom": "직접 입력…", @@ -5971,7 +6070,7 @@ "anthropicKeyLabel": "Anthropic API 키", "baseUrlLabel": "기본 URL", "localKeyLabel": "API 키 (선택)", - "localDescription": "OpenAI 호환 엔드포인트라면 무엇이든 쓸 수 있어요 — 로컬 모델(Ollama, LM Studio)은 물론 LiteLLM, OpenRouter, vLLM 같은 게이트웨이도요. 기본 URL은 /v1로 끝나고, API 키는 선택이에요.", + "localDescription": "OpenAI 호환 엔드포인트라면 무엇이든 쓸 수 있어요. 로컬 모델(Ollama, LM Studio)은 물론 LiteLLM, OpenRouter, vLLM 같은 게이트웨이도요. 기본 URL은 /v1로 끝나고, API 키는 선택이에요.", "savedPreview": "(저장됨: {preview})", "savedShort": "(저장됨)", "saveCta": "저장", @@ -5987,7 +6086,7 @@ "admin-openai": "관리자 OpenAI" }, "title": "대체 순서", - "description": "기본 제공자가 실패하면 HealthLog가 순서대로 다음 제공자를 시도해요. 행을 끌어 순서를 바꾸고, 스위치를 끄면 제공자를 지우지 않고 비활성화할 수 있어요.", + "description": "활성 제공자가 실패하면 HealthLog가 이 순서대로 다음 활성 제공자를 시도해요.", "moveUp": "위로", "moveDown": "아래로", "removeFromChain": "순서에서 제거", @@ -6000,11 +6099,8 @@ "resetConfirmTitle": "순서를 기본값으로 되돌릴까요?", "resetConfirmBody": "순서가 Codex → OpenAI → Anthropic → 로컬 → 관리자 OpenAI로 돌아가요. 저장한 인증 정보는 그대로예요." }, - "providerCardTitle": "AI 제공자", "activeProviderHeading": "사용 중인 제공자", - "activeProviderBody": "가장 먼저 쓸 제공자를 고르세요. 아래 양식은 선택한 제공자만 설정하고, 실패했을 때 어떻게 할지는 맨 아래 대체 순서가 정해요.", "activeProviderLabel": "기본 제공자", - "providerConfigTitle": "제공자 설정", "providerSelect": { "codex": "ChatGPT 계정 (Codex)", "openai": "OpenAI (내 API 키)", @@ -6014,7 +6110,7 @@ "admin-openai": "관리자가 제공하는 OpenAI" }, "compat": { - "description": "OpenAI /v1/chat/completions API를 쓰는 엔드포인트라면 무엇이든 정식 제공자로 쓸 수 있어요: LiteLLM, OpenRouter, vLLM 같은 게이트웨이요. 기본 URL은 /v1로 끝나고, API 키는 선택이며, 모델은 게이트웨이가 연결해 주는 것을 쓰면 돼요. OpenAI 키는 이 엔드포인트에 절대 전달되지 않아요 — 이 제공자는 자체 키를 써요.", + "description": "OpenAI /v1/chat/completions API를 쓰는 엔드포인트라면 무엇이든 정식 제공자로 쓸 수 있어요: LiteLLM, OpenRouter, vLLM 같은 게이트웨이요. 기본 URL은 /v1로 끝나고, API 키는 선택이며, 모델은 게이트웨이가 연결해 주는 것을 쓰면 돼요. OpenAI 키는 이 엔드포인트에 절대 전달되지 않아요. 이 제공자는 자체 키를 써요.", "keyLabel": "API 키 (선택)", "modelHint": "게이트웨이가 기대하는 모델 이름이에요. 비워 두면 다른 제공자에 저장한 모델을 쓰고, 둘 다 없으면 이 제공자는 건너뛰어요." }, @@ -6050,13 +6146,13 @@ "notConfigured": "운영자가 이 인스턴스에 공용 OpenAI 키를 설정하지 않았어요." }, "testProvider": "사용 중인 제공자 테스트", - "testSuccess": "정상 — {provider} ({model})", + "testSuccess": "정상: {provider} ({model})", "testFailedShort": "테스트 실패: {message}", - "testUnexpectedResponse": "AI 제공자 연결에 실패했어요 — 서버에서 예상 밖의 응답이 왔어요.", - "testReasonCredentials": "제공자가 인증 정보를 거부했어요 — AI 설정에서 다시 인증해 주세요.", - "testReasonRateLimited": "제공자가 요청 빈도를 제한했어요 — 잠시 뒤 다시 시도해 주세요.", + "testUnexpectedResponse": "AI 제공자 연결에 실패했어요. 서버에서 예상 밖의 응답이 왔어요.", + "testReasonCredentials": "제공자가 인증 정보를 거부했어요. AI 설정에서 다시 인증해 주세요.", + "testReasonRateLimited": "제공자가 요청 빈도를 제한했어요. 잠시 뒤 다시 시도해 주세요.", "testReasonServerError": "AI 제공자가 서버 오류를 반환했어요.", - "testReasonBadRequest": "제공자가 요청을 거부했어요(HTTP {status}) — 엔드포인트는 응답했으니 연결 문제가 아니라 요청 형식이나 모델 이름 문제예요.", + "testReasonBadRequest": "제공자가 요청을 거부했어요(HTTP {status}), 엔드포인트는 응답했으니 연결 문제가 아니라 요청 형식이나 모델 이름 문제예요.", "testReasonUnreachable": "AI 제공자에 연결하지 못했어요.", "coachMemory": { "title": "코치가 기억하는 것", @@ -6144,20 +6240,20 @@ "autoRead": { "title": "AI로 문서 자동 읽기", "subLabel": "문서마다 따로 누르지 않아도 설정한 AI 제공자가 문서를 읽고 색인해요.", - "honesty": "켜 두면 올리는 문서마다 설정한 AI 제공자에게 전송되어 읽히고, 설명이 붙고, 검색용으로 색인돼요 — 문서별 확인 절차는 없어요. 켜는 순간 과거 문서에도 적용돼요. 이미 올렸지만 아직 요약이 없는 문서도 켤 때마다 최대 200개까지 함께 전송돼요. 이 작업은 서버에서 이뤄지고 문서 내용은 이 기기를 떠나 그 제공자에게 전달돼요. 설정한 제공자가 구독 연결(API 키가 아니라 로그인한 AI 계정)이라면 그 제공자의 데이터 정책이 적용돼요 — 구독 제공자는 내용을 모델 개선에 쓸 수 있고, 이를 보호하는 데이터 처리 계약도 없어요. 셀프 호스팅 로컬 모델이 읽은 문서는 서버 밖으로 나가지 않아요. 기본값은 꺼짐이고, 이 조건을 받아들일 때만 켜세요.", - "confirm": "이해했어요 — 켜기", + "honesty": "켜 두면 올리는 문서마다 설정한 AI 제공자에게 전송되어 읽히고, 설명이 붙고, 검색용으로 색인돼요. 문서별 확인 절차는 없어요. 켜는 순간 과거 문서에도 적용돼요. 이미 올렸지만 아직 요약이 없는 문서도 켤 때마다 최대 200개까지 함께 전송돼요. 이 작업은 서버에서 이뤄지고 문서 내용은 이 기기를 떠나 그 제공자에게 전달돼요. 설정한 제공자가 구독 연결(API 키가 아니라 로그인한 AI 계정)이라면 그 제공자의 데이터 정책이 적용돼요. 구독 제공자는 내용을 모델 개선에 쓸 수 있고, 이를 보호하는 데이터 처리 계약도 없어요. 셀프 호스팅 로컬 모델이 읽은 문서는 서버 밖으로 나가지 않아요. 기본값은 꺼짐이고, 이 조건을 받아들일 때만 켜세요.", + "confirm": "이해했어요, 켜기", "cancel": "취소" }, "centralCodex": { "title": "서버의 공용 AI 연결 사용", - "subLabel": "운영자가 이 서버에 설정한 연결로 AI 요청을 보내고, 내 제공자는 대체 수단으로 함께 써요.", - "honesty": "공용 연결은 운영자가 이 서버의 모든 사용자를 위해 연결해 둔 로그인 AI 계정 하나예요 — 내 키가 아니에요. 이 연결로 보낸 요청은 기본적으로 제공자의 학습에 쓰이고 이를 보호하는 데이터 처리 계약도 없으며, 계정의 시간당·주당 한도를 사용을 선택한 다른 모든 사용자와 나눠 써요. 내가 설정한 제공자는 여전히 먼저 시도돼요. 이 조건을 받아들일 때만 켜세요.", - "confirm": "이해했어요 — 연결 사용", + "subLabel": "AI 요청을 서버의 공유 연결로 보내고, 내 제공자는 대체 수단으로 써요.", + "honesty": "공용 연결은 운영자가 이 서버의 모든 사용자를 위해 연결해 둔 로그인 AI 계정 하나예요. 내 키가 아니에요. 이 연결로 보낸 요청은 기본적으로 제공자의 학습에 쓰이고 이를 보호하는 데이터 처리 계약도 없으며, 계정의 시간당·주당 한도를 사용을 선택한 다른 모든 사용자와 나눠 써요. 내가 설정한 제공자는 여전히 먼저 시도돼요. 이 조건을 받아들일 때만 켜세요.", + "confirm": "이해했어요, 연결 사용", "cancel": "취소" }, "consent": { "title": "AI 동의", - "activeSince": "{date}에 동의했어요. AI 기능을 위해 건강 데이터가 설정한 제공자에게 전송될 수 있어요.", + "activeSince": "{date}에 동의했어요: AI 기능을 위해 건강 데이터가 설정한 제공자에게 전송될 수 있어요.", "withdrawn": "동의를 철회했어요. 다른 동의가 적용되지 않는 한, 코치와 인사이트는 더 이상 이 서버가 제공하는 AI 연결을 사용하지 않고 온라인 모델도 문서를 읽지 않아요. 자신의 키나 로컬 모델을 사용하는 코치와 인사이트는 계속 작동해요.", "withdraw": "동의 철회", "confirmBody": "다른 동의가 여전히 적용되지 않는 한, 다음이 즉시 중지됩니다: 이 서버가 제공하는 AI 연결을 사용하는 코치와 인사이트, 그리고 온라인 모델을 통한 문서 읽기. 자신의 키나 로컬 모델을 사용하는 코치와 인사이트는 계속 작동합니다. 인사이트에 적용되는 다른 동의가 없으면 모델이 작성한 메모가 삭제됩니다: 상태 메모, 일일 브리핑, 모델이 작성한 기간 요약, 새 측정값 알림 문장, 운동 메모. 코치 대화, 코치가 기억한 내용, 계획, 문서 요약은 그대로 남으며 직접 읽거나 삭제할 수 있습니다.", @@ -6168,7 +6264,7 @@ "error": "철회를 저장하지 못했어요. 다시 시도해 주세요.", "documentReading": { "title": "문서 읽기 동의", - "activeSince": "{date}에 동의함. 문서, 스캔한 검사 결과지, 약 설명이 읽기용으로 AI 서비스에 전송될 수 있습니다.", + "activeSince": "{date}에 동의했어요: 문서, 스캔한 검사지, 약 설명이 읽기용으로 AI 서비스에 전송될 수 있어요.", "confirmBody": "문서, 스캔한 검사 결과지, 약 설명이 더 이상 읽기용으로 전송되지 않습니다. 이미 가지고 있는 것은 삭제되지 않습니다." } }, @@ -6202,7 +6298,9 @@ "high": "높음" }, "coachNote": "코치에서는 코치 설정의 사고 깊이가 적용됩니다." - } + }, + "providerCardDescription": "HealthLog가 가장 먼저 쓰는 제공자와 그 키, 모델이에요.", + "runtimeTitle": "확인 및 새로고침" }, "dashboard": { "units": { @@ -6226,8 +6324,8 @@ "withingsClientId": "클라이언트 ID", "withingsClientSecret": "클라이언트 시크릿", "withingsCredentialsSaved": "자격 증명을 저장했어요", - "withingsCredentialsSavedPlaceholder": "저장됨 — 바꾸려면 새로 입력", - "withingsCredentialsSavedPlaceholderSecret": "저장됨 — 바꾸려면 새로 입력", + "withingsCredentialsSavedPlaceholder": "저장됨: 바꾸려면 새로 입력", + "withingsCredentialsSavedPlaceholderSecret": "저장됨: 바꾸려면 새로 입력", "withingsSaveCredentials": "저장", "configured": "설정됨", "withingsSync": "지금 동기화", @@ -6242,7 +6340,7 @@ }, "withingsFullSyncResult": "측정값 {count}개를 전체 동기화했어요", "withingsSyncFailed": "동기화에 실패했어요", - "withingsOauthConnected": "Withings를 연결했어요 — 첫 동기화가 백그라운드에서 시작돼요.", + "withingsOauthConnected": "Withings를 연결했어요. 첫 동기화가 백그라운드에서 시작돼요.", "withingsOauthFailed": "Withings 연결에 실패했어요", "withingsOauthError": { "csrf1": "브라우저 쿠키가 없어서 보안 확인에 실패했어요. 같은 브라우저에서 연결을 다시 시작해 주세요.", @@ -6267,8 +6365,8 @@ "whoopClientId": "클라이언트 ID", "whoopClientSecret": "클라이언트 시크릿", "whoopCredentialsSaved": "자격 증명을 저장했어요", - "whoopCredentialsSavedPlaceholder": "저장됨 — 바꾸려면 새로 입력", - "whoopCredentialsSavedPlaceholderSecret": "저장됨 — 바꾸려면 새로 입력", + "whoopCredentialsSavedPlaceholder": "저장됨: 바꾸려면 새로 입력", + "whoopCredentialsSavedPlaceholderSecret": "저장됨: 바꾸려면 새로 입력", "whoopSaveCredentials": "저장", "whoopSync": "지금 동기화", "whoopFullSync": "전체 데이터 동기화", @@ -6283,7 +6381,7 @@ "whoopConnect": "WHOOP으로 연결", "whoopNoCredentials": "WHOOP을 연결하려면 위에 API 자격 증명을 입력해 주세요.", "whoopBackfillInProgress": "WHOOP 기록을 백그라운드에서 가져오는 중이에요…", - "whoopOauthConnected": "WHOOP을 연결했어요 — 첫 동기화가 백그라운드에서 시작돼요.", + "whoopOauthConnected": "WHOOP을 연결했어요. 첫 동기화가 백그라운드에서 시작돼요.", "whoopOauthFailed": "WHOOP 연결에 실패했어요", "whoopOauthError": { "csrf1": "브라우저 쿠키가 없어서 보안 확인에 실패했어요. 같은 브라우저에서 연결을 다시 시작해 주세요.", @@ -6297,14 +6395,14 @@ }, "fitbit": "Fitbit", "fitbitTag": "Fitbit & Pixel", - "fitbitDescription": "Fitbit을 연결하면 Fitbit과 Pixel Watch의 건강 지표를 동기화해요 — 체중, 심박수 등.", + "fitbitDescription": "Fitbit을 연결하면 Fitbit과 Pixel Watch의 건강 지표를 동기화해요: 체중, 심박수 등.", "fitbitExperimentalBadge": "실험 기능", "fitbitCredentials": "API 자격 증명", "fitbitClientId": "클라이언트 ID", "fitbitClientSecret": "클라이언트 시크릿", "fitbitCredentialsSaved": "자격 증명을 저장했어요", - "fitbitCredentialsSavedPlaceholder": "저장됨 — 바꾸려면 새로 입력", - "fitbitCredentialsSavedPlaceholderSecret": "저장됨 — 바꾸려면 새로 입력", + "fitbitCredentialsSavedPlaceholder": "저장됨: 바꾸려면 새로 입력", + "fitbitCredentialsSavedPlaceholderSecret": "저장됨: 바꾸려면 새로 입력", "fitbitSaveCredentials": "저장", "fitbitSync": "지금 동기화", "fitbitFullSync": "전체 데이터 동기화", @@ -6319,7 +6417,7 @@ "fitbitConnect": "Fitbit으로 연결", "fitbitNoCredentials": "Fitbit을 연결하려면 위에 API 자격 증명을 입력해 주세요.", "fitbitBackfillInProgress": "Fitbit 기록을 백그라운드에서 가져오는 중이에요…", - "fitbitOauthConnected": "Fitbit을 연결했어요 — 첫 동기화가 백그라운드에서 시작돼요.", + "fitbitOauthConnected": "Fitbit을 연결했어요. 첫 동기화가 백그라운드에서 시작돼요.", "fitbitOauthFailed": "Fitbit 연결에 실패했어요", "googleHealth": "Google Health", "googleHealthTag": "Fitbit & Pixel", @@ -6329,8 +6427,8 @@ "googleHealthClientId": "클라이언트 ID", "googleHealthClientSecret": "클라이언트 시크릿", "googleHealthCredentialsSaved": "자격 증명을 저장했어요", - "googleHealthCredentialsSavedPlaceholder": "저장됨 — 바꾸려면 새로 입력", - "googleHealthCredentialsSavedPlaceholderSecret": "저장됨 — 바꾸려면 새로 입력", + "googleHealthCredentialsSavedPlaceholder": "저장됨: 바꾸려면 새로 입력", + "googleHealthCredentialsSavedPlaceholderSecret": "저장됨: 바꾸려면 새로 입력", "googleHealthSaveCredentials": "저장", "googleHealthSync": "지금 동기화", "googleHealthSyncCheckStatus": "상태 새로고침", @@ -6364,9 +6462,9 @@ "googleHealthConnect": "Google로 연결", "googleHealthNoCredentials": "Google Health를 연결하려면 위에 API 자격 증명을 입력해 주세요.", "googleHealthBackfillInProgress": "Google Health 기록을 백그라운드에서 가져오는 중이에요…", - "googleHealthReauthBanner": "Google이 연결을 만료시켰어요 — OAuth 앱이 테스트 모드로 있는 동안 주기적으로 생기는 일이에요. 다시 연결하면 동기화가 이어져요.", + "googleHealthReauthBanner": "Google이 연결을 만료시켰어요. OAuth 앱이 테스트 모드로 있는 동안 주기적으로 생기는 일이에요. 다시 연결하면 동기화가 이어져요.", "googleHealthReconnect": "다시 연결", - "googleHealthOauthConnected": "Google Health를 연결했어요 — 첫 동기화가 백그라운드에서 시작돼요.", + "googleHealthOauthConnected": "Google Health를 연결했어요. 첫 동기화가 백그라운드에서 시작돼요.", "googleHealthOauthFailed": "Google Health 연결에 실패했어요", "googleHealthOauthError": { "csrf1": "브라우저 쿠키가 없어서 보안 확인에 실패했어요. 같은 브라우저에서 연결을 다시 시작해 주세요.", @@ -6401,10 +6499,10 @@ }, "integrationPill": { "connected": "연결됨", - "errorReconnect": "오류 — 다시 연결", + "errorReconnect": "오류: 다시 연결", "warningServerError": "동기화 실패 중", "failureCount": "{count}/{threshold}", - "parkedReconnect": "일시중지 — 다시 연결", + "parkedReconnect": "일시중지: 다시 연결", "notConnected": "연결 안 됨", "justNow": "방금", "minutesAgo": "{count}분 전", @@ -6419,7 +6517,7 @@ }, "integrationFreshness": { "title": "받은 데이터", - "summary": "데이터 유형 {count}개 · 최신 {relative}", + "summary": "데이터 유형 {count}개, 최신 {relative}", "quiet": "{count}개 잠잠", "workouts": "운동" }, @@ -6433,31 +6531,33 @@ "fitbitViewData": "수면 보기", "googleHealthViewData": "걸음 수 보기", "apiTokens": "API 토큰", - "apiTokensDescription": "외부에서 복용을 기록할 때 쓰는 API 토큰을 확인하고 폐기해요.", + "apiTokensDescription": "측정값, 운동, 문서, 복용 기록용 토큰을 확인하고 폐기해요.", "measurementsToken": { "title": "측정 토큰", "description": "체중계나 시계, 홈 오토메이션이 측정값을 기록할 수 있도록 토큰을 만들어요.", "detail": "토큰은 한 번만 표시돼요.", "scopeNote": "본인 기록에 새 측정값을 추가하는 것만 할 수 있어요. 데이터를 읽거나, 무언가를 고치거나 지우거나, 다른 토큰을 만들 수는 없어요. 측정값은 외부 기기에서 온 것으로 표시되니 나중에 구분할 수 있고, 언제든 고칠 수 있어요.", - "createFailed": "토큰을 만들지 못했어요. 다시 시도해 주세요." + "createFailed": "토큰을 만들지 못했어요. 다시 시도해 주세요.", + "namePlaceholder": "이름 (예: Home Assistant)" }, "workoutsToken": { "title": "운동 토큰", "description": "시계 연동 브리지나 동기화 스크립트가 운동 기록을 추가할 수 있도록 토큰을 만들어요.", "detail": "토큰은 한 번만 표시돼요.", "scopeNote": "본인 기록에 새 운동을 추가하는 것만 할 수 있어요. 데이터를 읽거나, 무언가를 고치거나 지우거나, 다른 토큰을 만들 수는 없어요. 이 토큰으로 추가된 운동은 외부 기기에서 온 것으로 표시되고, 휴대폰이 같은 운동을 기록했다면 휴대폰의 기록이 보여요.", - "createFailed": "토큰을 만들지 못했어요. 다시 시도해 주세요." + "createFailed": "토큰을 만들지 못했어요. 다시 시도해 주세요.", + "namePlaceholder": "이름 (예: Garmin)" }, "documentsToken": { "title": "문서 토큰", "description": "Paperless-ngx, Papra 또는 가져오기 스크립트가 문서함에 문서를 추가할 수 있도록 토큰을 만들어요.", "detail": "토큰은 한 번만 표시돼요.", "scopeNote": "본인 문서함에 새 문서를 추가하는 것만 할 수 있어요. 목록을 보거나, 열거나, 내려받거나, 고치거나 지울 수 없고, 다른 토큰을 만들 수도 없어요. 파일이 저장됐는지만 알 수 있고, 어떻게 정리했는지는 알 수 없어요.", - "createFailed": "토큰을 만들지 못했어요. 다시 시도해 주세요." + "createFailed": "토큰을 만들지 못했어요. 다시 시도해 주세요.", + "namePlaceholder": "이름 (예: Paperless-ngx)" }, - "tokenMintMovedDescription": "토큰은 쓰이는 곳에서 발급돼요. 측정값과 문서는 위에서, 특정 약은 그 약의 API 엔드포인트에서 만들 수 있어요.", - "tokenNamePlaceholder": "토큰 이름 (예: iPhone 단축어)", - "tokenCreated": "토큰을 만들었어요 — 지금 복사하세요! 다시 보여주지 않아요.", + "tokenMintMovedDescription": "토큰은 쓰이는 곳에서 발급돼요. 측정값, 운동, 문서는 위에서, 특정 약은 그 약의 API 엔드포인트에서 만들 수 있어요.", + "tokenCreated": "토큰을 만들었어요. 지금 복사하세요! 다시 보여주지 않아요.", "tokenCopy": "토큰 복사", "tokenCopied": "토큰을 클립보드에 복사했어요", "tokenCopyFailed": "토큰을 복사하지 못했어요", @@ -6496,7 +6596,7 @@ "deleteAccountCta": "계정 삭제", "deleteAccountConfirmTitle": "계정을 영구히 삭제할까요?", "deleteAccountConfirmDescription": "계정과 모든 건강 데이터, 패스키, 세션, 감사 기록이 영구히 삭제돼요. 삭제 직후 로그아웃돼요.", - "deleteAccountSuccess": "계정을 삭제했어요 — 로그아웃할게요.", + "deleteAccountSuccess": "계정을 삭제했어요. 로그아웃할게요.", "deleteAccountFailed": "계정을 삭제하지 못했어요", "deleteAccountFinal": "영구 삭제", "saved": "저장됨", @@ -6519,7 +6619,7 @@ "testing": "테스트 중…", "ok": "연결됨 (지연 {latency}ms)", "errors": { - "credentials_rejected": "자격 증명이 거부됐어요 — 토큰을 확인해 주세요", + "credentials_rejected": "자격 증명이 거부됐어요. 토큰을 확인해 주세요", "rate_limited": "상대 서버에서 요청 수를 제한했어요", "timeout": "요청 시간이 초과됐어요", "upstream_error": "상대 서버가 오류를 반환했어요", @@ -6530,7 +6630,7 @@ "private_origin_not_approved": "비공개 네트워크 대상이에요. 운영자가 NOTIFICATION_PRIVATE_ORIGINS에 이 출처를 등록해야 해요", "private_origin_not_grantable": "이 주소는 링크 로컬, 메타데이터 또는 미지정 주소예요. 운영자 등록으로도 열 수 없으니 릴레이가 실제로 수신하는 주소를 사용해 주세요", "url_invalid": "URL이 올바르지 않아요", - "redirected": "엔드포인트가 리디렉션돼요 — URL을 확인해 주세요", + "redirected": "엔드포인트가 리디렉션돼요. URL을 확인해 주세요", "endpoint_not_found": "해당 URL에 엔드포인트가 없어요", "credentials_unreadable": "자격 증명을 복호화할 수 없어요", "upstream_invalid_json": "상대 서버가 잘못된 JSON을 보냈어요", @@ -6541,7 +6641,7 @@ }, "notificationStatus": { "title": "발송 상태", - "description": "채널별 상태예요 — '자동 비활성화'는 영구 오류가 이어져 재시도를 멈췄다는 뜻이에요.", + "description": "채널별 상태이며, '자동 비활성화'는 영구 오류가 이어져 재시도를 멈췄다는 뜻이에요.", "emptyDescription": "설정 › 연동 › 발송 채널에서 채널을 설정해 주세요.", "stateActive": "사용 중", "stateAutoDisabled": "자동 비활성화", @@ -6567,7 +6667,7 @@ }, "healthRecord": { "title": "건강 기록 내보내기", - "description": "의사에게 보여줄 전체 기록 — PDF, FHIR, 또는 둘 다.", + "description": "의사에게 보여줄 전체 기록: PDF, FHIR, 또는 둘 다.", "format": "형식", "format_pdf": "PDF", "format_fhir": "구조화 (FHIR)", @@ -6599,7 +6699,7 @@ "createTitle": "새 공유 링크", "createDescription": "링크는 읽기 전용이고 유효 기간이 있어요.", "label": "라벨", - "labelPlaceholder": "예: 김 선생님 — 건강검진", + "labelPlaceholder": "예: 김 선생님, 건강검진", "range": "공유할 기록 기간(일)", "rangeHint": "공유 범위가 과거 며칠까지 포함될지 정해요.", "expiry": "만료까지(일)", @@ -6619,7 +6719,7 @@ "statusRevoked": "해지됨", "statusExpired": "만료됨", "inactiveTitle": "해지·만료됨 ({count})", - "shownOnce": "한 번만 보여줘요. 지금 QR 코드와 암호를 저장해 두세요 — 나중에는 둘 다 복구할 수 없어요.", + "shownOnce": "한 번만 보여줘요. 지금 QR 코드와 암호를 저장해 두세요. 나중에는 둘 다 복구할 수 없어요.", "qrAlt": "공유 링크와 암호가 담긴 QR 코드", "linkLabel": "링크", "passphraseLabel": "암호", @@ -6630,12 +6730,12 @@ "attachTitle": "문서 첨부", "attachButton": "문서 선택", "attachCount": "{max}개 중 {count}개 선택됨", - "attachFrozen": "링크를 만들면 문서 구성이 고정돼요 — 나중에 문서를 추가하거나 뺄 수 없어요.", + "attachFrozen": "링크를 만들면 문서 구성이 고정돼요. 나중에 문서를 추가하거나 뺄 수 없어요.", "attachRemove": "제거: {title}", "exifNote": "공유할 때 JPEG, PNG, WebP 이미지에서는 카메라 메타데이터(EXIF/GPS)를 제거해요. PDF, TIFF, HEIC, Office 파일은 그대로 전달돼요.", "documentCount": "첨부한 문서: {count}개", "qrScanTitle": "다른 기기에서 열려면 스캔하세요", - "qrScanHint": "휴대폰 카메라로 코드를 비추면 공유된 기록이 열려요 — 암호는 이미 포함돼 있어요.", + "qrScanHint": "휴대폰 카메라로 코드를 비추면 공유된 기록이 열려요. 암호는 이미 포함돼 있어요.", "pickerTitle": "문서 첨부", "pickerDescription": "보관함에서 검색해 공유할 문서를 고르세요. 최대 {max}개예요.", "pickerSearch": "문서 검색", @@ -6648,7 +6748,8 @@ "statusNeedsReselect": "내용 다시 선택", "reselectReason": "이 링크는 선택한 적 없는 범위를 제공하고 있어서 닫았어요. 새로 만들고 담을 내용을 골라 주세요.", "reselectAction": "새 링크 만들기", - "createDetail": "토큰은 만들 때 한 번만 보여줘요 — 그때 저장해 두세요. 복구할 수 없어요." + "createDetail": "토큰은 만들 때 한 번만 보여줘요. 그때 저장해 두세요. 복구할 수 없어요.", + "activeDescription": "공유한 링크와 만료 시점, 열람 횟수를 보여줘요." }, "genderOther": "기타", "nightscout": "Nightscout", @@ -6656,10 +6757,10 @@ "nightscoutDescription": "직접 운영하는 Nightscout 사이트를 연결해 연속 혈당 측정값을 혈당 패널로 동기화해요.", "nightscoutViewData": "혈당 보기", "nightscoutUrl": "Nightscout URL", - "nightscoutUrlSavedPlaceholder": "저장됨 — 바꾸려면 새 URL을 입력하세요", + "nightscoutUrlSavedPlaceholder": "저장됨: 바꾸려면 새 URL을 입력하세요", "nightscoutToken": "API 토큰", "nightscoutTokenOptional": "공개 사이트라면 선택 사항이에요", - "nightscoutTokenSavedPlaceholder": "저장됨 — 바꾸려면 새 토큰을 입력하세요", + "nightscoutTokenSavedPlaceholder": "저장됨: 바꾸려면 새 토큰을 입력하세요", "nightscoutTokenHelp": "Nightscout 관리자 도구에서 발급한 액세스 토큰을 붙여 넣으세요. 사이트를 누구나 읽을 수 있다면 비워 두세요.", "nightscoutPrivateOperatorTitle": "비공개 Nightscout에 접근하려면 서버 운영자의 승인이 필요해요.", "nightscoutPrivateOperatorDescription": "서버 운영자에게 이 Nightscout 출처를 그대로 승인해 달라고 요청하세요.", @@ -6680,7 +6781,7 @@ "polarClientId": "클라이언트 ID", "polarClientSecret": "클라이언트 시크릿", "polarCredentialsSaved": "자격 증명을 저장했어요", - "polarCredentialsSavedPlaceholder": "저장됨 — 바꾸려면 새로 입력하세요", + "polarCredentialsSavedPlaceholder": "저장됨: 바꾸려면 새로 입력하세요", "polarSaveCredentials": "저장", "polarUnavailable": "이 서버에는 Polar가 설정돼 있지 않아요. 위에 직접 Polar OAuth 자격 증명을 넣거나, 관리자에게 공용 앱 설정을 요청하세요.", "polarConnect": "Polar 연결", @@ -6690,7 +6791,7 @@ "polarDisconnect": "연결 해제", "polarDisconnectTitle": "Polar 연결을 해제할까요?", "polarDisconnectDescription": "앞으로 동기화가 멈춰요. 이미 가져온 측정값은 기록에 그대로 남아요.", - "polarOauthConnected": "Polar를 연결했어요 — 첫 동기화가 백그라운드에서 시작돼요.", + "polarOauthConnected": "Polar를 연결했어요. 첫 동기화가 백그라운드에서 시작돼요.", "polarOauthFailed": "Polar 연결에 실패했어요", "polarOauthError": { "csrf1": "브라우저 쿠키가 없어서 보안 확인에 실패했어요. 같은 브라우저에서 연결을 다시 시작해 주세요.", @@ -6709,7 +6810,7 @@ "ouraClientId": "클라이언트 ID", "ouraClientSecret": "클라이언트 시크릿", "ouraCredentialsSaved": "자격 증명을 저장했어요", - "ouraCredentialsSavedPlaceholder": "저장됨 — 바꾸려면 새로 입력하세요", + "ouraCredentialsSavedPlaceholder": "저장됨: 바꾸려면 새로 입력하세요", "ouraSaveCredentials": "저장", "ouraUnavailable": "이 서버에는 Oura가 설정돼 있지 않아요. 위에 직접 Oura OAuth 자격 증명을 넣거나, 관리자에게 공용 앱 설정을 요청하세요.", "ouraConnect": "Oura 연결", @@ -6719,7 +6820,7 @@ "ouraDisconnect": "연결 해제", "ouraDisconnectTitle": "Oura 연결을 해제할까요?", "ouraDisconnectDescription": "앞으로 동기화가 멈춰요. 이미 가져온 측정값은 기록에 그대로 남아요.", - "ouraOauthConnected": "Oura를 연결했어요 — 첫 동기화가 백그라운드에서 시작돼요.", + "ouraOauthConnected": "Oura를 연결했어요. 첫 동기화가 백그라운드에서 시작돼요.", "ouraOauthFailed": "Oura 연결에 실패했어요", "ouraOauthError": { "csrf1": "브라우저 쿠키가 없어서 보안 확인에 실패했어요. 같은 브라우저에서 연결을 다시 시작해 주세요.", @@ -6750,7 +6851,7 @@ } }, "integrationRedirectGuide": { - "intro": "직접 만든 {provider} 개발자 앱으로 연결해요 — 설정에 5분쯤 걸려요:", + "intro": "직접 만든 {provider} 개발자 앱으로 연결해요. 설정에 5분쯤 걸려요:", "step1": "{provider} 개발자 포털에서 앱을 만드세요.", "step2": "리디렉션/콜백 URL을 다음으로 설정하세요:", "step3": "발급받은 클라이언트 ID와 클라이언트 시크릿을 위에 붙여 넣으세요." @@ -6838,7 +6939,7 @@ }, "passkeyNudge": { "title": "패스키를 추가하면 더 빠르게 로그인해요", - "body": "패스키로 지문, 얼굴, 기기 PIN을 써서 로그인할 수 있어요 — 비밀번호를 외울 필요가 없어요." + "body": "패스키로 지문, 얼굴, 기기 PIN을 써서 로그인할 수 있어요. 비밀번호를 외울 필요가 없어요." }, "trustedDevices": { "title": "신뢰하는 기기", @@ -6857,7 +6958,7 @@ "revokeAllConfirmBody": "이 기기를 포함해 모든 기기에서 다음에 로그인할 때 2단계 인증을 다시 물어봐요." }, "revokeSessionConfirmTitle": "이 기기를 로그아웃할까요?", - "revokeSessionConfirmBody": "{device} — 다시 로그인해야 해요. 기기에 기록된 내용은 영향받지 않아요.", + "revokeSessionConfirmBody": "{device}: 다시 로그인해야 해요. 기기에 기록된 내용은 영향받지 않아요.", "signOutEverywhereConfirmTitle": "다른 기기를 모두 로그아웃할까요?", "signOutEverywhereConfirmBody": "다른 모든 기기, 연결된 모든 AI 어시스턴트와 모든 API 토큰은 다시 로그인하거나 다시 설정해야 합니다. 이 기기는 로그인 상태로 유지됩니다. 기록한 내용에는 영향이 없습니다.", "signOutEverywhereShareLinks": "의료진 공유 링크도 종료", @@ -6874,12 +6975,11 @@ }, "stored": { "title": "저장되는 항목", - "measurements": "측정값 (체중, 혈압, 맥박, 혈당, 수면, 체성분)", - "medications": "약과 복용 기록", - "moodLabs": "기분 기록과 검사 결과", - "coach": "코치 대화", + "measurements": "측정값: 체중, 혈압, 맥박과 직접 또는 기기로 기록한 모든 값", "integrations": "연동 토큰 (암호화됨)", - "security": "계정·보안 활동 (세션, 로그인 기록)" + "security": "계정·보안 활동 (세션, 로그인 기록)", + "profile": "프로필, 병력, 건강 기록 정보", + "modules": "모듈별 기록: {list}" }, "retention": { "title": "보관 기간", @@ -6910,13 +7010,11 @@ "mcp": { "enableTitle": "원격 MCP 엔드포인트", "enableDescription": "MCP를 지원하는 어시스턴트(Claude.ai, ChatGPT)가 범위가 제한된 토큰으로 건강 데이터를 읽게 해요.", - "enabled": "켜짐", - "disabled": "꺼짐", "enableToggleLabel": "MCP 엔드포인트 켜기", "endpointLabel": "커넥터 URL", "connectDescription": "어시스턴트의 커스텀 커넥터 설정에 이 URL을 붙여 넣고, 아래 토큰으로 인증하거나 안내가 나오면 로그인하세요.", "tokensTitle": "커넥터 토큰", - "tokensDescription": "어시스턴트를 연결할 토큰을 발급하세요 — 아래에서 기록을 허용하지 않으면 읽기 전용이에요.", + "tokensDescription": "어시스턴트를 연결할 토큰을 발급하세요, 아래에서 기록을 허용하지 않으면 읽기 전용이에요.", "scopeNote": "새 토큰은 본인 기록만 읽을 수 있어요. 쓰기 권한(health:write)은 아래 토글로 토큰마다 따로 부여해요. 토큰은 관리자 기능에 절대 접근할 수 없어요.", "writeScopeToggleLabel": "기록 허용 (health:write)", "writeScopeNote": "health:write 범위를 추가해 어시스턴트가 새 측정값과 기분 기록을 계정에 남길 수 있게 해요. 모든 쓰기는 먼저 확인을 거쳐요. 그래도 토큰은 기존 기록을 삭제하거나 수정할 수 없고, 약을 바꾸거나 REST·관리자 기능에 접근할 수도 없어요. 읽기 전용으로 쓰려면 꺼 두세요.", @@ -6930,7 +7028,8 @@ "connectionRevokeDescription": "어시스턴트의 접근을 즉시 끊고 갱신도 막아요. 다시 연결하려면 승인을 새로 받아야 해요.", "connectionRevokeFailed": "어시스턴트 연결을 해제하지 못했어요.", "connectionsDetail": "연결을 해지하면 갱신 권한까지 포함해 접근이 즉시 끊겨요.", - "tokensDetail": "토큰은 한 번만 보여줘요." + "tokensDetail": "토큰은 한 번만 보여줘요.", + "tokenNamePlaceholder": "이름 (예: Claude Desktop)" }, "strava": "Strava", "stravaDescription": "Strava 계정을 연결해 활동을 운동 기록으로 동기화해요.", @@ -6939,7 +7038,7 @@ "stravaClientId": "클라이언트 ID", "stravaClientSecret": "클라이언트 시크릿", "stravaCredentialsSaved": "자격 증명을 저장했어요", - "stravaCredentialsSavedPlaceholder": "저장됨 — 바꾸려면 새로 입력하세요", + "stravaCredentialsSavedPlaceholder": "저장됨: 바꾸려면 새로 입력하세요", "stravaSaveCredentials": "저장", "stravaUnavailable": "이 서버에는 Strava가 설정돼 있지 않아요. 위에 직접 Strava API 자격 증명을 넣거나, 관리자에게 공용 앱 설정을 요청하세요.", "stravaConnect": "Strava 연결", @@ -6949,7 +7048,7 @@ "stravaDisconnect": "연결 해제", "stravaDisconnectTitle": "Strava 연결을 해제할까요?", "stravaDisconnectDescription": "앞으로 동기화가 멈춰요. 이미 가져온 활동은 기록에 그대로 남아요.", - "stravaOauthConnected": "Strava를 연결했어요 — 첫 동기화가 백그라운드에서 시작돼요.", + "stravaOauthConnected": "Strava를 연결했어요. 첫 동기화가 백그라운드에서 시작돼요.", "stravaOauthFailed": "Strava 연결에 실패했어요", "stravaOauthError": { "csrf1": "브라우저 쿠키가 없어서 보안 확인에 실패했어요. 같은 브라우저에서 연결을 다시 시작해 주세요.", @@ -6977,21 +7076,28 @@ "title": "동기화 진행 상황", "receivedLabel": "받은 기록 수", "oldestLabel": "받아온 가장 오래된 측정값", - "flowing": "최근 1시간 안에 데이터가 들어왔어요 — iPhone 앱이 계속 보내는 중이에요.", - "waiting": "iPhone 앱이 데이터를 더 보내기를 기다리는 중이에요. 앱이 열려 있거나 iOS가 백그라운드 시간을 줄 때 전송이 이어져요 — 처음 대량으로 채워 넣는 동안 잠잠한 구간이 생기는 건 정상이고 오류가 아니에요." + "flowing": "최근 1시간 안에 데이터가 들어왔어요. iPhone 앱이 계속 보내는 중이에요.", + "waiting": "iPhone 앱이 데이터를 더 보내기를 기다리는 중이에요. 앱이 열려 있거나 iOS가 백그라운드 시간을 줄 때 전송이 이어져요. 처음 대량으로 채워 넣는 동안 잠잠한 구간이 생기는 건 정상이고 오류가 아니에요." }, "delivery": { "title": "전송", "lastTriggerLabel": "마지막 동기화 도착", "lastBackgroundLabel": "마지막 백그라운드 동기화", - "backgroundNever": "없음 — 지금까지 모든 동기화가 앱이 열린 상태에서 들어왔어요", + "backgroundNever": "없음: 지금까지 모든 동기화가 앱이 열린 상태에서 들어왔어요", "noSyncYet": "아직 도착한 동기화가 없어서 여기 보여줄 내용이 없어요.", "trigger": { "foreground": "앱이 열린 상태에서", "background": "백그라운드에서", "push": "푸시 깨우기로", - "unknown": "앱이 트리거를 알려주지 않았어요" + "unknown": "앱이 트리거를 알려주지 않았어요", + "manual": "'모두 동기화'로" } + }, + "freshness": { + "title": "데이터 유형별", + "lastReceived": "마지막 수신 {relative}", + "lastNewValue": "마지막 새 값 {relative}", + "never": "아직 수신되지 않음" } }, "garminInfo": { @@ -7052,6 +7158,12 @@ "description": "건강 데이터를 공유하거나 내보내는 작업입니다. 계속하려면 본인임을 확인하세요.", "signInAgain": "다시 로그인한 후 한 번 더 시도하세요. 이 계정에는 여기서 확인할 비밀번호나 패스키가 없습니다.", "failed": "확인하지 못했습니다. 입력한 내용을 확인하고 다시 시도하세요." + }, + "passwordTitle": "비밀번호", + "tokenNameLabel": "토큰 이름", + "displayCard": { + "title": "표시", + "description": "언어, 단위, 형식은 바꾸는 즉시 저장돼요." } }, "admin": { @@ -7078,20 +7190,20 @@ "memUsedPercent": "메모리", "diskBusyPercent": "디스크 I/O", "last2hours": "최근 2시간", - "empty": "샘플링을 시작했어요 — 1–2분 뒤에 데이터가 나타나요.", + "empty": "샘플링을 시작했어요. 1–2분 뒤에 데이터가 나타나요.", "loadError": "호스트 지표를 불러오지 못했어요. 페이지를 새로고침해 보세요." }, "aiServerKey": { "title": "AI 서버 키", "description": "자기 키를 설정하지 않은 계정을 위한 운영자 키예요.", - "byokHint": "사용자 본인 키(BYOK, ChatGPT 로그인, 로컬 제공자)가 항상 우선해요 — 서버 키는 제공자 체인의 마지막 단계예요.", + "byokHint": "사용자 본인 키(BYOK, ChatGPT 로그인, 로컬 제공자)가 항상 우선해요. 서버 키는 제공자 체인의 마지막 단계예요.", "consentHint": "서버 키로 나가는 요청에는 사용자의 유효한 AI 동의가 필요해요. 동의 기록이 없으면 아무것도 전송하지 않아요.", "baseUrlHint": "기본 URL이 API를 결정합니다. https://api.anthropic.com/v1 같은 Anthropic 주소는 인식되며, 그 밖의 호스트는 OpenAI 호환 API로 호출됩니다.", "statusConfigured": "설정됨", "statusNotConfigured": "설정 안 됨", "keyLabel": "API 키", "keyPlaceholder": "sk-...", - "keyPlaceholderSet": "저장됨 ({preview}) — 바꾸려면 새 키를 입력하세요", + "keyPlaceholderSet": "저장됨 ({preview}): 바꾸려면 새 키를 입력하세요", "modelLabel": "모델", "baseUrlLabel": "Base URL", "save": "저장", @@ -7110,7 +7222,7 @@ "title": "인사이트 품질", "loading": "피드백 요약 불러오는 중…", "loadError": "인사이트 품질 요약을 불러오지 못했어요. 페이지를 새로고침해 보세요.", - "empty": "아직 피드백이 없어요. 집계는 매일 04:00에 돌아요 — 사용자가 추천을 평가하기 시작하면 도움됨 비율 구간이 여기에 나타나요.", + "empty": "아직 피드백이 없어요. 집계는 매일 04:00에 돌아요. 사용자가 추천을 평가하기 시작하면 도움됨 비율 구간이 여기에 나타나요.", "windowLabel": "최근 {days}일", "generatedAtLabel": "생성 시각", "colSeverity": "심각도", @@ -7125,7 +7237,7 @@ "title": "코치 피드백", "loading": "코치 피드백 요약 불러오는 중…", "loadError": "코치 피드백 요약을 불러오지 못했어요. 페이지를 새로고침해 보세요.", - "empty": "아직 코치 피드백이 없어요. 집계는 매일 04:00에 돌아요 — 사용자가 코치 답변을 평가하면 도움됨 비율 구간이 여기에 나타나요.", + "empty": "아직 코치 피드백이 없어요. 집계는 매일 04:00에 돌아요. 사용자가 코치 답변을 평가하면 도움됨 비율 구간이 여기에 나타나요.", "windowLabel": "최근 {days}일", "generatedAtLabel": "생성 시각", "colPromptVersion": "프롬프트 버전", @@ -7144,13 +7256,13 @@ "appSettingsDescription": "회원가입, 언어, 새 계정 시간대의 전역 기본값이에요.", "registrationEnabled": "회원가입 허용", "registrationEnabledDescription": "끄면 새 사용자가 가입할 수 없어요.", - "registrationOpenHint": "회원가입이 열려 있어요 — 이 인스턴스에 접근할 수 있는 누구나 계정을 만들 수 있어요. 필요한 사람이 다 가입했으면 여기서 닫으세요.", + "registrationOpenHint": "회원가입이 열려 있어요. 이 인스턴스에 접근할 수 있는 누구나 계정을 만들 수 있어요. 필요한 사람이 다 가입했으면 여기서 닫으세요.", "defaultLanguage": "기본 언어", "defaultLanguageDescription": "새 사용자와 대체값으로 쓰여요.", "defaultUserTimezone": "새 계정의 기본 시간대", - "defaultUserTimezoneDescription": "브라우저에서 시간대를 감지하지 못한 새 계정에 쓰여요. 기존 사용자에게는 영향이 없어요.", + "defaultUserTimezoneDescription": "브라우저에서 시간대를 감지하지 못한 새 계정에 쓰이고, 기존 계정은 그대로예요.", "defaultUserTimezoneFallback": "Europe/Berlin (기본값)", - "servicesGlobal": "서비스 (전역)", + "servicesGlobal": "채널과 API", "servicesGlobalDescription": "끈 서비스는 어떤 사용자도 쓸 수 없어요.", "telegramGlobal": "Telegram 알림을 허용해요.", "ntfyGlobal": "ntfy 알림을 허용해요.", @@ -7158,19 +7270,21 @@ "integrationWebPush": "웹 푸시", "offlineGeoLabel": "위치 데이터", "offlineGeoEnabled": "오프라인 (GeoLite2)", - "offlineGeoFallback": "온라인 ({host}) — GeoLite2 데이터베이스를 마운트하고 GEOLITE2_DIR을 설정하세요", + "offlineGeoFallback": "온라인 ({host}). GeoLite2 데이터베이스를 마운트하고 GEOLITE2_DIR을 설정하세요", + "offlineGeoOff": "꺼짐", + "offlineGeoChosen": "온라인 ({host})", "apiGlobal": "API 엔드포인트와 API 토큰을 허용해요.", "umamiTitle": "Umami", - "umamiDescription": "Umami 웹 분석", + "umamiDescription": "Umami 웹 분석이에요.", "glitchtipTitle": "GlitchTip", - "glitchtipDescription": "GlitchTip 오류 추적", + "glitchtipDescription": "GlitchTip 오류 추적이에요.", "webPushVapidTitle": "웹 푸시 VAPID", - "umamiEnabled": "Umami 추적을 켜요.", + "umamiEnabled": "Umami 추적 켜기", "umamiScriptUrl": "Umami 스크립트 URL", "umamiScriptUrlPlaceholder": "https://analytics.example.com/script.js", "umamiWebsiteId": "Umami 웹사이트 ID", "umamiWebsiteIdPlaceholder": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "glitchtipEnabled": "GlitchTip 오류 추적을 켜요.", + "glitchtipEnabled": "GlitchTip 오류 추적 켜기", "glitchtipDsn": "GlitchTip DSN", "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "GlitchTip 환경", @@ -7232,12 +7346,12 @@ "title": "모듈 사용 여부", "description": "서버 전체에서 모듈을 끌 수 있어요.", "saveError": "모듈 사용 여부를 바꾸지 못했어요.", - "detail": "여기서 끈 모듈은 개인 설정과 관계없이 모든 계정에서 꺼져요. 핵심 영역(체중, 혈압, 맥박, 복약)은 항상 켜져 있고 끌 수 없어요.", - "coachFollowsSwitch": "어시스턴트의 코치 스위치를 따릅니다." + "detail": "체중, 혈압, 맥박은 핵심 측정값이라 항상 켜져 있어요.", + "coachFollowsSwitch": "코치 섹션의 코치 스위치를 따라요." }, "invites": { - "title": "초대", - "description": "가입 초대 링크 — 회원가입이 닫혀 있어도 동작해요.", + "title": "초대 링크", + "description": "링크마다 회원가입이 닫혀 있어도 가입할 수 있어요.", "create": "초대 만들기", "createTitle": "초대 만들기", "createDescription": "회원가입이 닫혀 있어도 이 링크로는 가입할 수 있어요.", @@ -7249,7 +7363,7 @@ "createdToast": "초대를 만들었어요", "createError": "초대를 만들지 못했어요.", "tokenTitle": "초대 링크", - "tokenOnceHint": "지금만 복사할 수 있어요 — 링크를 복사하거나 QR 코드를 스캔하게 하세요.", + "tokenOnceHint": "지금만 복사할 수 있어요. 링크를 복사하거나 QR 코드를 스캔하게 하세요.", "hashExplainer": "링크의 해시만 저장하기 때문에 나중에 다시 보여줄 수 없어요. 잃어버렸다면 초대를 취소하고 새로 만드세요.", "copyLink": "링크 복사", "copiedToast": "링크를 복사했어요", @@ -7283,8 +7397,8 @@ "revokedToast": "초대를 취소했어요.", "revokeError": "초대를 취소하지 못했어요." }, - "userManagement": "사용자 관리", - "userManagementDescription": "계정 관리: 역할, 비밀번호 재설정, 강제 로그아웃.", + "userManagement": "계정", + "userManagementDescription": "계정마다 역할, 비밀번호 재설정, 강제 로그아웃을 관리해요.", "userName": "이름", "userEmail": "이메일", "userRole": "역할", @@ -7311,14 +7425,13 @@ "tokenName": "이름", "tokenPermissions": "권한", "tokenPermissionAll": "전체", - "tokenPermissionAllTooltip": "와일드카드 권한 — 이 토큰은 모든 API 경로를 호출할 수 있어요.", + "tokenPermissionAllTooltip": "와일드카드 권한: 이 토큰은 모든 API 경로를 호출할 수 있어요.", "tokenStatus": "상태", "tokenLastUsed": "마지막 사용", "tokenCreated": "생성", "tokenNeverUsed": "없음", "tokensEmptyTitle": "아직 API 토큰이 없어요", "tokensEmptyDescription": "네이티브 클라이언트가 로그인할 때나 설정 → 계정에서 토큰이 발급돼요.", - "loginOverview": "로그인 개요", "loginOverviewDescription": "모든 계정의 로그인·인증 이벤트예요. 필터링하고 CSV로 내보낼 수 있어요.", "allAuthEvents": "전체 인증 이벤트", "failedOnly": "실패만", @@ -7351,6 +7464,7 @@ "finalDelete": "영구 삭제", "deletedResult": "테이블 {tables}개에서 행 {rows}개를 삭제했어요. 사용자 계정과 로그인 자격 증명은 남겼어요.", "authRegister": "회원가입", + "authClaim": "프로필 넘겨받음", "authLogin": "로그인", "authLoginPasskey": "패스키 로그인", "authLoginPassword": "비밀번호 로그인", @@ -7425,7 +7539,7 @@ }, "services": { "title": "서비스", - "subtitle": "알림 채널과 연동을 시스템 전체에서 켜거나 꺼요." + "subtitle": "알림 채널과 API를 서버 전체에서 켜거나 꺼요." }, "integrations": { "title": "연동", @@ -7450,7 +7564,7 @@ "forceLogout": "강제 로그아웃", "forceLogoutConfirmTitle": "{name} 강제 로그아웃할까요?", "forceLogoutConfirmBody": "이 사용자의 활성 세션이 모두 해제돼요. 모든 기기에서 다시 로그인해야 해요.", - "forceLogoutSuccess": "{name} — 세션 {count}개에서 로그아웃했어요", + "forceLogoutSuccess": "{name}: 세션 {count}개에서 로그아웃했어요", "forceLogoutFailed": "강제 로그아웃하지 못했어요", "cannotLogoutSelf": "자기 자신은 강제 로그아웃할 수 없어요", "emptyTitle": "이 화면에 사용자가 없어요", @@ -7489,7 +7603,7 @@ "app-logs": { "title": "앱 로그", "subtitle": "이 프로세스가 남긴 최근 구조화 wide-event를 실시간으로 봐요.", - "processNote": "앱 프로세스 로그만 보여줘요 — 워커 프로세스 로그는 Loki 연동을 설정하면 그쪽으로 가요.", + "processNote": "앱 프로세스 로그만 보여주며, 워커 프로세스 로그는 Loki 연동을 설정하면 그쪽으로 가요.", "filterTraceId": "추적 ID", "filterAction": "동작 포함…", "filterLevel": "레벨", @@ -7511,7 +7625,8 @@ "refresh": "새로고침", "eventDetails": "이벤트 상세", "closeDetails": "상세 닫기", - "processDetail": "버퍼는 휘발성이고 최대 500개까지만 담아요." + "processDetail": "버퍼는 휘발성이고 최대 500개까지만 담아요.", + "cardTitle": "최근 이벤트" }, "backups": { "title": "백업", @@ -7523,7 +7638,7 @@ "loading": "백업을 불러오는 중…", "loadError": "백업을 불러오지 못했어요.", "emptyTitle": "아직 백업이 없어요", - "emptyDescription": "백업은 매주 일요일 03:00에 자동으로 돌아요 — 지금 실행해서 첫 스냅샷을 만들 수도 있어요.", + "emptyDescription": "백업은 매주 일요일 03:00에 자동으로 돌아요. 지금 실행해서 첫 스냅샷을 만들 수도 있어요.", "colUser": "사용자", "colType": "유형", "typeWeeklyAuto": "주간 (자동)", @@ -7559,11 +7674,11 @@ "restoreTitle": "백업에서 복원", "restoreDescription": "{username}의 현재 데이터 전체를 {when} 스냅샷으로 바꿔요. 기존 측정, 약, 복용 기록, 기분 기록, 알림 채널, 푸시 구독, 대기 중인 Telegram 삭제 항목이 지워져요.", "restoreInstanceSettingsLabel": "인스턴스 설정도 함께 복원", - "restoreInstanceSettingsHint": "가입, MFA 필수 여부, 기본 언어, 모듈 사용 여부, 알림과 AI 설정 — 스냅샷에서 가져와 이 설치본의 모든 사용자에게 적용돼요. 이 계정만 복원하려면 선택하지 마세요.", + "restoreInstanceSettingsHint": "가입, MFA 필수 여부, 기본 언어, 모듈 사용 여부, 알림과 AI 설정은 스냅샷에서 가져와 이 설치본의 모든 사용자에게 적용돼요. 이 계정만 복원하려면 선택하지 마세요.", "restorePromptLabel": "계속하려면 RESTORE를 입력해 주세요", "previewTitle": "이 백업에 담긴 내용", "previewLoading": "내용을 읽는 중…", - "previewUnavailable": "내용을 읽지 못했어요 — 복원은 그래도 할 수 있어요.", + "previewUnavailable": "내용을 읽지 못했어요. 복원은 그래도 할 수 있어요.", "previewStillReading": "이 백업은 이전 버전에서 만들어져서 내용을 처음부터 끝까지 읽고 있어요. 기록이 많으면 시간이 좀 걸려요. 몇 분 뒤에 이 창을 다시 열면 내용이 보여요. 복원은 이것과 상관없이 할 수 있어요.", "previewEmpty": "파일에 기록이 없어요.", "previewOther": "기타 기록", @@ -7583,7 +7698,7 @@ "restoreInProgress": "복원 중…", "restoreSuccess": "복원을 마쳤어요", "restoreFailed": "복원하지 못했어요", - "restoreSkippedToast": "복원을 마쳤어요 — 되돌리지 못한 증상 연결: {links}", + "restoreSkippedToast": "복원을 마쳤어요. 되돌리지 못한 증상 연결: {links}", "restoreSkippedTitle": "복원하지 못한 연결: {links}", "restoreSkippedDescription": "파일이 이 설치본에 없는 기록을 가리키고 있어요. 나머지는 모두 복원했어요. 같은 목록이 감사 로그에도 남아 있어요.", "restoreSkippedLinks": "연결 {count}개", @@ -7606,7 +7721,7 @@ "restoreSkippedMedicationTarget": "약 목표 분석물", "restoreSkippedScheduleRevisionLink": "일정 개정 연결", "restoreSkippedAccountSetting": "계정 설정", - "restoreMissingTitle": "복원을 중단했어요 — 파일이 불완전해요", + "restoreMissingTitle": "복원을 중단했어요. 파일이 불완전해요", "restoreMissingDescription": "파일의 매니페스트에는 아래 항목이 들어 있다고 적혀 있는데 실제로는 없어요. 아무것도 바꾸지 않았어요. 복원은 해당 기록 전체를 갈아엎기 때문에, 파일에 없는 항목을 되돌리면 그 항목이 통째로 비워졌을 거예요.", "restoreMissingRemedy": "재해 복구용 스냅샷을 쓰거나, 계정을 다시 내보내서 새 파일을 업로드해 주세요. 매니페스트가 처음부터 '빠졌다'고 표시한 항목은 다른 경우라 정상적으로 복원돼요.", "restoreMissingDocuments": "문서", @@ -7678,7 +7793,8 @@ "restoreSkippedCycleCiphertext": "이 서버에 없는 키로 암호화된 주기 메모 또는 이름(해당 날짜나 증상은 그 내용 없이 유지됨)", "restoreSkippedMoodLabelCiphertext": "이 서버에 없는 키로 암호화된 기분 태그 또는 그룹 이름(이름 없이 유지됨)", "restoreSkippedVisitCiphertext": "이 서버에 없는 키로 암호화된 진료 또는 의료진 텍스트(해당 내용 없이 유지됨)", - "restoreSkippedVaccinationCiphertext": "이 서버에 없는 키로 암호화된 예방접종 메모(접종 기록은 메모 없이 유지됨)" + "restoreSkippedVaccinationCiphertext": "이 서버에 없는 키로 암호화된 예방접종 메모(접종 기록은 메모 없이 유지됨)", + "restoreSkippedEnvironmentLocationCiphertext": "이 서버에 없는 키로 암호화된 환경 기간 또는 날짜의 위치(암호화된 채 유지되며 해당 날짜는 새로 가져오지 않음)" }, "danger-zone": { "title": "위험 구역", @@ -7695,7 +7811,6 @@ "encryption": { "title": "암호화", "subtitle": "저장 데이터의 암호화 적용 범위와 키 교체 상태예요.", - "loading": "암호화 상태를 불러오는 중…", "loadError": "암호화 상태를 불러오지 못했어요.", "coverageTitle": "적용 범위", "coverageDescription": "키 id별로 암호화된 행이 얼마나 있는지 보여줘요.", @@ -7730,7 +7845,7 @@ "completed": "완료", "failed": "실패" }, - "coverageDetail": "키 id와 개수만 보여줘요 — 키 자체는 절대 표시하지 않아요.", + "coverageDetail": "키 id와 개수만 보여줘요. 키 자체는 절대 표시하지 않아요.", "backupsTitle": "백업에 아직 필요한 키", "backupsDescription": "아래 백업 중 하나라도 표시하는 동안 폐기한 키는 계속 필요합니다.", "backupsStoredLine": "키 {key}: 저장된 복사본 {count}개, 가장 오래된 것은 {when}", @@ -7740,12 +7855,45 @@ "backupsNone": "아직 필요한 키를 기록한 백업이 없습니다.", "backupsStillNeed": "모든 행이 회전되었지만 백업에 아직 {keys}가 필요합니다", "backupsUnrecordedBadge": "모든 행이 회전되었지만 이전 백업에는 아직 폐기한 키가 필요할 수 있습니다", - "backupsDetail": "회전은 모든 행을 다시 암호화하지만, 백업은 내용이 기록된 키를 그대로 유지하며 복원에는 그 키가 필요합니다. 아래에 더 이상 표시되지 않을 때까지 폐기한 키를 ENCRYPTION_KEYS에 유지하세요." + "backupsDetail": "회전은 모든 행을 다시 암호화하지만, 백업은 내용이 기록된 키를 그대로 유지하며 복원에는 그 키가 필요합니다. 아래에 더 이상 표시되지 않을 때까지 폐기한 키를 ENCRYPTION_KEYS에 유지하세요.", + "columnsShow": "표 보기 (행 있음 {count}개)", + "columnsHide": "표 숨기기", + "columnsShowAll": "열 {count}개 모두 보기", + "columnsShowWithRows": "행이 있는 열만", + "columnsNoneWithRows": "아직 행이 있는 암호화 열이 없어요." + }, + "maintenance": { + "title": "측정값 테이블 유지 관리", + "description": "정리가 끝난 뒤, 오래된 동기화 잔여 데이터가 차지하던 공간을 되돌려 줍니다.", + "body": "측정값 테이블에 vacuum을 실행한 다음 인덱스를 하나씩 다시 만듭니다. 실행 중에도 읽기와 저장은 계속 작동합니다. 한가한 시간을 고르고, 가장 큰 인덱스를 한 번 더 복사할 만큼의 디스크 여유 공간을 확보하세요.", + "tableSize": "인덱스 포함 테이블", + "indexSize": "인덱스", + "loading": "유지 관리 상태를 확인하는 중…", + "loadError": "유지 관리 상태를 읽을 수 없습니다.", + "unavailable": "연결된 백그라운드 작업자가 없어 여기에서는 유지 관리를 실행할 수 없습니다.", + "purgePending": "오래된 동기화 잔여 데이터 정리가 아직 진행 중입니다. 정리가 끝나면 유지 관리를 시작할 수 있습니다.", + "state": { + "idle": "아직 실행되지 않았습니다.", + "queued": "백그라운드 작업자를 기다리는 중입니다.", + "running": "{since}부터 실행 중입니다. 큰 인스턴스에서는 시간이 걸릴 수 있습니다.", + "completed": "{at}에 완료되었습니다.", + "refusedPurge": "정리가 아직 진행 중이어서 시작되지 않았습니다. 정리가 끝난 뒤 다시 시작하세요.", + "alreadyRunning": "다른 실행이 이미 작업 중이어서 시작되지 않았습니다.", + "failed": "{at}에 중단되었습니다. 앱 로그에 단계가 나와 있고, 런북에서 확인할 사항을 설명합니다." + }, + "start": "유지 관리 시작", + "confirmTitle": "지금 유지 관리를 시작할까요?", + "confirmBody": "백그라운드에서 한 단계씩 실행되며 시간이 걸릴 수 있습니다. 오늘 아직 백업하지 않았다면 먼저 백업하세요.", + "confirm": "시작", + "cancel": "취소", + "started": "유지 관리를 시작했습니다.", + "alreadyQueued": "유지 관리가 이미 대기 중이거나 실행 중입니다.", + "startFailed": "유지 관리를 시작할 수 없습니다." } }, "carrier": "통신사", "mfaRequired": "2단계 인증 필수", - "mfaRequiredDescription": "2단계 인증이 없는 계정은 로그인 후 등록 화면으로 보내요. 웹 사용자에게 등록을 요구할 뿐, 아직 등록하지 않은 계정의 API나 네이티브(Bearer) 접근을 막지는 않아요.", + "mfaRequiredDescription": "2단계 인증이 없는 계정은 웹 로그인 후 등록 화면으로 보내고, API와 네이티브(Bearer) 접근은 그대로 열려 있어요.", "mfaEnforcedOn": "MFA 켬", "mfaEnforcedOff": "MFA 끔", "mfaEnforcedOnHint": "이 사용자에게 2단계 인증이 필수예요. 누르면 해제돼요.", @@ -7775,13 +7923,13 @@ "connectedSince": "연결 시작: {when}", "connected": "공용 AI 연결을 등록했어요", "disconnected": "공용 AI 연결을 해제했어요", - "error": "문제가 생겼어요 — 다시 시도해 주세요" + "error": "문제가 생겼어요. 다시 시도해 주세요" }, "failingJobs": { "title": "백그라운드 작업", "description": "재시도를 다 쓰고 포기한 작업이 있는 큐예요.", "none": "최근 {hours}시간 동안 실패한 작업이 없어요.", - "unavailable": "읽을 작업 큐가 없어요 — 이 배포본은 백그라운드 워커 없이 돌고 있어요.", + "unavailable": "읽을 작업 큐가 없어요. 이 배포본은 백그라운드 워커 없이 돌고 있어요.", "countOne": "실패 {count}건", "countFew": "실패 {count}건", "countOther": "실패 {count}건", @@ -7807,7 +7955,10 @@ "typeCodex": "ChatGPT 로그인 (개인)", "typeLocal": "로컬 모델 (개인)", "typeGateway": "OpenAI 호환 게이트웨이 (개인)", - "spendToday": "오늘({date}): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다." + "spendToday": "오늘({date}): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다.", + "statusPaused": "일시 중지됨", + "pausedHint": "반복된 오류로 사용자 {count}명에 대해 {at}까지 일시 중지되었습니다. 그 후 자동으로 다시 시도합니다.", + "modelNotServed": "엔드포인트가 더 이상 {model} 모델을 제공하지 않습니다. AI 설정에서 제공되는 모델을 선택하세요." }, "cardError": { "title": "페이지의 이 부분을 표시할 수 없습니다", @@ -7864,7 +8015,9 @@ "restartNote": "변경 후 HealthLog를 다시 시작하세요.", "lastResort": "최후의 수단: 키가 맞다고 확신하고 위 세 가지가 모두 해당하지 않을 때만 {setting}을 설정하고 다시 시작하세요. HealthLog는 그래도 서비스하며 결과를 로그에만 남깁니다. 이 설정은 안전 검사를 끕니다. 키가 틀리면 모든 암호화된 값이 실패하고 새 데이터가 그 키로 기록됩니다.", "codes": "운영자용: API 요청은 오류 코드 {code}와 함께 503으로 응답하고, /api/health는 원인 {reason}을 알려 줍니다." - } + }, + "loginOverviewCardTitle": "로그인 이벤트", + "userColumn": "사용자" }, "achievements": { "title": "업적", @@ -7880,7 +8033,9 @@ "noneUnlockedYet": "아직 달성한 업적이 없어요.", "pointsValue": "{points}포인트", "metricCount": "{count}", - "metricDays": "{count}일", + "metricDaysOne": "{count}일", + "metricDaysFew": "{count}일", + "metricDaysOther": "{count}일", "metricPercent": "{count}%", "badges": { "intakeTotal1": { @@ -7903,14 +8058,6 @@ "title": "복용 전설", "description": "복용 기록 300회를 남겨요." }, - "overIntake1": { - "title": "한 번 더", - "description": "계획보다 한 번 이상 더 복용해요." - }, - "skippedIntake1": { - "title": "한 걸음 물러서기", - "description": "계획된 복용을 한 번 이상 건너뛰어요." - }, "passkeyCreated1": { "title": "패스키 등록", "description": "첫 패스키를 만들어요." @@ -7936,19 +8083,19 @@ "description": "하루 동안 모든 약을 제때 복용해요." }, "compliance801": { - "title": "복약 순응도 80% · 1일", + "title": "1일간 복약 순응도 80%", "description": "30일 복약 순응도 80% 이상을 1일 유지해요." }, "bmiGreen1": { - "title": "BMI 정상 · 1일", + "title": "1일간 BMI 정상", "description": "BMI를 1일 동안 정상 범위로 유지해요." }, "bpGreen1": { - "title": "혈압 정상 · 1일", + "title": "1일간 혈압 정상", "description": "혈압을 1일 동안 정상 범위로 유지해요." }, "pulseGreen1": { - "title": "맥박 정상 · 1일", + "title": "1일간 맥박 정상", "description": "안정 시 맥박을 1일 동안 정상 범위로 유지해요." }, "onTimePerfect7": { @@ -7956,19 +8103,19 @@ "description": "7일 연속으로 모든 약을 제때 복용해요." }, "compliance807": { - "title": "복약 순응도 80% · 7일", + "title": "7일간 복약 순응도 80%", "description": "30일 복약 순응도 80% 이상을 7일 연속 유지해요." }, "bmiGreen7": { - "title": "BMI 정상 · 7일", + "title": "7일간 BMI 정상", "description": "BMI를 7일 연속 정상 범위로 유지해요." }, "bpGreen7": { - "title": "혈압 정상 · 7일", + "title": "7일간 혈압 정상", "description": "혈압을 7일 연속 정상 범위로 유지해요." }, "pulseGreen7": { - "title": "맥박 정상 · 7일", + "title": "7일간 맥박 정상", "description": "안정 시 맥박을 7일 연속 정상 범위로 유지해요." }, "onTimePerfect30": { @@ -7976,19 +8123,19 @@ "description": "30일 연속으로 모든 약을 제때 복용해요." }, "compliance8030": { - "title": "복약 순응도 80% · 30일", + "title": "30일간 복약 순응도 80%", "description": "30일 복약 순응도 80% 이상을 30일 연속 유지해요." }, "bmiGreen30": { - "title": "BMI 정상 · 30일", + "title": "30일간 BMI 정상", "description": "BMI를 30일 연속 정상 범위로 유지해요." }, "bpGreen30": { - "title": "혈압 정상 · 30일", + "title": "30일간 혈압 정상", "description": "혈압을 30일 연속 정상 범위로 유지해요." }, "pulseGreen30": { - "title": "맥박 정상 · 30일", + "title": "30일간 맥박 정상", "description": "안정 시 맥박을 30일 연속 정상 범위로 유지해요." }, "onTimePerfect180": { @@ -7996,19 +8143,19 @@ "description": "180일 연속으로 모든 약을 제때 복용해요." }, "compliance80180": { - "title": "복약 순응도 80% · 180일", + "title": "180일간 복약 순응도 80%", "description": "30일 복약 순응도 80% 이상을 180일 연속 유지해요." }, "bmiGreen180": { - "title": "BMI 정상 · 180일", + "title": "180일간 BMI 정상", "description": "BMI를 180일 연속 정상 범위로 유지해요." }, "bpGreen180": { - "title": "혈압 정상 · 180일", + "title": "180일간 혈압 정상", "description": "혈압을 180일 연속 정상 범위로 유지해요." }, "pulseGreen180": { - "title": "맥박 정상 · 180일", + "title": "180일간 맥박 정상", "description": "안정 시 맥박을 180일 연속 정상 범위로 유지해요." }, "onTimePerfect360": { @@ -8016,19 +8163,19 @@ "description": "360일 연속으로 모든 약을 제때 복용해요." }, "compliance80360": { - "title": "복약 순응도 80% · 360일", + "title": "360일간 복약 순응도 80%", "description": "30일 복약 순응도 80% 이상을 360일 연속 유지해요." }, "bmiGreen360": { - "title": "BMI 정상 · 360일", + "title": "360일간 BMI 정상", "description": "BMI를 360일 연속 정상 범위로 유지해요." }, "bpGreen360": { - "title": "혈압 정상 · 360일", + "title": "360일간 혈압 정상", "description": "혈압을 360일 연속 정상 범위로 유지해요." }, "pulseGreen360": { - "title": "맥박 정상 · 360일", + "title": "360일간 맥박 정상", "description": "안정 시 맥박을 360일 연속 정상 범위로 유지해요." }, "moodFirst": { @@ -8112,15 +8259,15 @@ "description": "앱 언어를 한 번 이상 바꿨어요." }, "missFree7": { - "title": "복용 누락 없음 · 7일", + "title": "7일간 복용 누락 없음", "description": "7일 연속으로 복용을 한 번도 놓치지 않아요." }, "missFree30": { - "title": "복용 누락 없음 · 30일", + "title": "30일간 복용 누락 없음", "description": "30일 연속으로 복용을 한 번도 놓치지 않아요." }, "missFree90": { - "title": "복용 누락 없음 · 90일", + "title": "90일간 복용 누락 없음", "description": "90일 연속으로 복용을 한 번도 놓치지 않아요." }, "measurementWeeks4": { @@ -8129,10 +8276,10 @@ }, "selfContextComplete": { "title": "자기 보고 완성", - "description": "자기 보고 항목 네 가지를 모두 채웠어요 — 코치가 상황을 알아요." + "description": "자기 보고 항목 네 가지를 모두 채웠어요. 코치가 상황을 알아요." }, "sleepLog7": { - "title": "수면 기록 · 7일", + "title": "7일간 수면 기록", "description": "7일 연속으로 수면 데이터를 기록해요." } }, @@ -8160,7 +8307,7 @@ "dashboardCard": { "title": "최근 달성", "viewAll": "전체 보기", - "empty": "아직 업적이 없어요 — 기록을 이어가 첫 업적을 달성해 보세요." + "empty": "아직 업적이 없어요. 기록을 이어가 첫 업적을 달성해 보세요." } }, "notifications": { @@ -8222,9 +8369,6 @@ "enabledToast": "능동 코치 알림을 켰어요.", "disabledToast": "능동 코치 알림을 껐어요.", "saveError": "저장하지 못했어요. 나중에 다시 시도해 주세요.", - "suggestionsLabel": "예시 질문", - "suggestionsDesc": "코치 화면에 미리 준비된 예시 질문을 가끔 보여줘요.", - "suggestionsAria": "코치 예시 질문 켜고 끄기", "aiComposedLabel": "코치가 알림 문구 작성", "aiComposedDesc": "제공자를 쓸 수 있으면 고정 템플릿 대신 모델이 알림 문구를 써요. 기본값은 꺼짐이에요.", "aiComposedAria": "AI가 쓴 능동 알림 켜고 끄기" @@ -8295,7 +8439,27 @@ "loginRequired": "알림을 관리하려면 로그인해 주세요.", "someChannelsGloballyDisabled": "설정에서 꺼둔 채널이 있어서 그 채널로는 알림을 받지 못해요.", "reminderSettingsLink": "알림 설정", - "channelsLink": "채널" + "channelsLink": "채널", + "handover": { + "claimedTitle": "{name} 님에게 이제 자신의 계정이 있습니다", + "claimedEnd": "{name} 님이 자신의 건강 기록을 넘겨받았습니다. 내 접근 권한은 종료되었습니다.", + "claimedRead": "{name} 님이 자신의 건강 기록을 넘겨받았습니다. 계속 볼 수는 있지만 더 이상 변경할 수 없습니다.", + "claimedManage": "{name} 님이 자신의 건강 기록을 넘겨받았습니다. 다르게 결정할 때까지 계속 관리할 수 있습니다.", + "changedTitle": "{name} 님이 내 접근 권한을 바꿨습니다", + "changedEnd": "{name} 님의 건강 기록에 대한 내 접근 권한이 종료되었습니다.", + "changedRead": "이제 {name} 님의 건강 기록을 볼 수는 있지만 변경할 수는 없습니다.", + "changedManage": "{name} 님의 건강 기록을 관리할 수 있습니다.", + "unnamed": { + "claimedTitle": "돌보던 분이 이제 자신의 계정을 갖게 되었어요", + "claimedEnd": "돌보던 분이 자신의 건강 기록을 넘겨받았어요. 내 접근 권한은 종료되었어요.", + "claimedRead": "돌보던 분이 자신의 건강 기록을 넘겨받았어요. 계속 볼 수는 있지만 더 이상 변경할 수 없어요.", + "claimedManage": "돌보던 분이 자신의 건강 기록을 넘겨받았어요. 그분이 다르게 결정할 때까지 계속 관리할 수 있어요.", + "changedTitle": "건강 기록에 대한 접근 권한이 변경되었어요", + "changedEnd": "돌보던 분의 건강 기록에 대한 접근 권한이 종료되었어요.", + "changedRead": "이제 돌보던 분의 건강 기록을 볼 수 있지만 변경할 수는 없어요.", + "changedManage": "돌보던 분의 건강 기록을 관리할 수 있어요." + } + } }, "i18n": { "maintainershipBanner": { @@ -8326,7 +8490,7 @@ } }, "edit": { - "title": "목표 범위 편집 — {metric}", + "title": "목표 범위 편집: {metric}", "description": "차트·인사이트·진료용 리포트에 쓰이는 목표 범위를 조정해요. 값 단위는 {unit}.", "derivedHint": "이 카드는 다른 지표에서 계산돼요. 기준이 되는 목표(예: 혈압이나 체중)를 편집해 주세요.", "minLabel": "최소", @@ -8350,15 +8514,19 @@ "currentValue": "현재: {value} {unit}", "targetRangeValue": "목표: {min}–{max} {unit}", "sourceLabel": "출처: {source}", + "sourceNames": { + "moodEntries": "기분 기록", + "sevenDays": "7일" + }, "inTarget": "목표 범위 안", "aboveTarget": "목표보다 {delta} {unit} 높음", "belowTarget": "목표보다 {delta} {unit} 낮음", "diastolic": "이완기", - "glucoseFasting": "혈당 — 공복", - "glucosePostprandial": "혈당 — 식후", - "glucoseRandom": "혈당 — 무작위", - "glucoseBedtime": "혈당 — 취침 전", - "glucoseUnspecified": "혈당 — 미지정", + "glucoseFasting": "혈당 (공복)", + "glucosePostprandial": "혈당 (식후)", + "glucoseRandom": "혈당 (무작위)", + "glucoseBedtime": "혈당 (취침 전)", + "glucoseUnspecified": "혈당 (미지정)", "status": { "underweight": "저체중", "normal": "정상", @@ -8421,11 +8589,11 @@ "errorUnknownAction": "알 수 없는 동작이에요.", "snoozeOneHour": "1시간", "snoozeThreeHours": "3시간", - "snoozedFor": "{name} — {duration} 동안 미뤘어요.", - "skipped": "{name} — 건너뛰었어요.", - "alreadyRecorded": "{name} — 이미 기록돼 있어요.", - "recordedAsTaken": "{name} — 복용으로 기록했어요.", - "confirmed": "{name} — 확인했어요.", + "snoozedFor": "{name}: {duration} 동안 미뤘어요.", + "skipped": "{name}: 건너뛰었어요.", + "alreadyRecorded": "{name}: 이미 기록돼 있어요.", + "recordedAsTaken": "{name}: 복용으로 기록했어요.", + "confirmed": "{name}: 확인했어요.", "genericConfirmed": "확인했어요.", "cancelled": "취소했어요.", "moodLogged": "기록함: {score}/5", @@ -8444,7 +8612,7 @@ "askMedicationName": "약을 지정해 주세요. 예: taken 라미프릴.", "medicationNotFoundExact": "약을 찾지 못했어요. HealthLog에 있는 이름 그대로 보내 주세요.", "helpHeader": "사용할 수 있는 명령:", - "helpBody": "/help — 이 도움말 보기\n/add — 복용 기록하기\n/start — 봇 시작하기\n\n텍스트 명령:\ntaken <이름> — 복용 확인\n\n알림 속 버튼:\n• 복용 — 복용 확인\n• 🕐 1h / 🕐 3h — 알림 미루기\n• ⏭ 건너뛰기 — 복용 건너뛰기\n• ✓ 확인 — 놓친 복용 확인", + "helpBody": "/help: 이 도움말 보기\n/add: 복용 기록하기\n/start: 봇 시작하기\n\n텍스트 명령:\ntaken <이름>: 복용 확인\n\n알림 속 버튼:\n• 복용: 복용 확인\n• 🕐 1h / 🕐 3h: 알림 미루기\n• ⏭ 건너뛰기: 복용 건너뛰기\n• ✓ 확인: 놓친 복용 확인", "measureValuePrompt": "값을 답장으로 보내면 기록해요(선택).", "buttonMeasureValue": "값…", "measureValueSaved": "측정값을 저장했어요.", @@ -8469,9 +8637,9 @@ }, "lowStockReminders": { "title": "재고 부족: {medName}", - "bodyToday": "{medName}: {units}개 남았어요 — 하루치도 안 돼요.", - "bodyReorder": "{medName}: {units}개 남았어요. {runsOutOn}쯤 소진돼요 — {reorderBy}까지 재주문하세요.", - "bodyLastDose": "{medName}: {units}개 남았어요 — 마지막 복용분이에요. {runsOutOn}쯤 소진돼요.", + "bodyToday": "{medName}: {units}개 남았어요. 하루치도 안 돼요.", + "bodyReorder": "{medName}: {units}개 남았어요. {runsOutOn}쯤 소진돼요. {reorderBy}까지 재주문하세요.", + "bodyLastDose": "{medName}: {units}개 남았어요. 마지막 복용분이에요. {runsOutOn}쯤 소진돼요.", "bodyExpired": "{medName}: 쓸 수 있는 재고가 없어요. {units}개가 유효기간 만료로 표시돼 있어요.", "bodyAsNeeded": "{medName}: {units}개만 남았어요. 다음에 필요하기 전에 재주문하세요." }, @@ -8498,11 +8666,11 @@ "questionConditions": "코치가 알아야 할 만성 질환이 있나요?", "questionFocus": "코치가 특별히 신경 써야 할 부분이 있나요?", "questionAllergies": "알레르기나 불내증이 있나요?", - "questionAboutMe": "코치에게 자신에 대해 조금 알려줄래요 — 생활 루틴, 목표, 상황 같은 것?" + "questionAboutMe": "코치에게 생활 루틴, 목표, 상황 같은 자신의 이야기를 조금 알려줄래요?" }, "clinicianView": { "title": "공유된 건강 기록", - "period": "보고 기간: {start} – {end}", + "period": "보고 기간: {start}–{end}", "expires": "이 링크는 {date}에 만료돼요.", "provenance": "본인이 직접 기록한 HealthLog 데이터에서 내보낸 읽기 전용 요약이에요. 값은 본인이 기록한 것이고 기기에서 동기화된 측정값이 섞여 있을 수 있어요. 임상 검사가 아니라 환자가 보고한 자료로 봐 주세요.", "labs": "검사 수치", @@ -8526,7 +8694,7 @@ "strain": "부하 점수", "score": "웰니스 점수" }, - "footer": "HealthLog에서 공유했어요 — 셀프 호스팅 개인 건강 기록 앱.", + "footer": "HealthLog(셀프 호스팅 개인 건강 기록 앱)에서 공유했어요.", "unlock": { "title": "암호 입력", "description": "이 공유 건강 기록은 보호돼 있어요. 전달받은 암호를 입력해 주세요.", @@ -8561,7 +8729,11 @@ }, "course": "복용 기간 {n}", "courseRange": "{start}부터 {end}까지", - "courseSince": "{start}부터" + "courseSince": "{start}부터", + "unavailable": { + "title": "링크가 만료되었거나 유효하지 않아요", + "description": "이 공유 링크는 만료되었거나 취소되었거나 불완전해요. 공유한 분께 새 링크를 요청해 주세요." + } }, "cycle": { "symptomCategory": { @@ -8614,7 +8786,7 @@ }, "crosstab": { "description": "내 데이터를 기준으로, 각 측정값이 황체기와 난포기에서 어떻게 다른지 비교했어요.", - "pairLabel": "{metric} · 황체기와 난포기 비교", + "pairLabel": "{metric}: 황체기와 난포기 비교", "detail": "황체기 {lutealAvg}{unit} ({lutealDays}일), 난포기 {follicularAvg}{unit} ({follicularDays}일).", "metricRestingHeartRate": "안정 시 심박수", "metricHrv": "심박변이도", @@ -8737,8 +8909,8 @@ "ariaUnknown": "아직 진행 중인 주기가 없어요", "caption": "주기 중", "firstPeriodCta": "첫 생리 기록하기", - "periodOverdue": "생리가 평소보다 늦어지고 있어요 — 다음 생리를 기록할 때까지 날짜 세기를 멈춰요.", - "periodOverdueDays": "생리가 평소보다 {days}일 늦어지고 있어요 — 다음 생리를 기록할 때까지 날짜 세기를 멈춰요.", + "periodOverdue": "생리가 평소보다 늦어지고 있어요. 다음 생리를 기록할 때까지 날짜 세기를 멈춰요.", + "periodOverdueDays": "생리가 평소보다 {days}일 늦어지고 있어요. 다음 생리를 기록할 때까지 날짜 세기를 멈춰요.", "logPeriodCta": "생리 기록" }, "calendar": { @@ -8750,7 +8922,7 @@ "prevMonth": "이전 달", "nextMonth": "다음 달", "loading": "캘린더를 불러오는 중…", - "learning": "주기를 배우는 중이에요 — 전체 주기를 세 번 이상 기록하면 가임기, 배란, 단계가 채워져요. 그때까지 캘린더에는 기록한 것만 보여요.", + "learning": "주기를 배우는 중이에요. 전체 주기를 세 번 이상 기록하면 가임기, 배란, 단계가 채워져요. 그때까지 캘린더에는 기록한 것만 보여요.", "legendSymptom": "증상", "legendOvulationConfirmed": "확인된 배란", "flowSpotting": "점상 출혈", @@ -8763,7 +8935,7 @@ "predictions": { "title": "다음에 올 일", "nextPeriod": "다음 생리", - "nextPeriodWindow": "{low} – {high}", + "nextPeriodWindow": "{low}–{high}", "fertileWindow": "가임기", "ovulation": "예상 배란일", "confidenceHigh": "높음", @@ -8772,7 +8944,7 @@ "stillLearning": "아직 배우는 중", "stillLearningBody": "전체 주기를 세 번 이상 기록하면 예측이 더 정확해져요. 그때까지는 대략적인 추정이에요.", "rawMode": "예측 꺼짐", - "rawModeBody": "예측을 꺼 두셨어요 — 예보 없이 기록한 데이터만 보여요.", + "rawModeBody": "예측을 꺼 두셨어요. 예보 없이 기록한 데이터만 보여요.", "none": "아직 예측이 없어요. 생리를 기록하고 시작해 보세요.", "ovulationConfirmed": "배란 (확인됨)" }, @@ -8805,7 +8977,7 @@ "symptoms": "증상", "temperature": "기초 체온", "temperatureDisturbed": "측정값을 방해받음으로 표시", - "temperatureDisturbedHint": "발열, 아픈 날, 음주, 늦은 측정 — 체온 평가에서 제외돼요.", + "temperatureDisturbedHint": "발열, 아픈 날, 음주, 늦은 측정은 체온 평가에서 제외돼요.", "ovulationTest": "배란 테스트", "mucus": "자궁경부 점액", "cervix": "자궁경부", @@ -8834,7 +9006,7 @@ "temperatureSection": "체온 및 배란", "intimacySection": "성관계 및 피임", "testsSection": "검사", - "phaseDayHeader": "{day}일째 · {phase}", + "phaseDayHeader": "{day}일째, {phase}", "bbtHint": "아침에 일어나기 전, 가장 먼저 재 주세요.", "titleForDate": "{date} 기록", "descriptionToday": "오늘. 생리량, 증상, 메모예요.", @@ -8901,7 +9073,7 @@ "fieldInfo": { "bbt": "아침에 일어나기 전 가장 먼저 재는 안정 시 체온이에요. 배란 후에는 약 0.2~0.3°C 올라가요.", "bbtLabel": "기초 체온이 뭔가요?", - "mucus": "질 분비물은 주기에 따라 달라져요 — 건조하거나 끈적한 상태에서 크림 같은 상태로, 가장 가임력이 높은 날 무렵에는 물 같고 달걀흰자 같은 상태로 바뀌어요.", + "mucus": "질 분비물은 주기에 따라 달라져요. 건조하거나 끈적한 상태에서 크림 같은 상태로, 가장 가임력이 높은 날 무렵에는 물 같고 달걀흰자 같은 상태로 바뀌어요.", "mucusLabel": "자궁경부 점액이 뭔가요?", "ovulationTest": "LH 급상승을 잡아내는 가정용 소변 검사예요. LH는 배란 약 24~36h 전에 치솟는 호르몬이고, 양성이면 가장 가임력이 높은 시기라는 뜻이에요.", "ovulationTestLabel": "배란 테스트가 뭔가요?", @@ -8909,7 +9081,7 @@ "spottingLabel": "생리 사이 출혈이 뭔가요?", "pregnancyTest": "가정용 임신(hCG) 테스트 결과예요.", "pregnancyTestLabel": "임신 테스트가 뭔가요?", - "progesteroneTest": "배란이 일어났는지 확인하는 검사예요 — 배란 후에 프로게스테론이 올라가요.", + "progesteroneTest": "배란이 일어났는지 확인하는 검사예요. 배란 후에 프로게스테론이 올라가요.", "progesteroneTestLabel": "프로게스테론 검사가 뭔가요?", "intercourse": "이 날 성관계가 있었는지 기록해요.", "intercourseLabel": "성관계 항목은 무엇을 기록하나요?", @@ -8962,7 +9134,7 @@ "postpone": { "menuItem": "미루기…", "title": "예정일 미루기", - "description": "이번 예정일만 옮겨요 — 반복 주기는 그대로예요.", + "description": "이번 예정일만 옮겨요. 반복 주기는 그대로예요.", "chip": "+{days}일", "dateLabel": "새 날짜", "confirm": "미루기" @@ -9011,10 +9183,10 @@ "VISCERAL_FAT": "내장지방", "BODY_MASS_INDEX": "체질량지수(BMI)", "WAIST_CIRCUMFERENCE": "허리둘레", - "PHQ9_SCORE": "PHQ-9 · 기분", - "GAD7_SCORE": "GAD-7 · 걱정", - "WHO5_SCORE": "WHO-5 · 웰빙", - "SCI_SCORE": "SCI · 수면" + "PHQ9_SCORE": "PHQ-9 (기분)", + "GAD7_SCORE": "GAD-7 (걱정)", + "WHO5_SCORE": "WHO-5 (웰빙)", + "SCI_SCORE": "SCI (수면)" }, "typeGroups": { "vitals": "활력징후", @@ -9154,7 +9326,7 @@ "sourceRangePlaceholder": "예: 3.5 - 5.0 또는 < 5", "sourceRangeAdd": "+ 결과지의 범위 추가", "sourceRangeHint": "선택 사항이에요. 이 측정값에만 적용되고, 저장된 범위는 그대로예요.", - "sourceRangeUnparsed": "적은 그대로 뒀어요 — 수치 범위를 읽지 못했어요." + "sourceRangeUnparsed": "적은 그대로 뒀어요. 수치 범위를 읽지 못했어요." }, "backToLabs": "검사 결과로 돌아가기", "hasNote": "메모", @@ -9172,7 +9344,7 @@ "emptyDescription": "값을 추가하면 이 바이오마커의 추이를 볼 수 있어요.", "title": "바이오마커", "genericDescription": "기록 중인 검사 바이오마커예요. 측정값을 추가하면 참고 범위와 비교한 추이를 볼 수 있어요.", - "valuesTitle": "{marker} — 전체 측정값", + "valuesTitle": "{marker}: 전체 측정값", "valuesDescription": "이 바이오마커의 모든 측정값이에요. 항목을 바로 편집하거나 삭제할 수 있어요.", "backToMarker": "{marker} 화면으로 돌아가기", "latestResult": "최근 결과" @@ -9211,7 +9383,7 @@ "form": { "seedLabel": "자주 쓰는 마커에서 시작", "seedPlaceholder": "마커 선택 (선택 사항)", - "seedHint": "하나를 고르면 아래 항목이 채워져요 — 범위는 검사실에 맞게 조정하세요.", + "seedHint": "하나를 고르면 아래 항목이 채워져요. 범위는 검사실에 맞게 조정하세요.", "name": "이름", "namePlaceholder": "예: LDL 콜레스테롤", "unit": "단위", @@ -9225,7 +9397,7 @@ "upperPlaceholder": "상한값", "context": "설명", "contextPlaceholder": "이 마커에 대한 메모 (선택)", - "rangeHint": "하한과 상한 중 하나는 비워도 돼요 — 한쪽만 있는 범위가 돼요 (예: LDL < 116).", + "rangeHint": "하한과 상한 중 하나는 비워도 돼요. 한쪽만 있는 범위가 돼요 (예: LDL < 116).", "requiredError": "이름과 단위는 필수예요.", "rangeOrderError": "하한은 상한보다 클 수 없어요.", "save": "바이오마커 저장", @@ -9343,12 +9515,12 @@ "dateLabel": "날짜", "refLowLabel": "참고 하한", "refHighLabel": "참고 상한", - "newBiomarker": "새 바이오마커 — 새로 만들어요", + "newBiomarker": "새 바이오마커: 새로 만들어요", "linksExisting": "기존 {name} 항목에 연결돼요", "duplicateWarning": "같은 측정값이 이미 있어요", "lowConfidence": "이 값은 다시 확인해 주세요", "lowConfidenceBadge": "확신 낮음", - "valueUnreadable": "값을 읽지 못했어요 — 직접 입력해 주세요", + "valueUnreadable": "값을 읽지 못했어요. 직접 입력해 주세요", "saveSelected": "선택한 {count}개 저장", "discardAll": "모두 버리기", "savedToast": "검사 결과 {count}개를 저장했어요", @@ -9464,6 +9636,10 @@ "vaccinations": { "label": "예방접종", "description": "예방접종 기록을 구조화해서: 용량, 차수, 다음 추가 접종." + }, + "timeline": { + "label": "타임라인", + "description": "몇 년을 한눈에: 수치 옆에 질환, 약, 진료가 놓이고, 내 삶의 사건도 함께 보여요." } }, "moduleOff": { @@ -9495,7 +9671,7 @@ }, "form": { "date": "날짜", - "catalogLabel": "백신 (목록)", + "catalogLabel": "백신", "identityHint": "목록에서 고르거나 아래에 직접 입력해 주세요. 둘 중 하나면 충분해요.", "catalogNone": "백신 선택", "catalogSearch": "백신 검색", @@ -9616,10 +9792,31 @@ "overdue": "{date}부터 추가접종 기한 지남", "dueSoon": "{date} 추가접종 예정", "current": "다음 추가접종 {date}" + }, + "custom": { + "title": "직접 추가한 백신", + "add": "백신 직접 추가", + "name": "이름", + "components": "예방 대상", + "seriesDoses": "기초 접종 횟수", + "boosterMonths": "추가 접종 주기(개월)", + "saved": "백신을 저장했습니다", + "deleted": "백신을 삭제했습니다", + "deleteConfirm": "이 백신을 삭제할까요? 이미 기록된 접종은 이름이 그대로 남습니다.", + "saveFailed": "백신을 저장하지 못했습니다.", + "nameTaken": "같은 이름의 백신이 이미 있습니다.", + "editTitle": "직접 추가한 백신 수정", + "description": "목록에 없는 백신도 목록의 백신과 똑같이 계산돼요.", + "componentsHint": "예방하는 질병을 모두 골라 주세요.", + "boosterHint": "추가 접종이 없으면 비워 두세요.", + "protectsAgainst": "{list} 예방", + "deleteTitle": "직접 추가한 백신을 삭제할까요?", + "sectionDescription": "목록에 없어서 직접 추가한 백신이에요.", + "catalogGroup": "목록" } }, "illness": { - "title": "아픈 날 일지", + "title": "아픈 날", "subtitle": "아픈 날과 컨디션 에피소드를 돌아보는 기록이에요.", "settings": { "viewDescription": "에피소드를 카드로 볼지 간단한 목록으로 볼지 선택하세요.", @@ -9706,7 +9903,7 @@ }, "correlation": { "title": "어떻게 진행됐나", - "learning": "아직 학습 중이에요 — 며칠 더 기록하면 패턴이 보여요.", + "learning": "아직 학습 중이에요. 며칠 더 기록하면 패턴이 보여요.", "preOnsetTitle": "어떤 신호가 먼저 왔나", "preOnsetBody": "시작일로 표시하기 전에 기준값에서 벗어났던 신호예요.", "nadirTitle": "무엇이 달라졌나", @@ -9733,7 +9930,7 @@ "countOne": "이 기간에 에피소드를 1개 기록했어요.", "countOther": "이 기간에 에피소드를 {count}개 기록했어요.", "typicalGap": "평균 회복 시차: 몸이 나아지고 수치가 안정되기까지 {days}일.", - "typicalGapWithDriver": "평균 회복 시차: {days}일 — {vital} 안정보다 몸이 먼저 나아지는 편이에요.", + "typicalGapWithDriver": "평균 회복 시차: {days}일, {vital} 안정보다 몸이 먼저 나아지는 편이에요.", "typicalGapLearning": "종료된 에피소드가 몇 개 더 쌓이면 평균 회복 시차가 나와요.", "none": "이 기간에 기록한 에피소드가 없어요.", "analyze": "회복 시차 분석" @@ -9794,8 +9991,8 @@ "stop": "충분히 측정하고 있어요", "accepted": "알림을 설정했어요", "dismissed": "알겠어요, 다시 제안하지 않을게요", - "stopped": "알겠어요 — 알림 제안은 그만할게요", - "failed": "설정하지 못했어요 — 다시 시도해 주세요", + "stopped": "알겠어요. 알림 제안은 그만할게요", + "failed": "설정하지 못했어요. 다시 시도해 주세요", "cadence": { "weightDaily": "매일 비슷한 시간에 체중을 재세요", "bp722": "일주일 동안 하루 두 번 혈압을 재세요", @@ -9807,7 +10004,7 @@ "dismiss": "나중에", "applied": "추가했어요", "dismissed": "닫았어요", - "failed": "추가하지 못했어요 — 다시 시도해 주세요", + "failed": "추가하지 못했어요. 다시 시도해 주세요", "checkup": { "title": "건강검진 알림을 추가할까요?" }, @@ -9835,9 +10032,9 @@ "reviewPrefix": "점검: {when}", "accept": "계획 수락", "decline": "거절", - "accepted": "계획을 수락했어요 — 코치가 기억할게요.", + "accepted": "계획을 수락했어요. 코치가 기억할게요.", "declined": "제안을 거절했어요.", - "failed": "잘 안 됐어요 — 다시 시도해 주세요.", + "failed": "잘 안 됐어요. 다시 시도해 주세요.", "markMet": "목표 달성", "abandon": "계획 종료", "remove": "삭제", @@ -9858,8 +10055,8 @@ } }, "refusal": { - "outOfScope": "저는 기록에 있는 건강 지표만 도와드릴 수 있어요 — 측정값, 약, 기분, 추이 같은 것들이요. 그 범위 밖의 질문에는 답할 수 없어요.", - "promptInjection": "제 지침을 무시하게 만들려는 표현이 보였어요. 저는 HealthLog 코치이고 본인의 건강 데이터만 정리해 드려요 — 그 범위에 맞게 다시 질문해 주세요.", + "outOfScope": "저는 기록에 있는 건강 지표만 도와드릴 수 있어요. 측정값, 약, 기분, 추이 같은 것들이요. 그 범위 밖의 질문에는 답할 수 없어요.", + "promptInjection": "제 지침을 무시하게 만들려는 표현이 보였어요. 저는 HealthLog 코치이고 본인의 건강 데이터만 정리해 드려요. 그 범위에 맞게 다시 질문해 주세요.", "conversationPoisoned": "이 대화의 앞선 메시지에 제 지침을 무시하게 만드는 표현이 있어요. 새 대화를 시작해 주세요." }, "noRecordedData": "물어보신 내용에 대해 기록된 측정값이 없어서 수치를 알려드릴 수 없어요. 기록을 남기신 뒤에 다시 물어보시면 본인 수치로 답해 드릴게요.", @@ -9895,7 +10092,9 @@ "illness": "질병과 회복", "cycle": "생리 주기", "correlations": "지표 간 연관성", - "snapshot": "기록 요약" + "snapshot": "기록 요약", + "environment": "날씨와 대기", + "day": "하루" }, "window": { "last7days": "최근 7일", @@ -10084,10 +10283,10 @@ "saved": "저장됨", "error": "문제가 생겼어요. 다시 시도해 주세요.", "instrument": { - "phq9": "PHQ-9 · 기분", - "gad7": "GAD-7 · 걱정", - "who5": "WHO-5 · 웰빙", - "sci": "SCI · 수면" + "phq9": "PHQ-9 (기분)", + "gad7": "GAD-7 (걱정)", + "who5": "WHO-5 (웰빙)", + "sci": "SCI (수면)" }, "instrumentSub": { "phq9": "우울 선별, 9문항", @@ -10108,7 +10307,7 @@ "2": "많이 어려웠다", "3": "매우 많이 어려웠다" }, - "functionalOptionalHint": "선택 사항이에요 — 이 문항에 답하지 않아도 제출할 수 있어요.", + "functionalOptionalHint": "선택 사항이에요. 이 문항에 답하지 않아도 제출할 수 있어요.", "result": { "title": "내 결과", "totalLabel": "총점", @@ -10298,7 +10497,7 @@ }, "openDetail": "자세히 보기", "historyLoadError": "검사 기록을 불러오지 못했어요", - "historyLoadErrorHint": "결과가 없다는 뜻은 아니에요 — 목록을 가져오지 못했을 뿐이에요. 다시 시도해 주세요.", + "historyLoadErrorHint": "결과가 없다는 뜻은 아니에요. 목록을 가져오지 못했을 뿐이에요. 다시 시도해 주세요.", "privacyNote": "답변은 암호화되어 저장되고, AI 코치에게는 전달되지 않아요." }, "records": { @@ -10528,12 +10727,34 @@ "daylight": "낮 길이", "precip": "강수량", "pressureMean": "기압", - "pressureDelta": "기압 변화" + "pressureDelta": "기압 변화", + "pm25": "초미세먼지 (PM2.5)", + "pm10": "미세먼지 (PM10)", + "no2": "이산화질소", + "ozone8h": "오존 (8시간 최고)", + "europeanAqi": "유럽 대기질 지수", + "uvIndexMax": "자외선 지수 (최고)", + "dustMax": "사하라 먼지", + "pollenMax": "꽃가루 (최고)" + }, + "chip": { + "day": "{date} 날씨", + "pollenHigh": "꽃가루 많음: {kinds}", + "hotNight": "더운 밤", + "veryPoorAir": "대기질 매우 나쁨" + }, + "pollen": { + "alder": "오리나무", + "birch": "자작나무", + "grass": "잔디", + "mugwort": "쑥", + "olive": "올리브", + "ragweed": "돼지풀" } }, "documents": { "title": "문서", - "subtitle": "모든 소견서·리포트·영상을 한곳에서 검색해요 — 저장할 때 암호화돼요.", + "subtitle": "모든 소견서, 리포트, 영상을 한곳에서 검색해요. 저장할 때 암호화돼요.", "pageUpload": "업로드", "ownerOnlyUpload": "문서는 기록 소유자만 추가할 수 있습니다.", "kind": { @@ -10575,15 +10796,15 @@ }, "error": { "fileTooLarge": "이 파일은 {maxSize} 제한보다 커요.", - "quotaExceeded": "저장 공간이 가득 찼어요 — {quota} 중 {used} 사용 중이에요.", + "quotaExceeded": "저장 공간이 가득 찼어요. {quota} 중 {used} 사용 중이에요.", "unsupportedType": "지원하지 않는 파일 형식이에요.", "purged": "이 문서는 완전히 삭제되어 복구할 수 없어요.", "duplicateExists": "같은 문서가 이미 저장돼 있어요.", - "rateLimited": "지금은 업로드가 너무 많아요 — 몇 분 뒤에 다시 시도해 주세요.", + "rateLimited": "지금은 업로드가 너무 많아요. 몇 분 뒤에 다시 시도해 주세요.", "generic": "업로드하지 못했어요. 다시 시도해 주세요." }, "toast": { - "duplicate": "이미 저장돼 있어요 — 기존 문서를 표시해요.", + "duplicate": "이미 저장돼 있어요. 기존 문서를 표시해요.", "deleted": "문서를 삭제했어요", "restoreFailed": "문서를 복구하지 못했어요." }, @@ -10606,7 +10827,7 @@ }, "empty": { "title": "문서 보관함이 비어 있어요", - "description": "의사 소견서, 검사 리포트, 영상, 처방전, 예방접종 기록을 암호화해 보관하고 종류·질환·연도로 검색해요.", + "description": "의사 소견서, 검사 리포트, 영상, 처방전, 예방접종 기록을 암호화해 보관하고 종류, 질환, 연도별로 검색해요.", "action": "첫 문서 올리기", "noMatchesTitle": "일치하는 문서 없음", "noMatchesDescription": "현재 검색·필터에 맞는 문서가 없어요." @@ -10646,9 +10867,9 @@ "linkCondition": "연결", "noConditions": "질환에 연결되지 않음", "previewUnavailable": "이 파일 형식은 미리보기를 지원하지 않아요", - "uploadedMeta": "추가 {date} · {size}", + "uploadedMeta": "{date} 추가, {size}", "importedFrom": "{system}에서 가져옴", - "importedFromWithId": "{system}에서 가져옴 · ID {id}", + "importedFromWithId": "{system}에서 가져옴, ID {id}", "sourceOther": "다른 시스템", "share": "공유", "labValuesLink": "검사 수치 {count}개 보기", @@ -10715,7 +10936,7 @@ "errorNotIndexed": "문서를 먼저 읽은 뒤에 추출해 주세요.", "errorLocalOcrDisabled": "AI 설정에서 로컬 OCR을 먼저 켜 주세요.", "errorProvider": "이 작업에 쓸 AI 제공자가 설정돼 있지 않아요.", - "errorTextImageOnly": "로컬 OCR은 이미지만 읽어요 — 이 파일은 읽을 수 없어요.", + "errorTextImageOnly": "로컬 OCR은 이미지만 읽어요. 이 파일은 읽을 수 없어요.", "errorConsent": "AI로 문서를 읽으려면 먼저 동의가 필요합니다.", "errorUnavailable": "지금 여기에서는 AI로 문서를 읽을 수 없습니다." }, @@ -10864,6 +11085,21 @@ "finishedHint": "다시 검색하거나 필터를 바꿔 문서를 더 고르세요.", "maxReached": "한 번에 최대 {max}개 문서를 가져올 수 있습니다.", "noMatchHint": "이름의 일부로 검색하거나 태그와 날짜를 지워 보세요." + }, + "customize": "보기 사용자 지정", + "layout": { + "viewDescription": "미리보기가 있는 카드 또는 간단한 목록.", + "arrangementHeading": "배치", + "arrangementDescription": "월별로 따로 묶거나 전체 너비에 이어서 표시합니다.", + "stacked": "월별로 나누기", + "flow": "이어서 보기", + "saveFailed": "보기 설정을 저장하지 못했습니다." + }, + "deleteConfirm": { + "titleOne": "이 문서를 삭제할까요?", + "titleMany": "문서 {count}개를 삭제할까요?", + "body": "삭제 직후에는 되돌릴 수 있습니다. 30일이 지나면 완전히 삭제됩니다.", + "action": "삭제" } }, "selfDescription": { @@ -10950,7 +11186,7 @@ "genericDescription": "직접 기록하는 측정이에요. 값을 추가하면 시간에 따른 변화를 볼 수 있어요.", "emptyTitle": "아직 값이 없어요", "emptyDescription": "첫 값을 추가하면 이 지표를 차트로 볼 수 있어요.", - "valuesTitle": "{metric} — 전체 값", + "valuesTitle": "{metric}: 전체 값", "valuesDescription": "이 지표에 기록된 모든 값이에요.", "backToMetric": "돌아가기: {metric}" } @@ -11003,20 +11239,20 @@ } }, "hydration": { - "referenceMeta": "권장 섭취량 {value} {unit}/일 · EFSA" + "referenceMeta": "권장 섭취량 {value} {unit}/일 (EFSA)" }, "caffeine": { - "ceilingMeta": "안전 상한 {value} {unit}/일 · EFSA" + "ceilingMeta": "안전 상한 {value} {unit}/일 (EFSA)" }, "micronutrients": { "title": "비타민·미네랄", - "rowMeta": "{window}일 중 {days}일 · 기준 {value} {unit} (EFSA)", + "rowMeta": "{window}일 중 {days}일, 기준 {value} {unit} (EFSA)", "rowMetaNoRef": "{window}일 중 {days}일", "footer": "이 기간에 데이터가 있는 미량영양소는 추적 중인 {total}개 중 {tracked}개예요.", "emptyTitle": "아직 동기화된 미량영양소 데이터가 없어요", "loadError": "영양소 데이터를 불러오지 못했어요" }, - "sparseNote": "여기 수치가 낮은 건 보통 결핍이 아니라 기록이 드물다는 뜻이에요 — Apple Health의 하루 합계는 앱이 기록한 양만 반영하고, 전체 식이 섭취량은 아니에요." + "sparseNote": "여기 수치가 낮은 건 보통 결핍이 아니라 기록이 드물다는 뜻이에요. Apple Health의 하루 합계는 앱이 기록한 양만 반영하고, 전체 식이 섭취량은 아니에요." }, "daily": { "line": { @@ -11025,45 +11261,45 @@ }, "push": { "title": "아침 브리핑", - "bodyProvisional": "{line} · 어젯밤 수면은 아직 들어오지 않았어요." + "bodyProvisional": "{line}\n어젯밤 수면은 아직 들어오지 않았어요." }, "item": { "doseWindow": { "title": "복약 시간", "body": "곧 복용할 시간이에요.", - "bodyNamed": "{name} — 곧 복용할 시간이에요.", + "bodyNamed": "{name}: 곧 복용할 시간이에요.", "overdueTitle": "복약 시간 지남", "overdueBody": "복용 시간이 지났어요.", - "overdueBodyNamed": "{name} — 복용 시간이 지났어요." + "overdueBodyNamed": "{name}: 복용 시간이 지났어요." }, "preventiveCare": { "title": "건강검진 시기", - "body": "{label} — 받을 때가 됐어요.", + "body": "{label}: 받을 때가 됐어요.", "bodyManyNamed": "받을 때가 된 건강검진: {labels}" }, "syncIssue": { "title": "동기화 확인이 필요해요", - "body": "{integration} 동기화가 멈췄어요 — 데이터를 최신으로 유지하려면 다시 연결해 주세요." + "body": "{integration} 동기화가 멈췄어요. 데이터를 최신으로 유지하려면 다시 연결해 주세요." }, "coachCheckin": { "title": "계획을 점검할 때예요", - "body": "이 계획을 세운 지 일주일쯤 됐어요 — 계속할지, 조정할지, 놓아줄지 정해 보세요. 어느 쪽이든 부담 갖지 않아도 돼요.", - "bodyPlan": "일주일쯤 전에 이렇게 정했어요: “{plan}”. 계속할지, 조정할지, 놓아줄지 정해 보세요 — 어느 쪽이든 부담 갖지 않아도 돼요.", + "body": "이 계획을 세운 지 일주일쯤 됐어요. 계속할지, 조정할지, 놓아줄지 정해 보세요. 어느 쪽이든 부담 갖지 않아도 돼요.", + "bodyPlan": "일주일쯤 전에 이렇게 정했어요: “{plan}”. 계속할지, 조정할지, 놓아줄지 정해 보세요. 어느 쪽이든 부담 갖지 않아도 돼요.", "adjustPrompt": "점검 계획을 조정하고 싶어요.", "adjustPromptPlan": "이 계획을 조정하고 싶어요: “{plan}”" }, "tensionWindow": { "title": "오늘 긴장이 있었을 수 있어요", "body": { - "morning": "오늘 아침 가만히 있는 동안에도 심박수가 안정 시 기준값 위로 한동안 유지됐어요 — 긴장이 있었을 수 있어요.", - "afternoon": "오늘 오후 가만히 있는 동안에도 심박수가 안정 시 기준값 위로 한동안 유지됐어요 — 긴장이 있었을 수 있어요.", - "evening": "오늘 저녁 가만히 있는 동안에도 심박수가 안정 시 기준값 위로 한동안 유지됐어요 — 긴장이 있었을 수 있어요.", - "night": "밤사이 가만히 있는 동안에도 심박수가 안정 시 기준값 위로 한동안 유지됐어요 — 긴장이 있었을 수 있어요." + "morning": "오늘 아침 가만히 있는 동안에도 심박수가 안정 시 기준값 위로 한동안 유지됐어요. 긴장이 있었을 수 있어요.", + "afternoon": "오늘 오후 가만히 있는 동안에도 심박수가 안정 시 기준값 위로 한동안 유지됐어요. 긴장이 있었을 수 있어요.", + "evening": "오늘 저녁 가만히 있는 동안에도 심박수가 안정 시 기준값 위로 한동안 유지됐어요. 긴장이 있었을 수 있어요.", + "night": "밤사이 가만히 있는 동안에도 심박수가 안정 시 기준값 위로 한동안 유지됐어요. 긴장이 있었을 수 있어요." } }, "ecgNewRecording": { "title": "새 심전도 기록", - "body": "기기가 새 심전도를 기록했어요 — 지금 볼 수 있어요.", + "body": "기기가 새 심전도를 기록했어요. 지금 볼 수 있어요.", "bodyVerdict": "기기가 새 심전도를 기록하고 이렇게 알렸어요: {verdict}.", "verdict": { "irregular": "불규칙한 리듬 가능성", @@ -11083,9 +11319,9 @@ }, "upcomingVisit": { "title": "다가오는 예약", - "bodyToday": "{what} — 오늘", - "bodyTomorrow": "{what} — 내일", - "bodyDayAfterTomorrow": "{what} — 모레" + "bodyToday": "{what}: 오늘", + "bodyTomorrow": "{what}: 내일", + "bodyDayAfterTomorrow": "{what}: 모레" } }, "action": { @@ -11143,7 +11379,7 @@ }, "record": { "title": "{metric} 최고 기록", - "body": "오늘 {metric} 개인 최고 기록이에요 — 조용히 적어 뒀어요, 하실 일은 없어요." + "body": "오늘 {metric} 개인 최고 기록이에요. 조용히 적어 뒀어요, 하실 일은 없어요." }, "returnToBaseline": { "title": "범위 복귀: {metric}", @@ -11305,7 +11541,7 @@ "canManage": "열람, 추가, 그리고 있는 내용의 수정·삭제까지 가능", "entireRecord": "기록 전체가 대상", "sections": "대상: {sections}", - "noSections": "대상 없음 — 이 권한으로는 기록의 어떤 부분도 열 수 없어요", + "noSections": "대상 없음: 이 권한으로는 기록의 어떤 부분도 열 수 없어요", "invited": "초대", "accepted": "수락", "expires": "만료", @@ -11475,7 +11711,47 @@ "guardianErrorFailed": "초대를 보내지 못했어요.", "errorMfaRequired": "여기에는 2단계 인증이 필요한데 계정에 아직 없어요. 설정 → 보안에서 등록한 뒤 다시 시도해 주세요.", "rosterLoadError": "이 기록을 돌보는 사람을 불러오지 못했어요.", - "errorRateLimit": "최근 한 시간 동안 만든 프로필이 너무 많아요. 잠시 기다렸다 다시 시도해 주세요." + "errorRateLimit": "최근 한 시간 동안 만든 프로필이 너무 많아요. 잠시 기다렸다 다시 시도해 주세요.", + "handover": { + "action": "넘겨주기", + "title": "{name} 님에게 넘겨주기", + "description": "{name} 님에게 자신의 로그인을 만들어 주세요. 아무것도 복사되거나 옮겨지지 않고, 기록이 그대로 그 사람의 계정이 됩니다.", + "guardiansLabel": "넘겨준 뒤의 접근 권한", + "guardiansHint": "내 제안입니다. {name} 님이 로그인한 뒤 보고 결정합니다.", + "access": { + "end": "접근 없음", + "read": "보기만", + "manage": "관리" + }, + "expiryLabel": "링크 유효 기간", + "expiry1": "1일", + "expiry7": "7일", + "expiry14": "14일", + "minorHint": "{name} 님은 16세 미만입니다. 보기 권한을 유지하고 이 단계를 함께 이야기해 보는 것이 좋습니다.", + "irreversible": "{name} 님이 링크를 사용하면 넘겨주기를 되돌릴 수 없습니다. 기록은 그 사람의 것이 되고, 누가 접근 권한을 유지할지 직접 결정합니다.", + "afterwards": "{name} 님이 안내 사항과 AI 동의를 직접 확인합니다. 이 기록의 알림은 더 이상 나에게 오지 않습니다.", + "replaceNote": "새 링크를 만들면 열려 있는 링크는 철회됩니다.", + "create": "링크 만들기", + "creating": "만드는 중…", + "linkOnce": "이 링크는 지금만 표시됩니다. {name} 님에게 직접 또는 믿을 수 있는 방법으로 전달하거나 코드를 스캔하게 하세요.", + "qrAlt": "{name} 님을 위한 넘겨주기 링크 QR 코드", + "urlLabel": "넘겨주기 링크", + "copy": "복사", + "copied": "링크를 복사했습니다", + "copyError": "링크를 복사하지 못했습니다. 선택해서 직접 복사하세요.", + "linkExpires": "{date}까지 유효합니다.", + "done": "완료", + "pending": "{date}까지 넘겨주기 링크가 열려 있습니다.", + "replace": "새 링크", + "revoke": "링크 철회", + "revoked": "링크를 철회했습니다", + "revokeFailed": "링크를 철회하지 못했습니다. 다시 시도하세요.", + "errorFailed": "넘겨주기 링크를 만들지 못했습니다.", + "errorRosterChanged": "그 사이 기록을 돌보는 사람이 바뀌었습니다. 닫았다가 다시 여세요.", + "oidcOnly": "이 서버가 통합 로그인(SSO)만 허용하는 동안에는 프로필을 넘겨줄 수 없습니다.", + "loadError": "넘겨주기 상태를 불러오지 못했습니다.", + "entryHint": "{name} 님이 이 기록을 직접 관리할 준비가 되었을 때." + } }, "toast": { "savedTo": "{name} 님의 기록에 저장했어요" @@ -11594,7 +11870,13 @@ "symptomEventDelete": "{name}님이 증상 기록을 삭제했습니다", "symptomDefinitionCreate": "{name}님이 증상을 추가했습니다", "symptomDefinitionUpdate": "{name}님이 증상을 변경했습니다", - "symptomDefinitionDelete": "{name}님이 증상을 제거했습니다" + "symptomDefinitionDelete": "{name}님이 증상을 제거했습니다", + "customVaccineCreate": "{name} 님이 백신을 직접 추가했어요", + "customVaccineUpdate": "{name} 님이 직접 추가한 백신을 수정했어요", + "customVaccineDelete": "{name} 님이 직접 추가한 백신을 삭제했어요", + "lifeEventCreate": "{name} 님이 삶의 사건을 추가했어요", + "lifeEventUpdate": "{name} 님이 삶의 사건을 수정했어요", + "lifeEventDelete": "{name} 님이 삶의 사건을 삭제했어요" }, "section": { "measurements": { @@ -11650,6 +11932,22 @@ }, "anamnesis": { "relativeNote": "가족력 항목은 이 사람의 친척에 대한 내용이에요. 여기 기록되기를 상대가 원할 때만 추가하세요." + }, + "handoverDecision": { + "title": "내 기록에 접근할 수 있는 사람", + "description": "기록을 관리해 온 사람들이 앞으로 무엇을 할 수 있을지 정하세요.", + "proposed": "제안: {access}", + "settled": "공유 접근에서 이미 변경됨", + "empty": "다른 사람은 내 기록에 접근할 수 없었습니다.", + "later": "설정, 공유 접근에서 언제든지 바꿀 수 있습니다.", + "save": "확인", + "saving": "저장하는 중…", + "decideLater": "나중에", + "errorFailed": "결정을 저장하지 못했습니다. 다시 시도하세요.", + "loadError": "기록을 관리해 온 사람들을 불러오지 못했습니다.", + "notificationsTitle": "내 알림 설정하기", + "notificationsBody": "지금까지 이 기록의 알림은 기록을 관리하던 사람들에게 갔습니다. 이제 알림 채널을 추가하면 바로 나에게 옵니다.", + "notificationsAction": "알림 설정 열기" } }, "encounters": { @@ -11670,7 +11968,7 @@ "both": "양쪽", "none": "지정 안 함" }, - "bodySiteWithSide": "{site} · {side}", + "bodySiteWithSide": "{site} ({side})", "sectionTitle": "진료", "sectionDescription": "병원 방문과 잡아 둔 예약을 한곳에서 봐요.", "add": "진료", @@ -11685,7 +11983,7 @@ "created": "진료를 저장했어요", "updated": "진료를 수정했어요", "saveFailed": "진료를 저장하지 못했어요.", - "checkupNotClosed": "진료를 저장했어요. 건강검진은 열린 채로 남아요 — 권한이 거기까지는 미치지 않아요.", + "checkupNotClosed": "진료를 저장했어요. 건강검진은 열린 채로 남아요. 권한이 거기까지는 미치지 않아요.", "status": { "planned": "예정", "done": "방문함", @@ -11702,7 +12000,7 @@ "dateDerivesStatusHint": "미래 날짜는 잡아 둔 예약으로 처리돼요.", "status": "상태", "practitioner": "병원 또는 의사", - "practitionerHint": "선택 — 이름은 나중에 추가해도 돼요.", + "practitionerHint": "선택: 이름은 나중에 추가해도 돼요.", "practitionerNone": "지정 안 함", "practitionerSearch": "이름이나 병원 검색", "practitionerNoMatch": "항목을 찾지 못했어요.", @@ -11760,9 +12058,8 @@ }, "practitioners": { "title": "병원과 의사", - "description": "나만의 주소록이에요 — 병원을 한 번만 입력하면 어디서든 골라 쓸 수 있어요.", + "description": "나만의 주소록이에요. 병원을 한 번만 입력하면 어디서든 골라 쓸 수 있어요.", "linkLabel": "주소록", - "add": "항목", "searchPlaceholder": "이름, 병원, 진료과 검색", "loadError": "주소록을 불러오지 못했어요.", "emptyTitle": "아직 항목이 없어요", @@ -11859,7 +12156,6 @@ "unreadableLabel": "읽을 수 없는 증상", "intensityPill": "{value}/10", "entry": { - "sheetTitle": "증상 기록", "sheetDescription": "직접 정한 증상과 강도, 시각을 기록합니다.", "symptom": "증상", "symptomRequired": "증상을 선택하세요.", @@ -11908,5 +12204,411 @@ "title": "기록된 증상", "filed": "이 질병과 함께 기록됨" } + }, + "day": { + "scoreOfMax": "/ {max}", + "cycleDay": "주기 {n}일째", + "cyclePhaseLine": { + "MENSTRUAL": "주기 {n}일째, 월경기", + "FOLLICULAR": "주기 {n}일째, 난포기", + "OVULATORY": "주기 {n}일째, 배란기", + "LUTEAL": "주기 {n}일째, 황체기" + }, + "previousDay": "전날", + "nextDay": "다음 날", + "close": "하루 닫기", + "hidePanel": "하루 숨기기", + "showPanel": "{date} 보기", + "openDay": "하루 열기", + "viewWholeDay": "하루 전체 보기", + "captureForDay": "이 날에 기록하기", + "empty": "이 날에는 기록이 없어요.", + "notShared": "일부 영역은 공유되지 않았어요.", + "inTimeline": "타임라인에서 보기", + "askCoach": "이 날에 대해 코치에게 묻기", + "chartHint": "점을 클릭하면 그날이 열려요.", + "loadFailed": "이 날을 불러오지 못했어요.", + "dayOf": "{m}일 중 {n}일째", + "dayN": "{n}일째", + "usualRange": "평소 {lo}~{hi}", + "groups": { + "scores": "점수", + "running": "그날 진행 중", + "values": "수치", + "happened": "있었던 일" + }, + "notable": { + "extremeHigh": "{since} 이후 가장 높은 하루 값", + "extremeLow": "{since} 이후 가장 낮은 하루 값", + "firstValue": "이 종류의 첫 측정", + "gap": "{days}일 동안 측정 없음" + }, + "previousDayShortcut": "전날 (Alt + 왼쪽 화살표)", + "nextDayShortcut": "다음 날 (Alt + 오른쪽 화살표)", + "captureShort": "기록", + "inTimelineShort": "타임라인", + "askCoachPrompt": "{date}에 무슨 일이 있었나요?", + "chartHintTouch": "점을 탭하면 값이 보여요.", + "chartHintBar": "막대를 클릭하면 그날이 열려요.", + "chartHintTouchBar": "막대를 탭하면 값이 보여요.", + "chartHintCell": "날짜 칸을 클릭하면 그날이 열려요.", + "chartHintTouchCell": "날짜 칸을 탭하면 값이 보여요.", + "coachChips": "이 답변에 쓰인 날", + "description": "이 날에 대한 모든 기록이에요.", + "openDocument": "문서 열기", + "previousReading": "이전, {date}", + "rugLegend": "축 아래 점은 기록이 있는 날을, 고리는 눈에 띄는 날을 표시해요.", + "sheetExpand": "전체 높이로 펼치기", + "sheetShrink": "줄이기", + "valuesLegend": "점은 이 날, 막대는 직전 30일", + "valuesAllOne": "모든 수치", + "valuesAllFew": "수치 {count}개 모두", + "valuesAllOther": "수치 {count}개 모두", + "valuesFewer": "수치 줄여 보기", + "countEntriesOne": "기록 1개", + "countEntriesFew": "기록 {count}개", + "countEntriesOther": "기록 {count}개", + "meanOfReadingsOne": "측정 1회 평균", + "meanOfReadingsFew": "측정 {count}회 평균", + "meanOfReadingsOther": "측정 {count}회 평균", + "sinceLastVisit": { + "title": "{date} 마지막 방문 이후", + "countOne": "기록 1개", + "countFew": "기록 {count}개", + "countOther": "기록 {count}개", + "allOne": "모두 보기", + "allFew": "{count}개 모두 보기", + "allOther": "{count}개 모두 보기", + "fewer": "줄여 보기", + "note": "평가 없이 기록에서 모았어요. 날짜를 누르면 그날 전체가 열려요.", + "report": "{date}부터 오늘까지 진료 보고서" + }, + "travel": "여행 중", + "event": { + "started": "{label} 시작", + "ended": "{label} 끝남", + "paused": "{label} 일시 중지", + "resumed": "{label} 다시 시작", + "illnessBegan": "{label} 시작", + "illnessResolved": "{label} 나음" + }, + "pickDate": "날짜 선택", + "pickerTitle": "날짜로 이동", + "today": "오늘", + "hasEntries": "기록 있음", + "dayNSince": "{n}일째, {date}부터", + "sinceDate": "{date}부터" + }, + "timeline": { + "title": "타임라인", + "subtitle": "언제 무엇이 있었는지, 내 수치와 나란히.", + "standing": "지속", + "startUnknown": "시작일 미상", + "zoom": { + "all": "전체", + "year": "12개월", + "quarter": "3개월", + "range": "기간" + }, + "lanes": { + "life": "삶", + "illness": "질환", + "allergies": "알레르기", + "medications": "약", + "vaccinations": "예방접종", + "visits": "진료와 시술", + "labs": "검사", + "documents": "문서", + "cycle": "생리 주기" + }, + "readiness": { + "title": "타임라인에 이미 담긴 것", + "subtitle": "열기 전에 솔직하게 살펴보는 현황이에요.", + "menuEntry": "데이터 현황", + "hint": "빈 줄은 무언가 생길 때까지 숨겨져요. 꺼 둔 모듈은 나타나지 않아요. 이 현황은 나중에 타임라인의 데이터 현황에서 다시 볼 수 있어요.", + "later": "나중에", + "open": "타임라인 열기", + "verdict": { + "carries": "이미 충분히 채워졌어요.", + "thin": "아직 부족해요. 당분간 타임라인은 주로 수치를 보여줘요." + }, + "status": { + "carries": "충분", + "thin": "부족", + "empty": "비어 있음" + }, + "lanes": { + "values": "수치", + "mood": "기분", + "environment": "환경", + "life": "삶의 사건" + }, + "meter": "{total}개 중 {carrying}개 줄이 채워졌어요", + "carriesDetail": "{total}개 중 {carrying}개 줄로 {since}부터 한눈에 볼 수 있어요.", + "loadFailed": "현황을 불러오지 못했어요.", + "card": { + "title": "타임라인에 이미 담긴 것", + "summary": "{since}까지 거슬러 올라가며, {total}개 중 {carrying}개 줄이 이미 채워졌어요.", + "dismiss": "숨기기" + }, + "detail": { + "valuesOne": "{since}부터 측정 항목 {count}개", + "valuesFew": "{since}부터 측정 항목 {count}개", + "valuesOther": "{since}부터 측정 항목 {count}개", + "illnessOne": "에피소드 {count}개, 만성 {chronic}개", + "illnessFew": "에피소드 {count}개, 만성 {chronic}개", + "illnessOther": "에피소드 {count}개, 만성 {chronic}개", + "allergiesOne": "알레르기 {count}개", + "allergiesFew": "알레르기 {count}개", + "allergiesOther": "알레르기 {count}개", + "medicationsOne": "약 {count}개, 시작일 없음 {missingStart}개", + "medicationsFew": "약 {count}개, 시작일 없음 {missingStart}개", + "medicationsOther": "약 {count}개, 시작일 없음 {missingStart}개", + "medicationsDatedOne": "시작일 있는 약 {count}개", + "medicationsDatedFew": "시작일 있는 약 {count}개", + "medicationsDatedOther": "시작일 있는 약 {count}개", + "vaccinationsOne": "예방접종 {count}건", + "vaccinationsFew": "예방접종 {count}건", + "vaccinationsOther": "예방접종 {count}건", + "visitsOne": "진료 {count}건, 시술 {procedures}건", + "visitsFew": "진료 {count}건, 시술 {procedures}건", + "visitsOther": "진료 {count}건, 시술 {procedures}건", + "visitsPlannedOne": "예정된 진료만 {count}건", + "visitsPlannedFew": "예정된 진료만 {count}건", + "visitsPlannedOther": "예정된 진료만 {count}건", + "labsOne": "결과가 있는 날 {count}일", + "labsFew": "결과가 있는 날 {count}일", + "labsOther": "결과가 있는 날 {count}일", + "documentsOne": "날짜 있는 문서 {count}개", + "documentsFew": "날짜 있는 문서 {count}개", + "documentsOther": "날짜 있는 문서 {count}개", + "lifeOne": "삶의 사건 {count}개", + "lifeFew": "삶의 사건 {count}개", + "lifeOther": "삶의 사건 {count}개", + "cycleOne": "주기 {count}개", + "cycleFew": "주기 {count}개", + "cycleOther": "주기 {count}개", + "moodOne": "{since}부터 기록 {count}개", + "moodFew": "{since}부터 기록 {count}개", + "moodOther": "{since}부터 기록 {count}개", + "environmentOne": "환경 데이터가 있는 날 {count}일", + "environmentFew": "환경 데이터가 있는 날 {count}일", + "environmentOther": "환경 데이터가 있는 날 {count}일", + "empty": "아직 없음" + }, + "gap": { + "valuesEmpty": "소스 연결", + "illnessEmpty": "에피소드 추가", + "medicationsWithoutStart": "채우기", + "visitsEmpty": "진료 추가", + "documentsUndated": "확인하기", + "labsEmpty": "결과 추가", + "vaccinationsEmpty": "예방접종 추가", + "lifeEventsEmpty": "추가" + }, + "gapLine": { + "medicationsWithoutStartOne": "시작일 없는 약 {count}개", + "medicationsWithoutStartFew": "시작일 없는 약 {count}개", + "medicationsWithoutStartOther": "시작일 없는 약 {count}개", + "documentsUndatedOne": "날짜 없는 문서 {count}개", + "documentsUndatedFew": "날짜 없는 문서 {count}개", + "documentsUndatedOther": "날짜 없는 문서 {count}개", + "valuesEmpty": "아직 오랜 기간의 수치가 없어요", + "illnessEmpty": "아직 시작일이 있는 질환이 없어요", + "visitsEmpty": "아직 완료된 진료가 없어요", + "labsEmpty": "아직 검사 결과가 없어요", + "vaccinationsEmpty": "아직 예방접종이 없어요", + "lifeEventsEmpty": "아직 삶의 사건이 없어요" + } + }, + "legendSpan": "기간", + "legendEvent": "사건", + "legendMeanQuarter": "분기 평균", + "legendMeanMonth": "월평균", + "legendMeanWeek": "주간 평균", + "legendThin": "측정 3회 미만", + "legendGap": "측정 없는 짧은 공백", + "today": "오늘", + "noDataYet": "아직 데이터 없음", + "layers": "레이어", + "layersLanes": "줄", + "zoomLabel": "보기 범위", + "chartLabel": "{from}부터 {to}까지의 타임라인", + "keyboardHint": "화살표 키로 선택을 옮기고, Home과 End로 양 끝으로 이동하며, Enter로 그날을 열어요.", + "loadFailed": "타임라인을 불러오지 못했어요.", + "empty": { + "title": "아직 타임라인에 아무것도 없어요", + "description": "날짜가 있는 기록이 생기면 여기에 나타나요." + }, + "values": { + "menuTitle": "레인 아래 수치", + "trigger": "수치 ({count})", + "noneInWindow": "이 기간에 수치 없음", + "series": { + "BLOOD_PRESSURE_SYS": "수축기 혈압", + "BLOOD_PRESSURE_DIA": "이완기 혈압" + }, + "noValue": "값 없음", + "bloodPressure": "혈압", + "readingsOne": "측정 1회", + "readingsFew": "측정 {count}회 평균", + "readingsOther": "측정 {count}회 평균" + }, + "table": { + "caption": "타임라인 기록", + "lane": "줄", + "entry": "기록", + "start": "시작", + "end": "끝", + "seriesCaption": "기간별 평균", + "period": "기간" + }, + "selection": { + "empty": "이 기간에는 항목이 없습니다", + "since": "{date}부터", + "range": "{from}~{to}", + "mean": "Ø {values}", + "week": "{date} 주", + "hintClick": "시점을 클릭하면 여기에 표시됩니다", + "hintTap": "시점을 탭하면 여기에 표시됩니다" + }, + "chronicle": { + "months": "월", + "years": "연도", + "groupingLabel": "묶기", + "empty": "{from}~{to} 기록 없음", + "emptySingle": "{period} 기록 없음", + "ended": "{label} 끝남", + "afterDaysOne": "{count}일 후", + "afterDaysFew": "{count}일 후", + "afterDaysOther": "{count}일 후", + "notable": { + "extremeHigh": "오랜만의 최고값", + "extremeLow": "오랜만의 최저값", + "firstValue": "측정 항목의 첫 수치", + "gap": "수치에 긴 공백" + }, + "notableMeta": "자세한 내용은 그날을 열어 보세요" + }, + "standingAllergiesOne": "알레르기 {count}개", + "standingAllergiesFew": "알레르기 {count}개", + "standingAllergiesOther": "알레르기 {count}개", + "legendMeanDay": "일 평균", + "range": { + "from": "시작", + "to": "종료", + "label": "선택한 기간", + "open": "기간 선택", + "clear": "전체 보기", + "apply": "적용" + }, + "item": { + "labValuesOne": "검사 수치 {count}개", + "labValuesFew": "검사 수치 {count}개", + "labValuesOther": "검사 수치 {count}개" + } + }, + "lifeEvents": { + "title": "삶의 사건", + "add": "삶의 사건", + "privacy": "제목과 메모는 암호화되어 저장돼요. 어떤 AI에도 보내지 않아요.", + "emptyHint": "이사, 출산, 새 직장 같은 일이 몇 년에 기준점을 만들어 줘요.", + "save": "저장", + "cancel": "취소", + "delete": "삶의 사건 삭제", + "saved": "삶의 사건을 저장했어요", + "deleted": "삶의 사건을 삭제했어요", + "fields": { + "title": "무슨 일이 있었나요?", + "category": "종류", + "when": "언제", + "precision": "정확도", + "addEnd": "기간이었다면 종료일", + "endDate": "종료", + "note": "메모", + "optionalTag": "선택", + "notePlaceholder": "나중에 알고 싶은 내용", + "removeEnd": "종료일 삭제" + }, + "category": { + "FAMILY": "가족", + "HOME": "주거", + "WORK": "일", + "LOSS": "상실", + "OTHER": "기타" + }, + "precision": { + "DAY": "일", + "MONTH": "월", + "YEAR": "연" + }, + "edit": "삶의 사건 편집", + "errors": { + "titleRequired": "사건의 이름을 적어 주세요.", + "titleTooLong": "최대 120자까지예요.", + "categoryRequired": "종류를 골라 주세요.", + "dateRequired": "올바른 날짜를 입력해 주세요.", + "endBeforeStart": "끝이 시작보다 앞서요.", + "noteTooLong": "최대 2000자까지예요." + }, + "saveFailed": "저장하지 못했어요.", + "deleteFailed": "삭제하지 못했어요.", + "deleteConfirm": { + "title": "이 삶의 사건을 삭제할까요?", + "body": "제목과 메모가 사라져요." + } + }, + "palette": { + "open": "검색…", + "openLabel": "페이지, 설정, 작업 검색", + "title": "검색", + "description": "페이지, 설정, 모듈 또는 작업을 찾습니다.", + "placeholder": "페이지, 설정, 모듈 검색…", + "results": "결과", + "noResults": "“{query}”에 대한 결과가 없습니다.", + "moduleOn": "켜짐", + "moduleOff": "꺼짐", + "hintMove": "이동", + "hintOpen": "열기", + "hintClose": "닫기", + "groups": { + "recent": "최근", + "pages": "페이지", + "insights": "인사이트", + "settings": "설정", + "modules": "모듈", + "actions": "작업" + }, + "actions": { + "capture": "새 항목", + "coach": "코치에게 묻기", + "backup": "백업 만들기", + "today": "오늘 열기", + "shortcuts": "키보드 단축키 보기" + } + }, + "shortcuts": { + "title": "키보드 단축키", + "description": "입력란에 입력하는 중이 아니면 앱 어디에서나 작동하는 키입니다.", + "menuEntry": "키보드 단축키", + "then": "다음", + "search": "검색", + "help": "이 목록 보기", + "close": "대화 상자나 패널 닫기", + "previousDay": "이전 날", + "nextDay": "다음 날", + "dayHint": "하루가 열려 있을 때.", + "groups": { + "navigation": "탐색", + "actions": "작업", + "day": "하루" + } + }, + "aboutPage": { + "eyebrow": "정보", + "title": "HealthLog 정보", + "lastUpdated": "마지막 업데이트: {date}", + "project": "프로젝트", + "credits": "크레딧" } } diff --git a/messages/pl.json b/messages/pl.json index 58c0b8d61..5ae6159a1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -49,7 +49,9 @@ "dismiss": "Odrzuć", "moreInfo": "Więcej informacji", "durationHoursMinutes": "{hours} godz. {minutes} min", - "durationMinutes": "{minutes} min" + "durationMinutes": "{minutes} min", + "durationClockMinutes": "{time} min", + "durationClockHours": "{time} godz." }, "apiErrors": { "measurement": { @@ -165,7 +167,7 @@ "anamnesisShiftSchedule": "Harmonogram pracy", "anamnesisColFact": "Pozycja profilu", "anamnesisColValue": "Zapisana wartość", - "subtitle": "HealthLog — Osobisty raport zdrowotny", + "subtitle": "HealthLog: osobisty raport zdrowotny", "patient": "Pacjent", "dateOfBirth": "Data urodzenia", "gender": "Płeć", @@ -175,18 +177,18 @@ "height": "Wzrost", "period": "Okres raportu", "createdOn": "Utworzono", - "vitalsTitle": "Parametry życiowe — Podsumowanie", + "vitalsTitle": "Parametry życiowe: podsumowanie", "colParameter": "Parametr", "colCurrent": "Aktualny", "colAvgPeriod": "Śr. okresu", "colMin": "Min", "colMax": "Maks", "colN": "n", - "bpClassificationTitle": "Ciśnienie tętnicze — klasyfikacja ESH (2023)", + "bpClassificationTitle": "Ciśnienie tętnicze: klasyfikacja ESH (2023)", "avgBp": "Średnie ciśnienie tętnicze", "classification": "Klasyfikacja", "bmiTitle": "Wskaźnik masy ciała (BMI)", - "bmiRow": "BMI:{bmi} kg/m² — Classification: {class} (WHO)", + "bmiRow": "BMI:{bmi} kg/m², Classification: {class} (WHO)", "complianceTitle": "Przestrzeganie leków", "glp1Title": "Terapia GLP-1", "glp1WeightSummary": "Waga w okresie raportu: {start} kg → {end} kg ({delta} kg)", @@ -204,7 +206,7 @@ "colExpected": "Oczekiwane", "colComplianceRate": "Wskaźnik przestrzegania", "moodTitle": "Przebieg nastroju", - "moodSummary": "Średnia:{avg} / 5.0 · Entries: {count} · Range: {min}–{max}", + "moodSummary": "Średnia {avg} / 5.0, liczba wpisów {count}, zakres {min}–{max}", "colMood": "Nastrój", "colCount": "Liczba", "colShare": "Udział", @@ -234,8 +236,8 @@ "typeGlucoseRandom": "Glukoza (dowolna)", "typeGlucoseBedtime": "Glukoza (przed snem)", "typeGlucoseUnspecified": "Glukoza (bez oznaczenia)", - "glucoseClassificationTitle": "Glikemia — zakresy referencyjne (ADA 2024 / DDG)", - "glucoseRow": "{label}: śr. {avg} {unit} (cel {rangeMin}–{rangeMax}) — {class}", + "glucoseClassificationTitle": "Glikemia: zakresy referencyjne (ADA 2024 / DDG)", + "glucoseRow": "{label}: śr. {avg} {unit} (cel {rangeMin}–{rangeMax}), {class}", "glucoseInTarget": "w celu", "glucoseBelowTarget": "poniżej celu", "glucoseAboveTarget": "powyżej celu", @@ -287,7 +289,7 @@ "gmi": "Wskaźnik zarządzania glukozą (GMI)", "eA1c": "Szacowane HbA1c", "cv": "Zmienność (CV%)", - "spotCaveat": "To szacunki z pojedynczych pomiarów, nie profil z monitora ciągłego — czytaj je jako kierunek zmian, a nie kliniczny werdykt." + "spotCaveat": "To szacunki z pojedynczych pomiarów, nie profil z monitora ciągłego. Czytaj je jako kierunek zmian, a nie kliniczny werdykt." }, "illnessTitle": "Schorzenia i choroby", "illnessOngoing": "Trwająca", @@ -376,12 +378,12 @@ "backToDashboard": "Powrót do panelu" }, "offlineBanner": { - "message": "Brak połączenia — zmian nie można zapisać, dopóki nie wrócisz online.", - "saveFailed": "Nie udało się zapisać — jesteś offline. Spróbuj ponownie, gdy wrócisz online.", + "message": "Brak połączenia: zmian nie można zapisać, dopóki nie wrócisz online.", + "saveFailed": "Nie udało się zapisać, jesteś offline. Spróbuj ponownie, gdy wrócisz online.", "showingCached": "Wyświetlane są ostatnio zsynchronizowane dane." }, "demoBanner": { - "message": "Tryb demo — zmiany nie są zapisywane." + "message": "Tryb demo: zmiany nie są zapisywane." }, "nav": { "dashboard": "Panel", @@ -427,7 +429,9 @@ "workout": "Trening", "workoutDescription": "Sesja, której nie zapisały twoje urządzenia", "symptom": "Objaw", - "symptomDescription": "Jeden z twoich objawów, z nasileniem" + "symptomDescription": "Jeden z twoich objawów, z nasileniem", + "lifeEventDescription": "Przeprowadzka, narodziny, nowa praca", + "lifeEvent": "Wydarzenie życiowe" }, "cycle": "Cykl", "labs": "Wyniki badań", @@ -436,7 +440,8 @@ "illness": "Choroba", "documents": "Dokumenty", "vaccinations": "Szczepienia", - "mentalWellbeing": "Dobrostan psychiczny" + "mentalWellbeing": "Dobrostan psychiczny", + "timeline": "Oś czasu" }, "auth": { "login": "Zaloguj się", @@ -453,14 +458,13 @@ "hasAccount": "Masz już konto?", "loginFailed": "Logowanie nie powiodło się", "registerFailed": "Rejestracja nie powiodła się", - "invitedBanner": "Otrzymano zaproszenie — utwórz konto, nawet jeśli rejestracja jest zamknięta.", + "invitedBanner": "Otrzymano zaproszenie. Utwórz konto, nawet jeśli rejestracja jest zamknięta.", "alreadySignedIn": { "title": "Masz już zalogowaną sesję", "body": "Masz zalogowaną sesję jako {username}. Zaproszenie tworzy nowe konto. Wyloguj się, aby je przyjąć.", "signOut": "Wyloguj się i przyjmij zaproszenie" }, "createAccount": "Utwórz konto", - "newAccount": "Utwórz nowe konto", "backToPasskey": "Wróć do passkey", "passkeySetupLater": "Passkey możesz skonfigurować później w ustawieniach.", "passwordMinLength": "Min. 12 znaków", @@ -513,6 +517,37 @@ "fixLan": "Serwer w sieci domowej bez TLS: ustaw SESSION_COOKIE_SECURE=false w jego ustawieniach i uruchom go ponownie.", "fixHttps": "Serwer za proxy HTTPS: otwórz zamiast tego adres https:// i upewnij się, że proxy przekazuje X-Forwarded-Proto: https.", "localhostNote": "Ta strona działa na http://localhost, a serwer ustawia ciasteczko logowania tylko dla https://. Większość przeglądarek akceptuje to na localhost; jeśli po zalogowaniu wracasz tutaj, ustaw SESSION_COOKIE_SECURE=false." + }, + "claim": { + "title": "Przejmij swoją kartę zdrowia", + "titleNamed": "Witaj, {name}", + "description": "Do tej pory ktoś prowadził Twoją kartę zdrowia za Ciebie. Wybierz sposób logowania, a stanie się ona Twoim własnym kontem. Nic nie jest kopiowane ani przenoszone.", + "loading": "Sprawdzanie linku…", + "invalid": "Ten link już nie działa", + "invalidHint": "Jest nieznany, wygasł, został wycofany albo już użyty. Poproś osobę, która go wysłała, o nowy.", + "oidcOnly": "Ten serwer pozwala tylko na logowanie jednokrotne (SSO), więc profilu nie można tu jeszcze przejąć.", + "signedInTitle": "Najpierw się wyloguj", + "signedInBody": "Masz zalogowaną sesję jako {username}. Ten link tworzy logowanie dla kogoś innego, więc wyloguj się, zanim go użyjesz.", + "signOut": "Wyloguj i kontynuuj", + "guardiansTitle": "Kto na razie zachowuje dostęp", + "guardiansHint": "Zaproponowane przez osoby, które prowadziły Twoją kartę. Zdecydujesz zaraz po zalogowaniu.", + "guardianUnnamed": "Opiekun", + "usernameHint": "Od 3 do 30 liter, cyfr, _ lub -.", + "usernameReserved": "Nazwy użytkownika zaczynające się od managed- są zarezerwowane.", + "emailHint": "Wymagany, żebyś mógł wrócić, jeśli zapomnisz hasła.", + "consentNote": "Zastrzeżenie i każde użycie AI potwierdzasz sam. Do tego czasu nic z Twojej karty nie trafia do dostawcy AI.", + "expires": "Ten link jest ważny do {date}.", + "submit": "Przejmij", + "submitting": "Przejmowanie…", + "errorTaken": "Ta nazwa użytkownika lub adres e-mail jest już zajęty.", + "errorRateLimit": "Zbyt wiele prób. Odczekaj kilka minut i spróbuj ponownie.", + "errorFailed": "Nie udało się. Spróbuj ponownie za chwilę.", + "errorOffline": "Nie udało się połączyć z serwerem. Sprawdź połączenie i spróbuj ponownie." + }, + "inviteInvalid": { + "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" } }, "dashboard": { @@ -544,17 +579,17 @@ "sleepShort": "Sen", "stepsShort": "Kroki", "vo2MaxShort": "VO₂ max", - "bpInTargetWindow90": "RR-cel · 90 d", - "bpInTargetWindowDynamic": "RR-cel · {days} d", + "bpInTargetWindow90": "RR-cel (90 d)", + "bpInTargetWindowDynamic": "RR-cel ({days} d)", "bpInTargetCollecting": "zbieranie danych", "medications": "Leki", "quickAddMeasurement": "Zarejestruj pomiar", "quickAddMood": "Zarejestruj nastrój", "quickAddMedicationIntake": "Zarejestruj przyjęcie", "quickAddWorkout": "Zarejestruj trening", + "quickAddLifeEvent": "Zapisz wydarzenie życiowe", "customizeDashboard": "Dostosuj pulpit", "medicationIntakeQuickAdd": { - "sheetTitle": "Zarejestruj przyjęcie leku", "sheetDescription": "Zapisz dawkę, godzinę i lek w kilku stuknięciach.", "medicationLabel": "Lek", "medicationPlaceholder": "Wybierz lek", @@ -667,7 +702,8 @@ "body": "Bez kopii klucza Twoich danych nie da się odzyskać po ponownej instalacji. To zajmie minutę.", "action": "Otwórz krok" }, - "quickAddSymptom": "Zapisz objaw" + "quickAddSymptom": "Zapisz objaw", + "todayCardVisible": "Pokaż górną kartę" }, "measurements": { "title": "Pomiary", @@ -739,7 +775,7 @@ "formReset": "Resetuj formularz", "saveError": "Błąd zapisu", "duplicateTimestamp": "Pomiar tego typu już istnieje dla wybranej daty.", - "serverOwnedSource": "Wartości z połączonego źródła nie można edytować — znacznik czasu i notatka pozostają edytowalne.", + "serverOwnedSource": "Wartości z połączonego źródła nie można edytować. Znacznik czasu i notatka pozostają edytowalne.", "deleteError": "Błąd usuwania", "deletedToast": "Pomiar usunięty", "restoredToast": "Pomiar przywrócony", @@ -834,11 +870,12 @@ "durationHoursOnly": "{hours} godz.", "durationMinutesOnly": "{minutes} min", "durationInvalid": "Wpisz czas trwania, np. 7,5, 7:30 lub 7h 30m.", - "durationTooLong": "Jeden wpis nie może przekraczać 24 godzin." + "durationTooLong": "Jeden wpis nie może przekraczać 24 godzin.", + "sourceHealthConnect": "Health Connect" }, "metricCatalog": { "title": "Co HealthLog potrafi śledzić", - "description": "Wszystkie obszary metryk obsługiwane przez HealthLog — również te, dla których nie masz jeszcze danych — oraz co je dostarcza.", + "description": "Wszystkie obszary metryk obsługiwane przez HealthLog (również te, dla których nie masz jeszcze danych) oraz co je dostarcza.", "statusPresent": "Śledzone", "statusAbsent": "Jeszcze nieśledzone", "statusModuleOff": "Wyłączone", @@ -846,11 +883,11 @@ "connectCta": "Połącz źródło", "viewCta": "Zobacz", "ecg": { - "source": "Jednoodprowadzeniowe zapisy EKG z kompatybilnego zegarka — obecnie Withings ScanWatch — synchronizowane automatycznie." + "source": "Jednoodprowadzeniowe zapisy EKG z kompatybilnego zegarka (obecnie Withings ScanWatch), synchronizowane automatycznie." }, "watchdog": { "title": "Automatyczny nadzór", - "description": "HealthLog dyskretnie obserwuje też dane w tle — powiadomienia o nieregularnym rytmie i zaburzeniach oddychania z podłączonego zegarka, alerty o odchyleniu od Twojej wartości bazowej oraz zauważalne zmiany między dwoma ostatnimi panelami badań laboratoryjnych — i pokazuje kartę tylko wtedy, gdy coś zwraca uwagę. Jeśli podłączone jest co najmniej jedno z tych źródeł, brak karty to dobry znak, a nie brakująca funkcja; jeśli żadne nie jest jeszcze podłączone, po prostu nic nie jest jeszcze monitorowane." + "description": "HealthLog dyskretnie obserwuje też dane w tle (powiadomienia o nieregularnym rytmie i zaburzeniach oddychania z podłączonego zegarka, alerty o odchyleniu od Twojej wartości bazowej oraz zauważalne zmiany między dwoma ostatnimi panelami badań laboratoryjnych) i pokazuje kartę tylko wtedy, gdy coś zwraca uwagę. Jeśli podłączone jest co najmniej jedno z tych źródeł, brak karty to dobry znak, a nie brakująca funkcja; jeśli żadne nie jest jeszcze podłączone, po prostu nic nie jest jeszcze monitorowane." } }, "mood": { @@ -1007,7 +1044,7 @@ "archive": "Archiwizuj", "restore": "Przywróć", "archivedTitle": "Zarchiwizowane tagi", - "archivedDescription": "Zarchiwizowane tagi pozostają w Twoich wcześniejszych wpisach — znikają tylko z wyboru.", + "archivedDescription": "Zarchiwizowane tagi pozostają w Twoich wcześniejszych wpisach, znikają tylko z wyboru.", "archivedEmpty": "Nic nie zarchiwizowano.", "purgeTitle": "Usunąć tag na zawsze?", "purgeBody": "Tag zostanie usunięty z {count} wcześniejszych wpisów. Tej operacji nie można cofnąć.", @@ -1032,7 +1069,7 @@ }, "sectionMoreTags": "Więcej tagów", "sectionNoteDetails": "Notatka i szczegóły", - "quickTagsHelp": "Twoje częste tagi — jedno dotknięcie", + "quickTagsHelp": "Twoje częste tagi: jedno dotknięcie", "quickTagsExpand": "Więcej", "loadError": "Nie udało się załadować wpisów nastroju.", "sectionLevelA": "Samopoczucie", @@ -1210,8 +1247,8 @@ "dialogDescription": "Te leki są teraz do przyjęcia. Potwierdź, aby zarejestrować je wszystkie jako przyjęte.", "confirm": "Przyjmij wszystkie ({count})", "successToast": "Zarejestrowano przyjęcia: {count}", - "partialToast": "Zarejestrowano: {taken}, nieudane: {failed} – spróbuj ponownie", - "failedToast": "Nie zarejestrowano żadnego przyjęcia – spróbuj ponownie" + "partialToast": "Zarejestrowano: {taken}, nieudane: {failed}. Spróbuj ponownie", + "failedToast": "Nie zarejestrowano żadnego przyjęcia. Spróbuj ponownie" }, "logIntake": { "dialogTitle": "Zarejestruj przyjęcie", @@ -1300,7 +1337,7 @@ "complianceInfoLabel": "Co oznacza przestrzeganie zaleceń?", "lastIntake": "Ostatnie przyjęcie:", "nextIntake": "Następne przyjęcie:", - "nextIntakeOverdue": "Zaległa · {time} — wciąż można przyjąć", + "nextIntakeOverdue": "Zaległa od {time}, wciąż można przyjąć", "dayStreak": "dni serii", "dayStreakOne": "dzień serii", "cycleDueToday": "Termin dzisiaj", @@ -1309,10 +1346,10 @@ "skipped": "Pominięte", "intakeToastTaken": "{name} oznaczone jako przyjęte", "intakeToastSkipped": "{name} pominięte", - "intakeToastFailed": "Nie udało się zapisać {name} — spróbuj ponownie", + "intakeToastFailed": "Nie udało się zapisać {name}. Spróbuj ponownie", "intakeUndo": "Cofnij", "intakeUndone": "Wpis cofnięty", - "intakeUndoFailed": "Nie udało się cofnąć — popraw w historii", + "intakeUndoFailed": "Nie udało się cofnąć. Popraw w historii", "glp1LastInjection": "{label}", "glp1NextInjectionDays": "{label} (za {days} dni)", "glp1NextInjectionToday": "dziś", @@ -1394,10 +1431,10 @@ "error": { "empty": "Najpierw wpisz opis.", "tooLong": "Utrzymuj opis poniżej 2000 znaków.", - "rateLimit": "Zbyt wiele żądań — poczekaj chwilę i spróbuj ponownie.", - "noProvider": "Brak skonfigurowanego dostawcy AI — skonfiguruj go w Ustawieniach.", - "budget": "Osiągnięto dzienny limit AI — spróbuj jutro.", - "network": "Nie udało się połączyć z dostawcą AI — spróbuj ponownie.", + "rateLimit": "Zbyt wiele żądań. Poczekaj chwilę i spróbuj ponownie.", + "noProvider": "Brak skonfigurowanego dostawcy AI. Skonfiguruj go w Ustawieniach.", + "budget": "Osiągnięto dzienny limit AI. Spróbuj jutro.", + "network": "Nie udało się połączyć z dostawcą AI. Spróbuj ponownie.", "consent": "Odczyt Twojego opisu przez AI wymaga najpierw Twojej zgody.", "unavailable": "Odczyt opisów przez AI jest tu teraz niedostępny." } @@ -1413,7 +1450,7 @@ "everyNMonths": "Co N miesięcy w określonym dniu", "yearly": "Raz w roku", "rolling": "Co N dni od ostatniego przyjęcia (elastycznie)", - "rollingExplainer": "Liczy od ostatniego zarejestrowanego przyjęcia — wstrzymuje się, jeśli pominiesz dawkę.", + "rollingExplainer": "Liczy od ostatniego zarejestrowanego przyjęcia i wstrzymuje się, jeśli pominiesz dawkę.", "oneShot": "Dawka jednorazowa" }, "weekdays": { @@ -1471,7 +1508,7 @@ "night": "Noc" }, "max": { - "reached": "Osiągnięto maksimum — usuń jedną godzinę przed dodaniem kolejnej." + "reached": "Osiągnięto maksimum. Usuń jedną godzinę przed dodaniem kolejnej." } }, "courseWindow": { @@ -1521,7 +1558,7 @@ }, "step3": { "title": "Jaka jest dawka?", - "subline": "Tabletka, wziew, kropla — w zależności od formy.", + "subline": "Tabletka, wziew, kropla: w zależności od formy.", "amountLabel": "Ilość", "amountPlaceholder": "np. 5", "unitLabel": "Jednostka", @@ -1547,7 +1584,7 @@ }, "dosesPerUnitLabel": "Dawki na jednostkę", "dosesPerUnitPlaceholder": "np. 4", - "dosesPerUnitHint": "Dawki na wstrzykiwacz / fiolkę — zostaw puste, aby nie śledzić zapasów.", + "dosesPerUnitHint": "Dawki na wstrzykiwacz / fiolkę. Zostaw puste, aby nie śledzić zapasów.", "short": "Dawka", "unitsPerDoseLabel": "Jednostki na dawkę", "unitsPerDoseHint": "np. 2 tabletki po 2 mg na dawkę 4 mg lub ½ tabletki przy dzielonej dawce" @@ -1559,7 +1596,7 @@ }, "step5": { "title": "Jak często go przyjmujesz?", - "subline": "Wybierz częstotliwość — szczegóły w następnym kroku.", + "subline": "Wybierz częstotliwość, szczegóły w następnym kroku.", "short": "Częstotliwość" }, "step6": { @@ -1581,7 +1618,7 @@ }, "step7": { "title": "Kiedy go przyjmujesz?", - "subline": "Jedna lub więcej pór dnia — przypomnienia opierają się na tej liście.", + "subline": "Jedna lub więcej pór dnia. Przypomnienia opierają się na tej liście.", "presetsLabel": "Ustawienia wstępne pory dnia", "presets": { "morning": "Rano", @@ -1589,7 +1626,7 @@ "evening": "Wieczór", "night": "Noc" }, - "overrideHint": "Opcjonalnie — ustaw inną dawkę lub liczbę jednostek tylko dla tej pory. Pozostaw puste, aby użyć wartości domyślnej leku.", + "overrideHint": "Opcjonalnie: ustaw inną dawkę lub liczbę jednostek tylko dla tej pory. Pozostaw puste, aby użyć wartości domyślnej leku.", "doseOverrideLabel": "Dawka dla tej pory (opcjonalnie)", "doseOverridePlaceholder": "np. pół tabletki", "unitsOverrideLabel": "Jednostki zużyte o tej porze", @@ -1597,11 +1634,11 @@ "short": "Godziny" }, "step8": { - "title": "Gotowe — czy HealthLog ma Ci przypominać?", + "title": "Gotowe. Czy HealthLog ma Ci przypominać?", "subline": "Sprawdź wszystko poniżej przed zapisaniem.", "remindersLabel": "Przypomnienia włączone", - "remindersDescription": "Push, Telegram lub ntfy — w zależności od ustawień.", - "noChannelHint": "Przypomnienia wymagają kanału powiadomień — obecnie żaden nie jest aktywny.", + "remindersDescription": "Push, Telegram lub ntfy, w zależności od ustawień.", + "noChannelHint": "Przypomnienia wymagają kanału powiadomień, a obecnie żaden nie jest aktywny.", "noChannelHintLink": "Skonfiguruj powiadomienia", "short": "Gotowe" } @@ -1650,7 +1687,7 @@ }, "asNeeded": { "label": "W razie potrzeby", - "description": "Bez stałego planu — zapisz przyjęcie, gdy go potrzebujesz (np. lek przeciwbólowy)." + "description": "Bez stałego planu: zapisz przyjęcie, gdy go potrzebujesz (np. lek przeciwbólowy)." } }, "summary": { @@ -1685,7 +1722,7 @@ } }, "errors": { - "submitFailed": "Nie udało się zapisać leku — spróbuj ponownie." + "submitFailed": "Nie udało się zapisać leku. Spróbuj ponownie." } }, "doseStrength": { @@ -1701,11 +1738,11 @@ "empty": "Nie zarejestrowano jeszcze kroków zwiększania dawki. Dodaj datowane zmiany dawki, aby nakreślić plan.", "youAreHere": "Jesteś tutaj", "doseStep": "{dose} {unit}", - "plannedFor": "Zaplanowano · {date}" + "plannedFor": "Zaplanowano na {date}" }, "injectionSiteBodyOutlineAriaLabel": "Sylwetka ciała", "injectionSitePickerAriaLabel": "Wybór miejsca iniekcji", - "injectionSiteRecommendedAriaLabel": "{site} — zalecane następne miejsce", + "injectionSiteRecommendedAriaLabel": "{site}, zalecane następne miejsce", "injectionSiteLegendAriaLabel": "Legenda oznaczeń", "injectionSiteLegendRecommended": "Zalecane następne miejsce", "injectionSiteLegendLastUsed": "Ostatnio użyte miejsce", @@ -1752,7 +1789,7 @@ "intakeHistoryStatusSkipped": "Pominięty", "intakeHistorySortByTaken": "Sortuj wg czasu przyjęcia", "intakeHistorySortByScheduled": "Sortuj wg czasu zaplanowanego", - "intakeHistoryPageInfo": "Strona {page} z {total} · {count} wpisów", + "intakeHistoryPageInfo": "Strona {page} z {total}, {count} wpisów", "intakeHistoryPrev": "Poprzednia", "intakeHistoryNext": "Następna", "glp1": { @@ -1793,7 +1830,7 @@ "cadence": { "oneShotOn": "Dawka jednorazowa w dniu {date}.", "oneShotPending": "Dawka jednorazowa oczekuje.", - "asNeeded": "W razie potrzeby — bez stałego planu." + "asNeeded": "W razie potrzeby, bez stałego planu." }, "intake": { "title": "Historia przyjęć", @@ -1820,7 +1857,7 @@ "dialogTitle": "Edytuj przyjęcie", "takenAtLabel": "Przyjęto o", "scheduledForHint": "Zaplanowano: {dateTime}", - "farFromScheduledWarning": "Znacznie odbiega od zaplanowanego terminu — sprawdź datę.", + "farFromScheduledWarning": "Znacznie odbiega od zaplanowanego terminu. Sprawdź datę.", "skippedLabel": "Pominięto", "save": "Zapisz", "cancel": "Anuluj", @@ -1852,7 +1889,7 @@ "markAsTaken": "Oznacz jako przyjętą", "markAsSkipped": "Oznacz jako pominiętą", "adHocTag": "poza planem", - "takenDetail": "planowo {planned} · przyjęto {taken}", + "takenDetail": "planowo {planned}, przyjęto {taken}", "doseTaken": "Dawka: {dose}", "status": { "taken_on_time": "na czas", @@ -1864,10 +1901,10 @@ }, "addDialog": { "title": "Dodaj przyjęcie", - "description": "Zarejestruj dawkę — także wstecznie lub poza planem.", + "description": "Zarejestruj dawkę, także wstecznie lub poza planem.", "takenAtLabel": "Przyjęto o", "skippedLabel": "Pominięte", - "nudgeHint": "Blisko slotu {slot} — przy zapisie zapytamy, czy przypisać.", + "nudgeHint": "Blisko slotu {slot}. Przy zapisie zapytamy, czy przypisać.", "cancel": "Anuluj", "submit": "Zapisz" }, @@ -1916,7 +1953,7 @@ }, "codes": { "label": "Kody kliniczne (ATC / RxNorm)", - "note": "Opcjonalne. Używane w eksporcie FHIR dokumentacji zdrowotnej. Kody są zapisywane dokładnie tak, jak wprowadzone — nigdy nie są zgadywane.", + "note": "Opcjonalne. Używane w eksporcie FHIR dokumentacji zdrowotnej. Kody są zapisywane dokładnie tak, jak wprowadzone, i nigdy nie są zgadywane.", "atcLabel": "ATC", "rxNormLabel": "RxNorm", "saved": "Kody kliniczne zapisane", @@ -1936,7 +1973,7 @@ "title": "Zakończ lek", "helper": "Zatrzymuje przypomnienia i oznacza lek jako zakończony. Historia pozostaje widoczna.", "button": "Zakończ", - "dialogTitle": "Zakończ {name} — zatrzymuje przypomnienia, historia pozostaje widoczna", + "dialogTitle": "Zakończ {name}: zatrzymuje przypomnienia, historia pozostaje widoczna", "dialogBody": "Brak kolejnych przypomnień. Stare wpisy pozostają na osi czasu.", "toast": "Lek zakończony", "failed": "Nie udało się zakończyć leku." @@ -1974,7 +2011,7 @@ "copyFailed": "Nie udało się skopiować.", "tokenCopied": "Token skopiowany", "copyToken": "Kopiuj token", - "mintedHint": "Skopiuj ten token teraz — jest pokazywany tylko raz." + "mintedHint": "Skopiuj ten token teraz. Jest pokazywany tylko raz." }, "advanced": { "dataPortability": { @@ -2003,7 +2040,6 @@ "bestand": "Zapas", "verlauf": "Historia", "injektion": "Iniekcja", - "api": "API", "erweitert": "Zaawansowane", "wirkung": "Działanie" }, @@ -2015,9 +2051,9 @@ "statusTitle": "Status", "nextDoseLabel": "Następna dawka", "nextDoseNone": "Brak nadchodzącej dawki.", - "nextDoseAsNeeded": "W razie potrzeby — brak zaplanowanej dawki.", + "nextDoseAsNeeded": "W razie potrzeby, brak zaplanowanej dawki.", "nextDoseNotTracked": "Przyjęcia nie są rejestrowane, tylko zapis.", - "pausedHint": "Wstrzymano — brak przypomnień.", + "pausedHint": "Wstrzymano, brak przypomnień.", "reminderLabel": "Przypomnienie", "reminderOn": "Włączone", "reminderOff": "Wyłączone", @@ -2088,7 +2124,7 @@ "packagingDefaultPack": "Domyślny rozmiar opakowania: {units} jedn.", "packagingNoDefaultPack": "Brak domyślnego rozmiaru opakowania", "packagingEdit": "Edytuj", - "packagingDescription": "Określ, ile jednostek zużywa jedna dawka i jaki jest typowy rozmiar nowego opakowania — np. po zmianie producenta z innym blistrem.", + "packagingDescription": "Określ, ile jednostek zużywa jedna dawka i jaki jest typowy rozmiar nowego opakowania, np. po zmianie producenta z innym blistrem.", "packagingUnitsPerDoseLabel": "Jednostki na dawkę", "packagingUnitsPerDoseHelper": "Np. dwie tabletki 2 mg na dawkę 4 mg lub 1,5 dla półtorej (powyżej 0, do 100). Zarejestrowane dawki pozostają bez zmian.", "packagingDefaultPackLabel": "Domyślny rozmiar opakowania (jednostki)", @@ -2139,11 +2175,11 @@ "hideEras": "Ukryj wcześniejsze okresy", "addEra": "Dodaj wcześniejszy okres", "addEraTitle": "Dodaj wcześniejszy okres", - "addEraDescription": "Zapisz, o których godzinach ten lek był wcześniej przyjmowany — np. zanim zmieniono go tutaj.", + "addEraDescription": "Zapisz, o których godzinach ten lek był wcześniej przyjmowany, np. zanim zmieniono go tutaj.", "editAria": "Edytuj okres", "editEraTitle": "Edytuj okres", "editEraDescription": "Dostosuj godziny przyjmowania i zakres dat tego okresu.", - "archivedEditHint": "Ten okres został zapisany automatycznie — Twoja zmiana koryguje zarejestrowaną historię; oryginał pozostaje wewnętrznie jako zapis.", + "archivedEditHint": "Ten okres został zapisany automatycznie. Twoja zmiana koryguje zarejestrowaną historię; oryginał pozostaje wewnętrznie jako zapis.", "updatedToast": "Okres zaktualizowany", "updateError": "Nie udało się zaktualizować okresu. Nie może nakładać się na inne okresy i musi kończyć się przed aktualnym planem.", "timesLabel": "Godziny przyjmowania", @@ -2169,7 +2205,7 @@ "saved": "Zapisano harmonogram", "failed": "Nie udało się zapisać harmonogramu", "window": { - "intro": "Dla każdej dawki ustal, kiedy przyjęcie jest jeszcze punktualne — dokładna godzina (±1 h) lub własne okno, np. 07:00–09:00.", + "intro": "Dla każdej dawki ustal, kiedy przyjęcie jest jeszcze punktualne: dokładna godzina (±1 h) lub własne okno, np. 07:00–09:00.", "explicitToggle": "Własne okno", "startLabel": "Początek okna", "endLabel": "Koniec okna", @@ -2182,10 +2218,10 @@ } }, "logInjectionSiteTitle": "Zarejestruj miejsce iniekcji", - "logInjectionSiteDescription": "Gdzie wstrzyknięto {name}? Opcjonalnie — możesz to pominąć.", + "logInjectionSiteDescription": "Gdzie wstrzyknięto {name}? Opcjonalnie, możesz to pominąć.", "logInjectionSiteSkip": "Pomiń", "logInjectionSiteConfirm": "Zapisz miejsce", - "logInjectionSiteNoneAvailable": "Brak dostępnego miejsca iniekcji — wszystkie miejsca są wykluczone w Twoich ustawieniach.", + "logInjectionSiteNoneAvailable": "Brak dostępnego miejsca iniekcji: wszystkie miejsca są wykluczone w Twoich ustawieniach.", "logInjectionSiteSaveFailed": "Nie udało się zapisać miejsca iniekcji. Spróbuj ponownie.", "perSlotUnits": "{units} na dawkę", "trackInjectionSitesToggle": "Rejestruj miejsca iniekcji", @@ -2210,16 +2246,15 @@ "tableStockDoses": "Dawki: {count}", "tableStockRunway": "≈ {days} dni zapasu", "cardLowStockRunway": "Zapas: ≈ {days} dni", - "takeActionFor": "Przyjmij – {name}", - "skipActionFor": "Pomiń – {name}", - "reorderTitle": "Zmień kolejność", - "reorderDescription": "Ustal kolejność swoich leków – obowiązuje w widoku kart i w tabeli.", + "takeActionFor": "Przyjmij: {name}", + "skipActionFor": "Pomiń: {name}", + "reorderDescription": "Ustal kolejność swoich leków w widoku kart i w tabeli.", "reorderSaved": "Kolejność zapisana", "reorderSaveFailed": "Nie udało się zapisać kolejności", "customize": "Dostosuj leki", "efficacy": { "title": "Działanie", - "intro": "Jak zmieniały się pomiary, na które ma wpływać ten lek, w okolicach rozpoczęcia jego przyjmowania. Pokazuje to, co zbiega się w czasie — powiązanie, nie przyczynę — i nigdy porady dotyczącej leczenia ani dawki.", + "intro": "Jak zmieniały się pomiary, na które ma wpływać ten lek, w okolicach rozpoczęcia jego przyjmowania. Pokazuje to, co zbiega się w czasie (powiązanie, nie przyczynę), i nigdy porady dotyczącej leczenia ani dawki.", "notEligible": "Brak pomiaru, który można powiązać z tym lekiem.", "startFallback": "Nie ustawiono daty rozpoczęcia, więc porównanie używa pierwszego zapisanego pomiaru jako punktu odniesienia.", "noStart": "Ustaw datę rozpoczęcia, aby porównać pomiary sprzed i po rozpoczęciu.", @@ -2232,7 +2267,7 @@ "levelShift": "Wykryto zmianę poziomu w okolicach {date}.", "plateau": { "title": "Plateau masy ciała przy obecnej dawce", - "evidence": "Zmiana masy ciała o {delta} {unit} w ciągu ostatnich {window} dni ({readings} pomiarów) — {days} dni na dawce {dose}." + "evidence": "Zmiana masy ciała o {delta} {unit} w ciągu ostatnich {window} dni ({readings} pomiarów), {days} dni na dawce {dose}." }, "retarget": { "label": "To nie ten pomiar?", @@ -2242,16 +2277,16 @@ "error": "Nie udało się zapisać tej zmiany. Spróbuj ponownie." }, "beforeAfter": { - "summary": "Ø {before} przedtem → Ø {after} od tego czasu — zmiana o {delta}.", + "summary": "Ø {before} przedtem → Ø {after} od tego czasu, zmiana o {delta}.", "counts": "Na podstawie {beforeCount} pomiarów przedtem i {afterCount} od tego czasu.", - "needBefore": "Za mało pomiarów przed rozpoczęciem — do porównania potrzeba około {weeks} tygodni przed i po.", - "needAfter": "Za mało pomiarów od rozpoczęcia — do porównania potrzeba około {weeks} tygodni przed i po.", + "needBefore": "Za mało pomiarów przed rozpoczęciem. Do porównania potrzeba około {weeks} tygodni przed i po.", + "needAfter": "Za mało pomiarów od rozpoczęcia. Do porównania potrzeba około {weeks} tygodni przed i po.", "needStart": "Ustaw datę rozpoczęcia, aby porównać pomiary sprzed i po rozpoczęciu.", - "needData": "Za mało pomiarów — do porównania potrzeba około {weeks} tygodni przed i po rozpoczęciu." + "needData": "Za mało pomiarów. Do porównania potrzeba około {weeks} tygodni przed i po rozpoczęciu." }, "insights": { "title": "Działanie leków", - "subtitle": "Co zbiega się w czasie z pomiarami, na które celują Twoje leki — tylko powiązanie, nigdy werdykt.", + "subtitle": "Co zbiega się w czasie z pomiarami, na które celują Twoje leki: tylko powiązanie, nigdy werdykt.", "link": "Otwórz widok działania", "empty": "Za mało danych, aby powiązać Twoje leki z docelowymi pomiarami." }, @@ -2285,7 +2320,7 @@ "reasonMissingMedication": "wiersz nie nazywa żadnego leku", "reasonUnreadableDosage": "dawka nie jest liczbą", "reasonUnreadableRow": "wiersz ma mniej kolumn niż nagłówek", - "reasonStatusNoDoseInformation": "plik nie zapisuje nic o tej dawce — nikt nie odpowiedział i nic nie zostało zanotowane", + "reasonStatusNoDoseInformation": "plik nie zapisuje nic o tej dawce: nikt nie odpowiedział i nic nie zostało zanotowane", "reasonStatusReminderEvent": "wiersz dotyczy odłożonego przypomnienia, co nic nie mówi o dawce", "reasonStatusNotificationNotSent": "wiersz zapisuje, że nie wysłano powiadomienia, co dotyczy aplikacji, a nie dawki" }, @@ -2357,7 +2392,7 @@ }, "charts": { "loadingLabel": "Ładowanie wykresu…", - "loadingSlowHint": "Obliczanie analiz — to może chwilę potrwać.", + "loadingSlowHint": "Obliczanie analiz, to może chwilę potrwać.", "weight": "Waga", "bloodPressure": "Ciśnienie tętnicze", "pulse": "Tętno", @@ -2379,6 +2414,7 @@ "days7Label": "7 dni", "days7Title": "Ostatnie 7 dni", "days30Label": "30 dni", + "daysNLabel": "{days} dni", "days30Title": "Ostatnie 30 dni", "days90Label": "90 dni", "days90Title": "Ostatnie 90 dni", @@ -2443,9 +2479,9 @@ "toggle": "Punkty danych ({count})", "columnDate": "Data", "noValue": "Brak wartości", - "captionDay": "Jeden wiersz na dzień dla {metric} — dokładnie te punkty, które rysuje wykres.", - "captionWeek": "Jeden wiersz na tydzień dla {metric}, średnia z dni tego tygodnia — dokładnie te punkty, które rysuje wykres.", - "captionMonth": "Jeden wiersz na miesiąc dla {metric}, średnia z dni tego miesiąca — dokładnie te punkty, które rysuje wykres.", + "captionDay": "Jeden wiersz na dzień dla {metric}: dokładnie te punkty, które rysuje wykres.", + "captionWeek": "Jeden wiersz na tydzień dla {metric}, średnia z dni tego tygodnia: dokładnie te punkty, które rysuje wykres.", + "captionMonth": "Jeden wiersz na miesiąc dla {metric}, średnia z dni tego miesiąca: dokładnie te punkty, które rysuje wykres.", "hourlyMeanNote": "{metric} to średnia z każdego dnia, w której każda godzina liczy się raz, dzięki czemu trening nie przeważa nad resztą dnia." }, "personalBaseline": "Twoja średnia", @@ -2474,7 +2510,8 @@ "moodHeatmapEmpty": "Mapa cieplna nastroju, brak zarejestrowanych dni.", "complianceHeatmap": "Mapa cieplna przyjmowania leków od {start} do {end}, {days} aktywnych dni.", "complianceHeatmapEmpty": "Mapa cieplna przyjmowania leków, brak aktywnych dni." - } + }, + "medicationsEmptyAction": "Dodaj lek" }, "chart": { "overlay": { @@ -2505,8 +2542,8 @@ }, "tooltipPrior": "Poprzedni okres", "unavailable": { - "lastMonth": "Porównanie niedostępne — brak danych z poprzedniego miesiąca", - "lastYear": "Porównanie niedostępne — brak danych z poprzedniego roku" + "lastMonth": "Porównanie niedostępne: brak danych z poprzedniego miesiąca", + "lastYear": "Porównanie niedostępne: brak danych z poprzedniego roku" }, "notAvailableForCompliance": "Porównanie niedostępne dla przestrzegania" }, @@ -2518,7 +2555,9 @@ "dismissError": "Nie udało się odrzucić tego wzorca.", "restoreError": "Nie udało się przywrócić tego wzorca.", "loadError": "Nie udało się wczytać wzorców.", - "empty": "Żaden aktualny wzorzec nie przekroczył progu dowodów." + "empty": "Żaden aktualny wzorzec nie przekroczył progu dowodów.", + "seasonalNote": "Od wersji 1.42 HealthLog usuwa porę roku i długoterminowy trend, zanim porówna wartości. Zobaczysz mniej zależności, ale te, które zostały, są pewniejsze. Wiele wcześniejszych wzorców wynikało po prostu z kalendarza.", + "seasonalNoteDismiss": "Zamknij uwagę" }, "correlation": { "direction": { @@ -2550,29 +2589,37 @@ "ENV_DAYLIGHT": "światło dzienne", "ENV_PRECIP": "opady", "ENV_PRESSURE_MEAN": "ciśnienie atmosferyczne", - "ENV_PRESSURE_DELTA": "dobowa zmiana ciśnienia" + "ENV_PRESSURE_DELTA": "dobowa zmiana ciśnienia", + "ENV_PM25": "pył drobny (PM2,5)", + "ENV_OZONE_8H": "ozon", + "ENV_POLLEN_MAX": "pyłki" }, "daily": { - "high": "Gdy {behaviour} rośnie, {outcome} następnego dnia zwykle wykazuje tendencję do {direction} w Twoich danych — wzorzec wart obserwacji, nie przyczyna.", - "moderate": "Gdy {behaviour} rośnie, {outcome} następnego dnia wydaje się wykazywać tendencję do {direction}, na podstawie dostępnych danych — wzorzec wart obserwacji, nie przyczyna.", - "faint": "Gdy {behaviour} rośnie, w Twoich danych pojawia się co najwyżej słaba wskazówka, że {outcome} następnego dnia wykazuje tendencję do {direction} — zbyt słaba, by na niej polegać, nigdy przyczyna." + "high": "Gdy {behaviour} rośnie, {outcome} następnego dnia zwykle wykazuje tendencję do {direction} w Twoich danych. To wzorzec wart obserwacji, nie przyczyna.", + "moderate": "Gdy {behaviour} rośnie, {outcome} następnego dnia wydaje się wykazywać tendencję do {direction}, na podstawie dostępnych danych. To wzorzec wart obserwacji, nie przyczyna.", + "faint": "Gdy {behaviour} rośnie, w Twoich danych pojawia się co najwyżej słaba wskazówka, że {outcome} następnego dnia wykazuje tendencję do {direction}. To zbyt mało, by na tym polegać, i nigdy nie jest to przyczyna." }, "lab": { - "default": "Wyższe wartości {lab} idą w parze z tym, że {outcome} wykazuje tendencję do {direction} w tych samych okresach w Twoich danych — zależność, którą warto obserwować z lekarzem, nigdy przyczyna.", - "faint": "Wyższe wartości {lab} co najwyżej słabo sugerują, że {outcome} wykazuje tendencję do {direction} w tych samych okresach w Twoich danych — zbyt słabo, by na tym polegać, zależność do omówienia z lekarzem, nigdy przyczyna." + "default": "Wyższe wartości {lab} idą w parze z tym, że {outcome} wykazuje tendencję do {direction} w tych samych okresach w Twoich danych, zależność, którą warto obserwować z lekarzem, nigdy przyczyna.", + "faint": "Wyższe wartości {lab} co najwyżej słabo sugerują, że {outcome} wykazuje tendencję do {direction} w tych samych okresach w Twoich danych. To zbyt mało, by na tym polegać, i nigdy nie jest to przyczyna, ale warto omówić to z lekarzem." }, "hypothesis": { "bpCompliance": { - "support": "Lepsze przyjmowanie leków idzie w parze z niższymi wartościami skurczowymi — wzorzec wart obserwacji.", - "surprising": "Przyjmowanie leków i ciśnienie skurczowe zmieniają się razem w Twoich danych — zaskakujące; skonsultuj się z lekarzem, zanim coś zmienisz.", + "support": "Lepsze przyjmowanie leków idzie w parze z niższymi wartościami skurczowymi, wzorzec wart obserwacji.", + "surprising": "Przyjmowanie leków i ciśnienie skurczowe zmieniają się razem w Twoich danych, co jest zaskakujące. Skonsultuj się z lekarzem, zanim coś zmienisz.", "none": "Przyjmowanie leków i ciśnienie skurczowe nie zmieniają się wyraźnie razem w tym okresie." }, "moodPulse": { - "support": "Dni z gorszym nastrojem idą w parze z wyższym tętnem spoczynkowym — wzorzec wart obserwacji.", - "surprising": "Dni z lepszym nastrojem idą w parze z wyższym tętnem spoczynkowym w Twoich danych — zaskakujące.", + "support": "Dni z gorszym nastrojem idą w parze z wyższym tętnem spoczynkowym, wzorzec wart obserwacji.", + "surprising": "Dni z lepszym nastrojem idą w parze z wyższym tętnem spoczynkowym w Twoich danych, co jest zaskakujące.", "none": "Nastrój i tętno spoczynkowe nie zmieniają się wyraźnie razem w tym okresie." }, - "weightWeekday": "Waga w {weekday} jest {delta} kg {direction} Twojej średniej z pozostałych dni — wzorzec wart obserwacji." + "weightWeekday": "Waga w {weekday} jest {delta} kg {direction} Twojej średniej z pozostałych dni, wzorzec wart obserwacji." + }, + "dailySameDay": { + "high": "Więcej {behaviour} tego samego dnia i dzień wcześniej idzie w twoich danych zwykle w parze z {direction} {outcome}. To wzorzec wart obserwacji, nie przyczyna.", + "moderate": "Więcej {behaviour} tego samego dnia i dzień wcześniej wydaje się, na podstawie dotychczasowych danych, iść w parze z {direction} {outcome}. To wzorzec wart obserwacji, nie przyczyna.", + "faint": "Więcej {behaviour} tego samego dnia i dzień wcześniej pokazuje w twoich danych co najwyżej słaby ślad {direction} {outcome}. To za mało, by na tym polegać, i nigdy nie jest to przyczyna." } }, "noAnalysisYet": "Brak oceny.", @@ -2619,7 +2666,7 @@ "narrativeWeek": "Ten tydzień", "narrativeMonth": "Ten miesiąc", "narrativeUpdating": "Aktualizowanie…", - "heroFallbackSubtitle": "Codzienne spojrzenie na twoje trendy — prosto z liczb, które rejestrujesz.", + "heroFallbackSubtitle": "Codzienne spojrzenie na twoje trendy, prosto z liczb, które rejestrujesz.", "heroGreetingMorning": "Dzień dobry", "heroGreetingAfternoon": "Dzień dobry", "heroGreetingEvening": "Dobry wieczór", @@ -2684,7 +2731,7 @@ "referenceSleep": "Co najmniej {low} minut na noc; dłuższy sen nie obniża wyniku", "referenceAdiposity": "Stosunek obwodu talii do wzrostu poniżej {high}", "referenceHba1c": "HbA1c od {low} do {high}%", - "referenceDetail": "{label} · {source} · {kind} · od {low} do {high}", + "referenceDetail": "{label}: od {low} do {high} ({kind}, {source})", "openBound": "otwarty", "laboratoryReferenceSource": "Zakresy referencyjne laboratorium", "personalTargetSource": "Osobisty cel", @@ -2719,7 +2766,8 @@ "coverage": { "recommendedMet": "Pokrywa trzy obszary zalecane przez wynik", "areas": "{present} z {required} obszarów zdrowia" - } + }, + "historyLink": "Przebieg" }, "dailyBriefing": { "title": "Briefing dnia", @@ -2729,7 +2777,7 @@ "emptyDescription": "Wygeneruj dzisiejszy briefing, aby zobaczyć podsumowanie trendów.", "emptyAction": "Wygeneruj briefing", "loadingLabel": "Przygotowanie briefingu", - "refreshFailedHint": "Nie udało się odświeżyć briefingu — pokazujemy poprzedni.", + "refreshFailedHint": "Nie udało się odświeżyć briefingu, pokazujemy poprzedni.", "retryAction": "Spróbuj ponownie", "failedTitle": "Nie udało się wygenerować briefingu", "failedDescription": "Dostawca AI przekroczył limit czasu lub zwrócił błąd. Spróbuj ponownie albo zwiększ limit czasu odpowiedzi w ustawieniach AI dla wolnego modelu lokalnego.", @@ -2778,7 +2826,7 @@ "experimentCta": "Uruchom 7-dniowy eksperyment", "experimentTooltip": "Wkrótce", "emptyTitle": "Potrzeba więcej danych, aby pokazać tę korelację", - "sourceChip": "na podstawie {n} sparowanych pomiarów · {window}", + "sourceChip": "na podstawie {n} sparowanych pomiarów ({window})", "sourceWindowLast30": "Ostatnie 30 dni", "confidenceHigh": "Wysoka pewność", "confidenceModerate": "Średnia pewność", @@ -2793,14 +2841,14 @@ } }, "coach": { - "refusalOutOfScope": "Mogę pomóc tylko w sprawach danych zdrowotnych z twojego logu — pomiarów, leków, nastroju, trendów. Pytań spoza tego zakresu nie podejmuję.", - "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.", + "refusalOutOfScope": "Mogę pomóc tylko w sprawach danych zdrowotnych z twojego logu: pomiarów, leków, nastroju, trendów. Pytań spoza tego zakresu nie podejmuję.", + "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 (UTC).", "errorProvider": "Coach nie mógł teraz skontaktować się z dostawcą analizy. Spróbuj za chwilę.", "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.", + "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", - "errorNetwork": "Brak połączenia z Internetem — spróbuj ponownie, gdy wrócisz online.", + "errorNetwork": "Brak połączenia z Internetem. Spróbuj ponownie, gdy wrócisz online.", "prefsSaveError": "Nie udało się zapisać ustawień Coacha. Spróbuj ponownie.", "errorCredentialExpired": "Połączenie z dostawcą AI wygasło. Połącz je ponownie w Ustawieniach, aby Coach działał dalej.", "tagline": "Osobisty coach zdrowia, oparty na twoich danych.", @@ -2819,7 +2867,7 @@ "thinking": "Myślę…", "unverifiedFiguresNotice": "Niektórych liczb nie udało się zweryfikować z Twoimi danymi, więc je pominięto.", "composerPlaceholder": "Zapytaj mnie …", - "nudgeBubbleLabel": "Nowa wiadomość od trenera — otwórz czat", + "nudgeBubbleLabel": "Nowa wiadomość od trenera: otwórz czat", "fabLabel": "Otwórz trenera", "readAloud": "Czytaj na głos", "readAloudStop": "Zatrzymaj czytanie", @@ -2843,7 +2891,7 @@ "guided": { "offerTitle": "Coach ma {count} pytania do Ciebie", "offerTitleOne": "Coach ma pytanie do Ciebie", - "offerBody": "Krótkie odpowiedzi doprecyzują Twoje informacje o sobie — jedno pytanie po drugim, tutaj na czacie.", + "offerBody": "Krótkie odpowiedzi doprecyzują Twoje informacje o sobie: jedno pytanie po drugim, tutaj na czacie.", "offerStart": "Odpowiedz teraz", "offerLater": "Później", "offerDismiss": "Nie pytaj więcej", @@ -2853,7 +2901,7 @@ "later": "Później", "dismissRemaining": "Nie pytaj więcej", "answerPlaceholder": "Twoja odpowiedź…", - "summaryTitle": "Gotowe — dzięki za odpowiedzi!", + "summaryTitle": "Gotowe, dzięki za odpowiedzi!", "summaryAnswered": "odpowiedziano na {answered} z {total}", "summaryAdopted": "{adopted} dodano do informacji o sobie", "summaryAdoptedNone": "nie dodano nic nowego do informacji o sobie", @@ -2865,6 +2913,7 @@ "historyEmpty": "Twoje rozmowy pojawią się tutaj. Zadaj pytanie, aby zacząć.", "historyDeleteAria": "Usuń rozmowę", "historyDeleted": "Rozmowa usunięta", + "historyDeleteFailed": "Nie udało się usunąć rozmowy.", "rename": { "action": "Zmień nazwę rozmowy", "input": "Tytuł rozmowy", @@ -2934,7 +2983,7 @@ "settingsVerbosityBrief": "Krótka", "settingsVerbosityDefault": "Standardowa", "settingsVerbosityDetailed": "Szczegółowa", - "settingsVerbosityConciseHint": "Zwięzły ton i tak skraca odpowiedzi — szczegółowość pozostaje ustawiona na „Krótko”, dopóki jest aktywny.", + "settingsVerbosityConciseHint": "Zwięzły ton i tak skraca odpowiedzi, więc szczegółowość pozostaje ustawiona na „Krótko”, dopóki jest aktywny.", "settingsSourcesSummary": "Coach widzi {count} z {total} obszarów danych i patrzy {window} wstecz.", "settingsSourcesSummaryAll": "Coach widzi {count} z {total} obszarów danych i całą historię.", "settingsSourcesPointerLink": "Otwórz ustawienia coacha", @@ -2956,23 +3005,23 @@ "cluster": { "cardio": { "label": "Sercowo-naczyniowe", - "hint": "Ciśnienie · tętno · HRV · tętno spoczynkowe i marszowe" + "hint": "Ciśnienie, tętno, HRV, tętno spoczynkowe i marszowe" }, "body": { "label": "Skład ciała", - "hint": "Waga · tłuszcz · mięśnie · BMI" + "hint": "Waga, tłuszcz, mięśnie i BMI" }, "activity": { "label": "Aktywność", - "hint": "Kroki · energia · dystans · VO₂ max" + "hint": "Kroki, energia, dystans i VO₂ max" }, "workouts": { "label": "Treningi", - "hint": "Biegi · przejażdżki · sesje" + "hint": "Biegi, przejażdżki i sesje" }, "sleep": { "label": "Sen", - "hint": "Czas trwania · fazy" + "hint": "Czas trwania i fazy" }, "mood": { "label": "Nastrój", @@ -2984,15 +3033,15 @@ }, "medication": { "label": "Leki", - "hint": "Przestrzeganie · kontekst GLP-1" + "hint": "Przestrzeganie i kontekst GLP-1" }, "mobility": { "label": "Mobilność i chód", - "hint": "Stabilność · asymetria · chód" + "hint": "Stabilność, asymetria i chód" }, "environment": { "label": "Środowisko", - "hint": "Hałas · światło · temp. skóry" + "hint": "Hałas, światło dzienne i temperatura skóry" } }, "settingsClustersLabel": "Dane widziane przez Coacha", @@ -3041,25 +3090,11 @@ "steadyLatestWithin": "Twoje pomiary zwykle wynoszą {low} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był taki sam.", "steadyLatestAbove": "Twoje pomiary zwykle wynoszą {low} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był wyższy.", "steadyLatestBelow": "Twoje pomiary zwykle wynoszą {low} {unit}; ostatni pomiar, z {date}, wyniósł {value} i był niższy.", - "insufficient": "Wciąż poznaję twój zwykły zakres — rejestruj jeszcze przez kilka dni.", + "insufficient": "Wciąż poznaję twój zwykły zakres. Rejestruj jeszcze przez kilka dni.", "driver": "{note}" }, "scope": { - "prefix": "Coach jest już przy {metric}", - "tap": "Dotknij, aby zapytać", - "question": "Wytłumacz mi tę wartość — na co powinienem zwrócić uwagę?" - }, - "seeded": { - "prefix": "Dziś warto rzucić okiem: {signal}", - "signal": { - "readiness": "gotowość", - "recovery": "regeneracja" - }, - "question": { - "readiness": "Moja gotowość jest dziś nietypowa — z czego to wynika?", - "recovery": "Moja regeneracja się zmieniła — co za tym stoi?" - }, - "dismiss": "Odrzuć" + "prefix": "Coach jest już przy {metric}" }, "settings": "Ustawienia", "history": { @@ -3069,33 +3104,33 @@ "groupEarlier": "Wcześniej" }, "seed": { - "bloodPressure": "Przejdź ze mną przez trend mojego ciśnienia krwi z ostatnich 30 dni — czy jest coś, na co powinienem zwrócić uwagę?", + "bloodPressure": "Przejdź ze mną przez trend mojego ciśnienia krwi z ostatnich 30 dni. Czy jest coś, na co powinienem zwrócić uwagę?", "weight": "Jak zmieniała się ostatnio moja waga i czy ten kierunek wymaga reakcji?", "bmi": "Co mówi mi mój trend BMI i jak powinienem go odczytywać?", - "pulse": "Przejdź ze mną przez moje odczyty tętna — czy coś odbiega od normy?", + "pulse": "Przejdź ze mną przez moje odczyty tętna. Czy coś odbiega od normy?", "restingHr": "Jak zmieniało się moje tętno spoczynkowe i co mówi o mojej kondycji?", "hrv": "Co moja zmienność rytmu serca mówi mi o regeneracji i stresie w ostatnim czasie?", - "sleep": "Przejdź ze mną przez mój ostatni sen — długość, regularność i wszystko, co warto zmienić.", + "sleep": "Przejdź ze mną przez mój ostatni sen: długość, regularność i wszystko, co warto zmienić.", "mood": "Jakie wzorce widzisz w moim nastroju w ciągu ostatnich kilku tygodni?", "medications": "Jak wygląda moje przestrzeganie zaleceń lekowych i czy pokrywa się z tym, jak się czułem?", "steps": "Jak aktywny byłem ostatnio na podstawie moich kroków i jak wypada to na tle zdrowego punktu odniesienia?", "activeEnergy": "Co mój trend aktywnej energii mówi o moim poziomie aktywności w ostatnim czasie?", "cardioFitness": "Co oznacza trend mojej wydolności krążeniowo-oddechowej (VO₂ max) i jak skierować go we właściwą stronę?", - "bloodGlucose": "Przejdź ze mną przez moje ostatnie odczyty glukozy — czy mieszczą się w zdrowym zakresie?", + "bloodGlucose": "Przejdź ze mną przez moje ostatnie odczyty glukozy. Czy mieszczą się w zdrowym zakresie?", "oxygenSaturation": "Co mówi mi mój trend saturacji krwi tlenem (SpO₂)?", "respiratoryRate": "Co mój trend częstości oddechów mówi o moim zdrowiu?", - "workouts": "Przejdź ze mną przez moje ostatnie treningi — obciążenie, częstotliwość i jak regeneruję się między nimi.", + "workouts": "Przejdź ze mną przez moje ostatnie treningi: obciążenie, częstotliwość i jak regeneruję się między nimi.", "recoveryPage": "Dlaczego moja regeneracja jest teraz na takim poziomie i co na nią wpływa?", - "briefing": "Przejdź ze mną przez dzisiejszy briefing — na czym powinienem się skupić i czy jest coś, co warto podjąć?", + "briefing": "Przejdź ze mną przez dzisiejszy briefing. Na czym powinienem się skupić i czy jest coś, co warto podjąć?", "healthScore": "Dlaczego mój wynik zdrowia wynosi {score} na 100 i co poprawiłoby go najbardziej?", - "periodWeek": "Przejdź ze mną przez to, jak minął mój ostatni tydzień w moich wskaźnikach zdrowia — co się wyróżnia?", - "periodMonth": "Przejdź ze mną przez to, jak minął mój ostatni miesiąc w moich wskaźnikach zdrowia — co się wyróżnia?", + "periodWeek": "Przejdź ze mną przez to, jak minął mój ostatni tydzień w moich wskaźnikach zdrowia. Co się wyróżnia?", + "periodMonth": "Przejdź ze mną przez to, jak minął mój ostatni miesiąc w moich wskaźnikach zdrowia. Co się wyróżnia?", "correlationBpCompliance": "Jak moje przestrzeganie zaleceń lekowych wiąże się z moim ciśnieniem krwi? Wyjaśnij mi, co ta korelacja oznacza dla mnie.", "correlationMoodPulse": "Czy istnieje rzeczywisty związek między moim nastrojem a tętnem? Pomóż mi odczytać tę korelację.", "correlationWeightWeekday": "Czy moja waga podlega cotygodniowemu wzorcowi? Co mówi mi ta korelacja według dnia tygodnia?", - "recommendation": "Powiedz mi więcej o tej rekomendacji: \"{text}\" — dlaczego mnie dotyczy i co powinienem z nią zrobić?" + "recommendation": "Powiedz mi więcej o tej rekomendacji: \"{text}\". Dlaczego mnie dotyczy i co powinienem z nią zrobić?" }, - "assessmentPrompt": "Wyjaśnij mi moją ocenę {metric} — co oznacza i co powinienem z tym zrobić?", + "assessmentPrompt": "Wyjaśnij mi moją ocenę {metric}: co oznacza i co powinienem z tym zrobić?", "attach": { "menuChooseFromDocuments": "Wybierz z dokumentów", "menuUploadNew": "Prześlij nowy dokument", @@ -3109,13 +3144,13 @@ "pickerConfirm": "Załącz ({count})", "pillRemove": "Usuń {title}", "indexing": "Indeksowanie…", - "indexingHint": "Dokument jest jeszcze indeksowany – wyślesz, gdy będzie gotowy.", - "fencedHint": "Ta rozmowa działa bez dostępu do Twoich danych zdrowotnych – coach czyta tylko załączone dokumenty.", + "indexingHint": "Dokument jest jeszcze indeksowany. Wyślesz, gdy będzie gotowy.", + "fencedHint": "Ta rozmowa działa bez dostępu do Twoich danych zdrowotnych: coach czyta tylko załączone dokumenty.", "documentsAttachedOne": "1 załączony dokument", "documentsAttachedOther": "Załączone dokumenty: {count}", "railBadge": "Odizolowany czat dokumentu", "uploadError": "Przesyłanie nie powiodło się. Spróbuj ponownie.", - "notFoundError": "Ten link do dokumentu jest już nieprawidłowy – zamiast tego rozpoczynamy zwykły czat." + "notFoundError": "Ten link do dokumentu jest już nieprawidłowy, więc zamiast tego rozpoczynamy zwykły czat." }, "interrupted": { "note": "Ta odpowiedź została przerwana przed ukończeniem.", @@ -3180,9 +3215,9 @@ "cap": "Wiele kroków, teraz podsumowuje", "noProgress": "Brak nowych danych, podsumowuje" }, - "thoughtProcessOne": "Tok myślenia · {count} krok", - "thoughtProcessFew": "Tok myślenia · {count} kroki", - "thoughtProcessOther": "Tok myślenia · {count} kroków", + "thoughtProcessOne": "Tok myślenia ({count} krok)", + "thoughtProcessFew": "Tok myślenia ({count} kroki)", + "thoughtProcessOther": "Tok myślenia ({count} kroków)", "awaitingAnswer": "Potrzebna odpowiedź" }, "memory": { @@ -3222,7 +3257,8 @@ "capped": "(ograniczone przez administratora)", "disabled": "Administrator wyłączył głębszy namysł." }, - "memoryLink": "Pamięć ({count})" + "memoryLink": "Pamięć ({count})", + "newConversation": "Nowa rozmowa" }, "relativeJustNow": "przed chwilą", "relativeMinutesAgoOne": "{count} minutę temu", @@ -3285,7 +3321,7 @@ }, "hrv": { "title": "HRV", - "description": "Zmienność rytmu serca w czasie — sygnał regeneracji układu nerwowego, rejestrowany nocą przez Apple Watch i inne zgodne wearable.", + "description": "Zmienność rytmu serca w czasie: sygnał regeneracji układu nerwowego, rejestrowany nocą przez Apple Watch i inne zgodne wearable.", "chartTitle": "Zmienność rytmu serca", "emptyState": { "title": "Brak wartości HRV", @@ -3294,17 +3330,17 @@ }, "restingHr": { "title": "Tętno spoczynkowe", - "description": "Dzienna linia bazowa rytmu serca — różna od tętna punktowego na stronie Pulse. Apple Zdrowie podaje jedną wartość na dobę.", + "description": "Dzienna linia bazowa rytmu serca, różna od tętna punktowego na stronie Pulse. Apple Zdrowie podaje jedną wartość na dobę.", "chartTitle": "Tętno spoczynkowe", "emptyState": { "title": "Brak tętna spoczynkowego", "description": "Tętno spoczynkowe pojawi się, gdy Apple Watch lub zgodne urządzenie Withings spędzi spokojny dzień na nadgarstku." }, - "coachPrefill": "Nie zarejestrowałem jeszcze tętna spoczynkowego — dlaczego dzienna wartość spoczynkowa różni się od pojedynczego pomiaru tętna i jak ją poprawić?" + "coachPrefill": "Nie zarejestrowałem jeszcze tętna spoczynkowego. Dlaczego dzienna wartość spoczynkowa różni się od pojedynczego pomiaru tętna i jak ją poprawić?" }, "oxygenSaturation": { "title": "Saturacja tlenu", - "description": "Tlen we krwi (SpO₂) — kliniczny zakres normy 95–100 %, ze strefami docelowymi zacienionymi na wykresie.", + "description": "Tlen we krwi (SpO₂): kliniczny zakres normy 95–100 %, ze strefami docelowymi zacienionymi na wykresie.", "chartTitle": "Saturacja tlenu", "emptyState": { "title": "Brak próbek SpO₂", @@ -3317,13 +3353,13 @@ "chartTitle": "Temperatura ciała", "emptyState": { "title": "Brak temperatury ciała", - "description": "Zarejestruj pomiar lub pozwól wearable zsynchronizować jeden — trend pojawi się tutaj.", + "description": "Zarejestruj pomiar lub pozwól wearable zsynchronizować jeden, a trend pojawi się tutaj.", "cta": "Zarejestruj temperaturę ciała" } }, "steps": { "title": "Kroki", - "description": "Dzienna liczba kroków — jeden wiersz na dobę, z Apple Zdrowie lub podłączonego urządzenia aktywności.", + "description": "Dzienna liczba kroków: jeden wiersz na dobę, z Apple Zdrowie lub podłączonego urządzenia aktywności.", "chartTitle": "Kroki dziennie", "emptyState": { "title": "Brak danych o krokach", @@ -3332,7 +3368,7 @@ }, "activeEnergy": { "title": "Energia czynna", - "description": "Dzienna suma energii czynnej w kilokaloriach — jeden wiersz na dobę, z Apple Zdrowie lub aktywności Withings.", + "description": "Dzienna suma energii czynnej w kilokaloriach: jeden wiersz na dobę, z Apple Zdrowie lub aktywności Withings.", "chartTitle": "Energia czynna", "emptyState": { "title": "Brak danych o energii czynnej", @@ -3352,11 +3388,11 @@ }, "explainer": { "bloodPressureBody": "Ciśnienie krwi to siła, z jaką krew napiera na ściany tętnic, zapisywana jako skurczowe do rozkurczowego (szczyt podczas uderzenia serca do wartości spoczynkowej między uderzeniami). Stale wysokie wartości z czasem obciążają serce i naczynia.", - "pulseBody": "Tętno to liczba uderzeń serca na minutę. Pojedynczy pomiar odzwierciedla to, co robiłeś w danej chwili — w spoczynku wynosi zwykle 60–100 uderzeń na minutę i naturalnie rośnie przy ruchu, stresie lub kofeinie.", + "pulseBody": "Tętno to liczba uderzeń serca na minutę. Pojedynczy pomiar odzwierciedla to, co robiłeś w danej chwili. W spoczynku wynosi zwykle 60–100 uderzeń na minutę i naturalnie rośnie przy ruchu, stresie lub kofeinie.", "weightBody": "Masa ciała to jeden z najprostszych sygnałów długoterminowego bilansu energetycznego i ogólnego zdrowia. Trend na przestrzeni tygodni liczy się znacznie bardziej niż pojedynczy odczyt, który zmienia się wraz z nawodnieniem, jedzeniem i porą dnia.", - "bmiBody": "BMI to waga podzielona przez wzrost do kwadratu — jedna liczba przypisująca Cię do szerokiej kategorii wagowej. To badanie przesiewowe dla populacji, a nie diagnoza, i nie odróżnia mięśni od tłuszczu.", + "bmiBody": "BMI to waga podzielona przez wzrost do kwadratu: jedna liczba przypisująca Cię do szerokiej kategorii wagowej. To badanie przesiewowe dla populacji, a nie diagnoza, i nie odróżnia mięśni od tłuszczu.", "sleepBody": "Długość snu to czas, który faktycznie przespałeś w nocy, podzielony na fazy lekką, głęboką i REM. Większość dorosłych potrzebuje około siedmiu do dziewięciu godzin; stała ilość i jakość wspierają regenerację, nastrój i metabolizm.", - "moodBody": "Śledzenie nastroju rejestruje Twoje samopoczucie w czasie na prostej skali. Regularne zapisywanie ujawnia wzorce — jak sen, aktywność czy leki łączą się z samopoczuciem — których pojedynczy dzień nigdy nie pokaże.", + "moodBody": "Śledzenie nastroju rejestruje Twoje samopoczucie w czasie na prostej skali. Regularne zapisywanie ujawnia wzorce (jak sen, aktywność czy leki łączą się z samopoczuciem), których pojedynczy dzień nigdy nie pokaże.", "medicationsBody": "Przestrzeganie zaleceń to rzetelność przyjmowania leków zgodnie z zaleceniami, pokazana tu jako odsetek oczekiwanych dawek, które zarejestrowałeś. Regularne przyjmowanie często decyduje o tym, czy terapia działa zgodnie z zamierzeniem.", "restingHrBody": "Tętno spoczynkowe to częstość uderzeń serca w pełnym spoczynku, zwykle mierzona w nocy. Niższe tętno zwykle wskazuje na lepszą wydolność krążeniową, a rosnący trend może być wczesnym sygnałem stresu lub choroby.", "hrvBody": "HRV to drobna zmienność czasu między kolejnymi uderzeniami serca. Wyższa zmienność zwykle odzwierciedla dobrze zregenerowany, elastyczny układ nerwowy, a utrzymujący się spadek może sygnalizować zmęczenie, stres lub niewystarczającą regenerację.", @@ -3367,12 +3403,12 @@ "respiratoryRateBody": "Częstość oddechów to liczba wdechów na minutę. W spoczynku większość dorosłych oddycha około dwunastu do dwudziestu razy na minutę; utrzymujący się wzrost może towarzyszyć chorobie, wysiłkowi lub stresowi.", "bodyWaterBody": "Całkowita woda w organizmie to udział płynów w masie ciała, wewnątrz i wokół komórek. Zmienia się wraz z nawodnieniem, posiłkami i aktywnością, więc trend w ciągu tygodni mówi więcej niż pojedynczy pomiar.", "boneMassBody": "Masa kostna to szacowana waga minerałów w szkielecie. Pomiary z wagi są przybliżone, ale stabilny lub rosnący trend sprzyja długotrwałej wytrzymałości kości.", - "fatFreeMassBody": "Masa beztłuszczowa to wszystko w ciele, co nie jest tłuszczem — mięśnie, kości, narządy i woda. Utrzymanie lub jej budowanie przy spadku wagi zwykle wskazuje na zdrowszy skład ciała.", - "fatMassBody": "Masa tłuszczowa to łączna waga tłuszczu w ciele. Pewna ilość tłuszczu jest niezbędna dla hormonów i magazynowania energii, ale jego nadmiar — zwłaszcza w okolicy brzucha — zwiększa długoterminowe ryzyko zdrowotne (WHO 2024).", + "fatFreeMassBody": "Masa beztłuszczowa to wszystko w ciele, co nie jest tłuszczem: mięśnie, kości, narządy i woda. Utrzymanie lub jej budowanie przy spadku wagi zwykle wskazuje na zdrowszy skład ciała.", + "fatMassBody": "Masa tłuszczowa to łączna waga tłuszczu w ciele. Pewna ilość tłuszczu jest niezbędna dla hormonów i magazynowania energii, ale jego nadmiar (zwłaszcza w okolicy brzucha) zwiększa długoterminowe ryzyko zdrowotne (WHO 2024).", "bodyFatBody": "Procent tkanki tłuszczowej to część masy ciała, którą stanowi tłuszcz. Zdrowe zakresy zależą od płci i wieku, a wagi szacują go metodą bioimpedancji, dlatego trend z kilku tygodni mówi więcej niż pojedynczy pomiar.", "muscleMassBody": "Masa mięśniowa to szacowana waga mięśni w ciele. Więcej mięśni wspiera siłę, metabolizm i sprawność, a ich utrzymanie z wiekiem zwykle wymaga aktywnego wysiłku.", "visceralFatBody": "Tłuszcz trzewny to tłuszcz gromadzony głęboko w jamie brzusznej wokół narządów. W odróżnieniu od tłuszczu podskórnego jego wysoki poziom jest ściśle powiązany z chorobami serca i cukrzycą typu 2, więc im niżej, tym lepiej.", - "leanBodyMassBody": "Beztłuszczowa masa ciała to całkowita waga pomniejszona o tłuszcz — mięśnie, kości, narządy i woda razem. Śledzenie jej obok wagi pokazuje, czy zmiany wynikają z mięśni, czy z tłuszczu.", + "leanBodyMassBody": "Beztłuszczowa masa ciała to całkowita waga pomniejszona o tłuszcz: mięśnie, kości, narządy i woda razem. Śledzenie jej obok wagi pokazuje, czy zmiany wynikają z mięśni, czy z tłuszczu.", "flightsClimbedBody": "Pokonane piętra liczą kondygnacje, które pokonujesz w górę każdego dnia, gdzie jedno piętro to około trzech metrów wzniesienia. To prosty wskaźnik codziennej aktywności i wysiłku nóg.", "walkingDistanceBody": "Dystans marszu i biegu to droga, jaką pokonujesz pieszo każdego dnia. Uzupełnia liczbę kroków, pokazując, jak daleko się poruszałeś, a nie tylko jak często stawiałeś kroki.", "walkingSteadinessBody": "Stabilność chodu szacuje, jak stabilny i zrównoważony jest twój sposób chodzenia, w skali od dobrej do niskiej. Niski poziom wskazuje na większe ryzyko upadku i warto omówić go z lekarzem.", @@ -3389,28 +3425,28 @@ "daylightBody": "Czas w świetle dziennym to ilość czasu spędzanego w świetle na zewnątrz każdego dnia. Regularne światło dzienne wspiera zdrowy rytm snu i czuwania oraz nastrój (CDC 2024), a czas na świeżym powietrzu wiąże się ze zdrowiem oczu.", "bloodGlucoseBody": "Poziom glukozy we krwi to ilość cukru we krwi, głównego źródła energii organizmu. Rośnie po jedzeniu i ustala się między posiłkami; wartości utrzymujące się wysoko mogą sygnalizować cukrzycę lub stan przedcukrzycowy.", "skinTemperatureBody": "Temperatura skóry to tutaj niewielka nocna zmiana temperatury nadgarstka względem twojej własnej wartości bazowej, a nie bezwzględny pomiar gorączki. Zmiany mogą odzwierciedlać sen, otoczenie, chorobę lub fazę cyklu menstruacyjnego.", - "cardioFitnessBody": "VO₂ max to ilość tlenu, jaką organizm potrafi wykorzystać przy maksymalnym wysiłku — najlepszy laboratoryjny wskaźnik wydolności tlenowej, który urządzenia noszone szacują pośrednio. Wyższy lub rosnący trend to mocny sygnał dobrego zdrowia układu krążenia.", + "cardioFitnessBody": "VO₂ max to ilość tlenu, jaką organizm potrafi wykorzystać przy maksymalnym wysiłku. To najlepszy laboratoryjny wskaźnik wydolności tlenowej, który urządzenia noszone szacują pośrednio. Wyższy lub rosnący trend to mocny sygnał dobrego zdrowia układu krążenia.", "cardioRecoveryBody": "Regeneracja serca to liczba uderzeń na minutę, o jaką spada tętno w pierwszej minucie po zakończeniu intensywnego wysiłku. Większy spadek oznacza, że serce szybciej się uspokaja, co świadczy o dobrej kondycji.", "wristTemperatureBody": "Temperatura nadgarstka jest mierzona podczas snu i podawana jako różnica względem Twojej wartości bazowej, a nie jako wartość bezwzględna. Stabilny odczyt jest normalny, a utrzymujący się wzrost może wskazywać na chorobę, intensywny trening lub zmianę cyklu.", "fallsBody": "Tutaj liczone są poważne upadki wykryte przez urządzenie noszone. Celem jest zero; rosnąca liczba to sygnał ryzyka związanego z mobilnością, który warto omówić z lekarzem, zwłaszcza u osób starszych (CDC STEADI 2024).", - "sixMinuteWalkBody": "To szacunek, jak daleko mógłbyś przejść w sześć minut — klasyczny test kliniczny mobilności i wydolności krążeniowo-oddechowej. Wartość z urządzenia noszonego to przybliżenie, a nie nadzorowany test kliniczny; liczy się stały lub rosnący trend.", - "stairAscentSpeedBody": "To prędkość, z jaką pokonujesz bieg schodów. To czuły wskaźnik siły nóg i mobilności — stałe lub rosnące tempo to dobry znak, a spadające warto obserwować.", - "stairDescentSpeedBody": "To prędkość, z jaką schodzisz bieg schodów. Razem z prędkością wchodzenia odzwierciedla kontrolę nóg i równowagę — stałe tempo to dobry znak, a spadające warto obserwować.", - "breathingDisturbancesBody": "Urządzenie noszone potrafi oszacować, jak często Twój oddech był przerywany podczas snu. Niższy wskaźnik jest lepszy; poziom oznaczony jako podwyższony — zwłaszcza przez wiele nocy — warto omówić z lekarzem. To przybliżenie przesiewowe, a nie diagnoza bezdechu sennego.", - "stepsBody": "Liczba kroków to łączna liczba kroków wykonanych każdego dnia — najprostsza miara codziennego ruchu. Stały nawyk liczy się bardziej niż pojedynczy intensywny dzień, a okrągły cel 10 000 kroków to hasło reklamowe, a nie wynik badań.", - "readinessBody": "Gotowość to pojedynczy dzienny odczyt tego, jak przygotowane jest Twoje ciało, by zmierzyć się z dniem — łączy tętno spoczynkowe, zmienność rytmu serca i sen. To przybliżenie z urządzenia względem Twojej własnej bazy, nie pomiar kliniczny: wyższy wynik oznacza wypoczęcie, niższy to delikatna wskazówka, by zwolnić tempo.", - "sleepScoreBody": "Twój wynik snu streszcza całą noc w jednej liczbie — długość, efektywność, regularność pór oraz proporcje snu lekkiego, głębokiego i REM. Obserwuj wzorzec w ujęciu tygodni, zamiast gonić za idealnym wynikiem jednej nocy.", - "recoveryBody": "Regeneracja odzwierciedla, jak dobrze Twoje ciało się odbudowało — opiera się na nocnych sygnałach, takich jak zmienność rytmu serca i tętno spoczynkowe. Wysoki wynik to zielone światło, by przyspieszyć; niższy sugeruje, że organizm wciąż się regeneruje.", - "strainBody": "Obciążenie ujmuje, ile wysiłku sercowo-naczyniowego i fizycznego nałożyłeś na swoje ciało — od treningów po aktywny dzień na nogach. Gdy zbierze się wystarczająco danych, jest oceniane względem Twojego własnego typowego wysiłku, a nie cudzej miary.", + "sixMinuteWalkBody": "To szacunek, jak daleko mógłbyś przejść w sześć minut, czyli klasyczny test kliniczny mobilności i wydolności krążeniowo-oddechowej. Wartość z urządzenia noszonego to przybliżenie, a nie nadzorowany test kliniczny; liczy się stały lub rosnący trend.", + "stairAscentSpeedBody": "To prędkość, z jaką pokonujesz bieg schodów. To czuły wskaźnik siły nóg i mobilności: stałe lub rosnące tempo to dobry znak, a spadające warto obserwować.", + "stairDescentSpeedBody": "To prędkość, z jaką schodzisz bieg schodów. Razem z prędkością wchodzenia odzwierciedla kontrolę nóg i równowagę: stałe tempo to dobry znak, a spadające warto obserwować.", + "breathingDisturbancesBody": "Urządzenie noszone potrafi oszacować, jak często Twój oddech był przerywany podczas snu. Niższy wskaźnik jest lepszy; poziom oznaczony jako podwyższony, zwłaszcza przez wiele nocy, warto omówić z lekarzem. To przybliżenie przesiewowe, a nie diagnoza bezdechu sennego.", + "stepsBody": "Liczba kroków to łączna liczba kroków wykonanych każdego dnia, najprostsza miara codziennego ruchu. Stały nawyk liczy się bardziej niż pojedynczy intensywny dzień, a okrągły cel 10 000 kroków to hasło reklamowe, a nie wynik badań.", + "readinessBody": "Gotowość to pojedynczy dzienny odczyt tego, jak przygotowane jest Twoje ciało, by zmierzyć się z dniem. Łączy tętno spoczynkowe, zmienność rytmu serca i sen. To przybliżenie z urządzenia względem Twojej własnej bazy, nie pomiar kliniczny: wyższy wynik oznacza wypoczęcie, niższy to delikatna wskazówka, by zwolnić tempo.", + "sleepScoreBody": "Twój wynik snu streszcza całą noc w jednej liczbie: długość, efektywność, regularność pór oraz proporcje snu lekkiego, głębokiego i REM. Obserwuj wzorzec w ujęciu tygodni, zamiast gonić za idealnym wynikiem jednej nocy.", + "recoveryBody": "Regeneracja odzwierciedla, jak dobrze Twoje ciało się odbudowało. Opiera się na nocnych sygnałach, takich jak zmienność rytmu serca i tętno spoczynkowe. Wysoki wynik to zielone światło, by przyspieszyć; niższy sugeruje, że organizm wciąż się regeneruje.", + "strainBody": "Obciążenie ujmuje, ile wysiłku sercowo-naczyniowego i fizycznego nałożyłeś na swoje ciało, od treningów po aktywny dzień na nogach. Gdy zbierze się wystarczająco danych, jest oceniane względem Twojego własnego typowego wysiłku, a nie cudzej miary.", "stressBody": "Stres jest tu szacunkiem fizjologicznego obciążenia Twojego układu nerwowego, odczytywanym głównie ze wzorców zmienności rytmu serca, a nie z tego, jak się świadomie czujesz. Niższy wynik wskazuje na spokojny, dobrze regulowany stan; wyższy to zachęta, by wpleść regenerację.", - "recoveryPageBody": "Regeneracja łączy nocne sygnały rejestrowane przez Twoje urządzenie – recharge, obciążenie, load oraz dzienne sumy tętna i energii – aby pokazać, jak dobrze Twój organizm się zregenerował. Patrz na wzorzec w ciągu kilku dni, a nie na pojedynczą liczbę; niższy wynik to zaproszenie do zwolnienia tempa, a nie alarm.", + "recoveryPageBody": "Regeneracja łączy nocne sygnały rejestrowane przez Twoje urządzenie (recharge, obciążenie, load oraz dzienne sumy tętna i energii), aby pokazać, jak dobrze Twój organizm się zregenerował. Patrz na wzorzec w ciągu kilku dni, a nie na pojedynczą liczbę; niższy wynik to zaproszenie do zwolnienia tempa, a nie alarm.", "pulsePressureBody": "Ciśnienie tętna to różnica między wartością skurczową a rozkurczową (skurczowe minus rozkurczowe). Jako ogólna wskazówka, około 40 mmHg jest typowe w spoczynku; szeroka różnica powyżej około 60 mmHg w średnim wieku może odzwierciedlać usztywnienie tętnic, a wąska różnica poniżej około 25 mmHg może towarzyszyć niskiej objętości wyrzutowej (StatPearls/NIH 2023). To powód do rozmowy, nie diagnoza.", "meanArterialPressureBody": "Średnie ciśnienie tętnicze to przeciętne ciśnienie, jakie widzą Twoje narządy w trakcie uderzenia serca, w przybliżeniu rozkurczowe plus jedna trzecia różnicy do skurczowego. Jako ogólna wskazówka, wartość spoczynkowa mieści się w okolicy 70–100 mmHg (StatPearls/NIH 2023). Często przywoływany próg ~60 mmHg to pojęcie z medycyny ostrej, nie alarm do samodzielnego pomiaru.", "cycleEducationBody": "Twój cykl przebiega przez cztery fazy sterowane hormonami, a jego długość i regularność to dostępne okno na ogólny stan zdrowia. Jako ogólna wskazówka, u osób dorosłych cykle trwają zwykle 21–35 dni (źródła kliniczne podają 24–38), z krwawieniem około pięciu dni (ACOG 2015). Faza lutealna jest stałą kotwicą, dlatego owulację najlepiej szacować, licząc wstecz od następnej spodziewanej miesiączki, nigdy według stałego „14. dnia”. Przewidziane okno płodne to oszacowanie, które wymaga kilku zarejestrowanych cykli i pozostaje przybliżone, a podstawowa temperatura ciała potwierdza owulację po fakcie, a nie ją przewiduje. To nie jest metoda antykoncepcji.", "sleepHygieneBody": "Kilka opartych na dowodach nawyków wspiera sen: stałe pory kładzenia się i wstawania także w weekendy, jasne światło rano i przyciemnione wieczory, chłodna, ciemna i cicha sypialnia oraz unikanie kofeiny po południu, alkoholu późnym wieczorem i obfitych posiłków przed snem (CDC 2024). Regularność przewiduje zdrowie co najmniej tak silnie jak długość snu, a całkowity czas snu z urządzenia jest znacznie bardziej wiarygodny niż jego podział na sen głęboki, lekki i REM." }, "bpDerived": { - "pulsePressure": "Ciśnienie tętna (różnica między Twoimi wartościami) wynosi około {value} mmHg – około 40 jest typowe w spoczynku.", + "pulsePressure": "Ciśnienie tętna (różnica między Twoimi wartościami) wynosi około {value} mmHg; około 40 jest typowe w spoczynku.", "meanArterialPressure": "Średnie ciśnienie tętnicze (średnia, jaką widzą Twoje narządy) wynosi około {value} mmHg, zwykle w okolicy 70–100.", "caveat": "Te wartości są wyliczone z ostatniego pomiaru jako ogólna wskazówka, nie jako diagnoza." }, @@ -3422,7 +3458,7 @@ "mean": "Średnia" }, "showAllValues": "Pokaż wszystkie pomiary", - "valuesPageTitle": "{metric} — wszystkie pomiary", + "valuesPageTitle": "{metric}: wszystkie pomiary", "valuesPageDescription": "Każdy pomiar stojący za tą metryką. Edytuj lub usuń wpis bezpośrednio.", "diversity": { "trigger": "Wskazówka pomiarowa", @@ -3462,11 +3498,11 @@ "inBed": "W łóżku", "nap": "Drzemka", "napCount": "{count} drzemki/ek", - "unavailable": "Brak danych o fazach — fazy snu pojawią się tutaj, gdy tylko dostarczy je połączone źródło." + "unavailable": "Brak danych o fazach. Fazy snu pojawią się tutaj, gdy tylko dostarczy je połączone źródło." }, "empty": { "title": "Brak danych o śnie", - "description": "Połącz źródło takie jak Apple Health lub urządzenie noszone — albo zapisuj sen ręcznie — a ten widok wypełni się automatycznie.", + "description": "Połącz źródło takie jak Apple Health lub urządzenie noszone albo zapisuj sen ręcznie, a ten widok wypełni się automatycznie.", "action": "Zarządzaj urządzeniami" }, "debt": { @@ -3475,21 +3511,21 @@ "caption": "Pozostały bilans z ostatnich {nights} nocy", "clearValue": "Na bieżąco", "clearCaption": "Nic do nadrobienia w ostatnich {nights} nocach", - "learning": "Wciąż analizujemy Twój sen — {counted} nocy do tej pory, jeszcze {remaining}, aby uzyskać wiarygodny bilans.", + "learning": "Wciąż analizujemy Twój sen: {counted} nocy do tej pory, jeszcze {remaining}, aby uzyskać wiarygodny bilans.", "computedInfo": "To własne oszacowanie HealthLog: ruchomy bilans potrzeby snu minus sen z ostatnich nocy, który sen nadrabiający zmniejsza. Może różnić się od natywnego wskaźnika długu snu z urządzenia, które korzysta z własnego modelu zapotrzebowania." }, "average": { "title": "Średnia na noc", "value": "{hours} godz. {minutes} min", "caption": "Średni sen w ciągu ostatnich {nights} nocy", - "learning": "Wciąż analizujemy Twój sen — {counted} nocy do tej pory, jeszcze {remaining}, aby uzyskać wiarygodną średnią." + "learning": "Wciąż analizujemy Twój sen: {counted} nocy do tej pory, jeszcze {remaining}, aby uzyskać wiarygodną średnią." }, "chronotype": { "title": "Chronotyp", - "learning": "Wciąż poznajemy Twój rytm — {counted} z {total} wolnych nocy. Chronotyp wymaga kilku poranków bez budzika, aby coś znaczył.", + "learning": "Wciąż poznajemy Twój rytm: {counted} z {total} wolnych nocy. Chronotyp wymaga kilku poranków bez budzika, aby coś znaczył.", "advancedToggle": "Zaawansowane", "socialJetlagValue": "{hours} godz. {minutes} min jet lagu społecznego", - "socialJetlagCaption": "Różnica między porą snu w dni robocze i wolne — jak niewielka cotygodniowa zmiana strefy czasowej.", + "socialJetlagCaption": "Różnica między porą snu w dni robocze i wolne, jak niewielka cotygodniowa zmiana strefy czasowej.", "msfScValue": "Skorygowany środek snu {clock}", "msfScCaption": "Skorygowany o sen nadrabiany w weekend (MSFsc).", "dayTypeNote": "Dni wolne przyjmuje się jako weekendy, dopóki nie zostanie dodane śledzenie typu dnia.", @@ -3568,7 +3604,7 @@ "navRecovery": "Regeneracja", "workouts": { "title": "Treningi", - "description": "Ostatnie biegi, jazdy, marsze i treningi siłowe — zdeduplikowane między Apple Watch a Withings.", + "description": "Ostatnie biegi, jazdy, marsze i treningi siłowe, zdeduplikowane między Apple Watch a Withings.", "emptyState": { "title": "Brak treningów", "description": "Treningi trafiają tu z Apple Zdrowie na iPhonie i z Withings. Możesz też wpisać trening ręcznie." @@ -3678,7 +3714,10 @@ "endInFuture": "Trening kończyłby się w przyszłości. Ustaw początek na godzinę, o której się zaczął.", "distanceInvalid": "Podaj dystans jako liczbę w realistycznym zakresie.", "energyInvalid": "Podaj energię czynną jako liczbę do 20 000 kcal." - } + }, + "editTitle": "Edytuj trening", + "editAction": "Edytuj", + "updated": "Trening zaktualizowany" } }, "bloodGlucose": { @@ -3693,11 +3732,11 @@ "clinical": { "title": "Przegląd glukozy", "subtitle": "Spojrzenie na ostatnie {days} dni, na podstawie {count} pomiarów.", - "spotCaveat": "To szacunki z pojedynczych pomiarów punktowych, a nie profil z czujnika ciągłego — traktuj je jako kierunek, nie jako diagnozę.", + "spotCaveat": "To szacunki z pojedynczych pomiarów punktowych, a nie profil z czujnika ciągłego. Traktuj je jako kierunek, nie jako diagnozę.", "labsLink": "Wyniki laboratoryjne? HbA1c lub glukozę na czczo z pobrania krwi zapisz w sekcji Laboratorium.", "learningTitle": "Wciąż poznajemy Twoją glukozę", - "learningBody": "Na razie to {count} w ciągu {days}. Przy nieco dłuższej historii obraz się wyostrza — rejestruj dalej lub pozwól uzupełnić to podłączonemu urządzeniu.", - "learningBodyEmpty": "Gdy pojawi się kilka pomiarów — wpisanych przez Ciebie lub zsynchronizowanych z urządzenia — czas w zakresie, średnia i zmienność nabiorą tu kształtu.", + "learningBody": "Na razie to {count} w ciągu {days}. Przy nieco dłuższej historii obraz się wyostrza, więc rejestruj dalej lub pozwól uzupełnić to podłączonemu urządzeniu.", + "learningBodyEmpty": "Gdy pojawi się kilka pomiarów (wpisanych przez Ciebie lub zsynchronizowanych z urządzenia), czas w zakresie, średnia i zmienność nabiorą tu kształtu.", "readingsCount": "{count} pomiarów", "readingsCountOne": "1 pomiar", "daysCount": "{days} dni", @@ -3735,7 +3774,7 @@ "intro": "Złożone miary o poziomie badawczym dla bliższego spojrzenia. Każda sprowadza Twoje pomiary do jednej liczby ważonej ryzykiem.", "jIndex": { "label": "Indeks J", - "help": "Pojedynczy wynik łączący średnią i jej rozrzut — niższy oznacza ściślejszą kontrolę." + "help": "Pojedynczy wynik łączący średnią i jej rozrzut. Niższy oznacza ściślejszą kontrolę." }, "lbgi": { "label": "Ryzyko niedocukrzeń (LBGI)", @@ -3852,7 +3891,7 @@ "navSkinTemperature": "Temp. skóry", "pulseWaveVelocity": { "title": "Prędkość fali tętna", - "description": "Prędkość fali tętna tętniczego w czasie, w m/s — wyższe wartości sugerują sztywniejsze tętnice.", + "description": "Prędkość fali tętna tętniczego w czasie, w m/s. Wyższe wartości sugerują sztywniejsze tętnice.", "chartTitle": "Prędkość fali tętna", "emptyState": { "title": "Brak danych", @@ -4002,7 +4041,6 @@ "navWalkingSpeed": "Prędkość chodu", "mood": { "logLink": "Zapisz nastrój", - "mentalWellbeingLink": "Wykonaj sprawdzian dobrostanu psychicznego", "heatmapTitle": "Kalendarz nastroju", "heatmapNoEntry": "Brak wpisu", "legendGreat": "Świetnie", @@ -4013,7 +4051,7 @@ "distributionDaysUnit": "dni", "weekdayTitle": "Średnia wg dnia tygodnia", "weekdayNoData": "Brak danych", - "weekdayBestWorst": "Najlepszy dzień: {best} · Najtrudniejszy dzień: {worst}", + "weekdayBestWorst": "Najlepszy dzień: {best}, najtrudniejszy dzień: {worst}", "tagsTitle": "Tagi i wyzwalacze", "tagAvgMood": "Średni nastrój z tym tagiem", "correlationsTitle": "Nastrój a inne wskaźniki", @@ -4029,7 +4067,7 @@ "strengthModerate": "Umiarkowana", "strengthWeak": "Słaba", "strengthNone": "Brak", - "source": "{n} sparowanych dni · r = {r}", + "source": "{n} sparowanych dni, r = {r}", "emptyTitle": "Za mało sparowanych danych", "emptyDescription": "Rejestruj nastrój i ten wskaźnik w te same dni, aby zobaczyć korelację.", "pendingDescription": "{n} z {needed} sparowanych dni. Korelacja pojawi się, gdy dni wystarczy, aby odróżnić wzorzec od przypadku.", @@ -4046,7 +4084,7 @@ "inTargetCaption": "ostatnich dni w zakresie dobrego nastroju", "narrative": { "title": "Co się wyróżnia", - "weekdayDip": "Twój nastrój najbardziej spada w {weekday} — {delta} poniżej twojego tygodnia.", + "weekdayDip": "Twój nastrój najbardziej spada w {weekday}: {delta} poniżej twojego tygodnia.", "weekdayPeak": "{weekday} to zawsze twój najlepszy dzień, średnio {value}.", "trendUp": "Twój nastrój rośnie w ciągu ostatnich {days} dni (+{delta} względem poprzedniego okresu).", "trendDown": "Twój nastrój spada w ciągu ostatnich {days} dni (-{delta} względem poprzedniego okresu).", @@ -4055,7 +4093,7 @@ "tagLift": "Dni z tagiem „{tag}” są średnio o {delta} powyżej twojego ogólnego nastroju.", "tagDrop": "Dni z tagiem „{tag}” są o {delta} poniżej twojej średniej.", "inTarget": "Przez {pct}% ostatnich 30 dni byłeś w zakresie dobrego nastroju.", - "streak": "Rejestrujesz nastrój od {days} dni z rzędu — tak trzymaj.", + "streak": "Rejestrujesz nastrój od {days} dni z rzędu. Tak trzymaj.", "timeOfDay": "Najlepiej czujesz się {bucket}, średnio {value}." }, "timeOfDay": { @@ -4088,7 +4126,7 @@ "crosstab": { "title": "Tagi a Twoje wskaźniki zdrowia", "description": "Jak wskaźnik zdrowia różni się w dniach z danym tagiem w porównaniu z dniami bez niego.", - "pairLabel": "{tag} · {metric}", + "pairLabel": "{metric} w dni z: {tag}", "metricActiveEnergy": "Aktywna energia", "metricSleepDuration": "Sen", "metricNextDayRecovery": "Regeneracja następnego dnia", @@ -4105,7 +4143,7 @@ "factorCrosstab": { "title": "Twoje ocenione czynniki i parametry życiowe", "description": "Jak parametr życiowy różni się w dniach, w których oceniłeś dany czynnik nisko, w porównaniu z dniami, w których oceniłeś go wysoko.", - "pairLabel": "{factor} · {metric}", + "pairLabel": "{metric} w dni z: {factor}", "metricSleepDuration": "Sen", "metricSteps": "Kroki", "metricRestingHeartRate": "Tętno spoczynkowe", @@ -4126,12 +4164,12 @@ "title": "Co się wyróżnia", "subheading": "Wykryte relacje", "footer": "Wyłonione z {tested} par dzień po dniu testowanych na wszystkich Twoich wskaźnikach, po korekcie fałszywych odkryć.", - "disclaimer": "Przefiltrowane statystycznie, ale wciąż obserwacyjne — wzorzec warty uwagi, nie przyczyna.", + "disclaimer": "Przefiltrowane statystycznie, ale wciąż obserwacyjne: wzorzec warty uwagi, nie przyczyna.", "moodUp": "Wyższy {factor} zwykle wiąże się z lepszym nastrojem następnego dnia w Twoich danych.", "moodDown": "Wyższy {factor} zwykle wiąże się z gorszym nastrojem następnego dnia w Twoich danych.", "outcomeUp": "Lepszy nastrój zwykle wiąże się z wyższym {factor} następnego dnia w Twoich danych.", "outcomeDown": "Lepszy nastrój zwykle wiąże się z niższym {factor} następnego dnia w Twoich danych.", - "stat": "{n} sparowanych dni · r = {r} · q = {q}", + "stat": "{n} sparowanych dni, r = {r}, q = {q}", "statLabel": "Jak to obliczono", "explainerLabel": "Jak to obliczono" }, @@ -4165,8 +4203,8 @@ "deviationAbove": "Twoja własna ocena leży {amount} punktów powyżej tego zakresu.", "deviationBelow": "Twoja własna ocena leży {amount} punktów poniżej tego zakresu.", "weightedTitle": "Co najbardziej zaważyło w porównaniu tego dnia", - "weightedUp": "{label} — liczyło się w stronę wyższej oczekiwanej wartości", - "weightedDown": "{label} — liczyło się w stronę niższej oczekiwanej wartości", + "weightedUp": "{label}: liczyło się w stronę wyższej oczekiwanej wartości", + "weightedDown": "{label}: liczyło się w stronę niższej oczekiwanej wartości", "caveat": "To porównanie z twoimi wcześniejszymi dniami, nie stwierdzenie, jak doszło do takiego przebiegu dnia.", "learning": "Faza uczenia: zapisano {entries} z {threshold} dni. Porównanie pojawi się, gdy będzie ich dość.", "noPattern": "W zapisanych dotąd {entries} dniach nic nie jest na tyle regularne, by porównać z nimi dzień. Gdy zapisujesz też kontekst dnia, więcej da się porównać.", @@ -4175,15 +4213,15 @@ }, "derived": { "coverage": { - "summary": "{present} z {required} danych · {percent}%", + "summary": "{present} z {required} danych ({percent}%)", "ratioLabel": "{present}/{required}", "historyDays": "Na podstawie {count} dni historii", "missing": "Zapisuj te dane, aby uściślić wartość: {list}", - "summaryLabelled": "{label} · {percent}%" + "summaryLabelled": "{label} ({percent}%)" }, "scoreRing": { "aria": "Wynik {score} na 100, {band}", - "ariaProvisional": "Wynik jeszcze niedostępny — za mało danych", + "ariaProvisional": "Wynik jeszcze niedostępny, za mało danych", "provisionalCaption": "Wciąż za mało danych", "band": { "green": "dobry", @@ -4197,7 +4235,7 @@ "inputsLabel": "Dane wejściowe", "noInputs": "Żadne dane nie potwierdzają jeszcze tej wartości.", "methodLabel": "Metoda", - "window": "Z ostatnich {days} dni · dane {source}", + "window": "Z ostatnich {days} dni danych {source}", "asOf": "na dzień {date}", "standardLink": "Standard: {name}" }, @@ -4207,7 +4245,7 @@ "loadingLabel": "Wczytywanie parametrów życiowych", "loadError": "Nie udało się teraz wczytać Twoich parametrów życiowych.", "infoLabel": "Jak to jest obliczane?", - "building": "Budowanie Twojego typowego zakresu — {count} z {target} dni", + "building": "Budowanie Twojego typowego zakresu: {count} z {target} dni", "typicalRange": "Typowe dla Ciebie: {low}–{high}", "fitnessYounger": "Kondycja jak u osoby ~{years} lat młodszej", "fitnessOlder": "Kondycja ~{years} lat powyżej Twojego wieku", @@ -4239,9 +4277,8 @@ "anatomy": { "outOf": "/100", "contributorsLabel": "Czynniki", - "contributorValue": "{value} · {weight}% wagi", + "contributorValue": "{value}, waga {weight}%", "contributorMissing": "brak danych", - "disclaimer": "Orientacyjne — to nie ocena kliniczna.", "insufficient": "Za mało danych, by to ocenić. Rejestruj dalej, a wynik pojawi się tutaj." }, "composite": { @@ -4260,77 +4297,75 @@ "READINESS": { "title": "Gotowość", "description": "Codzienny wskaźnik samopoczucia oparty na tym, jak twoje sygnały wypadają względem twojej własnej bazy.", - "method": "Mieszanka 0–100 z odchylenia tętna spoczynkowego, odchylenia HRV (SDNN), jakości snu, stabilności częstości oddechu i stabilności nastroju — każde mierzone względem twojej osobistej bazy. Wagi są renormalizowane na komponentach wspieranych przez twoje dane. Wskaźnik samopoczucia, nie wynik regeneracji ani kliniczny.", + "method": "Mieszanka 0–100 z odchylenia tętna spoczynkowego, odchylenia HRV (SDNN), jakości snu, stabilności częstości oddechu i stabilności nastroju, każde mierzone względem twojej osobistej bazy. Wagi są renormalizowane na komponentach wspieranych przez twoje dane. Wskaźnik samopoczucia, nie wynik regeneracji ani kliniczny.", "component": { "rhr": "Tętno spoczynkowe", "hrv": "HRV (SDNN)", "sleep": "Sen", "respiratory": "Częstość oddechu", "mood": "Stabilność nastroju" - }, - "caveat": "Wskaźnik dobrostanu, nie ocena regeneracji ani wynik kliniczny." + } }, "VITALS_BASELINE": { - "method": "Twój osobisty typowy zakres to mediana Twoich dziennych wartości w danym okresie ± odporne rozproszenie (skalowane medianowe odchylenie bezwzględne). Śledzi TWOJE odczyty, a nie normę populacyjną — wartość w obrębie pasma jest więc typowa dla Ciebie." + "method": "Twój osobisty typowy zakres to mediana Twoich dziennych wartości w danym okresie ± odporne rozproszenie (skalowane medianowe odchylenie bezwzględne). Śledzi TWOJE odczyty, a nie normę populacyjną, więc wartość w obrębie pasma jest typowa dla Ciebie." }, "HRV_BALANCE": { "method": "Twoja niedawna średnia HRV (SDNN) na tle Twojej własnej ruchomej linii bazowej (mediana ± odporne rozproszenie). Zrównoważone oznacza, że mieści się w Twoim osobistym zakresie; pasmo budowane jest z Twoich danych, nigdy z progu populacyjnego." }, "BMI": { - "method": "Masa ÷ wzrost², sklasyfikowane według kategorii WHO dla dorosłych (niedowaga / norma / nadwaga / otyłość). To tylko wskaźnik przesiewowy — nie odróżnia mięśni od tłuszczu." + "method": "Masa ÷ wzrost², sklasyfikowane według kategorii WHO dla dorosłych (niedowaga / norma / nadwaga / otyłość). To tylko wskaźnik przesiewowy, który nie odróżnia mięśni od tłuszczu." }, "FITNESS_AGE": { - "method": "Twoje VO₂max odniesione do percentyli wydolności krążeniowo-oddechowej według wieku i płci, wyrażone jako różnica wieku sprawnościowego. VO₂max to szacunek Twojego urządzenia — tutaj nigdy nie jest przeliczany ponownie." + "method": "Twoje VO₂max odniesione do percentyli wydolności krążeniowo-oddechowej według wieku i płci, wyrażone jako różnica wieku sprawnościowego. VO₂max to szacunek Twojego urządzenia, tutaj nigdy nie jest przeliczany ponownie." }, "VASCULAR_AGE_DELTA": { "method": "Szacunek wieku naczyniowego / prędkości fali tętna z Twojego urządzenia porównany z Twoim wiekiem metrykalnym, wyrażony jako różnica w latach. Szacunek pochodzi z urządzenia; HealthLog jedynie go przedstawia.", - "caveat": "Własny szacunek Twojego urządzenia, jedynie przedstawiony — opisowy, nie kliniczna ocena naczyniowa.", + "caveat": "Własny szacunek Twojego urządzenia, jedynie przedstawiony: opisowy, nie kliniczna ocena naczyniowa.", "description": "Jak szacunek wieku naczyniowego Twojego urządzenia wypada na tle Twojego wieku metrykalnego." }, "COINCIDENT_DEVIATION": { "method": "Sygnał pojawiający się, gdy co najmniej dwa Twoje parametry życiowe wykraczają tego samego dnia poza swój osobisty typowy zakres. Każde pasmo to odporna mediana ± k·MAD Twojej historii, więc dostosowuje się do Ciebie, a nie do progu populacyjnego.", - "caveat": "Sygnał z Twoich własnych linii bazowych — opisowy, nie diagnoza ani alarm kliniczny." + "caveat": "Sygnał z Twoich własnych linii bazowych: opisowy, nie diagnoza ani alarm kliniczny." }, "RECOVERY_SCORE": { - "method": "Nocny wskaźnik regeneracji 0–100 łączący Twoje HRV, tętno spoczynkowe i sen względem Twojej linii bazowej. Wyższy oznacza lepszą regenerację. Liczony raz na noc i tu tylko odczytywany — nigdy nieprzeliczany na bieżąco.", - "caveat": "Opisowy wskaźnik dobrostanu, nie kliniczna ani treningowa miara regeneracji.", + "method": "Nocny wskaźnik regeneracji 0–100 łączący Twoje HRV, tętno spoczynkowe i sen względem Twojej linii bazowej. Wyższy oznacza lepszą regenerację. Liczony raz na noc i tu tylko odczytywany, nigdy nieprzeliczany na bieżąco.", "description": "Nocny wskaźnik regeneracji z Twojego HRV, tętna spoczynkowego i snu." }, "STRESS_SCORE": { "method": "Nocny wskaźnik stresu 0–100 na podstawie kształtu Twojego HRV w ciągu dnia względem linii bazowej. Wyższy oznacza większe obciążenie fizjologiczne.", - "caveat": "Wskaźnik pochodzący z HRV, a NIE pomiar stresu metodą EDA / reakcji skórno-galwanicznej — Twój zegarek nie ma czujnika EDA. Opisowy, nie kliniczny.", "description": "Nocny wskaźnik stresu na podstawie kształtu Twojego HRV w ciągu dnia." }, "STRAIN_SCORE": { "method": "Nocny wskaźnik obciążenia 0–100 na podstawie Twojego obciążenia aktywnością ważonego tętnem (model impulsu treningowego typu TRIMP). Wyższy oznacza cięższy dzień.", - "anchorPersonal": "Zakotwiczony w Twoim własnym niedawnym obciążeniu z dni treningowych — oceniany względem Twojego typowego wysiłku, więc naprawdę ciężki dzień dla Ciebie wypada wysoko.", + "anchorPersonal": "Zakotwiczony w Twoim własnym niedawnym obciążeniu z dni treningowych. Oceniany względem Twojego typowego wysiłku, więc naprawdę ciężki dzień dla Ciebie wypada wysoko.", "anchorPopulation": "Oceniany względem ogólnego punktu odniesienia, gdy dopiero budujesz historię treningów; gdy zbierzesz jej wystarczająco, przełącza się na Twój własny typowy wysiłek.", - "caveat": "Opisowy wskaźnik obciążenia aktywnością, nie kliniczna ani treningowa miara obciążenia.", - "description": "Nocny wskaźnik obciążenia aktywnością z Twojego wysiłku ważonego tętnem, względem Twojego typowego wysiłku." + "description": "Nocny wskaźnik obciążenia aktywnością z Twojego wysiłku ważonego tętnem, względem Twojego typowego wysiłku.", + "deviceCaption": "Dzienne obciążenie z Twojego urządzenia: {value} z {max}", + "deviceSource": "Dopóki HealthLog nie obliczy w nocy własnej wartości, karta pokazuje dzienne obciążenie z Twojego urządzenia, przeliczone na 0–100." }, "WRIST_TEMPERATURE_BASELINE": { - "method": "Twoja typowa nocna temperatura nadgarstka to mediana Twoich nocnych odczytów w danym okresie, ± solidny rozrzut (przeskalowane medianowe odchylenie bezwzględne). Śledzi TWOJE odczyty — ostatnia noc jest pokazana jako odchylenie od Twojej własnej bazy, a nie od normy populacyjnej.", - "caveat": "Tylko osobiste pasmo odchylenia — opisowe, nie sygnał choroby, gorączki ani fazy cyklu. Interpretujesz je samodzielnie." + "method": "Twoja typowa nocna temperatura nadgarstka to mediana Twoich nocnych odczytów w danym okresie, ± solidny rozrzut (przeskalowane medianowe odchylenie bezwzględne). Śledzi TWOJE odczyty: ostatnia noc jest pokazana jako odchylenie od Twojej własnej bazy, a nie od normy populacyjnej.", + "caveat": "Tylko osobiste pasmo odchylenia: opisowe, nie sygnał choroby, gorączki ani fazy cyklu. Interpretujesz je samodzielnie." }, "STAIR_ASCENT_SPEED_BASELINE": { - "method": "Twoje typowe tempo wchodzenia po schodach to mediana Twoich dziennych wartości w danym okresie, ± solidny rozrzut (przeskalowane medianowe odchylenie bezwzględne). Pasmo jest budowane z Twoich danych — nie ma populacyjnego progu prędkości na schodach, bo tempo zależy od geometrii schodów i długości nóg.", - "caveat": "Osobiste pasmo trendu mobilności — opisowe, nie ocena kruchości ani ryzyka upadku." + "method": "Twoje typowe tempo wchodzenia po schodach to mediana Twoich dziennych wartości w danym okresie, ± solidny rozrzut (przeskalowane medianowe odchylenie bezwzględne). Pasmo jest budowane z Twoich danych. Nie ma populacyjnego progu prędkości na schodach, bo tempo zależy od geometrii schodów i długości nóg.", + "caveat": "Osobiste pasmo trendu mobilności: opisowe, nie ocena kruchości ani ryzyka upadku." }, "STAIR_DESCENT_SPEED_BASELINE": { - "method": "Twoje typowe tempo schodzenia po schodach to mediana Twoich dziennych wartości w danym okresie, ± solidny rozrzut (przeskalowane medianowe odchylenie bezwzględne). Pasmo jest budowane z Twoich danych — nie ma populacyjnego progu prędkości na schodach, bo tempo zależy od geometrii schodów i długości nóg.", - "caveat": "Osobiste pasmo trendu mobilności — opisowe, nie ocena kruchości ani ryzyka upadku." + "method": "Twoje typowe tempo schodzenia po schodach to mediana Twoich dziennych wartości w danym okresie, ± solidny rozrzut (przeskalowane medianowe odchylenie bezwzględne). Pasmo jest budowane z Twoich danych. Nie ma populacyjnego progu prędkości na schodach, bo tempo zależy od geometrii schodów i długości nóg.", + "caveat": "Osobiste pasmo trendu mobilności: opisowe, nie ocena kruchości ani ryzyka upadku." }, "SIX_MINUTE_WALK_BAND": { - "method": "SZACOWANY przez Twoje urządzenie dystans sześciominutowego marszu odniesiony do wzorca Enrighta i Sherrilla dla Twojego wieku, wzrostu, wagi i płci, jako procent wartości przewidywanej. Dystans to szacunek urządzenia — nigdy nie jest tu przeliczany. Bez Twojego wieku, wzrostu, wagi i płci procent jest ukryty i pokazywane są tylko dystans i trend.", - "caveat": "Szacunek odniesiony do opublikowanego wzorca — sygnał wydolności funkcjonalnej, nie kliniczny test sześciominutowego marszu ani diagnoza. Wzorzec opracowano dla wieku 40–80 lat; poza tym zakresem wartość przewidywana jest ekstrapolacją." + "method": "SZACOWANY przez Twoje urządzenie dystans sześciominutowego marszu odniesiony do wzorca Enrighta i Sherrilla dla Twojego wieku, wzrostu, wagi i płci, jako procent wartości przewidywanej. Dystans to szacunek urządzenia i nigdy nie jest tu przeliczany. Bez Twojego wieku, wzrostu, wagi i płci procent jest ukryty i pokazywane są tylko dystans i trend.", + "caveat": "Szacunek odniesiony do opublikowanego wzorca: sygnał wydolności funkcjonalnej, nie kliniczny test sześciominutowego marszu ani diagnoza. Wzorzec opracowano dla wieku 40–80 lat; poza tym zakresem wartość przewidywana jest ekstrapolacją." }, "TRAJECTORY": { "method": "Prosta dopasowana metodą najmniejszych kwadratów do Twoich ostatnich wartości dziennych i rzutowana w przód na krótki horyzont, z klasycznym przedziałem predykcji, który poszerza się tym bardziej, im dalej sięga. Obliczana tylko wtedy, gdy ostatni trend jest dość wyraźny, a Twoja historia wystarczająco długa i aktualna; w przeciwnym razie linia nie jest rysowana.", - "caveat": "Rzut Twojego własnego ostatniego trendu — nie prognoza, cel ani kliniczne przewidywanie. Sprawdza się tylko, jeśli ostatni wzorzec się utrzyma; zacieniony pas pokazuje, jak szybko ta pewność maleje." + "caveat": "Rzut Twojego własnego ostatniego trendu, nie prognoza, cel ani kliniczne przewidywanie. Sprawdza się tylko, jeśli ostatni wzorzec się utrzyma; zacieniony pas pokazuje, jak szybko ta pewność maleje." }, "SAME_TIME_BASELINE": { "method": "Twoja dzisiejsza suma narastająca zestawiona z medianą tej samej godziny lokalnej z ostatnich czterech tygodni, z odpornym pasmem mediana ± MAD wokół niej. Liczą się tylko dni z wystarczającą liczbą zapisów, a porównanie nie pojawia się, dopóki nie ma ich co najmniej czternastu.", - "caveat": "Porównanie z twoją własną niedawną historią, a nie cel. Mówi, gdzie dziś jesteś względem swojego zwykłego wzorca o tej godzinie — i nic o tym, czy ten wzorzec jest dla ciebie właściwy." + "caveat": "Porównanie z twoją własną niedawną historią, a nie cel. Mówi, gdzie dziś jesteś względem swojego zwykłego wzorca o tej godzinie, i nic o tym, czy ten wzorzec jest dla ciebie właściwy." } }, "scores": { @@ -4341,7 +4376,9 @@ "recovery": "Wynik regeneracji", "recoveryCrossLink": "Zobacz dane regeneracji i obciążenia z Twojego urządzenia", "stress": "Stres", - "strain": "Obciążenie" + "strain": "Obciążenie", + "historyTitle": "Przebieg", + "historyWindow": "Ostatnie {days} dni" }, "coincident": { "label": "Kilka parametrów poza bazą", @@ -4351,31 +4388,31 @@ "allClear": "Dziś wszystkie Twoje parametry życiowe mieszczą się w osobistym zakresie.", "allClearMeta": "Sprawdzono {count} Twoich parametrów względem Twojego typowego zakresu.", "watch": "Dziś jeden parametr jest poza swoim zwykłym zakresem.", - "watchVital": "Poza zakresem: {vital}. Często to jednorazowy odczyt — warto obserwować przez kilka dni.", + "watchVital": "Poza zakresem: {vital}. Często to jednorazowy odczyt, warto obserwować przez kilka dni.", "firedHeadline": "Dziś {count} Twoich parametrów życiowych jest poza osobistym zakresem.", - "factors": "Możliwe czynniki — nigdy przyczyna: intensywny trening, słaby sen, alkohol, wysokość, stres lub choroba. To wskazówka z Twoich własnych baz, nie diagnoza.", - "building": "Budujemy Twoje osobiste bazy — zarejestruj jeszcze kilka dni, a to się pojawi.", + "factors": "Możliwe czynniki (nigdy przyczyna): intensywny trening, słaby sen, alkohol, wysokość, stres lub choroba. To wskazówka z Twoich własnych baz, nie diagnoza.", + "building": "Budujemy Twoje osobiste bazy. Zarejestruj jeszcze kilka dni, a to się pojawi.", "illnessExplained": "Te odchylenia prawdopodobnie wynikają z zapisanego schorzenia." }, "trajectory": { "cardTitle": "Krótkoterminowy rzut", - "headline": "Jeśli ten wzorzec się utrzyma — następne {days} dni", + "headline": "Jeśli ten wzorzec się utrzyma: następne {days} dni", "range": "Rzutowane na około {low}–{high} {unit} za {days} dni.", "caveat": "Rzut Twojego własnego ostatniego trendu, nie prognoza. Zacieniony pas poszerza się, pokazując, jak szybko rośnie niepewność.", - "insufficient": "Jeszcze brak wystarczająco wyraźnego trendu do rzutowania — rejestruj dalej, a rzut pojawi się, gdy ostatni wzorzec będzie dość wyraźny.", + "insufficient": "Jeszcze brak wystarczająco wyraźnego trendu do rzutowania. Rejestruj dalej, a rzut pojawi się, gdy ostatni wzorzec będzie dość wyraźny.", "daysNeeded": "Potrzeba jeszcze około {count} dni rejestrowania, zanim pojawi się rzut." }, "sameTime": { "sectionTitle": "Dziś do tej pory", "sectionSubtitle": "Twoje sumy narastające względem tego, co o tej godzinie jest u ciebie zwykłe", - "learning": "Uczę się twojego zwykłego dnia — {count} z {target} dni", + "learning": "Uczę się twojego zwykłego dnia: {count} z {target} dni", "framing": "Zwykle do {time}: {typical} {unit}", "comparisonLabel": "Twój zwykły dzień" } }, "cardioFitness": { "title": "Wydolność", - "description": "Twoje VO₂ max w czasie — główna miara wydolności tlenowej.", + "description": "Twoje VO₂ max w czasie, główna miara wydolności tlenowej.", "chartTitle": "VO₂ max (mL/(kg·min))", "emptyState": { "title": "Brak danych o wydolności", @@ -4455,7 +4492,7 @@ "navBreathingDisturbances": "Oddychanie", "rhythmEvents": { "sectionTitle": "Powiadomienia zdrowotne urządzenia", - "sectionIntro": "Powiadomienia, które zegarek wysłał samodzielnie. HealthLog jedynie je wyświetla — nie wykrywa ich, nie potwierdza ani nie interpretuje.", + "sectionIntro": "Powiadomienia, które zegarek wysłał samodzielnie. HealthLog jedynie je wyświetla: nie wykrywa ich, nie potwierdza ani nie interpretuje.", "loadError": "Nie udało się wczytać powiadomień urządzenia.", "disclaimer": "To alerty wygenerowane przez Twoje urządzenie za pomocą jego wbudowanego wykrywania. Są tu pokazywane wyłącznie dla Twojej informacji. To nie jest ocena medyczna wykonana przez HealthLog, a HealthLog nie diagnozuje żadnych schorzeń. Jeśli powiadomienie Cię niepokoi, skontaktuj się z lekarzem.", "event": { @@ -4500,13 +4537,13 @@ "detail": "Ukryte strony szczegółów znikają również z górnej nawigacji." }, "customize": "Dostosuj Analizy", - "regenerateRateLimited": "Osiągnięto limit odświeżeń — spróbuj ponownie później.", - "regenerateUnavailable": "Nie otrzymano nowej analizy — spróbuj ponownie później.", - "regenerateError": "Trwa odświeżanie — spróbuj ponownie za chwilę.", + "regenerateRateLimited": "Osiągnięto limit odświeżeń. Spróbuj ponownie później.", + "regenerateUnavailable": "Nie otrzymano nowej analizy. Spróbuj ponownie później.", + "regenerateError": "Trwa odświeżanie. Spróbuj ponownie za chwilę.", "regenerateSettling": "Ocena jest wciąż przygotowywana", "regenerateSettleFailed": "Nie udało się ukończyć odświeżania. Spróbuj ponownie później.", "deviceScore": { - "learning": "Wciąż poznajemy Twoją linię bazową – kilka kolejnych nocy uzupełni trend.", + "learning": "Wciąż poznajemy Twoją linię bazową. Kilka kolejnych nocy uzupełni trend.", "average": "Średnia z 30 dni {value}", "unitScore": "pkt", "unitMinutes": "min", @@ -4519,14 +4556,14 @@ }, "recovery": { "title": "Regeneracja", - "description": "Jak dobrze Twój organizm się zregenerował – sygnały regeneracji, obciążenia i load, które Twoje urządzenie rejestruje w nocy.", + "description": "Jak dobrze Twój organizm się zregenerował: sygnały regeneracji, obciążenia i load, które Twoje urządzenie rejestruje w nocy.", "scoreCrossLink": "Zobacz łączny wynik regeneracji", "emptyTitle": "Brak danych o regeneracji", "empty": "Brak danych o regeneracji. Połącz konto WHOOP, Polar lub Oura, a te wyniki uzupełnią się po synchronizacji urządzenia.", "block": { - "ansChargeExplainer": "Jak naładowany jest Twój autonomiczny układ nerwowy na dany dzień — odczyt regeneracji oparty na nocnych sygnałach serca i oddechu.", + "ansChargeExplainer": "Jak naładowany jest Twój autonomiczny układ nerwowy na dany dzień: odczyt regeneracji oparty na nocnych sygnałach serca i oddechu.", "dayStrainExplainer": "Dzienne obciążenie sercowo-naczyniowe na skali wysiłku Twojego urządzenia. Odczytuj je w odniesieniu do regeneracji, nie jako cel sam w sobie.", - "workoutStrainExplainer": "Jak wymagający był pojedynczy trening na tej samej skali wysiłku — koszt sercowo-naczyniowy tej sesji.", + "workoutStrainExplainer": "Jak wymagający był pojedynczy trening na tej samej skali wysiłku, czyli koszt sercowo-naczyniowy tej sesji.", "cardioLoadExplainer": "Przybliżenie obciążenia treningowego, które śledzi nagromadzone obciążenie sercowo-naczyniowe. Porównuj je z własnymi ostatnimi dniami.", "averageHeartRateExplainer": "Twoje średnie tętno w ciągu całego dnia lub cyklu, zgodnie z odczytem Twojego urządzenia.", "maxHeartRateExplainer": "Twoje maksymalne tętno w ciągu całego dnia lub cyklu, zgodnie z odczytem Twojego urządzenia.", @@ -4538,8 +4575,8 @@ "subtitle": "Nocne odchylenie od Twojej linii bazowej" }, "resilience": { - "explainer": "Jak dobrze Twój organizm równoważy codzienne obciążenie z regeneracją — odczytane z nocnych sygnałów z ostatnich dni.", - "learning": "Wciąż poznajemy Twoją odporność — kilka kolejnych dni ustali poziom.", + "explainer": "Jak dobrze Twój organizm równoważy codzienne obciążenie z regeneracją, odczytane z nocnych sygnałów z ostatnich dni.", + "learning": "Wciąż poznajemy Twoją odporność. Kilka kolejnych dni ustali poziom.", "band": { "limited": "Ograniczona", "adequate": "Wystarczająca", @@ -4584,7 +4621,7 @@ "nights": "Śledzono {count} nocy", "indexLine": "ostatni średni indeks {value}", "eventsFlagged": "{count} zdarzeń oznaczonych przez urządzenie", - "disclaimer": "To sygnał przesiewowy, a nie diagnoza — skonsultuj się z lekarzem." + "disclaimer": "To sygnał przesiewowy, a nie diagnoza. Skonsultuj się z lekarzem." }, "labsChanges": { "sectionTitle": "Od ostatnich badań", @@ -4600,14 +4637,14 @@ "navWaistToHeight": "Talia/wzrost", "gripStrength": { "title": "Siła chwytu", - "description": "Maksymalna siła chwytu dłoni — solidny wskaźnik ogólnej siły ciała. Poniżej progu zależnego od płci (mężczyźni 27 kg, kobiety 16 kg) sygnalizuje prawdopodobną sarkopenię; odczyt prowadzi Twój własny trend.", + "description": "Maksymalna siła chwytu dłoni, solidny wskaźnik ogólnej siły ciała. Poniżej progu zależnego od płci (mężczyźni 27 kg, kobiety 16 kg) sygnalizuje prawdopodobną sarkopenię; odczyt prowadzi Twój własny trend.", "chartTitle": "Siła chwytu", "emptyState": { "title": "Brak pomiarów siły chwytu", "description": "Zapisz pomiar z dynamometru ręcznego (zanotuj najlepszy z kilku ściśnięć), a trend pojawi się tutaj.", "cta": "Zapisz siłę chwytu" }, - "coachPrefill": "Nie zapisałem jeszcze siły chwytu — co mówi o zdrowiu moich mięśni i jak ją zmierzyć oraz poprawić?" + "coachPrefill": "Nie zapisałem jeszcze siły chwytu. Co mówi o zdrowiu moich mięśni i jak ją zmierzyć oraz poprawić?" }, "pain": { "title": "Ból (0–10)", @@ -4618,29 +4655,29 @@ "description": "Zapisz, jak odczuwasz ból teraz, w skali 0–10, a trend pojawi się tutaj.", "cta": "Zapisz ocenę bólu" }, - "coachPrefill": "Nie zapisałem jeszcze ocen bólu — jak śledzić ból w skali 0–10 i co liczy się jako istotna zmiana?" + "coachPrefill": "Nie zapisałem jeszcze ocen bólu. Jak śledzić ból w skali 0–10 i co liczy się jako istotna zmiana?" }, "waist": { "title": "Obwód talii", - "description": "Obwód talii w centymetrach — wskaźnik tłuszczu brzusznego. Próg zwiększonego ryzyka WHO zależy od płci (mężczyźni 94 cm, kobiety 80 cm); odczyt prowadzi Twój własny trend.", + "description": "Obwód talii w centymetrach, wskaźnik tłuszczu brzusznego. Próg zwiększonego ryzyka WHO zależy od płci (mężczyźni 94 cm, kobiety 80 cm); odczyt prowadzi Twój własny trend.", "chartTitle": "Obwód talii", "emptyState": { "title": "Brak pomiarów talii", "description": "Zmierz talię w połowie odległości między najniższym żebrem a kością biodrową, na końcu normalnego wydechu, i zapisz tutaj.", "cta": "Zapisz obwód talii" }, - "coachPrefill": "Nie zapisałem jeszcze pomiarów talii — jak prawidłowo zmierzyć talię i co oznacza ta liczba dla mojego zdrowia?" + "coachPrefill": "Nie zapisałem jeszcze pomiarów talii. Jak prawidłowo zmierzyć talię i co oznacza ta liczba dla mojego zdrowia?" }, "waistToHeight": { "title": "Stosunek talii do wzrostu", - "description": "Twoja talia podzielona przez wzrost — najbardziej uniwersalna pojedyncza liczba. Utrzymanie go poniżej 0,5 (talia mniejsza niż połowa wzrostu) to prosty cel.", + "description": "Twoja talia podzielona przez wzrost: najbardziej uniwersalna pojedyncza liczba. Utrzymanie go poniżej 0,5 (talia mniejsza niż połowa wzrostu) to prosty cel.", "chartTitle": "Stosunek talii do wzrostu", "emptyState": { "title": "Brak stosunku talii do wzrostu", "description": "Zapisz stosunek talii do wzrostu, a trend pojawi się tutaj. Zasada: utrzymuj talię poniżej połowy wzrostu.", "cta": "Zapisz stosunek talii do wzrostu" }, - "coachPrefill": "Nie zapisałem jeszcze stosunku talii do wzrostu — dlaczego to przydatna liczba i jak utrzymać ją poniżej 0,5?" + "coachPrefill": "Nie zapisałem jeszcze stosunku talii do wzrostu. Dlaczego to przydatna liczba i jak utrzymać ją poniżej 0,5?" }, "ecg": { "sectionTitle": "Zapisy EKG", @@ -4681,9 +4718,9 @@ "emptyCta": "Połącz urządzenie" }, "intradayPulse": { - "title": "Przebieg Twojego dnia", - "caption": "Twoje tętno w ciągu dnia, uśrednione w 10-minutowych odcinkach.", - "hourlyNote": "Pokazano średnią godzinową dla tego dnia — ostatnie dni pokazują pełny, 10-minutowy szczegół.", + "title": "Tętno w ciągu dnia", + "caption": "Uśrednione w krokach co 10 minut.", + "hourlyNote": "Pokazano średnią godzinową dla tego dnia. Ostatnie dni pokazują pełny, 10-minutowy szczegół.", "empty": "Za mało pomiarów z tego dnia, aby pokazać jego przebieg.", "coverageOne": "Na podstawie 1 pomiaru w ciągu {hours}h.", "coverageMany": "Na podstawie {count} pomiarów w ciągu {hours}h.", @@ -4692,10 +4729,10 @@ "previousDay": "Poprzedni dzień", "nextDay": "Następny dzień", "tension": { - "morning": "Utrzymujący się podwyższony spoczynek rano — możliwe napięcie.", - "afternoon": "Utrzymujący się podwyższony spoczynek po południu — możliwe napięcie.", - "evening": "Utrzymujący się podwyższony spoczynek wieczorem — możliwe napięcie.", - "night": "Utrzymujący się podwyższony spoczynek w nocy — możliwe napięcie." + "morning": "Utrzymujący się podwyższony spoczynek rano: możliwe napięcie.", + "afternoon": "Utrzymujący się podwyższony spoczynek po południu: możliwe napięcie.", + "evening": "Utrzymujący się podwyższony spoczynek wieczorem: możliwe napięcie.", + "night": "Utrzymujący się podwyższony spoczynek w nocy: możliwe napięcie." } }, "navEcg": "EKG", @@ -4706,6 +4743,19 @@ "settingsBody": "HealthLog działa w pełni bez AI. Podłącz dostawcę poniżej, jeśli chcesz codzienne podsumowanie, krótkie notatki o pomiarach i Coacha.", "action": "Skonfiguruj AI", "dismiss": "Nie teraz" + }, + "bmiHeight": { + "coachPrefill": "Nie podałem jeszcze wzrostu. Dlaczego BMI ma znaczenie i co powinienem wiedzieć, zanim go ustawię?" + }, + "medicationsEmpty": { + "coachPrefill": "Nie dodałem jeszcze żadnych leków. Co powinienem wiedzieć, zanim zacznę tu śledzić przestrzeganie zaleceń?" + }, + "scoreHistory": { + "healthTitle": "Przebieg wyniku zdrowia", + "healthDescription": "Twój wynik zdrowia dzień po dniu, taki jak był pokazywany każdego dnia.", + "emptyDescription": "W tym okresie nie zapisano jeszcze żadnego wyniku.", + "usualRange": "Zacieniowane: twój zwykły zakres, od {lo} do {hi}.", + "seam": "Linia przerywa się tam, gdzie zmieniło się to, co liczy się do wyniku." } }, "thresholds": { @@ -4735,14 +4785,15 @@ "metricBodyFat": "Tkanka tłuszczowa", "metricSleep": "Czas snu", "metricSteps": "Kroki/dzień", - "metricGlucoseFasting": "Glikemia — na czczo", - "metricGlucosePostprandial": "Glikemia — po posiłku", - "metricGlucoseRandom": "Glikemia — dowolna", - "metricGlucoseBedtime": "Glikemia — przed snem", + "metricGlucoseFasting": "Glikemia: na czczo", + "metricGlucosePostprandial": "Glikemia: po posiłku", + "metricGlucoseRandom": "Glikemia: dowolna", + "metricGlucoseBedtime": "Glikemia: przed snem", "metricBodyWater": "Woda w organizmie", "metricBoneMass": "Masa kostna", "metricOxygenSaturation": "Saturacja tlenem (SpO₂)", - "unsetExplanation": "Brak własnej wartości — aktywna jest wartość domyślna." + "unsetExplanation": "Brak własnej wartości, aktywna jest wartość domyślna.", + "cardTitle": "Zakresy według metryki" }, "onboarding": { "errorGeneric": "Coś poszło nie tak podczas kończenia konfiguracji. Spróbuj ponownie.", @@ -4755,11 +4806,11 @@ "steps": { "dashboardOverview": { "title": "Twój pulpit", - "body": "Pulpit zbiera wszystkie moduły na jednym ekranie. Przytrzymaj kafelek, aby go przestawić lub ukryć (Ustawienia → Układ pulpitu). Kafelek jest wart tyle, ile dane za nim — kolejne moduły je wypełniają." + "body": "Pulpit zbiera wszystkie moduły na jednym ekranie. Przytrzymaj kafelek, aby go przestawić lub ukryć (Ustawienia → Układ pulpitu). Kafelek jest wart tyle, ile dane za nim, a kolejne moduły je wypełniają." }, "quickAdd": { "title": "Zapisane w kilka sekund", - "body": "Przez „+ Dodaj” zapiszesz każdy pomiar w sekundy — wagę, ciśnienie, tętno, glukozę. Ukryta moc: datowanie wstecz zapomnianych wartości. Wszystko, co tu wpiszesz, zasila trendy, wynik zdrowia i analizy AI." + "body": "Przez „+ Dodaj” zapiszesz każdy pomiar w sekundy: wagę, ciśnienie, tętno, glukozę. Ukryta moc: datowanie wstecz zapomnianych wartości. Wszystko, co tu wpiszesz, zasila trendy, wynik zdrowia i analizy AI." }, "measurements": { "title": "Pomiary i zakresy docelowe", @@ -4771,7 +4822,7 @@ }, "labs": { "title": "Wyniki laboratoryjne", - "body": "Laboratorium gromadzi biomarkery z zakresami referencyjnymi — morfologia, cholesterol, HbA1c. Atut: import i eksport FHIR. Wartości poza zakresem wracają jako dowody w analizach i w raporcie dla lekarza." + "body": "Laboratorium gromadzi biomarkery z zakresami referencyjnymi: morfologia, cholesterol, HbA1c. Atut: import i eksport FHIR. Wartości poza zakresem wracają jako dowody w analizach i w raporcie dla lekarza." }, "illness": { "title": "Dziennik chorób", @@ -4787,15 +4838,15 @@ }, "mood": { "title": "Nastrój", - "body": "Nastrój zapisuje samopoczucie i tagi w dwie sekundy. Atut: własne tagi. Trendy nastroju korelują ze snem, lekami i cyklem — Coach dostrzega wzorce." + "body": "Nastrój zapisuje samopoczucie i tagi w dwie sekundy. Atut: własne tagi. Trendy nastroju korelują ze snem, lekami i cyklem, a Coach dostrzega wzorce." }, "insights": { "title": "Analizy", - "body": "Analizy to serce aplikacji: dynamiczna analiza oparta na dowodach zamiast ogólnych porad — każde stwierdzenie wskazuje na Twoje realne dane. Ukryte: układ można dostosować. Analizy łączą wszystko, co dostarczają moduły powyżej." + "body": "Analizy to serce aplikacji: dynamiczna analiza oparta na dowodach zamiast ogólnych porad, a każde stwierdzenie wskazuje na Twoje realne dane. Ukryte: układ można dostosować. Analizy łączą wszystko, co dostarczają moduły powyżej." }, "coach": { "title": "Coach", - "body": "Coach odpowiada na pytania o Twoje dane w dialogu, korzystając z wywiadu, leków i wyników jako kontekstu. Dostawcę AI wybierasz sam (własny klucz lub lokalnie). Niczego nie przelicza — czyta te same liczby co pulpit." + "body": "Coach odpowiada na pytania o Twoje dane w dialogu, korzystając z wywiadu, leków i wyników jako kontekstu. Dostawcę AI wybierasz sam (własny klucz lub lokalnie). Niczego nie przelicza, czyta te same liczby co pulpit." }, "integrations": { "title": "Integracje", @@ -4803,15 +4854,15 @@ }, "export": { "title": "Raport dla lekarza", - "body": "Raport dla lekarza łączy pomiary, leki, wyniki laboratoryjne i badania w PDF lub FHIR na wizytę. Tu zbiegają się wszystkie moduły — pełny raport powstaje tylko z zadbanych danych." + "body": "Raport dla lekarza łączy pomiary, leki, wyniki laboratoryjne i badania w PDF lub FHIR na wizytę. Tu zbiegają się wszystkie moduły, więc pełny raport powstaje tylko z zadbanych danych." }, "achievements": { "title": "Osiągnięcia", - "body": "Osiągnięcia nagradzają konsekwencję — passy i kamienie milowe. Mała pętla motywacji, dzięki której łatwiej wytrwać." + "body": "Osiągnięcia nagradzają konsekwencję: passy i kamienie milowe. Mała pętla motywacji, dzięki której łatwiej wytrwać." }, "wrapUp": { "title": "To koniec przewodnika", - "body": "Ten przewodnik pokazuje się tylko raz — nie otworzy się ponownie sam. Wszystko, co pokazał, zostaje na miejscu, a funkcje możesz włączać i wyłączać w dowolnym momencie w Ustawienia → Moduły." + "body": "Ten przewodnik pokazuje się tylko raz i nie otworzy się ponownie sam. Wszystko, co pokazał, zostaje na miejscu, a funkcje możesz włączać i wyłączać w dowolnym momencie w Ustawienia → Moduły." } } }, @@ -4842,7 +4893,7 @@ "title": "Wszystko gotowe", "body": "Witaj na pokładzie. Pulpit czeka na Ciebie.", "returnCta": "Otwórz pulpit", - "learning": "Niektóre wnioski — glukoza, rytm snu, trendy ciśnienia, cykl — wyostrzają się w ciągu pierwszego tygodnia lub dwóch, w miarę gromadzenia danych. Puste kafelki oznaczają teraz tylko, że wciąż poznajemy Twój punkt odniesienia.", + "learning": "Niektóre wnioski (glukoza, rytm snu, trendy ciśnienia, cykl) wyostrzają się w ciągu pierwszego tygodnia lub dwóch, w miarę gromadzenia danych. Puste kafelki oznaczają teraz tylko, że wciąż poznajemy Twój punkt odniesienia.", "connectCta": "Połącz urządzenie", "logCta": "Zapisz pierwszy pomiar", "importCta": "Importuj istniejącą historię", @@ -4859,33 +4910,33 @@ "anamnesis": { "title": "O Twoim zdrowiu (opcjonalnie)", "subtitle": "Trochę kontekstu, aby HealthLog rzetelniej odczytywał Twoje wyniki.", - "intro": "Podziel się tym, czym chcesz — każde pole jest opcjonalne i możesz je zmienić w dowolnej chwili w Ustawieniach.", + "intro": "Podziel się tym, czym chcesz. Każde pole jest opcjonalne i możesz je zmienić w dowolnej chwili w Ustawieniach.", "conditionsLabel": "Aktualne schorzenia", "conditionsPlaceholder": "np. nadciśnienie, cukrzyca typu 2", "conditionsHint": "Pomaga HealthLog ujmować Twoje pomiary w kontekście, zamiast oznaczać to, czego należy się spodziewać.", "allergiesLabel": "Alergie lub nietolerancje", "allergiesPlaceholder": "np. penicylina, laktoza", - "privacyNote": "Przechowywane w postaci zaszyfrowanej i używane tylko po to, by dać Ci lepszy kontekst. Nie pytamy tu o leki — dodasz je w dowolnej chwili na stronie Leki." + "privacyNote": "Przechowywane w postaci zaszyfrowanej i używane tylko po to, by dać Ci lepszy kontekst. Nie pytamy tu o leki, dodasz je w dowolnej chwili na stronie Leki." }, "ai": { "panelTitle": "Zobacz, jak wyglądają insighty", - "panelIntro": "Coach, codzienny briefing i oceny dla poszczególnych metryk zamieniają Twoje własne liczby w prosty język. Oto jak to wygląda — zanim cokolwiek podłączysz.", + "panelIntro": "Coach, codzienny briefing i oceny dla poszczególnych metryk zamieniają Twoje własne liczby w prosty język. Oto jak to wygląda, zanim cokolwiek podłączysz.", "showSample": "Zobacz przykładowy briefing", "sampleTitle": "Codzienny briefing", "sampleTag": "Przykład", "sampleAriaLabel": "Przykładowy codzienny briefing", - "sampleBody": "Waga niższa o 1,2 kg w ciągu dwóch tygodni. Tętno spoczynkowe stabilne na 58 bpm. Ciśnienie w normie przez 6 z 7 dni — jedyny wysoki odczyt nastąpił po późnej, słonej kolacji.", - "sampleCaption": "Tylko przykład — to nie są Twoje dane. Tak czyta się briefing, gdy insighty są włączone.", + "sampleBody": "Waga niższa o 1,2 kg w ciągu dwóch tygodni. Tętno spoczynkowe stabilne na 58 bpm. Ciśnienie w normie przez 6 z 7 dni; jedyny wysoki odczyt nastąpił po późnej, słonej kolacji.", + "sampleCaption": "Tylko przykład, to nie są Twoje dane. Tak czyta się briefing, gdy insighty są włączone.", "ladderTitle": "Trzy sposoby, by to włączyć", - "ladderLocalTitle": "Model lokalny — zapytania do AI zostają w Twojej sieci", - "ladderLocalBody": "Skieruj HealthLog na Ollama lub LM Studio na własnej maszynie. Za darmo i w pełni prywatnie — spokojny wybór domyślny.", + "ladderLocalTitle": "Model lokalny: zapytania do AI zostają w Twojej sieci", + "ladderLocalBody": "Skieruj HealthLog na Ollama lub LM Studio na własnej maszynie. Za darmo i w pełni prywatnie: spokojny wybór domyślny.", "ladderByokTitle": "Użyj własnego klucza", "ladderByokBody": "Użyj własnego klucza OpenAI lub Anthropic. Działa na Twoim koncie i rozliczeniu, a poziom API domyślnie wyklucza Twoje dane z treningu.", "ladderOauthTitle": "Zaloguj się przez subskrypcję", - "ladderOauthBody": "Podłącz konto ChatGPT — bez kosztu za token, ale warunki poziomu konsumenckiego mogą wykorzystywać treści do treningu, więc to świadomy wybór, nigdy domyślny dla danych o zdrowiu.", - "keylessLine": "HealthLog jest w pełni użyteczny bez AI. Twoje kafelki, wykresy, trendy i eksporty działają — AI dokłada tylko odczyt prostym językiem.", + "ladderOauthBody": "Podłącz konto ChatGPT. Bez kosztu za token, ale warunki poziomu konsumenckiego mogą wykorzystywać treści do treningu, więc to świadomy wybór, nigdy domyślny dla danych o zdrowiu.", + "keylessLine": "HealthLog jest w pełni użyteczny bez AI. Twoje kafelki, wykresy, trendy i eksporty działają; AI dokłada tylko odczyt prostym językiem.", "setupCta": "Skonfiguruj (około minuty)", - "sharedKeyNote": "Ta instancja udostępnia już wspólnego dostawcę AI, więc insighty działają dla Ciebie od razu — bez konfiguracji.", + "sharedKeyNote": "Ta instancja udostępnia już wspólnego dostawcę AI, więc insighty działają dla Ciebie od razu, bez konfiguracji.", "offer": { "title": "Ta instancja potrafi już napisać twoje podsumowanie zwykłym językiem.", "body": "Operator udostępnia dostawcę, który działał po raz ostatni w ciągu ostatnich 24 godzin. Jedno dotknięcie włącza zwykły język; twoje dane trafią wtedy do tego jednego dostawcy i do nikogo więcej.", @@ -5102,7 +5153,7 @@ "profileDescription": "Wzrost, data urodzenia i płeć odblokowują spersonalizowane cele.", "profileCta": "Otwórz profil", "measurementTitle": "Zarejestruj pierwszy pomiar", - "measurementDescription": "Jeden pomiar aktywuje kafelek — trend rysuje się od 5. pomiaru.", + "measurementDescription": "Jeden pomiar aktywuje kafelek, a trend rysuje się od 5. pomiaru.", "measurementCta": "Dodaj pomiar", "medicationTitle": "Dodaj lek", "medicationDescription": "Śledź przestrzeganie i otrzymuj przypomnienia dokładnie wtedy, kiedy ich potrzebujesz.", @@ -5111,7 +5162,7 @@ "dataSourceDescription": "Automatycznie synchronizuj wagę, ciśnienie, sen i więcej z Withings, WHOOP, Oura, Polar, Nightscout, Fitbit lub Apple Health.", "dataSourceCta": "Połącz", "notificationsTitle": "Skonfiguruj powiadomienia", - "notificationsDescription": "Wybierz, jak HealthLog ma ci przypominać — Telegram, ntfy lub Web Push.", + "notificationsDescription": "Wybierz, jak HealthLog ma ci przypominać: Telegram, ntfy lub Web Push.", "notificationsCta": "Otwórz powiadomienia", "insightsTitle": "Włącz insighty (opcjonalnie)", "insightsDescription": "Dodaj model lokalny lub własny klucz AI, aby odblokować codzienny briefing i Coach.", @@ -5259,12 +5310,10 @@ "sections": { "account": { "title": "Konto", - "description": "Profil, hasło, klucze dostępu i przewodnik wprowadzający.", "subtitle": "Twój profil, tożsamość i dane logowania." }, "integrations": { "title": "Integracje", - "description": "Połączone usługi, kanały dostarczania i priorytet źródeł w jednym miejscu.", "subtitle": "Połącz i zarządzaj źródłami danych oraz urządzeniami.", "connectionsHeading": "Połączenia", "channelsHeading": "Kanały dostarczania", @@ -5273,16 +5322,14 @@ }, "notifications": { "title": "Powiadomienia", - "description": "Wszystkie przypomnienia w jednym miejscu — co i kiedy oraz gdzie są dostarczane.", "subtitle": "Wybierz, które przypomnienia otrzymujesz i gdzie są dostarczane.", "remindersHeading": "Przypomnienia", "matrixLink": "Powiadomienia wg kanału", "channelsLink": "Kanały dostarczania" }, "layout": { - "title": "Wygląd", - "description": "Ustal, jak wyglądają i są rozłożone pulpit, analizy i Twoje moduły śledzenia.", - "backToHub": "Powrót do Wygląd", + "title": "Układ", + "backToHub": "Powrót do układu", "dashboard": { "title": "Pulpit", "description": "Układ i kolejność kafelków." @@ -5291,7 +5338,7 @@ "title": "Analizy", "description": "Kolejność przeglądu i pigułek nawigacyjnych." }, - "subtitle": "Dostosuj wygląd pulpitu, analiz i modułów śledzenia.", + "subtitle": "Wybierz, co pokazują pulpit, analizy i moduły śledzenia oraz w jakiej kolejności.", "medications": { "title": "Leki", "description": "Widok listy i ręczna kolejność." @@ -5311,6 +5358,10 @@ "mood": { "title": "Tagi nastroju", "description": "Grupy, tagi i zarchiwizowane tagi w wyborze nastroju." + }, + "documents": { + "title": "Dokumenty", + "description": "Widok i układ miesięcy." } }, "dashboard": { @@ -5318,15 +5369,13 @@ }, "thresholds": { "title": "Cele", - "description": "Dla każdej metryki: własny zakres docelowy zdrowych wartości.", "subtitle": "Ustaw osobiste zakresy docelowe dla każdej metryki." }, "sources": { "title": "Priorytet źródeł", - "description": "Wybierz, które źródło wygrywa, gdy kilka rejestruje tę samą metrykę tego samego dnia.", "integrationsHint": "Źródła danych podłączysz lub odłączysz na stronie integracji.", "integrationsHintLink": "Otwórz integracje", - "cardTitle": "Priorytet źródeł według metryki", + "cardTitle": "Kolejność według metryki", "help": "Gdy kilka źródeł dostarcza tę samą wartość tego samego dnia, źródło na górze liczy się jako kanoniczne.", "resetDefaults": "Przywróć wartości domyślne", "resetDefaultsTitle": "Zresetować kolejność źródeł?", @@ -5339,7 +5388,7 @@ "resetSuccess": "Przywrócono domyślny priorytet źródeł", "perMetricToggle": "Ustawienia dla poszczególnych metryk ({count})", "perMetricHelp": "Metryki dostosowane względem domyślnej kolejności. Użyj tej listy, aby przywrócić pojedynczą metrykę do wartości domyślnej, nie zmieniając pozostałych.", - "perMetricEmpty": "Brak indywidualnych ustawień — każda metryka korzysta z globalnej kolejności powyżej.", + "perMetricEmpty": "Brak indywidualnych ustawień. Każda metryka korzysta z globalnej kolejności powyżej.", "resetMetric": "Resetuj", "deviceTypeToggle": "Priorytet według typu urządzenia ({count})", "deviceTypeHelp": "W obrębie źródła decyduje, które urządzenie wygrywa. Gdy Apple Watch i iPhone rejestrują kroki, wyżej sklasyfikowany typ urządzenia liczy się jako odczyt kanoniczny.", @@ -5364,7 +5413,8 @@ "respiratoryRate": "Częstość oddechów", "recovery": "Regeneracja", "stress": "Stres", - "sleepDebt": "Dług snu" + "sleepDebt": "Dług snu", + "hrvRmssd": "HRV (RMSSD)" }, "sourceLabels": { "WITHINGS": "Withings", @@ -5381,7 +5431,8 @@ "MCP": "MCP", "GOOGLE_HEALTH": "Google Health", "STRAVA": "Strava", - "EXTERNAL": "Urządzenie zewnętrzne" + "EXTERNAL": "Urządzenie zewnętrzne", + "HEALTH_CONNECT": "Health Connect" }, "deviceLabels": { "watch": "Zegarek", @@ -5401,28 +5452,25 @@ "daysWithData": "{count} z {days} dni", "moduleHint": "Synchronizacją steruje moduł spożycia składników odżywczych.", "moduleHintLink": "Zarządzaj modułami", - "detail": "Tylko do odczytu — wartości należą do aplikacji źródłowej." + "detail": "Tylko do odczytu: wartości należą do aplikacji źródłowej." }, - "helpDetail": "Pozostałe pozostają zapisane na potrzeby audytu." + "helpDetail": "Pozostałe pozostają zapisane na potrzeby audytu. Widać tylko źródła, które połączono lub z których są dane.", + "noneInUse": "Nie ma jeszcze źródeł do uszeregowania. Połącz jedno w Integracjach albo wpisz wartość." }, "ai": { "title": "Dostawca AI", - "description": "Dostawca, model i klucz API do analiz, a także Twój kontekst „o mnie”.", "subtitle": "Wybierz dostawcę AI i użyj własnego klucza." }, "api": { - "title": "API & Tokens", - "description": "Tokeny Bearer dla własnych skryptów i aplikacji — zapisuj pomiary lub przyjęcia leków z dowolnego miejsca.", + "title": "API i tokeny", "subtitle": "Zarządzaj punktami końcowymi API i tokenami dostępu." }, "advanced": { "title": "Zaawansowane", - "description": "Wyjście awaryjne: trwale usuń wszystkie dane zdrowotne albo zamknij konto.", "subtitle": "Reset danych i usunięcie konta." }, "export": { "title": "Eksport i import", - "description": "Pobierz swoje dane jako CSV lub pełną kopię zapasową JSON albo zaimportuj z Apple Health, JSON lub CSV. Raport dla lekarza znajdziesz teraz w Dokumentacji zdrowotnej.", "otherOptionsHeading": "Inne opcje eksportu", "downloadFailed": "Nie udało się pobrać eksportu. Spróbuj ponownie.", "actions": { @@ -5449,12 +5497,12 @@ "fullBackup": { "title": "Pełna kopia zapasowa", "description": "Cała dokumentacja w jednym pliku JSON.", - "scopeNote": "Metadane dokumentów oraz podsumowanie AI, jeśli istnieje, są uwzględnione jako czysty tekst; oryginalne pliki dokumentów oraz dane tras GPS/próbek treningów nie są uwzględnione — pobierz je osobno z archiwum dokumentów lub eksportu treningów.", + "scopeNote": "Metadane dokumentów oraz podsumowanie AI, jeśli istnieje, są uwzględnione jako czysty tekst; oryginalne pliki dokumentów oraz dane tras GPS/próbek treningów nie są uwzględnione. Pobierz je osobno z archiwum dokumentów lub eksportu treningów.", "encryptToggle": "Zaszyfruj hasłem-frazą", "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.", + "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.", "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." } }, @@ -5469,7 +5517,7 @@ "dropHint": "lub kliknij, aby wybrać plik", "fileInputLabel": "Wybierz plik export.zip z Apple Health", "choose": "Wybierz plik", - "idempotencyNote": "Ponowne przesłanie tego samego archiwum scala dane — nie tworzy duplikatów. Duże archiwa są przetwarzane w tle.", + "idempotencyNote": "Ponowne przesłanie tego samego archiwum scala dane i nie tworzy duplikatów. Duże archiwa są przetwarzane w tle.", "uploading": "Przesyłanie…", "uploadFailed": "Przesyłanie nie powiodło się. Spróbuj ponownie.", "rateLimited": "Zbyt wiele przesłań. Poczekaj chwilę i spróbuj ponownie.", @@ -5478,7 +5526,7 @@ "failureInterrupted": "Import został przerwany przed zakończeniem, zwykle przez restart serwera lub brak pamięci. Dane są bezpieczne; prześlij archiwum ponownie, aby spróbować jeszcze raz.", "failureInsufficientMemory": "Limit pamięci serwera jest zbyt niski dla archiwum tej wielkości. Poproś osobę administrującą serwerem o zwiększenie limitu pamięci Node.js (NODE_OPTIONS=--max-old-space-size), a następnie prześlij archiwum ponownie.", "rowsImported": "Zaimportowano dotąd {count} wierszy", - "doneSummary": "Gotowe — zaimportowano {imported} z {read} rekordów, pominięto {skipped} rekordów klinicznych.", + "doneSummary": "Gotowe: zaimportowano {imported} z {read} rekordów, pominięto {skipped} rekordów klinicznych.", "doneNothing": "Z tego archiwum nic nie zaimportowano: dotarło 0 z {read} rekordów.", "writtenByHealthLogOne": "Pominięto {count} wpis, który HealthLog sam zapisał w Apple Health, ponieważ jest już w Twoich danych.", "writtenByHealthLogOther": "Pominięto {count} wpisów, które HealthLog sam zapisał w Apple Health, ponieważ są już w Twoich danych.", @@ -5491,12 +5539,12 @@ "parsing": "Odczytywanie eksportu…", "upserting": "Importowanie rekordów…" }, - "skipUnsupported": "{count} próbek pominięto — typy danych, których HealthLog nie importuje.", + "skipUnsupported": "Pominięto {count} próbek: typy danych, których HealthLog nie importuje.", "skipRefused": "{count} próbek nie udało się zapisać (nieczytelne, poza zakresem lub zapis się nie powiódł).", "skipBreakdown": "Pokaż zestawienie ({count} typów)", "skipBreakdownMore": "+ {count} kolejnych typów", "cycleSummary": "{samples} próbek cyklu połączono w {days} dzienników dziennych.", - "cycleModuleOff": "{count} próbek cyklu pominięto — śledzenie cyklu jest wyłączone dla tego konta.", + "cycleModuleOff": "Pominięto {count} próbek cyklu, bo śledzenie cyklu jest wyłączone dla tego konta.", "cycleDaysFailed": "{count} dni cyklu nie udało się zapisać: {reason}" }, "json": { @@ -5581,8 +5629,8 @@ "failed": "Import nie udał się. Nic nie zostało zapisane.", "columnsHeading": "Co się dzieje z każdą kolumną", "jsonOnly": "tylko JSON", - "previewLine": "Odczytane wiersze: {rows} · do zaimportowania: {queued} · odrzucone: {refused}", - "submittedLine": "Odczytane wiersze: {rows} · w kolejce do importu: {queued}", + "previewLine": "Odczytane wiersze: {rows}, do zaimportowania: {queued}, odrzucone: {refused}", + "submittedLine": "Odczytane wiersze: {rows}, w kolejce do importu: {queued}", "unmatched": "Żaden zapisany lek nie odpowiada: {names}", "ambiguous": "Odpowiada więcej niż jednemu lekowi: {names}", "mirrored": "Skopiowany z innej aplikacji, więc nie można dodać dawki: {names}", @@ -5618,18 +5666,59 @@ "tooManyRows": "Ten plik ma więcej wierszy, niż może przyjąć jeden import. Podziel go i zaimportuj części." }, "detail": "Jeden plik obejmuje wszystkie leki, które zawiera." + }, + "healthConnect": { + "title": "Eksport z Health Connect", + "description": "Zaimportuj plik ZIP, który aplikacja Health Connect na Androidzie tworzy przy eksporcie danych.", + "choose": "Wybierz plik ZIP", + "uploading": "Przesyłanie…", + "uploadFailed": "Przesyłanie nie powiodło się. Spróbuj ponownie.", + "tooLarge": "Plik jest większy, niż przyjmuje ten serwer.", + "doneSummary": "Zaimportowano wartości: {count}.", + "failed": "Import nie powiódł się: {reason}", + "phase": { + "queued": "Czeka na start", + "unpacking": "Rozpakowywanie pliku", + "parsing": "Odczytywanie wpisów", + "upserting": "Zapisywanie wartości", + "done": "Gotowe", + "failed": "Niepowodzenie" + }, + "steps": { + "export": "Na telefonie z Androidem otwórz Health Connect, przejdź do Zarządzaj danymi, potem Eksport i import, i rozpocznij eksport.", + "cloud": "Health Connect zapisuje eksport tylko w aplikacji chmurowej, takiej jak Google Drive, Dropbox czy Nextcloud, nigdy w samym telefonie. Wybierz tam jedną z nich.", + "upload": "Pobierz plik ZIP z tej chmury na to urządzenie i prześlij go tutaj." + }, + "dropLabel": "Upuść tutaj plik ZIP z Health Connect", + "dropHint": "lub kliknij, aby go wybrać. Do 1 GB.", + "fileInputLabel": "Plik ZIP eksportu Health Connect", + "privacyNote": "Plik jest usuwany z serwera zaraz po zakończeniu importu. Dane z aplikacji, które łączysz już bezpośrednio, np. Withings lub Fitbit, są pomijane, żeby nic nie trafiło podwójnie. Ponowne przesłanie tego samego pliku niczego nie zmienia.", + "rateLimited": "Za dużo przesyłań w krótkim czasie. Odczekaj minutę i spróbuj ponownie.", + "busy": "Dla Twojego konta trwa już import. Poczekaj, aż się zakończy.", + "rowsSoFar": "Zapisane wartości do tej pory: {count}", + "doneNothing": "Import się zakończył, ale nie było nic nowego do dodania.", + "workouts": "Treningi", + "leftOutConnected": "Pominięte wpisy z aplikacji, które łączysz już bezpośrednio: {count}.", + "nutrientsModuleOff": "Sumy wody i składników odżywczych nie zostały zaimportowane, bo moduł Składniki odżywcze jest wyłączony.", + "cycleModuleOff": "Dane o miesiączce nie zostały zaimportowane, bo śledzenie cyklu jest wyłączone.", + "failure": { + "unsupportedVersion": "Ten eksport Health Connect jest zbyt stary, aby go odczytać. Zaktualizuj Health Connect na telefonie i wyeksportuj ponownie.", + "notHealthConnect": "Ten plik nie jest eksportem Health Connect. Wybierz plik ZIP zapisany przez Health Connect.", + "unsafeSchema": "Ten plik nie ma struktury eksportu Health Connect i nie został odczytany.", + "tooLarge": "Eksport jest większy, niż ten serwer przyjmuje.", + "stagingMissing": "Przesłanego pliku nie było już na serwerze. Prześlij go ponownie.", + "interrupted": "Import został przerwany przez ponowne uruchomienie serwera. Prześlij plik ponownie." + } } }, "subtitle": "Wyeksportuj dane lub zaimportuj z kopii zapasowej." }, "about": { "title": "O tej aplikacji", - "description": "Wersja, licencja, linki.", "subtitle": "Wersja, licencja i linki do projektu." }, "sharing": { "title": "Udostępnianie", - "description": "Utwórz link tylko do odczytu, aby udostępnić swoją dokumentację zdrowotną lekarzowi.", "subtitle": "Udostępnij dostęp tylko do odczytu do swoich danych." }, "insights": { @@ -5656,7 +5745,7 @@ "error": "Nie udało się zapisać. Spróbuj ponownie.", "toggleable": { "title": "Co śledzisz", - "description": "Wyłączone moduły znikają z nawigacji, kafelków i analiz — Twoje dane pozostają nienaruszone." + "description": "Wyłączone moduły znikają z nawigacji, kafelków i analiz, a Twoje dane pozostają nienaruszone." }, "manageIn": "Zarządzaj w {section}", "operatorDisabled": "Wyłączone na całym serwerze", @@ -5664,12 +5753,10 @@ }, "gesundheitsakte": { "title": "Dokumentacja zdrowotna", - "description": "Wyeksportuj pełną dokumentację zdrowotną jako PDF, FHIR R4 lub łączony pakiet ZIP.", "subtitle": "Eksportuj kompletną dokumentację zdrowotną gotową dla lekarza." }, "coach": { "title": "Coach", - "description": "Włącz Coacha, ton i zakres, zapisane wspomnienia.", "subtitle": "Dostosuj Coacha, jego proaktywne podpowiedzi i pamięć." }, "security": { @@ -5678,8 +5765,7 @@ }, "privacy": { "title": "Dane i prywatność", - "subtitle": "Twoje dane, retencja, szyfrowanie i bezpieczeństwo konta w jednym miejscu.", - "description": "Eksport, usuwanie, retencja, stan szyfrowania, sesje i aktywność zabezpieczeń." + "subtitle": "Twoje dane, retencja, szyfrowanie i bezpieczeństwo konta w jednym miejscu." }, "mcp": { "title": "Łącznik MCP", @@ -5687,8 +5773,7 @@ }, "anamnesis": { "title": "Wywiad medyczny", - "subtitle": "Zarządzaj swoimi alergiami i wywiadem rodzinnym.", - "description": "Ustrukturyzowane rekordy alergii i wywiadu rodzinnego." + "subtitle": "Twoja historia zdrowia, styl życia, dane alarmowe i to, co może czytać AI." }, "environment": { "title": "Lokalizacja i pogoda", @@ -5710,7 +5795,7 @@ "storedDays": "Zapisano dni pogody: {count}.", "home": { "title": "Lokalizacja domowa", - "description": "Przybliżona lokalizacja na poziomie miasta – nigdy dokładny ślad. Pogoda i światło dzienne są pobierane dla tego miejsca w dni bez okresu lokalizacji, od dnia jego ustawienia.", + "description": "Przybliżona lokalizacja na poziomie miasta, nigdy dokładny ślad. Pogoda i światło dzienne są pobierane dla tego miejsca w dni bez okresu lokalizacji, od dnia jego ustawienia.", "current": "Obecnie: {label}", "none": "Nie ustawiono jeszcze lokalizacji domowej.", "since": "Pogoda zapisywana od {date}. Wcześniejsze dni pozostają puste, dopóki nie dodasz okresu lokalizacji." @@ -5722,7 +5807,8 @@ "add": "Dodaj {label}", "remove": "Usuń okres lokalizacji", "removeConfirmTitle": "Usunąć tę podróż?", - "removeConfirmBody": "Pomiary z tego okresu pozostają w historii; znika tylko wpis podróży." + "removeConfirmBody": "Pomiary z tego okresu pozostają w historii; znika tylko wpis podróży.", + "range": "Od {start} do {end}" }, "backfill": { "title": "Uzupełnij minione dni", @@ -5732,7 +5818,21 @@ "noResults": "Nie znaleziono pasujących miejsc.", "fetchFailedOne": "Ostatnia aktualizacja pogody w tle nie powiodła się ({when}).", "fetchFailedFew": "Ostatnie {count} aktualizacje pogody w tle nie powiodły się, najnowsza {when}.", - "fetchFailedOther": "Ostatnich {count} aktualizacji pogody w tle nie powiodło się, najnowsza {when}." + "fetchFailedOther": "Ostatnich {count} aktualizacji pogody w tle nie powiodło się, najnowsza {when}.", + "airQuality": { + "title": "Jakość powietrza i pyłki", + "description": "Codzienna jakość powietrza, pyłki i UV dla twojej lokalizacji, z modelu CAMS przez Open-Meteo.", + "toggle": "Uwzględniaj jakość powietrza i pyłki", + "operatorDisabled": "Jakość powietrza jest wyłączona na tym serwerze.", + "coverage": "Dni z danymi o jakości powietrza: {count}", + "pollenEuropeOnly": "Dane o pyłkach obejmują tylko Europę.", + "historyProgress": "Uzupełnianie jakości powietrza: {done} z {total} dni", + "historyComplete": "Jakość powietrza uzupełniono dla wszystkich wcześniejszych dni z wpisami, które obejmuje źródło ({total} dni). Starsze dni pozostają puste.", + "attribution": "Źródła danych i licencje", + "saveError": "Nie udało się zapisać ustawienia.", + "latest": "Najnowszy dzień ({date})", + "modelNote": "Modelowane wartości zewnętrzne dla twojej okolicy, nie twoja osobista ekspozycja." + } }, "score": { "title": "Wynik zdrowia", @@ -5765,7 +5865,7 @@ "omitted": "{count} filarów nie ma na liście, bo ich śledzenie jest wyłączone. Włącz moduł z powrotem, a jego filar wróci.", "omittedLink": "Otwórz moduły", "refusal": { - "empty": "Wynik zdrowia potrzebuje co najmniej jednego obszaru zdrowia do oceny. Zostaw wybrany przynajmniej jeden — wynik sam powie, na których obszarach się opiera." + "empty": "Wynik zdrowia potrzebuje co najmniej jednego obszaru zdrowia do oceny. Zostaw wybrany przynajmniej jeden, a wynik sam powie, na których obszarach się opiera." }, "notice": { "upgrade": { @@ -5859,7 +5959,6 @@ }, "changePassword": "Zmień hasło", "changePasswordDescription": "Zastąp obecne hasło nowym.", - "passwordReset": "Resetowanie hasła", "currentPassword": "Obecne hasło", "newPassword": "Nowe hasło", "confirmNewPassword": "Potwierdź nowe hasło", @@ -5904,9 +6003,9 @@ "botToken": "Token bota", "chatId": "ID czatu", "enableNotifications": "Włącz powiadomienia", - "telegramStep1": "1. Utwórz bota przez @BotFather w Telegramie i skopiuj token.", - "telegramStep2": "2. Wyślij /start do swojego bota, aby aktywować czat.", - "telegramStep3": "3. Znajdź swoje ID czatu przez @userinfobot lub API bota.", + "telegramStep1": "Utwórz bota przez @BotFather w Telegramie i skopiuj token.", + "telegramStep2": "Wyślij /start do swojego bota, aby aktywować czat.", + "telegramStep3": "Znajdź swoje ID czatu przez @userinfobot lub API bota.", "ntfy": "ntfy", "ntfyDescription": "Powiadomienia przez ntfy (własny serwer lub ntfy.sh).", "ntfyEnable": "Włącz ntfy", @@ -5915,7 +6014,7 @@ "ntfyAuthToken": "Token uwierzytelniania (opcjonalny)", "ntfyAuthTokenHint": "Wymagany tylko dla prywatnych tematów z kontrolą dostępu.", "webhook": "Webhook", - "webhookDescription": "Wysyłaj powiadomienia metodą POST na dowolny URL — Gotify, Discord, Slack, Matrix lub Home Assistant.", + "webhookDescription": "Powiadomienia metodą POST na dowolny URL: Discord, Slack, Gotify, Home Assistant, n8n lub przekaźnik przyjmujący JSON.", "webhookEnable": "Włącz webhook", "webhookUrl": "Adres URL webhooka", "webhookUrlHint": "Pełny adres URL dla żądania POST. Adres w sieci prywatnej musi zatwierdzić administrator serwera.", @@ -5923,6 +6022,7 @@ "webhookHeaderValue": "Wartość nagłówka (opcjonalnie)", "webhookFormat": "Format wiadomości", "webhookFormatGeneric": "Ogólny JSON", + "webhookFormatGenericHint": "Wklej adres webhooka Discorda lub Slacka bez zmian. Home Assistant i n8n odczytują pola title, message, eventType i priority.", "webhookFormatGotify": "Gotify", "webhookFormatGotifyHint": "Użyj adresu URL kończącego się na /message, a token aplikacji wpisz w nagłówku X-Gotify-Key.", "email": "E-mail", @@ -5939,7 +6039,6 @@ "webPushSubscribed": "Powiadomienia push włączone!", "webPushUnsubscribed": "Powiadomienia push wyłączone.", "webPushSubscribeFailed": "Aktywacja nie powiodła się", - "kiInsightsDescription": "Opcjonalnie: zapisz swój klucz OpenAI, aby uzyskać codzienne, automatyczne analizy w Insights.", "codexConnected": "ChatGPT połączony pomyślnie! Insights jest teraz aktywny.", "codexDisconnected": "Połączenie z ChatGPT usunięte.", "codexConnectionFailed": "Połączenie z ChatGPT nie powiodło się. Spróbuj ponownie.", @@ -5949,7 +6048,7 @@ "rawDataWarning": "Szczegółowa historia jest włączona: każda analiza wysyła dodatkowo średnie dzienne z ostatnich 90 dni, średnie tygodniowe z roku wcześniej i średnie miesięczne do 5 lat wstecz.", "regenerateInsights": "Wygeneruj raporty ponownie", "regenerateSuccess": "Raporty wygenerowane ponownie", - "regenerateRateLimit": "Poczekaj — osiągnięto godzinny limit analiz.", + "regenerateRateLimit": "Poczekaj, osiągnięto godzinny limit analiz.", "lastGeneratedAt": "Ostatnio wygenerowano", "ai": { "chatgptConnectedBadge": "ChatGPT połączony", @@ -5959,11 +6058,11 @@ "deviceCodeHeading": "Dokończ łączenie na chatgpt.com", "deviceCodeStep1": "Otwórz ten link na dowolnym urządzeniu:", "deviceCodeStep2": "Wprowadź ten jednorazowy kod:", - "deviceCodeStep3": "Zatwierdź połączenie — ta strona zaktualizuje się automatycznie.", + "deviceCodeStep3": "Zatwierdź połączenie. Ta strona zaktualizuje się automatycznie.", "deviceCodeCopy": "Kopiuj", "deviceCodeWaiting": "Oczekiwanie na zatwierdzenie…", "deviceCodeCancel": "Anuluj", - "oauthNotConfigured": "OAuth ChatGPT nie jest skonfigurowany w tej instancji — użyj zamiast tego własnego klucza API poniżej.", + "oauthNotConfigured": "OAuth ChatGPT nie jest skonfigurowany w tej instancji. Użyj zamiast tego własnego klucza API poniżej.", "modelLabel": "Model", "modelOptionDefault": "— Domyślny —", "modelOptionCustom": "Niestandardowy…", @@ -5971,7 +6070,7 @@ "anthropicKeyLabel": "Klucz API Anthropic", "baseUrlLabel": "Base URL", "localKeyLabel": "Klucz API (opcjonalny)", - "localDescription": "Działa tu każdy punkt końcowy zgodny z OpenAI — model lokalny (Ollama, LM Studio) albo brama taka jak LiteLLM, OpenRouter czy vLLM. Adres bazowy kończy się na /v1; klucz API jest opcjonalny.", + "localDescription": "Działa tu każdy punkt końcowy zgodny z OpenAI: model lokalny (Ollama, LM Studio) albo brama taka jak LiteLLM, OpenRouter czy vLLM. Adres bazowy kończy się na /v1; klucz API jest opcjonalny.", "savedPreview": "(zapisano {preview})", "savedShort": "(zapisano)", "saveCta": "Zapisz", @@ -5987,7 +6086,7 @@ "admin-openai": "OpenAI administratora" }, "title": "Łańcuch zapasowy", - "description": "Jeśli główny dostawca zawiedzie, HealthLog przechodzi przez łańcuch po kolei. Przeciągaj wiersze, aby zmienić kolejność; użyj przełącznika, aby wyłączyć dostawcę bez usuwania go.", + "description": "Gdy aktywny dostawca zawiedzie, HealthLog próbuje kolejnego włączonego w tej kolejności.", "moveUp": "Przesuń w górę", "moveDown": "Przesuń w dół", "removeFromChain": "Usuń z łańcucha", @@ -6000,11 +6099,8 @@ "resetConfirmTitle": "Przywrócić domyślny łańcuch?", "resetConfirmBody": "Łańcuch wraca do Codex → OpenAI → Anthropic → Lokalny → OpenAI administratora. Zapisane dane logowania pozostają nienaruszone." }, - "providerCardTitle": "Dostawca AI", "activeProviderHeading": "Aktywny dostawca", - "activeProviderBody": "Wybierz dostawcę, którego chcesz użyć jako pierwszego. Formularz poniżej konfiguruje tylko wybranego dostawcę; łańcuch zapasowy na dole decyduje, co się stanie w razie awarii.", "activeProviderLabel": "Główny dostawca", - "providerConfigTitle": "Konfiguracja dostawcy", "providerSelect": { "codex": "Konto ChatGPT (Codex)", "openai": "OpenAI (Twój klucz API)", @@ -6014,7 +6110,7 @@ "admin-openai": "OpenAI udostępniony przez administratora" }, "compat": { - "description": "Pełnoprawny dostawca dla każdego punktu końcowego mówiącego API /v1/chat/completions OpenAI: LiteLLM, OpenRouter, vLLM lub inna brama. Adres bazowy kończy się na /v1, klucz API jest opcjonalny, a model to ten, który kieruje twoja brama. Twój klucz OpenAI nigdy nie trafia do tego punktu końcowego — ten dostawca ma własny.", + "description": "Pełnoprawny dostawca dla każdego punktu końcowego mówiącego API /v1/chat/completions OpenAI: LiteLLM, OpenRouter, vLLM lub inna brama. Adres bazowy kończy się na /v1, klucz API jest opcjonalny, a model to ten, który kieruje twoja brama. Twój klucz OpenAI nigdy nie trafia do tego punktu końcowego, bo ten dostawca ma własny.", "keyLabel": "Klucz API (opcjonalny)", "modelHint": "Nazwa modelu, której oczekuje twoja brama. Pozostawione puste, użyty zostanie model zapisany dla pozostałych dostawców; gdy nie ustawiono żadnego, ten dostawca jest pomijany." }, @@ -6050,18 +6146,18 @@ "notConfigured": "Operator nie skonfigurował współdzielonego klucza OpenAI w tej instancji." }, "testProvider": "Przetestuj aktywnego dostawcę", - "testSuccess": "OK — {provider} ({model})", + "testSuccess": "OK: {provider} ({model})", "testFailedShort": "Test nie powiódł się: {message}", - "testUnexpectedResponse": "Połączenie z dostawcą AI nie powiodło się — nieoczekiwana odpowiedź serwera.", - "testReasonCredentials": "Dostawca odrzucił dane logowania — uwierzytelnij się ponownie w ustawieniach AI.", - "testReasonRateLimited": "Dostawca ograniczył żądanie — spróbuj ponownie za chwilę.", + "testUnexpectedResponse": "Połączenie z dostawcą AI nie powiodło się: nieoczekiwana odpowiedź serwera.", + "testReasonCredentials": "Dostawca odrzucił dane logowania. Uwierzytelnij się ponownie w ustawieniach AI.", + "testReasonRateLimited": "Dostawca ograniczył żądanie. Spróbuj ponownie za chwilę.", "testReasonServerError": "Dostawca AI zwrócił błąd serwera.", - "testReasonBadRequest": "Dostawca odrzucił żądanie (HTTP {status}) — punkt końcowy odpowiedział, więc problem dotyczy formatu żądania lub nazwy modelu, a nie łączności.", + "testReasonBadRequest": "Dostawca odrzucił żądanie (HTTP {status}). Punkt końcowy odpowiedział, więc problem dotyczy formatu żądania lub nazwy modelu, a nie łączności.", "testReasonUnreachable": "Nie udało się połączyć z dostawcą AI.", "coachMemory": { "title": "Co Coach pamięta", "description": "Trwałe informacje, które przekazałeś Coachowi.", - "empty": "Coach nie zapisał jeszcze żadnych informacji. Podczas rozmowy zapamiętuje trwałe szczegóły, którymi się dzielisz — Twoje cele, dolegliwości, preferencje — i możesz je tutaj w każdej chwili usunąć.", + "empty": "Coach nie zapisał jeszcze żadnych informacji. Podczas rozmowy zapamiętuje trwałe szczegóły, którymi się dzielisz (Twoje cele, dolegliwości, preferencje), i możesz je tutaj w każdej chwili usunąć.", "learnedPrefix": "zapamiętano {when}", "categoryPreference": "Preferencje", "categoryCondition": "Dolegliwości", @@ -6077,7 +6173,7 @@ "forgetAllConfirmBody": "Spowoduje to usunięcie wszystkich zapisanych informacji. Coach zacznie od nowa przy następnej rozmowie. Tej operacji nie można cofnąć.", "forgetAllConfirmAction": "Zapomnij wszystko", "cancel": "Anuluj", - "summaryNote": "Coach może też przechowywać krótkie, bieżące podsumowanie każdej rozmowy. Nie ma dla niego osobnej kontrolki — usunięcie rozmowy usuwa jej podsumowanie.", + "summaryNote": "Coach może też przechowywać krótkie, bieżące podsumowanie każdej rozmowy. Nie ma dla niego osobnej kontrolki: usunięcie rozmowy usuwa jej podsumowanie.", "loadError": "Nie udało się wczytać tego, co Coach pamięta.", "forgotToast": "Informacja zapomniana.", "forgotError": "Nie udało się zapomnieć tej informacji. Spróbuj ponownie.", @@ -6098,7 +6194,7 @@ "markDone": "Gotowe", "dismiss": "Odrzuć", "deleteAria": "Usuń to przypomnienie", - "note": "Przypomnienia są przechowywane w postaci zaszyfrowanej. Usuń je w dowolnej chwili — znika z tego, co widzi Coach.", + "note": "Przypomnienia są przechowywane w postaci zaszyfrowanej. Usuń je w dowolnej chwili, a znikną z tego, co widzi Coach.", "status": { "proposed": "Sugerowane", "active": "Zapisane", @@ -6144,20 +6240,20 @@ "autoRead": { "title": "Automatyczne czytanie dokumentów przez AI", "subLabel": "Dokumenty są czytane i indeksowane przez skonfigurowanego dostawcę AI bez potwierdzania każdego dokumentu.", - "honesty": "Po włączeniu każdy przesłany dokument jest wysyłany do skonfigurowanego dostawcy AI, aby go odczytać, opisać i zaindeksować do wyszukiwania — bez potwierdzania każdego dokumentu. Włączenie działa też wstecz: dokumenty już przesłane, które nie mają jeszcze streszczenia, również zostaną wysłane — do 200 przy każdym włączeniu. Dzieje się to na serwerze, a treść dokumentu opuszcza to urządzenie i trafia do tego dostawcy. Jeśli skonfigurowany dostawca to połączenie subskrypcyjne (zalogowane konto AI zamiast klucza API), obowiązują jego własne ustawienia danych — dostawcy subskrypcyjni mogą wykorzystywać treść do ulepszania swoich modeli i nie obejmuje tego żadna umowa powierzenia przetwarzania danych. Dokumenty odczytywane przez samodzielnie hostowany model lokalny nigdy nie opuszczają Twojego serwera. Domyślnie wyłączone; włącz je tylko wtedy, gdy akceptujesz ten kompromis dla swoich dokumentów.", - "confirm": "Rozumiem — włącz", + "honesty": "Po włączeniu każdy przesłany dokument jest wysyłany do skonfigurowanego dostawcy AI, aby go odczytać, opisać i zaindeksować do wyszukiwania, bez potwierdzania każdego dokumentu. Włączenie działa też wstecz: dokumenty już przesłane, które nie mają jeszcze streszczenia, również zostaną wysłane (do 200 przy każdym włączeniu). Dzieje się to na serwerze, a treść dokumentu opuszcza to urządzenie i trafia do tego dostawcy. Jeśli skonfigurowany dostawca to połączenie subskrypcyjne (zalogowane konto AI zamiast klucza API), obowiązują jego własne ustawienia danych: dostawcy subskrypcyjni mogą wykorzystywać treść do ulepszania swoich modeli i nie obejmuje tego żadna umowa powierzenia przetwarzania danych. Dokumenty odczytywane przez samodzielnie hostowany model lokalny nigdy nie opuszczają Twojego serwera. Domyślnie wyłączone; włącz je tylko wtedy, gdy akceptujesz ten kompromis dla swoich dokumentów.", + "confirm": "Rozumiem, włącz", "cancel": "Anuluj" }, "centralCodex": { "title": "Użyj wspólnego dostępu AI serwera", - "subLabel": "Kieruj zapytania AI przez połączenie skonfigurowane przez operatora dla tego serwera, obok Twoich własnych dostawców jako rozwiązanie awaryjne.", - "honesty": "Wspólny dostęp to jedno zalogowane konto AI, które operator podłączył dla wszystkich na tym serwerze — nie Twój własny klucz. Zapytania, które przez nie wysyłasz, są domyślnie wykorzystywane do trenowania modeli dostawcy i nie obejmuje ich żadna umowa powierzenia przetwarzania danych, godzinowe i tygodniowe limity konta dzielisz z wszystkimi innymi, którzy się zgodzili, a Twoi właśni dostawcy są próbowani jako pierwsi. Włącz to tylko, jeśli akceptujesz ten kompromis.", - "confirm": "Rozumiem — użyj połączenia", + "subLabel": "Zapytania AI idą przez wspólne połączenie serwera, a Twoi dostawcy służą jako zapas.", + "honesty": "Wspólny dostęp to jedno zalogowane konto AI, które operator podłączył dla wszystkich na tym serwerze, a nie Twój własny klucz. Zapytania, które przez nie wysyłasz, są domyślnie wykorzystywane do trenowania modeli dostawcy i nie obejmuje ich żadna umowa powierzenia przetwarzania danych, godzinowe i tygodniowe limity konta dzielisz z wszystkimi innymi, którzy się zgodzili, a Twoi właśni dostawcy są próbowani jako pierwsi. Włącz to tylko, jeśli akceptujesz ten kompromis.", + "confirm": "Rozumiem, użyj połączenia", "cancel": "Anuluj" }, "consent": { "title": "Zgoda na AI", - "activeSince": "Udzielona {date}. Twoje dane zdrowotne mogą być wysyłane do skonfigurowanego dostawcy na potrzeby funkcji AI.", + "activeSince": "Udzielona {date}: Twoje dane zdrowotne mogą trafiać do skonfigurowanego dostawcy na potrzeby funkcji AI.", "withdrawn": "Wycofana. Jeśli żadna inna zgoda tego nie obejmuje, Coach i Insights nie korzystają już z dostępu AI zapewnianego przez ten serwer, a żaden model online nie czyta Twoich dokumentów. Na własnym kluczu lub lokalnym modelu Coach i Insights działają dalej.", "withdraw": "Wycofaj zgodę", "confirmBody": "Jeśli żadna inna Twoja zgoda ich nie obejmuje, od razu przestają działać: Coach i Insights tam, gdzie korzystają z dostępu AI zapewnianego przez ten serwer, oraz czytanie dokumentów przez model online. Na własnym kluczu lub lokalnym modelu Coach i Insights działają dalej. Jeśli żadna inna zgoda nie obejmuje już Insights, notatki napisane dla Ciebie przez model zostają usunięte: notatki o stanie, codzienne podsumowanie, podsumowania okresu napisane przez model, linie po nowym pomiarze i notatki o treningach. Twoje rozmowy z Coachem, to, co zapamiętał Coach, Twoje plany i podsumowania dokumentów zostają, a Ty możesz je sam przeczytać lub usunąć.", @@ -6168,7 +6264,7 @@ "error": "Nie udało się zapisać wycofania. Spróbuj ponownie.", "documentReading": { "title": "Zgoda na odczyt dokumentów", - "activeSince": "Udzielona {date}. Dokumenty, zeskanowane wyniki badań i opisy leków mogą być wysyłane do Twojej usługi AI do odczytu.", + "activeSince": "Udzielona {date}: dokumenty, skany badań i opisy leków mogą trafiać do Twojej usługi AI do odczytu.", "confirmBody": "Dokumenty, zeskanowane wyniki badań i opisy leków nie są już wysyłane do odczytu. Nic, co już masz, nie zostaje usunięte." } }, @@ -6202,7 +6298,9 @@ "high": "Wysokie" }, "coachNote": "W Coachu obowiązuje głębokość myślenia z ustawień Coacha." - } + }, + "providerCardDescription": "Dostawca, którego HealthLog pyta najpierw, wraz z kluczem i modelem.", + "runtimeTitle": "Sprawdź i odśwież" }, "dashboard": { "units": { @@ -6226,8 +6324,8 @@ "withingsClientId": "ID klienta", "withingsClientSecret": "Sekret klienta", "withingsCredentialsSaved": "Dane logowania zapisane", - "withingsCredentialsSavedPlaceholder": "Zapisano — wprowadź nowy, aby zastąpić", - "withingsCredentialsSavedPlaceholderSecret": "Zapisano — wprowadź nowy, aby zastąpić", + "withingsCredentialsSavedPlaceholder": "Zapisano. Wprowadź nowy, aby zastąpić", + "withingsCredentialsSavedPlaceholderSecret": "Zapisano. Wprowadź nowy, aby zastąpić", "withingsSaveCredentials": "Zapisz", "configured": "Skonfigurowano", "withingsSync": "Synchronizuj teraz", @@ -6242,7 +6340,7 @@ }, "withingsFullSyncResult": "{count} pomiarów w pełni zsynchronizowano", "withingsSyncFailed": "Synchronizacja nie powiodła się", - "withingsOauthConnected": "Withings połączony — pierwsza synchronizacja rozpocznie się w tle.", + "withingsOauthConnected": "Withings połączony. Pierwsza synchronizacja rozpocznie się w tle.", "withingsOauthFailed": "Połączenie z Withings nie powiodło się", "withingsOauthError": { "csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.", @@ -6267,8 +6365,8 @@ "whoopClientId": "Client ID", "whoopClientSecret": "Client Secret", "whoopCredentialsSaved": "Dane zapisane", - "whoopCredentialsSavedPlaceholder": "Zapisano — wpisz nowe, aby zastąpić", - "whoopCredentialsSavedPlaceholderSecret": "Zapisano — wpisz nowe, aby zastąpić", + "whoopCredentialsSavedPlaceholder": "Zapisano. Wpisz nowe, aby zastąpić", + "whoopCredentialsSavedPlaceholderSecret": "Zapisano. Wpisz nowe, aby zastąpić", "whoopSaveCredentials": "Zapisz", "whoopSync": "Synchronizuj teraz", "whoopFullSync": "Synchronizuj wszystkie dane", @@ -6283,7 +6381,7 @@ "whoopConnect": "Połącz z WHOOP", "whoopNoCredentials": "Wprowadź powyżej swoje dane API, aby połączyć WHOOP.", "whoopBackfillInProgress": "Importowanie historii WHOOP w tle…", - "whoopOauthConnected": "WHOOP połączony — pierwsza synchronizacja startuje w tle.", + "whoopOauthConnected": "WHOOP połączony. Pierwsza synchronizacja startuje w tle.", "whoopOauthFailed": "Połączenie z WHOOP nie powiodło się", "whoopOauthError": { "csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.", @@ -6297,14 +6395,14 @@ }, "fitbit": "Fitbit", "fitbitTag": "Fitbit i Pixel", - "fitbitDescription": "Połącz Fitbit, aby synchronizować dane z Fitbit i Pixel Watch — wagę, tętno i więcej.", + "fitbitDescription": "Połącz Fitbit, aby synchronizować dane z Fitbit i Pixel Watch: wagę, tętno i więcej.", "fitbitExperimentalBadge": "Eksperymentalne", "fitbitCredentials": "Dane uwierzytelniające API", "fitbitClientId": "Client ID", "fitbitClientSecret": "Client Secret", "fitbitCredentialsSaved": "Dane uwierzytelniające zapisane", - "fitbitCredentialsSavedPlaceholder": "Zapisano — wpisz nowe, aby zastąpić", - "fitbitCredentialsSavedPlaceholderSecret": "Zapisano — wpisz nowe, aby zastąpić", + "fitbitCredentialsSavedPlaceholder": "Zapisano. Wpisz nowe, aby zastąpić", + "fitbitCredentialsSavedPlaceholderSecret": "Zapisano. Wpisz nowe, aby zastąpić", "fitbitSaveCredentials": "Zapisz", "fitbitSync": "Synchronizuj teraz", "fitbitFullSync": "Synchronizuj wszystkie dane", @@ -6319,18 +6417,18 @@ "fitbitConnect": "Połącz z Fitbit", "fitbitNoCredentials": "Wprowadź powyżej swoje dane uwierzytelniające API, aby połączyć Fitbit.", "fitbitBackfillInProgress": "Importowanie historii Fitbit w tle…", - "fitbitOauthConnected": "Fitbit połączony — pierwsza synchronizacja startuje w tle.", + "fitbitOauthConnected": "Fitbit połączony. Pierwsza synchronizacja startuje w tle.", "fitbitOauthFailed": "Połączenie z Fitbit nie powiodło się", "googleHealth": "Google Health", "googleHealthTag": "Fitbit i Pixel", "googleHealthBetaBadge": "Beta", - "googleHealthDescription": "Połącz Fitbit, Pixel Watch i Fitbit Air przez konto Google — waga, tętno, sen i aktywność. Zastępuje to klasyczne połączenie Fitbit, które Google wyłącza we wrześniu 2026.", + "googleHealthDescription": "Połącz Fitbit, Pixel Watch i Fitbit Air przez konto Google: waga, tętno, sen i aktywność. Zastępuje to klasyczne połączenie Fitbit, które Google wyłącza we wrześniu 2026.", "googleHealthCredentials": "Dane API", "googleHealthClientId": "Identyfikator klienta", "googleHealthClientSecret": "Sekret klienta", "googleHealthCredentialsSaved": "Dane logowania zapisane", - "googleHealthCredentialsSavedPlaceholder": "Zapisane — wpisz nowe, aby zastąpić", - "googleHealthCredentialsSavedPlaceholderSecret": "Zapisane — wpisz nowe, aby zastąpić", + "googleHealthCredentialsSavedPlaceholder": "Zapisane. Wpisz nowe, aby zastąpić", + "googleHealthCredentialsSavedPlaceholderSecret": "Zapisane. Wpisz nowe, aby zastąpić", "googleHealthSaveCredentials": "Zapisz", "googleHealthSync": "Synchronizuj teraz", "googleHealthSyncCheckStatus": "Odśwież stan", @@ -6364,9 +6462,9 @@ "googleHealthConnect": "Połącz z Google", "googleHealthNoCredentials": "Wprowadź powyżej swoje dane API, aby połączyć Google Health.", "googleHealthBackfillInProgress": "Importowanie historii Google Health w tle…", - "googleHealthReauthBanner": "Google unieważnił połączenie — dzieje się to okresowo, dopóki Twoja aplikacja OAuth pozostaje w trybie testowym. Połącz ponownie, aby wznowić synchronizację.", + "googleHealthReauthBanner": "Google unieważnił połączenie. Dzieje się to okresowo, dopóki Twoja aplikacja OAuth pozostaje w trybie testowym. Połącz ponownie, aby wznowić synchronizację.", "googleHealthReconnect": "Połącz ponownie", - "googleHealthOauthConnected": "Google Health połączony — pierwsza synchronizacja startuje w tle.", + "googleHealthOauthConnected": "Google Health połączony. Pierwsza synchronizacja startuje w tle.", "googleHealthOauthFailed": "Połączenie z Google Health nie powiodło się", "googleHealthOauthError": { "csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.", @@ -6401,10 +6499,10 @@ }, "integrationPill": { "connected": "Połączono", - "errorReconnect": "Błąd — połącz ponownie", + "errorReconnect": "Błąd: połącz ponownie", "warningServerError": "Synchronizacja nie działa", "failureCount": "{count}/{threshold}", - "parkedReconnect": "Wstrzymane — połącz ponownie", + "parkedReconnect": "Wstrzymane: połącz ponownie", "notConnected": "Nie połączono", "justNow": "przed chwilą", "minutesAgo": "{count} min temu", @@ -6419,7 +6517,7 @@ }, "integrationFreshness": { "title": "Odebrane dane", - "summary": "{count} typów danych · najnowsze {relative}", + "summary": "{count} typów danych, najnowsze {relative}", "quiet": "{count} bez danych", "workouts": "Treningi" }, @@ -6433,31 +6531,33 @@ "fitbitViewData": "Zobacz swój sen", "googleHealthViewData": "Zobacz swoje kroki", "apiTokens": "Tokeny API", - "apiTokensDescription": "Przeglądaj i unieważniaj tokeny API do zewnętrznego rejestrowania przyjęć leków.", + "apiTokensDescription": "Przeglądaj i unieważniaj tokeny do pomiarów, treningów, dokumentów i przyjęć leków.", "measurementsToken": { "title": "Tokeny pomiarów", "description": "Utwórz token, aby waga, zegarek lub automatyka domowa mogły dodawać pomiary.", "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." + "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" }, "workoutsToken": { "title": "Tokeny treningów", "description": "Utwórz token, aby mostek zegarka lub skrypt synchronizacji mogły dodawać Twoje treningi.", "detail": "Token pokazujemy tylko raz.", "scopeNote": "Może wyłącznie dodawać nowe treningi do Twojej dokumentacji. Nie odczyta danych, niczego nie zmieni ani nie usunie i nie utworzy kolejnego tokenu. Dodane przez niego treningi są oznaczane jako pochodzące z urządzenia zewnętrznego, a jeśli telefon zapisał tę samą sesję, widzisz zapis z telefonu.", - "createFailed": "Nie udało się utworzyć tokenu. Spróbuj ponownie." + "createFailed": "Nie udało się utworzyć tokenu. Spróbuj ponownie.", + "namePlaceholder": "Nazwa, np. Garmin" }, "documentsToken": { "title": "Tokeny dokumentów", "description": "Utwórz token, aby Paperless-ngx, Papra lub skrypt importu mogły dodawać dokumenty do Twojego archiwum.", "detail": "Token pokazujemy tylko raz.", "scopeNote": "Może wyłącznie dodawać nowe dokumenty do Twojego archiwum. Niczego nie wyświetli, nie otworzy, nie pobierze, nie zmieni ani nie usunie i nie utworzy kolejnego tokenu. Dowiaduje się tylko, czy plik został zapisany, a nie to, jak został opisany.", - "createFailed": "Nie udało się utworzyć tokenu. Spróbuj ponownie." + "createFailed": "Nie udało się utworzyć tokenu. Spróbuj ponownie.", + "namePlaceholder": "Nazwa, np. Paperless-ngx" }, - "tokenMintMovedDescription": "Tokeny wydajemy tam, gdzie działają: powyżej dla pomiarów i dokumentów albo w punkcie końcowym API danego leku dla tego jednego leku.", - "tokenNamePlaceholder": "Nazwa tokena (np. Skrót iPhone)", - "tokenCreated": "Token utworzony — skopiuj go teraz! Nie zostanie pokazany ponownie.", + "tokenMintMovedDescription": "Tokeny wydajemy tam, gdzie działają: powyżej dla pomiarów, treningów i dokumentów albo w punkcie końcowym API danego leku dla tego jednego leku.", + "tokenCreated": "Token utworzony. Skopiuj go teraz! Nie zostanie pokazany ponownie.", "tokenCopy": "Kopiuj token", "tokenCopied": "Token skopiowany do schowka", "tokenCopyFailed": "Nie udało się skopiować tokenu", @@ -6496,7 +6596,7 @@ "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.", - "deleteAccountSuccess": "Konto usunięte — trwa wylogowywanie.", + "deleteAccountSuccess": "Konto usunięte, trwa wylogowywanie.", "deleteAccountFailed": "Nie udało się usunąć konta", "deleteAccountFinal": "Usuń trwale", "saved": "Zapisano", @@ -6519,7 +6619,7 @@ "testing": "Testowanie…", "ok": "Połączono (opóźnienie {latency} ms)", "errors": { - "credentials_rejected": "Dane logowania odrzucone — sprawdź token", + "credentials_rejected": "Dane logowania odrzucone, sprawdź token", "rate_limited": "Ograniczone przez usługę nadrzędną", "timeout": "Przekroczono limit czasu żądania", "upstream_error": "Usługa nadrzędna zwróciła błąd", @@ -6530,7 +6630,7 @@ "private_origin_not_approved": "Cel w sieci prywatnej. Operator musi dodać to źródło do NOTIFICATION_PRIVATE_ORIGINS", "private_origin_not_grantable": "Ten adres to link-local, adres metadanych lub nieokreślony; żadna zgoda operatora nie może go otworzyć. Użyj adresu, na którym nasłuchuje przekaźnik", "url_invalid": "Adres URL jest nieprawidłowy", - "redirected": "Punkt końcowy przekierowuje — sprawdź adres URL", + "redirected": "Punkt końcowy przekierowuje, sprawdź adres URL", "endpoint_not_found": "Nie znaleziono punktu końcowego pod adresem URL", "credentials_unreadable": "Nie można odszyfrować danych logowania", "upstream_invalid_json": "Usługa nadrzędna wysłała nieprawidłowy JSON", @@ -6541,7 +6641,7 @@ }, "notificationStatus": { "title": "Niezawodność kanałów", - "description": "Stan każdego kanału — „Automatycznie wyłączone” oznacza zatrzymanie po trwałych błędach.", + "description": "Stan każdego kanału; „Automatycznie wyłączone” oznacza zatrzymanie po trwałych błędach.", "emptyDescription": "Dodaj kanał w Ustawienia › Integracje › Kanały dostarczania.", "stateActive": "Aktywne", "stateAutoDisabled": "Automatycznie wyłączone", @@ -6567,7 +6667,7 @@ }, "healthRecord": { "title": "Eksportuj dokumentację zdrowotną", - "description": "Pełna dokumentacja dla lekarza — jako PDF, jako FHIR lub oba.", + "description": "Pełna dokumentacja dla lekarza: jako PDF, jako FHIR lub oba.", "format": "Format", "format_pdf": "PDF", "format_fhir": "Ustrukturyzowany (FHIR)", @@ -6599,7 +6699,7 @@ "createTitle": "Nowy link udostępniania", "createDescription": "Link jest tylko do odczytu i ograniczony czasowo.", "label": "Etykieta", - "labelPlaceholder": "np. dr Kowalski — kontrola roczna", + "labelPlaceholder": "np. dr Kowalski, kontrola roczna", "range": "Dni historii", "rangeHint": "Jak daleko wstecz sięga udostępnione okno.", "expiry": "Wygasa za (dni)", @@ -6619,7 +6719,7 @@ "statusRevoked": "Unieważniony", "statusExpired": "Wygasły", "inactiveTitle": "Unieważnione i wygasłe ({count})", - "shownOnce": "Wyświetlane tylko raz. Zapisz teraz kod QR i hasło — nie można ich później odzyskać.", + "shownOnce": "Wyświetlane tylko raz. Zapisz teraz kod QR i hasło, bo nie można ich później odzyskać.", "qrAlt": "Kod QR zawierający link do udostępniania i hasło", "linkLabel": "Link", "passphraseLabel": "Hasło", @@ -6630,12 +6730,12 @@ "attachTitle": "Dołącz dokumenty", "attachButton": "Wybierz dokumenty", "attachCount": "Wybrano {count} z {max}", - "attachFrozen": "Zestaw dokumentów jest ustalany przy tworzeniu linku — później nie można dodawać ani usuwać dokumentów.", + "attachFrozen": "Zestaw dokumentów jest ustalany przy tworzeniu linku. Później nie można dodawać ani usuwać dokumentów.", "attachRemove": "Usuń {title}", "exifNote": "Metadane aparatu (EXIF/GPS) są usuwane z obrazów JPEG, PNG i WebP podczas udostępniania. Pliki PDF, TIFF, HEIC i Office są przekazywane bez zmian.", "documentCount": "Dołączone dokumenty: {count}", "qrScanTitle": "Zeskanuj, aby otworzyć na innym urządzeniu", - "qrScanHint": "Skieruj aparat telefonu na kod, aby otworzyć udostępnioną dokumentację — hasło jest już dołączone.", + "qrScanHint": "Skieruj aparat telefonu na kod, aby otworzyć udostępnioną dokumentację. Hasło jest już dołączone.", "pickerTitle": "Dołącz dokumenty", "pickerDescription": "Przeszukaj swój sejf i wybierz dokumenty do udostępnienia. Maksymalnie {max}.", "pickerSearch": "Szukaj dokumentów", @@ -6648,7 +6748,8 @@ "statusNeedsReselect": "Wybierz zawartość ponownie", "reselectReason": "Ten link udostępniał zakres, którego nikt nie wybrał, więc został zamknięty. Utwórz nowy i wybierz jego zawartość.", "reselectAction": "Utwórz zamiennik", - "createDetail": "Token jest pokazywany tylko raz przy tworzeniu — zapisz go wtedy; nie można go odzyskać." + "createDetail": "Token jest pokazywany tylko raz przy tworzeniu. Zapisz go wtedy; nie można go odzyskać.", + "activeDescription": "Udostępnione linki, termin ich wygaśnięcia i liczba otwarć." }, "genderOther": "Inna", "nightscout": "Nightscout", @@ -6656,10 +6757,10 @@ "nightscoutDescription": "Połącz własną stronę Nightscout, aby synchronizować ciągłe odczyty glukozy w panelu glukozy.", "nightscoutViewData": "Zobacz swoją glukozę", "nightscoutUrl": "Adres URL Nightscout", - "nightscoutUrlSavedPlaceholder": "Zapisano — wpisz nowy adres URL, aby go zastąpić", + "nightscoutUrlSavedPlaceholder": "Zapisano. Wpisz nowy adres URL, aby go zastąpić", "nightscoutToken": "Token API", "nightscoutTokenOptional": "Opcjonalny dla witryny publicznej", - "nightscoutTokenSavedPlaceholder": "Zapisano — wpisz nowy token, aby go zastąpić", + "nightscoutTokenSavedPlaceholder": "Zapisano. Wpisz nowy token, aby go zastąpić", "nightscoutTokenHelp": "Wklej token dostępu z narzędzi administracyjnych Nightscout. Pozostaw puste, jeśli witryna jest publicznie czytelna.", "nightscoutPrivateOperatorTitle": "Dostęp do prywatnego serwera Nightscout wymaga zgody administratora serwera.", "nightscoutPrivateOperatorDescription": "Poproś administratora serwera o zatwierdzenie dokładnie tego pochodzenia Nightscout.", @@ -6680,7 +6781,7 @@ "polarClientId": "Client ID", "polarClientSecret": "Client Secret", "polarCredentialsSaved": "Dane zapisane", - "polarCredentialsSavedPlaceholder": "Zapisano — wpisz nowe, aby zastąpić", + "polarCredentialsSavedPlaceholder": "Zapisano. Wpisz nowe, aby zastąpić", "polarSaveCredentials": "Zapisz", "polarUnavailable": "Polar nie jest skonfigurowany na tym serwerze. Dodaj powyżej własne dane OAuth Polar lub poproś administratora o skonfigurowanie współdzielonej aplikacji.", "polarConnect": "Połącz Polar", @@ -6690,7 +6791,7 @@ "polarDisconnect": "Rozłącz", "polarDisconnectTitle": "Rozłączyć Polar?", "polarDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty pozostaną w historii.", - "polarOauthConnected": "Polar połączony — pierwsza synchronizacja startuje w tle.", + "polarOauthConnected": "Polar połączony. Pierwsza synchronizacja startuje w tle.", "polarOauthFailed": "Połączenie z Polar nie powiodło się", "polarOauthError": { "csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.", @@ -6709,7 +6810,7 @@ "ouraClientId": "Client ID", "ouraClientSecret": "Client Secret", "ouraCredentialsSaved": "Dane zapisane", - "ouraCredentialsSavedPlaceholder": "Zapisano — wpisz nowe, aby zastąpić", + "ouraCredentialsSavedPlaceholder": "Zapisano. Wpisz nowe, aby zastąpić", "ouraSaveCredentials": "Zapisz", "ouraUnavailable": "Oura nie jest skonfigurowana na tym serwerze. Dodaj powyżej własne dane OAuth Oura lub poproś administratora o skonfigurowanie współdzielonej aplikacji.", "ouraConnect": "Połącz Oura", @@ -6719,7 +6820,7 @@ "ouraDisconnect": "Rozłącz", "ouraDisconnectTitle": "Rozłączyć Oura?", "ouraDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty pozostaną w historii.", - "ouraOauthConnected": "Oura połączona — pierwsza synchronizacja startuje w tle.", + "ouraOauthConnected": "Oura połączona. Pierwsza synchronizacja startuje w tle.", "ouraOauthFailed": "Połączenie z Oura nie powiodło się", "ouraOauthError": { "csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.", @@ -6750,7 +6851,7 @@ } }, "integrationRedirectGuide": { - "intro": "To połączenie działa przez Twoją własną aplikację deweloperską {provider} — około 5 minut konfiguracji:", + "intro": "To połączenie działa przez Twoją własną aplikację deweloperską {provider}, około 5 minut konfiguracji:", "step1": "Utwórz aplikację w portalu deweloperskim {provider}.", "step2": "Ustaw jej URL przekierowania/callback na:", "step3": "Wklej powyżej otrzymany identyfikator klienta i sekret klienta." @@ -6810,7 +6911,7 @@ }, "recovery": { "saveNow": "Zapisz teraz te kody odzyskiwania", - "saveNowHint": "Każdy kod działa raz. Są wyświetlane tylko teraz — przechowuj je w bezpiecznym miejscu. Jeśli stracisz aplikację uwierzytelniającą, kod odzyskiwania będzie jedyną drogą powrotu.", + "saveNowHint": "Każdy kod działa raz. Są wyświetlane tylko teraz, więc przechowuj je w bezpiecznym miejscu. Jeśli stracisz aplikację uwierzytelniającą, kod odzyskiwania będzie jedyną drogą powrotu.", "download": "Pobierz", "remainingOne": "Pozostał {count} kod odzyskiwania.", "remainingOther": "Pozostało {count} kodów odzyskiwania.", @@ -6838,7 +6939,7 @@ }, "passkeyNudge": { "title": "Dodaj klucz dostępu, aby logować się szybciej", - "body": "Klucze dostępu pozwalają logować się odciskiem palca, twarzą lub kodem PIN urządzenia — bez hasła do zapamiętania." + "body": "Klucze dostępu pozwalają logować się odciskiem palca, twarzą lub kodem PIN urządzenia, bez hasła do zapamiętania." }, "trustedDevices": { "title": "Zaufane urządzenia", @@ -6874,12 +6975,11 @@ }, "stored": { "title": "Co jest przechowywane", - "measurements": "Pomiary (waga, ciśnienie, tętno, glukoza, sen, skład ciała)", - "medications": "Leki i historia przyjmowania", - "moodLabs": "Wpisy nastroju i wyniki badań laboratoryjnych", - "coach": "Rozmowy z asystentem", + "measurements": "Pomiary: waga, ciśnienie, tętno i każda inna wartość zapisana przez ciebie lub urządzenie", "integrations": "Tokeny integracji (szyfrowane)", - "security": "Aktywność konta i zabezpieczeń (sesje, dziennik logowań)" + "security": "Aktywność konta i zabezpieczeń (sesje, dziennik logowań)", + "profile": "Twój profil, wywiad medyczny i dane dokumentacji zdrowotnej", + "modules": "Wpisy poszczególnych modułów: {list}" }, "retention": { "title": "Retencja", @@ -6910,13 +7010,11 @@ "mcp": { "enableTitle": "Zdalny punkt końcowy MCP", "enableDescription": "Zezwól asystentowi obsługującemu MCP (Claude.ai, ChatGPT) na odczyt danych zdrowotnych za pomocą ograniczonego tokenu.", - "enabled": "Włączony", - "disabled": "Wyłączony", "enableToggleLabel": "Włącz punkt końcowy MCP", "endpointLabel": "Adres URL łącznika", "connectDescription": "Wklej ten adres URL w konfiguracji własnego łącznika asystenta, a następnie autoryzuj za pomocą poniższego tokenu lub zaloguj się po wyświetleniu monitu.", "tokensTitle": "Tokeny łącznika", - "tokensDescription": "Wygeneruj token dla asystenta — tylko odczyt, chyba że pozwolisz poniżej na zapis.", + "tokensDescription": "Wygeneruj token dla asystenta: tylko odczyt, chyba że pozwolisz poniżej na zapis.", "scopeNote": "Nowe tokeny mogą tylko odczytywać Twoje dane. Dostęp do zapisu (health:write) przyznajesz dla każdego tokena przełącznikiem poniżej. Token nigdy nie może uzyskać dostępu do funkcji administracyjnych.", "writeScopeToggleLabel": "Zezwól na zapis (health:write)", "writeScopeNote": "Dodaje zakres health:write, aby asystent mógł zapisywać nowe pomiary i wpisy nastroju na Twoim koncie. Każdy zapis jest najpierw potwierdzany. Token nadal nie może usuwać ani edytować istniejących wpisów, zmieniać leków ani uzyskiwać dostępu do funkcji REST lub administracyjnych. Pozostaw wyłączone, aby uzyskać dostęp tylko do odczytu.", @@ -6930,7 +7028,8 @@ "connectionRevokeDescription": "To natychmiast kończy dostęp asystenta i uniemożliwia odnawianie. Ponowne połączenie będzie wymagać nowej autoryzacji.", "connectionRevokeFailed": "Nie udało się rozłączyć asystenta.", "tokensDetail": "Token jest wyświetlany tylko raz.", - "connectionsDetail": "Cofnięcie połączenia natychmiast kończy dostęp, w tym możliwość odnawiania." + "connectionsDetail": "Cofnięcie połączenia natychmiast kończy dostęp, w tym możliwość odnawiania.", + "tokenNamePlaceholder": "Nazwa, np. Claude Desktop" }, "strava": "Strava", "stravaDescription": "Połącz konto Strava, aby synchronizować aktywności z historią treningów.", @@ -6939,7 +7038,7 @@ "stravaClientId": "Identyfikator klienta", "stravaClientSecret": "Sekret klienta", "stravaCredentialsSaved": "Dane zapisane", - "stravaCredentialsSavedPlaceholder": "Zapisane — wpisz nowe, aby zastąpić", + "stravaCredentialsSavedPlaceholder": "Zapisane. Wpisz nowe, aby zastąpić", "stravaSaveCredentials": "Zapisz", "stravaUnavailable": "Strava nie jest skonfigurowana na tym serwerze. Dodaj powyżej własne dane API Strava lub poproś administratora o skonfigurowanie wspólnej aplikacji.", "stravaConnect": "Połącz Strava", @@ -6949,7 +7048,7 @@ "stravaDisconnect": "Rozłącz", "stravaDisconnectTitle": "Rozłączyć Strava?", "stravaDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane aktywności pozostaną w historii.", - "stravaOauthConnected": "Strava połączona — pierwsza synchronizacja rusza w tle.", + "stravaOauthConnected": "Strava połączona. Pierwsza synchronizacja rusza w tle.", "stravaOauthFailed": "Połączenie ze Strava nie powiodło się", "stravaOauthError": { "csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.", @@ -6977,33 +7076,40 @@ "title": "Postęp synchronizacji", "receivedLabel": "Odebrane rekordy", "oldestLabel": "Najstarszy osiągnięty pomiar", - "flowing": "W ciągu ostatniej godziny dotarły dane — aplikacja na iPhonie nadal je przesyła.", - "waiting": "Oczekiwanie na kolejne dane z aplikacji na iPhonie. Przesyłanie trwa dalej, gdy aplikacja jest otwarta lub gdy iOS przydzieli jej czas w tle — cisza podczas pierwszego dużego uzupełniania historii jest normalna, to nie błąd." + "flowing": "W ciągu ostatniej godziny dotarły dane. Aplikacja na iPhonie nadal je przesyła.", + "waiting": "Oczekiwanie na kolejne dane z aplikacji na iPhonie. Przesyłanie trwa dalej, gdy aplikacja jest otwarta lub gdy iOS przydzieli jej czas w tle. Cisza podczas pierwszego dużego uzupełniania historii jest normalna, to nie błąd." }, "delivery": { "title": "Dostarczanie", "lastTriggerLabel": "Ostatnia synchronizacja dotarła", "lastBackgroundLabel": "Ostatnia synchronizacja w tle", - "backgroundNever": "Nigdy — jak dotąd każda synchronizacja dotarła przy otwartej aplikacji", + "backgroundNever": "Nigdy: jak dotąd każda synchronizacja dotarła przy otwartej aplikacji", "noSyncYet": "Żadna synchronizacja jeszcze nie dotarła, więc nie ma tu nic do zgłoszenia.", "trigger": { "foreground": "przy otwartej aplikacji", "background": "w tle", "push": "z wybudzenia push", - "unknown": "aplikacja nie zgłosiła wyzwalacza" + "unknown": "aplikacja nie zgłosiła wyzwalacza", + "manual": "przez „Synchronizuj wszystko”" } + }, + "freshness": { + "title": "Według typu danych", + "lastReceived": "Ostatnio odebrano {relative}", + "lastNewValue": "Ostatnia nowa wartość {relative}", + "never": "Jeszcze nie odebrano" } }, "garminInfo": { "title": "Garmin", - "body": "Garmin nie ma tutaj bezpośredniego połączenia. Połącz przez Apple Health (iOS) lub Google Health Connect (Android). Zastrzeżone wskaźniki Garmina — Body Battery, Training Load, status HRV, VO₂ max — nie są udostępniane przez Garmina żadnej z tych platform.", + "body": "Garmin nie ma tutaj bezpośredniego połączenia. Połącz przez Apple Health (iOS) lub Google Health Connect (Android). Zastrzeżone wskaźniki Garmina (Body Battery, Training Load, status HRV, VO₂ max) nie są udostępniane przez Garmina żadnej z tych platform.", "docsLink": "Przeczytaj przewodnik" }, "glucoseReference": { "title": "Zakres referencyjny glukozy", "description": "Do jakiego zakresu porównywane są twoje wyniki glukozy we krwi.", "enable": "Użyj zakresu docelowego dla cukrzycy", - "explainer": "Wyłączone — wyniki są oceniane względem ogólnego zakresu referencyjnego dla osób bez cukrzycy. Włączone — są oceniane względem węższego zakresu docelowego glikemii z ADA Standards of Care, a Coach opiera na nim swoje uwagi o glukozie. Twoje własne wartości docelowe poniżej nadal mają pierwszeństwo.", + "explainer": "Wyłączone: wyniki są oceniane względem ogólnego zakresu referencyjnego dla osób bez cukrzycy. Włączone: są oceniane względem węższego zakresu docelowego glikemii z ADA Standards of Care, a Coach opiera na nim swoje uwagi o glukozie. Twoje własne wartości docelowe poniżej nadal mają pierwszeństwo.", "disclaimer": "To wybiera zakres referencyjny. Nie jest to diagnoza, nigdy nie jest wywnioskowane z pomiaru, a aplikacja nie podejmuje na tej podstawie działań klinicznych. O to, jakie cele dotyczą ciebie, zapytaj swojego lekarza.", "error": "Nie udało się zapisać ustawienia." }, @@ -7052,6 +7158,12 @@ "description": "Ta czynność udostępnia lub eksportuje Twoje dane zdrowotne. Potwierdź, że to Ty, aby kontynuować.", "signInAgain": "Zaloguj się ponownie, a potem spróbuj jeszcze raz. Twoje konto nie ma hasła ani klucza dostępu do potwierdzenia tutaj.", "failed": "To się nie udało. Sprawdź wprowadzone dane i spróbuj ponownie." + }, + "passwordTitle": "Hasło", + "tokenNameLabel": "Nazwa tokena", + "displayCard": { + "title": "Wyświetlanie", + "description": "Język, jednostki i formaty zapisywane od razu po zmianie." } }, "admin": { @@ -7071,27 +7183,27 @@ "loadingStatus": "Ładowanie stanu...", "systemStatusLoadError": "Nie udało się załadować stanu systemu.", "systemStatusRetry": "Spróbuj ponownie", - "adminSettingsLoadError": "Nie udało się załadować ustawień administracyjnych. Przełączniki poniżej mogą nie odzwierciedlać bieżącego stanu — odśwież stronę.", + "adminSettingsLoadError": "Nie udało się załadować ustawień administracyjnych. Przełączniki poniżej mogą nie odzwierciedlać bieżącego stanu, odśwież stronę.", "hostMetrics": { "title": "Obciążenie hosta", "load1": "Obciążenie (1m)", "memUsedPercent": "Pamięć", "diskBusyPercent": "Disk I/O", "last2hours": "Ostatnie 2 godziny", - "empty": "Rozpoczęto próbkowanie — dane pojawią się w ciągu 1–2 minut.", + "empty": "Rozpoczęto próbkowanie. Dane pojawią się w ciągu 1–2 minut.", "loadError": "Nie udało się załadować metryk hosta. Spróbuj odświeżyć stronę." }, "aiServerKey": { "title": "Serwerowy klucz AI", "description": "Klucz operatora dla kont, które nie mają własnego.", - "byokHint": "Własny klucz użytkownika (BYOK, logowanie ChatGPT lub dostawca lokalny) zawsze ma pierwszeństwo — klucz serwera jest ostatnim ogniwem łańcucha dostawców.", + "byokHint": "Własny klucz użytkownika (BYOK, logowanie ChatGPT lub dostawca lokalny) zawsze ma pierwszeństwo; klucz serwera jest ostatnim ogniwem łańcucha dostawców.", "consentHint": "Żądania przez klucz serwera wymagają aktywnej zgody użytkownika na AI; bez potwierdzenia zgody nic nie jest wysyłane.", "baseUrlHint": "Adres bazowy decyduje o API: adres Anthropic taki jak https://api.anthropic.com/v1 jest rozpoznawany, każdy inny host jest wywoływany przez API zgodne z OpenAI.", "statusConfigured": "Skonfigurowany", "statusNotConfigured": "Nieskonfigurowany", "keyLabel": "Klucz API", "keyPlaceholder": "sk-...", - "keyPlaceholderSet": "Zapisany ({preview}) — wpisz nowy klucz, aby go zastąpić", + "keyPlaceholderSet": "Zapisany ({preview}). Wpisz nowy klucz, aby go zastąpić", "modelLabel": "Model", "baseUrlLabel": "Adres bazowy URL", "save": "Zapisz", @@ -7125,13 +7237,16 @@ "typeCodex": "Logowanie ChatGPT (osobiste)", "typeLocal": "Model lokalny (osobisty)", "typeGateway": "Brama zgodna z OpenAI (osobista)", - "spendToday": "Dzisiaj ({date}): {total} tokenów, w tym {operator} na kluczu operatora." + "spendToday": "Dzisiaj ({date}): {total} tokenów, w tym {operator} na kluczu operatora.", + "statusPaused": "Wstrzymany", + "pausedHint": "Wstrzymany po powtarzających się błędach dla {count} użytkowników do {at}. Potem zostanie automatycznie ponowiony.", + "modelNotServed": "Punkt końcowy nie oferuje już modelu {model}. Wybierz w ustawieniach AI model, który oferuje." }, "aiQuality": { "title": "Jakość analiz", "loading": "Ładowanie podsumowania opinii...", "loadError": "Nie udało się załadować podsumowania jakości analiz. Spróbuj odświeżyć stronę.", - "empty": "Brak opinii. Agregator uruchamia się codziennie o 04:00 — gdy użytkownicy zaczną oceniać rekomendacje, tutaj pojawią się wskaźniki przydatności.", + "empty": "Brak opinii. Agregator uruchamia się codziennie o 04:00. Gdy użytkownicy zaczną oceniać rekomendacje, tutaj pojawią się wskaźniki przydatności.", "windowLabel": "Ostatnie {days} dni", "generatedAtLabel": "Wygenerowano", "colSeverity": "Ważność", @@ -7146,7 +7261,7 @@ "title": "Opinie o Coachu", "loading": "Ładowanie podsumowania opinii o Coachu...", "loadError": "Nie udało się załadować podsumowania opinii o Coachu. Spróbuj odświeżyć stronę.", - "empty": "Brak opinii o Coachu. Agregator uruchamia się codziennie o 04:00 — gdy użytkownicy ocenią odpowiedzi Coacha, tutaj pojawią się wskaźniki przydatności.", + "empty": "Brak opinii o Coachu. Agregator uruchamia się codziennie o 04:00. Gdy użytkownicy ocenią odpowiedzi Coacha, tutaj pojawią się wskaźniki przydatności.", "windowLabel": "Ostatnie {days} dni", "generatedAtLabel": "Wygenerowano", "colPromptVersion": "Wersja promptu", @@ -7165,13 +7280,13 @@ "appSettingsDescription": "Globalne ustawienia domyślne rejestracji, języka i strefy czasowej nowych kont.", "registrationEnabled": "Zezwól na rejestrację", "registrationEnabledDescription": "Gdy wyłączone, nowi użytkownicy nie mogą się zarejestrować.", - "registrationOpenHint": "Rejestracja jest otwarta — każdy, kto ma dostęp do tej instancji, może założyć konto. Zamknij ją tutaj, gdy wszyscy będą mieć konto.", + "registrationOpenHint": "Rejestracja jest otwarta: każdy, kto ma dostęp do tej instancji, może założyć konto. Zamknij ją tutaj, gdy wszyscy będą mieć konto.", "defaultLanguage": "Domyślny język", "defaultLanguageDescription": "Używany dla nowych użytkowników i jako rozwiązanie zapasowe.", "defaultUserTimezone": "Domyślna strefa czasowa dla nowych kont", - "defaultUserTimezoneDescription": "Używana dla nowych kont bez strefy wykrytej przez przeglądarkę. Nie wpływa na istniejących użytkowników.", + "defaultUserTimezoneDescription": "Używana dla nowych kont bez strefy wykrytej przez przeglądarkę; istniejące konta zachowują swoją.", "defaultUserTimezoneFallback": "Europe/Berlin (domyślna)", - "servicesGlobal": "Usługi (globalne)", + "servicesGlobal": "Kanały i API", "servicesGlobalDescription": "Wyłączone usługi nie są dostępne dla żadnego użytkownika.", "telegramGlobal": "Zezwól na powiadomienia Telegram.", "ntfyGlobal": "Zezwól na powiadomienia ntfy.", @@ -7179,19 +7294,21 @@ "integrationWebPush": "Web Push", "offlineGeoLabel": "Dane geograficzne", "offlineGeoEnabled": "Offline (GeoLite2)", - "offlineGeoFallback": "Online ({host}) — podłącz bazy GeoLite2 i ustaw GEOLITE2_DIR", + "offlineGeoFallback": "Online ({host}): podłącz bazy GeoLite2 i ustaw GEOLITE2_DIR", + "offlineGeoOff": "Wyłączone", + "offlineGeoChosen": "Online ({host})", "apiGlobal": "Zezwól na punkty końcowe API i tokeny API.", "umamiTitle": "Umami", - "umamiDescription": "Analityka internetowa z Umami", + "umamiDescription": "Analityka internetowa z Umami.", "glitchtipTitle": "GlitchTip", - "glitchtipDescription": "Śledzenie błędów z GlitchTip", + "glitchtipDescription": "Śledzenie błędów z GlitchTip.", "webPushVapidTitle": "Web Push VAPID", - "umamiEnabled": "Włącz śledzenie Umami.", + "umamiEnabled": "Włącz śledzenie Umami", "umamiScriptUrl": "Adres URL skryptu Umami", "umamiScriptUrlPlaceholder": "https://analytics.example.com/script.js", "umamiWebsiteId": "Identyfikator witryny Umami", "umamiWebsiteIdPlaceholder": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "glitchtipEnabled": "Włącz śledzenie błędów GlitchTip.", + "glitchtipEnabled": "Włącz śledzenie błędów GlitchTip", "glitchtipDsn": "GlitchTip DSN", "glitchtipDsnPlaceholder": "https://@glitchtip.example.com/", "glitchtipEnvironment": "Środowisko GlitchTip", @@ -7214,7 +7331,7 @@ "webPushVapidGenerating": "Generowanie…", "webPushVapidGenerated": "Klucze VAPID wygenerowane. Zapisz temat, aby zakończyć.", "webPushVapidGenerateFailed": "Nie udało się wygenerować kluczy VAPID.", - "webPushVapidGenerateConfirm": "Klucze VAPID są już skonfigurowane. Wygenerowanie nowej pary unieważnia wszystkie subskrypcje push w przeglądarce — każde urządzenie musi zasubskrybować ponownie. Kontynuować?", + "webPushVapidGenerateConfirm": "Klucze VAPID są już skonfigurowane. Wygenerowanie nowej pary unieważnia wszystkie subskrypcje push w przeglądarce, więc każde urządzenie musi zasubskrybować ponownie. Kontynuować?", "webPushVapidGenerateConfirmTitle": "Wygenerować nowe klucze VAPID?", "webPushVapidRegenerate": "Wygeneruj ponownie", "configured": "Skonfigurowano", @@ -7253,12 +7370,12 @@ "title": "Dostępność modułów", "description": "Wyłącz moduł dla całego serwera.", "saveError": "Nie udało się zaktualizować dostępności modułów.", - "detail": "Moduł wyłączony tutaj jest nieaktywny dla każdego konta, niezależnie od preferencji użytkownika. Domeny podstawowe (waga, ciśnienie, tętno, leki) są zawsze włączone i nie można ich wyłączyć.", - "coachFollowsSwitch": "Podlega przełącznikowi Coach w sekcji Asystent." + "detail": "Waga, ciśnienie i tętno to podstawowe pomiary i zawsze pozostają włączone.", + "coachFollowsSwitch": "Podlega przełącznikowi Coach w sekcji Coach." }, "invites": { - "title": "Zaproszenia", - "description": "Linki z zaproszeniem do rejestracji — działają nawet przy zamkniętej rejestracji.", + "title": "Linki z zaproszeniem", + "description": "Każdy link pozwala na rejestrację nawet przy zamkniętej rejestracji.", "create": "Utwórz zaproszenie", "createTitle": "Utwórz zaproszenie", "createDescription": "Link umożliwia rejestrację nawet przy zamkniętej rejestracji.", @@ -7270,8 +7387,8 @@ "createdToast": "Zaproszenie utworzone", "createError": "Nie udało się utworzyć zaproszenia.", "tokenTitle": "Link z zaproszeniem", - "tokenOnceHint": "Do skopiowania tylko teraz — skopiuj link lub udostępnij kod QR do zeskanowania.", - "hashExplainer": "Zapisywany jest tylko hash linku — nie można go później ponownie wyświetlić. W razie utraty unieważnij zaproszenie i utwórz nowe.", + "tokenOnceHint": "Do skopiowania tylko teraz: skopiuj link lub udostępnij kod QR do zeskanowania.", + "hashExplainer": "Zapisywany jest tylko hash linku, więc nie można go później ponownie wyświetlić. W razie utraty unieważnij zaproszenie i utwórz nowe.", "copyLink": "Kopiuj link", "copiedToast": "Link skopiowany", "copyError": "Kopiowanie nie powiodło się.", @@ -7304,8 +7421,8 @@ "revokedToast": "Zaproszenie unieważnione.", "revokeError": "Nie udało się unieważnić zaproszenia." }, - "userManagement": "Zarządzanie użytkownikami", - "userManagementDescription": "Zarządzaj kontami: role, resety haseł i wymuszone wylogowania.", + "userManagement": "Konta", + "userManagementDescription": "Role, resety haseł i wymuszone wylogowania dla każdego konta.", "userName": "Nazwa", "userEmail": "E-mail", "userRole": "Rola", @@ -7332,14 +7449,13 @@ "tokenName": "Nazwa", "tokenPermissions": "Uprawnienia", "tokenPermissionAll": "Wszystko", - "tokenPermissionAllTooltip": "Uprawnienie wieloznaczne — ten token może wywołać każdą trasę API.", + "tokenPermissionAllTooltip": "Uprawnienie wieloznaczne: ten token może wywołać każdą trasę API.", "tokenStatus": "Stan", "tokenLastUsed": "Ostatnio użyto", "tokenCreated": "Utworzono", "tokenNeverUsed": "Nigdy", "tokensEmptyTitle": "Brak tokenów API", "tokensEmptyDescription": "Tokeny są wydawane, gdy klient natywny się loguje, lub przez Ustawienia → Konto.", - "loginOverview": "Przegląd logowań", "loginOverviewDescription": "Zdarzenia logowania i uwierzytelniania wszystkich kont, z filtrowaniem i eksportem CSV.", "allAuthEvents": "Wszystkie zdarzenia uwierzytelniania", "failedOnly": "Tylko nieudane", @@ -7372,6 +7488,7 @@ "finalDelete": "Usuń trwale", "deletedResult": "Usunięto {rows} wierszy w {tables} tabelach. Konta użytkowników i dane logowania zostały zachowane.", "authRegister": "Rejestracja", + "authClaim": "Profil przejęty", "authLogin": "Zaloguj", "authLoginPasskey": "Logowanie kluczem passkey", "authLoginPassword": "Logowanie hasłem", @@ -7446,7 +7563,7 @@ }, "services": { "title": "Usługi", - "subtitle": "Włącz lub wyłącz kanały powiadomień i integracje w całym systemie." + "subtitle": "Włącz lub wyłącz kanały powiadomień i API dla całego serwera." }, "integrations": { "title": "Integracje", @@ -7510,7 +7627,7 @@ "app-logs": { "title": "Logi aplikacji", "subtitle": "Wyświetl najnowsze ustrukturyzowane wide-events emitowane przez ten proces.", - "processNote": "Wyświetlane są tylko logi procesu aplikacji — logi procesu workera trafiają do Loki, gdy ta integracja jest skonfigurowana.", + "processNote": "Wyświetlane są tylko logi procesu aplikacji; logi procesu workera trafiają do Loki, gdy ta integracja jest skonfigurowana.", "filterTraceId": "Identyfikator śladu", "filterAction": "Akcja zawiera…", "filterLevel": "Poziom", @@ -7532,7 +7649,8 @@ "refresh": "Odśwież", "eventDetails": "Szczegóły zdarzenia", "closeDetails": "Zamknij szczegóły", - "processDetail": "Bufor jest ulotny i ograniczony do 500 wpisów." + "processDetail": "Bufor jest ulotny i ograniczony do 500 wpisów.", + "cardTitle": "Ostatnie zdarzenia" }, "backups": { "title": "Kopie zapasowe", @@ -7544,7 +7662,7 @@ "loading": "Ładowanie kopii zapasowych...", "loadError": "Nie udało się załadować kopii zapasowych.", "emptyTitle": "Brak kopii zapasowych", - "emptyDescription": "Kopie zapasowe wykonują się automatycznie w każdą niedzielę o 03:00 — lub uruchom jedną teraz, aby utworzyć pierwszą migawkę.", + "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)", @@ -7580,11 +7698,11 @@ "restoreTitle": "Przywróć z kopii zapasowej", "restoreDescription": "Spowoduje to zastąpienie WSZYSTKICH bieżących danych {username} migawką z {when}. Istniejące pomiary, leki, zdarzenia przyjęcia, wpisy nastroju, kanały powiadomień, subskrypcje push i zakolejkowane usunięcia Telegram zostaną usunięte.", "restoreInstanceSettingsLabel": "Przywróć też ustawienia instancji", - "restoreInstanceSettingsHint": "Rejestracja, wymóg MFA, domyślny język, dostępność modułów, konfiguracja powiadomień i AI — brane z migawki i stosowane do wszystkich w tej instalacji. Zostaw niezaznaczone, aby przywrócić tylko to konto.", + "restoreInstanceSettingsHint": "Rejestracja, wymóg MFA, domyślny język, dostępność modułów, konfiguracja powiadomień i AI: brane z migawki i stosowane do wszystkich w tej instalacji. Zostaw niezaznaczone, aby przywrócić tylko to konto.", "restorePromptLabel": "Wpisz RESTORE, aby kontynuować", "previewTitle": "Zawartość kopii", "previewLoading": "Odczytywanie zawartości…", - "previewUnavailable": "Nie udało się odczytać zawartości — przywracanie jest nadal możliwe.", + "previewUnavailable": "Nie udało się odczytać zawartości, ale przywracanie jest nadal możliwe.", "previewStillReading": "Ta kopia powstała we wcześniejszej wersji, więc jej zawartość jest odczytywana w całości, co przy dużych danych chwilę trwa. Otwórz to okno ponownie za kilka minut, aby ją zobaczyć. Przywracanie od tego nie zależy.", "previewEmpty": "Plik nie zawiera rekordów.", "previewOther": "Inne rekordy", @@ -7627,7 +7745,7 @@ "restoreSkippedMedicationTarget": "Analit docelowy leku", "restoreSkippedScheduleRevisionLink": "Następstwo okresów schematu", "restoreSkippedAccountSetting": "Ustawienie konta", - "restoreMissingTitle": "Przywracanie odrzucone — plik jest niekompletny", + "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.", "restoreMissingDocuments": "Dokumenty", @@ -7699,7 +7817,8 @@ "restoreSkippedCycleCiphertext": "Notatka lub etykieta cyklu zaszyfrowana kluczem, którego ten serwer nie ma (dzień lub objaw zachowany bez niej)", "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)" + "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)" }, "danger-zone": { "title": "Strefa zagrożenia", @@ -7716,7 +7835,6 @@ "encryption": { "title": "Szyfrowanie", "subtitle": "Pokrycie szyfrowaniem i status rotacji kluczy dla danych w spoczynku.", - "loading": "Ładowanie statusu szyfrowania...", "loadError": "Nie udało się wczytać statusu szyfrowania.", "coverageTitle": "Pokrycie", "coverageDescription": "Ile zaszyfrowanych wierszy przypada na każdy identyfikator klucza.", @@ -7751,7 +7869,7 @@ "completed": "Zakończono", "failed": "Niepowodzenie" }, - "coverageDetail": "Pokazywane są tylko identyfikatory kluczy i liczby — nigdy materiał klucza.", + "coverageDetail": "Pokazywane są tylko identyfikatory kluczy i liczby, nigdy materiał klucza.", "backupsTitle": "Klucze, których kopie nadal potrzebują", "backupsDescription": "Wycofany klucz jest potrzebny, dopóki którakolwiek kopia poniżej go wymienia.", "backupsStoredLine": "Klucz {key}: {count} zapisanych kopii, najstarsza z {when}", @@ -7761,12 +7879,45 @@ "backupsNone": "Żadna kopia nie zapisała jeszcze kluczy, których potrzebuje.", "backupsStillNeed": "Wszystkie wiersze są zrotowane, ale kopie nadal potrzebują {keys}", "backupsUnrecordedBadge": "Wszystkie wiersze są zrotowane, ale starsze kopie mogą nadal potrzebować wycofanego klucza", - "backupsDetail": "Rotacja ponownie szyfruje każdy wiersz, ale kopia zachowuje klucz, którym zapisano jej zawartość, a jej przywrócenie wymaga tego klucza. Trzymaj wycofany klucz w ENCRYPTION_KEYS, dopóki widnieje poniżej." + "backupsDetail": "Rotacja ponownie szyfruje każdy wiersz, ale kopia zachowuje klucz, którym zapisano jej zawartość, a jej przywrócenie wymaga tego klucza. Trzymaj wycofany klucz w ENCRYPTION_KEYS, dopóki widnieje poniżej.", + "columnsShow": "Pokaż tabelę (z wierszami: {count})", + "columnsHide": "Ukryj tabelę", + "columnsShowAll": "Pokaż wszystkie kolumny ({count})", + "columnsShowWithRows": "Tylko kolumny z wierszami", + "columnsNoneWithRows": "Żadna zaszyfrowana kolumna nie zawiera jeszcze wierszy." + }, + "maintenance": { + "title": "Konserwacja tabeli pomiarów", + "description": "Zwalnia miejsce zajmowane przez stare pozostałości synchronizacji, gdy czyszczenie już się zakończy.", + "body": "Wykonuje vacuum tabeli pomiarów, a potem odbudowuje jej indeksy, jeden po drugim. Odczyt i zapis działają w tym czasie dalej. Wybierz spokojną godzinę i zapewnij wolne miejsce na dysku na jeszcze jedną kopię największego indeksu.", + "tableSize": "Tabela z indeksami", + "indexSize": "Indeksy", + "loading": "Sprawdzanie stanu konserwacji…", + "loadError": "Nie udało się odczytać stanu konserwacji.", + "unavailable": "Nie jest podłączony żaden proces w tle, więc konserwacja nie może tu działać.", + "purgePending": "Czyszczenie starych pozostałości synchronizacji wciąż trwa. Konserwacja może się rozpocząć, gdy się zakończy.", + "state": { + "idle": "Jeszcze nie uruchomiona.", + "queued": "Czeka na proces w tle.", + "running": "Trwa od {since}. Na dużej instancji może to chwilę potrwać.", + "completed": "Zakończona {at}.", + "refusedPurge": "Nie rozpoczęła się, bo czyszczenie wciąż trwało. Uruchom ją ponownie, gdy czyszczenie się zakończy.", + "alreadyRunning": "Nie rozpoczęła się, bo inny przebieg już działał.", + "failed": "Zatrzymana {at}. Dziennik aplikacji podaje krok, a runbook wyjaśnia, co sprawdzić." + }, + "start": "Uruchom konserwację", + "confirmTitle": "Uruchomić konserwację teraz?", + "confirmBody": "Działa w tle, krok po kroku, i może chwilę potrwać. Jeśli dziś nie zrobiono jeszcze kopii zapasowej, najpierw ją zrób.", + "confirm": "Uruchom", + "cancel": "Anuluj", + "started": "Konserwacja uruchomiona.", + "alreadyQueued": "Konserwacja jest już w kolejce lub trwa.", + "startFailed": "Nie udało się uruchomić konserwacji." } }, "carrier": "Operator", "mfaRequired": "Wymagaj drugiego składnika", - "mfaRequiredDescription": "Każde konto bez aktywnego drugiego składnika jest po zalogowaniu kierowane do konfiguracji. To zachęca użytkowników sieci do włączenia go, ale nie blokuje bezpośredniego dostępu przez API ani natywnego (Bearer) dla kont jeszcze nieskonfigurowanych.", + "mfaRequiredDescription": "Konta bez drugiego składnika są po logowaniu w przeglądarce kierowane do jego konfiguracji; dostęp przez API i natywny (Bearer) pozostaje otwarty.", "mfaEnforcedOn": "MFA wł.", "mfaEnforcedOff": "MFA wył.", "mfaEnforcedOnHint": "Ten użytkownik musi używać drugiego składnika. Kliknij, aby przestać go wymagać.", @@ -7784,7 +7935,7 @@ "centralCodex": { "title": "Wspólne połączenie AI", "description": "Podłącz jedno zalogowane konto ChatGPT, które każdy użytkownik tego serwera może włączyć dla funkcji AI.", - "honesty": "To jedno wspólne, zalogowane konto AI — nie klucz API. Każdy, kto je włączy, wysyła przez nie zapytania, więc godzinowe i tygodniowe limity konta są dzielone między nimi, a te same limity zasilają już Coacha. Dostawcy subskrypcyjni mogą wykorzystywać treść do ulepszania swoich modeli i nie obejmuje tego żadna umowa powierzenia przetwarzania danych, dlatego nigdy nie jest to opcja domyślna i pozostaje poniżej dostawców bez trenowania przy odczycie dokumentów. Podłącz tylko konto, które chcesz udostępniać w ten sposób.", + "honesty": "To jedno wspólne, zalogowane konto AI, a nie klucz API. Każdy, kto je włączy, wysyła przez nie zapytania, więc godzinowe i tygodniowe limity konta są dzielone między nimi, a te same limity zasilają już Coacha. Dostawcy subskrypcyjni mogą wykorzystywać treść do ulepszania swoich modeli i nie obejmuje tego żadna umowa powierzenia przetwarzania danych, dlatego nigdy nie jest to opcja domyślna i pozostaje poniżej dostawców bez trenowania przy odczycie dokumentów. Podłącz tylko konto, które chcesz udostępniać w ten sposób.", "statusConnected": "Połączono", "statusDisconnected": "Nie połączono", "statusExpired": "Wygasło", @@ -7796,13 +7947,13 @@ "connectedSince": "Połączono od {when}", "connected": "Wspólne połączenie AI podłączone", "disconnected": "Wspólne połączenie AI usunięte", - "error": "Coś poszło nie tak — spróbuj ponownie" + "error": "Coś poszło nie tak. Spróbuj ponownie" }, "failingJobs": { "title": "Zadania w tle", "description": "Kolejki, których zadania poddały się po wyczerpaniu ponowień.", "none": "W ciągu ostatnich {hours} godzin żadne zadanie nie zawiodło.", - "unavailable": "Brak kolejki zadań do odczytu — to wdrożenie działa bez workera w tle.", + "unavailable": "Brak kolejki zadań do odczytu: to wdrożenie działa bez workera w tle.", "countOne": "{count} niepowodzenie", "countFew": "{count} niepowodzenia", "countOther": "{count} niepowodzeń", @@ -7864,7 +8015,9 @@ "restartNote": "Po zmianie uruchom HealthLog ponownie.", "lastResort": "Ostateczność, tylko jeśli masz pewność, że klucz jest właściwy, a żadna z trzech dróg nie pasuje: ustaw {setting} i uruchom ponownie. HealthLog będzie wtedy działać mimo to i tylko zapisze wynik w logu. To wyłącza kontrolę bezpieczeństwa: przy złym kluczu każda zaszyfrowana wartość zawiedzie, a nowe dane zostaną zapisane tym kluczem.", "codes": "Dla administratora: żądania API odpowiadają 503 z kodem błędu {code}, a /api/health podaje powód {reason}." - } + }, + "loginOverviewCardTitle": "Zdarzenia logowania", + "userColumn": "Użytkownik" }, "achievements": { "title": "Osiągnięcia", @@ -7880,7 +8033,9 @@ "noneUnlockedYet": "Nie odblokowano jeszcze żadnego osiągnięcia.", "pointsValue": "{points} punktów", "metricCount": "{count}", - "metricDays": "{count} dni", + "metricDaysOne": "{count} dzień", + "metricDaysFew": "{count} dni", + "metricDaysOther": "{count} dni", "metricPercent": "{count}%", "badges": { "intakeTotal1": { @@ -7903,14 +8058,6 @@ "title": "Legenda przyjęć", "description": "Zarejestruj 300 przyjęć leków." }, - "overIntake1": { - "title": "Podwójna dawka", - "description": "Przyjmij lek co najmniej raz więcej niż zaplanowano." - }, - "skippedIntake1": { - "title": "Krok wstecz", - "description": "Pomiń co najmniej jedno zaplanowane przyjęcie." - }, "passkeyCreated1": { "title": "Klucz dostępu ustawiony", "description": "Utwórz swój pierwszy klucz dostępu." @@ -7936,19 +8083,19 @@ "description": "Przyjmij wszystkie leki na czas przez 1 dzień." }, "compliance801": { - "title": "Przestrzeganie 80% · 1d", + "title": "Przestrzeganie 80% przez 1 dzień", "description": "Osiągnij co najmniej 80% przestrzegania w 30 dni przez 1 dzień." }, "bmiGreen1": { - "title": "BMI na zielono · 1d", + "title": "BMI na zielono przez 1 dzień", "description": "Utrzymaj BMI w normie przez 1 dzień." }, "bpGreen1": { - "title": "Ciśnienie na zielono · 1d", + "title": "Ciśnienie na zielono przez 1 dzień", "description": "Utrzymaj ciśnienie w normie przez 1 dzień." }, "pulseGreen1": { - "title": "Tętno na zielono · 1d", + "title": "Tętno na zielono przez 1 dzień", "description": "Utrzymaj tętno spoczynkowe w normie przez 1 dzień." }, "onTimePerfect7": { @@ -7956,19 +8103,19 @@ "description": "Przyjmij wszystkie leki na czas przez 7 kolejnych dni." }, "compliance807": { - "title": "Przestrzeganie 80% · 7d", + "title": "Przestrzeganie 80% przez 7 dni", "description": "Osiągnij co najmniej 80% przestrzegania w 30 dni przez 7 kolejnych dni." }, "bmiGreen7": { - "title": "BMI na zielono · 7d", + "title": "BMI na zielono przez 7 dni", "description": "Utrzymaj BMI w normie przez 7 kolejnych dni." }, "bpGreen7": { - "title": "Ciśnienie na zielono · 7d", + "title": "Ciśnienie na zielono przez 7 dni", "description": "Utrzymaj ciśnienie w normie przez 7 kolejnych dni." }, "pulseGreen7": { - "title": "Tętno na zielono · 7d", + "title": "Tętno na zielono przez 7 dni", "description": "Utrzymaj tętno spoczynkowe w normie przez 7 kolejnych dni." }, "onTimePerfect30": { @@ -7976,19 +8123,19 @@ "description": "Przyjmij wszystkie leki na czas przez 30 kolejnych dni." }, "compliance8030": { - "title": "Przestrzeganie 80% · 30d", + "title": "Przestrzeganie 80% przez 30 dni", "description": "Osiągnij co najmniej 80% przestrzegania w 30 dni przez 30 kolejnych dni." }, "bmiGreen30": { - "title": "BMI na zielono · 30d", + "title": "BMI na zielono przez 30 dni", "description": "Utrzymaj BMI w normie przez 30 kolejnych dni." }, "bpGreen30": { - "title": "Ciśnienie na zielono · 30d", + "title": "Ciśnienie na zielono przez 30 dni", "description": "Utrzymaj ciśnienie w normie przez 30 kolejnych dni." }, "pulseGreen30": { - "title": "Tętno na zielono · 30d", + "title": "Tętno na zielono przez 30 dni", "description": "Utrzymaj tętno spoczynkowe w normie przez 30 kolejnych dni." }, "onTimePerfect180": { @@ -7996,19 +8143,19 @@ "description": "Przyjmij wszystkie leki na czas przez 180 kolejnych dni." }, "compliance80180": { - "title": "Przestrzeganie 80% · 180d", + "title": "Przestrzeganie 80% przez 180 dni", "description": "Osiągnij co najmniej 80% przestrzegania w 30 dni przez 180 kolejnych dni." }, "bmiGreen180": { - "title": "BMI na zielono · 180d", + "title": "BMI na zielono przez 180 dni", "description": "Utrzymaj BMI w normie przez 180 kolejnych dni." }, "bpGreen180": { - "title": "Ciśnienie na zielono · 180d", + "title": "Ciśnienie na zielono przez 180 dni", "description": "Utrzymaj ciśnienie w normie przez 180 kolejnych dni." }, "pulseGreen180": { - "title": "Tętno na zielono · 180d", + "title": "Tętno na zielono przez 180 dni", "description": "Utrzymaj tętno spoczynkowe w normie przez 180 kolejnych dni." }, "onTimePerfect360": { @@ -8016,19 +8163,19 @@ "description": "Przyjmij wszystkie leki na czas przez 360 kolejnych dni." }, "compliance80360": { - "title": "Przestrzeganie 80% · 360d", + "title": "Przestrzeganie 80% przez 360 dni", "description": "Osiągnij co najmniej 80% przestrzegania w 30 dni przez 360 kolejnych dni." }, "bmiGreen360": { - "title": "BMI na zielono · 360d", + "title": "BMI na zielono przez 360 dni", "description": "Utrzymaj BMI w normie przez 360 kolejnych dni." }, "bpGreen360": { - "title": "Ciśnienie na zielono · 360d", + "title": "Ciśnienie na zielono przez 360 dni", "description": "Utrzymaj ciśnienie w normie przez 360 kolejnych dni." }, "pulseGreen360": { - "title": "Tętno na zielono · 360d", + "title": "Tętno na zielono przez 360 dni", "description": "Utrzymaj tętno spoczynkowe w normie przez 360 kolejnych dni." }, "moodFirst": { @@ -8112,15 +8259,15 @@ "description": "Zmieniłeś język aplikacji co najmniej raz." }, "missFree7": { - "title": "Bez pominiętej dawki · 7 dni", + "title": "Bez pominiętej dawki przez 7 dni", "description": "7 kolejnych dni bez pominiętej dawki." }, "missFree30": { - "title": "Bez pominiętej dawki · 30 dni", + "title": "Bez pominiętej dawki przez 30 dni", "description": "30 kolejnych dni bez pominiętej dawki." }, "missFree90": { - "title": "Bez pominiętej dawki · 90 dni", + "title": "Bez pominiętej dawki przez 90 dni", "description": "90 kolejnych dni bez pominiętej dawki." }, "measurementWeeks4": { @@ -8129,10 +8276,10 @@ }, "selfContextComplete": { "title": "Wywiad uzupełniony", - "description": "Wszystkie cztery pola wywiadu wypełnione — Coach zna Twój kontekst." + "description": "Wszystkie cztery pola wywiadu wypełnione. Coach zna Twój kontekst." }, "sleepLog7": { - "title": "Dziennik snu · 7 dni", + "title": "Dziennik snu przez 7 dni", "description": "Dane o śnie zapisane przez 7 kolejnych dni." } }, @@ -8151,7 +8298,7 @@ }, "hiddenCard": { "title": "Ukryte osiągnięcie", - "description": "Śledź dalej — może na nie natrafisz.", + "description": "Śledź dalej, może na nie natrafisz.", "ariaLabel": "Ukryte zablokowane osiągnięcie" }, "hiddenUnlockToast": { @@ -8160,7 +8307,7 @@ "dashboardCard": { "title": "Ostatnio odblokowane", "viewAll": "Zobacz wszystkie", - "empty": "Brak osiągnięć — zapisuj dalej, aby odblokować pierwsze." + "empty": "Brak osiągnięć. Zapisuj dalej, aby odblokować pierwsze." } }, "notifications": { @@ -8222,9 +8369,6 @@ "enabledToast": "Proaktywne podpowiedzi Coacha włączone.", "disabledToast": "Proaktywne podpowiedzi Coacha wyłączone.", "saveError": "Nie udało się zapisać. Spróbuj ponownie później.", - "suggestionsLabel": "Przykładowe sugestie", - "suggestionsDesc": "Czasami pokazuje gotowe przykładowe pytanie na ekranie Coacha.", - "suggestionsAria": "Przełącz przykładowe sugestie Coacha", "aiComposedLabel": "Pozwól Coachowi sformułować przypomnienie", "aiComposedDesc": "Gdy dostawca jest dostępny, proaktywne przypomnienie tworzy model zamiast stałego szablonu. Domyślnie wyłączone.", "aiComposedAria": "Przełącz proaktywne przypomnienie tworzone przez SI" @@ -8295,7 +8439,27 @@ "loginRequired": "Zaloguj się, aby zarządzać powiadomieniami.", "someChannelsGloballyDisabled": "Co najmniej jeden kanał jest wyłączony w ustawieniach i nie będzie otrzymywać powiadomień.", "reminderSettingsLink": "Ustawienia przypomnień", - "channelsLink": "Kanały" + "channelsLink": "Kanały", + "handover": { + "claimedTitle": "{name} ma teraz własne konto", + "claimedEnd": "{name} przejmuje swoją kartę zdrowia. Twój dostęp się zakończył.", + "claimedRead": "{name} przejmuje swoją kartę zdrowia. Nadal możesz ją oglądać, ale już nie zmieniać.", + "claimedManage": "{name} przejmuje swoją kartę zdrowia. Nadal możesz nią zarządzać, dopóki nie zapadnie inna decyzja.", + "changedTitle": "{name} zmienia Twój dostęp", + "changedEnd": "Twój dostęp do karty zdrowia osoby {name} się zakończył.", + "changedRead": "Możesz teraz oglądać kartę zdrowia osoby {name}, ale nie zmieniać jej.", + "changedManage": "Możesz zarządzać kartą zdrowia osoby {name}.", + "unnamed": { + "claimedTitle": "Osoba pod Twoją opieką ma teraz własne konto", + "claimedEnd": "Osoba pod Twoją opieką przejęła swoją dokumentację zdrowotną. Twój dostęp wygasł.", + "claimedRead": "Osoba pod Twoją opieką przejęła swoją dokumentację zdrowotną. Nadal możesz ją przeglądać, ale nie możesz już jej zmieniać.", + "claimedManage": "Osoba pod Twoją opieką przejęła swoją dokumentację zdrowotną. Nadal możesz nią zarządzać, dopóki nie zdecyduje inaczej.", + "changedTitle": "Twój dostęp do dokumentacji zdrowotnej się zmienił", + "changedEnd": "Twój dostęp do dokumentacji zdrowotnej osoby pod Twoją opieką wygasł.", + "changedRead": "Możesz teraz przeglądać dokumentację zdrowotną osoby pod Twoją opieką, ale nie możesz jej zmieniać.", + "changedManage": "Możesz zarządzać dokumentacją zdrowotną osoby pod Twoją opieką." + } + } }, "i18n": { "maintainershipBanner": { @@ -8326,7 +8490,7 @@ } }, "edit": { - "title": "Edytuj zakres docelowy — {metric}", + "title": "Edytuj zakres docelowy: {metric}", "description": "Dostosuj zakres docelowy używany w wykresach, insights i raporcie medycznym. Wartości w {unit}.", "derivedHint": "Ta karta jest wyprowadzona z innych metryk. Edytuj cele bazowe (np. ciśnienie lub waga).", "minLabel": "Min", @@ -8350,15 +8514,19 @@ "currentValue": "Aktualnie: {value} {unit}", "targetRangeValue": "Zakres docelowy: {min}–{max} {unit}", "sourceLabel": "Źródło: {source}", + "sourceNames": { + "moodEntries": "Wpisy nastroju", + "sevenDays": "7 dni" + }, "inTarget": "W zakresie docelowym", "aboveTarget": "{delta} {unit} powyżej zakresu", "belowTarget": "{delta} {unit} poniżej zakresu", "diastolic": "Rozkurczowe", - "glucoseFasting": "Glikemia — na czczo", - "glucosePostprandial": "Glikemia — po posiłku", - "glucoseRandom": "Glikemia — dowolna", - "glucoseBedtime": "Glikemia — przed snem", - "glucoseUnspecified": "Glikemia — bez oznaczenia", + "glucoseFasting": "Glikemia: na czczo", + "glucosePostprandial": "Glikemia: po posiłku", + "glucoseRandom": "Glikemia: dowolna", + "glucoseBedtime": "Glikemia: przed snem", + "glucoseUnspecified": "Glikemia: bez oznaczenia", "status": { "underweight": "Niedowaga", "normal": "Normal", @@ -8444,7 +8612,7 @@ "askMedicationName": "Podaj lek, np. przyjęty Ramipril.", "medicationNotFoundExact": "Nie znaleziono leku. Wyślij nazwę dokładnie tak, jak w HealthLog.", "helpHeader": "Dostępne polecenia:", - "helpBody": "/help — pokaż tę pomoc\n/add — zapisz przyjęcie\n/start — uruchom bota\n\nPolecenia tekstowe:\ntaken <nazwa> — potwierdź przyjęcie\n\nPrzyciski w przypomnieniach:\n• Przyjęto — potwierdź przyjęcie\n• 🕐 1h / 🕐 3h — odłóż przypomnienie\n• ⏭ Pomiń — pomiń przyjęcie\n• ✓ Potwierdź — potwierdź pominięte przyjęcie", + "helpBody": "/help: pokaż tę pomoc\n/add: zapisz przyjęcie\n/start: uruchom bota\n\nPolecenia tekstowe:\ntaken <nazwa>: potwierdź przyjęcie\n\nPrzyciski w przypomnieniach:\n• Przyjęto: potwierdź przyjęcie\n• 🕐 1h / 🕐 3h: odłóż przypomnienie\n• ⏭ Pomiń: pomiń przyjęcie\n• ✓ Potwierdź: potwierdź pominięte przyjęcie", "measureValuePrompt": "Odpowiedz wartością, aby ją zapisać (opcjonalnie).", "buttonMeasureValue": "Wartość…", "measureValueSaved": "Pomiar zapisany.", @@ -8469,9 +8637,9 @@ }, "lowStockReminders": { "title": "Niski zapas: {medName}", - "bodyToday": "{medName}: pozostało {units} jednostek — mniej niż jeden dzień zapasu.", - "bodyReorder": "{medName}: pozostało {units} jednostek. Zapas skończy się około {runsOutOn} — zamów ponownie do {reorderBy}.", - "bodyLastDose": "{medName}: pozostało {units} jednostek — to mniej więcej Twoja ostatnia dawka. Zapas skończy się około {runsOutOn}.", + "bodyToday": "{medName}: pozostało {units} jednostek, czyli mniej niż jeden dzień zapasu.", + "bodyReorder": "{medName}: pozostało {units} jednostek. Zapas skończy się około {runsOutOn}. Zamów ponownie do {reorderBy}.", + "bodyLastDose": "{medName}: pozostało {units} jednostek, to mniej więcej Twoja ostatnia dawka. Zapas skończy się około {runsOutOn}.", "bodyExpired": "{medName}: brak zapasu do użycia. {units} jednostek oznaczono jako przeterminowane.", "bodyAsNeeded": "{medName}: zostało tylko {units} jednostek. Zamów ponownie, zanim będziesz potrzebować kolejnej dawki." }, @@ -8498,11 +8666,11 @@ "questionConditions": "Czy masz choroby przewlekłe, o których Coach powinien wiedzieć?", "questionFocus": "Czy jest coś konkretnego, na co Coach powinien zwracać uwagę?", "questionAllergies": "Czy masz alergie lub nietolerancje?", - "questionAboutMe": "Chcesz opowiedzieć Coachowi coś o sobie — codzienność, cele, kontekst?" + "questionAboutMe": "Chcesz opowiedzieć Coachowi coś o sobie: codzienność, cele, kontekst?" }, "clinicianView": { "title": "Udostępniona dokumentacja zdrowotna", - "period": "Okres raportu: {start} – {end}", + "period": "Okres raportu: {start}–{end}", "expires": "Ten link wygasa dnia {date}.", "provenance": "To jest tylko do odczytu podsumowanie udostępnione przez osobę, do której należy, wyeksportowane z jej samodzielnie rejestrowanych danych HealthLog. Wartości są zgłaszane samodzielnie i mogą obejmować odczyty zsynchronizowane z urządzeń; należy je traktować jako zgłoszone przez pacjenta, a nie jako badanie kliniczne.", "labs": "Wyniki laboratoryjne", @@ -8526,7 +8694,7 @@ "strain": "Wynik obciążenia", "score": "Wynik dobrostanu" }, - "footer": "Udostępnione z HealthLog — samodzielnie hostowanego trackera zdrowia.", + "footer": "Udostępnione z HealthLog, samodzielnie hostowanego trackera zdrowia.", "unlock": { "title": "Wprowadź hasło", "description": "Ta udostępniona dokumentacja zdrowotna jest chroniona. Wprowadź otrzymane hasło, aby ją wyświetlić.", @@ -8561,7 +8729,11 @@ }, "course": "Kuracja {n}", "courseRange": "od {start} do {end}", - "courseSince": "od {start}" + "courseSince": "od {start}", + "unavailable": { + "title": "Link wygasł lub jest nieprawidłowy", + "description": "Ten link udostępniania wygasł, został cofnięty lub jest niekompletny. Poproś osobę, która go udostępniła, o nowy link." + } }, "cycle": { "symptomCategory": { @@ -8610,11 +8782,11 @@ "generic": "Twoje {metric} jest o około {delta} {unit} {dir} w fazie lutealnej niż w fazie folikularnej.", "dirHigher": "wyższe", "dirLower": "niższe", - "empty": "Za mało cykli i pomiarów, aby pokazać wzorzec fazy. Rejestruj dalej — uzupełni się to wraz z historią." + "empty": "Za mało cykli i pomiarów, aby pokazać wzorzec fazy. Rejestruj dalej, a uzupełni się to wraz z historią." }, "crosstab": { "description": "Jak każdy pomiar wypada między fazą lutealną a folikularną, na Twoich danych.", - "pairLabel": "{metric} · lutealna vs folikularna", + "pairLabel": "{metric}: faza lutealna vs folikularna", "detail": "Lutealna {lutealAvg} {unit} ({lutealDays} dni) vs folikularna {follicularAvg} {unit} ({follicularDays} dni).", "metricRestingHeartRate": "Tętno spoczynkowe", "metricHrv": "Zmienność rytmu serca", @@ -8650,7 +8822,7 @@ }, "context": { "MENSTRUAL": "Poziom hormonów jest teraz najniższy, dlatego te dni mogą sprawiać wrażenie spokojnego resetu. Niektóre osoby zauważają skurcze, lżejszy sen albo chęć zwolnienia tempa; inne przechodzą przez nie niemal bez zmian. To wszystko mieści się w normie, a odczucia potrafią zmieniać się z cyklu na cykl.", - "FOLLICULAR": "Wraz z rosnącym estrogenem wiele osób opisuje te dni jako jasne i pełne otwartości – sen często przychodzi łatwiej, a umysł wydaje się bardziej klarowny. Podstawowa temperatura ciała zwykle utrzymuje się w niższym zakresie aż do chwili tuż przed owulacją. Długość tej fazy różni się najbardziej – od osoby do osoby i od cyklu do cyklu.", + "FOLLICULAR": "Wraz z rosnącym estrogenem wiele osób opisuje te dni jako jasne i pełne otwartości: sen często przychodzi łatwiej, a umysł wydaje się bardziej klarowny. Podstawowa temperatura ciała zwykle utrzymuje się w niższym zakresie aż do chwili tuż przed owulacją. Długość tej fazy różni się najbardziej, od osoby do osoby i od cyklu do cyklu.", "OVULATORY": "Estrogen osiąga szczyt tuż przed uwolnieniem komórki jajowej, często z krótkim wzrostem nastroju i niewielkim podniesieniem podstawowej temperatury ciała w ciągu kolejnego dnia lub dwóch. Śluz szyjkowy w tym oknie zwykle staje się bardziej przejrzysty i ciągliwy. Krótkie ukłucie po jednej stronie niektóre osoby zauważają; wiele nie czuje nic, a oba warianty są zupełnie normalne.", "LUTEAL": "Progesteron prowadzi przez drugą połowę cyklu, a podstawowa temperatura ciała zwykle ustala się nieco wyżej niż wcześniej. Niektóre osoby zauważają z dnia na dzień zmiany w śnie, apetycie, nastroju czy energii, podczas gdy inne czują się stabilnie przez cały czas. Ten wzorzec jest indywidualny i często wygląda inaczej z miesiąca na miesiąc." }, @@ -8737,8 +8909,8 @@ "ariaUnknown": "Brak aktywnego cyklu", "caption": "twojego cyklu", "firstPeriodCta": "Zapisz pierwszą miesiączkę", - "periodOverdue": "Okres jest późniejszy niż zwykle — licznik dni jest wstrzymany, dopóki nie zapiszesz następnego okresu.", - "periodOverdueDays": "Okres jest {days} dni późniejszy niż zwykle — licznik dni jest wstrzymany, dopóki nie zapiszesz następnego okresu.", + "periodOverdue": "Okres jest późniejszy niż zwykle. Licznik dni jest wstrzymany, dopóki nie zapiszesz następnego okresu.", + "periodOverdueDays": "Okres jest {days} dni późniejszy niż zwykle. Licznik dni jest wstrzymany, dopóki nie zapiszesz następnego okresu.", "logPeriodCta": "Zapisz okres" }, "calendar": { @@ -8750,7 +8922,7 @@ "prevMonth": "Poprzedni miesiąc", "nextMonth": "Następny miesiąc", "loading": "Ładowanie kalendarza…", - "learning": "Uczymy się Twojego cyklu — zapisz co najmniej trzy pełne cykle, a okno płodne, owulacja i fazy się uzupełnią. Do tego czasu kalendarz pokazuje tylko to, co zapisałaś.", + "learning": "Uczymy się Twojego cyklu. Zapisz co najmniej trzy pełne cykle, a okno płodne, owulacja i fazy się uzupełnią. Do tego czasu kalendarz pokazuje tylko to, co zapisałaś.", "legendSymptom": "Objawy", "legendOvulationConfirmed": "Potwierdzona owulacja", "flowSpotting": "plamienie", @@ -8763,7 +8935,7 @@ "predictions": { "title": "Co dalej", "nextPeriod": "Następna miesiączka", - "nextPeriodWindow": "{low} – {high}", + "nextPeriodWindow": "{low}–{high}", "fertileWindow": "Okno płodne", "ovulation": "Szacowana owulacja", "confidenceHigh": "Wysoka", @@ -8772,7 +8944,7 @@ "stillLearning": "Wciąż się uczę", "stillLearningBody": "Zapisz co najmniej trzy pełne cykle, a prognozy się wyostrzą. Do tego czasu to zgrubne oszacowanie.", "rawMode": "Prognoza wyłączona", - "rawModeBody": "Wyłączyłaś prognozy — pokazywane są tylko zapisane dane, bez przewidywań.", + "rawModeBody": "Wyłączyłaś prognozy: pokazywane są tylko zapisane dane, bez przewidywań.", "none": "Brak prognozy. Zapisz miesiączkę, aby zacząć.", "ovulationConfirmed": "Owulacja (potwierdzona)" }, @@ -8805,7 +8977,7 @@ "symptoms": "Objawy", "temperature": "Podstawowa temperatura ciała", "temperatureDisturbed": "Oznacz pomiar jako zakłócony", - "temperatureDisturbedHint": "Gorączka, choroba, alkohol lub spóźniony pomiar — wykluczony z oceny temperatury.", + "temperatureDisturbedHint": "Gorączka, choroba, alkohol lub spóźniony pomiar: wykluczony z oceny temperatury.", "ovulationTest": "Test owulacyjny", "mucus": "Śluz szyjkowy", "cervix": "Szyjka macicy", @@ -8834,7 +9006,7 @@ "temperatureSection": "Temperatura i owulacja", "intimacySection": "Intymność i antykoncepcja", "testsSection": "Testy", - "phaseDayHeader": "Dzień {day} · {phase}", + "phaseDayHeader": "Dzień {day}, {phase}", "bbtHint": "Zmierz ją rano zaraz po przebudzeniu, przed wstaniem.", "titleForDate": "Wpis z {date}", "descriptionToday": "Dzisiaj. Krwawienie, objawy i notatki.", @@ -8868,7 +9040,7 @@ "remindersDescription": "Włącz przypomnienia o okresie dla każdego kanału w ustawieniach powiadomień.", "remindersLink": "Ustawienia powiadomień", "fertileReminder": "Przypomnienie o oknie płodności", - "fertileReminderDescription": "Otrzymaj powiadomienie kilka dni przed szacowanym oknem płodności. Domyślnie wyłączone – włącz je dla każdego kanału w ustawieniach powiadomień." + "fertileReminderDescription": "Otrzymaj powiadomienie kilka dni przed szacowanym oknem płodności. Domyślnie wyłączone; włącz je dla każdego kanału w ustawieniach powiadomień." }, "testResult": { "POSITIVE": "Pozytywny", @@ -8901,15 +9073,15 @@ "fieldInfo": { "bbt": "Twoja temperatura spoczynkowa, mierzona rano zaraz po przebudzeniu, przed wstaniem. Po owulacji nieznacznie rośnie (o około 0,2–0,3 °C).", "bbtLabel": "Czym jest temperatura podstawowa?", - "mucus": "Wydzielina z pochwy zmienia się w cyklu — od suchej lub lepkiej do kremowej, a następnie wodnistej i przypominającej białko jaja w najbardziej płodnych dniach.", + "mucus": "Wydzielina z pochwy zmienia się w cyklu: od suchej lub lepkiej do kremowej, a następnie wodnistej i przypominającej białko jaja w najbardziej płodnych dniach.", "mucusLabel": "Czym jest śluz szyjkowy?", - "ovulationTest": "Domowy test moczu wykrywający skok LH — wyrzut hormonu około 24–36 h przed owulacją. Wynik dodatni wskazuje najbardziej płodne okno.", + "ovulationTest": "Domowy test moczu wykrywający skok LH, czyli wyrzut hormonu około 24–36 h przed owulacją. Wynik dodatni wskazuje najbardziej płodne okno.", "ovulationTestLabel": "Czym jest test owulacyjny?", "spotting": "Lekkie krwawienie poza okresem.", "spottingLabel": "Czym jest plamienie między okresami?", "pregnancyTest": "Wynik domowego testu ciążowego (hCG).", "pregnancyTestLabel": "Czym jest test ciążowy?", - "progesteroneTest": "Test potwierdzający, że doszło do owulacji — progesteron rośnie po niej.", + "progesteroneTest": "Test potwierdzający, że doszło do owulacji: progesteron rośnie po niej.", "progesteroneTestLabel": "Czym jest test progesteronu?", "intercourse": "Czy tego dnia doszło do stosunku.", "intercourseLabel": "Co śledzi stosunek?", @@ -8962,14 +9134,14 @@ "postpone": { "menuItem": "Przełóż…", "title": "Przełóż termin", - "description": "Przesuwa tylko bieżący termin — regularny rytm pozostaje bez zmian.", + "description": "Przesuwa tylko bieżący termin, a regularny rytm pozostaje bez zmian.", "chip": "+{days} dni", "dateLabel": "Nowa data", "confirm": "Przełóż" }, "skip": { "heading": "Pomiń ten termin", - "explainer": "Zapisane uczciwie jako pominięte — nie jako wykonane. Odstęp zaczyna się od nowa od dzisiaj.", + "explainer": "Zapisane uczciwie jako pominięte, nie jako wykonane. Odstęp zaczyna się od nowa od dzisiaj.", "action": "Pomiń", "confirmTitle": "Pominąć ten termin?" }, @@ -9011,10 +9183,10 @@ "VISCERAL_FAT": "Tłuszcz trzewny", "BODY_MASS_INDEX": "Wskaźnik masy ciała (BMI)", "WAIST_CIRCUMFERENCE": "Obwód talii", - "PHQ9_SCORE": "PHQ-9 · nastrój", - "GAD7_SCORE": "GAD-7 · lęk", - "WHO5_SCORE": "WHO-5 · dobrostan", - "SCI_SCORE": "SCI · sen" + "PHQ9_SCORE": "PHQ-9 (nastrój)", + "GAD7_SCORE": "GAD-7 (lęk)", + "WHO5_SCORE": "WHO-5 (dobrostan)", + "SCI_SCORE": "SCI (sen)" }, "typeGroups": { "vitals": "Parametry życiowe", @@ -9110,7 +9282,7 @@ "localOcr": { "heading": "Odczytuj skany na tym urządzeniu", "description": "Odczytuje zdjęcie na Twoim urządzeniu i wysyła do dostawcy tylko rozpoznany tekst.", - "detail": "Przydatne, gdy dostawca AI nie odczytuje obrazów. Mniej dokładne — sprawdź każdą wartość przed zapisem." + "detail": "Przydatne, gdy dostawca AI nie odczytuje obrazów. Mniej dokładne, więc sprawdź każdą wartość przed zapisem." }, "sort": { "heading": "Sortowanie", @@ -9154,7 +9326,7 @@ "sourceRangePlaceholder": "np. 3,5 - 5,0 lub < 5", "sourceRangeAdd": "+ Dodaj zakres z wyniku", "sourceRangeHint": "Opcjonalne. Dotyczy tylko tego pomiaru; zapisany zakres pozostaje bez zmian.", - "sourceRangeUnparsed": "Zapisane bez zmian — nie odczytano z tego zakresu liczbowego." + "sourceRangeUnparsed": "Zapisane bez zmian, nie odczytano z tego zakresu liczbowego." }, "backToLabs": "Wróć do wyników", "hasNote": "Notatka", @@ -9172,7 +9344,7 @@ "emptyDescription": "Dodaj wartość, aby śledzić ten biomarker w czasie.", "title": "Biomarker", "genericDescription": "Biomarker laboratoryjny, który śledzisz. Dodaj pomiary, aby obserwować jego trend względem zakresu referencyjnego.", - "valuesTitle": "{marker} — wszystkie odczyty", + "valuesTitle": "{marker}: wszystkie odczyty", "valuesDescription": "Każdy odczyt tego biomarkera. Edytuj lub usuń wpis bezpośrednio.", "backToMarker": "Powrót do {marker}", "latestResult": "Ostatni wynik" @@ -9192,7 +9364,7 @@ }, "biomarker": { "manageTitle": "Biomarkery", - "managerDescription": "Zdefiniuj marker raz — jednostka i zakres są używane dla każdego pomiaru.", + "managerDescription": "Zdefiniuj marker raz, a jednostka i zakres będą używane dla każdego pomiaru.", "define": "Zdefiniuj", "defineFirst": "Zdefiniuj pierwszy biomarker", "defineTitle": "Zdefiniuj biomarker", @@ -9211,7 +9383,7 @@ "form": { "seedLabel": "Zacznij od popularnego markera", "seedPlaceholder": "Wybierz marker (opcjonalnie)", - "seedHint": "Wybór wypełnia pola poniżej — dopasuj zakres do swojego laboratorium.", + "seedHint": "Wybór wypełnia pola poniżej. Dopasuj zakres do swojego laboratorium.", "name": "Nazwa", "namePlaceholder": "np. Cholesterol LDL", "unit": "Jednostka", @@ -9225,7 +9397,7 @@ "upperPlaceholder": "Górna granica", "context": "Kontekst", "contextPlaceholder": "Opcjonalna notatka o tym markerze", - "rangeHint": "Obie granice są opcjonalne — zostaw jedną pustą dla zakresu otwartego (np. LDL < 116).", + "rangeHint": "Obie granice są opcjonalne. Zostaw jedną pustą dla zakresu otwartego (np. LDL < 116).", "requiredError": "Nazwa i jednostka są wymagane.", "rangeOrderError": "Dolna granica nie może przekraczać górnej.", "save": "Zapisz biomarker", @@ -9281,36 +9453,36 @@ "bloodCount": "Morfologia krwi" }, "desc": { - "total-cholesterol": "Cholesterol całkowity to suma cholesterolu przenoszonego przez wszystkie cząstki lipoprotein we krwi, w tym frakcje LDL, HDL i pozostałości. Sam cholesterol jest niezbędnym budulcem błon komórkowych, hormonów i kwasów żółciowych. Jako pojedyncza liczba pozostaje miarą zgrubną, ponieważ zarówno wysoki udział HDL, jak i wysoki udział LDL podnoszą sumę — dlatego zwykle odczytuje się go wraz z poszczególnymi frakcjami. Oznacza się go przede wszystkim w ramach długoterminowej oceny ryzyka sercowo-naczyniowego.", + "total-cholesterol": "Cholesterol całkowity to suma cholesterolu przenoszonego przez wszystkie cząstki lipoprotein we krwi, w tym frakcje LDL, HDL i pozostałości. Sam cholesterol jest niezbędnym budulcem błon komórkowych, hormonów i kwasów żółciowych. Jako pojedyncza liczba pozostaje miarą zgrubną, ponieważ zarówno wysoki udział HDL, jak i wysoki udział LDL podnoszą sumę, dlatego zwykle odczytuje się go wraz z poszczególnymi frakcjami. Oznacza się go przede wszystkim w ramach długoterminowej oceny ryzyka sercowo-naczyniowego.", "ldl": "Cholesterol LDL to cholesterol przenoszony przez cząstki lipoprotein o małej gęstości, czyli frakcja, która dostarcza cholesterol do tkanek, ale może też odkładać go w ścianach tętnic. Przez lata odkładanie to przyczynia się do blaszki miażdżycowej, dlatego LDL jest jednym z najbaczniej obserwowanych markerów lipidowych. Mniejsza ekspozycja w długim okresie zwykle wiąże się z niższym ryzykiem sercowo-naczyniowym. Interpretuje się go według kategorii ryzyka, a nie wobec jednej stałej wartości prawidłowej.", "hdl": "Cholesterol HDL to cholesterol przenoszony przez cząstki lipoprotein o dużej gęstości, które zbierają nadmiar cholesterolu z tkanek i odprowadzają go do wątroby w celu ponownego wykorzystania lub usunięcia. Z powodu tej funkcji transportowej często nazywa się go frakcją ochronną. Bardzo niskie wartości zwykle towarzyszą wyższemu ryzyku sercowo-naczyniowemu, jednak sztuczne jego podnoszenie nie obniżało ryzyka w sposób wiarygodny, dlatego odczytuje się go jako marker, a nie cel. Zwykle interpretuje się go razem z LDL i trójglicerydami.", "triglycerides": "Trójglicerydy to główna postać tłuszczu krążącego we krwi, dostarczana zarówno z dietą, jak i wytwarzana przez wątrobę, i stanowią kluczowy zapas energii. Wartości rosną po posiłkach, dlatego próbkę pobiera się zwykle na czczo, aby była porównywalna. Utrzymujące się wysokie trójglicerydy wiążą się z ryzykiem metabolicznym i sercowo-naczyniowym i często towarzyszą insulinooporności oraz niskiemu HDL. Odczytuje się je jako jeden ze składników całościowego obrazu lipidowego.", "fasting-glucose": "Glukoza na czczo to stężenie cukru we krwi mierzone po nocnym poście, odzwierciedlające, jak organizm utrzymuje swój podstawowy poziom cukru bez niedawnego posiłku. Glukoza jest głównym natychmiastowym paliwem organizmu, a insulina utrzymuje ją zwykle w wąskim zakresie. Pomiar na czczo eliminuje wpływ niedawnych posiłków, co czyni go standardowym badaniem przesiewowym działania regulacji glukozy. To jeden z markerów, którymi ocenia się zdrowie metaboliczne w czasie.", "hba1c": "HbA1c to odsetek hemoglobiny w czerwonych krwinkach, który związał się z glukozą i gromadzi się proporcjonalnie do średniej ekspozycji na cukier we krwi. Ponieważ czerwone krwinki żyją około trzech miesięcy, odzwierciedla średnie stężenie glukozy z poprzednich dwóch–trzech miesięcy, a nie pojedynczej chwili. Czyni to z niego stabilny, niezależny od posiłków obraz kontroli glikemii. Jego wiarygodność mogą zaburzać stany zmieniające czas życia czerwonych krwinek.", "tsh": "TSH (hormon tyreotropowy) jest uwalniany przez przysadkę, aby sygnalizować tarczycy wytwarzanie jej hormonów, i dostosowuje się w górę lub w dół, by utrzymać je w równowadze. Dzięki tej pętli sprzężenia zwrotnego TSH jest najczulszym badaniem przesiewowym pierwszego rzutu funkcji tarczycy: zwykle zmienia się, zanim same hormony tarczycy odbiegną od normy. Podwyższona lub obniżona wartość wskazuje na niedoczynną lub nadczynną tarczycę i zwykle prowadzi do bezpośredniego oznaczenia hormonów tarczycy. Ciąża i wiek przesuwają oczekiwany zakres.", - "ft3": "Wolne T3 (trójjodotyronina) to niezwiązana, biologicznie czynna postać silniejszego hormonu tarczycy — frakcja dostępna do działania na tkanki. Większość krążącego T3 jest związana z białkami nośnikowymi i nieaktywna, dlatego wolna część odzwierciedla rzeczywistą dostępność hormonu. Oznacza się je, aby scharakteryzować funkcję tarczycy, zwłaszcza wraz z TSH i wolnym T4, gdy wyniki są niejasne. Wartości silnie zależą od metody oznaczenia i odczytuje się je w kontekście klinicznym.", + "ft3": "Wolne T3 (trójjodotyronina) to niezwiązana, biologicznie czynna postać silniejszego hormonu tarczycy, czyli frakcja dostępna do działania na tkanki. Większość krążącego T3 jest związana z białkami nośnikowymi i nieaktywna, dlatego wolna część odzwierciedla rzeczywistą dostępność hormonu. Oznacza się je, aby scharakteryzować funkcję tarczycy, zwłaszcza wraz z TSH i wolnym T4, gdy wyniki są niejasne. Wartości silnie zależą od metody oznaczenia i odczytuje się je w kontekście klinicznym.", "ft4": "Wolne T4 (tyroksyna) to niezwiązana postać głównego hormonu, który tarczyca uwalnia do krwi; służy jako rezerwa, którą tkanki w razie potrzeby przekształcają w bardziej aktywne T3. Wolna frakcja odzwierciedla to, co jest faktycznie dostępne dla organizmu, niezależnie od poziomu białek nośnikowych. Zwykle oznacza się je wraz z TSH, aby odróżnić problemy samej tarczycy od zaburzeń sygnalizacji przysadkowej. Ciąża i niektóre leki mogą je przesuwać.", - "ferritin": "Ferrytyna to główne białko magazynujące żelazo w organizmie, a jej poziom we krwi z grubsza odzwierciedla całkowite zapasy żelaza. Niska wartość wskazuje na wyczerpane zapasy, a wysoka może odzwierciedlać przeładowanie żelazem — lub po prostu stan zapalny, ponieważ ferrytyna rośnie także jako białko ostrej fazy. Z tego powodu często interpretuje się ją wraz z innymi markerami żelaza i oznakami zapalenia. To jedna z podstawowych miar oceny gospodarki żelazem.", - "transferrin-saturation": "Wysycenie transferyny to odsetek transferyny — białka przenoszącego żelazo we krwi — który jest aktualnie obładowany żelazem. Łączy ono ilość żelaza w transporcie ze zdolnością organizmu do jego przenoszenia, dając migawkę dostępności żelaza, która uzupełnia obraz zapasów dawany przez ferrytynę. Niskie wysycenie sugeruje niedobór żelaza, a wysoka wartość może wskazywać na przeładowanie. Odczytuje się je wraz z ferrytyną i pełnym profilem żelaza.", + "ferritin": "Ferrytyna to główne białko magazynujące żelazo w organizmie, a jej poziom we krwi z grubsza odzwierciedla całkowite zapasy żelaza. Niska wartość wskazuje na wyczerpane zapasy, a wysoka może odzwierciedlać przeładowanie żelazem lub po prostu stan zapalny, ponieważ ferrytyna rośnie także jako białko ostrej fazy. Z tego powodu często interpretuje się ją wraz z innymi markerami żelaza i oznakami zapalenia. To jedna z podstawowych miar oceny gospodarki żelazem.", + "transferrin-saturation": "Wysycenie transferyny to odsetek transferyny (białka przenoszącego żelazo we krwi), który jest aktualnie obładowany żelazem. Łączy ono ilość żelaza w transporcie ze zdolnością organizmu do jego przenoszenia, dając migawkę dostępności żelaza, która uzupełnia obraz zapasów dawany przez ferrytynę. Niskie wysycenie sugeruje niedobór żelaza, a wysoka wartość może wskazywać na przeładowanie. Odczytuje się je wraz z ferrytyną i pełnym profilem żelaza.", "vitamin-d": "Witaminę D oznacza się jako 25-hydroksywitaminę D, krążącą postać magazynową i standardowy marker ogólnego stanu witaminy D. Organizm wytwarza ją w skórze wystawionej na światło słoneczne i część przyswaja z pożywienia; wspiera ona gospodarkę wapniową, zdrowie kości i funkcję odpornościową. Wartości zmieniają się wraz z porą roku, szerokością geograficzną, odcieniem skóry i suplementacją. Oznacza się ją, aby z czasem ocenić, czy zapasy są wystarczające.", - "vitamin-b12": "Witamina B12 (kobalamina) to niezbędna witamina pozyskiwana głównie z produktów pochodzenia zwierzęcego i magazynowana w dużej mierze w wątrobie. Jest potrzebna do tworzenia czerwonych krwinek, syntezy DNA i zdrowia układu nerwowego. Długotrwały niedobór — z powodu małego spożycia lub upośledzonego wchłaniania — może prowadzić do niedokrwistości i objawów neurologicznych. Oznacza się ją, aby ocenić stan odżywienia, zwłaszcza tam, gdzie spożycie lub wchłanianie mogą być ograniczone.", + "vitamin-b12": "Witamina B12 (kobalamina) to niezbędna witamina pozyskiwana głównie z produktów pochodzenia zwierzęcego i magazynowana w dużej mierze w wątrobie. Jest potrzebna do tworzenia czerwonych krwinek, syntezy DNA i zdrowia układu nerwowego. Długotrwały niedobór (z powodu małego spożycia lub upośledzonego wchłaniania) może prowadzić do niedokrwistości i objawów neurologicznych. Oznacza się ją, aby ocenić stan odżywienia, zwłaszcza tam, gdzie spożycie lub wchłanianie mogą być ograniczone.", "folate": "Foliany (witamina B9) to witamina z grupy B obecna w zielonych warzywach liściastych, roślinach strączkowych i żywności wzbogacanej, kluczowa dla tworzenia czerwonych krwinek i syntezy DNA. Działa w ścisłym powiązaniu z witaminą B12, a niedobór któregokolwiek z nich może wywołać podobny rodzaj niedokrwistości. Odpowiednia ilość folianów jest szczególnie ważna na początku ciąży dla zdrowego rozwoju płodu. Oznacza się je, aby ocenić stan odżywienia i przyczynę niektórych niedokrwistości.", "hs-crp": "Wysokoczułe CRP mierzy białko C-reaktywne, marker uwalniany przez wątrobę w odpowiedzi na stan zapalny, metodą dostrojoną do wykrywania niskich stężeń istotnych dla ryzyka sercowo-naczyniowego. W odróżnieniu od standardowego CRP stosowanego przy ostrych infekcjach wersja wysokoczuła wychwytuje subtelny, przewlekły stan zapalny powiązany z chorobą naczyń. Ponieważ każda niedawna infekcja lub uraz może je podnieść, interpretuje się je z dala od ostrej choroby i często potwierdza powtórną próbką. Odczytuje się je jako jeden z czynników całościowego ryzyka sercowo-naczyniowego.", "creatinine": "Kreatynina to produkt przemiany materii prawidłowego metabolizmu mięśni, który nerki filtrują z krwi w dość stałym tempie. Ponieważ jej wytwarzanie jest stosunkowo stałe, jej poziom we krwi służy jako praktyczna miara tego, jak dobrze nerki usuwają produkty przemiany materii. Na wartości wpływają też masa mięśniowa, nawodnienie i dieta, dlatego zwykle interpretuje się je wraz z szacowanym współczynnikiem filtracji. To podstawowy marker funkcji nerek.", "egfr": "eGFR (szacowany współczynnik filtracji kłębuszkowej) szacuje, ile krwi nerki filtrują w ciągu minuty, na podstawie kreatyniny oraz wieku i płci. Przekłada surową wartość kreatyniny na bardziej bezpośrednio zrozumiałą miarę funkcji nerek. Spadek eGFR w czasie to główny sposób klasyfikowania i śledzenia przewlekłych zmian w nerkach. Jako wartość szacunkowa odczytuje się go jako trend, a nie pojedynczą dokładną liczbę.", "alt": "ALT (aminotransferaza alaninowa) to enzym skupiony w komórkach wątroby, uwalniany do krwi, gdy komórki te są obciążone lub uszkodzone. Ponieważ jest stosunkowo swoisty dla wątroby, należy do głównych markerów wykrywania uszkodzenia lub zapalenia wątroby. Wartości mogą rosnąć przy stłuszczeniu wątroby, wirusowym zapaleniu wątroby, alkoholu lub niektórych lekach. Zwykle odczytuje się go wraz z AST i pozostałymi markerami profilu wątrobowego.", "ast": "AST (aminotransferaza asparaginianowa) to enzym obecny w wątrobie, ale także w mięśniach, sercu i czerwonych krwinkach, uwalniany przy uszkodzeniu którejkolwiek z tych tkanek. Ponieważ jest mniej swoisty dla wątroby niż ALT, oba interpretuje się razem, a ich stosunek może wskazywać na źródło i charakter problemu. Intensywny wysiłek lub uraz mięśni mogą go podnieść niezależnie od wątroby. To jeden ze standardowych enzymów profilu wątrobowego.", - "ggt": "GGT (gamma-glutamylotransferaza) to enzym wątroby i dróg żółciowych, który rośnie zwłaszcza przy utrudnionym odpływie żółci lub w wyniku ekspozycji na alkohol. Często służy do wyjaśnienia, dlaczego inny marker wątrobowy jest podwyższony — na przykład, aby odróżnić źródło wątrobowe od żółciowego. Jest czuły, ale mało swoisty, dlatego odczytuje się go w kontekście reszty profilu wątrobowego. To jeden z kilku markerów używanych do oceny zdrowia wątroby i dróg żółciowych.", + "ggt": "GGT (gamma-glutamylotransferaza) to enzym wątroby i dróg żółciowych, który rośnie zwłaszcza przy utrudnionym odpływie żółci lub w wyniku ekspozycji na alkohol. Często służy do wyjaśnienia, dlaczego inny marker wątrobowy jest podwyższony, na przykład, aby odróżnić źródło wątrobowe od żółciowego. Jest czuły, ale mało swoisty, dlatego odczytuje się go w kontekście reszty profilu wątrobowego. To jeden z kilku markerów używanych do oceny zdrowia wątroby i dróg żółciowych.", "sodium": "Sód to główny elektrolit płynu poza komórkami organizmu i podstawowy regulator gospodarki płynami oraz objętości krwi. Organizm reguluje go ściśle poprzez pragnienie, hormony i nerki, dlatego jego poziom we krwi odzwierciedla raczej równowagę między wodą a solą niż samo spożycie. Nieprawidłowe wartości zwykle sygnalizują zaburzenie gospodarki wodnej, a nie samego sodu. To podstawowy element panelu metabolicznego i elektrolitowego.", "potassium": "Potas to główny elektrolit wewnątrz komórek organizmu i jest niezbędny do przewodzenia nerwowego oraz równego rytmu mięśnia sercowego. Jego poziom we krwi utrzymywany jest w wąskim zakresie przez nerki i sygnały hormonalne, ponieważ zarówno wysokie, jak i niskie wartości mogą zaburzać aktywność elektryczną serca. Na wartości mogą wpływać funkcja nerek, nawodnienie i kilka powszechnych leków. To podstawowa miara panelu elektrolitowego.", "hemoglobin": "Hemoglobina to zawierające żelazo białko czerwonych krwinek, które wiąże tlen w płucach i przenosi go do tkanek. Jej stężenie jest główną miarą zdolności krwi do przenoszenia tlenu i centralnym markerem wykrywania niedokrwistości. Niskie wartości mogą odzwierciedlać utratę krwi, niedobór żelaza lub witamin albo zmniejszone wytwarzanie, a wysokie mogą następować po odwodnieniu lub w warunkach niedoboru tlenu. To filar pełnej morfologii krwi.", "hematocrit": "Hematokryt to udział czerwonych krwinek w objętości krwi, wyrażony w procentach. Zmienia się ściśle wraz z hemoglobiną i daje uzupełniający obraz masy czerwonych krwinek oraz zdolności krwi do przenoszenia tlenu. Wpływa na niego nawodnienie, ponieważ zmiany objętości osocza przesuwają ten udział nawet wtedy, gdy liczba czerwonych krwinek pozostaje stała. Odczytuje się go wraz z hemoglobiną i resztą morfologii.", "wbc": "Liczba białych krwinek to całkowita liczba komórek odpornościowych krążących we krwi, ruchomej obrony organizmu przed infekcjami. Rośnie w odpowiedzi na infekcję, stan zapalny lub stres i może spadać, gdy szpik kostny jest osłabiony lub w niektórych chorobach odpornościowych. Ponieważ sumuje kilka odrębnych typów komórek, wyraźną zmianę często uzupełnia się rozmazem, który rozbija ją na składowe. To podstawowy element pełnej morfologii krwi.", "platelets": "Płytki krwi to drobne fragmenty komórek we krwi, które zlepiają się i tworzą początkowy czop zatrzymujący krwawienie z uszkodzonego naczynia. Ich liczba odzwierciedla równowagę między wytwarzaniem w szpiku kostnym a zużyciem lub rozpadem. Niska liczba może zwiększać skłonność do krwawień, a wysoka może następować po stanie zapalnym lub innych bodźcach. Stanowią część pełnej morfologii krwi i są podstawową miarą zdolności krzepnięcia.", - "apob": "Apolipoproteina B to białko strukturalne obecne na każdej z aterogennych cząstek lipoprotein, więc jej pomiar zlicza te cząstki bezpośrednio, a nie cholesterol, który przenoszą. Ponieważ na każdej cząstce LDL, VLDL i pokrewnej znajduje się jedna cząsteczka ApoB, może ona wychwycić ryzyko sercowo-naczyniowe, które umyka wartości cholesterolu LDL — zwłaszcza gdy liczba cząstek i ich zawartość cholesterolu się rozchodzą. Z tego powodu coraz częściej używa się jej do uściślenia oceny ryzyka opartej na lipidach. Interpretuje się ją wraz ze zwykłym profilem lipidowym.", + "apob": "Apolipoproteina B to białko strukturalne obecne na każdej z aterogennych cząstek lipoprotein, więc jej pomiar zlicza te cząstki bezpośrednio, a nie cholesterol, który przenoszą. Ponieważ na każdej cząstce LDL, VLDL i pokrewnej znajduje się jedna cząsteczka ApoB, może ona wychwycić ryzyko sercowo-naczyniowe, które umyka wartości cholesterolu LDL, zwłaszcza gdy liczba cząstek i ich zawartość cholesterolu się rozchodzą. Z tego powodu coraz częściej używa się jej do uściślenia oceny ryzyka opartej na lipidach. Interpretuje się ją wraz ze zwykłym profilem lipidowym.", "lp-a": "Lipoproteina(a) to cząstka podobna do LDL z dodatkowo przyłączonym białkiem, której poziom jest w dużej mierze uwarunkowany genetycznie i pozostaje dość stabilny przez całe życie. Podwyższona Lp(a) jest niezależnym, dziedzicznym czynnikiem ryzyka chorób sercowo-naczyniowych i zwężenia zastawki aortalnej. Ponieważ niewiele się zmienia, zwykle oznacza się ją raz, aby ustalić ryzyko danej osoby na całe życie, a nie śledzić wielokrotnie. Podaje się ją w jednostkach molowych i odczytuje w ramach całościowego ryzyka sercowo-naczyniowego.", - "fasting-insulin": "Insulina na czczo to poziom insuliny — hormonu, który przenosi glukozę z krwi do komórek — mierzony po nocnym poście. W połączeniu z glukozą na czczo pomaga oszacować insulinooporność, czyli stan, w którym organizm potrzebuje więcej insuliny, aby utrzymać glukozę w zakresie. Wysoka insulina na czczo przy prawidłowej glukozie może być wczesnym znakiem, że ta kompensacja jest w toku. Odczytuje się ją w ramach obrazu zdrowia metabolicznego, a nie w oderwaniu.", - "omega-3-index": "Indeks omega-3 to udział EPA i DHA — dwóch długołańcuchowych morskich kwasów tłuszczowych omega-3 — wśród kwasów tłuszczowych błon czerwonych krwinek. Ponieważ czerwone krwinki odnawiają się powoli, odzwierciedla on długoterminowe spożycie omega-3, a nie niedawny posiłek. Wyższy indeks wiązano w badaniach obserwacyjnych z niższym ryzykiem sercowo-naczyniowym. Śledzi się go jako stabilny marker stanu omega-3, który reaguje na dietę w ciągu miesięcy." + "fasting-insulin": "Insulina na czczo to poziom insuliny (hormonu, który przenosi glukozę z krwi do komórek) mierzony po nocnym poście. W połączeniu z glukozą na czczo pomaga oszacować insulinooporność, czyli stan, w którym organizm potrzebuje więcej insuliny, aby utrzymać glukozę w zakresie. Wysoka insulina na czczo przy prawidłowej glukozie może być wczesnym znakiem, że ta kompensacja jest w toku. Odczytuje się ją w ramach obrazu zdrowia metabolicznego, a nie w oderwaniu.", + "omega-3-index": "Indeks omega-3 to udział EPA i DHA (dwóch długołańcuchowych morskich kwasów tłuszczowych omega-3) wśród kwasów tłuszczowych błon czerwonych krwinek. Ponieważ czerwone krwinki odnawiają się powoli, odzwierciedla on długoterminowe spożycie omega-3, a nie niedawny posiłek. Wyższy indeks wiązano w badaniach obserwacyjnych z niższym ryzykiem sercowo-naczyniowym. Śledzi się go jako stabilny marker stanu omega-3, który reaguje na dietę w ciągu miesięcy." }, "apob": "Apolipoproteina B (ApoB)", "lp-a": "Lipoproteina(a)", @@ -9328,7 +9500,7 @@ "acceptImageOnly": "JPEG, PNG lub WebP", "extracting": "Odczytywanie wyniku…", "readingOnDevice": "Odczytywanie na Twoim urządzeniu…", - "localModeHint": "Odczytywane na Twoim urządzeniu i wysyłane jako tekst. Mniej dokładne — sprawdź każdą wartość.", + "localModeHint": "Odczytywane na Twoim urządzeniu i wysyłane jako tekst. Mniej dokładne, więc sprawdź każdą wartość.", "rangeFromScan": "Zakres referencyjny pobrany ze skanu", "rangeVerify": "Zweryfikuj zakres referencyjny", "reviewTitle": "Sprawdź przed zapisaniem", @@ -9343,12 +9515,12 @@ "dateLabel": "Data", "refLowLabel": "Dolna granica", "refHighLabel": "Górna granica", - "newBiomarker": "Nowy biomarker — zostanie utworzony", + "newBiomarker": "Nowy biomarker, zostanie utworzony", "linksExisting": "Łączy z istniejącym {name}", "duplicateWarning": "Pasujący wynik już istnieje", "lowConfidence": "Sprawdź tę wartość", "lowConfidenceBadge": "Niska pewność", - "valueUnreadable": "Wartość nieczytelna — wprowadź ją ręcznie", + "valueUnreadable": "Wartość nieczytelna, wprowadź ją ręcznie", "saveSelected": "Zapisz {count} wybranych", "discardAll": "Odrzuć wszystko", "savedToast": "Zapisano {count} wyników laboratoryjnych", @@ -9464,6 +9636,10 @@ "vaccinations": { "label": "Szczepienia", "description": "Książeczka szczepień uporządkowana: dawka, seria, następne przypomnienie." + }, + "timeline": { + "label": "Oś czasu", + "description": "Lata w jednym widoku: choroby, leki i wizyty obok Twoich wartości, a do tego Twoje własne wydarzenia życiowe." } }, "moduleOff": { @@ -9495,8 +9671,8 @@ }, "form": { "date": "Data", - "catalogLabel": "Szczepionka (katalog)", - "identityHint": "Wybierz z katalogu lub wpisz poniżej — wystarczy jedno.", + "catalogLabel": "Szczepionka", + "identityHint": "Wybierz szczepionkę z listy lub wpisz ją poniżej. Wystarczy jedno.", "catalogNone": "Wybierz szczepionkę", "catalogSearch": "Szukaj szczepionek", "catalogNoMatch": "Nie znaleziono wpisu w katalogu", @@ -9573,7 +9749,7 @@ }, "empty": { "title": "Nie zarejestrowano jeszcze żadnego szczepienia", - "description": "Przepisz swoją kartę szczepień wiersz po wierszu — wystarczą data i szczepionka." + "description": "Przepisz swoją kartę szczepień wiersz po wierszu. Wystarczą data i szczepionka." }, "catalog": { "tetanus": "Tężec", @@ -9616,10 +9792,31 @@ "overdue": "Dawka przypominająca zaległa od {date}", "dueSoon": "Dawka przypominająca {date}", "current": "Następna dawka przypominająca {date}" + }, + "custom": { + "title": "Własne szczepionki", + "add": "Dodaj własną szczepionkę", + "name": "Nazwa", + "components": "Chroni przed", + "seriesDoses": "Dawki w szczepieniu podstawowym", + "boosterMonths": "Dawka przypominająca co (miesiące)", + "saved": "Szczepionka zapisana", + "deleted": "Szczepionka usunięta", + "deleteConfirm": "Usunąć tę szczepionkę? Zapisane już dawki zachowają swoją nazwę.", + "saveFailed": "Nie udało się zapisać szczepionki.", + "nameTaken": "Masz już szczepionkę o tej nazwie.", + "editTitle": "Edytuj własną szczepionkę", + "description": "Szczepionka spoza katalogu, liczona jak każdy wpis z katalogu.", + "componentsHint": "Wybierz wszystkie choroby, przed którymi chroni.", + "boosterHint": "Zostaw puste, jeśli nie ma dawki przypominającej.", + "protectsAgainst": "Chroni przed: {list}.", + "deleteTitle": "Usunąć własną szczepionkę?", + "sectionDescription": "Szczepionki dodane przez Ciebie, bo nie ma ich w katalogu.", + "catalogGroup": "Katalog" } }, "illness": { - "title": "Dziennik chorób", + "title": "Choroba", "subtitle": "Retrospektywny zapis Twoich epizodów chorób i dolegliwości.", "settings": { "viewDescription": "Wybierz, czy epizody mają być wyświetlane jako karty czy zwarta lista.", @@ -9706,7 +9903,7 @@ }, "correlation": { "title": "Jak przebiegało", - "learning": "Wciąż się uczę — zapisz jeszcze kilka dni, aby zobaczyć tu wzorce.", + "learning": "Wciąż się uczę. Zapisz jeszcze kilka dni, aby zobaczyć tu wzorce.", "preOnsetTitle": "Jak się zapowiadało", "preOnsetBody": "Sygnały, które odbiegły od Twojej bazy przed oznaczonym początkiem.", "nadirTitle": "Co się zmieniło", @@ -9733,7 +9930,7 @@ "countOne": "W tym okresie zapisałeś 1 epizod.", "countOther": "W tym okresie zapisałeś {count} epizodów.", "typicalGap": "Typowa luka rekonwalescencji: {days} dni między poprawą a ustabilizowaniem wartości.", - "typicalGapWithDriver": "Typowa luka rekonwalescencji: {days} dni – zwykle czujesz się lepiej, zanim {vital} się ustabilizuje.", + "typicalGapWithDriver": "Typowa luka rekonwalescencji: {days} dni. Zwykle czujesz się lepiej, zanim {vital} się ustabilizuje.", "typicalGapLearning": "Jeszcze kilka zakończonych epizodów i pojawi się tu typowa luka rekonwalescencji.", "none": "Brak epizodów zapisanych w tym okresie.", "analyze": "Przeanalizuj lukę rekonwalescencji" @@ -9758,7 +9955,7 @@ "loadError": "Nie udało się wczytać tego epizodu.", "backToList": "Powrót do dziennika", "detail": { - "note": "Note" + "note": "Notatka" }, "edit": { "title": "Edytuj epizod", @@ -9807,7 +10004,7 @@ "dismiss": "Nie teraz", "applied": "Dodano", "dismissed": "Odrzucono", - "failed": "Nie udało się dodać — spróbuj ponownie", + "failed": "Nie udało się dodać. Spróbuj ponownie", "checkup": { "title": "Dodać przypomnienie o badaniu?" }, @@ -9835,9 +10032,9 @@ "reviewPrefix": "Przegląd: {when}", "accept": "Przyjmij plan", "decline": "Odrzuć", - "accepted": "Plan przyjęty — Coach będzie o nim pamiętał.", + "accepted": "Plan przyjęty. Coach będzie o nim pamiętał.", "declined": "Propozycja odrzucona.", - "failed": "Nie udało się — spróbuj ponownie.", + "failed": "Nie udało się. Spróbuj ponownie.", "markMet": "Cel osiągnięty", "abandon": "Zakończ plan", "remove": "Usuń", @@ -9859,10 +10056,10 @@ }, "refusal": { "outOfScope": "Mogę pomóc wyłącznie z danymi zdrowotnymi z Twojego dziennika: pomiarami, lekami, nastrojem, trendami. Na pytania spoza tego zakresu nie odpowiadam.", - "promptInjection": "Zauważyłem polecenia, które próbują obejść moje wytyczne. Jestem Coachem HealthLog i podsumowuję wyłącznie Twoje własne dane zdrowotne — sformułuj pytanie w tych ramach.", + "promptInjection": "Zauważyłem polecenia, które próbują obejść moje wytyczne. Jestem Coachem HealthLog i podsumowuję wyłącznie Twoje własne dane zdrowotne. Sformułuj pytanie w tych ramach.", "conversationPoisoned": "Wcześniejsza wiadomość w tej rozmowie zawiera polecenia, które próbują obejść moje wytyczne. Rozpocznij nową rozmowę." }, - "noRecordedData": "Nie masz zapisanych żadnych pomiarów dla tego, o co pytasz, więc nie mogę podać Ci żadnej liczby. Gdy coś zapiszesz, zapytaj ponownie — wtedy oprę się na Twoich własnych wartościach.", + "noRecordedData": "Nie masz zapisanych żadnych pomiarów dla tego, o co pytasz, więc nie mogę podać Ci żadnej liczby. Gdy coś zapiszesz, zapytaj ponownie, a wtedy oprę się na Twoich własnych wartościach.", "step": { "read": "Czytam: {domain}", "readWindow": "Czytam: {domain}, {window}", @@ -9895,7 +10092,9 @@ "illness": "Choroba i powrót do zdrowia", "cycle": "Cykl", "correlations": "Związki między wskaźnikami", - "snapshot": "Podsumowanie danych" + "snapshot": "Podsumowanie danych", + "environment": "Pogoda i powietrze", + "day": "Jeden dzień" }, "window": { "last7days": "ostatnie 7 dni", @@ -10073,7 +10272,7 @@ }, "mentalHealth": { "pageTitle": "Dobrostan psychiczny", - "pageDescription": "Cztery zwalidowane, dobrowolne testy — liczy się przebieg w czasie, nie pojedynczy wynik.", + "pageDescription": "Cztery zwalidowane, dobrowolne testy: liczy się przebieg w czasie, nie pojedynczy wynik.", "attributionLabel": "Źródło", "attributionShow": "Źródło i licencja: {instrument}", "choosePrompt": "Wybierz sprawdzenie", @@ -10084,10 +10283,10 @@ "saved": "Zapisano", "error": "Coś poszło nie tak. Spróbuj ponownie.", "instrument": { - "phq9": "PHQ-9 · nastrój", - "gad7": "GAD-7 · lęk", - "who5": "WHO-5 · samopoczucie", - "sci": "SCI · sen" + "phq9": "PHQ-9 (nastrój)", + "gad7": "GAD-7 (lęk)", + "who5": "WHO-5 (samopoczucie)", + "sci": "SCI (sen)" }, "instrumentSub": { "phq9": "Przesiew depresji, 9 pytań", @@ -10108,7 +10307,7 @@ "2": "Bardzo utrudniły", "3": "Skrajnie utrudniły" }, - "functionalOptionalHint": "Opcjonalne — możesz wysłać bez odpowiedzi na to pytanie.", + "functionalOptionalHint": "Opcjonalne: możesz wysłać bez odpowiedzi na to pytanie.", "result": { "title": "Twój wynik", "totalLabel": "Wynik łączny", @@ -10298,7 +10497,7 @@ }, "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.", + "historyLoadErrorHint": "To nie znaczy, że nie masz wyników; nie udało się pobrać listy. Spróbuj ponownie.", "privacyNote": "Twoje odpowiedzi są przechowywane w postaci zaszyfrowanej i nigdy nie trafiają do Coacha AI." }, "records": { @@ -10528,12 +10727,34 @@ "daylight": "Światło dzienne", "precip": "Opady", "pressureMean": "Ciśnienie atmosferyczne", - "pressureDelta": "Wahanie ciśnienia" + "pressureDelta": "Wahanie ciśnienia", + "pm25": "Pył drobny (PM2,5)", + "pm10": "Pył (PM10)", + "no2": "Dwutlenek azotu", + "ozone8h": "Ozon (maksimum 8-godzinne)", + "europeanAqi": "Europejski indeks jakości powietrza", + "uvIndexMax": "Indeks UV (maksimum)", + "dustMax": "Pył saharyjski", + "pollenMax": "Pyłki (najwyższa wartość)" + }, + "chip": { + "day": "Pogoda {date}", + "pollenHigh": "Dużo pyłków: {kinds}", + "hotNight": "Ciepła noc", + "veryPoorAir": "Bardzo złe powietrze" + }, + "pollen": { + "alder": "olcha", + "birch": "brzoza", + "grass": "trawy", + "mugwort": "bylica", + "olive": "oliwka", + "ragweed": "ambrozja" } }, "documents": { "title": "Dokumenty", - "subtitle": "Każde pismo, wynik i skan w jednym przeszukiwalnym miejscu — zaszyfrowane w spoczynku.", + "subtitle": "Każde pismo, wynik i skan w jednym przeszukiwalnym miejscu, zaszyfrowane w spoczynku.", "pageUpload": "Prześlij", "ownerOnlyUpload": "Dokumenty może dodawać tylko właściciel kartoteki.", "kind": { @@ -10575,15 +10796,15 @@ }, "error": { "fileTooLarge": "Ten plik przekracza limit {maxSize}.", - "quotaExceeded": "Miejsce jest pełne — użyto {used} z {quota}.", + "quotaExceeded": "Miejsce jest pełne: użyto {used} z {quota}.", "unsupportedType": "Ten typ pliku nie jest obsługiwany.", "purged": "Ten dokument został trwale usunięty i nie można go przywrócić.", "duplicateExists": "Identyczny dokument jest już zapisany.", - "rateLimited": "Zbyt wiele przesyłań — spróbuj ponownie za kilka minut.", + "rateLimited": "Zbyt wiele przesyłań. Spróbuj ponownie za kilka minut.", "generic": "Przesyłanie nie powiodło się. Spróbuj ponownie." }, "toast": { - "duplicate": "Już zapisano — istniejący dokument został wyróżniony.", + "duplicate": "Już zapisano. Istniejący dokument został wyróżniony.", "deleted": "Dokument usunięty", "restoreFailed": "Nie udało się przywrócić dokumentu." }, @@ -10606,7 +10827,7 @@ }, "empty": { "title": "Twój sejf dokumentów jest pusty", - "description": "Przechowuj pisma lekarskie, wyniki laboratoryjne, obrazowanie, recepty i zaświadczenia o szczepieniach — zaszyfrowane i przeszukiwalne według typu, schorzenia i roku.", + "description": "Przechowuj pisma lekarskie, wyniki laboratoryjne, obrazowanie, recepty i zaświadczenia o szczepieniach. Są zaszyfrowane i można je przeszukiwać według typu, schorzenia i roku.", "action": "Prześlij pierwszy dokument", "noMatchesTitle": "Brak pasujących dokumentów", "noMatchesDescription": "Nic nie pasuje do bieżącego wyszukiwania i filtrów." @@ -10646,9 +10867,9 @@ "linkCondition": "Powiąż", "noConditions": "Niepowiązany ze schorzeniem", "previewUnavailable": "Brak podglądu dla tego typu pliku", - "uploadedMeta": "Dodano {date} · {size}", + "uploadedMeta": "Dodano {date}, {size}", "importedFrom": "Zaimportowano z: {system}", - "importedFromWithId": "Zaimportowano z: {system} · ID {id}", + "importedFromWithId": "Zaimportowano z: {system}, ID {id}", "sourceOther": "inny system", "share": "Udostępnij", "labValuesLink": "Zobacz {count} wyników laboratoryjnych", @@ -10748,7 +10969,7 @@ "CONDITION": "Schorzenie", "MEDICATION_STATEMENT": "Lek" }, - "statedRange": "podany zakres {range}", + "statedRange": "Podany zakres {range}", "needsCheck": "Sprawdź tę wartość z dokumentem, a potem ją zapisz.", "verify": "Sprawdź", "verifySave": "Zapisz wartość", @@ -10864,6 +11085,21 @@ "finishedHint": "Wyszukaj ponownie lub zmień filtr, aby wybrać więcej dokumentów.", "maxReached": "Możesz zaimportować naraz najwyżej {max} dokumentów.", "noMatchHint": "Spróbuj części nazwy albo wyczyść tag i daty." + }, + "customize": "Dostosuj widok", + "layout": { + "viewDescription": "Karty z miniaturą albo zwarta lista.", + "arrangementHeading": "Układ", + "arrangementDescription": "Każdy miesiąc osobno albo wszystko po kolei na całą szerokość.", + "stacked": "Miesiące jeden pod drugim", + "flow": "Ciągły", + "saveFailed": "Nie udało się zapisać widoku." + }, + "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.", + "action": "Usuń" } }, "selfDescription": { @@ -10950,7 +11186,7 @@ "genericDescription": "Pomiar, który śledzisz. Dodawaj wartości, aby obserwować go w czasie.", "emptyTitle": "Brak wartości", "emptyDescription": "Dodaj pierwszą wartość, aby zacząć przedstawiać tę metrykę na wykresie.", - "valuesTitle": "{metric} — wszystkie wartości", + "valuesTitle": "{metric}: wszystkie wartości", "valuesDescription": "Wszystkie zapisane wartości tej metryki.", "backToMetric": "Powrót do {metric}" } @@ -10997,26 +11233,26 @@ "lastAttemptReason": { "unitMismatch": "Otrzymane wartości nie pasowały do jednostek, których ta aplikacja oczekuje dla tych składników odżywczych, więc nie udało się ich zapisać.", "valueOutOfRange": "Otrzymane ilości były wyższe, niż ta aplikacja może wiarygodnie zapisać, więc nie udało się ich zapisać.", - "dayInvalid": "Wpisy zawierały datę, której serwer nie mógł zaakceptować. Może się to zdarzyć, gdy zegar lub strefa czasowa po stronie wysyłającej są nieprawidłowe — warto to sprawdzić.", + "dayInvalid": "Wpisy zawierały datę, której serwer nie mógł zaakceptować. Może się to zdarzyć, gdy zegar lub strefa czasowa po stronie wysyłającej są nieprawidłowe, więc warto to sprawdzić.", "upsertFailed": "Te wpisy dotarły do serwera, ale problem po naszej stronie uniemożliwił ich zapisanie. Nie musisz nic robić.", "unknown": "Te wpisy dotarły, ale nie udało się ich zapisać." } }, "hydration": { - "referenceMeta": "Zalecane spożycie {value} {unit}/dzień · EFSA" + "referenceMeta": "Zalecane spożycie {value} {unit}/dzień (EFSA)" }, "caffeine": { - "ceilingMeta": "Bezpieczny limit {value} {unit}/dzień · EFSA" + "ceilingMeta": "Bezpieczny limit {value} {unit}/dzień (EFSA)" }, "micronutrients": { "title": "Witaminy i minerały", - "rowMeta": "{days} z {window} dni · zalecane {value} {unit} (EFSA)", + "rowMeta": "{days} z {window} dni, zalecane {value} {unit} (EFSA)", "rowMetaNoRef": "{days} z {window} dni", "footer": "{tracked} z {total} śledzonych mikroskładników ma dane w tym okresie.", "emptyTitle": "Brak jeszcze zsynchronizowanych danych o mikroskładnikach", "loadError": "Nie udało się wczytać danych o składnikach odżywczych" }, - "sparseNote": "Niska wartość zwykle oznacza tu rzadkie zapisywanie, a nie niedobór — dzienne sumy z Apple Health odzwierciedlają to, co zapisały twoje aplikacje, a nie całkowite spożycie w diecie." + "sparseNote": "Niska wartość zwykle oznacza tu rzadkie zapisywanie, a nie niedobór. Dzienne sumy z Apple Health odzwierciedlają to, co zapisały twoje aplikacje, a nie całkowite spożycie w diecie." }, "daily": { "line": { @@ -11025,7 +11261,7 @@ }, "push": { "title": "Twoje poranne podsumowanie", - "bodyProvisional": "{line} · Brakuje jeszcze snu z zeszłej nocy." + "bodyProvisional": "{line}\nBrakuje jeszcze snu z zeszłej nocy." }, "item": { "doseWindow": { @@ -11043,27 +11279,27 @@ }, "syncIssue": { "title": "Synchronizacja wymaga uwagi", - "body": "{integration} się nie synchronizuje — połącz ponownie, aby dane były aktualne." + "body": "{integration} się nie synchronizuje. Połącz ponownie, aby dane były aktualne." }, "coachCheckin": { "title": "Czas przyjrzeć się planowi", - "body": "Minął mniej więcej tydzień, odkąd ustaliłeś ten plan — zachowaj go, dostosuj lub odpuść. Bez presji.", - "bodyPlan": "Około tygodnia temu postanowiłeś: „{plan}”. Zachowaj to, dostosuj lub odpuść — bez presji.", + "body": "Minął mniej więcej tydzień, odkąd ustaliłeś ten plan. Zachowaj go, dostosuj lub odpuść. Bez presji.", + "bodyPlan": "Około tygodnia temu postanowiłeś: „{plan}”. Zachowaj to, dostosuj lub odpuść, bez presji.", "adjustPrompt": "Chciałbym dostosować mój plan kontrolny.", "adjustPromptPlan": "Chciałbym dostosować ten plan: „{plan}”" }, "tensionWindow": { "title": "Może chwila napięcia dzisiaj", "body": { - "morning": "Twoje tętno przez dłuższą chwilę rano utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu — możliwe napięcie.", - "afternoon": "Twoje tętno przez dłuższą chwilę po południu utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu — możliwe napięcie.", - "evening": "Twoje tętno przez dłuższą chwilę wieczorem utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu — możliwe napięcie.", - "night": "Twoje tętno przez dłuższą chwilę w nocy utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu — możliwe napięcie." + "morning": "Twoje tętno przez dłuższą chwilę rano utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu. Możliwe napięcie.", + "afternoon": "Twoje tętno przez dłuższą chwilę po południu utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu. Możliwe napięcie.", + "evening": "Twoje tętno przez dłuższą chwilę wieczorem utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu. Możliwe napięcie.", + "night": "Twoje tętno przez dłuższą chwilę w nocy utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu. Możliwe napięcie." } }, "ecgNewRecording": { "title": "Nowy zapis EKG", - "body": "Twoje urządzenie zarejestrowało nowe EKG — gotowe do podglądu.", + "body": "Twoje urządzenie zarejestrowało nowe EKG, gotowe do podglądu.", "bodyVerdict": "Twoje urządzenie zarejestrowało nowe EKG i zgłosiło: {verdict}.", "verdict": { "irregular": "możliwy nieregularny rytm", @@ -11083,9 +11319,9 @@ }, "upcomingVisit": { "title": "Zbliża się wizyta", - "bodyToday": "{what} — dzisiaj", - "bodyTomorrow": "{what} — jutro", - "bodyDayAfterTomorrow": "{what} — pojutrze" + "bodyToday": "{what}, dzisiaj", + "bodyTomorrow": "{what}, jutro", + "bodyDayAfterTomorrow": "{what}, pojutrze" } }, "action": { @@ -11104,7 +11340,7 @@ "scoreLabel": "Wynik zdrowia", "deltaVsBaseline": "{delta} wzgl. Twojej wartości bazowej", "deltaFlat": "Zgodnie z Twoją wartością bazową", - "sleepPending": "Sen z ostatniej nocy jeszcze nie dotarł — dzisiejszy odczyt jest wstępny.", + "sleepPending": "Sen z ostatniej nocy jeszcze nie dotarł, więc dzisiejszy odczyt jest wstępny.", "worthALook": "Warto sprawdzić", "allClear": "Dziś nic nie wymaga Twojej uwagi.", "ringLink": "Otwórz szczegóły: {metric}", @@ -11358,7 +11594,7 @@ "errorScope": "Tych sekcji nie da się udostępnić w ten sposób. Wybierz co najmniej jedną sekcję albo udostępnij całą kartotekę.", "errorManageBrowserOnly": "Dostęp zarządzający można przyznać tylko w przeglądarce.", "errorStepUp": "Potwierdź drugi składnik logowania i spróbuj ponownie.", - "errorOffline": "To nie dotarło do serwera. Twoje zaproszenie nadal tu jest — sprawdź połączenie i spróbuj ponownie." + "errorOffline": "To nie dotarło do serwera. Twoje zaproszenie nadal tu jest. Sprawdź połączenie i spróbuj ponownie." }, "given": { "title": "Kto może otworzyć moją kartę", @@ -11413,7 +11649,7 @@ "managed": { "title": "Profile, którymi się opiekujesz", "description": "Karty zdrowia bez własnego logowania.", - "explainer": "Zarządzany profil to karta zdrowia osoby, która sama się nie loguje — dziecka albo kogoś, kim się opiekujesz. Nie ma logowania ani adresu e-mail, a Ty stajesz się pierwszą osobą opiekującą się profilem. Później możesz dodać kolejne, a każda z was może wejść do karty i ją uzupełniać.", + "explainer": "Zarządzany profil to karta zdrowia osoby, która sama się nie loguje, na przykład dziecka albo kogoś, kim się opiekujesz. Nie ma logowania ani adresu e-mail, a Ty stajesz się pierwszą osobą opiekującą się profilem. Później możesz dodać kolejne, a każda z was może wejść do karty i ją uzupełniać.", "nameLabel": "Nazwa", "namePlaceholder": "Nazwa, po której rozpoznasz tę kartę", "nameHint": "Do 80 znaków. Wyświetla się wszędzie tam, gdzie pojawia się ta karta.", @@ -11435,7 +11671,7 @@ "creating": "Tworzenie…", "created": "{name} jest gotowy. Do tej karty przejdziesz z menu konta.", "errorStepUp": "Potwierdź drugi składnik logowania i spróbuj ponownie.", - "errorOffline": "To nie dotarło do serwera. Nic się nie zmieniło — sprawdź połączenie i spróbuj ponownie.", + "errorOffline": "To nie dotarło do serwera. Nic się nie zmieniło. Sprawdź połączenie i spróbuj ponownie.", "errorInvalid": "Serwer odrzucił te dane. Sprawdź nazwę, datę i strefę czasową.", "errorFailed": "Nie udało się utworzyć profilu.", "edit": "Edytuj", @@ -11446,7 +11682,7 @@ "editErrorFailed": "Nie udało się zapisać zmian.", "delete": "Usuń profil", "deleteTitle": "Usunąć {name}?", - "deleteBody": "Wszystko, co zapisano dla {name}, znika razem z profilem — pomiary, leki, dokumenty i notatki. Tego nie da się cofnąć.", + "deleteBody": "Wszystko, co zapisano dla {name}, znika razem z profilem: pomiary, leki, dokumenty i notatki. Tego nie da się cofnąć.", "deleteGuardiansOne": "{count} osoba opiekuje się dziś tą kartą i ją straci.", "deleteGuardiansFew": "{count} osoby opiekują się dziś tą kartą i ją stracą.", "deleteGuardiansOther": "{count} osób opiekuje się dziś tą kartą i ją straci.", @@ -11475,7 +11711,47 @@ "guardianErrorFailed": "Nie udało się wysłać zaproszenia.", "errorMfaRequired": "To wymaga drugiego składnika logowania, a Twoje konto jeszcze go nie ma. Skonfiguruj go w Ustawieniach → Bezpieczeństwo i spróbuj ponownie.", "rosterLoadError": "Nie udało się wczytać, kto opiekuje się tą kartą.", - "errorRateLimit": "Zbyt wiele profili utworzonych w ostatniej godzinie. Odczekaj chwilę i spróbuj ponownie." + "errorRateLimit": "Zbyt wiele profili utworzonych w ostatniej godzinie. Odczekaj chwilę i spróbuj ponownie.", + "handover": { + "action": "Przekaż", + "title": "Przekaż osobie {name}", + "description": "Daj osobie {name} własne logowanie. Nic nie jest kopiowane ani przenoszone: karta staje się jej kontem.", + "guardiansLabel": "Dostęp po przekazaniu", + "guardiansHint": "Twoja propozycja. {name} zobaczy ją i zdecyduje po zalogowaniu.", + "access": { + "end": "Brak dostępu", + "read": "Tylko podgląd", + "manage": "Zarządzanie" + }, + "expiryLabel": "Link ważny przez", + "expiry1": "1 dzień", + "expiry7": "7 dni", + "expiry14": "14 dni", + "minorHint": "{name} ma mniej niż 16 lat. Warto zachować dostęp do podglądu i omówić ten krok wspólnie.", + "irreversible": "Gdy {name} użyje linku, przekazania nie da się cofnąć. Karta będzie należeć do tej osoby i to ona zdecyduje, kto zachowa dostęp.", + "afterwards": "{name} osobiście potwierdzi zastrzeżenie i każdą zgodę na AI. Przypomnienia dotyczące tej karty przestaną do Ciebie trafiać.", + "replaceNote": "Utworzenie nowego linku wycofuje otwarty.", + "create": "Utwórz link", + "creating": "Tworzenie…", + "linkOnce": "Ten link jest widoczny tylko teraz. Przekaż go osobie {name} osobiście lub zaufanym kanałem albo pozwól zeskanować kod.", + "qrAlt": "Kod QR linku przekazania dla {name}", + "urlLabel": "Link przekazania", + "copy": "Kopiuj", + "copied": "Link skopiowany", + "copyError": "Nie udało się skopiować linku. Zaznacz go i skopiuj ręcznie.", + "linkExpires": "Ważny do {date}.", + "done": "Gotowe", + "pending": "Link przekazania jest otwarty do {date}.", + "replace": "Nowy link", + "revoke": "Wycofaj link", + "revoked": "Link wycofany", + "revokeFailed": "Nie udało się wycofać linku. Spróbuj ponownie.", + "errorFailed": "Nie udało się utworzyć linku przekazania.", + "errorRosterChanged": "W międzyczasie zmieniło się, kto opiekuje się kartą. Zamknij to i otwórz ponownie.", + "oidcOnly": "Przekazanie profilu nie jest możliwe, dopóki ten serwer pozwala tylko na logowanie jednokrotne (SSO).", + "loadError": "Nie udało się wczytać stanu przekazania.", + "entryHint": "Gdy {name} chce samodzielnie prowadzić tę kartę." + } }, "toast": { "savedTo": "Zapisano w karcie użytkownika {name}" @@ -11594,7 +11870,13 @@ "symptomEventDelete": "{name} usunął(-ęła) wpis objawu", "symptomDefinitionCreate": "{name} dodał(a) objaw", "symptomDefinitionUpdate": "{name} zmienił(a) objaw", - "symptomDefinitionDelete": "{name} usunął(-ęła) objaw" + "symptomDefinitionDelete": "{name} usunął(-ęła) objaw", + "customVaccineCreate": "{name} dodał własną szczepionkę", + "customVaccineUpdate": "{name} zmienił własną szczepionkę", + "customVaccineDelete": "{name} usunął własną szczepionkę", + "lifeEventCreate": "{name} dodał wydarzenie życiowe", + "lifeEventUpdate": "{name} zmienił wydarzenie życiowe", + "lifeEventDelete": "{name} usunął wydarzenie życiowe" }, "section": { "measurements": { @@ -11632,7 +11914,7 @@ }, "actionError": { "retry": "Spróbuj ponownie", - "offline": "To nie dotarło do serwera. Nic się nie zmieniło — sprawdź połączenie i spróbuj ponownie.", + "offline": "To nie dotarło do serwera. Nic się nie zmieniło. Sprawdź połączenie i spróbuj ponownie.", "rateLimited": "Zbyt wiele prób pod rząd. Odczekaj minutę i spróbuj ponownie.", "acceptExpired": "To zaproszenie wygasło. Poproś o nowe.", "acceptWithdrawn": "To zaproszenie zostało wycofane.", @@ -11644,12 +11926,28 @@ "notFound": "Tego dostępu już nie ma.", "revokeFailed": "Nie udało się zakończyć dostępu.", "renounceFailed": "Nie udało się zwrócić dostępu.", - "profileNotFound": "Tego profilu już nie ma — mógł zostać usunięty albo Twój dostęp do niego się zakończył.", + "profileNotFound": "Tego profilu już nie ma. Mógł zostać usunięty albo Twój dostęp do niego się zakończył.", "deleteProfileFailed": "Nie udało się usunąć profilu.", "removeGuardianFailed": "Nie udało się zakończyć tego dostępu." }, "anamnesis": { "relativeNote": "Wpis w wywiadzie rodzinnym opisuje bliskich tej osoby. Dodaj go tylko wtedy, gdy chciałaby, żeby został tu zapisany." + }, + "handoverDecision": { + "title": "Kto zachowuje dostęp do Twojej karty", + "description": "Zdecyduj, co od teraz mogą osoby, które prowadziły Twoją kartę.", + "proposed": "Proponowane: {access}", + "settled": "Już zmienione w Udostępnionym dostępie", + "empty": "Nikt inny nie miał dostępu do Twojej karty.", + "later": "Możesz to zmienić w każdej chwili w Ustawieniach, Udostępniony dostęp.", + "save": "Potwierdź", + "saving": "Zapisywanie…", + "decideLater": "Później", + "errorFailed": "Nie udało się zapisać decyzji. Spróbuj ponownie.", + "loadError": "Nie udało się wczytać osób, które prowadziły Twoją kartę.", + "notificationsTitle": "Ustaw własne przypomnienia", + "notificationsBody": "Do tej pory przypomnienia dla tej karty trafiały do osób, które ją prowadziły. Od teraz trafiają do Ciebie, gdy tylko dodasz kanał powiadomień.", + "notificationsAction": "Otwórz powiadomienia" } }, "encounters": { @@ -11670,7 +11968,7 @@ "both": "Obie strony", "none": "Nie podano" }, - "bodySiteWithSide": "{site} · {side}", + "bodySiteWithSide": "{site} ({side})", "sectionTitle": "Wizyty", "sectionDescription": "Wizyty u lekarza i umówione terminy w jednym miejscu.", "add": "Wizyta", @@ -11685,7 +11983,7 @@ "created": "Wizyta zapisana", "updated": "Wizyta zaktualizowana", "saveFailed": "Nie udało się zapisać wizyty.", - "checkupNotClosed": "Wizyta zapisana. Badanie pozostaje otwarte — twój dostęp tego nie obejmuje.", + "checkupNotClosed": "Wizyta zapisana. Badanie pozostaje otwarte, bo twój dostęp tego nie obejmuje.", "status": { "planned": "Zaplanowana", "done": "Odbyta", @@ -11702,7 +12000,7 @@ "dateDerivesStatusHint": "Data w przyszłości oznacza umówiony termin.", "status": "Status", "practitioner": "Gabinet lub lekarz", - "practitionerHint": "Opcjonalne — nazwę możesz dodać później.", + "practitionerHint": "Opcjonalne: nazwę możesz dodać później.", "practitionerNone": "Nie podano", "practitionerSearch": "Szukaj nazwiska lub gabinetu", "practitionerNoMatch": "Nie znaleziono wpisu.", @@ -11760,9 +12058,8 @@ }, "practitioners": { "title": "Gabinety i lekarze", - "description": "Twoja własna książka adresowa — wpisz gabinet raz, wybieraj go wszędzie.", + "description": "Twoja własna książka adresowa: wpisz gabinet raz, wybieraj go wszędzie.", "linkLabel": "Książka adresowa", - "add": "Wpis", "searchPlaceholder": "Szukaj nazwiska, gabinetu lub specjalizacji", "loadError": "Nie udało się wczytać książki adresowej.", "emptyTitle": "Brak wpisów", @@ -11859,7 +12156,6 @@ "unreadableLabel": "Nieczytelny objaw", "intensityPill": "{value}/10", "entry": { - "sheetTitle": "Zapisz objaw", "sheetDescription": "Jeden z twoich objawów, jak silny i kiedy.", "symptom": "Objaw", "symptomRequired": "Wybierz objaw.", @@ -11908,5 +12204,411 @@ "title": "Zapisane objawy", "filed": "zapisano przy tej chorobie" } + }, + "day": { + "scoreOfMax": "z {max}", + "cycleDay": "Dzień cyklu {n}", + "cyclePhaseLine": { + "MENSTRUAL": "Dzień cyklu {n}, faza menstruacyjna", + "FOLLICULAR": "Dzień cyklu {n}, faza folikularna", + "OVULATORY": "Dzień cyklu {n}, faza owulacyjna", + "LUTEAL": "Dzień cyklu {n}, faza lutealna" + }, + "previousDay": "Poprzedni dzień", + "nextDay": "Następny dzień", + "close": "Zamknij dzień", + "hidePanel": "Ukryj dzień", + "showPanel": "Pokaż {date}", + "openDay": "Otwórz dzień", + "viewWholeDay": "Zobacz cały dzień", + "captureForDay": "Dodaj dla tego dnia", + "empty": "Brak wpisów dla tego dnia.", + "notShared": "Niektóre obszary nie są Ci udostępnione.", + "inTimeline": "Na osi czasu", + "askCoach": "Zapytaj Coacha o ten dzień", + "chartHint": "Kliknij punkt, aby otworzyć jego dzień.", + "loadFailed": "Nie udało się wczytać tego dnia.", + "dayOf": "Dzień {n} z {m}", + "dayN": "Dzień {n}", + "usualRange": "Zwykle od {lo} do {hi}", + "groups": { + "scores": "Wyniki", + "running": "W toku tego dnia", + "values": "Wartości", + "happened": "Co się działo" + }, + "notable": { + "extremeHigh": "Najwyższa wartość dzienna od {since}", + "extremeLow": "Najniższa wartość dzienna od {since}", + "firstValue": "Pierwszy pomiar tego rodzaju", + "gap": "{days} dni bez pomiaru" + }, + "previousDayShortcut": "Poprzedni dzień (Alt + strzałka w lewo)", + "nextDayShortcut": "Następny dzień (Alt + strzałka w prawo)", + "captureShort": "Zapisz", + "inTimelineShort": "Oś czasu", + "askCoachPrompt": "Co się działo {date}?", + "chartHintTouch": "Dotknij punktu, aby zobaczyć wartość.", + "chartHintBar": "Kliknij słupek, aby otworzyć jego dzień.", + "chartHintTouchBar": "Dotknij słupka, aby zobaczyć wartości.", + "chartHintCell": "Kliknij dzień, aby go otworzyć.", + "chartHintTouchCell": "Dotknij dnia, aby zobaczyć jego wartość.", + "coachChips": "Dni w tej odpowiedzi", + "description": "Wszystko, co Twoje wpisy zawierają dla tego dnia.", + "openDocument": "Otwórz dokument", + "previousReading": "Wcześniej, {date}", + "rugLegend": "Punkty pod osią oznaczają dni z wpisami, pierścień dzień godny uwagi.", + "sheetExpand": "Rozwiń na pełną wysokość", + "sheetShrink": "Zmniejsz", + "valuesLegend": "Punkt to ten dzień, pasek to 30 dni wcześniej", + "valuesAllOne": "Wszystkie wartości", + "valuesAllFew": "Wszystkie {count} wartości", + "valuesAllOther": "Wszystkie {count} wartości", + "valuesFewer": "Mniej wartości", + "countEntriesOne": "1 wpis", + "countEntriesFew": "{count} wpisy", + "countEntriesOther": "{count} wpisów", + "meanOfReadingsOne": "Średnia z 1 pomiaru", + "meanOfReadingsFew": "Średnia z {count} pomiarów", + "meanOfReadingsOther": "Średnia z {count} pomiarów", + "sinceLastVisit": { + "title": "Od ostatniej wizyty {date}", + "countOne": "1 wpis", + "countFew": "{count} wpisy", + "countOther": "{count} wpisów", + "allOne": "Pokaż wszystko", + "allFew": "Pokaż wszystkie {count}", + "allOther": "Pokaż wszystkie {count}", + "fewer": "Pokaż mniej", + "note": "Zestawione z Twoich wpisów, bez oceny. Każda data otwiera cały dzień.", + "report": "Raport lekarski od {date} do dziś" + }, + "travel": "W podróży", + "event": { + "started": "Początek: {label}", + "ended": "Koniec: {label}", + "paused": "Wstrzymano: {label}", + "resumed": "Wznowiono: {label}", + "illnessBegan": "Początek: {label}", + "illnessResolved": "Wyzdrowienie: {label}" + }, + "pickDate": "Wybierz datę", + "pickerTitle": "Przejdź do dnia", + "today": "Dzisiaj", + "hasEntries": "z wpisami", + "dayNSince": "Dzień {n}, od {date}", + "sinceDate": "Od {date}" + }, + "timeline": { + "title": "Oś czasu", + "subtitle": "Co i kiedy się działo, obok Twoich wartości.", + "standing": "Stałe", + "startUnknown": "początek nieznany", + "zoom": { + "all": "Wszystko", + "year": "12 miesięcy", + "quarter": "3 miesiące", + "range": "Zakres" + }, + "lanes": { + "life": "Życie", + "illness": "Choroby", + "allergies": "Alergie", + "medications": "Leki", + "vaccinations": "Szczepienia", + "visits": "Lekarz i zabiegi", + "labs": "Badania", + "documents": "Dokumenty", + "cycle": "Cykl" + }, + "readiness": { + "title": "Co już zawiera Twoja oś czasu", + "subtitle": "Uczciwy przegląd, zanim ją otworzysz.", + "menuEntry": "Stan danych", + "hint": "Puste ścieżki pozostają ukryte, dopóki coś się nie pojawi. Wyłączone moduły nigdy się nie pojawiają. Ten przegląd znajdziesz później w sekcji Stan danych na osi czasu.", + "later": "Później", + "open": "Otwórz oś czasu", + "verdict": { + "carries": "Już ma treść.", + "thin": "Jeszcze skromna. Na razie oś czasu pokazuje głównie Twoje wartości." + }, + "status": { + "carries": "wypełniona", + "thin": "skromna", + "empty": "pusta" + }, + "lanes": { + "values": "Wartości", + "mood": "Nastrój", + "environment": "Otoczenie", + "life": "Wydarzenia życiowe" + }, + "meter": "{carrying} z {total} ścieżek ma treść", + "carriesDetail": "{carrying} z {total} ścieżek daje przegląd od {since}.", + "loadFailed": "Nie udało się wczytać przeglądu.", + "card": { + "title": "Co już zawiera oś czasu", + "summary": "Sięga do {since}; {carrying} z {total} ścieżek ma już treść.", + "dismiss": "Ukryj" + }, + "detail": { + "valuesOne": "{count} pomiar od {since}", + "valuesFew": "{count} pomiary od {since}", + "valuesOther": "{count} pomiarów od {since}", + "illnessOne": "{count} epizod; przewlekłe: {chronic}", + "illnessFew": "{count} epizody; przewlekłe: {chronic}", + "illnessOther": "{count} epizodów; przewlekłe: {chronic}", + "allergiesOne": "{count} alergia", + "allergiesFew": "{count} alergie", + "allergiesOther": "{count} alergii", + "medicationsOne": "{count} lek; bez daty rozpoczęcia: {missingStart}", + "medicationsFew": "{count} leki; bez daty rozpoczęcia: {missingStart}", + "medicationsOther": "{count} leków; bez daty rozpoczęcia: {missingStart}", + "medicationsDatedOne": "{count} lek z datą rozpoczęcia", + "medicationsDatedFew": "{count} leki z datą rozpoczęcia", + "medicationsDatedOther": "{count} leków z datą rozpoczęcia", + "vaccinationsOne": "{count} szczepienie", + "vaccinationsFew": "{count} szczepienia", + "vaccinationsOther": "{count} szczepień", + "visitsOne": "{count} wizyta, z zabiegiem: {procedures}", + "visitsFew": "{count} wizyty, z zabiegiem: {procedures}", + "visitsOther": "{count} wizyt, z zabiegiem: {procedures}", + "visitsPlannedOne": "{count} wizyta, tylko zaplanowana", + "visitsPlannedFew": "{count} wizyty, tylko zaplanowane", + "visitsPlannedOther": "{count} wizyt, tylko zaplanowane", + "labsOne": "{count} dzień z wynikami", + "labsFew": "{count} dni z wynikami", + "labsOther": "{count} dni z wynikami", + "documentsOne": "{count} dokument z datą", + "documentsFew": "{count} dokumenty z datą", + "documentsOther": "{count} dokumentów z datą", + "lifeOne": "{count} wydarzenie życiowe", + "lifeFew": "{count} wydarzenia życiowe", + "lifeOther": "{count} wydarzeń życiowych", + "cycleOne": "{count} cykl", + "cycleFew": "{count} cykle", + "cycleOther": "{count} cykli", + "moodOne": "{count} wpis od {since}", + "moodFew": "{count} wpisy od {since}", + "moodOther": "{count} wpisów od {since}", + "environmentOne": "{count} dzień z danymi o środowisku", + "environmentFew": "{count} dni z danymi o środowisku", + "environmentOther": "{count} dni z danymi o środowisku", + "empty": "Jeszcze brak" + }, + "gap": { + "valuesEmpty": "Połącz źródła", + "illnessEmpty": "Dodaj epizod", + "medicationsWithoutStart": "Uzupełnij", + "visitsEmpty": "Dodaj wizytę", + "documentsUndated": "Przejrzyj", + "labsEmpty": "Dodaj wynik", + "vaccinationsEmpty": "Dodaj szczepienia", + "lifeEventsEmpty": "Dodaj" + }, + "gapLine": { + "medicationsWithoutStartOne": "{count} lek bez daty rozpoczęcia", + "medicationsWithoutStartFew": "{count} leki bez daty rozpoczęcia", + "medicationsWithoutStartOther": "{count} leków bez daty rozpoczęcia", + "documentsUndatedOne": "{count} dokument bez daty", + "documentsUndatedFew": "{count} dokumenty bez daty", + "documentsUndatedOther": "{count} dokumentów bez daty", + "valuesEmpty": "Jeszcze brak wartości z dłuższego okresu", + "illnessEmpty": "Jeszcze brak chorób z datą początku", + "visitsEmpty": "Jeszcze brak odbytych wizyt", + "labsEmpty": "Jeszcze brak wyników badań", + "vaccinationsEmpty": "Jeszcze brak szczepień", + "lifeEventsEmpty": "Brak wydarzeń życiowych" + } + }, + "legendSpan": "Okres", + "legendEvent": "Zdarzenie", + "legendMeanQuarter": "Średnia kwartalna", + "legendMeanMonth": "Średnia miesięczna", + "legendMeanWeek": "Średnia tygodniowa", + "legendThin": "Mniej niż 3 pomiary", + "legendGap": "Krótka przerwa bez pomiarów", + "today": "Dziś", + "noDataYet": "Jeszcze brak danych", + "layers": "Warstwy", + "layersLanes": "Ścieżki", + "zoomLabel": "Zakres widoku", + "chartLabel": "Oś czasu od {from} do {to}", + "keyboardHint": "Strzałki przesuwają wybór, Home i End przeskakują na krańce, Enter otwiera dzień.", + "loadFailed": "Nie udało się wczytać osi czasu.", + "empty": { + "title": "Na osi czasu jeszcze nic nie ma", + "description": "Wpisy z datą pojawią się tutaj, gdy tylko jakieś będą." + }, + "values": { + "menuTitle": "Wartości pod ścieżkami", + "trigger": "Wartości ({count})", + "noneInWindow": "Brak wartości w tym okresie", + "series": { + "BLOOD_PRESSURE_SYS": "Ciśnienie skurczowe", + "BLOOD_PRESSURE_DIA": "Ciśnienie rozkurczowe" + }, + "noValue": "brak wartości", + "bloodPressure": "Ciśnienie krwi", + "readingsOne": "1 pomiar", + "readingsFew": "średnia z {count} pomiarów", + "readingsOther": "średnia z {count} pomiarów" + }, + "table": { + "caption": "Wpisy na osi czasu", + "lane": "Ścieżka", + "entry": "Wpis", + "start": "Początek", + "end": "Koniec", + "seriesCaption": "Średnie według okresu", + "period": "Okres" + }, + "selection": { + "empty": "Brak wpisów w tym okresie", + "since": "od {date}", + "range": "od {from} do {to}", + "mean": "Ø {values}", + "week": "Tydzień od {date}", + "hintClick": "Kliknij punkt w czasie, aby zobaczyć go tutaj", + "hintTap": "Dotknij punktu w czasie, aby zobaczyć go tutaj" + }, + "chronicle": { + "months": "Miesiące", + "years": "Lata", + "groupingLabel": "Grupowanie", + "empty": "Brak wpisów od {from} do {to}", + "emptySingle": "Brak wpisów: {period}", + "ended": "Koniec: {label}", + "afterDaysOne": "po 1 dniu", + "afterDaysFew": "po {count} dniach", + "afterDaysOther": "po {count} dniach", + "notable": { + "extremeHigh": "Najwyższa wartość od dawna", + "extremeLow": "Najniższa wartość od dawna", + "firstValue": "Pierwsza wartość pomiaru", + "gap": "Dłuższa przerwa w wartościach" + }, + "notableMeta": "Otwórz dzień, aby zobaczyć szczegóły" + }, + "standingAllergiesOne": "{count} alergia", + "standingAllergiesFew": "{count} alergie", + "standingAllergiesOther": "{count} alergii", + "legendMeanDay": "Średnia dzienna", + "range": { + "from": "Od", + "to": "Do", + "label": "Wybrany zakres", + "open": "Wybierz zakres", + "clear": "Pokaż wszystko", + "apply": "Zastosuj" + }, + "item": { + "labValuesOne": "1 wynik badania", + "labValuesFew": "{count} wyniki badań", + "labValuesOther": "{count} wyników badań" + } + }, + "lifeEvents": { + "title": "Wydarzenie życiowe", + "add": "Wydarzenie życiowe", + "privacy": "Tytuł i notatka są zapisywane w postaci zaszyfrowanej. Nie trafiają do żadnej AI.", + "emptyHint": "Przeprowadzka, narodziny dziecka czy nowa praca dają latom punkt odniesienia.", + "save": "Zapisz", + "cancel": "Anuluj", + "delete": "Usuń wydarzenie życiowe", + "saved": "Zapisano wydarzenie życiowe", + "deleted": "Usunięto wydarzenie życiowe", + "fields": { + "title": "Co się wydarzyło?", + "category": "Rodzaj", + "when": "Kiedy", + "precision": "Dokładność", + "addEnd": "Data zakończenia, jeśli był to okres", + "endDate": "Do", + "note": "Notatka", + "optionalTag": "opcjonalnie", + "notePlaceholder": "Coś, co zechcesz wiedzieć później", + "removeEnd": "Usuń datę zakończenia" + }, + "category": { + "FAMILY": "Rodzina", + "HOME": "Mieszkanie", + "WORK": "Praca", + "LOSS": "Strata", + "OTHER": "Inne" + }, + "precision": { + "DAY": "Dzień", + "MONTH": "Miesiąc", + "YEAR": "Rok" + }, + "edit": "Edytuj wydarzenie życiowe", + "errors": { + "titleRequired": "Nadaj wydarzeniu nazwę.", + "titleTooLong": "Najwyżej 120 znaków.", + "categoryRequired": "Wybierz rodzaj.", + "dateRequired": "Podaj prawidłową datę.", + "endBeforeStart": "Koniec jest przed początkiem.", + "noteTooLong": "Najwyżej 2000 znaków." + }, + "saveFailed": "Nie udało się zapisać.", + "deleteFailed": "Nie udało się usunąć.", + "deleteConfirm": { + "title": "Usunąć to wydarzenie życiowe?", + "body": "Tytuł i notatka znikną." + } + }, + "palette": { + "open": "Szukaj…", + "openLabel": "Szukaj stron, ustawień i akcji", + "title": "Szukaj", + "description": "Znajdź stronę, ustawienie, moduł lub akcję.", + "placeholder": "Szukaj stron, ustawień, modułów…", + "results": "Wyniki", + "noResults": "Brak wyników dla „{query}”.", + "moduleOn": "Wł.", + "moduleOff": "Wył.", + "hintMove": "Wybierz", + "hintOpen": "Otwórz", + "hintClose": "Zamknij", + "groups": { + "recent": "Ostatnie", + "pages": "Strony", + "insights": "Insights", + "settings": "Ustawienia", + "modules": "Moduły", + "actions": "Akcje" + }, + "actions": { + "capture": "Nowy wpis", + "coach": "Zapytaj Coacha", + "backup": "Utwórz kopię zapasową", + "today": "Otwórz dzisiaj", + "shortcuts": "Pokaż skróty klawiszowe" + } + }, + "shortcuts": { + "title": "Skróty klawiszowe", + "description": "Klawisze działające w całej aplikacji, z wyjątkiem pisania w polu.", + "menuEntry": "Skróty klawiszowe", + "then": "potem", + "search": "Szukaj", + "help": "Pokaż tę listę", + "close": "Zamknij okno lub panel", + "previousDay": "Poprzedni dzień", + "nextDay": "Następny dzień", + "dayHint": "Gdy otwarty jest dzień.", + "groups": { + "navigation": "Nawigacja", + "actions": "Akcje", + "day": "Dzień" + } + }, + "aboutPage": { + "eyebrow": "Informacje", + "title": "O HealthLog", + "lastUpdated": "Ostatnia aktualizacja: {date}", + "project": "Projekt", + "credits": "Podziękowania" } } diff --git a/next.config.ts b/next.config.ts index 2edfcfba3..672f10a91 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,8 +1,31 @@ +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + import type { NextConfig } from "next"; import bundleAnalyzer from "@next/bundle-analyzer"; import { version as PKG_VERSION } from "./package.json"; +/** + * The message catalogs' content hash, the cache key of the boot script + * (`/i18n/.js?v=…`, `src/app/layout.tsx`). The service worker serves + * that URL cache-first, so a key that does not move with the catalog keeps + * an old catalog alive: keyed by the package version, every build of one + * release (a beta, a redeploy) shared one URL, and a key added since the + * first of them read as its raw name. + */ +function catalogVersion(): string { + const dir = join(process.cwd(), "messages"); + const hash = createHash("sha256"); + for (const file of readdirSync(dir) + .filter((f) => f.endsWith(".json")) + .sort()) { + hash.update(file).update(readFileSync(join(dir, file))); + } + return hash.digest("hex").slice(0, 16); +} + const nextConfig: NextConfig = { output: "standalone", // `next build` type-checks the whole tsconfig program since 16.3, which @@ -20,6 +43,7 @@ const nextConfig: NextConfig = { // had to discover "pull-to-refresh" themselves after every release. env: { NEXT_PUBLIC_APP_VERSION: PKG_VERSION, + NEXT_PUBLIC_I18N_CATALOG_VERSION: catalogVersion(), }, // v1.4.33 IW2 — strip `console.*` calls from the production bundle // (keep `console.error` + `console.warn` so the GlitchTip reporter diff --git a/package.json b/package.json index 60195cac5..eaae5bf4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.41.2", + "version": "1.42.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", diff --git a/playwright.config.ts b/playwright.config.ts index bf46cf31d..796f9b4fe 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -110,7 +110,10 @@ export default defineConfig({ name: "chromium-desktop", // The disk-layer spec needs a live service worker, which the shared // `use` block blocks for every other spec. It runs in its own project. - testIgnore: ["v137-record-session-fence-offline.spec.ts"], + testIgnore: [ + "v137-record-session-fence-offline.spec.ts", + "pwa-offline.spec.ts", + ], use: { ...devices["Desktop Chrome"], viewport: { width: 1280, height: 720 }, @@ -130,10 +133,14 @@ export default defineConfig({ // cannot pass is worse than no check, so the spec gets a project rather // than a caveat. // - // It uses no route mocks for exactly this reason, and it is the only - // spec in this project. + // It uses no route mocks for exactly this reason. v1.42 — the offline + // page journey (`pwa-offline.spec.ts`) joined it on the same terms: + // what the worker itself serves without a connection, no mocks. name: "chromium-service-worker", - testMatch: ["v137-record-session-fence-offline.spec.ts"], + testMatch: [ + "v137-record-session-fence-offline.spec.ts", + "pwa-offline.spec.ts", + ], use: { ...devices["Desktop Chrome"], viewport: { width: 1280, height: 720 }, @@ -185,6 +192,11 @@ export default defineConfig({ // through stable data-slots, not a mobile layout, so it runs in one // project. "vaccinations.spec.ts", + // The timeline journey moves its account's timeline module on and + // off; two projects on that one account would flip it under each + // other. It checks the phone chronicle itself by resizing inside the + // desktop project. + "timeline.spec.ts", // The blood-glucose journey writes readings to the one shared account // and then reads GLOBAL verdicts off it — three rows in the list, one // tile per meal-time context — while also moving that account's @@ -215,6 +227,7 @@ export default defineConfig({ "doctor-report-delegate.spec.ts", // Runs only in the service-worker project. "v137-record-session-fence-offline.spec.ts", + "pwa-offline.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 @@ -276,6 +289,11 @@ export default defineConfig({ // account under the first. They prove server state through stable // data-slots, not a mobile layout, so they run in one project. "ai-optional-account.spec.ts", + // The score-history journey moves its account's top card and range + // tabs; two projects on that one account would flip them under each + // other. It checks the phone width by resizing inside the desktop + // project. + "score-history.spec.ts", ], use: { // Pixel 5 — Chromium-based mobile profile so CI only needs diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 215c1f798..a1a2e80f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,9 @@ overrides: '@hono/node-server@<1.19.15': ^1.19.15 picomatch@<4.0.4: ^4.0.4 body-parser@<2.3.0: ^2.3.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 flatted@<3.4.2: ^3.4.2 @@ -5721,8 +5724,8 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss-selector-parser@7.1.4: - resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} + postcss-selector-parser@7.1.6: + resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==} engines: {node: '>=4'} postcss@8.5.23: @@ -6267,8 +6270,8 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - smol-toml@1.8.0: - resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} + smol-toml@1.9.0: + resolution: {integrity: sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==} engines: {node: '>= 18'} sonner@2.0.8: @@ -6281,10 +6284,6 @@ packages: '@types/react': optional: true - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - source-map-js@1.2.2: resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} engines: {node: '>=0.10.0'} @@ -9489,7 +9488,7 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 - source-map-js: 1.2.1 + source-map-js: 1.2.2 tailwindcss: 4.3.3 '@tailwindcss/oxide-android-arm64@4.3.3': @@ -11946,7 +11945,7 @@ snapshots: oxc-parser: 0.143.0 oxc-resolver: 11.24.2 picomatch: 4.0.7 - smol-toml: 1.8.0 + smol-toml: 1.9.0 strip-json-comments: 5.0.3 tinyglobby: 0.2.17 unbash: 4.0.10 @@ -12634,7 +12633,7 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-selector-parser@7.1.4: + postcss-selector-parser@7.1.6: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 @@ -12649,13 +12648,13 @@ snapshots: dependencies: nanoid: 3.3.18 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 postcss@8.5.26: dependencies: nanoid: 3.3.18 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 postgres-array@2.0.0: {} @@ -13215,7 +13214,7 @@ snapshots: open: 11.0.0 ora: 8.2.0 postcss: 8.5.25 - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.6 prompts: 2.4.2 recast: 0.23.19 stringify-object: 5.0.0 @@ -13314,7 +13313,7 @@ snapshots: sisteransi@1.0.5: {} - smol-toml@1.8.0: {} + smol-toml@1.9.0: {} sonner@2.0.8(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): dependencies: @@ -13323,8 +13322,6 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 - source-map-js@1.2.1: {} - source-map-js@1.2.2: {} source-map@0.6.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a35c1a52d..5e1ffece7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -27,6 +27,13 @@ overrides: "@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-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 + # are quadratic-time parsers reached at build or dev time only. + "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" # Array-limit bypass through bracket-key comma parsing, and a denial of # service through an attacker-controlled isBuffer. 6.16.0 covers both. "qs@<6.16.0": "^6.16.0" diff --git a/prisma/migrations/0379_measurement_source_health_connect/migration.sql b/prisma/migrations/0379_measurement_source_health_connect/migration.sql new file mode 100644 index 000000000..e427a3f98 --- /dev/null +++ b/prisma/migrations/0379_measurement_source_health_connect/migration.sql @@ -0,0 +1,14 @@ +-- Android Health Connect import provenance (#972). +-- +-- Rows written by the Health Connect import worker carry their own +-- `MeasurementSource` value, so they stay distinguishable from the Apple +-- Health and generic IMPORT rows and get an isolated idempotency namespace +-- `(userId, type, HEALTH_CONNECT, externalId)`. +-- +-- Purely additive enum extension, alone in its migration: Postgres forbids +-- USING a freshly added value in the same transaction, and nothing here uses +-- it. `ADD VALUE IF NOT EXISTS` makes a rerun safe. Mirrors 0346 (EXTERNAL). +-- +-- Reversibility: Postgres cannot remove an enum value, so `HEALTH_CONNECT` +-- stays (inert with no rows). +ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'HEALTH_CONNECT'; diff --git a/prisma/migrations/0380_import_job_kind/migration.sql b/prisma/migrations/0380_import_job_kind/migration.sql new file mode 100644 index 000000000..ba7d31c2f --- /dev/null +++ b/prisma/migrations/0380_import_job_kind/migration.sql @@ -0,0 +1,9 @@ +-- Which importer owns an `import_jobs` row (#972). +-- +-- Until now every row was an Apple Health export; the Health Connect importer +-- shares the table. The reconcile cron and the staged-file sweep scope by +-- `kind` so one importer never fails or deletes the other's running job. +-- Every existing row is an Apple Health job, which the default records. +ALTER TABLE "import_jobs" ADD COLUMN "kind" TEXT NOT NULL DEFAULT 'apple_health'; + +CREATE INDEX "import_jobs_user_id_kind_started_at_idx" ON "import_jobs"("user_id", "kind", "started_at"); diff --git a/prisma/migrations/0381_environment_air_quality/migration.sql b/prisma/migrations/0381_environment_air_quality/migration.sql new file mode 100644 index 000000000..6c6ae407f --- /dev/null +++ b/prisma/migrations/0381_environment_air_quality/migration.sql @@ -0,0 +1,40 @@ +-- Air quality, pollen and UV per environment day (#615). +-- +-- Twenty-two nullable columns filled from the Open-Meteo air-quality feed +-- (CAMS Europe / global). Nullable throughout: a partial feed stores what it +-- has, and a day the feed never covered stays NULL rather than zero. +-- `aq_fetched_at` NULL marks a day whose air-quality part has not been +-- fetched yet; the partial gap index serves the nightly fill's lookup. +-- +-- `users.environment_air_quality_enabled` is the per-user switch, on by +-- default; the operator switch `ENVIRONMENT_AIR_QUALITY_DISABLED` wins. +ALTER TABLE "environment_contexts" + ADD COLUMN "apparent_max" DOUBLE PRECISION, + ADD COLUMN "pm25_mean" DOUBLE PRECISION, + ADD COLUMN "pm25_max" DOUBLE PRECISION, + ADD COLUMN "pm10_mean" DOUBLE PRECISION, + ADD COLUMN "no2_mean" DOUBLE PRECISION, + ADD COLUMN "so2_mean" DOUBLE PRECISION, + ADD COLUMN "co_mean" DOUBLE PRECISION, + ADD COLUMN "o3_max_8h" DOUBLE PRECISION, + ADD COLUMN "eaqi_max" DOUBLE PRECISION, + ADD COLUMN "usaqi_max" DOUBLE PRECISION, + ADD COLUMN "uv_index_max" DOUBLE PRECISION, + ADD COLUMN "dust_max" DOUBLE PRECISION, + ADD COLUMN "aod_max" DOUBLE PRECISION, + ADD COLUMN "pollen_alder_max" DOUBLE PRECISION, + ADD COLUMN "pollen_birch_max" DOUBLE PRECISION, + ADD COLUMN "pollen_grass_max" DOUBLE PRECISION, + ADD COLUMN "pollen_mugwort_max" DOUBLE PRECISION, + ADD COLUMN "pollen_olive_max" DOUBLE PRECISION, + ADD COLUMN "pollen_ragweed_max" DOUBLE PRECISION, + ADD COLUMN "aq_domain" TEXT, + ADD COLUMN "aq_hours" INTEGER, + ADD COLUMN "aq_fetched_at" TIMESTAMP(3); + +CREATE INDEX "environment_contexts_aq_gap_idx" + ON "environment_contexts"("user_id", "date") + WHERE "aq_fetched_at" IS NULL; + +ALTER TABLE "users" + ADD COLUMN "environment_air_quality_enabled" BOOLEAN NOT NULL DEFAULT true; diff --git a/prisma/migrations/0382_environment_location_encrypted/migration.sql b/prisma/migrations/0382_environment_location_encrypted/migration.sql new file mode 100644 index 000000000..9be9f545f --- /dev/null +++ b/prisma/migrations/0382_environment_location_encrypted/migration.sql @@ -0,0 +1,36 @@ +-- Environment locations sealed at rest (#615). +-- +-- The home location, each dated location period and the per-day resolved +-- location gain a sealed copy (lat, lon, label under AAD +-- `healthlog/environment-location/v1`). The free-text encryption backfill +-- fills it from the plaintext and then clears the plaintext; the plaintext +-- columns therefore become nullable here and drop in a later release, once +-- the backfill reports zero remaining rows everywhere. +-- +-- The partial indexes serve the backfill's discovery ("rows still holding +-- plaintext") and empty themselves as it runs. +ALTER TABLE "users" ADD COLUMN "home_location_encrypted" BYTEA; + +ALTER TABLE "environment_travel_locations" + ADD COLUMN "location_encrypted" BYTEA, + ALTER COLUMN "lat" DROP NOT NULL, + ALTER COLUMN "lon" DROP NOT NULL, + ALTER COLUMN "label" DROP NOT NULL; + +ALTER TABLE "environment_contexts" + ADD COLUMN "location_encrypted" BYTEA, + ALTER COLUMN "lat" DROP NOT NULL, + ALTER COLUMN "lon" DROP NOT NULL, + ALTER COLUMN "location_label" DROP NOT NULL; + +CREATE INDEX "users_home_location_plain_idx" + ON "users"("id") + WHERE "home_lat" IS NOT NULL; + +CREATE INDEX "environment_travel_locations_plain_location_idx" + ON "environment_travel_locations"("id") + WHERE "lat" IS NOT NULL; + +CREATE INDEX "environment_contexts_plain_location_idx" + ON "environment_contexts"("id") + WHERE "lat" IS NOT NULL; diff --git a/prisma/migrations/0383_managed_profile_handover/migration.sql b/prisma/migrations/0383_managed_profile_handover/migration.sql new file mode 100644 index 000000000..6bdc836ce --- /dev/null +++ b/prisma/migrations/0383_managed_profile_handover/migration.sql @@ -0,0 +1,32 @@ +-- Managed profile handover links (#959). +-- +-- A guardian mints a one-time link that hands a managed profile over to the +-- person it describes. Only the HMAC-SHA256 hash of the token is stored. +-- `proposals_json` carries the guardian-side proposal per guardian (end, +-- read, manage) that the new owner confirms when claiming. The partial +-- unique index allows at most one open (unused, unrevoked) link per profile. +CREATE TABLE "managed_profile_handovers" ( + "id" TEXT NOT NULL, + "profile_id" TEXT NOT NULL, + "created_by_id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "proposals_json" JSONB NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + "used_at" TIMESTAMP(3), + "revoked_at" TIMESTAMP(3), + + CONSTRAINT "managed_profile_handovers_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "managed_profile_handovers_token_hash_key" ON "managed_profile_handovers"("token_hash"); + +CREATE INDEX "managed_profile_handovers_profile_id_idx" ON "managed_profile_handovers"("profile_id"); + +CREATE UNIQUE INDEX "managed_profile_handovers_open_idx" + ON "managed_profile_handovers"("profile_id") + WHERE "used_at" IS NULL AND "revoked_at" IS NULL; + +ALTER TABLE "managed_profile_handovers" ADD CONSTRAINT "managed_profile_handovers_profile_id_fkey" FOREIGN KEY ("profile_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "managed_profile_handovers" ADD CONSTRAINT "managed_profile_handovers_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/0384_healthkit_type_sync/migration.sql b/prisma/migrations/0384_healthkit_type_sync/migration.sql new file mode 100644 index 000000000..32ee27029 --- /dev/null +++ b/prisma/migrations/0384_healthkit_type_sync/migration.sql @@ -0,0 +1,16 @@ +-- Per-HealthKit-type arrival ledger (#1173). +-- +-- One row per (user, type), upserted by the measurement batch route: when the +-- type last arrived, under which `syncTrigger`, and when it last brought a new +-- or updated sample. Diagnostic state the next sync rebuilds; not backed up. +CREATE TABLE "healthkit_type_syncs" ( + "user_id" TEXT NOT NULL, + "type" "measurement_type" NOT NULL, + "last_received_at" TIMESTAMP(3) NOT NULL, + "last_trigger" TEXT, + "last_new_sample_at" TIMESTAMP(3), + + CONSTRAINT "healthkit_type_syncs_pkey" PRIMARY KEY ("user_id","type") +); + +ALTER TABLE "healthkit_type_syncs" ADD CONSTRAINT "healthkit_type_syncs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/0385_lab_unit_conversion_source/migration.sql b/prisma/migrations/0385_lab_unit_conversion_source/migration.sql new file mode 100644 index 000000000..b732f99c2 --- /dev/null +++ b/prisma/migrations/0385_lab_unit_conversion_source/migration.sql @@ -0,0 +1,12 @@ +-- Lab unit conversion provenance (#1095). +-- +-- `biomarkers.analyte_key` names the catalogue analyte a marker resolves to, +-- which selects its unit-conversion table. `lab_results.source_value` / +-- `source_unit` keep the value and unit as printed on the report when a write +-- converted the reading into the marker's canonical unit. All nullable; no +-- existing row changes. +ALTER TABLE "biomarkers" ADD COLUMN "analyte_key" TEXT; + +ALTER TABLE "lab_results" + ADD COLUMN "source_value" DOUBLE PRECISION, + ADD COLUMN "source_unit" TEXT; diff --git a/prisma/migrations/0386_custom_vaccine/migration.sql b/prisma/migrations/0386_custom_vaccine/migration.sql new file mode 100644 index 000000000..4b7d7c322 --- /dev/null +++ b/prisma/migrations/0386_custom_vaccine/migration.sql @@ -0,0 +1,29 @@ +-- User-defined vaccines (#1005). +-- +-- A vaccine the user defines for a product the shipped catalogue does not +-- list, in the same shape a catalogue entry resolves to. A dose links to it +-- through `vaccination_records.custom_vaccine_id`; deleting the definition +-- sets the link to NULL and the dose stays on its `vaccine_name`. +CREATE TABLE "custom_vaccines" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "components" TEXT[], + "typical_series_doses" INTEGER, + "booster_interval_months" INTEGER, + "deleted_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "custom_vaccines_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "custom_vaccines_user_id_name_key" ON "custom_vaccines"("user_id", "name"); + +ALTER TABLE "custom_vaccines" ADD CONSTRAINT "custom_vaccines_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "vaccination_records" ADD COLUMN "custom_vaccine_id" TEXT; + +CREATE INDEX "vaccination_records_custom_vaccine_id_idx" ON "vaccination_records"("custom_vaccine_id"); + +ALTER TABLE "vaccination_records" ADD CONSTRAINT "vaccination_records_custom_vaccine_id_fkey" FOREIGN KEY ("custom_vaccine_id") REFERENCES "custom_vaccines"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/0387_life_event/migration.sql b/prisma/migrations/0387_life_event/migration.sql new file mode 100644 index 000000000..785afe466 --- /dev/null +++ b/prisma/migrations/0387_life_event/migration.sql @@ -0,0 +1,31 @@ +-- Life events for the timeline (#613). +-- +-- A dated anchor in the person's life: a move, a birth, a new job, a loss. +-- Dates are calendar strings (`YYYY-MM-DD`) without a time zone, and +-- `precision` says how much of the date is known; at MONTH or YEAR the stored +-- date is the first day of that month or year. Title and note are encrypted +-- at rest. Soft-deleted like the other record tables; the index serves the +-- per-user live list in date order. +CREATE TYPE "life_event_category" AS ENUM ('FAMILY', 'HOME', 'WORK', 'LOSS', 'OTHER'); + +CREATE TYPE "life_event_precision" AS ENUM ('DAY', 'MONTH', 'YEAR'); + +CREATE TABLE "life_events" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "category" "life_event_category" NOT NULL, + "start_date" TEXT NOT NULL, + "end_date" TEXT, + "precision" "life_event_precision" NOT NULL, + "title_encrypted" BYTEA NOT NULL, + "note_encrypted" BYTEA, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "life_events_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "life_events_user_id_deleted_at_start_date_idx" ON "life_events"("user_id", "deleted_at", "start_date"); + +ALTER TABLE "life_events" ADD CONSTRAINT "life_events_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/0388_documents_layout/migration.sql b/prisma/migrations/0388_documents_layout/migration.sql new file mode 100644 index 000000000..b7b5122c7 --- /dev/null +++ b/prisma/migrations/0388_documents_layout/migration.sql @@ -0,0 +1,3 @@ +-- Per-user document vault presentation (view + month arrangement). +-- Nullable, display-only; null reads as the defaults. +ALTER TABLE "users" ADD COLUMN "documents_layout_json" JSONB; diff --git a/prisma/migrations/0389_measurement_fold_repair/migration.sql b/prisma/migrations/0389_measurement_fold_repair/migration.sql new file mode 100644 index 000000000..c53691304 --- /dev/null +++ b/prisma/migrations/0389_measurement_fold_repair/migration.sql @@ -0,0 +1,12 @@ +-- One row per account once the fold repair has recomputed the daily and +-- hourly means an older release folded in two runs. The compaction-tombstone +-- purge waits for it, because the tombstones it deletes are what the repair +-- reads. Holds no health data; the account's deletion takes it along. +CREATE TABLE "measurement_fold_repairs" ( + "user_id" TEXT NOT NULL, + "completed_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "measurement_fold_repairs_pkey" PRIMARY KEY ("user_id") +); + +ALTER TABLE "measurement_fold_repairs" ADD CONSTRAINT "measurement_fold_repairs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/0390_handover_creator_set_null/migration.sql b/prisma/migrations/0390_handover_creator_set_null/migration.sql new file mode 100644 index 000000000..22525bd7d --- /dev/null +++ b/prisma/migrations/0390_handover_creator_set_null/migration.sql @@ -0,0 +1,14 @@ +-- v1.42 (#959): a handover outlives the Guardian who minted it. +-- +-- `created_by_id` cascaded, so a Guardian deleting their own account deleted +-- every handover they had issued, including a claimed one whose decision the +-- new owner had not made yet. The claim had already applied the proposal +-- (possibly MANAGE for a co-Guardian), and the owner lost the one place that +-- asks them to confirm it. The creator is now cleared instead; an unclaimed +-- link without a creator is refused by the claim like any link whose creator +-- lost access. +ALTER TABLE "managed_profile_handovers" ALTER COLUMN "created_by_id" DROP NOT NULL; + +ALTER TABLE "managed_profile_handovers" DROP CONSTRAINT "managed_profile_handovers_created_by_id_fkey"; + +ALTER TABLE "managed_profile_handovers" ADD CONSTRAINT "managed_profile_handovers_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/0391_environment_aq_history/migration.sql b/prisma/migrations/0391_environment_aq_history/migration.sql new file mode 100644 index 000000000..67b503977 --- /dev/null +++ b/prisma/migrations/0391_environment_aq_history/migration.sql @@ -0,0 +1,4 @@ +-- Progress of the air-quality history backfill (days covered of days the +-- source can serve). Written by the background job only; null until its +-- first run. +ALTER TABLE "users" ADD COLUMN "environment_aq_history_json" JSONB; diff --git a/prisma/migrations/0392_correlation_seasonal_engine_since/migration.sql b/prisma/migrations/0392_correlation_seasonal_engine_since/migration.sql new file mode 100644 index 000000000..4a9b66e3c --- /dev/null +++ b/prisma/migrations/0392_correlation_seasonal_engine_since/migration.sql @@ -0,0 +1,4 @@ +-- When the instance first stored a discovery finding under the seasonally +-- adjusted correlation engine. Stamped once by the engine itself; null until +-- its first run, which counts every existing pattern as an earlier finding. +ALTER TABLE "app_settings" ADD COLUMN "correlation_seasonal_engine_since" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dcd081188..49e96f28f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -64,9 +64,11 @@ model User { /// /// A timestamp rather than a boolean: it says since when, and preserves a /// future-compatible null state without adding a transition surface here. - /// This release does not expose a way to clear the marker or attach - /// credentials; the grants and activity trail remain intact if a later, - /// separately designed handover ever needs that state. + /// Cleared by the handover flow (`ManagedProfileHandover`): when the + /// person the profile describes claims it with credentials of their own, + /// the marker goes back to null in the same transaction that settles the + /// guardians' grants, so the account becomes an ordinary adult account. + /// The grants and activity trail stay intact across that transition. /// /// Consumers land in this release: the guardian resolver in /// `src/lib/api-handler.ts` reads it on every guardian request, and the @@ -91,16 +93,32 @@ model User { // without a home set) simply has no environment rows. `homeTimezone` anchors // the day-key an observation is stored under (it can differ from the display // `timezone` above for an account that tracks weather for another locale). - homeLat Float? @map("home_lat") - homeLon Float? @map("home_lon") - homeLabel String? @map("home_label") - homeTimezone String? @map("home_timezone") + homeLat Float? @map("home_lat") + homeLon Float? @map("home_lon") + homeLabel String? @map("home_label") + homeTimezone String? @map("home_timezone") // v1.25 — effective-from instant for the home location. Stamped the moment a // home is set/updated. Day resolution attributes a past day to home ONLY when // the day is on/after this instant; earlier days are skipped (never fabricated // from the current home). To fill the deep past the user adds an explicit // dated location period instead. Backfill defaults its start to this instant. - homeSince DateTime? @map("home_since") + homeSince DateTime? @map("home_since") + /// v1.42 — sealed home location (lat, lon, label) + /// under AAD `healthlog/environment-location/v1`. Replaces the plaintext + /// `homeLat` / `homeLon` / `homeLabel` once the free-text encryption backfill + /// reports zero remaining rows; the plaintext columns drop in v1.43. + homeLocationEncrypted Bytes? @map("home_location_encrypted") + /// v1.42 — per-user switch for the air-quality and + /// pollen part of the environment module. On by default; off means the + /// nightly fetch skips the air-quality feed and the surfaces leave the + /// fields out. The operator switch `ENVIRONMENT_AIR_QUALITY_DISABLED` wins. + environmentAirQualityEnabled Boolean @default(true) @map("environment_air_quality_enabled") + /// v1.42 — progress of the air-quality history backfill + /// (`src/lib/environment/air-quality-history.ts`): how many past days with + /// entries the source can cover, how many carry air quality, when it was + /// last checked and whether it is through. Written by the job only; read by + /// `GET /api/environment` for the settings card. Null until the first run. + environmentAqHistoryJson Json? @map("environment_aq_history_json") // Codex OAuth (ChatGPT subscription) codexAccessTokenEncrypted String? @map("codex_access_token_encrypted") @@ -298,6 +316,12 @@ model User { // once the user has explicitly toggled the view or saved an order. // Shape: { "version": 1, "view": "table", "order": ["", ...] } medicationListLayoutJson Json? @map("medication_list_layout_json") + // v1.42 — per-user document vault presentation: the card/list view and + // the stacked/flowing month arrangement. Display-only, preserve-when-absent + // PUT on `/api/documents/inbound/layout`, resolved field by field on read + // (`src/lib/documents/documents-layout.ts`). Null = defaults (cards, stacked). + // Shape: { "version": 1, "view": "list", "arrangement": "flow" } + documentsLayoutJson Json? @map("documents_layout_json") // v1.17.0 — per-user mood-tag presentation: group order + per-group tag // placement (catalogue tags "moved" into a group live here only — their // `categoryId` never changes). Display-only, preserve-when-absent PUT, @@ -971,6 +995,20 @@ model User { // `vaccinations` module key, which governs the surfaces and not the store. vaccinationRecords VaccinationRecord[] vaccinationDocumentLinks VaccinationDocumentLink[] + // v1.42 — user-defined vaccines for products the shipped catalogue does + // not list (#1005). + customVaccines CustomVaccine[] + // v1.42 — per-HealthKit-type arrival ledger (#1173). + healthKitTypeSyncs HealthKitTypeSync[] + // v1.42 — managed-profile handover links (#959): the profile being handed + // over, and the guardian who minted the link. + handoversOfProfile ManagedProfileHandover[] @relation("HandoverProfile") + handoversCreated ManagedProfileHandover[] @relation("HandoverCreator") + // v1.42 — dated anchors in the person's life for the timeline (#613): a + // move, a birth, a new job. Shared under the `profile` domain; never sent to + // a model. + lifeEvents LifeEvent[] + measurementFoldRepair MeasurementFoldRepair? // v1.7.0 profile — optional patient-identity fields surfaced on the // health-record export cover (PDF) and the FHIR `Patient` resource. @@ -1074,16 +1112,21 @@ model EnvironmentContext { date String /// Coarse resolved location for the day (lat/lon rounded to 1 decimal, about - /// 11 km, + label + the source that won precedence). - lat Float - lon Float + /// 11 km, + label + the source that won precedence). Nullable since v1.42: + /// the sealed copy in `locationEncrypted` replaces the plaintext once the + /// encryption backfill has run; the plaintext columns drop in v1.43. + lat Float? + lon Float? /// @internal: human-readable label of the resolved location, stored as /// operator/debug provenance beside the coordinates; surfaces select the /// numeric and weather columns only, never this label. The backup selects it /// (`src/lib/export/environment-backup.ts`) with the rest of the row: a /// restored reading has to keep saying where it was read. - locationLabel String @map("location_label") - source EnvironmentLocationSource + locationLabel String? @map("location_label") + /// v1.42 — sealed (lat, lon, label) for the day, + /// AAD `healthlog/environment-location/v1`. + locationEncrypted Bytes? @map("location_encrypted") + source EnvironmentLocationSource /// Daily temperature (°C). Min doubles as the overnight-low proxy. tempMin Float? @map("temp_min") @@ -1111,6 +1154,54 @@ model EnvironmentContext { /// WMO weather code for the day (descriptive condition lookup). weatherCode Int? @map("weather_code") + // v1.42 (#615) — air quality, pollen and UV from the Open-Meteo + // air-quality feed (CAMS). All nullable: a partial or absent feed stores + // what it has, and a day the feed never covered stays NULL, never zero. + /// Daily apparent ("feels-like") maximum (°C). + apparentMax Float? @map("apparent_max") + /// PM2.5 daily mean (µg/m³). + pm25Mean Float? @map("pm25_mean") + /// PM2.5 hourly maximum over the local day (µg/m³). + pm25Max Float? @map("pm25_max") + /// PM10 daily mean (µg/m³). + pm10Mean Float? @map("pm10_mean") + /// Nitrogen dioxide daily mean (µg/m³). + no2Mean Float? @map("no2_mean") + /// Sulphur dioxide daily mean (µg/m³). + so2Mean Float? @map("so2_mean") + /// Carbon monoxide daily mean (µg/m³). + coMean Float? @map("co_mean") + /// Ozone, maximum 8-hour running mean of the day (µg/m³). + o3Max8h Float? @map("o3_max_8h") + /// European AQI, daily maximum. + eaqiMax Float? @map("eaqi_max") + /// US AQI, daily maximum. + usaqiMax Float? @map("usaqi_max") + /// UV index, daily maximum. + uvIndexMax Float? @map("uv_index_max") + /// Saharan dust, daily maximum (µg/m³). + dustMax Float? @map("dust_max") + /// Aerosol optical depth, daily maximum. + aodMax Float? @map("aod_max") + /// Alder pollen, daily maximum (grains/m³, Europe only). + pollenAlderMax Float? @map("pollen_alder_max") + /// Birch pollen, daily maximum (grains/m³, Europe only). + pollenBirchMax Float? @map("pollen_birch_max") + /// Grass pollen, daily maximum (grains/m³, Europe only). + pollenGrassMax Float? @map("pollen_grass_max") + /// Mugwort pollen, daily maximum (grains/m³, Europe only). + pollenMugwortMax Float? @map("pollen_mugwort_max") + /// Olive pollen, daily maximum (grains/m³, Europe only). + pollenOliveMax Float? @map("pollen_olive_max") + /// Ragweed pollen, daily maximum (grains/m³, Europe only). + pollenRagweedMax Float? @map("pollen_ragweed_max") + /// Which air-quality model domain served the day (`cams_europe` / `cams_global`). + aqDomain String? @map("aq_domain") + /// Hourly air-quality samples the day was aggregated from (75 % rule). + aqHours Int? @map("aq_hours") + /// When the air-quality part was last fetched; NULL = not yet fetched (gap index). + aqFetchedAt DateTime? @map("aq_fetched_at") + /// When the row was last fetched/refreshed from the upstream feed. fetchedAt DateTime @default(now()) @map("fetched_at") createdAt DateTime @default(now()) @map("created_at") @@ -1118,6 +1209,9 @@ model EnvironmentContext { @@unique([userId, date]) @@index([userId, date]) + // `environment_contexts_aq_gap_idx` (user_id, date) WHERE aq_fetched_at IS + // NULL and `environment_contexts_plain_location_idx` (id) WHERE lat IS NOT + // NULL are partial indexes from migrations 0381 / 0382 (raw SQL only). @@map("environment_contexts") } @@ -1138,14 +1232,22 @@ model EnvironmentTravelLocation { startDate String @map("start_date") endDate String @map("end_date") - lat Float - lon Float - label String + /// Plaintext coarse location. Nullable since v1.42: the sealed copy in + /// `locationEncrypted` replaces it once the encryption backfill has run; + /// the plaintext columns drop in v1.43. + lat Float? + lon Float? + label String? + /// v1.42 — sealed (lat, lon, label) for the + /// period, AAD `healthlog/environment-location/v1`. + locationEncrypted Bytes? @map("location_encrypted") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@index([userId, startDate, endDate]) + // `environment_travel_locations_plain_location_idx` (id) WHERE lat IS NOT + // NULL is a partial index from migration 0382 (raw SQL only). @@map("environment_travel_locations") } @@ -1791,6 +1893,12 @@ enum MeasurementSource { // user's own. iOS DTO must mirror this exact spelling so the // /api/sync/changes delta feed decodes the rows. EXTERNAL + // v1.42 — rows imported from an Android Health Connect export (#972). + // Server-owned: written only by the Health Connect import worker, never + // by a client, so the batch + single-POST surfaces reject it like IMPORT. + // iOS DTO must mirror this spelling so the /api/sync/changes delta feed + // decodes the rows. + HEALTH_CONNECT @@map("measurement_source") } @@ -3814,6 +3922,71 @@ model InviteToken { @@map("invite_tokens") } +/// v1.42 (#959) — a one-time link that hands a managed profile over to the +/// person it describes. Minted by a guardian with MANAGE on the profile; the +/// token (`hlp_` + 64 hex) is shown once and only its HMAC-SHA256 hash is +/// stored, the `InviteToken` scheme. Claiming sets credentials on the +/// profile's own `User` row (no data moves), clears `managedProfileAt` and +/// settles each guardian's grant by the proposal recorded here. At most one +/// open link per profile (partial unique index in migration 0383). +model ManagedProfileHandover { + id String @id @default(cuid()) + /// The managed profile (`User` row with `managedProfileAt` set). + profileId String @map("profile_id") + /// The guardian who minted the link. Cleared when that account is deleted + /// (0390): a claimed handover keeps the new owner's pending decision, and + /// an unclaimed link without a creator is refused like any link whose + /// creator lost access. + createdById String? @map("created_by_id") + /// HMAC-SHA256 of the token, unique; the claim and its preview look the + /// link up by it (`src/lib/managed-profiles/handover.ts`). + tokenHash String @unique @map("token_hash") + /// The handover's state, versioned and read fail-closed by + /// `parseHandoverState`: the issuing guardian's proposal per guardian + /// (end / read / manage), and after the claim what each guardian was given + /// and the new owner's final decision. The pending-decision read on the new + /// owner's account is answered from here. + proposalsJson Json @map("proposals_json") + createdAt DateTime @default(now()) @map("created_at") + /// Link expiry (1, 7 or 14 days after minting). + expiresAt DateTime @map("expires_at") + /// Set when the profile was claimed through the link; the token is spent. + usedAt DateTime? @map("used_at") + /// Set when a guardian withdrew the link, a newer link replaced it, or its + /// creator lost access. + revokedAt DateTime? @map("revoked_at") + + profile User @relation("HandoverProfile", fields: [profileId], references: [id], onDelete: Cascade) + createdBy User? @relation("HandoverCreator", fields: [createdById], references: [id], onDelete: SetNull) + + @@index([profileId]) + // `managed_profile_handovers_open_idx` UNIQUE (profile_id) WHERE used_at IS + // NULL AND revoked_at IS NULL is a partial index from migration 0383. + @@map("managed_profile_handovers") +} + +/// v1.42 (#1173) — when each HealthKit type last arrived through the batch +/// ingest, and by which trigger. One row per (user, type), upserted by the +/// batch route; read by the Apple Health card's per-type freshness list. +/// Diagnostic state: rebuilt by the next sync, so it is not backed up. +model HealthKitTypeSync { + userId String @map("user_id") + type MeasurementType + /// Last batch that carried this type. + lastReceivedAt DateTime @map("last_received_at") + /// `syncTrigger` of that batch + /// (`foreground` / `background` / `push` / `manual`), NULL when absent. + lastTrigger String? @map("last_trigger") + /// Last batch that inserted or updated a sample of + /// this type (as opposed to sending only duplicates). + lastNewSampleAt DateTime? @map("last_new_sample_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@id([userId, type]) + @@map("healthkit_type_syncs") +} + // v1.16.0 — one row per signup an invite admitted. `InviteToken.usedBy` // keeps only the LAST consumer (it overwrites on multi-use invites); // this ledger carries the full history so the admin table can show @@ -4958,8 +5131,8 @@ model AppSettings { // `src/lib/ai/reasoning/resolve.ts` and published per record on // `GET /api/auth/me` as `coachReasoning`. Defaults keep reasoning on with no // cap, so an upgrade needs no admin action. - aiReasoningEnabled Boolean @default(true) @map("ai_reasoning_enabled") - aiReasoningMaxEffort String @default("high") @map("ai_reasoning_max_effort") + aiReasoningEnabled Boolean @default(true) @map("ai_reasoning_enabled") + aiReasoningMaxEffort String @default("high") @map("ai_reasoning_max_effort") // v1.18.0 — operator-level server-wide module availability. A DISABLED // allowlist mirroring the per-user `User.modulePreferencesJson`: absent / @@ -4998,6 +5171,14 @@ model AppSettings { /// step names who confirmed when the account still exists. encryptionKeyBackupConfirmedByUserId String? @map("encryption_key_backup_confirmed_by_user_id") + /// When this instance first stored a discovery finding under the seasonally + /// adjusted correlation engine (v1.42). Stamped once, by the first + /// `syncAcceptedPatterns` run of a discovery family, before it writes; null + /// until then. Consumer: `hadFindingsBeforeSeasonalAdjustment`, which counts + /// a discovery pattern created before it (or any, while it is null) as a + /// finding of the earlier engine and so earns the one-time note. + correlationSeasonalEngineSince DateTime? @map("correlation_seasonal_engine_since") + @@map("app_settings") } @@ -5439,6 +5620,13 @@ model ImportJob { pgBossJobId String? @unique @map("pg_boss_job_id") /// `queued | unpacking | parsing | upserting | done | failed`. status String @default("queued") + /// v1.42 (#972) — which importer owns the row: `apple_health` (every row + /// before v1.42) or `health_connect`. The reconcile cron and the + /// staged-file sweep look each row up on its own kind's queue + /// (`src/lib/jobs/apple-health-import-worker.ts`), so one importer never + /// fails or deletes the other's running job; the upload dedupe and the + /// Health Connect status route scope by it. + kind String @default("apple_health") /// Free-text reason on `failed`; null otherwise. failureReason String? @map("failure_reason") /// Bytes uploaded — surfaced to the operator audit trail. @@ -5486,6 +5674,7 @@ model ImportJob { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId, startedAt]) + @@index([userId, kind, startedAt]) @@index([status]) @@map("import_jobs") } @@ -5929,6 +6118,13 @@ model LabResult { sourceReferenceLow Float? @map("source_reference_low") sourceReferenceHigh Float? @map("source_reference_high") sourceReferenceText String? @map("source_reference_text") + /// @pending-consumer(#1095) v1.42 — the value as printed on the source + /// report, kept when a write converted it into the biomarker's canonical + /// unit. NULL when no conversion happened. A later edit of `value` clears it. + sourceValue Float? @map("source_value") + /// @pending-consumer(#1095) v1.42 — the unit the source report printed, + /// paired with `sourceValue`. + sourceUnit String? @map("source_unit") /// When the sample was taken (UTC instant; wall-clock anchoring is the /// reader's concern per the schema's UTC-only convention). takenAt DateTime @map("taken_at") @@ -5997,6 +6193,10 @@ model Biomarker { /// and from the lab-entry pickers, but never deleted — its readings and /// canonical unit/range definition survive. hidden Boolean @default(false) @map("hidden") + /// @pending-consumer(#1095) v1.42 — the catalogue analyte this marker + /// resolves to (alias list in `src/lib/labs/biomarker-catalog.ts`), which + /// selects the unit-conversion table. NULL = not resolved; no conversion. + analyteKey String? @map("analyte_key") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -9120,13 +9320,13 @@ model EncounterConditionLink { /// module is off; these data routes stay exempt so a restore or an import /// keeps working and re-enabling finds every row intact. model VaccinationRecord { - id String @id @default(cuid()) - userId String @map("user_id") + id String @id @default(cuid()) + userId String @map("user_id") /// The day the dose was given, at UTC midnight. An Impfpass carries dates, /// never times, so the time component is not meaningful and is not /// collected. The API refuses future instants: a vaccination is logged /// after the fact, and a planned booster is a reminder, not a record. - occurredAt DateTime @map("occurred_at") + occurredAt DateTime @map("occurred_at") /// Catalogue identity. NULL = a free-text-only row, which is the arm that /// makes a 1987 Pass line loggable at all. /// @@ -9136,48 +9336,86 @@ model VaccinationRecord { /// resolve, but a restore writes whatever the backup held. A slug that /// stops resolving degrades to the free-text arm — a tested behaviour, not /// an accident (`src/lib/vaccinations/__tests__/catalog-integrity.test.ts`). - antigenSlug String? @map("antigen_slug") + antigenSlug String? @map("antigen_slug") /// The Pass's own wording, verbatim. May be a trade name; that is user /// data. Plaintext and bounded so the list can render and search it. - vaccineName String? @map("vaccine_name") + vaccineName String? @map("vaccine_name") /// The Pass's claim about which dose this is. Optional — the series /// derivation falls back to the chronological index per component antigen. - doseNumber Int? @map("dose_number") + doseNumber Int? @map("dose_number") /// Per-record override of the catalogue's typical series length, for the /// schedule a person was actually given. - seriesDoses Int? @map("series_doses") + seriesDoses Int? @map("series_doses") /// Chargenbezeichnung (IfSG §22). A batch code, not a secret: plaintext and /// bounded so the list and the report can render it without a decrypt. - lotNumber String? @map("lot_number") - site VaccinationSite? - practitionerId String? @map("practitioner_id") - encounterId String? @map("encounter_id") + lotNumber String? @map("lot_number") + site VaccinationSite? + practitionerId String? @map("practitioner_id") + encounterId String? @map("encounter_id") /// The booster reminder this dose satisfied, when one matched. The act runs /// through `satisfyReminder`; this column is the record of it, not the act. /// One id for possibly several satisfied rows — the engine keeps /// `lastSatisfiedAt` per reminder, this keeps the first match in catalogue /// component order. - reminderId String? @map("reminder_id") + reminderId String? @map("reminder_id") + /// v1.42 (#1005) — the user's own vaccine definition when the dose is for a + /// product the catalogue does not list, resolved through + /// `src/lib/vaccinations/resolve-vaccine-entry.ts`. Catalogue wins when both + /// resolve. Removing the definition unlinks the dose and leaves it a name + /// (`removeCustomVaccine`); SetNull covers a hard delete the same way. + customVaccineId String? @map("custom_vaccine_id") /// The one free-text field (AES-256-GCM at rest, the `*Encrypted` Bytes /// convention). Impfreaktionen live here in v1 rather than in a structured /// reaction model nobody has asked for yet. - noteEncrypted Bytes? @map("note_encrypted") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + noteEncrypted Bytes? @map("note_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") /// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant. - deletedAt DateTime? @map("deleted_at") + deletedAt DateTime? @map("deleted_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) practitioner Practitioner? @relation(fields: [practitionerId], references: [id], onDelete: SetNull) encounter Encounter? @relation(fields: [encounterId], references: [id], onDelete: SetNull) reminder MeasurementReminder? @relation(fields: [reminderId], references: [id], onDelete: SetNull) + customVaccine CustomVaccine? @relation(fields: [customVaccineId], references: [id], onDelete: SetNull) documentLinks VaccinationDocumentLink[] @@index([userId, deletedAt, occurredAt]) @@index([userId, antigenSlug, occurredAt]) + @@index([customVaccineId]) @@map("vaccination_records") } +/// v1.42 (#1005) — a vaccine the user defines for a product the shipped +/// catalogue does not list. Same shape the resolver hands out for a +/// catalogue entry (`components` = antigen slugs, typical series length, +/// booster interval), so series derivation and booster matching treat both +/// alike. Soft-deleted; a deleted definition leaves its doses standing on +/// their `vaccineName`. +model CustomVaccine { + id String @id @default(cuid()) + userId String @map("user_id") + /// Display name, unique per user (the API also refuses a case-only twin). + name String + /// Antigen slugs the product covers, from `ANTIGEN_SLUGS`; storage is + /// tolerant, the resolver skips a slug it does not know. + components String[] + /// Typical primary-series length; the series total for its doses. + typicalSeriesDoses Int? @map("typical_series_doses") + /// Booster interval in months, the booster offer's prefill; NULL = none. + boosterIntervalMonths Int? @map("booster_interval_months") + /// Soft-delete tombstone. NULL = live. + deletedAt DateTime? @map("deleted_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + records VaccinationRecord[] + + @@unique([userId, name]) + @@map("custom_vaccines") +} + /// Where the dose went. Small and closed, because the Pass's own vocabulary /// is small and closed; OTHER absorbs the rest rather than growing the enum. enum VaccinationSite { @@ -9356,3 +9594,89 @@ model DocumentAiRun { @@index([status, expiresAt]) @@map("document_ai_runs") } + +/// A dated anchor in the person's life for the timeline (v1.42, #613): a move, +/// a birth, a new job, a loss. Not a health reading and not a symptom; it gives +/// the years around the readings something to hang on. +/// +/// Shared under the `profile` domain, beside allergies, visits and the +/// immunization history. Never sent to a model in any form: the Coach, the +/// MCP endpoint and every AI job leave it out, and the day loader's model-facing +/// projection drops it. +/// +/// Dates are calendar dates without a time zone (`YYYY-MM-DD`), the +/// `src/lib/tz/date-only.ts` convention, because "we moved in September 2023" +/// names a day on a calendar, not an instant. `precision` says how much of the +/// date the person actually knows; at MONTH or YEAR the stored date is the +/// first day of that month or year, and only a DAY event appears in a day view. +model LifeEvent { + id String @id @default(cuid()) + userId String @map("user_id") + /// @pending-consumer(#613) The kind of event, from a deliberately short list. + category LifeEventCategory + /// @pending-consumer(#613) First day of the event, `YYYY-MM-DD`. At MONTH or + /// YEAR precision the first day of that month or year. + startDate String @map("start_date") + /// @pending-consumer(#613) Last day, `YYYY-MM-DD`, for an event that spans a + /// period (parental leave, a year abroad). NULL = a single date. + endDate String? @map("end_date") + /// @pending-consumer(#613) How much of the date is known. Applies to both + /// ends. + precision LifeEventPrecision + /// @pending-consumer(#613) What happened, in the person's own words, + /// AES-256-GCM encrypted at rest. + titleEncrypted Bytes @map("title_encrypted") + /// @pending-consumer(#613) An optional note, AES-256-GCM encrypted at rest. + noteEncrypted Bytes? @map("note_encrypted") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + /// Soft-delete tombstone. NULL = live. + deletedAt DateTime? @map("deleted_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, deletedAt, startDate]) + @@map("life_events") +} + +/// What kind of life event. Few on purpose: travel already comes from the +/// environment module, and a long list turns an anchor into a diary. +enum LifeEventCategory { + FAMILY + HOME + WORK + LOSS + OTHER + + @@map("life_event_category") +} + +/// How much of a life event's date the person knows. +enum LifeEventPrecision { + DAY + MONTH + YEAR + + @@map("life_event_precision") +} + +/// v1.42 — the fold repair has been through this account. +/// +/// Before v1.42 the daily-mean consolidation and the dense hourly fold could +/// fold a local day in two runs, the second overwriting the stored mean with +/// the mean of the later part alone. The one-time repair +/// (`src/lib/jobs/measurement-fold-repair.ts`) recomputes every such mean from +/// the samples still in the table, the fold's own tombstones among them. The +/// compaction-tombstone purge deletes exactly those tombstones, so it leaves an +/// account alone until this row exists, and a restore that writes back a file +/// older than `completedAt`'s run removes the row so the repair goes again. +model MeasurementFoldRepair { + userId String @id @map("user_id") + /// When the repair finished the account. A restore compares it with the + /// file's export instant; the purge only asks whether the row exists. + completedAt DateTime @map("completed_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("measurement_fold_repairs") +} diff --git a/public/sw.js b/public/sw.js index c83b525f8..603d4a6af 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.41.2"; + /* @sw-version-fallback */ "v1.42.0"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` @@ -230,8 +230,9 @@ self.addEventListener("fetch", (event) => { return; } - // Locale-catalog boot script — cache-first. The URL is versioned - // (`/i18n/?v=`) and served immutable, so cache-first is + // Locale-catalog boot script — cache-first. The URL is versioned by the + // catalogs' content hash (`/i18n/?v=`, `next.config.ts`), + // so a changed catalog is a new URL and cache-first is // correct and keeps the catalog available on an offline relaunch (the // cached shell HTML references the same versioned URL it was rendered // with). Carries no user data — same public strings as the repository's @@ -341,6 +342,14 @@ async function networkFirstApi(request) { * scores straight into the HTML and emits `no-store`; the explicit * path skip is belt-and-braces so a revoked share can never linger in * CacheStorage and render back offline. + * + * In practice rule 1 covers every signed-in page: `src/proxy.ts` pins + * `private, no-store` on all of them, because several server-prefetch one + * account's record into the HTML. Only the public pages (sign-in, legal) + * land in this cache, so a navigation to an app page without a connection + * gets the offline page below, never somebody's cached record. A page that + * is already open keeps working offline from the cached chunks and the + * allowlisted data reads. */ function isCacheableNavigation(request, response) { const cacheControl = response.headers.get("Cache-Control") || ""; @@ -568,16 +577,28 @@ async function networkFirst(event, cacheName) { // "Offline" token + a generic retry hint expressed as an icon // (the round-trip arrow), so the page does the right thing in any // locale without shipping a translation bundle into the worker. - return new Response( - 'HealthLog — Offline

HealthLog

Offline

', - { - status: 503, - headers: { "Content-Type": "text/html; charset=utf-8" }, - }, - ); + // + // v1.42 — the arrow is a real control now (a link to the same URL, + // 64 px), the page reloads by itself as soon as the connection is + // back, it follows the device's light or dark scheme, and it keeps + // clear of the notch and home indicator of an installed app. + return new Response(OFFLINE_PAGE, { + status: 503, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); } } +const OFFLINE_PAGE = + 'HealthLog — Offline

HealthLog

Offline

' + + ''; + /** * Cache a successful network response without making CacheStorage * availability part of the fetch contract. Quota, eviction, or trim failures diff --git a/scripts/compare-correlation-findings.ts b/scripts/compare-correlation-findings.ts new file mode 100644 index 000000000..886e5e0f4 --- /dev/null +++ b/scripts/compare-correlation-findings.ts @@ -0,0 +1,338 @@ +/** + * Read-only before/after count of correlation findings per account, for the + * release-candidate dry run of the v1.42 statistics change. + * + * v1.42 changes what the discovery engine accepts: trend and season are + * removed from both series before correlating, p-values use the effective + * sample size, shrinkage and the phrasing tier read that effective size, and + * the weather channels are tested once over the averaged day-before-and-same- + * day exposure instead of a next-day lag. Every one of those can make an + * existing finding disappear. This script answers "how many, and where", + * on a copy of production, before the release ships. + * + * For each account it builds the matrix exactly as the correlations route + * does (same assembler, same 180-day window, same module map, same tiered + * read) and scans it twice: + * + * - BEFORE: the pre-v1.42 rules, restated here from the engine's own + * exported pieces (lag join at 1 day, Pearson with df = n − 2, BH-FDR, + * shrinkage and tier by n), over the raw daily weather values; + * - AFTER: the engine as it now ships. + * + * It prints counts only. No correlation, p-value, metric value or account + * identifier leaves the database: accounts are listed by a short hash of + * their id, channels by their stable key family. Nothing is written — the + * pattern store is not synced, no cache is touched. + * + * Run (inside the app container, which has DATABASE_URL): + * pnpm dlx tsx scripts/compare-correlation-findings.ts [--limit N] + */ +import "dotenv/config"; + +import { createHash } from "node:crypto"; + +import { prisma } from "@/lib/db"; +import { resolveModuleMap } from "@/lib/modules/gate"; +import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix"; +import { MIN_PAIRED_N, pearson } from "@/lib/insights/correlations"; +import { + benjaminiHochberg, + confidenceTier, + discoverCorrelations, + discoverEmergingCorrelations, + EARLY_FDR_Q, + EARLY_MIN_PAIRS, + EARLY_WINDOW_DAYS, + FDR_Q, + filterSeriesToWindow, + lagJoin, + metricFamily, + shrinkEstimate, + type DailySeriesPoint, + type NamedSeries, +} from "@/lib/insights/correlation-discovery"; +import { ENVIRONMENT_FIELDS } from "@/lib/environment/fields"; +import { + isSurfaceVisible, + correlationChannelSurfaceId, +} from "@/lib/modules/surface"; +import { wallClockInTz } from "@/lib/tz/wall-clock"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; + +const MS_PER_DAY = 86_400_000; +/** The correlations route's window. */ +const WINDOW_DAYS = 180; + +interface Finding { + behaviour: string; + outcome: string; +} + +interface LegacyFinding extends Finding { + tier: ReturnType; +} + +/** The pre-v1.42 discovery rules, restated from the engine's parts. */ +function legacyDiscover( + series: NamedSeries[], + opts: { minPairs: number; fdrQ: number }, +): { found: LegacyFinding[]; pairsTested: number } { + const behaviours = series.filter((s) => s.role === "behaviour"); + const outcomes = series.filter((s) => s.role === "outcome"); + const tested: Array = []; + for (const b of behaviours) { + for (const o of outcomes) { + if (metricFamily(b.key) === metricFamily(o.key)) continue; + const { xs, ys } = lagJoin(b.points, o.points, 1); + if (xs.length < opts.minPairs) continue; + const result = pearson({ xs, ys, minPairs: opts.minPairs }); + if (result.status !== "ok") continue; + tested.push({ + behaviour: b.key, + outcome: o.key, + n: result.n, + r: result.r, + pValue: result.pValue, + }); + } + } + const q = benjaminiHochberg(tested.map((t) => t.pValue)); + const found = tested + .filter((t, i) => t.pValue < 0.05 && q[i] <= opts.fdrQ) + .map((t) => ({ + behaviour: t.behaviour, + outcome: t.outcome, + tier: confidenceTier(shrinkEstimate(t.r, t.n), t.n), + })) + .filter((t) => t.tier !== null); + return { found, pairsTested: tested.length }; +} + +/** The pre-v1.42 emerging pass: same rules, trailing window, no faint tier. */ +function legacyEmerging( + series: NamedSeries[], + retrospective: Finding[], + recentFromDayKey: string, +): Finding[] { + const recent = legacyDiscover( + filterSeriesToWindow(series, recentFromDayKey), + { minPairs: EARLY_MIN_PAIRS, fdrQ: EARLY_FDR_Q }, + ); + const established = new Set(retrospective.map(key)); + return recent.found + .filter((f) => f.tier !== "faint") + .filter((f) => !established.has(key(f))); +} + +function key(f: Finding): string { + return `${f.behaviour}␟${f.outcome}`; +} + +function tzDayKey(at: Date, tz: string): string { + const { year, month, day } = wallClockInTz(at, tz); + return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; +} + +/** + * The weather channels as the engine read them before v1.42: one raw daily + * value per field, no averaging. Read here directly because the shipped + * series now carries the averaged window. + */ +async function legacyEnvironmentSeries( + userId: string, + since: Date, +): Promise> { + // Same UTC lower bound the engine's own environment read uses. + const sinceKey = since.toISOString().slice(0, 10); + const rows = await prisma.environmentContext.findMany({ + where: { userId, date: { gte: sinceKey } }, + orderBy: { date: "asc" }, + take: 1000, + }); + const out = new Map(); + for (const field of ENVIRONMENT_FIELDS) { + const points: DailySeriesPoint[] = []; + for (const row of rows) { + const raw = row[field.column]; + if (raw == null || !Number.isFinite(raw)) continue; + const value = + field.column === "sunshineSec" || field.column === "daylightSec" + ? raw / 3600 + : raw; + points.push({ day: row.date, value }); + } + out.set(field.key, points); + } + return out; +} + +/** Channel family label for the aggregate table (no user data). */ +function familyLabel(channel: string): string { + if (channel.startsWith("ENV_")) return "ENVIRONMENT"; + if (channel.startsWith("CUSTOM_METRIC:")) return "CUSTOM_METRIC"; + if (channel.startsWith("SYMPTOM:")) return "SYMPTOM_EVENT"; + if (channel.startsWith("FACTOR:")) return "MOOD_FACTOR"; + return channel; +} + +interface Tally { + before: number; + after: number; +} + +function bump(map: Map, k: string, side: keyof Tally) { + const t = map.get(k) ?? { before: 0, after: 0 }; + t[side]++; + map.set(k, t); +} + +function parseLimit(argv: string[]): number | undefined { + const i = argv.indexOf("--limit"); + if (i === -1) return undefined; + const n = Number(argv[i + 1]); + return Number.isInteger(n) && n > 0 ? n : undefined; +} + +async function main(): Promise { + const limit = parseLimit(process.argv.slice(2)); + const users = await prisma.user.findMany({ + select: { id: true, timezone: true }, + orderBy: { createdAt: "asc" }, + ...(limit ? { take: limit } : {}), + }); + + const now = new Date(); + const since = new Date(now.getTime() - WINDOW_DAYS * MS_PER_DAY); + const byBehaviour = new Map(); + const byOutcome = new Map(); + const totals = { + accounts: 0, + scanned: 0, + retroBefore: 0, + retroAfter: 0, + recentBefore: 0, + recentAfter: 0, + kept: 0, + lost: 0, + gained: 0, + }; + + console.log( + "account tested(b/a) retro b→a recent b→a kept lost new env b→a", + ); + for (const user of users) { + totals.accounts++; + const tz = user.timezone ?? DEFAULT_TIMEZONE; + const modules = await resolveModuleMap(user.id); + const { series } = await assembleDiscoveryMatrix(user.id, { + tz, + since, + fetchMode: "tiered", + modules, + }); + const envVisible = isSurfaceVisible( + correlationChannelSurfaceId(ENVIRONMENT_FIELDS[0].key), + modules, + ); + const rawEnv = envVisible + ? await legacyEnvironmentSeries(user.id, since) + : new Map(); + const legacySeries: NamedSeries[] = series.map((s) => + s.key.startsWith("ENV_") + ? { key: s.key, role: s.role, points: rawEnv.get(s.key) ?? [] } + : { key: s.key, role: s.role, label: s.label, points: s.points }, + ); + + const recentFromDayKey = tzDayKey( + new Date(now.getTime() - EARLY_WINDOW_DAYS * MS_PER_DAY), + tz, + ); + + const legacy = legacyDiscover(legacySeries, { + minPairs: MIN_PAIRED_N, + fdrQ: FDR_Q, + }); + const before = legacy.found; + const beforeRecent = legacyEmerging(legacySeries, before, recentFromDayKey); + const after = discoverCorrelations(series, { locale: "en" }); + const afterRecent = discoverEmergingCorrelations(series, after, { + recentFromDayKey, + locale: "en", + }); + + if (legacy.pairsTested === 0 && after.pairsTested === 0) continue; + totals.scanned++; + + const beforeKeys = new Set(before.map(key)); + const afterKeys = new Set(after.discovered.map(key)); + const kept = [...afterKeys].filter((k) => beforeKeys.has(k)).length; + const lost = beforeKeys.size - kept; + const gained = afterKeys.size - kept; + const envBefore = before.filter((f) => f.behaviour.startsWith("ENV_")); + const envAfter = after.discovered.filter((f) => + f.behaviour.startsWith("ENV_"), + ); + + for (const f of before) { + bump(byBehaviour, familyLabel(f.behaviour), "before"); + bump(byOutcome, familyLabel(f.outcome), "before"); + } + for (const f of after.discovered) { + bump(byBehaviour, familyLabel(f.behaviour), "after"); + bump(byOutcome, familyLabel(f.outcome), "after"); + } + + totals.retroBefore += before.length; + totals.retroAfter += after.discovered.length; + totals.recentBefore += beforeRecent.length; + totals.recentAfter += afterRecent.emerging.length; + totals.kept += kept; + totals.lost += lost; + totals.gained += gained; + + const tag = createHash("sha256").update(user.id).digest("hex").slice(0, 8); + console.log( + [ + tag.padEnd(9), + `${legacy.pairsTested}/${after.pairsTested}`.padEnd(12), + `${before.length}→${after.discovered.length}`.padEnd(10), + `${beforeRecent.length}→${afterRecent.emerging.length}`.padEnd(11), + String(kept).padStart(4), + String(lost).padStart(4), + String(gained).padStart(3), + ` ${envBefore.length}→${envAfter.length}`, + ].join(" "), + ); + } + + console.log(""); + console.log( + `accounts ${totals.accounts}, with a testable matrix ${totals.scanned}`, + ); + console.log( + `retrospective findings ${totals.retroBefore} → ${totals.retroAfter} (kept ${totals.kept}, lost ${totals.lost}, new ${totals.gained})`, + ); + console.log( + `emerging findings ${totals.recentBefore} → ${totals.recentAfter}`, + ); + const table = (title: string, map: Map) => { + console.log(""); + console.log(`${title}: before → after`); + for (const [k, t] of [...map.entries()].sort((a, b) => + a[0].localeCompare(b[0]), + )) { + console.log(` ${k.padEnd(24)} ${t.before} → ${t.after}`); + } + }; + table("by behaviour family", byBehaviour); + table("by outcome family", byOutcome); +} + +main() + .catch((err: unknown) => { + console.error(err); + process.exitCode = 1; + }) + .finally(() => { + void prisma.$disconnect(); + }); diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index d63628946..08a6796ec 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -186,6 +186,44 @@ } ] }, + { + "name": "Environmental context (Open-Meteo)", + "description": "Endpoints and switches for the opt-in Environment module. Every variable is optional: unset uses the hosted, keyless Open-Meteo services. Point the URLs at a self-hosted Open-Meteo instance to keep coordinate lookups off a third party.", + "required": false, + "variables": [ + { + "name": "OPENMETEO_BASE_URL", + "purpose": "Historical weather (archive) API base URL. Default https://archive-api.open-meteo.com." + }, + { + "name": "OPENMETEO_GEOCODING_URL", + "purpose": "Geocoding API base URL for the home and travel location search. Default https://geocoding-api.open-meteo.com." + }, + { + "name": "OPENMETEO_AIR_QUALITY_URL", + "purpose": "Air-quality API base URL (CAMS air quality, pollen, UV). Default https://air-quality-api.open-meteo.com. CAMS data is licensed by the Copernicus Atmosphere Monitoring Service; the attribution is shown in the app." + }, + { + "name": "ENVIRONMENT_AIR_QUALITY_DISABLED", + "purpose": "Set to 1, true or yes to stop the air-quality requests for the whole instance. Weather and daylight keep working; the per-user switch cannot turn air quality back on." + } + ] + }, + { + "name": "Database session tuning", + "description": "Per-session Postgres settings the app applies to every pooled connection.", + "required": false, + "variables": [ + { + "name": "DATABASE_WORK_MEM", + "purpose": "Postgres work_mem for every app session, as a Postgres size string. Default 16MB. Raise it when large sorts and aggregates spill to disk; each sort step of each connection may use this much." + }, + { + "name": "DATABASE_SESSION_OPTIONS_DISABLED", + "purpose": "Set to 1, true or yes to send no session settings (statement timeout, work_mem) as the libpq options startup parameter. Needed behind PgBouncer, which refuses that parameter unless it is in ignore_startup_parameters." + } + ] + }, { "name": "APNs (iOS push)", "description": "All four core APNS_* vars together. Missing any disables APNs silently \u2014 the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic required row whenever 1-3 of the 4 are set.", diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 6e28c1d91..acbab8c25 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -662,6 +662,14 @@ async function main() { prisma.symptomEvent, ), ); + // v1.42 — a life event's title and note. The note is NULL on most rows, + // which the walk skips. + results.push( + await rotateBytesColumn("LifeEvent", "titleEncrypted", prisma.lifeEvent), + ); + results.push( + await rotateBytesColumn("LifeEvent", "noteEncrypted", prisma.lifeEvent), + ); // ───── v1.19.0 ECG waveform (Bytes column) ───── // "waveformEncrypted" holds the JSON-encoded micro-volt sample array in the @@ -815,6 +823,30 @@ async function main() { } as unknown as CorpusClient), ); + // ───── v1.42 environment locations (Bytes, binary2, batched) ───── + // The sealed home, each dated location period and each day's resolved + // location, all under the environment-location label. NULL on a row the + // encryption backfill has not reached yet, which the walk skips. + results.push( + await rotateRegistryColumn("User", "homeLocationEncrypted", { + user: prisma.user, + } as unknown as CorpusClient), + ); + results.push( + await rotateRegistryColumn( + "EnvironmentTravelLocation", + "locationEncrypted", + { + environmentTravelLocation: prisma.environmentTravelLocation, + } as unknown as CorpusClient, + ), + ); + results.push( + await rotateRegistryColumn("EnvironmentContext", "locationEncrypted", { + environmentContext: prisma.environmentContext, + } as unknown as CorpusClient), + ); + // ───── v1.39.3 custom-metric reading note (Bytes column) ───── // NULL on a reading without a note, and on a row the free-text backfill has // not reached yet; `rotateBytesColumn` skips both. diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index 1097ed149..7c606cb91 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -44,6 +44,14 @@ * today back over a few weeks * - App settings (registration disabled, English locale, the key-backup * step confirmed for the key the demo runs with) + * - A multi-year history before the 90-day window (about four and a half + * years) for the timeline and the day view: blood pressure in bursts with + * real gaps, weekly weight, phone steps, watch resting heart rate / HRV / + * sleep from the third year, yearly blood panels, a resolved chronic + * condition and infections, ramipril with a dose change and a sick-day + * pause, flu shots, visits, life events at day / month / year precision, + * a sealed home location with a holiday period, and stored weather, air + * quality and pollen for every day. The timeline module is switched on. * * Every date is relative to "now" so the demo stays fresh on every re-seed. * What ages is the SEEDED DATA, not the script: the pillar windows keep @@ -75,6 +83,7 @@ import { getActiveKeyId, getKeyFingerprint, } from "../src/lib/crypto"; +import { ENVIRONMENT_LOCATION_AAD } from "../src/lib/crypto/encrypted-columns"; import { DEFAULT_DASHBOARD_LAYOUT, serializeDashboardLayout, @@ -268,6 +277,137 @@ function settleLatest(values: number[], lookback = 28): void { values[values.length - 1] = Math.round(median * 10) / 10; } +// ── Multi-year history ─────────────────────── +// The timeline and the day view read years, not weeks. Everything before the +// 90-day window is written by `seedHistory` below; these anchors place its +// story (all in days before "now") so the sections that share a date agree. + +/** How far back the record reaches: about four and a half years. */ +const HISTORY_DAYS = 1650; +/** Ramipril 2.5 mg starts after a blood-pressure work-up. */ +const RAMIPRIL_START_DAYS_AGO = 1130; +/** Ramipril raised to 5 mg when the readings stay above target. */ +const RAMIPRIL_DOSE_UP_DAYS_AGO = 760; +/** A sick-day pause of ramipril during a stomach bug. */ +const RAMIPRIL_PAUSE = { from: 425, to: 416 }; +/** Vitamin D3 from the winter before last. */ +const VITAMIN_D_START_DAYS_AGO = 400; +/** A daughter is born; sleep and the blood-pressure habit both suffer. */ +const DAUGHTER_BORN_DAYS_AGO = 650; +/** A watch arrives at the start of the record's third year. */ +const WATCH_FROM_DAYS_AGO = 912; +/** One summer holiday away from home. */ +const HOLIDAY = { from: 470, to: 457 }; + +/** The demo's home: a neutral German city, coarse (one decimal). */ +const DEMO_HOME = { lat: 48.0, lon: 7.8, label: "Freiburg" }; +/** The holiday location, coarse; its label is never shown on a surface. */ +const DEMO_HOLIDAY_PLACE = { lat: 54.1, lon: 12.1, label: "Holiday" }; + +/** + * Piecewise-linear target through `[daysAgo, value]` knots, oldest first. + * Outside the knots the nearest end holds. + */ +function knotValue(knots: Array<[number, number]>, n: number): number { + if (n >= knots[0][0]) return knots[0][1]; + for (let i = 1; i < knots.length; i++) { + const [d0, v0] = knots[i - 1]; + const [d1, v1] = knots[i]; + if (n <= d0 && n >= d1) { + const f = d0 === d1 ? 0 : (d0 - n) / (d0 - d1); + return v0 + (v1 - v0) * f; + } + } + return knots[knots.length - 1][1]; +} + +/** Uniform noise in `[-amp, amp]`. */ +function jitter(amp: number): number { + return (Math.random() * 2 - 1) * amp; +} + +/** The calendar quarter of a Berlin day key, e.g. `2025-Q3`. */ +function quarterOf(dayKey: string): string { + const month = Number(dayKey.slice(5, 7)); + return `${dayKey.slice(0, 4)}-Q${Math.floor((month - 1) / 3) + 1}`; +} + +/** Day of the year (1–366) of a `YYYY-MM-DD` key. */ +function dayOfYear(dayKey: string): number { + const d = Date.UTC( + Number(dayKey.slice(0, 4)), + Number(dayKey.slice(5, 7)) - 1, + Number(dayKey.slice(8, 10)), + ); + return ( + Math.floor((d - Date.UTC(Number(dayKey.slice(0, 4)), 0, 1)) / 864e5) + 1 + ); +} + +/** Seal a coarse location the way `sealLocation` does in the app. */ +function sealDemoLocation(loc: { + lat: number; + lon: number; + label: string; +}): Buffer { + return encryptBytes( + Buffer.from( + JSON.stringify({ lat: loc.lat, lon: loc.lon, label: loc.label }), + "utf8", + ), + ENVIRONMENT_LOCATION_AAD, + ); +} + +/** + * Multi-row INSERT in chunks, so a few thousand history rows do not cost a + * round trip each. `rows` are positional, in `columns` order. + */ +async function bulkInsert( + client: pg.PoolClient, + table: string, + columns: string[], + rows: unknown[][], + chunk = 500, +): Promise { + for (let start = 0; start < rows.length; start += chunk) { + const slice = rows.slice(start, start + chunk); + const params: unknown[] = []; + const tuples = slice.map((row) => { + const marks = row.map((value) => { + params.push(value); + return `$${params.length}`; + }); + return `(${marks.join(", ")})`; + }); + await client.query( + `INSERT INTO ${table} (${columns.join(", ")}) VALUES ${tuples.join(", ")}`, + params, + ); + } +} + +/** Pollen season bump: 0 outside `[from, to]`, peaking at `peak` mid-season. */ +function pollenSeason( + doy: number, + from: number, + to: number, + peak: number, +): number { + if (doy < from || doy > to) return 0; + const phase = (doy - from) / (to - from); + return peak * Math.sin(Math.PI * phase) ** 2; +} + +/** Hours of daylight at latitude `lat` on day-of-year `doy`. */ +function daylightHours(lat: number, doy: number): number { + const rad = Math.PI / 180; + const decl = 23.44 * Math.sin(rad * (360 / 365) * (284 + doy)); + const x = -Math.tan(lat * rad) * Math.tan(decl * rad); + const ha = Math.acos(Math.min(1, Math.max(-1, x))) / rad; + return (2 * ha) / 15; +} + // ── Main ───────────────────────────────────────── async function seed() { @@ -905,6 +1045,112 @@ async function seed() { } } + // ── Readiness and health-score history ──── + // The day view reads readiness from the COMPUTED `RECOVERY_SCORE` row the + // nightly job files for the night that ended (noon UTC of the day before + // the wake morning, `recovery:`), and the health score from the + // `health_score_records` row of each local day. Neither job reaches back, + // so a fresh seed carried both for today at most; mint the history the + // jobs would have written over the window. + console.log("Creating readiness and health-score history..."); + const shiftKey = (key: string, by: number) => + new Date(Date.parse(`${key}T12:00:00Z`) + by * 86_400_000) + .toISOString() + .slice(0, 10); + const readinessRows: unknown[][] = []; + for (let i = 1; i < span; i++) { + const wakeKey = berlinDayKey(daysAgoAt(days - i, 7, 0)); + const endedKey = shiftKey(wakeKey, -1); + const at = new Date(`${endedKey}T12:00:00.000Z`); + const value = Math.round( + Math.min(100, Math.max(0, recovery[i] * 0.7 + 20 + jitter(4))), + ); + readinessRows.push([ + cuid(), + userId, + "RECOVERY_SCORE", + value, + "score", + "COMPUTED", + `recovery:${endedKey}`, + at, + at, + at, + ]); + } + await bulkInsert( + client, + "measurements", + [ + "id", + "user_id", + "type", + "value", + "unit", + "source", + "external_id", + "measured_at", + "created_at", + "updated_at", + ], + readinessRows, + ); + const PILLAR_BASE: Record = { + BLOOD_PRESSURE: 86, + GLYCAEMIA: 98, + ACTIVITY: 95, + SLEEP: 76, + ADIPOSITY: 97, + WELLBEING: 76, + LIPIDS: 100, + }; + const composites = randomWalk(87, 90, 60, 3, { min: 80, max: 96 }); + const scoreRows: unknown[][] = []; + for (let n = 60; n >= 1; n--) { + const pillars = Object.fromEntries( + Object.entries(PILLAR_BASE).map(([k, v]) => [ + k, + Math.round(Math.min(100, Math.max(40, v + jitter(5)))), + ]), + ); + const at = daysAgoAt(n, 4, 50); + scoreRows.push([ + cuid(), + userId, + berlinDayKey(at), + "Europe/Berlin", + Math.round(composites[60 - n]), + "green", + 4, + Object.keys(PILLAR_BASE), + JSON.stringify(pillars), + createHash("sha256").update(`demo-health-score:${n}`).digest("hex"), + 0, + at, + at, + ]); + } + await bulkInsert( + client, + "health_score_records", + [ + "id", + "user_id", + "day_key", + "timezone", + "composite", + "band", + "score_version", + "composition", + "pillar_scores", + "input_fingerprint", + "config_version", + "computed_at", + "created_at", + ], + scoreRows, + ); + // ── Workouts ────────────────────────────── // ~3–4 sessions/week across the window: a mix of running, strength, and // cycling, with the fields the workout tiles render — duration, energy, @@ -1010,12 +1256,21 @@ async function seed() { // ── Medications ─────────────────────────── console.log("Creating medications..."); - // Medication 1: Ramipril (blood pressure) + // Medication 1: Ramipril (blood pressure). Started about three years + // back at 2.5 mg and raised to 5 mg a year later; the multi-year history + // section below writes the dose changes, a short sick-day pause and the + // intakes before the 90-day window. `starts_on` is the stated start the + // timeline lane draws from. const med1Id = cuid(); await client.query( - `INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at) - VALUES ($1, $2, 'Ramipril', '5mg', true, true, $3, $3)`, - [med1Id, userId, daysAgo(120)], + `INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, starts_on, created_at, updated_at) + VALUES ($1, $2, 'Ramipril', '5mg', true, true, $3, $4, $4)`, + [ + med1Id, + userId, + formatDate(daysAgo(RAMIPRIL_START_DAYS_AGO)), + daysAgo(RAMIPRIL_START_DAYS_AGO), + ], ); const sched1Id = cuid(); await client.query( @@ -1027,9 +1282,14 @@ async function seed() { // Medication 2: Vitamin D3 const med2Id = cuid(); await client.query( - `INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at) - VALUES ($1, $2, 'Vitamin D3', '2000 IU', true, true, $3, $3)`, - [med2Id, userId, daysAgo(90)], + `INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, starts_on, created_at, updated_at) + VALUES ($1, $2, 'Vitamin D3', '2000 IU', true, true, $3, $4, $4)`, + [ + med2Id, + userId, + formatDate(daysAgo(VITAMIN_D_START_DAYS_AGO)), + daysAgo(VITAMIN_D_START_DAYS_AGO), + ], ); const sched2Id = cuid(); await client.query( @@ -1041,9 +1301,9 @@ async function seed() { // Medication 3: Magnesium (evening) const med3Id = cuid(); await client.query( - `INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, created_at, updated_at) - VALUES ($1, $2, 'Magnesium', '400mg', true, true, $3, $3)`, - [med3Id, userId, daysAgo(60)], + `INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, starts_on, created_at, updated_at) + VALUES ($1, $2, 'Magnesium', '400mg', true, true, $3, $4, $4)`, + [med3Id, userId, formatDate(daysAgo(60)), daysAgo(60)], ); const sched3Id = cuid(); await client.query( @@ -1829,7 +2089,7 @@ async function seed() { const insertEncounter = async (params: { occurredAt: Date; status: "DONE" | "PLANNED"; - kind: "ROUTINE" | "PROCEDURE"; + kind: "ROUTINE" | "PROCEDURE" | "PREVENTIVE" | "SPECIALIST" | "ACUTE"; practitionerId: string; reason: string; outcome?: string; @@ -1943,6 +2203,785 @@ async function seed() { } // ── Achievements ───────────────────────── + // ── Multi-year history ──────────────────── + // The 90-day window above is what the dashboard reads; the timeline and + // the day view read years. This section writes the record before that + // window, about four and a half years back, with the density a real one + // has: blood pressure in bursts with a quarter of silence after the + // daughter's birth, weight weekly, a phone's step count all along, and a + // watch's resting heart rate, HRV and sleep from the third year on. Around + // it: the blood-pressure work-up that started ramipril, its dose change + // and sick-day pause, illnesses with start and end, flu shots, check-ups + // with their blood panels, life events at day, month and year precision, + // the home location with a summer holiday, and the stored weather, air + // quality and pollen for every day of the record. Every value is invented. + console.log("Creating multi-year history..."); + const today = berlinDayKey(); + const dayKeyAgo = (n: number) => berlinDayKey(daysAgoAt(n, 12)); + + // Measurements before the 90-day window. + const historyRows: unknown[][] = []; + const addMeasurement = ( + type: string, + value: number, + unit: string, + source: string, + at: Date, + ) => { + historyRows.push([cuid(), userId, type, value, unit, source, at, at, at]); + }; + // Blood pressure: a quarter without a reading after the birth (the line + // breaks into a dashed bridge there) and one early quarter with only two + // readings (drawn hollow). + const silentBpQuarter = quarterOf(dayKeyAgo(DAUGHTER_BORN_DAYS_AGO - 45)); + const thinBpQuarter = quarterOf(dayKeyAgo(1380)); + const thinWeightQuarter = silentBpQuarter; + const quarterCounts = new Map(); + const sysKnots: Array<[number, number]> = [ + [HISTORY_DAYS, 134], + [1160, 143], + [RAMIPRIL_START_DAYS_AGO, 142], + [1000, 136], + [RAMIPRIL_DOSE_UP_DAYS_AGO, 135], + [600, 130], + [91, 128], + ]; + const diaKnots: Array<[number, number]> = [ + [HISTORY_DAYS, 86], + [1160, 92], + [RAMIPRIL_START_DAYS_AGO, 91], + [1000, 87], + [RAMIPRIL_DOSE_UP_DAYS_AGO, 86], + [600, 83], + [91, 82], + ]; + const weightKnots: Array<[number, number]> = [ + [HISTORY_DAYS, 83.4], + [1180, 85.2], + [DAUGHTER_BORN_DAYS_AGO, 88.6], + [420, 87.9], + [91, 86.5], + ]; + const restingKnots: Array<[number, number]> = [ + [WATCH_FROM_DAYS_AGO, 63], + [DAUGHTER_BORN_DAYS_AGO, 65], + [500, 63], + [91, 64], + ]; + const hrvKnots: Array<[number, number]> = [ + [WATCH_FROM_DAYS_AGO, 46], + [DAUGHTER_BORN_DAYS_AGO, 39], + [480, 45], + [91, 48], + ]; + const sleepKnots: Array<[number, number]> = [ + [WATCH_FROM_DAYS_AGO, 445], + [DAUGHTER_BORN_DAYS_AGO + 5, 440], + [DAUGHTER_BORN_DAYS_AGO - 10, 365], + [480, 410], + [91, 420], + ]; + const stepKnots: Array<[number, number]> = [ + [HISTORY_DAYS, 6300], + [DAUGHTER_BORN_DAYS_AGO, 5600], + [560, 7400], + [91, 6500], + ]; + // The watch is off the wrist for a fortnight one summer (a broken strap). + const watchOff = { from: 300, to: 287 }; + + for (let n = HISTORY_DAYS; n > days; n--) { + const at = daysAgo(n); + const key = dayKeyAgo(n); + const quarter = quarterOf(key); + const paused = n <= RAMIPRIL_PAUSE.from && n >= RAMIPRIL_PAUSE.to; + + // Blood pressure, in bursts: daily during the work-up, a few times a + // week once treatment settled, now and then before it all started. + const bpChance = + n > 1165 + ? 0.12 + : n > 1090 + ? 0.9 + : n > RAMIPRIL_DOSE_UP_DAYS_AGO - 30 && + n < RAMIPRIL_DOSE_UP_DAYS_AGO + 20 + ? 0.7 + : paused || (n < RAMIPRIL_PAUSE.to && n > RAMIPRIL_PAUSE.to - 14) + ? 0.9 + : 0.3; + const bpTaken = quarterCounts.get(`bp:${quarter}`) ?? 0; + const bpAllowed = + quarter === silentBpQuarter + ? false + : quarter === thinBpQuarter + ? bpTaken < 2 + : true; + if (bpAllowed && Math.random() < bpChance) { + quarterCounts.set(`bp:${quarter}`, bpTaken + 1); + const bump = paused ? 7 : 0; + const sys = Math.round(knotValue(sysKnots, n) + bump + jitter(6)); + const dia = Math.round(knotValue(diaKnots, n) + bump / 2 + jitter(4)); + addMeasurement("BLOOD_PRESSURE_SYS", sys, "mmHg", "MANUAL", at); + addMeasurement("BLOOD_PRESSURE_DIA", dia, "mmHg", "MANUAL", at); + addMeasurement( + "PULSE", + Math.round(72 + jitter(6)), + "bpm", + "MANUAL", + at, + ); + } + + // Weight, once a week on the bathroom scale (thin after the birth). + if (n % 7 === 3) { + const wTaken = quarterCounts.get(`w:${quarter}`) ?? 0; + if (quarter !== thinWeightQuarter || wTaken < 2) { + quarterCounts.set(`w:${quarter}`, wTaken + 1); + addMeasurement( + "WEIGHT", + Math.round((knotValue(weightKnots, n) + jitter(0.6)) * 10) / 10, + "kg", + "WITHINGS", + at, + ); + } + } + + // Steps from the phone, every day of the record. + addMeasurement( + "ACTIVITY_STEPS", + Math.max(1200, Math.round(knotValue(stepKnots, n) + jitter(2200))), + "steps", + "APPLE_HEALTH", + at, + ); + + // The watch: resting heart rate, HRV and sleep, daily from year three. + const wearing = + n <= WATCH_FROM_DAYS_AGO && + !(n <= watchOff.from && n >= watchOff.to) && + Math.random() > 0.04; + if (wearing) { + addMeasurement( + "RESTING_HEART_RATE", + Math.round(knotValue(restingKnots, n) + jitter(3)), + "bpm", + "APPLE_HEALTH", + at, + ); + addMeasurement( + "HEART_RATE_VARIABILITY", + Math.round(knotValue(hrvKnots, n) + jitter(7)), + "ms", + "APPLE_HEALTH", + at, + ); + addMeasurement( + "SLEEP_DURATION", + Math.round(knotValue(sleepKnots, n) + jitter(40)), + "minutes", + "APPLE_HEALTH", + at, + ); + } + } + await bulkInsert( + client, + "measurements", + [ + "id", + "user_id", + "type", + "value", + "unit", + "source", + "measured_at", + "created_at", + "updated_at", + ], + historyRows, + ); + + // Ramipril and Vitamin D3 intakes before the 90-day window, at the same + // slot anchoring as the window itself, and none while ramipril is paused. + const intakeRows: unknown[][] = []; + const addIntake = (medicationId: string, n: number, compliance: number) => { + const slot = berlinHmAsUtc(8, 0, daysAgo(n)); + if (Math.random() < compliance) { + const taken = new Date( + slot.getTime() + (5 + Math.floor(Math.random() * 50)) * 60_000, + ); + intakeRows.push([ + cuid(), + userId, + medicationId, + slot, + taken, + false, + "WEB", + taken, + taken, + ]); + } else { + intakeRows.push([ + cuid(), + userId, + medicationId, + slot, + null, + true, + "WEB", + slot, + slot, + ]); + } + }; + for (let n = RAMIPRIL_START_DAYS_AGO; n > days; n--) { + if (n <= RAMIPRIL_PAUSE.from && n >= RAMIPRIL_PAUSE.to) continue; + addIntake(med1Id, n, 0.97); + if (n <= VITAMIN_D_START_DAYS_AGO) addIntake(med2Id, n, 0.94); + } + await bulkInsert( + client, + "medication_intake_events", + [ + "id", + "user_id", + "medication_id", + "scheduled_for", + "taken_at", + "skipped", + "source", + "created_at", + "updated_at", + ], + intakeRows, + ); + + // The ramipril dose history and its sick-day pause. + for (const [n, value] of [ + [RAMIPRIL_START_DAYS_AGO, 2.5], + [RAMIPRIL_DOSE_UP_DAYS_AGO, 5], + ] as const) { + await client.query( + `INSERT INTO medication_dose_changes (id, medication_id, effective_from, dose_value, dose_unit, created_at) + VALUES ($1, $2, $3, $4, 'mg', $3)`, + [cuid(), med1Id, daysAgoAt(n, 8), value], + ); + } + await client.query( + `INSERT INTO medication_pause_eras (id, medication_id, user_id, paused_at, resumed_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6)`, + [ + cuid(), + med1Id, + userId, + daysAgoAt(RAMIPRIL_PAUSE.from, 9), + daysAgoAt(RAMIPRIL_PAUSE.to - 1, 8), + daysAgoAt(RAMIPRIL_PAUSE.from, 9), + ], + ); + // A finished antibiotic course for the bronchitis. + await client.query( + `INSERT INTO medications (id, user_id, name, dose, active, notifications_enabled, starts_on, ends_on, created_at, updated_at) + VALUES ($1, $2, 'Amoxicillin', '1000mg', false, false, $3, $4, $5, $5)`, + [ + cuid(), + userId, + formatDate(daysAgo(797)), + formatDate(daysAgo(791)), + daysAgo(797), + ], + ); + + // Illnesses before the window, each with a start and an end. The back + // pain is the chronic one; it settled after a course of physiotherapy, + // so nothing here is left open (an open episode puts the account in + // Rest Mode). + for (const ep of [ + { + label: "Chronic lower back pain", + type: "CHRONIC", + lifecycle: "CHRONIC_ONGOING", + from: 1560, + to: 1240, + note: "Desk job and too little movement. Physiotherapy twice a week.", + }, + { + label: "Influenza", + type: "INFECTION", + lifecycle: "ACUTE", + from: 1370, + to: 1361, + note: "High fever for three days, a week in bed.", + }, + { + label: "Acute bronchitis", + type: "INFECTION", + lifecycle: "ACUTE", + from: 800, + to: 788, + note: "Cough that would not settle. Antibiotics from the practice.", + }, + { + label: "Gastroenteritis", + type: "INFECTION", + lifecycle: "ACUTE", + from: 427, + to: 422, + note: "Stomach bug from daycare. Ramipril paused until eating again.", + }, + ]) { + await client.query( + `INSERT INTO illness_episodes (id, user_id, label, type, lifecycle, onset_at, resolved_at, note_encrypted, created_at, updated_at) + VALUES ($1, $2, $3, $4::illness_type, $5::illness_lifecycle, $6, $7, $8, $6, $7)`, + [ + cuid(), + userId, + ep.label, + ep.type, + ep.lifecycle, + daysAgo(ep.from), + daysAgo(ep.to), + encBytes(ep.note), + ], + ); + } + + // A cardiology practice for the work-up, then the visits over the years. + const cardiologyId = cuid(); + await client.query( + `INSERT INTO practitioners (id, user_id, name, specialty, created_at, updated_at) + VALUES ($1, $2, 'Cardiology practice', 'Cardiology', $3, $3)`, + [cardiologyId, userId, daysAgo(1140)], + ); + const historyVisits: Array<{ + n: number; + kind: "ROUTINE" | "PREVENTIVE" | "SPECIALIST" | "ACUTE"; + practitionerId: string; + reason: string; + outcome: string; + }> = [ + { + n: 1560, + kind: "PREVENTIVE", + practitionerId: familyPracticeId, + reason: "Health check-up with a blood panel.", + outcome: "All fine. Back pain: referral to physiotherapy.", + }, + { + n: 1190, + kind: "PREVENTIVE", + practitionerId: familyPracticeId, + reason: "Health check-up with a blood panel.", + outcome: + "Blood pressure high at the practice. Home readings for two weeks.", + }, + { + n: 1140, + kind: "SPECIALIST", + practitionerId: cardiologyId, + reason: "Blood pressure work-up with a 24-hour measurement.", + outcome: "Mild hypertension, heart otherwise healthy. Ramipril 2.5 mg.", + }, + { + n: 830, + kind: "PREVENTIVE", + practitionerId: familyPracticeId, + reason: "Health check-up with a blood panel.", + outcome: "Lipids slightly up. More exercise, recheck next year.", + }, + { + n: 797, + kind: "ACUTE", + practitionerId: familyPracticeId, + reason: "Cough and fever for five days.", + outcome: "Acute bronchitis. Amoxicillin for seven days.", + }, + { + n: RAMIPRIL_DOSE_UP_DAYS_AGO, + kind: "ROUTINE", + practitionerId: familyPracticeId, + reason: "Blood pressure follow-up.", + outcome: "Still above target, ramipril raised to 5 mg.", + }, + { + n: 470, + kind: "PREVENTIVE", + practitionerId: familyPracticeId, + reason: "Health check-up with a blood panel.", + outcome: "Blood pressure on target. Lipids better.", + }, + { + n: 426, + kind: "ACUTE", + practitionerId: familyPracticeId, + reason: "Vomiting and diarrhoea since yesterday.", + outcome: + "Gastroenteritis. Pause ramipril until eating and drinking normally.", + }, + ]; + for (const v of historyVisits) { + await insertEncounter({ + occurredAt: daysAgoAt(v.n, 9, 15), + status: "DONE", + kind: v.kind, + practitionerId: v.practitionerId, + reason: v.reason, + outcome: v.outcome, + }); + } + + // The blood panels of those check-ups: HbA1c, lipids and haemoglobin. + const historyPanels: Array<{ n: number; values: Record }> = + [ + { + n: 1560, + values: { hba1c: 5.3, tc: 205, ldl: 128, hdl: 49, hb: 15.1 }, + }, + { + n: 1190, + values: { hba1c: 5.4, tc: 214, ldl: 137, hdl: 47, hb: 14.8 }, + }, + { n: 1140, values: { tc: 216, ldl: 140, hdl: 47 } }, + { + n: 830, + values: { hba1c: 5.6, tc: 209, ldl: 132, hdl: 48, hb: 15.0 }, + }, + { + n: 470, + values: { hba1c: 5.5, tc: 199, ldl: 121, hdl: 51, hb: 14.6 }, + }, + ]; + const panelLabs: Record> = { + hba1c: { analyte: "HbA1c", unit: "%", low: 4.0, high: 5.6 }, + tc: { analyte: "Total Cholesterol", unit: "mg/dL", low: 0, high: 200 }, + ldl: { analyte: "LDL", unit: "mg/dL", low: 0, high: 130 }, + hdl: { analyte: "HDL", unit: "mg/dL", low: 40, high: 100 }, + hb: { analyte: "Hemoglobin", unit: "g/dL", low: 13.5, high: 17.5 }, + }; + for (const panel of historyPanels) { + for (const [key, value] of Object.entries(panel.values)) { + await insertQuant( + "Blood panel", + { ...panelLabs[key], value }, + daysAgoAt(panel.n, 9, 0), + ); + } + } + + // Vaccinations: a flu shot every autumn and a tetanus-diphtheria- + // pertussis booster. The newest flu shot is recent, so nothing is due. + for (const v of [ + { n: 1440, slug: "influenza", name: "Influenza" }, + { n: 1075, slug: "influenza", name: "Influenza" }, + { n: 985, slug: "tdap", name: "Tdap" }, + { n: 710, slug: "influenza", name: "Influenza" }, + { n: 345, slug: "influenza", name: "Influenza" }, + { n: 12, slug: "influenza", name: "Influenza" }, + ]) { + const at = daysAgoAt(v.n, 10, 30); + await client.query( + `INSERT INTO vaccination_records (id, user_id, occurred_at, antigen_slug, vaccine_name, site, practitioner_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, 'LEFT_ARM'::vaccination_site, $6, $3, $3)`, + [cuid(), userId, at, v.slug, v.name, familyPracticeId], + ); + } + + // Life events, at the precision a person actually remembers them. + const monthStart = (n: number) => `${dayKeyAgo(n).slice(0, 7)}-01`; + const lastYear = Number(today.slice(0, 4)) - 1; + const lifeEvents: Array<{ + category: "FAMILY" | "HOME" | "WORK" | "OTHER"; + start: string; + end: string | null; + precision: "DAY" | "MONTH" | "YEAR"; + title: string; + note?: string; + }> = [ + { + category: "HOME", + start: monthStart(HISTORY_DAYS), + end: null, + precision: "MONTH", + title: "Moved to Freiburg", + }, + { + category: "WORK", + start: dayKeyAgo(1185), + end: null, + precision: "DAY", + title: "Started a new job", + note: "Longer commute, more sitting.", + }, + { + category: "FAMILY", + start: dayKeyAgo(DAUGHTER_BORN_DAYS_AGO), + end: null, + precision: "DAY", + title: "Our daughter was born", + }, + { + category: "FAMILY", + start: monthStart(DAUGHTER_BORN_DAYS_AGO - 20), + end: monthStart(DAUGHTER_BORN_DAYS_AGO - 80), + precision: "MONTH", + title: "Parental leave", + }, + { + category: "OTHER", + start: `${lastYear}-01-01`, + end: null, + precision: "YEAR", + title: "Started running regularly", + }, + { + category: "HOME", + start: dayKeyAgo(380), + end: null, + precision: "DAY", + title: "Moved into a bigger flat", + }, + { + category: "FAMILY", + start: dayKeyAgo(46), + end: null, + precision: "DAY", + title: "First day at daycare", + note: "Settling-in week, short days.", + }, + ]; + for (const e of lifeEvents) { + await client.query( + `INSERT INTO life_events (id, user_id, category, start_date, end_date, precision, title_encrypted, note_encrypted, created_at, updated_at) + VALUES ($1, $2, $3::life_event_category, $4, $5, $6::life_event_precision, $7, $8, NOW(), NOW())`, + [ + cuid(), + userId, + e.category, + e.start, + e.end, + e.precision, + encBytes(e.title), + e.note ? encBytes(e.note) : null, + ], + ); + } + + // The timeline module is opt-in; the demo shows it. The home location + // is sealed the way the settings route writes it (readable columns null). + const homeSince = daysAgoAt(HISTORY_DAYS, 12); + await client.query( + `UPDATE users + SET module_preferences_json = $2, + home_lat = NULL, home_lon = NULL, home_label = NULL, + home_location_encrypted = $3, + home_timezone = 'Europe/Berlin', + home_since = $4, + environment_air_quality_enabled = true + WHERE id = $1`, + [ + userId, + JSON.stringify({ timeline: true }), + sealDemoLocation(DEMO_HOME), + homeSince, + ], + ); + await client.query( + `INSERT INTO environment_travel_locations (id, user_id, start_date, end_date, location_encrypted, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW(), NOW())`, + [ + cuid(), + userId, + dayKeyAgo(HOLIDAY.from), + dayKeyAgo(HOLIDAY.to), + sealDemoLocation(DEMO_HOLIDAY_PLACE), + ], + ); + + // Stored weather, air quality and pollen for every day of the record, + // written as the nightly fetch would leave them (no feed is called). + // Seasonal shapes for a city in the south-west: mild winters, warm + // summers, alder and birch in spring, grass into July, mugwort and + // ragweed from late summer well into October. + const sealedHome = sealDemoLocation(DEMO_HOME); + const sealedHoliday = sealDemoLocation(DEMO_HOLIDAY_PLACE); + const envRows: unknown[][] = []; + let cloud = 55; + const fetchedAt = new Date(); + for (let n = HISTORY_DAYS; n >= 0; n--) { + const key = dayKeyAgo(n); + const away = n <= HOLIDAY.from && n >= HOLIDAY.to; + const doy = dayOfYear(key); + const season = Math.sin((2 * Math.PI * (doy - 110)) / 365); + cloud = Math.min( + 100, + Math.max(0, cloud + (60 - cloud) * 0.3 + jitter(35)), + ); + const rainy = cloud > 72 && Math.random() < 0.7; + const tempMean = + Math.round( + ((away ? 9.5 : 11) + + (away ? 8 : 9.5) * season + + jitter(3) - + (rainy ? 1.5 : 0)) * + 10, + ) / 10; + const tempMin = Math.round((tempMean - 4 - Math.random() * 2) * 10) / 10; + const tempMax = + Math.round( + (tempMean + 4.5 + Math.random() * 2.5 - (rainy ? 2 : 0)) * 10, + ) / 10; + const daylight = daylightHours( + away ? DEMO_HOLIDAY_PLACE.lat : DEMO_HOME.lat, + doy, + ); + const sunshine = Math.max(0, daylight * (1 - cloud / 100) * 0.85); + const precip = rainy ? Math.round((1 + Math.random() * 14) * 10) / 10 : 0; + const weatherCode = rainy + ? tempMean < 1 + ? 71 + : precip > 8 + ? 63 + : 61 + : cloud > 80 + ? 3 + : cloud > 35 + ? 2 + : cloud > 15 + ? 1 + : 0; + // Air: more particles in winter (heating, inversions), more ozone in + // summer, a little cleaner on rainy days and by the sea. + const winter = (1 - season) / 2; + const clean = (rainy ? 0.7 : 1) * (away ? 0.6 : 1); + const pm25Mean = + Math.round((5 + 9 * winter + jitter(2.5)) * clean * 10) / 10; + const pm25Max = + Math.round(pm25Mean * (1.5 + Math.random() * 0.6) * 10) / 10; + const pm10Mean = Math.round((pm25Mean * 1.6 + jitter(2)) * 10) / 10; + const no2Mean = + Math.round((12 + 10 * winter + jitter(4)) * clean * 10) / 10; + const o3Max8h = Math.round( + (55 + 45 * (1 - winter) + jitter(12)) * (rainy ? 0.8 : 1), + ); + const eaqiMax = Math.round( + Math.max(pm25Max * 1.7, pm10Mean * 0.9, o3Max8h * 0.42, no2Mean * 0.8), + ); + const usaqiMax = Math.round(Math.max(pm25Max * 3.1, o3Max8h * 0.45)); + const uvIndexMax = + Math.round( + Math.max( + 0.3, + (1 + 6.5 * (1 - winter)) * (1 - cloud / 160) + jitter(0.4), + ) * 10, + ) / 10; + // Pollen, damped on rainy days, none by the sea in late summer. + const damp = (rainy ? 0.35 : 1) * (0.7 + Math.random() * 0.6); + const pollen = (from: number, to: number, peak: number) => + Math.round(pollenSeason(doy, from, to, peak) * damp); + envRows.push([ + cuid(), + userId, + key, + null, + null, + null, + away ? sealedHoliday : sealedHome, + away ? "TRAVEL" : "HOME", + tempMin, + tempMax, + tempMean, + Math.round((tempMean - (rainy ? 2 : 0.5)) * 10) / 10, + Math.round(sunshine * 3600), + Math.round(daylight * 3600), + precip, + Math.round((1016 - (rainy ? 9 : 0) + jitter(5)) * 10) / 10, + Math.round((2 + Math.random() * (rainy ? 8 : 4)) * 10) / 10, + Math.round(60 + cloud * 0.25 + jitter(8)), + Math.round(cloud), + weatherCode, + Math.round((tempMax + (rainy ? -1 : 1.5)) * 10) / 10, + pm25Mean, + pm25Max, + pm10Mean, + no2Mean, + Math.round(1.2 + Math.random() * 1.5), + Math.round(180 + 120 * winter + jitter(30)), + o3Max8h, + eaqiMax, + usaqiMax, + uvIndexMax, + Math.round(Math.random() * (doy > 90 && doy < 250 ? 18 : 4)), + Math.round((0.08 + Math.random() * 0.2) * 100) / 100, + pollen(35, 85, 70), + pollen(92, 135, away ? 20 : 190), + pollen(128, 215, 85), + away ? 0 : pollen(195, 255, 28), + 0, + away ? 0 : pollen(220, 305, 45), + "cams_europe", + 24, + fetchedAt, + fetchedAt, + fetchedAt, + fetchedAt, + ]); + } + await bulkInsert( + client, + "environment_contexts", + [ + "id", + "user_id", + "date", + "lat", + "lon", + "location_label", + "location_encrypted", + "source", + "temp_min", + "temp_max", + "temp_mean", + "apparent_mean", + "sunshine_sec", + "daylight_sec", + "precip_sum", + "pressure_mean", + "pressure_delta", + "humidity_mean", + "cloud_mean", + "weather_code", + "apparent_max", + "pm25_mean", + "pm25_max", + "pm10_mean", + "no2_mean", + "so2_mean", + "co_mean", + "o3_max_8h", + "eaqi_max", + "usaqi_max", + "uv_index_max", + "dust_max", + "aod_max", + "pollen_alder_max", + "pollen_birch_max", + "pollen_grass_max", + "pollen_mugwort_max", + "pollen_olive_max", + "pollen_ragweed_max", + "aq_domain", + "aq_hours", + "aq_fetched_at", + "fetched_at", + "created_at", + "updated_at", + ], + envRows, + 200, + ); + console.log("Creating achievements..."); // Every id below is a real key from the achievement registry @@ -2743,6 +3782,9 @@ async function seed() { ` Baked AI texts: comprehensive + daily briefing, ${statusCards.length} status cards, ${periodNarratives.length} period narratives (en)`, ); console.log(` Achievements: ${achievements.length}`); + console.log( + ` History: ${historyRows.length} readings over ${HISTORY_DAYS} days, ${lifeEvents.length} life events, ${envRows.length} environment days`, + ); } catch (err) { await client.query("ROLLBACK"); console.error("Seed failed:", err); diff --git a/src/__tests__/account-payload-consumer-guard.test.ts b/src/__tests__/account-payload-consumer-guard.test.ts index 174f0e199..6adc921bf 100644 --- a/src/__tests__/account-payload-consumer-guard.test.ts +++ b/src/__tests__/account-payload-consumer-guard.test.ts @@ -153,6 +153,12 @@ const ACTIVE_RECORD_ONLY_FIELDS = [ * only when the claim is true and checkable. */ const NO_CLIENT_CONSUMER: Record = { + features: + "A capability probe for clients released apart from the server: the native app asks " + + "whether this server takes the record-only medication switch before the account has a " + + "medication to learn it from (iOS #116, item 18). The web client is built into the same " + + "image as the server, so everything it renders is by construction supported; a web read " + + "of the flag would be a read written to satisfy this guard.", insurerIkNumber: "No web surface reads or writes it — the account form offers the insurer name and the " + "insurance number, not the institution number. The native client does consume it: it " + diff --git a/src/__tests__/acting-account-boundary-guard.test.ts b/src/__tests__/acting-account-boundary-guard.test.ts index ad7666d33..107794519 100644 --- a/src/__tests__/acting-account-boundary-guard.test.ts +++ b/src/__tests__/acting-account-boundary-guard.test.ts @@ -229,6 +229,9 @@ describe("grant management is not delegable by construction", () => { "app/api/account/grants/[id]/route.ts", "app/api/account/grants/[id]/accept/route.ts", "app/api/account/grants/[id]/renounce/route.ts", + // v1.42 (#959) — the new owner's decision about former Guardians ends and + // writes grant rows, so it is a grant route like the four above. + "app/api/account/handover-decision/route.ts", ]; it("every grant route exists and resolves through bare requireAuth", () => { @@ -300,6 +303,20 @@ describe("ending a grant and clearing the switch are one act", () => { expect(body).toContain("clearActingSessions"); }); + it("ends a handover Guardian's row and clears their switch in the caller's transaction", () => { + // v1.42 (#959) — the one other place a grant ends: settling a Guardian's + // access when a managed profile is claimed or decided. It runs inside the + // claim's own transaction (it takes `tx`), so the guard here is that the + // cleanup is part of the same function as the ending. + const body = functionBody( + read("lib/sharing/grants.ts"), + "settleHandoverGuardianGrant", + ); + expect(body.length).toBeGreaterThan(0); + expect(body).toContain("revokedAt: input.now"); + expect(body).toContain("clearActingSessions(pair, tx)"); + }); + it("routes an ending through the cleanup, never through the bare transition", () => { for (const rel of [ "app/api/account/grants/[id]/route.ts", diff --git a/src/__tests__/compose-env-whitelist-guard.test.ts b/src/__tests__/compose-env-whitelist-guard.test.ts index 79f28af68..027cb5262 100644 --- a/src/__tests__/compose-env-whitelist-guard.test.ts +++ b/src/__tests__/compose-env-whitelist-guard.test.ts @@ -102,6 +102,8 @@ const NOT_OPERATOR_CONFIGURABLE: Readonly> = { // --- Baked at build time by the Dockerfile's ARG/ENV pairs. --- NEXT_PUBLIC_APP_VERSION: "Dockerfile build arg; a runtime value cannot change what is compiled in", + NEXT_PUBLIC_I18N_CATALOG_VERSION: + "computed by next.config.ts from the message catalogs at build time", NEXT_PUBLIC_APP_BUILD_SHA: "Dockerfile build arg, stamped from the release commit", NEXT_PUBLIC_APP_BUILT_AT: "Dockerfile build arg, stamped at image build", diff --git a/src/__tests__/date-order-explicit-formatter-guard.test.ts b/src/__tests__/date-order-explicit-formatter-guard.test.ts index cf89630c0..a31307fcb 100644 --- a/src/__tests__/date-order-explicit-formatter-guard.test.ts +++ b/src/__tests__/date-order-explicit-formatter-guard.test.ts @@ -235,6 +235,10 @@ function scan(): Scan { * would make the key move when the preference did. */ const RAW_ALLOWLIST: ReadonlyArray<{ file: string; why: string }> = [ + { + file: "src/components/timeline/timeline-dates.ts", + why: "`todayKeyIn` asks `en-CA` for today's `YYYY-MM-DD` key in the record's zone, to compare with the timeline's calendar keys. Never rendered; every label there is a month name.", + }, { file: "src/app/api/measurement-reminders/[id]/snooze/route.ts", why: "An ISO day probe split back into numbers to do day arithmetic on the postpone target. Never rendered.", diff --git a/src/__tests__/dense-measurement-read-guard.test.ts b/src/__tests__/dense-measurement-read-guard.test.ts index 224bfc15b..fa81848b6 100644 --- a/src/__tests__/dense-measurement-read-guard.test.ts +++ b/src/__tests__/dense-measurement-read-guard.test.ts @@ -81,6 +81,8 @@ const DENSE_TYPES: ReadonlySet = new Set([ * list, keyed `::`, with why each stays bounded. */ const ALLOWED: Record = { + "lib/analytics/summaries-slice.ts::(shorthand)": + "one hourly-mean type (pulse) over one local day, the latest: the same rows the day view reads for that day, bounded by a day, not by the sampling history", "lib/insights/comprehensive-generate.ts::(shorthand)": 'Only reached for SLEEP_DURATION (guarded by `type === "SLEEP_DURATION"`); a few stage rows per night.', "lib/insights/derived/vascular-age.ts::VASCULAR_AGE_TYPE": @@ -107,6 +109,8 @@ const ALLOWED: Record = { "Cross-source merge probe for one posted sleep segment: SLEEP_DURATION in a ±tolerance window around its end.", "lib/targets/build-response.ts::{ in: recentTypes": "Thirty days of weight, blood pressure, resting heart rate, body fat and steps (drained to one row per day). PULSE is filtered out of `recentTypes` and folds in SQL.", + "lib/import/health-connect/import.ts::{ in: spotTypes": + "The Health Connect import's same-reading check: the spot types of one 500-row batch (weight, blood pressure, vitals) in that batch's time window, ±2 s. Heart rate and sleep never take the check.", "lib/doctor-report/collect.ts::{ notIn: rawExcluded": "The report window's sparse types. Pulse, heart-rate variability and blood oxygen over 10 000 readings or any window over 90 days, and glucose over 90 days, are in `rawExcluded` and read as day buckets; glucose inside 90 days stays raw for the clinical panel (per-reading metrics), below 26 000 rows at a sensor's rate.", }; @@ -123,6 +127,8 @@ const ALLOWED_SQL: Record = { "Canonical-source blood pressure and weight over 90 days: sparse types, a few rows a day.", "lib/measurements/reconcile-external-measurement.ts::1": "Row locks (`FOR UPDATE`) on an id list the caller already read; returns those ids only.", + "lib/jobs/pr-detection.ts::1": + "Selects account ids; `measurements` is read inside `EXISTS`, one row per account at most.", "lib/rollups/measurement-coverage.ts::1": "Selects from `unnest` of the caller's type list; `measurements` is read inside `EXISTS`, one row per type at most.", }; diff --git a/src/__tests__/derived-response-consumer-guard.test.ts b/src/__tests__/derived-response-consumer-guard.test.ts index e219c8b9a..8b58f5780 100644 --- a/src/__tests__/derived-response-consumer-guard.test.ts +++ b/src/__tests__/derived-response-consumer-guard.test.ts @@ -972,7 +972,15 @@ describe("derived response consumer guard", () => { `derived apiSuccess branch ${index + 1} must preserve producer lineage`, ).toEqual(expectedLineage); } - expect(variableCall(ROUTE_FILE, "derived")).toBe("computeDerivedMetric"); + // v1.42 — the single read goes through the derived cache; the value is + // still the compute's, built inside the cache's builder. + const derivedProducer = variableCall(ROUTE_FILE, "derived"); + expect(["computeDerivedMetric", "cachedSwr"]).toContain(derivedProducer); + if (derivedProducer === "cachedSwr") { + expect(readFileSync(ROUTE_FILE, "utf8")).toMatch( + /const derived = await cachedSwr\([\s\S]*?computeDerivedMetric\(/, + ); + } expect(variableCall(ROUTE_FILE, "assessment")).toBe( "resolveDerivedAssessment", ); diff --git a/src/__tests__/derived-wellness-value-contract.test.ts b/src/__tests__/derived-wellness-value-contract.test.ts index 9034c98da..d58de8d62 100644 --- a/src/__tests__/derived-wellness-value-contract.test.ts +++ b/src/__tests__/derived-wellness-value-contract.test.ts @@ -112,4 +112,29 @@ describe("published WellnessScoreValue", () => { Object.keys(derived.value as unknown as Record).sort(), ); }); + + it("accepts the strain value served from the device's day strain", async () => { + findMany.mockImplementation(async (args: { where: { type: string } }) => + args.where.type === "DAY_STRAIN" + ? [ + { + value: 12.6, + measuredAt: new Date("2026-06-01T04:00:00Z"), + source: "WHOOP", + }, + ] + : [], + ); + const derived = await computeWellnessScore("STRAIN_SCORE", "u1", PROFILE, { + now: NOW, + }); + expect(derived.status).toBe("ok"); + if (derived.status !== "ok") return; + const parsed = wellnessScoreValue.safeParse(derived.value); + expect(parsed.error?.issues ?? []).toEqual([]); + expect(Object.keys(wellnessScoreValue.shape).sort()).toEqual( + Object.keys(derived.value as unknown as Record).sort(), + ); + findMany.mockReset(); + }); }); diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index a429ecb1a..46c0d9655 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -439,6 +439,15 @@ const REGISTRY: DestructiveEntry[] = [ confirm: ["AlertDialog"], }, + { + file: "components/timeline/use-timeline.ts", + destroys: "one life event with its encrypted title and note", + triggers: ["components/timeline/life-event-form.tsx"], + // The route soft-deletes; there is no restore route or surface yet. + recovery: "tombstoned-no-restore", + confirm: ["ConfirmButton"], + }, + // ── Cycle ─────────────────────────────────────────────────────────────── { file: "components/cycle/use-cycle.ts", diff --git a/src/__tests__/environment-backup-completeness.test.ts b/src/__tests__/environment-backup-completeness.test.ts new file mode 100644 index 000000000..fbc6f5933 --- /dev/null +++ b/src/__tests__/environment-backup-completeness.test.ts @@ -0,0 +1,161 @@ +/** + * v1.42 (#615) — pair guard binding the two environment models in + * `prisma/schema.prisma` to the backup that carries them + * (`src/lib/export/environment-backup.ts`), on the pattern of + * `measurement-backup-completeness.test.ts`. + * + * Twenty-two air-quality columns and three sealed locations arrived in one + * release, and nothing failed when a column was missing from the backup: the + * export kept building, the restore kept parsing (the section schema is + * `.passthrough()`), and the column came back NULL on every restored day. So + * both ends are compared as literals: + * + * - every scalar column of `EnvironmentContext` and + * `EnvironmentTravelLocation` is in the named backup select (or excluded + * here with a reason); + * - every selected `EnvironmentContext` column is written by the export + * (`row.`, or the location helper for the location columns); + * - every selected column is written back by the restore (`:` in + * `restoreEnvironmentData`, or listed in `AIR_QUALITY_COLUMNS`, which the + * restore maps over). + * + * Mutation check: delete `pm25Mean: true` from the context select and the + * first case names it; delete `"pm25Mean",` from `AIR_QUALITY_COLUMNS` and the + * restore case names it. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const ROOT = process.cwd(); +const schema = readFileSync(join(ROOT, "prisma", "schema.prisma"), "utf8"); +const source = readFileSync( + join(ROOT, "src", "lib", "export", "environment-backup.ts"), + "utf8", +); + +/** Columns the backup is right to leave out, with the reason. */ +const EXCLUDED: Record = { + id: "nothing in the file or the database addresses one of these rows; a day is identified by its date", + userId: + "user-scoped backup; the owner is re-derived from the restoring account", +}; + +/** Columns written through the location helper rather than `row.`. */ +const LOCATION_COLUMNS = new Set([ + "lat", + "lon", + "locationLabel", + "label", + "locationEncrypted", +]); + +function block(text: string, start: string, end = "\n}"): string { + const from = text.indexOf(start); + expect(from, `${start} not found`).toBeGreaterThan(-1); + const to = text.indexOf(end, from); + expect(to).toBeGreaterThan(from); + return text.slice(from, to); +} + +function scalarColumns(model: string): string[] { + const models = new Set( + [...schema.matchAll(/^model\s+([A-Za-z_]\w*)\s*\{/gm)].map((m) => m[1]), + ); + const out: string[] = []; + for (const raw of block(schema, `model ${model} {`).split("\n").slice(1)) { + const line = raw.trim(); + if (!line || line.startsWith("//") || line.startsWith("@@")) continue; + const m = /^([a-zA-Z_]\w*)\s+([A-Za-z_]\w*)(\[\])?/.exec(line); + if (!m || m[3] || models.has(m[2]) || line.includes("@relation")) continue; + out.push(m[1]); + } + return out; +} + +function selectKeys(name: string): string[] { + return [ + ...block(source, `const ${name} = {`).matchAll(/^ {2}(\w+): true,$/gm), + ].map((m) => m[1]); +} + +const restoreBody = block( + source, + "export async function restoreEnvironmentData(", + "\n}\n", +); +const airQualityList = block(source, "const AIR_QUALITY_COLUMNS = [", "]"); + +describe.each([ + ["EnvironmentContext", "ENVIRONMENT_CONTEXT_BACKUP_SELECT"], + ["EnvironmentTravelLocation", "ENVIRONMENT_TRAVEL_LOCATION_BACKUP_SELECT"], +])("%s backup completeness", (model, selectName) => { + const columns = scalarColumns(model); + const selected = selectKeys(selectName); + + it("parses both ends into a plausible shape", () => { + expect(columns.length).toBeGreaterThan(8); + expect(columns).toContain("locationEncrypted"); + expect(selected.length).toBeGreaterThan(5); + }); + + it("the backup select covers every column", () => { + const missing = columns.filter( + (c) => !selected.includes(c) && !(c in EXCLUDED), + ); + expect( + missing, + `${selectName} omits these ${model} columns; a restore brings them back NULL`, + ).toEqual([]); + }); + + it("the restore writes every selected column back", () => { + const unwritten = selected.filter( + (c) => + !new RegExp(`\\b${c}:`).test(restoreBody) && + !airQualityList.includes(`"${c}"`) && + // The day key and the source are written under their own names. + !["date", "startDate", "endDate"].includes(c), + ); + expect(unwritten, "selected but never restored").toEqual([]); + }); +}); + +describe("EnvironmentContext export", () => { + it("writes every selected column into the entry", () => { + const selected = selectKeys("ENVIRONMENT_CONTEXT_BACKUP_SELECT"); + const unwritten = selected.filter( + (c) => + !LOCATION_COLUMNS.has(c) && !new RegExp(`row\\.${c}\\b`).test(source), + ); + expect(unwritten, "selected but never exported").toEqual([]); + }); + + it("covers all twenty-two air-quality columns and the sealed location", () => { + const selected = selectKeys("ENVIRONMENT_CONTEXT_BACKUP_SELECT"); + for (const column of [ + "apparentMax", + "pm25Mean", + "o3Max8h", + "pollenRagweedMax", + "aqDomain", + "aqHours", + "aqFetchedAt", + "locationEncrypted", + ]) { + expect(selected, column).toContain(column); + } + }); + + it("carries no stale exclusion", () => { + const all = new Set([ + ...scalarColumns("EnvironmentContext"), + ...scalarColumns("EnvironmentTravelLocation"), + ]); + for (const [column, reason] of Object.entries(EXCLUDED)) { + expect(all.has(column), column).toBe(true); + expect(reason.length).toBeGreaterThan(20); + } + }); +}); diff --git a/src/__tests__/error-level-discipline-guard.test.ts b/src/__tests__/error-level-discipline-guard.test.ts new file mode 100644 index 000000000..bd1b96be9 --- /dev/null +++ b/src/__tests__/error-level-discipline-guard.test.ts @@ -0,0 +1,327 @@ +/** + * A caught error in a background job is either reported or explained. + * + * The failures that cost the most in this codebase were never thrown at + * anyone: a nightly pass that caught its error, annotated an `info` line and + * carried on, completed in pg-boss every night while doing nothing. The + * briefing warm read "all-failed" for a week before anyone could tell which + * account and why; the environment fetch threw on every run for the life of + * the module. A `catch` that drops what it caught is where that starts. + * + * Scope: every `catch` clause and every `.catch(handler)` under + * `src/lib/jobs/` (tests excluded). A catch passes when its body does one of: + * + * - rethrows, rejects, or hands the error to a stream callback; + * - fails the job (`jobFailed`); + * - logs at warn or error: `logCaught` / `caughtAs`, `emitSignal`, + * `reportWorkerError`, + * `addWarning`, `setError`, `elevateLevel`, `workerLog("error", …)`, + * `console.error` / `console.warn`; + * - writes the failure to a ledger an operator surface reads + * (`recordSyncFailure`, `failAiRun`, `markFailed`, + * `recordThumbnailFailure`, `recordBriefingFailure`); + * - counts it into a failure tally (`failed += 1`, `errored++`, + * `failedSubjects.set(…)`) that the job reports in its outcome facts, so a + * systemic failure shows as a count and a partial run as + * `job.run.partial`; + * - hands it back to its caller in the result (`error: err…`), which is + * how every boot-time discovery enqueue reports to the registrar that + * logs it at error. + * + * Anything else is listed in `ALLOWED` below with the reason it may stay + * silent, keyed by file and enclosing function, with how many such catches + * that function holds. The reasons are the review: "best effort" is not one; + * "the pgboss schema is absent on a web-only deployment and null is the + * documented answer" is. + * + * The list cannot rot quietly in either direction. A new silent catch fails + * the second case. An entry whose catches were fixed or removed fails the + * third case (stale entry). And the fourth case removes each entry in turn + * and asserts the sweep goes red without it, so every entry is shown to be + * load-bearing, and the sweep is shown to find what it claims to find. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); +const SCOPE = "lib/jobs/"; + +const REPORTS = + /\bthrow\b|\breject\(|\bcallback\(\s*err|\bjobFailed\(|\blogCaught\(|\bcaughtAs\(|\bemitSignal\(|\breportWorkerError\(|\.addWarning\(|\.setError\(|\belevateLevel\(|\bworkerLog\(\s*["']error|\bconsole\.(error|warn)\(|\brecordSyncFailure\(|\bfailAiRun\(|\bmarkFailed\(|\brecordThumbnailFailure\(|\brecordBriefingFailure\(|\berror:\s*err\b/; + +/** A failure tally: `summary.failed += 1`, `errored++`, `failures += 1`. */ +const COUNTS = + /\b[\w.]*(?:failed|failures|errored|Failed|Failures)\w*\s*(?:\+=|\+\+|\.set\(|\.push\()/; + +interface Finding { + /** `::` */ + key: string; + line: number; + body: string; +} + +function scan(file: string, text: string): Finding[] { + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); + const out: Finding[] = []; + const visit = (node: ts.Node, fn: string): void => { + let name = fn; + if ( + (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) && + node.name + ) { + name = node.name.getText(sf); + } else if ( + ts.isVariableDeclaration(node) && + node.initializer && + (ts.isArrowFunction(node.initializer) || + ts.isFunctionExpression(node.initializer)) + ) { + name = node.name.getText(sf); + } + let body: string | null = null; + if (ts.isCatchClause(node)) body = node.block.getText(sf); + if ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.getText(sf) === "catch" && + node.arguments[0] + ) { + body = node.arguments[0].getText(sf); + } + if (body !== null && !REPORTS.test(body) && !COUNTS.test(body)) { + out.push({ + key: `${file}::${name}`, + line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1, + body, + }); + } + ts.forEachChild(node, (child) => visit(child, name)); + }; + visit(sf, ""); + return out; +} + +/** + * Catches allowed to stay silent, by file and function: how many, and why. + * Keep the reason checkable against the code it names. + */ +const ALLOWED: Readonly> = { + "lib/jobs/apple-health-import-worker.ts::reconcileOrphanImportJobs": { + count: 1, + reason: + "A failed live-state lookup reads as live on purpose: the heartbeat is fresh, flipping a running import would kill it, and the next boot re-evaluates once the heartbeat goes stale.", + }, + "lib/jobs/apple-health-import-worker.ts::safeUnlink": { + count: 1, + reason: + "Deleting a staged file that a previous step or sweep already removed; ENOENT is the expected case and nothing is lost either way.", + }, + "lib/jobs/backup-restore.ts::writeProgress": { + count: 1, + reason: + "A progress tick for the status poll; the next tick rewrites it and the job's own outcome is recorded by `finish`.", + }, + "lib/jobs/backup-restore.ts::runBackupRestoreJob": { + count: 1, + reason: + "Waits out the in-flight progress write before the final state is written; that write's failure is the progress tick's, see writeProgress.", + }, + "lib/jobs/backup-restore.ts::sweepInterruptedRestores": { + count: 1, + reason: + "Completing the dead worker's superseded pg-boss delivery; if it fails the delivery expires on its own, and the restore row it belonged to is already failed and counted.", + }, + "lib/jobs/boss-instance.ts::startProducerAttempts": { + count: 2, + reason: + "The start error is kept and thrown after the last attempt; stopping a boss that never started can only fail for the same reason.", + }, + "lib/jobs/boss-instance.ts::superviseProducerReconnect": { + count: 1, + reason: + "Stopping the producer whose connection already failed, before reconnecting; the reconnect loop reports its own failures.", + }, + "lib/jobs/free-text-encryption-backfill.ts::scrubContactAuditDetails": { + count: 1, + reason: + "A parser: details that are not this action's JSON are left alone by contract (null means 'nothing to scrub'), not an error.", + }, + "lib/jobs/geo-backfill.ts::runGeoBackfill": { + count: 1, + reason: + "The row was deleted by the retention sweep between read and update; counted as still unresolved, and the next pass no longer sees it.", + }, + "lib/jobs/host-metric-sampler.ts::captureHostMetric": { + count: 1, + reason: + "Disk counters exist only on Linux; elsewhere the sample records null for them by design.", + }, + "lib/jobs/host-metric-sampler.ts::readLinuxDiskStats": { + count: 1, + reason: "No /proc/diskstats outside Linux; see captureHostMetric.", + }, + "lib/jobs/job-failures.ts::readActiveJobsStartedBefore": { + count: 1, + reason: + "No pgboss schema on a web-only deployment; null is the documented 'no queue to ask', kept apart from an empty list.", + }, + "lib/jobs/job-failures.ts::readConsecutiveRunFailures": { + count: 1, + reason: + "Called from a failure path that is already logging at error; when the queue cannot be read, 1 (this run) is the only known count.", + }, + "lib/jobs/job-failures.ts::readFailingQueues": { + count: 1, + reason: "See readActiveJobsStartedBefore: null means no queue schema.", + }, + "lib/jobs/job-failures.ts::readLastQueueRun": { + count: 1, + reason: "See readActiveJobsStartedBefore: null means no queue schema.", + }, + "lib/jobs/job-failures.ts::readQueueFailureForUser": { + count: 1, + reason: "See readActiveJobsStartedBefore: null means no queue schema.", + }, + "lib/jobs/job-failures.ts::readQueueRunningSince": { + count: 1, + reason: "See readActiveJobsStartedBefore: null means no queue schema.", + }, + "lib/jobs/measurement-maintenance-status.ts::readMeasurementMaintenanceStatus": + { + count: 1, + reason: + "No pgboss schema on a web-only deployment; the admin card reads `available: false` and says so.", + }, + "lib/jobs/measurement-maintenance-status.ts::readSizes": { + count: 1, + reason: + "A size probe for an admin card: null shows no figure, and the status read must not fail over it.", + }, + "lib/jobs/job-observer.ts::emitJobLine": { + count: 1, + reason: + "The catch around writing a log line itself; there is nowhere further to log to, and a log line must not break the job.", + }, + "lib/jobs/offhost-backup.ts::getS3Client": { + count: 2, + reason: + "headObject answers 'absent' for a missing object (its contract), and the lifecycle read maps NoSuchLifecycleConfiguration to 'missing' and anything else to 'unknown', which the status card shows.", + }, + "lib/jobs/offhost-backup.ts::loadOffhostConfigSafe": { + count: 1, + reason: + "A malformed key reads as 'not configured' for the status card; the backup pass itself calls the throwing loader and fails loudly.", + }, + "lib/jobs/offhost-backup.ts::probeOffhostLifecycle": { + count: 1, + reason: + "The lifecycle probe answers 'unknown', which the admin card renders as such; it is a display read, not a pass.", + }, + "lib/jobs/offhost-backup.ts::runOffhostRoundtripTest": { + count: 2, + reason: + "The admin's connection test returns its error in the result it shows (ok: false, error), and deleting its own probe object is cleanup.", + }, + "lib/jobs/offhost-backup.ts::withEachKey": { + count: 1, + reason: + "Trying each key of the ring in turn; the last error is thrown when no key opens the object.", + }, + "lib/jobs/reminder/medication-reminder-check.ts::cleanupScheduledTelegramDeletions": + { + count: 1, + reason: + "Deleting a reminder message the person usually already deleted; the scheduled row is removed either way, and logging every expected 400 would bury the real ones.", + }, + "lib/jobs/reminder/polar-sync.ts::syncUserPolarLegs": { + count: 2, + reason: + "Both legs run, the first error is kept and thrown after the second leg.", + }, + "lib/jobs/restore-drill.ts::runRestoreDrill": { + count: 1, + reason: + "The per-account drill failure is recorded on the account's result (ok: false, error) and counted into the run's restore_drill_accounts_failed fact.", + }, +}; + +function sweep(): Finding[] { + return walkSourceFiles(join(SRC, SCOPE), { floor: 100 }) + .filter((p) => !p.includes("__tests__") && !p.endsWith(".test.ts")) + .flatMap((p) => + scan(`${SCOPE}${p}`, readFileSync(join(SRC, SCOPE, p), "utf8")), + ); +} + +function unallowed( + findings: readonly Finding[], + allowed: Readonly>, +): string[] { + const byKey = new Map(); + for (const f of findings) { + byKey.set(f.key, [...(byKey.get(f.key) ?? []), f]); + } + const out: string[] = []; + for (const [key, list] of byKey) { + const allowance = allowed[key]?.count ?? 0; + if (list.length > allowance) { + for (const f of list.slice(allowance)) { + out.push( + `${key} (line ${f.line}): ${f.body.replace(/\s+/g, " ").slice(0, 120)}`, + ); + } + } + } + return out.sort(); +} + +describe("error-level discipline in background jobs", () => { + it("the scanner flags a silent catch and passes a reported one", () => { + const planted = scan( + "lib/jobs/planted.ts", + [ + "async function quiet() { try { await a(); } catch { /* best effort */ } }", + "async function info() { try { await a(); } catch (e) { annotate({ action: { name: 'x.failed' } }); } }", + "async function chained() { await a().catch(() => {}); }", + "async function loud() { try { await a(); } catch (e) { logCaught('x.y.failed', e); } }", + "async function counted() { try { await a(); } catch { summary.failed += 1; } }", + "async function rethrown() { try { await a(); } catch (e) { throw e; } }", + ].join("\n"), + ); + expect(planted.map((f) => f.key)).toEqual([ + "lib/jobs/planted.ts::quiet", + "lib/jobs/planted.ts::info", + "lib/jobs/planted.ts::chained", + ]); + }); + + it("every silent catch in src/lib/jobs is fixed or explained", () => { + expect(unallowed(sweep(), ALLOWED)).toEqual([]); + }); + + it("no allow-list entry outlives the catches it explains", () => { + const counts = new Map(); + for (const f of sweep()) counts.set(f.key, (counts.get(f.key) ?? 0) + 1); + const stale = Object.entries(ALLOWED) + .filter(([key, { count }]) => (counts.get(key) ?? 0) < count) + .map( + ([key, { count }]) => + `${key}: allows ${count}, finds ${counts.get(key) ?? 0}`, + ); + expect(stale).toEqual([]); + }); + + it("every allow-list entry is load-bearing: removing it turns the sweep red", () => { + const findings = sweep(); + const notLoadBearing = Object.keys(ALLOWED).filter((key) => { + const without = { ...ALLOWED }; + delete (without as Record)[key]; + return unallowed(findings, without).length === 0; + }); + expect(notLoadBearing).toEqual([]); + }); +}); diff --git a/src/__tests__/guard-floor-guard.test.ts b/src/__tests__/guard-floor-guard.test.ts index bce4fae04..7578cf6ce 100644 --- a/src/__tests__/guard-floor-guard.test.ts +++ b/src/__tests__/guard-floor-guard.test.ts @@ -83,8 +83,8 @@ const NO_FLOOR_NEEDED: Readonly> = { "unit test of the ownership helper", // List a temporary directory the test itself filled. - "lib/import/__tests__/apple-health-staging.test.ts": - "lists its own temp staging directory", + "lib/import/__tests__/unzip-health-connect.test.ts": + "lists the temp directory to show a refused extraction left no file behind", "lib/multipart/__tests__/stream-to-disk.test.ts": "lists its own temp directory", }; diff --git a/src/__tests__/i18n-catalog-cache-key.test.ts b/src/__tests__/i18n-catalog-cache-key.test.ts new file mode 100644 index 000000000..6888e79f6 --- /dev/null +++ b/src/__tests__/i18n-catalog-cache-key.test.ts @@ -0,0 +1,31 @@ +/** + * The locale catalog's boot script is cached by its URL: the service worker + * serves `/i18n/.js?v=…` cache-first. Keyed by the package version, + * every build of one release shared one URL, so a beta updated within a + * release kept its first catalog and every key added since read as its raw + * name ("timeline.zoom.range"). The key is the catalogs' content hash now. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const read = (rel: string) => readFileSync(join(process.cwd(), rel), "utf8"); + +describe("the locale catalog's cache key", () => { + it("moves with the catalogs, not with the release", () => { + const layout = read("src/app/layout.tsx"); + const src = + /src=\{`\/i18n\/\$\{initialLocale\}\.js\?v=\$\{([^}]*)\}`\}/.exec(layout); + expect(src?.[1]).toContain("NEXT_PUBLIC_I18N_CATALOG_VERSION"); + expect(src?.[1]).not.toContain("NEXT_PUBLIC_APP_VERSION"); + }); + + it("is computed from the message catalogs at build time", () => { + const config = read("next.config.ts"); + expect(config).toMatch( + /NEXT_PUBLIC_I18N_CATALOG_VERSION:\s*catalogVersion\(\)/, + ); + expect(config).toMatch(/join\(process\.cwd\(\), "messages"\)/); + }); +}); diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index b5e5ad23a..49f7a32d9 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -98,6 +98,7 @@ const LEGIT_IDENTICAL = new Set([ "insights.derived.composite.READINESS.component.hrv", "labs.catalog.hs-crp", "measurements.typeHrvRmssd", + "settings.sections.sources.metrics.hrvRmssd", "measurements.typeVo2Max", // "{hours} h {minutes} min": the SI symbols for hour and minute. "measurements.durationHoursMinutes", @@ -112,6 +113,7 @@ const LEGIT_IDENTICAL = new Set([ "admin.webPushVapidTitle", "measurements.sourceAppleHealth", "measurements.sourceGoogleHealth", + "measurements.sourceHealthConnect", "medications.sourceAppleHealth", "settings.ai.providerChain.types.anthropic", "settings.ai.providerChain.types.codex", @@ -119,6 +121,7 @@ const LEGIT_IDENTICAL = new Set([ "settings.sections.export.import.appleHealth.title", "settings.sections.sources.sourceLabels.APPLE_HEALTH", "settings.sections.sources.sourceLabels.GOOGLE_HEALTH", + "settings.sections.sources.sourceLabels.HEALTH_CONNECT", "settings.appleHealth.title", "mood.tag.fastFood", // Technical / config labels conventionally kept verbatim. diff --git a/src/__tests__/i18n-no-dot-separator.test.ts b/src/__tests__/i18n-no-dot-separator.test.ts new file mode 100644 index 000000000..d315ec9c1 --- /dev/null +++ b/src/__tests__/i18n-no-dot-separator.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +/** + * No middle-dot or dash separator in visible strings. + * + * A value like "Added {date} · {size}" or "Weight · body fat · muscle" reads + * as a list of fragments rather than as the app's plain voice, and so does + * "{what} — tomorrow" or "Could not save — try again": the dash stands in + * for a comma, a colon or a full stop the sentence should have said. Facts + * are joined with a comma, a colon or a real phrase instead. This guard + * walks every bundle under `messages/` and fails on any string that contains + * a space, then U+00B7 (middle dot), U+2014 (em dash) or U+2013 (en dash), + * then a space. A middle dot inside a unit ("mL/(kg·min)") and a range + * ("70–100 mmHg", "{start}–{end}") have no surrounding spaces and are not + * matched: an unspaced en dash is how a range is written, not a pause. + * + * A key that genuinely needs the pattern goes into `ALLOWED` with the reason + * written next to it. An allowed key that no longer contains the pattern + * fails too, so the list cannot go stale. + */ + +const MESSAGES_DIR = join(__dirname, "../../messages"); +const SEPARATORS = [" · ", " — ", " – "] as const; + +const hasSeparator = (value: string) => + SEPARATORS.some((separator) => value.includes(separator)); + +/** Key path -> why the separator is right there. Empty by intent. */ +const ALLOWED: Record = {}; + +/** + * Subtrees whose wording is not ours to change. The questionnaire items are + * the licensed, validated text of PHQ-9, GAD-7 and the other instruments in + * each language; a dash there is part of the instrument (PHQ-9 item 6 reads + * "Feeling bad about yourself — or that you are a failure ..."), and + * rewording it would change what the score measures. + */ +const EXEMPT_PREFIXES: Record = { + "mentalHealth.items.": "validated questionnaire wording, verbatim", +}; + +const isExempt = (key: string) => + Object.keys(EXEMPT_PREFIXES).some((prefix) => key.startsWith(prefix)); + +function flatten( + obj: unknown, + prefix: string, + out: Map, +): Map { + if (obj == null || typeof obj !== "object") return out; + for (const [k, v] of Object.entries(obj as Record)) { + const key = prefix ? `${prefix}.${k}` : k; + if (typeof v === "string") out.set(key, v); + else if (typeof v === "object") flatten(v, key, out); + } + return out; +} + +const bundles = readdirSync(MESSAGES_DIR) + .filter((file) => file.endsWith(".json")) + .sort(); + +describe("message bundles carry no ' · ', ' — ' or ' – ' separator", () => { + it("finds the bundles", () => { + expect(bundles.length).toBeGreaterThanOrEqual(7); + }); + + for (const file of bundles) { + const values = flatten( + JSON.parse(readFileSync(join(MESSAGES_DIR, file), "utf8")), + "", + new Map(), + ); + + it(`${file} walks a non-empty set of strings`, () => { + expect(values.size).toBeGreaterThan(1000); + }); + + it(`${file} has no dot or dash separator outside the allowlist`, () => { + const offenders = [...values] + .filter( + ([key, value]) => + hasSeparator(value) && !(key in ALLOWED) && !isExempt(key), + ) + .map(([key, value]) => `${key} = ${JSON.stringify(value)}`); + expect(offenders).toEqual([]); + }); + + it(`${file} has no stale allowlist entry`, () => { + const stale = Object.keys(ALLOWED).filter( + (key) => !hasSeparator(values.get(key) ?? ""), + ); + expect(stale).toEqual([]); + }); + } +}); diff --git a/src/__tests__/i18n-reverse-coverage.test.ts b/src/__tests__/i18n-reverse-coverage.test.ts index 759d4b43c..256bcbd89 100644 --- a/src/__tests__/i18n-reverse-coverage.test.ts +++ b/src/__tests__/i18n-reverse-coverage.test.ts @@ -66,6 +66,19 @@ const DYNAMIC_ALLOWLIST_PREFIXES = [ "apiErrors", ] as const; +/** + * Keys a release lands ahead of their call sites, so that the packages built + * in parallel on the release branch share one bundle and never edit it + * themselves. Each entry names the package that wires the call sites and the + * issue it serves. + * + * This list expires by construction: the companion test fails as soon as an + * entry no longer covers a single orphan, so a prefix whose call sites have + * all landed must be dropped in the same change, and the list is empty again + * before the release is tagged. It is NOT a place for keys nobody will wire. + */ +const RELEASE_PENDING_PREFIXES: Record = {}; + /** * Interpolating strings that LOOK like key templates and are not. Each entry * whitelists nothing — the template still contributes no reachable keys — it @@ -118,9 +131,14 @@ function nodePaths(obj: unknown, prefix: string, out: Set): void { } } +function underPrefix(leaf: string, prefix: string): boolean { + return leaf === prefix || leaf.startsWith(`${prefix}.`); +} + function isAllowlisted(leaf: string): boolean { - return DYNAMIC_ALLOWLIST_PREFIXES.some( - (p) => leaf === p || leaf.startsWith(`${p}.`), + return ( + DYNAMIC_ALLOWLIST_PREFIXES.some((p) => underPrefix(leaf, p)) || + Object.keys(RELEASE_PENDING_PREFIXES).some((p) => underPrefix(leaf, p)) ); } @@ -185,4 +203,18 @@ describe("i18n reverse coverage", () => { ); } }, 15_000); + + it("every release-pending prefix still covers an unwired key", () => { + const { leaves, ref } = scan(); + const stale = Object.keys(RELEASE_PENDING_PREFIXES).filter( + (prefix) => + !leaves.some( + (leaf) => underPrefix(leaf, prefix) && !isReferenced(leaf, ref), + ), + ); + expect( + stale, + "These RELEASE_PENDING_PREFIXES entries have all their call sites now — drop them", + ).toEqual([]); + }, 15_000); }); diff --git a/src/__tests__/job-retention-floor-guard.test.ts b/src/__tests__/job-retention-floor-guard.test.ts new file mode 100644 index 000000000..52aeedb38 --- /dev/null +++ b/src/__tests__/job-retention-floor-guard.test.ts @@ -0,0 +1,105 @@ +/** + * No queue may forget its failed jobs before the failure readers look. + * + * `job-failures.ts` reads pg-boss's own `state = 'failed'` rows over a 72-hour + * window: the admin status card, the per-account "last run failed" hint and + * the repeated-failure count behind the `job.run.failed` alert all rest on + * those rows still existing. pg-boss deletes a terminal row + * `deleteAfterSeconds` after it finished. v1.42 shortens that option on the + * pure-volume queues, and a value under the reader window would make a + * failing queue read as healthy, with nothing anywhere saying so. + * + * The rule: a `deleteAfterSeconds` in the source is either produced by + * `failureReaderRetention(hours)` (which throws under the floor) or written as + * a plain literal at or above `FAILED_ROW_AVAILABILITY_FLOOR_HOURS`. Anything + * else, a named constant included, fails here, because a constant's value is + * not checkable from the call site and the helper exists to make it so. + * + * Mutation check, run when this guard was written: a planted + * `deleteAfterSeconds: 48 * 60 * 60` in a job file fails the second case, and + * `deleteAfterSeconds: VOLUME_SECONDS` fails it too; the matcher self-test + * below proves the scan sees both shapes, so an empty sweep is not mistaken + * for a clean one. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { walkSourceFiles } from "./helpers/source-files"; +import { FAILED_ROW_AVAILABILITY_FLOOR_HOURS } from "@/lib/jobs/job-failures"; + +const SRC = join(process.cwd(), "src"); +const FLOOR_SECONDS = FAILED_ROW_AVAILABILITY_FLOOR_HOURS * 3600; + +/** The one file allowed to spell the option out: the helper itself. */ +const HELPER_FILE = "lib/jobs/job-failures.ts"; + +const OPTION = /\bdeleteAfterSeconds\s*:\s*([^,}\n]+)/g; + +/** Evaluate a sum of products of integer literals; null for anything else. */ +function literalSeconds(expression: string): number | null { + const cleaned = expression.trim().replace(/_/g, ""); + if (!/^\d+(\s*[*+]\s*\d+)*$/.test(cleaned)) return null; + return cleaned + .split("+") + .map((term) => + term + .split("*") + .map((factor) => Number(factor.trim())) + .reduce((a, b) => a * b, 1), + ) + .reduce((a, b) => a + b, 0); +} + +interface Finding { + file: string; + expression: string; +} + +function scan(file: string, text: string): Finding[] { + const out: Finding[] = []; + for (const match of text.matchAll(OPTION)) { + out.push({ file, expression: match[1].trim() }); + } + return out; +} + +function violations(findings: readonly Finding[]): string[] { + return findings + .filter((f) => { + const seconds = literalSeconds(f.expression); + return seconds === null || seconds < FLOOR_SECONDS; + }) + .map((f) => `${f.file}: deleteAfterSeconds: ${f.expression}`); +} + +function sourceFindings(): Finding[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")) + .filter((p) => p !== HELPER_FILE) + .flatMap((p) => scan(p, readFileSync(join(SRC, p), "utf8"))); +} + +describe("job retention floor", () => { + it("the matcher sees both a short literal and a named constant", () => { + const planted = scan( + "planted.ts", + [ + "await boss.createQueue(Q, { deleteAfterSeconds: 48 * 60 * 60 });", + "await boss.createQueue(R, { deleteAfterSeconds: VOLUME_SECONDS });", + "await boss.createQueue(S, { deleteAfterSeconds: 7 * 24 * 3600 });", + ].join("\n"), + ); + expect(planted).toHaveLength(3); + expect(violations(planted)).toEqual([ + "planted.ts: deleteAfterSeconds: 48 * 60 * 60", + "planted.ts: deleteAfterSeconds: VOLUME_SECONDS", + ]); + }); + + it("every queue retention in the tree keeps failed rows past the reader window", () => { + expect(violations(sourceFindings())).toEqual([]); + }); +}); diff --git a/src/__tests__/measurement-write-plausibility-guard.test.ts b/src/__tests__/measurement-write-plausibility-guard.test.ts index b35b56f35..9b3cf83e0 100644 --- a/src/__tests__/measurement-write-plausibility-guard.test.ts +++ b/src/__tests__/measurement-write-plausibility-guard.test.ts @@ -76,6 +76,10 @@ const WRITERS: Record = { "lib/mcp/writes.ts": { kind: "validator" }, "lib/measurements/create-from-telegram.ts": { kind: "validator" }, "lib/measurements/import-apple-health-export.ts": { kind: "validator" }, + // v1.42 — the Health Connect export import. Spot rows go through the bulk + // insert after `validateMeasurementRange`; day totals through the gated + // reconcile seam. + "lib/import/health-connect/import.ts": { kind: "validator" }, "app/api/import/csv/route.ts": { kind: "upstream", validatedIn: "lib/import/csv-measurements.ts", diff --git a/src/__tests__/measurement-write-tail-guard.test.ts b/src/__tests__/measurement-write-tail-guard.test.ts index dc56b62f8..061f49718 100644 --- a/src/__tests__/measurement-write-tail-guard.test.ts +++ b/src/__tests__/measurement-write-tail-guard.test.ts @@ -72,6 +72,8 @@ const WRITE_SITES: Record = { "a primitive; every caller runs the tail over the verdicts' dirty identities", "lib/measurements/import-apple-health-export.ts": "the import worker refolds the import's span and re-warms every imported type", + "lib/import/health-connect/import.ts": + "the Health Connect import worker refolds the import's span and re-warms every imported type", "lib/export/restore-backup.ts": "a restore replaces the whole record: it refolds the whole window and drops every per-user cache", // Value-preserving reshapes: the same readings folded into daily or hourly @@ -98,6 +100,12 @@ const WRITE_SITES: Record = { "encrypts the note column in place; no value, instant or type changes", "lib/jobs/measurement-tombstone-cleanup.ts": "hard-deletes rows that were already soft-deleted and read nowhere", + "lib/jobs/compaction-tombstone-purge.ts": + "hard-deletes compaction tombstones, rows already soft-deleted and read nowhere", + "lib/jobs/measurement-fold-repair.ts": + "corrects the value of a folded stats: mean in place, takes the window's live samples into it, and refolds the DAY rollup of a touched daily mean itself; no instant or type changes", + "lib/measurements/fold-constituents.ts": + "soft-deletes the samples a fold or the fold repair has just taken into a recomputed stats: mean, which accounts for them from then on", }; function sourceFiles(): string[] { @@ -155,8 +163,12 @@ describe("measurement write tail", () => { const handRolled = Object.entries(WRITE_SITES) .filter(([, reason]) => reason.includes("re-warms")) .map(([file]) => file) + // The two export importers leave both legs to their workers, which + // run them once over the whole import. .filter( - (file) => file !== "lib/measurements/import-apple-health-export.ts", + (file) => + file !== "lib/measurements/import-apple-health-export.ts" && + file !== "lib/import/health-connect/import.ts", ); expect(handRolled.length).toBeGreaterThan(0); const missing = handRolled.filter((file) => { diff --git a/src/__tests__/openapi-security-declaration-guard.test.ts b/src/__tests__/openapi-security-declaration-guard.test.ts index b40a68398..ab57f201b 100644 --- a/src/__tests__/openapi-security-declaration-guard.test.ts +++ b/src/__tests__/openapi-security-declaration-guard.test.ts @@ -79,6 +79,10 @@ const UNAUTHENTICATED: Readonly> = { "Says whether single sign-on is configured, so the sign-in screen can offer it.", "/api/notifications/vapid": "The instance's public Web Push key. Public by definition.", + "/api/auth/claim/preview": + "Previews a managed-profile handover link for a person who has no credential yet; the one-time token in the body is the credential.", + "/api/auth/claim": + "Turns a managed profile into the claimant's own account and creates their first credential; the one-time token in the body authorises it.", // The tombstones, sourced from the registry rather than restated one by one. // A retired path answers 410 from the proxy before any handler runs, and that // answer never reads the request's cookies or Authorization header — so it is diff --git a/src/__tests__/prefetch-page-caching-guard.test.ts b/src/__tests__/prefetch-page-caching-guard.test.ts index 883bb2c9d..609b32256 100644 --- a/src/__tests__/prefetch-page-caching-guard.test.ts +++ b/src/__tests__/prefetch-page-caching-guard.test.ts @@ -175,6 +175,7 @@ const PREFETCHING_ROUTES = [ "/checkups", "/coach", "/insights", + "/insights/mood", "/insights/workouts", "/medications", "/mood", diff --git a/src/__tests__/proxy-claim-public.test.ts b/src/__tests__/proxy-claim-public.test.ts new file mode 100644 index 000000000..cb903b791 --- /dev/null +++ b/src/__tests__/proxy-claim-public.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * v1.42 (#959) — `/claim/` is the managed-profile handover link. + * + * The twin of `proxy-invite-public.test.ts`: the link is a shape-validated + * edge redirect onto `/auth/claim?token=…`, it carries no session, and the + * hop is uncacheable, unindexable and never leaked as a `Referer`. The two API + * routes behind the page authenticate by the token in the request body, so + * they pass the page gate; on a demo instance they are refused like every + * other mutation off the allowlist. + */ + +vi.mock("@/lib/process-type", () => ({ + shouldRunWeb: () => true, +})); + +import { proxy, PUBLIC_PATHS } from "../proxy"; + +const VALID_TOKEN = `hlp_${"a".repeat(64)}`; + +function makeRequest( + pathname: string, + init: { cookies?: Record; method?: string } = {}, +): NextRequest { + const cookieHeader = Object.entries(init.cookies ?? {}) + .map(([k, v]) => `${k}=${v}`) + .join("; "); + return new NextRequest(`http://localhost${pathname}`, { + method: init.method ?? "GET", + headers: cookieHeader ? { cookie: cookieHeader } : {}, + }); +} + +describe("proxy.ts /claim public allowlist", () => { + it("lists the landing and the anonymous claim routes as public", () => { + expect(PUBLIC_PATHS).toContain("/claim/"); + expect(PUBLIC_PATHS).toContain("/api/auth/claim"); + }); + + it("does not bounce an unauthenticated /claim/ to /auth/login", () => { + const res = proxy(makeRequest(`/claim/${VALID_TOKEN}`)); + expect(res.headers.get("location") ?? "").not.toMatch(/\/auth\/login/); + }); + + it("does not bounce a pending-onboarding session off the link", () => { + const res = proxy( + makeRequest(`/claim/${VALID_TOKEN}`, { + cookies: { healthlog_session: "sess-1", hl_onboarding: "pending" }, + }), + ); + expect(res.headers.get("location") ?? "").not.toMatch(/\/onboarding/); + }); + + it("lets the anonymous preview and claim reach their handlers", () => { + for (const path of ["/api/auth/claim", "/api/auth/claim/preview"]) { + const res = proxy(makeRequest(path, { method: "POST" })); + expect(res.status, path).not.toBe(401); + expect(res.headers.get("location"), path).toBeNull(); + } + }); +}); + +describe("proxy.ts /claim edge redirect", () => { + it("307s a shape-valid token onto the claim page", () => { + const res = proxy(makeRequest(`/claim/${VALID_TOKEN}`)); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toBe( + `http://localhost/auth/claim?token=${VALID_TOKEN}`, + ); + }); + + it.each([ + ["an uppercase hex body", `hlp_${"A".repeat(64)}`], + ["a short body", `hlp_${"a".repeat(63)}`], + ["a long body", `hlp_${"a".repeat(65)}`], + ["an invite token", `hlv_${"a".repeat(64)}`], + ["markup", "%3Cscript%3E"], + ])("drops %s rather than echoing it into the target", (_label, segment) => { + const res = proxy(makeRequest(`/claim/${segment}`)); + expect(res.headers.get("location")).toBe("http://localhost/auth/claim"); + }); + + it("hardens the token-in-path hop against cache, index and referrer leaks", () => { + const res = proxy(makeRequest(`/claim/${VALID_TOKEN}`)); + expect(res.headers.get("Cache-Control")).toBe( + "no-store, no-cache, must-revalidate", + ); + expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow"); + expect(res.headers.get("Referrer-Policy")).toBe("no-referrer"); + }); +}); + +describe("proxy.ts DEMO_MODE refuses the handover", () => { + const original = process.env.DEMO_MODE; + afterEach(() => { + if (original === undefined) delete process.env.DEMO_MODE; + else process.env.DEMO_MODE = original; + }); + + it.each([ + ["POST", "/api/auth/claim"], + ["POST", "/api/auth/claim/preview"], + ["POST", "/api/managed-profiles/p1/handover"], + ["DELETE", "/api/managed-profiles/p1/handover"], + ["POST", "/api/account/handover-decision"], + ])("403s %s %s on a demo instance", async (method, path) => { + process.env.DEMO_MODE = "true"; + const res = proxy( + makeRequest(path, { + method, + cookies: { healthlog_session: "sess-1" }, + }), + ); + expect(res.status).toBe(403); + }); + + it("leaves the same claim alone off demo mode", () => { + delete process.env.DEMO_MODE; + const res = proxy(makeRequest("/api/auth/claim", { method: "POST" })); + expect(res.status).not.toBe(403); + }); +}); diff --git a/src/__tests__/pulse-day-mean-reader-guard.test.ts b/src/__tests__/pulse-day-mean-reader-guard.test.ts index 6f76b206d..1e418fc3a 100644 --- a/src/__tests__/pulse-day-mean-reader-guard.test.ts +++ b/src/__tests__/pulse-day-mean-reader-guard.test.ts @@ -114,6 +114,10 @@ const TS_READERS: Record = { via: "lib/ai/coach/snapshot-series.ts", passes: /buildDailyValueRows\([^)]*"PULSE"/, }, + "components/day/day-values-model.ts": { + exempt: + "means several readings of a type the day route sends one by one (weight, blood pressure, glucose); pulse arrives as one server-side day value (`dayValueShape` in lib/day/values.ts)", + }, "components/charts/health-chart.tsx": { exempt: "a rolling average over the points the chart draws: display smoothing, not a stated figure", diff --git a/src/__tests__/pwa-mobile-shell-guard.test.ts b/src/__tests__/pwa-mobile-shell-guard.test.ts new file mode 100644 index 000000000..d8edd594a --- /dev/null +++ b/src/__tests__/pwa-mobile-shell-guard.test.ts @@ -0,0 +1,162 @@ +/** + * Structural guards for the installed app on a phone — the parts no browser + * in CI can show, because neither Chromium nor WebKit under Playwright + * reports a safe-area inset or opens an on-screen keyboard. + * + * 1. The authenticated shell takes the status-bar and side insets once, + * on its outer wrapper, and no 4rem header band pads itself inside its + * own height (which on a phone with a 59 px inset left the top bar a + * 5 px content box and pushed the logo across its bottom border). + * 2. The stylesheet carries the rules the shell and the keyboard bridge + * depend on. + * 3. Every form-field primitive keeps 16 px type on a touch screen: iOS + * Safari zooms the page into a focused field below 16 px, and a width + * breakpoint alone (`md:text-sm`) handed 14 px to a phone held sideways + * and to every iPad. The small size is allowed only behind + * `pointer-fine:`. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const ROOT = resolve(__dirname, "../.."); +const read = (path: string) => readFileSync(resolve(ROOT, path), "utf8"); + +/** Drop `//` and block comments so prose about a class cannot satisfy a check. */ +function code(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:"'`])\/\/.*$/gm, "$1"); +} + +describe("the shell owns the safe area", () => { + it("the authenticated shell wrapper takes the insets", () => { + const shell = code(read("src/components/layout/auth-shell.tsx")); + expect(shell).toMatch(/className="shell-safe-area flex h-dvh flex-col"/); + }); + + it.each([ + "src/components/layout/top-bar.tsx", + "src/components/insights/coach-panel/conversations-panel.tsx", + "src/components/day/day-layer.tsx", + ])("%s pads no header band with the status-bar inset", (path) => { + const source = code(read(path)); + expect(source).not.toMatch(/paddingTop:\s*"env\(safe-area-inset-top/); + expect(source).not.toMatch(/\bpt-\[env\(safe-area-inset-top/); + }); + + it("globals.css defines the shell band, the keyboard rules and pointer-fine", () => { + const css = read("src/app/globals.css"); + expect(css).toMatch( + /\.shell-safe-area\s*\{[^}]*padding-top:\s*env\(safe-area-inset-top/, + ); + expect(css).toMatch( + /\[data-sheet-side="bottom"\]\s*\{\s*bottom:\s*var\(--keyboard-inset/, + ); + expect(css).toMatch( + /html\[data-keyboard="open"\] \[data-keyboard-hide\]\s*\{\s*display:\s*none/, + ); + expect(css).toMatch(/@custom-variant pointer-fine/); + }); + + it("bottom toasts clear the bar under the same query the bar follows", () => { + // Sonner's own mobile offset stops at 600 px; the bar shows up to `md` + // and on a phone held sideways, so the lift keys on `shell-mobile`. + const css = code(read("src/app/globals.css")); + expect(css).toMatch( + /html \[data-sonner-toaster\]\[data-y-position="bottom"\]\s*\{\s*@variant shell-mobile\s*\{\s*bottom:\s*calc\(4rem \+ env\(safe-area-inset-bottom/, + ); + }); + + it("the bottom bar and the Coach button step aside for the keyboard", () => { + expect(read("src/components/layout/bottom-nav.tsx")).toContain( + 'data-keyboard-hide=""', + ); + expect(read("src/components/insights/layout-coach-fab.tsx")).toContain( + 'data-keyboard-hide=""', + ); + }); + + it("a bottom sheet on the keyboard drops its home-indicator padding", () => { + // Standing on the keyboard, the safe-area inset left a ~34 px gap + // between the sheet's last row and the keys. + expect(code(read("src/app/globals.css"))).toMatch( + /@custom-variant keyboard-open \(html\[data-keyboard="open"\] &\);/, + ); + const sheet = code(read("src/components/ui/responsive-sheet.tsx")); + const padded = (sheet.match( + /"[^"]*pb-\[calc\(env\(safe-area-inset-bottom[^"]*"/g, + ) ?? []) as string[]; + expect(padded.length).toBe(2); + for (const cls of padded) expect(cls).toMatch(/\bkeyboard-open:pb-4\b/); + }); + + it("those rules sit under the utilities, and the bottom sheet sets no bottom utility", () => { + // Unlayered, the rules outranked every utility on the same element: a + // `bg-*` on the shell or a `bottom-*` on a sheet would have been ignored + // without a trace. In the components layer a utility wins, so the bottom + // sheet must not carry one that would pin it under the keyboard. + const css = code(read("src/app/globals.css")); + const start = css.indexOf("@layer components {"); + expect(start).toBeGreaterThan(-1); + let depth = 0; + let end = start; + for (let i = css.indexOf("{", start); i < css.length; i += 1) { + if (css[i] === "{") depth += 1; + else if (css[i] === "}" && --depth === 0) { + end = i; + break; + } + } + const layer = css.slice(start, end); + expect(layer).toMatch(/\.shell-safe-area\s*\{/); + expect(layer).toMatch(/\[data-sheet-side="bottom"\]\s*\{\s*bottom:/); + expect(layer).toMatch(/\[data-keyboard-hide\]\s*\{\s*display:\s*none/); + expect(layer).toMatch(/\[data-sheet-side="left"\]\s*\{\s*border-left:/); + // Sonner's own rules are unlayered; the toast lift must stay so. + expect(layer).not.toMatch(/data-sonner-toaster/); + + const sheet = code(read("src/components/ui/sheet.tsx")); + const bottomBranch = sheet.match(/side === "bottom" &&\s*"([^"]*)"/)?.[1]; + expect(bottomBranch).toBeDefined(); + expect(bottomBranch).not.toMatch(/(? { + expect(read("src/components/ui/sheet.tsx")).toMatch( + /data-sheet-side=\{side\}/, + ); + }); +}); + +describe("form fields keep 16 px on a touch screen", () => { + const FIELDS = [ + "src/components/ui/input.tsx", + "src/components/ui/textarea.tsx", + "src/components/ui/native-select.tsx", + "src/components/ui/date-field.tsx", + "src/components/ui/time-field.tsx", + ]; + + it.each(FIELDS)( + "%s sets text-base and shrinks only under a fine pointer", + (path) => { + // The field's own box is the class string that draws its border; the + // rest of the file (a picker's buttons, a caption) is not typed into. + const boxes = (code(read(path)).match( + /"[^"]*\bborder-input(?![\w-])[^"]*"/g, + ) ?? []) as string[]; + expect(boxes.length, `${path} has no field class`).toBeGreaterThan(0); + for (const box of boxes) { + expect(box).toMatch(/\btext-base\b/); + // Any sub-16 px size must sit behind `pointer-fine:`; `file:` styles + // the upload button, not typed text. + const bare = box.match( + /(? = [ ], ["src/app/api/cycle/day-logs/route.ts", "/api/cycle/day-logs"], ["src/app/api/vaccinations/route.ts", "/api/vaccinations"], + ["src/app/api/vaccinations/custom/route.ts", "/api/vaccinations/custom"], ["src/app/api/allergies/route.ts", "/api/allergies"], ["src/app/api/biomarkers/route.ts", "/api/biomarkers"], ["src/app/api/encounters/route.ts", "/api/encounters"], diff --git a/src/__tests__/resolved-field-consumer-guard.test.ts b/src/__tests__/resolved-field-consumer-guard.test.ts index b7461f861..d63da7a57 100644 --- a/src/__tests__/resolved-field-consumer-guard.test.ts +++ b/src/__tests__/resolved-field-consumer-guard.test.ts @@ -144,8 +144,8 @@ const RULES: ResolvedFieldRule[] = [ allow: { "src/components/settings/ai/disable-coach-card.tsx": "the Activate-Coach switch: it edits the stored opt-out, so its checked state is that value", - "src/components/settings/modules-section.tsx": - "the module toggle row for the Coach: its checked state is the stored opt-out it writes", + "src/hooks/use-module-toggle.ts": + "the module switch for the Coach (Settings hub and command palette): its checked state is the stored opt-out it writes", "src/components/settings/managed-record-settings-section.tsx": "the managed record's own settings form: the field's default is the record's stored opt-out", "src/hooks/use-auth.ts": @@ -169,6 +169,8 @@ const RULES: ResolvedFieldRule[] = [ allow: { "src/components/settings/modules-section.tsx": "the Modules hub: it shows the operator-off state of each toggle row, which is what `moduleAvailability` is published for", + "src/components/command-palette/use-palette-index.ts": + "the command palette's module switches: like the Modules hub it leaves out a switch the operator turned off, which is what `moduleAvailability` is published for", "src/hooks/use-auth.ts": "the `/me` payload normaliser: types and coerces the field, decides nothing", }, diff --git a/src/__tests__/retired-route-links-guard.test.ts b/src/__tests__/retired-route-links-guard.test.ts new file mode 100644 index 000000000..874c20989 --- /dev/null +++ b/src/__tests__/retired-route-links-guard.test.ts @@ -0,0 +1,48 @@ +/** + * Links point at a route that renders, not at a retired one that only + * redirects: every hop through a redirect is a round trip on a phone, and + * the redirect stubs exist for old bookmarks, not for the app's own links. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const ROOT = join(__dirname, "..", ".."); + +/** Retired route → where it redirects. */ +const RETIRED: Record = { + "/medications/new": "/medications?new=1", +}; + +function walk(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + if (name === "__tests__" || name === "generated") return []; + const abs = join(dir, name); + if (statSync(abs).isDirectory()) return walk(abs); + return /\.tsx?$/.test(name) ? [abs] : []; + }); +} + +/** Every regex metacharacter escaped, the backslash included. */ +function escapeRegExp(text: string): string { + return text.replace(/[\\^$.*+?()[\]{}|/]/g, "\\$&"); +} + +describe("no link targets a retired route", () => { + const files = walk(join(ROOT, "src")); + + it("scans the source tree", () => { + expect(files.length).toBeGreaterThan(100); + }); + + it.each(Object.entries(RETIRED))("%s is linked as %s", (route) => { + const pattern = new RegExp( + `(?:href|push|replace)[=(]\\s*\\{?\\s*["'\`]${escapeRegExp(route)}["'\`?#]`, + ); + const offenders = files + .filter((file) => pattern.test(readFileSync(file, "utf8"))) + .map((file) => relative(ROOT, file)); + expect(offenders).toEqual([]); + }); +}); diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts index c64e25563..a62b16f78 100644 --- a/src/__tests__/service-worker.test.ts +++ b/src/__tests__/service-worker.test.ts @@ -217,6 +217,10 @@ function bootServiceWorker(): SwHarness { URL, Promise, console, + // Read through the context so a test can install fake timers before + // dispatching (the slow-navigation fallback arms one per navigation). + setTimeout: (...args: Parameters) => setTimeout(...args), + clearTimeout: (id: ReturnType) => clearTimeout(id), }; vm.createContext(context); vm.runInContext(SW_SOURCE, context); @@ -617,6 +621,23 @@ describe("sw.js — networkFirst offline fallback", () => { }); }); +describe("sw.js — offline page", () => { + it("offers a retry control, reloads by itself when the connection returns, and follows the device scheme", async () => { + const harness = bootServiceWorker(); + const res = await dispatchNavigationFetch(harness, "/"); + expect(res.status).toBe(503); + const html = await res.text(); + // A real control, not a decorative icon: a link back to the same URL. + expect(html).toMatch(/ { it("does not cache a navigation response that carries Cache-Control: no-store", async () => { const harness = bootServiceWorker(); diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts index 324af37db..b06ac7b19 100644 --- a/src/__tests__/session-surface-guard.test.ts +++ b/src/__tests__/session-surface-guard.test.ts @@ -85,6 +85,17 @@ describe("S1 — the direct session-resolution set is frozen", () => { // here twice over: it would throw 401 at the anonymous caller the route // exists for, and this route wants the ABSENCE of a session. "app/api/auth/register/route.ts": "refuses to register over a live session", + // v1.42 (#959) — the anonymous managed-profile claim and its preview. Same + // argument as registration: claiming signs the new owner in, so a Guardian + // opening their own handover link must be refused rather than signed out + // of their account and into the person's. The probe only asks whether a + // session EXISTS; the credential these routes act on is the one-time + // `hlp_` token in the body, and the probe is resolved in the route file + // itself (not inside `claimGate`) so both stay visible on this list. + "app/api/auth/claim/route.ts": + "refuses to claim a profile over a live session", + "app/api/auth/claim/preview/route.ts": + "refuses to preview a handover link over a live session", // Anonymous IdP callback. Probes for an already-valid session so a // replayed callback redirects instead of minting a second identity. "app/api/auth/oidc/callback/route.ts": @@ -127,6 +138,8 @@ describe("S1 — the direct session-resolution set is frozen", () => { "RSC prefetch of the session user's coach state, skipped under a switch", "app/insights/page.tsx": "RSC prefetch of the session user's insights, skipped under a switch", + "app/insights/mood/page.tsx": + "RSC prefetch of the session user's mood calendar and line, skipped under a switch", "app/insights/workouts/page.tsx": "RSC prefetch of the session user's workouts, skipped under a switch", "app/medications/page.tsx": @@ -223,6 +236,9 @@ describe("S2 — the URL-token authentication set is frozen", () => { "app/c/[token]/d/[id]/route.ts": "serves a shared document's bytes", "app/c/[token]/fhir/route.ts": "serves the shared record as a FHIR bundle", "app/c/[token]/report.pdf/route.ts": "serves the shared record as a PDF", + // The segment's not-found page: the copy a dead link shows. It reads no + // token and resolves nothing; the page above decides, and answers 404. + "app/c/[token]/not-found.tsx": "renders the dead-link copy; reads no token", // The passphrase gate for the same token. Anonymous by design; mints the // token-scoped unlock cookie the view above checks. "app/api/c/[token]/unlock/route.ts": "verifies the share-link passphrase", @@ -243,7 +259,18 @@ describe("S2 — the URL-token authentication set is frozen", () => { // Listed because it is a URL-token surface, with the reason being that // it authenticates nothing. "app/invite/[token]/page.tsx": - "validates the invite token's shape and redirects; no lookup", + "validates the invite token's shape and redirects, or says the link is invalid; no lookup", + // v1.42 (#959) — the managed-profile handover link. The landing is the + // invite landing's twin: a shape check on the `hlp_` segment and a + // redirect, no lookup (the proxy normally answers it at the edge). + "app/claim/[token]/page.tsx": + "validates the handover token's shape and redirects; no lookup", + // The claim page reads the token off `?token=` and sends it in the BODY of + // the anonymous preview and claim requests, which are the only places a + // handover token is resolved. The page itself authenticates nothing and + // renders nothing about the profile before the preview answers. + "app/auth/claim/page.tsx": + "carries the handover token from the query string into the claim requests' body", }; /** @@ -281,8 +308,10 @@ describe("S2 — the URL-token authentication set is frozen", () => { "app/api/withings/webhook/[token]/route.ts", "app/c/[token]/d/[id]/route.ts", "app/c/[token]/fhir/route.ts", + "app/c/[token]/not-found.tsx", "app/c/[token]/page.tsx", "app/c/[token]/report.pdf/route.ts", + "app/claim/[token]/page.tsx", "app/invite/[token]/page.tsx", ]); }); @@ -304,7 +333,10 @@ describe("S2 — the URL-token authentication set is frozen", () => { const byQuery = appFilesMatching(QUERY_CREDENTIAL); expect(byQuery.length).toBeGreaterThan(0); - expect(byQuery).toEqual(["app/api/withings/webhook/route.ts"]); + expect(byQuery).toEqual([ + "app/api/withings/webhook/route.ts", + "app/auth/claim/page.tsx", + ]); }); it("the three legs together equal the frozen allowlist", () => { diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index e2177cc3e..b7b768b4b 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -971,6 +971,14 @@ const DELEGABLE_ROUTES: Record = { domain: "profile", why: "Which of the record's doses a scanned page dated around a given day belongs to. A read over the same rows the immunization list serves, reduced to a verdict — the caller learns nothing it could not learn from the list itself, and the anchor it passes is a date rather than an id, so it cannot address a row.", }, + "app/api/vaccinations/custom/route.ts": { + domain: "profile", + why: "The record's own vaccine definitions: a name, the antigens from the catalogue's closed list, a series length and a booster interval. Part of the immunization history the dose list already shows a `profile` grant, and it names no credential, no integration and no notification channel. Every row is read and written under the resolved user id. The create arm is a delegable write.", + }, + "app/api/vaccinations/custom/[id]/route.ts": { + domain: "profile", + why: "One of the record's own vaccine definitions, fetch-then-guard against the resolved user. Reached only through its MANAGE arms; a definition another record holds is a 404 like any foreign id.", + }, "app/api/practitioners/route.ts": { domain: "profile", why: "The record's own address book of doctors and practices. Record content, not account configuration — it touches no credential, no integration and no notification channel, which is the fence the classification turns on. The create arm is a delegable write.", @@ -1007,6 +1015,36 @@ const DELEGABLE_ROUTES: Record = { domain: "illness", why: "One symptom occurrence of the record, fetch-then-guard against the resolved user. Reached only through this module's MANAGE arm.", }, + // v1.42 (#613) — the day, its index and the visit preparation are admitted + // on the readings and narrowed section by section after that: every other + // section is read only when the grant also covers that section's own + // domain (`actingDomainVisibility`), and a section it does not cover is + // named `not_shared` and never read. The section-to-domain map + // (`src/lib/day/sections.ts`) is the one the routes serving those rows + // declare here, so the day never shows more of a section than its own list + // would. The timeline and its readiness lay every section side by side and + // stay whole-record reads; their lanes are narrowed the same way all the + // same. + "app/api/day/[date]/route.ts": { + domain: "measurements", + why: "One local day of the record: the readings in its window, and the other sections only where the grant covers each one's own domain, the rest named not_shared and never read. Read-only, every row scoped to the resolved user, decrypted only for the session that resolved it.", + }, + "app/api/day/index/route.ts": { + domain: "measurements", + why: "Which days of a bounded window hold anything, by section key, narrowed per section like the day. Dates and section names only, no values and no free text; every read scoped to the resolved user.", + }, + "app/api/day/notable/route.ts": { + domain: "measurements", + why: "The preparation of the next visit: notable days of the readings and the context changes of the sections the grant covers, as keys and the record's own names. Read-only, scoped to the resolved user, narrowed per section like the day.", + }, + "app/api/timeline/route.ts": { + domain: "record", + why: "The record over the years: spans and points from every section plus value series. Read-only, scoped to the resolved user, module-gated on the record's own `timeline` switch.", + }, + "app/api/timeline/readiness/route.ts": { + domain: "record", + why: "Counts per timeline lane with an in-app link per gap. Counts only, no record content; scoped to the resolved user and module-gated like the timeline.", + }, "app/api/workouts/route.ts": { domain: "measurements", why: "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.", @@ -1144,6 +1182,10 @@ const DELEGABLE_ROUTES: Record = { domain: "documents", why: "The record's vault state: quota, the filter bar's condition chips, and index coverage. Without it the admitted list loses its filters. The `assistAvailable` boolean is the same integration-adjacent availability flag `nutrients` already returns — no credential, endpoint or token — and every AI action it would gate stays refused. No write arm exists.", }, + "app/api/documents/inbound/layout/route.ts": { + domain: "documents", + why: "The record's vault presentation, read only: two closed enums (card or list view, stacked or flowing months) and nothing else. The admitted list renders through it, so a refused read would leave a delegate's vault waiting on a shape it can never learn. The PUT stays bare — how the owner's vault is laid out is the owner's choice, the same split `/api/medications/layout` keeps.", + }, /* ------------------------------------------------------------------ */ /* v1.37.0 — the fifty-one modules that became delegable at MANAGE. */ @@ -1372,6 +1414,10 @@ const DELEGABLE_ROUTES: Record = { domain: "record", why: "Rhythm events over the record's own recordings. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", }, + "app/api/insights/score-history/route.ts": { + domain: "record", + why: "A score's daily course: the stored health-score days, the nightly readiness rows, the per-night sleep score. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, "app/api/insights/targets/route.ts": { domain: "record", why: "The record's resolved targets. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", @@ -1457,6 +1503,8 @@ const DELEGABLE_WRITE_ROUTES: Record = { "Logging a dose. Every id the body may carry — the practitioner, the visit, the pages to file it against — is re-narrowed to the resolved record before anything is written, so a delegate cannot attach one record's scan to another's dose. The booster reminders it clears are the RECORD's, which is correct: it is the owner's booster plan, and a helper transcribing an Impfpass is doing exactly the work that should settle it.", "app/api/vaccinations/[id]/booster/route.ts": "Confirming the booster reminder a dose suggests. The reminder is minted under the RECORD and keyed on the antigen the server reads from the dose's catalogue entry, never from the body, so a delegate cannot point it at an antigen the dose does not contain; a second confirmation re-anchors the one reminder rather than minting another.", + "app/api/vaccinations/custom/route.ts": + "Adding a vaccine definition to the record. It lands under the RECORD, which is correct: it is the owner's Impfpass a helper is transcribing, and the doses logged against it are the owner's. The rate bucket keys on the ACTOR, and a name the record already holds is the ordinary 409 the owner would hit themselves.", "app/api/medications/[id]/side-effects/route.ts": "Recording a side effect. Admitted on one condition, met at the call site: the POST rate bucket keys on the ACTOR, so a delegate burns their own allowance rather than the owner's and cannot collect a fresh one by switching records.", "app/api/medications/route.ts": @@ -1603,6 +1651,11 @@ const DELEGABLE_MANAGE_ROUTES: Record = { conditions: [], why: "Reopening a deleted dose. The document links survived the tombstone, so it comes back with its page still filed against it.", }, + "app/api/vaccinations/custom/[id]/route.ts": { + domain: "profile", + conditions: ["C4"], + why: "Correcting and removing one vaccine definition. The removal soft-deletes it and lets go of the doses that named it, each keeping a name, so no dose leaves the record; the audit row names the definition and how many doses it let go. C4 on the edit: the antigens, the series length and the booster interval are filed before and after, and a rename is named, never quoted. Neither verb re-runs the booster satisfy matcher, so a delegate cannot move a booster's due date by correcting a definition.", + }, "app/api/illness/episodes/[id]/resolve/route.ts": { domain: "illness", conditions: [], @@ -1883,6 +1936,11 @@ const DELEGABLE_MANAGE_ROUTES: Record = { conditions: [], why: "Rhythm events over the record's own recordings.", }, + "app/api/insights/score-history/route.ts": { + domain: "record", + conditions: [], + why: "A score's daily course, read from the same rows the score pages and the day view read. Deterministic, no provider, nothing enqueued.", + }, "app/api/insights/targets/route.ts": { domain: "record", conditions: [], @@ -2010,8 +2068,33 @@ const ACTOR_ROUTES: Record = { * list, the occurrence create on the write literal, and the definition create, * the definition edit/delete and the occurrence edit/delete on the manage * literal. 232 -> 242 with both. + * + * v1.42 -- the person's own vaccine definitions (#1005) add four: both + * modules on the record list, the create on the write literal and the + * edit/delete on the manage literal. 242 -> 246. + * + * v1.42 -- the day view and the timeline add five reads on the record list: + * the day, the day index, the timeline, its readiness inventory and the + * life-event list in `profile`. 246 -> 251. + * + * v1.42 -- the visit preparation joins the record list beside the day, and + * the life-event writes join it with their audit rows: the edit/delete module + * on the record list, the create and the edit/delete on the manage literal. + * 251 -> 255. + * + * v1.42 -- the document vault's presentation read joins the record list + * beside the rest of the vault; its PUT stays bare. 255 -> 256. + * + * v1.42 -- life events leave every list: they are owner-only in this + * release (no share level was worded for them), so both modules take a bare + * `requireAuth()` and drop off the record list and the manage literal. + * 256 -> 252. + * + * v1.42 — the score-history read joins the record list and the manage + * literal (a score's daily course, MANAGE like the derived score itself). + * 252 -> 254. */ -const FROZEN_ENTRY_COUNT = 242; +const FROZEN_ENTRY_COUNT = 254; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -2772,7 +2855,7 @@ describe("(g) the MANAGE route set is frozen", () => { it("keeps the admitted mutation inventory complete and discoverable", () => { expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0); - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(84); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(87); const expected = ADMITTED_MUTATING_HANDLERS.map( ({ handlerModule, action, level }) => diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 64fb05022..aba2dbe9e 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.ts @@ -82,6 +82,9 @@ const PINNED_AFFORDANCES: Record< // the two status grids used to spell the tint at every call site and now // name a tone instead, so the marker lives here once. "src/components/admin/_shared.tsx": { "text-success": 3, "toast.success": 1 }, + // v1.42: the readiness inventory's "carries" status icon, one arm of the + // carries / thin / empty tone table, beside its spoken status label. + "src/components/timeline/readiness-inventory.tsx": { "text-success": 1 }, "src/components/admin/ai-server-key-section.tsx": { "toast.success": 1 }, "src/components/admin/api-token-overview-section.tsx": { "text-success": 1 }, "src/components/admin/app-log-preview-section.tsx": { @@ -97,6 +100,11 @@ const PINNED_AFFORDANCES: Record< "toast.success": 1, }, "src/components/admin/glitchtip-section.tsx": { "toast.success": 1 }, + // Fires only when the route answered `enqueued: true`; a run already queued + // reads as an info toast instead, and the outcome itself is the status line. + "src/components/admin/measurement-maintenance-card.tsx": { + "toast.success": 1, + }, "src/components/admin/invite-tokens-section.tsx": { "text-success": 2, "toast.success": 3, @@ -371,10 +379,9 @@ const PINNED_AFFORDANCES: Record< "text-success": 2, }, "src/components/settings/mcp-section.tsx": { - "text-success": 5, + "text-success": 4, "toast.success": 2, }, - "src/components/settings/modules-section.tsx": { "toast.success": 1 }, "src/components/settings/notification-status-card.tsx": { CheckCircle2: 2, "text-success": 1, diff --git a/src/__tests__/ui-code-no-dash-separator.test.ts b/src/__tests__/ui-code-no-dash-separator.test.ts new file mode 100644 index 000000000..92c913376 --- /dev/null +++ b/src/__tests__/ui-code-no-dash-separator.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +/** + * The code-side twin of `i18n-no-dot-separator.test.ts`: a string the code + * composes itself (an aria-label `${action} — ${name}`, a page title, a + * Coach prefill) carries no spaced em or en dash either. The bundles say + * "Move up: Ramipril" and "No connection: changes can't be saved", so a + * label put together in a component says it the same way. + * + * The matcher looks at string and template literals on one line, outside + * comments: a quote or backtick, then text, a space, U+2014 or U+2013, a + * space. A range written in code ("10–12", `${from}–${to}`) has no spaces + * around its dash and is not matched. A template literal broken across lines + * escapes the one-line matcher, which is this guard's known limit. + */ + +const ROOT = join(__dirname, ".."); +const DIRS = ["components", "app"].map((d) => join(ROOT, d)); +const LITERAL = /(["'`])[^"'`]* [—–] [^"'`]*\1/; + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) { + if (name === "__tests__") continue; + walk(path, out); + } else if (/\.(ts|tsx)$/.test(name) && !/\.test\./.test(name)) { + out.push(path); + } + } + return out; +} + +const isComment = (line: string) => /^\s*(\/\/|\*|\/\*|\{\/\*)/.test(line); + +describe("composed UI strings carry no spaced dash", () => { + const files = DIRS.flatMap((d) => walk(d)); + + it("walks the component and app trees", () => { + expect(files.length).toBeGreaterThan(500); + }); + + it("has no ' — ' or ' – ' inside a string or template literal", () => { + const offenders: string[] = []; + for (const file of files) { + readFileSync(file, "utf8") + .split("\n") + .forEach((line, i) => { + if (isComment(line)) return; + const code = line.replace(/\s\/\/\s.*$/, ""); + if (LITERAL.test(code)) { + offenders.push(`${relative(ROOT, file)}:${i + 1}: ${line.trim()}`); + } + }); + } + expect(offenders).toEqual([]); + }); + + it("catches the pattern it is written for", () => { + expect(LITERAL.test("aria-label={`${a} — ${b}`}")).toBe(true); + expect(LITERAL.test('title: "About – HealthLog"')).toBe(true); + expect(LITERAL.test("`${from}–${to}`")).toBe(false); + }); +}); diff --git a/src/__tests__/user-column-backup-classification.test.ts b/src/__tests__/user-column-backup-classification.test.ts index a5fa12e90..111f1dbb5 100644 --- a/src/__tests__/user-column-backup-classification.test.ts +++ b/src/__tests__/user-column-backup-classification.test.ts @@ -51,18 +51,21 @@ describe("User column backup classification", () => { ); }); + // The person's own data, sealed at rest for privacy rather than a secret + // that grants anything: the insurance number, and since v1.42 the home + // location the environment module resolves weather against. + const SEALED_SETTINGS = ["insuranceNumberEncrypted", "homeLocationEncrypted"]; + it("carries no credential-shaped column as a setting", () => { - // The insurance number is the one sealed setting: it is the person's own - // data, sealed at rest for privacy, not a secret that grants anything. const sealedSettings = ACCOUNT_SETTING_COLUMNS.filter((column) => /Encrypted$|Token|Secret|password|totp|oidc/i.test(column), ); - expect(sealedSettings).toEqual(["insuranceNumberEncrypted"]); + expect(sealedSettings.sort()).toEqual([...SEALED_SETTINGS].sort()); }); - it("never classifies a sealed column other than the insurance number as anything but a credential", () => { + it("never classifies a sealed column other than the sealed settings as anything but a credential", () => { const sealed = columns.filter( - (c) => /Encrypted$/.test(c) && c !== "insuranceNumberEncrypted", + (c) => /Encrypted$/.test(c) && !SEALED_SETTINGS.includes(c), ); expect(sealed.length).toBeGreaterThan(20); expect(sealed.filter((c) => classified[c] !== "CREDENTIAL")).toEqual([]); diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts index 860b45b45..82ba66592 100644 --- a/src/__tests__/v137-wave-zero-fixtures.test.ts +++ b/src/__tests__/v137-wave-zero-fixtures.test.ts @@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => { }); it("keeps every admitted mutation uniquely addressable and fully controlled", () => { - expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(84); + expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(87); expect( new Set( ADMITTED_MUTATING_HANDLERS.map( diff --git a/src/__tests__/workout-sport-provenance-guard.test.ts b/src/__tests__/workout-sport-provenance-guard.test.ts index 72195978b..f135279b1 100644 --- a/src/__tests__/workout-sport-provenance-guard.test.ts +++ b/src/__tests__/workout-sport-provenance-guard.test.ts @@ -107,6 +107,15 @@ const PROVIDER_INGESTS: ProviderIngest[] = [ writeFile: "lib/measurements/import-apple-health-export.ts", metadataKey: "activityType", }, + { + provider: "Health Connect export", + mapper: "mapHealthConnectSportType", + mapperFile: "lib/import/health-connect/mapper.ts", + captureFile: "lib/import/health-connect/import.ts", + captureField: "exercise_type", + writeFile: "lib/import/health-connect/import.ts", + metadataKey: "healthConnectExerciseType", + }, { provider: "Fitbit", mapper: "mapFitbitSportType", diff --git a/src/app/__tests__/dashboard-hero-wiring.test.ts b/src/app/__tests__/dashboard-hero-wiring.test.ts index 5242915c0..b6c5811cb 100644 --- a/src/app/__tests__/dashboard-hero-wiring.test.ts +++ b/src/app/__tests__/dashboard-hero-wiring.test.ts @@ -42,6 +42,17 @@ describe("dashboard hero — page wiring", () => { ); }); + it("hides the top card, its skeleton and its error card only on an explicit false", () => { + // One gate in front of the whole three-way branch, so switching the card + // off leaves no reserve and no retry card where it stood. + const gate = src.indexOf("layout.todayCardVisible === false ? null :"); + expect(gate).toBeGreaterThan(-1); + const hero = src.indexOf(" { expect(src).not.toContain("DashboardHero"); expect(src).not.toContain("DashboardHeroSkeleton"); diff --git a/src/app/__tests__/error-chunk-reload-guard.test.ts b/src/app/__tests__/error-chunk-reload-guard.test.ts index 5e2c5a55a..96cd5ec36 100644 --- a/src/app/__tests__/error-chunk-reload-guard.test.ts +++ b/src/app/__tests__/error-chunk-reload-guard.test.ts @@ -1,5 +1,6 @@ /** - * Unit tests for the chunk-error auto-reload guard in `src/app/error.tsx`. + * Unit tests for the chunk-error auto-reload guard in + * `src/lib/pwa/chunk-reload.ts` (both error boundaries call it). * * The guard is keyed on the running shell's build version: one reload * attempt per BROKEN SHELL, not one per session. After a successful @@ -10,7 +11,11 @@ import { describe, expect, it } from "vitest"; -import { chunkReloadGuardValue, shouldAttemptChunkReload } from "../error"; +import { + chunkReloadGuardValue, + isChunkLoadError, + shouldAttemptChunkReload, +} from "@/lib/pwa/chunk-reload"; describe("shouldAttemptChunkReload", () => { it("reloads when no attempt is recorded", () => { @@ -39,3 +44,39 @@ describe("shouldAttemptChunkReload", () => { expect(shouldAttemptChunkReload("unversioned", "")).toBe(false); }); }); + +describe("isChunkLoadError", () => { + it.each([ + [{ name: "ChunkLoadError", message: "x" }], + [{ name: "Error", message: "Loading chunk 123 failed." }], + [{ name: "Error", message: "Loading CSS chunk 7 failed." }], + [ + { + name: "Error", + message: "Failed to load chunk /_next/static/chunks/a.js", + }, + ], + [ + { + name: "TypeError", + message: "Failed to fetch dynamically imported module: /x.js", + }, + ], + // WebKit and Firefox word a failed dynamic import differently. + [{ name: "TypeError", message: "Importing a module script failed." }], + [ + { + name: "TypeError", + message: "error loading dynamically imported module: /x.js", + }, + ], + ])("recognises %j", (err) => { + expect(isChunkLoadError(err)).toBe(true); + }); + + it("leaves other errors to the error page", () => { + expect( + isChunkLoadError({ name: "TypeError", message: "x is undefined" }), + ).toBe(false); + }); +}); diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 45d3e0e4e..69e0134a6 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -1,6 +1,10 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { resolveIntlLocale } from "@/lib/format-locale"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; + /** * v1.4.27 B3 — Public about / credits page. * @@ -40,7 +44,7 @@ export const dynamic = "force-dynamic"; const LAST_UPDATED = "2026-08-02"; export const metadata: Metadata = { - title: "About — HealthLog", + title: "About | HealthLog", description: "Open-source credits and third-party data attributions for the HealthLog project.", robots: { index: true, follow: true }, @@ -60,14 +64,24 @@ function Section({

{title}

-
+
{children}
); } -export default function AboutPage() { +export default async function AboutPage() { + // The headings, the date and the sign-in link follow the reader's locale; + // the body is the attribution text the licences ask for and stays as + // written. + const locale = await resolveServerLocale(); + const { t } = getServerTranslator(locale); + const lastUpdated = new Intl.DateTimeFormat(resolveIntlLocale(locale), { + dateStyle: "medium", + timeZone: "UTC", + }).format(new Date(`${LAST_UPDATED}T12:00:00Z`)); + return (
@@ -82,7 +96,7 @@ export default function AboutPage() { href="/auth/login" className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-sm" > - Sign in + {t("auth.login")}
@@ -99,17 +113,19 @@ export default function AboutPage() { >

- About + {t("aboutPage.eyebrow")}

- About HealthLog + {t("aboutPage.title")}

- Last updated: {LAST_UPDATED} +

-
+

HealthLog is an open-source, self-hostable personal-health-tracking application. The source code lives at{" "} @@ -117,7 +133,7 @@ export default function AboutPage() { href="https://github.com/MBombeck/HealthLog" target="_blank" rel="noopener noreferrer" - className="text-primary hover:underline" + className="text-primary underline underline-offset-2" > github.com/MBombeck/HealthLog {" "} @@ -127,7 +143,7 @@ export default function AboutPage() {

-
+

HealthLog stands on a number of open-source libraries and public data sources. The list below covers the third-party assets that ship @@ -145,7 +161,7 @@ export default function AboutPage() { href="https://www.maxmind.com" target="_blank" rel="noopener noreferrer" - className="text-primary hover:underline" + className="text-primary underline underline-offset-2" > www.maxmind.com @@ -164,7 +180,7 @@ export default function AboutPage() { href="https://www.maxmind.com/en/geolite/eula" target="_blank" rel="noopener noreferrer" - className="text-primary hover:underline" + className="text-primary underline underline-offset-2" > GeoLite End User License Agreement @@ -173,7 +189,7 @@ export default function AboutPage() { href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener noreferrer" - className="text-primary hover:underline" + className="text-primary underline underline-offset-2" > Creative Commons Attribution-ShareAlike 4.0 International License {" "} diff --git a/src/app/achievements/__tests__/page.test.tsx b/src/app/achievements/__tests__/page.test.tsx index e29878045..5f11c7a5b 100644 --- a/src/app/achievements/__tests__/page.test.tsx +++ b/src/app/achievements/__tests__/page.test.tsx @@ -132,8 +132,6 @@ const mockData = { ] satisfies AchievementProgress[], metrics: { totalTakenIntakes: 1, - overIntakeCount: 0, - skippedIntakeCount: 0, bmiGreenStreak: 0, bpGreenStreak: 0, pulseGreenStreak: 0, diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index 57d1d4f8a..6c0fe99b0 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -35,7 +35,7 @@ import { PageHeader } from "@/components/ui/page-header"; import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { useAuth } from "@/hooks/use-auth"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { formatDate } from "@/lib/format"; import { useAchievementsQuery } from "@/lib/queries/use-achievements-query"; import { @@ -81,12 +81,13 @@ const CATEGORY_LABEL_KEY: Record = { function formatMetric( format: AchievementProgress["format"], value: number, - t: (key: string, params?: Record) => string, + i18n: Pick, "t" | "tCount">, ) { if (format === "percent") - return t("achievements.metricPercent", { count: value }); - if (format === "days") return t("achievements.metricDays", { count: value }); - return t("achievements.metricCount", { count: value }); + return i18n.t("achievements.metricPercent", { count: value }); + // "1 Tag", not "1 Tage": the day count takes the plural form. + if (format === "days") return i18n.tCount("achievements.metricDays", value); + return i18n.t("achievements.metricCount", { count: value }); } /** @@ -127,9 +128,11 @@ export function groupByCategory( interface AchievementCardProps { achievement: AchievementProgress; t: ReturnType["t"]; + tCount: ReturnType["tCount"]; } -function AchievementCard({ achievement, t }: AchievementCardProps) { +function AchievementCard({ achievement, t, tCount }: AchievementCardProps) { + const i18n = { t, tCount }; const Icon = iconMap[achievement.icon] ?? Star; const unlocked = achievement.unlocked; @@ -175,7 +178,7 @@ function AchievementCard({ achievement, t }: AchievementCardProps) { className={ unlocked ? "border-primary/30 from-primary/8 to-primary/0 rounded-xl border bg-gradient-to-br p-3" - : "border-border bg-card/50 rounded-xl border p-3 opacity-70" + : "border-border bg-card/50 rounded-xl border p-3" } >

@@ -191,7 +194,7 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {

{t(achievement.titleKey)}

-

+

{t(achievement.descriptionKey)}

@@ -215,8 +218,16 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {

{t("achievements.criterionHint", { - current: formatMetric(achievement.format, achievement.current, t), - target: formatMetric(achievement.format, achievement.target, t), + current: formatMetric( + achievement.format, + achievement.current, + i18n, + ), + target: formatMetric( + achievement.format, + achievement.target, + i18n, + ), })}

{/* v1.4.33 IW9 — aria-label so the bar carries an accessible name. */} @@ -224,8 +235,16 @@ function AchievementCard({ achievement, t }: AchievementCardProps) { value={achievement.progressPercent} className="h-1.5" aria-label={t("achievements.criterionHint", { - current: formatMetric(achievement.format, achievement.current, t), - target: formatMetric(achievement.format, achievement.target, t), + current: formatMetric( + achievement.format, + achievement.current, + i18n, + ), + target: formatMetric( + achievement.format, + achievement.target, + i18n, + ), })} />
@@ -259,7 +278,9 @@ function AchievementCard({ achievement, t }: AchievementCardProps) { export default function AchievementsPage() { const { user, isAuthenticated, isLoading: authLoading } = useAuth(); - const { t } = useTranslations(); + const { t, tCount } = useTranslations(); + const fmt = useFormatters(); + const i18n = { t, tCount }; // v1.18.0 — the achievements module gate. When the account has the // module turned off the whole page disappears: an empty state stands in @@ -335,9 +356,9 @@ export default function AchievementsPage() { {t("achievements.points")}

- {summary?.earnedPoints ?? 0} + {fmt.integer(summary?.earnedPoints ?? 0)} - / {summary?.totalPoints ?? 0} + / {fmt.integer(summary?.totalPoints ?? 0)}

{t(summary.nextAchievement.titleKey)}

-

+

{t(summary.nextAchievement.descriptionKey)}

@@ -381,13 +402,13 @@ export default function AchievementsPage() { {formatMetric( summary.nextAchievement.format, summary.nextAchievement.current, - t, + i18n, )}{" "} /{" "} {formatMetric( summary.nextAchievement.format, summary.nextAchievement.target, - t, + i18n, )}{" "} ({summary.nextAchievement.progressPercent}%)

@@ -449,6 +470,7 @@ export default function AchievementsPage() { key={achievement.id} achievement={achievement} t={t} + tCount={tCount} /> ))}
diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index cfb9fdc8e..d927a353e 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -5,7 +5,7 @@ * a given slug. Lives next to the route page so the shell can stay a * server component while the auth-gated body remains a client component. * - * This is the equivalent of `SECTION_COMPONENTS` in + * This is the equivalent of `SettingsSectionLoader` for * `src/app/settings/[section]/page.tsx`, lifted to a separate file so * the route page can be a server component (Settings can keep * `"use client"`-leaning components in its page because none of them @@ -36,6 +36,9 @@ import { ModuleAvailabilitySection } from "@/components/admin/module-availabilit import { RemindersSection } from "@/components/admin/reminders-section"; import { ServicesSection } from "@/components/admin/services-section"; import { SystemStatusSection } from "@/components/admin/system-status-section"; +import { RevealWhenSettled } from "@/components/ui/reveal-when-settled"; +import { Skeleton } from "@/components/ui/skeleton"; +import { MeasurementMaintenanceCard } from "@/components/admin/measurement-maintenance-card"; import { InviteTokensSection } from "@/components/admin/invite-tokens-section"; import { UserManagementSection } from "@/components/admin/user-management-section"; import type { AdminSectionSlug } from "@/components/admin/section-slugs"; @@ -58,9 +61,18 @@ export function AdminSectionRenderer({ switch (slug) { case "system-status": return ( - - - + // The status cards grow as their reads land (host metrics, workers, + // queues); revealed together so the maintenance card below is not + // pushed down the page three times while they load. + } + > + + + {/* v1.42 — the table maintenance after the tombstone purge. */} + + + ); case "general": return ( diff --git a/src/app/api/__tests__/ai-route-inventory.ts b/src/app/api/__tests__/ai-route-inventory.ts index 3e314e53d..78d509cef 100644 --- a/src/app/api/__tests__/ai-route-inventory.ts +++ b/src/app/api/__tests__/ai-route-inventory.ts @@ -137,11 +137,6 @@ export const AI_ROUTES: Readonly> = { capabilities: ["coach"], why: "The unread signal of a Coach that is unavailable is the quiet empty shape, never a refusal.", }, - "src/app/api/insights/coach/seeded-question/route.ts": { - kind: "mixed", - capabilities: ["coach"], - why: "The opener exists only to open the Coach: `signal: null` while it is unavailable.", - }, "src/app/api/coach/about-me/route.ts": { kind: "mixed", capabilities: ["aboutMeQuestions"], @@ -215,6 +210,8 @@ export const DATA_ROUTES: Readonly> = { "src/app/api/insights/ecg/[id]/route.ts": "One device recording's waveform.", "src/app/api/insights/rhythm-events/route.ts": "The device's own flagged events, verbatim.", + "src/app/api/insights/score-history/route.ts": + "A score's stored or computed daily values; no model text.", "src/app/api/insights/cards/route.ts": "Rule alerts from the threshold engine; `provider` is `rules`.", "src/app/api/insights/correlations/route.ts": diff --git a/src/app/api/__tests__/inline-schema-strictness-inventory.test.ts b/src/app/api/__tests__/inline-schema-strictness-inventory.test.ts index 034df0f81..80a5dc67f 100644 --- a/src/app/api/__tests__/inline-schema-strictness-inventory.test.ts +++ b/src/app/api/__tests__/inline-schema-strictness-inventory.test.ts @@ -67,6 +67,10 @@ const DELIBERATELY_OPEN: Record = { reason: "hand-built query object", }, "insights/derived/route.ts": { count: 1, reason: "hand-built query object" }, + "insights/score-history/route.ts": { + count: 1, + reason: "hand-built query object", + }, "insights/metric-status/route.ts": { count: 1, reason: "hand-built query object", @@ -119,6 +123,14 @@ const DELIBERATELY_OPEN: Record = { count: 1, reason: "read filter over whole searchParams", }, + "medications/compliance/route.ts": { + count: 1, + reason: "read filter over whole searchParams", + }, + "mood/insights/route.ts": { + count: 1, + reason: "read filter over whole searchParams", + }, // --------------------------------------------------------------- // 3. Native-client ingest and device registration. This is where the diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index 20d98d73a..64d8ba407 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -193,6 +193,11 @@ const MODULE_ROUTE_TREES_BY_KEY: Readonly< // data-layer-gated: every `/api/vaccinations/*` route is raw CRUD over the // person's own doses, so all six are EXEMPT below under the same reasoning. vaccinations: ["src/app/api/vaccinations"], + // v1.42 (#613) — the life timeline. The timeline reads and the readiness + // inventory gate on `timeline`; the life-event row store beside them is + // EXEMPT below under the data-layer reasoning. The day view is not this + // module's: it is core and lives under `/api/day`, which no key owns. + timeline: ["src/app/api/timeline", "src/app/api/life-events"], }; /** Every declared tree, de-duplicated — the set this inventory walks. */ @@ -402,12 +407,25 @@ const EXEMPT_ROUTES: ReadonlyArray = [ "src/app/api/vaccinations/[id]/route.ts", "src/app/api/vaccinations/[id]/restore/route.ts", "src/app/api/vaccinations/[id]/links/route.ts", + // v1.42 (#1005) — the record's own vaccine definitions. Same posture: a + // restore must bring back the definitions its doses point at with the + // surface hidden. + "src/app/api/vaccinations/custom/route.ts", + "src/app/api/vaccinations/custom/[id]/route.ts", // The booster mint and the upload suggestion are the same posture: a restore // or an import that arms a booster, or a document review that files a scan, // must keep working with the surface hidden, so the data routes stay exempt // while the nav entry, the picker and the report leaf hide. "src/app/api/vaccinations/[id]/booster/route.ts", "src/app/api/vaccinations/suggest/route.ts", + // ── DATA LAYER (timeline) ───────────────────────────────────────── + // v1.42 (#613) — the record's life events. Raw CRUD over the person's own + // rows: the module decides whether they SURFACE (the timeline, the day's + // life-event line, the capture entry), not whether the store accepts them. + // A restore must bring them back with the module off, and re-enabling must + // find every event where it was. + "src/app/api/life-events/route.ts", + "src/app/api/life-events/[id]/route.ts", // ── INFRA / CREDENTIALS (mcp) ───────────────────────────────────── // The remote MCP endpoint itself is `src/app/mcp/route.ts`, outside `/api`, // and it carries the gate: an account with the module off gets a 404 there diff --git a/src/app/api/account/handover-decision/route.ts b/src/app/api/account/handover-decision/route.ts new file mode 100644 index 000000000..0824d3ab2 --- /dev/null +++ b/src/app/api/account/handover-decision/route.ts @@ -0,0 +1,109 @@ +/** + * `/api/account/handover-decision` — the new owner's final word on each former + * Guardian after claiming a managed profile (v1.42, #959). + * + * The claim applies the access the issuing Guardian proposed, so the record + * is never without a decision. This is where the owner sees it and decides: + * + * - `GET` answers the decision that is waiting, or `pending: null`. + * - `POST` records it. A Guardian the body leaves out keeps what the claim + * gave them; after this, the ordinary Settings → Shared access panel is + * where access changes. + * + * A grant-management surface, resolved like the others (`/api/account/ + * grants/**`): bare `requireAuth()`, which refuses under an acting-account + * switch and refuses a narrow Bearer scope. The owner is the session user; no + * party to any grant comes from the body, only the claim-time grant id that + * names which former Guardian a decision is about. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { + decideHandover, + HandoverError, + readPendingHandoverDecision, +} from "@/lib/managed-profiles/handover"; +import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify"; +import { prisma } from "@/lib/db"; +import { handoverDecisionSchema } from "@/lib/validations/managed-profile-handover"; + +export const GET = apiHandler(async (_request: NextRequest) => { + const { user } = await requireAuth(); + const pending = await readPendingHandoverDecision(user.id); + annotate({ + action: { name: "managed_profile.handover.decision_read" }, + meta: { pending: pending !== null }, + }); + return apiSuccess({ + pending: pending + ? { + claimedAt: pending.claimedAt.toISOString(), + guardians: pending.guardians, + } + : null, + }); +}); + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = handoverDecisionSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + let result; + try { + result = await decideHandover({ + userId: user.id, + decisions: parsed.data.decisions.map((d) => ({ + grantId: d.grantId, + access: d.access, + })), + }); + } catch (error) { + if (error instanceof HandoverError) { + if (error.code === "unknown_guardian") { + return apiError( + "A decision names somebody who was not a Guardian", + 422, + { + errorCode: "managed_profile.handover.unknown_guardian", + }, + ); + } + return apiError("There is no handover decision waiting", 409, { + errorCode: "managed_profile.handover.no_pending", + }); + } + throw error; + } + + if (result.changed.length > 0) { + const self = await prisma.user.findUnique({ + where: { id: user.id }, + select: { displayName: true }, + }); + notifyGuardiansOfHandover( + "changed", + self?.displayName ?? null, + result.changed, + ); + } + + annotate({ + action: { name: "managed_profile.handover.decided" }, + meta: { changed: result.changed.length }, + }); + return apiSuccess({ decided: true, changed: result.changed.length }); +}); diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 476e47073..70e40146c 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -84,7 +84,7 @@ const handler = apiHandler( details: { reason: "missing_confirmation", backupId: id }, }); return apiError( - "Confirmation token missing — replacing an account's data requires confirm: 'RESTORE'", + "Confirmation token missing. Replacing an account's data requires confirm: 'RESTORE'", 422, ); } diff --git a/src/app/api/admin/central-codex/device-poll/route.ts b/src/app/api/admin/central-codex/device-poll/route.ts index 5f8fecc65..b5e77028f 100644 --- a/src/app/api/admin/central-codex/device-poll/route.ts +++ b/src/app/api/admin/central-codex/device-poll/route.ts @@ -45,7 +45,7 @@ export const POST = apiHandler(async (request: NextRequest) => { userCode = decoded.userCode; } catch { cookieStore.delete("central_codex_device"); - return apiError("Invalid device-auth state — restart the flow", 400); + return apiError("Invalid device-auth state. Restart the flow", 400); } let result; @@ -55,7 +55,7 @@ export const POST = apiHandler(async (request: NextRequest) => { const message = err instanceof Error ? err.message : "unknown"; annotate({ meta: { central_codex_device_poll_error: message } }); return apiError( - "Central Codex device-poll failed — please restart the flow", + "Central Codex device-poll failed. Please restart the flow", 502, ); } diff --git a/src/app/api/admin/import-apple-health-export/route.ts b/src/app/api/admin/import-apple-health-export/route.ts index b3ea40528..ab4b03e51 100644 --- a/src/app/api/admin/import-apple-health-export/route.ts +++ b/src/app/api/admin/import-apple-health-export/route.ts @@ -22,6 +22,7 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { APPLE_HEALTH_IMPORT_V2_QUEUE, + APPLE_HEALTH_IMPORT_KIND, APPLE_HEALTH_IMPORT_PARSER_REVISION, APPLE_HEALTH_IMPORT_SEND_OPTIONS, type AppleHealthImportPayload, @@ -31,6 +32,7 @@ import { createImportJobUnlessBusy, discardStagedUpload, IMPORT_BUSY_CODE, + IMPORT_BUSY_MESSAGE, } from "@/lib/import/apple-health-staging"; import { unlink } from "node:fs/promises"; @@ -128,6 +130,7 @@ export const POST = apiHandler(async (request: NextRequest) => { const existing = await prisma.importJob.findFirst({ where: { userId: targetUser.id, + kind: APPLE_HEALTH_IMPORT_KIND, uploadSha256: uploaded.sha256, parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION, status: { not: "failed" }, @@ -170,11 +173,10 @@ export const POST = apiHandler(async (request: NextRequest) => { }); if ("busy" in staged) { await discardStagedUpload(uploaded.filePath); - return apiError( - "An Apple Health import is already running for this account. Wait for it to finish, then upload again.", - 409, - { errorCode: IMPORT_BUSY_CODE, jobId: staged.busy.id }, - ); + return apiError(IMPORT_BUSY_MESSAGE, 409, { + errorCode: IMPORT_BUSY_CODE, + jobId: staged.busy.id, + }); } const importJob = staged.created; diff --git a/src/app/api/admin/maintenance/measurements/__tests__/status.test.ts b/src/app/api/admin/maintenance/measurements/__tests__/status.test.ts new file mode 100644 index 000000000..b4cb3012e --- /dev/null +++ b/src/app/api/admin/maintenance/measurements/__tests__/status.test.ts @@ -0,0 +1,143 @@ +/** + * `GET /api/admin/maintenance/measurements` — the status the admin card + * starts and follows the run with. Admin-only, and a web-only deployment + * (no pg-boss schema) answers `available: false` instead of failing. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ prisma: { $queryRaw: vi.fn() } })); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { runStateOf } from "@/lib/jobs/measurement-maintenance-status"; + +const ADMIN = { + session: { id: "s-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "admin-1", username: "admin", role: "ADMIN" as const }, +}; +const USER = { + session: { id: "s-2", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "bob", role: "USER" as const }, +}; + +const get = () => + (GET as unknown as (r: NextRequest) => Promise)( + new NextRequest("http://localhost/api/admin/maintenance/measurements"), + ); + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("GET /api/admin/maintenance/measurements", () => { + it("is refused to anyone but an admin", async () => { + vi.mocked(getSession).mockResolvedValue(USER as never); + expect((await get()).status).toBe(403); + expect(prisma.$queryRaw).not.toHaveBeenCalled(); + }); + + it("reports a running pass, the purge and the table size", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN as never); + vi.mocked(prisma.$queryRaw) + // sizes + .mockResolvedValueOnce([ + { table_bytes: BigInt(5_000_000_000), index_bytes: BigInt(2e9) }, + ] as never) + // newest run + .mockResolvedValueOnce([ + { + state: "active", + created_on: new Date("2026-10-08T08:00:00Z"), + started_on: new Date("2026-10-08T08:00:05Z"), + completed_on: null, + outcome: null, + }, + ] as never) + // purge queue + .mockResolvedValueOnce([{ n: 0 }] as never); + + const body = await (await get()).json(); + expect(body.data).toEqual({ + available: true, + purgePending: false, + run: { + state: "running", + requestedAt: "2026-10-08T08:00:00.000Z", + startedAt: "2026-10-08T08:00:05.000Z", + finishedAt: null, + outcome: null, + }, + sizes: { tableBytes: 5_000_000_000, indexBytes: 2_000_000_000 }, + }); + }); + + it("says when a finished run refused because the purge was still working", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN as never); + vi.mocked(prisma.$queryRaw) + .mockResolvedValueOnce([ + { table_bytes: BigInt(1), index_bytes: BigInt(1) }, + ] as never) + .mockResolvedValueOnce([ + { + state: "completed", + created_on: new Date("2026-10-08T08:00:00Z"), + started_on: new Date("2026-10-08T08:00:01Z"), + completed_on: new Date("2026-10-08T08:00:02Z"), + outcome: "refused_purge_running", + }, + ] as never) + .mockResolvedValueOnce([{ n: 1 }] as never); + + const body = await (await get()).json(); + expect(body.data.purgePending).toBe(true); + expect(body.data.run.outcome).toBe("refused_purge_running"); + }); + + it("answers available: false where there is no queue to ask", async () => { + vi.mocked(getSession).mockResolvedValue(ADMIN as never); + vi.mocked(prisma.$queryRaw) + .mockResolvedValueOnce([ + { table_bytes: BigInt(10), index_bytes: BigInt(4) }, + ] as never) + .mockRejectedValue(new Error('relation "pgboss.job" does not exist')); + + const res = await get(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toEqual({ + available: false, + purgePending: false, + run: null, + sizes: { tableBytes: 10, indexBytes: 4 }, + }); + }); +}); + +describe("runStateOf", () => { + it("maps every pg-boss state onto the card's four", () => { + expect(runStateOf("created")).toBe("queued"); + expect(runStateOf("retry")).toBe("queued"); + expect(runStateOf("active")).toBe("running"); + expect(runStateOf("completed")).toBe("completed"); + expect(runStateOf("failed")).toBe("failed"); + expect(runStateOf("cancelled")).toBe("failed"); + }); +}); diff --git a/src/app/api/admin/maintenance/measurements/route.ts b/src/app/api/admin/maintenance/measurements/route.ts new file mode 100644 index 000000000..37fa23109 --- /dev/null +++ b/src/app/api/admin/maintenance/measurements/route.ts @@ -0,0 +1,110 @@ +/** + * `POST /api/admin/maintenance/measurements` — queue the operator-triggered + * `VACUUM (ANALYZE)` / `REINDEX INDEX CONCURRENTLY` pass over `measurements` + * (v1.42). Cookie-only admin: `requireAdmin()` refuses every Bearer. + * + * Nothing runs in the request: the pass runs in the background worker + * (`measurement-maintenance.ts`), one statement at a time, and refuses to + * start while the compaction-tombstone purge is still working. At most one + * run is queued or running; a second press answers `enqueued: false`. + * Runbook: `docs/ops/measurement-maintenance.md`. + * + * `GET` reads where the newest run stands, for the admin card that starts it + * and follows it. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { + MEASUREMENT_MAINTENANCE_QUEUE, + MEASUREMENT_MAINTENANCE_SEND_OPTIONS, + type MeasurementMaintenancePayload, +} from "@/lib/jobs/measurement-maintenance"; +import { readMeasurementMaintenanceStatus } from "@/lib/jobs/measurement-maintenance-status"; +import { annotate } from "@/lib/logging/context"; + +/** Two booleans; anything longer is not a request for this route. */ +const MAX_BODY_BYTES = 1024; + +const maintenanceRequestSchema = z + .object({ + vacuum: z.boolean().default(true), + reindex: z.boolean().default(true), + }) + .strict(); + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAdmin(); + + // The body is optional: an empty one runs both steps. + const text = await request.text(); + if (text.length > MAX_BODY_BYTES) { + return apiError("Request body too large", 413); + } + let body: unknown = {}; + if (text.trim().length > 0) { + try { + body = JSON.parse(text); + } catch { + return apiError("Invalid JSON", 400); + } + } + const parsed = maintenanceRequestSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error); + const payload: MeasurementMaintenancePayload = { + vacuum: parsed.data.vacuum, + reindex: parsed.data.reindex, + }; + if (!payload.vacuum && !payload.reindex) { + return apiError("Choose at least one step", 422); + } + + annotate({ + action: { name: "admin.maintenance.measurements.requested" }, + meta: { vacuum: payload.vacuum, reindex: payload.reindex }, + }); + + const boss = getGlobalBoss(); + if (!boss) { + return apiError("Background worker is not available", 503); + } + + const jobId = await boss.send( + MEASUREMENT_MAINTENANCE_QUEUE, + payload, + MEASUREMENT_MAINTENANCE_SEND_OPTIONS, + ); + const enqueued = jobId !== null; + + await auditLog("admin.maintenance.measurements.requested", { + userId: user.id, + ipAddress: getClientIp(request), + details: { ...payload, enqueued }, + }); + annotate({ meta: { maintenance_enqueued: enqueued } }); + + return apiSuccess({ enqueued }, 202); +}); + +export const GET = apiHandler(async () => { + await requireAdmin(); + const status = await readMeasurementMaintenanceStatus(); + annotate({ + action: { name: "admin.maintenance.measurements.status" }, + meta: { + available: status.available, + run_state: status.run?.state ?? null, + purge_pending: status.purgePending, + }, + }); + return apiSuccess(status); +}); diff --git a/src/app/api/admin/provider-health/__tests__/route.test.ts b/src/app/api/admin/provider-health/__tests__/route.test.ts index 408ba63ea..6735c919b 100644 --- a/src/app/api/admin/provider-health/__tests__/route.test.ts +++ b/src/app/api/admin/provider-health/__tests__/route.test.ts @@ -17,13 +17,24 @@ vi.mock("@/lib/db", () => ({ prisma: { providerHealth: { groupBy: vi.fn(), + // v1.42 — the paused rows. + findMany: vi.fn(async () => []), }, coachUsage: { aggregate: vi.fn(), }, + // v1.42 — the operator key's model check reads the admin settings. + appSettings: { findUnique: vi.fn(async () => null) }, }, })); +// v1.42 — the model check probes the operator endpoint's listing. +const probeModelListing = vi.fn(); +vi.mock("@/lib/ai/model-availability", () => ({ + probeModelListing: (...a: unknown[]) => probeModelListing(...a), +})); +vi.mock("@/lib/crypto", () => ({ decrypt: (v: string) => v })); + vi.mock("@/lib/api-handler", async () => { const actual = await vi.importActual( @@ -115,6 +126,9 @@ describe("GET /api/admin/provider-health", () => { lastOkAt: "2026-08-27T07:00:00.000Z", lastFailureAt: "2026-08-27T06:00:00.000Z", lastFailureStatus: 503, + pausedUsers: 0, + pausedUntil: null, + modelListing: null, }, ]); }); @@ -257,3 +271,89 @@ describe("GET /api/admin/provider-health", () => { expect(body.data.providers).toEqual([]); }); }); + +describe("GET /api/admin/provider-health — pause and model check (v1.42)", () => { + const findMany = vi.mocked(prisma.providerHealth.findMany); + const settings = vi.mocked(prisma.appSettings.findUnique); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireAdmin).mockResolvedValue({ + user: { id: "admin" }, + } as never); + aggregate.mockResolvedValue({ + _sum: { totalTokens: 0, operatorTokens: 0 }, + } as never); + findMany.mockResolvedValue([] as never); + settings.mockResolvedValue(null as never); + }); + + it("counts the users a provider is paused for and when the last pause lifts", async () => { + groupBy.mockResolvedValue([ + group("admin-openai", "hard_failed", 2, { + consecutiveFailures: 1766, + lastFailureAt: new Date("2026-10-08T04:31:00Z"), + lastStatus: 500, + }), + ] as never); + findMany.mockResolvedValue([ + { + providerType: "admin-openai", + nextRetryAt: new Date("2026-10-08T05:01:00Z"), + }, + { + providerType: "admin-openai", + nextRetryAt: new Date("2026-10-09T04:31:00Z"), + }, + ] as never); + + const body = await (await GET()).json(); + expect(body.data.providers[0]).toMatchObject({ + pausedUsers: 2, + pausedUntil: "2026-10-09T04:31:00.000Z", + }); + // Only rows past the pause threshold, inside their window, are read. + expect(findMany.mock.calls[0][0]).toMatchObject({ + where: { + lastResult: "hard_failed", + consecutiveFailures: { gte: 5 }, + }, + }); + }); + + it("names a model the operator's endpoint no longer lists", async () => { + groupBy.mockResolvedValue([ + group("admin-openai", "hard_failed", 1, { + consecutiveFailures: 9, + lastFailureAt: new Date("2026-10-08T04:31:00Z"), + lastStatus: 500, + }), + ] as never); + settings.mockResolvedValue({ + adminAiModel: "gpt-5.4-mini", + adminAiBaseUrl: "http://openai-oauth-proxy:10531/v1", + adminAiKeyEncrypted: "key", + } as never); + probeModelListing.mockResolvedValue("not_listed"); + + const body = await (await GET()).json(); + expect(body.data.providers[0].modelListing).toEqual({ + model: "gpt-5.4-mini", + listing: "not_listed", + }); + expect(probeModelListing).toHaveBeenCalledWith( + expect.objectContaining({ operatorTrusted: true, model: "gpt-5.4-mini" }), + ); + }); + + it("does not probe while the operator's key is healthy", async () => { + groupBy.mockResolvedValue([ + group("admin-openai", "ok", 4, { + lastOkAt: new Date("2026-10-08T04:31:00Z"), + }), + ] as never); + const body = await (await GET()).json(); + expect(body.data.providers[0].modelListing).toBeNull(); + expect(probeModelListing).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/admin/provider-health/route.ts b/src/app/api/admin/provider-health/route.ts index 74579d8c3..199d99923 100644 --- a/src/app/api/admin/provider-health/route.ts +++ b/src/app/api/admin/provider-health/route.ts @@ -4,6 +4,14 @@ import { prisma } from "@/lib/db"; import { apiSuccess } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { OPERATOR_HELD_PROVIDER_TYPES } from "@/lib/ai/provider-egress"; +import { PROVIDER_PAUSE_THRESHOLD } from "@/lib/ai/provider-health-ledger"; +import { + probeModelListing, + type ModelListing, +} from "@/lib/ai/model-availability"; +import { decrypt } from "@/lib/crypto"; +import { isAnthropicBaseUrl } from "@/lib/ai/provider"; +import { isCanonicalOpenAIEndpoint } from "@/lib/ai/openai-capabilities"; export const dynamic = "force-dynamic"; @@ -42,6 +50,20 @@ interface ProviderHealthSummary { * success clears the status along with the rest of the failure state. */ lastFailureStatus: number | null; + /** + * v1.42 — users whose chain has this provider PAUSED right now (a run of + * hard failures past the pause threshold, inside its window). The chain + * walks a paused provider last and tries it again once the window lifts. + */ + pausedUsers: number; + /** When the latest of those pauses lifts. Null when none is paused. */ + pausedUntil: string | null; + /** + * v1.42 — for the operator's own key while it is failing: whether its + * endpoint still lists the configured model. `not_listed` is the fix the + * card names; null when there is nothing to check. + */ + modelListing: { model: string; listing: ModelListing } | null; } /** @@ -71,6 +93,19 @@ export const GET = apiHandler(async () => { }, }); + // v1.42 — paused providers: a hard-failure run at or past the threshold, + // inside its window. Read as rows (one per user and type, two columns) so + // the count and the latest lift time are exact, not folded from a group. + const now = new Date(); + const pausedRows = await prisma.providerHealth.findMany({ + where: { + lastResult: "hard_failed", + consecutiveFailures: { gte: PROVIDER_PAUSE_THRESHOLD }, + nextRetryAt: { gt: now }, + }, + select: { providerType: true, nextRetryAt: true }, + }); + const byType = new Map(); for (const g of groups) { const row = byType.get(g.providerType) ?? { @@ -81,6 +116,9 @@ export const GET = apiHandler(async () => { lastOkAt: null, lastFailureAt: null, lastFailureStatus: null, + pausedUsers: 0, + pausedUntil: null, + modelListing: null, }; row.tracked += g._count._all; if (g.lastResult !== "ok") { @@ -103,6 +141,21 @@ export const GET = apiHandler(async () => { byType.set(g.providerType, row); } + for (const paused of pausedRows) { + const row = byType.get(paused.providerType); + if (!row || paused.nextRetryAt === null) continue; + row.pausedUsers += 1; + const until = paused.nextRetryAt.toISOString(); + if (!row.pausedUntil || until > row.pausedUntil) row.pausedUntil = until; + } + + // The operator's own key, while it is failing: does its endpoint still list + // the configured model? A free GET, cached; only on this admin read. + const adminRow = byType.get("admin-openai"); + if (adminRow && adminRow.failing > 0) { + adminRow.modelListing = await adminModelListing(); + } + const providers = [...byType.values()].sort((a, b) => { const ca = CENTRAL_TYPES.indexOf(a.providerType); const cb = CENTRAL_TYPES.indexOf(b.providerType); @@ -136,3 +189,42 @@ export const GET = apiHandler(async () => { }, }); }); + +/** + * The model check for the operator's key. Never fails the readout: a missing + * settings row, a key that does not decrypt, or a probe error is simply no + * answer. + */ +async function adminModelListing(): Promise<{ + model: string; + listing: ModelListing; +} | null> { + try { + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + select: { + adminAiModel: true, + adminAiBaseUrl: true, + adminAiKeyEncrypted: true, + }, + }); + if (!settings?.adminAiKeyEncrypted) return null; + const baseUrl = settings.adminAiBaseUrl ?? "https://api.openai.com/v1"; + // The same scope as the client's own check: an OpenAI-compatible + // endpoint the operator typed. `api.openai.com` names a missing model in + // its own error; Anthropic lists models on a different contract. + if (isCanonicalOpenAIEndpoint(baseUrl) || isAnthropicBaseUrl(baseUrl)) { + return null; + } + const model = settings.adminAiModel ?? "gpt-4o"; + const listing = await probeModelListing({ + baseUrl, + apiKey: decrypt(settings.adminAiKeyEncrypted), + model, + operatorTrusted: true, + }); + return { model, listing }; + } catch { + return null; + } +} diff --git a/src/app/api/admin/settings/__tests__/route.test.ts b/src/app/api/admin/settings/__tests__/route.test.ts index 9745f6a41..3b1c86bd9 100644 --- a/src/app/api/admin/settings/__tests__/route.test.ts +++ b/src/app/api/admin/settings/__tests__/route.test.ts @@ -434,6 +434,41 @@ describe("PUT /api/admin/settings", () => { expect(args?.update).toEqual({ defaultUserTimezone: null }); }); + // Every shipped locale is a valid server default, not only de/en. + it("persists any shipped locale as the default language", async () => { + vi.mocked(prisma.appSettings.upsert).mockResolvedValue({ + id: "singleton", + registrationEnabled: true, + defaultLocale: "fr", + telegramGlobal: true, + ntfyGlobal: true, + webPushGlobal: true, + webPushVapidPublicKey: null, + webPushVapidSubject: null, + webPushVapidPrivateKeyEncrypted: null, + apiGlobal: true, + umamiEnabled: false, + umamiScriptUrl: null, + umamiWebsiteId: null, + glitchtipEnabled: false, + glitchtipDsn: null, + glitchtipEnvironment: null, + reminderLateMinutes: 120, + reminderMissedMinutes: 240, + defaultUserTimezone: null, + } as never); + const res = await PUT(jsonReq({ defaultLocale: "fr" })); + expect(res.status).toBe(200); + const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0]; + expect(args?.update).toEqual({ defaultLocale: "fr" }); + }); + + it("returns 422 for a locale the app does not ship", async () => { + const res = await PUT(jsonReq({ defaultLocale: "xx" })); + expect(res.status).toBe(422); + expect(prisma.appSettings.upsert).not.toHaveBeenCalled(); + }); + it("returns 422 for an invalid IANA zone in defaultUserTimezone", async () => { const res = await PUT(jsonReq({ defaultUserTimezone: "Mars/Olympus" })); expect(res.status).toBe(422); diff --git a/src/app/api/admin/status/__tests__/route.test.ts b/src/app/api/admin/status/__tests__/route.test.ts index bffaa5be3..deef1897b 100644 --- a/src/app/api/admin/status/__tests__/route.test.ts +++ b/src/app/api/admin/status/__tests__/route.test.ts @@ -44,6 +44,17 @@ vi.mock("@/lib/jobs/worker-status", () => ({ })), })); +const geoMock = vi.hoisted(() => ({ + lookup: "online" as "offline" | "online" | "off", + chosen: false, + host: "ipwho.is", +})); +vi.mock("@/lib/geo", () => ({ + geoLookupMode: () => geoMock.lookup, + geoProviderChosen: () => geoMock.chosen, + resolveGeoProviderHost: () => geoMock.host, +})); + vi.mock("@/lib/jobs/job-failures", () => ({ JOB_FAILURE_WINDOW_HOURS: 72, readFailingQueues: vi.fn(), @@ -208,4 +219,17 @@ describe("GET /api/admin/status (integration health summary)", () => { ], }); }); + + it("carries the login-IP geo state the public version route no longer shows", async () => { + geoMock.lookup = "online"; + geoMock.chosen = true; + geoMock.host = "ip-api.com"; + const res = await GET(); + const body = (await res.json()) as { data: { geo: unknown } }; + expect(body.data.geo).toEqual({ + lookup: "online", + providerChosen: true, + providerHost: "ip-api.com", + }); + }); }); diff --git a/src/app/api/admin/status/route.ts b/src/app/api/admin/status/route.ts index d94de0dd4..5e7f4b9ab 100644 --- a/src/app/api/admin/status/route.ts +++ b/src/app/api/admin/status/route.ts @@ -9,6 +9,11 @@ import { readFailingQueues, } from "@/lib/jobs/job-failures"; import { readFileSync } from "node:fs"; +import { + geoLookupMode, + geoProviderChosen, + resolveGeoProviderHost, +} from "@/lib/geo"; // Build timestamp (set at build time via next.config) const BUILD_TIME = process.env.BUILD_TIMESTAMP || new Date().toISOString(); @@ -109,5 +114,12 @@ export const GET = apiHandler(async () => { : null, webPush: webPushConfigured ? { configured: true } : null, }, + // How login IPs are placed on this host. Admin-only: the public + // `/api/version` keeps just `offlineGeoEnabled` and `geoProviderHost`. + geo: { + lookup: geoLookupMode(), + providerChosen: geoProviderChosen(), + providerHost: resolveGeoProviderHost(), + }, }); }); diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index 1c6c53cca..eb8166511 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -276,13 +276,13 @@ function classifyTestFailure( return { reasonCode: "credentials", reason: - "Provider rejected the credentials — re-authenticate in AI settings.", + "Provider rejected the credentials. Re-authenticate in AI settings.", }; } if (status === 429) { return { reasonCode: "rate_limited", - reason: "Provider rate-limited the request — try again shortly.", + reason: "Provider rate-limited the request. Try again shortly.", }; } if (status >= 500) { @@ -298,7 +298,7 @@ function classifyTestFailure( if (status >= 400 && status < 500) { return { reasonCode: "bad_request", - reason: `The provider rejected the request (HTTP ${status}) — the endpoint answered, so this is a request-shape or model-name problem, not connectivity.`, + reason: `The provider rejected the request (HTTP ${status}). The endpoint answered, so this is a request-shape or model-name problem, not connectivity.`, }; } return { diff --git a/src/app/api/auth/claim/__tests__/route.test.ts b/src/app/api/auth/claim/__tests__/route.test.ts new file mode 100644 index 000000000..b5b9722b1 --- /dev/null +++ b/src/app/api/auth/claim/__tests__/route.test.ts @@ -0,0 +1,266 @@ +/** + * v1.42 (#959) — the anonymous claim and its preview, at the route seam. + * + * The transaction itself is integration-tested + * (`tests/integration/managed-profile-handover.test.ts`); here the order of + * the refusals is pinned, because each one is a security property: + * + * - single sign-on only refuses before anything else; + * - a live session is refused BEFORE the rate limit (a state, not an + * attempt), and before any token is read; + * - the rate limit runs before the body is parsed; + * - an unusable token answers the same 404 on both routes, and the claim + * checks the link BEFORE probing whether a username or email is taken, so + * the route is not a name oracle for somebody without a live link; + * - nothing is hashed for a token that is refused. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn() } }, +})); +vi.mock("@/lib/auth/session", () => ({ + createSession: vi.fn(), + getSession: vi.fn(), +})); +vi.mock("@/lib/auth/oidc", () => ({ isOidcOnly: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + checkAuthSurfaceRateLimit: vi.fn(), + rateLimitHeaders: () => ({}), +})); +vi.mock("@/lib/auth/password", () => ({ + hashPassword: vi.fn(), + checkPasswordStrength: vi.fn(), +})); +vi.mock("@/lib/password-breach-check", () => ({ + checkPasswordBreachIfEnabled: vi.fn(), +})); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/auth/login-alert", () => ({ recordSignInDevice: vi.fn() })); +vi.mock("@/lib/i18n/server-locale", () => ({ + resolveServerLocale: vi.fn().mockResolvedValue("en"), +})); +vi.mock("@/lib/managed-profiles/handover", async () => { + const actual = await vi.importActual< + typeof import("@/lib/managed-profiles/handover") + >("@/lib/managed-profiles/handover"); + return { + HandoverError: actual.HandoverError, + previewHandover: vi.fn(), + claimManagedProfile: vi.fn(), + }; +}); +vi.mock("@/lib/managed-profiles/handover-notify", () => ({ + notifyGuardiansOfHandover: vi.fn(), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST as claim } from "../route"; +import { POST as preview } from "../preview/route"; +import { prisma } from "@/lib/db"; +import { createSession, getSession } from "@/lib/auth/session"; +import { isOidcOnly } from "@/lib/auth/oidc"; +import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; +import { checkPasswordStrength, hashPassword } from "@/lib/auth/password"; +import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check"; +import { + claimManagedProfile, + HandoverError, + previewHandover, +} from "@/lib/managed-profiles/handover"; +import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify"; + +const TOKEN = `hlp_${"a".repeat(64)}`; +const BODY = { + token: TOKEN, + username: "alex", + email: "alex@example.test", + password: "a long enough passphrase", +}; + +function post(path: string, body: unknown): NextRequest { + return new NextRequest(`http://localhost${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +const PREVIEW = { + displayName: "Alex", + expiresAt: new Date("2030-01-01T00:00:00Z"), + guardians: [{ grantId: "g1", displayName: "Sam", proposal: "read" as const }], +}; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(isOidcOnly).mockReturnValue(false); + vi.mocked(getSession).mockResolvedValue(null as never); + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: true, + ip: "203.0.113.7", + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null as never); + vi.mocked(checkPasswordStrength).mockReturnValue({ + isAcceptable: true, + feedback: [], + } as never); + vi.mocked(checkPasswordBreachIfEnabled).mockResolvedValue(null as never); + vi.mocked(hashPassword).mockResolvedValue("argon2-hash"); + vi.mocked(previewHandover).mockResolvedValue(PREVIEW); + vi.mocked(claimManagedProfile).mockResolvedValue({ + profileId: "p1", + username: "alex", + displayName: "Alex", + guardians: [{ guardianId: "u-sam", access: "read" }], + }); +}); + +async function errorCode(res: Response): Promise { + const body = (await res.json()) as { meta?: { errorCode?: string } }; + return body.meta?.errorCode; +} + +describe.each([ + ["claim", claim, "/api/auth/claim", BODY], + ["preview", preview, "/api/auth/claim/preview", { token: TOKEN }], +] as const)( + "POST %s — refusals that read no token", + (_n, route, path, body) => { + it("refuses on a single-sign-on-only instance before anything else", async () => { + vi.mocked(isOidcOnly).mockReturnValue(true); + vi.mocked(getSession).mockResolvedValue({ user: { id: "u1" } } as never); + const res = await route(post(path, body)); + expect(res.status).toBe(403); + expect(await errorCode(res)).toBe("profile_claim.oidc_only_unsupported"); + expect(previewHandover).not.toHaveBeenCalled(); + }); + + it("refuses over a live session before spending the rate limit", async () => { + vi.mocked(getSession).mockResolvedValue({ user: { id: "u1" } } as never); + const res = await route(post(path, body)); + expect(res.status).toBe(409); + expect(await errorCode(res)).toBe("auth.already_authenticated"); + expect(checkAuthSurfaceRateLimit).not.toHaveBeenCalled(); + expect(previewHandover).not.toHaveBeenCalled(); + }); + + it("answers 429 from the per-address bucket before parsing the body", async () => { + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: false, + ip: "203.0.113.7", + } as never); + const res = await route(post(path, body)); + expect(res.status).toBe(429); + expect(previewHandover).not.toHaveBeenCalled(); + }); + + it("answers the same 404 for an unusable link", async () => { + vi.mocked(previewHandover).mockResolvedValue(null); + const res = await route(post(path, body)); + expect(res.status).toBe(404); + expect(await errorCode(res)).toBe("profile_claim.invalid"); + }); + }, +); + +describe("POST /api/auth/claim", () => { + it("checks the link before the username and email probe", async () => { + vi.mocked(previewHandover).mockResolvedValue(null); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: "x" } as never); + const res = await claim(post("/api/auth/claim", BODY)); + // A taken name would be a 409; the link is checked first, so 404. + expect(res.status).toBe(404); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + expect(hashPassword).not.toHaveBeenCalled(); + }); + + it("answers 409 profile_claim.taken for a name in use, before hashing", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({ + id: "x", + } as never); + const res = await claim(post("/api/auth/claim", BODY)); + expect(res.status).toBe(409); + expect(await errorCode(res)).toBe("profile_claim.taken"); + expect(hashPassword).not.toHaveBeenCalled(); + expect(claimManagedProfile).not.toHaveBeenCalled(); + }); + + it("refuses a username with the managed-profile prefix as 422", async () => { + const res = await claim( + post("/api/auth/claim", { ...BODY, username: "managed-alex" }), + ); + expect(res.status).toBe(422); + expect(previewHandover).not.toHaveBeenCalled(); + }); + + it("refuses an unknown field (strict body)", async () => { + const res = await claim( + post("/api/auth/claim", { ...BODY, userId: "someone-else" }), + ); + expect(res.status).toBe(422); + }); + + it("maps a race lost inside the transaction to the same 404", async () => { + vi.mocked(claimManagedProfile).mockRejectedValue( + new HandoverError("invalid"), + ); + const res = await claim(post("/api/auth/claim", BODY)); + expect(res.status).toBe(404); + expect(createSession).not.toHaveBeenCalled(); + }); + + it("maps a unique-constraint race to 409 taken", async () => { + vi.mocked(claimManagedProfile).mockRejectedValue( + new HandoverError("taken"), + ); + const res = await claim(post("/api/auth/claim", BODY)); + expect(res.status).toBe(409); + expect(await errorCode(res)).toBe("profile_claim.taken"); + }); + + it("signs the new owner in with onboarding owed and tells the guardians", async () => { + const res = await claim(post("/api/auth/claim", BODY)); + expect(res.status).toBe(201); + expect(claimManagedProfile).toHaveBeenCalledWith( + expect.objectContaining({ + rawToken: TOKEN, + username: "alex", + email: "alex@example.test", + passwordHash: "argon2-hash", + }), + ); + expect(createSession).toHaveBeenCalledWith("p1", true, "203.0.113.7", null); + expect(notifyGuardiansOfHandover).toHaveBeenCalledWith("claimed", "Alex", [ + { guardianId: "u-sam", access: "read" }, + ]); + const body = (await res.json()) as { data: unknown }; + expect(body.data).toEqual({ userId: "p1", username: "alex" }); + }); +}); + +describe("POST /api/auth/claim/preview", () => { + it("returns the minimal preview and nothing else", async () => { + const res = await preview( + post("/api/auth/claim/preview", { token: TOKEN }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: Record }; + expect(Object.keys(body.data).sort()).toEqual([ + "displayName", + "expiresAt", + "guardians", + ]); + expect(body.data.expiresAt).toBe("2030-01-01T00:00:00.000Z"); + }); +}); diff --git a/src/app/api/auth/claim/preview/route.ts b/src/app/api/auth/claim/preview/route.ts new file mode 100644 index 000000000..865f7fdfb --- /dev/null +++ b/src/app/api/auth/claim/preview/route.ts @@ -0,0 +1,50 @@ +/** + * `POST /api/auth/claim/preview` — what a handover link would hand over: the + * profile's display name, the link's expiry and each Guardian's proposed + * access (v1.42, #959). No health data. + * + * Anonymous by design: the person opening the link has no credentials yet. + * The one-time `hlp_` token travels in the BODY, never in this request's URL, + * so it never reaches an access log or a wide event's `http.path`. Every + * unusable link — malformed, unknown, expired, used, withdrawn, a profile no + * longer managed, a creator who lost access — answers the same 404. + */ +import { NextRequest } from "next/server"; + +import { apiHandler } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { getSession } from "@/lib/auth/session"; +import { annotate } from "@/lib/logging/context"; +import { claimGate, claimInvalid } from "@/lib/managed-profiles/claim-gate"; +import { previewHandover } from "@/lib/managed-profiles/handover"; +import { claimPreviewSchema } from "@/lib/validations/managed-profile-handover"; + +export const POST = apiHandler(async (request: NextRequest) => { + // Refuses to preview over a live session — see `claimGate`. + const gate = await claimGate( + request, + "preview", + (await getSession()) !== null, + ); + if (gate.refusal) return gate.refusal; + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + const parsed = claimPreviewSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const preview = await previewHandover(parsed.data.token); + annotate({ + action: { name: "profile_claim.preview" }, + meta: { found: preview !== null }, + }); + if (!preview) return claimInvalid(); + + return apiSuccess({ + displayName: preview.displayName, + expiresAt: preview.expiresAt.toISOString(), + guardians: preview.guardians, + }); +}); diff --git a/src/app/api/auth/claim/route.ts b/src/app/api/auth/claim/route.ts new file mode 100644 index 000000000..82bba356d --- /dev/null +++ b/src/app/api/auth/claim/route.ts @@ -0,0 +1,151 @@ +/** + * `POST /api/auth/claim` — claim a managed profile through its handover link + * (v1.42, #959). The person the profile describes sets a username, an email + * and a password, and the profile becomes their own account. No data moves. + * + * Anonymous by design: the claimant has no credentials yet, and the one-time + * `hlp_` token in the BODY is what authorises the request. The order of the + * work is the registration route's, for the same reasons: + * + * 1. refusals that read no token (`claimGate`: SSO-only, live session, rate + * limit); + * 2. the body, strictly parsed, and the link checked; + * 3. the username and email collision probe, before any expensive work; + * 4. password strength, the breach corpus, the Argon2id hash — outside the + * lock, because none of it depends on the profile; + * 5. the claim itself, one transaction under the managed-profile lock + * (`claimManagedProfile`), where the token is consumed; + * 6. after the commit: the session, the device record, and a notification + * to each former Guardian on their own channels. + * + * A closed registration does not block a claim: no account is created, the + * link is the door. + */ +import { NextRequest } from "next/server"; + +import { apiHandler } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { recordSignInDevice } from "@/lib/auth/login-alert"; +import { checkPasswordStrength, hashPassword } from "@/lib/auth/password"; +import { createSession, getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import { annotate } from "@/lib/logging/context"; +import { claimGate, claimInvalid } from "@/lib/managed-profiles/claim-gate"; +import { + claimManagedProfile, + HandoverError, + previewHandover, +} from "@/lib/managed-profiles/handover"; +import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify"; +import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check"; +import { claimSchema } from "@/lib/validations/managed-profile-handover"; + +const taken = () => + apiError("Username or email already taken", 409, { + errorCode: "profile_claim.taken", + }); + +export const POST = apiHandler(async (request: NextRequest) => { + // Refuses to claim over a live session — see `claimGate`. + const gate = await claimGate(request, "claim", (await getSession()) !== null); + if (gate.refusal) return gate.refusal; + const ip = gate.ip ?? "unknown"; + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = claimSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + const { token, username, email, password } = parsed.data; + + // The link first. Without this the collision probe below would answer 409 + // to anybody, token or not, and the claim route would be a username and + // email oracle that registration being closed does not close. With it, only + // the holder of a live link learns that a name is taken. The claim re-checks + // everything under the lock; this read decides nothing on its own. + if (!(await previewHandover(token))) return claimInvalid(); + + // Before the strength check, the breach lookup and the hash, exactly as the + // registration route orders it (its comment carries the timing argument). + // The profile's own row never collides here: its username is the generated + // `managed-…` one, which no chosen name may start with, and it has no email. + const [existingEmail, existingUsername] = await Promise.all([ + prisma.user.findUnique({ where: { email }, select: { id: true } }), + prisma.user.findUnique({ where: { username }, select: { id: true } }), + ]); + if (existingEmail || existingUsername) return taken(); + + const locale = await resolveServerLocale({ request }); + const strength = checkPasswordStrength(password, [username, email], locale); + if (!strength.isAcceptable) { + return apiError( + strength.feedback[0] || "Password too weak (score < 3)", + 422, + ); + } + const breach = await checkPasswordBreachIfEnabled(password); + if (breach?.breached) { + return apiError( + getServerTranslator(locale).t("auth.passwordBreached"), + 422, + ); + } + const passwordHash = await hashPassword(password); + + let claimed; + try { + claimed = await claimManagedProfile({ + rawToken: token, + username, + email, + passwordHash, + ipAddress: ip, + }); + } catch (error) { + if (error instanceof HandoverError) { + annotate({ + action: { name: "profile_claim.refused" }, + meta: { reason: error.code }, + }); + return error.code === "taken" ? taken() : claimInvalid(); + } + throw error; + } + + // The new owner starts with onboarding owed: the claim reset it, because + // the disclaimer and the setup were a Guardian's answers, not theirs. + const ua = request.headers.get("user-agent"); + await createSession(claimed.profileId, true, ip, ua); + void recordSignInDevice({ + userId: claimed.profileId, + ip, + userAgent: ua, + alertOnNew: false, + }); + await auditLog("auth.claim", { + userId: claimed.profileId, + actorUserId: null, + ipAddress: ip, + details: { method: "password" }, + }); + + notifyGuardiansOfHandover("claimed", claimed.displayName, claimed.guardians); + + annotate({ + action: { name: "profile_claim.complete" }, + meta: { guardians: claimed.guardians.length }, + }); + return apiSuccess( + { userId: claimed.profileId, username: claimed.username }, + 201, + ); +}); diff --git a/src/app/api/auth/codex/device-poll/route.ts b/src/app/api/auth/codex/device-poll/route.ts index 060b2c08c..05d148189 100644 --- a/src/app/api/auth/codex/device-poll/route.ts +++ b/src/app/api/auth/codex/device-poll/route.ts @@ -47,7 +47,7 @@ export const POST = apiHandler(async (request: NextRequest) => { userCode = decoded.userCode; } catch { cookieStore.delete("codex_device"); - return apiError("Invalid device-auth state — restart the flow", 400); + return apiError("Invalid device-auth state. Restart the flow", 400); } let result; @@ -56,7 +56,7 @@ export const POST = apiHandler(async (request: NextRequest) => { } catch (err) { const message = err instanceof Error ? err.message : "unknown"; annotate({ meta: { codex_device_poll_error: message } }); - return apiError("Codex device-poll failed — please restart the flow", 502); + return apiError("Codex device-poll failed. Please restart the flow", 502); } if (result.status === "pending") { diff --git a/src/app/api/auth/me/health-score-config/route.ts b/src/app/api/auth/me/health-score-config/route.ts index b07c53c07..f262d37a2 100644 --- a/src/app/api/auth/me/health-score-config/route.ts +++ b/src/app/api/auth/me/health-score-config/route.ts @@ -88,7 +88,7 @@ const healthScoreConfigPatchSchema = z */ const BREADTH_REFUSAL = { no_pillars_selected: - "A health score needs at least one area of health to score. Keep one selected — the score will say which areas it is built from.", + "A health score needs at least one area of health to score. Keep one selected. The score will say which areas it is built from.", } as const; export const GET = apiHandler(async () => { diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts index dc485fe69..a833a6b22 100644 --- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -104,7 +104,6 @@ describe("GET /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", - ambientSuggestions: true, nudgeAiComposed: false, }, measurementReminder: { @@ -141,7 +140,6 @@ describe("GET /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", - ambientSuggestions: true, nudgeAiComposed: false, }, measurementReminder: { @@ -180,7 +178,6 @@ describe("GET /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", - ambientSuggestions: true, nudgeAiComposed: false, }, measurementReminder: { @@ -230,7 +227,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", - ambientSuggestions: true, nudgeAiComposed: false, }, measurementReminder: { @@ -259,7 +255,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", - ambientSuggestions: true, nudgeAiComposed: false, }, measurementReminder: { @@ -289,7 +284,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", - ambientSuggestions: true, nudgeAiComposed: false, }, measurementReminder: { @@ -311,7 +305,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", - ambientSuggestions: true, nudgeAiComposed: false, }, measurementReminder: { @@ -395,7 +388,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", - ambientSuggestions: true, nudgeAiComposed: false, }, measurementReminder: { @@ -435,7 +427,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", - ambientSuggestions: true, nudgeAiComposed: false, }, measurementReminder: { @@ -464,7 +455,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", - ambientSuggestions: true, nudgeAiComposed: false, }, measurementReminder: { @@ -510,7 +500,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => { nudgeVitals: true, nudgeRoutine: true, nudgeFrequency: "weekly", - ambientSuggestions: true, nudgeAiComposed: false, }, measurementReminder: { diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 4967136e2..2b46fbfe7 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -74,9 +74,17 @@ import { recordSessionForPayload } from "@/lib/sharing/record-session-fence"; import { loadAiCapabilities } from "@/lib/ai/capabilities/load"; import { loadCoachReasoningState } from "@/lib/ai/reasoning/controls"; import { parseCoachPrefs } from "@/lib/validations/coach-prefs"; +import { storedGlucoseUnit } from "@/lib/glucose"; export const dynamic = "force-dynamic"; +/** + * Capability flags for clients that are not shipped with the server. Constant + * by design: a feature the server supports is supported for every account, + * and a per-account answer belongs in `modules` or `ai`, not here. + */ +const SERVER_FEATURES = { trackIntake: true } as const; + export const GET = apiHandler(async () => { const auth = await requireActorAuth(); const { user } = auth; @@ -331,7 +339,8 @@ export const GET = apiHandler(async () => { avatarUrl: user.avatarUpdatedAt ? buildAvatarUrl(user.id, user.avatarUpdatedAt) : null, - glucoseUnit: user.glucoseUnit ?? null, + // The stored choice, published as the closed pair the contract names. + glucoseUnit: storedGlucoseUnit(user.glucoseUnit), // v1.7.0 — global metric/imperial display preference. Canonical // storage stays SI; this only drives the display-time transform // branch. Null defaults to "metric" on the client. @@ -442,5 +451,11 @@ export const GET = apiHandler(async () => { // behind it have one reader by design, and a route that projected them // itself would be a second statement of what a record context is. recordSession: recordSessionForPayload(auth), + // v1.42 — what this server understands, for a client released on its own + // schedule (iOS #116, item 18). `trackIntake: true` says medications take + // the record-only switch, so an add form can offer it before the account + // has a medication to learn it from. A flag only ever appears here once + // the server supports it, and stays `true`; absence means an older server. + features: SERVER_FEATURES, }); }); diff --git a/src/app/api/auth/me/source-priority/__tests__/route.test.ts b/src/app/api/auth/me/source-priority/__tests__/route.test.ts index fdc7cc849..4b4801ea5 100644 --- a/src/app/api/auth/me/source-priority/__tests__/route.test.ts +++ b/src/app/api/auth/me/source-priority/__tests__/route.test.ts @@ -11,6 +11,10 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/integrations/sources-in-use", () => ({ + getSourcesInUse: vi.fn().mockResolvedValue(["WITHINGS", "MANUAL"]), +})); + vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); @@ -73,6 +77,17 @@ describe("GET /api/auth/me/source-priority", () => { expect(env.data).toHaveProperty("metricPriority"); expect(env.data).toHaveProperty("deviceTypePriority"); }); + + it("carries the account's sources in use beside the ladders", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + sourcePriorityJson: null, + } as never); + + const res = await (GET as () => Promise)(); + const env = (await res.json()) as { data: { inUse: string[] } }; + expect(env.data.inUse).toEqual(["WITHINGS", "MANUAL"]); + }); }); describe("PUT /api/auth/me/source-priority", () => { diff --git a/src/app/api/auth/me/source-priority/route.ts b/src/app/api/auth/me/source-priority/route.ts index d5897cd1d..e37a4a528 100644 --- a/src/app/api/auth/me/source-priority/route.ts +++ b/src/app/api/auth/me/source-priority/route.ts @@ -30,6 +30,7 @@ import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { invalidateUserProfile } from "@/lib/cache/invalidate"; +import { getSourcesInUse } from "@/lib/integrations/sources-in-use"; import { parseSourcePriority, sourcePrioritySchema, @@ -41,11 +42,19 @@ export const GET = apiHandler(async () => { const { user } = await requireAuth(); annotate({ action: { name: "auth.me.source-priority.get" } }); - const row = await prisma.user.findUnique({ - where: { id: user.id }, - select: { sourcePriorityJson: true }, + const [row, inUse] = await Promise.all([ + prisma.user.findUnique({ + where: { id: user.id }, + select: { sourcePriorityJson: true }, + }), + getSourcesInUse(user.id), + ]); + // `inUse` is read-only context for the editor: which ranked sources this + // account has connected or holds data from. It never feeds the ladder. + return apiSuccess({ + ...parseSourcePriority(row?.sourcePriorityJson), + inUse, }); - return apiSuccess(parseSourcePriority(row?.sourcePriorityJson)); }); export const PUT = apiHandler(async (req: Request) => { @@ -118,5 +127,8 @@ export const PUT = apiHandler(async (req: Request) => { action: { name: "auth.me.source-priority.put" }, meta: { keys: Object.keys(parsed.data).length }, }); - return apiSuccess(parseSourcePriority(parsed.data)); + return apiSuccess({ + ...parseSourcePriority(parsed.data), + inUse: await getSourcesInUse(user.id), + }); }); diff --git a/src/app/api/auth/passkey/login-verify/__tests__/refusals.test.ts b/src/app/api/auth/passkey/login-verify/__tests__/refusals.test.ts new file mode 100644 index 000000000..a41950f37 --- /dev/null +++ b/src/app/api/auth/passkey/login-verify/__tests__/refusals.test.ts @@ -0,0 +1,164 @@ +/** + * A passkey sign-in that cannot be checked answers with its own status and + * `meta.errorCode`, never a 500. The real `verifyAuthentication` runs here + * against a mocked database, so the test pins the whole path from the + * library's refusal to the wire: an expired or already-used challenge, a + * credential this server does not know, a body that is not an assertion, and + * an assertion the WebAuthn library refuses. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + $queryRaw: vi.fn(), + passkey: { findUnique: vi.fn(), update: vi.fn() }, + user: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@simplewebauthn/server", async (importOriginal) => ({ + ...(await importOriginal()), + verifyAuthenticationResponse: vi.fn(), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + checkAuthSurfaceRateLimit: vi.fn(), +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; +import { verifyAuthenticationResponse } from "@simplewebauthn/server"; + +const ASSERTION = { + id: "cred-1", + rawId: "cred-1", + type: "public-key", + response: { + clientDataJSON: "e30", + authenticatorData: "AA", + signature: "AA", + }, +}; + +function request(credential: unknown = ASSERTION): NextRequest { + return new NextRequest("http://localhost/api/auth/passkey/login-verify", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ challengeId: "ch-1", credential }), + }); +} + +async function refusal(res: Response) { + const body = (await res.json()) as { + data: null; + meta?: { errorCode?: string }; + }; + return { status: res.status, code: body.meta?.errorCode, data: body.data }; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: 0, + ip: "203.0.113.1", + } as never); + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { challenge: "c-1", user_id: null }, + ] as never); + vi.mocked(prisma.passkey.findUnique).mockResolvedValue({ + id: "pk-1", + userId: "user-1", + credentialId: "cred-1", + credentialPublicKey: new Uint8Array([1]), + counter: BigInt(0), + transports: [], + } as never); +}); + +describe("POST /api/auth/passkey/login-verify — named refusals", () => { + it("answers an expired or already-used challenge with 401 passkey.challenge.expired", async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never); + expect(await refusal(await POST(request()))).toEqual({ + status: 401, + code: "passkey.challenge.expired", + data: null, + }); + expect(auditLog).toHaveBeenCalledWith( + "auth.login.failed", + expect.objectContaining({ + details: { reason: "passkey_challenge_expired" }, + }), + ); + }); + + it("answers a challenge begun for another account the same way", async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { challenge: "c-1", user_id: "someone-else" }, + ] as never); + const out = await refusal(await POST(request())); + expect(out.status).toBe(401); + expect(out.code).toBe("passkey.challenge.expired"); + }); + + it("answers a passkey that is not registered here with 404 passkey.unknown", async () => { + vi.mocked(prisma.passkey.findUnique).mockResolvedValue(null as never); + expect(await refusal(await POST(request()))).toEqual({ + status: 404, + code: "passkey.unknown", + data: null, + }); + expect(auditLog).toHaveBeenCalledWith( + "auth.login.failed", + expect.objectContaining({ details: { reason: "passkey_unknown" } }), + ); + }); + + it("answers a body that is not an assertion with 422 passkey.response.invalid", async () => { + const out = await refusal(await POST(request({ id: "cred-1" }))); + expect(out.status).toBe(422); + expect(out.code).toBe("passkey.response.invalid"); + }); + + it("answers an assertion the library refuses with 401 passkey.verification.failed", async () => { + vi.mocked(verifyAuthenticationResponse).mockRejectedValue( + new Error("Unexpected authentication response origin"), + ); + const out = await refusal(await POST(request())); + expect(out.status).toBe(401); + expect(out.code).toBe("passkey.verification.failed"); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it("still lets a real fault surface as one", async () => { + vi.mocked(prisma.passkey.findUnique).mockRejectedValue( + new Error("connection reset"), + ); + const res = await POST(request()); + expect(res.status).toBe(500); + }); +}); diff --git a/src/app/api/auth/passkey/login-verify/route.ts b/src/app/api/auth/passkey/login-verify/route.ts index 4032fdb15..527c29bcc 100644 --- a/src/app/api/auth/passkey/login-verify/route.ts +++ b/src/app/api/auth/passkey/login-verify/route.ts @@ -1,4 +1,8 @@ -import { verifyAuthentication } from "@/lib/auth/passkey"; +import { + PasskeyAuthError, + verifyAuthentication, + type PasskeyAuthErrorKind, +} from "@/lib/auth/passkey"; import { createSession } from "@/lib/auth/session"; import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; import { recordSignInDevice } from "@/lib/auth/login-alert"; @@ -20,6 +24,43 @@ import { issueAccessAndRefresh } from "@/lib/auth/refresh-token"; import { isOidcOnly } from "@/lib/auth/oidc"; import { completePendingOidcLink } from "@/lib/auth/oidc-pending-link"; +const PASSKEY_VERIFICATION_FAILED_CODE = "passkey.verification.failed"; + +/** + * How each passkey refusal answers. The challenge and verification cases are + * 401 (the sign-in did not prove anything), the unknown credential is 404 (the + * passkey is not registered on this server), and a malformed body is 422. + */ +const PASSKEY_REFUSALS: Record< + PasskeyAuthErrorKind, + { status: number; errorCode: string; message: string; auditReason: string } +> = { + challenge_expired: { + status: 401, + errorCode: "passkey.challenge.expired", + message: "The sign-in took too long or was already used. Try again.", + auditReason: "passkey_challenge_expired", + }, + unknown_passkey: { + status: 404, + errorCode: "passkey.unknown", + message: "This passkey is not registered here.", + auditReason: "passkey_unknown", + }, + malformed: { + status: 422, + errorCode: "passkey.response.invalid", + message: "The passkey response could not be read.", + auditReason: "passkey_response_invalid", + }, + verification_failed: { + status: 401, + errorCode: PASSKEY_VERIFICATION_FAILED_CODE, + message: "Passkey verification failed", + auditReason: "passkey_verification_failed", + }, +}; + export const POST = apiHandler(async (request: NextRequest) => { // OIDC_ONLY must block passkey login too, not just password login — a // credential minted before SSO was mandated must not stay a live bypass. @@ -55,17 +96,39 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("challengeId and credential required", 422); } - const { verification, passkey } = await verifyAuthentication( - challengeId, - credential, - ); + let outcome: Awaited>; + try { + outcome = await verifyAuthentication(challengeId, credential); + } catch (err) { + // A known client-side case — an expired challenge, a passkey that is no + // longer registered here, a malformed or refused assertion — answers + // with its own status and code (iOS #116, item 20) so the app can tell + // the person what to do instead of reporting a server error. Anything + // else is a real fault and keeps propagating. + if (!(err instanceof PasskeyAuthError)) throw err; + const refusal = PASSKEY_REFUSALS[err.kind]; + await auditLog("auth.login.failed", { + ipAddress: ip, + details: { reason: refusal.auditReason }, + }); + annotate({ + action: { name: "auth.login.passkey.refused" }, + meta: { reason: err.kind }, + }); + return apiError(refusal.message, refusal.status, { + errorCode: refusal.errorCode, + }); + } + const { verification, passkey } = outcome; if (!verification.verified) { await auditLog("auth.login.failed", { ipAddress: ip, details: { reason: "passkey_verification_failed" }, }); - return apiError("Passkey verification failed", 401); + return apiError("Passkey verification failed", 401, { + errorCode: PASSKEY_VERIFICATION_FAILED_CODE, + }); } const user = await prisma.user.findUnique({ diff --git a/src/app/api/auth/passkeys/[id]/route.ts b/src/app/api/auth/passkeys/[id]/route.ts index b8a79f6cd..87cd24b77 100644 --- a/src/app/api/auth/passkeys/[id]/route.ts +++ b/src/app/api/auth/passkeys/[id]/route.ts @@ -135,7 +135,7 @@ export const DELETE = apiHandler( if (passkeyCount <= 1 && !hasPassword) { return apiError( - "Cannot delete — at least one authentication method must remain", + "Cannot delete. At least one authentication method must remain", 400, ); } diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts index adb89145f..2bcc6fa8f 100644 --- a/src/app/api/auth/refresh/route.ts +++ b/src/app/api/auth/refresh/route.ts @@ -96,14 +96,14 @@ export const POST = apiHandler(async (request: NextRequest) => { // the token and re-authenticates. if (result.reason === "already_used") { return apiError( - "Refresh token reuse detected — please log in again.", + "Refresh token reuse detected. Please log in again.", 401, { errorCode: "auth.refresh.reuse" }, ); } if (result.reason === "revoked") { return apiError( - "Refresh token has been revoked — please log in again.", + "Refresh token has been revoked. Please log in again.", 401, { errorCode: "auth.refresh.revoked" }, ); diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index 2c4b5623c..007cb8b55 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -58,7 +58,7 @@ export const POST = apiHandler(async (request: NextRequest) => { if (existingSession) { annotate({ action: { name: "auth.register.refused_authenticated" } }); return apiError( - "Already signed in — sign out first to use an invitation", + "Already signed in. Sign out first to use an invitation", 409, { errorCode: "auth.already_authenticated" }, ); diff --git a/src/app/api/dashboard/chart-overlay-prefs/route.ts b/src/app/api/dashboard/chart-overlay-prefs/route.ts index daa87e2ad..35338d159 100644 --- a/src/app/api/dashboard/chart-overlay-prefs/route.ts +++ b/src/app/api/dashboard/chart-overlay-prefs/route.ts @@ -97,39 +97,41 @@ export const PUT = apiHandler(async (request: NextRequest) => { return returnAllZodIssues(parsed.error, 422); } - // Read-modify-write inside a Serializable transaction so two - // concurrent toggles (e.g. user opens two tabs and flips overlays - // on different charts) can't drop one another's update by reading - // the same layout snapshot and clobbering each other on write. + // Read-modify-write under a row lock, so two concurrent toggles (two + // tabs, two charts flipped in quick succession) can neither drop one + // another's update nor fail. `FOR UPDATE` queues every writer for this + // account behind the one holding the row, and the read happens through + // the same `tx`, after the lock, so it sees the previous writer's commit. + // A Serializable transaction used to guard this instead: it kept the + // updates from clobbering each other by aborting all but one of them + // with a serialization failure, which surfaced as a 500. // Resolver normalises legacy / missing fields, so layouts saved // before v1.4.18 pick up the new field with default-empty prefs // without a one-off migration. - await prisma.$transaction( - async (tx) => { - const row = await tx.user.findUnique({ - where: { id: user.id }, - select: { dashboardWidgetsJson: true }, - }); + await prisma.$transaction(async (tx) => { + await tx.$queryRaw`SELECT id FROM users WHERE id = ${user.id} FOR UPDATE`; + const row = await tx.user.findUnique({ + where: { id: user.id }, + select: { dashboardWidgetsJson: true }, + }); - const current = resolveDashboardLayout(row?.dashboardWidgetsJson); - const next: DashboardLayout = { - ...current, - chartOverlayPrefs: { - ...(current.chartOverlayPrefs ?? {}), - [parsed.data.chartKey]: parsed.data.prefs, - }, - }; - const normalized = serializeDashboardLayout(next); + const current = resolveDashboardLayout(row?.dashboardWidgetsJson); + const next: DashboardLayout = { + ...current, + chartOverlayPrefs: { + ...(current.chartOverlayPrefs ?? {}), + [parsed.data.chartKey]: parsed.data.prefs, + }, + }; + const normalized = serializeDashboardLayout(next); - await tx.user.update({ - where: { id: user.id }, - data: { - dashboardWidgetsJson: toJson(normalized), - }, - }); - }, - { isolationLevel: "Serializable" }, - ); + await tx.user.update({ + where: { id: user.id }, + data: { + dashboardWidgetsJson: toJson(normalized), + }, + }); + }); // The partial-update path mutates the same `User.dashboardWidgetsJson` // blob the `/api/dashboard/widgets` GET reads from. Bust the cache so diff --git a/src/app/api/dashboard/widgets/__tests__/route.test.ts b/src/app/api/dashboard/widgets/__tests__/route.test.ts index 177950e2f..fc9bc3e33 100644 --- a/src/app/api/dashboard/widgets/__tests__/route.test.ts +++ b/src/app/api/dashboard/widgets/__tests__/route.test.ts @@ -11,6 +11,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +const transactionLock = vi.hoisted(() => vi.fn(async () => [])); const transactionUser = vi.hoisted(() => ({ findUnique: vi.fn(), update: vi.fn(), @@ -123,8 +124,12 @@ beforeEach(() => { // test below the dead-switch block assumes. vi.mocked(isModuleEnabled).mockResolvedValue(true as never); vi.mocked(prisma.$transaction).mockImplementation((async ( - callback: (tx: { user: typeof transactionUser }) => Promise, - ) => callback({ user: transactionUser })) as never); + callback: (tx: { + user: typeof transactionUser; + $queryRaw: typeof transactionLock; + }) => Promise, + ) => + callback({ user: transactionUser, $queryRaw: transactionLock })) as never); }); describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)", () => { @@ -929,7 +934,9 @@ describe("DELETE /api/dashboard/widgets — web-owned reset scope", () => { selectedScoreRings: ["READINESS", "SLEEP_SCORE"], heroRingOrder: ["SLEEP_SCORE", "HEALTH_SCORE", "READINESS"], enabledHeroItemKinds: ["milestone"], + todayCardVisible: false, }); + expect(stored.todayCardVisible).toBe(false); const advanced = new Date("2026-07-24T12:00:00.000Z"); transactionUser.findUnique.mockResolvedValue({ dashboardWidgetsJson: stored, @@ -940,9 +947,13 @@ describe("DELETE /api/dashboard/widgets — web-owned reset scope", () => { expect(res.status).toBe(200); expect(prisma.$transaction).toHaveBeenCalledOnce(); - expect(vi.mocked(prisma.$transaction).mock.calls[0]?.[1]).toEqual({ - isolationLevel: "Serializable", - }); + // A row lock, not Serializable isolation, which failed concurrent + // writers with a serialization error. + expect(vi.mocked(prisma.$transaction).mock.calls[0]?.[1]).toBeUndefined(); + expect(transactionLock).toHaveBeenCalledOnce(); + expect( + (transactionLock.mock.calls[0] as unknown[] | undefined)?.[0], + ).toEqual(expect.arrayContaining([expect.stringContaining("FOR UPDATE")])); expect(prisma.user.findUnique).not.toHaveBeenCalled(); expect(prisma.user.update).not.toHaveBeenCalled(); expect(transactionUser.findUnique).toHaveBeenCalledOnce(); @@ -958,6 +969,10 @@ describe("DELETE /api/dashboard/widgets — web-owned reset scope", () => { expect( Object.prototype.hasOwnProperty.call(persisted, "enabledHeroItemKinds"), ).toBe(false); + // A reset brings a hidden top card back; shown is stored by omission. + expect( + Object.prototype.hasOwnProperty.call(persisted, "todayCardVisible"), + ).toBe(false); expect(persisted.comparisonBaseline).toBe("lastYear"); expect(persisted.chartOverlayPrefs).toEqual({ weight: { @@ -1076,6 +1091,96 @@ describe("dashboard widgets — hero primary content", () => { }); }); +describe("dashboard widgets — the top card switch", () => { + const weightOnly = [ + { id: "weight" as const, visible: true, tileVisible: true, order: 0 }, + ]; + + function persistedLayout() { + const updateArg = vi.mocked(prisma.user.update).mock + .calls[0]?.[0] as unknown as { + data: { dashboardWidgetsJson: { todayCardVisible?: boolean } }; + }; + return updateArg.data.dashboardWidgetsJson; + } + + it("accepts and persists todayCardVisible: false", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: null, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ version: 1, widgets: weightOnly, todayCardVisible: false }), + ); + expect(res.status).toBe(200); + expect(persistedLayout().todayCardVisible).toBe(false); + const body = (await res.json()) as { + data: { todayCardVisible?: boolean }; + }; + expect(body.data.todayCardVisible).toBe(false); + }); + + it("rejects a non-boolean with 422", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: null, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ version: 1, widgets: weightOnly, todayCardVisible: "no" }), + ); + expect(res.status).toBe(422); + expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled(); + }); + + it("keeps a hidden top card hidden when the client omits the field", async () => { + // A save from a client that predates the field must not bring it back. + const stored = serializeDashboardLayout({ + version: 1, + widgets: weightOnly, + todayCardVisible: false, + }); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: stored, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut(makeReq({ version: 1, widgets: weightOnly })); + expect(res.status).toBe(200); + expect(persistedLayout().todayCardVisible).toBe(false); + }); + + it("stores showing it again as the omitted default", async () => { + const stored = serializeDashboardLayout({ + version: 1, + widgets: weightOnly, + todayCardVisible: false, + }); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: stored, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + + const res = await callPut( + makeReq({ version: 1, widgets: weightOnly, todayCardVisible: true }), + ); + expect(res.status).toBe(200); + expect(persistedLayout()).not.toHaveProperty("todayCardVisible"); + }); + + it("GET answers true for a layout that never chose", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dashboardWidgetsJson: serializeDashboardLayout(DEFAULT_DASHBOARD_LAYOUT), + } as never); + const res = await callGet(); + const body = (await res.json()) as { + data: { todayCardVisible?: boolean }; + }; + expect(body.data.todayCardVisible).toBe(true); + }); +}); + describe("a toggle that cannot do anything is not offered", () => { // A switch that is on over a tile that can never paint, with nothing // saying why, is the defect the HRV fallback exists to remove — so the diff --git a/src/app/api/dashboard/widgets/route.ts b/src/app/api/dashboard/widgets/route.ts index 13a1c37a7..d4d3675b1 100644 --- a/src/app/api/dashboard/widgets/route.ts +++ b/src/app/api/dashboard/widgets/route.ts @@ -170,6 +170,10 @@ const layoutSchema = z.object({ // Same preserve-when-absent contract as `comparisonBaseline`; the // resolver drops unknown stored values back to "score" on read. hero: z.enum(HERO_PRIMARY_CONTENTS).optional(), + // v1.42 — whether the dashboard shows its top card. Same additive, + // preserve-when-absent contract as `hero`; the default (shown) is omitted + // from storage. + todayCardVisible: z.boolean().optional(), // v1.32.16 (issue #581) — the optimistic-concurrency base token used to // live here. v1.32.21 (R5a) moved it to the shared `takeBaseToken` helper, // which strips it BEFORE this parse: the token is transport, not a layout @@ -490,30 +494,33 @@ export const DELETE = apiHandler(async () => { // dashboard to defaults is the loudest possible version of redecorating it. const { user } = await requireAuth(); - const { normalized, updatedAt } = await prisma.$transaction( - async (tx) => { - const existing = await tx.user.findUnique({ - where: { id: user.id }, - select: { dashboardWidgetsJson: true }, - }); - const existingLayout = resolveDashboardLayout( - existing?.dashboardWidgetsJson, - ); - const normalized = serializeDashboardLayout({ - ...existingLayout, - widgets: DEFAULT_DASHBOARD_LAYOUT.widgets, - enabledHeroItemKinds: DEFAULT_DASHBOARD_LAYOUT.enabledHeroItemKinds, - }); + // Under a row lock rather than Serializable isolation: a reset racing a + // chart-overlay toggle on the same account waits for it instead of + // failing with a serialization error (a 500). + const { normalized, updatedAt } = await prisma.$transaction(async (tx) => { + await tx.$queryRaw`SELECT id FROM users WHERE id = ${user.id} FOR UPDATE`; + const existing = await tx.user.findUnique({ + where: { id: user.id }, + select: { dashboardWidgetsJson: true }, + }); + const existingLayout = resolveDashboardLayout( + existing?.dashboardWidgetsJson, + ); + const normalized = serializeDashboardLayout({ + ...existingLayout, + widgets: DEFAULT_DASHBOARD_LAYOUT.widgets, + enabledHeroItemKinds: DEFAULT_DASHBOARD_LAYOUT.enabledHeroItemKinds, + // A reset brings a hidden top card back. + todayCardVisible: DEFAULT_DASHBOARD_LAYOUT.todayCardVisible, + }); - const updated = await tx.user.update({ - where: { id: user.id }, - data: { dashboardWidgetsJson: toJson(normalized) }, - select: { updatedAt: true }, - }); - return { normalized, updatedAt: updated.updatedAt.toISOString() }; - }, - { isolationLevel: "Serializable" }, - ); + const updated = await tx.user.update({ + where: { id: user.id }, + data: { dashboardWidgetsJson: toJson(normalized) }, + select: { updatedAt: true }, + }); + return { normalized, updatedAt: updated.updatedAt.toISOString() }; + }); annotate({ action: { name: "dashboard.widgets.reset" } }); diff --git a/src/app/api/day/[date]/route.ts b/src/app/api/day/[date]/route.ts new file mode 100644 index 000000000..5ca359c8a --- /dev/null +++ b/src/app/api/day/[date]/route.ts @@ -0,0 +1,59 @@ +/** + * `GET /api/day/{date}` — one local day across the record (v1.42, #613): what + * ran through it, the readings in its window, what happened on it, and the + * few deterministic observations worth a second look. The shape is + * `DayResponse` in `src/lib/day/contract.ts`; `loadDay` builds it. + * + * Core, not a module: the day opens for every account. Sections of a + * switched-off module are left out. + * + * Admission: a read of the record's readings (`measurements`), narrowed + * section by section after that. The day is built around the readings, so a + * grant has to open them before it opens the day; every other section is then + * read only when the grant also covers that section's own domain + * (`actingDomainVisibility`, the same predicate the body-site and visit routes + * narrow with), and a section it does not cover is named in `sections` with + * `not_shared` and never read. No section can show a delegate more than the + * section's own route would: the domain each one maps to is the one those + * routes declare (`src/lib/day/sections.ts`). + */ +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { loadDay } from "@/lib/day/load-day"; +import { resolveDayAccess } from "@/lib/day/sections"; +import { dayPathSchema } from "@/lib/day/wire-schemas"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; + +type RouteParams = { params: Promise<{ date: string }> }; + +export const GET = apiHandler(async (_request: Request, ctx: RouteParams) => { + const { user, grantId } = await requireRecordAuth("read", "measurements"); + const parsed = dayPathSchema.safeParse(await ctx.params); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { errorCode: "day.invalid" }); + } + const access = await resolveDayAccess({ + recordId: user.id, + domainVisible: await actingDomainVisibility(prisma, grantId), + owner: grantId === null, + }); + const day = await loadDay({ + recordId: user.id, + day: parsed.data.date, + access, + }); + annotate({ + action: { name: "day.read" }, + meta: { + values: day.counts.values, + entries: day.counts.entries, + running: day.running.length, + notable: day.notable.length, + not_shared: Object.keys(day.sections).length, + delegated: grantId !== null, + }, + }); + return apiSuccess(day); +}); diff --git a/src/app/api/day/index/route.ts b/src/app/api/day/index/route.ts new file mode 100644 index 000000000..08269d4fe --- /dev/null +++ b/src/app/api/day/index/route.ts @@ -0,0 +1,57 @@ +/** + * `GET /api/day/index?from=&to=` — which days in a window hold anything, and + * which of them carry a notable observation (v1.42, #613). Feeds the row of + * day dots under a chart and the day links in lists; at most + * `DAY_INDEX_MAX_SPAN_DAYS` days per call. The shape is `DayIndexResponse` in + * `src/lib/day/contract.ts`. + * + * Same admission and the same section narrowing as the day itself: a grant + * that does not cover a section never sees that section's days. + */ +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { loadDayIndex } from "@/lib/day/day-index"; +import { resolveDayAccess } from "@/lib/day/sections"; +import { dayIndexQuerySchema } from "@/lib/day/wire-schemas"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; + +export const GET = apiHandler(async (request: Request) => { + const { user, grantId } = await requireRecordAuth("read", "measurements"); + const params = new URL(request.url).searchParams; + const parsed = dayIndexQuerySchema.safeParse({ + from: params.get("from") ?? undefined, + to: params.get("to") ?? undefined, + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { errorCode: "day.invalid" }); + } + const [access, tz] = await Promise.all([ + actingDomainVisibility(prisma, grantId).then((domainVisible) => + resolveDayAccess({ + recordId: user.id, + domainVisible, + owner: grantId === null, + }), + ), + resolveUserTimezone(user.id), + ]); + const index = await loadDayIndex({ + recordId: user.id, + from: parsed.data.from, + to: parsed.data.to, + access, + tz, + }); + annotate({ + action: { name: "day.index.read" }, + meta: { + days: Object.keys(index.days).length, + notable: index.notable.length, + delegated: grantId !== null, + }, + }); + return apiSuccess(index); +}); diff --git a/src/app/api/day/notable/route.ts b/src/app/api/day/notable/route.ts new file mode 100644 index 000000000..74cc8ec9f --- /dev/null +++ b/src/app/api/day/notable/route.ts @@ -0,0 +1,60 @@ +/** + * `GET /api/day/notable?from=&to=` — what changed and what stood out in a + * window, for the preparation of the next visit (v1.42, #613). Without + * `from` the window starts at the last completed visit the caller may see; + * without `to` it ends today. The shape is `DayNotableWindowResponse` in + * `src/lib/day/contract.ts`. + * + * Observations and changes only, as keys and the record's own names: no + * value judgement, no cause. Same admission and section narrowing as the day. + */ +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { resolveDayAccess } from "@/lib/day/sections"; +import { loadSinceLastVisit } from "@/lib/day/visit-preparation"; +import { dayNotableQuerySchema } from "@/lib/day/wire-schemas"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; +import { userDayKey } from "@/lib/tz/format"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; + +export const GET = apiHandler(async (request: Request) => { + const { user, grantId } = await requireRecordAuth("read", "measurements"); + const params = new URL(request.url).searchParams; + const parsed = dayNotableQuerySchema.safeParse({ + from: params.get("from") ?? undefined, + to: params.get("to") ?? undefined, + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { errorCode: "day.invalid" }); + } + const [access, tz] = await Promise.all([ + actingDomainVisibility(prisma, grantId).then((domainVisible) => + resolveDayAccess({ + recordId: user.id, + domainVisible, + owner: grantId === null, + }), + ), + resolveUserTimezone(user.id), + ]); + const window = await loadSinceLastVisit({ + recordId: user.id, + access, + tz, + today: userDayKey(new Date(), tz), + from: parsed.data.from, + to: parsed.data.to, + }); + annotate({ + action: { name: "day.notable.read" }, + meta: { + anchor: window.anchor, + observations: window.observations.length, + changes: window.changes.length, + delegated: grantId !== null, + }, + }); + return apiSuccess(window); +}); diff --git a/src/app/api/documents/inbound/layout/__tests__/route.test.ts b/src/app/api/documents/inbound/layout/__tests__/route.test.ts new file mode 100644 index 000000000..41ca79b2b --- /dev/null +++ b/src/app/api/documents/inbound/layout/__tests__/route.test.ts @@ -0,0 +1,202 @@ +/** + * `/api/documents/inbound/layout` — the vault presentation. + * + * GET answers the defaults for an account that never chose, without writing + * a row; PUT is preserve-when-absent per field, refuses unknown values and + * unknown keys with the multi-issue 422, and both halves stop at the + * documents module gate. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + update: vi.fn(), + }, + }, + toJson: (v: unknown) => v, +})); + +vi.mock("@/lib/auth/session", () => ({ + getSession: vi.fn(), +})); + +vi.mock("@/lib/logging/transports", () => ({ + emitIfSampled: vi.fn(), +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET, PUT } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { DEFAULT_DOCUMENTS_LAYOUT } from "@/lib/documents/documents-layout"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "tester", + role: "USER" as const, + displayName: null, + }, +}; + +const callGet = GET as unknown as () => Promise; +const callPut = PUT as unknown as (req: NextRequest) => Promise; + +function put(body: unknown): Promise { + return callPut( + new NextRequest("http://localhost/api/documents/inbound/layout", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); +}); + +describe("GET /api/documents/inbound/layout", () => { + it("answers the defaults and writes nothing for an account that never chose", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + documentsLayoutJson: null, + } as never); + + const res = await callGet(); + expect(res.status).toBe(200); + expect((await res.json()).data).toEqual(DEFAULT_DOCUMENTS_LAYOUT); + expect(DEFAULT_DOCUMENTS_LAYOUT).toEqual({ + version: 1, + view: "cards", + arrangement: "flow", + }); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("keeps a saved stacked arrangement although the default is flow", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + documentsLayoutJson: { + version: 1, + view: "cards", + arrangement: "stacked", + }, + } as never); + + const res = await callGet(); + expect((await res.json()).data.arrangement).toBe("stacked"); + }); + + it("serves the stored choice, repairing an unknown value field by field", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + documentsLayoutJson: { version: 1, view: "list", arrangement: "spiral" }, + } as never); + + const res = await callGet(); + expect((await res.json()).data).toEqual({ + version: 1, + view: "list", + arrangement: "flow", + }); + }); + + it("stops at the module gate", async () => { + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: new Response(null, { status: 403 }), + } as never); + + const res = await callGet(); + expect(res.status).toBe(403); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); +}); + +describe("PUT /api/documents/inbound/layout", () => { + it("keeps the stored arrangement when only the view changes", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + documentsLayoutJson: { version: 1, view: "cards", arrangement: "flow" }, + } as never); + + const res = await put({ version: 1, view: "list" }); + expect(res.status).toBe(200); + const expected = { version: 1, view: "list", arrangement: "flow" }; + expect((await res.json()).data).toEqual(expected); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { documentsLayoutJson: expected }, + }); + }); + + it("keeps the stored view when only the arrangement changes", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + documentsLayoutJson: { version: 1, view: "list", arrangement: "stacked" }, + } as never); + + const res = await put({ version: 1, arrangement: "flow" }); + expect((await res.json()).data).toEqual({ + version: 1, + view: "list", + arrangement: "flow", + }); + }); + + it("stores only the chosen field, so the untouched one keeps following the default", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + documentsLayoutJson: null, + } as never); + + const res = await put({ version: 1, view: "list" }); + expect((await res.json()).data).toEqual({ + version: 1, + view: "list", + arrangement: "flow", + }); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { documentsLayoutJson: { version: 1, view: "list" } }, + }); + }); + + it("refuses an unknown value and an unknown key with 422 and writes nothing", async () => { + const bad = await put({ version: 1, view: "carousel" }); + expect(bad.status).toBe(422); + const extra = await put({ version: 1, view: "list", userId: "user-2" }); + expect(extra.status).toBe(422); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("stops at the module gate before reading the body", async () => { + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: new Response(null, { status: 403 }), + } as never); + + const res = await put({ version: 1, view: "list" }); + expect(res.status).toBe(403); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/documents/inbound/layout/route.ts b/src/app/api/documents/inbound/layout/route.ts new file mode 100644 index 000000000..e81e5bca8 --- /dev/null +++ b/src/app/api/documents/inbound/layout/route.ts @@ -0,0 +1,103 @@ +/** + * GET / PUT the document vault presentation (view + month arrangement). + * + * GET returns the resolved presentation, defaults merged in when the user has + * not chosen yet. PUT changes it with preserve-when-absent semantics: a body + * carrying only `view` keeps the stored arrangement and vice versa, the same + * contract `/api/medications/layout` keeps for its view and order. The blob + * lives on its own `User` column (`documents_layout_json`) per the + * per-surface-column convention. + * + * Both halves sit behind the documents module gate like every other route + * under `/api/documents/inbound`. + */ +import { z } from "zod/v4"; + +import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { prisma, toJson } from "@/lib/db"; +import { + DOCUMENTS_LAYOUT_ARRANGEMENTS, + DOCUMENTS_LAYOUT_VIEWS, + resolveDocumentsLayout, + storedDocumentsLayoutChoices, + type DocumentsLayout, +} from "@/lib/documents/documents-layout"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +export const dynamic = "force-dynamic"; + +const layoutPutSchema = z + .object({ + version: z.literal(1), + view: z.enum(DOCUMENTS_LAYOUT_VIEWS).optional(), + arrangement: z.enum(DOCUMENTS_LAYOUT_ARRANGEMENTS).optional(), + }) + .strict(); + +async function readStoredLayout(userId: string): Promise { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { documentsLayoutJson: true }, + }); + return row?.documentsLayoutJson; +} + +export const GET = apiHandler(async () => { + // Read: the RECORD's presentation, so a delegate browsing somebody's vault + // sees it the way its owner arranged it. The write below stays the + // caller's own and refuses under a switch. + const { user } = await requireRecordAuth("read", "documents"); + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const layout = resolveDocumentsLayout(await readStoredLayout(user.id)); + annotate({ + action: { name: "documents.layout.read" }, + meta: { view: layout.view, arrangement: layout.arrangement }, + }); + return apiSuccess(layout); +}); + +export const PUT = apiHandler(async (request: Request) => { + // Bare on purpose: how the owner's vault is laid out is the owner's choice, + // not something a helper rearranges on their behalf. + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "inboundDocuments"); + if (!gate.enabled) return gate.response; + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; + + const parsed = layoutPutSchema.safeParse(body); + if (!parsed.success) { + annotate({ action: { name: "documents.layout.validation-failed" } }); + return returnAllZodIssues(parsed.error, 422); + } + + // Preserve-when-absent: one stored read fills whichever field the body + // left out. Only fields the user has chosen are written, so a field never + // picked keeps following the default (which can change between releases). + const choices = { + ...storedDocumentsLayoutChoices(await readStoredLayout(user.id)), + ...(parsed.data.view ? { view: parsed.data.view } : {}), + ...(parsed.data.arrangement + ? { arrangement: parsed.data.arrangement } + : {}), + }; + const next: DocumentsLayout = resolveDocumentsLayout(choices); + + await prisma.user.update({ + where: { id: user.id }, + data: { documentsLayoutJson: toJson({ version: 1, ...choices }) }, + }); + + annotate({ + action: { name: "documents.layout.update" }, + meta: { view: next.view, arrangement: next.arrangement }, + }); + return apiSuccess(next); +}); diff --git a/src/app/api/encounters/[id]/route.ts b/src/app/api/encounters/[id]/route.ts index a83c937cf..9445b6416 100644 --- a/src/app/api/encounters/[id]/route.ts +++ b/src/app/api/encounters/[id]/route.ts @@ -297,7 +297,7 @@ export const PATCH = apiHandler( if (outcome === "contradictory-reminder") { return apiError( - "A booked visit cannot also close a checkup — it has not happened yet", + "A booked visit cannot also close a checkup. It has not happened yet", 422, { errorCode: "encounter.reminder-conflict" }, ); diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index 87e72f062..43151980d 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -301,7 +301,7 @@ async function postEncounter(request: NextRequest): Promise { } if (created === "contradictory-reminder") { return apiError( - "A planned visit cannot also close a checkup — it has not happened yet", + "A planned visit cannot also close a checkup. It has not happened yet", 422, { errorCode: "encounter.reminder-conflict" }, ); diff --git a/src/app/api/environment/backfill/__tests__/route.test.ts b/src/app/api/environment/backfill/__tests__/route.test.ts new file mode 100644 index 000000000..2d1cea45f --- /dev/null +++ b/src/app/api/environment/backfill/__tests__/route.test.ts @@ -0,0 +1,117 @@ +/** + * v1.42 — one account must not drain the instance-wide Open-Meteo budget. + * + * The backfill route shared the analytics-read bucket (120 a minute) and sent + * every explicit range without a key, so one account could queue a + * two-year backfill again and again. It is now held to three a rolling hour, + * and to one queued backfill per twenty-minute slot, keyed on the account. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { send, store } = vi.hoisted(() => ({ + send: vi.fn(), + store: new Map(), +})); + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>( + h: T, + ): T => h, + requireAuth: vi.fn(async () => ({ user: { id: "acct-1" } })), + }; +}); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: () => ({ send }), +})); +vi.mock("@/lib/rate-limit", async () => { + const actual = + await vi.importActual( + "@/lib/rate-limit", + ); + const counted = (limit: number) => async (key: string) => { + const count = (store.get(key) ?? 0) + 1; + store.set(key, count); + return { + allowed: count <= limit, + limit, + remaining: Math.max(0, limit - count), + resetAt: Date.now() + 1000, + }; + }; + return { + ...actual, + checkAnalyticsReadRateLimit: (id: string) => + counted(120)(`analytics-read:${id}`), + checkEnvironmentBackfillRateLimit: (id: string) => + counted(actual.ENVIRONMENT_BACKFILL_LIMIT)(`environment-backfill:${id}`), + }; +}); +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + findUnique: vi.fn(async () => ({ + homeLat: null, + homeLocationEncrypted: new Uint8Array([1]), + homeSince: new Date("2025-01-01T00:00:00Z"), + })), + }, + }, +})); + +import { POST } from "../route"; + +function post() { + return new NextRequest("http://localhost/api/environment/backfill", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ startDate: "2025-01-01", endDate: "2026-10-01" }), + }); +} + +beforeEach(() => { + store.clear(); + send.mockReset().mockResolvedValue("job-id"); +}); + +describe("POST /api/environment/backfill budget protection", () => { + it("sends with a per-account singleton key and slot", async () => { + const res = await POST(post()); + expect(res.status).toBe(202); + expect(send).toHaveBeenCalledWith( + "environment-fetch", + expect.objectContaining({ userId: "acct-1" }), + expect.objectContaining({ + singletonKey: "environment-backfill:acct-1", + singletonSeconds: 20 * 60, + }), + ); + }); + + it("answers 409 when the account's slot already holds a backfill", async () => { + send.mockResolvedValue(null); + const res = await POST(post()); + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.meta.errorCode).toBe("environment.backfill_pending"); + }); + + it("refuses the fourth backfill in an hour with 429", async () => { + for (let i = 0; i < 3; i++) { + expect((await POST(post())).status).toBe(202); + } + const res = await POST(post()); + expect(res.status).toBe(429); + expect(send).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/app/api/environment/backfill/route.ts b/src/app/api/environment/backfill/route.ts index 1a02e93aa..690a338ea 100644 --- a/src/app/api/environment/backfill/route.ts +++ b/src/app/api/environment/backfill/route.ts @@ -6,6 +6,11 @@ * The span is capped (the worker re-checks the cap too). Requires a home * location (otherwise there is nothing to resolve). Module-gated + rate-limited; * `userId` is narrowed from auth. + * + * Every backfill draws on the instance-wide Open-Meteo budget, so one account + * is held to three a rolling hour (429), one queued per twenty minutes (409 + * `environment.backfill_pending`), and its own share of the daily budget in + * the worker (`OPEN_METEO_ACCOUNT_DAY_SHARE`). */ import { NextRequest } from "next/server"; @@ -18,7 +23,10 @@ import { } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; -import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { + checkAnalyticsReadRateLimit, + checkEnvironmentBackfillRateLimit, +} from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; import { environmentBackfillSchema } from "@/lib/validations/environment"; import { @@ -26,7 +34,7 @@ import { defaultBackfillRange, utcDayKey, } from "@/lib/environment/service"; -import { enqueueEnvironmentFetch } from "@/lib/jobs/environment-fetch"; +import { enqueueEnvironmentBackfill } from "@/lib/jobs/environment-fetch"; const MS_PER_DAY = 24 * 60 * 60 * 1000; @@ -55,9 +63,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const profile = await prisma.user.findUnique({ where: { id: user.id }, - select: { homeLat: true, homeSince: true }, + select: { homeLat: true, homeLocationEncrypted: true, homeSince: true }, }); - if (profile?.homeLat == null) { + // The sealed home (v1.42), or the readable one the encryption backfill has + // not cleared yet. + if (profile?.homeLocationEncrypted == null && profile?.homeLat == null) { return apiError("Set a home location before backfilling.", 409, { errorCode: "environment.no_home", }); @@ -94,11 +104,24 @@ export const POST = apiHandler(async (request: NextRequest) => { ); } - const enqueued = await enqueueEnvironmentFetch({ + const backfillLimit = await checkEnvironmentBackfillRateLimit(user.id); + if (!backfillLimit.allowed) { + return apiError("Too many backfills. Please retry later.", 429, { + errorCode: "environment.backfill_rate_limited", + }); + } + + const outcome = await enqueueEnvironmentBackfill({ userId: user.id, startDate, endDate, }); + if (outcome === "already_queued") { + return apiError("A backfill for this account is already queued.", 409, { + errorCode: "environment.backfill_pending", + }); + } + const enqueued = outcome === "enqueued"; annotate({ action: { name: "environment.backfill.enqueue" }, diff --git a/src/app/api/environment/home/route.ts b/src/app/api/environment/home/route.ts index 507cfc6bb..3c6d554ce 100644 --- a/src/app/api/environment/home/route.ts +++ b/src/app/api/environment/home/route.ts @@ -16,7 +16,9 @@ import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; import { homeLocationSchema } from "@/lib/validations/environment"; import { roundCoarse } from "@/lib/environment/open-meteo"; +import { sealLocation } from "@/lib/environment/location-cipher"; import { enqueueEnvironmentFetch } from "@/lib/jobs/environment-fetch"; +import { enqueueAirQualityHistory } from "@/lib/jobs/environment-air-quality-history"; export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -46,27 +48,38 @@ export const PUT = apiHandler(async (request: NextRequest) => { // set/update re-stamps — the home is "effective from the moment it is set". const homeSince = new Date(); const home = { - homeLat: roundCoarse(lat), - homeLon: roundCoarse(lon), - homeLabel: label, - homeTimezone: timezone, - homeSince, + lat: roundCoarse(lat), + lon: roundCoarse(lon), + label, }; - await prisma.user.update({ where: { id: user.id }, data: home }); + await prisma.user.update({ + where: { id: user.id }, + data: { + homeLocationEncrypted: sealLocation(home), + homeLat: null, + homeLon: null, + homeLabel: null, + homeTimezone: timezone, + homeSince, + }, + }); // Kick a lookback refresh so recent days populate promptly. No-ops cleanly // when no worker is bound; the nightly cron still covers it. await enqueueEnvironmentFetch({ userId: user.id }); + // And the past: air quality for every earlier day with entries, in the + // background and inside the request budget. A no-op while it is off. + await enqueueAirQualityHistory(user.id); annotate({ action: { name: "environment.home.set" } }); return apiSuccess({ home: { - lat: home.homeLat, - lon: home.homeLon, - label: home.homeLabel, - timezone: home.homeTimezone, + lat: home.lat, + lon: home.lon, + label: home.label, + timezone, since: homeSince.toISOString(), }, }); diff --git a/src/app/api/environment/preferences/route.ts b/src/app/api/environment/preferences/route.ts new file mode 100644 index 000000000..e8e30f43b --- /dev/null +++ b/src/app/api/environment/preferences/route.ts @@ -0,0 +1,71 @@ +/** + * `PATCH /api/environment/preferences` — the caller's environment switches, + * today `airQualityEnabled` (v1.42, #615). Module-gated like the rest of the + * environment surface; `userId` is narrowed from auth. + * + * Off: the nightly run stops asking the air-quality feed for this account, + * and the overview, the Coach, MCP and the correlations leave the air-quality + * values out. Days already stored keep theirs. On again: a lookback run is + * queued, and the nightly gap fill catches up on the days that never had any. + * The operator switch (`ENVIRONMENT_AIR_QUALITY_DISABLED`) wins either way, + * and the answer says when it does. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; +import { environmentPreferencesSchema } from "@/lib/validations/environment"; +import { isAirQualityOperatorDisabled } from "@/lib/environment/open-meteo-air-quality"; +import { enqueueEnvironmentFetch } from "@/lib/jobs/environment-fetch"; +import { enqueueAirQualityHistory } from "@/lib/jobs/environment-air-quality-history"; + +export const PATCH = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + const gate = await requireModuleEnabled(user.id, "environment"); + if (!gate.enabled) return gate.response; + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; + + const parsed = environmentPreferencesSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "environment.invalid", + }); + } + + const before = await prisma.user.findUnique({ + where: { id: user.id }, + select: { environmentAirQualityEnabled: true }, + }); + const airQualityEnabled = parsed.data.airQualityEnabled; + await prisma.user.update({ + where: { id: user.id }, + data: { environmentAirQualityEnabled: airQualityEnabled }, + }); + + const operatorDisabled = isAirQualityOperatorDisabled(); + // Turned on: fetch the recent days now rather than at night. No-ops when + // no worker is bound; the nightly run covers it. + if ( + airQualityEnabled && + !operatorDisabled && + before?.environmentAirQualityEnabled === false + ) { + await enqueueEnvironmentFetch({ userId: user.id }); + // Switched on: fill the past as well, every earlier day with entries. + await enqueueAirQualityHistory(user.id); + } + + annotate({ + action: { name: "environment.preferences.update" }, + meta: { air_quality_enabled: airQualityEnabled }, + }); + + return apiSuccess({ airQualityEnabled, operatorDisabled }); +}); diff --git a/src/app/api/environment/route.ts b/src/app/api/environment/route.ts index bb447e24b..cb75a7260 100644 --- a/src/app/api/environment/route.ts +++ b/src/app/api/environment/route.ts @@ -5,6 +5,14 @@ * summary of stored daily observations (count + latest day), and the upstream * attribution string. Module-gated: a 403 `module.disabled` envelope when the * opt-in module is off. `userId` is narrowed from auth, never a body field. + * + * v1.42 (#615) — the locations are stored sealed and opened here, so the + * answer keeps its readable shape. Added beside it: the air-quality state + * (the account switch, the operator switch, how many days carry air-quality + * values), the newest stored day for the dashboard chips (with its air + * quality only while the part is on), and every attribution line in + * `attributions`, and the progress of the air-quality history backfill + * (`airQuality.history`). */ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; @@ -12,6 +20,11 @@ import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; import { OPEN_METEO_ATTRIBUTION } from "@/lib/environment/open-meteo"; +import { isAirQualityOperatorDisabled } from "@/lib/environment/open-meteo-air-quality"; +import { environmentAttributionLines } from "@/lib/environment/air-quality-contract"; +import { readLocation } from "@/lib/environment/location-cipher"; +import { isAirQualityActive } from "@/lib/environment/service"; +import { readAirQualityHistoryState } from "@/lib/environment/air-quality-history"; import { ENVIRONMENT_FETCH_QUEUE } from "@/lib/jobs/environment-fetch"; import { readQueueFailureForUser } from "@/lib/jobs/job-failures"; @@ -23,50 +36,101 @@ export const GET = apiHandler(async () => { const gate = await requireModuleEnabled(user.id, "environment"); if (!gate.enabled) return gate.response; - const [profile, travel, contextCount, latest, lastFetchFailure] = - await Promise.all([ - prisma.user.findUnique({ - where: { id: user.id }, - select: { - homeLat: true, - homeLon: true, - homeLabel: true, - homeTimezone: true, - homeSince: true, - timezone: true, - }, - }), - prisma.environmentTravelLocation.findMany({ - where: { userId: user.id }, - orderBy: { startDate: "desc" }, - select: { - id: true, - startDate: true, - endDate: true, - lat: true, - lon: true, - label: true, - }, - }), - prisma.environmentContext.count({ where: { userId: user.id } }), - prisma.environmentContext.findFirst({ - where: { userId: user.id }, - orderBy: { date: "desc" }, - select: { date: true, fetchedAt: true }, - }), - // A day count on its own cannot tell an empty module from a broken one — - // which is exactly how a job that failed on every run since the module - // shipped could render as "0 days recorded" and nothing else. The queue - // knows; the overview asks it. - readQueueFailureForUser(ENVIRONMENT_FETCH_QUEUE, user.id), - ]); + const [ + profile, + travelRows, + contextCount, + latest, + airDays, + latestAir, + lastFetchFailure, + ] = await Promise.all([ + prisma.user.findUnique({ + where: { id: user.id }, + select: { + homeLat: true, + homeLon: true, + homeLabel: true, + homeLocationEncrypted: true, + homeTimezone: true, + homeSince: true, + timezone: true, + environmentAirQualityEnabled: true, + environmentAqHistoryJson: true, + }, + }), + prisma.environmentTravelLocation.findMany({ + where: { userId: user.id }, + orderBy: { startDate: "desc" }, + select: { + id: true, + startDate: true, + endDate: true, + lat: true, + lon: true, + label: true, + locationEncrypted: true, + }, + }), + prisma.environmentContext.count({ where: { userId: user.id } }), + prisma.environmentContext.findFirst({ + where: { userId: user.id }, + orderBy: { date: "desc" }, + select: { + date: true, + fetchedAt: true, + tempMin: true, + tempMax: true, + apparentMax: true, + pm25Mean: true, + pm10Mean: true, + no2Mean: true, + o3Max8h: true, + eaqiMax: true, + uvIndexMax: true, + dustMax: true, + pollenAlderMax: true, + pollenBirchMax: true, + pollenGrassMax: true, + pollenMugwortMax: true, + pollenOliveMax: true, + pollenRagweedMax: true, + aqFetchedAt: true, + }, + }), + // A day "carries air quality" when the feed returned hourly values for + // it; a day fetched with nothing in it (outside the feed) does not count. + prisma.environmentContext.count({ + where: { userId: user.id, aqHours: { gt: 0 } }, + }), + prisma.environmentContext.findFirst({ + where: { userId: user.id, aqHours: { gt: 0 } }, + orderBy: { date: "desc" }, + select: { date: true, aqDomain: true }, + }), + // A day count on its own cannot tell an empty module from a broken one — + // which is exactly how a job that failed on every run since the module + // shipped could render as "0 days recorded" and nothing else. The queue + // knows; the overview asks it. + readQueueFailureForUser(ENVIRONMENT_FETCH_QUEUE, user.id), + ]); + const homeLocation = profile + ? readLocation({ + sealed: profile.homeLocationEncrypted, + lat: profile.homeLat, + lon: profile.homeLon, + // The label may be absent on a pre-v1.42 home; the answer has + // always allowed a null label there. + label: profile.homeLabel ?? "", + }) + : null; const home = - profile?.homeLat != null && profile?.homeLon != null + profile && homeLocation ? { - lat: profile.homeLat, - lon: profile.homeLon, - label: profile.homeLabel, + lat: homeLocation.lat, + lon: homeLocation.lon, + label: homeLocation.label === "" ? null : homeLocation.label, timezone: profile.homeTimezone ?? profile.timezone, // Effective-from instant: the settings surface uses it to label the // home and to prefill the backfill start (conservative default range). @@ -74,12 +138,85 @@ export const GET = apiHandler(async () => { } : null; + const travel = travelRows.flatMap((row) => { + const location = readLocation({ + sealed: row.locationEncrypted, + lat: row.lat, + lon: row.lon, + label: row.label, + }); + return location + ? [ + { + id: row.id, + startDate: row.startDate, + endDate: row.endDate, + ...location, + }, + ] + : []; + }); + + const accountEnabled = profile?.environmentAirQualityEnabled ?? true; + const operatorDisabled = isAirQualityOperatorDisabled(); + const airActive = isAirQualityActive(accountEnabled); + // The history backfill's progress, while the part is on: what the last run + // counted, so the card can say how far the past has been filled. + const historyState = airActive + ? readAirQualityHistoryState(profile?.environmentAqHistoryJson) + : null; + const history = historyState + ? { + total: historyState.total, + done: historyState.done, + complete: historyState.complete, + checkedAt: historyState.checkedAt, + } + : null; + const latestYear = Number( + (latestAir?.date ?? latest?.date ?? new Date().toISOString()).slice(0, 4), + ); + + const latestDay = latest + ? { + date: latest.date, + tempMin: latest.tempMin, + tempMax: latest.tempMax, + apparentMax: latest.apparentMax, + // The air-quality part of the day only while the part is on, and + // only once it was fetched: an unfetched day says nothing, which is + // not the same as clean air. + airQuality: + airActive && latest.aqFetchedAt + ? { + pm25Mean: latest.pm25Mean, + pm10Mean: latest.pm10Mean, + no2Mean: latest.no2Mean, + o3Max8h: latest.o3Max8h, + eaqiMax: latest.eaqiMax, + uvIndexMax: latest.uvIndexMax, + dustMax: latest.dustMax, + pollen: { + alder: latest.pollenAlderMax, + birch: latest.pollenBirchMax, + grass: latest.pollenGrassMax, + mugwort: latest.pollenMugwortMax, + olive: latest.pollenOliveMax, + ragweed: latest.pollenRagweedMax, + }, + } + : null, + } + : null; + annotate({ action: { name: "environment.overview.read" }, meta: { has_home: home !== null, travel_count: travel.length, context_days: contextCount, + air_quality_days: airDays, + air_quality_active: airActive, last_fetch_failed: lastFetchFailure !== null, }, }); @@ -92,10 +229,23 @@ export const GET = apiHandler(async () => { latestDate: latest?.date ?? null, latestFetchedAt: latest?.fetchedAt?.toISOString() ?? null, }, + airQuality: { + enabled: accountEnabled, + operatorDisabled, + days: airDays, + latestDate: latestAir?.date ?? null, + domain: latestAir?.aqDomain ?? null, + history, + }, + latestDay, // null = the last background runs did not fail (or there is no queue to // ask). Non-null names the failure so a zero day count is never left to // stand alone. No error text: that message is written for an operator. lastFetchFailure, attribution: OPEN_METEO_ATTRIBUTION, + attributions: environmentAttributionLines({ + airQuality: airActive, + year: latestYear, + }), }); }); diff --git a/src/app/api/environment/travel/route.ts b/src/app/api/environment/travel/route.ts index 5f0a0eed2..0f38eda59 100644 --- a/src/app/api/environment/travel/route.ts +++ b/src/app/api/environment/travel/route.ts @@ -6,6 +6,10 @@ * fallback). Coordinates are rounded to 1 decimal (about 11 km). Adding an override * enqueues a lookback refresh so its days re-resolve. Module-gated; `userId` is * narrowed from auth. + * + * v1.42 (#615) — the period's location is stored sealed + * (`locationEncrypted`); the readable columns are written empty and the + * answer carries the coarse location as before. */ import { NextRequest } from "next/server"; @@ -16,6 +20,7 @@ import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; import { travelLocationSchema } from "@/lib/validations/environment"; import { roundCoarse } from "@/lib/environment/open-meteo"; +import { sealLocation } from "@/lib/environment/location-cipher"; import { enqueueEnvironmentFetch } from "@/lib/jobs/environment-fetch"; export const POST = apiHandler(async (request: NextRequest) => { @@ -41,24 +46,24 @@ export const POST = apiHandler(async (request: NextRequest) => { } const entry = parsed.data; - const created = await prisma.environmentTravelLocation.create({ + const location = { + lat: roundCoarse(entry.lat), + lon: roundCoarse(entry.lon), + label: entry.label, + }; + const row = await prisma.environmentTravelLocation.create({ data: { userId: user.id, startDate: entry.startDate, endDate: entry.endDate, - lat: roundCoarse(entry.lat), - lon: roundCoarse(entry.lon), - label: entry.label, - }, - select: { - id: true, - startDate: true, - endDate: true, - lat: true, - lon: true, - label: true, + locationEncrypted: sealLocation(location), + lat: null, + lon: null, + label: null, }, + select: { id: true, startDate: true, endDate: true }, }); + const created = { ...row, ...location }; // Re-resolve the affected window so the override's days pick up its weather. await enqueueEnvironmentFetch({ diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index 392263eff..bd0a478da 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -50,6 +50,7 @@ vi.mock("@/lib/db", () => ({ encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, + customVaccine: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, // The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the // same reason as the visit tables above. @@ -75,6 +76,7 @@ vi.mock("@/lib/db", () => ({ extractedFact: { findMany: vi.fn().mockResolvedValue([]) }, ecgRecording: { findMany: vi.fn().mockResolvedValue([]) }, symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) }, + lifeEvent: { findMany: vi.fn().mockResolvedValue([]) }, // v1.15.0 — cycle tables read by the full-backup helper. cycleProfile: { findUnique: vi.fn() }, menstrualCycle: { findMany: vi.fn() }, @@ -158,6 +160,7 @@ beforeEach(() => { [] as never, ); vi.mocked(prisma.vaccinationRecord.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.customVaccine.findMany).mockResolvedValue([] as never); vi.mocked(prisma.vaccinationDocumentLink.findMany).mockResolvedValue( [] as never, ); @@ -171,6 +174,7 @@ beforeEach(() => { vi.mocked(prisma.coachReminder.findMany).mockResolvedValue([] as never); vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never); vi.mocked(prisma.symptomDefinition.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.lifeEvent.findMany).mockResolvedValue([] as never); vi.mocked(prisma.moodTagCategory.findMany).mockResolvedValue([] as never); vi.mocked(prisma.moodTagHidden.findMany).mockResolvedValue([] as never); vi.mocked(prisma.mentalHealthAssessment.findMany).mockResolvedValue( diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts index 0e04c6bba..288b99d8e 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -76,6 +76,7 @@ vi.mock("@/lib/db", () => ({ extractedFact: { findMany: vi.fn() }, ecgRecording: { findMany: vi.fn() }, symptomDefinition: { findMany: vi.fn() }, + lifeEvent: { findMany: vi.fn() }, onboardingRecord: { findUnique: vi.fn().mockResolvedValue(null) }, environmentTravelLocation: { findMany: vi.fn().mockResolvedValue([]) }, environmentContext: { findMany: vi.fn().mockResolvedValue([]) }, @@ -88,6 +89,7 @@ vi.mock("@/lib/db", () => ({ measurementReminder: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, + customVaccine: { findMany: vi.fn().mockResolvedValue([]) }, encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, @@ -213,6 +215,7 @@ beforeEach(() => { vi.mocked(prisma.extractedFact.findMany).mockResolvedValue([] as never); vi.mocked(prisma.ecgRecording.findMany).mockResolvedValue([] as never); vi.mocked(prisma.symptomDefinition.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.lifeEvent.findMany).mockResolvedValue([] as never); vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never); }); @@ -275,6 +278,12 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => { where: { userId: "user-1", deletedAt: null }, }), ); + // v1.42 — a deleted life event stays out of a portable file. + expect(prisma.lifeEvent.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1", deletedAt: null }, + }), + ); // Biomarker + Workout carry no soft-delete tombstone — userId-only scope. expect(prisma.biomarker.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { userId: "user-1" } }), diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 8c01d3a7b..a96e6ac92 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -42,6 +42,7 @@ vi.mock("@/lib/db", () => ({ encounterLabLink: { findMany: vi.fn().mockResolvedValue([]) }, encounterConditionLink: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationRecord: { findMany: vi.fn().mockResolvedValue([]) }, + customVaccine: { findMany: vi.fn().mockResolvedValue([]) }, vaccinationDocumentLink: { findMany: vi.fn().mockResolvedValue([]) }, // The reminder engine tables (v1.37.20, #223 / iOS #68). Empty for the // same reason as the visit tables above. @@ -67,6 +68,7 @@ vi.mock("@/lib/db", () => ({ extractedFact: { findMany: vi.fn().mockResolvedValue([]) }, ecgRecording: { findMany: vi.fn().mockResolvedValue([]) }, symptomDefinition: { findMany: vi.fn().mockResolvedValue([]) }, + lifeEvent: { findMany: vi.fn().mockResolvedValue([]) }, cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/app/api/import/apple-health-export/route.ts b/src/app/api/import/apple-health-export/route.ts index 6919703ff..eb3c81f50 100644 --- a/src/app/api/import/apple-health-export/route.ts +++ b/src/app/api/import/apple-health-export/route.ts @@ -30,6 +30,7 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { APPLE_HEALTH_IMPORT_V2_QUEUE, + APPLE_HEALTH_IMPORT_KIND, APPLE_HEALTH_IMPORT_PARSER_REVISION, APPLE_HEALTH_IMPORT_SEND_OPTIONS, type AppleHealthImportPayload, @@ -39,6 +40,7 @@ import { createImportJobUnlessBusy, discardStagedUpload, IMPORT_BUSY_CODE, + IMPORT_BUSY_MESSAGE, } from "@/lib/import/apple-health-staging"; import { unlink } from "node:fs/promises"; @@ -123,6 +125,7 @@ export const POST = apiHandler(async (request: NextRequest) => { const existing = await prisma.importJob.findFirst({ where: { userId: user.id, + kind: APPLE_HEALTH_IMPORT_KIND, uploadSha256: uploaded.sha256, parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION, status: { not: "failed" }, @@ -167,11 +170,10 @@ export const POST = apiHandler(async (request: NextRequest) => { }); if ("busy" in staged) { await discardStagedUpload(uploaded.filePath); - return apiError( - "An Apple Health import is already running for this account. Wait for it to finish, then upload again.", - 409, - { errorCode: IMPORT_BUSY_CODE, jobId: staged.busy.id }, - ); + return apiError(IMPORT_BUSY_MESSAGE, 409, { + errorCode: IMPORT_BUSY_CODE, + jobId: staged.busy.id, + }); } const importJob = staged.created; diff --git a/src/app/api/import/csv/route.ts b/src/app/api/import/csv/route.ts index 37aafbf5e..e6990a216 100644 --- a/src/app/api/import/csv/route.ts +++ b/src/app/api/import/csv/route.ts @@ -149,7 +149,7 @@ export const POST = apiHandler(async (request: NextRequest) => { ); if (okRows.length > MAX_ROWS) { return apiError( - `CSV has ${okRows.length} valid rows — the limit is ${MAX_ROWS} per import`, + `CSV has ${okRows.length} valid rows. The limit is ${MAX_ROWS} per import`, 422, ); } diff --git a/src/app/api/import/health-connect-export/__tests__/route.test.ts b/src/app/api/import/health-connect-export/__tests__/route.test.ts new file mode 100644 index 000000000..bba3c79b6 --- /dev/null +++ b/src/app/api/import/health-connect-export/__tests__/route.test.ts @@ -0,0 +1,252 @@ +/** + * The Health Connect upload and status routes: the size cap, the staging + * name the sweep knows, the job's `kind`, the content-hash dedupe, the one + * import per account rule and the status of the account's latest job. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +const mocks = vi.hoisted(() => ({ + runningImportFindFirst: vi.fn(async () => null), + checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }), + bossSend: vi.fn().mockResolvedValue("boss-job-1"), + getGlobalBoss: vi.fn(), + importJobCreate: vi.fn(), + importJobFindFirst: vi.fn().mockResolvedValue(null), + importJobUpdate: vi.fn(), + streamToDisk: vi.fn(), + unlink: vi.fn().mockResolvedValue(undefined), + rm: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: mocks.checkRateLimit, +})); + +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: mocks.getGlobalBoss, +})); + +vi.mock("@/lib/multipart/stream-to-disk", () => ({ + streamMultipartToDisk: mocks.streamToDisk, +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, unlink: mocks.unlink, rm: mocks.rm }; +}); + +vi.mock("@/lib/db", () => ({ + prisma: { + importJob: { + create: mocks.importJobCreate, + findFirst: mocks.importJobFindFirst, + update: mocks.importJobUpdate, + }, + // The one-running-import check and the new row, in one transaction. + // The check finds nothing running unless a test says otherwise. + $transaction: async (fn: (tx: unknown) => unknown) => + fn({ + $executeRaw: async () => 0, + importJob: { + findFirst: mocks.runningImportFindFirst, + create: mocks.importJobCreate, + }, + }), + }, +})); + +import { POST } from "../route"; +import { GET as STATUS } from "../status/route"; +import { getSession } from "@/lib/auth/session"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +const STAGED_PATH = + "/tmp/healthlog-health-connect-import-0b7f3c2a-1d4e-4f5a-9b8c-7d6e5f4a3b2c.bin"; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + mocks.checkRateLimit.mockResolvedValue({ allowed: true }); + mocks.bossSend.mockResolvedValue("boss-job-1"); + mocks.getGlobalBoss.mockReturnValue({ send: mocks.bossSend }); + mocks.importJobCreate.mockResolvedValue({ id: "ij-1" }); + mocks.importJobFindFirst.mockResolvedValue(null); + mocks.runningImportFindFirst.mockResolvedValue(null); + mocks.importJobUpdate.mockResolvedValue({ id: "ij-1" }); + mocks.streamToDisk.mockResolvedValue({ + filePath: STAGED_PATH, + bytes: 100, + sha256: "deadbeef", + originalFilename: "Health Connect.zip", + textFields: {}, + }); + mocks.unlink.mockResolvedValue(undefined); + mocks.rm.mockResolvedValue(undefined); +}); + +function upload(headers: Record = {}): NextRequest { + return new NextRequest("http://localhost/api/import/health-connect-export", { + method: "POST", + headers: { + "content-type": "multipart/form-data; boundary=---x", + ...headers, + }, + body: "stub", + }); +} + +describe("POST /api/import/health-connect-export", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + expect((await POST(upload())).status).toBe(401); + }); + + it("refuses a declared body above 1 GiB before reading it", async () => { + const res = await POST( + upload({ "content-length": String(1024 * 1024 * 1024 + 1) }), + ); + expect(res.status).toBe(413); + expect(mocks.streamToDisk).not.toHaveBeenCalled(); + }); + + it("caps the streamed body at 1 GiB and stages it under its own name", async () => { + await POST(upload()); + expect(mocks.streamToDisk).toHaveBeenCalledWith( + expect.anything(), + expect.any(String), + expect.objectContaining({ + maxBytes: 1024 * 1024 * 1024, + tmpPrefix: "healthlog-health-connect-import", + }), + ); + }); + + it("creates a health_connect job and queues it on its own queue", async () => { + const res = await POST(upload()); + expect(res.status).toBe(202); + expect((await res.json()).data).toEqual({ + jobId: "ij-1", + status: "queued", + }); + expect(mocks.importJobCreate).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: "user-1", + kind: "health_connect", + uploadSha256: "deadbeef", + }), + }), + ); + expect(mocks.bossSend).toHaveBeenCalledWith( + "health-connect-import", + { userId: "user-1", importJobId: "ij-1", uploadPath: STAGED_PATH }, + { retryLimit: 0, expireInSeconds: 21600 }, + ); + }); + + it("resolves the same bytes to the earlier job and drops the new copy", async () => { + mocks.importJobFindFirst.mockResolvedValue({ id: "ij-0", status: "done" }); + const res = await POST(upload()); + expect(res.status).toBe(202); + expect((await res.json()).data).toEqual({ + jobId: "ij-0", + status: "done", + idempotent: true, + }); + expect(mocks.importJobFindFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + kind: "health_connect", + status: { not: "failed" }, + }), + }), + ); + expect(mocks.unlink).toHaveBeenCalledWith(STAGED_PATH); + expect(mocks.bossSend).not.toHaveBeenCalled(); + }); + + it("refuses a second import while one runs and discards the upload", async () => { + mocks.runningImportFindFirst.mockResolvedValue({ + id: "ij-running", + status: "parsing", + } as never); + const res = await POST(upload()); + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.meta).toMatchObject({ + errorCode: "import.apple_health.busy", + jobId: "ij-running", + }); + expect(mocks.rm).toHaveBeenCalledWith(STAGED_PATH, { force: true }); + expect(mocks.bossSend).not.toHaveBeenCalled(); + }); + + it("answers 503 and removes the upload when no worker runs", async () => { + mocks.getGlobalBoss.mockReturnValue(null); + const res = await POST(upload()); + expect(res.status).toBe(503); + expect(mocks.rm).toHaveBeenCalledWith(STAGED_PATH, { force: true }); + }); +}); + +describe("GET /api/import/health-connect-export/status", () => { + it("answers null when the account never imported", async () => { + const res = await STATUS( + new NextRequest( + "http://localhost/api/import/health-connect-export/status", + ), + ); + expect(res.status).toBe(200); + expect((await res.json()).data).toEqual({ job: null }); + }); + + it("answers the account's latest Health Connect job", async () => { + mocks.importJobFindFirst.mockResolvedValue({ + id: "ij-9", + status: "done", + startedAt: new Date("2026-10-01T10:00:00Z"), + completedAt: new Date("2026-10-01T10:01:00Z"), + uploadBytes: 1234, + progress: { rowsUpserted: 5 }, + result: { kind: "health_connect", totals: { rowsUpserted: 5 } }, + failureReason: null, + }); + const res = await STATUS( + new NextRequest( + "http://localhost/api/import/health-connect-export/status", + ), + ); + expect((await res.json()).data.job).toMatchObject({ + jobId: "ij-9", + status: "done", + completedAt: "2026-10-01T10:01:00.000Z", + }); + expect(mocks.importJobFindFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-1", kind: "health_connect" }, + }), + ); + }); +}); diff --git a/src/app/api/import/health-connect-export/route.ts b/src/app/api/import/health-connect-export/route.ts new file mode 100644 index 000000000..04177f556 --- /dev/null +++ b/src/app/api/import/health-connect-export/route.ts @@ -0,0 +1,191 @@ +/** + * `POST /api/import/health-connect-export` — upload an Android Health Connect + * export ZIP for background import (v1.42, #972). + * + * Mirrors the Apple Health upload (`../apple-health-export/route.ts`): the + * route sits outside the proxy matcher (`src/lib/http/proxy-bypass-routes.ts`) + * so the body is streamed to disk after authentication instead of buffered, + * hashed with SHA-256 on the way, refused past the size cap, and handed to + * the `health-connect-import` queue. One import runs per account at a time, + * whatever its kind (`createImportJobUnlessBusy`). Re-uploading the same + * bytes resolves to the still-viable job instead of importing twice; a failed + * job never blocks a retry of the same file. + * + * The audit row carries the upload's size and hash and nothing from inside + * the file. + */ +import type { NextRequest } from "next/server"; +import { unlink } from "node:fs/promises"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { + HEALTH_CONNECT_IMPORT_KIND, + HEALTH_CONNECT_IMPORT_PARSER_REVISION, + HEALTH_CONNECT_IMPORT_QUEUE, + HEALTH_CONNECT_IMPORT_SEND_OPTIONS, + type HealthConnectImportPayload, +} from "@/lib/jobs/health-connect-import-worker"; +import { streamMultipartToDisk } from "@/lib/multipart/stream-to-disk"; +import { + createImportJobUnlessBusy, + discardStagedUpload, + IMPORT_BUSY_CODE, + IMPORT_BUSY_MESSAGE, +} from "@/lib/import/apple-health-staging"; + +export const dynamic = "force-dynamic"; + +/** + * Hard cap on the upload. The ZIP holds one SQLite file that compresses to + * about half; a multi-year export with minute-by-minute heart rate sits in + * the low hundreds of megabytes, and 1 GiB leaves room for the long tail. + */ +const HEALTH_CONNECT_MAX_UPLOAD_BYTES = 1024 * 1024 * 1024; + +/** Three uploads per minute per account, like the Apple Health upload. */ +const RATE_LIMIT_MAX = 3; +const RATE_LIMIT_WINDOW_MS = 60_000; + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "import.health-connect.kickoff" } }); + + const rl = await checkRateLimit( + `import:health-connect:${user.id}`, + RATE_LIMIT_MAX, + RATE_LIMIT_WINDOW_MS, + ); + if (!rl.allowed) { + throw new HttpError(429, "Too many import uploads, try again later"); + } + + const declaredBytes = Number(request.headers.get("content-length") ?? 0); + if (declaredBytes > HEALTH_CONNECT_MAX_UPLOAD_BYTES) { + await auditLog("import.health-connect.kickoff.denied", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + reason: "content_length_exceeded", + contentLength: declaredBytes, + }, + }); + return apiError("Upload exceeds the 1 GB limit", 413); + } + + const body = request.body; + if (!body) return apiError("Request body is required", 400); + + let uploaded; + try { + uploaded = await streamMultipartToDisk( + body, + request.headers.get("content-type"), + { + maxBytes: HEALTH_CONNECT_MAX_UPLOAD_BYTES, + fieldName: "file", + tmpPrefix: "healthlog-health-connect-import", + }, + ); + } catch (err) { + const message = err instanceof Error ? err.message : "upload_failed"; + await auditLog("import.health-connect.kickoff.denied", { + userId: user.id, + ipAddress: getClientIp(request), + details: { reason: "stream_to_disk_failed" }, + }); + return apiError(`Multipart upload failed: ${message}`, 422); + } + + const existing = await prisma.importJob.findFirst({ + where: { + userId: user.id, + kind: HEALTH_CONNECT_IMPORT_KIND, + uploadSha256: uploaded.sha256, + parserRevision: HEALTH_CONNECT_IMPORT_PARSER_REVISION, + status: { not: "failed" }, + }, + orderBy: { startedAt: "desc" }, + }); + if (existing) { + await unlink(uploaded.filePath).catch(() => {}); + annotate({ meta: { idempotent_hit: true, job_id: existing.id } }); + return apiSuccess( + { jobId: existing.id, status: existing.status, idempotent: true }, + 202, + ); + } + + const boss = getGlobalBoss(); + if (!boss) { + await auditLog("import.health-connect.kickoff.denied", { + userId: user.id, + ipAddress: getClientIp(request), + details: { reason: "worker_not_running" }, + }); + await discardStagedUpload(uploaded.filePath); + throw new HttpError(503, "Background worker is not running"); + } + + const staged = await createImportJobUnlessBusy(prisma, { + userId: user.id, + kind: HEALTH_CONNECT_IMPORT_KIND, + status: "queued", + uploadBytes: uploaded.bytes, + uploadSha256: uploaded.sha256, + parserRevision: HEALTH_CONNECT_IMPORT_PARSER_REVISION, + }); + if ("busy" in staged) { + await discardStagedUpload(uploaded.filePath); + return apiError(IMPORT_BUSY_MESSAGE, 409, { + errorCode: IMPORT_BUSY_CODE, + jobId: staged.busy.id, + }); + } + const importJob = staged.created; + + const payload: HealthConnectImportPayload = { + userId: user.id, + importJobId: importJob.id, + uploadPath: uploaded.filePath, + }; + const bossJobId = await boss.send( + HEALTH_CONNECT_IMPORT_QUEUE, + payload, + HEALTH_CONNECT_IMPORT_SEND_OPTIONS, + ); + if (!bossJobId) { + await prisma.importJob.update({ + where: { id: importJob.id }, + data: { + status: "failed", + failureReason: + "The import could not be queued. Upload the export again.", + completedAt: new Date(), + }, + }); + await discardStagedUpload(uploaded.filePath); + throw new HttpError(503, "The import could not be queued"); + } + await prisma.importJob.update({ + where: { id: importJob.id }, + data: { pgBossJobId: bossJobId }, + }); + + await auditLog("import.health-connect.kickoff", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + jobId: importJob.id, + uploadBytes: uploaded.bytes, + uploadSha256: uploaded.sha256, + }, + }); + + return apiSuccess({ jobId: importJob.id, status: "queued" as const }, 202); +}); diff --git a/src/app/api/import/health-connect-export/status/route.ts b/src/app/api/import/health-connect-export/status/route.ts new file mode 100644 index 000000000..7b56526d9 --- /dev/null +++ b/src/app/api/import/health-connect-export/status/route.ts @@ -0,0 +1,53 @@ +/** + * `GET /api/import/health-connect-export/status` — the caller's most recent + * Health Connect import job (v1.42, #972), or `null` when there never was + * one. The settings card polls it while a job runs and shows the last + * outcome after a reload. + * + * The result is counts only (per type, per app); the import never stores a + * value or a timestamp from the file on the job row. + */ +import type { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { HEALTH_CONNECT_IMPORT_KIND } from "@/lib/jobs/health-connect-import-worker"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async (_request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "import.health-connect.status" } }); + + const row = await prisma.importJob.findFirst({ + where: { userId: user.id, kind: HEALTH_CONNECT_IMPORT_KIND }, + orderBy: { startedAt: "desc" }, + select: { + id: true, + status: true, + startedAt: true, + completedAt: true, + uploadBytes: true, + progress: true, + result: true, + failureReason: true, + }, + }); + + return apiSuccess({ + job: row + ? { + jobId: row.id, + status: row.status, + startedAt: row.startedAt.toISOString(), + completedAt: row.completedAt?.toISOString() ?? null, + uploadBytes: row.uploadBytes, + progress: (row.progress as Record) ?? {}, + result: (row.result as Record | null) ?? null, + failureReason: row.failureReason, + } + : null, + }); +}); diff --git a/src/app/api/ingest/medication/__tests__/route.test.ts b/src/app/api/ingest/medication/__tests__/route.test.ts index 43cba330c..37c3850c6 100644 --- a/src/app/api/ingest/medication/__tests__/route.test.ts +++ b/src/app/api/ingest/medication/__tests__/route.test.ts @@ -577,4 +577,22 @@ describe("POST /api/ingest/medication — the rate-limit bucket", () => { ); expect(res.status).toBe(429); }); + + it("refuses a dose for a record-only medication with medication.intake.notTracked", async () => { + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue( + null as never, + ); + vi.mocked(prisma.medication.findFirst).mockResolvedValue({ + id: "med-1", + userId: "user-1", + name: "Metformin", + trackIntake: false, + } as never); + const res = await POST( + postReq({ medicationName: "Metformin", idempotencyKey: "k-record-001" }), + ); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.meta?.errorCode).toBe("medication.intake.notTracked"); + }); }); diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index 2d45fc4bd..6b53f9c88 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -31,6 +31,8 @@ import { import { consumeForIntake } from "@/lib/medications/inventory/consumption"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { isRecordOnly } from "@/lib/medications/intake-tracking"; +import { refuseUntrackedIntake } from "@/lib/medications/route-guards"; import { NextRequest } from "next/server"; const RATE_LIMIT = 60; @@ -224,6 +226,11 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("API endpoint for this medication is disabled", 403); } + // A record-only medication takes no new dose — the same refusal every + // dose-writing route gives (iOS #116, item 15). Checked after the scope + // gate so a token without this medication's scope learns nothing about it. + if (isRecordOnly(medication)) return refuseUntrackedIntake(); + // The external ingest path runs without a User record in scope; // resolve the timezone once — band attribution and the compliance // recompute below both anchor on the user's local day. diff --git a/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap b/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap index 7581c58cd..0ba896537 100644 --- a/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap +++ b/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap @@ -1240,7 +1240,7 @@ exports[`coach chat golden transcripts > 04 inbound refusal on a new conversatio }, { "call": "appendMessage", - "content": "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.", + "content": "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.", "metricSource": { "metrics": [ "general", @@ -1256,7 +1256,7 @@ exports[`coach chat golden transcripts > 04 inbound refusal on a new conversatio "contentType": "text/event-stream; charset=utf-8", "frames": [ { - "token": "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.", + "token": "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.", "type": "token", }, { @@ -1302,7 +1302,7 @@ exports[`coach chat golden transcripts > 05 replay injection in a stored user tu }, { "call": "appendMessage", - "content": "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.", + "content": "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.", "metricSource": { "metrics": [ "general", @@ -1318,7 +1318,7 @@ exports[`coach chat golden transcripts > 05 replay injection in a stored user tu "contentType": "text/event-stream; charset=utf-8", "frames": [ { - "token": "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.", + "token": "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.", "type": "token", }, { diff --git a/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts b/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts deleted file mode 100644 index 06b0c2aaf..000000000 --- a/src/app/api/insights/coach/seeded-question/__tests__/route.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; - -/** - * v1.21.2 (A3) — the seeded-question route resolves today's single most - * notable derived signal into a tappable Coach opener, server-side. When the - * confidence + notability gate yields nothing the route returns - * `{ signal: null }` and the hero keeps its neutral greeting — never a - * fabricated opener. - */ -const findUniqueMock = vi.fn().mockResolvedValue({ notificationPrefs: null }); -vi.mock("@/lib/db", () => ({ - prisma: { - user: { findUnique: (...args: unknown[]) => findUniqueMock(...args) }, - }, -})); - -// The `coach` capability decides whether an opener is served at all; pinned -// here so the route test does not depend on the capability loader's reads. -const AVAILABLE = { available: true, reason: null, onDeviceAllowed: true }; -const getAiCapability = vi.fn(); -vi.mock("@/lib/ai/capabilities/gate", () => ({ - getAiCapability: (...args: unknown[]) => getAiCapability(...args), -})); - -vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/rate-limit", () => ({ - checkAnalyticsReadRateLimit: vi - .fn() - .mockResolvedValue({ allowed: true, remaining: 119, resetAt: 0 }), -})); -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -// Stub the profile loader + the shared briefing detector so the route test -// stays isolated from the compute tier. -vi.mock("@/lib/insights/derived", async (importOriginal) => ({ - ...(await importOriginal()), - loadBaselineProfile: vi - .fn() - .mockResolvedValue({ ageYears: 38, sex: "MALE", heightCm: 180 }), -})); - -vi.mock("@/lib/insights/derived-briefing", () => ({ - detectDerivedBriefingSignals: vi.fn(), -})); - -import { getSession } from "@/lib/auth/session"; -import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing"; -import { GET } from "../route"; - -const callGet = GET as unknown as (req: NextRequest) => Promise; -function makeReq(): NextRequest { - return new NextRequest( - new URL("http://localhost/api/insights/coach/seeded-question"), - ); -} - -const SESSION_OK = { - session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { - id: "user-1", - username: "testuser", - role: "USER" as const, - locale: "en", - }, -}; - -beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - findUniqueMock.mockResolvedValue({ notificationPrefs: null }); - getAiCapability.mockResolvedValue(AVAILABLE); -}); - -describe("GET /api/insights/coach/seeded-question", () => { - it("returns the most notable signal (the head) when one crosses the gate", async () => { - vi.mocked(detectDerivedBriefingSignals).mockResolvedValue({ - signals: [ - { - sourceMetric: "readiness", - label: "readiness", - score: 58, - band: "yellow", - confidence: 72, - }, - { - sourceMetric: "recovery", - label: "recovery", - score: 49, - band: "red", - confidence: 65, - }, - ], - }); - const res = await callGet(makeReq()); - expect(res.status).toBe(200); - const body = (await res.json()) as { - data: { - signal: { sourceMetric: string; score: number; band: string } | null; - } | null; - error: string | null; - }; - expect(body.error).toBeNull(); - expect(body.data?.signal).toEqual({ - sourceMetric: "readiness", - score: 58, - band: "yellow", - }); - expect(getAiCapability).toHaveBeenCalledWith("coach"); - expect((body.data as { ai?: unknown }).ai).toEqual(AVAILABLE); - }); - - it.each(["operator_disabled", "user_disabled", "no_provider"] as const)( - "answers { signal: null, ai } for %s without running the detector", - async (reason) => { - const ai = { available: false, reason, onDeviceAllowed: false }; - getAiCapability.mockResolvedValue(ai); - const res = await callGet(makeReq()); - expect(res.status).toBe(200); - const body = (await res.json()) as { - data: { signal: unknown; ai: unknown }; - }; - expect(body.data).toEqual({ signal: null, ai }); - expect(detectDerivedBriefingSignals).not.toHaveBeenCalled(); - }, - ); - - it("returns { signal: null } when nothing crosses the gate (neutral fallback)", async () => { - vi.mocked(detectDerivedBriefingSignals).mockResolvedValue(null); - const res = await callGet(makeReq()); - expect(res.status).toBe(200); - const body = (await res.json()) as { - data: { signal: unknown } | null; - error: string | null; - }; - expect(body.error).toBeNull(); - expect(body.data?.signal).toBeNull(); - }); - - it("returns { signal: null } and skips the detector when ambient suggestions are off", async () => { - findUniqueMock.mockResolvedValue({ - notificationPrefs: { coach: { ambientSuggestions: false } }, - }); - const res = await callGet(makeReq()); - expect(res.status).toBe(200); - const body = (await res.json()) as { - data: { signal: unknown } | null; - error: string | null; - }; - expect(body.error).toBeNull(); - expect(body.data?.signal).toBeNull(); - // The opt-out short-circuits before the compute tier. - expect(detectDerivedBriefingSignals).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/api/insights/coach/seeded-question/route.ts b/src/app/api/insights/coach/seeded-question/route.ts deleted file mode 100644 index 83efeacd2..000000000 --- a/src/app/api/insights/coach/seeded-question/route.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * v1.21.2 (A3) — pre-seeded relevance opener for the unscoped Coach. - * - * `GET /api/insights/coach/seeded-question` resolves today's single most - * notable derived wellness signal (readiness / recovery shift, the same - * confidence-gated detector the daily briefing uses) into a tappable - * suggested opener for the Coach's blank new-chat hero. When no signal - * crosses the gate the route returns `{ signal: null }` and the hero - * keeps its neutral greeting — never a fabricated opener. - * - * The signal is selected SERVER-SIDE; the client renders the resolved DTO - * and never recomputes it (server-authoritative parity). It IS registered in - * the OpenAPI contract. This comment said the opposite until the route was - * published — it shipped as a same-origin web surface and the sentence was - * never revisited. A comment that contradicts the registry is worse than no - * comment, because the next reader believes it instead of checking. - * - * Mirrors the sibling derived routes: `apiHandler` wrapper, `requireAuth` - * (userId narrowed from the session/Bearer, never a body/query field), - * analytics-read rate limit. - * - * A mixed read: the opener only exists to open the Coach, so it is served - * only while the `coach` capability is available. Otherwise the route answers - * 200 `{ signal: null, ai }`, the same neutral shape as "nothing notable", - * with the reason beside it. - */ -import { apiError, apiSuccess } from "@/lib/api-response"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { annotate } from "@/lib/logging/context"; -import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; -import { getAiCapability } from "@/lib/ai/capabilities/gate"; -import type { AiCapabilityState } from "@/lib/ai/capabilities/types"; -import { prisma } from "@/lib/db"; -import { loadBaselineProfile } from "@/lib/insights/derived"; -import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing"; -import { resolveCoachAmbientSuggestionsEnabled } from "@/lib/validations/notification-prefs"; - -export const dynamic = "force-dynamic"; - -/** Resolved opener the hero renders, or null when nothing notable. */ -export interface CoachSeededQuestionDTO { - /** - * The notable signal driving the opener, or null when nothing crossed - * the briefing detector's confidence + notability gate. - */ - signal: { - /** Sentinel id (`readiness` / `recovery`) the client keys i18n on. */ - sourceMetric: string; - /** Latest 0–100 score. */ - score: number; - /** Band — `yellow` / `red` (green never surfaces as notable). */ - band: string; - } | null; - /** Whether the Coach this opener feeds is available, and why not. */ - ai: AiCapabilityState; -} - -export const GET = apiHandler(async () => { - const { user } = await requireAuth(); - - // The opener feeds the Coach hero: with the Coach unavailable there is no - // hero to seed, so the neutral shape answers and says why. - const ai = await getAiCapability("coach"); - if (!ai.available) { - return apiSuccess({ signal: null, ai } satisfies CoachSeededQuestionDTO); - } - - const rl = await checkAnalyticsReadRateLimit(user.id); - if (!rl.allowed) { - return apiError("Too many analytics requests. Please retry later.", 429); - } - - // Server-authoritative ambient-suggestions opt-out: when the user turns - // proactive suggestions off the hero must not seed an opener, so the route - // skips the detector work and returns the neutral `{ signal: null }` shape. - const prefsRow = await prisma.user.findUnique({ - where: { id: user.id }, - select: { notificationPrefs: true }, - }); - if ( - !resolveCoachAmbientSuggestionsEnabled(prefsRow?.notificationPrefs ?? null) - ) { - annotate({ - action: { name: "coach.seeded-question.resolve" }, - meta: { has_signal: false, source_metric: "none", band: "none" }, - }); - return apiSuccess({ signal: null, ai } satisfies CoachSeededQuestionDTO); - } - - // Profile read once via the shared loader; passed into the detector, - // never re-fetched per candidate (the detector probes readiness + - // recovery off the one profile). - const profile = await loadBaselineProfile(prisma, user.id); - const detected = await detectDerivedBriefingSignals(user.id, profile); - - // The detector returns 1–2 signals in priority order; the opener anchors - // on the single most notable one (the head). - const top = detected?.signals[0] ?? null; - const signal: CoachSeededQuestionDTO["signal"] = top - ? { sourceMetric: top.sourceMetric, score: top.score, band: top.band } - : null; - - annotate({ - action: { name: "coach.seeded-question.resolve" }, - meta: { - has_signal: signal !== null, - source_metric: signal?.sourceMetric ?? "none", - band: signal?.band ?? "none", - }, - }); - - return apiSuccess({ signal, ai } satisfies CoachSeededQuestionDTO); -}); diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index 42413f24f..ee6729d96 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -70,6 +70,7 @@ vi.mock("@/lib/insights/correlation-patterns", () => ({ }, syncAcceptedPatterns: vi.fn().mockResolvedValue(new Map()), decisionForEvidence: vi.fn().mockReturnValue(null), + hadFindingsBeforeSeasonalAdjustment: vi.fn().mockResolvedValue(false), })); vi.mock("next/headers", () => ({ @@ -88,7 +89,10 @@ import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate"; import { apiError } from "@/lib/api-response"; import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; -import { syncAcceptedPatterns } from "@/lib/insights/correlation-patterns"; +import { + hadFindingsBeforeSeasonalAdjustment, + syncAcceptedPatterns, +} from "@/lib/insights/correlation-patterns"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -155,6 +159,7 @@ beforeEach(() => { ).mockResolvedValue([]); vi.mocked(prisma.customMetric.findMany).mockResolvedValue([] as never); vi.mocked(prisma.symptomDefinition.findMany).mockResolvedValue([] as never); + vi.mocked(hadFindingsBeforeSeasonalAdjustment).mockResolvedValue(false); }); const callGet = GET as unknown as (req: NextRequest) => Promise; @@ -181,6 +186,18 @@ describe("GET /api/insights/correlations", () => { expect(body.data.fdrQ).toBeGreaterThan(0); }); + it("reports whether the record had findings before the seasonal adjustment", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(hadFindingsBeforeSeasonalAdjustment).mockResolvedValue(true); + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { findingsBeforeSeasonalAdjustment: boolean }; + }; + expect(body.data.findingsBeforeSeasonalAdjustment).toBe(true); + expect(hadFindingsBeforeSeasonalAdjustment).toHaveBeenCalledWith("user-1"); + }); + it("serves a cache hit without re-running the discovery reads or the pattern-sync writes", async () => { // The SWR cell caches the built body for 60 s; the second GET inside that // window must neither re-read the measurement window nor re-run the two diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index c94021409..ef9594848 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -36,6 +36,7 @@ import { } from "@/lib/insights/correlation-discovery"; import { decisionForEvidence, + hadFindingsBeforeSeasonalAdjustment, PATTERN_FAMILIES, syncAcceptedPatterns, } from "@/lib/insights/correlation-patterns"; @@ -131,17 +132,23 @@ async function buildCorrelationsResponse( // // v1.22 — lab draws (for the labs ↔ outcome pass) fetch alongside; they feed // a different pass over a different grain, so they are not matrix channels. - const [matrix, labDraws] = await Promise.all([ - assembleDiscoveryMatrix(userId, { - tz, - since, - fetchMode: "tiered", - modules, - }), - isSurfaceVisible("correlation:LAB_DRAWS", modules) - ? fetchLabDraws(userId, tz, since) - : Promise.resolve([]), - ]); + // + // The earlier-findings probe is one indexed lookup on the pattern store; it + // tells the client whether this record had findings under the engine before + // the seasonal adjustment, which is what earns the one-time explanation. + const [matrix, labDraws, findingsBeforeSeasonalAdjustment] = + await Promise.all([ + assembleDiscoveryMatrix(userId, { + tz, + since, + fetchMode: "tiered", + modules, + }), + isSurfaceVisible("correlation:LAB_DRAWS", modules) + ? fetchLabDraws(userId, tz, since) + : Promise.resolve([]), + hadFindingsBeforeSeasonalAdjustment(userId), + ]); const { series, diagnostics } = matrix; const result = discoverCorrelations(series, { locale }); @@ -246,5 +253,6 @@ async function buildCorrelationsResponse( discovered, emerging: emergingWithDecisions, labCorrelations, + findingsBeforeSeasonalAdjustment, }; } diff --git a/src/app/api/insights/derived/__tests__/route.test.ts b/src/app/api/insights/derived/__tests__/route.test.ts index 2a3f56e68..efdb4407d 100644 --- a/src/app/api/insights/derived/__tests__/route.test.ts +++ b/src/app/api/insights/derived/__tests__/route.test.ts @@ -90,6 +90,7 @@ import { getSession } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; import { computeDerivedMetric } from "@/lib/insights/derived"; import { DERIVED_MAX_WINDOW_DAYS } from "@/lib/insights/derived/types"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -119,6 +120,8 @@ function makeReqWith(params: Record): NextRequest { beforeEach(() => { vi.clearAllMocks(); + // v1.42 — the route reads through the derived cache now. + __resetAllCachesForTests(); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: new Date("1986-01-01"), @@ -270,3 +273,34 @@ describe("GET /api/insights/derived — windowDays", () => { } }); }); + +// v1.42 — the single-score read was the one derived surface with no cache: +// every open of a score's detail recomputed it over the rollup tier. +describe("GET /api/insights/derived — read-through cache (v1.42)", () => { + it("serves a repeat read from the cache, per metric, type and window", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + await callGet(makeReq("VITALS_BASELINE", "RESTING_HEART_RATE")); + await callGet(makeReq("VITALS_BASELINE", "RESTING_HEART_RATE")); + expect(computeDerivedMetric).toHaveBeenCalledTimes(1); + + await callGet(makeReq("VITALS_BASELINE", "WEIGHT")); + await callGet( + makeReqWith({ + metric: "VITALS_BASELINE", + type: "RESTING_HEART_RATE", + windowDays: "7", + }), + ); + expect(computeDerivedMetric).toHaveBeenCalledTimes(3); + }); + + it("recomputes after the account's measurements change", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const { invalidateUserMeasurements } = + await import("@/lib/cache/invalidate"); + await callGet(makeReq("VITALS_BASELINE", "RESTING_HEART_RATE")); + invalidateUserMeasurements("user-1"); + await callGet(makeReq("VITALS_BASELINE", "RESTING_HEART_RATE")); + expect(computeDerivedMetric).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/app/api/insights/derived/route.ts b/src/app/api/insights/derived/route.ts index 2438fd636..ae67d9a22 100644 --- a/src/app/api/insights/derived/route.ts +++ b/src/app/api/insights/derived/route.ts @@ -12,7 +12,8 @@ * The `data` payload is the flat `Derived` union so iOS can decode + * combine values across metrics: `{ metric, status, value?, coverage, * confidence?, provenance, reason? }`. Numbers are pure compute over the - * rollup tier (Tier-1, no narrative) — no provider call, no cache table. + * rollup tier (Tier-1, no narrative) — no provider call, no cache table; + * since v1.42 the in-process derived cache, as the batch route uses. * * Wave 1 implements `VITALS_BASELINE` end-to-end; other registered ids * return a `not_implemented` insufficient until their compute lands in a @@ -36,6 +37,7 @@ import { DERIVED_MAX_WINDOW_DAYS } from "@/lib/insights/derived/types"; import { resolveDerivedAssessment } from "@/lib/insights/derived/derived-assessment-ai"; import { resolveServerLocale } from "@/lib/i18n/server-locale"; import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate"; +import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; export const dynamic = "force-dynamic"; @@ -122,14 +124,31 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!gate.enabled) return gate.response; } - const profile = await loadBaselineProfile(prisma, user.id); - const derived = await computeDerivedMetric({ - metric, - userId: user.id, - profile, - type: parsed.data.type ?? null, - windowDays: parsed.data.windowDays, - }); + // v1.42 — read-through the per-user derived cache with + // stale-while-revalidate, like the batch grid beside it. This single-score + // read was the one derived surface that recomputed on every request: the + // score detail sheet asks for it on each open, and a composite walks the + // rollup tier once per input. Same bucket, so the invalidation the batch + // relies on covers it unchanged (measurement writes evict or mark the + // `${userId}|` prefix stale, mood writes mark it stale). The module gate + // above and the AI assessment below stay per request. + const type = parsed.data.type ?? null; + const windowDays = parsed.data.windowDays; + const derived = await cachedSwr( + caches.insightsDerived as ServerCache< + Awaited> + >, + `${user.id}|single|${metric}|${type ?? ""}|${windowDays ?? ""}`, + async () => + computeDerivedMetric({ + metric, + userId: user.id, + profile: await loadBaselineProfile(prisma, user.id), + type, + windowDays, + }), + annotate, + ); annotate({ action: { name: "insights.derived" }, diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 3668e2443..40a00ebb8 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -810,7 +810,7 @@ export const POST = apiHandler((request: NextRequest) => // bounds cost. 429 with a distinct code so the client can say so. if (e instanceof BriefingBudgetExceededError) { return apiError( - "Daily AI token budget reached — insights will be available again tomorrow.", + "Daily AI token budget reached. Insights will be available again tomorrow.", 429, { errorCode: "insights.generate.budgetExceeded" }, ); @@ -859,7 +859,7 @@ export const POST = apiHandler((request: NextRequest) => ); if (allAuth) { return apiError( - "AI provider rejected the request — check your API key in Settings > AI", + "AI provider rejected the request. Check your API key in Settings > AI", 422, ); } @@ -881,7 +881,7 @@ export const POST = apiHandler((request: NextRequest) => ); if (allTransport) { return apiError( - "AI provider connection failed — check your AI settings", + "AI provider connection failed. Check your AI settings", 422, ); } @@ -907,7 +907,7 @@ export const POST = apiHandler((request: NextRequest) => const status = err.httpStatus ?? 0; if (status === 401 || status === 403) { return apiError( - "AI provider rejected the request — check your API key in Settings > AI", + "AI provider rejected the request. Check your API key in Settings > AI", 422, ); } @@ -921,7 +921,7 @@ export const POST = apiHandler((request: NextRequest) => ); } return apiError( - "AI provider connection failed — check your AI settings", + "AI provider connection failed. Check your AI settings", 422, ); } @@ -945,7 +945,7 @@ export const POST = apiHandler((request: NextRequest) => if (result.finishReason === "length") { annotate({ meta: { insights_response_truncated: true } }); return apiError( - "AI response was cut off before the JSON completed — raise the token limit (INSIGHTS_MAX_TOKENS)", + "AI response was cut off before the JSON completed. Raise the token limit (INSIGHTS_MAX_TOKENS)", 422, { errorCode: "ai_response_truncated" }, ); diff --git a/src/app/api/insights/score-history/route.ts b/src/app/api/insights/score-history/route.ts new file mode 100644 index 000000000..68c7be4bc --- /dev/null +++ b/src/app/api/insights/score-history/route.ts @@ -0,0 +1,98 @@ +/** + * `GET /api/insights/score-history?score=&days=` — one score's daily + * course over the trailing `days`, for the history chart on its page. + * + * Serves the three scores that have no stored measurement series of their + * own to chart: the health score (its `HealthScoreRecord` rows), readiness + * (the nightly blend the recovery job persists) and the sleep score + * (computed per night). The nightly recovery, stress and strain scores are + * stored readings and chart through the measurement series route like any + * other metric. What each point means, and why the health score's line can + * break, is written at `readScoreHistory`. + * + * `days` is the chart's range tab: 7, 30, 90, or the "All" tab's ten years. + * Any whole number in between is accepted, so a client is not tied to these + * tabs; out of range is a 422, never a silent clamp. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; +import { loadBaselineProfile } from "@/lib/insights/derived"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { resolveStoredTimezone } from "@/lib/tz/resolver"; +import { readScoreHistory } from "@/lib/insights/score-history"; +import { + SCORE_HISTORY_IDS, + SCORE_HISTORY_MAX_DAYS, +} from "@/lib/insights/score-history-ids"; +import { DERIVED_MODULE } from "../derived/route"; + +export const dynamic = "force-dynamic"; + +const scoreHistoryQuerySchema = z.object({ + score: z.enum(SCORE_HISTORY_IDS), + days: z.coerce.number().int().min(1).max(SCORE_HISTORY_MAX_DAYS), +}); + +export const GET = apiHandler(async (request: NextRequest) => { + // The same standing as the score itself: the derived scores and the other + // insight reads are a MANAGE-level read over the whole record. + const { user } = await requireRecordAuth("manage", "record"); + + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + + const parsed = scoreHistoryQuerySchema.safeParse({ + score: request.nextUrl.searchParams.get("score"), + days: request.nextUrl.searchParams.get("days"), + }); + if (!parsed.success) { + annotate({ + action: { name: "insights.score-history.invalid-query" }, + meta: { issue_count: parsed.error.issues.length }, + }); + return returnAllZodIssues(parsed.error, 422); + } + const { score, days } = parsed.data; + + // Readiness is the recovery module's, the sleep score the sleep module's, + // through the same map the derived routes gate on. The health score is + // core and carries no gate. + const moduleKey = + score === "HEALTH_SCORE" ? undefined : DERIVED_MODULE[score]; + if (moduleKey) { + const gate = await requireModuleEnabled(user.id, moduleKey); + if (!gate.enabled) return gate.response; + } + + const history = await readScoreHistory({ + userId: user.id, + score, + days, + now: new Date(), + tz: await resolveStoredTimezone(user.timezone), + profile: () => loadBaselineProfile(prisma, user.id), + priorityJson: () => loadUserSourcePriority(user.id), + }); + + annotate({ + action: { name: "insights.score-history" }, + meta: { + score, + days, + points: history.points.length, + seams: history.points.filter((p) => p.seamBreak).length, + has_band: history.band !== null, + }, + }); + + return apiSuccess(history); +}); diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts index 26ac0eae7..f029d6a7f 100644 --- a/src/app/api/integrations/healthkit/__tests__/route.test.ts +++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts @@ -8,12 +8,20 @@ vi.mock("@/lib/db", () => ({ auditLog: { create: vi.fn() }, // Per-metric freshness + first-run sync progress for the APPLE_HEALTH // source (#778). - measurement: { groupBy: vi.fn(), aggregate: vi.fn() }, + measurement: { aggregate: vi.fn() }, workout: { groupBy: vi.fn(), aggregate: vi.fn() }, + // v1.42 — the newest sample per live type is one raw probe per type, and + // the per-type arrival ledger (#1173) is read beside it. + $queryRaw: vi.fn(), + healthKitTypeSync: { findMany: vi.fn() }, }, toJson: (v: T) => v, })); +vi.mock("@/lib/measurements/live-types", () => ({ + listLiveMeasurementTypes: vi.fn(), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -38,6 +46,7 @@ vi.mock("next/headers", () => ({ import { GET, PATCH } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { listLiveMeasurementTypes } from "@/lib/measurements/live-types"; import { APPLE_HEALTH_DATA_STALE_AFTER_MS } from "@/lib/integrations/sync-verdict"; const SESSION_OK = { @@ -53,7 +62,9 @@ beforeEach(() => { } as never); vi.mocked(prisma.user.update).mockResolvedValue({} as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); - vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); + vi.mocked(listLiveMeasurementTypes).mockResolvedValue([]); + vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never); + vi.mocked(prisma.healthKitTypeSync.findMany).mockResolvedValue([] as never); vi.mocked(prisma.workout.groupBy).mockResolvedValue([] as never); vi.mocked(prisma.measurement.aggregate).mockResolvedValue({ _count: { _all: 0 }, @@ -276,31 +287,59 @@ describe("GET /api/integrations/healthkit — sync health", () => { expect((await read()).syncHealth.verdict).toBe("pending_first_sync"); }); + // The column only moves on a live batch. Rows that reached the account any + // other way (the export.zip import, a batch before the column existed) left + // it null, and the card said "waiting for first data" beside a four-digit + // record count. Arrival is a data question: any Apple Health row settles it. + it("is not pending when the per-type ledger records an arrival", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: null, + } as never); + vi.mocked(prisma.healthKitTypeSync.findMany).mockResolvedValue([ + { + type: "PULSE", + lastReceivedAt: ago(2 * DAY), + lastTrigger: null, + lastNewSampleAt: null, + }, + ] as never); + expect((await read()).syncHealth.verdict).toBe("fresh"); + }); + + it("is not pending when Apple Health rows exist without any sync stamp", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: null, + } as never); + vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]); + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { type: "PULSE", last_seen: ago(DAY) }, + ] as never); + const body = await read(); + expect(body.syncHealth.verdict).toBe("fresh"); + expect(body.metricFreshness.map((e) => e.stale)).toEqual([false]); + }); + it("flags one quiet metric inside an otherwise healthy connection", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ healthKitConfigJson: null, healthKitLastSyncedAt: ago(60 * 60 * 1000), } as never); - vi.mocked(prisma.measurement.groupBy).mockResolvedValue([ - { - source: "APPLE_HEALTH", - type: "RESPIRATORY_RATE", - _max: { measuredAt: ago(30 * DAY) }, - }, - { - source: "APPLE_HEALTH", - type: "PULSE", - _max: { measuredAt: ago(60 * 60 * 1000) }, - }, + vi.mocked(listLiveMeasurementTypes).mockResolvedValue([ + "RESPIRATORY_RATE", + "PULSE", + ]); + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { type: "RESPIRATORY_RATE", last_seen: ago(30 * DAY) }, + { type: "PULSE", last_seen: ago(60 * 60 * 1000) }, ] as never); const body = await read(); // Only the Apple Health source is read. - const groupByArgs = vi.mocked(prisma.measurement.groupBy).mock - .calls[0]?.[0] as unknown as { - where: { source: { in: string[] } }; - }; - expect(groupByArgs.where.source.in).toEqual(["APPLE_HEALTH"]); + expect(vi.mocked(listLiveMeasurementTypes).mock.calls[0]?.[1]).toEqual({ + source: "APPLE_HEALTH", + }); const byType = Object.fromEntries( body.metricFreshness.map((entry) => [entry.type, entry.stale]), ); @@ -382,11 +421,75 @@ describe("GET /api/integrations/healthkit — sync health", () => { healthKitConfigJson: null, healthKitLastSyncedAt: ago(DAY), } as never); - vi.mocked(prisma.measurement.groupBy).mockRejectedValue( - new Error("groupBy hiccup"), + vi.mocked(listLiveMeasurementTypes).mockRejectedValue( + new Error("live-type hiccup"), ); const body = await read(); expect(body.metricFreshness).toEqual([]); expect(body.syncHealth.verdict).toBe("fresh"); }); + + // #1173 — when a type last ARRIVED and under which trigger, beside when its + // newest sample was taken. A type that only ever comes with "Sync all" is + // visible as `manual`; a type no live sync carried yet reads null. + it("lays the per-type arrival facts over the freshness entries", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: ago(60 * 60 * 1000), + } as never); + vi.mocked(listLiveMeasurementTypes).mockResolvedValue([ + "RESPIRATORY_RATE", + "PULSE", + ]); + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { type: "RESPIRATORY_RATE", last_seen: ago(2 * DAY) }, + { type: "PULSE", last_seen: ago(60 * 60 * 1000) }, + ] as never); + vi.mocked(prisma.workout.groupBy).mockResolvedValue([ + { source: "APPLE_HEALTH", _max: { startedAt: ago(DAY) } }, + ] as never); + const receivedAt = ago(3 * 60 * 60 * 1000); + vi.mocked(prisma.healthKitTypeSync.findMany).mockResolvedValue([ + { + type: "RESPIRATORY_RATE", + lastReceivedAt: receivedAt, + lastTrigger: "manual", + lastNewSampleAt: null, + }, + ] as never); + + const body = await read(); + const byType = Object.fromEntries( + body.metricFreshness.map((entry) => [entry.type, entry]), + ); + expect(byType.RESPIRATORY_RATE).toMatchObject({ + lastReceivedAt: receivedAt.toISOString(), + lastTrigger: "manual", + lastNewSampleAt: null, + }); + expect(byType.PULSE).toMatchObject({ + lastReceivedAt: null, + lastTrigger: null, + lastNewSampleAt: null, + }); + // Workouts ride their own batch route, which the ledger does not cover. + expect(byType.WORKOUTS).not.toHaveProperty("lastReceivedAt"); + }); + + it("leaves the arrival facts off when the ledger read fails", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: ago(DAY), + } as never); + vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]); + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { type: "PULSE", last_seen: ago(DAY) }, + ] as never); + vi.mocked(prisma.healthKitTypeSync.findMany).mockRejectedValue( + new Error("ledger hiccup"), + ); + const body = await read(); + expect(body.metricFreshness).toHaveLength(1); + expect(body.metricFreshness[0]).not.toHaveProperty("lastReceivedAt"); + }); }); diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts index e9428e4c1..886e489ea 100644 --- a/src/app/api/integrations/healthkit/route.ts +++ b/src/app/api/integrations/healthkit/route.ts @@ -21,6 +21,11 @@ import { annotate } from "@/lib/logging/context"; import { prisma, toJson } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { getAppleHealthSyncProgress } from "@/lib/integrations/apple-health-progress"; +import { + getHealthKitTypeArrivals, + newestAppleHealthDataAt, + withHealthKitArrivals, +} from "@/lib/integrations/healthkit-type-sync"; import { getSourceFreshness } from "@/lib/integrations/metric-freshness"; import { classifyMetricFreshness, @@ -158,17 +163,9 @@ export const GET = apiHandler(async () => { // three-week-dead pipe rendered the same green as one that delivered an hour // ago. Fail-soft on the per-metric read — an honesty signal is never worth // 500-ing the config the iOS client needs. - // No `connected` hint: Apple Health has nothing to connect or disconnect, so - // "never delivered" resolves to `pending_first_sync`, not `disconnected`. - const syncHealth = resolveSyncVerdict({ - configured: true, - lastDataAt: lastSyncedAt, - legacyLastSyncedAt: lastSyncedAt, - cadence: PUSH_CADENCE, - }); - const samples = await getSourceFreshness(user.id, "APPLE_HEALTH").catch( - () => [], - ); + // #1173 — when each type last ARRIVED and under which trigger, beside when + // its newest sample was taken. Fail-soft to null, which leaves the entries + // without arrival fields rather than reporting every type as never received. // Issue #778 — a first-run backfill used to be invisible from the web: the // card said "last sync 2 minutes ago" while giving no sense of how much had // arrived or how far back it reached. These two figures (row count + oldest @@ -176,9 +173,23 @@ export const GET = apiHandler(async () => { // progress; the phone's queue and throttle state stay on the phone and are // not guessed at. Fail-soft for the same reason the freshness read is — // a progress line is never worth 500-ing the config the iOS client needs. - const syncProgress = await getAppleHealthSyncProgress(user.id).catch( - () => null, - ); + const [samples, arrivals, syncProgress] = await Promise.all([ + getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []), + getHealthKitTypeArrivals(user.id).catch(() => null), + getAppleHealthSyncProgress(user.id).catch(() => null), + ]); + // No `connected` hint: Apple Health has nothing to connect or disconnect, so + // "never delivered" resolves to `pending_first_sync`, not `disconnected`. + // "Delivered" is read off the data, not only off the user column: that + // column moves on a live batch alone, so rows from the export.zip import + // left it null and the card said "waiting for first data" beside a + // four-digit record count. + const syncHealth = resolveSyncVerdict({ + configured: true, + lastDataAt: newestAppleHealthDataAt(lastSyncedAt, arrivals, samples), + legacyLastSyncedAt: lastSyncedAt, + cadence: PUSH_CADENCE, + }); return apiSuccess({ entries, @@ -186,7 +197,10 @@ export const GET = apiHandler(async () => { lastSyncTrigger, lastBackgroundSyncAt, syncHealth, - metricFreshness: classifyMetricFreshness(samples, syncHealth.verdict), + metricFreshness: withHealthKitArrivals( + classifyMetricFreshness(samples, syncHealth.verdict), + arrivals, + ), syncProgress, }); }); diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts index 75f54a607..1c0dde365 100644 --- a/src/app/api/integrations/status/__tests__/route.test.ts +++ b/src/app/api/integrations/status/__tests__/route.test.ts @@ -13,11 +13,16 @@ vi.mock("@/lib/db", () => ({ fitbitConnection: { findUnique: vi.fn(async () => null) }, googleHealthConnection: { findUnique: vi.fn(async () => null) }, moodEntry: { count: vi.fn(async () => 0) }, - measurement: { groupBy: vi.fn(async () => []) }, + $queryRaw: vi.fn(async () => []), workout: { groupBy: vi.fn(async () => []) }, }, })); +// v1.42 — the per-source type walk that replaced the measurement groupBy. +vi.mock("@/lib/measurements/live-types", () => ({ + listLiveMeasurementTypes: vi.fn(async () => []), +})); + vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/api-response", () => ({ @@ -53,6 +58,7 @@ vi.mock("@/lib/oura/credentials", () => ({ import { GET } from "../route"; import { prisma } from "@/lib/db"; +import { listLiveMeasurementTypes } from "@/lib/measurements/live-types"; import { ATTEMPT_STALE_AFTER_MS } from "@/lib/integrations/sync-verdict"; const userFind = prisma.user.findUnique as ReturnType; @@ -315,7 +321,7 @@ describe("/api/integrations/status — the freshness read failed", () => { }); it("reports the degradation instead of passing an empty list off as an answer", async () => { - (prisma.measurement.groupBy as ReturnType).mockRejectedValue( + vi.mocked(listLiveMeasurementTypes).mockRejectedValue( new Error("statement timeout"), ); @@ -331,9 +337,7 @@ describe("/api/integrations/status — the freshness read failed", () => { }); it("reports no degradation when the read simply found nothing", async () => { - (prisma.measurement.groupBy as ReturnType).mockResolvedValue( - [], - ); + vi.mocked(listLiveMeasurementTypes).mockResolvedValue([]); (prisma.workout.groupBy as ReturnType).mockResolvedValue([]); const envelope = await fetchEnvelope(); diff --git a/src/app/api/labs/[id]/route.ts b/src/app/api/labs/[id]/route.ts index 34afa72a7..fa2c7b326 100644 --- a/src/app/api/labs/[id]/route.ts +++ b/src/app/api/labs/[id]/route.ts @@ -172,14 +172,14 @@ export const PUT = apiHandler( const rowIsQualitative = existing.valueText !== null; if (rowIsQualitative && d.value !== undefined) { return apiError( - "This is a qualitative reading — edit its result text, not a numeric value", + "This is a qualitative reading. Edit its result text, not a numeric value", 422, { errorCode: "labs.update.qualitativeExpected" }, ); } if (!rowIsQualitative && d.valueText !== undefined) { return apiError( - "This is a numeric reading — edit its value, not a qualitative result", + "This is a numeric reading. Edit its value, not a qualitative result", 422, { errorCode: "labs.update.numericExpected" }, ); diff --git a/src/app/api/life-events/[id]/route.ts b/src/app/api/life-events/[id]/route.ts new file mode 100644 index 000000000..6a7d6cd12 --- /dev/null +++ b/src/app/api/life-events/[id]/route.ts @@ -0,0 +1,142 @@ +/** + * `PATCH /api/life-events/{id}` edits one life event; `DELETE` soft-deletes + * it (v1.42, #613). Owner-only like the list beside it (see there): no + * delegate at any level, and no guardian acting for a managed profile. A + * foreign, unknown or already deleted id is the same 404. + * + * The edit files the replaced dates, category and precision (C4) and names a + * changed title or note without quoting either. The delete tombstones the + * row, so the audit row names it and the restore of a backup keeps it. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { + lifeEventIdPathSchema, + lifeEventUpdateSchema, +} from "@/lib/day/wire-schemas"; +import { prisma } from "@/lib/db"; +import { + findOwnLifeEvent, + mergeLifeEventEdit, + toLifeEventDTO, +} from "@/lib/life-events/service"; +import { annotate } from "@/lib/logging/context"; +import { overwriteDetails } from "@/lib/sharing/audit-details"; + +type RouteParams = { params: Promise<{ id: string }> }; + +function notFound() { + return apiError("Life event not found", 404, { + errorCode: "life_event.not_found", + }); +} + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const path = lifeEventIdPathSchema.safeParse(await params); + if (!path.success) return notFound(); + const existing = await findOwnLifeEvent(user.id, path.data.id); + if (!existing) return notFound(); + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = lifeEventUpdateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "life_event.invalid", + }); + } + const merged = mergeLifeEventEdit(existing, parsed.data); + if (!merged.ok) { + return returnAllZodIssues(merged.error, 422, { + errorCode: "life_event.invalid", + }); + } + + const updated = await prisma.lifeEvent.update({ + where: { id: existing.id }, + data: merged.data, + }); + + await auditLog("life_event.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + lifeEventId: existing.id, + ...overwriteDetails({ + before: { + category: existing.category, + startDate: existing.startDate, + endDate: existing.endDate, + precision: existing.precision, + }, + after: { + category: updated.category, + startDate: updated.startDate, + endDate: updated.endDate, + precision: updated.precision, + }, + redacted: [ + ...(parsed.data.title !== undefined ? ["title"] : []), + ...(parsed.data.note !== undefined ? ["note"] : []), + ], + }), + }, + }); + annotate({ + action: { + name: "life_event.update", + entity_type: "life_event", + entity_id: existing.id, + }, + }); + return apiSuccess(toLifeEventDTO(updated)); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireAuth(); + const path = lifeEventIdPathSchema.safeParse(await params); + if (!path.success) return notFound(); + const existing = await findOwnLifeEvent(user.id, path.data.id); + if (!existing) return notFound(); + + const deleted = await prisma.lifeEvent.update({ + where: { id: existing.id }, + data: { deletedAt: new Date() }, + }); + + await auditLog("life_event.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + lifeEventId: existing.id, + category: existing.category, + startDate: existing.startDate, + precision: existing.precision, + }, + }); + annotate({ + action: { + name: "life_event.delete", + entity_type: "life_event", + entity_id: existing.id, + }, + }); + return apiSuccess(toLifeEventDTO(deleted)); + }, +); diff --git a/src/app/api/life-events/route.ts b/src/app/api/life-events/route.ts new file mode 100644 index 000000000..cdb0d32a0 --- /dev/null +++ b/src/app/api/life-events/route.ts @@ -0,0 +1,97 @@ +/** + * `GET /api/life-events` lists the account's life events, oldest first; + * `POST` adds one (v1.42, #613). Title and note are encrypted at rest and + * never reach a model. Shapes and request schemas in `src/lib/day/`. + * + * Owner-only in v1.42. A life event (a loss, a separation, a new job) is + * the most personal row the record holds, and no share level was ever + * worded for it, so no delegate reaches it: not a `profile` share, not a + * legacy whole-record share, not MANAGE, not a guardian acting for a + * managed profile. Bare `requireAuth()`, which refuses under an + * acting-account switch and refuses a narrow Bearer scope. Sharing life + * events is a separate decision for a later release. + * + * The row store answers whether or not the `timeline` module is on, the + * data-layer posture of the other record tables, so a restore keeps working + * and re-enabling finds the events intact. The create is additive, + * rate-limited, audited with the category and the precision and never the + * title. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { lifeEventCreateSchema } from "@/lib/day/wire-schemas"; +import { withIdempotency } from "@/lib/idempotency"; +import { + createLifeEvent, + listLifeEvents, + toLifeEventDTO, +} from "@/lib/life-events/service"; +import { annotate } from "@/lib/logging/context"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + const events = await listLifeEvents(user.id); + annotate({ + action: { name: "life_event.list", entity_type: "life_event" }, + meta: { count: events.length }, + }); + return apiSuccess({ events }); +}); + +export const POST = apiHandler(withIdempotency<[NextRequest]>(postLifeEvent)); + +async function postLifeEvent(request: NextRequest): Promise { + const { user } = await requireAuth(); + + const writeRl = await checkRecordWriteRateLimit(user.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = lifeEventCreateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "life_event.invalid", + }); + } + + const created = await createLifeEvent(user.id, parsed.data); + + await auditLog("life_event.create", { + userId: user.id, + ipAddress: getClientIp(request), + // Never the title or the note: both are encrypted at rest and the audit + // table is not a second store for them. + details: { + lifeEventId: created.id, + category: created.category, + precision: created.precision, + }, + }); + annotate({ + action: { + name: "life_event.create", + entity_type: "life_event", + entity_id: created.id, + }, + meta: { category: created.category, precision: created.precision }, + }); + return apiSuccess(toLifeEventDTO(created), 201); +} diff --git a/src/app/api/managed-profiles/[id]/handover/route.ts b/src/app/api/managed-profiles/[id]/handover/route.ts new file mode 100644 index 000000000..3c702f200 --- /dev/null +++ b/src/app/api/managed-profiles/[id]/handover/route.ts @@ -0,0 +1,186 @@ +/** + * `/api/managed-profiles/[id]/handover` — the Guardian's side of handing a + * managed profile over to the person it describes (v1.42, #959). + * + * - `GET` reads whether a link is open, never the link itself (only its hash + * exists). Cookie-only, no step-up: it discloses to a Guardian what they + * already administer. + * - `POST` mints the one-time link with a proposal per Guardian. Step-up + * gated like every act that changes what a managed profile is + * (`requireFreshMfa`), and rate-limited like its siblings. + * - `DELETE` withdraws the open link. Cookie-only and NOT step-up gated: + * taking a link back must never be harder than handing it out. + * + * All three answer the same 404 for "no such profile", "not managed" and "not + * a Guardian of it", so the route is not an enumeration oracle. Bearer tokens + * cannot reach any of them: every gate here resolves the cookie session. + */ +import { NextRequest } from "next/server"; + +import { + apiHandler, + MFA_STEP_UP_MAX_AGE_SECONDS, + requireCookieAuth, + requireFreshMfa, +} from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { buildHandoverUrl } from "@/lib/auth/handover-token"; +import { isOidcOnly } from "@/lib/auth/oidc"; +import { annotate } from "@/lib/logging/context"; +import { + createHandover, + HandoverError, + readHandoverStatus, + revokeHandover, +} from "@/lib/managed-profiles/handover"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { createHandoverSchema } from "@/lib/validations/managed-profile-handover"; + +type RouteParams = { params: Promise<{ id: string }> }; + +const notFound = () => + apiError("Managed profile not found", 404, { + errorCode: "managed_profile.not_found", + }); + +/** + * v1.42 refuses the handover on a single-sign-on-only instance: the claim + * sets a password, and such an instance admits none. The SSO arm of the claim + * is a separate piece of work. + */ +const oidcOnlyRefusal = () => + apiError( + "Handing over a profile is not available while this server allows single sign-on only", + 403, + { errorCode: "profile_claim.oidc_only_unsupported" }, + ); + +/** Same ceiling as creating, editing and inviting: ten an hour per caller. */ +const CREATE_LIMIT = 10; +const CREATE_WINDOW_MS = 60 * 60 * 1000; + +export const GET = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireCookieAuth(); + const { id } = await params; + + const status = await readHandoverStatus({ + profileId: id, + guardianId: user.id, + }); + if (!status) return notFound(); + + annotate({ + action: { name: "managed_profile.handover.read" }, + meta: { profile_id: id, open: status.open !== null }, + }); + return apiSuccess({ + available: !isOidcOnly(), + open: status.open + ? { + id: status.open.id, + createdAt: status.open.createdAt.toISOString(), + expiresAt: status.open.expiresAt.toISOString(), + createdByYou: status.open.createdByCaller, + } + : null, + }); + }, +); + +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS); + // Before the id is read and before any lock is taken, as the siblings do: + // the service locks on the id it is handed before deciding the caller is + // not a Guardian of it. + const rateLimit = await checkRateLimit( + `managed-profile:handover:${user.id}`, + CREATE_LIMIT, + CREATE_WINDOW_MS, + ); + if (!rateLimit.allowed) { + return apiError("Too many handover links, try again later", 429); + } + if (isOidcOnly()) return oidcOnlyRefusal(); + const { id } = await params; + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + const parsed = createHandoverSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + let created; + try { + created = await createHandover({ + profileId: id, + guardianId: user.id, + expiresInDays: parsed.data.expiresInDays, + proposals: parsed.data.proposals.map((p) => ({ + grantId: p.grantId, + proposal: p.proposal, + })), + }); + } catch (error) { + if (error instanceof HandoverError) { + if (error.code === "unknown_guardian") { + return apiError( + "A proposal names somebody who is not a Guardian", + 422, + { + errorCode: "managed_profile.handover.unknown_guardian", + }, + ); + } + return notFound(); + } + throw error; + } + + annotate({ + action: { name: "managed_profile.handover.create" }, + meta: { + profile_id: id, + expires_in_days: parsed.data.expiresInDays, + proposals: parsed.data.proposals.length, + }, + }); + return apiSuccess( + { + id: created.id, + token: created.token, + url: buildHandoverUrl(created.token, request.url), + expiresAt: created.expiresAt.toISOString(), + }, + 201, + ); + }, +); + +export const DELETE = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireCookieAuth(); + const { id } = await params; + + let result; + try { + result = await revokeHandover({ profileId: id, guardianId: user.id }); + } catch (error) { + if (error instanceof HandoverError) return notFound(); + throw error; + } + + annotate({ + action: { name: "managed_profile.handover.revoke" }, + meta: { profile_id: id, revoked: result.revoked }, + }); + return apiSuccess(result); + }, +); diff --git a/src/app/api/mcp/oauth/authorize/route.ts b/src/app/api/mcp/oauth/authorize/route.ts index c8a71d9a8..f681d0a91 100644 --- a/src/app/api/mcp/oauth/authorize/route.ts +++ b/src/app/api/mcp/oauth/authorize/route.ts @@ -85,7 +85,7 @@ function badRequest(error: string, description: string): Response { function html(body: string, status = 200): Response { return new Response( - `HealthLog — Authorize access${body}`, + `HealthLog: Authorize access${body}`, { status, headers: { "content-type": "text/html; charset=utf-8" }, @@ -419,13 +419,13 @@ async function authorizeGet(request: NextRequest): Promise { const provenance = v.clientSource === "cimd" ? `

Verified origin: ${htmlEscape(v.clientOrigin)}

` - : `

Unverified application (dynamically registered — the name above is self-reported and not verified).

`; + : `

Unverified application (dynamically registered, the name above is self-reported and not verified).

`; // Plain-language access summary. When write is granted, say so explicitly — // the app will be able to READ and LOG/WRITE health data, not just read. const writeGranted = v.scope.split(/\s+/).includes(SCOPE_HEALTH_WRITE); const accessSummary = writeGranted ? `

This grants read AND write access: the application will be able to read your own health records and log new measurements and mood entries to your account on your behalf. It cannot delete or change existing entries, edit medications, or reach admin functions.

` - : `

Scope: ${htmlEscape(v.scope)} — read-only access to your own health records.

`; + : `

Scope: ${htmlEscape(v.scope)}. Read-only access to your own health records.

`; // What a read reaches, in plain words, before the person clicks Allow. // Document text is named on its own because it is the widest thing a // connection can read (v1.39.3: search and fetch reach it). diff --git a/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts b/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts index 3f766d9fb..085fe93d4 100644 --- a/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts +++ b/src/app/api/measurements/batch/__tests__/aggregated-hr-wire.test.ts @@ -19,6 +19,8 @@ vi.mock("@/lib/db", () => ({ prisma: { user: { update: vi.fn(), + // The `folded_window` guard reads the account's zone for old samples. + findUnique: vi.fn().mockResolvedValue({ timezone: null }), }, measurement: { findMany: vi.fn(), @@ -366,7 +368,14 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34) it("keeps the per-sample uuid HR path immutable (duplicate, not overwrite)", async () => { const SAMPLE_ID = "B3A1C0DE-0000-4000-8000-000000000000"; vi.mocked(prisma.measurement.findMany).mockResolvedValue([ - { type: "PULSE", source: "APPLE_HEALTH", externalId: SAMPLE_ID }, + { + type: "PULSE", + source: "APPLE_HEALTH", + externalId: SAMPLE_ID, + // Not the posted instant, so the exact-duplicate prefilter leaves + // the row to the reconciler this suite stubs. + measuredAt: new Date(0), + }, ] as never); const res = await POST( @@ -461,7 +470,14 @@ describe("POST /api/measurements/batch — stats tombstone resurrection", () => // sample stays a checkpointing `duplicate` — no update runs at all. const SAMPLE_ID = "D00DAD00-0000-4000-8000-000000000000"; vi.mocked(prisma.measurement.findMany).mockResolvedValue([ - { type: "PULSE", source: "APPLE_HEALTH", externalId: SAMPLE_ID }, + { + type: "PULSE", + source: "APPLE_HEALTH", + externalId: SAMPLE_ID, + // Not the posted instant, so the exact-duplicate prefilter leaves + // the row to the reconciler this suite stubs. + measuredAt: new Date(0), + }, ] as never); const res = await POST( diff --git a/src/app/api/measurements/batch/__tests__/duplicate-prefilter.test.ts b/src/app/api/measurements/batch/__tests__/duplicate-prefilter.test.ts new file mode 100644 index 000000000..63d2c32bf --- /dev/null +++ b/src/app/api/measurements/batch/__tests__/duplicate-prefilter.test.ts @@ -0,0 +1,616 @@ +/** + * v1.42 — the exact-duplicate prefilter, the `folded_window` guard, and what + * a batch of duplicates only no longer sets off. + * + * The route runs against an in-memory `measurements` table and a reconciler + * stand-in that answers from it the way `reconcileExternalMeasurement` does + * for samples (external and natural identity, live or tombstoned). The golden + * comparison runs every batch twice: once as shipped, once with the + * prefilter's read answering nothing, which is the pre-v1.42 path where every + * row went through the reconciler. Statuses, reasons and counters must match + * exactly; only the transaction count may differ. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +interface StoredRow { + id: string; + userId: string; + type: string; + source: string; + externalId: string | null; + measuredAt: Date; + sleepStage: string | null; + value: number; + deletedAt: Date | null; +} + +const store = vi.hoisted(() => ({ + rows: [] as StoredRow[], + /** Golden mode: the prefilter's read answers nothing. */ + prefilterBlind: false, + nextId: 1, +})); + +function sameInstant(a: Date, b: Date) { + return a.getTime() === b.getTime(); +} + +vi.mock("@/lib/db", () => { + const prisma = { + user: { + update: vi.fn().mockResolvedValue({}), + findUnique: vi.fn().mockResolvedValue({ timezone: "Europe/Berlin" }), + }, + measurement: { + findMany: vi.fn( + async (args: { + where: { + userId: string; + OR?: unknown; + deletedAt?: null; + type?: { in: string[] }; + source?: string; + externalId?: { in: string[] }; + }; + }) => { + const where = args.where; + // Cross-source merge probe: these suites post no MANUAL twins. + if (where.OR) return []; + const ids = where.externalId?.in ?? []; + // The `folded_window` lookup: live `stats:` rows by id. + if (where.deletedAt === null) { + return store.rows.filter( + (r) => + r.userId === where.userId && + r.deletedAt === null && + r.source === where.source && + (where.type?.in ?? []).includes(r.type) && + r.externalId !== null && + ids.includes(r.externalId), + ); + } + // The prefilter: every row, live or not, carrying a posted id. + if (store.prefilterBlind) return []; + return store.rows.filter( + (r) => + r.userId === where.userId && + r.externalId !== null && + ids.includes(r.externalId), + ); + }, + ), + }, + $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn({})), + }; + return { prisma }; +}); + +vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({ + reconcileExternalMeasurement: async ( + _tx: unknown, + desired: { + userId: string; + type: string; + source: string; + externalId: string; + measuredAt: Date; + sleepStage?: string | null; + value: number; + }, + options: { exactExternalMatch?: "update" | "duplicate" } = {}, + ) => { + const scope = store.rows.filter( + (r) => + r.userId === desired.userId && + r.type === desired.type && + r.source === desired.source, + ); + const externalHit = scope.find((r) => r.externalId === desired.externalId); + const naturalHit = scope.find( + (r) => + sameInstant(r.measuredAt, desired.measuredAt) && + r.sleepStage === (desired.sleepStage ?? null), + ); + if (!externalHit && !naturalHit) { + const row: StoredRow = { + id: `m${store.nextId++}`, + userId: desired.userId, + type: desired.type, + source: desired.source, + externalId: desired.externalId, + measuredAt: desired.measuredAt, + sleepStage: desired.sleepStage ?? null, + value: desired.value, + deletedAt: null, + }; + store.rows.push(row); + return { status: "inserted", row }; + } + const canonical = (externalHit ?? naturalHit)!; + const redundant = + externalHit && naturalHit && externalHit !== naturalHit + ? naturalHit + : undefined; + if ( + options.exactExternalMatch === "duplicate" && + externalHit === naturalHit + ) { + return { status: "duplicate", row: canonical }; + } + if (redundant) { + redundant.deletedAt = new Date(); + redundant.measuredAt = new Date(0); + } + const wasDeleted = canonical.deletedAt !== null; + canonical.externalId = desired.externalId; + canonical.measuredAt = desired.measuredAt; + canonical.sleepStage = desired.sleepStage ?? null; + canonical.value = desired.value; + canonical.deletedAt = null; + return { + status: wasDeleted ? "resurrected" : "updated", + row: canonical, + dirtyIdentities: [], + }; + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); +vi.mock("@/lib/jobs/pr-detection", () => ({ + enqueuePrDetection: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/jobs/reminder-satisfy", () => ({ + enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMeasurements: vi.fn(), +})); +vi.mock("@/lib/rollups/after-measurement-mutation", () => ({ + afterMeasurementMutation: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/arrivals/emit-shared", () => ({ + emitDataArrival: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/daily/morning-refresh-trigger", () => ({ + maybeEnqueueMorningRefresh: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { enqueuePrDetection } from "@/lib/jobs/pr-detection"; +import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; + +const USER = "user-1"; +const NOW = Date.now(); +const HOUR = 3_600_000; +const DAY = 24 * HOUR; + +interface Entry { + hkIdentifier: string; + value: number; + unit: string; + startDate: string; + endDate: string; + externalId: string; + sleepStage?: number; + source?: "APPLE_HEALTH" | "MANUAL"; +} + +function weight(externalId: string, at: Date, value = 80): Entry { + return { + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + value, + unit: "kg", + startDate: at.toISOString(), + endDate: at.toISOString(), + externalId, + }; +} + +function pulse(externalId: string, at: Date, value = 60): Entry { + return { + hkIdentifier: "HKQuantityTypeIdentifierHeartRate", + value, + unit: "count/min", + startDate: at.toISOString(), + endDate: at.toISOString(), + externalId, + }; +} + +function seed(row: Partial & { externalId: string; type: string }) { + store.rows.push({ + id: `seed${store.nextId++}`, + userId: USER, + source: "APPLE_HEALTH", + measuredAt: new Date(NOW - DAY), + sleepStage: null, + value: 80, + deletedAt: null, + ...row, + }); +} + +function request(entries: Entry[], syncTrigger?: string) { + return new NextRequest("http://localhost/api/measurements/batch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ entries, ...(syncTrigger ? { syncTrigger } : {}) }), + }); +} + +type Body = { + data: { + processed: number; + inserted: number; + updated: number; + duplicates: number; + failed: number; + entries: Array<{ index: number; status: string; reason?: string }>; + }; +}; + +async function post(entries: Entry[], syncTrigger?: string) { + const res = await POST(request(entries, syncTrigger)); + expect(res.status).toBe(200); + return ((await res.json()) as Body).data; +} + +/** Run a batch on a copy of the store as shipped and on a copy without the prefilter. */ +async function golden(setup: () => void, entries: Entry[]) { + store.rows = []; + store.nextId = 1; + setup(); + store.prefilterBlind = true; + vi.mocked(prisma.$transaction).mockClear(); + const old = await post(entries); + const oldTransactions = vi.mocked(prisma.$transaction).mock.calls.length; + const oldRows = JSON.stringify(store.rows); + + store.rows = []; + store.nextId = 1; + setup(); + store.prefilterBlind = false; + vi.mocked(prisma.$transaction).mockClear(); + const next = await post(entries); + const transactions = vi.mocked(prisma.$transaction).mock.calls.length; + expect(next).toEqual(old); + expect(JSON.stringify(store.rows)).toBe(oldRows); + return { old, next, oldTransactions, transactions }; +} + +beforeEach(() => { + vi.clearAllMocks(); + store.rows = []; + store.prefilterBlind = false; + vi.mocked(getSession).mockResolvedValue({ + session: { id: "s", expiresAt: new Date(NOW + HOUR) }, + user: { id: USER, username: "u", role: "USER" as const }, + } as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + limit: 60, + remaining: 60, + resetAt: NOW + 60_000, + }); +}); + +describe("exact-duplicate prefilter (v1.42)", () => { + it("1: 500 exact live duplicates open no transaction and answer as before", async () => { + const entries = Array.from({ length: 500 }, (_, i) => + weight(`uuid-${i}`, new Date(NOW - DAY - i * 60_000)), + ); + const setup = () => + entries.forEach((e) => + seed({ + type: "WEIGHT", + externalId: e.externalId, + measuredAt: new Date(e.endDate), + }), + ); + const { next, oldTransactions, transactions } = await golden( + setup, + entries, + ); + expect(next.duplicates).toBe(500); + expect(oldTransactions).toBe(500); + expect(transactions).toBe(0); + }); + + it("2: an exact hit on a tombstone stays a duplicate and the row stays deleted", async () => { + const at = new Date(NOW - DAY); + const setup = () => + seed({ + type: "WEIGHT", + externalId: "uuid-gone", + measuredAt: at, + deletedAt: new Date(NOW - HOUR), + }); + const { next, transactions } = await golden(setup, [ + weight("uuid-gone", at), + ]); + expect(next.entries[0]).toEqual({ index: 0, status: "duplicate" }); + expect(transactions).toBe(0); + expect(store.rows[0].deletedAt).not.toBeNull(); + }); + + it("3: the same id at another instant still reconciles to `updated`", async () => { + const setup = () => + seed({ + type: "WEIGHT", + externalId: "uuid-move", + measuredAt: new Date(NOW - 2 * DAY), + }); + const { next, transactions } = await golden(setup, [ + weight("uuid-move", new Date(NOW - DAY)), + ]); + expect(next.entries[0].status).toBe("updated"); + expect(transactions).toBe(1); + }); + + it("4: the same id with another sleep stage still reconciles", async () => { + const at = new Date(NOW - DAY); + const setup = () => + seed({ + type: "SLEEP_DURATION", + externalId: "uuid-sleep", + measuredAt: at, + sleepStage: "DEEP", + value: 30, + }); + const entry: Entry = { + hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis", + value: 30, + unit: "min", + startDate: new Date(at.getTime() - 30 * 60_000).toISOString(), + endDate: at.toISOString(), + externalId: "uuid-sleep", + sleepStage: 3, + }; + const { transactions } = await golden(setup, [entry]); + expect(transactions).toBe(1); + }); + + it("5: `stats:` rows are never prefiltered; a second snapshot is superseded", async () => { + const day = new Date(NOW - DAY).toISOString().slice(0, 10); + const id = `stats:HKQuantityTypeIdentifierStepCount:${day}`; + const steps = (value: number): Entry => ({ + hkIdentifier: "HKQuantityTypeIdentifierStepCount", + value, + unit: "count", + startDate: `${day}T00:00:00.000Z`, + endDate: `${day}T18:00:00.000Z`, + externalId: id, + }); + const setup = () => + seed({ + type: "ACTIVITY_STEPS", + externalId: id, + measuredAt: new Date(`${day}T18:00:00.000Z`), + value: 1000, + }); + const { next, transactions } = await golden(setup, [ + steps(2000), + steps(3000), + ]); + expect(next.entries).toEqual([ + { index: 0, status: "duplicate", reason: "superseded_in_batch" }, + { index: 1, status: "updated" }, + ]); + expect(transactions).toBe(1); + }); + + it("6: an id twice in the batch and not stored inserts once, then duplicates", async () => { + const at = new Date(NOW - DAY); + const { next } = await golden(() => {}, [ + weight("uuid-twice", at), + weight("uuid-twice", at), + ]); + expect(next.entries.map((e) => e.status)).toEqual([ + "inserted", + "duplicate", + ]); + }); + + it("6b: a stored id posted twice is left to the reconciler", async () => { + const at = new Date(NOW - DAY); + const setup = () => + seed({ type: "WEIGHT", externalId: "uuid-twice", measuredAt: at }); + const { transactions } = await golden(setup, [ + weight("uuid-twice", at), + weight("uuid-twice", at), + ]); + expect(transactions).toBe(2); + }); + + it("9: the same id held by another account is not a duplicate", async () => { + const at = new Date(NOW - DAY); + const setup = () => + seed({ + type: "WEIGHT", + externalId: "uuid-shared", + measuredAt: at, + userId: "user-2", + }); + const { next } = await golden(setup, [weight("uuid-shared", at)]); + expect(next.entries[0].status).toBe("inserted"); + }); + + it("10: an all-duplicate batch still sets the sync checkpoint", async () => { + const at = new Date(NOW - DAY); + seed({ type: "WEIGHT", externalId: "uuid-dup", measuredAt: at }); + await post([weight("uuid-dup", at)], "background"); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: USER }, + data: { + lastSyncedAt: expect.any(Date), + healthKitLastSyncedAt: expect.any(Date), + healthKitLastSyncTrigger: "background", + healthKitLastBackgroundSyncAt: expect.any(Date), + }, + }); + }); +}); + +describe("a batch of duplicates only (v1.42)", () => { + it("enqueues no PR detection, no reminder sweep and writes no detection audit row", async () => { + const at = new Date(NOW - DAY); + seed({ type: "WEIGHT", externalId: "uuid-dup", measuredAt: at }); + await post([weight("uuid-dup", at)]); + expect(enqueuePrDetection).not.toHaveBeenCalled(); + expect(enqueueReminderSatisfy).not.toHaveBeenCalled(); + expect( + vi + .mocked(auditLog) + .mock.calls.some( + ([action]) => action === "personal_records.detection_enqueued", + ), + ).toBe(false); + }); + + it("still enqueues all three when a row was written", async () => { + await post([weight("uuid-new", new Date(NOW - DAY))]); + expect(enqueuePrDetection).toHaveBeenCalledTimes(1); + expect(enqueueReminderSatisfy).toHaveBeenCalledTimes(1); + expect( + vi + .mocked(auditLog) + .mock.calls.some( + ([action]) => action === "personal_records.detection_enqueued", + ), + ).toBe(true); + }); +}); + +describe("cache invalidation by origin (v1.42)", () => { + it.each([ + ["foreground", { evict: true }], + ["manual", { evict: true }], + ["background", undefined], + ["push", undefined], + [undefined, undefined], + ])("syncTrigger %s → %o", async (trigger, expected) => { + await post([weight(`uuid-${trigger}`, new Date(NOW - DAY))], trigger); + expect(invalidateUserMeasurements).toHaveBeenCalledWith(USER, expected); + }); + + it("evicts for a background batch that wrote a hand-entered reading", async () => { + await post( + [ + { + ...weight("manual-1", new Date(NOW - DAY)), + source: "MANUAL", + }, + ], + "background", + ); + expect(invalidateUserMeasurements).toHaveBeenCalledWith(USER, { + evict: true, + }); + }); +}); + +describe("folded_window guard (v1.42)", () => { + // Europe/Berlin is UTC+1 in winter and UTC+2 in summer; the store's + // account zone is Berlin. 120 days back is past the 90-day dense window. + const old = new Date(NOW - 120 * DAY); + const localHourId = () => { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + hour12: false, + }).formatToParts(old); + const get = (t: string) => parts.find((p) => p.type === t)!.value; + const hour = get("hour") === "24" ? "00" : get("hour"); + return `stats:HKQuantityTypeIdentifierHeartRate:${get("year")}-${get("month")}-${get("day")}T${hour}`; + }; + + it("12a: an old raw sample under a live hourly `stats:` row is a folded duplicate", async () => { + seed({ + type: "PULSE", + externalId: localHourId(), + measuredAt: new Date(old.getTime() + 1), + }); + const data = await post([pulse("uuid-old-hr", old)]); + expect(data.entries[0]).toEqual({ + index: 0, + status: "duplicate", + reason: "folded_window", + }); + expect(store.rows.some((r) => r.externalId === "uuid-old-hr")).toBe(false); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("12b: without a covering `stats:` row it is inserted", async () => { + const data = await post([pulse("uuid-old-hr", old)]); + expect(data.entries[0].status).toBe("inserted"); + }); + + it("12b': a tombstoned `stats:` row does not cover", async () => { + seed({ + type: "PULSE", + externalId: localHourId(), + measuredAt: new Date(old.getTime() + 1), + deletedAt: new Date(), + }); + const data = await post([pulse("uuid-old-hr", old)]); + expect(data.entries[0].status).toBe("inserted"); + }); + + it("12c: a mean-type sample younger than 36 h is inserted even under a daily row", async () => { + const recent = new Date(NOW - 2 * HOUR); + const day = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + }).format(recent); + seed({ + type: "RESPIRATORY_RATE", + externalId: `stats:HKQuantityTypeIdentifierRespiratoryRate:${day}`, + measuredAt: new Date(recent.getTime() + 1), + }); + const data = await post([ + { + hkIdentifier: "HKQuantityTypeIdentifierRespiratoryRate", + value: 14, + unit: "count/min", + startDate: recent.toISOString(), + endDate: recent.toISOString(), + externalId: "uuid-rr", + }, + ]); + expect(data.entries[0].status).toBe("inserted"); + }); + + it("does not touch a MANUAL reading of the same instant", async () => { + seed({ + type: "PULSE", + externalId: localHourId(), + measuredAt: new Date(old.getTime() + 1), + }); + const data = await post([{ ...pulse("manual-hr", old), source: "MANUAL" }]); + expect(data.entries[0].status).toBe("inserted"); + }); +}); diff --git a/src/app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts b/src/app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts index 7597199f4..188687af5 100644 --- a/src/app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts +++ b/src/app/api/measurements/batch/__tests__/hr-bucket-observability.test.ts @@ -21,6 +21,8 @@ vi.mock("@/lib/db", () => ({ prisma: { user: { update: vi.fn(), + // The `folded_window` guard reads the account's zone for old samples. + findUnique: vi.fn().mockResolvedValue({ timezone: null }), }, measurement: { findMany: vi.fn(), diff --git a/src/app/api/measurements/batch/__tests__/issue-paths.test.ts b/src/app/api/measurements/batch/__tests__/issue-paths.test.ts new file mode 100644 index 000000000..8d7edf6c0 --- /dev/null +++ b/src/app/api/measurements/batch/__tests__/issue-paths.test.ts @@ -0,0 +1,183 @@ +/** + * `POST /api/measurements/batch` — which issue belongs to which entry. + * + * A whole-batch 422 (`measurement.batch.invalid`) lists every problem in + * `details.issues`. The native client splits such a batch: it drops the + * entries the issues name and resends the rest, so one bad reading does not + * cost the valid ones. That only works if every problem caused by one entry + * carries a path that starts `entries.`, and if the problems that are not + * about an entry (the wrapper itself) never do. Both halves are pinned here, + * and the first case proves the split by resending what is left. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { update: vi.fn() }, + measurement: { findMany: vi.fn() }, + $transaction: vi.fn(async (fn: unknown) => { + if (typeof fn === "function") { + return (fn as (tx: unknown) => unknown)(prisma); + } + }), + }, +})); + +vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({ + reconcileExternalMeasurement: vi.fn( + async (_tx: unknown, input: Record) => ({ + status: "inserted", + row: { id: "row-1", ...input }, + }), + ), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); +vi.mock("@/lib/jobs/pr-detection", () => ({ + enqueuePrDetection: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/jobs/reminder-satisfy", () => ({ + enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMeasurements: vi.fn(), +})); +vi.mock("@/lib/rollups/after-measurement-mutation", () => ({ + afterMeasurementMutation: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/daily/morning-refresh-trigger", () => ({ + maybeEnqueueMorningRefresh: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/arrivals/emit-shared", () => ({ + emitDataArrival: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +function post(body: unknown) { + return POST( + new NextRequest("http://localhost/api/measurements/batch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); +} + +const AT = new Date(Date.now() - 3_600_000).toISOString(); + +function entry(i: number, overrides: Record = {}) { + return { + hkIdentifier: "HKQuantityTypeIdentifierHeartRate", + value: 64, + unit: "count/min", + startDate: AT, + endDate: AT, + externalId: `uuid-${i}`, + ...overrides, + }; +} + +type Issue = { path: string; code: string }; + +async function issuesFor(body: unknown): Promise { + const res = await post(body); + expect(res.status).toBe(422); + const json = (await res.json()) as { + details: { issues: Issue[] }; + meta?: { errorCode?: string }; + }; + expect(json.meta?.errorCode).toBe("measurement.batch.invalid"); + return json.details.issues; +} + +/** The entry index an issue names, or null when it names the batch itself. */ +function entryIndex(path: string): number | null { + const m = /^entries\.(\d+)(?:\.|$)/.exec(path); + return m ? Number(m[1]) : null; +} + +/** The paths that are about the batch wrapper and no single entry. */ +const BATCH_LEVEL_PATHS = new Set(["", "entries", "syncTrigger"]); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + limit: 60, + remaining: 60, + resetAt: Date.now() + 60_000, + }); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([]); +}); + +describe("POST /api/measurements/batch — every entry problem names its entry", () => { + it("names each bad entry as entries., so the rest of the batch can be resent", async () => { + const entries = [ + entry(0), + entry(1, { startDate: "yesterday" }), + entry(2), + entry(3, { value: "64" }), + "not an entry", + entry(5, { hkIdentifier: "", unit: "" }), + entry(6), + ]; + const issues = await issuesFor({ entries }); + + expect(issues.length).toBeGreaterThan(0); + for (const issue of issues) { + expect(entryIndex(issue.path), issue.path).not.toBeNull(); + } + const bad = new Set(issues.map((i) => entryIndex(i.path))); + expect([...bad].sort()).toEqual([1, 3, 4, 5]); + + // The split the client performs: drop the named entries, resend the rest. + const kept = entries.filter((_, i) => !bad.has(i)); + const res = await post({ entries: kept }); + expect(res.status).toBe(200); + }); + + it("names the batch itself, never an entry, when the wrapper is wrong", async () => { + const cases: unknown[] = [ + [], + { entries: [] }, + { entries: "x" }, + { entries: [entry(0)], syncTrigger: "sometimes" }, + ]; + let seen = 0; + for (const body of cases) { + for (const issue of await issuesFor(body)) { + expect(BATCH_LEVEL_PATHS.has(issue.path), issue.path).toBe(true); + expect(entryIndex(issue.path)).toBeNull(); + seen += 1; + } + } + expect(seen).toBeGreaterThanOrEqual(cases.length); + }); +}); diff --git a/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts b/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts index 83a7416d5..38f2318fb 100644 --- a/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts +++ b/src/app/api/measurements/batch/__tests__/sync-trigger.test.ts @@ -25,7 +25,11 @@ const mocks = vi.hoisted(() => ({ annotate: vi.fn() })); vi.mock("@/lib/db", () => ({ prisma: { - user: { update: vi.fn() }, + user: { + update: vi.fn(), + // The `folded_window` guard reads the account's zone for old samples. + findUnique: vi.fn().mockResolvedValue({ timezone: null }), + }, measurement: { findMany: vi.fn(), createManyAndReturn: vi.fn(), diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 40c0b3e20..fa0c55400 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -65,6 +65,15 @@ import { type MergeCandidate, } from "@/lib/measurements/cross-source-merge"; import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement"; +import { classifyRangeRejection } from "@/lib/measurements/plausibility-gate"; +import { + FOLDED_WINDOW_REASON, + findFoldedWindowDuplicates, +} from "@/lib/measurements/folded-window"; +import { + recordHealthKitTypeArrivals, + type HealthKitTypeArrival, +} from "@/lib/integrations/healthkit-type-sync"; import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant"; import { classifyExternalId, @@ -86,7 +95,11 @@ import { emitDataArrival } from "@/lib/arrivals/emit-shared"; import { groupRowsByArrivalKind } from "@/lib/arrivals/measurement-kind"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; -import { Prisma, type MeasurementType } from "@/generated/prisma/client"; +import { + Prisma, + type MeasurementSource, + type MeasurementType, +} from "@/generated/prisma/client"; // v1.4.25 W16c — historical-backfill threshold for PR push // suppression. A batch larger than this fires the detection job with @@ -181,7 +194,9 @@ const batchEntrySchema = z.object({ // client knows its own wake reason, so it is client-asserted by necessity; // being diagnostic-only, that carries no trust surface. Optional so every // pre-#66 caller stays byte-for-byte unchanged. -const syncTriggerEnum = z.enum(["foreground", "background", "push"]); +// v1.42 (#1173) — `manual` names a user-initiated "Sync all" run, as opposed to +// the app simply being open (`foreground`). +const syncTriggerEnum = z.enum(["foreground", "background", "push", "manual"]); const batchPayloadSchema = z.object({ entries: z.array(batchEntrySchema).min(1), @@ -528,6 +543,8 @@ async function postBatch(request: NextRequest): Promise { // composite-key duplicate). Surfaced as a dedicated wide-event count so // an operator can see how often the standalone-pair mirror collapses. let crossSourceMergedCount = 0; + // v1.42 — subset of `duplicateCount` refused by the `folded_window` guard. + let foldedWindowCount = 0; // Only rows the reconciler actually inserted drive arrival side effects. const insertedPrepared: Prepared[] = []; const writtenIdentities: Array<{ @@ -619,6 +636,128 @@ async function postBatch(request: NextRequest): Promise { ); } + // v1.42 — exact-duplicate prefilter. A re-sent sample used to cost one + // interactive transaction of about ten round-trips (savepoint, three + // advisory locks, two candidate reads, a row lock) only for the + // reconciler to answer `duplicate` without writing. A backfill after an + // anchor reset re-sends whole 500-entry batches of exactly that. + // + // The reconciler answers `duplicate` for a non-`stats:` row exactly when + // an existing row, live OR tombstoned, has the same + // `(type, source, externalId)` AND the same `measuredAt` and + // `sleepStage`: the natural-identity index is unique NULLS NOT DISTINCT, + // so that row is both its external and its natural match, and with no + // aggregation provenance on a sample nothing else can win. One read over + // the batch's sample ids, without a `deletedAt` filter, answers that for + // every row at once. + // + // The verdict is taken at the very point the reconciler would have been + // called, after `superseded_in_batch` and the cross-source merge, so + // statuses, reasons, counters and the in-batch merge candidates are the + // same as before. A row is only prefiltered when neither its external + // key nor its natural identity occurs a second time in the batch: an + // earlier entry sharing either could move or retire the matched row + // before this one is reached, and then the reconciler has to decide. + // `stats:` rows are never prefiltered (overwrite semantics), and neither + // is a value the reconciler's own plausibility gate would refuse. + const externalKeyCounts = new Map(); + const naturalKeyCounts = new Map(); + const naturalKeyOf = ( + type: unknown, + source: unknown, + measuredAt: Date, + sleepStage: unknown, + ) => `${type}::${source}::${measuredAt.getTime()}::${sleepStage ?? "null"}`; + for (const p of prepared) { + const externalKey = `${p.row.type}::${p.row.source}::${p.row.externalId}`; + externalKeyCounts.set( + externalKey, + (externalKeyCounts.get(externalKey) ?? 0) + 1, + ); + const naturalKey = naturalKeyOf( + p.row.type, + p.row.source, + p.row.measuredAt as Date, + p.row.sleepStage, + ); + naturalKeyCounts.set( + naturalKey, + (naturalKeyCounts.get(naturalKey) ?? 0) + 1, + ); + } + const sampleExternalIds = [ + ...new Set( + prepared + .map((p) => p.row.externalId as string) + .filter((externalId) => !isStatsExternalId(externalId)), + ), + ]; + const existingByExternalKey = new Map< + string, + { measuredAt: Date; sleepStage: unknown } + >(); + if (sampleExternalIds.length > 0) { + const existing = await prisma.measurement.findMany({ + where: { userId: user.id, externalId: { in: sampleExternalIds } }, + select: { + type: true, + source: true, + externalId: true, + measuredAt: true, + sleepStage: true, + }, + }); + for (const row of existing) { + existingByExternalKey.set( + `${row.type}::${row.source}::${row.externalId}`, + { measuredAt: row.measuredAt, sleepStage: row.sleepStage }, + ); + } + } + function isExactStoredDuplicate(p: Prepared): boolean { + const externalKey = `${p.row.type}::${p.row.source}::${p.row.externalId}`; + const stored = existingByExternalKey.get(externalKey); + if (!stored) return false; + const measuredAt = p.row.measuredAt as Date; + const naturalKey = naturalKeyOf( + p.row.type, + p.row.source, + measuredAt, + p.row.sleepStage, + ); + return ( + stored.measuredAt.getTime() === measuredAt.getTime() && + (stored.sleepStage ?? null) === (p.row.sleepStage ?? null) && + externalKeyCounts.get(externalKey) === 1 && + naturalKeyCounts.get(naturalKey) === 1 && + classifyRangeRejection( + p.row.type as MeasurementType, + p.row.value as number, + ) === null + ); + } + + // v1.42 — the `folded_window` guard (`folded-window.ts`). A raw Apple + // Health sample of a dense or mean type, old enough to have been folded, + // whose hour or day a live `stats:` row already covers, is a duplicate: + // the fold no longer leaves the raw rows behind as tombstones, so this is + // what keeps a re-upload after an anchor reset from standing beside the + // mean that already accounts for it. One read for the batch, and none at + // all when no row is old enough. + const foldedIndexes = await findFoldedWindowDuplicates( + prisma, + user.id, + prepared.map((p) => ({ + type: p.row.type as MeasurementType, + source: p.row.source as MeasurementSource, + externalId: p.row.externalId as string, + measuredAt: p.row.measuredAt as Date, + })), + ); + const foldedPrepared = new Set( + [...foldedIndexes].map((index) => prepared[index]), + ); + for (const p of prepared) { const externalId = p.row.externalId as string; const statsRow = isStatsExternalId(externalId); @@ -644,6 +783,32 @@ async function postBatch(request: NextRequest): Promise { continue; } + // Both verdicts below are the reconciler's `duplicate`, reached without + // its transaction; like every duplicate they fall through to the + // in-batch merge candidates. + const prefiltered = !statsRow && isExactStoredDuplicate(p); + const folded = !prefiltered && !statsRow && foldedPrepared.has(p); + if (prefiltered || folded) { + results[p.index] = folded + ? { + index: p.index, + status: "duplicate", + reason: FOLDED_WINDOW_REASON, + } + : { index: p.index, status: "duplicate" }; + duplicateCount++; + if (folded) foldedWindowCount++; + if (isMergeableSource(p.row.source as string)) { + inBatchCandidates.push({ + type: p.row.type as MeasurementType, + source: p.row.source as string, + value: p.row.value as number, + measuredAt: p.row.measuredAt as Date, + }); + } + continue; + } + const verdict = await prisma.$transaction((tx) => reconcileExternalMeasurement( tx, @@ -774,6 +939,34 @@ async function postBatch(request: NextRequest): Promise { const syncedAt = new Date(); const deliveredWithoutTheApp = syncTrigger === "background" || syncTrigger === "push"; + // v1.42 (#1173) — what this sync brought per type, for the per-type + // freshness list on the Apple Health card. Apple Health rows only, and + // under the same conditions as the checkpoint beside it. + const arrivals = new Map(); + for (const p of prepared) { + if (p.row.source !== "APPLE_HEALTH") continue; + const status = results[p.index]?.status; + if ( + status !== "inserted" && + status !== "updated" && + status !== "duplicate" + ) { + continue; + } + const type = p.row.type as MeasurementType; + const entry = arrivals.get(type) ?? { accepted: 0, newSamples: 0 }; + entry.accepted += 1; + if (status !== "duplicate") entry.newSamples += 1; + arrivals.set(type, entry); + } + if (arrivals.size > 0) { + await recordHealthKitTypeArrivals(prisma, { + userId: user.id, + counts: arrivals, + trigger: syncTrigger ?? null, + at: syncedAt, + }); + } await prisma.user.update({ where: { id: user.id }, data: { @@ -918,15 +1111,18 @@ async function postBatch(request: NextRequest): Promise { }); } - // v1.4.25 W16c — kick off PR detection for this user. We always - // enqueue when at least one row was written (or the batch had any - // measurements to consider) so a single off-day reading still gets - // evaluated; the warm-up gate inside the detector decides whether - // it's a record. Suppress push notifications for historical - // backfills above the silent threshold. Updated `stats:*` rows also - // count — a per-day-total overwrite can flip the day's value past a - // personal record. - if (insertedCount > 0 || updatedCount > 0 || duplicateCount > 0) { + // v1.4.25 W16c — kick off PR detection for this user when at least one + // row was written, so a single off-day reading still gets evaluated; the + // warm-up gate inside the detector decides whether it's a record. Suppress + // push notifications for historical backfills above the silent threshold. + // Updated `stats:*` rows also count — a per-day-total overwrite can flip + // the day's value past a personal record. + // + // v1.42 — a batch of duplicates only no longer enqueues. It changed + // nothing a record or a reminder could read, and an anchor-reset backfill + // sends hundreds of them, each of which used to queue a detection pass, a + // reminder sweep and an audit row for no new reading. + if (insertedCount > 0 || updatedCount > 0) { const silent = entries.length > PR_DETECTION_SILENT_THRESHOLD; // v1.18.1 — eventful Vorsorge satisfaction. A matching reading just // landed; resolve the user's reminders now rather than waiting on the @@ -968,9 +1164,13 @@ async function postBatch(request: NextRequest): Promise { skipped_by_reason: skippedByReason, failed: failedCount, // v1.32.8 (iOS #66) — diagnostic sync-trigger tag (foreground / - // background / push), or null when the client did not send one. - // Observability only; it never influenced any count above. + // background / push / manual), or null when the client did not send + // one. It never influenced any count above; since v1.42 it decides + // how hard the caches below are invalidated. syncTrigger: syncTrigger ?? null, + // v1.42 — duplicates refused because a live `stats:` row already + // covers the sample's hour or day (`folded-window.ts`). + folded_window: foldedWindowCount, }, }); @@ -982,12 +1182,26 @@ async function postBatch(request: NextRequest): Promise { // and must invalidate every consumer that reads through this user's // measurements. if (insertedCount > 0 || updatedCount > 0) { - // v1.18.9 (#38) — hard-evict so a focus-refetch after a background - // iOS / Apple-Health batch sync returns post-sync data. A mark-stale - // would let the `cachedSwr` snapshot serve the pre-batch body on the - // very next read, leaving the dashboard up to ~180 s stale; the manual - // measurement routes already pass `{ evict: true }` for the same reason. - invalidateUserMeasurements(user.id, { evict: true }); + // v1.42 — evict only when someone is looking. v1.18.9 (#38) evicted on + // every batch so a focus-refetch after a sync showed post-sync data, but + // a phone syncing in the background posts a batch every few minutes all + // day, and each eviction sent the next dashboard read into a cold + // rebuild. Every other background sync marks stale, which `cachedSwr` + // serves once while one rebuild runs; this route now does the same + // unless the app is open (`foreground`), the person pressed sync + // (`manual`), or the batch wrote one of their own hand-entered readings + // (`MANUAL`), whose entry has to show on the very next read. + const wroteManualRow = prepared.some( + (p) => + p.row.source === "MANUAL" && + (results[p.index]?.status === "inserted" || + results[p.index]?.status === "updated"), + ); + const evict = + syncTrigger === "foreground" || + syncTrigger === "manual" || + wroteManualRow; + invalidateUserMeasurements(user.id, evict ? { evict: true } : undefined); // v1.37.19 (C2-F1) — shared post-mutation tail for every distinct // (type, day) the batch touched. Updates participate as well as diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index 5a11e30ba..367f82e11 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -7,6 +7,7 @@ import { buildCompliancePayload, complianceCacheKey, type CompliancePayload, + type CompliancePayloadWire, } from "@/lib/medications/compliance-payload"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -113,6 +114,16 @@ export const GET = apiHandler( }, }); - return apiSuccess(payload); + // The report windows ride the cached cell for the batched read only; this + // route's published shape does not carry them. + const wire: CompliancePayloadWire = { + applicable: payload.applicable, + notApplicableReason: payload.notApplicableReason, + compliance7: payload.compliance7, + compliance30: payload.compliance30, + dailyCompliance: payload.dailyCompliance, + complianceDisplay: payload.complianceDisplay, + }; + return apiSuccess(wire); }, ); diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index 5e068be58..1705cb8a1 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -71,6 +71,7 @@ vi.mock("@/lib/medications/inventory/consumption", () => ({ vi.mock("@/lib/medications/route-guards", () => ({ assertMedicationOwnership: vi.fn().mockResolvedValue(null), + assertMedicationTakesIntake: vi.fn().mockResolvedValue(null), })); vi.mock("@/lib/cache/invalidate", () => ({ diff --git a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts index b7a9a4519..053deba20 100644 --- a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts @@ -79,6 +79,7 @@ vi.mock("@/lib/medications/lifecycle", () => ({ vi.mock("@/lib/medications/route-guards", () => ({ assertMedicationOwnership: vi.fn().mockResolvedValue(null), + assertMedicationTakesIntake: vi.fn().mockResolvedValue(null), })); vi.mock("@/lib/cache/invalidate", () => ({ diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index b61961363..2cf36546a 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -27,7 +27,10 @@ import { restoreForIntake, } from "@/lib/medications/inventory/consumption"; import { reconcileOneShotState } from "@/lib/medications/lifecycle"; -import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { + assertMedicationOwnership, + assertMedicationTakesIntake, +} from "@/lib/medications/route-guards"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear"; @@ -111,8 +114,11 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { const intakeSource = authMethod === "bearer" ? "API" : "WEB"; const { id } = await params; - // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. - const guard = await assertMedicationOwnership(id, user.id); + // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. This is a + // dose write, so the guard also refuses a record-only medication with + // `medication.intake.notTracked` (iOS #116, item 15): its history stays, + // but a new dose would be stored and then counted nowhere. + const guard = await assertMedicationTakesIntake(id, user.id); if (guard) return guard; const { data: body, error: jsonError } = await safeJson(request, { diff --git a/src/app/api/medications/compliance/__tests__/route.test.ts b/src/app/api/medications/compliance/__tests__/route.test.ts index 0f49d87d4..1f7059142 100644 --- a/src/app/api/medications/compliance/__tests__/route.test.ts +++ b/src/app/api/medications/compliance/__tests__/route.test.ts @@ -274,4 +274,70 @@ describe("GET /api/medications/compliance", () => { ); expect(prisma.medication.findMany).not.toHaveBeenCalled(); }); + + it("carries one account-wide figure in meta.aggregate, from applicable medications only", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + medication("med-1"), + medication("med-2"), + { + ...medication("mirror-1"), + externalSource: "APPLE_HEALTH", + externalId: "hk-concept-1", + schedules: [], + }, + ] as never); + + const res = await (GET as (req: Request) => Promise)( + new Request("http://localhost"), + ); + const body = await res.json(); + const items = body.data as Array<{ + compliance7: { totalExpected: number; missed: number }; + compliance30: { totalExpected: number }; + }>; + expect(body.meta.aggregate.medicationCount).toBe(2); + // Summed over the two scheduled medications; the mirror adds nothing. + expect(body.meta.aggregate.compliance7.totalExpected).toBe( + items[0].compliance7.totalExpected + items[1].compliance7.totalExpected, + ); + expect(body.meta.aggregate.compliance30.totalExpected).toBe( + items[0].compliance30.totalExpected + items[1].compliance30.totalExpected, + ); + expect(body.meta.aggregate.compliance7.totalExpected).toBeGreaterThan(0); + expect(body.meta).not.toHaveProperty("days"); + }); + + it("answers meta.aggregate null when no medication's adherence applies", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + const res = await (GET as (req: Request) => Promise)( + new Request("http://localhost"), + ); + const body = await res.json(); + expect(body.meta).toEqual({ aggregate: null }); + }); + + it("adds a complianceN block per medication and to the aggregate on ?days=N", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + medication("med-1"), + ] as never); + const res = await (GET as (req: Request) => Promise)( + new Request("http://localhost/api/medications/compliance?days=90"), + ); + expect(res.status).toBe(200); + const body = await res.json(); + const item = body.data[0] as Record; + expect(item.compliance90.totalExpected).toBeGreaterThan( + item.compliance30.totalExpected, + ); + expect(body.meta.days).toBe(90); + expect(body.meta.aggregate.compliance90).toEqual(item.compliance90); + }); + + it("refuses a window the ledger does not serve", async () => { + const res = await (GET as (req: Request) => Promise)( + new Request("http://localhost/api/medications/compliance?days=45"), + ); + expect(res.status).toBe(422); + expect(prisma.medication.findMany).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/medications/compliance/route.ts b/src/app/api/medications/compliance/route.ts index 59d17ee03..d8e0bd49e 100644 --- a/src/app/api/medications/compliance/route.ts +++ b/src/app/api/medications/compliance/route.ts @@ -1,15 +1,22 @@ import pLimit from "p-limit"; +import { z } from "zod/v4"; import { COURSES_COMPLIANCE_SELECT } from "@/lib/analytics/compliance"; import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiSuccess, apiError } from "@/lib/api-response"; import { + apiError, + apiSuccessWithMeta, + returnAllZodIssues, +} from "@/lib/api-response"; +import { + aggregateCompliance, buildCompliancePayload, complianceCacheKey, type CompliancePayload, } from "@/lib/medications/compliance-payload"; +import { COMPLIANCE_REPORT_DAYS } from "@/lib/analytics/compliance"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { checkRateLimit } from "@/lib/rate-limit"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; @@ -28,9 +35,29 @@ import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; * wire shape — the cards render rates / streak / display block only; the * detail page keeps the per-id route for the grid. */ -export const GET = apiHandler(async () => { +/** + * `?days=N` adds a `complianceN` block per medication and to the account + * aggregate, for a report that covers its own window rather than the fixed 7 + * and 30 days. Only the windows the ledger serves are accepted; anything else + * is a 422, never a silently rounded window. + */ +const querySchema = z.object({ + days: z + .enum(COMPLIANCE_REPORT_DAYS.map(String) as [string, ...string[]]) + .transform(Number) + .optional(), +}); + +export const GET = apiHandler(async (request: Request) => { const { user, actor } = await requireRecordAuth("read", "medications"); + const query = querySchema.safeParse( + Object.fromEntries(new URL(request.url).searchParams), + ); + if (!query.success) return returnAllZodIssues(query.error, 422); + const days = query.data.days as + (typeof COMPLIANCE_REPORT_DAYS)[number] | undefined; + // v1.36.0 — the bucket keys on the ACTOR while everything below it scopes to // the record. Two reasons, and both are the same reason from opposite ends: // a delegate hammering this route must burn their own allowance rather than @@ -74,7 +101,7 @@ export const GET = apiHandler(async () => { // on a cold cache. Warm / stale cells return without touching the // database at all; `Promise.all` keeps the response in list order. const limit = pLimit(3); - const items = await Promise.all( + const results = await Promise.all( medications.map((medication) => limit(async () => { const payload = await cachedSwr( @@ -83,22 +110,69 @@ export const GET = apiHandler(async () => { () => buildCompliancePayload(medication, user.id, userTz), annotate, ); - return { - medicationId: medication.id, - applicable: payload.applicable, - notApplicableReason: payload.notApplicableReason, - compliance7: payload.compliance7, - compliance30: payload.compliance30, - complianceDisplay: payload.complianceDisplay, - }; + return { payload, medicationId: medication.id }; }), ), ); + // A cell cached before the report windows existed carries none; it reads + // as the not-applicable placeholder until its next rebuild rather than + // failing the whole read. + const windowFor = (payload: CompliancePayload, n: number) => + payload.reportWindows?.[n as keyof CompliancePayload["reportWindows"]]; + + const items = results.map(({ payload, medicationId }) => ({ + medicationId, + applicable: payload.applicable, + notApplicableReason: payload.notApplicableReason, + compliance7: payload.compliance7, + compliance30: payload.compliance30, + ...(days !== undefined && days !== 30 + ? { [`compliance${days}`]: windowFor(payload, days) ?? null } + : {}), + complianceDisplay: payload.complianceDisplay, + })); + + // The account-wide figure the widget ring and the report show, built only + // from medications whose adherence means something (`applicable`). + const applicable = results + .map((r) => r.payload) + .filter((payload) => payload.applicable); + const reportWindow = + days !== undefined && days !== 30 + ? applicable.map((payload) => windowFor(payload, days)) + : []; + const aggregate = + applicable.length === 0 + ? null + : { + compliance7: aggregateCompliance( + applicable.map((p) => p.compliance7), + ), + compliance30: aggregateCompliance( + applicable.map((p) => p.compliance30), + ), + ...(days !== undefined && days !== 30 + ? { + [`compliance${days}`]: reportWindow.every(Boolean) + ? aggregateCompliance( + reportWindow as NonNullable< + (typeof reportWindow)[number] + >[], + ) + : null, + } + : {}), + medicationCount: applicable.length, + }; + annotate({ action: { name: "medication.compliance_summary.read" }, - meta: { count: items.length }, + meta: { count: items.length, days: days ?? null }, }); - return apiSuccess(items); + return apiSuccessWithMeta(items, { + aggregate, + ...(days !== undefined ? { days } : {}), + }); }); diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index cc1bf78d9..473eb90f9 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -381,6 +381,45 @@ describe("POST /api/medications/intake", () => { expect(res.status).toBe(422); }); + it("refuses to resolve an open dose of a record-only medication with medication.intake.notTracked", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ + id: "e1", + userId: "user-1", + medicationId: "m1", + takenAt: null, + skipped: false, + scheduledFor: new Date("2026-05-18T10:00:00.000Z"), + medication: { trackIntake: false }, + } as never); + const res = await POST(req({ intakeId: "e1", status: "taken" })); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.meta?.errorCode).toBe("medication.intake.notTracked"); + expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled(); + expect(consumeForIntake).not.toHaveBeenCalled(); + }); + + it("still lets the owner correct a dose a record-only medication already has", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ + id: "e1", + userId: "user-1", + medicationId: "m1", + takenAt: new Date("2026-05-18T10:05:00.000Z"), + skipped: false, + scheduledFor: new Date("2026-05-18T10:00:00.000Z"), + medication: { trackIntake: false }, + } as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({ + id: "e1", + skipped: true, + takenAt: null, + } as never); + const res = await POST(req({ intakeId: "e1", status: "skipped" })); + expect(res.status).toBe(200); + }); + it("marks event as skipped", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({ diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index 25765da23..464d57691 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -244,6 +244,29 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => { vi.useRealTimers(); }); + it("skips an entry for a record-only medication as intake_not_tracked and keeps the rest", async () => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + { id: "med-1", trackIntake: false }, + ] as never); + const res = await POST( + postReq({ + entries: [ + { + medicationId: "med-1", + scheduledFor: "2026-06-15T05:00:00.000Z", + takenAt: "2026-06-15T05:01:00.000Z", + }, + ], + }), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.entries).toEqual([ + { index: 0, status: "skipped", reason: "intake_not_tracked" }, + ]); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + }); + it("C2 — a pending echo onto an already-TAKEN slot is reported duplicate, NOT a downgrade", async () => { // Existing TAKEN row at the 07:00 slot. The bulk entry is a pending // echo (no takenAt, skipped false) — must NOT clear takenAt. diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 5064c0699..69fd01a1f 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -86,6 +86,10 @@ import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { dispatchMedicationIntakeWebClearBulk } from "@/lib/notifications/web-push-clear"; import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; +import { + INTAKE_NOT_TRACKED_REASON, + isRecordOnly, +} from "@/lib/medications/intake-tracking"; const MAX_ENTRIES_PER_BATCH = 500; const BATCH_RATE_LIMIT_MAX = 60; @@ -256,6 +260,7 @@ async function postBulk(request: NextRequest): Promise { trackInjectionSites: true, allowedInjectionSites: true, externalSource: true, + trackIntake: true, }, }); const ownedSet = new Set(ownedMedications.map((m) => m.id)); @@ -366,6 +371,16 @@ async function postBulk(request: NextRequest): Promise { continue; } + // A record-only medication takes no new dose (iOS #116, item 15). The + // single-dose routes answer 422 `medication.intake.notTracked`; here the + // same decision is one entry's outcome, so the rest of the batch lands. + if (isRecordOnly(medById.get(entry.medicationId) ?? {})) { + const reason = INTAKE_NOT_TRACKED_REASON; + skipped.push({ index: i, reason }); + results.push({ index: i, status: "skipped", reason }); + continue; + } + // Both dedup keys on this route are only idempotent while the value // is STABLE across client launches: `(userId, externalId)` for the // Apple dose event, and the `@unique` `idempotency_key` column for diff --git a/src/app/api/medications/intake/dose-history-import/route.ts b/src/app/api/medications/intake/dose-history-import/route.ts index 3949c3479..2d3cda2ce 100644 --- a/src/app/api/medications/intake/dose-history-import/route.ts +++ b/src/app/api/medications/intake/dose-history-import/route.ts @@ -176,7 +176,7 @@ export const POST = apiHandler(async (request: NextRequest) => { if (plan.entries.length > MAX_QUEUED_ROWS) { return apiError( - `The file holds ${plan.entries.length} importable rows — the limit is ${MAX_QUEUED_ROWS} per import`, + `The file holds ${plan.entries.length} importable rows. The limit is ${MAX_QUEUED_ROWS} per import`, 422, { errorCode: "too_many_rows" }, ); diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 93c8edaad..e4f6b0ca7 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -54,7 +54,11 @@ import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear"; import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake"; import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; -import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking"; +import { + TRACKED_INTAKE_EVENT_WHERE, + isRecordOnly, +} from "@/lib/medications/intake-tracking"; +import { refuseUntrackedIntake } from "@/lib/medications/route-guards"; // The query + body schemas moved to `@/lib/validations/medication` when this // endpoint joined the published contract: the OpenAPI registry generates from @@ -265,6 +269,7 @@ export const POST = apiHandler(async (request: NextRequest) => { deliveryForm: true, trackInjectionSites: true, allowedInjectionSites: true, + trackIntake: true, }, }, }, @@ -273,6 +278,16 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Intake event not found", 404); } + // A record-only medication keeps the doses it already has, and an owner may + // still correct one of those (that is an edit of history). What it no longer + // takes is a NEW dose: resolving or snoozing an event that is still open + // would record one, and it would then be counted nowhere (iOS #116, item + // 15). Refused by name, the same code the per-medication route answers with. + const stillOpen = existing.takenAt === null && !existing.skipped; + if (stillOpen && isRecordOnly(existing.medication)) { + return refuseUntrackedIntake(); + } + // v1.36.1 — this route is admitted for delegates because marking a dose is // the thing a person standing next to the patient needs to do. It is an // UPDATE, though, and two of the transitions it can express are not that. diff --git a/src/app/api/mood/insights/__tests__/route.test.ts b/src/app/api/mood/insights/__tests__/route.test.ts new file mode 100644 index 000000000..55836ce48 --- /dev/null +++ b/src/app/api/mood/insights/__tests__/route.test.ts @@ -0,0 +1,86 @@ +/** + * `GET /api/mood/insights?days=N` serves the stability score for the period + * a client shows. The aggregate is cached once per user with every window in + * it; the route picks one, names it, and keeps the per-window map off the + * wire. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/modules/gate", () => ({ + requireModuleEnabled: vi.fn(async () => ({ enabled: true })), +})); +vi.mock("@/lib/cache/server-cache", () => ({ + cachedSwr: vi.fn(), + caches: { moodInsights: {} }, +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { cachedSwr } from "@/lib/cache/server-cache"; +import { requireModuleEnabled } from "@/lib/modules/gate"; + +const year = { score: 40, stdDev: 0.9, band: "variable", days: 300 }; +const month = { score: 90, stdDev: 0.2, band: "verySteady", days: 28 }; + +const AGGREGATE = { + summary: { totalEntries: 300 }, + heatmap: { windowDays: 365, cells: [] }, + tags: [], + stability: year, + stabilityByWindow: { 30: month, 90: null, 180: year, 365: year }, +}; + +const call = (query = "") => + (GET as (req: Request) => Promise)( + new Request(`http://localhost/api/mood/insights${query}`), + ); + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue({ + session: { id: "s", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "u", role: "USER", timezone: "UTC" }, + } as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never); + vi.mocked(cachedSwr).mockResolvedValue(AGGREGATE as never); +}); + +describe("GET /api/mood/insights — stability per period", () => { + it("serves the whole year by default and names the window", async () => { + const body = await (await call()).json(); + expect(body.data.stability).toEqual(year); + expect(body.data.stabilityWindowDays).toBe(365); + expect(body.data).not.toHaveProperty("stabilityByWindow"); + }); + + it("serves the chosen window as stability on ?days=30", async () => { + const body = await (await call("?days=30")).json(); + expect(body.data.stability).toEqual(month); + expect(body.data.stabilityWindowDays).toBe(30); + }); + + it("answers null, not the year's score, when the window has too few days", async () => { + const body = await (await call("?days=90")).json(); + expect(body.data.stability).toBeNull(); + expect(body.data.stabilityWindowDays).toBe(90); + }); + + it("refuses a window it does not offer", async () => { + const res = await call("?days=45"); + expect(res.status).toBe(422); + expect(cachedSwr).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/mood/insights/route.ts b/src/app/api/mood/insights/route.ts index 95d37c5c9..d856af2fd 100644 --- a/src/app/api/mood/insights/route.ts +++ b/src/app/api/mood/insights/route.ts @@ -1,10 +1,15 @@ -import { apiSuccess } from "@/lib/api-response"; +import { z } from "zod/v4"; + +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { fetchMoodAggregates, + MOOD_STABILITY_WINDOWS, + moodInsightsWire, type MoodAggregates, + type MoodStabilityWindow, } from "@/lib/insights/mood-aggregates"; import { requireModuleEnabled } from "@/lib/modules/gate"; @@ -26,9 +31,28 @@ export const dynamic = "force-dynamic"; * aggregate is served immediately and a single background recompute * warms a fresh one, so an active logger never re-pays the cold compute. */ -export const GET = apiHandler(async () => { +/** + * `?days=N` cuts `stability` to the trailing N days, so a period picker shows + * the steadiness of the period it shows. Without it the score covers the + * whole year, as it always has; `stabilityWindowDays` names the window the + * served score covers either way. + */ +const querySchema = z.object({ + days: z + .enum(MOOD_STABILITY_WINDOWS.map(String) as [string, ...string[]]) + .transform((v) => Number(v) as MoodStabilityWindow) + .optional(), +}); + +export const GET = apiHandler(async (request: Request) => { const { user } = await requireAuth(); + const query = querySchema.safeParse( + Object.fromEntries(new URL(request.url).searchParams), + ); + if (!query.success) return returnAllZodIssues(query.error, 422); + const stabilityWindowDays: MoodStabilityWindow = query.data.days ?? 365; + // Per-domain gate: the mood-insights aggregate surface only serves the // mood module's analysis page, so it must be gated on the mood module // (mirrors `/api/insights/mood-status`). Disabled ⇒ 403 module.disabled. @@ -51,5 +75,5 @@ export const GET = apiHandler(async () => { }, }); - return apiSuccess(result); + return apiSuccess(moodInsightsWire(result, stabilityWindowDays)); }); diff --git a/src/app/api/settings/webhook/route.ts b/src/app/api/settings/webhook/route.ts index 49cccdec3..0c583c34e 100644 --- a/src/app/api/settings/webhook/route.ts +++ b/src/app/api/settings/webhook/route.ts @@ -42,8 +42,7 @@ const NOT_GRANTABLE_REFUSAL = * GET: current config (header value redacted — only presence flagged). * PUT: upsert config. * - * Covers Gotify / Discord / Slack / Matrix-bridge / Home Assistant in one - * channel: the user supplies a URL and an optional shared-secret header. SSRF + * Covers Discord / Slack / Gotify / Home Assistant / n8n in one channel: the user supplies a URL and an optional shared-secret header. SSRF * is enforced at input time (the schema's target predicate) and again at * dispatch time (`safeFetch` with the connect-time pin); a private origin * passes both only when the operator listed it in diff --git a/src/app/api/settings/webhook/test/__tests__/route.test.ts b/src/app/api/settings/webhook/test/__tests__/route.test.ts new file mode 100644 index 000000000..b317e1dd5 --- /dev/null +++ b/src/app/api/settings/webhook/test/__tests__/route.test.ts @@ -0,0 +1,150 @@ +/** + * The webhook test button against a Discord webhook URL, through the real + * sender with only the network mocked. + * + * The mocked target answers the way Discord does: 400 with code 50006 + * ("Cannot send an empty message") for a body without `content` or `embeds`, + * 204 otherwise. The generic body used to carry neither, so the test button + * and every reminder failed against a Discord URL the settings card names. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + notificationChannel: { findUnique: vi.fn() }, + }, +})); + +vi.mock("@/lib/crypto", () => ({ + encrypt: vi.fn((value: string) => `encrypted:${value}`), + decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")), +})); + +const safeFetchMock = vi.fn(); +vi.mock("@/lib/safe-fetch", () => ({ + safeFetch: (...args: unknown[]) => safeFetchMock(...args), + SafeFetchError: class SafeFetchError extends Error { + kind: string; + constructor(message: string, kind: string) { + super(message); + this.kind = kind; + } + }, +})); + +vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ + recordPushAttempt: vi.fn(), + recordPushAttemptForPayload: vi.fn(), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 4, + resetAt: Date.now() + 60_000, + }), + rateLimitHeaders: () => ({}), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST as postTest } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; + +const DISCORD_URL = + "https://discord.com/api/webhooks/123456789012345678/AbCdEfGhIjKlMnOpQrStUvWxYz0123456789"; + +/** Answers the way Discord's execute-webhook endpoint does. */ +function discordAnswer(_url: string, init: { body: string }): Response { + const body = JSON.parse(init.body) as Record; + const content = typeof body.content === "string" ? body.content : ""; + if (content.trim().length === 0 && body.embeds === undefined) { + return new Response( + JSON.stringify({ message: "Cannot send an empty message", code: 50006 }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + if (content.length > 2000) { + return new Response( + JSON.stringify({ message: "Invalid Form Body", code: 50035 }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + return new Response(null, { status: 204 }); +} + +const POST = postTest as (request: Request) => Promise; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue({ + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" }, + } as never); + vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({ + config: `encrypted:${JSON.stringify({ url: DISCORD_URL })}`, + } as never); + safeFetchMock.mockImplementation( + async (url: string, init: { body: string }) => discordAnswer(url, init), + ); +}); + +describe("POST /api/settings/webhook/test — a Discord webhook URL", () => { + it("delivers: the generic body carries the content Discord requires", async () => { + const response = await POST( + new Request("http://localhost/api/settings/webhook/test", { + method: "POST", + }), + ); + + expect(response.status).toBe(200); + expect((await response.json()).data).toEqual({ sent: true }); + + expect(safeFetchMock).toHaveBeenCalledTimes(1); + const [url, init] = safeFetchMock.mock.calls[0] as [ + string, + { body: string }, + ]; + expect(url).toBe(DISCORD_URL); + const sent = JSON.parse(init.body); + expect(sent.content).toBe( + "HealthLog Test\nHealthLog: Connection successful! Webhook notifications are active.", + ); + expect(sent.allowed_mentions).toEqual({ parse: [] }); + }); + + it("passes Discord's own error through when the target still refuses", async () => { + safeFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ message: "Unknown Webhook", code: 10015 }), + { status: 404, headers: { "Content-Type": "application/json" } }, + ), + ); + + const response = await POST( + new Request("http://localhost/api/settings/webhook/test", { + method: "POST", + }), + ); + + expect(response.status).toBe(502); + const json = await response.json(); + expect(json.meta.upstreamStatus).toBe(404); + expect(json.meta.upstreamBody).toContain("Unknown Webhook"); + }); +}); diff --git a/src/app/api/sync/changes/route.ts b/src/app/api/sync/changes/route.ts index 810cf3460..43c379a9d 100644 --- a/src/app/api/sync/changes/route.ts +++ b/src/app/api/sync/changes/route.ts @@ -51,6 +51,11 @@ import type { NextRequest } from "next/server"; import { z } from "zod/v4"; import { prisma } from "@/lib/db"; +import { + findCompactionTombstones, + isFoldedWindowCandidate, + loadFoldTimezone, +} from "@/lib/measurements/folded-window"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, @@ -416,7 +421,37 @@ export const GET = apiHandler(async (request: NextRequest) => { const mPage = mHasMore ? measurementRows.slice(0, limit) : measurementRows; const measurementUpserts: MeasurementUpsert[] = []; const measurementTombstones: MeasurementTombstone[] = []; + // v1.42 — compaction tombstones are not deletions anyone made. The folds + // used to soft-delete the raw Apple Health samples they folded into an + // hourly or daily `stats:` row, and those rows rode this feed as + // tombstones for 75 days. They are left out now, by the same classifier the + // backlog purge uses (`folded-window.ts`); the cursor still advances past + // them, so the keyset walk is unchanged. A person's own deletions are + // reported exactly as before. + const compaction = new Set(); + const tombstonesOnPage = mPage.filter((row) => row.deletedAt !== null); + if (tombstonesOnPage.length > 0) { + const candidates = tombstonesOnPage.map((row) => ({ + type: row.type, + source: row.source, + externalId: row.externalId, + measuredAt: row.measuredAt, + deletedAt: row.deletedAt, + })); + if ( + candidates.some((row) => isFoldedWindowCandidate(row, row.deletedAt!)) + ) { + const classA = await findCompactionTombstones( + prisma, + user.id, + await loadFoldTimezone(prisma, user.id), + candidates, + ); + for (const index of classA) compaction.add(tombstonesOnPage[index].id); + } + } for (const row of mPage) { + if (compaction.has(row.id)) continue; if (row.deletedAt) { measurementTombstones.push({ id: row.id, diff --git a/src/app/api/timeline/readiness/route.ts b/src/app/api/timeline/readiness/route.ts new file mode 100644 index 000000000..0f4be4737 --- /dev/null +++ b/src/app/api/timeline/readiness/route.ts @@ -0,0 +1,42 @@ +/** + * `GET /api/timeline/readiness` — what the timeline can already show, lane by + * lane, with one link per gap (v1.42, #613). Counts and keys, never a score. + * The shape is `TimelineReadinessResponse` in `src/lib/day/contract.ts`; + * `loadTimelineReadiness` builds it. + * + * Gated on the opt-in `timeline` module, admitted like the timeline, and + * narrowed the same way: a lane the grant does not cover is not listed. + */ +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; +import { loadTimelineReadiness } from "@/lib/timeline/readiness"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; + +export const GET = apiHandler(async () => { + const { user, grantId } = await requireRecordAuth("read", "record"); + const gate = await requireModuleEnabled(user.id, "timeline"); + if (!gate.enabled) return gate.response; + const [modules, domainVisible, tz] = await Promise.all([ + resolveModuleMap(user.id), + actingDomainVisibility(prisma, grantId), + resolveUserTimezone(user.id), + ]); + const readiness = await loadTimelineReadiness({ + recordId: user.id, + tz, + access: { modules, domainVisible, owner: grantId === null }, + }); + annotate({ + action: { name: "timeline.readiness.read" }, + meta: { + verdict: readiness.verdict, + lanes: readiness.lanes.length, + carrying: readiness.lanes.filter((l) => l.status === "carries").length, + }, + }); + return apiSuccess(readiness); +}); diff --git a/src/app/api/timeline/route.ts b/src/app/api/timeline/route.ts new file mode 100644 index 000000000..6180427b2 --- /dev/null +++ b/src/app/api/timeline/route.ts @@ -0,0 +1,76 @@ +/** + * `GET /api/timeline?zoom=&from=&to=&values=` — the record laid out over the + * years (v1.42, #613): lanes of conditions, medications, visits, documents and + * life events, the standing items without a start, and value series by zoom. + * The shape is `TimelineResponse` in `src/lib/day/contract.ts`; `loadTimeline` + * builds it. + * + * Gated on the opt-in `timeline` module. Admission declares the whole record: + * the timeline lays every section side by side, so a grant scoped to some of + * them does not open it. The lanes are narrowed to the grant's domains all + * the same (`actingDomainVisibility`), so the day this admission is reviewed + * and narrowed the loader already reads no lane it should not. + */ +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { timelineQuerySchema } from "@/lib/day/wire-schemas"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; +import { loadTimeline, parseSeriesKeys } from "@/lib/timeline/load-timeline"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; + +export const GET = apiHandler(async (request: Request) => { + const { user, grantId } = await requireRecordAuth("read", "record"); + const gate = await requireModuleEnabled(user.id, "timeline"); + if (!gate.enabled) return gate.response; + + const params = new URL(request.url).searchParams; + const parsed = timelineQuerySchema.safeParse({ + zoom: params.get("zoom") ?? undefined, + from: params.get("from") ?? undefined, + to: params.get("to") ?? undefined, + values: params.get("values") ?? undefined, + }); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "timeline.invalid", + }); + } + if (parsed.data.from && parsed.data.to && parsed.data.from > parsed.data.to) { + return apiError("`from` must not be after `to`", 422, { + errorCode: "timeline.invalid", + }); + } + const seriesKeys = parseSeriesKeys(parsed.data.values); + if (seriesKeys === null) { + return apiError("Unknown value series", 422, { + errorCode: "timeline.invalid", + }); + } + + const [modules, domainVisible, tz] = await Promise.all([ + resolveModuleMap(user.id), + actingDomainVisibility(prisma, grantId), + resolveUserTimezone(user.id), + ]); + const timeline = await loadTimeline({ + recordId: user.id, + query: parsed.data, + seriesKeys, + access: { modules, domainVisible, owner: grantId === null }, + tz, + }); + annotate({ + action: { name: "timeline.read" }, + meta: { + zoom: timeline.zoom, + lanes: timeline.lanes.length, + items: timeline.lanes.reduce((n, l) => n + l.items.length, 0), + series: timeline.series.length, + notable: timeline.notable.length, + }, + }); + return apiSuccess(timeline); +}); diff --git a/src/app/api/vaccinations/[id]/route.ts b/src/app/api/vaccinations/[id]/route.ts index 1b85c023d..376016267 100644 --- a/src/app/api/vaccinations/[id]/route.ts +++ b/src/app/api/vaccinations/[id]/route.ts @@ -34,6 +34,7 @@ import { VACCINATION_INCLUDE, applyVaccinationLinks, loadVaccinationDocuments, + resolveOwnedCustomVaccine, resolveOwnedEncounter, resolveOwnedPractitioner, resolveSeriesFor, @@ -116,9 +117,13 @@ export const PATCH = apiHandler( entry.vaccineName !== undefined ? entry.vaccineName : existing.vaccineName; - if (!nextSlug && !nextName) { + const nextCustom = + entry.customVaccineId !== undefined + ? entry.customVaccineId + : existing.customVaccineId; + if (!nextSlug && !nextName && !nextCustom) { return apiError( - "A dose keeps a name: pick one from the catalogue or type what the record says", + "A dose keeps a name: pick one from the catalogue or your own vaccines, or type what the record says", 422, { errorCode: "vaccination.identity-required" }, ); @@ -141,6 +146,14 @@ export const PATCH = apiHandler( ); if (!owned) return "unknown-encounter" as const; } + if (entry.customVaccineId) { + const owned = await resolveOwnedCustomVaccine( + tx, + user.id, + entry.customVaccineId, + ); + if (!owned) return "unknown-custom-vaccine" as const; + } const data: Prisma.VaccinationRecordUpdateInput = {}; if (entry.occurredAt !== undefined) { @@ -165,6 +178,11 @@ export const PATCH = apiHandler( ? { connect: { id: entry.encounterId } } : { disconnect: true }; } + if (entry.customVaccineId !== undefined) { + data.customVaccine = entry.customVaccineId + ? { connect: { id: entry.customVaccineId } } + : { disconnect: true }; + } const updated = await tx.vaccinationRecord.update({ where: { id }, @@ -184,6 +202,11 @@ export const PATCH = apiHandler( errorCode: "vaccination.encounter-not-found", }); } + if (outcome === "unknown-custom-vaccine") { + return apiError("Vaccine not found", 404, { + errorCode: "vaccination.custom-vaccine-not-found", + }); + } const row = await prisma.vaccinationRecord.findUniqueOrThrow({ where: { id }, @@ -209,6 +232,7 @@ export const PATCH = apiHandler( doseNumber: existing.doseNumber, practitionerId: existing.practitionerId, encounterId: existing.encounterId, + customVaccineId: existing.customVaccineId, }, after: { occurredAt: outcome.updated.occurredAt, @@ -216,6 +240,7 @@ export const PATCH = apiHandler( doseNumber: outcome.updated.doseNumber, practitionerId: outcome.updated.practitionerId, encounterId: outcome.updated.encounterId, + customVaccineId: outcome.updated.customVaccineId, }, }), }, @@ -227,7 +252,10 @@ export const PATCH = apiHandler( entity_type: "vaccination", entity_id: id, }, - meta: { antigen_slug: row.antigenSlug ?? "free-text" }, + meta: { + antigen_slug: + row.antigenSlug ?? (row.customVaccineId ? "custom" : "free-text"), + }, }); return apiSuccess(toVaccinationDTO(row, series.get(id) ?? [], documents)); diff --git a/src/app/api/vaccinations/custom/[id]/route.ts b/src/app/api/vaccinations/custom/[id]/route.ts new file mode 100644 index 000000000..e929aa1d9 --- /dev/null +++ b/src/app/api/vaccinations/custom/[id]/route.ts @@ -0,0 +1,200 @@ +/** + * `PATCH /api/vaccinations/custom/{id}` — edit one of the record's own + * vaccine definitions. + * `DELETE /api/vaccinations/custom/{id}` — remove it (v1.42, #1005). + * + * MANAGE, classified `profile`, the same split as the dose edit and delete. + * + * An edit is a correction of the definition and applies to every dose logged + * against it: the series those doses sit in is derived on read, so changing + * the antigens or the series length re-reads them, exactly as a corrected + * catalogue entry would. It deliberately does not re-run the booster satisfy + * matcher — that belongs to the act of logging a dose, which happened once. + * + * The removal soft-deletes the definition and lets go of every dose that + * named it; each dose keeps a name (`removeCustomVaccine`). Owner-scoped by + * fetch-then-guard; `userId` is narrowed from auth and never a body field. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { isP2002 } from "@/lib/prisma-errors"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { overwriteDetails } from "@/lib/sharing/audit-details"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { customVaccineUpdateSchema } from "@/lib/validations/vaccinations"; +import { toCustomVaccineDTO } from "@/lib/vaccinations/dto"; +import { + CUSTOM_VACCINE_SELECT, + findLiveNameClash, + removeCustomVaccine, +} from "@/lib/vaccinations/custom-vaccines"; +import type { Prisma } from "@/generated/prisma/client"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export const PATCH = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireRecordAuth("manage", "profile"); + + const { id } = await params; + const existing = await prisma.customVaccine.findUnique({ where: { id } }); + if ( + !existing || + existing.userId !== user.id || + existing.deletedAt !== null + ) { + return apiError("Vaccine not found", 404, { + errorCode: "vaccination.custom.not-found", + }); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 8 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = customVaccineUpdateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "vaccination.custom.invalid", + }); + } + const entry = parsed.data; + + // The lookup below and the unique index answer the same question; two + // requests racing with the same name both pass the lookup, and the + // loser meets the index. That is the same answer, so it gets the same + // 409 rather than a 500. + const outcome = await prisma + .$transaction(async (tx) => { + if ( + entry.name !== undefined && + entry.name !== existing.name && + (await findLiveNameClash(tx, user.id, entry.name, id)) + ) { + return "name-taken" as const; + } + // A removed definition may still hold the new name on the unique + // index. It is gone from the person's view, so it gives the name up + // rather than blocking the rename. + if (entry.name !== undefined && entry.name !== existing.name) { + await tx.customVaccine.deleteMany({ + where: { + userId: user.id, + name: entry.name, + deletedAt: { not: null }, + }, + }); + } + // Field-by-field — never spread the parsed object whole. + const data: Prisma.CustomVaccineUpdateInput = {}; + if (entry.name !== undefined) data.name = entry.name; + if (entry.components !== undefined) data.components = entry.components; + if (entry.typicalSeriesDoses !== undefined) { + data.typicalSeriesDoses = entry.typicalSeriesDoses; + } + if (entry.boosterIntervalMonths !== undefined) { + data.boosterIntervalMonths = entry.boosterIntervalMonths; + } + return tx.customVaccine.update({ + where: { id }, + data, + select: CUSTOM_VACCINE_SELECT, + }); + }) + .catch((err: unknown) => { + if (isP2002(err)) return "name-taken" as const; + throw err; + }); + + if (outcome === "name-taken") { + return apiError("A vaccine with this name already exists", 409, { + errorCode: "vaccination.custom.name-taken", + }); + } + + await auditLog("vaccination.custom.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + customVaccineId: id, + // The name is the person's own wording: a rename is named in + // `fields`, never quoted. + ...overwriteDetails({ + before: { + components: existing.components.join(","), + typicalSeriesDoses: existing.typicalSeriesDoses, + boosterIntervalMonths: existing.boosterIntervalMonths, + }, + after: { + components: outcome.components.join(","), + typicalSeriesDoses: outcome.typicalSeriesDoses, + boosterIntervalMonths: outcome.boosterIntervalMonths, + }, + redacted: outcome.name !== existing.name ? ["name"] : [], + }), + }, + }); + + annotate({ + action: { + name: "vaccination.custom.update", + entity_type: "custom-vaccine", + entity_id: id, + }, + }); + + return apiSuccess(toCustomVaccineDTO(outcome)); + }, +); + +export const DELETE = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireRecordAuth("manage", "profile"); + + const { id } = await params; + const existing = await prisma.customVaccine.findUnique({ + where: { id }, + select: { id: true, userId: true, name: true, deletedAt: true }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Vaccine not found", 404, { + errorCode: "vaccination.custom.not-found", + }); + } + + // Already removed: removing twice is a no-op that still succeeds. + let dosesUnlinked = 0; + if (existing.deletedAt === null) { + dosesUnlinked = await prisma.$transaction((tx) => + removeCustomVaccine(tx, user.id, existing), + ); + } + + await auditLog("vaccination.custom.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { customVaccineId: id, dosesUnlinked }, + }); + + annotate({ + action: { + name: "vaccination.custom.delete", + entity_type: "custom-vaccine", + entity_id: id, + }, + meta: { doses_unlinked: dosesUnlinked }, + }); + + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/vaccinations/custom/route.ts b/src/app/api/vaccinations/custom/route.ts new file mode 100644 index 000000000..dbb15e488 --- /dev/null +++ b/src/app/api/vaccinations/custom/route.ts @@ -0,0 +1,150 @@ +/** + * `GET /api/vaccinations/custom` — the record's own vaccine definitions. + * `POST /api/vaccinations/custom` — add one (v1.42, #1005). + * + * A definition is a product the shipped catalogue does not list, described in + * the shape a catalogue entry resolves to: a name, the antigens it protects + * against (from the catalogue's closed antigen list), a typical series length + * and a booster interval. A dose logged against it counts into a series, + * clears a booster and offers one exactly the way a catalogue pick does + * (`src/lib/vaccinations/resolve-vaccine-entry.ts`). + * + * Record-scoped like the dose log and classified `profile` with it: a + * definition is part of the immunization history a delegate granted `profile` + * already sees, names no credential, no integration and no notification + * channel. The list is READ, the create WRITE, the same split as the doses. + * + * `userId` is narrowed from auth and fed to every `where`; it is never a body + * field. The `data` object is built field-by-field. + */ +import { NextRequest } from "next/server"; + +import { prisma } from "@/lib/db"; +import { isP2002 } from "@/lib/prisma-errors"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; +import { customVaccineCreateSchema } from "@/lib/validations/vaccinations"; +import { toCustomVaccineDTO } from "@/lib/vaccinations/dto"; +import { + CUSTOM_VACCINE_SELECT, + findLiveNameClash, +} from "@/lib/vaccinations/custom-vaccines"; + +export const GET = apiHandler(async () => { + const { user } = await requireRecordAuth("read", "profile"); + + const rows = await prisma.customVaccine.findMany({ + where: { userId: user.id, deletedAt: null }, + orderBy: [{ name: "asc" }, { id: "asc" }], + select: CUSTOM_VACCINE_SELECT, + }); + + annotate({ + action: { name: "vaccination.custom.list", entity_type: "custom-vaccine" }, + meta: { count: rows.length }, + }); + + return apiSuccess(rows.map(toCustomVaccineDTO)); +}); + +export const POST = apiHandler(async (request: NextRequest) => { + const { user, actor } = await requireRecordAuth("write", "profile"); + + // The shared per-account write ceiling, keyed on the ACTOR so a delegate + // spends their own allowance (`checkRecordWriteRateLimit`). + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429, { + errorCode: "record_write.rate_limited", + }); + } + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 8 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = customVaccineCreateSchema.safeParse(rawBody); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422, { + errorCode: "vaccination.custom.invalid", + }); + } + const entry = parsed.data; + + // The lookup below and the unique index answer the same question; two + // requests racing with the same name both pass the lookup, and the + // loser meets the index. That is the same answer, so it gets the same + // 409 rather than a 500. + const outcome = await prisma + .$transaction(async (tx) => { + if (await findLiveNameClash(tx, user.id, entry.name)) { + return "name-taken" as const; + } + // Names are unique per record, removed definitions included, because the + // index is. A name the person removed and now adds again brings that row + // back with the new values rather than failing on the index: deleting + // unlinked every dose, so nothing re-attaches on the way back. + const removed = await tx.customVaccine.findFirst({ + where: { userId: user.id, name: entry.name, deletedAt: { not: null } }, + select: { id: true }, + }); + const data = { + name: entry.name, + components: entry.components, + typicalSeriesDoses: entry.typicalSeriesDoses ?? null, + boosterIntervalMonths: entry.boosterIntervalMonths ?? null, + }; + if (removed) { + return tx.customVaccine.update({ + where: { id: removed.id }, + data: { ...data, deletedAt: null }, + select: CUSTOM_VACCINE_SELECT, + }); + } + return tx.customVaccine.create({ + data: { userId: user.id, ...data }, + select: CUSTOM_VACCINE_SELECT, + }); + }) + .catch((err: unknown) => { + if (isP2002(err)) return "name-taken" as const; + throw err; + }); + + if (outcome === "name-taken") { + return apiError("A vaccine with this name already exists", 409, { + errorCode: "vaccination.custom.name-taken", + }); + } + + await auditLog("vaccination.custom.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + customVaccineId: outcome.id, + components: outcome.components, + boosterIntervalMonths: outcome.boosterIntervalMonths, + }, + }); + + annotate({ + action: { + name: "vaccination.custom.create", + entity_type: "custom-vaccine", + entity_id: outcome.id, + }, + meta: { components: outcome.components.length }, + }); + + return apiSuccess(toCustomVaccineDTO(outcome), 201); +}); diff --git a/src/app/api/vaccinations/route.ts b/src/app/api/vaccinations/route.ts index beeb81826..f7cda6128 100644 --- a/src/app/api/vaccinations/route.ts +++ b/src/app/api/vaccinations/route.ts @@ -43,6 +43,7 @@ import { import { VACCINATION_INCLUDE, applyVaccinationLinks, + resolveOwnedCustomVaccine, resolveOwnedEncounter, resolveOwnedPractitioner, resolveOwnerTimezone, @@ -194,6 +195,14 @@ async function postVaccination(request: NextRequest): Promise { const owned = await resolveOwnedEncounter(tx, user.id, entry.encounterId); if (!owned) return "unknown-encounter" as const; } + if (entry.customVaccineId) { + const owned = await resolveOwnedCustomVaccine( + tx, + user.id, + entry.customVaccineId, + ); + if (!owned) return "unknown-custom-vaccine" as const; + } // Field-by-field — never spread the parsed object whole. const row = await tx.vaccinationRecord.create({ @@ -208,6 +217,7 @@ async function postVaccination(request: NextRequest): Promise { site: entry.site ?? null, practitionerId: entry.practitionerId ?? null, encounterId: entry.encounterId ?? null, + customVaccineId: entry.customVaccineId ?? null, noteEncrypted: entry.note ? encryptToBytes(entry.note) : null, }, select: { id: true }, @@ -223,7 +233,10 @@ async function postVaccination(request: NextRequest): Promise { const boosters = await satisfyBoostersForDose( tx, user.id, - entry.antigenSlug ?? null, + { + antigenSlug: entry.antigenSlug ?? null, + customVaccineId: entry.customVaccineId ?? null, + }, occurredAt, timezone, ); @@ -251,6 +264,11 @@ async function postVaccination(request: NextRequest): Promise { errorCode: "vaccination.encounter-not-found", }); } + if (created === "unknown-custom-vaccine") { + return apiError("Vaccine not found", 404, { + errorCode: "vaccination.custom-vaccine-not-found", + }); + } const series = await resolveSeriesFor(prisma, user.id); @@ -261,6 +279,7 @@ async function postVaccination(request: NextRequest): Promise { vaccinationId: created.id, occurredAt: created.occurredAt.toISOString(), antigenSlug: created.antigenSlug, + customVaccineId: created.customVaccineId, // The note is deliberately absent: it is encrypted PHI and the audit // table is not a second copy of the record. }, @@ -273,7 +292,9 @@ async function postVaccination(request: NextRequest): Promise { entity_id: created.id, }, meta: { - antigen_slug: created.antigenSlug ?? "free-text", + antigen_slug: + created.antigenSlug ?? + (created.customVaccineId ? "custom" : "free-text"), linked_documents: entry.documentIds?.length ?? 0, satisfied_boosters: satisfiedReminderIds.length, }, @@ -285,7 +306,11 @@ async function postVaccination(request: NextRequest): Promise { entity_type: "measurement-reminder", entity_id: reminderId, }, - meta: { antigen_slug: created.antigenSlug ?? "free-text" }, + meta: { + antigen_slug: + created.antigenSlug ?? + (created.customVaccineId ? "custom" : "free-text"), + }, }); } diff --git a/src/app/api/version/__tests__/route.test.ts b/src/app/api/version/__tests__/route.test.ts index ec988a145..4803e2ab5 100644 --- a/src/app/api/version/__tests__/route.test.ts +++ b/src/app/api/version/__tests__/route.test.ts @@ -106,4 +106,23 @@ describe("GET /api/version", () => { const body = (await response.json()) as VersionEnvelope; expect(body.data.geoProviderHost).toBe("ip-api.com"); }); + + it("keeps the finer geo state off the public answer", async () => { + // Lookup mode and whether the operator chose the provider are admin-only + // (`/api/admin/status`); the public route would only add a way to tell + // instances apart. + const response = await (GET as unknown as () => Promise)(); + const body = (await response.json()) as { data: Record }; + expect(Object.keys(body.data).sort()).toEqual([ + "buildSha", + "builtAt", + "changelog", + "docs", + "geoProviderHost", + "license", + "offlineGeoEnabled", + "repository", + "version", + ]); + }); }); diff --git a/src/app/api/version/route.ts b/src/app/api/version/route.ts index 01177527e..9c0435b8e 100644 --- a/src/app/api/version/route.ts +++ b/src/app/api/version/route.ts @@ -36,7 +36,10 @@ export const dynamic = "force-dynamic"; * `geoProviderHost` (the host of `IP_GEO_LOOKUP_URL`, default * `ipwho.is`) and the resolver emits a one-shot admin notification on * first use. `geoProviderHost` lets the admin status surface name the - * real provider instead of assuming the default. + * real provider instead of assuming the default. Both were public before + * v1.42 and stay so for existing readers; the finer geo state (lookup mode, + * whether the operator chose the provider) is admin-only and lives on + * `/api/admin/status`, so it adds nothing to tell instances apart by. */ export const GET = apiHandler(async () => { // v1.4.43 B11 — prefer the build-arg-injected env var so the runtime diff --git a/src/app/api/withings/webhook/route.ts b/src/app/api/withings/webhook/route.ts index a5b2ea7ac..ecda2c96e 100644 --- a/src/app/api/withings/webhook/route.ts +++ b/src/app/api/withings/webhook/route.ts @@ -66,7 +66,7 @@ async function checkAndWarn( } if (auth.via === "query") { getEvent()?.addWarning( - `withings webhook secret received via legacy URL query — migrate to ${MIGRATION_URL} (re-subscribe with the path-segment token form)`, + `withings webhook secret received via legacy URL query. Migrate to ${MIGRATION_URL} (re-subscribe with the path-segment token form)`, ); } return null; diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index a5ff667fa..033cebee8 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -335,6 +335,12 @@ export const GET = apiHandler( avgHr: effectiveAvgHeartRate, maxHr: effectiveMaxHeartRate, minHr: row.minHeartRate, + // The row's own heart rate. `avgHr` / `maxHr` above may be filled in + // from a twin by the canonical picker, which is right for display and + // wrong for an edit: re-posting them would store the twin's values on + // the hand-entered row (#1162). + storedAvgHr: row.avgHeartRate, + storedMaxHr: row.maxHeartRate, stepCount: row.stepCount, elevationM: row.elevationM, pauseDurationSec: row.pauseDurationSec, diff --git a/src/app/auth/claim/page.tsx b/src/app/auth/claim/page.tsx new file mode 100644 index 000000000..217302b9c --- /dev/null +++ b/src/app/auth/claim/page.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; + +import { ClaimProfile } from "@/components/auth/claim-profile"; + +/** + * v1.42 (#959) — `/auth/claim?token=hlp_…`, where the link a Guardian handed + * over (`/claim/`) lands after the edge redirect. + * + * A URL-credential surface by shape (`session-surface-guard.test.ts`, S2): the + * page reads the one-time token off the query string and passes it to the + * form, which sends it only in the BODY of the anonymous preview and claim + * requests. Nothing here resolves the token; those two routes do, behind one + * uniform 404 and a per-address rate limit. + */ +export default function ClaimProfilePage() { + const searchParams = useSearchParams(); + return ; +} diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx index 8b1bc9c34..e8cb2498f 100644 --- a/src/app/auth/register/page.tsx +++ b/src/app/auth/register/page.tsx @@ -175,14 +175,11 @@ export default function RegisterPage() {
-
-

- {t("auth.createAccount")} -

-

- {t("auth.newAccount")} -

-
+ {/* The title says it; a subtitle repeating "new account" said it + twice. */} +

+ {t("auth.createAccount")} +

{inviteToken && ( diff --git a/src/app/c/[token]/not-found.tsx b/src/app/c/[token]/not-found.tsx new file mode 100644 index 000000000..9aa8e7138 --- /dev/null +++ b/src/app/c/[token]/not-found.tsx @@ -0,0 +1,40 @@ +import { LinkIcon } from "lucide-react"; + +import { resolveShareViewLocale } from "@/lib/clinician-share/request-locale"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; + +/** + * What a practice sees when a shared link no longer opens: expired, revoked, + * mistyped. The page still answers 404 for every failure class alike (the + * share page's `notFound()`), so a probe learns nothing from it; what changes + * is the copy. The app's own 404 said "this page does not exist" and offered + * a dashboard the reader has no account for. This one says what happened in + * the reader's terms and what to do, and links nowhere. + */ +export default async function ClinicianShareNotFound() { + const locale = await resolveShareViewLocale(); + const { t } = getServerTranslator(locale); + + return ( +
+ +
+

+ {t("clinicianView.unavailable.title")} +

+

+ {t("clinicianView.unavailable.description")} +

+
+
+ ); +} diff --git a/src/app/claim/[token]/page.tsx b/src/app/claim/[token]/page.tsx new file mode 100644 index 000000000..a1fa4fff6 --- /dev/null +++ b/src/app/claim/[token]/page.tsx @@ -0,0 +1,34 @@ +import { redirect } from "next/navigation"; + +import { looksLikeHandoverToken } from "@/lib/auth/handover-token"; + +/** + * v1.42 (#959) — the managed-profile handover link landing. + * + * `https:///claim/` is the link a Guardian hands over. The + * proxy answers it at the edge with a 307 onto `/auth/claim?token=…` + * (`src/proxy.ts`), so in practice this page never renders; it stays as the + * fallback for any path that bypasses the proxy matcher, exactly like + * `src/app/invite/[token]/page.tsx`. + * + * Security, the invite landing's posture: + * - the `hlp_` shape is validated before the token is reflected into the + * redirect target, so a malformed segment lands on the bare claim page + * and the page never echoes attacker-controlled text; + * - no database read: a well-formed unknown token and a valid one redirect + * identically. Whether a token is real is only decided by the anonymous + * preview and claim routes, behind their uniform 404 and rate limit; + * - the token is never logged here (and `/claim/` is a registered secret + * path in `src/lib/logging/redact.ts`). + */ +export default async function ClaimLandingPage({ + params, +}: { + params: Promise<{ token: string }>; +}) { + const { token } = await params; + if (looksLikeHandoverToken(token)) { + redirect(`/auth/claim?token=${encodeURIComponent(token)}`); + } + redirect("/auth/claim"); +} diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index d42ffa50f..fdadea8ad 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -17,7 +17,6 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorCard } from "@/components/ui/query-error-card"; -import { COACH_SCROLLBAR } from "@/components/insights/coach-panel/message-thread"; import { ConversationRename } from "@/components/insights/coach-panel/conversation-rename"; import { ConversationTranscript } from "@/components/insights/coach-panel/conversation-transcript"; import { @@ -170,7 +169,6 @@ function CoachConversationsBody({ readOnly }: { readOnly: boolean }) { data-slot="coach-conversations-list" className={cn( "-mx-1 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-1", - COACH_SCROLLBAR, )} > {isLoading && visible.length === 0 ? ( @@ -219,7 +217,9 @@ function CoachConversationsBody({ readOnly }: { readOnly: boolean }) { className={cn( "group relative flex flex-wrap items-center gap-1.5 rounded-xl px-3 py-2.5", "text-sm transition-colors", - "text-muted-foreground hover:bg-muted/40 hover:text-foreground", + // The title is the reader's own content: foreground, + // with the timestamp below as the muted meta line. + "text-foreground hover:bg-muted/40", )} > } - coachPrefill="I haven't recorded any blood pressure yet — why does it matter, and what should I know before I start?" + coachPrefill="I haven't recorded any blood pressure yet. Why does it matter, and what should I know before I start?" /> ); @@ -98,10 +101,10 @@ export default function InsightsBlutdruckPage() { { min: bpTargets.diastolic.min, max: bpTargets.diastolic.max, - color: "var(--info)", + color: "var(--chart-4)", opacity: 0.21, label: t("charts.diastolic"), - textColor: "var(--info)", + textColor: "var(--chart-4)", lineOpacity: 0.24, }, ] @@ -128,6 +131,7 @@ export default function InsightsBlutdruckPage() { // brushing the single chart reports per-type windowed stats and each // column reads its own half. @@ -166,7 +172,7 @@ export default function InsightsBlutdruckPage() { types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]} title={t("charts.bloodPressure")} titleIcon={HeartPulse} - colors={["var(--chart-3)", "var(--info)"]} + colors={["var(--chart-3)", "var(--chart-4)"]} unit="mmHg" yAxisUnit="mmHg" targetZones={bpTargetZones} @@ -174,6 +180,7 @@ export default function InsightsBlutdruckPage() { userTimezone={user?.timezone} onVisibleStats={onVisibleStats} showDataTable + dayLinks /> {/* v1.12.4 — target card sits between the chart and the assessment on diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx index 8f0fbc906..5ec84f3fa 100644 --- a/src/app/insights/bmi/page.tsx +++ b/src/app/insights/bmi/page.tsx @@ -69,7 +69,7 @@ export default function InsightsBmiPage() { } - coachPrefill="I haven't recorded any weight yet — why does BMI matter for me, and what should I know before I start tracking it?" + coachPrefill="I haven't recorded any weight yet. Why does BMI matter for me, and what should I know before I start tracking it?" /> ); @@ -86,7 +86,6 @@ export default function InsightsBmiPage() { icon={} title={t("insights.bmiEmptyTitle")} description={t("insights.bmiEmptyDescription")} - ctaSize="lg" action={ } /> - + ); } @@ -117,13 +116,14 @@ export default function InsightsBmiPage() { chartKey="bmi" types={["WEIGHT"]} title={t("targets.bmi")} - colors={["var(--dracula-yellow)"]} + colors={["var(--chart-5)"]} unit="kg/m²" valueMode="bmi" valueBands={BMI_BANDS} compareBaseline={compareBaseline} userTimezone={user?.timezone} showDataTable + dayLinks /> diff --git a/src/app/insights/body-fat/page.tsx b/src/app/insights/body-fat/page.tsx index 1b8c3eab6..2d51b0478 100644 --- a/src/app/insights/body-fat/page.tsx +++ b/src/app/insights/body-fat/page.tsx @@ -21,7 +21,7 @@ export default function InsightsBodyFatPage() { chartKey="bodyFat" i18nPrefix="insights.bodyFat" explainerMetric="bodyFat" - color="var(--warning)" + color="var(--chart-5)" unit="%" statIcon={Percent} emptyStateIcon={} diff --git a/src/app/insights/body-temperature/page.tsx b/src/app/insights/body-temperature/page.tsx index 7c4527b5f..9c2ed241d 100644 --- a/src/app/insights/body-temperature/page.tsx +++ b/src/app/insights/body-temperature/page.tsx @@ -24,7 +24,7 @@ export default function InsightsBodyTemperaturePage() { color="var(--chart-3)" emptyStateIcon={} emptyStateCtaType="BODY_TEMPERATURE" - coachPrefill="I haven't logged any body temperature yet — what's the healthy range, and what should I do if it drifts?" + coachPrefill="I haven't logged any body temperature yet. What's the healthy range, and what should I do if it drifts?" /> ); } diff --git a/src/app/insights/body-water/page.tsx b/src/app/insights/body-water/page.tsx index 006cb963b..e5483e26c 100644 --- a/src/app/insights/body-water/page.tsx +++ b/src/app/insights/body-water/page.tsx @@ -21,11 +21,11 @@ export default function InsightsKoerperwasserPage() { chartKey="totalBodyWater" i18nPrefix="insights.totalBodyWater" explainerMetric="bodyWater" - color="var(--info)" + color="var(--chart-4)" emptyStateIcon={} emptyStateCtaType={null} captureType="TOTAL_BODY_WATER" - coachPrefill="I haven't logged any total body water yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any total body water yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/bone-mass/page.tsx b/src/app/insights/bone-mass/page.tsx index d5235de3d..acecafe5e 100644 --- a/src/app/insights/bone-mass/page.tsx +++ b/src/app/insights/bone-mass/page.tsx @@ -21,11 +21,11 @@ export default function InsightsKnochenmassePage() { chartKey="boneMass" i18nPrefix="insights.boneMass" explainerMetric="boneMass" - color="var(--warning)" + color="var(--chart-5)" emptyStateIcon={} emptyStateCtaType={null} captureType="BONE_MASS" - coachPrefill="I haven't logged any bone mass yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any bone mass yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/breathing-disturbances/page.tsx b/src/app/insights/breathing-disturbances/page.tsx index c9d62cb30..52d092204 100644 --- a/src/app/insights/breathing-disturbances/page.tsx +++ b/src/app/insights/breathing-disturbances/page.tsx @@ -20,11 +20,11 @@ export default function InsightsBreathingDisturbancesPage() { chartKey="breathingDisturbances" i18nPrefix="insights.breathingDisturbances" explainerMetric="breathingDisturbances" - color="var(--info)" + color="var(--chart-4)" unitKey="insights.units.breathingEvents" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any sleep-breathing-disturbance data yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any sleep-breathing-disturbance data yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/cardio-fitness/page.tsx b/src/app/insights/cardio-fitness/page.tsx index 6de5d9b29..8ea2756fc 100644 --- a/src/app/insights/cardio-fitness/page.tsx +++ b/src/app/insights/cardio-fitness/page.tsx @@ -27,7 +27,7 @@ export default function InsightsCardioFitnessPage() { statIcon={Gauge} emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any cardio-fitness (VO₂ max) data yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any cardio-fitness (VO₂ max) data yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/cardio-recovery/page.tsx b/src/app/insights/cardio-recovery/page.tsx index 16cc1fdf0..f54fdf5e1 100644 --- a/src/app/insights/cardio-recovery/page.tsx +++ b/src/app/insights/cardio-recovery/page.tsx @@ -26,7 +26,7 @@ export default function InsightsCardioRecoveryPage() { yAxisUnit="bpm" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any cardio-recovery data yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any cardio-recovery data yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/catalog/page.tsx b/src/app/insights/catalog/page.tsx index 5f050dee2..db106235c 100644 --- a/src/app/insights/catalog/page.tsx +++ b/src/app/insights/catalog/page.tsx @@ -122,8 +122,10 @@ function CatalogRowSkeleton() { data-slot="catalog-row-skeleton" className="flex items-start justify-between gap-3 py-2" > -
- + {/* Sized to the loaded row (a 22 px name-and-badge line over a 16 px + source line), so the groups below do not move when it lands. */} +
+
diff --git a/src/app/insights/daylight/page.tsx b/src/app/insights/daylight/page.tsx index 5cecb4509..f6b4d023f 100644 --- a/src/app/insights/daylight/page.tsx +++ b/src/app/insights/daylight/page.tsx @@ -21,12 +21,12 @@ export default function InsightsTageslichtPage() { chartKey="timeInDaylight" i18nPrefix="insights.timeInDaylight" explainerMetric="daylight" - color="var(--success)" + color="var(--chart-2)" unit="min" yAxisUnit="min" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any time spent in daylight yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any time spent in daylight yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/double-support-time/page.tsx b/src/app/insights/double-support-time/page.tsx index 9b7112851..021ccb94a 100644 --- a/src/app/insights/double-support-time/page.tsx +++ b/src/app/insights/double-support-time/page.tsx @@ -21,12 +21,12 @@ export default function InsightsDoppelstandphasePage() { chartKey="walkingDoubleSupport" i18nPrefix="insights.walkingDoubleSupport" explainerMetric="doubleSupportTime" - color="var(--success)" + color="var(--chart-2)" unit="%" yAxisUnit="%" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any double support time yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any double support time yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/environmental-audio/page.tsx b/src/app/insights/environmental-audio/page.tsx index 1259a1536..42e9e41d9 100644 --- a/src/app/insights/environmental-audio/page.tsx +++ b/src/app/insights/environmental-audio/page.tsx @@ -26,7 +26,7 @@ export default function InsightsLaermbelastungPage() { yAxisUnit="dBA" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any environmental sound exposure yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any environmental sound exposure yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/falls/page.tsx b/src/app/insights/falls/page.tsx index 157383c52..6144b740a 100644 --- a/src/app/insights/falls/page.tsx +++ b/src/app/insights/falls/page.tsx @@ -19,11 +19,11 @@ export default function InsightsFallsPage() { chartKey="fallCount" i18nPrefix="insights.falls" explainerMetric="falls" - color="var(--destructive)" + color="var(--chart-3)" unitKey="insights.units.falls" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any fall data yet — what does this metric tell me about my health, and how do I lower my fall risk?" + coachPrefill="I haven't logged any fall data yet. What does this metric tell me about my health, and how do I lower my fall risk?" /> ); } diff --git a/src/app/insights/fat-free-mass/page.tsx b/src/app/insights/fat-free-mass/page.tsx index 6daf8006e..c51c7f995 100644 --- a/src/app/insights/fat-free-mass/page.tsx +++ b/src/app/insights/fat-free-mass/page.tsx @@ -24,7 +24,7 @@ export default function InsightsFettfreieMassePage() { color="var(--chart-1)" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any fat-free mass yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any fat-free mass yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/fat-mass/page.tsx b/src/app/insights/fat-mass/page.tsx index 1ae453b10..835faa73d 100644 --- a/src/app/insights/fat-mass/page.tsx +++ b/src/app/insights/fat-mass/page.tsx @@ -21,10 +21,10 @@ export default function InsightsFettmassePage() { chartKey="fatMass" i18nPrefix="insights.fatMass" explainerMetric="fatMass" - color="var(--warning)" + color="var(--chart-5)" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any fat mass yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any fat mass yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/flights-climbed/page.tsx b/src/app/insights/flights-climbed/page.tsx index c76a259f0..9aa247148 100644 --- a/src/app/insights/flights-climbed/page.tsx +++ b/src/app/insights/flights-climbed/page.tsx @@ -21,11 +21,11 @@ export default function InsightsStockwerkePage() { chartKey="flightsClimbed" i18nPrefix="insights.flightsClimbed" explainerMetric="flightsClimbed" - color="var(--success)" + color="var(--chart-2)" unitKey="insights.units.flights" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any flights climbed yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any flights climbed yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/grip-strength/page.tsx b/src/app/insights/grip-strength/page.tsx index 52750739b..824482e3d 100644 --- a/src/app/insights/grip-strength/page.tsx +++ b/src/app/insights/grip-strength/page.tsx @@ -22,7 +22,7 @@ export default function InsightsGripStrengthPage() { insightMetric="GRIP_STRENGTH" chartKey="gripStrength" i18nPrefix="insights.gripStrength" - color="var(--success)" + color="var(--chart-2)" statIcon={Dumbbell} emptyStateIcon={} emptyStateCtaType="GRIP_STRENGTH" diff --git a/src/app/insights/headphone-audio/page.tsx b/src/app/insights/headphone-audio/page.tsx index ac0e7c2ed..fafcaabbb 100644 --- a/src/app/insights/headphone-audio/page.tsx +++ b/src/app/insights/headphone-audio/page.tsx @@ -26,7 +26,7 @@ export default function InsightsKopfhoererpegelPage() { yAxisUnit="dBA" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any headphone audio exposure yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any headphone audio exposure yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/health-score/__tests__/page.test.tsx b/src/app/insights/health-score/__tests__/page.test.tsx new file mode 100644 index 000000000..ae7b58fef --- /dev/null +++ b/src/app/insights/health-score/__tests__/page.test.tsx @@ -0,0 +1,90 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { DAY_SCORE_HREF } from "@/lib/day/contract"; + +/** + * v1.42 — `/insights/health-score`: the health score over time, reached from + * the score panel and from the day view's score tile. + */ + +vi.mock("@/lib/i18n/context", () => ({ + useTranslations: () => ({ t: (key: string) => key }), +})); + +vi.mock("@/components/insights/sub-page-shell", () => ({ + SubPageShell: ({ + title, + backLink, + children, + }: { + title: string; + backLink: React.ReactNode; + children: React.ReactNode; + }) => ( +
+ {backLink} + {children} +
+ ), +})); + +vi.mock("@/components/ui/back-link", () => ({ + BackLink: ({ href }: { href: string }) => ( + + ), +})); + +vi.mock("@/components/insights/derived/score-history", () => ({ + ScoreTrendChartDynamic: ({ + score, + chartKey, + label, + }: { + score: string; + chartKey: string; + label: string; + }) => ( +
+ ), +})); + +import HealthScoreHistoryPage from "../page"; + +describe("/insights/health-score", () => { + it("charts the health score's history under its own range slot", () => { + const html = renderToStaticMarkup(); + expect(html).toContain('data-title="insights.scoreHistory.healthTitle"'); + expect(html).toContain( + 'data-slot="trend-probe" data-score="HEALTH_SCORE" data-chart-key="scoreHealth" data-label="insights.healthScore.label"', + ); + expect(html).toContain('href="/insights"'); + }); + + it("is where the day view's health-score tile opens", () => { + expect(DAY_SCORE_HREF.healthScore).toBe("/insights/health-score"); + }); + + it("every score tile in the day view opens a page that exists", () => { + const app = join(__dirname, "..", "..", ".."); + for (const href of Object.values(DAY_SCORE_HREF)) { + const segments = href.split("/").filter(Boolean); + const direct = join(app, ...segments, "page.tsx"); + const dynamic = join( + app, + ...segments.slice(0, -1), + "[metric]", + "page.tsx", + ); + expect(existsSync(direct) || existsSync(dynamic), href).toBe(true); + } + }); +}); diff --git a/src/app/insights/health-score/page.tsx b/src/app/insights/health-score/page.tsx new file mode 100644 index 000000000..a67eaf21e --- /dev/null +++ b/src/app/insights/health-score/page.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useTranslations } from "@/lib/i18n/context"; +import { BackLink } from "@/components/ui/back-link"; +import { SubPageShell } from "@/components/insights/sub-page-shell"; +import { ScoreTrendChartDynamic } from "@/components/insights/derived/score-history"; + +/** + * v1.42 — `/insights/health-score`, the health score over time. + * + * The score itself, with its pillars, lives in the Insights overview's hero + * band; this page is where it can be followed day by day. Each point is the + * score as it was shown that day (its stored day row), and opens that day. + * Reached from the score panel's history link and from the score tile in the + * day view. + */ +export default function HealthScoreHistoryPage() { + const { t } = useTranslations(); + const title = t("insights.healthScore.label"); + + return ( + + } + > + + + ); +} diff --git a/src/app/insights/hrv/page.tsx b/src/app/insights/hrv/page.tsx index fca82fbe7..f46fce113 100644 --- a/src/app/insights/hrv/page.tsx +++ b/src/app/insights/hrv/page.tsx @@ -15,7 +15,9 @@ import { EcgCrossLink } from "@/components/insights/ecg-cross-link"; * SDNN as millisecond `HEART_RATE_VARIABILITY` rows; Oura / Polar / WHOOP * store nightly RMSSD as `HRV_RMSSD`. The page renders SDNN when present * and falls back to the RMSSD series (labelled "RMSSD") otherwise, so a - * ring / strap user's stored HRV always surfaces. + * ring / strap user's stored HRV always surfaces. Since iOS 27 an Apple + * Watch writes both (#1110); with rows of both the page charts each on its + * own, labelled, because the two are not one measure. */ export default function InsightsHrvPage() { return ( @@ -23,6 +25,8 @@ export default function InsightsHrvPage() { measurementType="HEART_RATE_VARIABILITY" fallbackMeasurementType="HRV_RMSSD" fallbackMeasureLabel="RMSSD" + primaryMeasureLabel="SDNN" + fallbackColor="var(--chart-2)" statusMetric="HEART_RATE_VARIABILITY" insightMetric="HEART_RATE_VARIABILITY" chartKey="hrv" @@ -33,7 +37,7 @@ export default function InsightsHrvPage() { yAxisUnit="ms" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any HRV data yet — what does heart-rate variability tell me, and how do I capture it?" + coachPrefill="I haven't logged any HRV data yet. What does heart-rate variability tell me, and how do I capture it?" // S10 / H1 — device-attributed pointer into the ECG viewer, in the // HRV / heart context. Self-gates to nothing without recordings. afterAssessment={} diff --git a/src/app/insights/lean-body-mass/page.tsx b/src/app/insights/lean-body-mass/page.tsx index e31113b3b..ac41b155f 100644 --- a/src/app/insights/lean-body-mass/page.tsx +++ b/src/app/insights/lean-body-mass/page.tsx @@ -24,7 +24,7 @@ export default function InsightsMagermassePage() { color="var(--chart-1)" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any lean body mass yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any lean body mass yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index 99aa76753..8a7327dee 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -199,7 +199,6 @@ export default function InsightsMedikamentePage() { icon={} title={t("insights.emptyState.medication.title")} description={t("insights.emptyState.medication.description")} - ctaSize="lg" action={ } /> - + ); } diff --git a/src/app/insights/mood/page-client.tsx b/src/app/insights/mood/page-client.tsx new file mode 100644 index 000000000..83defd6e6 --- /dev/null +++ b/src/app/insights/mood/page-client.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import dynamic from "next/dynamic"; +import Link from "next/link"; +import { useCallback, useState } from "react"; +import { Smile } from "lucide-react"; + +import { useAuth } from "@/hooks/use-auth"; +import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { queryKeys } from "@/lib/query-keys"; +import { useTranslations } from "@/lib/i18n/context"; +import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; +import { Button } from "@/components/ui/button"; +import { ChartSkeleton } from "@/components/charts/chart-skeleton"; +import { MetricEmptyState } from "@/components/insights/metric-empty-state"; +import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; +import { + MoodInsightsSections, + useMoodInsights, +} from "@/components/insights/mood/mood-insights-sections"; +import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card"; +import { + SubPageAddButton, + SubPageShell, +} from "@/components/insights/sub-page-shell"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; +import { apiGet } from "@/lib/api/api-fetch"; + +/** + * The mood form, loaded only when the header plus opens it: the route's first + * paint is the calendar and the chart, and the form's tag catalogue is heavy. + */ +const MoodForm = dynamic( + () => + import("@/components/mood/mood-form").then((mod) => ({ + default: mod.MoodForm, + })), + { ssr: false, loading: () => null }, +); + +/** + * v1.4.25 W4 — `/insights/mood`. + * + * Routed Mood sub-page. Unlike the mother page (which hid the mood + * section entirely when no mood data existed), a dedicated sub-page + * cannot just blank itself — we'd land on an empty white page. Instead, + * the sub-page surfaces a clear empty-state CTA into `/mood` so the + * user can log their first entry, matching the Apple Health "No data" + * convention (research §1.1). + */ +const MoodChart = dynamic( + () => + import("@/components/charts/chart-runtime").then((mod) => ({ + default: mod.MoodChart, + })), + { ssr: false, loading: () => }, +); + +/** + * The day's two readings. Deferred like every other block on this route that + * is not the first paint: it reads its own endpoint and renders below the + * calendar, so nothing about it belongs in the eager graph of a page whose + * first paint is a heatmap. This route sits close to its bundle ceiling and + * every addition to it goes through the same treatment. + */ +const MoodPrognosisCard = dynamic( + () => + import("@/components/insights/mood/mood-prognosis-card").then((mod) => ({ + default: mod.MoodPrognosisCard, + })), + { ssr: false, loading: () => null }, +); + +interface ComprehensiveMoodData { + moodSummary: { count: number } | null; +} + +export default function InsightsMoodPageClient() { + const { user, isAuthenticated } = useAuth(); + const { t } = useTranslations(); + const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated); + // A mood entry is a MANAGE create under the mind section, as on `/mood`. + const { canManageDomain } = useRecordCapabilities(); + const canAddMood = canManageDomain("mind") && user?.modules?.mood !== false; + const [addOpen, setAddOpen] = useState(false); + const [footerEl, setFooterEl] = useState(null); + + // v1.42 — the calendar and the line chart appear together. The chart says + // when its read has settled (`onDataReady`, the dashboard's reveal gate); + // the calendar's read is the shared mood-insights cell. Until both are in, + // each holds its final-height skeleton, so the reveal moves nothing. + const [chartSettled, setChartSettled] = useState(false); + const markChartSettled = useCallback(() => setChartSettled(true), []); + const insights = useMoodInsights(); + const topRevealed = chartSettled && !insights.isPending; + + // Reuse the mother-page comprehensive query — TanStack Query + // dedups so this is a free cache read for the common case. + const { data: comprehensive } = useQuery({ + queryKey: queryKeys.insightsComprehensive(), + queryFn: async () => { + return apiGet("/api/insights/comprehensive"); + }, + enabled: isAuthenticated, + }); + + const moodCount = comprehensive?.moodSummary?.count ?? 0; + + // v1.4.27 F17 — Mood is event-driven so the gate reads + // `hasMood = moodCount > 0`. CTA targets `/mood` (the dedicated + // mood-logging surface) — short-circuits the user to the quickest + // path to log their first entry. + // + // v1.4.28 R3d (BK-F-M1) — empty-state render delegates to the shared + // `` primitive. The mood data path stays on + // `/api/insights/comprehensive` because the `moodSummary.count` + // signal is event-driven, not sensor-aggregated. + if (isAuthenticated && comprehensive && moodCount === 0) { + return ( + + } + title={t("insights.emptyState.mood.title")} + description={t("insights.emptyState.mood.description")} + cta={ + + } + coachPrefill="I haven't logged any mood entries yet. Why does mood tracking matter, and how should I start?" + /> + + ); + } + + return ( + setAddOpen(true)} + slot="insights-mood-add" + /> + ) : null + } + > + {canAddMood ? ( + } + > + {addOpen ? ( + setAddOpen(false)} + onCancel={() => setAddOpen(false)} + footerSlot={footerEl} + /> + ) : null} + + ) : null} + + {/* v1.12.7 — operator spine: heading + summary (the shell above), then + the Stimmungskalender, then the line chart, then the Ziel card, then + the better-days Einschätzung, then the classification + breakdowns. + The heatmap and the assessment are lifted out of `MoodInsightsSections` + into their own regions so they land in this exact order; all three + regions share one `moodInsights` query (TanStack dedups the fetch). */} + + {/* v1.42 — the "log a mood entry" and "take a check-in" text links that + sat here are gone: logging is the header plus, the same control every + other Insights page carries, and the check-in has its own entry in + the navigation. */} + + + {/* No `` here: mood is event-driven, not a + MeasurementType series, so the period-over-period range read + (`/api/analytics/range`, keyed on a MeasurementType enum) has + nothing to aggregate. */} + {/* Held invisible under its own skeleton until the calendar above is + ready too; it keeps its place in the flow the whole time, so the + page below never moves. */} +
+ {!topRevealed ? ( + + ) : null} +
+ +
+
+ + + + {/* The day's own rating beside what the account's past days imply. It + sits high because the self-assessment is the leading value on this + page, and directly under the line chart because that is where a + reader has just seen the rating it leads with. */} + + + {/* The better-days Einschätzung sits directly under the Ziel card, + ahead of the classification tiles and breakdowns. */} + + + + + {/* v1.12.2 — the assessment is the LAST block on every bespoke + metric-detail page, matching the canonical spine the generic + scaffold (weight / bmi / pulse / blood-pressure) renders. The + reader sees the trend and the breakdown sections first, then the + narration of them at the foot. */} + } /> +
+ ); +} diff --git a/src/app/insights/mood/page.tsx b/src/app/insights/mood/page.tsx index 50886b099..b4b544dde 100644 --- a/src/app/insights/mood/page.tsx +++ b/src/app/insights/mood/page.tsx @@ -1,175 +1,126 @@ -"use client"; +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from "@tanstack/react-query"; -import { useQuery } from "@tanstack/react-query"; -import dynamic from "next/dynamic"; -import Link from "next/link"; -import { ArrowRight, Smile } from "lucide-react"; - -import { useAuth } from "@/hooks/use-auth"; +import { getUnswitchedSession } from "@/lib/auth/acting-carrier"; +import { buildMoodDailySeries } from "@/lib/analytics/mood-series"; +import { + cached, + cachedSwr, + caches, + type ServerCache, +} from "@/lib/cache/server-cache"; +import { + fetchMoodAggregates, + moodInsightsWire, + type MoodAggregates, +} from "@/lib/insights/mood-aggregates"; +import type { MoodDailySeries } from "@/lib/analytics/mood-series"; +import { settleWithin } from "@/lib/async/settle-within"; +import { resolveModuleMap } from "@/lib/modules/gate"; import { queryKeys } from "@/lib/query-keys"; -import { useTranslations } from "@/lib/i18n/context"; -import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs"; -import { Button } from "@/components/ui/button"; -import { ChartSkeleton } from "@/components/charts/chart-skeleton"; -import { MetricEmptyState } from "@/components/insights/metric-empty-state"; -import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; -import { MoodInsightsSections } from "@/components/insights/mood/mood-insights-sections"; -import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card"; -import { SubPageShell } from "@/components/insights/sub-page-shell"; -import { apiGet } from "@/lib/api/api-fetch"; + +import InsightsMoodPageClient from "./page-client"; /** - * v1.4.25 W4 — `/insights/mood`. - * - * Routed Mood sub-page. Unlike the mother page (which hid the mood - * section entirely when no mood data existed), a dedicated sub-page - * cannot just blank itself — we'd land on an empty white page. Instead, - * the sub-page surfaces a clear empty-state CTA into `/mood` so the - * user can log their first entry, matching the Apple Health "No data" - * convention (research §1.1). + * How long the first HTML waits for the two reads. Both are cached per user + * (the aggregate stale-while-revalidate), so a warm visit answers in a few + * milliseconds; a cold aggregate can take longer than a reader should wait + * for a blank page, and then the client's own fetch takes over. */ -const MoodChart = dynamic( - () => - import("@/components/charts/chart-runtime").then((mod) => ({ - default: mod.MoodChart, - })), - { ssr: false, loading: () => }, -); +const PREFETCH_BUDGET_MS = 1_500; -/** - * The day's two readings. Deferred like every other block on this route that - * is not the first paint: it reads its own endpoint and renders below the - * calendar, so nothing about it belongs in the eager graph of a page whose - * first paint is a heatmap. This route sits close to its bundle ceiling and - * every addition to it goes through the same treatment. - */ -const MoodPrognosisCard = dynamic( - () => - import("@/components/insights/mood/mood-prognosis-card").then((mod) => ({ - default: mod.MoodPrognosisCard, - })), - { ssr: false, loading: () => null }, -); - -interface ComprehensiveMoodData { - moodSummary: { count: number } | null; +function withinBudget(work: Promise): Promise { + return settleWithin(work, PREFETCH_BUDGET_MS, null).catch(() => null); } -export default function InsightsStimmungPage() { - const { user, isAuthenticated } = useAuth(); - const { t } = useTranslations(); - const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated); - - // Reuse the mother-page comprehensive query — TanStack Query - // dedups so this is a free cache read for the common case. - const { data: comprehensive } = useQuery({ - queryKey: queryKeys.insightsComprehensive(), - queryFn: async () => { - return apiGet("/api/insights/comprehensive"); - }, - enabled: isAuthenticated, - }); - - const moodCount = comprehensive?.moodSummary?.count ?? 0; - - // v1.4.27 F17 — Mood is event-driven so the gate reads - // `hasMood = moodCount > 0`. CTA targets `/mood` (the dedicated - // mood-logging surface) — short-circuits the user to the quickest - // path to log their first entry. - // - // v1.4.28 R3d (BK-F-M1) — empty-state render delegates to the shared - // `` primitive. The mood data path stays on - // `/api/insights/comprehensive` because the `moodSummary.count` - // signal is event-driven, not sensor-aggregated. - if (isAuthenticated && comprehensive && moodCount === 0) { - return ( - - } - title={t("insights.emptyState.mood.title")} - description={t("insights.emptyState.mood.description")} - cta={ - - } - coachPrefill="I haven't logged any mood entries yet — why does mood tracking matter, and how should I start?" - /> - - ); +/** + * Thin RSC wrapper around the (client) mood insights page. + * + * The page's first paint is the mood calendar over the line chart. Both read + * their own endpoint after hydrate, and the calendar's aggregate is the + * slower of the two, so the chart used to paint first and the calendar pushed + * it down when it landed. This wrapper runs the SAME two reads the routes run + * (`moodInsightsWire` over the cached aggregate, `buildMoodDailySeries` over + * its cache) in parallel during SSR and hands both to TanStack, so the two + * appear together in the first client frame. The client page still gates the + * pair on each other, which is what keeps them together when this wrapper + * steps aside. + * + * Contract notes (the `/mood` wrapper's, unchanged): + * - the seeded keys are exactly the client's (`moodInsights()`, + * `moodAnalytics()`), and each value is JSON-round-tripped into the wire + * shape the client `queryFn` reads — never a Date-carrying sibling; + * - module-gate parity: nothing is prefetched when `modules.mood` is off; + * - record identity: `getUnswitchedSession()` answers null while the browser + * acts on somebody else's record, so a delegate's own mood never seeds a + * page opened on the owner's record; + * - fail-soft and time-boxed: no session, a lookup hiccup or a slow cold + * aggregate renders the page exactly as the client path would. + */ +export default async function InsightsMoodPage() { + // The e2e server turns every SSR prefetch off so its route mocks, which + // only see CLIENT fetches, keep governing what the page paints. + if (process.env.DASHBOARD_SSR_PREFETCH === "false") { + return ; } + let dehydratedState = null; + try { + const session = await getUnswitchedSession(); + if (session) { + const { user } = session; + const modules = await resolveModuleMap(user.id); + if (modules.mood !== false) { + const [aggregates, series] = await Promise.all([ + withinBudget( + cachedSwr( + caches.moodInsights as ServerCache, + user.id, + () => fetchMoodAggregates(user.id), + ), + ), + withinBudget( + cached( + caches.moodAnalytics as ServerCache, + user.id, + () => buildMoodDailySeries(user.id), + ), + ), + ]); + const queryClient = new QueryClient(); + if (aggregates) { + queryClient.setQueryData( + queryKeys.moodInsights(), + JSON.parse(JSON.stringify(moodInsightsWire(aggregates, 365))), + ); + } + if (series) { + queryClient.setQueryData( + queryKeys.moodAnalytics(), + JSON.parse( + JSON.stringify({ + entries: series.entries, + summary: series.summary, + }), + ), + ); + } + if (aggregates || series) dehydratedState = dehydrate(queryClient); + } + } + } catch { + // Prefetch is an accelerator, never a gate — the client path stands. + } + + if (dehydratedState === null) { + return ; + } return ( - - {/* v1.12.7 — operator spine: heading + summary (the shell above), then - the Stimmungskalender, then the line chart, then the Ziel card, then - the better-days Einschätzung, then the classification + breakdowns. - The heatmap and the assessment are lifted out of `MoodInsightsSections` - into their own regions so they land in this exact order; all three - regions share one `moodInsights` query (TanStack dedups the fetch). */} - - {/* 2026-07-17 UX/IA audit M9 — mood, the mental-wellbeing screeners, and - this insights page form one mental-health domain; this cross-links - into the other two so the trend a user reads here can lead - somewhere. */} -
- - {t("insights.mood.logLink")} -
- - - - {/* No `` here: mood is event-driven, not a - MeasurementType series, so the period-over-period range read - (`/api/analytics/range`, keyed on a MeasurementType enum) has - nothing to aggregate. */} - - - - - {/* The day's own rating beside what the account's past days imply. It - sits high because the self-assessment is the leading value on this - page, and directly under the line chart because that is where a - reader has just seen the rating it leads with. */} - - - {/* The better-days Einschätzung sits directly under the Ziel card, - ahead of the classification tiles and breakdowns. */} - - - - - {/* v1.12.2 — the assessment is the LAST block on every bespoke - metric-detail page, matching the canonical spine the generic - scaffold (weight / bmi / pulse / blood-pressure) renders. The - reader sees the trend and the breakdown sections first, then the - narration of them at the foot. */} - } /> -
+ + + ); } diff --git a/src/app/insights/muscle-mass/page.tsx b/src/app/insights/muscle-mass/page.tsx index a4dc3c786..42ce5fa8f 100644 --- a/src/app/insights/muscle-mass/page.tsx +++ b/src/app/insights/muscle-mass/page.tsx @@ -24,7 +24,7 @@ export default function InsightsMuskelmassePage() { color="var(--chart-1)" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any muscle mass yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any muscle mass yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/oxygen/page.tsx b/src/app/insights/oxygen/page.tsx index d752f2b2a..02ab4d397 100644 --- a/src/app/insights/oxygen/page.tsx +++ b/src/app/insights/oxygen/page.tsx @@ -23,7 +23,7 @@ export default function InsightsOxygenSaturationPage() { chartKey="oxygenSaturation" i18nPrefix="insights.oxygenSaturation" explainerMetric="oxygenSaturation" - color="var(--info)" + color="var(--chart-4)" unit="%" yAxisUnit="%" valueBands={[ @@ -37,7 +37,7 @@ export default function InsightsOxygenSaturationPage() { emptyStateIcon={} emptyStateCtaType={null} captureType="OXYGEN_SATURATION" - coachPrefill="I haven't logged any oxygen-saturation data yet — what does SpO₂ tell me, and what range should I aim for?" + coachPrefill="I haven't logged any oxygen-saturation data yet. What does SpO₂ tell me, and what range should I aim for?" /> ); } diff --git a/src/app/insights/page-client.tsx b/src/app/insights/page-client.tsx index 375088b05..5c3a146d2 100644 --- a/src/app/insights/page-client.tsx +++ b/src/app/insights/page-client.tsx @@ -408,7 +408,6 @@ export default function InsightsPageClient() { icon={} title={t("insights.emptyTitle")} description={t("insights.emptyDescription")} - ctaSize="lg" action={ } - coachPrefill="I haven't recorded any resting pulse yet — why does it matter, and what should I know before I start?" + coachPrefill="I haven't recorded any resting pulse yet. Why does it matter, and what should I know before I start?" /> ); @@ -123,11 +127,13 @@ export default function InsightsPulsPage() { explainerMetric="pulse" statStrip={ } coachReadStrip={ @@ -151,14 +157,15 @@ export default function InsightsPulsPage() { titleIcon={Heart} colors={ hasRestingHr - ? ["var(--success)", "var(--destructive)"] - : ["var(--success)"] + ? ["var(--chart-2)", "var(--chart-3)"] + : ["var(--chart-2)"] } unit="bpm" compareBaseline={compareBaseline} userTimezone={user?.timezone} onVisibleStats={onVisibleStats} showDataTable + dayLinks /> {/* S11 — the intraday "shape of the day" layer: 10-minute mean heart diff --git a/src/app/insights/respiratory-rate/page.tsx b/src/app/insights/respiratory-rate/page.tsx index 90b47e650..ac58a3568 100644 --- a/src/app/insights/respiratory-rate/page.tsx +++ b/src/app/insights/respiratory-rate/page.tsx @@ -21,11 +21,11 @@ export default function InsightsAtemfrequenzPage() { chartKey="respiratoryRate" i18nPrefix="insights.respiratoryRate" explainerMetric="respiratoryRate" - color="var(--info)" + color="var(--chart-4)" unitKey="insights.units.respiratoryRate" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any respiratory rate yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any respiratory rate yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/resting-pulse/page.tsx b/src/app/insights/resting-pulse/page.tsx index fa7efe387..920f8ea2f 100644 --- a/src/app/insights/resting-pulse/page.tsx +++ b/src/app/insights/resting-pulse/page.tsx @@ -25,7 +25,7 @@ export default function InsightsRestingHrPage() { chartKey="restingHr" i18nPrefix="insights.restingHr" explainerMetric="restingHr" - color="var(--destructive)" + color="var(--chart-3)" unit="bpm" yAxisUnit="bpm" emptyStateIcon={} diff --git a/src/app/insights/six-minute-walk/page.tsx b/src/app/insights/six-minute-walk/page.tsx index 9c0e52e84..3bc1905f6 100644 --- a/src/app/insights/six-minute-walk/page.tsx +++ b/src/app/insights/six-minute-walk/page.tsx @@ -20,12 +20,12 @@ export default function InsightsSixMinuteWalkPage() { chartKey="sixMinuteWalkDistance" i18nPrefix="insights.sixMinuteWalk" explainerMetric="sixMinuteWalk" - color="var(--success)" + color="var(--chart-2)" unit="m" yAxisUnit="m" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any six-minute-walk data yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any six-minute-walk data yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/skin-temperature/page.tsx b/src/app/insights/skin-temperature/page.tsx index 065dc2bb8..1fff4f129 100644 --- a/src/app/insights/skin-temperature/page.tsx +++ b/src/app/insights/skin-temperature/page.tsx @@ -29,16 +29,16 @@ export default function InsightsHauttemperaturPage() { chartKey="skinTemperature" i18nPrefix="insights.skinTemperature" explainerMetric="skinTemperature" - color="var(--warning)" + color="var(--chart-5)" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any skin temperature yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any skin temperature yet. What does this metric tell me about my health, and how do I improve it?" afterChart={ } - coachPrefill="I don't have any sleep data yet — why does sleep tracking matter, and what should I know before I connect a source?" + coachPrefill="I don't have any sleep data yet. Why does sleep tracking matter, and what should I know before I connect a source?" /> ); diff --git a/src/app/insights/stair-ascent-speed/page.tsx b/src/app/insights/stair-ascent-speed/page.tsx index a91043428..0d4e85b3b 100644 --- a/src/app/insights/stair-ascent-speed/page.tsx +++ b/src/app/insights/stair-ascent-speed/page.tsx @@ -20,12 +20,12 @@ export default function InsightsStairAscentSpeedPage() { chartKey="stairAscentSpeed" i18nPrefix="insights.stairAscentSpeed" explainerMetric="stairAscentSpeed" - color="var(--success)" + color="var(--chart-2)" unit="m/s" yAxisUnit="m/s" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any stair-ascent-speed data yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any stair-ascent-speed data yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/stair-descent-speed/page.tsx b/src/app/insights/stair-descent-speed/page.tsx index 3dc31f478..948a67404 100644 --- a/src/app/insights/stair-descent-speed/page.tsx +++ b/src/app/insights/stair-descent-speed/page.tsx @@ -20,12 +20,12 @@ export default function InsightsStairDescentSpeedPage() { chartKey="stairDescentSpeed" i18nPrefix="insights.stairDescentSpeed" explainerMetric="stairDescentSpeed" - color="var(--success)" + color="var(--chart-2)" unit="m/s" yAxisUnit="m/s" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any stair-descent-speed data yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any stair-descent-speed data yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/step-length/page.tsx b/src/app/insights/step-length/page.tsx index d373f0f80..2eed765ca 100644 --- a/src/app/insights/step-length/page.tsx +++ b/src/app/insights/step-length/page.tsx @@ -21,12 +21,12 @@ export default function InsightsSchrittlaengePage() { chartKey="walkingStepLength" i18nPrefix="insights.walkingStepLength" explainerMetric="stepLength" - color="var(--success)" + color="var(--chart-2)" unit="m" yAxisUnit="m" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any walking step length yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any walking step length yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/steps/page.tsx b/src/app/insights/steps/page.tsx index cbd8015d3..57783168e 100644 --- a/src/app/insights/steps/page.tsx +++ b/src/app/insights/steps/page.tsx @@ -26,12 +26,12 @@ export default function InsightsStepsPage() { i18nPrefix="insights.steps" explainerMetric="steps" statIcon={Footprints} - color="var(--success)" + color="var(--chart-2)" unitKey="insights.units.steps" emptyStateIcon={} emptyStateCtaType={null} captureType="ACTIVITY_STEPS" - coachPrefill="I haven't logged any steps yet — what's a reasonable daily step target, and how does walking more help my health?" + coachPrefill="I haven't logged any steps yet. What's a reasonable daily step target, and how does walking more help my health?" /> ); } diff --git a/src/app/insights/vascular-age/page.tsx b/src/app/insights/vascular-age/page.tsx index 82edd06fc..db5bbeea9 100644 --- a/src/app/insights/vascular-age/page.tsx +++ b/src/app/insights/vascular-age/page.tsx @@ -25,7 +25,7 @@ export default function InsightsGefaessalterPage() { unitKey="insights.units.years" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any vascular age yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any vascular age yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/visceral-fat/page.tsx b/src/app/insights/visceral-fat/page.tsx index fcefd0522..ae22f62b8 100644 --- a/src/app/insights/visceral-fat/page.tsx +++ b/src/app/insights/visceral-fat/page.tsx @@ -21,12 +21,12 @@ export default function InsightsViszeralfettPage() { chartKey="visceralFat" i18nPrefix="insights.visceralFat" explainerMetric="visceralFat" - color="var(--warning)" + color="var(--chart-5)" unit="" yAxisUnit="" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any visceral fat yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any visceral fat yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/waist/page.tsx b/src/app/insights/waist/page.tsx index 6c0d74bda..1079652c7 100644 --- a/src/app/insights/waist/page.tsx +++ b/src/app/insights/waist/page.tsx @@ -21,7 +21,7 @@ export default function InsightsWaistPage() { insightMetric="WAIST_CIRCUMFERENCE" chartKey="waistCircumference" i18nPrefix="insights.waist" - color="var(--info)" + color="var(--chart-4)" statIcon={Ruler} emptyStateIcon={} emptyStateCtaType="WAIST_CIRCUMFERENCE" diff --git a/src/app/insights/walking-asymmetry/page.tsx b/src/app/insights/walking-asymmetry/page.tsx index cc870dc61..640f05f06 100644 --- a/src/app/insights/walking-asymmetry/page.tsx +++ b/src/app/insights/walking-asymmetry/page.tsx @@ -21,12 +21,12 @@ export default function InsightsGangasymmetriePage() { chartKey="walkingAsymmetry" i18nPrefix="insights.walkingAsymmetry" explainerMetric="walkingAsymmetry" - color="var(--success)" + color="var(--chart-2)" unit="%" yAxisUnit="%" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any walking asymmetry yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any walking asymmetry yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/walking-distance/page.tsx b/src/app/insights/walking-distance/page.tsx index 26df9821e..5d4c4cf4b 100644 --- a/src/app/insights/walking-distance/page.tsx +++ b/src/app/insights/walking-distance/page.tsx @@ -21,10 +21,10 @@ export default function InsightsGehstreckePage() { chartKey="walkingRunningDistance" i18nPrefix="insights.walkingRunningDistance" explainerMetric="walkingDistance" - color="var(--success)" + color="var(--chart-2)" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any walking and running distance yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any walking and running distance yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/walking-heart-rate/page.tsx b/src/app/insights/walking-heart-rate/page.tsx index 0340bfdd8..ae4ba79d6 100644 --- a/src/app/insights/walking-heart-rate/page.tsx +++ b/src/app/insights/walking-heart-rate/page.tsx @@ -21,12 +21,12 @@ export default function InsightsGehpulsPage() { chartKey="walkingHeartRateAverage" i18nPrefix="insights.walkingHeartRateAverage" explainerMetric="walkingHeartRate" - color="var(--destructive)" + color="var(--chart-3)" unit="bpm" yAxisUnit="bpm" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any walking heart rate average yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any walking heart rate average yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/walking-speed/page.tsx b/src/app/insights/walking-speed/page.tsx index bdb43a22f..fc4b5d809 100644 --- a/src/app/insights/walking-speed/page.tsx +++ b/src/app/insights/walking-speed/page.tsx @@ -21,10 +21,10 @@ export default function InsightsGehgeschwindigkeitPage() { chartKey="walkingSpeed" i18nPrefix="insights.walkingSpeed" explainerMetric="walkingSpeed" - color="var(--success)" + color="var(--chart-2)" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any walking speed yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any walking speed yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/walking-steadiness/page.tsx b/src/app/insights/walking-steadiness/page.tsx index 91bfa01ce..0fa566184 100644 --- a/src/app/insights/walking-steadiness/page.tsx +++ b/src/app/insights/walking-steadiness/page.tsx @@ -21,12 +21,12 @@ export default function InsightsGangstabilitaetPage() { chartKey="walkingSteadiness" i18nPrefix="insights.walkingSteadiness" explainerMetric="walkingSteadiness" - color="var(--success)" + color="var(--chart-2)" unit="%" yAxisUnit="%" emptyStateIcon={} emptyStateCtaType={null} - coachPrefill="I haven't logged any walking steadiness yet — what does this metric tell me about my health, and how do I improve it?" + coachPrefill="I haven't logged any walking steadiness yet. What does this metric tell me about my health, and how do I improve it?" /> ); } diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index 00ad2e0ca..8866094ff 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -48,7 +48,11 @@ export default function InsightsGewichtPage() { const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated); const unitDisplay = useUnitDisplay(); - const { data: analytics, isEmpty } = useInsightsAnalytics("WEIGHT"); + const { + data: analytics, + isEmpty, + isLoading: analyticsLoading, + } = useInsightsAnalytics("WEIGHT"); const weightSummary = analytics?.summaries?.WEIGHT ?? null; // v1.34 — the user's own weight target, read through the shared @@ -105,7 +109,7 @@ export default function InsightsGewichtPage() { : weightSummary; // v1.12.8 — visible-range stats shared between the chart and the strip. - const { statsByType, onVisibleStats } = useChartDomainStats(); + const { statsByType, statsSettled, onVisibleStats } = useChartDomainStats(); if (isEmpty) { return ( @@ -125,7 +129,7 @@ export default function InsightsGewichtPage() { } - coachPrefill="I haven't recorded any weight yet — why does it matter, and what should I know before I start tracking?" + coachPrefill="I haven't recorded any weight yet. Why does it matter, and what should I know before I start tracking?" /> ); @@ -170,12 +174,14 @@ export default function InsightsGewichtPage() { explainerMetric="weight" statStrip={ } coachReadStrip={} @@ -203,6 +209,7 @@ export default function InsightsGewichtPage() { valueScale={weightScale} onVisibleStats={onVisibleStats} showDataTable + dayLinks /> diff --git a/src/app/insights/workouts/[id]/page.tsx b/src/app/insights/workouts/[id]/page.tsx index 4d8d66d64..338d55b20 100644 --- a/src/app/insights/workouts/[id]/page.tsx +++ b/src/app/insights/workouts/[id]/page.tsx @@ -11,6 +11,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { canonicalWorkoutDetailHref } from "@/lib/workouts/canonical-detail-route"; import { DeleteWorkoutButton } from "@/components/workouts/delete-workout-button"; +import { EditWorkoutButton } from "@/components/workouts/edit-workout-button"; import { WorkoutDetailHeader, WorkoutDetailStats, @@ -65,10 +66,21 @@ export default function InsightsWorkoutDetailPage({ // No description. The one this page used to carry belongs to the LIST // ("Recent runs, rides, walks…, deduped…") and describes a surface // this is not — a detail page must not claim to be the list. - // A workout entered by hand can be deleted here; the control renders - // nothing for a synced one. + // A workout entered by hand can be edited (#1162) and deleted here; + // both controls render nothing for a synced one. headerAction={ - data && !canonicalHref ? : null + data && !canonicalHref ? ( +
+ + +
+ ) : null } backLink={ setLogOpen(true)} - className={cn( - "text-muted-foreground hover:text-foreground relative size-10", - "before:absolute before:-inset-1.5 before:content-['']", - )} - > -
); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 70e93c9dd..71de7f39c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -9,6 +9,7 @@ import { MonitoringBootstrap } from "@/components/monitoring/bootstrap"; import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter"; import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale"; import { isKeyMismatch } from "@/lib/boot/key-mismatch-state"; +import { THEME_BOOT_SCRIPT, THEME_COLOR } from "@/lib/pwa/theme-color"; import { KeyMismatchPage } from "./key-mismatch-page"; const inter = Inter({ @@ -20,7 +21,7 @@ const inter = Inter({ export const metadata: Metadata = { title: "HealthLog", description: - "Self-hosted health tracker — weight, blood pressure, glucose, mood, medications. Withings + Apple Health sync, transparent derived wellness metrics, AI Insights you own.", + "Self-hosted health tracker: weight, blood pressure, glucose, mood, medications. Withings + Apple Health sync, transparent derived wellness metrics, AI Insights you own.", manifest: "/manifest.json", icons: { icon: "/favicon.svg", @@ -50,7 +51,7 @@ export const metadata: Metadata = { url: "/logo-readme.png", width: 1000, height: 1000, - alt: "HealthLog — your health data, your server", + alt: "HealthLog: your health data, your server", }, ], }, @@ -71,15 +72,14 @@ export const viewport: Viewport = { // the active palette. The hex values are the resolved background of // `--background` from `app/globals.css` for each theme so the bar // edge never seams against the page on cold paint. + // The client rewrites both to the app's own theme once it is known + // (`applyThemeColor`), since that need not be the operating system's. themeColor: [ - { media: "(prefers-color-scheme: light)", color: "#f3f2f5" }, - { media: "(prefers-color-scheme: dark)", color: "#282a36" }, + { media: "(prefers-color-scheme: light)", color: THEME_COLOR.light }, + { media: "(prefers-color-scheme: dark)", color: THEME_COLOR.dark }, ], }; -// Inline script to apply theme before first paint (prevents FOUC) -const themeScript = `(function(){try{var t=localStorage.getItem("healthlog-theme");var c=(t==="light"||t==="dark")?t:(t==="system"?(window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light"):"dark");document.documentElement.classList.add(c)}catch(e){document.documentElement.classList.add("dark")}})()`; - export default async function RootLayout({ children, }: Readonly<{ @@ -98,7 +98,9 @@ export default async function RootLayout({