Compare commits

..
153 Commits
Author SHA1 Message Date
Marc-André Bombeck 417288d3f5 Merge pull request #643 from MBombeck/release/v1.32.39
v1.32.39 — the doctor report carries only what was chosen
2026-07-26 01:25:15 +02:00
Marc-André Bombeck e67bb25338 chore(release): v1.32.39 — the doctor report carries only what was chosen 2026-07-26 01:12:03 +02:00
Marc-André Bombeck 84d78d2926 fix(units): round a converted value where the conversion happens
Every entry in the display-transform registry declares the decimals its
unit is read at. That number was carried to the render layer and read by
exactly one screen out of all the ones that show a converted value, so
everywhere else the raw product of a kilogram and 2.20462262185 reached
the surface. Pounds came out with a dozen digits it never needed.

Move the rounding into `applyDisplayTransform` and
`applyDisplayTransformDelta`, so a surface gets a correctly-scaled number
whether or not it remembers to ask. Patching the weight cell would have
left the same trap behind inches, Fahrenheit, miles per hour and miles.

Two boundaries stay exempt and are documented as such. A transform that
does not rescale is the exact identity and is never rounded, so a stored
78.45 kg keeps rendering as 78.45 on the metric branch and every
untransformed metric is byte-identical. `invertDisplayTransform` — the
entry boundary — keeps full precision, because a value rounded on the way
out and inverted on the way back would drift the stored canonical number
a little further on every save; the entry surfaces quantise to their own
two-decimal canonical dialect instead.

`applyDisplayTransformUnrounded` is the one documented escape hatch, for
arithmetic whose result is not rendered. The target adapter needs it: its
guardrails round inward, and rounding the 30 kg floor to the nearest
tenth of a pound first gives an edge that inverts to 29.98 kg and the
server rejects it.

The weight page and the HealthKit scaffold were folding the factor into
their stat strips by hand. Both now convert through the helper, so the
raw multiplier is only ever handed to the chart, which owns its own
series and formatting.

Formatting is unchanged. Precision is fixed at the conversion and the
surfaces keep the formatter they already used, so switching preference
changes the unit and nothing else about how the number looks.

Refs #627
2026-07-26 00:46:03 +02:00
Marc-André Bombeck 708846f72b chore(db): renumber the report-selection migration behind the glucose-context repair 2026-07-26 00:46:03 +02:00
Marc-André Bombeck bc1633db40 fix(export): make the standard report a click, not a preselection
The scope picker arrived with the standard doctor's report already ticked,
so someone who opened the panel and pressed Generate sent material they
never chose. Nothing is selected now until a person selects it.

The template keeps its definition and loses its automatic application: it
sits on a button at the top of the picker, named, on both surfaces that
mount the picker, and it stays there so the standard set can be restored
after experimenting. One click still gets a complete doctor's report.

Generate and the share-link create refuse an empty scope, with the scope
line beside them saying what to do rather than reporting a failure after
the press; the line also describes the control, so the disabled state is
not silent for a screen reader.
2026-07-26 00:46:03 +02:00
Marc-André Bombeck 59acbed5ab fix(i18n): deliver the locale catalog before the client bundle takes its first render
The root layout shipped the active locale's catalog as `<script defer>` and
counted on it executing ahead of Next's hydration scripts. It does not: Next
emits its own chunks as `async` tags AHEAD of anything the layout renders into
`<head>`, and an `async` script runs the moment its fetch settles. So the
370 KB catalog was racing the app bundle, and on a cold load it lost about a
third of the time — `load-locale` initialised with an empty cache, every `t()`
in the hydration render returned its raw key against the server's real text,
and React tore the tree down with a text hydration mismatch. The user saw a
frame of `nav.skipToContent` before the provider's backfill landed.

Register the catalog through `next/script` at `beforeInteractive` instead.
Next's own bootstrap awaits those scripts before requiring a single app module,
and pairs the registration with a `<link rel=preload>` so the download still
starts with the document rather than after it. The CSP nonce rides along, so
the posture is unchanged.

Fold the boot global into the cache lazily rather than snapshotting it at
module evaluation. The snapshot made correctness depend on an ordering the
module cannot see; reading on demand reduces the invariant to something local
and testable — whatever the boot script assigns is visible to the first `t()`
that asks, whenever it lands.

Measured on the dashboard render spec, 30 repeats per project against a
production build: 11 of 30 failed on desktop before, 0 of 60 after.
2026-07-26 00:46:03 +02:00
Marc-André Bombeck 7b56f9ddbd test(export): stop the Gesundheitsakte specs racing hydration
Everything on that page is server-rendered, so a row is visible long before
the client boundary that owns it is live. A chevron click landed on inert
markup about one run in four, and a `boundingBox()` occasionally came back
null because the node had just been replaced.

The page is one client boundary carrying the export panel and the sharing
card, so the sharing card's own query is a sound signal that the surface is
wired. One helper opens the page and waits for it; every case on that route
goes through it, including the disclosure case that was already doing this
by hand.
2026-07-26 00:46:03 +02:00
Marc-André Bombeck e9b7d5f885 test(export): point the export specs at the surface they mean
Three of the picker assertions resolved to two elements and failed strict
mode; they now name the export mount, and a new case pins that each mount
carries exactly one of every id the picker renders.

The included-data disclosure case asserted a collapsed first render. The
panel opens on a first run on purpose — that is the only place the standard
template is applied, and a template behind a closed disclosure is opt-out
wearing a hat — so the case now asserts the contract that replaced it: open
on first render, and still a disclosure, closing and reopening under the
same control.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck fcd3dd2d07 fix(export): make each scope-picker mount addressable on its own
Settings → Gesundheitsakte mounts the one scope picker twice: the export
panel at the top, the share-link create form under `#sharing`. Sharing the
component is deliberate, but it also meant every test id inside it resolved
to two elements on that page, so nothing in the picker could be addressed
without guessing which one you got.

The picker now takes a required `surface` and every id under it ends with
that surface — `report-group-row-vitals-export` and
`report-group-row-vitals-share` are two controls and read as two controls.
The summary line beside each picker follows the same rule. A third mount
picks a value and stays addressable by construction.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 2f92c0577b chore(db): renumber the report-selection migration after 0273 landed on main 2026-07-26 00:45:08 +02:00
Marc-André Bombeck ffe99caccd fix(fhir): stop advertising a credential no client can obtain
The capability descriptor named `fhir:read` as the scope a narrow token needs
for the REST face. No code path mints a token carrying it — every mint grants a
wildcard, the medication ingest scope, or an MCP scope — so the face is
reachable by the owner's cookie session or a wildcard device token and by
nothing else. Advertising an unobtainable credential invites a client to go
looking for one. The constant stays where it belongs, as the fail-closed check
each route makes; the descriptor now reports the fact instead.

End-to-end coverage for the scope picker: the first run opens it expanded with
the fenced tier rendered and no group control over it, a group row clears the
44 px floor at 390 px, and a leaf grid opens from its chevron. All on stable
test ids rather than text a breakpoint can hide.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck f12290532f fix(sharing): let the owner replace a link the upgrade closed, and count only what is shown
Migration 0273 revokes every live record share link minted before the selection
model, because their frozen scope was "everything except mood and cycle" — a
scope nobody chose. The owner has to see why the link stopped and be able to
replace it without retyping what they already decided, or a deliberate closure
reads as a fault. The list now names that state, says why in one sentence, and
offers a replacement that carries the label, the window and the lifetime
across, leaving exactly the decision that link never carried.

Writing the test for it found a defect in the picker. The group count and the
group checkbox read the catalogue's group rather than the leaves the surface
actually renders, so on the share-link form — where the insurance leaf is
hidden because the server refuses it — the identity row read "1/2" and drew
itself half-selected over a leaf the owner could neither see nor reach, and its
checkbox would have tried to add it. The three helpers take the rendered leaves
now, so a control cannot describe more than it offers.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 9e68ff525f feat(share): serve the record a link already implies, and say when there is none
Two downloads under the token — the FHIR document Bundle and the clinical PDF —
behind the same unlock cookie the document route enforces, serving exactly the
link's frozen selection. That is what the removed FHIR toggle promised, in the
form a practice actually uses: open the link, unlock it, save the file. No new
credential type, no new scope, no mint path that does not exist.

The gate is the document route's, step for step: the same live check, the same
token-scoped cookie, the same blunt 404 for every miss class so a probe cannot
tell "no such link" from "revoked" from "documents-only". It differs in one
number, because generating a bundle is far more expensive than serving a stored
blob and must not share a budget with it. Each download writes its own audit
row. The scope cannot exceed the page's: both resolve the same frozen leaf list
through the same resolver, and the insurance leaf is refused at create, so the
insurance number cannot reach this path at all.

The MCP doctor-visit summary stops claiming presence it cannot support. An
account whose owner never saved a report selection resolves to the empty one on
that surface, and an empty scope produces an empty payload — which now reads as
{ present: false, reason }, the same sentinel the other reads use, instead of a
record with every section quietly missing.

Mutation-checked: removing the unlock check, the rate limit, the documents-only
guard or the audit row reddens its own case; pointing the bucket at the document
route's key reddens the bucket assertion; making presence unconditional reddens
the absence case.

The saved profile's shape moves out of the module that reads the row. The panel
is a client component and parses the blob from the /me payload; importing the
reader dragged the database client into the browser bundle, which typecheck and
the unit suite both pass and only `next build` catches.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck f42934487d test(export): prove the scope holds at the artefact, not just at the switch
Five layers, because the defect had five places to hide.

The catalogue guard checks what the compiler cannot state: every leaf resolves
a label a human can read, every group resolves one, the fenced tier stays last,
and the shipped template intersects the fence at nothing.

The selection guard pins the three minting functions against each other. A
stored blob this build cannot read resolves to the empty selection, never to a
default set — that is what makes the revoked legacy links revocable rather than
silently re-scoped. A request naming an id the server does not know is refused
by name rather than dropped, because dropping it would narrow a scope the
caller did express.

The egress guard freezes the set of aggregator callers with the source of each
one's selection written down. The required brand-typed parameter already stops
a new path compiling without an answer; this stops one being added without a
human writing the answer down, which is the moment a reviewer asks the
question.

The behavioural sweep aggregates every leaf in and out against a fixture that
carries a row for each, and asserts the payload differs. It is parametrised
over the catalogue, so a leaf added later is covered the day it is added. Its
positive control asserts the all-on payload is genuinely populated first, so a
"no difference" result means the gate is inert and not that the account is
empty. Beside it: an unmapped measurement type is excluded rather than passed
through, no table behind an unchosen leaf is queried at all, and an empty
selection produces an empty record.

The artefact layer runs the real route and reads what comes out — the FHIR
bundle, the PDF text and the zip's contents — with a positive control beside
every exclusion.

Mutation-checked: making the gate admit everything reddens the sweep for every
leaf; passing an unmapped type through reddens the fail-closed case; dropping
the mood query's condition reddens the zero-read case; returning the BMI
unconditionally reddens the sweep naming BODY_MASS_INDEX; adding a fenced leaf
to the template reddens the purity guard; removing the panel's seeding block
reddens the first-run and repeat-run cases; dropping the insurance refusal
reddens the share-link case; adding a caller outside the frozen list reddens
the egress guard naming the file.

The panel suite grew past the practice name it was written for and is renamed
for what it now covers: the three states, the fenced tier having no group
control, and the scope line naming its sensitive inclusions.

Integration coverage moves to the leaf model, including the case this release
exists for: an account with data in every domain and a caller that named
nothing gets an empty record.

The OpenAPI contract catches up with the routes — the required selection, the
refused keys, the share summary's new fields, the moved `Patient/$everything`,
the Coverage search and the five typed reads.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 582db8a538 feat(export): one grouped scope picker, on both surfaces that choose a scope
The export panel rendered twenty flat switches behind a collapsed disclosure,
and the share-link form rendered none at all — it posted whatever the create
schema defaulted to. Both now mount the same picker: twelve named groups, a
count on each, the leaf grid one chevron away, and a fenced tier below a
separator.

The fence is mechanical, not a label. The sensitive tier has no group
checkbox, so no single control anywhere in the picker can turn on more than
one of mood, cycle, family history, PHQ-9, GAD-7, WHO-5 or SCI at a time.
There is no select-all. The shipped template contains none of them. And the
scope line under the button restates every sensitive inclusion by name rather
than folding it into a count, because a number cannot be checked at a glance
and "incl. Mood, PHQ-9" can.

First run opens expanded with the named standard template applied and the
fenced tier visibly empty, so the user presses Generate having seen twelve
named groups. Every later run opens collapsed carrying their own last
selection, which makes the waiting-room path two interactions.

Only leaves are ever stored. A group's checkbox is derived from its leaves, so
the two cannot disagree, and switching a group off and back on inside one
session restores the pattern instead of flooding it.

The cycle export card is deleted. It posted empty groups under a docblock
claiming every other section was off, and empty groups resolved to the
defaults — so a "cycle only" download carried weight, blood pressure, pulse,
oxygen saturation, body fat, body composition, the medication list, adherence,
labs, allergies and family history. Cycle is a fenced leaf in the one picker
now, reachable for the PDF as well as for FHIR, which is more than the card
offered.

The panel gains a 30-day preset and a custom window, and its error line says
what went wrong instead of interpolating a status code.

The Checkbox primitive learns the indeterminate state it already had the Radix
support for. A partly-selected group drawn as unchecked would say "none of
this is included", which on this surface is a false statement about what
leaves the instance.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 5e490f31ec feat(export): make the doctor report carry only what was chosen
The report resolved an absent key to "included". A caller that sent no
sections object, or an empty group inside one, received the full record at
server-chosen defaults: roughly fifty metrics plus the medication list, labs,
allergies and family history. The web panel sent every key explicitly so it
looked fine from the browser, but the share link, the FHIR REST face, the MCP
doctor-visit resource and the cycle-only export card all went through paths
where absence meant yes. No arrangement of booleans fixes a default that means
yes, so the shape goes.

A selection is now an inclusion list of leaf ids. A leaf is the smallest thing
that can be released or withheld: every measurement type, and thirteen
structured sections — identity, insurer, the glucose panel, labs, the drug
list, the administration ledger, adherence, the GLP-1 block, allergies,
illness episodes, family history, mood, cycle. Membership is inclusion,
absence is exclusion, and there is no defaults constant anywhere to drift.

Three locks keep a metric added later off a report chosen earlier. A stored
selection is a list of what was chosen, so a leaf that did not exist then is
not in it. The catalogue is keyed over the measurement enum, so adding a
member without deciding its group fails typecheck in the same commit. And the
aggregator's filter inverts: a type the catalogue does not know is excluded and
annotated, where it used to be passed through.

The aggregator's third parameter is now required and brand-typed, which turned
the three surfaces that had no scope at all into compile errors. Each one had
to name a source, and each now replays the owner's saved selection — the same
object reviewed in Settings. An account that never saved one gets nothing
there rather than a guess.

Identity and insurance become selectable, and a share link refuses the
insurance leaf outright. The share view never decrypted the number; that is
structural now instead of incidental. The KVNR is not even read unless the
leaf was chosen.

Two surfaces gain a record they always implied. A share link serves its frozen
selection as a FHIR bundle and as the PDF, behind the same unlock cookie the
document route uses, with its own rate bucket, its own audit row and the same
blunt 404 for every miss. That is what the removed FHIR toggle promised, in
the form a practice actually uses.

The AI summary leaves the report. Its text came from the cached briefing with
no relation to the selection, so its scope could not be proven, and prose whose
scope cannot be proven has no place on a document whose whole point is a proven
scope.

The PDF table stops being a nine-type whitelist. It prints every selected leaf
that has data, sub-headed by group, in the same order the panel shows — so
selecting resting heart rate no longer means seeing it in the bundle and not in
the PDF. The clinician view is grouped the same way and reads real labels
instead of "Phq9 score".

Migration 0273 adds the saved-selection column, drops the orphaned prefs column
and the two retired share-link scope columns, and revokes every live record
share link minted before this model. Their frozen scope was "everything except
mood and cycle", which is a scope nobody chose; narrowing it would still be a
scope nobody chose. Documents-only shares are untouched. The sharing list shows
each closed link in a pick-again state with its label, window and expiry kept.

The retired allowFhirApi and resourceTypes stay on the create and list
responses as the constant false and empty list. The request refuses them. They
come off the response once the native client confirms it does not decode them.

Six files are split at seams this change already touched: the aggregator into
its gate, its dense-bucket query, its series, its glucose panel, its medication
ledger and its clinical records; the export route's three emitters into their
own module; the clinician view into its sections, its document list and its
downloads.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck f90219784b test(export): pin the selection controls against going inert again
Two layers, because the defect had two halves and each needs its own kind of
proof.

The structural guard walks the panel's control shape, folds every control
through the wire schema and the resolver the route uses, and fails when
flipping one changes nothing in the resolved selection. It also checks the
panel renders a row for every control it declares, and that every resolved
flag is read downstream, either by gating measurement types or by name in the
aggregator. That is the link nobody had written: the dead switches were dead
for several releases and no test connected "the panel renders this" to "the
export honours this".

The behavioural half runs the real export route and reads what comes out. The
previously-dead measurement switches are asserted against the FHIR bundle and
the PDF table, with a positive control beside each exclusion so nothing passes
on an empty fixture. The screening totals are asserted absent for a caller
that names other sections and for a caller that sends no sections at all, then
present when asked for by name. Allergies and family history are asserted
absent from the bundle and never read from the database, and the package case
opens the zip and checks the bundle inside it agrees with the PDF beside it.

Mutation-checked: reverting the allergy gate, the medication-list gate, the
screener entries or the measurement entries turns the matching cases red and
leaves the rest green; removing a control's fold entry or its aggregator entry
turns the guard red with the control named.

The remaining changes update fixtures the new shapes touch: prefs objects now
resolve the added flags from the defaults, and the share-link fixtures drop
the retired FHIR scope, with a case pinning that the create route now refuses
those keys instead of ignoring them.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck 68b9903966 fix(export): make the doctor-report selection controls tell the truth
The export panel rendered twenty data switches and eight of them changed
nothing. The grouped selection folded down to twelve flat flags and dropped
oxygen saturation, body fat, body composition, resting heart rate, HRV, VO2
max, steps and distance on the way, so a user who switched any of them off
still exported the readings in the PDF, in the FHIR bundle, in the package,
through a clinician share link and over MCP. Each one now resolves to a flag
the aggregator reads, and the rows are excluded at the query rather than
fetched and dropped.

Three more controls were untruthful in their own way:

- The medications switch documented itself as excluding all medication data
  and excluded the adherence figures only. The drug list and the per-dose
  administration ledger rode along regardless. Both follow the switch now;
  the rows are still read, because compliance and the GLP-1 block are computed
  from them, and they no longer leave the aggregator.
- Allergies and family history were gated for the PDF and fetched
  unconditionally for the FHIR bundle, so a package could carry a compliant
  PDF and a violating bundle in the same zip. The reads are gated, and a
  deselected section is not read at all.
- The PHQ-9, GAD-7, WHO-5 and SCI screening totals had no per-report control.
  They rode the mental-health module alone, which has been default-on since
  v1.29.1, so depression and anxiety screeners reached FHIR bundles, share
  links and the MCP doctor-visit resource on accounts that never chose to send
  them. They get their own section key, defaulting off, with the module gate
  kept as the second lock, and the stale privacy-by-default comment that
  argued otherwise is corrected.

The share link's FHIR promise is removed rather than implemented. The create
form offered a toggle and a resource-type picker, the values were persisted
and badged on the finished link, and the API description called the toggle a
REST reachability switch, while no route has ever answered a share token with
FHIR or any other machine-readable format. Serving one is a feature with real
security surface and does not belong in a correctness fix. The toggle, the
picker, the badge and both claims are gone; the create schema rejects the two
keys instead of accepting and ignoring them; the columns stay, unread, so no
data is destroyed to undo a promise.

Two files are split along seams this change already touched. The
measurement-type gating maps and the filter that reads them leave the
aggregator for their own module, so the query gate and the output gate cannot
disagree; the export panel's control shape and the selection it builds leave
the component, so the selection contract can be extended without the
rendering. Three accepted-and-discarded wire keys (activity energy, GLP-1,
side effects) are dropped: nothing rendered them and nothing read them.
2026-07-26 00:45:08 +02:00
Marc-André Bombeck b32d1be607 Merge pull request #642 from MBombeck/fix/csv-import-silent-skip
v1.32.38 — accept a blood-glucose reading that carries no context
2026-07-25 23:16:22 +02:00
Marc-André Bombeck a23ee61184 test(e2e): route cleanup happens by construction, not per spec
A spec that arms `page.route(...)` in a `beforeEach` and never drops the
handler races its own teardown. The context closes, an async handler is
still awaiting, its next call rejects with "Test ended.", and Playwright
attributes that to the test that had just passed. With `retries: 2` plus
`failOnFlakyTests`, which spec bites is a matter of timing.

`e2e/setup/test.ts` re-exports Playwright's `test` extended with an auto
fixture that unroutes every page and the context after each test, and
clears the page event listeners a spec subscribes to; `expect` rides
along so a spec needs one import. Auto means a spec author cannot forget
it and does not have to know it exists. Every spec now takes `test` from
there, and `e2e-route-cleanup-guard.test.ts` keeps the import boundary
closed with an allowlist that carries its reason.

The per-file `afterEach` on the cycle spec goes away — leaving both would
read as not trusting the fixture.

Not covered: a page inside a context a spec builds itself. Only
`share-documents.spec.ts` does that, and it owns those contexts.
2026-07-25 23:02:03 +02:00
Marc-André Bombeck c506b72002 test(e2e): drop the cycle route handlers before teardown closes the page 2026-07-25 22:34:03 +02:00
Marc-André Bombeck ec49157e85 chore(release): v1.32.38 — accept a blood-glucose reading that carries no context 2026-07-25 22:18:03 +02:00
Marc-André Bombeck 208a2248cd test(import): cover the HealthKit glucose shape against the relaxed constraint
A HealthKit blood-glucose sample carries no context either — the mapping has
no field for one — so the batch ingest was building the same row the old
CHECK refused. Fold that source into the case that pins the contextless
shape.

Refs #640
2026-07-25 21:57:06 +02:00
Marc-André Bombeck e43e758c8d docs(import): put a contextless sensor reading in the downloadable example
The example CSV is the file people copy. It showed a glucose row with a
FASTING context and nothing else, which is the shape a meter produces, not
the shape a continuous sensor produces. It now carries both.

Refs #640
2026-07-25 21:56:10 +02:00
Marc-André Bombeck 4e95bd28f8 fix(import): stop the Apple Health card ticking an archive that landed nothing
Same shape as the CSV and JSON cards: reaching the done state rendered the
success tick whether or not a single row was upserted. The archive's count
picks the tone now, and an archive that wrote nothing says so.

The clinical-record count stays out of that decision. Skipping
electrocardiograms and lab documents is a documented, deliberate exclusion,
not a refused row, so it must not read as a warning.

Refs #640
2026-07-25 21:45:10 +02:00
Marc-André Bombeck 0c9832eb8d fix(import): accept a blood-glucose reading that carries no context
A CSV import of a continuous-sensor export wrote nothing and rendered a
green success state. Three faults compounded, and the reporter took all
three apart before filing.

The parser refused every BLOOD_GLUCOSE row with a blank glucoseContext.
Behind it, the CHECK constraint from migration 0021 read both ways at
once: a glucose row MUST carry a context, every other type MUST NOT. The
second half is right and stays. The first was written when the only
glucose writer was the entry form, where a person picks fasting or
post-meal at the moment of the reading. Every machine writer that
arrived since has no such answer per sample, so the Nightscout pull, the
MCP log tool, the Telegram capture and both bulk importers were all
building rows the database then refused.

A missing context now stores as NULL, never as a fifth enum member.
Every per-context reader selects by equality against the four members,
so the reading stays out of the doctor report's fasting rows, the
matching FHIR observations and the per-context targets, while the chart,
the all-time summary and the clinical panel keep it in full. Hand entry
still asks for a context, because at that moment you know it.

The import cards now read the counts rather than the status code.
Nothing written is a failure, a mixed file is a warning, and the refused
rows are grouped by reason with a count instead of fifty identical
lines, with the per-line detail one disclosure away. Both cards render
through one outcome table, so a tick cannot reach a result that wrote
nothing.

Refs #640
2026-07-25 21:23:26 +02:00
Marc-André Bombeck 019ad52c0d Merge pull request #641 from MBombeck/fix/reject-unstable-external-ids
v1.32.37 — refuse an identity that cannot be stable, and scope the intake replay key to its owner
2026-07-25 19:27:04 +02:00
Marc-André Bombeck 132238fd05 chore(release): v1.32.37 — refuse an identity that cannot be stable, and scope the intake replay key to its owner 2026-07-25 19:14:30 +02:00
Marc-André Bombeck 18691d4550 fix(medications): scope the intake idempotency key to its owner
`medication_intake_events.idempotency_key` carried a GLOBAL unique from
0001_init. The value is client-supplied, so two people running the same
client can mint the same key — and on a global unique the second one is
simply refused by the constraint. Their dose is not written and nothing
tells them so. The bulk drain made it worse: its replay probe read the
key with no user scope, resolved the first user's row, and reported the
second user's entry as a duplicate carrying a stranger's row id.

Migration 0273 replaces the global unique with `(user_id,
idempotency_key)`, the grain every other client-supplied identity on the
table already uses. Both unscoped reads in the bulk drain now key on the
composite; the other four reads were already narrowed by user.

On existing data this is a relaxation, so nothing needs reconciling: the
old constraint is strictly stronger than the new one, and no pair of rows
can violate an index the previous one already forbade. The migration
still carries a reconciliation step for a database whose global index was
dropped by hand at some point. It never deletes or rewrites a dose — it
clears the dedup HINT on the later row of a colliding pair and keeps
every intake event, timestamp, attribution and inventory stamp intact.
The new index is created before the old one is dropped, so a failure
aborts with the old protection still in place.

A structural guard freezes the read side: no `medicationIntakeEvent`
query may filter on the key without narrowing by `userId`.
2026-07-25 18:41:40 +02:00
Marc-André Bombeck 15b5f6f92f fix(ingest): hold the stability floor for identities no request schema sees
The dedup key on the medication intake surfaces is not only `externalId`.
`MedicationIntakeEvent.idempotency_key` is a `@unique` column persisted
with the dose row forever, with no TTL and no sweep, matched by plain
equality on the per-medication POST, the bulk drain and the external
ingest bridge. That makes it an identity, not a replay token, and it
carries the same failure mode: a key that rotates per client launch never
matches its own earlier post, so every retry logs the dose again. The
blank case is sharper still — the column is unique table-wide, so an
empty key resolves to whichever unrelated row stored it first. Refuse
both shapes at the schema on the single-entry paths and per entry on the
bulk drain.

The MCP write path hashes a host-supplied key into the `externalId` half
of its dedup key, so the shape is laundered before anything downstream
could see it and the check has to run on the input. That closes a live
one: every tool handler falls back to `""` when the host omits the
argument, and `""` digests to a constant, so each keyless write after the
first reported `already_logged` and was silently dropped.

The HTTP `Idempotency-Key` header stays out, and now says why in the
guard: `IdempotencyKey.expiresAt` bounds it and the cleanup job sweeps
it, so it is a windowed replay token and rotation is its design.

Extend the structural guard with a provenance rule for identities that
never pass a Zod request schema — a spreadsheet column, an MCP tool
argument. Inside the import and MCP trees, a module handling an identity
must apply the floor itself or import a module that does. That is a real
chain rather than a list of known offenders: a new importer that mints an
`externalId` from file input and imports nothing checked fails it, with
no allowlist to escape into.
2026-07-25 18:07:13 +02:00
Marc-André Bombeck 6e4bcffadf test(ingest): prove the external-id floor cannot be reintroduced
Harden the structural guard so it reads the file body with its imports
stripped: grepping the raw source cannot tell a file that APPLIES the
check from one that merely imports it, so deleting the call while
leaving the import slipped straight through.

Add the partial-success case on a second batch envelope — the mood bulk
ingest echoes the offending entry back with its index, status and
reason while the good rows still upsert.
2026-07-25 17:31:39 +02:00
Marc-André Bombeck 2ab66ab2e3 fix(ingest): refuse an external id that cannot be a stable identity
An external id is half of every ingest dedup key — (userId, source,
externalId), (userId, externalSource, externalId), (userId, type,
source, externalId). Re-posting the same triple is idempotent and
returns the existing row, which holds only while the id is stable
across client launches. A client that serialised an opaque platform
object with its default object description sent a per-allocation
memory address instead, so no sweep ever matched its own earlier row
and each one minted a fresh record: a live instance grew 23 phantom
medications in a day, none of which could collect a dose, because the
dose side resolved through the same rotating string.

Add one shared classifier for identifiers that provably cannot be an
identity — an angle-bracketed object description carrying a hex
pointer, a bare 0x address, and a blank value — and wire it at every
surface that accepts a client-supplied externalId.

Single-entry creates refuse through the schema, so the client gets the
standard 422 multi-issue envelope with a message naming the shape it
sent and what to send instead. Batch and bulk surfaces classify per
entry and report the offending row as skipped with reason
unstable_external_id, leaving the rest of the payload to land: one bad
row must not stop a sync. Every refusal carries pinned wide-event meta
(count, shape, surface) and never the id itself.

A structural guard freezes both routes into the floor: a request-side
externalId declaration either applies the field check or carries a
marker naming the file that checks it per entry, and the guard verifies
that file really does. The backup-restore payload is the one exemption,
with its reason recorded — it replays ids the server itself wrote.
2026-07-25 17:21:00 +02:00
Marc-André Bombeck ef9891fec5 Merge pull request #639 from MBombeck/release/v1.32.36
release/v1.32.36 — make the loose-ends failure class hard to reintroduce
2026-07-25 16:41:38 +02:00
Marc-André Bombeck 7c66413c1d chore(release): v1.32.36 — make the loose-ends failure class hard to reintroduce 2026-07-25 16:24:29 +02:00
Marc-André Bombeck c2d6adaff5 chore(audit): sweep for write-only columns from the release battery
Whether a column has a reader anywhere in the tree is a whole-program property,
and grep is the only cheap approximation. Grep is unsound both ways here:
generic names match unrelated identifiers and read as consumed, while
consumption through a select-less read plus whole-row serialization reads as
unconsumed. A gate built on that would drift towards green and stop meaning
anything, which is the failure this whole line of work exists to prevent. So it
stays a review tool with a human reading the output, run from the deep-audit
step and never in CI. The script says so itself and prints the false-positive
sources under every run.

It also carries the convention that makes the tool useful: a change that adds a
column, payload field, or preference contains its consumer, or the schema
comment carries `@internal` for something deliberately never surfaced or
`@pending-consumer(#issue)` for one still coming. The second marker is
re-listed on every run, so a follow-up that never happened surfaces at the next
release audit instead of never. Recorded in the contributor notes alongside the
two questions that are exact enough to be gates.
2026-07-25 16:24:24 +02:00
Marc-André Bombeck 99b8dfd566 test(guards): bind the account payload and the backup select to their pairs
Every finding behind this work is a two-ended change whose ends shipped in
different releases with nothing holding them together, and every gate the repo
runs proves one end: typecheck proves the export exists, knip proves it is
imported, the OpenAPI check proves the schema matches. None of them notice when
the second end never arrives.

Two questions here can be answered exactly, so they become tests.

The account endpoint rides every app boot and carries the user's preferences.
Its field names are read out of the response literal and each one must be read
by a client module — property access or destructuring, comments stripped,
because a doc comment claiming a consumer is what the remembered practice name
had for its whole dead life. That field would have failed this on the day it
shipped. One field is allowlisted today with its reason written out: the
insurer institution number has no web surface at all, which the entry keeps
visible rather than papering over.

The measurement backup select is now a named constant, compared field by field
against the model in the schema. Both sides are literals, so the comparison is
exact — no name heuristics, nothing that can drift towards accepting. A column
added to the model and forgotten by the backup fails at the commit that adds
it, which is how the aggregation provenance went missing. Scoped to the one
model; extend it as each further model becomes backup-relevant.
2026-07-25 16:24:12 +02:00
Marc-André Bombeck d8a3c08e12 fix(i18n): stop one key template from whitelisting a whole namespace
The reverse-coverage guard asserts that every key in the bundle is reachable
from a call site. It had stopped doing that for most of the bundle. A template
whose only static anchor was a namespace root registered that root as a subtree
base, so a single per-provider OAuth key marked every key under `settings.*`
reachable, and the same shape did it for `measurementReminders.*`. A cache-key
string that happens to start with a namespace word had quietly done it for
`insights.*` too. The guard stayed green throughout, which is the part worth
noticing: a pair guard degrades into a one-end guard without any signal.

Extract the matcher into a helper with its own unit tests, and change what it
accepts. A template carrying both a literal prefix and a literal suffix now
records the pair and requires both ends to match, so the OAuth template covers
the provider keys and nothing else. A template with no suffix whose anchor is a
bare namespace no longer whitelists anything — it fails the guard by name, with
the two honest remedies in the message. The same door is used by the template,
assignment and concatenation arms, so none of them can reopen it. Template
bodies are read by scanning forward from a known opening backtick rather than
by pairing backticks across the tree, which was drifting and dropping whole
files out of the scan.

Three call sites build their keys whole instead of interpolating onto a
namespace root: the two measurement-reminder due helpers, the mental-health
option labels, and the OAuth error copy, which now names its per-provider base.
Two audit-log action names that merely look like keys carry a written
exemption; the API error catalog is a runtime lookup and is allowlisted with
its reason.

With the matcher honest, 149 keys had no call site left. They belong to an
explainer popover, a hypnogram, a passkey table and an insights hero that were
all rebuilt or removed, and they are deleted from all six locales. The
explainer guard stops pinning its title leaves, which no surface has rendered
since the popover went.
2026-07-25 16:23:58 +02:00
Marc-André Bombeck f9a5e6a11f Merge pull request #638 from MBombeck/release/v1.32.35
release/v1.32.35 — remember the practice name, keep the source provenance in a backup
2026-07-25 15:44:30 +02:00
Marc-André Bombeck fe2e20a14b chore(release): v1.32.35 — remember the practice name, keep the source provenance in a backup 2026-07-25 15:28:39 +02:00
Marc-André Bombeck 2a396dd23e fix(backup): carry the aggregation provenance through backup and restore
The disaster-recovery backup selected every Measurement scalar except the
aggregation fields, so a restore brought every `stats:` aggregate back with no
provenance and the Apple Health authority ladder came back flat: an export.xml
source-day estimate could then overwrite a native HealthKit statistic.

`aggregationProvenance` now rides the select, the disaster-recovery
serialization, the payload schema and the restore mapping, following
`externalSourceVersion` exactly — including staying out of the reduced
portable shape, which carries the human-readable subset only. The schema field
is optional, so every backup written before this parses unchanged.

Restoring one of those older backups no longer recreates the ambiguity
migration 0263 repaired: an Apple Health row under a `stats:` identity with no
provenance comes back as LEGACY_UNKNOWN, the sentinel the cumulative drain
already writes in that situation, so a native sync or archive reprocess can
repair it. The gate is scoped as 0263 scoped its backfill — other providers
mint `stats:` identities for their cumulative dailies and have never carried
provenance, and ordinary point rows keep NULL.
2026-07-25 15:20:47 +02:00
Marc-André Bombeck f8f460bb66 refactor(measurements): drop the write-only aggregation columns (migration 0272)
Migration 0263 added three columns beside the Apple Health `stats:*`
authority ladder. Only `aggregation_provenance` is ever read back: the
external-measurement reconciler selects it and refuses a lower-authority write
against a higher-authority canonical row, and the cumulative drain reads it
too. That column stays.

`aggregation_contributor_count` and `aggregation_selected_source_hash` are
written by three call sites and read by none. No select, no raw SQL, no export,
no backup, no API contract, no repair path. The import-time selection keeps its
deterministic tiebreak, which compares an in-memory map key computed per flush
rather than a value read back from the table.

Both are plain nullable scalars with no index, unique, check or foreign key, so
the drop is a catalog-only operation that touches no measured value.
2026-07-25 15:03:44 +02:00
Marc-André Bombeck a3f1d780e7 feat(export): remember the practice name between doctor reports
The doctor-report contract has always described the practice / clinic name as
a stored preference, and `/api/auth/me` has returned
`User.lastReportPracticeName` since it shipped, but nothing ever wrote the
column. Every report asked for the clinic name again.

The export route now persists the sanitised name after the requested artefact
has actually been produced, so a generation that throws cannot overwrite the
remembered value with a half-typed one. Clearing the field persists null, which
keeps the cover line cleared next time. The export panel seeds its input from
the auth payload during render, gated per user id, so a late resolve never
clobbers a field the user is already typing into, and a finished export
invalidates the cached payload so a remount reads the new value.
2026-07-25 15:00:27 +02:00
Marc-André Bombeck ddc18bbd11 Merge pull request #637 from MBombeck/release/v1.32.34
release/v1.32.34 — one shell and one rhythm for the insights overview
2026-07-25 14:40:16 +02:00
Marc-André Bombeck c623745942 chore(release): v1.32.34 — one shell and one rhythm for the insights overview
Interface housekeeping. The overview sections drop their hand-rolled card
frames for a shared shell with one inner rhythm, failed reads on dense metric
pages get a named row primitive that puts the message in the content tier, and
the last off-scale spacing and text sizes go back onto the scale.

The tag chip and the sub-twelve-pixel text step are primitives now, so neither
can be hand-copied at a slightly different value again.
2026-07-25 14:21:30 +02:00
Marc-André Bombeck 338808eb5f refactor(ui): route the sleep-composition header through the tile header
The stage-composition chart card was the last insights tile with a
hand-rolled title row. It now uses the canonical header primitive; the
subtitle and the window toggles keep the shape they had, since the toggles
have to drop to their own row on a phone.

Also sweeps the last off-scale spacing: the two dense tiles inside a
recommendation move onto the sanctioned inner-tile step, and the not-found
button onto the scale. The banned twenty-pixel step is now absent from the
tree. The chart body is untouched.
2026-07-25 14:12:01 +02:00
Marc-André Bombeck 2decc37f07 refactor(insights): one shell and one rhythm for the overview sections
Seven overview sections had hand-rolled the same card literal, and the copies
had already drifted on the one thing the literal did not pin: the space
between blocks. Three used 12 px, two 8 px, one 16 px, one under the other in
the same column.

`InsightSectionCard` composes `Card`, so the frame comes from the card
contract, and fixes the inner rhythm at 12 px. The daily briefing moves onto
it as well, and the lab-changes list keeps its edge-to-edge rows through the
documented flush variant.

Visible change on the overview column: two bodies open by 4 px, one tightens
by 4 px, and all of them pick up the shadow their card neighbours already
had. Chart bodies are untouched.
2026-07-25 14:09:22 +02:00
Marc-André Bombeck b4f4cb8b55 refactor(ui): promote the compact read-failure row to a primitive
The HealthKit metric sub-pages and the vitals dashboard had each hand-rolled
the same failed-read row, byte for byte. The tall centred error card is the
right shape for a list surface but dominates a dense metric strip, so the row
stays — as a named primitive rather than a third copy.

`QueryErrorRow` carries the shipped shape with one correction: the failure
message renders in the foreground tier. An alert is the content of a failed
surface, not meta about it, and both copies had it muted. The retry glyph
matches its tall sibling. Both converted surfaces pass their existing slot
names through, so their hooks are unchanged.
2026-07-25 14:02:59 +02:00
Marc-André Bombeck 933dfcfdfb refactor(ui): mint the text-2xs token and the neutral TagChip
Three integration cards carried the same hand-copied chip literal in the
card-header accessory slot, each free to drift on the next touch; the size
itself was an arbitrary value repeated at three slightly different spellings
across the tree.

- Mint `--text-2xs` (0.6875rem with a matched line-height) as the one
  sanctioned step below `text-xs`.
- Mint `TagChip` and adopt it on the Fitbit, Google Health, and Nightscout
  cards. It stays distinct from `Badge`, which carries status meaning.
- Sweep the arbitrary micro sizes: the stepper label and the two dose-ledger
  outline badges move onto the token; the muted target caption and the muted
  ledger meta row move up to the `text-xs` muted floor.

No visible change beyond the two muted rows gaining a fraction of a pixel.
2026-07-25 14:00:25 +02:00
Marc-André Bombeck 405bebf1a0 test(e2e): stop the medications refetch spec racing its own listener
Arm the response waiter before navigating, so a response that lands during
the initial load is still observed instead of timing out on an event that
already happened, and poll for the returning refetch rather than sleeping a
fixed span that a loaded machine can outrun.
2026-07-25 12:13:47 +02:00
Marc-André Bombeck d9b8a69b7f Merge pull request #635 from MBombeck/release/v1.32.33
release/v1.32.33 — remove the moodLog integration
2026-07-25 12:13:00 +02:00
Marc-André Bombeck a006f76320 chore(release): v1.32.33 — remove the moodLog integration
Version bump, regenerated OpenAPI spec (version field only; the moodLog routes
were never registered on the public contract), and the release notes.
2026-07-25 11:36:33 +02:00
Marc-André Bombeck d0b45e36b3 feat(integrations): remove the moodLog integration
Mood has been a first-class module here for a long time, with entries,
structured tags, rated factors and notes. The bridge to the external moodLog
service had nothing left to add, and its Settings card was withdrawn in
a56e6fe61 on the promise of a dedicated removal. This delivers it.

Removed: the hourly `moodlog-sync` queue, cron and handler binding; the
inbound webhook and its shared-secret handling; the reverse push and its four
call sites; the pull-sync module; the per-user credential routes and the
instance-wide toggle; the entry on the `/api/integrations/status` envelope,
the `IntegrationKey` union, the cadence map and the display-name table; the
boot-time secret rotation pass; 22 message keys per locale.

Migration 0271 drops the five `users` credential columns and
`app_settings.mood_log_global`, clears the ledger row for the provider, and
deletes the retired pg-boss schedule so no cron keeps enqueuing work no
handler drains.

Mood data is untouched. `mood_entries` is not named anywhere in the migration
and cannot be reached from it: the dropped columns carry no index, constraint
or foreign key, and `mood_entries.user_id` references `users(id)`, not any of
them. Rows the bridge imported keep `source = 'MOODLOG'`, and every read path
- list filter, source badge, exports, backups - still resolves that value.

`src/lib/validations/moodlog.ts` held the native mood-entry schemas alongside
the bridge's own; it is now `validations/mood.ts` and carries only the former.
2026-07-25 11:36:18 +02:00
Marc-André Bombeck 4ed8c8f39b Merge pull request #634 from MBombeck/release/v1.32.31
release/v1.32.31 — the integrations page says what is actually happening
2026-07-25 10:14:50 +02:00
Marc-André Bombeck e657bf928e chore(release): v1.32.31 — the integrations page says what is actually happening 2026-07-25 09:41:58 +02:00
Marc-André Bombeck 10593a11a8 test(integrations): cover the verdict ladder and the coverage guarantee
The load-bearing one is the panel-coverage test: render the panel with an
envelope carrying every integration key and require each configured one to
surface as a card or as a fallback row. Drop the fallback row and it goes red,
which is the whole point — the previous shape of this bug was a provider on
the wire that no screen rendered, and no card-shaped test could have caught it.

Alongside it: the ladder table with the month-silent fixture that resolves to
stalled, the 24-hour and 7-day boundaries asserted against literals rather than
the constants they check, the per-metric stale flag firing only under a healthy
provider, and a structural guard that the verdict-to-pill mapping is defined in
exactly one module.

The card suites migrate with the semantics: a transient failure now expects the
warning pill everywhere, and the Apple Health suite trades its bespoke badge
states for pill assertions. The e2e mocks gain the verdict shape, the
nightscout entry, and an assertion that the card-less row is on the page.
2026-07-25 09:41:53 +02:00
Marc-André Bombeck ce74fd52a2 feat(settings): render the sync verdict and per-metric freshness
Three `pillStateFor` implementations disagreed with each other, so the same
ledger state painted red on one card and amber on the next, on one page. All
three are gone; the client projects the server verdict once.

Red now means "your action fixes this" and nothing else. A transient upstream
failure carries the warning tone: telling a user to reconnect against a 503
sends them clicking at something they cannot repair, and the detail belongs in
the inline error line anyway. Three new pill states carry the rest — no recent
data, sync stopped, waiting for first data.

Per-metric freshness has been computed on every status call since v1.27.30 and
rendered nowhere. Every connected card now opens a disclosure listing each data
type the connection has delivered and how long ago, with a quiet one flagged.
Collapsed by default so a 25-type card keeps its shape, but the collapsed
summary already names the count, or the signal hides behind a click. A type a
provider never delivered simply is not listed.

The panel iterates the envelope and renders a plain status row for every
configured entry no card claims. That is the structural half: a connection can
no longer be running, failing and absent from the page at once.

Apple Health drops its bespoke badge for the shared pill, gains the divider and
body inset every sibling carries, and says something when a week has passed
with nothing arriving. Nightscout reads the envelope instead of its own status
round-trip. The OAuth card uses the shared error primitive, all nine cards
carry the divider testid, and the four original connectors gain the "view your
data" link the newer ones already had.
2026-07-25 09:41:37 +02:00
Marc-André Bombeck de47bfa5ba feat(integrations): resolve sync health on the server
The ledger records what the last attempt did. It has no way to say what is
no longer being attempted: a row frozen at `error_transient` for a month
reads exactly like one retrying every hour, because nothing anywhere read
the age of `lastAttemptAt`.

`sync-verdict.ts` derives the missing reading as a pure read-side
projection: eight verdicts down one ladder, no writer, no cron, nothing that
can desynchronise from the row it reads. `stalled` is the new one, and it
deliberately outranks `failing` — an integration nobody is calling is not
failing, it is not running.

Every entry on `/api/integrations/status` carries the verdict, computed
inline from data the route already holds. That placement is the point: a
card-level verdict misses a card-less provider by construction, which is how
a dead sync stayed invisible. Nightscout joins the envelope so its card can
stop fetching its own status.

The freshness module gains the workout leg (Strava writes no measurements at
all) and a source-parameterised core so the Apple Health route can reuse it.
Its per-metric `stale` flag fires only while the provider itself reads
healthy: that combination is the dead-pipe signature, and when the whole
connection has stopped every metric ages together anyway.

MCP reports the verdict beside `connected`, which only ever meant "not
explicitly disconnected".
2026-07-25 09:41:20 +02:00
Marc-André Bombeck a1a978f5e6 Merge pull request #633 from MBombeck/release/v1.32.30
release/v1.32.30 — height is entered and shown in the unit you use
2026-07-25 08:38:00 +02:00
Marc-André Bombeck 41c2213fe6 chore(release): v1.32.30 — height is entered and shown in the unit you use 2026-07-25 08:21:57 +02:00
Marc-André Bombeck 7a0e2cb5b2 test(profile): cover the height round-trip, the converted bounds, and both entry surfaces
Sweep every feet + inches pair the fields can offer and every whole-inch
height across the canonical range, asserting the value comes back
unchanged and lands inside the server's window. Assert the metric branch
with toBe so a re-derivation of a stored height cannot slip through.

Render both entry surfaces under each preference: the metric case keeps
the single centimetres box with the canonical guardrails, the imperial
case shows the feet + inches pair seeded from the stored value. Extend
the structural guard so a surface that drops the adapter, hardcodes a
centimetre guardrail, or hardcodes a unit label trips a tripwire.
2026-07-25 08:21:51 +02:00
Marc-André Bombeck 1b41126fb3 feat(profile): enter and show height in the selected unit
Height is a profile column, not a MeasurementType, so the display
transform that carries the metric/imperial preference across every
reading surface never reached it. Add the adapter that closes the gap.

With imperial selected both entry surfaces render a feet + inches pair
instead of a centimetres box; with metric selected the field is what it
was. The adapter converts to canonical centimetres at the entry
boundary, so storage, the wire, the Zod bound, and every consumer of
the value stay unchanged.

Imperial entry quantises to whole inches, which map to centimetres
without a remainder, so save-then-reopen returns the typed pair. The
offered feet window rounds inward against the canonical 50-300 window
so a height the fields allow is never refused on save.

The unit select shares the Settings card with the height field, so a
flip re-expresses the in-flight draft through the new branch rather
than stranding an empty field.
2026-07-25 08:21:42 +02:00
Marc-André Bombeck d0d880d4ba docs(changelog): credit the reporter behind the unit-preference fix by name 2026-07-25 08:03:28 +02:00
Marc-André Bombeck 5b32f6ce4c Merge pull request #632 from MBombeck/release/v1.32.29
release/v1.32.29 — a re-scored Fitbit night stops double counting
2026-07-25 06:32:45 +02:00
Marc-André Bombeck fb74b686e3 chore(release): v1.32.29 — a re-scored Fitbit night stops double counting
Stable per-segment sleep keys plus the replace-by-window sweep, a one-shot
repair over the past year, Retry-After handling, and the unchanged-value write
skip. Migration 0270 records which connections the repair has been through.
2026-07-25 06:15:37 +02:00
Marc-André Bombeck a08c759763 test(fitbit): cover the stable sleep key, the sweep, and the one-shot repair
Mirrors the Google sleep suites name for name: the mapper keeps its externalIds
across a re-score that re-classifies and renumbers segments, the sweep stays
bounded to the night's window and never touches the fresh keep-set, a null
window issues no delete at all, and a cleanup failure never fails the sync.

The end-to-end fixture runs one night stored under the old keys through
`syncUserSleep` and pins the ordering the heal depends on — sweep first, then
the rescue re-keying the tombstoned rows in place, no parallel insert — plus a
second pass that writes nothing at all.

Also covers the repair's verdict gates (bounded 365-day window, marker stamped
only on a clean run, watermark untouched, dead token and reauth park returning
without a stamp), the unchanged-value skip including the re-classified segment
it must not swallow, and both Retry-After paths.
2026-07-25 06:15:37 +02:00
Marc-André Bombeck ce41d5bf17 fix(fitbit): key sleep segments on their start instant and sweep re-score leftovers
Fitbit re-scores a night after the fact, and the per-segment externalId carried
a positional index plus the stage label. Both moved on a re-score: renumbered
segments minted fresh keys, the previous scoring's rows stayed live because
nothing swept them, and the night total counted both copies from then on.

Key the segment on the stable session anchor plus its own start instant
(`<logId>:sleep:<segment-start>`), and add the replace-by-window sweep every
other segment-writing provider already has. The two ship together with the
natural-key rescue that has been sitting in the Fitbit upsert unused: the sweep
tombstones the old-keyed rows first, so the rescue re-keys those very rows in
place instead of inserting a parallel copy or losing the insert to
`skipDuplicates` against the natural unique index.

A one-shot repair queue re-reads the bounded sleep history once per connection
so no user has to do anything; it stamps `sleepRepairedAt` (migration 0270) only
on a clean run, never touches the sync watermark, and reports the rows it
removed. Nights older than the 365-day backfill horizon keep their old keys and
whatever duplicates they already hold — they can no longer gain new ones.

Alongside: `fitbitGet` now honours a `Retry-After` inside a 120 s cap by waiting
once and retrying once, and fails fast above it so a long hold cannot starve the
rest of the poll cohort or outlive a job's expiration. The measurement upsert
skips a write whose payload is unchanged, which takes the hourly overlap off the
delta feed for every Fitbit resource.
2026-07-25 06:15:37 +02:00
Marc-André Bombeck a11717e65a Merge pull request #631 from MBombeck/release/v1.32.28
release/v1.32.28 — Oura catches up after a gap, and the last four providers get manual sync
2026-07-25 05:03:46 +02:00
Marc-André Bombeck 40a746b2b7 chore(release): v1.32.28 — Oura catches up after a gap; manual sync for the last four providers 2026-07-25 04:43:18 +02:00
Marc-André Bombeck 0bcbb88db1 feat(settings): put a sync-now button on the four cards that lacked one
A route with no caller is an API detail, not a feature. The Oura, Polar,
Strava and Nightscout cards get the same action WHOOP already had, in the
same row as Test connection, rendered only once connected: post the sync,
show the count or the failure, invalidate the reads that depend on
measurements. No full-history dialog, since these providers have no such
arm. Keys added across the six locales.
2026-07-25 04:43:12 +02:00
Marc-André Bombeck a9f1629f82 feat(integrations): add manual sync for Oura, Polar, Strava and Nightscout
These four were the only connected providers with no way to ask for a
sync; they ran on their scheduled pass and nothing else. Each gets a POST
sync route on the hardened Fitbit and Google Health template: session auth,
a 5-per-minute per-user bucket, and a fixed 502 on failure. No body and no
full-sync flag, because none of the four entry points has a full-history
arm to call. Polar goes through the shared legs entry point so both legs
run with the attribution rules built for them.

The Nightscout error arm is the one that matters: a base URL carries the
API token as a query parameter, so the caught message can hold the user's
secret and never reaches the response body. The classified failure is
already on the ledger, recorded with the provider's own redaction.

Connecting also starts the first sync now. The poll handler has accepted a
single-user payload since it was written and never had a producer, so a
fresh connection sat empty for up to an hour waiting for the schedule. The
Oura and Polar callbacks and the Nightscout connect route enqueue one
targeted job each, best effort, with the hourly tick still the safety net.
Strava needs none: its callback already enqueues the history backfill,
which covers the recent window.

The four poll-queue names move into their own module so a request-path
caller can name a queue without dragging the whole worker handler graph
into the request bundle, following the backfill-queue precedent. Creation,
scheduling and handler binding stay in the registrar, and the dead-queue
guard follows the declarations.
2026-07-25 04:43:06 +02:00
Marc-André Bombeck 9bbbeb805c fix(oura): derive the sync window from the newest imported reading
The lookback was pinned at seven days, so a sync that stayed broken for
longer than a week came back with a window too narrow to reach the gap.
The quieter case did the same damage with every signal green: a ring left
unsynced holds its nights in Oura's cloud until the app is opened, every
hourly tick succeeds with empty arrays, and when the days finally arrive
they sit outside the window.

Anchor the window on the newest live Oura measurement instead, clamped
between the existing seven-day floor and a new thirty-day ceiling, plus a
day of overlap. Both failure modes leave the same fingerprint, so one
anchor closes both, and the watermark cannot drift out of step with the
data because it is the data. Tombstones are excluded: the sleep sweep
soft-deletes rows a revised hypnogram orphaned, and one of those must not
pass for a live reading.

The ceiling is thirty rather than the ninety days Oura's guidance would
allow, because the whole batch still rides one transaction. With data
flowing normally the clamp floors at seven and the tick is unchanged.
A connection with no rows at all takes the full month.
2026-07-25 04:42:52 +02:00
Marc-André Bombeck 8af5c6a0eb Merge pull request #630 from MBombeck/release/v1.32.27
release/v1.32.27 — target and threshold panels follow the unit preference
2026-07-25 03:43:29 +02:00
Marc-André Bombeck 7a4e819679 chore(release): v1.32.27 — target and threshold panels follow the unit preference 2026-07-25 03:24:24 +02:00
Marc-André Bombeck c86de7d317 test(targets): pin the target unit round-trip and extend the display guard
Cover the adapter end to end: an imperial seed, a guardrail projected
into the display unit, a save inverted back to canonical, and a reopen
that shows the number the user typed, swept across the whole editable
window at the input's own step. Temperature carries the affine case,
where a difference must take the factor without the offset.

Assert the metric path is the exact identity rather than merely close,
so a stored value cannot drift by a rounding pass.

Extend the structural guard to sweep the three target and threshold
surfaces, and add two tripwires: each must call the preference hook and
convert through the shared adapter. Reintroducing a hardcoded unit or
swapping the hook for a literal now fails the guard.
2026-07-25 03:24:19 +02:00
Marc-André Bombeck bf73755899 fix(targets): follow the unit preference in target and threshold panels
Target ranges stayed canonical while every reading surface moved to the
metric/imperial preference in the previous release, so an imperial
account saw a weight chart in pounds above a target reference in
kilograms. The panel is a coupled display and edit unit: converting only
its display would have corrupted the edit seed and the save round-trip,
which is why it was held back.

Add one adapter that owns every direction of the conversion and route
all three surfaces through it. The reference panel converts the
canonical payload once and hands the converted pair to the range bar,
the status pill, and the editor seed. The editor seeds from the stored
value converted to the display unit, validates against guardrails
projected into that same unit, and inverts the typed number back to
canonical before the PUT.

Guardrails round inward, so the 30 kg floor projects to 66.2 lb rather
than 66.1 lb, which would have inverted below the floor the server
enforces. Absolute values carry the affine offset, differences take the
factor alone. Input step comes from the transform's own precision.

Storage is untouched: thresholds stay canonical SI, and for a metric
account the adapter is the exact identity in both directions, so a
stored value is unchanged bit for bit.
2026-07-25 03:24:10 +02:00
Marc-André Bombeck cfd5cf0e0c Merge pull request #629 from MBombeck/release/v1.32.26
release/v1.32.26 — apply the metric/imperial preference across every reading surface
2026-07-25 00:48:54 +02:00
Marc-André Bombeck 2e5841c337 docs(changelog): date v1.32.26 and note the target-panel unit gap 2026-07-25 00:37:28 +02:00
Marc-André Bombeck a9b2ab2d2b chore(release): v1.32.26 — apply the metric/imperial preference across every reading surface
Surface a self-hoster's report that the unit preference was stored but read by
almost no display. Weight, body composition, waist, and temperature now follow
metric or imperial at entry and everywhere they render, storage stays canonical
SI, and the MCP logging tool converts a known unit hint or refuses an unknown
one. OpenAPI diff is version-only; the wire stays canonical.
2026-07-25 00:35:07 +02:00
Marc-André Bombeck 6cf56ce456 test: mock the unit-display hook in the crosstab, vitals, and MCP suites
The crosstab, vitals, and cycle-summary renders are static server renders with
no QueryClient; the new preferred-unit hook reads through the auth query, so
mock it to a real metric-preference implementation (identity values, canonical
symbols) that matches the render those suites assert. Register the new unit
resolver on the MCP write-tools module mock so the preview path resolves.
2026-07-25 00:34:51 +02:00
Marc-André Bombeck ddc598afcb test(measurements): cover the unit transform, MCP unit hardening, and a display guard
Add affine-correctness tests for the transform registry (°C→°F absolute vs a
delta that must stay offset-free, lb/in factors, inverse round-trips), a
resolver test for the inbound unit aliases, a preferred-unit entry-form label
test, and MCP cases for converting a lb hint to canonical kg and refusing an
unknown unit. Add the structural guard that trips if any dashboard or insight
surface reintroduces a hardcoded transformed-type unit or value scale. Ship the
diagnose/convert script pair for legacy non-canonical measurement rows.
2026-07-25 00:21:36 +02:00
Marc-André Bombeck 13b88e47f5 fix(insights): convert weight and temperature in the mood/cycle crosstabs and the plateau note
Route the weight and temperature displays in the mood-factor and cycle-phase
crosstabs and the GLP-1 plateau note through the unit transform: absolute phase
averages take the affine conversion, deltas stay factor-only, and the unit
symbol follows the preference. Thread a unit token into the plateau evidence
copy across the six locales so the sentence reads in the user's unit.
2026-07-25 00:16:41 +02:00
Marc-André Bombeck e1ca523e21 fix(insights): route every weight-class and temperature surface through the unit transform
Wire the display transform into the chart scaffold, the chart itself (with an
affine offset for the temperature conversion), the dashboard trend tiles and
weight chart, the vitals grid, the coach read strip, and the device-score
tile. Each surface resolves its unit and scale from the user's preference at a
single choke point and stops hardcoding the canonical unit, so an imperial user
sees a consistent value, axis, band, tooltip, and range hint while a metric
user's render stays byte-identical. Retire the local celsius display shim now
that the transform owns the symbol.
2026-07-25 00:10:53 +02:00
Marc-André Bombeck dd7213db5c fix(measurements): honor the unit preference at entry and in the record list
Add the affine display-transform registry (kg↔lb, cm↔in, °C↔°F with the
temperature offset carried as an absolute-only shift), a single client hook
that reads the preference, and a shared inbound unit-alias resolver. Wire the
manual entry form to label, prefill, and convert at the boundary so an imperial
reading persists in canonical SI, and route the record list value cells and
edit dialog through the same choke point. Close the one write path that could
persist a non-canonical unit: the MCP log_measurement tool now resolves a unit
hint to canonical or refuses it, reusing the resolver the CSV importer already
relied on.
2026-07-24 23:58:45 +02:00
Marc-André Bombeck 14dcab786e Merge pull request #628 from MBombeck/release/v1.32.25
release/v1.32.25 — cap external medication-mirror growth and surface provenance
2026-07-24 23:32:51 +02:00
Marc-André Bombeck bb79d28911 chore(release): v1.32.25 — surface mirrored-medication provenance and cap external mirror growth 2026-07-24 23:18:26 +02:00
Marc-André Bombeck 40aae4b66d chore(scripts): apply prettier to the phantom-med cleanup script
Formatting only; the format:check gate flagged the file. No behaviour change.
2026-07-24 23:18:26 +02:00
Marc-André Bombeck 1007ca3d1c test(medications): cover the mirror growth guard and provenance echo
A 31st new mirror create at the cap returns 422 with the limit_exceeded
errorCode; an idempotent re-post of an existing mirror at the cap still returns
200; a native create at the cap is unaffected and never runs the count. The
list read exposes externalSource as APPLE_HEALTH for a mirror and null for a
native row.
2026-07-24 23:18:26 +02:00
Marc-André Bombeck dbe3c3dc26 fix(medications): cap externally mirrored rows and surface their provenance
A client that mints a fresh externalId for the same source concept on every
sync sweep never matches the (userId, externalSource, externalId) idempotency
triple, so each sweep would otherwise create a new mirror row without bound.
Add a growth guard on POST /api/medications: once an account already holds 30
medications mirrored from an external source, a genuinely new mirror create is
refused with 422 and the errorCode medications.mirror.limit_exceeded. The guard
sits after the idempotency lookup, so an idempotent re-post of a medication the
account already mirrors still returns 200, and native creates never run the
count.

Echo externalSource on the medications list read so the web UI and an operator
can tell a mirrored row from a native one, and document both in the OpenAPI
contract.
2026-07-24 23:18:14 +02:00
Marc-André Bombeck 6c8e921faa chore(scripts): add read-only diagnose + guarded cleanup for phantom Apple-Health mirror meds
The diagnose script reports the provenance, external-id values, and intake/schedule
counts of medications by name. The cleanup script deletes only name-matched
Apple-Health mirror rows with zero intakes and zero schedules, dry-run by default,
replicating the delete route (category cleanup, token revoke, audit, cache bust).
2026-07-24 23:02:03 +02:00
Marc-André Bombeck 816f517d1b Merge pull request #626 from MBombeck/release/v1.32.24
release/v1.32.24 — isolate Polar collections and log envelope drift
2026-07-24 22:10:37 +02:00
Marc-André Bombeck 4ac39a7052 chore(release): v1.32.24 — isolate Polar collections and log envelope drift
Per-collection isolation for the Polar vitals sync: one unavailable data type
no longer blanks the rest, a failing collection is named on the integration
status, and an unexpected response shape is logged instead of reading as no
data. Bump version, regenerate the OpenAPI version string, add the changelog
entry.
2026-07-24 21:26:35 +02:00
Marc-André Bombeck 1d31c51d01 fix(polar): log envelope-shape drift instead of silently reading it as no data
The collection fetchers degrade an unexpected 200 body to an empty list, so a
Polar-side envelope rename would read as "the user has no data" and stamp a
clean sync while importing nothing. Emit a fixed-shape polar.envelope.drift
breadcrumb (verb, HTTP status, top-level key names only, never values) at each
silent-empty site in the collection fetcher and the exercises fetcher, keeping
the log-and-degrade behavior so a live account stays alive while the gap becomes
visible in the event stream.

Truth up the client docstrings against the verified AccessLink contract: each
vitals list is a bare array or a named wrapper over a server-fixed 28-day window
with no pagination, and exercises is a bare array over the last 30 days of
uploads. Every hourly tick re-reads the full window, so a late-synced device or
a short outage self-heals with no cursor or watermark; history beyond the window
is an upstream cap. No request parameters added.
2026-07-24 21:19:58 +02:00
Marc-André Bombeck 4574155ce6 fix(polar): isolate each vitals collection so one dead read no longer blanks the rest
Restructure the Polar vitals sync from a single Promise.all over the five
collections into a Promise.allSettled over a per-collection spec, each closure
fetching and mapping so a throwing mapper is contained exactly like a failing
fetch. The healthy collections import; a failing one is recorded once as a
partial failure that names it, without stamping success and without rethrowing,
so a single unavailable data type — SpO2 on a watch without the sensor, a
higher-tier cardio-load read an account lacks — stops blanking recharge, sleep,
and activity on every tick.

A 401 is now a per-collection failure classified reauth_required, not a
whole-leg throw; the write-path escape still surfaces unmarked for the poll
boundary to record once. Mirrors the shipped Oura isolation, minus the
token-refresh leg Polar has no path for.
2026-07-24 21:19:46 +02:00
Marc-André Bombeck 60ec39c2be Merge pull request #625 from MBombeck/release/v1.32.23
release/v1.32.23 — honest sync status across the non-sensitive providers
2026-07-24 20:35:23 +02:00
Marc-André Bombeck 43880e8cca docs(changelog): drop an em dash from the v1.32.23 note 2026-07-24 20:08:11 +02:00
Marc-André Bombeck bb631de7c8 chore(release): v1.32.23 — honest sync status across the non-sensitive providers
A failed sync no longer reads as success across WHOOP, Withings, Oura, Polar,
Nightscout, and Strava: a dead WHOOP token stays parked instead of flipping back
to connected, an interrupted history backfill is retried rather than marked
complete, Withings ECG syncs on its own hourly schedule, activity and sleep
write failures surface on the status card, poll-cohort sync errors that
previously vanished are recorded, and Strava history import starts at connect
time.
2026-07-24 20:06:06 +02:00
Marc-André Bombeck e47200505e fix(withings): sync ECG on a schedule and surface write failures
Add an hourly ECG catch-net cron so a watch-only account with no scale pulls its
recordings without a webhook: the queue was registered and bound but never
scheduled, and the handler now grows a cohort arm that walks every connection on
a payload-less tick, mirroring the activity and sleep handlers.

Make the activity and sleep write paths honest. A swallowed activity batch-write
fell through to the success stamp — a green pill over lost data, green forever on
a persistent write bug; it now records a transient failure and refuses the
stamp. A reconcile throw out of either sleep transaction propagated past the
stamp and was swallowed by both callers, leaving neither success nor failure
recorded; the source now records a transient failure and rethrows so the cron
and webhook paths stay honest without changing their warn-and-continue
behaviour.
2026-07-24 20:05:57 +02:00
Marc-André Bombeck 9a69e4e557 fix(whoop): a dead token no longer reports a successful sync
Port the hard-fail ledger the Fitbit transport already carries. A revoked WHOOP
grant makes every resource short-circuit and return zero rows without throwing,
so the cycle read as clean: it stamped success, un-parked the reconnect state,
reset the failure streak, and the hourly cohort hammered the dead refresh token
on every tick. Record the three dead-token null-return paths on the ledger and
fold that verdict into both drivers — the full orchestrator and the per-resource
cron path that runs most often — so a dead-token cycle refuses the success
stamp and the park holds.

Return an imported-plus-failed verdict from the orchestrator and gate the
history backfill on it: an interrupted full-history walk now throws for a
pg-boss retry instead of being stamped complete, and a connection parked at
reauth returns without burning the retry budget. Thread the same verdict into
the one-shot sleep-timeline backfill.

Also catch a write-path throw on both drivers so a persistent measurement-write
failure surfaces on the status ledger instead of leaving a stale-green pill, and
widen the cycle overlap to the seven-day re-score window the other collections
use so a late-revised day's strain and energy are re-fetched rather than
stepped past.
2026-07-24 20:05:41 +02:00
Marc-André Bombeck 8be7458c7a fix(sync): surface poll-cohort sync failures that previously vanished
Introduce a recorded-marker primitive on the integration ledger so a provider
that records-then-rethrows a sync failure is not recorded a second time at the
poll-cohort boundary, while an unrecorded escape (an Oura or Polar write-path
throw, or any future path) lands on the ledger exactly once, correctly
classified and redacted by the source that owns it. Without the marker a
boundary-blind recorder would double-count the failure streak and could persist
a Nightscout token-bearing URL into the stored error.

Give the poll-cohort factory a required per-provider failure recorder and wire
one for Oura, Polar, Nightscout, and Strava.

Nightscout now counts only freshly inserted readings instead of insert plus
re-confirm (the inflated count fired the reminder trigger every tick), records a
transient failure and skips the success stamp when some rows fail to write, and
emits the measurement-arrival spine for its inserts. Correct the docstring that
promised a far-history backfill that does not exist: the hourly window imports
about 48 hours and nothing deeper.

Strava now enqueues its history backfill at connect time, mirroring the WHOOP
and Fitbit callbacks, so deep history imports within the hour instead of waiting
for the next worker restart.
2026-07-24 20:05:23 +02:00
Marc-André Bombeck cf755d32af Merge pull request #624 from MBombeck/release/v1.32.22
release/v1.32.22 — concurrent-edit guard for prefs records, plus the inventory and privacy races
2026-07-24 18:41:56 +02:00
Marc-André Bombeck 574367737f fix(coach-prefs): return the pure default with no token for a never-saved user
The GET spread the optimistic-concurrency `updatedAt` into the response even
when the user had never saved preferences, which broke the strict-equality
contract that a never-saved read is exactly `DEFAULT_COACH_PREFS`. Mirror the
insights-layout never-saved GET: a null `coachPrefsJson` returns the pure
default with no token (the first save is the tokenless unconditional write), and
only a saved row and the PUT response carry `updatedAt`. Update the integration
round-trip to expect the token on the saved GET and PUT while keeping the
`coachPrefsJson` column assertion pure.
2026-07-24 18:28:41 +02:00
Marc-André Bombeck e65390792d chore(release): v1.32.22 — extend the concurrent-edit guard to the prefs records; serialise the inventory and privacy races 2026-07-24 18:08:15 +02:00
Marc-André Bombeck f9afa794e9 test(concurrency): cover the prefs guards, the inventory lock, and the privacy re-check
Add interleaved-writer, tokenless-compat, and malformed-token coverage for the
notification-prefs, modules, coach-prefs, and mood-tag-layout write guards,
including the notification-prefs case where a stale web write must not revert
the app-managed medication flag. Pin the inventory PATCH and DELETE routing
through the advisory lock, and cover the insight-generation privacy re-check on
both the main commit and the reroll arm.
2026-07-24 18:08:01 +02:00
Marc-André Bombeck 3f33a30415 feat(concurrency): echo the base token from the prefs write clients and document it
Wire the notification-prefs cards, the module toggles, the coach-preferences
save, and the two mood-tag manage cards to echo the optimistic-concurrency
token they last read and to reload on a 409 instead of resaving over a newer
change. Document the token and the 409 on the four write endpoints in the
OpenAPI registry via the shared base-token field and conflict-response
fragments.
2026-07-24 18:07:43 +02:00
Marc-André Bombeck 8849f2cecd feat(concurrency): guard the prefs writes and serialise inventory + privacy races
Apply the shared optimistic-concurrency guard (takeBaseToken + guardedUserUpdate)
to the notification-prefs, mood-tag-layout, modules, and coach-prefs write
endpoints so an interleaved edit can no longer silently overwrite a category a
fresher writer just set; a tokenless request keeps the prior unconditional
write. Route the inventory stock-correction PATCH and DELETE through the same
per-medication advisory lock intake consumption takes, so an absolute stock
write can no longer clobber a concurrent dose decrement. Guard every insight
cache commit on the privacy mode still being what the generation read, so a
privacy-mode change is respected by a generation that was already running.
2026-07-24 17:31:34 +02:00
Marc-André Bombeck 5c581e9e58 Merge pull request #623 from MBombeck/release/v1.32.21
release/v1.32.21 — optimistic-concurrency helper applied to insights, medications, and about-me
2026-07-24 16:57:22 +02:00
Marc-André Bombeck 572f5176bd test(concurrency): cover the layout and about-me write guards
Add per-endpoint unit tests for the insights layout, medications layout, and
coach about-me write guards: an interleaved stale-base writer gets a 409 and
changes nothing, a tokenless request still writes (older-client compatibility),
the fresh token is echoed, and a malformed token 422s. Cover the client token
echo, the two 409 dispositions, and the insights read that must keep the token
through the resolver. Refresh the CHANGELOG and the generated OpenAPI spec.
2026-07-24 16:39:39 +02:00
Marc-André Bombeck ab03bc96f1 feat(concurrency): guard layout and about-me writes against a concurrent overwrite
Extract the dashboard-layout optimistic-concurrency mechanism (issue #581)
into a shared server helper (takeBaseToken plus guardedUserUpdate /
guardedRowUpdate) and a client helper (readUpdatedAtToken, withBaseToken,
isConflict). Retrofit the dashboard-widgets route onto the helper and apply the
same guard to the insights layout, medications layout, and coach about-me write
endpoints.

A request that omits the base token keeps the byte-identical unconditional
write, so older clients are unaffected. Document the contract in the OpenAPI
registry, including the dashboard-widgets path that was previously absent.
2026-07-24 16:26:58 +02:00
Marc-André Bombeck df4425babf Merge pull request #622 from MBombeck/release/v1.32.20
release/v1.32.20 — screen the medication-adherence card and background lines like every other insight
2026-07-24 15:46:19 +02:00
Marc-André Bombeck 0317942f1c chore(release): v1.32.20 — screen the medication-adherence card and background lines like every other insight 2026-07-24 15:18:26 +02:00
Marc-André Bombeck f251ae13f8 test(insights): cover the adherence-card screen and the hero-line envelope guard
Compliance card: a dose-change imperative and a fabricated risk figure are
withheld to the deterministic fallback (no model text persisted, updatedAt
stays null), while a clean grounded summary still renders and persists.

Hero lines: a well-formed, truncated, or fenced JSON envelope resolves to the
deterministic lead/template for both the arrival reaction and the coach nudge;
a plain-text line still ships verbatim.
2026-07-24 15:10:24 +02:00
Marc-André Bombeck 0baffe04cd fix(insights): screen the medication-adherence card and background lines uniformly
Route the medication-compliance status card through finalizeStatusSummary so
it enforces the same dose / risk / causal outbound contracts its seven sibling
status cards do, not only the unparseable-envelope withhold it already had. A
dose-change imperative or a fabricated figure is now withheld to the controlled
fallback on this surface too.

Add the status cards' structured-envelope detection to the two background hero
lines (arrival reaction, coach nudge): a brace-led or fenced JSON reply, or a
truncated envelope under the char cap, now resolves to the deterministic lead
rather than rendering raw.

Fold in the residuals from the same sweep: strip a truncated SUGGEST-ACTION
marker from the Coach prose tail, drop an unreachable zero-rate branch in the
adherence fold, and document why the internal conversation summary stays off
the render-boundary screen.
2026-07-24 14:32:21 +02:00
Marc-André Bombeck 1d3963bebf Merge pull request #621 from MBombeck/release/v1.32.19
release/v1.32.19 — Today view refreshes immediately after a write, plus a guard so it cannot regress
2026-07-24 14:13:30 +02:00
Marc-André Bombeck 15b8df11bc chore(release): v1.32.19 — clear the Today view the instant a write lands
A dose logged from another screen marked the Today hero and the dashboard
stale without re-reading them, so returning to the home page kept showing the
old state until the 120 s poll or a hard reload. The daily reads run with
refetchOnMount off, so a bundle invalidation with the default active
refetch-type never touched them while they were unmounted.

Route every intake-affecting medication write through one blessed
invalidateMedicationReads (bundle invalidation plus a forced inactive refetch
of the digest and snapshot): Take all due, the intake edit dialog, the history
bulk/row delete, the dose-history ledger and its add dialog, the lifecycle and
danger-zone actions, and the intake import. Give the Vorsorge reminder
mutations the same daily-rail refresh, bust the biomarker assessment and the
labs-changes read after a lab write, and refetch the daily reads after a
briefing regenerate so the Today lead updates without a reload.

Restore the dashboard-layout concurrency belt: the settings layout query now
always loads its own tokened copy, so a home-page visit can no longer strand a
save without its base token and silently drop the v1.32.16 conflict guard.

Add a structural guard that fails CI when a new site invalidates a daily-reads
bundle without the paired inactive refetch, so this class cannot recur a
fourth time, plus behavioural coverage for the batch take and the Vorsorge
refresh.

Refs #581
2026-07-24 13:56:22 +02:00
Marc-André Bombeck 5c36a9a776 Merge pull request #620 from MBombeck/release/v1.32.18
release/v1.32.18 — a dead Fitbit token no longer stamps a successful sync
2026-07-24 13:19:44 +02:00
Marc-André Bombeck 115c3e3dcd chore(release): v1.32.18 — a dead Fitbit token no longer stamps a successful sync
Hold the Fitbit sync verdict when the token is dead or a write fails, and
verdict-gate the history backfill so a partial first-time import retries
instead of marking itself complete. No schema or contract change; the OpenAPI
version string tracks the bump.
2026-07-24 13:00:27 +02:00
Marc-André Bombeck da8dfe4256 test(fitbit): pin the dead-token verdict and the verdict-gated backfill
New sync-dead-token suite mirrors the Google Health parity test: the three
getValidToken null-return paths each register on the hard-fail ledger, a
successful refresh leaves it empty, and a dead-token cycle returns
{ imported: 0, failed: true } without stamping markSynced or recordSyncSuccess.
Extends the upsert suite with the two write-catch labels (measurements:create,
measurements:update) and the sync orchestrator suite with a write-label
watermark hold. Rewrites the backfill suite to mirror google-health-backfill:
a clean run stamps, a failed verdict throws without stamping, a parked
connection returns without running the sync.
2026-07-24 12:52:17 +02:00
Marc-André Bombeck 25b7ada8bf fix(fitbit): hold the sync verdict when a token is dead or a write fails
A revoked or expired Fitbit refresh token used to fail the cycle's status
ledger and then stamp the same cycle a success: the connection flipped back
to connected, the watermark advanced, and after a reconnect the 24h overlap
could no longer reach the missed days. getValidToken's three null-return
paths (credentials missing, refresh failure, vanished connection) now register
on the per-cycle hard-fail ledger, so the cycle's verdict fails, the park at
error_reauth holds, and lastSyncedAt does not move. The two measurement write
catches do the same, so fetched-but-unwritten rows are re-fetched next tick
instead of aging out of the overlap.

syncUserFitbit now returns { imported, failed }; the manual-sync route, the
poll cohort adapter, and the backfill consume the new shape. The backfill
stamps backfillCompletedAt only after a clean full-history walk: a parked
connection returns without stamping, and a failed verdict throws so pg-boss
retries on the already-configured retry budget.
2026-07-24 12:47:27 +02:00
Marc-André Bombeck 6a0bec7553 Merge pull request #619 from MBombeck/release/v1.32.17
release/v1.32.17 — pair mood correlations on the local logging day (v1.32.12 regression)
2026-07-24 12:31:59 +02:00
Marc-André Bombeck 490963dfd8 chore(release): v1.32.17 — pair mood correlations on the local logging day 2026-07-24 12:17:20 +02:00
Marc-André Bombeck 0ee5081766 test(insights): cover local-day mood correlation pairing
Berlin boundary pairs (previously dropped), the gone New York lag-1
offset, all three metric partners moving, UTC-user invariance, and a
metric-by-metric guard proving the weight x BP and BP-medication joins
stay byte-identical across timezones. Update the mocked aggregates for
the additive weightRawRows field and pin the widened raw pull in the
aggregator test.
2026-07-24 12:11:10 +02:00
Marc-André Bombeck 1a08083436 fix(insights): pair mood correlations on the local logging day
The mood series has been local-day-keyed since v1.32.12, but the metric
partners it joins against (blood pressure, weight, resting pulse) were
still keyed on the UTC calendar day. The join is an exact day-string
match, so for anyone outside UTC an evening reading landed on the next
UTC day and paired with the wrong mood entry — a one-day misalignment
sold as a same-day correlation.

Derive the three mood pairing partners as exact local-day means, keyed in
the user's own timezone via userDayKey (Intl-based, DST-safe). Blood
pressure and resting pulse reuse raw rows already on the request path;
weight rides one additive column on the existing raw-rows query. The
UTC-consistent weight x BP and BP-medication joins are left untouched —
both their sides share one UTC key space and they migrate with the
rollup tier later.
2026-07-24 12:04:40 +02:00
Marc-André Bombeck 1e72d0aea0 Merge pull request #618 from MBombeck/release/v1.32.16
release/v1.32.16 — dashboard layout Save stays authoritative against a concurrent ring write
2026-07-24 11:25:39 +02:00
Marc-André Bombeck ed260f1ed8 chore(release): v1.32.16 — keep a saved dashboard layout from a concurrent ring save
Refs #581
2026-07-24 11:09:44 +02:00
Marc-André Bombeck cd344047dd test(dashboard): cover optimistic-concurrency + the serialised ring/Save writes
Server: a write with the current base token succeeds and returns the advanced
token; a stale base returns 409 and writes nothing (no unconditional fallback);
a malformed base 422s; an omitted base keeps the unconditional write. Client:
the ring payload never carries widgets and threads the base token, the shared
busy gate covers either write, and the Save/ring wiring is source-pinned.

Refs #581
2026-07-24 11:09:30 +02:00
Marc-André Bombeck 69359a609a fix(dashboard): keep a saved layout authoritative against a concurrent ring save
A dashboard layout change could be silently reverted when a hero score-ring
toggle was made around the same moment. Both writes PUT the whole layout to
/api/dashboard/widgets, and whichever reached the server last won, so a ring
write built from a pre-save snapshot overwrote a fresher Save.

Guard the write with optimistic concurrency. The client sends the updatedAt it
based its edit on; the route writes conditionally on the stored row still
carrying that token (updateMany where updatedAt = base) and returns 409 with a
typed body on a stale base, writing nothing. The advanced token rides back on
every GET/PUT response so the client can rebase. No migration: the User row
already has an @updatedAt column.

On the client, the ring PUT carries only its own fields plus the base token,
the Save and ring writes share one mutation scope so they serialise, the Save
button and ring controls disable while either write is in flight, and a 409
refetches the winning layout and asks the user to redo the change.

Refs #581
2026-07-24 11:07:09 +02:00
Marc-André Bombeck 872fe047e3 Merge pull request #612 from MBombeck/release/v1.32.15
release/v1.32.15 — sync Polar training sessions into workouts
2026-07-24 10:31:52 +02:00
Marc-André Bombeck 73594d35f7 chore(release): v1.32.15 — Polar training sessions sync into workouts
Refs #568
2026-07-24 10:18:13 +02:00
Marc-André Bombeck aa850acab8 test(polar): cover exercise sync, mapper parity, and the shared-ledger legs
Parity suite proves a Polar exercise and an equivalent Strava activity map to a
downstream-indistinguishable Workout row (sport bucket, start instant, duration,
distance, calories, HR, null-vs-zero). Regression guard asserts the exercises
request hits GET /v3/exercises with no userId path segment. Dedup ladder tests
mirror the Strava suite (overwrite-in-place, Apple+Polar twin collapses, Polar
above Strava). Composite test proves a failure in one Polar leg leaves the
other leg's result intact on the shared ledger. Defensive-shape tests cover the
four flagged assumptions: numeric-vs-string id, UTC-offset composition (positive
and negative), bare-array-vs-paged body, and a 403 scope error as a soft error.

Refs #568
2026-07-24 10:10:05 +02:00
Marc-André Bombeck 8bb0719c3a feat(polar): sync Polar exercises into Workout rows
Pull the athlete's exercises from the stateless GET /v3/exercises list (last
30 days of uploads, no transaction, no userId in the path) and upsert each into
the Workout table as source POLAR, keyed on (userId, source, externalId) so a
re-fetch or Polar-side re-analysis overwrites in place. A new table-driven
sport map normalises the raw Polar sport labels into the canonical
WorkoutSportType bucket at ingest, mirroring the Strava/WHOOP mappers; the raw
labels and training load ride metadata for provenance.

The exercise leg rides the existing hourly polar-sync cohort tick alongside the
vitals leg, with independent attribution on the shared polar ledger: each leg
is attempted and records its own failure, and neither can mask the other's
result. Polar takes its place in the workout source ladder between Withings and
Strava. No migration and no OpenAPI change: MeasurementSource.POLAR and the
full workouts source enum already exist.

Refs #568
2026-07-24 10:05:18 +02:00
Marc-André Bombeck aeee3d657d Merge pull request #610 from MBombeck/release/v1.32.14
release/v1.32.14 — Coach withheld-figure elision mark and per-message notice, plus provenance reload fix
2026-07-24 09:44:22 +02:00
Marc-André Bombeck 5ad66af565 fix(coach): mark withheld figures with an elision mark plus a per-message notice
Replace the internal "[unverified]" token the grounding guard left in a Coach
reply with the editorial elision mark […], and add a quiet muted caption
under the bubble explaining that some figures could not be checked against the
user's data. The mark holds the slot grammar-safely mid-sentence, before a
surviving unit, and inside a range; the notice carries the meaning. The withheld
count rides the existing provenance envelope (unverifiedFigures) through the
stream, persistence, and reload, and the fenced document-chat path carries a
minimal envelope so its notice survives too. New i18n key across all six locales.

Also fix an adjacent reload gap: provenanceFromJson restored only a subset of the
serialised envelope, so a persisted suggestion card, confirm action card, and the
tool trace were dropped on conversation reload despite the render layer expecting
them to survive. Restore them defensively alongside the new field.
2026-07-24 09:29:20 +02:00
Marc-André Bombeck 0b828e2c5a Merge pull request #609 from MBombeck/release/v1.32.13
release/v1.32.13 — withhold malformed status assessment output instead of rendering raw JSON
2026-07-24 08:08:37 +02:00
Marc-André Bombeck f88546dc2b chore(release): v1.32.13 — withhold malformed status assessment output
Bump to 1.32.13, regenerate the OpenAPI version stamp, and record the
CHANGELOG entry for the truncated-assessment fix.

Refs #608
2026-07-24 07:54:47 +02:00
Marc-André Bombeck 93c0d616bb fix(insights): withhold malformed status output instead of rendering raw JSON
A truncated or malformed { "summary": "..." } envelope fell through the
status-card parser and rendered raw — braces, the summary property name,
quotes, and literal \n — in the Assessment card (resting pulse and the
other per-metric cards). parseSummaryFromContent returned the raw content
on any JSON parse failure, and a truncated envelope with no closing brace
also fell into that path, so partial structured output reached the reader.

Split the read into extractAssessmentSummary, which classifies a completion
as a parsed summary, genuine prose, or an unparseable structured envelope.
A payload that opened like a JSON object but failed to parse (or parsed
without a usable summary) resolves to the controlled fallback rather than
raw text; plain prose that merely contains a brace is still preserved.
finalizeStatusSummary withholds an unparseable envelope so every status
card serves its deterministic stub, and the two direct callers
(medication compliance, derived-score assessment) get the same guard.

Refs #608
2026-07-24 07:45:02 +02:00
Marc-André Bombeck 4f95afa33c Merge pull request #607 from MBombeck/release/v1.32.12
release/v1.32.12 — mood rollups key by local day, not UTC day
2026-07-24 04:04:18 +02:00
Marc-André Bombeck 1fca6c1d25 fix(mood): key mood rollups by the local day, not the UTC day
The persistent mood-rollup writer bucketed on the UTC truncation of
`mood_logged_at` and ignored the canonical per-row `MoodEntry.date`
label. Every coverage-miss fallback keys on `entry.date`, so the rollup
tier disagreed with the fallback and with the day the user logged the
mood: a late-evening or after-midnight local mood landed on the wrong
calendar day, and the label shifted the moment the boot backfill flipped
a user from the live fallback onto the rollup tier.

Key every bucket off the stored `date` label instead — DAY, WEEK, MONTH
and YEAR — with `bucket_start` set to that label's UTC-midnight instant.
The rollup is now byte-identical to the live `entry.date` fallback and
timezone-independent by construction: DST correctness stays where it
already lives, in `moodDateKey` at write time. No consumer changes, no
schema change; every rollup reader already formats `bucket_start` as a
UTC day label.

Migration 0269 empties `mood_entry_rollups` so the boot-time
full-backfill worker re-mints every user's buckets under the corrected
keying; the worker now deletes-then-refolds so it is self-cleaning
against any row left by an older instance. While the table is empty,
reads fall back to the live `date`-keyed walk, which was already
correct, so there is no window where a wrong day is shown.
2026-07-24 03:50:14 +02:00
Marc-André Bombeck 6cf9ad1273 Merge pull request #606 from MBombeck/release/v1.32.11
release/v1.32.11 — native web-handoff login for self-hosted iOS
2026-07-24 02:44:44 +02:00
Marc-André Bombeck 0b3280c604 chore(release): v1.32.11 — native web-handoff login for self-hosted iOS
Self-hosters can sign in to the iOS app through their own domain, so the
password manager files credentials under the right site and passkeys run in
the correct origin. The app opens the instance's own web login inside an
ASWebAuthenticationSession; after an interactive login the server mints a
single-use, PKCE-locked handoff code on a compiled-in custom scheme, which the
app exchanges for the standard native token bundle. The token pair never rides
a URL — only the opaque code does.

Three endpoints mirror the OIDC native handoff and share its mint/consume core,
now extracted to native-handoff.ts (the OIDC leg keeps working unchanged, as
the default flow):

  - GET  /api/auth/native/login    validates the PKCE challenge, sets a
                                   short-lived encrypted state cookie, and
                                   redirects to /auth/login?flow=native.
  - GET  /api/auth/native/complete validates the web session, enforces the
                                   freshness binding, mints the code, and
                                   redirects to healthlog://login-callback.
  - POST /api/auth/native/token    exchanges the code under the cookie-less
                                   native transport gate for the native bundle.

The freshness binding refuses any session older than the flow start, so a
pre-existing browser session can never silently complete a handoff. The start
time is stamped from the database clock (SELECT now()) to stay comparable to
Session.createdAt with no app/DB skew, and createdAt is read through a
dedicated projection so the comparison cannot be silently dropped. A flow
discriminator on the handoff row (migration 0268) makes an OIDC code
non-redeemable at the web-login route and vice versa.

The callback scheme is a compile-time constant with no redirect_uri parameter,
requireAdmin and requireFreshMfa stay cookie-only, and the exchange mints no
wider scope than password login. The state cookie is AES-256-GCM with a fresh
IV. Adds the flow=native login-page banner in all six locales and the three
routes to the OpenAPI contract.
2026-07-24 02:28:51 +02:00
Marc-André Bombeck d2d79b70a2 Merge pull request #605 from MBombeck/release/v1.32.9
release/v1.32.9 — Coach Guard II: grounding ledger, action ladder, six-locale risk screen
2026-07-23 23:20:16 +02:00
Marc-André Bombeck dece1f89d9 docs(changelog): restore the 1.32.8 heading above its entry 2026-07-23 22:52:53 +02:00
Marc-André Bombeck 6333d4d989 chore(release): v1.32.9 — Coach Guard II (grounding ledger + action ladder)
The Coach output guard now tracks the numbers it was shown across the whole
conversation, so a figure carried forward or grounded in a goal, a schedule,
or a reference band stops being marked unverified, while an invented one is
still caught and a fabricated risk number now blocks in six languages.
2026-07-23 22:50:03 +02:00
Marc-André Bombeck 9c95e713d2 feat(coach): typed grounding ledger + four-rung action ladder (Guard II)
Promote the per-turn magnitude bag to a per-conversation typed Grounding
Ledger built from every source the model was actually shown: this turn's
tool payloads, prior-turn tool figures, coach-memory goals, the medication
schedule, the reference-grounding block, and guided blocks. Assistant prose
is never a registration source, so a figure that slipped a rung cannot
launder into an authoritative value on the next turn.

Complete the action ladder: a reconciled number passes, a reference or
education sentence is exempt across all six locales, a positively-typed
benign number (date, time, year, ordinal, list marker) passes while an
untyped unreconciled one stays on the strip path, and an unreconciled
health figure is soft-stripped.

Gate the dose continuation exemption on the user's actual schedule: a
"keep taking your N mg" is trusted only when N is a dose the user is on;
an off-schedule maintenance dose is caught. Extend the risk and percent
classifiers to fr/es/it/pl to match the EN/DE depth.

No schema migration: the ledger is rebuilt per turn from persisted tool
traces and re-rendered prompt blocks.
2026-07-23 22:49:54 +02:00
Marc-André Bombeck 25393d93fe Merge pull request #604 from MBombeck/release/v1.32.8
release/v1.32.8 — Provenance: transport-derived intake source + diagnostic sync trigger
2026-07-23 21:52:22 +02:00
Marc-André Bombeck 7b765c7659 chore(release): v1.32.8 — carry write provenance on records made through the app
Bumps the version, regenerates the OpenAPI contract and the service-worker
version fallback, and records the release notes for the intake-source and
sync-trigger provenance surface plus the shared hhmm util and migration-number
guard.
2026-07-23 21:36:10 +02:00
Marc-André Bombeck c1bb997de7 test(auth): assert the cookie path returns the derived authMethod
The cookie arm of requireAuth now carries authMethod: "cookie" alongside the
session payload, so the exact-shape assertion expects it.
2026-07-23 21:36:01 +02:00
Marc-André Bombeck 6e46afc1fc test(prisma): guard against duplicate migration numeric prefixes
Two short-lived branches can each grab the next free migration number and
both merge, leaving the tree with two directories sharing a four-digit
prefix. This structural test fails the build the moment a prefix repeats, so
the collision surfaces at PR CI instead of after merge. One historical
collision (0025) predates the guard and is grandfathered — a migration
directory name is the primary key in the applied-migrations ledger, so it
cannot be renamed without orphaning the record on every deployed database.
2026-07-23 21:26:54 +02:00
Marc-André Bombeck 9e6453c51d refactor(medications): consolidate the hhmm-to-minutes helpers into one shared util
Seven near-identical copies of the HH:mm to minutes-since-midnight parser
had accumulated across the scheduling surfaces in two flavours: a strict one
that trusts an already-validated literal and a null-returning one for
unvalidated input. Both now live in src/lib/medications/scheduling/hhmm.ts as
hhmmToMinutes and hhmmToMinutesOrNull, and every call site imports from there.
Behaviour is identical: the per-medication route keeps its malformed-to-zero
fallback as a thin adapter over the shared core, and dose-window re-exports
the strict variant so its consumers and unit suite keep their import site.
2026-07-23 21:25:15 +02:00
Marc-André Bombeck 61a1592665 test(medications,measurements): cover transport-derived intake source and the diagnostic syncTrigger
Route-level tests that run the real apiHandler + requireAuth so the
cookie-vs-Bearer derivation is exercised end to end, not papered over by a
unit mock:

- a cookie POST records WEB, a Bearer POST records API, and a body that
  forges `source` is ignored;
- the dose-history ledger selects and returns `source` per row, null on a
  legacy row;
- a batch carrying `syncTrigger:"background"` rides the ingest annotation,
  a batch without it annotates null, an invalid value is 422, and the field
  leaves the per-row insert/update outcomes byte-identical.
2026-07-23 21:21:21 +02:00
Marc-André Bombeck 27b548ab13 feat(medications,measurements): derive write provenance from transport and surface it
Root-cause the intake source attribution and give the HealthKit batch a
diagnostic sync-trigger, one attribution surface for both.

AuthContext now carries a read-only authMethod ("cookie" | "bearer"),
derived from the same branch that sets the wide event's auth_method. The
medication-intake route maps it to the stored IntakeSource: a Bearer
(native) call records API, a cookie (browser) call records WEB. The other
producer values are untouched — a converge onto a pending reminder row
updates through applyCanonicalSlotWrite, which never rewrites source, so
REMINDER / IMPORT / APPLE_HEALTH provenance is preserved. The value is
never client-asserted, the same posture as userId being narrowed from auth.

The dose-history ledger now selects and returns source per intake row so a
client can label how each dose was recorded. The endpoint gains its first
OpenAPI entry (path plus response schema) so the field lands in the
iOS-consumed contract.

The measurements batch accepts an optional per-request syncTrigger
(foreground | background | push). It is diagnostic-only: recorded on the
measurement.batch.ingest wide event and nothing else — never persisted,
never part of the dedup key, never an input to attribution or how a sample
is stored. Sending it or omitting it produces byte-identical row outcomes.

Regenerated docs/api/openapi.yaml.
2026-07-23 21:15:17 +02:00
Marc-André Bombeck d321391024 Merge pull request #603 from MBombeck/release/v1.32.7
release/v1.32.7 — Coach Guard I: typed grounding, narrowed risk and dose banks
2026-07-23 20:40:32 +02:00
Marc-André Bombeck 9d584806c7 chore(release): v1.32.7 — Coach Guard I (typed grounding + narrowed risk/dose banks) 2026-07-23 20:25:08 +02:00
Marc-André Bombeck a69d0493f8 feat(coach): typed grounding tokenizer + normal-form reconciler + narrowed risk/dose banks
Replace the untyped magnitude bag in the Coach numeric verifier with a typed
tokenizer and a normal-form reconciler, and narrow the outbound risk and dose
banks so correct figures and correct refusals stop being blocked while every
fabrication shape still blocks.

Grounding verifier (coach-prose-grounding.ts):
- Classify each prose number before grading — ISO/written dates, clock times,
  bare years, ordinals, list markers, hyphenated time spans, ranges, percents,
  thousands-separated integers, unit-suffixed measurements. Benign types are
  never graded or stripped; ranges decompose into per-endpoint magnitudes.
- Grade against typed authoritative entries (value + kind + aggregation).
  Sign-insensitive, canonical rounding, ratio to percent in percent space, and
  minutes to hours widen the match only for an aggregate, kinded entry; kind
  scoping stops a weight value grounding a blood-pressure claim.
- Thousands/decimal separators follow the reply language.
- Boundary-safe strip so a flagged token never clips a larger grounded number.

Outbound screen (outbound-screen.ts):
- Risk bank requires an asserted figure or a categorical engine result; a bare
  engine or horizon mention (education/refusal) passes. Spelled-out percents
  count as figures. No refusal-context exemption. EN + DE.
- Dose bank keeps a maintenance-continuation restatement, voided by any change
  stem. Taper suffix fixed.

Route + fenced-chat thread the reply locale into the verifier.
2026-07-23 20:07:03 +02:00
725 changed files with 47897 additions and 14358 deletions

No files matched your search

@@ -39,7 +39,6 @@ body:
- Notifications
- AI Insights
- Withings integration
- moodLog integration
- Self-hosting / Docker
- Other
validations:
+1 -3
View File
@@ -4,7 +4,7 @@ Instructions for AI coding agents (OpenAI Codex, Claude Code, Cursor, etc.) work
## Project
**HealthLog** — a personal health-tracking web app (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first PWA design.
**HealthLog** — a personal health-tracking web app (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, Dracula-themed UI, mobile-first PWA design.
**Status**: v1.4.0 (released) → v1.4.1 in progress. v1.4.0 shipped
the UI guidelines + Skeleton/EmptyState primitives, medical-citations
@@ -102,7 +102,6 @@ src/
│ ├── import/ # JSON data import
│ ├── doctor-report/ # Doctor report data aggregation
│ ├── gamification/ # Achievements (persistent)
│ ├── integrations/moodlog/ # moodLog.app webhook + sync + status
│ └── ...
├── components/
│ ├── ui/ # shadcn/ui primitives
@@ -135,7 +134,6 @@ src/
│ ├── gamification/ # Achievement definitions + progress
│ ├── insights/ # Insight pipeline + prompts
│ ├── logging/ # Wide Events: builder, context, sampler, transports, background
│ ├── moodlog/ # moodLog.app webhook + sync
│ ├── monitoring/ # Umami / GlitchTip server-side hooks
│ └── withings/ # Withings OAuth client + sync service
├── hooks/
+850
View File
@@ -2,6 +2,856 @@
## [Unreleased]
## [1.32.39] — 2026-07-26
The doctor report now carries what you chose, and nothing else.
**Ten of the twenty switches did nothing.** The panel offered controls for
oxygen saturation, body fat, body composition, resting heart rate, heart-rate
variability, VO2 max, steps and distance. Switching them off changed nothing:
the value was folded away before it reached the report. Two more claimed less
than they did, and the medication switch left the dose-by-dose record in place
while appearing to remove the drug list.
**Allergies and family history went out regardless.** They were read straight
from the database in the export route, so a report could exclude them and the
structured file in the same download would still contain them.
**The mental-health questionnaires went out without being asked for.** The
depression, anxiety, wellbeing and sleep scores reached the structured export,
the automation interface and a shared link, gated only by a module that had
been switched on by default some releases ago. They now have their own entry
and are off unless you pick them.
**The cycle export shipped ten other things.** The card that promised to export
only cycle data sent an empty request, and an empty request fell back to the
defaults, so it also carried weight, blood pressure, pulse, oxygen saturation,
body fat, the medication list, adherence, lab results, allergies and family
history. The comment above that code said every other section was off.
**What replaces all of it.** One picker, on both surfaces that choose a scope.
Twelve groups, each releasable or excludable as a whole and openable down to
the individual value. All 77 measurement types and 13 record sections are in
it, each in exactly one group. Sensitive material sits below a divider with no
group control, so no single click can turn on more than one of those at a time.
Nothing is preselected. A new report starts empty and one button fills in what
a general visit usually needs, which you can then change. Generating is not
possible while nothing is chosen.
A metric added to the app in future cannot appear in a scope you saved
earlier. That is not a promise, it is a compile error: a new measurement type
without a group breaks the build, and every path that produces a report has to
name where its scope came from.
**Share links.** A link can now be fetched as a PDF or as a structured file
behind the same passphrase that already protects the view, which is what a
practice actually does with one. The old promise of an interface that never
existed is gone. Links created before this release carry no explicit choice
and are therefore closed; each one can be reissued in a click with its label,
period and expiry carried over.
**The written summary has left the report.** It could not be checked against
what you chose, and prose whose scope cannot be shown has no place on a
document whose value is exactly that.
**Structured export corrections.** Someone who records their gender as
something other than female or male was exported as female. FHIR has a code
for it and it is now used, and an unrecognised value is left out rather than
guessed. The capability document described a resource twice and omitted
another that was actually served; both corrected.
**Also in this release.** Roughly one page load in three showed raw text keys
for a moment instead of words, because the language file was racing the
application bundle and losing. It is now loaded before the first render.
**For operators.** Migration `0275` stores the new scope and removes the old
preferences. Existing share links are closed by it and need reissuing.
## [1.32.38] — 2026-07-25
Blood-glucose readings from anything other than the manual entry form were
never saved.
A rule added years ago required every blood-glucose reading to carry a
context: fasting, after a meal, random, or bedtime. That is a fair thing to
ask of someone typing a reading in by hand. It is impossible for a sensor,
which measures every few minutes and cannot know what you were doing.
So every automated path failed. A CSV import reported the rows as skipped and
still showed a green tick. Nightscout counted them as failed rows and carried
on. Apple Health has no context field at all, so not one of its readings could
ever have been stored. The same for readings written through the automation
interface, through Telegram, and through the JSON importer. None of it was
visible: no error reached anyone, and the affected charts and panels simply
stayed empty as though nothing had been measured.
A reading with no context is now stored with no context, which is what it is.
Where a context is given it is still checked. Manual entry still asks for one,
because the form offers the choices and nothing is lost by asking.
Absence is now shown as absence: a reading without a context is left out of the
per-context breakdowns rather than being counted as one of them.
**Imports no longer claim to have worked when they did not.** A run that wrote
nothing is shown as a failure. A run that wrote some rows and skipped others is
shown as a partial result. Only a run that actually landed something shows as
done. Skipped rows are grouped by reason with a count instead of listing the
first fifty identical lines, and the per-row detail is still there behind a
disclosure. This covers the CSV, JSON and Apple Health cards alike.
Reported by @lutzkind, who traced it to the parser, ran it in isolation to rule
out the timestamp and unit handling, and checked the database afterwards to
prove nothing had been written. Files that were rejected can simply be imported
again.
**For operators.** Migration `0274` relaxes the constraint. It adds no column
and rewrites no row. Readings that were refused in the past were never stored,
so they need a fresh import from the source.
## [1.32.37] — 2026-07-25
Two ways a record could quietly lose data, both closed.
An identifier that a client sends along with a reading is meant to say "this is
the same reading you already have". One client was sending a value that changed
every time the app restarted, so nothing ever matched, and every sync created a
fresh copy. On one instance that produced 23 medications nobody had ever taken,
none of them with a dose or a single logged intake, because the doses were
looking for a medication under an identifier that no longer existed.
The server now refuses an identifier that cannot possibly be stable, with a 422
that says what is wrong instead of quietly making duplicates. A batch rejects
only the offending entry and still accepts the rest, so one bad row cannot stop
a sync. Twelve entry points check it. The identifier shapes that real apps and
devices actually send are pinned by tests, so a future rule cannot get greedy
and start dropping valid data.
The second one was worse and had no symptom at all. The repeat-protection key
on a medication intake was unique across the whole database rather than per
account. Two people generating the same key independently meant the second one
could not save their dose at all. The database refused it, and the intake was
gone. The key is now unique per account, and every lookup asks who it belongs
to. Recording an intake through the automation interface without sending a key
had a related problem: the first one saved and every one after it was discarded
as a repeat. Also fixed.
The migration does not touch any existing intake. It relaxes a rule rather than
tightening one, so nothing that was allowed before becomes disallowed now.
## [1.32.36] — 2026-07-25
Nothing here changes what you see. This release is about the checks that run
before a release goes out.
A recurring shape of mistake in this project is a half-built feature: something
is stored, or sent to the app, and the part that was supposed to use it never
gets written. Each end works on its own, so nothing complains. Three checks now
run on every build and refuse that. One walks the fields the app receives about
your account and insists each one is actually read somewhere. One compares the
measurement table against what a backup writes out, so a new column cannot go
missing from your backup file. The third fixes a check that had gone quiet: it
was meant to find translation entries no screen uses any more, but one line of
it had been waving through several thousand entries at once for a long time
without anyone being able to tell.
With that check honest again, 149 leftover translations turned out to belong to
screens that were rebuilt or removed some time ago. They are gone from all six
languages. Nothing that is on screen today was touched.
## [1.32.35] — 2026-07-25
The doctor report stops asking for your practice name every time.
You type a clinic or practice name onto the cover of a health record export,
and until now that was all it did. The field emptied itself again the moment
you left the panel, so next month's report started from a blank line. The name
you used last time is now kept and filled in for you. Clearing the line works
the same way and is remembered too, so a cover without a practice line stays
without one. The name is saved only once the report has actually been
produced, which means an export that fails partway leaves the previous value
alone.
A backup now carries the origin of your daily totals. Steps, distance and
similar figures that arrive from Apple Health as one number per day are stored
with a note of where that number came from, so a rough estimate read out of an
archive can never overwrite a total the phone measured itself. That note was
missing from the backup file, so a restore brought the figures back without it
and the protection went quiet. It rides the backup now. Restoring an older
backup written before this fix marks the affected daily totals as unclassified
instead of leaving them ambiguous, which is the state a later sync knows how
to repair.
Migration 0272 drops two columns that were written and never read. They held a
contributor count and a source fingerprint for archive derived daily totals,
and no screen, export or repair path has ever looked at either. The figures
themselves and the origin note above are untouched.
## [1.32.34] — 2026-07-25
Housekeeping on the interface. Most of it you will not see.
The sections on the insights overview had each been given their own card frame
by hand, and the copies had drifted apart on the one thing nobody thought to
write down: how much room to leave between the blocks inside them. Some used 8
pixels, some 12, one 16, all sitting one under the other in the same column.
They now share a single frame with a single rhythm, so a few of those cards
open or tighten very slightly and all of them pick up the same faint shadow
their neighbours already had.
When a metric page cannot load, the message now reads in the normal text
colour with a warning glyph beside it, instead of the grey reserved for
timestamps and units. A failure is what that surface has to say at that moment,
so it should not look like a footnote.
The rest is invisible from the outside. The sleep composition card uses the
same title row as every other card, a handful of one-off text sizes and
paddings went back onto the scale the rest of the app uses, and the spacing
rule that was already enforced in the build now has nothing left to catch.
## [1.32.33] — 2026-07-25
The moodLog integration is gone. It bridged mood entries to and from a separate
self-hosted mood app back when HealthLog had no real mood module of its own.
That stopped being true a long time ago: mood is tracked natively here now,
with tags, rated factors and notes, and the bridge had nothing left to add. Its
settings card was withdrawn a while back and the rest of it kept running in
the background against an upstream nobody was pointing at any more. This
finishes the job properly.
- **Your mood entries are untouched.** Every entry stays exactly where it is,
including the ones that originally arrived through moodLog. They keep their
source label, they still show up in the mood list, the filters, the charts,
the correlations and the doctor report. Nothing was deleted and nothing was
relabelled. Removing the pipe does not remove what came through it.
- **What was removed.** The hourly pull, the inbound webhook, the outbound
push, the per-account credentials and webhook secret, the admin toggle, the
entry on the integrations status feed, and the message strings behind the old
card. Migration `0271_drop_moodlog_integration` drops the six columns those
surfaces used and clears the retired sync schedule. It names no mood table.
- **One less admin switch that did more than it said.** The instance-wide
toggle was described as controlling mood-log reminders. It actually shut off
the whole integration, quietly, with no other visible effect. It is gone
along with the thing it gated.
If you were still relying on the bridge, export your mood data from the other
app and import it once; the entries land in the same place they always did.
## [1.32.31] — 2026-07-25
The integrations page could tell you a connection was failing. It could not
tell you a connection had stopped trying. Those look the same from the outside:
the page keeps showing whatever error was recorded last, and it keeps showing
it for as long as nobody looks, because nothing anywhere read how old the last
attempt actually was. This release adds that reading, and every status on the
page now comes from it.
- **A sync that has stopped running is labelled as stopped.** When a connection
has not even attempted to sync for a day, it says so, with how long it has
been. Previously it kept displaying the last error it managed to record, which
reads like something that is still retrying.
- **Per data type, when a value last arrived.** Each connected card now opens a
short list: every data type that connection has ever delivered, and how long
ago the newest one came in. A type that has gone quiet for two weeks while the
rest of the connection is healthy is called out, which is the shape a single
broken pipe takes: one revoked permission, one endpoint that started returning
nothing, while the sync itself keeps reporting success. Types the connection
has never delivered are simply not listed. Nothing is invented.
- **Apple Health has a staleness threshold.** Any timestamp at all used to paint
the green "data received" badge, so a phone that stopped delivering three
weeks ago looked exactly like one that delivered this morning. After a week of
silence the card now says so and points you at the app's Apple Health
permissions. It also lists the same per data type detail as the others.
- **A configured connection with no card of its own now appears.** If your
account has a connection set up that the page has no card for, it gets a plain
status row instead of being left off entirely. That is a structural fix: a
connection can no longer be running, failing and invisible at the same time.
- **The status colours mean one consistent thing.** Red is now reserved for the
cases your own action fixes, which is reconnecting or resuming. An upstream
outage or a rate limit carries the amber tone instead, because clicking
reconnect against a server error has never helped anyone. Every card uses the
same mapping. Three of them used to disagree with each other on the same page.
- **Nightscout reads the same status source as everything else.** It was the
last card fetching its own status separately, which is why it could show a
different colour for the same condition.
The assistant tools report the new verdict too, so asking why your data looks
stale gets the real answer instead of "connected".
## [1.32.30] — 2026-07-25
Height was the last value still asked for in centimetres regardless of the
setting. Both previous releases closed with a note saying so. It sat outside
the fix because height is not a measurement at all: it is a field on your
profile, so the conversion the reading surfaces go through never reached it.
- **Height is entered in the unit you use.** With imperial selected, the
profile card and the onboarding step ask for feet and inches in two boxes,
the way a person actually states a height. With metric selected the single
centimetre box is exactly what it was.
- **The value you type comes back.** Feet and inches are kept to the nearest
whole inch, which converts to centimetres without a remainder, so saving
5 ft 11 in and reopening the page shows 5 ft 11 in again. The limits the
fields offer are the imperial equivalents of the ones the server enforces,
rounded inward, so a height you are allowed to type is never refused on save.
- **Storage is unchanged.** Height is still kept in centimetres, the API still
sends and receives centimetres, and BMI and the weight bands still work from
that same value. A metric account's stored height is untouched by this
release.
- **Switching the unit keeps what you were typing.** The unit selector sits on
the same card as the height field, so flipping it re-expresses whatever is in
the field rather than clearing it.
With this the preference reported in #627 reaches every value the app shows you
or asks you for.
## [1.32.29] — 2026-07-25
Fitbit re-scores a night hours after you wake up: blocks get split, merged or
relabelled. HealthLog identified each block by its position in the list, so a
re-score that renumbered the blocks looked like a set of brand new ones. The
previous version stayed on file, nothing removed it, and from then on the night
counted twice. It was quiet: the sync kept reporting success, because the sync
was in fact working.
- **A re-scored night no longer double counts.** Each sleep block is now
identified by the moment it starts, which does not move when Fitbit rescores
the night. A block that changes from light to deep updates in place instead
of leaving the old copy behind, and anything the new scoring dropped is
cleared from that night's window before the new blocks are written.
- **Nights already affected repair themselves.** A one off pass runs once per
connection after the update and rereads the past year of sleep, so the
duplicates collapse without you doing anything. It covers 365 days, the same
horizon the Fitbit history import uses. Nights older than that keep whatever
they already hold; they cannot gain new duplicates, since Fitbit does not
rescore year old nights. Migration `0270_fitbit_sleep_repair_marker` records
which connections have been through the pass.
- **Rate limits are handled instead of ignored.** When Fitbit asks for a short
wait, the request now waits and tries again once. A long wait is refused
outright and left to the next scheduled run, so one throttled account cannot
hold up everybody else's.
- **Fewer pointless writes.** Fitbit rereads the last 24 hours every hour. Rows
that came back unchanged were rewritten anyway, which showed up as needless
traffic for the iOS app. Unchanged rows are now left alone.
## [1.32.28] — 2026-07-25
Oura used to look back exactly one week and no further. If the sync had been
broken for longer than that, or if the ring sat unused and the older nights
only reached Oura's cloud later, those days fell outside the window and were
never imported. Nothing said so, because a sync that finds nothing still counts
as a sync that worked.
- **Oura catches up after a gap.** The window is now measured from the newest
Oura reading actually on file rather than from a fixed week. A ten day gap
pulls eleven days on the next run and closes itself; a month is the ceiling.
When data is arriving normally the window stays at a week and nothing about
the hourly run changes.
- **A fresh Oura connection imports a month** instead of a week. Deeper history
is a separate question and is not part of this release.
- **Sync now for Oura, Polar, Strava and Nightscout.** These four had no way to
ask for a sync; they only ever ran on their scheduled pass. Each connected
card gets the same button WHOOP and Fitbit already had, limited to a few runs
per minute.
- **Connecting starts the first sync straight away.** Oura, Polar and
Nightscout used to sit empty for up to an hour after connecting, waiting for
the next scheduled pass. The first pull now runs on connect, and the schedule
stays as the fallback.
## [1.32.27] — 2026-07-25
Target and threshold panels now follow the metric and imperial preference. The
previous release listed this as the one place still reading in kilograms and
Celsius: with imperial selected a weight chart read in pounds while the target
reference right below it read in kilograms.
- **Target ranges read in your unit.** On the weight insight page the target
band, the range bar, the status pill, and the 30 day average all read in
pounds when imperial is selected. Body water and bone mass follow the same
rule in the threshold settings.
- **Editing a target works in the unit you see.** The target editor and the
threshold settings seed their fields from the stored value converted to your
unit, check what you type against limits expressed in that same unit, and
convert back before saving. A value typed at the displayed limit is accepted
instead of being rejected against a kilogram bound you never saw. Saving and
reopening shows the number you typed.
- **Storage is unchanged.** Targets are still kept in canonical SI, and a metric
account's saved values are untouched by this release.
- **Known remaining.** Height is still entered in centimetres.
This finishes the job @DrGithubble started in #627.
## [1.32.26] — 2026-07-25
The metric and imperial display preference is now applied everywhere your
weight, body composition, waist, and temperature readings appear. Until now the
setting was saved but almost nothing read it, so the numbers stayed in
kilograms and Celsius regardless of the choice. Thanks to @DrGithubble, who
reported this in #627 and traced it himself, down to the hardcoded kilograms in
the entry form and the missing imperial branch in the display transform. His
follow-up was the more useful half: a reading genuinely tagged in another unit
was being relabelled by any surface that printed the type's canonical unit. That
is what turned a one-screen fix into a proper one.
- **Weight and body composition follow the preference.** With imperial
selected, weight, muscle mass, body water, bone mass, fat mass, and grip
strength read in pounds across the dashboard tiles, the charts, the record
list, the insight pages, and the coach read strip. Body temperature, skin and
wrist temperature read in degrees Fahrenheit, and waist reads in inches.
Storage is unchanged: every reading is still kept in canonical SI, and a
metric user sees exactly the same numbers as before.
- **Manual entry converts at the point of entry.** When you log or edit a value
with imperial selected, the field is labelled in your unit and the number you
type is converted back to canonical on save, so a pound reading is stored as
the correct kilogram value rather than a mislabelled one.
- **A logging tool can no longer store a foreign unit.** The measurement logging
tool used by connected assistants now converts a recognised unit hint (such as
pounds or Fahrenheit) to canonical, and refuses anything it does not
recognise, so a reading is never saved under the wrong unit.
- **Known remaining.** Height is entered in centimetres for now. Target and
threshold panels also still read in kilograms and Celsius, so with imperial
selected a weight chart reads in pounds while its target reference below it
reads in kilograms. The stored values are correct either way; both are
separate follow-ups.
## [1.32.25] — 2026-07-24
Mirrored medications are now marked as such, and the server limits how many
medications one external source can mirror so a client bug can no longer fill
the list with duplicates.
- **Mirrored medications are marked on the list.** The medications list now
reports whether each entry was mirrored from an external source (today Apple
Health) or created in HealthLog directly. A synced row is no longer
indistinguishable from one you added yourself.
- **One external source can no longer flood the list.** The server refuses to
mirror more than 30 medications from a single external source per account. A
client that keeps re-sending the same medication under a changing identifier
can no longer create a fresh row on every sync. A repeat of a medication
already mirrored still resolves to the existing row, and medications you add
yourself are never affected.
## [1.32.24] — 2026-07-24
Polar sync is more resilient when one data type is unavailable. A single
collection that a device or account cannot serve no longer blanks the rest of
the Polar data on every hourly check.
- **One unavailable Polar data type no longer blocks the others.** SpO2 on a
watch without the sensor, or a data type an account is not entitled to, used
to fail the whole Polar sync and leave recharge, sleep, and activity empty.
Each collection now imports on its own, so the ones that are available still
come in and only the one that failed is held back.
- **A failing Polar collection is named on the integration status.** When a
collection cannot be read, the sync is recorded as failed with the failing
collection named, instead of showing a healthy state over missing data.
- **Unexpected Polar response shapes are logged, not read as empty.** If Polar
returns a body that does not match the expected shape, it is now recorded for
diagnosis rather than silently treated as "no data", which previously could
mark a sync healthy while importing nothing.
## [1.32.23] — 2026-07-24
Sync status now tells the truth for the connected wearables and CGM sources. A
few paths could report a sync as healthy when it had actually failed, which
reset the failure streak, hid the reconnect prompt, and let a dead grant get
retried on every hourly tick. This release closes those gaps so a failed sync
reads as failed, an interrupted history import is retried instead of marked
complete, and a couple of sources start pulling data sooner.
- **A dead WHOOP token no longer reports a successful sync.** When the stored
grant is revoked, the connection stays parked with its reconnect prompt and
the last-synced time stays put, instead of flipping back to connected and
hammering the revoked token every hour. This holds on both the manual sync and
the hourly background path.
- **An interrupted WHOOP history import is retried, not marked done.** A
full-history backfill that stops partway is no longer stamped complete, so the
gap is retried rather than frozen in place. To pull deep history after
connecting, run a full sync from Settings. It walks back to 2020.
- **Withings ECG now syncs on its own schedule.** A watch-only account with no
scale previously never had its ECG recordings pulled. An hourly check now
brings them in without waiting on a webhook.
- **Withings activity and sleep write failures now surface.** A failed write no
longer reads as a clean sync; it shows up on the integration's status instead
of leaving a green state over missing data.
- **Oura, Polar, and Nightscout sync errors that previously vanished now
surface.** A write that fails partway through is recorded on the integration
status rather than being silently dropped, and each error is classified and
redacted by the source that owns it.
- **Strava history import starts at connect time.** Deep history begins
importing right after you connect instead of waiting for the next server
restart.
- **WHOOP cycle data uses a wider catch-up window.** A day's strain and energy
figures that are still being revised are re-fetched for a week, matching the
other WHOOP collections, so a late revision is not missed.
## [1.32.22] — 2026-07-24
The concurrent-edit protection that reached the layout surfaces now covers the
rest of the preference records too. Notification preferences, module toggles,
coach preferences, and the mood-tag layout all get the same guard, so a save
based on an older read stops and reloads instead of quietly reverting a change
another writer just made. Two more races are closed on the server side: a stock
correction can no longer overwrite a dose consumption that ran at the same
moment, and a privacy-mode change is now respected by an insight generation that
was already running. Older clients that do not send the new marker keep working
exactly as before.
- **Notification preferences no longer clobber a concurrent change.** The
server merges each category into one stored record. A save based on an older
read now reloads instead of writing the whole record back without the change
another device just made. This is what closes the case where a web save could
silently switch off the app-managed medication reminder flag and bring back a
duplicate reminder.
- **Module toggles, coach preferences, and the mood-tag layout get the same
guard.** Each is one stored record that more than one surface can write; a
stale save is now stopped and reloaded rather than reverting a newer one. The
coach-preferences save covers both the tuning options and the excluded-metric
list together, so a conflict leaves both untouched.
- **A stock correction can no longer race a dose consumption.** Correcting the
remaining count on a container now runs under the same per-medication lock a
logged dose takes, so the two serialize instead of one overwriting the other.
- **A privacy-mode change is respected by an in-flight insight generation.** A
briefing that was already generating when you switch the privacy mode no
longer writes its old-scope output over the cache the switch cleared. It stops
short of the write and the next run regenerates under the new mode.
- **Older clients are unaffected.** A request that does not carry the new base
marker keeps the previous save behavior, so nothing needs updating to keep
working. The new contract is documented in the published API spec.
## [1.32.21] — 2026-07-24
Insights and medications layout saves, and the coach about-me note, are now
protected against a concurrent edit overwriting a newer one. This is the same
protection the dashboard layout already had. If you save a layout in one place
while something else saved a change to the same record a moment earlier, your
save now stops and reloads the current state instead of quietly reverting that
other change. Older clients that do not send the new marker keep working exactly
as before.
- **Insights layout saves no longer clobber a concurrent edit.** Saving the
overview arrangement and saving the pill order are two separate surfaces that
write the same record. If one lands while the other was based on an older
read, the later save now reloads and asks you to redo it rather than reverting
the first.
- **Medications layout saves get the same guard.** The card/table view toggle
and the manual order both write one record, so a stale toggle can no longer
resurrect an order you just saved, and the reverse holds too.
- **The coach about-me note is guarded as well.** A save based on an older read
is stopped and reloaded instead of overwriting a newer version of the note.
- **Older clients are unaffected.** A request that does not carry the new base
marker keeps the previous save behavior unchanged, so nothing needs updating
to keep working.
- **The dashboard layout contract is now in the published API spec.** It was
missing from the OpenAPI file even though it was the first surface to get this
protection.
## [1.32.20] — 2026-07-24
The medication-adherence insight now passes through the same safety screen as
every other insight card. It was the one card of eight that showed the model's
text without running that screen first. It already held back a reply that came
back broken or cut off, but a dose-change instruction or an invented figure
could still reach it. It now runs the same check the other seven cards run, and
anything that trips the check falls back to the plain, data-grounded line the
card shows when no model is set up.
- **The medication-adherence card is screened like the rest.** A reply that
reads as a dose change, or that cites a made-up risk figure, is held back and
the card shows its plain fallback. A normal grounded summary still shows as
before.
- **Two background notification lines can no longer print a raw model reply.**
The "just in" reaction line and the proactive coach nudge now catch a reply
that arrived as structured data or got cut off partway, and use their fixed
wording instead of printing it as is.
- **Smaller cleanups in the same area.** A stray marker left behind by a
cut-off coach reply is now stripped from the text, and an unreachable branch
in the adherence figure was removed.
## [1.32.19] — 2026-07-24
After you logged a dose, the Today view could keep showing it as still due
until you reloaded the page. The Today hero and the dashboard read from a cache
that refreshes on a timer, and a write made from another screen marked that
cache stale without actually re-reading it. The change reached the server, but
the screen you came back to still showed the old state for up to two minutes,
or until a hard refresh.
- **A dose you take now clears from Today at once.** This covers the "Take all
due" button on the medications page, editing or deleting a logged dose, and
importing intake history. The Today view and the dashboard now re-read the
moment the write lands instead of waiting for the timer.
- **A preventive-care reminder marked done leaves the Today rail immediately.**
Marking one off, editing it, or deleting it updates the Today view straight
away rather than after a refresh.
- **A lab reading you add updates its cards right away.** Adding a reading
refreshes that marker's assessment on the same page and the "what changed
since your last panel" card, instead of leaving them on their earlier text.
- **A briefing you regenerate shows up on Today without a reload.** The new
briefing now reaches the Today hero and the dashboard as soon as it is ready.
- **Saving a dashboard layout keeps its safeguard against a conflicting edit.**
A visit to the home page could leave the layout editor without the token that
guards a save, which quietly turned off the protection added in v1.32.16
against two edits overwriting each other. The editor now always loads its own
copy, so the guard stays on.
Thanks to @lutzkind, whose run of reports surfaced the stale Today view.
Refs #581
## [1.32.18] — 2026-07-24
When a Fitbit token was revoked or expired, the hourly sync noticed the failure
but then stamped the same cycle as a success. The connection flipped back to
connected, the sync position moved forward, and after you reconnected the
catch-up window could no longer reach the days that were missed. So the data
was skipped for good, and in the meantime the failing token was retried every
hour.
- **A revoked Fitbit token no longer reports a successful sync.** A failed token
refresh now fails the whole cycle. The connection stays parked until you
reconnect, the sync position holds instead of advancing past real data, and
the once-per-failure alert stops repeating every hour.
- **Data after a reconnect is no longer skipped.** Because the position is held
back on a failed cycle, the next successful sync still covers the days that
were missed rather than starting after them.
- **A write failure during a sync holds its place too.** If rows were fetched
but could not be saved, the cycle keeps its position so the next run fetches
them again instead of letting them fall outside the catch-up window.
- **An interrupted history backfill no longer marks itself complete.** A partial
first-time import is retried until a full pass finishes, so the one-year
history walk fills in rather than freezing halfway.
If your Fitbit history already has a gap from before this release: reconnect
Fitbit if prompted, then open Settings, Integrations, Fitbit and run a full
sync. It re-walks the last year and fills the gap in place. Gaps older than a
year are outside the backfill window and cannot be recovered.
## [1.32.17] — 2026-07-24
The mood correlations on the Insights page could line up a day off. Your mood
is dated by the calendar day you logged it in your own timezone, but the
metrics it was matched against were still grouped by the UTC day. If you live
away from UTC, an evening reading slid onto the next UTC day, so a mood and a
reading from the same evening were paired as if a day apart. A regression from
v1.32.12, when the mood side moved to local days on its own.
- **Mood now pairs with metrics from the same local day.** The blood pressure,
weight, and resting pulse series that mood is correlated against are grouped
by the day you logged them in your own timezone, so both sides of the pairing
share one calendar. An evening entry no longer drifts onto the next day.
- **Non-UTC users get the correct same-day figures.** For anyone west of UTC an
evening reading used to pair with the wrong day's mood, turning a same-day
relationship into a one-day-apart one. Those correlations and their scatter
charts now reflect the day the data was actually recorded. UTC users see no
change.
## [1.32.16] — 2026-07-24
Changing which tiles and charts appear on your dashboard could get undone if
you also toggled a hero score ring at about the same moment. Both actions save
the whole layout, and the ring change was built from an older copy, so
whichever reached the server last won. Thanks to @lutzkind for the clear
report.
- **A saved layout now stays saved.** Your dashboard save is treated as the
authoritative one. If another change arrives after it but was based on an
older version of the layout, the server declines that write rather than
letting it overwrite your work. The ring toggle also carries only its own
fields, so it can no longer pull stale tiles along with it.
- **The two saves take turns instead of racing.** While a layout save is in
flight the score ring controls are held, and while a ring change is saving
the Save button waits, so the two writes can no longer overlap on the server.
- **A change underneath you reloads instead of getting lost.** In the rare case
where another tab saved first, the layout refetches from the server and a
small note asks you to make your change again against the fresh copy.
Refs #581.
## [1.32.15] — 2026-07-24
Your Polar training sessions now sync into HealthLog, alongside the recovery,
sleep, and daily activity that Polar already brought in. Thanks to
@StefanIndustries, whose report asked for this after the vitals sync landed.
- **Polar exercises arrive as workouts.** Every run, ride, swim, or gym session
you upload to Polar Flow shows up in your workout history with its sport,
start time, duration, distance, calories, and heart rate. The raw Polar sport
label and training load ride along in the background so nothing is lost. The
sync reads your last 30 days of uploads once an hour, so a session synced
late from your watch is still caught.
- **A Polar session does not double up with your other sources.** If the same
workout also reaches HealthLog through Apple Health or another connection, the
two are matched and only one is shown. A Polar watch counts as a direct
heart-rate recording, so it ranks above a Strava re-upload of the same run but
below your primary on-wrist wearables. You can change that order under
Settings, Sources.
- **The two Polar halves stay independent.** A hiccup fetching your training
sessions no longer holds back the recovery and sleep sync, and the reverse
holds too, so a problem on one side never masks the other.
## [1.32.14] — 2026-07-24
When the Coach removes a number it could not check against your data, the reply
now reads clearly and tells you what happened.
- **A withheld figure reads as a quiet omission.** The Coach checks every number
in its answer against the figures it was actually shown, and holds back any it
cannot verify so a drifted value never reaches you as if it were real. That
held-back number used to show up as an internal `[unverified]` marker in the
middle of the sentence. It now appears as a short editorial mark, and a small
muted line under the message tells you that some figures could not be checked
and were left out. The line is worded for each of the six languages and stays
out of the way until there is something to say.
- **Coach action cards and the tool trace survive a reload.** A suggested
reminder, a confirm-to-create action card, and the "what I looked at" tool
trace were saved with the message but dropped when the conversation was
reopened, so they quietly vanished on reload. They are now restored with the
rest of the message, so reopening a thread shows the same cards and trace it
showed the first time.
## [1.32.13] — 2026-07-24
An Insights assessment card no longer shows the raw model output when a response
comes back incomplete. If the underlying text was cut off, the card now falls
back to its computed summary instead of printing the unfinished data.
- **A cut-off assessment stays hidden.** The per-metric assessment cards, like
the one for resting pulse, ask the model for a small structured reply and then
read the summary out of it. When that reply arrived truncated, the reader
could see the whole envelope on the card, including the property name, the
braces, the quotes, and the escaped line breaks. The card now recognises an
incomplete or malformed reply and serves the deterministic summary it already
falls back to when no model text is available, so the raw output never reaches
you.
- **Plain-text assessments are untouched.** A model that answers in an ordinary
sentence rather than the structured shape is still shown as written, so this
change only affects broken replies and leaves normal ones exactly as before.
- **The same guard covers the other cards.** Medication compliance and the
derived-score assessments read their summary the same way, so they now hold
back a broken reply too rather than persisting it as the day's text.
Thanks to @lutzkind for reporting this one.
## [1.32.12] — 2026-07-24
A mood you log late in the evening now lands on the day you logged it, in your
own timezone, both on the dashboard and in the Coach's mood view.
- **Your late-night mood stays on the right day.** The pre-computed mood history
used to file each entry by its UTC date, so a mood logged after dark in a
timezone that runs ahead of or behind UTC could jump to the neighbouring day
once the nightly rebuild ran. Every mood now files under the calendar day it
was logged in your own timezone, matching the raw entry list.
- **The dashboard and the Coach agree.** The daily mood series behind the
dashboard tile and the Coach's mood view now read the same day for an entry as
the entry list does, so a value no longer shifts by a day when the faster
pre-computed path takes over from the live one.
- **The correction applies itself.** On upgrade the pre-computed mood history is
cleared and rebuilt under the corrected keying. While it rebuilds, mood reads
fall back to the live calculation, which was already correct, so there is no
window where the wrong day is shown.
## [1.32.11] — 2026-07-24
Signing in to the iOS app on a self-hosted domain now runs through your own web
login, so saved passwords and passkeys behave the way they should.
- **Sign in through your own domain.** On a self-hosted instance the app now
opens your instance's real web login page inside a secure in-app browser.
Because the login happens on your own origin, your password manager files the
credentials under the right site and passkeys can run at all, which the older
native form could not do on a custom domain.
- **A single-use handoff, not a token in a link.** Once you have logged in, the
server hands the app a one-time code that lives for about ninety seconds and
can be redeemed only once. The access and refresh tokens never ride a URL. The
whole exchange mirrors the existing native single sign-on handoff and reuses
its protections, including the per-request PKCE binding.
- **The login has to happen inside the flow.** The app receives a code only when
you actually authenticate during that browser session. A login left open in
the background from before cannot complete the handoff by itself, so a stray
link can never quietly sign the app in as you.
- **Your second factor still applies.** An account with an authenticator app or
a security key completes that step on the web page before any code is issued,
exactly as it does everywhere else.
## [1.32.9] — 2026-07-23
The Coach output guard now remembers the numbers you were actually shown across
a whole conversation, so figures you legitimately carry forward stop getting
marked unverified while an invented one still gets caught.
- **A figure from earlier in the chat is no longer flagged when you bring it
back up.** The guard now keeps a running record of every number the Coach was
given this turn and in earlier turns, plus your goal figures, your medication
doses, and the reference bands it was shown. A value it can trace to one of
those is left alone even a few turns later. A value the Coach only wrote in an
earlier reply is never trusted on that basis, so a number that once slipped
through cannot quietly become authoritative later.
- **An educational sentence keeps its numbers.** A line that states a general
guideline, like the note that adults usually need seven to nine hours of
sleep, is recognised as a reference and left intact instead of having its
numbers stripped. This works in every supported language.
- **A dose you are actually on reads as a restatement, a different one does
not.** When the Coach says to keep taking a dose, the reminder is allowed only
when the amount matches a dose on your schedule. A wrong maintenance amount, or
any wording that nudges the dose up or down, is still held back.
- **Fabricated risk numbers are caught in more languages.** The screen that
blocks an invented clinical risk figure or a named risk-engine result now
reads French, Spanish, Italian, and Polish to the same depth it already read
English and German, including a percentage written out in words.
## [1.32.8] — 2026-07-23
Health records written through the app now carry where they came from, and a
HealthKit sync can say what set it off.
- **A dose logged from the app is recorded as an app entry.** Every intake used
to be stored as a web entry no matter how it arrived. The server now reads the
transport it authenticated the call with and tags the dose to match, so a dose
logged in the native app reads as an app entry while one logged in the browser
stays a web entry. A reminder confirmation, an import, and an Apple Health
mirror keep their own labels. The value is derived on the server and can never
be set from the request body.
- **The dose history shows how each dose was recorded.** The per-dose history
returns a source label on every intake, so a client can show at a glance
whether a dose came from the web, the app, a reminder, an import, or an Apple
Health mirror. Rows written before the label existed report as unknown rather
than guessing.
- **A HealthKit sync can report what woke it.** The batch upload accepts an
optional trigger tag reading foreground, background, or push. It is recorded
for diagnostics only and never changes which readings are stored or how they
are deduplicated, so sending it or leaving it off produces the same result.
## [1.32.7] — 2026-07-23
The Coach output guard now reads numbers the way you actually write them, so a
correct figure stops getting marked unverified while an invented one still gets
caught.
- **A number you reformatted is no longer flagged.** The guard recognises a
rounded restatement, a delta narrated as a drop, an adherence rate written as
a percent, minutes read back as hours, a reformatted date, and a
thousands-separated step count, each checked against the figure the server
actually gave the Coach. A drifted number, like an average of 128 quoted back
as 138, is still caught.
- **A blood-pressure claim can no longer borrow a weight number.** Every figure
is matched inside its own unit family, and a raw reading never earns the
looser rounding reserved for a headline average, so a fabricated average
cannot slip through on a nearby sample.
- **A correct clinical-risk refusal streams through untouched.** Naming ASCVD or
a ten-year risk to explain that a clinician computes it now passes. What still
blocks is an asserted figure or a categorical verdict, whether it carries
digits ("your risk is about 14%"), spelled-out words ("roughly twelve
percent"), or no number at all ("SCORE2 would put you in the high-risk band").
- **A dose you already take reads as support.** "Keep taking your 7.5 mg as
prescribed" passes, while anything that moves the dose stays blocked.
- **A figure removed as unverifiable leaves the sentence intact.** The strip
touches only the exact token, so a year like 2023 is never clipped by a
flagged 23.
Refs #587, #591.
## [1.32.6] — 2026-07-23
Hardening and data-integrity fixes that were reviewed earlier and are now folded
+2 -1
View File
@@ -89,6 +89,7 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
- **Every API route wraps in `apiHandler`, with three documented exceptions.** The MCP OAuth routes (`mcp/oauth/{authorize,token,register}`) use `withBackgroundEvent` instead, because OAuth requires RFC-format error bodies that the standard envelope cannot express. They carry the same protections by hand — rate limit, body cap, kill switch, mandatory PKCE-S256, redirect-URI allowlist. Every other route wraps. Every body-accepting route runs Zod `safeParse` and returns 422 via `returnAllZodIssues` (multi-issue envelope sanitised against echoed input through `sanitiseZodIssues`).
- **`userId` is always narrowed from session or Bearer.** No route accepts `userId` as a body field — it comes from `requireAuth()` and feeds the Prisma `where`. The batch endpoints (`/api/measurements/batch`, `/api/mood-entries/bulk`, `/api/medications/intake/bulk`, `/api/workouts/batch`) don't even declare a `userId` field in their Zod schemas.
- **No mass assignment.** Every `prisma.X.{create,update}({ data: ... })` builds its `data` object field-by-field from `parsed.data`, never by spreading the parsed object whole. `tokens.create` builds `permissions` from a hardcoded literal, never from request input.
- **A two-ended change carries both ends.** Adding a column, a payload field, or a user preference without its consumer is the failure mode this repo keeps rediscovering: the column ships with the schema change, the reader is "the follow-up", and nothing in the gate notices because every other check proves one end. So the consumer lands in the same release branch, or the schema comment says why it cannot: `/// @internal: <reason>` for something deliberately never surfaced, `/// @pending-consumer(#issue)` for a consumer that is genuinely still coming. `pnpm dlx tsx scripts/audit-column-readers.ts` sweeps for unannotated write-only columns and re-lists every `@pending-consumer` marker so a follow-up that never happened surfaces at the next release audit. Run it in the deep-audit step; it is a review tool with a grep-shaped matcher, deliberately not a gate. The two questions that can be answered exactly are gates instead — `account-payload-consumer-guard.test.ts` (every `/api/auth/me` field has a client reader) and `measurement-backup-completeness.test.ts` (every `Measurement` column reaches the backup).
- **Bearer scope is fail-closed.** `requireAuth()` with no argument admits a cookie session or a wildcard (`["*"]`) token only — a narrow-scope token is refused with 403 and an `undeclared_scope` audit row. A route that means to accept a narrow token names the scope: `requireAuth(FHIR_READ_SCOPE)`. Adding a route therefore cannot silently widen an existing token's reach, because the default arm denies. Only the login / passkey / refresh paths mint `["*"]`; every user-facing mint is narrow, and narrow now means narrow. Two surfaces resolve a Bearer token outside `requireAuth` — `/api/ingest/medication` and `/mcp` — and that set is frozen by a structural test (`src/__tests__/bearer-scope-enforcement-guard.test.ts`).
- **`requireAdmin()` is cookie-only.** Bearer tokens — even with `["*"]` scope — cannot reach admin endpoints. This is a structural boundary in `requireAdmin()` (`src/lib/api-handler.ts`), not a runtime check that future code can soften.
- **Raw SQL is parameter-bound or whitelist-spliced.** Every `$queryRaw` uses tagged-template parameters. Every `$queryRawUnsafe` / `$executeRawUnsafe` either passes positional `$N` placeholders with an argument array OR splices a value already asserted against a closed enum + regex (see the whitelist-splice block in `src/lib/rollups/measurement-rollups.ts`, around the `$queryRawUnsafe` calls). Inline comments document the whitelist at the splice point.
@@ -140,7 +141,7 @@ The v1.5.2 audit produced `.planning/security-audit-v1.5.2.md` and a stack of pe
- **SSRF: input-time `isPublicUrl` is the floor.** The helper `isPublicUrl()` in `src/lib/validations/notifications.ts` catches every IPv4 / IPv6 alt-notation class (octal / hex / decimal IPv4, IPv4-mapped IPv6, IPv6 ULA, CGNAT, link-local, loopback, metadata). Use it for every outbound URL that comes from a user / admin input. DNS rebinding and the redirect-follow gap remain as architectural follow-ups (issues #217 + #218). Until those land, every NEW outbound `fetch()` that touches a user-controlled host must pin `redirect: "manual"` and an `AbortSignal.timeout`.
- **Outbound egress goes through `safeFetch`.** `src/lib/safe-fetch.ts` is the one documented egress entry (`redirect: "manual"` + `AbortSignal.timeout(15_000)` defaults; opt into the connect-time DNS-rebinding pin with `requirePublicHost: true` for any operator- or user-supplied host). The in-repo ESLint rule `healthlog/safe-fetch-required` (set to `error`) bans raw `fetch(` under `src/lib/` + `src/app/` outside the wrapper internals and test files; same-origin relative-path (`/api/…`) client fetches are exempt by construction.
- **Rate limits live in Postgres.** `src/lib/rate-limit.ts` uses a single atomic SQL upsert returning `{count, reset_at}`. Multi-instance correctness is structural. Bucket-key convention: `<surface>:${identifier}` where identifier is `userId` for authenticated routes and the trusted-proxy IP for anonymous. Anonymous auth surfaces use `checkAuthSurfaceRateLimit(...)` so a trust-violation collapses every anonymous caller into a single tight bucket rather than a free-for-all under the unknown-IP bucket.
- **Webhooks: shared-secret + `timingSafeEqual` + per-source rate limit BEFORE secret verification.** Withings, Telegram, moodLog, the Coolify deploy webhook, and the CSP report endpoint all follow this exact pattern. The Withings path-segment secret is scrubbed from `http.path` / `http.route` by `redactSecrets()` via the `PATH_SECRET_PATHS` registry in `src/lib/logging/redact.ts:40`.
- **Webhooks: shared-secret + `timingSafeEqual` + per-source rate limit BEFORE secret verification.** Withings, Telegram, the Coolify deploy webhook, and the CSP report endpoint all follow this exact pattern. The Withings path-segment secret is scrubbed from `http.path` / `http.route` by `redactSecrets()` via the `PATH_SECRET_PATHS` registry in `src/lib/logging/redact.ts:40`.
- **Idempotency keys are user-scoped, length-bounded, and refuse to cache secret-shaped responses.** `src/lib/idempotency.ts`. A client cannot replay another user's idempotency key (composite unique index by `userId_key_method_path`). The cache helper refuses to persist a response body containing `hlk_` / `hlr_` / `sk-` patterns even if a future caller wraps an auth endpoint by mistake.
- **CSP: strict, per-request nonce, surgical `connect-src`.** Production CSP gates third-party hosts to the routes that need them — AI hosts only on `/settings/ai/*`, `wbsapi.withings.net` only on `/settings/integrations/withings/*` + `/api/withings/*`. Exactly one `dangerouslySetInnerHTML` in the tree (the theme bootstrap in `src/app/layout.tsx`, nonce-bound, file-literal content). No markdown library installed anywhere; Coach / briefing / insights render as plain React text children. Don't add one without a security review of the renderer's HTML-allow defaults.
- **No CORS headers anywhere.** Same-origin-only by construction. Adding `Access-Control-Allow-Origin: *` anywhere would silently open every authenticated endpoint to cross-origin POST; webhook routes accept cross-origin POSTs but they authenticate by header secret, not cookie.
+1 -1
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project
HealthLog — a personal health-tracking PWA (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first design, offline caching, and doctor report PDF export.
HealthLog — a personal health-tracking PWA (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, Dracula-themed UI, mobile-first design, offline caching, and doctor report PDF export.
## Commands
+1824 -718
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -1,6 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 620" font-family="ui-sans-serif, -apple-system, Segoe UI, Roboto, sans-serif" role="img" aria-labelledby="title desc">
<title id="title">HealthLog data flow — device to insight</title>
<desc id="desc">Five-stage pipeline: sources (Withings, Apple Health export, iPhone HealthKit, manual entry, moodLog.app) feed ingest endpoints, which write to Postgres Measurement and MeasurementRollup tables, then surface on Dashboard tiles, Insights cards, the AI Coach, and the Doctor PDF.</desc>
<desc id="desc">Five-stage pipeline: sources (Withings, Apple Health export, iPhone HealthKit, manual entry, Nightscout) feed ingest endpoints, which write to Postgres Measurement and MeasurementRollup tables, then surface on Dashboard tiles, Insights cards, the AI Coach, and the Doctor PDF.</desc>
<!-- Dracula background -->
<rect x="0" y="0" width="1200" height="620" fill="#282a36" />
@@ -45,8 +45,8 @@
<text x="120" y="411" text-anchor="middle" fill="#bd93f9" font-size="11">PWA · iOS Shortcuts</text>
<rect x="30" y="444" width="180" height="56" rx="10" fill="#44475a" stroke="#8be9fd" stroke-width="1.5" />
<text x="120" y="469" text-anchor="middle" font-weight="600">moodLog.app</text>
<text x="120" y="487" text-anchor="middle" fill="#bd93f9" font-size="11">webhook</text>
<text x="120" y="469" text-anchor="middle" font-weight="600">Nightscout</text>
<text x="120" y="487" text-anchor="middle" fill="#bd93f9" font-size="11">pull sync</text>
</g>
<!-- INGEST (orange #ffb86c) -->

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+1 -1
View File
@@ -81,7 +81,7 @@
<rect x="1010" y="240" width="160" height="100" rx="10" fill="#44475a" stroke="#8be9fd" stroke-width="1.5" stroke-dasharray="4 4" />
<text x="1090" y="266" text-anchor="middle" fill="#f8f8f2" font-size="13" font-weight="600">Withings</text>
<text x="1090" y="282" text-anchor="middle" fill="#6272a4" font-size="11">Apple Health</text>
<text x="1090" y="298" text-anchor="middle" fill="#6272a4" font-size="11">moodLog.app</text>
<text x="1090" y="298" text-anchor="middle" fill="#6272a4" font-size="11">Nightscout</text>
<text x="1090" y="314" text-anchor="middle" fill="#6272a4" font-size="11">AI provider</text>
<text x="1090" y="332" text-anchor="middle" fill="#bd93f9" font-size="11">(per-user)</text>
</g>

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

+1 -1
View File
@@ -8,7 +8,7 @@ copy.
| File | Story it tells |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `01-data-flow.svg` | Sources (Withings, Apple Health export.zip, iPhone HealthKit, manual, moodLog) → ingest endpoints → Measurement + MeasurementRollup → reads (rollup probe / live SQL fallback / cache) → surfaces (dashboard, insights, Coach, doctor PDF). |
| `01-data-flow.svg` | Sources (Withings, Apple Health export.zip, iPhone HealthKit, manual, Nightscout) → ingest endpoints → Measurement + MeasurementRollup → reads (rollup probe / live SQL fallback / cache) → surfaces (dashboard, insights, Coach, doctor PDF). |
| `02-coach-pipeline.svg` | User question → snapshot builder → prompt assembler → provider chain (ChatGPT OAuth / OpenAI / Anthropic / local / admin-shared) → response parser → cited reply with mini-charts. |
| `03-self-hosting-topology.svg` | Internet → reverse proxy (TLS) → Next.js app (port 3000) ↔ pg-boss worker ↔ Postgres 16, plus GHCR image pull, optional Coolify autodeploy, optional S3 off-host backup. |
| `04-source-priority.svg` | Three sources for the same metric on the same day → resolver picks one canonical row per (cumulative vs point) ladder. Losing rows stay in the audit trail. |
+72 -3
View File
@@ -1,6 +1,6 @@
# Data import
HealthLog has two ways to bring data in, both reachable from
HealthLog has three ways to bring data in, all reachable from
**Settings → Export & Import**:
1. An **Apple Health `export.zip`** upload — the full archive from the
@@ -10,10 +10,18 @@ HealthLog has two ways to bring data in, both reachable from
2. A **generic JSON import** — a small, explicit payload of measurements
and mood entries, useful for restoring a partial export or migrating
from another tracker. This page documents that format.
3. A **CSV import** — one measurement per row, for a spreadsheet or a
meter / sensor export. Documented in section 3 below.
Both paths skip rows that already exist, so re-running an import is
All three paths skip rows that already exist, so re-running an import is
safe — it merges rather than duplicating.
Every import reports what it actually wrote. A file where nothing was
written is shown as a failure, a file where some rows landed and some
were refused is shown as a partial result, and the refused rows are
grouped by reason so one bad column reads as one line rather than a
scroll.
## 1. Apple Health `export.zip`
On the iPhone: **Health app → profile picture → Export All Health
@@ -56,7 +64,28 @@ skipped and counted under `skipped` in the response.
| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. |
| `notes` | no | string | Optional free text. |
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE` (e.g. fasting / post-meal). |
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE`. See [Glucose context](#glucose-context). |
### Glucose context
`glucoseContext` is one of `FASTING`, `POSTPRANDIAL`, `RANDOM`,
`BEDTIME`, and it is genuinely optional on a `BLOOD_GLUCOSE` row. Leave
it out (JSON) or leave the cell blank (CSV) and the reading is stored
with no context.
That is the normal case for a continuous sensor or meter export, which
records a value and a timestamp per reading and classifies nothing. A
reading with no context still counts everywhere a reading counts: the
chart, the all-time summary, and the trailing-30-day clinical panel
(time in range, GMI, CV%, estimated A1c) are all context-agnostic. It is
left out only of the per-context breakdowns — the doctor report's
fasting / post-meal rows, the matching FHIR observations, the per-context
targets — because no classification was recorded and none is invented.
Setting the column on any type other than `BLOOD_GLUCOSE` is rejected.
Setting it to a value outside the four above is rejected. Entering a
reading by hand in the app still asks for a context, because at that
moment you know it.
### Mood entry
@@ -195,8 +224,48 @@ The response is a count envelope:
}
```
## 3. CSV import
`POST /api/import/csv` takes the raw file as `text/csv` — one measurement
per row, no JSON wrapper. The **CSV file** control in
**Settings → Export & Import** posts to it, and **Download example**
mints a file with the right header.
A header row is required; column order does not matter and header
matching is case-insensitive.
| Column | Required | Notes |
| ---------------- | -------- | --------------------------------------------------------------------------------------------------------- |
| `type` | yes | A `MeasurementType` from the table above. |
| `value` | yes | A number, in the row's `unit`. |
| `unit` | yes | The canonical unit, or a recognised alias: `mmol/L` → `mg/dL`, `lb` → `kg`, `in` → `cm`, `°F` → `°C`. |
| `measuredAt` | yes | ISO-8601 **with an explicit offset** (`Z` or `±HH:MM`, and `±HHMM` is accepted). No offset, no import. |
| `glucoseContext` | no | Only for `BLOOD_GLUCOSE`; blank means no context. See [Glucose context](#glucose-context). |
| `notes` | no | Free text, up to 200 characters. |
| `externalId` | no | A source-stable id. With it, a re-upload updates the same row; without it, a re-upload creates a new one. |
A sensor export therefore needs no glucose column at all:
```csv
type,value,unit,measuredAt,glucoseContext,notes,externalId
BLOOD_GLUCOSE,5.3,mmol/L,2024-04-03T13:15:00+1100,,,sensor-1
BLOOD_GLUCOSE,5.9,mmol/L,2024-04-03T13:30:00+1100,,,sensor-2
```
Limits: 16 MB per upload, 10 000 valid rows per import, 5 imports per
hour (shared with the JSON import). `?dryRun=1` validates and previews
without writing anything — the **Preview** button uses it.
The response carries per-row outcomes alongside the counts, and the card
renders the counts, not the status code: rows written and none skipped is
a success, a mixed result is a warning, and nothing written is a failure
with the reasons grouped by count.
## Converting a CSV into the import JSON
The CSV import above covers measurements. Mood entries have no CSV
route, so a mood history still goes through the JSON payload.
A spreadsheet export is the most common starting point. The shape is
small enough to convert by hand for a few rows, or with a one-off script
for many. The steps are the same either way:
+2 -2
View File
@@ -324,7 +324,7 @@ The 1.4 release splits the legacy 3150-LOC settings monolith into one
route per top-level section:
- `/settings/account` — profile, password, passkeys, account deletion.
- `/settings/integrations` — Withings, moodLog, Telegram, ntfy, Web Push.
- `/settings/integrations` — Withings, Nightscout, Telegram, ntfy, Web Push.
- `/settings/notifications` — channel matrix + quiet hours + reminders.
- `/settings/dashboard` — tile layout editor, default ranges, threshold
overrides.
@@ -426,7 +426,7 @@ control. Behavior:
- **History**: each test writes a row to a tiny per-integration log
visible in the section. Last 5 tests, timestamp + result.
- **Coverage**: required for Telegram, ntfy, Web Push, AI provider,
Withings, moodLog, GlitchTip, Umami, email (when wired), backup
Withings, Nightscout, GlitchTip, Umami, email (when wired), backup
target. Headless E2E asserts presence + working call for each.
### 4.6 Server actions and mutations
+4 -2
View File
@@ -12,10 +12,12 @@ The Playwright config (`../playwright.config.ts`) builds the app (`next build`)
## Layout
- **`setup/`** — `global-setup.ts` (auth + DB seeding), `test-helpers.ts` (shared login + navigation helpers).
- **`setup/`** — `test.ts` (the suite's `test` + `expect` entry point), `global-setup.ts` (auth + DB seeding), `test-helpers.ts` (shared login + navigation helpers).
- **`utils/`** — fixtures such as `mock-dashboard-snapshot.ts`.
- **`*.spec.ts`** — one spec per flow: auth/login/redirect, dashboard, charts, the medication-wizard cadences (`medications-wizard-*`), insights generation + scroll restoration, doctor report, settings, mobile-viewport consistency, locale switch, accessibility (`a11y.spec.ts`), and the `v1427-*` regression set.
## Convention
## Conventions
Import `test` and `expect` from `./setup/test`, never from `@playwright/test` — types (`Page`, `Route`, …) still come from the package. The entry point extends Playwright's `test` with an automatic fixture that drops route handlers and page listeners after every test, so a handler left in flight cannot fail the test it belonged to once the context closes. `src/__tests__/e2e-route-cleanup-guard.test.ts` enforces the import boundary.
Assert against stable `data-*` attributes (`data-slot`, `data-display-step`, root display attrs), not viewport-dependent visible text. Responsive `sm:hidden` UI changes break `getByText` on desktop while the underlying element is still present — anchoring on data attributes keeps the specs stable across breakpoints. See [`../CLAUDE.md`](../CLAUDE.md).
+2 -7
View File
@@ -1,11 +1,6 @@
import AxeBuilder from "@axe-core/playwright";
import {
expect,
test,
type Locator,
type Page,
type Route,
} from "@playwright/test";
import type { Locator, Page, Route } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { POPULATED_SUMMARIES } from "./utils/mock-dashboard-snapshot";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
/**
* The proxy at src/proxy.ts is the single gate enforcing auth on every
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { setTimeout as delay } from "node:timers/promises";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot";
+2 -1
View File
@@ -16,7 +16,8 @@
* seeds a real `document_content_index` row (`ensureVaultAiFixture`) and drives
* the real list union.
*/
import { expect, test, type Page } from "@playwright/test";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+2 -1
View File
@@ -15,7 +15,8 @@
* AI e2e, so it is pinned here too. Assertions target stable data-slots, never
* viewport-dependent text.
*/
import { expect, test, type Page } from "@playwright/test";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+2 -1
View File
@@ -22,7 +22,8 @@
* the specs time out. The seeded demo user has no AI provider, so the
* no-provider state is the default.
*/
import { expect, test, type Page } from "@playwright/test";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+2 -1
View File
@@ -17,7 +17,8 @@
* they create, never on the shared doctor-flow corpus.
*/
import AxeBuilder from "@axe-core/playwright";
import { expect, test, type Page } from "@playwright/test";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
/**
* Locale-key drift smoke check — switch the cookie between EN and DE
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
/**
* Smoke checks on the login surface — must render, must wire the
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+2 -1
View File
@@ -1,4 +1,5 @@
import { expect, test, type Route } from "@playwright/test";
import type { Route } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+14 -9
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
@@ -41,11 +41,15 @@ test("medications list refetches on client-side navigation back to the page", as
}
});
// Initial load — the list query mounts and fetches once.
await page.goto("/medications");
await page.waitForResponse(
// Initial load — the list query mounts and fetches once. Arm the waiter
// BEFORE navigating: `goto` resolves on load, so a response that already
// arrived would never be seen by a listener attached afterwards, and the
// wait would time out on an event that had long since happened.
const firstListFetch = page.waitForResponse(
(r) => new URL(r.url()).pathname === "/api/medications",
);
await page.goto("/medications");
await firstListFetch;
expect(medsRequests).toBe(1);
// Client-side nav AWAY (dashboard) then BACK — App Router unmounts and
@@ -56,10 +60,11 @@ test("medications list refetches on client-side navigation back to the page", as
await page.locator('a[href="/medications"]').first().click();
await page.waitForURL("**/medications");
// Give a returning refetch time to fire.
await page.waitForTimeout(1500);
// Desired: the return mounts a fresh observer that refetches. Pre-fix
// the 5-min staleTime masks it and this stays at 1 (the bug).
expect(medsRequests).toBeGreaterThanOrEqual(2);
// the 5-min staleTime masks it and this stays at 1 (the bug). Poll rather
// than sleeping a fixed span: it returns the moment the refetch lands, and
// it does not turn a slow CI machine into a false failure.
await expect
.poll(() => medsRequests, { timeout: 10_000 })
.toBeGreaterThanOrEqual(2);
});
+177 -28
View File
@@ -1,4 +1,6 @@
import { expect, test } from "@playwright/test";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
@@ -12,9 +14,11 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
* and generic-JSON cards.
* 3. The full health-record export panel lives on its own top-level
* `/settings/gesundheitsakte` section (v1.18.0 S5) — including the
* "included data" checklist, a disclosure collapsed by default that
* expands on demand.
* 4. Clicking the Measurements CSV download button fires a real
* "included data" disclosure, which a first run opens and which stays
* operable in both directions.
* 4. The scope picker mounts twice on that page (the export panel and the
* share-link create form) and each mount is separately addressable.
* 5. Clicking the Measurements CSV download button fires a real
* browser download — proving the `/api/export/measurements`
* endpoint is reachable from the browser end-to-end.
*
@@ -22,6 +26,30 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
* download is enough to lock in the wiring without doubling the e2e
* runtime.
*/
/**
* Open Settings → Gesundheitsakte and wait until the page is actually live.
*
* Everything on that page is server-rendered, so the rows are visible long
* before the client boundary that owns them has hydrated — a click or a
* `boundingBox()` taken on `domcontentloaded` alone hits inert or replaced
* markup often enough to matter. `GesundheitsakteSection` is one client
* boundary carrying both the export panel and the sharing card, and the
* sharing card's query only runs once that boundary is live, so its request is
* the signal that the whole surface is wired.
*/
async function openGesundheitsakte(page: Page): Promise<void> {
const boundaryHydrated = page.waitForResponse(
(response) =>
response.request().method() === "GET" &&
new URL(response.url()).pathname === "/api/share-links",
);
await page.goto("/settings/gesundheitsakte", {
waitUntil: "domcontentloaded",
});
await boundaryHydrated;
}
test.describe("Settings → Export & Import", () => {
test.use({ storageState: STORAGE_STATE_PATH });
@@ -45,14 +73,136 @@ test.describe("Settings → Export & Import", () => {
}) => {
// v1.18.0 (S5) — the health-record export is the hero of the
// dedicated, module-gated Gesundheitsakte section.
await page.goto("/settings/gesundheitsakte", {
waitUntil: "domcontentloaded",
});
await openGesundheitsakte(page);
await expect(page.getByTestId("health-record-export-panel")).toBeVisible({
timeout: 10_000,
});
});
test("gives each scope-picker mount its own test ids", async ({ page }) => {
// The Gesundheitsakte page carries two mounts of the same picker: the
// export panel at the top and the share-link create form under `#sharing`.
// Every id inside the picker ends with the surface that mounts it, so an
// assertion binds to one mount instead of resolving to both.
await openGesundheitsakte(page);
await expect(page.getByTestId("report-scope-picker-export")).toHaveCount(1);
await expect(page.getByTestId("report-scope-picker-share")).toHaveCount(1);
for (const id of [
"report-group-row-vitals",
"report-group-check-vitals",
"report-sensitive-tier",
"report-scope-summary",
]) {
await expect(page.getByTestId(`${id}-export`)).toHaveCount(1);
await expect(page.getByTestId(`${id}-share`)).toHaveCount(1);
}
});
test("opens the scope picker expanded on a first run, fenced tier and all", async ({
page,
}) => {
// The three panel states are the release's whole point, and on the first
// one nothing is selected at all. The picker still opens without a click,
// because with an empty scope the standard-report button and the named
// groups are exactly what the person needs in front of them. Asserted
// against the export mount; the share form runs its own picker.
await openGesundheitsakte(page);
await expect(page.getByTestId("report-scope-picker-export")).toBeVisible({
timeout: 10_000,
});
await expect(
page.getByTestId("report-group-row-vitals-export"),
).toBeVisible();
await expect(
page.getByTestId("report-group-row-sensitive-export"),
).toHaveCount(0);
await expect(
page.getByTestId("report-sensitive-tier-export"),
).toBeVisible();
// The fenced tier has no group control — there is no single click that
// turns on more than one sensitive leaf.
await expect(
page.getByTestId("report-group-check-sensitive-export"),
).toHaveCount(0);
await expect(page.getByTestId("report-scope-summary-export")).toBeVisible();
});
test("first run selects nothing and refuses to generate until it does", async ({
page,
}) => {
// The rule the whole surface exists for: nothing reaches the report that
// was not ticked. So a first run starts empty, Generate refuses the press
// and says why, and the one-click action is what makes that cost nothing —
// it fills the standard set and the control comes alive.
await openGesundheitsakte(page);
const generate = page.getByTestId("health-record-generate");
await expect(generate).toBeVisible({ timeout: 10_000 });
await expect(generate).toBeDisabled();
await expect(
page.getByTestId("report-group-row-vitals-export"),
).toContainText("0/7");
await expect(page.getByTestId("report-scope-summary-export")).toContainText(
"Nothing selected yet",
);
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.
await expect(
page.getByTestId("report-group-row-vitals-export"),
).toContainText("3/7");
await expect(
page.getByTestId("report-group-row-body-export"),
).toContainText("2/12");
await expect(
page.getByTestId("report-group-row-cardio-export"),
).toContainText("0/14");
// The fenced tier is not part of any one-click set.
await expect(
page.getByTestId("report-scope-summary-export"),
).not.toContainText("incl.");
});
test("a share link cannot be minted on an empty scope either", async ({
page,
}) => {
await openGesundheitsakte(page);
const create = page.getByTestId("share-create-submit");
await expect(create).toBeVisible({ timeout: 10_000 });
await page.locator("#share-label").fill("Cardiology");
// A label alone is not a scope: the link would serve a page with nothing
// on it, so the control stays shut until something is chosen.
await expect(create).toBeDisabled();
await expect(page.getByTestId("report-scope-summary-share")).toContainText(
"Nothing selected yet",
);
await page.getByTestId("report-apply-standard-share").click();
await expect(create).toBeEnabled();
});
test("clears the tap floor on the group rows at 390 px", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await openGesundheitsakte(page);
const row = page.getByTestId("report-group-row-vitals-export");
await expect(row).toBeVisible({ timeout: 10_000 });
const box = await row.boundingBox();
expect(box).not.toBeNull();
expect(box!.height).toBeGreaterThanOrEqual(44);
});
test("opens a group's leaf grid from its chevron", async ({ page }) => {
await openGesundheitsakte(page);
const row = page.getByTestId("report-group-row-vitals-export");
await expect(row).toBeVisible({ timeout: 10_000 });
// Collapsed: the leaves are behind the disclosure, which is what makes a
// repeat run two interactions.
await expect(page.getByTestId("report-leaf-PULSE-export")).toHaveCount(0);
await row.getByRole("button", { expanded: false }).click();
await expect(page.getByTestId("report-leaf-PULSE-export")).toBeVisible();
});
test("renders the import surfaces with stable testids", async ({ page }) => {
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
// v1.15.7 (issue #281) — the Apple Health and generic-JSON import
@@ -89,11 +239,18 @@ test.describe("Settings → Export & Import", () => {
expect(Array.isArray(parsed.moodEntries)).toBe(true);
});
test("included-data checklist is collapsed by default and expands on demand", async ({
test("included-data disclosure opens on a first run and still collapses", async ({
page,
}) => {
// v1.18.0 (S5) — the panel (and its disclosure) live on the
// dedicated Gesundheitsakte section now.
// The disclosure that fronts the scope picker on
// `/settings/gesundheitsakte`. An account with no saved selection is a
// first run, and a first run opens it: that is the only place the standard
// template is applied, and a template hidden behind a closed disclosure
// would be opt-out wearing a hat.
//
// Opening by default must not turn the disclosure into decoration, so the
// operable part is asserted in both directions — it closes, taking the
// panel with it, and it reopens.
await page.route("**/api/share-links", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
@@ -105,29 +262,21 @@ test.describe("Settings → Export & Import", () => {
body: JSON.stringify({ data: [], error: null, meta: null }),
});
});
const sharingDataReady = page.waitForResponse(
(response) =>
response.status() === 200 &&
response.request().method() === "GET" &&
new URL(response.url()).pathname === "/api/share-links",
);
await page.goto("/settings/gesundheitsakte", {
waitUntil: "domcontentloaded",
});
await sharingDataReady;
await openGesundheitsakte(page);
const toggle = page.getByTestId("health-record-included-data-toggle");
const panel = page.getByTestId("health-record-included-data-panel");
await expect(toggle).toBeVisible({ timeout: 10_000 });
// Collapsed on first render — the checklist panel is absent.
// Open on first render, with the checklist panel behind it.
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(panel).toBeVisible();
// Collapses on demand, and the panel goes with it.
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(
page.getByTestId("health-record-included-data-panel"),
).toHaveCount(0);
// Expands on demand.
await expect(panel).toHaveCount(0);
// And back — the disclosure stays a disclosure.
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(
page.getByTestId("health-record-included-data-panel"),
).toBeVisible();
await expect(panel).toBeVisible();
});
test("Measurements CSV download fires a real download event", async ({
+61 -30
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
@@ -45,7 +45,6 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
ntfyGlobal: true,
webPushGlobal: true,
apiGlobal: true,
moodLogGlobal: true,
},
error: null,
}),
@@ -53,6 +52,9 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
);
const recent = new Date(Date.now() - 12 * 60 * 1000).toISOString();
const monthAgo = new Date(
Date.now() - 28 * 24 * 60 * 60 * 1000,
).toISOString();
await page.route("**/api/integrations/status", (route) =>
route.fulfill({
@@ -72,7 +74,7 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
// v1.12.1 — the cards read connection state from this
// consolidated envelope (the per-provider routes were
// dropped from the section). The Withings pill keys on
// `connected`; the moodLog pill keys on `configured`.
// `connected`; a card-less pill keys on `configured`.
configured: true,
connected: true,
connectedAt: "2026-04-01T12:00:00Z",
@@ -81,19 +83,26 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
tokenExpired: false,
scope: "user.info,user.metrics,user.activity",
hasActivityScope: true,
syncHealth: { verdict: "fresh", since: null },
metricFreshness: [
{ type: "WEIGHT", lastSeenAt: recent, stale: false },
],
},
{
integration: "moodlog",
// Configured, card-less, and no longer attempting: the case
// the fallback row exists for. Synthetic since v1.32.33 —
// every shipped provider has a card again, and the row must
// stay proven before the next one is dropped.
integration: "example-provider",
state: "connected",
lastSuccessAt: recent,
lastAttemptAt: recent,
lastError: null,
consecutiveFailures: 0,
configured: true,
enabled: true,
legacyLastSyncedAt: recent,
webhookSecret: "ml_xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
entryCount: 42,
legacyLastSyncedAt: monthAgo,
syncHealth: { verdict: "stalled", since: monthAgo },
metricFreshness: [],
},
// v1.28.x — Strava OAuth card reads off this consolidated
// envelope; without a `strava` entry the new card's query key
@@ -108,6 +117,23 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
configured: false,
available: false,
hasOwnCredentials: false,
syncHealth: { verdict: "disconnected", since: null },
metricFreshness: [],
},
// Nightscout reads the envelope too since the card dropped its
// own status round-trip; without an entry its pill never resolves.
{
integration: "nightscout",
state: "disconnected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
connected: false,
configured: false,
hasToken: false,
allowPrivateHost: false,
syncHealth: { verdict: "disconnected", since: null },
metricFreshness: [],
},
],
},
@@ -133,23 +159,6 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
}),
);
await page.route("**/api/integrations/moodlog/status", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
configured: true,
enabled: true,
lastSyncedAt: recent,
entryCount: 42,
webhookSecret: "ml_xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
},
error: null,
}),
}),
);
await page.goto("/settings/integrations", {
waitUntil: "domcontentloaded",
});
@@ -157,10 +166,27 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
// Wait for the pills to mount (queries resolve after first paint).
// One pill per card: Withings, WHOOP, Fitbit, Google Health, Polar,
// Oura, Strava, Nightscout. (The Garmin info note is not an OAuth card and
// carries no pill.)
// Oura, Strava, Nightscout — plus the fallback row for the configured,
// card-less entry. (The Garmin info note is not an OAuth card and
// carries no pill; the Apple Health card's pill keeps its own testid.)
const pills = page.locator('[data-testid="integration-status-pill"]');
await expect(pills).toHaveCount(8, { timeout: 10_000 });
await expect(pills).toHaveCount(9, { timeout: 10_000 });
// The acceptance case: a configured integration with no card of its own,
// which has stopped even attempting, is visible on the page.
const fallback = page.locator(
'[data-testid="integration-fallback-row"][data-integration="example-provider"]',
);
await expect(fallback).toHaveCount(1);
await expect(
fallback.locator('[data-testid="integration-status-pill"]'),
).toHaveAttribute("data-state", "stalled");
// Card anatomy is uniform now that Nightscout, the OAuth cards and Apple
// Health all carry the divider.
await expect(
page.locator('[data-testid="integration-card-divider"]'),
).toHaveCount(9);
// Withings is connected in this fixture; WHOOP is a BYO-keys card that
// stays dormant until an operator adds credentials, so assert the one
@@ -210,7 +236,12 @@ test.describe("Apple Health guidance", () => {
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { entries: [], lastSyncedAt: null },
data: {
entries: [],
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
metricFreshness: [],
},
error: null,
}),
}),
@@ -224,7 +255,7 @@ test.describe("Apple Health guidance", () => {
await expect(card).toContainText("not a web or OAuth connection");
await expect(page.getByTestId("apple-health-status")).toHaveAttribute(
"data-state",
"setup",
"pending-setup",
);
const importLink = page.getByTestId("apple-health-import-link");
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+90
View File
@@ -0,0 +1,90 @@
/**
* The E2E suite's `test` / `expect` entry point.
*
* Every spec imports from here instead of from `@playwright/test`, and the
* structural guard `src/__tests__/e2e-route-cleanup-guard.test.ts` keeps it
* that way.
*
* ## Why
*
* A spec that registers `page.route(...)` in a `beforeEach` leaves the handler
* armed until the context closes. If the handler is async — `route.fetch()`,
* an `await` before `route.fulfill()` — teardown can close the context while
* one invocation is still in flight. The callback then rejects against a dead
* target, Playwright attributes the rejection to the test that just finished,
* and a test that had already passed is reported as failed. With `retries: 2`
* plus `failOnFlakyTests` on CI, that is a release blocker whose trigger is
* timing, not code.
*
* The remedy Playwright names is `unrouteAll({ behavior: "ignoreErrors" })`
* before the test ends. Written per spec that is 36 copies of the same three
* lines and one forgotten copy away from the same failure. Written as an auto
* fixture it happens for every test in the suite, including specs nobody has
* written yet, and a spec author never has to know it exists.
*
* ## What it covers
*
* - `page.route(...)` on every page in the test's context.
* - `context.route(...)` on the context itself.
* - Page event listeners for the interactive event types a spec subscribes to.
* A listener that awaits something (a `dialog` handler, a `response` handler
* that reads a body) can reject after teardown for exactly the same reason a
* route handler can.
*
* ## What it does not cover
*
* A page that lives in a context the spec created itself — `browser.newContext()`
* inside a helper — is invisible here, because this fixture can only see the
* context Playwright built for the test. `share-documents.spec.ts` is the one
* spec that does this; it owns those contexts and has to clean them up itself.
*/
import { test as base, expect } from "@playwright/test";
/**
* Page event types cleared at teardown. Deliberately not the lifecycle events
* (`close`, `crash`, `frameattached`, …): those describe the teardown we are
* standing in, and nothing in the suite hangs work off them. These are the
* types a spec attaches a handler to, and the ones whose handler can still be
* running when the context goes away.
*/
const OBSERVED_PAGE_EVENTS = [
"console",
"dialog",
"download",
"filechooser",
"pageerror",
"popup",
"request",
"requestfailed",
"requestfinished",
"response",
"websocket",
"worker",
] as const;
export const test = base.extend<{ routeCleanup: void }>({
routeCleanup: [
async ({ context }, use) => {
await use();
// Best-effort: a spec is free to close its own context (or the browser
// can die under it), and a teardown helper must never be the thing that
// turns a green run red. Everything below is a cleanup, so a failure to
// clean up has nothing left to protect.
try {
for (const page of context.pages()) {
await page.unrouteAll({ behavior: "ignoreErrors" });
for (const event of OBSERVED_PAGE_EVENTS) {
await page.removeAllListeners(event, { behavior: "ignoreErrors" });
}
}
await context.unrouteAll({ behavior: "ignoreErrors" });
} catch {
// Target already gone — nothing left to unroute.
}
},
{ auto: true },
],
});
export { expect };
+10 -4
View File
@@ -24,7 +24,8 @@
* broader corpus (`ensureVaultFixture`) supplies the ≥50 documents the picker
* cap test and the not-attached foreign-id probe need.
*/
import { expect, test, type Browser, type Page } from "@playwright/test";
import type { Browser, Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
@@ -50,9 +51,10 @@ interface CreatedShare {
}
/**
* Drive the owner create flow: open the picker, isolate the share-fixture trio
* by search, select all three, create the link, and read the one-time token +
* passphrase off the reveal card. Also asserts the QR promotion.
* Drive the owner create flow: name the link, choose a record scope, open the
* picker, isolate the share-fixture trio by search, select all three, create
* the link, and read the one-time token + passphrase off the reveal card. Also
* asserts the QR promotion.
*/
async function createShareWithDocs(page: Page): Promise<CreatedShare> {
const label = `${SHARE_DOC_PREFIX} ${Date.now()}`;
@@ -66,6 +68,10 @@ async function createShareWithDocs(page: Page): Promise<CreatedShare> {
await page.goto("/settings/sharing");
await sharingDataReady;
await page.locator("#share-label").fill(label);
// A record link starts with an empty scope and cannot be minted on one, so
// the scope is chosen here the way an owner chooses it: one click on the
// named standard set.
await page.getByTestId("report-apply-standard-share").click();
// Open the document picker and isolate the seeded trio.
await page.getByTestId("share-attach-open").click();
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import pg from "pg";
import { E2E_USER, STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
/**
* v1.4.27 MB6 — public surfaces.
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
/**
* /api/version is the public, unauthenticated endpoint that drives
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+149 -242
View File
@@ -1,6 +1,7 @@
{
"common": {
"save": "Speichern",
"conflictReloaded": "Vom Server neu geladen. Bitte nimm deine Änderung noch einmal vor.",
"cancel": "Abbrechen",
"delete": "Löschen",
"undo": "Rückgängig",
@@ -24,6 +25,10 @@
"openTimePicker": "Uhrzeitauswahl öffnen",
"hours": "Stunden",
"minutes": "Minuten",
"feet": "ft",
"inches": "in",
"heightFeet": "Körpergröße in Fuß",
"heightInches": "Körpergröße in Zoll",
"period": "AM/PM",
"am": "AM",
"pm": "PM",
@@ -48,11 +53,15 @@
},
"apiErrors": {
"measurement": {
"create": { "invalid": "Prüfe die Messdaten und versuche es erneut." },
"create": {
"invalid": "Prüfe die Messdaten und versuche es erneut."
},
"duplicate_timestamp": "Für diesen Zeitpunkt ist bereits eine Messung vorhanden."
},
"mood": {
"list": { "invalid": "Die Filter der Stimmungsliste sind ungültig." },
"list": {
"invalid": "Die Filter der Stimmungsliste sind ungültig."
},
"create": {
"invalid": "Prüfe den Stimmungseintrag und versuche es erneut."
},
@@ -66,8 +75,12 @@
}
},
"labs": {
"create": { "invalid": "Prüfe den Laborwert und versuche es erneut." },
"result": { "notFound": "Dieser Laborwert ist nicht mehr verfügbar." },
"create": {
"invalid": "Prüfe den Laborwert und versuche es erneut."
},
"result": {
"notFound": "Dieser Laborwert ist nicht mehr verfügbar."
},
"biomarker": {
"notFound": "Dieser Biomarker ist nicht mehr verfügbar.",
"duplicate": "Ein Biomarker mit diesem Namen ist bereits vorhanden.",
@@ -97,7 +110,9 @@
"complete": {
"invalid": "Prüfe die Profildaten und versuche es erneut."
},
"user": { "notFound": "Dein Konto wurde nicht gefunden." },
"user": {
"notFound": "Dein Konto wurde nicht gefunden."
},
"step": {
"rateLimited": "Zu viele Versuche. Warte kurz und versuche es erneut.",
"invalid": "Dieser Onboarding-Schritt konnte nicht gespeichert werden.",
@@ -155,16 +170,6 @@
"footerDisclaimer1": "Dieser Bericht wurde automatisch aus selbst erfassten Daten generiert und dient ausschließlich zur Unterstützung",
"footerDisclaimer2": "des Arzt-Patienten-Gesprächs. Er ersetzt keine ärztliche Diagnose. Korrelationen implizieren keine Kausalität.",
"footerSource": "Quelle: HealthLog | Zeitzone: {timezone} | Erstellt: {timestamp}",
"typeWeight": "Körpergewicht",
"typeBpSys": "Systolischer Blutdruck",
"typeBpDia": "Diastolischer Blutdruck",
"typePulse": "Ruhepuls",
"typeBodyFat": "Körperfettanteil",
"typeSleep": "Schlafdauer",
"typeSteps": "Aktivität (Schritte)",
"typeTotalBodyWater": "Gesamtkörperwasser",
"typeBoneMass": "Knochenmasse",
"typeOxygenSaturation": "Sauerstoffsättigung",
"unitSteps": "Schritte",
"bmiUnderweight": "Untergewicht",
"bmiNormal": "Normalgewicht",
@@ -200,8 +205,6 @@
"summaryTrend": "{label}: zuletzt {latest} {arrow}",
"summaryAdherence": "Medikamenten-Therapietreue: {rate}% (gewichteter Mittelwert).",
"chartsTitle": "Trend-Diagramme",
"aiSummaryTitle": "KI-Zusammenfassung — nicht klinisch validiert",
"aiSummaryDisclaimer": "Dieser Abschnitt wurde automatisch von einem KI-Modell erzeugt und ist nicht klinisch validiert. Er ersetzt keine ärztliche Beurteilung.",
"packageReadme": "HealthLog Gesundheitsakte\n\nDieses Paket enthält:\n- report.pdf: den Gesundheitsbericht für die ärztliche Vorlage.\n- bundle.json: dieselben Daten als HL7-FHIR-R4-Dokument (Bundle), ePA-kompatibel.\n\nErstellt mit HealthLog.",
"wellnessTitle": "Wellness-Zusammenfassung",
"wellnessDisclaimer": "Dies sind beschreibende Wellness-Werte, die aus den erfassten Signalen berechnet werden (0–100). Sie sind keine klinischen Beurteilungen oder Diagnosen.",
@@ -360,6 +363,9 @@
"errorRegistrationDisabled": "Kein Konto passt zu dieser Identität, und neue Registrierungen sind derzeit geschlossen.",
"errorFailed": "Single Sign-On ist fehlgeschlagen. Bitte versuche es erneut."
},
"native": {
"signingInToApp": "Anmeldung bei der HealthLog-App"
},
"mfa": {
"title": "Zwei-Faktor-Authentifizierung",
"totpHint": "Gib den 6-stelligen Code aus deiner Authenticator-App ein.",
@@ -451,6 +457,7 @@
"layoutReset": "Auf Defaults zurücksetzen",
"layoutSaveSuccess": "Dashboard-Layout gespeichert",
"layoutSaveError": "Layout konnte nicht gespeichert werden",
"layoutConflictReloaded": "Layout wurde vom Server neu geladen. Bitte nimm deine Änderung erneut vor.",
"layoutResetSuccess": "Auf Standard-Layout zurückgesetzt",
"layoutUsingDefaults": "Standard-Layout aktiv",
"layoutCustomized": "Eigenes Layout aktiv",
@@ -892,7 +899,6 @@
"emptyDescription": "Lege Medikamente, Dosierungen und Einnahmefenster an, um die Therapietreue zu erfassen.",
"loadFailed": "Medikamente konnten nicht geladen werden",
"retryLoad": "Erneut laden",
"name": "Name",
"inactive": "Pausiert",
"intakeHistory": "Einnahme-Verlauf",
"openDetailPage": "Medikament-Detailseite öffnen",
@@ -1195,8 +1201,6 @@
"dosesPerUnitLabel": "Dosen pro Einheit",
"dosesPerUnitPlaceholder": "z. B. 4",
"dosesPerUnitHint": "Dosen pro Pen / Ampulle — leer lassen, wenn kein Bestand getrackt wird.",
"injectionSiteToggle": "Injektionsstellen-Rotation",
"injectionSiteHint": "Optionaler Rotationshelfer. Die Stelle jeder Dosis wird beim Eintragen der Einnahme erfasst.",
"short": "Dosis",
"unitsPerDoseLabel": "Einheiten pro Dosis",
"unitsPerDoseHint": "z. B. 2 Tabletten à 2 mg für eine 4-mg-Dosis – oder ½ Tablette bei geteilter Dosis"
@@ -1247,7 +1251,6 @@
"remindersDescription": "Push, Telegram oder ntfy — je nach deinen Einstellungen.",
"noChannelHint": "Erinnerungen brauchen einen Benachrichtigungskanal — aktuell ist keiner aktiv.",
"noChannelHintLink": "Benachrichtigungen einrichten",
"multiScheduleNote": "Dieses Medikament hat mehrere Zeitpläne. Den Detail-Editor öffnen wir in einer späteren Version.",
"short": "Fertig"
}
},
@@ -1880,7 +1883,7 @@
"levelShift": "Um {date} wurde eine Verschiebung des Niveaus erkannt.",
"plateau": {
"title": "Gewichts-Plateau auf aktueller Dosis",
"evidence": "Gewichtsveränderung von {delta} kg in den letzten {window} Tagen ({readings} Messungen) — seit {days} Tagen auf {dose}."
"evidence": "Gewichtsveränderung von {delta} {unit} in den letzten {window} Tagen ({readings} Messungen) — seit {days} Tagen auf {dose}."
},
"retarget": {
"label": "Nicht der richtige Messwert?",
@@ -2113,7 +2116,6 @@
"emptyDescription": "Insights erscheinen, sobald du ein paar Messwerte erfasst hast. Beginne mit Gewicht oder Blutdruck.",
"emptyAddMeasurement": "Messwert erfassen",
"editMode": {
"open": "Anpassen",
"done": "Fertig",
"reset": "Zurücksetzen",
"title": "Übersicht anpassen",
@@ -2140,16 +2142,6 @@
"assessmentPreparing": "Einschätzung wird vorbereitet…",
"noProviderConfigured": "Verbinde einen KI-Anbieter, um hier eine Einschätzung zu deinen Werten zu sehen.",
"noProviderAction": "Zu den KI-Einstellungen",
"startAnalysis": "Einschätzung starten",
"refreshAnalysis": "Einschätzung aktualisieren",
"keyTakeaway": "Das Wichtigste",
"findingsTitle": "Befunde",
"heroFindingPositive": "Wichtigster Befund",
"heroFindingNeutral": "Wichtigster Befund",
"heroFindingAttention": "Beachten",
"heroFindingWarning": "Wichtigster Alarm",
"correlationsTitle": "Zusammenhänge",
"recommendationsTitle": "Empfehlungen",
"personalRecord": {
"badge": "PR",
"tooltip": "Persönlicher Rekord: {value} {unit} am {date}"
@@ -2157,53 +2149,33 @@
"recommendation": {
"source": "Quelle",
"viewSource": "Leitlinie in neuem Tab öffnen",
"rationale": "Warum diese Empfehlung",
"rationaleWindow": "Fenster",
"rationaleComparedTo": "Verglichen mit",
"rationaleDeviation": "Abweichung",
"rationaleExpand": "Begründung anzeigen",
"rationaleCollapse": "Begründung ausblenden",
"legacyPayloadCta": "Insights aktualisiert — Neugenerierung lädt die neuen Erklärfunktionen",
"feedbackHelpful": "Hilfreich",
"feedbackNotHelpful": "Nicht hilfreich",
"feedbackThanks": "Danke für dein Feedback",
"feedbackConfirmed": "Feedback gespeichert",
"feedbackAlreadyRated": "Bereits bewertet",
"feedbackError": "Dein Feedback konnte nicht gespeichert werden. Bitte versuche es erneut.",
"confidence": "Vertrauen",
"confidenceAria": "Vertrauen: {value} von 100",
"confidenceHigh": "Hohes Vertrauen",
"confidenceMedium": "Mittleres Vertrauen",
"confidenceLow": "Geringes Vertrauen — wenige Datenpunkte",
"confidenceDraft": "Entwurf"
},
"dataFoundation": "Datengrundlage",
"dataGaps": "Datenlücken:",
"lastUpdated": "Zuletzt aktualisiert",
"confidenceHoch": "Hoch",
"confidenceMittel": "Mittel",
"confidenceGering": "Gering",
"loadError": "Auswertungen konnten nicht geladen werden",
"heroPersonalBaseline": "Basierend auf deinen letzten 90 Tagen",
"heroRegenerate": "Neu generieren",
"heroRegenerating": "Wird generiert",
"regenerateAnalysis": "Einschätzung neu erstellen",
"regenerateSuccess": "Einschätzung wurde neu erstellt",
"warmStarted": "Einschätzungen werden im Hintergrund erstellt",
"narrativeTitle": "Rückblick",
"narrativeWeek": "Diese Woche",
"narrativeMonth": "Dieser Monat",
"narrativePreparing": "Deine Zusammenfassung wird erstellt…",
"narrativeUpdating": "Wird aktualisiert…",
"narrativeProvenanceLabel": "Wie das zustande kam",
"narrativeProvenanceMethod": "Eine Zusammenfassung des Zeitraums in einfacher Sprache, ausschließlich aus deinen erfassten Werten und den Zusammenhängen, die die statistische Mehrfachvergleichskorrektur überstanden haben. Beschreibend, nie eine Ursache.",
"narrativeProvenanceMetrics": "Basierend auf: {metrics}",
"narrativeProvenanceWindow": "Zeitfenster: {from} bis {to}",
"heroFallbackSubtitle": "Täglicher Blick auf deine Trends — direkt aus den Zahlen, die du protokollierst.",
"heroGreetingMorning": "Guten Morgen",
"heroGreetingAfternoon": "Guten Tag",
"heroGreetingEvening": "Guten Abend",
"heroComingSoonTooltip": "Kommt bald",
"heroActionAskCoach": "Coach fragen",
"healthScore": {
"label": "Gesundheitsscore",
@@ -2216,14 +2188,12 @@
"componentMood": "Stimmung",
"componentCompliance": "Therapietreue",
"deltaExplainer": {
"trigger": "Was bedeutet diese Veränderung?",
"title": "So wird die Veränderung berechnet",
"description": "Veränderung gegenüber letzter Woche: {delta}",
"body": "Der Score kombiniert Blutdruck, Gewicht, Stimmung und Therapietreue. Wir vergleichen den heutigen Stand mit demselben Zeitpunkt vor einer Woche. Erfasse eine frische Messung oder einen Stimmungs-Check, um den Wert nach oben zu schubsen."
},
"provenance": {
"toggle": "Zusammensetzung",
"weightLabel": "Anteil",
"mixedBanner": "Einige Komponenten kombinieren mehrere Quellen. Die Aufschlüsselung zeigt den aktuellsten Wert je Komponente.",
"footnote": "Anteile sind nach verfügbaren Daten neu skaliert.",
"asOfLabel": "Stand {date}",
@@ -2287,10 +2257,6 @@
"viewDetail": "{metric} im Detail"
},
"vo2Max": {
"statLatest": "Aktuell",
"statMin": "Min",
"statMax": "Max",
"empty": "Noch keine VO₂-Max-Daten — synchronisiere Apple Health, um diese Ansicht zu füllen.",
"cardioLinkTitle": "Cardio-Fitness (VO₂ max)",
"cardioLinkBody": "VO₂ max ist das zentrale Maß für die aerobe Fitness. Das vollständige Diagramm und die Einschätzung findest du auf der eigenen Seite.",
"cardioLinkCta": "Cardio-Fitness öffnen"
@@ -2347,6 +2313,7 @@
"send": "Senden",
"stop": "Stopp",
"thinking": "Denke nach…",
"unverifiedFiguresNotice": "Einige Zahlen ließen sich nicht mit deinen Daten abgleichen und wurden weggelassen.",
"composerPlaceholder": "Frag mich etwas…",
"nudgeBubbleLabel": "Neue Nachricht vom Coach — Chat öffnen",
"fabLabel": "Coach öffnen",
@@ -2754,125 +2721,68 @@
"gewichtDescription": "Gewichtsverlauf gegen das aus deiner Größe abgeleitete gesunde Band, mit Einschätzung.",
"pulsDescription": "Pulsverlauf gegenüber dem persönlichen Karvonen-Zielband, mit Einschätzung.",
"stimmungDescription": "Tägliche Stimmungswerte im Zeitverlauf mit einer textbasierten Einschätzung.",
"stimmungEmptyTitle": "Erste Stimmung erfassen",
"stimmungEmptyDescription": "Stimmungs-Insights erscheinen, sobald du einige Einträge protokolliert hast. Tippe unten, um zu beginnen.",
"stimmungEmptyAction": "Stimmungs-Tagebuch öffnen",
"medikamenteDescription": "Einnahme-Kalender pro Medikament plus eine textbasierte Einschätzung.",
"medikamenteEmptyTitle": "Erstes Medikament hinzufügen",
"medikamenteEmptyDescription": "Therapietreue-Insights brauchen mindestens ein erfasstes Medikament.",
"medikamenteEmptyAction": "Medikamente öffnen",
"bmiDescription": "BMI aus deinem Gewicht und deiner Größe abgeleitet, mit Einschätzung.",
"target": {
"adjustLink": "Zielbereich anpassen"
},
"explainer": {
"trigger": "Was ist {title}?",
"bloodPressureTitle": "Blutdruck",
"bloodPressureBody": "Der Blutdruck ist die Kraft, die das Blut auf die Gefäßwände ausübt, angegeben als systolisch zu diastolisch (Spitzenwert beim Herzschlag über dem Ruhewert dazwischen). Dauerhaft hohe Werte belasten Herz und Gefäße auf Dauer. Als allgemeine Orientierung gilt nach europäischer Leitlinie (ESH 2023) unter 120/80 als optimal und ab 140/90 als Bluthochdruck; die US-Leitlinie (ACC/AHA 2017) setzt mit 130–139/80–89 (Stadium 1) eine strengere Grenze. Einordnung immer aus einer ruhigen, sitzenden Messung, nie aus einem einzelnen Wert.",
"pulseTitle": "Puls",
"pulseBody": "Der Puls gibt an, wie oft das Herz pro Minute schlägt. Eine Einzelmessung spiegelt den Moment wider – in Ruhe liegt er meist bei 60–100 Schlägen pro Minute und steigt natürlicherweise bei Bewegung, Stress oder Koffein.",
"weightTitle": "Gewicht",
"weightBody": "Das Körpergewicht ist eines der einfachsten Signale für die langfristige Energiebilanz und die allgemeine Gesundheit. Der Trend über Wochen zählt weit mehr als ein einzelner Wert, der mit Flüssigkeit, Ernährung und Tageszeit schwankt.",
"bmiTitle": "Body-Mass-Index (BMI)",
"bmiBody": "Der BMI ist dein Gewicht geteilt durch deine Körpergröße zum Quadrat und ordnet dich einer groben Gewichtskategorie zu. Er ist ein Orientierungswert, keine Diagnose, und unterscheidet nicht zwischen Muskeln und Fett.",
"sleepTitle": "Schlaf",
"sleepBody": "Die Schlafdauer ist die tatsächlich geschlafene Zeit pro Nacht, aufgeteilt in Leicht-, Tief- und REM-Schlaf. Die meisten Erwachsenen brauchen etwa sieben bis neun Stunden; gleichmäßige Menge und Qualität unterstützen Erholung, Stimmung und Stoffwechsel.",
"moodTitle": "Stimmung",
"moodBody": "Die Stimmungserfassung hält auf einer einfachen Skala fest, wie du dich fühlst. Regelmäßiges Protokollieren zeigt Muster – wie Schlaf, Aktivität oder Medikamente mit dem Befinden zusammenhängen –, die ein einzelner Tag nie offenbart.",
"medicationsTitle": "Medikamenten-Therapietreue",
"medicationsBody": "Die Therapietreue zeigt, wie zuverlässig du deine Medikamente wie verordnet einnimmst, hier als Anteil der erwarteten Dosen, die du erfasst hast. Eine gleichmäßige Einnahme entscheidet oft darüber, ob eine Therapie wie beabsichtigt wirkt.",
"restingHrTitle": "Ruhepuls",
"restingHrBody": "Der Ruhepuls ist die Herzfrequenz in völliger Ruhe, meist über Nacht gemessen. Ein niedriger Ruhepuls deutet in der Regel auf eine bessere Herz-Kreislauf-Fitness hin, ein steigender Trend kann ein frühes Zeichen für Stress oder Krankheit sein.",
"hrvTitle": "Herzfrequenzvariabilität (HRV)",
"hrvBody": "Die HRV ist die kleine Schwankung der Zeit zwischen aufeinanderfolgenden Herzschlägen. Eine höhere Variabilität spiegelt meist ein gut erholtes, anpassungsfähiges Nervensystem wider, ein anhaltender Rückgang kann auf Erschöpfung, Stress oder mangelnde Erholung hinweisen.",
"oxygenSaturationTitle": "Sauerstoffsättigung (SpO₂)",
"oxygenSaturationBody": "Die SpO₂ ist der Prozentsatz der roten Blutkörperchen, die Sauerstoff transportieren. Als allgemeine Orientierung sind Werte von 95–100 % in Ruhe normal (StatPearls/NIH 2023); Werte dauerhaft unter 90 % verdienen Aufmerksamkeit, kurze nächtliche Abfälle sind häufig und über ~98 % bringt nichts. Optische Sensoren können die wahre Sättigung bei dunklerer Haut überschätzen (US FDA 2024) — ein einzelner normnaher Wert ist daher keine Entwarnung; bei Sorge warm und ruhig erneut messen.",
"bodyTemperatureTitle": "Körpertemperatur",
"bodyTemperatureBody": "Die Körpertemperatur spiegelt deinen Kernwärmezustand wider, normalerweise um 37 °C mit kleinen Tagesschwankungen. Ein anhaltender Anstieg kann auf eine Infektion hindeuten; nächtliche Messungen am Handgelenk erfassen vor allem relative Änderungen, keinen klinischen Wert.",
"activeEnergyTitle": "Aktive Energie",
"activeEnergyBody": "Die aktive Energie sind die Kalorien, die du durch Bewegung verbrennst, zusätzlich zum Grundumsatz in Ruhe. Sie ist ein direktes Maß dafür, wie körperlich aktiv dein Tag war.",
"workoutsTitle": "Workouts",
"workoutsBody": "Workouts sind die strukturierten Trainingseinheiten, die deine Geräte mit Art, Dauer und Energie aufzeichnen. Ihre Erfassung zeigt, wie sich regelmäßiges Training über Wochen summiert und wie es mit deinen anderen Gesundheitssignalen zusammenhängt.",
"respiratoryRateTitle": "Atemfrequenz",
"respiratoryRateBody": "Die Atemfrequenz ist die Anzahl der Atemzüge pro Minute. In Ruhe atmen die meisten Erwachsenen etwa zwölf- bis zwanzigmal pro Minute; ein anhaltender Anstieg kann mit Krankheit, Anstrengung oder Stress einhergehen.",
"bodyWaterTitle": "Gesamtkörperwasser",
"bodyWaterBody": "Das Gesamtkörperwasser ist der Anteil deines Körpergewichts, der aus Flüssigkeit in und um die Zellen besteht. Es schwankt mit Flüssigkeitshaushalt, Ernährung und Aktivität, daher sagt der Trend über Wochen mehr aus als ein einzelner Wert.",
"boneMassTitle": "Knochenmasse",
"boneMassBody": "Die Knochenmasse ist das geschätzte Gewicht der Mineralien in deinem Skelett. Waagengestützte Schätzungen sind ungefähr, doch ein gleichbleibender oder steigender Trend unterstützt die langfristige Knochenstärke.",
"fatFreeMassTitle": "Fettfreie Masse",
"fatFreeMassBody": "Die fettfreie Masse ist alles in deinem Körper, das kein Fett ist — Muskeln, Knochen, Organe und Wasser. Sie zu halten oder aufzubauen, während das Gewicht sinkt, deutet meist auf eine gesündere Körperzusammensetzung hin.",
"fatMassTitle": "Fettmasse",
"fatMassBody": "Die Fettmasse ist das Gesamtgewicht des Körperfetts. Etwas Fett ist für Hormone und Energiespeicher unverzichtbar, doch zu viel — besonders am Bauch — erhöht das langfristige Gesundheitsrisiko (WHO 2024).",
"muscleMassTitle": "Muskelmasse",
"muscleMassBody": "Die Muskelmasse ist das geschätzte Gewicht der Muskeln in deinem Körper. Mehr Muskeln unterstützen Kraft, Stoffwechsel und Beweglichkeit und müssen mit zunehmendem Alter aktiv erhalten werden.",
"visceralFatTitle": "Viszeralfett",
"visceralFatBody": "Viszeralfett ist das Fett, das tief im Bauchraum um die Organe gespeichert wird. Anders als das Unterhautfett ist ein hoher Wert eng mit Herzkrankheiten und Typ-2-Diabetes verbunden (AHA, Circulation 2021), daher ist niedriger meist besser. Die Skala zeigt einen einheitenlosen Wert (etwa 1–12 gilt als gesunder Bereich, ab 13 als erhöht); das ist eine andere Skala als die bildgebenden cm²-Schwellenwerte, die in der Klinik verwendet werden, und nicht mit ihnen austauschbar.",
"leanBodyMassTitle": "Magermasse",
"leanBodyMassBody": "Die Magermasse ist dein Gesamtgewicht abzüglich des Körperfetts — Muskeln, Knochen, Organe und Wasser zusammen. Sie neben dem Gewicht zu verfolgen zeigt, ob Veränderungen aus Muskeln oder aus Fett stammen.",
"flightsClimbedTitle": "Etagen gestiegen",
"flightsClimbedBody": "Die gestiegenen Etagen zählen die Stockwerke, die du täglich nach oben gehst, wobei eine Etage etwa drei Metern Höhe entspricht. Es ist ein einfacher Marker für Alltagsaktivität und Beinarbeit.",
"walkingDistanceTitle": "Geh- + Laufstrecke",
"walkingDistanceBody": "Die Geh- und Laufstrecke ist die Strecke, die du täglich zu Fuß zurücklegst. Sie ergänzt die Schrittzahl, indem sie zeigt, wie weit du dich bewegt hast, nicht nur wie oft du getreten bist.",
"walkingSteadinessTitle": "Gehstabilität",
"walkingSteadinessBody": "Die Gehstabilität schätzt, wie stabil und ausgewogen dein Gangbild ist, eingeteilt von OK bis niedrig. Ein niedriger Wert deutet auf ein höheres Sturzrisiko hin und sollte ärztlich besprochen werden.",
"walkingHeartRateTitle": "Herzfrequenz beim Gehen",
"walkingHeartRateBody": "Die Herzfrequenz beim Gehen ist dein durchschnittlicher Puls bei einem gleichmäßigen Spaziergang. Ein über die Zeit niedrigerer Wert spiegelt bei gleicher Anstrengung oft eine bessere Herz-Kreislauf-Fitness wider.",
"walkingAsymmetryTitle": "Gangasymmetrie",
"walkingAsymmetryBody": "Die Gangasymmetrie ist der Anteil der Zeit, in der deine Schritte zwischen linkem und rechtem Bein ungleichmäßig sind. Ein höherer Prozentsatz kann auf Schmerzen, eine Verletzung oder ein Gangproblem hindeuten, das näher betrachtet werden sollte.",
"doubleSupportTimeTitle": "Doppelunterstützungszeit",
"doubleSupportTimeBody": "Die Doppelunterstützungszeit ist der Anteil jedes Gehzyklus, in dem beide Füße gleichzeitig den Boden berühren. Ein höherer Prozentsatz bedeutet meist ein vorsichtigeres, weniger stabiles Gangbild.",
"stepLengthTitle": "Schrittlänge",
"stepLengthBody": "Die Schrittlänge ist der durchschnittliche Abstand von einem Fußauftritt zum nächsten. Eine über die Zeit kürzere Schrittlänge kann Müdigkeit, Vorsicht oder eine veränderte Beweglichkeit widerspiegeln.",
"walkingSpeedTitle": "Gehgeschwindigkeit",
"walkingSpeedBody": "Die Gehgeschwindigkeit ist, wie schnell du dich auf ebenem Untergrund fortbewegst. Sie ist ein gut untersuchter Marker für allgemeine Gesundheit und Beweglichkeit, und ein gleichbleibendes oder steigendes Tempo ist ein gutes Zeichen.",
"pulseWaveVelocityTitle": "Pulswellengeschwindigkeit",
"pulseWaveVelocityBody": "Die Pulswellengeschwindigkeit misst, wie schnell die Druckwelle jedes Herzschlags durch deine Arterien läuft. Eine schnellere Welle weist auf steifere Arterien hin, was mit einem höheren Herz-Kreislauf-Risiko verbunden ist.",
"vascularAgeTitle": "Gefäßalter",
"vascularAgeBody": "Das Gefäßalter schätzt anhand der Arteriensteifigkeit, wie alt sich deine Arterien im Vergleich zu deinem tatsächlichen Alter verhalten. Ein Gefäßalter unter dem echten Alter deutet auf gesündere Blutgefäße hin.",
"environmentalAudioTitle": "Umgebungslautstärke",
"environmentalAudioBody": "Die Umgebungslautstärke erfasst die Lautstärke deiner Umwelt in Dezibel. Anhaltende Belastung über etwa 70 dB kann das Gehör mit der Zeit schädigen, daher sind niedrigere Tagesmittel sicherer.",
"headphoneAudioTitle": "Kopfhörerlautstärke",
"headphoneAudioBody": "Die Kopfhörerlautstärke erfasst in Dezibel, wie laut du über Ohrhörer oder Kopfhörer hörst. Die Lautstärke niedrig zu halten und Pausen einzulegen schützt dein Gehör langfristig.",
"audioEventsTitle": "Laute Geräuschereignisse",
"audioEventsBody": "Laute Geräuschereignisse markieren Momente, in denen der Lärm um dich herum ein Niveau erreicht hat, das bei ausreichender Belastung das Gehör schädigen kann. Häufige Ereignisse sind ein Hinweis, eine ruhigere Umgebung oder Gehörschutz zu suchen.",
"daylightTitle": "Zeit im Tageslicht",
"daylightBody": "Die Zeit im Tageslicht ist, wie lange du dich täglich im Freien im Licht aufhältst. Regelmäßiges Tageslicht unterstützt einen gesunden Schlaf-Wach-Rhythmus und die Stimmung (CDC 2024), und Zeit im Freien ist mit der Augengesundheit verbunden.",
"bloodGlucoseTitle": "Blutzucker",
"bloodGlucoseBody": "Der Blutzucker ist die Zuckermenge in deinem Blut, die wichtigste Energiequelle des Körpers. Er steigt nach dem Essen und pendelt sich zwischen den Mahlzeiten ein; dauerhaft hohe Werte können auf Diabetes oder Prädiabetes hinweisen.",
"skinTemperatureTitle": "Hauttemperatur",
"skinTemperatureBody": "Die Hauttemperatur ist hier die kleine nächtliche Abweichung deiner Handgelenkstemperatur von deinem eigenen Ausgangswert, keine absolute Fiebermessung. Veränderungen können Schlaf, Umgebung, Krankheit oder Zyklusphase widerspiegeln.",
"cardioFitnessTitle": "Cardiofitness (VO₂max)",
"cardioFitnessBody": "VO₂max gibt an, wie viel Sauerstoff dein Körper bei maximaler Anstrengung verwerten kann — der beste Laborindex der Ausdauerfitness, den Wearables indirekt schätzen. Als allgemeine Orientierung: Fitness ist ein abgestufter Prädiktor der Überlebensrate — jeder zusätzliche MET (etwa 3,5 ml/kg/min) geht mit rund 10–25 % geringerer Sterblichkeit einher (AHA, Circulation 2016). Ein höherer oder steigender Trend ist ein starkes Zeichen für eine gute Herz-Kreislauf-Gesundheit.",
"cardioRecoveryTitle": "Erholungspuls",
"cardioRecoveryBody": "Der Erholungspuls gibt an, um wie viele Schläge pro Minute dein Puls in der ersten Minute nach dem Ende einer anstrengenden Belastung fällt. Ein größerer Abfall bedeutet, dass sich dein Herz schneller beruhigt — ein Zeichen guter Fitness.",
"wristTemperatureTitle": "Handgelenktemperatur",
"wristTemperatureBody": "Die Handgelenktemperatur wird im Schlaf gemessen und als Abweichung von deinem persönlichen Ausgangswert angegeben, nicht als absoluter Wert. Ein gleichmäßiger Wert ist normal, ein anhaltender Anstieg kann auf Krankheit, intensives Training oder eine Zyklusveränderung hindeuten.",
"fallsTitle": "Stürze",
"fallsBody": "Hier werden die von deinem Wearable erkannten schweren Stürze gezählt. Null ist das Ziel; eine steigende Zahl ist ein Hinweis auf ein erhöhtes Sturzrisiko, das du — besonders im höheren Alter — mit einer Ärztin oder einem Arzt besprechen solltest (CDC STEADI 2024).",
"sixMinuteWalkTitle": "6-Minuten-Gehstrecke",
"sixMinuteWalkBody": "Dies ist eine Schätzung, wie weit du in sechs Minuten gehen könntest — ein etablierter klinischer Test für Mobilität und kardiopulmonale Ausdauer. Der Wearable-Wert ist ein Näherungswert, nicht der überwachte klinische Test; aussagekräftig ist der gleichbleibende oder steigende Trend.",
"stairAscentSpeedTitle": "Treppensteig-Tempo",
"stairAscentSpeedBody": "Dies gibt an, wie schnell du eine Treppe hinaufgehst. Es ist ein empfindlicher Marker für Beinkraft und Mobilität — ein gleichbleibendes oder steigendes Tempo ist gut, ein sinkendes solltest du im Blick behalten.",
"stairDescentSpeedTitle": "Treppab-Tempo",
"stairDescentSpeedBody": "Dies gibt an, wie schnell du eine Treppe hinabgehst. Zusammen mit dem Steig-Tempo zeigt es Beinkontrolle und Gleichgewicht — ein gleichmäßiges Tempo ist gut, ein sinkendes solltest du im Blick behalten.",
"breathingDisturbancesTitle": "Atemaussetzer im Schlaf",
"breathingDisturbancesBody": "Ein Wearable kann schätzen, wie oft deine Atmung im Schlaf unterbrochen war. Ein niedriger Index ist besser; ein als erhöht eingestufter Wert — vor allem über viele Nächte — sollte mit einer Ärztin oder einem Arzt besprochen werden. Es ist ein Screening-Näherungswert, keine Schlafapnoe-Diagnose.",
"stepsTitle": "Schritte",
"stepsBody": "Die Schrittzahl ist die Gesamtzahl der Schritte, die du täglich gehst. Sie ist das einfachste Maß für Alltagsbewegung, und eine stetige Gewohnheit zählt mehr als ein einzelner großer Tag. Als allgemeine Orientierung fällt das Sterblichkeitsrisiko deutlich Richtung etwa 8.000 Schritte pro Tag, mit weiterem Nutzen bis rund 12.000 (Saint-Maurice JAMA 2020); bei älteren Menschen liegt der optimale Wert niedriger. Die „10.000“ ist ein Werbeslogan, keine Forschung.",
"readinessBody": "Die Tagesform ist ein einzelner Tageswert dafür, wie bereit dein Körper für den Tag ist — er verbindet Ruhepuls, Herzfrequenzvariabilität, Schlaf und die Beständigkeit deiner letzten Nächte. Es ist ein Geräte-Näherungswert, der an deinem eigenen Ausgangswert gemessen wird, keine klinische Messung, und zwischen Geräten nicht vergleichbar. Ein höherer Wert heißt erholt und belastbar; ein niedrigerer ist ein sanfter Hinweis, einen Gang zurückzuschalten.",
"sleepScoreBody": "Dein Schlafscore fasst eine ganze Nacht in einer Zahl zusammen — aus Dauer, Effizienz, Regelmäßigkeit und der Verteilung von Leicht-, Tief- und REM-Schlaf. Achte eher auf das Muster über Wochen als auf einen perfekten Wert in einer einzelnen Nacht.",
"recoveryBody": "Die Erholung zeigt, wie gut sich dein Körper regeneriert hat — gestützt auf nächtliche Signale wie Herzfrequenzvariabilität und Ruhepuls. Es ist ein geräteabgeleiteter Näherungswert an deinem eigenen Ausgangswert, keine klinische Messung, und zwischen Geräten nicht vergleichbar. Ein hoher Wert ist grünes Licht, mehr zu geben; ein niedrigerer deutet an, dass dein System noch aufbaut.",
"strainBody": "Die Belastung erfasst, wie viel kardiovaskuläre und körperliche Last du deinem Körper zugemutet hast — von Workouts bis zu einem aktiven Tag auf den Beinen. Es ist ein geräteabgeleiteter Index, gemessen an deinem eigenen üblichen Aufwand, keine klinische Messung, und der Kennwert ist zwischen Geräten nicht vergleichbar.",
"stressBody": "Stress ist hier eine Geräte-Heuristik aus Mustern der Herzfrequenzvariabilität — keine Cortisol-Messung und nicht, wie du dich bewusst fühlst, und auch kein klinischer Stress-Score. Lies ihn an deinem eigenen Ausgangswert; zwischen Geräten ist er nicht vergleichbar. Ein niedrigerer Wert deutet auf einen ruhigen, gut regulierten Zustand hin; ein höherer ist eine Einladung, Erholung einzuplanen.",
"recoveryPageTitle": "Erholung",
"recoveryPageBody": "Die Erholung fasst die nächtlichen Signale deines Wearables zusammen – Recharge, Belastung, Load sowie deine Herzfrequenz- und Energiewerte des Tages – und zeigt, wie gut sich dein Körper erholt hat. Achte eher auf das Muster über mehrere Tage als auf einen einzelnen Wert; ein schwächerer Wert ist eine Einladung, kürzerzutreten, kein Alarm.",
"pulsePressureTitle": "Pulsdruck",
"pulsePressureBody": "Der Pulsdruck ist die Differenz zwischen deinem systolischen und diastolischen Wert (systolisch minus diastolisch). Als allgemeine Orientierung sind etwa 40 mmHg in Ruhe typisch; eine große Differenz über rund 60 mmHg im mittleren Alter kann auf versteifte Arterien hinweisen, eine kleine Differenz unter etwa 25 mmHg kann mit niedrigem Schlagvolumen einhergehen (StatPearls/NIH 2023). Ein Gesprächsanlass, keine Diagnose.",
"meanArterialPressureTitle": "Mittlerer arterieller Druck",
"meanArterialPressureBody": "Der mittlere arterielle Druck ist der durchschnittliche Druck, den deine Organe über einen Herzschlag sehen – grob der diastolische Wert plus ein Drittel der Differenz zum systolischen. Als allgemeine Orientierung liegt ein Ruhewert bei etwa 70–100 mmHg (StatPearls/NIH 2023). Die oft genannte Grenze von ~60 mmHg ist ein Konzept der Akutmedizin, kein Alarm für die Selbstmessung.",
"cycleEducationBody": "Dein Zyklus durchläuft vier hormongesteuerte Phasen, und seine Länge und Regelmäßigkeit sind ein gut zugängliches Fenster auf die allgemeine Gesundheit. Als allgemeine Orientierung dauern Zyklen bei Erwachsenen typischerweise 21–35 Tage (klinische Referenzen nennen 24–38), mit einer Blutung von rund fünf Tagen (ACOG 2015). Die Lutealphase ist der stabile Anker, daher schätzt man den Eisprung am besten durch Rückrechnen von der nächsten erwarteten Periode – nie über einen festen „Tag 14“. Ein vorhergesagtes fruchtbares Fenster ist eine Schätzung, die mehrere erfasste Zyklen braucht und ungefähr bleibt, und die Basaltemperatur bestätigt den Eisprung im Nachhinein, statt ihn vorherzusagen. Dies ist keine Verhütungsmethode.",
"sleepHygieneBody": "Einige evidenzbasierte Gewohnheiten unterstützen den Schlaf: gleichbleibende Zubett- und Aufstehzeiten auch am Wochenende, helles Morgenlicht und gedämpfte Abende, ein kühles, dunkles und ruhiges Zimmer sowie der Verzicht auf Koffein am Nachmittag, späten Alkohol und große späte Mahlzeiten (CDC 2024). Regelmäßigkeit sagt die Gesundheit mindestens so stark voraus wie die Dauer, und die Gesamtschlafzeit eines Wearables ist weit verlässlicher als seine Aufteilung in Tief-, Leicht- und REM-Schlaf.",
"referenceGuidance": "Allgemeine Orientierung, keine medizinische Beratung. Referenzbereiche sind Populationswerte, die sich mit Alter, Geschlecht und Erkrankungen verschieben – dein eigener Verlauf über die Zeit führt die Einordnung."
"sleepHygieneBody": "Einige evidenzbasierte Gewohnheiten unterstützen den Schlaf: gleichbleibende Zubett- und Aufstehzeiten auch am Wochenende, helles Morgenlicht und gedämpfte Abende, ein kühles, dunkles und ruhiges Zimmer sowie der Verzicht auf Koffein am Nachmittag, späten Alkohol und große späte Mahlzeiten (CDC 2024). Regelmäßigkeit sagt die Gesundheit mindestens so stark voraus wie die Dauer, und die Gesamtschlafzeit eines Wearables ist weit verlässlicher als seine Aufteilung in Tief-, Leicht- und REM-Schlaf."
},
"bpDerived": {
"pulsePressure": "Der Pulsdruck (die Differenz zwischen deinen Werten) beträgt etwa {value} mmHg – rund 40 ist in Ruhe typisch.",
@@ -2906,16 +2816,6 @@
"compositionTitle": "Phasen-Verteilung",
"compositionSubtitle": "Letzte {nights} Nächte",
"compositionAriaLabel": "Schlafphasen-Verteilung über {nights} Nächte",
"hypnogram": {
"title": "Letzte Nacht",
"subtitle": "{asleep} geschlafen",
"ariaLabel": "Schlafphasen-Verlauf der letzten Nacht: {asleep} geschlafen, {awakenings} Wachphasen",
"asleepTotal": "Geschlafen {value}",
"inBedTotal": "Im Bett {value}",
"awakenings": "{count} Wachphasen",
"source": "Quelle: {source}",
"estimateNote": "Die Phasen-Zeiten sind geschätzt — diese Quelle liefert Summen je Phase, keinen gemessenen Verlauf."
},
"sourceDiscrepancy": {
"tooltipTitle": "Quellen weichen ab"
},
@@ -2970,7 +2870,6 @@
"loadError": "Dein Schlafrhythmus konnte gerade nicht geladen werden. Er erscheint wieder, sobald die Verbindung steht."
}
},
"correlations": "Korrelationen",
"medicationCompliance": "Therapietreue",
"dayStreak": "{count} Tage Serie",
"dayStreakOne": "{count} Tag Serie",
@@ -2979,19 +2878,12 @@
"taken": "genommen",
"skipped": "übersprungen",
"missed": "verpasst",
"generalStatusBadge": {
"good": "Gut",
"watch": "Beobachten",
"critical": "Kritisch"
},
"assessmentTitle": "Einschätzung",
"restMode": {
"title": "Ruhemodus",
"body": "Eine Krankheits- oder Beschwerdephase ist aktiv, daher spiegelt dies eine Erholungsphase wider. Dein Score wird dafür nicht gesenkt.",
"bodySince": "Seit {since} ist eine Krankheits- oder Beschwerdephase aktiv, daher spiegelt dies eine Erholungsphase wider. Dein Score wird dafür nicht gesenkt."
},
"assessmentShowMore": "Mehr anzeigen",
"assessmentShowLess": "Weniger anzeigen",
"bloodPressureSectionTitle": "Blutdruck",
"weightSectionTitle": "Gewicht",
"pulseSectionTitle": "Puls",
@@ -2999,8 +2891,6 @@
"complianceNoData": "Keine Daten zur Therapietreue verfügbar",
"generate": "Neue Analyse erstellen",
"moodSectionTitle": "Stimmung",
"confidence": "Konfidenz",
"cached": "Gecacht",
"emptyState": {
"bloodPressure": {
"title": "Noch keine Blutdruckwerte",
@@ -3049,11 +2939,6 @@
"title": "Noch keine Workouts",
"description": "Öffne die Apple-Health-App auf dem iPhone, damit Workouts hier synchronisiert werden. Withings-Aktivitätsimport pflegt Geh- und Radtouren mit ein."
},
"row": {
"duration": "Dauer",
"distance": "Distanz",
"energy": "Aktive Energie"
},
"detail": {
"backToList": "Zurück zu Workouts",
"statsTitle": "Werte",
@@ -3066,13 +2951,9 @@
"statsStepCount": "Schritte",
"statsElevation": "Höhenmeter",
"statsPace": "Pace",
"statsSource": "Quelle",
"routeTitle": "Strecke",
"routeUnavailable": "Für dieses Workout liegt keine GPS-Strecke vor.",
"hrChartTitle": "Herzfrequenz",
"hrChartUnavailable": "Sekundengenaue Herzfrequenz-Werte werden für dieses Workout nicht gespeichert.",
"notFound": "Workout nicht gefunden.",
"loading": "Workout wird geladen…",
"hrFromPulse": "Aus deinen Herzfrequenzdaten rund um diese Einheit",
"hrAxisMinutes": "Min.",
"sportAverageLabel": "Dein aktueller Durchschnitt:",
@@ -3544,7 +3425,6 @@
"title": "Was auffällt",
"subheading": "Entdeckte Zusammenhänge",
"footer": "Herausgefiltert aus {tested} Tag-zu-Tag-Paaren, die über all deine Werte getestet wurden, nach der Korrektur für Fehlentdeckungen.",
"empty": "Bisher hat kein Stimmungszusammenhang die statistische Hürde genommen. Logge weiter.",
"disclaimer": "Statistisch gefiltert, aber weiterhin beobachtend – ein Muster, das einen Blick wert ist, keine Ursache.",
"moodUp": "Höheres {factor} geht in deinen Daten tendenziell mit besserer Stimmung am Folgetag einher.",
"moodDown": "Höheres {factor} geht in deinen Daten tendenziell mit schlechterer Stimmung am Folgetag einher.",
@@ -3923,8 +3803,6 @@
},
"briefing": {
"memory": {
"recall": "Zuletzt: {text}",
"forward": "Mit Blick nach vorn: {text}",
"forwardWatch": "{metric} ist im Blick zu behalten.",
"forwardHolding": "Im Moment ist alles stabil."
}
@@ -3938,9 +3816,7 @@
}
},
"streak": {
"returnLabel": "Dein {metric} ist seit {count} Tagen wieder in deinem üblichen Bereich — was es auch war, es ist vorbei.",
"daysInside": "{count} Tage drinnen",
"daysOutside": "{count} Tage draußen"
"returnLabel": "Dein {metric} ist seit {count} Tagen wieder in deinem üblichen Bereich — was es auch war, es ist vorbei."
},
"updatedTodayAt": "Heute aktualisiert, {time}",
"updatedYesterday": "Gestern aktualisiert",
@@ -4241,6 +4117,7 @@
"displayNamePlaceholder": "Wie soll dich HealthLog ansprechen?",
"displayNameHint": "Erscheint im Dashboard-Gruß und in Berichten.",
"heightLabel": "Größe (cm)",
"heightLabelFtIn": "Größe (ft, in)",
"genderLabel": "Geschlecht",
"genderNone": "Keine Angabe",
"genderMale": "Männlich",
@@ -4568,10 +4445,6 @@
"passphraseHelp": "Leitet den Archivschlüssel per Argon2id ab und versiegelt ihn mit AES-256-GCM. Speichere die Datei mit der Endung .hlx.",
"noRecoveryWarning": "Es gibt keine Wiederherstellung — geht diese Passphrase verloren, lässt sich das Archiv nicht mehr öffnen. Bewahre sie sicher auf.",
"stepUpNeeded": "Bestätige deinen zweiten Faktor und versuche es erneut."
},
"cycle": {
"title": "Zyklus",
"description": "Zyklus- und Reproduktionsdaten als FHIR-Dokument — letzte Periode, durchschnittliche Zyklus- und Periodenlänge, aktuelle Phase."
}
},
"import": {
@@ -4593,6 +4466,7 @@
"failed": "Der Import ist fehlgeschlagen.",
"rowsImported": "Bisher {count} Einträge importiert",
"doneSummary": "Fertig — {imported} von {read} Datensätzen importiert, {skipped} klinische Datensätze übersprungen.",
"doneNothing": "Aus diesem Archiv wurde nichts importiert: 0 von {read} Datensätzen sind angekommen.",
"estimateWarning": "Kumulative Summen für {count} Tag(e) wurden anhand der größten Exportquelle geschätzt. Native Apple-Health-Statistiken bleiben maßgeblich.",
"phase": {
"queued": "In Warteschlange…",
@@ -4616,7 +4490,10 @@
"rateLimited": "Zu viele Importe in dieser Stunde (Grenze 5). Bitte später erneut versuchen.",
"failed": "Der Import ist fehlgeschlagen. Bitte erneut versuchen.",
"readFailed": "Diese Datei konnte nicht gelesen werden.",
"resultSummary": "{measurements} Messwerte und {moods} Stimmungseinträge importiert, {skipped} übersprungen."
"resultSummary": "{measurements} Messwerte und {moods} Stimmungseinträge importiert, {skipped} übersprungen.",
"resultSuccess": "{measurements} Messwerte und {moods} Stimmungseinträge importiert.",
"resultNothing": "Nichts importiert. Alle {skipped} Einträge wurden übersprungen.",
"resultEmpty": "Diese Datei enthielt weder Messwerte noch Stimmungseinträge."
},
"csv": {
"title": "CSV-Datei",
@@ -4637,7 +4514,30 @@
"readFailed": "Diese Datei konnte nicht gelesen werden.",
"previewSummary": "Vorschau: {inserted} würden importiert, {skipped} übersprungen.",
"resultSummary": "{inserted} neu importiert und {updated} aktualisiert, {skipped} übersprungen.",
"rowError": "Zeile {line}: {reason}"
"rowError": "Zeile {line}: {reason}",
"resultSuccess": "{inserted} neu importiert und {updated} aktualisiert.",
"resultNothing": "Nichts importiert. Alle {skipped} Zeilen wurden übersprungen.",
"resultEmpty": "Diese Datei hat eine Kopfzeile, aber keine Datenzeilen.",
"previewSuccess": "Vorschau: {inserted} würden importiert.",
"previewNothing": "Vorschau: nichts würde importiert. Alle {skipped} Zeilen würden übersprungen.",
"skipGroupOne": "{count} Zeile übersprungen: {reason}",
"skipGroupFew": "{count} Zeilen übersprungen: {reason}",
"skipGroupOther": "{count} Zeilen übersprungen: {reason}",
"skipDetails": "Alle übersprungenen Zeilen anzeigen",
"reasonUnknownType": "unbekannter Messwerttyp",
"reasonInvalidValue": "Wert ist keine Zahl",
"reasonValueOutOfRange": "Wert außerhalb des plausiblen Bereichs",
"reasonUnknownUnit": "Einheit für diesen Typ nicht erkannt",
"reasonMissingTimezoneOffset": "Zeitstempel ohne Zeitzonen-Offset",
"reasonInvalidTimestamp": "Zeitstempel nicht lesbar",
"reasonImplausibleTimestamp": "Zeitstempel liegt in der Zukunft oder vor 1900",
"reasonUnexpectedGlucoseContext": "Glukose-Kontext in einer Zeile, die kein Blutzucker ist",
"reasonInvalidGlucoseContext": "Glukose-Kontext ist kein zulässiger Wert",
"reasonNotesTooLong": "Notiz ist zu lang",
"reasonExternalIdTooLong": "Externe ID ist zu lang",
"reasonUnstableExternalId": "Externe ID ist über Exporte hinweg nicht stabil",
"reasonDuplicate": "bereits importiert",
"reasonUnknown": "übersprungen"
}
},
"subtitle": "Exportiere deine Daten oder importiere aus einer Sicherung."
@@ -4788,6 +4688,7 @@
},
"username": "Nutzername",
"height": "Körpergröße (cm)",
"heightFtIn": "Körpergröße (ft, in)",
"dateOfBirth": "Geburtsdatum",
"dateOfBirthHint": "Wird für die automatische Berechnung der Blutdruck-Zielwerte benötigt.",
"gender": "Geschlecht",
@@ -4821,17 +4722,10 @@
"passwordMismatch": "Neue Passwörter stimmen nicht überein",
"passwordUpdated": "Passwort erfolgreich geändert",
"passkeys": "Passkeys",
"registeredPasskeys": "Registrierte Passkeys",
"passkeysDescription": "Passkeys ermöglichen sicheres Anmelden ohne Passwort.",
"passkeyName": "Name",
"passkeyDevice": "Gerät",
"passkeyBackup": "Backup",
"passkeyCreated": "Erstellt",
"passkeyActions": "Aktionen",
"noPasskeys": "Keine Passkeys registriert.",
"addPasskey": "Passkey hinzufügen",
"passkeyAdded": "Passkey erfolgreich hinzugefügt!",
"passkeyOptionsError": "Konnte Passkey-Optionen nicht laden",
"passkeyRegistrationFailed": "Fehler bei Passkey-Registrierung",
"passkeyRegistrationCancelled": "Passkey-Registrierung abgebrochen",
"passkeyNotSupported": "Dein Browser oder Gerät unterstützt Passkeys noch nicht. Bitte einen anderen Browser oder ein anderes Gerät verwenden.",
@@ -4880,7 +4774,6 @@
"webPushSubscribed": "Push-Benachrichtigungen aktiviert!",
"webPushUnsubscribed": "Push-Benachrichtigungen deaktiviert.",
"webPushSubscribeFailed": "Aktivierung fehlgeschlagen",
"webPushActive": "Aktiv",
"kiInsightsDescription": "Optional: Hinterlege deinen OpenAI-Key für tägliche, automatische Auswertungen in den Insights.",
"codexConnected": "ChatGPT erfolgreich verbunden! Insights sind jetzt aktiv.",
"codexDisconnected": "ChatGPT-Verbindung getrennt.",
@@ -4918,7 +4811,6 @@
"savedShort": "(gespeichert)",
"saveCta": "Speichern",
"saved": "Gespeichert",
"saveFailed": "Speichern fehlgeschlagen",
"errorGeneric": "Fehler",
"providerChain": {
"types": {
@@ -5054,11 +4946,9 @@
"hint": "Wird als zusätzlicher Kontext an Coach und Tagesbriefing übergeben. Leer lassen, um nichts mitzusenden.",
"profileHint": "Alter und Geschlecht stammen aus deinem Profil und werden automatisch mitgegeben.",
"freeTextLabel": "Über mich (Freitext)",
"conditionsLabel": "Chronische Erkrankungen",
"conditionsPlaceholder": "Z. B. Bluthochdruck, Typ-2-Diabetes…",
"allergiesLabel": "Allergien & Unverträglichkeiten",
"allergiesPlaceholder": "Z. B. Laktoseintoleranz, Penicillin-Allergie…",
"focusLabel": "Worauf soll der Coach achten?",
"focusPlaceholder": "Z. B. behalte meinen Blutdruck im Blick, motiviere mich zu mehr Bewegung…",
"completeness": "{answered} von {total} beantwortet",
"questionsTitle": "Der Coach hat Rückfragen",
@@ -5136,8 +5026,6 @@
"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."
},
"integrationsSourcesHint": "Liefern mehrere Quellen dieselbe Metrik, entscheidet die Quellen-Priorität, welcher Wert zählt.",
"integrationsSourcesHintLink": "Quellen-Priorität öffnen",
"withingsSynchronize": "Synchronisieren",
"withingsDisconnect": "Trennen",
"withingsDisconnectTitle": "Withings-Verbindung trennen?",
@@ -5267,7 +5155,7 @@
"integrationPill": {
"connected": "Verbunden",
"errorReconnect": "Fehler — neu verbinden",
"warningServerError": "Verbunden, aber Serverfehler",
"warningServerError": "Verbunden, Sync-Problem",
"parkedReconnect": "Pausiert — manuell wieder verbinden",
"notConnected": "Nicht verbunden",
"justNow": "gerade eben",
@@ -5277,8 +5165,26 @@
"ariaLabel": "Integrationsstatus",
"resumeCta": "Wieder verbinden",
"resumeSuccess": "Integration wieder aktiv",
"resumeError": "Wieder verbinden fehlgeschlagen"
"resumeError": "Wieder verbinden fehlgeschlagen",
"stale": "Verbunden, keine neuen Daten",
"stalled": "Sync gestoppt",
"pendingSetup": "Warte auf erste Daten"
},
"integrationFreshness": {
"title": "Empfangene Daten",
"summary": "{count} Datentypen · neuestes {relative}",
"quiet": "{count} still",
"workouts": "Workouts"
},
"integrationFallback": {
"description": "Für dieses Konto eingerichtet, hat aber keine eigene Einstellungskarte. Diese Zeile sorgt dafür, dass eine Verbindung nicht unbemerkt ausfallen kann.",
"lastAttempt": "Letzter Versuch {relative}",
"noAttempt": "Kein Sync-Versuch aufgezeichnet"
},
"withingsViewData": "Messwerte ansehen",
"whoopViewData": "Erholung ansehen",
"fitbitViewData": "Schlaf ansehen",
"googleHealthViewData": "Schritte ansehen",
"apiTokens": "API-Tokens",
"apiTokensDescription": "API-Tokens für die externe Medikamenten-Einnahme prüfen und widerrufen. Tokens werden pro Medikament über dessen API-Endpoint-Schalter ausgestellt.",
"tokenMintMovedDescription": "Tokens werden pro Medikament ausgestellt. Öffne ein Medikament und aktiviere dessen API-Endpoint, um ein Token zu erhalten, das nur für dieses Medikament gilt.",
@@ -5310,10 +5216,7 @@
"apiEndpointPath": "Pfad",
"apiEndpointAuth": "Authentifizierung",
"apiEndpointExample": "Body-Beispiel",
"collapse": "Einklappen",
"expand": "Ausklappen",
"dangerZone": "Alle Daten löschen",
"dangerZoneTitle": "Gefahrenzone",
"dangerZoneDescription": "Löscht alle deine Gesundheitsdaten und Integrationen. Dein Nutzerkonto bleibt erhalten.",
"dangerZoneConfirm": "Alle Daten löschen?",
"dangerZoneConfirmDescription": "Dieser Vorgang löscht alle deine Gesundheitsdaten und Integrationen unwiderruflich. Dein Nutzerkonto bleibt erhalten.",
@@ -5330,31 +5233,9 @@
"deleteAccountFinal": "Endgültig löschen",
"saved": "Gespeichert",
"savingError": "Fehler beim Speichern",
"moodLogTitle": "moodLog",
"moodLogDescription": "Stimmungseinträge übernehmen.",
"moodLogDeprecated": "Veraltet: Stimmung wird jetzt nativ in HealthLog mit Tags und Faktoren erfasst. Diese Integration funktioniert weiterhin, wird aber in einer künftigen Version entfernt.",
"moodLogUrl": "moodLog URL",
"moodLogUrlPlaceholder": "https://mood.example.com",
"moodLogApiKey": "API-Key",
"moodLogApiKeyPlaceholder": "ml_...",
"moodLogWebhookSecret": "Webhook-Secret",
"moodLogWebhookSecretHelp": "Dieses Secret in moodLog als Webhook-Secret eintragen",
"moodLogSync": "Jetzt synchronisieren",
"moodLogFullSync": "Alle Daten synchronisieren",
"moodLogFullSyncConfirm": "Synchronisieren",
"moodLogFullSyncTitle": "Vollsynchronisierung?",
"moodLogFullSyncDescription": "Alle verfügbaren Stimmungsdaten werden vollständig synchronisiert.",
"moodLogDisconnect": "Trennen",
"moodLogDisconnectTitle": "moodLog trennen?",
"moodLogDisconnectDescription": "Die Verbindung wird getrennt und alle importierten Stimmungsdaten werden gelöscht.",
"moodLogEntries": "Einträge",
"moodLogSyncResult": "{count} Einträge synchronisiert",
"moodLogSyncFailed": "Sync fehlgeschlagen",
"moodLogDisconnected": "moodLog getrennt",
"about": {
"version": "App-Version",
"gitSha": "Build",
"builtAt": "Erstellt {time}",
"builtAtLabel": "Erstellt",
"license": "Lizenz",
"repository": "Quellcode",
@@ -5426,34 +5307,17 @@
"practiceName": "Praxis (optional)",
"practiceNamePlaceholder": "z. B. Dr. med. Mustermann",
"includedData": "Enthaltene Daten",
"groupVitals": "Vitalwerte",
"groupCardio": "Herz & Fitness",
"groupActivity": "Aktivität & Schlaf",
"weight": "Gewicht",
"bp": "Blutdruck",
"pulse": "Puls",
"oxygenSaturation": "Sauerstoffsättigung",
"bodyFat": "Körperfett",
"bodyComposition": "Körperzusammensetzung",
"restingHeartRate": "Ruhepuls",
"hrv": "Herzfrequenzvariabilität",
"vo2max": "VO₂max",
"steps": "Schritte",
"distance": "Distanz",
"sleep": "Schlaf",
"glucose": "Blutzucker",
"medications": "Medikamente & Therapietreue",
"mood": "Stimmung (standardmäßig aus — sensible Daten)",
"bmi": "BMI",
"includeCharts": "Trend-Diagramme einbetten (nur PDF)",
"includeAiSummary": "KI-Zusammenfassung anhängen (nicht klinisch validiert)",
"fhirNote": "Die strukturierte Ausgabe folgt dem FHIR-R4-Standard (ePA-kompatibel).",
"generate": "Erstellen",
"error": "Export fehlgeschlagen ({code}).",
"labs": "Laborwerte",
"allergies": "Allergien & Unverträglichkeiten",
"familyHistory": "Familienanamnese",
"documentsLink": "Dokumente zum Zeitraum"
"documentsLink": "Dokumente zum Zeitraum",
"range30": "Letzte 30 Tage",
"rangeCustom": "Eigener Zeitraum",
"rangeCustomStart": "Von",
"rangeCustomEnd": "Bis",
"errorGeneric": "Der Export konnte nicht erstellt werden. Bitte erneut versuchen.",
"errorRateLimit": "Das Exportlimit für diese Stunde ist erreicht.",
"errorModuleDisabled": "Der Gesundheitsakten-Export ist für dieses Konto ausgeschaltet."
},
"globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen",
"globalExcludedInjectionSitesHint": "Hier gelistete Stellen werden für keine Medikation angeboten und beim Erfassen einer Dosis abgelehnt — auch wenn eine Medikation sie bevorzugt.",
@@ -5467,13 +5331,8 @@
"expiry": "Läuft ab in (Tagen)",
"expiryHint": "Höchstens {max} Tage.",
"expiryInvalid": "Die Gültigkeit muss zwischen 1 und {max} Tagen liegen.",
"fhirApi": "Eingegrenzte FHIR-API erlauben",
"fhirApiHint": "Lasse den Link zusätzlich zur Seitenansicht einen schreibgeschützten FHIR-Endpunkt bereitstellen.",
"resourceTypes": "FHIR-Ressourcentypen",
"create": "Link erstellen",
"tokenCreated": "Freigabe-Link erstellt",
"tokenOnce": "Kopiere diesen Link jetzt — der Token wird nur einmal angezeigt und lässt sich nicht wiederherstellen.",
"copy": "Link kopieren",
"copied": "In die Zwischenablage kopiert.",
"activeTitle": "Aktive Links",
"noActive": "Keine aktiven Freigabe-Links.",
@@ -5487,7 +5346,6 @@
"statusExpired": "Abgelaufen",
"inactiveTitle": "Widerrufen und abgelaufen ({count})",
"shownOnce": "Nur einmal sichtbar. Erfasse QR-Code und Passphrase jetzt — beide lassen sich später nicht wiederherstellen.",
"qrLabel": "QR-Code",
"qrAlt": "QR-Code mit Freigabelink und Passphrase",
"linkLabel": "Link",
"passphraseLabel": "Passphrase",
@@ -5510,7 +5368,12 @@
"pickerEmpty": "Keine Dokumente gefunden.",
"pickerDone": "Fertig",
"pickerSelectedCount": "{count} von {max} ausgewählt",
"pickerMaxReached": "Maximum von {max} Dokumenten erreicht."
"pickerMaxReached": "Maximum von {max} Dokumenten erreicht.",
"scopeTitle": "Was dieser Link enthält",
"scopeInsuranceExcluded": "Versichertendaten werden über einen Link nie geteilt.",
"statusNeedsReselect": "Inhalt neu wählen",
"reselectReason": "Dieser Link lieferte einen Umfang, den niemand gewählt hatte, und wurde deshalb geschlossen. Legen Sie einen neuen an und wählen Sie den Inhalt.",
"reselectAction": "Ersatz anlegen"
},
"genderOther": "Divers",
"cycleTracking": {
@@ -5537,13 +5400,15 @@
"nightscoutUpdate": "Änderungen speichern",
"nightscoutConnected": "Nightscout verbunden.",
"nightscoutConnectFailed": "Verbindung zu Nightscout fehlgeschlagen.",
"nightscoutSync": "Jetzt synchronisieren",
"nightscoutSyncResult": "{count} Messwerte synchronisiert",
"nightscoutSyncFailed": "Synchronisierung fehlgeschlagen",
"nightscoutDisconnect": "Trennen",
"nightscoutDisconnectTitle": "Nightscout trennen?",
"nightscoutDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Glukosewerte bleiben in deinem Verlauf.",
"polar": "Polar",
"polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen und tägliche Aktivität zu synchronisieren.",
"polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen, tägliche Aktivität und Trainingseinheiten zu synchronisieren.",
"polarViewData": "Schlaf & Erholung ansehen",
"polarHelp": "Melde dich bei Polar an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.",
"polarCredentials": "API-Zugangsdaten",
"polarClientId": "Client ID",
"polarClientSecret": "Client Secret",
@@ -5552,6 +5417,9 @@
"polarSaveCredentials": "Speichern",
"polarUnavailable": "Polar ist auf diesem Server nicht konfiguriert. Füge oben deine eigenen Polar-OAuth-Zugangsdaten hinzu oder bitte deinen Administrator, die gemeinsame App zu konfigurieren.",
"polarConnect": "Polar verbinden",
"polarSync": "Jetzt synchronisieren",
"polarSyncResult": "{count} Messwerte synchronisiert",
"polarSyncFailed": "Synchronisierung fehlgeschlagen",
"polarDisconnect": "Trennen",
"polarDisconnectTitle": "Polar trennen?",
"polarDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Werte bleiben in deinem Verlauf.",
@@ -5570,7 +5438,6 @@
"oura": "Oura",
"ouraDescription": "Verbinde dein Oura-Konto, um Readiness, Schlafphasen, Herzfrequenzvariabilität und tägliche Aktivität zu synchronisieren.",
"ouraViewData": "Schlaf & Erholung ansehen",
"ouraHelp": "Melde dich bei Oura an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.",
"ouraCredentials": "API-Zugangsdaten",
"ouraClientId": "Client ID",
"ouraClientSecret": "Client Secret",
@@ -5579,6 +5446,9 @@
"ouraSaveCredentials": "Speichern",
"ouraUnavailable": "Oura ist auf diesem Server nicht konfiguriert. Füge oben deine eigenen Oura-OAuth-Zugangsdaten hinzu oder bitte deinen Administrator, die gemeinsame App zu konfigurieren.",
"ouraConnect": "Oura verbinden",
"ouraSync": "Jetzt synchronisieren",
"ouraSyncResult": "{count} Messwerte synchronisiert",
"ouraSyncFailed": "Synchronisierung fehlgeschlagen",
"ouraDisconnect": "Trennen",
"ouraDisconnectTitle": "Oura trennen?",
"ouraDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Werte bleiben in deinem Verlauf.",
@@ -5807,6 +5677,9 @@
"stravaSaveCredentials": "Speichern",
"stravaUnavailable": "Strava ist auf diesem Server nicht konfiguriert. Füge oben deine eigenen Strava-API-Zugangsdaten hinzu oder bitte deinen Administrator, die gemeinsame App zu konfigurieren.",
"stravaConnect": "Strava verbinden",
"stravaSync": "Jetzt synchronisieren",
"stravaSyncResult": "{count} Messwerte synchronisiert",
"stravaSyncFailed": "Synchronisierung fehlgeschlagen",
"stravaDisconnect": "Trennen",
"stravaDisconnectTitle": "Strava trennen?",
"stravaDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Aktivitäten bleiben in deinem Verlauf.",
@@ -5826,17 +5699,14 @@
"title": "Apple Health",
"description": "Die Live-Synchronisierung erfolgt über die HealthLog-iOS-App – nicht über eine Web- oder OAuth-Verbindung.",
"status": {
"checking": "Apple-Health-Daten werden geprüft…",
"dataReceived": "Apple-Health-Daten empfangen",
"setup": "In der iOS-App einrichten",
"unavailable": "Apple-Health-Status nicht verfügbar"
},
"lastDataLabel": "Letzte Apple-Health-Daten:",
"setupTitle": "Live-Synchronisierung aktivieren",
"permissionStep": "Öffne in der HealthLog-iOS-App Einstellungen → Apple Health und erlaube die Gesundheitskategorien, die du teilen möchtest.",
"backgroundStep": "Lass die Hintergrundaktualisierung aktiviert. Die App synchronisiert neue Daten automatisch im Vordergrund und wenn iOS Hintergrundzeit gewährt.",
"importNote": "Du brauchst ältere Daten oder kannst die iOS-App nicht nutzen? Der Apple-Health-XML/ZIP-Upload ist ein einmaliger Ersatzweg und aktiviert keine Live-Synchronisierung.",
"importAction": "Einmaligen Import öffnen"
"importAction": "Einmaligen Import öffnen",
"staleNote": "Seit über einer Woche sind keine Apple-Health-Daten angekommen. Öffne die HealthLog-App auf deinem iPhone und prüfe die Apple-Health-Berechtigungen."
},
"garminInfo": {
"title": "Garmin",
@@ -5945,7 +5815,6 @@
"telegramGlobal": "Telegram-Benachrichtigungen erlauben.",
"ntfyGlobal": "ntfy-Benachrichtigungen erlauben.",
"webPushGlobal": "Browser-Push-Benachrichtigungen erlauben.",
"moodLogGlobal": "moodLog-Erinnerungen erlauben.",
"integrationWebPush": "Web Push",
"offlineGeoLabel": "Geodaten",
"offlineGeoEnabled": "Offline (GeoLite2)",
@@ -7040,7 +6909,6 @@
"period": "Berichtszeitraum: {start} – {end}",
"expires": "Dieser Link läuft am {date} ab.",
"provenance": "Dies ist eine schreibgeschützte Zusammenfassung, die von der betreffenden Person geteilt wurde, exportiert aus ihren selbst erfassten HealthLog-Daten. Die Werte sind selbst erhoben und können geräteübertragene Messwerte enthalten; behandeln Sie sie als von der Patientin/dem Patienten berichtet, nicht als klinische Untersuchung.",
"vitals": "Vitalwerte",
"labs": "Laborwerte",
"medications": "Medikamente & Therapietreue",
"bmi": "Body-Mass-Index (BMI)",
@@ -7080,7 +6948,10 @@
"download": "Herunterladen",
"attachmentHint": "Diese Datei wird auf Ihr Gerät heruntergeladen (keine Vorschau in der Seite).",
"exifNote": "Kamera-Metadaten (EXIF/GPS) wurden aus geteilten JPEG-, PNG- und WebP-Bildern entfernt."
}
},
"downloadPdf": "Als PDF herunterladen",
"downloadFhir": "Als FHIR herunterladen",
"bmiSection": "Body-Mass-Index"
},
"cycle": {
"symptomCategory": {
@@ -7459,7 +7330,6 @@
"measurementReminders": {
"sectionTitle": "Vorsorge",
"sectionDescription": "Erinnerungen, was wann und wo zu messen oder zu prüfen ist.",
"addButton": "Erinnerung hinzufügen",
"markDone": "Erledigt",
"delete": "Löschen",
"deleteConfirmTitle": "Erinnerung löschen?",
@@ -8988,5 +8858,42 @@
}
}
}
},
"reportSelection": {
"groupIdentity": "Stammdaten",
"groupVitals": "Vitalwerte",
"groupCardio": "Herz & Kreislauf",
"groupBody": "Körpermaße",
"groupSleepRecovery": "Schlaf & Erholung",
"groupActivity": "Aktivität",
"groupMobility": "Mobilität & Gang",
"groupEnvironment": "Umgebung",
"groupGlucose": "Blutzucker",
"groupLabs": "Laborwerte",
"groupMedications": "Medikamente",
"groupHistory": "Vorerkrankungen & Allergien",
"groupSensitive": "Sensible Daten",
"leafPatientIdentity": "Name, Geburtsdatum, Geschlecht, Größe",
"leafInsurance": "Krankenkasse und Versichertennummer",
"leafGlucosePanel": "Blutzucker-Panel (Zeit im Zielbereich, GMI, Variabilität)",
"leafLabResults": "Laborwerte",
"leafMedicationList": "Medikamentenliste",
"leafMedicationAdministrations": "Jede erfasste Einnahme",
"leafMedicationCompliance": "Einnahmetreue",
"leafGlp1Therapy": "GLP-1-Therapie (Dosisverlauf, Nebenwirkungen)",
"leafAllergies": "Allergien und Unverträglichkeiten",
"leafIllnessEpisodes": "Krankheitsepisoden",
"leafFamilyHistory": "Familienanamnese",
"leafMood": "Stimmung",
"leafCycle": "Menstruationszyklus",
"templateStandard": "Standard-Arztbericht",
"applyStandard": "{template} übernehmen",
"standardHint": "Ein Klick füllt aus, was ein normaler Arztbesuch meist braucht. Jeder Eintrag bleibt änderbar.",
"groupCount": "{on}/{total}",
"groupDisclosure": "Einträge in {group} anzeigen",
"sensitiveHint": "Hier ist nie etwas vorausgewählt. Wählen Sie jeden Eintrag selbst.",
"scopeNone": "Noch nichts ausgewählt. Wählen Sie aus, was enthalten sein soll, oder übernehmen Sie den Standardbericht.",
"scopeCount": "{count} Einträge aus {groups} Bereichen",
"scopeSensitive": "inkl. {names}"
}
}
+164 -247
View File
@@ -1,6 +1,7 @@
{
"common": {
"save": "Save",
"conflictReloaded": "Reloaded from the server. Please make your change again.",
"cancel": "Cancel",
"delete": "Delete",
"undo": "Undo",
@@ -24,6 +25,10 @@
"openTimePicker": "Open time picker",
"hours": "Hours",
"minutes": "Minutes",
"feet": "ft",
"inches": "in",
"heightFeet": "Height in feet",
"heightInches": "Height in inches",
"period": "AM/PM",
"am": "AM",
"pm": "PM",
@@ -48,13 +53,21 @@
},
"apiErrors": {
"measurement": {
"create": { "invalid": "Check the measurement details and try again." },
"create": {
"invalid": "Check the measurement details and try again."
},
"duplicate_timestamp": "A measurement already exists for this time."
},
"mood": {
"list": { "invalid": "The mood list filters are invalid." },
"create": { "invalid": "Check the mood entry and try again." },
"update": { "invalid": "Check the mood entry and try again." },
"list": {
"invalid": "The mood list filters are invalid."
},
"create": {
"invalid": "Check the mood entry and try again."
},
"update": {
"invalid": "Check the mood entry and try again."
},
"duplicate_timestamp": "A mood entry already exists for this time.",
"not_found": "This mood entry is no longer available.",
"ratedFactor": {
@@ -62,12 +75,18 @@
}
},
"labs": {
"create": { "invalid": "Check the lab result and try again." },
"result": { "notFound": "This lab result is no longer available." },
"create": {
"invalid": "Check the lab result and try again."
},
"result": {
"notFound": "This lab result is no longer available."
},
"biomarker": {
"notFound": "This biomarker is no longer available.",
"duplicate": "A biomarker with this name already exists.",
"create": { "invalid": "Check the biomarker details and try again." },
"create": {
"invalid": "Check the biomarker details and try again."
},
"update": {
"invalid": "Check the biomarker details and try again.",
"referenceRangeInvalid": "The lower reference value must not exceed the upper value."
@@ -85,9 +104,15 @@
"disclaimer": {
"invalid": "Please confirm the medical disclaimer again."
},
"tour": { "invalid": "The tour progress could not be saved." },
"complete": { "invalid": "Check the profile details and try again." },
"user": { "notFound": "Your account could not be found." },
"tour": {
"invalid": "The tour progress could not be saved."
},
"complete": {
"invalid": "Check the profile details and try again."
},
"user": {
"notFound": "Your account could not be found."
},
"step": {
"rateLimited": "Too many attempts. Please wait and try again.",
"invalid": "This onboarding step could not be saved.",
@@ -145,16 +170,6 @@
"footerDisclaimer1": "This report was generated automatically from self-tracked data and is intended to support",
"footerDisclaimer2": "the doctor–patient conversation. It does not replace a medical diagnosis. Correlations do not imply causation.",
"footerSource": "Source: HealthLog | Timezone: {timezone} | Created: {timestamp}",
"typeWeight": "Body weight",
"typeBpSys": "Systolic blood pressure",
"typeBpDia": "Diastolic blood pressure",
"typePulse": "Resting pulse",
"typeBodyFat": "Body fat",
"typeSleep": "Sleep duration",
"typeSteps": "Activity (steps)",
"typeTotalBodyWater": "Total body water",
"typeBoneMass": "Bone mass",
"typeOxygenSaturation": "Oxygen saturation",
"unitSteps": "steps",
"bmiUnderweight": "Underweight",
"bmiNormal": "Normal weight",
@@ -190,8 +205,6 @@
"summaryTrend": "{label}: latest {latest} {arrow}",
"summaryAdherence": "Medication adherence: {rate}% (weighted mean).",
"chartsTitle": "Trend charts",
"aiSummaryTitle": "AI summary — not clinically validated",
"aiSummaryDisclaimer": "This section was generated automatically by an AI model and is not clinically validated. It does not replace a medical assessment.",
"packageReadme": "HealthLog health record\n\nThis package contains:\n- report.pdf: the health report for your doctor.\n- bundle.json: the same data as an HL7 FHIR R4 document Bundle, ePA-compatible.\n\nGenerated with HealthLog.",
"wellnessTitle": "Wellness summary",
"wellnessDisclaimer": "These are descriptive wellness scores computed from your tracked signals (0–100). They are not clinical assessments or diagnoses.",
@@ -350,6 +363,9 @@
"errorRegistrationDisabled": "No account matches that identity, and new sign-ups are currently closed.",
"errorFailed": "Single sign-on failed. Please try again."
},
"native": {
"signingInToApp": "Signing in to the HealthLog app"
},
"mfa": {
"title": "Two-factor authentication",
"totpHint": "Enter the 6-digit code from your authenticator app.",
@@ -441,6 +457,7 @@
"layoutReset": "Reset to defaults",
"layoutSaveSuccess": "Dashboard layout saved",
"layoutSaveError": "Could not save dashboard layout",
"layoutConflictReloaded": "Layout reloaded from the server. Please make your change again.",
"layoutResetSuccess": "Reset to default layout",
"layoutUsingDefaults": "Using default layout",
"layoutCustomized": "Custom layout active",
@@ -882,7 +899,6 @@
"emptyDescription": "Add medications, dosages, and reminder windows to track adherence.",
"loadFailed": "Could not load medications",
"retryLoad": "Retry",
"name": "Name",
"inactive": "Paused",
"intakeHistory": "Intake history",
"openDetailPage": "Open medication detail page",
@@ -1185,8 +1201,6 @@
"dosesPerUnitLabel": "Doses per unit",
"dosesPerUnitPlaceholder": "e.g. 4",
"dosesPerUnitHint": "Doses per pen / vial — leave empty for no inventory tracking.",
"injectionSiteToggle": "Injection-site rotation",
"injectionSiteHint": "Optional rotation guide. The site for each dose is recorded when you log the intake.",
"short": "Dose",
"unitsPerDoseLabel": "Units per dose",
"unitsPerDoseHint": "e.g. 2 tablets of 2 mg for a 4 mg dose, or ½ a tablet for a split dose"
@@ -1237,7 +1251,6 @@
"remindersDescription": "Push, Telegram or ntfy — based on your settings.",
"noChannelHint": "Reminders need a notification channel — none is active right now.",
"noChannelHintLink": "Set up notifications",
"multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release.",
"short": "Done"
}
},
@@ -1870,7 +1883,7 @@
"levelShift": "A shift in the level was detected around {date}.",
"plateau": {
"title": "Weight plateau on the current dose",
"evidence": "Weight moved by {delta} kg over the last {window} days ({readings} readings) — {days} days on {dose}."
"evidence": "Weight moved by {delta} {unit} over the last {window} days ({readings} readings) — {days} days on {dose}."
},
"retarget": {
"label": "Not the right measurement?",
@@ -2105,7 +2118,6 @@
"emptyDescription": "Insights light up once you've logged a handful of measurements. Start with weight or blood pressure.",
"emptyAddMeasurement": "Add a measurement",
"editMode": {
"open": "Customize",
"done": "Done",
"reset": "Reset",
"title": "Customize overview",
@@ -2130,16 +2142,6 @@
"bmiEmptyAction": "Open account settings",
"noProviderConfigured": "Connect an AI provider to see an assessment of your readings here.",
"noProviderAction": "Open AI settings",
"startAnalysis": "Start assessment",
"refreshAnalysis": "Refresh assessment",
"keyTakeaway": "Key Takeaway",
"findingsTitle": "Findings",
"heroFindingPositive": "Top finding",
"heroFindingNeutral": "Top finding",
"heroFindingAttention": "Worth attention",
"heroFindingWarning": "Top alert",
"correlationsTitle": "Correlations",
"recommendationsTitle": "Recommendations",
"personalRecord": {
"badge": "PR",
"tooltip": "Personal record: {value} {unit} on {date}"
@@ -2147,53 +2149,33 @@
"recommendation": {
"source": "Source",
"viewSource": "Open guideline in a new tab",
"rationale": "Why this recommendation",
"rationaleWindow": "Window",
"rationaleComparedTo": "Compared to",
"rationaleDeviation": "Deviation",
"rationaleExpand": "Show why",
"rationaleCollapse": "Hide why",
"legacyPayloadCta": "Insights updated — regenerate for new explainability features",
"feedbackHelpful": "Helpful",
"feedbackNotHelpful": "Not helpful",
"feedbackThanks": "Thanks for your feedback",
"feedbackConfirmed": "Feedback recorded",
"feedbackAlreadyRated": "Already rated",
"feedbackError": "Could not save your feedback. Please try again.",
"confidence": "Confidence",
"confidenceAria": "Confidence: {value} of 100",
"confidenceHigh": "High confidence",
"confidenceMedium": "Moderate confidence",
"confidenceLow": "Low confidence — based on limited data",
"confidenceDraft": "Draft"
},
"dataFoundation": "Data coverage",
"dataGaps": "Data gaps:",
"lastUpdated": "Last updated",
"confidenceHoch": "High",
"confidenceMittel": "Medium",
"confidenceGering": "Low",
"loadError": "Failed to load insights",
"heroPersonalBaseline": "Based on your last 90 days",
"heroRegenerate": "Regenerate",
"heroRegenerating": "Regenerating",
"regenerateAnalysis": "Re-run assessment",
"regenerateSuccess": "Assessment refreshed",
"warmStarted": "Assessments are being prepared in the background",
"narrativeTitle": "In review",
"narrativeWeek": "This week",
"narrativeMonth": "This month",
"narrativePreparing": "Preparing your summary…",
"narrativeUpdating": "Updating…",
"narrativeProvenanceLabel": "How this was put together",
"narrativeProvenanceMethod": "A plain-language summary of the period, drawn only from your logged numbers and the associations that survived statistical multiple-comparison control. Descriptive, never a cause.",
"narrativeProvenanceMetrics": "Based on: {metrics}",
"narrativeProvenanceWindow": "Window: {from} to {to}",
"heroFallbackSubtitle": "A daily read of your trends, drawn straight from the numbers you've logged.",
"heroGreetingMorning": "Good morning",
"heroGreetingAfternoon": "Good afternoon",
"heroGreetingEvening": "Good evening",
"heroComingSoonTooltip": "Coming soon",
"heroActionAskCoach": "Ask the coach",
"healthScore": {
"label": "Health Score",
@@ -2206,14 +2188,12 @@
"componentMood": "Mood",
"componentCompliance": "Adherence",
"deltaExplainer": {
"trigger": "What does this delta mean?",
"title": "How the delta is computed",
"description": "Change vs last week: {delta}",
"body": "The score combines BP, weight, mood and medication adherence. We compare today's score with the same time last week. Log a fresh reading or a mood check to nudge it up."
},
"provenance": {
"toggle": "Driven by",
"weightLabel": "share",
"mixedBanner": "Some components blend multiple sources. The breakdown shows the most recent value per component.",
"footnote": "Weights are rescaled to the data that's actually available.",
"asOfLabel": "as of {date}",
@@ -2277,10 +2257,6 @@
"viewDetail": "View {metric} details"
},
"vo2Max": {
"statLatest": "Latest",
"statMin": "Min",
"statMax": "Max",
"empty": "No VO₂ max data yet — sync Apple Health to populate this view.",
"cardioLinkTitle": "Cardio fitness (VO₂ max)",
"cardioLinkBody": "VO₂ max is the headline measure of aerobic fitness. The full chart and assessment live on its own page.",
"cardioLinkCta": "Open cardio fitness"
@@ -2337,6 +2313,7 @@
"send": "Send",
"stop": "Stop",
"thinking": "Thinking…",
"unverifiedFiguresNotice": "Some figures couldn't be checked against your data and were left out.",
"composerPlaceholder": "Ask me anything…",
"nudgeBubbleLabel": "New message from the Coach — open chat",
"fabLabel": "Open the Coach",
@@ -2744,125 +2721,68 @@
"gewichtDescription": "Weight trend mapped against your height-derived healthy band, plus a written assessment.",
"pulsDescription": "Pulse trend against the personalised Karvonen target band, plus a written assessment.",
"stimmungDescription": "Daily mood scores over time with a written assessment.",
"stimmungEmptyTitle": "Log your first mood",
"stimmungEmptyDescription": "Mood insights light up once you've logged a few entries. Tap below to get started.",
"stimmungEmptyAction": "Open mood journal",
"medikamenteDescription": "Per-medication adherence calendars and a written assessment.",
"medikamenteEmptyTitle": "Add your first medication",
"medikamenteEmptyDescription": "Adherence insights need at least one tracked medication.",
"medikamenteEmptyAction": "Open medications",
"bmiDescription": "BMI derived from your weight and height, with a written assessment.",
"target": {
"adjustLink": "Adjust target range"
},
"explainer": {
"trigger": "What is {title}?",
"bloodPressureTitle": "Blood pressure",
"bloodPressureBody": "Blood pressure is the force your blood exerts on the artery walls, written as systolic over diastolic (the peak during a heartbeat over the resting value between beats). Consistently high readings strain the heart and blood vessels over time. As general guidance, European guidance (ESH 2023) calls under 120/80 optimal and 140/90 or above hypertension; US guidance (ACC/AHA 2017) sets a stricter line, labelling 130–139/80–89 as Stage 1. Categorise from a calm seated reading, never a single number.",
"pulseTitle": "Pulse",
"pulseBody": "Your pulse is how many times your heart beats per minute. A spot reading reflects what you were doing in the moment — at rest it is typically 60–100 beats per minute, and it rises naturally with movement, stress, or caffeine.",
"weightTitle": "Weight",
"weightBody": "Body weight is one of the simplest signals of long-term energy balance and overall health. The trend over weeks matters far more than any single reading, which fluctuates with hydration, food, and time of day.",
"bmiTitle": "Body mass index (BMI)",
"bmiBody": "BMI is your weight divided by your height squared, giving a single number that places you in a broad weight category. It is a population-level screen, not a diagnosis, and does not distinguish muscle from fat.",
"sleepTitle": "Sleep",
"sleepBody": "Sleep duration is how long you actually slept across the night, split into light, deep, and REM stages. Most adults need roughly seven to nine hours; both consistent quantity and quality support recovery, mood, and metabolism.",
"moodTitle": "Mood",
"moodBody": "Mood tracking records how you feel over time on a simple scale. Logging it regularly reveals patterns — how sleep, activity, or medication line up with how you feel — that a single day never shows.",
"medicationsTitle": "Medication adherence",
"medicationsBody": "Adherence is how reliably you take your medication as prescribed, shown here as the share of expected doses you actually logged. Steady intake is often what makes a treatment work as intended.",
"restingHrTitle": "Resting heart rate",
"restingHrBody": "Resting heart rate is your heart rate when you are completely at rest, usually measured overnight — distinct from your pulse during activity. A lower resting rate generally points to better cardiovascular fitness, and a rising trend can be an early sign of stress or illness. As general guidance, a normal adult resting rate is 60–100 beats per minute (AHA 2024); a trained heart can sit benignly at 40–60.",
"hrvTitle": "Heart rate variability (HRV)",
"hrvBody": "HRV is the tiny variation in time between consecutive heartbeats. Higher variability usually reflects a well-recovered, adaptable nervous system, while a sustained drop can signal fatigue, stress, or insufficient recovery.",
"oxygenSaturationTitle": "Oxygen saturation (SpO₂)",
"oxygenSaturationBody": "SpO₂ is the percentage of your red blood cells carrying oxygen. As general guidance, readings of 95–100% are normal at rest (StatPearls/NIH 2023); values that stay below 90% warrant attention, though brief nightly dips are common and above ~98% adds no benefit. Optical readers can over-read true saturation in people with darker skin (US FDA 2024), so a single near-normal reading is not a clearance — retake when warm and still if concerned.",
"bodyTemperatureTitle": "Body temperature",
"bodyTemperatureBody": "Body temperature reflects your core thermal state. As general guidance, the population mean is about 36.6 °C — not 37.0 °C — with a normal oral range of roughly 35.7–37.4 °C and a fever from 38.0 °C (J Gen Intern Med 2019). It swings about 0.5 °C across the day, and sites differ by up to ~1 °C, so tag where you measured. Overnight wrist readings track relative change, not a clinical fever value.",
"activeEnergyTitle": "Active energy",
"activeEnergyBody": "Active energy is the calories you burn through movement, on top of the baseline your body uses at rest. It is a direct measure of how physically active your day was.",
"workoutsTitle": "Workouts",
"workoutsBody": "Workouts are the structured exercise sessions your devices record, with type, duration, and energy. Tracking them shows how regular training accumulates over weeks and how it relates to your other health signals.",
"respiratoryRateTitle": "Respiratory rate",
"respiratoryRateBody": "Respiratory rate is the number of breaths you take per minute. As general guidance, at rest most adults breathe 12–20 times a minute (American Lung Association); it is meaningful only truly at rest or asleep. A sustained rise of about +3 above your own nightly baseline can be an early illness signal — clinical escalation cutoffs are hospital tools, not self-alarms.",
"bodyWaterTitle": "Total body water",
"bodyWaterBody": "Total body water is the share of your body weight made up of fluid, inside and around your cells. It shifts with hydration, food, and activity, so the trend over weeks tells you more than any single reading.",
"boneMassTitle": "Bone mass",
"boneMassBody": "Bone mass is the estimated weight of the mineral in your skeleton. Scale-based estimates are approximate, but a steady or rising trend supports long-term bone strength.",
"fatFreeMassTitle": "Fat-free mass",
"fatFreeMassBody": "Fat-free mass is everything in your body that is not fat — muscle, bone, organs, and water. Holding or building it while losing weight usually signals healthier body composition.",
"fatMassTitle": "Fat mass",
"fatMassBody": "Fat mass is the total weight of fat in your body. Some fat is essential for hormones and energy storage, but too much — especially around the abdomen — raises long-term health risk (WHO 2024).",
"muscleMassTitle": "Muscle mass",
"muscleMassBody": "Muscle mass is the estimated weight of the muscle in your body. More muscle supports strength, metabolism, and mobility, and it tends to need active effort to preserve as you age.",
"visceralFatTitle": "Visceral fat",
"visceralFatBody": "Visceral fat is the fat stored deep in the abdomen around your organs. Unlike fat under the skin, a high level is closely linked to heart disease and type 2 diabetes (AHA, Circulation 2021), so lower is generally better. The scale shows a unitless rating (about 1–12 is the healthy range, 13 and up elevated); this is a separate scale from the imaging cm² thresholds clinicians use and is not interchangeable with them.",
"leanBodyMassTitle": "Lean body mass",
"leanBodyMassBody": "Lean body mass is your total weight minus body fat — muscle, bone, organs, and water combined. Tracking it alongside weight shows whether changes come from muscle or from fat.",
"flightsClimbedTitle": "Flights climbed",
"flightsClimbedBody": "Flights climbed counts the floors you go up each day, with one flight roughly ten feet of elevation. It is a simple marker of incidental activity and lower-body effort.",
"walkingDistanceTitle": "Walking + running distance",
"walkingDistanceBody": "Walking and running distance is the ground you cover on foot each day. It complements step count by reflecting how far you moved, not just how often you stepped.",
"walkingSteadinessTitle": "Walking steadiness",
"walkingSteadinessBody": "Walking steadiness estimates how stable and balanced your gait is, classed from OK to low. A lower level points to a higher chance of a fall and is worth discussing with a clinician.",
"walkingHeartRateTitle": "Walking heart rate",
"walkingHeartRateBody": "Walking heart rate is your average pulse during a steady walk. A lower walking rate over time often reflects improving cardiovascular fitness for the same effort.",
"walkingAsymmetryTitle": "Walking asymmetry",
"walkingAsymmetryBody": "Walking asymmetry is the share of time your steps are uneven between the left and right leg. A higher percentage can point to pain, injury, or a gait problem worth looking into.",
"doubleSupportTimeTitle": "Double support time",
"doubleSupportTimeBody": "Double support time is the share of each walking cycle when both feet touch the ground at once. A higher percentage usually means a more cautious, less stable gait.",
"stepLengthTitle": "Step length",
"stepLengthBody": "Step length is the average distance from one footfall to the next. A shorter step over time can reflect fatigue, caution, or a change in mobility.",
"walkingSpeedTitle": "Walking speed",
"walkingSpeedBody": "Walking speed is how fast you move on a level walk. It is a well-studied marker of overall health and mobility, and a steady or rising pace is a good sign.",
"pulseWaveVelocityTitle": "Pulse wave velocity",
"pulseWaveVelocityBody": "Pulse wave velocity measures how fast the pressure wave from each heartbeat travels along your arteries. A faster wave indicates stiffer arteries, which is linked to higher cardiovascular risk. As general guidance, European clinical reference flags carotid-femoral velocity above 10 m/s (ESC/ESH 2018) — but a consumer estimate is a proxy, not interchangeable with that clinical measurement.",
"vascularAgeTitle": "Vascular age",
"vascularAgeBody": "Vascular age estimates how old your arteries behave compared with your actual age, based on arterial stiffness. A vascular age below your real age suggests healthier blood vessels.",
"environmentalAudioTitle": "Environmental sound levels",
"environmentalAudioBody": "Environmental sound levels track the loudness of the world around you in decibels. Prolonged exposure above about 70 dB can damage hearing over time, so lower daily averages are safer.",
"headphoneAudioTitle": "Headphone audio levels",
"headphoneAudioBody": "Headphone audio levels track how loudly you listen through earbuds or headphones, in decibels. Keeping the volume down and taking breaks protects your hearing over the long run.",
"audioEventsTitle": "Loud sound events",
"audioEventsBody": "Loud sound events flag moments when noise around you reached a level that can harm hearing with enough exposure. Frequent events are a cue to seek quieter surroundings or hearing protection.",
"daylightTitle": "Time in daylight",
"daylightBody": "Time in daylight is how long you spend in outdoor light each day. Regular daylight supports a healthy sleep-wake rhythm and mood (CDC 2024), and time outdoors is linked to eye health.",
"bloodGlucoseTitle": "Blood glucose",
"bloodGlucoseBody": "Blood glucose is the amount of sugar in your blood, the body's main energy source. It rises after eating and settles between meals. As general guidance for adults without diabetes, a fasting reading under 100 mg/dL is normal, 100–125 is prediabetes, and 126 or above falls in the diabetes range (ADA 2024). A single high reading does not diagnose — a clinician confirms with a repeat test, and people managing diabetes use different, clinician-set targets.",
"skinTemperatureTitle": "Skin temperature",
"skinTemperatureBody": "Skin temperature here is the small nightly change in your wrist temperature from your own baseline, not an absolute fever reading. Shifts can track sleep, environment, illness, or menstrual-cycle phase.",
"cardioFitnessTitle": "Cardio fitness (VO₂ max)",
"cardioFitnessBody": "VO₂ max is how much oxygen your body can use at peak effort — the best lab index of aerobic fitness, which wearables estimate indirectly. As general guidance, fitness is a graded predictor of survival: each one-MET (about 3.5 mL/kg/min) higher value associates with roughly 10–25% lower mortality (AHA, Circulation 2016). A higher or rising trend is a strong sign of cardiovascular health.",
"cardioRecoveryTitle": "Cardio recovery",
"cardioRecoveryBody": "Cardio recovery is how many beats per minute your heart rate falls in the first minute after you stop hard exercise. A bigger drop means your heart settles down faster, which is a sign of good fitness.",
"wristTemperatureTitle": "Wrist temperature",
"wristTemperatureBody": "Wrist temperature is measured while you sleep and reported as the difference from your personal baseline, not an absolute value. A steady reading is normal, while a sustained rise can hint at illness, intense training, or a menstrual-cycle change.",
"fallsTitle": "Falls",
"fallsBody": "This counts the hard falls your wearable detects. Zero is the goal; a rising count is a mobility-risk signal worth raising with a clinician, especially for older adults (CDC STEADI 2024).",
"sixMinuteWalkTitle": "Six-minute walk distance",
"sixMinuteWalkBody": "This is an estimate of how far you could walk in six minutes — a long-standing clinical test of mobility and cardiopulmonary endurance. The wearable figure is a proxy, not the supervised clinical test; a steady or rising trend is the useful read.",
"stairAscentSpeedTitle": "Stair ascent speed",
"stairAscentSpeedBody": "This is how fast you climb a flight of stairs. It is a sensitive marker of leg strength and mobility — a steady or rising pace is a good sign, and a falling one is worth watching.",
"stairDescentSpeedTitle": "Stair descent speed",
"stairDescentSpeedBody": "This is how fast you go down a flight of stairs. Together with ascent speed it tracks leg control and balance — a steady pace is a good sign, and a falling one is worth watching.",
"breathingDisturbancesTitle": "Sleep breathing disturbances",
"breathingDisturbancesBody": "A wearable can estimate how often your breathing was interrupted during sleep. A lower index is better; a level flagged as elevated, especially across many nights, is worth raising with a clinician. It is a screening proxy, not a sleep-apnea diagnosis.",
"stepsTitle": "Steps",
"stepsBody": "Step count is the total number of steps you take each day. It is the simplest measure of everyday movement, and a steady habit matters more than any single big day. As general guidance, mortality risk falls steeply toward about 8,000 steps a day with continued benefit through roughly 12,000 (Saint-Maurice JAMA 2020); the optimal count is lower for older adults. The '10,000' figure is a marketing slogan, not research.",
"readinessBody": "Readiness is a single daily read on how prepared your body is to take on the day, blending resting heart rate, heart-rate variability, sleep, and how steady your recent nights have been. It is a device proxy benchmarked against your own baseline, not a clinical measure, and scores are not comparable across devices. A higher score means recovered and resourced; a lower one is a gentle cue to ease off.",
"sleepScoreBody": "Your sleep score distils a whole night into one number — duration, efficiency, regularity, and the balance of light, deep, and REM. Watch the pattern across weeks rather than chasing a perfect score on any single night.",
"recoveryBody": "Recovery reflects how well your body has bounced back, drawing on overnight signals like heart-rate variability and resting heart rate. It is a device-derived proxy read against your own baseline, not a clinical measure, and is not comparable across devices. A strong score is a green light to push; a softer one suggests your system is still rebuilding.",
"strainBody": "Strain captures how much cardiovascular and physical load you have put on your body, from workouts to an active day on your feet. It is a device-derived index judged against your own typical effort, not a clinical measure, and the headline number is not comparable across devices.",
"stressBody": "Stress here is a device heuristic derived from heart-rate-variability patterns, not a cortisol measurement or how you consciously feel — and not a clinical stress score. Read it against your own baseline; it is not comparable across devices. A lower score points to a calm, well-regulated state; a higher one is an invitation to build in recovery.",
"recoveryPageTitle": "Recovery",
"recoveryPageBody": "Recovery brings together the overnight signals your wearable records — recharge, strain, load and your day's heart-rate and energy totals — to show how well your body has bounced back. Read the pattern across days rather than any single number; a softer reading is an invitation to ease off, not an alarm.",
"pulsePressureTitle": "Pulse pressure",
"pulsePressureBody": "Pulse pressure is the gap between your systolic and diastolic readings (systolic minus diastolic). As general guidance, about 40 mmHg is typical at rest; a wide gap above roughly 60 mmHg in middle age can reflect stiffening arteries, while a narrow gap under about 25 mmHg can accompany low stroke volume (StatPearls/NIH 2023). It is a discussion prompt, not a diagnosis.",
"meanArterialPressureTitle": "Mean arterial pressure",
"meanArterialPressureBody": "Mean arterial pressure is the average pressure your organs see across a heartbeat, roughly the diastolic plus a third of the gap to systolic. As general guidance, a resting value sits around 70–100 mmHg (StatPearls/NIH 2023). The often-cited ~60 mmHg floor is an acute-care concept, not a consumer self-alarm.",
"cycleEducationBody": "Your cycle runs through four phases driven by shifting hormones, and its length and regularity are an accessible window on overall health. As general guidance, adult cycles typically run 21–35 days (clinical references cite 24–38), with bleeding around five days (ACOG 2015). The luteal phase is the steady anchor, so ovulation is best estimated by counting back from your next expected period — never a fixed 'day 14'. A predicted fertile window is an estimate that needs several recorded cycles and stays approximate, and basal body temperature confirms ovulation after the fact rather than predicting it. This is not a contraceptive method.",
"sleepHygieneBody": "A few evidence-based habits support sleep: keep consistent bed and wake times including weekends, get bright morning daylight and dim your evenings, keep the room cool, dark and quiet, and avoid afternoon caffeine, late alcohol and large late meals (CDC 2024). Regularity predicts health at least as strongly as duration, and a wearable's total sleep time is far more trustworthy than its deep, light and REM split.",
"referenceGuidance": "General guidance, not medical advice. Reference ranges are population anchors that shift with your age, sex and health conditions — your own trend over time leads the read."
"sleepHygieneBody": "A few evidence-based habits support sleep: keep consistent bed and wake times including weekends, get bright morning daylight and dim your evenings, keep the room cool, dark and quiet, and avoid afternoon caffeine, late alcohol and large late meals (CDC 2024). Regularity predicts health at least as strongly as duration, and a wearable's total sleep time is far more trustworthy than its deep, light and REM split."
},
"bpDerived": {
"pulsePressure": "Pulse pressure (the gap between your readings) is about {value} mmHg — roughly 40 is typical at rest.",
@@ -2896,16 +2816,6 @@
"compositionTitle": "Stage composition",
"compositionSubtitle": "Last {nights} nights",
"compositionAriaLabel": "Sleep stage composition over {nights} nights",
"hypnogram": {
"title": "Last night",
"subtitle": "{asleep} asleep",
"ariaLabel": "Sleep stage progression for last night: {asleep} asleep, {awakenings} awakenings",
"asleepTotal": "Asleep {value}",
"inBedTotal": "In bed {value}",
"awakenings": "{count} awakenings",
"source": "Source: {source}",
"estimateNote": "Stage timing is estimated — this source reports totals per stage, not a measured timeline."
},
"sourceDiscrepancy": {
"tooltipTitle": "Sources disagree"
},
@@ -2960,7 +2870,6 @@
"loadError": "Your sleep rhythm couldn't load just now. It'll reappear once the connection settles."
}
},
"correlations": "Correlations",
"medicationCompliance": "Medication adherence",
"dayStreak": "{count} day streak",
"dayStreakOne": "{count} day streak",
@@ -2969,19 +2878,12 @@
"taken": "taken",
"skipped": "skipped",
"missed": "missed",
"generalStatusBadge": {
"good": "Good",
"watch": "Watch",
"critical": "Critical"
},
"assessmentTitle": "Assessment",
"restMode": {
"title": "Rest Mode",
"body": "An illness or condition episode is active, so this reflects a recovery window. Your score isn't lowered for it.",
"bodySince": "An illness or condition episode has been active since {since}, so this reflects a recovery window. Your score isn't lowered for it."
},
"assessmentShowMore": "Show more",
"assessmentShowLess": "Show less",
"bloodPressureSectionTitle": "Blood Pressure",
"weightSectionTitle": "Weight",
"pulseSectionTitle": "Pulse",
@@ -2989,8 +2891,6 @@
"complianceNoData": "No adherence data available.",
"generate": "Generate new analysis",
"moodSectionTitle": "Mood",
"confidence": "Confidence",
"cached": "Cached",
"emptyState": {
"bloodPressure": {
"title": "No blood pressure entries yet",
@@ -3039,11 +2939,6 @@
"title": "No workouts yet",
"description": "Open the Apple Health app on your iPhone to sync workouts here. Withings activity import keeps walking and cycling sessions flowing in too."
},
"row": {
"duration": "Duration",
"distance": "Distance",
"energy": "Active energy"
},
"detail": {
"backToList": "Back to workouts",
"statsTitle": "Stats",
@@ -3056,13 +2951,9 @@
"statsStepCount": "Steps",
"statsElevation": "Elevation",
"statsPace": "Pace",
"statsSource": "Source",
"routeTitle": "Route",
"routeUnavailable": "No GPS route recorded for this workout.",
"hrChartTitle": "Heart rate",
"hrChartUnavailable": "Per-second heart-rate samples are not stored for this workout.",
"notFound": "Workout not found.",
"loading": "Loading workout…",
"hrFromPulse": "From your heart-rate data around this session",
"hrAxisMinutes": "min",
"sportAverageLabel": "Your recent average:",
@@ -3534,7 +3425,6 @@
"title": "What stands out",
"subheading": "Discovered relationships",
"footer": "Surfaced from {tested} day-to-day pairs tested across all your metrics, after the false-discovery correction.",
"empty": "No mood relationships cleared the statistical bar yet. Keep logging.",
"disclaimer": "Statistically filtered, but still observational — a pattern worth watching, not a cause.",
"moodUp": "Higher {factor} tends to go with higher next-day mood in your data.",
"moodDown": "Higher {factor} tends to go with lower next-day mood in your data.",
@@ -3913,8 +3803,6 @@
},
"briefing": {
"memory": {
"recall": "Earlier, {text}",
"forward": "Looking ahead: {text}",
"forwardWatch": "Worth keeping an eye on {metric}.",
"forwardHolding": "Everything's holding steady for now."
}
@@ -3928,9 +3816,7 @@
}
},
"streak": {
"returnLabel": "Your {metric} has been back inside your usual range for {count} days now — whatever that was, it's passed.",
"daysInside": "{count} days inside",
"daysOutside": "{count} days outside"
"returnLabel": "Your {metric} has been back inside your usual range for {count} days now — whatever that was, it's passed."
},
"updatedTodayAt": "Updated today, {time}",
"updatedYesterday": "Updated yesterday",
@@ -4231,6 +4117,7 @@
"displayNamePlaceholder": "How should HealthLog address you?",
"displayNameHint": "Shown on the dashboard greeting and in reports.",
"heightLabel": "Height (cm)",
"heightLabelFtIn": "Height (ft, in)",
"genderLabel": "Gender",
"genderNone": "Prefer not to say",
"genderMale": "Male",
@@ -4558,10 +4445,6 @@
"passphraseHelp": "Derives the archive key with Argon2id and seals it with AES-256-GCM. Save the file with a .hlx extension.",
"noRecoveryWarning": "There is no recovery — if you lose this passphrase, the archive cannot be opened. Keep it somewhere safe.",
"stepUpNeeded": "Confirm your second factor, then try again."
},
"cycle": {
"title": "Cycle",
"description": "Cycle and reproductive-health data as a FHIR document — last period, average cycle and period length, current phase."
}
},
"import": {
@@ -4583,6 +4466,7 @@
"failed": "The import failed.",
"rowsImported": "{count} rows imported so far",
"doneSummary": "Done — {imported} of {read} records imported, {skipped} clinical records skipped.",
"doneNothing": "Nothing was imported from that archive: 0 of {read} records landed.",
"estimateWarning": "Cumulative totals for {count} day(s) were estimated from the largest export source. Native Apple Health statistics remain authoritative.",
"phase": {
"queued": "Queued…",
@@ -4606,7 +4490,10 @@
"rateLimited": "Too many imports this hour (limit 5). Please try again later.",
"failed": "The import failed. Please try again.",
"readFailed": "Couldn't read that file.",
"resultSummary": "Imported {measurements} measurements and {moods} mood entries, {skipped} skipped."
"resultSummary": "Imported {measurements} measurements and {moods} mood entries, {skipped} skipped.",
"resultSuccess": "Imported {measurements} measurements and {moods} mood entries.",
"resultNothing": "Nothing was imported. All {skipped} entries were skipped.",
"resultEmpty": "That file carried no measurements and no mood entries."
},
"csv": {
"title": "CSV file",
@@ -4627,7 +4514,30 @@
"readFailed": "Couldn't read that file.",
"previewSummary": "Preview: {inserted} would import, {skipped} skipped.",
"resultSummary": "Imported {inserted} new and updated {updated}, {skipped} skipped.",
"rowError": "Line {line}: {reason}"
"rowError": "Line {line}: {reason}",
"resultSuccess": "Imported {inserted} new and updated {updated}.",
"resultNothing": "Nothing was imported. All {skipped} rows were skipped.",
"resultEmpty": "That file has a header but no data rows.",
"previewSuccess": "Preview: {inserted} would import.",
"previewNothing": "Preview: nothing would import. All {skipped} rows would be skipped.",
"skipGroupOne": "{count} row skipped: {reason}",
"skipGroupFew": "{count} rows skipped: {reason}",
"skipGroupOther": "{count} rows skipped: {reason}",
"skipDetails": "Show every skipped row",
"reasonUnknownType": "unknown measurement type",
"reasonInvalidValue": "value is not a number",
"reasonValueOutOfRange": "value outside the plausible range",
"reasonUnknownUnit": "unit not recognised for this type",
"reasonMissingTimezoneOffset": "timestamp has no timezone offset",
"reasonInvalidTimestamp": "timestamp could not be read",
"reasonImplausibleTimestamp": "timestamp is in the future or before 1900",
"reasonUnexpectedGlucoseContext": "glucose context set on a row that is not blood glucose",
"reasonInvalidGlucoseContext": "glucose context is not one of the allowed values",
"reasonNotesTooLong": "note is too long",
"reasonExternalIdTooLong": "external ID is too long",
"reasonUnstableExternalId": "external ID is not stable across exports",
"reasonDuplicate": "already imported",
"reasonUnknown": "skipped"
}
},
"subtitle": "Export your data or import from a backup."
@@ -4778,6 +4688,7 @@
},
"username": "Username",
"height": "Height (cm)",
"heightFtIn": "Height (ft, in)",
"dateOfBirth": "Date of birth",
"dateOfBirthHint": "Used for automatic blood pressure target calculations.",
"gender": "Gender",
@@ -4811,17 +4722,10 @@
"passwordMismatch": "New passwords do not match",
"passwordUpdated": "Password updated successfully",
"passkeys": "Passkeys",
"registeredPasskeys": "Registered Passkeys",
"passkeysDescription": "Passkeys allow secure login without a password.",
"passkeyName": "Name",
"passkeyDevice": "Device",
"passkeyBackup": "Backup",
"passkeyCreated": "Created",
"passkeyActions": "Actions",
"noPasskeys": "No passkeys registered.",
"addPasskey": "Add passkey",
"passkeyAdded": "Passkey added successfully!",
"passkeyOptionsError": "Could not load passkey options",
"passkeyRegistrationFailed": "Passkey registration failed",
"passkeyRegistrationCancelled": "Passkey registration cancelled",
"passkeyNotSupported": "Your browser or device doesn't support passkeys yet. Try a different browser or device.",
@@ -4870,7 +4774,6 @@
"webPushSubscribed": "Push notifications enabled!",
"webPushUnsubscribed": "Push notifications disabled.",
"webPushSubscribeFailed": "Activation failed",
"webPushActive": "Active",
"kiInsightsDescription": "Optional: Save your OpenAI key for daily, automatic evaluations in Insights.",
"codexConnected": "ChatGPT connected successfully! Insights are now active.",
"codexDisconnected": "ChatGPT connection removed.",
@@ -4908,7 +4811,6 @@
"savedShort": "(saved)",
"saveCta": "Save",
"saved": "Saved",
"saveFailed": "Save failed",
"errorGeneric": "Error",
"providerChain": {
"types": {
@@ -5044,11 +4946,9 @@
"hint": "Passed to the Coach and the daily briefing as additional context. Leave empty to send nothing.",
"profileHint": "Age and gender come from your profile and are included automatically.",
"freeTextLabel": "About me (free text)",
"conditionsLabel": "Chronic conditions",
"conditionsPlaceholder": "E.g. hypertension, type 2 diabetes…",
"allergiesLabel": "Allergies & intolerances",
"allergiesPlaceholder": "E.g. lactose intolerance, penicillin allergy…",
"focusLabel": "What should the Coach watch?",
"focusPlaceholder": "E.g. keep an eye on my blood pressure, motivate me to move more…",
"completeness": "{answered} of {total} answered",
"questionsTitle": "The Coach has follow-up questions",
@@ -5126,8 +5026,6 @@
"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."
},
"integrationsSourcesHint": "When several sources report the same metric, the source priority decides which value counts.",
"integrationsSourcesHintLink": "Open source priority",
"withingsSynchronize": "Synchronize",
"withingsDisconnect": "Disconnect",
"withingsDisconnectTitle": "Disconnect Withings?",
@@ -5257,7 +5155,7 @@
"integrationPill": {
"connected": "Connected",
"errorReconnect": "Error — reconnect",
"warningServerError": "Connected, server error",
"warningServerError": "Connected, sync issue",
"parkedReconnect": "Paused — reconnect manually",
"notConnected": "Not connected",
"justNow": "just now",
@@ -5267,8 +5165,26 @@
"ariaLabel": "Integration status",
"resumeCta": "Reconnect",
"resumeSuccess": "Integration resumed",
"resumeError": "Reconnect failed"
"resumeError": "Reconnect failed",
"stale": "Connected, no recent data",
"stalled": "Sync stopped",
"pendingSetup": "Waiting for first data"
},
"integrationFreshness": {
"title": "Data received",
"summary": "{count} data types · newest {relative}",
"quiet": "{count} quiet",
"workouts": "Workouts"
},
"integrationFallback": {
"description": "Configured on this account without a settings card of its own. This row exists so a connection cannot stop working unnoticed.",
"lastAttempt": "Last attempt {relative}",
"noAttempt": "No sync attempt recorded"
},
"withingsViewData": "View your measurements",
"whoopViewData": "View your recovery",
"fitbitViewData": "View your sleep",
"googleHealthViewData": "View your steps",
"apiTokens": "API Tokens",
"apiTokensDescription": "Review and revoke API tokens for external medication intake. Tokens are issued per medication from the medication's API endpoint toggle.",
"tokenMintMovedDescription": "Tokens are issued per medication. Open a medication and enable its API endpoint to get a token scoped to that medication alone.",
@@ -5300,10 +5216,7 @@
"apiEndpointPath": "Path",
"apiEndpointAuth": "Authentication",
"apiEndpointExample": "Body example",
"collapse": "Collapse",
"expand": "Expand",
"dangerZone": "Delete All Data",
"dangerZoneTitle": "Danger zone",
"dangerZoneDescription": "Deletes all your health data and integrations. Your user account will be preserved.",
"dangerZoneConfirm": "Delete all data?",
"dangerZoneConfirmDescription": "This will permanently delete all your health data and integrations. Your user account will be preserved.",
@@ -5320,31 +5233,9 @@
"deleteAccountFinal": "Delete permanently",
"saved": "Saved",
"savingError": "Error saving",
"moodLogTitle": "moodLog",
"moodLogDescription": "Import mood entries.",
"moodLogDeprecated": "Deprecated: mood is now tracked natively in HealthLog with tags and factors. This integration still works but will be removed in a future release.",
"moodLogUrl": "moodLog URL",
"moodLogUrlPlaceholder": "https://mood.example.com",
"moodLogApiKey": "API Key",
"moodLogApiKeyPlaceholder": "ml_...",
"moodLogWebhookSecret": "Webhook Secret",
"moodLogWebhookSecretHelp": "Enter this secret in moodLog as webhook secret",
"moodLogSync": "Sync now",
"moodLogFullSync": "Sync all data",
"moodLogFullSyncConfirm": "Synchronize",
"moodLogFullSyncTitle": "Full sync?",
"moodLogFullSyncDescription": "All available mood data will be fully synchronized.",
"moodLogDisconnect": "Disconnect",
"moodLogDisconnectTitle": "Disconnect moodLog?",
"moodLogDisconnectDescription": "The connection will be removed and all imported mood data will be deleted.",
"moodLogEntries": "Entries",
"moodLogSyncResult": "{count} entries synchronized",
"moodLogSyncFailed": "Sync failed",
"moodLogDisconnected": "moodLog disconnected",
"about": {
"version": "App version",
"gitSha": "Build",
"builtAt": "Built {time}",
"builtAtLabel": "Built",
"license": "License",
"repository": "Source code",
@@ -5416,34 +5307,17 @@
"practiceName": "Practice (optional)",
"practiceNamePlaceholder": "e.g. Dr. Smith",
"includedData": "Included data",
"groupVitals": "Vital signs",
"groupCardio": "Heart & fitness",
"groupActivity": "Activity & sleep",
"weight": "Weight",
"bp": "Blood pressure",
"pulse": "Pulse",
"oxygenSaturation": "Oxygen saturation",
"bodyFat": "Body fat",
"bodyComposition": "Body composition",
"restingHeartRate": "Resting heart rate",
"hrv": "Heart rate variability",
"vo2max": "VO₂max",
"steps": "Steps",
"distance": "Distance",
"sleep": "Sleep",
"glucose": "Blood glucose",
"medications": "Medications & adherence",
"mood": "Mood (off by default — sensitive data)",
"bmi": "BMI",
"includeCharts": "Embed trend charts (PDF only)",
"includeAiSummary": "Append AI summary (not clinically validated)",
"fhirNote": "The structured output follows the FHIR R4 standard (ePA-compatible).",
"generate": "Generate",
"error": "Export failed ({code}).",
"labs": "Lab results",
"allergies": "Allergies & intolerances",
"familyHistory": "Family history",
"documentsLink": "Documents for this period"
"documentsLink": "Documents for this period",
"range30": "Last 30 days",
"rangeCustom": "Custom period",
"rangeCustomStart": "From",
"rangeCustomEnd": "To",
"errorGeneric": "The export could not be generated. Please try again.",
"errorRateLimit": "You have reached the export limit for this hour.",
"errorModuleDisabled": "The health record export is switched off for this account."
},
"globalExcludedInjectionSitesLabel": "Globally excluded injection sites",
"globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them.",
@@ -5457,13 +5331,8 @@
"expiry": "Expires in (days)",
"expiryHint": "At most {max} days.",
"expiryInvalid": "Expiry must be between 1 and {max} days.",
"fhirApi": "Allow scoped FHIR API",
"fhirApiHint": "Let the link serve a read-only FHIR endpoint in addition to the page view.",
"resourceTypes": "FHIR resource types",
"create": "Create link",
"tokenCreated": "Share link created",
"tokenOnce": "Copy this link now — the token is shown only once and cannot be recovered.",
"copy": "Copy link",
"copied": "Copied to clipboard.",
"activeTitle": "Active links",
"noActive": "No active share links.",
@@ -5477,7 +5346,6 @@
"statusExpired": "Expired",
"inactiveTitle": "Revoked and expired ({count})",
"shownOnce": "Shown once only. Capture the QR code and passphrase now — neither can be recovered later.",
"qrLabel": "QR code",
"qrAlt": "QR code containing the share link and passphrase",
"linkLabel": "Link",
"passphraseLabel": "Passphrase",
@@ -5500,7 +5368,12 @@
"pickerEmpty": "No documents found.",
"pickerDone": "Done",
"pickerSelectedCount": "{count} of {max} selected",
"pickerMaxReached": "You've reached the maximum of {max} documents."
"pickerMaxReached": "You've reached the maximum of {max} documents.",
"scopeTitle": "What this link contains",
"scopeInsuranceExcluded": "Insurance details are never shared through a link.",
"statusNeedsReselect": "Pick contents again",
"reselectReason": "This link served a scope that was never chosen, so it was closed. Create a new one and pick what it should contain.",
"reselectAction": "Create a replacement"
},
"genderOther": "Other",
"cycleTracking": {
@@ -5527,13 +5400,15 @@
"nightscoutUpdate": "Save changes",
"nightscoutConnected": "Nightscout connected.",
"nightscoutConnectFailed": "Could not connect to Nightscout.",
"nightscoutSync": "Sync now",
"nightscoutSyncResult": "{count} measurements synchronized",
"nightscoutSyncFailed": "Sync failed",
"nightscoutDisconnect": "Disconnect",
"nightscoutDisconnectTitle": "Disconnect Nightscout?",
"nightscoutDisconnectDescription": "Future syncs stop. Glucose readings already imported stay in your history.",
"polar": "Polar",
"polarDescription": "Connect your Polar account to sync Nightly Recharge recovery, sleep stages, and daily activity.",
"polarDescription": "Connect your Polar account to sync Nightly Recharge recovery, sleep stages, daily activity, and training sessions.",
"polarViewData": "View your sleep & recovery",
"polarHelp": "Sign in with Polar to grant access. Data is pulled in the background once an hour.",
"polarCredentials": "API Credentials",
"polarClientId": "Client ID",
"polarClientSecret": "Client Secret",
@@ -5542,6 +5417,9 @@
"polarSaveCredentials": "Save",
"polarUnavailable": "Polar is not configured on this server. Add your own Polar OAuth credentials above, or ask your administrator to configure the shared app.",
"polarConnect": "Connect Polar",
"polarSync": "Sync now",
"polarSyncResult": "{count} measurements synchronized",
"polarSyncFailed": "Sync failed",
"polarDisconnect": "Disconnect",
"polarDisconnectTitle": "Disconnect Polar?",
"polarDisconnectDescription": "Future syncs stop. Readings already imported stay in your history.",
@@ -5560,7 +5438,6 @@
"oura": "Oura",
"ouraDescription": "Connect your Oura account to sync readiness, sleep stages, heart-rate variability, and daily activity.",
"ouraViewData": "View your sleep & recovery",
"ouraHelp": "Sign in with Oura to grant access. Data is pulled in the background once an hour.",
"ouraCredentials": "API Credentials",
"ouraClientId": "Client ID",
"ouraClientSecret": "Client Secret",
@@ -5569,6 +5446,9 @@
"ouraSaveCredentials": "Save",
"ouraUnavailable": "Oura is not configured on this server. Add your own Oura OAuth credentials above, or ask your administrator to configure the shared app.",
"ouraConnect": "Connect Oura",
"ouraSync": "Sync now",
"ouraSyncResult": "{count} measurements synchronized",
"ouraSyncFailed": "Sync failed",
"ouraDisconnect": "Disconnect",
"ouraDisconnectTitle": "Disconnect Oura?",
"ouraDisconnectDescription": "Future syncs stop. Readings already imported stay in your history.",
@@ -5797,6 +5677,9 @@
"stravaSaveCredentials": "Save",
"stravaUnavailable": "Strava is not configured on this server. Add your own Strava API credentials above, or ask your administrator to configure the shared app.",
"stravaConnect": "Connect Strava",
"stravaSync": "Sync now",
"stravaSyncResult": "{count} measurements synchronized",
"stravaSyncFailed": "Sync failed",
"stravaDisconnect": "Disconnect",
"stravaDisconnectTitle": "Disconnect Strava?",
"stravaDisconnectDescription": "Future syncs stop. Activities already imported stay in your history.",
@@ -5816,17 +5699,14 @@
"title": "Apple Health",
"description": "Live sync comes from the HealthLog iOS app. It is not a web or OAuth connection.",
"status": {
"checking": "Checking Apple Health data…",
"dataReceived": "Apple Health data received",
"setup": "Set up in iOS app",
"unavailable": "Apple Health status unavailable"
},
"lastDataLabel": "Last Apple Health data:",
"setupTitle": "Turn on live sync",
"permissionStep": "In the HealthLog iOS app, open Settings → Apple Health and allow the health categories you want to share.",
"backgroundStep": "Keep Background App Refresh enabled. The app syncs new data automatically in the foreground and when iOS grants background time.",
"importNote": "Need older history or cannot use the iOS app? The Apple Health XML/ZIP upload is a one-shot fallback and does not enable live sync.",
"importAction": "Open one-shot import"
"importAction": "Open one-shot import",
"staleNote": "No Apple Health data has arrived for over a week. Open the HealthLog app on your iPhone and check the Apple Health permissions."
},
"garminInfo": {
"title": "Garmin",
@@ -5935,7 +5815,6 @@
"telegramGlobal": "Allow Telegram notifications.",
"ntfyGlobal": "Allow ntfy notifications.",
"webPushGlobal": "Allow browser push notifications.",
"moodLogGlobal": "Allow mood-log reminders.",
"integrationWebPush": "Web Push",
"offlineGeoLabel": "Geo data",
"offlineGeoEnabled": "Offline (GeoLite2)",
@@ -7030,7 +6909,6 @@
"period": "Reporting period: {start} – {end}",
"expires": "This link expires on {date}.",
"provenance": "This is a read-only summary shared by the person it belongs to, exported from their self-tracked HealthLog data. Values are self-recorded and may include device-synced readings; treat them as patient-reported, not as a clinical examination.",
"vitals": "Vital signs",
"labs": "Lab values",
"medications": "Medications & adherence",
"bmi": "Body mass index (BMI)",
@@ -7070,7 +6948,10 @@
"download": "Download",
"attachmentHint": "This file downloads to your device (no in-page preview).",
"exifNote": "Camera metadata (EXIF/GPS) has been removed from shared JPEG, PNG and WebP images."
}
},
"downloadPdf": "Download as PDF",
"downloadFhir": "Download as FHIR",
"bmiSection": "Body mass index"
},
"cycle": {
"symptomCategory": {
@@ -7449,7 +7330,6 @@
"measurementReminders": {
"sectionTitle": "Checkups",
"sectionDescription": "Reminders for what to measure or check, when, and where.",
"addButton": "Add reminder",
"markDone": "Done",
"delete": "Delete",
"deleteConfirmTitle": "Delete reminder?",
@@ -8978,5 +8858,42 @@
}
}
}
},
"reportSelection": {
"groupIdentity": "Personal details",
"groupVitals": "Vital signs",
"groupCardio": "Heart & circulation",
"groupBody": "Body measurements",
"groupSleepRecovery": "Sleep & recovery",
"groupActivity": "Activity",
"groupMobility": "Mobility & gait",
"groupEnvironment": "Environment",
"groupGlucose": "Blood glucose",
"groupLabs": "Lab results",
"groupMedications": "Medications",
"groupHistory": "Conditions & allergies",
"groupSensitive": "Sensitive data",
"leafPatientIdentity": "Name, date of birth, gender, height",
"leafInsurance": "Insurer and insurance number",
"leafGlucosePanel": "Glucose panel (time in range, GMI, variability)",
"leafLabResults": "Lab results",
"leafMedicationList": "Medication list",
"leafMedicationAdministrations": "Every logged dose",
"leafMedicationCompliance": "Adherence rate",
"leafGlp1Therapy": "GLP-1 therapy (dose history, side effects)",
"leafAllergies": "Allergies and intolerances",
"leafIllnessEpisodes": "Illness episodes",
"leafFamilyHistory": "Family history",
"leafMood": "Mood",
"leafCycle": "Menstrual cycle",
"templateStandard": "Standard doctor's report",
"applyStandard": "Apply {template}",
"standardHint": "One click fills in what a general visit usually needs. Every entry stays editable.",
"groupCount": "{on}/{total}",
"groupDisclosure": "Show the entries in {group}",
"sensitiveHint": "Nothing here is ever preselected. Pick each entry yourself.",
"scopeNone": "Nothing selected yet. Choose what to include, or apply the standard report.",
"scopeCount": "{count} entries from {groups} areas",
"scopeSensitive": "incl. {names}"
}
}
+146 -241
View File
@@ -1,6 +1,7 @@
{
"common": {
"save": "Guardar",
"conflictReloaded": "Recargado desde el servidor. Vuelve a aplicar tu cambio.",
"cancel": "Cancelar",
"delete": "Eliminar",
"undo": "Deshacer",
@@ -24,6 +25,10 @@
"openTimePicker": "Abrir el selector de hora",
"hours": "Horas",
"minutes": "Minutos",
"feet": "ft",
"inches": "in",
"heightFeet": "Altura en pies",
"heightInches": "Altura en pulgadas",
"period": "a. m./p. m.",
"am": "a. m.",
"pm": "p. m.",
@@ -54,7 +59,9 @@
"duplicate_timestamp": "Ya existe una medición para esta hora."
},
"mood": {
"list": { "invalid": "Los filtros de la lista de ánimo no son válidos." },
"list": {
"invalid": "Los filtros de la lista de ánimo no son válidos."
},
"create": {
"invalid": "Comprueba la entrada de ánimo e inténtalo de nuevo."
},
@@ -94,12 +101,18 @@
}
},
"onboarding": {
"disclaimer": { "invalid": "Confirma de nuevo el aviso médico." },
"tour": { "invalid": "No se pudo guardar el progreso del recorrido." },
"disclaimer": {
"invalid": "Confirma de nuevo el aviso médico."
},
"tour": {
"invalid": "No se pudo guardar el progreso del recorrido."
},
"complete": {
"invalid": "Comprueba los datos del perfil e inténtalo de nuevo."
},
"user": { "notFound": "No se pudo encontrar tu cuenta." },
"user": {
"notFound": "No se pudo encontrar tu cuenta."
},
"step": {
"rateLimited": "Demasiados intentos. Espera e inténtalo de nuevo.",
"invalid": "No se pudo guardar este paso de la introducción.",
@@ -157,16 +170,6 @@
"footerDisclaimer1": "Este informe se generó automáticamente a partir de datos autorregistrados y está destinado a apoyar",
"footerDisclaimer2": "la conversación entre médico y paciente. No sustituye un diagnóstico médico. Las correlaciones no implican causalidad.",
"footerSource": "Fuente: HealthLog | Zona horaria: {timezone} | Creado: {timestamp}",
"typeWeight": "Peso corporal",
"typeBpSys": "Tensión arterial sistólica",
"typeBpDia": "Tensión arterial diastólica",
"typePulse": "Pulso en reposo",
"typeBodyFat": "Grasa corporal",
"typeSleep": "Duración del sueño",
"typeSteps": "Actividad (pasos)",
"typeTotalBodyWater": "Agua corporal total",
"typeBoneMass": "Masa ósea",
"typeOxygenSaturation": "Saturación de oxígeno",
"unitSteps": "Pasos",
"bmiUnderweight": "Peso insuficiente",
"bmiNormal": "Peso normal",
@@ -202,8 +205,6 @@
"summaryTrend": "{label}: último {latest} {arrow}",
"summaryAdherence": "Adherencia a la medicación: {rate}% (media ponderada).",
"chartsTitle": "Gráficos de tendencia",
"aiSummaryTitle": "Resumen de IA — no validado clínicamente",
"aiSummaryDisclaimer": "Esta sección fue generada automáticamente por un modelo de IA y no está validada clínicamente. No sustituye una evaluación médica.",
"packageReadme": "Historial de salud de HealthLog\n\nEste paquete contiene:\n- report.pdf: el informe de salud para su médico.\n- bundle.json: los mismos datos como Bundle de documento HL7 FHIR R4, compatible con ePA.\n\nGenerado con HealthLog.",
"wellnessTitle": "Resumen de bienestar",
"wellnessDisclaimer": "Son puntuaciones de bienestar descriptivas calculadas a partir de tus señales registradas (0–100). No son evaluaciones clínicas ni diagnósticos.",
@@ -362,6 +363,9 @@
"errorRegistrationDisabled": "Ninguna cuenta coincide con esa identidad y los nuevos registros están cerrados actualmente.",
"errorFailed": "El inicio de sesión único falló. Inténtalo de nuevo."
},
"native": {
"signingInToApp": "Iniciando sesión en la app de HealthLog"
},
"mfa": {
"title": "Autenticación de dos factores",
"totpHint": "Introduce el código de 6 dígitos de tu aplicación de autenticación.",
@@ -453,6 +457,7 @@
"layoutReset": "Restablecer valores por defecto",
"layoutSaveSuccess": "Diseño del panel guardado",
"layoutSaveError": "No se ha podido guardar el diseño",
"layoutConflictReloaded": "Se ha recargado el diseño desde el servidor. Vuelve a aplicar tu cambio.",
"layoutResetSuccess": "Restablecido al diseño por defecto",
"layoutUsingDefaults": "Diseño por defecto activo",
"layoutCustomized": "Diseño personalizado activo",
@@ -894,7 +899,6 @@
"emptyDescription": "Crea medicamentos, dosis y franjas horarias para registrar el cumplimiento.",
"loadFailed": "No se han podido cargar los medicamentos",
"retryLoad": "Reintentar",
"name": "Nombre",
"inactive": "En pausa",
"intakeHistory": "Historial de tomas",
"openDetailPage": "Abrir la página de detalle del medicamento",
@@ -1197,8 +1201,6 @@
"dosesPerUnitLabel": "Dosis por unidad",
"dosesPerUnitPlaceholder": "p. ej. 4",
"dosesPerUnitHint": "Dosis por pluma / vial — déjalo vacío para no controlar el inventario.",
"injectionSiteToggle": "Rotación del sitio de inyección",
"injectionSiteHint": "Guía de rotación opcional. El sitio de cada dosis se registra al anotar la toma.",
"short": "Dosis",
"unitsPerDoseLabel": "Unidades por dosis",
"unitsPerDoseHint": "p. ej., 2 comprimidos de 2 mg para una dosis de 4 mg, o ½ comprimido para una dosis partida"
@@ -1249,7 +1251,6 @@
"remindersDescription": "Push, Telegram o ntfy — según tus ajustes.",
"noChannelHint": "Los recordatorios necesitan un canal de notificaciones — ahora mismo no hay ninguno activo.",
"noChannelHintLink": "Configurar notificaciones",
"multiScheduleNote": "Este medicamento tiene varios horarios. El editor detallado volverá en una versión posterior.",
"short": "Listo"
}
},
@@ -1882,7 +1883,7 @@
"levelShift": "Se detectó un cambio de nivel en torno a {date}.",
"plateau": {
"title": "Meseta de peso con la dosis actual",
"evidence": "Variación de peso de {delta} kg en los últimos {window} días ({readings} mediciones) — {days} días con {dose}."
"evidence": "Variación de peso de {delta} {unit} en los últimos {window} días ({readings} mediciones) — {days} días con {dose}."
},
"retarget": {
"label": "¿No es la medición correcta?",
@@ -2117,7 +2118,6 @@
"emptyDescription": "Los insights aparecen en cuanto registres algunas mediciones. Empieza por peso o tensión arterial.",
"emptyAddMeasurement": "Registrar medición",
"editMode": {
"open": "Personalizar",
"done": "Listo",
"reset": "Restablecer",
"title": "Personalizar resumen",
@@ -2142,16 +2142,6 @@
"bmiEmptyAction": "Abrir ajustes de cuenta",
"noProviderConfigured": "Conecta un proveedor de IA para ver aquí una valoración de tus lecturas.",
"noProviderAction": "Abrir ajustes de IA",
"startAnalysis": "Iniciar valoración",
"refreshAnalysis": "Actualizar valoración",
"keyTakeaway": "Lo más importante",
"findingsTitle": "Hallazgos",
"heroFindingPositive": "Hallazgo principal",
"heroFindingNeutral": "Hallazgo principal",
"heroFindingAttention": "A tener en cuenta",
"heroFindingWarning": "Alerta principal",
"correlationsTitle": "Correlaciones",
"recommendationsTitle": "Recomendaciones",
"personalRecord": {
"badge": "PR",
"tooltip": "Récord personal: {value} {unit} el {date}"
@@ -2159,53 +2149,33 @@
"recommendation": {
"source": "Fuente",
"viewSource": "Abrir guía en una pestaña nueva",
"rationale": "Por qué esta recomendación",
"rationaleWindow": "Ventana",
"rationaleComparedTo": "Comparado con",
"rationaleDeviation": "Desviación",
"rationaleExpand": "Mostrar justificación",
"rationaleCollapse": "Ocultar justificación",
"legacyPayloadCta": "Insights actualizados — vuelve a generar para activar las nuevas explicaciones",
"feedbackHelpful": "Útil",
"feedbackNotHelpful": "No útil",
"feedbackThanks": "Gracias por tu opinión",
"feedbackConfirmed": "Opinión guardada",
"feedbackAlreadyRated": "Ya valorado",
"feedbackError": "No se pudo guardar tu opinión. Inténtalo de nuevo.",
"confidence": "Confianza",
"confidenceAria": "Confianza: {value} de 100",
"confidenceHigh": "Confianza alta",
"confidenceMedium": "Confianza media",
"confidenceLow": "Confianza baja — pocos datos",
"confidenceDraft": "Borrador"
},
"dataFoundation": "Base de datos",
"dataGaps": "Vacíos de datos:",
"lastUpdated": "Última actualización",
"confidenceHoch": "Alta",
"confidenceMittel": "Media",
"confidenceGering": "Baja",
"loadError": "No se han podido cargar los análisis",
"heroPersonalBaseline": "Basado en tus últimos 90 días",
"heroRegenerate": "Regenerar",
"heroRegenerating": "Generando",
"regenerateAnalysis": "Volver a generar la valoración",
"regenerateSuccess": "Valoración regenerada",
"warmStarted": "Las valoraciones se están preparando en segundo plano",
"narrativeTitle": "Resumen",
"narrativeWeek": "Esta semana",
"narrativeMonth": "Este mes",
"narrativePreparing": "Preparando tu resumen…",
"narrativeUpdating": "Actualizando…",
"narrativeProvenanceLabel": "Cómo se elaboró esto",
"narrativeProvenanceMethod": "Un resumen del período en lenguaje sencillo, basado únicamente en tus valores registrados y en las asociaciones que superaron el control estadístico de comparaciones múltiples. Descriptivo, nunca una causa.",
"narrativeProvenanceMetrics": "Basado en: {metrics}",
"narrativeProvenanceWindow": "Ventana: del {from} al {to}",
"heroFallbackSubtitle": "Una mirada diaria a tus tendencias — directamente de los datos que registras.",
"heroGreetingMorning": "Buenos días",
"heroGreetingAfternoon": "Buenas tardes",
"heroGreetingEvening": "Buenas noches",
"heroComingSoonTooltip": "Próximamente",
"heroActionAskCoach": "Preguntar al coach",
"healthScore": {
"label": "Puntuación de salud",
@@ -2218,14 +2188,12 @@
"componentMood": "Estado de ánimo",
"componentCompliance": "Cumplimiento",
"deltaExplainer": {
"trigger": "¿Qué significa este cambio?",
"title": "Cómo se calcula el cambio",
"description": "Cambio respecto a la semana pasada: {delta}",
"body": "La puntuación combina PA, peso, ánimo y adherencia. Comparamos la puntuación de hoy con la del mismo momento de la semana anterior. Registra una medición nueva o una nota de ánimo para empujarla hacia arriba."
},
"provenance": {
"toggle": "Compuesto por",
"weightLabel": "peso",
"mixedBanner": "Algunos componentes combinan varias fuentes. El desglose muestra el valor más reciente por componente.",
"footnote": "Los pesos se reescalan según los datos realmente disponibles.",
"asOfLabel": "a fecha de {date}",
@@ -2289,10 +2257,6 @@
"viewDetail": "Ver detalles de {metric}"
},
"vo2Max": {
"statLatest": "Actual",
"statMin": "Mín",
"statMax": "Máx",
"empty": "Aún no hay datos de VO₂ máx — sincroniza Apple Health para llenar esta vista.",
"cardioLinkTitle": "Forma cardiovascular (VO₂ máx)",
"cardioLinkBody": "El VO₂ máx es la medida principal de la forma aeróbica. El gráfico completo y la evaluación están en su propia página.",
"cardioLinkCta": "Abrir forma cardiovascular"
@@ -2349,6 +2313,7 @@
"send": "Enviar",
"stop": "Detener",
"thinking": "Pensando…",
"unverifiedFiguresNotice": "Algunas cifras no se pudieron contrastar con tus datos y se omitieron.",
"composerPlaceholder": "Pregúntame algo …",
"nudgeBubbleLabel": "Nuevo mensaje del coach — abrir el chat",
"fabLabel": "Abrir el coach",
@@ -2756,125 +2721,68 @@
"gewichtDescription": "Evolución del peso frente a la banda saludable derivada de tu altura, con valoración.",
"pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.",
"stimmungDescription": "Valores diarios de estado de ánimo en el tiempo, con una valoración en texto.",
"stimmungEmptyTitle": "Registrar primer estado de ánimo",
"stimmungEmptyDescription": "Los insights de ánimo aparecen en cuanto registres unas cuantas entradas. Toca abajo para empezar.",
"stimmungEmptyAction": "Abrir diario de ánimo",
"medikamenteDescription": "Calendario de tomas por medicamento, más una valoración en texto.",
"medikamenteEmptyTitle": "Añadir primer medicamento",
"medikamenteEmptyDescription": "Los insights de adherencia necesitan al menos un medicamento registrado.",
"medikamenteEmptyAction": "Abrir medicamentos",
"bmiDescription": "IMC derivado de tu peso y altura, con valoración.",
"target": {
"adjustLink": "Ajustar el rango objetivo"
},
"explainer": {
"trigger": "¿Qué es {title}?",
"bloodPressureTitle": "Presión arterial",
"bloodPressureBody": "La presión arterial es la fuerza que ejerce la sangre sobre las paredes de las arterias, expresada como sistólica sobre diastólica (el pico durante un latido sobre el valor en reposo entre latidos). Los valores altos de forma constante sobrecargan el corazón y los vasos con el tiempo.",
"pulseTitle": "Pulso",
"pulseBody": "El pulso es cuántas veces late el corazón por minuto. Una medición puntual refleja lo que hacías en ese momento: en reposo suele ser de 60 a 100 latidos por minuto y sube de forma natural con el movimiento, el estrés o la cafeína.",
"weightTitle": "Peso",
"weightBody": "El peso corporal es una de las señales más simples del balance energético a largo plazo y de la salud general. La tendencia a lo largo de las semanas importa mucho más que cualquier lectura individual, que varía con la hidratación, la comida y la hora del día.",
"bmiTitle": "Índice de masa corporal (IMC)",
"bmiBody": "El IMC es tu peso dividido por tu altura al cuadrado, un único número que te sitúa en una categoría amplia de peso. Es un cribado poblacional, no un diagnóstico, y no distingue el músculo de la grasa.",
"sleepTitle": "Sueño",
"sleepBody": "La duración del sueño es cuánto dormiste realmente durante la noche, dividido en fases ligera, profunda y REM. La mayoría de los adultos necesita entre siete y nueve horas; tanto la cantidad como la calidad constantes favorecen la recuperación, el ánimo y el metabolismo.",
"moodTitle": "Estado de ánimo",
"moodBody": "El registro del estado de ánimo anota cómo te sientes con el tiempo en una escala sencilla. Anotarlo con regularidad revela patrones —cómo el sueño, la actividad o la medicación se relacionan con cómo te sientes— que un solo día nunca muestra.",
"medicationsTitle": "Adherencia a la medicación",
"medicationsBody": "La adherencia es la fiabilidad con la que tomas tu medicación según lo prescrito, aquí como la proporción de dosis previstas que registraste. Una toma constante suele ser lo que hace que un tratamiento funcione como se pretende.",
"restingHrTitle": "Frecuencia cardíaca en reposo",
"restingHrBody": "La frecuencia cardíaca en reposo es tu ritmo cardíaco cuando estás completamente en reposo, normalmente medido durante la noche. Una frecuencia más baja suele indicar mejor forma cardiovascular, y una tendencia al alza puede ser una señal temprana de estrés o enfermedad.",
"hrvTitle": "Variabilidad de la frecuencia cardíaca (VFC)",
"hrvBody": "La VFC es la pequeña variación en el tiempo entre latidos consecutivos. Una mayor variabilidad suele reflejar un sistema nervioso bien recuperado y adaptable, mientras que una caída sostenida puede señalar fatiga, estrés o recuperación insuficiente.",
"oxygenSaturationTitle": "Saturación de oxígeno (SpO₂)",
"oxygenSaturationBody": "La SpO₂ es el porcentaje de glóbulos rojos que transportan oxígeno. Los valores de 95 a 100 % son normales en reposo; los valores que se mantienen por debajo del 90 % merecen atención, aunque las bajadas breves nocturnas son frecuentes.",
"bodyTemperatureTitle": "Temperatura corporal",
"bodyTemperatureBody": "La temperatura corporal refleja tu estado térmico central, normalmente en torno a 37 °C con pequeñas variaciones diarias. Un aumento sostenido puede indicar una infección, mientras que las lecturas nocturnas en la muñeca reflejan sobre todo cambios relativos, no un valor clínico.",
"activeEnergyTitle": "Energía activa",
"activeEnergyBody": "La energía activa son las calorías que quemas con el movimiento, además del consumo basal en reposo. Es una medida directa de lo físicamente activo que fue tu día.",
"workoutsTitle": "Entrenamientos",
"workoutsBody": "Los entrenamientos son las sesiones de ejercicio estructurado que registran tus dispositivos, con tipo, duración y energía. Registrarlos muestra cómo el entrenamiento regular se acumula a lo largo de las semanas y cómo se relaciona con tus otras señales de salud.",
"respiratoryRateTitle": "Frecuencia respiratoria",
"respiratoryRateBody": "La frecuencia respiratoria es el número de respiraciones que haces por minuto. En reposo, la mayoría de los adultos respiran entre doce y veinte veces por minuto; un aumento sostenido puede acompañar a una enfermedad, el esfuerzo o el estrés.",
"bodyWaterTitle": "Agua corporal total",
"bodyWaterBody": "El agua corporal total es la parte de tu peso compuesta por líquido, dentro y alrededor de las células. Varía con la hidratación, la comida y la actividad, así que la tendencia a lo largo de semanas dice más que una sola medición.",
"boneMassTitle": "Masa ósea",
"boneMassBody": "La masa ósea es el peso estimado del mineral de tu esqueleto. Las estimaciones de la báscula son aproximadas, pero una tendencia estable o al alza favorece la fortaleza ósea a largo plazo.",
"fatFreeMassTitle": "Masa libre de grasa",
"fatFreeMassBody": "La masa libre de grasa es todo lo que en tu cuerpo no es grasa: músculo, hueso, órganos y agua. Mantenerla o aumentarla mientras pierdes peso suele indicar una composición corporal más sana.",
"fatMassTitle": "Masa grasa",
"fatMassBody": "La masa grasa es el peso total de la grasa de tu cuerpo. Algo de grasa es esencial para las hormonas y la reserva de energía, pero un exceso —sobre todo en el abdomen— eleva el riesgo para la salud a largo plazo (WHO 2024).",
"muscleMassTitle": "Masa muscular",
"muscleMassBody": "La masa muscular es el peso estimado del músculo de tu cuerpo. Más músculo favorece la fuerza, el metabolismo y la movilidad, y suele requerir esfuerzo activo para conservarse con la edad.",
"visceralFatTitle": "Grasa visceral",
"visceralFatBody": "La grasa visceral es la grasa almacenada en lo profundo del abdomen, alrededor de los órganos. A diferencia de la grasa bajo la piel, un nivel alto se asocia estrechamente con enfermedad cardíaca y diabetes tipo 2 (AHA, Circulation 2021), por lo que cuanto más baja, mejor. La escala muestra una puntuación sin unidades (aproximadamente 1–12 es el rango saludable, 13 o más se considera elevado); es una escala distinta de los umbrales en cm² por imagen que usan los profesionales clínicos y no es intercambiable con ellos.",
"leanBodyMassTitle": "Masa magra corporal",
"leanBodyMassBody": "La masa magra corporal es tu peso total menos la grasa: músculo, hueso, órganos y agua en conjunto. Seguirla junto al peso muestra si los cambios vienen del músculo o de la grasa.",
"flightsClimbedTitle": "Pisos subidos",
"flightsClimbedBody": "Los pisos subidos cuentan las plantas que subes cada día, con un piso equivalente a unos tres metros de altura. Es un indicador sencillo de la actividad cotidiana y el esfuerzo del tren inferior.",
"walkingDistanceTitle": "Distancia a pie y corriendo",
"walkingDistanceBody": "La distancia a pie y corriendo es el terreno que recorres caminando cada día. Complementa el recuento de pasos al reflejar cuánto te moviste, no solo con qué frecuencia diste pasos.",
"walkingSteadinessTitle": "Estabilidad al caminar",
"walkingSteadinessBody": "La estabilidad al caminar estima cuán estable y equilibrada es tu marcha, clasificada de correcta a baja. Un nivel bajo indica mayor probabilidad de caída y conviene comentarlo con un profesional.",
"walkingHeartRateTitle": "Frecuencia cardíaca al caminar",
"walkingHeartRateBody": "La frecuencia cardíaca al caminar es tu pulso medio durante una caminata constante. Un valor más bajo con el tiempo suele reflejar una mejor forma cardiovascular para el mismo esfuerzo.",
"walkingAsymmetryTitle": "Asimetría al caminar",
"walkingAsymmetryBody": "La asimetría al caminar es la proporción de tiempo en que tus pasos son desiguales entre la pierna izquierda y la derecha. Un porcentaje más alto puede indicar dolor, lesión o un problema de marcha que conviene revisar.",
"doubleSupportTimeTitle": "Tiempo de doble apoyo",
"doubleSupportTimeBody": "El tiempo de doble apoyo es la proporción de cada ciclo de marcha en que ambos pies tocan el suelo a la vez. Un porcentaje más alto suele indicar una marcha más cautelosa y menos estable.",
"stepLengthTitle": "Longitud de zancada",
"stepLengthBody": "La longitud de zancada es la distancia media de una pisada a la siguiente. Una zancada más corta con el tiempo puede reflejar fatiga, cautela o un cambio en la movilidad.",
"walkingSpeedTitle": "Velocidad al caminar",
"walkingSpeedBody": "La velocidad al caminar es lo rápido que te mueves en terreno llano. Es un marcador bien estudiado de la salud general y la movilidad, y un ritmo estable o al alza es buena señal.",
"pulseWaveVelocityTitle": "Velocidad de la onda de pulso",
"pulseWaveVelocityBody": "La velocidad de la onda de pulso mide la rapidez con que la onda de presión de cada latido viaja por tus arterias. Una onda más rápida indica arterias más rígidas, lo que se asocia a mayor riesgo cardiovascular.",
"vascularAgeTitle": "Edad vascular",
"vascularAgeBody": "La edad vascular estima qué edad aparentan tus arterias en comparación con tu edad real, según la rigidez arterial. Una edad vascular por debajo de la real sugiere vasos sanguíneos más sanos.",
"environmentalAudioTitle": "Niveles de sonido ambiental",
"environmentalAudioBody": "Los niveles de sonido ambiental registran el volumen del entorno en decibelios. La exposición prolongada por encima de unos 70 dB puede dañar la audición con el tiempo, así que los promedios diarios más bajos son más seguros.",
"headphoneAudioTitle": "Niveles de audio con auriculares",
"headphoneAudioBody": "Los niveles de audio con auriculares registran en decibelios el volumen al que escuchas con auriculares. Bajar el volumen y hacer pausas protege tu audición a largo plazo.",
"audioEventsTitle": "Eventos de sonido fuerte",
"audioEventsBody": "Los eventos de sonido fuerte señalan momentos en que el ruido a tu alrededor alcanzó un nivel que puede dañar la audición con suficiente exposición. Los eventos frecuentes son una señal para buscar un entorno más silencioso o protección auditiva.",
"daylightTitle": "Tiempo a la luz del día",
"daylightBody": "El tiempo a la luz del día es cuánto pasas bajo luz exterior cada día. La luz diurna regular favorece un ritmo sueño-vigilia y un estado de ánimo saludables (CDC 2024), y el tiempo al aire libre se asocia con la salud ocular.",
"bloodGlucoseTitle": "Glucosa en sangre",
"bloodGlucoseBody": "La glucosa en sangre es la cantidad de azúcar en tu sangre, la principal fuente de energía del cuerpo. Sube tras comer y se estabiliza entre comidas; valores que se mantienen altos con el tiempo pueden indicar diabetes o prediabetes.",
"skinTemperatureTitle": "Temperatura de la piel",
"skinTemperatureBody": "La temperatura de la piel aquí es el pequeño cambio nocturno de la temperatura de tu muñeca respecto a tu propio valor de referencia, no una medición absoluta de fiebre. Las variaciones pueden seguir el sueño, el entorno, una enfermedad o la fase del ciclo menstrual.",
"cardioFitnessTitle": "Condición cardiovascular (VO₂ máx)",
"cardioFitnessBody": "El VO₂ máx es la cantidad de oxígeno que tu cuerpo puede usar al máximo esfuerzo: el mejor índice de laboratorio de la forma aeróbica, que los wearables estiman de forma indirecta. Como orientación general, la forma física es un predictor gradual de supervivencia: cada MET adicional (unos 3,5 ml/kg/min) se asocia a una mortalidad entre un 10 % y un 25 % menor (AHA, Circulation 2016). Una tendencia más alta o en aumento es una fuerte señal de salud cardiovascular.",
"cardioRecoveryTitle": "Recuperación cardiaca",
"cardioRecoveryBody": "La recuperación cardiaca es cuántas pulsaciones por minuto baja tu frecuencia cardiaca en el primer minuto tras parar un ejercicio intenso. Una caída mayor significa que tu corazón se calma más rápido, señal de buena forma física.",
"wristTemperatureTitle": "Temperatura de muñeca",
"wristTemperatureBody": "La temperatura de muñeca se mide mientras duermes y se indica como la diferencia respecto a tu valor de referencia, no como un valor absoluto. Un valor estable es normal, mientras que una subida sostenida puede indicar enfermedad, entrenamiento intenso o un cambio del ciclo.",
"fallsTitle": "Caídas",
"fallsBody": "Esto cuenta las caídas fuertes que detecta tu wearable. El objetivo es cero; un número creciente es una señal de riesgo de movilidad que conviene comentar con un profesional, sobre todo en personas mayores (CDC STEADI 2024).",
"sixMinuteWalkTitle": "Distancia en marcha de 6 minutos",
"sixMinuteWalkBody": "Es una estimación de cuánto podrías caminar en seis minutos, una prueba clínica clásica de movilidad y resistencia cardiopulmonar. El valor del wearable es una aproximación, no la prueba clínica supervisada; lo útil es la tendencia estable o en aumento.",
"stairAscentSpeedTitle": "Velocidad subiendo escaleras",
"stairAscentSpeedBody": "Es lo rápido que subes un tramo de escaleras. Es un marcador sensible de fuerza de piernas y movilidad: un ritmo estable o en aumento es buena señal y uno que baja conviene vigilar.",
"stairDescentSpeedTitle": "Velocidad bajando escaleras",
"stairDescentSpeedBody": "Es lo rápido que bajas un tramo de escaleras. Junto con la velocidad de subida refleja el control de piernas y el equilibrio: un ritmo estable es buena señal y uno que baja conviene vigilar.",
"breathingDisturbancesTitle": "Alteraciones respiratorias del sueño",
"breathingDisturbancesBody": "Un wearable puede estimar con qué frecuencia se interrumpió tu respiración mientras dormías. Un índice más bajo es mejor; un nivel marcado como elevado, sobre todo durante muchas noches, conviene comentarlo con un profesional. Es una aproximación de cribado, no un diagnóstico de apnea del sueño.",
"stepsTitle": "Pasos",
"stepsBody": "El recuento de pasos es el número total de pasos que das cada día. Es la medida más sencilla del movimiento cotidiano: un recuento diario más alto suele reflejar un estilo de vida más activo, y un hábito constante importa más que un único día destacado.",
"readinessBody": "La preparación es una lectura diaria de lo listo que está tu cuerpo para afrontar el día, combinando la frecuencia cardíaca en reposo, la variabilidad de la frecuencia cardíaca, el sueño y la regularidad de tus últimas noches. Una puntuación más alta indica que tu organismo se ve recuperado y con recursos; una más baja es un aviso suave para bajar el ritmo.",
"sleepScoreBody": "Tu puntuación de sueño resume toda una noche en un solo número — duración, eficiencia, regularidad y el equilibrio entre sueño ligero, profundo y REM. Fíjate en el patrón a lo largo de las semanas en lugar de perseguir una puntuación perfecta una sola noche.",
"recoveryBody": "La recuperación refleja lo bien que tu cuerpo se ha repuesto, a partir de señales nocturnas como la variabilidad de la frecuencia cardíaca y la frecuencia en reposo. Una puntuación alta es luz verde para exigirte; una más baja sugiere que tu organismo aún se está reconstruyendo.",
"strainBody": "El esfuerzo capta cuánta carga cardiovascular y física le has impuesto a tu cuerpo, desde los entrenamientos hasta un día activo de pie. Cuando ya hay suficiente historial, se valora frente a tu propio esfuerzo habitual, no frente a un estándar ajeno.",
"stressBody": "El estrés aquí es una estimación de la carga fisiológica sobre tu sistema nervioso, leída sobre todo a partir de patrones de variabilidad de la frecuencia cardíaca y no de cómo te sientes conscientemente. Una puntuación más baja apunta a un estado calmado y bien regulado; una más alta es una invitación a incorporar recuperación.",
"recoveryPageTitle": "Recuperación",
"recoveryPageBody": "La recuperación reúne las señales nocturnas que registra tu wearable —recarga, esfuerzo, carga y los totales de frecuencia cardiaca y energía del día— para mostrar cómo se ha recuperado tu cuerpo. Fíjate en el patrón a lo largo de los días más que en un solo número; un valor más bajo es una invitación a bajar el ritmo, no una alarma.",
"pulsePressureTitle": "Presión de pulso",
"pulsePressureBody": "La presión de pulso es la diferencia entre tu valor sistólico y diastólico (sistólico menos diastólico). Como orientación general, unos 40 mmHg es lo típico en reposo; una diferencia amplia por encima de unos 60 mmHg en la mediana edad puede reflejar arterias más rígidas, mientras que una diferencia estrecha por debajo de unos 25 mmHg puede acompañar a un bajo volumen sistólico (StatPearls/NIH 2023). Es un motivo de conversación, no un diagnóstico.",
"meanArterialPressureTitle": "Presión arterial media",
"meanArterialPressureBody": "La presión arterial media es la presión promedio que ven tus órganos a lo largo de un latido, aproximadamente la diastólica más un tercio de la diferencia con la sistólica. Como orientación general, un valor en reposo ronda los 70–100 mmHg (StatPearls/NIH 2023). El umbral de ~60 mmHg que se suele citar es un concepto de cuidados agudos, no una alarma para el autocontrol.",
"cycleEducationBody": "Tu ciclo atraviesa cuatro fases impulsadas por hormonas, y su duración y regularidad son una ventana accesible a la salud general. Como orientación general, los ciclos en personas adultas suelen durar 21–35 días (las referencias clínicas citan 24–38), con un sangrado de unos cinco días (ACOG 2015). La fase lútea es el ancla estable, así que la ovulación se estima mejor contando hacia atrás desde tu próxima regla prevista, nunca con un «día 14» fijo. Una ventana fértil predicha es una estimación que necesita varios ciclos registrados y sigue siendo aproximada, y la temperatura basal confirma la ovulación a posteriori en lugar de predecirla. No es un método anticonceptivo.",
"sleepHygieneBody": "Algunos hábitos con respaldo de evidencia favorecen el sueño: mantener horarios constantes de acostarse y levantarse incluso los fines de semana, recibir luz brillante por la mañana y atenuar las noches, una habitación fresca, oscura y silenciosa, y evitar la cafeína por la tarde, el alcohol tardío y las comidas copiosas a última hora (CDC 2024). La regularidad predice la salud al menos tanto como la duración, y el tiempo total de sueño de un wearable es mucho más fiable que su reparto entre sueño profundo, ligero y REM.",
"referenceGuidance": "Orientación general, no consejo médico. Los rangos de referencia son valores poblacionales que cambian con tu edad, sexo y condiciones de salud; tu propia tendencia a lo largo del tiempo guía la lectura."
"sleepHygieneBody": "Algunos hábitos con respaldo de evidencia favorecen el sueño: mantener horarios constantes de acostarse y levantarse incluso los fines de semana, recibir luz brillante por la mañana y atenuar las noches, una habitación fresca, oscura y silenciosa, y evitar la cafeína por la tarde, el alcohol tardío y las comidas copiosas a última hora (CDC 2024). La regularidad predice la salud al menos tanto como la duración, y el tiempo total de sueño de un wearable es mucho más fiable que su reparto entre sueño profundo, ligero y REM."
},
"bpDerived": {
"pulsePressure": "La presión de pulso (la diferencia entre tus valores) es de unos {value} mmHg; alrededor de 40 es lo típico en reposo.",
@@ -2908,16 +2816,6 @@
"compositionTitle": "Distribución de fases",
"compositionSubtitle": "Últimas {nights} noches",
"compositionAriaLabel": "Distribución de fases del sueño en {nights} noches",
"hypnogram": {
"title": "Última noche",
"subtitle": "{asleep} dormido",
"ariaLabel": "Progresión de las fases del sueño de la última noche: {asleep} dormido, {awakenings} despertares",
"asleepTotal": "Dormido {value}",
"inBedTotal": "En la cama {value}",
"awakenings": "{count} despertares",
"source": "Fuente: {source}",
"estimateNote": "Los tiempos de cada fase son estimados: esta fuente informa totales por fase, no una cronología medida."
},
"sourceDiscrepancy": {
"tooltipTitle": "Las fuentes difieren"
},
@@ -2972,7 +2870,6 @@
"loadError": "Tu ritmo de sueño no pudo cargarse ahora mismo. Reaparecerá en cuanto se estabilice la conexión."
}
},
"correlations": "Correlaciones",
"medicationCompliance": "Cumplimiento de medicación",
"dayStreak": "{count} días de racha",
"dayStreakOne": "{count} día de racha",
@@ -2981,19 +2878,12 @@
"taken": "Tomada",
"skipped": "Omitida",
"missed": "Perdida",
"generalStatusBadge": {
"good": "Bien",
"watch": "Observar",
"critical": "Crítico"
},
"assessmentTitle": "Valoración",
"restMode": {
"title": "Modo descanso",
"body": "Hay un episodio de enfermedad o afección activo, por lo que esto refleja un periodo de recuperación. Tu puntuación no baja por ello.",
"bodySince": "Hay un episodio de enfermedad o afección activo desde el {since}, por lo que esto refleja un periodo de recuperación. Tu puntuación no baja por ello."
},
"assessmentShowMore": "Mostrar más",
"assessmentShowLess": "Mostrar menos",
"bloodPressureSectionTitle": "Presión arterial",
"weightSectionTitle": "Peso",
"pulseSectionTitle": "Pulso",
@@ -3001,8 +2891,6 @@
"complianceNoData": "Sin datos de cumplimiento disponibles",
"generate": "Generar análisis nuevo",
"moodSectionTitle": "Estado de ánimo",
"confidence": "Confianza",
"cached": "En caché",
"emptyState": {
"bloodPressure": {
"title": "Aún no hay registros de presión arterial",
@@ -3051,11 +2939,6 @@
"title": "Aún no hay entrenamientos",
"description": "Abre la app Apple Salud en el iPhone para sincronizar tus entrenamientos. La importación de actividad de Withings también incorpora caminatas y rutas en bici."
},
"row": {
"duration": "Duración",
"distance": "Distancia",
"energy": "Energía activa"
},
"detail": {
"backToList": "Volver a entrenamientos",
"statsTitle": "Estadísticas",
@@ -3068,13 +2951,9 @@
"statsStepCount": "Pasos",
"statsElevation": "Desnivel",
"statsPace": "Ritmo",
"statsSource": "Fuente",
"routeTitle": "Ruta",
"routeUnavailable": "No se registró una ruta GPS para este entrenamiento.",
"hrChartTitle": "Frecuencia cardíaca",
"hrChartUnavailable": "Las muestras de frecuencia cardíaca por segundo no se almacenan para este entrenamiento.",
"notFound": "Entrenamiento no encontrado.",
"loading": "Cargando entrenamiento…",
"hrFromPulse": "A partir de tus datos de frecuencia cardíaca en torno a esta sesión",
"hrAxisMinutes": "min",
"sportAverageLabel": "Tu promedio reciente:",
@@ -3546,7 +3425,6 @@
"title": "Lo que destaca",
"subheading": "Relaciones descubiertas",
"footer": "Extraídas de {tested} pares de un día a otro probados en todas tus métricas, tras la corrección de falsos descubrimientos.",
"empty": "Todavía ninguna relación de ánimo superó el umbral estadístico. Sigue registrando.",
"disclaimer": "Filtrado estadísticamente, pero aún observacional: un patrón que vale la pena observar, no una causa.",
"moodUp": "Un mayor {factor} suele coincidir con mejor ánimo al día siguiente en tus datos.",
"moodDown": "Un mayor {factor} suele coincidir con peor ánimo al día siguiente en tus datos.",
@@ -3925,8 +3803,6 @@
},
"briefing": {
"memory": {
"recall": "Antes, {text}",
"forward": "Mirando hacia adelante: {text}",
"forwardWatch": "Conviene vigilar {metric}.",
"forwardHolding": "Por ahora todo se mantiene estable."
}
@@ -3940,9 +3816,7 @@
}
},
"streak": {
"returnLabel": "Tu {metric} ha vuelto a tu rango habitual tras {count} días — fuera lo que fuera, ya pasó.",
"daysInside": "{count} días dentro",
"daysOutside": "{count} días fuera"
"returnLabel": "Tu {metric} ha vuelto a tu rango habitual tras {count} días — fuera lo que fuera, ya pasó."
},
"updatedTodayAt": "Actualizado hoy, {time}",
"updatedYesterday": "Actualizado ayer",
@@ -4243,6 +4117,7 @@
"displayNamePlaceholder": "¿Cómo debería llamarte HealthLog?",
"displayNameHint": "Aparece en el saludo del panel y en los informes.",
"heightLabel": "Altura (cm)",
"heightLabelFtIn": "Altura (ft, in)",
"genderLabel": "Sexo",
"genderNone": "Prefiero no decirlo",
"genderMale": "Hombre",
@@ -4570,10 +4445,6 @@
"passphraseHelp": "Deriva la clave del archivo con Argon2id y lo sella con AES-256-GCM. Guarda el archivo con la extensión .hlx.",
"noRecoveryWarning": "No hay recuperación: si pierdes esta contraseña, el archivo no se podrá abrir. Guárdala en un lugar seguro.",
"stepUpNeeded": "Confirma tu segundo factor y vuelve a intentarlo."
},
"cycle": {
"title": "Ciclo",
"description": "Datos del ciclo y de salud reproductiva como documento FHIR: última menstruación, duración media del ciclo y del periodo, fase actual."
}
},
"import": {
@@ -4595,6 +4466,7 @@
"failed": "La importación falló.",
"rowsImported": "{count} filas importadas hasta ahora",
"doneSummary": "Listo: {imported} de {read} registros importados, {skipped} registros clínicos omitidos.",
"doneNothing": "No se importó nada de ese archivo: llegaron 0 de {read} registros.",
"estimateWarning": "Los totales acumulados de {count} día(s) se estimaron a partir de la mayor fuente de la exportación. Las estadísticas nativas de Apple Health siguen siendo la referencia.",
"phase": {
"queued": "En cola…",
@@ -4618,7 +4490,10 @@
"rateLimited": "Demasiadas importaciones esta hora (límite 5). Inténtalo más tarde.",
"failed": "La importación falló. Inténtalo de nuevo.",
"readFailed": "No se pudo leer ese archivo.",
"resultSummary": "{measurements} mediciones y {moods} entradas de ánimo importadas, {skipped} omitidas."
"resultSummary": "{measurements} mediciones y {moods} entradas de ánimo importadas, {skipped} omitidas.",
"resultSuccess": "{measurements} mediciones y {moods} entradas de ánimo importadas.",
"resultNothing": "No se importó nada. Se omitieron las {skipped} entradas.",
"resultEmpty": "Ese archivo no contenía mediciones ni entradas de ánimo."
},
"csv": {
"title": "Archivo CSV",
@@ -4639,7 +4514,30 @@
"readFailed": "No se pudo leer ese archivo.",
"previewSummary": "Vista previa: {inserted} se importarían, {skipped} omitidas.",
"resultSummary": "{inserted} nuevas importadas y {updated} actualizadas, {skipped} omitidas.",
"rowError": "Línea {line}: {reason}"
"rowError": "Línea {line}: {reason}",
"resultSuccess": "{inserted} nuevas importadas y {updated} actualizadas.",
"resultNothing": "No se importó nada. Se omitieron las {skipped} filas.",
"resultEmpty": "Ese archivo tiene encabezado pero ninguna fila de datos.",
"previewSuccess": "Vista previa: {inserted} se importarían.",
"previewNothing": "Vista previa: no se importaría nada. Se omitirían las {skipped} filas.",
"skipGroupOne": "{count} fila omitida: {reason}",
"skipGroupFew": "{count} filas omitidas: {reason}",
"skipGroupOther": "{count} filas omitidas: {reason}",
"skipDetails": "Mostrar todas las filas omitidas",
"reasonUnknownType": "tipo de medición desconocido",
"reasonInvalidValue": "el valor no es un número",
"reasonValueOutOfRange": "valor fuera del rango plausible",
"reasonUnknownUnit": "unidad no reconocida para este tipo",
"reasonMissingTimezoneOffset": "marca de tiempo sin desfase de zona horaria",
"reasonInvalidTimestamp": "no se pudo leer la marca de tiempo",
"reasonImplausibleTimestamp": "la marca de tiempo está en el futuro o es anterior a 1900",
"reasonUnexpectedGlucoseContext": "contexto de glucosa en una fila que no es de glucosa en sangre",
"reasonInvalidGlucoseContext": "el contexto de glucosa no es un valor permitido",
"reasonNotesTooLong": "la nota es demasiado larga",
"reasonExternalIdTooLong": "el ID externo es demasiado largo",
"reasonUnstableExternalId": "el ID externo no es estable entre exportaciones",
"reasonDuplicate": "ya importada",
"reasonUnknown": "omitida"
}
},
"subtitle": "Exporta tus datos o importa desde una copia de seguridad."
@@ -4790,6 +4688,7 @@
},
"username": "Nombre de usuario",
"height": "Altura (cm)",
"heightFtIn": "Altura (ft, in)",
"dateOfBirth": "Fecha de nacimiento",
"dateOfBirthHint": "Se usa para el cálculo automático de los objetivos de presión arterial.",
"gender": "Sexo",
@@ -4823,17 +4722,10 @@
"passwordMismatch": "Las nuevas contraseñas no coinciden",
"passwordUpdated": "Contraseña actualizada correctamente",
"passkeys": "Passkeys",
"registeredPasskeys": "Claves de acceso registradas",
"passkeysDescription": "Las claves de acceso permiten iniciar sesión de forma segura sin contraseña.",
"passkeyName": "Nombre",
"passkeyDevice": "Dispositivo",
"passkeyBackup": "Backup",
"passkeyCreated": "Creada",
"passkeyActions": "Acciones",
"noPasskeys": "No hay claves de acceso registradas.",
"addPasskey": "Añadir clave de acceso",
"passkeyAdded": "¡Clave de acceso añadida correctamente!",
"passkeyOptionsError": "No se pudieron cargar las opciones de la clave de acceso",
"passkeyRegistrationFailed": "Falló el registro de la clave de acceso",
"passkeyRegistrationCancelled": "Registro de la clave de acceso cancelado",
"passkeyNotSupported": "Tu navegador o dispositivo aún no admite claves de acceso. Prueba con otro navegador o dispositivo.",
@@ -4882,7 +4774,6 @@
"webPushSubscribed": "¡Notificaciones push activadas!",
"webPushUnsubscribed": "Notificaciones push desactivadas.",
"webPushSubscribeFailed": "Falló la activación",
"webPushActive": "Activo",
"kiInsightsDescription": "Opcional: guarda tu clave de OpenAI para análisis diarios y automáticos en Insights.",
"codexConnected": "¡ChatGPT conectado correctamente! Insights ya está activo.",
"codexDisconnected": "Conexión con ChatGPT eliminada.",
@@ -4920,7 +4811,6 @@
"savedShort": "(guardada)",
"saveCta": "Guardar",
"saved": "Guardado",
"saveFailed": "Falló el guardado",
"errorGeneric": "Error",
"providerChain": {
"types": {
@@ -5056,11 +4946,9 @@
"hint": "Se envía al Coach y al informe diario como contexto adicional. Déjalo vacío para no enviar nada.",
"profileHint": "La edad y el género provienen de tu perfil y se incluyen automáticamente.",
"freeTextLabel": "Sobre mí (texto libre)",
"conditionsLabel": "Enfermedades crónicas",
"conditionsPlaceholder": "P. ej. hipertensión, diabetes tipo 2…",
"allergiesLabel": "Alergias e intolerancias",
"allergiesPlaceholder": "P. ej. intolerancia a la lactosa, alergia a la penicilina…",
"focusLabel": "¿En qué debería fijarse el Coach?",
"focusPlaceholder": "P. ej. vigila mi tensión arterial, motívame a moverme más…",
"completeness": "{answered} de {total} respondidas",
"questionsTitle": "El Coach tiene preguntas",
@@ -5138,8 +5026,6 @@
"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."
},
"integrationsSourcesHint": "Si varias fuentes aportan la misma métrica, la prioridad de fuentes decide qué valor cuenta.",
"integrationsSourcesHintLink": "Abrir prioridad de fuentes",
"withingsSynchronize": "Sincronizar",
"withingsDisconnect": "Desconectar",
"withingsDisconnectTitle": "¿Desconectar Withings?",
@@ -5269,7 +5155,7 @@
"integrationPill": {
"connected": "Conectado",
"errorReconnect": "Error — reconectar",
"warningServerError": "Conectado, error del servidor",
"warningServerError": "Conectado, problema de sincronización",
"parkedReconnect": "Pausado — reconectar manualmente",
"notConnected": "No conectado",
"justNow": "ahora mismo",
@@ -5279,8 +5165,26 @@
"ariaLabel": "Estado de la integración",
"resumeCta": "Reconectar",
"resumeSuccess": "Integración reanudada",
"resumeError": "Reconexión fallida"
"resumeError": "Reconexión fallida",
"stale": "Conectado, sin datos recientes",
"stalled": "Sincronización detenida",
"pendingSetup": "Esperando los primeros datos"
},
"integrationFreshness": {
"title": "Datos recibidos",
"summary": "{count} tipos de datos · más reciente {relative}",
"quiet": "{count} en silencio",
"workouts": "Entrenamientos"
},
"integrationFallback": {
"description": "Configurado en esta cuenta pero sin tarjeta de ajustes propia. Esta fila existe para que una conexión no deje de funcionar sin que nadie lo note.",
"lastAttempt": "Último intento {relative}",
"noAttempt": "Ningún intento de sincronización registrado"
},
"withingsViewData": "Ver tus mediciones",
"whoopViewData": "Ver tu recuperación",
"fitbitViewData": "Ver tu sueño",
"googleHealthViewData": "Ver tus pasos",
"apiTokens": "Tokens de API",
"apiTokensDescription": "Revisa y revoca tokens de API para el registro externo de tomas de medicación. Los tokens se emiten por medicamento desde su interruptor de endpoint de API.",
"tokenMintMovedDescription": "Los tokens se emiten por medicamento. Abre un medicamento y activa su endpoint de API para obtener un token limitado únicamente a ese medicamento.",
@@ -5312,10 +5216,7 @@
"apiEndpointPath": "Ruta",
"apiEndpointAuth": "Autenticación",
"apiEndpointExample": "Ejemplo de cuerpo",
"collapse": "Contraer",
"expand": "Expandir",
"dangerZone": "Eliminar todos los datos",
"dangerZoneTitle": "Zona de peligro",
"dangerZoneDescription": "Elimina todos tus datos de salud e integraciones. Tu cuenta de usuario se conservará.",
"dangerZoneConfirm": "¿Eliminar todos los datos?",
"dangerZoneConfirmDescription": "Esto eliminará de forma permanente todos tus datos de salud e integraciones. Tu cuenta de usuario se conservará.",
@@ -5332,31 +5233,9 @@
"deleteAccountFinal": "Eliminar permanentemente",
"saved": "Guardado",
"savingError": "Error al guardar",
"moodLogTitle": "moodLog",
"moodLogDescription": "Import mood data from moodLog",
"moodLogDeprecated": "Obsoleto: el estado de ánimo ahora se registra de forma nativa en HealthLog con etiquetas y factores. Esta integración sigue funcionando, pero se eliminará en una versión futura.",
"moodLogUrl": "moodLog URL",
"moodLogUrlPlaceholder": "https://mood.example.com",
"moodLogApiKey": "Clave de API",
"moodLogApiKeyPlaceholder": "ml_...",
"moodLogWebhookSecret": "Secreto del webhook",
"moodLogWebhookSecretHelp": "Introduce este secreto en moodLog como secreto del webhook",
"moodLogSync": "Sincronizar ahora",
"moodLogFullSync": "Sincronizar todos los datos",
"moodLogFullSyncConfirm": "Sincronizar",
"moodLogFullSyncTitle": "¿Sincronización completa?",
"moodLogFullSyncDescription": "Se sincronizarán por completo todos los datos de estado de ánimo disponibles.",
"moodLogDisconnect": "Desconectar",
"moodLogDisconnectTitle": "¿Desconectar moodLog?",
"moodLogDisconnectDescription": "Se eliminará la conexión y se borrarán todos los datos de estado de ánimo importados.",
"moodLogEntries": "Entradas",
"moodLogSyncResult": "{count} entradas sincronizadas",
"moodLogSyncFailed": "Falló la sincronización",
"moodLogDisconnected": "moodLog desconectado",
"about": {
"version": "Versión de la app",
"gitSha": "Build",
"builtAt": "Compilado {time}",
"builtAtLabel": "Compilado",
"license": "Licencia",
"repository": "Código fuente",
@@ -5428,34 +5307,17 @@
"practiceName": "Consulta (opcional)",
"practiceNamePlaceholder": "p. ej. Dr. García",
"includedData": "Datos incluidos",
"groupVitals": "Signos vitales",
"groupCardio": "Corazón y forma física",
"groupActivity": "Actividad y sueño",
"weight": "Peso",
"bp": "Presión arterial",
"pulse": "Pulso",
"oxygenSaturation": "Saturación de oxígeno",
"bodyFat": "Grasa corporal",
"bodyComposition": "Composición corporal",
"restingHeartRate": "Pulso en reposo",
"hrv": "Variabilidad de la frecuencia cardíaca",
"vo2max": "VO₂máx",
"steps": "Pasos",
"distance": "Distancia",
"sleep": "Sueño",
"glucose": "Glucosa en sangre",
"medications": "Medicamentos y adherencia",
"mood": "Estado de ánimo (desactivado por defecto — datos sensibles)",
"bmi": "IMC",
"includeCharts": "Incrustar gráficos de tendencia (solo PDF)",
"includeAiSummary": "Añadir resumen de IA (no validado clínicamente)",
"fhirNote": "La salida estructurada sigue el estándar FHIR R4 (compatible con ePA).",
"generate": "Generar",
"error": "Error en la exportación ({code}).",
"labs": "Resultados de análisis",
"allergies": "Alergias e intolerancias",
"familyHistory": "Antecedentes familiares",
"documentsLink": "Documentos del período"
"documentsLink": "Documentos del período",
"range30": "Últimos 30 días",
"rangeCustom": "Periodo personalizado",
"rangeCustomStart": "Desde",
"rangeCustomEnd": "Hasta",
"errorGeneric": "No se pudo generar la exportación. Inténtelo de nuevo.",
"errorRateLimit": "Ha alcanzado el límite de exportaciones de esta hora.",
"errorModuleDisabled": "La exportación del expediente está desactivada para esta cuenta."
},
"globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente",
"globalExcludedInjectionSitesHint": "Los sitios listados aquí no se ofrecen para ningún medicamento y se rechazan al registrar una dosis, aunque un medicamento los prefiera.",
@@ -5469,13 +5331,8 @@
"expiry": "Caduca en (días)",
"expiryHint": "Como máximo {max} días.",
"expiryInvalid": "La caducidad debe estar entre 1 y {max} días.",
"fhirApi": "Permitir API FHIR acotada",
"fhirApiHint": "Deja que el enlace sirva un endpoint FHIR de solo lectura además de la vista de página.",
"resourceTypes": "Tipos de recurso FHIR",
"create": "Crear enlace",
"tokenCreated": "Enlace para compartir creado",
"tokenOnce": "Copia este enlace ahora — el token se muestra una sola vez y no se puede recuperar.",
"copy": "Copiar enlace",
"copied": "Copiado al portapapeles.",
"activeTitle": "Enlaces activos",
"noActive": "No hay enlaces para compartir activos.",
@@ -5489,7 +5346,6 @@
"statusExpired": "Caducado",
"inactiveTitle": "Revocados y caducados ({count})",
"shownOnce": "Se muestra una sola vez. Guarde ahora el código QR y la frase de acceso — no se pueden recuperar después.",
"qrLabel": "Código QR",
"qrAlt": "Código QR con el enlace para compartir y la frase de acceso",
"linkLabel": "Enlace",
"passphraseLabel": "Frase de acceso",
@@ -5512,7 +5368,12 @@
"pickerEmpty": "No se encontraron documentos.",
"pickerDone": "Listo",
"pickerSelectedCount": "{count} de {max} seleccionados",
"pickerMaxReached": "Has alcanzado el máximo de {max} documentos."
"pickerMaxReached": "Has alcanzado el máximo de {max} documentos.",
"scopeTitle": "Qué contiene este enlace",
"scopeInsuranceExcluded": "Los datos del seguro nunca se comparten por enlace.",
"statusNeedsReselect": "Elegir contenido de nuevo",
"reselectReason": "Este enlace mostraba un alcance que nadie eligió, por eso se cerró. Cree uno nuevo y elija su contenido.",
"reselectAction": "Crear un reemplazo"
},
"genderOther": "Otro",
"cycleTracking": {
@@ -5539,13 +5400,15 @@
"nightscoutUpdate": "Guardar cambios",
"nightscoutConnected": "Nightscout conectado.",
"nightscoutConnectFailed": "No se pudo conectar con Nightscout.",
"nightscoutSync": "Sincronizar ahora",
"nightscoutSyncResult": "{count} mediciones sincronizadas",
"nightscoutSyncFailed": "Error de sincronización",
"nightscoutDisconnect": "Desconectar",
"nightscoutDisconnectTitle": "¿Desconectar Nightscout?",
"nightscoutDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas de glucosa ya importadas permanecen en tu historial.",
"polar": "Polar",
"polarDescription": "Conecta tu cuenta de Polar para sincronizar la recuperación Nightly Recharge, fases de sueño y actividad diaria.",
"polarDescription": "Conecta tu cuenta de Polar para sincronizar la recuperación Nightly Recharge, fases de sueño, actividad diaria y sesiones de entrenamiento.",
"polarViewData": "Ver tu sueño y recuperación",
"polarHelp": "Inicia sesión en Polar para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.",
"polarCredentials": "Credenciales de API",
"polarClientId": "Client ID",
"polarClientSecret": "Client Secret",
@@ -5554,6 +5417,9 @@
"polarSaveCredentials": "Guardar",
"polarUnavailable": "Polar no está configurado en este servidor. Añade arriba tus propias credenciales OAuth de Polar, o pide a tu administrador que configure la app compartida.",
"polarConnect": "Conectar Polar",
"polarSync": "Sincronizar ahora",
"polarSyncResult": "{count} mediciones sincronizadas",
"polarSyncFailed": "Error de sincronización",
"polarDisconnect": "Desconectar",
"polarDisconnectTitle": "¿Desconectar Polar?",
"polarDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas ya importadas permanecen en tu historial.",
@@ -5572,7 +5438,6 @@
"oura": "Oura",
"ouraDescription": "Conecta tu cuenta de Oura para sincronizar disposición, fases de sueño, variabilidad cardíaca y actividad diaria.",
"ouraViewData": "Ver tu sueño y recuperación",
"ouraHelp": "Inicia sesión en Oura para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.",
"ouraCredentials": "Credenciales de API",
"ouraClientId": "Client ID",
"ouraClientSecret": "Client Secret",
@@ -5581,6 +5446,9 @@
"ouraSaveCredentials": "Guardar",
"ouraUnavailable": "Oura no está configurado en este servidor. Añade arriba tus propias credenciales OAuth de Oura, o pide a tu administrador que configure la app compartida.",
"ouraConnect": "Conectar Oura",
"ouraSync": "Sincronizar ahora",
"ouraSyncResult": "{count} mediciones sincronizadas",
"ouraSyncFailed": "Error de sincronización",
"ouraDisconnect": "Desconectar",
"ouraDisconnectTitle": "¿Desconectar Oura?",
"ouraDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas ya importadas permanecen en tu historial.",
@@ -5809,6 +5677,9 @@
"stravaSaveCredentials": "Guardar",
"stravaUnavailable": "Strava no está configurado en este servidor. Añade arriba tus propias credenciales de API de Strava o pide a tu administrador que configure la app compartida.",
"stravaConnect": "Conectar Strava",
"stravaSync": "Sincronizar ahora",
"stravaSyncResult": "{count} mediciones sincronizadas",
"stravaSyncFailed": "Error de sincronización",
"stravaDisconnect": "Desconectar",
"stravaDisconnectTitle": "¿Desconectar Strava?",
"stravaDisconnectDescription": "Las sincronizaciones futuras se detienen. Las actividades ya importadas permanecen en tu historial.",
@@ -5828,17 +5699,14 @@
"title": "Apple Health",
"description": "La sincronización en vivo se realiza desde la app HealthLog para iOS, no mediante una conexión web ni OAuth.",
"status": {
"checking": "Comprobando datos de Apple Health…",
"dataReceived": "Datos de Apple Health recibidos",
"setup": "Configurar en la app de iOS",
"unavailable": "Estado de Apple Health no disponible"
},
"lastDataLabel": "Últimos datos de Apple Health:",
"setupTitle": "Activar la sincronización en vivo",
"permissionStep": "En la app HealthLog para iOS, abre Ajustes → Apple Health y permite las categorías de salud que quieras compartir.",
"backgroundStep": "Mantén activada la actualización en segundo plano. La app sincroniza datos nuevos automáticamente en primer plano y cuando iOS le concede tiempo en segundo plano.",
"importNote": "¿Necesitas un historial anterior o no puedes usar la app de iOS? La carga XML/ZIP de Apple Health es una alternativa puntual y no activa la sincronización en vivo.",
"importAction": "Abrir importación puntual"
"importAction": "Abrir importación puntual",
"staleNote": "Hace más de una semana que no llegan datos de Apple Health. Abre la app HealthLog en tu iPhone y revisa los permisos de Apple Health."
},
"garminInfo": {
"title": "Garmin",
@@ -5947,7 +5815,6 @@
"telegramGlobal": "Permitir notificaciones de Telegram.",
"ntfyGlobal": "Permitir notificaciones de ntfy.",
"webPushGlobal": "Permitir notificaciones push del navegador.",
"moodLogGlobal": "Permitir recordatorios del registro de ánimo.",
"integrationWebPush": "Web Push",
"offlineGeoLabel": "Datos geográficos",
"offlineGeoEnabled": "Sin conexión (GeoLite2)",
@@ -7042,7 +6909,6 @@
"period": "Período del informe: {start} – {end}",
"expires": "Este enlace caduca el {date}.",
"provenance": "Este es un resumen de solo lectura compartido por la persona a la que pertenece, exportado de sus datos de HealthLog autorregistrados. Los valores son autoinformados y pueden incluir lecturas sincronizadas desde dispositivos; trátelos como informados por el paciente, no como un examen clínico.",
"vitals": "Signos vitales",
"labs": "Valores de laboratorio",
"medications": "Medicamentos y adherencia",
"bmi": "Índice de masa corporal (IMC)",
@@ -7082,7 +6948,10 @@
"download": "Descargar",
"attachmentHint": "Este archivo se descarga en tu dispositivo (sin vista previa en la página).",
"exifNote": "Los metadatos de la cámara (EXIF/GPS) se han eliminado de las imágenes JPEG, PNG y WebP compartidas."
}
},
"downloadPdf": "Descargar como PDF",
"downloadFhir": "Descargar como FHIR",
"bmiSection": "Índice de masa corporal"
},
"cycle": {
"symptomCategory": {
@@ -7461,7 +7330,6 @@
"measurementReminders": {
"sectionTitle": "Revisiones",
"sectionDescription": "Recordatorios de qué medir o revisar, cuándo y dónde.",
"addButton": "Añadir recordatorio",
"markDone": "Hecho",
"delete": "Eliminar",
"deleteConfirmTitle": "¿Eliminar recordatorio?",
@@ -8990,5 +8858,42 @@
}
}
}
},
"reportSelection": {
"groupIdentity": "Datos personales",
"groupVitals": "Signos vitales",
"groupCardio": "Corazón y circulación",
"groupBody": "Medidas corporales",
"groupSleepRecovery": "Sueño y recuperación",
"groupActivity": "Actividad",
"groupMobility": "Movilidad y marcha",
"groupEnvironment": "Entorno",
"groupGlucose": "Glucosa en sangre",
"groupLabs": "Resultados de laboratorio",
"groupMedications": "Medicamentos",
"groupHistory": "Antecedentes y alergias",
"groupSensitive": "Datos sensibles",
"leafPatientIdentity": "Nombre, fecha de nacimiento, sexo, altura",
"leafInsurance": "Aseguradora y número de seguro",
"leafGlucosePanel": "Panel de glucosa (tiempo en rango, GMI, variabilidad)",
"leafLabResults": "Resultados de laboratorio",
"leafMedicationList": "Lista de medicamentos",
"leafMedicationAdministrations": "Cada dosis registrada",
"leafMedicationCompliance": "Adherencia",
"leafGlp1Therapy": "Terapia GLP-1 (historial de dosis, efectos secundarios)",
"leafAllergies": "Alergias e intolerancias",
"leafIllnessEpisodes": "Episodios de enfermedad",
"leafFamilyHistory": "Antecedentes familiares",
"leafMood": "Estado de ánimo",
"leafCycle": "Ciclo menstrual",
"templateStandard": "Informe médico estándar",
"applyStandard": "Aplicar {template}",
"standardHint": "Un clic rellena lo que suele necesitar una consulta general. Cada entrada sigue siendo editable.",
"groupCount": "{on}/{total}",
"groupDisclosure": "Mostrar las entradas de {group}",
"sensitiveHint": "Aquí nunca hay nada preseleccionado. Elija cada entrada usted mismo.",
"scopeNone": "Nada seleccionado todavía. Elija qué incluir o aplique el informe estándar.",
"scopeCount": "{count} entradas de {groups} áreas",
"scopeSensitive": "incl. {names}"
}
}
+143 -240
View File
@@ -1,6 +1,7 @@
{
"common": {
"save": "Enregistrer",
"conflictReloaded": "Rechargé depuis le serveur. Refais ta modification.",
"cancel": "Annuler",
"delete": "Supprimer",
"undo": "Annuler",
@@ -24,6 +25,10 @@
"openTimePicker": "Ouvrir le sélecteur d’heure",
"hours": "Heures",
"minutes": "Minutes",
"feet": "ft",
"inches": "in",
"heightFeet": "Taille en pieds",
"heightInches": "Taille en pouces",
"period": "AM/PM",
"am": "AM",
"pm": "PM",
@@ -57,8 +62,12 @@
"list": {
"invalid": "Les filtres de la liste d’humeur ne sont pas valides."
},
"create": { "invalid": "Vérifiez l’entrée d’humeur et réessayez." },
"update": { "invalid": "Vérifiez l’entrée d’humeur et réessayez." },
"create": {
"invalid": "Vérifiez l’entrée d’humeur et réessayez."
},
"update": {
"invalid": "Vérifiez l’entrée d’humeur et réessayez."
},
"duplicate_timestamp": "Une entrée d’humeur existe déjà pour cette heure.",
"not_found": "Cette entrée d’humeur n’est plus disponible.",
"ratedFactor": {
@@ -101,7 +110,9 @@
"complete": {
"invalid": "Vérifiez les informations du profil et réessayez."
},
"user": { "notFound": "Votre compte est introuvable." },
"user": {
"notFound": "Votre compte est introuvable."
},
"step": {
"rateLimited": "Trop de tentatives. Patientez puis réessayez.",
"invalid": "Cette étape d’accueil n’a pas pu être enregistrée.",
@@ -159,16 +170,6 @@
"footerDisclaimer1": "Ce rapport a été généré automatiquement à partir de données auto-déclarées et vise à soutenir",
"footerDisclaimer2": "la conversation médecin-patient. Il ne remplace pas un diagnostic médical. Les corrélations n'impliquent pas la causalité.",
"footerSource": "Source : HealthLog | Fuseau horaire : {timezone} | Créé : {timestamp}",
"typeWeight": "Poids corporel",
"typeBpSys": "Tension artérielle systolique",
"typeBpDia": "Tension artérielle diastolique",
"typePulse": "Pouls au repos",
"typeBodyFat": "Masse grasse",
"typeSleep": "Durée du sommeil",
"typeSteps": "Activité (pas)",
"typeTotalBodyWater": "Eau corporelle totale",
"typeBoneMass": "Masse osseuse",
"typeOxygenSaturation": "Saturation en oxygène",
"unitSteps": "Pas",
"bmiUnderweight": "Insuffisance pondérale",
"bmiNormal": "Poids normal",
@@ -204,8 +205,6 @@
"summaryTrend": "{label} : dernier {latest} {arrow}",
"summaryAdherence": "Observance médicamenteuse : {rate}% (moyenne pondérée).",
"chartsTitle": "Graphiques de tendance",
"aiSummaryTitle": "Résumé IA — non validé cliniquement",
"aiSummaryDisclaimer": "Cette section a été générée automatiquement par un modèle d’IA et n’est pas validée cliniquement. Elle ne remplace pas une évaluation médicale.",
"packageReadme": "Dossier de santé HealthLog\n\nCe paquet contient :\n- report.pdf : le rapport de santé pour votre médecin.\n- bundle.json : les mêmes données sous forme de Bundle document HL7 FHIR R4, compatible ePA.\n\nGénéré avec HealthLog.",
"wellnessTitle": "Résumé de bien-être",
"wellnessDisclaimer": "Ce sont des scores de bien-être descriptifs calculés à partir de vos signaux suivis (0–100). Ce ne sont pas des évaluations cliniques ni des diagnostics.",
@@ -364,6 +363,9 @@
"errorRegistrationDisabled": "Aucun compte ne correspond à cette identité et les nouvelles inscriptions sont actuellement fermées.",
"errorFailed": "L'authentification unique a échoué. Veuillez réessayer."
},
"native": {
"signingInToApp": "Connexion à l'application HealthLog"
},
"mfa": {
"title": "Authentification à deux facteurs",
"totpHint": "Saisissez le code à 6 chiffres de votre application d'authentification.",
@@ -455,6 +457,7 @@
"layoutReset": "Rétablir les valeurs par défaut",
"layoutSaveSuccess": "Mise en page enregistrée",
"layoutSaveError": "Impossible d’enregistrer la mise en page",
"layoutConflictReloaded": "Mise en page rechargée depuis le serveur. Veuillez refaire votre modification.",
"layoutResetSuccess": "Mise en page par défaut restaurée",
"layoutUsingDefaults": "Mise en page par défaut active",
"layoutCustomized": "Mise en page personnalisée active",
@@ -896,7 +899,6 @@
"emptyDescription": "Crée des médicaments, des doses et des fenêtres horaires pour suivre l’observance.",
"loadFailed": "Impossible de charger les médicaments",
"retryLoad": "Réessayer",
"name": "Nom",
"inactive": "En pause",
"intakeHistory": "Historique des prises",
"openDetailPage": "Ouvrir la page de détail du médicament",
@@ -1199,8 +1201,6 @@
"dosesPerUnitLabel": "Doses par unité",
"dosesPerUnitPlaceholder": "p. ex. 4",
"dosesPerUnitHint": "Doses par stylo / flacon — laisser vide pour ne pas suivre le stock.",
"injectionSiteToggle": "Rotation des sites d'injection",
"injectionSiteHint": "Guide de rotation facultatif. Le site de chaque dose est enregistré lors de la saisie de la prise.",
"short": "Dose",
"unitsPerDoseLabel": "Unités par dose",
"unitsPerDoseHint": "p. ex. 2 comprimés de 2 mg pour une dose de 4 mg, ou ½ comprimé pour une dose fractionnée"
@@ -1251,7 +1251,6 @@
"remindersDescription": "Push, Telegram ou ntfy — selon vos réglages.",
"noChannelHint": "Les rappels nécessitent un canal de notification — aucun n'est actif pour le moment.",
"noChannelHintLink": "Configurer les notifications",
"multiScheduleNote": "Ce médicament a plusieurs horaires. L’éditeur détaillé reviendra dans une version ultérieure.",
"short": "Fini"
}
},
@@ -1884,7 +1883,7 @@
"levelShift": "Un changement de niveau a été détecté autour du {date}.",
"plateau": {
"title": "Plateau de poids à la dose actuelle",
"evidence": "Variation de poids de {delta} kg sur les {window} derniers jours ({readings} mesures) — {days} jours sous {dose}."
"evidence": "Variation de poids de {delta} {unit} sur les {window} derniers jours ({readings} mesures) — {days} jours sous {dose}."
},
"retarget": {
"label": "Ce n'est pas la bonne mesure ?",
@@ -2119,7 +2118,6 @@
"emptyDescription": "Les insights apparaissent dès que tu auras saisi quelques mesures. Commence par le poids ou la tension.",
"emptyAddMeasurement": "Saisir une mesure",
"editMode": {
"open": "Personnaliser",
"done": "Terminé",
"reset": "Réinitialiser",
"title": "Personnaliser l’aperçu",
@@ -2144,16 +2142,6 @@
"bmiEmptyAction": "Ouvrir les paramètres du compte",
"noProviderConfigured": "Connecte un fournisseur d'IA pour voir ici une évaluation de tes mesures.",
"noProviderAction": "Ouvrir les réglages IA",
"startAnalysis": "Lancer l’évaluation",
"refreshAnalysis": "Actualiser l’évaluation",
"keyTakeaway": "L’essentiel",
"findingsTitle": "Constats",
"heroFindingPositive": "Constat principal",
"heroFindingNeutral": "Constat principal",
"heroFindingAttention": "À surveiller",
"heroFindingWarning": "Alerte principale",
"correlationsTitle": "Corrélations",
"recommendationsTitle": "Recommandations",
"personalRecord": {
"badge": "PR",
"tooltip": "Record personnel : {value} {unit} le {date}"
@@ -2161,53 +2149,33 @@
"recommendation": {
"source": "Source",
"viewSource": "Ouvrir la recommandation dans un nouvel onglet",
"rationale": "Pourquoi cette recommandation",
"rationaleWindow": "Fenêtre",
"rationaleComparedTo": "Comparé à",
"rationaleDeviation": "Écart",
"rationaleExpand": "Afficher la justification",
"rationaleCollapse": "Masquer la justification",
"legacyPayloadCta": "Insights mis à jour — regénère pour activer les nouvelles explications",
"feedbackHelpful": "Utile",
"feedbackNotHelpful": "Peu utile",
"feedbackThanks": "Merci pour ton retour",
"feedbackConfirmed": "Retour enregistré",
"feedbackAlreadyRated": "Déjà noté",
"feedbackError": "Impossible d’enregistrer votre retour. Réessayez.",
"confidence": "Confiance",
"confidenceAria": "Confiance : {value} sur 100",
"confidenceHigh": "Confiance élevée",
"confidenceMedium": "Confiance moyenne",
"confidenceLow": "Confiance faible — peu de points de données",
"confidenceDraft": "Brouillon"
},
"dataFoundation": "Base de données",
"dataGaps": "Lacunes :",
"lastUpdated": "Dernière mise à jour",
"confidenceHoch": "Élevée",
"confidenceMittel": "Moyenne",
"confidenceGering": "Faible",
"loadError": "Impossible de charger les analyses",
"heroPersonalBaseline": "Basé sur tes 90 derniers jours",
"heroRegenerate": "Régénérer",
"heroRegenerating": "Génération en cours",
"regenerateAnalysis": "Relancer l’évaluation",
"regenerateSuccess": "Évaluation regénérée",
"warmStarted": "Les évaluations sont en cours de préparation en arrière-plan",
"narrativeTitle": "Rétrospective",
"narrativeWeek": "Cette semaine",
"narrativeMonth": "Ce mois-ci",
"narrativePreparing": "Préparation de votre résumé…",
"narrativeUpdating": "Mise à jour…",
"narrativeProvenanceLabel": "Comment cela a été établi",
"narrativeProvenanceMethod": "Un résumé de la période en langage clair, établi uniquement à partir de vos valeurs enregistrées et des associations ayant survécu au contrôle statistique des comparaisons multiples. Descriptif, jamais une cause.",
"narrativeProvenanceMetrics": "Basé sur : {metrics}",
"narrativeProvenanceWindow": "Fenêtre : du {from} au {to}",
"heroFallbackSubtitle": "Un coup d’œil quotidien sur tes tendances — directement issu des chiffres que tu saisis.",
"heroGreetingMorning": "Bonjour",
"heroGreetingAfternoon": "Bonjour",
"heroGreetingEvening": "Bonsoir",
"heroComingSoonTooltip": "Bientôt disponible",
"heroActionAskCoach": "Demander au coach",
"healthScore": {
"label": "Score de santé",
@@ -2220,14 +2188,12 @@
"componentMood": "Humeur",
"componentCompliance": "Observance",
"deltaExplainer": {
"trigger": "Que signifie cet écart ?",
"title": "Comment l'écart est calculé",
"description": "Écart par rapport à la semaine dernière : {delta}",
"body": "Le score combine TA, poids, humeur et observance. Nous comparons le score d'aujourd'hui à celui de la semaine précédente à la même période. Enregistre une nouvelle mesure ou une note d'humeur pour le faire remonter."
},
"provenance": {
"toggle": "Composé de",
"weightLabel": "part",
"mixedBanner": "Certains composants combinent plusieurs sources. La ventilation affiche la valeur la plus récente par composant.",
"footnote": "Les pondérations sont recalibrées en fonction des données disponibles.",
"asOfLabel": "au {date}",
@@ -2291,10 +2257,6 @@
"viewDetail": "Voir le détail de {metric}"
},
"vo2Max": {
"statLatest": "Actuel",
"statMin": "Min",
"statMax": "Max",
"empty": "Pas encore de données VO₂ max — synchronise Apple Health pour remplir cette vue.",
"cardioLinkTitle": "Condition cardio (VO₂ max)",
"cardioLinkBody": "Le VO₂ max est la mesure de référence de la condition aérobie. Le graphique complet et l’évaluation se trouvent sur leur propre page.",
"cardioLinkCta": "Ouvrir la condition cardio"
@@ -2351,6 +2313,7 @@
"send": "Envoyer",
"stop": "Arrêter",
"thinking": "Réflexion en cours…",
"unverifiedFiguresNotice": "Certains chiffres n'ont pas pu être vérifiés avec tes données et ont été omis.",
"composerPlaceholder": "Pose ta question …",
"nudgeBubbleLabel": "Nouveau message du coach — ouvrir le chat",
"fabLabel": "Ouvrir le coach",
@@ -2758,125 +2721,68 @@
"gewichtDescription": "Évolution du poids face à la bande saine dérivée de ta taille, avec évaluation.",
"pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.",
"stimmungDescription": "Valeurs d’humeur quotidiennes dans le temps, avec une évaluation textuelle.",
"stimmungEmptyTitle": "Saisir la première humeur",
"stimmungEmptyDescription": "Les insights d’humeur apparaissent dès que tu auras saisi quelques entrées. Appuie ci-dessous pour démarrer.",
"stimmungEmptyAction": "Ouvrir le journal d’humeur",
"medikamenteDescription": "Calendrier des prises par médicament, ainsi qu’une évaluation textuelle.",
"medikamenteEmptyTitle": "Ajouter le premier médicament",
"medikamenteEmptyDescription": "Les insights d’observance ont besoin d’au moins un médicament enregistré.",
"medikamenteEmptyAction": "Ouvrir les médicaments",
"bmiDescription": "IMC dérivé de ton poids et de ta taille, avec évaluation.",
"target": {
"adjustLink": "Ajuster la plage cible"
},
"explainer": {
"trigger": "Qu'est-ce que {title} ?",
"bloodPressureTitle": "Tension artérielle",
"bloodPressureBody": "La tension artérielle est la force exercée par le sang sur les parois des artères, exprimée en systolique sur diastolique (le pic lors d'un battement sur la valeur de repos entre les battements). Des valeurs durablement élevées sollicitent le cœur et les vaisseaux à la longue.",
"pulseTitle": "Pouls",
"pulseBody": "Le pouls correspond au nombre de battements du cœur par minute. Une mesure ponctuelle reflète ce que vous faisiez à l'instant : au repos, il est généralement de 60 à 100 battements par minute et augmente naturellement avec le mouvement, le stress ou la caféine.",
"weightTitle": "Poids",
"weightBody": "Le poids corporel est l'un des signaux les plus simples de l'équilibre énergétique à long terme et de la santé globale. La tendance sur plusieurs semaines compte bien plus qu'une mesure isolée, qui varie avec l'hydratation, l'alimentation et l'heure de la journée.",
"bmiTitle": "Indice de masse corporelle (IMC)",
"bmiBody": "L'IMC correspond à votre poids divisé par votre taille au carré, un seul chiffre qui vous situe dans une large catégorie de poids. C'est un dépistage de population, pas un diagnostic, et il ne distingue pas le muscle de la graisse.",
"sleepTitle": "Sommeil",
"sleepBody": "La durée du sommeil est le temps réellement dormi au cours de la nuit, réparti entre sommeil léger, profond et paradoxal. La plupart des adultes ont besoin d'environ sept à neuf heures ; une quantité et une qualité régulières soutiennent la récupération, l'humeur et le métabolisme.",
"moodTitle": "Humeur",
"moodBody": "Le suivi de l'humeur consigne ce que vous ressentez au fil du temps sur une échelle simple. Le noter régulièrement révèle des tendances — comment le sommeil, l'activité ou les médicaments s'alignent sur votre ressenti — qu'une seule journée ne montre jamais.",
"medicationsTitle": "Observance du traitement",
"medicationsBody": "L'observance mesure la fiabilité avec laquelle vous prenez votre traitement comme prescrit, ici sous forme de la part des doses attendues que vous avez enregistrées. Une prise régulière est souvent ce qui permet à un traitement d'agir comme prévu.",
"restingHrTitle": "Fréquence cardiaque au repos",
"restingHrBody": "La fréquence cardiaque au repos est votre rythme cardiaque lorsque vous êtes totalement au repos, généralement mesuré la nuit. Une fréquence plus basse indique souvent une meilleure forme cardiovasculaire, et une tendance à la hausse peut être un signe précoce de stress ou de maladie.",
"hrvTitle": "Variabilité de la fréquence cardiaque (VFC)",
"hrvBody": "La VFC est la petite variation du temps entre des battements consécutifs. Une variabilité plus élevée reflète généralement un système nerveux bien récupéré et adaptable, tandis qu'une baisse durable peut signaler de la fatigue, du stress ou une récupération insuffisante.",
"oxygenSaturationTitle": "Saturation en oxygène (SpO₂)",
"oxygenSaturationBody": "La SpO₂ est le pourcentage de globules rouges transportant de l'oxygène. Des valeurs de 95 à 100 % sont normales au repos ; des valeurs qui restent sous 90 % méritent attention, bien que de brèves baisses nocturnes soient fréquentes.",
"bodyTemperatureTitle": "Température corporelle",
"bodyTemperatureBody": "La température corporelle reflète votre état thermique central, normalement autour de 37 °C avec de petites variations quotidiennes. Une hausse durable peut indiquer une infection, tandis que les mesures nocturnes au poignet suivent surtout des variations relatives plutôt qu'une valeur clinique.",
"activeEnergyTitle": "Énergie active",
"activeEnergyBody": "L'énergie active correspond aux calories que vous brûlez par le mouvement, en plus de la dépense de base au repos. C'est une mesure directe du niveau d'activité physique de votre journée.",
"workoutsTitle": "Entraînements",
"workoutsBody": "Les entraînements sont les séances d'exercice structurées que vos appareils enregistrent, avec le type, la durée et l'énergie. Les suivre montre comment un entraînement régulier s'accumule au fil des semaines et comment il se relie à vos autres signaux de santé.",
"respiratoryRateTitle": "Fréquence respiratoire",
"respiratoryRateBody": "La fréquence respiratoire est le nombre de respirations que vous prenez par minute. Au repos, la plupart des adultes respirent environ douze à vingt fois par minute ; une hausse durable peut accompagner une maladie, un effort ou du stress.",
"bodyWaterTitle": "Eau corporelle totale",
"bodyWaterBody": "L'eau corporelle totale est la part de votre poids constituée de liquide, à l'intérieur et autour des cellules. Elle varie avec l'hydratation, l'alimentation et l'activité, si bien que la tendance sur plusieurs semaines en dit plus qu'une seule mesure.",
"boneMassTitle": "Masse osseuse",
"boneMassBody": "La masse osseuse est le poids estimé des minéraux de votre squelette. Les estimations sur balance sont approximatives, mais une tendance stable ou en hausse soutient la solidité osseuse à long terme.",
"fatFreeMassTitle": "Masse non grasse",
"fatFreeMassBody": "La masse non grasse est tout ce qui, dans votre corps, n'est pas de la graisse : muscle, os, organes et eau. La maintenir ou la développer tout en perdant du poids signale en général une composition corporelle plus saine.",
"fatMassTitle": "Masse grasse",
"fatMassBody": "La masse grasse est le poids total de la graisse de votre corps. Une part de graisse est indispensable aux hormones et au stockage d'énergie, mais un excès — surtout au niveau de l'abdomen — augmente le risque pour la santé à long terme (WHO 2024).",
"muscleMassTitle": "Masse musculaire",
"muscleMassBody": "La masse musculaire est le poids estimé des muscles de votre corps. Plus de muscle soutient la force, le métabolisme et la mobilité, et demande généralement un effort actif pour être préservé avec l'âge.",
"visceralFatTitle": "Graisse viscérale",
"visceralFatBody": "La graisse viscérale est la graisse stockée au plus profond de l'abdomen, autour des organes. Contrairement à la graisse sous la peau, un niveau élevé est étroitement lié aux maladies cardiaques et au diabète de type 2 (AHA, Circulation 2021) : plus elle est basse, mieux c'est. L'échelle indique une valeur sans unité (environ 1 à 12 correspond à la plage saine, 13 et plus étant élevé) ; il s'agit d'une échelle distincte des seuils en cm² obtenus par imagerie qu'utilisent les cliniciens, et elle n'est pas interchangeable avec eux.",
"leanBodyMassTitle": "Masse maigre",
"leanBodyMassBody": "La masse maigre est votre poids total moins la graisse corporelle : muscle, os, organes et eau réunis. La suivre en parallèle du poids montre si les variations viennent du muscle ou de la graisse.",
"flightsClimbedTitle": "Étages montés",
"flightsClimbedBody": "Les étages montés comptent les niveaux que vous gravissez chaque jour, un étage correspondant à environ trois mètres de dénivelé. C'est un indicateur simple de l'activité du quotidien et de l'effort des jambes.",
"walkingDistanceTitle": "Distance à pied et en course",
"walkingDistanceBody": "La distance à pied et en course est le trajet que vous parcourez à pied chaque jour. Elle complète le nombre de pas en reflétant la distance parcourue, pas seulement la fréquence des pas.",
"walkingSteadinessTitle": "Stabilité de la marche",
"walkingSteadinessBody": "La stabilité de la marche estime à quel point votre démarche est stable et équilibrée, classée d'acceptable à faible. Un niveau bas indique un risque de chute plus élevé et mérite d'être évoqué avec un professionnel de santé.",
"walkingHeartRateTitle": "Fréquence cardiaque à la marche",
"walkingHeartRateBody": "La fréquence cardiaque à la marche est votre pouls moyen lors d'une marche régulière. Une valeur plus basse au fil du temps reflète souvent une meilleure forme cardiovasculaire pour le même effort.",
"walkingAsymmetryTitle": "Asymétrie de la marche",
"walkingAsymmetryBody": "L'asymétrie de la marche est la part du temps où vos pas sont inégaux entre la jambe gauche et la droite. Un pourcentage plus élevé peut signaler une douleur, une blessure ou un trouble de la marche à examiner.",
"doubleSupportTimeTitle": "Temps de double appui",
"doubleSupportTimeBody": "Le temps de double appui est la part de chaque cycle de marche où les deux pieds touchent le sol en même temps. Un pourcentage plus élevé traduit en général une démarche plus prudente et moins stable.",
"stepLengthTitle": "Longueur de pas",
"stepLengthBody": "La longueur de pas est la distance moyenne d'une foulée à la suivante. Un pas plus court au fil du temps peut traduire de la fatigue, de la prudence ou un changement de mobilité.",
"walkingSpeedTitle": "Vitesse de marche",
"walkingSpeedBody": "La vitesse de marche est la rapidité de votre déplacement sur terrain plat. C'est un marqueur bien étudié de la santé générale et de la mobilité, et une allure stable ou en hausse est bon signe.",
"pulseWaveVelocityTitle": "Vitesse de l'onde de pouls",
"pulseWaveVelocityBody": "La vitesse de l'onde de pouls mesure la rapidité avec laquelle l'onde de pression de chaque battement parcourt vos artères. Une onde plus rapide indique des artères plus rigides, liées à un risque cardiovasculaire accru.",
"vascularAgeTitle": "Âge vasculaire",
"vascularAgeBody": "L'âge vasculaire estime, d'après la rigidité artérielle, l'âge apparent de vos artères par rapport à votre âge réel. Un âge vasculaire inférieur à votre âge réel suggère des vaisseaux sanguins en meilleure santé.",
"environmentalAudioTitle": "Niveaux sonores ambiants",
"environmentalAudioBody": "Les niveaux sonores ambiants suivent le volume de votre environnement en décibels. Une exposition prolongée au-delà d'environ 70 dB peut endommager l'audition avec le temps : des moyennes journalières plus basses sont plus sûres.",
"headphoneAudioTitle": "Niveaux audio au casque",
"headphoneAudioBody": "Les niveaux audio au casque suivent, en décibels, le volume auquel vous écoutez avec des écouteurs ou un casque. Baisser le volume et faire des pauses protège votre audition sur le long terme.",
"audioEventsTitle": "Événements sonores forts",
"audioEventsBody": "Les événements sonores forts signalent les moments où le bruit autour de vous a atteint un niveau pouvant nuire à l'audition en cas d'exposition suffisante. Des événements fréquents invitent à rechercher un environnement plus calme ou une protection auditive.",
"daylightTitle": "Temps à la lumière du jour",
"daylightBody": "Le temps à la lumière du jour correspond à la durée passée en lumière extérieure chaque jour. Une lumière naturelle régulière soutient un rythme veille-sommeil et une humeur sains (CDC 2024), et le temps passé dehors est lié à la santé oculaire.",
"bloodGlucoseTitle": "Glycémie",
"bloodGlucoseBody": "La glycémie est la quantité de sucre dans votre sang, principale source d'énergie de l'organisme. Elle augmente après les repas et se stabilise entre eux ; des valeurs durablement élevées peuvent signaler un diabète ou un prédiabète.",
"skinTemperatureTitle": "Température cutanée",
"skinTemperatureBody": "La température cutanée correspond ici à la petite variation nocturne de la température de votre poignet par rapport à votre propre référence, et non à une mesure absolue de fièvre. Les variations peuvent suivre le sommeil, l'environnement, une maladie ou la phase du cycle menstruel.",
"cardioFitnessTitle": "Capacité cardio (VO₂ max)",
"cardioFitnessBody": "Le VO₂ max correspond à la quantité d'oxygène que votre corps peut utiliser à l'effort maximal — le meilleur indice de laboratoire de la forme aérobie, que les wearables estiment de façon indirecte. À titre indicatif, la condition physique est un prédicteur graduel de survie : chaque MET supplémentaire (environ 3,5 ml/kg/min) est associé à une mortalité inférieure d'environ 10 à 25 % (AHA, Circulation 2016). Une tendance plus élevée ou en hausse est un fort signe de santé cardiovasculaire.",
"cardioRecoveryTitle": "Récupération cardiaque",
"cardioRecoveryBody": "La récupération cardiaque correspond au nombre de battements par minute dont votre fréquence cardiaque diminue dans la première minute après l'arrêt d'un effort intense. Une baisse plus forte signifie que votre cœur se calme plus vite, signe d'une bonne condition physique.",
"wristTemperatureTitle": "Température du poignet",
"wristTemperatureBody": "La température du poignet est mesurée pendant votre sommeil et indiquée comme l'écart par rapport à votre valeur de référence, et non comme une valeur absolue. Une valeur stable est normale, tandis qu'une hausse durable peut signaler une maladie, un entraînement intense ou un changement de cycle.",
"fallsTitle": "Chutes",
"fallsBody": "Ceci compte les chutes lourdes détectées par votre wearable. L'objectif est zéro ; un nombre en hausse est un signal de risque de mobilité à évoquer avec un professionnel, surtout pour les personnes âgées (CDC STEADI 2024).",
"sixMinuteWalkTitle": "Distance de marche de 6 minutes",
"sixMinuteWalkBody": "C'est une estimation de la distance que vous pourriez parcourir en six minutes, un test clinique classique de mobilité et d'endurance cardiopulmonaire. La valeur du wearable est une approximation, pas le test clinique encadré ; c'est la tendance stable ou en hausse qui compte.",
"stairAscentSpeedTitle": "Vitesse en montée d'escaliers",
"stairAscentSpeedBody": "C'est la vitesse à laquelle vous montez une volée de marches. C'est un marqueur sensible de la force des jambes et de la mobilité : un rythme stable ou en hausse est bon signe, un rythme en baisse mérite attention.",
"stairDescentSpeedTitle": "Vitesse en descente d'escaliers",
"stairDescentSpeedBody": "C'est la vitesse à laquelle vous descendez une volée de marches. Avec la vitesse de montée, elle reflète le contrôle des jambes et l'équilibre : un rythme stable est bon signe, un rythme en baisse mérite attention.",
"breathingDisturbancesTitle": "Troubles respiratoires du sommeil",
"breathingDisturbancesBody": "Un wearable peut estimer à quelle fréquence votre respiration a été interrompue pendant le sommeil. Un indice plus bas est préférable ; un niveau signalé comme élevé, surtout sur plusieurs nuits, mérite d'être évoqué avec un professionnel. C'est une approximation de dépistage, pas un diagnostic d'apnée du sommeil.",
"stepsTitle": "Pas",
"stepsBody": "Le nombre de pas correspond au total de pas que vous faites chaque jour. C'est la mesure la plus simple de l'activité quotidienne : un total journalier plus élevé reflète généralement un mode de vie plus actif, et une habitude régulière compte plus qu'une seule grosse journée.",
"readinessBody": "La forme du jour est une lecture quotidienne unique de la disposition de votre corps à affronter la journée, mêlant fréquence cardiaque au repos, variabilité de la fréquence cardiaque, sommeil et régularité de vos dernières nuits. Un score élevé signifie que votre organisme paraît récupéré et disponible ; un score plus bas est une invitation douce à lever le pied.",
"sleepScoreBody": "Votre score de sommeil résume toute une nuit en un seul chiffre — durée, efficacité, régularité des horaires et équilibre entre sommeil léger, profond et paradoxal. Observez la tendance sur plusieurs semaines plutôt que de courir après un score parfait sur une seule nuit.",
"recoveryBody": "La récupération reflète la qualité du rétablissement de votre corps, à partir de signaux nocturnes comme la variabilité de la fréquence cardiaque et la fréquence au repos. Un score élevé est un feu vert pour pousser ; un score plus bas suggère que votre organisme se reconstruit encore.",
"strainBody": "La charge mesure l'effort cardiovasculaire et physique que vous avez imposé à votre corps, des entraînements à une journée active debout. Dès qu'il y a assez d'historique, elle est évaluée par rapport à votre propre effort habituel, pas à un standard extérieur.",
"stressBody": "Le stress est ici une estimation de la charge physiologique sur votre système nerveux, lue surtout à partir des schémas de variabilité de la fréquence cardiaque et non de ce que vous ressentez consciemment. Un score plus bas indique un état calme et bien régulé ; un score plus élevé invite à prévoir de la récupération.",
"recoveryPageTitle": "Récupération",
"recoveryPageBody": "La récupération rassemble les signaux nocturnes enregistrés par votre wearable — recharge, effort, charge ainsi que vos totaux de fréquence cardiaque et d'énergie du jour — pour montrer à quel point votre corps a récupéré. Observez la tendance sur plusieurs jours plutôt qu'un seul chiffre ; une valeur plus faible invite à lever le pied, ce n'est pas une alarme.",
"pulsePressureTitle": "Pression pulsée",
"pulsePressureBody": "La pression pulsée est l'écart entre votre valeur systolique et diastolique (systolique moins diastolique). À titre indicatif général, environ 40 mmHg est typique au repos ; un écart large au-dessus d'environ 60 mmHg à l'âge moyen peut refléter des artères rigidifiées, tandis qu'un écart étroit en dessous d'environ 25 mmHg peut accompagner un faible volume d'éjection (StatPearls/NIH 2023). C'est un sujet de discussion, pas un diagnostic.",
"meanArterialPressureTitle": "Pression artérielle moyenne",
"meanArterialPressureBody": "La pression artérielle moyenne est la pression moyenne que voient vos organes sur un battement, soit grosso modo la diastolique plus un tiers de l'écart avec la systolique. À titre indicatif général, une valeur au repos se situe autour de 70–100 mmHg (StatPearls/NIH 2023). Le seuil de ~60 mmHg souvent cité est un concept de soins aigus, pas une alarme pour l'autosurveillance.",
"cycleEducationBody": "Votre cycle traverse quatre phases pilotées par les hormones, et sa durée et sa régularité sont une fenêtre accessible sur la santé globale. À titre indicatif général, les cycles chez l'adulte durent typiquement 21–35 jours (les références cliniques citent 24–38), avec des règles d'environ cinq jours (ACOG 2015). La phase lutéale est l'ancrage stable, donc l'ovulation s'estime au mieux en comptant à rebours depuis vos prochaines règles prévues, jamais à un « jour 14 » fixe. Une fenêtre fertile prédite est une estimation qui demande plusieurs cycles enregistrés et reste approximative, et la température basale confirme l'ovulation après coup plutôt qu'elle ne la prédit. Ce n'est pas une méthode de contraception.",
"sleepHygieneBody": "Quelques habitudes fondées sur les preuves favorisent le sommeil : des heures de coucher et de lever régulières, week-ends compris, une lumière vive le matin et des soirées tamisées, une chambre fraîche, sombre et calme, et éviter la caféine l'après-midi, l'alcool tardif et les repas copieux tard le soir (CDC 2024). La régularité prédit la santé au moins autant que la durée, et le temps de sommeil total d'un wearable est bien plus fiable que sa répartition entre sommeil profond, léger et paradoxal.",
"referenceGuidance": "Indication générale, pas un avis médical. Les plages de référence sont des repères de population qui évoluent avec votre âge, votre sexe et vos conditions de santé ; votre propre tendance dans le temps guide la lecture."
"sleepHygieneBody": "Quelques habitudes fondées sur les preuves favorisent le sommeil : des heures de coucher et de lever régulières, week-ends compris, une lumière vive le matin et des soirées tamisées, une chambre fraîche, sombre et calme, et éviter la caféine l'après-midi, l'alcool tardif et les repas copieux tard le soir (CDC 2024). La régularité prédit la santé au moins autant que la durée, et le temps de sommeil total d'un wearable est bien plus fiable que sa répartition entre sommeil profond, léger et paradoxal."
},
"bpDerived": {
"pulsePressure": "La pression pulsée (l'écart entre vos valeurs) est d'environ {value} mmHg ; environ 40 est typique au repos.",
@@ -2910,16 +2816,6 @@
"compositionTitle": "Répartition des phases",
"compositionSubtitle": "{nights} dernières nuits",
"compositionAriaLabel": "Répartition des phases de sommeil sur {nights} nuits",
"hypnogram": {
"title": "La nuit dernière",
"subtitle": "{asleep} de sommeil",
"ariaLabel": "Progression des phases de sommeil de la nuit dernière : {asleep} de sommeil, {awakenings} réveils",
"asleepTotal": "Sommeil {value}",
"inBedTotal": "Au lit {value}",
"awakenings": "{count} réveils",
"source": "Source : {source}",
"estimateNote": "Les durées des phases sont estimées : cette source indique des totaux par phase, pas une chronologie mesurée."
},
"sourceDiscrepancy": {
"tooltipTitle": "Les sources divergent"
},
@@ -2974,7 +2870,6 @@
"loadError": "Votre rythme de sommeil n'a pas pu se charger pour l'instant. Il réapparaîtra dès que la connexion sera rétablie."
}
},
"correlations": "Corrélations",
"medicationCompliance": "Observance des médicaments",
"dayStreak": "{count} jours de série",
"dayStreakOne": "{count} jour de série",
@@ -2983,19 +2878,12 @@
"taken": "Pris",
"skipped": "Sauté",
"missed": "Manqué",
"generalStatusBadge": {
"good": "Bien",
"watch": "Surveiller",
"critical": "Critique"
},
"assessmentTitle": "Évaluation",
"restMode": {
"title": "Mode repos",
"body": "Un épisode de maladie ou d'affection est en cours, ceci reflète donc une période de récupération. Votre score n'en est pas pénalisé.",
"bodySince": "Un épisode de maladie ou d'affection est en cours depuis le {since}, ceci reflète donc une période de récupération. Votre score n'en est pas pénalisé."
},
"assessmentShowMore": "Afficher plus",
"assessmentShowLess": "Afficher moins",
"bloodPressureSectionTitle": "Tension artérielle",
"weightSectionTitle": "Poids",
"pulseSectionTitle": "Pouls",
@@ -3003,8 +2891,6 @@
"complianceNoData": "Aucune donnée d’observance disponible",
"generate": "Générer une nouvelle analyse",
"moodSectionTitle": "Humeur",
"confidence": "Confiance",
"cached": "En cache",
"emptyState": {
"bloodPressure": {
"title": "Aucune mesure de tension artérielle",
@@ -3053,11 +2939,6 @@
"title": "Aucun entraînement",
"description": "Ouvre l'app Apple Santé sur ton iPhone pour synchroniser tes entraînements. L'import d'activité Withings alimente également la marche et le vélo."
},
"row": {
"duration": "Durée",
"distance": "Distance",
"energy": "Énergie active"
},
"detail": {
"backToList": "Retour aux entraînements",
"statsTitle": "Statistiques",
@@ -3070,13 +2951,9 @@
"statsStepCount": "Pas",
"statsElevation": "Dénivelé",
"statsPace": "Allure",
"statsSource": "Source",
"routeTitle": "Parcours",
"routeUnavailable": "Aucun parcours GPS enregistré pour cet entraînement.",
"hrChartTitle": "Fréquence cardiaque",
"hrChartUnavailable": "Les échantillons de fréquence cardiaque à la seconde ne sont pas stockés pour cet entraînement.",
"notFound": "Entraînement introuvable.",
"loading": "Chargement de l'entraînement…",
"hrFromPulse": "À partir de vos données de fréquence cardiaque autour de cette séance",
"hrAxisMinutes": "min",
"sportAverageLabel": "Votre moyenne récente :",
@@ -3548,7 +3425,6 @@
"title": "Ce qui ressort",
"subheading": "Relations découvertes",
"footer": "Issues de {tested} paires d'un jour à l'autre testées sur l'ensemble de vos mesures, après correction des fausses découvertes.",
"empty": "Aucune relation d'humeur n'a encore franchi le seuil statistique. Continuez à enregistrer.",
"disclaimer": "Filtré statistiquement, mais toujours observationnel : une tendance à surveiller, pas une cause.",
"moodUp": "Un {factor} plus élevé tend à coïncider avec une meilleure humeur le lendemain dans vos données.",
"moodDown": "Un {factor} plus élevé tend à coïncider avec une moins bonne humeur le lendemain dans vos données.",
@@ -3927,8 +3803,6 @@
},
"briefing": {
"memory": {
"recall": "Plus tôt, {text}",
"forward": "En regardant vers l'avenir : {text}",
"forwardWatch": "{metric} est à surveiller.",
"forwardHolding": "Pour l'instant, tout reste stable."
}
@@ -3942,9 +3816,7 @@
}
},
"streak": {
"returnLabel": "Ton {metric} est de nouveau dans ta plage habituelle après {count} jours — quoi que c'était, c'est passé.",
"daysInside": "{count} jours dans la plage",
"daysOutside": "{count} jours hors plage"
"returnLabel": "Ton {metric} est de nouveau dans ta plage habituelle après {count} jours — quoi que c'était, c'est passé."
},
"updatedTodayAt": "Mis à jour aujourd'hui, {time}",
"updatedYesterday": "Mis à jour hier",
@@ -4245,6 +4117,7 @@
"displayNamePlaceholder": "Comment HealthLog doit-il vous appeler ?",
"displayNameHint": "Apparaît dans le message d'accueil du tableau de bord et dans les rapports.",
"heightLabel": "Taille (cm)",
"heightLabelFtIn": "Taille (ft, in)",
"genderLabel": "Genre",
"genderNone": "Préfère ne pas le préciser",
"genderMale": "Homme",
@@ -4572,10 +4445,6 @@
"passphraseHelp": "Dérive la clé de l'archive avec Argon2id et la scelle avec AES-256-GCM. Enregistrez le fichier avec l'extension .hlx.",
"noRecoveryWarning": "Aucune récupération possible : si vous perdez cette phrase secrète, l'archive ne pourra pas être ouverte. Conservez-la en lieu sûr.",
"stepUpNeeded": "Confirmez votre second facteur, puis réessayez."
},
"cycle": {
"title": "Cycle",
"description": "Données de cycle et de santé reproductive sous forme de document FHIR : dernières règles, durée moyenne du cycle et des règles, phase actuelle."
}
},
"import": {
@@ -4597,6 +4466,7 @@
"failed": "L'import a échoué.",
"rowsImported": "{count} lignes importées jusqu'ici",
"doneSummary": "Terminé — {imported} enregistrements sur {read} importés, {skipped} enregistrements cliniques ignorés.",
"doneNothing": "Rien n'a été importé de cette archive : 0 enregistrement sur {read} est arrivé.",
"estimateWarning": "Les totaux cumulés de {count} jour(s) ont été estimés à partir de la source d’export la plus élevée. Les statistiques Apple Santé natives restent la référence.",
"phase": {
"queued": "En file d'attente…",
@@ -4620,7 +4490,10 @@
"rateLimited": "Trop d'imports cette heure-ci (limite 5). Réessayez plus tard.",
"failed": "L'import a échoué. Veuillez réessayer.",
"readFailed": "Impossible de lire ce fichier.",
"resultSummary": "{measurements} mesures et {moods} entrées d'humeur importées, {skipped} ignorées."
"resultSummary": "{measurements} mesures et {moods} entrées d'humeur importées, {skipped} ignorées.",
"resultSuccess": "{measurements} mesures et {moods} entrées d'humeur importées.",
"resultNothing": "Rien n'a été importé. Les {skipped} entrées ont toutes été ignorées.",
"resultEmpty": "Ce fichier ne contenait ni mesures ni entrées d'humeur."
},
"csv": {
"title": "Fichier CSV",
@@ -4641,7 +4514,30 @@
"readFailed": "Impossible de lire ce fichier.",
"previewSummary": "Aperçu : {inserted} seraient importées, {skipped} ignorées.",
"resultSummary": "{inserted} nouvelles importées et {updated} mises à jour, {skipped} ignorées.",
"rowError": "Ligne {line} : {reason}"
"rowError": "Ligne {line} : {reason}",
"resultSuccess": "{inserted} nouvelles importées et {updated} mises à jour.",
"resultNothing": "Rien n'a été importé. Les {skipped} lignes ont toutes été ignorées.",
"resultEmpty": "Ce fichier a un en-tête mais aucune ligne de données.",
"previewSuccess": "Aperçu : {inserted} seraient importées.",
"previewNothing": "Aperçu : rien ne serait importé. Les {skipped} lignes seraient toutes ignorées.",
"skipGroupOne": "{count} ligne ignorée : {reason}",
"skipGroupFew": "{count} lignes ignorées : {reason}",
"skipGroupOther": "{count} lignes ignorées : {reason}",
"skipDetails": "Afficher toutes les lignes ignorées",
"reasonUnknownType": "type de mesure inconnu",
"reasonInvalidValue": "la valeur n'est pas un nombre",
"reasonValueOutOfRange": "valeur hors de la plage plausible",
"reasonUnknownUnit": "unité non reconnue pour ce type",
"reasonMissingTimezoneOffset": "horodatage sans décalage horaire",
"reasonInvalidTimestamp": "horodatage illisible",
"reasonImplausibleTimestamp": "horodatage dans le futur ou antérieur à 1900",
"reasonUnexpectedGlucoseContext": "contexte glycémique sur une ligne qui n'est pas une glycémie",
"reasonInvalidGlucoseContext": "le contexte glycémique n'est pas une valeur autorisée",
"reasonNotesTooLong": "la note est trop longue",
"reasonExternalIdTooLong": "l'identifiant externe est trop long",
"reasonUnstableExternalId": "l'identifiant externe n'est pas stable d'un export à l'autre",
"reasonDuplicate": "déjà importée",
"reasonUnknown": "ignorée"
}
},
"subtitle": "Exportez vos données ou importez depuis une sauvegarde."
@@ -4792,6 +4688,7 @@
},
"username": "Nom d'utilisateur",
"height": "Taille (cm)",
"heightFtIn": "Taille (ft, in)",
"dateOfBirth": "Date de naissance",
"dateOfBirthHint": "Utilisé pour le calcul automatique des objectifs de tension artérielle.",
"gender": "Sexe",
@@ -4825,17 +4722,10 @@
"passwordMismatch": "Les nouveaux mots de passe ne correspondent pas",
"passwordUpdated": "Mot de passe mis à jour",
"passkeys": "Passkeys",
"registeredPasskeys": "Clés d’accès enregistrées",
"passkeysDescription": "Les clés d’accès permettent une connexion sécurisée sans mot de passe.",
"passkeyName": "Nom",
"passkeyDevice": "Appareil",
"passkeyBackup": "Backup",
"passkeyCreated": "Créée",
"passkeyActions": "Actions",
"noPasskeys": "Aucune clé d’accès enregistrée.",
"addPasskey": "Ajouter une clé d’accès",
"passkeyAdded": "Clé d’accès ajoutée !",
"passkeyOptionsError": "Impossible de charger les options de clé d’accès",
"passkeyRegistrationFailed": "Échec de l’enregistrement de la clé d’accès",
"passkeyRegistrationCancelled": "Enregistrement de la clé d’accès annulé",
"passkeyNotSupported": "Votre navigateur ou appareil ne prend pas encore en charge les clés d’accès. Essayez un autre navigateur ou appareil.",
@@ -4884,7 +4774,6 @@
"webPushSubscribed": "Notifications push activées !",
"webPushUnsubscribed": "Notifications push désactivées.",
"webPushSubscribeFailed": "Échec de l’activation",
"webPushActive": "Actif",
"kiInsightsDescription": "Facultatif : enregistrez votre clé OpenAI pour des analyses quotidiennes et automatiques dans Insights.",
"codexConnected": "ChatGPT connecté ! Insights est maintenant actif.",
"codexDisconnected": "Connexion à ChatGPT supprimée.",
@@ -4922,7 +4811,6 @@
"savedShort": "(enregistrée)",
"saveCta": "Enregistrer",
"saved": "Enregistré",
"saveFailed": "Échec de l’enregistrement",
"errorGeneric": "Erreur",
"providerChain": {
"types": {
@@ -5058,11 +4946,9 @@
"hint": "Transmis au Coach et au briefing quotidien comme contexte supplémentaire. Laissez vide pour ne rien envoyer.",
"profileHint": "L'âge et le genre proviennent de votre profil et sont inclus automatiquement.",
"freeTextLabel": "À propos de moi (texte libre)",
"conditionsLabel": "Maladies chroniques",
"conditionsPlaceholder": "P. ex. hypertension, diabète de type 2…",
"allergiesLabel": "Allergies et intolérances",
"allergiesPlaceholder": "P. ex. intolérance au lactose, allergie à la pénicilline…",
"focusLabel": "À quoi le Coach doit-il veiller ?",
"focusPlaceholder": "P. ex. surveille ma tension, motive-moi à bouger davantage…",
"completeness": "{answered} sur {total} renseignés",
"questionsTitle": "Le Coach a des questions",
@@ -5140,8 +5026,6 @@
"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."
},
"integrationsSourcesHint": "Quand plusieurs sources fournissent la même métrique, la priorité des sources décide quelle valeur compte.",
"integrationsSourcesHintLink": "Ouvrir la priorité des sources",
"withingsSynchronize": "Synchroniser",
"withingsDisconnect": "Déconnecter",
"withingsDisconnectTitle": "Déconnecter Withings ?",
@@ -5271,7 +5155,7 @@
"integrationPill": {
"connected": "Connecté",
"errorReconnect": "Erreur — reconnecter",
"warningServerError": "Connecté, erreur serveur",
"warningServerError": "Connecté, problème de synchronisation",
"parkedReconnect": "En pause — reconnecter manuellement",
"notConnected": "Non connecté",
"justNow": "à l’instant",
@@ -5281,8 +5165,26 @@
"ariaLabel": "Statut de l’intégration",
"resumeCta": "Reconnecter",
"resumeSuccess": "Intégration reprise",
"resumeError": "Échec de la reconnexion"
"resumeError": "Échec de la reconnexion",
"stale": "Connecté, aucune donnée récente",
"stalled": "Synchronisation arrêtée",
"pendingSetup": "En attente des premières données"
},
"integrationFreshness": {
"title": "Données reçues",
"summary": "{count} types de données · plus récent {relative}",
"quiet": "{count} silencieux",
"workouts": "Séances"
},
"integrationFallback": {
"description": "Configuré sur ce compte sans carte de réglages dédiée. Cette ligne existe pour qu'une connexion ne puisse pas cesser de fonctionner sans que personne ne le voie.",
"lastAttempt": "Dernière tentative {relative}",
"noAttempt": "Aucune tentative de synchronisation enregistrée"
},
"withingsViewData": "Voir vos mesures",
"whoopViewData": "Voir votre récupération",
"fitbitViewData": "Voir votre sommeil",
"googleHealthViewData": "Voir vos pas",
"apiTokens": "Jetons API",
"apiTokensDescription": "Consultez et révoquez les jetons API pour l’enregistrement externe des prises de médicaments. Les jetons sont émis par médicament depuis son interrupteur de point de terminaison API.",
"tokenMintMovedDescription": "Les jetons sont émis par médicament. Ouvrez un médicament et activez son point de terminaison API pour obtenir un jeton limité à ce seul médicament.",
@@ -5314,10 +5216,7 @@
"apiEndpointPath": "Chemin",
"apiEndpointAuth": "Authentification",
"apiEndpointExample": "Exemple de corps",
"collapse": "Réduire",
"expand": "Développer",
"dangerZone": "Supprimer toutes les données",
"dangerZoneTitle": "Zone à risque",
"dangerZoneDescription": "Supprime toutes vos données de santé et intégrations. Votre compte utilisateur sera conservé.",
"dangerZoneConfirm": "Supprimer toutes les données ?",
"dangerZoneConfirmDescription": "Cette action supprimera définitivement toutes vos données de santé et intégrations. Votre compte utilisateur sera conservé.",
@@ -5334,31 +5233,9 @@
"deleteAccountFinal": "Supprimer définitivement",
"saved": "Enregistré",
"savingError": "Erreur lors de l’enregistrement",
"moodLogTitle": "moodLog",
"moodLogDescription": "Import mood data from moodLog",
"moodLogDeprecated": "Obsolète : l’humeur est désormais suivie nativement dans HealthLog avec tags et facteurs. Cette intégration fonctionne encore mais sera supprimée dans une version future.",
"moodLogUrl": "moodLog URL",
"moodLogUrlPlaceholder": "https://mood.example.com",
"moodLogApiKey": "Clé API",
"moodLogApiKeyPlaceholder": "ml_...",
"moodLogWebhookSecret": "Secret du webhook",
"moodLogWebhookSecretHelp": "Saisissez ce secret dans moodLog comme secret de webhook",
"moodLogSync": "Synchroniser maintenant",
"moodLogFullSync": "Synchroniser toutes les données",
"moodLogFullSyncConfirm": "Synchroniser",
"moodLogFullSyncTitle": "Synchronisation complète ?",
"moodLogFullSyncDescription": "Toutes les données d’humeur disponibles seront entièrement synchronisées.",
"moodLogDisconnect": "Déconnecter",
"moodLogDisconnectTitle": "Déconnecter moodLog ?",
"moodLogDisconnectDescription": "La connexion sera supprimée et toutes les données d’humeur importées seront supprimées.",
"moodLogEntries": "Entrées",
"moodLogSyncResult": "{count} entrées synchronisées",
"moodLogSyncFailed": "Échec de la synchronisation",
"moodLogDisconnected": "moodLog déconnecté",
"about": {
"version": "Version de l’app",
"gitSha": "Build",
"builtAt": "Compilé {time}",
"builtAtLabel": "Compilé",
"license": "Licence",
"repository": "Code source",
@@ -5430,34 +5307,17 @@
"practiceName": "Cabinet (facultatif)",
"practiceNamePlaceholder": "p. ex. Dr Dupont",
"includedData": "Données incluses",
"groupVitals": "Signes vitaux",
"groupCardio": "Cœur & forme",
"groupActivity": "Activité & sommeil",
"weight": "Poids",
"bp": "Tension artérielle",
"pulse": "Pouls",
"oxygenSaturation": "Saturation en oxygène",
"bodyFat": "Masse grasse",
"bodyComposition": "Composition corporelle",
"restingHeartRate": "Pouls au repos",
"hrv": "Variabilité de la fréquence cardiaque",
"vo2max": "VO₂max",
"steps": "Pas",
"distance": "Distance",
"sleep": "Sommeil",
"glucose": "Glycémie",
"medications": "Médicaments & observance",
"mood": "Humeur (désactivé par défaut — données sensibles)",
"bmi": "IMC",
"includeCharts": "Intégrer les graphiques de tendance (PDF uniquement)",
"includeAiSummary": "Ajouter le résumé IA (non validé cliniquement)",
"fhirNote": "La sortie structurée suit la norme FHIR R4 (compatible ePA).",
"generate": "Générer",
"error": "Échec de l’export ({code}).",
"labs": "Résultats d'analyses",
"allergies": "Allergies et intolérances",
"familyHistory": "Antécédents familiaux",
"documentsLink": "Documents de la période"
"documentsLink": "Documents de la période",
"range30": "30 derniers jours",
"rangeCustom": "Période personnalisée",
"rangeCustomStart": "Du",
"rangeCustomEnd": "Au",
"errorGeneric": "L'export n'a pas pu être généré. Veuillez réessayer.",
"errorRateLimit": "Vous avez atteint la limite d'exports pour cette heure.",
"errorModuleDisabled": "L'export du dossier est désactivé pour ce compte."
},
"globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement",
"globalExcludedInjectionSitesHint": "Les sites listés ici ne sont proposés pour aucun médicament et sont refusés lors de l'enregistrement d'une dose, même si un médicament les privilégie.",
@@ -5471,13 +5331,8 @@
"expiry": "Expire dans (jours)",
"expiryHint": "Au plus {max} jours.",
"expiryInvalid": "L'expiration doit être comprise entre 1 et {max} jours.",
"fhirApi": "Autoriser l'API FHIR restreinte",
"fhirApiHint": "Laissez le lien servir un point d'accès FHIR en lecture seule en plus de la vue page.",
"resourceTypes": "Types de ressource FHIR",
"create": "Créer le lien",
"tokenCreated": "Lien de partage créé",
"tokenOnce": "Copiez ce lien maintenant — le jeton n'est affiché qu'une fois et ne peut pas être récupéré.",
"copy": "Copier le lien",
"copied": "Copié dans le presse-papiers.",
"activeTitle": "Liens actifs",
"noActive": "Aucun lien de partage actif.",
@@ -5491,7 +5346,6 @@
"statusExpired": "Expiré",
"inactiveTitle": "Révoqués et expirés ({count})",
"shownOnce": "Affiché une seule fois. Enregistrez le code QR et la phrase secrète maintenant — ils ne pourront pas être récupérés ensuite.",
"qrLabel": "Code QR",
"qrAlt": "Code QR contenant le lien de partage et la phrase secrète",
"linkLabel": "Lien",
"passphraseLabel": "Phrase secrète",
@@ -5514,7 +5368,12 @@
"pickerEmpty": "Aucun document trouvé.",
"pickerDone": "Terminé",
"pickerSelectedCount": "{count} sur {max} sélectionnés",
"pickerMaxReached": "Vous avez atteint le maximum de {max} documents."
"pickerMaxReached": "Vous avez atteint le maximum de {max} documents.",
"scopeTitle": "Ce que contient ce lien",
"scopeInsuranceExcluded": "Les données d'assurance ne sont jamais partagées via un lien.",
"statusNeedsReselect": "Choisir à nouveau le contenu",
"reselectReason": "Ce lien diffusait un périmètre que personne n'avait choisi ; il a donc été fermé. Créez-en un nouveau et choisissez son contenu.",
"reselectAction": "Créer un remplacement"
},
"genderOther": "Autre",
"cycleTracking": {
@@ -5541,13 +5400,15 @@
"nightscoutUpdate": "Enregistrer les modifications",
"nightscoutConnected": "Nightscout connecté.",
"nightscoutConnectFailed": "Impossible de se connecter à Nightscout.",
"nightscoutSync": "Synchroniser",
"nightscoutSyncResult": "{count} mesures synchronisées",
"nightscoutSyncFailed": "Échec de la synchronisation",
"nightscoutDisconnect": "Déconnecter",
"nightscoutDisconnectTitle": "Déconnecter Nightscout ?",
"nightscoutDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures de glycémie déjà importées restent dans votre historique.",
"polar": "Polar",
"polarDescription": "Connecte ton compte Polar pour synchroniser la récupération Nightly Recharge, les phases de sommeil et l'activité.",
"polarDescription": "Connecte ton compte Polar pour synchroniser la récupération Nightly Recharge, les phases de sommeil, l'activité et les séances d'entraînement.",
"polarViewData": "Voir votre sommeil et récupération",
"polarHelp": "Connectez-vous à Polar pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.",
"polarCredentials": "Identifiants API",
"polarClientId": "Client ID",
"polarClientSecret": "Client Secret",
@@ -5556,6 +5417,9 @@
"polarSaveCredentials": "Enregistrer",
"polarUnavailable": "Polar n'est pas configuré sur ce serveur. Ajoutez vos propres identifiants OAuth Polar ci-dessus, ou demandez à votre administrateur de configurer l'application partagée.",
"polarConnect": "Connecter Polar",
"polarSync": "Synchroniser",
"polarSyncResult": "{count} mesures synchronisées",
"polarSyncFailed": "Échec de la synchronisation",
"polarDisconnect": "Déconnecter",
"polarDisconnectTitle": "Déconnecter Polar ?",
"polarDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures déjà importées restent dans votre historique.",
@@ -5574,7 +5438,6 @@
"oura": "Oura",
"ouraDescription": "Connecte ton compte Oura pour synchroniser readiness, phases de sommeil, variabilité cardiaque et activité quotidienne.",
"ouraViewData": "Voir votre sommeil et récupération",
"ouraHelp": "Connectez-vous à Oura pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.",
"ouraCredentials": "Identifiants API",
"ouraClientId": "Client ID",
"ouraClientSecret": "Client Secret",
@@ -5583,6 +5446,9 @@
"ouraSaveCredentials": "Enregistrer",
"ouraUnavailable": "Oura n'est pas configuré sur ce serveur. Ajoutez vos propres identifiants OAuth Oura ci-dessus, ou demandez à votre administrateur de configurer l'application partagée.",
"ouraConnect": "Connecter Oura",
"ouraSync": "Synchroniser",
"ouraSyncResult": "{count} mesures synchronisées",
"ouraSyncFailed": "Échec de la synchronisation",
"ouraDisconnect": "Déconnecter",
"ouraDisconnectTitle": "Déconnecter Oura ?",
"ouraDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures déjà importées restent dans votre historique.",
@@ -5811,6 +5677,9 @@
"stravaSaveCredentials": "Enregistrer",
"stravaUnavailable": "Strava n'est pas configuré sur ce serveur. Ajoute tes propres identifiants API Strava ci-dessus ou demande à ton administrateur de configurer l'application partagée.",
"stravaConnect": "Connecter Strava",
"stravaSync": "Synchroniser",
"stravaSyncResult": "{count} mesures synchronisées",
"stravaSyncFailed": "Échec de la synchronisation",
"stravaDisconnect": "Déconnecter",
"stravaDisconnectTitle": "Déconnecter Strava ?",
"stravaDisconnectDescription": "Les synchronisations futures s'arrêtent. Les activités déjà importées restent dans ton historique.",
@@ -5830,17 +5699,14 @@
"title": "Apple Health",
"description": "La synchronisation en direct passe par l’app HealthLog pour iOS, et non par une connexion web ou OAuth.",
"status": {
"checking": "Vérification des données Apple Health…",
"dataReceived": "Données Apple Health reçues",
"setup": "Configurer dans l’app iOS",
"unavailable": "Statut Apple Health indisponible"
},
"lastDataLabel": "Dernières données Apple Health :",
"setupTitle": "Activer la synchronisation en direct",
"permissionStep": "Dans l’app HealthLog pour iOS, ouvre Réglages → Apple Health et autorise les catégories de santé que tu souhaites partager.",
"backgroundStep": "Laisse l’actualisation en arrière-plan activée. L’app synchronise automatiquement les nouvelles données au premier plan et lorsque iOS lui accorde du temps en arrière-plan.",
"importNote": "Besoin d’un ancien historique ou impossible d’utiliser l’app iOS ? L’envoi XML/ZIP Apple Health est une solution ponctuelle et n’active pas la synchronisation en direct.",
"importAction": "Ouvrir l’import ponctuel"
"importAction": "Ouvrir l’import ponctuel",
"staleNote": "Aucune donnée Apple Health n'est arrivée depuis plus d'une semaine. Ouvrez l'app HealthLog sur votre iPhone et vérifiez les autorisations Apple Health."
},
"garminInfo": {
"title": "Garmin",
@@ -5949,7 +5815,6 @@
"telegramGlobal": "Autoriser les notifications Telegram.",
"ntfyGlobal": "Autoriser les notifications ntfy.",
"webPushGlobal": "Autoriser les notifications push du navigateur.",
"moodLogGlobal": "Autoriser les rappels du journal d'humeur.",
"integrationWebPush": "Web Push",
"offlineGeoLabel": "Données géographiques",
"offlineGeoEnabled": "Hors ligne (GeoLite2)",
@@ -7044,7 +6909,6 @@
"period": "Période couverte : {start} – {end}",
"expires": "Ce lien expire le {date}.",
"provenance": "Il s'agit d'un résumé en lecture seule partagé par la personne concernée, exporté de ses données HealthLog auto-enregistrées. Les valeurs sont auto-déclarées et peuvent inclure des mesures synchronisées depuis des appareils ; à considérer comme rapportées par le patient, non comme un examen clinique.",
"vitals": "Signes vitaux",
"labs": "Valeurs de laboratoire",
"medications": "Médicaments et observance",
"bmi": "Indice de masse corporelle (IMC)",
@@ -7084,7 +6948,10 @@
"download": "Télécharger",
"attachmentHint": "Ce fichier se télécharge sur votre appareil (pas d'aperçu dans la page).",
"exifNote": "Les métadonnées de l'appareil photo (EXIF/GPS) ont été supprimées des images JPEG, PNG et WebP partagées."
}
},
"downloadPdf": "Télécharger en PDF",
"downloadFhir": "Télécharger en FHIR",
"bmiSection": "Indice de masse corporelle"
},
"cycle": {
"symptomCategory": {
@@ -7463,7 +7330,6 @@
"measurementReminders": {
"sectionTitle": "Examens",
"sectionDescription": "Rappels de ce qu'il faut mesurer ou vérifier, quand et où.",
"addButton": "Ajouter un rappel",
"markDone": "Terminé",
"delete": "Supprimer",
"deleteConfirmTitle": "Supprimer le rappel ?",
@@ -8992,5 +8858,42 @@
}
}
}
},
"reportSelection": {
"groupIdentity": "Données personnelles",
"groupVitals": "Signes vitaux",
"groupCardio": "Cœur et circulation",
"groupBody": "Mensurations",
"groupSleepRecovery": "Sommeil et récupération",
"groupActivity": "Activité",
"groupMobility": "Mobilité et marche",
"groupEnvironment": "Environnement",
"groupGlucose": "Glycémie",
"groupLabs": "Résultats de laboratoire",
"groupMedications": "Médicaments",
"groupHistory": "Antécédents et allergies",
"groupSensitive": "Données sensibles",
"leafPatientIdentity": "Nom, date de naissance, sexe, taille",
"leafInsurance": "Assurance et numéro d'assuré",
"leafGlucosePanel": "Bilan glycémique (temps dans la cible, GMI, variabilité)",
"leafLabResults": "Résultats de laboratoire",
"leafMedicationList": "Liste des médicaments",
"leafMedicationAdministrations": "Chaque prise enregistrée",
"leafMedicationCompliance": "Observance",
"leafGlp1Therapy": "Traitement GLP-1 (historique des doses, effets indésirables)",
"leafAllergies": "Allergies et intolérances",
"leafIllnessEpisodes": "Épisodes de maladie",
"leafFamilyHistory": "Antécédents familiaux",
"leafMood": "Humeur",
"leafCycle": "Cycle menstruel",
"templateStandard": "Compte rendu médical standard",
"applyStandard": "Appliquer {template}",
"standardHint": "Un clic remplit ce dont une consultation générale a généralement besoin. Chaque élément reste modifiable.",
"groupCount": "{on}/{total}",
"groupDisclosure": "Afficher les éléments de {group}",
"sensitiveHint": "Rien n'est jamais présélectionné ici. Choisissez chaque élément vous-même.",
"scopeNone": "Rien de sélectionné pour l'instant. Choisissez ce qui doit figurer, ou appliquez le compte rendu standard.",
"scopeCount": "{count} éléments issus de {groups} domaines",
"scopeSensitive": "dont {names}"
}
}
+158 -245
View File
@@ -1,6 +1,7 @@
{
"common": {
"save": "Salva",
"conflictReloaded": "Ricaricato dal server. Applica di nuovo la tua modifica.",
"cancel": "Annulla",
"delete": "Elimina",
"undo": "Annulla",
@@ -24,6 +25,10 @@
"openTimePicker": "Apri il selettore di orario",
"hours": "Ore",
"minutes": "Minuti",
"feet": "ft",
"inches": "in",
"heightFeet": "Altezza in piedi",
"heightInches": "Altezza in pollici",
"period": "AM/PM",
"am": "AM",
"pm": "PM",
@@ -48,13 +53,21 @@
},
"apiErrors": {
"measurement": {
"create": { "invalid": "Controlla i dati della misurazione e riprova." },
"create": {
"invalid": "Controlla i dati della misurazione e riprova."
},
"duplicate_timestamp": "Esiste già una misurazione per questo orario."
},
"mood": {
"list": { "invalid": "I filtri dell’elenco dell’umore non sono validi." },
"create": { "invalid": "Controlla la voce dell’umore e riprova." },
"update": { "invalid": "Controlla la voce dell’umore e riprova." },
"list": {
"invalid": "I filtri dell’elenco dell’umore non sono validi."
},
"create": {
"invalid": "Controlla la voce dell’umore e riprova."
},
"update": {
"invalid": "Controlla la voce dell’umore e riprova."
},
"duplicate_timestamp": "Esiste già una voce dell’umore per questo orario.",
"not_found": "Questa voce dell’umore non è più disponibile.",
"ratedFactor": {
@@ -71,7 +84,9 @@
"biomarker": {
"notFound": "Questo biomarcatore non è più disponibile.",
"duplicate": "Esiste già un biomarcatore con questo nome.",
"create": { "invalid": "Controlla i dati del biomarcatore e riprova." },
"create": {
"invalid": "Controlla i dati del biomarcatore e riprova."
},
"update": {
"invalid": "Controlla i dati del biomarcatore e riprova.",
"referenceRangeInvalid": "Il valore di riferimento inferiore non può superare quello superiore."
@@ -86,12 +101,18 @@
}
},
"onboarding": {
"disclaimer": { "invalid": "Conferma nuovamente l’avviso medico." },
"disclaimer": {
"invalid": "Conferma nuovamente l’avviso medico."
},
"tour": {
"invalid": "Non è stato possibile salvare l’avanzamento del tour."
},
"complete": { "invalid": "Controlla i dati del profilo e riprova." },
"user": { "notFound": "Impossibile trovare il tuo account." },
"complete": {
"invalid": "Controlla i dati del profilo e riprova."
},
"user": {
"notFound": "Impossibile trovare il tuo account."
},
"step": {
"rateLimited": "Troppi tentativi. Attendi e riprova.",
"invalid": "Non è stato possibile salvare questo passaggio introduttivo.",
@@ -149,16 +170,6 @@
"footerDisclaimer1": "Questo rapporto è stato generato automaticamente da dati auto-registrati ed è destinato a supportare",
"footerDisclaimer2": "il dialogo medico-paziente. Non sostituisce una diagnosi medica. Le correlazioni non implicano causalità.",
"footerSource": "Fonte: HealthLog | Fuso orario: {timezone} | Creato: {timestamp}",
"typeWeight": "Peso corporeo",
"typeBpSys": "Pressione arteriosa sistolica",
"typeBpDia": "Pressione arteriosa diastolica",
"typePulse": "Pulsazioni a riposo",
"typeBodyFat": "Massa grassa",
"typeSleep": "Durata del sonno",
"typeSteps": "Attività (passi)",
"typeTotalBodyWater": "Acqua corporea totale",
"typeBoneMass": "Massa ossea",
"typeOxygenSaturation": "Saturazione di ossigeno",
"unitSteps": "Passi",
"bmiUnderweight": "Sottopeso",
"bmiNormal": "Peso normale",
@@ -194,8 +205,6 @@
"summaryTrend": "{label}: ultimo {latest} {arrow}",
"summaryAdherence": "Aderenza ai farmaci: {rate}% (media ponderata).",
"chartsTitle": "Grafici di tendenza",
"aiSummaryTitle": "Riepilogo IA — non validato clinicamente",
"aiSummaryDisclaimer": "Questa sezione è stata generata automaticamente da un modello di IA e non è validata clinicamente. Non sostituisce una valutazione medica.",
"packageReadme": "Cartella clinica HealthLog\n\nQuesto pacchetto contiene:\n- report.pdf: il referto sanitario per il medico.\n- bundle.json: gli stessi dati come Bundle documento HL7 FHIR R4, compatibile ePA.\n\nGenerato con HealthLog.",
"wellnessTitle": "Riepilogo del benessere",
"wellnessDisclaimer": "Sono punteggi di benessere descrittivi calcolati dai tuoi segnali registrati (0–100). Non sono valutazioni cliniche né diagnosi.",
@@ -354,6 +363,9 @@
"errorRegistrationDisabled": "Nessun account corrisponde a questa identità e le nuove registrazioni sono attualmente chiuse.",
"errorFailed": "L'accesso Single Sign-On non è riuscito. Riprova."
},
"native": {
"signingInToApp": "Accesso all'app HealthLog"
},
"mfa": {
"title": "Autenticazione a due fattori",
"totpHint": "Inserisci il codice a 6 cifre della tua app di autenticazione.",
@@ -445,6 +457,7 @@
"layoutReset": "Ripristina i valori predefiniti",
"layoutSaveSuccess": "Layout della dashboard salvato",
"layoutSaveError": "Impossibile salvare il layout",
"layoutConflictReloaded": "Layout ricaricato dal server. Applica di nuovo la tua modifica.",
"layoutResetSuccess": "Layout predefinito ripristinato",
"layoutUsingDefaults": "Layout predefinito attivo",
"layoutCustomized": "Layout personalizzato attivo",
@@ -886,7 +899,6 @@
"emptyDescription": "Crea farmaci, dosi e fasce orarie per registrare l’aderenza.",
"loadFailed": "Impossibile caricare i farmaci",
"retryLoad": "Riprova",
"name": "Nome",
"inactive": "In pausa",
"intakeHistory": "Cronologia assunzioni",
"openDetailPage": "Apri la pagina di dettaglio del farmaco",
@@ -1189,8 +1201,6 @@
"dosesPerUnitLabel": "Dosi per unità",
"dosesPerUnitPlaceholder": "es. 4",
"dosesPerUnitHint": "Dosi per penna / fiala — lascia vuoto per non tracciare le scorte.",
"injectionSiteToggle": "Rotazione del sito di iniezione",
"injectionSiteHint": "Guida alla rotazione facoltativa. Il sito di ogni dose viene registrato al momento della registrazione dell'assunzione.",
"short": "Dose",
"unitsPerDoseLabel": "Unità per dose",
"unitsPerDoseHint": "ad es. 2 compresse da 2 mg per una dose da 4 mg, oppure ½ compressa per una dose divisa"
@@ -1241,7 +1251,6 @@
"remindersDescription": "Push, Telegram o ntfy — in base alle tue impostazioni.",
"noChannelHint": "I promemoria richiedono un canale di notifica — al momento nessuno è attivo.",
"noChannelHintLink": "Configura le notifiche",
"multiScheduleNote": "Questo farmaco ha più orari. L’editor dettagliato tornerà in una versione successiva.",
"short": "Fatto"
}
},
@@ -1874,7 +1883,7 @@
"levelShift": "È stato rilevato un cambio di livello intorno al {date}.",
"plateau": {
"title": "Plateau del peso alla dose attuale",
"evidence": "Variazione di peso di {delta} kg negli ultimi {window} giorni ({readings} misurazioni) — {days} giorni con {dose}."
"evidence": "Variazione di peso di {delta} {unit} negli ultimi {window} giorni ({readings} misurazioni) — {days} giorni con {dose}."
},
"retarget": {
"label": "Non è la misurazione giusta?",
@@ -2109,7 +2118,6 @@
"emptyDescription": "Gli insights compaiono appena registri qualche misurazione. Inizia da peso o pressione.",
"emptyAddMeasurement": "Registra misurazione",
"editMode": {
"open": "Personalizza",
"done": "Fine",
"reset": "Reimposta",
"title": "Personalizza panoramica",
@@ -2134,16 +2142,6 @@
"bmiEmptyAction": "Apri impostazioni account",
"noProviderConfigured": "Collega un provider IA per vedere qui una valutazione delle tue misurazioni.",
"noProviderAction": "Apri impostazioni IA",
"startAnalysis": "Avvia valutazione",
"refreshAnalysis": "Aggiorna valutazione",
"keyTakeaway": "Ciò che conta",
"findingsTitle": "Riscontri",
"heroFindingPositive": "Riscontro principale",
"heroFindingNeutral": "Riscontro principale",
"heroFindingAttention": "Da tenere d’occhio",
"heroFindingWarning": "Avviso principale",
"correlationsTitle": "Correlazioni",
"recommendationsTitle": "Raccomandazioni",
"personalRecord": {
"badge": "PR",
"tooltip": "Record personale: {value} {unit} il {date}"
@@ -2151,53 +2149,33 @@
"recommendation": {
"source": "Fonte",
"viewSource": "Apri la linea guida in una nuova scheda",
"rationale": "Perché questo consiglio",
"rationaleWindow": "Finestra",
"rationaleComparedTo": "Confrontato con",
"rationaleDeviation": "Scostamento",
"rationaleExpand": "Mostra motivazione",
"rationaleCollapse": "Nascondi motivazione",
"legacyPayloadCta": "Insights aggiornati — rigenera per attivare le nuove spiegazioni",
"feedbackHelpful": "Utile",
"feedbackNotHelpful": "Poco utile",
"feedbackThanks": "Grazie per il riscontro",
"feedbackConfirmed": "Feedback salvato",
"feedbackAlreadyRated": "Già valutato",
"feedbackError": "Impossibile salvare il tuo riscontro. Riprova.",
"confidence": "Confidenza",
"confidenceAria": "Confidenza: {value} su 100",
"confidenceHigh": "Confidenza alta",
"confidenceMedium": "Confidenza media",
"confidenceLow": "Confidenza bassa — pochi dati",
"confidenceDraft": "Bozza"
},
"dataFoundation": "Base dati",
"dataGaps": "Lacune dati:",
"lastUpdated": "Ultimo aggiornamento",
"confidenceHoch": "Alta",
"confidenceMittel": "Media",
"confidenceGering": "Bassa",
"loadError": "Impossibile caricare le analisi",
"heroPersonalBaseline": "Basato sugli ultimi 90 giorni",
"heroRegenerate": "Rigenera",
"heroRegenerating": "Generazione in corso",
"regenerateAnalysis": "Rigenera la valutazione",
"regenerateSuccess": "Valutazione rigenerata",
"warmStarted": "Le valutazioni vengono preparate in background",
"narrativeTitle": "Riepilogo",
"narrativeWeek": "Questa settimana",
"narrativeMonth": "Questo mese",
"narrativePreparing": "Preparazione del riepilogo…",
"narrativeUpdating": "Aggiornamento…",
"narrativeProvenanceLabel": "Come è stato realizzato",
"narrativeProvenanceMethod": "Un riepilogo del periodo in linguaggio semplice, basato esclusivamente sui valori registrati e sulle associazioni che hanno superato il controllo statistico dei confronti multipli. Descrittivo, mai una causa.",
"narrativeProvenanceMetrics": "Basato su: {metrics}",
"narrativeProvenanceWindow": "Finestra: dal {from} al {to}",
"heroFallbackSubtitle": "Uno sguardo quotidiano ai tuoi trend — direttamente dai numeri che registri.",
"heroGreetingMorning": "Buongiorno",
"heroGreetingAfternoon": "Buon pomeriggio",
"heroGreetingEvening": "Buonasera",
"heroComingSoonTooltip": "In arrivo",
"heroActionAskCoach": "Chiedi al coach",
"healthScore": {
"label": "Punteggio di salute",
@@ -2210,14 +2188,12 @@
"componentMood": "Umore",
"componentCompliance": "Aderenza",
"deltaExplainer": {
"trigger": "Cosa significa questa variazione?",
"title": "Come si calcola la variazione",
"description": "Variazione rispetto alla settimana scorsa: {delta}",
"body": "Il punteggio combina PA, peso, umore e aderenza. Confrontiamo il punteggio di oggi con quello dello stesso momento della settimana scorsa. Registra una nuova misurazione o una nota di umore per spingerlo verso l'alto."
},
"provenance": {
"toggle": "Composto da",
"weightLabel": "peso",
"mixedBanner": "Alcuni componenti combinano più fonti. Il dettaglio mostra il valore più recente per componente.",
"footnote": "I pesi sono ricalcolati in base ai dati effettivamente disponibili.",
"asOfLabel": "al {date}",
@@ -2281,10 +2257,6 @@
"viewDetail": "Vedi i dettagli di {metric}"
},
"vo2Max": {
"statLatest": "Attuale",
"statMin": "Min",
"statMax": "Max",
"empty": "Ancora nessun dato VO₂ max — sincronizza Apple Health per riempire questa vista.",
"cardioLinkTitle": "Forma cardio (VO₂ max)",
"cardioLinkBody": "Il VO₂ max è la misura principale della forma aerobica. Il grafico completo e la valutazione sono nella loro pagina dedicata.",
"cardioLinkCta": "Apri forma cardio"
@@ -2341,6 +2313,7 @@
"send": "Invia",
"stop": "Interrompi",
"thinking": "Sto pensando…",
"unverifiedFiguresNotice": "Alcuni numeri non è stato possibile verificarli con i tuoi dati e sono stati omessi.",
"composerPlaceholder": "Chiedimi pure …",
"nudgeBubbleLabel": "Nuovo messaggio dal coach — apri la chat",
"fabLabel": "Apri il coach",
@@ -2748,125 +2721,68 @@
"gewichtDescription": "Andamento del peso rispetto alla fascia sana derivata dalla tua altezza, con valutazione.",
"pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.",
"stimmungDescription": "Valori giornalieri di umore nel tempo, con una valutazione testuale.",
"stimmungEmptyTitle": "Registra il primo umore",
"stimmungEmptyDescription": "Gli insights dell’umore compaiono appena registri qualche voce. Tocca sotto per iniziare.",
"stimmungEmptyAction": "Apri diario umore",
"medikamenteDescription": "Calendario delle assunzioni per farmaco e una valutazione testuale.",
"medikamenteEmptyTitle": "Aggiungi il primo farmaco",
"medikamenteEmptyDescription": "Gli insights di aderenza richiedono almeno un farmaco registrato.",
"medikamenteEmptyAction": "Apri farmaci",
"bmiDescription": "BMI derivato dal tuo peso e dalla tua altezza, con valutazione.",
"target": {
"adjustLink": "Modifica l'intervallo obiettivo"
},
"explainer": {
"trigger": "Che cos'è {title}?",
"bloodPressureTitle": "Pressione arteriosa",
"bloodPressureBody": "La pressione arteriosa è la forza che il sangue esercita sulle pareti delle arterie, indicata come sistolica su diastolica (il picco durante un battito sul valore a riposo tra i battiti). Valori costantemente alti affaticano il cuore e i vasi nel tempo.",
"pulseTitle": "Polso",
"pulseBody": "Il polso indica quante volte il cuore batte al minuto. Una misurazione singola riflette ciò che stavi facendo in quel momento: a riposo è di solito tra 60 e 100 battiti al minuto e aumenta naturalmente con il movimento, lo stress o la caffeina.",
"weightTitle": "Peso",
"weightBody": "Il peso corporeo è uno dei segnali più semplici dell'equilibrio energetico a lungo termine e della salute generale. L'andamento nelle settimane conta molto più di una singola misurazione, che varia con l'idratazione, il cibo e l'ora del giorno.",
"bmiTitle": "Indice di massa corporea (IMC)",
"bmiBody": "L'IMC è il peso diviso per l'altezza al quadrato, un singolo numero che ti colloca in un'ampia categoria di peso. È uno screening di popolazione, non una diagnosi, e non distingue il muscolo dal grasso.",
"sleepTitle": "Sonno",
"sleepBody": "La durata del sonno è quanto hai effettivamente dormito nella notte, suddivisa in fasi leggera, profonda e REM. La maggior parte degli adulti necessita di circa sette-nove ore; quantità e qualità costanti favoriscono recupero, umore e metabolismo.",
"moodTitle": "Umore",
"moodBody": "Il monitoraggio dell'umore registra come ti senti nel tempo su una scala semplice. Annotarlo con regolarità rivela schemi — come sonno, attività o farmaci si allineano a come ti senti — che un singolo giorno non mostra mai.",
"medicationsTitle": "Aderenza alla terapia",
"medicationsBody": "L'aderenza misura quanto regolarmente assumi i farmaci come prescritto, qui come quota delle dosi previste che hai registrato. Un'assunzione costante è spesso ciò che fa funzionare una terapia come previsto.",
"restingHrTitle": "Frequenza cardiaca a riposo",
"restingHrBody": "La frequenza cardiaca a riposo è il battito quando sei completamente a riposo, di solito misurato durante la notte. Una frequenza più bassa indica in genere una migliore forma cardiovascolare, mentre un andamento in aumento può essere un segnale precoce di stress o malattia.",
"hrvTitle": "Variabilità della frequenza cardiaca (HRV)",
"hrvBody": "L'HRV è la piccola variazione del tempo tra battiti consecutivi. Una variabilità più alta riflette di solito un sistema nervoso ben recuperato e adattabile, mentre un calo prolungato può segnalare affaticamento, stress o recupero insufficiente.",
"oxygenSaturationTitle": "Saturazione di ossigeno (SpO₂)",
"oxygenSaturationBody": "La SpO₂ è la percentuale di globuli rossi che trasportano ossigeno. Valori tra 95 e 100% sono normali a riposo; valori che restano sotto il 90% meritano attenzione, anche se brevi cali notturni sono comuni.",
"bodyTemperatureTitle": "Temperatura corporea",
"bodyTemperatureBody": "La temperatura corporea riflette il tuo stato termico centrale, normalmente intorno ai 37 °C con piccole oscillazioni giornaliere. Un aumento prolungato può indicare un'infezione, mentre le misurazioni notturne al polso seguono soprattutto variazioni relative, non un valore clinico.",
"activeEnergyTitle": "Energia attiva",
"activeEnergyBody": "L'energia attiva sono le calorie che bruci con il movimento, oltre al consumo di base a riposo. È una misura diretta di quanto è stata fisicamente attiva la tua giornata.",
"workoutsTitle": "Allenamenti",
"workoutsBody": "Gli allenamenti sono le sessioni di esercizio strutturato che i tuoi dispositivi registrano, con tipo, durata ed energia. Tracciarli mostra come l'allenamento regolare si accumula nel corso delle settimane e come si collega agli altri segnali di salute.",
"respiratoryRateTitle": "Frequenza respiratoria",
"respiratoryRateBody": "La frequenza respiratoria è il numero di respiri che fai al minuto. A riposo la maggior parte degli adulti respira circa dodici-venti volte al minuto; un aumento prolungato può accompagnare malattia, sforzo o stress.",
"bodyWaterTitle": "Acqua corporea totale",
"bodyWaterBody": "L'acqua corporea totale è la quota del tuo peso costituita da liquido, dentro e intorno alle cellule. Varia con l'idratazione, l'alimentazione e l'attività, quindi l'andamento nelle settimane dice più di una singola misura.",
"boneMassTitle": "Massa ossea",
"boneMassBody": "La massa ossea è il peso stimato dei minerali del tuo scheletro. Le stime da bilancia sono approssimative, ma un andamento stabile o in aumento sostiene la robustezza ossea a lungo termine.",
"fatFreeMassTitle": "Massa magra",
"fatFreeMassBody": "La massa magra è tutto ciò che nel corpo non è grasso: muscoli, ossa, organi e acqua. Mantenerla o aumentarla mentre si perde peso indica di solito una composizione corporea più sana.",
"fatMassTitle": "Massa grassa",
"fatMassBody": "La massa grassa è il peso totale del grasso nel corpo. Un po' di grasso è essenziale per gli ormoni e le riserve di energia, ma un eccesso — soprattutto sull'addome — aumenta il rischio per la salute a lungo termine (WHO 2024).",
"muscleMassTitle": "Massa muscolare",
"muscleMassBody": "La massa muscolare è il peso stimato dei muscoli del corpo. Più muscolo sostiene forza, metabolismo e mobilità, e di solito richiede impegno attivo per essere mantenuto con l'età.",
"visceralFatTitle": "Grasso viscerale",
"visceralFatBody": "Il grasso viscerale è il grasso depositato in profondità nell'addome, intorno agli organi. A differenza del grasso sottocutaneo, un livello elevato è strettamente legato a malattie cardiache e diabete di tipo 2 (AHA, Circulation 2021), quindi più basso è in genere meglio. La scala mostra un valore senza unità di misura (circa 1–12 è l'intervallo salutare, 13 e oltre è elevato); si tratta di una scala distinta dalle soglie in cm² ottenute per immagini che i clinici utilizzano, e non è intercambiabile con esse.",
"leanBodyMassTitle": "Massa magra corporea",
"leanBodyMassBody": "La massa magra corporea è il peso totale meno il grasso: muscoli, ossa, organi e acqua insieme. Seguirla accanto al peso mostra se i cambiamenti derivano dal muscolo o dal grasso.",
"flightsClimbedTitle": "Piani saliti",
"flightsClimbedBody": "I piani saliti contano i livelli che sali ogni giorno, dove un piano corrisponde a circa tre metri di dislivello. È un indicatore semplice dell'attività quotidiana e dello sforzo degli arti inferiori.",
"walkingDistanceTitle": "Distanza a piedi e di corsa",
"walkingDistanceBody": "La distanza a piedi e di corsa è il percorso che copri a piedi ogni giorno. Integra il conteggio dei passi mostrando quanto ti sei mosso, non solo quanto spesso hai mosso il passo.",
"walkingSteadinessTitle": "Stabilità del cammino",
"walkingSteadinessBody": "La stabilità del cammino stima quanto è stabile ed equilibrata la tua andatura, classificata da buona a bassa. Un livello basso indica una maggiore probabilità di caduta ed è opportuno parlarne con un medico.",
"walkingHeartRateTitle": "Frequenza cardiaca in camminata",
"walkingHeartRateBody": "La frequenza cardiaca in camminata è il battito medio durante una camminata costante. Un valore più basso nel tempo riflette spesso una migliore forma cardiovascolare a parità di sforzo.",
"walkingAsymmetryTitle": "Asimmetria del cammino",
"walkingAsymmetryBody": "L'asimmetria del cammino è la quota di tempo in cui i tuoi passi sono diseguali tra gamba sinistra e destra. Una percentuale più alta può indicare dolore, un infortunio o un problema di andatura da approfondire.",
"doubleSupportTimeTitle": "Tempo di doppio appoggio",
"doubleSupportTimeBody": "Il tempo di doppio appoggio è la quota di ogni ciclo del passo in cui entrambi i piedi toccano terra contemporaneamente. Una percentuale più alta indica di solito un'andatura più prudente e meno stabile.",
"stepLengthTitle": "Lunghezza del passo",
"stepLengthBody": "La lunghezza del passo è la distanza media da un appoggio del piede al successivo. Un passo più corto nel tempo può riflettere stanchezza, prudenza o un cambiamento nella mobilità.",
"walkingSpeedTitle": "Velocità del cammino",
"walkingSpeedBody": "La velocità del cammino è quanto rapidamente ti muovi in piano. È un marcatore ben studiato della salute generale e della mobilità, e un ritmo stabile o in aumento è un buon segno.",
"pulseWaveVelocityTitle": "Velocità dell'onda di polso",
"pulseWaveVelocityBody": "La velocità dell'onda di polso misura la rapidità con cui l'onda di pressione di ogni battito percorre le arterie. Un'onda più veloce indica arterie più rigide, associate a un rischio cardiovascolare più alto.",
"vascularAgeTitle": "Età vascolare",
"vascularAgeBody": "L'età vascolare stima quanto si comportano vecchie le tue arterie rispetto alla tua età reale, in base alla rigidità arteriosa. Un'età vascolare inferiore a quella reale suggerisce vasi sanguigni più sani.",
"environmentalAudioTitle": "Livelli sonori ambientali",
"environmentalAudioBody": "I livelli sonori ambientali rilevano il volume dell'ambiente intorno a te in decibel. Un'esposizione prolungata sopra i 70 dB circa può danneggiare l'udito nel tempo, quindi medie giornaliere più basse sono più sicure.",
"headphoneAudioTitle": "Livelli audio in cuffia",
"headphoneAudioBody": "I livelli audio in cuffia rilevano in decibel quanto forte ascolti con auricolari o cuffie. Tenere basso il volume e fare pause protegge l'udito nel lungo periodo.",
"audioEventsTitle": "Eventi sonori intensi",
"audioEventsBody": "Gli eventi sonori intensi segnalano i momenti in cui il rumore intorno a te ha raggiunto un livello che può danneggiare l'udito con esposizione sufficiente. Eventi frequenti sono un invito a cercare un ambiente più silenzioso o protezioni acustiche.",
"daylightTitle": "Tempo alla luce del giorno",
"daylightBody": "Il tempo alla luce del giorno è quanto trascorri sotto la luce esterna ogni giorno. La luce diurna regolare sostiene un sano ritmo sonno-veglia e l'umore (CDC 2024), e il tempo all'aperto è collegato alla salute degli occhi.",
"bloodGlucoseTitle": "Glicemia",
"bloodGlucoseBody": "La glicemia è la quantità di zucchero nel sangue, la principale fonte di energia del corpo. Sale dopo i pasti e si stabilizza tra un pasto e l'altro; valori che restano alti nel tempo possono indicare diabete o prediabete.",
"skinTemperatureTitle": "Temperatura cutanea",
"skinTemperatureBody": "La temperatura cutanea qui è la piccola variazione notturna della temperatura del polso rispetto al tuo valore di riferimento, non una misura assoluta della febbre. Le variazioni possono seguire sonno, ambiente, malattia o fase del ciclo mestruale.",
"cardioFitnessTitle": "Forma cardio (VO₂ max)",
"cardioFitnessBody": "Il VO₂ max indica quanto ossigeno il tuo corpo riesce a utilizzare al massimo sforzo: il miglior indice di laboratorio della forma aerobica, che i wearable stimano in modo indiretto. Come orientamento generale, la forma fisica è un predittore graduale della sopravvivenza: ogni MET in più (circa 3,5 ml/kg/min) si associa a una mortalità inferiore di circa il 10–25 % (AHA, Circulation 2016). Una tendenza più alta o in aumento è un forte segno di salute cardiovascolare.",
"cardioRecoveryTitle": "Recupero cardiaco",
"cardioRecoveryBody": "Il recupero cardiaco è di quanti battiti al minuto cala la frequenza cardiaca nel primo minuto dopo aver interrotto uno sforzo intenso. Un calo maggiore significa che il cuore si calma più in fretta, segno di buona forma fisica.",
"wristTemperatureTitle": "Temperatura del polso",
"wristTemperatureBody": "La temperatura del polso viene misurata mentre dormi ed è indicata come differenza dal tuo valore di riferimento, non come valore assoluto. Un valore stabile è normale, mentre un aumento prolungato può indicare malattia, allenamento intenso o un cambiamento del ciclo.",
"fallsTitle": "Cadute",
"fallsBody": "Qui vengono contate le cadute forti rilevate dal tuo wearable. L'obiettivo è zero; un numero in aumento è un segnale di rischio di mobilità da segnalare a un medico, soprattutto per le persone anziane (CDC STEADI 2024).",
"sixMinuteWalkTitle": "Distanza nel cammino di 6 minuti",
"sixMinuteWalkBody": "È una stima di quanto potresti camminare in sei minuti, un classico test clinico di mobilità e resistenza cardiopolmonare. Il valore del wearable è un'approssimazione, non il test clinico supervisionato; ciò che conta è la tendenza stabile o in aumento.",
"stairAscentSpeedTitle": "Velocità in salita scale",
"stairAscentSpeedBody": "È la velocità con cui sali una rampa di scale. È un marcatore sensibile di forza delle gambe e mobilità: un ritmo stabile o in aumento è un buon segno, mentre uno in calo va tenuto d'occhio.",
"stairDescentSpeedTitle": "Velocità in discesa scale",
"stairDescentSpeedBody": "È la velocità con cui scendi una rampa di scale. Insieme alla velocità in salita riflette il controllo delle gambe e l'equilibrio: un ritmo stabile è un buon segno, mentre uno in calo va tenuto d'occhio.",
"breathingDisturbancesTitle": "Disturbi respiratori nel sonno",
"breathingDisturbancesBody": "Un wearable può stimare quanto spesso la respirazione si è interrotta durante il sonno. Un indice più basso è migliore; un livello segnalato come elevato, soprattutto su molte notti, andrebbe segnalato a un medico. È un'approssimazione di screening, non una diagnosi di apnea del sonno.",
"stepsTitle": "Passi",
"stepsBody": "Il conteggio dei passi è il numero totale di passi che fai ogni giorno. È la misura più semplice del movimento quotidiano: un conteggio giornaliero più alto riflette di solito uno stile di vita più attivo, e un'abitudine costante conta più di un singolo giorno intenso.",
"readinessBody": "La prontezza è una lettura giornaliera di quanto il tuo corpo sia pronto ad affrontare la giornata, combinando frequenza cardiaca a riposo, variabilità della frequenza cardiaca, sonno e regolarità delle ultime notti. Un punteggio più alto indica che il tuo organismo appare recuperato e pieno di risorse; uno più basso è un invito gentile a rallentare.",
"sleepScoreBody": "Il tuo punteggio del sonno riassume un'intera notte in un solo numero — durata, efficienza, regolarità degli orari ed equilibrio tra sonno leggero, profondo e REM. Osserva l'andamento nell'arco delle settimane invece di rincorrere un punteggio perfetto in una singola notte.",
"recoveryBody": "Il recupero riflette quanto bene il tuo corpo si è ripreso, basandosi su segnali notturni come la variabilità della frequenza cardiaca e la frequenza a riposo. Un punteggio alto è il via libera per spingere; uno più basso suggerisce che il tuo organismo si sta ancora ricostruendo.",
"strainBody": "Lo sforzo misura quanto carico cardiovascolare e fisico hai imposto al tuo corpo, dagli allenamenti a una giornata attiva in piedi. Quando c'è abbastanza storico, viene valutato rispetto al tuo sforzo abituale, non rispetto a uno standard esterno.",
"stressBody": "Lo stress qui è una stima del carico fisiologico sul tuo sistema nervoso, letto soprattutto dagli schemi di variabilità della frequenza cardiaca e non da come ti senti consapevolmente. Un punteggio più basso indica uno stato calmo e ben regolato; uno più alto è un invito a inserire del recupero.",
"recoveryPageTitle": "Recupero",
"recoveryPageBody": "Il recupero riunisce i segnali notturni registrati dal tuo wearable — ricarica, sforzo, carico e i totali di frequenza cardiaca ed energia della giornata — per mostrare quanto bene il tuo corpo si è ripreso. Guarda l'andamento su più giorni piuttosto che un singolo numero; un valore più basso è un invito a rallentare, non un allarme.",
"pulsePressureTitle": "Pressione differenziale",
"pulsePressureBody": "La pressione differenziale è la differenza tra il valore sistolico e quello diastolico (sistolica meno diastolica). Come orientamento generale, circa 40 mmHg è tipico a riposo; un divario ampio sopra i 60 mmHg circa nella mezza età può riflettere arterie più rigide, mentre un divario stretto sotto i 25 mmHg circa può accompagnare una bassa gittata sistolica (StatPearls/NIH 2023). È uno spunto di discussione, non una diagnosi.",
"meanArterialPressureTitle": "Pressione arteriosa media",
"meanArterialPressureBody": "La pressione arteriosa media è la pressione media che i tuoi organi vedono in un battito, all'incirca la diastolica più un terzo del divario con la sistolica. Come orientamento generale, un valore a riposo si aggira sui 70–100 mmHg (StatPearls/NIH 2023). La soglia di ~60 mmHg spesso citata è un concetto di terapia intensiva, non un allarme per l'automisurazione.",
"cycleEducationBody": "Il tuo ciclo attraversa quattro fasi guidate dagli ormoni, e la sua durata e regolarità sono una finestra accessibile sulla salute generale. Come orientamento generale, nei adulti i cicli durano tipicamente 21–35 giorni (i riferimenti clinici citano 24–38), con un sanguinamento di circa cinque giorni (ACOG 2015). La fase luteale è l'ancora stabile, quindi l'ovulazione si stima meglio contando a ritroso dalla prossima mestruazione attesa, mai con un «giorno 14» fisso. Una finestra fertile prevista è una stima che richiede più cicli registrati e resta approssimativa, e la temperatura basale conferma l'ovulazione a posteriori anziché prevederla. Non è un metodo contraccettivo.",
"sleepHygieneBody": "Alcune abitudini basate sull'evidenza favoriscono il sonno: orari costanti per coricarsi e svegliarsi anche nel weekend, luce intensa al mattino e serate soffuse, una stanza fresca, buia e silenziosa, ed evitare la caffeina nel pomeriggio, l'alcol a tarda ora e i pasti abbondanti a fine giornata (CDC 2024). La regolarità predice la salute almeno quanto la durata, e il tempo di sonno totale di un wearable è molto più affidabile della sua suddivisione tra sonno profondo, leggero e REM.",
"referenceGuidance": "Orientamento generale, non un consiglio medico. Gli intervalli di riferimento sono valori di popolazione che cambiano con la tua età, il sesso e le condizioni di salute; il tuo andamento nel tempo guida la lettura."
"sleepHygieneBody": "Alcune abitudini basate sull'evidenza favoriscono il sonno: orari costanti per coricarsi e svegliarsi anche nel weekend, luce intensa al mattino e serate soffuse, una stanza fresca, buia e silenziosa, ed evitare la caffeina nel pomeriggio, l'alcol a tarda ora e i pasti abbondanti a fine giornata (CDC 2024). La regolarità predice la salute almeno quanto la durata, e il tempo di sonno totale di un wearable è molto più affidabile della sua suddivisione tra sonno profondo, leggero e REM."
},
"bpDerived": {
"pulsePressure": "La pressione differenziale (il divario tra i tuoi valori) è di circa {value} mmHg; circa 40 è tipico a riposo.",
@@ -2900,16 +2816,6 @@
"compositionTitle": "Distribuzione delle fasi",
"compositionSubtitle": "Ultime {nights} notti",
"compositionAriaLabel": "Distribuzione delle fasi del sonno su {nights} notti",
"hypnogram": {
"title": "Ultima notte",
"subtitle": "{asleep} di sonno",
"ariaLabel": "Andamento delle fasi del sonno dell'ultima notte: {asleep} di sonno, {awakenings} risvegli",
"asleepTotal": "Sonno {value}",
"inBedTotal": "A letto {value}",
"awakenings": "{count} risvegli",
"source": "Fonte: {source}",
"estimateNote": "I tempi delle fasi sono stimati: questa sorgente riporta i totali per fase, non una cronologia misurata."
},
"sourceDiscrepancy": {
"tooltipTitle": "Le fonti divergono"
},
@@ -2964,7 +2870,6 @@
"loadError": "Non è stato possibile caricare il tuo ritmo del sonno in questo momento. Riapparirà non appena la connessione sarà stabile."
}
},
"correlations": "Correlazioni",
"medicationCompliance": "Aderenza ai farmaci",
"dayStreak": "{count} giorni di serie",
"dayStreakOne": "{count} giorno di serie",
@@ -2973,19 +2878,12 @@
"taken": "Assunta",
"skipped": "Saltata",
"missed": "Persa",
"generalStatusBadge": {
"good": "Bene",
"watch": "Osservare",
"critical": "Critico"
},
"assessmentTitle": "Valutazione",
"restMode": {
"title": "Modalità riposo",
"body": "È in corso un episodio di malattia o disturbo, quindi questo riflette una fase di recupero. Il tuo punteggio non viene abbassato per questo.",
"bodySince": "È in corso un episodio di malattia o disturbo dal {since}, quindi questo riflette una fase di recupero. Il tuo punteggio non viene abbassato per questo."
},
"assessmentShowMore": "Mostra altro",
"assessmentShowLess": "Mostra meno",
"bloodPressureSectionTitle": "Pressione arteriosa",
"weightSectionTitle": "Peso",
"pulseSectionTitle": "Polso",
@@ -2993,8 +2891,6 @@
"complianceNoData": "Nessun dato di aderenza disponibile",
"generate": "Genera nuova analisi",
"moodSectionTitle": "Umore",
"confidence": "Confidenza",
"cached": "In cache",
"emptyState": {
"bloodPressure": {
"title": "Nessuna misurazione della pressione",
@@ -3043,11 +2939,6 @@
"title": "Nessun allenamento",
"description": "Apri l'app Salute di Apple sull'iPhone per sincronizzare gli allenamenti. L'import di attività di Withings porta dentro anche camminate e uscite in bici."
},
"row": {
"duration": "Durata",
"distance": "Distanza",
"energy": "Energia attiva"
},
"detail": {
"backToList": "Torna agli allenamenti",
"statsTitle": "Statistiche",
@@ -3060,13 +2951,9 @@
"statsStepCount": "Passi",
"statsElevation": "Dislivello",
"statsPace": "Passo",
"statsSource": "Fonte",
"routeTitle": "Percorso",
"routeUnavailable": "Nessun tracciato GPS registrato per questo allenamento.",
"hrChartTitle": "Frequenza cardiaca",
"hrChartUnavailable": "I campioni di frequenza cardiaca al secondo non vengono salvati per questo allenamento.",
"notFound": "Allenamento non trovato.",
"loading": "Caricamento allenamento…",
"hrFromPulse": "Dai tuoi dati di frequenza cardiaca intorno a questa sessione",
"hrAxisMinutes": "min",
"sportAverageLabel": "La tua media recente:",
@@ -3538,7 +3425,6 @@
"title": "Cosa risalta",
"subheading": "Relazioni scoperte",
"footer": "Estratte da {tested} coppie giorno per giorno testate su tutte le tue misure, dopo la correzione per le false scoperte.",
"empty": "Nessuna relazione sull'umore ha ancora superato la soglia statistica. Continua a registrare.",
"disclaimer": "Filtrato statisticamente, ma sempre osservazionale: uno schema da tenere d'occhio, non una causa.",
"moodUp": "Un {factor} più alto tende a coincidere con un umore migliore il giorno dopo nei tuoi dati.",
"moodDown": "Un {factor} più alto tende a coincidere con un umore peggiore il giorno dopo nei tuoi dati.",
@@ -3917,8 +3803,6 @@
},
"briefing": {
"memory": {
"recall": "In precedenza, {text}",
"forward": "Guardando avanti: {text}",
"forwardWatch": "Vale la pena tenere d'occhio {metric}.",
"forwardHolding": "Per ora è tutto stabile."
}
@@ -3932,9 +3816,7 @@
}
},
"streak": {
"returnLabel": "Il tuo {metric} è di nuovo nel tuo intervallo abituale dopo {count} giorni — qualunque cosa fosse, è passata.",
"daysInside": "{count} giorni dentro",
"daysOutside": "{count} giorni fuori"
"returnLabel": "Il tuo {metric} è di nuovo nel tuo intervallo abituale dopo {count} giorni — qualunque cosa fosse, è passata."
},
"updatedTodayAt": "Aggiornato oggi, {time}",
"updatedYesterday": "Aggiornato ieri",
@@ -4235,6 +4117,7 @@
"displayNamePlaceholder": "Come deve chiamarti HealthLog?",
"displayNameHint": "Compare nel saluto della dashboard e nei report.",
"heightLabel": "Altezza (cm)",
"heightLabelFtIn": "Altezza (ft, in)",
"genderLabel": "Sesso",
"genderNone": "Preferisco non specificare",
"genderMale": "Maschile",
@@ -4562,10 +4445,6 @@
"passphraseHelp": "Deriva la chiave dell'archivio con Argon2id e lo sigilla con AES-256-GCM. Salva il file con estensione .hlx.",
"noRecoveryWarning": "Non c'è recupero: se perdi questa passphrase, l'archivio non potrà essere aperto. Conservala in un luogo sicuro.",
"stepUpNeeded": "Conferma il secondo fattore e riprova."
},
"cycle": {
"title": "Ciclo",
"description": "Dati del ciclo e di salute riproduttiva come documento FHIR: ultima mestruazione, durata media di ciclo e mestruazioni, fase attuale."
}
},
"import": {
@@ -4587,6 +4466,7 @@
"failed": "L'importazione non è riuscita.",
"rowsImported": "{count} righe importate finora",
"doneSummary": "Fatto: {imported} di {read} record importati, {skipped} record clinici saltati.",
"doneNothing": "Da questo archivio non è stato importato nulla: sono arrivati 0 record su {read}.",
"estimateWarning": "I totali cumulativi di {count} giorno/i sono stati stimati dalla fonte di esportazione più alta. Le statistiche native di Apple Salute restano autorevoli.",
"phase": {
"queued": "In coda…",
@@ -4610,7 +4490,10 @@
"rateLimited": "Troppe importazioni in questa ora (limite 5). Riprova più tardi.",
"failed": "L'importazione non è riuscita. Riprova.",
"readFailed": "Impossibile leggere il file.",
"resultSummary": "{measurements} misurazioni e {moods} voci dell'umore importate, {skipped} saltate."
"resultSummary": "{measurements} misurazioni e {moods} voci dell'umore importate, {skipped} saltate.",
"resultSuccess": "{measurements} misurazioni e {moods} voci dell'umore importate.",
"resultNothing": "Non è stato importato nulla. Tutte le {skipped} voci sono state saltate.",
"resultEmpty": "Questo file non conteneva né misurazioni né voci dell'umore."
},
"csv": {
"title": "File CSV",
@@ -4631,7 +4514,30 @@
"readFailed": "Impossibile leggere questo file.",
"previewSummary": "Anteprima: {inserted} verrebbero importate, {skipped} saltate.",
"resultSummary": "{inserted} nuove importate e {updated} aggiornate, {skipped} saltate.",
"rowError": "Riga {line}: {reason}"
"rowError": "Riga {line}: {reason}",
"resultSuccess": "{inserted} nuove importate e {updated} aggiornate.",
"resultNothing": "Non è stato importato nulla. Tutte le {skipped} righe sono state saltate.",
"resultEmpty": "Questo file ha un'intestazione ma nessuna riga di dati.",
"previewSuccess": "Anteprima: {inserted} verrebbero importate.",
"previewNothing": "Anteprima: non verrebbe importato nulla. Tutte le {skipped} righe verrebbero saltate.",
"skipGroupOne": "{count} riga saltata: {reason}",
"skipGroupFew": "{count} righe saltate: {reason}",
"skipGroupOther": "{count} righe saltate: {reason}",
"skipDetails": "Mostra tutte le righe saltate",
"reasonUnknownType": "tipo di misurazione sconosciuto",
"reasonInvalidValue": "il valore non è un numero",
"reasonValueOutOfRange": "valore fuori dall'intervallo plausibile",
"reasonUnknownUnit": "unità non riconosciuta per questo tipo",
"reasonMissingTimezoneOffset": "marca temporale senza offset del fuso orario",
"reasonInvalidTimestamp": "marca temporale illeggibile",
"reasonImplausibleTimestamp": "marca temporale nel futuro o precedente al 1900",
"reasonUnexpectedGlucoseContext": "contesto glicemico su una riga che non è glicemia",
"reasonInvalidGlucoseContext": "il contesto glicemico non è un valore consentito",
"reasonNotesTooLong": "la nota è troppo lunga",
"reasonExternalIdTooLong": "l'ID esterno è troppo lungo",
"reasonUnstableExternalId": "l'ID esterno non è stabile tra un export e l'altro",
"reasonDuplicate": "già importata",
"reasonUnknown": "saltata"
}
},
"subtitle": "Esporta i tuoi dati o importa da un backup."
@@ -4782,6 +4688,7 @@
},
"username": "Nome utente",
"height": "Altezza (cm)",
"heightFtIn": "Altezza (ft, in)",
"dateOfBirth": "Data di nascita",
"dateOfBirthHint": "Usato per il calcolo automatico degli obiettivi di pressione arteriosa.",
"gender": "Sesso",
@@ -4815,17 +4722,10 @@
"passwordMismatch": "Le nuove password non coincidono",
"passwordUpdated": "Password aggiornata correttamente",
"passkeys": "Passkeys",
"registeredPasskeys": "Passkey registrate",
"passkeysDescription": "Le passkey consentono un accesso sicuro senza password.",
"passkeyName": "Nome",
"passkeyDevice": "Dispositivo",
"passkeyBackup": "Backup",
"passkeyCreated": "Creata",
"passkeyActions": "Azioni",
"noPasskeys": "Nessuna passkey registrata.",
"addPasskey": "Aggiungi passkey",
"passkeyAdded": "Passkey aggiunta correttamente!",
"passkeyOptionsError": "Impossibile caricare le opzioni della passkey",
"passkeyRegistrationFailed": "Registrazione della passkey non riuscita",
"passkeyRegistrationCancelled": "Registrazione della passkey annullata",
"passkeyNotSupported": "Il tuo browser o dispositivo non supporta ancora le passkey. Prova un altro browser o dispositivo.",
@@ -4874,7 +4774,6 @@
"webPushSubscribed": "Notifiche push attivate!",
"webPushUnsubscribed": "Notifiche push disattivate.",
"webPushSubscribeFailed": "Attivazione non riuscita",
"webPushActive": "Attivo",
"kiInsightsDescription": "Facoltativo: salva la tua chiave OpenAI per analisi giornaliere e automatiche in Insights.",
"codexConnected": "ChatGPT connesso correttamente! Insights è ora attivo.",
"codexDisconnected": "Connessione a ChatGPT rimossa.",
@@ -4912,7 +4811,6 @@
"savedShort": "(salvata)",
"saveCta": "Salva",
"saved": "Salvato",
"saveFailed": "Salvataggio non riuscito",
"errorGeneric": "Errore",
"providerChain": {
"types": {
@@ -5048,11 +4946,9 @@
"hint": "Inviato al Coach e al briefing giornaliero come contesto aggiuntivo. Lascia vuoto per non inviare nulla.",
"profileHint": "Età e genere provengono dal tuo profilo e vengono inclusi automaticamente.",
"freeTextLabel": "Su di me (testo libero)",
"conditionsLabel": "Malattie croniche",
"conditionsPlaceholder": "Es. ipertensione, diabete di tipo 2…",
"allergiesLabel": "Allergie e intolleranze",
"allergiesPlaceholder": "Es. intolleranza al lattosio, allergia alla penicillina…",
"focusLabel": "A cosa deve fare attenzione il Coach?",
"focusPlaceholder": "Es. tieni d'occhio la mia pressione, motivami a muovermi di più…",
"completeness": "{answered} su {total} compilati",
"questionsTitle": "Il Coach ha alcune domande",
@@ -5130,8 +5026,6 @@
"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."
},
"integrationsSourcesHint": "Se più fonti forniscono la stessa metrica, la priorità delle fonti decide quale valore conta.",
"integrationsSourcesHintLink": "Apri la priorità delle fonti",
"withingsSynchronize": "Sincronizza",
"withingsDisconnect": "Disconnetti",
"withingsDisconnectTitle": "Disconnettere Withings?",
@@ -5261,7 +5155,7 @@
"integrationPill": {
"connected": "Connesso",
"errorReconnect": "Errore — riconnetti",
"warningServerError": "Connesso, errore del server",
"warningServerError": "Connesso, problema di sincronizzazione",
"parkedReconnect": "In pausa — riconnettere manualmente",
"notConnected": "Non connesso",
"justNow": "proprio ora",
@@ -5271,8 +5165,26 @@
"ariaLabel": "Stato dell’integrazione",
"resumeCta": "Riconnetti",
"resumeSuccess": "Integrazione ripresa",
"resumeError": "Riconnessione fallita"
"resumeError": "Riconnessione fallita",
"stale": "Connesso, nessun dato recente",
"stalled": "Sincronizzazione ferma",
"pendingSetup": "In attesa dei primi dati"
},
"integrationFreshness": {
"title": "Dati ricevuti",
"summary": "{count} tipi di dati · più recente {relative}",
"quiet": "{count} in silenzio",
"workouts": "Allenamenti"
},
"integrationFallback": {
"description": "Configurato su questo account ma senza una scheda di impostazioni propria. Questa riga esiste perché una connessione non possa smettere di funzionare senza che nessuno se ne accorga.",
"lastAttempt": "Ultimo tentativo {relative}",
"noAttempt": "Nessun tentativo di sincronizzazione registrato"
},
"withingsViewData": "Vedi le tue misurazioni",
"whoopViewData": "Vedi il tuo recupero",
"fitbitViewData": "Vedi il tuo sonno",
"googleHealthViewData": "Vedi i tuoi passi",
"apiTokens": "Token API",
"apiTokensDescription": "Rivedi e revoca i token API per la registrazione esterna delle assunzioni di farmaci. I token vengono emessi per singolo farmaco dal relativo interruttore dell’endpoint API.",
"tokenMintMovedDescription": "I token vengono emessi per singolo farmaco. Apri un farmaco e attiva il suo endpoint API per ottenere un token valido solo per quel farmaco.",
@@ -5304,10 +5216,7 @@
"apiEndpointPath": "Percorso",
"apiEndpointAuth": "Autenticazione",
"apiEndpointExample": "Esempio di body",
"collapse": "Comprimi",
"expand": "Espandi",
"dangerZone": "Elimina tutti i dati",
"dangerZoneTitle": "Zona pericolosa",
"dangerZoneDescription": "Elimina tutti i tuoi dati sanitari e le integrazioni. Il tuo account utente verrà conservato.",
"dangerZoneConfirm": "Eliminare tutti i dati?",
"dangerZoneConfirmDescription": "Questa azione eliminerà definitivamente tutti i tuoi dati sanitari e le integrazioni. Il tuo account utente verrà conservato.",
@@ -5324,31 +5233,9 @@
"deleteAccountFinal": "Elimina definitivamente",
"saved": "Salvato",
"savingError": "Errore durante il salvataggio",
"moodLogTitle": "moodLog",
"moodLogDescription": "Import mood data from moodLog",
"moodLogDeprecated": "Obsoleto: l’umore ora è registrato nativamente in HealthLog con tag e fattori. Questa integrazione funziona ancora ma verrà rimossa in una versione futura.",
"moodLogUrl": "moodLog URL",
"moodLogUrlPlaceholder": "https://mood.example.com",
"moodLogApiKey": "Chiave API",
"moodLogApiKeyPlaceholder": "ml_...",
"moodLogWebhookSecret": "Secret del webhook",
"moodLogWebhookSecretHelp": "Inserisci questo secret in moodLog come secret del webhook",
"moodLogSync": "Sincronizza ora",
"moodLogFullSync": "Sincronizza tutti i dati",
"moodLogFullSyncConfirm": "Sincronizza",
"moodLogFullSyncTitle": "Sincronizzazione completa?",
"moodLogFullSyncDescription": "Tutti i dati sull’umore disponibili verranno sincronizzati completamente.",
"moodLogDisconnect": "Disconnetti",
"moodLogDisconnectTitle": "Disconnettere moodLog?",
"moodLogDisconnectDescription": "La connessione verrà rimossa e tutti i dati sull’umore importati verranno eliminati.",
"moodLogEntries": "Voci",
"moodLogSyncResult": "{count} voci sincronizzate",
"moodLogSyncFailed": "Sincronizzazione non riuscita",
"moodLogDisconnected": "moodLog disconnesso",
"about": {
"version": "Versione dell’app",
"gitSha": "Build",
"builtAt": "Compilato {time}",
"builtAtLabel": "Compilato",
"license": "Licenza",
"repository": "Codice sorgente",
@@ -5420,34 +5307,17 @@
"practiceName": "Studio (facoltativo)",
"practiceNamePlaceholder": "es. Dr. Rossi",
"includedData": "Dati inclusi",
"groupVitals": "Parametri vitali",
"groupCardio": "Cuore & fitness",
"groupActivity": "Attività & sonno",
"weight": "Peso",
"bp": "Pressione sanguigna",
"pulse": "Polso",
"oxygenSaturation": "Saturazione di ossigeno",
"bodyFat": "Massa grassa",
"bodyComposition": "Composizione corporea",
"restingHeartRate": "Frequenza a riposo",
"hrv": "Variabilità della frequenza cardiaca",
"vo2max": "VO₂max",
"steps": "Passi",
"distance": "Distanza",
"sleep": "Sonno",
"glucose": "Glicemia",
"medications": "Farmaci e aderenza",
"mood": "Umore (disattivato di default — dati sensibili)",
"bmi": "BMI",
"includeCharts": "Incorpora grafici di tendenza (solo PDF)",
"includeAiSummary": "Aggiungi riepilogo IA (non validato clinicamente)",
"fhirNote": "L’output strutturato segue lo standard FHIR R4 (compatibile ePA).",
"generate": "Genera",
"error": "Esportazione non riuscita ({code}).",
"labs": "Risultati degli esami",
"allergies": "Allergie e intolleranze",
"familyHistory": "Anamnesi familiare",
"documentsLink": "Documenti del periodo"
"documentsLink": "Documenti del periodo",
"range30": "Ultimi 30 giorni",
"rangeCustom": "Periodo personalizzato",
"rangeCustomStart": "Dal",
"rangeCustomEnd": "Al",
"errorGeneric": "Non è stato possibile generare l'esportazione. Riprova.",
"errorRateLimit": "Hai raggiunto il limite di esportazioni per questa ora.",
"errorModuleDisabled": "L'esportazione della cartella è disattivata per questo account."
},
"globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente",
"globalExcludedInjectionSitesHint": "I siti elencati qui non vengono proposti per alcun farmaco e vengono rifiutati al momento della registrazione di una dose, anche se un farmaco li preferisce.",
@@ -5461,13 +5331,8 @@
"expiry": "Scade tra (giorni)",
"expiryHint": "Al massimo {max} giorni.",
"expiryInvalid": "La scadenza deve essere tra 1 e {max} giorni.",
"fhirApi": "Consenti API FHIR limitata",
"fhirApiHint": "Lascia che il link offra un endpoint FHIR di sola lettura oltre alla vista della pagina.",
"resourceTypes": "Tipi di risorsa FHIR",
"create": "Crea link",
"tokenCreated": "Link di condivisione creato",
"tokenOnce": "Copia ora questo link — il token viene mostrato una sola volta e non può essere recuperato.",
"copy": "Copia link",
"copied": "Copiato negli appunti.",
"activeTitle": "Link attivi",
"noActive": "Nessun link di condivisione attivo.",
@@ -5481,7 +5346,6 @@
"statusExpired": "Scaduto",
"inactiveTitle": "Revocati e scaduti ({count})",
"shownOnce": "Mostrato una sola volta. Salva ora il codice QR e la passphrase — non potranno essere recuperati in seguito.",
"qrLabel": "Codice QR",
"qrAlt": "Codice QR contenente il link di condivisione e la passphrase",
"linkLabel": "Link",
"passphraseLabel": "Passphrase",
@@ -5504,7 +5368,12 @@
"pickerEmpty": "Nessun documento trovato.",
"pickerDone": "Fatto",
"pickerSelectedCount": "{count} di {max} selezionati",
"pickerMaxReached": "Hai raggiunto il massimo di {max} documenti."
"pickerMaxReached": "Hai raggiunto il massimo di {max} documenti.",
"scopeTitle": "Cosa contiene questo link",
"scopeInsuranceExcluded": "I dati assicurativi non vengono mai condivisi tramite link.",
"statusNeedsReselect": "Scegli di nuovo il contenuto",
"reselectReason": "Questo link mostrava un ambito che nessuno aveva scelto, perciò è stato chiuso. Creane uno nuovo e scegli cosa deve contenere.",
"reselectAction": "Crea un sostituto"
},
"genderOther": "Altro",
"cycleTracking": {
@@ -5531,13 +5400,15 @@
"nightscoutUpdate": "Salva modifiche",
"nightscoutConnected": "Nightscout connesso.",
"nightscoutConnectFailed": "Impossibile connettersi a Nightscout.",
"nightscoutSync": "Sincronizza ora",
"nightscoutSyncResult": "{count} misurazioni sincronizzate",
"nightscoutSyncFailed": "Sincronizzazione non riuscita",
"nightscoutDisconnect": "Disconnetti",
"nightscoutDisconnectTitle": "Disconnettere Nightscout?",
"nightscoutDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture della glicemia già importate restano nella tua cronologia.",
"polar": "Polar",
"polarDescription": "Collega il tuo account Polar per sincronizzare il recupero Nightly Recharge, le fasi del sonno e l'attività giornaliera.",
"polarDescription": "Collega il tuo account Polar per sincronizzare il recupero Nightly Recharge, le fasi del sonno, l'attività giornaliera e le sessioni di allenamento.",
"polarViewData": "Vedi sonno e recupero",
"polarHelp": "Accedi a Polar per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.",
"polarCredentials": "Credenziali API",
"polarClientId": "Client ID",
"polarClientSecret": "Client Secret",
@@ -5546,6 +5417,9 @@
"polarSaveCredentials": "Salva",
"polarUnavailable": "Polar non è configurato su questo server. Aggiungi sopra le tue credenziali OAuth di Polar oppure chiedi al tuo amministratore di configurare l'app condivisa.",
"polarConnect": "Collega Polar",
"polarSync": "Sincronizza ora",
"polarSyncResult": "{count} misurazioni sincronizzate",
"polarSyncFailed": "Sincronizzazione non riuscita",
"polarDisconnect": "Disconnetti",
"polarDisconnectTitle": "Disconnettere Polar?",
"polarDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture già importate restano nella tua cronologia.",
@@ -5564,7 +5438,6 @@
"oura": "Oura",
"ouraDescription": "Collega il tuo account Oura per sincronizzare prontezza, fasi del sonno, variabilità cardiaca e attività giornaliera.",
"ouraViewData": "Vedi sonno e recupero",
"ouraHelp": "Accedi a Oura per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.",
"ouraCredentials": "Credenziali API",
"ouraClientId": "Client ID",
"ouraClientSecret": "Client Secret",
@@ -5573,6 +5446,9 @@
"ouraSaveCredentials": "Salva",
"ouraUnavailable": "Oura non è configurato su questo server. Aggiungi sopra le tue credenziali OAuth di Oura oppure chiedi al tuo amministratore di configurare l'app condivisa.",
"ouraConnect": "Collega Oura",
"ouraSync": "Sincronizza ora",
"ouraSyncResult": "{count} misurazioni sincronizzate",
"ouraSyncFailed": "Sincronizzazione non riuscita",
"ouraDisconnect": "Disconnetti",
"ouraDisconnectTitle": "Disconnettere Oura?",
"ouraDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture già importate restano nella tua cronologia.",
@@ -5801,6 +5677,9 @@
"stravaSaveCredentials": "Salva",
"stravaUnavailable": "Strava non è configurato su questo server. Aggiungi sopra le tue credenziali API di Strava o chiedi al tuo amministratore di configurare l'app condivisa.",
"stravaConnect": "Collega Strava",
"stravaSync": "Sincronizza ora",
"stravaSyncResult": "{count} misurazioni sincronizzate",
"stravaSyncFailed": "Sincronizzazione non riuscita",
"stravaDisconnect": "Disconnetti",
"stravaDisconnectTitle": "Disconnettere Strava?",
"stravaDisconnectDescription": "Le sincronizzazioni future si interrompono. Le attività già importate restano nella cronologia.",
@@ -5820,17 +5699,14 @@
"title": "Apple Health",
"description": "La sincronizzazione in tempo reale avviene tramite l’app HealthLog per iOS, non tramite una connessione web o OAuth.",
"status": {
"checking": "Controllo dei dati Apple Health…",
"dataReceived": "Dati Apple Health ricevuti",
"setup": "Configura nell’app iOS",
"unavailable": "Stato di Apple Health non disponibile"
},
"lastDataLabel": "Ultimi dati Apple Health:",
"setupTitle": "Attiva la sincronizzazione in tempo reale",
"permissionStep": "Nell’app HealthLog per iOS, apri Impostazioni → Apple Health e autorizza le categorie di salute che vuoi condividere.",
"backgroundStep": "Mantieni attivo l’aggiornamento in background. L’app sincronizza automaticamente i nuovi dati in primo piano e quando iOS concede tempo in background.",
"importNote": "Ti serve lo storico precedente o non puoi usare l’app iOS? Il caricamento XML/ZIP di Apple Health è un’alternativa una tantum e non attiva la sincronizzazione in tempo reale.",
"importAction": "Apri importazione una tantum"
"importAction": "Apri importazione una tantum",
"staleNote": "Da più di una settimana non arrivano dati da Apple Health. Apri l'app HealthLog sull'iPhone e controlla i permessi di Apple Health."
},
"garminInfo": {
"title": "Garmin",
@@ -5939,7 +5815,6 @@
"telegramGlobal": "Consentire le notifiche Telegram.",
"ntfyGlobal": "Consentire le notifiche ntfy.",
"webPushGlobal": "Consentire le notifiche push del browser.",
"moodLogGlobal": "Consentire i promemoria del diario dell'umore.",
"integrationWebPush": "Web Push",
"offlineGeoLabel": "Dati geografici",
"offlineGeoEnabled": "Offline (GeoLite2)",
@@ -7034,7 +6909,6 @@
"period": "Periodo del report: {start} – {end}",
"expires": "Questo link scade il {date}.",
"provenance": "Questo è un riepilogo di sola lettura condiviso dalla persona a cui appartiene, esportato dai suoi dati HealthLog autoregistrati. I valori sono autodichiarati e possono includere letture sincronizzate da dispositivi; vanno considerati come riferiti dal paziente, non come un esame clinico.",
"vitals": "Parametri vitali",
"labs": "Valori di laboratorio",
"medications": "Farmaci e aderenza",
"bmi": "Indice di massa corporea (IMC)",
@@ -7074,7 +6948,10 @@
"download": "Scarica",
"attachmentHint": "Questo file viene scaricato sul tuo dispositivo (nessuna anteprima nella pagina).",
"exifNote": "I metadati della fotocamera (EXIF/GPS) sono stati rimossi dalle immagini JPEG, PNG e WebP condivise."
}
},
"downloadPdf": "Scarica come PDF",
"downloadFhir": "Scarica come FHIR",
"bmiSection": "Indice di massa corporea"
},
"cycle": {
"symptomCategory": {
@@ -7453,7 +7330,6 @@
"measurementReminders": {
"sectionTitle": "Controlli",
"sectionDescription": "Promemoria su cosa misurare o controllare, quando e dove.",
"addButton": "Aggiungi promemoria",
"markDone": "Fatto",
"delete": "Elimina",
"deleteConfirmTitle": "Eliminare il promemoria?",
@@ -8982,5 +8858,42 @@
}
}
}
},
"reportSelection": {
"groupIdentity": "Dati anagrafici",
"groupVitals": "Parametri vitali",
"groupCardio": "Cuore e circolazione",
"groupBody": "Misure corporee",
"groupSleepRecovery": "Sonno e recupero",
"groupActivity": "Attività",
"groupMobility": "Mobilità e andatura",
"groupEnvironment": "Ambiente",
"groupGlucose": "Glicemia",
"groupLabs": "Esami di laboratorio",
"groupMedications": "Farmaci",
"groupHistory": "Patologie e allergie",
"groupSensitive": "Dati sensibili",
"leafPatientIdentity": "Nome, data di nascita, sesso, altezza",
"leafInsurance": "Assicurazione e numero di iscrizione",
"leafGlucosePanel": "Pannello glicemico (tempo nel range, GMI, variabilità)",
"leafLabResults": "Esami di laboratorio",
"leafMedicationList": "Elenco dei farmaci",
"leafMedicationAdministrations": "Ogni dose registrata",
"leafMedicationCompliance": "Aderenza",
"leafGlp1Therapy": "Terapia GLP-1 (storico dosi, effetti collaterali)",
"leafAllergies": "Allergie e intolleranze",
"leafIllnessEpisodes": "Episodi di malattia",
"leafFamilyHistory": "Anamnesi familiare",
"leafMood": "Umore",
"leafCycle": "Ciclo mestruale",
"templateStandard": "Referto medico standard",
"applyStandard": "Applica {template}",
"standardHint": "Un clic compila ciò che serve di solito a una visita generale. Ogni voce resta modificabile.",
"groupCount": "{on}/{total}",
"groupDisclosure": "Mostra le voci di {group}",
"sensitiveHint": "Qui non è mai preselezionato nulla. Scegli ogni voce tu stesso.",
"scopeNone": "Ancora niente selezionato. Scegli cosa includere oppure applica il referto standard.",
"scopeCount": "{count} voci da {groups} aree",
"scopeSensitive": "incl. {names}"
}
}
+167 -248
View File
@@ -1,6 +1,7 @@
{
"common": {
"save": "Zapisz",
"conflictReloaded": "Odświeżono z serwera. Wprowadź zmianę jeszcze raz.",
"cancel": "Anuluj",
"delete": "Usuń",
"undo": "Cofnij",
@@ -24,6 +25,10 @@
"openTimePicker": "Otwórz wybór godziny",
"hours": "Godziny",
"minutes": "Minuty",
"feet": "ft",
"inches": "in",
"heightFeet": "Wzrost w stopach",
"heightInches": "Wzrost w calach",
"period": "AM/PM",
"am": "AM",
"pm": "PM",
@@ -48,13 +53,21 @@
},
"apiErrors": {
"measurement": {
"create": { "invalid": "Sprawdź dane pomiaru i spróbuj ponownie." },
"create": {
"invalid": "Sprawdź dane pomiaru i spróbuj ponownie."
},
"duplicate_timestamp": "Pomiar dla tego czasu już istnieje."
},
"mood": {
"list": { "invalid": "Filtry listy nastroju są nieprawidłowe." },
"create": { "invalid": "Sprawdź wpis nastroju i spróbuj ponownie." },
"update": { "invalid": "Sprawdź wpis nastroju i spróbuj ponownie." },
"list": {
"invalid": "Filtry listy nastroju są nieprawidłowe."
},
"create": {
"invalid": "Sprawdź wpis nastroju i spróbuj ponownie."
},
"update": {
"invalid": "Sprawdź wpis nastroju i spróbuj ponownie."
},
"duplicate_timestamp": "Wpis nastroju dla tego czasu już istnieje.",
"not_found": "Ten wpis nastroju nie jest już dostępny.",
"ratedFactor": {
@@ -62,12 +75,18 @@
}
},
"labs": {
"create": { "invalid": "Sprawdź wynik badania i spróbuj ponownie." },
"result": { "notFound": "Ten wynik badania nie jest już dostępny." },
"create": {
"invalid": "Sprawdź wynik badania i spróbuj ponownie."
},
"result": {
"notFound": "Ten wynik badania nie jest już dostępny."
},
"biomarker": {
"notFound": "Ten biomarker nie jest już dostępny.",
"duplicate": "Biomarker o tej nazwie już istnieje.",
"create": { "invalid": "Sprawdź dane biomarkera i spróbuj ponownie." },
"create": {
"invalid": "Sprawdź dane biomarkera i spróbuj ponownie."
},
"update": {
"invalid": "Sprawdź dane biomarkera i spróbuj ponownie.",
"referenceRangeInvalid": "Dolna wartość referencyjna nie może przekraczać górnej."
@@ -82,10 +101,18 @@
}
},
"onboarding": {
"disclaimer": { "invalid": "Potwierdź ponownie informację medyczną." },
"tour": { "invalid": "Nie udało się zapisać postępu prezentacji." },
"complete": { "invalid": "Sprawdź dane profilu i spróbuj ponownie." },
"user": { "notFound": "Nie znaleziono Twojego konta." },
"disclaimer": {
"invalid": "Potwierdź ponownie informację medyczną."
},
"tour": {
"invalid": "Nie udało się zapisać postępu prezentacji."
},
"complete": {
"invalid": "Sprawdź dane profilu i spróbuj ponownie."
},
"user": {
"notFound": "Nie znaleziono Twojego konta."
},
"step": {
"rateLimited": "Zbyt wiele prób. Zaczekaj i spróbuj ponownie.",
"invalid": "Nie udało się zapisać tego kroku wprowadzenia.",
@@ -143,16 +170,6 @@
"footerDisclaimer1": "Ten raport został wygenerowany automatycznie z danych samodzielnie rejestrowanych i ma na celu wspierać",
"footerDisclaimer2": "rozmowę lekarza z pacjentem. Nie zastępuje diagnozy medycznej. Korelacje nie oznaczają związku przyczynowego.",
"footerSource": "Źródło: HealthLog | Strefa czasowa: {timezone} | Utworzono: {timestamp}",
"typeWeight": "Masa ciała",
"typeBpSys": "Ciśnienie skurczowe",
"typeBpDia": "Ciśnienie rozkurczowe",
"typePulse": "Tętno spoczynkowe",
"typeBodyFat": "Tkanka tłuszczowa",
"typeSleep": "Czas snu",
"typeSteps": "Aktywność (kroki)",
"typeTotalBodyWater": "Całkowita woda w organizmie",
"typeBoneMass": "Masa kostna",
"typeOxygenSaturation": "Saturacja tlenem",
"unitSteps": "Kroki",
"bmiUnderweight": "Niedowaga",
"bmiNormal": "Waga prawidłowa",
@@ -188,8 +205,6 @@
"summaryTrend": "{label}: ostatnio {latest} {arrow}",
"summaryAdherence": "Przestrzeganie zaleceń lekowych: {rate}% (średnia ważona).",
"chartsTitle": "Wykresy trendów",
"aiSummaryTitle": "Podsumowanie AI — niezweryfikowane klinicznie",
"aiSummaryDisclaimer": "Ta sekcja została wygenerowana automatycznie przez model AI i nie jest zweryfikowana klinicznie. Nie zastępuje oceny lekarskiej.",
"packageReadme": "Dokumentacja zdrowotna HealthLog\n\nTen pakiet zawiera:\n- report.pdf: raport zdrowotny dla lekarza.\n- bundle.json: te same dane jako dokument HL7 FHIR R4 (Bundle), zgodny z ePA.\n\nWygenerowano w HealthLog.",
"wellnessTitle": "Podsumowanie dobrostanu",
"wellnessDisclaimer": "To opisowe wyniki dobrostanu obliczone na podstawie śledzonych sygnałów (0–100). Nie są to oceny kliniczne ani diagnozy.",
@@ -348,6 +363,9 @@
"errorRegistrationDisabled": "Żadne konto nie pasuje do tej tożsamości, a nowe rejestracje są obecnie zamknięte.",
"errorFailed": "Logowanie jednokrotne nie powiodło się. Spróbuj ponownie."
},
"native": {
"signingInToApp": "Logowanie do aplikacji HealthLog"
},
"mfa": {
"title": "Uwierzytelnianie dwuskładnikowe",
"totpHint": "Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej.",
@@ -439,6 +457,7 @@
"layoutReset": "Przywróć wartości domyślne",
"layoutSaveSuccess": "Układ panelu zapisany",
"layoutSaveError": "Nie udało się zapisać układu",
"layoutConflictReloaded": "Układ został ponownie wczytany z serwera. Wprowadź zmianę jeszcze raz.",
"layoutResetSuccess": "Przywrócono układ domyślny",
"layoutUsingDefaults": "Aktywny układ domyślny",
"layoutCustomized": "Aktywny układ niestandardowy",
@@ -880,7 +899,6 @@
"emptyDescription": "Utwórz leki, dawki i okna czasowe, aby śledzić przestrzeganie.",
"loadFailed": "Nie udało się załadować leków",
"retryLoad": "Spróbuj ponownie",
"name": "Nazwa",
"inactive": "Wstrzymany",
"intakeHistory": "Historia przyjęć",
"openDetailPage": "Otwórz stronę szczegółów leku",
@@ -1183,8 +1201,6 @@
"dosesPerUnitLabel": "Dawki na jednostkę",
"dosesPerUnitPlaceholder": "np. 4",
"dosesPerUnitHint": "Dawki na wstrzykiwacz / fiolkę — zostaw puste, aby nie śledzić zapasów.",
"injectionSiteToggle": "Rotacja miejsc wstrzyknięcia",
"injectionSiteHint": "Opcjonalny przewodnik rotacji. Miejsce każdej dawki zapisywane jest przy rejestrowaniu przyjęcia.",
"short": "Dawka",
"unitsPerDoseLabel": "Jednostki na dawkę",
"unitsPerDoseHint": "np. 2 tabletki po 2 mg na dawkę 4 mg lub ½ tabletki przy dzielonej dawce"
@@ -1235,7 +1251,6 @@
"remindersDescription": "Push, Telegram lub ntfy — w zależności od ustawień.",
"noChannelHint": "Przypomnienia wymagają kanału powiadomień — obecnie żaden nie jest aktywny.",
"noChannelHintLink": "Skonfiguruj powiadomienia",
"multiScheduleNote": "Ten lek ma wiele harmonogramów. Szczegółowy edytor wróci w kolejnej wersji.",
"short": "Gotowe"
}
},
@@ -1868,7 +1883,7 @@
"levelShift": "Wykryto zmianę poziomu w okolicach {date}.",
"plateau": {
"title": "Plateau masy ciała przy obecnej dawce",
"evidence": "Zmiana masy ciała o {delta} kg w ciągu ostatnich {window} dni ({readings} pomiarów) — {days} dni na dawce {dose}."
"evidence": "Zmiana masy ciała o {delta} {unit} w ciągu ostatnich {window} dni ({readings} pomiarów) — {days} dni na dawce {dose}."
},
"retarget": {
"label": "To nie ten pomiar?",
@@ -2103,7 +2118,6 @@
"emptyDescription": "Insights pojawią się, gdy zarejestrujesz kilka pomiarów. Zacznij od wagi lub ciśnienia.",
"emptyAddMeasurement": "Zarejestruj pomiar",
"editMode": {
"open": "Dostosuj",
"done": "Gotowe",
"reset": "Przywróć",
"title": "Dostosuj przegląd",
@@ -2128,16 +2142,6 @@
"bmiEmptyAction": "Otwórz ustawienia konta",
"noProviderConfigured": "Połącz dostawcę AI, aby zobaczyć tutaj ocenę swoich pomiarów.",
"noProviderAction": "Otwórz ustawienia AI",
"startAnalysis": "Uruchom ocenę",
"refreshAnalysis": "Odśwież ocenę",
"keyTakeaway": "Najważniejsze",
"findingsTitle": "Wnioski",
"heroFindingPositive": "Najważniejszy wniosek",
"heroFindingNeutral": "Najważniejszy wniosek",
"heroFindingAttention": "Do uwagi",
"heroFindingWarning": "Najważniejszy alert",
"correlationsTitle": "Korelacje",
"recommendationsTitle": "Rekomendacje",
"personalRecord": {
"badge": "PR",
"tooltip": "Rekord osobisty: {value} {unit} dnia {date}"
@@ -2145,53 +2149,33 @@
"recommendation": {
"source": "Źródło",
"viewSource": "Otwórz wytyczne w nowej karcie",
"rationale": "Dlaczego ta rekomendacja",
"rationaleWindow": "Okno",
"rationaleComparedTo": "W porównaniu z",
"rationaleDeviation": "Odchylenie",
"rationaleExpand": "Pokaż uzasadnienie",
"rationaleCollapse": "Ukryj uzasadnienie",
"legacyPayloadCta": "Insights zaktualizowane — wygeneruj ponownie, aby aktywować nowe wyjaśnienia",
"feedbackHelpful": "Przydatne",
"feedbackNotHelpful": "Mało przydatne",
"feedbackThanks": "Dzięki za opinię",
"feedbackConfirmed": "Opinia zapisana",
"feedbackAlreadyRated": "Już ocenione",
"feedbackError": "Nie udało się zapisać opinii. Spróbuj ponownie.",
"confidence": "Pewność",
"confidenceAria": "Pewność: {value} na 100",
"confidenceHigh": "Wysoka pewność",
"confidenceMedium": "Średnia pewność",
"confidenceLow": "Niska pewność — mało danych",
"confidenceDraft": "Wersja robocza"
},
"dataFoundation": "Podstawa danych",
"dataGaps": "Braki danych:",
"lastUpdated": "Ostatnia aktualizacja",
"confidenceHoch": "Wysoka",
"confidenceMittel": "Średnia",
"confidenceGering": "Niska",
"loadError": "Nie udało się załadować analiz",
"heroPersonalBaseline": "Na podstawie ostatnich 90 dni",
"heroRegenerate": "Wygeneruj ponownie",
"heroRegenerating": "Generowanie",
"regenerateAnalysis": "Uruchom ocenę ponownie",
"regenerateSuccess": "Ocena wygenerowana ponownie",
"warmStarted": "Oceny są przygotowywane w tle",
"narrativeTitle": "Przegląd",
"narrativeWeek": "Ten tydzień",
"narrativeMonth": "Ten miesiąc",
"narrativePreparing": "Przygotowywanie podsumowania…",
"narrativeUpdating": "Aktualizowanie…",
"narrativeProvenanceLabel": "Jak to powstało",
"narrativeProvenanceMethod": "Podsumowanie okresu w prostym języku, oparte wyłącznie na zarejestrowanych wartościach i powiązaniach, które przeszły statystyczną korektę dla porównań wielokrotnych. Opisowe, nigdy nie przyczynowe.",
"narrativeProvenanceMetrics": "Na podstawie: {metrics}",
"narrativeProvenanceWindow": "Okno: od {from} do {to}",
"heroFallbackSubtitle": "Codzienne spojrzenie na twoje trendy — prosto z liczb, które rejestrujesz.",
"heroGreetingMorning": "Dzień dobry",
"heroGreetingAfternoon": "Dzień dobry",
"heroGreetingEvening": "Dobry wieczór",
"heroComingSoonTooltip": "Wkrótce",
"heroActionAskCoach": "Zapytaj coacha",
"healthScore": {
"label": "Wynik zdrowia",
@@ -2204,14 +2188,12 @@
"componentMood": "Nastrój",
"componentCompliance": "Przestrzeganie",
"deltaExplainer": {
"trigger": "Co oznacza ta zmiana?",
"title": "Jak liczona jest ta zmiana",
"description": "Zmiana względem ubiegłego tygodnia: {delta}",
"body": "Wynik łączy ciśnienie, wagę, nastrój i przyjmowanie leków. Porównujemy dzisiejszy wynik z tym samym momentem tydzień wcześniej. Dodaj świeży pomiar lub wpis o nastroju, żeby pchnąć go w górę."
},
"provenance": {
"toggle": "Składa się z",
"weightLabel": "udział",
"mixedBanner": "Niektóre komponenty łączą wiele źródeł. Zestawienie pokazuje najświeższą wartość dla każdego komponentu.",
"footnote": "Wagi są przeskalowywane do dostępnych danych.",
"asOfLabel": "stan na {date}",
@@ -2275,10 +2257,6 @@
"viewDetail": "Zobacz szczegóły: {metric}"
},
"vo2Max": {
"statLatest": "Aktualne",
"statMin": "Min",
"statMax": "Maks",
"empty": "Brak danych VO₂ max — zsynchronizuj Apple Health, aby uzupełnić ten widok.",
"cardioLinkTitle": "Wydolność krążeniowa (VO₂ max)",
"cardioLinkBody": "VO₂ max to główna miara wydolności tlenowej. Pełny wykres i ocenę znajdziesz na osobnej stronie.",
"cardioLinkCta": "Otwórz wydolność krążeniową"
@@ -2335,6 +2313,7 @@
"send": "Wyślij",
"stop": "Zatrzymaj",
"thinking": "Myślę…",
"unverifiedFiguresNotice": "Niektórych liczb nie udało się zweryfikować z Twoimi danymi, więc je pominięto.",
"composerPlaceholder": "Zapytaj mnie …",
"nudgeBubbleLabel": "Nowa wiadomość od trenera — otwórz czat",
"fabLabel": "Otwórz trenera",
@@ -2742,125 +2721,68 @@
"gewichtDescription": "Przebieg wagi na tle zdrowego pasma wyliczonego z twojego wzrostu, z oceną.",
"pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.",
"stimmungDescription": "Dzienne wartości nastroju w czasie wraz z oceną tekstową.",
"stimmungEmptyTitle": "Zarejestruj pierwszy nastrój",
"stimmungEmptyDescription": "Insights nastroju pojawiają się, gdy zarejestrujesz kilka wpisów. Stuknij poniżej, aby zacząć.",
"stimmungEmptyAction": "Otwórz dziennik nastroju",
"medikamenteDescription": "Kalendarz przyjęć dla każdego leku plus ocena tekstowa.",
"medikamenteEmptyTitle": "Dodaj pierwszy lek",
"medikamenteEmptyDescription": "Insights przestrzegania wymagają przynajmniej jednego zarejestrowanego leku.",
"medikamenteEmptyAction": "Otwórz leki",
"bmiDescription": "BMI obliczone z twojej wagi i wzrostu, z oceną.",
"target": {
"adjustLink": "Dostosuj zakres docelowy"
},
"explainer": {
"trigger": "Czym jest {title}?",
"bloodPressureTitle": "Ciśnienie krwi",
"bloodPressureBody": "Ciśnienie krwi to siła, z jaką krew napiera na ściany tętnic, zapisywana jako skurczowe do rozkurczowego (szczyt podczas uderzenia serca do wartości spoczynkowej między uderzeniami). Stale wysokie wartości z czasem obciążają serce i naczynia.",
"pulseTitle": "Tętno",
"pulseBody": "Tętno to liczba uderzeń serca na minutę. Pojedynczy pomiar odzwierciedla to, co robiłeś w danej chwili — w spoczynku wynosi zwykle 60–100 uderzeń na minutę i naturalnie rośnie przy ruchu, stresie lub kofeinie.",
"weightTitle": "Waga",
"weightBody": "Masa ciała to jeden z najprostszych sygnałów długoterminowego bilansu energetycznego i ogólnego zdrowia. Trend na przestrzeni tygodni liczy się znacznie bardziej niż pojedynczy odczyt, który zmienia się wraz z nawodnieniem, jedzeniem i porą dnia.",
"bmiTitle": "Wskaźnik masy ciała (BMI)",
"bmiBody": "BMI to waga podzielona przez wzrost do kwadratu — jedna liczba przypisująca Cię do szerokiej kategorii wagowej. To badanie przesiewowe dla populacji, a nie diagnoza, i nie odróżnia mięśni od tłuszczu.",
"sleepTitle": "Sen",
"sleepBody": "Długość snu to czas, który faktycznie przespałeś w nocy, podzielony na fazy lekką, głęboką i REM. Większość dorosłych potrzebuje około siedmiu do dziewięciu godzin; stała ilość i jakość wspierają regenerację, nastrój i metabolizm.",
"moodTitle": "Nastrój",
"moodBody": "Śledzenie nastroju rejestruje Twoje samopoczucie w czasie na prostej skali. Regularne zapisywanie ujawnia wzorce — jak sen, aktywność czy leki łączą się z samopoczuciem — których pojedynczy dzień nigdy nie pokaże.",
"medicationsTitle": "Przestrzeganie zaleceń lekowych",
"medicationsBody": "Przestrzeganie zaleceń to rzetelność przyjmowania leków zgodnie z zaleceniami, pokazana tu jako odsetek oczekiwanych dawek, które zarejestrowałeś. Regularne przyjmowanie często decyduje o tym, czy terapia działa zgodnie z zamierzeniem.",
"restingHrTitle": "Tętno spoczynkowe",
"restingHrBody": "Tętno spoczynkowe to częstość uderzeń serca w pełnym spoczynku, zwykle mierzona w nocy. Niższe tętno zwykle wskazuje na lepszą wydolność krążeniową, a rosnący trend może być wczesnym sygnałem stresu lub choroby.",
"hrvTitle": "Zmienność rytmu serca (HRV)",
"hrvBody": "HRV to drobna zmienność czasu między kolejnymi uderzeniami serca. Wyższa zmienność zwykle odzwierciedla dobrze zregenerowany, elastyczny układ nerwowy, a utrzymujący się spadek może sygnalizować zmęczenie, stres lub niewystarczającą regenerację.",
"oxygenSaturationTitle": "Saturacja tlenem (SpO₂)",
"oxygenSaturationBody": "SpO₂ to odsetek czerwonych krwinek przenoszących tlen. Wartości 95–100% są normalne w spoczynku; wartości utrzymujące się poniżej 90% wymagają uwagi, choć krótkie nocne spadki są częste.",
"bodyTemperatureTitle": "Temperatura ciała",
"bodyTemperatureBody": "Temperatura ciała odzwierciedla stan cieplny rdzenia, zwykle około 37 °C z niewielkimi dobowymi wahaniami. Utrzymujący się wzrost może wskazywać na infekcję, podczas gdy nocne pomiary na nadgarstku śledzą głównie zmiany względne, a nie wartość kliniczną.",
"activeEnergyTitle": "Energia aktywna",
"activeEnergyBody": "Energia aktywna to kalorie spalane przez ruch, ponad podstawowy wydatek w spoczynku. To bezpośrednia miara tego, jak aktywny fizycznie był Twój dzień.",
"workoutsTitle": "Treningi",
"workoutsBody": "Treningi to ustrukturyzowane sesje ćwiczeń rejestrowane przez Twoje urządzenia, wraz z typem, czasem trwania i energią. Ich śledzenie pokazuje, jak regularny trening kumuluje się przez tygodnie i jak wiąże się z innymi sygnałami zdrowotnymi.",
"respiratoryRateTitle": "Częstość oddechów",
"respiratoryRateBody": "Częstość oddechów to liczba wdechów na minutę. W spoczynku większość dorosłych oddycha około dwunastu do dwudziestu razy na minutę; utrzymujący się wzrost może towarzyszyć chorobie, wysiłkowi lub stresowi.",
"bodyWaterTitle": "Całkowita woda w organizmie",
"bodyWaterBody": "Całkowita woda w organizmie to udział płynów w masie ciała, wewnątrz i wokół komórek. Zmienia się wraz z nawodnieniem, posiłkami i aktywnością, więc trend w ciągu tygodni mówi więcej niż pojedynczy pomiar.",
"boneMassTitle": "Masa kostna",
"boneMassBody": "Masa kostna to szacowana waga minerałów w szkielecie. Pomiary z wagi są przybliżone, ale stabilny lub rosnący trend sprzyja długotrwałej wytrzymałości kości.",
"fatFreeMassTitle": "Masa beztłuszczowa",
"fatFreeMassBody": "Masa beztłuszczowa to wszystko w ciele, co nie jest tłuszczem — mięśnie, kości, narządy i woda. Utrzymanie lub jej budowanie przy spadku wagi zwykle wskazuje na zdrowszy skład ciała.",
"fatMassTitle": "Masa tłuszczowa",
"fatMassBody": "Masa tłuszczowa to łączna waga tłuszczu w ciele. Pewna ilość tłuszczu jest niezbędna dla hormonów i magazynowania energii, ale jego nadmiar — zwłaszcza w okolicy brzucha — zwiększa długoterminowe ryzyko zdrowotne (WHO 2024).",
"muscleMassTitle": "Masa mięśniowa",
"muscleMassBody": "Masa mięśniowa to szacowana waga mięśni w ciele. Więcej mięśni wspiera siłę, metabolizm i sprawność, a ich utrzymanie z wiekiem zwykle wymaga aktywnego wysiłku.",
"visceralFatTitle": "Tłuszcz trzewny",
"visceralFatBody": "Tłuszcz trzewny to tłuszcz gromadzony głęboko w jamie brzusznej wokół narządów. W odróżnieniu od tłuszczu podskórnego jego wysoki poziom jest ściśle powiązany z chorobami serca i cukrzycą typu 2 (AHA, Circulation 2021), więc im niżej, tym lepiej. Skala pokazuje wartość bezjednostkową (mniej więcej 1–12 to zakres zdrowy, 13 i więcej to wartość podwyższona); to inna skala niż progi w cm² z badań obrazowych stosowane przez klinicystów i nie należy jej traktować z nimi zamiennie.",
"leanBodyMassTitle": "Beztłuszczowa masa ciała",
"leanBodyMassBody": "Beztłuszczowa masa ciała to całkowita waga pomniejszona o tłuszcz — mięśnie, kości, narządy i woda razem. Śledzenie jej obok wagi pokazuje, czy zmiany wynikają z mięśni, czy z tłuszczu.",
"flightsClimbedTitle": "Pokonane piętra",
"flightsClimbedBody": "Pokonane piętra liczą kondygnacje, które pokonujesz w górę każdego dnia, gdzie jedno piętro to około trzech metrów wzniesienia. To prosty wskaźnik codziennej aktywności i wysiłku nóg.",
"walkingDistanceTitle": "Dystans marszu i biegu",
"walkingDistanceBody": "Dystans marszu i biegu to droga, jaką pokonujesz pieszo każdego dnia. Uzupełnia liczbę kroków, pokazując, jak daleko się poruszałeś, a nie tylko jak często stawiałeś kroki.",
"walkingSteadinessTitle": "Stabilność chodu",
"walkingSteadinessBody": "Stabilność chodu szacuje, jak stabilny i zrównoważony jest twój sposób chodzenia, w skali od dobrej do niskiej. Niski poziom wskazuje na większe ryzyko upadku i warto omówić go z lekarzem.",
"walkingHeartRateTitle": "Tętno podczas chodzenia",
"walkingHeartRateBody": "Tętno podczas chodzenia to średni puls w trakcie równego marszu. Niższa wartość w czasie często odzwierciedla poprawę wydolności krążeniowej przy tym samym wysiłku.",
"walkingAsymmetryTitle": "Asymetria chodu",
"walkingAsymmetryBody": "Asymetria chodu to odsetek czasu, w którym kroki są nierówne między lewą a prawą nogą. Wyższy procent może wskazywać na ból, kontuzję lub problem z chodem wart sprawdzenia.",
"doubleSupportTimeTitle": "Czas podwójnego podparcia",
"doubleSupportTimeBody": "Czas podwójnego podparcia to udział każdego cyklu chodu, w którym obie stopy dotykają ziemi jednocześnie. Wyższy procent zwykle oznacza ostrożniejszy, mniej stabilny chód.",
"stepLengthTitle": "Długość kroku",
"stepLengthBody": "Długość kroku to średnia odległość od jednego postawienia stopy do następnego. Krótszy krok w czasie może odzwierciedlać zmęczenie, ostrożność lub zmianę sprawności ruchowej.",
"walkingSpeedTitle": "Prędkość chodu",
"walkingSpeedBody": "Prędkość chodu to tempo poruszania się po płaskim terenie. To dobrze zbadany wskaźnik ogólnego zdrowia i sprawności, a stałe lub rosnące tempo to dobry znak.",
"pulseWaveVelocityTitle": "Prędkość fali tętna",
"pulseWaveVelocityBody": "Prędkość fali tętna mierzy, jak szybko fala ciśnienia z każdego uderzenia serca przemieszcza się przez tętnice. Szybsza fala wskazuje na sztywniejsze tętnice, co wiąże się z wyższym ryzykiem sercowo-naczyniowym.",
"vascularAgeTitle": "Wiek naczyniowy",
"vascularAgeBody": "Wiek naczyniowy szacuje, jak stare zachowują się twoje tętnice w porównaniu z rzeczywistym wiekiem, na podstawie sztywności tętnic. Wiek naczyniowy niższy od rzeczywistego sugeruje zdrowsze naczynia krwionośne.",
"environmentalAudioTitle": "Poziom dźwięku otoczenia",
"environmentalAudioBody": "Poziom dźwięku otoczenia śledzi głośność otoczenia w decybelach. Długotrwała ekspozycja powyżej około 70 dB może z czasem uszkodzić słuch, więc niższe średnie dzienne są bezpieczniejsze.",
"headphoneAudioTitle": "Poziom dźwięku w słuchawkach",
"headphoneAudioBody": "Poziom dźwięku w słuchawkach śledzi w decybelach, jak głośno słuchasz przez słuchawki. Ściszanie głośności i robienie przerw chroni słuch w dłuższej perspektywie.",
"audioEventsTitle": "Zdarzenia głośnego dźwięku",
"audioEventsBody": "Zdarzenia głośnego dźwięku oznaczają momenty, gdy hałas wokół ciebie osiągnął poziom mogący przy wystarczającej ekspozycji uszkodzić słuch. Częste zdarzenia to sygnał, by poszukać cichszego otoczenia lub ochrony słuchu.",
"daylightTitle": "Czas w świetle dziennym",
"daylightBody": "Czas w świetle dziennym to ilość czasu spędzanego w świetle na zewnątrz każdego dnia. Regularne światło dzienne wspiera zdrowy rytm snu i czuwania oraz nastrój (CDC 2024), a czas na świeżym powietrzu wiąże się ze zdrowiem oczu.",
"bloodGlucoseTitle": "Poziom glukozy we krwi",
"bloodGlucoseBody": "Poziom glukozy we krwi to ilość cukru we krwi, głównego źródła energii organizmu. Rośnie po jedzeniu i ustala się między posiłkami; wartości utrzymujące się wysoko mogą sygnalizować cukrzycę lub stan przedcukrzycowy.",
"skinTemperatureTitle": "Temperatura skóry",
"skinTemperatureBody": "Temperatura skóry to tutaj niewielka nocna zmiana temperatury nadgarstka względem twojej własnej wartości bazowej, a nie bezwzględny pomiar gorączki. Zmiany mogą odzwierciedlać sen, otoczenie, chorobę lub fazę cyklu menstruacyjnego.",
"cardioFitnessTitle": "Wydolność krążeniowa (VO₂ max)",
"cardioFitnessBody": "VO₂ max to ilość tlenu, jaką organizm potrafi wykorzystać przy maksymalnym wysiłku — najlepszy laboratoryjny wskaźnik wydolności tlenowej, który urządzenia noszone szacują pośrednio. Dla orientacji: sprawność fizyczna jest stopniowanym predyktorem przeżycia — każdy dodatkowy MET (około 3,5 ml/kg/min) wiąże się z mniej więcej 10–25 % niższą śmiertelnością (AHA, Circulation 2016). Wyższy lub rosnący trend to mocny sygnał dobrego zdrowia układu krążenia.",
"cardioRecoveryTitle": "Regeneracja serca",
"cardioRecoveryBody": "Regeneracja serca to liczba uderzeń na minutę, o jaką spada tętno w pierwszej minucie po zakończeniu intensywnego wysiłku. Większy spadek oznacza, że serce szybciej się uspokaja, co świadczy o dobrej kondycji.",
"wristTemperatureTitle": "Temperatura nadgarstka",
"wristTemperatureBody": "Temperatura nadgarstka jest mierzona podczas snu i podawana jako różnica względem Twojej wartości bazowej, a nie jako wartość bezwzględna. Stabilny odczyt jest normalny, a utrzymujący się wzrost może wskazywać na chorobę, intensywny trening lub zmianę cyklu.",
"fallsTitle": "Upadki",
"fallsBody": "Tutaj liczone są poważne upadki wykryte przez urządzenie noszone. Celem jest zero; rosnąca liczba to sygnał ryzyka związanego z mobilnością, który warto omówić z lekarzem, zwłaszcza u osób starszych (CDC STEADI 2024).",
"sixMinuteWalkTitle": "Dystans marszu 6-minutowego",
"sixMinuteWalkBody": "To szacunek, jak daleko mógłbyś przejść w sześć minut — klasyczny test kliniczny mobilności i wydolności krążeniowo-oddechowej. Wartość z urządzenia noszonego to przybliżenie, a nie nadzorowany test kliniczny; liczy się stały lub rosnący trend.",
"stairAscentSpeedTitle": "Prędkość wchodzenia po schodach",
"stairAscentSpeedBody": "To prędkość, z jaką pokonujesz bieg schodów. To czuły wskaźnik siły nóg i mobilności — stałe lub rosnące tempo to dobry znak, a spadające warto obserwować.",
"stairDescentSpeedTitle": "Prędkość schodzenia po schodach",
"stairDescentSpeedBody": "To prędkość, z jaką schodzisz bieg schodów. Razem z prędkością wchodzenia odzwierciedla kontrolę nóg i równowagę — stałe tempo to dobry znak, a spadające warto obserwować.",
"breathingDisturbancesTitle": "Zaburzenia oddychania we śnie",
"breathingDisturbancesBody": "Urządzenie noszone potrafi oszacować, jak często Twój oddech był przerywany podczas snu. Niższy wskaźnik jest lepszy; poziom oznaczony jako podwyższony — zwłaszcza przez wiele nocy — warto omówić z lekarzem. To przybliżenie przesiewowe, a nie diagnoza bezdechu sennego.",
"stepsTitle": "Kroki",
"stepsBody": "Liczba kroków to łączna liczba kroków wykonanych każdego dnia. To najprostsza miara codziennego ruchu — wyższa dzienna liczba zwykle odzwierciedla bardziej aktywny tryb życia, a stały nawyk liczy się bardziej niż pojedynczy intensywny dzień.",
"readinessBody": "Gotowość to pojedynczy dzienny odczyt tego, jak przygotowane jest Twoje ciało, by zmierzyć się z dniem — łączy tętno spoczynkowe, zmienność rytmu serca, sen i regularność ostatnich nocy. Wyższy wynik oznacza, że Twój organizm wygląda na wypoczęty i pełen zasobów; niższy to delikatna wskazówka, by zwolnić tempo.",
"sleepScoreBody": "Twój wynik snu streszcza całą noc w jednej liczbie — długość, efektywność, regularność pór oraz proporcje snu lekkiego, głębokiego i REM. Obserwuj wzorzec w ujęciu tygodni, zamiast gonić za idealnym wynikiem jednej nocy.",
"recoveryBody": "Regeneracja odzwierciedla, jak dobrze Twoje ciało się odbudowało — opiera się na nocnych sygnałach, takich jak zmienność rytmu serca i tętno spoczynkowe. Wysoki wynik to zielone światło, by przyspieszyć; niższy sugeruje, że organizm wciąż się regeneruje.",
"strainBody": "Obciążenie ujmuje, ile wysiłku sercowo-naczyniowego i fizycznego nałożyłeś na swoje ciało — od treningów po aktywny dzień na nogach. Gdy zbierze się wystarczająco danych, jest oceniane względem Twojego własnego typowego wysiłku, a nie cudzej miary.",
"stressBody": "Stres jest tu szacunkiem fizjologicznego obciążenia Twojego układu nerwowego, odczytywanym głównie ze wzorców zmienności rytmu serca, a nie z tego, jak się świadomie czujesz. Niższy wynik wskazuje na spokojny, dobrze regulowany stan; wyższy to zachęta, by wpleść regenerację.",
"recoveryPageTitle": "Regeneracja",
"recoveryPageBody": "Regeneracja łączy nocne sygnały rejestrowane przez Twoje urządzenie – recharge, obciążenie, load oraz dzienne sumy tętna i energii – aby pokazać, jak dobrze Twój organizm się zregenerował. Patrz na wzorzec w ciągu kilku dni, a nie na pojedynczą liczbę; niższy wynik to zaproszenie do zwolnienia tempa, a nie alarm.",
"pulsePressureTitle": "Ciśnienie tętna",
"pulsePressureBody": "Ciśnienie tętna to różnica między wartością skurczową a rozkurczową (skurczowe minus rozkurczowe). Jako ogólna wskazówka, około 40 mmHg jest typowe w spoczynku; szeroka różnica powyżej około 60 mmHg w średnim wieku może odzwierciedlać usztywnienie tętnic, a wąska różnica poniżej około 25 mmHg może towarzyszyć niskiej objętości wyrzutowej (StatPearls/NIH 2023). To powód do rozmowy, nie diagnoza.",
"meanArterialPressureTitle": "Średnie ciśnienie tętnicze",
"meanArterialPressureBody": "Średnie ciśnienie tętnicze to przeciętne ciśnienie, jakie widzą Twoje narządy w trakcie uderzenia serca, w przybliżeniu rozkurczowe plus jedna trzecia różnicy do skurczowego. Jako ogólna wskazówka, wartość spoczynkowa mieści się w okolicy 70–100 mmHg (StatPearls/NIH 2023). Często przywoływany próg ~60 mmHg to pojęcie z medycyny ostrej, nie alarm do samodzielnego pomiaru.",
"cycleEducationBody": "Twój cykl przebiega przez cztery fazy sterowane hormonami, a jego długość i regularność to dostępne okno na ogólny stan zdrowia. Jako ogólna wskazówka, u osób dorosłych cykle trwają zwykle 21–35 dni (źródła kliniczne podają 24–38), z krwawieniem około pięciu dni (ACOG 2015). Faza lutealna jest stałą kotwicą, dlatego owulację najlepiej szacować, licząc wstecz od następnej spodziewanej miesiączki, nigdy według stałego „14. dnia”. Przewidziane okno płodne to oszacowanie, które wymaga kilku zarejestrowanych cykli i pozostaje przybliżone, a podstawowa temperatura ciała potwierdza owulację po fakcie, a nie ją przewiduje. To nie jest metoda antykoncepcji.",
"sleepHygieneBody": "Kilka opartych na dowodach nawyków wspiera sen: stałe pory kładzenia się i wstawania także w weekendy, jasne światło rano i przyciemnione wieczory, chłodna, ciemna i cicha sypialnia oraz unikanie kofeiny po południu, alkoholu późnym wieczorem i obfitych posiłków przed snem (CDC 2024). Regularność przewiduje zdrowie co najmniej tak silnie jak długość snu, a całkowity czas snu z urządzenia jest znacznie bardziej wiarygodny niż jego podział na sen głęboki, lekki i REM.",
"referenceGuidance": "Ogólna wskazówka, nie porada medyczna. Zakresy referencyjne to wartości populacyjne, które zmieniają się wraz z Twoim wiekiem, płcią i stanem zdrowia; to Twój własny trend w czasie prowadzi odczyt."
"sleepHygieneBody": "Kilka opartych na dowodach nawyków wspiera sen: stałe pory kładzenia się i wstawania także w weekendy, jasne światło rano i przyciemnione wieczory, chłodna, ciemna i cicha sypialnia oraz unikanie kofeiny po południu, alkoholu późnym wieczorem i obfitych posiłków przed snem (CDC 2024). Regularność przewiduje zdrowie co najmniej tak silnie jak długość snu, a całkowity czas snu z urządzenia jest znacznie bardziej wiarygodny niż jego podział na sen głęboki, lekki i REM."
},
"bpDerived": {
"pulsePressure": "Ciśnienie tętna (różnica między Twoimi wartościami) wynosi około {value} mmHg – około 40 jest typowe w spoczynku.",
@@ -2894,16 +2816,6 @@
"compositionTitle": "Rozkład faz",
"compositionSubtitle": "Ostatnie {nights} nocy",
"compositionAriaLabel": "Rozkład faz snu w {nights} nocach",
"hypnogram": {
"title": "Ostatnia noc",
"subtitle": "{asleep} snu",
"ariaLabel": "Przebieg faz snu ostatniej nocy: {asleep} snu, {awakenings} przebudzeń",
"asleepTotal": "Sen {value}",
"inBedTotal": "W łóżku {value}",
"awakenings": "{count} przebudzeń",
"source": "Źródło: {source}",
"estimateNote": "Czasy faz są szacowane — to źródło podaje sumy dla każdej fazy, a nie zmierzony przebieg."
},
"sourceDiscrepancy": {
"tooltipTitle": "Źródła się różnią"
},
@@ -2958,7 +2870,6 @@
"loadError": "Nie udało się teraz wczytać Twojego rytmu snu. Pojawi się ponownie, gdy połączenie się ustabilizuje."
}
},
"correlations": "Korelacje",
"medicationCompliance": "Przestrzeganie leków",
"dayStreak": "{count} dni serii",
"dayStreakOne": "{count} dzień serii",
@@ -2967,19 +2878,12 @@
"taken": "Przyjęte",
"skipped": "Pominięte",
"missed": "Pominięte",
"generalStatusBadge": {
"good": "Dobrze",
"watch": "Obserwuj",
"critical": "Krytyczny"
},
"assessmentTitle": "Ocena",
"restMode": {
"title": "Tryb odpoczynku",
"body": "Trwa epizod choroby lub dolegliwości, więc odzwierciedla to okres rekonwalescencji. Twój wynik nie jest za to obniżany.",
"bodySince": "Od {since} trwa epizod choroby lub dolegliwości, więc odzwierciedla to okres rekonwalescencji. Twój wynik nie jest za to obniżany."
},
"assessmentShowMore": "Pokaż więcej",
"assessmentShowLess": "Pokaż mniej",
"bloodPressureSectionTitle": "Ciśnienie krwi",
"weightSectionTitle": "Waga",
"pulseSectionTitle": "Tętno",
@@ -2987,8 +2891,6 @@
"complianceNoData": "Brak danych o przestrzeganiu",
"generate": "Wygeneruj nową analizę",
"moodSectionTitle": "Nastrój",
"confidence": "Pewność",
"cached": "Z pamięci podręcznej",
"emptyState": {
"bloodPressure": {
"title": "Brak pomiarów ciśnienia krwi",
@@ -3037,11 +2939,6 @@
"title": "Brak treningów",
"description": "Otwórz aplikację Apple Zdrowie na iPhonie, aby zsynchronizować treningi. Import aktywności z Withings także dorzuca marsze i przejażdżki."
},
"row": {
"duration": "Czas trwania",
"distance": "Dystans",
"energy": "Energia czynna"
},
"detail": {
"backToList": "Powrót do treningów",
"statsTitle": "Statystyki",
@@ -3054,13 +2951,9 @@
"statsStepCount": "Kroki",
"statsElevation": "Przewyższenie",
"statsPace": "Tempo",
"statsSource": "Źródło",
"routeTitle": "Trasa",
"routeUnavailable": "Dla tego treningu nie zapisano trasy GPS.",
"hrChartTitle": "Tętno",
"hrChartUnavailable": "Próbki tętna co sekundę nie są zapisywane dla tego treningu.",
"notFound": "Nie znaleziono treningu.",
"loading": "Wczytywanie treningu…",
"hrFromPulse": "Na podstawie Twoich danych tętna z okolic tej sesji",
"hrAxisMinutes": "min",
"sportAverageLabel": "Twoja ostatnia średnia:",
@@ -3532,7 +3425,6 @@
"title": "Co się wyróżnia",
"subheading": "Wykryte relacje",
"footer": "Wyłonione z {tested} par dzień po dniu testowanych na wszystkich Twoich wskaźnikach, po korekcie fałszywych odkryć.",
"empty": "Żadna relacja nastroju nie przekroczyła jeszcze progu statystycznego. Notuj dalej.",
"disclaimer": "Przefiltrowane statystycznie, ale wciąż obserwacyjne — wzorzec warty uwagi, nie przyczyna.",
"moodUp": "Wyższy {factor} zwykle wiąże się z lepszym nastrojem następnego dnia w Twoich danych.",
"moodDown": "Wyższy {factor} zwykle wiąże się z gorszym nastrojem następnego dnia w Twoich danych.",
@@ -3911,8 +3803,6 @@
},
"briefing": {
"memory": {
"recall": "Wcześniej: {text}",
"forward": "Patrząc w przyszłość: {text}",
"forwardWatch": "Warto mieć na oku {metric}.",
"forwardHolding": "Na razie wszystko jest stabilne."
}
@@ -3926,9 +3816,7 @@
}
},
"streak": {
"returnLabel": "Twój {metric} wrócił do twojego zwykłego zakresu po {count} dniach — cokolwiek to było, minęło.",
"daysInside": "{count} dni w zakresie",
"daysOutside": "{count} dni poza zakresem"
"returnLabel": "Twój {metric} wrócił do twojego zwykłego zakresu po {count} dniach — cokolwiek to było, minęło."
},
"updatedTodayAt": "Zaktualizowano dzisiaj, {time}",
"updatedYesterday": "Zaktualizowano wczoraj",
@@ -4229,6 +4117,7 @@
"displayNamePlaceholder": "Jak HealthLog ma się do Ciebie zwracać?",
"displayNameHint": "Pojawia się w powitaniu na pulpicie i w raportach.",
"heightLabel": "Wzrost (cm)",
"heightLabelFtIn": "Wzrost (ft, in)",
"genderLabel": "Płeć",
"genderNone": "Wolę nie podawać",
"genderMale": "Mężczyzna",
@@ -4556,10 +4445,6 @@
"passphraseHelp": "Wyprowadza klucz archiwum za pomocą Argon2id i pieczętuje je AES-256-GCM. Zapisz plik z rozszerzeniem .hlx.",
"noRecoveryWarning": "Nie ma odzyskiwania — jeśli zgubisz tę frazę, archiwum nie da się otworzyć. Przechowuj ją bezpiecznie.",
"stepUpNeeded": "Potwierdź drugi składnik i spróbuj ponownie."
},
"cycle": {
"title": "Cykl",
"description": "Dane cyklu i zdrowia reprodukcyjnego jako dokument FHIR: ostatnia miesiączka, średnia długość cyklu i miesiączki, bieżąca faza."
}
},
"import": {
@@ -4581,6 +4466,7 @@
"failed": "Import nie powiódł się.",
"rowsImported": "Zaimportowano dotąd {count} wierszy",
"doneSummary": "Gotowe — zaimportowano {imported} z {read} rekordów, pominięto {skipped} rekordów klinicznych.",
"doneNothing": "Z tego archiwum nic nie zaimportowano: dotarło 0 z {read} rekordów.",
"estimateWarning": "Łączne wartości dla {count} dni oszacowano na podstawie największego źródła w eksporcie. Natywne statystyki Apple Health pozostają nadrzędne.",
"phase": {
"queued": "W kolejce…",
@@ -4604,7 +4490,10 @@
"rateLimited": "Zbyt wiele importów w tej godzinie (limit 5). Spróbuj później.",
"failed": "Import nie powiódł się. Spróbuj ponownie.",
"readFailed": "Nie można odczytać tego pliku.",
"resultSummary": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju, pominięto {skipped}."
"resultSummary": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju, pominięto {skipped}.",
"resultSuccess": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju.",
"resultNothing": "Nic nie zaimportowano. Pominięto wszystkie {skipped} wpisów.",
"resultEmpty": "Ten plik nie zawierał pomiarów ani wpisów nastroju."
},
"csv": {
"title": "Plik CSV",
@@ -4625,7 +4514,30 @@
"readFailed": "Nie udało się odczytać tego pliku.",
"previewSummary": "Podgląd: {inserted} zostanie zaimportowanych, pominięto {skipped}.",
"resultSummary": "Zaimportowano {inserted} nowych i zaktualizowano {updated}, pominięto {skipped}.",
"rowError": "Wiersz {line}: {reason}"
"rowError": "Wiersz {line}: {reason}",
"resultSuccess": "Zaimportowano {inserted} nowych i zaktualizowano {updated}.",
"resultNothing": "Nic nie zaimportowano. Pominięto wszystkie {skipped} wierszy.",
"resultEmpty": "Ten plik ma nagłówek, ale nie zawiera wierszy danych.",
"previewSuccess": "Podgląd: {inserted} zostanie zaimportowanych.",
"previewNothing": "Podgląd: nic nie zostanie zaimportowane. Pominięte zostaną wszystkie {skipped} wierszy.",
"skipGroupOne": "Pominięto {count} wiersz: {reason}",
"skipGroupFew": "Pominięto {count} wiersze: {reason}",
"skipGroupOther": "Pominięto {count} wierszy: {reason}",
"skipDetails": "Pokaż wszystkie pominięte wiersze",
"reasonUnknownType": "nieznany typ pomiaru",
"reasonInvalidValue": "wartość nie jest liczbą",
"reasonValueOutOfRange": "wartość poza wiarygodnym zakresem",
"reasonUnknownUnit": "jednostka nierozpoznana dla tego typu",
"reasonMissingTimezoneOffset": "znacznik czasu bez przesunięcia strefy czasowej",
"reasonInvalidTimestamp": "nie udało się odczytać znacznika czasu",
"reasonImplausibleTimestamp": "znacznik czasu w przyszłości lub sprzed 1900 roku",
"reasonUnexpectedGlucoseContext": "kontekst glukozy w wierszu, który nie jest pomiarem glukozy",
"reasonInvalidGlucoseContext": "kontekst glukozy nie jest dozwoloną wartością",
"reasonNotesTooLong": "notatka jest zbyt długa",
"reasonExternalIdTooLong": "identyfikator zewnętrzny jest zbyt długi",
"reasonUnstableExternalId": "identyfikator zewnętrzny nie jest stabilny między eksportami",
"reasonDuplicate": "już zaimportowano",
"reasonUnknown": "pominięto"
}
},
"subtitle": "Wyeksportuj dane lub zaimportuj z kopii zapasowej."
@@ -4776,6 +4688,7 @@
},
"username": "Nazwa użytkownika",
"height": "Wzrost (cm)",
"heightFtIn": "Wzrost (ft, in)",
"dateOfBirth": "Data urodzenia",
"dateOfBirthHint": "Używane do automatycznego obliczania docelowych wartości ciśnienia.",
"gender": "Płeć",
@@ -4809,17 +4722,10 @@
"passwordMismatch": "Nowe hasła nie są zgodne",
"passwordUpdated": "Hasło zaktualizowane pomyślnie",
"passkeys": "Passkeys",
"registeredPasskeys": "Zarejestrowane klucze dostępu",
"passkeysDescription": "Klucze dostępu umożliwiają bezpieczne logowanie bez hasła.",
"passkeyName": "Nazwa",
"passkeyDevice": "Urządzenie",
"passkeyBackup": "Backup",
"passkeyCreated": "Utworzono",
"passkeyActions": "Akcje",
"noPasskeys": "Brak zarejestrowanych kluczy dostępu.",
"addPasskey": "Dodaj klucz dostępu",
"passkeyAdded": "Klucz dostępu dodany pomyślnie!",
"passkeyOptionsError": "Nie udało się wczytać opcji klucza dostępu",
"passkeyRegistrationFailed": "Rejestracja klucza dostępu nie powiodła się",
"passkeyRegistrationCancelled": "Rejestracja klucza dostępu anulowana",
"passkeyNotSupported": "Twoja przeglądarka lub urządzenie nie obsługuje jeszcze kluczy dostępu. Wypróbuj inną przeglądarkę lub urządzenie.",
@@ -4868,7 +4774,6 @@
"webPushSubscribed": "Powiadomienia push włączone!",
"webPushUnsubscribed": "Powiadomienia push wyłączone.",
"webPushSubscribeFailed": "Aktywacja nie powiodła się",
"webPushActive": "Aktywne",
"kiInsightsDescription": "Opcjonalnie: zapisz swój klucz OpenAI, aby uzyskać codzienne, automatyczne analizy w Insights.",
"codexConnected": "ChatGPT połączony pomyślnie! Insights jest teraz aktywny.",
"codexDisconnected": "Połączenie z ChatGPT usunięte.",
@@ -4906,7 +4811,6 @@
"savedShort": "(zapisano)",
"saveCta": "Zapisz",
"saved": "Zapisano",
"saveFailed": "Zapis nie powiódł się",
"errorGeneric": "Błąd",
"providerChain": {
"types": {
@@ -5042,11 +4946,9 @@
"hint": "Przekazywane Coachowi i codziennemu briefingowi jako dodatkowy kontekst. Zostaw puste, aby nic nie wysyłać.",
"profileHint": "Wiek i płeć pochodzą z Twojego profilu i są dołączane automatycznie.",
"freeTextLabel": "O mnie (tekst dowolny)",
"conditionsLabel": "Choroby przewlekłe",
"conditionsPlaceholder": "Np. nadciśnienie, cukrzyca typu 2…",
"allergiesLabel": "Alergie i nietolerancje",
"allergiesPlaceholder": "Np. nietolerancja laktozy, alergia na penicylinę…",
"focusLabel": "Na co Coach ma zwracać uwagę?",
"focusPlaceholder": "Np. pilnuj mojego ciśnienia, motywuj mnie do ruchu…",
"completeness": "Wypełnione: {answered} z {total}",
"questionsTitle": "Coach ma pytania",
@@ -5124,8 +5026,6 @@
"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."
},
"integrationsSourcesHint": "Gdy kilka źródeł dostarcza tę samą metrykę, priorytet źródeł decyduje, która wartość się liczy.",
"integrationsSourcesHintLink": "Otwórz priorytet źródeł",
"withingsSynchronize": "Synchronizuj",
"withingsDisconnect": "Rozłącz",
"withingsDisconnectTitle": "Rozłączyć Withings?",
@@ -5255,7 +5155,7 @@
"integrationPill": {
"connected": "Połączono",
"errorReconnect": "Błąd — połącz ponownie",
"warningServerError": "Połączono, błąd serwera",
"warningServerError": "Połączono, problem z synchronizacją",
"parkedReconnect": "Wstrzymane — połącz ręcznie",
"notConnected": "Nie połączono",
"justNow": "przed chwilą",
@@ -5265,8 +5165,26 @@
"ariaLabel": "Status integracji",
"resumeCta": "Połącz ponownie",
"resumeSuccess": "Integracja wznowiona",
"resumeError": "Ponowne połączenie nieudane"
"resumeError": "Ponowne połączenie nieudane",
"stale": "Połączono, brak nowych danych",
"stalled": "Synchronizacja zatrzymana",
"pendingSetup": "Czekam na pierwsze dane"
},
"integrationFreshness": {
"title": "Odebrane dane",
"summary": "{count} typów danych · najnowsze {relative}",
"quiet": "{count} bez danych",
"workouts": "Treningi"
},
"integrationFallback": {
"description": "Skonfigurowane na tym koncie, ale bez własnej karty ustawień. Ten wiersz istnieje po to, by przerwane połączenie nie pozostało niezauważone.",
"lastAttempt": "Ostatnia próba {relative}",
"noAttempt": "Brak zapisanej próby synchronizacji"
},
"withingsViewData": "Zobacz swoje pomiary",
"whoopViewData": "Zobacz swoją regenerację",
"fitbitViewData": "Zobacz swój sen",
"googleHealthViewData": "Zobacz swoje kroki",
"apiTokens": "Tokeny API",
"apiTokensDescription": "Przeglądaj i unieważniaj tokeny API do zewnętrznego rejestrowania przyjęć leków. Tokeny są wydawane dla pojedynczego leku z jego przełącznika punktu końcowego API.",
"tokenMintMovedDescription": "Tokeny są wydawane dla pojedynczego leku. Otwórz lek i włącz jego punkt końcowy API, aby otrzymać token obejmujący wyłącznie ten lek.",
@@ -5298,10 +5216,7 @@
"apiEndpointPath": "Ścieżka",
"apiEndpointAuth": "Uwierzytelnianie",
"apiEndpointExample": "Przykład treści",
"collapse": "Zwiń",
"expand": "Rozwiń",
"dangerZone": "Usuń wszystkie dane",
"dangerZoneTitle": "Strefa zagrożenia",
"dangerZoneDescription": "Usuwa wszystkie Twoje dane zdrowotne i integracje. Twoje konto użytkownika zostanie zachowane.",
"dangerZoneConfirm": "Usunąć wszystkie dane?",
"dangerZoneConfirmDescription": "Spowoduje to trwałe usunięcie wszystkich Twoich danych zdrowotnych i integracji. Twoje konto użytkownika zostanie zachowane.",
@@ -5318,31 +5233,9 @@
"deleteAccountFinal": "Usuń trwale",
"saved": "Zapisano",
"savingError": "Błąd podczas zapisywania",
"moodLogTitle": "moodLog",
"moodLogDescription": "Import mood data from moodLog",
"moodLogDeprecated": "Wycofane: nastrój jest teraz śledzony natywnie w HealthLog z tagami i czynnikami. Ta integracja nadal działa, ale zostanie usunięta w przyszłej wersji.",
"moodLogUrl": "moodLog URL",
"moodLogUrlPlaceholder": "https://mood.example.com",
"moodLogApiKey": "Klucz API",
"moodLogApiKeyPlaceholder": "ml_...",
"moodLogWebhookSecret": "Sekret webhooka",
"moodLogWebhookSecretHelp": "Wprowadź ten sekret w moodLog jako sekret webhooka",
"moodLogSync": "Synchronizuj teraz",
"moodLogFullSync": "Synchronizuj wszystkie dane",
"moodLogFullSyncConfirm": "Synchronizuj",
"moodLogFullSyncTitle": "Pełna synchronizacja?",
"moodLogFullSyncDescription": "Wszystkie dostępne dane o nastroju zostaną w pełni zsynchronizowane.",
"moodLogDisconnect": "Rozłącz",
"moodLogDisconnectTitle": "Rozłączyć moodLog?",
"moodLogDisconnectDescription": "Połączenie zostanie usunięte, a wszystkie zaimportowane dane o nastroju zostaną skasowane.",
"moodLogEntries": "Wpisy",
"moodLogSyncResult": "{count} wpisów zsynchronizowano",
"moodLogSyncFailed": "Synchronizacja nie powiodła się",
"moodLogDisconnected": "moodLog rozłączony",
"about": {
"version": "Wersja aplikacji",
"gitSha": "Build",
"builtAt": "Skompilowano {time}",
"builtAtLabel": "Skompilowano",
"license": "Licencja",
"repository": "Kod źródłowy",
@@ -5414,34 +5307,17 @@
"practiceName": "Gabinet (opcjonalnie)",
"practiceNamePlaceholder": "np. dr Kowalski",
"includedData": "Uwzględnione dane",
"groupVitals": "Parametry życiowe",
"groupCardio": "Serce i kondycja",
"groupActivity": "Aktywność i sen",
"weight": "Waga",
"bp": "Ciśnienie krwi",
"pulse": "Tętno",
"oxygenSaturation": "Saturacja tlenem",
"bodyFat": "Tkanka tłuszczowa",
"bodyComposition": "Skład ciała",
"restingHeartRate": "Tętno spoczynkowe",
"hrv": "Zmienność rytmu serca",
"vo2max": "VO₂max",
"steps": "Kroki",
"distance": "Dystans",
"sleep": "Sen",
"glucose": "Glukoza we krwi",
"medications": "Leki i przestrzeganie zaleceń",
"mood": "Nastrój (domyślnie wyłączony — dane wrażliwe)",
"bmi": "BMI",
"includeCharts": "Osadź wykresy trendów (tylko PDF)",
"includeAiSummary": "Dołącz podsumowanie AI (niezweryfikowane klinicznie)",
"fhirNote": "Dane ustrukturyzowane są zgodne ze standardem FHIR R4 (kompatybilne z ePA).",
"generate": "Generuj",
"error": "Eksport nie powiódł się ({code}).",
"labs": "Wyniki badań",
"allergies": "Alergie i nietolerancje",
"familyHistory": "Wywiad rodzinny",
"documentsLink": "Dokumenty z tego okresu"
"documentsLink": "Dokumenty z tego okresu",
"range30": "Ostatnie 30 dni",
"rangeCustom": "Własny okres",
"rangeCustomStart": "Od",
"rangeCustomEnd": "Do",
"errorGeneric": "Nie udało się wygenerować eksportu. Spróbuj ponownie.",
"errorRateLimit": "Osiągnięto limit eksportów w tej godzinie.",
"errorModuleDisabled": "Eksport dokumentacji jest wyłączony dla tego konta."
},
"globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji",
"globalExcludedInjectionSitesHint": "Miejsca wymienione tutaj nie są proponowane dla żadnego leku i są odrzucane przy rejestrowaniu dawki, nawet jeśli lek je preferuje.",
@@ -5455,13 +5331,8 @@
"expiry": "Wygasa za (dni)",
"expiryHint": "Najwyżej {max} dni.",
"expiryInvalid": "Wygaśnięcie musi mieścić się między 1 a {max} dniami.",
"fhirApi": "Zezwól na ograniczone API FHIR",
"fhirApiHint": "Pozwól, aby link udostępniał punkt końcowy FHIR tylko do odczytu obok widoku strony.",
"resourceTypes": "Typy zasobów FHIR",
"create": "Utwórz link",
"tokenCreated": "Link udostępniania utworzony",
"tokenOnce": "Skopiuj ten link teraz — token jest pokazywany tylko raz i nie można go odzyskać.",
"copy": "Kopiuj link",
"copied": "Skopiowano do schowka.",
"activeTitle": "Aktywne linki",
"noActive": "Brak aktywnych linków udostępniania.",
@@ -5475,7 +5346,6 @@
"statusExpired": "Wygasły",
"inactiveTitle": "Unieważnione i wygasłe ({count})",
"shownOnce": "Wyświetlane tylko raz. Zapisz teraz kod QR i hasło — nie można ich później odzyskać.",
"qrLabel": "Kod QR",
"qrAlt": "Kod QR zawierający link do udostępniania i hasło",
"linkLabel": "Link",
"passphraseLabel": "Hasło",
@@ -5498,7 +5368,12 @@
"pickerEmpty": "Nie znaleziono dokumentów.",
"pickerDone": "Gotowe",
"pickerSelectedCount": "Wybrano {count} z {max}",
"pickerMaxReached": "Osiągnięto maksymalną liczbę {max} dokumentów."
"pickerMaxReached": "Osiągnięto maksymalną liczbę {max} dokumentów.",
"scopeTitle": "Co zawiera ten link",
"scopeInsuranceExcluded": "Dane ubezpieczenia nigdy nie są udostępniane przez link.",
"statusNeedsReselect": "Wybierz zawartość ponownie",
"reselectReason": "Ten link udostępniał zakres, którego nikt nie wybrał, więc został zamknięty. Utwórz nowy i wybierz jego zawartość.",
"reselectAction": "Utwórz zamiennik"
},
"genderOther": "Inna",
"cycleTracking": {
@@ -5525,13 +5400,15 @@
"nightscoutUpdate": "Zapisz zmiany",
"nightscoutConnected": "Nightscout połączony.",
"nightscoutConnectFailed": "Nie można połączyć z Nightscout.",
"nightscoutSync": "Synchronizuj teraz",
"nightscoutSyncResult": "Zsynchronizowano {count} pomiarów",
"nightscoutSyncFailed": "Synchronizacja nie powiodła się",
"nightscoutDisconnect": "Rozłącz",
"nightscoutDisconnectTitle": "Rozłączyć Nightscout?",
"nightscoutDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty glukozy pozostaną w historii.",
"polar": "Polar",
"polarDescription": "Połącz konto Polar, aby synchronizować regenerację Nightly Recharge, fazy snu i dzienną aktywność.",
"polarDescription": "Połącz konto Polar, aby synchronizować regenerację Nightly Recharge, fazy snu, dzienną aktywność i treningi.",
"polarViewData": "Zobacz sen i regenerację",
"polarHelp": "Zaloguj się w Polar, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.",
"polarCredentials": "Dane API",
"polarClientId": "Client ID",
"polarClientSecret": "Client Secret",
@@ -5540,6 +5417,9 @@
"polarSaveCredentials": "Zapisz",
"polarUnavailable": "Polar nie jest skonfigurowany na tym serwerze. Dodaj powyżej własne dane OAuth Polar lub poproś administratora o skonfigurowanie współdzielonej aplikacji.",
"polarConnect": "Połącz Polar",
"polarSync": "Synchronizuj teraz",
"polarSyncResult": "Zsynchronizowano {count} pomiarów",
"polarSyncFailed": "Synchronizacja nie powiodła się",
"polarDisconnect": "Rozłącz",
"polarDisconnectTitle": "Rozłączyć Polar?",
"polarDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty pozostaną w historii.",
@@ -5558,7 +5438,6 @@
"oura": "Oura",
"ouraDescription": "Połącz konto Oura, aby synchronizować gotowość, fazy snu, zmienność rytmu serca i dzienną aktywność.",
"ouraViewData": "Zobacz sen i regenerację",
"ouraHelp": "Zaloguj się w Oura, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.",
"ouraCredentials": "Dane API",
"ouraClientId": "Client ID",
"ouraClientSecret": "Client Secret",
@@ -5567,6 +5446,9 @@
"ouraSaveCredentials": "Zapisz",
"ouraUnavailable": "Oura nie jest skonfigurowana na tym serwerze. Dodaj powyżej własne dane OAuth Oura lub poproś administratora o skonfigurowanie współdzielonej aplikacji.",
"ouraConnect": "Połącz Oura",
"ouraSync": "Synchronizuj teraz",
"ouraSyncResult": "Zsynchronizowano {count} pomiarów",
"ouraSyncFailed": "Synchronizacja nie powiodła się",
"ouraDisconnect": "Rozłącz",
"ouraDisconnectTitle": "Rozłączyć Oura?",
"ouraDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty pozostaną w historii.",
@@ -5795,6 +5677,9 @@
"stravaSaveCredentials": "Zapisz",
"stravaUnavailable": "Strava nie jest skonfigurowana na tym serwerze. Dodaj powyżej własne dane API Strava lub poproś administratora o skonfigurowanie wspólnej aplikacji.",
"stravaConnect": "Połącz Strava",
"stravaSync": "Synchronizuj teraz",
"stravaSyncResult": "Zsynchronizowano {count} pomiarów",
"stravaSyncFailed": "Synchronizacja nie powiodła się",
"stravaDisconnect": "Rozłącz",
"stravaDisconnectTitle": "Rozłączyć Strava?",
"stravaDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane aktywności pozostaną w historii.",
@@ -5814,17 +5699,14 @@
"title": "Apple Health",
"description": "Synchronizacja na żywo odbywa się przez aplikację HealthLog na iOS, a nie przez połączenie internetowe ani OAuth.",
"status": {
"checking": "Sprawdzanie danych Apple Health…",
"dataReceived": "Odebrano dane Apple Health",
"setup": "Skonfiguruj w aplikacji iOS",
"unavailable": "Stan Apple Health jest niedostępny"
},
"lastDataLabel": "Najnowsze dane Apple Health:",
"setupTitle": "Włącz synchronizację na żywo",
"permissionStep": "W aplikacji HealthLog na iOS otwórz Ustawienia → Apple Health i zezwól na udostępnianie wybranych kategorii zdrowotnych.",
"backgroundStep": "Pozostaw włączone odświeżanie aplikacji w tle. Aplikacja synchronizuje nowe dane automatycznie na pierwszym planie oraz gdy iOS przydzieli jej czas w tle.",
"importNote": "Potrzebujesz starszej historii lub nie możesz użyć aplikacji iOS? Przesłanie pliku XML/ZIP z Apple Health jest jednorazową alternatywą i nie włącza synchronizacji na żywo.",
"importAction": "Otwórz jednorazowy import"
"importAction": "Otwórz jednorazowy import",
"staleNote": "Od ponad tygodnia nie napłynęły żadne dane z Apple Health. Otwórz aplikację HealthLog na iPhonie i sprawdź uprawnienia Apple Health."
},
"garminInfo": {
"title": "Garmin",
@@ -5933,7 +5815,6 @@
"telegramGlobal": "Zezwól na powiadomienia Telegram.",
"ntfyGlobal": "Zezwól na powiadomienia ntfy.",
"webPushGlobal": "Zezwól na powiadomienia push przeglądarki.",
"moodLogGlobal": "Zezwól na przypomnienia dziennika nastroju.",
"integrationWebPush": "Web Push",
"offlineGeoLabel": "Dane geograficzne",
"offlineGeoEnabled": "Offline (GeoLite2)",
@@ -7028,7 +6909,6 @@
"period": "Okres raportu: {start} – {end}",
"expires": "Ten link wygasa dnia {date}.",
"provenance": "To jest tylko do odczytu podsumowanie udostępnione przez osobę, do której należy, wyeksportowane z jej samodzielnie rejestrowanych danych HealthLog. Wartości są zgłaszane samodzielnie i mogą obejmować odczyty zsynchronizowane z urządzeń; należy je traktować jako zgłoszone przez pacjenta, a nie jako badanie kliniczne.",
"vitals": "Parametry życiowe",
"labs": "Wyniki laboratoryjne",
"medications": "Leki i przestrzeganie zaleceń",
"bmi": "Wskaźnik masy ciała (BMI)",
@@ -7068,7 +6948,10 @@
"download": "Pobierz",
"attachmentHint": "Ten plik zostanie pobrany na Twoje urządzenie (bez podglądu na stronie).",
"exifNote": "Metadane aparatu (EXIF/GPS) zostały usunięte z udostępnionych obrazów JPEG, PNG i WebP."
}
},
"downloadPdf": "Pobierz jako PDF",
"downloadFhir": "Pobierz jako FHIR",
"bmiSection": "Wskaźnik masy ciała"
},
"cycle": {
"symptomCategory": {
@@ -7447,7 +7330,6 @@
"measurementReminders": {
"sectionTitle": "Badania",
"sectionDescription": "Przypomnienia, co, kiedy i gdzie zmierzyć lub sprawdzić.",
"addButton": "Dodaj przypomnienie",
"markDone": "Gotowe",
"delete": "Usuń",
"deleteConfirmTitle": "Usunąć przypomnienie?",
@@ -8976,5 +8858,42 @@
}
}
}
},
"reportSelection": {
"groupIdentity": "Dane osobowe",
"groupVitals": "Parametry życiowe",
"groupCardio": "Serce i krążenie",
"groupBody": "Wymiary ciała",
"groupSleepRecovery": "Sen i regeneracja",
"groupActivity": "Aktywność",
"groupMobility": "Mobilność i chód",
"groupEnvironment": "Otoczenie",
"groupGlucose": "Poziom glukozy",
"groupLabs": "Wyniki badań",
"groupMedications": "Leki",
"groupHistory": "Choroby i alergie",
"groupSensitive": "Dane wrażliwe",
"leafPatientIdentity": "Imię, data urodzenia, płeć, wzrost",
"leafInsurance": "Ubezpieczyciel i numer ubezpieczenia",
"leafGlucosePanel": "Panel glukozy (czas w zakresie, GMI, zmienność)",
"leafLabResults": "Wyniki badań",
"leafMedicationList": "Lista leków",
"leafMedicationAdministrations": "Każda zarejestrowana dawka",
"leafMedicationCompliance": "Przestrzeganie zaleceń",
"leafGlp1Therapy": "Terapia GLP-1 (historia dawek, działania niepożądane)",
"leafAllergies": "Alergie i nietolerancje",
"leafIllnessEpisodes": "Epizody chorobowe",
"leafFamilyHistory": "Wywiad rodzinny",
"leafMood": "Nastrój",
"leafCycle": "Cykl menstruacyjny",
"templateStandard": "Standardowy raport lekarski",
"applyStandard": "Zastosuj {template}",
"standardHint": "Jedno kliknięcie wypełnia to, czego zwykle wymaga wizyta u lekarza rodzinnego. Każdą pozycję można potem zmienić.",
"groupCount": "{on}/{total}",
"groupDisclosure": "Pokaż pozycje w {group}",
"sensitiveHint": "Nic tu nie jest wstępnie wybrane. Wybierz każdą pozycję samodzielnie.",
"scopeNone": "Nic jeszcze nie wybrano. Wybierz, co ma się znaleźć w dokumencie, albo zastosuj raport standardowy.",
"scopeCount": "{count} pozycji z {groups} obszarów",
"scopeSensitive": "w tym {names}"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.32.6",
"version": "1.32.39",
"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,14 @@
-- v1.32.11 — native web-handoff login (iOS #65).
--
-- The `oidc_native_handoffs` table now backs two flows: the original OIDC SSO
-- native leg and the first-party web-handoff login a self-hoster's iOS app runs
-- against its own domain. Both mint the same single-use, PKCE-locked, hashed-at-
-- rest handoff code through the shared core; the `flow` column records which
-- flow minted a row so the consume path can refuse a cross-flow redemption
-- (an `oidc` code presented at the web-login token route, or vice versa).
--
-- Additive and backfill-safe: every existing row is an OIDC handoff, so the
-- column defaults to 'oidc' and no data migration is needed.
ALTER TABLE "oidc_native_handoffs"
ADD COLUMN "flow" TEXT NOT NULL DEFAULT 'oidc';
@@ -0,0 +1,18 @@
-- v1.32.12 — mood rollup timezone re-key.
--
-- The mood rollup writer used to bucket on the UTC truncation of
-- `mood_logged_at`, so a mood logged late in the evening (or after
-- midnight) in a non-UTC timezone landed on the wrong calendar day.
-- The writer now keys every bucket on the canonical per-row
-- `mood_entries."date"` label instead, byte-identical to the live
-- fallback and to the day the user logged the mood in their own zone.
--
-- Data-only migration: no schema change. `MoodEntryRollup` is a derived
-- cache, fully reconstructible from `mood_entries` at any time. Emptying
-- the table drops every stale UTC-keyed row; the app then serves correct
-- data through the live `date`-keyed fallback until the rollups are
-- rebuilt, and the boot-time full-backfill worker
-- (`enqueueBootTimeMoodRollupBackfill` → delete-then-refold) re-mints
-- every mood-owning user's buckets under the new keying. No mood data is
-- touched.
DELETE FROM "mood_entry_rollups";
@@ -0,0 +1,4 @@
-- One-shot Fitbit sleep duplicate repair (stable per-segment sleep keys).
-- Null = repair pending; stamped once the boot-time bounded sleep re-read
-- completes for the connection.
ALTER TABLE "fitbit_connections" ADD COLUMN "sleep_repaired_at" TIMESTAMP(3);
@@ -0,0 +1,64 @@
-- v1.32.33 — retire the moodLog integration.
--
-- The bridge to the external moodLog service is gone: no pull cron, no
-- inbound webhook, no reverse push, no settings or admin surface. What this
-- migration removes is the integration's own state and nothing else.
--
-- DATA BOUNDARY — read before editing.
--
-- `mood_entries` is NOT touched. Mood is a first-class HealthLog module and
-- every row in that table is the user's own health record, including the rows
-- the bridge imported (they carry `source = 'MOODLOG'` and keep it, so the
-- mood list still labels them honestly). Nothing below names `mood_entries`,
-- and nothing below can reach it:
--
-- * The six dropped columns are plain scalars on `users` / `app_settings`.
-- None carries an index, a unique, a check or a foreign key (see
-- migration 0014, which added all six), so `DROP COLUMN` rewrites those
-- two tables' tuples and stops there. `DROP COLUMN` never cascades into
-- another table on its own, and there is no CASCADE keyword here.
-- * `mood_entries.user_id` references `users(id)`, not any dropped column.
-- Dropping a non-referenced column leaves that foreign key intact and
-- leaves every mood row addressable.
-- * The `integration_statuses` delete is scoped by `integration = 'moodlog'`
-- — sync-ledger bookkeeping for a provider that no longer exists. That
-- table holds no health values and nothing references it.
--
-- The pg-boss cleanup is guarded on the schema existing: the worker owns that
-- schema and creates it at boot, so a first-install database runs this
-- migration before `pgboss` exists. Without it, a live instance would keep an
-- hourly `moodlog-sync` schedule enqueuing work no handler drains.
-- Stop the retired cron and drain anything it already queued.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'pgboss' AND table_name = 'schedule'
) THEN
DELETE FROM pgboss.schedule WHERE name = 'moodlog-sync';
END IF;
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'pgboss' AND table_name = 'job'
) THEN
DELETE FROM pgboss.job WHERE name = 'moodlog-sync';
END IF;
END
$$;
-- Sync-ledger row for the retired provider.
DELETE FROM "integration_statuses" WHERE "integration" = 'moodlog';
-- Per-user credentials and the enable flag.
ALTER TABLE "users"
DROP COLUMN IF EXISTS "mood_log_url_encrypted",
DROP COLUMN IF EXISTS "mood_log_api_key_encrypted",
DROP COLUMN IF EXISTS "mood_log_enabled",
DROP COLUMN IF EXISTS "mood_log_last_synced_at",
DROP COLUMN IF EXISTS "mood_log_webhook_secret";
-- Instance-wide kill switch for the retired integration.
ALTER TABLE "app_settings"
DROP COLUMN IF EXISTS "mood_log_global";
@@ -0,0 +1,36 @@
-- v1.32.35 — drop the two write-only aggregation columns on `measurements`.
--
-- Migration 0263 added three columns alongside the Apple Health `stats:*`
-- authority ladder. Only one of them was ever read back:
--
-- * `aggregation_provenance` is load-bearing and STAYS. The external
-- measurement reconciler selects it and refuses a lower-authority write
-- against a higher-authority canonical row, which is what keeps an
-- export.xml source-day estimate from clobbering a native HealthKit
-- statistic. The cumulative drain reads it too.
-- * `aggregation_contributor_count` and `aggregation_selected_source_hash`
-- are written and never read. Three call sites write them (the batch
-- ingest and the cumulative drain write explicit NULLs, the export.xml
-- import writes real values); nothing selects them, no raw SQL names
-- them, they reach no export, no backup, no API contract and no repair
-- path. Verified by a repo-wide search over every tracked file for both
-- the Prisma field names and the column names.
--
-- The import-time selection algorithm keeps its deterministic tiebreak: the
-- source hash it compares is an in-memory map key computed per flush, not a
-- value read back from this table. Only the persisted copies go.
--
-- DATA BOUNDARY — read before editing.
--
-- Both columns are plain nullable scalars added by 0263 with no index, no
-- unique, no check and no foreign key, so `DROP COLUMN` rewrites the tuples of
-- `measurements` and stops there. `DROP COLUMN` never cascades into another
-- table on its own, and there is no CASCADE keyword here. No measured value,
-- timestamp, unit, note or identity field is touched, and the documented
-- LEGACY_UNKNOWN repair path operates on `aggregation_provenance`, which
-- survives. In Postgres this is a catalog-only operation, so it is cheap even
-- on the largest table in the schema.
ALTER TABLE "measurements"
DROP COLUMN IF EXISTS "aggregation_contributor_count",
DROP COLUMN IF EXISTS "aggregation_selected_source_hash";
@@ -0,0 +1,47 @@
-- Scope the medication-intake idempotency key per user.
--
-- `medication_intake_events.idempotency_key` has carried a GLOBAL unique
-- index since 0001_init. The key is client-supplied, so a global unique
-- means one user's key can block another user's: the second user's write
-- is refused by the constraint and the dose is silently lost, and the
-- bulk route's unscoped replay probe resolves the key to the FIRST user's
-- row and hands its id back to the second user.
--
-- The correct grain is per user, matching every other client-supplied
-- identity on this table (`(user_id, external_id)`, migration 0239).
--
-- On existing data this is a RELAXATION: the old constraint is strictly
-- stronger than the new one — if no two rows anywhere share a key, then
-- no two rows of the SAME user share one either — so no row can violate
-- the new index and nothing needs reconciling. Step 1 below is a safety
-- net for a database whose global index was dropped by hand at some
-- point; against an intact schema it matches zero rows.
--
-- Step 1 never deletes or rewrites a dose. It only clears the dedup HINT
-- on the strictly later row of a colliding pair, keeping every intake
-- event, its timestamps, its attribution and its inventory stamp intact.
-- The cost is that one row loses replay protection it never really had.
-- 1. Safety net: break any pre-existing same-user key collision by
-- clearing the key on every row but the earliest of its group.
UPDATE "medication_intake_events" AS e
SET "idempotency_key" = NULL
WHERE e."idempotency_key" IS NOT NULL
AND EXISTS (
SELECT 1
FROM "medication_intake_events" AS earlier
WHERE earlier."user_id" = e."user_id"
AND earlier."idempotency_key" = e."idempotency_key"
AND (
earlier."created_at" < e."created_at"
OR (earlier."created_at" = e."created_at" AND earlier."id" < e."id")
)
);
-- 2. Create the per-user unique BEFORE dropping the global one, so a
-- failure here aborts the migration with the old protection intact.
CREATE UNIQUE INDEX "medication_intake_events_user_id_idempotency_key_key"
ON "medication_intake_events" ("user_id", "idempotency_key");
-- 3. Retire the global unique.
DROP INDEX IF EXISTS "medication_intake_events_idempotency_key_key";
@@ -0,0 +1,31 @@
-- v1.32.x — a blood-glucose reading may carry no context.
--
-- Migration 0021 introduced `measurements.glucose_context` with a CHECK that
-- read both ways at once: a BLOOD_GLUCOSE row MUST carry a context, and every
-- other type MUST NOT. The second half is right and stays. The first half was
-- written when the only glucose writer was the manual entry form, where a
-- person picks fasting / post-meal / random / bedtime at the moment of the
-- reading.
--
-- Every machine writer that arrived later has no such classification per
-- sample: a continuous sensor export, the Nightscout SGV pull, the MCP
-- `log_measurement` tool and the Telegram numeric capture all build their row
-- without a context, so the insert failed against this constraint — a CSV
-- import of a sensor history wrote nothing, a Nightscout tick counted every
-- reading as a failed row.
--
-- A missing context is missing. It stores as NULL, never as a fifth enum
-- member: every per-context reader (the doctor report, the FHIR per-context
-- Observations, the analytics + dashboard breakdowns, the target builder)
-- selects by equality against the four members, so NULL falls out of each
-- rather than being presented as a classification that nobody recorded. The
-- context-agnostic readers (the clinical panel, the charts, the all-time
-- summary) keep the row in full.
ALTER TABLE "measurements"
DROP CONSTRAINT IF EXISTS "measurements_glucose_context_requires_type";
ALTER TABLE "measurements"
ADD CONSTRAINT "measurements_glucose_context_requires_type"
CHECK (
type = 'BLOOD_GLUCOSE' OR glucose_context IS NULL
);
@@ -0,0 +1,29 @@
-- The owner's saved report selection: the leaf-id inclusion list plus the
-- format / range / charts choices the export panel restores.
ALTER TABLE "users" ADD COLUMN "report_selection_json" JSONB;
-- Retire the orphaned flat prefs column. No code outside its own route ever
-- read it — the route's docblock claimed the aggregator consulted it, which
-- was not true — and the route is removed in the same release.
ALTER TABLE "users" DROP COLUMN "doctor_report_prefs_json";
-- Retire the share-link FHIR scope columns. The face they described was never
-- built and their controls are gone. An inert column with a plausible name is
-- precisely what a later contributor rewires by mistake.
ALTER TABLE "clinician_share_links" DROP COLUMN "resource_types";
ALTER TABLE "clinician_share_links" DROP COLUMN "allow_fhir_api";
-- Revoke every live RECORD share link minted before the selection model. Their
-- frozen scope is `{}` or a flat prefs blob, both of which resolved to
-- "everything except mood and cycle" — a scope nobody chose. Downgrading them
-- to something narrower would still be a scope nobody chose, so they close and
-- the owner re-mints from the sharing list, which shows each one in a
-- pick-again state with its label, window and expiry preserved.
--
-- Documents-only shares are untouched: they carry an explicit empty report
-- scope and serve no health metric.
UPDATE "clinician_share_links"
SET "revoked_at" = NOW()
WHERE "revoked_at" IS NULL
AND "document_only" = false
AND "expires_at" > NOW();
+63 -45
View File
@@ -233,13 +233,6 @@ model User {
// predicate `strava_backfill_completed_at IS NULL`), mirroring WHOOP/Fitbit.
stravaBackfillCompletedAt DateTime? @map("strava_backfill_completed_at")
// moodLog integration (per-user, encrypted)
moodLogUrlEncrypted String? @map("mood_log_url_encrypted")
moodLogApiKeyEncrypted String? @map("mood_log_api_key_encrypted")
moodLogEnabled Boolean @default(false) @map("mood_log_enabled")
moodLogLastSyncedAt DateTime? @map("mood_log_last_synced_at")
moodLogWebhookSecret String? @map("mood_log_webhook_secret")
// Locale (null = use system/admin default)
locale String? @map("locale") // "de" | "en" | null
@@ -498,19 +491,22 @@ model User {
// `MoodReminderDispatch` table (one row per user + local date).
moodReminderEnabled Boolean @default(false) @map("mood_reminder_enabled")
// v1.4.25 W6c — Doctor-report section toggles.
// Shape:
// { "bp": boolean,
// "weight": boolean,
// "pulse": boolean,
// "bmi": boolean,
// "mood": boolean, // privacy-sensitive; default OFF
// "compliance": boolean,
// "sleep": boolean }
// Null = documented defaults (every section ON except mood). Mood is
// opt-in by default — the API layer drops mood entirely from the
// payload when the toggle is off so the data never leaves the DB row.
doctorReportPrefsJson Json? @map("doctor_report_prefs_json")
// The owner's saved report selection: the leaf-id inclusion list plus the
// format / range / charts choices the export panel restores. Shape:
// { "v": 2,
// "leaves": ["WEIGHT", "BLOOD_PRESSURE_SYS", "LAB_RESULTS", …],
// "format": "pdf" | "fhir" | "package",
// "rangeDays": number,
// "includeCharts": boolean }
// Null = never saved, which resolves to the EMPTY selection everywhere.
// Read by the export panel, the share-link create form, the FHIR REST face
// and the MCP doctor-visit surfaces.
//
// Replaces `doctorReportPrefsJson`, whose flat opt-out shape could not
// express "not chosen": an absent key resolved to included, so a caller who
// said nothing received the whole record. Membership is the whole contract
// here — absence is exclusion, and there is no defaults constant to drift.
reportSelectionJson Json? @map("report_selection_json")
// v1.4.49 M-DOUBLE-REMINDER — per-user notification preferences blob.
//
@@ -1643,14 +1639,14 @@ model Measurement {
// source-day estimate derived from export.xml; migrated ambiguous rows are
// explicitly LEGACY_UNKNOWN until a native sync or archive reprocess repairs
// them.
aggregationProvenance MeasurementAggregationProvenance? @map("aggregation_provenance")
// Distinct source identities contributing to an export.xml source-day
// estimate. Source labels themselves are never persisted.
aggregationContributorCount Int? @map("aggregation_contributor_count")
// SHA-256 of the deterministically selected source identity. Non-identifying
// provenance for repeatability without retaining device/source labels.
aggregationSelectedSourceHash String? @map("aggregation_selected_source_hash")
// Only set for BLOOD_GLUCOSE measurements. NULL for all others.
aggregationProvenance MeasurementAggregationProvenance? @map("aggregation_provenance")
// Only set for BLOOD_GLUCOSE measurements. NULL for all others, and NULL
// for a BLOOD_GLUCOSE row whose writer has no classification to record —
// a sensor export, the Nightscout pull, the MCP tool, the Telegram capture.
// A CHECK constraint (migrations 0021 + 0274) enforces the type pairing in
// one direction only: a non-glucose row can never carry a context. Every
// per-context reader selects by equality against the four enum members, so
// a NULL context stays absent rather than reading as a classification.
glucoseContext GlucoseContext? @map("glucose_context")
// v1.4.23 — per-stage sleep label. NULL for non-sleep rows; non-NULL
// only for `type = SLEEP_DURATION`. Mirrors the glucoseContext
@@ -2770,7 +2766,12 @@ model MedicationIntakeEvent {
/// nightly dedup leaves both standalone.
attributionSource IntakeAttributionSource @default(AUTO) @map("attribution_source")
source IntakeSource @default(WEB)
idempotencyKey String? @unique @map("idempotency_key")
/// v1.32.37 — client-supplied replay anchor, unique PER USER (migration
/// 0273). It carried a GLOBAL unique from 0001_init, which let one
/// user's key refuse another user's write outright and let an unscoped
/// lookup resolve to a stranger's row. Same grain as `(user_id,
/// external_id)` below: every read must key on `(userId, idempotencyKey)`.
idempotencyKey String? @map("idempotency_key")
createdAt DateTime @default(now()) @map("created_at")
/// v1.4.25 W4d — only populated for GLP-1 intake events (the picker
/// is the only surface that writes this column). Existing rows stay
@@ -2842,6 +2843,7 @@ model MedicationIntakeEvent {
/// unique above, migration 0121), so the schema keeps the full
/// `@@unique` for the `userId_externalId` compound where-key.
@@unique([userId, externalId])
@@unique([userId, idempotencyKey])
@@index([userId, medicationId, scheduledFor])
// v1.7.0 sync — keyset support for the `/api/sync/changes` intake feed
// (`where userId` ordered by `(updatedAt asc, id asc)`).
@@ -3415,10 +3417,22 @@ model RefreshToken {
// (`WHERE consumed_at IS NULL`); a second presentation after consumption revokes
// the token pair the first exchange issued (refresh-token reuse-detection
// posture). 90-second TTL enforced at read; a daily sweep prunes stale rows.
// v1.32.11 — this table now backs BOTH native handoff flows: the original
// OIDC SSO leg (`flow = "oidc"`) and the first-party web-handoff login the iOS
// app uses on a self-hosted domain (`flow = "web_login"`, iOS #65). The mint +
// consume core is shared (`src/lib/auth/native-handoff.ts`); the `flow`
// discriminator is written at mint and CHECKED at exchange, so an oidc code can
// never be redeemed at the web-login token route and vice versa.
model OidcNativeHandoff {
id String @id @default(cuid())
userId String @map("user_id")
codeHash String @unique @map("code_hash") // hashToken(hlh_<…>)
/// Which handoff flow minted this code — `oidc` (the SSO native leg) or
/// `web_login` (the first-party web-handoff, iOS #65). Defaults to `oidc` so
/// every pre-existing row keeps its meaning. The consume path filters on it,
/// making a cross-flow redemption structurally impossible (not merely
/// unobserved).
flow String @default("oidc") @map("flow")
/// The app's S256 PKCE challenge, bound at mint; the exchange verifies
/// s256(code_verifier) against it (constant-time) before consuming.
codeChallenge String @map("code_challenge")
@@ -3643,6 +3657,10 @@ model FitbitConnection {
/// Set once the self-converging history backfill walks every collection to
/// completion. Null = backfill still in flight (or never started).
backfillCompletedAt DateTime? @map("backfill_completed_at")
/// Set once the one-shot sleep repair re-reads the bounded sleep history and
/// collapses the duplicates the earlier volatile segment key left behind.
/// Null = repair pending.
sleepRepairedAt DateTime? @map("sleep_repaired_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -3739,7 +3757,7 @@ model GoogleHealthOAuthState {
@@map("google_health_oauth_states")
}
// ─── Mood Entries (moodLog) ───────────────────────────────────
// ─── Mood Entries ─────────────────────────────────────────────
model MoodEntry {
id String @id @default(cuid())
@@ -3765,11 +3783,15 @@ model MoodEntry {
/// IllnessDayLog / CycleDayLog / LabResult note convention). Registered in
/// ENCRYPTED_COLUMNS + the rotation script.
noteEncrypted Bytes? @map("note_encrypted")
source String @default("MOODLOG") // WEB, TELEGRAM, DAYLIO
// Provenance. MANUAL / WEB / TELEGRAM / DAYLIO / MCP, plus the legacy
// MOODLOG value carried by rows the retired moodLog bridge imported —
// those rows are user history and keep their label. The column default
// is legacy too; every writer sets `source` explicitly.
source String @default("MOODLOG")
// v1.12.1 — stable per-source dedup id. NULL for native / MANUAL
// entries (those dedup on the legacy `(userId, date, moodLoggedAt)`
// key). When an importer receives a source-stable id (the moodLog
// upstream entry id, a Daylio row id) it writes it here and upserts on
// key). When an importer receives a source-stable id (an iOS row
// UUID, a Daylio row id) it writes it here and upserts on
// `@@unique([userId, source, externalId])`, so a re-import is
// idempotent even when `moodLoggedAt` re-rounds / re-zones. Postgres
// treats NULLs as distinct in that unique, so MANUAL rows never
@@ -3843,7 +3865,7 @@ model MoodEntry {
//
// Backward-compat: this is purely ADDITIVE. The legacy flat
// `MoodEntry.tags` (a JSON string array of free-text keys) keeps working
// unchanged — the GLP-1 quick-tag chips, the Telegram/MoodLog sync, and
// unchanged — the GLP-1 quick-tag chips, the Telegram capture, and
// every existing row stay on the flat axis. The structured taxonomy is a
// second, richer capture surface that the tag-frequency / lift breakdown
// folds in next to the flat tags (`mood-aggregates.ts`).
@@ -4079,12 +4101,12 @@ model MedicationComplianceRollup {
// (the sync entry-points consult this row before doing any external
// work).
//
// `integration` values are bare strings ("withings", "moodlog") rather
// `integration` values are bare strings ("withings", "whoop") rather
// than an enum so future integrations don't require a migration.
model IntegrationStatus {
id String @id @default(cuid())
userId String @map("user_id")
integration String // "withings" | "moodlog"
integration String // "withings" | "whoop" | "fitbit" | …
state String @default("connected") // "connected" | "error_reauth" | "error_transient" | "disconnected" | "parked"
lastSuccessAt DateTime? @map("last_success_at")
lastAttemptAt DateTime? @map("last_attempt_at")
@@ -4127,7 +4149,6 @@ model AppSettings {
webPushVapidPrivateKeyEncrypted String? @map("web_push_vapid_private_key_encrypted")
webPushVapidSubject String? @map("web_push_vapid_subject")
apiGlobal Boolean @default(true) @map("api_global") // global toggle
moodLogGlobal Boolean @default(true) @map("mood_log_global")
umamiEnabled Boolean @default(false) @map("umami_enabled")
umamiScriptUrl String? @map("umami_script_url")
umamiWebsiteId String? @map("umami_website_id")
@@ -5332,8 +5353,8 @@ model PushAttempt {
// `ApiToken.tokenHash`, keyed by `API_TOKEN_HMAC_KEY`); the plaintext is
// returned to the owner exactly once at creation and never persisted.
//
// The scope is FROZEN at creation: `rangeStart`, `sectionsJson` and
// `resourceTypes` are write-once and a share can NEVER widen later — only
// The scope is FROZEN at creation: `rangeStart` and `sectionsJson` are
// write-once and a share can NEVER widen later — only
// `revokedAt`, `lastAccessAt` and `accessCount` mutate after mint. This
// mirrors the append-only spirit of `ConsentReceipt`. `expiresAt` is
// mandatory (capped at `SHARE_LINK_MAX_DAYS`, default 90) — there is no
@@ -5360,13 +5381,10 @@ model ClinicianShareLink {
rangeStart DateTime @map("range_start")
/// Window end. Null = rolling ("up to now"); set = frozen snapshot window.
rangeEnd DateTime? @map("range_end")
/// Section toggles (the `DoctorReportPrefs` shape the export uses). JSON.
/// The frozen report scope: `{ "v": 2, "leaves": [...] }`. Write-once at
/// mint, never widened. A blob this build cannot read resolves to the empty
/// selection, so a drifted row serves nothing rather than defaulting open.
sectionsJson Json @map("sections_json")
/// FHIR resource types this link may serve (a subset of the catalogue),
/// e.g. `["Observation","MedicationStatement"]`.
resourceTypes String[] @map("resource_types")
/// Whether the FHIR API is reachable via this link at all (vs the view only).
allowFhirApi Boolean @default(false) @map("allow_fhir_api")
/// v1.28.16 — the authoritative "documents-only" flag, frozen write-once at
/// create. When true the public view serves ONLY the attached documents and
/// the aggregator is never called, INDEPENDENT of `sectionsJson`. Decoupling
+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.32.6";
/* @sw-version-fallback */ "v1.32.39";
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/*`
+254
View File
@@ -0,0 +1,254 @@
/**
* Release-battery sweep: which Prisma columns are written but never read?
*
* Run it as part of the deep-audit step, not in CI:
*
* pnpm dlx tsx scripts/audit-column-readers.ts
* pnpm dlx tsx scripts/audit-column-readers.ts --model Measurement
* pnpm dlx tsx scripts/audit-column-readers.ts --all
*
* ## Why this is a script and not a test
*
* The question "does this column have a reader anywhere in the tree" is a
* whole-program property, and the only cheap way to approximate it is grep.
* Grep is unsound in both directions here. Generic field names (`value`,
* `type`, `state`, `source`) match unrelated identifiers and read as consumed
* when they are not. Consumption through a select-less `findUnique` plus
* whole-row serialization reads as unconsumed when it is not. Raw SQL and
* dynamic selects fall outside the matcher entirely.
*
* A gate built on that matcher would drift towards green and stop meaning
* anything, which is exactly the failure this whole family of guards exists to
* prevent — a pair guard that quietly degrades into a one-end guard is worse
* than no guard, because it also removes the suspicion. So this stays a review
* tool with a human reading the output. The two questions that CAN be answered
* exactly — is every account-payload field consumed, is every Measurement
* column backed up — are tests instead:
* `src/__tests__/account-payload-consumer-guard.test.ts` and
* `src/__tests__/measurement-backup-completeness.test.ts`.
*
* ## The annotation convention
*
* A pull request that adds a column, payload field, or preference contains its
* consumer. When it genuinely cannot, the schema comment says so and this
* sweep treats the marker as an expiring allowlist entry:
*
* /// @pending-consumer(#123) — iOS reads this from v1.33; server-side unread until then.
* /// @internal: cooldown bookkeeping for the reminder cron; never surfaced.
*
* `@internal` is permanent and silences the field. `@pending-consumer` is
* temporary: the field stays out of the findings list but is reported
* separately every run, so an issue that closed without the consumer landing
* becomes visible at the next release audit instead of never.
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
const ROOT = join(import.meta.dirname, "..");
const SCHEMA = join(ROOT, "prisma", "schema.prisma");
const SRC = join(ROOT, "src");
/** `generated` is the Prisma client: megabytes of noise, and never a consumer. */
const SKIP_DIRS = new Set([
"generated",
"node_modules",
".next",
"__tests__",
"__mocks__",
]);
/**
* Names too generic for the matcher to say anything useful about. Reported in
* their own section rather than dropped, so the sweep never claims a clean
* bill it cannot back up.
*/
const GENERIC_NAMES = new Set([
"id",
"name",
"type",
"value",
"unit",
"source",
"state",
"status",
"label",
"kind",
"data",
"key",
"code",
"url",
"title",
"notes",
"userId",
"createdAt",
"updatedAt",
"deletedAt",
]);
interface Field {
model: string;
name: string;
annotation: "internal" | "pending-consumer" | null;
annotationText: string | null;
}
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
if (SKIP_DIRS.has(entry)) continue;
const p = join(dir, entry);
if (statSync(p).isDirectory()) walk(p, out);
else if (entry.endsWith(".ts") || entry.endsWith(".tsx")) out.push(p);
}
return out;
}
function parseSchema(schema: string): Field[] {
const models = new Set(
[...schema.matchAll(/^model\s+([A-Za-z_]\w*)\s*\{/gm)].map((m) => m[1]),
);
const fields: Field[] = [];
for (const model of models) {
const start = schema.indexOf(`model ${model} {`);
const end = schema.indexOf("\n}", start);
const lines = schema.slice(start, end).split("\n").slice(1);
let pending: string[] = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) continue;
if (line.startsWith("//") || line.startsWith("///")) {
pending.push(line);
continue;
}
if (line.startsWith("@@")) {
pending = [];
continue;
}
const m = /^([a-zA-Z_]\w*)\s+([A-Za-z_]\w*)(\[\])?(\?)?/.exec(line);
if (!m) {
pending = [];
continue;
}
const [, name, type, isList] = m;
if (isList || models.has(type) || line.includes("@relation")) {
pending = [];
continue;
}
const comment = [...pending, line].join(" ");
let annotation: Field["annotation"] = null;
let annotationText: string | null = null;
const internal = /@internal:?\s*(.*)$/.exec(comment);
const pendingConsumer = /@pending-consumer\(([^)]*)\)\s*(.*)$/.exec(
comment,
);
if (pendingConsumer) {
annotation = "pending-consumer";
annotationText = `${pendingConsumer[1]} ${pendingConsumer[2]}`.trim();
} else if (internal) {
annotation = "internal";
annotationText = internal[1].trim();
}
fields.push({ model, name, annotation, annotationText });
pending = [];
}
}
return fields;
}
interface Usage {
writes: number;
reads: number;
selects: number;
}
function countUsage(text: string, field: string): Usage {
const esc = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const selects = text.match(
new RegExp(`(?<![\\w$])${esc}:\\s*(?:true|false)(?![\\w$])`, "g"),
);
const writes = text.match(
new RegExp(`(?<![\\w$.])${esc}:\\s*(?!true|false)`, "g"),
);
const reads = text.match(
new RegExp(`\\.\\s*${esc}(?![\\w$])|[{,]\\s*${esc}\\s*[,}]`, "g"),
);
return {
writes: writes?.length ?? 0,
reads: reads?.length ?? 0,
selects: selects?.length ?? 0,
};
}
function main() {
const args = process.argv.slice(2);
const modelFilter = args.includes("--model")
? args[args.indexOf("--model") + 1]
: null;
const includeGeneric = args.includes("--all");
const fields = parseSchema(readFileSync(SCHEMA, "utf8")).filter(
(f) => !modelFilter || f.model === modelFilter,
);
const text = walk(SRC)
.map((f) => readFileSync(f, "utf8"))
.join("\n");
const findings: Array<Field & Usage> = [];
const generic: Array<Field & Usage> = [];
const pendingConsumers: Field[] = [];
for (const field of fields) {
if (field.annotation === "pending-consumer") pendingConsumers.push(field);
if (field.annotation) continue;
const usage = countUsage(text, field.name);
if (usage.reads > 0 || usage.selects > 0) continue;
if (usage.writes === 0) continue;
if (GENERIC_NAMES.has(field.name) && !includeGeneric) {
generic.push({ ...field, ...usage });
continue;
}
findings.push({ ...field, ...usage });
}
const scope = modelFilter ? `model ${modelFilter}` : "every model";
console.log(
`Column-reader sweep over ${scope} — ${fields.length} scalar column(s).\n`,
);
if (findings.length === 0) {
console.log("No unannotated write-only columns matched.\n");
} else {
console.log(
`${findings.length} column(s) written but never read or selected — verify each by hand:\n`,
);
for (const f of findings) {
console.log(` ${f.model}.${f.name} (writes: ${f.writes})`);
}
console.log("");
}
if (pendingConsumers.length > 0) {
console.log(
`${pendingConsumers.length} column(s) marked @pending-consumer — check whether the issue closed without the consumer landing:\n`,
);
for (const f of pendingConsumers) {
console.log(` ${f.model}.${f.name} ${f.annotationText ?? ""}`);
}
console.log("");
}
if (generic.length > 0) {
console.log(
`${generic.length} match(es) suppressed as too generic to judge (re-run with --all to see them).\n`,
);
}
console.log(
"Expect false positives: raw SQL, dynamic selects, and whole-row serialization\n" +
"all read columns this matcher cannot see. Confirm a finding before acting on it.",
);
}
main();
@@ -0,0 +1,138 @@
/**
* Remove the phantom Apple-Health mirror medications minted by the unstable
* externalId bug (a fresh key per sync sweep => one duplicate row per sweep).
*
* SAFETY:
* - Dry-run by default. Deletes ONLY when called with `--execute`.
* - Targets ONLY rows that match ALL of: the given name, externalSource =
* APPLE_HEALTH, ZERO intake events, and ZERO schedules. A row with any
* intake history or any schedule is never touched. Real medications
* (different name, or with intake/schedule history) can never match.
* - Each delete replicates the DELETE /api/medications/{id} route exactly:
* revoke medication-scoped tokens, deleteMedicationCategory, cascade
* delete, audit row. Caches are busted per affected user at the end (and
* otherwise clear on TTL / the next write).
*
* STOP THE SOURCE FIRST: while the iOS Apple-Health medication mirror is still
* enabled on the device, every sync re-mints these rows and cleanup will undo
* itself. Turn the toggle off (or ship the iOS fix) before running --execute.
*
* Run (inside the app container):
* pnpm dlx tsx scripts/cleanup-apple-health-phantom-meds.ts # dry-run
* pnpm dlx tsx scripts/cleanup-apple-health-phantom-meds.ts --execute # delete
* pnpm dlx tsx scripts/cleanup-apple-health-phantom-meds.ts --name "Blutdruckmittel" --execute
*/
import "dotenv/config";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { deleteMedicationCategory } from "@/lib/medication-category";
function argValue(flag: string): string | undefined {
const i = process.argv.indexOf(flag);
return i >= 0 ? process.argv[i + 1] : undefined;
}
async function main(): Promise<void> {
const name = argValue("--name") ?? "Blutdruckmittel";
const execute = process.argv.includes("--execute");
// Candidate set: name + Apple-Health provenance only. Intake / schedule
// counts are checked per row below and are the hard safety gate.
const candidates = await prisma.medication.findMany({
where: { name, externalSource: "APPLE_HEALTH" },
select: { id: true, userId: true, externalId: true, createdAt: true },
orderBy: { createdAt: "asc" },
});
const deletable: { id: string; userId: string }[] = [];
const skipped: { id: string; reason: string }[] = [];
for (const m of candidates) {
const [intakes, schedules] = await Promise.all([
prisma.medicationIntakeEvent.count({
where: { medicationId: m.id, deletedAt: null },
}),
prisma.medicationSchedule.count({ where: { medicationId: m.id } }),
]);
if (intakes > 0 || schedules > 0) {
skipped.push({
id: m.id,
reason: `has ${intakes} intake(s) / ${schedules} schedule(s) — PRESERVED`,
});
continue;
}
deletable.push({ id: m.id, userId: m.userId });
}
console.log(
`\n=== cleanup plan for name "${name}" (externalSource APPLE_HEALTH) ===`,
);
console.log(`candidates found: ${candidates.length}`);
console.log(`safe to delete: ${deletable.length}`);
console.log(`preserved (intake/schedule present): ${skipped.length}`);
if (skipped.length > 0) {
console.log("\nPreserved rows:");
for (const s of skipped) console.log(` ${s.id} — ${s.reason}`);
}
console.log("\nRows to delete:");
for (const d of deletable) console.log(` ${d.id}`);
if (!execute) {
console.log(
"\nDRY RUN — nothing was deleted. Re-run with --execute to remove the rows above.",
);
return;
}
console.log(`\n--execute: deleting ${deletable.length} row(s)...`);
const affectedUsers = new Set<string>();
let deleted = 0;
for (const { id, userId } of deletable) {
// Mirror the DELETE route: revoke medication-scoped tokens (none expected
// on a phantom row, harmless if absent), category cleanup, cascade delete,
// audit row.
await prisma.apiToken.updateMany({
where: {
userId,
revoked: false,
permissions: { has: `medication:${id}:ingest` },
},
data: { revoked: true },
});
await deleteMedicationCategory(id);
await prisma.medication.delete({ where: { id } });
await auditLog("medication.delete", {
userId,
details: {
medicationId: id,
name,
reason: "apple_health_phantom_cleanup",
},
});
affectedUsers.add(userId);
deleted += 1;
}
for (const userId of affectedUsers) {
invalidateUserMedications(userId, { evict: true });
}
console.log(
`\nDone. Deleted ${deleted} row(s) across ${affectedUsers.size} user(s).`,
);
console.log(
"Caches were busted for the affected users; a page refresh reflects the change.",
);
}
main()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => {
void prisma.$disconnect();
});
@@ -0,0 +1,104 @@
/**
* Convert measurement rows stored in a RECOGNISED non-canonical unit to
* canonical SI, in place. Companion to
* `diagnose-noncanonical-measurement-units.ts` — run the diagnose first.
*
* SAFETY:
* - Dry-run by default. Writes ONLY when called with `--execute`.
* - Touches ONLY rows whose stored `unit` differs from the type's canonical
* unit AND resolves through the shared alias resolver (lb/lbs/pound(s),
* in/inch(es), °F, mmol/L). A row whose unit is already canonical, or whose
* unit is unrecognised, is never touched.
* - Each row is rewritten to the canonical `{ value, unit }` the same
* resolver the CSV importer + MCP write path use produces, so the number is
* re-expressed, never re-labelled in place (which would corrupt it).
* - Affected users' measurement caches are busted at the end.
*
* This repairs the storage LABEL + value for the recognised-alias legacy rows
* (the MCP-leak class). It cannot repair a row whose unit was already canonical
* but whose value was a foreign unit — that class is not mechanically
* identifiable (see the diagnose script).
*
* Run (inside the app container):
* pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts # dry-run
* pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts --execute # write
*/
import "dotenv/config";
import { prisma } from "@/lib/db";
import { getUnitForType } from "@/lib/validations/measurement";
import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
async function main(): Promise<void> {
const execute = process.argv.includes("--execute");
const groups = await prisma.measurement.groupBy({
by: ["type", "unit"],
_count: { _all: true },
});
const convertibleGroups = groups.filter((g) => {
if (g.unit === null || g.unit === getUnitForType(g.type)) return false;
return resolveToCanonicalUnit(g.type, 1, g.unit) !== null;
});
if (convertibleGroups.length === 0) {
console.log(
"Nothing to convert — no recognised non-canonical units found.",
);
return;
}
const affectedUsers = new Set<string>();
let converted = 0;
for (const g of convertibleGroups) {
const canonical = getUnitForType(g.type);
const rows = await prisma.measurement.findMany({
where: { type: g.type, unit: g.unit },
select: { id: true, userId: true, value: true, unit: true },
});
for (const row of rows) {
const resolved = resolveToCanonicalUnit(
g.type,
row.value,
row.unit as string,
);
if (!resolved) continue; // defensive; group already filtered
converted += 1;
affectedUsers.add(row.userId);
console.log(
` ${execute ? "convert" : "would convert"} ${g.type} ${row.id}: ` +
`${row.value} ${row.unit} -> ${resolved.value} ${resolved.unit}`,
);
if (execute) {
await prisma.measurement.update({
where: { id: row.id },
data: { value: resolved.value, unit: canonical },
});
}
}
}
if (execute) {
for (const userId of affectedUsers) {
invalidateUserMeasurements(userId, { evict: true });
}
console.log(
`\nConverted ${converted} row(s) across ${affectedUsers.size} user(s). ` +
"Rollups recompute on the next read/backfill.",
);
} else {
console.log(
`\nDry run: ${converted} row(s) across ${affectedUsers.size} user(s) ` +
"would be converted. Re-run with --execute to write.",
);
}
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
+121
View File
@@ -0,0 +1,121 @@
/**
* Read-only diagnostic for the phantom Apple-Health medication mirror.
*
* Prints the shape of every medication matching a name (default
* "Blutdruckmittel"): its provenance (externalSource / externalId), dose,
* asNeeded flag, createdAt, and its intake-event + schedule counts. Nothing is
* written. Use it to confirm the root cause before running any cleanup:
*
* - externalSource = APPLE_HEALTH with many DISTINCT externalId values
* (address-shaped or otherwise mutually different) => the iOS mirror is
* minting a fresh key per sync, so idempotency never matches.
* - externalSource NULL => the rows are not mirror rows (a different path).
* - createdAt spread => one row per sync sweep vs a single burst.
*
* Run (inside the app container, which has DATABASE_URL):
* pnpm dlx tsx scripts/diagnose-apple-health-meds.ts
* pnpm dlx tsx scripts/diagnose-apple-health-meds.ts --name "Blutdruckmittel"
*/
import "dotenv/config";
import { prisma } from "@/lib/db";
function argValue(flag: string): string | undefined {
const i = process.argv.indexOf(flag);
return i >= 0 ? process.argv[i + 1] : undefined;
}
async function main(): Promise<void> {
const name = argValue("--name") ?? "Blutdruckmittel";
const meds = await prisma.medication.findMany({
where: { name },
select: {
id: true,
userId: true,
name: true,
dose: true,
asNeeded: true,
externalSource: true,
externalId: true,
createdAt: true,
},
orderBy: { createdAt: "asc" },
});
if (meds.length === 0) {
console.log(`No medications found with name "${name}".`);
return;
}
const rows = [];
for (const m of meds) {
const [intakes, schedules] = await Promise.all([
prisma.medicationIntakeEvent.count({
where: { medicationId: m.id, deletedAt: null },
}),
prisma.medicationSchedule.count({ where: { medicationId: m.id } }),
]);
rows.push({
id: m.id,
user: m.userId.slice(0, 8),
source: m.externalSource ?? "NULL",
externalId: m.externalId ?? "NULL",
dose: m.dose,
asNeeded: m.asNeeded,
intakes,
schedules,
createdAt: m.createdAt.toISOString(),
});
}
console.log(`\n=== ${rows.length} medication(s) named "${name}" ===\n`);
console.table(rows);
const distinctExternalIds = new Set(
meds.map((m) => m.externalId).filter((v): v is string => v !== null),
);
const bySource = new Map<string, number>();
for (const m of meds) {
const key = m.externalSource ?? "NULL";
bySource.set(key, (bySource.get(key) ?? 0) + 1);
}
const created = meds.map((m) => m.createdAt.getTime());
const withIntake = rows.filter((r) => r.intakes > 0).length;
const withSchedule = rows.filter((r) => r.schedules > 0).length;
console.log("\n=== summary ===");
console.log(`total rows: ${rows.length}`);
console.log(
`externalSource breakdown: ${[...bySource.entries()]
.map(([k, v]) => `${k}=${v}`)
.join(", ")}`,
);
console.log(`distinct externalId: ${distinctExternalIds.size}`);
console.log(`rows with >=1 intake: ${withIntake}`);
console.log(`rows with >=1 schedule: ${withSchedule}`);
console.log(
`createdAt range: ${new Date(Math.min(...created)).toISOString()} .. ${new Date(Math.max(...created)).toISOString()}`,
);
console.log(
"\nSample externalId values (first 5):\n" +
meds
.slice(0, 5)
.map((m) => ` ${m.externalId ?? "NULL"}`)
.join("\n"),
);
console.log(
"\nRead-only. No rows were modified. If the shape confirms the phantom set,\n" +
"run scripts/cleanup-apple-health-phantom-meds.ts (dry-run first).",
);
}
main()
.then(() => process.exit(0))
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => {
void prisma.$disconnect();
});
@@ -0,0 +1,76 @@
/**
* Read-only diagnostic for measurement rows stored in a NON-canonical unit.
*
* Canonical storage is SI: every write path stamps `getUnitForType(type)`. The
* one historical leak was the MCP `log_measurement` tool, which persisted a
* caller-supplied `unit` verbatim (closed in v1.32.26). A row whose `unit`
* column differs from its type's canonical unit is such a legacy row, and it
* mislabels every display surface that trusts the column.
*
* This groups measurements by `(type, unit)` and prints every group whose
* `unit` is not the canonical unit for its type, flagging whether the stored
* unit is a RECOGNISED alias (mechanically convertible by the companion
* convert script) or UNKNOWN (needs a manual look). Nothing is written.
*
* NOT auto-fixable and deliberately out of scope: a row whose stored unit IS
* already canonical but whose VALUE was a foreign unit (e.g. a lb reading typed
* into the old kg-labelled entry form). Those are indistinguishable from real
* canonical readings; the reporter edits them via the record list.
*
* Run (inside the app container, which has DATABASE_URL):
* pnpm dlx tsx scripts/diagnose-noncanonical-measurement-units.ts
*/
import "dotenv/config";
import { prisma } from "@/lib/db";
import { getUnitForType } from "@/lib/validations/measurement";
import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases";
async function main(): Promise<void> {
const groups = await prisma.measurement.groupBy({
by: ["type", "unit"],
_count: { _all: true },
});
const mismatches = groups.filter((g) => {
if (g.unit === null) return false;
return g.unit !== getUnitForType(g.type);
});
if (mismatches.length === 0) {
console.log("No non-canonical measurement units found. Storage is clean.");
return;
}
let convertible = 0;
let unknown = 0;
console.log(
`Found ${mismatches.length} non-canonical (type, unit) group(s):\n`,
);
for (const g of mismatches.sort((a, b) => b._count._all - a._count._all)) {
const canonical = getUnitForType(g.type);
// Probe convertibility with a neutral value — only the recognition matters.
const recognised = resolveToCanonicalUnit(g.type, 1, g.unit as string);
const status = recognised ? "CONVERTIBLE" : "UNKNOWN — manual review";
if (recognised) convertible += g._count._all;
else unknown += g._count._all;
console.log(
` ${g.type}: stored="${g.unit}" canonical="${canonical}" ` +
`rows=${g._count._all} [${status}]`,
);
}
console.log(
`\nTotals: ${convertible} row(s) convertible, ${unknown} row(s) unknown.`,
);
console.log(
"Convert the recognised aliases with:\n" +
" pnpm dlx tsx scripts/convert-noncanonical-measurement-units.ts --execute",
);
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
+2 -3
View File
@@ -62,11 +62,10 @@ reset to `'disconnected'`), `ai_anthropic_key_encrypted`,
`withings_client_id_encrypted`, `withings_client_secret_encrypted`,
`whoop_client_id_encrypted`, `whoop_client_secret_encrypted`,
`fitbit_client_id_encrypted`, `fitbit_client_secret_encrypted`,
`telegram_bot_token`, `telegram_chat_id`, `mood_log_url_encrypted`,
`mood_log_api_key_encrypted`, `mood_log_webhook_secret`,
`telegram_bot_token`, `telegram_chat_id`,
`insurance_number_encrypted`, `insurer_name`, `insurer_ik_number`.
Forced flags: `telegram_enabled=false`, `mood_log_enabled=false`,
Forced flags: `telegram_enabled=false`,
`role='ADMIN'`.
**Kept:** `password_hash` (demo login works), `avatar_bytes` /
+2 -9
View File
@@ -302,7 +302,7 @@ fi
#
# users — explicit ordered list. Secret/credential columns are NULLed in the
# SELECT (literal NULL in the right position). role forced to 'ADMIN'.
# telegram_enabled / mood_log_enabled forced false. Kept: password_hash,
# telegram_enabled forced false. Kept: password_hash,
# avatar_*, all display/prefs/layout JSON, unit prefs, profile fields.
#
# The SELECT projects literals for the NULLed/forced columns and the real
@@ -318,8 +318,6 @@ readonly USERS_COLS="id, username, email, password_hash, role, created_at, updat
withings_client_id_encrypted, withings_client_secret_encrypted,
whoop_client_id_encrypted, whoop_client_secret_encrypted,
fitbit_client_id_encrypted, fitbit_client_secret_encrypted,
mood_log_url_encrypted, mood_log_api_key_encrypted, mood_log_enabled,
mood_log_last_synced_at, mood_log_webhook_secret,
locale, thresholds_json, dashboard_widgets_json, insights_layout_json,
glucose_unit, unit_preference,
ai_provider, ai_model, ai_base_url, ai_anthropic_key_encrypted, ai_local_key_encrypted,
@@ -358,11 +356,6 @@ readonly USERS_SELECT="id, username, email, password_hash,
NULL::text AS whoop_client_secret_encrypted,
NULL::text AS fitbit_client_id_encrypted,
NULL::text AS fitbit_client_secret_encrypted,
NULL::text AS mood_log_url_encrypted,
NULL::text AS mood_log_api_key_encrypted,
false AS mood_log_enabled,
NULL::timestamptz AS mood_log_last_synced_at,
NULL::text AS mood_log_webhook_secret,
locale, thresholds_json, dashboard_widgets_json, insights_layout_json,
glucose_unit, unit_preference,
ai_provider, ai_model, ai_base_url,
@@ -651,7 +644,7 @@ fi
echo
echo "HARD assertion 3: the demo user exists on edge01 with role=ADMIN and the apps01 id."
psql_dst "-c \"SELECT id, username, role, telegram_enabled, mood_log_enabled FROM users WHERE id = '$DEMO_USER_ID';\""
psql_dst "-c \"SELECT id, username, role, telegram_enabled FROM users WHERE id = '$DEMO_USER_ID';\""
echo
echo "=============================================================="
+1 -5
View File
@@ -159,8 +159,7 @@ async function main() {
// ───── User — integration credentials + AI keys + KVNR (String) ─────
// Columns: "codexAccessTokenEncrypted" "codexRefreshTokenEncrypted"
// "telegramBotToken" "moodLogWebhookSecret" "moodLogUrlEncrypted"
// "moodLogApiKeyEncrypted" "withingsClientIdEncrypted"
// "telegramBotToken" "withingsClientIdEncrypted"
// "withingsClientSecretEncrypted" "whoopClientIdEncrypted"
// "whoopClientSecretEncrypted" "fitbitClientIdEncrypted"
// "fitbitClientSecretEncrypted" "nightscoutUrlEncrypted"
@@ -176,9 +175,6 @@ async function main() {
"codexAccessTokenEncrypted",
"codexRefreshTokenEncrypted",
"telegramBotToken",
"moodLogWebhookSecret",
"moodLogUrlEncrypted",
"moodLogApiKeyEncrypted",
"withingsClientIdEncrypted",
"withingsClientSecretEncrypted",
"whoopClientIdEncrypted",
@@ -0,0 +1,189 @@
/**
* v1.32.36 — pair guard over the account payload (`GET /api/auth/me`).
*
* The recurring failure this exists to prevent: a two-ended feature whose ends
* ship in different releases. The column lands with the schema change, the
* payload field lands with the API change, and the client that was supposed to
* read it is "the follow-up". Nothing in the gate notices, because every other
* guard proves ONE end — typecheck proves the export exists, knip proves it is
* imported, the OpenAPI check proves the schema matches. None of them prove the
* pair.
*
* `/api/auth/me` is the preferences envelope: it rides every app boot and is
* where user-facing preference fields surface. This test reads the field names
* straight out of the route's response literal and asserts each one is read by
* at least one client module. `lastReportPracticeName` shipped as column +
* doc contract + payload field and sat NULL with no reader for several
* releases; this guard would have failed on the commit that introduced it, and
* the allowlist entry needed to silence it would have had no honest reason to
* write.
*
* ## What it deliberately does not prove
*
* - It proves a field is READ somewhere, not that it is rendered correctly, nor
* that every surface that should read it does. That is what the
* unit-preference display guard does for its own narrower question.
* - The matcher is textual (property access or destructuring position), so a
* field whose name collides with an unrelated local reads as consumed. Every
* such name in this payload (`id`, `email`, `role`, `gender`, `timezone`,
* `modules`) is consumed ubiquitously anyway, so the over-acceptance costs
* nothing here. It would cost something on a payload full of generic names,
* which is why this is a per-envelope guard and not a general sweep.
* - It says nothing about iOS. A field consumed only by the native client must
* be allowlisted with that reason written down, and the reason is an
* assumption about another repository until an audit crosses over.
*
* Mutation check: append a field to the response literal in
* `src/app/api/auth/me/route.ts` and this goes red; delete the practice-name
* read in `health-record-export-panel.tsx` and it goes red too.
*/
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, it, expect } from "vitest";
const SRC = join(process.cwd(), "src");
const ME_ROUTE = join(SRC, "app", "api", "auth", "me", "route.ts");
/**
* Where a client consumer may live. `src/app/api/**` is excluded — the server
* writing the field is the other end of the pair, not a consumer of it.
*/
const CONSUMER_ROOTS = [
join(SRC, "components"),
join(SRC, "hooks"),
join(SRC, "app"),
];
const SKIP_DIRS = new Set([
"__tests__",
"__mocks__",
"node_modules",
".next",
"generated",
]);
/**
* `src/hooks/use-auth.ts` declares the `AuthUser` transport type. Naming a
* field in a type declaration is not consuming it — that is precisely the
* shape `lastReportPracticeName` had while it was dead — so the declaration
* file is not a consumer.
*/
const NOT_A_CONSUMER = new Set([
join(SRC, "hooks", "use-auth.ts"),
join(SRC, "app", "api", "auth", "me", "route.ts"),
]);
/**
* Fields with no client reader, each with the reason it is nonetheless
* correct for the payload to carry it. An entry here is a claim; write one
* only when the claim is true and checkable.
*/
const NO_CLIENT_CONSUMER: Record<string, string> = {
insurerIkNumber:
"No web surface reads or writes it — the account form offers the insurer name and the " +
"insurance number, not the institution number. The native client does consume it: it " +
"builds the FHIR Coverage payor from insurerName-or-IK, per " +
".planning/ios-coord/v0.11-ios-to-server-kvnr-only-coverage.md. Server-side the stored " +
"value feeds the doctor-report cover and the Coverage resource in the health-record " +
"export. The missing web field is a real gap, deliberately not closed in a guard-only " +
"release; this entry is what keeps it visible.",
};
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
if (SKIP_DIRS.has(name)) continue;
const p = join(dir, name);
if (statSync(p).isDirectory()) {
// The API tree is the producing end, never the consuming one.
if (p === join(SRC, "app", "api")) continue;
walk(p, out);
} else if (
(name.endsWith(".ts") || name.endsWith(".tsx")) &&
!NOT_A_CONSUMER.has(p) &&
!name.endsWith(".test.ts") &&
!name.endsWith(".test.tsx")
) {
out.push(p);
}
}
return out;
}
/** Top-level keys of the `apiSuccess({ … })` literal the route returns. */
function payloadFields(source: string): string[] {
const start = source.indexOf("return apiSuccess({");
expect(start).toBeGreaterThan(-1);
const body = source.slice(start);
const fields: string[] = [];
for (const m of body.matchAll(/^ {4}([a-zA-Z_$][\w$]*)\s*[:,]/gm)) {
fields.push(m[1]);
}
return [...new Set(fields)];
}
/**
* Comments are stripped before matching. A doc comment naming a field is a
* claim about a consumer, not a consumer — `lastReportPracticeName` had one of
* those for its whole dead life, and counting it would make this guard agree
* with the very prose that was wrong.
*/
function stripComments(text: string): string {
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1");
}
/**
* A property access (`user.glucoseUnit`) or a destructuring / object position
* (`{ glucoseUnit }`, `{ glucoseUnit: unit }`).
*/
function readsField(text: string, field: string): boolean {
const re = new RegExp(
`\\.\\s*${field}(?![\\w$])|[{,]\\s*${field}\\s*[,}:=]`,
"m",
);
return re.test(text);
}
describe("account payload consumer guard", () => {
const source = readFileSync(ME_ROUTE, "utf8");
const fields = payloadFields(source);
const consumerFiles = CONSUMER_ROOTS.flatMap((root) => walk(root));
const consumerText = consumerFiles.map((f) =>
stripComments(readFileSync(f, "utf8")),
);
it("reads a plausible field set out of the route", () => {
expect(fields.length).toBeGreaterThan(20);
expect(fields).toContain("lastReportPracticeName");
expect(fields).toContain("moduleAvailability");
expect(consumerFiles.length).toBeGreaterThan(200);
});
it("every field the account endpoint returns has a client consumer", () => {
const unconsumed = fields.filter(
(field) =>
!(field in NO_CLIENT_CONSUMER) &&
!consumerText.some((text) => readsField(text, field)),
);
if (unconsumed.length > 0) {
const report = unconsumed.map((f) => ` ❌ ${f}`).join("\n");
throw new Error(
`GET /api/auth/me returns ${unconsumed.length} field(s) no client module reads:\n${report}\n\n` +
"Wire the consumer in the same release, or add the field to " +
"NO_CLIENT_CONSUMER with the reason it is correct to ship unread. " +
"A field with no honest reason belongs in neither the payload nor the schema.",
);
}
});
it("the allowlist carries no stale entry", () => {
for (const [field, reason] of Object.entries(NO_CLIENT_CONSUMER)) {
expect(
fields,
`${field} is allowlisted but no longer returned`,
).toContain(field);
expect(reason.length).toBeGreaterThan(20);
}
});
});
@@ -0,0 +1,171 @@
/**
* Structural guard on the "stale Today hero / dashboard after a write" class.
*
* The daily reads — `dailyDigest()` (Today hero) and `dashboardSnapshot()`
* (dashboard) — run with `refetchOnMount: false` and a 120 s poll. A mutation
* that invalidates one of the daily-reads-bearing bundles
* (`medicationDependentKeys` / `measurementDependentKeys` / `moodDependentKeys`)
* with the DEFAULT `refetchType: "active"` only refetches MOUNTED queries — so a
* write made from a surface where the daily reads are UNMOUNTED (the medications
* page, a detail page, the mood/measurement forms) marks them stale but never
* refetches them. Returning to `/` then serves the pre-write cache until the
* poll ticks or the tab is hard-reloaded.
*
* The blessed contract is `refetchInactiveDailyReads(queryClient)` (or its
* wrapper `invalidateMedicationReads`), which forces the two inactive reads to
* refetch. This class has recurred THREE times (v1.16.11 snapshot, v1.29.1
* digest, v1.32.19 "Take all due") because the bundle cannot express
* `refetchType`, so every new call site re-decides it by hand. This guard makes
* a fourth recurrence fail CI.
*
* Like the Bearer-scope guard, this is a tripwire, not a proof. It cannot show
* an allowlisted exception is correct — only that no NEW site invalidates a
* daily bundle without the paired refetch, and no PAIRED site loses its refetch,
* without someone editing this file.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join, sep } from "node:path";
import { globSync } from "node:fs";
const SRC = join(process.cwd(), "src");
/** The factory itself DEFINES the bundles + helpers; it is never a call site. */
const FACTORY = "lib/query-keys/index.ts";
/**
* A direct-bundle invalidation call site: `invalidateKeys(<qc>, <bundle>)` or
* `invalidateKeys(<qc>, [...<bundle>, <localKey>])`. Captures every form the
* codebase actually uses (bare bundle, or spread into an array with an extra
* per-surface key). Sites routed through `invalidateMedicationReads` do NOT
* match — the wrapper carries the refetch internally, so they are compliant by
* construction and correctly invisible here.
*/
const INVALIDATION =
/invalidateKeys\(\s*[^,]+,\s*\[?\s*(?:\.\.\.)?\s*(?:medicationDependentKeys|measurementDependentKeys|moodDependentKeys)\b/g;
// Match the CALL form only (open paren), never the bare import specifier —
// otherwise the `import { refetchInactiveDailyReads }` line would inflate the
// count and let a file drop one paired call while still "passing".
const REFETCH = /refetchInactiveDailyReads\(/g;
function sourceFiles(): string[] {
return globSync("**/*.{ts,tsx}", { cwd: SRC })
.filter(
(p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"),
)
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.map((p) => p.split(sep).join("/"))
.sort();
}
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
function count(s: string, re: RegExp): number {
return (s.match(re) ?? []).length;
}
/**
* Files that invalidate a daily bundle WITHOUT pairing the inactive refetch,
* deliberately. Frozen: a new entry is a conscious decision that must land in a
* reviewed diff, exactly like the Bearer-scope mint allowlist. Each entry is a
* surface where the missing refetch is intentional, grouped by reason.
*/
const NO_REFETCH_ALLOWLIST = [
// The digest is ACTIVE on the dashboard where the quick-add lives, so the
// default active invalidation already refetches it (audit B, §1: "not the
// bug").
"components/dashboard/medication-intake-quick-add.tsx",
// Injection-site metadata saved AFTER an already-recorded (and already
// refetched) take — it does not change any dose's due/taken state.
"components/medications/glp1-medication-card.tsx",
"components/medications/medication-card.tsx",
// Pull-to-refresh: a current-surface read gesture on the medications page,
// not a hero-affecting write (audit B, B-5).
"app/medications/page-client.tsx",
// Medication configuration / metadata surfaces — rename, dose, schedule
// times, phase config, push preferences, API tokens, the create wizard.
// None records an intake; a schedule edit's next-due shift converges via the
// standard poll from a deep-config surface. Tighten in a later pass if a
// config surface is shown to strand the rail.
"components/medications/scheduling/schedule-times-editor.tsx",
"components/medications/sections/api-tokens-row.tsx",
"components/medications/sections/notifications-section.tsx",
"components/medications/sections/phase-config-sheet.tsx",
"components/medications/sections/settings-section.tsx",
"components/medications/wizard/medication-wizard-dialog.tsx",
// Background integration-sync completion on the Settings surface — the user
// is configuring an integration, not acting on the hero; convergence via the
// daily reads' own poll/focus refetch is the intended behaviour.
"components/settings/integrations/fitbit-card.tsx",
"components/settings/integrations/google-health-card.tsx",
"components/settings/integrations/nightscout-card.tsx",
"components/settings/integrations/oauth-provider-card.tsx",
"components/settings/integrations/whoop-card.tsx",
"components/settings/integrations/withings-card.tsx",
].sort();
describe("daily-reads refetch guard", () => {
it("the blessed helper pairs the bundle invalidation with the inactive refetch", () => {
const src = read(FACTORY);
// The one canonical entry point every intake surface routes through.
expect(src).toMatch(/export async function invalidateMedicationReads\(/);
// It must do BOTH — invalidate the bundle AND force the inactive refetch.
const body = src.slice(
src.indexOf("export async function invalidateMedicationReads("),
);
expect(body).toMatch(
/invalidateKeys\(\s*queryClient,\s*medicationDependentKeys\s*\)/,
);
expect(body).toMatch(/refetchInactiveDailyReads\(queryClient\)/);
});
it("every bundle-invalidation site pairs a refetch, except the frozen allowlist", () => {
// For each file that invalidates a daily bundle, the count of paired
// refetches must be at least the count of invalidations. A count rule (not
// a line-proximity heuristic) catches BOTH a new unpaired file AND a new
// unpaired call site slipped into an already-compliant file (its refetch
// count would then fall below its invalidation count).
const nonCompliant: string[] = [];
for (const rel of sourceFiles()) {
if (rel === FACTORY) continue;
const s = read(rel);
const invalidations = count(s, INVALIDATION);
if (invalidations === 0) continue;
const refetches = count(s, REFETCH);
if (refetches < invalidations) nonCompliant.push(rel);
}
// The set of deliberately-unpaired sites must be EXACTLY the allowlist:
// a new intake mutation that forgets the refetch appears here but not in
// the allowlist → fail; a paired site that loses its refetch drops into
// this set → fail; a deliberate new exception must be added above.
expect(nonCompliant.sort()).toEqual(NO_REFETCH_ALLOWLIST);
});
it("the fixed intake surfaces route through the blessed helper or pair the refetch", () => {
// Positive pin: the v1.32.19 seed sites must carry the fix, so a revert to
// a bare `invalidateKeys(medicationDependentKeys)` is caught here as well
// as by the count assertion above.
const routedThroughHelper = [
"components/medications/take-all-due.ts",
"components/medications/intake-edit-dialog.tsx",
"components/medications/intake-history-editable.tsx",
"components/medications/intake-import-dialog.tsx",
"components/medications/sections/destructive-zone-section.tsx",
"components/medications/use-medication-intake.ts",
];
for (const rel of routedThroughHelper) {
expect(read(rel)).toMatch(/invalidateMedicationReads\(/);
}
const pairInPlace = [
"components/medications/dose-history-ledger.tsx",
"components/medications/dose-history-add-dialog.tsx",
];
for (const rel of pairInPlace) {
expect(read(rel)).toMatch(REFETCH);
}
});
});
@@ -0,0 +1,207 @@
/**
* Guard for the converted-value precision class (issue #627, second half).
*
* Every entry in the display-transform registry declares the `decimals` its
* unit is read at. Before v1.32.39 that number was carried all the way to the
* render layer and read by exactly one screen out of all the ones that show a
* converted value, so everywhere else the raw product of a kilogram and
* 2.20462262185 reached the surface unrounded. The fix moved the rounding into
* the conversion helpers, which means a surface gets a correctly-scaled number
* whether or not it remembers to ask.
*
* This file guards the two ways that could be undone:
*
* 1. A PROPERTY over the whole registry — every branch, swept over a spread
* of raw values, must come out already at its declared grain, and a
* non-rescaling branch must come out byte-identical. A property test is
* the honest shape here: the thing being guaranteed is arithmetic, and a
* structural sweep for "did the surface remember to round" is exactly the
* per-call-site discipline this change exists to abolish.
* 2. A STRUCTURAL freeze on the two ways to get an unrounded converted value
* anyway — importing the documented escape hatch, or hand-rolling
* `value * transform.factor + transform.offset` at a call site. Both sets
* are pinned to a named allowlist, so a new converted-value surface
* cannot quietly join them.
*/
import { readFileSync, globSync } from "node:fs";
import { join } from "node:path";
import { describe, it, expect } from "vitest";
import {
applyDisplayTransform,
applyDisplayTransformDelta,
getDisplayTransform,
transformRescales,
TRANSFORMED_TYPES,
} from "@/lib/measurements/display-transform";
const SRC = join(process.cwd(), "src");
/** The registry itself owns the arithmetic; it is not a call site. */
const REGISTRY = "lib/measurements/display-transform.ts";
/**
* The one module allowed to import the unrounded escape hatch. The target
* adapter needs the unrounded pound edge of a canonical guardrail so it can
* round it INWARD — rounding to the nearest tenth first gives 66.1 lb for the
* 30 kg floor, which inverts to 29.98 kg and the server rejects it.
*/
const UNROUNDED_IMPORTERS = ["lib/targets/target-unit-display.ts"];
/**
* The surfaces allowed to read a transform's raw `factor` / `offset`. All four
* do it for the same reason: the chart takes the multiplier as a prop, folds
* it into its own series at the single read boundary, and formats the result
* itself. Everything those files RENDER directly goes through the rounding
* helpers. Adding a file here means arguing that its number never reaches a
* screen unrounded.
*/
const RAW_SCALE_SURFACES = [
"app/page-client.tsx",
"app/insights/weight/page.tsx",
"components/insights/healthkit-metric-page.tsx",
"components/insights/device-score-tile.tsx",
];
/** Matches a `factor` / `offset` read off a display transform. */
const RAW_SCALE_READ =
/\b\w*[Tt]ransform(?:For\([^)]*\))?\??\.(factor|offset)\b/;
function sourceFiles(): string[] {
return globSync("**/*.{ts,tsx}", { cwd: SRC }).filter(
(rel) =>
// Never walk the Prisma client, and skip the suites that legitimately
// exercise the raw arithmetic they are asserting about.
!rel.startsWith("generated/") && !rel.includes("__tests__/"),
);
}
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
describe("display-transform rounding guard — the registry property", () => {
const RAW_VALUES = [
0, 1, 1.3, 10, 36.6, 36.66666, 37, 78.45, 83.99981845627636, 84, 95.25, 210,
1609.344, 8046.72, -0.5, -12.3456789,
];
it("sweeps a non-empty registry", () => {
// Guards against a set that silently empties out (a green void).
expect(TRANSFORMED_TYPES.size).toBeGreaterThan(5);
});
it("declares a usable decimal count on every branch", () => {
for (const type of TRANSFORMED_TYPES) {
for (const preference of ["metric", "imperial"] as const) {
const { decimals } = getDisplayTransform(type, preference);
expect(Number.isInteger(decimals)).toBe(true);
expect(decimals).toBeGreaterThanOrEqual(0);
}
}
});
it("never returns a converted value finer than its declared decimals", () => {
const offenders: string[] = [];
for (const type of TRANSFORMED_TYPES) {
for (const preference of ["metric", "imperial"] as const) {
const transform = getDisplayTransform(type, preference);
if (!transformRescales(transform)) continue;
const scale = 10 ** transform.decimals;
for (const raw of RAW_VALUES) {
for (const [label, shown] of [
["absolute", applyDisplayTransform(raw, transform)],
["delta", applyDisplayTransformDelta(raw, transform)],
] as const) {
// Re-rounding at the declared grain must be a no-op: the value
// carries no more precision than the transform says it should.
if (Math.round(shown * scale) / scale !== shown) {
offenders.push(
`${type}/${preference} ${label} ${raw} → ${shown}`,
);
}
}
}
}
}
expect(offenders).toEqual([]);
});
it("leaves a non-rescaling branch byte-identical", () => {
// The metric branch only relabels the symbol; the number it shows IS the
// stored number, and a stored 78.45 kg is not conversion noise.
const offenders: string[] = [];
for (const type of TRANSFORMED_TYPES) {
for (const preference of ["metric", "imperial"] as const) {
const transform = getDisplayTransform(type, preference);
if (transformRescales(transform)) continue;
for (const raw of RAW_VALUES) {
if (applyDisplayTransform(raw, transform) !== raw) {
offenders.push(`${type}/${preference} absolute ${raw}`);
}
if (applyDisplayTransformDelta(raw, transform) !== raw) {
offenders.push(`${type}/${preference} delta ${raw}`);
}
}
}
}
expect(offenders).toEqual([]);
});
it("leaves an untransformed type byte-identical", () => {
for (const type of ["PULSE", "BLOOD_GLUCOSE", "BODY_MASS_INDEX"]) {
const transform = getDisplayTransform(type, "imperial");
for (const raw of RAW_VALUES) {
expect(applyDisplayTransform(raw, transform)).toBe(raw);
expect(applyDisplayTransformDelta(raw, transform)).toBe(raw);
}
}
});
});
describe("display-transform rounding guard — the bypass freeze", () => {
it("sweeps a non-empty source tree", () => {
expect(sourceFiles().length).toBeGreaterThan(500);
});
it("confines the unrounded escape hatch to its documented importers", () => {
const importers = sourceFiles().filter(
(rel) =>
rel !== REGISTRY &&
read(rel).includes("applyDisplayTransformUnrounded"),
);
expect(importers.sort()).toEqual([...UNROUNDED_IMPORTERS].sort());
});
it("confines hand-rolled transform scaling to the chart-feeding surfaces", () => {
const offenders = sourceFiles().filter(
(rel) =>
rel !== REGISTRY &&
!UNROUNDED_IMPORTERS.includes(rel) &&
RAW_SCALE_READ.test(read(rel)),
);
expect(offenders.sort()).toEqual([...RAW_SCALE_SURFACES].sort());
});
it("every allowlisted surface reads the raw scale to feed a chart", () => {
// The justification for holding the raw multiplier at all: the chart
// takes it as a prop. A file that stops passing it has no reason to be
// reading `.factor` any more and belongs back behind the helpers.
// The prop as it is actually passed — a bare substring would still
// match a renamed identifier that no longer reaches any chart.
const offenders = RAW_SCALE_SURFACES.filter(
(rel) => !/\bvalueScale=/.test(read(rel)),
);
expect(offenders).toEqual([]);
});
it("every allowlisted surface still converts its rendered values", () => {
// The chart formats its own series; anything these files render outside
// the chart must come back through the preference hook.
const offenders = RAW_SCALE_SURFACES.filter(
(rel) => !read(rel).includes("useUnitDisplay()"),
);
expect(offenders).toEqual([]);
});
});
@@ -0,0 +1,149 @@
/**
* Structural guard: a rendered scope control with no gating path fails the
* build, and a gating path with no control fails it too.
*
* The export panel once shipped twenty data switches, eight of which changed
* nothing at all — the wire fold read twelve of the keys it accepted and
* dropped the rest on the floor. Nothing failed; the control simply lied, and
* it lied for several releases because no test connected "the panel renders
* this" to "the export honours this".
*
* The selection model removed the fold, so the shape that defect lived in is
* gone. This file is the same connection retargeted at the leaf catalogue,
* which is now the one thing the picker, the aggregator, the PDF and the share
* view all read:
*
* 1. Every leaf the catalogue declares is reachable in the picker — the
* picker renders from the catalogue, so the check is that the catalogue
* places each leaf in exactly one group and the picker renders each group.
* 2. Flipping exactly one leaf changes the resolved selection. This is the
* assertion that would have caught all eight dead switches.
* 3. Every leaf is honoured somewhere downstream: as a measurement type the
* gate strips, or by name in the aggregator's own body.
*
* What it does NOT prove: that the excluded data is actually absent from a
* rendered artefact. That is behavioural and is covered where the artefacts are
* built — `src/app/api/export/health-record/__tests__/route.test.ts` opens the
* real PDF, the real bundle and the real zip. This guard is the cheap
* structural half that cannot be satisfied by writing a plausible-looking
* control.
*
* Mutation checks are recorded per assertion below.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
ALL_LEAF_IDS,
MEASUREMENT_LEAF_GROUP,
REPORT_GROUPS,
STRUCTURED_LEAF_GROUP,
isStructuredLeafId,
leafGroup,
type ReportLeafId,
} from "@/lib/report-selection/catalogue";
import { selectionFromLeaves } from "@/lib/report-selection/selection";
const REPO_ROOT = join(__dirname, "..", "..");
const COLLECT_PATH = join(
REPO_ROOT,
"src",
"lib",
"doctor-report",
"collect.ts",
);
const PICKER_PATH = join(
REPO_ROOT,
"src",
"components",
"settings",
"report-selection",
"report-scope-picker.tsx",
);
const collectSource = readFileSync(COLLECT_PATH, "utf8");
const pickerSource = readFileSync(PICKER_PATH, "utf8");
/** Strip comments so a leaf named only in prose does not count as read. */
function stripComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}
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(90);
expect(Object.keys(MEASUREMENT_LEAF_GROUP)).toHaveLength(77);
expect(Object.keys(STRUCTURED_LEAF_GROUP)).toHaveLength(13);
});
it("places every leaf in exactly one group", () => {
const seen = new Map<ReportLeafId, number>();
for (const group of REPORT_GROUPS) {
for (const leaf of group.leaves) {
seen.set(leaf, (seen.get(leaf) ?? 0) + 1);
}
}
const duplicated = [...seen.entries()].filter(([, n]) => n > 1);
const missing = ALL_LEAF_IDS.filter((leaf) => !seen.has(leaf));
expect(duplicated, "a leaf appears in more than one group").toEqual([]);
expect(missing, "a leaf is in no group and so is unreachable").toEqual([]);
});
it("renders every group the catalogue declares", () => {
// The picker maps over `REPORT_GROUPS` and splits the fenced tier out by
// id, so this is the check that the split cannot silently drop a group.
expect(pickerSource).toContain("REPORT_GROUPS.map");
expect(pickerSource).toContain("SENSITIVE_GROUP_ID");
});
it("makes flipping any single leaf change the resolved selection", () => {
const all = selectionFromLeaves(ALL_LEAF_IDS);
const inert: ReportLeafId[] = [];
for (const leaf of ALL_LEAF_IDS) {
const without = selectionFromLeaves(
ALL_LEAF_IDS.filter((l) => l !== leaf),
);
if (
without.has(leaf) ||
without.leaves.length !== all.leaves.length - 1
) {
inert.push(leaf);
}
}
expect(
inert,
"these leaves cannot be individually withheld, so their control would lie",
).toEqual([]);
});
it("has something downstream read every leaf", () => {
// A leaf is honoured either as a measurement type — the gate strips those
// by set membership over the whole enum — or by name in the aggregator.
const body = stripComments(collectSource);
const unread = ALL_LEAF_IDS.filter(
(leaf) => isStructuredLeafId(leaf) && !body.includes(`"${leaf}"`),
);
expect(
unread,
"these structured leaves are never read in the aggregator, so switching " +
"them off would change nothing that leaves the instance",
).toEqual([]);
});
it("keeps every sensitive leaf in the fenced tier", () => {
for (const leaf of [
"MOOD",
"CYCLE",
"FAMILY_HISTORY",
"PHQ9_SCORE",
"GAD7_SCORE",
"WHO5_SCORE",
"SCI_SCORE",
] as ReportLeafId[]) {
expect(leafGroup(leaf), `${leaf} must be fenced`).toBe("sensitive");
}
});
});
@@ -0,0 +1,156 @@
/**
* Structural guard on the E2E suite's route-cleanup boundary.
*
* A spec that arms `page.route(...)` in a `beforeEach` and never drops the
* handler races its own teardown: the context closes, an in-flight handler
* rejects, and Playwright reports a test that had already passed as failed.
* `e2e/setup/test.ts` removes the possibility with an auto fixture, but only
* for specs that take their `test` from there — a spec that imports straight
* from `@playwright/test` silently opts out and looks identical in review.
*
* So the entry point is the invariant, and this file is what keeps it true.
* It is a tripwire, not a proof: it shows nobody has changed the import
* boundary without editing this file. That the fixture actually cleans up is
* a runtime property, proven by the suite itself.
*/
import { describe, it, expect } from "vitest";
import { readFileSync, globSync } from "node:fs";
import { join, sep } from "node:path";
const E2E = join(process.cwd(), "e2e");
/** The shared entry point, relative to `e2e/`. */
const ENTRY = "setup/test.ts";
/**
* Files allowed to import `test` from `@playwright/test` directly, each with
* the reason it has to.
*/
const DIRECT_IMPORT_ALLOWLIST: Record<string, string> = {
// The entry point itself — it extends Playwright's base `test`, which it
// cannot do without importing it.
[ENTRY]: "defines the extended `test` every other file imports",
};
function e2eFiles(): string[] {
return globSync("**/*.ts", { cwd: E2E })
.map((p) => p.split(sep).join("/"))
.sort();
}
function read(rel: string): string {
return readFileSync(join(E2E, rel), "utf8");
}
/**
* The file with its comments removed. The entry point documents the very API
* calls it makes, so a naive substring match would be satisfied by the prose
* alone — a fixture gutted to a no-op would still read as compliant.
*/
function stripComments(src: string): string {
return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}
/**
* Every `from "<module>"` import statement in a file, as
* `{ module, specifiers }` — `specifiers` is the raw brace body, or `"*"` for
* a namespace import.
*/
function imports(src: string): Array<{ module: string; specifiers: string }> {
const found: Array<{ module: string; specifiers: string }> = [];
for (const m of src.matchAll(
/import\s+(type\s+)?\{([\s\S]*?)\}\s*from\s*"([^"]+)"/g,
)) {
// `import type { … }` imports no runtime binding at all.
found.push({ module: m[3], specifiers: m[1] ? "" : m[2] });
}
for (const m of src.matchAll(/import\s+\*\s+as\s+\w+\s+from\s*"([^"]+)"/g)) {
found.push({ module: m[1], specifiers: "*" });
}
return found;
}
/** Does this file import the runtime `test` binding from `module`? */
function importsTestFrom(src: string, module: string): boolean {
return imports(src)
.filter((i) => i.module === module)
.some(
(i) =>
i.specifiers === "*" ||
i.specifiers
.split(",")
.map((s) => s.trim())
.filter((s) => s && !s.startsWith("type "))
.some((s) => /^test(\s+as\s+\w+)?$/.test(s)),
);
}
describe("the E2E suite takes `test` from the shared entry point", () => {
it("no file outside the allowlist imports `test` from @playwright/test", () => {
const offenders = e2eFiles().filter(
(rel) =>
!(rel in DIRECT_IMPORT_ALLOWLIST) &&
importsTestFrom(read(rel), "@playwright/test"),
);
expect(offenders).toEqual([]);
});
it("the allowlist names only files that exist and still need the exemption", () => {
for (const rel of Object.keys(DIRECT_IMPORT_ALLOWLIST)) {
expect(
importsTestFrom(read(rel), "@playwright/test"),
`${rel} is allowlisted but no longer imports \`test\` directly — drop the entry`,
).toBe(true);
}
});
it("every spec imports `test` from the entry point", () => {
const specs = e2eFiles().filter((rel) => rel.endsWith(".spec.ts"));
// Sanity: the sweep found the suite, not an empty directory.
expect(specs.length).toBeGreaterThan(40);
const missing = specs.filter((rel) => {
const src = read(rel);
// Specs sit at `e2e/<name>.spec.ts`, so the entry resolves as
// `./setup/test`. A nested spec would need a different prefix; none
// exists, and this assertion is what would surface one.
return !importsTestFrom(src, "./setup/test");
});
expect(missing).toEqual([]);
});
});
describe("the entry point still cleans up", () => {
const src = stripComments(read(ENTRY));
it("registers the cleanup as an automatic fixture", () => {
// `auto: true` is the whole point: a spec author cannot forget it and does
// not have to declare it. Without the flag the fixture is dead code.
expect(src).toMatch(/\{\s*auto:\s*true\s*\}/);
});
it("drops route handlers on both the page and the context", () => {
expect(src).toMatch(/page\.unrouteAll\(\{\s*behavior:\s*"ignoreErrors"/);
expect(src).toMatch(/context\.unrouteAll\(\{\s*behavior:\s*"ignoreErrors"/);
});
it("drops page event listeners", () => {
expect(src).toMatch(/removeAllListeners\(/);
});
it("cleans up after the test body, not before it", () => {
// `await use()` first, cleanup after — a fixture that cleaned up before
// the test would unroute the handlers a `beforeEach` had just armed.
const useAt = src.indexOf("await use()");
const unrouteAt = src.indexOf("unrouteAll(");
expect(useAt).toBeGreaterThan(-1);
expect(unrouteAt).toBeGreaterThan(useAt);
});
it("exports both `test` and `expect`, so a spec needs one import", () => {
expect(src).toMatch(/export const test\b/);
expect(src).toMatch(/export \{ expect \}/);
});
});
@@ -0,0 +1,404 @@
/**
* Structural guard on the external-id stability floor.
*
* A client-supplied `externalId` is half of every ingest dedup key. An id
* that rotates between client launches never matches its own earlier row,
* so each sync sweep mints a fresh record — the failure mode that filled a
* live instance with phantom medications, one per sweep, none of which
* could ever collect a dose.
*
* The fix is only durable if it cannot be bypassed by the NEXT route.
* These guards freeze the two legal ways to accept the field:
*
* 1. the request schema routes it through `assertStableExternalId`, so
* the refusal rides the standard 422 multi-issue envelope; or
* 2. the field is deliberately permissive because a schema-level refusal
* would fail a whole batch on one bad row — in which case the
* declaration carries an `@external-id-checked-per-entry: <path>`
* marker naming the file that runs `classifyExternalId` per entry,
* and this guard verifies that file really does.
*
* They are tripwires, not proofs. A reviewer who waves through a bad
* addition defeats them, and no test substitutes for that review.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join, sep } from "node:path";
import { globSync } from "node:fs";
const SRC = join(process.cwd(), "src");
const VALIDATOR_MODULE = "@/lib/validations/external-id";
function sourceFiles(): string[] {
return globSync("**/*.{ts,tsx}", { cwd: SRC })
.filter(
(p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"),
)
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.map((p) => p.split(sep).join("/"))
.sort();
}
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
/**
* The file with its `import … from "…";` statements removed.
*
* A guard that greps the raw source cannot tell "this file APPLIES the
* check" from "this file merely imports it" — deleting the call while
* leaving the import would slip straight through. Every usage assertion
* below therefore runs against the body, and the module import is
* asserted separately where it matters.
*/
function body(rel: string): string {
return read(rel).replace(/import\s+[\s\S]*?from\s+"[^"]+";/g, "");
}
/**
* A REQUEST-side declaration of a client-supplied identity string:
* `externalId: z.…` / `externalSourceId: z.…` / `idempotencyKey: z.…` in a
* Zod object shape.
*
* `idempotencyKey` is here because `MedicationIntakeEvent.idempotency_key`
* is NOT a windowed replay token — it is a `@unique` column persisted with
* the dose row forever and matched by plain equality, which makes it an
* identity with the same failure mode. The HTTP `Idempotency-Key` HEADER
* is a different thing and stays out: `IdempotencyKey.expiresAt` bounds it
* and `lib/jobs/idempotency-cleanup.ts` sweeps it.
*
* Response DTOs under `lib/openapi/` are excluded below — those echo a
* value the server already stored and validating them would be validating
* our own output.
*/
const DECLARATION =
/^\s*(externalId|externalSourceId|idempotencyKey)\s*:\s*z\b/gm;
/** `// @external-id-checked-per-entry: <path under repo root>` */
const MARKER = /@external-id-checked-per-entry:\s*(\S+)/;
/**
* Files that declare the field but are deliberately outside the floor.
* Each entry states WHY; an addition here is a decision, not a shortcut.
*/
const EXEMPT: Record<string, string> = {
// Backup restore reads a payload the SERVER itself wrote and encrypted.
// It has to reproduce the stored rows byte-for-byte — including ids that
// predate this floor — or a self-hoster with an already-poisoned export
// could never restore it. Nothing client-authored reaches this schema.
"lib/validations/backup.ts":
"restore payload is a server-written backup; refusing a stored id would block a restore",
};
/**
* Trees where a stored identity is derived from input that never passes
* through a Zod request schema — an uploaded file's row, or an MCP host's
* tool argument. The declaration rule above cannot see those, so this one
* works on provenance instead: inside these trees, any module that touches
* an identity must either apply the floor itself or import a module that
* does.
*
* That is a real chain, not a list of known offenders: a NEW importer that
* mints an `externalId` from file input and imports nothing checked fails
* here, with no allowlist to add itself to.
*/
const DERIVED_IDENTITY_TREES = ["lib/import/", "app/api/import/", "lib/mcp/"];
const IDENTITY_MENTION = /\b(externalId|idempotencyKey)\b/;
/** Floor symbols that count as "this module applies the check itself". */
const FLOOR_SYMBOLS = [
"classifyExternalId",
"isStableExternalId",
"assertStableExternalId",
"assertStableIdempotencyKey",
];
/** Local `@/…` and relative modules this file imports, as src-relative paths. */
function importedLocalModules(rel: string): string[] {
const source = read(rel);
const dir = rel.split("/").slice(0, -1).join("/");
const out: string[] = [];
for (const m of source.matchAll(/from\s+"([^"]+)"/g)) {
const spec = m[1];
let target: string | null = null;
if (spec.startsWith("@/")) target = spec.slice(2);
else if (spec.startsWith("./")) target = `${dir}/${spec.slice(2)}`;
else if (spec.startsWith("../")) {
const parts = dir.split("/");
let rest = spec;
while (rest.startsWith("../")) {
parts.pop();
rest = rest.slice(3);
}
target = [...parts, rest].join("/");
}
if (target) out.push(target.replace(/\.tsx?$/, ""));
}
return out;
}
function appliesFloor(rel: string): boolean {
const b = body(rel);
return FLOOR_SYMBOLS.some((sym) => b.includes(sym));
}
/** Lines of `rel` (1-based) on which a request-side declaration appears. */
function declarationLines(rel: string): number[] {
const source = read(rel);
const lines: number[] = [];
DECLARATION.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = DECLARATION.exec(source)) !== null) {
lines.push(source.slice(0, m.index).split("\n").length);
}
return lines;
}
function declaringFiles(): string[] {
return sourceFiles()
.filter((rel) => !rel.startsWith("lib/openapi/"))
.filter((rel) => declarationLines(rel).length > 0);
}
describe("every client-supplied external id routes through the stability floor", () => {
it("finds the declaring files at all (the matcher still matches)", () => {
// A guard whose matcher silently stops matching passes forever. Pin the
// known set so a rename of the field or of the Zod idiom trips here.
const files = declaringFiles();
expect(files.length).toBeGreaterThanOrEqual(8);
expect(files).toContain("app/api/measurements/batch/route.ts");
expect(files).toContain("lib/validations/medication/create-update.ts");
});
it.each(declaringFiles())(
"%s either applies the field check or names the per-entry checker",
(rel) => {
if (rel in EXEMPT) {
expect(EXEMPT[rel].length).toBeGreaterThan(20);
return;
}
const usesFieldCheck =
read(rel).includes(VALIDATOR_MODULE) &&
(body(rel).includes("assertStableExternalId") ||
body(rel).includes("assertStableIdempotencyKey"));
const marksPerEntry = MARKER.test(read(rel));
expect(
usesFieldCheck || marksPerEntry,
`${rel} declares a client-supplied identity string but neither applies ` +
"`assertStableExternalId` / `assertStableIdempotencyKey` to its request schema " +
"nor marks the declaration `@external-id-checked-per-entry: <path>` naming the " +
"route that checks it per entry.",
).toBe(true);
},
);
it.each(declaringFiles())(
"%s — every per-entry marker points at a file that really checks per entry",
(rel) => {
if (rel in EXEMPT) return;
const source = read(rel);
MARKER.lastIndex = 0;
const targets = [...source.matchAll(new RegExp(MARKER, "g"))].map(
(m) => m[1],
);
for (const target of targets) {
expect(
target.startsWith("src/"),
`${rel}: marker target "${target}" must be a repo-root-relative src/ path`,
).toBe(true);
const checker = body(target.replace(/^src\//, ""));
expect(
checker.includes("classifyExternalId"),
`${rel} points its per-entry marker at ${target}, but that file never calls classifyExternalId.`,
).toBe(true);
}
},
);
});
describe("identities derived outside a Zod schema stay inside the floor", () => {
/**
* The CSV importer builds an `externalId` from a spreadsheet column, and
* the MCP write path hashes a host-supplied key into one. Neither goes
* through a Zod request field, so the declaration rule above is blind to
* both — which is exactly the gap this rule closes.
*/
function derivedIdentityFiles(): string[] {
return sourceFiles()
.filter((rel) => DERIVED_IDENTITY_TREES.some((t) => rel.startsWith(t)))
.filter((rel) => IDENTITY_MENTION.test(read(rel)));
}
it("finds the derived-identity modules at all (the matcher still matches)", () => {
const files = derivedIdentityFiles();
expect(files).toContain("lib/import/csv-measurements.ts");
expect(files).toContain("lib/mcp/writes.ts");
});
it.each(derivedIdentityFiles())(
"%s applies the floor itself or imports a module that does",
(rel) => {
if (appliesFloor(rel)) return;
const sources = importedLocalModules(rel).filter((target) => {
try {
return appliesFloor(`${target}.ts`);
} catch {
return false;
}
});
expect(
sources.length,
`${rel} handles an external identity but neither applies the stability floor ` +
"nor imports a module that does. It derives an identity from input no Zod " +
"request schema sees (a file row, an MCP tool argument), so nothing else " +
"will catch an unstable value here.",
).toBeGreaterThan(0);
},
);
it("the CSV importer refuses an unstable id with its own row reason", () => {
// The importer has no per-entry Zod field to hang a refine on; its
// contract is a per-row `reason` code. Pin that the floor is wired to
// that contract and not merely imported.
const b = body("lib/import/csv-measurements.ts");
expect(b).toContain("isStableExternalId");
expect(b).toContain('skip("unstable_external_id")');
expect(read("lib/import/csv-measurements.ts")).toContain(
'| "unstable_external_id"',
);
});
it("the MCP write path checks the key BEFORE it is hashed into an identity", () => {
// A SHA-256 of a pointer address is clean hex, so the externalId floor
// downstream can never see the shape. The check has to run on the input.
const b = body("lib/mcp/writes.ts");
const guardAt = b.indexOf("checkMcpIdempotencyKey");
const hashAt = b.indexOf("mcpExternalId");
expect(guardAt).toBeGreaterThan(-1);
expect(hashAt).toBeGreaterThan(-1);
expect(b).toContain("classifyExternalId");
// Every entry point returns the refusal before reaching the hash.
for (const fn of [
"logMcpMeasurement",
"logMcpMood",
"logMcpBloodPressure",
]) {
const start = b.indexOf(`export async function ${fn}`);
expect(start, `${fn} not found`).toBeGreaterThan(-1);
const scope = b.slice(start, start + 2000);
expect(
scope.indexOf("checkMcpIdempotencyKey"),
`${fn} does not check the idempotency key before writing`,
).toBeGreaterThan(-1);
}
});
});
describe("the intake idempotency key is never read across users", () => {
/**
* `medication_intake_events.idempotency_key` carried a GLOBAL unique
* from 0001_init until migration 0273. Client-supplied keys on a global
* unique meant one user's key blocked another user's write outright,
* and an unscoped lookup resolved a stranger's row and handed back its
* id. The column is now unique per user; every read has to key on
* `(userId, idempotencyKey)` or the leak comes straight back.
*/
function intakeQueriesFilteringOnTheKey(): Array<{
file: string;
line: number;
scoped: boolean;
}> {
const out: Array<{ file: string; line: number; scoped: boolean }> = [];
for (const rel of sourceFiles()) {
const source = read(rel);
if (!source.includes("medicationIntakeEvent.")) continue;
const call = /medicationIntakeEvent\.(\w+)\(\s*\{/g;
let m: RegExpExecArray | null;
while ((m = call.exec(source)) !== null) {
// Walk to the matching brace so the window is the call, not a
// fixed slice that could bleed into the next statement.
let depth = 0;
let j = source.indexOf("{", m.index);
for (; j < source.length; j++) {
if (source[j] === "{") depth++;
else if (source[j] === "}" && --depth === 0) break;
}
const body = source.slice(m.index, j + 1);
const whereAt = body.indexOf("where");
if (whereAt === -1) continue;
const where = body.slice(whereAt, whereAt + 400);
if (!where.includes("idempotencyKey")) continue;
out.push({
file: rel,
line: source.slice(0, m.index).split("\n").length,
// Keyed on the row's own primary id is scoped by construction —
// that is an update-by-id that happens to WRITE the key.
scoped: where.includes("userId") || /where:\s*\{\s*id:/.test(where),
});
}
}
return out;
}
it("finds the reads at all (the matcher still matches)", () => {
const found = intakeQueriesFilteringOnTheKey();
expect(found.length).toBeGreaterThanOrEqual(6);
expect(found.map((f) => f.file)).toContain(
"app/api/medications/intake/bulk/route.ts",
);
});
it("every query filtering on the key also narrows by userId", () => {
const unscoped = intakeQueriesFilteringOnTheKey().filter((f) => !f.scoped);
expect(
unscoped,
"a medicationIntakeEvent query filters on idempotencyKey without a userId. " +
"The key is client-supplied and unique per USER (migration 0273); an " +
"unscoped read resolves another user's row.",
).toEqual([]);
});
it("the schema keeps the key unique per user, not globally", () => {
const schema = readFileSync(
join(process.cwd(), "prisma", "schema.prisma"),
"utf8",
);
expect(schema).toContain("@@unique([userId, idempotencyKey])");
expect(schema).not.toMatch(
/idempotencyKey\s+String\?\s+@unique\s+@map\("idempotency_key"\)/,
);
});
});
describe("the batch surfaces refuse per entry, never whole-batch", () => {
// Each of these drains a client sync queue. A schema-level refusal here
// would stop a whole batch of good rows because of one bad one, which is
// worse than the duplicate it prevents.
const BATCH_ROUTES = [
"app/api/measurements/batch/route.ts",
"app/api/mood-entries/bulk/route.ts",
"app/api/medications/intake/bulk/route.ts",
"app/api/workouts/batch/route.ts",
"app/api/cycle/day-logs/bulk/route.ts",
"app/api/import/route.ts",
];
it.each(BATCH_ROUTES)("%s classifies each entry itself", (rel) => {
const source = body(rel);
expect(source).toContain("classifyExternalId");
expect(source).toContain("unstableExternalIdMeta");
});
it.each(BATCH_ROUTES.filter((r) => r !== "app/api/import/route.ts"))(
"%s reports the shared per-entry reason code",
(rel) => {
// The JSON importer has no per-entry status envelope — it reports a
// skipped COUNT — so it is the one batch surface without the code.
expect(body(rel)).toContain("UNSTABLE_EXTERNAL_ID_REASON");
},
);
});
+334
View File
@@ -0,0 +1,334 @@
/**
* v1.32.36 — the reachability matcher behind `i18n-reverse-coverage.test.ts`,
* extracted so it can be unit-tested in isolation.
*
* The reverse guard asserts that every leaf in `messages/en.json` is reachable
* from at least one call site. It is a pair guard: one end is the bundle, the
* other end is the tree. A pair guard is only worth its green state while its
* matcher stays honest, and this one had degraded — see the bare-namespace note
* below.
*
* ## Recognised construction patterns
*
* - a literal t("a.b.c"), and a t("a.b") that returns a subtree;
* - pluralKey("a.b", n) / tCount("a.b", n), which resolve to the three
* One / Few / Other tiers and nothing else;
* - a **pair template** — a t(template) call carrying BOTH a literal prefix and
* a literal suffix, as in the per-provider OAuth status keys. Both ends must
* match, so the template covers settings.polarOauthConnected and nothing else
* under settings.;
* - a **prefix template** — a literal prefix and no suffix, as in the cycle
* phase labels, covering the cycle.phase.* subtree;
* - a **suffix template** — a leading interpolation and a literal tail, as in
* the per-metric insight page titles. The tail alone means nothing, so it is
* matched only against a base that some other arm has established;
* - a declared i18n base — a dotted literal assigned to an `i18nPrefix` prop or
* variable. This is the convention the OAuth provider card, the cadence
* picker and the HealthKit metric page already follow. A declared base covers
* the exact key and serves as the base half of a suffix template; it does NOT
* open its subtree;
* - a full string-literal node path, covering the subtree it names (map values
* handed to t());
* - an explicit allowlist for keys resolved through a DB column.
*
* ## Why a bare namespace is refused rather than accepted
*
* Before v1.32.36, a prefix ending in a dot was recorded as a subtree base
* regardless of depth. The per-provider OAuth template therefore registered the
* base `settings` and marked every one of the several thousand keys under
* settings.* as reachable, and a second instance did the same for
* measurementReminders.*. The guard still passed, so the degradation was
* invisible: a pair guard had quietly become a one-end guard.
*
* Accepting a bare namespace silently is a false pass. Refusing it silently
* would only trade that for a false orphan report. So a suffix-less template
* rooted at a bare top-level namespace is a **collector violation**: the guard
* fails and names the template. The two honest remedies are to build a
* fully-qualified key at the call site (as the measurement-reminder due helpers
* do) or to give the template a literal suffix so it becomes a pair.
*/
/** A template whose prefix is a bare top-level namespace and which has no suffix. */
export interface BareNamespaceViolation {
/** The offending literal, as it appears in the source. */
literal: string;
/** Which ingestion arm saw it first. */
arm: "template" | "assignment" | "concat";
}
/** A template carrying both a literal prefix and a literal suffix. */
interface KeyPair {
/** Raw leading literal, dot-terminated or not. */
prefix: string;
/** Raw trailing literal, leading dot preserved. */
suffix: string;
}
export interface Reference {
/** Literal t("…") keys plus the plural tiers. */
literalKeys: Set<string>;
/** Dotted bases (deep prefix template / a full string-literal node path): cover the subtree. */
dottedBases: Set<string>;
/** Dot-less concat prefixes: cover any leaf that string-starts with them. */
concatPrefixes: Set<string>;
/** Prefix+suffix templates: both ends must match. */
pairs: KeyPair[];
/** Trailing literals of a leading-interpolation template. */
suffixes: Set<string>;
/** Bases declared through an `i18nPrefix` literal. Exact match plus suffix base. */
declaredPrefixes: Set<string>;
}
export interface CollectResult {
ref: Reference;
violations: BareNamespaceViolation[];
}
/** `settings` is bare; `settings.sections` is not. */
function isBareNamespace(base: string): boolean {
return !base.includes(".");
}
/**
* Split a template body into its leading and trailing literals: the admin
* section title template yields the prefix "admin.section." and the suffix
* ".title".
*/
function splitTemplate(content: string): {
prefix: string | null;
suffix: string | null;
} {
const open = content.indexOf("${");
if (open === -1) return { prefix: null, suffix: null };
const rawPrefix = open > 0 ? content.slice(0, open) : "";
const close = content.lastIndexOf("}");
const rawSuffix =
close !== -1 && close < content.length - 1 ? content.slice(close + 1) : "";
return {
// A prefix with no dot is not an i18n path root; ignore it.
prefix: rawPrefix.includes(".") ? rawPrefix : null,
suffix: rawSuffix || null,
};
}
/**
* Body of the template literal whose opening backtick sits at `start`.
*
* Scanning forward from a known opening backtick rather than pairing backticks
* across the whole tree matters: the concatenated sources hold tens of
* thousands of backticks, plenty of them inside comments and strings, so naive
* pairing silently shifts and whole files fall out of the scan. That is how a
* matcher stops seeing call sites without anyone noticing.
*/
function templateBodyAt(text: string, start: number): string | null {
let depth = 0;
for (let i = start + 1; i < text.length; i++) {
const c = text[i];
if (c === "\\") {
i++;
continue;
}
if (c === "$" && text[i + 1] === "{") {
depth++;
i++;
continue;
}
if (c === "}" && depth > 0) {
depth--;
continue;
}
if (c === "`" && depth === 0) return text.slice(start + 1, i);
}
return null;
}
export function collectReferences(
allText: string,
allNodes: Set<string>,
topNamespaces: Set<string>,
): CollectResult {
const literalKeys = new Set<string>();
const dottedBases = new Set<string>();
const concatPrefixes = new Set<string>();
const suffixes = new Set<string>();
const declaredPrefixes = new Set<string>();
const pairs: KeyPair[] = [];
const pairSeen = new Set<string>();
const violations: BareNamespaceViolation[] = [];
const violationSeen = new Set<string>();
const addPair = (prefix: string, suffix: string) => {
const id = [prefix, suffix].join(" ");
if (pairSeen.has(id)) return;
pairSeen.add(id);
pairs.push({ prefix, suffix });
};
/**
* The single door into `dottedBases` for every ingestion arm. A bare
* top-level namespace never opens a subtree — it is reported instead.
* Violations dedupe on the literal, not on the arm: one template is seen by
* both the t() arm and the generic backtick arm, and it is one defect.
*/
const addDottedBase = (
base: string,
arm: BareNamespaceViolation["arm"],
literal: string,
) => {
if (isBareNamespace(base)) {
if (violationSeen.has(literal)) return;
violationSeen.add(literal);
violations.push({ literal, arm });
return;
}
dottedBases.add(base);
};
const literalKeyRe =
/(?<![a-zA-Z0-9_])t\(\s*["']([a-zA-Z][a-zA-Z0-9_.-]+)["']/g;
for (const m of allText.matchAll(literalKeyRe)) literalKeys.add(m[1]);
// Plural-tier composition: tCount("dashboard.staleHintWeeks", n) and the
// underlying pluralKey("…", n, locale) both resolve to <base>One / <base>Few
// / <base>Other at runtime, so the base literal accounts for exactly those
// three leaves and no others. Kept as an explicit tier list rather than a
// prefix match so a stray sibling key under the same base still reports as
// an orphan.
const pluralKeyRe =
/(?:pluralKey|tCount)\(\s*["']([a-zA-Z][a-zA-Z0-9_.-]+)["']/g;
for (const m of allText.matchAll(pluralKeyRe)) {
for (const tier of ["One", "Few", "Other"])
literalKeys.add(`${m[1]}${tier}`);
}
const ingestTemplate = (
content: string,
arm: BareNamespaceViolation["arm"],
) => {
const { prefix, suffix } = splitTemplate(content);
if (prefix && suffix) {
addPair(prefix, suffix);
return;
}
if (prefix) {
if (prefix.endsWith("."))
addDottedBase(prefix.slice(0, -1), arm, content);
else concatPrefixes.add(prefix);
return;
}
if (suffix) suffixes.add(suffix);
};
// Templates passed directly to t(…).
const tStartRe = /(?<![a-zA-Z0-9_])t\(\s*`/g;
for (const m of allText.matchAll(tStartRe)) {
const body = templateBodyAt(allText, m.index + m[0].length - 1);
if (body && body.includes("${")) ingestTemplate(body, "template");
}
// Key strings assembled outside the call and handed to t() (the
// describeInjectionSite pattern). Recognised only when the template's
// leading literal is rooted at a real top-level i18n namespace, so a cache
// key / action name that interpolates can't mask a dead key.
const templateStartRe = /`[a-zA-Z][a-zA-Z0-9_.-]*\$\{/g;
for (const m of allText.matchAll(templateStartRe)) {
const body = templateBodyAt(allText, m.index);
if (!body) continue;
const { prefix } = splitTemplate(body);
if (!prefix) continue;
if (!topNamespaces.has(prefix.split(".")[0])) continue;
ingestTemplate(body, "assignment");
}
// String-concatenation prefixes: "notifications.event" + eventType builds
// keys like notifications.eventMoodReminder. Recognised only when the
// literal is rooted at a real top-level namespace. The concat arm sees no
// suffix, so a dot-terminated namespace root here is the same hole shape the
// template arm refuses.
const plusConcatRe = /["']([a-zA-Z][a-zA-Z0-9_.]*)["']\s*\+/g;
for (const m of allText.matchAll(plusConcatRe)) {
const lit = m[1];
if (!lit.includes(".")) continue;
if (!topNamespaces.has(lit.split(".")[0])) continue;
if (lit.endsWith(".")) addDottedBase(lit.slice(0, -1), "concat", lit);
else concatPrefixes.add(lit);
}
// Declared i18n bases. The component that receives one builds keys by
// appending a literal suffix, whose trailing literals live in `suffixes`;
// the declaration supplies the base half. Deliberately NOT a subtree base —
// only the exact key and the suffix pairing.
const declaredPrefixRe = /i18nPrefix\s*[=:]\s*["']([a-zA-Z][\w.-]*)["']/g;
for (const m of allText.matchAll(declaredPrefixRe)) {
const lit = m[1];
if (!lit.includes(".")) continue;
if (!topNamespaces.has(lit.split(".")[0])) continue;
declaredPrefixes.add(lit);
}
// Full string-literal node paths (covers map values returned to t()).
const litPathRe = /["']([a-zA-Z][a-zA-Z0-9_-]*(?:\.[a-zA-Z0-9_-]+)+)["']/g;
for (const m of allText.matchAll(litPathRe)) {
if (allNodes.has(m[1])) dottedBases.add(m[1]);
}
return {
ref: {
literalKeys,
dottedBases,
concatPrefixes,
pairs,
suffixes,
declaredPrefixes,
},
violations,
};
}
/** Bases a suffix template may attach to. */
function suffixBaseMatches(base: string, ref: Reference): boolean {
for (const b of ref.dottedBases) {
if (base === b || base.startsWith(`${b}.`) || b.startsWith(`${base}.`)) {
return true;
}
}
for (const ck of ref.literalKeys) {
if (base === ck || base.startsWith(`${ck}.`) || ck.startsWith(`${base}.`)) {
return true;
}
}
for (const pre of ref.concatPrefixes) {
if (base.startsWith(pre)) return true;
}
return ref.declaredPrefixes.has(base);
}
export function isReferenced(leaf: string, ref: Reference): boolean {
if (ref.literalKeys.has(leaf)) return true;
for (const ck of ref.literalKeys) {
if (leaf.startsWith(`${ck}.`)) return true;
}
for (const base of ref.dottedBases) {
if (leaf === base || leaf.startsWith(`${base}.`)) return true;
}
for (const pre of ref.concatPrefixes) {
if (leaf.startsWith(pre)) return true;
}
if (ref.declaredPrefixes.has(leaf)) return true;
for (const { prefix, suffix } of ref.pairs) {
if (
leaf.length > prefix.length + suffix.length &&
leaf.startsWith(prefix) &&
leaf.endsWith(suffix)
) {
return true;
}
}
for (const suffix of ref.suffixes) {
if (!leaf.endsWith(suffix) || leaf.length === suffix.length) continue;
const base = leaf.slice(0, leaf.length - suffix.length).replace(/\.$/, "");
if (!base) continue;
if (suffixBaseMatches(base, ref)) return true;
}
return false;
}
@@ -109,7 +109,6 @@ const LEGIT_IDENTICAL = new Set<string>([
"settings.ai.providerChain.types.anthropic",
"settings.ai.providerChain.types.codex",
"settings.ai.providerSelect.anthropic",
"settings.moodLogUrl",
"settings.sections.export.import.appleHealth.title",
"settings.sections.sources.sourceLabels.APPLE_HEALTH",
"settings.appleHealth.title",
Loaded 100 of 725 files, more files were not shown because too many files have changed in this diff. Show more