mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
417288d3f5 | ||
|
|
e67bb25338 | ||
|
|
84d78d2926 | ||
|
|
708846f72b | ||
|
|
bc1633db40 | ||
|
|
59acbed5ab | ||
|
|
7b56f9ddbd | ||
|
|
e9b7d5f885 | ||
|
|
fcd3dd2d07 | ||
|
|
2f92c0577b | ||
|
|
ffe99caccd | ||
|
|
f12290532f | ||
|
|
9e68ff525f | ||
|
|
f42934487d | ||
|
|
582db8a538 | ||
|
|
5e490f31ec | ||
|
|
f90219784b | ||
|
|
68b9903966 | ||
|
|
b32d1be607 | ||
|
|
a23ee61184 | ||
|
|
c506b72002 | ||
|
|
ec49157e85 | ||
|
|
208a2248cd | ||
|
|
e43e758c8d | ||
|
|
4e95bd28f8 | ||
|
|
0c9832eb8d | ||
|
|
019ad52c0d | ||
|
|
132238fd05 | ||
|
|
18691d4550 | ||
|
|
15b5f6f92f | ||
|
|
6e4bcffadf | ||
|
|
2ab66ab2e3 | ||
|
|
ef9891fec5 | ||
|
|
7c66413c1d | ||
|
|
c2d6adaff5 | ||
|
|
99b8dfd566 | ||
|
|
d8a3c08e12 | ||
|
|
f9a5e6a11f | ||
|
|
fe2e20a14b | ||
|
|
2a396dd23e | ||
|
|
f8f460bb66 | ||
|
|
a3f1d780e7 | ||
|
|
ddc18bbd11 | ||
|
|
c623745942 | ||
|
|
338808eb5f | ||
|
|
2decc37f07 | ||
|
|
b4f4cb8b55 | ||
|
|
933dfcfdfb | ||
|
|
405bebf1a0 | ||
|
|
d9b8a69b7f | ||
|
|
a006f76320 | ||
|
|
d0b45e36b3 | ||
|
|
4ed8c8f39b | ||
|
|
e657bf928e | ||
|
|
10593a11a8 | ||
|
|
ce74fd52a2 | ||
|
|
de47bfa5ba | ||
|
|
a1a978f5e6 | ||
|
|
41c2213fe6 | ||
|
|
7a0e2cb5b2 | ||
|
|
1b41126fb3 | ||
|
|
d0d880d4ba | ||
|
|
5b32f6ce4c | ||
|
|
fb74b686e3 | ||
|
|
a08c759763 | ||
|
|
ce41d5bf17 | ||
|
|
a11717e65a | ||
|
|
40a746b2b7 | ||
|
|
0bcbb88db1 | ||
|
|
a9f1629f82 | ||
|
|
9bbbeb805c | ||
|
|
8af5c6a0eb | ||
|
|
7a4e819679 | ||
|
|
c86de7d317 | ||
|
|
bf73755899 | ||
|
|
cfd5cf0e0c | ||
|
|
2e5841c337 | ||
|
|
a9b2ab2d2b | ||
|
|
6cf56ce456 | ||
|
|
ddc598afcb | ||
|
|
13b88e47f5 | ||
|
|
e1ca523e21 | ||
|
|
dd7213db5c | ||
|
|
14dcab786e | ||
|
|
bb79d28911 | ||
|
|
40aae4b66d | ||
|
|
1007ca3d1c | ||
|
|
dbe3c3dc26 | ||
|
|
6c8e921faa | ||
|
|
816f517d1b | ||
|
|
4ac39a7052 | ||
|
|
1d31c51d01 | ||
|
|
4574155ce6 | ||
|
|
60ec39c2be | ||
|
|
43880e8cca | ||
|
|
bb631de7c8 | ||
|
|
e47200505e | ||
|
|
9a69e4e557 | ||
|
|
8be7458c7a | ||
|
|
cf755d32af | ||
|
|
574367737f | ||
|
|
e65390792d | ||
|
|
f9afa794e9 | ||
|
|
3f33a30415 | ||
|
|
8849f2cecd | ||
|
|
5c581e9e58 | ||
|
|
572f5176bd | ||
|
|
ab03bc96f1 | ||
|
|
df4425babf | ||
|
|
0317942f1c | ||
|
|
f251ae13f8 | ||
|
|
0baffe04cd | ||
|
|
1d3963bebf | ||
|
|
15b8df11bc | ||
|
|
5c36a9a776 | ||
|
|
115c3e3dcd | ||
|
|
da8dfe4256 | ||
|
|
25b7ada8bf | ||
|
|
6a0bec7553 | ||
|
|
490963dfd8 | ||
|
|
0ee5081766 | ||
|
|
1a08083436 | ||
|
|
1e72d0aea0 | ||
|
|
ed260f1ed8 | ||
|
|
cd344047dd | ||
|
|
69359a609a | ||
|
|
872fe047e3 | ||
|
|
73594d35f7 | ||
|
|
aa850acab8 | ||
|
|
8bb0719c3a | ||
|
|
aeee3d657d | ||
|
|
5ad66af565 | ||
|
|
0b828e2c5a | ||
|
|
f88546dc2b | ||
|
|
93c0d616bb | ||
|
|
4f95afa33c | ||
|
|
1fca6c1d25 | ||
|
|
6cf9ad1273 | ||
|
|
0b3280c604 | ||
|
|
d2d79b70a2 | ||
|
|
dece1f89d9 | ||
|
|
6333d4d989 | ||
|
|
9c95e713d2 | ||
|
|
25393d93fe | ||
|
|
7b765c7659 | ||
|
|
c1bb997de7 | ||
|
|
6e46afc1fc | ||
|
|
9e6453c51d | ||
|
|
61a1592665 | ||
|
|
27b548ab13 | ||
|
|
d321391024 | ||
|
|
9d584806c7 | ||
|
|
a69d0493f8 |
No files matched your search
@@ -39,7 +39,6 @@ body:
|
||||
- Notifications
|
||||
- AI Insights
|
||||
- Withings integration
|
||||
- moodLog integration
|
||||
- Self-hosting / Docker
|
||||
- Other
|
||||
validations:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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 |
@@ -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 |
@@ -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. |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -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,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,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,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -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
@@ -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,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import {
|
||||
STUB_MEDICATION_ID,
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import {
|
||||
STUB_MEDICATION_ID,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import {
|
||||
STUB_MEDICATION_ID,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import {
|
||||
STUB_MEDICATION_ID,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import {
|
||||
STUB_MEDICATION_ID,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import {
|
||||
STUB_MEDICATION_ID,
|
||||
|
||||
@@ -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,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,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -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,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,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
@@ -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
@@ -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 ({
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -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,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,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
/**
|
||||
* v1.4.27 MB6 — public surfaces.
|
||||
|
||||
@@ -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
@@ -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,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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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/*`
|
||||
|
||||
@@ -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());
|
||||
@@ -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());
|
||||
@@ -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` /
|
||||
|
||||
@@ -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 "=============================================================="
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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
Reference in new issue
Block a user