mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
896 files changed
+65022
-8998
No files matched your search
@@ -70,6 +70,12 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# for a self-hosted Nightscout that is not reachable from the public internet.
|
||||
# NIGHTSCOUT_PRIVATE_ORIGINS=""
|
||||
|
||||
# Dawarich instances on your own network that the movement-context connection
|
||||
# may reach, comma-separated exact origins (e.g. "http://dawarich.lan:3000").
|
||||
# Each entry is one scheme://host[:port], never a suffix or a range. A public
|
||||
# Dawarich instance needs nothing here.
|
||||
# DAWARICH_PRIVATE_ORIGINS=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Notification targets on a private network -- optional
|
||||
@@ -327,6 +333,13 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# docs/integrations/ai-providers.md.
|
||||
# DOCUMENT_AI_LIMIT_PER_HOUR=""
|
||||
|
||||
# Minimum hours between two regenerations of a status card after a sync
|
||||
# (optional). A watch or an integration delivers readings all day, and each
|
||||
# delivery would otherwise regenerate the cards it touches every hour. Hand
|
||||
# entries keep a one-hour gap. Default 6, whole hours from 1 to 48. The value
|
||||
# under Admin > Coach wins over this one. See docs/integrations/ai-providers.md.
|
||||
# AI_SYNC_REGEN_GAP_HOURS=""
|
||||
|
||||
# Hourly upload ceiling for each document-upload token (optional). Applies
|
||||
# only to uploads made with a token minted under Settings > API & Tokens >
|
||||
# Document tokens: a Paperless workflow or the import script. Every upload
|
||||
|
||||
@@ -15,7 +15,7 @@ body:
|
||||
id: version
|
||||
attributes:
|
||||
label: HealthLog version
|
||||
description: Output of `/api/version` or the tag of the GHCR image you're running.
|
||||
description: Settings, About shows it, as does `/api/version`, or the tag of the GHCR image you're running.
|
||||
placeholder: "1.4.19"
|
||||
validations:
|
||||
required: true
|
||||
@@ -55,11 +55,27 @@ body:
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: error-id
|
||||
attributes:
|
||||
label: Error ID
|
||||
description: If the failure showed an error ID (eight characters, e.g. `3f9a2c1b`), paste it here. The operator finds the event with it under Admin, App logs.
|
||||
placeholder: "3f9a2c1b"
|
||||
- type: textarea
|
||||
id: support-info
|
||||
attributes:
|
||||
label: Support info / support report
|
||||
description: |
|
||||
Everyone: Settings, About, "Copy support info".
|
||||
Operators: Admin, System status, "Support report", then Copy.
|
||||
Both are redacted (no names, emails, values or tokens); read them before you paste.
|
||||
See https://github.com/MBombeck/HealthLog/blob/main/docs/self-hosting/support.md
|
||||
render: text
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant logs / screenshots
|
||||
description: Paste any relevant log output (redact secrets) or attach screenshots. Wide-event logs from `/admin/app-logs` are useful.
|
||||
label: Screenshots / other logs
|
||||
description: Attach screenshots, or paste other log output if you have it. Redact secrets and personal data first.
|
||||
render: shell
|
||||
- type: input
|
||||
id: browser
|
||||
|
||||
@@ -24,17 +24,6 @@ updates:
|
||||
- "patch"
|
||||
- "minor"
|
||||
ignore:
|
||||
# zod 4.5 made `z.iso.datetime()` require seconds, so a request carrying
|
||||
# `2026-01-01T06:15Z` that passes today would be refused with a 422 on
|
||||
# every route that validates a timestamp, and 4.6 rewrites nullable and
|
||||
# primitive unions in the generated JSON Schema from `anyOf` to `type`
|
||||
# arrays, which reshapes the OpenAPI document and every MCP tool schema.
|
||||
# Both are contract changes for the web client, the iOS app and MCP
|
||||
# callers, not a routine bump, so the move past 4.4 gets its own change
|
||||
# with the timestamp producers audited. Patch releases on 4.4 still
|
||||
# arrive.
|
||||
- dependency-name: "zod"
|
||||
versions: [">=4.5.0"]
|
||||
# Next 16.4.0 breaks URL state after history traversal: with a sheet
|
||||
# opened through `?doc=`, Back leaves it open, and `[` / `]` sometimes
|
||||
# do not move `?day=`. Reproduced in three end-to-end journeys (3 of 40
|
||||
|
||||
@@ -1,5 +1,89 @@
|
||||
# Changelog
|
||||
|
||||
## [1.43.0] — 2026-10-16
|
||||
|
||||
Records that link to each other, supplement composition as a nutrient source,
|
||||
life circumstances with AUDIT-C, AI usage levels, marker merging, Dawarich,
|
||||
single sign-on handover and an error id on every failure.
|
||||
|
||||
### Added
|
||||
|
||||
- **Relation links.** Five link tables (migration 0410): lab result and
|
||||
document, visit and medication, visit and vaccination, medication and
|
||||
document, medication and condition. `POST/DELETE /api/medications/{id}/links`
|
||||
and `/api/labs/{id}/links`; visit and vaccination link routes take the new
|
||||
target kinds. Six detail responses carry `related[]`, filtered by module and
|
||||
sharing scope; a scan committed from a document links its lab results.
|
||||
Backed up both ways, portable restores included.
|
||||
- **Supplement composition** (migration 0404). `PUT
|
||||
/api/medications/{id}/ingredients` (MANAGE-delegable), `POST
|
||||
.../ingredients/extract` (label photo through the document AI, owner only,
|
||||
proposal only), `GET /api/nutrients/catalog` with input units and EFSA upper
|
||||
limits (with the form each covers). Taken doses write `SUPPLEMENT` nutrient
|
||||
rows per local day; `/api/nutrients` and `/daily` split by source and judge
|
||||
the upper limit. DHA and EPA join the catalogue.
|
||||
- **Life circumstances** (migration 0405). Six sections with a valid-since
|
||||
date and per-section AI release (`/api/anamnesis/life-circumstances`,
|
||||
`ai-sections`, `confirm`); AUDIT-C (`/api/anamnesis/audit-c`) in validated
|
||||
en/de/ko/pl wording with per-locale cut-offs and sources, English with
|
||||
`validatedLocale: false` for es/fr/it. Read by the Coach (released sections
|
||||
only), the doctor report and FHIR. Screening hints by account language.
|
||||
- **AI usage levels** (migration 0402). Frugal, standard and full; admin
|
||||
ceiling and default, user choice within (`ai.usage` on `/api/auth/me`, `PUT
|
||||
/api/auth/me/ai-usage`). Sync gap by source, compact JSON in prompts, a
|
||||
stable Coach prompt prefix. `AI_SYNC_REGEN_GAP_HOURS` sets the instance's
|
||||
hours between card refreshes after a sync.
|
||||
- **Marker merge and aliases** (migration 0403).
|
||||
`POST /api/biomarkers/{id}/merge` with a `dryRun` preview (the web always
|
||||
shows it first), alias editing, alias choice on scan review, catalogue shorthands; link pickers search a year or
|
||||
a month.
|
||||
- **Dawarich** (migration 0406). Daily distance, car and train km, places
|
||||
count, time away from home and travel days; no places or coordinates
|
||||
stored. Private hosts only through `DAWARICH_PRIVATE_ORIGINS`. Movement
|
||||
block in the day view, two correlation channels, travel suggestions.
|
||||
- **Managed profile handover through single sign-on**
|
||||
(`POST /api/auth/claim/sso`), also under `OIDC_ONLY`.
|
||||
- **Supportability.** `meta.requestId` on every error envelope and an error id
|
||||
with copy button in the web; "Copy support info" under Settings, About; a
|
||||
separate problems ring in
|
||||
`/api/admin/app-logs`; an admin support report with accounts anonymised.
|
||||
- **Server-resolved fields for clients.** Direction verdicts and trend
|
||||
sentiment on the snapshot, summary, targets and series; chart reference
|
||||
lines; personal-record benchmarks; mood patterns; cycle `absorbed` /
|
||||
`restored`; hourly `stats:` rows for summed types and `syncTrigger:
|
||||
"backfill"` with `lastBackfillAt` (migration 0408).
|
||||
- **Pulse day mean v2** (migration 0407). Sparse hours weigh `min(1, n/3)`;
|
||||
admin dry run, apply and rebuild.
|
||||
|
||||
### Changed
|
||||
|
||||
- zod 4.6.7. Request instants must carry seconds (`isoDateTime()`); a refusal
|
||||
says so and is counted as `validation.datetime.no_seconds`. OpenAPI writes
|
||||
nullable fields as type arrays.
|
||||
- The legacy integration status fields are deprecated and go in v1.44; read
|
||||
`health`.
|
||||
- Below a default band, blood pressure, resting pulse and body fat read as
|
||||
`hold`; steps and SpO2 above their band never read as `up-bad`. Targets
|
||||
judge the seven-day mean, like the dashboard.
|
||||
- Migration 0409 drops the plaintext location columns once the v1.42
|
||||
backfill has sealed every row; recovery steps in
|
||||
`docs/ops/migration-0409-plaintext-location.md`.
|
||||
- Push notifications to guardians and in discreet mode open the app root.
|
||||
- Zod left the first load of `/`, `/measurements`, `/insights/mood` and
|
||||
`/settings/[section]`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A backup with a qualitative lab value (empty unit) restores.
|
||||
- Free weekdays reach a prompt only with the work section released; a
|
||||
concurrent about-me save no longer re-enables a released section.
|
||||
- Hour buckets and the nightly drain cut days in the same timezone; both
|
||||
starts of the repeated fall-back hour count.
|
||||
- Dawarich and Nightscout responses are read with a size bound; a Dawarich
|
||||
window edge no longer overwrites a complete day.
|
||||
- Supplement nutrient rows are recomputed when the nutrients module returns
|
||||
or the timezone changes.
|
||||
|
||||
## [1.42.4] — 2026-10-16
|
||||
|
||||
Reliability: one integration health model with a watchdog and a shared
|
||||
|
||||
+6
-6
@@ -1,13 +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 /inLine truncated
|
||||
"$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 /inLine truncated
|
||||
"routeBaselineKbGz": {
|
||||
"/page": 429,
|
||||
"/page": 355,
|
||||
"/insights/page": 304,
|
||||
"/measurements/page": 411,
|
||||
"/insights/mood/page": 379,
|
||||
"/settings/[section]/page": 403
|
||||
"/measurements/page": 335,
|
||||
"/insights/mood/page": 334,
|
||||
"/settings/[section]/page": 307
|
||||
},
|
||||
"routeDriftAllowanceKbGz": 20,
|
||||
"totalClientKbGz": 4790,
|
||||
"totalClientKbGz": 4975,
|
||||
"maxRechartsChunks": 1
|
||||
}
|
||||
@@ -230,6 +230,11 @@ x-healthlog-env: &healthlog_env
|
||||
# validator should accept, comma-separated (e.g. "http://nightscout.lan").
|
||||
# Without it a LAN/private Nightscout URL is refused as an SSRF target.
|
||||
NIGHTSCOUT_PRIVATE_ORIGINS: "${NIGHTSCOUT_PRIVATE_ORIGINS:-}"
|
||||
# Dawarich instances on your own network that the movement-context
|
||||
# connection may reach, comma-separated exact origins (e.g.
|
||||
# "http://dawarich.lan:3000"). Without it a LAN/private Dawarich URL is
|
||||
# refused as an SSRF target; a public instance needs nothing here.
|
||||
DAWARICH_PRIVATE_ORIGINS: "${DAWARICH_PRIVATE_ORIGINS:-}"
|
||||
# Webhook (Gotify, Home Assistant, n8n) and ntfy targets on your own
|
||||
# network, comma-separated exact origins (e.g. "https://gotify.example.com,
|
||||
# http://ntfy.lan:8080"). Without it a target that resolves to a private
|
||||
@@ -332,6 +337,11 @@ x-healthlog-env: &healthlog_env
|
||||
# (the `environment:` block is a whitelist). See
|
||||
# docs/integrations/ai-providers.md.
|
||||
DOCUMENT_AI_LIMIT_PER_HOUR: "${DOCUMENT_AI_LIMIT_PER_HOUR:-}"
|
||||
# Minimum hours between two status-card regenerations after a sync
|
||||
# (optional; default 6, whole hours 1-48; hand entries keep one hour). The
|
||||
# value under Admin > Coach wins. Listed here so an operator value in .env
|
||||
# reaches the container. See docs/integrations/ai-providers.md.
|
||||
AI_SYNC_REGEN_GAP_HOURS: "${AI_SYNC_REGEN_GAP_HOURS:-}"
|
||||
# Hourly upload ceiling for each narrow document-upload token (a Paperless
|
||||
# workflow, the import script; optional; default 120, clamped 1-1000).
|
||||
# Uploads from the web app and the phone keep their own bucket. Listed here
|
||||
|
||||
+4
-1
@@ -33,7 +33,10 @@ notes, and machine-readable specs that ship alongside the source:
|
||||
three access levels, the eight sections a grant can be narrowed to,
|
||||
managed profiles for somebody with no login and the guardians who look
|
||||
after them, what revocation does and does not undo, and which trust
|
||||
properties of the instance are unchanged by any of it.
|
||||
properties of the instance are unchanged by any of it. And
|
||||
[`self-hosting/support.md`](./self-hosting/support.md) — reporting a
|
||||
problem: error ids, the support info every account can copy, the
|
||||
operator's redacted support report, and finding an error in the admin log.
|
||||
- [`using/`](./using/) — notes on using the app that sit close to the
|
||||
code. [`using/search.md`](./using/search.md) covers the command palette's
|
||||
record search: opening it, what it finds, how matching works, and its
|
||||
|
||||
+7859
-3730
File diff suppressed because it is too large.
Load diff
@@ -431,6 +431,31 @@ many documents against a generous provider budget; lower it on a
|
||||
tight one. The 429 names the actual wait (the window is an hour) and
|
||||
carries the reset instant in `X-RateLimit-Reset`.
|
||||
|
||||
`AI_SYNC_REGEN_GAP_HOURS` (default `6`, whole hours from 1 to 48) is
|
||||
the minimum time between two regenerations of a status card after a sync
|
||||
at the `standard` AI usage level. A watch or an integration delivers
|
||||
readings all day, and each delivery moves today's figures, so without a
|
||||
gap every touched card regenerated about once an hour. A reading entered
|
||||
by hand keeps a one-hour gap, so it is still narrated the same day. The
|
||||
value set under Admin > Coach (AI usage levels) wins over the environment.
|
||||
|
||||
Each account runs at one of three AI usage levels, chosen under
|
||||
Settings > AI within the bounds the operator sets under Admin > Coach (the
|
||||
highest level allowed and the default for accounts without a choice):
|
||||
|
||||
| | `frugal` | `standard` | `full` |
|
||||
| ------------------------------- | ------------------ | -------------------- | -------------------- |
|
||||
| Status cards after a sync | at most once a day | the gap above | hourly |
|
||||
| Status cards after a hand entry | every 6 hours | hourly | hourly |
|
||||
| Snapshot size (compact JSON) | 10 000 characters | 14 000 | 14 000 |
|
||||
| Background reasoning | off | job default | job default |
|
||||
| Daily briefing | short form | full | full |
|
||||
| Automatic document reading | on request only | the account's switch | the account's switch |
|
||||
| Supplement label photo | not offered | offered | offered |
|
||||
|
||||
The Coach is the same at every level. Settings shows an estimate of
|
||||
tokens per day for each level, scaled from the account's last seven days.
|
||||
|
||||
`LABS_OCR_LIMIT_PER_HOUR` (default `6`, clamped to 1–1000) does the
|
||||
same for lab-report scans. The photo or PDF scan and the browser-OCR
|
||||
text scan share one bucket per user, and a scan that fails before the
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Dawarich integration
|
||||
|
||||
HealthLog can read daily movement context from a [Dawarich](https://dawarich.app)
|
||||
instance: how far you moved, how much of it by car or by train, how many places
|
||||
you visited, how long you were away from home, and whether a day was spent in
|
||||
another country. It is part of the Environment module and runs only while that
|
||||
module is on.
|
||||
|
||||
HealthLog never stores a coordinate, a track, a place name or a visit. It reads
|
||||
the day summaries and keeps one row of numbers per day. The place list is read
|
||||
live when you pick your home and is not saved.
|
||||
|
||||
## What is read
|
||||
|
||||
Once a night (03:20 Europe/Berlin) HealthLog fetches the last seven days, ending
|
||||
yesterday. Connecting fetches the last 30 days. Each request carries your API
|
||||
key as `Authorization: Bearer <key>`.
|
||||
|
||||
| Dawarich endpoint | What HealthLog keeps |
|
||||
| ----------------------- | -------------------------------------------------------------------------------------- |
|
||||
| `GET /api/v1/timeline` | total distance, distance by car (`driving`), distance by train, places visited |
|
||||
| `GET /api/v1/visits` | minutes spent at your home place, to work out time away from home |
|
||||
| `GET /api/v1/residency` | the country of each day, sealed at rest, and whether it differs from your home country |
|
||||
| `GET /api/v1/places` | nothing; the list is shown once so you can pick your home |
|
||||
| `GET /api/v1/users/me` | nothing; used to check the key |
|
||||
|
||||
Time away from home is the day's length minus the time a visit at your home
|
||||
place covers. Without a home place, or on a day without a single visit, it is
|
||||
left empty rather than shown as zero. Dawarich leaves out a mode that moved
|
||||
less than half a kilometre; HealthLog then reads that mode as zero for the day.
|
||||
|
||||
Residency needs API access on a hosted Dawarich plan; a self-hosted instance
|
||||
has it. Without it there are no travel days, and everything else still works.
|
||||
Dawarich keys residency by UTC date, so on a day that crosses a border late in
|
||||
the evening the country can be the next morning's.
|
||||
|
||||
## Connect
|
||||
|
||||
1. In Dawarich, open your account settings and copy the API key.
|
||||
2. In HealthLog, turn on the Environment module, then open
|
||||
**Settings → Integrations → Dawarich**.
|
||||
3. Enter the address of the instance and the key, and connect.
|
||||
4. Pick your home place from the list, and set your home country if you like.
|
||||
Without one, the country you spent most days in over the last year counts.
|
||||
|
||||
Run Dawarich and HealthLog in the same time zone. Dawarich groups the
|
||||
timeline by its own zone, HealthLog by yours; if they differ, a day can come
|
||||
back without its distances.
|
||||
|
||||
## A Dawarich instance on your own network
|
||||
|
||||
A public instance works without any server setting. An instance on a private
|
||||
network (a LAN address, a `.lan` name, a container on the same host) is refused
|
||||
unless the operator lists its exact origin:
|
||||
|
||||
```env
|
||||
DAWARICH_PRIVATE_ORIGINS="http://dawarich.lan:3000"
|
||||
```
|
||||
|
||||
The grammar is the one `NIGHTSCOUT_PRIVATE_ORIGINS` uses
|
||||
([Nightscout](./nightscout.md)): one `scheme://host[:port]` per entry, comma
|
||||
separated, no path, no wildcard and no range. Listing a host does not open its
|
||||
subdomains or another port. The unspecified address, link-local and the
|
||||
cloud-metadata range can never be listed. Restart HealthLog after changing it.
|
||||
The variable is on the `environment:` whitelist in `docker-compose.yml`.
|
||||
|
||||
## Where the values show up
|
||||
|
||||
- The day view has an **On the move** section with the day's figures.
|
||||
- Time away from home and the distance travelled join the correlation scan as
|
||||
two more factors.
|
||||
- Runs of at least two travel days that no trip covers yet are offered as
|
||||
trips under **Settings → Environment → Location periods**. Using one fills
|
||||
in the dates; you pick the place.
|
||||
- The Coach and the MCP `get_day` tool see the figures and whether it was a
|
||||
travel day, never the country.
|
||||
|
||||
## Disconnect
|
||||
|
||||
Disconnecting stops the nightly fetch and keeps the stored days. **Disconnect
|
||||
and delete days** removes them as well. The days are part of the backup; the
|
||||
address and key are not, so a restored account connects again.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Upgrade stopped at migration 0409
|
||||
|
||||
v1.43 removes the readable location columns that v1.42 replaced with sealed
|
||||
copies (`users.home_lat/home_lon/home_label`,
|
||||
`environment_contexts.lat/lon/location_label`,
|
||||
`environment_travel_locations.lat/lon/label`). Before it drops anything,
|
||||
migration `0409_drop_plaintext_location` checks that the v1.42 encryption
|
||||
backfill has sealed every one of those values. If any row still holds a
|
||||
readable location, the migration stops and the app container does not start.
|
||||
|
||||
## What you see
|
||||
|
||||
The container exits during the migration step with:
|
||||
|
||||
```text
|
||||
A migration failed to apply. New migrations cannot be applied before the error is recovered from. ...
|
||||
Migration name: 0409_drop_plaintext_location
|
||||
Database error code: P0001
|
||||
Database error:
|
||||
ERROR: HealthLog migration 0409 stopped: 3 home location(s), 0 location period(s) and 12 daily location(s) are still stored in plaintext. ...
|
||||
```
|
||||
|
||||
On every later start, Prisma refuses with `P3009` ("migrate found failed
|
||||
migrations in the target database") until the failed attempt is cleared.
|
||||
|
||||
## What it means
|
||||
|
||||
Nothing was lost and nothing was dropped. The check runs before any other
|
||||
statement in the migration, so the location columns and their values are all
|
||||
still there. The migrations before 0409 in the same image did apply; they only
|
||||
add tables, columns and enum values, so the previous release runs on top of
|
||||
them unchanged.
|
||||
|
||||
The cause is a v1.42 instance that was not running long enough for its
|
||||
encryption backfill to reach every account, for example a host that went from
|
||||
v1.41 straight to v1.43, or one that was stopped soon after updating to v1.42.
|
||||
|
||||
## How to recover
|
||||
|
||||
The order matters: clear the failed attempt first, because the previous
|
||||
release runs `prisma migrate deploy` on start as well and refuses to boot
|
||||
while a failed migration is on record.
|
||||
|
||||
```bash
|
||||
# 1. Mark the failed 0409 attempt as rolled back. The entrypoint is replaced
|
||||
# so the container does not try to migrate first.
|
||||
docker compose run --rm --entrypoint sh app -c \
|
||||
'NODE_PATH=/opt/prisma-cli/node_modules node /opt/prisma-cli/node_modules/prisma/build/index.js migrate resolve --rolled-back 0409_drop_plaintext_location'
|
||||
|
||||
# 2. Start the previous release. Set this in the compose `.env` (or your
|
||||
# platform's environment) and recreate the container.
|
||||
# HEALTHLOG_IMAGE_REF=:v1.42.4
|
||||
docker compose up -d app
|
||||
```
|
||||
|
||||
The previous release starts the encryption backfill about three minutes after
|
||||
boot and again once a day. It works through the accounts one at a time. Watch
|
||||
it with the three counts the migration checks; all three must reach 0:
|
||||
|
||||
```bash
|
||||
docker compose exec -T db psql -U healthlog healthlog -c "
|
||||
SELECT
|
||||
(SELECT count(*) FROM users WHERE home_lat IS NOT NULL) AS home,
|
||||
(SELECT count(*) FROM environment_travel_locations WHERE lat IS NOT NULL) AS periods,
|
||||
(SELECT count(*) FROM environment_contexts WHERE lat IS NOT NULL) AS days;"
|
||||
```
|
||||
|
||||
The app log shows each pass as
|
||||
`[free-text-encryption-backfill] user=... ` lines. If the counts stop going
|
||||
down, look for `[free-text-encryption-backfill] user=... failed` in the log;
|
||||
the error on the line after it names the cause.
|
||||
|
||||
Once all three counts are 0:
|
||||
|
||||
```bash
|
||||
# 3. Dump the database. This is the copy you go back to if anything else
|
||||
# goes wrong; keep it with your encryption key.
|
||||
docker compose exec -T db pg_dump -U healthlog healthlog \
|
||||
| gzip > healthlog-before-0409-$(date +%F).sql.gz
|
||||
|
||||
# 4. Upgrade again: remove the pin (or set it to the new version) and
|
||||
# recreate the container. Migration 0409 now runs through.
|
||||
docker compose pull app
|
||||
docker compose up -d app
|
||||
curl -s http://localhost:3000/api/version
|
||||
```
|
||||
|
||||
## What not to do
|
||||
|
||||
- Do not clear the readable columns by hand to get past the check. The
|
||||
check exists because those values have no sealed copy yet; setting them to
|
||||
`NULL` deletes the location for good.
|
||||
- Do not run `migrate resolve --applied 0409_drop_plaintext_location`. That
|
||||
records the migration as done while the columns are still there, and the
|
||||
v1.43 schema no longer matches the database.
|
||||
|
||||
Background: [`deploy.md`](deploy.md) for the general update and rollback
|
||||
order, [`backup-restore.md`](backup-restore.md) for restoring a dump.
|
||||
@@ -231,11 +231,58 @@ as something to log in as.
|
||||
person it was sent to, so the admin notification diagnostic can answer
|
||||
"who was this for" and "whose record was it about" separately.
|
||||
|
||||
There is no emancipation flow: no route, no control and no copy suggests
|
||||
a managed profile can be turned into an ordinary account. The data model
|
||||
leaves room for it (the credentials are nullable and the managed marker
|
||||
can be cleared) and clearing that marker stops the notification fan-out,
|
||||
but nothing in v1.37.0 exercises any of that.
|
||||
### Handing a record over to its person
|
||||
|
||||
Since v1.42 a guardian can hand a managed profile over to the person it
|
||||
describes, so the record becomes their own account with its whole history.
|
||||
Nothing is copied or moved; the profile's row gets credentials, loses the
|
||||
managed marker, and stops fanning reminders out to its guardians.
|
||||
|
||||
- A guardian with a fresh second factor creates a one-time link (valid for
|
||||
one, seven or fourteen days, shown once) and proposes, for each guardian,
|
||||
no access, view only or manage. Only a keyed hash of the link is stored,
|
||||
at most one link per profile is open, and any guardian can withdraw it
|
||||
without a second factor.
|
||||
- The person opens the link, sees the record's name, the expiry and who
|
||||
keeps which access, and chooses how they sign in. The claim runs as one
|
||||
transaction: the link is consumed, the proposals are applied, open
|
||||
invitations the record offered are withdrawn, and the disclaimer,
|
||||
onboarding and AI consent are reset so the person gives them themselves.
|
||||
After the first sign-in they confirm or change each guardian's access.
|
||||
- How the person signs in depends on the instance:
|
||||
|
||||
| Instance | Ways in |
|
||||
| --------------------------------- | -------------------------- |
|
||||
| No SSO configured | Password |
|
||||
| SSO configured, `OIDC_ONLY` unset | Password or single sign-on |
|
||||
| `OIDC_ONLY="true"` | Single sign-on only |
|
||||
|
||||
The guardian's panel and the claim page both show which ways are open.
|
||||
|
||||
- **Through single sign-on** (v1.43) the person picks a username and
|
||||
continues to the identity provider. When they come back, the provider's
|
||||
`(issuer, sub)` pair is bound to the record in the same transaction, the
|
||||
address is taken from the provider only when it asserts
|
||||
`email_verified: true` (otherwise the account has no address), and no
|
||||
password is set. Refused, with the record left exactly as it was:
|
||||
a provider identity that already signs in to an account on this instance
|
||||
(one identity, one account; the refusal is audited on that account as
|
||||
`auth.oidc.claim_conflict`), a verified address another account holds,
|
||||
a username taken in the meantime, a link that was used, withdrawn,
|
||||
replaced or expired while the person was at the provider, and a browser
|
||||
that is signed in to another account.
|
||||
- The link never appears in a URL the server builds: the claim page sends it
|
||||
in request bodies only, and the single-sign-on leg carries a sealed,
|
||||
ten-minute, HttpOnly cookie that names the link by its id. The `/claim/`
|
||||
path and `hlp_` tokens are scrubbed from logs.
|
||||
- The iOS app does not claim. The person claims in a browser and then signs
|
||||
in to the app as usual (with SSO, through the app's own SSO sign-in).
|
||||
|
||||
Audit rows: `managed_profile.handover.created` and `.revoked` on the record,
|
||||
`managed_profile.claim_sso.started` when a single-sign-on claim starts,
|
||||
`managed_profile.claimed` and `auth.claim` (with `method: "password"` or
|
||||
`"sso"`) when it lands, and `managed_profile.handed_over` on each guardian's
|
||||
own account.
|
||||
|
||||
## Seeing what happened
|
||||
|
||||
|
||||
@@ -218,6 +218,11 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Where to look.** For a bug report, copy the support report (Admin,
|
||||
System status) and the support info (Settings, About), and note the error
|
||||
id the failure showed. Admin, App logs finds the event behind an error id.
|
||||
What each contains, and what it never contains, is in
|
||||
[`support.md`](./support.md).
|
||||
- **`app` container exits with a Prisma migration error.** The
|
||||
Postgres healthcheck races the migration on slow disks. Run
|
||||
`docker compose restart app` once the database has stabilised —
|
||||
@@ -398,5 +403,9 @@ The line is a notice and does not pause the start: the migrations run right
|
||||
after it. So the dump belongs before step 2, not after you see the line; by
|
||||
then the database is already being migrated.
|
||||
|
||||
If an update to v1.43 stops at migration `0409_drop_plaintext_location`,
|
||||
nothing was changed; the recovery is in
|
||||
[`docs/ops/migration-0409-plaintext-location.md`](../ops/migration-0409-plaintext-location.md).
|
||||
|
||||
For the ordering on a host where you want the migration result before the
|
||||
new container takes traffic, see [`docs/ops/deploy.md`](../ops/deploy.md).
|
||||
@@ -472,7 +472,7 @@ once per event type, and every channel carries the same target:
|
||||
| Medication reminder, low supply, shared dose | `/medications` (low supply opens the medication) |
|
||||
| Check-up and measurement reminders | `/checkups` |
|
||||
| Proactive Coach nudge | the Coach conversation it started |
|
||||
| Cycle reminders | `/cycle` (nothing with discreet notifications on) |
|
||||
| Cycle reminders | `/cycle` (the dashboard with discreet notifications on) |
|
||||
| New sign-in | Settings, Security |
|
||||
| Integration sync failing | Settings, Integrations (an admin alert about another account opens the admin page) |
|
||||
| Illness red flag | the illness episode |
|
||||
@@ -496,8 +496,11 @@ How each channel carries it:
|
||||
ntfy, Telegram, the webhook and email need an absolute link, built from
|
||||
`APP_URL` (or `NEXT_PUBLIC_APP_URL`). Without one those channels deliver as
|
||||
before, just without a link. A notification about a profile you look after
|
||||
as a Guardian carries no deep link, because your session opens on your own
|
||||
record.
|
||||
as a Guardian opens the dashboard rather than the profile's page, because
|
||||
your session opens on your own record and the record switcher lives there.
|
||||
A discreet notification opens the dashboard as well, so its link names
|
||||
nothing. The silent background sync push for medication intake is the only
|
||||
notification without a link.
|
||||
|
||||
### Health figures in notifications
|
||||
|
||||
@@ -538,6 +541,23 @@ token is the only credential, so it is built to be harmless on its own:
|
||||
The buttons need `APP_URL`, because the ntfy app calls the URL from your
|
||||
phone. Your phone has to reach the instance at that address.
|
||||
|
||||
### Quick answers behind forward auth
|
||||
|
||||
If the instance sits behind a forward-auth gateway (Authelia, Authentik,
|
||||
oauth2-proxy, Cloudflare Access and the like), the gateway answers the
|
||||
button's request with its login page and the answer is never logged: the
|
||||
ntfy app has no session with the gateway. Exempt the one path from the
|
||||
gateway:
|
||||
|
||||
```
|
||||
/api/notifications/action/*
|
||||
```
|
||||
|
||||
The route does not rely on the gateway. The signed token is its only
|
||||
credential, it is rate-limited before the token is read, and it can log one
|
||||
mood answer for one account and nothing else. Every other path stays behind
|
||||
the gateway.
|
||||
|
||||
What the token cannot protect against is the topic itself. The button URLs,
|
||||
token included, travel through your ntfy server inside the message, and the
|
||||
message is delivered to everyone subscribed to the topic. For the six hours
|
||||
|
||||
@@ -112,6 +112,12 @@ Client registration essentials only — the IdP's own docs cover the rest.
|
||||
MFA in the IdP's policy engine — that is the point of delegating
|
||||
authentication. HealthLog-side 2FA remains available on top for the
|
||||
step-up gates above.
|
||||
- **Claiming a managed profile binds a fresh identity only.** When a
|
||||
person claims a managed profile through SSO, their `(issuer, sub)` is
|
||||
bound to that record, and only if no account on the instance already
|
||||
holds it, and the provider's address is used only when it is verified
|
||||
and not already taken. A claim can therefore never attach somebody's
|
||||
existing sign-in to a second record or take over an existing account.
|
||||
- **Linking is once.** There is no unlink/relink flow in this cut; the
|
||||
pin is deliberately sticky. Moving an account to a new IdP identity is
|
||||
an operator decision (a manual `users.oidc_issuer` / `users.oidc_sub`
|
||||
@@ -134,6 +140,10 @@ Consequences to accept before turning it on:
|
||||
- **Self-disabling on half-config.** The flag only takes effect while
|
||||
the three provider vars are fully set, so a typo in the provider group
|
||||
can never lock every user out.
|
||||
- **Managed profiles are still handed over** (v1.43). The person claims the
|
||||
record through the identity provider instead of setting a password; the
|
||||
password claim is refused. See
|
||||
[Handing a record over to its person](account-sharing.md#handing-a-record-over-to-its-person).
|
||||
|
||||
### Break-glass (IdP down or misconfigured)
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# Reporting a problem
|
||||
|
||||
HealthLog does not send anything home, and nobody but the operator can read
|
||||
an instance's logs. So a bug report has to carry what is needed to find the
|
||||
fault. Two texts do that, and both are redacted before you see them.
|
||||
|
||||
| Who | Where | What it is |
|
||||
| -------- | ---------------------------------------- | ------------------------------------------ |
|
||||
| Everyone | Settings, About, **Copy support info** | Your app version, settings and connections |
|
||||
| Operator | Admin, System status, **Support report** | The whole instance, accounts as numbers |
|
||||
|
||||
Add the **error id** of the failure if you have one, and paste both texts into
|
||||
the [bug report](https://github.com/MBombeck/HealthLog/issues/new?template=bug_report.yml).
|
||||
Read them before you post. They are built to contain nothing personal, but
|
||||
you are the one publishing them.
|
||||
|
||||
## Error ids
|
||||
|
||||
When the server fails (a 5xx answer), the message shows **Error ID** and
|
||||
eight characters, for example `3f9a2c1b`, with a copy button that copies the
|
||||
full id. A page that fails to render shows one too when the server has one
|
||||
to give: the request id of the failed call, or the digest the server printed
|
||||
beside the stack in its log.
|
||||
|
||||
The id is the request id of the failing call. Every error answer of the API
|
||||
carries it in `meta.requestId` and in the `x-request-id` header. An incoming
|
||||
`x-request-id` is only adopted when it consists of letters, digits and hyphens
|
||||
(8 to 64 characters); anything else is replaced with a fresh id.
|
||||
|
||||
The operator finds the event under **Admin, App logs** by typing the id (or
|
||||
its first 8 characters) into **Error ID**. A search by id reads the second log
|
||||
buffer, which keeps the last 1,000 warnings and errors of the process. Ordinary
|
||||
traffic cannot push them out, so an error stays findable for much longer than
|
||||
in the main buffer of 500 events. Both buffers live in memory: a restart
|
||||
empties them, and a split `web` / `worker` deployment has one per process.
|
||||
|
||||
The same search works on the API:
|
||||
|
||||
```
|
||||
GET /api/admin/app-logs?requestId=3f9a2c1b&ring=problems
|
||||
```
|
||||
|
||||
A 4xx answer (a refused input, a missing permission) shows no id. Its message
|
||||
says what to change.
|
||||
|
||||
## Support info (everyone)
|
||||
|
||||
**Settings, About, Copy support info** puts together, in your browser:
|
||||
|
||||
- server version and build, and your browser;
|
||||
- language, time zone and the modules you have switched on;
|
||||
- the AI state: whether a provider is set up, your usage level, and which AI
|
||||
features are available and why not;
|
||||
- for each connected source (Withings, Oura, and so on), its health state,
|
||||
reason, last success, newest data and the last recorded sync attempts;
|
||||
- the last five error ids of this browser session.
|
||||
|
||||
It never contains your username, email address, name, server address,
|
||||
tokens, recorded values or record ids. Nothing is sent anywhere; the text
|
||||
goes to your clipboard.
|
||||
|
||||
## Support report (operator)
|
||||
|
||||
**Admin, System status, Support report** creates a text with **Copy** and
|
||||
**Download**. It is created when you press a button, not on page load, and
|
||||
shown above the buttons so you can read it first. It contains:
|
||||
|
||||
- **Build:** version, build SHA, build time, Node, process type, heap limit,
|
||||
uptime.
|
||||
- **Database:** PostgreSQL version, server time zone, size, last applied
|
||||
migration, unfinished migrations.
|
||||
- **Configuration**, as yes or no only: secure cookie flag, OIDC, OIDC only,
|
||||
Loki, GlitchTip, off-host backup, APNs, Web Push, SMTP, GeoLite2, the number
|
||||
of `NOTIFICATION_PRIVATE_ORIGINS` entries, operator AI key, central Codex,
|
||||
the AI provider types accounts chose, demo mode.
|
||||
- **Jobs:** whether the worker runs in this process and when it last
|
||||
reported, the newest finished job, and every queue with failures in the
|
||||
last 72 hours with an error class (`timeout`, `TypeError`, `P2002`,
|
||||
`ECONNREFUSED`, an HTTP status), never the message.
|
||||
- **Connections:** per source, how many accounts are in each health state and
|
||||
reason, which accounts are not `ok`, and open watchdog incidents.
|
||||
- **AI:** the usage level default and ceiling, accounts per usage level, and
|
||||
the provider ledger per provider type (working, failing, credential
|
||||
refused, paused).
|
||||
- **Notifications:** per channel, delivered, failed and skipped in the last 7
|
||||
days, with the most frequent failure reasons.
|
||||
- **Backups:** newest in-database copy, the last weekly run, off-host
|
||||
freshness, and the last restore drill.
|
||||
- **Recent warnings and errors:** the 20 most frequent action names in the
|
||||
warning and error buffer of this process, with count, last seen and error
|
||||
class.
|
||||
|
||||
Accounts appear as `Account 1`, `Account 2` and so on, numbered by creation.
|
||||
The report never contains an email address, a username, an account id, a
|
||||
recorded value, a token, or the host name of another service. As a second
|
||||
layer, the finished text is scrubbed for the instance's own usernames,
|
||||
emails, names and ids, for tokens, URLs, email addresses, ids, IP addresses
|
||||
and host names before it is shown.
|
||||
|
||||
## Logs beyond the report
|
||||
|
||||
`docker compose logs app` prints every wide event as one JSON line;
|
||||
`LOG_LEVEL=debug` adds debug events. With `LOKI_ENDPOINT` set the same events
|
||||
go to Loki, and GlitchTip (Admin, General) receives unexpected server errors.
|
||||
Each line carries `request_id`, so an error id finds its line there too.
|
||||
+622
-41
@@ -51,7 +51,10 @@
|
||||
"durationHoursMinutes": "{hours} Std. {minutes} Min.",
|
||||
"durationMinutes": "{minutes} Min.",
|
||||
"durationClockMinutes": "{time} Min.",
|
||||
"durationClockHours": "{time} Std."
|
||||
"durationClockHours": "{time} Std.",
|
||||
"errorIdLabel": "Fehler-ID",
|
||||
"errorIdCopy": "Fehler-ID kopieren",
|
||||
"errorIdCopied": "Fehler-ID kopiert"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -154,6 +157,20 @@
|
||||
"event": {
|
||||
"notFound": "Diesen Eintrag gibt es nicht mehr."
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
"notFound": "Dieses Medikament ist nicht mehr vorhanden.",
|
||||
"ingredients": {
|
||||
"invalid": "Prüf die Zusammensetzung und versuch es noch einmal.",
|
||||
"unitMismatch": "Diese Einheit gibt es für den Nährstoff nicht.",
|
||||
"extract": {
|
||||
"fileType": "Lade ein JPEG, PNG, WebP oder PDF hoch.",
|
||||
"fileTooLarge": "Die Datei ist größer als 10 MB.",
|
||||
"rateLimited": "Zu viele Etiketten gelesen. Versuch es in einer Stunde noch einmal.",
|
||||
"budgetExceeded": "Dein KI-Budget für heute ist aufgebraucht.",
|
||||
"failed": "Das Etikett konnte nicht gelesen werden. Versuch ein schärferes Foto."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -367,7 +384,20 @@
|
||||
"notes": "Weitere Hinweise"
|
||||
},
|
||||
"courseRange": "Einnahmephase {start} bis {end}",
|
||||
"courseSince": "Einnahmephase seit {start}"
|
||||
"courseSince": "Einnahmephase seit {start}",
|
||||
"lifePackYears": "Packungsjahre",
|
||||
"lifeAuditC": "AUDIT-C (Screening)",
|
||||
"lifeAuditCScore": "{score} von 12 am {date}.",
|
||||
"lifeAuditCAbove": "Grenzwert {cutoff} erreicht oder überschritten ({source}).",
|
||||
"lifeAuditCBelow": "Unter dem Grenzwert {cutoff} ({source}).",
|
||||
"lifeAuditCNoCutoff": "Kein geschlechtsbezogener Grenzwert angewandt.",
|
||||
"lifeActivity": "Körperliche Aktivität",
|
||||
"lifeActivityValue": "{minutes} Minuten pro Woche, Selbstauskunft",
|
||||
"lifeSitting": "Sitzen am Werktag",
|
||||
"lifeSittingValue": "{hours} Stunden, Selbstauskunft",
|
||||
"lifeWork": "Arbeit",
|
||||
"lifeNightWork": "Nachtarbeit",
|
||||
"lifeExposures": "Belastungen: {list}"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Da ist etwas schiefgelaufen",
|
||||
@@ -525,7 +555,7 @@
|
||||
"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.",
|
||||
"oidcOnly": "Dieser Server erlaubt nur Single Sign-on. Fahre stattdessen mit deinem Identitätsanbieter fort.",
|
||||
"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",
|
||||
@@ -542,7 +572,19 @@
|
||||
"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."
|
||||
"errorOffline": "Das hat den Server nicht erreicht. Prüfe deine Verbindung und versuche es erneut.",
|
||||
"ssoContinue": "Weiter mit {provider}",
|
||||
"ssoStarting": "Weiterleitung…",
|
||||
"ssoHint": "Ab dann meldest du dich hier mit deinem Konto bei {provider} an.",
|
||||
"ssoDivider": "oder",
|
||||
"ssoFallbackName": "Single Sign-on",
|
||||
"errorTitle": "Die Übernahme hat nicht geklappt",
|
||||
"errorSubjectBound": "Diese Anmeldung gehört schon zu einem anderen Konto auf diesem Server. Öffne den Link noch einmal und nimm ein anderes Konto bei deinem Identitätsanbieter.",
|
||||
"errorEmailTaken": "Dein Identitätsanbieter hat eine E-Mail-Adresse geliefert, die hier schon ein anderes Konto nutzt. Öffne den Link noch einmal und nimm ein anderes Konto, oder frag die Person, die den Server betreibt.",
|
||||
"errorUsernameTaken": "Jemand hat diesen Benutzernamen inzwischen vergeben. Öffne den Link noch einmal und wähle einen anderen.",
|
||||
"errorSignedIn": "Bei der Rückkehr warst du angemeldet. Melde dich ab und öffne den Link noch einmal.",
|
||||
"errorSso": "Die Anmeldung bei deinem Identitätsanbieter hat nicht funktioniert. Öffne den Link noch einmal, um es erneut zu versuchen.",
|
||||
"signInInstead": "Bei diesem Konto anmelden"
|
||||
},
|
||||
"inviteInvalid": {
|
||||
"title": "Einladung ungültig",
|
||||
@@ -2399,6 +2441,34 @@
|
||||
"invalidRange": "Der letzte Tag kann nicht vor dem ersten liegen.",
|
||||
"generic": "Das wurde nicht gespeichert. Bitte versuch es noch einmal."
|
||||
}
|
||||
},
|
||||
"composition": {
|
||||
"title": "Zusammensetzung",
|
||||
"empty": "Trag ein, was eine Tablette oder Kapsel enthält, damit jede genommene Dosis in deine Mikronährstoffe zählt.",
|
||||
"perUnitNote": "Pro Tablette oder Kapsel; jede genommene Dosis zählt in die Mikronährstoffe des Tages.",
|
||||
"add": "Hinzufügen",
|
||||
"edit": "Bearbeiten",
|
||||
"rowHelper": "Was eine Tablette oder Kapsel enthält, für die Mikronährstoffe.",
|
||||
"sheetTitle": "Was eine Einheit enthält",
|
||||
"sheetDescription": "Trag die Mengen pro Tablette oder Kapsel so ein, wie sie auf dem Etikett stehen.",
|
||||
"nutrientLabel": "Nährstoff",
|
||||
"amountLabel": "Menge",
|
||||
"unitLabel": "Einheit",
|
||||
"choose": "Nährstoff wählen",
|
||||
"addRow": "Nährstoff hinzufügen",
|
||||
"removeRow": "{name} entfernen",
|
||||
"editorEmpty": "Noch kein Nährstoff. Füge einen pro Zeile des Etiketts hinzu.",
|
||||
"labelPhoto": "Vom Etikettfoto lesen",
|
||||
"labelReading": "Etikett wird gelesen…",
|
||||
"labelWaiting": "Der Hintergrunddienst hat das Etikett noch nicht übernommen.",
|
||||
"labelReviewNote": "Prüf jede Zeile mit dem Etikett, bevor du speicherst.",
|
||||
"lowConfidence": "Diese Zeile war schwer lesbar; prüf sie mit dem Etikett.",
|
||||
"unmatchedTitle": "Zeilen ohne passenden Nährstoff, bei Bedarf von Hand eintragen:",
|
||||
"servingNote": "Auf dem Etikett steht: {note}. Die Mengen sind pro einzelner Einheit angegeben.",
|
||||
"labelError": "Das Etikett konnte nicht gelesen werden",
|
||||
"saveError": "Die Zusammensetzung konnte nicht gespeichert werden",
|
||||
"catalogError": "Die Nährstoffliste konnte nicht geladen werden",
|
||||
"invalidRow": "Wähl in jeder Zeile einen Nährstoff und trag eine Menge über null ein."
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -2666,7 +2736,8 @@
|
||||
"assessmentPreparing": "Einschätzung wird vorbereitet…",
|
||||
"personalRecord": {
|
||||
"badge": "PR",
|
||||
"tooltip": "Persönlicher Rekord: {value} {unit} am {date}"
|
||||
"tooltip": "Persönlicher Rekord: {value} {unit} am {date}",
|
||||
"benchmark": "Typisch in dem Alter: {low} bis {high} {unit} ({source})"
|
||||
},
|
||||
"loadError": "Auswertungen konnten nicht geladen werden",
|
||||
"heroPersonalBaseline": "Basierend auf deinen letzten 90 Tagen",
|
||||
@@ -4220,6 +4291,21 @@
|
||||
"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.",
|
||||
"featureValue": "{field}: {value}"
|
||||
},
|
||||
"patterns": {
|
||||
"title": "Muster in deiner Stimmung",
|
||||
"basedOn": "Grundlage: {subject} und {comparison} Tage. Ein Muster, keine Ursache.",
|
||||
"previousDayHigher": "Nach einem Tag unter deinem Üblichen ist der nächste Tag meist besser ({subject} gegenüber {comparison} nach einem Tag darüber).",
|
||||
"previousDayLower": "Ein Tag unter deinem Üblichen wirkt meist in den nächsten Tag hinein ({subject} gegenüber {comparison} nach einem Tag darüber).",
|
||||
"weekendHigher": "Am Wochenende ist deine Stimmung meist besser ({subject} gegenüber {comparison} unter der Woche).",
|
||||
"weekendLower": "Am Wochenende ist deine Stimmung meist schlechter ({subject} gegenüber {comparison} unter der Woche).",
|
||||
"bestWeekday": "{weekday} ist meist dein bester Tag ({subject} gegenüber {comparison} an den übrigen Tagen).",
|
||||
"notesHigher": "An Tagen mit Notiz ist deine Stimmung meist besser ({subject} gegenüber {comparison} ohne).",
|
||||
"notesLower": "An Tagen mit Notiz ist deine Stimmung meist schlechter ({subject} gegenüber {comparison} ohne).",
|
||||
"delayedTagHigher": "Am Tag nach „{tag}“ ist deine Stimmung meist besser ({subject} gegenüber {comparison} sonst).",
|
||||
"delayedTagLower": "Am Tag nach „{tag}“ ist deine Stimmung meist schlechter ({subject} gegenüber {comparison} sonst).",
|
||||
"multipleEntriesHigher": "An Tagen mit mehreren Einträgen ist deine Stimmung meist besser ({subject} gegenüber {comparison} an Tagen mit einem).",
|
||||
"multipleEntriesLower": "An Tagen mit mehreren Einträgen ist deine Stimmung meist schlechter ({subject} gegenüber {comparison} an Tagen mit einem)."
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
@@ -5459,7 +5545,7 @@
|
||||
"subtitle": "Lege fest, welche Quelle gewinnt, wenn zwei dieselbe Messgröße liefern.",
|
||||
"nutrients": {
|
||||
"title": "Synchronisierte Nährstoffzufuhr",
|
||||
"description": "Tagessummen aus Apple Health der letzten {days} Tage.",
|
||||
"description": "Tagessummen der letzten {days} Tage aus Apple Health und den eingenommenen Supplementen.",
|
||||
"empty": "Noch keine Nährstoffdaten synchronisiert.",
|
||||
"latest": "{amount} {unit} am {date}",
|
||||
"daysWithData": "{count} von {days} Tagen",
|
||||
@@ -5821,7 +5907,10 @@
|
||||
"remove": "Ortszeitraum entfernen",
|
||||
"removeConfirmTitle": "Diese Reise entfernen?",
|
||||
"removeConfirmBody": "Messwerte aus diesem Zeitraum bleiben erhalten; nur der Reise-Eintrag verschwindet.",
|
||||
"range": "{start} bis {end}"
|
||||
"range": "{start} bis {end}",
|
||||
"suggestionsIntro": "Dawarich hat Tage im Ausland gefunden, die noch zu keinem Zeitraum gehören.",
|
||||
"suggestionUse": "Übernehmen",
|
||||
"suggestionDismiss": "Vorschlag verwerfen"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Vergangene Tage nacherfassen",
|
||||
@@ -6255,7 +6344,8 @@
|
||||
"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"
|
||||
"cancel": "Abbrechen",
|
||||
"restingFrugal": "Pausiert auf der sparsamen KI-Stufe: Dokumente werden nur auf Knopfdruck gelesen."
|
||||
},
|
||||
"centralCodex": {
|
||||
"title": "Gemeinsamen KI-Zugang des Servers nutzen",
|
||||
@@ -6313,7 +6403,33 @@
|
||||
"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"
|
||||
"runtimeTitle": "Prüfen und aktualisieren",
|
||||
"usage": {
|
||||
"title": "KI-Einsatz",
|
||||
"description": "Wie viel KI-Arbeit im Hintergrund für dich läuft, während der Coach auf jeder Stufe gleich bleibt.",
|
||||
"levels": {
|
||||
"frugal": {
|
||||
"label": "Sparsam",
|
||||
"hint": "Karten erneuern sich nach einem Sync höchstens einmal am Tag, ein kurzes Tagesbriefing, Dokumente werden nur auf Knopfdruck gelesen."
|
||||
},
|
||||
"standard": {
|
||||
"label": "Standard",
|
||||
"hint": "Karten erneuern sich nach einem Sync alle paar Stunden, das volle Tagesbriefing."
|
||||
},
|
||||
"full": {
|
||||
"label": "Voll",
|
||||
"hint": "Karten erneuern sich nach neuen Daten bis zu stündlich, wie vor dieser Einstellung."
|
||||
}
|
||||
},
|
||||
"limited": "Vom Betreiber begrenzt.",
|
||||
"estimate": "etwa {tokens} Tokens am Tag, geschätzt",
|
||||
"measured": "Gemessen: {tokens} Tokens am Tag in den letzten {days} Tagen, Coach eingeschlossen. Die Werte je Stufe sind Schätzungen.",
|
||||
"noEstimate": "Eine Schätzung je Stufe erscheint nach drei Tagen KI-Nutzung.",
|
||||
"capped": "Du hast {choice} gewählt, das der Server gerade nicht erlaubt, deshalb gilt {level}. Deine Wahl bleibt gespeichert.",
|
||||
"followsDefault": "Du folgst dem Standard des Servers ({level}).",
|
||||
"useDefault": "Standard des Servers verwenden ({level})",
|
||||
"saveError": "Die Stufe konnte nicht gespeichert werden. Bitte versuche es erneut."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"units": {
|
||||
@@ -6646,7 +6762,14 @@
|
||||
"docs": "Dokumentation",
|
||||
"credits": "Credits & Datenquellen",
|
||||
"linksHeading": "Quellen & Dokumentation",
|
||||
"newerAvailable": "Neue Version: {tag}"
|
||||
"newerAvailable": "Neue Version: {tag}",
|
||||
"support": {
|
||||
"title": "Support",
|
||||
"description": "Eine Zusammenfassung für eine Fehlermeldung, ohne Namen, E-Mail oder Gesundheitswerte.",
|
||||
"copy": "Support-Info kopieren",
|
||||
"copied": "Kopiert",
|
||||
"copyFailed": "Die Support-Info konnte nicht zusammengestellt werden."
|
||||
}
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Verbindung testen",
|
||||
@@ -7133,7 +7256,8 @@
|
||||
"lastReceived": "Zuletzt empfangen {relative}",
|
||||
"lastNewValue": "Letzter neuer Wert {relative}",
|
||||
"lastBackground": "Zuletzt im Hintergrund {relative}",
|
||||
"never": "Noch nicht empfangen"
|
||||
"never": "Noch nicht empfangen",
|
||||
"lastBackfill": "Verlauf nachgeladen {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
@@ -7267,6 +7391,46 @@
|
||||
"failureStreak": "Fehlerserie: {count} von {threshold}",
|
||||
"copyFailed": "Kopieren hat nicht geklappt. Markiere den Text und kopiere ihn von Hand."
|
||||
}
|
||||
},
|
||||
"dawarich": "Dawarich",
|
||||
"dawarichTag": "Bewegung",
|
||||
"dawarichDescription": "Verbinde deine Dawarich-Instanz, um Tagesstrecke, Zeit außer Haus und Reisetage zu übernehmen.",
|
||||
"dawarichUrl": "Dawarich-Adresse",
|
||||
"dawarichApiKey": "API-Schlüssel",
|
||||
"dawarichApiKeyHelp": "Du findest ihn in Dawarich unter Konto. HealthLog liest damit nur Tageswerte, nie Koordinaten oder Ortsnamen.",
|
||||
"dawarichConnect": "Verbinden",
|
||||
"dawarichUpdate": "Änderungen speichern",
|
||||
"dawarichConnected": "Dawarich verbunden. Die letzten 30 Tage werden gerade geladen.",
|
||||
"dawarichSync": "Jetzt abrufen",
|
||||
"dawarichSyncQueued": "Die letzten sieben Tage werden gleich abgerufen.",
|
||||
"dawarichSyncAlreadyQueued": "Ein Abruf läuft bereits.",
|
||||
"dawarichDisconnect": "Trennen",
|
||||
"dawarichDisconnectTitle": "Dawarich trennen?",
|
||||
"dawarichDisconnectDescription": "Künftige Abrufe stoppen. Bereits gespeicherte Tage bleiben erhalten.",
|
||||
"dawarichDisconnectDelete": "Trennen und Tage löschen",
|
||||
"dawarichDisconnectDeleteTitle": "Trennen und alle Tage löschen?",
|
||||
"dawarichDisconnectDeleteDescription": "Die Verbindung und alle gespeicherten Bewegungstage werden entfernt. Das lässt sich nicht rückgängig machen.",
|
||||
"dawarichHomePlaceSet": "Zuhause ist festgelegt.",
|
||||
"dawarichHomePlaceMissing": "Lege dein Zuhause fest, damit die Zeit außer Haus gemessen werden kann.",
|
||||
"dawarichHomeCountryIs": "Heimatland: {country}.",
|
||||
"dawarichHomeCountryMissing": "Noch kein Heimatland, daher keine Reisetage.",
|
||||
"dawarichDaysStored": "Gespeicherte Tage: {count}",
|
||||
"dawarichHomePlace": "Zuhause",
|
||||
"dawarichHomePlaceChoose": "Ort aus Dawarich wählen",
|
||||
"dawarichHomePlaceChange": "Zuhause wählen",
|
||||
"dawarichHomeCountry": "Heimatland",
|
||||
"dawarichHomeCountryHelp": "Zweistelliger Ländercode, etwa DE. Leer gelassen gilt das Land, in dem du im letzten Jahr am häufigsten warst.",
|
||||
"dawarichHomeSave": "Speichern",
|
||||
"dawarichHomeSaved": "Gespeichert.",
|
||||
"dawarichErrors": {
|
||||
"invalid": "Diese Adresse funktioniert nicht. Nutze die Adresse der Instanz ohne Zugangsdaten.",
|
||||
"privateOrigin": "Diese Instanz liegt in einem privaten Netz. Bitte den Betreiber des Servers, sie freizugeben.",
|
||||
"credentials": "Dawarich hat den API-Schlüssel nicht angenommen.",
|
||||
"unreachable": "Dawarich ist nicht erreichbar. Prüfe die Adresse und ob die Instanz läuft.",
|
||||
"rateLimited": "Zu viele Versuche. Warte kurz und versuche es dann erneut.",
|
||||
"homePlaceUnknown": "Diesen Ort kennt die Dawarich-Instanz nicht mehr.",
|
||||
"notConnected": "Dawarich ist nicht verbunden.",
|
||||
"generic": "Das hat nicht geklappt. Versuche es erneut."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -7343,7 +7507,8 @@
|
||||
"spendToday": "Heute, jeweils am eigenen Kalendertag jedes Kontos: {total} Tokens, davon {operator} auf dem Schlüssel des Betreibers (Konten mit Verbrauch: {accounts}).",
|
||||
"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."
|
||||
"modelNotServed": "Der Endpunkt bietet das Modell {model} nicht mehr an. Wähle in den KI-Einstellungen ein Modell, das er anbietet.",
|
||||
"usageLevels": "Konten je KI-Stufe: {frugal} sparsam, {standard} Standard, {full} voll."
|
||||
},
|
||||
"aiQuality": {
|
||||
"title": "Auswertungs-Qualität",
|
||||
@@ -7753,7 +7918,8 @@
|
||||
"eventDetails": "Event-Details",
|
||||
"closeDetails": "Details schließen",
|
||||
"processDetail": "Der Puffer ist flüchtig und auf 500 Einträge begrenzt.",
|
||||
"cardTitle": "Letzte Ereignisse"
|
||||
"cardTitle": "Letzte Ereignisse",
|
||||
"filterErrorId": "Fehler-ID"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
@@ -7840,6 +8006,7 @@
|
||||
"restoreSkippedCoachAttachment": "Coach-Dokumentverknüpfung",
|
||||
"restoreSkippedCoachReference": "Coach-Verweis",
|
||||
"restoreSkippedDocumentConditionLink": "Dokumentzuordnung",
|
||||
"restoreSkippedRelationLink": "Verknüpfung zwischen Einträgen",
|
||||
"restoreSkippedExtractedFact": "Dokumentbefund",
|
||||
"restoreSkippedFactCommitment": "Befundübernahme",
|
||||
"docsLink": "Dokumentation",
|
||||
@@ -8016,6 +8183,56 @@
|
||||
"started": "Pflege gestartet.",
|
||||
"alreadyQueued": "Ein Pflegelauf ist bereits eingeplant oder läuft.",
|
||||
"startFailed": "Die Pflege konnte nicht gestartet werden."
|
||||
},
|
||||
"pulseDayMean": {
|
||||
"title": "Puls-Tagesmittel",
|
||||
"description": "Wie Stunden mit nur wenigen Messwerten in den Tagespuls eingehen.",
|
||||
"body": "Bisher zählt jede Stunde mit einem Messwert gleich viel, sodass ein einzelner Ausreißer einen ganzen Tag verschieben kann. Die gewichtete Methode lässt eine Stunde ab drei Messwerten voll zählen und einen einzelnen Messwert zu einem Drittel. Starte zuerst den Probelauf, um zu sehen, wie viele Tage sich ändern würden. Beim Anwenden werden die gespeicherten Pulstage im Hintergrund neu berechnet.",
|
||||
"method": {
|
||||
"hourly": "Jede Stunde gleich",
|
||||
"weighted": "Dünne Stunden gewichtet"
|
||||
},
|
||||
"loading": "Puls-Tagesmittel wird geprüft…",
|
||||
"loadError": "Der Stand des Puls-Tagesmittels konnte nicht gelesen werden.",
|
||||
"state": {
|
||||
"idle": "Noch kein Probelauf.",
|
||||
"queued": "Der Probelauf wartet auf den Hintergrunddienst.",
|
||||
"running": "Der Probelauf läuft.",
|
||||
"done": "Probelauf am {at} abgeschlossen.",
|
||||
"failed": "Der Probelauf wurde abgebrochen. Das App-Protokoll nennt die Ursache; starte ihn erneut."
|
||||
},
|
||||
"result": {
|
||||
"days": "Verglichene Tage",
|
||||
"changed": "Tage mit mehr als 1 bpm Änderung",
|
||||
"max": "Größte Änderung",
|
||||
"maxValue": "{value} bpm",
|
||||
"bucket": "{range} bpm",
|
||||
"account": "Konto {account}",
|
||||
"accountDays": "{changed} von {days} Tagen ändern sich"
|
||||
},
|
||||
"appliedAt": "Gewichtete Methode angewendet am {at}.",
|
||||
"dryRun": "Probelauf",
|
||||
"apply": "Anwenden",
|
||||
"confirmTitle": "Gewichtetes Puls-Tagesmittel anwenden?",
|
||||
"confirmBody": "Danach werden die Pulstage aller Konten mit gewichteten dünnen Stunden berechnet. Die gespeicherten Pulstage werden im Hintergrund neu berechnet, was auf einer großen Instanz eine Weile dauern kann.",
|
||||
"confirm": "Anwenden",
|
||||
"cancel": "Abbrechen",
|
||||
"dryRunStarted": "Probelauf gestartet.",
|
||||
"applied": "Gewichtete Methode angewendet. Die Pulstage werden neu berechnet.",
|
||||
"appliedNoRebuild": "Gewichtete Methode angewendet. Ein anderer Lauf arbeitet noch; wende sie erneut an, sobald er fertig ist, damit die Pulstage neu berechnet werden.",
|
||||
"error": {
|
||||
"busy": "Ein Probelauf oder eine Neuberechnung läuft noch. Versuche es erneut, sobald er fertig ist.",
|
||||
"dryRunRequired": "Starte zuerst einen Probelauf. Zum Anwenden braucht es einen, der in den letzten sieben Tagen abgeschlossen wurde.",
|
||||
"generic": "Die Anfrage ist nicht durchgegangen."
|
||||
},
|
||||
"rebuild": {
|
||||
"queued": "Die Neuberechnung wartet auf den Hintergrunddienst.",
|
||||
"running": "Die Pulstage werden neu berechnet.",
|
||||
"done": "Pulstage am {at} für {accounts} Konten neu berechnet.",
|
||||
"failed": "Die Neuberechnung ist abgebrochen. Das App-Log nennt die Ursache; starte sie erneut."
|
||||
},
|
||||
"rebuildAgain": "Erneut berechnen",
|
||||
"rebuildStarted": "Neuberechnung gestartet."
|
||||
}
|
||||
},
|
||||
"carrier": "Netzbetreiber",
|
||||
@@ -8120,7 +8337,25 @@
|
||||
"codes": "Für den Betrieb: API-Anfragen antworten mit 503 und dem Fehlercode {code}, /api/health meldet den Grund {reason}."
|
||||
},
|
||||
"loginOverviewCardTitle": "Anmeldeereignisse",
|
||||
"userColumn": "Nutzer"
|
||||
"userColumn": "Nutzer",
|
||||
"supportReport": {
|
||||
"title": "Support-Bericht",
|
||||
"description": "Eine Zusammenfassung dieser Instanz für eine Fehlermeldung, ohne E-Mails, IDs oder Werte.",
|
||||
"body": "Konten erscheinen als „Account 1“, „Account 2“ und so weiter. Lies den Bericht, bevor du ihn teilst.",
|
||||
"copy": "Kopieren",
|
||||
"copied": "Kopiert",
|
||||
"download": "Herunterladen",
|
||||
"loadError": "Der Support-Bericht konnte nicht erstellt werden."
|
||||
},
|
||||
"aiUsage": {
|
||||
"title": "KI-Einsatz-Stufen",
|
||||
"description": "Die höchste Stufe, die Konten wählen dürfen, und die Stufe, mit der sie starten.",
|
||||
"maxLabel": "Höchste Stufe",
|
||||
"defaultLabel": "Standardstufe",
|
||||
"defaultAboveCeiling": "Die Standardstufe darf nicht über der höchsten Stufe liegen.",
|
||||
"gapLabel": "Stunden zwischen Karten-Erneuerungen nach einem Sync (Standard)",
|
||||
"gapHint": "Ganze Stunden von 1 bis 48. Leer lassen für AI_SYNC_REGEN_GAP_HOURS, sonst 6. Gerade wirksam: {hours} h."
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Erfolge",
|
||||
@@ -9215,7 +9450,9 @@
|
||||
"periodStartSaved": "Periodenbeginn gespeichert",
|
||||
"periodEndSaved": "Periodenende gespeichert",
|
||||
"startedPeriodOnDate": "Periode hat an diesem Tag begonnen",
|
||||
"endedPeriodOnDate": "Periode an diesem Tag beendet"
|
||||
"endedPeriodOnDate": "Periode an diesem Tag beendet",
|
||||
"startAbsorbed": "Periodenbeginn gespeichert. Dein Beginn am {absorbed} wurde mit dem {date} zusammengeführt.",
|
||||
"startRestored": "Gespeichert. Der Beginn am {dates} ist wieder da."
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Periode steht wahrscheinlich bevor",
|
||||
@@ -9604,12 +9841,16 @@
|
||||
"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."
|
||||
"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.",
|
||||
"alkaline-phosphatase": "Die alkalische Phosphatase (AP) ist ein Enzym, das vor allem in Leber, Gallenwegen und Knochen vorkommt. Sie steigt, wenn der Gallenfluss gestört ist, und in Phasen aktiven Knochenaufbaus, etwa im Wachstum von Kindern und Jugendlichen oder bei der Heilung eines Bruchs. Weil beide Organe sie abgeben, wird ein erhöhter Wert meist zusammen mit der GGT und den übrigen Leberwerten gelesen, um eine Ursache in der Leber von einer im Knochen zu unterscheiden. Sie gehört zu den üblichen Leberwerten.",
|
||||
"bilirubin-total": "Das Gesamtbilirubin ist das gelbe Abbauprodukt des Hämoglobins aus gealterten roten Blutkörperchen, gemessen als Summe seiner unkonjugierten und konjugierten Form. Die Leber nimmt es auf, bindet es und scheidet es mit der Galle aus, daher zeigt der Wert, wie gut dieser Weg funktioniert. Er kann bei Lebererkrankungen, verschlossenen Gallenwegen oder vermehrtem Abbau roter Blutkörperchen steigen, harmlos auch beim Gilbert-Syndrom. Er wird zusammen mit den übrigen Leberwerten gelesen."
|
||||
},
|
||||
"apob": "Apolipoprotein B (ApoB)",
|
||||
"lp-a": "Lipoprotein(a)",
|
||||
"fasting-insulin": "Nüchtern-Insulin",
|
||||
"omega-3-index": "Omega-3-Index"
|
||||
"omega-3-index": "Omega-3-Index",
|
||||
"alkaline-phosphatase": "Alkalische Phosphatase (AP)",
|
||||
"bilirubin-total": "Bilirubin gesamt"
|
||||
},
|
||||
"noteLoadFailed": "Notiz konnte nicht geladen werden; sie bleibt beim Speichern unverändert.",
|
||||
"showingLatestOf": "Die neuesten {shown} von {total} Messwerten werden angezeigt",
|
||||
@@ -9683,6 +9924,54 @@
|
||||
"fromReport": "Befundbereich {range}",
|
||||
"catalogDiffers": "dein Bereich {range}",
|
||||
"printed": "auf dem Befund in {unit}: {range}"
|
||||
},
|
||||
"match": {
|
||||
"viaAlias": "über einen Namen, den du früher zugeordnet hast",
|
||||
"viaCatalog": "als übliche Abkürzung erkannt",
|
||||
"chooseLabel": "Gehört zu",
|
||||
"suggestionOption": "{name} ({unit})",
|
||||
"newMarkerOption": "Ein neuer Biomarker",
|
||||
"rememberHint": "Der Name wird für den nächsten Befund gemerkt.",
|
||||
"chooseHint": "Wähle einen Biomarker, wenn du ihn schon erfasst."
|
||||
},
|
||||
"aliases": {
|
||||
"title": "Auch geschrieben als",
|
||||
"taken": "{alias} gehört schon zu einem anderen Biomarker.",
|
||||
"saveError": "Die Namen konnten nicht gespeichert werden. Versuche es erneut.",
|
||||
"remove": "{alias} entfernen",
|
||||
"empty": "Ergänze die Namen, die andere Labore für {name} drucken, damit ihre Befunde hier landen.",
|
||||
"placeholder": "z. B. Alk.P",
|
||||
"addLabel": "Weiterer Name für diesen Biomarker",
|
||||
"add": "Hinzufügen",
|
||||
"alsoWritten": "Auch geschrieben als {aliases}"
|
||||
},
|
||||
"merge": {
|
||||
"open": "Zusammenführen",
|
||||
"title": "Biomarker zusammenführen",
|
||||
"description": "Fasse Biomarker zusammen, die derselbe Test unter anderem Namen sind.",
|
||||
"pickHint": "Wähle mindestens zwei Biomarker aus.",
|
||||
"keepLabel": "Behalten",
|
||||
"keepHint": "Die anderen gehen in diesen auf, und ihre Namen werden seine weiteren Namen.",
|
||||
"showPreview": "Vorschau zeigen",
|
||||
"back": "Zurück",
|
||||
"confirm": "Jetzt zusammenführen",
|
||||
"previewSummaryOne": "{count} Messung wechselt zu {name} ({unit}).",
|
||||
"previewSummaryFew": "{count} Messungen wechseln zu {name} ({unit}).",
|
||||
"previewSummaryOther": "{count} Messungen wechseln zu {name} ({unit}).",
|
||||
"movesOne": "{count} Messung, schon in derselben Einheit",
|
||||
"movesFew": "{count} Messungen, schon in derselben Einheit",
|
||||
"movesOther": "{count} Messungen, schon in derselben Einheit",
|
||||
"convertedOne": "{count} Messung, umgerechnet von {unit} in {targetUnit}",
|
||||
"convertedFew": "{count} Messungen, umgerechnet von {unit} in {targetUnit}",
|
||||
"convertedOther": "{count} Messungen, umgerechnet von {unit} in {targetUnit}",
|
||||
"refused": "{unit} lässt sich nicht in {targetUnit} umrechnen.",
|
||||
"aliasesAdded": "Diese Namen führen danach zum behaltenen Biomarker:",
|
||||
"blocked": "Solange sich eine Einheit nicht umrechnen lässt, wird nichts zusammengeführt. Lass diesen Biomarker weg oder behalte einen anderen.",
|
||||
"noUndo": "Das Zusammenführen lässt sich nicht rückgängig machen.",
|
||||
"doneToastOne": "In {name} zusammengeführt, {count} Messung verschoben",
|
||||
"doneToastFew": "In {name} zusammengeführt, {count} Messungen verschoben",
|
||||
"doneToastOther": "In {name} zusammengeführt, {count} Messungen verschoben",
|
||||
"error": "Die Biomarker konnten nicht zusammengeführt werden. Versuche es erneut."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -10644,25 +10933,11 @@
|
||||
},
|
||||
"profileFacts": {
|
||||
"cardTitle": "Lebensstil-Kontext",
|
||||
"cardDescription": "Erfasse aktuelle Angaben, die die Einordnung deiner Gesundheitswerte verändern.",
|
||||
"smokingLabel": "Rauchstatus",
|
||||
"alcoholLabel": "Alkoholkonsummuster",
|
||||
"shiftLabel": "Arbeitszeitmodell",
|
||||
"notRecorded": "Nicht erfasst",
|
||||
"unreadable": "Gespeicherter Wert konnte nicht gelesen werden",
|
||||
"save": "Speichern",
|
||||
"savedToast": "Lebensstil-Kontext gespeichert",
|
||||
"savePartialError": "{kinds} konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
"saveKindError": "Nicht gespeichert",
|
||||
"loadError": "Lebensstil-Kontext konnte nicht geladen werden",
|
||||
"remove": "{kind} entfernen",
|
||||
"removeShort": "Entfernen",
|
||||
"removeConfirmTitle": "{kind} entfernen?",
|
||||
"removeConfirmDescription": "Der aktuelle Wert gilt danach als nicht erfasst. Der datierte Verlauf bleibt erhalten.",
|
||||
"removedToast": "{kind} aus dem aktuellen Lebensstil-Kontext entfernt",
|
||||
"removeError": "{kind} konnte nicht entfernt werden",
|
||||
"historyTitle": "Frühere Werte",
|
||||
"to": "bis",
|
||||
"values": {
|
||||
"SMOKING_STATUS": {
|
||||
"NEVER": "Nie geraucht",
|
||||
@@ -10842,6 +11117,235 @@
|
||||
"savedToast": "Notfalldaten gespeichert",
|
||||
"saveError": "Notfalldaten konnten nicht gespeichert werden.",
|
||||
"loadError": "Notfalldaten konnten nicht geladen werden."
|
||||
},
|
||||
"life": {
|
||||
"cardTitle": "Lebensumstände",
|
||||
"cardDescription": "Rauchen, Alkohol, Bewegung, Arbeit, Gewohnheiten und Ziele, datiert und jeweils freiwillig.",
|
||||
"loadError": "Deine Lebensumstände konnten nicht geladen werden.",
|
||||
"noAnswer": "Keine Angabe",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"validSince": "Gilt seit",
|
||||
"month": "Monat",
|
||||
"year": "Jahr",
|
||||
"lastConfirmed": "Zuletzt bestätigt am {date}",
|
||||
"notAnswered": "Noch nicht beantwortet",
|
||||
"unreadable": "Diese Antwort kann auf diesem Server nicht gelesen werden.",
|
||||
"save": "Abschnitt speichern",
|
||||
"savedToast": "Abschnitt gespeichert",
|
||||
"saveError": "Der Abschnitt konnte nicht gespeichert werden.",
|
||||
"review": {
|
||||
"question": "Stimmt das noch? Deine letzte Angabe ist über ein Jahr alt.",
|
||||
"confirm": "Stimmt noch",
|
||||
"confirmedToast": "Danke, als weiterhin gültig vermerkt",
|
||||
"confirmError": "Die Bestätigung konnte nicht gespeichert werden."
|
||||
},
|
||||
"ai": {
|
||||
"label": "Diesen Abschnitt für die KI freigeben",
|
||||
"shiftLabel": "Schichtarbeit für die KI freigeben",
|
||||
"hint": "Aus heißt: Coach und Briefings sehen diese Angaben nie.",
|
||||
"savedToast": "KI-Freigabe geändert",
|
||||
"saveError": "Die KI-Freigabe konnte nicht gespeichert werden."
|
||||
},
|
||||
"sections": {
|
||||
"tobacco": {
|
||||
"title": "Rauchen und Nikotin"
|
||||
},
|
||||
"alcohol": {
|
||||
"title": "Alkohol"
|
||||
},
|
||||
"activity": {
|
||||
"title": "Bewegung und Sitzen"
|
||||
},
|
||||
"work": {
|
||||
"title": "Arbeit und Rhythmus"
|
||||
},
|
||||
"habits": {
|
||||
"title": "Schlaf, Ernährung und Koffein"
|
||||
},
|
||||
"goals": {
|
||||
"title": "Ziele"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"smokingStatus": "Rauchstatus",
|
||||
"products": "Genutzte Produkte",
|
||||
"cigarettesPerDay": "Zigaretten pro Tag",
|
||||
"startYear": "Jahr des Rauchbeginns",
|
||||
"quitMonth": "Rauchstopp",
|
||||
"secondhandSmoke": "Regelmäßig Passivrauch",
|
||||
"moderateDaysPerWeek": "Tage pro Woche mit mäßiger bis anstrengender Bewegung, etwa zügigem Gehen",
|
||||
"moderateMinutesPerDay": "Minuten an diesen Tagen, im Schnitt",
|
||||
"sittingHoursWorkday": "Stunden im Sitzen an einem Werktag",
|
||||
"shiftSchedule": "Schichtarbeit",
|
||||
"situation": "Arbeitssituation",
|
||||
"physicalNature": "Art der Arbeit",
|
||||
"nightWork": "Nachtarbeit",
|
||||
"exposures": "Belastungen bei der Arbeit",
|
||||
"freeWeekdays": "Freie Wochentage",
|
||||
"napRegularly": "Regelmäßiger Mittagsschlaf",
|
||||
"snoringReported": "Andere sagen, du schnarchst",
|
||||
"dietPattern": "Ernährungsweise",
|
||||
"dietSpecifics": "Besonderheiten",
|
||||
"lateMealsPerWeek": "Mahlzeiten nach 21 Uhr pro Woche",
|
||||
"fruitVegPortionsPerDay": "Portionen Obst und Gemüse pro Tag",
|
||||
"caffeineDrinksPerDay": "Koffeinhaltige Getränke pro Tag",
|
||||
"latestCaffeine": "Spätestes Koffein am Tag",
|
||||
"goals": "Woran du arbeiten möchtest",
|
||||
"goalsHint": "Höchstens drei."
|
||||
},
|
||||
"values": {
|
||||
"products": {
|
||||
"cigarettes": "Zigaretten",
|
||||
"cigars_pipe": "Zigarre oder Pfeife",
|
||||
"e_cigarette": "E-Zigarette",
|
||||
"heated_tobacco": "Tabakerhitzer",
|
||||
"snus_pouches": "Snus oder Nikotinbeutel"
|
||||
},
|
||||
"situation": {
|
||||
"none": "Keine Erwerbsarbeit",
|
||||
"indoor": "Überwiegend drinnen",
|
||||
"outdoor": "Überwiegend draußen",
|
||||
"mixed": "Drinnen und draußen"
|
||||
},
|
||||
"physicalNature": {
|
||||
"mostly_sitting": "Überwiegend sitzend",
|
||||
"standing_walking": "Stehend oder gehend",
|
||||
"heavy_physical": "Schwere körperliche Arbeit"
|
||||
},
|
||||
"exposures": {
|
||||
"heat": "Hitze",
|
||||
"cold": "Kälte",
|
||||
"dust": "Staub",
|
||||
"fumes_solvents": "Dämpfe oder Lösungsmittel",
|
||||
"noise": "Lärm"
|
||||
},
|
||||
"dietPattern": {
|
||||
"mixed": "Mischkost",
|
||||
"vegetarian": "Vegetarisch",
|
||||
"vegan": "Vegan",
|
||||
"pescetarian": "Pescetarisch",
|
||||
"other": "Andere"
|
||||
},
|
||||
"dietSpecifics": {
|
||||
"intermittent_fasting": "Intervallfasten",
|
||||
"low_carb": "Kohlenhydratarm",
|
||||
"prescribed_diet": "Ärztlich verordnete Diät"
|
||||
},
|
||||
"latestCaffeine": {
|
||||
"before_12": "Vor 12 Uhr",
|
||||
"12_15": "12 bis 15 Uhr",
|
||||
"15_18": "15 bis 18 Uhr",
|
||||
"after_18": "Nach 18 Uhr"
|
||||
},
|
||||
"goals": {
|
||||
"weight": "Gewicht",
|
||||
"blood_pressure": "Blutdruck",
|
||||
"blood_sugar": "Blutzucker",
|
||||
"sleep": "Schlaf",
|
||||
"fitness": "Fitness",
|
||||
"stress_recovery": "Stress und Erholung",
|
||||
"medication_routine": "Medikamente regelmäßig nehmen",
|
||||
"quit_smoking": "Rauchstopp",
|
||||
"less_alcohol": "Weniger Alkohol"
|
||||
},
|
||||
"weekdays": {
|
||||
"1": "Mo",
|
||||
"2": "Di",
|
||||
"3": "Mi",
|
||||
"4": "Do",
|
||||
"5": "Fr",
|
||||
"6": "Sa",
|
||||
"7": "So"
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
"packYears": "{value} Packungsjahre",
|
||||
"packYearsMissing": "Für Packungsjahre fehlen Zigaretten pro Tag oder das Jahr des Rauchbeginns.",
|
||||
"activityMinutes": "{value} Minuten pro Woche.",
|
||||
"meetsWho": "Damit erreichst du die WHO-Empfehlung von 150 Minuten pro Woche.",
|
||||
"belowWho": "Das liegt unter der WHO-Empfehlung von 150 Minuten pro Woche.",
|
||||
"measuredSteps": "Deine gemessenen Schritte haben Vorrang, diese Angabe ergänzt sie.",
|
||||
"freeDaysDefault": "Ohne Angabe gelten Samstag und Sonntag als frei."
|
||||
},
|
||||
"auditC": {
|
||||
"intro": "Drei Fragen zu Alkohol aus dem WHO-AUDIT. Ein Screening, keine Diagnose.",
|
||||
"start": "Fragen beantworten",
|
||||
"again": "Erneut beantworten",
|
||||
"offerDue": "Ein kurzer Blick einmal im Jahr genügt.",
|
||||
"notValidated": "Für deine Sprache gibt es noch keine geprüfte Übersetzung, deshalb stehen die Fragen auf Englisch.",
|
||||
"submit": "Antworten speichern",
|
||||
"savedToast": "Antworten gespeichert",
|
||||
"saveError": "Die Antworten konnten nicht gespeichert werden.",
|
||||
"result": "{score} von 12 Punkten",
|
||||
"above": "Am oder über dem Screening-Grenzwert von {cutoff}.",
|
||||
"below": "Unter dem Screening-Grenzwert von {cutoff}.",
|
||||
"noCutoff": "Ohne Angabe zum Geschlecht im Profil zeigen wir nur die Summe.",
|
||||
"source": "Quelle: {source}",
|
||||
"history": "Frühere Einträge",
|
||||
"remove": "Eintrag entfernen",
|
||||
"removeConfirmTitle": "Diesen Eintrag entfernen?",
|
||||
"removeConfirmBody": "Der Eintrag verschwindet aus deinem Verlauf. Das lässt sich hier nicht rückgängig machen.",
|
||||
"removedToast": "Eintrag entfernt",
|
||||
"removeError": "Der Eintrag konnte nicht entfernt werden.",
|
||||
"historyEntry": "{date}: {score} von 12",
|
||||
"legacyLabel": "Frühere Angabe: {value}",
|
||||
"legacyHint": "Aus der kurzen Alkoholfrage vor diesem Fragebogen. Sobald ein Fragebogen-Ergebnis vorliegt, liest der Coach sie nicht mehr.",
|
||||
"legacyRemove": "Frühere Angabe entfernen",
|
||||
"legacyRemoveConfirmTitle": "Frühere Angabe entfernen?",
|
||||
"legacyRemoveConfirmBody": "Die frühere Angabe verschwindet aus deinen Daten. Fragebogen-Ergebnisse bleiben.",
|
||||
"legacyRemovedToast": "Frühere Angabe entfernt",
|
||||
"legacyRemoveError": "Die frühere Angabe konnte nicht entfernt werden."
|
||||
},
|
||||
"takeOver": {
|
||||
"conditionsButton": "In Zustandstagebuch übernehmen",
|
||||
"allergiesButton": "In Allergien übernehmen",
|
||||
"conditionsTitle": "Chronische Erkrankung anlegen",
|
||||
"allergiesTitle": "Allergie anlegen",
|
||||
"description": "Dein Freitext bleibt, bis du ihn selbst leerst.",
|
||||
"freeTextLabel": "Dein Freitext",
|
||||
"conditionLabel": "Erkrankung",
|
||||
"onset": "Seit",
|
||||
"save": "Anlegen",
|
||||
"savedToast": "Eintrag angelegt",
|
||||
"saveError": "Der Eintrag konnte nicht angelegt werden.",
|
||||
"clearQuestion": "Der Eintrag ist gespeichert. Freitext jetzt leeren?",
|
||||
"clear": "Freitext leeren",
|
||||
"keep": "Behalten",
|
||||
"clearedToast": "Freitext geleert",
|
||||
"clearError": "Der Freitext konnte nicht geleert werden."
|
||||
},
|
||||
"screening": {
|
||||
"cardTitle": "Hinweise zur Früherkennung",
|
||||
"cardDescription": "Aus deinen Lebensumständen, mit Regel und Land, aus dem sie stammt.",
|
||||
"noReminder": "Nur ein Hinweis; eine Erinnerung legst du bei Bedarf selbst an.",
|
||||
"source": "Quelle: {source}",
|
||||
"titles": {
|
||||
"lung_ldct": "Niedrigdosis-CT zur Lungenkrebs-Früherkennung",
|
||||
"aaa_ultrasound": "Ultraschall der Bauchschlagader"
|
||||
},
|
||||
"status": {
|
||||
"suggested": "Könnte für dich gelten",
|
||||
"not_eligible": "Gilt nicht für dich",
|
||||
"unknown": "Zu wenige Angaben"
|
||||
},
|
||||
"reasons": {
|
||||
"never_smoked": "Du hast nie geraucht.",
|
||||
"missing_birth_date": "Dein Geburtsdatum fehlt.",
|
||||
"age_out_of_range": "Dein Alter liegt außerhalb des Bereichs.",
|
||||
"missing_smoking_status": "Dein Rauchstatus fehlt.",
|
||||
"no_cigarettes": "Die Regel zählt nur Zigaretten.",
|
||||
"missing_smoking_history": "Zigaretten pro Tag oder das Jahr des Rauchbeginns fehlen.",
|
||||
"pack_years_below_15": "Weniger als 15 Packungsjahre.",
|
||||
"pack_years_below_20": "Weniger als 20 Packungsjahre.",
|
||||
"smoked_years_below_25": "Weniger als 25 Jahre geraucht.",
|
||||
"missing_quit_date": "Das Datum des Rauchstopps fehlt.",
|
||||
"quit_over_10_years": "Der Rauchstopp liegt über 10 Jahre zurück.",
|
||||
"quit_over_15_years": "Der Rauchstopp liegt über 15 Jahre zurück.",
|
||||
"missing_sex": "Im Profil fehlt das Geschlecht.",
|
||||
"sex_not_male": "Die Regel gilt für Männer."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
@@ -10875,6 +11379,10 @@
|
||||
"mugwort": "Beifuß",
|
||||
"olive": "Olive",
|
||||
"ragweed": "Ambrosia"
|
||||
},
|
||||
"movement": {
|
||||
"awayFromHome": "Zeit außer Haus",
|
||||
"distance": "Zurückgelegte Strecke"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -11122,7 +11630,8 @@
|
||||
"extractEmpty": "Aus diesem Dokument konnten keine Werte ausgelesen werden.",
|
||||
"failUnitRequired": "Ein Zahlenwert braucht eine Einheit, bevor er gespeichert werden kann.",
|
||||
"failUnitMismatch": "Die angegebene Einheit passt nicht zur gespeicherten Einheit dieses Markers. Passe sie an und schließe die Prüfung erneut ab.",
|
||||
"failGeneric": "Dieser Wert konnte nicht gespeichert werden."
|
||||
"failGeneric": "Dieser Wert konnte nicht gespeichert werden.",
|
||||
"failUnknownBiomarker": "Den gewählten Biomarker gibt es nicht mehr. Wähle einen anderen."
|
||||
},
|
||||
"contentIndex": {
|
||||
"indexPrompt": "Dokumentinhalte durchsuchbar machen.",
|
||||
@@ -11344,7 +11853,9 @@
|
||||
"molybdenum": "Molybdän",
|
||||
"iodine": "Jod",
|
||||
"water": "Wasser",
|
||||
"caffeine": "Koffein"
|
||||
"caffeine": "Koffein",
|
||||
"dha": "DHA (Omega-3)",
|
||||
"epa": "EPA (Omega-3)"
|
||||
},
|
||||
"page": {
|
||||
"title": "Nährstoffaufnahme",
|
||||
@@ -11376,9 +11887,26 @@
|
||||
"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"
|
||||
"loadError": "Deine Nährstoffdaten konnten nicht geladen werden",
|
||||
"sourcePart": "{source} {value} {unit}",
|
||||
"aboveUpperLimit": "Über der tolerierbaren Höchstmenge für Erwachsene von {value} {unit} pro Tag ({source}).",
|
||||
"aboveUpperLimitSupplemental": "Der Anteil aus Präparaten liegt über der tolerierbaren Höchstmenge für Erwachsene von {value} {unit} pro Tag für Präparate ({source}).",
|
||||
"upperLimitForm": {
|
||||
"retinol": "Die Höchstmenge gilt nur für vorgeformtes Vitamin A (Retinol): Stammt die Menge aus Beta-Carotin, gilt sie nicht.",
|
||||
"folic_acid": "Die Höchstmenge gilt für Folsäure aus Präparaten und angereicherten Lebensmitteln, nicht für das Folat in gewöhnlicher Nahrung.",
|
||||
"magnesium_salts": "Die Höchstmenge gilt für leicht lösliche Magnesiumsalze in Präparaten, nicht für das Magnesium aus der Nahrung.",
|
||||
"alpha_tocopherol": "Die Höchstmenge ist für Alpha-Tocopherol festgelegt."
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"units": {
|
||||
"IU": "IE"
|
||||
},
|
||||
"sources": {
|
||||
"APPLE_HEALTH": "Synchronisiert",
|
||||
"MANUAL": "Eingetragen",
|
||||
"SUPPLEMENT": "Präparate"
|
||||
}
|
||||
},
|
||||
"daily": {
|
||||
"line": {
|
||||
@@ -11880,9 +12408,11 @@
|
||||
"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.",
|
||||
"oidcOnly": "Ein Profil zu übergeben ist auf diesem Server nicht möglich.",
|
||||
"loadError": "Der Stand der Übergabe ließ sich nicht laden.",
|
||||
"entryHint": "Wenn {name} die Akte selbst führen möchte."
|
||||
"entryHint": "Wenn {name} die Akte selbst führen möchte.",
|
||||
"methodSso": "{name} meldet sich beim Einlösen des Links über Single Sign-on an.",
|
||||
"methodBoth": "{name} kann ein Passwort festlegen oder sich über Single Sign-on anmelden."
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
@@ -12008,7 +12538,11 @@
|
||||
"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"
|
||||
"lifeEventDelete": "{name} hat ein Lebensereignis entfernt",
|
||||
"biomarkerMerge": "{name} hat Biomarker zusammengeführt",
|
||||
"medicationIngredientsUpdate": "{name} hat die Zusammensetzung eines Präparats geändert",
|
||||
"auditCCreate": "{name} hat den AUDIT-C-Fragebogen ausgefüllt",
|
||||
"auditCDelete": "{name} hat einen AUDIT-C-Eintrag gelöscht"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
@@ -12227,7 +12761,34 @@
|
||||
"removed": "Verknüpfung mit {name} gelöst",
|
||||
"linked": "Verknüpft",
|
||||
"loadNewer": "Neuere laden",
|
||||
"loadOlder": "Ältere laden"
|
||||
"loadOlder": "Ältere laden",
|
||||
"period": {
|
||||
"showing": "Zeitraum: {period}"
|
||||
}
|
||||
},
|
||||
"related": {
|
||||
"title": "Verwandt",
|
||||
"kind": {
|
||||
"labResult": "Laborwert",
|
||||
"encounter": "Besuch",
|
||||
"document": "Dokument",
|
||||
"conditionEpisode": "Erkrankung",
|
||||
"medication": "Medikament",
|
||||
"vaccination": "Impfung"
|
||||
},
|
||||
"via": {
|
||||
"encounter": "über einen Besuch",
|
||||
"efficacyTarget": "über das Wirkziel"
|
||||
},
|
||||
"pick": {
|
||||
"conditionEpisode": "Erkrankungen",
|
||||
"document": "Dokumente",
|
||||
"encounter": "Besuche",
|
||||
"medication": "Medikamente",
|
||||
"vaccination": "Impfungen"
|
||||
},
|
||||
"nothingToOffer": "Noch nichts zum Verknüpfen.",
|
||||
"saveError": "Die Verknüpfung konnte nicht gespeichert werden."
|
||||
}
|
||||
},
|
||||
"bodySites": {
|
||||
@@ -12369,7 +12930,8 @@
|
||||
"scores": "Scores",
|
||||
"running": "Laufend an diesem Tag",
|
||||
"values": "Werte",
|
||||
"happened": "Was war"
|
||||
"happened": "Was war",
|
||||
"movement": "Unterwegs"
|
||||
},
|
||||
"notable": {
|
||||
"extremeHigh": "Höchster Tageswert seit {since}",
|
||||
@@ -12425,7 +12987,18 @@
|
||||
"today": "Heute",
|
||||
"hasEntries": "mit Einträgen",
|
||||
"dayNSince": "Tag {n}, seit {date}",
|
||||
"sinceDate": "Seit {date}"
|
||||
"sinceDate": "Seit {date}",
|
||||
"movement": {
|
||||
"total": "Strecke",
|
||||
"driving": "Mit dem Auto",
|
||||
"train": "Mit der Bahn",
|
||||
"places": "Besuchte Orte",
|
||||
"away": "Außer Haus",
|
||||
"travel": "Reisetag",
|
||||
"travelYes": "Ja",
|
||||
"kilometres": "{value} km",
|
||||
"miles": "{value} mi"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Zeitleiste",
|
||||
@@ -12760,5 +13333,13 @@
|
||||
"lastUpdated": "Zuletzt aktualisiert: {date}",
|
||||
"project": "Projekt",
|
||||
"credits": "Danksagungen"
|
||||
},
|
||||
"series": {
|
||||
"reference": {
|
||||
"targetUpper": "Dein Ziel, Obergrenze",
|
||||
"targetLower": "Dein Ziel, Untergrenze",
|
||||
"clinicalUpper": "Klinische Obergrenze",
|
||||
"clinicalLower": "Klinische Untergrenze"
|
||||
}
|
||||
}
|
||||
}
|
||||
+622
-41
@@ -51,7 +51,10 @@
|
||||
"durationHoursMinutes": "{hours}h {minutes}m",
|
||||
"durationMinutes": "{minutes}m",
|
||||
"durationClockMinutes": "{time} min",
|
||||
"durationClockHours": "{time} h"
|
||||
"durationClockHours": "{time} h",
|
||||
"errorIdLabel": "Error ID",
|
||||
"errorIdCopy": "Copy error ID",
|
||||
"errorIdCopied": "Error ID copied"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -154,6 +157,20 @@
|
||||
"event": {
|
||||
"notFound": "This entry no longer exists."
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
"notFound": "This medication is no longer available.",
|
||||
"ingredients": {
|
||||
"invalid": "Check the composition and try again.",
|
||||
"unitMismatch": "This unit is not offered for the nutrient.",
|
||||
"extract": {
|
||||
"fileType": "Upload a JPEG, PNG, WebP or PDF.",
|
||||
"fileTooLarge": "The file is larger than 10 MB.",
|
||||
"rateLimited": "Too many label reads. Try again in an hour.",
|
||||
"budgetExceeded": "Your AI usage budget for today is reached.",
|
||||
"failed": "The label could not be read. Try a sharper photo."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -367,7 +384,20 @@
|
||||
"immunizationsColDose": "Dose",
|
||||
"immunizationsColLot": "Lot",
|
||||
"courseRange": "Course {start} to {end}",
|
||||
"courseSince": "Course since {start}"
|
||||
"courseSince": "Course since {start}",
|
||||
"lifePackYears": "Pack-years",
|
||||
"lifeAuditC": "AUDIT-C (screening)",
|
||||
"lifeAuditCScore": "{score} of 12 on {date}.",
|
||||
"lifeAuditCAbove": "At or above the cut-off of {cutoff} ({source}).",
|
||||
"lifeAuditCBelow": "Below the cut-off of {cutoff} ({source}).",
|
||||
"lifeAuditCNoCutoff": "No sex-specific cut-off applied.",
|
||||
"lifeActivity": "Physical activity",
|
||||
"lifeActivityValue": "{minutes} minutes a week, self-reported",
|
||||
"lifeSitting": "Sitting on a workday",
|
||||
"lifeSittingValue": "{hours} hours, self-reported",
|
||||
"lifeWork": "Work",
|
||||
"lifeNightWork": "night work",
|
||||
"lifeExposures": "exposures: {list}"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Something went wrong",
|
||||
@@ -525,7 +555,7 @@
|
||||
"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.",
|
||||
"oidcOnly": "This server only allows single sign-on. Continue with your identity provider instead.",
|
||||
"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",
|
||||
@@ -542,7 +572,19 @@
|
||||
"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."
|
||||
"errorOffline": "That did not reach the server. Check your connection and try again.",
|
||||
"ssoContinue": "Continue with {provider}",
|
||||
"ssoStarting": "Redirecting…",
|
||||
"ssoHint": "From then on you sign in here with your {provider} account.",
|
||||
"ssoDivider": "or",
|
||||
"ssoFallbackName": "single sign-on",
|
||||
"errorTitle": "The handover did not go through",
|
||||
"errorSubjectBound": "This sign-in already belongs to another account on this server. Open the link again and use a different account at your identity provider.",
|
||||
"errorEmailTaken": "Your identity provider gave an email address that another account here already uses. Open the link again and use a different account, or ask the server's administrator.",
|
||||
"errorUsernameTaken": "Someone took that username in the meantime. Open the link again and choose another one.",
|
||||
"errorSignedIn": "You were signed in when you came back. Sign out, then open the link again.",
|
||||
"errorSso": "Signing in with your identity provider did not work. Open the link again to try once more.",
|
||||
"signInInstead": "Sign in to that account"
|
||||
},
|
||||
"inviteInvalid": {
|
||||
"title": "Invitation invalid",
|
||||
@@ -2399,6 +2441,34 @@
|
||||
"invalidRange": "The last day cannot be before the first.",
|
||||
"generic": "That did not save. Please try again."
|
||||
}
|
||||
},
|
||||
"composition": {
|
||||
"title": "Composition",
|
||||
"empty": "Add what one tablet or capsule contains to count every dose taken in your micronutrients.",
|
||||
"perUnitNote": "Per tablet or capsule; every dose taken adds this to the day's micronutrients.",
|
||||
"add": "Add",
|
||||
"edit": "Edit",
|
||||
"rowHelper": "What one tablet or capsule contains, for its micronutrients.",
|
||||
"sheetTitle": "What one unit contains",
|
||||
"sheetDescription": "Enter the amounts per tablet or capsule as the label states them.",
|
||||
"nutrientLabel": "Nutrient",
|
||||
"amountLabel": "Amount",
|
||||
"unitLabel": "Unit",
|
||||
"choose": "Choose a nutrient",
|
||||
"addRow": "Add a nutrient",
|
||||
"removeRow": "Remove {name}",
|
||||
"editorEmpty": "No nutrient yet. Add one per line of the label.",
|
||||
"labelPhoto": "Read from a label photo",
|
||||
"labelReading": "Reading the label…",
|
||||
"labelWaiting": "Still waiting for the background worker to pick this up.",
|
||||
"labelReviewNote": "Check each line against the label before you save.",
|
||||
"lowConfidence": "This line was hard to read; check it against the label.",
|
||||
"unmatchedTitle": "Lines without a matching nutrient, to enter by hand if needed:",
|
||||
"servingNote": "The label states: {note}. The amounts are shown per single unit.",
|
||||
"labelError": "The label could not be read",
|
||||
"saveError": "The composition could not be saved",
|
||||
"catalogError": "The list of nutrients could not be loaded",
|
||||
"invalidRow": "Choose a nutrient and enter an amount above zero in every line."
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -2666,7 +2736,8 @@
|
||||
"bmiEmptyAction": "Open account settings",
|
||||
"personalRecord": {
|
||||
"badge": "PR",
|
||||
"tooltip": "Personal record: {value} {unit} on {date}"
|
||||
"tooltip": "Personal record: {value} {unit} on {date}",
|
||||
"benchmark": "Typical at that age: {low} to {high} {unit} ({source})"
|
||||
},
|
||||
"loadError": "Failed to load insights",
|
||||
"heroPersonalBaseline": "Based on your last 90 days",
|
||||
@@ -4220,6 +4291,21 @@
|
||||
"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.",
|
||||
"featureValue": "{field}: {value}"
|
||||
},
|
||||
"patterns": {
|
||||
"title": "Patterns in your mood",
|
||||
"basedOn": "Based on {subject} and {comparison} days. A pattern, not a cause.",
|
||||
"previousDayHigher": "After a below-usual day, the next day tends to be better ({subject} against {comparison} after an above-usual day).",
|
||||
"previousDayLower": "A below-usual day tends to carry over into the next one ({subject} against {comparison} after an above-usual day).",
|
||||
"weekendHigher": "Your mood tends to be higher at the weekend ({subject} against {comparison} on weekdays).",
|
||||
"weekendLower": "Your mood tends to be lower at the weekend ({subject} against {comparison} on weekdays).",
|
||||
"bestWeekday": "{weekday} tends to be your best day ({subject} against {comparison} on the other days).",
|
||||
"notesHigher": "On days with a note your mood tends to be higher ({subject} against {comparison} without one).",
|
||||
"notesLower": "On days with a note your mood tends to be lower ({subject} against {comparison} without one).",
|
||||
"delayedTagHigher": "The day after “{tag}”, your mood tends to be higher ({subject} against {comparison} otherwise).",
|
||||
"delayedTagLower": "The day after “{tag}”, your mood tends to be lower ({subject} against {comparison} otherwise).",
|
||||
"multipleEntriesHigher": "On days you log more than once, your mood tends to be higher ({subject} against {comparison} on single-entry days).",
|
||||
"multipleEntriesLower": "On days you log more than once, your mood tends to be lower ({subject} against {comparison} on single-entry days)."
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
@@ -5459,7 +5545,7 @@
|
||||
"subtitle": "Decide which source wins when two report the same metric.",
|
||||
"nutrients": {
|
||||
"title": "Synced nutrient intake",
|
||||
"description": "Daily totals synced from Apple Health over the last {days} days.",
|
||||
"description": "Daily totals over the last {days} days: what Apple Health synced plus the supplements you took.",
|
||||
"empty": "No nutrient data has been synced yet.",
|
||||
"latest": "{amount} {unit} on {date}",
|
||||
"daysWithData": "{count} of {days} days",
|
||||
@@ -5821,7 +5907,10 @@
|
||||
"remove": "Remove location period",
|
||||
"removeConfirmTitle": "Remove this trip?",
|
||||
"removeConfirmBody": "Readings taken during it stay in your record; only the trip entry goes.",
|
||||
"range": "{start} to {end}"
|
||||
"range": "{start} to {end}",
|
||||
"suggestionsIntro": "Dawarich found days abroad that no period covers yet.",
|
||||
"suggestionUse": "Use",
|
||||
"suggestionDismiss": "Dismiss suggestion"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Backfill past days",
|
||||
@@ -6255,7 +6344,8 @@
|
||||
"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"
|
||||
"cancel": "Cancel",
|
||||
"restingFrugal": "Paused at the frugal AI usage level: documents are read only when you ask."
|
||||
},
|
||||
"centralCodex": {
|
||||
"title": "Use the server's shared AI access",
|
||||
@@ -6313,7 +6403,33 @@
|
||||
"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"
|
||||
"runtimeTitle": "Check and refresh",
|
||||
"usage": {
|
||||
"title": "AI usage",
|
||||
"description": "How much AI work runs in the background for you, while the Coach stays the same at every level.",
|
||||
"levels": {
|
||||
"frugal": {
|
||||
"label": "Frugal",
|
||||
"hint": "Cards refresh at most once a day after a sync, a short daily briefing, documents are read only when you ask."
|
||||
},
|
||||
"standard": {
|
||||
"label": "Standard",
|
||||
"hint": "Cards refresh every few hours after a sync, the full daily briefing."
|
||||
},
|
||||
"full": {
|
||||
"label": "Full",
|
||||
"hint": "Cards refresh up to every hour after new data, as before this setting existed."
|
||||
}
|
||||
},
|
||||
"limited": "Limited by the server operator.",
|
||||
"estimate": "about {tokens} tokens a day, estimated",
|
||||
"measured": "Measured: {tokens} tokens a day over the last {days} days, the Coach included. The figures per level are estimates.",
|
||||
"noEstimate": "An estimate per level appears after three days of AI use.",
|
||||
"capped": "You chose {choice}, which the server does not allow at the moment, so {level} applies. Your choice is kept.",
|
||||
"followsDefault": "You follow the server default ({level}).",
|
||||
"useDefault": "Use the server default ({level})",
|
||||
"saveError": "The level could not be saved. Please try again."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"units": {
|
||||
@@ -6646,7 +6762,14 @@
|
||||
"docs": "Documentation",
|
||||
"credits": "Credits & data attributions",
|
||||
"linksHeading": "Sources & docs",
|
||||
"newerAvailable": "Newer version: {tag}"
|
||||
"newerAvailable": "Newer version: {tag}",
|
||||
"support": {
|
||||
"title": "Support",
|
||||
"description": "A summary for a bug report, without your name, email or health values.",
|
||||
"copy": "Copy support info",
|
||||
"copied": "Copied",
|
||||
"copyFailed": "The support info could not be put together."
|
||||
}
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Test connection",
|
||||
@@ -7133,7 +7256,8 @@
|
||||
"lastReceived": "Last received {relative}",
|
||||
"lastNewValue": "Last new value {relative}",
|
||||
"lastBackground": "Last in background {relative}",
|
||||
"never": "Not received yet"
|
||||
"never": "Not received yet",
|
||||
"lastBackfill": "History caught up {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
@@ -7267,6 +7391,46 @@
|
||||
"failureStreak": "Failure streak: {count} of {threshold}",
|
||||
"copyFailed": "Could not copy. Select the text and copy it by hand."
|
||||
}
|
||||
},
|
||||
"dawarich": "Dawarich",
|
||||
"dawarichTag": "Movement",
|
||||
"dawarichDescription": "Connect your Dawarich instance to bring in daily distance, time away from home and travel days.",
|
||||
"dawarichUrl": "Dawarich address",
|
||||
"dawarichApiKey": "API key",
|
||||
"dawarichApiKeyHelp": "You find it in Dawarich under Account. HealthLog uses it to read daily values only, never coordinates or place names.",
|
||||
"dawarichConnect": "Connect",
|
||||
"dawarichUpdate": "Save changes",
|
||||
"dawarichConnected": "Dawarich connected. The last 30 days are loading now.",
|
||||
"dawarichSync": "Fetch now",
|
||||
"dawarichSyncQueued": "The last seven days will be fetched shortly.",
|
||||
"dawarichSyncAlreadyQueued": "A fetch is already on its way.",
|
||||
"dawarichDisconnect": "Disconnect",
|
||||
"dawarichDisconnectTitle": "Disconnect Dawarich?",
|
||||
"dawarichDisconnectDescription": "Future fetches stop. Days already stored stay.",
|
||||
"dawarichDisconnectDelete": "Disconnect and delete days",
|
||||
"dawarichDisconnectDeleteTitle": "Disconnect and delete every day?",
|
||||
"dawarichDisconnectDeleteDescription": "The connection and every stored movement day are removed. This cannot be undone.",
|
||||
"dawarichHomePlaceSet": "Home is set.",
|
||||
"dawarichHomePlaceMissing": "Set your home so time away from home can be measured.",
|
||||
"dawarichHomeCountryIs": "Home country: {country}.",
|
||||
"dawarichHomeCountryMissing": "No home country yet, so no travel days.",
|
||||
"dawarichDaysStored": "Days stored: {count}",
|
||||
"dawarichHomePlace": "Home",
|
||||
"dawarichHomePlaceChoose": "Choose a place from Dawarich",
|
||||
"dawarichHomePlaceChange": "Choose home",
|
||||
"dawarichHomeCountry": "Home country",
|
||||
"dawarichHomeCountryHelp": "Two-letter country code, such as DE. Left empty, the country you spent most days in over the last year counts.",
|
||||
"dawarichHomeSave": "Save",
|
||||
"dawarichHomeSaved": "Saved.",
|
||||
"dawarichErrors": {
|
||||
"invalid": "That address does not work. Use the instance address without credentials.",
|
||||
"privateOrigin": "This instance is on a private network. Ask the server operator to approve it.",
|
||||
"credentials": "Dawarich did not accept the API key.",
|
||||
"unreachable": "Dawarich cannot be reached. Check the address and that the instance is running.",
|
||||
"rateLimited": "Too many attempts. Wait a moment, then try again.",
|
||||
"homePlaceUnknown": "The Dawarich instance no longer knows this place.",
|
||||
"notConnected": "Dawarich is not connected.",
|
||||
"generic": "That did not work. Try again."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -7343,7 +7507,8 @@
|
||||
"spendToday": "Today, on each account's own calendar day: {total} tokens, {operator} of them on the operator's own key (accounts with spend: {accounts}).",
|
||||
"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."
|
||||
"modelNotServed": "The endpoint no longer offers the model {model}. Choose a model it lists in the AI settings.",
|
||||
"usageLevels": "Accounts per AI usage level: {frugal} frugal, {standard} standard, {full} full."
|
||||
},
|
||||
"aiQuality": {
|
||||
"title": "Insights Quality",
|
||||
@@ -7753,7 +7918,8 @@
|
||||
"eventDetails": "Event details",
|
||||
"closeDetails": "Close details",
|
||||
"processDetail": "Buffer is volatile and capped at 500 entries.",
|
||||
"cardTitle": "Recent events"
|
||||
"cardTitle": "Recent events",
|
||||
"filterErrorId": "Error ID"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
@@ -7840,6 +8006,7 @@
|
||||
"restoreSkippedCoachAttachment": "Coach document link",
|
||||
"restoreSkippedCoachReference": "Coach reference",
|
||||
"restoreSkippedDocumentConditionLink": "Document filing",
|
||||
"restoreSkippedRelationLink": "Link between records",
|
||||
"restoreSkippedExtractedFact": "Document fact",
|
||||
"restoreSkippedFactCommitment": "Fact commitment",
|
||||
"docsLink": "Documentation",
|
||||
@@ -8016,6 +8183,56 @@
|
||||
"started": "Maintenance started.",
|
||||
"alreadyQueued": "A maintenance run is already queued or running.",
|
||||
"startFailed": "Could not start the maintenance."
|
||||
},
|
||||
"pulseDayMean": {
|
||||
"title": "Pulse day mean",
|
||||
"description": "How hours with only a few readings count toward a day's pulse.",
|
||||
"body": "Up to now every hour with a reading counts the same, so a single stray reading can move a whole day. The weighted method lets an hour count fully from three readings on, and a lone reading a third. Run the dry run first to see how many days would change. Applying recalculates the stored pulse days in the background.",
|
||||
"method": {
|
||||
"hourly": "Every hour equal",
|
||||
"weighted": "Sparse hours weighted"
|
||||
},
|
||||
"loading": "Checking the pulse day mean…",
|
||||
"loadError": "Could not read the pulse day-mean status.",
|
||||
"state": {
|
||||
"idle": "No dry run yet.",
|
||||
"queued": "The dry run is waiting for the background worker.",
|
||||
"running": "The dry run is in progress.",
|
||||
"done": "Dry run finished on {at}.",
|
||||
"failed": "The dry run stopped. The app log names the cause; start it again."
|
||||
},
|
||||
"result": {
|
||||
"days": "Days compared",
|
||||
"changed": "Days moving more than 1 bpm",
|
||||
"max": "Largest change",
|
||||
"maxValue": "{value} bpm",
|
||||
"bucket": "{range} bpm",
|
||||
"account": "Account {account}",
|
||||
"accountDays": "{changed} of {days} days move"
|
||||
},
|
||||
"appliedAt": "Weighted method applied on {at}.",
|
||||
"dryRun": "Dry run",
|
||||
"apply": "Apply",
|
||||
"confirmTitle": "Apply the weighted pulse day mean?",
|
||||
"confirmBody": "Every account's pulse days are then computed with sparse hours weighted. The stored pulse days are recalculated in the background, which can take a while on a large instance.",
|
||||
"confirm": "Apply",
|
||||
"cancel": "Cancel",
|
||||
"dryRunStarted": "Dry run started.",
|
||||
"applied": "Weighted method applied. The pulse days are being recalculated.",
|
||||
"appliedNoRebuild": "Weighted method applied. Another run is still working, so apply again once it has finished to recalculate the pulse days.",
|
||||
"error": {
|
||||
"busy": "A dry run or recalculation is still running. Try again once it has finished.",
|
||||
"dryRunRequired": "Run a dry run first. Applying needs one that finished in the last seven days.",
|
||||
"generic": "The request did not go through."
|
||||
},
|
||||
"rebuild": {
|
||||
"queued": "The recalculation is waiting for the background worker.",
|
||||
"running": "The pulse days are being recalculated.",
|
||||
"done": "Pulse days recalculated on {at} for {accounts} accounts.",
|
||||
"failed": "The recalculation stopped. The app log names the cause; run it again."
|
||||
},
|
||||
"rebuildAgain": "Recalculate again",
|
||||
"rebuildStarted": "Recalculation started."
|
||||
}
|
||||
},
|
||||
"carrier": "Carrier",
|
||||
@@ -8120,7 +8337,25 @@
|
||||
"codes": "For the operator: API requests answer 503 with error code {code}, and /api/health reports reason {reason}."
|
||||
},
|
||||
"loginOverviewCardTitle": "Sign-in events",
|
||||
"userColumn": "User"
|
||||
"userColumn": "User",
|
||||
"supportReport": {
|
||||
"title": "Support report",
|
||||
"description": "A summary of this instance for a bug report, without emails, IDs or values.",
|
||||
"body": "Accounts appear as “Account 1”, “Account 2” and so on. Read the report before you share it.",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"download": "Download",
|
||||
"loadError": "The support report could not be created."
|
||||
},
|
||||
"aiUsage": {
|
||||
"title": "AI usage levels",
|
||||
"description": "The highest level accounts may choose and the level they start at.",
|
||||
"maxLabel": "Highest level",
|
||||
"defaultLabel": "Default level",
|
||||
"defaultAboveCeiling": "The default level cannot be above the highest level.",
|
||||
"gapLabel": "Hours between card refreshes after a sync (Standard)",
|
||||
"gapHint": "Whole hours from 1 to 48. Leave empty to use AI_SYNC_REGEN_GAP_HOURS, else 6. In effect now: {hours} h."
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Achievements",
|
||||
@@ -9215,7 +9450,9 @@
|
||||
"periodStartSaved": "Period start saved",
|
||||
"periodEndSaved": "End of period saved",
|
||||
"startedPeriodOnDate": "My period started this day",
|
||||
"endedPeriodOnDate": "My period ended this day"
|
||||
"endedPeriodOnDate": "My period ended this day",
|
||||
"startAbsorbed": "Period start saved. Your start on {absorbed} was merged into {date}.",
|
||||
"startRestored": "Saved. The start on {dates} is back."
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Period likely soon",
|
||||
@@ -9604,12 +9841,16 @@
|
||||
"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."
|
||||
"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.",
|
||||
"alkaline-phosphatase": "Alkaline phosphatase (ALP) is an enzyme found mainly in the liver, the bile ducts and bone. It rises when bile flow is blocked and during phases of active bone building, such as growth in children and adolescents or bone healing. Because both organs release it, a raised value is usually read together with GGT and the other liver markers to tell a liver source from a bone source. It is one of the standard liver-panel markers.",
|
||||
"bilirubin-total": "Total bilirubin is the yellow breakdown product of haemoglobin from worn-out red blood cells, measured as the sum of its unconjugated and conjugated forms. The liver takes it up, conjugates it and excretes it in bile, so the level reflects how well this pathway is working. It can rise with liver disease, blocked bile ducts or increased breakdown of red cells, and harmlessly in Gilbert's syndrome. It is read alongside the other liver markers."
|
||||
},
|
||||
"apob": "Apolipoprotein B (ApoB)",
|
||||
"lp-a": "Lipoprotein(a)",
|
||||
"fasting-insulin": "Fasting insulin",
|
||||
"omega-3-index": "Omega-3 index"
|
||||
"omega-3-index": "Omega-3 index",
|
||||
"alkaline-phosphatase": "Alkaline phosphatase (ALP)",
|
||||
"bilirubin-total": "Total bilirubin"
|
||||
},
|
||||
"noteLoadFailed": "Note could not be loaded; it will be kept unchanged when you save.",
|
||||
"showingLatestOf": "Showing the latest {shown} of {total} readings",
|
||||
@@ -9683,6 +9924,54 @@
|
||||
"fromReport": "Report range {range}",
|
||||
"catalogDiffers": "your range {range}",
|
||||
"printed": "as printed in {unit}: {range}"
|
||||
},
|
||||
"match": {
|
||||
"viaAlias": "by a name you chose before",
|
||||
"viaCatalog": "recognised as a common abbreviation",
|
||||
"chooseLabel": "Belongs to",
|
||||
"suggestionOption": "{name} ({unit})",
|
||||
"newMarkerOption": "A new biomarker",
|
||||
"rememberHint": "This name will be remembered for the next report.",
|
||||
"chooseHint": "Pick a biomarker if this is one you already track."
|
||||
},
|
||||
"aliases": {
|
||||
"title": "Also written as",
|
||||
"taken": "{alias} already belongs to another biomarker.",
|
||||
"saveError": "The names could not be saved. Try again.",
|
||||
"remove": "Remove {alias}",
|
||||
"empty": "Add the names other labs print for {name}, so their reports land here.",
|
||||
"placeholder": "e.g. Alk.P",
|
||||
"addLabel": "Another name for this biomarker",
|
||||
"add": "Add",
|
||||
"alsoWritten": "Also written as {aliases}"
|
||||
},
|
||||
"merge": {
|
||||
"open": "Merge",
|
||||
"title": "Merge biomarkers",
|
||||
"description": "Combine markers that are the same test written differently.",
|
||||
"pickHint": "Select at least two biomarkers.",
|
||||
"keepLabel": "Keep",
|
||||
"keepHint": "The others move into this one and their names become its other names.",
|
||||
"showPreview": "Show preview",
|
||||
"back": "Back",
|
||||
"confirm": "Merge now",
|
||||
"previewSummaryOne": "{count} reading moves to {name} ({unit}).",
|
||||
"previewSummaryFew": "{count} readings move to {name} ({unit}).",
|
||||
"previewSummaryOther": "{count} readings move to {name} ({unit}).",
|
||||
"movesOne": "{count} reading, already in the same unit",
|
||||
"movesFew": "{count} readings, already in the same unit",
|
||||
"movesOther": "{count} readings, already in the same unit",
|
||||
"convertedOne": "{count} reading, converted from {unit} to {targetUnit}",
|
||||
"convertedFew": "{count} readings, converted from {unit} to {targetUnit}",
|
||||
"convertedOther": "{count} readings, converted from {unit} to {targetUnit}",
|
||||
"refused": "{unit} cannot be converted to {targetUnit}.",
|
||||
"aliasesAdded": "These names will lead to the kept biomarker:",
|
||||
"blocked": "Nothing can be merged while a unit cannot be converted. Leave that biomarker out or keep another one.",
|
||||
"noUndo": "A merge cannot be undone.",
|
||||
"doneToastOne": "Merged into {name}, {count} reading moved",
|
||||
"doneToastFew": "Merged into {name}, {count} readings moved",
|
||||
"doneToastOther": "Merged into {name}, {count} readings moved",
|
||||
"error": "The biomarkers could not be merged. Try again."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -10644,25 +10933,11 @@
|
||||
},
|
||||
"profileFacts": {
|
||||
"cardTitle": "Lifestyle context",
|
||||
"cardDescription": "Record the current facts that change how your health readings should be interpreted.",
|
||||
"smokingLabel": "Smoking status",
|
||||
"alcoholLabel": "Alcohol pattern",
|
||||
"shiftLabel": "Work schedule",
|
||||
"notRecorded": "Not recorded",
|
||||
"unreadable": "Stored value could not be read",
|
||||
"save": "Save",
|
||||
"savedToast": "Lifestyle context saved",
|
||||
"savePartialError": "Couldn't save {kinds}. Please try again.",
|
||||
"saveKindError": "Not saved",
|
||||
"loadError": "Could not load lifestyle context",
|
||||
"remove": "Remove {kind}",
|
||||
"removeShort": "Remove",
|
||||
"removeConfirmTitle": "Remove {kind}?",
|
||||
"removeConfirmDescription": "The current value will become not recorded. Its dated history will be retained.",
|
||||
"removedToast": "{kind} removed from current lifestyle context",
|
||||
"removeError": "Could not remove {kind}",
|
||||
"historyTitle": "Previous values",
|
||||
"to": "to",
|
||||
"values": {
|
||||
"SMOKING_STATUS": {
|
||||
"NEVER": "Never smoked",
|
||||
@@ -10842,6 +11117,235 @@
|
||||
"savedToast": "Emergency information saved",
|
||||
"saveError": "Couldn't save your emergency information.",
|
||||
"loadError": "Couldn't load your emergency information."
|
||||
},
|
||||
"life": {
|
||||
"cardTitle": "Life circumstances",
|
||||
"cardDescription": "Smoking, alcohol, activity, work, habits and goals, dated and each optional.",
|
||||
"loadError": "Your life circumstances could not be loaded.",
|
||||
"noAnswer": "No answer",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"validSince": "Valid since",
|
||||
"month": "Month",
|
||||
"year": "Year",
|
||||
"lastConfirmed": "Last confirmed on {date}",
|
||||
"notAnswered": "Not answered yet",
|
||||
"unreadable": "This answer cannot be read on this server.",
|
||||
"save": "Save section",
|
||||
"savedToast": "Section saved",
|
||||
"saveError": "The section could not be saved.",
|
||||
"review": {
|
||||
"question": "Is this still true? Your last answer is more than a year old.",
|
||||
"confirm": "Still true",
|
||||
"confirmedToast": "Thanks, marked as still true",
|
||||
"confirmError": "The confirmation could not be saved."
|
||||
},
|
||||
"ai": {
|
||||
"label": "Share this section with the AI",
|
||||
"shiftLabel": "Share shift work with the AI",
|
||||
"hint": "Off means the Coach and the briefings never see these answers.",
|
||||
"savedToast": "AI sharing updated",
|
||||
"saveError": "The AI setting could not be saved."
|
||||
},
|
||||
"sections": {
|
||||
"tobacco": {
|
||||
"title": "Smoking and nicotine"
|
||||
},
|
||||
"alcohol": {
|
||||
"title": "Alcohol"
|
||||
},
|
||||
"activity": {
|
||||
"title": "Activity and sitting"
|
||||
},
|
||||
"work": {
|
||||
"title": "Work and rhythm"
|
||||
},
|
||||
"habits": {
|
||||
"title": "Sleep, diet and caffeine"
|
||||
},
|
||||
"goals": {
|
||||
"title": "Goals"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"smokingStatus": "Smoking status",
|
||||
"products": "Products used",
|
||||
"cigarettesPerDay": "Cigarettes per day",
|
||||
"startYear": "Year you started",
|
||||
"quitMonth": "When you stopped",
|
||||
"secondhandSmoke": "Regular secondhand smoke",
|
||||
"moderateDaysPerWeek": "Days per week with moderate to strenuous activity, such as brisk walking",
|
||||
"moderateMinutesPerDay": "Minutes on those days, on average",
|
||||
"sittingHoursWorkday": "Hours sitting on a workday",
|
||||
"shiftSchedule": "Shift work",
|
||||
"situation": "Work situation",
|
||||
"physicalNature": "Kind of work",
|
||||
"nightWork": "Night work",
|
||||
"exposures": "Exposures at work",
|
||||
"freeWeekdays": "Free days of the week",
|
||||
"napRegularly": "Regular nap",
|
||||
"snoringReported": "Others say you snore",
|
||||
"dietPattern": "Diet",
|
||||
"dietSpecifics": "Specifics",
|
||||
"lateMealsPerWeek": "Meals after 9 pm per week",
|
||||
"fruitVegPortionsPerDay": "Portions of fruit and vegetables per day",
|
||||
"caffeineDrinksPerDay": "Caffeinated drinks per day",
|
||||
"latestCaffeine": "Latest caffeine of the day",
|
||||
"goals": "What you want to work on",
|
||||
"goalsHint": "Up to three."
|
||||
},
|
||||
"values": {
|
||||
"products": {
|
||||
"cigarettes": "Cigarettes",
|
||||
"cigars_pipe": "Cigars or pipe",
|
||||
"e_cigarette": "E-cigarette",
|
||||
"heated_tobacco": "Heated tobacco",
|
||||
"snus_pouches": "Snus or nicotine pouches"
|
||||
},
|
||||
"situation": {
|
||||
"none": "Not in paid work",
|
||||
"indoor": "Mostly indoors",
|
||||
"outdoor": "Mostly outdoors",
|
||||
"mixed": "Indoors and outdoors"
|
||||
},
|
||||
"physicalNature": {
|
||||
"mostly_sitting": "Mostly sitting",
|
||||
"standing_walking": "Standing or walking",
|
||||
"heavy_physical": "Heavy physical work"
|
||||
},
|
||||
"exposures": {
|
||||
"heat": "Heat",
|
||||
"cold": "Cold",
|
||||
"dust": "Dust",
|
||||
"fumes_solvents": "Fumes or solvents",
|
||||
"noise": "Noise"
|
||||
},
|
||||
"dietPattern": {
|
||||
"mixed": "Mixed diet",
|
||||
"vegetarian": "Vegetarian",
|
||||
"vegan": "Vegan",
|
||||
"pescetarian": "Pescetarian",
|
||||
"other": "Other"
|
||||
},
|
||||
"dietSpecifics": {
|
||||
"intermittent_fasting": "Intermittent fasting",
|
||||
"low_carb": "Low carb",
|
||||
"prescribed_diet": "Diet prescribed by a doctor"
|
||||
},
|
||||
"latestCaffeine": {
|
||||
"before_12": "Before noon",
|
||||
"12_15": "Noon to 3 pm",
|
||||
"15_18": "3 to 6 pm",
|
||||
"after_18": "After 6 pm"
|
||||
},
|
||||
"goals": {
|
||||
"weight": "Weight",
|
||||
"blood_pressure": "Blood pressure",
|
||||
"blood_sugar": "Blood sugar",
|
||||
"sleep": "Sleep",
|
||||
"fitness": "Fitness",
|
||||
"stress_recovery": "Stress and recovery",
|
||||
"medication_routine": "Taking medication regularly",
|
||||
"quit_smoking": "Stop smoking",
|
||||
"less_alcohol": "Drink less alcohol"
|
||||
},
|
||||
"weekdays": {
|
||||
"1": "Mon",
|
||||
"2": "Tue",
|
||||
"3": "Wed",
|
||||
"4": "Thu",
|
||||
"5": "Fri",
|
||||
"6": "Sat",
|
||||
"7": "Sun"
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
"packYears": "{value} pack-years",
|
||||
"packYearsMissing": "Pack-years need cigarettes per day and the year you started.",
|
||||
"activityMinutes": "{value} minutes a week.",
|
||||
"meetsWho": "That meets the WHO recommendation of 150 minutes a week.",
|
||||
"belowWho": "That is below the WHO recommendation of 150 minutes a week.",
|
||||
"measuredSteps": "Your measured steps come first; this answer adds context.",
|
||||
"freeDaysDefault": "Without an answer, Saturday and Sunday count as free days."
|
||||
},
|
||||
"auditC": {
|
||||
"intro": "Three questions on alcohol from the WHO AUDIT. A screening, not a diagnosis.",
|
||||
"start": "Answer the questions",
|
||||
"again": "Answer again",
|
||||
"offerDue": "A quick look once a year is enough.",
|
||||
"notValidated": "There is no validated translation for your language yet, so the questions are shown in English.",
|
||||
"submit": "Save answers",
|
||||
"savedToast": "Answers saved",
|
||||
"saveError": "The answers could not be saved.",
|
||||
"result": "{score} of 12 points",
|
||||
"above": "At or above the screening cut-off of {cutoff}.",
|
||||
"below": "Below the screening cut-off of {cutoff}.",
|
||||
"noCutoff": "Without a sex in your profile only the sum is shown.",
|
||||
"source": "Source: {source}",
|
||||
"history": "Earlier entries",
|
||||
"remove": "Remove entry",
|
||||
"removeConfirmTitle": "Remove this entry?",
|
||||
"removeConfirmBody": "The entry leaves your history. This cannot be undone here.",
|
||||
"removedToast": "Entry removed",
|
||||
"removeError": "The entry could not be removed.",
|
||||
"historyEntry": "{date}: {score} of 12",
|
||||
"legacyLabel": "Earlier answer: {value}",
|
||||
"legacyHint": "From the short alcohol question asked before this questionnaire. Once a questionnaire result is on file, the Coach no longer reads it.",
|
||||
"legacyRemove": "Remove earlier answer",
|
||||
"legacyRemoveConfirmTitle": "Remove the earlier answer?",
|
||||
"legacyRemoveConfirmBody": "The earlier answer leaves your record. Questionnaire results stay.",
|
||||
"legacyRemovedToast": "Earlier answer removed",
|
||||
"legacyRemoveError": "The earlier answer could not be removed."
|
||||
},
|
||||
"takeOver": {
|
||||
"conditionsButton": "Add to condition journal",
|
||||
"allergiesButton": "Add to allergies",
|
||||
"conditionsTitle": "Add a chronic condition",
|
||||
"allergiesTitle": "Add an allergy",
|
||||
"description": "Your free text stays as it is until you clear it yourself.",
|
||||
"freeTextLabel": "Your free text",
|
||||
"conditionLabel": "Condition",
|
||||
"onset": "Since",
|
||||
"save": "Add",
|
||||
"savedToast": "Entry added",
|
||||
"saveError": "The entry could not be added.",
|
||||
"clearQuestion": "The entry is saved. Clear the free text now?",
|
||||
"clear": "Clear free text",
|
||||
"keep": "Keep it",
|
||||
"clearedToast": "Free text cleared",
|
||||
"clearError": "The free text could not be cleared."
|
||||
},
|
||||
"screening": {
|
||||
"cardTitle": "Screening hints",
|
||||
"cardDescription": "From your life circumstances, with the rule and the country it comes from.",
|
||||
"noReminder": "This is a hint only; set a reminder yourself if you want one.",
|
||||
"source": "Source: {source}",
|
||||
"titles": {
|
||||
"lung_ldct": "Low-dose CT for lung cancer",
|
||||
"aaa_ultrasound": "Ultrasound of the abdominal aorta"
|
||||
},
|
||||
"status": {
|
||||
"suggested": "May apply to you",
|
||||
"not_eligible": "Does not apply to you",
|
||||
"unknown": "Not enough information"
|
||||
},
|
||||
"reasons": {
|
||||
"never_smoked": "You have never smoked.",
|
||||
"missing_birth_date": "Your birth date is missing.",
|
||||
"age_out_of_range": "Your age is outside the range.",
|
||||
"missing_smoking_status": "Your smoking status is missing.",
|
||||
"no_cigarettes": "The rule counts cigarettes only.",
|
||||
"missing_smoking_history": "Cigarettes per day or the start year is missing.",
|
||||
"pack_years_below_15": "Fewer than 15 pack-years.",
|
||||
"pack_years_below_20": "Fewer than 20 pack-years.",
|
||||
"smoked_years_below_25": "Fewer than 25 years of smoking.",
|
||||
"missing_quit_date": "The date you stopped is missing.",
|
||||
"quit_over_10_years": "You stopped more than 10 years ago.",
|
||||
"quit_over_15_years": "You stopped more than 15 years ago.",
|
||||
"missing_sex": "Your sex is missing in the profile.",
|
||||
"sex_not_male": "The rule applies to men."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
@@ -10875,6 +11379,10 @@
|
||||
"mugwort": "mugwort",
|
||||
"olive": "olive",
|
||||
"ragweed": "ragweed"
|
||||
},
|
||||
"movement": {
|
||||
"awayFromHome": "Time away from home",
|
||||
"distance": "Distance travelled"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -11122,7 +11630,8 @@
|
||||
"extractEmpty": "No values could be extracted from this document.",
|
||||
"failUnitRequired": "A numeric value needs a unit before it can be saved.",
|
||||
"failUnitMismatch": "The stated unit does not match the saved unit for this marker. Adjust it, then finish the review again.",
|
||||
"failGeneric": "This value could not be saved."
|
||||
"failGeneric": "This value could not be saved.",
|
||||
"failUnknownBiomarker": "The chosen biomarker no longer exists. Pick another one."
|
||||
},
|
||||
"contentIndex": {
|
||||
"indexPrompt": "Make document contents searchable.",
|
||||
@@ -11344,7 +11853,9 @@
|
||||
"molybdenum": "Molybdenum",
|
||||
"iodine": "Iodine",
|
||||
"water": "Water",
|
||||
"caffeine": "Caffeine"
|
||||
"caffeine": "Caffeine",
|
||||
"dha": "DHA (omega-3)",
|
||||
"epa": "EPA (omega-3)"
|
||||
},
|
||||
"page": {
|
||||
"title": "Nutrient intake",
|
||||
@@ -11376,9 +11887,26 @@
|
||||
"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"
|
||||
"loadError": "Your nutrient data could not be loaded",
|
||||
"sourcePart": "{source} {value} {unit}",
|
||||
"aboveUpperLimit": "Above the tolerable upper intake level for adults of {value} {unit} a day ({source}).",
|
||||
"aboveUpperLimitSupplemental": "The supplement share is above the tolerable upper intake level for adults of {value} {unit} a day for supplements ({source}).",
|
||||
"upperLimitForm": {
|
||||
"retinol": "The level covers preformed vitamin A (retinol) only: if the amount comes from beta-carotene, it does not apply.",
|
||||
"folic_acid": "The level covers folic acid from supplements and fortified foods, not the folate in ordinary food.",
|
||||
"magnesium_salts": "The level covers readily dissociable magnesium salts in supplements, not the magnesium in food.",
|
||||
"alpha_tocopherol": "The level is set for alpha-tocopherol."
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"units": {
|
||||
"IU": "IU"
|
||||
},
|
||||
"sources": {
|
||||
"APPLE_HEALTH": "Synced",
|
||||
"MANUAL": "Entered",
|
||||
"SUPPLEMENT": "Supplements"
|
||||
}
|
||||
},
|
||||
"daily": {
|
||||
"line": {
|
||||
@@ -11880,9 +12408,11 @@
|
||||
"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.",
|
||||
"oidcOnly": "Handing over a profile is not available on this server.",
|
||||
"loadError": "The handover status could not be loaded.",
|
||||
"entryHint": "When {name} is ready to look after this record themselves."
|
||||
"entryHint": "When {name} is ready to look after this record themselves.",
|
||||
"methodSso": "{name} signs in through single sign-on when they use the link.",
|
||||
"methodBoth": "{name} can set a password or sign in through single sign-on."
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
@@ -12008,7 +12538,11 @@
|
||||
"customVaccineDelete": "{name} removed an own vaccine",
|
||||
"lifeEventCreate": "{name} added a life event",
|
||||
"lifeEventUpdate": "{name} changed a life event",
|
||||
"lifeEventDelete": "{name} removed a life event"
|
||||
"lifeEventDelete": "{name} removed a life event",
|
||||
"biomarkerMerge": "{name} merged biomarkers",
|
||||
"medicationIngredientsUpdate": "{name} changed what a supplement contains",
|
||||
"auditCCreate": "{name} answered the AUDIT-C questions",
|
||||
"auditCDelete": "{name} removed an AUDIT-C entry"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
@@ -12227,7 +12761,34 @@
|
||||
"removed": "Unlinked {name}",
|
||||
"linked": "Linked",
|
||||
"loadNewer": "Load newer",
|
||||
"loadOlder": "Load older"
|
||||
"loadOlder": "Load older",
|
||||
"period": {
|
||||
"showing": "Showing {period}"
|
||||
}
|
||||
},
|
||||
"related": {
|
||||
"title": "Related",
|
||||
"kind": {
|
||||
"labResult": "Lab result",
|
||||
"encounter": "Visit",
|
||||
"document": "Document",
|
||||
"conditionEpisode": "Condition",
|
||||
"medication": "Medication",
|
||||
"vaccination": "Vaccination"
|
||||
},
|
||||
"via": {
|
||||
"encounter": "through a visit",
|
||||
"efficacyTarget": "through the efficacy target"
|
||||
},
|
||||
"pick": {
|
||||
"conditionEpisode": "Conditions",
|
||||
"document": "Documents",
|
||||
"encounter": "Visits",
|
||||
"medication": "Medications",
|
||||
"vaccination": "Vaccinations"
|
||||
},
|
||||
"nothingToOffer": "Nothing to link yet.",
|
||||
"saveError": "The link could not be saved."
|
||||
}
|
||||
},
|
||||
"bodySites": {
|
||||
@@ -12369,7 +12930,8 @@
|
||||
"scores": "Scores",
|
||||
"running": "Running that day",
|
||||
"values": "Values",
|
||||
"happened": "What happened"
|
||||
"happened": "What happened",
|
||||
"movement": "On the move"
|
||||
},
|
||||
"notable": {
|
||||
"extremeHigh": "Highest daily value since {since}",
|
||||
@@ -12425,7 +12987,18 @@
|
||||
"today": "Today",
|
||||
"hasEntries": "has entries",
|
||||
"dayNSince": "Day {n}, since {date}",
|
||||
"sinceDate": "Since {date}"
|
||||
"sinceDate": "Since {date}",
|
||||
"movement": {
|
||||
"total": "Distance",
|
||||
"driving": "By car",
|
||||
"train": "By train",
|
||||
"places": "Places visited",
|
||||
"away": "Away from home",
|
||||
"travel": "Travel day",
|
||||
"travelYes": "Yes",
|
||||
"kilometres": "{value} km",
|
||||
"miles": "{value} mi"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Timeline",
|
||||
@@ -12760,5 +13333,13 @@
|
||||
"lastUpdated": "Last updated: {date}",
|
||||
"project": "Project",
|
||||
"credits": "Credits"
|
||||
},
|
||||
"series": {
|
||||
"reference": {
|
||||
"targetUpper": "Your target, upper limit",
|
||||
"targetLower": "Your target, lower limit",
|
||||
"clinicalUpper": "Clinical upper limit",
|
||||
"clinicalLower": "Clinical lower limit"
|
||||
}
|
||||
}
|
||||
}
|
||||
+622
-41
@@ -51,7 +51,10 @@
|
||||
"durationHoursMinutes": "{hours} h {minutes} min",
|
||||
"durationMinutes": "{minutes} min",
|
||||
"durationClockMinutes": "{time} min",
|
||||
"durationClockHours": "{time} h"
|
||||
"durationClockHours": "{time} h",
|
||||
"errorIdLabel": "ID de error",
|
||||
"errorIdCopy": "Copiar ID de error",
|
||||
"errorIdCopied": "ID de error copiado"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -154,6 +157,20 @@
|
||||
"event": {
|
||||
"notFound": "Esta entrada ya no existe."
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
"notFound": "Este medicamento ya no está disponible.",
|
||||
"ingredients": {
|
||||
"invalid": "Revisa la composición e inténtalo de nuevo.",
|
||||
"unitMismatch": "Esta unidad no está disponible para el nutriente.",
|
||||
"extract": {
|
||||
"fileType": "Sube un JPEG, PNG, WebP o PDF.",
|
||||
"fileTooLarge": "El archivo supera los 10 MB.",
|
||||
"rateLimited": "Demasiadas lecturas de etiquetas. Vuelve a intentarlo dentro de una hora.",
|
||||
"budgetExceeded": "Has alcanzado tu presupuesto de IA de hoy.",
|
||||
"failed": "No se pudo leer la etiqueta. Prueba con una foto más nítida."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -367,7 +384,20 @@
|
||||
"notes": "Notas adicionales"
|
||||
},
|
||||
"courseRange": "Tratamiento del {start} al {end}",
|
||||
"courseSince": "Tratamiento desde el {start}"
|
||||
"courseSince": "Tratamiento desde el {start}",
|
||||
"lifePackYears": "Paquetes-año",
|
||||
"lifeAuditC": "AUDIT-C (cribado)",
|
||||
"lifeAuditCScore": "{score} de 12 el {date}.",
|
||||
"lifeAuditCAbove": "En o por encima del punto de corte de {cutoff} ({source}).",
|
||||
"lifeAuditCBelow": "Por debajo del punto de corte de {cutoff} ({source}).",
|
||||
"lifeAuditCNoCutoff": "Sin punto de corte específico por sexo.",
|
||||
"lifeActivity": "Actividad física",
|
||||
"lifeActivityValue": "{minutes} minutos a la semana, declarado",
|
||||
"lifeSitting": "Tiempo sentado en día laborable",
|
||||
"lifeSittingValue": "{hours} horas, declarado",
|
||||
"lifeWork": "Trabajo",
|
||||
"lifeNightWork": "trabajo nocturno",
|
||||
"lifeExposures": "exposiciones: {list}"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Algo ha salido mal",
|
||||
@@ -525,7 +555,7 @@
|
||||
"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.",
|
||||
"oidcOnly": "Este servidor solo permite el inicio de sesión único. Continúa con tu proveedor de identidad.",
|
||||
"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",
|
||||
@@ -542,7 +572,19 @@
|
||||
"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."
|
||||
"errorOffline": "No se llegó al servidor. Comprueba tu conexión y vuelve a intentarlo.",
|
||||
"ssoContinue": "Continuar con {provider}",
|
||||
"ssoStarting": "Redirigiendo…",
|
||||
"ssoHint": "A partir de entonces inicias sesión aquí con tu cuenta de {provider}.",
|
||||
"ssoDivider": "o",
|
||||
"ssoFallbackName": "inicio de sesión único",
|
||||
"errorTitle": "La entrega no se ha completado",
|
||||
"errorSubjectBound": "Este inicio de sesión ya pertenece a otra cuenta de este servidor. Vuelve a abrir el enlace y usa otra cuenta en tu proveedor de identidad.",
|
||||
"errorEmailTaken": "Tu proveedor de identidad ha enviado una dirección de correo que ya usa otra cuenta aquí. Vuelve a abrir el enlace y usa otra cuenta, o pregunta a quien administra el servidor.",
|
||||
"errorUsernameTaken": "Alguien ha cogido ese nombre de usuario mientras tanto. Vuelve a abrir el enlace y elige otro.",
|
||||
"errorSignedIn": "Tenías una sesión iniciada al volver. Cierra la sesión y vuelve a abrir el enlace.",
|
||||
"errorSso": "No se ha podido iniciar sesión con tu proveedor de identidad. Vuelve a abrir el enlace para intentarlo otra vez.",
|
||||
"signInInstead": "Iniciar sesión en esa cuenta"
|
||||
},
|
||||
"inviteInvalid": {
|
||||
"title": "Invitación no válida",
|
||||
@@ -2399,6 +2441,34 @@
|
||||
"invalidRange": "El último día no puede ser anterior al primero.",
|
||||
"generic": "No se ha guardado. Inténtalo de nuevo."
|
||||
}
|
||||
},
|
||||
"composition": {
|
||||
"title": "Composición",
|
||||
"empty": "Añade lo que contiene un comprimido o una cápsula para que cada dosis tomada cuente en tus micronutrientes.",
|
||||
"perUnitNote": "Por comprimido o cápsula; cada dosis tomada se suma a los micronutrientes del día.",
|
||||
"add": "Añadir",
|
||||
"edit": "Editar",
|
||||
"rowHelper": "Lo que contiene un comprimido o una cápsula, para los micronutrientes.",
|
||||
"sheetTitle": "Lo que contiene una unidad",
|
||||
"sheetDescription": "Introduce las cantidades por comprimido o cápsula tal como figuran en la etiqueta.",
|
||||
"nutrientLabel": "Nutriente",
|
||||
"amountLabel": "Cantidad",
|
||||
"unitLabel": "Unidad",
|
||||
"choose": "Elige un nutriente",
|
||||
"addRow": "Añadir un nutriente",
|
||||
"removeRow": "Quitar {name}",
|
||||
"editorEmpty": "Aún no hay nutrientes. Añade uno por cada línea de la etiqueta.",
|
||||
"labelPhoto": "Leer de una foto de la etiqueta",
|
||||
"labelReading": "Leyendo la etiqueta…",
|
||||
"labelWaiting": "El proceso en segundo plano aún no ha empezado con la etiqueta.",
|
||||
"labelReviewNote": "Comprueba cada línea con la etiqueta antes de guardar.",
|
||||
"lowConfidence": "Esta línea era difícil de leer; compruébala con la etiqueta.",
|
||||
"unmatchedTitle": "Líneas sin nutriente correspondiente, para introducir a mano si hace falta:",
|
||||
"servingNote": "La etiqueta indica: {note}. Las cantidades se muestran por unidad.",
|
||||
"labelError": "No se pudo leer la etiqueta",
|
||||
"saveError": "No se pudo guardar la composición",
|
||||
"catalogError": "No se pudo cargar la lista de nutrientes",
|
||||
"invalidRow": "Elige un nutriente e introduce una cantidad mayor que cero en cada línea."
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -2666,7 +2736,8 @@
|
||||
"bmiEmptyAction": "Abrir ajustes de cuenta",
|
||||
"personalRecord": {
|
||||
"badge": "PR",
|
||||
"tooltip": "Récord personal: {value} {unit} el {date}"
|
||||
"tooltip": "Récord personal: {value} {unit} el {date}",
|
||||
"benchmark": "Lo típico a esa edad: de {low} a {high} {unit} ({source})"
|
||||
},
|
||||
"loadError": "No se han podido cargar los análisis",
|
||||
"heroPersonalBaseline": "Basado en tus últimos 90 días",
|
||||
@@ -4220,6 +4291,21 @@
|
||||
"learning": "Fase de aprendizaje: {entries} de {threshold} días registrados. La comparación aparece cuando haya suficientes.",
|
||||
"noPattern": "En los {entries} días registrados hasta ahora no hay nada lo bastante constante para comparar un día. Cuando anotas también el contexto del día, se puede comparar una parte mayor.",
|
||||
"featureValue": "{field}: {value}"
|
||||
},
|
||||
"patterns": {
|
||||
"title": "Patrones en tu estado de ánimo",
|
||||
"basedOn": "Basado en {subject} y {comparison} días. Un patrón, no una causa.",
|
||||
"previousDayHigher": "Después de un día por debajo de lo habitual, el siguiente suele ser mejor ({subject} frente a {comparison} tras un día por encima).",
|
||||
"previousDayLower": "Un día por debajo de lo habitual suele arrastrarse al siguiente ({subject} frente a {comparison} tras un día por encima).",
|
||||
"weekendHigher": "Tu estado de ánimo suele ser mejor el fin de semana ({subject} frente a {comparison} entre semana).",
|
||||
"weekendLower": "Tu estado de ánimo suele ser peor el fin de semana ({subject} frente a {comparison} entre semana).",
|
||||
"bestWeekday": "{weekday} suele ser tu mejor día ({subject} frente a {comparison} el resto de días).",
|
||||
"notesHigher": "Los días con una nota tu estado de ánimo suele ser mejor ({subject} frente a {comparison} sin nota).",
|
||||
"notesLower": "Los días con una nota tu estado de ánimo suele ser peor ({subject} frente a {comparison} sin nota).",
|
||||
"delayedTagHigher": "El día después de «{tag}», tu estado de ánimo suele ser mejor ({subject} frente a {comparison} en otros casos).",
|
||||
"delayedTagLower": "El día después de «{tag}», tu estado de ánimo suele ser peor ({subject} frente a {comparison} en otros casos).",
|
||||
"multipleEntriesHigher": "Los días que registras más de una vez, tu estado de ánimo suele ser mejor ({subject} frente a {comparison} con un solo registro).",
|
||||
"multipleEntriesLower": "Los días que registras más de una vez, tu estado de ánimo suele ser peor ({subject} frente a {comparison} con un solo registro)."
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
@@ -5459,7 +5545,7 @@
|
||||
"subtitle": "Decide qué fuente prevalece cuando dos informan la misma métrica.",
|
||||
"nutrients": {
|
||||
"title": "Ingesta de nutrientes sincronizada",
|
||||
"description": "Totales diarios sincronizados desde Apple Health en los últimos {days} días.",
|
||||
"description": "Totales diarios de los últimos {days} días: lo que sincronizó Apple Health más los suplementos que tomaste.",
|
||||
"empty": "Todavía no se han sincronizado datos de nutrientes.",
|
||||
"latest": "{amount} {unit} el {date}",
|
||||
"daysWithData": "{count} de {days} días",
|
||||
@@ -5821,7 +5907,10 @@
|
||||
"remove": "Eliminar periodo de ubicación",
|
||||
"removeConfirmTitle": "¿Quitar este viaje?",
|
||||
"removeConfirmBody": "Las mediciones de ese periodo permanecen; solo desaparece la entrada del viaje.",
|
||||
"range": "Del {start} al {end}"
|
||||
"range": "Del {start} al {end}",
|
||||
"suggestionsIntro": "Dawarich encontró días en el extranjero que aún no cubre ningún periodo.",
|
||||
"suggestionUse": "Usar",
|
||||
"suggestionDismiss": "Descartar sugerencia"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Rellenar días pasados",
|
||||
@@ -6255,7 +6344,8 @@
|
||||
"subLabel": "Los documentos son leídos e indexados por tu proveedor de IA configurado, sin confirmar cada documento.",
|
||||
"honesty": "Cuando está activado, cada documento que subes se envía a tu proveedor de IA configurado para leerlo, describirlo e indexarlo para la búsqueda, sin confirmación por documento. Activarlo también actúa de forma retroactiva: los documentos que ya has subido y que aún no tienen resumen también se envían, hasta 200 cada vez que lo activas. Esto ocurre en el servidor y el contenido del documento sale de esta máquina hacia ese proveedor. Si tu proveedor configurado es una conexión de suscripción (una cuenta de IA con sesión iniciada en lugar de una clave de API), se aplican sus propios ajustes de datos: los proveedores de suscripción pueden usar el contenido para mejorar sus modelos, y ningún acuerdo de tratamiento de datos lo cubre. Los documentos leídos por un modelo local autoalojado nunca salen de tu servidor. Desactivado de forma predeterminada; actívalo solo si aceptas este compromiso para tus documentos.",
|
||||
"confirm": "Entendido: activar",
|
||||
"cancel": "Cancelar"
|
||||
"cancel": "Cancelar",
|
||||
"restingFrugal": "En pausa en el nivel de ahorro de IA: los documentos solo se leen cuando lo pides."
|
||||
},
|
||||
"centralCodex": {
|
||||
"title": "Usar el acceso de IA compartido del servidor",
|
||||
@@ -6313,7 +6403,33 @@
|
||||
"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"
|
||||
"runtimeTitle": "Comprobar y actualizar",
|
||||
"usage": {
|
||||
"title": "Uso de la IA",
|
||||
"description": "Cuánto trabajo de IA se hace en segundo plano para ti, mientras el Coach sigue igual en todos los niveles.",
|
||||
"levels": {
|
||||
"frugal": {
|
||||
"label": "Ahorro",
|
||||
"hint": "Las tarjetas se actualizan como mucho una vez al día tras una sincronización, un resumen diario breve, los documentos solo se leen cuando lo pides."
|
||||
},
|
||||
"standard": {
|
||||
"label": "Estándar",
|
||||
"hint": "Las tarjetas se actualizan cada pocas horas tras una sincronización, el resumen diario completo."
|
||||
},
|
||||
"full": {
|
||||
"label": "Completo",
|
||||
"hint": "Las tarjetas se actualizan hasta cada hora con datos nuevos, como antes de existir este ajuste."
|
||||
}
|
||||
},
|
||||
"limited": "Limitado por quien administra el servidor.",
|
||||
"estimate": "unos {tokens} tokens al día, estimado",
|
||||
"measured": "Medido: {tokens} tokens al día en los últimos {days} días, Coach incluido. Las cifras por nivel son estimaciones.",
|
||||
"noEstimate": "La estimación por nivel aparece tras tres días de uso de la IA.",
|
||||
"capped": "Elegiste {choice}, que el servidor no permite ahora mismo, así que se aplica {level}. Tu elección se conserva.",
|
||||
"followsDefault": "Sigues el valor predeterminado del servidor ({level}).",
|
||||
"useDefault": "Usar el valor predeterminado del servidor ({level})",
|
||||
"saveError": "No se pudo guardar el nivel. Inténtalo de nuevo."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"units": {
|
||||
@@ -6646,7 +6762,14 @@
|
||||
"docs": "Documentación",
|
||||
"credits": "Créditos y atribuciones de datos",
|
||||
"linksHeading": "Fuentes y documentación",
|
||||
"newerAvailable": "Nueva versión: {tag}"
|
||||
"newerAvailable": "Nueva versión: {tag}",
|
||||
"support": {
|
||||
"title": "Soporte",
|
||||
"description": "Un resumen para un informe de error, sin tu nombre, correo ni valores de salud.",
|
||||
"copy": "Copiar información de soporte",
|
||||
"copied": "Copiado",
|
||||
"copyFailed": "No se pudo reunir la información de soporte."
|
||||
}
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Probar conexión",
|
||||
@@ -7133,7 +7256,8 @@
|
||||
"lastReceived": "Última recepción {relative}",
|
||||
"lastNewValue": "Último valor nuevo {relative}",
|
||||
"lastBackground": "Última vez en segundo plano {relative}",
|
||||
"never": "Aún no recibido"
|
||||
"never": "Aún no recibido",
|
||||
"lastBackfill": "Historial recuperado {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
@@ -7267,6 +7391,46 @@
|
||||
"failureStreak": "Racha de fallos: {count} de {threshold}",
|
||||
"copyFailed": "No se pudo copiar. Selecciona el texto y cópialo a mano."
|
||||
}
|
||||
},
|
||||
"dawarich": "Dawarich",
|
||||
"dawarichTag": "Movimiento",
|
||||
"dawarichDescription": "Conecta tu instancia de Dawarich para traer la distancia diaria, el tiempo fuera de casa y los días de viaje.",
|
||||
"dawarichUrl": "Dirección de Dawarich",
|
||||
"dawarichApiKey": "Clave de API",
|
||||
"dawarichApiKeyHelp": "Lo encuentras en Dawarich en Cuenta. HealthLog solo lee valores diarios con ella, nunca coordenadas ni nombres de lugares.",
|
||||
"dawarichConnect": "Conectar",
|
||||
"dawarichUpdate": "Guardar cambios",
|
||||
"dawarichConnected": "Dawarich conectado. Se están cargando los últimos 30 días.",
|
||||
"dawarichSync": "Obtener ahora",
|
||||
"dawarichSyncQueued": "Los últimos siete días se obtendrán en breve.",
|
||||
"dawarichSyncAlreadyQueued": "Ya hay una obtención en curso.",
|
||||
"dawarichDisconnect": "Desconectar",
|
||||
"dawarichDisconnectTitle": "¿Desconectar Dawarich?",
|
||||
"dawarichDisconnectDescription": "Las próximas obtenciones se detienen. Los días ya guardados se conservan.",
|
||||
"dawarichDisconnectDelete": "Desconectar y borrar los días",
|
||||
"dawarichDisconnectDeleteTitle": "¿Desconectar y borrar todos los días?",
|
||||
"dawarichDisconnectDeleteDescription": "Se eliminan la conexión y todos los días de movimiento guardados. No se puede deshacer.",
|
||||
"dawarichHomePlaceSet": "El hogar está definido.",
|
||||
"dawarichHomePlaceMissing": "Define tu hogar para poder medir el tiempo fuera de casa.",
|
||||
"dawarichHomeCountryIs": "País de residencia: {country}.",
|
||||
"dawarichHomeCountryMissing": "Aún no hay país de residencia, así que no hay días de viaje.",
|
||||
"dawarichDaysStored": "Días guardados: {count}",
|
||||
"dawarichHomePlace": "Hogar",
|
||||
"dawarichHomePlaceChoose": "Elige un lugar de Dawarich",
|
||||
"dawarichHomePlaceChange": "Elegir hogar",
|
||||
"dawarichHomeCountry": "País de residencia",
|
||||
"dawarichHomeCountryHelp": "Código de país de dos letras, como DE. Si lo dejas vacío, cuenta el país donde pasaste más días el último año.",
|
||||
"dawarichHomeSave": "Guardar",
|
||||
"dawarichHomeSaved": "Guardado.",
|
||||
"dawarichErrors": {
|
||||
"invalid": "Esa dirección no funciona. Usa la dirección de la instancia sin credenciales.",
|
||||
"privateOrigin": "Esta instancia está en una red privada. Pide al operador del servidor que la autorice.",
|
||||
"credentials": "Dawarich no aceptó la clave de API.",
|
||||
"unreachable": "No se puede acceder a Dawarich. Comprueba la dirección y que la instancia esté en marcha.",
|
||||
"rateLimited": "Demasiados intentos. Espera un momento y vuelve a intentarlo.",
|
||||
"homePlaceUnknown": "La instancia de Dawarich ya no conoce este lugar.",
|
||||
"notConnected": "Dawarich no está conectado.",
|
||||
"generic": "No ha funcionado. Inténtalo de nuevo."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -7343,7 +7507,8 @@
|
||||
"spendToday": "Hoy, en el día natural de cada cuenta: {total} tokens, {operator} de ellos en la clave del operador (cuentas con consumo: {accounts}).",
|
||||
"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."
|
||||
"modelNotServed": "El endpoint ya no ofrece el modelo {model}. Elige en los ajustes de IA un modelo que ofrezca.",
|
||||
"usageLevels": "Cuentas por nivel de uso de la IA: {frugal} ahorro, {standard} estándar, {full} completo."
|
||||
},
|
||||
"aiQuality": {
|
||||
"title": "Calidad de los análisis",
|
||||
@@ -7753,7 +7918,8 @@
|
||||
"eventDetails": "Detalles del evento",
|
||||
"closeDetails": "Cerrar detalles",
|
||||
"processDetail": "El búfer es volátil y está limitado a 500 entradas.",
|
||||
"cardTitle": "Eventos recientes"
|
||||
"cardTitle": "Eventos recientes",
|
||||
"filterErrorId": "ID de error"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Copias de seguridad",
|
||||
@@ -7840,6 +8006,7 @@
|
||||
"restoreSkippedCoachAttachment": "Enlace de documento del Coach",
|
||||
"restoreSkippedCoachReference": "Referencia del Coach",
|
||||
"restoreSkippedDocumentConditionLink": "Clasificación de documento",
|
||||
"restoreSkippedRelationLink": "Vínculo entre registros",
|
||||
"restoreSkippedExtractedFact": "Dato del documento",
|
||||
"restoreSkippedFactCommitment": "Registro del dato",
|
||||
"docsLink": "Documentación",
|
||||
@@ -8016,6 +8183,56 @@
|
||||
"started": "Mantenimiento iniciado.",
|
||||
"alreadyQueued": "Ya hay un mantenimiento en cola o en marcha.",
|
||||
"startFailed": "No se pudo iniciar el mantenimiento."
|
||||
},
|
||||
"pulseDayMean": {
|
||||
"title": "Media diaria del pulso",
|
||||
"description": "Cómo cuentan las horas con pocas lecturas en el pulso de un día.",
|
||||
"body": "Hasta ahora, cada hora con una lectura cuenta lo mismo, así que una sola lectura suelta puede mover un día entero. El método ponderado hace que una hora cuente por completo a partir de tres lecturas, y una lectura aislada un tercio. Ejecuta primero la prueba para ver cuántos días cambiarían. Al aplicarlo, los días de pulso guardados se recalculan en segundo plano.",
|
||||
"method": {
|
||||
"hourly": "Todas las horas igual",
|
||||
"weighted": "Horas escasas ponderadas"
|
||||
},
|
||||
"loading": "Comprobando la media diaria del pulso…",
|
||||
"loadError": "No se pudo leer el estado de la media diaria del pulso.",
|
||||
"state": {
|
||||
"idle": "Aún no hay ninguna prueba.",
|
||||
"queued": "La prueba está esperando al proceso en segundo plano.",
|
||||
"running": "La prueba está en curso.",
|
||||
"done": "Prueba terminada el {at}.",
|
||||
"failed": "La prueba se detuvo. El registro de la app indica la causa; vuelve a iniciarla."
|
||||
},
|
||||
"result": {
|
||||
"days": "Días comparados",
|
||||
"changed": "Días que cambian más de 1 bpm",
|
||||
"max": "Mayor cambio",
|
||||
"maxValue": "{value} bpm",
|
||||
"bucket": "{range} bpm",
|
||||
"account": "Cuenta {account}",
|
||||
"accountDays": "Cambian {changed} de {days} días"
|
||||
},
|
||||
"appliedAt": "Método ponderado aplicado el {at}.",
|
||||
"dryRun": "Prueba",
|
||||
"apply": "Aplicar",
|
||||
"confirmTitle": "¿Aplicar la media diaria del pulso ponderada?",
|
||||
"confirmBody": "A partir de entonces, los días de pulso de todas las cuentas se calculan con las horas escasas ponderadas. Los días de pulso guardados se recalculan en segundo plano, lo que puede tardar en una instancia grande.",
|
||||
"confirm": "Aplicar",
|
||||
"cancel": "Cancelar",
|
||||
"dryRunStarted": "Prueba iniciada.",
|
||||
"applied": "Método ponderado aplicado. Los días de pulso se están recalculando.",
|
||||
"appliedNoRebuild": "Método ponderado aplicado. Otro proceso sigue en marcha; vuelve a aplicarlo cuando termine para recalcular los días de pulso.",
|
||||
"error": {
|
||||
"busy": "Todavía hay una prueba o un recálculo en marcha. Inténtalo de nuevo cuando termine.",
|
||||
"dryRunRequired": "Ejecuta primero una prueba. Para aplicar hace falta una terminada en los últimos siete días.",
|
||||
"generic": "La solicitud no se completó."
|
||||
},
|
||||
"rebuild": {
|
||||
"queued": "El recálculo espera al proceso en segundo plano.",
|
||||
"running": "Se están recalculando los días de pulso.",
|
||||
"done": "Días de pulso recalculados el {at} para {accounts} cuentas.",
|
||||
"failed": "El recálculo se detuvo. El registro de la app indica la causa; vuelve a ejecutarlo."
|
||||
},
|
||||
"rebuildAgain": "Recalcular de nuevo",
|
||||
"rebuildStarted": "Recálculo iniciado."
|
||||
}
|
||||
},
|
||||
"carrier": "Operador",
|
||||
@@ -8120,7 +8337,25 @@
|
||||
"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"
|
||||
"userColumn": "Usuario",
|
||||
"supportReport": {
|
||||
"title": "Informe de soporte",
|
||||
"description": "Un resumen de esta instancia para un informe de error, sin correos, ID ni valores.",
|
||||
"body": "Las cuentas aparecen como «Account 1», «Account 2», etc. Lee el informe antes de compartirlo.",
|
||||
"copy": "Copiar",
|
||||
"copied": "Copiado",
|
||||
"download": "Descargar",
|
||||
"loadError": "No se pudo crear el informe de soporte."
|
||||
},
|
||||
"aiUsage": {
|
||||
"title": "Niveles de uso de la IA",
|
||||
"description": "El nivel más alto que pueden elegir las cuentas y el nivel con el que empiezan.",
|
||||
"maxLabel": "Nivel más alto",
|
||||
"defaultLabel": "Nivel predeterminado",
|
||||
"defaultAboveCeiling": "El nivel predeterminado no puede estar por encima del nivel más alto.",
|
||||
"gapLabel": "Horas entre actualizaciones de tarjetas tras una sincronización (Estándar)",
|
||||
"gapHint": "Horas enteras de 1 a 48. Déjalo vacío para usar AI_SYNC_REGEN_GAP_HOURS, si no 6. Vigente ahora: {hours} h."
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Logros",
|
||||
@@ -9215,7 +9450,9 @@
|
||||
"periodStartSaved": "Inicio del periodo guardado",
|
||||
"periodEndSaved": "Fin del periodo guardado",
|
||||
"startedPeriodOnDate": "Mi periodo empezó este día",
|
||||
"endedPeriodOnDate": "Mi periodo terminó este día"
|
||||
"endedPeriodOnDate": "Mi periodo terminó este día",
|
||||
"startAbsorbed": "Inicio del periodo guardado. Tu inicio del {absorbed} se ha unido al del {date}.",
|
||||
"startRestored": "Guardado. El inicio del {dates} vuelve a estar."
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "El periodo llegará pronto",
|
||||
@@ -9604,12 +9841,16 @@
|
||||
"apob": "La apolipoproteína B es la proteína estructural que lleva cada una de las partículas de lipoproteína aterogénicas, de modo que medirla cuenta directamente esas partículas en lugar del colesterol que transportan. Como hay una molécula de ApoB en cada partícula de LDL, VLDL y afines, puede captar un riesgo cardiovascular que un valor de colesterol LDL pasa por alto, sobre todo cuando el número de partículas y su contenido de colesterol divergen. Por eso se usa cada vez más para afinar la evaluación del riesgo basada en los lípidos. Se interpreta junto con el perfil lipídico habitual.",
|
||||
"lp-a": "La lipoproteína(a) es una partícula similar al LDL con una proteína adicional unida, cuyo nivel viene determinado en gran medida por la genética y se mantiene bastante estable a lo largo de la vida. Una Lp(a) elevada es un factor de riesgo independiente y hereditario de enfermedad cardiovascular y de estrechamiento de la válvula aórtica. Como cambia poco, suele medirse una sola vez para establecer el riesgo vital de una persona, en lugar de seguirla de forma repetida. Se expresa en unidades molares y se lee como parte del riesgo cardiovascular global.",
|
||||
"fasting-insulin": "La insulina en ayunas es el nivel de insulina —la hormona que traslada la glucosa de la sangre a las células— medido tras un ayuno nocturno. Junto con la glucosa en ayunas, ayuda a estimar la resistencia a la insulina, el estado en el que el cuerpo necesita más insulina para mantener la glucosa dentro de su rango. Una insulina en ayunas alta con una glucosa normal puede ser un signo temprano de que esa compensación está en marcha. Se lee como parte de un cuadro de salud metabólica y no de forma aislada.",
|
||||
"omega-3-index": "El índice omega-3 es la proporción de EPA y DHA —los dos ácidos grasos omega-3 marinos de cadena larga— entre los ácidos grasos de las membranas de los glóbulos rojos. Como los glóbulos rojos se renuevan despacio, refleja la ingesta de omega-3 a largo plazo y no una comida reciente. Un índice más alto se ha asociado en estudios observacionales a un menor riesgo cardiovascular. Se sigue como un marcador estable del estado de omega-3 que responde a la dieta a lo largo de meses."
|
||||
"omega-3-index": "El índice omega-3 es la proporción de EPA y DHA —los dos ácidos grasos omega-3 marinos de cadena larga— entre los ácidos grasos de las membranas de los glóbulos rojos. Como los glóbulos rojos se renuevan despacio, refleja la ingesta de omega-3 a largo plazo y no una comida reciente. Un índice más alto se ha asociado en estudios observacionales a un menor riesgo cardiovascular. Se sigue como un marcador estable del estado de omega-3 que responde a la dieta a lo largo de meses.",
|
||||
"alkaline-phosphatase": "La fosfatasa alcalina es una enzima presente sobre todo en el hígado, las vías biliares y el hueso. Sube cuando se obstruye el flujo de la bilis y en fases de formación ósea activa, como el crecimiento en niños y adolescentes o la consolidación de una fractura. Como la liberan ambos órganos, un valor alto suele leerse junto con la GGT y los demás marcadores hepáticos para distinguir un origen hepático de uno óseo. Es uno de los marcadores habituales del perfil hepático.",
|
||||
"bilirubin-total": "La bilirrubina total es el producto amarillo de la degradación de la hemoglobina de los glóbulos rojos envejecidos, medido como la suma de sus formas no conjugada y conjugada. El hígado la capta, la conjuga y la elimina por la bilis, de modo que su nivel refleja cómo funciona esa vía. Puede subir con enfermedades del hígado, obstrucción de las vías biliares o mayor destrucción de glóbulos rojos, y de forma inofensiva en el síndrome de Gilbert. Se interpreta junto con los demás marcadores hepáticos."
|
||||
},
|
||||
"apob": "Apolipoproteína B (ApoB)",
|
||||
"lp-a": "Lipoproteína(a)",
|
||||
"fasting-insulin": "Insulina en ayunas",
|
||||
"omega-3-index": "Índice omega-3"
|
||||
"omega-3-index": "Índice omega-3",
|
||||
"alkaline-phosphatase": "Fosfatasa alcalina",
|
||||
"bilirubin-total": "Bilirrubina total"
|
||||
},
|
||||
"noteLoadFailed": "No se pudo cargar la nota; se mantendrá sin cambios al guardar.",
|
||||
"showingLatestOf": "Mostrando los {shown} resultados más recientes de {total}",
|
||||
@@ -9683,6 +9924,54 @@
|
||||
"fromReport": "Rango del informe {range}",
|
||||
"catalogDiffers": "tu rango {range}",
|
||||
"printed": "impreso en {unit}: {range}"
|
||||
},
|
||||
"match": {
|
||||
"viaAlias": "por un nombre que elegiste antes",
|
||||
"viaCatalog": "reconocido como una abreviatura habitual",
|
||||
"chooseLabel": "Pertenece a",
|
||||
"suggestionOption": "{name} ({unit})",
|
||||
"newMarkerOption": "Un biomarcador nuevo",
|
||||
"rememberHint": "Este nombre se recordará para el próximo informe.",
|
||||
"chooseHint": "Elige un biomarcador si ya lo registras."
|
||||
},
|
||||
"aliases": {
|
||||
"title": "También escrito como",
|
||||
"taken": "{alias} ya pertenece a otro biomarcador.",
|
||||
"saveError": "No se pudieron guardar los nombres. Inténtalo de nuevo.",
|
||||
"remove": "Quitar {alias}",
|
||||
"empty": "Añade los nombres que otros laboratorios imprimen para {name}, para que sus informes lleguen aquí.",
|
||||
"placeholder": "p. ej. FA",
|
||||
"addLabel": "Otro nombre para este biomarcador",
|
||||
"add": "Añadir",
|
||||
"alsoWritten": "También escrito como {aliases}"
|
||||
},
|
||||
"merge": {
|
||||
"open": "Combinar",
|
||||
"title": "Combinar biomarcadores",
|
||||
"description": "Une los biomarcadores que son la misma prueba escrita de otra forma.",
|
||||
"pickHint": "Selecciona al menos dos biomarcadores.",
|
||||
"keepLabel": "Conservar",
|
||||
"keepHint": "Los demás pasan a este y sus nombres se convierten en sus otros nombres.",
|
||||
"showPreview": "Ver vista previa",
|
||||
"back": "Atrás",
|
||||
"confirm": "Combinar ahora",
|
||||
"previewSummaryOne": "{count} medición pasa a {name} ({unit}).",
|
||||
"previewSummaryFew": "{count} mediciones pasan a {name} ({unit}).",
|
||||
"previewSummaryOther": "{count} mediciones pasan a {name} ({unit}).",
|
||||
"movesOne": "{count} medición, ya en la misma unidad",
|
||||
"movesFew": "{count} mediciones, ya en la misma unidad",
|
||||
"movesOther": "{count} mediciones, ya en la misma unidad",
|
||||
"convertedOne": "{count} medición, convertida de {unit} a {targetUnit}",
|
||||
"convertedFew": "{count} mediciones, convertidas de {unit} a {targetUnit}",
|
||||
"convertedOther": "{count} mediciones, convertidas de {unit} a {targetUnit}",
|
||||
"refused": "{unit} no se puede convertir a {targetUnit}.",
|
||||
"aliasesAdded": "Estos nombres llevarán al biomarcador conservado:",
|
||||
"blocked": "No se combina nada mientras una unidad no se pueda convertir. Deja fuera ese biomarcador o conserva otro.",
|
||||
"noUndo": "Una combinación no se puede deshacer.",
|
||||
"doneToastOne": "Combinado en {name}, {count} medición movida",
|
||||
"doneToastFew": "Combinado en {name}, {count} mediciones movidas",
|
||||
"doneToastOther": "Combinado en {name}, {count} mediciones movidas",
|
||||
"error": "No se pudieron combinar los biomarcadores. Inténtalo de nuevo."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -10644,25 +10933,11 @@
|
||||
},
|
||||
"profileFacts": {
|
||||
"cardTitle": "Contexto del estilo de vida",
|
||||
"cardDescription": "Registra los datos actuales que cambian la interpretación de tus mediciones de salud.",
|
||||
"smokingLabel": "Tabaquismo",
|
||||
"alcoholLabel": "Patrón de consumo de alcohol",
|
||||
"shiftLabel": "Horario de trabajo",
|
||||
"notRecorded": "No registrado",
|
||||
"unreadable": "No se pudo leer el valor guardado",
|
||||
"save": "Guardar",
|
||||
"savedToast": "Contexto del estilo de vida guardado",
|
||||
"savePartialError": "No se pudo guardar {kinds}. Inténtalo de nuevo.",
|
||||
"saveKindError": "No guardado",
|
||||
"loadError": "No se pudo cargar el contexto del estilo de vida",
|
||||
"remove": "Eliminar {kind}",
|
||||
"removeShort": "Quitar",
|
||||
"removeConfirmTitle": "¿Eliminar {kind}?",
|
||||
"removeConfirmDescription": "El valor actual pasará a constar como no registrado. Se conservará su historial fechado.",
|
||||
"removedToast": "{kind} eliminado del contexto actual del estilo de vida",
|
||||
"removeError": "No se pudo eliminar {kind}",
|
||||
"historyTitle": "Valores anteriores",
|
||||
"to": "a",
|
||||
"values": {
|
||||
"SMOKING_STATUS": {
|
||||
"NEVER": "Nunca ha fumado",
|
||||
@@ -10842,6 +11117,235 @@
|
||||
"savedToast": "Información de emergencia guardada",
|
||||
"saveError": "No se pudo guardar la información de emergencia.",
|
||||
"loadError": "No se pudo cargar la información de emergencia."
|
||||
},
|
||||
"life": {
|
||||
"cardTitle": "Circunstancias de vida",
|
||||
"cardDescription": "Tabaco, alcohol, actividad, trabajo, hábitos y objetivos, con fecha y siempre opcionales.",
|
||||
"loadError": "No se pudieron cargar tus circunstancias de vida.",
|
||||
"noAnswer": "Sin respuesta",
|
||||
"yes": "Sí",
|
||||
"no": "No",
|
||||
"validSince": "Válido desde",
|
||||
"month": "Mes",
|
||||
"year": "Año",
|
||||
"lastConfirmed": "Confirmado por última vez el {date}",
|
||||
"notAnswered": "Aún sin responder",
|
||||
"unreadable": "Esta respuesta no se puede leer en este servidor.",
|
||||
"save": "Guardar sección",
|
||||
"savedToast": "Sección guardada",
|
||||
"saveError": "No se pudo guardar la sección.",
|
||||
"review": {
|
||||
"question": "¿Sigue siendo cierto? Tu última respuesta tiene más de un año.",
|
||||
"confirm": "Sigue siendo cierto",
|
||||
"confirmedToast": "Gracias, marcado como vigente",
|
||||
"confirmError": "No se pudo guardar la confirmación."
|
||||
},
|
||||
"ai": {
|
||||
"label": "Compartir esta sección con la IA",
|
||||
"shiftLabel": "Compartir el trabajo por turnos con la IA",
|
||||
"hint": "Desactivado, el Coach y los resúmenes nunca ven estas respuestas.",
|
||||
"savedToast": "Uso por la IA actualizado",
|
||||
"saveError": "No se pudo guardar el ajuste de la IA."
|
||||
},
|
||||
"sections": {
|
||||
"tobacco": {
|
||||
"title": "Tabaco y nicotina"
|
||||
},
|
||||
"alcohol": {
|
||||
"title": "Alcohol"
|
||||
},
|
||||
"activity": {
|
||||
"title": "Actividad y tiempo sentado"
|
||||
},
|
||||
"work": {
|
||||
"title": "Trabajo y ritmo"
|
||||
},
|
||||
"habits": {
|
||||
"title": "Sueño, alimentación y cafeína"
|
||||
},
|
||||
"goals": {
|
||||
"title": "Objetivos"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"smokingStatus": "Consumo de tabaco",
|
||||
"products": "Productos que usas",
|
||||
"cigarettesPerDay": "Cigarrillos al día",
|
||||
"startYear": "Año en que empezaste",
|
||||
"quitMonth": "Cuándo lo dejaste",
|
||||
"secondhandSmoke": "Humo ajeno con frecuencia",
|
||||
"moderateDaysPerWeek": "Días por semana con actividad moderada o intensa, como caminar a paso rápido",
|
||||
"moderateMinutesPerDay": "Minutos esos días, de media",
|
||||
"sittingHoursWorkday": "Horas sentado en un día laborable",
|
||||
"shiftSchedule": "Trabajo por turnos",
|
||||
"situation": "Situación laboral",
|
||||
"physicalNature": "Tipo de trabajo",
|
||||
"nightWork": "Trabajo nocturno",
|
||||
"exposures": "Exposiciones en el trabajo",
|
||||
"freeWeekdays": "Días libres de la semana",
|
||||
"napRegularly": "Siesta habitual",
|
||||
"snoringReported": "Otros dicen que roncas",
|
||||
"dietPattern": "Alimentación",
|
||||
"dietSpecifics": "Particularidades",
|
||||
"lateMealsPerWeek": "Comidas después de las 21 h por semana",
|
||||
"fruitVegPortionsPerDay": "Raciones de fruta y verdura al día",
|
||||
"caffeineDrinksPerDay": "Bebidas con cafeína al día",
|
||||
"latestCaffeine": "Última cafeína del día",
|
||||
"goals": "En qué quieres trabajar",
|
||||
"goalsHint": "Como máximo tres."
|
||||
},
|
||||
"values": {
|
||||
"products": {
|
||||
"cigarettes": "Cigarrillos",
|
||||
"cigars_pipe": "Puros o pipa",
|
||||
"e_cigarette": "Cigarrillo electrónico",
|
||||
"heated_tobacco": "Tabaco calentado",
|
||||
"snus_pouches": "Snus o bolsas de nicotina"
|
||||
},
|
||||
"situation": {
|
||||
"none": "Sin trabajo remunerado",
|
||||
"indoor": "Sobre todo en interiores",
|
||||
"outdoor": "Sobre todo al aire libre",
|
||||
"mixed": "Dentro y fuera"
|
||||
},
|
||||
"physicalNature": {
|
||||
"mostly_sitting": "Sobre todo sentado",
|
||||
"standing_walking": "De pie o caminando",
|
||||
"heavy_physical": "Trabajo físico pesado"
|
||||
},
|
||||
"exposures": {
|
||||
"heat": "Calor",
|
||||
"cold": "Frío",
|
||||
"dust": "Polvo",
|
||||
"fumes_solvents": "Humos o disolventes",
|
||||
"noise": "Ruido"
|
||||
},
|
||||
"dietPattern": {
|
||||
"mixed": "Dieta mixta",
|
||||
"vegetarian": "Vegetariana",
|
||||
"vegan": "Vegana",
|
||||
"pescetarian": "Pescetariana",
|
||||
"other": "Otra"
|
||||
},
|
||||
"dietSpecifics": {
|
||||
"intermittent_fasting": "Ayuno intermitente",
|
||||
"low_carb": "Baja en carbohidratos",
|
||||
"prescribed_diet": "Dieta prescrita por el médico"
|
||||
},
|
||||
"latestCaffeine": {
|
||||
"before_12": "Antes de las 12 h",
|
||||
"12_15": "De 12 a 15 h",
|
||||
"15_18": "De 15 a 18 h",
|
||||
"after_18": "Después de las 18 h"
|
||||
},
|
||||
"goals": {
|
||||
"weight": "Peso",
|
||||
"blood_pressure": "Presión arterial",
|
||||
"blood_sugar": "Glucosa",
|
||||
"sleep": "Sueño",
|
||||
"fitness": "Forma física",
|
||||
"stress_recovery": "Estrés y recuperación",
|
||||
"medication_routine": "Tomar la medicación con regularidad",
|
||||
"quit_smoking": "Dejar de fumar",
|
||||
"less_alcohol": "Beber menos alcohol"
|
||||
},
|
||||
"weekdays": {
|
||||
"1": "lun",
|
||||
"2": "mar",
|
||||
"3": "mié",
|
||||
"4": "jue",
|
||||
"5": "vie",
|
||||
"6": "sáb",
|
||||
"7": "dom"
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
"packYears": "{value} paquetes-año",
|
||||
"packYearsMissing": "Para los paquetes-año faltan los cigarrillos al día o el año en que empezaste.",
|
||||
"activityMinutes": "{value} minutos a la semana.",
|
||||
"meetsWho": "Cumple la recomendación de la OMS de 150 minutos semanales.",
|
||||
"belowWho": "Está por debajo de la recomendación de la OMS de 150 minutos semanales.",
|
||||
"measuredSteps": "Tus pasos medidos tienen prioridad; esta respuesta aporta contexto.",
|
||||
"freeDaysDefault": "Sin respuesta, el sábado y el domingo cuentan como días libres."
|
||||
},
|
||||
"auditC": {
|
||||
"intro": "Tres preguntas sobre alcohol del AUDIT de la OMS. Un cribado, no un diagnóstico.",
|
||||
"start": "Responder las preguntas",
|
||||
"again": "Responder de nuevo",
|
||||
"offerDue": "Basta con revisarlo una vez al año.",
|
||||
"notValidated": "Aún no hay una traducción validada para tu idioma, por eso las preguntas aparecen en inglés.",
|
||||
"submit": "Guardar respuestas",
|
||||
"savedToast": "Respuestas guardadas",
|
||||
"saveError": "No se pudieron guardar las respuestas.",
|
||||
"result": "{score} de 12 puntos",
|
||||
"above": "En o por encima del punto de corte de cribado de {cutoff}.",
|
||||
"below": "Por debajo del punto de corte de cribado de {cutoff}.",
|
||||
"noCutoff": "Sin sexo en tu perfil solo se muestra la suma.",
|
||||
"source": "Fuente: {source}",
|
||||
"history": "Entradas anteriores",
|
||||
"remove": "Eliminar entrada",
|
||||
"removeConfirmTitle": "¿Eliminar esta entrada?",
|
||||
"removeConfirmBody": "La entrada sale de tu historial. Aquí no se puede deshacer.",
|
||||
"removedToast": "Entrada eliminada",
|
||||
"removeError": "No se pudo eliminar la entrada.",
|
||||
"historyEntry": "{date}: {score} de 12",
|
||||
"legacyLabel": "Respuesta anterior: {value}",
|
||||
"legacyHint": "De la pregunta breve sobre alcohol anterior a este cuestionario. En cuanto hay un resultado del cuestionario, el Coach deja de leerla.",
|
||||
"legacyRemove": "Eliminar respuesta anterior",
|
||||
"legacyRemoveConfirmTitle": "¿Eliminar la respuesta anterior?",
|
||||
"legacyRemoveConfirmBody": "La respuesta anterior sale de tu registro. Los resultados del cuestionario se mantienen.",
|
||||
"legacyRemovedToast": "Respuesta anterior eliminada",
|
||||
"legacyRemoveError": "No se pudo eliminar la respuesta anterior."
|
||||
},
|
||||
"takeOver": {
|
||||
"conditionsButton": "Pasar al diario de afecciones",
|
||||
"allergiesButton": "Pasar a alergias",
|
||||
"conditionsTitle": "Añadir una enfermedad crónica",
|
||||
"allergiesTitle": "Añadir una alergia",
|
||||
"description": "Tu texto libre se queda como está hasta que lo borres tú.",
|
||||
"freeTextLabel": "Tu texto libre",
|
||||
"conditionLabel": "Enfermedad",
|
||||
"onset": "Desde",
|
||||
"save": "Añadir",
|
||||
"savedToast": "Entrada añadida",
|
||||
"saveError": "No se pudo añadir la entrada.",
|
||||
"clearQuestion": "La entrada está guardada. ¿Borrar ahora el texto libre?",
|
||||
"clear": "Borrar texto libre",
|
||||
"keep": "Conservarlo",
|
||||
"clearedToast": "Texto libre borrado",
|
||||
"clearError": "No se pudo borrar el texto libre."
|
||||
},
|
||||
"screening": {
|
||||
"cardTitle": "Indicaciones de cribado",
|
||||
"cardDescription": "A partir de tus circunstancias de vida, con la norma y el país de origen.",
|
||||
"noReminder": "Es solo una indicación; si quieres un recordatorio, créalo tú.",
|
||||
"source": "Fuente: {source}",
|
||||
"titles": {
|
||||
"lung_ldct": "TC de baja dosis para cáncer de pulmón",
|
||||
"aaa_ultrasound": "Ecografía de la aorta abdominal"
|
||||
},
|
||||
"status": {
|
||||
"suggested": "Podría aplicarse a ti",
|
||||
"not_eligible": "No se aplica a ti",
|
||||
"unknown": "Falta información"
|
||||
},
|
||||
"reasons": {
|
||||
"never_smoked": "Nunca has fumado.",
|
||||
"missing_birth_date": "Falta tu fecha de nacimiento.",
|
||||
"age_out_of_range": "Tu edad está fuera del rango.",
|
||||
"missing_smoking_status": "Falta tu consumo de tabaco.",
|
||||
"no_cigarettes": "La norma solo cuenta cigarrillos.",
|
||||
"missing_smoking_history": "Faltan los cigarrillos al día o el año de inicio.",
|
||||
"pack_years_below_15": "Menos de 15 paquetes-año.",
|
||||
"pack_years_below_20": "Menos de 20 paquetes-año.",
|
||||
"smoked_years_below_25": "Menos de 25 años fumando.",
|
||||
"missing_quit_date": "Falta la fecha en que lo dejaste.",
|
||||
"quit_over_10_years": "Lo dejaste hace más de 10 años.",
|
||||
"quit_over_15_years": "Lo dejaste hace más de 15 años.",
|
||||
"missing_sex": "Falta el sexo en tu perfil.",
|
||||
"sex_not_male": "La norma se aplica a hombres."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
@@ -10875,6 +11379,10 @@
|
||||
"mugwort": "artemisa",
|
||||
"olive": "olivo",
|
||||
"ragweed": "ambrosía"
|
||||
},
|
||||
"movement": {
|
||||
"awayFromHome": "Tiempo fuera de casa",
|
||||
"distance": "Distancia recorrida"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -11122,7 +11630,8 @@
|
||||
"extractEmpty": "No se pudieron extraer valores de este documento.",
|
||||
"failUnitRequired": "Un valor numérico necesita una unidad antes de poder guardarse.",
|
||||
"failUnitMismatch": "La unidad indicada no coincide con la unidad guardada de este marcador. Ajústala y termina la revisión de nuevo.",
|
||||
"failGeneric": "Este valor no se pudo guardar."
|
||||
"failGeneric": "Este valor no se pudo guardar.",
|
||||
"failUnknownBiomarker": "El biomarcador elegido ya no existe. Elige otro."
|
||||
},
|
||||
"contentIndex": {
|
||||
"indexPrompt": "Haz que el contenido de los documentos sea buscable.",
|
||||
@@ -11344,7 +11853,9 @@
|
||||
"molybdenum": "Molibdeno",
|
||||
"iodine": "Yodo",
|
||||
"water": "Agua",
|
||||
"caffeine": "Cafeína"
|
||||
"caffeine": "Cafeína",
|
||||
"dha": "DHA (ácido graso omega-3)",
|
||||
"epa": "EPA (ácido graso omega-3)"
|
||||
},
|
||||
"page": {
|
||||
"title": "Ingesta de nutrientes",
|
||||
@@ -11376,9 +11887,26 @@
|
||||
"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",
|
||||
"loadError": "No se pudieron cargar tus datos de nutrientes"
|
||||
"loadError": "No se pudieron cargar tus datos de nutrientes",
|
||||
"sourcePart": "{source} {value} {unit}",
|
||||
"aboveUpperLimit": "Por encima del nivel máximo tolerable para adultos de {value} {unit} al día ({source}).",
|
||||
"aboveUpperLimitSupplemental": "La parte de los suplementos supera el nivel máximo tolerable para adultos de {value} {unit} al día para suplementos ({source}).",
|
||||
"upperLimitForm": {
|
||||
"retinol": "El nivel solo se aplica a la vitamina A preformada (retinol): si la cantidad procede de betacaroteno, no se aplica.",
|
||||
"folic_acid": "El nivel se aplica al ácido fólico de suplementos y alimentos enriquecidos, no al folato de los alimentos habituales.",
|
||||
"magnesium_salts": "El nivel se aplica a las sales de magnesio fácilmente disociables de los suplementos, no al magnesio de los alimentos.",
|
||||
"alpha_tocopherol": "El nivel está fijado para el alfa-tocoferol."
|
||||
}
|
||||
},
|
||||
"sparseNote": "Un valor bajo aquí suele significar un registro escaso, no una deficiencia: los totales diarios de Apple Health reflejan lo que registraron tus apps, no la ingesta dietética total."
|
||||
"sparseNote": "Un valor bajo aquí suele significar un registro escaso, no una deficiencia: los totales diarios de Apple Health reflejan lo que registraron tus apps, no la ingesta dietética total.",
|
||||
"units": {
|
||||
"IU": "UI"
|
||||
},
|
||||
"sources": {
|
||||
"APPLE_HEALTH": "Sincronizado",
|
||||
"MANUAL": "Introducido",
|
||||
"SUPPLEMENT": "Suplementos"
|
||||
}
|
||||
},
|
||||
"daily": {
|
||||
"line": {
|
||||
@@ -11880,9 +12408,11 @@
|
||||
"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.",
|
||||
"oidcOnly": "En este servidor no se puede entregar un perfil.",
|
||||
"loadError": "No se pudo cargar el estado de la entrega.",
|
||||
"entryHint": "Cuando {name} quiera llevar este historial por su cuenta."
|
||||
"entryHint": "Cuando {name} quiera llevar este historial por su cuenta.",
|
||||
"methodSso": "{name} inicia sesión con el inicio de sesión único cuando usa el enlace.",
|
||||
"methodBoth": "{name} puede elegir una contraseña o iniciar sesión con el inicio de sesión único."
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
@@ -12008,7 +12538,11 @@
|
||||
"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"
|
||||
"lifeEventDelete": "{name} eliminó un acontecimiento vital",
|
||||
"biomarkerMerge": "{name} combinó biomarcadores",
|
||||
"medicationIngredientsUpdate": "{name} cambió la composición de un suplemento",
|
||||
"auditCCreate": "{name} respondió el cuestionario AUDIT-C",
|
||||
"auditCDelete": "{name} eliminó una entrada del AUDIT-C"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
@@ -12227,7 +12761,34 @@
|
||||
"removed": "Se desvinculó {name}",
|
||||
"linked": "Vinculados",
|
||||
"loadNewer": "Cargar más recientes",
|
||||
"loadOlder": "Cargar anteriores"
|
||||
"loadOlder": "Cargar anteriores",
|
||||
"period": {
|
||||
"showing": "Mostrando {period}"
|
||||
}
|
||||
},
|
||||
"related": {
|
||||
"title": "Relacionado",
|
||||
"kind": {
|
||||
"labResult": "Resultado de laboratorio",
|
||||
"encounter": "Consulta",
|
||||
"document": "Documento",
|
||||
"conditionEpisode": "Enfermedad",
|
||||
"medication": "Medicamento",
|
||||
"vaccination": "Vacuna"
|
||||
},
|
||||
"via": {
|
||||
"encounter": "a través de una consulta",
|
||||
"efficacyTarget": "a través del objetivo de eficacia"
|
||||
},
|
||||
"pick": {
|
||||
"conditionEpisode": "Enfermedades",
|
||||
"document": "Documentos",
|
||||
"encounter": "Consultas",
|
||||
"medication": "Medicamentos",
|
||||
"vaccination": "Vacunas"
|
||||
},
|
||||
"nothingToOffer": "Todavía no hay nada que vincular.",
|
||||
"saveError": "No se pudo guardar el vínculo."
|
||||
}
|
||||
},
|
||||
"bodySites": {
|
||||
@@ -12369,7 +12930,8 @@
|
||||
"scores": "Puntuaciones",
|
||||
"running": "En curso ese día",
|
||||
"values": "Valores",
|
||||
"happened": "Qué pasó"
|
||||
"happened": "Qué pasó",
|
||||
"movement": "En movimiento"
|
||||
},
|
||||
"notable": {
|
||||
"extremeHigh": "Valor diario más alto desde {since}",
|
||||
@@ -12425,7 +12987,18 @@
|
||||
"today": "Hoy",
|
||||
"hasEntries": "con entradas",
|
||||
"dayNSince": "Día {n}, desde el {date}",
|
||||
"sinceDate": "Desde el {date}"
|
||||
"sinceDate": "Desde el {date}",
|
||||
"movement": {
|
||||
"total": "Distancia",
|
||||
"driving": "En coche",
|
||||
"train": "En tren",
|
||||
"places": "Lugares visitados",
|
||||
"away": "Fuera de casa",
|
||||
"travel": "Día de viaje",
|
||||
"travelYes": "Sí",
|
||||
"kilometres": "{value} km",
|
||||
"miles": "{value} mi"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Cronología",
|
||||
@@ -12760,5 +13333,13 @@
|
||||
"lastUpdated": "Última actualización: {date}",
|
||||
"project": "Proyecto",
|
||||
"credits": "Créditos"
|
||||
},
|
||||
"series": {
|
||||
"reference": {
|
||||
"targetUpper": "Tu objetivo, límite superior",
|
||||
"targetLower": "Tu objetivo, límite inferior",
|
||||
"clinicalUpper": "Límite clínico superior",
|
||||
"clinicalLower": "Límite clínico inferior"
|
||||
}
|
||||
}
|
||||
}
|
||||
+622
-41
@@ -51,7 +51,10 @@
|
||||
"durationHoursMinutes": "{hours} h {minutes} min",
|
||||
"durationMinutes": "{minutes} min",
|
||||
"durationClockMinutes": "{time} min",
|
||||
"durationClockHours": "{time} h"
|
||||
"durationClockHours": "{time} h",
|
||||
"errorIdLabel": "ID d'erreur",
|
||||
"errorIdCopy": "Copier l'ID d'erreur",
|
||||
"errorIdCopied": "ID d'erreur copié"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -154,6 +157,20 @@
|
||||
"event": {
|
||||
"notFound": "Cette entrée n'existe plus."
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
"notFound": "Ce médicament n’est plus disponible.",
|
||||
"ingredients": {
|
||||
"invalid": "Vérifiez la composition et réessayez.",
|
||||
"unitMismatch": "Cette unité n’est pas proposée pour ce nutriment.",
|
||||
"extract": {
|
||||
"fileType": "Importez un JPEG, PNG, WebP ou PDF.",
|
||||
"fileTooLarge": "Le fichier dépasse 10 Mo.",
|
||||
"rateLimited": "Trop de lectures d’étiquettes. Réessayez dans une heure.",
|
||||
"budgetExceeded": "Votre budget d’IA du jour est atteint.",
|
||||
"failed": "L’étiquette n’a pas pu être lue. Essayez une photo plus nette."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -367,7 +384,20 @@
|
||||
"notes": "Notes complémentaires"
|
||||
},
|
||||
"courseRange": "Traitement du {start} au {end}",
|
||||
"courseSince": "Traitement depuis le {start}"
|
||||
"courseSince": "Traitement depuis le {start}",
|
||||
"lifePackYears": "Paquets-années",
|
||||
"lifeAuditC": "AUDIT-C (dépistage)",
|
||||
"lifeAuditCScore": "{score} sur 12 le {date}.",
|
||||
"lifeAuditCAbove": "Au seuil de {cutoff} ou au-dessus ({source}).",
|
||||
"lifeAuditCBelow": "Sous le seuil de {cutoff} ({source}).",
|
||||
"lifeAuditCNoCutoff": "Aucun seuil propre au sexe appliqué.",
|
||||
"lifeActivity": "Activité physique",
|
||||
"lifeActivityValue": "{minutes} minutes par semaine, déclaré",
|
||||
"lifeSitting": "Temps assis un jour de travail",
|
||||
"lifeSittingValue": "{hours} heures, déclaré",
|
||||
"lifeWork": "Travail",
|
||||
"lifeNightWork": "travail de nuit",
|
||||
"lifeExposures": "expositions : {list}"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Une erreur est survenue",
|
||||
@@ -525,7 +555,7 @@
|
||||
"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.",
|
||||
"oidcOnly": "Ce serveur n'autorise que l'authentification unique. Continuez plutôt avec votre fournisseur d'identité.",
|
||||
"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",
|
||||
@@ -542,7 +572,19 @@
|
||||
"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."
|
||||
"errorOffline": "Le serveur n'a pas été atteint. Vérifiez votre connexion et réessayez.",
|
||||
"ssoContinue": "Continuer avec {provider}",
|
||||
"ssoStarting": "Redirection…",
|
||||
"ssoHint": "Ensuite, vous vous connectez ici avec votre compte {provider}.",
|
||||
"ssoDivider": "ou",
|
||||
"ssoFallbackName": "l'authentification unique",
|
||||
"errorTitle": "La transmission n'a pas abouti",
|
||||
"errorSubjectBound": "Cette connexion appartient déjà à un autre compte sur ce serveur. Rouvrez le lien et utilisez un autre compte chez votre fournisseur d'identité.",
|
||||
"errorEmailTaken": "Votre fournisseur d'identité a transmis une adresse e-mail déjà utilisée par un autre compte ici. Rouvrez le lien et utilisez un autre compte, ou contactez la personne qui administre le serveur.",
|
||||
"errorUsernameTaken": "Quelqu'un a pris ce nom d'utilisateur entre-temps. Rouvrez le lien et choisissez-en un autre.",
|
||||
"errorSignedIn": "Vous étiez connecté à votre retour. Déconnectez-vous, puis rouvrez le lien.",
|
||||
"errorSso": "La connexion avec votre fournisseur d'identité n'a pas fonctionné. Rouvrez le lien pour réessayer.",
|
||||
"signInInstead": "Se connecter à ce compte"
|
||||
},
|
||||
"inviteInvalid": {
|
||||
"title": "Invitation invalide",
|
||||
@@ -2399,6 +2441,34 @@
|
||||
"invalidRange": "Le dernier jour ne peut pas précéder le premier.",
|
||||
"generic": "L’enregistrement a échoué. Réessaie."
|
||||
}
|
||||
},
|
||||
"composition": {
|
||||
"title": "Composition",
|
||||
"empty": "Indiquez ce que contient un comprimé ou une gélule pour que chaque prise compte dans vos micronutriments.",
|
||||
"perUnitNote": "Par comprimé ou gélule ; chaque prise s’ajoute aux micronutriments du jour.",
|
||||
"add": "Ajouter",
|
||||
"edit": "Modifier",
|
||||
"rowHelper": "Ce que contient un comprimé ou une gélule, pour les micronutriments.",
|
||||
"sheetTitle": "Ce que contient une unité",
|
||||
"sheetDescription": "Saisissez les quantités par comprimé ou gélule telles qu’indiquées sur l’étiquette.",
|
||||
"nutrientLabel": "Nutriment",
|
||||
"amountLabel": "Quantité",
|
||||
"unitLabel": "Unité",
|
||||
"choose": "Choisissez un nutriment",
|
||||
"addRow": "Ajouter un nutriment",
|
||||
"removeRow": "Retirer {name}",
|
||||
"editorEmpty": "Aucun nutriment pour l’instant. Ajoutez-en un par ligne de l’étiquette.",
|
||||
"labelPhoto": "Lire depuis une photo de l’étiquette",
|
||||
"labelReading": "Lecture de l’étiquette…",
|
||||
"labelWaiting": "Le service en arrière-plan n’a pas encore pris l’étiquette en charge.",
|
||||
"labelReviewNote": "Vérifiez chaque ligne avec l’étiquette avant d’enregistrer.",
|
||||
"lowConfidence": "Cette ligne était difficile à lire ; vérifiez-la avec l’étiquette.",
|
||||
"unmatchedTitle": "Lignes sans nutriment correspondant, à saisir à la main si besoin :",
|
||||
"servingNote": "L’étiquette indique : {note}. Les quantités sont données par unité.",
|
||||
"labelError": "L’étiquette n’a pas pu être lue",
|
||||
"saveError": "La composition n’a pas pu être enregistrée",
|
||||
"catalogError": "La liste des nutriments n’a pas pu être chargée",
|
||||
"invalidRow": "Choisissez un nutriment et saisissez une quantité supérieure à zéro sur chaque ligne."
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -2666,7 +2736,8 @@
|
||||
"bmiEmptyAction": "Ouvrir les paramètres du compte",
|
||||
"personalRecord": {
|
||||
"badge": "PR",
|
||||
"tooltip": "Record personnel : {value} {unit} le {date}"
|
||||
"tooltip": "Record personnel : {value} {unit} le {date}",
|
||||
"benchmark": "Valeur typique à cet âge : de {low} à {high} {unit} ({source})"
|
||||
},
|
||||
"loadError": "Impossible de charger les analyses",
|
||||
"heroPersonalBaseline": "Basé sur tes 90 derniers jours",
|
||||
@@ -4220,6 +4291,21 @@
|
||||
"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.",
|
||||
"featureValue": "{field} : {value}"
|
||||
},
|
||||
"patterns": {
|
||||
"title": "Tendances de votre humeur",
|
||||
"basedOn": "Sur la base de {subject} et {comparison} jours. Une tendance, pas une cause.",
|
||||
"previousDayHigher": "Après une journée en dessous de l’habitude, le lendemain est souvent meilleur ({subject} contre {comparison} après une journée au-dessus).",
|
||||
"previousDayLower": "Une journée en dessous de l’habitude a tendance à se prolonger le lendemain ({subject} contre {comparison} après une journée au-dessus).",
|
||||
"weekendHigher": "Votre humeur a tendance à être meilleure le week-end ({subject} contre {comparison} en semaine).",
|
||||
"weekendLower": "Votre humeur a tendance à être plus basse le week-end ({subject} contre {comparison} en semaine).",
|
||||
"bestWeekday": "{weekday} est souvent votre meilleur jour ({subject} contre {comparison} les autres jours).",
|
||||
"notesHigher": "Les jours avec une note, votre humeur a tendance à être meilleure ({subject} contre {comparison} sans note).",
|
||||
"notesLower": "Les jours avec une note, votre humeur a tendance à être plus basse ({subject} contre {comparison} sans note).",
|
||||
"delayedTagHigher": "Le lendemain de « {tag} », votre humeur a tendance à être meilleure ({subject} contre {comparison} sinon).",
|
||||
"delayedTagLower": "Le lendemain de « {tag} », votre humeur a tendance à être plus basse ({subject} contre {comparison} sinon).",
|
||||
"multipleEntriesHigher": "Les jours où vous notez plusieurs fois, votre humeur a tendance à être meilleure ({subject} contre {comparison} avec une seule saisie).",
|
||||
"multipleEntriesLower": "Les jours où vous notez plusieurs fois, votre humeur a tendance à être plus basse ({subject} contre {comparison} avec une seule saisie)."
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
@@ -5459,7 +5545,7 @@
|
||||
"subtitle": "Choisissez la source prioritaire en cas de doublon de mesure.",
|
||||
"nutrients": {
|
||||
"title": "Apports en nutriments synchronisés",
|
||||
"description": "Totaux quotidiens synchronisés depuis Apple Health sur les {days} derniers jours.",
|
||||
"description": "Totaux quotidiens sur les {days} derniers jours : ce qu’Apple Health a synchronisé, plus les compléments que vous avez pris.",
|
||||
"empty": "Aucune donnée de nutriments synchronisée pour l'instant.",
|
||||
"latest": "{amount} {unit} le {date}",
|
||||
"daysWithData": "{count} jours sur {days}",
|
||||
@@ -5821,7 +5907,10 @@
|
||||
"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.",
|
||||
"range": "Du {start} au {end}"
|
||||
"range": "Du {start} au {end}",
|
||||
"suggestionsIntro": "Dawarich a trouvé des jours à l’étranger qu’aucune période ne couvre encore.",
|
||||
"suggestionUse": "Utiliser",
|
||||
"suggestionDismiss": "Ignorer la suggestion"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Récupérer les jours passés",
|
||||
@@ -6255,7 +6344,8 @@
|
||||
"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",
|
||||
"cancel": "Annuler"
|
||||
"cancel": "Annuler",
|
||||
"restingFrugal": "En pause au niveau d'IA économe : les documents ne sont lus qu'à votre demande."
|
||||
},
|
||||
"centralCodex": {
|
||||
"title": "Utiliser l'accès IA partagé du serveur",
|
||||
@@ -6313,7 +6403,33 @@
|
||||
"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"
|
||||
"runtimeTitle": "Vérifier et actualiser",
|
||||
"usage": {
|
||||
"title": "Usage de l'IA",
|
||||
"description": "La quantité de travail d'IA effectuée en arrière-plan pour vous, le Coach restant le même à chaque niveau.",
|
||||
"levels": {
|
||||
"frugal": {
|
||||
"label": "Économe",
|
||||
"hint": "Les cartes se mettent à jour au plus une fois par jour après une synchronisation, un bilan quotidien court, les documents ne sont lus qu'à votre demande."
|
||||
},
|
||||
"standard": {
|
||||
"label": "Standard",
|
||||
"hint": "Les cartes se mettent à jour toutes les quelques heures après une synchronisation, le bilan quotidien complet."
|
||||
},
|
||||
"full": {
|
||||
"label": "Complet",
|
||||
"hint": "Les cartes se mettent à jour jusqu'à chaque heure avec de nouvelles données, comme avant ce réglage."
|
||||
}
|
||||
},
|
||||
"limited": "Limité par l'administrateur du serveur.",
|
||||
"estimate": "environ {tokens} jetons par jour, estimation",
|
||||
"measured": "Mesuré : {tokens} jetons par jour sur les {days} derniers jours, Coach compris. Les chiffres par niveau sont des estimations.",
|
||||
"noEstimate": "Une estimation par niveau apparaît après trois jours d'utilisation de l'IA.",
|
||||
"capped": "Vous avez choisi {choice}, que le serveur n'autorise pas pour le moment, donc {level} s'applique. Votre choix est conservé.",
|
||||
"followsDefault": "Vous suivez le réglage par défaut du serveur ({level}).",
|
||||
"useDefault": "Utiliser le réglage par défaut du serveur ({level})",
|
||||
"saveError": "Le niveau n'a pas pu être enregistré. Réessaie."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"units": {
|
||||
@@ -6646,7 +6762,14 @@
|
||||
"docs": "Documentation",
|
||||
"credits": "Crédits et attributions de données",
|
||||
"linksHeading": "Sources et documentation",
|
||||
"newerAvailable": "Nouvelle version : {tag}"
|
||||
"newerAvailable": "Nouvelle version : {tag}",
|
||||
"support": {
|
||||
"title": "Assistance",
|
||||
"description": "Un résumé pour un signalement de bug, sans votre nom, votre e-mail ni vos valeurs de santé.",
|
||||
"copy": "Copier les infos d'assistance",
|
||||
"copied": "Copié",
|
||||
"copyFailed": "Les infos d'assistance n'ont pas pu être rassemblées."
|
||||
}
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Tester la connexion",
|
||||
@@ -7133,7 +7256,8 @@
|
||||
"lastReceived": "Dernière réception {relative}",
|
||||
"lastNewValue": "Dernière nouvelle valeur {relative}",
|
||||
"lastBackground": "Dernière fois en arrière-plan {relative}",
|
||||
"never": "Pas encore reçu"
|
||||
"never": "Pas encore reçu",
|
||||
"lastBackfill": "Historique rattrapé {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
@@ -7267,6 +7391,46 @@
|
||||
"failureStreak": "Série d’échecs : {count} sur {threshold}",
|
||||
"copyFailed": "La copie a échoué. Sélectionnez le texte et copiez-le à la main."
|
||||
}
|
||||
},
|
||||
"dawarich": "Dawarich",
|
||||
"dawarichTag": "Déplacements",
|
||||
"dawarichDescription": "Connecte ton instance Dawarich pour récupérer la distance du jour, le temps hors de chez toi et les jours de voyage.",
|
||||
"dawarichUrl": "Adresse Dawarich",
|
||||
"dawarichApiKey": "Clé d’API",
|
||||
"dawarichApiKeyHelp": "Tu la trouves dans Dawarich sous Compte. HealthLog ne lit que des valeurs journalières, jamais de coordonnées ni de noms de lieux.",
|
||||
"dawarichConnect": "Connecter",
|
||||
"dawarichUpdate": "Enregistrer les modifications",
|
||||
"dawarichConnected": "Dawarich connecté. Les 30 derniers jours sont en cours de chargement.",
|
||||
"dawarichSync": "Récupérer maintenant",
|
||||
"dawarichSyncQueued": "Les sept derniers jours seront récupérés sous peu.",
|
||||
"dawarichSyncAlreadyQueued": "Une récupération est déjà en cours.",
|
||||
"dawarichDisconnect": "Déconnecter",
|
||||
"dawarichDisconnectTitle": "Déconnecter Dawarich ?",
|
||||
"dawarichDisconnectDescription": "Les prochaines récupérations s’arrêtent. Les jours déjà enregistrés restent.",
|
||||
"dawarichDisconnectDelete": "Déconnecter et supprimer les jours",
|
||||
"dawarichDisconnectDeleteTitle": "Déconnecter et supprimer tous les jours ?",
|
||||
"dawarichDisconnectDeleteDescription": "La connexion et tous les jours de déplacement enregistrés sont supprimés. C’est irréversible.",
|
||||
"dawarichHomePlaceSet": "Le domicile est défini.",
|
||||
"dawarichHomePlaceMissing": "Définis ton domicile pour que le temps hors de chez toi puisse être mesuré.",
|
||||
"dawarichHomeCountryIs": "Pays de résidence : {country}.",
|
||||
"dawarichHomeCountryMissing": "Pas encore de pays de résidence, donc pas de jours de voyage.",
|
||||
"dawarichDaysStored": "Jours enregistrés : {count}",
|
||||
"dawarichHomePlace": "Domicile",
|
||||
"dawarichHomePlaceChoose": "Choisis un lieu dans Dawarich",
|
||||
"dawarichHomePlaceChange": "Choisir le domicile",
|
||||
"dawarichHomeCountry": "Pays de résidence",
|
||||
"dawarichHomeCountryHelp": "Code pays à deux lettres, par exemple DE. Laissé vide, c’est le pays où tu as passé le plus de jours l’an dernier qui compte.",
|
||||
"dawarichHomeSave": "Enregistrer",
|
||||
"dawarichHomeSaved": "Enregistré.",
|
||||
"dawarichErrors": {
|
||||
"invalid": "Cette adresse ne fonctionne pas. Utilise l’adresse de l’instance sans identifiants.",
|
||||
"privateOrigin": "Cette instance est sur un réseau privé. Demande à l’opérateur du serveur de l’autoriser.",
|
||||
"credentials": "Dawarich n’a pas accepté la clé d’API.",
|
||||
"unreachable": "Dawarich est injoignable. Vérifie l’adresse et que l’instance fonctionne.",
|
||||
"rateLimited": "Trop de tentatives. Patiente un instant, puis réessaie.",
|
||||
"homePlaceUnknown": "L’instance Dawarich ne connaît plus ce lieu.",
|
||||
"notConnected": "Dawarich n’est pas connecté.",
|
||||
"generic": "Ça n’a pas marché. Réessaie."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -7343,7 +7507,8 @@
|
||||
"spendToday": "Aujourd'hui, selon le jour civil de chaque compte : {total} jetons, dont {operator} sur la clé de l'opérateur (comptes avec consommation : {accounts}).",
|
||||
"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."
|
||||
"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.",
|
||||
"usageLevels": "Comptes par niveau d'usage de l'IA : {frugal} économe, {standard} standard, {full} complet."
|
||||
},
|
||||
"aiQuality": {
|
||||
"title": "Qualité des analyses",
|
||||
@@ -7753,7 +7918,8 @@
|
||||
"eventDetails": "Détails de l'événement",
|
||||
"closeDetails": "Fermer les détails",
|
||||
"processDetail": "Le tampon est volatil et limité à 500 entrées.",
|
||||
"cardTitle": "Événements récents"
|
||||
"cardTitle": "Événements récents",
|
||||
"filterErrorId": "ID d'erreur"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Sauvegardes",
|
||||
@@ -7840,6 +8006,7 @@
|
||||
"restoreSkippedCoachAttachment": "Lien de document du Coach",
|
||||
"restoreSkippedCoachReference": "Référence du Coach",
|
||||
"restoreSkippedDocumentConditionLink": "Classement du document",
|
||||
"restoreSkippedRelationLink": "Lien entre entrées",
|
||||
"restoreSkippedExtractedFact": "Donnée du document",
|
||||
"restoreSkippedFactCommitment": "Enregistrement de la donnée",
|
||||
"docsLink": "Documentation",
|
||||
@@ -8016,6 +8183,56 @@
|
||||
"started": "Maintenance lancée.",
|
||||
"alreadyQueued": "Une maintenance est déjà en file d'attente ou en cours.",
|
||||
"startFailed": "Impossible de lancer la maintenance."
|
||||
},
|
||||
"pulseDayMean": {
|
||||
"title": "Moyenne journalière du pouls",
|
||||
"description": "Comment les heures avec peu de mesures comptent dans le pouls d'une journée.",
|
||||
"body": "Jusqu'ici, chaque heure avec une mesure compte autant, si bien qu'une seule mesure isolée peut déplacer toute une journée. La méthode pondérée fait compter une heure pleinement à partir de trois mesures, et une mesure isolée pour un tiers. Lancez d'abord la simulation pour voir combien de jours changeraient. L'application recalcule en arrière-plan les journées de pouls enregistrées.",
|
||||
"method": {
|
||||
"hourly": "Toutes les heures à égalité",
|
||||
"weighted": "Heures clairsemées pondérées"
|
||||
},
|
||||
"loading": "Vérification de la moyenne journalière du pouls…",
|
||||
"loadError": "Impossible de lire l'état de la moyenne journalière du pouls.",
|
||||
"state": {
|
||||
"idle": "Aucune simulation pour l'instant.",
|
||||
"queued": "La simulation attend le processus d'arrière-plan.",
|
||||
"running": "La simulation est en cours.",
|
||||
"done": "Simulation terminée le {at}.",
|
||||
"failed": "La simulation s'est arrêtée. Le journal de l'application en indique la cause ; relancez-la."
|
||||
},
|
||||
"result": {
|
||||
"days": "Jours comparés",
|
||||
"changed": "Jours qui bougent de plus de 1 bpm",
|
||||
"max": "Plus grand écart",
|
||||
"maxValue": "{value} bpm",
|
||||
"bucket": "{range} bpm",
|
||||
"account": "Compte {account}",
|
||||
"accountDays": "{changed} jours sur {days} changent"
|
||||
},
|
||||
"appliedAt": "Méthode pondérée appliquée le {at}.",
|
||||
"dryRun": "Simulation",
|
||||
"apply": "Appliquer",
|
||||
"confirmTitle": "Appliquer la moyenne journalière pondérée du pouls ?",
|
||||
"confirmBody": "Les journées de pouls de tous les comptes sont alors calculées avec les heures clairsemées pondérées. Les journées enregistrées sont recalculées en arrière-plan, ce qui peut prendre un moment sur une grande instance.",
|
||||
"confirm": "Appliquer",
|
||||
"cancel": "Annuler",
|
||||
"dryRunStarted": "Simulation lancée.",
|
||||
"applied": "Méthode pondérée appliquée. Les journées de pouls sont en cours de recalcul.",
|
||||
"appliedNoRebuild": "Méthode pondérée appliquée. Une autre exécution est encore en cours ; appliquez de nouveau une fois terminée pour recalculer les journées de pouls.",
|
||||
"error": {
|
||||
"busy": "Une simulation ou un recalcul est encore en cours. Réessayez une fois terminé.",
|
||||
"dryRunRequired": "Lancez d'abord une simulation. L'application en exige une terminée au cours des sept derniers jours.",
|
||||
"generic": "La requête n'a pas abouti."
|
||||
},
|
||||
"rebuild": {
|
||||
"queued": "Le recalcul attend le processus en arrière-plan.",
|
||||
"running": "Les jours de pouls sont en cours de recalcul.",
|
||||
"done": "Jours de pouls recalculés le {at} pour {accounts} comptes.",
|
||||
"failed": "Le recalcul s’est arrêté. Le journal de l’app en indique la cause ; relancez-le."
|
||||
},
|
||||
"rebuildAgain": "Recalculer à nouveau",
|
||||
"rebuildStarted": "Recalcul lancé."
|
||||
}
|
||||
},
|
||||
"carrier": "Opérateur",
|
||||
@@ -8120,7 +8337,25 @@
|
||||
"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"
|
||||
"userColumn": "Utilisateur",
|
||||
"supportReport": {
|
||||
"title": "Rapport d'assistance",
|
||||
"description": "Un résumé de cette instance pour un signalement de bug, sans e-mails, identifiants ni valeurs.",
|
||||
"body": "Les comptes apparaissent comme « Account 1 », « Account 2 », etc. Lisez le rapport avant de le partager.",
|
||||
"copy": "Copier",
|
||||
"copied": "Copié",
|
||||
"download": "Télécharger",
|
||||
"loadError": "Le rapport d'assistance n'a pas pu être créé."
|
||||
},
|
||||
"aiUsage": {
|
||||
"title": "Niveaux d'usage de l'IA",
|
||||
"description": "Le niveau le plus élevé que les comptes peuvent choisir et le niveau de départ.",
|
||||
"maxLabel": "Niveau le plus élevé",
|
||||
"defaultLabel": "Niveau par défaut",
|
||||
"defaultAboveCeiling": "Le niveau par défaut ne peut pas dépasser le niveau le plus élevé.",
|
||||
"gapLabel": "Heures entre deux mises à jour des cartes après une synchronisation (Standard)",
|
||||
"gapHint": "Heures entières de 1 à 48. Laissez vide pour utiliser AI_SYNC_REGEN_GAP_HOURS, sinon 6. En vigueur : {hours} h."
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Réussites",
|
||||
@@ -9215,7 +9450,9 @@
|
||||
"periodStartSaved": "Début des règles enregistré",
|
||||
"periodEndSaved": "Fin des règles enregistrée",
|
||||
"startedPeriodOnDate": "Mes règles ont commencé ce jour-là",
|
||||
"endedPeriodOnDate": "Mes règles se sont terminées ce jour-là"
|
||||
"endedPeriodOnDate": "Mes règles se sont terminées ce jour-là",
|
||||
"startAbsorbed": "Début des règles enregistré. Votre début du {absorbed} a été fusionné avec celui du {date}.",
|
||||
"startRestored": "Enregistré. Le début du {dates} est de retour."
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Règles probablement bientôt",
|
||||
@@ -9604,12 +9841,16 @@
|
||||
"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."
|
||||
"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.",
|
||||
"alkaline-phosphatase": "Les phosphatases alcalines (PAL) sont des enzymes présentes surtout dans le foie, les voies biliaires et l'os. Elles augmentent lorsque l'écoulement de la bile est entravé et pendant les phases de formation osseuse active, comme la croissance chez l'enfant et l'adolescent ou la consolidation d'une fracture. Comme les deux organes les libèrent, une valeur élevée se lit en général avec la GGT et les autres marqueurs hépatiques pour distinguer une origine hépatique d'une origine osseuse. Elles font partie du bilan hépatique habituel.",
|
||||
"bilirubin-total": "La bilirubine totale est le produit jaune de la dégradation de l'hémoglobine des globules rouges usés, mesuré comme la somme de ses formes non conjuguée et conjuguée. Le foie la capte, la conjugue et l'élimine dans la bile, si bien que son taux reflète le bon fonctionnement de cette voie. Elle peut augmenter en cas de maladie du foie, d'obstruction des voies biliaires ou de destruction accrue des globules rouges, et sans gravité dans le syndrome de Gilbert. Elle se lit avec les autres marqueurs hépatiques."
|
||||
},
|
||||
"apob": "Apolipoprotéine B (ApoB)",
|
||||
"lp-a": "Lipoprotéine(a)",
|
||||
"fasting-insulin": "Insuline à jeun",
|
||||
"omega-3-index": "Indice oméga-3"
|
||||
"omega-3-index": "Indice oméga-3",
|
||||
"alkaline-phosphatase": "Phosphatases alcalines (PAL)",
|
||||
"bilirubin-total": "Bilirubine totale"
|
||||
},
|
||||
"noteLoadFailed": "La note n’a pas pu être chargée ; elle restera inchangée à l’enregistrement.",
|
||||
"showingLatestOf": "Affichage des {shown} derniers résultats sur {total}",
|
||||
@@ -9683,6 +9924,54 @@
|
||||
"fromReport": "Plage du compte rendu {range}",
|
||||
"catalogDiffers": "votre plage {range}",
|
||||
"printed": "imprimé en {unit} : {range}"
|
||||
},
|
||||
"match": {
|
||||
"viaAlias": "par un nom que tu as choisi auparavant",
|
||||
"viaCatalog": "reconnu comme une abréviation courante",
|
||||
"chooseLabel": "Correspond à",
|
||||
"suggestionOption": "{name} ({unit})",
|
||||
"newMarkerOption": "Un nouveau biomarqueur",
|
||||
"rememberHint": "Ce nom sera retenu pour le prochain compte rendu.",
|
||||
"chooseHint": "Choisis un biomarqueur si tu le suis déjà."
|
||||
},
|
||||
"aliases": {
|
||||
"title": "Aussi écrit",
|
||||
"taken": "{alias} appartient déjà à un autre biomarqueur.",
|
||||
"saveError": "Les noms n'ont pas pu être enregistrés. Réessaie.",
|
||||
"remove": "Retirer {alias}",
|
||||
"empty": "Ajoute les noms que d'autres laboratoires impriment pour {name}, pour que leurs comptes rendus arrivent ici.",
|
||||
"placeholder": "p. ex. PAL",
|
||||
"addLabel": "Autre nom pour ce biomarqueur",
|
||||
"add": "Ajouter",
|
||||
"alsoWritten": "Aussi écrit {aliases}"
|
||||
},
|
||||
"merge": {
|
||||
"open": "Fusionner",
|
||||
"title": "Fusionner des biomarqueurs",
|
||||
"description": "Réunis les biomarqueurs qui sont le même examen écrit autrement.",
|
||||
"pickHint": "Sélectionne au moins deux biomarqueurs.",
|
||||
"keepLabel": "Conserver",
|
||||
"keepHint": "Les autres sont fondus dans celui-ci et leurs noms deviennent ses autres noms.",
|
||||
"showPreview": "Voir l'aperçu",
|
||||
"back": "Retour",
|
||||
"confirm": "Fusionner maintenant",
|
||||
"previewSummaryOne": "{count} mesure passe à {name} ({unit}).",
|
||||
"previewSummaryFew": "{count} mesures passent à {name} ({unit}).",
|
||||
"previewSummaryOther": "{count} mesures passent à {name} ({unit}).",
|
||||
"movesOne": "{count} mesure, déjà dans la même unité",
|
||||
"movesFew": "{count} mesures, déjà dans la même unité",
|
||||
"movesOther": "{count} mesures, déjà dans la même unité",
|
||||
"convertedOne": "{count} mesure, convertie de {unit} en {targetUnit}",
|
||||
"convertedFew": "{count} mesures, converties de {unit} en {targetUnit}",
|
||||
"convertedOther": "{count} mesures, converties de {unit} en {targetUnit}",
|
||||
"refused": "{unit} ne peut pas être converti en {targetUnit}.",
|
||||
"aliasesAdded": "Ces noms mèneront au biomarqueur conservé :",
|
||||
"blocked": "Rien n'est fusionné tant qu'une unité ne peut pas être convertie. Laisse ce biomarqueur de côté ou conserve-en un autre.",
|
||||
"noUndo": "Une fusion ne peut pas être annulée.",
|
||||
"doneToastOne": "Fusionné dans {name}, {count} mesure déplacée",
|
||||
"doneToastFew": "Fusionné dans {name}, {count} mesures déplacées",
|
||||
"doneToastOther": "Fusionné dans {name}, {count} mesures déplacées",
|
||||
"error": "Les biomarqueurs n'ont pas pu être fusionnés. Réessaie."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -10644,25 +10933,11 @@
|
||||
},
|
||||
"profileFacts": {
|
||||
"cardTitle": "Contexte du mode de vie",
|
||||
"cardDescription": "Indiquez les faits actuels qui modifient l'interprétation de vos mesures de santé.",
|
||||
"smokingLabel": "Tabagisme",
|
||||
"alcoholLabel": "Habitudes de consommation d'alcool",
|
||||
"shiftLabel": "Organisation du travail",
|
||||
"notRecorded": "Non renseigné",
|
||||
"unreadable": "La valeur enregistrée n'a pas pu être lue",
|
||||
"save": "Enregistrer",
|
||||
"savedToast": "Contexte du mode de vie enregistré",
|
||||
"savePartialError": "Impossible d'enregistrer {kinds}. Veuillez réessayer.",
|
||||
"saveKindError": "Non enregistré",
|
||||
"loadError": "Impossible de charger le contexte du mode de vie",
|
||||
"remove": "Supprimer : {kind}",
|
||||
"removeShort": "Supprimer",
|
||||
"removeConfirmTitle": "Supprimer : {kind} ?",
|
||||
"removeConfirmDescription": "La valeur actuelle sera considérée comme non renseignée. Son historique daté sera conservé.",
|
||||
"removedToast": "{kind} supprimé du contexte actuel du mode de vie",
|
||||
"removeError": "Impossible de supprimer : {kind}",
|
||||
"historyTitle": "Valeurs précédentes",
|
||||
"to": "à",
|
||||
"values": {
|
||||
"SMOKING_STATUS": {
|
||||
"NEVER": "N'a jamais fumé",
|
||||
@@ -10842,6 +11117,235 @@
|
||||
"savedToast": "Informations d'urgence enregistrées",
|
||||
"saveError": "Impossible d'enregistrer vos informations d'urgence.",
|
||||
"loadError": "Impossible de charger vos informations d'urgence."
|
||||
},
|
||||
"life": {
|
||||
"cardTitle": "Conditions de vie",
|
||||
"cardDescription": "Tabac, alcool, activité, travail, habitudes et objectifs, datés et tous facultatifs.",
|
||||
"loadError": "Impossible de charger vos conditions de vie.",
|
||||
"noAnswer": "Pas de réponse",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"validSince": "Valable depuis",
|
||||
"month": "Mois",
|
||||
"year": "Année",
|
||||
"lastConfirmed": "Confirmé pour la dernière fois le {date}",
|
||||
"notAnswered": "Pas encore répondu",
|
||||
"unreadable": "Cette réponse ne peut pas être lue sur ce serveur.",
|
||||
"save": "Enregistrer la section",
|
||||
"savedToast": "Section enregistrée",
|
||||
"saveError": "Impossible d'enregistrer la section.",
|
||||
"review": {
|
||||
"question": "Est-ce toujours vrai ? Votre dernière réponse date de plus d'un an.",
|
||||
"confirm": "Toujours vrai",
|
||||
"confirmedToast": "Merci, noté comme toujours valable",
|
||||
"confirmError": "Impossible d'enregistrer la confirmation."
|
||||
},
|
||||
"ai": {
|
||||
"label": "Partager cette section avec l'IA",
|
||||
"shiftLabel": "Partager le travail posté avec l'IA",
|
||||
"hint": "Désactivé, le Coach et les synthèses ne voient jamais ces réponses.",
|
||||
"savedToast": "Partage avec l'IA mis à jour",
|
||||
"saveError": "Impossible d'enregistrer le réglage de l'IA."
|
||||
},
|
||||
"sections": {
|
||||
"tobacco": {
|
||||
"title": "Tabac et nicotine"
|
||||
},
|
||||
"alcohol": {
|
||||
"title": "Alcool"
|
||||
},
|
||||
"activity": {
|
||||
"title": "Activité et position assise"
|
||||
},
|
||||
"work": {
|
||||
"title": "Travail et rythme"
|
||||
},
|
||||
"habits": {
|
||||
"title": "Sommeil, alimentation et caféine"
|
||||
},
|
||||
"goals": {
|
||||
"title": "Objectifs"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"smokingStatus": "Statut tabagique",
|
||||
"products": "Produits utilisés",
|
||||
"cigarettesPerDay": "Cigarettes par jour",
|
||||
"startYear": "Année de début",
|
||||
"quitMonth": "Date d'arrêt",
|
||||
"secondhandSmoke": "Fumée passive régulière",
|
||||
"moderateDaysPerWeek": "Jours par semaine avec une activité modérée à intense, comme la marche rapide",
|
||||
"moderateMinutesPerDay": "Minutes ces jours-là, en moyenne",
|
||||
"sittingHoursWorkday": "Heures assis un jour de travail",
|
||||
"shiftSchedule": "Travail posté",
|
||||
"situation": "Situation de travail",
|
||||
"physicalNature": "Type de travail",
|
||||
"nightWork": "Travail de nuit",
|
||||
"exposures": "Expositions au travail",
|
||||
"freeWeekdays": "Jours libres de la semaine",
|
||||
"napRegularly": "Sieste régulière",
|
||||
"snoringReported": "D'autres disent que vous ronflez",
|
||||
"dietPattern": "Alimentation",
|
||||
"dietSpecifics": "Particularités",
|
||||
"lateMealsPerWeek": "Repas après 21 h par semaine",
|
||||
"fruitVegPortionsPerDay": "Portions de fruits et légumes par jour",
|
||||
"caffeineDrinksPerDay": "Boissons caféinées par jour",
|
||||
"latestCaffeine": "Dernière caféine de la journée",
|
||||
"goals": "Ce sur quoi vous voulez travailler",
|
||||
"goalsHint": "Trois au maximum."
|
||||
},
|
||||
"values": {
|
||||
"products": {
|
||||
"cigarettes": "Cigarettes",
|
||||
"cigars_pipe": "Cigare ou pipe",
|
||||
"e_cigarette": "Cigarette électronique",
|
||||
"heated_tobacco": "Tabac chauffé",
|
||||
"snus_pouches": "Snus ou sachets de nicotine"
|
||||
},
|
||||
"situation": {
|
||||
"none": "Sans activité rémunérée",
|
||||
"indoor": "Surtout en intérieur",
|
||||
"outdoor": "Surtout en extérieur",
|
||||
"mixed": "Intérieur et extérieur"
|
||||
},
|
||||
"physicalNature": {
|
||||
"mostly_sitting": "Surtout assis",
|
||||
"standing_walking": "Debout ou en marchant",
|
||||
"heavy_physical": "Travail physique lourd"
|
||||
},
|
||||
"exposures": {
|
||||
"heat": "Chaleur",
|
||||
"cold": "Froid",
|
||||
"dust": "Poussière",
|
||||
"fumes_solvents": "Fumées ou solvants",
|
||||
"noise": "Bruit"
|
||||
},
|
||||
"dietPattern": {
|
||||
"mixed": "Alimentation mixte",
|
||||
"vegetarian": "Végétarienne",
|
||||
"vegan": "Végane",
|
||||
"pescetarian": "Pescétarienne",
|
||||
"other": "Autre"
|
||||
},
|
||||
"dietSpecifics": {
|
||||
"intermittent_fasting": "Jeûne intermittent",
|
||||
"low_carb": "Pauvre en glucides",
|
||||
"prescribed_diet": "Régime prescrit par un médecin"
|
||||
},
|
||||
"latestCaffeine": {
|
||||
"before_12": "Avant midi",
|
||||
"12_15": "De midi à 15 h",
|
||||
"15_18": "De 15 h à 18 h",
|
||||
"after_18": "Après 18 h"
|
||||
},
|
||||
"goals": {
|
||||
"weight": "Poids",
|
||||
"blood_pressure": "Tension artérielle",
|
||||
"blood_sugar": "Glycémie",
|
||||
"sleep": "Sommeil",
|
||||
"fitness": "Forme physique",
|
||||
"stress_recovery": "Stress et récupération",
|
||||
"medication_routine": "Prendre ses médicaments régulièrement",
|
||||
"quit_smoking": "Arrêter de fumer",
|
||||
"less_alcohol": "Boire moins d'alcool"
|
||||
},
|
||||
"weekdays": {
|
||||
"1": "lun.",
|
||||
"2": "mar.",
|
||||
"3": "mer.",
|
||||
"4": "jeu.",
|
||||
"5": "ven.",
|
||||
"6": "sam.",
|
||||
"7": "dim."
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
"packYears": "{value} paquets-années",
|
||||
"packYearsMissing": "Pour les paquets-années, il manque les cigarettes par jour ou l'année de début.",
|
||||
"activityMinutes": "{value} minutes par semaine.",
|
||||
"meetsWho": "Cela atteint la recommandation de l'OMS de 150 minutes par semaine.",
|
||||
"belowWho": "C'est en dessous de la recommandation de l'OMS de 150 minutes par semaine.",
|
||||
"measuredSteps": "Vos pas mesurés passent en premier ; cette réponse apporte du contexte.",
|
||||
"freeDaysDefault": "Sans réponse, le samedi et le dimanche comptent comme jours libres."
|
||||
},
|
||||
"auditC": {
|
||||
"intro": "Trois questions sur l'alcool tirées de l'AUDIT de l'OMS. Un dépistage, pas un diagnostic.",
|
||||
"start": "Répondre aux questions",
|
||||
"again": "Répondre à nouveau",
|
||||
"offerDue": "Un rapide coup d'œil une fois par an suffit.",
|
||||
"notValidated": "Il n'existe pas encore de traduction validée pour votre langue, les questions sont donc en anglais.",
|
||||
"submit": "Enregistrer les réponses",
|
||||
"savedToast": "Réponses enregistrées",
|
||||
"saveError": "Impossible d'enregistrer les réponses.",
|
||||
"result": "{score} points sur 12",
|
||||
"above": "Au seuil de dépistage de {cutoff} ou au-dessus.",
|
||||
"below": "Sous le seuil de dépistage de {cutoff}.",
|
||||
"noCutoff": "Sans sexe dans votre profil, seule la somme est affichée.",
|
||||
"source": "Source : {source}",
|
||||
"history": "Entrées précédentes",
|
||||
"remove": "Supprimer l'entrée",
|
||||
"removeConfirmTitle": "Supprimer cette entrée ?",
|
||||
"removeConfirmBody": "L'entrée quitte votre historique. Impossible d'annuler ici.",
|
||||
"removedToast": "Entrée supprimée",
|
||||
"removeError": "Impossible de supprimer l'entrée.",
|
||||
"historyEntry": "{date} : {score} sur 12",
|
||||
"legacyLabel": "Réponse précédente : {value}",
|
||||
"legacyHint": "Issue de la courte question sur l’alcool posée avant ce questionnaire. Dès qu’un résultat du questionnaire existe, le Coach ne la lit plus.",
|
||||
"legacyRemove": "Supprimer la réponse précédente",
|
||||
"legacyRemoveConfirmTitle": "Supprimer la réponse précédente ?",
|
||||
"legacyRemoveConfirmBody": "La réponse précédente quitte votre dossier. Les résultats du questionnaire restent.",
|
||||
"legacyRemovedToast": "Réponse précédente supprimée",
|
||||
"legacyRemoveError": "La réponse précédente n’a pas pu être supprimée."
|
||||
},
|
||||
"takeOver": {
|
||||
"conditionsButton": "Reprendre dans le journal des affections",
|
||||
"allergiesButton": "Reprendre dans les allergies",
|
||||
"conditionsTitle": "Ajouter une maladie chronique",
|
||||
"allergiesTitle": "Ajouter une allergie",
|
||||
"description": "Votre texte libre reste tel quel jusqu'à ce que vous l'effaciez vous-même.",
|
||||
"freeTextLabel": "Votre texte libre",
|
||||
"conditionLabel": "Maladie",
|
||||
"onset": "Depuis",
|
||||
"save": "Ajouter",
|
||||
"savedToast": "Entrée ajoutée",
|
||||
"saveError": "Impossible d'ajouter l'entrée.",
|
||||
"clearQuestion": "L'entrée est enregistrée. Effacer le texte libre maintenant ?",
|
||||
"clear": "Effacer le texte libre",
|
||||
"keep": "Le garder",
|
||||
"clearedToast": "Texte libre effacé",
|
||||
"clearError": "Impossible d'effacer le texte libre."
|
||||
},
|
||||
"screening": {
|
||||
"cardTitle": "Indications de dépistage",
|
||||
"cardDescription": "Tirées de vos conditions de vie, avec la règle et le pays d'origine.",
|
||||
"noReminder": "Ce n'est qu'une indication ; créez vous-même un rappel si vous en voulez un.",
|
||||
"source": "Source : {source}",
|
||||
"titles": {
|
||||
"lung_ldct": "Scanner faible dose pour le cancer du poumon",
|
||||
"aaa_ultrasound": "Échographie de l'aorte abdominale"
|
||||
},
|
||||
"status": {
|
||||
"suggested": "Pourrait vous concerner",
|
||||
"not_eligible": "Ne vous concerne pas",
|
||||
"unknown": "Informations insuffisantes"
|
||||
},
|
||||
"reasons": {
|
||||
"never_smoked": "Vous n'avez jamais fumé.",
|
||||
"missing_birth_date": "Votre date de naissance manque.",
|
||||
"age_out_of_range": "Votre âge est hors de la tranche.",
|
||||
"missing_smoking_status": "Votre statut tabagique manque.",
|
||||
"no_cigarettes": "La règle ne compte que les cigarettes.",
|
||||
"missing_smoking_history": "Il manque les cigarettes par jour ou l'année de début.",
|
||||
"pack_years_below_15": "Moins de 15 paquets-années.",
|
||||
"pack_years_below_20": "Moins de 20 paquets-années.",
|
||||
"smoked_years_below_25": "Moins de 25 ans de tabagisme.",
|
||||
"missing_quit_date": "La date d'arrêt manque.",
|
||||
"quit_over_10_years": "Arrêt il y a plus de 10 ans.",
|
||||
"quit_over_15_years": "Arrêt il y a plus de 15 ans.",
|
||||
"missing_sex": "Le sexe manque dans votre profil.",
|
||||
"sex_not_male": "La règle s'applique aux hommes."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
@@ -10875,6 +11379,10 @@
|
||||
"mugwort": "armoise",
|
||||
"olive": "olivier",
|
||||
"ragweed": "ambroisie"
|
||||
},
|
||||
"movement": {
|
||||
"awayFromHome": "Temps hors de chez soi",
|
||||
"distance": "Distance parcourue"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -11122,7 +11630,8 @@
|
||||
"extractEmpty": "Aucune valeur n’a pu être extraite de ce document.",
|
||||
"failUnitRequired": "Une valeur numérique a besoin d’une unité avant de pouvoir être enregistrée.",
|
||||
"failUnitMismatch": "L’unité indiquée ne correspond pas à l’unité enregistrée pour ce marqueur. Ajustez-la, puis terminez à nouveau la vérification.",
|
||||
"failGeneric": "Cette valeur n’a pas pu être enregistrée."
|
||||
"failGeneric": "Cette valeur n’a pas pu être enregistrée.",
|
||||
"failUnknownBiomarker": "Le biomarqueur choisi n'existe plus. Choisis-en un autre."
|
||||
},
|
||||
"contentIndex": {
|
||||
"indexPrompt": "Rendre le contenu des documents consultable.",
|
||||
@@ -11344,7 +11853,9 @@
|
||||
"molybdenum": "Molybdène",
|
||||
"iodine": "Iode",
|
||||
"water": "Eau",
|
||||
"caffeine": "Caféine"
|
||||
"caffeine": "Caféine",
|
||||
"dha": "DHA (oméga-3)",
|
||||
"epa": "EPA (oméga-3)"
|
||||
},
|
||||
"page": {
|
||||
"title": "Apports en nutriments",
|
||||
@@ -11376,9 +11887,26 @@
|
||||
"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"
|
||||
"loadError": "Tes données de nutriments n'ont pas pu être chargées",
|
||||
"sourcePart": "{source} {value} {unit}",
|
||||
"aboveUpperLimit": "Au-dessus de la limite supérieure de sécurité pour les adultes de {value} {unit} par jour ({source}).",
|
||||
"aboveUpperLimitSupplemental": "La part des compléments dépasse la limite supérieure de sécurité pour les adultes de {value} {unit} par jour pour les compléments ({source}).",
|
||||
"upperLimitForm": {
|
||||
"retinol": "La limite ne concerne que la vitamine A préformée (rétinol) : si la quantité provient du bêta-carotène, elle ne s'applique pas.",
|
||||
"folic_acid": "La limite concerne l'acide folique des compléments et des aliments enrichis, pas les folates de l'alimentation courante.",
|
||||
"magnesium_salts": "La limite concerne les sels de magnésium facilement dissociables des compléments, pas le magnésium des aliments.",
|
||||
"alpha_tocopherol": "La limite est fixée pour l'alpha-tocophérol."
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"units": {
|
||||
"IU": "UI"
|
||||
},
|
||||
"sources": {
|
||||
"APPLE_HEALTH": "Synchronisé",
|
||||
"MANUAL": "Saisi",
|
||||
"SUPPLEMENT": "Compléments"
|
||||
}
|
||||
},
|
||||
"daily": {
|
||||
"line": {
|
||||
@@ -11880,9 +12408,11 @@
|
||||
"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.",
|
||||
"oidcOnly": "Transmettre un profil n'est pas possible sur ce serveur.",
|
||||
"loadError": "L'état de la transmission n'a pas pu être chargé.",
|
||||
"entryHint": "Quand {name} souhaite tenir ce dossier de façon autonome."
|
||||
"entryHint": "Quand {name} souhaite tenir ce dossier de façon autonome.",
|
||||
"methodSso": "{name} se connecte par authentification unique en utilisant le lien.",
|
||||
"methodBoth": "{name} peut définir un mot de passe ou se connecter par authentification unique."
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
@@ -12008,7 +12538,11 @@
|
||||
"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"
|
||||
"lifeEventDelete": "{name} a supprimé un événement de vie",
|
||||
"biomarkerMerge": "{name} a fusionné des biomarqueurs",
|
||||
"medicationIngredientsUpdate": "{name} a modifié la composition d’un complément",
|
||||
"auditCCreate": "{name} a répondu au questionnaire AUDIT-C",
|
||||
"auditCDelete": "{name} a supprimé une entrée AUDIT-C"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
@@ -12227,7 +12761,34 @@
|
||||
"removed": "{name} dissocié",
|
||||
"linked": "Liés",
|
||||
"loadNewer": "Charger les plus récents",
|
||||
"loadOlder": "Charger les plus anciens"
|
||||
"loadOlder": "Charger les plus anciens",
|
||||
"period": {
|
||||
"showing": "Période : {period}"
|
||||
}
|
||||
},
|
||||
"related": {
|
||||
"title": "Liés",
|
||||
"kind": {
|
||||
"labResult": "Résultat d’analyse",
|
||||
"encounter": "Consultation",
|
||||
"document": "Document",
|
||||
"conditionEpisode": "Maladie",
|
||||
"medication": "Médicament",
|
||||
"vaccination": "Vaccination"
|
||||
},
|
||||
"via": {
|
||||
"encounter": "via une consultation",
|
||||
"efficacyTarget": "via la cible d’efficacité"
|
||||
},
|
||||
"pick": {
|
||||
"conditionEpisode": "Maladies",
|
||||
"document": "Documents",
|
||||
"encounter": "Consultations",
|
||||
"medication": "Médicaments",
|
||||
"vaccination": "Vaccinations"
|
||||
},
|
||||
"nothingToOffer": "Rien à lier pour l’instant.",
|
||||
"saveError": "Le lien n’a pas pu être enregistré."
|
||||
}
|
||||
},
|
||||
"bodySites": {
|
||||
@@ -12369,7 +12930,8 @@
|
||||
"scores": "Scores",
|
||||
"running": "En cours ce jour-là",
|
||||
"values": "Valeurs",
|
||||
"happened": "Ce qui s’est passé"
|
||||
"happened": "Ce qui s’est passé",
|
||||
"movement": "En déplacement"
|
||||
},
|
||||
"notable": {
|
||||
"extremeHigh": "Valeur journalière la plus haute depuis {since}",
|
||||
@@ -12425,7 +12987,18 @@
|
||||
"today": "Aujourd’hui",
|
||||
"hasEntries": "avec des entrées",
|
||||
"dayNSince": "Jour {n}, depuis le {date}",
|
||||
"sinceDate": "Depuis le {date}"
|
||||
"sinceDate": "Depuis le {date}",
|
||||
"movement": {
|
||||
"total": "Distance",
|
||||
"driving": "En voiture",
|
||||
"train": "En train",
|
||||
"places": "Lieux visités",
|
||||
"away": "Hors de chez toi",
|
||||
"travel": "Jour de voyage",
|
||||
"travelYes": "Oui",
|
||||
"kilometres": "{value} km",
|
||||
"miles": "{value} mi"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Chronologie",
|
||||
@@ -12760,5 +13333,13 @@
|
||||
"lastUpdated": "Dernière mise à jour : {date}",
|
||||
"project": "Projet",
|
||||
"credits": "Crédits"
|
||||
},
|
||||
"series": {
|
||||
"reference": {
|
||||
"targetUpper": "Votre objectif, limite haute",
|
||||
"targetLower": "Votre objectif, limite basse",
|
||||
"clinicalUpper": "Limite clinique haute",
|
||||
"clinicalLower": "Limite clinique basse"
|
||||
}
|
||||
}
|
||||
}
|
||||
+622
-41
@@ -51,7 +51,10 @@
|
||||
"durationHoursMinutes": "{hours} h {minutes} min",
|
||||
"durationMinutes": "{minutes} min",
|
||||
"durationClockMinutes": "{time} min",
|
||||
"durationClockHours": "{time} h"
|
||||
"durationClockHours": "{time} h",
|
||||
"errorIdLabel": "ID errore",
|
||||
"errorIdCopy": "Copia ID errore",
|
||||
"errorIdCopied": "ID errore copiato"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -154,6 +157,20 @@
|
||||
"event": {
|
||||
"notFound": "Questa voce non esiste più."
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
"notFound": "Questo farmaco non è più disponibile.",
|
||||
"ingredients": {
|
||||
"invalid": "Controlla la composizione e riprova.",
|
||||
"unitMismatch": "Questa unità non è prevista per il nutriente.",
|
||||
"extract": {
|
||||
"fileType": "Carica un JPEG, PNG, WebP o PDF.",
|
||||
"fileTooLarge": "Il file supera i 10 MB.",
|
||||
"rateLimited": "Troppe letture di etichette. Riprova tra un'ora.",
|
||||
"budgetExceeded": "Hai raggiunto il budget di IA di oggi.",
|
||||
"failed": "Non è stato possibile leggere l'etichetta. Prova con una foto più nitida."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -367,7 +384,20 @@
|
||||
"notes": "Note aggiuntive"
|
||||
},
|
||||
"courseRange": "Ciclo dal {start} al {end}",
|
||||
"courseSince": "Ciclo dal {start}"
|
||||
"courseSince": "Ciclo dal {start}",
|
||||
"lifePackYears": "Pacchetti-anno",
|
||||
"lifeAuditC": "AUDIT-C (valutazione di screening)",
|
||||
"lifeAuditCScore": "{score} su 12 il {date}.",
|
||||
"lifeAuditCAbove": "Pari o sopra la soglia di {cutoff} ({source}).",
|
||||
"lifeAuditCBelow": "Sotto la soglia di {cutoff} ({source}).",
|
||||
"lifeAuditCNoCutoff": "Nessuna soglia specifica per sesso applicata.",
|
||||
"lifeActivity": "Attività fisica",
|
||||
"lifeActivityValue": "{minutes} minuti a settimana, dichiarati",
|
||||
"lifeSitting": "Tempo seduto in un giorno lavorativo",
|
||||
"lifeSittingValue": "{hours} ore, dichiarate",
|
||||
"lifeWork": "Lavoro",
|
||||
"lifeNightWork": "lavoro notturno",
|
||||
"lifeExposures": "esposizioni: {list}"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Qualcosa è andato storto",
|
||||
@@ -525,7 +555,7 @@
|
||||
"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.",
|
||||
"oidcOnly": "Questo server consente solo il single sign-on. Continua invece con il tuo provider di identità.",
|
||||
"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",
|
||||
@@ -542,7 +572,19 @@
|
||||
"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."
|
||||
"errorOffline": "Il server non è stato raggiunto. Controlla la connessione e riprova.",
|
||||
"ssoContinue": "Continua con {provider}",
|
||||
"ssoStarting": "Reindirizzamento…",
|
||||
"ssoHint": "Da quel momento accedi qui con il tuo account {provider}.",
|
||||
"ssoDivider": "oppure",
|
||||
"ssoFallbackName": "single sign-on (SSO)",
|
||||
"errorTitle": "La consegna non è andata a buon fine",
|
||||
"errorSubjectBound": "Questo accesso appartiene già a un altro account su questo server. Riapri il link e usa un altro account presso il tuo provider di identità.",
|
||||
"errorEmailTaken": "Il tuo provider di identità ha fornito un indirizzo email che qui usa già un altro account. Riapri il link e usa un altro account, oppure chiedi a chi amministra il server.",
|
||||
"errorUsernameTaken": "Nel frattempo qualcuno ha preso quel nome utente. Riapri il link e scegline un altro.",
|
||||
"errorSignedIn": "Al ritorno avevi già effettuato l'accesso. Esci, poi riapri il link.",
|
||||
"errorSso": "L'accesso con il tuo provider di identità non è riuscito. Riapri il link per riprovare.",
|
||||
"signInInstead": "Accedi a quell'account"
|
||||
},
|
||||
"inviteInvalid": {
|
||||
"title": "Invito non valido",
|
||||
@@ -2399,6 +2441,34 @@
|
||||
"invalidRange": "L’ultimo giorno non può precedere il primo.",
|
||||
"generic": "Il salvataggio non è riuscito. Riprova."
|
||||
}
|
||||
},
|
||||
"composition": {
|
||||
"title": "Composizione",
|
||||
"empty": "Aggiungi cosa contiene una compressa o capsula, così ogni dose assunta conta nei tuoi micronutrienti.",
|
||||
"perUnitNote": "Per compressa o capsula; ogni dose assunta si aggiunge ai micronutrienti del giorno.",
|
||||
"add": "Aggiungi",
|
||||
"edit": "Modifica",
|
||||
"rowHelper": "Cosa contiene una compressa o capsula, per i micronutrienti.",
|
||||
"sheetTitle": "Cosa contiene un'unità",
|
||||
"sheetDescription": "Inserisci le quantità per compressa o capsula come riportate in etichetta.",
|
||||
"nutrientLabel": "Nutriente",
|
||||
"amountLabel": "Quantità",
|
||||
"unitLabel": "Unità",
|
||||
"choose": "Scegli un nutriente",
|
||||
"addRow": "Aggiungi un nutriente",
|
||||
"removeRow": "Rimuovi {name}",
|
||||
"editorEmpty": "Nessun nutriente ancora. Aggiungine uno per ogni riga dell'etichetta.",
|
||||
"labelPhoto": "Leggi da una foto dell'etichetta",
|
||||
"labelReading": "Lettura dell'etichetta…",
|
||||
"labelWaiting": "Il servizio in background non ha ancora preso in carico l'etichetta.",
|
||||
"labelReviewNote": "Controlla ogni riga con l'etichetta prima di salvare.",
|
||||
"lowConfidence": "Questa riga era difficile da leggere; controllala con l'etichetta.",
|
||||
"unmatchedTitle": "Righe senza nutriente corrispondente, da inserire a mano se serve:",
|
||||
"servingNote": "L'etichetta riporta: {note}. Le quantità sono indicate per singola unità.",
|
||||
"labelError": "Non è stato possibile leggere l'etichetta",
|
||||
"saveError": "Non è stato possibile salvare la composizione",
|
||||
"catalogError": "Non è stato possibile caricare l'elenco dei nutrienti",
|
||||
"invalidRow": "Scegli un nutriente e inserisci una quantità maggiore di zero in ogni riga."
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -2666,7 +2736,8 @@
|
||||
"bmiEmptyAction": "Apri impostazioni account",
|
||||
"personalRecord": {
|
||||
"badge": "PR",
|
||||
"tooltip": "Record personale: {value} {unit} il {date}"
|
||||
"tooltip": "Record personale: {value} {unit} il {date}",
|
||||
"benchmark": "Tipico a quell’età: da {low} a {high} {unit} ({source})"
|
||||
},
|
||||
"loadError": "Impossibile caricare le analisi",
|
||||
"heroPersonalBaseline": "Basato sugli ultimi 90 giorni",
|
||||
@@ -4220,6 +4291,21 @@
|
||||
"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.",
|
||||
"featureValue": "{field}: {value}"
|
||||
},
|
||||
"patterns": {
|
||||
"title": "Schemi nel tuo umore",
|
||||
"basedOn": "Basato su {subject} e {comparison} giorni. Uno schema, non una causa.",
|
||||
"previousDayHigher": "Dopo un giorno sotto la tua media, il giorno seguente tende a essere migliore ({subject} contro {comparison} dopo un giorno sopra).",
|
||||
"previousDayLower": "Un giorno sotto la tua media tende a trascinarsi in quello seguente ({subject} contro {comparison} dopo un giorno sopra).",
|
||||
"weekendHigher": "Il tuo umore tende a essere migliore nel fine settimana ({subject} contro {comparison} nei giorni feriali).",
|
||||
"weekendLower": "Il tuo umore tende a essere peggiore nel fine settimana ({subject} contro {comparison} nei giorni feriali).",
|
||||
"bestWeekday": "{weekday} tende a essere il tuo giorno migliore ({subject} contro {comparison} negli altri giorni).",
|
||||
"notesHigher": "Nei giorni con una nota il tuo umore tende a essere migliore ({subject} contro {comparison} senza).",
|
||||
"notesLower": "Nei giorni con una nota il tuo umore tende a essere peggiore ({subject} contro {comparison} senza).",
|
||||
"delayedTagHigher": "Il giorno dopo «{tag}», il tuo umore tende a essere migliore ({subject} contro {comparison} negli altri casi).",
|
||||
"delayedTagLower": "Il giorno dopo «{tag}», il tuo umore tende a essere peggiore ({subject} contro {comparison} negli altri casi).",
|
||||
"multipleEntriesHigher": "Nei giorni in cui registri più volte, il tuo umore tende a essere migliore ({subject} contro {comparison} con una sola voce).",
|
||||
"multipleEntriesLower": "Nei giorni in cui registri più volte, il tuo umore tende a essere peggiore ({subject} contro {comparison} con una sola voce)."
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
@@ -5459,7 +5545,7 @@
|
||||
"subtitle": "Decidi quale fonte prevale quando due riportano la stessa metrica.",
|
||||
"nutrients": {
|
||||
"title": "Assunzione di nutrienti sincronizzata",
|
||||
"description": "Totali giornalieri sincronizzati da Apple Health negli ultimi {days} giorni.",
|
||||
"description": "Totali giornalieri degli ultimi {days} giorni: quanto sincronizzato da Apple Health più gli integratori che hai assunto.",
|
||||
"empty": "Nessun dato sui nutrienti ancora sincronizzato.",
|
||||
"latest": "{amount} {unit} il {date}",
|
||||
"daysWithData": "{count} giorni su {days}",
|
||||
@@ -5821,7 +5907,10 @@
|
||||
"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.",
|
||||
"range": "Dal {start} al {end}"
|
||||
"range": "Dal {start} al {end}",
|
||||
"suggestionsIntro": "Dawarich ha trovato giorni all’estero non ancora coperti da un periodo.",
|
||||
"suggestionUse": "Usa",
|
||||
"suggestionDismiss": "Ignora il suggerimento"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Recupera i giorni passati",
|
||||
@@ -6255,7 +6344,8 @@
|
||||
"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",
|
||||
"cancel": "Annulla"
|
||||
"cancel": "Annulla",
|
||||
"restingFrugal": "In pausa al livello di IA a risparmio: i documenti vengono letti solo su richiesta."
|
||||
},
|
||||
"centralCodex": {
|
||||
"title": "Usa l'accesso IA condiviso del server",
|
||||
@@ -6313,7 +6403,33 @@
|
||||
"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"
|
||||
"runtimeTitle": "Verifica e aggiorna",
|
||||
"usage": {
|
||||
"title": "Uso dell'IA",
|
||||
"description": "Quanto lavoro di IA viene svolto in background per te, mentre il Coach resta uguale a ogni livello.",
|
||||
"levels": {
|
||||
"frugal": {
|
||||
"label": "Risparmio",
|
||||
"hint": "Le schede si aggiornano al massimo una volta al giorno dopo una sincronizzazione, un riepilogo giornaliero breve, i documenti vengono letti solo su richiesta."
|
||||
},
|
||||
"standard": {
|
||||
"label": "Standard",
|
||||
"hint": "Le schede si aggiornano ogni poche ore dopo una sincronizzazione, il riepilogo giornaliero completo."
|
||||
},
|
||||
"full": {
|
||||
"label": "Completo",
|
||||
"hint": "Le schede si aggiornano fino a ogni ora con nuovi dati, come prima di questa impostazione."
|
||||
}
|
||||
},
|
||||
"limited": "Limitato dall'amministratore del server.",
|
||||
"estimate": "circa {tokens} token al giorno, stima",
|
||||
"measured": "Misurato: {tokens} token al giorno negli ultimi {days} giorni, Coach compreso. I valori per livello sono stime.",
|
||||
"noEstimate": "La stima per livello compare dopo tre giorni di uso dell'IA.",
|
||||
"capped": "Hai scelto {choice}, che il server al momento non consente, quindi vale {level}. La tua scelta resta salvata.",
|
||||
"followsDefault": "Segui l'impostazione predefinita del server ({level}).",
|
||||
"useDefault": "Usa l'impostazione predefinita del server ({level})",
|
||||
"saveError": "Non è stato possibile salvare il livello. Riprova."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"units": {
|
||||
@@ -6646,7 +6762,14 @@
|
||||
"docs": "Documentazione",
|
||||
"credits": "Crediti e attribuzioni dei dati",
|
||||
"linksHeading": "Fonti e documentazione",
|
||||
"newerAvailable": "Nuova versione: {tag}"
|
||||
"newerAvailable": "Nuova versione: {tag}",
|
||||
"support": {
|
||||
"title": "Assistenza",
|
||||
"description": "Un riepilogo per una segnalazione di errore, senza nome, email o valori di salute.",
|
||||
"copy": "Copia le info di assistenza",
|
||||
"copied": "Copiato",
|
||||
"copyFailed": "Non è stato possibile raccogliere le info di assistenza."
|
||||
}
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Prova connessione",
|
||||
@@ -7133,7 +7256,8 @@
|
||||
"lastReceived": "Ultima ricezione {relative}",
|
||||
"lastNewValue": "Ultimo nuovo valore {relative}",
|
||||
"lastBackground": "Ultima volta in background {relative}",
|
||||
"never": "Non ancora ricevuto"
|
||||
"never": "Non ancora ricevuto",
|
||||
"lastBackfill": "Storico recuperato {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
@@ -7267,6 +7391,46 @@
|
||||
"failureStreak": "Serie di errori: {count} su {threshold}",
|
||||
"copyFailed": "Copia non riuscita. Seleziona il testo e copialo a mano."
|
||||
}
|
||||
},
|
||||
"dawarich": "Dawarich",
|
||||
"dawarichTag": "Movimento",
|
||||
"dawarichDescription": "Collega la tua istanza Dawarich per importare la distanza giornaliera, il tempo fuori casa e i giorni di viaggio.",
|
||||
"dawarichUrl": "Indirizzo Dawarich",
|
||||
"dawarichApiKey": "Chiave API",
|
||||
"dawarichApiKeyHelp": "La trovi in Dawarich alla voce Account. HealthLog legge solo valori giornalieri, mai coordinate o nomi di luoghi.",
|
||||
"dawarichConnect": "Collega",
|
||||
"dawarichUpdate": "Salva modifiche",
|
||||
"dawarichConnected": "Dawarich collegato. Gli ultimi 30 giorni si stanno caricando.",
|
||||
"dawarichSync": "Recupera ora",
|
||||
"dawarichSyncQueued": "Gli ultimi sette giorni verranno recuperati a breve.",
|
||||
"dawarichSyncAlreadyQueued": "Un recupero è già in corso.",
|
||||
"dawarichDisconnect": "Disconnetti",
|
||||
"dawarichDisconnectTitle": "Disconnettere Dawarich?",
|
||||
"dawarichDisconnectDescription": "I recuperi futuri si fermano. I giorni già salvati restano.",
|
||||
"dawarichDisconnectDelete": "Disconnetti ed elimina i giorni",
|
||||
"dawarichDisconnectDeleteTitle": "Disconnettere ed eliminare tutti i giorni?",
|
||||
"dawarichDisconnectDeleteDescription": "Vengono rimossi il collegamento e tutti i giorni di movimento salvati. Non si può annullare.",
|
||||
"dawarichHomePlaceSet": "La casa è impostata.",
|
||||
"dawarichHomePlaceMissing": "Imposta la tua casa per poter misurare il tempo fuori casa.",
|
||||
"dawarichHomeCountryIs": "Paese di residenza: {country}.",
|
||||
"dawarichHomeCountryMissing": "Nessun paese di residenza per ora, quindi niente giorni di viaggio.",
|
||||
"dawarichDaysStored": "Giorni salvati: {count}",
|
||||
"dawarichHomePlace": "Casa",
|
||||
"dawarichHomePlaceChoose": "Scegli un luogo da Dawarich",
|
||||
"dawarichHomePlaceChange": "Scegli la casa",
|
||||
"dawarichHomeCountry": "Paese di residenza",
|
||||
"dawarichHomeCountryHelp": "Codice paese di due lettere, ad esempio DE. Se lo lasci vuoto, vale il paese in cui hai passato più giorni nell’ultimo anno.",
|
||||
"dawarichHomeSave": "Salva",
|
||||
"dawarichHomeSaved": "Salvato.",
|
||||
"dawarichErrors": {
|
||||
"invalid": "Questo indirizzo non funziona. Usa l’indirizzo dell’istanza senza credenziali.",
|
||||
"privateOrigin": "Questa istanza è in una rete privata. Chiedi all’operatore del server di autorizzarla.",
|
||||
"credentials": "Dawarich non ha accettato la chiave API.",
|
||||
"unreachable": "Dawarich non è raggiungibile. Controlla l’indirizzo e che l’istanza sia attiva.",
|
||||
"rateLimited": "Troppi tentativi. Attendi un momento e riprova.",
|
||||
"homePlaceUnknown": "L’istanza Dawarich non conosce più questo luogo.",
|
||||
"notConnected": "Dawarich non è collegato.",
|
||||
"generic": "Non ha funzionato. Riprova."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -7343,7 +7507,8 @@
|
||||
"spendToday": "Oggi, secondo il giorno di calendario di ciascun account: {total} token, di cui {operator} sulla chiave dell'operatore (account con consumo: {accounts}).",
|
||||
"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."
|
||||
"modelNotServed": "L’endpoint non offre più il modello {model}. Scegli nelle impostazioni IA un modello che offre.",
|
||||
"usageLevels": "Account per livello di uso dell'IA: {frugal} risparmio, {standard} standard, {full} completo."
|
||||
},
|
||||
"aiQuality": {
|
||||
"title": "Qualità delle analisi",
|
||||
@@ -7753,7 +7918,8 @@
|
||||
"eventDetails": "Dettagli dell'evento",
|
||||
"closeDetails": "Chiudi dettagli",
|
||||
"processDetail": "Il buffer è volatile e limitato a 500 voci.",
|
||||
"cardTitle": "Eventi recenti"
|
||||
"cardTitle": "Eventi recenti",
|
||||
"filterErrorId": "ID errore"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Backup",
|
||||
@@ -7840,6 +8006,7 @@
|
||||
"restoreSkippedCoachAttachment": "Collegamento documento del Coach",
|
||||
"restoreSkippedCoachReference": "Riferimento del Coach",
|
||||
"restoreSkippedDocumentConditionLink": "Classificazione del documento",
|
||||
"restoreSkippedRelationLink": "Collegamento tra voci",
|
||||
"restoreSkippedExtractedFact": "Dato del documento",
|
||||
"restoreSkippedFactCommitment": "Registrazione del dato",
|
||||
"docsLink": "Documentazione",
|
||||
@@ -8016,6 +8183,56 @@
|
||||
"started": "Manutenzione avviata.",
|
||||
"alreadyQueued": "Una manutenzione è già in coda o in corso.",
|
||||
"startFailed": "Impossibile avviare la manutenzione."
|
||||
},
|
||||
"pulseDayMean": {
|
||||
"title": "Media giornaliera del polso",
|
||||
"description": "Come contano le ore con poche misurazioni nel polso di una giornata.",
|
||||
"body": "Finora ogni ora con una misurazione conta allo stesso modo, quindi una sola misurazione isolata può spostare un'intera giornata. Il metodo ponderato fa contare un'ora per intero da tre misurazioni in su, e una misurazione isolata per un terzo. Avvia prima la prova per vedere quanti giorni cambierebbero. Applicandolo, i giorni di polso salvati vengono ricalcolati in background.",
|
||||
"method": {
|
||||
"hourly": "Tutte le ore uguali",
|
||||
"weighted": "Ore rade ponderate"
|
||||
},
|
||||
"loading": "Controllo della media giornaliera del polso…",
|
||||
"loadError": "Impossibile leggere lo stato della media giornaliera del polso.",
|
||||
"state": {
|
||||
"idle": "Ancora nessuna prova.",
|
||||
"queued": "La prova è in attesa del processo in background.",
|
||||
"running": "La prova è in corso.",
|
||||
"done": "Prova terminata il {at}.",
|
||||
"failed": "La prova si è interrotta. Il registro dell'app ne indica la causa; avviala di nuovo."
|
||||
},
|
||||
"result": {
|
||||
"days": "Giorni confrontati",
|
||||
"changed": "Giorni che cambiano di oltre 1 bpm",
|
||||
"max": "Variazione maggiore",
|
||||
"maxValue": "{value} bpm",
|
||||
"bucket": "{range} bpm",
|
||||
"account": "Account {account}",
|
||||
"accountDays": "Cambiano {changed} giorni su {days}"
|
||||
},
|
||||
"appliedAt": "Metodo ponderato applicato il {at}.",
|
||||
"dryRun": "Prova",
|
||||
"apply": "Applica",
|
||||
"confirmTitle": "Applicare la media giornaliera del polso ponderata?",
|
||||
"confirmBody": "Da quel momento i giorni di polso di tutti gli account vengono calcolati con le ore rade ponderate. I giorni salvati vengono ricalcolati in background, il che può richiedere un po' su un'istanza grande.",
|
||||
"confirm": "Applica",
|
||||
"cancel": "Annulla",
|
||||
"dryRunStarted": "Prova avviata.",
|
||||
"applied": "Metodo ponderato applicato. I giorni di polso vengono ricalcolati.",
|
||||
"appliedNoRebuild": "Metodo ponderato applicato. Un'altra esecuzione è ancora in corso; applica di nuovo quando è terminata per ricalcolare i giorni di polso.",
|
||||
"error": {
|
||||
"busy": "Una prova o un ricalcolo è ancora in corso. Riprova quando è terminato.",
|
||||
"dryRunRequired": "Avvia prima una prova. Per applicare ne serve una terminata negli ultimi sette giorni.",
|
||||
"generic": "La richiesta non è andata a buon fine."
|
||||
},
|
||||
"rebuild": {
|
||||
"queued": "Il ricalcolo attende il processo in background.",
|
||||
"running": "I giorni del polso sono in fase di ricalcolo.",
|
||||
"done": "Giorni del polso ricalcolati il {at} per {accounts} account.",
|
||||
"failed": "Il ricalcolo si è interrotto. Il log dell’app indica la causa; avvialo di nuovo."
|
||||
},
|
||||
"rebuildAgain": "Ricalcola di nuovo",
|
||||
"rebuildStarted": "Ricalcolo avviato."
|
||||
}
|
||||
},
|
||||
"carrier": "Operatore",
|
||||
@@ -8120,7 +8337,25 @@
|
||||
"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"
|
||||
"userColumn": "Utente",
|
||||
"supportReport": {
|
||||
"title": "Rapporto di assistenza",
|
||||
"description": "Un riepilogo di questa istanza per una segnalazione di errore, senza email, ID o valori.",
|
||||
"body": "Gli account compaiono come “Account 1”, “Account 2” e così via. Leggi il rapporto prima di condividerlo.",
|
||||
"copy": "Copia",
|
||||
"copied": "Copiato",
|
||||
"download": "Scarica",
|
||||
"loadError": "Non è stato possibile creare il rapporto di assistenza."
|
||||
},
|
||||
"aiUsage": {
|
||||
"title": "Livelli di uso dell'IA",
|
||||
"description": "Il livello più alto che gli account possono scegliere e il livello di partenza.",
|
||||
"maxLabel": "Livello più alto",
|
||||
"defaultLabel": "Livello predefinito",
|
||||
"defaultAboveCeiling": "Il livello predefinito non può superare il livello più alto.",
|
||||
"gapLabel": "Ore tra due aggiornamenti delle schede dopo una sincronizzazione (Standard)",
|
||||
"gapHint": "Ore intere da 1 a 48. Lascia vuoto per usare AI_SYNC_REGEN_GAP_HOURS, altrimenti 6. In vigore ora: {hours} h."
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Obiettivi",
|
||||
@@ -9215,7 +9450,9 @@
|
||||
"periodStartSaved": "Inizio delle mestruazioni salvato",
|
||||
"periodEndSaved": "Fine delle mestruazioni salvata",
|
||||
"startedPeriodOnDate": "Le mestruazioni sono iniziate questo giorno",
|
||||
"endedPeriodOnDate": "Le mestruazioni sono finite questo giorno"
|
||||
"endedPeriodOnDate": "Le mestruazioni sono finite questo giorno",
|
||||
"startAbsorbed": "Inizio delle mestruazioni salvato. Il tuo inizio del {absorbed} è stato unito a quello del {date}.",
|
||||
"startRestored": "Salvato. L’inizio del {dates} è di nuovo presente."
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Ciclo probabilmente in arrivo",
|
||||
@@ -9604,12 +9841,16 @@
|
||||
"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."
|
||||
"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.",
|
||||
"alkaline-phosphatase": "La fosfatasi alcalina (ALP) è un enzima presente soprattutto nel fegato, nelle vie biliari e nell'osso. Sale quando il flusso della bile è ostacolato e nelle fasi di formazione ossea attiva, come la crescita nei bambini e negli adolescenti o la guarigione di una frattura. Poiché la rilasciano entrambi gli organi, un valore alto si legge di solito insieme alla GGT e agli altri indici epatici per distinguere un'origine epatica da una ossea. È uno degli indici abituali del profilo epatico.",
|
||||
"bilirubin-total": "La bilirubina totale è il prodotto giallo della degradazione dell'emoglobina dei globuli rossi invecchiati, misurato come somma delle sue forme non coniugata e coniugata. Il fegato la capta, la coniuga e la elimina con la bile, quindi il suo livello riflette il funzionamento di questa via. Può salire con malattie del fegato, ostruzione delle vie biliari o aumentata distruzione dei globuli rossi, e in modo innocuo nella sindrome di Gilbert. Si legge insieme agli altri indici epatici."
|
||||
},
|
||||
"apob": "Apolipoproteina B (ApoB)",
|
||||
"lp-a": "Lipoproteina(a)",
|
||||
"fasting-insulin": "Insulina a digiuno",
|
||||
"omega-3-index": "Indice omega-3"
|
||||
"omega-3-index": "Indice omega-3",
|
||||
"alkaline-phosphatase": "Fosfatasi alcalina (ALP)",
|
||||
"bilirubin-total": "Bilirubina totale"
|
||||
},
|
||||
"noteLoadFailed": "Impossibile caricare la nota; resterà invariata al salvataggio.",
|
||||
"showingLatestOf": "Visualizzazione degli ultimi {shown} risultati su {total}",
|
||||
@@ -9683,6 +9924,54 @@
|
||||
"fromReport": "Intervallo del referto {range}",
|
||||
"catalogDiffers": "il tuo intervallo {range}",
|
||||
"printed": "stampato in {unit}: {range}"
|
||||
},
|
||||
"match": {
|
||||
"viaAlias": "tramite un nome che hai scelto in precedenza",
|
||||
"viaCatalog": "riconosciuto come abbreviazione comune",
|
||||
"chooseLabel": "Appartiene a",
|
||||
"suggestionOption": "{name} ({unit})",
|
||||
"newMarkerOption": "Un nuovo biomarcatore",
|
||||
"rememberHint": "Questo nome verrà ricordato per il prossimo referto.",
|
||||
"chooseHint": "Scegli un biomarcatore se lo registri già."
|
||||
},
|
||||
"aliases": {
|
||||
"title": "Scritto anche come",
|
||||
"taken": "{alias} appartiene già a un altro biomarcatore.",
|
||||
"saveError": "Non è stato possibile salvare i nomi. Riprova.",
|
||||
"remove": "Rimuovi {alias}",
|
||||
"empty": "Aggiungi i nomi che altri laboratori stampano per {name}, così i loro referti arrivano qui.",
|
||||
"placeholder": "es. ALP",
|
||||
"addLabel": "Altro nome per questo biomarcatore",
|
||||
"add": "Aggiungi",
|
||||
"alsoWritten": "Scritto anche come {aliases}"
|
||||
},
|
||||
"merge": {
|
||||
"open": "Unisci",
|
||||
"title": "Unisci biomarcatori",
|
||||
"description": "Riunisci i biomarcatori che sono lo stesso esame scritto in modo diverso.",
|
||||
"pickHint": "Seleziona almeno due biomarcatori.",
|
||||
"keepLabel": "Mantieni",
|
||||
"keepHint": "Gli altri confluiscono in questo e i loro nomi diventano i suoi altri nomi.",
|
||||
"showPreview": "Mostra anteprima",
|
||||
"back": "Indietro",
|
||||
"confirm": "Unisci ora",
|
||||
"previewSummaryOne": "{count} misurazione passa a {name} ({unit}).",
|
||||
"previewSummaryFew": "{count} misurazioni passano a {name} ({unit}).",
|
||||
"previewSummaryOther": "{count} misurazioni passano a {name} ({unit}).",
|
||||
"movesOne": "{count} misurazione, già nella stessa unità",
|
||||
"movesFew": "{count} misurazioni, già nella stessa unità",
|
||||
"movesOther": "{count} misurazioni, già nella stessa unità",
|
||||
"convertedOne": "{count} misurazione, convertita da {unit} a {targetUnit}",
|
||||
"convertedFew": "{count} misurazioni, convertite da {unit} a {targetUnit}",
|
||||
"convertedOther": "{count} misurazioni, convertite da {unit} a {targetUnit}",
|
||||
"refused": "{unit} non può essere convertito in {targetUnit}.",
|
||||
"aliasesAdded": "Questi nomi porteranno al biomarcatore mantenuto:",
|
||||
"blocked": "Non si unisce nulla finché un'unità non può essere convertita. Escludi quel biomarcatore o mantienine un altro.",
|
||||
"noUndo": "Un'unione non può essere annullata.",
|
||||
"doneToastOne": "Unito in {name}, {count} misurazione spostata",
|
||||
"doneToastFew": "Unito in {name}, {count} misurazioni spostate",
|
||||
"doneToastOther": "Unito in {name}, {count} misurazioni spostate",
|
||||
"error": "Non è stato possibile unire i biomarcatori. Riprova."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -10644,25 +10933,11 @@
|
||||
},
|
||||
"profileFacts": {
|
||||
"cardTitle": "Contesto dello stile di vita",
|
||||
"cardDescription": "Registra i dati attuali che cambiano l'interpretazione delle tue misurazioni sanitarie.",
|
||||
"smokingLabel": "Abitudine al fumo",
|
||||
"alcoholLabel": "Consumo di alcol",
|
||||
"shiftLabel": "Orario di lavoro",
|
||||
"notRecorded": "Non registrato",
|
||||
"unreadable": "Impossibile leggere il valore salvato",
|
||||
"save": "Salva",
|
||||
"savedToast": "Contesto dello stile di vita salvato",
|
||||
"savePartialError": "Impossibile salvare {kinds}. Riprova.",
|
||||
"saveKindError": "Non salvato",
|
||||
"loadError": "Impossibile caricare il contesto dello stile di vita",
|
||||
"remove": "Rimuovi {kind}",
|
||||
"removeShort": "Rimuovi",
|
||||
"removeConfirmTitle": "Rimuovere {kind}?",
|
||||
"removeConfirmDescription": "Il valore attuale risulterà non registrato. La cronologia datata verrà conservata.",
|
||||
"removedToast": "{kind} rimosso dal contesto attuale dello stile di vita",
|
||||
"removeError": "Impossibile rimuovere {kind}",
|
||||
"historyTitle": "Valori precedenti",
|
||||
"to": "a",
|
||||
"values": {
|
||||
"SMOKING_STATUS": {
|
||||
"NEVER": "Non ha mai fumato",
|
||||
@@ -10842,6 +11117,235 @@
|
||||
"savedToast": "Informazioni di emergenza salvate",
|
||||
"saveError": "Impossibile salvare le informazioni di emergenza.",
|
||||
"loadError": "Impossibile caricare le informazioni di emergenza."
|
||||
},
|
||||
"life": {
|
||||
"cardTitle": "Circostanze di vita",
|
||||
"cardDescription": "Fumo, alcol, attività, lavoro, abitudini e obiettivi, datati e tutti facoltativi.",
|
||||
"loadError": "Impossibile caricare le tue circostanze di vita.",
|
||||
"noAnswer": "Nessuna risposta",
|
||||
"yes": "Sì",
|
||||
"no": "No",
|
||||
"validSince": "Valido dal",
|
||||
"month": "Mese",
|
||||
"year": "Anno",
|
||||
"lastConfirmed": "Confermato l'ultima volta il {date}",
|
||||
"notAnswered": "Non ancora compilato",
|
||||
"unreadable": "Questa risposta non può essere letta su questo server.",
|
||||
"save": "Salva sezione",
|
||||
"savedToast": "Sezione salvata",
|
||||
"saveError": "Impossibile salvare la sezione.",
|
||||
"review": {
|
||||
"question": "È ancora così? La tua ultima risposta ha più di un anno.",
|
||||
"confirm": "È ancora così",
|
||||
"confirmedToast": "Grazie, segnato come ancora valido",
|
||||
"confirmError": "Impossibile salvare la conferma."
|
||||
},
|
||||
"ai": {
|
||||
"label": "Condividi questa sezione con l'IA",
|
||||
"shiftLabel": "Condividi il lavoro a turni con l'IA",
|
||||
"hint": "Se disattivato, il Coach e i riepiloghi non vedono mai queste risposte.",
|
||||
"savedToast": "Condivisione con l'IA aggiornata",
|
||||
"saveError": "Impossibile salvare l'impostazione dell'IA."
|
||||
},
|
||||
"sections": {
|
||||
"tobacco": {
|
||||
"title": "Fumo e nicotina"
|
||||
},
|
||||
"alcohol": {
|
||||
"title": "Alcol"
|
||||
},
|
||||
"activity": {
|
||||
"title": "Attività e tempo seduto"
|
||||
},
|
||||
"work": {
|
||||
"title": "Lavoro e ritmo"
|
||||
},
|
||||
"habits": {
|
||||
"title": "Sonno, alimentazione e caffeina"
|
||||
},
|
||||
"goals": {
|
||||
"title": "Obiettivi"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"smokingStatus": "Abitudine al fumo",
|
||||
"products": "Prodotti usati",
|
||||
"cigarettesPerDay": "Sigarette al giorno",
|
||||
"startYear": "Anno di inizio",
|
||||
"quitMonth": "Quando hai smesso",
|
||||
"secondhandSmoke": "Fumo passivo regolare",
|
||||
"moderateDaysPerWeek": "Giorni a settimana con attività moderata o intensa, come camminare a passo svelto",
|
||||
"moderateMinutesPerDay": "Minuti in quei giorni, in media",
|
||||
"sittingHoursWorkday": "Ore seduto in un giorno lavorativo",
|
||||
"shiftSchedule": "Lavoro a turni",
|
||||
"situation": "Situazione lavorativa",
|
||||
"physicalNature": "Tipo di lavoro",
|
||||
"nightWork": "Lavoro notturno",
|
||||
"exposures": "Esposizioni al lavoro",
|
||||
"freeWeekdays": "Giorni liberi della settimana",
|
||||
"napRegularly": "Pisolino abituale",
|
||||
"snoringReported": "Altri dicono che russi",
|
||||
"dietPattern": "Alimentazione",
|
||||
"dietSpecifics": "Particolarità",
|
||||
"lateMealsPerWeek": "Pasti dopo le 21 a settimana",
|
||||
"fruitVegPortionsPerDay": "Porzioni di frutta e verdura al giorno",
|
||||
"caffeineDrinksPerDay": "Bevande con caffeina al giorno",
|
||||
"latestCaffeine": "Ultima caffeina della giornata",
|
||||
"goals": "Su cosa vuoi lavorare",
|
||||
"goalsHint": "Al massimo tre."
|
||||
},
|
||||
"values": {
|
||||
"products": {
|
||||
"cigarettes": "Sigarette",
|
||||
"cigars_pipe": "Sigari o pipa",
|
||||
"e_cigarette": "Sigaretta elettronica",
|
||||
"heated_tobacco": "Tabacco riscaldato",
|
||||
"snus_pouches": "Snus o bustine di nicotina"
|
||||
},
|
||||
"situation": {
|
||||
"none": "Nessun lavoro retribuito",
|
||||
"indoor": "Soprattutto al chiuso",
|
||||
"outdoor": "Soprattutto all'aperto",
|
||||
"mixed": "Al chiuso e all'aperto"
|
||||
},
|
||||
"physicalNature": {
|
||||
"mostly_sitting": "Soprattutto seduto",
|
||||
"standing_walking": "In piedi o camminando",
|
||||
"heavy_physical": "Lavoro fisico pesante"
|
||||
},
|
||||
"exposures": {
|
||||
"heat": "Calore",
|
||||
"cold": "Freddo",
|
||||
"dust": "Polvere",
|
||||
"fumes_solvents": "Fumi o solventi",
|
||||
"noise": "Rumore"
|
||||
},
|
||||
"dietPattern": {
|
||||
"mixed": "Dieta mista",
|
||||
"vegetarian": "Vegetariana",
|
||||
"vegan": "Vegana",
|
||||
"pescetarian": "Pescetariana",
|
||||
"other": "Altro"
|
||||
},
|
||||
"dietSpecifics": {
|
||||
"intermittent_fasting": "Digiuno intermittente",
|
||||
"low_carb": "Povera di carboidrati",
|
||||
"prescribed_diet": "Dieta prescritta dal medico"
|
||||
},
|
||||
"latestCaffeine": {
|
||||
"before_12": "Prima delle 12",
|
||||
"12_15": "Dalle 12 alle 15",
|
||||
"15_18": "Dalle 15 alle 18",
|
||||
"after_18": "Dopo le 18"
|
||||
},
|
||||
"goals": {
|
||||
"weight": "Peso",
|
||||
"blood_pressure": "Pressione arteriosa",
|
||||
"blood_sugar": "Glicemia",
|
||||
"sleep": "Sonno",
|
||||
"fitness": "Forma fisica",
|
||||
"stress_recovery": "Stress e recupero",
|
||||
"medication_routine": "Prendere i farmaci con regolarità",
|
||||
"quit_smoking": "Smettere di fumare",
|
||||
"less_alcohol": "Bere meno alcol"
|
||||
},
|
||||
"weekdays": {
|
||||
"1": "lun",
|
||||
"2": "mar",
|
||||
"3": "mer",
|
||||
"4": "gio",
|
||||
"5": "ven",
|
||||
"6": "sab",
|
||||
"7": "dom"
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
"packYears": "{value} pacchetti-anno",
|
||||
"packYearsMissing": "Per i pacchetti-anno mancano le sigarette al giorno o l'anno di inizio.",
|
||||
"activityMinutes": "{value} minuti a settimana.",
|
||||
"meetsWho": "Raggiunge la raccomandazione dell'OMS di 150 minuti a settimana.",
|
||||
"belowWho": "È sotto la raccomandazione dell'OMS di 150 minuti a settimana.",
|
||||
"measuredSteps": "I tuoi passi misurati hanno la precedenza; questa risposta aggiunge contesto.",
|
||||
"freeDaysDefault": "Senza risposta, sabato e domenica contano come giorni liberi."
|
||||
},
|
||||
"auditC": {
|
||||
"intro": "Tre domande sull'alcol tratte dall'AUDIT dell'OMS. Uno screening, non una diagnosi.",
|
||||
"start": "Rispondi alle domande",
|
||||
"again": "Rispondi di nuovo",
|
||||
"offerDue": "Basta un'occhiata una volta all'anno.",
|
||||
"notValidated": "Non c'è ancora una traduzione validata per la tua lingua, per questo le domande sono in inglese.",
|
||||
"submit": "Salva le risposte",
|
||||
"savedToast": "Risposte salvate",
|
||||
"saveError": "Impossibile salvare le risposte.",
|
||||
"result": "{score} punti su 12",
|
||||
"above": "Pari o sopra la soglia di screening di {cutoff}.",
|
||||
"below": "Sotto la soglia di screening di {cutoff}.",
|
||||
"noCutoff": "Senza sesso nel profilo viene mostrata solo la somma.",
|
||||
"source": "Fonte: {source}",
|
||||
"history": "Voci precedenti",
|
||||
"remove": "Rimuovi voce",
|
||||
"removeConfirmTitle": "Rimuovere questa voce?",
|
||||
"removeConfirmBody": "La voce esce dalla tua cronologia. Qui non si può annullare.",
|
||||
"removedToast": "Voce rimossa",
|
||||
"removeError": "Impossibile rimuovere la voce.",
|
||||
"historyEntry": "{date}: {score} su 12",
|
||||
"legacyLabel": "Risposta precedente: {value}",
|
||||
"legacyHint": "Dalla breve domanda sull’alcol posta prima di questo questionario. Quando c’è un risultato del questionario, il Coach non la legge più.",
|
||||
"legacyRemove": "Rimuovi risposta precedente",
|
||||
"legacyRemoveConfirmTitle": "Rimuovere la risposta precedente?",
|
||||
"legacyRemoveConfirmBody": "La risposta precedente esce dai tuoi dati. I risultati del questionario restano.",
|
||||
"legacyRemovedToast": "Risposta precedente rimossa",
|
||||
"legacyRemoveError": "Non è stato possibile rimuovere la risposta precedente."
|
||||
},
|
||||
"takeOver": {
|
||||
"conditionsButton": "Riporta nel diario delle condizioni",
|
||||
"allergiesButton": "Riporta nelle allergie",
|
||||
"conditionsTitle": "Aggiungi una malattia cronica",
|
||||
"allergiesTitle": "Aggiungi un'allergia",
|
||||
"description": "Il tuo testo libero resta com'è finché non lo cancelli tu.",
|
||||
"freeTextLabel": "Il tuo testo libero",
|
||||
"conditionLabel": "Malattia",
|
||||
"onset": "Dal",
|
||||
"save": "Aggiungi",
|
||||
"savedToast": "Voce aggiunta",
|
||||
"saveError": "Impossibile aggiungere la voce.",
|
||||
"clearQuestion": "La voce è salvata. Cancellare ora il testo libero?",
|
||||
"clear": "Cancella testo libero",
|
||||
"keep": "Tienilo",
|
||||
"clearedToast": "Testo libero cancellato",
|
||||
"clearError": "Impossibile cancellare il testo libero."
|
||||
},
|
||||
"screening": {
|
||||
"cardTitle": "Indicazioni di screening",
|
||||
"cardDescription": "Dalle tue circostanze di vita, con la regola e il paese da cui proviene.",
|
||||
"noReminder": "È solo un'indicazione; se vuoi un promemoria, crealo tu.",
|
||||
"source": "Fonte: {source}",
|
||||
"titles": {
|
||||
"lung_ldct": "TC a basso dosaggio per il tumore del polmone",
|
||||
"aaa_ultrasound": "Ecografia dell'aorta addominale"
|
||||
},
|
||||
"status": {
|
||||
"suggested": "Potrebbe riguardarti",
|
||||
"not_eligible": "Non ti riguarda",
|
||||
"unknown": "Informazioni insufficienti"
|
||||
},
|
||||
"reasons": {
|
||||
"never_smoked": "Non hai mai fumato.",
|
||||
"missing_birth_date": "Manca la tua data di nascita.",
|
||||
"age_out_of_range": "La tua età è fuori dall'intervallo.",
|
||||
"missing_smoking_status": "Manca la tua abitudine al fumo.",
|
||||
"no_cigarettes": "La regola conta solo le sigarette.",
|
||||
"missing_smoking_history": "Mancano le sigarette al giorno o l'anno di inizio.",
|
||||
"pack_years_below_15": "Meno di 15 pacchetti-anno.",
|
||||
"pack_years_below_20": "Meno di 20 pacchetti-anno.",
|
||||
"smoked_years_below_25": "Meno di 25 anni di fumo.",
|
||||
"missing_quit_date": "Manca la data in cui hai smesso.",
|
||||
"quit_over_10_years": "Hai smesso più di 10 anni fa.",
|
||||
"quit_over_15_years": "Hai smesso più di 15 anni fa.",
|
||||
"missing_sex": "Nel profilo manca il sesso.",
|
||||
"sex_not_male": "La regola vale per gli uomini."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
@@ -10875,6 +11379,10 @@
|
||||
"mugwort": "artemisia",
|
||||
"olive": "olivo",
|
||||
"ragweed": "ambrosia"
|
||||
},
|
||||
"movement": {
|
||||
"awayFromHome": "Tempo fuori casa",
|
||||
"distance": "Distanza percorsa"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -11122,7 +11630,8 @@
|
||||
"extractEmpty": "Non è stato possibile estrarre valori da questo documento.",
|
||||
"failUnitRequired": "Un valore numerico ha bisogno di un’unità prima di poter essere salvato.",
|
||||
"failUnitMismatch": "L’unità indicata non corrisponde all’unità salvata per questo marcatore. Correggila e concludi di nuovo la revisione.",
|
||||
"failGeneric": "Questo valore non è stato salvato."
|
||||
"failGeneric": "Questo valore non è stato salvato.",
|
||||
"failUnknownBiomarker": "Il biomarcatore scelto non esiste più. Scegline un altro."
|
||||
},
|
||||
"contentIndex": {
|
||||
"indexPrompt": "Rendi ricercabile il contenuto dei documenti.",
|
||||
@@ -11344,7 +11853,9 @@
|
||||
"molybdenum": "Molibdeno",
|
||||
"iodine": "Iodio",
|
||||
"water": "Acqua",
|
||||
"caffeine": "Caffeina"
|
||||
"caffeine": "Caffeina",
|
||||
"dha": "DHA (acido grasso omega-3)",
|
||||
"epa": "EPA (acido grasso omega-3)"
|
||||
},
|
||||
"page": {
|
||||
"title": "Apporto di nutrienti",
|
||||
@@ -11376,9 +11887,26 @@
|
||||
"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"
|
||||
"loadError": "Non è stato possibile caricare i tuoi dati sui nutrienti",
|
||||
"sourcePart": "{source} {value} {unit}",
|
||||
"aboveUpperLimit": "Sopra il livello massimo tollerabile per gli adulti di {value} {unit} al giorno ({source}).",
|
||||
"aboveUpperLimitSupplemental": "La quota degli integratori supera il livello massimo tollerabile per gli adulti di {value} {unit} al giorno per gli integratori ({source}).",
|
||||
"upperLimitForm": {
|
||||
"retinol": "Il livello vale solo per la vitamina A preformata (retinolo): se la quantità proviene dal beta-carotene, non si applica.",
|
||||
"folic_acid": "Il livello vale per l'acido folico da integratori e alimenti arricchiti, non per i folati del cibo comune.",
|
||||
"magnesium_salts": "Il livello vale per i sali di magnesio facilmente dissociabili negli integratori, non per il magnesio degli alimenti.",
|
||||
"alpha_tocopherol": "Il livello è fissato per l'alfa-tocoferolo."
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"units": {
|
||||
"IU": "UI"
|
||||
},
|
||||
"sources": {
|
||||
"APPLE_HEALTH": "Sincronizzato",
|
||||
"MANUAL": "Inserito",
|
||||
"SUPPLEMENT": "Integratori"
|
||||
}
|
||||
},
|
||||
"daily": {
|
||||
"line": {
|
||||
@@ -11880,9 +12408,11 @@
|
||||
"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.",
|
||||
"oidcOnly": "Su questo server non è possibile consegnare un profilo.",
|
||||
"loadError": "Impossibile caricare lo stato della consegna.",
|
||||
"entryHint": "Quando {name} vuole tenere questa cartella in autonomia."
|
||||
"entryHint": "Quando {name} vuole tenere questa cartella in autonomia.",
|
||||
"methodSso": "{name} accede con il single sign-on quando usa il link.",
|
||||
"methodBoth": "{name} può impostare una password oppure accedere con il single sign-on."
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
@@ -12008,7 +12538,11 @@
|
||||
"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"
|
||||
"lifeEventDelete": "{name} ha rimosso un evento della vita",
|
||||
"biomarkerMerge": "{name} ha unito dei biomarcatori",
|
||||
"medicationIngredientsUpdate": "{name} ha modificato la composizione di un integratore",
|
||||
"auditCCreate": "{name} ha compilato il questionario AUDIT-C",
|
||||
"auditCDelete": "{name} ha eliminato una voce AUDIT-C"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
@@ -12227,7 +12761,34 @@
|
||||
"removed": "Collegamento a {name} rimosso",
|
||||
"linked": "Collegati",
|
||||
"loadNewer": "Carica più recenti",
|
||||
"loadOlder": "Carica meno recenti"
|
||||
"loadOlder": "Carica meno recenti",
|
||||
"period": {
|
||||
"showing": "Periodo: {period}"
|
||||
}
|
||||
},
|
||||
"related": {
|
||||
"title": "Correlati",
|
||||
"kind": {
|
||||
"labResult": "Valore di laboratorio",
|
||||
"encounter": "Visita",
|
||||
"document": "Documento",
|
||||
"conditionEpisode": "Malattia",
|
||||
"medication": "Farmaco",
|
||||
"vaccination": "Vaccinazione"
|
||||
},
|
||||
"via": {
|
||||
"encounter": "tramite una visita",
|
||||
"efficacyTarget": "tramite l’obiettivo di efficacia"
|
||||
},
|
||||
"pick": {
|
||||
"conditionEpisode": "Malattie",
|
||||
"document": "Documenti",
|
||||
"encounter": "Visite",
|
||||
"medication": "Farmaci",
|
||||
"vaccination": "Vaccinazioni"
|
||||
},
|
||||
"nothingToOffer": "Ancora niente da collegare.",
|
||||
"saveError": "Non è stato possibile salvare il collegamento."
|
||||
}
|
||||
},
|
||||
"bodySites": {
|
||||
@@ -12369,7 +12930,8 @@
|
||||
"scores": "Punteggi",
|
||||
"running": "In corso quel giorno",
|
||||
"values": "Valori",
|
||||
"happened": "Cosa è successo"
|
||||
"happened": "Cosa è successo",
|
||||
"movement": "In movimento"
|
||||
},
|
||||
"notable": {
|
||||
"extremeHigh": "Valore giornaliero più alto dal {since}",
|
||||
@@ -12425,7 +12987,18 @@
|
||||
"today": "Oggi",
|
||||
"hasEntries": "con voci",
|
||||
"dayNSince": "Giorno {n}, dal {date}",
|
||||
"sinceDate": "Dal {date}"
|
||||
"sinceDate": "Dal {date}",
|
||||
"movement": {
|
||||
"total": "Distanza",
|
||||
"driving": "In auto",
|
||||
"train": "In treno",
|
||||
"places": "Luoghi visitati",
|
||||
"away": "Fuori casa",
|
||||
"travel": "Giorno di viaggio",
|
||||
"travelYes": "Sì",
|
||||
"kilometres": "{value} km",
|
||||
"miles": "{value} mi"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Cronologia",
|
||||
@@ -12760,5 +13333,13 @@
|
||||
"lastUpdated": "Ultimo aggiornamento: {date}",
|
||||
"project": "Progetto",
|
||||
"credits": "Riconoscimenti"
|
||||
},
|
||||
"series": {
|
||||
"reference": {
|
||||
"targetUpper": "Il tuo obiettivo, limite superiore",
|
||||
"targetLower": "Il tuo obiettivo, limite inferiore",
|
||||
"clinicalUpper": "Limite clinico superiore",
|
||||
"clinicalLower": "Limite clinico inferiore"
|
||||
}
|
||||
}
|
||||
}
|
||||
+622
-41
@@ -51,7 +51,10 @@
|
||||
"durationHoursMinutes": "{hours}시간 {minutes}분",
|
||||
"durationMinutes": "{minutes}분",
|
||||
"durationClockMinutes": "{time}분",
|
||||
"durationClockHours": "{time}시간"
|
||||
"durationClockHours": "{time}시간",
|
||||
"errorIdLabel": "오류 ID",
|
||||
"errorIdCopy": "오류 ID 복사",
|
||||
"errorIdCopied": "오류 ID 복사됨"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -154,6 +157,20 @@
|
||||
"event": {
|
||||
"notFound": "이 기록은 더 이상 없습니다."
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
"notFound": "이 약은 더 이상 없어요.",
|
||||
"ingredients": {
|
||||
"invalid": "성분을 확인한 뒤 다시 시도하세요.",
|
||||
"unitMismatch": "이 영양소에는 이 단위를 쓸 수 없어요.",
|
||||
"extract": {
|
||||
"fileType": "JPEG, PNG, WebP 또는 PDF를 올려 주세요.",
|
||||
"fileTooLarge": "파일이 10MB보다 커요.",
|
||||
"rateLimited": "라벨을 너무 많이 읽었어요. 한 시간 뒤에 다시 시도하세요.",
|
||||
"budgetExceeded": "오늘의 AI 사용 한도에 도달했어요.",
|
||||
"failed": "라벨을 읽지 못했어요. 더 선명한 사진으로 다시 시도하세요."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -367,7 +384,20 @@
|
||||
"immunizationsColDose": "차수",
|
||||
"immunizationsColLot": "로트번호",
|
||||
"courseRange": "복용 기간 {start}부터 {end}까지",
|
||||
"courseSince": "복용 기간 {start}부터"
|
||||
"courseSince": "복용 기간 {start}부터",
|
||||
"lifePackYears": "갑년",
|
||||
"lifeAuditC": "AUDIT-C (선별검사)",
|
||||
"lifeAuditCScore": "{date}, 12점 중 {score}점.",
|
||||
"lifeAuditCAbove": "기준점 {cutoff}점 이상 ({source}).",
|
||||
"lifeAuditCBelow": "기준점 {cutoff}점 미만 ({source}).",
|
||||
"lifeAuditCNoCutoff": "성별 기준점을 적용하지 않음.",
|
||||
"lifeActivity": "신체 활동",
|
||||
"lifeActivityValue": "주당 {minutes}분, 본인 응답",
|
||||
"lifeSitting": "평일 앉아 있는 시간",
|
||||
"lifeSittingValue": "{hours}시간, 본인 응답",
|
||||
"lifeWork": "일",
|
||||
"lifeNightWork": "야간 근무",
|
||||
"lifeExposures": "노출: {list}"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "문제가 생겼어요",
|
||||
@@ -525,7 +555,7 @@
|
||||
"loading": "링크를 확인하는 중…",
|
||||
"invalid": "이 링크는 더 이상 작동하지 않습니다",
|
||||
"invalidHint": "알 수 없거나, 만료되었거나, 철회되었거나, 이미 사용된 링크입니다. 보낸 사람에게 새 링크를 요청하세요.",
|
||||
"oidcOnly": "이 서버는 통합 로그인(SSO)만 허용하므로 아직 여기서 프로필을 넘겨받을 수 없습니다.",
|
||||
"oidcOnly": "이 서버는 통합 로그인(SSO)만 허용합니다. 대신 ID 공급자로 계속하세요.",
|
||||
"signedInTitle": "먼저 로그아웃하세요",
|
||||
"signedInBody": "{username} 계정으로 로그인되어 있습니다. 이 링크는 다른 사람의 로그인을 만드는 링크이므로 사용하기 전에 로그아웃하세요.",
|
||||
"signOut": "로그아웃하고 계속",
|
||||
@@ -542,7 +572,19 @@
|
||||
"errorTaken": "이미 사용 중인 사용자 이름 또는 이메일 주소입니다.",
|
||||
"errorRateLimit": "시도 횟수가 너무 많습니다. 몇 분 후 다시 시도하세요.",
|
||||
"errorFailed": "처리하지 못했습니다. 잠시 후 다시 시도하세요.",
|
||||
"errorOffline": "서버에 연결하지 못했습니다. 연결을 확인하고 다시 시도하세요."
|
||||
"errorOffline": "서버에 연결하지 못했습니다. 연결을 확인하고 다시 시도하세요.",
|
||||
"ssoContinue": "{provider}(으)로 계속",
|
||||
"ssoStarting": "이동하는 중…",
|
||||
"ssoHint": "이후에는 {provider} 계정으로 여기에 로그인합니다.",
|
||||
"ssoDivider": "또는",
|
||||
"ssoFallbackName": "통합 로그인(SSO)",
|
||||
"errorTitle": "넘겨받기를 완료하지 못했습니다",
|
||||
"errorSubjectBound": "이 로그인은 이미 이 서버의 다른 계정에 연결되어 있습니다. 링크를 다시 열고 ID 공급자에서 다른 계정을 사용하세요.",
|
||||
"errorEmailTaken": "ID 공급자가 보낸 이메일 주소를 이 서버의 다른 계정이 이미 사용하고 있습니다. 링크를 다시 열고 다른 계정을 사용하거나 서버 관리자에게 문의하세요.",
|
||||
"errorUsernameTaken": "그 사이에 다른 사람이 이 사용자 이름을 사용했습니다. 링크를 다시 열고 다른 이름을 선택하세요.",
|
||||
"errorSignedIn": "돌아왔을 때 이미 로그인되어 있었습니다. 로그아웃한 다음 링크를 다시 여세요.",
|
||||
"errorSso": "ID 공급자로 로그인하지 못했습니다. 링크를 다시 열어 한 번 더 시도하세요.",
|
||||
"signInInstead": "그 계정으로 로그인"
|
||||
},
|
||||
"inviteInvalid": {
|
||||
"title": "유효하지 않은 초대예요",
|
||||
@@ -2399,6 +2441,34 @@
|
||||
"invalidRange": "마지막 날은 첫날보다 앞설 수 없어요.",
|
||||
"generic": "저장하지 못했어요. 다시 시도해 주세요."
|
||||
}
|
||||
},
|
||||
"composition": {
|
||||
"title": "성분",
|
||||
"empty": "정제나 캡슐 하나에 든 성분을 입력하면 복용한 모든 용량이 미량영양소에 반영돼요.",
|
||||
"perUnitNote": "정제 또는 캡슐 1개 기준이며, 복용한 용량마다 그날의 미량영양소에 더해져요.",
|
||||
"add": "추가",
|
||||
"edit": "수정",
|
||||
"rowHelper": "정제나 캡슐 하나에 든 성분으로, 미량영양소에 반영돼요.",
|
||||
"sheetTitle": "1회 단위에 든 성분",
|
||||
"sheetDescription": "라벨에 적힌 대로 정제 또는 캡슐 1개당 함량을 입력하세요.",
|
||||
"nutrientLabel": "영양소",
|
||||
"amountLabel": "함량",
|
||||
"unitLabel": "단위",
|
||||
"choose": "영양소 선택",
|
||||
"addRow": "영양소 추가",
|
||||
"removeRow": "{name} 삭제",
|
||||
"editorEmpty": "아직 영양소가 없어요. 라벨 한 줄마다 하나씩 추가하세요.",
|
||||
"labelPhoto": "라벨 사진에서 읽기",
|
||||
"labelReading": "라벨을 읽는 중…",
|
||||
"labelWaiting": "백그라운드 작업이 아직 라벨을 처리하지 않았어요.",
|
||||
"labelReviewNote": "저장하기 전에 각 줄을 라벨과 비교해 확인하세요.",
|
||||
"lowConfidence": "이 줄은 읽기 어려웠어요. 라벨과 비교해 확인하세요.",
|
||||
"unmatchedTitle": "맞는 영양소가 없는 줄이에요. 필요하면 직접 입력하세요:",
|
||||
"servingNote": "라벨 표기: {note}. 함량은 1개 기준으로 표시돼요.",
|
||||
"labelError": "라벨을 읽지 못했어요",
|
||||
"saveError": "성분을 저장하지 못했어요",
|
||||
"catalogError": "영양소 목록을 불러오지 못했어요",
|
||||
"invalidRow": "모든 줄에서 영양소를 고르고 0보다 큰 함량을 입력하세요."
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -2666,7 +2736,8 @@
|
||||
"bmiEmptyAction": "계정 설정 열기",
|
||||
"personalRecord": {
|
||||
"badge": "신기록",
|
||||
"tooltip": "개인 최고 기록: {date} {value}{unit}"
|
||||
"tooltip": "개인 최고 기록: {date} {value}{unit}",
|
||||
"benchmark": "그 나이의 일반적인 범위: {low}~{high} {unit} ({source})"
|
||||
},
|
||||
"loadError": "인사이트를 불러오지 못했어요",
|
||||
"heroPersonalBaseline": "최근 90일 기준",
|
||||
@@ -4220,6 +4291,21 @@
|
||||
"learning": "학습 중이에요: {threshold}일 중 {entries}일 기록했어요. 충분히 모이면 비교가 나타나요.",
|
||||
"noPattern": "지금까지 기록한 {entries}일 중에는 하루를 견줄 만큼 일정한 패턴이 없어요. 하루의 맥락까지 함께 남기면 비교할 수 있는 게 늘어나요.",
|
||||
"featureValue": "{field}: {value}"
|
||||
},
|
||||
"patterns": {
|
||||
"title": "기분 패턴",
|
||||
"basedOn": "{subject}일과 {comparison}일 기준. 원인이 아닌 패턴이에요.",
|
||||
"previousDayHigher": "평소보다 낮은 날 다음 날은 대체로 더 나아요 (평소보다 높은 날 다음의 {comparison} 대비 {subject}).",
|
||||
"previousDayLower": "평소보다 낮은 날은 다음 날까지 이어지는 경향이 있어요 (평소보다 높은 날 다음의 {comparison} 대비 {subject}).",
|
||||
"weekendHigher": "주말에는 기분이 대체로 더 좋아요 (평일 {comparison} 대비 {subject}).",
|
||||
"weekendLower": "주말에는 기분이 대체로 더 낮아요 (평일 {comparison} 대비 {subject}).",
|
||||
"bestWeekday": "{weekday}이 대체로 가장 좋은 날이에요 (다른 날 {comparison} 대비 {subject}).",
|
||||
"notesHigher": "메모를 남긴 날에는 기분이 대체로 더 좋아요 (메모 없는 날 {comparison} 대비 {subject}).",
|
||||
"notesLower": "메모를 남긴 날에는 기분이 대체로 더 낮아요 (메모 없는 날 {comparison} 대비 {subject}).",
|
||||
"delayedTagHigher": "'{tag}' 다음 날에는 기분이 대체로 더 좋아요 (그 외 {comparison} 대비 {subject}).",
|
||||
"delayedTagLower": "'{tag}' 다음 날에는 기분이 대체로 더 낮아요 (그 외 {comparison} 대비 {subject}).",
|
||||
"multipleEntriesHigher": "하루에 여러 번 기록한 날에는 기분이 대체로 더 좋아요 (한 번 기록한 날 {comparison} 대비 {subject}).",
|
||||
"multipleEntriesLower": "하루에 여러 번 기록한 날에는 기분이 대체로 더 낮아요 (한 번 기록한 날 {comparison} 대비 {subject})."
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
@@ -5459,7 +5545,7 @@
|
||||
"subtitle": "같은 지표를 두 소스가 보낼 때 어느 쪽을 쓸지 정해요.",
|
||||
"nutrients": {
|
||||
"title": "동기화된 영양 섭취",
|
||||
"description": "최근 {days}일간 Apple Health에서 동기화한 하루 합계예요.",
|
||||
"description": "최근 {days}일간의 하루 합계예요. Apple Health에서 동기화한 값에 복용한 보충제가 더해져요.",
|
||||
"empty": "아직 동기화된 영양 데이터가 없어요.",
|
||||
"latest": "{date}에 {amount} {unit}",
|
||||
"daysWithData": "{days}일 중 {count}일",
|
||||
@@ -5821,7 +5907,10 @@
|
||||
"remove": "위치 기간 삭제",
|
||||
"removeConfirmTitle": "이 여행을 삭제할까요?",
|
||||
"removeConfirmBody": "그 기간에 측정한 값은 기록에 그대로 남고, 여행 항목만 사라져요.",
|
||||
"range": "{start}~{end}"
|
||||
"range": "{start}~{end}",
|
||||
"suggestionsIntro": "Dawarich가 아직 어떤 기간에도 속하지 않은 해외 체류일을 찾았습니다.",
|
||||
"suggestionUse": "사용",
|
||||
"suggestionDismiss": "제안 닫기"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "지난 날짜 채우기",
|
||||
@@ -6255,7 +6344,8 @@
|
||||
"subLabel": "문서마다 따로 누르지 않아도 설정한 AI 제공자가 문서를 읽고 색인해요.",
|
||||
"honesty": "켜 두면 올리는 문서마다 설정한 AI 제공자에게 전송되어 읽히고, 설명이 붙고, 검색용으로 색인돼요. 문서별 확인 절차는 없어요. 켜는 순간 과거 문서에도 적용돼요. 이미 올렸지만 아직 요약이 없는 문서도 켤 때마다 최대 200개까지 함께 전송돼요. 이 작업은 서버에서 이뤄지고 문서 내용은 이 기기를 떠나 그 제공자에게 전달돼요. 설정한 제공자가 구독 연결(API 키가 아니라 로그인한 AI 계정)이라면 그 제공자의 데이터 정책이 적용돼요. 구독 제공자는 내용을 모델 개선에 쓸 수 있고, 이를 보호하는 데이터 처리 계약도 없어요. 셀프 호스팅 로컬 모델이 읽은 문서는 서버 밖으로 나가지 않아요. 기본값은 꺼짐이고, 이 조건을 받아들일 때만 켜세요.",
|
||||
"confirm": "이해했어요, 켜기",
|
||||
"cancel": "취소"
|
||||
"cancel": "취소",
|
||||
"restingFrugal": "AI 절약 단계에서는 일시 중지돼요. 문서는 요청할 때만 읽어요."
|
||||
},
|
||||
"centralCodex": {
|
||||
"title": "서버의 공용 AI 연결 사용",
|
||||
@@ -6313,7 +6403,33 @@
|
||||
"coachNote": "코치에서는 코치 설정의 사고 깊이가 적용됩니다."
|
||||
},
|
||||
"providerCardDescription": "HealthLog가 가장 먼저 쓰는 제공자와 그 키, 모델이에요.",
|
||||
"runtimeTitle": "확인 및 새로고침"
|
||||
"runtimeTitle": "확인 및 새로고침",
|
||||
"usage": {
|
||||
"title": "AI 사용량",
|
||||
"description": "백그라운드에서 실행되는 AI 작업의 양이며, 코치는 모든 단계에서 똑같아요.",
|
||||
"levels": {
|
||||
"frugal": {
|
||||
"label": "절약",
|
||||
"hint": "동기화 후 카드는 하루에 최대 한 번 새로 고쳐지고, 일일 브리핑은 짧아지며, 문서는 요청할 때만 읽어요."
|
||||
},
|
||||
"standard": {
|
||||
"label": "표준",
|
||||
"hint": "동기화 후 카드는 몇 시간마다 새로 고쳐지고, 일일 브리핑은 전체 형태예요."
|
||||
},
|
||||
"full": {
|
||||
"label": "전체",
|
||||
"hint": "새 데이터가 들어오면 카드가 최대 한 시간마다 새로 고쳐져요. 이 설정이 생기기 전과 같아요."
|
||||
}
|
||||
},
|
||||
"limited": "서버 운영자가 제한했어요.",
|
||||
"estimate": "하루 약 {tokens} 토큰, 추정치",
|
||||
"measured": "측정값: 최근 {days}일 동안 하루 {tokens} 토큰이에요. 코치 사용량도 포함돼요. 단계별 수치는 추정치예요.",
|
||||
"noEstimate": "AI를 3일 사용하면 단계별 추정치가 표시돼요.",
|
||||
"capped": "{choice} 단계를 선택했지만 지금은 서버에서 허용하지 않아 {level} 단계가 적용돼요. 선택은 그대로 저장돼요.",
|
||||
"followsDefault": "서버 기본값({level})을 따르고 있어요.",
|
||||
"useDefault": "서버 기본값 사용({level})",
|
||||
"saveError": "단계를 저장하지 못했어요. 다시 시도해 주세요."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"units": {
|
||||
@@ -6646,7 +6762,14 @@
|
||||
"docs": "문서",
|
||||
"credits": "크레딧 및 데이터 출처",
|
||||
"linksHeading": "출처 및 문서",
|
||||
"newerAvailable": "새 버전: {tag}"
|
||||
"newerAvailable": "새 버전: {tag}",
|
||||
"support": {
|
||||
"title": "지원",
|
||||
"description": "버그 신고용 요약이며, 이름, 이메일, 건강 수치는 포함되지 않습니다.",
|
||||
"copy": "지원 정보 복사",
|
||||
"copied": "복사됨",
|
||||
"copyFailed": "지원 정보를 만들 수 없습니다."
|
||||
}
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "연결 테스트",
|
||||
@@ -7133,7 +7256,8 @@
|
||||
"lastReceived": "마지막 수신 {relative}",
|
||||
"lastNewValue": "마지막 새 값 {relative}",
|
||||
"lastBackground": "마지막 백그라운드 수신 {relative}",
|
||||
"never": "아직 수신되지 않음"
|
||||
"never": "아직 수신되지 않음",
|
||||
"lastBackfill": "기록 보충 {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
@@ -7267,6 +7391,46 @@
|
||||
"failureStreak": "연속 실패: {threshold}회 중 {count}회",
|
||||
"copyFailed": "복사하지 못했어요. 텍스트를 선택해서 직접 복사해 주세요."
|
||||
}
|
||||
},
|
||||
"dawarich": "Dawarich",
|
||||
"dawarichTag": "이동",
|
||||
"dawarichDescription": "Dawarich 인스턴스를 연결해 하루 이동 거리, 집 밖에 있던 시간, 여행일을 가져옵니다.",
|
||||
"dawarichUrl": "Dawarich 주소",
|
||||
"dawarichApiKey": "API 키",
|
||||
"dawarichApiKeyHelp": "Dawarich의 계정 메뉴에서 찾을 수 있습니다. HealthLog는 이 키로 하루 단위 값만 읽고 좌표나 장소 이름은 읽지 않습니다.",
|
||||
"dawarichConnect": "연결",
|
||||
"dawarichUpdate": "변경 사항 저장",
|
||||
"dawarichConnected": "Dawarich가 연결되었습니다. 최근 30일을 불러오는 중입니다.",
|
||||
"dawarichSync": "지금 가져오기",
|
||||
"dawarichSyncQueued": "최근 7일을 곧 가져옵니다.",
|
||||
"dawarichSyncAlreadyQueued": "이미 가져오는 중입니다.",
|
||||
"dawarichDisconnect": "연결 해제",
|
||||
"dawarichDisconnectTitle": "Dawarich 연결을 해제할까요?",
|
||||
"dawarichDisconnectDescription": "앞으로의 가져오기가 중단됩니다. 이미 저장된 날은 남습니다.",
|
||||
"dawarichDisconnectDelete": "연결 해제 후 기록 삭제",
|
||||
"dawarichDisconnectDeleteTitle": "연결을 해제하고 모든 날을 삭제할까요?",
|
||||
"dawarichDisconnectDeleteDescription": "연결과 저장된 모든 이동 기록이 삭제됩니다. 되돌릴 수 없습니다.",
|
||||
"dawarichHomePlaceSet": "집이 설정되어 있습니다.",
|
||||
"dawarichHomePlaceMissing": "집 밖에 있던 시간을 재려면 집을 설정하세요.",
|
||||
"dawarichHomeCountryIs": "거주 국가: {country}.",
|
||||
"dawarichHomeCountryMissing": "거주 국가가 없어 여행일도 없습니다.",
|
||||
"dawarichDaysStored": "저장된 날: {count}",
|
||||
"dawarichHomePlace": "집",
|
||||
"dawarichHomePlaceChoose": "Dawarich에서 장소 선택",
|
||||
"dawarichHomePlaceChange": "집 선택",
|
||||
"dawarichHomeCountry": "거주 국가",
|
||||
"dawarichHomeCountryHelp": "DE처럼 두 글자 국가 코드입니다. 비워 두면 지난 1년 동안 가장 많은 날을 보낸 나라가 기준이 됩니다.",
|
||||
"dawarichHomeSave": "저장",
|
||||
"dawarichHomeSaved": "저장했습니다.",
|
||||
"dawarichErrors": {
|
||||
"invalid": "이 주소는 사용할 수 없습니다. 인증 정보 없이 인스턴스 주소를 입력하세요.",
|
||||
"privateOrigin": "이 인스턴스는 사설 네트워크에 있습니다. 서버 운영자에게 허용을 요청하세요.",
|
||||
"credentials": "Dawarich가 API 키를 받아들이지 않았습니다.",
|
||||
"unreachable": "Dawarich에 연결할 수 없습니다. 주소와 인스턴스 실행 여부를 확인하세요.",
|
||||
"rateLimited": "시도가 너무 많습니다. 잠시 후 다시 시도하세요.",
|
||||
"homePlaceUnknown": "Dawarich 인스턴스에 이 장소가 더 이상 없습니다.",
|
||||
"notConnected": "Dawarich가 연결되어 있지 않습니다.",
|
||||
"generic": "실패했습니다. 다시 시도하세요."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -7729,7 +7893,8 @@
|
||||
"eventDetails": "이벤트 상세",
|
||||
"closeDetails": "상세 닫기",
|
||||
"processDetail": "버퍼는 휘발성이고 최대 500개까지만 담아요.",
|
||||
"cardTitle": "최근 이벤트"
|
||||
"cardTitle": "최근 이벤트",
|
||||
"filterErrorId": "오류 ID"
|
||||
},
|
||||
"backups": {
|
||||
"title": "백업",
|
||||
@@ -7816,6 +7981,7 @@
|
||||
"restoreSkippedCoachAttachment": "코치 문서 연결",
|
||||
"restoreSkippedCoachReference": "코치 참조",
|
||||
"restoreSkippedDocumentConditionLink": "문서 분류",
|
||||
"restoreSkippedRelationLink": "기록 간 연결",
|
||||
"restoreSkippedExtractedFact": "문서 추출 정보",
|
||||
"restoreSkippedFactCommitment": "추출 정보 확정",
|
||||
"docsLink": "문서",
|
||||
@@ -7992,6 +8158,56 @@
|
||||
"started": "유지 관리를 시작했습니다.",
|
||||
"alreadyQueued": "유지 관리가 이미 대기 중이거나 실행 중입니다.",
|
||||
"startFailed": "유지 관리를 시작할 수 없습니다."
|
||||
},
|
||||
"pulseDayMean": {
|
||||
"title": "하루 평균 맥박",
|
||||
"description": "측정값이 적은 시간이 하루 맥박에 반영되는 방식입니다.",
|
||||
"body": "지금까지는 측정값이 있는 모든 시간이 똑같이 반영되어, 동떨어진 측정값 하나가 하루 전체를 움직일 수 있습니다. 가중 방식에서는 측정값이 세 개 이상인 시간이 온전히 반영되고, 측정값이 하나뿐인 시간은 3분의 1만 반영됩니다. 먼저 시험 실행으로 바뀌는 날이 얼마나 되는지 확인하세요. 적용하면 저장된 맥박 일별 값이 백그라운드에서 다시 계산됩니다.",
|
||||
"method": {
|
||||
"hourly": "모든 시간 동일",
|
||||
"weighted": "드문 시간 가중"
|
||||
},
|
||||
"loading": "하루 평균 맥박을 확인하는 중…",
|
||||
"loadError": "하루 평균 맥박 상태를 읽을 수 없습니다.",
|
||||
"state": {
|
||||
"idle": "아직 시험 실행이 없습니다.",
|
||||
"queued": "시험 실행이 백그라운드 작업자를 기다리는 중입니다.",
|
||||
"running": "시험 실행이 진행 중입니다.",
|
||||
"done": "{at}에 시험 실행이 끝났습니다.",
|
||||
"failed": "시험 실행이 중단되었습니다. 앱 로그에 원인이 있습니다. 다시 시작하세요."
|
||||
},
|
||||
"result": {
|
||||
"days": "비교한 날",
|
||||
"changed": "1 bpm 넘게 바뀌는 날",
|
||||
"max": "가장 큰 변화",
|
||||
"maxValue": "{value} bpm",
|
||||
"bucket": "{range} bpm",
|
||||
"account": "계정 {account}",
|
||||
"accountDays": "{days}일 중 {changed}일 변경"
|
||||
},
|
||||
"appliedAt": "{at}에 가중 방식이 적용되었습니다.",
|
||||
"dryRun": "시험 실행",
|
||||
"apply": "적용",
|
||||
"confirmTitle": "가중 하루 평균 맥박을 적용할까요?",
|
||||
"confirmBody": "이후 모든 계정의 맥박 일별 값이 드문 시간을 가중하여 계산됩니다. 저장된 맥박 일별 값은 백그라운드에서 다시 계산되며, 큰 인스턴스에서는 시간이 걸릴 수 있습니다.",
|
||||
"confirm": "적용",
|
||||
"cancel": "취소",
|
||||
"dryRunStarted": "시험 실행을 시작했습니다.",
|
||||
"applied": "가중 방식이 적용되었습니다. 맥박 일별 값을 다시 계산하는 중입니다.",
|
||||
"appliedNoRebuild": "가중 방식이 적용되었습니다. 다른 실행이 아직 진행 중이니, 끝난 뒤 다시 적용하여 맥박 일별 값을 다시 계산하세요.",
|
||||
"error": {
|
||||
"busy": "시험 실행이나 재계산이 아직 진행 중입니다. 끝난 뒤 다시 시도하세요.",
|
||||
"dryRunRequired": "먼저 시험 실행을 하세요. 적용하려면 최근 7일 안에 끝난 시험 실행이 필요합니다.",
|
||||
"generic": "요청이 처리되지 않았습니다."
|
||||
},
|
||||
"rebuild": {
|
||||
"queued": "재계산이 백그라운드 작업을 기다리고 있습니다.",
|
||||
"running": "맥박 일별 값을 재계산하고 있습니다.",
|
||||
"done": "{at}에 계정 {accounts}개의 맥박 일별 값을 재계산했습니다.",
|
||||
"failed": "재계산이 중단되었습니다. 앱 로그에 원인이 있습니다. 다시 실행하세요."
|
||||
},
|
||||
"rebuildAgain": "다시 계산",
|
||||
"rebuildStarted": "재계산을 시작했습니다."
|
||||
}
|
||||
},
|
||||
"carrier": "통신사",
|
||||
@@ -8061,7 +8277,8 @@
|
||||
"spendToday": "오늘(각 계정의 현지 날짜 기준): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다(사용량이 있는 계정: {accounts}개).",
|
||||
"statusPaused": "일시 중지됨",
|
||||
"pausedHint": "반복된 오류로 사용자 {count}명에 대해 {at}까지 일시 중지되었습니다. 그 후 자동으로 다시 시도합니다.",
|
||||
"modelNotServed": "엔드포인트가 더 이상 {model} 모델을 제공하지 않습니다. AI 설정에서 제공되는 모델을 선택하세요."
|
||||
"modelNotServed": "엔드포인트가 더 이상 {model} 모델을 제공하지 않습니다. AI 설정에서 제공되는 모델을 선택하세요.",
|
||||
"usageLevels": "AI 사용량 단계별 계정 수: 절약 {frugal}, 표준 {standard}, 전체 {full}."
|
||||
},
|
||||
"cardError": {
|
||||
"title": "페이지의 이 부분을 표시할 수 없습니다",
|
||||
@@ -8120,7 +8337,25 @@
|
||||
"codes": "운영자용: API 요청은 오류 코드 {code}와 함께 503으로 응답하고, /api/health는 원인 {reason}을 알려 줍니다."
|
||||
},
|
||||
"loginOverviewCardTitle": "로그인 이벤트",
|
||||
"userColumn": "사용자"
|
||||
"userColumn": "사용자",
|
||||
"supportReport": {
|
||||
"title": "지원 보고서",
|
||||
"description": "버그 신고용 이 인스턴스의 요약이며, 이메일, ID, 수치는 포함되지 않습니다.",
|
||||
"body": "계정은 “Account 1”, “Account 2”처럼 표시됩니다. 공유하기 전에 보고서를 확인하세요.",
|
||||
"copy": "복사",
|
||||
"copied": "복사됨",
|
||||
"download": "다운로드",
|
||||
"loadError": "지원 보고서를 만들 수 없습니다."
|
||||
},
|
||||
"aiUsage": {
|
||||
"title": "AI 사용량 단계",
|
||||
"description": "계정이 선택할 수 있는 최고 단계와 처음 시작하는 단계예요.",
|
||||
"maxLabel": "최고 단계",
|
||||
"defaultLabel": "기본 단계",
|
||||
"defaultAboveCeiling": "기본 단계는 최고 단계보다 높을 수 없어요.",
|
||||
"gapLabel": "동기화 후 카드 새로 고침 간격(시간, 표준 단계)",
|
||||
"gapHint": "1에서 48 사이의 정수 시간이에요. 비워 두면 AI_SYNC_REGEN_GAP_HOURS를, 없으면 6을 사용해요. 현재 적용 값: {hours}시간."
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "업적",
|
||||
@@ -9215,7 +9450,9 @@
|
||||
"periodStartSaved": "생리 시작을 저장했어요",
|
||||
"periodEndSaved": "생리 종료를 저장했어요",
|
||||
"startedPeriodOnDate": "이 날 생리를 시작했어요",
|
||||
"endedPeriodOnDate": "이 날 생리가 끝났어요"
|
||||
"endedPeriodOnDate": "이 날 생리가 끝났어요",
|
||||
"startAbsorbed": "생리 시작을 저장했어요. {absorbed}의 시작은 {date}로 합쳐졌어요.",
|
||||
"startRestored": "저장했어요. {dates}의 시작이 다시 표시돼요."
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "생리가 곧 시작될 것 같아요",
|
||||
@@ -9604,12 +9841,16 @@
|
||||
"apob": "아포지단백 B는 죽상경화를 일으키는 지단백 입자마다 하나씩 붙어 있는 구조 단백질이라, 이 값을 재면 입자가 나르는 콜레스테롤이 아니라 입자 수를 직접 세는 셈이에요. LDL과 VLDL, 관련 입자 하나에 ApoB 분자가 하나씩 있기 때문에, 입자 수와 콜레스테롤 양이 어긋날 때 LDL 콜레스테롤이 놓치는 심혈관 위험을 잡아낼 수 있어요. 그래서 지질 기반 위험 평가를 다듬는 데 점점 많이 쓰여요. 기본 지질 검사와 함께 해석해요.",
|
||||
"lp-a": "지단백(a)는 LDL과 비슷한 입자에 단백질이 하나 더 붙은 형태로, 수치는 대체로 유전으로 정해지고 평생 꽤 일정하게 유지돼요. Lp(a)가 높으면 심혈관 질환과 대동맥판막 협착의 독립적인 유전 위험 요인이에요. 거의 변하지 않기 때문에 반복해서 추적하기보다 평생 위험을 파악하려고 보통 한 번만 재요. 몰 단위로 보고되고 전체 심혈관 위험의 일부로 읽어요.",
|
||||
"fasting-insulin": "공복 인슐린은 혈액 속 포도당을 세포로 옮기는 호르몬인 인슐린을 밤새 금식한 뒤 잰 값이에요. 공복혈당과 함께 보면 혈당을 유지하려고 몸이 더 많은 인슐린을 필요로 하는 상태, 즉 인슐린 저항성을 가늠할 수 있어요. 혈당은 정상인데 공복 인슐린이 높다면 이런 보상이 이미 진행 중이라는 이른 신호일 수 있어요. 단독으로 보기보다 대사 건강 전체 그림의 일부로 읽어요.",
|
||||
"omega-3-index": "오메가3 지수는 적혈구 막의 지방산 가운데 긴사슬 해양 오메가3 지방산인 EPA와 DHA가 차지하는 비율이에요. 적혈구는 천천히 교체되기 때문에 최근 식사가 아니라 장기적인 오메가3 섭취를 반영해요. 지수가 높을수록 관찰연구에서 심혈관 위험이 낮은 것과 연관됐어요. 몇 달에 걸쳐 식사에 반응하는 안정적인 오메가3 상태 지표로 기록해요."
|
||||
"omega-3-index": "오메가3 지수는 적혈구 막의 지방산 가운데 긴사슬 해양 오메가3 지방산인 EPA와 DHA가 차지하는 비율이에요. 적혈구는 천천히 교체되기 때문에 최근 식사가 아니라 장기적인 오메가3 섭취를 반영해요. 지수가 높을수록 관찰연구에서 심혈관 위험이 낮은 것과 연관됐어요. 몇 달에 걸쳐 식사에 반응하는 안정적인 오메가3 상태 지표로 기록해요.",
|
||||
"alkaline-phosphatase": "알칼리성 인산분해효소(ALP)는 주로 간, 담관, 뼈에 있는 효소예요. 담즙 흐름이 막히거나, 어린이와 청소년의 성장기나 골절이 아무는 시기처럼 뼈가 활발히 만들어질 때 올라가요. 두 기관 모두에서 나오기 때문에 높은 값은 보통 GGT와 다른 간 지표와 함께 읽어서 간에서 온 것인지 뼈에서 온 것인지 구분해요. 기본 간 검사 지표 중 하나예요.",
|
||||
"bilirubin-total": "총빌리루빈은 수명이 다한 적혈구의 헤모글로빈이 분해되면서 생기는 노란 물질로, 비포합형과 포합형을 합친 값으로 측정해요. 간이 이를 받아들여 포합한 뒤 담즙으로 내보내기 때문에, 수치는 이 경로가 얼마나 잘 작동하는지를 보여 줘요. 간 질환, 담관 폐쇄, 적혈구 파괴 증가 때 올라갈 수 있고, 길버트 증후군에서는 해롭지 않게 올라가기도 해요. 다른 간 지표와 함께 읽어요."
|
||||
},
|
||||
"apob": "아포지단백 B (ApoB)",
|
||||
"lp-a": "지단백(a)",
|
||||
"fasting-insulin": "공복 인슐린",
|
||||
"omega-3-index": "오메가3 지수"
|
||||
"omega-3-index": "오메가3 지수",
|
||||
"alkaline-phosphatase": "알칼리성 인산분해효소(ALP)",
|
||||
"bilirubin-total": "총빌리루빈"
|
||||
},
|
||||
"noteLoadFailed": "메모를 불러오지 못했어요. 저장해도 메모는 그대로 유지돼요.",
|
||||
"showingLatestOf": "측정값 {total}개 중 최근 {shown}개를 보여주고 있어요",
|
||||
@@ -9683,6 +9924,54 @@
|
||||
"fromReport": "결과지 범위 {range}",
|
||||
"catalogDiffers": "내 범위 {range}",
|
||||
"printed": "결과지 원문({unit}): {range}"
|
||||
},
|
||||
"match": {
|
||||
"viaAlias": "전에 고른 이름으로 연결됨",
|
||||
"viaCatalog": "흔히 쓰는 약어로 인식됨",
|
||||
"chooseLabel": "연결할 항목",
|
||||
"suggestionOption": "{name} ({unit})",
|
||||
"newMarkerOption": "새 바이오마커",
|
||||
"rememberHint": "다음 결과지를 위해 이 이름을 기억해요.",
|
||||
"chooseHint": "이미 기록 중인 바이오마커라면 골라 주세요."
|
||||
},
|
||||
"aliases": {
|
||||
"title": "다른 표기",
|
||||
"taken": "{alias}은(는) 이미 다른 바이오마커에 연결돼 있어요.",
|
||||
"saveError": "이름을 저장하지 못했어요. 다시 시도해 주세요.",
|
||||
"remove": "{alias} 삭제",
|
||||
"empty": "다른 검사기관이 {name}에 쓰는 이름을 추가하면 그 결과지도 여기로 모여요.",
|
||||
"placeholder": "예: ALP",
|
||||
"addLabel": "이 바이오마커의 다른 이름",
|
||||
"add": "추가",
|
||||
"alsoWritten": "다른 표기: {aliases}"
|
||||
},
|
||||
"merge": {
|
||||
"open": "합치기",
|
||||
"title": "바이오마커 합치기",
|
||||
"description": "이름만 다르게 적힌 같은 검사 항목을 하나로 모아요.",
|
||||
"pickHint": "바이오마커를 두 개 이상 골라 주세요.",
|
||||
"keepLabel": "남길 항목",
|
||||
"keepHint": "나머지는 이 항목으로 옮겨지고, 그 이름은 이 항목의 다른 표기가 돼요.",
|
||||
"showPreview": "미리 보기",
|
||||
"back": "뒤로",
|
||||
"confirm": "지금 합치기",
|
||||
"previewSummaryOne": "측정값 {count}개가 {name} ({unit})(으)로 옮겨져요.",
|
||||
"previewSummaryFew": "측정값 {count}개가 {name} ({unit})(으)로 옮겨져요.",
|
||||
"previewSummaryOther": "측정값 {count}개가 {name} ({unit})(으)로 옮겨져요.",
|
||||
"movesOne": "측정값 {count}개, 이미 같은 단위",
|
||||
"movesFew": "측정값 {count}개, 이미 같은 단위",
|
||||
"movesOther": "측정값 {count}개, 이미 같은 단위",
|
||||
"convertedOne": "측정값 {count}개, {unit}에서 {targetUnit}(으)로 환산",
|
||||
"convertedFew": "측정값 {count}개, {unit}에서 {targetUnit}(으)로 환산",
|
||||
"convertedOther": "측정값 {count}개, {unit}에서 {targetUnit}(으)로 환산",
|
||||
"refused": "{unit}은(는) {targetUnit}(으)로 환산할 수 없어요.",
|
||||
"aliasesAdded": "이 이름들은 남긴 바이오마커로 연결돼요:",
|
||||
"blocked": "환산할 수 없는 단위가 있으면 아무것도 합치지 않아요. 그 바이오마커를 빼거나 다른 항목을 남겨 주세요.",
|
||||
"noUndo": "합친 뒤에는 되돌릴 수 없어요.",
|
||||
"doneToastOne": "{name}(으)로 합쳤어요, 측정값 {count}개 이동",
|
||||
"doneToastFew": "{name}(으)로 합쳤어요, 측정값 {count}개 이동",
|
||||
"doneToastOther": "{name}(으)로 합쳤어요, 측정값 {count}개 이동",
|
||||
"error": "바이오마커를 합치지 못했어요. 다시 시도해 주세요."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -10644,25 +10933,11 @@
|
||||
},
|
||||
"profileFacts": {
|
||||
"cardTitle": "생활 습관 정보",
|
||||
"cardDescription": "측정값을 어떻게 해석해야 하는지에 영향을 주는 현재 상황을 기록해 주세요.",
|
||||
"smokingLabel": "흡연 상태",
|
||||
"alcoholLabel": "음주 패턴",
|
||||
"shiftLabel": "근무 일정",
|
||||
"notRecorded": "기록 없음",
|
||||
"unreadable": "저장된 값을 읽지 못했어요",
|
||||
"save": "저장",
|
||||
"savedToast": "생활 습관 정보를 저장했어요",
|
||||
"savePartialError": "저장 실패: {kinds}. 다시 시도해 주세요.",
|
||||
"saveKindError": "저장 안 됨",
|
||||
"loadError": "생활 습관 정보를 불러오지 못했어요",
|
||||
"remove": "삭제: {kind}",
|
||||
"removeShort": "삭제",
|
||||
"removeConfirmTitle": "{kind} 삭제할까요?",
|
||||
"removeConfirmDescription": "현재 값이 '기록 없음'으로 바뀌어요. 날짜별 이력은 그대로 남아요.",
|
||||
"removedToast": "현재 생활 습관 정보에서 삭제함: {kind}",
|
||||
"removeError": "삭제하지 못함: {kind}",
|
||||
"historyTitle": "이전 값",
|
||||
"to": "~",
|
||||
"values": {
|
||||
"SMOKING_STATUS": {
|
||||
"NEVER": "피운 적 없음",
|
||||
@@ -10842,6 +11117,235 @@
|
||||
"savedToast": "응급 정보를 저장했어요",
|
||||
"saveError": "응급 정보를 저장하지 못했어요.",
|
||||
"loadError": "응급 정보를 불러오지 못했어요."
|
||||
},
|
||||
"life": {
|
||||
"cardTitle": "생활 환경",
|
||||
"cardDescription": "흡연, 음주, 활동, 일, 습관과 목표를 날짜와 함께, 모두 선택적으로 기록해요.",
|
||||
"loadError": "생활 환경을 불러오지 못했어요.",
|
||||
"noAnswer": "응답 안 함",
|
||||
"yes": "예",
|
||||
"no": "아니요",
|
||||
"validSince": "적용 시작",
|
||||
"month": "월",
|
||||
"year": "연도",
|
||||
"lastConfirmed": "{date}에 마지막으로 확인함",
|
||||
"notAnswered": "아직 답하지 않음",
|
||||
"unreadable": "이 서버에서는 이 응답을 읽을 수 없어요.",
|
||||
"save": "이 항목 저장",
|
||||
"savedToast": "항목을 저장했어요",
|
||||
"saveError": "항목을 저장하지 못했어요.",
|
||||
"review": {
|
||||
"question": "아직 그대로인가요? 마지막 응답이 1년도 더 지났어요.",
|
||||
"confirm": "그대로예요",
|
||||
"confirmedToast": "고마워요. 여전히 맞다고 표시했어요",
|
||||
"confirmError": "확인을 저장하지 못했어요."
|
||||
},
|
||||
"ai": {
|
||||
"label": "이 항목을 AI와 공유",
|
||||
"shiftLabel": "교대 근무를 AI와 공유",
|
||||
"hint": "끄면 코치와 브리핑이 이 응답을 전혀 보지 않아요.",
|
||||
"savedToast": "AI 공유 설정을 바꿨어요",
|
||||
"saveError": "AI 설정을 저장하지 못했어요."
|
||||
},
|
||||
"sections": {
|
||||
"tobacco": {
|
||||
"title": "흡연과 니코틴"
|
||||
},
|
||||
"alcohol": {
|
||||
"title": "음주"
|
||||
},
|
||||
"activity": {
|
||||
"title": "활동과 앉아 있는 시간"
|
||||
},
|
||||
"work": {
|
||||
"title": "일과 생활 리듬"
|
||||
},
|
||||
"habits": {
|
||||
"title": "수면, 식사, 카페인"
|
||||
},
|
||||
"goals": {
|
||||
"title": "목표"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"smokingStatus": "흡연 상태",
|
||||
"products": "사용하는 제품",
|
||||
"cigarettesPerDay": "하루 담배 개비 수",
|
||||
"startYear": "흡연을 시작한 해",
|
||||
"quitMonth": "금연한 시기",
|
||||
"secondhandSmoke": "간접흡연에 자주 노출",
|
||||
"moderateDaysPerWeek": "빠르게 걷기처럼 중간 강도 이상으로 움직이는 날, 주당",
|
||||
"moderateMinutesPerDay": "그런 날의 평균 시간(분)",
|
||||
"sittingHoursWorkday": "평일에 앉아 있는 시간",
|
||||
"shiftSchedule": "교대 근무",
|
||||
"situation": "근무 환경",
|
||||
"physicalNature": "일의 종류",
|
||||
"nightWork": "야간 근무",
|
||||
"exposures": "일할 때의 노출",
|
||||
"freeWeekdays": "쉬는 요일",
|
||||
"napRegularly": "규칙적인 낮잠",
|
||||
"snoringReported": "다른 사람이 코를 곤다고 함",
|
||||
"dietPattern": "식사 방식",
|
||||
"dietSpecifics": "특이 사항",
|
||||
"lateMealsPerWeek": "주당 밤 9시 이후 식사 횟수",
|
||||
"fruitVegPortionsPerDay": "하루 과일·채소 섭취량(회)",
|
||||
"caffeineDrinksPerDay": "하루 카페인 음료 수",
|
||||
"latestCaffeine": "하루 중 마지막 카페인",
|
||||
"goals": "노력하고 싶은 부분",
|
||||
"goalsHint": "최대 세 가지."
|
||||
},
|
||||
"values": {
|
||||
"products": {
|
||||
"cigarettes": "궐련",
|
||||
"cigars_pipe": "시가 또는 파이프",
|
||||
"e_cigarette": "전자담배",
|
||||
"heated_tobacco": "궐련형 전자담배",
|
||||
"snus_pouches": "스누스 또는 니코틴 파우치"
|
||||
},
|
||||
"situation": {
|
||||
"none": "유급 노동 안 함",
|
||||
"indoor": "주로 실내",
|
||||
"outdoor": "주로 실외",
|
||||
"mixed": "실내와 실외"
|
||||
},
|
||||
"physicalNature": {
|
||||
"mostly_sitting": "주로 앉아서",
|
||||
"standing_walking": "서 있거나 걸으며",
|
||||
"heavy_physical": "힘든 육체노동"
|
||||
},
|
||||
"exposures": {
|
||||
"heat": "더위",
|
||||
"cold": "추위",
|
||||
"dust": "먼지",
|
||||
"fumes_solvents": "연기 또는 용제",
|
||||
"noise": "소음"
|
||||
},
|
||||
"dietPattern": {
|
||||
"mixed": "일반식",
|
||||
"vegetarian": "채식",
|
||||
"vegan": "비건",
|
||||
"pescetarian": "페스코 채식",
|
||||
"other": "기타"
|
||||
},
|
||||
"dietSpecifics": {
|
||||
"intermittent_fasting": "간헐적 단식",
|
||||
"low_carb": "저탄수화물",
|
||||
"prescribed_diet": "의사가 처방한 식단"
|
||||
},
|
||||
"latestCaffeine": {
|
||||
"before_12": "정오 전",
|
||||
"12_15": "정오부터 오후 3시",
|
||||
"15_18": "오후 3시부터 6시",
|
||||
"after_18": "오후 6시 이후"
|
||||
},
|
||||
"goals": {
|
||||
"weight": "체중",
|
||||
"blood_pressure": "혈압",
|
||||
"blood_sugar": "혈당",
|
||||
"sleep": "수면",
|
||||
"fitness": "체력",
|
||||
"stress_recovery": "스트레스와 회복",
|
||||
"medication_routine": "약을 규칙적으로 복용",
|
||||
"quit_smoking": "금연",
|
||||
"less_alcohol": "음주 줄이기"
|
||||
},
|
||||
"weekdays": {
|
||||
"1": "월",
|
||||
"2": "화",
|
||||
"3": "수",
|
||||
"4": "목",
|
||||
"5": "금",
|
||||
"6": "토",
|
||||
"7": "일"
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
"packYears": "{value}갑년",
|
||||
"packYearsMissing": "갑년을 계산하려면 하루 개비 수와 흡연 시작 연도가 필요해요.",
|
||||
"activityMinutes": "주당 {value}분.",
|
||||
"meetsWho": "WHO 권장량인 주당 150분을 채워요.",
|
||||
"belowWho": "WHO 권장량인 주당 150분보다 적어요.",
|
||||
"measuredSteps": "측정된 걸음 수가 우선이고, 이 응답은 맥락을 더해요.",
|
||||
"freeDaysDefault": "응답이 없으면 토요일과 일요일을 쉬는 날로 봐요."
|
||||
},
|
||||
"auditC": {
|
||||
"intro": "WHO AUDIT의 음주 관련 세 문항이에요. 선별검사이며 진단이 아니에요.",
|
||||
"start": "문항에 답하기",
|
||||
"again": "다시 답하기",
|
||||
"offerDue": "1년에 한 번 확인하면 충분해요.",
|
||||
"notValidated": "사용하는 언어로 검증된 번역이 아직 없어 문항을 영어로 보여 드려요.",
|
||||
"submit": "답변 저장",
|
||||
"savedToast": "답변을 저장했어요",
|
||||
"saveError": "답변을 저장하지 못했어요.",
|
||||
"result": "12점 중 {score}점",
|
||||
"above": "선별 기준점 {cutoff}점 이상이에요.",
|
||||
"below": "선별 기준점 {cutoff}점 미만이에요.",
|
||||
"noCutoff": "프로필에 성별이 없어 합계만 보여 드려요.",
|
||||
"source": "출처: {source}",
|
||||
"history": "이전 기록",
|
||||
"remove": "기록 삭제",
|
||||
"removeConfirmTitle": "이 기록을 삭제할까요?",
|
||||
"removeConfirmBody": "기록이 내역에서 사라져요. 여기서는 되돌릴 수 없어요.",
|
||||
"removedToast": "기록을 삭제했어요",
|
||||
"removeError": "기록을 삭제하지 못했어요.",
|
||||
"historyEntry": "{date}: 12점 중 {score}점",
|
||||
"legacyLabel": "이전 답변: {value}",
|
||||
"legacyHint": "이 설문 전에 묻던 짧은 음주 질문의 답변입니다. 설문 결과가 있으면 코치는 더 이상 이 답변을 읽지 않습니다.",
|
||||
"legacyRemove": "이전 답변 삭제",
|
||||
"legacyRemoveConfirmTitle": "이전 답변을 삭제할까요?",
|
||||
"legacyRemoveConfirmBody": "이전 답변이 기록에서 삭제됩니다. 설문 결과는 그대로 남습니다.",
|
||||
"legacyRemovedToast": "이전 답변을 삭제했습니다",
|
||||
"legacyRemoveError": "이전 답변을 삭제하지 못했습니다."
|
||||
},
|
||||
"takeOver": {
|
||||
"conditionsButton": "상태 일지로 옮기기",
|
||||
"allergiesButton": "알레르기로 옮기기",
|
||||
"conditionsTitle": "만성 질환 추가",
|
||||
"allergiesTitle": "알레르기 추가",
|
||||
"description": "직접 지우기 전까지 자유 입력 내용은 그대로 남아요.",
|
||||
"freeTextLabel": "자유 입력 내용",
|
||||
"conditionLabel": "질환",
|
||||
"onset": "시작",
|
||||
"save": "추가",
|
||||
"savedToast": "항목을 추가했어요",
|
||||
"saveError": "항목을 추가하지 못했어요.",
|
||||
"clearQuestion": "항목을 저장했어요. 지금 자유 입력 내용을 지울까요?",
|
||||
"clear": "자유 입력 지우기",
|
||||
"keep": "그대로 두기",
|
||||
"clearedToast": "자유 입력 내용을 지웠어요",
|
||||
"clearError": "자유 입력 내용을 지우지 못했어요."
|
||||
},
|
||||
"screening": {
|
||||
"cardTitle": "검진 안내",
|
||||
"cardDescription": "생활 환경에서 나온 안내로, 기준과 그 기준을 만든 나라를 함께 보여 줘요.",
|
||||
"noReminder": "안내일 뿐이에요. 알림이 필요하면 직접 만들어 주세요.",
|
||||
"source": "출처: {source}",
|
||||
"titles": {
|
||||
"lung_ldct": "폐암 저선량 CT",
|
||||
"aaa_ultrasound": "복부 대동맥 초음파"
|
||||
},
|
||||
"status": {
|
||||
"suggested": "해당될 수 있어요",
|
||||
"not_eligible": "해당되지 않아요",
|
||||
"unknown": "정보가 부족해요"
|
||||
},
|
||||
"reasons": {
|
||||
"never_smoked": "담배를 피운 적이 없어요.",
|
||||
"missing_birth_date": "생년월일이 없어요.",
|
||||
"age_out_of_range": "나이가 기준 범위 밖이에요.",
|
||||
"missing_smoking_status": "흡연 상태가 없어요.",
|
||||
"no_cigarettes": "이 기준은 궐련만 세어요.",
|
||||
"missing_smoking_history": "하루 개비 수나 흡연 시작 연도가 없어요.",
|
||||
"pack_years_below_15": "15갑년 미만이에요.",
|
||||
"pack_years_below_20": "20갑년 미만이에요.",
|
||||
"smoked_years_below_25": "흡연 기간이 25년 미만이에요.",
|
||||
"missing_quit_date": "금연 시기가 없어요.",
|
||||
"quit_over_10_years": "금연한 지 10년이 넘었어요.",
|
||||
"quit_over_15_years": "금연한 지 15년이 넘었어요.",
|
||||
"missing_sex": "프로필에 성별이 없어요.",
|
||||
"sex_not_male": "이 기준은 남성에게 적용돼요."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
@@ -10875,6 +11379,10 @@
|
||||
"mugwort": "쑥",
|
||||
"olive": "올리브",
|
||||
"ragweed": "돼지풀"
|
||||
},
|
||||
"movement": {
|
||||
"awayFromHome": "집 밖에 있던 시간",
|
||||
"distance": "이동 거리"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -11122,7 +11630,8 @@
|
||||
"extractEmpty": "이 문서에서 추출할 수 있는 값이 없었어요.",
|
||||
"failUnitRequired": "숫자 값을 저장하려면 단위가 필요해요.",
|
||||
"failUnitMismatch": "기재된 단위가 이 지표에 저장된 단위와 달라요. 수정한 뒤 검토를 다시 완료해 주세요.",
|
||||
"failGeneric": "이 값을 저장하지 못했어요."
|
||||
"failGeneric": "이 값을 저장하지 못했어요.",
|
||||
"failUnknownBiomarker": "고른 바이오마커가 더 이상 없어요. 다른 항목을 골라 주세요."
|
||||
},
|
||||
"contentIndex": {
|
||||
"indexPrompt": "문서 내용을 검색할 수 있게 만들어요.",
|
||||
@@ -11344,7 +11853,9 @@
|
||||
"molybdenum": "몰리브덴",
|
||||
"iodine": "요오드",
|
||||
"water": "수분",
|
||||
"caffeine": "카페인"
|
||||
"caffeine": "카페인",
|
||||
"dha": "DHA (오메가-3)",
|
||||
"epa": "EPA (오메가-3)"
|
||||
},
|
||||
"page": {
|
||||
"title": "영양소 섭취",
|
||||
@@ -11376,9 +11887,26 @@
|
||||
"rowMetaNoRef": "{window}일 중 {days}일",
|
||||
"footer": "이 기간에 데이터가 있는 미량영양소는 추적 중인 {total}개 중 {tracked}개예요.",
|
||||
"emptyTitle": "아직 동기화된 미량영양소 데이터가 없어요",
|
||||
"loadError": "영양소 데이터를 불러오지 못했어요"
|
||||
"loadError": "영양소 데이터를 불러오지 못했어요",
|
||||
"sourcePart": "{source} {value} {unit}",
|
||||
"aboveUpperLimit": "성인 하루 상한 섭취량 {value} {unit}을(를) 넘었어요 ({source}).",
|
||||
"aboveUpperLimitSupplemental": "보충제로 섭취한 양이 성인 보충제 기준 하루 상한 섭취량 {value} {unit}을(를) 넘었어요 ({source}).",
|
||||
"upperLimitForm": {
|
||||
"retinol": "이 상한은 기성 비타민 A(레티놀)에만 적용돼요. 베타카로틴에서 온 양이라면 적용되지 않아요.",
|
||||
"folic_acid": "이 상한은 보충제와 강화식품의 엽산에 적용되며, 일반 음식의 엽산염에는 적용되지 않아요.",
|
||||
"magnesium_salts": "이 상한은 보충제의 쉽게 해리되는 마그네슘염에 적용되며, 음식의 마그네슘에는 적용되지 않아요.",
|
||||
"alpha_tocopherol": "이 상한은 알파토코페롤 기준이에요."
|
||||
}
|
||||
},
|
||||
"sparseNote": "여기 수치가 낮은 건 보통 결핍이 아니라 기록이 드물다는 뜻이에요. Apple Health의 하루 합계는 앱이 기록한 양만 반영하고, 전체 식이 섭취량은 아니에요."
|
||||
"sparseNote": "여기 수치가 낮은 건 보통 결핍이 아니라 기록이 드물다는 뜻이에요. Apple Health의 하루 합계는 앱이 기록한 양만 반영하고, 전체 식이 섭취량은 아니에요.",
|
||||
"units": {
|
||||
"IU": "IU"
|
||||
},
|
||||
"sources": {
|
||||
"APPLE_HEALTH": "동기화됨",
|
||||
"MANUAL": "직접 입력",
|
||||
"SUPPLEMENT": "보충제"
|
||||
}
|
||||
},
|
||||
"daily": {
|
||||
"line": {
|
||||
@@ -11880,9 +12408,11 @@
|
||||
"revokeFailed": "링크를 철회하지 못했습니다. 다시 시도하세요.",
|
||||
"errorFailed": "넘겨주기 링크를 만들지 못했습니다.",
|
||||
"errorRosterChanged": "그 사이 기록을 돌보는 사람이 바뀌었습니다. 닫았다가 다시 여세요.",
|
||||
"oidcOnly": "이 서버가 통합 로그인(SSO)만 허용하는 동안에는 프로필을 넘겨줄 수 없습니다.",
|
||||
"oidcOnly": "이 서버에서는 프로필을 넘겨줄 수 없습니다.",
|
||||
"loadError": "넘겨주기 상태를 불러오지 못했습니다.",
|
||||
"entryHint": "{name} 님이 이 기록을 직접 관리할 준비가 되었을 때."
|
||||
"entryHint": "{name} 님이 이 기록을 직접 관리할 준비가 되었을 때.",
|
||||
"methodSso": "{name}님은 링크를 사용할 때 통합 로그인(SSO)으로 로그인합니다.",
|
||||
"methodBoth": "{name}님은 비밀번호를 정하거나 통합 로그인(SSO)으로 로그인할 수 있습니다."
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
@@ -12008,7 +12538,11 @@
|
||||
"customVaccineDelete": "{name} 님이 직접 추가한 백신을 삭제했어요",
|
||||
"lifeEventCreate": "{name} 님이 삶의 사건을 추가했어요",
|
||||
"lifeEventUpdate": "{name} 님이 삶의 사건을 수정했어요",
|
||||
"lifeEventDelete": "{name} 님이 삶의 사건을 삭제했어요"
|
||||
"lifeEventDelete": "{name} 님이 삶의 사건을 삭제했어요",
|
||||
"biomarkerMerge": "{name} 님이 바이오마커를 합쳤어요",
|
||||
"medicationIngredientsUpdate": "{name} 님이 보충제 성분을 수정했어요",
|
||||
"auditCCreate": "{name} 님이 AUDIT-C 문항에 답했어요",
|
||||
"auditCDelete": "{name} 님이 AUDIT-C 기록을 삭제했어요"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
@@ -12227,7 +12761,34 @@
|
||||
"removed": "{name} 연결 해제됨",
|
||||
"linked": "연결됨",
|
||||
"loadNewer": "최근 항목 더 보기",
|
||||
"loadOlder": "이전 항목 더 보기"
|
||||
"loadOlder": "이전 항목 더 보기",
|
||||
"period": {
|
||||
"showing": "{period} 표시 중"
|
||||
}
|
||||
},
|
||||
"related": {
|
||||
"title": "관련 항목",
|
||||
"kind": {
|
||||
"labResult": "검사 결과",
|
||||
"encounter": "진료",
|
||||
"document": "문서",
|
||||
"conditionEpisode": "질환",
|
||||
"medication": "약",
|
||||
"vaccination": "예방접종"
|
||||
},
|
||||
"via": {
|
||||
"encounter": "진료를 통해",
|
||||
"efficacyTarget": "효과 목표를 통해"
|
||||
},
|
||||
"pick": {
|
||||
"conditionEpisode": "질환",
|
||||
"document": "문서",
|
||||
"encounter": "진료",
|
||||
"medication": "약",
|
||||
"vaccination": "예방접종"
|
||||
},
|
||||
"nothingToOffer": "아직 연결할 항목이 없습니다.",
|
||||
"saveError": "연결을 저장하지 못했습니다."
|
||||
}
|
||||
},
|
||||
"bodySites": {
|
||||
@@ -12369,7 +12930,8 @@
|
||||
"scores": "점수",
|
||||
"running": "그날 진행 중",
|
||||
"values": "수치",
|
||||
"happened": "있었던 일"
|
||||
"happened": "있었던 일",
|
||||
"movement": "이동"
|
||||
},
|
||||
"notable": {
|
||||
"extremeHigh": "{since} 이후 가장 높은 하루 값",
|
||||
@@ -12425,7 +12987,18 @@
|
||||
"today": "오늘",
|
||||
"hasEntries": "기록 있음",
|
||||
"dayNSince": "{n}일째, {date}부터",
|
||||
"sinceDate": "{date}부터"
|
||||
"sinceDate": "{date}부터",
|
||||
"movement": {
|
||||
"total": "이동 거리",
|
||||
"driving": "자동차",
|
||||
"train": "기차",
|
||||
"places": "방문한 장소",
|
||||
"away": "집 밖",
|
||||
"travel": "여행일",
|
||||
"travelYes": "예",
|
||||
"kilometres": "{value}km",
|
||||
"miles": "{value}mi"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
"title": "타임라인",
|
||||
@@ -12760,5 +13333,13 @@
|
||||
"lastUpdated": "마지막 업데이트: {date}",
|
||||
"project": "프로젝트",
|
||||
"credits": "크레딧"
|
||||
},
|
||||
"series": {
|
||||
"reference": {
|
||||
"targetUpper": "내 목표 상한",
|
||||
"targetLower": "내 목표 하한",
|
||||
"clinicalUpper": "임상 기준 상한",
|
||||
"clinicalLower": "임상 기준 하한"
|
||||
}
|
||||
}
|
||||
}
|
||||
+622
-41
@@ -51,7 +51,10 @@
|
||||
"durationHoursMinutes": "{hours} godz. {minutes} min",
|
||||
"durationMinutes": "{minutes} min",
|
||||
"durationClockMinutes": "{time} min",
|
||||
"durationClockHours": "{time} godz."
|
||||
"durationClockHours": "{time} godz.",
|
||||
"errorIdLabel": "ID błędu",
|
||||
"errorIdCopy": "Kopiuj ID błędu",
|
||||
"errorIdCopied": "Skopiowano ID błędu"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
@@ -154,6 +157,20 @@
|
||||
"event": {
|
||||
"notFound": "Ten wpis już nie istnieje."
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
"notFound": "Ten lek nie jest już dostępny.",
|
||||
"ingredients": {
|
||||
"invalid": "Sprawdź skład i spróbuj ponownie.",
|
||||
"unitMismatch": "Ta jednostka nie jest dostępna dla tego składnika.",
|
||||
"extract": {
|
||||
"fileType": "Prześlij plik JPEG, PNG, WebP lub PDF.",
|
||||
"fileTooLarge": "Plik jest większy niż 10 MB.",
|
||||
"rateLimited": "Za dużo odczytów etykiet. Spróbuj ponownie za godzinę.",
|
||||
"budgetExceeded": "Dzisiejszy budżet AI został wykorzystany.",
|
||||
"failed": "Nie udało się odczytać etykiety. Spróbuj ostrzejszego zdjęcia."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -367,7 +384,20 @@
|
||||
"notes": "Dodatkowe uwagi"
|
||||
},
|
||||
"courseRange": "Kuracja od {start} do {end}",
|
||||
"courseSince": "Kuracja od {start}"
|
||||
"courseSince": "Kuracja od {start}",
|
||||
"lifePackYears": "Paczkolata",
|
||||
"lifeAuditC": "AUDIT-C (badanie przesiewowe)",
|
||||
"lifeAuditCScore": "{score} z 12 dnia {date}.",
|
||||
"lifeAuditCAbove": "Na lub powyżej progu {cutoff} ({source}).",
|
||||
"lifeAuditCBelow": "Poniżej progu {cutoff} ({source}).",
|
||||
"lifeAuditCNoCutoff": "Bez progu zależnego od płci.",
|
||||
"lifeActivity": "Aktywność fizyczna",
|
||||
"lifeActivityValue": "{minutes} minut w tygodniu, według deklaracji",
|
||||
"lifeSitting": "Siedzenie w dzień roboczy",
|
||||
"lifeSittingValue": "{hours} godz., według deklaracji",
|
||||
"lifeWork": "Praca",
|
||||
"lifeNightWork": "praca w nocy",
|
||||
"lifeExposures": "narażenia: {list}"
|
||||
},
|
||||
"errorBoundary": {
|
||||
"title": "Coś poszło nie tak",
|
||||
@@ -525,7 +555,7 @@
|
||||
"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ąć.",
|
||||
"oidcOnly": "Ten serwer pozwala tylko na logowanie jednokrotne (SSO). Kontynuuj u swojego dostawcy tożsamości.",
|
||||
"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",
|
||||
@@ -542,7 +572,19 @@
|
||||
"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."
|
||||
"errorOffline": "Nie udało się połączyć z serwerem. Sprawdź połączenie i spróbuj ponownie.",
|
||||
"ssoContinue": "Kontynuuj przez {provider}",
|
||||
"ssoStarting": "Przekierowywanie…",
|
||||
"ssoHint": "Od tej pory logujesz się tutaj swoim kontem {provider}.",
|
||||
"ssoDivider": "lub",
|
||||
"ssoFallbackName": "logowanie jednokrotne (SSO)",
|
||||
"errorTitle": "Przejęcie się nie udało",
|
||||
"errorSubjectBound": "To logowanie należy już do innego konta na tym serwerze. Otwórz link ponownie i użyj innego konta u swojego dostawcy tożsamości.",
|
||||
"errorEmailTaken": "Twój dostawca tożsamości przekazał adres e-mail, którego używa już inne konto na tym serwerze. Otwórz link ponownie i użyj innego konta albo zapytaj osobę, która administruje serwerem.",
|
||||
"errorUsernameTaken": "W międzyczasie ktoś zajął tę nazwę użytkownika. Otwórz link ponownie i wybierz inną.",
|
||||
"errorSignedIn": "Po powrocie byłeś zalogowany. Wyloguj się i otwórz link ponownie.",
|
||||
"errorSso": "Logowanie u dostawcy tożsamości się nie powiodło. Otwórz link ponownie, aby spróbować jeszcze raz.",
|
||||
"signInInstead": "Zaloguj się na to konto"
|
||||
},
|
||||
"inviteInvalid": {
|
||||
"title": "Nieprawidłowe zaproszenie",
|
||||
@@ -2399,6 +2441,34 @@
|
||||
"invalidRange": "Ostatni dzień nie może być przed pierwszym.",
|
||||
"generic": "Nie udało się zapisać. Spróbuj ponownie."
|
||||
}
|
||||
},
|
||||
"composition": {
|
||||
"title": "Skład",
|
||||
"empty": "Dodaj, co zawiera jedna tabletka lub kapsułka, aby każda przyjęta dawka liczyła się do twoich mikroskładników.",
|
||||
"perUnitNote": "Na tabletkę lub kapsułkę; każda przyjęta dawka dolicza się do mikroskładników dnia.",
|
||||
"add": "Dodaj",
|
||||
"edit": "Edytuj",
|
||||
"rowHelper": "Co zawiera jedna tabletka lub kapsułka, dla mikroskładników.",
|
||||
"sheetTitle": "Co zawiera jedna jednostka",
|
||||
"sheetDescription": "Wpisz ilości na tabletkę lub kapsułkę tak, jak podano na etykiecie.",
|
||||
"nutrientLabel": "Składnik",
|
||||
"amountLabel": "Ilość",
|
||||
"unitLabel": "Jednostka",
|
||||
"choose": "Wybierz składnik",
|
||||
"addRow": "Dodaj składnik",
|
||||
"removeRow": "Usuń {name}",
|
||||
"editorEmpty": "Brak składników. Dodaj jeden na każdą linię etykiety.",
|
||||
"labelPhoto": "Odczytaj ze zdjęcia etykiety",
|
||||
"labelReading": "Odczytywanie etykiety…",
|
||||
"labelWaiting": "Usługa w tle jeszcze nie zajęła się etykietą.",
|
||||
"labelReviewNote": "Sprawdź każdą linię z etykietą przed zapisaniem.",
|
||||
"lowConfidence": "Ta linia była trudna do odczytania; sprawdź ją z etykietą.",
|
||||
"unmatchedTitle": "Linie bez pasującego składnika, w razie potrzeby wpisz je ręcznie:",
|
||||
"servingNote": "Na etykiecie: {note}. Ilości podano na jedną jednostkę.",
|
||||
"labelError": "Nie udało się odczytać etykiety",
|
||||
"saveError": "Nie udało się zapisać składu",
|
||||
"catalogError": "Nie udało się wczytać listy składników",
|
||||
"invalidRow": "W każdej linii wybierz składnik i wpisz ilość większą od zera."
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -2666,7 +2736,8 @@
|
||||
"bmiEmptyAction": "Otwórz ustawienia konta",
|
||||
"personalRecord": {
|
||||
"badge": "PR",
|
||||
"tooltip": "Rekord osobisty: {value} {unit} dnia {date}"
|
||||
"tooltip": "Rekord osobisty: {value} {unit} dnia {date}",
|
||||
"benchmark": "Typowo w tym wieku: od {low} do {high} {unit} ({source})"
|
||||
},
|
||||
"loadError": "Nie udało się załadować analiz",
|
||||
"heroPersonalBaseline": "Na podstawie ostatnich 90 dni",
|
||||
@@ -4220,6 +4291,21 @@
|
||||
"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ć.",
|
||||
"featureValue": "{field}: {value}"
|
||||
},
|
||||
"patterns": {
|
||||
"title": "Wzorce w twoim nastroju",
|
||||
"basedOn": "Na podstawie {subject} i {comparison} dni. Wzorzec, nie przyczyna.",
|
||||
"previousDayHigher": "Po dniu poniżej twojej normy następny dzień jest zwykle lepszy ({subject} wobec {comparison} po dniu powyżej normy).",
|
||||
"previousDayLower": "Dzień poniżej twojej normy zwykle przenosi się na następny ({subject} wobec {comparison} po dniu powyżej normy).",
|
||||
"weekendHigher": "W weekendy twój nastrój jest zwykle lepszy ({subject} wobec {comparison} w dni robocze).",
|
||||
"weekendLower": "W weekendy twój nastrój jest zwykle gorszy ({subject} wobec {comparison} w dni robocze).",
|
||||
"bestWeekday": "{weekday} to zwykle twój najlepszy dzień ({subject} wobec {comparison} w pozostałe dni).",
|
||||
"notesHigher": "W dni z notatką twój nastrój jest zwykle lepszy ({subject} wobec {comparison} bez notatki).",
|
||||
"notesLower": "W dni z notatką twój nastrój jest zwykle gorszy ({subject} wobec {comparison} bez notatki).",
|
||||
"delayedTagHigher": "Dzień po „{tag}” twój nastrój jest zwykle lepszy ({subject} wobec {comparison} w pozostałe dni).",
|
||||
"delayedTagLower": "Dzień po „{tag}” twój nastrój jest zwykle gorszy ({subject} wobec {comparison} w pozostałe dni).",
|
||||
"multipleEntriesHigher": "W dni z kilkoma wpisami twój nastrój jest zwykle lepszy ({subject} wobec {comparison} przy jednym wpisie).",
|
||||
"multipleEntriesLower": "W dni z kilkoma wpisami twój nastrój jest zwykle gorszy ({subject} wobec {comparison} przy jednym wpisie)."
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
@@ -5459,7 +5545,7 @@
|
||||
"subtitle": "Zdecyduj, które źródło wygrywa, gdy dwa podają tę samą metrykę.",
|
||||
"nutrients": {
|
||||
"title": "Zsynchronizowane spożycie składników",
|
||||
"description": "Dzienne sumy z Apple Health z ostatnich {days} dni.",
|
||||
"description": "Dzienne sumy z ostatnich {days} dni: dane zsynchronizowane z Apple Health oraz przyjęte suplementy.",
|
||||
"empty": "Nie zsynchronizowano jeszcze danych o składnikach odżywczych.",
|
||||
"latest": "{amount} {unit} dnia {date}",
|
||||
"daysWithData": "{count} z {days} dni",
|
||||
@@ -5821,7 +5907,10 @@
|
||||
"remove": "Usuń okres lokalizacji",
|
||||
"removeConfirmTitle": "Usunąć tę podróż?",
|
||||
"removeConfirmBody": "Pomiary z tego okresu pozostają w historii; znika tylko wpis podróży.",
|
||||
"range": "Od {start} do {end}"
|
||||
"range": "Od {start} do {end}",
|
||||
"suggestionsIntro": "Dawarich znalazł dni za granicą, których nie obejmuje jeszcze żaden okres.",
|
||||
"suggestionUse": "Użyj",
|
||||
"suggestionDismiss": "Odrzuć sugestię"
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Uzupełnij minione dni",
|
||||
@@ -6255,7 +6344,8 @@
|
||||
"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",
|
||||
"cancel": "Anuluj"
|
||||
"cancel": "Anuluj",
|
||||
"restingFrugal": "Wstrzymane na oszczędnym poziomie AI: dokumenty są czytane tylko na żądanie."
|
||||
},
|
||||
"centralCodex": {
|
||||
"title": "Użyj wspólnego dostępu AI serwera",
|
||||
@@ -6313,7 +6403,33 @@
|
||||
"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ż"
|
||||
"runtimeTitle": "Sprawdź i odśwież",
|
||||
"usage": {
|
||||
"title": "Użycie AI",
|
||||
"description": "Ile pracy AI wykonuje się dla Ciebie w tle, przy czym Coach działa tak samo na każdym poziomie.",
|
||||
"levels": {
|
||||
"frugal": {
|
||||
"label": "Oszczędny",
|
||||
"hint": "Karty odświeżają się po synchronizacji najwyżej raz dziennie, krótkie podsumowanie dnia, dokumenty są czytane tylko na żądanie."
|
||||
},
|
||||
"standard": {
|
||||
"label": "Standardowy",
|
||||
"hint": "Karty odświeżają się po synchronizacji co kilka godzin, pełne podsumowanie dnia."
|
||||
},
|
||||
"full": {
|
||||
"label": "Pełny",
|
||||
"hint": "Karty odświeżają się po nowych danych nawet co godzinę, jak przed wprowadzeniem tego ustawienia."
|
||||
}
|
||||
},
|
||||
"limited": "Ograniczone przez administratora serwera.",
|
||||
"estimate": "około {tokens} tokenów dziennie, szacunkowo",
|
||||
"measured": "Zmierzono: {tokens} tokenów dziennie w ostatnich {days} dniach, łącznie z Coachem. Wartości dla poziomów są szacunkami.",
|
||||
"noEstimate": "Szacunek dla każdego poziomu pojawi się po trzech dniach korzystania z AI.",
|
||||
"capped": "Wybrano poziom {choice}, na który serwer obecnie nie pozwala, więc obowiązuje {level}. Twój wybór zostaje zapisany.",
|
||||
"followsDefault": "Korzystasz z ustawienia domyślnego serwera ({level}).",
|
||||
"useDefault": "Użyj ustawienia domyślnego serwera ({level})",
|
||||
"saveError": "Nie udało się zapisać poziomu. Spróbuj ponownie."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"units": {
|
||||
@@ -6646,7 +6762,14 @@
|
||||
"docs": "Dokumentacja",
|
||||
"credits": "Autorzy i źródła danych",
|
||||
"linksHeading": "Źródła i dokumentacja",
|
||||
"newerAvailable": "Nowa wersja: {tag}"
|
||||
"newerAvailable": "Nowa wersja: {tag}",
|
||||
"support": {
|
||||
"title": "Pomoc",
|
||||
"description": "Podsumowanie do zgłoszenia błędu, bez imienia, adresu e-mail i wartości zdrowotnych.",
|
||||
"copy": "Kopiuj informacje dla pomocy",
|
||||
"copied": "Skopiowano",
|
||||
"copyFailed": "Nie udało się zebrać informacji dla pomocy."
|
||||
}
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Testuj połączenie",
|
||||
@@ -7133,7 +7256,8 @@
|
||||
"lastReceived": "Ostatnio odebrano {relative}",
|
||||
"lastNewValue": "Ostatnia nowa wartość {relative}",
|
||||
"lastBackground": "Ostatnio w tle {relative}",
|
||||
"never": "Jeszcze nie odebrano"
|
||||
"never": "Jeszcze nie odebrano",
|
||||
"lastBackfill": "Historia uzupełniona {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
@@ -7267,6 +7391,46 @@
|
||||
"failureStreak": "Seria błędów: {count} z {threshold}",
|
||||
"copyFailed": "Nie udało się skopiować. Zaznacz tekst i skopiuj go ręcznie."
|
||||
}
|
||||
},
|
||||
"dawarich": "Dawarich",
|
||||
"dawarichTag": "Ruch",
|
||||
"dawarichDescription": "Połącz swoją instancję Dawarich, aby pobierać dzienny dystans, czas poza domem i dni podróży.",
|
||||
"dawarichUrl": "Adres Dawarich",
|
||||
"dawarichApiKey": "Klucz API",
|
||||
"dawarichApiKeyHelp": "Znajdziesz go w Dawarich w sekcji Konto. HealthLog odczytuje nim tylko wartości dzienne, nigdy współrzędne ani nazwy miejsc.",
|
||||
"dawarichConnect": "Połącz",
|
||||
"dawarichUpdate": "Zapisz zmiany",
|
||||
"dawarichConnected": "Dawarich połączony. Trwa wczytywanie ostatnich 30 dni.",
|
||||
"dawarichSync": "Pobierz teraz",
|
||||
"dawarichSyncQueued": "Ostatnie siedem dni zostanie zaraz pobrane.",
|
||||
"dawarichSyncAlreadyQueued": "Pobieranie już trwa.",
|
||||
"dawarichDisconnect": "Rozłącz",
|
||||
"dawarichDisconnectTitle": "Rozłączyć Dawarich?",
|
||||
"dawarichDisconnectDescription": "Kolejne pobierania zostaną zatrzymane. Zapisane dni pozostaną.",
|
||||
"dawarichDisconnectDelete": "Rozłącz i usuń dni",
|
||||
"dawarichDisconnectDeleteTitle": "Rozłączyć i usunąć wszystkie dni?",
|
||||
"dawarichDisconnectDeleteDescription": "Połączenie i wszystkie zapisane dni ruchu zostaną usunięte. Tego nie można cofnąć.",
|
||||
"dawarichHomePlaceSet": "Dom jest ustawiony.",
|
||||
"dawarichHomePlaceMissing": "Ustaw dom, aby można było mierzyć czas poza domem.",
|
||||
"dawarichHomeCountryIs": "Kraj zamieszkania: {country}.",
|
||||
"dawarichHomeCountryMissing": "Brak kraju zamieszkania, więc brak dni podróży.",
|
||||
"dawarichDaysStored": "Zapisane dni: {count}",
|
||||
"dawarichHomePlace": "Dom",
|
||||
"dawarichHomePlaceChoose": "Wybierz miejsce z Dawarich",
|
||||
"dawarichHomePlaceChange": "Wybierz dom",
|
||||
"dawarichHomeCountry": "Kraj zamieszkania",
|
||||
"dawarichHomeCountryHelp": "Dwuliterowy kod kraju, na przykład DE. Jeśli zostawisz puste, liczy się kraj, w którym spędziłeś najwięcej dni w ostatnim roku.",
|
||||
"dawarichHomeSave": "Zapisz",
|
||||
"dawarichHomeSaved": "Zapisano.",
|
||||
"dawarichErrors": {
|
||||
"invalid": "Ten adres nie działa. Użyj adresu instancji bez danych logowania.",
|
||||
"privateOrigin": "Ta instancja jest w sieci prywatnej. Poproś operatora serwera o jej dopuszczenie.",
|
||||
"credentials": "Dawarich nie zaakceptował klucza API.",
|
||||
"unreachable": "Dawarich jest nieosiągalny. Sprawdź adres i czy instancja działa.",
|
||||
"rateLimited": "Zbyt wiele prób. Odczekaj chwilę i spróbuj ponownie.",
|
||||
"homePlaceUnknown": "Instancja Dawarich nie zna już tego miejsca.",
|
||||
"notConnected": "Dawarich nie jest połączony.",
|
||||
"generic": "Nie udało się. Spróbuj ponownie."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -7343,7 +7507,8 @@
|
||||
"spendToday": "Dzisiaj, według dnia kalendarzowego każdego konta: {total} tokenów, w tym {operator} na kluczu operatora (konta ze zużyciem: {accounts}).",
|
||||
"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."
|
||||
"modelNotServed": "Punkt końcowy nie oferuje już modelu {model}. Wybierz w ustawieniach AI model, który oferuje.",
|
||||
"usageLevels": "Konta według poziomu użycia AI: oszczędny {frugal}, standardowy {standard}, pełny {full}."
|
||||
},
|
||||
"aiQuality": {
|
||||
"title": "Jakość analiz",
|
||||
@@ -7753,7 +7918,8 @@
|
||||
"eventDetails": "Szczegóły zdarzenia",
|
||||
"closeDetails": "Zamknij szczegóły",
|
||||
"processDetail": "Bufor jest ulotny i ograniczony do 500 wpisów.",
|
||||
"cardTitle": "Ostatnie zdarzenia"
|
||||
"cardTitle": "Ostatnie zdarzenia",
|
||||
"filterErrorId": "ID błędu"
|
||||
},
|
||||
"backups": {
|
||||
"title": "Kopie zapasowe",
|
||||
@@ -7840,6 +8006,7 @@
|
||||
"restoreSkippedCoachAttachment": "Powiązanie dokumentu z Coachem",
|
||||
"restoreSkippedCoachReference": "Odwołanie Coacha",
|
||||
"restoreSkippedDocumentConditionLink": "Przypisanie dokumentu",
|
||||
"restoreSkippedRelationLink": "Powiązanie między wpisami",
|
||||
"restoreSkippedExtractedFact": "Fakt z dokumentu",
|
||||
"restoreSkippedFactCommitment": "Zapis faktu",
|
||||
"docsLink": "Dokumentacja",
|
||||
@@ -8016,6 +8183,56 @@
|
||||
"started": "Konserwacja uruchomiona.",
|
||||
"alreadyQueued": "Konserwacja jest już w kolejce lub trwa.",
|
||||
"startFailed": "Nie udało się uruchomić konserwacji."
|
||||
},
|
||||
"pulseDayMean": {
|
||||
"title": "Dzienna średnia tętna",
|
||||
"description": "Jak godziny z niewieloma pomiarami liczą się do tętna danego dnia.",
|
||||
"body": "Dotąd każda godzina z pomiarem liczy się tak samo, więc jeden przypadkowy pomiar może przesunąć cały dzień. Metoda ważona sprawia, że godzina liczy się w pełni od trzech pomiarów, a pojedynczy pomiar w jednej trzeciej. Najpierw uruchom próbę, aby zobaczyć, ile dni by się zmieniło. Zastosowanie przelicza zapisane dni tętna w tle.",
|
||||
"method": {
|
||||
"hourly": "Każda godzina tak samo",
|
||||
"weighted": "Rzadkie godziny ważone"
|
||||
},
|
||||
"loading": "Sprawdzanie dziennej średniej tętna…",
|
||||
"loadError": "Nie udało się odczytać stanu dziennej średniej tętna.",
|
||||
"state": {
|
||||
"idle": "Jeszcze nie było próby.",
|
||||
"queued": "Próba czeka na proces w tle.",
|
||||
"running": "Próba trwa.",
|
||||
"done": "Próba zakończona {at}.",
|
||||
"failed": "Próba została przerwana. Dziennik aplikacji podaje przyczynę; uruchom ją ponownie."
|
||||
},
|
||||
"result": {
|
||||
"days": "Porównane dni",
|
||||
"changed": "Dni ze zmianą powyżej 1 bpm",
|
||||
"max": "Największa zmiana",
|
||||
"maxValue": "{value} bpm",
|
||||
"bucket": "{range} bpm",
|
||||
"account": "Konto {account}",
|
||||
"accountDays": "Zmienia się {changed} z {days} dni"
|
||||
},
|
||||
"appliedAt": "Metoda ważona zastosowana {at}.",
|
||||
"dryRun": "Próba",
|
||||
"apply": "Zastosuj",
|
||||
"confirmTitle": "Zastosować ważoną dzienną średnią tętna?",
|
||||
"confirmBody": "Od tej chwili dni tętna wszystkich kont są liczone z ważonymi rzadkimi godzinami. Zapisane dni tętna są przeliczane w tle, co na dużej instancji może chwilę potrwać.",
|
||||
"confirm": "Zastosuj",
|
||||
"cancel": "Anuluj",
|
||||
"dryRunStarted": "Próba uruchomiona.",
|
||||
"applied": "Metoda ważona zastosowana. Dni tętna są przeliczane.",
|
||||
"appliedNoRebuild": "Metoda ważona zastosowana. Inny przebieg wciąż trwa; zastosuj ponownie, gdy się zakończy, aby przeliczyć dni tętna.",
|
||||
"error": {
|
||||
"busy": "Próba lub przeliczanie wciąż trwa. Spróbuj ponownie, gdy się zakończy.",
|
||||
"dryRunRequired": "Najpierw uruchom próbę. Do zastosowania potrzebna jest próba zakończona w ciągu ostatnich siedmiu dni.",
|
||||
"generic": "Żądanie nie zostało zrealizowane."
|
||||
},
|
||||
"rebuild": {
|
||||
"queued": "Ponowne przeliczenie czeka na proces w tle.",
|
||||
"running": "Dni pulsu są przeliczane.",
|
||||
"done": "Dni pulsu przeliczone {at} dla kont: {accounts}.",
|
||||
"failed": "Przeliczanie zostało przerwane. Przyczynę podaje log aplikacji; uruchom je ponownie."
|
||||
},
|
||||
"rebuildAgain": "Przelicz ponownie",
|
||||
"rebuildStarted": "Przeliczanie rozpoczęte."
|
||||
}
|
||||
},
|
||||
"carrier": "Operator",
|
||||
@@ -8120,7 +8337,25 @@
|
||||
"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"
|
||||
"userColumn": "Użytkownik",
|
||||
"supportReport": {
|
||||
"title": "Raport dla pomocy",
|
||||
"description": "Podsumowanie tej instancji do zgłoszenia błędu, bez adresów e-mail, identyfikatorów i wartości.",
|
||||
"body": "Konta pojawiają się jako „Account 1”, „Account 2” itd. Przeczytaj raport, zanim go udostępnisz.",
|
||||
"copy": "Kopiuj",
|
||||
"copied": "Skopiowano",
|
||||
"download": "Pobierz",
|
||||
"loadError": "Nie udało się utworzyć raportu dla pomocy."
|
||||
},
|
||||
"aiUsage": {
|
||||
"title": "Poziomy użycia AI",
|
||||
"description": "Najwyższy poziom, jaki mogą wybrać konta, i poziom, od którego zaczynają.",
|
||||
"maxLabel": "Najwyższy poziom",
|
||||
"defaultLabel": "Poziom domyślny",
|
||||
"defaultAboveCeiling": "Poziom domyślny nie może być wyższy niż najwyższy poziom.",
|
||||
"gapLabel": "Godziny między odświeżeniami kart po synchronizacji (Standardowy)",
|
||||
"gapHint": "Pełne godziny od 1 do 48. Zostaw puste, aby użyć AI_SYNC_REGEN_GAP_HOURS, w przeciwnym razie 6. Obecnie obowiązuje: {hours} h."
|
||||
}
|
||||
},
|
||||
"achievements": {
|
||||
"title": "Osiągnięcia",
|
||||
@@ -9215,7 +9450,9 @@
|
||||
"periodStartSaved": "Początek miesiączki zapisany",
|
||||
"periodEndSaved": "Koniec miesiączki zapisany",
|
||||
"startedPeriodOnDate": "Miesiączka zaczęła się tego dnia",
|
||||
"endedPeriodOnDate": "Miesiączka skończyła się tego dnia"
|
||||
"endedPeriodOnDate": "Miesiączka skończyła się tego dnia",
|
||||
"startAbsorbed": "Początek miesiączki zapisany. Twój początek z {absorbed} został połączony z {date}.",
|
||||
"startRestored": "Zapisano. Początek z {dates} jest z powrotem."
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Miesiączka prawdopodobnie wkrótce",
|
||||
@@ -9604,12 +9841,16 @@
|
||||
"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."
|
||||
"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.",
|
||||
"alkaline-phosphatase": "Fosfataza alkaliczna (ALP) to enzym występujący głównie w wątrobie, drogach żółciowych i kościach. Rośnie przy utrudnionym odpływie żółci oraz w okresach aktywnej przebudowy kości, na przykład podczas wzrostu u dzieci i młodzieży albo gojenia złamania. Ponieważ uwalniają ją oba narządy, podwyższony wynik czyta się zwykle razem z GGTP i pozostałymi próbami wątrobowymi, aby odróżnić przyczynę wątrobową od kostnej. Należy do standardowych prób wątrobowych.",
|
||||
"bilirubin-total": "Bilirubina całkowita to żółty produkt rozpadu hemoglobiny ze zużytych czerwonych krwinek, mierzony jako suma jej postaci niesprzężonej i sprzężonej. Wątroba ją wychwytuje, sprzęga i wydala z żółcią, więc jej poziom pokazuje, jak działa ta droga. Może rosnąć przy chorobach wątroby, zablokowanych drogach żółciowych lub nasilonym rozpadzie czerwonych krwinek, a nieszkodliwie w zespole Gilberta. Odczytuje się ją razem z pozostałymi próbami wątrobowymi."
|
||||
},
|
||||
"apob": "Apolipoproteina B (ApoB)",
|
||||
"lp-a": "Lipoproteina(a)",
|
||||
"fasting-insulin": "Insulina na czczo",
|
||||
"omega-3-index": "Indeks omega-3"
|
||||
"omega-3-index": "Indeks omega-3",
|
||||
"alkaline-phosphatase": "Fosfataza alkaliczna (ALP)",
|
||||
"bilirubin-total": "Bilirubina całkowita"
|
||||
},
|
||||
"noteLoadFailed": "Nie udało się wczytać notatki; pozostanie niezmieniona po zapisaniu.",
|
||||
"showingLatestOf": "Wyświetlanie najnowszych {shown} z {total} wyników",
|
||||
@@ -9683,6 +9924,54 @@
|
||||
"fromReport": "Zakres z wyniku {range}",
|
||||
"catalogDiffers": "twój zakres {range}",
|
||||
"printed": "wydrukowano w {unit}: {range}"
|
||||
},
|
||||
"match": {
|
||||
"viaAlias": "według nazwy, którą wybrałeś wcześniej",
|
||||
"viaCatalog": "rozpoznano jako popularny skrót",
|
||||
"chooseLabel": "Należy do",
|
||||
"suggestionOption": "{name} ({unit})",
|
||||
"newMarkerOption": "Nowy biomarker",
|
||||
"rememberHint": "Ta nazwa zostanie zapamiętana na następny wynik.",
|
||||
"chooseHint": "Wybierz biomarker, jeśli już go śledzisz."
|
||||
},
|
||||
"aliases": {
|
||||
"title": "Zapisywany też jako",
|
||||
"taken": "{alias} należy już do innego biomarkera.",
|
||||
"saveError": "Nie udało się zapisać nazw. Spróbuj ponownie.",
|
||||
"remove": "Usuń {alias}",
|
||||
"empty": "Dodaj nazwy, które inne laboratoria drukują dla {name}, aby ich wyniki trafiały tutaj.",
|
||||
"placeholder": "np. ALP",
|
||||
"addLabel": "Inna nazwa tego biomarkera",
|
||||
"add": "Dodaj",
|
||||
"alsoWritten": "Zapisywany też jako {aliases}"
|
||||
},
|
||||
"merge": {
|
||||
"open": "Scal",
|
||||
"title": "Scal biomarkery",
|
||||
"description": "Połącz biomarkery, które są tym samym badaniem zapisanym inaczej.",
|
||||
"pickHint": "Zaznacz co najmniej dwa biomarkery.",
|
||||
"keepLabel": "Zachowaj",
|
||||
"keepHint": "Pozostałe zostaną włączone do tego, a ich nazwy staną się jego innymi nazwami.",
|
||||
"showPreview": "Pokaż podgląd",
|
||||
"back": "Wstecz",
|
||||
"confirm": "Scal teraz",
|
||||
"previewSummaryOne": "{count} pomiar przechodzi do {name} ({unit}).",
|
||||
"previewSummaryFew": "{count} pomiary przechodzą do {name} ({unit}).",
|
||||
"previewSummaryOther": "{count} pomiarów przechodzi do {name} ({unit}).",
|
||||
"movesOne": "{count} pomiar, już w tej samej jednostce",
|
||||
"movesFew": "{count} pomiary, już w tej samej jednostce",
|
||||
"movesOther": "{count} pomiarów, już w tej samej jednostce",
|
||||
"convertedOne": "{count} pomiar, przeliczony z {unit} na {targetUnit}",
|
||||
"convertedFew": "{count} pomiary, przeliczone z {unit} na {targetUnit}",
|
||||
"convertedOther": "{count} pomiarów, przeliczonych z {unit} na {targetUnit}",
|
||||
"refused": "Nie można przeliczyć {unit} na {targetUnit}.",
|
||||
"aliasesAdded": "Te nazwy będą prowadzić do zachowanego biomarkera:",
|
||||
"blocked": "Nic nie zostanie scalone, dopóki jednej jednostki nie da się przeliczyć. Pomiń ten biomarker albo zachowaj inny.",
|
||||
"noUndo": "Scalenia nie można cofnąć.",
|
||||
"doneToastOne": "Scalono w {name}, przeniesiono {count} pomiar",
|
||||
"doneToastFew": "Scalono w {name}, przeniesiono {count} pomiary",
|
||||
"doneToastOther": "Scalono w {name}, przeniesiono {count} pomiarów",
|
||||
"error": "Nie udało się scalić biomarkerów. Spróbuj ponownie."
|
||||
}
|
||||
},
|
||||
"modules": {
|
||||
@@ -10644,25 +10933,11 @@
|
||||
},
|
||||
"profileFacts": {
|
||||
"cardTitle": "Kontekst stylu życia",
|
||||
"cardDescription": "Zapisz aktualne informacje, które zmieniają interpretację pomiarów zdrowotnych.",
|
||||
"smokingLabel": "Palenie tytoniu",
|
||||
"alcoholLabel": "Wzorzec spożycia alkoholu",
|
||||
"shiftLabel": "Harmonogram pracy",
|
||||
"notRecorded": "Brak danych",
|
||||
"unreadable": "Nie udało się odczytać zapisanej wartości",
|
||||
"save": "Zapisz",
|
||||
"savedToast": "Zapisano kontekst stylu życia",
|
||||
"savePartialError": "Nie udało się zapisać {kinds}. Spróbuj ponownie.",
|
||||
"saveKindError": "Nie zapisano",
|
||||
"loadError": "Nie udało się wczytać kontekstu stylu życia",
|
||||
"remove": "Usuń: {kind}",
|
||||
"removeShort": "Usuń",
|
||||
"removeConfirmTitle": "Usunąć: {kind}?",
|
||||
"removeConfirmDescription": "Bieżąca wartość zmieni się na brak danych. Historia z datami zostanie zachowana.",
|
||||
"removedToast": "Usunięto {kind} z bieżącego kontekstu stylu życia",
|
||||
"removeError": "Nie udało się usunąć: {kind}",
|
||||
"historyTitle": "Poprzednie wartości",
|
||||
"to": "do",
|
||||
"values": {
|
||||
"SMOKING_STATUS": {
|
||||
"NEVER": "Nigdy nie pali",
|
||||
@@ -10842,6 +11117,235 @@
|
||||
"savedToast": "Informacje na wypadek nagły zapisane",
|
||||
"saveError": "Nie udało się zapisać informacji na wypadek nagły.",
|
||||
"loadError": "Nie udało się wczytać informacji na wypadek nagły."
|
||||
},
|
||||
"life": {
|
||||
"cardTitle": "Warunki życia",
|
||||
"cardDescription": "Palenie, alkohol, aktywność, praca, nawyki i cele, z datą i zawsze dobrowolnie.",
|
||||
"loadError": "Nie udało się wczytać warunków życia.",
|
||||
"noAnswer": "Brak odpowiedzi",
|
||||
"yes": "Tak",
|
||||
"no": "Nie",
|
||||
"validSince": "Obowiązuje od",
|
||||
"month": "Miesiąc",
|
||||
"year": "Rok",
|
||||
"lastConfirmed": "Ostatnio potwierdzono {date}",
|
||||
"notAnswered": "Jeszcze bez odpowiedzi",
|
||||
"unreadable": "Tej odpowiedzi nie da się odczytać na tym serwerze.",
|
||||
"save": "Zapisz sekcję",
|
||||
"savedToast": "Sekcja zapisana",
|
||||
"saveError": "Nie udało się zapisać sekcji.",
|
||||
"review": {
|
||||
"question": "Czy to nadal aktualne? Twoja ostatnia odpowiedź ma ponad rok.",
|
||||
"confirm": "Nadal aktualne",
|
||||
"confirmedToast": "Dziękujemy, oznaczono jako aktualne",
|
||||
"confirmError": "Nie udało się zapisać potwierdzenia."
|
||||
},
|
||||
"ai": {
|
||||
"label": "Udostępnij tę sekcję AI",
|
||||
"shiftLabel": "Udostępnij pracę zmianową AI",
|
||||
"hint": "Po wyłączeniu Coach i podsumowania nigdy nie widzą tych odpowiedzi.",
|
||||
"savedToast": "Udostępnianie AI zaktualizowane",
|
||||
"saveError": "Nie udało się zapisać ustawienia AI."
|
||||
},
|
||||
"sections": {
|
||||
"tobacco": {
|
||||
"title": "Palenie i nikotyna"
|
||||
},
|
||||
"alcohol": {
|
||||
"title": "Alkohol"
|
||||
},
|
||||
"activity": {
|
||||
"title": "Aktywność i siedzenie"
|
||||
},
|
||||
"work": {
|
||||
"title": "Praca i rytm dnia"
|
||||
},
|
||||
"habits": {
|
||||
"title": "Sen, odżywianie i kofeina"
|
||||
},
|
||||
"goals": {
|
||||
"title": "Cele"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"smokingStatus": "Palenie tytoniu",
|
||||
"products": "Używane produkty",
|
||||
"cigarettesPerDay": "Papierosy dziennie",
|
||||
"startYear": "Rok rozpoczęcia",
|
||||
"quitMonth": "Kiedy rzucono",
|
||||
"secondhandSmoke": "Regularne bierne palenie",
|
||||
"moderateDaysPerWeek": "Dni w tygodniu z umiarkowaną lub intensywną aktywnością, np. szybkim marszem",
|
||||
"moderateMinutesPerDay": "Minuty w te dni, średnio",
|
||||
"sittingHoursWorkday": "Godziny siedzenia w dzień roboczy",
|
||||
"shiftSchedule": "Praca zmianowa",
|
||||
"situation": "Sytuacja zawodowa",
|
||||
"physicalNature": "Rodzaj pracy",
|
||||
"nightWork": "Praca w nocy",
|
||||
"exposures": "Narażenia w pracy",
|
||||
"freeWeekdays": "Wolne dni tygodnia",
|
||||
"napRegularly": "Regularna drzemka",
|
||||
"snoringReported": "Inni mówią, że chrapiesz",
|
||||
"dietPattern": "Sposób odżywiania",
|
||||
"dietSpecifics": "Szczegóły",
|
||||
"lateMealsPerWeek": "Posiłki po 21:00 w tygodniu",
|
||||
"fruitVegPortionsPerDay": "Porcje owoców i warzyw dziennie",
|
||||
"caffeineDrinksPerDay": "Napoje z kofeiną dziennie",
|
||||
"latestCaffeine": "Najpóźniejsza kofeina w ciągu dnia",
|
||||
"goals": "Nad czym chcesz pracować",
|
||||
"goalsHint": "Najwyżej trzy."
|
||||
},
|
||||
"values": {
|
||||
"products": {
|
||||
"cigarettes": "Papierosy",
|
||||
"cigars_pipe": "Cygara lub fajka",
|
||||
"e_cigarette": "E-papieros",
|
||||
"heated_tobacco": "Podgrzewany tytoń",
|
||||
"snus_pouches": "Snus lub saszetki nikotynowe"
|
||||
},
|
||||
"situation": {
|
||||
"none": "Bez pracy zarobkowej",
|
||||
"indoor": "Głównie w pomieszczeniach",
|
||||
"outdoor": "Głównie na zewnątrz",
|
||||
"mixed": "W środku i na zewnątrz"
|
||||
},
|
||||
"physicalNature": {
|
||||
"mostly_sitting": "Głównie siedząca",
|
||||
"standing_walking": "Stojąca lub w ruchu",
|
||||
"heavy_physical": "Ciężka praca fizyczna"
|
||||
},
|
||||
"exposures": {
|
||||
"heat": "Upał",
|
||||
"cold": "Zimno",
|
||||
"dust": "Pył",
|
||||
"fumes_solvents": "Opary lub rozpuszczalniki",
|
||||
"noise": "Hałas"
|
||||
},
|
||||
"dietPattern": {
|
||||
"mixed": "Dieta mieszana",
|
||||
"vegetarian": "Wegetariańska",
|
||||
"vegan": "Wegańska",
|
||||
"pescetarian": "Peskatariańska",
|
||||
"other": "Inna"
|
||||
},
|
||||
"dietSpecifics": {
|
||||
"intermittent_fasting": "Post przerywany",
|
||||
"low_carb": "Niskowęglowodanowa",
|
||||
"prescribed_diet": "Dieta zalecona przez lekarza"
|
||||
},
|
||||
"latestCaffeine": {
|
||||
"before_12": "Przed 12:00",
|
||||
"12_15": "12:00 do 15:00",
|
||||
"15_18": "15:00 do 18:00",
|
||||
"after_18": "Po 18:00"
|
||||
},
|
||||
"goals": {
|
||||
"weight": "Waga",
|
||||
"blood_pressure": "Ciśnienie krwi",
|
||||
"blood_sugar": "Cukier we krwi",
|
||||
"sleep": "Sen",
|
||||
"fitness": "Kondycja",
|
||||
"stress_recovery": "Stres i regeneracja",
|
||||
"medication_routine": "Regularne przyjmowanie leków",
|
||||
"quit_smoking": "Rzucenie palenia",
|
||||
"less_alcohol": "Mniej alkoholu"
|
||||
},
|
||||
"weekdays": {
|
||||
"1": "pon.",
|
||||
"2": "wt.",
|
||||
"3": "śr.",
|
||||
"4": "czw.",
|
||||
"5": "pt.",
|
||||
"6": "sob.",
|
||||
"7": "niedz."
|
||||
}
|
||||
},
|
||||
"derived": {
|
||||
"packYears": "{value} paczkolat",
|
||||
"packYearsMissing": "Do paczkolat brakuje liczby papierosów dziennie albo roku rozpoczęcia.",
|
||||
"activityMinutes": "{value} minut w tygodniu.",
|
||||
"meetsWho": "To spełnia zalecenie WHO 150 minut tygodniowo.",
|
||||
"belowWho": "To poniżej zalecenia WHO 150 minut tygodniowo.",
|
||||
"measuredSteps": "Zmierzone kroki mają pierwszeństwo, ta odpowiedź dodaje kontekst.",
|
||||
"freeDaysDefault": "Bez odpowiedzi sobota i niedziela liczą się jako dni wolne."
|
||||
},
|
||||
"auditC": {
|
||||
"intro": "Trzy pytania o alkohol z testu AUDIT WHO. Badanie przesiewowe, nie diagnoza.",
|
||||
"start": "Odpowiedz na pytania",
|
||||
"again": "Odpowiedz ponownie",
|
||||
"offerDue": "Wystarczy zajrzeć raz w roku.",
|
||||
"submit": "Zapisz odpowiedzi",
|
||||
"notValidated": "Dla Twojego języka nie ma jeszcze zwalidowanego tłumaczenia, dlatego pytania są po angielsku.",
|
||||
"savedToast": "Odpowiedzi zapisane",
|
||||
"saveError": "Nie udało się zapisać odpowiedzi.",
|
||||
"result": "{score} z 12 punktów",
|
||||
"above": "Na lub powyżej progu przesiewowego {cutoff}.",
|
||||
"below": "Poniżej progu przesiewowego {cutoff}.",
|
||||
"noCutoff": "Bez płci w profilu pokazujemy tylko sumę.",
|
||||
"source": "Źródło: {source}",
|
||||
"history": "Wcześniejsze wpisy",
|
||||
"remove": "Usuń wpis",
|
||||
"removeConfirmTitle": "Usunąć ten wpis?",
|
||||
"removeConfirmBody": "Wpis zniknie z historii. Tutaj nie da się tego cofnąć.",
|
||||
"removedToast": "Wpis usunięty",
|
||||
"removeError": "Nie udało się usunąć wpisu.",
|
||||
"historyEntry": "{date}: {score} z 12",
|
||||
"legacyLabel": "Wcześniejsza odpowiedź: {value}",
|
||||
"legacyHint": "Z krótkiego pytania o alkohol sprzed tego kwestionariusza. Gdy jest wynik kwestionariusza, Coach przestaje ją czytać.",
|
||||
"legacyRemove": "Usuń wcześniejszą odpowiedź",
|
||||
"legacyRemoveConfirmTitle": "Usunąć wcześniejszą odpowiedź?",
|
||||
"legacyRemoveConfirmBody": "Wcześniejsza odpowiedź zniknie z Twoich danych. Wyniki kwestionariusza zostają.",
|
||||
"legacyRemovedToast": "Wcześniejsza odpowiedź usunięta",
|
||||
"legacyRemoveError": "Nie udało się usunąć wcześniejszej odpowiedzi."
|
||||
},
|
||||
"takeOver": {
|
||||
"conditionsButton": "Przenieś do dziennika stanów",
|
||||
"allergiesButton": "Przenieś do alergii",
|
||||
"conditionsTitle": "Dodaj chorobę przewlekłą",
|
||||
"allergiesTitle": "Dodaj alergię",
|
||||
"description": "Twój tekst pozostaje bez zmian, dopóki sam go nie wyczyścisz.",
|
||||
"freeTextLabel": "Twój tekst",
|
||||
"conditionLabel": "Choroba",
|
||||
"onset": "Od",
|
||||
"save": "Dodaj",
|
||||
"savedToast": "Wpis dodany",
|
||||
"saveError": "Nie udało się dodać wpisu.",
|
||||
"clearQuestion": "Wpis zapisany. Wyczyścić teraz tekst?",
|
||||
"clear": "Wyczyść tekst",
|
||||
"keep": "Zostaw",
|
||||
"clearedToast": "Tekst wyczyszczony",
|
||||
"clearError": "Nie udało się wyczyścić tekstu."
|
||||
},
|
||||
"screening": {
|
||||
"cardTitle": "Wskazówki dotyczące badań przesiewowych",
|
||||
"cardDescription": "Na podstawie warunków życia, z regułą i krajem, z którego pochodzi.",
|
||||
"noReminder": "To tylko wskazówka; przypomnienie ustaw sam, jeśli chcesz.",
|
||||
"source": "Źródło: {source}",
|
||||
"titles": {
|
||||
"lung_ldct": "Niskodawkowa TK w kierunku raka płuca",
|
||||
"aaa_ultrasound": "USG aorty brzusznej"
|
||||
},
|
||||
"status": {
|
||||
"suggested": "Może Cię dotyczyć",
|
||||
"not_eligible": "Nie dotyczy Cię",
|
||||
"unknown": "Za mało informacji"
|
||||
},
|
||||
"reasons": {
|
||||
"never_smoked": "Nigdy nie paliłeś.",
|
||||
"missing_birth_date": "Brakuje daty urodzenia.",
|
||||
"age_out_of_range": "Wiek poza zakresem.",
|
||||
"missing_smoking_status": "Brakuje informacji o paleniu.",
|
||||
"no_cigarettes": "Reguła liczy tylko papierosy.",
|
||||
"missing_smoking_history": "Brakuje liczby papierosów dziennie lub roku rozpoczęcia.",
|
||||
"pack_years_below_15": "Mniej niż 15 paczkolat.",
|
||||
"pack_years_below_20": "Mniej niż 20 paczkolat.",
|
||||
"smoked_years_below_25": "Mniej niż 25 lat palenia.",
|
||||
"missing_quit_date": "Brakuje daty rzucenia.",
|
||||
"quit_over_10_years": "Rzucenie ponad 10 lat temu.",
|
||||
"quit_over_15_years": "Rzucenie ponad 15 lat temu.",
|
||||
"missing_sex": "W profilu brakuje płci.",
|
||||
"sex_not_male": "Reguła dotyczy mężczyzn."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"environment": {
|
||||
@@ -10875,6 +11379,10 @@
|
||||
"mugwort": "bylica",
|
||||
"olive": "oliwka",
|
||||
"ragweed": "ambrozja"
|
||||
},
|
||||
"movement": {
|
||||
"awayFromHome": "Czas poza domem",
|
||||
"distance": "Przebyty dystans"
|
||||
}
|
||||
},
|
||||
"documents": {
|
||||
@@ -11122,7 +11630,8 @@
|
||||
"extractEmpty": "Z tego dokumentu nie udało się odczytać żadnych wartości.",
|
||||
"failUnitRequired": "Wartość liczbowa potrzebuje jednostki, zanim będzie można ją zapisać.",
|
||||
"failUnitMismatch": "Podana jednostka nie zgadza się z zapisaną jednostką tego wskaźnika. Popraw ją i zakończ przegląd ponownie.",
|
||||
"failGeneric": "Nie udało się zapisać tej wartości."
|
||||
"failGeneric": "Nie udało się zapisać tej wartości.",
|
||||
"failUnknownBiomarker": "Wybrany biomarker już nie istnieje. Wybierz inny."
|
||||
},
|
||||
"contentIndex": {
|
||||
"indexPrompt": "Udostępnij treść dokumentów do wyszukiwania.",
|
||||
@@ -11344,7 +11853,9 @@
|
||||
"molybdenum": "Molibden",
|
||||
"iodine": "Jod",
|
||||
"water": "Woda",
|
||||
"caffeine": "Kofeina"
|
||||
"caffeine": "Kofeina",
|
||||
"dha": "DHA (kwas omega-3)",
|
||||
"epa": "EPA (kwas omega-3)"
|
||||
},
|
||||
"page": {
|
||||
"title": "Spożycie składników odżywczych",
|
||||
@@ -11376,9 +11887,26 @@
|
||||
"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"
|
||||
"loadError": "Nie udało się wczytać danych o składnikach odżywczych",
|
||||
"sourcePart": "{source} {value} {unit}",
|
||||
"aboveUpperLimit": "Powyżej tolerowanego górnego poziomu spożycia dla dorosłych {value} {unit} dziennie ({source}).",
|
||||
"aboveUpperLimitSupplemental": "Część z suplementów przekracza tolerowany górny poziom dla dorosłych {value} {unit} dziennie dla suplementów ({source}).",
|
||||
"upperLimitForm": {
|
||||
"retinol": "Poziom dotyczy tylko gotowej witaminy A (retinolu): jeśli ilość pochodzi z beta-karotenu, nie ma zastosowania.",
|
||||
"folic_acid": "Poziom dotyczy kwasu foliowego z suplementów i żywności wzbogacanej, nie folianów ze zwykłego jedzenia.",
|
||||
"magnesium_salts": "Poziom dotyczy łatwo dysocjujących soli magnezu w suplementach, nie magnezu z jedzenia.",
|
||||
"alpha_tocopherol": "Poziom ustalono dla alfa-tokoferolu."
|
||||
}
|
||||
},
|
||||
"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.",
|
||||
"units": {
|
||||
"IU": "j.m."
|
||||
},
|
||||
"sources": {
|
||||
"APPLE_HEALTH": "Zsynchronizowane",
|
||||
"MANUAL": "Wpisane",
|
||||
"SUPPLEMENT": "Suplementy"
|
||||
}
|
||||
},
|
||||
"daily": {
|
||||
"line": {
|
||||
@@ -11880,9 +12408,11 @@
|
||||
"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).",
|
||||
"oidcOnly": "Przekazanie profilu nie jest możliwe na tym serwerze.",
|
||||
"loadError": "Nie udało się wczytać stanu przekazania.",
|
||||
"entryHint": "Gdy {name} chce samodzielnie prowadzić tę kartę."
|
||||
"entryHint": "Gdy {name} chce samodzielnie prowadzić tę kartę.",
|
||||
"methodSso": "{name} loguje się przez logowanie jednokrotne (SSO), gdy użyje linku.",
|
||||
"methodBoth": "{name} może ustawić hasło albo zalogować się przez logowanie jednokrotne (SSO)."
|
||||
}
|
||||
},
|
||||
"toast": {
|
||||
@@ -12008,7 +12538,11 @@
|
||||
"customVaccineDelete": "{name} usunął własną szczepionkę",
|
||||
"lifeEventCreate": "{name} dodał wydarzenie życiowe",
|
||||
"lifeEventUpdate": "{name} zmienił wydarzenie życiowe",
|
||||
"lifeEventDelete": "{name} usunął wydarzenie życiowe"
|
||||
"lifeEventDelete": "{name} usunął wydarzenie życiowe",
|
||||
"biomarkerMerge": "{name} scalił biomarkery",
|
||||
"medicationIngredientsUpdate": "{name} zmienił skład suplementu",
|
||||
"auditCCreate": "{name} wypełnił kwestionariusz AUDIT-C",
|
||||
"auditCDelete": "{name} usunął wpis AUDIT-C"
|
||||
},
|
||||
"section": {
|
||||
"measurements": {
|
||||
@@ -12227,7 +12761,34 @@
|
||||
"removed": "Odłączono: {name}",
|
||||
"linked": "Połączone",
|
||||
"loadNewer": "Wczytaj nowsze",
|
||||
"loadOlder": "Wczytaj starsze"
|
||||
"loadOlder": "Wczytaj starsze",
|
||||
"period": {
|
||||
"showing": "Okres: {period}"
|
||||
}
|
||||
},
|
||||
"related": {
|
||||
"title": "Powiązane",
|
||||
"kind": {
|
||||
"labResult": "Wynik badania",
|
||||
"encounter": "Wizyta",
|
||||
"document": "Dokument",
|
||||
"conditionEpisode": "Choroba",
|
||||
"medication": "Lek",
|
||||
"vaccination": "Szczepienie"
|
||||
},
|
||||
"via": {
|
||||
"encounter": "przez wizytę",
|
||||
"efficacyTarget": "przez cel skuteczności"
|
||||
},
|
||||
"pick": {
|
||||
"conditionEpisode": "Choroby",
|
||||
"document": "Dokumenty",
|
||||
"encounter": "Wizyty",
|
||||
"medication": "Leki",
|
||||
"vaccination": "Szczepienia"
|
||||
},
|
||||
"nothingToOffer": "Na razie nie ma czego powiązać.",
|
||||
"saveError": "Nie udało się zapisać powiązania."
|
||||
}
|
||||
},
|
||||
"bodySites": {
|
||||
@@ -12369,7 +12930,8 @@
|
||||
"scores": "Wyniki",
|
||||
"running": "W toku tego dnia",
|
||||
"values": "Wartości",
|
||||
"happened": "Co się działo"
|
||||
"happened": "Co się działo",
|
||||
"movement": "W drodze"
|
||||
},
|
||||
"notable": {
|
||||
"extremeHigh": "Najwyższa wartość dzienna od {since}",
|
||||
@@ -12425,7 +12987,18 @@
|
||||
"today": "Dzisiaj",
|
||||
"hasEntries": "z wpisami",
|
||||
"dayNSince": "Dzień {n}, od {date}",
|
||||
"sinceDate": "Od {date}"
|
||||
"sinceDate": "Od {date}",
|
||||
"movement": {
|
||||
"total": "Dystans",
|
||||
"driving": "Samochodem",
|
||||
"train": "Pociągiem",
|
||||
"places": "Odwiedzone miejsca",
|
||||
"away": "Poza domem",
|
||||
"travel": "Dzień podróży",
|
||||
"travelYes": "Tak",
|
||||
"kilometres": "{value} km",
|
||||
"miles": "{value} mi"
|
||||
}
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Oś czasu",
|
||||
@@ -12760,5 +13333,13 @@
|
||||
"lastUpdated": "Ostatnia aktualizacja: {date}",
|
||||
"project": "Projekt",
|
||||
"credits": "Podziękowania"
|
||||
},
|
||||
"series": {
|
||||
"reference": {
|
||||
"targetUpper": "Twój cel, górna granica",
|
||||
"targetLower": "Twój cel, dolna granica",
|
||||
"clinicalUpper": "Kliniczna górna granica",
|
||||
"clinicalLower": "Kliniczna dolna granica"
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.42.4",
|
||||
"version": "1.43.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",
|
||||
@@ -103,7 +103,7 @@
|
||||
"undici": "^8.11.2",
|
||||
"web-push": "^3.6.7",
|
||||
"yaml": "^2.9.1",
|
||||
"zod": "^4.4.3",
|
||||
"zod": "^4.6.7",
|
||||
"zod-openapi": "^6.0.2",
|
||||
"zxcvbn-typescript": "^5.0.1"
|
||||
},
|
||||
|
||||
Generated
+22
-27
@@ -58,10 +58,10 @@ importers:
|
||||
version: 3.2.2(react@19.3.0)
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.9.1
|
||||
version: 5.9.1(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(effect@3.20.0)(react-hook-form@7.89.0(@types/react@19.3.0)(react@19.3.0))(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3)
|
||||
version: 5.9.1(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(effect@3.20.0)(react-hook-form@7.89.0(@types/react@19.3.0)(react@19.3.0))(valibot@1.4.2(typescript@6.0.3))(zod@4.6.7)
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.32.1
|
||||
version: 1.32.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.4.3)
|
||||
version: 1.32.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.6.7)
|
||||
'@napi-rs/canvas':
|
||||
specifier: 1.0.10
|
||||
version: 1.0.10
|
||||
@@ -192,11 +192,11 @@ importers:
|
||||
specifier: ^2.9.1
|
||||
version: 2.9.1
|
||||
zod:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
specifier: ^4.6.7
|
||||
version: 4.6.7
|
||||
zod-openapi:
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.2(zod@4.4.3)
|
||||
version: 6.0.2(zod@4.6.7)
|
||||
zxcvbn-typescript:
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1
|
||||
@@ -6918,11 +6918,8 @@ packages:
|
||||
zod@3.25.76:
|
||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||
|
||||
zod@4.4.3:
|
||||
resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==}
|
||||
|
||||
zod@4.6.5:
|
||||
resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==}
|
||||
zod@4.6.7:
|
||||
resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==}
|
||||
|
||||
zxcvbn-typescript@5.0.1:
|
||||
resolution: {integrity: sha512-If2a6jdm278dfWmn6NL7oC1lNYRHOthBw0C78LsKvtAHHFNsMudvFhDn2fF/b74txWtSosJJiuJ7jBPn/xP6HQ==}
|
||||
@@ -7507,7 +7504,7 @@ snapshots:
|
||||
dependencies:
|
||||
hono: 4.13.7
|
||||
|
||||
'@hookform/resolvers@5.9.1(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(effect@3.20.0)(react-hook-form@7.89.0(@types/react@19.3.0)(react@19.3.0))(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3)':
|
||||
'@hookform/resolvers@5.9.1(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(effect@3.20.0)(react-hook-form@7.89.0(@types/react@19.3.0)(react@19.3.0))(valibot@1.4.2(typescript@6.0.3))(zod@4.6.7)':
|
||||
dependencies:
|
||||
'@standard-schema/utils': 0.3.0
|
||||
react-hook-form: 7.89.0(@types/react@19.3.0)(react@19.3.0)
|
||||
@@ -7517,7 +7514,7 @@ snapshots:
|
||||
ajv-formats: 2.1.1(ajv@8.20.0)
|
||||
effect: 3.20.0
|
||||
valibot: 1.4.2(typescript@6.0.3)
|
||||
zod: 4.4.3
|
||||
zod: 4.6.7
|
||||
|
||||
'@humanfs/core@0.19.2':
|
||||
dependencies:
|
||||
@@ -7738,7 +7735,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.4.3)':
|
||||
'@modelcontextprotocol/sdk@1.32.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.6.7)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.17(hono@4.13.7)
|
||||
ajv: 8.20.0
|
||||
@@ -7755,8 +7752,8 @@ snapshots:
|
||||
json-schema-typed: 8.0.2
|
||||
pkce-challenge: 5.0.1
|
||||
raw-body: 3.0.2
|
||||
zod: 4.4.3
|
||||
zod-to-json-schema: 3.25.2(zod@4.4.3)
|
||||
zod: 4.6.7
|
||||
zod-to-json-schema: 3.25.2(zod@4.6.7)
|
||||
optionalDependencies:
|
||||
'@cfworker/json-schema': 4.1.1
|
||||
transitivePeerDependencies:
|
||||
@@ -10899,8 +10896,8 @@ snapshots:
|
||||
'@babel/parser': 7.29.9
|
||||
eslint: 9.39.3(jiti@2.7.0)(supports-color@7.2.0)
|
||||
hermes-parser: 0.25.1
|
||||
zod: 4.6.5
|
||||
zod-validation-error: 4.0.2(zod@4.6.5)
|
||||
zod: 4.6.7
|
||||
zod-validation-error: 4.0.2(zod@4.6.7)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -11760,7 +11757,7 @@ snapshots:
|
||||
tinyglobby: 0.2.17
|
||||
unbash: 4.0.12
|
||||
yaml: 2.9.1
|
||||
zod: 4.6.5
|
||||
zod: 4.6.7
|
||||
|
||||
language-subtag-registry@0.3.23: {}
|
||||
|
||||
@@ -13895,26 +13892,24 @@ snapshots:
|
||||
|
||||
zlibjs@0.3.1: {}
|
||||
|
||||
zod-openapi@6.0.2(zod@4.4.3):
|
||||
zod-openapi@6.0.2(zod@4.6.7):
|
||||
dependencies:
|
||||
zod: 4.4.3
|
||||
zod: 4.6.7
|
||||
|
||||
zod-to-json-schema@3.25.2(zod@3.25.76):
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
zod-to-json-schema@3.25.2(zod@4.4.3):
|
||||
zod-to-json-schema@3.25.2(zod@4.6.7):
|
||||
dependencies:
|
||||
zod: 4.4.3
|
||||
zod: 4.6.7
|
||||
|
||||
zod-validation-error@4.0.2(zod@4.6.5):
|
||||
zod-validation-error@4.0.2(zod@4.6.7):
|
||||
dependencies:
|
||||
zod: 4.6.5
|
||||
zod: 4.6.7
|
||||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zod@4.4.3: {}
|
||||
|
||||
zod@4.6.5: {}
|
||||
zod@4.6.7: {}
|
||||
|
||||
zxcvbn-typescript@5.0.1: {}
|
||||
@@ -0,0 +1,24 @@
|
||||
-- AI usage in three levels (#1217).
|
||||
--
|
||||
-- An account picks frugal, standard or full; the operator sets the highest
|
||||
-- level an account may pick and the level an account without its own choice
|
||||
-- runs at. `ai_sync_regen_gap_hours` is the minimum gap between status-card
|
||||
-- regenerations after a sync at `standard` (NULL = env or 6 h). Both
|
||||
-- instance defaults are STANDARD; an account's stored NULL follows the
|
||||
-- instance default.
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ai_usage_level" AS ENUM ('FRUGAL', 'STANDARD', 'FULL');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "ai_usage_level" "ai_usage_level";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN "ai_usage_max_level" "ai_usage_level" NOT NULL DEFAULT 'STANDARD',
|
||||
ADD COLUMN "ai_usage_default_level" "ai_usage_level" NOT NULL DEFAULT 'STANDARD',
|
||||
ADD COLUMN "ai_sync_regen_gap_hours" INTEGER;
|
||||
|
||||
ALTER TABLE "app_settings"
|
||||
ADD CONSTRAINT "app_settings_ai_sync_regen_gap_hours_check"
|
||||
CHECK ("ai_sync_regen_gap_hours" IS NULL OR "ai_sync_regen_gap_hours" BETWEEN 1 AND 48);
|
||||
@@ -0,0 +1,58 @@
|
||||
-- Biomarker aliases and the merge ledger (D1223).
|
||||
--
|
||||
-- An alias is another spelling of a marker on one account; the fold of the
|
||||
-- name is unique per account, so one spelling resolves to one marker. A merge
|
||||
-- moves the source markers' readings onto the target and deletes the sources;
|
||||
-- `biomarker_merges` keeps one row per source so a stale id can answer with
|
||||
-- the marker it went into.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "biomarker_aliases" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"biomarker_id" TEXT NOT NULL,
|
||||
"name" VARCHAR(120) NOT NULL,
|
||||
"name_folded" VARCHAR(120) NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "biomarker_aliases_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "biomarker_aliases_source_check" CHECK ("source" IN ('MERGE', 'IMPORT_CHOICE', 'MANUAL'))
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "biomarker_merges" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"target_biomarker_id" TEXT NOT NULL,
|
||||
"source_biomarker_id" TEXT NOT NULL,
|
||||
"source_name" VARCHAR(120) NOT NULL,
|
||||
"reading_count" INTEGER NOT NULL,
|
||||
"merged_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "biomarker_merges_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "biomarker_aliases_biomarker_id_idx" ON "biomarker_aliases"("biomarker_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "biomarker_aliases_user_id_name_folded_key" ON "biomarker_aliases"("user_id", "name_folded");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "biomarker_merges_user_id_source_biomarker_id_idx" ON "biomarker_merges"("user_id", "source_biomarker_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "biomarker_merges_target_biomarker_id_idx" ON "biomarker_merges"("target_biomarker_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "biomarker_aliases" ADD CONSTRAINT "biomarker_aliases_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "biomarker_aliases" ADD CONSTRAINT "biomarker_aliases_biomarker_id_fkey" FOREIGN KEY ("biomarker_id") REFERENCES "biomarkers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "biomarker_merges" ADD CONSTRAINT "biomarker_merges_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "biomarker_merges" ADD CONSTRAINT "biomarker_merges_target_biomarker_id_fkey" FOREIGN KEY ("target_biomarker_id") REFERENCES "biomarkers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Supplement composition.
|
||||
--
|
||||
-- What one unit (tablet, capsule) of a medication contains, per nutrient, in
|
||||
-- the nutrient catalogue's canonical unit. One row per (medication, nutrient).
|
||||
-- The entered amount and unit are kept when the input unit differed (IU for
|
||||
-- vitamin D). Taken doses add their share to `nutrient_intake_days` under the
|
||||
-- text source `SUPPLEMENT`, which needs no schema change. Reading a label
|
||||
-- photo is a background document AI run of its own kind; the value is added
|
||||
-- only, nothing here uses it.
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "document_ai_run_kind" ADD VALUE 'SUPPLEMENT_LABEL_EXTRACT';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "medication_ingredients" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"medication_id" TEXT NOT NULL,
|
||||
"nutrient" VARCHAR(40) NOT NULL,
|
||||
"amount_per_unit" DOUBLE PRECISION NOT NULL,
|
||||
"unit" VARCHAR(8) NOT NULL,
|
||||
"entered_amount" DOUBLE PRECISION,
|
||||
"entered_unit" VARCHAR(8),
|
||||
"source" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "medication_ingredients_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "medication_ingredients_amount_per_unit_check" CHECK ("amount_per_unit" > 0),
|
||||
CONSTRAINT "medication_ingredients_source_check" CHECK ("source" IN ('MANUAL', 'LABEL_AI'))
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "medication_ingredients_user_id_idx" ON "medication_ingredients"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "medication_ingredients_medication_id_nutrient_key" ON "medication_ingredients"("medication_id", "nutrient");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "medication_ingredients" ADD CONSTRAINT "medication_ingredients_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "medication_ingredients" ADD CONSTRAINT "medication_ingredients_medication_id_fkey" FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Life circumstances (#614).
|
||||
--
|
||||
-- Six new structured fact kinds (sealed JSON values, `schema: 1`), four new
|
||||
-- AI sections that are never in the column default, AUDIT-C as an assessment
|
||||
-- instrument, and the per-section "still true" confirmations. The new enum
|
||||
-- values are added only; nothing in this migration uses them, so the
|
||||
-- in-transaction ADD VALUE restriction does not apply.
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "health_profile_fact_kind" ADD VALUE 'TOBACCO_DETAIL';
|
||||
ALTER TYPE "health_profile_fact_kind" ADD VALUE 'ACTIVITY_HABIT';
|
||||
ALTER TYPE "health_profile_fact_kind" ADD VALUE 'WORK_CONTEXT';
|
||||
ALTER TYPE "health_profile_fact_kind" ADD VALUE 'FREE_WEEKDAYS';
|
||||
ALTER TYPE "health_profile_fact_kind" ADD VALUE 'HABITS';
|
||||
ALTER TYPE "health_profile_fact_kind" ADD VALUE 'HEALTH_GOALS';
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "health_profile_ai_section" ADD VALUE 'ACTIVITY';
|
||||
ALTER TYPE "health_profile_ai_section" ADD VALUE 'WORK';
|
||||
ALTER TYPE "health_profile_ai_section" ADD VALUE 'HABITS';
|
||||
ALTER TYPE "health_profile_ai_section" ADD VALUE 'GOALS';
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "assessment_instrument" ADD VALUE 'AUDIT_C';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_health_profiles" ADD COLUMN "life_confirmed_json" JSONB;
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Dawarich movement context.
|
||||
--
|
||||
-- Per-account sealed connection (URL, API key, preferences) and one row per
|
||||
-- local day with daily values only: distances in metres, places visited,
|
||||
-- minutes away from home, and the sealed country code of the day. No
|
||||
-- coordinate, place name or visit is stored anywhere.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN "dawarich_url_encrypted" TEXT,
|
||||
ADD COLUMN "dawarich_api_key_encrypted" TEXT,
|
||||
ADD COLUMN "dawarich_prefs_encrypted" BYTEA,
|
||||
ADD COLUMN "dawarich_allow_private_host" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dawarich_days" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"date" VARCHAR(10) NOT NULL,
|
||||
"total_distance_m" INTEGER,
|
||||
"driving_distance_m" INTEGER,
|
||||
"train_distance_m" INTEGER,
|
||||
"places_visited" INTEGER,
|
||||
"away_from_home_minutes" INTEGER,
|
||||
"country_code_encrypted" BYTEA,
|
||||
"is_travel_day" BOOLEAN,
|
||||
"fetched_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "dawarich_days_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "dawarich_days_user_id_date_key" ON "dawarich_days"("user_id", "date");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dawarich_days" ADD CONSTRAINT "dawarich_days_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Pulse day-mean method (#1107 follow-up).
|
||||
--
|
||||
-- The method every pulse day-mean reader uses, switched only by the admin
|
||||
-- apply step after a dry run. `hour_mean_v1` is the behaviour up to v1.42.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN "pulse_day_mean_method" TEXT NOT NULL DEFAULT 'hour_mean_v1',
|
||||
ADD COLUMN "pulse_day_mean_applied_at" TIMESTAMP(3),
|
||||
ADD COLUMN "pulse_day_mean_dry_run_json" JSONB;
|
||||
|
||||
ALTER TABLE "app_settings"
|
||||
ADD CONSTRAINT "app_settings_pulse_day_mean_method_check"
|
||||
CHECK ("pulse_day_mean_method" IN ('hour_mean_v1', 'weighted_hour_mean_v2'));
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Hour buckets for summed intraday types (ios #77).
|
||||
--
|
||||
-- A day of steps, active energy, distance or flights can be built from the
|
||||
-- hourly `stats:<HK>:<YYYY-MM-DD>T<HH>` rows the app posts instead of the fold
|
||||
-- of single samples. The hour increments live beside the running totals;
|
||||
-- `basis` says which source built the profile.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "intraday_cumulative_profiles"
|
||||
ADD COLUMN "hourly_increments" DOUBLE PRECISION[],
|
||||
ADD COLUMN "basis" TEXT NOT NULL DEFAULT 'PER_SAMPLE',
|
||||
ADD COLUMN "bucket_count" INTEGER;
|
||||
|
||||
ALTER TABLE "intraday_cumulative_profiles"
|
||||
ADD CONSTRAINT "intraday_cumulative_profiles_basis_check"
|
||||
CHECK ("basis" IN ('PER_SAMPLE', 'HOUR_BUCKETS'));
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Drop the plaintext location columns (#615 follow-up).
|
||||
--
|
||||
-- Since v1.42 every location is sealed (`users.home_location_encrypted`,
|
||||
-- `environment_contexts.location_encrypted`,
|
||||
-- `environment_travel_locations.location_encrypted`), and the free-text
|
||||
-- encryption backfill moved the plaintext into the sealed copy and cleared
|
||||
-- it. This migration removes the plaintext columns and the partial indexes
|
||||
-- from 0382 that served the backfill.
|
||||
--
|
||||
-- It refuses to run while any row still holds a plaintext location: such a
|
||||
-- row has not been sealed yet, and dropping the column would lose it. Start
|
||||
-- the previous release, let the backfill finish (the three counts below must
|
||||
-- all be 0), then upgrade again. The operator steps, including clearing the
|
||||
-- failed attempt, are in docs/ops/migration-0409-plaintext-location.md.
|
||||
DO $$
|
||||
DECLARE
|
||||
home_rows BIGINT;
|
||||
travel_rows BIGINT;
|
||||
day_rows BIGINT;
|
||||
BEGIN
|
||||
SELECT count(*) INTO home_rows FROM "users" WHERE "home_lat" IS NOT NULL;
|
||||
SELECT count(*) INTO travel_rows FROM "environment_travel_locations" WHERE "lat" IS NOT NULL;
|
||||
SELECT count(*) INTO day_rows FROM "environment_contexts" WHERE "lat" IS NOT NULL;
|
||||
IF home_rows > 0 OR travel_rows > 0 OR day_rows > 0 THEN
|
||||
RAISE EXCEPTION 'HealthLog migration 0409 stopped: % home location(s), % location period(s) and % daily location(s) are still stored in plaintext. Run the previous release until its free-text encryption backfill reports zero remaining rows, then upgrade again. Nothing was changed. Recovery steps: docs/ops/migration-0409-plaintext-location.md', home_rows, travel_rows, day_rows;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX IF EXISTS "users_home_location_plain_idx";
|
||||
DROP INDEX IF EXISTS "environment_travel_locations_plain_location_idx";
|
||||
DROP INDEX IF EXISTS "environment_contexts_plain_location_idx";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "users"
|
||||
DROP COLUMN "home_lat",
|
||||
DROP COLUMN "home_lon",
|
||||
DROP COLUMN "home_label";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "environment_contexts"
|
||||
DROP COLUMN "lat",
|
||||
DROP COLUMN "lon",
|
||||
DROP COLUMN "location_label";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "environment_travel_locations"
|
||||
DROP COLUMN "lat",
|
||||
DROP COLUMN "lon",
|
||||
DROP COLUMN "label";
|
||||
@@ -0,0 +1,151 @@
|
||||
-- Relation links that close the remaining pairs of the relation matrix.
|
||||
--
|
||||
-- Lab result and document, visit and medication, visit and vaccination,
|
||||
-- medication and document, medication and condition (indication). Same shape
|
||||
-- as `encounter_lab_links`: both ends cascade, the pair is unique.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "lab_result_document_links" (
|
||||
"id" TEXT NOT NULL,
|
||||
"lab_result_id" TEXT NOT NULL,
|
||||
"document_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "lab_result_document_links_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "encounter_medication_links" (
|
||||
"id" TEXT NOT NULL,
|
||||
"encounter_id" TEXT NOT NULL,
|
||||
"medication_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "encounter_medication_links_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "encounter_vaccination_links" (
|
||||
"id" TEXT NOT NULL,
|
||||
"encounter_id" TEXT NOT NULL,
|
||||
"vaccination_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "encounter_vaccination_links_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "medication_document_links" (
|
||||
"id" TEXT NOT NULL,
|
||||
"medication_id" TEXT NOT NULL,
|
||||
"document_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "medication_document_links_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "medication_condition_links" (
|
||||
"id" TEXT NOT NULL,
|
||||
"medication_id" TEXT NOT NULL,
|
||||
"episode_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "medication_condition_links_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "lab_result_document_links_document_id_idx" ON "lab_result_document_links"("document_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "lab_result_document_links_user_id_idx" ON "lab_result_document_links"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "lab_result_document_links_lab_result_id_document_id_key" ON "lab_result_document_links"("lab_result_id", "document_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "encounter_medication_links_medication_id_idx" ON "encounter_medication_links"("medication_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "encounter_medication_links_user_id_idx" ON "encounter_medication_links"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "encounter_medication_links_encounter_id_medication_id_key" ON "encounter_medication_links"("encounter_id", "medication_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "encounter_vaccination_links_vaccination_id_idx" ON "encounter_vaccination_links"("vaccination_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "encounter_vaccination_links_user_id_idx" ON "encounter_vaccination_links"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "encounter_vaccination_links_encounter_id_vaccination_id_key" ON "encounter_vaccination_links"("encounter_id", "vaccination_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "medication_document_links_document_id_idx" ON "medication_document_links"("document_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "medication_document_links_user_id_idx" ON "medication_document_links"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "medication_document_links_medication_id_document_id_key" ON "medication_document_links"("medication_id", "document_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "medication_condition_links_episode_id_idx" ON "medication_condition_links"("episode_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "medication_condition_links_user_id_idx" ON "medication_condition_links"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "medication_condition_links_medication_id_episode_id_key" ON "medication_condition_links"("medication_id", "episode_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "lab_result_document_links" ADD CONSTRAINT "lab_result_document_links_lab_result_id_fkey" FOREIGN KEY ("lab_result_id") REFERENCES "lab_results"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "lab_result_document_links" ADD CONSTRAINT "lab_result_document_links_document_id_fkey" FOREIGN KEY ("document_id") REFERENCES "inbound_documents"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "lab_result_document_links" ADD CONSTRAINT "lab_result_document_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_medication_links" ADD CONSTRAINT "encounter_medication_links_encounter_id_fkey" FOREIGN KEY ("encounter_id") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_medication_links" ADD CONSTRAINT "encounter_medication_links_medication_id_fkey" FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_medication_links" ADD CONSTRAINT "encounter_medication_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_vaccination_links" ADD CONSTRAINT "encounter_vaccination_links_encounter_id_fkey" FOREIGN KEY ("encounter_id") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_vaccination_links" ADD CONSTRAINT "encounter_vaccination_links_vaccination_id_fkey" FOREIGN KEY ("vaccination_id") REFERENCES "vaccination_records"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_vaccination_links" ADD CONSTRAINT "encounter_vaccination_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "medication_document_links" ADD CONSTRAINT "medication_document_links_medication_id_fkey" FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "medication_document_links" ADD CONSTRAINT "medication_document_links_document_id_fkey" FOREIGN KEY ("document_id") REFERENCES "inbound_documents"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "medication_document_links" ADD CONSTRAINT "medication_document_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "medication_condition_links" ADD CONSTRAINT "medication_condition_links_medication_id_fkey" FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "medication_condition_links" ADD CONSTRAINT "medication_condition_links_episode_id_fkey" FOREIGN KEY ("episode_id") REFERENCES "illness_episodes"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "medication_condition_links" ADD CONSTRAINT "medication_condition_links_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+388
-56
@@ -94,9 +94,6 @@ 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")
|
||||
// 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
|
||||
@@ -105,9 +102,8 @@ model User {
|
||||
// dated location period instead. Backfill defaults its start to this instant.
|
||||
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.
|
||||
/// under AAD `healthlog/environment-location/v1`. The only copy since
|
||||
/// v1.43: migration 0409 dropped the plaintext columns it replaced.
|
||||
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
|
||||
@@ -237,6 +233,21 @@ model User {
|
||||
nightscoutTokenEncrypted String? @map("nightscout_token_encrypted")
|
||||
nightscoutAllowPrivateHost Boolean @default(false) @map("nightscout_allow_private_host")
|
||||
|
||||
// v1.43 — Dawarich movement context (per-user, encrypted at app level, part
|
||||
// of the `environment` module). Base URL and API key sealed like the
|
||||
// Nightscout pair; `dawarichPrefsEncrypted` seals
|
||||
// `{ homePlaceId, homeCountry, derivedHomeCountry, dismissedSuggestionIds }`.
|
||||
// Same SSRF floor as Nightscout: a private host needs its exact origin in
|
||||
// `DAWARICH_PRIVATE_ORIGINS`; the switch below never grants it.
|
||||
/// Sealed Dawarich base URL.
|
||||
dawarichUrlEncrypted String? @map("dawarich_url_encrypted")
|
||||
/// Sealed Dawarich API key.
|
||||
dawarichApiKeyEncrypted String? @map("dawarich_api_key_encrypted")
|
||||
/// Sealed home place id, home country and dismissed travel suggestions.
|
||||
dawarichPrefsEncrypted Bytes? @map("dawarich_prefs_encrypted")
|
||||
/// @internal: display only; whether the operator list approved the instance at connect time. Egress is re-decided from `DAWARICH_PRIVATE_ORIGINS` on every request, never from this column.
|
||||
dawarichAllowPrivateHost Boolean @default(false) @map("dawarich_allow_private_host")
|
||||
|
||||
// v1.17.0 — Polar OAuth credentials (F4, per-user, encrypted at app level).
|
||||
// Mirrors the WHOOP credential columns: the per-app authorized-user cap on
|
||||
// Polar AccessLink makes a single shared app unworkable for a multi-operator
|
||||
@@ -612,6 +623,14 @@ model User {
|
||||
// `useAuth()` hook.
|
||||
disableCoach Boolean @default(false) @map("disable_coach")
|
||||
|
||||
/// The account's own AI usage level (#1217).
|
||||
/// NULL follows the instance default (`AppSettings.aiUsageDefaultLevel`);
|
||||
/// a stored level above `AppSettings.aiUsageMaxLevel` is kept but capped
|
||||
/// at read time. Written by `PUT /api/auth/me/ai-usage`, resolved in
|
||||
/// `src/lib/ai/usage-level.ts` and published as `ai.usage` on
|
||||
/// `GET /api/auth/me`.
|
||||
aiUsageLevel AiUsageLevel? @map("ai_usage_level")
|
||||
|
||||
// v1.18.0 — module enable/disable foundation.
|
||||
//
|
||||
// A DISABLED allowlist for the optional ("secondary") domains. The
|
||||
@@ -1017,6 +1036,18 @@ model User {
|
||||
// move, a birth, a new job. Shared under the `profile` domain; never sent to
|
||||
// a model.
|
||||
lifeEvents LifeEvent[]
|
||||
// v1.43 — biomarker aliases and the merge ledger (D1223), supplement
|
||||
// composition, Dawarich daily values, and the relation links that close
|
||||
// the remaining pairs of the relation matrix.
|
||||
biomarkerAliases BiomarkerAlias[]
|
||||
biomarkerMerges BiomarkerMerge[]
|
||||
medicationIngredients MedicationIngredient[]
|
||||
dawarichDays DawarichDay[]
|
||||
labResultDocumentLinks LabResultDocumentLink[]
|
||||
encounterMedicationLinks EncounterMedicationLink[]
|
||||
encounterVaccinationLinks EncounterVaccinationLink[]
|
||||
medicationDocumentLinks MedicationDocumentLink[]
|
||||
medicationConditionLinks MedicationConditionLink[]
|
||||
measurementFoldRepair MeasurementFoldRepair?
|
||||
|
||||
// v1.7.0 profile — optional patient-identity fields surfaced on the
|
||||
@@ -1120,20 +1151,9 @@ model EnvironmentContext {
|
||||
/// YYYY-MM-DD anchored to the resolved location's timezone.
|
||||
date String
|
||||
|
||||
/// Coarse resolved location for the day (lat/lon rounded to 1 decimal, about
|
||||
/// 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")
|
||||
/// v1.42 — sealed (lat, lon, label) for the day,
|
||||
/// AAD `healthlog/environment-location/v1`.
|
||||
/// v1.42 — sealed coarse resolved location for the day (lat/lon rounded to
|
||||
/// 1 decimal, about 11 km, + label), AAD `healthlog/environment-location/v1`.
|
||||
/// The only copy since v1.43: migration 0409 dropped the plaintext columns.
|
||||
locationEncrypted Bytes? @map("location_encrypted")
|
||||
source EnvironmentLocationSource
|
||||
|
||||
@@ -1219,8 +1239,7 @@ 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).
|
||||
// NULL is a partial index from migration 0381 (raw SQL only).
|
||||
@@map("environment_contexts")
|
||||
}
|
||||
|
||||
@@ -1241,22 +1260,15 @@ model EnvironmentTravelLocation {
|
||||
startDate String @map("start_date")
|
||||
endDate String @map("end_date")
|
||||
|
||||
/// 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")
|
||||
/// v1.42 — sealed coarse location (lat, lon, label) for the period, AAD
|
||||
/// `healthlog/environment-location/v1`. The only copy since v1.43:
|
||||
/// migration 0409 dropped the plaintext columns.
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -1701,6 +1713,13 @@ enum AssessmentInstrument {
|
||||
GAD7
|
||||
WHO5
|
||||
SCI
|
||||
/// v1.43 (#614) — AUDIT-C, the alcohol section of the life circumstances.
|
||||
/// Stored here for the scoring, encryption and backup it shares with the
|
||||
/// screeners, but never served by `/api/mental-health/assessments` (a
|
||||
/// closed decoder in older app builds): read through
|
||||
/// `GET /api/anamnesis/life-circumstances`, written through
|
||||
/// `POST /api/anamnesis/audit-c`.
|
||||
AUDIT_C
|
||||
|
||||
@@map("assessment_instrument")
|
||||
}
|
||||
@@ -2316,6 +2335,19 @@ model IntradayCumulativeProfile {
|
||||
/// two rows is not a shape; the baseline reader uses this to refuse such
|
||||
/// a day rather than let it drag the median.
|
||||
sampleCount Int @map("sample_count")
|
||||
/// v1.43 (#77) — per-hour increments posted as
|
||||
/// `stats:<HK>:<YYYY-MM-DD>T<HH>` rows (`intraday-hour-buckets.ts`), 24
|
||||
/// slots in canonical units, -1 for an hour not posted. Empty for a day
|
||||
/// the phone never posted hours for.
|
||||
hourlyIncrements Float[] @map("hourly_increments")
|
||||
/// What `hourlyCumulative` was built from: `PER_SAMPLE` (the fold of raw
|
||||
/// rows) or `HOUR_BUCKETS` (the posted hours, once a day holds four).
|
||||
/// CHECK in migration 0408. Read by the profile reader and the drain's
|
||||
/// merge, which leaves a bucket-built day alone.
|
||||
basis String @default("PER_SAMPLE")
|
||||
/// How many hours the phone posted for the day; NULL when it posted none.
|
||||
/// Below four the day keeps the per-sample fold (or no curve yet).
|
||||
bucketCount Int? @map("bucket_count")
|
||||
/// The IANA zone the day was bucketed in. A user who later moves zones
|
||||
/// leaves behind profiles cut on the old clock; carrying the zone lets
|
||||
/// the reader drop the mismatched days instead of silently comparing
|
||||
@@ -2898,6 +2930,12 @@ model Medication {
|
||||
|
||||
schedules MedicationSchedule[]
|
||||
intakeEvents MedicationIntakeEvent[]
|
||||
/// v1.43 — composition per unit (supplements), and the relation links to
|
||||
/// visits, documents and conditions (indication).
|
||||
ingredients MedicationIngredient[]
|
||||
encounterLinks EncounterMedicationLink[]
|
||||
documentLinks MedicationDocumentLink[]
|
||||
conditionLinks MedicationConditionLink[]
|
||||
intakeImportJobs MedicationIntakeImportJob[]
|
||||
phaseConfig ReminderPhaseConfig?
|
||||
telegramMessages TelegramReminderMessage[]
|
||||
@@ -4009,7 +4047,11 @@ model HealthKitTypeSync {
|
||||
/// A `MeasurementType` name for rows the measurement batch writes, or
|
||||
/// `MEDICATION_DOSE` for Apple Health doses the intake bulk route
|
||||
/// receives. Text, not the enum, since v1.42.4 (migration 0401) because a
|
||||
/// dose is not a measurement type.
|
||||
/// dose is not a measurement type. Since v1.43 (ios #77) a
|
||||
/// `backfill:<type>` row records when a `syncTrigger: "backfill"` batch
|
||||
/// last carried the type (its `lastReceivedAt`, every other column NULL),
|
||||
/// so a catch-up leaves the live row of the type untouched; live readers
|
||||
/// skip the prefix (`healthkit-type-sync.ts`).
|
||||
type String
|
||||
/// Last batch that carried this type.
|
||||
lastReceivedAt DateTime @map("last_received_at")
|
||||
@@ -5227,6 +5269,21 @@ model AppSettings {
|
||||
assistantInsightStatusEnabled Boolean @default(true) @map("assistant_insight_status_enabled")
|
||||
assistantDocumentAiEnabled Boolean @default(true) @map("assistant_document_ai_enabled")
|
||||
|
||||
// v1.43 — the operator's AI usage bounds (#1217). `aiUsageMaxLevel` is the
|
||||
// highest level an account may choose, `aiUsageDefaultLevel` the level an
|
||||
// account without its own choice runs at (never above the ceiling),
|
||||
// `aiSyncRegenGapHours` the minimum gap between status-card regenerations
|
||||
// after a sync at `standard` (NULL = env `AI_SYNC_REGEN_GAP_HOURS`, else 6).
|
||||
// Edited through `PUT /api/admin/settings/assistant-flags` (`aiUsage`).
|
||||
/// Instance ceiling for the per-account AI usage level (resolved in
|
||||
/// `src/lib/ai/capabilities/usage.ts`).
|
||||
aiUsageMaxLevel AiUsageLevel @default(STANDARD) @map("ai_usage_max_level")
|
||||
/// Level for accounts without their own choice.
|
||||
aiUsageDefaultLevel AiUsageLevel @default(STANDARD) @map("ai_usage_default_level")
|
||||
/// Sync regeneration gap in hours (1..48); NULL = env or 6. Read by the
|
||||
/// status invalidator at the `standard` level.
|
||||
aiSyncRegenGapHours Int? @map("ai_sync_regen_gap_hours")
|
||||
|
||||
// v1.41 — the operator's reasoning controls. `aiReasoningEnabled` switches
|
||||
// provider-side reasoning off for every surface (the Coach and the three
|
||||
// background jobs that use it); `aiReasoningMaxEffort` caps the level a
|
||||
@@ -5283,6 +5340,20 @@ model AppSettings {
|
||||
/// finding of the earlier engine and so earns the one-time note.
|
||||
correlationSeasonalEngineSince DateTime? @map("correlation_seasonal_engine_since")
|
||||
|
||||
// v1.43 — pulse day-mean method (#1107 follow-up). `hour_mean_v1` weighs
|
||||
// every hour alike; `weighted_hour_mean_v2` weighs an hour by
|
||||
// `min(1, n / 3)`. Switched only by the admin apply step after a dry run;
|
||||
// the dry-run result is the only thing a dry run writes.
|
||||
/// Active pulse day-mean method (CHECK in migration 0407). Readers: every
|
||||
/// process's mirror (`pulse-day-mean-method.ts`) and the rollup tier
|
||||
/// (`storedHourWeightSql`); the admin status route.
|
||||
pulseDayMeanMethod String @default("hour_mean_v1") @map("pulse_day_mean_method")
|
||||
/// When the admin applied the current method; shown on the admin card.
|
||||
pulseDayMeanAppliedAt DateTime? @map("pulse_day_mean_applied_at")
|
||||
/// Last dry-run status and result (account ordinals only, no ids); the apply
|
||||
/// step's precondition and the admin card's result.
|
||||
pulseDayMeanDryRunJson Json? @map("pulse_day_mean_dry_run_json")
|
||||
|
||||
@@map("app_settings")
|
||||
}
|
||||
|
||||
@@ -6250,9 +6321,10 @@ model LabResult {
|
||||
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
biomarker Biomarker? @relation(fields: [biomarkerId], references: [id], onDelete: SetNull)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
biomarker Biomarker? @relation(fields: [biomarkerId], references: [id], onDelete: SetNull)
|
||||
encounterLinks EncounterLabLink[]
|
||||
documentLinks LabResultDocumentLink[]
|
||||
|
||||
@@index([userId, analyte, takenAt])
|
||||
@@index([userId, deletedAt])
|
||||
@@ -6309,6 +6381,10 @@ model Biomarker {
|
||||
/// v1.28 — medications the user has pinned to this biomarker as their
|
||||
/// efficacy target. SetNull on delete keeps the override rows as orphans.
|
||||
efficacyTargets MedicationEfficacyTarget[]
|
||||
/// v1.43 — other spellings that resolve to this marker, and the merges that
|
||||
/// folded other markers into it.
|
||||
aliases BiomarkerAlias[]
|
||||
mergesInto BiomarkerMerge[]
|
||||
|
||||
@@unique([userId, name])
|
||||
@@index([userId])
|
||||
@@ -6962,6 +7038,12 @@ model UserHealthProfile {
|
||||
emergencyContactsEncrypted Bytes? @map("emergency_contacts_encrypted")
|
||||
emergencyImplantsEncrypted Bytes? @map("emergency_implants_encrypted")
|
||||
emergencyNoteEncrypted Bytes? @map("emergency_note_encrypted")
|
||||
/// v1.43 (#614) — "still true" confirmations of the life
|
||||
/// circumstance sections: `{ "<section key>": "<ISO instant>" }`. Written by
|
||||
/// `POST /api/anamnesis/life-circumstances/confirm`, read as
|
||||
/// `lastConfirmedAt` / `reviewDue` by the life-circumstances read route.
|
||||
/// Plaintext: section keys and timestamps only, never an answer.
|
||||
lifeConfirmedJson Json? @map("life_confirmed_json")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -7006,6 +7088,18 @@ enum AdvanceDirectiveStatus {
|
||||
@@map("advance_directive_status")
|
||||
}
|
||||
|
||||
/// v1.43 — how much background AI work an account asks for (#1217): status
|
||||
/// card regeneration cadence, snapshot ceiling, background reasoning, briefing
|
||||
/// length and automatic document reading. Wire form is lower case
|
||||
/// (`frugal` / `standard` / `full`); the order is the ceiling order.
|
||||
enum AiUsageLevel {
|
||||
FRUGAL
|
||||
STANDARD
|
||||
FULL
|
||||
|
||||
@@map("ai_usage_level")
|
||||
}
|
||||
|
||||
/// Sections of the health profile that may enter AI prompt context. This is an
|
||||
/// inclusion list: a section absent from the array is neither decrypted nor
|
||||
/// assembled for Coach or comprehensive briefings.
|
||||
@@ -7018,6 +7112,14 @@ enum HealthProfileAiSection {
|
||||
SMOKING_STATUS
|
||||
ALCOHOL_PATTERN
|
||||
SHIFT_SCHEDULE
|
||||
/// v1.43 (#614) — life-circumstance sections. Never in the column default
|
||||
/// and never listed by `GET /api/coach/about-me` (a closed decoder in older
|
||||
/// app builds); switched through
|
||||
/// `PATCH /api/anamnesis/life-circumstances/ai-sections`.
|
||||
ACTIVITY
|
||||
WORK
|
||||
HABITS
|
||||
GOALS
|
||||
|
||||
@@map("health_profile_ai_section")
|
||||
}
|
||||
@@ -7029,6 +7131,20 @@ enum HealthProfileFactKind {
|
||||
SMOKING_STATUS
|
||||
ALCOHOL_PATTERN
|
||||
SHIFT_SCHEDULE
|
||||
/// v1.43 (#614) — life-circumstance facts. The value is a sealed JSON object
|
||||
/// with `schema: 1`, one Zod schema per kind. `GET /api/anamnesis/facts`
|
||||
/// never returns these (a closed decoder in older app builds); they are read
|
||||
/// through `GET /api/anamnesis/life-circumstances`. Readers: the coach
|
||||
/// self-context (all six, per released AI section), the doctor report and
|
||||
/// FHIR (tobacco, activity, work), the chronotype (free weekdays), the
|
||||
/// environment read (work). `HABITS` and `HEALTH_GOALS` are coach-only by
|
||||
/// design: diet and goals are context for a conversation, not findings.
|
||||
TOBACCO_DETAIL
|
||||
ACTIVITY_HABIT
|
||||
WORK_CONTEXT
|
||||
FREE_WEEKDAYS
|
||||
HABITS
|
||||
HEALTH_GOALS
|
||||
|
||||
@@map("health_profile_fact_kind")
|
||||
}
|
||||
@@ -8175,15 +8291,16 @@ model IllnessEpisode {
|
||||
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
/// Parent condition for a flare/recurrence (self-relation).
|
||||
parent IllnessEpisode? @relation("IllnessFlares", fields: [parentConditionId], references: [id], onDelete: SetNull)
|
||||
flares IllnessEpisode[] @relation("IllnessFlares")
|
||||
dayLogs IllnessDayLog[]
|
||||
documentLinks DocumentConditionLink[]
|
||||
encounterLinks EncounterConditionLink[]
|
||||
parent IllnessEpisode? @relation("IllnessFlares", fields: [parentConditionId], references: [id], onDelete: SetNull)
|
||||
flares IllnessEpisode[] @relation("IllnessFlares")
|
||||
dayLogs IllnessDayLog[]
|
||||
documentLinks DocumentConditionLink[]
|
||||
encounterLinks EncounterConditionLink[]
|
||||
medicationLinks MedicationConditionLink[]
|
||||
/// Symptom events the person filed against this episode while it was open.
|
||||
symptomEvents SymptomEvent[]
|
||||
symptomEvents SymptomEvent[]
|
||||
|
||||
@@index([userId, deletedAt])
|
||||
@@index([userId, onsetAt])
|
||||
@@ -8762,6 +8879,9 @@ model InboundDocument {
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
encounterLinks EncounterDocumentLink[]
|
||||
/// v1.43 — lab results and medications filed against this document.
|
||||
labResultLinks LabResultDocumentLink[]
|
||||
medicationLinks MedicationDocumentLink[]
|
||||
/// Vaccination memberships (m:n via `VaccinationDocumentLink`): a scanned
|
||||
/// Impfpass page carries many doses, and a dose may be evidenced by more
|
||||
/// than one page.
|
||||
@@ -9215,15 +9335,17 @@ model Encounter {
|
||||
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
practitioner Practitioner? @relation(fields: [practitionerId], references: [id], onDelete: SetNull)
|
||||
reminder MeasurementReminder? @relation(fields: [reminderId], references: [id], onDelete: SetNull)
|
||||
documentLinks EncounterDocumentLink[]
|
||||
labLinks EncounterLabLink[]
|
||||
conditionLinks EncounterConditionLink[]
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
practitioner Practitioner? @relation(fields: [practitionerId], references: [id], onDelete: SetNull)
|
||||
reminder MeasurementReminder? @relation(fields: [reminderId], references: [id], onDelete: SetNull)
|
||||
documentLinks EncounterDocumentLink[]
|
||||
labLinks EncounterLabLink[]
|
||||
conditionLinks EncounterConditionLink[]
|
||||
medicationLinks EncounterMedicationLink[]
|
||||
vaccinationLinks EncounterVaccinationLink[]
|
||||
/// Doses administered at this visit. SetNull on the far side: deleting the
|
||||
/// visit leaves the immunization history intact.
|
||||
vaccinations VaccinationRecord[]
|
||||
vaccinations VaccinationRecord[]
|
||||
|
||||
@@index([userId, deletedAt, occurredAt])
|
||||
@@index([userId, status, occurredAt])
|
||||
@@ -9477,12 +9599,13 @@ model VaccinationRecord {
|
||||
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
|
||||
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[]
|
||||
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[]
|
||||
encounterLinks EncounterVaccinationLink[]
|
||||
|
||||
@@index([userId, deletedAt, occurredAt])
|
||||
@@index([userId, antigenSlug, occurredAt])
|
||||
@@ -9635,6 +9758,9 @@ enum DocumentAiRunKind {
|
||||
/// Structured facts read out of a stored document, staged PENDING for the
|
||||
/// review step. The confirm route stays the only write into the record.
|
||||
DOCUMENT_EXTRACT
|
||||
/// v1.43 — a supplement label read into a proposed composition. Nothing is
|
||||
/// stored until the person saves it through the ingredients route.
|
||||
SUPPLEMENT_LABEL_EXTRACT
|
||||
|
||||
@@map("document_ai_run_kind")
|
||||
}
|
||||
@@ -9784,3 +9910,209 @@ model MeasurementFoldRepair {
|
||||
|
||||
@@map("measurement_fold_repairs")
|
||||
}
|
||||
|
||||
// ─── v1.43 contract tables ───────────────────────────────────
|
||||
|
||||
/// v1.43 (D1223) — another spelling of a biomarker on one account: a name a
|
||||
/// merge folded in, a name chosen at import, or one typed on the marker. The
|
||||
/// resolver (`src/lib/labs/biomarker-resolve.ts`) matches a printed name
|
||||
/// against `nameFolded` after the marker name and before the catalogue.
|
||||
model BiomarkerAlias {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
biomarkerId String @map("biomarker_id")
|
||||
/// The alias as written, shown on the marker.
|
||||
name String @db.VarChar(120)
|
||||
/// `foldForMatch(name)`, the match key; unique per account.
|
||||
nameFolded String @map("name_folded") @db.VarChar(120)
|
||||
/// `MERGE`, `IMPORT_CHOICE` or `MANUAL` (CHECK in migration 0403).
|
||||
source String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
biomarker Biomarker @relation(fields: [biomarkerId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, nameFolded])
|
||||
@@index([biomarkerId])
|
||||
@@map("biomarker_aliases")
|
||||
}
|
||||
|
||||
/// v1.43 (D1223) — one source marker folded into a target. The source row is
|
||||
/// gone after the merge; this ledger is what answers `GET /api/biomarkers/{id}`
|
||||
/// for a merged id with `meta.mergedInto`, and what a later split would need.
|
||||
model BiomarkerMerge {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
/// The marker the source was folded into.
|
||||
targetBiomarkerId String @map("target_biomarker_id")
|
||||
/// Id of the deleted source marker (no FK: the row is gone).
|
||||
sourceBiomarkerId String @map("source_biomarker_id")
|
||||
/// The source marker's name at merge time.
|
||||
sourceName String @map("source_name") @db.VarChar(120)
|
||||
/// How many readings moved from the source.
|
||||
readingCount Int @map("reading_count")
|
||||
/// When the merge ran.
|
||||
mergedAt DateTime @default(now()) @map("merged_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
target Biomarker @relation(fields: [targetBiomarkerId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, sourceBiomarkerId])
|
||||
@@index([targetBiomarkerId])
|
||||
@@map("biomarker_merges")
|
||||
}
|
||||
|
||||
/// v1.43 — what one unit (tablet, capsule) of a medication contains, in the
|
||||
/// nutrient catalogue's canonical unit. Every taken dose contributes
|
||||
/// `amountPerUnit × unitsPerDose` to that day's `NutrientIntakeDay` row with
|
||||
/// `source = "SUPPLEMENT"`. A changed formula is a new medication.
|
||||
model MedicationIngredient {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
medicationId String @map("medication_id")
|
||||
/// Nutrient catalogue code (`src/lib/nutrients/catalog.ts`).
|
||||
nutrient String @db.VarChar(40)
|
||||
/// Amount per unit in the canonical unit (> 0, CHECK in migration 0404).
|
||||
amountPerUnit Float @map("amount_per_unit")
|
||||
/// The catalogue's canonical unit for `nutrient`.
|
||||
unit String @db.VarChar(8)
|
||||
/// The amount as entered, when it was entered in another input unit.
|
||||
enteredAmount Float? @map("entered_amount")
|
||||
/// The input unit it was entered in (e.g. `IU`).
|
||||
enteredUnit String? @map("entered_unit") @db.VarChar(8)
|
||||
/// `MANUAL` or `LABEL_AI` (CHECK in migration 0404).
|
||||
source String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([medicationId, nutrient])
|
||||
@@index([userId])
|
||||
@@map("medication_ingredients")
|
||||
}
|
||||
|
||||
/// v1.43 — one local day of Dawarich movement context. Daily values only:
|
||||
/// no coordinate, no place name, no visit is ever stored. The country code is
|
||||
/// sealed under its own label. Part of the `environment` module.
|
||||
model DawarichDay {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
/// Local calendar day (`YYYY-MM-DD`) in the account's timezone.
|
||||
date String @db.VarChar(10)
|
||||
/// Timeline `summary.total_distance`, metres.
|
||||
totalDistanceM Int? @map("total_distance_m")
|
||||
/// Timeline `summary.mode_distances.driving`, metres.
|
||||
drivingDistanceM Int? @map("driving_distance_m")
|
||||
/// Timeline `summary.mode_distances.train`, metres.
|
||||
trainDistanceM Int? @map("train_distance_m")
|
||||
/// Timeline `summary.places_visited`.
|
||||
placesVisited Int? @map("places_visited")
|
||||
/// Minutes away from the home place; NULL = no visit coverage that day.
|
||||
awayFromHomeMinutes Int? @map("away_from_home_minutes")
|
||||
/// Sealed ISO-3166 alpha-2 country of the day (`/residency`).
|
||||
countryCodeEncrypted Bytes? @map("country_code_encrypted")
|
||||
/// Country differs from the home country.
|
||||
isTravelDay Boolean? @map("is_travel_day")
|
||||
/// When the day was last fetched from Dawarich.
|
||||
fetchedAt DateTime @map("fetched_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, date])
|
||||
@@map("dawarich_days")
|
||||
}
|
||||
|
||||
/// v1.43 — lab result ↔ document (the report it was read from). A scan commit
|
||||
/// with a `documentId` files the link itself.
|
||||
model LabResultDocumentLink {
|
||||
id String @id @default(cuid())
|
||||
labResultId String @map("lab_result_id")
|
||||
documentId String @map("document_id")
|
||||
userId String @map("user_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
labResult LabResult @relation(fields: [labResultId], references: [id], onDelete: Cascade)
|
||||
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([labResultId, documentId])
|
||||
@@index([documentId])
|
||||
@@index([userId])
|
||||
@@map("lab_result_document_links")
|
||||
}
|
||||
|
||||
/// v1.43 — visit ↔ medication (prescribed, changed or reviewed at the visit).
|
||||
model EncounterMedicationLink {
|
||||
id String @id @default(cuid())
|
||||
encounterId String @map("encounter_id")
|
||||
medicationId String @map("medication_id")
|
||||
userId String @map("user_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
|
||||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([encounterId, medicationId])
|
||||
@@index([medicationId])
|
||||
@@index([userId])
|
||||
@@map("encounter_medication_links")
|
||||
}
|
||||
|
||||
/// v1.43 — visit ↔ vaccination dose (given at the visit).
|
||||
model EncounterVaccinationLink {
|
||||
id String @id @default(cuid())
|
||||
encounterId String @map("encounter_id")
|
||||
vaccinationId String @map("vaccination_id")
|
||||
userId String @map("user_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
|
||||
vaccination VaccinationRecord @relation(fields: [vaccinationId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([encounterId, vaccinationId])
|
||||
@@index([vaccinationId])
|
||||
@@index([userId])
|
||||
@@map("encounter_vaccination_links")
|
||||
}
|
||||
|
||||
/// v1.43 — medication ↔ document (a prescription, a package insert, a plan).
|
||||
model MedicationDocumentLink {
|
||||
id String @id @default(cuid())
|
||||
medicationId String @map("medication_id")
|
||||
documentId String @map("document_id")
|
||||
userId String @map("user_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||||
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([medicationId, documentId])
|
||||
@@index([documentId])
|
||||
@@index([userId])
|
||||
@@map("medication_document_links")
|
||||
}
|
||||
|
||||
/// v1.43 — medication ↔ condition episode (the indication).
|
||||
model MedicationConditionLink {
|
||||
id String @id @default(cuid())
|
||||
medicationId String @map("medication_id")
|
||||
episodeId String @map("episode_id")
|
||||
userId String @map("user_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||||
episode IllnessEpisode @relation(fields: [episodeId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([medicationId, episodeId])
|
||||
@@index([episodeId])
|
||||
@@index([userId])
|
||||
@@map("medication_condition_links")
|
||||
}
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.42.4";
|
||||
/* @sw-version-fallback */ "v1.43.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/*`
|
||||
|
||||
@@ -142,6 +142,10 @@
|
||||
"name": "DOCUMENT_AI_LIMIT_PER_HOUR",
|
||||
"purpose": "Per-user hourly ceiling for the document-AI actions (read/index, extract, suggest, summarise share one bucket). Default 6, clamped to 1-1000."
|
||||
},
|
||||
{
|
||||
"name": "AI_SYNC_REGEN_GAP_HOURS",
|
||||
"purpose": "Minimum hours between two status-card regenerations after a sync. Default 6, whole hours 1-48; hand entries keep one hour. The admin AI setting wins."
|
||||
},
|
||||
{
|
||||
"name": "DOCUMENT_UPLOAD_LIMIT_PER_HOUR",
|
||||
"purpose": "Hourly upload ceiling per document-upload token (Paperless workflow, import script). Default 120, clamped to 1-1000. Web and phone uploads keep their own bucket."
|
||||
@@ -306,6 +310,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Dawarich on a private network",
|
||||
"description": "Operator grant for Dawarich instances on the operator's own network, read by the movement context of the Environment module. Unset, a Dawarich URL that resolves to a private address is refused at connect time with private_origin_not_approved. Optional; a public instance never needs it.",
|
||||
"required": false,
|
||||
"variables": [
|
||||
{
|
||||
"name": "DAWARICH_PRIVATE_ORIGINS",
|
||||
"purpose": "Comma-separated exact origins (scheme://host[:port], no path, no wildcard, no CIDR), e.g. \"http://dawarich.lan:3000\". Link-local, metadata and unspecified addresses stay refused even when listed; a malformed entry makes the connect form name it."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Document picker",
|
||||
"description": "Operator grant for the document picker, which lets a person search their own Paperless-ngx or Papra instance and import picked documents. Unset, the picker is off. Optional.",
|
||||
|
||||
@@ -306,6 +306,8 @@ async function main() {
|
||||
"googleHealthClientSecretEncrypted",
|
||||
"nightscoutUrlEncrypted",
|
||||
"nightscoutTokenEncrypted",
|
||||
"dawarichUrlEncrypted",
|
||||
"dawarichApiKeyEncrypted",
|
||||
"polarAccessTokenEncrypted",
|
||||
"polarUserIdEncrypted",
|
||||
"polarClientIdEncrypted",
|
||||
@@ -825,8 +827,8 @@ async function main() {
|
||||
|
||||
// ───── 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.
|
||||
// location, all under the environment-location label. NULL on a row
|
||||
// without a location, which the walk skips.
|
||||
results.push(
|
||||
await rotateRegistryColumn("User", "homeLocationEncrypted", {
|
||||
user: prisma.user,
|
||||
@@ -847,6 +849,20 @@ async function main() {
|
||||
} as unknown as CorpusClient),
|
||||
);
|
||||
|
||||
// ───── v1.43 Dawarich context (Bytes, binary2, batched) ─────
|
||||
// The sealed connection preferences on the user row and each stored day's
|
||||
// country code, each under its own label. NULL rows are skipped.
|
||||
results.push(
|
||||
await rotateRegistryColumn("User", "dawarichPrefsEncrypted", {
|
||||
user: prisma.user,
|
||||
} as unknown as CorpusClient),
|
||||
);
|
||||
results.push(
|
||||
await rotateRegistryColumn("DawarichDay", "countryCodeEncrypted", {
|
||||
dawarichDay: prisma.dawarichDay,
|
||||
} 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.
|
||||
|
||||
+19
-29
@@ -31,7 +31,8 @@
|
||||
* - 90 days of mood entries
|
||||
* - Six mental-wellbeing screeners (WHO-5, PHQ-9, GAD-7) spread over the
|
||||
* window, item answers encrypted the way the check-in route writes them
|
||||
* - Vorsorge (preventive-care) reminders — upcoming dental + annual physical
|
||||
* - Vorsorge (preventive-care) reminders — a dental check-up due today and
|
||||
* an annual physical in three months
|
||||
* - Two lab panels of biomarkers across two dates (quantitative with
|
||||
* reference ranges + qualitative "negativ" rows)
|
||||
* - Two illness episodes — a resolved acute cold with a day-by-day symptom
|
||||
@@ -492,24 +493,19 @@ async function seed() {
|
||||
);
|
||||
|
||||
// ── Dashboard showcase layout ─────────────
|
||||
// The demo seeds rich sleep / steps / glucose / VO2max / vorsorge data,
|
||||
// so persist an explicit dashboard layout that surfaces those tiles plus
|
||||
// the daily-verdict hero. The v1.20.0 defaults already turn the data
|
||||
// tiles on, but writing the layout verbatim keeps the public demo's home
|
||||
// page stable regardless of future default tweaks. Derived from
|
||||
// DEFAULT_DASHBOARD_LAYOUT with the seeded-rich strip tiles forced on.
|
||||
const SHOWCASE_TILE_IDS = new Set([
|
||||
"sleep",
|
||||
"steps",
|
||||
"glucose",
|
||||
"vo2Max",
|
||||
"recentWorkouts",
|
||||
]);
|
||||
// One row of six tiles (weight, the two blood-pressure values, resting
|
||||
// heart rate, HRV and mood) and the weight chart right below it, so the
|
||||
// first screen of the public demo shows the summary, the tiles and the
|
||||
// start of a chart together. Every other tile stays one switch away in
|
||||
// the layout settings; its data is seeded either way. Writing the layout
|
||||
// verbatim keeps the demo's home page stable across default tweaks.
|
||||
const SHOWCASE_TILE_IDS = new Set(["weight", "bp", "pulse", "hrv", "mood"]);
|
||||
const demoDashboardLayout = serializeDashboardLayout({
|
||||
...DEFAULT_DASHBOARD_LAYOUT,
|
||||
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) =>
|
||||
SHOWCASE_TILE_IDS.has(w.id) ? { ...w, tileVisible: true } : w,
|
||||
),
|
||||
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets.map((w) => ({
|
||||
...w,
|
||||
tileVisible: SHOWCASE_TILE_IDS.has(w.id),
|
||||
})),
|
||||
});
|
||||
await client.query(
|
||||
`UPDATE users SET dashboard_widgets_json = $1 WHERE id = $2`,
|
||||
@@ -1511,14 +1507,15 @@ async function seed() {
|
||||
|
||||
// ── Vorsorge reminders (preventive care) ──
|
||||
// User-created (origin VORSORGE) checklist reminders with no auto-resolve
|
||||
// measurement target. Both anchored to fire comfortably in the future so
|
||||
// the Vorsorge surface shows sensible upcoming items, not overdue ones.
|
||||
// measurement target. The dental check-up falls due this morning, so the
|
||||
// dashboard's "worth a look" carries it beside tomorrow's appointment; the
|
||||
// annual physical sits comfortably in the future.
|
||||
console.log("Creating Vorsorge reminders...");
|
||||
const dentalNextDue = daysAgo(-45); // ~6 weeks out
|
||||
const dentalNextDue = berlinHmAsUtc(9, 0); // due today, 09:00 local
|
||||
await client.query(
|
||||
`INSERT INTO measurement_reminders (id, user_id, label, interval_days, anchor_date, origin, notify_hour, location, next_due_at, created_at, updated_at)
|
||||
VALUES ($1, $2, 'Dental check-up', 365, $3, 'VORSORGE', 9, 'Dentist', $4, NOW(), NOW())`,
|
||||
[cuid(), userId, daysAgo(320), dentalNextDue],
|
||||
[cuid(), userId, daysAgo(365), dentalNextDue],
|
||||
);
|
||||
const physicalNextDue = daysAgo(-90); // ~3 months out
|
||||
await client.query(
|
||||
@@ -2795,12 +2792,11 @@ async function seed() {
|
||||
}
|
||||
|
||||
// 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).
|
||||
// is sealed the way the settings route writes it.
|
||||
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,
|
||||
@@ -2910,9 +2906,6 @@ async function seed() {
|
||||
cuid(),
|
||||
userId,
|
||||
key,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
away ? sealedHoliday : sealedHome,
|
||||
away ? "TRAVEL" : "HOME",
|
||||
tempMin,
|
||||
@@ -2961,9 +2954,6 @@ async function seed() {
|
||||
"id",
|
||||
"user_id",
|
||||
"date",
|
||||
"lat",
|
||||
"lon",
|
||||
"location_label",
|
||||
"location_encrypted",
|
||||
"source",
|
||||
"temp_min",
|
||||
|
||||
@@ -115,6 +115,11 @@ const EGRESS_SITES: Record<string, EgressSite> = {
|
||||
reason:
|
||||
"Document extraction/indexing; reached from the document routes and the index jobs, which resolve `documentAi` first.",
|
||||
},
|
||||
"lib/medications/ingredients/label-extract.ts": {
|
||||
recheck: false,
|
||||
reason:
|
||||
"Supplement label read (v1.43); reached from the background run worker (`lib/jobs/document-ai-run.ts`), which re-checks `supplementLabel` for the picked provider, for a label the ingredients extract route queued under that capability.",
|
||||
},
|
||||
"lib/labs/ocr-extract.ts": {
|
||||
recheck: false,
|
||||
reason:
|
||||
|
||||
@@ -47,8 +47,6 @@ const SKIP_DIRS = new Set([
|
||||
* runs on the server where the property always exists.
|
||||
*/
|
||||
const SERVER_SIDE_EXEMPT = new Set([
|
||||
// Next middleware — runs on the edge runtime, never in the page.
|
||||
"src/proxy.ts",
|
||||
// The one module allowed to reach for the platform call.
|
||||
"src/lib/random-id.ts",
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Client code holds no threshold or polarity table of its own (v1.43, ios
|
||||
* #116 items 3 and 4).
|
||||
*
|
||||
* Which way a change counts and where a chart's reference lines sit depend on
|
||||
* the person: their own target, their age, their sex, their glucose unit. The
|
||||
* server resolves both (`src/lib/targets/trend-verdict.ts`,
|
||||
* `src/lib/measurements/series-reference-lines.ts`) and publishes them on the
|
||||
* dashboard snapshot (`tiles.directions`), the vitals baseline (`direction`),
|
||||
* the summary and target reads and the series read. A client that keeps its
|
||||
* own copy answers for an imaginary average person: a rising blood pressure
|
||||
* painted as a setback for someone below their target, a 140 mmHg line on a
|
||||
* chart whose person has a band of 115 to 125.
|
||||
*
|
||||
* Four shapes are refused in client code (`src/components`, `src/hooks`, and
|
||||
* every `"use client"` module under `src/app` outside the API):
|
||||
*
|
||||
* 1. a `directionSentiment` given as a string literal: the tile must read
|
||||
* the resolved verdict;
|
||||
* 2. a `ReferenceLine` / `ReferenceArea` drawn at a number literal: a line
|
||||
* has to come from data the server resolved;
|
||||
* 3. a polarity table, i.e. a map or set of metric names to `up-good` /
|
||||
* `up-bad`, or a `…Sentiment(type)` function deciding one;
|
||||
* 4. an import of the server's resolvers into the browser (a type import
|
||||
* is fine).
|
||||
*
|
||||
* The guard cannot pass by reading nothing: the walk carries a floor, every
|
||||
* rule's matcher is proven against a sample of the shape it exists to catch,
|
||||
* every allowlist entry must still match with its exact count, and the rules
|
||||
* must see at least one real match across the tree.
|
||||
*
|
||||
* Its limit: it reads the source, not the running page. A polarity computed
|
||||
* from a variable that happens to hold a literal is not seen; review catches
|
||||
* that.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { stripComments, walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
|
||||
interface ClientFile {
|
||||
rel: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
function isClientModule(rel: string, source: string): boolean {
|
||||
if (rel.startsWith("src/components/") || rel.startsWith("src/hooks/")) {
|
||||
return true;
|
||||
}
|
||||
return /^\s*["']use client["']/.test(stripComments(source).trimStart());
|
||||
}
|
||||
|
||||
function clientFiles(): ClientFile[] {
|
||||
const roots: Array<{ dir: string; floor: number }> = [
|
||||
{ dir: "src/components", floor: 900 },
|
||||
{ dir: "src/hooks", floor: 50 },
|
||||
{ dir: "src/app", floor: 900 },
|
||||
];
|
||||
const out: ClientFile[] = [];
|
||||
for (const { dir, floor } of roots) {
|
||||
for (const file of walkSourceFiles(join(ROOT, dir), { floor })) {
|
||||
const rel = `${dir}/${file}`;
|
||||
if (rel.startsWith("src/app/api/")) continue;
|
||||
if (rel.includes("/__tests__/") || /\.test\.tsx?$/.test(rel)) continue;
|
||||
const source = readFileSync(join(ROOT, rel), "utf8");
|
||||
if (!isClientModule(rel, source)) continue;
|
||||
out.push({ rel, source });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parse(name: string, source: string): ts.SourceFile {
|
||||
return ts.createSourceFile(
|
||||
name,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
name.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
||||
);
|
||||
}
|
||||
|
||||
/** The literal text a JSX attribute initialiser carries, if it is one. */
|
||||
function literalOf(init: ts.JsxAttributeValue | undefined): string | null {
|
||||
if (!init) return null;
|
||||
if (ts.isStringLiteral(init)) return init.text;
|
||||
if (ts.isJsxExpression(init) && init.expression) {
|
||||
const e = init.expression;
|
||||
if (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) {
|
||||
return e.text;
|
||||
}
|
||||
if (ts.isNumericLiteral(e)) return e.text;
|
||||
if (
|
||||
ts.isPrefixUnaryExpression(e) &&
|
||||
e.operator === ts.SyntaxKind.MinusToken &&
|
||||
ts.isNumericLiteral(e.operand)
|
||||
) {
|
||||
return `-${e.operand.text}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function jsxAttributes(
|
||||
file: ts.SourceFile,
|
||||
visit: (
|
||||
tag: string,
|
||||
name: string,
|
||||
init: ts.JsxAttributeValue | undefined,
|
||||
node: ts.Node,
|
||||
) => void,
|
||||
): void {
|
||||
const walk = (node: ts.Node) => {
|
||||
if (ts.isJsxSelfClosingElement(node) || ts.isJsxOpeningElement(node)) {
|
||||
const tag = node.tagName.getText(file);
|
||||
for (const attr of node.attributes.properties) {
|
||||
if (!ts.isJsxAttribute(attr)) continue;
|
||||
visit(tag, attr.name.getText(file), attr.initializer, attr);
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, walk);
|
||||
};
|
||||
walk(file);
|
||||
}
|
||||
|
||||
function lineOf(file: ts.SourceFile, node: ts.Node): number {
|
||||
return file.getLineAndCharacterOfPosition(node.getStart(file)).line + 1;
|
||||
}
|
||||
|
||||
/** Rule 1 — `directionSentiment` given as a literal. */
|
||||
function literalDirections(name: string, source: string): string[] {
|
||||
const file = parse(name, source);
|
||||
const hits: string[] = [];
|
||||
jsxAttributes(file, (tag, attr, init, node) => {
|
||||
if (attr !== "directionSentiment") return;
|
||||
const literal = literalOf(init);
|
||||
if (literal !== null) {
|
||||
hits.push(
|
||||
`${name}:${lineOf(file, node)}: <${tag} directionSentiment="${literal}">`,
|
||||
);
|
||||
}
|
||||
});
|
||||
return hits;
|
||||
}
|
||||
|
||||
const LINE_TAGS = new Set(["ReferenceLine", "ReferenceArea"]);
|
||||
const LINE_ATTRS = new Set(["y", "y1", "y2"]);
|
||||
|
||||
/** Rule 2 — a chart reference drawn at a number literal. */
|
||||
function literalLines(name: string, source: string): string[] {
|
||||
const file = parse(name, source);
|
||||
const hits: string[] = [];
|
||||
jsxAttributes(file, (tag, attr, init, node) => {
|
||||
if (!LINE_TAGS.has(tag) || !LINE_ATTRS.has(attr)) return;
|
||||
const literal = literalOf(init);
|
||||
if (literal !== null && /^-?\d/.test(literal)) {
|
||||
hits.push(`${name}:${lineOf(file, node)}: <${tag} ${attr}={${literal}}>`);
|
||||
}
|
||||
});
|
||||
return hits;
|
||||
}
|
||||
|
||||
/** Rule 3 — a polarity table: line-shaped, over comment-stripped code. */
|
||||
const POLARITY_PATTERNS: RegExp[] = [
|
||||
// `RESTING_HEART_RATE: "up-bad"` / `["HRV", "up-good"]`
|
||||
/\b[A-Z][A-Z0-9_]{2,}["']?\s*[:,]\s*["']up-(good|bad)["']/,
|
||||
// `const UP_BAD_VITALS = new Set([...])`
|
||||
/\b(UP_GOOD|UP_BAD)_[A-Z0-9_]+\s*=/,
|
||||
// `function vitalSentiment(type: string)`
|
||||
/function\s+\w*Sentiment\s*\(\s*(type|metric|kind)\b/,
|
||||
];
|
||||
const POLARITY_SAMPLES = [
|
||||
'const POLARITY = { RESTING_HEART_RATE: "up-bad" };',
|
||||
'new Map([["HEART_RATE_VARIABILITY", "up-good"]])',
|
||||
"const UP_BAD_VITALS = new Set(['RESTING_HEART_RATE']);",
|
||||
"function vitalSentiment(type: string): TrendDirectionSentiment {",
|
||||
];
|
||||
|
||||
function polarityTables(name: string, source: string): string[] {
|
||||
const hits: string[] = [];
|
||||
stripComments(source)
|
||||
.split("\n")
|
||||
.forEach((line, index) => {
|
||||
if (POLARITY_PATTERNS.some((p) => p.test(line))) {
|
||||
hits.push(`${name}:${index + 1}: ${line.trim()}`);
|
||||
}
|
||||
});
|
||||
return hits;
|
||||
}
|
||||
|
||||
/** Rule 4 — the server's resolvers, imported into the browser. */
|
||||
const SERVER_RESOLVERS = [
|
||||
"@/lib/targets/trend-verdict",
|
||||
"@/lib/measurements/series-reference-lines",
|
||||
"@/lib/measurements/series-change",
|
||||
"@/lib/personal-records/record-benchmark",
|
||||
"@/lib/insights/mood-pattern-cards",
|
||||
];
|
||||
|
||||
function resolverImports(name: string, source: string): string[] {
|
||||
const file = parse(name, source);
|
||||
const hits: string[] = [];
|
||||
for (const statement of file.statements) {
|
||||
if (!ts.isImportDeclaration(statement)) continue;
|
||||
const from = (statement.moduleSpecifier as ts.StringLiteral).text;
|
||||
if (!SERVER_RESOLVERS.includes(from)) continue;
|
||||
if (statement.importClause?.isTypeOnly) continue;
|
||||
hits.push(`${name}:${lineOf(file, statement)}: import from "${from}"`);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
interface Rule {
|
||||
name: string;
|
||||
find: (name: string, source: string) => string[];
|
||||
/** Sources that must produce at least one hit, proving the matcher. */
|
||||
samples: Array<{ name: string; source: string }>;
|
||||
/** File → exact hit count it may have, with the reason. */
|
||||
allow: Record<string, { count: number; reason: string }>;
|
||||
}
|
||||
|
||||
const RULES: Rule[] = [
|
||||
{
|
||||
name: "a literal directionSentiment",
|
||||
find: literalDirections,
|
||||
samples: [
|
||||
{
|
||||
name: "sample.tsx",
|
||||
source: 'const a = <TrendCard directionSentiment="up-bad" />;',
|
||||
},
|
||||
{
|
||||
name: "sample.tsx",
|
||||
source: 'const a = <TrendCard directionSentiment={"up-good"} />;',
|
||||
},
|
||||
],
|
||||
allow: {
|
||||
"src/components/insights/derived/vitals-dashboard.tsx": {
|
||||
count: 6,
|
||||
reason:
|
||||
"the six derived re-frame tiles (same-time steps, six-minute walk, fitness age, vascular age, HRV balance, BMI) colour a figure whose good direction is its definition (a longer walk, a younger vascular age, no judgement on BMI), not a person's target; the per-vital baseline tile reads the server's `direction`",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "a reference line at a literal",
|
||||
find: literalLines,
|
||||
samples: [
|
||||
{
|
||||
name: "sample.tsx",
|
||||
source: 'const a = <ReferenceLine y={140} stroke="red" />;',
|
||||
},
|
||||
{
|
||||
name: "sample.tsx",
|
||||
source: "const a = <ReferenceArea y1={70} y2={180} />;",
|
||||
},
|
||||
],
|
||||
allow: {
|
||||
"src/components/charts/medication-compliance-chart.tsx": {
|
||||
count: 2,
|
||||
reason:
|
||||
"the opt-in adherence overlay: an 80 % minimum and the 100 % goal are properties of a share of doses, the same for every person, not a clinical threshold the person's profile moves",
|
||||
},
|
||||
"src/components/insights/mood/mood-time-of-day-chart.tsx": {
|
||||
count: 1,
|
||||
reason:
|
||||
"the 3.5 line is the green edge of the 1 to 5 mood scale itself (`MOOD_GREEN_MIN`), the same for every person",
|
||||
},
|
||||
"src/components/insights/mood/mood-weekday-chart.tsx": {
|
||||
count: 1,
|
||||
reason:
|
||||
"the 3.5 line is the green edge of the 1 to 5 mood scale itself (`MOOD_GREEN_MIN`), the same for every person",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "a polarity table",
|
||||
find: polarityTables,
|
||||
samples: POLARITY_SAMPLES.map((source) => ({ name: "sample.ts", source })),
|
||||
allow: {},
|
||||
},
|
||||
{
|
||||
name: "a server resolver imported into the browser",
|
||||
find: resolverImports,
|
||||
samples: [
|
||||
{
|
||||
name: "sample.ts",
|
||||
source:
|
||||
'import { resolveTrendVerdict } from "@/lib/targets/trend-verdict";',
|
||||
},
|
||||
],
|
||||
allow: {},
|
||||
},
|
||||
];
|
||||
|
||||
const FILES = clientFiles();
|
||||
|
||||
function hitsFor(rule: Rule): Map<string, string[]> {
|
||||
const out = new Map<string, string[]>();
|
||||
for (const { rel, source } of FILES) {
|
||||
const hits = rule.find(rel, source);
|
||||
if (hits.length > 0) out.set(rel, hits);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("client threshold literal guard", () => {
|
||||
it("walks a client tree of the size it expects", () => {
|
||||
expect(FILES.length).toBeGreaterThan(700);
|
||||
});
|
||||
|
||||
for (const rule of RULES) {
|
||||
describe(rule.name, () => {
|
||||
it("still catches the shape it exists to catch", () => {
|
||||
for (const sample of rule.samples) {
|
||||
expect(
|
||||
rule.find(sample.name, sample.source).length,
|
||||
`no hit on: ${sample.source}`,
|
||||
).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("finds none outside its allowlist, and the allowlist exactly", () => {
|
||||
const hits = hitsFor(rule);
|
||||
const offenders = [...hits.entries()]
|
||||
.filter(([rel]) => !(rel in rule.allow))
|
||||
.flatMap(([, lines]) => lines);
|
||||
expect(
|
||||
offenders,
|
||||
"client code decides a threshold or a polarity itself. Read the " +
|
||||
"server's resolved field instead (`tiles.directions`, `direction`, " +
|
||||
"`referenceLines`), or allowlist the file with its reason.",
|
||||
).toEqual([]);
|
||||
for (const [rel, { count }] of Object.entries(rule.allow)) {
|
||||
expect(
|
||||
hits.get(rel)?.length ?? 0,
|
||||
`${rel} is allowlisted for exactly ${count} hits`,
|
||||
).toBe(count);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it("reads a non-empty set of matches across the tree", () => {
|
||||
const total = RULES.reduce(
|
||||
(sum, rule) =>
|
||||
sum + [...hitsFor(rule).values()].reduce((n, l) => n + l.length, 0),
|
||||
0,
|
||||
);
|
||||
expect(total).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* The routes that publish a contract and do not yet carry its effect.
|
||||
*
|
||||
* v1.43 fixed its API contract before the features behind it were built: the
|
||||
* routes exist, authenticate, gate and validate as documented, and answer
|
||||
* `501 not_implemented` until their feature lands (the helper was removed
|
||||
* with the last stub; bring it back from history with the next one). That is safe only while nothing reaches them, and nothing
|
||||
* should: every client path sits behind a `features` flag that stays `false`.
|
||||
*
|
||||
* This list is the debt register. A feature that lands removes its route here
|
||||
* in the same change; a new stub has to be named here to exist. The release is
|
||||
* not ready to tag while the list is not empty, which the last case below
|
||||
* makes impossible to miss: it states the count it was written against, so
|
||||
* the change that empties the list also has to touch this file.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const API_ROOT = join(process.cwd(), "src", "app", "api");
|
||||
|
||||
/** Route file (under `src/app/api`) to the operations it still stubs. */
|
||||
const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {};
|
||||
|
||||
/** The size of the register when it was written; see the header. */
|
||||
const REGISTER_SIZE = 0;
|
||||
|
||||
function routeFiles(): string[] {
|
||||
return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] })
|
||||
.filter((rel) => rel.endsWith("route.ts"))
|
||||
.filter((rel) => !rel.includes("__tests__"));
|
||||
}
|
||||
|
||||
describe("contract stubs answering 501", () => {
|
||||
const routes = routeFiles();
|
||||
const found = routes
|
||||
.filter((rel) =>
|
||||
/\bnotImplemented\s*\(/.test(readFileSync(join(API_ROOT, rel), "utf8")),
|
||||
)
|
||||
.sort();
|
||||
|
||||
it("sweeps the whole route tree", () => {
|
||||
// The register can and should reach zero; the tree it is checked against
|
||||
// cannot. A matcher that read nothing would make the register look clean.
|
||||
expect(routes.length).toBeGreaterThan(400);
|
||||
});
|
||||
|
||||
it("are exactly the registered ones", () => {
|
||||
expect(found).toEqual(Object.keys(CONTRACT_STUBS).sort());
|
||||
});
|
||||
|
||||
it("each still names the operations it was registered with", () => {
|
||||
for (const [rel, operations] of Object.entries(CONTRACT_STUBS)) {
|
||||
const source = readFileSync(join(API_ROOT, rel), "utf8");
|
||||
for (const operation of operations) {
|
||||
expect(source, `${rel} no longer stubs ${operation}`).toContain(
|
||||
`notImplemented(${operation.endsWith(".") ? "`" : '"'}${operation}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("authenticate before they answer", () => {
|
||||
for (const rel of found) {
|
||||
const source = readFileSync(join(API_ROOT, rel), "utf8");
|
||||
expect(
|
||||
/\b(requireAuth|requireRecordAuth|requireAdmin|claimGate)\s*\(/.test(
|
||||
source,
|
||||
),
|
||||
`${rel} answers 501 without authenticating first`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("are still the register the release was planned with", () => {
|
||||
// Empty before the release is tagged: set REGISTER_SIZE to 0 with the
|
||||
// change that fills the last route.
|
||||
expect(Object.keys(CONTRACT_STUBS)).toHaveLength(REGISTER_SIZE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Every ISO instant under `src/` goes through `isoDateTime()`.
|
||||
*
|
||||
* Since v1.43 a request instant must carry seconds, and the refusal is counted
|
||||
* (`validation.datetime.no_seconds`) so a client on a minute-precision encoder
|
||||
* shows up in the logs. Both live in `src/lib/validations/datetime.ts`. A
|
||||
* schema that calls `z.iso.datetime(` or `z.string().datetime(` directly gets
|
||||
* zod's default message and is invisible to the counter, so a direct call
|
||||
* anywhere else is refused here.
|
||||
*
|
||||
* The matcher tolerates whitespace and line breaks between `z`, `.iso` /
|
||||
* `.string()` and `.datetime(`, because prettier splits long chains, and it
|
||||
* also refuses a bare `.datetime(` chained off anything, so an alias such as
|
||||
* `const s = z.string(); s.datetime()` cannot route around it. Comments are
|
||||
* stripped first so prose about the old form does not trip it.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
const HELPER = "lib/validations/datetime.ts";
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
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"));
|
||||
}
|
||||
|
||||
function stripComments(text: string): string {
|
||||
return text
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|[^:"'`])\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
const DIRECT_DATETIME = /\.\s*datetime\s*\(/g;
|
||||
const HELPER_CALL = /\bisoDateTime\s*\(/g;
|
||||
|
||||
function directDatetimeCalls(text: string): number {
|
||||
return (stripComments(text).match(DIRECT_DATETIME) ?? []).length;
|
||||
}
|
||||
|
||||
describe("datetime helper guard", () => {
|
||||
const files = sourceFiles();
|
||||
|
||||
it("finds no direct zod datetime call outside the helper", () => {
|
||||
const offenders = files
|
||||
.filter((rel) => rel !== HELPER)
|
||||
.flatMap((rel) => {
|
||||
const n = directDatetimeCalls(readFileSync(join(SRC, rel), "utf8"));
|
||||
return n > 0 ? [`${rel} (${n})`] : [];
|
||||
});
|
||||
expect(
|
||||
offenders,
|
||||
"use isoDateTime() from @/lib/validations/datetime instead",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("the helper itself is the one place that calls zod", () => {
|
||||
expect(directDatetimeCalls(readFileSync(join(SRC, HELPER), "utf8"))).toBe(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("actually sees the call sites it guards", () => {
|
||||
// An empty sweep agrees with an empty offender list. The helper has
|
||||
// hundreds of callers; a count far below that means the walk or the
|
||||
// matcher went blind, not that the tree is clean.
|
||||
const calls = files
|
||||
.filter((rel) => rel !== HELPER)
|
||||
.reduce(
|
||||
(sum, rel) =>
|
||||
sum +
|
||||
(readFileSync(join(SRC, rel), "utf8").match(HELPER_CALL) ?? [])
|
||||
.length,
|
||||
0,
|
||||
);
|
||||
expect(calls).toBeGreaterThan(350);
|
||||
});
|
||||
|
||||
it("matches the forms prettier produces", () => {
|
||||
expect(directDatetimeCalls("z.iso.datetime({ offset: true })")).toBe(1);
|
||||
expect(directDatetimeCalls("z.iso\n .datetime({ offset: true })")).toBe(1);
|
||||
expect(directDatetimeCalls("z\n .string()\n .datetime()")).toBe(1);
|
||||
expect(directDatetimeCalls("s . datetime ()")).toBe(1);
|
||||
expect(directDatetimeCalls("// z.iso.datetime() in prose")).toBe(0);
|
||||
expect(directDatetimeCalls("/* z.string().datetime() */")).toBe(0);
|
||||
expect(directDatetimeCalls("isoDateTime()")).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* v1.43 — pair guard binding `DawarichDay` in `prisma/schema.prisma` to the
|
||||
* backup that carries it (`src/lib/export/environment-backup.ts`, the
|
||||
* `dawarichDays` arm of the environment section), on the pattern of
|
||||
* `environment-backup-completeness.test.ts`.
|
||||
*
|
||||
* A column missing from the select comes back NULL on every restored day
|
||||
* without a word, because the section schema is `.passthrough()`. So both
|
||||
* ends are compared as literals:
|
||||
*
|
||||
* - every scalar column of `DawarichDay` is in `DAWARICH_DAY_BACKUP_SELECT`
|
||||
* (or excluded here with a reason);
|
||||
* - every selected column is written by the export (`row.<column>`);
|
||||
* - every selected column is written back by the restore (`<column>:`).
|
||||
*
|
||||
* Mutation check: delete `trainDistanceM: true,` from the select and the
|
||||
* first case names it; delete `trainDistanceM:` from the restore 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",
|
||||
);
|
||||
|
||||
const EXCLUDED: Record<string, string> = {
|
||||
id: "nothing addresses a day by its id; a day is identified by its date",
|
||||
userId:
|
||||
"user-scoped backup; the owner is re-derived from the restoring account",
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const columns = scalarColumns("DawarichDay");
|
||||
const selected = [
|
||||
...block(source, "const DAWARICH_DAY_BACKUP_SELECT = {").matchAll(
|
||||
/^ {2}(\w+): true,$/gm,
|
||||
),
|
||||
].map((m) => m[1]);
|
||||
const exportBody = block(source, "dawarichDays: dawarichRows.map(", "})),");
|
||||
const restoreBody = block(
|
||||
source,
|
||||
"export async function restoreEnvironmentData(",
|
||||
"\n}\n",
|
||||
);
|
||||
const restoreDawarich = block(restoreBody, "tx.dawarichDay.createMany(", "});");
|
||||
|
||||
describe("DawarichDay backup completeness", () => {
|
||||
it("parses both ends into a plausible shape", () => {
|
||||
expect(columns).toContain("countryCodeEncrypted");
|
||||
expect(columns).toContain("awayFromHomeMinutes");
|
||||
expect(selected.length).toBeGreaterThan(8);
|
||||
});
|
||||
|
||||
it("the backup select covers every column", () => {
|
||||
const missing = columns.filter(
|
||||
(c) => !selected.includes(c) && !(c in EXCLUDED),
|
||||
);
|
||||
expect(
|
||||
missing,
|
||||
"DAWARICH_DAY_BACKUP_SELECT omits these columns; a restore brings them back NULL",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("the export writes every selected column into the entry", () => {
|
||||
const unwritten = selected.filter(
|
||||
(c) => !new RegExp(`row\\.${c}\\b`).test(exportBody),
|
||||
);
|
||||
expect(unwritten, "selected but never exported").toEqual([]);
|
||||
});
|
||||
|
||||
it("the restore writes every selected column back", () => {
|
||||
const unwritten = selected.filter(
|
||||
(c) => !new RegExp(`\\b${c}:`).test(restoreDawarich),
|
||||
);
|
||||
expect(unwritten, "selected but never restored").toEqual([]);
|
||||
});
|
||||
|
||||
it("the restore clears the days it replaces", () => {
|
||||
expect(restoreBody).toMatch(/tx\.dawarichDay\.deleteMany\(/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* v1.43 — Dawarich is a movement context, never a location store. The schema
|
||||
* holds daily values only: no column of `DawarichDay`, and no Dawarich column
|
||||
* on `User`, may hold a coordinate, a place name, an address or a visit.
|
||||
*
|
||||
* The check reads the column names and their doc comments in
|
||||
* `prisma/schema.prisma`. A new column whose name or description says
|
||||
* latitude, longitude, coordinates, geometry, address, label, a place name or
|
||||
* a visit list fails here and has to argue its case in review.
|
||||
*
|
||||
* Mutation check: add `placeName String?` to `DawarichDay` and the first case
|
||||
* names it.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const schema = readFileSync(
|
||||
join(process.cwd(), "prisma", "schema.prisma"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const FORBIDDEN =
|
||||
/\b(lat|lon|lng|latitude|longitude|coord\w*|geo\w*|geometry|address|label|street|city|placeName|place_name|visits?Json|track|path|point)\b/i;
|
||||
|
||||
/** Column lines of a model with the `///` doc comment above each. */
|
||||
function columns(model: string): Array<{ name: string; text: string }> {
|
||||
const start = schema.indexOf(`model ${model} {`);
|
||||
expect(start, `model ${model} not found`).toBeGreaterThan(-1);
|
||||
const body = schema.slice(start, schema.indexOf("\n}", start));
|
||||
const out: Array<{ name: string; text: string }> = [];
|
||||
let doc = "";
|
||||
for (const raw of body.split("\n").slice(1)) {
|
||||
const line = raw.trim();
|
||||
if (line.startsWith("///")) {
|
||||
doc += ` ${line.slice(3)}`;
|
||||
continue;
|
||||
}
|
||||
const m = /^([a-zA-Z_]\w*)\s+\w/.exec(line);
|
||||
if (m && !line.startsWith("@@"))
|
||||
out.push({ name: m[1], text: `${m[1]} ${doc}` });
|
||||
doc = "";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("Dawarich stores no location", () => {
|
||||
it("DawarichDay holds daily values only", () => {
|
||||
const cols = columns("DawarichDay");
|
||||
expect(cols.map((c) => c.name)).toContain("awayFromHomeMinutes");
|
||||
const offending = cols
|
||||
.filter((c) => c.name !== "user")
|
||||
.filter((c) => FORBIDDEN.test(c.name) || FORBIDDEN.test(c.text))
|
||||
.map((c) => c.name);
|
||||
expect(offending).toEqual([]);
|
||||
});
|
||||
|
||||
it("the User row holds the connection, never a place", () => {
|
||||
const cols = columns("User").filter((c) => /^dawarich/.test(c.name));
|
||||
expect(cols.map((c) => c.name).sort()).toEqual([
|
||||
"dawarichAllowPrivateHost",
|
||||
"dawarichApiKeyEncrypted",
|
||||
"dawarichDays",
|
||||
"dawarichPrefsEncrypted",
|
||||
"dawarichUrlEncrypted",
|
||||
]);
|
||||
const offending = cols
|
||||
.filter((c) => FORBIDDEN.test(c.name))
|
||||
.map((c) => c.name);
|
||||
expect(offending).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -127,6 +127,8 @@ const ALLOWED_SQL: Record<string, string> = {
|
||||
"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/pulse-day-mean.ts::1":
|
||||
"Selects one row per account; `measurements` is read only inside the MIN / MAX scalar subqueries of each row.",
|
||||
"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":
|
||||
|
||||
@@ -160,6 +160,10 @@ function zodObjectShape(path: string, schemaName: string): ZodObjectShape {
|
||||
let current = expression;
|
||||
while (ts.isCallExpression(current)) {
|
||||
const callee = current.expression;
|
||||
// The shared instant helper (`@/lib/validations/datetime`) is a leaf.
|
||||
if (ts.isIdentifier(callee) && callee.text === "isoDateTime") {
|
||||
return [prefix];
|
||||
}
|
||||
expect(
|
||||
ts.isPropertyAccessExpression(callee),
|
||||
`${prefix || schemaName} contains an unsupported Zod call: ${current.getText(file)}`,
|
||||
|
||||
@@ -256,6 +256,20 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/links/related-records.tsx",
|
||||
destroys:
|
||||
"a relation link between two of the person's own records (a medication and its condition, document or visit; a visit and a medication or dose)",
|
||||
recovery: "permanent",
|
||||
confirm: [],
|
||||
unconfirmed: [
|
||||
{
|
||||
control: "remove a chip in a relation link picker",
|
||||
reason:
|
||||
"Removing a chip deletes one join row and nothing the person wrote: both records stay untouched, the unlink toast names what was unlinked, and the same picker re-files it with one tap. This is the shape every link picker in the app already has (the visit form, the document sheet, the dose form), and a dialog on one of them alone would make the same act cost two different amounts.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/insights/coach-panel/coach-conversation.tsx",
|
||||
destroys:
|
||||
@@ -389,8 +403,23 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
confirm: ["DeleteButton"],
|
||||
},
|
||||
{
|
||||
file: "components/records/health-profile-facts-manager.tsx",
|
||||
destroys: "the current value of one effective-dated health-profile fact",
|
||||
file: "components/records/life-circumstances-manager.tsx",
|
||||
destroys:
|
||||
"the current answer of one life-circumstance fact, when a section is saved with it set back to no answer",
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: [],
|
||||
unconfirmed: [
|
||||
{
|
||||
control:
|
||||
"Save section with a field set back to no answer — life-circumstances-manager.tsx",
|
||||
reason:
|
||||
"Setting a question back to no answer and pressing Save is the deliberate act itself, made field by field inside a form the person is editing, with the old answer visible until they change it. The delete closes the answer's interval rather than erasing it, so the dated history keeps it, and answering again takes one select. A second dialog on top of an explicit form edit would make correcting an answer dearer than giving it.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
file: "components/records/audit-c-panel.tsx",
|
||||
destroys: "one AUDIT-C entry from the alcohol history",
|
||||
recovery: "tombstoned-no-restore",
|
||||
confirm: ["ConfirmButton"],
|
||||
},
|
||||
@@ -635,6 +664,14 @@ const REGISTRY: DestructiveEntry[] = [
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/integrations/dawarich-card.tsx",
|
||||
destroys:
|
||||
"the stored Dawarich address, key and home settings, and with the second entry every stored movement day — reconnecting means typing them again; deleted days come back only as far as the instance still holds them",
|
||||
triggers: ["components/settings/integrations/integration-card.tsx"],
|
||||
recovery: "permanent",
|
||||
confirm: ["ConfirmDialog"],
|
||||
},
|
||||
{
|
||||
file: "components/settings/integrations/document-sources-card.tsx",
|
||||
destroys:
|
||||
|
||||
@@ -43,13 +43,7 @@ const EXCLUDED: Record<string, string> = {
|
||||
};
|
||||
|
||||
/** Columns written through the location helper rather than `row.<column>`. */
|
||||
const LOCATION_COLUMNS = new Set([
|
||||
"lat",
|
||||
"lon",
|
||||
"locationLabel",
|
||||
"label",
|
||||
"locationEncrypted",
|
||||
]);
|
||||
const LOCATION_COLUMNS = new Set(["locationEncrypted"]);
|
||||
|
||||
function block(text: string, start: string, end = "\n}"): string {
|
||||
const from = text.indexOf(start);
|
||||
@@ -95,9 +89,10 @@ describe.each([
|
||||
const selected = selectKeys(selectName);
|
||||
|
||||
it("parses both ends into a plausible shape", () => {
|
||||
expect(columns.length).toBeGreaterThan(8);
|
||||
// A period is seven columns since v1.43 (the readable location dropped).
|
||||
expect(columns.length).toBeGreaterThan(6);
|
||||
expect(columns).toContain("locationEncrypted");
|
||||
expect(selected.length).toBeGreaterThan(5);
|
||||
expect(selected.length).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
it("the backup select covers every column", () => {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Every error display goes through the one helper that shows an error id.
|
||||
*
|
||||
* A self-hoster reporting a failure needs the id the operator finds it by. The
|
||||
* web shows it on a server failure with a copy button, through two doors only:
|
||||
*
|
||||
* - toasts: `showErrorToast` (`src/lib/errors/error-toast.tsx`), which adds
|
||||
* the id when its `cause` is a 5xx and is otherwise exactly `toast.error`;
|
||||
* - error boundaries: `ErrorDetails` / `ErrorIdLine`, or `errorIdOf` for the
|
||||
* root boundary that renders without any provider.
|
||||
*
|
||||
* A raw `toast.error(` anywhere else is a toast that can never carry the id,
|
||||
* and a route boundary that skips the helper is a crash screen without it.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
const HELPER = "lib/errors/error-toast.tsx";
|
||||
|
||||
/** Whitespace-tolerant: `toast.error(`, `toast .error (`, `toast\n.error(`. */
|
||||
const RAW_TOAST_ERROR = /\btoast\s*\.\s*error\s*\(/g;
|
||||
const HELPER_CALL = /\bshowErrorToast\s*\(/g;
|
||||
|
||||
/**
|
||||
* Route boundaries that deliberately show no error id, with the reason. Each
|
||||
* entry is a decision, not a convenience.
|
||||
*/
|
||||
const BOUNDARY_EXEMPT: Readonly<Record<string, string>> = {
|
||||
// The clinician share view is opened by someone outside the instance, with
|
||||
// no account and no way to reach its operator; it shows a retry and nothing
|
||||
// a probe could learn from, by design (see the file's header).
|
||||
"app/c/error.tsx": "public share view, no operator relationship",
|
||||
};
|
||||
|
||||
const files = walkSourceFiles(SRC, { floor: 2500 }).filter(
|
||||
(rel) =>
|
||||
!rel.includes("__tests__/") &&
|
||||
!/\.test\.tsx?$/.test(rel) &&
|
||||
!rel.startsWith("generated/"),
|
||||
);
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
describe("error display helper guard", () => {
|
||||
it("finds the helper's own toast.error, so the matcher works", () => {
|
||||
const own = read(HELPER).match(RAW_TOAST_ERROR) ?? [];
|
||||
expect(own.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("allows no toast.error outside the helper", () => {
|
||||
const offenders = files
|
||||
.filter((rel) => rel !== HELPER)
|
||||
.filter((rel) => (read(rel).match(RAW_TOAST_ERROR) ?? []).length > 0)
|
||||
.sort();
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("routes the app's error toasts through showErrorToast", () => {
|
||||
const calls = files.reduce(
|
||||
(n, rel) => n + (read(rel).match(HELPER_CALL) ?? []).length,
|
||||
0,
|
||||
);
|
||||
// 200 call sites when the helper landed; a count far below it means the
|
||||
// matcher or the walk stopped seeing them.
|
||||
expect(calls).toBeGreaterThan(180);
|
||||
});
|
||||
|
||||
it("renders every route error boundary through the error id helper", () => {
|
||||
const boundaries = files.filter((rel) =>
|
||||
/^app\/(.*\/)?(error|global-error)\.tsx$/.test(rel),
|
||||
);
|
||||
expect(boundaries.length).toBeGreaterThanOrEqual(3);
|
||||
const missing = boundaries
|
||||
.filter((rel) => !(rel in BOUNDARY_EXEMPT))
|
||||
.filter((rel) => {
|
||||
const source = read(rel);
|
||||
return !(
|
||||
/<ErrorDetails\b/.test(source) ||
|
||||
/<ErrorIdLine\b/.test(source) ||
|
||||
/\berrorIdOf\s*\(/.test(source)
|
||||
);
|
||||
});
|
||||
expect(missing).toEqual([]);
|
||||
for (const rel of Object.keys(BOUNDARY_EXEMPT)) {
|
||||
expect(boundaries, `${rel} is exempt but no longer exists`).toContain(
|
||||
rel,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the id in the shared boundary panel", () => {
|
||||
const source = read("components/error-details.tsx");
|
||||
expect(source).toMatch(/<ErrorIdLine\b/);
|
||||
expect(source).toMatch(/\berrorIdOf\s*\(/);
|
||||
});
|
||||
});
|
||||
@@ -201,6 +201,11 @@ const ALLOWED: Readonly<Record<string, { count: number; reason: string }>> = {
|
||||
reason:
|
||||
"A size probe for an admin card: null shows no figure, and the status read must not fail over it.",
|
||||
},
|
||||
"lib/jobs/pulse-day-mean.ts::isPulseDayMeanRunActive": {
|
||||
count: 1,
|
||||
reason:
|
||||
"No pgboss schema on a web-only deployment, so no run can be active; the route answers 503 there before it asks.",
|
||||
},
|
||||
"lib/jobs/job-observer.ts::emitJobLine": {
|
||||
count: 1,
|
||||
reason:
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Error-envelope helpers for tests that compare whole response bodies.
|
||||
*
|
||||
* `apiHandler` stamps every error envelope with `meta.requestId`, a fresh id
|
||||
* per request. A test that proves two refusals are byte-identical (an
|
||||
* enumeration oracle check) compares everything else: the id is random, the
|
||||
* same for no two requests, and says nothing about the account.
|
||||
*/
|
||||
|
||||
/** The body text with `meta.requestId` removed, `meta` dropped when empty. */
|
||||
export function withoutRequestId(text: string): string {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
||||
return text;
|
||||
}
|
||||
const record = { ...(body as Record<string, unknown>) };
|
||||
const meta = record.meta;
|
||||
if (typeof meta === "object" && meta !== null && !Array.isArray(meta)) {
|
||||
const rest = { ...(meta as Record<string, unknown>) };
|
||||
delete rest.requestId;
|
||||
if (Object.keys(rest).length === 0) delete record.meta;
|
||||
else record.meta = rest;
|
||||
}
|
||||
return JSON.stringify(record);
|
||||
}
|
||||
|
||||
/** A response's body text with `meta.requestId` removed. */
|
||||
export async function bodyWithoutRequestId(res: Response): Promise<string> {
|
||||
return withoutRequestId(await res.text());
|
||||
}
|
||||
@@ -139,3 +139,77 @@ describe("insights unit-literal guard", () => {
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.43 — no nutrient unit spelled outside the nutrient catalogue.
|
||||
*
|
||||
* A supplement's composition converts an entered amount to the catalogue's
|
||||
* canonical unit, and the nutrient reads judge a day against an upper limit
|
||||
* in that unit. A µg/mg slip is a 1000× error, and the way it gets in is a
|
||||
* second place that spells a unit or a factor: a converter that knows "IU"
|
||||
* itself, a read that compares against "mg". So the rule is that the code
|
||||
* under `src/lib/nutrients/` and `src/lib/medications/ingredients/` names no
|
||||
* unit as a string literal; units and factors come from
|
||||
* `src/lib/nutrients/catalog.ts` (`NUTRIENT_UNIT`, `inputUnits`,
|
||||
* `toCanonicalNutrientAmount`, `parseNutrientInputUnit`), the one file allowed
|
||||
* to spell them.
|
||||
*/
|
||||
const NUTRIENT_UNIT_DIRS = [
|
||||
join(process.cwd(), "src/lib/nutrients"),
|
||||
join(process.cwd(), "src/lib/medications/ingredients"),
|
||||
];
|
||||
const NUTRIENT_CATALOG_FILE = join(
|
||||
process.cwd(),
|
||||
"src/lib/nutrients/catalog.ts",
|
||||
);
|
||||
|
||||
/** A string literal that is nothing but a nutrient unit. */
|
||||
const UNIT_LITERAL =
|
||||
/(["'`])(mg|ug|µg|μg|mcg|g|kg|ml|mL|l|L|IU|IE|UI|iu|ie|ui)\1/g;
|
||||
|
||||
function sourceFilesUnder(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
if (statSync(full).isDirectory()) {
|
||||
if (entry === "__tests__") continue;
|
||||
out.push(...sourceFilesUnder(full));
|
||||
} else if (/\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry)) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("nutrient unit-literal guard", () => {
|
||||
const files = NUTRIENT_UNIT_DIRS.flatMap(sourceFilesUnder);
|
||||
|
||||
it("scans the nutrient and composition code", () => {
|
||||
expect(files.length).toBeGreaterThanOrEqual(6);
|
||||
expect(files).toContain(NUTRIENT_CATALOG_FILE);
|
||||
});
|
||||
|
||||
it("the matcher sees the catalogue's own spellings", () => {
|
||||
// The catalogue is where units live, so a matcher that finds nothing
|
||||
// there would make the next assertion vacuously true.
|
||||
const catalog = readFileSync(NUTRIENT_CATALOG_FILE, "utf8");
|
||||
const seen = new Set([...catalog.matchAll(UNIT_LITERAL)].map((m) => m[2]));
|
||||
for (const unit of ["mg", "ug", "IU", "ml"]) {
|
||||
expect(seen.has(unit), `expected to see "${unit}" in the catalogue`).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("names no unit outside the catalogue", () => {
|
||||
const offenders: string[] = [];
|
||||
for (const file of files) {
|
||||
if (file === NUTRIENT_CATALOG_FILE) continue;
|
||||
const source = readFileSync(file, "utf8");
|
||||
for (const match of source.matchAll(UNIT_LITERAL)) {
|
||||
offenders.push(`${file.slice(file.indexOf("src/"))}: ${match[0]}`);
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* v1.43 — pair guard binding the `MedicationIngredient` model to both ends of
|
||||
* the backup: the writer's `ingredients: m.ingredients.map(...)` block in
|
||||
* `full-backup-payload.ts` and the nested create in `restore-backup.ts`.
|
||||
*
|
||||
* The failure it catches is the one the measurement guard beside it was
|
||||
* written for: a column added to the composition in a later release, the
|
||||
* writer or the restore left as "the follow-up", the payload schema's
|
||||
* `.passthrough()` swallowing it, and the column coming back empty on every
|
||||
* restored row. The column-by-column round trip
|
||||
* (`tests/integration/backup-round-trip.test.ts`) proves the same against a
|
||||
* database; this is the fast edge of it that runs with the unit suite.
|
||||
*
|
||||
* Mutation check: delete `enteredUnit: i.enteredUnit,` from the writer and the
|
||||
* first leg names `enteredUnit`; delete `source: i.source,` from the restore
|
||||
* and the second leg names `source`.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const read = (...parts: string[]) => readFileSync(join(ROOT, ...parts), "utf8");
|
||||
|
||||
/**
|
||||
* Columns neither end carries, each with its reason.
|
||||
* - `userId`: re-derived from the restoring account.
|
||||
* - `medicationId`: the rows are nested in their medication on both ends, so
|
||||
* the restore binds them to whatever id the medication got.
|
||||
* - `updatedAt`: `@updatedAt`, stamped by the restore's own write.
|
||||
*/
|
||||
const EXCLUDED: Record<string, string> = {
|
||||
userId: "owner re-derived from the restoring account",
|
||||
medicationId: "nested in its medication; bound by the nested create",
|
||||
updatedAt: "@updatedAt; stamped by the restore's write",
|
||||
};
|
||||
|
||||
function modelColumns(): string[] {
|
||||
const schema = read("prisma", "schema.prisma");
|
||||
const start = schema.indexOf("model MedicationIngredient {");
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
const block = schema.slice(start, schema.indexOf("\n}", start));
|
||||
const out: string[] = [];
|
||||
for (const raw of block.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 || line.includes("@relation")) continue;
|
||||
out.push(m[1]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The keys one end's ingredient block names, read off `<key>: i.<field>`. */
|
||||
function blockKeys(source: string, opener: string): Set<string> {
|
||||
const start = source.indexOf(opener);
|
||||
expect(start, `${opener} not found`).toBeGreaterThan(-1);
|
||||
const end = source.indexOf("})),", start);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
const body = source.slice(start, end);
|
||||
return new Set(
|
||||
[...body.matchAll(/\b([a-zA-Z_]\w*):\s*(?:i\.|new Date\(i\.)/g)].map(
|
||||
(m) => m[1],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe("medication ingredient backup completeness", () => {
|
||||
const columns = modelColumns();
|
||||
const carried = columns.filter((c) => !(c in EXCLUDED));
|
||||
|
||||
it("reads the model's columns", () => {
|
||||
expect(columns).toEqual(
|
||||
expect.arrayContaining(["nutrient", "amountPerUnit", "enteredUnit"]),
|
||||
);
|
||||
for (const key of Object.keys(EXCLUDED)) expect(columns).toContain(key);
|
||||
});
|
||||
|
||||
it("the writer carries every column", () => {
|
||||
const keys = blockKeys(
|
||||
read("src", "lib", "export", "full-backup-payload.ts"),
|
||||
"ingredients: m.ingredients.map((i) => ({",
|
||||
);
|
||||
expect(carried.filter((c) => !keys.has(c))).toEqual([]);
|
||||
});
|
||||
|
||||
it("the restore writes every column back", () => {
|
||||
const keys = blockKeys(
|
||||
read("src", "lib", "export", "restore-backup.ts"),
|
||||
"create: m.ingredients.map((i) => ({",
|
||||
);
|
||||
expect(carried.filter((c) => !keys.has(c))).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -83,6 +83,8 @@ const INTAKE_MAY_SEE_EVERY_ROW: Record<string, string> = {
|
||||
"folds duplicate rows of doses already recorded",
|
||||
"lib/doctor-report/collect.ts":
|
||||
"administration ledger lists recorded doses; rates gate via expectsDoses",
|
||||
"lib/medications/ingredients/recompute.ts":
|
||||
"sums the nutrients of doses that were taken; a recorded dose counts whatever the switch says",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -94,6 +96,10 @@ const MAY_READ_EVERY_ROW: Record<string, string> = {
|
||||
// as stored, schedule and switch included.
|
||||
"lib/export/full-backup-payload.ts": "backup carries the stored rows",
|
||||
"lib/export/restore-backup.ts": "restore writes the stored rows back",
|
||||
// v1.43 — the units a taken dose consumed, resolved per slot exactly as
|
||||
// the inventory does. It reads only doses already recorded as taken.
|
||||
"lib/medications/ingredients/recompute.ts":
|
||||
"units per slot of doses already taken",
|
||||
"app/api/export/route.ts": "data export of the stored record",
|
||||
"app/api/export/medications/route.ts": "medication CSV export of the record",
|
||||
// Intake WRITE helpers: they run only when a dose is being recorded, bind
|
||||
|
||||
@@ -258,6 +258,10 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/maintenance/pulse-day-mean": {
|
||||
kind: "adminConsole",
|
||||
methods: ["GET", "POST"],
|
||||
},
|
||||
"/api/admin/monitoring/glitchtip-test": {
|
||||
kind: "adminConsole",
|
||||
methods: ["POST"],
|
||||
@@ -284,6 +288,7 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
methods: ["POST"],
|
||||
},
|
||||
"/api/admin/status": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/support-report": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/tokens": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/users": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/users/{id}": { kind: "adminConsole", methods: ["PUT"] },
|
||||
|
||||
@@ -83,6 +83,8 @@ const UNAUTHENTICATED: Readonly<Record<string, string>> = {
|
||||
"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.",
|
||||
"/api/auth/claim/sso":
|
||||
"Starts claiming a managed profile through the identity provider; the claimant has no credential yet, and 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
|
||||
|
||||
@@ -74,7 +74,9 @@ describe("proxy matcher", () => {
|
||||
const routes = apiRoutePaths();
|
||||
expect(routes.length).toBeGreaterThan(300);
|
||||
|
||||
for (const path of PROXY_BYPASS_ROUTES) {
|
||||
for (const route of PROXY_BYPASS_ROUTES) {
|
||||
// A dynamic segment is filled the way `apiRoutePaths` fills it.
|
||||
const path = route.replace(/\[[^\]]+\]/g, "x");
|
||||
expect(routes, `${path} is not a route`).toContain(path);
|
||||
expect(re.test(path), `${path} still passes the proxy`).toBe(false);
|
||||
expect(re.test(`${path}/`), `${path}/ still passes the proxy`).toBe(
|
||||
@@ -98,6 +100,8 @@ describe("proxy matcher", () => {
|
||||
"/api/user/avatar/abc",
|
||||
"/api/documents/inbound/abc/extract",
|
||||
"/api/import/csv/preview",
|
||||
// The composition PUT beside the label upload stays behind the proxy.
|
||||
"/api/medications/abc/ingredients",
|
||||
]) {
|
||||
expect(re.test(path), path).toBe(true);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
* imports the shared statistic, hands the type to a fold that does, or
|
||||
* is listed with the reason it never averages pulse readings.
|
||||
*
|
||||
* A third matcher has no list: since the pulse day-mean method (v1.43) an
|
||||
* hour weighs by the method, so no day may still be folded from its hours'
|
||||
* means with every hour weighing one (`AVG(hour_mean)`, a sum over hours
|
||||
* divided by their number). The weight lives in the helper alone.
|
||||
*
|
||||
* Comments are stripped before matching, the matchers are whitespace-tolerant,
|
||||
* and the floors fail the guard if either matcher stops finding anything. The
|
||||
* planted cases run each matcher over an offender; dropping the type from the
|
||||
@@ -67,6 +72,15 @@ const TS_MEAN =
|
||||
|
||||
const NAMES_PULSE = /\bPULSE\b/;
|
||||
|
||||
/**
|
||||
* A day folded from its hours' means with every hour weighing one: SQL
|
||||
* `AVG(hour_mean)` and an in-memory sum over hours divided by their number.
|
||||
* Since v1.43 an hour weighs by the pulse day-mean method, which only the
|
||||
* helper applies (`hourWeightSql`, `combineHourCells`), so none may remain.
|
||||
*/
|
||||
const UNWEIGHTED_HOURS =
|
||||
/\bAVG\s*\(\s*(?:[a-z_]+\s*\.\s*)?"?hour_mean"?\s*\)|\/\s*hours\.(?:length|size)\b/g;
|
||||
|
||||
type Entry =
|
||||
/** Averages pulse and imports the shared statistic. */
|
||||
| { helper: true }
|
||||
@@ -85,6 +99,8 @@ const SQL_READERS: Record<string, Entry> = {
|
||||
"lib/doctor-report/dense-buckets.ts": HELPER,
|
||||
"lib/insights/comprehensive-aggregator.ts": HELPER,
|
||||
"lib/insights/comprehensive-generate.ts": HELPER,
|
||||
// The dry run's hour means, weighed by both methods through the helper.
|
||||
"lib/jobs/pulse-day-mean.ts": HELPER,
|
||||
"lib/measurements/daily-series-read.ts": HELPER,
|
||||
"lib/rollups/measurement-rollups.ts": HELPER,
|
||||
"lib/rollups/tiered-context.ts": HELPER,
|
||||
@@ -125,6 +141,10 @@ const TS_READERS: Record<string, Entry> = {
|
||||
"lib/analytics/correlations-fast-path.ts": {
|
||||
exempt: "mood mean; pulse enters as the resting series, never a day mean",
|
||||
},
|
||||
"app/api/dashboard/summary/route.ts": {
|
||||
exempt:
|
||||
"places a card's seven-day sparkline mean against its band for the trend verdict; each sparkline point is already one rollup day value, so the mean weighs every day once",
|
||||
},
|
||||
"lib/dashboard/snapshot.ts": {
|
||||
exempt: "summarises water-intake days and glucose contexts",
|
||||
},
|
||||
@@ -159,7 +179,11 @@ const TS_READERS: Record<string, Entry> = {
|
||||
},
|
||||
};
|
||||
|
||||
function scan(): { sql: Map<string, number>; ts: Map<string, number> } {
|
||||
function scan(): {
|
||||
sql: Map<string, number>;
|
||||
ts: Map<string, number>;
|
||||
unweighted: string[];
|
||||
} {
|
||||
const files = walkSourceFiles(SRC, { floor: 1500 }).filter(
|
||||
(f) =>
|
||||
!f.startsWith("generated/") &&
|
||||
@@ -169,8 +193,11 @@ function scan(): { sql: Map<string, number>; ts: Map<string, number> } {
|
||||
);
|
||||
const sql = new Map<string, number>();
|
||||
const ts = new Map<string, number>();
|
||||
const unweighted: string[] = [];
|
||||
for (const f of files) {
|
||||
const text = stripComments(readFileSync(join(SRC, f), "utf8"));
|
||||
if (UNWEIGHTED_HOURS.test(text)) unweighted.push(f);
|
||||
UNWEIGHTED_HOURS.lastIndex = 0;
|
||||
const s = text.match(SQL_MEAN)?.length ?? 0;
|
||||
if (s > 0) sql.set(f, s);
|
||||
if (NAMES_PULSE.test(text)) {
|
||||
@@ -178,10 +205,10 @@ function scan(): { sql: Map<string, number>; ts: Map<string, number> } {
|
||||
if (t > 0) ts.set(f, t);
|
||||
}
|
||||
}
|
||||
return { sql, ts };
|
||||
return { sql, ts, unweighted };
|
||||
}
|
||||
|
||||
const { sql, ts } = scan();
|
||||
const { sql, ts, unweighted } = scan();
|
||||
const source = (f: string) => readFileSync(join(SRC, f), "utf8");
|
||||
|
||||
function check(found: Map<string, number>, listed: Record<string, Entry>) {
|
||||
@@ -235,6 +262,22 @@ describe("pulse day-mean reader guard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves no day folded from equally weighted hours", () => {
|
||||
expect(unweighted).toEqual([]);
|
||||
});
|
||||
|
||||
it("catches a planted unweighted hour fold", () => {
|
||||
expect(`AVG(h.hour_mean) AS day_mean`.match(UNWEIGHTED_HOURS)).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
`hours.reduce((s, h) => s + h.total / h.count, 0) / hours.length`.match(
|
||||
UNWEIGHTED_HOURS,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(`SUM(h.hour_mean * w) / SUM(w)`.match(UNWEIGHTED_HOURS)).toBeNull();
|
||||
});
|
||||
|
||||
it("catches a planted plain AVG and a planted fold", () => {
|
||||
expect(
|
||||
`SELECT AVG( m."value" ) FROM measurements m`.match(SQL_MEAN),
|
||||
|
||||
@@ -157,6 +157,7 @@ describe("record settings contract", () => {
|
||||
oura: false,
|
||||
"google-health": false,
|
||||
strava: false,
|
||||
dawarich: false,
|
||||
} as const;
|
||||
|
||||
expect(
|
||||
@@ -187,6 +188,7 @@ describe("record settings contract", () => {
|
||||
oura: false,
|
||||
"google-health": false,
|
||||
strava: false,
|
||||
dawarich: false,
|
||||
} as const;
|
||||
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* v1.43 — pair guard binding the five relation link tables in
|
||||
* `prisma/schema.prisma` to the backup that carries them
|
||||
* (`src/lib/export/relation-links-backup.ts`), on the pattern of
|
||||
* `environment-backup-completeness.test.ts`.
|
||||
*
|
||||
* A link table is two ids and an instant, and the failure this guards is the
|
||||
* quiet one: a table the backup does not read comes back empty after a
|
||||
* restore, every record it joined still there and no longer pointing at its
|
||||
* partner, and nothing fails. So each end is compared as a literal:
|
||||
*
|
||||
* - every relation link table of the matrix is a two-ended model of the
|
||||
* backup plan, not on the debt register;
|
||||
* - every scalar column of each table is read by the export's select (or
|
||||
* excluded here with a reason);
|
||||
* - every read column is written back by the restore's `createMany`;
|
||||
* - the wire schema names the same two ends for the section key.
|
||||
*
|
||||
* Mutation check: delete `documentId: true` from the lab-result select and
|
||||
* the export case names it; delete the `MedicationConditionLink` line from
|
||||
* `TWO_ENDED_MODELS` and the plan case names it.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BACKED_UP_MODELS,
|
||||
COVERAGE_PENDING,
|
||||
TWO_ENDED_MODELS,
|
||||
} from "@/lib/export/backup-plan";
|
||||
import { RELATION_LINK_SECTION_KEYS } from "@/lib/export/relation-links-backup";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const schema = readFileSync(join(ROOT, "prisma", "schema.prisma"), "utf8");
|
||||
const source = readFileSync(
|
||||
join(ROOT, "src", "lib", "export", "relation-links-backup.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const wire = readFileSync(
|
||||
join(ROOT, "src", "lib", "validations", "backup.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
/** The five tables v1.43 adds to close the relation matrix. */
|
||||
const RELATION_LINK_MODELS = [
|
||||
"LabResultDocumentLink",
|
||||
"EncounterMedicationLink",
|
||||
"EncounterVaccinationLink",
|
||||
"MedicationDocumentLink",
|
||||
"MedicationConditionLink",
|
||||
] as const;
|
||||
|
||||
/** Columns the backup is right to leave out, with the reason. */
|
||||
const EXCLUDED: Record<string, string> = {
|
||||
id: "nothing addresses a link row by id; the pair is its identity (unique)",
|
||||
userId:
|
||||
"user-scoped backup; the owner is re-derived from the restoring account",
|
||||
};
|
||||
|
||||
function block(text: string, start: string, end: string): string {
|
||||
const from = text.indexOf(start);
|
||||
expect(from, `${start} not found`).toBeGreaterThan(-1);
|
||||
const to = text.indexOf(end, from);
|
||||
expect(to, `${end} not found after ${start}`).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} {`, "\n}")
|
||||
.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;
|
||||
}
|
||||
|
||||
const delegate = (model: string) => model[0].toLowerCase() + model.slice(1);
|
||||
|
||||
describe("relation link tables in the backup plan", () => {
|
||||
it.each(RELATION_LINK_MODELS)("%s travels both ways", (model) => {
|
||||
expect(BACKED_UP_MODELS as readonly string[]).toContain(model);
|
||||
expect(TWO_ENDED_MODELS as readonly string[]).toContain(model);
|
||||
expect(COVERAGE_PENDING).not.toHaveProperty(model);
|
||||
});
|
||||
|
||||
it("names one section key per table", () => {
|
||||
expect([...RELATION_LINK_SECTION_KEYS].sort()).toEqual(
|
||||
RELATION_LINK_MODELS.map((m) => `${delegate(m)}s`).sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(RELATION_LINK_MODELS)("%s backup completeness", (model) => {
|
||||
const columns = scalarColumns(model);
|
||||
const name = delegate(model);
|
||||
const exported = block(source, `prisma.${name}.findMany(`, "}),");
|
||||
const restored = block(source, `tx.${name}.createMany(`, "skipDuplicates");
|
||||
const wired = block(wire, ` ${name}s: z`, ".default([])");
|
||||
const ends = columns.filter((c) => !(c in EXCLUDED) && c !== "createdAt");
|
||||
|
||||
it("parses both ends into a plausible shape", () => {
|
||||
expect(columns).toEqual(
|
||||
expect.arrayContaining(["id", "userId", "createdAt"]),
|
||||
);
|
||||
expect(ends).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("the export reads every column", () => {
|
||||
const missing = columns.filter(
|
||||
(c) => !(c in EXCLUDED) && !new RegExp(`\\b${c}: true\\b`).test(exported),
|
||||
);
|
||||
expect(missing, `the export omits these ${model} columns`).toEqual([]);
|
||||
});
|
||||
|
||||
it("the restore writes every column back", () => {
|
||||
const unwritten = ends.filter(
|
||||
(c) => !new RegExp(`\\b${c}: r\\.${c}\\b`).test(restored),
|
||||
);
|
||||
expect(unwritten, `the restore drops these ${model} columns`).toEqual([]);
|
||||
// `stamp(...)` writes the owner and the original filing instant.
|
||||
expect(restored).toMatch(/\.\.\.stamp\(r\.createdAt\)/);
|
||||
});
|
||||
|
||||
it("the wire schema names the same two ends", () => {
|
||||
for (const end of ends) {
|
||||
expect(wired, `${name}s wire schema`).toContain(`"${end}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* An incoming `x-request-id` is client-controlled. It is echoed on the
|
||||
* response and written into every log line of the request, so it is adopted
|
||||
* only when it is a plain correlation token — `^[A-Za-z0-9-]{8,64}$` — and
|
||||
* replaced with a fresh id otherwise. Both places that read it are covered:
|
||||
* the proxy (every proxied path) and `apiHandler` (which also serves the
|
||||
* routes the proxy matcher leaves out).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
shouldRunWeb: () => true,
|
||||
shouldRunWorker: () => true,
|
||||
getProcessType: () => "all",
|
||||
}));
|
||||
|
||||
import { proxy } from "../proxy";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
||||
const VALID = ["3f9a2c1b", "abcDEF12-3456-7890", "a".repeat(64)];
|
||||
const INVALID = [
|
||||
"short",
|
||||
"a".repeat(65),
|
||||
"id with spaces",
|
||||
"id_underscore_x",
|
||||
"<script>alert(1)</script>",
|
||||
'x" level="error',
|
||||
"ümläut-id-1234",
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
function proxied(requestId: string): NextRequest {
|
||||
return new NextRequest("http://localhost/api/version", {
|
||||
headers: { "x-request-id": requestId },
|
||||
});
|
||||
}
|
||||
|
||||
describe("proxy: incoming x-request-id", () => {
|
||||
it.each(VALID)("adopts the well-formed id %s", (id) => {
|
||||
const res = proxy(proxied(id));
|
||||
expect(res.headers.get("x-request-id")).toBe(id);
|
||||
});
|
||||
|
||||
it.each(INVALID)("replaces the malformed id %j with a fresh one", (id) => {
|
||||
const res = proxy(proxied(id));
|
||||
const echoed = res.headers.get("x-request-id");
|
||||
expect(echoed).not.toBe(id);
|
||||
expect(echoed).toMatch(UUID);
|
||||
// The rewritten request header the route sees carries the same fresh id.
|
||||
expect(res.headers.get("x-middleware-request-x-request-id")).toBe(echoed);
|
||||
});
|
||||
});
|
||||
|
||||
describe("apiHandler: incoming x-request-id", () => {
|
||||
function call(id: string) {
|
||||
let logged: string | undefined;
|
||||
const wrapped = apiHandler(async (_request: NextRequest) => {
|
||||
logged = getEvent()?.getRequestId();
|
||||
return NextResponse.json({ data: "ok" });
|
||||
});
|
||||
return wrapped(
|
||||
new NextRequest("http://localhost/api/measurements/batch", {
|
||||
method: "POST",
|
||||
headers: { "x-request-id": id },
|
||||
}),
|
||||
).then((res) => ({ res, logged }));
|
||||
}
|
||||
|
||||
it.each(VALID)("logs and echoes the well-formed id %s", async (id) => {
|
||||
const { res, logged } = await call(id);
|
||||
expect(logged).toBe(id);
|
||||
expect(res.headers.get("x-request-id")).toBe(id);
|
||||
});
|
||||
|
||||
it.each(INVALID)("never logs or echoes the malformed id %j", async (id) => {
|
||||
const { res, logged } = await call(id);
|
||||
expect(logged).not.toBe(id);
|
||||
expect(logged).toMatch(UUID);
|
||||
expect(res.headers.get("x-request-id")).toBe(logged);
|
||||
});
|
||||
});
|
||||
@@ -289,3 +289,295 @@ describe("resolved-field consumer guard", () => {
|
||||
expect(total).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.43 (ios #116 items 3, 4, 5, 6, 9; #77) — the fields the server resolves
|
||||
* for the native client either reach the web, or say why they do not.
|
||||
*
|
||||
* The inventory is not hand-kept: every response property in the OpenAPI
|
||||
* route modules typed by one of the resolved-verdict schemas
|
||||
* (`src/lib/openapi/routes/trend-verdict.ts`) is discovered, and each must be
|
||||
* claimed by exactly one entry below. An entry either names the web module
|
||||
* that reads the field, by a pattern that names the field itself, or names
|
||||
* the field iOS-only with the reason, and where the reason is "the web reads
|
||||
* the same answer elsewhere", the server modules that must share the
|
||||
* resolver for that to stay true. A new resolved field with neither fails.
|
||||
*
|
||||
* Entries without a discovered property (`VITALS_BASELINE` sits in an open
|
||||
* record, `lastBackfillAt` is a plain timestamp) carry a web reader by hand.
|
||||
*
|
||||
* The rebuild side of the same rule (a client deciding a polarity or drawing
|
||||
* a threshold of its own) is `client-threshold-literal-guard.test.ts`.
|
||||
*/
|
||||
interface WebReader {
|
||||
file: string;
|
||||
pattern: RegExp;
|
||||
}
|
||||
|
||||
interface ResolvedFieldEntry {
|
||||
field: string;
|
||||
/** Discovered properties this entry claims: `<route module>#<property>`. */
|
||||
props: string[];
|
||||
/** The web modules that read the field. */
|
||||
readers?: WebReader[];
|
||||
/** Why the web has no reader; required when `readers` is absent. */
|
||||
iosOnly?: {
|
||||
reason: string;
|
||||
/**
|
||||
* When the reason is that the web reads the same answer from another
|
||||
* route: every file here must call the resolver both answers come from.
|
||||
*/
|
||||
sameResolver?: { pattern: RegExp; files: string[] };
|
||||
};
|
||||
}
|
||||
|
||||
const ROUTES_DIR = "src/lib/openapi/routes";
|
||||
const RESOLVED_SCHEMAS = [
|
||||
"trendVerdict",
|
||||
"trendSentiment",
|
||||
"seriesChange",
|
||||
"seriesReferenceLine",
|
||||
"recordBenchmark",
|
||||
"moodPattern",
|
||||
"cycleRestoredStart",
|
||||
];
|
||||
const VERDICT_RESOLVER = /\bresolve(?:TrendVerdict|SummaryVerdicts)\(/;
|
||||
|
||||
const V143_FIELDS: ResolvedFieldEntry[] = [
|
||||
{
|
||||
field: "GET /api/dashboard/snapshot tiles.directions",
|
||||
props: ["insights/schemas.ts#directions"],
|
||||
readers: [
|
||||
{ file: "src/app/page-client.tsx", pattern: /tiles\.directions/ },
|
||||
],
|
||||
},
|
||||
{
|
||||
field:
|
||||
"GET /api/dashboard/summary DashboardMetricCard.direction / trendSentiment",
|
||||
props: ["dashboard.ts#direction", "dashboard.ts#trendSentiment"],
|
||||
iosOnly: {
|
||||
reason:
|
||||
"the summary route is the native client's dashboard; the web dashboard colours its tiles from the snapshot's `tiles.directions`, resolved by the same verdict resolver",
|
||||
sameResolver: {
|
||||
pattern: VERDICT_RESOLVER,
|
||||
files: [
|
||||
"src/app/api/dashboard/summary/route.ts",
|
||||
"src/lib/dashboard/snapshot.ts",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "GET /api/insights/targets TargetItem.direction / trendSentiment",
|
||||
props: [
|
||||
"insights/schemas.ts#direction",
|
||||
"insights/schemas.ts#trendSentiment",
|
||||
],
|
||||
iosOnly: {
|
||||
reason:
|
||||
"the web target panels show the band and where the value sits, never a change, so there is nothing to colour; the native targets page colours the change, placed on the dashboard's seven-day basis",
|
||||
sameResolver: {
|
||||
pattern: VERDICT_RESOLVER,
|
||||
files: [
|
||||
"src/lib/targets/build-response.ts",
|
||||
"src/lib/dashboard/snapshot.ts",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "GET /api/insights/derived VITALS_BASELINE value.direction",
|
||||
props: [],
|
||||
readers: [
|
||||
{
|
||||
file: "src/components/insights/derived/vitals-dashboard.tsx",
|
||||
pattern: /v\.direction\?\.direction/,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
field: "GET /api/measurements/series direction / change / referenceLines",
|
||||
props: [
|
||||
"measurements.ts#direction",
|
||||
"measurements.ts#change",
|
||||
"measurements.ts#referenceLines",
|
||||
"trend-verdict.ts#sentiment",
|
||||
],
|
||||
iosOnly: {
|
||||
reason:
|
||||
"the web charts are fed by `/api/measurements/series-batch`, which carries no verdict, change or reference line; the web colours a chart's change from the tile verdict in `tiles.directions` and draws the person's bands from the snapshot's `targetBands`. The single-series read is the native chart source.",
|
||||
sameResolver: {
|
||||
pattern: VERDICT_RESOLVER,
|
||||
files: [
|
||||
"src/app/api/measurements/series/route.ts",
|
||||
"src/lib/dashboard/snapshot.ts",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "GET /api/personal-records benchmark",
|
||||
props: ["awards.ts#benchmark"],
|
||||
readers: [
|
||||
{
|
||||
file: "src/components/insights/personal-record-badge.tsx",
|
||||
pattern: /fresh\.benchmark/,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
field: "GET /api/mood/insights patterns",
|
||||
props: ["mood.ts#patterns"],
|
||||
readers: [
|
||||
{
|
||||
file: "src/components/insights/mood/mood-insights-breakdowns.tsx",
|
||||
pattern: /data\.patterns/,
|
||||
},
|
||||
{
|
||||
file: "src/components/insights/mood/mood-patterns.tsx",
|
||||
pattern: /pattern\.values\.subject/,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
field: "POST /api/cycle/period absorbed",
|
||||
props: ["cycle.ts#absorbed"],
|
||||
readers: [
|
||||
{
|
||||
file: "src/components/cycle/use-cycle.ts",
|
||||
pattern: /data\?\.absorbed/,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
field: "DELETE /api/cycle/day-logs/{id} (Prefer) and PATCH restored",
|
||||
props: ["cycle.ts#restored", "trend-verdict.ts#restored"],
|
||||
readers: [
|
||||
{
|
||||
file: "src/components/cycle/use-cycle.ts",
|
||||
pattern: /Prefer: "return=representation"/,
|
||||
},
|
||||
{
|
||||
file: "src/components/cycle/use-cycle.ts",
|
||||
pattern: /data\?\.restored/,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
field: "GET /api/integrations/healthkit metricFreshness[].lastBackfillAt",
|
||||
props: [],
|
||||
readers: [
|
||||
{
|
||||
file: "src/components/settings/integrations/apple-health-card.tsx",
|
||||
pattern: /entry\.lastBackfillAt/,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Every response property typed by a resolved-verdict schema, as
|
||||
* `<route module>#<property>`: `name: schema`, `name: z.array(schema)` and
|
||||
* `name: z.record(z.string(), schema)`, across line breaks. The schema
|
||||
* definitions themselves count too (`seriesChange.sentiment`,
|
||||
* `cycleDeleteOutcome.restored`), since they reach the wire nested.
|
||||
*/
|
||||
function discoverResolvedProps(
|
||||
sources: ReadonlyArray<{ rel: string; code: string }>,
|
||||
): string[] {
|
||||
const names = RESOLVED_SCHEMAS.join("|");
|
||||
const prop = new RegExp(
|
||||
String.raw`(\w+)\s*:\s*(?:z\s*\.\s*(?:array|record)\(\s*(?:z\s*\.\s*string\(\)\s*,\s*)?)?(?:${names})\b`,
|
||||
"g",
|
||||
);
|
||||
const out: string[] = [];
|
||||
for (const { rel, code } of sources) {
|
||||
for (const match of code.matchAll(prop)) out.push(`${rel}#${match[1]}`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function routeSources(): Array<{ rel: string; code: string }> {
|
||||
return walkSourceFiles(join(ROOT, ROUTES_DIR), { floor: 20 })
|
||||
.filter((file) => !/__tests__|\.test\.ts$/.test(file))
|
||||
.map((file) => ({
|
||||
rel: file,
|
||||
code: stripComments(readFileSync(join(ROOT, ROUTES_DIR, file), "utf8")),
|
||||
}));
|
||||
}
|
||||
|
||||
function unescaped(pattern: RegExp): string {
|
||||
return pattern.source.replace(/\\/g, "");
|
||||
}
|
||||
|
||||
describe("v1.43 resolved fields reach the web, or say why not", () => {
|
||||
const discovered = discoverResolvedProps(routeSources());
|
||||
|
||||
it("discovers the resolved fields from the route modules", () => {
|
||||
// A matcher that stopped matching would let every field go unclaimed
|
||||
// without anyone noticing; the inventory has twelve today.
|
||||
expect(discovered.length).toBeGreaterThanOrEqual(12);
|
||||
expect(
|
||||
discoverResolvedProps([
|
||||
{
|
||||
rel: "x.ts",
|
||||
code: "a: trendVerdict.nullable(), b: z\n .array(moodPattern), c: z.record(z.string(), trendVerdict)",
|
||||
},
|
||||
]),
|
||||
).toEqual(["x.ts#a", "x.ts#b", "x.ts#c"]);
|
||||
});
|
||||
|
||||
it("claims every discovered field exactly once, and nothing else", () => {
|
||||
const claimed = V143_FIELDS.flatMap((entry) => entry.props);
|
||||
const unclaimed = discovered.filter((key) => !claimed.includes(key));
|
||||
expect(
|
||||
unclaimed,
|
||||
"a resolved field has no web reader and no iOS-only reason: add an entry",
|
||||
).toEqual([]);
|
||||
const stale = claimed.filter((key) => !discovered.includes(key));
|
||||
expect(stale, "an entry claims a field the routes no longer serve").toEqual(
|
||||
[],
|
||||
);
|
||||
expect(new Set(claimed).size).toBe(claimed.length);
|
||||
});
|
||||
|
||||
for (const entry of V143_FIELDS) {
|
||||
it(`${entry.field} ${entry.readers ? "has a web reader" : "is iOS-only for a stated reason"}`, () => {
|
||||
expect(
|
||||
(entry.readers ? 1 : 0) + (entry.iosOnly ? 1 : 0),
|
||||
"an entry has a web reader or an iOS-only reason, not both or neither",
|
||||
).toBe(1);
|
||||
if (entry.readers) {
|
||||
expect(entry.readers.length).toBeGreaterThan(0);
|
||||
// The reader must read the field itself, not something beside it.
|
||||
for (const key of entry.props) {
|
||||
const name = key.split("#")[1]!;
|
||||
expect(
|
||||
entry.readers.some((reader) =>
|
||||
unescaped(reader.pattern).includes(name),
|
||||
),
|
||||
`no reader pattern names \`${name}\``,
|
||||
).toBe(true);
|
||||
}
|
||||
for (const reader of entry.readers) {
|
||||
const code = stripComments(
|
||||
readFileSync(join(ROOT, reader.file), "utf8"),
|
||||
);
|
||||
expect(
|
||||
reader.pattern.test(code),
|
||||
`${reader.file} no longer reads ${entry.field} (${reader.pattern})`,
|
||||
).toBe(true);
|
||||
}
|
||||
} else {
|
||||
expect(entry.iosOnly!.reason.length).toBeGreaterThan(60);
|
||||
for (const file of entry.iosOnly!.sameResolver?.files ?? []) {
|
||||
const code = stripComments(readFileSync(join(ROOT, file), "utf8"));
|
||||
expect(
|
||||
entry.iosOnly!.sameResolver!.pattern.test(code),
|
||||
`${file} no longer resolves ${entry.field} with the shared resolver`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -96,10 +96,15 @@ describe("S1 — the direct session-resolution set is frozen", () => {
|
||||
"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",
|
||||
// v1.43 (#959) — the single-sign-on claim start, same argument.
|
||||
"app/api/auth/claim/sso/route.ts":
|
||||
"refuses to start a single-sign-on claim over a live session",
|
||||
// Anonymous IdP callback. Probes for an already-valid session so a
|
||||
// replayed callback redirects instead of minting a second identity.
|
||||
// replayed callback redirects instead of minting a second identity, and
|
||||
// (v1.43, #959) so a single-sign-on claim is refused over a live session,
|
||||
// as the claim routes refuse it.
|
||||
"app/api/auth/oidc/callback/route.ts":
|
||||
"duplicate-callback probe on an otherwise anonymous route",
|
||||
"duplicate-callback probe and the claim's live-session refusal on an otherwise anonymous route",
|
||||
// Public liveness probe. The session read only decides whether the body
|
||||
// carries operational detail; the status code is computed without it.
|
||||
"app/api/health/route.ts": "admin-only detail on a public probe",
|
||||
@@ -367,6 +372,48 @@ describe("S2 — the URL-token authentication set is frozen", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("S4 — the single-sign-on claim cookie set is frozen", () => {
|
||||
/**
|
||||
* v1.43 (#959). A third way to act without a session: the sealed `hl_claim`
|
||||
* cookie carries a managed-profile claim from `POST /api/auth/claim/sso`
|
||||
* through the OIDC round trip, and the callback turns it — together with
|
||||
* the provider's subject — into somebody's account. It is neither a session
|
||||
* nor a URL credential, so neither leg above sees it.
|
||||
*
|
||||
* The handover token itself still only travels in the claim routes' bodies
|
||||
* and the claim page's query string (S2); the cookie names the link by id.
|
||||
* What this freezes is who may mint the cookie and who may act on it.
|
||||
*/
|
||||
const CLAIM_COOKIE_ALLOWLIST: Record<string, string> = {
|
||||
"app/api/auth/claim/sso/route.ts":
|
||||
"mints the cookie, only after checking the token and the username",
|
||||
"app/api/auth/oidc/login/route.ts":
|
||||
"requires the cookie for intent=claim and binds it into the OIDC state",
|
||||
"app/api/auth/oidc/callback/route.ts":
|
||||
"binds the provider's subject to the profile the cookie names",
|
||||
};
|
||||
|
||||
const CLAIM_COOKIE_USE =
|
||||
/\b(readClaimCookie|setClaimCookie|claimMatchesBinding|clearClaimCookie)\s*\(|\bCLAIM_COOKIE\b|["']hl_claim["']/;
|
||||
|
||||
it("no file under src/app touches the claim cookie unlisted", () => {
|
||||
const users = appFilesMatching(CLAIM_COOKIE_USE);
|
||||
expect(users.length).toBeGreaterThan(0);
|
||||
expect(users).toEqual(Object.keys(CLAIM_COOKIE_ALLOWLIST).sort());
|
||||
});
|
||||
|
||||
it("only the single-sign-on start mints it", () => {
|
||||
const minters = appFilesMatching(/\bsetClaimCookie\s*\(/);
|
||||
expect(minters).toEqual(["app/api/auth/claim/sso/route.ts"]);
|
||||
});
|
||||
|
||||
it("every allowlist entry carries a reason", () => {
|
||||
for (const [rel, reason] of Object.entries(CLAIM_COOKIE_ALLOWLIST)) {
|
||||
expect(reason.trim().length, `${rel} has no reason`).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("S3 — the two families stay distinct", () => {
|
||||
/**
|
||||
* A file that both resolves a session AND authenticates by a URL token is
|
||||
|
||||
@@ -853,12 +853,16 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
},
|
||||
"app/api/biomarkers/route.ts": {
|
||||
domain: "labs",
|
||||
why: "The record's biomarker catalogue: the analytes this account tracks and the ranges it tracks them against. Adding a marker is a delegable write; the PUT and DELETE on the sibling route are refused.",
|
||||
why: "The record's biomarker catalogue: the analytes this account tracks and the ranges it tracks them against. Adding a marker is a delegable write; the PUT and DELETE on the sibling route are manage-level.",
|
||||
},
|
||||
"app/api/biomarkers/[id]/route.ts": {
|
||||
domain: "labs",
|
||||
why: "One biomarker of the record, fetch-then-guard against the resolved user.",
|
||||
},
|
||||
"app/api/biomarkers/[id]/merge/route.ts": {
|
||||
domain: "labs",
|
||||
why: "Merging markers of the record into one (v1.43): every read and write is narrowed to the resolved user, and the sources must be that record's markers.",
|
||||
},
|
||||
"app/api/allergies/route.ts": {
|
||||
domain: "profile",
|
||||
why: "The record's allergy list — the single most useful thing a caregiver can read, and a plain list of the owner's own rows. The POST beside it is NOT delegable and is deliberately absent from the write literal below: the only surface that posts to it lives under `/settings`, which a switch closes, so admitting the write would freeze a permission ahead of any caller for it. The route comment carries the argument.",
|
||||
@@ -887,6 +891,18 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "profile",
|
||||
why: "The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.",
|
||||
},
|
||||
"app/api/anamnesis/life-circumstances/route.ts": {
|
||||
domain: "profile",
|
||||
why: "The record's life circumstances (v1.43), the structured sibling of the facts above and under the same `profile` grant. Read-only: the section switches and confirmations are written through their own owner-only routes; AUDIT-C through the manage-level pair below.",
|
||||
},
|
||||
"app/api/anamnesis/audit-c/route.ts": {
|
||||
domain: "profile",
|
||||
why: "Recording an AUDIT-C (v1.43) into the record, the alcohol section of the life circumstances. Reached only through its MANAGE arm, the level allergies and family history are written at; the entry is scored on the server against the record's profile.",
|
||||
},
|
||||
"app/api/anamnesis/audit-c/[id]/route.ts": {
|
||||
domain: "profile",
|
||||
why: "Removing one AUDIT-C entry of the record, scoped `userId: user.id` and `instrument: AUDIT_C` in the guarded update, so a foreign id or a screener id is a 404. Reached only through its MANAGE arm.",
|
||||
},
|
||||
"app/api/profile/summary/route.ts": {
|
||||
domain: "profile",
|
||||
why: "The bounded shared profile read combines only the record's allergies, family history, and current lifestyle facts. It carries no settings, AI configuration, mutations, or encrypted free text.",
|
||||
@@ -931,6 +947,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "measurements",
|
||||
why: "The record's sleep rhythm, built from `buildSleepRhythm(user.id)` and gated on the record's sleep module.",
|
||||
},
|
||||
"app/api/nutrients/catalog/route.ts": {
|
||||
domain: "measurements",
|
||||
why: "The nutrient catalogue as the composition form reads it (v1.43): codes, units and upper limits, labels in the record's language. Reference data the record's module gate opens; nothing of the record itself.",
|
||||
},
|
||||
"app/api/nutrients/route.ts": {
|
||||
domain: "measurements",
|
||||
why: "The record's nutrient overview. It reads the last ingest audit row to date the data — integration-adjacent metadata, and no credential, endpoint or token crosses the wire.",
|
||||
@@ -1093,6 +1113,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "medications",
|
||||
why: "One medication of the record, fetch-then-guard against the resolved user before anything is serialised.",
|
||||
},
|
||||
"app/api/medications/[id]/ingredients/route.ts": {
|
||||
domain: "medications",
|
||||
why: "One medication's composition per unit (v1.43), behind the ownership check against the resolved record. The PUT is the route's only verb and is manage-level; the label read beside it stays owner-only, because provider work is never admitted inside somebody else's record.",
|
||||
},
|
||||
"app/api/medications/[id]/cadence/route.ts": {
|
||||
domain: "medications",
|
||||
why: "One medication's cadence, behind `assertMedicationOwnership(id, user.id)` — the shared guard, resolved against the record.",
|
||||
@@ -1601,14 +1625,29 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
|
||||
},
|
||||
"app/api/biomarkers/[id]/route.ts": {
|
||||
domain: "labs",
|
||||
conditions: ["C4"],
|
||||
why: "Editing an analyte's reference range, the one admitted edit whose error is silent, which is why C4 puts it in the feed. The DELETE beside it stays refused.",
|
||||
conditions: ["C3", "C4"],
|
||||
why: "Editing an analyte's reference range, the one admitted edit whose error is silent, which is why C4 puts it in the feed. The DELETE beside it resolves through the same labs authority since v1.43 instead of a bare requireAuth; it hard-deletes the marker with its readings, so C3 files the name, unit and reading count it took.",
|
||||
},
|
||||
"app/api/biomarkers/[id]/merge/route.ts": {
|
||||
domain: "labs",
|
||||
conditions: ["C3"],
|
||||
why: "Merging markers moves their readings into one and deletes the sources, with no undo, so it sits at the same level as the delete beside it. C3: each deleted source files its id and how many readings went with it; the name stays in the merge ledger. The shared record-write ceiling is charged to the actor, like every record write.",
|
||||
},
|
||||
"app/api/allergies/route.ts": {
|
||||
domain: "profile",
|
||||
conditions: [],
|
||||
why: "Recording an allergy. Reachable through the record's settings on a managed profile, dormant on an invited one, and argued as such.",
|
||||
},
|
||||
"app/api/anamnesis/audit-c/route.ts": {
|
||||
domain: "profile",
|
||||
conditions: ["C1"],
|
||||
why: "Recording an AUDIT-C (v1.43) in the record a manager runs, like an allergy. C1: the 30-an-hour bucket keys on the actor. Arithmetic on three answers, no generation; the trail files the entry id and instrument, never the answers.",
|
||||
},
|
||||
"app/api/anamnesis/audit-c/[id]/route.ts": {
|
||||
domain: "profile",
|
||||
conditions: [],
|
||||
why: "Removing an AUDIT-C entry is a soft delete (`deletedAt`), so the row stays to reconstruct and the delete needs no C3; it is the counterpart of the create beside it.",
|
||||
},
|
||||
"app/api/allergies/[id]/route.ts": {
|
||||
domain: "profile",
|
||||
conditions: ["C3", "C4"],
|
||||
@@ -1754,6 +1793,11 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
|
||||
conditions: ["C4", "C7"],
|
||||
why: "Replacing a medication and its schedule. C4, and C7 because the previous cadence and the tombstoned pending slots are the only unrecoverable part. The DELETE beside it stays refused.",
|
||||
},
|
||||
"app/api/medications/[id]/ingredients/route.ts": {
|
||||
domain: "medications",
|
||||
conditions: ["C1", "C4"],
|
||||
why: "Replacing a supplement's composition (v1.43), which rewrites what every taken dose adds to the record's micronutrients. C1, and C4 because the replaced composition is filed as one comparable line. The recompute it queues is arithmetic, not generation.",
|
||||
},
|
||||
"app/api/medications/[id]/schedule-revisions/route.ts": {
|
||||
domain: "medications",
|
||||
conditions: [],
|
||||
@@ -2104,8 +2148,24 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
*
|
||||
* v1.42.4 -- the Labs list's per-marker overview joins the record list
|
||||
* beside `GET /api/labs`, the rows it groups. 255 -> 256.
|
||||
*
|
||||
* v1.43 -- two reads join the record list: the life circumstances beside the
|
||||
* facts (`profile`) and the nutrient catalogue beside the nutrient reads
|
||||
* (`measurements`). The biomarker DELETE joins the manage literal on a module
|
||||
* that was already there. 256 -> 258.
|
||||
*
|
||||
* v1.43 -- merging biomarkers joins the record list and the manage literal
|
||||
* (D1223). 258 -> 260.
|
||||
*
|
||||
* v1.43 -- a supplement's composition joins the record list and the manage
|
||||
* literal: its PUT replaces the composition every taken dose counts with.
|
||||
* 260 -> 262.
|
||||
*
|
||||
* v1.43 -- AUDIT-C's create and soft delete join the record list and the
|
||||
* manage literal under `profile`, the level allergies are written at.
|
||||
* 262 -> 266.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 256;
|
||||
const FROZEN_ENTRY_COUNT = 266;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
@@ -2866,7 +2926,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(87);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(92);
|
||||
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(
|
||||
({ handlerModule, action, level }) =>
|
||||
|
||||
@@ -92,6 +92,8 @@ const PINNED_AFFORDANCES: Record<
|
||||
"text-success": 1,
|
||||
},
|
||||
"src/components/admin/assistant-section.tsx": { "toast.success": 1 },
|
||||
// v1.43 — the AI usage bounds: a settings save, not a write with counts.
|
||||
"src/components/admin/ai-usage-section.tsx": { "toast.success": 1 },
|
||||
"src/components/admin/backups-section.tsx": { "toast.success": 4 },
|
||||
"src/components/admin/central-codex-section.tsx": { "toast.success": 2 },
|
||||
"src/components/admin/danger-zone-section.tsx": { "text-success": 1 },
|
||||
@@ -105,6 +107,11 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/admin/measurement-maintenance-card.tsx": {
|
||||
"toast.success": 1,
|
||||
},
|
||||
// Fires only on a 202: the dry run was queued, or the method was applied.
|
||||
// The dry run's outcome itself is the status line.
|
||||
"src/components/admin/pulse-day-mean-card.tsx": {
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/admin/invite-tokens-section.tsx": {
|
||||
"text-success": 2,
|
||||
"toast.success": 3,
|
||||
@@ -370,6 +377,11 @@ const PINNED_AFFORDANCES: Record<
|
||||
"toast.success": 2,
|
||||
},
|
||||
// The saved-credentials confirmation line.
|
||||
// Connected, a fetch queued, the home saved: confirmations of a request
|
||||
// that wrote no record (the fetch itself reports through `health`).
|
||||
"src/components/settings/integrations/dawarich-card.tsx": {
|
||||
"toast.success": 3,
|
||||
},
|
||||
"src/components/settings/integrations/credentials-form.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
@@ -496,7 +508,17 @@ const RESULT_SURFACES: ReadonlyArray<{
|
||||
forbidsMarkers: true,
|
||||
},
|
||||
{
|
||||
path: "src/components/records/health-profile-facts-manager.tsx",
|
||||
path: "src/components/records/life-circumstances-manager.tsx",
|
||||
through: /toastWrittenOutcome/,
|
||||
forbidsMarkers: true,
|
||||
},
|
||||
{
|
||||
path: "src/components/records/audit-c-panel.tsx",
|
||||
through: /toastWrittenOutcome/,
|
||||
forbidsMarkers: true,
|
||||
},
|
||||
{
|
||||
path: "src/components/records/free-text-take-over.tsx",
|
||||
through: /toastWrittenOutcome/,
|
||||
forbidsMarkers: true,
|
||||
},
|
||||
|
||||
@@ -47,7 +47,6 @@ const REQUIRED_KEYS = [
|
||||
"insights.healthScore.versionAndNoise",
|
||||
"insights.healthScore.methodVersion",
|
||||
"insights.healthScore.source",
|
||||
"records.profileFacts.save",
|
||||
"records.allergies.add",
|
||||
"records.allergies.addFirst",
|
||||
"records.family.add",
|
||||
@@ -62,6 +61,9 @@ const OBSOLETE_KEYS = [
|
||||
"insights.healthScore.composition",
|
||||
"documents.detail.summary.regenerate",
|
||||
"records.profileFacts.correct",
|
||||
// v1.43 — the profile-facts card became the life-circumstances card, whose
|
||||
// sections save on their own.
|
||||
"records.profileFacts.save",
|
||||
"settings.nightscoutPrivateHost",
|
||||
"settings.nightscoutPrivateHostHelp",
|
||||
] as const;
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => {
|
||||
});
|
||||
|
||||
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(87);
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(92);
|
||||
expect(
|
||||
new Set(
|
||||
ADMITTED_MUTATING_HANDLERS.map(
|
||||
|
||||
@@ -535,6 +535,10 @@ function zodObjectShape(path: string, schemaName: string): ZodObjectShape {
|
||||
let current = expression;
|
||||
while (ts.isCallExpression(current)) {
|
||||
const callee = current.expression;
|
||||
// The shared instant helper (`@/lib/validations/datetime`) is a leaf.
|
||||
if (ts.isIdentifier(callee) && callee.text === "isoDateTime") {
|
||||
return [prefix];
|
||||
}
|
||||
expect(
|
||||
ts.isPropertyAccessExpression(callee),
|
||||
`${prefix || schemaName} contains an unsupported Zod call: ${current.getText(file)}`,
|
||||
@@ -754,9 +758,20 @@ const OCR_COLLECTION_CONSUMERS: Readonly<Record<string, readonly string[]>> = {
|
||||
skipped: ["result.skipped.length"],
|
||||
};
|
||||
|
||||
/**
|
||||
* `/api/<provider>/sync` routes that only queue a background job and answer
|
||||
* 202 `{ enqueued }`: nothing is written while the request runs, so there is
|
||||
* no written outcome to resolve. Each entry says why.
|
||||
*/
|
||||
const QUEUE_ONLY_SYNC_ROUTES: Readonly<Record<string, string>> = {
|
||||
dawarich:
|
||||
"Dawarich daily values are fetched by the dawarich-sync job; the route answers 202 { enqueued } and the outcome reaches the card through the connection health, not this response.",
|
||||
};
|
||||
|
||||
function discoveredSyncRoutes(): Array<{ id: string; path: string }> {
|
||||
return readdirSync(API_ROOT, { withFileTypes: true }).flatMap((entry) => {
|
||||
if (!entry.isDirectory()) return [];
|
||||
if (entry.name in QUEUE_ONLY_SYNC_ROUTES) return [];
|
||||
const path = join(API_ROOT, entry.name, "sync", "route.ts");
|
||||
return existsSync(path) ? [{ id: entry.name, path }] : [];
|
||||
});
|
||||
@@ -1078,6 +1093,13 @@ describe("written-outcome response consumer guard", () => {
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
for (const [id, reason] of Object.entries(QUEUE_ONLY_SYNC_ROUTES)) {
|
||||
expect(
|
||||
existsSync(join(API_ROOT, id, "sync", "route.ts")),
|
||||
`${id} is listed as a queue-only sync route but has none`,
|
||||
).toBe(true);
|
||||
expect(reason.length).toBeGreaterThan(40);
|
||||
}
|
||||
const discovered = discoveredSyncRoutes().sort((a, b) =>
|
||||
a.id.localeCompare(b.id),
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ import { AiServerKeySection } from "@/components/admin/ai-server-key-section";
|
||||
import { ProviderHealthSection } from "@/components/admin/provider-health-section";
|
||||
import { CentralCodexSection } from "@/components/admin/central-codex-section";
|
||||
import { AssistantSection } from "@/components/admin/assistant-section";
|
||||
import { AiUsageSection } from "@/components/admin/ai-usage-section";
|
||||
import { CoachFeedbackSection } from "@/components/admin/coach-feedback-section";
|
||||
import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-section";
|
||||
import { AppLogPreviewSection } from "@/components/admin/app-log-preview-section";
|
||||
@@ -36,9 +37,11 @@ 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 { SupportReportCard } from "@/components/admin/support-report-card";
|
||||
import { RevealWhenSettled } from "@/components/ui/reveal-when-settled";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { MeasurementMaintenanceCard } from "@/components/admin/measurement-maintenance-card";
|
||||
import { PulseDayMeanCard } from "@/components/admin/pulse-day-mean-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";
|
||||
@@ -69,8 +72,12 @@ export function AdminSectionRenderer({
|
||||
>
|
||||
<SectionFrame>
|
||||
<SystemStatusSection />
|
||||
{/* v1.43 — the redacted report for a bug report. */}
|
||||
<SupportReportCard />
|
||||
{/* v1.42 — the table maintenance after the tombstone purge. */}
|
||||
<MeasurementMaintenanceCard />
|
||||
{/* v1.43 — the pulse day-mean method, dry run before apply. */}
|
||||
<PulseDayMeanCard />
|
||||
</SectionFrame>
|
||||
</RevealWhenSettled>
|
||||
);
|
||||
@@ -102,6 +109,7 @@ export function AdminSectionRenderer({
|
||||
return (
|
||||
<SectionFrame>
|
||||
<AssistantSection />
|
||||
<AiUsageSection />
|
||||
<AiServerKeySection />
|
||||
<CentralCodexSection />
|
||||
<ProviderHealthSection />
|
||||
|
||||
@@ -165,6 +165,36 @@ describe("AI capability route inventory", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Capabilities no route asks, each with its reason. Every other capability is
|
||||
* asked by at least one route on `AI_ROUTES`, so a key cannot be added to the
|
||||
* table and forgotten at the edge.
|
||||
*
|
||||
* - `reactionLines`: written by a job after a new reading arrives, never on
|
||||
* request; the job asks `aiCapabilityForJob`.
|
||||
*/
|
||||
const CAPABILITIES_WITHOUT_ROUTE: Readonly<
|
||||
Partial<Record<(typeof AI_CAPABILITY_KEYS)[number], string>>
|
||||
> = {
|
||||
reactionLines: "job only",
|
||||
};
|
||||
|
||||
describe("every capability reaches a route", () => {
|
||||
it("is asked by an AI route, or named with its reason", () => {
|
||||
const asked = new Set(
|
||||
Object.values(AI_ROUTES).flatMap((entry) => [...entry.capabilities]),
|
||||
);
|
||||
const orphaned = AI_CAPABILITY_KEYS.filter(
|
||||
(key) => !asked.has(key) && !(key in CAPABILITIES_WITHOUT_ROUTE),
|
||||
);
|
||||
expect(orphaned).toEqual([]);
|
||||
const stale = Object.keys(CAPABILITIES_WITHOUT_ROUTE).filter((key) =>
|
||||
asked.has(key as (typeof AI_CAPABILITY_KEYS)[number]),
|
||||
);
|
||||
expect(stale, "asked by a route now: drop it from the list").toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/** The route that queues each kind of background run. */
|
||||
const RUN_QUEUED_BY: Record<DocumentAiRunKindValue, string> = {
|
||||
DOCUMENT_INDEX: "src/app/api/documents/inbound/[id]/index/route.ts",
|
||||
@@ -172,6 +202,8 @@ const RUN_QUEUED_BY: Record<DocumentAiRunKindValue, string> = {
|
||||
DOCUMENT_SUMMARY: "src/app/api/documents/inbound/[id]/summary/route.ts",
|
||||
DOCUMENT_SUGGEST: "src/app/api/documents/inbound/[id]/suggest/route.ts",
|
||||
DOCUMENT_EXTRACT: "src/app/api/documents/inbound/[id]/extract/route.ts",
|
||||
SUPPLEMENT_LABEL_EXTRACT:
|
||||
"src/app/api/medications/[id]/ingredients/extract/route.ts",
|
||||
};
|
||||
|
||||
describe("background run kinds", () => {
|
||||
|
||||
@@ -92,6 +92,11 @@ export const AI_ROUTES: Readonly<Record<string, AiRouteEntry>> = {
|
||||
capabilities: ["labsOcr"],
|
||||
why: "Reads a lab report image with a model.",
|
||||
},
|
||||
"src/app/api/medications/[id]/ingredients/extract/route.ts": {
|
||||
kind: "action",
|
||||
capabilities: ["supplementLabel"],
|
||||
why: "Reads a supplement label photo with a model into a proposed composition.",
|
||||
},
|
||||
"src/app/api/medications/extract/route.ts": {
|
||||
kind: "action",
|
||||
capabilities: ["medicationExtract"],
|
||||
|
||||
@@ -252,6 +252,11 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
"src/app/api/labs/restore/route.ts",
|
||||
"src/app/api/biomarkers/route.ts",
|
||||
"src/app/api/biomarkers/[id]/route.ts",
|
||||
// v1.43 — merging markers and filing a reading against its report or visit
|
||||
// are the same row-store operations: the history and its links must stay
|
||||
// consistent while the Labs surface is hidden.
|
||||
"src/app/api/biomarkers/[id]/merge/route.ts",
|
||||
"src/app/api/labs/[id]/links/route.ts",
|
||||
// v1.18.9 — the Lab-OCR ingestion routes share the LabResult data-layer
|
||||
// reasoning: the labs module gates the SURFACE (the Labs page + scan
|
||||
// affordance), not the row store. The extract route is read-only vision
|
||||
@@ -315,6 +320,9 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
|
||||
// SURFACE (the detail-page tab + the insights summary), not the row store.
|
||||
"src/app/api/medications/[id]/efficacy/route.ts",
|
||||
"src/app/api/medications/[id]/efficacy/target/route.ts",
|
||||
// v1.43 — a medication's links to its visits, documents and indication are
|
||||
// raw link CRUD over the person's own rows, like the vaccination links.
|
||||
"src/app/api/medications/[id]/links/route.ts",
|
||||
// ── INFRA / UI-ONLY ───────────────────────────────────────────────
|
||||
// Static FHIR CapabilityStatement — server metadata, no user data.
|
||||
"src/app/api/fhir/metadata/route.ts",
|
||||
|
||||
@@ -136,4 +136,38 @@ describe("GET /api/admin/app-logs", () => {
|
||||
expect(json.error).toBe("Validation failed");
|
||||
expect(json.details.issues.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("finds an error by the first 8 characters of its id in the problems ring", async () => {
|
||||
appendLogEvent(
|
||||
makeEvent({ level: "error", request_id: "3f9a2c1b-1111-4000-8000-0000" }),
|
||||
);
|
||||
for (let i = 0; i < 600; i++) {
|
||||
appendLogEvent(makeEvent({ level: "info" }));
|
||||
}
|
||||
const gone = await GET(req("requestId=3f9a2c1b"));
|
||||
const goneJson = (await gone.json()) as { data: { events: WideEvent[] } };
|
||||
expect(goneJson.data.events).toHaveLength(0);
|
||||
|
||||
const res = await GET(req("requestId=3f9a2c1b&ring=problems"));
|
||||
const json = (await res.json()) as {
|
||||
data: {
|
||||
events: WideEvent[];
|
||||
meta: { problemsBufferMax: number; ring: string };
|
||||
};
|
||||
};
|
||||
expect(json.data.events.map((e) => e.request_id)).toEqual([
|
||||
"3f9a2c1b-1111-4000-8000-0000",
|
||||
]);
|
||||
expect(json.data.meta.problemsBufferMax).toBe(1000);
|
||||
expect(json.data.meta.ring).toBe("problems");
|
||||
});
|
||||
|
||||
it("refuses a request id shorter than 8 characters or outside the id alphabet", async () => {
|
||||
for (const bad of ["3f9a2c1", "3f9a2c1b%20OR%201", "a".repeat(65)]) {
|
||||
const res = await GET(req(`requestId=${bad}`));
|
||||
expect(res.status).toBe(422);
|
||||
}
|
||||
const ring = await GET(req("ring=everything"));
|
||||
expect(ring.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,45 @@
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { readLogBuffer, LOG_BUFFER_MAX } from "@/lib/logging/in-memory-buffer";
|
||||
import {
|
||||
readLogBuffer,
|
||||
LOG_BUFFER_MAX,
|
||||
PROBLEMS_BUFFER_MAX,
|
||||
} from "@/lib/logging/in-memory-buffer";
|
||||
import { redactSecrets } from "@/lib/logging/redact";
|
||||
import type { WideEvent } from "@/lib/logging/types";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const querySchema = z.object({
|
||||
// The error id a person reads out is the first 8 characters of the request
|
||||
// id; the full id (up to 64) matches as well. Same alphabet as an accepted
|
||||
// incoming `x-request-id`.
|
||||
requestId: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[A-Za-z0-9-]{8,64}$/)
|
||||
.optional(),
|
||||
ring: z.enum(["all", "problems"]).default("all"),
|
||||
traceId: z.string().trim().min(1).max(200).optional(),
|
||||
level: z.enum(["debug", "info", "warn", "error"]).optional(),
|
||||
action: z.string().trim().min(1).max(200).optional(),
|
||||
since: z.string().datetime().optional(),
|
||||
until: z.string().datetime().optional(),
|
||||
limit: z.coerce.number().int().min(1).max(500).default(200),
|
||||
since: isoDateTime({ offset: false }).optional(),
|
||||
until: isoDateTime({ offset: false }).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(PROBLEMS_BUFFER_MAX).default(200),
|
||||
});
|
||||
|
||||
/**
|
||||
* Admin endpoint: tail of the per-process in-memory wide-event ring buffer.
|
||||
*
|
||||
* `ring=problems` reads the second ring, which keeps the last 1,000 `warn` and
|
||||
* `error` events only, so a failure a person reported an hour ago is still
|
||||
* there after the main ring has turned over. `requestId` finds the event
|
||||
* behind an error id by prefix.
|
||||
*
|
||||
* The buffer is populated by `transports.emitEvent()` so the result mirrors
|
||||
* what would be shipped to Loki (sampler-gated). Per-process and volatile —
|
||||
* see `src/lib/logging/in-memory-buffer.ts` for the limitations.
|
||||
@@ -36,6 +55,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const result = querySchema.safeParse({
|
||||
requestId: searchParams.get("requestId") ?? undefined,
|
||||
ring: searchParams.get("ring") ?? undefined,
|
||||
traceId: searchParams.get("traceId") ?? undefined,
|
||||
level: searchParams.get("level") ?? undefined,
|
||||
action: searchParams.get("action") ?? undefined,
|
||||
@@ -47,6 +68,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const parsed = result.data;
|
||||
|
||||
const events = readLogBuffer({
|
||||
ring: parsed.ring,
|
||||
requestId: parsed.requestId,
|
||||
traceId: parsed.traceId,
|
||||
level: parsed.level,
|
||||
action: parsed.action,
|
||||
@@ -62,6 +85,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
meta: {
|
||||
total: redacted.length,
|
||||
bufferMax: LOG_BUFFER_MAX,
|
||||
problemsBufferMax: PROBLEMS_BUFFER_MAX,
|
||||
ring: parsed.ring,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { redactSecrets } from "@/lib/logging/redact";
|
||||
import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { isoDateTime } from "@/lib/validations/datetime";
|
||||
|
||||
/**
|
||||
* v1.4.16 phase B4: parse the union of legacy + new query params. Legacy
|
||||
@@ -27,8 +28,8 @@ const querySchema = z.object({
|
||||
actor: z.string().trim().min(1).max(200).optional(),
|
||||
action: z.string().trim().min(1).max(200).optional(),
|
||||
target: z.string().trim().min(1).max(200).optional(),
|
||||
since: z.string().datetime().optional(),
|
||||
until: z.string().datetime().optional(),
|
||||
since: isoDateTime({ offset: false }).optional(),
|
||||
until: isoDateTime({ offset: false }).optional(),
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* `GET/POST /api/admin/maintenance/pulse-day-mean` — the pulse day-mean
|
||||
* method's dry run and apply step. Admin only; apply needs a finished dry run
|
||||
* from the last seven days, and nothing starts while a run is active.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
$queryRaw: vi.fn(),
|
||||
appSettings: { findUnique: vi.fn(), upsert: vi.fn() },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() }));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import {
|
||||
activePulseDayMeanMethod,
|
||||
setActivePulseDayMeanMethod,
|
||||
} from "@/lib/measurements/day-statistic";
|
||||
|
||||
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 URL = "http://localhost/api/admin/maintenance/pulse-day-mean";
|
||||
const get = () =>
|
||||
(GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest(URL),
|
||||
);
|
||||
const post = (body: unknown) =>
|
||||
(POST as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest(URL, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
|
||||
const NOW = new Date("2026-10-16T12:00:00Z");
|
||||
const send = vi.fn();
|
||||
|
||||
function settings(
|
||||
dryRun: unknown,
|
||||
method = "hour_mean_v1",
|
||||
appliedAt: Date | null = null,
|
||||
) {
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
||||
pulseDayMeanMethod: method,
|
||||
pulseDayMeanAppliedAt: appliedAt,
|
||||
pulseDayMeanDryRunJson: dryRun,
|
||||
} as never);
|
||||
}
|
||||
|
||||
function activeJobs(n: number) {
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ n }] as never);
|
||||
}
|
||||
|
||||
const RESULT = {
|
||||
accounts: 1,
|
||||
daysCompared: 30,
|
||||
daysChangedOver1Bpm: 2,
|
||||
maxChangeBpm: 3.4,
|
||||
distribution: [
|
||||
{ bucket: "0-1", days: 28 },
|
||||
{ bucket: "1-2", days: 1 },
|
||||
{ bucket: "2-5", days: 1 },
|
||||
{ bucket: "5+", days: 0 },
|
||||
],
|
||||
perAccount: [{ account: "#1", days: 30, changedOver1Bpm: 2 }],
|
||||
};
|
||||
|
||||
function doneAt(iso: string) {
|
||||
return {
|
||||
status: "done",
|
||||
startedAt: iso,
|
||||
finishedAt: iso,
|
||||
result: RESULT,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(NOW);
|
||||
setActivePulseDayMeanMethod("hour_mean_v1");
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN as never);
|
||||
vi.mocked(getGlobalBoss).mockReturnValue({ send } as never);
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({} as never);
|
||||
send.mockResolvedValue("job-1");
|
||||
activeJobs(0);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
setActivePulseDayMeanMethod("hour_mean_v1");
|
||||
});
|
||||
|
||||
describe("admin only", () => {
|
||||
it("refuses anyone but an admin, before reading anything", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(USER as never);
|
||||
expect((await get()).status).toBe(403);
|
||||
expect((await post({ action: "dry_run" })).status).toBe(403);
|
||||
expect(prisma.appSettings.findUnique).not.toHaveBeenCalled();
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET", () => {
|
||||
it("answers the method and the last dry run", async () => {
|
||||
settings(doneAt("2026-10-15T08:00:00.000Z"));
|
||||
const body = await (await get()).json();
|
||||
expect(body.data).toEqual({
|
||||
method: "hour_mean_v1",
|
||||
appliedAt: null,
|
||||
lastDryRun: doneAt("2026-10-15T08:00:00.000Z"),
|
||||
lastRebuild: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a run marked running with no live job behind it as failed", async () => {
|
||||
settings({
|
||||
status: "running",
|
||||
startedAt: "2026-10-16T11:00:00.000Z",
|
||||
finishedAt: null,
|
||||
result: null,
|
||||
});
|
||||
activeJobs(0);
|
||||
const body = await (await get()).json();
|
||||
expect(body.data.lastDryRun.status).toBe("failed");
|
||||
});
|
||||
|
||||
it("drops a stored value of the wrong shape instead of passing it on", async () => {
|
||||
settings({ status: "done", userId: "u-1" });
|
||||
const body = await (await get()).json();
|
||||
expect(body.data.lastDryRun).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST dry_run", () => {
|
||||
it("marks the dry run queued and enqueues it", async () => {
|
||||
settings(null);
|
||||
const res = await post({ action: "dry_run" });
|
||||
expect(res.status).toBe(202);
|
||||
expect((await res.json()).data).toEqual({ enqueued: true });
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
"pulse-day-mean",
|
||||
{ action: "dry_run" },
|
||||
expect.objectContaining({
|
||||
retryLimit: 0,
|
||||
singletonKey: "pulse-day-mean",
|
||||
}),
|
||||
);
|
||||
expect(prisma.appSettings.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: {
|
||||
pulseDayMeanDryRunJson: expect.objectContaining({ status: "queued" }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("answers 409 busy while a run is active, and starts nothing", async () => {
|
||||
activeJobs(1);
|
||||
const res = await post({ action: "dry_run" });
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).meta.errorCode).toBe(
|
||||
"maintenance.pulseDayMean.busy",
|
||||
);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("puts the last dry run back when the send loses the singleton", async () => {
|
||||
const previous = doneAt("2026-10-15T08:00:00.000Z");
|
||||
settings(previous);
|
||||
send.mockResolvedValue(null);
|
||||
const res = await post({ action: "dry_run" });
|
||||
expect(res.status).toBe(409);
|
||||
expect(vi.mocked(prisma.appSettings.upsert).mock.lastCall?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
update: { pulseDayMeanDryRunJson: { ...previous, rebuild: null } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST apply", () => {
|
||||
it("is refused without a dry run", async () => {
|
||||
settings(null);
|
||||
const res = await post({ action: "apply" });
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).meta.errorCode).toBe(
|
||||
"maintenance.pulseDayMean.dryRunRequired",
|
||||
);
|
||||
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is refused with a dry run older than seven days", async () => {
|
||||
settings(doneAt("2026-10-09T11:59:00.000Z"));
|
||||
expect((await post({ action: "apply" })).status).toBe(409);
|
||||
});
|
||||
|
||||
it("is refused with a dry run that failed", async () => {
|
||||
settings({ ...doneAt("2026-10-15T08:00:00.000Z"), status: "failed" });
|
||||
expect((await post({ action: "apply" })).status).toBe(409);
|
||||
});
|
||||
|
||||
it("is refused 409 busy while a run is active", async () => {
|
||||
settings(doneAt("2026-10-15T08:00:00.000Z"));
|
||||
activeJobs(1);
|
||||
const res = await post({ action: "apply" });
|
||||
expect((await res.json()).meta.errorCode).toBe(
|
||||
"maintenance.pulseDayMean.busy",
|
||||
);
|
||||
});
|
||||
|
||||
it("switches the method, enqueues the rebuild and audits it", async () => {
|
||||
settings(doneAt("2026-10-15T08:00:00.000Z"));
|
||||
const res = await post({ action: "apply" });
|
||||
expect(res.status).toBe(202);
|
||||
expect((await res.json()).data).toEqual({ enqueued: true });
|
||||
expect(prisma.appSettings.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
update: {
|
||||
pulseDayMeanMethod: "weighted_hour_mean_v2",
|
||||
pulseDayMeanAppliedAt: NOW,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
"pulse-day-mean",
|
||||
{ action: "rebuild" },
|
||||
expect.anything(),
|
||||
);
|
||||
expect(activePulseDayMeanMethod()).toBe("weighted_hour_mean_v2");
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"admin.pulseDayMean.apply",
|
||||
expect.objectContaining({
|
||||
userId: "admin-1",
|
||||
details: { method: "weighted_hour_mean_v2", rebuildEnqueued: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST rebuild", () => {
|
||||
const failedRebuild = {
|
||||
status: "failed",
|
||||
startedAt: "2026-10-15T09:00:00.000Z",
|
||||
finishedAt: "2026-10-15T09:05:00.000Z",
|
||||
accounts: null,
|
||||
rows: null,
|
||||
};
|
||||
|
||||
it("is refused before any apply", async () => {
|
||||
settings(doneAt("2026-10-15T08:00:00.000Z"));
|
||||
const res = await post({ action: "rebuild" });
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).meta.errorCode).toBe(
|
||||
"maintenance.pulseDayMean.dryRunRequired",
|
||||
);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs the recalculation again after a stopped one, without a new dry run", async () => {
|
||||
// The dry run is three weeks old: apply would refuse it, a rebuild does not.
|
||||
settings(
|
||||
{ ...doneAt("2026-09-20T08:00:00.000Z"), rebuild: failedRebuild },
|
||||
"weighted_hour_mean_v2",
|
||||
new Date("2026-09-20T09:00:00.000Z"),
|
||||
);
|
||||
const res = await post({ action: "rebuild" });
|
||||
expect(res.status).toBe(202);
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
"pulse-day-mean",
|
||||
{ action: "rebuild" },
|
||||
expect.anything(),
|
||||
);
|
||||
expect(
|
||||
vi.mocked(prisma.appSettings.upsert).mock.lastCall?.[0],
|
||||
).toMatchObject({
|
||||
update: {
|
||||
pulseDayMeanDryRunJson: { rebuild: { status: "queued" } },
|
||||
},
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"admin.pulseDayMean.rebuild",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a rebuild left queued with no job behind it as failed", async () => {
|
||||
settings(
|
||||
{
|
||||
...doneAt("2026-10-15T08:00:00.000Z"),
|
||||
rebuild: { ...failedRebuild, status: "running", finishedAt: null },
|
||||
},
|
||||
"weighted_hour_mean_v2",
|
||||
new Date("2026-10-15T09:00:00.000Z"),
|
||||
);
|
||||
const body = await (await get()).json();
|
||||
expect(body.data.lastRebuild.status).toBe("failed");
|
||||
expect(body.data.lastDryRun.status).toBe("done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST body", () => {
|
||||
it("is strict", async () => {
|
||||
expect((await post({ action: "apply", force: true })).status).toBe(422);
|
||||
expect((await post({ action: "purge" })).status).toBe(422);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers 503 without a background worker", async () => {
|
||||
vi.mocked(getGlobalBoss).mockReturnValue(null as never);
|
||||
expect((await post({ action: "dry_run" })).status).toBe(503);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* `GET/POST /api/admin/maintenance/pulse-day-mean` — the pulse day-mean
|
||||
* method (#1107 follow-up).
|
||||
*
|
||||
* GET answers the active method, when it was applied and the last dry run
|
||||
* (status and result: per-account ordinal labels only, never an id). POST
|
||||
* `{ action: "dry_run" | "apply" | "rebuild" }` answers 202 `{ enqueued }`: a dry run
|
||||
* computes both methods for every account and day and writes nothing but its
|
||||
* result; `apply` needs a successful dry run from the last seven days (409
|
||||
* `maintenance.pulseDayMean.dryRunRequired`), switches the method and
|
||||
* rebuilds the pulse rollups. 409 `maintenance.pulseDayMean.busy` while a run
|
||||
* is active. Admin only, cookie session only. The work itself runs in the
|
||||
* background worker (`src/lib/jobs/pulse-day-mean.ts`).
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import {
|
||||
isPulseDayMeanRunActive,
|
||||
PULSE_DAY_MEAN_QUEUE,
|
||||
PULSE_DAY_MEAN_SEND_OPTIONS,
|
||||
readPulseDayMeanStatus,
|
||||
writeDryRun,
|
||||
writeRebuild,
|
||||
type PulseDayMeanPayload,
|
||||
} from "@/lib/jobs/pulse-day-mean";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
setActivePulseDayMeanMethod,
|
||||
type PulseDayMeanMethod,
|
||||
} from "@/lib/measurements/day-statistic";
|
||||
import {
|
||||
hasFreshDryRun,
|
||||
pulseDayMeanActionSchema,
|
||||
type PulseDayMeanStatus,
|
||||
} from "@/lib/validations/pulse-day-mean";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** The method the apply step switches to. */
|
||||
const APPLIED_METHOD: PulseDayMeanMethod = "weighted_hour_mean_v2";
|
||||
|
||||
function busy() {
|
||||
return apiError("A pulse day-mean run is already queued or running", 409, {
|
||||
errorCode: "maintenance.pulseDayMean.busy",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue the rebuild and record it as queued, so the card shows it from the
|
||||
* moment it is asked for and a rebuild that never starts reads as failed.
|
||||
* When another run holds the singleton, the previous rebuild state is put
|
||||
* back and the answer is false.
|
||||
*/
|
||||
async function sendRebuild(
|
||||
boss: NonNullable<ReturnType<typeof getGlobalBoss>>,
|
||||
previous: PulseDayMeanStatus["lastRebuild"],
|
||||
): Promise<boolean> {
|
||||
await writeRebuild({
|
||||
status: "queued",
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: null,
|
||||
accounts: null,
|
||||
rows: null,
|
||||
});
|
||||
const payload: PulseDayMeanPayload = { action: "rebuild" };
|
||||
const jobId = await boss.send(
|
||||
PULSE_DAY_MEAN_QUEUE,
|
||||
payload,
|
||||
PULSE_DAY_MEAN_SEND_OPTIONS,
|
||||
);
|
||||
if (jobId === null) await writeRebuild(previous);
|
||||
return jobId !== null;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
const status = await readPulseDayMeanStatus();
|
||||
annotate({
|
||||
action: { name: "admin.pulse_day_mean.status" },
|
||||
meta: {
|
||||
method: status.method,
|
||||
dry_run_status: status.lastDryRun?.status ?? null,
|
||||
},
|
||||
});
|
||||
return apiSuccess(status);
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAdmin();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = pulseDayMeanActionSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
const action = parsed.data.action;
|
||||
annotate({
|
||||
action: { name: "admin.pulse_day_mean.requested" },
|
||||
meta: { pulse_day_mean_action: action },
|
||||
});
|
||||
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) return apiError("Background worker is not available", 503);
|
||||
if (await isPulseDayMeanRunActive()) return busy();
|
||||
|
||||
if (action === "dry_run") {
|
||||
const previous = (await readPulseDayMeanStatus()).lastDryRun;
|
||||
await writeDryRun({
|
||||
status: "queued",
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: null,
|
||||
result: null,
|
||||
});
|
||||
const payload: PulseDayMeanPayload = { action: "dry_run" };
|
||||
const jobId = await boss.send(
|
||||
PULSE_DAY_MEAN_QUEUE,
|
||||
payload,
|
||||
PULSE_DAY_MEAN_SEND_OPTIONS,
|
||||
);
|
||||
if (jobId === null) {
|
||||
// Another run took the singleton between the check and the send; the
|
||||
// last dry run stays what it was.
|
||||
if (previous) await writeDryRun(previous);
|
||||
return busy();
|
||||
}
|
||||
annotate({ meta: { pulse_day_mean_enqueued: true } });
|
||||
return apiSuccess({ enqueued: true }, 202);
|
||||
}
|
||||
|
||||
if (action === "rebuild") {
|
||||
// The recalculation again, after an apply whose rebuild stopped. The
|
||||
// method is already switched; the dry run that justified it stands.
|
||||
const status = await readPulseDayMeanStatus();
|
||||
if (status.appliedAt === null) {
|
||||
return apiError(
|
||||
"Nothing applied yet; run a dry run and apply first",
|
||||
409,
|
||||
{ errorCode: "maintenance.pulseDayMean.dryRunRequired" },
|
||||
);
|
||||
}
|
||||
const enqueued = await sendRebuild(boss, status.lastRebuild);
|
||||
if (!enqueued) return busy();
|
||||
await auditLog("admin.pulseDayMean.rebuild", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { method: status.method },
|
||||
});
|
||||
annotate({ meta: { pulse_day_mean_enqueued: true } });
|
||||
return apiSuccess({ enqueued: true }, 202);
|
||||
}
|
||||
|
||||
if (!hasFreshDryRun(await readPulseDayMeanStatus())) {
|
||||
return apiError(
|
||||
"Run a dry run first; apply needs one finished in the last seven days",
|
||||
409,
|
||||
{ errorCode: "maintenance.pulseDayMean.dryRunRequired" },
|
||||
);
|
||||
}
|
||||
|
||||
// The method first, then the rebuild: the rollups read the stored method
|
||||
// inside their own statements, so a rebuild that starts before the switch
|
||||
// would write the old statistic again.
|
||||
const appliedAt = new Date();
|
||||
await prisma.appSettings.upsert({
|
||||
where: { id: "singleton" },
|
||||
create: {
|
||||
id: "singleton",
|
||||
pulseDayMeanMethod: APPLIED_METHOD,
|
||||
pulseDayMeanAppliedAt: appliedAt,
|
||||
},
|
||||
update: {
|
||||
pulseDayMeanMethod: APPLIED_METHOD,
|
||||
pulseDayMeanAppliedAt: appliedAt,
|
||||
},
|
||||
});
|
||||
setActivePulseDayMeanMethod(APPLIED_METHOD);
|
||||
const enqueued = await sendRebuild(
|
||||
boss,
|
||||
(await readPulseDayMeanStatus()).lastRebuild,
|
||||
);
|
||||
|
||||
await auditLog("admin.pulseDayMean.apply", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { method: APPLIED_METHOD, rebuildEnqueued: enqueued },
|
||||
});
|
||||
annotate({
|
||||
meta: { pulse_day_mean_enqueued: enqueued, method: APPLIED_METHOD },
|
||||
});
|
||||
return apiSuccess({ enqueued }, 202);
|
||||
});
|
||||
@@ -25,6 +25,8 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
// v1.42 — the operator key's model check reads the admin settings.
|
||||
appSettings: { findUnique: vi.fn(async () => null) },
|
||||
// v1.43 — accounts per effective AI usage level.
|
||||
user: { groupBy: vi.fn(async () => []) },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -378,3 +380,43 @@ describe("GET /api/admin/provider-health — pause and model check (v1.42)", ()
|
||||
expect(probeModelListing).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// #1217 — the operator sees how many accounts run at each level, resolved
|
||||
// against the current bounds, so lowering the ceiling moves the counts.
|
||||
describe("usage levels", () => {
|
||||
const userGroups = vi.mocked(prisma.user.groupBy);
|
||||
const settingsRow = vi.mocked(prisma.appSettings.findUnique);
|
||||
|
||||
beforeEach(() => {
|
||||
groupBy.mockResolvedValue([] as never);
|
||||
usageRows.mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
it("counts each account at its effective level", async () => {
|
||||
settingsRow.mockResolvedValue({
|
||||
aiUsageMaxLevel: "STANDARD",
|
||||
aiUsageDefaultLevel: "STANDARD",
|
||||
aiSyncRegenGapHours: null,
|
||||
} as never);
|
||||
userGroups.mockResolvedValue([
|
||||
{ aiUsageLevel: null, _count: { _all: 5 } },
|
||||
{ aiUsageLevel: "FRUGAL", _count: { _all: 2 } },
|
||||
// Above the ceiling: capped to standard.
|
||||
{ aiUsageLevel: "FULL", _count: { _all: 3 } },
|
||||
] as never);
|
||||
const res = await GET();
|
||||
const body = await res.json();
|
||||
expect(body.data.usageLevels).toEqual({ frugal: 2, standard: 8, full: 0 });
|
||||
});
|
||||
|
||||
it("reports zero everywhere on an empty instance", async () => {
|
||||
settingsRow.mockResolvedValue(null);
|
||||
userGroups.mockResolvedValue([] as never);
|
||||
const res = await GET();
|
||||
expect((await res.json()).data.usageLevels).toEqual({
|
||||
frugal: 0,
|
||||
standard: 0,
|
||||
full: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,13 @@ import {
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { isAnthropicBaseUrl } from "@/lib/ai/provider";
|
||||
import { isCanonicalOpenAIEndpoint } from "@/lib/ai/openai-capabilities";
|
||||
import { loadAiUsageSettings } from "@/lib/ai/capabilities/usage";
|
||||
import {
|
||||
AI_USAGE_LEVELS,
|
||||
resolveAiUsageLevel,
|
||||
usageLevelFromDb,
|
||||
type AiUsageLevel,
|
||||
} from "@/lib/ai/usage-level";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -186,9 +193,37 @@ export const GET = apiHandler(async () => {
|
||||
operatorTokens: spend.operator,
|
||||
accounts: spend.accounts,
|
||||
},
|
||||
usageLevels: await countAccountsByUsageLevel(),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.43 (#1217) — accounts per EFFECTIVE AI usage level: each stored choice
|
||||
* (or none) resolved against the operator's current bounds, so the readout
|
||||
* moves the moment the operator lowers the ceiling or the default. One
|
||||
* grouped read over the users' stored choices.
|
||||
*/
|
||||
async function countAccountsByUsageLevel(): Promise<
|
||||
Record<AiUsageLevel, number>
|
||||
> {
|
||||
const [settings, groups] = await Promise.all([
|
||||
loadAiUsageSettings(),
|
||||
prisma.user.groupBy({ by: ["aiUsageLevel"], _count: { _all: true } }),
|
||||
]);
|
||||
const counts = Object.fromEntries(
|
||||
AI_USAGE_LEVELS.map((level) => [level, 0]),
|
||||
) as Record<AiUsageLevel, number>;
|
||||
for (const group of groups) {
|
||||
const { level } = resolveAiUsageLevel({
|
||||
preference: usageLevelFromDb(group.aiUsageLevel),
|
||||
defaultLevel: settings.defaultLevel,
|
||||
maxLevel: settings.maxLevel,
|
||||
});
|
||||
counts[level] += group._count._all;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -48,6 +48,9 @@ const ALL_ON = {
|
||||
assistantDocumentAiEnabled: true,
|
||||
aiReasoningEnabled: true,
|
||||
aiReasoningMaxEffort: "high",
|
||||
aiUsageMaxLevel: "STANDARD",
|
||||
aiUsageDefaultLevel: "STANDARD",
|
||||
aiSyncRegenGapHours: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -211,6 +214,75 @@ describe("PUT /api/admin/settings/assistant-flags", () => {
|
||||
},
|
||||
);
|
||||
|
||||
// #1217 — the operator's usage bounds.
|
||||
it("stores the usage bounds and echoes them with the gap in effect", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
|
||||
...ALL_ON,
|
||||
aiUsageMaxLevel: "FULL",
|
||||
aiUsageDefaultLevel: "FRUGAL",
|
||||
aiSyncRegenGapHours: 12,
|
||||
} as never);
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
aiUsage: {
|
||||
maxLevel: "full",
|
||||
defaultLevel: "frugal",
|
||||
syncRegenGapHours: 12,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0][0];
|
||||
expect(upsert.update).toEqual({
|
||||
aiUsageMaxLevel: "FULL",
|
||||
aiUsageDefaultLevel: "FRUGAL",
|
||||
aiSyncRegenGapHours: 12,
|
||||
});
|
||||
const body = (await res.json()) as { data: { aiUsage: unknown } };
|
||||
expect(body.data.aiUsage).toEqual({
|
||||
maxLevel: "full",
|
||||
defaultLevel: "frugal",
|
||||
syncRegenGapHours: 12,
|
||||
effectiveSyncRegenGapHours: 12,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a default above the ceiling", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
aiUsage: {
|
||||
maxLevel: "standard",
|
||||
defaultLevel: "full",
|
||||
syncRegenGapHours: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as { meta: { errorCode: string } };
|
||||
expect(body.meta.errorCode).toBe("ai.usage.defaultAboveCeiling");
|
||||
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([0, 49, 2.5])(
|
||||
"refuses %s as the sync gap",
|
||||
async (syncRegenGapHours) => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
const res = await PUT(
|
||||
putReq({
|
||||
aiUsage: {
|
||||
maxLevel: "standard",
|
||||
defaultLevel: "standard",
|
||||
syncRegenGapHours,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects an empty body", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
const res = await PUT(putReq({}));
|
||||
|
||||
@@ -36,6 +36,16 @@
|
||||
* switching reasoning off stops no AI capability, it only stops the model
|
||||
* thinking longer before it answers.
|
||||
*
|
||||
* v1.43 (#1217) — and the AI usage bounds, optional `aiUsage`:
|
||||
* `{ maxLevel, defaultLevel, syncRegenGapHours }`. `maxLevel` is the highest
|
||||
* level an account may choose, `defaultLevel` the level an account without a
|
||||
* choice runs at (never above `maxLevel`: 422 `ai.usage.defaultAboveCeiling`),
|
||||
* `syncRegenGapHours` the minimum gap between status-card regenerations after
|
||||
* a sync at `standard` (1..48, `null` follows `AI_SYNC_REGEN_GAP_HOURS`, else
|
||||
* six). Lowering `maxLevel` caps every account above it at once; their stored
|
||||
* choice is kept. Echoed as `aiUsage` with the resolved gap beside the stored
|
||||
* one.
|
||||
*
|
||||
* `requireAdmin()` gates the route — non-admins get 403.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
@@ -55,6 +65,13 @@ import { resolveAssistantFlags } from "@/lib/feature-flags";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { REASONING_MAX_EFFORTS } from "@/lib/ai/reasoning/levels";
|
||||
import { parseReasoningMaxEffort } from "@/lib/ai/reasoning/resolve";
|
||||
import { aiUsageSettingsFromRow } from "@/lib/ai/capabilities/usage";
|
||||
import {
|
||||
usageLevelRank,
|
||||
usageLevelToDb,
|
||||
type AiUsageLevelDb,
|
||||
} from "@/lib/ai/usage-level";
|
||||
import { adminAiUsageSchema } from "@/lib/validations/ai-usage";
|
||||
|
||||
const assistantFlagsSchema = z
|
||||
.object({
|
||||
@@ -65,6 +82,7 @@ const assistantFlagsSchema = z
|
||||
assistantDocumentAiEnabled: z.boolean().optional(),
|
||||
aiReasoningEnabled: z.boolean().optional(),
|
||||
aiReasoningMaxEffort: z.enum(REASONING_MAX_EFFORTS).optional(),
|
||||
aiUsage: adminAiUsageSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -76,6 +94,9 @@ type AssistantFlagsRow = {
|
||||
assistantDocumentAiEnabled: boolean;
|
||||
aiReasoningEnabled: boolean;
|
||||
aiReasoningMaxEffort: string;
|
||||
aiUsageMaxLevel: AiUsageLevelDb;
|
||||
aiUsageDefaultLevel: AiUsageLevelDb;
|
||||
aiSyncRegenGapHours: number | null;
|
||||
};
|
||||
|
||||
const ROW_SELECT = {
|
||||
@@ -86,6 +107,9 @@ const ROW_SELECT = {
|
||||
assistantDocumentAiEnabled: true,
|
||||
aiReasoningEnabled: true,
|
||||
aiReasoningMaxEffort: true,
|
||||
aiUsageMaxLevel: true,
|
||||
aiUsageDefaultLevel: true,
|
||||
aiSyncRegenGapHours: true,
|
||||
} as const;
|
||||
|
||||
function buildResponseShape(row: AssistantFlagsRow) {
|
||||
@@ -109,6 +133,18 @@ function buildResponseShape(row: AssistantFlagsRow) {
|
||||
enabled: row.aiReasoningEnabled,
|
||||
maxEffort: parseReasoningMaxEffort(row.aiReasoningMaxEffort),
|
||||
},
|
||||
aiUsage: aiUsageShape(row),
|
||||
};
|
||||
}
|
||||
|
||||
/** The usage bounds as the admin panel edits them, the gap in effect beside. */
|
||||
function aiUsageShape(row: AssistantFlagsRow) {
|
||||
const settings = aiUsageSettingsFromRow(row);
|
||||
return {
|
||||
maxLevel: settings.maxLevel,
|
||||
defaultLevel: settings.defaultLevel,
|
||||
syncRegenGapHours: settings.storedSyncRegenGapHours,
|
||||
effectiveSyncRegenGapHours: settings.syncRegenGapHours,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,6 +168,9 @@ export const GET = apiHandler(async () => {
|
||||
assistantDocumentAiEnabled: settings?.assistantDocumentAiEnabled ?? true,
|
||||
aiReasoningEnabled: settings?.aiReasoningEnabled ?? true,
|
||||
aiReasoningMaxEffort: settings?.aiReasoningMaxEffort ?? "high",
|
||||
aiUsageMaxLevel: settings?.aiUsageMaxLevel ?? "STANDARD",
|
||||
aiUsageDefaultLevel: settings?.aiUsageDefaultLevel ?? "STANDARD",
|
||||
aiSyncRegenGapHours: settings?.aiSyncRegenGapHours ?? null,
|
||||
};
|
||||
|
||||
return apiSuccess(buildResponseShape(row));
|
||||
@@ -154,13 +193,28 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
// Field by field from the parsed body; the schema is strict, so every key
|
||||
// here is one the route owns.
|
||||
const { aiUsage, ...flags } = parsed.data;
|
||||
if (
|
||||
aiUsage &&
|
||||
usageLevelRank(aiUsage.defaultLevel) > usageLevelRank(aiUsage.maxLevel)
|
||||
) {
|
||||
return apiError("The default level is above the highest level", 422, {
|
||||
errorCode: "ai.usage.defaultAboveCeiling",
|
||||
});
|
||||
}
|
||||
const updates: Partial<AssistantFlagsRow> = {};
|
||||
for (const [key, value] of Object.entries(parsed.data)) {
|
||||
for (const [key, value] of Object.entries(flags)) {
|
||||
if (value !== undefined) {
|
||||
(updates as Record<string, boolean | string>)[key] = value;
|
||||
}
|
||||
}
|
||||
const auditDetails: Record<string, unknown> = { ...updates };
|
||||
if (aiUsage) {
|
||||
updates.aiUsageMaxLevel = usageLevelToDb(aiUsage.maxLevel);
|
||||
updates.aiUsageDefaultLevel = usageLevelToDb(aiUsage.defaultLevel);
|
||||
updates.aiSyncRegenGapHours = aiUsage.syncRegenGapHours;
|
||||
auditDetails.aiUsage = aiUsage;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return apiError("No valid fields", 422);
|
||||
@@ -187,6 +241,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
assistantDocumentAiEnabled: settings.assistantDocumentAiEnabled,
|
||||
aiReasoningEnabled: settings.aiReasoningEnabled,
|
||||
aiReasoningMaxEffort: settings.aiReasoningMaxEffort,
|
||||
aiUsageMaxLevel: settings.aiUsageMaxLevel,
|
||||
aiUsageDefaultLevel: settings.aiUsageDefaultLevel,
|
||||
aiSyncRegenGapHours: settings.aiSyncRegenGapHours,
|
||||
};
|
||||
|
||||
return apiSuccess(buildResponseShape(row));
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* `GET /api/admin/support-report` end to end over a mocked database: the
|
||||
* collector, the renderer and the redaction together. Accounts are seeded
|
||||
* with distinctive emails, ids, usernames and names, and the failures carry
|
||||
* a token, a URL and a host in their messages; none of it may reach the text.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const USERS = [
|
||||
{
|
||||
id: "cmownerabcdefghijklmnopq",
|
||||
username: "ownerperson",
|
||||
email: "owner.person@example.org",
|
||||
fullName: "Olive Owner",
|
||||
aiUsageLevel: null,
|
||||
},
|
||||
{
|
||||
id: "cmsecondabcdefghijklmnop",
|
||||
username: "secondperson",
|
||||
email: "second.person@mailbox.org",
|
||||
fullName: "Sam Second",
|
||||
aiUsageLevel: "FULL",
|
||||
},
|
||||
];
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findMany: vi.fn(async () => USERS),
|
||||
groupBy: vi.fn(async () => [
|
||||
{ aiProvider: "LOCAL", _count: { _all: 1 } },
|
||||
{ aiProvider: null, _count: { _all: 1 } },
|
||||
]),
|
||||
},
|
||||
appSettings: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
glitchtipEnabled: true,
|
||||
glitchtipDsn: "https://key@glitchtip.example.net/1",
|
||||
webPushVapidPublicKey: null,
|
||||
webPushVapidPrivateKeyEncrypted: null,
|
||||
adminAiKeyEncrypted: "v1:ciphertext",
|
||||
adminCodexConnectionStatus: "disconnected",
|
||||
aiUsageDefaultLevel: "STANDARD",
|
||||
aiUsageMaxLevel: "FULL",
|
||||
})),
|
||||
},
|
||||
$queryRaw: vi.fn(async (strings: TemplateStringsArray) => {
|
||||
const sql = strings.join("?");
|
||||
if (sql.includes("server_version")) {
|
||||
return [
|
||||
{ version: "16.4", timezone: "Etc/UTC", size: BigInt(52428800) },
|
||||
];
|
||||
}
|
||||
if (sql.includes("migration_name")) {
|
||||
return [
|
||||
{
|
||||
migration_name: "0410_relation_links",
|
||||
finished_at: new Date("2026-10-15T08:00:00Z"),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (sql.includes("finished_at IS NULL")) return [{ count: 0 }];
|
||||
if (sql.includes("pgboss.job")) {
|
||||
return [{ at: new Date("2026-10-15T09:59:00Z") }];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
integrationWatch: {
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
userId: "cmsecondabcdefghijklmnop",
|
||||
integration: "nightscout",
|
||||
incidentReason: "unexpected_silence",
|
||||
incidentOpenedAt: new Date("2026-10-14T00:00:00Z"),
|
||||
alertedAt: null,
|
||||
},
|
||||
]),
|
||||
},
|
||||
providerHealth: {
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
providerType: "LOCAL",
|
||||
lastResult: "hard_failed",
|
||||
lastStatus: 502,
|
||||
lastFailureAt: new Date("2026-10-15T07:00:00Z"),
|
||||
nextRetryAt: new Date(Date.now() + 60_000),
|
||||
},
|
||||
]),
|
||||
},
|
||||
pushAttempt: {
|
||||
groupBy: vi.fn(async (args: { by: string[] }) =>
|
||||
args.by.includes("result")
|
||||
? [
|
||||
{ channel: "NTFY", result: "ok", _count: { _all: 9 } },
|
||||
{ channel: "NTFY", result: "error", _count: { _all: 2 } },
|
||||
]
|
||||
: [{ channel: "NTFY", reason: "http_500", _count: { _all: 2 } }],
|
||||
),
|
||||
},
|
||||
dataBackup: {
|
||||
aggregate: vi.fn(async () => ({
|
||||
_max: { createdAt: new Date("2026-10-12T03:00:00Z") },
|
||||
})),
|
||||
count: vi.fn(async () => 2),
|
||||
},
|
||||
offhostBackupState: {
|
||||
findMany: vi.fn(async () => [
|
||||
{ lastSuccessAt: new Date("2026-10-15T02:00:00Z") },
|
||||
{ lastSuccessAt: null },
|
||||
]),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-handler", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/lib/api-handler")>(
|
||||
"@/lib/api-handler",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/job-failures", () => ({
|
||||
JOB_FAILURE_WINDOW_HOURS: 72,
|
||||
readFailingQueues: vi.fn(async () => [
|
||||
{
|
||||
queue: "withings-sync",
|
||||
failures: 4,
|
||||
lastFailedAt: "2026-10-15T09:00:00.000Z",
|
||||
lastError:
|
||||
"TypeError: fetch https://wbsapi.withings.net/v2?access_token=abc failed for owner.person@example.org",
|
||||
},
|
||||
]),
|
||||
readLastQueueRun: vi.fn(async (queue: string) =>
|
||||
queue === "data-restore-drill"
|
||||
? {
|
||||
at: "2026-10-01T04:11:00.000Z",
|
||||
state: "failed",
|
||||
error: "connect ECONNREFUSED 10.0.0.12:9000 (s3.nas.lan)",
|
||||
}
|
||||
: null,
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integrations/connection-health-load", () => ({
|
||||
loadConnectionHealth: vi.fn(async (userId: string) => {
|
||||
const empty = {
|
||||
withings: null,
|
||||
whoop: null,
|
||||
fitbit: null,
|
||||
"google-health": null,
|
||||
polar: null,
|
||||
oura: null,
|
||||
strava: null,
|
||||
nightscout: null,
|
||||
"apple-health": null,
|
||||
};
|
||||
if (userId === "cmownerabcdefghijklmnopq") {
|
||||
return {
|
||||
...empty,
|
||||
withings: { state: "ok", reason: "data_arriving" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
...empty,
|
||||
withings: { state: "action_required", reason: "reauth_required" },
|
||||
nightscout: { state: "degraded", reason: "unexpected_silence" },
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/geo", () => ({ offlineGeoReady: () => false }));
|
||||
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { appendLogEvent, clearLogBuffer } from "@/lib/logging/in-memory-buffer";
|
||||
import { GET } from "../route";
|
||||
|
||||
const FORBIDDEN = [
|
||||
...USERS.flatMap((u) => [u.id, u.username, u.email, u.fullName]),
|
||||
"wbsapi.withings.net",
|
||||
"access_token",
|
||||
"glitchtip.example.net",
|
||||
"10.0.0.12",
|
||||
"s3.nas.lan",
|
||||
"5d8e1b72-9a8c-49b7-801c-1dd42ba79dcc",
|
||||
"hlk_leaked_token",
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(requireAdmin).mockReset();
|
||||
clearLogBuffer();
|
||||
});
|
||||
|
||||
async function report(): Promise<{ generatedAt: string; text: string }> {
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
return (await res.json()).data;
|
||||
}
|
||||
|
||||
describe("GET /api/admin/support-report", () => {
|
||||
it("refuses a non-admin before reading anything", async () => {
|
||||
vi.mocked(requireAdmin).mockRejectedValue(new HttpError(403, "Forbidden"));
|
||||
await expect(GET()).rejects.toThrow("Forbidden");
|
||||
});
|
||||
|
||||
it("answers the redacted report with every section", async () => {
|
||||
appendLogEvent({
|
||||
timestamp: "2026-10-15T09:40:00.000Z",
|
||||
duration_ms: 20,
|
||||
request_id: "5d8e1b72-9a8c-49b7-801c-1dd42ba79dcc",
|
||||
trace_id: "t",
|
||||
level: "error",
|
||||
kind: "http",
|
||||
service: "healthlog",
|
||||
environment: "test",
|
||||
action: { name: "measurement.batch.ingest" },
|
||||
auth: { user_id: "cmownerabcdefghijklmnopq" },
|
||||
error: {
|
||||
type: "TypeError",
|
||||
message: "Bearer hlk_leaked_token for owner.person@example.org",
|
||||
},
|
||||
});
|
||||
|
||||
const { generatedAt, text } = await report();
|
||||
expect(Date.parse(generatedAt)).not.toBeNaN();
|
||||
for (const heading of [
|
||||
"## Build",
|
||||
"## Database",
|
||||
"## Configuration",
|
||||
"## Jobs",
|
||||
"## Connections",
|
||||
"## AI",
|
||||
"## Notifications (7 days)",
|
||||
"## Backups",
|
||||
"## Recent warnings and errors (this process)",
|
||||
]) {
|
||||
expect(text).toContain(heading);
|
||||
}
|
||||
expect(text).toContain("- PostgreSQL: 16.4");
|
||||
expect(text).toContain("0410_relation_links");
|
||||
expect(text).toContain("- GlitchTip: yes");
|
||||
expect(text).toContain("withings-sync: 4 failed");
|
||||
expect(text).toContain("(TypeError)");
|
||||
expect(text).toContain(
|
||||
"- withings: action_required / reauth_required: 1 (Account 2)",
|
||||
);
|
||||
expect(text).toContain("- withings: ok / data_arriving: 1");
|
||||
expect(text).toContain("nightscout: unexpected_silence since");
|
||||
expect(text).toContain("Account 2");
|
||||
expect(text).toContain(
|
||||
"- Accounts by usage level: FULL 1, instance default 1",
|
||||
);
|
||||
expect(text).toContain(
|
||||
"LOCAL: ok 0, failing 1, credential refused 0, paused 1",
|
||||
);
|
||||
expect(text).toContain("- NTFY: ok 9, failed 2, skipped 0; http_500 2");
|
||||
expect(text).toContain(
|
||||
"Last restore drill: failed at 2026-10-01T04:11:00.000Z (ECONNREFUSED)",
|
||||
);
|
||||
expect(text).toContain("- error measurement.batch.ingest: 1x");
|
||||
|
||||
for (const value of FORBIDDEN) {
|
||||
expect(text.toLowerCase(), value).not.toContain(value.toLowerCase());
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* `GET /api/admin/support-report` — a redacted plain-text report an operator
|
||||
* can paste into an issue: build, database, configuration as yes/no, jobs,
|
||||
* connections by health state, AI ledger and usage levels, notifications,
|
||||
* backups and the most frequent recent error actions. Never an email address,
|
||||
* a user id, a value, a token or a foreign host name; accounts appear as
|
||||
* "Account 1..n". Admin only, cookie session only.
|
||||
*
|
||||
* What goes in and how it is redacted lives in `src/lib/support/report.ts`;
|
||||
* the redaction guard is `src/lib/support/__tests__/support-report-redaction.test.ts`.
|
||||
*/
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
buildSupportReportText,
|
||||
collectSupportReportFacts,
|
||||
} from "@/lib/support/report";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
await requireAdmin();
|
||||
annotate({ action: { name: "admin.support_report.get" } });
|
||||
|
||||
const { facts, context } = await collectSupportReportFacts();
|
||||
const text = buildSupportReportText(facts, context);
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
support_report_accounts: facts.accounts.total,
|
||||
support_report_bytes: text.length,
|
||||
},
|
||||
});
|
||||
return apiSuccess({ generatedAt: facts.generatedAt, text });
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* `DELETE /api/anamnesis/audit-c/{id}` — remove an AUDIT-C entry (#614).
|
||||
* Soft delete (`deletedAt`); answers `{ id, removedAt }`, 404
|
||||
* `anamnesis.auditC.notFound` for an id that is not a live AUDIT-C entry of
|
||||
* the record. Authority `requireRecordAuth("manage", "profile")`, like the
|
||||
* create.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { invalidateHealthProfileFactConsumers } from "@/lib/profile/health-facts";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("manage", "profile");
|
||||
const { id } = await params;
|
||||
|
||||
// Soft delete, scoped to a live AUDIT-C entry of the record: a foreign
|
||||
// id, a screener's id or an entry already removed is the same 404.
|
||||
const now = new Date();
|
||||
const result = await prisma.mentalHealthAssessment.updateMany({
|
||||
where: { id, userId: user.id, instrument: "AUDIT_C", deletedAt: null },
|
||||
data: { deletedAt: now, syncVersion: { increment: 1 } },
|
||||
});
|
||||
if (result.count !== 1) {
|
||||
return apiError("AUDIT-C entry not found", 404, {
|
||||
errorCode: "anamnesis.auditC.notFound",
|
||||
});
|
||||
}
|
||||
await invalidateHealthProfileFactConsumers(user.id, prisma);
|
||||
const removed = { id, removedAt: now.toISOString() };
|
||||
|
||||
await auditLog("anamnesis.audit_c.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { assessmentId: removed.id, instrument: "AUDIT_C" },
|
||||
});
|
||||
annotate({
|
||||
action: {
|
||||
name: "anamnesis.audit_c.delete",
|
||||
entity_type: "mental_health_assessment",
|
||||
entity_id: removed.id,
|
||||
},
|
||||
});
|
||||
return apiSuccess(removed);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* `POST /api/anamnesis/audit-c` — record an AUDIT-C (#614).
|
||||
*
|
||||
* `{ items: [0..4, 0..4, 0..4], takenAt?, tz?, locale?, source, externalId? }`.
|
||||
* 201 `{ result: AuditCResult }` with the sum and the screening cut-off for
|
||||
* the entry's language and the profile's sex (null without one); a replay
|
||||
* with the same `externalId` answers 200 with the same result. Stored beside
|
||||
* the mental-health screeners but never listed by
|
||||
* `/api/mental-health/assessments`. At most 30 an hour, counted against the
|
||||
* person acting.
|
||||
*
|
||||
* Authority: `requireRecordAuth("manage", "profile")`, the level allergies and
|
||||
* family history are written at; the entry lands in the record and the trail
|
||||
* names the manager as actor.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditCInstrument } from "@/lib/mental-health/audit-c";
|
||||
import { createAuditC } from "@/lib/profile/life-circumstances";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditCRequestSchema } from "@/lib/validations/life-circumstances";
|
||||
|
||||
/** How far ahead of the server clock a `takenAt` may lie. */
|
||||
const AUDIT_C_CLOCK_SKEW_MS = 5 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postAuditC));
|
||||
|
||||
async function postAuditC(request: NextRequest): Promise<Response> {
|
||||
const { user, actor } = await requireRecordAuth("manage", "profile");
|
||||
|
||||
const rl = await checkRateLimit(`audit-c:${actor.id}`, 30, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many entries, try again later", 429, {
|
||||
errorCode: "anamnesis.auditC.rateLimited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = auditCRequestSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "anamnesis.auditC.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
|
||||
// The cut-off follows the language of the wording that was answered, and
|
||||
// the only wording this account is ever shown is the one the read route
|
||||
// delivers for its language. The stored language is that one, decided
|
||||
// here; a client naming another wording answered something this server
|
||||
// did not offer (or the account language changed under an open form), and
|
||||
// is refused rather than scored against the wrong table.
|
||||
const offered = auditCInstrument(user.locale ?? "en").locale;
|
||||
if (
|
||||
input.locale !== undefined &&
|
||||
auditCInstrument(input.locale).locale !== offered
|
||||
) {
|
||||
return apiError("The wording answered is not the one offered", 422, {
|
||||
errorCode: "anamnesis.auditC.invalid",
|
||||
});
|
||||
}
|
||||
// An entry dated in the future would hold the "newest" place for as long
|
||||
// as the clock takes to reach it. A few minutes of device clock skew pass.
|
||||
const takenAt = input.takenAt ? new Date(input.takenAt) : undefined;
|
||||
if (takenAt && takenAt.getTime() > Date.now() + AUDIT_C_CLOCK_SKEW_MS) {
|
||||
return apiError("takenAt lies in the future", 422, {
|
||||
errorCode: "anamnesis.auditC.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { result, replayed } = await createAuditC(user.id, {
|
||||
items: input.items,
|
||||
takenAt,
|
||||
tz: input.tz,
|
||||
locale: offered,
|
||||
source: input.source,
|
||||
externalId: input.externalId,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: replayed ? "anamnesis.audit_c.replay" : "anamnesis.audit_c.create",
|
||||
entity_type: "mental_health_assessment",
|
||||
entity_id: result.id,
|
||||
},
|
||||
});
|
||||
if (replayed) return apiSuccess({ result }, 200);
|
||||
|
||||
// The id and the instrument only; the answers and the sum stay out of the trail.
|
||||
await auditLog("anamnesis.audit_c.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { assessmentId: result.id, instrument: "AUDIT_C" },
|
||||
});
|
||||
return apiSuccess({ result }, 201);
|
||||
}
|
||||
@@ -13,16 +13,28 @@ import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
correctHealthProfileFact,
|
||||
correctLifeFact,
|
||||
removeHealthProfileFact,
|
||||
} from "@/lib/profile/health-facts";
|
||||
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
|
||||
import {
|
||||
healthProfileFactCorrectionSchema,
|
||||
validateCorrectedFactValue,
|
||||
type HealthProfileFactKind,
|
||||
} from "@/lib/validations/health-profile-facts";
|
||||
import {
|
||||
factCorrectionSchema,
|
||||
isLifeFactKind,
|
||||
parseLifeFactValue,
|
||||
} from "@/lib/validations/life-circumstances";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
function invalidValue(): Response {
|
||||
return apiError("Value is not valid for this fact kind", 422, {
|
||||
errorCode: "anamnesis.fact.invalidValue",
|
||||
});
|
||||
}
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -32,7 +44,7 @@ export const PATCH = apiHandler(
|
||||
});
|
||||
if (error) return error;
|
||||
|
||||
const parsed = healthProfileFactCorrectionSchema.safeParse(rawBody);
|
||||
const parsed = factCorrectionSchema.safeParse(rawBody);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
const current = await prisma.healthProfileFactRevision.findFirst({
|
||||
@@ -46,17 +58,38 @@ export const PATCH = apiHandler(
|
||||
});
|
||||
if (!current) return apiError("Current profile fact not found", 404);
|
||||
|
||||
const value = validateCorrectedFactValue(
|
||||
current.kind as HealthProfileFactKind,
|
||||
parsed.data.value,
|
||||
);
|
||||
if (!value) {
|
||||
return apiError("Value is not valid for this fact kind", 422, {
|
||||
errorCode: "anamnesis.fact.invalidValue",
|
||||
});
|
||||
// The value's form follows the stored kind: a string from the closed set
|
||||
// for the three original kinds, an object with `schema: 1` for the life
|
||||
// circumstances (v1.43, #614).
|
||||
const now = new Date();
|
||||
const validFrom = parsed.data.validFrom
|
||||
? dateOnlyAtNoonUtc(parsed.data.validFrom)
|
||||
: undefined;
|
||||
let corrected: { id: string; kind: string } | null;
|
||||
if (isLifeFactKind(current.kind)) {
|
||||
const value =
|
||||
typeof parsed.data.value === "string"
|
||||
? null
|
||||
: parseLifeFactValue(current.kind, parsed.data.value);
|
||||
if (!value) return invalidValue();
|
||||
corrected = await correctLifeFact(user.id, id, value, now, validFrom);
|
||||
} else {
|
||||
const value =
|
||||
typeof parsed.data.value === "string"
|
||||
? validateCorrectedFactValue(
|
||||
current.kind as HealthProfileFactKind,
|
||||
parsed.data.value,
|
||||
)
|
||||
: null;
|
||||
if (!value) return invalidValue();
|
||||
corrected = await correctHealthProfileFact(
|
||||
user.id,
|
||||
id,
|
||||
value,
|
||||
now,
|
||||
validFrom,
|
||||
);
|
||||
}
|
||||
|
||||
const corrected = await correctHealthProfileFact(user.id, id, value);
|
||||
if (!corrected) {
|
||||
return apiError("Profile fact changed since it was loaded", 409, {
|
||||
errorCode: "anamnesis.fact.conflict",
|
||||
|
||||
@@ -106,6 +106,8 @@ describe("anamnesis facts API", () => {
|
||||
"user-1",
|
||||
"SMOKING_STATUS",
|
||||
"FORMER",
|
||||
expect.any(Date),
|
||||
undefined,
|
||||
);
|
||||
expect(mocks.audit).toHaveBeenCalledOnce();
|
||||
});
|
||||
@@ -135,7 +137,13 @@ describe("anamnesis facts API", () => {
|
||||
where: expect.objectContaining({ id: "fact-1", userId: "user-1" }),
|
||||
}),
|
||||
);
|
||||
expect(mocks.correct).toHaveBeenCalledWith("user-1", "fact-1", "NEVER");
|
||||
expect(mocks.correct).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"fact-1",
|
||||
"NEVER",
|
||||
expect.any(Date),
|
||||
undefined,
|
||||
);
|
||||
expect(mocks.audit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
||||
@@ -14,10 +14,18 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
import {
|
||||
createHealthProfileFact,
|
||||
createLifeFact,
|
||||
readHealthProfileFacts,
|
||||
} from "@/lib/profile/health-facts";
|
||||
import { healthProfileFactWriteSchema } from "@/lib/validations/health-profile-facts";
|
||||
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
|
||||
import { factWriteSchema } from "@/lib/validations/life-circumstances";
|
||||
|
||||
/**
|
||||
* `GET` lists the three closed kinds only (`SMOKING_STATUS`, `ALCOHOL_PATTERN`,
|
||||
* `SHIFT_SCHEDULE`): older app builds decode this list closed. `POST` also
|
||||
* takes the six life-circumstance kinds (v1.43, #614), which are read back
|
||||
* through `GET /api/anamnesis/life-circumstances`.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
const payload = await readHealthProfileFacts(user.id);
|
||||
@@ -38,15 +46,33 @@ async function postFact(request: NextRequest): Promise<Response> {
|
||||
});
|
||||
if (error) return error;
|
||||
|
||||
const parsed = healthProfileFactWriteSchema.safeParse(rawBody);
|
||||
const parsed = factWriteSchema.safeParse(rawBody);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
const now = new Date();
|
||||
const validFrom = parsed.data.validFrom
|
||||
? dateOnlyAtNoonUtc(parsed.data.validFrom)
|
||||
: undefined;
|
||||
try {
|
||||
const fact = await createHealthProfileFact(
|
||||
user.id,
|
||||
parsed.data.kind,
|
||||
parsed.data.value,
|
||||
);
|
||||
const input = parsed.data;
|
||||
const fact =
|
||||
input.kind === "SMOKING_STATUS" ||
|
||||
input.kind === "ALCOHOL_PATTERN" ||
|
||||
input.kind === "SHIFT_SCHEDULE"
|
||||
? await createHealthProfileFact(
|
||||
user.id,
|
||||
input.kind,
|
||||
input.value,
|
||||
now,
|
||||
validFrom,
|
||||
)
|
||||
: await createLifeFact(
|
||||
user.id,
|
||||
input.kind,
|
||||
input.value,
|
||||
now,
|
||||
validFrom,
|
||||
);
|
||||
await auditLog("anamnesis.fact.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* `PATCH /api/anamnesis/life-circumstances/ai-sections` — switch which
|
||||
* life-circumstance sections may reach the AI (#614).
|
||||
*
|
||||
* `{ sections: { <SECTION>: boolean }, baseUpdatedAt? }` with at least one
|
||||
* key, over `SMOKING_STATUS`, `ALCOHOL_PATTERN`, `SHIFT_SCHEDULE`, `ACTIVITY`,
|
||||
* `WORK`, `HABITS`, `GOALS`. Sections not named keep their state. A stale
|
||||
* `baseUpdatedAt` is 409 `about_me_conflict`, the token `about-me` uses.
|
||||
* Own account only: which of a person's answers a model may read is that
|
||||
* person's decision.
|
||||
*/
|
||||
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 { annotate } from "@/lib/logging/context";
|
||||
import { updateLifeAiSections } from "@/lib/profile/life-circumstances";
|
||||
import { lifeAiSectionsPatchSchema } from "@/lib/validations/life-circumstances";
|
||||
|
||||
export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = lifeAiSectionsPatchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "anamnesis.lifeSections.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const outcome = await updateLifeAiSections(
|
||||
user.id,
|
||||
parsed.data.sections,
|
||||
parsed.data.baseUpdatedAt ? new Date(parsed.data.baseUpdatedAt) : undefined,
|
||||
);
|
||||
if (outcome.conflict) {
|
||||
annotate({ action: { name: "anamnesis.life_sections.conflict" } });
|
||||
return apiError("Self-context changed since it was loaded", 409, {
|
||||
errorCode: "about_me_conflict",
|
||||
});
|
||||
}
|
||||
|
||||
// Section names and switch states only; nothing about the answers.
|
||||
await auditLog("anamnesis.life_sections.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { sections: parsed.data.sections },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "anamnesis.life_sections.update" },
|
||||
meta: { changed: Object.keys(parsed.data.sections).length },
|
||||
});
|
||||
return apiSuccess({
|
||||
aiSections: outcome.aiSections,
|
||||
updatedAt: outcome.updatedAt,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* `POST /api/anamnesis/life-circumstances/confirm` — "still true" for one
|
||||
* section (#614). `{ section }`; stores the confirmation instant only, never a
|
||||
* new revision, and answers `{ section, lastConfirmedAt }`. Own account only,
|
||||
* like the answers' AI switches: it vouches for the person's own account of
|
||||
* their life, which a delegate cannot do for them.
|
||||
*/
|
||||
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 { confirmLifeSection } from "@/lib/profile/life-circumstances";
|
||||
import { lifeConfirmSchema } from "@/lib/validations/life-circumstances";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = lifeConfirmSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "anamnesis.lifeConfirm.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const lastConfirmedAt = await confirmLifeSection(
|
||||
user.id,
|
||||
parsed.data.section,
|
||||
);
|
||||
annotate({
|
||||
action: { name: "anamnesis.life_section.confirm" },
|
||||
meta: { section: parsed.data.section },
|
||||
});
|
||||
return apiSuccess({ section: parsed.data.section, lastConfirmedAt });
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* `GET /api/anamnesis/life-circumstances` — the six life-circumstance
|
||||
* sections (#614) with their current answers, AI switches, review state,
|
||||
* the derived values (pack-years, activity minutes, free weekdays, screening
|
||||
* hints) and AUDIT-C with its instrument text in the account's language.
|
||||
*
|
||||
* The new fact kinds are served here and only here: `GET /api/anamnesis/facts`
|
||||
* keeps its three kinds for clients that decode that list closed. Delegates
|
||||
* read it under the `profile` grant, like the facts.
|
||||
*/
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { buildLifeCircumstances } from "@/lib/profile/life-circumstances";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
const payload = await buildLifeCircumstances(user.id);
|
||||
annotate({
|
||||
action: { name: "anamnesis.life_circumstances.get" },
|
||||
meta: {
|
||||
fact_count: payload.sections.reduce(
|
||||
(sum, section) => sum + section.facts.length,
|
||||
0,
|
||||
),
|
||||
audit_c_count: payload.auditC.history.length,
|
||||
},
|
||||
});
|
||||
return apiSuccess(payload);
|
||||
});
|
||||
@@ -5,7 +5,11 @@
|
||||
* (`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;
|
||||
* - single sign-on only refuses the PASSWORD claim before anything else,
|
||||
* while the preview answers there and names `sso` as the way in (v1.43);
|
||||
* - the single-sign-on start needs a configured provider, refuses a live
|
||||
* session before the rate limit, checks the link before the username,
|
||||
* and seals the link's id (never the token) into `hl_claim`;
|
||||
* - 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;
|
||||
@@ -24,7 +28,18 @@ vi.mock("@/lib/auth/session", () => ({
|
||||
createSession: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/auth/oidc", () => ({ isOidcOnly: vi.fn() }));
|
||||
vi.mock("@/lib/auth/oidc", () => ({
|
||||
isOidcOnly: vi.fn(),
|
||||
isOidcConfigured: vi.fn(),
|
||||
getOidcConfig: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((s: string) => `sealed(${s})`),
|
||||
decrypt: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/auth/secure-cookie", () => ({
|
||||
shouldEmitSecureCookie: () => true,
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
checkAuthSurfaceRateLimit: vi.fn(),
|
||||
@@ -49,6 +64,7 @@ vi.mock("@/lib/managed-profiles/handover", async () => {
|
||||
return {
|
||||
HandoverError: actual.HandoverError,
|
||||
previewHandover: vi.fn(),
|
||||
usableHandoverForToken: vi.fn(),
|
||||
claimManagedProfile: vi.fn(),
|
||||
};
|
||||
});
|
||||
@@ -67,9 +83,11 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
import { POST as claim } from "../route";
|
||||
import { POST as preview } from "../preview/route";
|
||||
import { POST as claimSso } from "../sso/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createSession, getSession } from "@/lib/auth/session";
|
||||
import { isOidcOnly } from "@/lib/auth/oidc";
|
||||
import { getOidcConfig, isOidcConfigured, isOidcOnly } from "@/lib/auth/oidc";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { checkPasswordStrength, hashPassword } from "@/lib/auth/password";
|
||||
import { checkPasswordBreachIfEnabled } from "@/lib/password-breach-check";
|
||||
@@ -77,6 +95,7 @@ import {
|
||||
claimManagedProfile,
|
||||
HandoverError,
|
||||
previewHandover,
|
||||
usableHandoverForToken,
|
||||
} from "@/lib/managed-profiles/handover";
|
||||
import { notifyGuardiansOfHandover } from "@/lib/managed-profiles/handover-notify";
|
||||
|
||||
@@ -105,6 +124,12 @@ const PREVIEW = {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(isOidcOnly).mockReturnValue(false);
|
||||
vi.mocked(isOidcConfigured).mockReturnValue(false);
|
||||
vi.mocked(getOidcConfig).mockReturnValue(null);
|
||||
vi.mocked(usableHandoverForToken).mockResolvedValue({
|
||||
handoverId: "h1",
|
||||
profileId: "p1",
|
||||
});
|
||||
vi.mocked(getSession).mockResolvedValue(null as never);
|
||||
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
@@ -131,19 +156,62 @@ async function errorCode(res: Response): Promise<unknown> {
|
||||
return body.meta?.errorCode;
|
||||
}
|
||||
|
||||
const OIDC_CONFIG = {
|
||||
issuerUrl: "https://idp.example.test",
|
||||
clientId: "c",
|
||||
clientSecret: "s",
|
||||
scopes: "openid email",
|
||||
buttonLabel: "Example ID",
|
||||
};
|
||||
|
||||
function singleSignOnOnly(): void {
|
||||
vi.mocked(isOidcOnly).mockReturnValue(true);
|
||||
vi.mocked(isOidcConfigured).mockReturnValue(true);
|
||||
vi.mocked(getOidcConfig).mockReturnValue(OIDC_CONFIG);
|
||||
}
|
||||
|
||||
describe("on a single-sign-on-only instance", () => {
|
||||
it("refuses the password claim before anything else", async () => {
|
||||
singleSignOnOnly();
|
||||
vi.mocked(getSession).mockResolvedValue({ user: { id: "u1" } } as never);
|
||||
const res = await claim(post("/api/auth/claim", BODY));
|
||||
expect(res.status).toBe(403);
|
||||
expect(await errorCode(res)).toBe("profile_claim.oidc_only_unsupported");
|
||||
expect(previewHandover).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers the preview and names single sign-on as the only way in", async () => {
|
||||
singleSignOnOnly();
|
||||
const res = await preview(
|
||||
post("/api/auth/claim/preview", { token: TOKEN }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: Record<string, unknown> };
|
||||
expect(body.data.methods).toEqual(["sso"]);
|
||||
expect(body.data.ssoProviderName).toBe("Example ID");
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["claim", claim, "/api/auth/claim", BODY],
|
||||
["preview", preview, "/api/auth/claim/preview", { token: TOKEN }],
|
||||
["sso", claimSso, "/api/auth/claim/sso", { token: TOKEN, username: "alex" }],
|
||||
] 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();
|
||||
(name, route, path, body) => {
|
||||
beforeEach(() => {
|
||||
if (name === "sso") {
|
||||
vi.mocked(isOidcConfigured).mockReturnValue(true);
|
||||
vi.mocked(getOidcConfig).mockReturnValue(OIDC_CONFIG);
|
||||
}
|
||||
if (name === "sso") {
|
||||
vi.mocked(usableHandoverForToken).mockImplementation(async () => {
|
||||
// Routes the sso start through the same "link" spy the other two
|
||||
// use, so one set of assertions covers all three.
|
||||
const found = await previewHandover(TOKEN);
|
||||
return found ? { handoverId: "h1", profileId: "p1" } : null;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses over a live session before spending the rate limit", async () => {
|
||||
@@ -234,7 +302,7 @@ describe("POST /api/auth/claim", () => {
|
||||
expect(res.status).toBe(201);
|
||||
expect(claimManagedProfile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rawToken: TOKEN,
|
||||
link: { rawToken: TOKEN },
|
||||
username: "alex",
|
||||
email: "alex@example.test",
|
||||
passwordHash: "argon2-hash",
|
||||
@@ -260,7 +328,101 @@ describe("POST /api/auth/claim/preview", () => {
|
||||
"displayName",
|
||||
"expiresAt",
|
||||
"guardians",
|
||||
"methods",
|
||||
"ssoProviderName",
|
||||
]);
|
||||
expect(body.data.methods).toEqual(["password"]);
|
||||
expect(body.data.ssoProviderName).toBeNull();
|
||||
expect(body.data.expiresAt).toBe("2030-01-01T00:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/claim/sso", () => {
|
||||
const SSO_BODY = { token: TOKEN, username: "alex" };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(isOidcConfigured).mockReturnValue(true);
|
||||
vi.mocked(getOidcConfig).mockReturnValue(OIDC_CONFIG);
|
||||
});
|
||||
|
||||
it("answers 404 sso_unavailable without an identity provider, before anything else", async () => {
|
||||
vi.mocked(isOidcConfigured).mockReturnValue(false);
|
||||
vi.mocked(getSession).mockResolvedValue({ user: { id: "u1" } } as never);
|
||||
const res = await claimSso(post("/api/auth/claim/sso", SSO_BODY));
|
||||
expect(res.status).toBe(404);
|
||||
expect(await errorCode(res)).toBe("profile_claim.sso_unavailable");
|
||||
expect(checkAuthSurfaceRateLimit).not.toHaveBeenCalled();
|
||||
expect(usableHandoverForToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("works on a single-sign-on-only instance", async () => {
|
||||
singleSignOnOnly();
|
||||
const res = await claimSso(post("/api/auth/claim/sso", SSO_BODY));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("draws on the password claim's bucket", async () => {
|
||||
await claimSso(post("/api/auth/claim/sso", SSO_BODY));
|
||||
expect(checkAuthSurfaceRateLimit).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"auth:claim",
|
||||
5,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
});
|
||||
|
||||
it("checks the link before the username probe", async () => {
|
||||
vi.mocked(usableHandoverForToken).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: "x" } as never);
|
||||
const res = await claimSso(post("/api/auth/claim/sso", SSO_BODY));
|
||||
expect(res.status).toBe(404);
|
||||
expect(await errorCode(res)).toBe("profile_claim.invalid");
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
expect(res.headers.get("set-cookie")).toBeNull();
|
||||
});
|
||||
|
||||
it("answers 409 taken for a username in use, and sets no cookie", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: "x" } as never);
|
||||
const res = await claimSso(post("/api/auth/claim/sso", SSO_BODY));
|
||||
expect(res.status).toBe(409);
|
||||
expect(await errorCode(res)).toBe("profile_claim.taken");
|
||||
expect(res.headers.get("set-cookie")).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses an unknown field and a managed- username (strict body)", async () => {
|
||||
for (const body of [
|
||||
{ ...SSO_BODY, email: "x@example.test" },
|
||||
{ ...SSO_BODY, username: "managed-alex" },
|
||||
]) {
|
||||
const res = await claimSso(post("/api/auth/claim/sso", body));
|
||||
expect(res.status).toBe(422);
|
||||
}
|
||||
expect(usableHandoverForToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("seals the link's id and the username, never the token, into hl_claim", async () => {
|
||||
const res = await claimSso(post("/api/auth/claim/sso", SSO_BODY));
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data).toEqual({
|
||||
redirectTo: "/api/auth/oidc/login?intent=claim",
|
||||
});
|
||||
const cookie = res.headers.get("set-cookie") ?? "";
|
||||
expect(cookie).toMatch(/^hl_claim=/);
|
||||
expect(cookie).toMatch(/Path=\/api\/auth\/oidc/);
|
||||
expect(cookie).toMatch(/HttpOnly/i);
|
||||
expect(cookie).toMatch(/Secure/i);
|
||||
expect(cookie).toMatch(/SameSite=lax/i);
|
||||
expect(cookie).toMatch(/Max-Age=600/);
|
||||
const sealed = decodeURIComponent(cookie.split(";")[0]!.slice(9));
|
||||
const payload = JSON.parse(sealed.replace(/^sealed\(/, "").slice(0, -1));
|
||||
expect(payload.h).toBe("h1");
|
||||
expect(payload.u).toBe("alex");
|
||||
expect(typeof payload.b).toBe("string");
|
||||
expect(cookie).not.toContain(TOKEN);
|
||||
expect(JSON.stringify(vi.mocked(auditLog).mock.calls)).not.toContain(TOKEN);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"managed_profile.claim_sso.started",
|
||||
expect.objectContaining({ userId: "p1", details: { handoverId: "h1" } }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@
|
||||
* access (v1.42, #959). No health data.
|
||||
*
|
||||
* Anonymous by design: the person opening the link has no credentials yet.
|
||||
* Answered on a single-sign-on-only server too (v1.43): `methods` then names
|
||||
* only `sso`, and the page offers the identity provider.
|
||||
* 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
|
||||
@@ -15,7 +17,12 @@ 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 {
|
||||
claimGate,
|
||||
claimInvalid,
|
||||
handoverMethods,
|
||||
ssoProviderName,
|
||||
} from "@/lib/managed-profiles/claim-gate";
|
||||
import { previewHandover } from "@/lib/managed-profiles/handover";
|
||||
import { claimPreviewSchema } from "@/lib/validations/managed-profile-handover";
|
||||
|
||||
@@ -46,5 +53,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
displayName: preview.displayName,
|
||||
expiresAt: preview.expiresAt.toISOString(),
|
||||
guardians: preview.guardians,
|
||||
// v1.43 (#959) — which ways in this server offers, so the page shows the
|
||||
// password form, the provider button, or both.
|
||||
methods: handoverMethods(),
|
||||
ssoProviderName: ssoProviderName(),
|
||||
});
|
||||
});
|
||||
@@ -104,7 +104,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
let claimed;
|
||||
try {
|
||||
claimed = await claimManagedProfile({
|
||||
rawToken: token,
|
||||
link: { rawToken: token },
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* `POST /api/auth/claim/sso` — start claiming a managed profile through
|
||||
* single sign-on (v1.43, #959).
|
||||
*
|
||||
* Anonymous, like the password claim beside it, and ordered like it:
|
||||
*
|
||||
* 1. refusals that read no token (`claimGate`: no identity provider, a live
|
||||
* session, the per-address bucket the password claim also draws on);
|
||||
* 2. the body, strictly parsed, and the link checked;
|
||||
* 3. the username probe, so the person learns a name is taken here rather
|
||||
* than after the round trip to the provider.
|
||||
*
|
||||
* Nothing about the account changes here. The answer is a sealed `hl_claim`
|
||||
* cookie (`claim-cookie.ts`) naming the link by its id and carrying the
|
||||
* chosen username, and `{ redirectTo }` for the browser to navigate to. The
|
||||
* OIDC callback binds the provider's subject to the profile, under the lock,
|
||||
* with every check repeated. The token never rides a URL and never reaches the
|
||||
* cookie.
|
||||
*/
|
||||
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 { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
CLAIM_SSO_REDIRECT,
|
||||
setClaimCookie,
|
||||
} from "@/lib/managed-profiles/claim-cookie";
|
||||
import { claimGate, claimInvalid } from "@/lib/managed-profiles/claim-gate";
|
||||
import { usableHandoverForToken } from "@/lib/managed-profiles/handover";
|
||||
import { claimSsoSchema } from "@/lib/validations/claim-sso";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// Refuses a signed-in caller and rate-limits the address, as the password
|
||||
// claim does.
|
||||
const gate = await claimGate(request, "sso", (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 = claimSsoSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
const { token, username } = parsed.data;
|
||||
|
||||
// The link first, so the username probe below is no oracle for anybody
|
||||
// without a live link (the password claim's argument).
|
||||
const link = await usableHandoverForToken(token);
|
||||
if (!link) {
|
||||
annotate({
|
||||
action: { name: "profile_claim.sso.start" },
|
||||
meta: { found: false },
|
||||
});
|
||||
return claimInvalid();
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
return apiError("Username already taken", 409, {
|
||||
errorCode: "profile_claim.taken",
|
||||
});
|
||||
}
|
||||
|
||||
const response = apiSuccess({ redirectTo: CLAIM_SSO_REDIRECT });
|
||||
setClaimCookie(response, { handoverId: link.handoverId, username });
|
||||
|
||||
// Filed under the record, with no actor: nobody is signed in yet. No token,
|
||||
// no username — the username is not the person's until the claim lands.
|
||||
await auditLog("managed_profile.claim_sso.started", {
|
||||
userId: link.profileId,
|
||||
actorUserId: null,
|
||||
ipAddress: gate.ip,
|
||||
details: { handoverId: link.handoverId },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "profile_claim.sso.start" },
|
||||
meta: { found: true },
|
||||
});
|
||||
return response;
|
||||
});
|
||||
@@ -80,6 +80,7 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verifyPasswordOrDummy } from "@/lib/auth/password";
|
||||
import { bodyWithoutRequestId } from "@/__tests__/helpers/error-envelope";
|
||||
|
||||
function makeRequest(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/login", {
|
||||
@@ -159,7 +160,7 @@ describe("POST /api/auth/login — constant work across the refusals", () => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(account as never);
|
||||
const res = await POST(makeRequest());
|
||||
statuses.push(res.status);
|
||||
bodies.push(await res.text());
|
||||
bodies.push(await bodyWithoutRequestId(res));
|
||||
}
|
||||
expect(new Set(statuses)).toEqual(new Set([401]));
|
||||
expect(new Set(bodies).size).toBe(1);
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* `PUT /api/auth/me/ai-usage` (#1217): the account's own level, within the
|
||||
* operator's ceiling. A level above it is refused with 422
|
||||
* `ai.usage.aboveCeiling` and `meta.maxLevel`, never capped silently; `null`
|
||||
* returns to the instance default; the answer is the resolved `ai.usage`.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
stored: null as string | null,
|
||||
settings: {
|
||||
aiUsageMaxLevel: "STANDARD",
|
||||
aiUsageDefaultLevel: "STANDARD",
|
||||
aiSyncRegenGapHours: null,
|
||||
} as Record<string, unknown>,
|
||||
usage: [] as Array<{ totalTokens: number }>,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(async () => ({
|
||||
aiUsageLevel: db.stored,
|
||||
timezone: "UTC",
|
||||
})),
|
||||
update: vi.fn(async (args: { data: { aiUsageLevel: string | null } }) => {
|
||||
db.stored = args.data.aiUsageLevel;
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
appSettings: { findUnique: vi.fn(async () => db.settings) },
|
||||
coachUsage: { findMany: vi.fn(async () => db.usage) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveUserTimezone: vi.fn(async () => "UTC"),
|
||||
}));
|
||||
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(),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PUT } 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";
|
||||
|
||||
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 put = PUT as unknown as (req: Request) => Promise<Response>;
|
||||
|
||||
function req(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/ai-usage", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
db.stored = null;
|
||||
db.settings = {
|
||||
aiUsageMaxLevel: "STANDARD",
|
||||
aiUsageDefaultLevel: "STANDARD",
|
||||
aiSyncRegenGapHours: null,
|
||||
};
|
||||
db.usage = [];
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 3_600_000,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/me/ai-usage", () => {
|
||||
it("stores a level within the ceiling and answers the resolved state", async () => {
|
||||
db.usage = [
|
||||
{ totalTokens: 90_000 },
|
||||
{ totalTokens: 110_000 },
|
||||
{ totalTokens: 100_000 },
|
||||
];
|
||||
const res = await put(req({ level: "frugal" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(vi.mocked(prisma.user.update).mock.calls[0]![0]).toEqual({
|
||||
where: { id: "user-1" },
|
||||
data: { aiUsageLevel: "FRUGAL" },
|
||||
});
|
||||
const { data } = await res.json();
|
||||
expect(data).toMatchObject({
|
||||
level: "frugal",
|
||||
preference: "frugal",
|
||||
defaultLevel: "standard",
|
||||
maxLevel: "standard",
|
||||
source: "user",
|
||||
measuredTokensPerDay: 100_000,
|
||||
measuredDays: 3,
|
||||
});
|
||||
expect(data.options).toEqual([
|
||||
{ level: "frugal", allowed: true, estimatedTokensPerDay: 100_000 },
|
||||
{ level: "standard", allowed: true, estimatedTokensPerDay: 200_000 },
|
||||
{ level: "full", allowed: false, estimatedTokensPerDay: 666_667 },
|
||||
]);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.aiUsage.update",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: { previous: null, next: "frugal" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a level above the ceiling with the ceiling named", async () => {
|
||||
const res = await put(req({ level: "full" }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body.meta).toMatchObject({
|
||||
errorCode: "ai.usage.aboveCeiling",
|
||||
maxLevel: "standard",
|
||||
});
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts full once the operator allows it", async () => {
|
||||
db.settings = { ...db.settings, aiUsageMaxLevel: "FULL" };
|
||||
const res = await put(req({ level: "full" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data.level).toBe("full");
|
||||
});
|
||||
|
||||
it("returns to the instance default on null", async () => {
|
||||
db.stored = "FRUGAL";
|
||||
const res = await put(req({ level: null }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(vi.mocked(prisma.user.update).mock.calls[0]![0].data).toEqual({
|
||||
aiUsageLevel: null,
|
||||
});
|
||||
const { data } = await res.json();
|
||||
expect(data).toMatchObject({
|
||||
level: "standard",
|
||||
preference: null,
|
||||
source: "default",
|
||||
});
|
||||
// Fewer than three days of history: no estimate.
|
||||
expect(
|
||||
data.options.every(
|
||||
(o: { estimatedTokensPerDay: number | null }) =>
|
||||
o.estimatedTokensPerDay === null,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ level: "lavish" }],
|
||||
[{}],
|
||||
[{ level: "frugal", extra: true }],
|
||||
[{ level: 1 }],
|
||||
])("refuses %j as invalid", async (body) => {
|
||||
const res = await put(req(body));
|
||||
expect(res.status).toBe(422);
|
||||
expect((await res.json()).meta.errorCode).toBe("ai.usage.invalid");
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses the 31st change in an hour", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const res = await put(req({ level: "frugal" }));
|
||||
expect(res.status).toBe(429);
|
||||
expect((await res.json()).meta.errorCode).toBe("ai.usage.rateLimited");
|
||||
expect(vi.mocked(checkRateLimit).mock.calls[0]!.slice(1)).toEqual([
|
||||
30,
|
||||
60 * 60 * 1000,
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses a caller without a session", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await put(req({ level: "frugal" }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* `PUT /api/auth/me/ai-usage` — the account's own AI usage level (#1217).
|
||||
*
|
||||
* `{ level: "frugal" | "standard" | "full" | null }`; `null` follows the
|
||||
* instance default. A level above the operator's ceiling is refused with 422
|
||||
* `ai.usage.aboveCeiling`, never capped silently. Answers the resolved
|
||||
* `AiUsageState`, the same block `GET /api/auth/me` publishes as `ai.usage`.
|
||||
* Own account only (cookie or a full-scope token), at most 30 writes an hour.
|
||||
*/
|
||||
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 { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
loadAiUsageSettings,
|
||||
loadAiUsageState,
|
||||
} from "@/lib/ai/capabilities/usage";
|
||||
import { usageLevelRank, usageLevelToDb } from "@/lib/ai/usage-level";
|
||||
import { aiUsagePutSchema } from "@/lib/validations/ai-usage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(`ai-usage:${user.id}`, 30, 60 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many changes, try again later", 429, {
|
||||
errorCode: "ai.usage.rateLimited",
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = aiUsagePutSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422, {
|
||||
errorCode: "ai.usage.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { level } = parsed.data;
|
||||
const settings = await loadAiUsageSettings();
|
||||
if (
|
||||
level !== null &&
|
||||
usageLevelRank(level) > usageLevelRank(settings.maxLevel)
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "auth.me.ai_usage.put.above_ceiling" },
|
||||
meta: { level, maxLevel: settings.maxLevel },
|
||||
});
|
||||
return apiError(
|
||||
"This level is above the highest one the server allows",
|
||||
422,
|
||||
{ errorCode: "ai.usage.aboveCeiling", maxLevel: settings.maxLevel },
|
||||
);
|
||||
}
|
||||
|
||||
const previous = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { aiUsageLevel: true },
|
||||
});
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { aiUsageLevel: level === null ? null : usageLevelToDb(level) },
|
||||
});
|
||||
await auditLog("user.aiUsage.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
previous: previous?.aiUsageLevel?.toLowerCase() ?? null,
|
||||
next: level,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.me.ai_usage.put" },
|
||||
meta: { level: level ?? "default" },
|
||||
});
|
||||
|
||||
return apiSuccess(await loadAiUsageState(user.id, { withMeasurement: true }));
|
||||
});
|
||||
Loaded 100 of 896 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user