Merge pull request #1262 from MBombeck/release/v1.44.0

Release v1.44.0
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-22 03:03:28 +02:00
commit fdd0f50130
623 files changed
+44995 -5587

No files matched your search

+5 -3
View File
@@ -533,9 +533,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# TRUST_CF_CONNECTING_IP="1"
# Memory cap of the app container (read by docker-compose.yml, not by the app).
# Node sizes its heap from it: 1536m (the default) gives 792 MB, 1g only 524 MB,
# which a long-used account fills in normal operation. Lower it on a host with
# 2 GB or less, raise it on a busy instance.
# Node sizes its heap from it: 1536m (the default) gives 792 MB. 1536m is the
# supported floor: below it a long-used account fills the heap in normal
# operation, and the boot log, the support report and Admin > System status
# say the app runs below the supported floor. The host needs 3 GB of RAM,
# 4 GB recommended. Raise it on a busy instance; do not lower it.
# APP_MEMORY_LIMIT="1536m"
# -----------------------------------------------------------------------------
+108
View File
@@ -1,5 +1,113 @@
# Changelog
## [1.44.0] — 2026-10-22
### Added
- **Import lab results and medications from a spreadsheet.** Settings >
Export & Import has a card that reads the head of a CSV file, suggests a
column for each field from its header, and lets you correct the
separator, decimal mark, date order and time zone. A check run lists
every refused row with its line and reason before anything is written,
and importing the same file again only finds duplicates. Templates for
both kinds download from the card. (#1215)
- **An activity calendar** on the workouts page: a week or month of steps,
active minutes, workouts by sport and training load, each day measured
against your own usual range and each period against the ones before.
Weekly and monthly goals are optional. Today counts as in progress until
it is over.
- **Fitness, fatigue and form** above the workout list, from the training
impulse HealthLog already stores. A day with activity data but no
training counts as rest; a day without any data is left out instead of
counted, and the card says how current the figures are.
- **Where a value comes from.** Each metric page links to a view that lists
every source with readings for a day, its place in your source priority
and which one the charts use, with a side-by-side comparison of two
sources over 30, 90 or 365 days.
- **Lab trends.** A marker with at least three readings over a year shows
its slope, and when the straight line would leave the reference range
within three years. A series whose newest reading is more than a year
old is shown without a projection.
- **The change at a medication start**, as a level and a trend change with
intervals, next to the existing before and after comparison. Days with
missed doses, pauses and other medications that changed in the window are
accounted for, and the view says when the data are too thin.
- **More in the doctor report:** sleep duration, HRV as SDNN and RMSSD,
daily steps, a workout summary per sport and the medication effect, each
only when its module is on. The medication effect is included only when
the medication list is shared too. The report names the window it
analysed.
- **More correlations:** water, caffeine, micronutrients, alcohol days,
workout minutes, training impulse, activity ring minutes, State of Mind
and room climate against that night's sleep. State of Mind, mindful
minutes and alcohol days stay on the correlations page and are never
handed to the Coach or the narrative cards.
- **Level shifts in thirteen vitals over 180 days** instead of five over 90.
Slow drifts and plateaus no longer count as a shift.
- **Mental health screeners** show whether a change reaches the threshold
that counts as meaningful.
- **Apple Health:** exercise, stand and mindful minutes, stand hours, State
of Mind and AFib burden. Workout GPS tracks are kept only when you turn
that on (Settings > Data & privacy); tracks already stored stay, and
accounts that already kept tracks start with it on.
- **Oura:** workouts, intraday heart rate and a full history backfill that
resumes after a restart.
- **Indoor climate** (CO2, temperature, humidity) through the measurement
API, with a Home Assistant blueprint.
- **A guardian can connect a managed profile's integrations** (Withings,
WHOOP, Oura, Polar, Strava, Google Health), and each card says whose
record it connects for.
- **FHIR:** Observations as a time series by `date` and `code`, with RMSSD
and workouts coded.
### Changed
- **Settings are sorted into nine groups:** Account, Tracking,
Arrangement, Notifications, Health profile, AI, Connections, Data and
System. Every old address still works and forwards to the new place. On
a phone one button names the current section and opens the list. Each
card saves the same way and asks once before anything destructive, and
German wording uses one word per thing.
- **All OAuth integrations refresh their tokens the same way**, so a
refresh that races another one no longer signs you out of Strava, Google
Health or Withings. One provider account can be linked to only one
record.
- **Imports from Apple Health insert large batches faster**, and the health
check answers "starting" while the background jobs come up, so a slow
boot no longer marks the container unhealthy.
- **The metric pages load about 90 KB less JavaScript** on first visit.
### Fixed
- Oura no longer imports the equivalent walking distance as distance.
- A workout synced from two sources counts once in strain, the briefing
and the day view.
- The Coach's provider and model switch is back, and notification channel
switches act on the first tap again.
- Jobs queued during start-up wait for the queue instead of being dropped.
### Upgrade notes
- **1536 MB is the supported memory floor for the app container.** This is
the existing default of `APP_MEMORY_LIMIT`; if you lowered it, raise it
back. Below the floor the app still starts, but the boot log, the
support report and Admin > System status say so. Plan for 3 GB of RAM on
the host.
- Migrations 0424, 0430 to 0434, 0440 to 0442 and 0470 run on start. They
add columns and settings, turn the GPS track setting on for accounts
that already have tracks, and drop a cached copy of the AI insights
text, which is rebuilt on the next run. Workouts with a track but no
elevation get their climb filled in by a background pass after the
first start.
- If you use Fitbit, connect Google Health before 30 October 2026, when
Google turns the old Fitbit API off.
### Thanks
Thanks to @Cnote43 for the case for a spreadsheet import of twenty years of
lab results (#1215), and to @alondin and @haritowa for staying with the
Oura connection until it held (#1204).
## [1.43.11] — 2026-10-21
### Fixed
+5 -2
View File
@@ -320,8 +320,11 @@ ENV HOSTNAME="0.0.0.0"
# Start period: the entrypoint migrates before the server listens, and a
# migration waiting out a held lock retries for up to about eight minutes.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10m --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1
# Five retries: a pool saturated for a minute during a boot storm makes the
# probe's 2 s database check miss once or twice without marking the container
# unhealthy (docker-compose.yml explains the numbers).
HEALTHCHECK --interval=30s --timeout=5s --start-period=10m --retries=5 \
CMD wget --no-verbose --tries=1 --timeout=4 --spider http://127.0.0.1:3000/api/health || exit 1
ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["node", "server.js"]
+2 -2
View File
@@ -1,5 +1,5 @@
{
"$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": 355,
"/insights/page": 323,
@@ -8,7 +8,7 @@
"/settings/[section]/page": 307
},
"routeDriftAllowanceKbGz": 20,
"totalClientKbGz": 3755,
"totalClientKbGz": 3900,
"maxRechartsChunks": 1,
"maxCatalogKbGz": 210
}
+19 -6
View File
@@ -503,25 +503,38 @@ services:
# workload can grow large enough to starve Postgres on a shared host.
# Node sizes its heap from this limit: 1g gives a 524 MB heap, which a
# long-used account fills in normal operation, so garbage collection
# stalls requests for seconds. 1536m gives 792 MB. The support report and
# Admin > System status flag a heap below 700 MB. On a host with 2 GB or less, lower it with
# APP_MEMORY_LIMIT in .env; on a busy instance, raise it.
# stalls requests for seconds. 1536m gives 792 MB and is the supported
# floor (host: 3 GB RAM, 4 GB recommended). Below it the boot log, the
# support report and Admin > System status say so. On a busy instance,
# raise it with APP_MEMORY_LIMIT in .env; do not lower it.
mem_limit: "${APP_MEMORY_LIMIT:-1536m}"
healthcheck:
test:
[
"CMD-SHELL",
"wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1",
"wget --no-verbose --tries=1 --timeout=4 --spider http://127.0.0.1:3000/api/health || exit 1",
]
interval: 30s
timeout: 5s
retries: 3
# Five misses in a row, two and a half minutes, before "unhealthy". The
# probe gives the database two seconds to answer SELECT 1 and says 503
# past that, so a boot storm that saturates the pool for a minute after
# the start period (backfills, a restore, another tenant's import) costs
# a probe or two, not the container's health and the proxy's routing.
# wget's own 4 s timeout ends the request inside Docker's 5 s, so a
# stalled connection reads as one clean miss rather than a killed probe.
# A database that stays away for 2.5 minutes is a real outage and shows.
retries: 5
# The entrypoint applies migrations before the server listens. A
# migration that waits out a held lock retries for up to about eight
# minutes (docs/ops/deploy.md), and failed probes in that time must not
# read as a dead container. The first probe that answers still ends
# the start period, so a normal boot is healthy within 30 seconds of
# listening.
# listening. After it listens, the pg-boss worker starts in the
# background and /api/health answers 503 with reason "starting" until it
# is up (10–60 s under load, reported as "starting" for at most five
# minutes); inside the start period those probes do not count, so a slow
# worker start cannot mark the container unhealthy.
start_period: 10m
# Optional dedicated worker container — uncomment + set
+5 -1
View File
@@ -56,7 +56,11 @@ notes, and machine-readable specs that ship alongside the source:
— the iOS submission gate.
- [`codex-protocol-spec.md`](./codex-protocol-spec.md) — Codex / ChatGPT
OAuth protocol reverse-engineering notes.
- [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style.
- [`contributing/ui-standards.md`](./contributing/ui-standards.md) — the
binding UI standards: card anatomy, spacing, text colour, tokens and
which primitive to use where. Read before touching any surface.
- [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style; where
it disagrees with the UI standards, the standards win.
If you're a self-hoster looking to spin up an instance, start at
<https://docs.healthlog.dev> or jump to the [Quick Start in the project
+3416 -101
View File
File diff suppressed because it is too large. Load diff
+184
View File
@@ -0,0 +1,184 @@
# UI standards
The binding reference for building or touching any surface of the web app. The rules are derived from the primitives' own contracts (`src/components/ui/card.tsx`, `tile-header.tsx`, `page-header.tsx`, `settings/_card-header.tsx`, `layout/auth-shell.tsx`) and from a review of every surface against them. When a rule here conflicts with an existing file, the file is drift: fix it when you touch it, don't copy it. Where an older code comment or [`docs/ui-guidelines.md`](../ui-guidelines.md) says otherwise, this document wins. A deviation needs a written reason in the pull request.
---
## 1. Card anatomy
**The Card is gap-based, not padding-based** (`src/components/ui/card.tsx:24`): `flex flex-col gap-4 rounded-xl border py-4 md:gap-6 md:py-6`; header/content/footer carry `px-4 md:px-6`. The header→body distance comes from the **parent flex gap**, never from slot padding.
- FORBIDDEN: `pb-*` on `CardHeader`, `pt-*` on `CardContent`. Under the gap model these add on top of the gap (or are no-ops) and invert their intent. Want a tighter card? Override the gap on the Card itself: `<Card className="gap-2">`.
- Two densities, nothing else:
- `default`: Card as shipped: `py-4 md:py-6`, `gap-4 md:gap-6`.
- `compact`: `<Card density="compact">` (stat strips, assessment blocks, small tiles); resolves to `gap-2 py-3 md:gap-2 md:py-4`. Never spell it as a class list: the hand-written `gap-2 py-3 md:py-4` kept the base `md:gap-6` and opened 24 px on desktop (v1.43.8; `card-density.test.tsx` refuses the class list on a Card).
- No `md:py-5` half-steps.
- Radius: cards are `rounded-xl` (tracks `--radius`). `rounded-md` = controls, `rounded-lg` = inner tiles, `rounded-full` = pills. `rounded-2xl` only as a deliberately marked identity surface (Coach hero/input).
- Hand-rolled `bg-card … border … p-N` shells are legacy. New surfaces compose `<Card>` (or `SettingsCard` in Settings). If a hand-rolled shell must exist, its padding is exactly `p-4 md:p-6` or `p-3` (dense tile), never `p-5`, never a static `p-6`, never a `sm:` step (`SettingsCard`'s docblock explains why the breakpoint is `md:`).
- **Insights overview sections use `InsightSectionCard`** (`src/components/insights/insight-section-card.tsx`). It composes `<Card>`, so the frame is the card contract, and pins the section's inner rhythm at one step (`gap-3`); the six hand-rolled copies it replaced had drifted to three different rhythms in the same column. A section whose body is an edge-to-edge divided list (rows owning their own inset) passes `flush`; everything else takes the padded body. Do not hand-roll a section shell beside it.
- **The dashboard/insights hero band is a sanctioned identity surface.** Its translucent `bg-card/65 … backdrop-blur-sm` tiles are deliberate, not drift: the band reads as one glass surface behind the page's opening statement. It stays out of the shell sweep. Everything below the band is a plain card.
### Header patterns; exactly three, pick by surface
| Surface | Primitive | Contract |
| ---------------------------------- | ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Page title | `PageHeader` (`ui/page-header.tsx`) | H1 `text-2xl font-bold tracking-tight`, description `text-muted-foreground text-sm`, `space-y-1.5`, **no icon beside the H1**, optional backLink + actions slot |
| Settings card | `SettingsCard` + `SettingsCardHeader` (`settings/_card-header.tsx`) | icon `text-muted-foreground h-5 w-5`, title `h2 text-lg font-semibold`, status slot top-right, description muted below |
| Insights / dashboard / module tile | `Card` + `TileHeader` (`insights/tile-header.tsx`) | icon **`text-foreground` `h-5 w-5 shrink-0`**, title `CardTitle text-base`, `flex items-center gap-2`, optional `right` slot |
**Icon colour rule (both header contracts):** header icons are `text-foreground` (tiles) or `text-muted-foreground` (settings). Never `text-primary`, `text-info`, or any accent; primary/purple stays reserved for actions and highlight states. Pass the Lucide component (`icon={Target}`), never a pre-sized node; the primitive owns size and colour.
The gaps this section used to list are closed: `TileHeader` carries a `size="sm"` variant for compact tiles and an optional `icon` for icon-less headers, and `InsightSectionCard` is the overview container mirroring `SettingsCard`. Use them; do not invent a fourth header language.
## 2. Spacing scale
> **Scroll height is shell-owned.** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper + `<md` bottom-nav clearance on `<main>`). Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `<main>`; nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`.
- **Allowed steps:** `1 / 1.5 / 2 / 2.5 / 3 / 3.5 / 4 / 6 / 8 / 10`. `2.5` and `3.5` are allowed: the medication card's rhythm is built on them, and the in-between steps earn their keep in dense card interiors. `5` (20 px), as in `py-5` or `p-5`, is not allowed; replace it when you touch a file that still has it. `7` (28 px) is not allowed either: it was the old Settings and Admin `pl-7` body gutter, which is gone (§13).
- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`layout/auth-shell.tsx:318`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`.
- **Page rhythm:** root of every page is `space-y-6`. Grouped card sections: `space-y-10` between groups, `space-y-4` inside a group (notifications/integrations pattern). Insights sub-pages join `space-y-6`.
- **Left reading edge:** page-level text (H1, explainer, section headings) sits at the container edge. Only surfaces that paint a visible border/background may inset their content (`px-4 md:px-6`). A block without a border must not carry horizontal padding.
- **Grids:** grids **of** cards/tiles = `gap-4`; value read-outs **inside** one card = `gap-x-3 gap-y-2`; form field grids = `gap-4`.
- **Anchors:** `scroll-mt-28`, single token (31-to-1 in the tree already).
## 3. Text colour rules
The base tokens are AA-tuned in both themes (`--muted-foreground`: 5.0–6.4:1, documented in `globals.css`). The system is two tiers:
- **`text-foreground` = content.** Everything the user came to read: values, chart labels the reading depends on, AI narrative bodies, consent/safety copy, and **all user-entered data** (notes, journal text, free-text fields). User data is never muted.
- **`text-muted-foreground` = meta.** Timestamps, units, provenance, counts, placeholders, one-sentence descriptions under a heading. Heading-in-foreground + short-description-in-muted is the app-wide convention (CardDescription, PageHeader, SettingsCardHeader); keep it; do not "fix" it to foreground.
- **Description slots hold exactly one meta sentence, guideline ≤ 120 characters.** One sentence, one full stop, no paragraph stack. 120 characters is the guideline, not a hard cut: it is the p75 of the descriptions that already read well, and a description that needs 130 characters to stay true is fine while a two-sentence one never is. Multi-sentence explainer prose goes in the body as `<p className="text-sm">` (foreground), not into a muted description slot; if the body would not carry it either, the sentence is not needed on the surface at all and belongs in the docs. The description slot renders a single text run; it deliberately owns no vertical rhythm of its own, so a call site cannot stack paragraphs into it.
- **One statement, one string.** A section's page subtitle and its card description must not say the same thing twice on one screen. When a section owns a single card, the subtitle carries the statement and the card header drops its description. Same rule for a field label and its field description: the label names the field, the description adds what the label cannot.
- **FORBIDDEN: opacity modifiers on muted text.** `text-muted-foreground/70` ≈ 2.9–3.9:1 and `/80` ≈ 3.4–4.2:1, below AA, and they silently undo the token tuning. Full `text-muted-foreground` IS the de-emphasis tier; a further tier is expressed via size/weight, never via alpha. `text-foreground/80` is safe (≥6.7:1) and allowed. `/60` is icon-only (3:1 graphics floor).
- **Disabled look:** `opacity-50` on the container + `aria-disabled`, not colour dimming on the text node.
- **Size floor:** muted text is never smaller than `text-xs`. Below that, the one legal step is the `text-2xs` token (0.6875rem, minted in `globals.css`) and it is for chips, badges, and tabular-nums micro-labels with verified contrast, never for muted body or meta text. Arbitrary sizes (`text-[0.65rem]`, `text-[11px]`) are drift; chart-axis `text-[10px]`/`text-[11px]` stays tolerated as documented.
- **Status colours** carry meaning, not decoration: `text-success` / `text-warning` / `text-info` / `text-destructive` (+ `bg-*/10` washes). All four are AA-retuned per theme.
## 4. Colour token rules
- **Semantic tokens only** in components: `--foreground/--muted-foreground/--primary/--success/--warning/--info/--destructive/--chart-1..5/--border/--card`. The light theme remaps them wholesale; anything outside the token layer is invisible to a retune.
- **No raw `dracula-*` utilities in new code.** Only `--dracula-yellow` has a light override; `text-dracula-green` on a light card is 1.4:1. Status meaning → semantic token; brand identity (Coach gradient) → a named `--brand-*` token with per-theme values.
- **No hex in JS props.** Chart colours are passed as `var(--token)` strings, never `color="#22c55e"`. Data series use the `--chart-1..5` tokens (`color="var(--chart-2)"`); status tokens (`--success`, `--warning`, `--destructive`, `--info`) only where the colour carries meaning, such as a target band, time in range or a mood scale (v1.42). Recharts `stroke`/`fill` likewise. This is the known lint blind spot; review for it explicitly until the rule covers it.
- **No arbitrary-value colours** (`bg-[#…]`, `text-[oklch(…)]`).
- Exemptions (documented at the definition site): `global-error.tsx` (renders without the stylesheet), the `themeColor` meta, the QR quiet-zone `bg-white`.
## 5. Typography scale
| Tier | Classes | Where |
| ------------------------ | ------------------------------------------------------------------- | --------------------------------------------------------------- |
| App H1 | `text-2xl font-bold tracking-tight` | every module page, via `PageHeader`: weight is **bold**, always |
| Settings card title | `text-lg font-semibold` | via `SettingsCardHeader` |
| Tile title | `text-base` on `CardTitle` (`font-semibold` from the primitive) | via `TileHeader` |
| Chip / badge micro-label | `text-2xs` (11 px) | `TagChip`, `Badge`, tabular-nums counters: never muted prose |
| Section label | `text-xs font-medium uppercase tracking-wide text-muted-foreground` | list group headings |
| Body | `text-sm` (foreground) | default content |
| Meta | `text-xs text-muted-foreground` | timestamps, units, hints: the floor |
| Prose-page H1 | `text-3xl md:text-4xl font-bold` | about/privacy/landing tier only |
No `text-[9px]`. No off-scale sizes (`text-[28px]`); round to the neighbouring step. Icon sizes come from the five-step vocabulary `size-3 / 3.5 / 4 / 5 / 6`; prefer `size-N` over `h-N w-N` (identical CSS, one spelling).
## 6. Component mapping; when to use which primitive
| Situation | Use | Never |
| --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Page title + description + actions | `PageHeader`: `headingRef` + `focusable` for a title that takes focus after navigation, `titleAccessory` for a glyph or badge beside the H1 (outside its name) | hand-rolled `text-2xl font-bold` H1 blocks (`page-header-h1-guard.test.ts` lists where another H1 is the point: heroes, sign-in, error and prose pages, first-run steps) |
| A second copy of the page title in the same shell (mobile above the nav strip, desktop inside the grid) | `PageHeader headingAs="div"`: one real `<h1>`, one `role="heading" aria-level={1}` | a hand-rolled `role="heading"` div beside a hand-rolled `<h1>` |
| Settings card | `SettingsCard` + `SettingsCardHeader` + `FieldGroup` | raw `Card` in Settings sections |
| Heading above a group of cards (Insights overview, Settings groups) | `SectionHeading` (`ui/section-heading.tsx`) | a `SettingsCardHeader` with no card under it, or a hand-rolled `h2` |
| Action row in a Settings / Admin card | `SettingsCardActions` | a hand-rolled `flex justify-end gap-2` div |
| Admin mini stat tile | `StatTile` (`admin/_stat-tile.tsx`) | a hand-rolled `bg-muted/NN rounded-lg p-3` box |
| Insights/dashboard tile | `Card` + `TileHeader` | hand-rolled icon+title rows, `bg-card rounded-xl border p-4` divs |
| Insights overview section | `InsightSectionCard` | a hand-rolled section shell beside it |
| Neutral label chip on a card header | `TagChip` | a hand-copied `bg-muted … rounded-full` literal, or a `Badge` (that means status) |
| Read-query failed (`isError` on a primary / list surface) | `QueryErrorCard` with `error={query.error}` and `onRetry={refetch}` | bare `text-destructive` paragraphs without retry; a card without `error` |
| Read-query failed inside a dense metric strip, tile or supplementary block, where the tall card would dominate the geometry | `QueryErrorRow` with `error={query.error}` and `onRetry={refetch}` | a hand-rolled row; a third read-failure primitive (`ReadFailureRow` was one and is gone) |
| Read-query failed inside a card that already has its frame and header (a tile body, a settings card section) | `QueryErrorRow inset`: keeps the card and its title, no second border | `QueryErrorCard` inside a card (card in card); replacing the card and its header with the error |
| Read-query failed on a chart body | `ChartErrorState` with `error={query.error}`: same decision as the other two | either of the above |
| A list the server capped (`meta.truncated`) | `ListCapNotice` under the list, read through `apiGetList` / `apiPostList` | a hand-written "showing the latest N" line; reading `data.truncated` |
| Mutation error inside a form/sheet | inline `role="alert"` text | `QueryErrorCard` (wrong shape) |
| Honest no-data state | `EmptyState` | reusing error copy: a failed query must never fall through to "no data yet" |
| Modal reachable on mobile | `ResponsiveSheet` (bottom sheet on phones) | raw `Dialog` (admin/desktop-only surfaces excepted) |
| Date / time entry | `DateField` / `TimeField` / `DateTimeField` | native `type="date | time | datetime-local"` inputs (browser-locale trap, v1.25.4) |
| Small text action inside a line (Open schedule, Select all, View all, Retry, Learn more) | `InlineAction` (`ui/inline-action.tsx`): tone `primary` / `muted` / `foreground`, size `xs` / `sm`, `asChild` for a `Link`; owns the focus ring and `pointer-coarse:min-h-11` | a hand-rolled underlined `<button>` / `<Link>` (`inline-action-guard.test.ts`) |
| Mutually exclusive choice on one track (range, zoom, cards vs table, recent vs all) | `Segmented` (`ui/segmented.tsx`): no track, the chosen segment filled in primary (the chart tabs' look; a muted track with a raised segment was tried in 1.43.8 and dropped in 1.43.10 as too heavy); `mode="radio"` (default, roving focus) or `"toggle"` (view switches, form sheets), `iconOnly` for icon segments, `size="sm"` for range rows in a chart header; 44 px per segment on a coarse pointer | a row of ghost/secondary Buttons, a fifth segmented variant (`segmented.test.tsx` holds the definition to one module) |
| A grade beside a reading (confidence, score gain, band, lab range flag) | `ScorePill` (`ui/score-pill.tsx`): tone `success` / `info` / `warning` / `destructive` / `neutral`, `numeric` for figures; `CONFIDENCE_TONE` maps both confidence scales | a hand-rolled `rounded-full px-2 py-0.5 text-xs bg-*/15` chip (`score-pill-guard.test.ts`); `TagChip` (neutral label) and `Badge` (connection/account state) stay what they are |
| "This vital on these days vs those days" board | `MetricCrosstab` (`insights/metric-crosstab.tsx`): caller formats the numbers, the board owns row, neutral signed delta, confidence chip, dismissal | a fourth copy of the crosstab list |
| A trend / stat tile in a grid (KPI strip, derived tiles, their skeletons and building states) | `TrendTile` (`ui/trend-tile.tsx`) | the hand-spelled `bg-card border-border … p-4 md:p-6` frame |
| A whole card that points to another page | `CrossLinkCard` (`insights/cross-link-card.tsx`) | a `Link` styled as a card |
| Picking documents from the vault | `DocumentPickerSheet` (`documents/document-picker-sheet.tsx`); the caller decides what a toggle means | a copy of the search + list sheet |
| A field / chart / list with nothing to show | `EmptyState variant="plain" size="compact"` (with `action` for the next step), `ChartEmptyState` on a chart body | a hand-written muted paragraph (`empty-state-ratchet.test.ts` freezes each remaining site as `path: key`, and a new one fails; the frozen ones are field-level "not recorded" lines and editor hints) |
| A number shown to a person | `useFormatters()` (`fmt.number` / `integer` / `percent`), `formatBytes(bytes, locale)` from `lib/format/bytes` | `toFixed()`, a locale-less `toLocaleString()`, `String(a / b)` (`healthlog/no-unlocalised-decimal`) |
| Numeric range entry (0–10 self-rating, any bounded scale) | `Slider` / `SliderField` (`ui/slider.tsx`): `SliderField` when the scale has anchors, which is nearly always | a hand-rolled `input type="range"`, or an 11-button radiogroup (eleven 32 px targets overflow a 390 px viewport) |
| Outbound fetch | `safeFetch` | raw `fetch(` (lint-enforced) |
| TanStack query keys | `src/lib/query-keys/` factory | bare arrays (lint-enforced) |
**What a failed read offers is decided by the primitive, never the call site.** `QueryErrorCard`, `QueryErrorRow` and `ChartErrorState` take the query's `error` (required) and branch on `readFailureKind` (`src/lib/api/read-failure.ts`). It reads `meta.errorCode` first and the status second, because every error envelope carries a code (`http.*` when the route names none): a step-up or re-proof code on a 401 is a failure with retry, not an ended session; an unknown code falls back to the status.
- **Session ended** (a session code, or a 401 without a more specific one) → one sentence and a Sign in button (back to the current page). Never a silent blank.
- **403, refused** → the surface's `refused` state if it has one, otherwise nothing. A refusal is an answer, not a failure; no retry.
- **429, throttled** → one sentence that the read budget is spent, no retry (an immediate retry would only be refused again).
- **anything else** → the surface's message with Retry.
The `queryFn` hands the `ApiError` through untouched: no `try { … } catch { throw new Error(t(…)) }` around the fetch, the surface's sentence goes in through `message`. A call site does not inspect `error.status` around a read-failure primitive, and does not pass `error={null}`. `query-error-error-prop-guard.test.ts` enforces the prop and the untouched error at every site, `read-failure-markup-guard.test.ts` refuses hand-rolled failure markup next to a read hook. Error codes become sentences through `src/lib/i18n/translate-code.ts`, never through a `t(key) === key` probe at the call site (`translate-probe-guard.test.ts`).
## 7. Enforcement
Live, all at `error`: `healthlog/queryKey-factory`, `healthlog/safe-fetch-required`, `healthlog/api-fetch-required`, `healthlog/no-raw-palette-color` (palette + colour-shaped JS props + arbitrary values), `healthlog/no-dracula-utility`, `healthlog/spacing-scale` (`pt-`/`pb-` on `CardHeader`/`CardContent`, and the banned `5` step on a `bg-card` + `border` shell), `healthlog/no-raw-card-shell` (since v1.43.8: `bg-card` + `border` + `rounded-xl` on one literal under `src/components` / `src/app` outside `ui/`; the files where a shell is the point (shell primitives, the hero, popovers, the chart frame shared by chart/skeleton/error state, table frames, sign-in and first-run screens) are the rule's `allow` list in `eslint.config.mjs`, each with its reason, no inline escape), `healthlog/no-unlocalised-decimal` (since v1.43.8; reasoned `eslint-disable-next-line` only, for SVG coordinates and number-input values).
Structural tests (since v1.43.8): `inline-action-guard`, `score-pill-guard`, `empty-state-ratchet`, `page-header-h1-guard`, `card-density`, `segmented` (one definition), `client-route-zod-guard` (documents and Coach ship no zod in their static client graph).
Planned (until it lands, this is a review-checklist item):
1. A tile-header icon-colour check (`text-muted-foreground` / `text-primary` on a glyph inside a `CardTitle` or tile-header row).
**PR checklist for any UI change:** both themes checked visually · no new `dracula-*`/hex/opacity-muted · headers via a primitive · card gap untouched by slot padding · `isError` → `QueryErrorCard` or `QueryErrorRow` by surface, with `error` · mobile modal = `ResponsiveSheet` · `pnpm format` + `pnpm lint` + `pnpm typecheck` green.
---
## 8. Insights left reading edge; two edges, one gutter
Every text run on an Insights surface starts on one of exactly two vertical lines, and card headings offset from the card line by exactly one gutter:
- **Edge A, reading edge (x = 0):** the AuthShell container inner edge (`px-4 md:px-6`). All **borderless page-level blocks** start here with **no extra horizontal padding**: the sub-page `<h1>` + explainer (`SubPageShell`), and every `SectionHeading`. If a sub-page heading must track the card body instead, it uses the responsive card inset `px-4 md:px-6`, **never** a flat `px-6` (that sat 8 px right of the card body on mobile and reopened the heading-vs-body step).
- **Edge B, card edge (x = card `px`):** every `<Card>` insets header **and** body to `px-4 md:px-6`. No card inset with a static `p-5` (banned) or a `p-4`-only/`sm:` shell; header and body share one inset so the body left edge is identical on every tile.
- **Heading gutter (Edge B + gutter):** every card heading routes through `TileHeader`. The icon's left edge sits on Edge B; the heading **text** sits at Edge B + gutter (icon box + `gap-2` = 28 px at `size="default"`, 24 px at `size="sm"`). Compact tiles use `TileHeader size="sm"`, never a bespoke `h-4` icon row. Tile header icons are `text-foreground` (never muted, never an accent) at both sizes.
Forbidden on any Insights card: a hand-rolled icon+title row, a card title centred by default, an icon box outside `TileHeader`'s h-5/h-4 vocabulary, and a horizontal inset other than `px-4 md:px-6` (or the sanctioned `p-3` dense inner tile). Verify at 390 px: every card body shares one left x; every card heading text shares a second x one gutter to the right; the `<h1>` + any `SectionHeading` sit on the container edge.
## 9. One scroll floor; sub-shells never re-declare bottom-space
Reinforces §2's "scroll height is shell-owned". Only `AuthShell` owns the scroll container's bottom padding and any viewport-height reserve: the `<main>` clears the mobile bottom-nav (`pb-[calc(4rem+safe-area)]`) and its wrapper clears the Coach FAB (`pb-20`). **No page, section, or sub-shell** adds a `min-h-[calc(100dvh-…)]` / `min-h-dvh` reserve or a `pb-*` bottom gutter inside the scrolling `<main>`; a nested reserve stacks on the shell budget and scrolls past the last card.
A two-column sub-shell that needs a "don't jump while loading" floor derives it from **one shared source**: `SUB_SHELL_GRID_FLOOR` (`src/components/layout/shell-metrics.ts`); applied to the **grid** (not a column), so the `1fr` content row stretches to fill without over-shooting. It never re-declares the floor per-shell (`AdminShell` drifting to a stale 12rem column reserve is exactly how this recurred). Guard: `e2e/settings-overscroll.spec.ts` covers Settings **and** Admin and measures against each element's content-box bottom, so a redundant sub-shell `pb-*` fails instead of inflating the allowed budget.
## 10. Navigation; utilities live only in the user menu
Settings + Notifications are **account utilities, not feature destinations**. They live ONLY in the user/avatar menu (mobile top-bar `DropdownMenu`; desktop sidebar avatar menu, with Settings additionally in the sidebar footer). The mobile "More" overflow hub carries **feature destinations only** (Measurements, Mood, Cycle, Labs, Coach, …), never Settings or Notifications. The hub is `mobileMoreHubDestinations()` = `visibleNavDestinations` minus the primary slots (`/`, `/medications`, `/insights`); it does **not** append the utility tail. `NAV_UTILITY_DESTINATIONS` (`visibleUtilityDestinations()`) feeds the sidebar footer + user menus, not the mobile overflow. Never surface a utility in two menus reachable from the same screen.
## 11. Inline action row; the PageHeader pattern
A title/sentence with an adjacent action is one row, not a wrapping stack: `flex items-start justify-between gap-3`, the text `min-w-0 flex-1` (wraps internally), the action `shrink-0` (stays beside the text, never pushed to its own line). This is the canonical `PageHeader` / `TileHeader` (`right` slot) shape; reuse it (e.g. the dashboard-hero verdict + CTA) instead of a one-off `flex-wrap` row whose `gap-y-*` drops the action below on a full sentence. Keep the mobile tap-target floor (`min-h-11 sm:min-h-9`).
## 12. The action row; one place, one shape
A card's actions live in **one** row, and that row is the **last thing in the card**. Reference implementations: `src/components/settings/score-section.tsx`, `src/components/settings/coach-prefs-section.tsx`.
- **Use `SettingsCardActions`** (`settings/_card-actions.tsx`). It owns the row: `flex flex-wrap items-center gap-2`, `justify-end` by default. Never hand-roll a `flex justify-end gap-2` div in a Settings or Admin card.
- **Primary action:** last child of the row (rightmost), `variant` default, `size="sm"` + `min-h-11`. One per card.
- **Secondary action:** `outline`, before the primary in the same row. Cancel/reset are secondary, never `ghost` in the same row as a primary.
- **Destructive card-wide action:** the same row, `variant="destructive"`. Never hand-tinted (`text-destructive border-destructive/30` on an `outline` is drift).
- **Nothing follows the action row.** Status lines, result lists, and confirmations belong **above** it; a result set large enough to need its own frame becomes its own card or a collapsible.
- **The header `status` slot carries status, not actions**; a badge, a pill, a switch. Not a primary, not a destructive, not a filter group. Filters get a toolbar row above the content.
- **`outline` has exactly one meaning: secondary.** A card whose only actions are deep links out (privacy, about, export) has no primary; those links stay `outline size="sm"` and the row uses `align="start"`, because a navigation row reads with the text, not against it. That is the one documented exception, and `SettingsCardActions` spells it (`align="start"`).
## 13. Settings + Admin card rhythm; owned by the container
- **`SettingsCard` is gap-based**, like `Card`: `flex flex-col gap-4 p-4 md:p-6`. The header→body distance comes from the container's gap. A call site never adds `mt-4` to its first body child, never `mb-4` to the header, never `space-y-4` to the card. A denser card overrides the gap on the card itself (`className="gap-2"`), exactly as `Card` does.
- **The body starts at the card edge.** The old `pl-7` gutter (icon box + `gap-2`) is abolished: it is off the scale in §2, it broke the moment two neighbouring cards disagreed, and it silently mis-aligned action rows against their own forms. Header text is offset from the card edge by the icon gutter; body content is not. This is the deliberate difference from §8's Insights gutter, where the tiles carry no body form.
- **Group headings above a group of cards use `SectionHeading`** (`ui/section-heading.tsx`, the heading of the Insights overview, shared by every area), not a bare `SettingsCardHeader`. `text-base` on the group, `text-lg` on the card title: that step is what says which one labels which.
- **Admin uses the Settings anatomy verbatim.** No admin-only card shell, no admin-only header, no admin-only mini tile; `SettingsCard`, `SettingsCardHeader`, `SettingsCardActions`, `StatTile` (`admin/_stat-tile.tsx`) are the whole vocabulary.
- **A loading state paints the card and its header.** A section that renders a bare spinner while loading and a card afterwards moves the page under the cursor. Reference: `admin/coach-feedback-section.tsx`.
### Enforcement
The `p-5` / `py-5` / `px-5` / `space-y-5` family does not occur in the app, and `healthlog/spacing-scale` runs at `error` over `src/components/**` and `src/app/**`, so the step cannot come back unnoticed. `no-raw-card-shell` is described in §7. The header icon colour (§1) has no lint rule; check it in review.
@@ -0,0 +1,97 @@
# Push a room's CO2, temperature and humidity to HealthLog every few minutes.
#
# Use it for one room. The readings carry no room name, so a second room would
# land in the same series, and a push in the same minute is refused (409).
#
# Needs the `healthlog_room_climate` rest_command from
# docs/integrations/home-assistant.md ("Room climate") and a measurement
# ingest token. Every sensor is optional; a sensor that reports `unknown`,
# `unavailable` or a non-number is left out of that push, and a push with
# nothing left is skipped. Temperatures in °F are converted to °C.
blueprint:
name: HealthLog room climate
description: >-
Sends indoor CO2 (ppm), temperature (°C) and relative humidity (%) to
HealthLog through a measurement ingest token. HealthLog keeps each
reading and shows the day's mean, low and high.
domain: automation
homeassistant:
min_version: 2024.10.0
source_url: https://github.com/MBombeck/HealthLog/blob/main/docs/integrations/blueprints/healthlog-room-climate.yaml
input:
co2_sensor:
name: CO2 sensor
description: A sensor reporting carbon dioxide in ppm.
default: ""
selector:
entity:
domain: sensor
device_class: carbon_dioxide
temperature_sensor:
name: Temperature sensor
description: A sensor reporting the room temperature (°C or °F).
default: ""
selector:
entity:
domain: sensor
device_class: temperature
humidity_sensor:
name: Humidity sensor
description: A sensor reporting relative humidity in %.
default: ""
selector:
entity:
domain: sensor
device_class: humidity
interval_minutes:
name: Interval
description: >-
Minutes between pushes, on the hour and every interval after it.
15 is plenty for a daily picture.
default: "15"
selector:
select:
options: ["5", "10", "15", "20", "30", "60"]
mode: single
variables:
co2_sensor: !input co2_sensor
temperature_sensor: !input temperature_sensor
humidity_sensor: !input humidity_sensor
interval_minutes: !input interval_minutes
# A list of readings (Home Assistant keeps a rendered list a list).
readings: >-
{% set at = now().replace(second=0, microsecond=0).isoformat() %}
{% set ns = namespace(items=[]) %}
{% for type, entity in [
('INDOOR_CO2', co2_sensor),
('INDOOR_TEMPERATURE', temperature_sensor),
('INDOOR_HUMIDITY', humidity_sensor)
] %}
{% if entity and is_number(states(entity)) %}
{% set v = states(entity) | float %}
{% if type == 'INDOOR_TEMPERATURE'
and state_attr(entity, 'unit_of_measurement') == '°F' %}
{% set v = ((v - 32) * 5 / 9) | round(2) %}
{% endif %}
{% set ns.items = ns.items
+ [{'type': type, 'value': v, 'measuredAt': at}] %}
{% endif %}
{% endfor %}
{{ ns.items }}
triggers:
- trigger: time_pattern
minutes: "/5"
conditions:
- condition: template
value_template: >-
{{ now().minute % (interval_minutes | int(15)) == 0
and readings | count > 0 }}
actions:
- action: rest_command.healthlog_room_climate
data:
readings: "{{ readings }}"
+89 -6
View File
@@ -109,20 +109,20 @@ a new weight:
```yaml
automation:
- alias: "Push weight to HealthLog"
trigger:
- platform: state
triggers:
- trigger: state
entity_id: sensor.bathroom_scale_weight
condition:
conditions:
- condition: template
value_template: "{{ trigger.to_state.state not in ['unknown', 'unavailable'] }}"
action:
- service: rest_command.healthlog_measurement
actions:
- action: rest_command.healthlog_measurement
data:
type: WEIGHT
value: "{{ trigger.to_state.state | float }}"
```
The `condition` is not decoration. Home Assistant sensors publish `unknown` and
The condition is not decoration. Home Assistant sensors publish `unknown` and
`unavailable` as ordinary states, and forwarding one produces a 422 on every
restart.
@@ -149,6 +149,89 @@ Units are derived server-side from the type, so send the number in the unit
above and do not send a `unit` field. The full enum is in
`docs/api/openapi.yaml` under `MeasurementType`.
## Room climate
Indoor CO₂, temperature and humidity are their own types, kept apart from the
body temperatures and from the outdoor weather the environment module pulls:
| What the sensor reports | `type` | Unit expected |
| ----------------------- | -------------------- | ------------- |
| Carbon dioxide | `INDOOR_CO2` | ppm |
| Room temperature | `INDOOR_TEMPERATURE` | °C |
| Relative humidity | `INDOOR_HUMIDITY` | % |
Each push is kept as a reading, and the day's mean, low and high are what the
charts and the daily views read, so a sensor sampled every 15 minutes gives a
clear picture without anything further on your side. Pushing every few seconds
adds rows, not detail.
Add a second `rest_command` that takes a ready-made list of readings, so one
request carries all three:
```yaml
rest_command:
healthlog_room_climate:
url: "https://healthlog.example.com/api/measurements"
method: POST
headers:
Authorization: !secret healthlog_token
Content-Type: "application/json"
payload: "{{ readings | to_json }}"
```
Track one room, the one you sleep in: that is the one worth reading next to
your sleep. The readings carry no room name, so a second room would land in the
same series and blur both, and two pushes in the same minute collide on a 409
(see below).
Then import the blueprint (Home Assistant 2024.10 or newer), either from
[`docs/integrations/blueprints/healthlog-room-climate.yaml`](./blueprints/healthlog-room-climate.yaml)
or by its raw GitHub URL under Settings → Automations & scenes → Blueprints →
Import blueprint, and create one automation from it for that room. It asks for
the three sensors (each optional) and an interval of 5, 10, 15, 20, 30 or 60
minutes, counted from the full hour. Every push:
- leaves out a sensor that reports `unknown`, `unavailable` or anything that is
not a number, and skips the push when nothing is left;
- converts a temperature reported in °F to °C;
- stamps all readings with the current minute, so a retry of the same push is
refused with a 409 instead of written twice.
If you would rather write the automation yourself, this is the same thing for a
single room without the blueprint:
```yaml
automation:
- alias: "Push bedroom climate to HealthLog"
triggers:
- trigger: time_pattern
minutes: "/15"
variables:
readings: >-
{% set at = now().replace(second=0, microsecond=0).isoformat() %}
{% set ns = namespace(items=[]) %}
{% for type, entity in [
('INDOOR_CO2', 'sensor.bedroom_co2'),
('INDOOR_TEMPERATURE', 'sensor.bedroom_temperature'),
('INDOOR_HUMIDITY', 'sensor.bedroom_humidity')
] %}
{% if is_number(states(entity)) %}
{% set ns.items = ns.items + [{'type': type,
'value': states(entity) | float, 'measuredAt': at}] %}
{% endif %}
{% endfor %}
{{ ns.items }}
conditions:
- condition: template
value_template: "{{ readings | count > 0 }}"
actions:
- action: rest_command.healthlog_room_climate
data:
readings: "{{ readings }}"
```
One request takes up to five readings, so the room is one call.
## Sending several readings at once
A bare array writes up to five readings in one request — the shape to use for
+5 -3
View File
@@ -204,9 +204,11 @@ n8n, and Health Connect integrations need no change.
## URL changes
- **Settings has moved to per-section routes.** `/settings#integrations`
→ `/settings/integrations`, etc. The legacy `/settings#anchor` URLs
still 308-redirect to the new routes; sidebar deep-links and the
`<a href="/settings#thresholds">` patterns from 1.3 keep working.
→ `/settings/integrations`, etc. A legacy `/settings#anchor` URL opens
Settings (`/settings` redirects to `/settings/account`), but the part
after `#` never reaches the server, so it cannot pick the section: update
bookmarks and links of the `/settings#thresholds` shape to the
per-section route.
## What landed in v1.4 vs v1.4.1
+11
View File
@@ -109,6 +109,17 @@ that for you, and each boot retries). Anything else is a session you can
judge: a stuck client may be ended with `SELECT pg_terminate_backend(<pid>)`.
You never need to run `migrate resolve` by hand for this case.
### v1.44.0 notes
Migration `0430` drops the `insights_cached_text` column from `users`. A
process still running the previous image selects that column on every full
user read and fails once it is gone, so do not run old and new containers side
by side across this release: stop the old app container, let the new one
apply the migrations, then start it (the sequence above does this on a single
host). A web/worker split deployment stops both roles before the first new
container starts. Rolling back to an image older than v1.44.0 needs the column
back first (see Rollback below).
### v1.11.0 notes
Migrations `0110`–`0114` are additive (new tables / columns for the
+10 -8
View File
@@ -11,14 +11,16 @@ already installed, longer if a TLS-fronted public hostname is in scope
pulls a multi-arch image from GitHub Container Registry (linux/amd64
- linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work
unchanged.
- **2 GB RAM, 10 GB free disk.** The Postgres data volume grows with
measurement history; the Apple Health import worker can briefly
hold a 1.5 GB upload in `/tmp` while it parses. The app container is
capped at `APP_MEMORY_LIMIT`, `1536m` by default (Node then sizes its
heap at about 792 MB). On a 2 GB host that leaves too little for
Postgres and the system, so set `APP_MEMORY_LIMIT=1g` in `.env`
(a heap of about 524 MB); a long-used account then runs closer to
that heap, and the support report and Admin > System status say so.
- **3 GB RAM (4 GB recommended), 10 GB free disk.** The Postgres data
volume grows with measurement history; the Apple Health import worker
can briefly hold a 1.5 GB upload in `/tmp` while it parses. The app
container is capped at `APP_MEMORY_LIMIT`, `1536m` by default (Node
then sizes its heap at about 792 MB), and Postgres and the system need
the rest. 1536 MB is the supported floor for the app container: do not
lower it to fit a smaller host. Below it, a long-used account or a large
import can fill the heap and restart the app; the app still starts, but
the boot log, the support report and Admin > System status say it runs
below the supported floor. On a busy instance, raise it.
- **Outbound HTTPS** to `ghcr.io` (image pulls) plus whichever
integration endpoints you plan to enable (Withings, OpenAI, etc.).
The opt-in environmental-context module additionally reaches
+9 -5
View File
@@ -30,7 +30,7 @@ Three things must be true before `/mcp` will answer:
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`APP_URL` is set** | The endpoint fails closed without a pinned canonical origin. `APP_URL` (or `NEXT_PUBLIC_APP_URL`) is the origin every OAuth document and audience check is anchored to — `Host` is never trusted. Without it the surface stays invisible (`404`). |
| **The global API is on** | The operator's instance-wide API switch must be enabled (it is by default). With it off, every token path — including `/mcp` — returns `404`. |
| **The `mcp` module is on** | A per-account opt-in that ships off by default; you turn it on in Settings. Until then `/mcp` answers `404` for that account. |
| **The `mcp` module is on** | A per-account opt-in that ships off by default; you turn it on in **Settings → Modules**. Until then `/mcp` answers `404` for that account. |
`APP_URL` is the same origin you already use for passkeys and invite
links — for example `https://health.example`. It must be reachable by the
@@ -40,11 +40,15 @@ LAN-only origin works for Claude Desktop over stdio).
## 1. Enable the module
In the app: **Settings → MCP**. The card has three parts:
The switch lives with every other module: **Settings → Modules**, the
**MCP** row. Turn it on and the `/mcp` endpoint goes live for your account.
- **Enable** — the opt-in switch. Toggle it on; the `/mcp` endpoint goes
live for your account and the card shows your endpoint URL
(`<origin>/mcp`).
**Settings → MCP** is where you use it. The page has three parts:
- **State** — whether the module is on, with a link to its switch in
Modules, and your endpoint URL (`<origin>/mcp`). Turning the module off
pauses the endpoint; your tokens and connections stay and work again
once it is back on.
- **Connections** — cloud connectors that completed the OAuth flow appear
here; revoke any of them.
- **Tokens** — manual connector tokens (see below).
+1 -1
View File
@@ -13,7 +13,7 @@ one value you must keep yourself: the encryption key.
| Port | `3000` (HTTP) |
| User | runs as uid `1001`; no extra capabilities, `no-new-privileges` is fine |
| Health check | `GET /api/health`, `200` when ready |
| Start period | at least 60 s, with more retries than the image's 3 × 30 s: migrations run at every boot |
| Start period | at least 60 s, with more retries than the image's 5 × 30 s: migrations run at every boot |
| Database | PostgreSQL 16 (17 and later are not a drop-in), no extensions |
| Volumes | none for the app; the database gets its own dataset |
+5 -4
View File
@@ -1,9 +1,10 @@
# HealthLog UI Guidelines
> Single source of truth for visual & interaction patterns in the HealthLog
> PWA. Consult this document before adding a new component, page, or
> setting. If a pattern below doesn't fit your case, propose an addition
> here in the same PR.
> Visual & interaction patterns in the HealthLog PWA. The binding rules
> for card anatomy, spacing, text colour, tokens and primitives live in
> [`contributing/ui-standards.md`](./contributing/ui-standards.md); where
> this document disagrees with them, the standards win. If a pattern
> below doesn't fit your case, propose an addition in the same PR.
Last updated: 2026-05-08 (v1.4 cycle)
+68
View File
@@ -0,0 +1,68 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* The activity calendar on the workouts page (D1036), at phone width: the
* card renders a whole-week grid, pages back a period, switches to the week
* view, and the goals sheet saves a goal the calendar then reports.
*/
test.describe("activity calendar", () => {
test.use({
storageState: STORAGE_STATE_PATH,
viewport: { width: 390, height: 844 },
});
test("draws the month, pages back and switches to a week", async ({
page,
}) => {
await page.goto("/insights/workouts");
const card = page.locator('[data-slot="activity-calendar"]');
await expect(card).toBeVisible();
await expect(
page.locator('[data-slot="activity-calendar-label"]'),
).not.toHaveText("");
const days = page.locator('[data-slot="activity-calendar-day"]');
const monthCount = await days.count();
if (monthCount > 0) expect(monthCount % 7).toBe(0);
const label = page.locator('[data-slot="activity-calendar-label"]');
const before = await label.textContent();
await page.locator('[data-slot="activity-calendar-previous"]').click();
await expect(label).not.toHaveText(before ?? "");
await expect(card).toHaveAttribute("data-period", "month");
await page.locator('[data-slot="activity-calendar-period-week"]').click();
await expect(card).toHaveAttribute("data-period", "week");
if ((await days.count()) > 0) await expect(days).toHaveCount(7);
// Nothing in the card pushes the page sideways on a phone.
const overflow = await page.evaluate(
() =>
document.documentElement.scrollWidth -
document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
});
test("saves a goal and shows its progress", async ({ page }) => {
await page.goto("/insights/workouts");
await page.locator('[data-slot="activity-calendar-set-goals"]').click();
const field = page.locator(
'[data-slot="activity-goals-form"] [data-goal-key="stepsPerMonth"]',
);
const goal = page.locator(
'[data-slot="activity-calendar-goal"][data-goal-key="stepsPerMonth"]',
);
await field.fill("150000");
await page.locator('[data-slot="activity-goals-save"]').click();
await expect(goal).toBeVisible();
// Clear it again so the account is left as it was found.
await page.locator('[data-slot="activity-calendar-set-goals"]').click();
await field.fill("");
await page.locator('[data-slot="activity-goals-save"]').click();
await expect(goal).toHaveCount(0);
});
});
+7 -2
View File
@@ -4,6 +4,7 @@ import { join, relative, sep } from "node:path";
import type { APIRequestContext } from "@playwright/test";
import { ADMIN_SECTION_SLUGS } from "@/components/admin/section-slugs";
import { MODULE_SETTINGS_PAGE_IDS } from "@/components/settings/module-settings-page-ids";
import { SETTINGS_SECTION_SLUGS } from "@/components/settings/section-slugs";
import { expect, test } from "./setup/test";
@@ -80,17 +81,21 @@ const DYNAMIC_ROUTES: Record<string, Resolver> = {
ADMIN_SECTION_SLUGS.map((slug) => `/admin/${slug}`),
// `LAYOUT_GROUP_IDS` lives beside React components the test runner cannot
// load; `settings/layout/[module]` 404s an unknown group, so a stale entry
// here fails on the status check below rather than passing quietly.
// here fails on the status check below rather than passing quietly. Mood
// has no arrangement page since v1.44: its old address redirects to
// `/settings/modules/mood`, which the entry below sweeps.
"/settings/layout/[module]": async () =>
[
"dashboard",
"insights",
"medications",
"mood",
"labs",
"illness",
"documents",
"vorsorge",
].map((group) => `/settings/layout/${group}`),
"/settings/modules/[module]": async () =>
MODULE_SETTINGS_PAGE_IDS.map((module) => `/settings/modules/${module}`),
"/insights/scores/[metric]": async () =>
["sleep", "readiness", "recovery", "stress", "strain"].map(
(metric) => `/insights/scores/${metric}`,
+66
View File
@@ -0,0 +1,66 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Lab results from a spreadsheet (#1215), at phone width, against the real
* server: the mapping is suggested from the headers, the check reports a
* refused row by line and writes nothing, the import writes the rest, and a
* second import of the same file only finds duplicates.
*/
function csv(): string {
const day = new Date(Date.now() - 30 * 86_400_000).toISOString().slice(0, 10);
return [
"Analyte;Value;Unit;Date",
`E2E Marker CSV;5,4;mmol/L;${day}`,
`E2E Marker CSV;1.234;mmol/L;${day}`,
].join("\n");
}
test.describe("record CSV import", () => {
test.use({
storageState: STORAGE_STATE_PATH,
viewport: { width: 390, height: 844 },
});
test("checks, imports and then only finds duplicates", async ({ page }) => {
await page.goto("/settings/export");
await page.locator('[data-slot="record-import-open-labs"]').click();
const sheet = page.locator('[data-slot="record-import-sheet"]');
await expect(sheet).toBeVisible();
const file = {
name: "labs.csv",
mimeType: "text/csv",
buffer: Buffer.from(csv()),
};
await page.locator('[data-slot="record-import-file"]').setInputFiles(file);
await expect(
page.locator('[data-slot="record-import-map-analyte"]'),
).toHaveValue("0");
await expect(
page.locator('[data-slot="record-import-map-value"]'),
).toHaveValue("1");
await page.locator('[data-slot="record-import-check"]').click();
await expect(
page.locator('[data-slot="record-import-checked"]'),
).toBeVisible();
await expect(
page.locator('[data-slot="record-import-error-row"]'),
).toHaveCount(1);
await page.locator('[data-slot="record-import-run"]').click();
await expect(
page.locator('[data-slot="record-import-done"]'),
).toBeVisible();
// Second run of the same file.
await page.keyboard.press("Escape");
await page.locator('[data-slot="record-import-open-labs"]').click();
await page.locator('[data-slot="record-import-file"]').setInputFiles(file);
await page.locator('[data-slot="record-import-check"]').click();
await expect(
page.locator('[data-slot="record-import-run"]'),
).toBeDisabled();
});
});
+5 -2
View File
@@ -62,7 +62,8 @@ const SETTINGS_ROUTES = [
] as const;
/**
* The Layout hub's seven children. They are the routes whose header block
* The Arrangement hub's children, plus (v1.44) the module pages under
* Modules, which use the same back-link header. They are the routes whose header block
* changed MOST — the shell used to hand-roll a `space-y-6` between the
* back-link and the title, and `PageHeader`'s documented contract is
* `space-y-1.5`. Nothing measured them before, so the step went from 24 px
@@ -73,10 +74,12 @@ const LAYOUT_CHILD_ROUTES = [
"/settings/layout/dashboard",
"/settings/layout/insights",
"/settings/layout/medications",
"/settings/layout/mood",
"/settings/layout/labs",
"/settings/layout/illness",
"/settings/layout/vorsorge",
"/settings/modules/medications",
"/settings/modules/mood",
"/settings/modules/labs",
] as const;
/** The `space-y-1.5` between a back-link and the title it sits above. */
+7 -3
View File
@@ -149,8 +149,9 @@ test.describe("Settings → Export & Import", () => {
await page.getByTestId("report-apply-standard-export").click();
await expect(generate).toBeEnabled();
// Exactly the shipped set: the three classic vitals, weight and BMI, and
// nothing at all from the groups the template leaves alone.
// Exactly the shipped set: the three classic vitals, weight and BMI, the
// two heart-rate-variability rows, and nothing at all from the groups the
// template leaves alone.
await expect(
page.getByTestId("report-group-row-vitals-export"),
).toContainText("3/7");
@@ -159,7 +160,10 @@ test.describe("Settings → Export & Import", () => {
).toContainText("2/12");
await expect(
page.getByTestId("report-group-row-cardio-export"),
).toContainText("0/14");
).toContainText("2/15");
await expect(
page.getByTestId("report-group-row-mobility-export"),
).toContainText("0/11");
// The fenced tier is not part of any one-click set.
await expect(
page.getByTestId("report-scope-summary-export"),
+47
View File
@@ -0,0 +1,47 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Settings navigation at 390 px: one button names the current section and
* opens a sheet with every section under its group. It replaced a strip of
* every section as chips, where most of Settings sat off screen.
*/
test.describe("settings navigation on a phone", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("names the section, opens the grouped list and moves on a tap", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/settings/account", { waitUntil: "domcontentloaded" });
const trigger = page.locator('[data-slot="settings-mobile-nav-trigger"]');
await expect(trigger).toBeVisible({ timeout: 15_000 });
await expect(trigger).toContainText("Account");
// The click can land before hydration; retry until the sheet opens.
const list = page.locator('[data-slot="settings-mobile-nav-list"]');
await expect(async () => {
await trigger.click();
await expect(list).toBeVisible({ timeout: 2_000 });
}).toPass({ timeout: 15_000 });
for (const group of ["Account", "Tracking", "Notifications", "System"]) {
await expect(
list.getByRole("heading", { name: group, exact: true }),
).toBeVisible();
}
await list.getByRole("link", { name: "Security" }).click();
await expect(page).toHaveURL(/\/settings\/security$/);
await expect(list).toHaveCount(0);
await expect(trigger).toContainText("Security");
// Nothing scrolls sideways.
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - window.innerWidth,
);
expect(overflow).toBeLessThanOrEqual(1);
});
});
+2 -2
View File
@@ -33,7 +33,7 @@ const ROUTES = [
// one-scroll-floor assertion below).
"/settings/layout/insights",
"/settings/layout/medications",
"/settings/layout/mood",
"/settings/modules/mood",
"/settings/modules",
"/settings/account",
"/settings/notifications",
@@ -135,7 +135,7 @@ test.describe("settings + admin vertical over-scroll guard", () => {
expect(dims, "main-content / wrapper present").not.toBeNull();
if (!dims) return;
// One-scroll-floor (UI-STANDARDS §9), structural half. `<main>` may
// One-scroll-floor (ui-standards.md §9), structural half. `<main>` may
// only be the ONLY vertical scroll surface if it also OWNS the
// containing block of its absolutely-positioned descendants: an
// abspos box with no positioned ancestor resolves against the
+9 -3
View File
@@ -18,6 +18,8 @@
*/
import pg from "pg";
import { sealInsightsCachedText } from "@/lib/insights/cached-text-cipher";
import { E2E_AI_OPTIONAL } from "./global-setup";
function connect(): pg.Pool {
@@ -54,7 +56,7 @@ export async function resetAiOptionalAccount(): Promise<void> {
`UPDATE users SET
ai_provider = NULL,
ai_base_url = NULL,
insights_cached_text = NULL,
insights_cached_text_encrypted = NULL,
insights_cached_at = NULL,
insights_cached_locale = NULL,
disable_coach = false
@@ -80,16 +82,20 @@ export async function seedStoredBriefing(marker: string): Promise<void> {
// Generated now: only text written today is served as today's read.
briefingGeneratedAt: new Date().toISOString(),
};
// Sealed the way the app writes it; there is no readable column to seed.
// The import is static on purpose: a dynamic `import()` of app code bypasses
// the test runner's `@/` path mapping for that module's own imports.
const sealed = Buffer.from(sealInsightsCachedText(JSON.stringify(insights)));
await withAccount(async (pool, userId) => {
await pool.query(
`UPDATE users SET
ai_provider = 'LOCAL',
ai_base_url = 'http://127.0.0.1:9/v1',
insights_cached_text = $2,
insights_cached_text_encrypted = $2,
insights_cached_at = now(),
insights_cached_locale = 'en'
WHERE id = $1`,
[userId, JSON.stringify(insights)],
[userId, sealed],
);
await pool.query(
`INSERT INTO consent_receipts (id, user_id, kind, artefact, signed_at)
+1 -1
View File
@@ -802,7 +802,7 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
codex_connected_at = NULL,
codex_connection_status = 'disconnected',
insights_cached_at = NULL,
insights_cached_text = NULL,
insights_cached_text_encrypted = NULL,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
module_preferences_json = EXCLUDED.module_preferences_json,
+121
View File
@@ -0,0 +1,121 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* The provenance page (#1204) at phone width, against mocked reads: the
* day's sources with the used one marked, the comparison table, and the
* link from Settings > Source priority.
*/
const DAY = {
type: "ACTIVITY_STEPS",
date: "2026-10-01",
aggregation: "sum",
ranked: true,
metricKey: "steps",
ladder: ["APPLE_HEALTH", "FITBIT"],
picked: "APPLE_HEALTH",
pickReason: "highest_on_ladder",
sources: [
{
source: "APPLE_HEALTH",
rank: 0,
readings: 24,
value: 9500,
min: 10,
max: 900,
firstAt: "2026-10-01T06:00:00.000Z",
lastAt: "2026-10-01T20:00:00.000Z",
unit: "steps",
devices: ["phone", "watch"],
pass: null,
},
{
source: "FITBIT",
rank: 1,
readings: 1,
value: 8800,
min: 8800,
max: 8800,
firstAt: "2026-10-01T23:00:00.000Z",
lastAt: "2026-10-01T23:00:00.000Z",
unit: "steps",
devices: ["band"],
pass: "lower_on_ladder",
},
],
};
const COMPARE = {
type: "ACTIVITY_STEPS",
from: "2026-09-02",
to: "2026-10-01",
aggregation: "sum",
ranked: true,
ladder: ["APPLE_HEALTH", "FITBIT"],
unit: "steps",
sources: [
{ source: "APPLE_HEALTH", days: 30 },
{ source: "FITBIT", days: 28 },
],
a: "APPLE_HEALTH",
b: "FITBIT",
days: [{ date: "2026-10-01", a: 9500, b: 8800, picked: "APPLE_HEALTH" }],
summary: {
daysA: 30,
daysB: 28,
daysBoth: 28,
meanA: 9100,
meanB: 8700,
meanDifference: -400,
meanAbsoluteDifference: 450,
pickedA: 30,
pickedB: 0,
},
};
test.describe("source provenance", () => {
test.use({
storageState: STORAGE_STATE_PATH,
viewport: { width: 390, height: 844 },
});
test("explains the day's pick and compares two sources", async ({ page }) => {
await page.route(/\/api\/measurements\/provenance\/compare/, (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ data: COMPARE, error: null }),
}),
);
await page.route(/\/api\/measurements\/provenance\?/, (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({ data: DAY, error: null }),
}),
);
// Yesterday in UTC: the page refuses nothing, but a day the date field
// cannot pick (after today) would read as a fixture gone stale.
const day = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
await page.goto(`/insights/sources?type=ACTIVITY_STEPS&date=${day}`);
await expect(
page.locator('[data-slot="provenance-source-row"][data-picked="true"]'),
).toHaveAttribute("data-source", "APPLE_HEALTH");
await expect(
page.locator('[data-slot="provenance-verdict"]'),
).toBeVisible();
await expect(
page.locator('[data-slot="provenance-compare-row"]'),
).toHaveCount(1);
const overflow = await page.evaluate(
() =>
document.documentElement.scrollWidth -
document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
});
test("is reachable from Settings > Source priority", async ({ page }) => {
await page.goto("/settings/sources");
await page.locator('[data-slot="sources-provenance-cross-link"]').click();
await expect(page).toHaveURL(/\/insights\/sources/);
});
});
+2 -2
View File
@@ -6,7 +6,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
* Issue #490 — client-rendered timestamps follow the PROFILE timezone.
*
* `/api/auth/me` is mocked with an `Asia/Manila` profile; the sessions
* card on /settings/privacy renders `lastActiveAt` through
* card on /settings/security renders `lastActiveAt` through
* `useFormatters()`, which reads the localStorage timezone mirror that
* `fetchMe` fills from the mocked payload. A fixed UTC instant must
* therefore paint the MANILA wall clock (14:30), never the Berlin
@@ -75,7 +75,7 @@ test.describe("profile-timezone display (#490)", () => {
}),
);
await page.goto("/settings/privacy", { waitUntil: "domcontentloaded" });
await page.goto("/settings/security", { waitUntil: "domcontentloaded" });
// The sessions card is collapsed by default — expand it first. The
// toggle's onClick can be dropped if Playwright clicks before React has
@@ -124,7 +124,7 @@ async function expectAnnounced(page: Page, selector: string) {
* Copy somebody has to act on is content, not meta.
*
* Two halves, and both are needed. The class check is the design standard —
* UI-STANDARDS §3 reserves `text-muted-foreground` for meta, and consent or
* ui-standards.md §3 reserves `text-muted-foreground` for meta, and consent or
* safety text rendered as meta is text people skip. The ratio check is the
* measurable one: it walks up for an opaque background and asserts the AA
* floor for body text, so a future token change that keeps the class and
+62 -10
View File
@@ -1,5 +1,7 @@
import type { Page } from "@playwright/test";
import { MANAGED_OAUTH_PROVIDERS } from "@/lib/integrations/oauth-kit/managed-provider";
import { expect, test } from "./setup/test";
import {
E2E_LEVEL_RECORDS,
@@ -581,7 +583,7 @@ test.describe.serial("scoped sharing browser journeys", () => {
await leaveRecord(page);
});
test("[J2-manage-edits-without-settings] keeps managed Settings on the active record and integrations status-only", async ({
test("[J2-manage-edits-without-settings] keeps managed Settings on the active record and connects integrations for it", async ({
page,
}) => {
const record = E2E_LEVEL_RECORDS.find(
@@ -628,22 +630,40 @@ test.describe.serial("scoped sharing browser journeys", () => {
);
await expect(integrations).toBeVisible();
await expect(integrations).toHaveAttribute("data-record-id", accountId);
// Read-only: the shared integration cards render, but nothing on them
// acts on a provider. The only buttons are each card's own "details for a
// bug report" toggle and its copy button.
// A guardian holds MANAGE on the profile, so each OAuth provider's card
// carries one control, connect, and says whose record it connects for.
// Fitbit takes no new connections and Nightscout is not an OAuth flow, so
// those two stay status-only. No card takes typed input: provider
// credentials and sync settings stay with the guardian's own Settings.
await expect(
integrations.locator('[data-slot="integration-card"]'),
).toHaveCount(8);
await expect(
integrations.locator(
'[data-slot="integration-primary-action"], [data-slot="integration-menu-trigger"], input, select, textarea',
),
).toHaveCount(0);
for (const provider of MANAGED_OAUTH_PROVIDERS) {
const card = integrations.getByTestId(`managed-integration-${provider}`);
await expect(
card.getByTestId(`managed-integration-${provider}-connect`),
).toHaveText(`Connect for ${record.username}`);
await expect(
card.locator('[data-slot="managed-integration-for"]'),
).toHaveText(`Connects for ${record.username}`);
}
await expect(
integrations.locator(
'button:not([data-slot="integration-details-toggle"]):not([data-slot="integration-diagnostics-copy"])',
),
).toHaveCount(0);
).toHaveCount(MANAGED_OAUTH_PROVIDERS.length);
for (const provider of ["fitbit", "nightscout"]) {
await expect(
integrations
.getByTestId(`managed-integration-${provider}`)
.locator(
'button:not([data-slot="integration-details-toggle"]):not([data-slot="integration-diagnostics-copy"])',
),
).toHaveCount(0);
}
await expect(integrations.locator("input, select, textarea")).toHaveCount(
0,
);
await page.goBack();
await expect(profile).toHaveAttribute("data-record-id", accountId);
@@ -656,6 +676,38 @@ test.describe.serial("scoped sharing browser journeys", () => {
await expect(banner).toHaveCount(0);
});
test("[J2-manage-edits-without-settings] keeps integrations shut for a view or add grant", async ({
page,
}) => {
// The connect controls above are MANAGE-only. A view or view-and-add grant
// gets no integration surface at all, and the connect route refuses it.
for (const access of ["READ", "WRITE"] as const) {
const fenceHeaders = trackFenceAssertion(page);
const record = E2E_LEVEL_RECORDS.find(
(candidate) => candidate.access === access,
);
if (!record) throw new Error(`${access} fixture is missing`);
await openRecord(page, record.username);
await page.goto("/settings/integrations");
await expect(
page.locator('[data-slot="shared-record-unavailable"]'),
).toBeVisible();
await expect(page.locator('[data-slot="integration-card"]')).toHaveCount(
0,
);
const connectStatus = await page.evaluate(async (headers) => {
const response = await fetch(
"/api/record-settings/integrations/withings/connect",
{ method: "POST", headers },
);
return response.status;
}, fenceHeaders());
expect(connectStatus).toBe(403);
await leaveRecord(page);
}
});
test("[J1-adult-levels-lifecycle] refuses direct ungranted routes before coach reads start", async ({
page,
}) => {
+2 -2
View File
@@ -50,7 +50,7 @@
* `eslint-disable-next-line` with the reason — visible in review — rather
* than a silent registry entry here.
*
* @see .planning/audits/2026-07-05-fable/UI-STANDARDS.md §1 + §2 + §7.
* @see docs/contributing/ui-standards.md §1 + §2 + §7.
*/
"use strict";
@@ -181,7 +181,7 @@ const spacingScaleRule = {
slotPadding:
'"{{match}}" on <{{element}}> fights the gap-based Card contract — the header→body distance comes from the parent flex gap, so slot padding stacks on top of it (or is a no-op). Tune density on the Card itself instead: <Card className="gap-2 py-3 md:py-4">.',
cardShellOffScale:
'Off-scale "{{match}}" on a bg-card+border shell — the scale is 1/1.5/2/3/4/6/8/10, so the `5`/`7` steps and half-steps other than 1.5 are banned (UI-STANDARDS §2) and read off next to every sibling <Card> (p-4 md:p-6). Sweep the shell to an on-scale step (`p-4 md:p-6`, or `p-3` for a dense inner tile); new surfaces should compose <Card>.',
'Off-scale "{{match}}" on a bg-card+border shell — the scale is 1/1.5/2/3/4/6/8/10, so the `5`/`7` steps and half-steps other than 1.5 are banned (ui-standards.md §2) and read off next to every sibling <Card> (p-4 md:p-6). Sweep the shell to an on-scale step (`p-4 md:p-6`, or `p-3` for a dense inner tile); new surfaces should compose <Card>.',
},
},
create(context) {
+493 -78
View File
@@ -401,7 +401,41 @@
"lifeSittingValue": "{hours} Stunden, Selbstauskunft",
"lifeWork": "Arbeit",
"lifeNightWork": "Nachtarbeit",
"lifeExposures": "Belastungen: {list}"
"lifeExposures": "Belastungen: {list}",
"workouts": {
"title": "Trainings",
"summary": "{sessions} Einheiten, insgesamt {hours} h; im Schnitt {perWeek} Einheiten und {minutesPerWeek} Minuten pro Woche.",
"colSport": "Sportart",
"colSessions": "Einheiten",
"colTime": "Dauer",
"colDistance": "Strecke",
"colEnergy": "Energie",
"colHeartRate": "Ø Herzfrequenz",
"moreSports": "{count} weitere Sportarten sind in den Summen enthalten, aber nicht einzeln aufgeführt."
},
"medicationEffect": {
"title": "Veränderung rund um den Start der Medikamente",
"colMedication": "Medikament / Messwert",
"colStart": "Start",
"colLevel": "Niveauänderung (95-%-KI)",
"colSlope": "Trendänderung (95-%-KI)",
"colDays": "Tage vor / nach",
"to": "bis",
"week": "Woche",
"reasonNoStart": "Kein Startdatum erfasst",
"reasonBefore": "Zu wenige Werte vor dem Start",
"reasonAfter": "Zu wenige Werte seit dem Start",
"reasonFit": "Nicht berechenbar",
"excludedAdherence": "{count} Tage nach dem Start ausgelassen (unter {floor} % der Dosen eingenommen)",
"excludedNotTaking": "{count} Tage ausgelassen (pausiert oder zwischen Einnahmezeiträumen)",
"notTracked": "Einnahme nicht erfasst, daher keine Tage wegen ausgelassener Dosen entfernt",
"endDoseChange": "Zeitraum endet am {date}, vor der ersten Dosisänderung",
"days": "{before} Tage vor, {after} Tage nach dem Start, {excluded} Tage ausgelassen",
"method": "Segmentierte Regression über die Tageswerte von 90 Tagen vor bis höchstens 180 Tagen nach dem Start, Intervalle nach Newey-West. Tage nach dem Start, an denen in den 14 Tagen davor weniger als 80 % der Dosen eingenommen wurden, sind ausgelassen. Beschreibt einen zeitlichen Zusammenhang, keine nachgewiesene Wirkung des Medikaments.",
"autocorrelation": "starke Abhängigkeit vom Vortag, die Bereiche können daher noch zu eng sein",
"window": "ausgewertet {from} bis {to}, das kann über den Berichtszeitraum hinausreichen",
"windowLine": "Ausgewertet {from} bis {to}. Dieser Zeitraum kann über den Berichtszeitraum hinausreichen."
}
},
"errorBoundary": {
"title": "Da ist etwas schiefgelaufen",
@@ -677,7 +711,7 @@
"same_time_baseline": "Aktivität zur gleichen Tageszeit",
"upcoming_visit": "Termine"
},
"layoutReset": "Auf Defaults zurücksetzen",
"layoutReset": "Auf Standard zurücksetzen",
"layoutResetTitle": "Dashboard-Layout zurücksetzen?",
"layoutResetBody": "Dein Kachel-Layout und deine Auswahl für die Highlights für heute werden auf die Standardwerte zurückgesetzt.",
"layoutResetConfirm": "Layout zurücksetzen",
@@ -845,7 +879,7 @@
"previousPage": "Vorherige Seite",
"nextPage": "Nächste Seite",
"dailyTotalCaption": "({count} Einzelwerte)",
"openMetricInsights": "{type}-Auswertung öffnen",
"openMetricInsights": "{type} in Insights öffnen",
"sleepNapCaption": "inkl. {count} Nickerchen",
"sleepAwakeningsCaption": "{count} Wachphasen",
"expandDay": "Einzelwerte dieses Tages anzeigen",
@@ -911,6 +945,15 @@
"unitRating": "Stufe",
"typeWho5Score": "WHO-5-Wert",
"typeSciScore": "SCI-Wert",
"typeExerciseMinutes": "Trainingsminuten",
"typeStandMinutes": "Stehminuten",
"typeStandHours": "Stehstunden",
"typeMindfulMinutes": "Achtsamkeitsminuten",
"typeStateOfMindValence": "Gemütszustand",
"typeAfibBurden": "VHF-Last",
"typeIndoorCo2": "CO₂ (innen)",
"typeIndoorTemperature": "Raumtemperatur",
"typeIndoorHumidity": "Luftfeuchtigkeit (innen)",
"durationReadAs": "Gelesen als {duration}",
"durationHoursMinutes": "{hours} Std. {minutes} Min.",
"durationHoursOnly": "{hours} Std.",
@@ -1827,7 +1870,7 @@
"phaseRed": "Rot",
"phaseMinutes": "Min",
"phasePercent": "%",
"phaseResetDefaults": "Auf Defaults zurücksetzen",
"phaseResetDefaults": "Auf Standard zurücksetzen",
"phaseSaved": "Erinnerungsphasen gespeichert",
"sourceWeb": "Web",
"sourceApi": "iOS-App",
@@ -2350,7 +2393,31 @@
},
"scaleAbsolute": "Absolut",
"scalePercentSinceStart": "% seit Start",
"scaleToggleLabel": "Diagrammskala"
"scaleToggleLabel": "Diagrammskala",
"segmented": {
"title": "Veränderung zum Start",
"level": "Direkt zum Start hat sich das Niveau um {value} {unit} verschoben (95-%-Bereich {low} bis {high}).",
"slope": "Nach dem Start hat sich der Wochentrend um {value} {unit} pro Woche verändert (95-%-Bereich {low} bis {high}).",
"preSlope": "Vor dem Start lag der Trend bei {value} {unit} pro Woche.",
"includesZero": "Wo ein Bereich die Null einschließt, passen diese Werte auch zu gar keiner Veränderung.",
"basis": "Grundlage: {before} Tage vor und {after} Tage nach dem Start ({from} bis {to}).",
"excludedAdherence": "{count} Tage nach dem Start wurden ausgelassen, weil in den {lookback} Tagen davor weniger als {floor} % der Dosen eingenommen wurden.",
"excludedNotTaking": "{count} Tage wurden ausgelassen, weil das Medikament pausiert war oder zwischen zwei Einnahmezeiträumen lag.",
"notTracked": "Für dieses Medikament wird die Einnahme nicht erfasst, deshalb wurden keine Tage wegen ausgelassener Dosen entfernt.",
"endDoseChange": "Der Zeitraum endet vor der ersten Dosisänderung und beschreibt deshalb die Anfangsdosis.",
"confoundersTitle": "Weitere Medikamentenänderungen in diesem Zeitraum, die diese Ansicht nicht von diesem Medikament trennen kann:",
"confounderStarted": "{name} begonnen am {date}",
"confounderStopped": "{name} beendet am {date}",
"confounderDoseChange": "{name}: Dosis geändert am {date}",
"confounderPaused": "{name} pausiert am {date}",
"confounderResumed": "{name} fortgesetzt am {date}",
"method": "So wird gerechnet: Eine Gerade wird durch die Tageswerte vor dem Start gelegt, eine zweite durch die danach. Der Sprung zwischen beiden am Start ist die Niveauänderung, der Unterschied ihrer Steigungen die Trendänderung. Die Bereiche berücksichtigen, dass Werte von einem Tag auf den nächsten nachwirken.",
"needStart": "Lege ein Startdatum fest, um die Veränderung zum Start zu sehen.",
"needBefore": "Für die Veränderung zum Start braucht es Werte an mindestens {days} Tagen, verteilt über {span} Tage vor dem Start.",
"needAfter": "Für die Veränderung zum Start braucht es Werte an mindestens {days} Tagen, verteilt über {span} Tage seit dem Start und an Tagen mit eingenommenen Dosen.",
"fitFailed": "Diese Werte ließen sich nicht auswerten, deshalb wird keine Veränderung zum Start gezeigt.",
"autocorrelation": "Die Tageswerte hängen stark vom Vortag ab, deshalb können selbst diese Bereiche zu eng sein."
}
},
"import": {
"resultSuccess": "{imported} Einnahmen importiert",
@@ -2397,7 +2464,7 @@
"create": "Hinzufügen",
"limitReached": "Du hast die Grenze von 20 eigenen Kategorien erreicht.",
"saveFailed": "Das wurde nicht gespeichert. Bitte versuch es noch einmal.",
"bpGateNote": "Die Blutdruck-Auswertung zählt nur Medikamente in der eingebauten Kategorie Blutdruck.",
"bpGateNote": "Die Blutdruck-Insights zählen nur Medikamente in der eingebauten Kategorie Blutdruck.",
"manage": "Kategorien verwalten",
"manageDescription": "Deine eigenen Kategorien, neben den eingebauten.",
"hidden": "Ausgeblendet",
@@ -2679,7 +2746,20 @@
"ENV_PRESSURE_DELTA": "die Luftdruckschwankung am Tag",
"ENV_PM25": "der Feinstaub (PM2,5)",
"ENV_OZONE_8H": "das Ozon",
"ENV_POLLEN_MAX": "der Pollenflug"
"ENV_POLLEN_MAX": "der Pollenflug",
"HRV_RMSSD": "die Herzratenvariabilität (RMSSD)",
"HYDRATION": "die Trinkmenge",
"CAFFEINE": "die Koffeinmenge",
"ALCOHOL": "der Alkoholkonsum",
"WORKOUT_MINUTES": "die Workout-Dauer",
"TRAINING_LOAD": "die Trainingslast",
"EXERCISE_MINUTES": "die Trainingsminuten der Aktivitätsringe",
"STAND_MINUTES": "die Stehminuten",
"MINDFUL_MINUTES": "die Achtsamkeitsminuten",
"STATE_OF_MIND_VALENCE": "die erfasste Gemütslage",
"INDOOR_CO2": "der CO2-Wert im Raum",
"INDOOR_TEMPERATURE": "die Raumtemperatur",
"INDOOR_HUMIDITY": "die Luftfeuchtigkeit im Raum"
},
"daily": {
"high": "Wenn {behaviour} höher liegt, fällt {outcome} am Folgetag in deinen Daten tendenziell {direction} aus. Das ist ein Muster, das Beachtung verdient, keine Ursache.",
@@ -2707,6 +2787,12 @@
"high": "Mehr {behaviour} am selben Tag und am Vortag geht in deinen Daten tendenziell mit {direction} {outcome} einher. Das ist ein Muster, das sich zu beobachten lohnt, keine Ursache.",
"moderate": "Mehr {behaviour} am selben Tag und am Vortag scheint in deinen Daten nach bisheriger Lage mit {direction} {outcome} einherzugehen. Das ist ein Muster, das sich zu beobachten lohnt, keine Ursache.",
"faint": "Mehr {behaviour} am selben Tag und am Vortag zeigt in deinen Daten allenfalls einen schwachen Hinweis auf {direction} {outcome}. Das ist zu klein, um sich darauf zu stützen, und nie eine Ursache."
},
"channelNutrient": "die Zufuhr von {nutrient}",
"dailyOvernight": {
"high": "Wenn {behaviour} über Nacht höher liegt, fällt {outcome} in derselben Nacht in deinen Daten tendenziell {direction} aus. Das ist ein Muster, das Beachtung verdient, keine Ursache.",
"moderate": "Wenn {behaviour} über Nacht höher liegt, scheint {outcome} in derselben Nacht nach bisheriger Datenlage eher {direction} auszufallen. Das ist ein Muster, das Beachtung verdient, keine Ursache.",
"faint": "Wenn {behaviour} über Nacht höher liegt, gibt es in deinen Daten allenfalls einen schwachen Hinweis darauf, dass {outcome} in derselben Nacht {direction} ausfällt. Das ist zu gering, um sich darauf zu verlassen, und niemals eine Ursache."
}
},
"emptyTitle": "Noch nicht genug Daten",
@@ -2745,7 +2831,7 @@
"tooltip": "Persönlicher Rekord: {value} {unit} am {date}",
"benchmark": "Typisch in dem Alter: {low} bis {high} {unit} ({source})"
},
"loadError": "Auswertungen konnten nicht geladen werden",
"loadError": "Insights konnten nicht geladen werden",
"heroPersonalBaseline": "Basierend auf deinen letzten 90 Tagen",
"heroRegenerating": "Wird generiert",
"regenerateAnalysis": "Einschätzung neu erstellen",
@@ -3088,8 +3174,8 @@
"settingsCancel": "Abbrechen",
"dailyLimitTitle": "Heute-Limit erreicht",
"dailyLimitBody": "Tageslimit erreicht. Das Budget setzt sich um Mitternacht in deiner Zeitzone zurück.",
"providerRateLimitTitle": "Provider rate-limited",
"providerRateLimitBody": "Provider ist temporär überlastet; Reset in ~5 min.",
"providerRateLimitTitle": "Anbieter ausgelastet",
"providerRateLimitBody": "Der Anbieter ist gerade überlastet. In etwa 5 Minuten geht es wieder.",
"cluster": {
"cardio": {
"label": "Herz-Kreislauf",
@@ -3211,7 +3297,7 @@
"workouts": "Geh mit mir meine letzten Workouts durch: Belastung, Häufigkeit und wie ich mich dazwischen erhole.",
"recoveryPage": "Warum steht meine Erholung gerade dort, wo sie steht, und was treibt das?",
"briefing": "Geh mit mir das heutige Briefing durch. Worauf sollte ich mich konzentrieren, und gibt es etwas, das einen Schritt wert ist?",
"healthScore": "Warum liegt mein Gesundheits-Score bei {score} von 100, und was würde ihn am stärksten bewegen?",
"healthScore": "Warum liegt mein Gesundheitsscore bei {score} von 100, und was würde ihn am stärksten bewegen?",
"periodWeek": "Geh mit mir durch, wie meine letzte Woche über meine Gesundheitswerte hinweg lief. Was sticht heraus?",
"periodMonth": "Geh mit mir durch, wie mein letzter Monat über meine Gesundheitswerte hinweg lief. Was sticht heraus?",
"correlationBpCompliance": "Wie hängt meine Therapietreue mit meinem Blutdruck zusammen? Geh mit mir durch, was dieser Zusammenhang für mich bedeutet.",
@@ -3264,11 +3350,12 @@
"provider": "Antwortet mit",
"modelManaged": "Das Modell für diesen Anbieter legst du in den KI-Einstellungen fest.",
"noProvider": "Kein Anbieter verfügbar",
"allSettings": "Alle KI-Einstellungen",
"operatorCodex": "ChatGPT (vom Betreiber bereitgestellt)",
"changeModel": "In Einstellungen > KI ändern",
"saving": "Wird gespeichert…",
"saveFailed": "Die Änderung ließ sich nicht speichern.",
"allSettings": "Alle KI-Einstellungen",
"providerNotActive": "{provider} konnte nicht übernehmen: Es fehlt eine funktionierende Anmeldung oder ein Schlüssel. Richte das in den KI-Einstellungen ein.",
"operatorCodex": "ChatGPT (vom Betreiber bereitgestellt)",
"operatorManaged": "Diese Verbindung verwaltet der Betreiber deines Servers. Sobald einer deiner eigenen Anbieter eingerichtet ist, antwortet er zuerst."
},
"answer": {
@@ -3565,6 +3652,7 @@
"falls": "Stürze",
"years": "Jahre",
"breathingEvents": "Ereignisse",
"standHours": "Stunden",
"respiratoryRate": "Atemzüge/min"
},
"sleep": {
@@ -3807,6 +3895,16 @@
"editTitle": "Training bearbeiten",
"editAction": "Bearbeiten",
"updated": "Training aktualisiert"
},
"load": {
"title": "Trainingslast",
"fitness": "Fitness",
"fatigue": "Ermüdung",
"form": "Form",
"explainer": "Fitness ist dein Trainingsimpuls aus der Herzfrequenz, gemittelt über etwa sechs Wochen, Ermüdung über etwa eine Woche, und Form ist der Unterschied. Die Zahlen lassen sich mit deinen eigenen früheren vergleichen, nicht mit anderen Menschen oder Apps.",
"warmingUp": "Auf Basis von bisher {days} Tagen: Bis sechs Wochen abgedeckt sind, fällt die Fitness niedriger aus, als sie ist.",
"coverage": "{rated} der letzten {days} Tage ließen sich bewerten. Ein Tag ohne Aktivitätsdaten oder mit einem Training, das sich nicht auswerten ließ, bleibt außen vor, statt als Ruhetag zu zählen.",
"asOf": "Stand {date}, der letzte Tag, der sich bewerten ließ."
}
},
"bloodGlucose": {
@@ -4248,7 +4346,12 @@
"outcomeDown": "Bessere Stimmung geht in deinen Daten tendenziell mit niedrigerem {factor} am Folgetag einher.",
"stat": "{n} gepaarte Tage, r = {r}, q = {q}",
"statLabel": "Wie das berechnet wurde",
"explainerLabel": "Wie das berechnet wurde"
"explainerLabel": "Wie das berechnet wurde",
"channel": {
"ALCOHOL": "Alkoholkonsum",
"WORKOUT_MINUTES": "Workout-Dauer",
"TRAINING_LOAD": "Trainingslast"
}
},
"inTargetTitle": "Im Zielbereich",
"dimensions": {
@@ -4709,7 +4812,8 @@
"shiftDown": "{vital} hat sich gegenüber deiner jüngsten Norm nach unten verschoben.",
"rangeContext": "Aktuell {value}, sonst {low}–{high}.",
"rangeContextTimeOfDay": "Aktuell {value}, zu dieser Tageszeit sonst {low}–{high}.",
"shiftContext": "Etwa {before} davor, {after} seitdem."
"shiftContext": "Etwa {before} davor, {after} seitdem.",
"shiftDays": "Grundlage: {before} Tage davor und {after} seither."
},
"breathingScreening": {
"sectionTitle": "Atmung im Schlaf",
@@ -4860,10 +4964,10 @@
}
},
"thresholds": {
"defaultLabel": "Default",
"defaultLabel": "Standard",
"overrideToggleLabel": "Eigene Werte",
"resetAction": "Auf Default zurücksetzen",
"resetAllAction": "Alle auf Default zurücksetzen",
"resetAction": "Auf Standard zurücksetzen",
"resetAllAction": "Alle auf Standard zurücksetzen",
"resetAllTitle": "Alle eigenen Bereiche zurücksetzen?",
"resetAllBody": "Jede Messgröße kehrt zu ihrem Standardbereich zurück. Die von dir gesetzten Werte werden nirgends sonst gespeichert und lassen sich nicht wiederherstellen.",
"resetAllConfirm": "Alle zurücksetzen",
@@ -4877,7 +4981,7 @@
"overrideWarning": "Werte außerhalb der Leitlinien können zu ungesunden Zielen führen. Nur nach ärztlicher Rücksprache überschreiben.",
"saveSuccess": "Zielwerte aktualisiert",
"saveError": "Zielwerte konnten nicht gespeichert werden",
"resetSuccess": "Auf Default zurückgesetzt",
"resetSuccess": "Auf Standard zurückgesetzt",
"sourceOverride": "Eigener Wert",
"metricWeight": "Gewicht",
"metricBpSys": "Blutdruck (systolisch)",
@@ -4893,7 +4997,7 @@
"metricBodyWater": "Körperwasser",
"metricBoneMass": "Knochenmasse",
"metricOxygenSaturation": "Sauerstoffsättigung (SpO₂)",
"unsetExplanation": "Kein eigener Wert, berechneter Default aktiv.",
"unsetExplanation": "Kein eigener Wert, berechneter Standard aktiv.",
"cardTitle": "Bereiche je Messgröße"
},
"onboarding": {
@@ -4911,7 +5015,7 @@
},
"quickAdd": {
"title": "In Sekunden erfasst",
"body": "Über „+ Hinzufügen“ trägst du jeden Messwert in Sekunden ein: Gewicht, Blutdruck, Puls, Glukose. Versteckte Stärke: Werte lassen sich nachträglich datieren. Alles, was du hier einträgst, fließt in Trends, Health-Score und die KI-Insights."
"body": "Über „+ Hinzufügen“ trägst du jeden Messwert in Sekunden ein: Gewicht, Blutdruck, Puls, Glukose. Versteckte Stärke: Werte lassen sich nachträglich datieren. Alles, was du hier einträgst, fließt in Trends, Gesundheitsscore und die KI-Insights."
},
"measurements": {
"title": "Messwerte & Zielbereiche",
@@ -5289,12 +5393,17 @@
"groups": {
"account": "Konto",
"yourData": "Deine Daten",
"display": "Anzeige",
"healthProfile": "Gesundheitsprofil",
"ai": "KI",
"connectivity": "Konnektivität",
"system": "System"
}
"system": "System",
"tracking": "Erfassung",
"arrangement": "Anordnung",
"notifications": "Benachrichtigungen",
"connections": "Verbindungen",
"data": "Daten"
},
"allSections": "Alle Bereiche"
},
"sharedRecord": {
"managedSettings": {
@@ -5406,8 +5515,15 @@
"unavailableTitle": "In diesem geteilten Datensatz nicht verfügbar",
"unavailableDescription": "Dieser Einstellungsbereich ist beim Anzeigen des Datensatzes einer anderen Person bewusst nicht verfügbar.",
"integrationStatusDescription": "Verbindungsstatus für diesen verwalteten Datensatz.",
"integrationStatusDetail": "Ein betreutes Profil zeigt seine verbundenen Dienste nur als Status. Verbinden, neu verbinden, synchronisieren und trennen ist für Betreuende nicht möglich.",
"integrationStatusDocs": "So funktioniert das Teilen"
"integrationStatusDetail": "Dienste, die du hier verbindest, schicken ihre Daten in das Profil von {name}, nicht in deins. Ohne eigene Anbieter-App nutzt das Profil deine.",
"integrationStatusDocs": "So funktioniert das Teilen",
"integrationConnectsFor": "Verbindet für {name}",
"integrationConnectFor": "Für {name} verbinden",
"integrationDisconnect": "Trennen",
"integrationDisconnectTitle": "{provider} von {name} trennen?",
"integrationDisconnectBody": "Neue Daten kommen dann nicht mehr an. Was schon importiert ist, bleibt im Profil von {name}.",
"integrationNoClientApp": "Weder {name} noch dein eigenes Konto hat bisher eine {provider}-App. Richte zuerst bei deinen eigenen Integrationen eine ein.",
"integrationConnectFailed": "{provider} konnte nicht verbunden werden."
},
"sections": {
"account": {
@@ -5428,11 +5544,25 @@
"remindersHeading": "Erinnerungen",
"matrixLink": "Benachrichtigungen je Kanal",
"channelsLink": "Zustellkanäle",
"displayHeading": "Auf dem Handy"
"displayHeading": "Auf dem Handy",
"elsewhere": {
"title": "Dort eingestellt, wo sie hingehören",
"description": "Diese Erinnerungen stellst du direkt bei dem ein, worum es geht.",
"doses": {
"title": "Medikamenten-Einnahmen",
"description": "Einnahmezeiten und Erinnerungen stellst du beim jeweiligen Medikament ein.",
"link": "Medikamente öffnen"
},
"cycle": {
"title": "Zyklus",
"description": "Perioden- und Fruchtbarkeits-Erinnerungen schaltest du je Kanal in der Benachrichtigungsübersicht.",
"link": "Benachrichtigungsübersicht öffnen"
}
}
},
"layout": {
"title": "Layout",
"backToHub": "Zurück zu Layout",
"title": "Anordnung",
"backToHub": "Zurück zu Anordnung",
"dashboard": {
"title": "Dashboard",
"description": "Kachel-Layout und Reihenfolge."
@@ -5441,7 +5571,7 @@
"title": "Insights",
"description": "Reihenfolge der Übersicht und der Navigations-Pills."
},
"subtitle": "Lege fest, was Dashboard, Auswertungen und Tracking-Module zeigen und in welcher Reihenfolge.",
"subtitle": "Lege Reihenfolge und Ansicht von Dashboard, Insights und Tracking-Listen fest.",
"medications": {
"title": "Medikamente",
"description": "Listenansicht und manuelle Reihenfolge."
@@ -5458,10 +5588,6 @@
"title": "Vorsorge",
"description": "Listenansicht und Reihenfolge der Erinnerungen."
},
"mood": {
"title": "Stimmungs-Tags",
"description": "Gruppen, Tags und archivierte Tags der Stimmungs-Auswahl."
},
"documents": {
"title": "Dokumente",
"description": "Ansicht und Anordnung der Monate."
@@ -5848,11 +5974,32 @@
"error": "Konnte nicht gespeichert werden. Bitte erneut versuchen.",
"toggleable": {
"title": "Was du trackst",
"description": "Deaktivierte Module verschwinden aus Navigation, Kacheln und Auswertungen, deine Daten bleiben erhalten."
"description": "Deaktivierte Module verschwinden aus Navigation, Kacheln und Insights, deine Daten bleiben erhalten."
},
"manageIn": "In {section} verwalten",
"manageIn": "Unter {section} verwalten",
"operatorDisabled": "Serverweit deaktiviert",
"subtitle": "Lege fest, welche Tracking-Bereiche HealthLog überall anzeigt."
"subtitle": "Lege fest, welche Tracking-Bereiche HealthLog überall anzeigt.",
"backToHub": "Zurück zu den Modulen",
"openModuleSettings": "Moduleinstellungen",
"pages": {
"medications": {
"title": "Medikamente",
"description": "Injektionsstellen, die die Rotation auslässt."
},
"mood": {
"title": "Stimmungs-Tags",
"description": "Gruppen, eigene Tags und archivierte Tags."
},
"labs": {
"title": "Labor",
"description": "Biomarker und Texterkennung auf dem Gerät für Laborberichte."
},
"arrangementTitle": "Reihenfolge und Ansicht",
"arrangementDescription": "Wie diese Liste sortiert und angezeigt wird, legst du unter Anordnung fest.",
"openArrangement": "Unter Anordnung öffnen",
"moduleSettingsTitle": "Moduleinstellungen",
"moduleSettingsDescription": "Alles außer Reihenfolge und Ansicht findest du auf der Seite des Moduls unter Module."
}
},
"gesundheitsakte": {
"title": "Gesundheitsakte",
@@ -5971,12 +6118,12 @@
"omitted": "{count} Bereiche stehen nicht in der Liste, weil ihre Erfassung abgeschaltet ist. Schalte ein Modul wieder ein, dann kommt sein Bereich zurück.",
"omittedLink": "Module öffnen",
"refusal": {
"empty": "Ein Health Score braucht mindestens ein Feld der Gesundheit, das er bewerten kann. Behalte mindestens eines ausgewählt. Der Score nennt selbst, worauf er beruht."
"empty": "Ein Gesundheitsscore braucht mindestens ein Feld der Gesundheit, das er bewerten kann. Behalte mindestens eines ausgewählt. Der Score nennt selbst, worauf er beruht."
},
"notice": {
"upgrade": {
"title": "Was zählt, entscheidest jetzt du",
"body": "Welche Bereiche in deinen Health Score einflossen, richtete sich früher danach, welche Module du eingeschaltet hattest. Das ist jetzt eine eigene Entscheidung, und hier triffst du sie. Deine Zahl hat sich dadurch nicht bewegt: Sie zählt weiterhin genau das, was sie vorher gezählt hat."
"body": "Welche Bereiche in deinen Gesundheitsscore einflossen, richtete sich früher danach, welche Module du eingeschaltet hattest. Das ist jetzt eine eigene Entscheidung, und hier triffst du sie. Deine Zahl hat sich dadurch nicht bewegt: Sie zählt weiterhin genau das, was sie vorher gezählt hat."
},
"changed": {
"title": "Du hast geändert, was zählt",
@@ -6158,7 +6305,7 @@
"lastGeneratedAt": "Zuletzt generiert",
"ai": {
"chatgptConnectedBadge": "ChatGPT verbunden",
"adminAiActiveBadge": "Admin-Provider aktiv",
"adminAiActiveBadge": "Betreiber-Anbieter aktiv",
"connectionExpiredBadge": "Verbindung abgelaufen",
"connectedSince": "Verbunden seit {when}.",
"deviceCodeHeading": "Verbindung auf chatgpt.com abschließen",
@@ -6196,8 +6343,8 @@
"moveUp": "Nach oben",
"moveDown": "Nach unten",
"removeFromChain": "Aus Kette entfernen",
"addProvider": "Provider hinzufügen",
"addNoneAvailable": "Alle Provider sind bereits in der Kette",
"addProvider": "Anbieter hinzufügen",
"addNoneAvailable": "Alle Anbieter sind bereits in der Kette",
"saveOrder": "Reihenfolge speichern",
"saved": "Kette gespeichert",
"saveFailed": "Speichern der Kette fehlgeschlagen",
@@ -6205,7 +6352,7 @@
"resetConfirmTitle": "Kette auf Standard zurücksetzen?",
"resetConfirmBody": "Die Kette wird auf Codex → OpenAI → Anthropic → Lokal → Admin-OpenAI zurückgesetzt. Gespeicherte Zugangsdaten bleiben erhalten."
},
"activeProviderHeading": "Aktiver Provider",
"activeProviderHeading": "Aktiver Anbieter",
"activeProviderLabel": "Anbieter einrichten",
"providerSelect": {
"codex": "ChatGPT-Account (Codex)",
@@ -6248,10 +6395,10 @@
},
"adminOpenai": {
"title": "Admin-OpenAI",
"body": "Vom Betreiber bereitgestellter OpenAI-Key, genutzt als letzter Fallback ohne persönlichen Provider. Hier ist nichts einzustellen.",
"body": "Vom Betreiber bereitgestellter OpenAI-Key, genutzt als letzter Fallback ohne persönlichen Anbieter. Hier ist nichts einzustellen.",
"notConfigured": "Der Betreiber hat keinen geteilten OpenAI-Key auf dieser Instanz konfiguriert."
},
"testProvider": "Aktiven Provider testen",
"testProvider": "Aktiven Anbieter testen",
"testSuccess": "OK: {provider} ({model})",
"testFailedShort": "Test fehlgeschlagen: {message}",
"testUnexpectedResponse": "Verbindung zum KI-Anbieter fehlgeschlagen: unerwartete Antwort vom Server.",
@@ -6499,7 +6646,8 @@
"nocode": "Withings hat keinen Freigabe-Code übermittelt. Versuche es erneut und erlaube den Zugriff im Withings-Dialog.",
"nocreds": "Es sind keine Withings-API-Zugangsdaten hinterlegt. Speichere zuerst Client-ID und Client-Secret, dann verbinde erneut.",
"token": "Der Token-Austausch mit Withings ist fehlgeschlagen. Prüfe Client-ID und Client-Secret und versuche es erneut.",
"generic": "Unbekannter Fehler bei der Verbindung. Starte die Verbindung bitte erneut."
"generic": "Unbekannter Fehler bei der Verbindung. Starte die Verbindung bitte erneut.",
"already_linked": "Dieses Withings-Konto ist schon mit einem anderen HealthLog-Profil verbunden. Melde dich bei Withings mit dem Konto an, das zu diesem Profil gehört, oder trenne es zuerst vom anderen Profil."
},
"withingsSynchronize": "Synchronisieren",
"withingsDisconnect": "Trennen",
@@ -6538,7 +6686,8 @@
"nocode": "WHOOP hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im WHOOP-Dialog.",
"nocreds": "WHOOP ist nicht konfiguriert. Hinterlege oben deine WHOOP-OAuth-Zugangsdaten und versuche es erneut.",
"token": "Der Token-Austausch mit WHOOP ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut.",
"already_linked": "Dieses WHOOP-Konto ist schon mit einem anderen HealthLog-Profil verbunden. Melde dich bei WHOOP mit dem Konto an, das zu diesem Profil gehört, oder trenne es zuerst vom anderen Profil."
},
"fitbit": "Fitbit",
"fitbitTag": "Fitbit & Pixel",
@@ -6617,7 +6766,8 @@
"nocode": "Google hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Google-Dialog.",
"nocreds": "Google Health ist nicht konfiguriert. Hinterlege oben deine Google-OAuth-Zugangsdaten und versuche es erneut.",
"token": "Der Token-Austausch mit Google ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut.",
"already_linked": "Dieses Google-Konto ist schon mit einem anderen HealthLog-Profil verbunden. Melde dich bei Google mit dem Konto an, das zu diesem Profil gehört, oder trenne es zuerst vom anderen Profil."
},
"fitbitOauthError": {
"csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.",
@@ -6628,7 +6778,8 @@
"nocreds": "Fitbit ist nicht konfiguriert. Hinterlege oben deine Fitbit-OAuth-Zugangsdaten und versuche es erneut.",
"token": "Der Token-Austausch mit Fitbit ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut.",
"retired": "Google hat die Fitbit-Web-API abgeschaltet, deshalb lässt sich keine neue Fitbit-Verbindung mehr herstellen. Verbinde stattdessen Google Health, das dieselben Geräte liest."
"retired": "Google hat die Fitbit-Web-API abgeschaltet, deshalb lässt sich keine neue Fitbit-Verbindung mehr herstellen. Verbinde stattdessen Google Health, das dieselben Geräte liest.",
"already_linked": "Dieses Fitbit-Konto ist schon mit einem anderen HealthLog-Profil verbunden. Melde dich bei Fitbit mit dem Konto an, das zu diesem Profil gehört, oder trenne es zuerst vom anderen Profil."
},
"integrations": {
"withings": {
@@ -6665,7 +6816,9 @@
"items": {
"vo2max": "VO2max",
"cardio_age": "kardiovaskuläres Alter",
"resilience": "Resilienz"
"resilience": "Resilienz",
"workout": "Trainings",
"heartrate": "Herzfrequenz"
}
}
}
@@ -6972,7 +7125,8 @@
"nocreds": "Polar ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Polar-OAuth-Zugangsdaten zu hinterlegen.",
"token": "Der Token-Austausch mit Polar ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
"rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.",
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut.",
"already_linked": "Dieses Polar-Konto ist schon mit einem anderen HealthLog-Profil verbunden. Melde dich bei Polar mit dem Konto an, das zu diesem Profil gehört, oder trenne es zuerst vom anderen Profil."
},
"oura": "Oura",
"ouraDescription": "Verbinde dein Oura-Konto, um Readiness, Schlafphasen, Herzfrequenzvariabilität und tägliche Aktivität zu synchronisieren.",
@@ -7002,25 +7156,24 @@
"token": "Der Token-Austausch mit Oura ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
"rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.",
"issuer": "Die Anmeldeantwort kam nicht vom Autorisierungsserver von Oura, deshalb wurde die Verbindung zu deiner Sicherheit abgebrochen. Bitte starte die Verbindung erneut. Wenn das wieder passiert, sag deinem Administrator Bescheid.",
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut.",
"already_linked": "Dieses Oura-Konto ist schon mit einem anderen HealthLog-Profil verbunden. Melde dich bei Oura mit dem Konto an, das zu diesem Profil gehört, oder trenne es zuerst vom anderen Profil."
},
"integrationSetupGuide": "Einrichtungsanleitung",
"coach": {
"activate": {
"title": "Coach aktivieren",
"description": "Zeigt den Coach-Button und das Coach-Panel.",
"toggleAria": "Coach-Button und Coach-Panel aktivieren",
"savedOn": "Coach ist aktiviert.",
"savedOff": "Coach ist ausgeblendet.",
"saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen."
},
"memory": {
"edit": "Bearbeiten",
"source": {
"user": "von dir",
"coach": "vom Coach"
}
}
},
"state": {
"title": "Coach",
"description": "Ob der Coach-Button und das Coach-Panel erscheinen. Der Schalter liegt unter Module."
},
"nudgesMoved": "Stellst du mit deinen anderen Erinnerungen unter Benachrichtigungen ein.",
"openNudges": "In Benachrichtigungen öffnen"
},
"integrationRedirectGuide": {
"intro": "Die Verbindung läuft über deine eigene {provider}-Entwickler-App. Die Einrichtung dauert etwa 5 Minuten:",
@@ -7155,6 +7308,12 @@
"profile": "Dein Profil, deine Anamnese und die Angaben für die Gesundheitsakte",
"modules": "Einträge der Module: {list}"
},
"workoutRoutes": {
"title": "Trainingsrouten",
"description": "Den GPS-Verlauf neuer Trainings aus der iPhone-App und dem Apple-Health-Export speichern.",
"detail": "Für neue Konten aus. Eine Route zeigt, wo du wohnst, arbeitest und trainierst, deshalb wird sie erst gespeichert, wenn du das einschaltest. Die Trainings selbst werden immer gespeichert, und keine KI-Funktion liest jemals eine Route. Ausschalten stoppt neue Routen; schon gespeicherte bleiben, bis du das Training löschst.",
"loadError": "Die Routen-Einstellung konnte nicht geladen werden."
},
"retention": {
"title": "Aufbewahrung",
"description": "Wie lange zeitgebundene Datensätze vor der automatischen Bereinigung gespeichert bleiben.",
@@ -7163,12 +7322,6 @@
"delivery": "Benachrichtigungsprotokoll: {days} Tage",
"unavailable": "Aufbewahrungsdetails sind derzeit nicht verfügbar."
},
"export": {
"title": "Meine Daten herunterladen",
"description": "Deine Datensätze als CSV oder JSON oder eine vollständige Akte für die Praxis.",
"openExport": "Export & Backup",
"openHealthRecord": "Gesundheitsakte"
},
"delete": {
"title": "Löschen oder zurücksetzen",
"description": "Lösche dein Konto vollständig oder lösche deine Gesundheitsdaten und behalte das Konto.",
@@ -7179,12 +7332,16 @@
"title": "Datenschutz-Einstellung",
"description": "Steuere, wie viele deiner Daten an KI-Funktionen weitergegeben werden.",
"openAi": "KI-Datenschutzmodus"
},
"sessions": {
"title": "Sitzungen und Geräte",
"description": "Wo du angemeldet bist, welche Geräte den zweiten Faktor überspringen, und die Sicherheitsaktivität deines Kontos.",
"open": "Sicherheit öffnen"
}
},
"mcp": {
"enableTitle": "Remote-MCP-Endpunkt",
"enableDescription": "Erlaube einem MCP-fähigen Assistenten (Claude.ai, ChatGPT), deine Gesundheitsdaten über ein begrenztes Token zu lesen.",
"enableToggleLabel": "MCP-Endpunkt aktivieren",
"endpointLabel": "Connector-URL",
"connectDescription": "Füge diese URL in die Einrichtung des eigenen Connectors deines Assistenten ein und autorisiere dich mit dem Token unten oder melde dich bei Aufforderung an.",
"tokensTitle": "Connector-Tokens",
@@ -7203,7 +7360,8 @@
"connectionRevokeFailed": "Assistent konnte nicht getrennt werden.",
"tokensDetail": "Das Token wird nur einmal angezeigt.",
"connectionsDetail": "Das Widerrufen einer Verbindung beendet den Zugriff sofort, einschließlich der Möglichkeit zur Erneuerung.",
"tokenNamePlaceholder": "Name, z. B. Claude Desktop"
"tokenNamePlaceholder": "Name, z. B. Claude Desktop",
"offKeepsTokens": "Ausschalten pausiert den Endpunkt. Deine Tokens und Verbindungen bleiben und funktionieren wieder, sobald er an ist."
},
"strava": "Strava",
"stravaDescription": "Verbinde dein Strava-Konto, um deine Aktivitäten in deinen Trainingsverlauf zu übernehmen.",
@@ -7232,7 +7390,8 @@
"nocreds": "Strava ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Strava-API-Zugangsdaten hinzuzufügen.",
"token": "Der Token-Austausch mit Strava ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
"rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.",
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut.",
"already_linked": "Dieses Strava-Konto ist schon mit einem anderen HealthLog-Profil verbunden. Melde dich bei Strava mit dem Konto an, das zu diesem Profil gehört, oder trenne es zuerst vom anderen Profil."
},
"appleHealth": {
"title": "Apple Health",
@@ -7465,7 +7624,14 @@
"passkeyAddedToast": "Passkey hinzugefügt.",
"passkeyRemovedToast": "Passkey entfernt.",
"integrationDisconnected": "{provider} getrennt.",
"integrationDisconnectFailed": "{provider} konnte nicht getrennt werden."
"integrationDisconnectFailed": "{provider} konnte nicht getrennt werden.",
"timezoneSaved": "Zeitzone gespeichert.",
"moduleState": {
"on": "An",
"off": "Aus",
"change": "Unter Module ändern"
},
"timezoneUseDevice": "Zeitzone dieses Geräts übernehmen ({zone})"
},
"admin": {
"title": "Administration",
@@ -7545,7 +7711,7 @@
"usageLevels": "Konten je KI-Stufe: {frugal} sparsam, {standard} Standard, {full} voll."
},
"aiQuality": {
"title": "Auswertungs-Qualität",
"title": "Insights-Qualität",
"loading": "Feedback-Zusammenfassung wird geladen…",
"loadError": "Qualitätszusammenfassung konnte nicht geladen werden. Bitte die Seite neu laden.",
"empty": "Noch kein Feedback. Der Aggregator läuft täglich um 04:00. Sobald Nutzer Empfehlungen bewerten, erscheinen hier die Hilfreich-Quoten.",
@@ -7600,6 +7766,7 @@
"heapLimitLabel": "Heap-Grenze",
"heapLimitValue": "{mb} MB",
"heapLimitLow": "{mb} MB, zu knapp. APP_MEMORY_LIMIT=1536m in .env setzen",
"memoryBelowFloor": "Container {mb} MB, unter der unterstützten Untergrenze von {floor} MB. APP_MEMORY_LIMIT={floor}m in .env setzen",
"offlineGeoOff": "Aus",
"offlineGeoChosen": "Online ({host})",
"apiGlobal": "API-Endpunkte und API-Tokens erlauben.",
@@ -8855,6 +9022,10 @@
"detailedToast": "Benachrichtigungen zeigen jetzt Gesundheitswerte.",
"genericToast": "Benachrichtigungen zeigen keine Gesundheitswerte mehr.",
"saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen."
},
"checkupReminders": {
"title": "Vorsorge-Erinnerungen",
"description": "Schalte einzelne Vorsorge-Erinnerungen ein oder aus."
}
},
"i18n": {
@@ -9631,8 +9802,6 @@
"description": "Wert eintragen; die Erinnerung wird als erledigt markiert."
},
"manage": {
"title": "Erinnerungen verwalten",
"description": "Einzelne Erinnerungen aktivieren oder deaktivieren.",
"empty": "Noch keine Erinnerungen zum Anpassen."
},
"dashboard": {
@@ -10016,6 +10185,27 @@
"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."
},
"trend": {
"title": "Verlauf",
"slope": {
"rising": "Steigt um etwa {amount} {unit} pro Jahr, über {n} Werte seit {since}.",
"falling": "Sinkt um etwa {amount} {unit} pro Jahr, über {n} Werte seit {since}.",
"flat": "Keine Veränderung über {n} Werte seit {since}."
},
"projection": {
"exits": {
"high": "Liefe diese Gerade so weiter, erreichte sie um den {date} das obere Ende deines Referenzbereichs. Das ist eine Fortschreibung, keine Vorhersage.",
"low": "Liefe diese Gerade so weiter, erreichte sie um den {date} das untere Ende deines Referenzbereichs. Das ist eine Fortschreibung, keine Vorhersage."
},
"beyond_horizon": "In diesem Tempo bleibt die Gerade länger als {years} Jahre in deinem Referenzbereich, so weit reichen deine Werte zurück.",
"beyond_horizon_capped": "In diesem Tempo bleibt die Gerade länger als {years} Jahre in deinem Referenzbereich, so weit schaut die Fortschreibung voraus.",
"no_exit": "Die Gerade läuft von den Grenzen deines Referenzbereichs weg.",
"already_outside": "Die Gerade liegt bereits außerhalb deines Referenzbereichs.",
"no_clear_trend": "Über diese Werte ist die Veränderung nicht klar von keiner Veränderung zu unterscheiden, deshalb wird nichts fortgeschrieben.",
"stale": "Der neueste Wert ist vom {date}, also älter als ein Jahr. Die Gerade gilt für diesen Stand, ohne Projektion."
},
"interval": "95-%-Intervall der jährlichen Veränderung: {lo} bis {hi} {unit}."
}
},
"modules": {
@@ -10957,7 +11147,16 @@
"openDetail": "Details anzeigen",
"historyLoadError": "Dein Testverlauf konnte nicht geladen werden",
"historyLoadErrorHint": "Das heißt nicht, dass keine Ergebnisse vorliegen. Die Liste konnte nicht abgerufen werden. Versuche es erneut.",
"privacyNote": "Deine Antworten sind verschlüsselt gespeichert und erreichen den KI-Coach nicht."
"privacyNote": "Deine Antworten sind verschlüsselt gespeichert und erreichen den KI-Coach nicht.",
"trend": {
"sincePrevious": "Seit deinem vorigen Check-in am {date}: {change} Punkte.",
"sinceFirst": "Seit deinem ersten Check-in am {date}: {change} Punkte.",
"verdict": {
"improved": "Das erreicht die Veränderung von {threshold} Punkten, die Studien als bedeutsam ansehen, in die bessere Richtung.",
"worsened": "Das erreicht die Veränderung von {threshold} Punkten, die Studien als bedeutsam ansehen, in die weniger gute Richtung.",
"within_threshold": "Das ist weniger als die Veränderung von {threshold} Punkten, die Studien als bedeutsam ansehen."
}
}
},
"records": {
"aiInclusion": {
@@ -12216,7 +12415,10 @@
"leafVisits": "Arztbesuche",
"leafSurgicalHistory": "Operationen und Eingriffe",
"leafImmunizations": "Impfungen",
"leafEmergency": "Notfalldaten (Blutgruppe, Kontakte, Verfügungen)"
"leafEmergency": "Notfalldaten (Blutgruppe, Kontakte, Verfügungen)",
"leafMedicationEffect": "Veränderung rund um den Start jedes Medikaments (braucht die Medikamentenliste)",
"leafWorkouts": "Trainings (Übersicht je Sportart)",
"leafHrvSdnn": "HRV (SDNN)"
},
"recordSharing": {
"banner": {
@@ -12276,7 +12478,7 @@
"accessWriteDetail": "Sie kann Messwerte, Laborergebnisse, einen neuen Laborwert, einen Krankheitseintrag, eine Nebenwirkung zu einem Medikament und ein neues Medikament hinzufügen sowie eine Dosis als genommen oder ausgelassen markieren. Ändern oder löschen kann sie nichts, auch nicht das Eigene, und eine deiner Erinnerungen verschieben kann sie nicht. Das kannst nur du.",
"accessManageLabel": "Darf verwalten",
"accessManageCapability": "Sie kann Einträge anlegen, ändern und löschen, auch solche, die du selbst erfasst hast.",
"accessManageDetail": "Sie kann außerdem die daraus erzeugten Auswertungen lesen. Deine Anmeldung, deine Sicherheitseinstellungen, deine Verbindungen und den Zugriff anderer Personen kann sie nicht ändern. Du kannst das jederzeit beenden.",
"accessManageDetail": "Sie kann außerdem die daraus erzeugten Insights lesen. Deine Anmeldung, deine Sicherheitseinstellungen, deine Verbindungen und den Zugriff anderer Personen kann sie nicht ändern. Du kannst das jederzeit beenden.",
"manageStepUpNote": "Das geht nur im Browser, und falls du einen zweiten Faktor eingerichtet hast, wird er abgefragt.",
"scopeLegend": "Was die Person öffnen darf",
"scopeAllLabel": "Gesamte Akte",
@@ -12285,7 +12487,7 @@
"scopeSomeCapability": "Alles, was du nicht auswählst, bleibt für sie verschlossen.",
"scopeManageNote": "Verwaltender Zugriff umfasst immer die gesamte Akte.",
"scopeLeakNote": "Ein Bereich kann in seinen Notizen und Bezeichnungen auch den Rest deiner Gesundheit erwähnen. Wer einen Bereich bekommt, bekommt alles, was darin steht.",
"scopeAggregateNote": "Die Übersicht auf dem Dashboard, der Health Score und die Tageszusammenfassung erscheinen nur, wenn die gesamte Akte geteilt ist.",
"scopeAggregateNote": "Die Übersicht auf dem Dashboard, der Gesundheitsscore und die Tageszusammenfassung erscheinen nur, wenn die gesamte Akte geteilt ist.",
"scopeDocumentsNote": "Ein Dokument kann alles enthalten. Teile diesen Bereich nur, wenn die Person alles davon lesen darf.",
"scopeEmptyHint": "Wähle mindestens einen Bereich aus oder teile wieder die gesamte Akte.",
"scopeChooseHint": "Wähle, ob die gesamte Akte oder nur einzelne Bereiche geteilt werden.",
@@ -13396,5 +13598,218 @@
"clinicalUpper": "Klinische Obergrenze",
"clinicalLower": "Klinische Untergrenze"
}
},
"activityCalendar": {
"title": "Aktivitätskalender",
"periodLabel": "Zeitraum",
"period": {
"week": "Woche",
"month": "Monat"
},
"weekRange": "{start} bis {end}",
"previous": "Vorheriger Zeitraum",
"next": "Nächster Zeitraum",
"metricLabel": "Tage einfärben nach",
"metric": {
"steps": "Schritte",
"activeMinutes": "Aktive Minuten",
"load": "Trainingslast"
},
"empty": "In diesem Zeitraum gibt es noch keine Schritte, aktiven Minuten oder Trainings.",
"loadError": "Der Aktivitätskalender konnte nicht geladen werden.",
"weekGoalsColumn": "Wochenziele",
"weekGoalsMet": "Alle Wochenziele erreicht",
"weekGoalsNotMet": "Nicht alle Wochenziele erreicht",
"cell": {
"steps": "{value} Schritte",
"activeMinutes": "{value} aktive Minuten",
"workouts": "{value} Trainings: {sports}"
},
"level": {
"none": "Keine Daten",
"unrated": "Noch zu wenig Verlauf",
"low": "Weit unter deinem Üblichen",
"below": "Unter deinem Üblichen",
"usual": "Etwa wie üblich",
"above": "Über deinem Üblichen",
"high": "Weit über deinem Üblichen",
"in_progress": "Heute, noch nicht vorbei"
},
"legend": "Verglichen mit deinen 12 Wochen davor:",
"noBaseline": "Dein üblicher Bereich erscheint nach 14 Tagen mit Daten.",
"summary": {
"steps": "{value} Schritte",
"activeMinutes": "{value} aktive Minuten",
"workouts": "{value} Trainings",
"load": "Durchschnittliche Trainingslast {value}",
"usual": "Bis hierher üblich bei dir: {value}",
"noUsual": "Noch zu wenige frühere Wochen oder Monate zum Vergleichen.",
"usualThroughYesterday": "Bis gestern {value}, bis dahin üblich bei dir {usual}",
"firstDay": "Der Vergleich mit deinem Üblichen kommt, sobald der erste Tag vorbei ist."
},
"sportCount": "{sport}: {count}",
"goal": {
"workoutsPerWeek": "Trainings pro Woche",
"activeMinutesPerWeek": "Aktive Minuten pro Woche",
"stepsPerWeek": "Schritte pro Woche",
"stepsPerMonth": "Schritte pro Monat"
},
"goalOf": "{value} von {target}",
"setGoals": "Ziele festlegen",
"editGoals": "Ziele ändern",
"goalsSheet": {
"title": "Aktivitätsziele",
"description": "Lass ein Feld leer, wenn du dort kein Ziel möchtest.",
"none": "Kein Ziel",
"invalid": "Eine ganze Zahl von {min} bis {max}.",
"saveError": "Die Ziele konnten nicht gespeichert werden."
},
"crossLink": {
"title": "Aktivitätskalender",
"body": "Deine Schritte als Woche oder Monat, verglichen mit deinem Üblichen.",
"cta": "Kalender öffnen"
}
},
"sourceProvenance": {
"title": "Woher die Werte kommen",
"description": "Welche Quelle einen Wert an jedem Tag geliefert hat und welche deine Quellen-Priorität verwendet.",
"metric": "Messwert",
"day": "Tag",
"dayTitle": "Quellen an diesem Tag",
"loadError": "Die Quellen konnten nicht geladen werden.",
"dayEmpty": "An diesem Tag gibt es keine Werte dieser Art.",
"reason": {
"only_source": "{source} ist an diesem Tag die einzige Quelle.",
"highest_on_ladder": "{source} wird verwendet: Unter den Quellen dieses Tages steht sie in deiner Quellen-Priorität am höchsten.",
"name_tiebreak": "{source} wird verwendet: Keine der Quellen dieses Tages steht in deiner Quellen-Priorität, deshalb entscheidet der Name.",
"not_ranked": "Für diesen Wert gibt es keine Quellen-Priorität: Die Werte aller Quellen zählen."
},
"pass": {
"lower_on_ladder": "Steht in deiner Quellen-Priorität weiter unten.",
"not_on_ladder": "Steht für diesen Wert nicht in deiner Quellen-Priorität.",
"name_tiebreak": "Gleicher Rang wie die verwendete Quelle; der Name hat entschieden."
},
"used": "Verwendet",
"readings": "{count} Messungen, Geräte: {devices}",
"ladder": "Deine Reihenfolge für diesen Wert: {ladder}",
"utcNote": "Die Quelle wird pro Tag von Mitternacht bis Mitternacht UTC gewählt, genauso wie bei den Diagrammen.",
"openSettings": "Reihenfolge in der Quellen-Priorität ändern",
"compareTitle": "Zwei Quellen vergleichen",
"range": "Zeitraum",
"rangeDays": "{days} Tage",
"compareNeedsTwo": "In diesem Zeitraum hat nur eine Quelle Werte dieser Art.",
"side": {
"a": "Erste Quelle",
"b": "Zweite Quelle"
},
"daysBoth": "Tage mit beiden",
"meanOf": "Durchschnitt von {source}",
"meanAbsDiff": "Durchschnittliche Abweichung",
"pickedDays": "Verwendete Tage (erste / zweite)",
"pickedSplit": "{a} / {b}",
"compareNote": "Die Durchschnitte zählen nur Tage, an denen beide Quellen Werte haben, damit Gleiches mit Gleichem verglichen wird. Fett ist der Wert, den die Diagramme verwenden.",
"settingsHint": "Was diese Reihenfolge Tag für Tag entscheidet:",
"settingsHintLink": "Woher die Werte kommen",
"metricLink": "Woher diese Werte kommen"
},
"recordImport": {
"title": "Laborwerte und Medikamente aus einer Tabelle",
"description": "Hol dir Laborwerte aus Jahren oder eine Medikamentenliste aus einer CSV-Datei.",
"detail": "Du legst fest, welche Spalte was enthält. HealthLog prüft jede Zeile vorher und importiert erst, wenn du bestätigst. Zeilen, die schon da sind, werden übersprungen.",
"kind": {
"labs": "Laborwerte",
"medications": "Medikamente"
},
"sheetTitle": {
"labs": "Laborwerte importieren",
"medications": "Medikamente importieren"
},
"sheetDescription": {
"labs": "Marker werden deinen zugeordnet, Einheiten umgerechnet, wo die Umrechnung bekannt ist.",
"medications": "Name, Dosis, tägliche Uhrzeiten, bei Bedarf, Beginn und Ende. Andere Rhythmen legst du danach in der App fest."
},
"file": "CSV-Datei",
"template": "Vorlage herunterladen",
"mapping": "Welche Spalte enthält was",
"required": "(Pflicht)",
"notInFile": "Nicht in der Datei",
"column": "Spalte {n}",
"delimiter": "Trennzeichen",
"delimiterOption": {
"comma": "Komma",
"semicolon": "Semikolon",
"tab": "Tabulator"
},
"decimal": "Dezimalzeichen",
"decimalOption": {
"dot": "Punkt (5.4)",
"comma": "Komma (5,4)"
},
"dateOrder": "Datumsformat",
"dateOrderOption": {
"iso": "2025-03-01",
"dmy": "01.03.2025 (Tag zuerst)",
"mdy": "03/01/2025 (Monat zuerst)"
},
"header": "Die erste Zeile enthält Spaltennamen",
"reminders": "Erinnerungen für importierte Medikamente einschalten",
"timezoneNote": "Uhrzeiten ohne Zeitzone werden in der Zeitzone deines Kontos gelesen. Ein Datum ohne Uhrzeit bleibt dieser Kalendertag.",
"missing": "Noch auszuwählen: {fields}",
"check": "Datei prüfen",
"runIdle": "Importieren",
"run": "{count} Zeilen importieren",
"close": "Schließen",
"summaryCheck": "{rows} Zeilen gelesen: {inserted} können importiert werden, {duplicates} sind schon da, {failed} haben ein Problem.",
"summaryDone": "{inserted} Zeilen importiert. Übersprungen: {duplicates}, die schon da waren, und {failed} mit einem Problem.",
"markersMatched": "Deinen Markern zugeordnet:",
"markersCreated": "Neue Marker:",
"line": "Zeile {line}",
"errorsTruncated": "Nur die ersten 500 Probleme werden aufgelistet.",
"error": {
"tooLarge": "Die Datei ist größer als 16 MB.",
"rateLimited": "Zu viele Importe in der letzten Stunde. Versuch es später noch einmal.",
"invalid": "Die Datei oder die Spaltenauswahl konnte nicht gelesen werden.",
"failed": "Der Import hat nicht geklappt. Bitte versuch es noch einmal."
},
"reason": {
"missing_value": "Eine Pflichtzelle ist leer",
"invalid_number": "Keine Zahl",
"ambiguous_number": "Die Zahl ist mehrdeutig; prüf das Dezimalzeichen",
"missing_unit": "Eine Zahl braucht eine Einheit",
"invalid_date": "Kein Datum im gewählten Format",
"invalid_time": "Keine Uhrzeit",
"ambiguous_date": "Das Datum ist mehrdeutig; nutz eine vierstellige Jahreszahl und das gewählte Format",
"future_date": "Das Datum liegt in der Zukunft",
"text_too_long": "Der Text ist zu lang",
"unit_mismatch": "Diese Einheit lässt sich nicht in die Einheit des Markers umrechnen",
"invalid_reference_range": "Der Referenzbereich ist nicht lesbar",
"invalid_times": "Die Uhrzeiten sind nicht lesbar (z. B. 08:00 20:00)",
"missing_times": "Trag Einnahmezeiten ein oder markier das Medikament als bei Bedarf",
"invalid_yes_no": "Bitte ja oder nein",
"as_needed_with_times": "Ein Medikament bei Bedarf hat keine festen Uhrzeiten",
"end_before_start": "Das Ende liegt vor dem Beginn",
"invalid_medication": "So lässt sich das Medikament nicht speichern",
"oversized_row": "Die Zeile ist zu lang oder zu breit",
"row_limit": "Über der Zeilengrenze für einen Import",
"duplicate": "Schon da, übersprungen"
},
"field": {
"analyte": "Marker",
"value": "Ergebnis",
"unit": "Einheit",
"date": "Datum",
"time": "Uhrzeit",
"referenceRange": "Referenzbereich",
"referenceLow": "Referenz unten",
"referenceHigh": "Referenz oben",
"panel": "Gruppe",
"note": "Notiz",
"name": "Name",
"dose": "Dosis",
"times": "Uhrzeiten",
"asNeeded": "Bei Bedarf",
"startsOn": "Beginn",
"endsOn": "Ende"
}
}
}
+467 -52
View File
@@ -401,7 +401,41 @@
"lifeSittingValue": "{hours} hours, self-reported",
"lifeWork": "Work",
"lifeNightWork": "night work",
"lifeExposures": "exposures: {list}"
"lifeExposures": "exposures: {list}",
"workouts": {
"title": "Workouts",
"summary": "{sessions} sessions, {hours} h in total; on average {perWeek} sessions and {minutesPerWeek} minutes per week.",
"colSport": "Sport",
"colSessions": "Sessions",
"colTime": "Time",
"colDistance": "Distance",
"colEnergy": "Energy",
"colHeartRate": "Avg heart rate",
"moreSports": "{count} more sports are counted in the totals but not listed."
},
"medicationEffect": {
"title": "Change around each medication start",
"colMedication": "Medication / measure",
"colStart": "Start",
"colLevel": "Level change (95 % CI)",
"colSlope": "Trend change (95 % CI)",
"colDays": "Days before / after",
"to": "to",
"week": "week",
"reasonNoStart": "No start date recorded",
"reasonBefore": "Too few readings before the start",
"reasonAfter": "Too few readings since the start",
"reasonFit": "Could not be computed",
"excludedAdherence": "{count} days after the start left out (under {floor} % of doses taken)",
"excludedNotTaking": "{count} days left out (paused or between courses)",
"notTracked": "intake not tracked, so no days left out for missed doses",
"endDoseChange": "window ends {date}, before the first dose change",
"days": "{before} days before, {after} days after the start, {excluded} days left out",
"method": "Segmented regression on daily values from 90 days before to at most 180 days after the start, with Newey-West intervals. Days after the start on which fewer than 80 % of the doses of the preceding 14 days were taken are left out. Describes an association over time, not a proven effect of the medication.",
"autocorrelation": "strong day-to-day carry-over, so the ranges may still be too narrow",
"window": "analysed {from} to {to}, which can reach beyond the report period",
"windowLine": "Analysed {from} to {to}. This window can reach beyond the report period."
}
},
"errorBoundary": {
"title": "Something went wrong",
@@ -911,6 +945,15 @@
"unitRating": "rating",
"typeWho5Score": "WHO-5 score",
"typeSciScore": "SCI score",
"typeExerciseMinutes": "Exercise minutes",
"typeStandMinutes": "Standing minutes",
"typeStandHours": "Stand hours",
"typeMindfulMinutes": "Mindful minutes",
"typeStateOfMindValence": "State of mind",
"typeAfibBurden": "AFib burden",
"typeIndoorCo2": "Indoor CO₂",
"typeIndoorTemperature": "Indoor temperature",
"typeIndoorHumidity": "Indoor humidity",
"durationReadAs": "Read as {duration}",
"durationHoursMinutes": "{hours} h {minutes} min",
"durationHoursOnly": "{hours} h",
@@ -2350,7 +2393,31 @@
},
"scaleAbsolute": "Absolute",
"scalePercentSinceStart": "% since start",
"scaleToggleLabel": "Chart scale"
"scaleToggleLabel": "Chart scale",
"segmented": {
"title": "Change at the start",
"level": "Right at the start, the level moved by {value} {unit} (95 % range {low} to {high}).",
"slope": "After the start, the weekly trend changed by {value} {unit} per week (95 % range {low} to {high}).",
"preSlope": "Before the start, the trend was {value} {unit} per week.",
"includesZero": "Where a range includes zero, these readings are also consistent with no change.",
"basis": "Based on {before} days before and {after} days after the start ({from} to {to}).",
"excludedAdherence": "{count} days after the start were left out because fewer than {floor} % of the doses in the {lookback} days before them were taken.",
"excludedNotTaking": "{count} days were left out because the medication was paused or between courses.",
"notTracked": "Intake is not tracked for this medication, so no days were left out for missed doses.",
"endDoseChange": "The window ends before the first dose change, so this describes the starting dose.",
"confoundersTitle": "Other medication changes in this window, which this view cannot separate from this one:",
"confounderStarted": "{name} started on {date}",
"confounderStopped": "{name} stopped on {date}",
"confounderDoseChange": "{name}: dose changed on {date}",
"confounderPaused": "{name} paused on {date}",
"confounderResumed": "{name} resumed on {date}",
"method": "How this is worked out: one line is fitted to the daily values before the start and one after it. The jump between them at the start is the level change, the difference in their slopes the trend change. The ranges allow for readings that carry over from one day to the next.",
"needStart": "Set a start date to see the change at the start.",
"needBefore": "The change at the start needs readings on at least {days} days spread over {span} days before the start.",
"needAfter": "The change at the start needs readings on at least {days} days spread over {span} days since the start, on days the doses were taken.",
"fitFailed": "These readings could not be fitted, so no change at the start is shown.",
"autocorrelation": "The daily values carry over strongly from one day to the next, so even these ranges may be too narrow."
}
},
"import": {
"resultSuccess": "{imported} intakes imported",
@@ -2679,7 +2746,20 @@
"ENV_PRESSURE_DELTA": "intraday pressure swing",
"ENV_PM25": "fine particles (PM2.5)",
"ENV_OZONE_8H": "ozone",
"ENV_POLLEN_MAX": "pollen"
"ENV_POLLEN_MAX": "pollen",
"HRV_RMSSD": "heart-rate variability (RMSSD)",
"HYDRATION": "water intake",
"CAFFEINE": "caffeine intake",
"ALCOHOL": "alcohol use",
"WORKOUT_MINUTES": "workout minutes",
"TRAINING_LOAD": "training load",
"EXERCISE_MINUTES": "exercise minutes",
"STAND_MINUTES": "standing minutes",
"MINDFUL_MINUTES": "mindful minutes",
"STATE_OF_MIND_VALENCE": "logged state of mind",
"INDOOR_CO2": "indoor CO2",
"INDOOR_TEMPERATURE": "room temperature",
"INDOOR_HUMIDITY": "room humidity"
},
"daily": {
"high": "Higher {behaviour} tends to go with {direction} next-day {outcome} in your data. It is a pattern to watch, not a cause.",
@@ -2707,6 +2787,12 @@
"high": "Higher {behaviour} on the day and the day before tends to go with {direction} {outcome} in your data. It is a pattern to watch, not a cause.",
"moderate": "Higher {behaviour} on the day and the day before looks like it goes with {direction} {outcome} in your data, on the evidence so far. It is a pattern to watch, not a cause.",
"faint": "Higher {behaviour} on the day and the day before shows a faint hint, if anything, of {direction} {outcome} in your data. It is too small to lean on and never a cause."
},
"channelNutrient": "{nutrient} intake",
"dailyOvernight": {
"high": "Higher {behaviour} overnight tends to go with {direction} {outcome} that night in your data. It is a pattern to watch, not a cause.",
"moderate": "Higher {behaviour} overnight looks like it goes with {direction} {outcome} that night in your data, on the evidence so far. It is a pattern to watch, not a cause.",
"faint": "Higher {behaviour} overnight shows at most a faint hint of {direction} {outcome} that night in your data. It is too small to lean on and never a cause."
}
},
"noAnalysisYet": "No assessment yet.",
@@ -3264,11 +3350,12 @@
"provider": "Answers with",
"modelManaged": "The model for this provider is set in AI settings.",
"noProvider": "No provider available",
"allSettings": "All AI settings",
"operatorCodex": "ChatGPT (shared by the operator)",
"changeModel": "Change in Settings > AI",
"saving": "Saving…",
"saveFailed": "That change could not be saved.",
"allSettings": "All AI settings",
"providerNotActive": "{provider} could not take over: it has no working sign-in or key. Set it up in AI settings.",
"operatorCodex": "ChatGPT (shared by the operator)",
"operatorManaged": "Your server's operator manages this connection. Once one of your own providers is set up, it answers first."
},
"answer": {
@@ -3565,6 +3652,7 @@
"falls": "falls",
"years": "years",
"breathingEvents": "events",
"standHours": "hours",
"respiratoryRate": "breaths/min"
},
"sleep": {
@@ -3807,6 +3895,16 @@
"editTitle": "Edit workout",
"editAction": "Edit",
"updated": "Workout updated"
},
"load": {
"title": "Training load",
"fitness": "Fitness",
"fatigue": "Fatigue",
"form": "Form",
"explainer": "Fitness is your heart-rate training impulse averaged over about six weeks, fatigue over about one week, and form is the difference. The numbers compare with your own past, not with other people or other apps.",
"warmingUp": "Based on {days} days so far: until six weeks are covered, fitness reads lower than it is.",
"coverage": "{rated} of the last {days} days could be rated. A day with no activity data, or with a workout that could not be scored, is left out rather than counted as rest.",
"asOf": "As of {date}, the last day that could be rated."
}
},
"bloodGlucose": {
@@ -4248,7 +4346,12 @@
"outcomeDown": "Higher mood tends to go with lower next-day {factor} in your data.",
"stat": "{n} paired days, r = {r}, q = {q}",
"statLabel": "How this was computed",
"explainerLabel": "How this was computed"
"explainerLabel": "How this was computed",
"channel": {
"ALCOHOL": "alcohol use",
"WORKOUT_MINUTES": "workout minutes",
"TRAINING_LOAD": "training load"
}
},
"inTargetTitle": "In your range",
"dimensions": {
@@ -4709,7 +4812,8 @@
"shiftDown": "{vital} has shifted down from your recent normal.",
"rangeContext": "Now {value}, usually {low}–{high}.",
"rangeContextTimeOfDay": "Now {value}, usually {low}–{high} at this time of day.",
"shiftContext": "Around {before} before, {after} since."
"shiftContext": "Around {before} before, {after} since.",
"shiftDays": "Based on {before} days before and {after} since."
},
"breathingScreening": {
"sectionTitle": "Breathing during sleep",
@@ -5289,12 +5393,17 @@
"groups": {
"account": "Account",
"yourData": "Your data",
"display": "Display",
"healthProfile": "Health profile",
"ai": "AI",
"connectivity": "Connectivity",
"system": "System"
}
"system": "System",
"tracking": "Tracking",
"arrangement": "Arrangement",
"notifications": "Notifications",
"connections": "Connections",
"data": "Data"
},
"allSections": "All sections"
},
"sharedRecord": {
"managedSettings": {
@@ -5406,8 +5515,15 @@
"unavailableTitle": "Unavailable in this shared record",
"unavailableDescription": "This Settings area is intentionally unavailable while viewing another person's record.",
"integrationStatusDescription": "Connection status for this managed record.",
"integrationStatusDetail": "A managed profile shows its connected services as a status only. Connecting, reconnecting, syncing and disconnecting are not available to guardians.",
"integrationStatusDocs": "How sharing works"
"integrationStatusDetail": "Services you connect here send their data to {name}'s record, not to yours. Without a provider app of its own, the profile uses yours.",
"integrationStatusDocs": "How sharing works",
"integrationConnectsFor": "Connects for {name}",
"integrationConnectFor": "Connect for {name}",
"integrationDisconnect": "Disconnect",
"integrationDisconnectTitle": "Disconnect {provider} from {name}?",
"integrationDisconnectBody": "New data stops arriving. Everything already imported stays in {name}'s record.",
"integrationNoClientApp": "Neither {name} nor your own account has a {provider} app yet. Set one up under your own integrations first.",
"integrationConnectFailed": "Could not start connecting {provider}."
},
"sections": {
"account": {
@@ -5428,11 +5544,25 @@
"remindersHeading": "Reminders",
"matrixLink": "Per-channel notifications",
"channelsLink": "Delivery channels",
"displayHeading": "On your phone"
"displayHeading": "On your phone",
"elsewhere": {
"title": "Set where they belong",
"description": "These reminders are set next to what they are about.",
"doses": {
"title": "Medication doses",
"description": "Each medication's dose times and reminders are set on the medication.",
"link": "Open medications"
},
"cycle": {
"title": "Cycle",
"description": "Period and fertile-window reminders are switched per channel in the notification matrix.",
"link": "Open the notification matrix"
}
}
},
"layout": {
"title": "Layout",
"backToHub": "Back to Layout",
"title": "Arrangement",
"backToHub": "Back to Arrangement",
"dashboard": {
"title": "Dashboard",
"description": "Tile layout and order."
@@ -5441,7 +5571,7 @@
"title": "Insights",
"description": "Overview sections and navigation-pill order."
},
"subtitle": "Choose what your dashboard, insights and tracking modules show, and in which order.",
"subtitle": "Choose the order and view of your dashboard, insights and tracking lists.",
"medications": {
"title": "Medications",
"description": "List view and manual order."
@@ -5458,10 +5588,6 @@
"title": "Checkups",
"description": "List view and reminder order."
},
"mood": {
"title": "Mood tags",
"description": "Groups, tags, and archived tags for the mood picker."
},
"documents": {
"title": "Documents",
"description": "View and month arrangement."
@@ -5496,7 +5622,7 @@
"deviceTypeToggle": "Device-type priority ({count})",
"deviceTypeHelp": "Within a source, which device wins. When an Apple Watch and an iPhone both record steps, the higher-ranked device-type counts as the canonical reading.",
"deviceTypeDefault": "Default device-type ladder",
"resetDeviceTypes": "Reset device-types",
"resetDeviceTypes": "Reset device types",
"metrics": {
"steps": "Steps",
"activeEnergy": "Active energy",
@@ -5852,7 +5978,28 @@
},
"manageIn": "Manage in {section}",
"operatorDisabled": "Disabled server-wide",
"subtitle": "Choose which tracking areas HealthLog shows across the app."
"subtitle": "Choose which tracking areas HealthLog shows across the app.",
"backToHub": "Back to Modules",
"openModuleSettings": "Module settings",
"pages": {
"medications": {
"title": "Medications",
"description": "Injection sites the rotation leaves out."
},
"mood": {
"title": "Mood tags",
"description": "Groups, your own tags and archived tags."
},
"labs": {
"title": "Labs",
"description": "Biomarkers and on-device text recognition for lab reports."
},
"arrangementTitle": "Order and view",
"arrangementDescription": "How this list is sorted and shown lives under Arrangement.",
"openArrangement": "Open in Arrangement",
"moduleSettingsTitle": "Module settings",
"moduleSettingsDescription": "Everything beyond order and view lives on the module's page under Modules."
}
},
"gesundheitsakte": {
"title": "Health record",
@@ -6499,7 +6646,8 @@
"nocode": "Withings did not return an authorization code. Try again and allow access in the Withings dialog.",
"nocreds": "No Withings API credentials are stored. Save the client ID and client secret first, then connect again.",
"token": "The token exchange with Withings failed. Check the client ID and client secret and try again.",
"generic": "Unknown error while connecting. Please start the connection again."
"generic": "Unknown error while connecting. Please start the connection again.",
"already_linked": "This Withings account is already connected to another HealthLog profile. Sign in to Withings with the account that belongs to this profile, or disconnect it from the other profile first."
},
"withingsSynchronize": "Synchronize",
"withingsDisconnect": "Disconnect",
@@ -6538,7 +6686,8 @@
"nocode": "WHOOP did not return an authorization code. Try again and allow access in the WHOOP dialog.",
"nocreds": "WHOOP is not configured. Add your WHOOP OAuth credentials above and try again.",
"token": "The token exchange with WHOOP failed. Please start the connection again.",
"generic": "Unknown error while connecting. Please start the connection again."
"generic": "Unknown error while connecting. Please start the connection again.",
"already_linked": "This WHOOP account is already connected to another HealthLog profile. Sign in to WHOOP with the account that belongs to this profile, or disconnect it from the other profile first."
},
"fitbit": "Fitbit",
"fitbitTag": "Fitbit & Pixel",
@@ -6617,7 +6766,8 @@
"nocode": "Google did not return an authorization code. Try again and allow access in the Google dialog.",
"nocreds": "Google Health is not configured. Add your Google OAuth credentials above and try again.",
"token": "The token exchange with Google failed. Please start the connection again.",
"generic": "Unknown error while connecting. Please start the connection again."
"generic": "Unknown error while connecting. Please start the connection again.",
"already_linked": "This Google account is already connected to another HealthLog profile. Sign in to Google with the account that belongs to this profile, or disconnect it from the other profile first."
},
"fitbitOauthError": {
"csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.",
@@ -6628,7 +6778,8 @@
"nocreds": "Fitbit is not configured. Add your Fitbit OAuth credentials above and try again.",
"token": "The token exchange with Fitbit failed. Please start the connection again.",
"generic": "Unknown error while connecting. Please start the connection again.",
"retired": "Google has turned the Fitbit Web API off, so no new Fitbit connection can be made. Connect Google Health instead, which reads the same devices."
"retired": "Google has turned the Fitbit Web API off, so no new Fitbit connection can be made. Connect Google Health instead, which reads the same devices.",
"already_linked": "This Fitbit account is already connected to another HealthLog profile. Sign in to Fitbit with the account that belongs to this profile, or disconnect it from the other profile first."
},
"integrations": {
"withings": {
@@ -6665,7 +6816,9 @@
"items": {
"vo2max": "VO2 max",
"cardio_age": "cardiovascular age",
"resilience": "resilience"
"resilience": "resilience",
"workout": "workouts",
"heartrate": "heart rate"
}
}
}
@@ -6972,7 +7125,8 @@
"nocreds": "Polar is not configured on this server. Ask your administrator to add the Polar OAuth credentials.",
"token": "The token exchange with Polar failed. Please start the connection again.",
"rate_limited": "Too many connection attempts. Wait a moment and try again.",
"generic": "Unknown error while connecting. Please start the connection again."
"generic": "Unknown error while connecting. Please start the connection again.",
"already_linked": "This Polar account is already connected to another HealthLog profile. Sign in to Polar with the account that belongs to this profile, or disconnect it from the other profile first."
},
"oura": "Oura",
"ouraDescription": "Connect your Oura account to sync readiness, sleep stages, heart-rate variability, and daily activity.",
@@ -7002,25 +7156,24 @@
"token": "The token exchange with Oura failed. Please start the connection again.",
"rate_limited": "Too many connection attempts. Wait a moment and try again.",
"issuer": "The sign-in answer did not come from Oura's own authorization server, so the connection was stopped for your safety. Please start the connection again. If this keeps happening, tell your administrator.",
"generic": "Unknown error while connecting. Please start the connection again."
"generic": "Unknown error while connecting. Please start the connection again.",
"already_linked": "This Oura account is already connected to another HealthLog profile. Sign in to Oura with the account that belongs to this profile, or disconnect it from the other profile first."
},
"integrationSetupGuide": "Setup guide",
"coach": {
"activate": {
"title": "Activate Coach",
"description": "Show the Coach button and drawer.",
"toggleAria": "Activate the Coach button and drawer",
"savedOn": "Coach activated.",
"savedOff": "Coach hidden.",
"saveError": "Could not save the change. Please try again."
},
"memory": {
"edit": "Edit",
"source": {
"user": "from you",
"coach": "from the Coach"
}
}
},
"state": {
"title": "Coach",
"description": "Whether the Coach button and drawer show. The switch is in Modules."
},
"nudgesMoved": "Set with your other reminders under Notifications.",
"openNudges": "Open in Notifications"
},
"integrationRedirectGuide": {
"intro": "This connects through your own {provider} developer app, about 5 minutes to set up:",
@@ -7155,6 +7308,12 @@
"profile": "Your profile, medical history and health-record details",
"modules": "Records of each module: {list}"
},
"workoutRoutes": {
"title": "Workout routes",
"description": "Keep the GPS track of new workouts from the iPhone app and the Apple Health export.",
"detail": "Off for new accounts. A route shows where you live, work and train, so it is only kept once you turn this on. Workouts are kept either way, and no AI feature ever reads a route. Turning it off stops new routes; routes already kept stay until you delete the workout.",
"loadError": "The route setting could not be loaded."
},
"retention": {
"title": "Retention",
"description": "How long time-bound records are kept before automatic cleanup.",
@@ -7163,12 +7322,6 @@
"delivery": "Notification delivery log: kept {days} days",
"unavailable": "Retention details are unavailable right now."
},
"export": {
"title": "Download my data",
"description": "Your records as CSV or JSON, or a complete health record for your doctor.",
"openExport": "Export & backup",
"openHealthRecord": "Health record"
},
"delete": {
"title": "Delete or reset",
"description": "Remove your account entirely, or wipe your health records while keeping the account.",
@@ -7179,12 +7332,16 @@
"title": "Privacy posture",
"description": "Control how much of your data is shared with AI features.",
"openAi": "AI privacy mode"
},
"sessions": {
"title": "Sessions and devices",
"description": "Where you are signed in, the devices that skip the second factor, and the security activity of your account.",
"open": "Open security"
}
},
"mcp": {
"enableTitle": "Remote MCP endpoint",
"enableDescription": "Let an MCP-capable assistant (Claude.ai, ChatGPT) read your health data over a scoped token.",
"enableToggleLabel": "Enable the MCP endpoint",
"endpointLabel": "Connector URL",
"connectDescription": "Paste this URL into your assistant's custom-connector setup, then authorize with the token below or sign in when prompted.",
"tokensTitle": "Connector tokens",
@@ -7203,7 +7360,8 @@
"connectionRevokeFailed": "Could not disconnect the assistant.",
"connectionsDetail": "Revoking a connection ends its access immediately, including its ability to refresh.",
"tokensDetail": "The token is shown once.",
"tokenNamePlaceholder": "Name, e.g. Claude Desktop"
"tokenNamePlaceholder": "Name, e.g. Claude Desktop",
"offKeepsTokens": "Switching it off pauses the endpoint. Your tokens and connections stay and work again once it is back on."
},
"strava": "Strava",
"stravaDescription": "Connect your Strava account to sync your activities into your workout history.",
@@ -7232,7 +7390,8 @@
"nocreds": "Strava is not configured on this server. Ask your administrator to add the Strava API credentials.",
"token": "The token exchange with Strava failed. Please start the connection again.",
"rate_limited": "Too many connection attempts. Wait a moment and try again.",
"generic": "Unknown error while connecting. Please start the connection again."
"generic": "Unknown error while connecting. Please start the connection again.",
"already_linked": "This Strava account is already connected to another HealthLog profile. Sign in to Strava with the account that belongs to this profile, or disconnect it from the other profile first."
},
"appleHealth": {
"title": "Apple Health",
@@ -7465,7 +7624,14 @@
"passkeyAddedToast": "Passkey added.",
"passkeyRemovedToast": "Passkey removed.",
"integrationDisconnected": "{provider} disconnected.",
"integrationDisconnectFailed": "Could not disconnect {provider}."
"integrationDisconnectFailed": "Could not disconnect {provider}.",
"timezoneSaved": "Timezone saved.",
"moduleState": {
"on": "On",
"off": "Off",
"change": "Change in Modules"
},
"timezoneUseDevice": "Use this device's timezone ({zone})"
},
"admin": {
"title": "Administration",
@@ -7600,6 +7766,7 @@
"heapLimitLabel": "Heap limit",
"heapLimitValue": "{mb} MB",
"heapLimitLow": "{mb} MB, low. Set APP_MEMORY_LIMIT=1536m in .env",
"memoryBelowFloor": "Container {mb} MB, below the supported floor of {floor} MB. Set APP_MEMORY_LIMIT={floor}m in .env",
"offlineGeoOff": "Off",
"offlineGeoChosen": "Online ({host})",
"apiGlobal": "Allow API endpoints and API tokens.",
@@ -8855,6 +9022,10 @@
"detailedToast": "Notifications now include health figures.",
"genericToast": "Notifications no longer include health figures.",
"saveError": "Could not save. Please try again later."
},
"checkupReminders": {
"title": "Checkup reminders",
"description": "Switch individual checkup reminders on or off."
}
},
"i18n": {
@@ -9631,8 +9802,6 @@
"description": "Enter the reading; the reminder is marked done."
},
"manage": {
"title": "Manage reminders",
"description": "Enable or disable individual reminders.",
"empty": "No reminders to customize yet."
},
"dashboard": {
@@ -10016,6 +10185,27 @@
"doneToastFew": "Merged into {name}, {count} readings moved",
"doneToastOther": "Merged into {name}, {count} readings moved",
"error": "The biomarkers could not be merged. Try again."
},
"trend": {
"title": "Trend",
"slope": {
"rising": "Rising by about {amount} {unit} a year across {n} readings since {since}.",
"falling": "Falling by about {amount} {unit} a year across {n} readings since {since}.",
"flat": "No change across {n} readings since {since}."
},
"projection": {
"exits": {
"high": "If this straight line continued, it would reach the upper end of your reference range around {date}. That is a projection, not a forecast.",
"low": "If this straight line continued, it would reach the lower end of your reference range around {date}. That is a projection, not a forecast."
},
"beyond_horizon": "At this pace the line stays inside your reference range for more than {years} years, the span your readings cover.",
"beyond_horizon_capped": "At this pace the line stays inside your reference range for more than {years} years, as far ahead as the projection looks.",
"no_exit": "The line heads away from the edges of your reference range.",
"already_outside": "The line already sits outside your reference range.",
"no_clear_trend": "Across these readings the change is not clearly different from no change, so no projection is drawn.",
"stale": "The newest reading is from {date}, more than a year ago. The line is shown as of then, without a projection."
},
"interval": "95 % interval of the yearly change: {lo} to {hi} {unit}."
}
},
"modules": {
@@ -10957,7 +11147,16 @@
"openDetail": "Show details",
"historyLoadError": "Your assessment history could not be loaded",
"historyLoadErrorHint": "This does not mean you have no results; the list could not be fetched. Try again.",
"privacyNote": "Your answers are stored encrypted and never reach the AI Coach."
"privacyNote": "Your answers are stored encrypted and never reach the AI Coach.",
"trend": {
"sincePrevious": "Since your previous check-in on {date}: {change} points.",
"sinceFirst": "Since your first check-in on {date}: {change} points.",
"verdict": {
"improved": "That reaches the {threshold}-point change studies treat as meaningful, in the better direction.",
"worsened": "That reaches the {threshold}-point change studies treat as meaningful, in the less good direction.",
"within_threshold": "That is smaller than the {threshold}-point change studies treat as meaningful."
}
}
},
"records": {
"aiInclusion": {
@@ -12216,7 +12415,10 @@
"leafVisits": "Visits",
"leafSurgicalHistory": "Surgical history",
"leafImmunizations": "Immunizations",
"leafEmergency": "Emergency information (blood type, contacts, directives)"
"leafEmergency": "Emergency information (blood type, contacts, directives)",
"leafMedicationEffect": "Change around each medication start (needs the medication list)",
"leafWorkouts": "Workouts (summary per sport)",
"leafHrvSdnn": "HRV (SDNN)"
},
"recordSharing": {
"banner": {
@@ -13396,5 +13598,218 @@
"clinicalUpper": "Clinical upper limit",
"clinicalLower": "Clinical lower limit"
}
},
"activityCalendar": {
"title": "Activity calendar",
"periodLabel": "Calendar span",
"period": {
"week": "Week",
"month": "Month"
},
"weekRange": "{start} to {end}",
"previous": "Previous period",
"next": "Next period",
"metricLabel": "Colour the days by",
"metric": {
"steps": "Steps",
"activeMinutes": "Active minutes",
"load": "Training load"
},
"empty": "No steps, active minutes or workouts in this period yet.",
"loadError": "The activity calendar could not be loaded.",
"weekGoalsColumn": "Weekly goals",
"weekGoalsMet": "All weekly goals met",
"weekGoalsNotMet": "Not all weekly goals met",
"cell": {
"steps": "{value} steps",
"activeMinutes": "{value} active minutes",
"workouts": "{value} workouts: {sports}"
},
"level": {
"none": "No data",
"unrated": "Not enough history yet",
"low": "Far below your usual",
"below": "Below your usual",
"usual": "About your usual",
"above": "Above your usual",
"high": "Far above your usual",
"in_progress": "Today, still in progress"
},
"legend": "Compared with your 12 weeks before:",
"noBaseline": "Your usual range shows after 14 days with data.",
"summary": {
"steps": "{value} steps",
"activeMinutes": "{value} active minutes",
"workouts": "{value} workouts",
"load": "Average training load {value}",
"usual": "Your usual by this point: {value}",
"noUsual": "Not enough earlier weeks or months to compare with yet.",
"usualThroughYesterday": "Through yesterday {value}, your usual by then {usual}",
"firstDay": "Compared with your usual once the first day is over."
},
"sportCount": "{sport}: {count}",
"goal": {
"workoutsPerWeek": "Workouts per week",
"activeMinutesPerWeek": "Active minutes per week",
"stepsPerWeek": "Steps per week",
"stepsPerMonth": "Steps per month"
},
"goalOf": "{value} of {target}",
"setGoals": "Set goals",
"editGoals": "Change goals",
"goalsSheet": {
"title": "Activity goals",
"description": "Leave a field empty for no goal.",
"none": "No goal",
"invalid": "A whole number from {min} to {max}.",
"saveError": "The goals could not be saved."
},
"crossLink": {
"title": "Activity calendar",
"body": "Your steps as a week or a month, against your own usual.",
"cta": "Open calendar"
}
},
"sourceProvenance": {
"title": "Where values come from",
"description": "Which source delivered a metric each day, and which one your source priority uses.",
"metric": "Metric",
"day": "Day",
"dayTitle": "Sources on this day",
"loadError": "The sources could not be loaded.",
"dayEmpty": "No readings of this metric on this day.",
"reason": {
"only_source": "{source} is the only source on this day.",
"highest_on_ladder": "{source} is used: it stands highest in your source priority among this day's sources.",
"name_tiebreak": "{source} is used: none of this day's sources is in your source priority, so the name decided.",
"not_ranked": "This metric has no source priority: every source's readings count."
},
"pass": {
"lower_on_ladder": "Lower in your source priority.",
"not_on_ladder": "Not in your source priority for this metric.",
"name_tiebreak": "Same rank as the source used; the name decided."
},
"used": "Used",
"readings": "{count} readings, devices: {devices}",
"ladder": "Your order for this metric: {ladder}",
"utcNote": "The source is chosen per day from midnight to midnight UTC, the same way the charts choose it.",
"openSettings": "Change the order in Source priority",
"compareTitle": "Compare two sources",
"range": "Range",
"rangeDays": "{days} days",
"compareNeedsTwo": "Only one source has readings of this metric in this range.",
"side": {
"a": "First source",
"b": "Second source"
},
"daysBoth": "Days with both",
"meanOf": "Average from {source}",
"meanAbsDiff": "Average difference",
"pickedDays": "Days used (first / second)",
"pickedSplit": "{a} / {b}",
"compareNote": "Averages cover only the days both sources have, so they compare like with like. The value in bold is the one the charts use.",
"settingsHint": "To see what this order decides day by day:",
"settingsHintLink": "Where values come from",
"metricLink": "Where these values come from"
},
"recordImport": {
"title": "Lab results and medications from a spreadsheet",
"description": "Bring years of lab results or a medication list in from a CSV file.",
"detail": "You choose which column holds what, HealthLog checks every row first and imports nothing until you confirm. Rows already in your record are skipped.",
"kind": {
"labs": "Lab results",
"medications": "Medications"
},
"sheetTitle": {
"labs": "Import lab results",
"medications": "Import medications"
},
"sheetDescription": {
"labs": "Markers are matched to yours, units converted where the conversion is known.",
"medications": "Name, dose, daily times, as needed, start and end. Other rhythms you set up in the app afterwards."
},
"file": "CSV file",
"template": "Download template",
"mapping": "Which column holds what",
"required": "(required)",
"notInFile": "Not in the file",
"column": "Column {n}",
"delimiter": "Separator",
"delimiterOption": {
"comma": "Comma",
"semicolon": "Semicolon",
"tab": "Tab"
},
"decimal": "Decimal mark",
"decimalOption": {
"dot": "Dot (5.4)",
"comma": "Comma (5,4)"
},
"dateOrder": "Date format",
"dateOrderOption": {
"iso": "2025-03-01",
"dmy": "01.03.2025 (day first)",
"mdy": "03/01/2025 (month first)"
},
"header": "The first row holds column names",
"reminders": "Turn reminders on for imported medications",
"timezoneNote": "Times without a time zone are read in your account's time zone. A date without a time is kept as that calendar date.",
"missing": "Still to choose: {fields}",
"check": "Check file",
"runIdle": "Import",
"run": "Import {count} rows",
"close": "Close",
"summaryCheck": "{rows} rows read: {inserted} can be imported, {duplicates} already in your record, {failed} with a problem.",
"summaryDone": "Imported {inserted} rows. Skipped {duplicates} already in your record, {failed} with a problem.",
"markersMatched": "Matched to your markers:",
"markersCreated": "New markers:",
"line": "Line {line}",
"errorsTruncated": "Only the first 500 problems are listed.",
"error": {
"tooLarge": "The file is larger than 16 MB.",
"rateLimited": "Too many imports in the last hour. Try again later.",
"invalid": "The file or the column choice could not be read.",
"failed": "The import did not go through. Please try again."
},
"reason": {
"missing_value": "A required cell is empty",
"invalid_number": "Not a number",
"ambiguous_number": "The number reads two ways; check the decimal mark",
"missing_unit": "A number needs a unit",
"invalid_date": "Not a date in the chosen order",
"invalid_time": "Not a time of day",
"ambiguous_date": "The date reads two ways; use a four-digit year and the chosen order",
"future_date": "The date lies in the future",
"text_too_long": "The text is too long",
"unit_mismatch": "This unit does not convert into the marker's unit",
"invalid_reference_range": "The reference range cannot be read",
"invalid_times": "The times cannot be read (use 08:00 20:00)",
"missing_times": "Add intake times, or mark the medication as needed",
"invalid_yes_no": "Use yes or no",
"as_needed_with_times": "An as-needed medication has no fixed times",
"end_before_start": "The end lies before the start",
"invalid_medication": "The medication cannot be saved like this",
"oversized_row": "The row is too long or too wide",
"row_limit": "Past the row limit for one import",
"duplicate": "Already in your record, skipped"
},
"field": {
"analyte": "Marker",
"value": "Result",
"unit": "Unit",
"date": "Date",
"time": "Time",
"referenceRange": "Reference range",
"referenceLow": "Reference low",
"referenceHigh": "Reference high",
"panel": "Panel",
"note": "Note",
"name": "Name",
"dose": "Dose",
"times": "Times",
"asNeeded": "As needed",
"startsOn": "Start",
"endsOn": "End"
}
}
}
+466 -51
View File
@@ -401,7 +401,41 @@
"lifeSittingValue": "{hours} horas, declarado",
"lifeWork": "Trabajo",
"lifeNightWork": "trabajo nocturno",
"lifeExposures": "exposiciones: {list}"
"lifeExposures": "exposiciones: {list}",
"workouts": {
"title": "Entrenamientos",
"summary": "{sessions} sesiones, {hours} h en total; de media {perWeek} sesiones y {minutesPerWeek} minutos por semana.",
"colSport": "Deporte",
"colSessions": "Sesiones",
"colTime": "Tiempo",
"colDistance": "Distancia",
"colEnergy": "Energía",
"colHeartRate": "FC media",
"moreSports": "{count} deportes más se cuentan en los totales, pero no se enumeran."
},
"medicationEffect": {
"title": "Cambio en torno al inicio de cada medicamento",
"colMedication": "Medicamento / medida",
"colStart": "Inicio",
"colLevel": "Cambio de nivel (IC 95 %)",
"colSlope": "Cambio de tendencia (IC 95 %)",
"colDays": "Días antes / después",
"to": "a",
"week": "semana",
"reasonNoStart": "Sin fecha de inicio registrada",
"reasonBefore": "Muy pocos valores antes del inicio",
"reasonAfter": "Muy pocos valores desde el inicio",
"reasonFit": "No se pudo calcular",
"excludedAdherence": "{count} días después del inicio omitidos (menos del {floor} % de las dosis tomadas)",
"excludedNotTaking": "{count} días omitidos (en pausa o entre tandas)",
"notTracked": "toma no registrada, así que no se omitió ningún día por dosis olvidadas",
"endDoseChange": "el periodo termina el {date}, antes del primer cambio de dosis",
"days": "{before} días antes, {after} días después del inicio, {excluded} días omitidos",
"method": "Regresión segmentada sobre los valores diarios desde 90 días antes hasta como máximo 180 días después del inicio, con intervalos de Newey-West. Se omiten los días posteriores al inicio en los que se tomó menos del 80 % de las dosis de los 14 días anteriores. Describe una asociación en el tiempo, no un efecto demostrado del medicamento.",
"autocorrelation": "fuerte dependencia del día anterior, así que los rangos pueden seguir siendo demasiado estrechos",
"window": "analizado del {from} al {to}, puede ir más allá del periodo del informe",
"windowLine": "Analizado del {from} al {to}. Este intervalo puede ir más allá del periodo del informe."
}
},
"errorBoundary": {
"title": "Algo ha salido mal",
@@ -911,6 +945,15 @@
"unitRating": "nivel",
"typeWho5Score": "Puntuación WHO-5",
"typeSciScore": "Puntuación SCI",
"typeExerciseMinutes": "Minutos de ejercicio",
"typeStandMinutes": "Minutos de pie",
"typeStandHours": "Horas de pie",
"typeMindfulMinutes": "Minutos de atención plena",
"typeStateOfMindValence": "Estado de ánimo",
"typeAfibBurden": "Carga de FA",
"typeIndoorCo2": "CO₂ interior",
"typeIndoorTemperature": "Temperatura interior",
"typeIndoorHumidity": "Humedad interior",
"durationReadAs": "Se lee como {duration}",
"durationHoursMinutes": "{hours} h {minutes} min",
"durationHoursOnly": "{hours} h",
@@ -2350,7 +2393,31 @@
},
"scaleAbsolute": "Absoluto",
"scalePercentSinceStart": "% desde el inicio",
"scaleToggleLabel": "Escala del gráfico"
"scaleToggleLabel": "Escala del gráfico",
"segmented": {
"title": "Cambio al inicio",
"level": "Justo al inicio, el nivel se desplazó {value} {unit} (intervalo del 95 %: {low} a {high}).",
"slope": "Después del inicio, la tendencia semanal cambió {value} {unit} por semana (intervalo del 95 %: {low} a {high}).",
"preSlope": "Antes del inicio, la tendencia era de {value} {unit} por semana.",
"includesZero": "Cuando un intervalo incluye el cero, estos valores también son compatibles con ningún cambio.",
"basis": "Basado en {before} días antes y {after} días después del inicio ({from} a {to}).",
"excludedAdherence": "Se omitieron {count} días después del inicio porque en los {lookback} días anteriores se tomó menos del {floor} % de las dosis.",
"excludedNotTaking": "Se omitieron {count} días porque el medicamento estaba en pausa o entre dos tandas.",
"notTracked": "La toma de este medicamento no se registra, así que no se omitió ningún día por dosis olvidadas.",
"endDoseChange": "El periodo termina antes del primer cambio de dosis, así que describe la dosis inicial.",
"confoundersTitle": "Otros cambios de medicación en este periodo, que esta vista no puede separar de este:",
"confounderStarted": "{name} iniciado el {date}",
"confounderStopped": "{name} finalizado el {date}",
"confounderDoseChange": "{name}: dosis cambiada el {date}",
"confounderPaused": "{name} en pausa desde el {date}",
"confounderResumed": "{name} reanudado el {date}",
"method": "Cómo se calcula: se ajusta una recta a los valores diarios antes del inicio y otra a los posteriores. El salto entre ambas al inicio es el cambio de nivel y la diferencia de sus pendientes, el cambio de tendencia. Los intervalos tienen en cuenta que los valores de un día influyen en el siguiente.",
"needStart": "Indica una fecha de inicio para ver el cambio al inicio.",
"needBefore": "El cambio al inicio necesita valores de al menos {days} días repartidos en {span} días antes del inicio.",
"needAfter": "El cambio al inicio necesita valores de al menos {days} días repartidos en {span} días desde el inicio, en días con las dosis tomadas.",
"fitFailed": "No se pudieron ajustar estos valores, así que no se muestra el cambio al inicio.",
"autocorrelation": "Los valores diarios dependen mucho del día anterior, así que incluso estos rangos pueden ser demasiado estrechos."
}
},
"import": {
"resultSuccess": "{imported} tomas importadas",
@@ -2679,7 +2746,20 @@
"ENV_PRESSURE_DELTA": "la variación de presión del día",
"ENV_PM25": "las partículas finas (PM2,5)",
"ENV_OZONE_8H": "el ozono",
"ENV_POLLEN_MAX": "el polen"
"ENV_POLLEN_MAX": "el polen",
"HRV_RMSSD": "la variabilidad de la frecuencia cardiaca (RMSSD)",
"HYDRATION": "la ingesta de agua",
"CAFFEINE": "la ingesta de cafeína",
"ALCOHOL": "el consumo de alcohol",
"WORKOUT_MINUTES": "los minutos de entrenamiento",
"TRAINING_LOAD": "la carga de entrenamiento",
"EXERCISE_MINUTES": "los minutos de ejercicio",
"STAND_MINUTES": "los minutos de pie",
"MINDFUL_MINUTES": "los minutos de atención plena",
"STATE_OF_MIND_VALENCE": "el estado de ánimo registrado",
"INDOOR_CO2": "el CO2 interior",
"INDOOR_TEMPERATURE": "la temperatura interior",
"INDOOR_HUMIDITY": "la humedad interior"
},
"daily": {
"high": "Cuando {behaviour} es mayor, {outcome} del día siguiente tiende a {direction} en tus datos. Es un patrón que conviene observar, no una causa.",
@@ -2707,6 +2787,12 @@
"high": "Más {behaviour} el mismo día y el día anterior tiende a ir con {direction} {outcome} en tus datos: un patrón que conviene observar, no una causa.",
"moderate": "Más {behaviour} el mismo día y el día anterior parece ir con {direction} {outcome} en tus datos, según lo visto hasta ahora: un patrón que conviene observar, no una causa.",
"faint": "Más {behaviour} el mismo día y el día anterior muestra, como mucho, un leve indicio de {direction} {outcome} en tus datos: demasiado pequeño para fiarse, nunca una causa."
},
"channelNutrient": "la ingesta de {nutrient}",
"dailyOvernight": {
"high": "Más {behaviour} durante la noche tiende a ir con {direction} {outcome} esa misma noche en tus datos: un patrón que conviene observar, no una causa.",
"moderate": "Más {behaviour} durante la noche parece ir con {direction} {outcome} esa misma noche en tus datos, según lo visto hasta ahora: un patrón que conviene observar, no una causa.",
"faint": "Más {behaviour} durante la noche muestra, como mucho, un leve indicio de {direction} {outcome} esa misma noche en tus datos: demasiado pequeño para fiarse, nunca una causa."
}
},
"noAnalysisYet": "Aún no hay valoración.",
@@ -3264,11 +3350,12 @@
"provider": "Responde con",
"modelManaged": "El modelo de este proveedor se configura en los ajustes de IA.",
"noProvider": "Ningún proveedor disponible",
"allSettings": "Todos los ajustes de IA",
"operatorCodex": "ChatGPT (compartido por el operador)",
"changeModel": "Cambiar en Ajustes > IA",
"saving": "Guardando…",
"saveFailed": "No se pudo guardar el cambio.",
"allSettings": "Todos los ajustes de IA",
"providerNotActive": "{provider} no pudo encargarse: no tiene un inicio de sesión ni una clave que funcionen. Configúralo en los ajustes de IA.",
"operatorCodex": "ChatGPT (compartido por el operador)",
"operatorManaged": "El operador de tu servidor gestiona esta conexión. Cuando configures uno de tus propios proveedores, responderá primero."
},
"answer": {
@@ -3565,6 +3652,7 @@
"falls": "caídas",
"years": "años",
"breathingEvents": "eventos",
"standHours": "horas",
"respiratoryRate": "respiraciones/min"
},
"sleep": {
@@ -3807,6 +3895,16 @@
"editTitle": "Editar entrenamiento",
"editAction": "Editar",
"updated": "Entrenamiento actualizado"
},
"load": {
"title": "Carga de entrenamiento",
"fitness": "Forma de base",
"fatigue": "Fatiga",
"form": "Estado",
"explainer": "La forma de base es tu impulso de entrenamiento por frecuencia cardiaca promediado en unas seis semanas, la fatiga en una semana, y el estado es la diferencia. Las cifras se comparan con tu propio pasado, no con otras personas ni otras apps.",
"warmingUp": "Con {days} días hasta ahora: hasta cubrir seis semanas, la forma de base aparece más baja de lo que es.",
"coverage": "Se pudieron valorar {rated} de los últimos {days} días. Un día sin datos de actividad, o con un entrenamiento que no se pudo puntuar, se omite en lugar de contar como descanso.",
"asOf": "A fecha de {date}, el último día que se pudo valorar."
}
},
"bloodGlucose": {
@@ -4248,7 +4346,12 @@
"outcomeDown": "Mejor ánimo suele coincidir con un {factor} más bajo al día siguiente en tus datos.",
"stat": "{n} días emparejados, r = {r}, q = {q}",
"statLabel": "Cómo se calculó esto",
"explainerLabel": "Cómo se calculó esto"
"explainerLabel": "Cómo se calculó esto",
"channel": {
"ALCOHOL": "consumo de alcohol",
"WORKOUT_MINUTES": "minutos de entrenamiento",
"TRAINING_LOAD": "carga de entrenamiento"
}
},
"inTargetTitle": "En tu rango",
"dimensions": {
@@ -4709,7 +4812,8 @@
"shiftDown": "{vital} ha bajado respecto a tu normal reciente.",
"rangeContext": "Ahora {value}, normalmente {low}–{high}.",
"rangeContextTimeOfDay": "Ahora {value}, normalmente {low}–{high} a esta hora del día.",
"shiftContext": "Cerca de {before} antes, {after} desde entonces."
"shiftContext": "Cerca de {before} antes, {after} desde entonces.",
"shiftDays": "Con {before} días antes y {after} desde entonces."
},
"breathingScreening": {
"sectionTitle": "Respiración durante el sueño",
@@ -5289,12 +5393,17 @@
"groups": {
"account": "Cuenta",
"yourData": "Tus datos",
"display": "Visualización",
"healthProfile": "Perfil de salud",
"ai": "IA",
"connectivity": "Conectividad",
"system": "Sistema"
}
"system": "Sistema",
"tracking": "Seguimiento",
"arrangement": "Disposición",
"notifications": "Notificaciones",
"connections": "Conexiones",
"data": "Datos"
},
"allSections": "Todas las secciones"
},
"sharedRecord": {
"managedSettings": {
@@ -5406,8 +5515,15 @@
"unavailableTitle": "No disponible en este registro compartido",
"unavailableDescription": "Esta sección de Ajustes no está disponible intencionadamente mientras consultas el registro de otra persona.",
"integrationStatusDescription": "Estado de conexión de este registro gestionado.",
"integrationStatusDetail": "Un perfil gestionado muestra sus servicios conectados solo como estado. Conectar, volver a conectar, sincronizar y desconectar no está disponible para quienes lo gestionan.",
"integrationStatusDocs": "Cómo funciona compartir"
"integrationStatusDetail": "Los servicios que conectes aquí envían sus datos al registro de {name}, no al tuyo. Si el perfil no tiene una app propia del proveedor, usa la tuya.",
"integrationStatusDocs": "Cómo funciona compartir",
"integrationConnectsFor": "Conecta para {name}",
"integrationConnectFor": "Conectar para {name}",
"integrationDisconnect": "Desconectar",
"integrationDisconnectTitle": "¿Desconectar {provider} de {name}?",
"integrationDisconnectBody": "Dejarán de llegar datos nuevos. Todo lo ya importado se queda en el registro de {name}.",
"integrationNoClientApp": "Ni {name} ni tu propia cuenta tienen todavía una app de {provider}. Configura una primero en tus propias integraciones.",
"integrationConnectFailed": "No se pudo empezar a conectar {provider}."
},
"sections": {
"account": {
@@ -5428,11 +5544,25 @@
"remindersHeading": "Recordatorios",
"matrixLink": "Notificaciones por canal",
"channelsLink": "Canales de entrega",
"displayHeading": "En tu teléfono"
"displayHeading": "En tu teléfono",
"elsewhere": {
"title": "Se ajustan donde corresponden",
"description": "Estos recordatorios se ajustan junto a aquello a lo que se refieren.",
"doses": {
"title": "Tomas de medicación",
"description": "Las horas de toma y los recordatorios se ajustan en cada medicamento.",
"link": "Abrir medicamentos"
},
"cycle": {
"title": "Ciclo",
"description": "Los recordatorios del periodo y de la ventana fértil se activan por canal en la matriz de notificaciones.",
"link": "Abrir la matriz de notificaciones"
}
}
},
"layout": {
"title": "Diseño",
"backToHub": "Volver a Diseño",
"title": "Disposición",
"backToHub": "Volver a Disposición",
"dashboard": {
"title": "Panel",
"description": "Diseño y orden de los mosaicos."
@@ -5441,7 +5571,7 @@
"title": "Análisis",
"description": "Orden de la vista general y de las pestañas de navegación."
},
"subtitle": "Elige qué muestran tu panel, los análisis y los módulos de seguimiento, y en qué orden.",
"subtitle": "Elige el orden y la vista de tu panel, tus análisis y tus listas de seguimiento.",
"medications": {
"title": "Medicamentos",
"description": "Vista de lista y orden manual."
@@ -5458,10 +5588,6 @@
"title": "Revisiones",
"description": "Vista de lista y orden de recordatorios."
},
"mood": {
"title": "Etiquetas de ánimo",
"description": "Grupos, etiquetas y etiquetas archivadas del selector de ánimo."
},
"documents": {
"title": "Documentos",
"description": "Vista y disposición de los meses."
@@ -5852,7 +5978,28 @@
},
"manageIn": "Gestionar en {section}",
"operatorDisabled": "Desactivado en todo el servidor",
"subtitle": "Elige qué áreas de seguimiento muestra HealthLog en la app."
"subtitle": "Elige qué áreas de seguimiento muestra HealthLog en la app.",
"backToHub": "Volver a Módulos",
"openModuleSettings": "Ajustes del módulo",
"pages": {
"medications": {
"title": "Medicamentos",
"description": "Zonas de inyección que la rotación omite."
},
"mood": {
"title": "Etiquetas de ánimo",
"description": "Grupos, tus propias etiquetas y las archivadas."
},
"labs": {
"title": "Laboratorio",
"description": "Biomarcadores y reconocimiento de texto en el dispositivo para informes de laboratorio."
},
"arrangementTitle": "Orden y vista",
"arrangementDescription": "Cómo se ordena y muestra esta lista se ajusta en Disposición.",
"openArrangement": "Abrir en Disposición",
"moduleSettingsTitle": "Ajustes del módulo",
"moduleSettingsDescription": "Todo lo demás está en la página del módulo, en Módulos."
}
},
"gesundheitsakte": {
"title": "Historial médico",
@@ -6499,7 +6646,8 @@
"nocode": "Withings no devolvió ningún código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Withings.",
"nocreds": "No hay credenciales de la API de Withings guardadas. Guarda primero el Client ID y el Client Secret y vuelve a conectar.",
"token": "El intercambio de tokens con Withings falló. Comprueba el Client ID y el Client Secret e inténtalo de nuevo.",
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo.",
"already_linked": "Esta cuenta de Withings ya está conectada a otro perfil de HealthLog. Inicia sesión en Withings con la cuenta de este perfil o desconéctala primero del otro perfil."
},
"withingsSynchronize": "Sincronizar",
"withingsDisconnect": "Desconectar",
@@ -6538,7 +6686,8 @@
"nocode": "WHOOP no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de WHOOP.",
"nocreds": "WHOOP no está configurado. Añade tus credenciales OAuth de WHOOP arriba e inténtalo de nuevo.",
"token": "El intercambio de token con WHOOP falló. Inicia la conexión de nuevo.",
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo.",
"already_linked": "Esta cuenta de WHOOP ya está conectada a otro perfil de HealthLog. Inicia sesión en WHOOP con la cuenta de este perfil o desconéctala primero del otro perfil."
},
"fitbit": "Fitbit",
"fitbitTag": "Fitbit y Pixel",
@@ -6617,7 +6766,8 @@
"nocode": "Google no devolvió un código de autorización. Inténtalo de nuevo y concede el acceso en el diálogo de Google.",
"nocreds": "Google Health no está configurado. Añade arriba tus credenciales de OAuth de Google e inténtalo de nuevo.",
"token": "El intercambio de token con Google falló. Vuelve a iniciar la conexión.",
"generic": "Error desconocido al conectar. Vuelve a iniciar la conexión."
"generic": "Error desconocido al conectar. Vuelve a iniciar la conexión.",
"already_linked": "Esta cuenta de Google ya está conectada a otro perfil de HealthLog. Inicia sesión en Google con la cuenta de este perfil o desconéctala primero del otro perfil."
},
"fitbitOauthError": {
"csrf1": "La verificación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.",
@@ -6628,7 +6778,8 @@
"nocreds": "Fitbit no está configurado. Añade tus credenciales OAuth de Fitbit arriba e inténtalo de nuevo.",
"token": "El intercambio de token con Fitbit falló. Inicia la conexión de nuevo.",
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo.",
"retired": "Google desactivó la API web de Fitbit, así que ya no se puede crear una nueva conexión de Fitbit. Conecta Google Health, que lee los mismos dispositivos."
"retired": "Google desactivó la API web de Fitbit, así que ya no se puede crear una nueva conexión de Fitbit. Conecta Google Health, que lee los mismos dispositivos.",
"already_linked": "Esta cuenta de Fitbit ya está conectada a otro perfil de HealthLog. Inicia sesión en Fitbit con la cuenta de este perfil o desconéctala primero del otro perfil."
},
"integrations": {
"withings": {
@@ -6665,7 +6816,9 @@
"items": {
"vo2max": "VO2 máx.",
"cardio_age": "edad cardiovascular",
"resilience": "resiliencia"
"resilience": "resiliencia",
"workout": "entrenamientos",
"heartrate": "frecuencia cardiaca"
}
}
}
@@ -6972,7 +7125,8 @@
"nocreds": "Polar no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Polar.",
"token": "El intercambio de token con Polar falló. Inicia la conexión de nuevo.",
"rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.",
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo.",
"already_linked": "Esta cuenta de Polar ya está conectada a otro perfil de HealthLog. Inicia sesión en Polar con la cuenta de este perfil o desconéctala primero del otro perfil."
},
"oura": "Oura",
"ouraDescription": "Conecta tu cuenta de Oura para sincronizar disposición, fases de sueño, variabilidad cardíaca y actividad diaria.",
@@ -7002,25 +7156,24 @@
"token": "El intercambio de token con Oura falló. Inicia la conexión de nuevo.",
"rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.",
"issuer": "La respuesta de inicio de sesión no procedía del servidor de autorización de Oura, así que la conexión se detuvo por tu seguridad. Vuelve a iniciar la conexión. Si sigue ocurriendo, avisa a tu administrador.",
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo.",
"already_linked": "Esta cuenta de Oura ya está conectada a otro perfil de HealthLog. Inicia sesión en Oura con la cuenta de este perfil o desconéctala primero del otro perfil."
},
"integrationSetupGuide": "Guía de configuración",
"coach": {
"activate": {
"title": "Activar el Coach",
"description": "Muestra el botón y el panel del Coach.",
"toggleAria": "Activar el botón y el panel del Coach",
"savedOn": "Coach activado.",
"savedOff": "Coach oculto.",
"saveError": "No se pudo guardar el cambio. Inténtalo de nuevo."
},
"memory": {
"edit": "Editar",
"source": {
"user": "de ti",
"coach": "del Coach"
}
}
},
"state": {
"title": "Coach",
"description": "Si se muestran el botón y el panel del Coach. El interruptor está en Módulos."
},
"nudgesMoved": "Se ajustan con tus otros recordatorios en Notificaciones.",
"openNudges": "Abrir en Notificaciones"
},
"integrationRedirectGuide": {
"intro": "Esto se conecta mediante tu propia app de desarrollador de {provider}, con unos 5 minutos de configuración:",
@@ -7155,6 +7308,12 @@
"profile": "Tu perfil, tus antecedentes médicos y los datos del historial de salud",
"modules": "Registros de cada módulo: {list}"
},
"workoutRoutes": {
"title": "Rutas de entrenamiento",
"description": "Guardar el recorrido GPS de los entrenamientos nuevos de la app de iPhone y de la exportación de Apple Salud.",
"detail": "Desactivado en las cuentas nuevas. Una ruta muestra dónde vives, trabajas y entrenas, así que solo se guarda cuando lo activas. Los entrenamientos se guardan siempre y ninguna función de IA lee nunca una ruta. Al desactivarlo dejan de guardarse rutas nuevas; las ya guardadas se quedan hasta que borres el entrenamiento.",
"loadError": "No se pudo cargar el ajuste de rutas."
},
"retention": {
"title": "Retención",
"description": "Cuánto tiempo se conservan los registros con límite temporal antes de su limpieza automática.",
@@ -7163,12 +7322,6 @@
"delivery": "Registro de envío de notificaciones: {days} días",
"unavailable": "Los detalles de retención no están disponibles en este momento."
},
"export": {
"title": "Descargar mis datos",
"description": "Tus registros en CSV o JSON, o un historial completo para tu médico.",
"openExport": "Exportar y copia de seguridad",
"openHealthRecord": "Historial médico"
},
"delete": {
"title": "Eliminar o restablecer",
"description": "Elimina tu cuenta por completo o borra tus registros de salud conservando la cuenta.",
@@ -7179,12 +7332,16 @@
"title": "Configuración de privacidad",
"description": "Controla cuántos de tus datos se comparten con las funciones de IA.",
"openAi": "Modo de privacidad de IA"
},
"sessions": {
"title": "Sesiones y dispositivos",
"description": "Dónde tienes la sesión iniciada, qué dispositivos omiten el segundo factor y la actividad de seguridad de tu cuenta.",
"open": "Abrir seguridad"
}
},
"mcp": {
"enableTitle": "Endpoint MCP remoto",
"enableDescription": "Permite que un asistente compatible con MCP (Claude.ai, ChatGPT) lea tus datos de salud mediante un token limitado.",
"enableToggleLabel": "Activar el endpoint MCP",
"endpointLabel": "URL del conector",
"connectDescription": "Pega esta URL en la configuración del conector personalizado de tu asistente y autoriza con el token de abajo o inicia sesión cuando se te solicite.",
"tokensTitle": "Tokens del conector",
@@ -7203,7 +7360,8 @@
"connectionRevokeFailed": "No se pudo desconectar el asistente.",
"tokensDetail": "El token se muestra una sola vez.",
"connectionsDetail": "Revocar una conexión finaliza su acceso de inmediato, incluida su capacidad de renovación.",
"tokenNamePlaceholder": "Nombre, p. ej. Claude Desktop"
"tokenNamePlaceholder": "Nombre, p. ej. Claude Desktop",
"offKeepsTokens": "Desactivarlo pausa el punto de acceso. Tus tokens y conexiones se mantienen y vuelven a funcionar al activarlo."
},
"strava": "Strava",
"stravaDescription": "Conecta tu cuenta de Strava para sincronizar tus actividades con tu historial de entrenamientos.",
@@ -7232,7 +7390,8 @@
"nocreds": "Strava no está configurado en este servidor. Pide a tu administrador que añada las credenciales de API de Strava.",
"token": "El intercambio de tokens con Strava falló. Inicia la conexión de nuevo.",
"rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.",
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo.",
"already_linked": "Esta cuenta de Strava ya está conectada a otro perfil de HealthLog. Inicia sesión en Strava con la cuenta de este perfil o desconéctala primero del otro perfil."
},
"appleHealth": {
"title": "Apple Health",
@@ -7465,7 +7624,14 @@
"passkeyAddedToast": "Passkey añadida.",
"passkeyRemovedToast": "Passkey eliminada.",
"integrationDisconnected": "{provider} desconectado.",
"integrationDisconnectFailed": "No se pudo desconectar {provider}."
"integrationDisconnectFailed": "No se pudo desconectar {provider}.",
"timezoneSaved": "Zona horaria guardada.",
"moduleState": {
"on": "Activado",
"off": "Desactivado",
"change": "Cambiar en Módulos"
},
"timezoneUseDevice": "Usar la zona horaria de este dispositivo ({zone})"
},
"admin": {
"title": "Administration",
@@ -7600,6 +7766,7 @@
"heapLimitLabel": "Límite de heap",
"heapLimitValue": "{mb} MB",
"heapLimitLow": "{mb} MB, escaso: define APP_MEMORY_LIMIT=1536m en .env",
"memoryBelowFloor": "Contenedor {mb} MB, por debajo del mínimo admitido de {floor} MB: define APP_MEMORY_LIMIT={floor}m en .env",
"offlineGeoOff": "Desactivado",
"offlineGeoChosen": "En línea ({host})",
"apiGlobal": "Permitir endpoints de API y tokens de API.",
@@ -8855,6 +9022,10 @@
"detailedToast": "Las notificaciones ahora incluyen valores de salud.",
"genericToast": "Las notificaciones ya no incluyen valores de salud.",
"saveError": "No se pudo guardar. Inténtalo de nuevo más tarde."
},
"checkupReminders": {
"title": "Recordatorios de chequeos",
"description": "Activa o desactiva recordatorios de chequeos concretos."
}
},
"i18n": {
@@ -9631,8 +9802,6 @@
"description": "Introduce el valor; el recordatorio se marca como hecho."
},
"manage": {
"title": "Gestionar recordatorios",
"description": "Activa o desactiva recordatorios individuales.",
"empty": "Aún no hay recordatorios para personalizar."
},
"dashboard": {
@@ -10016,6 +10185,27 @@
"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."
},
"trend": {
"title": "Tendencia",
"slope": {
"rising": "Sube unos {amount} {unit} al año en {n} valores desde {since}.",
"falling": "Baja unos {amount} {unit} al año en {n} valores desde {since}.",
"flat": "Sin cambios en {n} valores desde {since}."
},
"projection": {
"exits": {
"high": "Si esta línea recta continuara, llegaría al límite superior de tu rango de referencia hacia el {date}. Es una proyección, no un pronóstico.",
"low": "Si esta línea recta continuara, llegaría al límite inferior de tu rango de referencia hacia el {date}. Es una proyección, no un pronóstico."
},
"beyond_horizon": "A este ritmo, la línea se mantiene dentro de tu rango de referencia más de {years} años, el periodo que cubren tus valores.",
"beyond_horizon_capped": "A este ritmo, la línea se mantiene dentro de tu rango de referencia más de {years} años, hasta donde llega la proyección.",
"no_exit": "La línea se aleja de los límites de tu rango de referencia.",
"already_outside": "La línea ya está fuera de tu rango de referencia.",
"no_clear_trend": "En estos valores, el cambio no se distingue con claridad de la ausencia de cambio, así que no se proyecta nada.",
"stale": "El valor más reciente es del {date}, hace más de un año. La línea se muestra a esa fecha, sin proyección."
},
"interval": "Intervalo del 95 % del cambio anual: de {lo} a {hi} {unit}."
}
},
"modules": {
@@ -10957,7 +11147,16 @@
"openDetail": "Ver detalles",
"historyLoadError": "No se pudo cargar tu historial de evaluaciones",
"historyLoadErrorHint": "Esto no significa que no tengas resultados: no se pudo obtener la lista. Vuelve a intentarlo.",
"privacyNote": "Tus respuestas se guardan cifradas y nunca llegan al Coach de IA."
"privacyNote": "Tus respuestas se guardan cifradas y nunca llegan al Coach de IA.",
"trend": {
"sincePrevious": "Desde tu revisión anterior del {date}: {change} puntos.",
"sinceFirst": "Desde tu primera revisión del {date}: {change} puntos.",
"verdict": {
"improved": "Alcanza el cambio de {threshold} puntos que los estudios consideran relevante, en la dirección favorable.",
"worsened": "Alcanza el cambio de {threshold} puntos que los estudios consideran relevante, en la dirección menos favorable.",
"within_threshold": "Es menor que el cambio de {threshold} puntos que los estudios consideran relevante."
}
}
},
"records": {
"aiInclusion": {
@@ -12216,7 +12415,10 @@
"leafVisits": "Visitas médicas",
"leafSurgicalHistory": "Antecedentes quirúrgicos",
"leafImmunizations": "Vacunas",
"leafEmergency": "Información de emergencia (grupo sanguíneo, contactos, voluntades)"
"leafEmergency": "Información de emergencia (grupo sanguíneo, contactos, voluntades)",
"leafMedicationEffect": "Cambio en torno al inicio de cada medicamento (requiere la lista de medicamentos)",
"leafWorkouts": "Entrenamientos (resumen por deporte)",
"leafHrvSdnn": "VFC (SDNN)"
},
"recordSharing": {
"banner": {
@@ -13396,5 +13598,218 @@
"clinicalUpper": "Límite clínico superior",
"clinicalLower": "Límite clínico inferior"
}
},
"activityCalendar": {
"title": "Calendario de actividad",
"periodLabel": "Periodo del calendario",
"period": {
"week": "Semana",
"month": "Mes"
},
"weekRange": "{start} a {end}",
"previous": "Periodo anterior",
"next": "Periodo siguiente",
"metricLabel": "Colorear los días por",
"metric": {
"steps": "Pasos",
"activeMinutes": "Minutos activos",
"load": "Carga de entrenamiento"
},
"empty": "Todavía no hay pasos, minutos activos ni entrenamientos en este periodo.",
"loadError": "No se pudo cargar el calendario de actividad.",
"weekGoalsColumn": "Objetivos semanales",
"weekGoalsMet": "Todos los objetivos semanales cumplidos",
"weekGoalsNotMet": "No se cumplieron todos los objetivos semanales",
"cell": {
"steps": "{value} pasos",
"activeMinutes": "{value} minutos activos",
"workouts": "{value} entrenamientos: {sports}"
},
"level": {
"none": "Sin datos",
"unrated": "Aún no hay suficiente historial",
"low": "Muy por debajo de lo habitual",
"below": "Por debajo de lo habitual",
"usual": "Como de costumbre",
"above": "Por encima de lo habitual",
"high": "Muy por encima de lo habitual",
"in_progress": "Hoy, aún en curso"
},
"legend": "Comparado con tus 12 semanas anteriores:",
"noBaseline": "Tu rango habitual aparece tras 14 días con datos.",
"summary": {
"steps": "{value} pasos",
"activeMinutes": "{value} minutos activos",
"workouts": "{value} entrenamientos",
"load": "Carga de entrenamiento media {value}",
"usual": "Lo habitual hasta este punto: {value}",
"noUsual": "Aún no hay suficientes semanas o meses anteriores para comparar.",
"usualThroughYesterday": "Hasta ayer {value}, lo habitual para ti hasta entonces {usual}",
"firstDay": "Se compara con lo habitual cuando termine el primer día."
},
"sportCount": "{sport}: {count}",
"goal": {
"workoutsPerWeek": "Entrenamientos por semana",
"activeMinutesPerWeek": "Minutos activos por semana",
"stepsPerWeek": "Pasos por semana",
"stepsPerMonth": "Pasos por mes"
},
"goalOf": "{value} de {target}",
"setGoals": "Fijar objetivos",
"editGoals": "Cambiar objetivos",
"goalsSheet": {
"title": "Objetivos de actividad",
"description": "Deja un campo vacío si no quieres un objetivo.",
"none": "Sin objetivo",
"invalid": "Un número entero de {min} a {max}.",
"saveError": "No se pudieron guardar los objetivos."
},
"crossLink": {
"title": "Calendario de actividad",
"body": "Tus pasos por semana o por mes, frente a lo que es habitual para ti.",
"cta": "Abrir calendario"
}
},
"sourceProvenance": {
"title": "De dónde vienen los valores",
"description": "Qué fuente aportó un valor cada día y cuál usa tu prioridad de fuentes.",
"metric": "Medida",
"day": "Día",
"dayTitle": "Fuentes de este día",
"loadError": "No se pudieron cargar las fuentes.",
"dayEmpty": "No hay valores de esta medida en este día.",
"reason": {
"only_source": "{source} es la única fuente de este día.",
"highest_on_ladder": "Se usa {source}: es la más alta en tu prioridad de fuentes entre las fuentes de este día.",
"name_tiebreak": "Se usa {source}: ninguna fuente de este día está en tu prioridad, así que decidió el nombre.",
"not_ranked": "Esta medida no tiene prioridad de fuentes: cuentan los valores de todas las fuentes."
},
"pass": {
"lower_on_ladder": "Más abajo en tu prioridad de fuentes.",
"not_on_ladder": "No está en tu prioridad de fuentes para esta medida.",
"name_tiebreak": "Mismo rango que la fuente usada; decidió el nombre."
},
"used": "Usada",
"readings": "{count} mediciones, dispositivos: {devices}",
"ladder": "Tu orden para esta medida: {ladder}",
"utcNote": "La fuente se elige por día, de medianoche a medianoche UTC, igual que en los gráficos.",
"openSettings": "Cambiar el orden en Prioridad de fuentes",
"compareTitle": "Comparar dos fuentes",
"range": "Periodo",
"rangeDays": "{days} días",
"compareNeedsTwo": "Solo una fuente tiene valores de esta medida en este periodo.",
"side": {
"a": "Primera fuente",
"b": "Segunda fuente"
},
"daysBoth": "Días con ambas",
"meanOf": "Media de {source}",
"meanAbsDiff": "Diferencia media",
"pickedDays": "Días usados (primera / segunda)",
"pickedSplit": "{a} / {b}",
"compareNote": "Las medias solo cuentan los días en que ambas fuentes tienen valores, para comparar lo comparable. En negrita, el valor que usan los gráficos.",
"settingsHint": "Para ver lo que decide este orden día a día:",
"settingsHintLink": "De dónde vienen los valores",
"metricLink": "De dónde vienen estos valores"
},
"recordImport": {
"title": "Resultados de laboratorio y medicamentos desde una hoja de cálculo",
"description": "Trae años de resultados o una lista de medicamentos desde un archivo CSV.",
"detail": "Tú eliges qué columna contiene qué; HealthLog revisa cada fila antes y no importa nada hasta que confirmes. Las filas que ya están se omiten.",
"kind": {
"labs": "Resultados de laboratorio",
"medications": "Medicamentos"
},
"sheetTitle": {
"labs": "Importar resultados",
"medications": "Importar medicamentos"
},
"sheetDescription": {
"labs": "Los marcadores se asignan a los tuyos y las unidades se convierten cuando se conoce la conversión.",
"medications": "Nombre, dosis, horas diarias, a demanda, inicio y fin. Otros ritmos se configuran después en la app."
},
"file": "Archivo CSV",
"template": "Descargar plantilla",
"mapping": "Qué columna contiene qué",
"required": "(obligatorio)",
"notInFile": "No está en el archivo",
"column": "Columna {n}",
"delimiter": "Separador",
"delimiterOption": {
"comma": "Coma",
"semicolon": "Punto y coma",
"tab": "Tabulador"
},
"decimal": "Separador decimal",
"decimalOption": {
"dot": "Punto (5.4)",
"comma": "Coma (5,4)"
},
"dateOrder": "Formato de fecha",
"dateOrderOption": {
"iso": "2025-03-01",
"dmy": "01.03.2025 (día primero)",
"mdy": "03/01/2025 (mes primero)"
},
"header": "La primera fila contiene los nombres de columna",
"reminders": "Activar recordatorios para los medicamentos importados",
"timezoneNote": "Las horas sin zona horaria se leen en la zona de tu cuenta. Una fecha sin hora se guarda como ese día del calendario.",
"missing": "Falta elegir: {fields}",
"check": "Revisar archivo",
"runIdle": "Importar",
"run": "Importar {count} filas",
"close": "Cerrar",
"summaryCheck": "{rows} filas leídas: {inserted} se pueden importar, {duplicates} ya están, {failed} con un problema.",
"summaryDone": "{inserted} filas importadas. Omitidas: {duplicates} que ya estaban y {failed} con un problema.",
"markersMatched": "Asignados a tus marcadores:",
"markersCreated": "Marcadores nuevos:",
"line": "Línea {line}",
"errorsTruncated": "Solo se listan los primeros 500 problemas.",
"error": {
"tooLarge": "El archivo supera los 16 MB.",
"rateLimited": "Demasiadas importaciones en la última hora. Inténtalo más tarde.",
"invalid": "No se pudo leer el archivo o la elección de columnas.",
"failed": "La importación no se completó. Inténtalo de nuevo."
},
"reason": {
"missing_value": "Falta una celda obligatoria",
"invalid_number": "No es un número",
"ambiguous_number": "El número se puede leer de dos maneras; revisa el separador decimal",
"missing_unit": "Un número necesita una unidad",
"invalid_date": "No es una fecha en el formato elegido",
"invalid_time": "No es una hora",
"ambiguous_date": "La fecha se puede leer de dos maneras; usa un año de cuatro cifras y el formato elegido",
"future_date": "La fecha está en el futuro",
"text_too_long": "El texto es demasiado largo",
"unit_mismatch": "Esta unidad no se puede convertir a la del marcador",
"invalid_reference_range": "No se puede leer el rango de referencia",
"invalid_times": "No se pueden leer las horas (ej. 08:00 20:00)",
"missing_times": "Añade horas de toma o marca el medicamento como a demanda",
"invalid_yes_no": "Usa sí o no",
"as_needed_with_times": "Un medicamento a demanda no tiene horas fijas",
"end_before_start": "El fin es anterior al inicio",
"invalid_medication": "El medicamento no se puede guardar así",
"oversized_row": "La fila es demasiado larga o ancha",
"row_limit": "Supera el límite de filas por importación",
"duplicate": "Ya está, omitida"
},
"field": {
"analyte": "Marcador",
"value": "Resultado",
"unit": "Unidad",
"date": "Fecha",
"time": "Hora",
"referenceRange": "Rango de referencia",
"referenceLow": "Referencia mínima",
"referenceHigh": "Referencia máxima",
"panel": "Grupo",
"note": "Nota",
"name": "Nombre",
"dose": "Dosis",
"times": "Horas",
"asNeeded": "A demanda",
"startsOn": "Inicio",
"endsOn": "Fin"
}
}
}
+464 -49
View File
@@ -401,7 +401,41 @@
"lifeSittingValue": "{hours} heures, déclaré",
"lifeWork": "Travail",
"lifeNightWork": "travail de nuit",
"lifeExposures": "expositions : {list}"
"lifeExposures": "expositions : {list}",
"workouts": {
"title": "Entraînements",
"summary": "{sessions} séances, {hours} h au total ; en moyenne {perWeek} séances et {minutesPerWeek} minutes par semaine.",
"colSport": "Sport",
"colSessions": "Séances",
"colTime": "Durée",
"colDistance": "Distance",
"colEnergy": "Énergie",
"colHeartRate": "FC moyenne",
"moreSports": "{count} autres sports sont inclus dans les totaux, sans être listés."
},
"medicationEffect": {
"title": "Changement autour du démarrage des médicaments",
"colMedication": "Médicament / mesure",
"colStart": "Début",
"colLevel": "Changement de niveau (IC 95 %)",
"colSlope": "Changement de tendance (IC 95 %)",
"colDays": "Jours avant / après",
"to": "à",
"week": "semaine",
"reasonNoStart": "Aucune date de début enregistrée",
"reasonBefore": "Trop peu de valeurs avant le démarrage",
"reasonAfter": "Trop peu de valeurs depuis le démarrage",
"reasonFit": "Calcul impossible",
"excludedAdherence": "{count} jours après le démarrage écartés (moins de {floor} % des doses prises)",
"excludedNotTaking": "{count} jours écartés (en pause ou entre deux périodes de prise)",
"notTracked": "prises non suivies, aucun jour écarté pour des doses manquées",
"endDoseChange": "la période s'arrête le {date}, avant le premier changement de dose",
"days": "{before} jours avant, {after} jours après le démarrage, {excluded} jours écartés",
"method": "Régression segmentée sur les valeurs quotidiennes, de 90 jours avant à 180 jours au plus après le démarrage, intervalles de Newey-West. Les jours après le démarrage où moins de 80 % des doses des 14 jours précédents ont été prises sont écartés. Décrit une association dans le temps, pas un effet démontré du médicament.",
"autocorrelation": "forte dépendance à la veille, les plages peuvent donc rester trop étroites",
"window": "analysé du {from} au {to}, ce qui peut dépasser la période du rapport",
"windowLine": "Analysé du {from} au {to}. Cette fenêtre peut dépasser la période du rapport."
}
},
"errorBoundary": {
"title": "Une erreur est survenue",
@@ -911,6 +945,15 @@
"unitRating": "niveau",
"typeWho5Score": "Score WHO-5",
"typeSciScore": "Score SCI",
"typeExerciseMinutes": "Minutes d’exercice",
"typeStandMinutes": "Minutes debout",
"typeStandHours": "Heures debout",
"typeMindfulMinutes": "Minutes de pleine conscience",
"typeStateOfMindValence": "État d’esprit",
"typeAfibBurden": "Charge de FA",
"typeIndoorCo2": "CO₂ intérieur",
"typeIndoorTemperature": "Température intérieure",
"typeIndoorHumidity": "Humidité intérieure",
"durationReadAs": "Compris comme {duration}",
"durationHoursMinutes": "{hours} h {minutes} min",
"durationHoursOnly": "{hours} h",
@@ -2350,7 +2393,31 @@
},
"scaleAbsolute": "Absolu",
"scalePercentSinceStart": "% depuis le début",
"scaleToggleLabel": "Échelle du graphique"
"scaleToggleLabel": "Échelle du graphique",
"segmented": {
"title": "Changement au démarrage",
"level": "Dès le démarrage, le niveau s'est déplacé de {value} {unit} (intervalle à 95 % : {low} à {high}).",
"slope": "Après le démarrage, la tendance hebdomadaire a changé de {value} {unit} par semaine (intervalle à 95 % : {low} à {high}).",
"preSlope": "Avant le démarrage, la tendance était de {value} {unit} par semaine.",
"includesZero": "Lorsqu'un intervalle inclut zéro, ces valeurs sont aussi compatibles avec une absence de changement.",
"basis": "Sur la base de {before} jours avant et {after} jours après le démarrage ({from} au {to}).",
"excludedAdherence": "{count} jours après le démarrage ont été écartés, car moins de {floor} % des doses ont été prises dans les {lookback} jours précédents.",
"excludedNotTaking": "{count} jours ont été écartés, car le médicament était en pause ou entre deux périodes de prise.",
"notTracked": "Les prises de ce médicament ne sont pas suivies, aucun jour n'a donc été écarté pour des doses manquées.",
"endDoseChange": "La période s'arrête avant le premier changement de dose et décrit donc la dose de départ.",
"confoundersTitle": "Autres changements de médicaments sur cette période, que cette vue ne peut pas distinguer de celui-ci :",
"confounderStarted": "{name} commencé le {date}",
"confounderStopped": "{name} arrêté le {date}",
"confounderDoseChange": "{name} : dose modifiée le {date}",
"confounderPaused": "{name} mis en pause le {date}",
"confounderResumed": "{name} repris le {date}",
"method": "Méthode : une droite est ajustée aux valeurs quotidiennes avant le démarrage, une autre à celles d'après. Le saut entre les deux au démarrage est le changement de niveau, l'écart de leurs pentes le changement de tendance. Les intervalles tiennent compte du fait que les valeurs d'un jour influencent le lendemain.",
"needStart": "Indique une date de début pour voir le changement au démarrage.",
"needBefore": "Le changement au démarrage demande des valeurs sur au moins {days} jours répartis sur {span} jours avant le démarrage.",
"needAfter": "Le changement au démarrage demande des valeurs sur au moins {days} jours répartis sur {span} jours depuis le démarrage, les jours où les doses ont été prises.",
"fitFailed": "Ces valeurs n'ont pas pu être ajustées, aucun changement au démarrage n'est donc affiché.",
"autocorrelation": "Les valeurs quotidiennes dépendent fortement de la veille, donc même ces plages peuvent être trop étroites."
}
},
"import": {
"resultSuccess": "{imported} prises importées",
@@ -2679,7 +2746,20 @@
"ENV_PRESSURE_DELTA": "la variation de pression sur la journée",
"ENV_PM25": "les particules fines (PM2,5)",
"ENV_OZONE_8H": "l’ozone",
"ENV_POLLEN_MAX": "le pollen"
"ENV_POLLEN_MAX": "le pollen",
"HRV_RMSSD": "la variabilité de la fréquence cardiaque (RMSSD)",
"HYDRATION": "l'apport en eau",
"CAFFEINE": "l'apport en caféine",
"ALCOHOL": "la consommation d'alcool",
"WORKOUT_MINUTES": "les minutes d'entraînement",
"TRAINING_LOAD": "la charge d'entraînement",
"EXERCISE_MINUTES": "les minutes d'exercice",
"STAND_MINUTES": "les minutes debout",
"MINDFUL_MINUTES": "les minutes de pleine conscience",
"STATE_OF_MIND_VALENCE": "l'état d'esprit noté",
"INDOOR_CO2": "le CO2 intérieur",
"INDOOR_TEMPERATURE": "la température intérieure",
"INDOOR_HUMIDITY": "l'humidité intérieure"
},
"daily": {
"high": "Quand {behaviour} monte, {outcome} du lendemain tend à {direction} dans tes données. C'est une tendance à surveiller, pas une cause.",
@@ -2707,6 +2787,12 @@
"high": "Plus de {behaviour} le jour même et la veille va souvent de pair avec {direction} {outcome} dans tes données : un schéma à surveiller, pas une cause.",
"moderate": "Plus de {behaviour} le jour même et la veille semble aller de pair avec {direction} {outcome} dans tes données, d’après ce qu’on voit jusqu’ici : un schéma à surveiller, pas une cause.",
"faint": "Plus de {behaviour} le jour même et la veille ne montre, au mieux, qu’un faible indice de {direction} {outcome} dans tes données : trop peu pour s’y fier, jamais une cause."
},
"channelNutrient": "l'apport en {nutrient}",
"dailyOvernight": {
"high": "Plus de {behaviour} pendant la nuit va souvent de pair avec {direction} {outcome} cette nuit-là dans tes données : un schéma à surveiller, pas une cause.",
"moderate": "Plus de {behaviour} pendant la nuit semble aller de pair avec {direction} {outcome} cette nuit-là dans tes données, d’après ce qu’on voit jusqu’ici : un schéma à surveiller, pas une cause.",
"faint": "Plus de {behaviour} pendant la nuit ne montre, au mieux, qu’un faible indice de {direction} {outcome} cette nuit-là dans tes données : trop peu pour s’y fier, jamais une cause."
}
},
"noAnalysisYet": "Pas encore d’évaluation.",
@@ -3264,11 +3350,12 @@
"provider": "Répond avec",
"modelManaged": "Le modèle de ce fournisseur se règle dans les paramètres IA.",
"noProvider": "Aucun fournisseur disponible",
"allSettings": "Tous les paramètres IA",
"operatorCodex": "ChatGPT (partagé par l'opérateur)",
"changeModel": "Modifier dans Réglages > IA",
"saving": "Enregistrement…",
"saveFailed": "Impossible d’enregistrer cette modification.",
"allSettings": "Tous les paramètres IA",
"providerNotActive": "{provider} n'a pas pu prendre le relais : aucune connexion ni clé valide. Configurez-le dans les réglages IA.",
"operatorCodex": "ChatGPT (partagé par l'opérateur)",
"operatorManaged": "L'opérateur de votre serveur gère cette connexion. Dès qu'un de vos propres fournisseurs est configuré, c'est lui qui répond en premier."
},
"answer": {
@@ -3565,6 +3652,7 @@
"falls": "chutes",
"years": "ans",
"breathingEvents": "événements",
"standHours": "heures",
"respiratoryRate": "respirations/min"
},
"sleep": {
@@ -3807,6 +3895,16 @@
"editTitle": "Modifier l’entraînement",
"editAction": "Modifier",
"updated": "Entraînement mis à jour"
},
"load": {
"title": "Charge d'entraînement",
"fitness": "Condition",
"fatigue": "Fatigue",
"form": "Fraîcheur",
"explainer": "La condition est ton impulsion d'entraînement par fréquence cardiaque moyennée sur environ six semaines, la fatigue sur environ une semaine, et la fraîcheur est la différence. Les chiffres se comparent à ton propre passé, pas à d'autres personnes ni à d'autres apps.",
"warmingUp": "Sur {days} jours pour l'instant : tant que six semaines ne sont pas couvertes, la condition paraît plus basse qu'elle ne l'est.",
"coverage": "{rated} des {days} derniers jours ont pu être évalués. Un jour sans données d'activité, ou avec un entraînement qui n'a pas pu être évalué, est écarté au lieu de compter comme repos.",
"asOf": "Au {date}, le dernier jour qui a pu être évalué."
}
},
"bloodGlucose": {
@@ -4248,7 +4346,12 @@
"outcomeDown": "Une meilleure humeur tend à coïncider avec un {factor} plus bas le lendemain dans vos données.",
"stat": "{n} jours appariés, r = {r}, q = {q}",
"statLabel": "Comment ceci est calculé",
"explainerLabel": "Comment ceci est calculé"
"explainerLabel": "Comment ceci est calculé",
"channel": {
"ALCOHOL": "consommation d'alcool",
"WORKOUT_MINUTES": "minutes d'entraînement",
"TRAINING_LOAD": "charge d'entraînement"
}
},
"inTargetTitle": "Dans votre plage",
"dimensions": {
@@ -4709,7 +4812,8 @@
"shiftDown": "{vital} a diminué par rapport à votre normale récente.",
"rangeContext": "Actuellement {value}, habituellement {low}–{high}.",
"rangeContextTimeOfDay": "Actuellement {value}, habituellement {low}–{high} à ce moment de la journée.",
"shiftContext": "Environ {before} avant, {after} depuis."
"shiftContext": "Environ {before} avant, {after} depuis.",
"shiftDays": "Sur {before} jours avant et {after} depuis."
},
"breathingScreening": {
"sectionTitle": "Respiration pendant le sommeil",
@@ -5289,12 +5393,17 @@
"groups": {
"account": "Compte",
"yourData": "Vos données",
"display": "Affichage",
"healthProfile": "Profil de santé",
"ai": "IA",
"connectivity": "Connectivité",
"system": "Système"
}
"system": "Système",
"tracking": "Suivi",
"arrangement": "Disposition",
"notifications": "Notifications",
"connections": "Connexions",
"data": "Données"
},
"allSections": "Toutes les sections"
},
"sharedRecord": {
"managedSettings": {
@@ -5406,8 +5515,15 @@
"unavailableTitle": "Indisponible dans ce dossier partagé",
"unavailableDescription": "Cette section des réglages est volontairement indisponible lorsque vous consultez le dossier d’une autre personne.",
"integrationStatusDescription": "État de connexion pour ce dossier géré.",
"integrationStatusDetail": "Un profil géré affiche ses services connectés uniquement sous forme d’état. Les personnes qui le gèrent ne peuvent ni connecter, ni reconnecter, ni synchroniser, ni déconnecter.",
"integrationStatusDocs": "Comment fonctionne le partage"
"integrationStatusDetail": "Les services que vous connectez ici envoient leurs données dans le dossier de {name}, pas dans le vôtre. Sans application propre chez le fournisseur, le profil utilise la vôtre.",
"integrationStatusDocs": "Comment fonctionne le partage",
"integrationConnectsFor": "Connecte pour {name}",
"integrationConnectFor": "Connecter pour {name}",
"integrationDisconnect": "Déconnecter",
"integrationDisconnectTitle": "Déconnecter {provider} de {name} ?",
"integrationDisconnectBody": "Les nouvelles données n’arriveront plus. Tout ce qui a déjà été importé reste dans le dossier de {name}.",
"integrationNoClientApp": "Ni {name} ni votre propre compte n’ont encore d’application {provider}. Configurez-en d’abord une dans vos propres intégrations.",
"integrationConnectFailed": "Impossible de lancer la connexion à {provider}."
},
"sections": {
"account": {
@@ -5428,7 +5544,21 @@
"remindersHeading": "Rappels",
"matrixLink": "Notifications par canal",
"channelsLink": "Canaux de diffusion",
"displayHeading": "Sur ton téléphone"
"displayHeading": "Sur ton téléphone",
"elsewhere": {
"title": "Réglés là où ils se rattachent",
"description": "Ces rappels se règlent à côté de ce qu’ils concernent.",
"doses": {
"title": "Prises de médicaments",
"description": "Les heures de prise et les rappels se règlent sur chaque médicament.",
"link": "Ouvrir les médicaments"
},
"cycle": {
"title": "Cycle",
"description": "Les rappels de règles et de fenêtre fertile s’activent par canal dans la grille des notifications.",
"link": "Ouvrir la grille des notifications"
}
}
},
"layout": {
"title": "Disposition",
@@ -5441,7 +5571,7 @@
"title": "Analyses",
"description": "Ordre de la vue d'ensemble et des onglets de navigation."
},
"subtitle": "Choisissez ce qu'affichent le tableau de bord, les analyses et les modules de suivi, et dans quel ordre.",
"subtitle": "Choisissez l’ordre et l’affichage du tableau de bord, des analyses et des listes de suivi.",
"medications": {
"title": "Médicaments",
"description": "Vue en liste et ordre manuel."
@@ -5458,10 +5588,6 @@
"title": "Examens",
"description": "Vue en liste et ordre des rappels."
},
"mood": {
"title": "Étiquettes d’humeur",
"description": "Groupes, étiquettes et étiquettes archivées du sélecteur d’humeur."
},
"documents": {
"title": "Documents",
"description": "Affichage et disposition des mois."
@@ -5852,7 +5978,28 @@
},
"manageIn": "Gérer dans {section}",
"operatorDisabled": "Désactivé sur tout le serveur",
"subtitle": "Choisissez les domaines de suivi affichés par HealthLog."
"subtitle": "Choisissez les domaines de suivi affichés par HealthLog.",
"backToHub": "Retour à Modules",
"openModuleSettings": "Réglages du module",
"pages": {
"medications": {
"title": "Médicaments",
"description": "Sites d’injection que la rotation laisse de côté."
},
"mood": {
"title": "Étiquettes d’humeur",
"description": "Groupes, vos propres étiquettes et les étiquettes archivées."
},
"labs": {
"title": "Analyses",
"description": "Biomarqueurs et reconnaissance de texte sur l’appareil pour les comptes rendus d’analyses."
},
"arrangementTitle": "Ordre et affichage",
"arrangementDescription": "Le tri et l’affichage de cette liste se règlent dans Disposition.",
"openArrangement": "Ouvrir dans Disposition",
"moduleSettingsTitle": "Réglages du module",
"moduleSettingsDescription": "Tout le reste se trouve sur la page du module, dans Modules."
}
},
"gesundheitsakte": {
"title": "Dossier médical",
@@ -6499,7 +6646,8 @@
"nocode": "Withings n'a renvoyé aucun code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Withings.",
"nocreds": "Aucun identifiant d'API Withings n'est enregistré. Enregistrez d'abord le Client ID et le Client Secret, puis reconnectez.",
"token": "L'échange de jetons avec Withings a échoué. Vérifiez le Client ID et le Client Secret, puis réessayez.",
"generic": "Erreur inconnue lors de la connexion. Relancez la connexion."
"generic": "Erreur inconnue lors de la connexion. Relancez la connexion.",
"already_linked": "Ce compte Withings est déjà connecté à un autre profil HealthLog. Connecte-toi à Withings avec le compte de ce profil, ou déconnecte-le d'abord de l'autre profil."
},
"withingsSynchronize": "Synchroniser",
"withingsDisconnect": "Déconnecter",
@@ -6538,7 +6686,8 @@
"nocode": "WHOOP n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre WHOOP.",
"nocreds": "WHOOP n'est pas configuré. Ajoutez vos identifiants OAuth WHOOP ci-dessus et réessayez.",
"token": "L'échange de jeton avec WHOOP a échoué. Veuillez relancer la connexion.",
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion.",
"already_linked": "Ce compte WHOOP est déjà connecté à un autre profil HealthLog. Connecte-toi à WHOOP avec le compte de ce profil, ou déconnecte-le d'abord de l'autre profil."
},
"fitbit": "Fitbit",
"fitbitTag": "Fitbit et Pixel",
@@ -6617,7 +6766,8 @@
"nocode": "Google n'a pas renvoyé de code d'autorisation. Réessaie et autorise l'accès dans la fenêtre Google.",
"nocreds": "Google Health n'est pas configuré. Ajoute tes identifiants OAuth Google ci-dessus et réessaie.",
"token": "L'échange de jeton avec Google a échoué. Relance la connexion.",
"generic": "Erreur inconnue lors de la connexion. Relance la connexion."
"generic": "Erreur inconnue lors de la connexion. Relance la connexion.",
"already_linked": "Ce compte Google est déjà connecté à un autre profil HealthLog. Connecte-toi à Google avec le compte de ce profil, ou déconnecte-le d'abord de l'autre profil."
},
"fitbitOauthError": {
"csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était absent. Veuillez relancer la connexion dans le même navigateur.",
@@ -6628,7 +6778,8 @@
"nocreds": "Fitbit n'est pas configuré. Ajoutez vos identifiants OAuth Fitbit ci-dessus et réessayez.",
"token": "L'échange de jeton avec Fitbit a échoué. Veuillez relancer la connexion.",
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion.",
"retired": "Google a désactivé l'API Web Fitbit : il n'est plus possible de créer une nouvelle connexion Fitbit. Connectez plutôt Google Health, qui lit les mêmes appareils."
"retired": "Google a désactivé l'API Web Fitbit : il n'est plus possible de créer une nouvelle connexion Fitbit. Connectez plutôt Google Health, qui lit les mêmes appareils.",
"already_linked": "Ce compte Fitbit est déjà connecté à un autre profil HealthLog. Connecte-toi à Fitbit avec le compte de ce profil, ou déconnecte-le d'abord de l'autre profil."
},
"integrations": {
"withings": {
@@ -6665,7 +6816,9 @@
"items": {
"vo2max": "la VO2 max",
"cardio_age": "l’âge cardiovasculaire",
"resilience": "la résilience"
"resilience": "la résilience",
"workout": "les entraînements",
"heartrate": "la fréquence cardiaque"
}
}
}
@@ -6972,7 +7125,8 @@
"nocreds": "Polar n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Polar.",
"token": "L'échange de jeton avec Polar a échoué. Veuillez relancer la connexion.",
"rate_limited": "Trop de tentatives de connexion. Patientez un instant et réessayez.",
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion.",
"already_linked": "Ce compte Polar est déjà connecté à un autre profil HealthLog. Connecte-toi à Polar avec le compte de ce profil, ou déconnecte-le d'abord de l'autre profil."
},
"oura": "Oura",
"ouraDescription": "Connecte ton compte Oura pour synchroniser readiness, phases de sommeil, variabilité cardiaque et activité quotidienne.",
@@ -7002,25 +7156,24 @@
"token": "L'échange de jeton avec Oura a échoué. Veuillez relancer la connexion.",
"rate_limited": "Trop de tentatives de connexion. Patientez un instant et réessayez.",
"issuer": "La réponse de connexion ne provenait pas du serveur d'autorisation d'Oura, la connexion a donc été interrompue par sécurité. Veuillez relancer la connexion. Si cela se reproduit, prévenez votre administrateur.",
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion.",
"already_linked": "Ce compte Oura est déjà connecté à un autre profil HealthLog. Connecte-toi à Oura avec le compte de ce profil, ou déconnecte-le d'abord de l'autre profil."
},
"integrationSetupGuide": "Guide de configuration",
"coach": {
"activate": {
"title": "Activer le Coach",
"description": "Affiche le bouton et le panneau du Coach.",
"toggleAria": "Activer le bouton et le panneau du Coach",
"savedOn": "Coach activé.",
"savedOff": "Coach masqué.",
"saveError": "Impossible d enregistrer la modification. Veuillez réessayer."
},
"memory": {
"edit": "Modifier",
"source": {
"user": "de toi",
"coach": "du Coach"
}
}
},
"state": {
"title": "Coach",
"description": "Si le bouton et le panneau du Coach s’affichent. L’interrupteur se trouve dans Modules."
},
"nudgesMoved": "Se règlent avec vos autres rappels dans Notifications.",
"openNudges": "Ouvrir dans Notifications"
},
"integrationRedirectGuide": {
"intro": "Cette connexion passe par votre propre application développeur {provider}, environ 5 minutes de configuration :",
@@ -7155,6 +7308,12 @@
"profile": "Votre profil, vos antécédents médicaux et les informations du dossier de santé",
"modules": "Données de chaque module : {list}"
},
"workoutRoutes": {
"title": "Parcours d’entraînement",
"description": "Conserver le tracé GPS des nouveaux entraînements de l’app iPhone et de l’export Apple Santé.",
"detail": "Désactivé pour les nouveaux comptes. Un parcours montre où vous habitez, travaillez et vous entraînez : il n’est donc conservé qu’une fois l’option activée. Les entraînements sont conservés dans tous les cas, et aucune fonction d’IA ne lit jamais un parcours. Désactiver l’option arrête les nouveaux parcours ; ceux déjà conservés restent jusqu’à la suppression de l’entraînement.",
"loadError": "Impossible de charger le réglage des parcours."
},
"retention": {
"title": "Conservation",
"description": "Durée de conservation des enregistrements limités dans le temps avant le nettoyage automatique.",
@@ -7163,12 +7322,6 @@
"delivery": "Journal d'envoi des notifications : {days} jours",
"unavailable": "Les détails de conservation sont indisponibles pour le moment."
},
"export": {
"title": "Télécharger mes données",
"description": "Vos enregistrements en CSV ou JSON, ou un dossier complet pour votre médecin.",
"openExport": "Export et sauvegarde",
"openHealthRecord": "Dossier médical"
},
"delete": {
"title": "Supprimer ou réinitialiser",
"description": "Supprimez complètement votre compte, ou effacez vos données de santé tout en conservant le compte.",
@@ -7179,12 +7332,16 @@
"title": "Posture de confidentialité",
"description": "Contrôlez la quantité de données partagées avec les fonctions d'IA.",
"openAi": "Mode confidentialité IA"
},
"sessions": {
"title": "Sessions et appareils",
"description": "Où tu es connecté, les appareils qui sautent le second facteur et l'activité de sécurité de ton compte.",
"open": "Ouvrir la sécurité"
}
},
"mcp": {
"enableTitle": "Point de terminaison MCP distant",
"enableDescription": "Autorisez un assistant compatible MCP (Claude.ai, ChatGPT) à lire vos données de santé via un jeton restreint.",
"enableToggleLabel": "Activer le point de terminaison MCP",
"endpointLabel": "URL du connecteur",
"connectDescription": "Collez cette URL dans la configuration du connecteur personnalisé de votre assistant, puis autorisez avec le jeton ci-dessous ou connectez-vous lorsque vous y êtes invité.",
"tokensTitle": "Jetons du connecteur",
@@ -7203,7 +7360,8 @@
"connectionRevokeFailed": "Impossible de déconnecter l'assistant.",
"tokensDetail": "Le jeton n'est affiché qu'une seule fois.",
"connectionsDetail": "Révoquer une connexion met fin à son accès immédiatement, y compris sa capacité à se renouveler.",
"tokenNamePlaceholder": "Nom, p. ex. Claude Desktop"
"tokenNamePlaceholder": "Nom, p. ex. Claude Desktop",
"offKeepsTokens": "Le désactiver met le point d’accès en pause. Vos jetons et connexions restent et fonctionnent de nouveau dès sa réactivation."
},
"strava": "Strava",
"stravaDescription": "Connecte ton compte Strava pour synchroniser tes activités avec ton historique d'entraînement.",
@@ -7232,7 +7390,8 @@
"nocreds": "Strava n'est pas configuré sur ce serveur. Demande à ton administrateur d'ajouter les identifiants API Strava.",
"token": "L'échange de jeton avec Strava a échoué. Relance la connexion.",
"rate_limited": "Trop de tentatives de connexion. Attends un instant et réessaie.",
"generic": "Erreur inconnue lors de la connexion. Relance la connexion."
"generic": "Erreur inconnue lors de la connexion. Relance la connexion.",
"already_linked": "Ce compte Strava est déjà connecté à un autre profil HealthLog. Connecte-toi à Strava avec le compte de ce profil, ou déconnecte-le d'abord de l'autre profil."
},
"appleHealth": {
"title": "Apple Health",
@@ -7465,7 +7624,14 @@
"passkeyAddedToast": "Passkey ajoutée.",
"passkeyRemovedToast": "Passkey supprimée.",
"integrationDisconnected": "{provider} déconnecté.",
"integrationDisconnectFailed": "Impossible de déconnecter {provider}."
"integrationDisconnectFailed": "Impossible de déconnecter {provider}.",
"timezoneSaved": "Fuseau horaire enregistré.",
"moduleState": {
"on": "Activé",
"off": "Désactivé",
"change": "Modifier dans Modules"
},
"timezoneUseDevice": "Utiliser le fuseau horaire de cet appareil ({zone})"
},
"admin": {
"title": "Administration",
@@ -7600,6 +7766,7 @@
"heapLimitLabel": "Limite du tas",
"heapLimitValue": "{mb} Mo",
"heapLimitLow": "{mb} Mo, trop juste : définissez APP_MEMORY_LIMIT=1536m dans .env",
"memoryBelowFloor": "Conteneur {mb} Mo, sous le minimum pris en charge de {floor} Mo : définissez APP_MEMORY_LIMIT={floor}m dans .env",
"offlineGeoOff": "Désactivé",
"offlineGeoChosen": "En ligne ({host})",
"apiGlobal": "Autoriser les points de terminaison d'API et les jetons d'API.",
@@ -8855,6 +9022,10 @@
"detailedToast": "Les notifications incluent désormais des valeurs de santé.",
"genericToast": "Les notifications n'incluent plus de valeurs de santé.",
"saveError": "Enregistrement impossible. Réessaie plus tard."
},
"checkupReminders": {
"title": "Rappels de bilans",
"description": "Activez ou désactivez chaque rappel de bilan."
}
},
"i18n": {
@@ -9631,8 +9802,6 @@
"description": "Saisissez la valeur ; le rappel est marqué comme fait."
},
"manage": {
"title": "Gérer les rappels",
"description": "Activer ou désactiver des rappels individuels.",
"empty": "Aucun rappel à personnaliser pour l'instant."
},
"dashboard": {
@@ -10016,6 +10185,27 @@
"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."
},
"trend": {
"title": "Tendance",
"slope": {
"rising": "Augmente d'environ {amount} {unit} par an sur {n} valeurs depuis le {since}.",
"falling": "Diminue d'environ {amount} {unit} par an sur {n} valeurs depuis le {since}.",
"flat": "Aucune variation sur {n} valeurs depuis le {since}."
},
"projection": {
"exits": {
"high": "Si cette droite se prolongeait, elle atteindrait la limite haute de ta plage de référence vers le {date}. C'est une projection, pas une prévision.",
"low": "Si cette droite se prolongeait, elle atteindrait la limite basse de ta plage de référence vers le {date}. C'est une projection, pas une prévision."
},
"beyond_horizon": "À ce rythme, la droite reste dans ta plage de référence plus de {years} ans, la durée couverte par tes valeurs.",
"beyond_horizon_capped": "À ce rythme, la droite reste dans ta plage de référence plus de {years} ans, aussi loin que porte la projection.",
"no_exit": "La droite s'éloigne des limites de ta plage de référence.",
"already_outside": "La droite est déjà hors de ta plage de référence.",
"no_clear_trend": "Sur ces valeurs, la variation ne se distingue pas clairement d'une absence de variation, donc aucune projection n'est tracée.",
"stale": "La valeur la plus récente date du {date}, il y a plus d’un an. La droite est montrée à cette date, sans projection."
},
"interval": "Intervalle à 95 % de la variation annuelle : de {lo} à {hi} {unit}."
}
},
"modules": {
@@ -10957,7 +11147,16 @@
"openDetail": "Voir les détails",
"historyLoadError": "Ton historique d'évaluations n'a pas pu être chargé",
"historyLoadErrorHint": "Cela ne veut pas dire que tu n'as aucun résultat : la liste n'a pas pu être récupérée. Réessaie.",
"privacyNote": "Vos réponses sont stockées chiffrées et n'atteignent jamais le Coach IA."
"privacyNote": "Vos réponses sont stockées chiffrées et n'atteignent jamais le Coach IA.",
"trend": {
"sincePrevious": "Depuis ton bilan précédent du {date} : {change} points.",
"sinceFirst": "Depuis ton premier bilan du {date} : {change} points.",
"verdict": {
"improved": "Cela atteint la variation de {threshold} points que les études jugent significative, dans le bon sens.",
"worsened": "Cela atteint la variation de {threshold} points que les études jugent significative, dans le sens moins favorable.",
"within_threshold": "C'est moins que la variation de {threshold} points que les études jugent significative."
}
}
},
"records": {
"aiInclusion": {
@@ -12216,7 +12415,10 @@
"leafVisits": "Consultations",
"leafSurgicalHistory": "Antécédents chirurgicaux",
"leafImmunizations": "Vaccinations",
"leafEmergency": "Informations d'urgence (groupe sanguin, contacts, directives)"
"leafEmergency": "Informations d'urgence (groupe sanguin, contacts, directives)",
"leafMedicationEffect": "Changement autour du démarrage de chaque médicament (nécessite la liste des médicaments)",
"leafWorkouts": "Entraînements (résumé par sport)",
"leafHrvSdnn": "VFC (SDNN)"
},
"recordSharing": {
"banner": {
@@ -13396,5 +13598,218 @@
"clinicalUpper": "Limite clinique haute",
"clinicalLower": "Limite clinique basse"
}
},
"activityCalendar": {
"title": "Calendrier d'activité",
"periodLabel": "Période du calendrier",
"period": {
"week": "Semaine",
"month": "Mois"
},
"weekRange": "{start} au {end}",
"previous": "Période précédente",
"next": "Période suivante",
"metricLabel": "Colorer les jours selon",
"metric": {
"steps": "Pas",
"activeMinutes": "Minutes actives",
"load": "Charge d'entraînement"
},
"empty": "Aucun pas, aucune minute active ni aucun entraînement sur cette période pour l'instant.",
"loadError": "Le calendrier d'activité n'a pas pu être chargé.",
"weekGoalsColumn": "Objectifs hebdomadaires",
"weekGoalsMet": "Tous les objectifs hebdomadaires atteints",
"weekGoalsNotMet": "Objectifs hebdomadaires pas tous atteints",
"cell": {
"steps": "{value} pas",
"activeMinutes": "{value} minutes actives",
"workouts": "{value} entraînements : {sports}"
},
"level": {
"none": "Aucune donnée",
"unrated": "Pas encore assez d'historique",
"low": "Bien en dessous de ton habitude",
"below": "En dessous de ton habitude",
"usual": "Comme d'habitude",
"above": "Au-dessus de ton habitude",
"high": "Bien au-dessus de ton habitude",
"in_progress": "Aujourd’hui, en cours"
},
"legend": "Comparé à tes 12 semaines précédentes :",
"noBaseline": "Ta plage habituelle apparaît après 14 jours avec des données.",
"summary": {
"steps": "{value} pas",
"activeMinutes": "{value} minutes actives",
"workouts": "{value} entraînements",
"load": "Charge d'entraînement moyenne {value}",
"usual": "Ton habitude à ce stade : {value}",
"noUsual": "Pas encore assez de semaines ou de mois précédents pour comparer.",
"usualThroughYesterday": "Jusqu’à hier {value}, ton habitude à ce stade {usual}",
"firstDay": "Comparé à ton habitude dès que le premier jour est terminé."
},
"sportCount": "{sport} : {count}",
"goal": {
"workoutsPerWeek": "Entraînements par semaine",
"activeMinutesPerWeek": "Minutes actives par semaine",
"stepsPerWeek": "Pas par semaine",
"stepsPerMonth": "Pas par mois"
},
"goalOf": "{value} sur {target}",
"setGoals": "Définir des objectifs",
"editGoals": "Modifier les objectifs",
"goalsSheet": {
"title": "Objectifs d'activité",
"description": "Laisse un champ vide pour ne pas fixer d'objectif.",
"none": "Aucun objectif",
"invalid": "Un nombre entier de {min} à {max}.",
"saveError": "Les objectifs n'ont pas pu être enregistrés."
},
"crossLink": {
"title": "Calendrier d'activité",
"body": "Tes pas par semaine ou par mois, comparés à ton habitude.",
"cta": "Ouvrir le calendrier"
}
},
"sourceProvenance": {
"title": "D'où viennent les valeurs",
"description": "Quelle source a fourni une valeur chaque jour, et laquelle ta priorité des sources utilise.",
"metric": "Mesure",
"day": "Jour",
"dayTitle": "Sources de ce jour",
"loadError": "Les sources n'ont pas pu être chargées.",
"dayEmpty": "Aucune valeur de cette mesure ce jour-là.",
"reason": {
"only_source": "{source} est la seule source ce jour-là.",
"highest_on_ladder": "{source} est utilisée : c'est la plus haute dans ta priorité des sources parmi celles de ce jour.",
"name_tiebreak": "{source} est utilisée : aucune source de ce jour n'est dans ta priorité, le nom a donc décidé.",
"not_ranked": "Cette mesure n'a pas de priorité des sources : les valeurs de toutes les sources comptent."
},
"pass": {
"lower_on_ladder": "Plus bas dans ta priorité des sources.",
"not_on_ladder": "Absente de ta priorité des sources pour cette mesure.",
"name_tiebreak": "Même rang que la source utilisée ; le nom a décidé."
},
"used": "Utilisée",
"readings": "{count} mesures, appareils : {devices}",
"ladder": "Ton ordre pour cette mesure : {ladder}",
"utcNote": "La source est choisie par jour, de minuit à minuit UTC, comme pour les graphiques.",
"openSettings": "Modifier l'ordre dans Priorité des sources",
"compareTitle": "Comparer deux sources",
"range": "Période",
"rangeDays": "{days} jours",
"compareNeedsTwo": "Une seule source a des valeurs de cette mesure sur cette période.",
"side": {
"a": "Première source",
"b": "Deuxième source"
},
"daysBoth": "Jours avec les deux",
"meanOf": "Moyenne de {source}",
"meanAbsDiff": "Écart moyen",
"pickedDays": "Jours utilisés (première / deuxième)",
"pickedSplit": "{a} / {b}",
"compareNote": "Les moyennes ne comptent que les jours où les deux sources ont des valeurs, pour comparer ce qui est comparable. En gras, la valeur utilisée par les graphiques.",
"settingsHint": "Pour voir ce que cet ordre décide jour après jour :",
"settingsHintLink": "D'où viennent les valeurs",
"metricLink": "D'où viennent ces valeurs"
},
"recordImport": {
"title": "Résultats d'analyses et médicaments depuis un tableur",
"description": "Importe des années de résultats ou une liste de médicaments depuis un fichier CSV.",
"detail": "Tu choisis quelle colonne contient quoi ; HealthLog vérifie chaque ligne d'abord et n'importe rien avant ta confirmation. Les lignes déjà présentes sont ignorées.",
"kind": {
"labs": "Résultats d'analyses",
"medications": "Médicaments"
},
"sheetTitle": {
"labs": "Importer des résultats",
"medications": "Importer des médicaments"
},
"sheetDescription": {
"labs": "Les marqueurs sont rattachés aux tiens, les unités converties quand la conversion est connue.",
"medications": "Nom, dose, heures quotidiennes, si besoin, début et fin. Les autres rythmes se règlent ensuite dans l'app."
},
"file": "Fichier CSV",
"template": "Télécharger le modèle",
"mapping": "Quelle colonne contient quoi",
"required": "(obligatoire)",
"notInFile": "Absent du fichier",
"column": "Colonne {n}",
"delimiter": "Séparateur",
"delimiterOption": {
"comma": "Virgule",
"semicolon": "Point-virgule",
"tab": "Tabulation"
},
"decimal": "Séparateur décimal",
"decimalOption": {
"dot": "Point (5.4)",
"comma": "Virgule (5,4)"
},
"dateOrder": "Format de date",
"dateOrderOption": {
"iso": "2025-03-01",
"dmy": "01.03.2025 (jour d'abord)",
"mdy": "03/01/2025 (mois d'abord)"
},
"header": "La première ligne contient les noms de colonnes",
"reminders": "Activer les rappels pour les médicaments importés",
"timezoneNote": "Les heures sans fuseau sont lues dans le fuseau de ton compte. Une date sans heure reste ce jour du calendrier.",
"missing": "Encore à choisir : {fields}",
"check": "Vérifier le fichier",
"runIdle": "Importer",
"run": "Importer {count} lignes",
"close": "Fermer",
"summaryCheck": "{rows} lignes lues : {inserted} importables, {duplicates} déjà présentes, {failed} avec un problème.",
"summaryDone": "{inserted} lignes importées. Ignorées : {duplicates} déjà présentes et {failed} avec un problème.",
"markersMatched": "Rattachés à tes marqueurs :",
"markersCreated": "Nouveaux marqueurs :",
"line": "Ligne {line}",
"errorsTruncated": "Seuls les 500 premiers problèmes sont listés.",
"error": {
"tooLarge": "Le fichier dépasse 16 Mo.",
"rateLimited": "Trop d'importations au cours de la dernière heure. Réessaie plus tard.",
"invalid": "Le fichier ou le choix des colonnes n'a pas pu être lu.",
"failed": "L'importation n'a pas abouti. Réessaie."
},
"reason": {
"missing_value": "Une cellule obligatoire est vide",
"invalid_number": "Ce n'est pas un nombre",
"ambiguous_number": "Le nombre se lit de deux façons ; vérifie le séparateur décimal",
"missing_unit": "Un nombre a besoin d'une unité",
"invalid_date": "Ce n'est pas une date au format choisi",
"invalid_time": "Ce n'est pas une heure",
"ambiguous_date": "La date se lit de deux façons ; utilise une année à quatre chiffres et le format choisi",
"future_date": "La date est dans le futur",
"text_too_long": "Le texte est trop long",
"unit_mismatch": "Cette unité ne se convertit pas dans celle du marqueur",
"invalid_reference_range": "La plage de référence est illisible",
"invalid_times": "Les heures sont illisibles (ex. 08:00 20:00)",
"missing_times": "Ajoute des heures de prise ou marque le médicament si besoin",
"invalid_yes_no": "Utilise oui ou non",
"as_needed_with_times": "Un médicament si besoin n'a pas d'heures fixes",
"end_before_start": "La fin précède le début",
"invalid_medication": "Le médicament ne peut pas être enregistré ainsi",
"oversized_row": "La ligne est trop longue ou trop large",
"row_limit": "Au-delà de la limite de lignes par importation",
"duplicate": "Déjà présente, ignorée"
},
"field": {
"analyte": "Marqueur",
"value": "Résultat",
"unit": "Unité",
"date": "Date",
"time": "Heure",
"referenceRange": "Plage de référence",
"referenceLow": "Référence basse",
"referenceHigh": "Référence haute",
"panel": "Groupe",
"note": "Note",
"name": "Nom",
"dose": "Dose",
"times": "Heures",
"asNeeded": "Si besoin",
"startsOn": "Début",
"endsOn": "Fin"
}
}
}
+466 -51
View File
@@ -401,7 +401,41 @@
"lifeSittingValue": "{hours} ore, dichiarate",
"lifeWork": "Lavoro",
"lifeNightWork": "lavoro notturno",
"lifeExposures": "esposizioni: {list}"
"lifeExposures": "esposizioni: {list}",
"workouts": {
"title": "Allenamenti",
"summary": "{sessions} sessioni, {hours} h in totale; in media {perWeek} sessioni e {minutesPerWeek} minuti a settimana.",
"colSport": "Sport",
"colSessions": "Sessioni",
"colTime": "Durata",
"colDistance": "Distanza",
"colEnergy": "Energia",
"colHeartRate": "FC media",
"moreSports": "Altri {count} sport sono inclusi nei totali ma non elencati."
},
"medicationEffect": {
"title": "Variazione intorno all'inizio dei farmaci",
"colMedication": "Farmaco / misura",
"colStart": "Inizio",
"colLevel": "Variazione di livello (IC 95 %)",
"colSlope": "Variazione di tendenza (IC 95 %)",
"colDays": "Giorni prima / dopo",
"to": "a",
"week": "settimana",
"reasonNoStart": "Nessuna data di inizio registrata",
"reasonBefore": "Troppo pochi valori prima dell'inizio",
"reasonAfter": "Troppo pochi valori dall'inizio",
"reasonFit": "Non calcolabile",
"excludedAdherence": "{count} giorni dopo l'inizio esclusi (meno del {floor} % delle dosi assunte)",
"excludedNotTaking": "{count} giorni esclusi (in pausa o tra due cicli)",
"notTracked": "assunzione non registrata, quindi nessun giorno escluso per dosi saltate",
"endDoseChange": "il periodo termina il {date}, prima della prima variazione di dose",
"days": "{before} giorni prima, {after} giorni dopo l'inizio, {excluded} giorni esclusi",
"method": "Regressione segmentata sui valori giornalieri da 90 giorni prima a non oltre 180 giorni dopo l'inizio, con intervalli di Newey-West. Sono esclusi i giorni dopo l'inizio in cui è stato assunto meno dell'80 % delle dosi dei 14 giorni precedenti. Descrive un'associazione nel tempo, non un effetto dimostrato del farmaco.",
"autocorrelation": "forte dipendenza dal giorno precedente, quindi gli intervalli possono restare troppo stretti",
"window": "analizzato dal {from} al {to}, può andare oltre il periodo del report",
"windowLine": "Analizzato dal {from} al {to}. Questo intervallo può andare oltre il periodo del report."
}
},
"errorBoundary": {
"title": "Qualcosa è andato storto",
@@ -911,6 +945,15 @@
"unitRating": "livello",
"typeWho5Score": "Punteggio WHO-5",
"typeSciScore": "Punteggio SCI",
"typeExerciseMinutes": "Minuti di esercizio",
"typeStandMinutes": "Minuti in piedi",
"typeStandHours": "Ore in piedi",
"typeMindfulMinutes": "Minuti di consapevolezza",
"typeStateOfMindValence": "Stato d’animo",
"typeAfibBurden": "Carico di FA",
"typeIndoorCo2": "CO₂ interna",
"typeIndoorTemperature": "Temperatura interna",
"typeIndoorHumidity": "Umidità interna",
"durationReadAs": "Letto come {duration}",
"durationHoursMinutes": "{hours} h {minutes} min",
"durationHoursOnly": "{hours} h",
@@ -2350,7 +2393,31 @@
},
"scaleAbsolute": "Assoluto",
"scalePercentSinceStart": "% dall'inizio",
"scaleToggleLabel": "Scala del grafico"
"scaleToggleLabel": "Scala del grafico",
"segmented": {
"title": "Variazione all'inizio",
"level": "Subito all'inizio il livello si è spostato di {value} {unit} (intervallo al 95 %: da {low} a {high}).",
"slope": "Dopo l'inizio la tendenza settimanale è cambiata di {value} {unit} a settimana (intervallo al 95 %: da {low} a {high}).",
"preSlope": "Prima dell'inizio la tendenza era di {value} {unit} a settimana.",
"includesZero": "Quando un intervallo include lo zero, questi valori sono compatibili anche con nessuna variazione.",
"basis": "Basato su {before} giorni prima e {after} giorni dopo l'inizio (dal {from} al {to}).",
"excludedAdherence": "{count} giorni dopo l'inizio sono stati esclusi perché nei {lookback} giorni precedenti è stato assunto meno del {floor} % delle dosi.",
"excludedNotTaking": "{count} giorni sono stati esclusi perché il farmaco era in pausa o tra due cicli.",
"notTracked": "L'assunzione di questo farmaco non viene registrata, quindi nessun giorno è stato escluso per dosi saltate.",
"endDoseChange": "Il periodo termina prima della prima variazione di dose, quindi descrive la dose iniziale.",
"confoundersTitle": "Altre variazioni di farmaci in questo periodo, che questa vista non può separare da questo:",
"confounderStarted": "{name} iniziato il {date}",
"confounderStopped": "{name} terminato il {date}",
"confounderDoseChange": "{name}: dose modificata il {date}",
"confounderPaused": "{name} sospeso il {date}",
"confounderResumed": "{name} ripreso il {date}",
"method": "Come si calcola: una retta viene adattata ai valori giornalieri prima dell'inizio e un'altra a quelli successivi. Il salto tra le due all'inizio è la variazione di livello, la differenza delle pendenze la variazione di tendenza. Gli intervalli tengono conto del fatto che i valori di un giorno influenzano il successivo.",
"needStart": "Imposta una data di inizio per vedere la variazione all'inizio.",
"needBefore": "La variazione all'inizio richiede valori in almeno {days} giorni distribuiti su {span} giorni prima dell'inizio.",
"needAfter": "La variazione all'inizio richiede valori in almeno {days} giorni distribuiti su {span} giorni dall'inizio, nei giorni con le dosi assunte.",
"fitFailed": "Non è stato possibile elaborare questi valori, quindi non viene mostrata alcuna variazione all'inizio.",
"autocorrelation": "I valori giornalieri dipendono molto dal giorno precedente, quindi anche questi intervalli possono essere troppo stretti."
}
},
"import": {
"resultSuccess": "{imported} assunzioni importate",
@@ -2679,7 +2746,20 @@
"ENV_PRESSURE_DELTA": "l'oscillazione di pressione nella giornata",
"ENV_PM25": "il particolato fine (PM2,5)",
"ENV_OZONE_8H": "l’ozono",
"ENV_POLLEN_MAX": "i pollini"
"ENV_POLLEN_MAX": "i pollini",
"HRV_RMSSD": "la variabilità della frequenza cardiaca (RMSSD)",
"HYDRATION": "l'assunzione di acqua",
"CAFFEINE": "l'assunzione di caffeina",
"ALCOHOL": "il consumo di alcol",
"WORKOUT_MINUTES": "i minuti di allenamento",
"TRAINING_LOAD": "il carico di allenamento",
"EXERCISE_MINUTES": "i minuti di esercizio",
"STAND_MINUTES": "i minuti in piedi",
"MINDFUL_MINUTES": "i minuti di consapevolezza",
"STATE_OF_MIND_VALENCE": "lo stato d'animo registrato",
"INDOOR_CO2": "la CO2 interna",
"INDOOR_TEMPERATURE": "la temperatura interna",
"INDOOR_HUMIDITY": "l'umidità interna"
},
"daily": {
"high": "Quando {behaviour} sale, {outcome} del giorno dopo tende a {direction} nei tuoi dati. È un andamento da osservare, non una causa.",
@@ -2707,6 +2787,12 @@
"high": "Più {behaviour} nello stesso giorno e in quello prima tende ad accompagnarsi a {direction} {outcome} nei tuoi dati: uno schema da osservare, non una causa.",
"moderate": "Più {behaviour} nello stesso giorno e in quello prima sembra accompagnarsi a {direction} {outcome} nei tuoi dati, per quanto visto finora: uno schema da osservare, non una causa.",
"faint": "Più {behaviour} nello stesso giorno e in quello prima mostra al massimo un debole indizio di {direction} {outcome} nei tuoi dati: troppo poco per contarci, mai una causa."
},
"channelNutrient": "l'assunzione di {nutrient}",
"dailyOvernight": {
"high": "Più {behaviour} durante la notte tende ad accompagnarsi a {direction} {outcome} quella stessa notte nei tuoi dati: uno schema da osservare, non una causa.",
"moderate": "Più {behaviour} durante la notte sembra accompagnarsi a {direction} {outcome} quella stessa notte nei tuoi dati, per quanto visto finora: uno schema da osservare, non una causa.",
"faint": "Più {behaviour} durante la notte mostra al massimo un debole indizio di {direction} {outcome} quella stessa notte nei tuoi dati: troppo poco per contarci, mai una causa."
}
},
"noAnalysisYet": "Ancora nessuna valutazione.",
@@ -3264,11 +3350,12 @@
"provider": "Risponde con",
"modelManaged": "Il modello di questo fornitore si imposta nelle impostazioni IA.",
"noProvider": "Nessun fornitore disponibile",
"allSettings": "Tutte le impostazioni IA",
"operatorCodex": "ChatGPT (condiviso dall'operatore)",
"changeModel": "Cambia in Impostazioni > IA",
"saving": "Salvataggio…",
"saveFailed": "Impossibile salvare la modifica.",
"allSettings": "Tutte le impostazioni IA",
"providerNotActive": "{provider} non è subentrato: manca un accesso o una chiave funzionante. Configuralo nelle impostazioni IA.",
"operatorCodex": "ChatGPT (condiviso dall'operatore)",
"operatorManaged": "Questa connessione è gestita dall'operatore del tuo server. Quando configuri uno dei tuoi fornitori, risponde per primo."
},
"answer": {
@@ -3565,6 +3652,7 @@
"falls": "cadute",
"years": "anni",
"breathingEvents": "eventi",
"standHours": "ore",
"respiratoryRate": "respiri/min"
},
"sleep": {
@@ -3807,6 +3895,16 @@
"editTitle": "Modifica allenamento",
"editAction": "Modifica",
"updated": "Allenamento aggiornato"
},
"load": {
"title": "Carico di allenamento",
"fitness": "Forma di base",
"fatigue": "Affaticamento",
"form": "Freschezza",
"explainer": "La forma di base è il tuo impulso di allenamento dalla frequenza cardiaca mediato su circa sei settimane, l'affaticamento su circa una settimana, e la freschezza è la differenza. I numeri si confrontano con il tuo passato, non con altre persone o altre app.",
"warmingUp": "Su {days} giorni finora: finché non sono coperte sei settimane, la forma di base risulta più bassa di quanto sia.",
"coverage": "{rated} degli ultimi {days} giorni sono stati valutati. Un giorno senza dati di attività, o con un allenamento che non è stato possibile valutare, viene escluso invece di contare come riposo.",
"asOf": "Al {date}, l'ultimo giorno che è stato possibile valutare."
}
},
"bloodGlucose": {
@@ -4248,7 +4346,12 @@
"outcomeDown": "Un umore migliore tende a coincidere con un {factor} più basso il giorno dopo nei tuoi dati.",
"stat": "{n} giorni appaiati, r = {r}, q = {q}",
"statLabel": "Come è stato calcolato",
"explainerLabel": "Come è stato calcolato"
"explainerLabel": "Come è stato calcolato",
"channel": {
"ALCOHOL": "consumo di alcol",
"WORKOUT_MINUTES": "minuti di allenamento",
"TRAINING_LOAD": "carico di allenamento"
}
},
"inTargetTitle": "Nel tuo intervallo",
"dimensions": {
@@ -4709,7 +4812,8 @@
"shiftDown": "{vital} si è spostato verso il basso rispetto alla tua norma recente.",
"rangeContext": "Ora {value}, di solito {low}–{high}.",
"rangeContextTimeOfDay": "Ora {value}, di solito {low}–{high} a quest'ora del giorno.",
"shiftContext": "Circa {before} prima, {after} da allora."
"shiftContext": "Circa {before} prima, {after} da allora.",
"shiftDays": "Su {before} giorni prima e {after} da allora."
},
"breathingScreening": {
"sectionTitle": "Respirazione durante il sonno",
@@ -5289,12 +5393,17 @@
"groups": {
"account": "Account",
"yourData": "I tuoi dati",
"display": "Visualizzazione",
"healthProfile": "Profilo sanitario",
"ai": "IA",
"connectivity": "Connettività",
"system": "Sistema"
}
"system": "Sistema",
"tracking": "Monitoraggio",
"arrangement": "Disposizione",
"notifications": "Notifiche",
"connections": "Connessioni",
"data": "Dati"
},
"allSections": "Tutte le sezioni"
},
"sharedRecord": {
"managedSettings": {
@@ -5406,8 +5515,15 @@
"unavailableTitle": "Non disponibile in questo record condiviso",
"unavailableDescription": "Questa area delle Impostazioni non è disponibile intenzionalmente mentre visualizzi il record di un’altra persona.",
"integrationStatusDescription": "Stato di connessione per questo record gestito.",
"integrationStatusDetail": "Un profilo gestito mostra i servizi collegati solo come stato. Chi lo gestisce non può collegare, ricollegare, sincronizzare o scollegare.",
"integrationStatusDocs": "Come funziona la condivisione"
"integrationStatusDetail": "I servizi che colleghi qui inviano i dati al profilo di {name}, non al tuo. Se il profilo non ha un'app propria del fornitore, usa la tua.",
"integrationStatusDocs": "Come funziona la condivisione",
"integrationConnectsFor": "Collega per {name}",
"integrationConnectFor": "Collega per {name}",
"integrationDisconnect": "Scollega",
"integrationDisconnectTitle": "Scollegare {provider} da {name}?",
"integrationDisconnectBody": "I nuovi dati smettono di arrivare. Quanto già importato resta nel profilo di {name}.",
"integrationNoClientApp": "Né {name} né il tuo account hanno ancora un'app {provider}. Configurane prima una nelle tue integrazioni.",
"integrationConnectFailed": "Impossibile avviare il collegamento a {provider}."
},
"sections": {
"account": {
@@ -5428,11 +5544,25 @@
"remindersHeading": "Promemoria",
"matrixLink": "Notifiche per canale",
"channelsLink": "Canali di recapito",
"displayHeading": "Sul telefono"
"displayHeading": "Sul telefono",
"elsewhere": {
"title": "Impostati dove appartengono",
"description": "Questi promemoria si impostano accanto a ciò che riguardano.",
"doses": {
"title": "Assunzioni di farmaci",
"description": "Orari e promemoria si impostano su ciascun farmaco.",
"link": "Apri i farmaci"
},
"cycle": {
"title": "Ciclo",
"description": "I promemoria del ciclo e della finestra fertile si attivano per canale nella griglia delle notifiche.",
"link": "Apri la griglia delle notifiche"
}
}
},
"layout": {
"title": "Layout",
"backToHub": "Torna a Layout",
"title": "Disposizione",
"backToHub": "Torna a Disposizione",
"dashboard": {
"title": "Cruscotto",
"description": "Layout e ordine dei riquadri."
@@ -5441,7 +5571,7 @@
"title": "Approfondimenti",
"description": "Ordine della panoramica e delle schede di navigazione."
},
"subtitle": "Scegli cosa mostrano dashboard, approfondimenti e moduli di monitoraggio, e in quale ordine.",
"subtitle": "Scegli ordine e vista della dashboard, delle analisi e degli elenchi di monitoraggio.",
"medications": {
"title": "Farmaci",
"description": "Vista elenco e ordine manuale."
@@ -5458,10 +5588,6 @@
"title": "Controlli",
"description": "Vista elenco e ordine dei promemoria."
},
"mood": {
"title": "Tag dell’umore",
"description": "Gruppi, tag e tag archiviati del selettore dell’umore."
},
"documents": {
"title": "Documenti",
"description": "Visualizzazione e disposizione dei mesi."
@@ -5852,7 +5978,28 @@
},
"manageIn": "Gestisci in {section}",
"operatorDisabled": "Disattivato a livello di server",
"subtitle": "Scegli quali aree di monitoraggio HealthLog mostra nell'app."
"subtitle": "Scegli quali aree di monitoraggio HealthLog mostra nell'app.",
"backToHub": "Torna a Moduli",
"openModuleSettings": "Impostazioni del modulo",
"pages": {
"medications": {
"title": "Farmaci",
"description": "Siti di iniezione esclusi dalla rotazione."
},
"mood": {
"title": "Tag dell’umore",
"description": "Gruppi, i tuoi tag e i tag archiviati."
},
"labs": {
"title": "Esami",
"description": "Biomarcatori e riconoscimento del testo sul dispositivo per i referti."
},
"arrangementTitle": "Ordine e vista",
"arrangementDescription": "Ordinamento e vista di questo elenco si impostano in Disposizione.",
"openArrangement": "Apri in Disposizione",
"moduleSettingsTitle": "Impostazioni del modulo",
"moduleSettingsDescription": "Tutto il resto è nella pagina del modulo, in Moduli."
}
},
"gesundheitsakte": {
"title": "Cartella clinica",
@@ -6499,7 +6646,8 @@
"nocode": "Withings non ha restituito alcun codice di autorizzazione. Riprova e consenti l'accesso nella finestra di Withings.",
"nocreds": "Non sono salvate credenziali API di Withings. Salva prima Client ID e Client Secret, poi ricollega.",
"token": "Lo scambio di token con Withings non è riuscito. Controlla Client ID e Client Secret e riprova.",
"generic": "Errore sconosciuto durante il collegamento. Avvia di nuovo il collegamento."
"generic": "Errore sconosciuto durante il collegamento. Avvia di nuovo il collegamento.",
"already_linked": "Questo account Withings è già collegato a un altro profilo HealthLog. Accedi a Withings con l'account di questo profilo oppure scollegalo prima dall'altro profilo."
},
"withingsSynchronize": "Sincronizza",
"withingsDisconnect": "Disconnetti",
@@ -6538,7 +6686,8 @@
"nocode": "WHOOP non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di WHOOP.",
"nocreds": "WHOOP non è configurato. Aggiungi le tue credenziali OAuth di WHOOP qui sopra e riprova.",
"token": "Lo scambio del token con WHOOP è fallito. Riavvia la connessione.",
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione.",
"already_linked": "Questo account WHOOP è già collegato a un altro profilo HealthLog. Accedi a WHOOP con l'account di questo profilo oppure scollegalo prima dall'altro profilo."
},
"fitbit": "Fitbit",
"fitbitTag": "Fitbit e Pixel",
@@ -6617,7 +6766,8 @@
"nocode": "Google non ha restituito un codice di autorizzazione. Riprova e consenti l'accesso nella finestra di Google.",
"nocreds": "Google Health non è configurato. Aggiungi qui sopra le tue credenziali OAuth di Google e riprova.",
"token": "Lo scambio del token con Google non è riuscito. Riavvia la connessione.",
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione.",
"already_linked": "Questo account Google è già collegato a un altro profilo HealthLog. Accedi a Google con l'account di questo profilo oppure scollegalo prima dall'altro profilo."
},
"fitbitOauthError": {
"csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.",
@@ -6628,7 +6778,8 @@
"nocreds": "Fitbit non è configurato. Aggiungi le tue credenziali OAuth di Fitbit qui sopra e riprova.",
"token": "Lo scambio del token con Fitbit è fallito. Riavvia la connessione.",
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione.",
"retired": "Google ha disattivato l'API web di Fitbit, quindi non è più possibile creare una nuova connessione Fitbit. Collega invece Google Health, che legge gli stessi dispositivi."
"retired": "Google ha disattivato l'API web di Fitbit, quindi non è più possibile creare una nuova connessione Fitbit. Collega invece Google Health, che legge gli stessi dispositivi.",
"already_linked": "Questo account Fitbit è già collegato a un altro profilo HealthLog. Accedi a Fitbit con l'account di questo profilo oppure scollegalo prima dall'altro profilo."
},
"integrations": {
"withings": {
@@ -6665,7 +6816,9 @@
"items": {
"vo2max": "VO2 max",
"cardio_age": "età cardiovascolare",
"resilience": "resilienza"
"resilience": "resilienza",
"workout": "allenamenti",
"heartrate": "frequenza cardiaca"
}
}
}
@@ -6972,7 +7125,8 @@
"nocreds": "Polar non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Polar.",
"token": "Lo scambio del token con Polar è fallito. Riavvia la connessione.",
"rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.",
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione.",
"already_linked": "Questo account Polar è già collegato a un altro profilo HealthLog. Accedi a Polar con l'account di questo profilo oppure scollegalo prima dall'altro profilo."
},
"oura": "Oura",
"ouraDescription": "Collega il tuo account Oura per sincronizzare prontezza, fasi del sonno, variabilità cardiaca e attività giornaliera.",
@@ -7002,25 +7156,24 @@
"token": "Lo scambio del token con Oura è fallito. Riavvia la connessione.",
"rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.",
"issuer": "La risposta di accesso non proveniva dal server di autorizzazione di Oura, quindi la connessione è stata interrotta per la tua sicurezza. Avvia di nuovo la connessione. Se succede ancora, avvisa il tuo amministratore.",
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione.",
"already_linked": "Questo account Oura è già collegato a un altro profilo HealthLog. Accedi a Oura con l'account di questo profilo oppure scollegalo prima dall'altro profilo."
},
"integrationSetupGuide": "Guida alla configurazione",
"coach": {
"activate": {
"title": "Attiva il Coach",
"description": "Mostra il pulsante e il pannello del Coach.",
"toggleAria": "Attiva il pulsante e il pannello del Coach",
"savedOn": "Coach attivato.",
"savedOff": "Coach nascosto.",
"saveError": "Impossibile salvare la modifica. Riprova."
},
"memory": {
"edit": "Modifica",
"source": {
"user": "da te",
"coach": "dal Coach"
}
}
},
"state": {
"title": "Coach",
"description": "Se il pulsante e il pannello del Coach sono visibili. L’interruttore è in Moduli."
},
"nudgesMoved": "Si impostano con gli altri promemoria in Notifiche.",
"openNudges": "Apri in Notifiche"
},
"integrationRedirectGuide": {
"intro": "Questa connessione passa dalla tua app sviluppatore {provider} (circa 5 minuti di configurazione):",
@@ -7155,6 +7308,12 @@
"profile": "Il tuo profilo, l'anamnesi e i dati della cartella sanitaria",
"modules": "Dati di ogni modulo: {list}"
},
"workoutRoutes": {
"title": "Percorsi degli allenamenti",
"description": "Conserva il tracciato GPS dei nuovi allenamenti dall’app per iPhone e dall’esportazione di Apple Salute.",
"detail": "Disattivato per i nuovi account. Un percorso mostra dove vivi, lavori e ti alleni, quindi viene conservato solo dopo averlo attivato. Gli allenamenti vengono conservati comunque e nessuna funzione IA legge mai un percorso. Disattivandolo si fermano i nuovi percorsi; quelli già conservati restano finché non elimini l’allenamento.",
"loadError": "Impossibile caricare l’impostazione dei percorsi."
},
"retention": {
"title": "Conservazione",
"description": "Per quanto tempo i record a tempo vengono conservati prima della pulizia automatica.",
@@ -7163,12 +7322,6 @@
"delivery": "Registro di invio delle notifiche: {days} giorni",
"unavailable": "I dettagli di conservazione non sono disponibili al momento."
},
"export": {
"title": "Scarica i miei dati",
"description": "I tuoi record in CSV o JSON, o una cartella completa per il medico.",
"openExport": "Esportazione e backup",
"openHealthRecord": "Fascicolo sanitario"
},
"delete": {
"title": "Elimina o reimposta",
"description": "Elimina completamente il tuo account oppure cancella i tuoi dati sanitari mantenendo l'account.",
@@ -7179,12 +7332,16 @@
"title": "Impostazione privacy",
"description": "Controlla quanti dei tuoi dati vengono condivisi con le funzioni di IA.",
"openAi": "Modalità privacy IA"
},
"sessions": {
"title": "Sessioni e dispositivi",
"description": "Dove hai effettuato l'accesso, i dispositivi che saltano il secondo fattore e l'attività di sicurezza del tuo account.",
"open": "Apri sicurezza"
}
},
"mcp": {
"enableTitle": "Endpoint MCP remoto",
"enableDescription": "Consenti a un assistente compatibile con MCP (Claude.ai, ChatGPT) di leggere i tuoi dati sulla salute tramite un token limitato.",
"enableToggleLabel": "Attiva l'endpoint MCP",
"endpointLabel": "URL del connettore",
"connectDescription": "Incolla questo URL nella configurazione del connettore personalizzato del tuo assistente e autorizza con il token qui sotto o accedi quando richiesto.",
"tokensTitle": "Token del connettore",
@@ -7203,7 +7360,8 @@
"connectionRevokeFailed": "Impossibile disconnettere l'assistente.",
"tokensDetail": "Il token viene mostrato una sola volta.",
"connectionsDetail": "Revocare una connessione ne termina subito l'accesso, inclusa la capacità di rinnovarsi.",
"tokenNamePlaceholder": "Nome, ad es. Claude Desktop"
"tokenNamePlaceholder": "Nome, ad es. Claude Desktop",
"offKeepsTokens": "Spegnerlo mette in pausa l’endpoint. Token e connessioni restano e tornano a funzionare quando lo riaccendi."
},
"strava": "Strava",
"stravaDescription": "Collega il tuo account Strava per sincronizzare le tue attività con la cronologia degli allenamenti.",
@@ -7232,7 +7390,8 @@
"nocreds": "Strava non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali API di Strava.",
"token": "Lo scambio del token con Strava non è riuscito. Riavvia la connessione.",
"rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.",
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione.",
"already_linked": "Questo account Strava è già collegato a un altro profilo HealthLog. Accedi a Strava con l'account di questo profilo oppure scollegalo prima dall'altro profilo."
},
"appleHealth": {
"title": "Apple Health",
@@ -7465,7 +7624,14 @@
"passkeyAddedToast": "Passkey aggiunta.",
"passkeyRemovedToast": "Passkey rimossa.",
"integrationDisconnected": "{provider} disconnesso.",
"integrationDisconnectFailed": "Impossibile disconnettere {provider}."
"integrationDisconnectFailed": "Impossibile disconnettere {provider}.",
"timezoneSaved": "Fuso orario salvato.",
"moduleState": {
"on": "Attivo",
"off": "Disattivo",
"change": "Cambia in Moduli"
},
"timezoneUseDevice": "Usa il fuso orario di questo dispositivo ({zone})"
},
"admin": {
"title": "Administration",
@@ -7600,6 +7766,7 @@
"heapLimitLabel": "Limite heap",
"heapLimitValue": "{mb} MB",
"heapLimitLow": "{mb} MB, poco: imposta APP_MEMORY_LIMIT=1536m in .env",
"memoryBelowFloor": "Container {mb} MB, sotto il minimo supportato di {floor} MB: imposta APP_MEMORY_LIMIT={floor}m in .env",
"offlineGeoOff": "Disattivato",
"offlineGeoChosen": "Online ({host})",
"apiGlobal": "Consentire endpoint API e token API.",
@@ -8855,6 +9022,10 @@
"detailedToast": "Le notifiche ora includono valori di salute.",
"genericToast": "Le notifiche non includono più valori di salute.",
"saveError": "Impossibile salvare. Riprova più tardi."
},
"checkupReminders": {
"title": "Promemoria dei controlli",
"description": "Attiva o disattiva i singoli promemoria dei controlli."
}
},
"i18n": {
@@ -9631,8 +9802,6 @@
"description": "Inserisci il valore; il promemoria viene segnato come fatto."
},
"manage": {
"title": "Gestisci promemoria",
"description": "Attiva o disattiva singoli promemoria.",
"empty": "Nessun promemoria da personalizzare."
},
"dashboard": {
@@ -10016,6 +10185,27 @@
"doneToastFew": "Unito in {name}, {count} misurazioni spostate",
"doneToastOther": "Unito in {name}, {count} misurazioni spostate",
"error": "Non è stato possibile unire i biomarcatori. Riprova."
},
"trend": {
"title": "Andamento",
"slope": {
"rising": "Sale di circa {amount} {unit} all'anno su {n} valori dal {since}.",
"falling": "Scende di circa {amount} {unit} all'anno su {n} valori dal {since}.",
"flat": "Nessuna variazione su {n} valori dal {since}."
},
"projection": {
"exits": {
"high": "Se questa retta proseguisse, raggiungerebbe il limite superiore del tuo intervallo di riferimento verso il {date}. È una proiezione, non una previsione.",
"low": "Se questa retta proseguisse, raggiungerebbe il limite inferiore del tuo intervallo di riferimento verso il {date}. È una proiezione, non una previsione."
},
"beyond_horizon": "A questo ritmo la retta resta nel tuo intervallo di riferimento per più di {years} anni, il periodo coperto dai tuoi valori.",
"beyond_horizon_capped": "A questo ritmo la retta resta nel tuo intervallo di riferimento per più di {years} anni, fin dove arriva la proiezione.",
"no_exit": "La retta si allontana dai limiti del tuo intervallo di riferimento.",
"already_outside": "La retta è già fuori dal tuo intervallo di riferimento.",
"no_clear_trend": "Su questi valori la variazione non si distingue chiaramente dall'assenza di variazione, quindi non si proietta nulla.",
"stale": "Il valore più recente è del {date}, più di un anno fa. La retta è mostrata a quella data, senza proiezione."
},
"interval": "Intervallo al 95 % della variazione annua: da {lo} a {hi} {unit}."
}
},
"modules": {
@@ -10957,7 +11147,16 @@
"openDetail": "Mostra dettagli",
"historyLoadError": "Non è stato possibile caricare la cronologia dei test",
"historyLoadErrorHint": "Questo non significa che non ci siano risultati: non è stato possibile recuperare l'elenco. Riprova.",
"privacyNote": "Le tue risposte sono salvate cifrate e non raggiungono mai il Coach IA."
"privacyNote": "Le tue risposte sono salvate cifrate e non raggiungono mai il Coach IA.",
"trend": {
"sincePrevious": "Dal tuo check-in precedente del {date}: {change} punti.",
"sinceFirst": "Dal tuo primo check-in del {date}: {change} punti.",
"verdict": {
"improved": "Raggiunge la variazione di {threshold} punti che gli studi considerano rilevante, nella direzione migliore.",
"worsened": "Raggiunge la variazione di {threshold} punti che gli studi considerano rilevante, nella direzione meno buona.",
"within_threshold": "È meno della variazione di {threshold} punti che gli studi considerano rilevante."
}
}
},
"records": {
"aiInclusion": {
@@ -12216,7 +12415,10 @@
"leafVisits": "Visite mediche",
"leafSurgicalHistory": "Anamnesi chirurgica",
"leafImmunizations": "Vaccinazioni",
"leafEmergency": "Informazioni di emergenza (gruppo sanguigno, contatti, disposizioni)"
"leafEmergency": "Informazioni di emergenza (gruppo sanguigno, contatti, disposizioni)",
"leafMedicationEffect": "Variazione intorno all'inizio di ogni farmaco (richiede l'elenco dei farmaci)",
"leafWorkouts": "Allenamenti (riepilogo per sport)",
"leafHrvSdnn": "HRV (SDNN)"
},
"recordSharing": {
"banner": {
@@ -13396,5 +13598,218 @@
"clinicalUpper": "Limite clinico superiore",
"clinicalLower": "Limite clinico inferiore"
}
},
"activityCalendar": {
"title": "Calendario attività",
"periodLabel": "Periodo del calendario",
"period": {
"week": "Settimana",
"month": "Mese"
},
"weekRange": "dal {start} al {end}",
"previous": "Periodo precedente",
"next": "Periodo successivo",
"metricLabel": "Colora i giorni per",
"metric": {
"steps": "Passi",
"activeMinutes": "Minuti attivi",
"load": "Carico di allenamento"
},
"empty": "Ancora nessun passo, minuto attivo o allenamento in questo periodo.",
"loadError": "Impossibile caricare il calendario attività.",
"weekGoalsColumn": "Obiettivi settimanali",
"weekGoalsMet": "Tutti gli obiettivi settimanali raggiunti",
"weekGoalsNotMet": "Non tutti gli obiettivi settimanali raggiunti",
"cell": {
"steps": "{value} passi",
"activeMinutes": "{value} minuti attivi",
"workouts": "{value} allenamenti: {sports}"
},
"level": {
"none": "Nessun dato",
"unrated": "Storico ancora insufficiente",
"low": "Molto sotto il tuo solito",
"below": "Sotto il tuo solito",
"usual": "Come al solito",
"above": "Sopra il tuo solito",
"high": "Molto sopra il tuo solito",
"in_progress": "Oggi, ancora in corso"
},
"legend": "Rispetto alle tue 12 settimane precedenti:",
"noBaseline": "Il tuo intervallo abituale compare dopo 14 giorni con dati.",
"summary": {
"steps": "{value} passi",
"activeMinutes": "{value} minuti attivi",
"workouts": "{value} allenamenti",
"load": "Carico di allenamento medio {value}",
"usual": "Il tuo solito fino a questo punto: {value}",
"noUsual": "Non ci sono ancora abbastanza settimane o mesi precedenti per un confronto.",
"usualThroughYesterday": "Fino a ieri {value}, il tuo solito fino a quel punto {usual}",
"firstDay": "Il confronto con il tuo solito arriva quando il primo giorno è finito."
},
"sportCount": "{sport}: {count}",
"goal": {
"workoutsPerWeek": "Allenamenti a settimana",
"activeMinutesPerWeek": "Minuti attivi a settimana",
"stepsPerWeek": "Passi a settimana",
"stepsPerMonth": "Passi al mese"
},
"goalOf": "{value} di {target}",
"setGoals": "Imposta obiettivi",
"editGoals": "Modifica obiettivi",
"goalsSheet": {
"title": "Obiettivi di attività",
"description": "Lascia vuoto un campo per non avere un obiettivo.",
"none": "Nessun obiettivo",
"invalid": "Un numero intero da {min} a {max}.",
"saveError": "Impossibile salvare gli obiettivi."
},
"crossLink": {
"title": "Calendario attività",
"body": "I tuoi passi per settimana o per mese, rispetto al tuo solito.",
"cta": "Apri il calendario"
}
},
"sourceProvenance": {
"title": "Da dove vengono i valori",
"description": "Quale fonte ha fornito un valore ogni giorno e quale usa la tua priorità delle fonti.",
"metric": "Misura",
"day": "Giorno",
"dayTitle": "Fonti di questo giorno",
"loadError": "Impossibile caricare le fonti.",
"dayEmpty": "Nessun valore di questa misura in questo giorno.",
"reason": {
"only_source": "{source} è l'unica fonte di questo giorno.",
"highest_on_ladder": "Viene usata {source}: tra le fonti di questo giorno è la più alta nella tua priorità delle fonti.",
"name_tiebreak": "Viene usata {source}: nessuna fonte di questo giorno è nella tua priorità, quindi ha deciso il nome.",
"not_ranked": "Questa misura non ha una priorità delle fonti: contano i valori di tutte le fonti."
},
"pass": {
"lower_on_ladder": "Più in basso nella tua priorità delle fonti.",
"not_on_ladder": "Non è nella tua priorità delle fonti per questa misura.",
"name_tiebreak": "Stesso rango della fonte usata; ha deciso il nome."
},
"used": "Usata",
"readings": "{count} misurazioni, dispositivi: {devices}",
"ladder": "Il tuo ordine per questa misura: {ladder}",
"utcNote": "La fonte viene scelta per giorno, da mezzanotte a mezzanotte UTC, come nei grafici.",
"openSettings": "Cambia l'ordine in Priorità delle fonti",
"compareTitle": "Confronta due fonti",
"range": "Periodo",
"rangeDays": "{days} giorni",
"compareNeedsTwo": "In questo periodo solo una fonte ha valori di questa misura.",
"side": {
"a": "Prima fonte",
"b": "Seconda fonte"
},
"daysBoth": "Giorni con entrambe",
"meanOf": "Media di {source}",
"meanAbsDiff": "Differenza media",
"pickedDays": "Giorni usati (prima / seconda)",
"pickedSplit": "{a} / {b}",
"compareNote": "Le medie contano solo i giorni in cui entrambe le fonti hanno valori, per confrontare cose confrontabili. In grassetto il valore usato dai grafici.",
"settingsHint": "Per vedere cosa decide questo ordine giorno per giorno:",
"settingsHintLink": "Da dove vengono i valori",
"metricLink": "Da dove vengono questi valori"
},
"recordImport": {
"title": "Esami di laboratorio e farmaci da un foglio di calcolo",
"description": "Porta dentro anni di esami o un elenco di farmaci da un file CSV.",
"detail": "Scegli tu quale colonna contiene cosa; HealthLog controlla ogni riga prima e non importa nulla finché non confermi. Le righe già presenti vengono saltate.",
"kind": {
"labs": "Esami di laboratorio",
"medications": "Farmaci"
},
"sheetTitle": {
"labs": "Importa esami",
"medications": "Importa farmaci"
},
"sheetDescription": {
"labs": "I marcatori vengono abbinati ai tuoi, le unità convertite quando la conversione è nota.",
"medications": "Nome, dose, orari giornalieri, al bisogno, inizio e fine. Gli altri ritmi si impostano poi nell'app."
},
"file": "File CSV",
"template": "Scarica il modello",
"mapping": "Quale colonna contiene cosa",
"required": "(obbligatorio)",
"notInFile": "Non nel file",
"column": "Colonna {n}",
"delimiter": "Separatore",
"delimiterOption": {
"comma": "Virgola",
"semicolon": "Punto e virgola",
"tab": "Tabulazione"
},
"decimal": "Separatore decimale",
"decimalOption": {
"dot": "Punto (5.4)",
"comma": "Virgola (5,4)"
},
"dateOrder": "Formato data",
"dateOrderOption": {
"iso": "2025-03-01",
"dmy": "01.03.2025 (prima il giorno)",
"mdy": "03/01/2025 (prima il mese)"
},
"header": "La prima riga contiene i nomi delle colonne",
"reminders": "Attiva i promemoria per i farmaci importati",
"timezoneNote": "Gli orari senza fuso orario vengono letti nel fuso del tuo account. Una data senza orario resta quel giorno del calendario.",
"missing": "Ancora da scegliere: {fields}",
"check": "Controlla il file",
"runIdle": "Importa",
"run": "Importa {count} righe",
"close": "Chiudi",
"summaryCheck": "{rows} righe lette: {inserted} importabili, {duplicates} già presenti, {failed} con un problema.",
"summaryDone": "{inserted} righe importate. Saltate: {duplicates} già presenti e {failed} con un problema.",
"markersMatched": "Abbinati ai tuoi marcatori:",
"markersCreated": "Nuovi marcatori:",
"line": "Riga {line}",
"errorsTruncated": "Sono elencati solo i primi 500 problemi.",
"error": {
"tooLarge": "Il file supera i 16 MB.",
"rateLimited": "Troppe importazioni nell'ultima ora. Riprova più tardi.",
"invalid": "Impossibile leggere il file o la scelta delle colonne.",
"failed": "L'importazione non è riuscita. Riprova."
},
"reason": {
"missing_value": "Una cella obbligatoria è vuota",
"invalid_number": "Non è un numero",
"ambiguous_number": "Il numero si legge in due modi; controlla il separatore decimale",
"missing_unit": "Un numero ha bisogno di un'unità",
"invalid_date": "Non è una data nel formato scelto",
"invalid_time": "Non è un orario",
"ambiguous_date": "La data si legge in due modi; usa un anno a quattro cifre e il formato scelto",
"future_date": "La data è nel futuro",
"text_too_long": "Il testo è troppo lungo",
"unit_mismatch": "Questa unità non si converte in quella del marcatore",
"invalid_reference_range": "L'intervallo di riferimento non è leggibile",
"invalid_times": "Gli orari non sono leggibili (es. 08:00 20:00)",
"missing_times": "Aggiungi gli orari di assunzione o segna il farmaco al bisogno",
"invalid_yes_no": "Usa sì o no",
"as_needed_with_times": "Un farmaco al bisogno non ha orari fissi",
"end_before_start": "La fine precede l'inizio",
"invalid_medication": "Il farmaco non si può salvare così",
"oversized_row": "La riga è troppo lunga o troppo larga",
"row_limit": "Oltre il limite di righe per importazione",
"duplicate": "Già presente, saltata"
},
"field": {
"analyte": "Marcatore",
"value": "Risultato",
"unit": "Unità",
"date": "Data",
"time": "Ora",
"referenceRange": "Intervallo di riferimento",
"referenceLow": "Riferimento minimo",
"referenceHigh": "Riferimento massimo",
"panel": "Gruppo",
"note": "Nota",
"name": "Nome",
"dose": "Dose",
"times": "Orari",
"asNeeded": "Al bisogno",
"startsOn": "Inizio",
"endsOn": "Fine"
}
}
}
+466 -51
View File
@@ -401,7 +401,41 @@
"lifeSittingValue": "{hours}시간, 본인 응답",
"lifeWork": "일",
"lifeNightWork": "야간 근무",
"lifeExposures": "노출: {list}"
"lifeExposures": "노출: {list}",
"workouts": {
"title": "운동",
"summary": "총 {sessions}회, {hours}시간; 주당 평균 {perWeek}회, {minutesPerWeek}분.",
"colSport": "종목",
"colSessions": "횟수",
"colTime": "시간",
"colDistance": "거리",
"colEnergy": "에너지",
"colHeartRate": "평균 심박수",
"moreSports": "{count}개 종목이 합계에는 포함되지만 목록에는 없습니다."
},
"medicationEffect": {
"title": "약 시작 전후의 변화",
"colMedication": "약 / 측정값",
"colStart": "시작",
"colLevel": "수준 변화(95% CI)",
"colSlope": "추세 변화(95% CI)",
"colDays": "전 / 후 일수",
"to": "~",
"week": "주",
"reasonNoStart": "기록된 시작일 없음",
"reasonBefore": "시작 전 측정값이 너무 적음",
"reasonAfter": "시작 후 측정값이 너무 적음",
"reasonFit": "계산할 수 없음",
"excludedAdherence": "시작 후 {count}일 제외(복용률 {floor}% 미만)",
"excludedNotTaking": "{count}일 제외(일시 중지 또는 복용 기간 사이)",
"notTracked": "복용 미기록으로 놓친 용량에 따른 제외 없음",
"endDoseChange": "기간은 첫 용량 변경 전인 {date}에 끝남",
"days": "시작 전 {before}일, 시작 후 {after}일, 제외 {excluded}일",
"method": "시작 90일 전부터 최대 180일 후까지의 일별 값에 대한 분절 회귀, Newey-West 구간. 직전 14일 복용률이 80% 미만인 시작 후 날짜는 제외했습니다. 시간에 따른 연관성을 보여 줄 뿐, 약의 효과를 입증하지 않습니다.",
"autocorrelation": "전날 값에 크게 좌우되므로 범위가 여전히 너무 좁을 수 있음",
"window": "{from}부터 {to}까지 분석, 보고 기간을 넘어설 수 있음",
"windowLine": "{from}부터 {to}까지 분석했습니다. 이 기간은 보고 기간을 넘어설 수 있습니다."
}
},
"errorBoundary": {
"title": "문제가 생겼어요",
@@ -911,6 +945,15 @@
"unitRating": "단계",
"typeWho5Score": "WHO-5 점수",
"typeSciScore": "SCI 점수",
"typeExerciseMinutes": "운동 시간(분)",
"typeStandMinutes": "서 있기 시간(분)",
"typeStandHours": "일어서기 시간",
"typeMindfulMinutes": "마음 챙기기 시간(분)",
"typeStateOfMindValence": "마음 상태",
"typeAfibBurden": "심방세동 부담",
"typeIndoorCo2": "실내 CO₂",
"typeIndoorTemperature": "실내 온도",
"typeIndoorHumidity": "실내 습도",
"durationReadAs": "{duration}(으)로 읽었어요",
"durationHoursMinutes": "{hours}시간 {minutes}분",
"durationHoursOnly": "{hours}시간",
@@ -2350,7 +2393,31 @@
},
"scaleAbsolute": "절대값",
"scalePercentSinceStart": "시작 이후 %",
"scaleToggleLabel": "차트 눈금"
"scaleToggleLabel": "차트 눈금",
"segmented": {
"title": "시작 시점의 변화",
"level": "시작 직후 수준이 {value} {unit} 이동했습니다(95% 범위 {low}~{high}).",
"slope": "시작 후 주간 추세가 주당 {value} {unit} 변했습니다(95% 범위 {low}~{high}).",
"preSlope": "시작 전 추세는 주당 {value} {unit}였습니다.",
"includesZero": "범위에 0이 포함되면 이 측정값은 변화가 없다는 것과도 일치합니다.",
"basis": "시작 전 {before}일과 시작 후 {after}일 기준({from}~{to}).",
"excludedAdherence": "직전 {lookback}일 동안 복용한 용량이 {floor}% 미만이어서 시작 후 {count}일을 제외했습니다.",
"excludedNotTaking": "약을 일시 중지했거나 복용 기간 사이였던 {count}일을 제외했습니다.",
"notTracked": "이 약은 복용을 기록하지 않으므로 놓친 용량 때문에 제외한 날이 없습니다.",
"endDoseChange": "기간이 첫 용량 변경 전에 끝나므로 시작 용량을 나타냅니다.",
"confoundersTitle": "이 기간의 다른 약 변경 사항으로, 이 화면에서는 이 약과 구분할 수 없습니다.",
"confounderStarted": "{name} {date} 시작",
"confounderStopped": "{name} {date} 중단",
"confounderDoseChange": "{name}: {date} 용량 변경",
"confounderPaused": "{name} {date} 일시 중지",
"confounderResumed": "{name} {date} 재개",
"method": "계산 방법: 시작 전 일별 값에 직선 하나를, 시작 후 값에 또 하나를 맞춥니다. 시작 시점의 두 직선 사이 간격이 수준 변화이고, 기울기 차이가 추세 변화입니다. 범위는 하루의 값이 다음 날로 이어지는 점을 반영합니다.",
"needStart": "시작 시점의 변화를 보려면 시작일을 설정하세요.",
"needBefore": "시작 시점의 변화를 보려면 시작 전 {span}일에 걸쳐 최소 {days}일의 측정값이 필요합니다.",
"needAfter": "시작 시점의 변화를 보려면 시작 후 {span}일에 걸쳐, 용량을 복용한 날 기준으로 최소 {days}일의 측정값이 필요합니다.",
"fitFailed": "이 측정값으로는 계산할 수 없어 시작 시점의 변화를 표시하지 않습니다.",
"autocorrelation": "하루 값이 전날 값에 크게 좌우되므로 이 범위도 너무 좁을 수 있습니다."
}
},
"import": {
"resultSuccess": "복용 {imported}건을 불러왔어요",
@@ -2679,7 +2746,20 @@
"ENV_PRESSURE_DELTA": "하루 중 기압 변화",
"ENV_PM25": "초미세먼지(PM2.5)",
"ENV_OZONE_8H": "오존",
"ENV_POLLEN_MAX": "꽃가루"
"ENV_POLLEN_MAX": "꽃가루",
"HRV_RMSSD": "심박 변이도(RMSSD)",
"HYDRATION": "수분 섭취량",
"CAFFEINE": "카페인 섭취량",
"ALCOHOL": "음주",
"WORKOUT_MINUTES": "운동 시간",
"TRAINING_LOAD": "훈련 부하",
"EXERCISE_MINUTES": "운동하기 시간",
"STAND_MINUTES": "서 있은 시간",
"MINDFUL_MINUTES": "마음챙김 시간",
"STATE_OF_MIND_VALENCE": "기록한 마음 상태",
"INDOOR_CO2": "실내 CO2",
"INDOOR_TEMPERATURE": "실내 온도",
"INDOOR_HUMIDITY": "실내 습도"
},
"daily": {
"high": "{behaviour} 수치가 높은 날에는 다음 날 {outcome} 수치가 {direction} 경향이 있어요. 원인이 아니라 지켜볼 만한 패턴이에요.",
@@ -2707,6 +2787,12 @@
"high": "당일과 전날의 {behaviour}이(가) 높을수록 내 데이터에서는 {outcome}이(가) {direction} 경향이 있습니다. 지켜볼 만한 패턴이지 원인은 아닙니다.",
"moderate": "지금까지의 데이터로 보면 당일과 전날의 {behaviour}이(가) 높을수록 {outcome}이(가) {direction} 것으로 보입니다. 지켜볼 만한 패턴이지 원인은 아닙니다.",
"faint": "당일과 전날의 {behaviour}이(가) 높을 때 내 데이터에서 {outcome}이(가) {direction} 기미가 아주 약하게 보일 뿐입니다. 의지하기엔 너무 작고, 원인은 아닙니다."
},
"channelNutrient": "{nutrient} 섭취량",
"dailyOvernight": {
"high": "밤사이 {behaviour}이(가) 높을수록 내 데이터에서는 그날 밤 {outcome}이(가) {direction} 경향이 있습니다. 지켜볼 만한 패턴이지 원인은 아닙니다.",
"moderate": "지금까지의 데이터로 보면 밤사이 {behaviour}이(가) 높을수록 그날 밤 {outcome}이(가) {direction} 것으로 보입니다. 지켜볼 만한 패턴이지 원인은 아닙니다.",
"faint": "밤사이 {behaviour}이(가) 높을 때 내 데이터에서 그날 밤 {outcome}이(가) {direction} 기미가 아주 약하게 보일 뿐입니다. 의지하기엔 너무 작고, 원인은 아닙니다."
}
},
"noAnalysisYet": "아직 평가가 없어요.",
@@ -3264,11 +3350,12 @@
"provider": "응답 제공자",
"modelManaged": "이 제공자의 모델은 AI 설정에서 정해요.",
"noProvider": "사용할 수 있는 제공자가 없어요",
"allSettings": "모든 AI 설정",
"operatorCodex": "ChatGPT (운영자 공유)",
"changeModel": "설정 > AI에서 변경",
"saving": "저장 중…",
"saveFailed": "변경 사항을 저장하지 못했어요.",
"allSettings": "모든 AI 설정",
"providerNotActive": "{provider}(으)로 전환하지 못했습니다. 작동하는 로그인이나 키가 없습니다. AI 설정에서 설정하세요.",
"operatorCodex": "ChatGPT (운영자 공유)",
"operatorManaged": "이 연결은 서버 운영자가 관리합니다. 내 공급자를 하나라도 설정하면 그 공급자가 먼저 답합니다."
},
"answer": {
@@ -3565,6 +3652,7 @@
"falls": "회",
"years": "세",
"breathingEvents": "회",
"standHours": "시간",
"respiratoryRate": "회/분"
},
"sleep": {
@@ -3807,6 +3895,16 @@
"editTitle": "운동 편집",
"editAction": "편집",
"updated": "운동을 업데이트했습니다"
},
"load": {
"title": "훈련 부하",
"fitness": "체력",
"fatigue": "피로",
"form": "컨디션",
"explainer": "체력은 심박 기반 훈련 자극을 약 6주 동안, 피로는 약 1주 동안 평균한 값이고, 컨디션은 그 차이입니다. 이 숫자는 다른 사람이나 다른 앱이 아니라 내 과거와 비교하는 값입니다.",
"warmingUp": "지금까지 {days}일 기준: 6주가 채워질 때까지는 체력이 실제보다 낮게 나옵니다.",
"coverage": "최근 {days}일 중 {rated}일을 평가할 수 있었습니다. 활동 데이터가 없는 날이나 평가할 수 없는 운동이 있는 날은 휴식일로 세지 않고 제외합니다.",
"asOf": "{date} 기준, 평가할 수 있었던 마지막 날입니다."
}
},
"bloodGlucose": {
@@ -4248,7 +4346,12 @@
"outcomeDown": "기분이 좋으면 다음 날 {factor} 값은 낮은 편이에요.",
"stat": "짝지은 {n}일, r = {r}, q = {q}",
"statLabel": "계산 방법",
"explainerLabel": "계산 방법"
"explainerLabel": "계산 방법",
"channel": {
"ALCOHOL": "음주",
"WORKOUT_MINUTES": "운동 시간",
"TRAINING_LOAD": "훈련 부하"
}
},
"inTargetTitle": "내 범위 안",
"dimensions": {
@@ -4709,7 +4812,8 @@
"shiftDown": "{vital}: 최근 평소 수준보다 내려갔어요.",
"rangeContext": "지금 {value}, 평소 {low}–{high}.",
"rangeContextTimeOfDay": "지금 {value}, 이 시간대 평소 {low}–{high}.",
"shiftContext": "이전엔 약 {before}, 이후엔 {after}."
"shiftContext": "이전엔 약 {before}, 이후엔 {after}.",
"shiftDays": "이전 {before}일과 이후 {after}일 기준입니다."
},
"breathingScreening": {
"sectionTitle": "수면 중 호흡",
@@ -5289,12 +5393,17 @@
"groups": {
"account": "계정",
"yourData": "내 데이터",
"display": "화면",
"healthProfile": "건강 프로필",
"ai": "AI",
"connectivity": "연결",
"system": "시스템"
}
"system": "시스템",
"tracking": "기록",
"arrangement": "배치",
"notifications": "알림",
"connections": "연결",
"data": "데이터"
},
"allSections": "모든 항목"
},
"sharedRecord": {
"managedSettings": {
@@ -5406,8 +5515,15 @@
"unavailableTitle": "이 공유 기록에서는 사용할 수 없어요",
"unavailableDescription": "다른 사람의 기록을 보는 동안에는 이 설정 영역을 사용할 수 없어요.",
"integrationStatusDescription": "이 관리 기록의 연결 상태예요.",
"integrationStatusDetail": "관리 프로필은 연결된 서비스를 상태로만 보여 줘요. 보호자는 연결, 다시 연결, 동기화, 연결 해제를 할 수 없어요.",
"integrationStatusDocs": "공유 방식 알아보기"
"integrationStatusDetail": "여기서 연결한 서비스의 데이터는 내 기록이 아니라 {name}의 기록으로 들어가요. 프로필에 자체 앱이 없으면 내 앱을 사용해요.",
"integrationStatusDocs": "공유 방식 알아보기",
"integrationConnectsFor": "{name}을(를) 위해 연결",
"integrationConnectFor": "{name}을(를) 위해 연결하기",
"integrationDisconnect": "연결 해제",
"integrationDisconnectTitle": "{name}에서 {provider} 연결을 해제할까요?",
"integrationDisconnectBody": "새 데이터가 더 이상 들어오지 않아요. 이미 가져온 데이터는 {name}의 기록에 남아요.",
"integrationNoClientApp": "{name}과(와) 내 계정 모두 아직 {provider} 앱이 없어요. 먼저 내 통합 설정에서 앱을 설정해 주세요.",
"integrationConnectFailed": "{provider} 연결을 시작하지 못했어요."
},
"sections": {
"account": {
@@ -5428,11 +5544,25 @@
"remindersHeading": "알림",
"matrixLink": "채널별 알림",
"channelsLink": "전송 채널",
"displayHeading": "휴대폰에서"
"displayHeading": "휴대폰에서",
"elsewhere": {
"title": "해당 위치에서 설정",
"description": "이 리마인더는 관련 항목 옆에서 설정합니다.",
"doses": {
"title": "약 복용",
"description": "복용 시간과 리마인더는 각 약에서 설정합니다.",
"link": "약 열기"
},
"cycle": {
"title": "주기",
"description": "생리와 가임기 리마인더는 알림 표에서 채널별로 켭니다.",
"link": "알림 표 열기"
}
}
},
"layout": {
"title": "화면 구성",
"backToHub": "화면 구성으로 돌아가기",
"title": "배치",
"backToHub": "배치로 돌아가기",
"dashboard": {
"title": "대시보드",
"description": "타일 배치와 순서."
@@ -5441,7 +5571,7 @@
"title": "인사이트",
"description": "개요 섹션과 내비게이션 칩 순서."
},
"subtitle": "대시보드, 인사이트, 기록 모듈에 무엇을 어떤 순서로 보여줄지 정해요.",
"subtitle": "대시보드, 인사이트, 기록 목록의 순서와 보기를 정하세요.",
"medications": {
"title": "복약",
"description": "목록 보기와 수동 순서."
@@ -5458,10 +5588,6 @@
"title": "건강검진",
"description": "목록 보기와 알림 순서."
},
"mood": {
"title": "기분 태그",
"description": "기분 선택기의 그룹, 태그, 보관된 태그."
},
"documents": {
"title": "문서",
"description": "보기 방식과 월 배치."
@@ -5852,7 +5978,28 @@
},
"manageIn": "{section}에서 관리",
"operatorDisabled": "서버 전체에서 꺼짐",
"subtitle": "HealthLog가 앱 전체에서 보여 줄 기록 영역을 골라요."
"subtitle": "HealthLog가 앱 전체에서 보여 줄 기록 영역을 골라요.",
"backToHub": "모듈로 돌아가기",
"openModuleSettings": "모듈 설정",
"pages": {
"medications": {
"title": "약",
"description": "순환에서 제외할 주사 부위입니다."
},
"mood": {
"title": "기분 태그",
"description": "그룹, 직접 만든 태그, 보관된 태그입니다."
},
"labs": {
"title": "검사",
"description": "바이오마커와 검사 보고서용 기기 내 텍스트 인식입니다."
},
"arrangementTitle": "순서와 보기",
"arrangementDescription": "이 목록의 정렬과 보기는 배치에서 설정합니다.",
"openArrangement": "배치에서 열기",
"moduleSettingsTitle": "모듈 설정",
"moduleSettingsDescription": "순서와 보기 외의 설정은 모듈의 페이지에 있습니다."
}
},
"gesundheitsakte": {
"title": "건강 기록",
@@ -6499,7 +6646,8 @@
"nocode": "Withings가 인증 코드를 보내지 않았어요. 다시 시도하고 Withings 창에서 접근을 허용해 주세요.",
"nocreds": "저장된 Withings API 자격 증명이 없어요. 클라이언트 ID와 클라이언트 시크릿을 먼저 저장한 뒤 다시 연결해 주세요.",
"token": "Withings와의 토큰 교환에 실패했어요. 클라이언트 ID와 클라이언트 시크릿을 확인하고 다시 시도해 주세요.",
"generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요."
"generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요.",
"already_linked": "이 Withings 계정은 이미 다른 HealthLog 프로필에 연결되어 있어요. 이 프로필의 Withings 계정으로 로그인하거나, 먼저 다른 프로필에서 연결을 해제해 주세요."
},
"withingsSynchronize": "동기화",
"withingsDisconnect": "연결 해제",
@@ -6538,7 +6686,8 @@
"nocode": "WHOOP이 인증 코드를 보내지 않았어요. 다시 시도하고 WHOOP 창에서 접근을 허용해 주세요.",
"nocreds": "WHOOP이 설정되지 않았어요. 위에 WHOOP OAuth 자격 증명을 추가하고 다시 시도해 주세요.",
"token": "WHOOP과의 토큰 교환에 실패했어요. 연결을 다시 시작해 주세요.",
"generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요."
"generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요.",
"already_linked": "이 WHOOP 계정은 이미 다른 HealthLog 프로필에 연결되어 있어요. 이 프로필의 WHOOP 계정으로 로그인하거나, 먼저 다른 프로필에서 연결을 해제해 주세요."
},
"fitbit": "Fitbit",
"fitbitTag": "Fitbit & Pixel",
@@ -6617,7 +6766,8 @@
"nocode": "Google이 인증 코드를 보내지 않았어요. 다시 시도하고 Google 창에서 접근을 허용해 주세요.",
"nocreds": "Google Health가 설정되지 않았어요. 위에 Google OAuth 자격 증명을 추가하고 다시 시도해 주세요.",
"token": "Google과의 토큰 교환에 실패했어요. 연결을 다시 시작해 주세요.",
"generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요."
"generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요.",
"already_linked": "이 Google 계정은 이미 다른 HealthLog 프로필에 연결되어 있어요. 이 프로필의 Google 계정으로 로그인하거나, 먼저 다른 프로필에서 연결을 해제해 주세요."
},
"fitbitOauthError": {
"csrf1": "브라우저 쿠키가 없어서 보안 확인에 실패했어요. 같은 브라우저에서 연결을 다시 시작해 주세요.",
@@ -6628,7 +6778,8 @@
"nocreds": "Fitbit이 설정되지 않았어요. 위에 Fitbit OAuth 자격 증명을 추가하고 다시 시도해 주세요.",
"token": "Fitbit과의 토큰 교환에 실패했어요. 연결을 다시 시작해 주세요.",
"generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요.",
"retired": "Google이 Fitbit Web API를 종료해서 새 Fitbit 연결을 만들 수 없어요. 같은 기기의 데이터를 읽는 Google Health를 대신 연결해 주세요."
"retired": "Google이 Fitbit Web API를 종료해서 새 Fitbit 연결을 만들 수 없어요. 같은 기기의 데이터를 읽는 Google Health를 대신 연결해 주세요.",
"already_linked": "이 Fitbit 계정은 이미 다른 HealthLog 프로필에 연결되어 있어요. 이 프로필의 Fitbit 계정으로 로그인하거나, 먼저 다른 프로필에서 연결을 해제해 주세요."
},
"integrations": {
"withings": {
@@ -6665,7 +6816,9 @@
"items": {
"vo2max": "VO2 max",
"cardio_age": "심혈관 나이",
"resilience": "회복탄력성"
"resilience": "회복탄력성",
"workout": "운동",
"heartrate": "심박수"
}
}
}
@@ -6972,7 +7125,8 @@
"nocreds": "이 서버에는 Polar가 설정돼 있지 않아요. 관리자에게 Polar OAuth 자격 증명을 추가해 달라고 요청하세요.",
"token": "Polar와의 토큰 교환에 실패했어요. 연결을 다시 시작해 주세요.",
"rate_limited": "연결 시도가 너무 많아요. 잠시 후 다시 시도해 주세요.",
"generic": "연결 중 알 수 없는 오류가 났어요. 연결을 다시 시작해 주세요."
"generic": "연결 중 알 수 없는 오류가 났어요. 연결을 다시 시작해 주세요.",
"already_linked": "이 Polar 계정은 이미 다른 HealthLog 프로필에 연결되어 있어요. 이 프로필의 Polar 계정으로 로그인하거나, 먼저 다른 프로필에서 연결을 해제해 주세요."
},
"oura": "Oura",
"ouraDescription": "Oura 계정을 연결해 준비도, 수면 단계, 심박변이도, 일일 활동을 동기화해요.",
@@ -7002,25 +7156,24 @@
"token": "Oura와의 토큰 교환에 실패했어요. 연결을 다시 시작해 주세요.",
"rate_limited": "연결 시도가 너무 많아요. 잠시 후 다시 시도해 주세요.",
"issuer": "로그인 응답이 Oura의 인증 서버에서 오지 않아서 안전을 위해 연결을 중단했어요. 연결을 다시 시작해 주세요. 계속 발생하면 관리자에게 알려 주세요.",
"generic": "연결 중 알 수 없는 오류가 났어요. 연결을 다시 시작해 주세요."
"generic": "연결 중 알 수 없는 오류가 났어요. 연결을 다시 시작해 주세요.",
"already_linked": "이 Oura 계정은 이미 다른 HealthLog 프로필에 연결되어 있어요. 이 프로필의 Oura 계정으로 로그인하거나, 먼저 다른 프로필에서 연결을 해제해 주세요."
},
"integrationSetupGuide": "설정 가이드",
"coach": {
"activate": {
"title": "코치 활성화",
"description": "코치 버튼과 패널을 표시해요.",
"toggleAria": "코치 버튼과 패널 활성화",
"savedOn": "코치를 켰어요.",
"savedOff": "코치를 숨겼어요.",
"saveError": "변경 사항을 저장하지 못했어요. 다시 시도해 주세요."
},
"memory": {
"edit": "편집",
"source": {
"user": "내가 알려 줌",
"coach": "코치가 기억함"
}
}
},
"state": {
"title": "코치",
"description": "코치 버튼과 패널 표시 여부입니다. 스위치는 모듈에 있습니다."
},
"nudgesMoved": "알림에서 다른 리마인더와 함께 설정합니다.",
"openNudges": "알림에서 열기"
},
"integrationRedirectGuide": {
"intro": "직접 만든 {provider} 개발자 앱으로 연결해요. 설정에 5분쯤 걸려요:",
@@ -7155,6 +7308,12 @@
"profile": "프로필, 병력, 건강 기록 정보",
"modules": "모듈별 기록: {list}"
},
"workoutRoutes": {
"title": "운동 경로",
"description": "iPhone 앱과 Apple 건강 내보내기에서 새 운동의 GPS 경로를 저장합니다.",
"detail": "새 계정에서는 꺼져 있어요. 경로는 사는 곳, 일하는 곳, 운동하는 곳을 보여 주므로 이 설정을 켠 뒤에만 저장됩니다. 운동 기록은 항상 저장되며, 어떤 AI 기능도 경로를 읽지 않습니다. 끄면 새 경로 저장이 멈추고, 이미 저장된 경로는 운동을 삭제할 때까지 남습니다.",
"loadError": "경로 설정을 불러오지 못했습니다."
},
"retention": {
"title": "보관 기간",
"description": "자동 정리 전까지 기간이 정해진 기록을 얼마나 보관하는지예요.",
@@ -7163,12 +7322,6 @@
"delivery": "알림 발송 기록: {days}일 보관",
"unavailable": "지금은 보관 기간 정보를 볼 수 없어요."
},
"export": {
"title": "내 데이터 내려받기",
"description": "기록을 CSV나 JSON으로, 또는 진료용 전체 건강 기록으로 받을 수 있어요.",
"openExport": "내보내기·백업",
"openHealthRecord": "건강 기록"
},
"delete": {
"title": "삭제 또는 초기화",
"description": "계정을 완전히 없애거나, 계정은 두고 건강 기록만 지워요.",
@@ -7179,12 +7332,16 @@
"title": "개인정보 보호 수준",
"description": "AI 기능에 데이터를 어디까지 공유할지 정해요.",
"openAi": "AI 개인정보 모드"
},
"sessions": {
"title": "세션 및 기기",
"description": "로그인된 위치, 2단계 인증을 건너뛰는 기기, 계정의 보안 활동을 확인해요.",
"open": "보안 열기"
}
},
"mcp": {
"enableTitle": "원격 MCP 엔드포인트",
"enableDescription": "MCP를 지원하는 어시스턴트(Claude.ai, ChatGPT)가 범위가 제한된 토큰으로 건강 데이터를 읽게 해요.",
"enableToggleLabel": "MCP 엔드포인트 켜기",
"endpointLabel": "커넥터 URL",
"connectDescription": "어시스턴트의 커스텀 커넥터 설정에 이 URL을 붙여 넣고, 아래 토큰으로 인증하거나 안내가 나오면 로그인하세요.",
"tokensTitle": "커넥터 토큰",
@@ -7203,7 +7360,8 @@
"connectionRevokeFailed": "어시스턴트 연결을 해제하지 못했어요.",
"connectionsDetail": "연결을 해지하면 갱신 권한까지 포함해 접근이 즉시 끊겨요.",
"tokensDetail": "토큰은 한 번만 보여줘요.",
"tokenNamePlaceholder": "이름 (예: Claude Desktop)"
"tokenNamePlaceholder": "이름 (예: Claude Desktop)",
"offKeepsTokens": "끄면 엔드포인트가 일시 중지됩니다. 토큰과 연결은 그대로 남고 다시 켜면 다시 작동합니다."
},
"strava": "Strava",
"stravaDescription": "Strava 계정을 연결해 활동을 운동 기록으로 동기화해요.",
@@ -7232,7 +7390,8 @@
"nocreds": "이 서버에는 Strava가 설정돼 있지 않아요. 관리자에게 Strava API 자격 증명을 추가해 달라고 요청하세요.",
"token": "Strava와의 토큰 교환에 실패했어요. 연결을 다시 시작해 주세요.",
"rate_limited": "연결 시도가 너무 많아요. 잠시 후 다시 시도해 주세요.",
"generic": "연결 중 알 수 없는 오류가 났어요. 연결을 다시 시작해 주세요."
"generic": "연결 중 알 수 없는 오류가 났어요. 연결을 다시 시작해 주세요.",
"already_linked": "이 Strava 계정은 이미 다른 HealthLog 프로필에 연결되어 있어요. 이 프로필의 Strava 계정으로 로그인하거나, 먼저 다른 프로필에서 연결을 해제해 주세요."
},
"appleHealth": {
"title": "Apple Health",
@@ -7465,7 +7624,14 @@
"passkeyAddedToast": "패스키를 추가했어요.",
"passkeyRemovedToast": "패스키를 삭제했어요.",
"integrationDisconnected": "{provider} 연결을 해제했어요.",
"integrationDisconnectFailed": "{provider} 연결을 해제하지 못했어요."
"integrationDisconnectFailed": "{provider} 연결을 해제하지 못했어요.",
"timezoneSaved": "시간대를 저장했습니다.",
"moduleState": {
"on": "켜짐",
"off": "꺼짐",
"change": "모듈에서 변경"
},
"timezoneUseDevice": "이 기기의 시간대 사용 ({zone})"
},
"admin": {
"title": "관리자",
@@ -7575,6 +7741,7 @@
"heapLimitLabel": "힙 한도",
"heapLimitValue": "{mb} MB",
"heapLimitLow": "{mb} MB, 부족합니다. .env에 APP_MEMORY_LIMIT=1536m을 설정하세요",
"memoryBelowFloor": "컨테이너 {mb} MB, 지원 최소치 {floor} MB 미만입니다. .env에 APP_MEMORY_LIMIT={floor}m을 설정하세요",
"offlineGeoOff": "꺼짐",
"offlineGeoChosen": "온라인 ({host})",
"apiGlobal": "API 엔드포인트와 API 토큰을 허용해요.",
@@ -8855,6 +9022,10 @@
"detailedToast": "이제 알림에 건강 수치가 포함됩니다.",
"genericToast": "이제 알림에 건강 수치가 포함되지 않습니다.",
"saveError": "저장하지 못했어요. 나중에 다시 시도해 주세요."
},
"checkupReminders": {
"title": "검진 리마인더",
"description": "검진 리마인더를 개별로 켜거나 끕니다."
}
},
"i18n": {
@@ -9631,8 +9802,6 @@
"description": "측정값을 입력하면 알림이 완료로 표시돼요."
},
"manage": {
"title": "알림 관리",
"description": "알림을 하나씩 켜거나 끌 수 있어요.",
"empty": "아직 설정할 알림이 없어요."
},
"dashboard": {
@@ -10016,6 +10185,27 @@
"doneToastFew": "{name}(으)로 합쳤어요, 측정값 {count}개 이동",
"doneToastOther": "{name}(으)로 합쳤어요, 측정값 {count}개 이동",
"error": "바이오마커를 합치지 못했어요. 다시 시도해 주세요."
},
"trend": {
"title": "추세",
"slope": {
"rising": "{since} 이후 {n}개 값에서 1년에 약 {amount} {unit}씩 오르고 있습니다.",
"falling": "{since} 이후 {n}개 값에서 1년에 약 {amount} {unit}씩 내려가고 있습니다.",
"flat": "{since} 이후 {n}개 값에서 변화가 없습니다."
},
"projection": {
"exits": {
"high": "이 직선이 그대로 이어진다면 {date}쯤 참고 범위의 상한에 닿습니다. 이는 연장선일 뿐 예측이 아닙니다.",
"low": "이 직선이 그대로 이어진다면 {date}쯤 참고 범위의 하한에 닿습니다. 이는 연장선일 뿐 예측이 아닙니다."
},
"beyond_horizon": "이 속도라면 {years}년 넘게 참고 범위 안에 머뭅니다. 측정값이 다루는 기간입니다.",
"beyond_horizon_capped": "이 속도라면 {years}년 넘게 참고 범위 안에 머뭅니다. 연장선이 내다보는 최대 기간입니다.",
"no_exit": "직선이 참고 범위의 경계에서 멀어지고 있습니다.",
"already_outside": "직선이 이미 참고 범위 밖에 있습니다.",
"no_clear_trend": "이 값들에서는 변화가 '변화 없음'과 뚜렷이 구별되지 않아 연장선을 그리지 않습니다.",
"stale": "가장 최근 값은 {date}로 1년이 넘었습니다. 직선은 그 시점 기준으로 표시하며 예측은 하지 않습니다."
},
"interval": "연간 변화의 95% 구간: {lo}~{hi} {unit}."
}
},
"modules": {
@@ -10957,7 +11147,16 @@
"openDetail": "자세히 보기",
"historyLoadError": "검사 기록을 불러오지 못했어요",
"historyLoadErrorHint": "결과가 없다는 뜻은 아니에요. 목록을 가져오지 못했을 뿐이에요. 다시 시도해 주세요.",
"privacyNote": "답변은 암호화되어 저장되고, AI 코치에게는 전달되지 않아요."
"privacyNote": "답변은 암호화되어 저장되고, AI 코치에게는 전달되지 않아요.",
"trend": {
"sincePrevious": "{date} 이전 체크인 이후: {change}점.",
"sinceFirst": "{date} 첫 체크인 이후: {change}점.",
"verdict": {
"improved": "연구에서 의미 있다고 보는 {threshold}점 변화에 도달했고, 좋아지는 방향입니다.",
"worsened": "연구에서 의미 있다고 보는 {threshold}점 변화에 도달했고, 덜 좋은 방향입니다.",
"within_threshold": "연구에서 의미 있다고 보는 {threshold}점 변화보다 작습니다."
}
}
},
"records": {
"aiInclusion": {
@@ -12216,7 +12415,10 @@
"leafVisits": "진료",
"leafSurgicalHistory": "수술 이력",
"leafImmunizations": "예방접종",
"leafEmergency": "응급 정보 (혈액형, 연락처, 사전지시)"
"leafEmergency": "응급 정보 (혈액형, 연락처, 사전지시)",
"leafMedicationEffect": "각 약 시작 전후의 변화 (약 목록 필요)",
"leafWorkouts": "운동(종목별 요약)",
"leafHrvSdnn": "심박변이도 (SDNN)"
},
"recordSharing": {
"banner": {
@@ -13396,5 +13598,218 @@
"clinicalUpper": "임상 기준 상한",
"clinicalLower": "임상 기준 하한"
}
},
"activityCalendar": {
"title": "활동 캘린더",
"periodLabel": "캘린더 기간",
"period": {
"week": "주",
"month": "월"
},
"weekRange": "{start} ~ {end}",
"previous": "이전 기간",
"next": "다음 기간",
"metricLabel": "날짜 색상 기준",
"metric": {
"steps": "걸음 수",
"activeMinutes": "활동 시간(분)",
"load": "운동 부하"
},
"empty": "이 기간에는 아직 걸음 수, 활동 시간, 운동 기록이 없습니다.",
"loadError": "활동 캘린더를 불러오지 못했습니다.",
"weekGoalsColumn": "주간 목표",
"weekGoalsMet": "주간 목표를 모두 달성함",
"weekGoalsNotMet": "주간 목표를 모두 달성하지는 못함",
"cell": {
"steps": "{value}걸음",
"activeMinutes": "활동 {value}분",
"workouts": "운동 {value}회: {sports}"
},
"level": {
"none": "데이터 없음",
"unrated": "아직 기록이 충분하지 않음",
"low": "평소보다 훨씬 적음",
"below": "평소보다 적음",
"usual": "평소와 비슷함",
"above": "평소보다 많음",
"high": "평소보다 훨씬 많음",
"in_progress": "오늘, 아직 진행 중"
},
"legend": "이전 12주와 비교:",
"noBaseline": "데이터가 있는 날이 14일이 되면 평소 범위가 표시됩니다.",
"summary": {
"steps": "{value}걸음",
"activeMinutes": "활동 {value}분",
"workouts": "운동 {value}회",
"load": "평균 운동 부하 {value}",
"usual": "이 시점까지의 평소 값: {value}",
"noUsual": "비교할 이전 주나 달이 아직 충분하지 않습니다.",
"usualThroughYesterday": "어제까지 {value}, 그때까지의 평소 {usual}",
"firstDay": "첫날이 끝나면 평소와 비교합니다."
},
"sportCount": "{sport}: {count}",
"goal": {
"workoutsPerWeek": "주당 운동 횟수",
"activeMinutesPerWeek": "주당 활동 시간(분)",
"stepsPerWeek": "주당 걸음 수",
"stepsPerMonth": "월간 걸음 수"
},
"goalOf": "{target} 중 {value}",
"setGoals": "목표 설정",
"editGoals": "목표 변경",
"goalsSheet": {
"title": "활동 목표",
"description": "목표를 두지 않으려면 칸을 비워 두세요.",
"none": "목표 없음",
"invalid": "{min}부터 {max}까지의 정수를 입력하세요.",
"saveError": "목표를 저장하지 못했습니다."
},
"crossLink": {
"title": "활동 캘린더",
"body": "주 또는 월 단위의 걸음 수를 내 평소 값과 비교합니다.",
"cta": "캘린더 열기"
}
},
"sourceProvenance": {
"title": "값의 출처",
"description": "날마다 어떤 소스가 값을 보냈고, 소스 우선순위가 어느 것을 쓰는지 보여 줍니다.",
"metric": "측정 항목",
"day": "날짜",
"dayTitle": "이 날의 소스",
"loadError": "소스를 불러오지 못했습니다.",
"dayEmpty": "이 날에는 이 항목의 값이 없습니다.",
"reason": {
"only_source": "이 날의 소스는 {source}뿐입니다.",
"highest_on_ladder": "{source}을(를) 사용합니다. 이 날의 소스 중 소스 우선순위가 가장 높습니다.",
"name_tiebreak": "{source}을(를) 사용합니다. 이 날의 소스가 모두 우선순위에 없어 이름으로 정했습니다.",
"not_ranked": "이 항목에는 소스 우선순위가 없어 모든 소스의 값이 반영됩니다."
},
"pass": {
"lower_on_ladder": "소스 우선순위에서 더 아래에 있습니다.",
"not_on_ladder": "이 항목의 소스 우선순위에 없습니다.",
"name_tiebreak": "사용한 소스와 순위가 같아 이름으로 정했습니다."
},
"used": "사용됨",
"readings": "측정 {count}회, 기기: {devices}",
"ladder": "이 항목의 순서: {ladder}",
"utcNote": "소스는 차트와 같은 방식으로 UTC 자정부터 자정까지 하루 단위로 선택됩니다.",
"openSettings": "소스 우선순위에서 순서 변경",
"compareTitle": "두 소스 비교",
"range": "기간",
"rangeDays": "{days}일",
"compareNeedsTwo": "이 기간에는 한 소스에만 이 항목의 값이 있습니다.",
"side": {
"a": "첫 번째 소스",
"b": "두 번째 소스"
},
"daysBoth": "둘 다 있는 날",
"meanOf": "{source} 평균",
"meanAbsDiff": "평균 차이",
"pickedDays": "사용된 날 (첫 번째 / 두 번째)",
"pickedSplit": "{a} / {b}",
"compareNote": "평균은 두 소스 모두 값이 있는 날만 계산하여 같은 조건으로 비교합니다. 굵은 값이 차트에 쓰이는 값입니다.",
"settingsHint": "이 순서가 날마다 무엇을 정하는지 보려면:",
"settingsHintLink": "값의 출처",
"metricLink": "이 값의 출처"
},
"recordImport": {
"title": "스프레드시트에서 검사 결과와 약 가져오기",
"description": "수년간의 검사 결과나 약 목록을 CSV 파일에서 가져옵니다.",
"detail": "어떤 열에 무엇이 있는지 직접 고르면, HealthLog가 모든 행을 먼저 확인하고 확인 전에는 아무것도 가져오지 않습니다. 이미 있는 행은 건너뜁니다.",
"kind": {
"labs": "검사 결과",
"medications": "약"
},
"sheetTitle": {
"labs": "검사 결과 가져오기",
"medications": "약 가져오기"
},
"sheetDescription": {
"labs": "마커는 내 마커에 연결되고, 변환 방법을 아는 경우 단위가 변환됩니다.",
"medications": "이름, 용량, 매일 복용 시간, 필요시, 시작일과 종료일. 다른 주기는 나중에 앱에서 설정합니다."
},
"file": "CSV 파일",
"template": "템플릿 다운로드",
"mapping": "각 열의 내용",
"required": "(필수)",
"notInFile": "파일에 없음",
"column": "{n}열",
"delimiter": "구분자",
"delimiterOption": {
"comma": "쉼표",
"semicolon": "세미콜론",
"tab": "탭"
},
"decimal": "소수점",
"decimalOption": {
"dot": "마침표 (5.4)",
"comma": "쉼표 (5,4)"
},
"dateOrder": "날짜 형식",
"dateOrderOption": {
"iso": "2025-03-01",
"dmy": "01.03.2025 (일 먼저)",
"mdy": "03/01/2025 (월 먼저)"
},
"header": "첫 행에 열 이름이 있음",
"reminders": "가져온 약의 알림 켜기",
"timezoneNote": "시간대가 없는 시간은 계정의 시간대로 읽습니다. 시간이 없는 날짜는 그 날짜 그대로 저장됩니다.",
"missing": "아직 선택할 항목: {fields}",
"check": "파일 확인",
"runIdle": "가져오기",
"run": "{count}행 가져오기",
"close": "닫기",
"summaryCheck": "{rows}행 읽음: {inserted}행 가져오기 가능, {duplicates}행 이미 있음, {failed}행 문제 있음.",
"summaryDone": "{inserted}행을 가져왔습니다. 이미 있던 {duplicates}행과 문제가 있는 {failed}행은 건너뛰었습니다.",
"markersMatched": "내 마커에 연결됨:",
"markersCreated": "새 마커:",
"line": "{line}행",
"errorsTruncated": "처음 500개의 문제만 표시됩니다.",
"error": {
"tooLarge": "파일이 16MB보다 큽니다.",
"rateLimited": "지난 한 시간 동안 가져오기가 너무 많았습니다. 나중에 다시 시도하세요.",
"invalid": "파일이나 열 선택을 읽을 수 없습니다.",
"failed": "가져오기에 실패했습니다. 다시 시도하세요."
},
"reason": {
"missing_value": "필수 칸이 비어 있음",
"invalid_number": "숫자가 아님",
"ambiguous_number": "숫자를 두 가지로 읽을 수 있음. 소수점을 확인하세요",
"missing_unit": "숫자에는 단위가 필요함",
"invalid_date": "선택한 형식의 날짜가 아님",
"invalid_time": "시간이 아님",
"ambiguous_date": "날짜를 두 가지로 읽을 수 있음. 네 자리 연도와 선택한 형식을 쓰세요",
"future_date": "날짜가 미래임",
"text_too_long": "텍스트가 너무 김",
"unit_mismatch": "이 단위는 마커의 단위로 변환할 수 없음",
"invalid_reference_range": "참고 범위를 읽을 수 없음",
"invalid_times": "시간을 읽을 수 없음 (예: 08:00 20:00)",
"missing_times": "복용 시간을 넣거나 필요시 약으로 표시하세요",
"invalid_yes_no": "예 또는 아니오로 쓰세요",
"as_needed_with_times": "필요시 약에는 정해진 시간이 없음",
"end_before_start": "종료일이 시작일보다 앞섬",
"invalid_medication": "이대로는 약을 저장할 수 없음",
"oversized_row": "행이 너무 길거나 넓음",
"row_limit": "한 번에 가져올 수 있는 행 수를 넘음",
"duplicate": "이미 있어 건너뜀"
},
"field": {
"analyte": "마커",
"value": "결과",
"unit": "단위",
"date": "날짜",
"time": "시간",
"referenceRange": "참고 범위",
"referenceLow": "참고 하한",
"referenceHigh": "참고 상한",
"panel": "그룹",
"note": "메모",
"name": "이름",
"dose": "용량",
"times": "시간",
"asNeeded": "필요시",
"startsOn": "시작",
"endsOn": "종료"
}
}
}
+464 -49
View File
@@ -401,7 +401,41 @@
"lifeSittingValue": "{hours} uur, zelf opgegeven",
"lifeWork": "Werk",
"lifeNightWork": "nachtwerk",
"lifeExposures": "blootstellingen: {list}"
"lifeExposures": "blootstellingen: {list}",
"workouts": {
"title": "Trainingen",
"summary": "{sessions} trainingen, {hours} u in totaal; gemiddeld {perWeek} trainingen en {minutesPerWeek} minuten per week.",
"colSport": "Sport",
"colSessions": "Trainingen",
"colTime": "Duur",
"colDistance": "Afstand",
"colEnergy": "Energie",
"colHeartRate": "Gem. hartslag",
"moreSports": "{count} andere sporten tellen mee in de totalen, maar staan niet in de lijst."
},
"medicationEffect": {
"title": "Verandering rond de start van medicijnen",
"colMedication": "Medicijn / meetwaarde",
"colStart": "Start",
"colLevel": "Niveauverandering (95%-BI)",
"colSlope": "Trendverandering (95%-BI)",
"colDays": "Dagen voor / na",
"to": "tot",
"week": "week",
"reasonNoStart": "Geen startdatum vastgelegd",
"reasonBefore": "Te weinig waarden vóór de start",
"reasonAfter": "Te weinig waarden sinds de start",
"reasonFit": "Niet te berekenen",
"excludedAdherence": "{count} dagen na de start weggelaten (minder dan {floor}% van de doses ingenomen)",
"excludedNotTaking": "{count} dagen weggelaten (gepauzeerd of tussen kuren)",
"notTracked": "inname niet bijgehouden, dus geen dagen weggelaten om gemiste doses",
"endDoseChange": "periode eindigt op {date}, vóór de eerste dosiswijziging",
"days": "{before} dagen voor, {after} dagen na de start, {excluded} dagen weggelaten",
"method": "Gesegmenteerde regressie op de dagwaarden van 90 dagen vóór tot hoogstens 180 dagen na de start, met Newey-West-intervallen. Dagen na de start waarop minder dan 80% van de doses van de 14 dagen ervoor is ingenomen, zijn weggelaten. Beschrijft een samenhang in de tijd, geen bewezen effect van het medicijn.",
"autocorrelation": "sterke afhankelijkheid van de vorige dag, dus de bereiken kunnen nog te smal zijn",
"window": "geanalyseerd van {from} tot {to}, dit kan buiten de rapportperiode vallen",
"windowLine": "Geanalyseerd van {from} tot {to}. Deze periode kan buiten de rapportperiode vallen."
}
},
"errorBoundary": {
"title": "Er is iets misgegaan",
@@ -911,6 +945,15 @@
"unitRating": "beoordeling",
"typeWho5Score": "WHO-5-score",
"typeSciScore": "SCI-score",
"typeExerciseMinutes": "Trainingsminuten",
"typeStandMinutes": "Staminuten",
"typeStandHours": "Stauren",
"typeMindfulMinutes": "Mindfulnessminuten",
"typeStateOfMindValence": "Gemoedstoestand",
"typeAfibBurden": "AF-belasting",
"typeIndoorCo2": "CO₂ binnen",
"typeIndoorTemperature": "Binnentemperatuur",
"typeIndoorHumidity": "Luchtvochtigheid binnen",
"durationReadAs": "Gelezen als {duration}",
"durationHoursMinutes": "{hours} u {minutes} min",
"durationHoursOnly": "{hours} u",
@@ -2350,7 +2393,31 @@
},
"scaleAbsolute": "Absoluut",
"scalePercentSinceStart": "% sinds start",
"scaleToggleLabel": "Schaal van grafiek"
"scaleToggleLabel": "Schaal van grafiek",
"segmented": {
"title": "Verandering bij de start",
"level": "Direct bij de start verschoof het niveau met {value} {unit} (95%-bereik {low} tot {high}).",
"slope": "Na de start veranderde de weektrend met {value} {unit} per week (95%-bereik {low} tot {high}).",
"preSlope": "Voor de start was de trend {value} {unit} per week.",
"includesZero": "Waar een bereik nul omvat, passen deze waarden ook bij geen verandering.",
"basis": "Gebaseerd op {before} dagen voor en {after} dagen na de start ({from} tot {to}).",
"excludedAdherence": "{count} dagen na de start zijn weggelaten, omdat in de {lookback} dagen ervoor minder dan {floor}% van de doses is ingenomen.",
"excludedNotTaking": "{count} dagen zijn weggelaten, omdat het medicijn gepauzeerd was of tussen twee kuren lag.",
"notTracked": "De inname van dit medicijn wordt niet bijgehouden, dus er zijn geen dagen weggelaten om gemiste doses.",
"endDoseChange": "De periode eindigt vóór de eerste dosiswijziging en beschrijft dus de startdosis.",
"confoundersTitle": "Andere medicijnwijzigingen in deze periode, die deze weergave niet van dit medicijn kan scheiden:",
"confounderStarted": "{name} gestart op {date}",
"confounderStopped": "{name} gestopt op {date}",
"confounderDoseChange": "{name}: dosis gewijzigd op {date}",
"confounderPaused": "{name} gepauzeerd op {date}",
"confounderResumed": "{name} hervat op {date}",
"method": "Zo wordt het berekend: door de dagwaarden vóór de start wordt een lijn gelegd en een tweede door die erna. De sprong tussen beide bij de start is de niveauverandering, het verschil in hun helling de trendverandering. De bereiken houden er rekening mee dat waarden van de ene dag doorwerken in de volgende.",
"needStart": "Stel een startdatum in om de verandering bij de start te zien.",
"needBefore": "Voor de verandering bij de start zijn waarden op minstens {days} dagen nodig, verspreid over {span} dagen vóór de start.",
"needAfter": "Voor de verandering bij de start zijn waarden op minstens {days} dagen nodig, verspreid over {span} dagen sinds de start en op dagen waarop de doses zijn ingenomen.",
"fitFailed": "Deze waarden konden niet worden verwerkt, dus er wordt geen verandering bij de start getoond.",
"autocorrelation": "De dagwaarden hangen sterk af van de vorige dag, dus zelfs deze bereiken kunnen te smal zijn."
}
},
"import": {
"resultSuccess": "{imported} innames geïmporteerd",
@@ -2679,7 +2746,20 @@
"ENV_PRESSURE_DELTA": "luchtdrukschommeling gedurende de dag",
"ENV_PM25": "fijnstof (PM2.5)",
"ENV_OZONE_8H": "ozon",
"ENV_POLLEN_MAX": "pollen"
"ENV_POLLEN_MAX": "pollen",
"HRV_RMSSD": "hartslagvariabiliteit (RMSSD)",
"HYDRATION": "waterinname",
"CAFFEINE": "cafeïne-inname",
"ALCOHOL": "alcoholgebruik",
"WORKOUT_MINUTES": "trainingsminuten",
"TRAINING_LOAD": "trainingsbelasting",
"EXERCISE_MINUTES": "beweegminuten",
"STAND_MINUTES": "staminuten",
"MINDFUL_MINUTES": "mindfulnessminuten",
"STATE_OF_MIND_VALENCE": "vastgelegde gemoedstoestand",
"INDOOR_CO2": "CO2 binnen",
"INDOOR_TEMPERATURE": "binnentemperatuur",
"INDOOR_HUMIDITY": "luchtvochtigheid binnen"
},
"daily": {
"high": "Bij hogere waarden voor {behaviour} ligt {outcome} de volgende dag in je gegevens vaak {direction}. Het is een patroon om in de gaten te houden, geen oorzaak.",
@@ -2707,6 +2787,12 @@
"high": "Bij hogere waarden voor {behaviour} op dezelfde dag en de dag ervoor ligt {outcome} in je gegevens vaak {direction}. Het is een patroon om in de gaten te houden, geen oorzaak.",
"moderate": "Op basis van het bewijs tot nu toe lijkt {outcome} in je gegevens {direction} te liggen bij hogere waarden voor {behaviour} op dezelfde dag en de dag ervoor. Het is een patroon om in de gaten te houden, geen oorzaak.",
"faint": "Bij hogere waarden voor {behaviour} op dezelfde dag en de dag ervoor is er in je gegevens hooguit een zwakke aanwijzing dat {outcome} {direction} ligt. Het is te klein om op te bouwen en nooit een oorzaak."
},
"channelNutrient": "inname van {nutrient}",
"dailyOvernight": {
"high": "Bij hogere waarden voor {behaviour} 's nachts ligt {outcome} die nacht in je gegevens vaak {direction}. Het is een patroon om in de gaten te houden, geen oorzaak.",
"moderate": "Op basis van het bewijs tot nu toe lijkt {outcome} die nacht in je gegevens {direction} te liggen bij hogere waarden voor {behaviour} 's nachts. Het is een patroon om in de gaten te houden, geen oorzaak.",
"faint": "Bij hogere waarden voor {behaviour} 's nachts is er in je gegevens hooguit een zwakke aanwijzing dat {outcome} die nacht {direction} ligt. Het is te klein om op te bouwen en nooit een oorzaak."
}
},
"noAnalysisYet": "Nog geen beoordeling.",
@@ -3264,11 +3350,12 @@
"provider": "Antwoordt met",
"modelManaged": "Het model voor deze provider stel je in bij de AI-instellingen.",
"noProvider": "Geen provider beschikbaar",
"allSettings": "Alle AI-instellingen",
"operatorCodex": "ChatGPT (gedeeld door de serverbeheerder)",
"changeModel": "Wijzigen in Instellingen > AI",
"saving": "Opslaan…",
"saveFailed": "Die wijziging kon niet worden opgeslagen.",
"allSettings": "Alle AI-instellingen",
"providerNotActive": "{provider} kon het niet overnemen: er is geen werkende aanmelding of sleutel. Stel deze provider in bij de AI-instellingen.",
"operatorCodex": "ChatGPT (gedeeld door de serverbeheerder)",
"operatorManaged": "De beheerder van je server beheert deze verbinding. Zodra een van je eigen providers is ingesteld, antwoordt die als eerste."
},
"answer": {
@@ -3565,6 +3652,7 @@
"falls": "valpartijen",
"years": "jaar",
"breathingEvents": "gebeurtenissen",
"standHours": "uur",
"respiratoryRate": "ademhalingen/min"
},
"sleep": {
@@ -3807,6 +3895,16 @@
"editTitle": "Workout bewerken",
"editAction": "Bewerken",
"updated": "Workout bijgewerkt"
},
"load": {
"title": "Trainingsbelasting",
"fitness": "Fitheid",
"fatigue": "Vermoeidheid",
"form": "Vorm",
"explainer": "Fitheid is je trainingsprikkel uit de hartslag, gemiddeld over ongeveer zes weken, vermoeidheid over ongeveer een week, en vorm is het verschil. De getallen zijn te vergelijken met je eigen verleden, niet met andere mensen of andere apps.",
"warmingUp": "Op basis van {days} dagen tot nu toe: tot zes weken gedekt zijn, valt de fitheid lager uit dan ze is.",
"coverage": "{rated} van de laatste {days} dagen konden worden beoordeeld. Een dag zonder activiteitsgegevens, of met een training die niet kon worden beoordeeld, wordt weggelaten in plaats van als rustdag te tellen.",
"asOf": "Stand van {date}, de laatste dag die kon worden beoordeeld."
}
},
"bloodGlucose": {
@@ -4248,7 +4346,12 @@
"outcomeDown": "Een hogere stemming gaat in je gegevens vaak samen met lagere waarden voor {factor} de volgende dag.",
"stat": "{n} gekoppelde dagen, r = {r}, q = {q}",
"statLabel": "Hoe dit is berekend",
"explainerLabel": "Hoe dit is berekend"
"explainerLabel": "Hoe dit is berekend",
"channel": {
"ALCOHOL": "alcoholgebruik",
"WORKOUT_MINUTES": "trainingsminuten",
"TRAINING_LOAD": "trainingsbelasting"
}
},
"inTargetTitle": "Binnen je bereik",
"dimensions": {
@@ -4709,7 +4812,8 @@
"shiftDown": "{vital} is gedaald ten opzichte van wat recent normaal was voor jou.",
"rangeContext": "Nu {value}, meestal {low}–{high}.",
"rangeContextTimeOfDay": "Nu {value}, op dit moment van de dag meestal {low}–{high}.",
"shiftContext": "Eerst rond {before}, sindsdien {after}."
"shiftContext": "Eerst rond {before}, sindsdien {after}.",
"shiftDays": "Op basis van {before} dagen ervoor en {after} sindsdien."
},
"breathingScreening": {
"sectionTitle": "Ademhaling tijdens de slaap",
@@ -5289,12 +5393,17 @@
"groups": {
"account": "Account",
"yourData": "Je gegevens",
"display": "Weergave",
"healthProfile": "Gezondheidsprofiel",
"ai": "AI",
"connectivity": "Verbindingen",
"system": "Systeem"
}
"system": "Systeem",
"tracking": "Bijhouden",
"arrangement": "Indeling",
"notifications": "Meldingen",
"connections": "Verbindingen",
"data": "Gegevens"
},
"allSections": "Alle onderdelen"
},
"sharedRecord": {
"managedSettings": {
@@ -5406,8 +5515,15 @@
"unavailableTitle": "Niet beschikbaar in dit gedeelde dossier",
"unavailableDescription": "Dit deel van Instellingen is bewust niet beschikbaar terwijl je het dossier van iemand anders bekijkt.",
"integrationStatusDescription": "Koppelingsstatus voor dit beheerde dossier.",
"integrationStatusDetail": "Een beheerd profiel toont de gekoppelde diensten alleen als status. Koppelen, opnieuw koppelen, synchroniseren en ontkoppelen zijn niet beschikbaar voor verzorgers.",
"integrationStatusDocs": "Hoe delen werkt"
"integrationStatusDetail": "Diensten die je hier koppelt, sturen hun gegevens naar het dossier van {name}, niet naar het jouwe. Zonder eigen app bij de aanbieder gebruikt het profiel de jouwe.",
"integrationStatusDocs": "Hoe delen werkt",
"integrationConnectsFor": "Koppelt voor {name}",
"integrationConnectFor": "Koppelen voor {name}",
"integrationDisconnect": "Ontkoppelen",
"integrationDisconnectTitle": "{provider} ontkoppelen van {name}?",
"integrationDisconnectBody": "Er komen geen nieuwe gegevens meer binnen. Wat al geïmporteerd is, blijft in het dossier van {name}.",
"integrationNoClientApp": "Noch {name} noch je eigen account heeft al een {provider}-app. Stel er eerst een in bij je eigen integraties.",
"integrationConnectFailed": "Koppelen met {provider} kon niet starten."
},
"sections": {
"account": {
@@ -5428,7 +5544,21 @@
"remindersHeading": "Herinneringen",
"matrixLink": "Meldingen per kanaal",
"channelsLink": "Bezorgkanalen",
"displayHeading": "Op je telefoon"
"displayHeading": "Op je telefoon",
"elsewhere": {
"title": "Ingesteld waar ze thuishoren",
"description": "Deze herinneringen stel je in bij waar ze over gaan.",
"doses": {
"title": "Medicatie-innames",
"description": "Innametijden en herinneringen stel je per medicijn in.",
"link": "Medicatie openen"
},
"cycle": {
"title": "Cyclus",
"description": "Herinneringen voor menstruatie en vruchtbare dagen zet je per kanaal aan in het meldingenoverzicht.",
"link": "Meldingenoverzicht openen"
}
}
},
"layout": {
"title": "Indeling",
@@ -5441,7 +5571,7 @@
"title": "Inzichten",
"description": "Overzichtsonderdelen en volgorde van de navigatieknoppen."
},
"subtitle": "Kies wat je dashboard, inzichten en registratiemodules laten zien, en in welke volgorde.",
"subtitle": "Kies de volgorde en weergave van je dashboard, inzichten en lijsten.",
"medications": {
"title": "Medicatie",
"description": "Lijstweergave en handmatige volgorde."
@@ -5458,10 +5588,6 @@
"title": "Preventieve controles",
"description": "Lijstweergave en volgorde van herinneringen."
},
"mood": {
"title": "Stemmingstags",
"description": "Groepen, tags en gearchiveerde tags voor de stemmingskiezer."
},
"documents": {
"title": "Documenten",
"description": "Weergave en indeling per maand."
@@ -5852,7 +5978,28 @@
},
"manageIn": "Beheren in {section}",
"operatorDisabled": "Op de hele server uitgeschakeld",
"subtitle": "Kies welke onderdelen HealthLog in de hele app toont."
"subtitle": "Kies welke onderdelen HealthLog in de hele app toont.",
"backToHub": "Terug naar Modules",
"openModuleSettings": "Module-instellingen",
"pages": {
"medications": {
"title": "Medicatie",
"description": "Injectieplaatsen die de rotatie overslaat."
},
"mood": {
"title": "Stemmingstags",
"description": "Groepen, je eigen tags en gearchiveerde tags."
},
"labs": {
"title": "Labwaarden",
"description": "Biomarkers en tekstherkenning op het apparaat voor labuitslagen."
},
"arrangementTitle": "Volgorde en weergave",
"arrangementDescription": "Hoe deze lijst wordt gesorteerd en getoond, stel je in bij Indeling.",
"openArrangement": "Openen in Indeling",
"moduleSettingsTitle": "Module-instellingen",
"moduleSettingsDescription": "Al het andere staat op de pagina van de module onder Modules."
}
},
"gesundheitsakte": {
"title": "Gezondheidsdossier",
@@ -6499,7 +6646,8 @@
"nocode": "Withings heeft geen autorisatiecode teruggestuurd. Probeer het opnieuw en sta toegang toe in het Withings-venster.",
"nocreds": "Er zijn geen Withings-API-inloggegevens opgeslagen. Sla eerst de client-ID en het clientgeheim op en koppel daarna opnieuw.",
"token": "De tokenuitwisseling met Withings is mislukt. Controleer de client-ID en het clientgeheim en probeer het opnieuw.",
"generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw."
"generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw.",
"already_linked": "Dit Withings-account is al gekoppeld aan een ander HealthLog-profiel. Log bij Withings in met het account van dit profiel, of ontkoppel het eerst van het andere profiel."
},
"withingsSynchronize": "Synchroniseren",
"withingsDisconnect": "Ontkoppelen",
@@ -6538,7 +6686,8 @@
"nocode": "WHOOP heeft geen autorisatiecode teruggestuurd. Probeer het opnieuw en sta toegang toe in het WHOOP-venster.",
"nocreds": "WHOOP is niet geconfigureerd. Voeg hierboven je WHOOP-OAuth-inloggegevens toe en probeer het opnieuw.",
"token": "De tokenuitwisseling met WHOOP is mislukt. Start de koppeling opnieuw.",
"generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw."
"generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw.",
"already_linked": "Dit WHOOP-account is al gekoppeld aan een ander HealthLog-profiel. Log bij WHOOP in met het account van dit profiel, of ontkoppel het eerst van het andere profiel."
},
"fitbit": "Fitbit",
"fitbitTag": "Fitbit & Pixel",
@@ -6617,7 +6766,8 @@
"nocode": "Google heeft geen autorisatiecode teruggestuurd. Probeer het opnieuw en sta toegang toe in het Google-venster.",
"nocreds": "Google Health is niet geconfigureerd. Voeg hierboven je Google-OAuth-inloggegevens toe en probeer het opnieuw.",
"token": "De tokenuitwisseling met Google is mislukt. Start de koppeling opnieuw.",
"generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw."
"generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw.",
"already_linked": "Dit Google-account is al gekoppeld aan een ander HealthLog-profiel. Log bij Google in met het account van dit profiel, of ontkoppel het eerst van het andere profiel."
},
"fitbitOauthError": {
"csrf1": "De beveiligingscontrole is mislukt omdat de browsercookie ontbrak. Start de koppeling opnieuw in dezelfde browser.",
@@ -6628,7 +6778,8 @@
"nocreds": "Fitbit is niet geconfigureerd. Voeg hierboven je Fitbit-OAuth-inloggegevens toe en probeer het opnieuw.",
"token": "De tokenuitwisseling met Fitbit is mislukt. Start de koppeling opnieuw.",
"generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw.",
"retired": "Google heeft de Fitbit Web API uitgeschakeld, dus er kan geen nieuwe Fitbit-koppeling meer worden gemaakt. Koppel in plaats daarvan Google Health, dat dezelfde apparaten leest."
"retired": "Google heeft de Fitbit Web API uitgeschakeld, dus er kan geen nieuwe Fitbit-koppeling meer worden gemaakt. Koppel in plaats daarvan Google Health, dat dezelfde apparaten leest.",
"already_linked": "Dit Fitbit-account is al gekoppeld aan een ander HealthLog-profiel. Log bij Fitbit in met het account van dit profiel, of ontkoppel het eerst van het andere profiel."
},
"integrations": {
"withings": {
@@ -6665,7 +6816,9 @@
"items": {
"vo2max": "VO2 max",
"cardio_age": "cardiovasculaire leeftijd",
"resilience": "veerkracht"
"resilience": "veerkracht",
"workout": "trainingen",
"heartrate": "hartslag"
}
}
}
@@ -6972,7 +7125,8 @@
"nocreds": "Polar is niet geconfigureerd op deze server. Vraag je beheerder om de Polar-OAuth-inloggegevens toe te voegen.",
"token": "De tokenuitwisseling met Polar is mislukt. Start de koppeling opnieuw.",
"rate_limited": "Te veel koppelingspogingen. Wacht even en probeer het opnieuw.",
"generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw."
"generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw.",
"already_linked": "Dit Polar-account is al gekoppeld aan een ander HealthLog-profiel. Log bij Polar in met het account van dit profiel, of ontkoppel het eerst van het andere profiel."
},
"oura": "Oura",
"ouraDescription": "Koppel je Oura-account om paraatheid, slaapfasen, hartslagvariabiliteit en dagelijkse activiteit te synchroniseren.",
@@ -7002,25 +7156,24 @@
"token": "De tokenuitwisseling met Oura is mislukt. Start de koppeling opnieuw.",
"rate_limited": "Te veel koppelingspogingen. Wacht even en probeer het opnieuw.",
"issuer": "Het inlogantwoord kwam niet van de eigen autorisatieserver van Oura, daarom is de koppeling voor je veiligheid gestopt. Start de koppeling opnieuw. Als dit blijft gebeuren, laat het dan je beheerder weten.",
"generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw."
"generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw.",
"already_linked": "Dit Oura-account is al gekoppeld aan een ander HealthLog-profiel. Log bij Oura in met het account van dit profiel, of ontkoppel het eerst van het andere profiel."
},
"integrationSetupGuide": "Installatiehandleiding",
"coach": {
"activate": {
"title": "Coach activeren",
"description": "De Coach-knop en het Coach-paneel tonen.",
"toggleAria": "De Coach-knop en het Coach-paneel activeren",
"savedOn": "Coach geactiveerd.",
"savedOff": "Coach verborgen.",
"saveError": "De wijziging kon niet worden opgeslagen. Probeer het opnieuw."
},
"memory": {
"edit": "Bewerken",
"source": {
"user": "van jou",
"coach": "van de Coach"
}
}
},
"state": {
"title": "Coach",
"description": "Of de Coach-knop en het Coach-paneel zichtbaar zijn. De schakelaar staat bij Modules."
},
"nudgesMoved": "Stel je in bij je andere herinneringen onder Meldingen.",
"openNudges": "Openen in Meldingen"
},
"integrationRedirectGuide": {
"intro": "Dit koppelt via je eigen {provider}-ontwikkelaarsapp; het instellen duurt ongeveer 5 minuten:",
@@ -7155,6 +7308,12 @@
"profile": "Je profiel, medische voorgeschiedenis en dossiergegevens",
"modules": "Gegevens van elke module: {list}"
},
"workoutRoutes": {
"title": "Trainingsroutes",
"description": "Het GPS-spoor van nieuwe trainingen uit de iPhone-app en de Apple Gezondheid-export bewaren.",
"detail": "Uit voor nieuwe accounts. Een route laat zien waar je woont, werkt en traint, dus hij wordt pas bewaard als je dit aanzet. Trainingen worden altijd bewaard en geen enkele AI-functie leest ooit een route. Uitzetten stopt nieuwe routes; al bewaarde routes blijven tot je de training verwijdert.",
"loadError": "De route-instelling kon niet worden geladen."
},
"retention": {
"title": "Bewaartermijn",
"description": "Hoe lang tijdgebonden gegevens worden bewaard voordat ze automatisch worden opgeschoond.",
@@ -7163,12 +7322,6 @@
"delivery": "Logboek van bezorgde meldingen: {days} dagen bewaard",
"unavailable": "Details over de bewaartermijn zijn op dit moment niet beschikbaar."
},
"export": {
"title": "Mijn gegevens downloaden",
"description": "Je gegevens als CSV of JSON, of een volledig medisch dossier voor je arts.",
"openExport": "Export en back-up",
"openHealthRecord": "Medisch dossier"
},
"delete": {
"title": "Verwijderen of wissen",
"description": "Verwijder je account volledig, of wis je gezondheidsgegevens en behoud je account.",
@@ -7179,12 +7332,16 @@
"title": "Privacyniveau",
"description": "Bepaal hoeveel van je gegevens met AI-functies wordt gedeeld.",
"openAi": "AI-privacymodus"
},
"sessions": {
"title": "Sessies en apparaten",
"description": "Waar je bent ingelogd, welke apparaten de tweede factor overslaan en de beveiligingsactiviteit van je account.",
"open": "Beveiliging openen"
}
},
"mcp": {
"enableTitle": "Extern MCP-endpoint",
"enableDescription": "Laat een assistent met MCP-ondersteuning (Claude.ai, ChatGPT) je gezondheidsgegevens lezen via een token met beperkte rechten.",
"enableToggleLabel": "Het MCP-endpoint inschakelen",
"endpointLabel": "Connector-URL",
"connectDescription": "Plak deze URL in de instellingen voor aangepaste connectors van je assistent en autoriseer daarna met het token hieronder, of log in wanneer daarom wordt gevraagd.",
"tokensTitle": "Connectortokens",
@@ -7203,7 +7360,8 @@
"connectionRevokeFailed": "De assistent kon niet worden ontkoppeld.",
"connectionsDetail": "Als je een koppeling intrekt, eindigt de toegang direct, inclusief de mogelijkheid om die te vernieuwen.",
"tokensDetail": "Het token wordt één keer getoond.",
"tokenNamePlaceholder": "Naam, bijv. Claude Desktop"
"tokenNamePlaceholder": "Naam, bijv. Claude Desktop",
"offKeepsTokens": "Uitzetten pauzeert het eindpunt. Je tokens en verbindingen blijven en werken weer zodra het aan staat."
},
"strava": "Strava",
"stravaDescription": "Koppel je Strava-account om je activiteiten naar je trainingsgeschiedenis te synchroniseren.",
@@ -7232,7 +7390,8 @@
"nocreds": "Strava is niet geconfigureerd op deze server. Vraag je beheerder om de Strava-API-inloggegevens toe te voegen.",
"token": "Het uitwisselen van het token met Strava is mislukt. Start het koppelen opnieuw.",
"rate_limited": "Te veel koppelpogingen. Wacht even en probeer het opnieuw.",
"generic": "Onbekende fout bij het koppelen. Start het koppelen opnieuw."
"generic": "Onbekende fout bij het koppelen. Start het koppelen opnieuw.",
"already_linked": "Dit Strava-account is al gekoppeld aan een ander HealthLog-profiel. Log bij Strava in met het account van dit profiel, of ontkoppel het eerst van het andere profiel."
},
"appleHealth": {
"title": "Apple Health",
@@ -7465,7 +7624,14 @@
"passkeyAddedToast": "Passkey toegevoegd.",
"passkeyRemovedToast": "Passkey verwijderd.",
"integrationDisconnected": "{provider} ontkoppeld.",
"integrationDisconnectFailed": "{provider} kon niet worden ontkoppeld."
"integrationDisconnectFailed": "{provider} kon niet worden ontkoppeld.",
"timezoneSaved": "Tijdzone opgeslagen.",
"moduleState": {
"on": "Aan",
"off": "Uit",
"change": "Wijzigen in Modules"
},
"timezoneUseDevice": "Tijdzone van dit apparaat gebruiken ({zone})"
},
"admin": {
"title": "Beheer",
@@ -7600,6 +7766,7 @@
"heapLimitLabel": "Heaplimiet",
"heapLimitValue": "{mb} MB",
"heapLimitLow": "{mb} MB, laag. Stel APP_MEMORY_LIMIT=1536m in via .env",
"memoryBelowFloor": "Container {mb} MB, onder het ondersteunde minimum van {floor} MB. Stel APP_MEMORY_LIMIT={floor}m in via .env",
"offlineGeoOff": "Uit",
"offlineGeoChosen": "Online ({host})",
"apiGlobal": "API-endpoints en API-tokens toestaan.",
@@ -8855,6 +9022,10 @@
"detailedToast": "Meldingen bevatten nu gezondheidscijfers.",
"genericToast": "Meldingen bevatten geen gezondheidscijfers meer.",
"saveError": "Opslaan mislukt. Probeer het later opnieuw."
},
"checkupReminders": {
"title": "Herinneringen voor controles",
"description": "Zet afzonderlijke herinneringen voor controles aan of uit."
}
},
"i18n": {
@@ -9631,8 +9802,6 @@
"description": "Voer de meetwaarde in; de herinnering wordt als gedaan gemarkeerd."
},
"manage": {
"title": "Herinneringen beheren",
"description": "Zet afzonderlijke herinneringen aan of uit.",
"empty": "Nog geen herinneringen om aan te passen."
},
"dashboard": {
@@ -10016,6 +10185,27 @@
"doneToastFew": "Samengevoegd in {name}, {count} metingen verplaatst",
"doneToastOther": "Samengevoegd in {name}, {count} metingen verplaatst",
"error": "De biomarkers konden niet worden samengevoegd. Probeer het opnieuw."
},
"trend": {
"title": "Verloop",
"slope": {
"rising": "Stijgt met ongeveer {amount} {unit} per jaar over {n} waarden sinds {since}.",
"falling": "Daalt met ongeveer {amount} {unit} per jaar over {n} waarden sinds {since}.",
"flat": "Geen verandering over {n} waarden sinds {since}."
},
"projection": {
"exits": {
"high": "Als deze rechte lijn zo doorliep, zou ze rond {date} de bovengrens van je referentiebereik bereiken. Dat is een doortrekking, geen voorspelling.",
"low": "Als deze rechte lijn zo doorliep, zou ze rond {date} de ondergrens van je referentiebereik bereiken. Dat is een doortrekking, geen voorspelling."
},
"beyond_horizon": "In dit tempo blijft de lijn langer dan {years} jaar binnen je referentiebereik, de periode die je waarden beslaan.",
"beyond_horizon_capped": "In dit tempo blijft de lijn langer dan {years} jaar binnen je referentiebereik, zo ver kijkt de doortrekking vooruit.",
"no_exit": "De lijn loopt weg van de grenzen van je referentiebereik.",
"already_outside": "De lijn ligt al buiten je referentiebereik.",
"no_clear_trend": "Over deze waarden is de verandering niet duidelijk te onderscheiden van geen verandering, dus er wordt niets doorgetrokken.",
"stale": "De nieuwste waarde is van {date}, meer dan een jaar geleden. De lijn staat op die datum, zonder projectie."
},
"interval": "95%-interval van de jaarlijkse verandering: {lo} tot {hi} {unit}."
}
},
"modules": {
@@ -10957,7 +11147,16 @@
"openDetail": "Details tonen",
"historyLoadError": "Je testgeschiedenis kon niet worden geladen",
"historyLoadErrorHint": "Dit betekent niet dat je geen resultaten hebt; de lijst kon niet worden opgehaald. Probeer het opnieuw.",
"privacyNote": "Je antwoorden worden versleuteld opgeslagen en komen nooit bij de AI-coach terecht."
"privacyNote": "Je antwoorden worden versleuteld opgeslagen en komen nooit bij de AI-coach terecht.",
"trend": {
"sincePrevious": "Sinds je vorige check-in op {date}: {change} punten.",
"sinceFirst": "Sinds je eerste check-in op {date}: {change} punten.",
"verdict": {
"improved": "Dat bereikt de verandering van {threshold} punten die studies als betekenisvol zien, in de betere richting.",
"worsened": "Dat bereikt de verandering van {threshold} punten die studies als betekenisvol zien, in de minder goede richting.",
"within_threshold": "Dat is minder dan de verandering van {threshold} punten die studies als betekenisvol zien."
}
}
},
"records": {
"aiInclusion": {
@@ -12216,7 +12415,10 @@
"leafVisits": "Bezoeken",
"leafSurgicalHistory": "Operatiegeschiedenis",
"leafImmunizations": "Vaccinaties",
"leafEmergency": "Noodinformatie (bloedgroep, contactpersonen, wilsverklaringen)"
"leafEmergency": "Noodinformatie (bloedgroep, contactpersonen, wilsverklaringen)",
"leafMedicationEffect": "Verandering rond de start van elk medicijn (vereist de medicatielijst)",
"leafWorkouts": "Trainingen (overzicht per sport)",
"leafHrvSdnn": "HRV (SDNN)"
},
"recordSharing": {
"banner": {
@@ -13396,5 +13598,218 @@
"clinicalUpper": "Klinische bovengrens",
"clinicalLower": "Klinische ondergrens"
}
},
"activityCalendar": {
"title": "Activiteitenkalender",
"periodLabel": "Kalenderperiode",
"period": {
"week": "Week",
"month": "Maand"
},
"weekRange": "{start} t/m {end}",
"previous": "Vorige periode",
"next": "Volgende periode",
"metricLabel": "Dagen kleuren op",
"metric": {
"steps": "Stappen",
"activeMinutes": "Actieve minuten",
"load": "Trainingsbelasting"
},
"empty": "Nog geen stappen, actieve minuten of trainingen in deze periode.",
"loadError": "De activiteitenkalender kon niet worden geladen.",
"weekGoalsColumn": "Weekdoelen",
"weekGoalsMet": "Alle weekdoelen gehaald",
"weekGoalsNotMet": "Niet alle weekdoelen gehaald",
"cell": {
"steps": "{value} stappen",
"activeMinutes": "{value} actieve minuten",
"workouts": "{value} trainingen: {sports}"
},
"level": {
"none": "Geen gegevens",
"unrated": "Nog te weinig geschiedenis",
"low": "Ver onder je gewone niveau",
"below": "Onder je gewone niveau",
"usual": "Ongeveer zoals gewoonlijk",
"above": "Boven je gewone niveau",
"high": "Ver boven je gewone niveau",
"in_progress": "Vandaag, nog bezig"
},
"legend": "Vergeleken met je 12 weken daarvoor:",
"noBaseline": "Je gebruikelijke bereik verschijnt na 14 dagen met gegevens.",
"summary": {
"steps": "{value} stappen",
"activeMinutes": "{value} actieve minuten",
"workouts": "{value} trainingen",
"load": "Gemiddelde trainingsbelasting {value}",
"usual": "Gebruikelijk tot dit punt: {value}",
"noUsual": "Nog niet genoeg eerdere weken of maanden om mee te vergelijken.",
"usualThroughYesterday": "Tot en met gisteren {value}, je gebruikelijke tot dan {usual}",
"firstDay": "Vergeleken met je gebruikelijke zodra de eerste dag voorbij is."
},
"sportCount": "{sport}: {count}",
"goal": {
"workoutsPerWeek": "Trainingen per week",
"activeMinutesPerWeek": "Actieve minuten per week",
"stepsPerWeek": "Stappen per week",
"stepsPerMonth": "Stappen per maand"
},
"goalOf": "{value} van {target}",
"setGoals": "Doelen instellen",
"editGoals": "Doelen wijzigen",
"goalsSheet": {
"title": "Activiteitsdoelen",
"description": "Laat een veld leeg als je geen doel wilt.",
"none": "Geen doel",
"invalid": "Een heel getal van {min} tot {max}.",
"saveError": "De doelen konden niet worden opgeslagen."
},
"crossLink": {
"title": "Activiteitenkalender",
"body": "Je stappen per week of maand, vergeleken met wat gewoon is voor jou.",
"cta": "Kalender openen"
}
},
"sourceProvenance": {
"title": "Waar de waarden vandaan komen",
"description": "Welke bron elke dag een waarde leverde en welke je bronprioriteit gebruikt.",
"metric": "Meetwaarde",
"day": "Dag",
"dayTitle": "Bronnen op deze dag",
"loadError": "De bronnen konden niet worden geladen.",
"dayEmpty": "Geen waarden van deze meting op deze dag.",
"reason": {
"only_source": "{source} is op deze dag de enige bron.",
"highest_on_ladder": "{source} wordt gebruikt: van de bronnen van deze dag staat die het hoogst in je bronprioriteit.",
"name_tiebreak": "{source} wordt gebruikt: geen enkele bron van deze dag staat in je bronprioriteit, dus de naam besliste.",
"not_ranked": "Deze meting heeft geen bronprioriteit: de waarden van alle bronnen tellen mee."
},
"pass": {
"lower_on_ladder": "Lager in je bronprioriteit.",
"not_on_ladder": "Staat voor deze meting niet in je bronprioriteit.",
"name_tiebreak": "Zelfde rang als de gebruikte bron; de naam besliste."
},
"used": "Gebruikt",
"readings": "{count} metingen, apparaten: {devices}",
"ladder": "Je volgorde voor deze meting: {ladder}",
"utcNote": "De bron wordt per dag gekozen, van middernacht tot middernacht UTC, net als bij de grafieken.",
"openSettings": "Volgorde wijzigen in Bronprioriteit",
"compareTitle": "Twee bronnen vergelijken",
"range": "Periode",
"rangeDays": "{days} dagen",
"compareNeedsTwo": "In deze periode heeft maar één bron waarden van deze meting.",
"side": {
"a": "Eerste bron",
"b": "Tweede bron"
},
"daysBoth": "Dagen met beide",
"meanOf": "Gemiddelde van {source}",
"meanAbsDiff": "Gemiddeld verschil",
"pickedDays": "Gebruikte dagen (eerste / tweede)",
"pickedSplit": "{a} / {b}",
"compareNote": "De gemiddelden tellen alleen dagen waarop beide bronnen waarden hebben, zodat je gelijke dagen vergelijkt. Vetgedrukt is de waarde die de grafieken gebruiken.",
"settingsHint": "Om te zien wat deze volgorde per dag beslist:",
"settingsHintLink": "Waar de waarden vandaan komen",
"metricLink": "Waar deze waarden vandaan komen"
},
"recordImport": {
"title": "Labuitslagen en medicijnen uit een spreadsheet",
"description": "Haal jaren aan labuitslagen of een medicijnlijst binnen uit een CSV-bestand.",
"detail": "Jij kiest welke kolom wat bevat; HealthLog controleert eerst elke rij en importeert pas als je bevestigt. Rijen die er al zijn worden overgeslagen.",
"kind": {
"labs": "Labuitslagen",
"medications": "Medicijnen"
},
"sheetTitle": {
"labs": "Labuitslagen importeren",
"medications": "Medicijnen importeren"
},
"sheetDescription": {
"labs": "Markers worden aan de jouwe gekoppeld, eenheden omgerekend als de omrekening bekend is.",
"medications": "Naam, dosis, dagelijkse tijden, zo nodig, begin en einde. Andere ritmes stel je daarna in de app in."
},
"file": "CSV-bestand",
"template": "Sjabloon downloaden",
"mapping": "Welke kolom bevat wat",
"required": "(verplicht)",
"notInFile": "Niet in het bestand",
"column": "Kolom {n}",
"delimiter": "Scheidingsteken",
"delimiterOption": {
"comma": "Komma",
"semicolon": "Puntkomma",
"tab": "Tab"
},
"decimal": "Decimaalteken",
"decimalOption": {
"dot": "Punt (5.4)",
"comma": "Komma (5,4)"
},
"dateOrder": "Datumnotatie",
"dateOrderOption": {
"iso": "2025-03-01",
"dmy": "01.03.2025 (dag eerst)",
"mdy": "03/01/2025 (maand eerst)"
},
"header": "De eerste rij bevat kolomnamen",
"reminders": "Herinneringen aanzetten voor geïmporteerde medicijnen",
"timezoneNote": "Tijden zonder tijdzone worden in de tijdzone van je account gelezen. Een datum zonder tijd blijft die kalenderdag.",
"missing": "Nog te kiezen: {fields}",
"check": "Bestand controleren",
"runIdle": "Importeren",
"run": "{count} rijen importeren",
"close": "Sluiten",
"summaryCheck": "{rows} rijen gelezen: {inserted} te importeren, {duplicates} al aanwezig, {failed} met een probleem.",
"summaryDone": "{inserted} rijen geïmporteerd. Overgeslagen: {duplicates} die er al waren en {failed} met een probleem.",
"markersMatched": "Gekoppeld aan je markers:",
"markersCreated": "Nieuwe markers:",
"line": "Regel {line}",
"errorsTruncated": "Alleen de eerste 500 problemen worden getoond.",
"error": {
"tooLarge": "Het bestand is groter dan 16 MB.",
"rateLimited": "Te veel imports in het afgelopen uur. Probeer het later opnieuw.",
"invalid": "Het bestand of de kolomkeuze kon niet worden gelezen.",
"failed": "Het importeren is niet gelukt. Probeer het opnieuw."
},
"reason": {
"missing_value": "Een verplichte cel is leeg",
"invalid_number": "Geen getal",
"ambiguous_number": "Het getal is op twee manieren te lezen; controleer het decimaalteken",
"missing_unit": "Een getal heeft een eenheid nodig",
"invalid_date": "Geen datum in de gekozen notatie",
"invalid_time": "Geen tijd",
"ambiguous_date": "De datum is op twee manieren te lezen; gebruik een jaartal van vier cijfers en de gekozen notatie",
"future_date": "De datum ligt in de toekomst",
"text_too_long": "De tekst is te lang",
"unit_mismatch": "Deze eenheid is niet om te rekenen naar die van de marker",
"invalid_reference_range": "Het referentiebereik is niet te lezen",
"invalid_times": "De tijden zijn niet te lezen (bijv. 08:00 20:00)",
"missing_times": "Vul innametijden in of markeer het medicijn als zo nodig",
"invalid_yes_no": "Gebruik ja of nee",
"as_needed_with_times": "Een zo-nodig-medicijn heeft geen vaste tijden",
"end_before_start": "Het einde ligt voor het begin",
"invalid_medication": "Het medicijn kan zo niet worden opgeslagen",
"oversized_row": "De rij is te lang of te breed",
"row_limit": "Boven de rijlimiet voor één import",
"duplicate": "Al aanwezig, overgeslagen"
},
"field": {
"analyte": "Marker",
"value": "Uitslag",
"unit": "Eenheid",
"date": "Datum",
"time": "Tijd",
"referenceRange": "Referentiebereik",
"referenceLow": "Referentie laag",
"referenceHigh": "Referentie hoog",
"panel": "Groep",
"note": "Notitie",
"name": "Naam",
"dose": "Dosis",
"times": "Tijden",
"asNeeded": "Zo nodig",
"startsOn": "Begin",
"endsOn": "Einde"
}
}
}
+465 -50
View File
@@ -401,7 +401,41 @@
"lifeSittingValue": "{hours} godz., według deklaracji",
"lifeWork": "Praca",
"lifeNightWork": "praca w nocy",
"lifeExposures": "narażenia: {list}"
"lifeExposures": "narażenia: {list}",
"workouts": {
"title": "Treningi",
"summary": "{sessions} treningów, łącznie {hours} h; średnio {perWeek} treningu i {minutesPerWeek} minut tygodniowo.",
"colSport": "Dyscyplina",
"colSessions": "Treningi",
"colTime": "Czas",
"colDistance": "Dystans",
"colEnergy": "Energia",
"colHeartRate": "Średnie tętno",
"moreSports": "Kolejne dyscypliny ({count}) są wliczone do sum, ale nie wymienione."
},
"medicationEffect": {
"title": "Zmiana wokół rozpoczęcia leków",
"colMedication": "Lek / pomiar",
"colStart": "Początek",
"colLevel": "Zmiana poziomu (95% CI)",
"colSlope": "Zmiana trendu (95% CI)",
"colDays": "Dni przed / po",
"to": "do",
"week": "tydzień",
"reasonNoStart": "Brak zapisanej daty rozpoczęcia",
"reasonBefore": "Za mało pomiarów przed rozpoczęciem",
"reasonAfter": "Za mało pomiarów od rozpoczęcia",
"reasonFit": "Nie można obliczyć",
"excludedAdherence": "Pominięto {count} dni po rozpoczęciu (przyjęto mniej niż {floor}% dawek)",
"excludedNotTaking": "Pominięto {count} dni (lek wstrzymany lub przerwa między kuracjami)",
"notTracked": "przyjmowanie nie jest rejestrowane, więc nie pominięto dni z powodu opuszczonych dawek",
"endDoseChange": "okres kończy się {date}, przed pierwszą zmianą dawki",
"days": "{before} dni przed, {after} dni po rozpoczęciu, pominięto {excluded} dni",
"method": "Regresja segmentowa na wartościach dziennych od 90 dni przed do najwyżej 180 dni po rozpoczęciu, przedziały Neweya-Westa. Pominięto dni po rozpoczęciu, w których w ciągu poprzednich 14 dni przyjęto mniej niż 80% dawek. Opisuje związek w czasie, a nie udowodnione działanie leku.",
"autocorrelation": "silna zależność od poprzedniego dnia, więc zakresy mogą nadal być zbyt wąskie",
"window": "analizowano od {from} do {to}, może to wykraczać poza okres raportu",
"windowLine": "Analizowano od {from} do {to}. Ten okres może wykraczać poza okres raportu."
}
},
"errorBoundary": {
"title": "Coś poszło nie tak",
@@ -911,6 +945,15 @@
"unitRating": "poziom",
"typeWho5Score": "Wynik WHO-5",
"typeSciScore": "Wynik SCI",
"typeExerciseMinutes": "Minuty ćwiczeń",
"typeStandMinutes": "Minuty stania",
"typeStandHours": "Godziny stania",
"typeMindfulMinutes": "Minuty uważności",
"typeStateOfMindValence": "Stan umysłu",
"typeAfibBurden": "Obciążenie AF",
"typeIndoorCo2": "CO₂ w pomieszczeniu",
"typeIndoorTemperature": "Temperatura w pomieszczeniu",
"typeIndoorHumidity": "Wilgotność w pomieszczeniu",
"durationReadAs": "Odczytano jako {duration}",
"durationHoursMinutes": "{hours} godz. {minutes} min",
"durationHoursOnly": "{hours} godz.",
@@ -2350,7 +2393,31 @@
},
"scaleAbsolute": "Bezwzględne",
"scalePercentSinceStart": "% od początku",
"scaleToggleLabel": "Skala wykresu"
"scaleToggleLabel": "Skala wykresu",
"segmented": {
"title": "Zmiana na początku",
"level": "Zaraz na początku poziom przesunął się o {value} {unit} (zakres 95%: od {low} do {high}).",
"slope": "Po rozpoczęciu trend tygodniowy zmienił się o {value} {unit} na tydzień (zakres 95%: od {low} do {high}).",
"preSlope": "Przed rozpoczęciem trend wynosił {value} {unit} na tydzień.",
"includesZero": "Gdy zakres obejmuje zero, te pomiary są zgodne także z brakiem zmiany.",
"basis": "Na podstawie {before} dni przed i {after} dni po rozpoczęciu (od {from} do {to}).",
"excludedAdherence": "Pominięto {count} dni po rozpoczęciu, bo w ciągu {lookback} dni przed nimi przyjęto mniej niż {floor}% dawek.",
"excludedNotTaking": "Pominięto {count} dni, bo lek był wstrzymany albo przypadały między kuracjami.",
"notTracked": "Przyjmowanie tego leku nie jest rejestrowane, więc nie pominięto żadnych dni z powodu opuszczonych dawek.",
"endDoseChange": "Okres kończy się przed pierwszą zmianą dawki, więc opisuje dawkę początkową.",
"confoundersTitle": "Inne zmiany leków w tym okresie, których ten widok nie potrafi oddzielić od tego leku:",
"confounderStarted": "{name} rozpoczęty {date}",
"confounderStopped": "{name} zakończony {date}",
"confounderDoseChange": "{name}: zmiana dawki {date}",
"confounderPaused": "{name} wstrzymany {date}",
"confounderResumed": "{name} wznowiony {date}",
"method": "Jak to jest liczone: przez dzienne wartości sprzed rozpoczęcia prowadzi się jedną prostą, a przez te późniejsze drugą. Skok między nimi w dniu rozpoczęcia to zmiana poziomu, różnica ich nachyleń to zmiana trendu. Zakresy uwzględniają, że wartości z jednego dnia przenoszą się na następny.",
"needStart": "Ustaw datę rozpoczęcia, aby zobaczyć zmianę na początku.",
"needBefore": "Do zmiany na początku potrzeba pomiarów z co najmniej {days} dni rozłożonych na {span} dni przed rozpoczęciem.",
"needAfter": "Do zmiany na początku potrzeba pomiarów z co najmniej {days} dni rozłożonych na {span} dni od rozpoczęcia, w dni z przyjętymi dawkami.",
"fitFailed": "Nie udało się dopasować tych pomiarów, więc zmiana na początku nie jest pokazana.",
"autocorrelation": "Wartości dzienne silnie zależą od poprzedniego dnia, więc nawet te zakresy mogą być zbyt wąskie."
}
},
"import": {
"resultSuccess": "Zaimportowano przyjęcia: {imported}",
@@ -2679,7 +2746,20 @@
"ENV_PRESSURE_DELTA": "dobowa zmiana ciśnienia",
"ENV_PM25": "pył drobny (PM2,5)",
"ENV_OZONE_8H": "ozon",
"ENV_POLLEN_MAX": "pyłki"
"ENV_POLLEN_MAX": "pyłki",
"HRV_RMSSD": "zmienność rytmu serca (RMSSD)",
"HYDRATION": "spożycie wody",
"CAFFEINE": "spożycie kofeiny",
"ALCOHOL": "spożycie alkoholu",
"WORKOUT_MINUTES": "minuty treningu",
"TRAINING_LOAD": "obciążenie treningowe",
"EXERCISE_MINUTES": "minuty ćwiczeń",
"STAND_MINUTES": "minuty na stojąco",
"MINDFUL_MINUTES": "minuty uważności",
"STATE_OF_MIND_VALENCE": "zapisany stan ducha",
"INDOOR_CO2": "CO2 w pomieszczeniu",
"INDOOR_TEMPERATURE": "temperatura w pomieszczeniu",
"INDOOR_HUMIDITY": "wilgotność w pomieszczeniu"
},
"daily": {
"high": "Gdy {behaviour} rośnie, {outcome} następnego dnia zwykle wykazuje tendencję do {direction} w Twoich danych. To wzorzec wart obserwacji, nie przyczyna.",
@@ -2707,6 +2787,12 @@
"high": "Więcej {behaviour} tego samego dnia i dzień wcześniej idzie w twoich danych zwykle w parze z {direction} {outcome}. To wzorzec wart obserwacji, nie przyczyna.",
"moderate": "Więcej {behaviour} tego samego dnia i dzień wcześniej wydaje się, na podstawie dotychczasowych danych, iść w parze z {direction} {outcome}. To wzorzec wart obserwacji, nie przyczyna.",
"faint": "Więcej {behaviour} tego samego dnia i dzień wcześniej pokazuje w twoich danych co najwyżej słaby ślad {direction} {outcome}. To za mało, by na tym polegać, i nigdy nie jest to przyczyna."
},
"channelNutrient": "spożycie: {nutrient}",
"dailyOvernight": {
"high": "Więcej {behaviour} w nocy idzie w twoich danych zwykle w parze z {direction} {outcome} tej samej nocy. To wzorzec wart obserwacji, nie przyczyna.",
"moderate": "Więcej {behaviour} w nocy wydaje się, na podstawie dotychczasowych danych, iść w parze z {direction} {outcome} tej samej nocy. To wzorzec wart obserwacji, nie przyczyna.",
"faint": "Więcej {behaviour} w nocy pokazuje w twoich danych co najwyżej słaby ślad {direction} {outcome} tej samej nocy. To za mało, by na tym polegać, i nigdy nie jest to przyczyna."
}
},
"noAnalysisYet": "Brak oceny.",
@@ -3264,11 +3350,12 @@
"provider": "Odpowiada przez",
"modelManaged": "Model tego dostawcy ustawisz w ustawieniach AI.",
"noProvider": "Brak dostępnego dostawcy",
"allSettings": "Wszystkie ustawienia AI",
"operatorCodex": "ChatGPT (udostępniony przez operatora)",
"changeModel": "Zmień w Ustawieniach > AI",
"saving": "Zapisywanie…",
"saveFailed": "Nie udało się zapisać zmiany.",
"allSettings": "Wszystkie ustawienia AI",
"providerNotActive": "{provider} nie mógł przejąć odpowiedzi: brak działającego logowania lub klucza. Skonfiguruj go w ustawieniach AI.",
"operatorCodex": "ChatGPT (udostępniony przez operatora)",
"operatorManaged": "Tym połączeniem zarządza operator Twojego serwera. Gdy skonfigurujesz własnego dostawcę, to on odpowie jako pierwszy."
},
"answer": {
@@ -3565,6 +3652,7 @@
"falls": "upadki",
"years": "lata",
"breathingEvents": "zdarzenia",
"standHours": "godz.",
"respiratoryRate": "oddechów/min"
},
"sleep": {
@@ -3807,6 +3895,16 @@
"editTitle": "Edytuj trening",
"editAction": "Edytuj",
"updated": "Trening zaktualizowany"
},
"load": {
"title": "Obciążenie treningowe",
"fitness": "Wydolność",
"fatigue": "Zmęczenie",
"form": "Forma",
"explainer": "Wydolność to twój bodziec treningowy z tętna uśredniony z około sześciu tygodni, zmęczenie z około tygodnia, a forma to różnica. Liczby porównuje się z twoją własną przeszłością, nie z innymi osobami ani aplikacjami.",
"warmingUp": "Na podstawie dotychczasowych {days} dni: dopóki nie minie sześć tygodni, wydolność wychodzi niższa, niż jest.",
"coverage": "Udało się ocenić {rated} z ostatnich {days} dni. Dzień bez danych o aktywności lub z treningiem, którego nie dało się ocenić, jest pomijany, a nie liczony jako odpoczynek.",
"asOf": "Stan na {date}, ostatni dzień, który dało się ocenić."
}
},
"bloodGlucose": {
@@ -4248,7 +4346,12 @@
"outcomeDown": "Lepszy nastrój zwykle wiąże się z niższym {factor} następnego dnia w Twoich danych.",
"stat": "{n} sparowanych dni, r = {r}, q = {q}",
"statLabel": "Jak to obliczono",
"explainerLabel": "Jak to obliczono"
"explainerLabel": "Jak to obliczono",
"channel": {
"ALCOHOL": "spożycie alkoholu",
"WORKOUT_MINUTES": "minuty treningu",
"TRAINING_LOAD": "obciążenie treningowe"
}
},
"inTargetTitle": "W Twoim zakresie",
"dimensions": {
@@ -4709,7 +4812,8 @@
"shiftDown": "{vital} przesunął się w dół względem Twojej ostatniej normy.",
"rangeContext": "Teraz {value}, zwykle {low}–{high}.",
"rangeContextTimeOfDay": "Teraz {value}, zwykle {low}–{high} o tej porze dnia.",
"shiftContext": "Około {before} wcześniej, {after} od tego czasu."
"shiftContext": "Około {before} wcześniej, {after} od tego czasu.",
"shiftDays": "Na podstawie {before} dni wcześniej i {after} od tego czasu."
},
"breathingScreening": {
"sectionTitle": "Oddychanie podczas snu",
@@ -5289,12 +5393,17 @@
"groups": {
"account": "Konto",
"yourData": "Twoje dane",
"display": "Wyświetlanie",
"healthProfile": "Profil zdrowotny",
"ai": "AI",
"connectivity": "Łączność",
"system": "System"
}
"system": "System",
"tracking": "Śledzenie",
"arrangement": "Układ",
"notifications": "Powiadomienia",
"connections": "Połączenia",
"data": "Dane"
},
"allSections": "Wszystkie sekcje"
},
"sharedRecord": {
"managedSettings": {
@@ -5406,8 +5515,15 @@
"unavailableTitle": "Niedostępne w tym udostępnionym rekordzie",
"unavailableDescription": "Ten obszar Ustawień jest celowo niedostępny podczas przeglądania rekordu innej osoby.",
"integrationStatusDescription": "Stan połączenia dla tego zarządzanego rekordu.",
"integrationStatusDetail": "Profil zarządzany pokazuje połączone usługi tylko jako stan. Opiekunowie nie mogą łączyć, ponownie łączyć, synchronizować ani rozłączać.",
"integrationStatusDocs": "Jak działa udostępnianie"
"integrationStatusDetail": "Usługi połączone tutaj wysyłają dane do profilu {name}, a nie do Twojego. Jeśli profil nie ma własnej aplikacji u dostawcy, używa Twojej.",
"integrationStatusDocs": "Jak działa udostępnianie",
"integrationConnectsFor": "Łączy dla: {name}",
"integrationConnectFor": "Połącz dla: {name}",
"integrationDisconnect": "Rozłącz",
"integrationDisconnectTitle": "Rozłączyć {provider} od profilu {name}?",
"integrationDisconnectBody": "Nowe dane przestaną napływać. Wszystko, co już zaimportowano, zostaje w profilu {name}.",
"integrationNoClientApp": "Ani {name}, ani Twoje konto nie ma jeszcze aplikacji {provider}. Najpierw skonfiguruj ją we własnych integracjach.",
"integrationConnectFailed": "Nie udało się rozpocząć łączenia z {provider}."
},
"sections": {
"account": {
@@ -5428,11 +5544,25 @@
"remindersHeading": "Przypomnienia",
"matrixLink": "Powiadomienia wg kanału",
"channelsLink": "Kanały dostarczania",
"displayHeading": "Na telefonie"
"displayHeading": "Na telefonie",
"elsewhere": {
"title": "Ustawiane tam, gdzie należą",
"description": "Te przypomnienia ustawiasz obok tego, czego dotyczą.",
"doses": {
"title": "Dawki leków",
"description": "Godziny przyjmowania i przypomnienia ustawiasz przy każdym leku.",
"link": "Otwórz leki"
},
"cycle": {
"title": "Cykl",
"description": "Przypomnienia o miesiączce i dniach płodnych włączasz dla każdego kanału w tabeli powiadomień.",
"link": "Otwórz tabelę powiadomień"
}
}
},
"layout": {
"title": "Układ",
"backToHub": "Powrót do układu",
"backToHub": "Wróć do Układu",
"dashboard": {
"title": "Pulpit",
"description": "Układ i kolejność kafelków."
@@ -5441,7 +5571,7 @@
"title": "Analizy",
"description": "Kolejność przeglądu i pigułek nawigacyjnych."
},
"subtitle": "Wybierz, co pokazują pulpit, analizy i moduły śledzenia oraz w jakiej kolejności.",
"subtitle": "Wybierz kolejność i widok pulpitu, analiz i list śledzenia.",
"medications": {
"title": "Leki",
"description": "Widok listy i ręczna kolejność."
@@ -5458,10 +5588,6 @@
"title": "Badania kontrolne",
"description": "Widok listy i kolejność przypomnień."
},
"mood": {
"title": "Tagi nastroju",
"description": "Grupy, tagi i zarchiwizowane tagi w wyborze nastroju."
},
"documents": {
"title": "Dokumenty",
"description": "Widok i układ miesięcy."
@@ -5852,7 +5978,28 @@
},
"manageIn": "Zarządzaj w {section}",
"operatorDisabled": "Wyłączone na całym serwerze",
"subtitle": "Wybierz, które obszary śledzenia pokazuje HealthLog w aplikacji."
"subtitle": "Wybierz, które obszary śledzenia pokazuje HealthLog w aplikacji.",
"backToHub": "Wróć do Modułów",
"openModuleSettings": "Ustawienia modułu",
"pages": {
"medications": {
"title": "Leki",
"description": "Miejsca wstrzyknięć pomijane w rotacji."
},
"mood": {
"title": "Tagi nastroju",
"description": "Grupy, własne tagi i tagi zarchiwizowane."
},
"labs": {
"title": "Badania",
"description": "Biomarkery i rozpoznawanie tekstu na urządzeniu dla wyników badań."
},
"arrangementTitle": "Kolejność i widok",
"arrangementDescription": "Sortowanie i widok tej listy ustawiasz w Układzie.",
"openArrangement": "Otwórz w Układzie",
"moduleSettingsTitle": "Ustawienia modułu",
"moduleSettingsDescription": "Wszystko poza kolejnością i widokiem jest na stronie modułu w Modułach."
}
},
"gesundheitsakte": {
"title": "Dokumentacja zdrowotna",
@@ -6499,7 +6646,8 @@
"nocode": "Withings nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Withings.",
"nocreds": "Brak zapisanych danych dostępowych API Withings. Najpierw zapisz Client ID i Client Secret, potem połącz ponownie.",
"token": "Wymiana tokenów z Withings nie powiodła się. Sprawdź Client ID i Client Secret i spróbuj ponownie.",
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie.",
"already_linked": "To konto Withings jest już połączone z innym profilem HealthLog. Zaloguj się do Withings kontem należącym do tego profilu albo najpierw odłącz je od tamtego profilu."
},
"withingsSynchronize": "Synchronizuj",
"withingsDisconnect": "Rozłącz",
@@ -6538,7 +6686,8 @@
"nocode": "WHOOP nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie WHOOP.",
"nocreds": "WHOOP nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth WHOOP i spróbuj ponownie.",
"token": "Wymiana tokena z WHOOP nie powiodła się. Rozpocznij połączenie ponownie.",
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie.",
"already_linked": "To konto WHOOP jest już połączone z innym profilem HealthLog. Zaloguj się do WHOOP kontem należącym do tego profilu albo najpierw odłącz je od tamtego profilu."
},
"fitbit": "Fitbit",
"fitbitTag": "Fitbit i Pixel",
@@ -6617,7 +6766,8 @@
"nocode": "Google nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Google.",
"nocreds": "Google Health nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth Google i spróbuj ponownie.",
"token": "Wymiana tokenu z Google nie powiodła się. Rozpocznij połączenie ponownie.",
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie.",
"already_linked": "To konto Google jest już połączone z innym profilem HealthLog. Zaloguj się do Google kontem należącym do tego profilu albo najpierw odłącz je od tamtego profilu."
},
"fitbitOauthError": {
"csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.",
@@ -6628,7 +6778,8 @@
"nocreds": "Fitbit nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth Fitbit i spróbuj ponownie.",
"token": "Wymiana tokena z Fitbit nie powiodła się. Rozpocznij połączenie ponownie.",
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie.",
"retired": "Google wyłączył Fitbit Web API, więc nie można już utworzyć nowego połączenia Fitbit. Połącz zamiast tego Google Health, które odczytuje dane z tych samych urządzeń."
"retired": "Google wyłączył Fitbit Web API, więc nie można już utworzyć nowego połączenia Fitbit. Połącz zamiast tego Google Health, które odczytuje dane z tych samych urządzeń.",
"already_linked": "To konto Fitbit jest już połączone z innym profilem HealthLog. Zaloguj się do Fitbit kontem należącym do tego profilu albo najpierw odłącz je od tamtego profilu."
},
"integrations": {
"withings": {
@@ -6665,7 +6816,9 @@
"items": {
"vo2max": "VO2 max",
"cardio_age": "wiek sercowo-naczyniowy",
"resilience": "odporność"
"resilience": "odporność",
"workout": "treningi",
"heartrate": "tętno"
}
}
}
@@ -6972,7 +7125,8 @@
"nocreds": "Polar nie jest skonfigurowany na tym serwerze. Poproś administratora o dodanie danych OAuth Polar.",
"token": "Wymiana tokena z Polar nie powiodła się. Rozpocznij połączenie ponownie.",
"rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.",
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie.",
"already_linked": "To konto Polar jest już połączone z innym profilem HealthLog. Zaloguj się do Polar kontem należącym do tego profilu albo najpierw odłącz je od tamtego profilu."
},
"oura": "Oura",
"ouraDescription": "Połącz konto Oura, aby synchronizować gotowość, fazy snu, zmienność rytmu serca i dzienną aktywność.",
@@ -7002,25 +7156,24 @@
"token": "Wymiana tokena z Oura nie powiodła się. Rozpocznij połączenie ponownie.",
"rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.",
"issuer": "Odpowiedź logowania nie pochodziła z serwera autoryzacji Oura, więc połączenie zostało przerwane dla Twojego bezpieczeństwa. Rozpocznij połączenie ponownie. Jeśli to się powtarza, powiadom administratora.",
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie.",
"already_linked": "To konto Oura jest już połączone z innym profilem HealthLog. Zaloguj się do Oura kontem należącym do tego profilu albo najpierw odłącz je od tamtego profilu."
},
"integrationSetupGuide": "Przewodnik konfiguracji",
"coach": {
"activate": {
"title": "Włącz Coacha",
"description": "Pokazuje przycisk i panel Coacha.",
"toggleAria": "Włącz przycisk i panel Coacha",
"savedOn": "Coach włączony.",
"savedOff": "Coach ukryty.",
"saveError": "Nie udało się zapisać zmiany. Spróbuj ponownie."
},
"memory": {
"edit": "Edytuj",
"source": {
"user": "od ciebie",
"coach": "od Coacha"
}
}
},
"state": {
"title": "Coach",
"description": "Czy przycisk i panel Coacha są widoczne. Przełącznik jest w Modułach."
},
"nudgesMoved": "Ustawiasz je razem z innymi przypomnieniami w Powiadomieniach.",
"openNudges": "Otwórz w Powiadomieniach"
},
"integrationRedirectGuide": {
"intro": "To połączenie działa przez Twoją własną aplikację deweloperską {provider}, około 5 minut konfiguracji:",
@@ -7155,6 +7308,12 @@
"profile": "Twój profil, wywiad medyczny i dane dokumentacji zdrowotnej",
"modules": "Wpisy poszczególnych modułów: {list}"
},
"workoutRoutes": {
"title": "Trasy treningów",
"description": "Zapisuj ślad GPS nowych treningów z aplikacji na iPhone’a i z eksportu Apple Zdrowie.",
"detail": "Wyłączone dla nowych kont. Trasa pokazuje, gdzie mieszkasz, pracujesz i trenujesz, więc zapisujemy ją dopiero po włączeniu. Treningi są zapisywane zawsze, a żadna funkcja AI nigdy nie odczytuje trasy. Wyłączenie zatrzymuje nowe trasy; już zapisane zostają do usunięcia treningu.",
"loadError": "Nie udało się wczytać ustawienia tras."
},
"retention": {
"title": "Retencja",
"description": "Jak długo rekordy ograniczone czasowo są przechowywane przed automatycznym czyszczeniem.",
@@ -7163,12 +7322,6 @@
"delivery": "Dziennik wysyłki powiadomień: {days} dni",
"unavailable": "Szczegóły przechowywania są obecnie niedostępne."
},
"export": {
"title": "Pobierz moje dane",
"description": "Twoje rekordy jako CSV lub JSON albo pełna dokumentacja dla lekarza.",
"openExport": "Eksport i kopia zapasowa",
"openHealthRecord": "Dokumentacja medyczna"
},
"delete": {
"title": "Usuń lub zresetuj",
"description": "Usuń całe konto albo wyczyść dane zdrowotne, zachowując konto.",
@@ -7179,12 +7332,16 @@
"title": "Ustawienia prywatności",
"description": "Kontroluj, ile Twoich danych jest udostępnianych funkcjom AI.",
"openAi": "Tryb prywatności AI"
},
"sessions": {
"title": "Sesje i urządzenia",
"description": "Gdzie jesteś zalogowany, które urządzenia pomijają drugi składnik i aktywność zabezpieczeń twojego konta.",
"open": "Otwórz zabezpieczenia"
}
},
"mcp": {
"enableTitle": "Zdalny punkt końcowy MCP",
"enableDescription": "Zezwól asystentowi obsługującemu MCP (Claude.ai, ChatGPT) na odczyt danych zdrowotnych za pomocą ograniczonego tokenu.",
"enableToggleLabel": "Włącz punkt końcowy MCP",
"endpointLabel": "Adres URL łącznika",
"connectDescription": "Wklej ten adres URL w konfiguracji własnego łącznika asystenta, a następnie autoryzuj za pomocą poniższego tokenu lub zaloguj się po wyświetleniu monitu.",
"tokensTitle": "Tokeny łącznika",
@@ -7203,7 +7360,8 @@
"connectionRevokeFailed": "Nie udało się rozłączyć asystenta.",
"tokensDetail": "Token jest wyświetlany tylko raz.",
"connectionsDetail": "Cofnięcie połączenia natychmiast kończy dostęp, w tym możliwość odnawiania.",
"tokenNamePlaceholder": "Nazwa, np. Claude Desktop"
"tokenNamePlaceholder": "Nazwa, np. Claude Desktop",
"offKeepsTokens": "Wyłączenie wstrzymuje punkt końcowy. Tokeny i połączenia zostają i działają ponownie po włączeniu."
},
"strava": "Strava",
"stravaDescription": "Połącz konto Strava, aby synchronizować aktywności z historią treningów.",
@@ -7232,7 +7390,8 @@
"nocreds": "Strava nie jest skonfigurowana na tym serwerze. Poproś administratora o dodanie danych API Strava.",
"token": "Wymiana tokenu ze Strava nie powiodła się. Rozpocznij połączenie ponownie.",
"rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.",
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie.",
"already_linked": "To konto Strava jest już połączone z innym profilem HealthLog. Zaloguj się do Strava kontem należącym do tego profilu albo najpierw odłącz je od tamtego profilu."
},
"appleHealth": {
"title": "Apple Health",
@@ -7465,7 +7624,14 @@
"passkeyAddedToast": "Dodano passkey.",
"passkeyRemovedToast": "Usunięto passkey.",
"integrationDisconnected": "Rozłączono {provider}.",
"integrationDisconnectFailed": "Nie udało się rozłączyć {provider}."
"integrationDisconnectFailed": "Nie udało się rozłączyć {provider}.",
"timezoneSaved": "Strefa czasowa zapisana.",
"moduleState": {
"on": "Włączone",
"off": "Wyłączone",
"change": "Zmień w Modułach"
},
"timezoneUseDevice": "Użyj strefy czasowej tego urządzenia ({zone})"
},
"admin": {
"title": "Administracja",
@@ -7600,6 +7766,7 @@
"heapLimitLabel": "Limit sterty",
"heapLimitValue": "{mb} MB",
"heapLimitLow": "{mb} MB, za mało: ustaw APP_MEMORY_LIMIT=1536m w .env",
"memoryBelowFloor": "Kontener {mb} MB, poniżej obsługiwanego minimum {floor} MB: ustaw APP_MEMORY_LIMIT={floor}m w .env",
"offlineGeoOff": "Wyłączone",
"offlineGeoChosen": "Online ({host})",
"apiGlobal": "Zezwól na punkty końcowe API i tokeny API.",
@@ -8855,6 +9022,10 @@
"detailedToast": "Powiadomienia zawierają teraz wartości zdrowotne.",
"genericToast": "Powiadomienia nie zawierają już wartości zdrowotnych.",
"saveError": "Nie udało się zapisać. Spróbuj ponownie później."
},
"checkupReminders": {
"title": "Przypomnienia o badaniach kontrolnych",
"description": "Włączaj lub wyłączaj pojedyncze przypomnienia o badaniach."
}
},
"i18n": {
@@ -9631,8 +9802,6 @@
"description": "Wpisz wartość; przypomnienie zostanie oznaczone jako wykonane."
},
"manage": {
"title": "Zarządzaj przypomnieniami",
"description": "Włącz lub wyłącz poszczególne przypomnienia.",
"empty": "Brak przypomnień do dostosowania."
},
"dashboard": {
@@ -10016,6 +10185,27 @@
"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."
},
"trend": {
"title": "Trend",
"slope": {
"rising": "Rośnie o około {amount} {unit} rocznie na podstawie {n} wyników od {since}.",
"falling": "Spada o około {amount} {unit} rocznie na podstawie {n} wyników od {since}.",
"flat": "Brak zmian na podstawie {n} wyników od {since}."
},
"projection": {
"exits": {
"high": "Gdyby ta prosta biegła dalej, osiągnęłaby górną granicę twojego zakresu referencyjnego około {date}. To ekstrapolacja, nie prognoza.",
"low": "Gdyby ta prosta biegła dalej, osiągnęłaby dolną granicę twojego zakresu referencyjnego około {date}. To ekstrapolacja, nie prognoza."
},
"beyond_horizon": "W tym tempie prosta pozostaje w twoim zakresie referencyjnym dłużej niż {years} lat, czyli okres objęty twoimi wynikami.",
"beyond_horizon_capped": "W tym tempie prosta pozostaje w twoim zakresie referencyjnym dłużej niż {years} lat, czyli tak daleko, jak sięga ekstrapolacja.",
"no_exit": "Prosta oddala się od granic twojego zakresu referencyjnego.",
"already_outside": "Prosta jest już poza twoim zakresem referencyjnym.",
"no_clear_trend": "Na podstawie tych wyników zmiana nie różni się wyraźnie od braku zmiany, więc nie jest wyznaczana ekstrapolacja.",
"stale": "Najnowszy wynik pochodzi z {date}, sprzed ponad roku. Prosta jest pokazana na ten dzień, bez projekcji."
},
"interval": "95% przedział rocznej zmiany: od {lo} do {hi} {unit}."
}
},
"modules": {
@@ -10957,7 +11147,16 @@
"openDetail": "Pokaż szczegóły",
"historyLoadError": "Nie udało się wczytać historii testów",
"historyLoadErrorHint": "To nie znaczy, że nie masz wyników; nie udało się pobrać listy. Spróbuj ponownie.",
"privacyNote": "Twoje odpowiedzi są przechowywane w postaci zaszyfrowanej i nigdy nie trafiają do Coacha AI."
"privacyNote": "Twoje odpowiedzi są przechowywane w postaci zaszyfrowanej i nigdy nie trafiają do Coacha AI.",
"trend": {
"sincePrevious": "Od poprzedniego sprawdzenia {date}: {change} pkt.",
"sinceFirst": "Od pierwszego sprawdzenia {date}: {change} pkt.",
"verdict": {
"improved": "To osiąga zmianę o {threshold} pkt, którą badania uznają za istotną, w lepszym kierunku.",
"worsened": "To osiąga zmianę o {threshold} pkt, którą badania uznają za istotną, w mniej korzystnym kierunku.",
"within_threshold": "To mniej niż zmiana o {threshold} pkt, którą badania uznają za istotną."
}
}
},
"records": {
"aiInclusion": {
@@ -12216,7 +12415,10 @@
"leafVisits": "Wizyty lekarskie",
"leafSurgicalHistory": "Historia operacji",
"leafImmunizations": "Szczepienia",
"leafEmergency": "Informacje na wypadek nagły (grupa krwi, kontakty, oświadczenia)"
"leafEmergency": "Informacje na wypadek nagły (grupa krwi, kontakty, oświadczenia)",
"leafMedicationEffect": "Zmiana wokół rozpoczęcia każdego leku (wymaga listy leków)",
"leafWorkouts": "Treningi (podsumowanie według dyscypliny)",
"leafHrvSdnn": "HRV (SDNN)"
},
"recordSharing": {
"banner": {
@@ -13396,5 +13598,218 @@
"clinicalUpper": "Kliniczna górna granica",
"clinicalLower": "Kliniczna dolna granica"
}
},
"activityCalendar": {
"title": "Kalendarz aktywności",
"periodLabel": "Zakres kalendarza",
"period": {
"week": "Tydzień",
"month": "Miesiąc"
},
"weekRange": "od {start} do {end}",
"previous": "Poprzedni okres",
"next": "Następny okres",
"metricLabel": "Koloruj dni według",
"metric": {
"steps": "Kroki",
"activeMinutes": "Aktywne minuty",
"load": "Obciążenie treningowe"
},
"empty": "W tym okresie nie ma jeszcze kroków, aktywnych minut ani treningów.",
"loadError": "Nie udało się wczytać kalendarza aktywności.",
"weekGoalsColumn": "Cele tygodniowe",
"weekGoalsMet": "Wszystkie cele tygodniowe osiągnięte",
"weekGoalsNotMet": "Nie wszystkie cele tygodniowe osiągnięte",
"cell": {
"steps": "Kroki: {value}",
"activeMinutes": "Aktywne minuty: {value}",
"workouts": "Treningi: {value} ({sports})"
},
"level": {
"none": "Brak danych",
"unrated": "Za mało historii",
"low": "Znacznie poniżej twojej normy",
"below": "Poniżej twojej normy",
"usual": "Mniej więcej jak zwykle",
"above": "Powyżej twojej normy",
"high": "Znacznie powyżej twojej normy",
"in_progress": "Dziś, jeszcze trwa"
},
"legend": "W porównaniu z twoimi 12 wcześniejszymi tygodniami:",
"noBaseline": "Twój zwykły zakres pojawi się po 14 dniach z danymi.",
"summary": {
"steps": "Kroki: {value}",
"activeMinutes": "Aktywne minuty: {value}",
"workouts": "Treningi: {value}",
"load": "Średnie obciążenie treningowe {value}",
"usual": "Twoja norma do tego momentu: {value}",
"noUsual": "Za mało wcześniejszych tygodni lub miesięcy do porównania.",
"usualThroughYesterday": "Do wczoraj {value}, twoja zwykła wartość do tego dnia {usual}",
"firstDay": "Porównanie ze zwykłą wartością pojawi się po zakończeniu pierwszego dnia."
},
"sportCount": "{sport}: {count}",
"goal": {
"workoutsPerWeek": "Treningi w tygodniu",
"activeMinutesPerWeek": "Aktywne minuty w tygodniu",
"stepsPerWeek": "Kroki w tygodniu",
"stepsPerMonth": "Kroki w miesiącu"
},
"goalOf": "{value} z {target}",
"setGoals": "Ustaw cele",
"editGoals": "Zmień cele",
"goalsSheet": {
"title": "Cele aktywności",
"description": "Zostaw pole puste, jeśli nie chcesz celu.",
"none": "Brak celu",
"invalid": "Liczba całkowita od {min} do {max}.",
"saveError": "Nie udało się zapisać celów."
},
"crossLink": {
"title": "Kalendarz aktywności",
"body": "Twoje kroki w tygodniu lub miesiącu na tle twojej normy.",
"cta": "Otwórz kalendarz"
}
},
"sourceProvenance": {
"title": "Skąd pochodzą wartości",
"description": "Które źródło dostarczyło wartość każdego dnia i którego używa twój priorytet źródeł.",
"metric": "Pomiar",
"day": "Dzień",
"dayTitle": "Źródła tego dnia",
"loadError": "Nie udało się wczytać źródeł.",
"dayEmpty": "Brak wartości tego pomiaru w tym dniu.",
"reason": {
"only_source": "{source} to jedyne źródło tego dnia.",
"highest_on_ladder": "Używane jest {source}: spośród źródeł tego dnia stoi najwyżej w twoim priorytecie źródeł.",
"name_tiebreak": "Używane jest {source}: żadnego ze źródeł tego dnia nie ma w twoim priorytecie, więc zdecydowała nazwa.",
"not_ranked": "Ten pomiar nie ma priorytetu źródeł: liczą się wartości ze wszystkich źródeł."
},
"pass": {
"lower_on_ladder": "Niżej w twoim priorytecie źródeł.",
"not_on_ladder": "Nie ma go w twoim priorytecie źródeł dla tego pomiaru.",
"name_tiebreak": "Ta sama pozycja co użyte źródło; zdecydowała nazwa."
},
"used": "Używane",
"readings": "Pomiary: {count}, urządzenia: {devices}",
"ladder": "Twoja kolejność dla tego pomiaru: {ladder}",
"utcNote": "Źródło jest wybierane na dzień, od północy do północy UTC, tak samo jak na wykresach.",
"openSettings": "Zmień kolejność w priorytecie źródeł",
"compareTitle": "Porównaj dwa źródła",
"range": "Okres",
"rangeDays": "{days} dni",
"compareNeedsTwo": "W tym okresie tylko jedno źródło ma wartości tego pomiaru.",
"side": {
"a": "Pierwsze źródło",
"b": "Drugie źródło"
},
"daysBoth": "Dni z oboma",
"meanOf": "Średnia z {source}",
"meanAbsDiff": "Średnia różnica",
"pickedDays": "Użyte dni (pierwsze / drugie)",
"pickedSplit": "{a} / {b}",
"compareNote": "Średnie liczą tylko dni, w których oba źródła mają wartości, aby porównywać podobne dni. Pogrubiona jest wartość używana na wykresach.",
"settingsHint": "Aby zobaczyć, co ta kolejność rozstrzyga dzień po dniu:",
"settingsHintLink": "Skąd pochodzą wartości",
"metricLink": "Skąd pochodzą te wartości"
},
"recordImport": {
"title": "Wyniki badań i leki z arkusza kalkulacyjnego",
"description": "Zaimportuj wyniki badań z wielu lat lub listę leków z pliku CSV.",
"detail": "Ty wybierasz, która kolumna zawiera co; HealthLog najpierw sprawdza każdy wiersz i niczego nie importuje bez twojego potwierdzenia. Wiersze, które już są, zostaną pominięte.",
"kind": {
"labs": "Wyniki badań",
"medications": "Leki"
},
"sheetTitle": {
"labs": "Importuj wyniki badań",
"medications": "Importuj leki"
},
"sheetDescription": {
"labs": "Markery są przypisywane do twoich, jednostki przeliczane, gdy przeliczenie jest znane.",
"medications": "Nazwa, dawka, codzienne godziny, w razie potrzeby, początek i koniec. Inne rytmy ustawisz potem w aplikacji."
},
"file": "Plik CSV",
"template": "Pobierz szablon",
"mapping": "Która kolumna zawiera co",
"required": "(wymagane)",
"notInFile": "Brak w pliku",
"column": "Kolumna {n}",
"delimiter": "Separator",
"delimiterOption": {
"comma": "Przecinek",
"semicolon": "Średnik",
"tab": "Tabulator"
},
"decimal": "Separator dziesiętny",
"decimalOption": {
"dot": "Kropka (5.4)",
"comma": "Przecinek (5,4)"
},
"dateOrder": "Format daty",
"dateOrderOption": {
"iso": "2025-03-01",
"dmy": "01.03.2025 (najpierw dzień)",
"mdy": "03/01/2025 (najpierw miesiąc)"
},
"header": "Pierwszy wiersz zawiera nazwy kolumn",
"reminders": "Włącz przypomnienia dla zaimportowanych leków",
"timezoneNote": "Godziny bez strefy czasowej są czytane w strefie twojego konta. Data bez godziny pozostaje tym dniem kalendarza.",
"missing": "Do wyboru: {fields}",
"check": "Sprawdź plik",
"runIdle": "Importuj",
"run": "Importuj wiersze: {count}",
"close": "Zamknij",
"summaryCheck": "Odczytane wiersze: {rows}. Do zaimportowania: {inserted}, już są: {duplicates}, z problemem: {failed}.",
"summaryDone": "Zaimportowane wiersze: {inserted}. Pominięte: {duplicates}, które już były, i {failed} z problemem.",
"markersMatched": "Przypisane do twoich markerów:",
"markersCreated": "Nowe markery:",
"line": "Wiersz {line}",
"errorsTruncated": "Wymieniono tylko pierwsze 500 problemów.",
"error": {
"tooLarge": "Plik jest większy niż 16 MB.",
"rateLimited": "Za dużo importów w ostatniej godzinie. Spróbuj później.",
"invalid": "Nie udało się odczytać pliku lub wyboru kolumn.",
"failed": "Import się nie powiódł. Spróbuj ponownie."
},
"reason": {
"missing_value": "Wymagana komórka jest pusta",
"invalid_number": "To nie jest liczba",
"ambiguous_number": "Liczbę można odczytać na dwa sposoby; sprawdź separator dziesiętny",
"missing_unit": "Liczba wymaga jednostki",
"invalid_date": "To nie jest data w wybranym formacie",
"invalid_time": "To nie jest godzina",
"ambiguous_date": "Datę można odczytać na dwa sposoby; użyj czterocyfrowego roku i wybranego formatu",
"future_date": "Data jest w przyszłości",
"text_too_long": "Tekst jest za długi",
"unit_mismatch": "Tej jednostki nie da się przeliczyć na jednostkę markera",
"invalid_reference_range": "Nie można odczytać zakresu referencyjnego",
"invalid_times": "Nie można odczytać godzin (np. 08:00 20:00)",
"missing_times": "Dodaj godziny przyjmowania albo oznacz lek jako w razie potrzeby",
"invalid_yes_no": "Użyj tak lub nie",
"as_needed_with_times": "Lek w razie potrzeby nie ma stałych godzin",
"end_before_start": "Koniec jest przed początkiem",
"invalid_medication": "Nie można tak zapisać leku",
"oversized_row": "Wiersz jest za długi lub za szeroki",
"row_limit": "Ponad limit wierszy dla jednego importu",
"duplicate": "Już jest, pominięto"
},
"field": {
"analyte": "Marker",
"value": "Wynik",
"unit": "Jednostka",
"date": "Data",
"time": "Godzina",
"referenceRange": "Zakres referencyjny",
"referenceLow": "Dolna granica",
"referenceHigh": "Górna granica",
"panel": "Grupa",
"note": "Notatka",
"name": "Nazwa",
"dose": "Dawka",
"times": "Godziny",
"asNeeded": "W razie potrzeby",
"startsOn": "Początek",
"endsOn": "Koniec"
}
}
}
+8 -1
View File
@@ -196,9 +196,16 @@ const nextConfig: NextConfig = {
destination: "/settings/layout/medications",
permanent: true,
},
// v1.44 — the mood page was tag management, which lives under
// Settings > Modules > Mood now; both older addresses go straight there.
{
source: "/settings/mood",
destination: "/settings/layout/mood",
destination: "/settings/modules/mood",
permanent: true,
},
{
source: "/settings/layout/mood",
destination: "/settings/modules/mood",
permanent: true,
},
{
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.43.11",
"version": "1.44.0",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -0,0 +1,13 @@
-- Where the Oura history backfill has got, and when it finished.
--
-- The backfill reads every Oura collection's history in slices through the
-- shared integration backfill lane. After each window it writes, the progress
-- note records where the collection's next window ends, so a slice that stops
-- on its budget or a rate limit resumes there instead of from the newest day.
-- The completion stamp drops the account from boot discovery.
--
-- Both nullable and unset for every existing row: every current Oura
-- connection is offered the backfill at the next boot, with no reconnect.
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "oura_backfill_progress" JSONB;
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "oura_backfill_completed_at" TIMESTAMP(3);
@@ -0,0 +1,24 @@
-- Drop the readable copy of the cached AI insights payload.
--
-- Since 0411 every write goes to `insights_cached_text_encrypted` and leaves
-- `insights_cached_text` NULL, and the boot-time free-text encryption backfill
-- sealed the rows written before. Nothing reads the readable column any more.
--
-- A host that skipped the releases between 0411 and this one may still hold a
-- readable payload the backfill never reached. It is a cache of model output,
-- not a record: the next nightly or on-demand warm writes a fresh, sealed one.
-- Dropping it is the safe direction; keeping it would leave health text in the
-- clear on exactly the hosts that upgraded the longest way round.
DROP INDEX IF EXISTS "users_insights_cached_text_backfill_idx";
ALTER TABLE "users" DROP COLUMN IF EXISTS "insights_cached_text";
-- A row whose only copy of the payload was the readable one now holds no
-- cached text at all, while its stamp still says the cache is fresh. Clear the
-- stamp so the nightly pregenerate picks the account up again instead of
-- waiting out the staleness window with an empty card.
UPDATE "users"
SET "insights_cached_at" = NULL
WHERE "insights_cached_text_encrypted" IS NULL
AND "insights_cached_at" IS NOT NULL;
@@ -0,0 +1,21 @@
-- Extend `measurement_type` with six Apple Health signals that sat in the
-- deferred list: the exercise and stand rings, mindful minutes, the State of
-- Mind valence, and AFib burden.
--
-- EXERCISE_MINUTES <- HKQuantityTypeIdentifierAppleExerciseTime
-- STAND_MINUTES <- HKQuantityTypeIdentifierAppleStandTime
-- STAND_HOURS <- HKCategoryTypeIdentifierAppleStandHour
-- MINDFUL_MINUTES <- HKCategoryTypeIdentifierMindfulSession
-- STATE_OF_MIND_VALENCE <- HKDataTypeIdentifierStateOfMind (valence)
-- AFIB_BURDEN <- HKQuantityTypeIdentifierAtrialFibrillationBurden
--
-- Purely additive: no existing row carries any of these values. IF NOT
-- EXISTS keeps a replay on a partially applied database harmless. Enum
-- values cannot be dropped in PostgreSQL, so there is no down path.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'EXERCISE_MINUTES';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STAND_MINUTES';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STAND_HOURS';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'MINDFUL_MINUTES';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'STATE_OF_MIND_VALENCE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'AFIB_BURDEN';
@@ -0,0 +1,6 @@
-- A per-account opt-in for keeping workout GPS tracks. Off by default: a
-- route shows where a person lives, works and trains, so new tracks are
-- stored only once the person turns this on. Tracks already stored stay
-- where they are; the setting decides what new syncs and imports keep.
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "store_workout_routes" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,11 @@
-- Extend `measurement_type` with room-climate readings pushed through the
-- ingest token (source EXTERNAL), for example by a Home Assistant
-- automation: indoor CO2 (ppm), indoor temperature (celsius) and indoor
-- relative humidity (percent).
--
-- Purely additive. IF NOT EXISTS keeps a replay harmless; enum values cannot
-- be dropped in PostgreSQL, so there is no down path.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'INDOOR_CO2';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'INDOOR_TEMPERATURE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'INDOOR_HUMIDITY';
@@ -0,0 +1,21 @@
-- Nobody who already keeps workout tracks loses them with 0432's opt-in.
--
-- 0432 added `users.store_workout_routes`, off by default. An account that
-- already holds at least one stored track has been syncing routes all along;
-- for it the setting starts ON, so its tracks keep arriving after the
-- upgrade. Only accounts without any stored track start OFF. Runs once, at
-- the upgrade; a person who turns it off afterwards stays off.
UPDATE "users" AS u
SET "store_workout_routes" = true
WHERE EXISTS (
SELECT 1
FROM "workout_routes" AS r
JOIN "workouts" AS w ON w."id" = r."workout_id"
WHERE w."user_id" = u."id"
);
-- Cursor of the one-time climb backfill (`src/lib/workouts/climb-backfill.ts`):
-- workouts stored before this release with a track but no elevation get their
-- metres climbed from the track, in id order, resumable across restarts.
-- NULL = not started, 'done' = finished.
ALTER TABLE "app_settings" ADD COLUMN IF NOT EXISTS "workout_climb_backfill_cursor" TEXT;
@@ -0,0 +1,17 @@
-- Two per-user preference columns for Settings.
--
-- module_list_layout_json: the card/list view, manual order and labs sort for
-- the labs, illness and checkups lists. These lived in the browser until now,
-- so they did not follow a person to another device. The client copies a value
-- it still holds in the browser up once, when the server has none for that list.
--
-- preview_features_json: the per-user opt-in to preview features, one boolean
-- per flag, all off by default.
--
-- Nullable, no default, no backfill: null reads as the defaults.
--
-- Reversibility: dropping the columns returns every list to its default
-- presentation and switches every preview feature off.
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "module_list_layout_json" JSONB;
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "preview_features_json" JSONB;
@@ -0,0 +1,9 @@
-- The Oura account id behind a connection.
--
-- Read from `personal_info` at connect, so a connect that would attach an
-- Oura account another record already holds can be refused. Nullable and
-- unset for every existing row: a current connection is never asked to
-- reconnect, the id fills in at its next connect.
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "oura_user_id" TEXT;
CREATE INDEX IF NOT EXISTS "users_oura_user_id_idx" ON "users" ("oura_user_id");
@@ -0,0 +1,12 @@
-- Which provider apps on a record were copied from a guardian, and from whom.
--
-- A guardian connecting a provider for a managed profile lends the profile
-- their own provider app (client id and secret, still sealed). The marker lets
-- the copy be removed when the provider is disconnected or the guardian's
-- MANAGE grant ends, instead of the secret staying on the profile for good.
--
-- Nullable and unset for every existing row. A copy made before this column
-- existed carries no marker and is left as it is: there is no reliable way to
-- tell it from an app the record holds of its own.
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "client_app_shared_by" JSONB;
@@ -0,0 +1,9 @@
-- Optional per-user activity goals for the activity calendar: workouts,
-- active minutes and steps per week, steps per month.
--
-- Nullable, no default, no backfill: null reads as "no goal set", which is
-- every account's state today.
--
-- Reversibility: dropping the column removes the goals; nothing else reads it.
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "activity_goals_json" JSONB;
+78 -16
View File
@@ -136,13 +136,6 @@ model User {
useCentralCodex Boolean @default(false) @map("use_central_codex")
insightsPrivacyMode String @default("aggregated") @map("insights_privacy_mode") // "aggregated" | "raw"
insightsCachedAt DateTime? @map("insights_cached_at")
/// @internal: legacy plaintext cache of the AI insights payload, read only
/// as a fallback for rows the free-text encryption backfill has not reached
/// yet (v1.43.7). Every write since then goes to `insightsCachedTextEncrypted`
/// and leaves this NULL; the backfill seals and nulls it on the old rows.
/// DROP this column in a later release, once the backfill reports zero
/// remaining rows on every instance.
insightsCachedText String? @map("insights_cached_text")
/// v1.43.7 — the cached AI insights payload (briefing, per-domain readings,
/// recommendations; JSON), AES-256-GCM binary layout under
/// `INSIGHTS_CACHED_TEXT_AAD`. Read and written only through
@@ -277,22 +270,44 @@ model User {
// v1.17.0 — Oura OAuth credentials (F4, per-user, encrypted at app level).
// Mirrors the WHOOP credential columns; OAuth access/refresh tokens stored
// encrypted.
ouraAccessTokenEncrypted String? @map("oura_access_token_encrypted")
ouraRefreshTokenEncrypted String? @map("oura_refresh_token_encrypted")
ouraAccessTokenEncrypted String? @map("oura_access_token_encrypted")
ouraRefreshTokenEncrypted String? @map("oura_refresh_token_encrypted")
// v1.17.1 — Oura BYO-app client credentials (W1c, per-user, encrypted at app
// level). Mirrors the WHOOP/Fitbit `clientId`/`clientSecret` columns 1:1 so a
// self-hoster pastes their own Oura Cloud app's client id/secret into Settings
// before the OAuth connect flow runs. Stored encrypted as a string via
// `src/lib/crypto.ts`, matching every other `*Encrypted` column.
ouraClientIdEncrypted String? @map("oura_client_id_encrypted")
ouraClientSecretEncrypted String? @map("oura_client_secret_encrypted")
ouraClientIdEncrypted String? @map("oura_client_id_encrypted")
ouraClientSecretEncrypted String? @map("oura_client_secret_encrypted")
/// v1.42.3 — Oura collections the current grant does not cover, as a JSON
/// string array of collection names (`spo2`, `vo2max`, `cardio_age`,
/// `resilience`). Written by the sync when a collection answers 401/403 while
/// its siblings succeed with the same token, which is a scope the grant never
/// carried, not a dead token. The Oura card reads it to ask for one
/// reconnect. Cleared on connect, disconnect and credential removal.
ouraScopeDenied Json? @map("oura_scope_denied")
ouraScopeDenied Json? @map("oura_scope_denied")
/// v1.44 — how far the Oura history backfill has got: per collection, the
/// day its next window ends on, and whether it is done or refused for a
/// scope; plus where the account's history starts once learned. Written
/// after every window so a slice resumes where the last one stopped (see
/// `src/lib/oura/backfill-progress.ts`); kept after completion so a
/// reconnect reads only what the old grant refused.
ouraBackfillProgress Json? @map("oura_backfill_progress")
/// v1.44 — the Oura history backfill finished (self-converging discovery
/// predicate `oura_backfill_completed_at IS NULL`). Cleared on connect.
ouraBackfillCompletedAt DateTime? @map("oura_backfill_completed_at")
/// v1.44 — the Oura account id (`personal_info.id`) of the current grant,
/// read at connect when the `personal` scope is granted. A connect whose
/// account another record already holds is refused
/// (`src/lib/integrations/oauth-kit/account-link.ts`). Cleared on disconnect.
ouraUserId String? @map("oura_user_id")
/// v1.44 — which provider apps on this record were copied from a
/// guardian at connect, and from whom: `{ "<provider>": "<guardianUserId>" }`.
/// A managed profile has no settings page for its own app, so a guardian's is
/// borrowed (`shareGuardianClientApp`). The copy is removed again when the
/// provider is disconnected or that guardian no longer holds a live MANAGE
/// grant on the record (`src/lib/integrations/oauth-kit/connect-target.ts`).
clientAppSharedBy Json? @map("client_app_shared_by")
// v1.28.x — Strava OAuth credentials (per-user BYO-app, encrypted at app
// level). Strava caps every newly-created API app at single-player mode
@@ -364,6 +379,25 @@ model User {
// Shape: { "groupOrder": ["<categoryKey>", ...],
// "placements": { "<categoryKey>": ["<tagKey>", ...] } }
moodTagLayoutJson Json? @map("mood_tag_layout_json")
// Per-user list presentation for the labs, illness and checkups lists:
// card/list view, manual order, and (labs only) the sort. One blob keyed by
// list, resolved field by field on read (`src/lib/settings/module-list-layout.ts`),
// preserve-when-absent PUT on `/api/settings/module-list-layout`. Null = the
// defaults. Earlier releases kept the same choices in the browser; the client
// copies a stored browser value up once when the server has none for that list.
// Shape: { "version": 1, "labs": { "view": "list", "order": ["<id>"], "sortDir": "manual" }, ... }
moduleListLayoutJson Json? @map("module_list_layout_json")
/// Per-user opt-in to preview features, one boolean per flag, all off by
/// default (`src/lib/settings/preview-features.ts`). Read and written
/// through `/api/settings/preview-features` and carried by the backup.
/// @pending-consumer(topics preview) the `topics` flag is stored, but no surface reads it and the Settings > System switch is unlisted until the topic overview pages ship.
previewFeaturesJson Json? @map("preview_features_json")
/// Optional activity goals the person set for themselves: workouts, active
/// minutes and steps per week, steps per month. Null or an absent key = no
/// goal. Parsed by `src/lib/validations/activity-goals.ts`, written through
/// `/api/activity/goals`, read by the activity calendar
/// (`/api/activity/calendar`), which reports progress against each goal set.
activityGoalsJson Json? @map("activity_goals_json")
// Blood-glucose display unit preference. Canonical storage is mg/dL.
glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL"
// v1.18.6 — explicit "I have diabetes / clinician glucose targets" opt-in.
@@ -381,6 +415,12 @@ model User {
// review screen remains the safety backstop. Default `false`; vision-capable
// users keep the higher-accuracy native path and never pay the WASM download.
labsLocalOcrEnabled Boolean @default(false) @map("labs_local_ocr_enabled")
/// v1.44 — keep the GPS track of a workout. OFF by default: a route is
/// where a person lives, works and runs, so it is stored only after they
/// opt in. Read by the workout batch route and the export.zip import, which
/// drop the track while it is off (the workout itself is always kept).
/// Never read by any AI path; the AI egress guard freezes the route readers.
storeWorkoutRoutes Boolean @default(false) @map("store_workout_routes")
// v1.7.0 unit pref — global metric/imperial display preference.
// Canonical storage stays SI; this only selects the display-time
// transform branch (km/h vs mph, km vs mi). Null = default "metric".
@@ -1710,6 +1750,24 @@ enum MeasurementType {
// Note the inverted direction versus PHQ-9 / GAD-7: HIGHER is better for both.
WHO5_SCORE // score (0–100) — WHO-5 well-being percentage (raw 0–25 × 4). Opt-in. See MentalHealthAssessment.
SCI_SCORE // score (0–32) — Sleep Condition Indicator total; higher = better sleep. Opt-in. See MentalHealthAssessment.
// ── v1.44 — Apple Health activity rings, mindfulness, mood and AFib burden ──
// All five arrive over POST /api/measurements/batch and the export.zip import.
// The minute and hour types are cumulative (daily SUM); AFib burden and the
// State of Mind valence are spot readings.
EXERCISE_MINUTES // minutes — HealthKit `appleExerciseTime`. The activity ring's exercise minutes; cumulative, daily SUM.
STAND_MINUTES // minutes — HealthKit `appleStandTime`. Minutes spent standing; cumulative, daily SUM.
STAND_HOURS // count — HealthKit `HKCategoryTypeIdentifierAppleStandHour`. One row per hour in which the person stood (`stood` = 1; an `idle` hour is not stored); cumulative, daily SUM gives the stand-ring hours.
MINDFUL_MINUTES // minutes — HealthKit `HKCategoryTypeIdentifierMindfulSession`. Session length from the sample's own dates; cumulative, daily SUM.
STATE_OF_MIND_VALENCE // valence (−1..1) — HealthKit `HKStateOfMind.valence` (iOS 18+). How pleasant the logged emotion or mood felt, from very unpleasant (−1) to very pleasant (1). A self-report, not a measurement.
// ── v1.44 — room climate from a home automation system ──
// Pushed through the ingest token (`measurements:write`, source EXTERNAL),
// e.g. by a Home Assistant automation. Spot readings: the daily rollup
// carries mean, min and max. Indoor, so never mixed with the outdoor
// environment channels.
INDOOR_CO2 // ppm — indoor carbon dioxide concentration.
INDOOR_TEMPERATURE // celsius — indoor air temperature. Distinct from every body temperature type.
INDOOR_HUMIDITY // percent (0–100) — indoor relative humidity.
AFIB_BURDEN // percent (0–100, scaled ×100 from Apple's 0..1 fraction) — HealthKit `atrialFibrillationBurden` (iOS 16+, AFib History). The share of time the watch estimated in AFib over a week. AWARENESS ONLY — never a HealthLog diagnosis.
@@map("measurement_type")
}
@@ -5398,12 +5456,16 @@ model AppSettings {
/// 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")
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")
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")
pulseDayMeanDryRunJson Json? @map("pulse_day_mean_dry_run_json")
/// @internal: progress marker of the one-time workout climb backfill
/// (`src/lib/workouts/climb-backfill.ts`): the last workout id scanned,
/// `done` when finished, NULL before it starts. Operational, never shown.
workoutClimbBackfillCursor String? @map("workout_climb_backfill_cursor")
@@map("app_settings")
}
@@ -6701,7 +6763,7 @@ model HostMetric {
// (no prompt-tuning flow yet — that ratchet is v1.4.17).
//
// Privacy: server-side fills providerType + promptVersion from the
// user's most recent cached payload (User.insightsCachedText) so the
// user's most recent cached payload (User.insightsCachedTextEncrypted) so the
// client can't tamper with the attribution.
//
// `(userId, recommendationId, recommendationText)` is the dedup key.
+1 -1
View File
@@ -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.43.11";
/* @sw-version-fallback */ "v1.44.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/*`
+2 -2
View File
@@ -327,7 +327,7 @@ readonly USERS_COLS="id, username, email, password_hash, role, created_at, updat
height_cm, date_of_birth, gender, timezone,
codex_access_token_encrypted, codex_refresh_token_encrypted, codex_token_expires_at,
codex_connected_at, codex_connection_status,
insights_privacy_mode, insights_cached_at, insights_cached_text, insights_cached_text_encrypted,
insights_privacy_mode, insights_cached_at, insights_cached_text_encrypted,
insights_exclude_metrics,
global_excluded_injection_sites,
telegram_bot_token, telegram_chat_id, telegram_enabled,
@@ -360,7 +360,7 @@ readonly USERS_SELECT="id, username, email, password_hash,
NULL::timestamptz AS codex_token_expires_at,
NULL::timestamptz AS codex_connected_at,
'disconnected' AS codex_connection_status,
insights_privacy_mode, insights_cached_at, insights_cached_text, insights_cached_text_encrypted,
insights_privacy_mode, insights_cached_at, insights_cached_text_encrypted,
insights_exclude_metrics,
global_excluded_injection_sites,
NULL::text AS telegram_bot_token,
+1 -3
View File
@@ -3555,15 +3555,13 @@ async function seed() {
await client.query(
`UPDATE users
SET insights_cached_text_encrypted = $2,
insights_cached_text = NULL,
insights_cached_at = NOW(),
insights_snapshot_hash = $3,
insights_briefing_reroll_date = $4
WHERE id = $1`,
[
userId,
// Sealed like every write since v1.43.7; the app never reads the
// readable column while a ciphertext is there.
// Sealed like every write since v1.43.7.
Buffer.from(
sealInsightsCachedText(JSON.stringify(comprehensiveInsight)),
),
@@ -288,3 +288,87 @@ describe("the status chokepoint's capability argument", () => {
expect(withCapability.capability).toBe("statusText");
});
});
/**
* Workout GPS tracks never reach a model.
*
* A route shows where a person lives, works and trains, and it is stored only
* after an opt-in. So the set of files that can open or write a track is
* frozen here, with the reason each needs it, and none of them may be a
* provider call site or sit in an AI module. The workout insight used to open
* the track to derive metres climbed; that figure is now filled into
* `Workout.elevationM` at write time (`route-climb.ts`), and the insight reads
* the column. A new reader fails the first check until someone decides, here,
* that it is not an AI path.
*
* Limit: the matcher looks for the cipher helpers, the sealed column and a
* `geometry: true` select. A read under some other spelling (a raw SQL
* `SELECT geometry_encrypted`, a backup dumping the model generically) would
* slip it. The generic backup dump and restore do carry the sealed column;
* they are not AI paths and hand it to no model.
*/
describe("workout route geometry never reaches an AI path", () => {
const ROUTE_READER =
/\breadRouteGeometry\s*\(|\bencryptRouteGeometry\s*\(|\bgeometryEncrypted\b|\bgeometry\s*:\s*true\b/;
const ROUTE_READERS: Record<string, string> = {
"app/api/workouts/[id]/route.ts":
"The workout detail read: serves the track to the person's own map.",
"app/api/workouts/batch/route.ts":
"Writes a kept track (opt-in) and derives its climb before storage.",
"lib/apple-health/route-import.ts":
"Writes a kept track from the Apple Health export (opt-in).",
"lib/crypto/encrypted-columns.ts":
"The encrypted-column registry the key rotation walks.",
"lib/jobs/free-text-encryption-backfill.ts":
"Seals legacy readable tracks in place.",
"lib/workouts/route-geometry-cipher.ts": "The cipher itself.",
"lib/workouts/climb-backfill.ts":
"One-time backfill: derives metres climbed into Workout.elevationM.",
};
/** Directories whose code feeds a model. */
const AI_AREAS = [
"lib/ai/",
"lib/insights/",
"lib/mcp/",
"app/mcp/",
"app/api/insights/",
"app/api/coach/",
];
const found = sourceFiles().filter((rel) => ROUTE_READER.test(code(rel)));
it("finds route readers at all (an empty matcher is a failure, not a pass)", () => {
expect(found.length).toBeGreaterThanOrEqual(4);
});
it("every file that opens or writes a track is named, with a reason", () => {
expect(found.filter((rel) => !(rel in ROUTE_READERS))).toEqual([]);
});
it("the list carries no file that no longer touches a track", () => {
expect(
Object.keys(ROUTE_READERS).filter((rel) => !found.includes(rel)),
).toEqual([]);
});
it("no route reader calls a provider or lives in an AI module", () => {
for (const rel of Object.keys(ROUTE_READERS)) {
expect(rel in EGRESS_SITES, `${rel} is an egress site`).toBe(false);
expect(EGRESS_CALL.test(code(rel)), `${rel} calls a provider`).toBe(
false,
);
expect(
AI_AREAS.some((area) => rel.startsWith(area)),
`${rel} sits in an AI module`,
).toBe(false);
}
});
it("the workout insight job does not read the track", () => {
const job = code("lib/jobs/workout-insight-generate.ts");
expect(job).not.toMatch(/\broute\s*:/);
expect(job).not.toMatch(ROUTE_READER);
});
});
@@ -104,8 +104,12 @@ describe("AI-input writes evict the capability answer", () => {
it("finds the write sites it is meant to hold (an empty sweep is a broken matcher)", () => {
const total = sites.reduce((n, s) => n + s.writes.length, 0);
expect(sites.length).toBeGreaterThanOrEqual(21);
expect(total).toBeGreaterThanOrEqual(22);
// v1.44 — three write sites left on purpose: the Coach's second switch
// and the MCP page's second switch (the one switch is in Modules), and
// the Coach overlay's copy of the provider and model selects (chosen in
// Settings > AI only).
expect(sites.length).toBeGreaterThanOrEqual(19);
expect(total).toBeGreaterThanOrEqual(19);
});
it.each(sites.map((s) => [s.rel, s] as const))(
@@ -0,0 +1,104 @@
/**
* The statistics behind the correlation, trend and changepoint surfaces are
* computed without a model.
*
* These modules answer "what does the record show": correlation channels,
* lab slopes and projected band exits, screener change against the
* clinically meaningful threshold, training load, level shifts. None of that
* may depend on a provider being configured, and none of it may become a way
* to reach one. A capability gate cannot protect a computation that quietly
* imports the provider chain, so this guard walks each module's import
* closure (value imports, `@/` and relative, through re-exports) and fails on
* anything under `lib/ai/` except the byte cipher the symptom decrypt uses,
* and on any provider call in the module's own text.
*
* Limits, stated: a dynamic `import()` or a `require` is not followed, and a
* type-only import is skipped by design (it carries no code). The list below
* is the set this guard covers; a new analysis module joins it by being added.
*/
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
const SRC = join(process.cwd(), "src");
/** The analysis modules held to "no model anywhere below". */
const ANALYSIS_MODULES = [
"lib/insights/correlation-discovery.ts",
"lib/insights/correlation-series-builders.ts",
"lib/insights/correlation-channel-series.ts",
"lib/insights/discovery-matrix.ts",
"lib/insights/derived/changepoint.ts",
"lib/labs/lab-trend.ts",
"lib/mental-health/assessment-trend.ts",
"lib/insights/training-load.ts",
] as const;
/** Reachable under `lib/ai/` without being a model: the at-rest cipher. */
const AI_TREE_ALLOWED = new Set(["lib/ai/coach/bytes-codec.ts"]);
/** A provider call, as `ai-egress-capability-guard` defines it. */
const EGRESS_CALL =
/\.generateCompletion(?:Stream)?\s*\(|\brunRawCompletionWithFallback\s*\(|\brunWithFallback\s*\(/;
const VALUE_IMPORT =
/(?:^|\n)\s*(?:import|export)\s+(?!type\b)[^;]*?from\s+["']([^"']+)["']/g;
function resolveImport(fromFile: string, spec: string): string | null {
let base: string;
if (spec.startsWith("@/")) base = join(SRC, spec.slice(2));
else if (spec.startsWith(".")) base = resolve(dirname(fromFile), spec);
else return null;
for (const candidate of [
`${base}.ts`,
`${base}.tsx`,
join(base, "index.ts"),
]) {
if (existsSync(candidate)) return candidate;
}
return null;
}
function closure(entry: string): string[] {
const seen = new Set<string>();
const stack = [join(SRC, entry)];
while (stack.length > 0) {
const file = stack.pop() as string;
if (seen.has(file)) continue;
seen.add(file);
// The generated Prisma client is never read (several MB, no model in it).
if (file.includes(`${SRC}/generated/`)) continue;
const source = readFileSync(file, "utf8");
for (const match of source.matchAll(VALUE_IMPORT)) {
const next = resolveImport(file, match[1]);
if (next) stack.push(next);
}
}
return [...seen].map((f) => relative(SRC, f));
}
describe("the analysis modules compute without a model", () => {
it.each(ANALYSIS_MODULES)("%s reaches no provider", (entry) => {
const reached = closure(entry);
// A walk that found nothing would pass any assertion below.
expect(reached.length).toBeGreaterThan(1);
const ai = reached.filter(
(f) => f.startsWith("lib/ai/") && !AI_TREE_ALLOWED.has(f),
);
expect(ai).toEqual([]);
});
it.each(ANALYSIS_MODULES)("%s makes no provider call", (entry) => {
expect(readFileSync(join(SRC, entry), "utf8")).not.toMatch(EGRESS_CALL);
});
it("would notice a provider import", () => {
// The walk follows the provider module itself into the chain.
expect(closure("lib/ai/provider.ts").length).toBeGreaterThan(1);
expect(
closure("lib/ai/provider.ts").some(
(f) => f === "lib/ai/provider-chain.ts",
),
).toBe(true);
});
});
@@ -106,6 +106,8 @@ const BACKFILL_STAMPS: Record<string, string> = {
"marks the connection's one-shot as done; not a listed record",
"src/lib/jobs/strava-backfill.ts > user":
"stamps the account's backfill-completed marker",
"src/lib/jobs/oura-backfill.ts > user":
"writes the account's backfill progress note and completed marker",
"src/lib/jobs/document-content-index-backfill.ts > documentContentIndex":
"the index row is rebuilt content; its updatedAt is the rebuild time",
"src/lib/jobs/lab-biomarker-backfill.ts > biomarker":
+7 -1
View File
@@ -1,5 +1,6 @@
/**
* The documents vault and the Coach load no schema library in the browser.
* The documents vault, the Coach and the metric pages load no schema library
* in the browser.
*
* Both routes reached zod through a constant: the list of document kinds, the
* bulk limit, the attachment cap, the Coach data clusters, a conversation
@@ -121,6 +122,11 @@ describe("client routes without the schema library", () => {
"src/app/documents/page.tsx",
"src/app/coach/page.tsx",
"src/app/coach/conversations/page.tsx",
// The metric sub-pages read the plausibility band through the value
// domain (`src/lib/measurements/value-ranges.ts`), and the values list
// checks its segment against the type label map.
"src/app/insights/pulse/page.tsx",
"src/app/insights/values/[type]/page.tsx",
]) {
it(`${route} ships no zod in its static client graph`, () => {
const hits = clientZodImports(route);
+15 -4
View File
@@ -39,8 +39,15 @@ import { describe, expect, it } from "vitest";
const SRC = join(process.cwd(), "src");
/** The prefix every customise shortcut points at. */
const LAYOUT_PREFIX = "/settings/layout/";
/**
* The prefixes a customise shortcut points at: a module's order and view
* under Arrangement, or (since v1.44) its own page under Modules.
*/
const SHORTCUT_PREFIXES = ["/settings/layout/", "/settings/modules/"];
function isShortcut(text: string): boolean {
return SHORTCUT_PREFIXES.some((prefix) => text.startsWith(prefix));
}
/** The answer a shared-record surface withholds these on: `!inSharedRecord`. */
const GUARD = "inSharedRecord";
@@ -62,6 +69,8 @@ const NOT_A_SHARED_DESTINATION: Record<string, string> = {
"The Insights page itself. Same flag, same reason.",
"components/insights/insights-edit-mode.tsx":
"The Insights arrange mode, reachable only from the page above.",
"components/settings/settings-anchor-forward.tsx":
"Not a shortcut: the table of old Settings anchors and where they moved, read on a Settings page, which the record gate already answers for.",
};
function allSourceFiles(): string[] {
@@ -127,7 +136,7 @@ function unguardedShortcuts(source: string, fileName: string): string[] {
const visit = (node: ts.Node): void => {
if (
ts.isStringLiteralLike(node) &&
node.text.startsWith(LAYOUT_PREFIX) &&
isShortcut(node.text) &&
!guardedByOwnRecord(node)
) {
found.push(node.text);
@@ -140,7 +149,9 @@ function unguardedShortcuts(source: string, fileName: string): string[] {
describe("the customise shortcut is withheld inside somebody else's record", () => {
const carrying = allSourceFiles().filter((rel) =>
readFileSync(join(SRC, rel), "utf8").includes(LAYOUT_PREFIX),
SHORTCUT_PREFIXES.some((prefix) =>
readFileSync(join(SRC, rel), "utf8").includes(prefix),
),
);
it("finds the surfaces that carry one", () => {
@@ -110,7 +110,7 @@ const REGISTRY: DestructiveEntry[] = [
destroys:
"every health record on the account, or the whole account including passkeys and audit history",
recovery: "permanent",
confirm: ["AlertDialog"],
confirm: ["ConfirmDialog"],
},
{
file: "components/settings/security-sessions-card.tsx",
@@ -129,13 +129,13 @@ const REGISTRY: DestructiveEntry[] = [
file: "components/settings/security-section/passkey-list-section.tsx",
destroys: "a passkey — hard-deleted, the credential cannot be re-derived",
recovery: "permanent",
confirm: ["AlertDialog"],
confirm: ["ConfirmButton"],
},
{
file: "components/settings/security-section/security-keys-card.tsx",
destroys: "a WebAuthn second factor",
recovery: "permanent",
confirm: ["AlertDialog"],
confirm: ["ConfirmButton"],
},
{
file: "components/settings/ai/remove-saved-key-button.tsx",
@@ -156,19 +156,19 @@ const REGISTRY: DestructiveEntry[] = [
file: "components/settings/api-section.tsx",
destroys: "an API token — revoked, and the secret is never shown again",
recovery: "permanent",
confirm: ["AlertDialog"],
confirm: ["ConfirmButton"],
},
{
file: "components/settings/mcp-section.tsx",
destroys: "an MCP client grant or an MCP token",
recovery: "permanent",
confirm: ["AlertDialog"],
confirm: ["ConfirmButton"],
},
{
file: "components/settings/sharing-section.tsx",
destroys: "a clinician share link — the URL stays dead once revoked",
recovery: "permanent",
confirm: ["AlertDialog"],
confirm: ["ConfirmButton"],
},
{
file: "components/settings/account-section/avatar-section.tsx",
@@ -214,7 +214,7 @@ const REGISTRY: DestructiveEntry[] = [
file: "components/settings/coach-memory-section.tsx",
destroys: "every remembered Coach fact at once",
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
confirm: ["ConfirmButton"],
},
{
file: "components/insights/coach-panel/use-coach-facts.ts",
@@ -672,6 +672,14 @@ const REGISTRY: DestructiveEntry[] = [
recovery: "permanent",
confirm: ["ConfirmDialog"],
},
{
file: "components/settings/managed-integration-status.tsx",
destroys:
"a managed profile's stored Withings, WHOOP, Google Health, Polar, Oura or Strava grant, disconnected by its guardian — reconnecting means authorising again",
triggers: ["components/settings/integrations/integration-card.tsx"],
recovery: "permanent",
confirm: ["ConfirmDialog"],
},
{
file: "components/settings/integrations/nightscout-card.tsx",
destroys:
@@ -96,6 +96,10 @@ describe("T1 — only the assembler fetches discovery channels", () => {
"fetchMoodFactorWindowSeries",
"fetchMeasurementDailySeriesTiered",
"fetchMeasurementWindowSeries",
"fetchNutrientSeries",
"fetchAlcoholSeries",
"fetchTrainingSeries",
"fetchIndoorClimateSeries",
] as const;
/**
@@ -74,9 +74,9 @@ describe("report scope gating — structural guard", () => {
it("reads a plausible catalogue", () => {
// Sanity floor: a silently degraded catalogue would pass everything below
// vacuously.
expect(ALL_LEAF_IDS.length).toBe(95);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(18);
expect(ALL_LEAF_IDS.length).toBe(106);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(86);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(20);
});
it("places every leaf in exactly one group", () => {
@@ -0,0 +1,113 @@
/**
* The container healthcheck cannot flap during a boot storm.
*
* `/api/health` gives the database two seconds to answer `SELECT 1` and says
* 503 past that. After the start period a saturated pool (backfills, a
* restore, a large import) can make one or two probes miss; the healthcheck
* has to absorb that and still report an outage that lasts. The compose file
* and the image's own HEALTHCHECK must agree, since the published image runs
* outside compose too.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
const ROOT = process.cwd();
function seconds(value: string): number {
const m = /^(\d+)(ms|s|m)$/.exec(value.trim());
if (!m) throw new Error(`unreadable duration ${value}`);
const n = Number(m[1]);
return m[2] === "ms" ? n / 1000 : m[2] === "m" ? n * 60 : n;
}
const probeTimeoutSeconds = (() => {
const src = readFileSync(join(ROOT, "src/app/api/health/route.ts"), "utf8");
const m = /DB_PROBE_TIMEOUT_MS = ([\d_]+);/.exec(src);
if (!m) throw new Error("DB_PROBE_TIMEOUT_MS not found");
return Number(m[1]!.replace(/_/g, "")) / 1000;
})();
interface Check {
interval: number;
timeout: number;
retries: number;
command: string;
}
function composeCheck(): Check {
const compose = parse(
readFileSync(join(ROOT, "docker-compose.yml"), "utf8"),
) as {
services: {
app: {
healthcheck: {
test: string[];
interval: string;
timeout: string;
retries: number;
};
};
};
};
const hc = compose.services.app.healthcheck;
return {
interval: seconds(hc.interval),
timeout: seconds(hc.timeout),
retries: hc.retries,
command: hc.test.join(" "),
};
}
function imageCheck(): Check {
const dockerfile = readFileSync(join(ROOT, "Dockerfile"), "utf8");
const m = /HEALTHCHECK ([^\n]*\\\n[^\n]*)/.exec(dockerfile);
if (!m) throw new Error("no HEALTHCHECK in the Dockerfile");
const line = m[1]!;
const flag = (name: string) => {
const f = new RegExp(`--${name}=(\\S+)`).exec(line);
if (!f) throw new Error(`HEALTHCHECK has no --${name}`);
return f[1]!;
};
return {
interval: seconds(flag("interval")),
timeout: seconds(flag("timeout")),
retries: Number(flag("retries")),
command: line.slice(line.indexOf("CMD")),
};
}
function wgetTimeout(command: string): number {
const m = /wget[^|]*--timeout=(\d+)/.exec(command);
if (!m) throw new Error(`no wget timeout in: ${command}`);
return Number(m[1]);
}
describe.each([
["docker-compose.yml", composeCheck],
["Dockerfile", imageCheck],
])("%s healthcheck", (_name, read) => {
it("rides out two minutes of slow probes before calling the app unhealthy", () => {
const check = read();
expect(check.interval * check.retries).toBeGreaterThanOrEqual(120);
});
it("ends the request inside Docker's timeout, after the database probe's", () => {
const check = read();
const wget = wgetTimeout(check.command);
expect(wget).toBeGreaterThan(probeTimeoutSeconds);
expect(wget).toBeLessThan(check.timeout);
});
});
it("compose and the image agree", () => {
const a = composeCheck();
const b = imageCheck();
expect([a.interval, a.timeout, a.retries]).toEqual([
b.interval,
b.timeout,
b.retries,
]);
});
@@ -0,0 +1,99 @@
/**
* The room-climate blueprint and its guide say one consistent thing.
*
* - The blueprint uses Home Assistant's current automation keys (`triggers`,
* `conditions`, `actions`, `trigger:` and `action:` inside them) and says
* which release it needs for them.
* - Every interval it offers divides the hour, so "every N minutes from the
* full hour" is what actually happens: the trigger fires every five minutes
* and the condition keeps `minute % N == 0`, which for an N that does not
* divide 60 drifts at the hour.
* - The guide recommends one room and never tells a reader to create one
* automation per room, which the readings (no room name) cannot support.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { parse, Scalar, type CollectionTag, type ScalarTag } from "yaml";
const ROOT = process.cwd();
const BLUEPRINT = join(
ROOT,
"docs/integrations/blueprints/healthlog-room-climate.yaml",
);
const GUIDE = join(ROOT, "docs/integrations/home-assistant.md");
const inputTag: ScalarTag = {
tag: "!input",
resolve: (value: string) => new Scalar(`!input ${value}`),
};
function blueprint(): Record<string, unknown> {
return parse(readFileSync(BLUEPRINT, "utf8"), {
customTags: [inputTag as ScalarTag | CollectionTag],
}) as Record<string, unknown>;
}
describe("room-climate blueprint", () => {
it("uses the current automation keys and names the release they need", () => {
const doc = blueprint();
for (const legacy of ["trigger", "condition", "action"]) {
expect(doc, legacy).not.toHaveProperty(legacy);
}
const triggers = doc.triggers as Array<Record<string, unknown>>;
const actions = doc.actions as Array<Record<string, unknown>>;
expect(triggers.every((t) => "trigger" in t && !("platform" in t))).toBe(
true,
);
expect(actions.every((a) => "action" in a && !("service" in a))).toBe(true);
const meta = doc.blueprint as {
homeassistant?: { min_version?: string };
};
expect(meta.homeassistant?.min_version).toMatch(/^2024\.10\./);
});
it("offers only intervals that divide the hour", () => {
const meta = blueprint().blueprint as {
input: {
interval_minutes: {
default: string;
selector: { select: { options: string[] } };
};
};
};
const options = meta.input.interval_minutes.selector.select.options;
expect(options.length).toBeGreaterThan(0);
for (const option of options) {
expect(60 % Number(option), option).toBe(0);
}
expect(options).toContain(meta.input.interval_minutes.default);
});
});
describe("room-climate guide", () => {
const guide = readFileSync(GUIDE, "utf8");
const section = guide.slice(
guide.indexOf("## Room climate"),
guide.indexOf("## Sending several readings at once"),
);
it("recommends one room and explains why", () => {
expect(section.length).toBeGreaterThan(200);
expect(section).toMatch(/Track one room/);
expect(section).toMatch(/409/);
expect(section).not.toMatch(/one automation per room/i);
expect(section).not.toMatch(/several automations/i);
});
it("writes its examples with the current keys", () => {
const examples = [...guide.matchAll(/```yaml\n([\s\S]*?)```/g)].map(
(m) => m[1]!,
);
expect(examples.length).toBeGreaterThan(0);
for (const example of examples) {
expect(example).not.toMatch(/^\s+- platform:/m);
expect(example).not.toMatch(/^\s+- service:/m);
}
});
});
@@ -98,6 +98,8 @@ const LEGIT_IDENTICAL = new Set<string>([
"insights.derived.composite.READINESS.component.hrv",
"labs.catalog.hs-crp",
"measurements.typeHrvRmssd",
// The report's SDNN row, named the way the RMSSD row above is.
"reportSelection.leafHrvSdnn",
"illness.vital.HRV_RMSSD",
"settings.sections.sources.metrics.hrvRmssd",
"measurements.typeVo2Max",
@@ -1,18 +1,16 @@
/**
* The cached AI insights payload is read and written in one place.
*
* Since v1.43.7 the payload is sealed in `User.insightsCachedTextEncrypted`
* and the readable `insightsCachedText` survives only as a fallback for rows
* the backfill has not reached. Two columns that must move together are a
* standing invitation to move one: a clear written as
* `{ insightsCachedText: null }` — the shape every clear had until this
* release — would now leave the whole sealed briefing behind, after a consent
* withdrawal of all places. So the names are spelled in
* `src/lib/insights/cached-text-cipher.ts`, and every other file goes through
* its select, write, clear and open helpers.
* The payload is sealed in `User.insightsCachedTextEncrypted`; the readable
* `insightsCachedText` column it replaced was dropped in v1.44.0 (migration
* 0430). Every reader and writer goes through
* `src/lib/insights/cached-text-cipher.ts` (its select, write, clear and open
* helpers), so a clear cannot forget the column and a read cannot skip the
* decryption. The column name is spelled there and in the tables that have
* to name every column of the row.
*
* The allowlist below is the set of files with a reason to spell the legacy
* name anyway, each stated. A file that joins it has to say why here.
* The readable name survives only as the snapshot builder's input field, which
* carries the OPENED text, never the column.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
@@ -23,42 +21,46 @@ import { stripComments, walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
const ALLOWED: Readonly<Record<string, string>> = {
"lib/insights/cached-text-cipher.ts": "the one module that owns the columns",
"lib/jobs/free-text-encryption-backfill.ts":
"the backfill that seals the legacy column",
const SEALED_ALLOWED: Readonly<Record<string, string>> = {
"lib/insights/cached-text-cipher.ts": "the one module that owns the column",
"lib/crypto/encrypted-columns.ts":
"the registry of every ciphertext-bearing column",
"lib/export/backup-plan.ts":
"the backup classification names every User column",
"lib/data-wipe/wipe-plan.ts":
"the wipe reset names every User column it resets, and resets both",
"lib/data-wipe/wipe-plan.ts": "the wipe reset names every User column",
};
const OPENED_TEXT_ALLOWED: Readonly<Record<string, string>> = {
"lib/dashboard/snapshot.ts":
"the snapshot builder's input field carries the OPENED text, not the column",
"the snapshot builder's input field carries the opened text",
"lib/dashboard/snapshot-read.ts":
"fills that input field from openInsightsCachedText",
};
/** The legacy name, not its sealed sibling, outside comments. */
const LEGACY_NAME = /\binsightsCachedText\b(?!Encrypted)/;
const SEALED_NAME = /\binsightsCachedTextEncrypted\b/;
/** The dropped readable name, not its sealed sibling. */
const READABLE_NAME = /\binsightsCachedText\b(?!Encrypted)/;
function filesSpellingTheLegacyName(): string[] {
function sourceFilesMatching(pattern: RegExp): string[] {
return walkSourceFiles(SRC, { floor: 2000 })
.filter((f) => !f.startsWith("generated/"))
.filter((f) => !f.includes("__tests__/") && !/\.test\.tsx?$/.test(f))
.filter((f) =>
LEGACY_NAME.test(stripComments(readFileSync(join(SRC, f), "utf8"))),
);
pattern.test(stripComments(readFileSync(join(SRC, f), "utf8"))),
)
.sort();
}
describe("insights cache column access", () => {
const found = filesSpellingTheLegacyName();
it("is confined to the cipher module and the stated exceptions", () => {
const strays = found.filter((f) => !(f in ALLOWED));
expect(strays).toEqual([]);
it("spells the sealed column only in the cipher module and the stated tables", () => {
const found = sourceFilesMatching(SEALED_NAME);
expect(found.length).toBe(4);
expect(found).toEqual(Object.keys(SEALED_ALLOWED).sort());
});
it("still finds every stated exception, so the matcher is live", () => {
expect(found.length).toBeGreaterThan(5);
expect([...found].sort()).toEqual(Object.keys(ALLOWED).sort());
it("uses the readable name only for the opened text the snapshot builder takes", () => {
const found = sourceFilesMatching(READABLE_NAME);
expect(found.length).toBe(2);
expect(found).toEqual(Object.keys(OPENED_TEXT_ALLOWED).sort());
});
});
@@ -14,6 +14,7 @@ const recordRef: { value: { kind: "managed" | "shared"; level: string } } = {
vi.mock("next/navigation", () => ({
usePathname: () => pathnameRef.value,
useRouter: () => ({ replace: () => {}, push: () => {} }),
}));
vi.mock("@/hooks/use-auth", () => ({
@@ -112,7 +113,9 @@ describe("managed record navigation", () => {
expect(
html.match(new RegExp(`href="/settings/${section.slug}"`, "g"))
?.length ?? 0,
).toBe(2);
// The sidebar carries the link; the phone's section sheet mounts its
// list only when opened (v1.44).
).toBe(1);
}
for (const section of unavailableDestinations) {
@@ -62,6 +62,7 @@ const WRITERS: Record<string, Disposition> = {
"lib/fitbit/sync-core.ts": { kind: "gate" },
"lib/google-health/sync-core.ts": { kind: "gate" },
"lib/nightscout/sync.ts": { kind: "gate" },
"lib/oura/sync-heart-rate.ts": { kind: "gate" },
// The shared identity seam. Polar, Oura, WHOOP, the Withings sleep leg,
// the Apple export importer and the batch endpoint all write through it,
// so the gate here is what makes a new provider fail closed.
@@ -73,6 +74,10 @@ const WRITERS: Record<string, Disposition> = {
validatedIn: "lib/validations/measurement.ts",
},
"app/api/import/route.ts": { kind: "validator" },
// v1.44 — the set-based insert writes rows that already passed the
// route's `validateMeasurementRange` per entry; everything else goes
// through the reconciler, which gates itself.
"app/api/measurements/batch/route.ts": { kind: "validator" },
"lib/mcp/writes.ts": { kind: "validator" },
"lib/measurements/create-from-telegram.ts": { kind: "validator" },
"lib/measurements/import-apple-health-export.ts": { kind: "validator" },
@@ -44,6 +44,7 @@ const WRITE_SITES: Record<string, string> = {
"app/api/measurements/bulk-delete/route.ts": TAIL,
"app/api/measurements/by-external-ids/route.ts": TAIL,
"app/api/measurements/restore/route.ts": TAIL,
"app/api/measurements/batch/route.ts": TAIL,
"app/api/measurements/route.ts": TAIL,
"app/api/mental-health/assessments/route.ts": TAIL,
"lib/insights/score-row.ts": TAIL,
@@ -53,6 +54,8 @@ const WRITE_SITES: Record<string, string> = {
"lib/whoop/webhook-handler.ts": TAIL,
// Retiring the rows of Oura sleep periods the person rejected or deleted.
"lib/oura/sync.ts": TAIL,
// Retiring the equivalent-distance rows earlier Oura syncs wrote.
"lib/oura/retire-distance.ts": TAIL,
// Integration syncs: each collects the (type, day) pairs it touched and
// runs both legs itself at the end of the sync.
"lib/fitbit/sync-core.ts":
@@ -61,6 +64,8 @@ const WRITE_SITES: Record<string, string> = {
"refolds the touched days and re-warms the touched types at the end of the upsert",
"lib/nightscout/sync.ts":
"refolds the touched days and re-warms the touched types at the end of the sync",
"lib/oura/sync-heart-rate.ts":
"collects the days each heart-rate page touched; refolds them and re-warms the type once per window",
"lib/withings/sync.ts":
"refolds the touched days and re-warms the touched types at the end of the sync",
"lib/withings/sync-activity.ts":
@@ -96,6 +96,9 @@ 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.44 — the CSV import writes new medications and reads only their names
// and doses to skip duplicates; it derives nothing due.
"lib/import/medications-csv.ts": "imports new rows, derives nothing due",
// 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":
@@ -0,0 +1,61 @@
/**
* Structural guard: a column a migration adds is added idempotently.
*
* Parallel release branches each add columns to `users`, and a host that ran
* a beta image already holds the columns a later rebuilt migration adds again.
* A bare `ADD COLUMN` then fails the boot with "column already exists". Every
* migration from `0422` on (the first one after the last release that shipped
* a bare form) writes `ADD COLUMN IF NOT EXISTS`, as `0424` and `0432` did from
* the start.
*/
import { readdirSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const MIGRATIONS_DIR = resolve(
dirname(fileURLToPath(import.meta.url)),
"../../prisma/migrations",
);
const FIRST_GUARDED = 422;
/** Lines that add a column without `IF NOT EXISTS`, comments stripped. */
function bareAddColumns(sql: string): string[] {
return sql
.split("\n")
.map((line) => line.replace(/--.*$/, ""))
.filter((line) => /\bADD\s+COLUMN\b/i.test(line))
.filter((line) => !/\bADD\s+COLUMN\s+IF\s+NOT\s+EXISTS\b/i.test(line))
.map((line) => line.trim());
}
describe("migrations add columns idempotently", () => {
it("recognises the bare form and accepts the guarded one", () => {
expect(
bareAddColumns('ALTER TABLE "u" ADD COLUMN "a" JSONB;'),
).toHaveLength(1);
expect(
bareAddColumns('ALTER TABLE "u" ADD COLUMN IF NOT EXISTS "a" JSONB;'),
).toEqual([]);
expect(bareAddColumns("-- ADD COLUMN in a comment")).toEqual([]);
});
it(`uses ADD COLUMN IF NOT EXISTS from ${FIRST_GUARDED} on`, () => {
const offenders: string[] = [];
for (const entry of readdirSync(MIGRATIONS_DIR, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const number = Number.parseInt(entry.name.slice(0, 4), 10);
if (!Number.isFinite(number) || number < FIRST_GUARDED) continue;
const sql = readFileSync(
resolve(MIGRATIONS_DIR, entry.name, "migration.sql"),
"utf8",
);
for (const line of bareAddColumns(sql)) {
offenders.push(`${entry.name}: ${line}`);
}
}
expect(offenders).toEqual([]);
});
});
+114
View File
@@ -0,0 +1,114 @@
/**
* Every OAuth provider integration goes through the one token lifecycle.
*
* `src/lib/integrations/oauth-kit/token.ts` owns reading a grant, deciding it
* needs a refresh, refreshing under the advisory lock, and writing the result
* back conditionally. It replaced seven copies of that sequence, which had
* drifted into four different safety levels, one of them a status route that
* refreshed a one-time token with no guard at all. This file is what stops an
* eighth copy:
*
* A. Each kit provider has `src/lib/<provider>/oauth-descriptor.ts`, and the
* descriptor names that provider.
* B. A provider client's `refreshAccessToken` is imported by that
* provider's descriptor and by nothing else outside tests.
* C. The refresh lock is taken by the kit and by the AI provider store
* (`src/lib/ai/provider.ts`, which is not an integration) only.
* D. A token is sealed into a connection row only by a callback route
* (the connect-time write). The kit writes ciphertext the descriptor is
* handed and never encrypts on its own.
*
* Fitbit is the one listed exception: the classic Fitbit Web API is retired
* on 2026-10-30 and its integration rides out on its own refresh until then,
* rather than being migrated onto a lifecycle it will never use.
*
* Every leg asserts a non-empty match set before it compares, so a matcher
* that stops matching fails instead of passing.
*/
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
/** The providers whose tokens the kit manages. */
const KIT_PROVIDERS = [
"withings",
"whoop",
"oura",
"strava",
"google-health",
"polar",
] as const;
/** Retired on 2026-10-30; keeps its own refresh until it is removed. */
const RETIRED = new Set(["src/lib/fitbit/sync-core.ts"]);
function productionFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((f) => !f.startsWith("generated/"))
.filter((f) => !f.includes("__tests__/") && !/\.test\.tsx?$/.test(f))
.map((f) => `src/${f}`);
}
function read(rel: string): string {
return readFileSync(join(process.cwd(), rel), "utf8");
}
describe("OAuth token lifecycle guard", () => {
const files = productionFiles();
it("A: every kit provider has a descriptor naming it", () => {
for (const provider of KIT_PROVIDERS) {
const rel = `src/lib/${provider}/oauth-descriptor.ts`;
expect(existsSync(join(process.cwd(), rel)), rel).toBe(true);
expect(read(rel)).toMatch(new RegExp(`provider:\\s*"${provider}"`));
}
});
it("B: a provider's refreshAccessToken is imported only by its descriptor", () => {
const importers = files.filter((f) => {
const src = read(f);
return /import\s*\{[^}]*\brefreshAccessToken\b[^}]*\}\s*from\s*["'](?:\.\/client|@\/lib\/[a-z-]+\/client)["']/.test(
src,
);
});
expect(importers.length).toBeGreaterThan(0);
const allowed = new Set([
...KIT_PROVIDERS.filter((p) => p !== "polar").map(
(p) => `src/lib/${p}/oauth-descriptor.ts`,
),
...RETIRED,
]);
expect(importers.filter((f) => !allowed.has(f))).toEqual([]);
});
it("C: only the kit and the AI provider store take the refresh lock", () => {
const callers = files.filter((f) =>
/\bacquireProviderTokenRefreshLock\s*\(/.test(read(f)),
);
expect(callers.length).toBeGreaterThan(0);
expect(callers.sort()).toEqual([
"src/lib/ai/provider.ts",
"src/lib/integrations/oauth-kit/token.ts",
"src/lib/integrations/oauth-refresh.ts",
]);
});
it("D: tokens are sealed into a connection only by a callback", () => {
const sealing = files.filter((f) =>
/\b(?:accessToken|refreshToken|[a-z]+AccessTokenEncrypted|[a-z]+RefreshTokenEncrypted):\s*encrypt\(/.test(
read(f),
),
);
expect(sealing.length).toBeGreaterThan(0);
const offenders = sealing.filter(
(f) =>
!RETIRED.has(f) &&
!/^src\/app\/api\/[a-z-]+\/callback\/route\.ts$/.test(f),
);
expect(offenders).toEqual([]);
});
});
@@ -104,6 +104,10 @@ const SQL_READERS: Record<string, Entry> = {
"lib/measurements/daily-series-read.ts": HELPER,
"lib/rollups/measurement-rollups.ts": HELPER,
"lib/rollups/tiered-context.ts": HELPER,
"lib/insights/correlation-channel-series.ts": {
exempt:
"the one SQL mean is the room-climate hour mean, over INDOOR_CO2 / INDOOR_TEMPERATURE / INDOOR_HUMIDITY only; pulse reaches the correlation channels through `dayValueRows` and the rollup compose",
},
};
/** Files that name pulse and hold an in-memory mean or a mean-making fold. */
@@ -156,10 +160,6 @@ const TS_READERS: Record<string, Entry> = {
"lib/insights/narrative/period-narrative.ts": {
exempt: "means of day-keyed series, already one value per day",
},
"lib/mcp/rich-reads.ts": {
exempt:
"changepoints over rollup bucket means, which weigh pulse days once (readCanonicalRollupBuckets)",
},
"lib/medications/efficacy/build-efficacy.ts": {
exempt: "means of the day-mean series (readDayMeanSeries)",
},
@@ -20,9 +20,11 @@ describe("record settings route guard", () => {
.map((entry) => `app/api/record-settings/${entry}`)
.sort();
expect(discovered).toHaveLength(3);
expect(discovered).toHaveLength(5);
expect(discovered).toEqual([
"app/api/record-settings/[family]/route.ts",
"app/api/record-settings/integrations/[provider]/connect/route.ts",
"app/api/record-settings/integrations/[provider]/disconnect/route.ts",
"app/api/record-settings/integrations/route.ts",
"app/api/record-settings/route.ts",
]);
@@ -32,6 +34,8 @@ describe("record settings route guard", () => {
const routes = [
"route.ts",
"integrations/route.ts",
"integrations/[provider]/connect/route.ts",
"integrations/[provider]/disconnect/route.ts",
"[family]/route.ts",
] as const;
const sources = await Promise.all(
+12 -3
View File
@@ -27,7 +27,7 @@ function expectMatchingMessageKeys(expected: unknown, actual: unknown): void {
}
describe("managed record settings UI", () => {
it("gates shared deep links and renders integration status without controls", async () => {
it("gates shared deep links and controls integrations only through the managed-record routes", async () => {
const [
statusView,
managedSettingsView,
@@ -58,8 +58,17 @@ describe("managed record settings UI", () => {
expect(statusView).toContain("recordSettingsIntegrations");
expect(statusView).toContain("/api/record-settings/integrations");
expect(statusView).toContain("assertRecordSettingsResponseForRecord");
expect(statusView).not.toContain("<Button");
expect(statusView).not.toContain("<Link");
// A guardian's controls go through the fenced managed-record routes, never
// the owner's provider routes and never a navigation to the API.
expect(statusView).toContain(
"/api/record-settings/integrations/${provider}/connect",
);
expect(statusView).toContain(
"/api/record-settings/integrations/${provider}/disconnect",
);
expect(statusView).not.toMatch(/["`]\/api\/\$\{provider\}\//);
expect(statusView).not.toMatch(/loadDocument\(\s*["`]\/api/);
expect(statusView).not.toMatch(/<Link[\s>]/);
expect(settingsPage).toContain("RecordSettingsSectionGate");
expect(sectionGate).toContain("if (!inSharedRecord) return children");
expect(sectionGate).toContain("ManagedIntegrationStatus");
@@ -142,8 +142,6 @@ const RULES: ResolvedFieldRule[] = [
'import { resolveAiCapability } from "@/lib/ai/capabilities/resolve";',
],
allow: {
"src/components/settings/ai/disable-coach-card.tsx":
"the Activate-Coach switch: it edits the stored opt-out, so its checked state is that value",
"src/hooks/use-module-toggle.ts":
"the module switch for the Coach (Settings hub and command palette): its checked state is the stored opt-out it writes",
"src/components/settings/managed-record-settings-section.tsx":
@@ -115,6 +115,14 @@ describe("S1 — the direct session-resolution set is frozen", () => {
"consent decision on a route outside apiHandler",
// Provider OAuth callbacks. The browser arrives as a top-level
// navigation, so the code is bound to the session user and redirected.
// v1.44 — Withings and Google Health join: a navigation cannot carry the
// record-session header a record resolver needs, and the session user may
// be a guardian finishing a managed profile's connect, which
// `verifyCallbackActor` checks against a live MANAGE grant.
"app/api/google-health/callback/route.ts":
"binds the OAuth code to the session user",
"app/api/withings/callback/route.ts":
"binds the OAuth code to the session user",
"app/api/oura/callback/route.ts":
"binds the OAuth code to the session user",
"app/api/polar/callback/route.ts":
@@ -127,7 +127,7 @@ describe("SESSION_USER_SELECT leaves out bytes and secrets", () => {
it("selects no credential, secret or large cached text", () => {
const secretShaped =
/Encrypted$|^passwordHash$|^totpSecret|^totpLastStep$|^telegramBotToken$|^insightsCachedText$|^environmentAqHistoryJson$/;
/Encrypted$|^passwordHash$|^totpSecret|^totpLastStep$|^telegramBotToken$|^environmentAqHistoryJson$/;
const secrets = columns
.map((c) => c.name)
.filter((name) => secretShaped.test(name));
+23
View File
@@ -0,0 +1,23 @@
/**
* Copy that promises something not yet shipped says so.
*
* The workout-route switch is off for new accounts, which is what its detail
* says rather than "off by default": an account that already held tracks at
* the upgrade started with it on (migration 0434). (The Topics preview switch
* this file also held is unlisted until its pages ship, and its copy with it.)
*/
import { describe, expect, it } from "vitest";
import en from "../../messages/en.json";
import de from "../../messages/de.json";
describe("settings copy tense", () => {
it("says workout routes are off for new accounts", () => {
expect(en.settings.privacy.workoutRoutes.detail).toMatch(
/^Off for new accounts\./,
);
expect(de.settings.privacy.workoutRoutes.detail).toMatch(
/^Für neue Konten aus\./,
);
});
});
@@ -0,0 +1,92 @@
/**
* The German Settings glossary: one word per thing.
*
* Settings used "Provider" beside "Anbieter", "Default" beside "Standard",
* and the score as "Health Score", "Health-Score", "Gesundheits-Score" and
* "Gesundheitsscore", sometimes on one screen. The settled words are
* Anbieter, Standard and Gesundheitsscore; the Insights area is "Insights"
* (the name already dominant in the catalog). This reads the German catalog
* under the Settings namespaces and fails on the retired spellings.
*
* Product names are not glossary words: "ChatGPT", "OpenAI" and the like are
* left alone, and so is "Provider" inside a code-shaped token.
*/
import { describe, expect, it } from "vitest";
import de from "../../messages/de.json";
const NAMESPACES = ["settings", "thresholds"] as const;
const RETIRED: ReadonlyArray<{ pattern: RegExp; use: string }> = [
{ pattern: /\bProvider\b/, use: "Anbieter" },
{ pattern: /\bDefaults?\b/, use: "Standard" },
{ pattern: /\bHealth[- ]?Score\b/i, use: "Gesundheitsscore" },
{ pattern: /\bGesundheits-Score\b/, use: "Gesundheitsscore" },
];
function walk(node: unknown, path: string, out: Array<[string, string]>) {
if (typeof node === "string") out.push([path, node]);
else if (node && typeof node === "object") {
for (const [key, value] of Object.entries(node)) {
walk(value, path ? `${path}.${key}` : key, out);
}
}
}
describe("German Settings glossary", () => {
const strings: Array<[string, string]> = [];
for (const ns of NAMESPACES) {
walk((de as Record<string, unknown>)[ns], ns, strings);
}
it("reads the catalog (an empty read is a broken walker)", () => {
expect(strings.length).toBeGreaterThan(500);
});
it("uses the settled words", () => {
const offenders: string[] = [];
for (const [key, value] of strings) {
for (const { pattern, use } of RETIRED) {
if (pattern.test(value)) offenders.push(`${key}: "${value}" → ${use}`);
}
}
expect(offenders).toEqual([]);
});
});
describe("German wording settled in v1.44", () => {
const at = (path: string): string =>
path
.split(".")
.reduce<unknown>(
(node, key) => (node as Record<string, unknown> | undefined)?.[key],
de,
) as string;
it("names the Tracking group with a German word", () => {
expect(at("settings.shell.groups.tracking")).toBe("Erfassung");
});
it("points to another Settings page with 'Unter …' and returns to the Modules", () => {
expect(at("settings.sections.modules.backToHub")).toBe(
"Zurück zu den Modulen",
);
expect(at("settings.moduleState.change")).toBe("Unter Module ändern");
expect(at("settings.sections.modules.pages.openArrangement")).toBe(
"Unter Anordnung öffnen",
);
expect(at("settings.sections.modules.manageIn")).toMatch(/^Unter /);
});
it("calls the Insights area Insights where a string names it", () => {
for (const key of [
"measurements.openMetricInsights",
"medications.category.custom.bpGateNote",
"admin.aiQuality.title",
"recordSharing.invite.accessManageDetail",
]) {
expect(at(key), key).not.toMatch(/Auswertung/);
expect(at(key), key).toMatch(/Insights/);
}
});
});
@@ -0,0 +1,279 @@
/**
* Every Settings address a person ever had keeps working.
*
* Settings has been regrouped many times (v1.4 split, v1.17 layout hub,
* v1.25 Darstellung, v1.44 nine groups), and every bookmark, cross-link in
* an older notification and docs page still points at one of those shapes.
* This walks the full list of historical `/settings/*` addresses through the
* redirects in `next.config.ts` and asserts each one lands on a route that
* renders: a section slug, an Arrangement sub-page or a module page. It does
* the same for the anchors a page forwards client-side when a card moved
* while its page stayed (`settings-anchor-forward.tsx`).
*
* A route removed without a redirect, a redirect pointing at a page that no
* longer exists, or a forward whose target 404s fails here by name.
*/
import { existsSync, readFileSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import { describe, expect, it } from "vitest";
import nextConfig from "../../next.config";
import { SETTINGS_SECTION_SLUGS } from "@/components/settings/section-slugs";
import { LAYOUT_GROUP_IDS } from "@/components/settings/layout-groups";
import { MODULE_SETTINGS_PAGE_IDS } from "@/components/settings/module-settings-page-ids";
import {
SETTINGS_ANCHOR_FORWARDS,
forwardedAnchorTarget,
} from "@/components/settings/settings-anchor-forward";
/** Every Settings path that was ever reachable, newest shapes included. */
const HISTORICAL_SETTINGS_PATHS: readonly string[] = [
"/settings",
// Section slugs of every release since the v1.4 split.
"/settings/account",
"/settings/security",
"/settings/access",
"/settings/modules",
"/settings/integrations",
"/settings/sources",
"/settings/channels",
"/settings/notifications",
"/settings/reminders",
"/settings/layout",
"/settings/environment",
"/settings/anamnesis",
"/settings/score",
"/settings/thresholds",
"/settings/ai",
"/settings/coach",
"/settings/api",
"/settings/mcp",
"/settings/gesundheitsakte",
"/settings/sharing",
"/settings/export",
"/settings/advanced",
"/settings/privacy",
"/settings/about",
// The per-module personalisation slugs before the layout hub.
"/settings/dashboard",
"/settings/insights",
"/settings/medications",
"/settings/mood",
"/settings/labs",
"/settings/illness",
"/settings/vorsorge",
// Layout hub sub-pages since v1.25.11.
"/settings/layout/dashboard",
"/settings/layout/insights",
"/settings/layout/medications",
"/settings/layout/mood",
"/settings/layout/labs",
"/settings/layout/illness",
"/settings/layout/documents",
"/settings/layout/vorsorge",
];
interface Redirect {
source: string;
destination: string;
}
async function settingsRedirects(): Promise<Redirect[]> {
const all = (await nextConfig.redirects?.()) ?? [];
return all
.filter((r) => r.source.startsWith("/settings"))
.map((r) => ({ source: r.source, destination: r.destination }));
}
/** Follow exact-match redirects until none applies; the hash is dropped. */
function resolve(path: string, redirects: readonly Redirect[]): string {
let current = path;
for (let hop = 0; hop < 5; hop += 1) {
const next = redirects.find((r) => r.source === current);
if (!next) return current;
current = next.destination.split("#")[0]!;
}
throw new Error(`redirect loop from ${path}`);
}
/** Does a path (no hash) render a Settings page? */
function renders(path: string): boolean {
if (path === "/settings") return true; // the page redirects to account
const parts = path.split("/").filter(Boolean);
if (parts[0] !== "settings") return false;
if (parts.length === 2) {
return (SETTINGS_SECTION_SLUGS as readonly string[]).includes(parts[1]!);
}
if (parts.length === 3 && parts[1] === "layout") {
return LAYOUT_GROUP_IDS.includes(parts[2]!);
}
if (parts.length === 3 && parts[1] === "modules") {
return (MODULE_SETTINGS_PAGE_IDS as readonly string[]).includes(parts[2]!);
}
return false;
}
describe("historical Settings addresses", () => {
it("reads the redirects from next.config.ts (an empty read is a broken matcher)", async () => {
const redirects = await settingsRedirects();
expect(redirects.length).toBeGreaterThanOrEqual(10);
});
it.each(HISTORICAL_SETTINGS_PATHS)(
"%s still lands on a page",
async (path) => {
const redirects = await settingsRedirects();
const landed = resolve(path, redirects);
expect(renders(landed), `${path} → ${landed}`).toBe(true);
},
);
it("every redirect into Settings points at a page that renders", async () => {
for (const r of await settingsRedirects()) {
const target = r.destination.split("#")[0]!;
expect(renders(target), `${r.source} → ${r.destination}`).toBe(true);
}
});
it("every routable section, sub-page and module page is on the list", () => {
const routable = [
...SETTINGS_SECTION_SLUGS.map((s) => `/settings/${s}`),
...LAYOUT_GROUP_IDS.map((id) => `/settings/layout/${id}`),
...MODULE_SETTINGS_PAGE_IDS.map((id) => `/settings/modules/${id}`),
];
// New routes join the list when they ship, so a later move cannot drop
// one silently. Module pages are new in v1.44 and are allowed here.
const listed = new Set(HISTORICAL_SETTINGS_PATHS);
const unlisted = routable.filter(
(path) => !listed.has(path) && !path.startsWith("/settings/modules/"),
);
expect(unlisted).toEqual([]);
});
it("keeps the v1.44 moves addressable", async () => {
const redirects = await settingsRedirects();
expect(resolve("/settings/layout/mood", redirects)).toBe(
"/settings/modules/mood",
);
expect(resolve("/settings/mood", redirects)).toBe("/settings/modules/mood");
});
});
const SRC = join(process.cwd(), "src");
/** The component file a Settings page renders from. */
function entryFilesFor(page: string): string[] {
const parts = page.split("/").filter(Boolean);
if (parts.length === 2) {
const loader = readFileSync(
join(SRC, "components/settings/settings-section-loader.tsx"),
"utf8",
);
const match = new RegExp(
`(?:^|\\s)"?${parts[1]}"?:\\s*\\(\\)\\s*=>\\s*import\\("@/([^"]+)"\\)`,
"m",
).exec(loader);
return match ? [resolveModule(match[1]!)].filter(isString) : [];
}
if (parts[1] === "modules") {
return [join(SRC, "components/settings/module-settings-pages.tsx")];
}
if (parts[1] === "layout") {
return [join(SRC, "components/settings/layout-section.tsx")];
}
return [];
}
function isString(value: string | null): value is string {
return value !== null;
}
function resolveModule(spec: string, from?: string): string | null {
const base = spec.startsWith(".")
? join(dirname(from!), spec)
: join(SRC, spec.replace(/^@\//, ""));
for (const candidate of [
base,
`${base}.tsx`,
`${base}.ts`,
join(base, "index.tsx"),
join(base, "index.ts"),
]) {
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
}
return null;
}
/**
* Does any component reachable from `entries` render `anchor`, as a card
* header's `anchor` prop or an element id? Walks the component imports only
* (`@/components/...` and relative), which is where a card's anchor lives.
*/
function anchorIsRendered(entries: string[], anchor: string): boolean {
const pattern = new RegExp(`(?:anchor|id)=["{]+"?${anchor}"`);
const seen = new Set<string>();
const queue = [...entries];
while (queue.length > 0 && seen.size < 600) {
const file = queue.shift()!;
if (seen.has(file)) continue;
seen.add(file);
const text = readFileSync(file, "utf8");
if (pattern.test(text)) return true;
for (const m of text.matchAll(/from\s+"((?:@\/components\/|\.)[^"]+)"/g)) {
const next = resolveModule(m[1]!, file);
if (next && !seen.has(next)) queue.push(next);
}
}
return false;
}
describe("anchors forwarded after a card moved", () => {
const forwards = Object.entries(SETTINGS_ANCHOR_FORWARDS).flatMap(
([page, anchors]) =>
Object.entries(anchors).map(([anchor, target]) => ({
page,
anchor,
target,
})),
);
it("has forwards to check", () => {
expect(forwards.length).toBeGreaterThanOrEqual(8);
});
it.each(forwards)(
"$page#$anchor forwards to a page that renders",
({ page, anchor, target }) => {
expect(renders(page), page).toBe(true);
expect(forwardedAnchorTarget(page, `#${anchor}`)).toBe(target);
expect(renders(target.split("#")[0]!), target).toBe(true);
},
);
it.each(forwards)(
"$target carries the anchor it is forwarded to",
({ target }) => {
const [page, anchor] = target.split("#");
if (!anchor) return;
const entries = entryFilesFor(page!);
expect(entries.length, page).toBeGreaterThan(0);
expect(
anchorIsRendered(entries, anchor),
`${anchor} not found on ${page}`,
).toBe(true);
},
);
it("the anchor search finds a known anchor and misses a made-up one", () => {
const entries = entryFilesFor("/settings/security");
expect(anchorIsRendered(entries, "sessions")).toBe(true);
expect(anchorIsRendered(entries, "no-such-anchor-anywhere")).toBe(false);
});
it("leaves an anchor that did not move alone", () => {
expect(forwardedAnchorTarget("/settings/privacy", "#retention")).toBeNull();
expect(forwardedAnchorTarget("/settings/security", "#sessions")).toBeNull();
expect(forwardedAnchorTarget("/settings/privacy", "")).toBeNull();
});
});
@@ -13,7 +13,7 @@
* is `STRUCTURED_LEAF_GROUP`, the other is HTML rendered by the real
* `<ClinicianView>` over a real fixture. It proves four things per leaf.
*
* 1. `DISPOSITIONS` is `Record<StructuredLeafId, …>`, so a nineteenth
* 1. `DISPOSITIONS` is `Record<StructuredLeafId, …>`, so a new
* structured leaf cannot be added to the catalogue without deciding here
* whether it renders — and saying so — or `pnpm typecheck` fails. That is
* the lock, and it is the compiler's, not this suite's.
@@ -291,6 +291,69 @@ const DISPOSITIONS: Record<StructuredLeafId, Disposition> = {
},
marker: "Semaglutide",
},
MEDICATION_EFFECT: {
kind: "rendered",
data: {
medicationEffects: [
{
medicationName: "Effect-marker drug",
startsOn: "2026-01-02",
targets: [
{
type: "BLOOD_PRESSURE_SYS",
effect: {
present: false,
reason: "insufficient_after",
level: 0.95,
window: null,
days: {
before: 40,
after: 3,
excludedLowAdherence: 0,
excludedNotTaking: 0,
},
adherence: {
status: "tracked",
floorPct: 80,
lookbackDays: 14,
},
levelChange: null,
slopeChangePerWeek: null,
preSlopePerWeek: null,
fit: null,
confounders: [],
},
},
],
},
],
},
marker: "Effect-marker drug",
},
WORKOUTS: {
kind: "rendered",
data: {
workouts: {
sessions: 3,
totalDurationSec: 7200,
sessionsPerWeek: 0.7,
minutesPerWeek: 28,
sportCount: 1,
bySport: [
{
sportType: "rowing",
sessions: 3,
durationSec: 7200,
distanceM: 15000,
distanceSessions: 3,
energyKcal: null,
avgHeartRate: null,
},
],
},
},
marker: "15.0 km",
},
ALLERGIES: {
kind: "rendered",
data: {
@@ -444,7 +507,7 @@ describe("clinician view — every catalogue leaf has a renderer or a reason", (
it("reads a plausible catalogue", () => {
// Sanity floor: a degraded import would satisfy every loop below
// vacuously, which is the failure mode a pair guard dies of.
expect(STRUCTURED_LEAF_IDS).toHaveLength(18);
expect(STRUCTURED_LEAF_IDS).toHaveLength(20);
expect(Object.keys(DISPOSITIONS).sort()).toEqual(
[...STRUCTURED_LEAF_IDS].sort(),
);
@@ -457,7 +457,7 @@ describe("the refusal stays on screen, addressed to its own row", () => {
const alert = /<[^>]*data-slot="grant-action-error"[^>]*>/.exec(html)?.[0];
expect(alert).toBeDefined();
// A refusal somebody has to act on is content. UI-STANDARDS §3 reserves
// A refusal somebody has to act on is content. ui-standards.md §3 reserves
// muted for meta, and a sentence nobody reads is a sentence nobody acts
// on.
expect(alert).toContain("text-destructive");
+33 -1
View File
@@ -1077,6 +1077,18 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "measurements",
why: "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.",
},
"app/api/measurements/provenance/route.ts": {
domain: "measurements",
why: "Which source delivered a metric on one UTC day and which one the record's source priority picks, with the reason. Read-only aggregates of the resolved user's readings; no AI, no note text.",
},
"app/api/measurements/provenance/compare/route.ts": {
domain: "measurements",
why: "Two sources of one metric side by side over at most a year of UTC days, with the source the ladder picked each day. Read-only aggregates of the resolved user's readings; no AI, no note text.",
},
"app/api/activity/calendar/route.ts": {
domain: "measurements",
why: "The activity calendar: day totals of steps, active minutes and load, workouts by sport, and the record owner's goals, computed without AI. Read-only, scoped to the resolved user, module-gated on the record's own `workouts` switch like the workout list beside it.",
},
"app/api/cycle/cycles/route.ts": {
domain: "cycle",
why: "The record's cycle history. Whether cycle tracking applies is a property of the record, and the gate gets there by re-loading the profile and gender for the resolved user id — note that the `gender` argument at the call site is dead (`requireCycleEnabled` names it `_gender`), so it is the id that carries the substitution, not that argument.",
@@ -2044,6 +2056,10 @@ const GUARDIAN_ROUTES: Record<string, string> = {
"Verified integration connection status for the managed record, without any provider control surface.",
"app/api/record-settings/[family]/route.ts":
"The closed, field-specific managed-record configuration DTOs; identity and credentials remain excluded.",
"app/api/record-settings/integrations/[provider]/connect/route.ts":
"Starts an OAuth connect for the managed record a guardian administers. It returns the consent URL and the state names the managed record; the callback re-checks the browser's live MANAGE grant before any token lands. Refused on an adult record by the marker, at every grant level.",
"app/api/record-settings/integrations/[provider]/disconnect/route.ts":
"Disconnects a provider from the managed record a guardian administers, through the same disconnect the owner runs. Imported data stays. Refused on an adult record by the marker, at every grant level.",
};
/**
@@ -2068,6 +2084,10 @@ const ACTOR_ROUTES: Record<string, string> = {
"The app shell reads it on every boot, including while a switch is on: the switcher, the banner naming whose record is open, and the route back out all bind this payload. Every field it returns is the caller's own — their preferences, their modules, their identity — and the one field about the switch says only which records they may open and which they are inside. It reads no row of the owner's, and its `accountAccess` block grants nothing: the resolver re-checks the grant on every delegated request regardless of what this payload said a moment ago.",
"app/api/auth/me/locale/route.ts":
"The UI language belongs to the person reading the screen, never to the record on it. Under a record scope this would transplant a delegate's choice onto the owner's row and send the owner's cron mail in a language they may not read. It has to keep working rather than merely refuse safely: the switcher's mount-time backfill fires on every page load, including the shared dashboard.",
"app/api/settings/module-list-layout/route.ts":
"How the person's own labs, illness and checkups lists are laid out: card or list view, manual order, sort. It reads and writes the caller's own row only. Inside another record the ids of a saved order simply match nothing and the list keeps the server order, so serving the person's own choice there reveals nothing of the owner's and grants nothing; refusing it under a switch broke the list controls in exactly the context they are used in.",
"app/api/settings/preview-features/route.ts":
"The person's own opt-in to preview features. A preview entry is chrome of the person reading the screen, never a property of the record on it; it reads and writes the caller's own row and nothing of the owner's.",
"app/api/feature-flags/route.ts":
"Reads the `AppSettings` singleton and no user row at all, so there is no record for a switch to substitute. It answers about the deployment, which for this purpose is the caller's side of the request; the shell gates the Coach launcher and the assistant surfaces on it, so a 403 here is a piece of chrome deciding it does not exist.",
};
@@ -2164,8 +2184,20 @@ const ACTOR_ROUTES: Record<string, string> = {
* 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.
*
* v1.44 -- a guardian connects and disconnects a managed profile's OAuth
* providers: two guardian-only entries. 266 -> 268.
*
* v1.44 -- the module list layout and the preview-feature switch are the
* person's own presentation and join the actor list. 268 -> 270.
*
* v1.44 -- three readable routes join the record list: the activity calendar
* read and the two source provenance reads (one day, and two sources side by
* side). The activity goals route does not: setting or changing goals stays
* owner-only, and a delegate sees the goals only as the calendar reports them.
* 270 -> 273.
*/
const FROZEN_ENTRY_COUNT = 266;
const FROZEN_ENTRY_COUNT = 273;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -30,6 +30,8 @@ const ALLOWED: Record<string, string> = {
"The correlation engine's centred sums and the Welch t-test variances.",
"lib/insights/mood-tag-influence.ts": "Welch t-test variances.",
"lib/insights/mood-pattern-cards.ts": "A pooled variance for a t statistic.",
"lib/stats/segmented-regression.ts":
"The total sum of squares behind R² and the HAC standard errors of a segmented fit; neither is reported as a spread.",
"lib/insights/derived/trajectory.ts":
"The residual standard error of a regression fit (÷ n − 2).",
"lib/insights/derived/sleep-score.ts":
@@ -82,6 +82,9 @@ const PINNED_AFFORDANCES: Record<
// the two status grids used to spell the tint at every call site and now
// name a tone instead, so the marker lives here once.
"src/components/admin/_shared.tsx": { "text-success": 3, "toast.success": 1 },
// v1.44: the activity calendar's per-week tick for "every weekly goal met",
// a state of a row the person reads, not the outcome of a write.
"src/components/activity/activity-calendar-card.tsx": { "text-success": 1 },
// v1.42: the readiness inventory's "carries" status icon, one arm of the
// carries / thin / empty tone table, beside its spoken status label.
"src/components/timeline/readiness-inventory.tsx": { "text-success": 1 },
@@ -348,7 +351,6 @@ const PINNED_AFFORDANCES: Record<
"src/components/settings/dashboard-layout-section.tsx": {
"toast.success": 2,
},
"src/components/settings/email-card.tsx": { "text-success": 1 },
"src/components/settings/environment-section.tsx": { "toast.success": 4 },
"src/components/settings/insights-pill-order-section.tsx": {
"toast.success": 1,
@@ -383,13 +385,12 @@ const PINNED_AFFORDANCES: Record<
},
"src/components/settings/mcp-section.tsx": {
"text-success": 4,
"toast.success": 2,
"toast.success": 1,
},
"src/components/settings/notification-status-card.tsx": {
CheckCircle2: 2,
"text-success": 1,
},
"src/components/settings/ntfy-card.tsx": { "text-success": 1 },
// The one that used to sit inside `account-section/index.tsx` — the card
// moved to Settings → Security, the line moved with it.
"src/components/settings/password-card.tsx": { "text-success": 1 },
@@ -397,7 +398,6 @@ const PINNED_AFFORDANCES: Record<
"src/components/settings/share-link-create-form.tsx": { "text-success": 3 },
"src/components/settings/sharing-section.tsx": { "text-success": 1 },
"src/components/settings/sources-section.tsx": { "toast.success": 2 },
"src/components/settings/telegram-card.tsx": { "text-success": 1 },
"src/components/settings/test-connection-button.tsx": {
CheckCircle2: 2,
"text-success": 1,
@@ -407,7 +407,6 @@ const PINNED_AFFORDANCES: Record<
},
"src/components/settings/trusted-devices-card.tsx": { "text-success": 1 },
"src/components/settings/web-push-card.tsx": { "text-success": 1 },
"src/components/settings/webhook-card.tsx": { "text-success": 1 },
"src/components/targets/target-edit-sheet.tsx": { "toast.success": 2 },
"src/components/ui/password-strength.tsx": { "text-success": 2 },
// The success tone of the grade chip (a high confidence, a score that went
+61
View File
@@ -0,0 +1,61 @@
/**
* The published UI standards read as public documentation.
*
* `docs/contributing/ui-standards.md` is the binding rulebook, linked from the
* code. It must not carry working shorthand that only made sense in the
* maintainer's notes, must not contradict itself on the spacing scale, and
* follows the docs' punctuation (no em dashes).
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const DOC = readFileSync(
join(process.cwd(), "docs/contributing/ui-standards.md"),
"utf8",
);
function section(number: number): string {
const start = DOC.indexOf(`## ${number}. `);
const next = DOC.indexOf("\n## ", start + 1);
return DOC.slice(start, next < 0 ? undefined : next);
}
describe("docs/contributing/ui-standards.md", () => {
it("has no em dashes", () => {
expect(DOC.length).toBeGreaterThan(5000);
expect(DOC).not.toContain("—");
});
it("carries no internal working shorthand", () => {
for (const phrase of [
/lineage/i,
/swept on touch/i,
/remains planned/i,
/#\d{2,4}\b/,
]) {
expect(DOC).not.toMatch(phrase);
}
});
it("states the spacing scale once, in §2", () => {
const scale = /`1 \/ 1\.5 \/ 2 \/ 2\.5 \/ 3 \/ 3\.5 \/ 4 \/ 6 \/ 8 \/ 10`/;
expect(section(2)).toMatch(scale);
// Every other section refers to §2 instead of listing its own copy.
for (const n of [1, 3, 8, 13]) {
expect(section(n), `§${n}`).not.toMatch(/`1\/1\.5\/2/);
}
});
});
describe("docs/migration/v1.3-to-v1.4.md", () => {
it("does not promise a fragment redirect the server cannot see", () => {
const doc = readFileSync(
join(process.cwd(), "docs/migration/v1.3-to-v1.4.md"),
"utf8",
);
expect(doc).toContain("Settings has moved to per-section routes");
expect(doc).not.toMatch(/#anchor` URLs\s+still 308-redirect/);
});
});
@@ -361,6 +361,16 @@ const UNIT_LITERAL_ALLOWLIST: Record<
reason:
"SI by policy: the clinician artefacts keep mass in kg (glucose follows the owner)",
},
"lib/doctor-report-pdf/activity-effect-sections.ts": {
lines: 2,
reason:
"SI by policy: the report's workout distance is in km, like every clinician artefact",
},
"components/clinician/activity-effect-sections.tsx": {
lines: 1,
reason:
"SI by policy: the clinician page's workout distance is in km, like the PDF",
},
"components/insights/workout-detail/splits.tsx": {
lines: 1,
reason: "kilometre splits are cut server-side; their pace is per km",
@@ -43,6 +43,12 @@ const IDENTITY_OWNER: Record<string, string> = {
"lib/jobs/workout-insight-generate.ts",
"lib/workouts/list-read.ts": "lib/workouts/list-read.ts",
"lib/workouts/sport-context.ts": "lib/workouts/sport-context.ts",
// The doctor report's per-sport block (v1.44): sums sessions, so a session
// two sources reported must count once, as it does on the list.
"lib/doctor-report/workouts.ts": "lib/doctor-report/workouts.ts",
// The activity calendar counts workouts per day; a session two devices
// recorded must count once there, as it does in the list.
"lib/activity/calendar-read.ts": "lib/activity/calendar-read.ts",
// The only indirection in the set. The Coach block is handed rows it does not
// read itself; the snapshot builder fetches them in parallel with every other
// block's data. Recording the owner here rather than assuming the call site
@@ -52,6 +58,16 @@ const IDENTITY_OWNER: Record<string, string> = {
// about to insert, so a duplicate inside one batch is dropped by the rule
// the list later applies.
"app/api/workouts/batch/route.ts": "app/api/workouts/batch/route.ts",
// The day-total training impulse, the briefing's workout tally and the day
// view's workout section: each counted a session synced by two services
// twice while the list showed it once.
"lib/insights/strain-score.ts": "lib/insights/strain-score.ts",
"lib/insights/feature-blocks.ts": "lib/insights/feature-blocks.ts",
"lib/day/records.ts": "lib/day/records.ts",
// The workout-minutes correlation channel: one session per day per sport,
// as the list shows it.
"lib/insights/correlation-channel-series.ts":
"lib/insights/correlation-channel-series.ts",
};
/**
@@ -116,6 +116,15 @@ const PROVIDER_INGESTS: ProviderIngest[] = [
writeFile: "lib/import/health-connect/import.ts",
metadataKey: "healthConnectExerciseType",
},
{
provider: "Oura",
mapper: "mapOuraSportType",
mapperFile: "lib/oura/sport-map.ts",
captureFile: "lib/oura/sync-workouts.ts",
captureField: "w.activity",
writeFile: "lib/oura/sync-workouts.ts",
metadataKey: "ouraActivity",
},
{
provider: "Fitbit",
mapper: "mapFitbitSportType",
@@ -53,6 +53,9 @@ const LIMITING_HELPERS: Record<string, string[]> = {
"lib/withings/webhook-handler.ts",
"lib/whoop/webhook-handler.ts",
],
// v1.44 — the lab and medication CSV imports: a preview bucket for a dry
// run, the shared `import:` bucket for the import itself.
handleRecordImport: ["lib/import/record-import-route.ts"],
};
/** Mutating routes with no limit, each with the reason it needs none. */
+1 -1
View File
@@ -318,7 +318,7 @@ export default function AchievementsPage() {
}
// A failed load must never fall through to the zeroed summary tiles + the
// "none unlocked yet" empty grid (UI-STANDARDS §6). Surface an honest
// "none unlocked yet" empty grid (ui-standards.md §6). Surface an honest
// retry instead.
if (isError) {
return (
@@ -0,0 +1,168 @@
/**
* `/api/activity/calendar` and `/api/activity/goals` (D1036).
*
* The goals: a PATCH replaces only what it names, `null` clears, an unknown
* key or a goal out of bounds is a 422 and writes nothing, and clearing the
* last goal returns the column to NULL rather than storing an empty object.
* The calendar: the module gate answers before any read, a future or malformed
* day is refused, and the reader is asked for exactly the window the period
* needs and never past today.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const mocks = vi.hoisted(() => ({
findUnique: vi.fn(),
update: vi.fn(),
readActivityDays: vi.fn(),
requireModuleEnabled: vi.fn(),
auditLog: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: (handler: unknown) => handler,
requireAuth: vi.fn(async () => ({ user: { id: "user-1" } })),
requireRecordAuth: vi.fn(async () => ({
user: { id: "user-1" },
actor: { id: "user-1" },
grantId: null,
})),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: mocks.auditLog }));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: mocks.findUnique, update: mocks.update } },
toJson: (v: unknown) => v,
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: mocks.requireModuleEnabled,
}));
vi.mock("@/lib/activity/calendar-read", () => ({
readActivityDays: mocks.readActivityDays,
}));
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: vi.fn(async () => "Europe/Berlin"),
}));
import { Prisma } from "@/generated/prisma/client";
import { GET as calendarGet } from "../calendar/route";
import { GET as goalsGet, PATCH as goalsPatch } from "../goals/route";
type Handler = (req: NextRequest) => Promise<Response>;
function patch(body: unknown) {
return (goalsPatch as unknown as Handler)(
new NextRequest("http://localhost/api/activity/goals", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
}
function calendar(query: string) {
return (calendarGet as unknown as Handler)(
new NextRequest(`http://localhost/api/activity/calendar${query}`),
);
}
beforeEach(() => {
vi.clearAllMocks();
mocks.findUnique.mockResolvedValue({
activityGoalsJson: { workoutsPerWeek: 3, stepsPerWeek: 50000 },
});
mocks.update.mockResolvedValue({});
mocks.requireModuleEnabled.mockResolvedValue({ enabled: true });
mocks.readActivityDays.mockResolvedValue({ days: [], loadType: null });
});
describe("activity goals", () => {
it("reads every goal, unset ones as null", async () => {
const res = await (goalsGet as unknown as () => Promise<Response>)();
const body = await res.json();
expect(body.data).toEqual({
workoutsPerWeek: 3,
activeMinutesPerWeek: null,
stepsPerWeek: 50000,
stepsPerMonth: null,
});
});
it("changes what the body names and keeps the rest", async () => {
const res = await patch({ stepsPerWeek: null, stepsPerMonth: 250000 });
expect(res.status).toBe(200);
expect(mocks.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
activityGoalsJson: {
workoutsPerWeek: 3,
activeMinutesPerWeek: null,
stepsPerWeek: null,
stepsPerMonth: 250000,
},
},
});
expect(mocks.auditLog).toHaveBeenCalledWith(
"activity.goals.update",
expect.objectContaining({ userId: "user-1" }),
);
});
it("stores NULL once the last goal is cleared", async () => {
await patch({ workoutsPerWeek: null, stepsPerWeek: null });
expect(mocks.update.mock.calls[0][0].data.activityGoalsJson).toBe(
Prisma.DbNull,
);
});
it.each([
[{ caloriesPerWeek: 3000 }],
[{ workoutsPerWeek: 0 }],
[{ stepsPerWeek: 2.5 }],
[{ stepsPerMonth: "200000" }],
])("refuses %j without writing", async (body) => {
const res = await patch(body);
expect(res.status).toBe(422);
expect(mocks.update).not.toHaveBeenCalled();
});
});
describe("activity calendar", () => {
it("answers the module gate before reading anything", async () => {
mocks.requireModuleEnabled.mockResolvedValue({
enabled: false,
response: new Response(null, { status: 403 }),
});
const res = await calendar("?period=month");
expect(res.status).toBe(403);
expect(mocks.readActivityDays).not.toHaveBeenCalled();
});
it.each(["?period=year", "?date=2026-13-01", "?date=yesterday"])(
"refuses %s",
async (q) => {
const res = await calendar(q);
expect(res.status).toBe(422);
},
);
it("refuses a day after today", async () => {
const res = await calendar("?date=2999-01-01");
expect(res.status).toBe(422);
expect(mocks.readActivityDays).not.toHaveBeenCalled();
});
it("reads the baseline window before the period and nothing past the grid", async () => {
const res = await calendar("?period=week&date=2025-03-12");
expect(res.status).toBe(200);
const args = mocks.readActivityDays.mock.calls[0][0];
// Week of Monday 2025-03-10; 84 days before it.
expect(args.fromKey).toBe("2024-12-16");
expect(args.toKey).toBe("2025-03-16");
expect(args.tz).toBe("Europe/Berlin");
const body = await res.json();
expect(body.data.start).toBe("2025-03-10");
expect(body.data.goals.workoutsPerWeek).toBe(3);
expect(body.data.days).toHaveLength(7);
});
});
+98
View File
@@ -0,0 +1,98 @@
/**
* `GET /api/activity/calendar` — a week or a month of activity against the
* person's own normal, with their weekly and monthly goals (D1036).
*
* Computed server-side, without AI, from the stored readings: steps (the
* canonical-source day totals the steps chart draws), exercise minutes,
* workouts after the cross-source collapse, and a training-load score when a
* device or HealthLog provides one. What "usual" means is in
* `src/lib/activity/calendar-math.ts`; it is always the person's own history,
* never a population norm.
*
* Owned by the Workouts module, like the page that shows it: a switched-off
* module answers 403 here as `/api/workouts` does.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import {
buildActivityCalendar,
periodBounds,
} from "@/lib/activity/calendar-math";
import { readActivityDays } from "@/lib/activity/calendar-read";
import { daysBetweenDateKeys, userDayKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { activityCalendarQuerySchema } from "@/lib/validations/activity-calendar";
import { parseActivityGoals } from "@/lib/validations/activity-goals";
export const dynamic = "force-dynamic";
/** How far back the calendar pages: ten years, the series route's ceiling. */
const MAX_DAYS_BACK = 3650 - 200;
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireRecordAuth("read", "measurements");
annotate({ action: { name: "activity.calendar.read" } });
const gate = await requireModuleEnabled(user.id, "workouts");
if (!gate.enabled) return gate.response;
const params = new URL(request.url).searchParams;
const parsed = activityCalendarQuerySchema.safeParse({
period: params.get("period") ?? undefined,
date: params.get("date") ?? undefined,
});
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const tz = await resolveUserTimezone(user.id);
const now = new Date();
const today = userDayKey(now, tz);
const anchor = parsed.data.date ?? today;
if (anchor > today) {
return apiError("The calendar cannot show a day after today", 422, {
errorCode: "activity.calendar.future_date",
});
}
if (daysBetweenDateKeys(anchor, today) > MAX_DAYS_BACK) {
return apiError("The calendar reaches back ten years", 422, {
errorCode: "activity.calendar.too_old",
});
}
const bounds = periodBounds(parsed.data.period, anchor);
const lastRead = bounds.gridEnd < today ? bounds.gridEnd : today;
const [{ days, loadType }, goalRow] = await Promise.all([
readActivityDays({
userId: user.id,
tz,
fromKey: bounds.readStart,
toKey: lastRead,
now,
includeWorkouts: true,
}),
prisma.user.findUnique({
where: { id: user.id },
select: { activityGoalsJson: true },
}),
]);
const calendar = buildActivityCalendar({
bounds,
today,
input: days,
goals: parseActivityGoals(goalRow?.activityGoalsJson ?? null),
});
annotate({
meta: {
activity_calendar_period: bounds.period,
activity_calendar_days_read: days.length,
activity_calendar_load_type: loadType ?? "none",
},
});
return apiSuccess({ ...calendar, timezone: tz, loadType });
});
+90
View File
@@ -0,0 +1,90 @@
/**
* GET / PATCH the caller's activity goals (D1036).
*
* Every goal is optional and set by the person; there is no default goal.
* PATCH replaces the goals the body names (`null` clears one) and keeps the
* rest; an unknown key or an out-of-bounds goal is refused with the
* multi-issue 422. The goals live on their own `User` column
* (`activity_goals_json`) and the activity calendar reads them.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { Prisma } from "@/generated/prisma/client";
import { prisma, toJson } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import {
ACTIVITY_GOAL_KEYS,
activityGoalsSchema,
hasNoActivityGoals,
mergeActivityGoals,
parseActivityGoals,
type ActivityGoals,
} from "@/lib/validations/activity-goals";
export const dynamic = "force-dynamic";
async function readGoals(userId: string): Promise<ActivityGoals> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { activityGoalsJson: true },
});
return parseActivityGoals(row?.activityGoalsJson ?? null);
}
function setKeys(goals: ActivityGoals): string[] {
return ACTIVITY_GOAL_KEYS.filter((key) => goals[key] !== null);
}
export const GET = apiHandler(async () => {
// Owner-only, like the write: a delegate sees the goals where they matter,
// inside the calendar, which carries them.
const { user } = await requireAuth();
const goals = await readGoals(user.id);
annotate({
action: { name: "activity.goals.read" },
meta: { set: setKeys(goals) },
});
return apiSuccess(goals);
});
export const PATCH = apiHandler(async (request: Request) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = activityGoalsSchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "activity.goals.validation-failed" } });
return returnAllZodIssues(parsed.error, 422);
}
const next = mergeActivityGoals(await readGoals(user.id), parsed.data);
await prisma.user.update({
where: { id: user.id },
data: {
activityGoalsJson: hasNoActivityGoals(next)
? Prisma.DbNull
: toJson(next),
},
});
await auditLog("activity.goals.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { set: setKeys(next) },
});
annotate({
action: { name: "activity.goals.update" },
meta: { set: setKeys(next) },
});
return apiSuccess(next);
});
@@ -44,12 +44,19 @@ vi.mock("@/lib/jobs/worker-status", () => ({
})),
}));
const heapMock = vi.hoisted(() => ({ mb: 792 }));
const heapMock = vi.hoisted(() => ({
mb: 792,
container: 1536 as number | null,
}));
vi.mock("@/lib/support/heap-limit", async () => {
const actual = await vi.importActual<
typeof import("@/lib/support/heap-limit")
>("@/lib/support/heap-limit");
return { ...actual, heapLimitMb: () => heapMock.mb };
return {
...actual,
heapLimitMb: () => heapMock.mb,
containerMemoryLimitMb: () => heapMock.container,
};
});
const geoMock = vi.hoisted(() => ({
@@ -200,12 +207,40 @@ describe("GET /api/admin/status (integration health summary)", () => {
const low = (await (await GET()).json()) as {
data: { memory: { heapLimitMb: number; low: boolean } };
};
expect(low.data.memory).toEqual({ heapLimitMb: 524, low: true });
expect(low.data.memory).toMatchObject({ heapLimitMb: 524, low: true });
heapMock.mb = 792;
const enough = (await (await GET()).json()) as {
data: { memory: { heapLimitMb: number; low: boolean } };
};
expect(enough.data.memory).toEqual({ heapLimitMb: 792, low: false });
expect(enough.data.memory).toMatchObject({ heapLimitMb: 792, low: false });
});
it("flags a container memory limit below the supported floor", async () => {
type Memory = {
containerLimitMb: number | null;
floorMb: number;
belowFloor: boolean;
};
heapMock.container = 1024;
const below = (await (await GET()).json()) as { data: { memory: Memory } };
expect(below.data.memory).toMatchObject({
containerLimitMb: 1024,
floorMb: 1536,
belowFloor: true,
});
heapMock.container = 1536;
const atFloor = (await (await GET()).json()) as {
data: { memory: Memory };
};
expect(atFloor.data.memory.belowFloor).toBe(false);
// No limit, or none readable: nothing to flag.
heapMock.container = null;
const none = (await (await GET()).json()) as { data: { memory: Memory } };
expect(none.data.memory).toMatchObject({
containerLimitMb: null,
belowFloor: false,
});
heapMock.container = 1536;
});
it("reports named failing queues through failingJobs only", async () => {
+16 -2
View File
@@ -9,7 +9,13 @@ import {
readFailingQueues,
} from "@/lib/jobs/job-failures";
import { readFileSync } from "node:fs";
import { heapLimitMb, isHeapLimitLow } from "@/lib/support/heap-limit";
import {
containerMemoryLimitMb,
heapLimitMb,
isBelowMemoryFloor,
isHeapLimitLow,
MEMORY_FLOOR_MB,
} from "@/lib/support/heap-limit";
import {
geoLookupMode,
geoProviderChosen,
@@ -77,6 +83,8 @@ export const GET = apiHandler(async () => {
// The process's heap ceiling, and whether it is the 1 GB-container size
// that a long-used account can fill (`heap-limit.ts`).
const heapLimit = heapLimitMb();
// And the container's own limit against the supported floor.
const containerLimit = containerMemoryLimitMb();
return apiSuccess({
version: process.env.npm_package_version || "0.1.0",
@@ -85,7 +93,13 @@ export const GET = apiHandler(async () => {
buildTime: BUILD_TIME,
startTime: START_TIME,
database: "connected",
memory: { heapLimitMb: heapLimit, low: isHeapLimitLow(heapLimit) },
memory: {
heapLimitMb: heapLimit,
low: isHeapLimitLow(heapLimit),
containerLimitMb: containerLimit,
floorMb: MEMORY_FLOOR_MB,
belowFloor: isBelowMemoryFloor(containerLimit),
},
worker: {
running: workerStatus.running,
startedAt: workerStatus.startedAt,
@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* The workout-route opt-in: off by default, a hard-set PATCH, audit-logged,
* and a malformed body refused before anything is written.
*/
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function mkPatch(body: unknown): Request {
return new Request("http://localhost/api/auth/me/workout-routes", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
});
describe("/api/auth/me/workout-routes", () => {
it("reads off when the row carries no value", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
const res = await GET();
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({
data: { storeWorkoutRoutes: false },
});
});
it("sets the flag field by field and audits the change", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
storeWorkoutRoutes: false,
} as never);
const res = await PATCH(mkPatch({ storeWorkoutRoutes: true }));
expect(res.status).toBe(200);
expect(await res.json()).toMatchObject({
data: { storeWorkoutRoutes: true },
});
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { storeWorkoutRoutes: true },
});
expect(auditLog).toHaveBeenCalledWith(
"user.storeWorkoutRoutes.update",
expect.objectContaining({
details: { previous: false, next: true },
}),
);
});
it("refuses a malformed body with 422 and writes nothing", async () => {
const res = await PATCH(mkPatch({ storeWorkoutRoutes: "yes" }));
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("refuses an unauthenticated caller", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await PATCH(mkPatch({ storeWorkoutRoutes: true }));
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
+109
View File
@@ -0,0 +1,109 @@
/**
* Per-account opt-in for keeping workout GPS tracks.
*
* GET /api/auth/me/workout-routes — current flag.
* PATCH /api/auth/me/workout-routes — body `{ storeWorkoutRoutes: boolean }`.
*
* Off by default. While it is off, `POST /api/workouts/batch` and the
* Apple Health export import keep every workout but drop its track, so a
* route (where a person lives, works and trains) is stored only after the
* person asked for it. Turning it off later stops new tracks; tracks already
* stored stay until the workout or the account is deleted.
*
* Same per-user-scalar pattern as `labs-local-ocr`: 60/min rate limit, a
* bounded body, Zod safeParse → 422, field-by-field write, audit row, and
* the resolved next state in the response.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { workoutRoutesPatchSchema } from "@/lib/validations/user-prefs";
export const dynamic = "force-dynamic";
const PATCH_RATE_LIMIT = 60;
const PATCH_WINDOW_MS = 60_000;
type WorkoutRoutesResponse = {
storeWorkoutRoutes: boolean;
};
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.workoutRoutes.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { storeWorkoutRoutes: true },
});
const payload: WorkoutRoutesResponse = {
storeWorkoutRoutes: row?.storeWorkoutRoutes ?? false,
};
return apiSuccess(payload);
});
export const PATCH = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`workout-routes:patch:${user.id}`,
PATCH_RATE_LIMIT,
PATCH_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
const { data: body, error: jsonError } = await safeJson(req, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = workoutRoutesPatchSchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "auth.me.workoutRoutes.patch.invalid_shape" } });
return returnAllZodIssues(parsed.error, 422);
}
const next = parsed.data.storeWorkoutRoutes;
const previous = await prisma.user.findUnique({
where: { id: user.id },
select: { storeWorkoutRoutes: true },
});
await prisma.user.update({
where: { id: user.id },
data: { storeWorkoutRoutes: next },
});
await auditLog("user.storeWorkoutRoutes.update", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
previous: previous?.storeWorkoutRoutes ?? false,
next,
},
});
annotate({
action: { name: "auth.me.workoutRoutes.patch" },
meta: { storeWorkoutRoutes: next },
});
const payload: WorkoutRoutesResponse = { storeWorkoutRoutes: next };
return apiSuccess(payload);
});
Loaded 100 of 623 files, more files were not shown because too many files have changed in this diff. Show more