mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
eed66ae350 | ||
|
|
e5cdd0d483 | ||
|
|
e986b788a2 | ||
|
|
b1e36d9fb0 | ||
|
|
1c82dbc401 | ||
|
|
4895726a48 | ||
|
|
3b3df3a1ac | ||
|
|
56b3e66686 | ||
|
|
7779dabfed | ||
|
|
60d5416cf8 | ||
|
|
e39161ad14 | ||
|
|
15f1d3a2b6 | ||
|
|
0908fac738 | ||
|
|
22c92a7e9d | ||
|
|
b948f56fec | ||
|
|
edfe5290c9 | ||
|
|
7bb5e1947c | ||
|
|
938e84a43a | ||
|
|
a9bb2f42ba | ||
|
|
f48f496c39 | ||
|
|
87e690bdcb | ||
|
|
0375fadd85 | ||
|
|
0598c90af2 | ||
|
|
ecf2d1a7d5 | ||
|
|
6715c4a69a | ||
|
|
fbdeec9204 | ||
|
|
32edb033e8 | ||
|
|
9ef3207cb6 | ||
|
|
fb50335459 | ||
|
|
d750c1827e | ||
|
|
1371bb1cf9 | ||
|
|
79843f8770 | ||
|
|
8736f44442 | ||
|
|
a2fc6ea243 | ||
|
|
58fddeb0d6 | ||
|
|
c256d945b3 | ||
|
|
35a3842bba | ||
|
|
8119cc6c82 | ||
|
|
62eb431d66 | ||
|
|
b01822be48 | ||
|
|
25a56983b3 | ||
|
|
7f55083c3c | ||
|
|
0d5d61bc36 | ||
|
|
b20f1b5c66 | ||
|
|
403eabb888 | ||
|
|
a30c31b25d | ||
|
|
085aa3f5c5 | ||
|
|
e38179e854 | ||
|
|
5fe952b63d | ||
|
|
b38f41b99d | ||
|
|
3ce6726646 | ||
|
|
9adeb8fc19 | ||
|
|
914e7658db | ||
|
|
be1d0fb728 | ||
|
|
3aa8ccd541 | ||
|
|
f1a1887a01 | ||
|
|
ad5806c53a | ||
|
|
00a8c81c8d | ||
|
|
1840779dd7 | ||
|
|
3c5afea912 | ||
|
|
b9425de40e | ||
|
|
74a386fcfd | ||
|
|
f36bbc51cd | ||
|
|
d966355f83 | ||
|
|
a846bbc90d | ||
|
|
4124c483eb | ||
|
|
ca1ba8b128 | ||
|
|
6ebc278c2c | ||
|
|
e9507a0c93 | ||
|
|
7eb3b91925 | ||
|
|
cd6b5f0cc6 | ||
|
|
8b7cbddd72 | ||
|
|
9484240688 | ||
|
|
1b717b4ca0 | ||
|
|
007ea76327 | ||
|
|
197f7bf5a1 | ||
|
|
e47d02702d | ||
|
|
5fc0b1eead | ||
|
|
a87a144b1e | ||
|
|
2716f302c7 | ||
|
|
5d784a639c | ||
|
|
a8ac376cf7 | ||
|
|
f8b0a64d69 | ||
|
|
5f4017d039 | ||
|
|
7b515f4f82 | ||
|
|
452d1a2ae3 | ||
|
|
a6d4bb414c | ||
|
|
f648c1748a | ||
|
|
1f94249bd5 | ||
|
|
d31a33e14c | ||
|
|
932bbc7955 | ||
|
|
140b6954f0 | ||
|
|
4591899657 | ||
|
|
44add3bc1e | ||
|
|
c8529fc0bb | ||
|
|
4cab5b3c63 | ||
|
|
c97ad3ed55 | ||
|
|
d40da09558 | ||
|
|
29e8cd8c4f | ||
|
|
29673c1155 | ||
|
|
430cbb2ceb | ||
|
|
7b51f9edaf | ||
|
|
c1f583b114 | ||
|
|
db3e56ed08 | ||
|
|
6e1945e8a0 | ||
|
|
e44cf8b804 | ||
|
|
69b61770c4 | ||
|
|
f1c977a7a7 | ||
|
|
ac9170945d | ||
|
|
87cda9b7a7 | ||
|
|
83da7c57c8 | ||
|
|
4cd0adc46d | ||
|
|
bec7538012 | ||
|
|
d103699099 | ||
|
|
2bf450cf17 | ||
|
|
048d7a4d84 | ||
|
|
9d03fe13f0 | ||
|
|
663b3530dd | ||
|
|
d5e23ebee6 | ||
|
|
6fee8ad091 | ||
|
|
1a469301b3 | ||
|
|
a0b7dae8eb | ||
|
|
ca170925dc | ||
|
|
096c8c799b | ||
|
|
e53a8ce8a0 | ||
|
|
ee6ae9c450 | ||
|
|
0991d754e4 | ||
|
|
4b87b9e33b | ||
|
|
0dba15e902 | ||
|
|
bfb4dbd7da | ||
|
|
8b13b46ac7 | ||
|
|
f9112925d2 | ||
|
|
94157a50c7 | ||
|
|
8ead442d44 | ||
|
|
f5889affb7 | ||
|
|
80e88cfb23 | ||
|
|
9ab8ed8545 | ||
|
|
ccd3c5e68f | ||
|
|
ef10ac5581 | ||
|
|
5d62236412 | ||
|
|
1b50dc0909 | ||
|
|
ba45a25cb0 | ||
|
|
ed049661b7 | ||
|
|
b2168df172 | ||
|
|
433d983e60 | ||
|
|
d68a7ca244 | ||
|
|
4c65949bac | ||
|
|
670e7097a2 | ||
|
|
30954901ac | ||
|
|
e557d9e9e6 | ||
|
|
571e415eaa | ||
|
|
4c3cc84daf | ||
|
|
e9d28b399e | ||
|
|
4ddc08f784 | ||
|
|
4bee263a59 | ||
|
|
8168e60dfd | ||
|
|
b0021a8258 | ||
|
|
fd9603a578 | ||
|
|
03fdcddb92 | ||
|
|
2ab21bb433 | ||
|
|
0627648bea | ||
|
|
306d1f7443 | ||
|
|
1c2cfffdb8 | ||
|
|
8bc1d75373 | ||
|
|
12844a9206 | ||
|
|
a8b0365ca5 | ||
|
|
fd6a80a921 | ||
|
|
0fca5ca207 | ||
|
|
b00187e659 | ||
|
|
eb5f81c84a | ||
|
|
80258914f2 | ||
|
|
851a82cc79 | ||
|
|
8e938a4f45 | ||
|
|
d40cb3eef3 | ||
|
|
860cb61b0b | ||
|
|
f05e91e1ef | ||
|
|
02a56047a4 | ||
|
|
fadd21b3fd | ||
|
|
54634a4f12 | ||
|
|
9cd0cc3d03 | ||
|
|
d85e7bf63c | ||
|
|
cd502d5aef | ||
|
|
6f3d06c39b | ||
|
|
d773f460e5 | ||
|
|
e4f5aa0b09 | ||
|
|
db86b4f76d | ||
|
|
cbb9528cb2 | ||
|
|
f8bb8f4b9b | ||
|
|
1679d24660 | ||
|
|
d4c4e5d08b | ||
|
|
943c6ad01f | ||
|
|
59ffa02b0c | ||
|
|
323b111a24 | ||
|
|
f31a12e174 | ||
|
|
c87ce161ba | ||
|
|
c23f2545cc | ||
|
|
57626c9c28 | ||
|
|
0e0f006702 | ||
|
|
f96c18574b | ||
|
|
e6e2210314 | ||
|
|
4183202ed3 | ||
|
|
576be8b746 | ||
|
|
f61483d19b | ||
|
|
b77a721474 | ||
|
|
5c2bee79d8 | ||
|
|
de088febe6 | ||
|
|
624ce3292c | ||
|
|
a3be2d7c92 | ||
|
|
395d59fd30 | ||
|
|
a5631f3d12 | ||
|
|
cd7e2bc0c4 | ||
|
|
79922631ce | ||
|
|
f2b146556a | ||
|
|
79c663e680 | ||
|
|
8a1d7502d0 | ||
|
|
3ccc991851 | ||
|
|
db08962d41 | ||
|
|
a877dcddf5 | ||
|
|
3d1a1cec86 | ||
|
|
e9424ef0be | ||
|
|
eb443a314c | ||
|
|
d0553cdace | ||
|
|
abb9a37672 | ||
|
|
0511e36ac9 | ||
|
|
813ad3ec5a | ||
|
|
81e66cbc5c | ||
|
|
b1370626fc | ||
|
|
7edf79bff3 | ||
|
|
b940f5667d | ||
|
|
0ddc5097e4 | ||
|
|
3d776a236a | ||
|
|
2d7a2b5c7c | ||
|
|
95e4dce991 | ||
|
|
544ae90365 | ||
|
|
0becc2a56d | ||
|
|
70e6e5e374 | ||
|
|
a6851b7e80 | ||
|
|
97df5c4704 | ||
|
|
ca37fab63c | ||
|
|
4b60852f9f | ||
|
|
3f5c34fd08 | ||
|
|
61d0d01cf1 | ||
|
|
8e94f6cb20 | ||
|
|
bb41b1673e | ||
|
|
42235076ac | ||
|
|
fa2b9531f7 | ||
|
|
a6600f0215 | ||
|
|
e6862df048 | ||
|
|
99dc3b6915 | ||
|
|
81e564a724 | ||
|
|
caa37b74b5 | ||
|
|
44046eda22 | ||
|
|
40ef4b40b0 | ||
|
|
a54cb0a47f | ||
|
|
4791d6f543 | ||
|
|
4ba600d908 | ||
|
|
f2083eba36 | ||
|
|
3ffaf1906c | ||
|
|
1045639076 | ||
|
|
eb071b5dd8 | ||
|
|
75943496f7 | ||
|
|
91c4f789db | ||
|
|
6b08ffd7c5 | ||
|
|
d2656a2d90 | ||
|
|
aed541910c | ||
|
|
9084737635 | ||
|
|
91a605de2a | ||
|
|
5211771e8d | ||
|
|
a83fb6f936 | ||
|
|
4548df84bf | ||
|
|
24b1269b28 | ||
|
|
076339c274 | ||
|
|
e31c66e959 | ||
|
|
40ad2dfc96 | ||
|
|
586c4318ca | ||
|
|
ff6956decb | ||
|
|
8012aa095b | ||
|
|
df611227f9 | ||
|
|
17033f9966 | ||
|
|
20ff0c1868 | ||
|
|
1920321e44 | ||
|
|
e04383f015 | ||
|
|
d0a74e37da | ||
|
|
5c83156dcf | ||
|
|
f8c4032515 | ||
|
|
d6692e3a4b | ||
|
|
ae3d291feb | ||
|
|
93738ae968 | ||
|
|
81065f3f9d | ||
|
|
decc6681f6 | ||
|
|
e4ec0eec49 | ||
|
|
37c33d0205 | ||
|
|
13197c7ee9 | ||
|
|
fff84ea84f | ||
|
|
2032b74fc6 | ||
|
|
ec4e3adfdc | ||
|
|
7fa6498ad8 | ||
|
|
15d63c3842 | ||
|
|
5b464b26de | ||
|
|
521f5d354c | ||
|
|
7802903d93 | ||
|
|
e0387c5ceb | ||
|
|
3743a46a40 | ||
|
|
9e2c445ad7 | ||
|
|
b75771a445 | ||
|
|
64e7c57734 | ||
|
|
c2420d7f02 | ||
|
|
16fd4966d0 | ||
|
|
ada147522f | ||
|
|
e6d9cc229a | ||
|
|
74891e95f4 | ||
|
|
57e855eeb3 | ||
|
|
dfa8675a89 | ||
|
|
906acee978 | ||
|
|
e61fe42bd1 | ||
|
|
04320c415d | ||
|
|
36f73233b0 | ||
|
|
f3d92520d1 | ||
|
|
5c60558d4f | ||
|
|
271abd7b64 | ||
|
|
427b5f0b7c | ||
|
|
d4f78811d9 | ||
|
|
6af0e36c88 | ||
|
|
9a6a05a0ef | ||
|
|
0b37c15a52 | ||
|
|
5fa18a3754 | ||
|
|
389bdb5d5d | ||
|
|
cf3f217e01 | ||
|
|
47a78896c5 | ||
|
|
bfe23c17b3 | ||
|
|
156c4574b5 | ||
|
|
e1c6941b20 | ||
|
|
b6a1fc1c2b | ||
|
|
0f5b18d946 | ||
|
|
4ca34d4b00 | ||
|
|
adf80da989 | ||
|
|
e8f4bb3dba | ||
|
|
0c5049cd69 | ||
|
|
c93d02c089 | ||
|
|
d2e36e2546 | ||
|
|
a70c98d2b4 | ||
|
|
fdc8ed81a0 | ||
|
|
c6b8d33b0c | ||
|
|
e062721812 | ||
|
|
c6885bc7d3 | ||
|
|
6b259c2c28 | ||
|
|
aaa05241b3 | ||
|
|
009e5adc43 | ||
|
|
a977df24a0 | ||
|
|
1d559d7ed0 | ||
|
|
56a32c528e | ||
|
|
e51e81c76f | ||
|
|
dfa4533c7c | ||
|
|
1e8e46f58c | ||
|
|
6c2eb15f05 | ||
|
|
635d7e2618 | ||
|
|
de8368cf01 | ||
|
|
1a642f1173 | ||
|
|
8dd04e0170 | ||
|
|
e9e186dca5 | ||
|
|
44ab74e125 | ||
|
|
71d7ca78cb | ||
|
|
5169be5887 | ||
|
|
a2cb4fb4a9 | ||
|
|
513e5309ac | ||
|
|
ccfda544f8 | ||
|
|
2c08201695 | ||
|
|
f223ca8979 | ||
|
|
3a45fcc3bd | ||
|
|
a2f926576e | ||
|
|
b00450aec8 | ||
|
|
c1c3aa9754 | ||
|
|
7ac9e7833e | ||
|
|
8c385b9ac2 | ||
|
|
9531bbdcec | ||
|
|
3c468b5e00 | ||
|
|
a66a346d0d | ||
|
|
20f0d13eba | ||
|
|
07f161bf25 | ||
|
|
5532a42ff6 | ||
|
|
66fe69b493 | ||
|
|
d2ad8e03e0 | ||
|
|
a0bde126dd | ||
|
|
749fb8eef1 | ||
|
|
efdcafb08d | ||
|
|
3dd5dbeb42 | ||
|
|
837c44e4c2 | ||
|
|
3dc6c7436f | ||
|
|
55186ccd3e | ||
|
|
a7c82eb582 | ||
|
|
d978c2b662 | ||
|
|
e7802701ba | ||
|
|
4c436d7dfc | ||
|
|
247bc4f33c | ||
|
|
1ff179178f | ||
|
|
7f44e2fa37 | ||
|
|
e8327109b1 | ||
|
|
e3dfe9f400 | ||
|
|
2c6699f682 | ||
|
|
2bc706a445 | ||
|
|
2b89259cce | ||
|
|
95ab0514b9 | ||
|
|
1417137162 | ||
|
|
2b3ff0e58f | ||
|
|
844b24c7ba | ||
|
|
bf1c052797 | ||
|
|
5f253f7d09 | ||
|
|
d124a3239d | ||
|
|
fb73c97125 | ||
|
|
4f7420905a | ||
|
|
8902d3985c | ||
|
|
30a0528ad0 | ||
|
|
cffebd78db | ||
|
|
8c0b200a5b | ||
|
|
0518c39050 | ||
|
|
a08e4378a0 | ||
|
|
0a08b166cf | ||
|
|
ba362f1d10 | ||
|
|
5cff7473c9 | ||
|
|
85a05efb4d | ||
|
|
d8e48a6304 | ||
|
|
60658be776 | ||
|
|
b11ecca8d2 | ||
|
|
43eddb63b0 | ||
|
|
351cf7dd85 | ||
|
|
05f80ad378 |
No files matched your search
@@ -49,6 +49,7 @@ public/sw-version.js
|
||||
|
||||
# Editor / agent worktrees + caches.
|
||||
.claude
|
||||
.worktrees
|
||||
.cursor
|
||||
.aider*
|
||||
.vscode
|
||||
|
||||
@@ -98,6 +98,17 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
# Users bring their own API keys via Settings. No server-side key needed by
|
||||
# default. The admin can configure a fallback key in the Admin Panel.
|
||||
|
||||
# Local-AI SSRF opt-in (optional). The local AI provider rejects private/
|
||||
# internal hosts by default. Set to an exact host allowlist (comma-separated,
|
||||
# e.g. "ollama.lan,10.0.0.5") or "true" for any private host — intended for a
|
||||
# self-hosted Ollama / LM Studio endpoint. See docs/integrations/ai-providers.md.
|
||||
# ALLOW_LOCAL_AI_PRIVATE_HOSTS=""
|
||||
|
||||
# Daily-briefing output-token ceiling (optional). Default 2500, clamped to
|
||||
# 500-8000. Raise it when a verbose model's briefing JSON gets cut off
|
||||
# ("AI response was cut off"). See docs/integrations/ai-providers.md.
|
||||
# INSIGHTS_MAX_TOKENS=""
|
||||
|
||||
# Maximum number of comprehensive-insight generations a single user can
|
||||
# trigger per hour (POST /api/insights/generate). Default 10. The 24h
|
||||
# DB cache short-circuits this for read traffic; only force=true and
|
||||
|
||||
@@ -115,10 +115,12 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# hides password/passkey login entirely once set to "true" -- but only takes
|
||||
# effect when the three vars above are also fully set, so a typo can't lock
|
||||
# every user out. Enforced server-side, not just a hidden button. The native
|
||||
# iOS client authenticates via password/passkey only -- there is no OIDC
|
||||
# flow for it yet -- so OIDC_ONLY="true" locks the iOS app out of sign-in
|
||||
# entirely until a native SSO flow ships. Do not enable it on a deployment
|
||||
# with active iOS users.
|
||||
# iOS client signs in through the native OIDC SSO handoff (the app opens the
|
||||
# login URL in an in-app browser and completes at a custom-scheme callback), so
|
||||
# OIDC_ONLY="true" is supported for iOS -- but only on the app build that ships
|
||||
# that flow. An older password/passkey-only iOS build is locked out of sign-in
|
||||
# by OIDC_ONLY; confirm the app version before enabling it on a deployment with
|
||||
# active iOS users.
|
||||
# Serve the issuer over HTTPS wherever possible: sign-in material (the
|
||||
# authorization code, the token-endpoint response carrying the ID token)
|
||||
# crosses this connection. Plain HTTP is defensible only on a fully trusted
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -37,6 +37,57 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
release-policy:
|
||||
name: Validate release policy
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout full history
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate release policy
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
echo "::notice::Pull-request image validation does not publish release tags."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$GITHUB_REF_TYPE" != "tag" ] || [[ ! "$GITHUB_REF_NAME" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
||||
echo "::error::Release ref '$GITHUB_REF_NAME' is not a strict SemVer tag (expected vMAJOR.MINOR.PATCH with no leading zeroes or prerelease suffix)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
package_version="$(node -p "require('./package.json').version")"
|
||||
tag_version="${GITHUB_REF_NAME#v}"
|
||||
if [ "$package_version" != "$tag_version" ]; then
|
||||
echo "::error::package.json version '$package_version' does not match release tag '$GITHUB_REF_NAME'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
|
||||
echo "::error::Release commit $GITHUB_SHA is not an ancestor of origin/main." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
successful_quality_runs="$(
|
||||
gh api "repos/$GITHUB_REPOSITORY/actions/runs?head_sha=$GITHUB_SHA&event=push&status=success&per_page=100" \
|
||||
--jq '[.workflow_runs[] | select(.name == "Security & Quality" and .head_branch == "main")] | length'
|
||||
)"
|
||||
if [ "$successful_quality_runs" -lt 1 ]; then
|
||||
echo "::error::Release commit $GITHUB_SHA has no successful prior Security & Quality push run on main." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Release policy passed for $GITHUB_REF_NAME at $GITHUB_SHA."
|
||||
|
||||
# ── Stage 1 ────────────────────────────────────────────────────────
|
||||
# Build each architecture on its own native GitHub-hosted runner so
|
||||
# Next.js's static-page workers run on real silicon instead of QEMU
|
||||
@@ -54,6 +105,7 @@ jobs:
|
||||
# manifest in a third step, so consumers see a single tag that
|
||||
# auto-selects on `docker pull` per local platform.
|
||||
build:
|
||||
needs: [release-policy]
|
||||
name: Build ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# 25 min wall clock — native amd64 build finished inside 12 min
|
||||
@@ -265,6 +317,11 @@ jobs:
|
||||
if: github.event_name != 'pull_request'
|
||||
needs: [build]
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
image_ref: ${{ steps.published.outputs.image_ref }}
|
||||
image_tag: ${{ steps.published.outputs.image_tag }}
|
||||
image_digest: ${{ steps.published.outputs.image_digest }}
|
||||
build_sha: ${{ steps.published.outputs.build_sha }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -335,9 +392,21 @@ jobs:
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.IMAGE_REF }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect published manifest
|
||||
- name: Resolve published image
|
||||
id: published
|
||||
env:
|
||||
IMAGE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
docker buildx imagetools inspect '${{ env.IMAGE_REF }}:${{ steps.meta.outputs.version }}'
|
||||
image_digest="$(docker buildx imagetools inspect "${IMAGE_REF}:${IMAGE_TAG}" --format '{{json .Manifest.Digest}}' | tr -d '"')"
|
||||
if [[ ! "$image_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then
|
||||
echo "::error::Could not resolve a valid manifest digest for ${IMAGE_REF}:${IMAGE_TAG}." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "image_ref=$IMAGE_REF" >> "$GITHUB_OUTPUT"
|
||||
echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "image_digest=$image_digest" >> "$GITHUB_OUTPUT"
|
||||
echo "build_sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
|
||||
docker buildx imagetools inspect "${IMAGE_REF}@${image_digest}"
|
||||
|
||||
# v1.16.4 — sign the multi-arch manifest index (keyless, Sigstore).
|
||||
# Signing the INDEX digest covers both per-platform manifests in
|
||||
@@ -357,91 +426,59 @@ jobs:
|
||||
|
||||
- name: Sign multi-arch manifest (keyless)
|
||||
env:
|
||||
IMAGE_VERSION: ${{ steps.meta.outputs.version }}
|
||||
IMAGE_DIGEST: ${{ steps.published.outputs.image_digest }}
|
||||
run: |
|
||||
digest="$(docker buildx imagetools inspect "${IMAGE_REF}:${IMAGE_VERSION}" --format '{{json .Manifest.Digest}}' | tr -d '"')"
|
||||
if [ -z "$digest" ]; then
|
||||
echo "::error::Could not resolve the manifest digest for ${IMAGE_REF}:${IMAGE_VERSION} — signing aborted."
|
||||
exit 1
|
||||
fi
|
||||
echo "Signing ${IMAGE_REF}@${digest}"
|
||||
cosign sign --yes "${IMAGE_REF}@${digest}"
|
||||
echo "::notice::Image index signed (keyless): ${IMAGE_REF}@${digest}. Verify with cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp 'https://github.com/${{ github.repository }}/' ${IMAGE_REF}@${digest}"
|
||||
echo "Signing ${IMAGE_REF}@${IMAGE_DIGEST}"
|
||||
cosign sign --yes "${IMAGE_REF}@${IMAGE_DIGEST}"
|
||||
echo "::notice::Image index signed (keyless): ${IMAGE_REF}@${IMAGE_DIGEST}. Verify with cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp 'https://github.com/${{ github.repository }}/' ${IMAGE_REF}@${IMAGE_DIGEST}"
|
||||
|
||||
# v1.4.15: trigger the Coolify deploy automatically once GHCR has
|
||||
# the new image. Replaces the former manual SSH + force-pull recipe
|
||||
# on the deployment host.
|
||||
#
|
||||
# Runs only on the publish path (push to `main` or to a `v*` tag), never
|
||||
# on PRs. `continue-on-error: true` because the image is *already*
|
||||
# published to GHCR by the time we reach this step — a Coolify-side
|
||||
# outage must not retroactively fail the build. The maintainer still
|
||||
# has the manual recipe as fallback.
|
||||
#
|
||||
# Required GitHub repository secrets (set by the maintainer once):
|
||||
# COOLIFY_WEBHOOK = full deploy URL incl. ?uuid=...&force=false
|
||||
# COOLIFY_TOKEN = Bearer token from Coolify UI -> Keys & Tokens
|
||||
# Both secrets are referenced exclusively here. If either is unset, the
|
||||
# step short-circuits to a skip rather than calling Coolify with empty
|
||||
# auth.
|
||||
#
|
||||
# v1.4.26 P6-3 — maintainer toggle.
|
||||
# Three releases in a row shipped via the host-side SSH-retag fallback
|
||||
# because the secrets above silently absent + the warn-only skip
|
||||
# below let the workflow stay green. The `vars.COOLIFY_AUTO_DEPLOY`
|
||||
# repository variable now distinguishes the two intended modes:
|
||||
#
|
||||
# COOLIFY_AUTO_DEPLOY=on → if secrets present, deploy; if absent,
|
||||
# FAIL the step so the maintainer sees the
|
||||
# missing-secret gap on the next release.
|
||||
# COOLIFY_AUTO_DEPLOY=off → unconditional skip with a notice. Use
|
||||
# this when the host-side SSH recipe is
|
||||
# the intended deploy path.
|
||||
# (unset / any other) → warn-and-skip (legacy behaviour).
|
||||
#
|
||||
# Set the variable under repo Settings → Secrets and variables →
|
||||
# Actions → Variables (NOT Secrets — it is intentionally readable).
|
||||
verify-published-image:
|
||||
name: Verify exact published image
|
||||
needs: merge
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
uses: ./.github/workflows/post-publish-verify.yml
|
||||
with:
|
||||
image_ref: ${{ needs.merge.outputs.image_ref }}
|
||||
image_tag: ${{ needs.merge.outputs.image_tag }}
|
||||
image_digest: ${{ needs.merge.outputs.image_digest }}
|
||||
build_sha: ${{ needs.merge.outputs.build_sha }}
|
||||
secrets: inherit
|
||||
|
||||
promote:
|
||||
name: Promote verified image to Coolify
|
||||
needs: [merge, verify-published-image]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Trigger Coolify deploy
|
||||
continue-on-error: true
|
||||
env:
|
||||
COOLIFY_WEBHOOK: ${{ secrets.COOLIFY_WEBHOOK }}
|
||||
COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }}
|
||||
COOLIFY_AUTO_DEPLOY: ${{ vars.COOLIFY_AUTO_DEPLOY }}
|
||||
IMAGE_VERSION: ${{ steps.meta.outputs.version }}
|
||||
IMAGE_TAG: ${{ needs.merge.outputs.image_tag }}
|
||||
IMAGE_DIGEST: ${{ needs.merge.outputs.image_digest }}
|
||||
BUILD_SHA: ${{ needs.merge.outputs.build_sha }}
|
||||
run: |
|
||||
mode="${COOLIFY_AUTO_DEPLOY:-}"
|
||||
if [ "$mode" = "off" ]; then
|
||||
echo "::notice::COOLIFY_AUTO_DEPLOY=off — skipping registry deploy hook. Maintainer uses the host-side SSH recipe instead."
|
||||
echo "::notice::COOLIFY_AUTO_DEPLOY=off — skipping registry deploy hook."
|
||||
exit 0
|
||||
fi
|
||||
if [ "$mode" != "on" ]; then
|
||||
echo "::warning::COOLIFY_AUTO_DEPLOY is not 'on' or 'off' — skipping auto-deploy. Set an explicit mode in repository variables."
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "$COOLIFY_WEBHOOK" ] || [ -z "$COOLIFY_TOKEN" ]; then
|
||||
if [ "$mode" = "on" ]; then
|
||||
echo "::error::COOLIFY_AUTO_DEPLOY=on but COOLIFY_WEBHOOK or COOLIFY_TOKEN secret is missing. Set both under repo Settings → Secrets and variables → Actions, or set COOLIFY_AUTO_DEPLOY=off to silence."
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::COOLIFY_WEBHOOK or COOLIFY_TOKEN secret missing — skipping auto-deploy. Configure in repo Settings → Secrets to enable, or set the COOLIFY_AUTO_DEPLOY variable to 'off' to silence this warning."
|
||||
exit 0
|
||||
echo "::error::COOLIFY_AUTO_DEPLOY=on but COOLIFY_WEBHOOK or COOLIFY_TOKEN secret is missing. Set both credentials or disable promotion explicitly." >&2
|
||||
exit 1
|
||||
fi
|
||||
# v1.4.31 — wait for GHCR cache propagation before firing
|
||||
# the Coolify webhook. Five releases in a row (v1.4.27
|
||||
# through v1.4.30.1) the webhook reported "finished" but
|
||||
# Coolify still pulled the prior `:latest` digest. The
|
||||
# `docker buildx imagetools create` + `inspect` steps
|
||||
# above confirm the manifest exists at registry-write time,
|
||||
# but GHCR's CDN edges can take 30-90 s to propagate the
|
||||
# new digest. The webhook fires inside that window, so
|
||||
# Coolify pulls "latest" while the edge still serves the
|
||||
# prior digest, gets "no change", and returns success.
|
||||
# Waiting out the propagation window before firing the
|
||||
# webhook closes that gap.
|
||||
echo "Waiting 90 s for GHCR cache propagation before triggering Coolify (image: $IMAGE_VERSION)…"
|
||||
|
||||
echo "Waiting 90 s for registry propagation before promoting ${IMAGE_TAG}@${IMAGE_DIGEST}."
|
||||
sleep 90
|
||||
# v1.4.22 C3 — force=true tells Coolify to skip its image-cache
|
||||
# and re-pull the registry digest. If the maintainer's secret
|
||||
# URL already carries a `?force=` query, leave it alone;
|
||||
# otherwise append `?force=true` (or `&force=true` if other
|
||||
# query params are present) so doc-only deploys still trigger
|
||||
# a registry-digest check.
|
||||
deploy_url="$COOLIFY_WEBHOOK"
|
||||
if ! echo "$deploy_url" | grep -q "force="; then
|
||||
if echo "$deploy_url" | grep -q "?"; then
|
||||
@@ -450,18 +487,18 @@ jobs:
|
||||
deploy_url="${deploy_url}?force=true"
|
||||
fi
|
||||
fi
|
||||
echo "Triggering Coolify deploy for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7})"
|
||||
response=$(curl --silent --show-error --max-time 30 \
|
||||
|
||||
echo "Triggering Coolify deploy for ${IMAGE_TAG}@${IMAGE_DIGEST} (source ${BUILD_SHA})."
|
||||
response="$(curl --silent --show-error --max-time 30 \
|
||||
--write-out "\n__HTTP_STATUS__:%{http_code}" \
|
||||
--request GET "$deploy_url" \
|
||||
--header "Authorization: Bearer $COOLIFY_TOKEN")
|
||||
--header "Authorization: Bearer $COOLIFY_TOKEN")"
|
||||
status="${response##*__HTTP_STATUS__:}"
|
||||
body="${response%__HTTP_STATUS__:*}"
|
||||
echo "Coolify response (HTTP $status):"
|
||||
echo "$body"
|
||||
if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then
|
||||
echo "::error::Coolify deploy webhook returned HTTP $status — image is published, but auto-deploy did not fire. Maintainer: deploy manually on the deployment host (SSH force-pull recipe, see docs/ops/deploy.md)."
|
||||
echo "::error::Coolify deploy webhook returned HTTP $status for verified image ${IMAGE_TAG}@${IMAGE_DIGEST}." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::Coolify auto-deploy webhook fired at $(date -u +%FT%TZ) for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7}). If /api/version doesn't reach the new tag within ~60s, re-trigger the deploy from the Coolify UI or deploy manually on the deployment host (docs/ops/deploy.md)."
|
||||
echo "Coolify deploy queued — awaiting outgoing notification webhook to /api/internal/deploy-webhook for final status."
|
||||
echo "Coolify deploy queued for verified image ${IMAGE_TAG}@${IMAGE_DIGEST}."
|
||||
@@ -37,10 +37,11 @@ jobs:
|
||||
DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public
|
||||
ENCRYPTION_KEYS: '{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'
|
||||
ENCRYPTION_ACTIVE_KEY_ID: v1
|
||||
API_TOKEN_HMAC_KEY: ci-hmac-key-32-bytes-min-padding-x
|
||||
API_TOKEN_HMAC_KEY: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
|
||||
AUTH_RP_NAME: HealthLog
|
||||
AUTH_RP_ID: localhost
|
||||
AUTH_RP_ORIGIN: http://localhost:3000
|
||||
SESSION_COOKIE_SECURE: "false"
|
||||
NODE_ENV: production
|
||||
|
||||
steps:
|
||||
@@ -48,7 +49,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
@@ -87,10 +88,11 @@ jobs:
|
||||
env:
|
||||
E2E_BASE_URL: http://localhost:3000
|
||||
|
||||
- name: Upload Playwright HTML report on failure
|
||||
if: failure()
|
||||
- name: Upload Playwright HTML report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
@@ -38,6 +38,6 @@ jobs:
|
||||
# string "0" — failing crypto's hex/base64 length check and breaking
|
||||
# the backups + integration-status suites for 50+ runs (regression
|
||||
# since v1.4.13). Quoting forces a literal scalar.
|
||||
API_TOKEN_HMAC_KEY: "integration-tests-do-not-use-in-prod"
|
||||
API_TOKEN_HMAC_KEY: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
||||
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
SESSION_SECRET: "integration-tests"
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
@@ -1,33 +1,42 @@
|
||||
name: Post-publish verify
|
||||
|
||||
# Informational reachability gate that runs AFTER `Build & Publish Docker
|
||||
# Image` finishes a `main` (or tag) build. Pulls the just-published image
|
||||
# from GHCR and probes:
|
||||
# - the image manifest is fetchable (i.e. the push actually completed)
|
||||
# - the container starts and `/api/version` returns the expected version
|
||||
# - the container's `/api/health` endpoint reports OK
|
||||
#
|
||||
# Does NOT block deploys — `continue-on-error` is set on every probe step
|
||||
# so a transient registry blip doesn't go red. This is a smoke gate the
|
||||
# v1.4.14 release would have caught: the main push appeared to "succeed"
|
||||
# from the deploy host's POV, but the host had to pull the tag image
|
||||
# because main's manifest never finished pushing.
|
||||
#
|
||||
# Triggered by `workflow_run` so it only runs after the build workflow
|
||||
# actually completes — chaining via `needs:` would force the build into
|
||||
# the same workflow file.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build & Publish Docker Image"]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
inputs:
|
||||
image_ref:
|
||||
description: Exact registry repository to verify
|
||||
required: true
|
||||
type: string
|
||||
image_tag:
|
||||
description: Exact published release tag
|
||||
required: true
|
||||
type: string
|
||||
image_digest:
|
||||
description: Exact published multi-arch manifest digest
|
||||
required: true
|
||||
type: string
|
||||
build_sha:
|
||||
description: Expected short source commit SHA
|
||||
required: true
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image_ref:
|
||||
description: Exact registry repository to verify
|
||||
required: true
|
||||
type: string
|
||||
image_tag:
|
||||
description: "GHCR image tag to verify (defaults to `main`)"
|
||||
required: false
|
||||
default: "main"
|
||||
description: Exact published release tag
|
||||
required: true
|
||||
type: string
|
||||
image_digest:
|
||||
description: Exact published multi-arch manifest digest
|
||||
required: true
|
||||
type: string
|
||||
build_sha:
|
||||
description: Expected short source commit SHA
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -35,14 +44,15 @@ permissions:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Pull + smoke-test published image
|
||||
name: Pull and smoke-test exact published image
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# Skip if the upstream build itself failed — there's nothing to verify.
|
||||
# Manual dispatch always runs.
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
env:
|
||||
IMAGE_REF: ${{ inputs.image_ref }}
|
||||
IMAGE_TAG: ${{ inputs.image_tag }}
|
||||
IMAGE_DIGEST: ${{ inputs.image_digest }}
|
||||
BUILD_SHA: ${{ inputs.build_sha }}
|
||||
DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public
|
||||
|
||||
services:
|
||||
postgres:
|
||||
@@ -60,15 +70,6 @@ jobs:
|
||||
--health-retries 10
|
||||
|
||||
steps:
|
||||
- name: Resolve image tag
|
||||
id: tag
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
echo "tag=${{ github.event.inputs.image_tag }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=main" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
with:
|
||||
@@ -76,69 +77,97 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Pull image
|
||||
id: pull
|
||||
continue-on-error: true
|
||||
- name: Pull exact image
|
||||
run: |
|
||||
IMAGE="ghcr.io/${{ github.repository }}:${{ steps.tag.outputs.tag }}"
|
||||
echo "Pulling $IMAGE"
|
||||
docker pull "$IMAGE"
|
||||
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
|
||||
TAG_REF="${IMAGE_REF}:${IMAGE_TAG}"
|
||||
DIGEST_REF="${IMAGE_REF}@${IMAGE_DIGEST}"
|
||||
|
||||
- name: Start container
|
||||
if: steps.pull.outcome == 'success'
|
||||
id: run
|
||||
continue-on-error: true
|
||||
echo "Pulling published tag $TAG_REF"
|
||||
docker pull "$TAG_REF"
|
||||
resolved_digest="$(docker buildx imagetools inspect "$TAG_REF" --format '{{json .Manifest.Digest}}' | tr -d '"')"
|
||||
if [ "$resolved_digest" != "$IMAGE_DIGEST" ]; then
|
||||
echo "::error::Published tag digest mismatch: $TAG_REF resolved to '$resolved_digest', expected '$IMAGE_DIGEST'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Pulling immutable image $DIGEST_REF"
|
||||
docker pull "$DIGEST_REF"
|
||||
|
||||
- name: Run migrations
|
||||
run: |
|
||||
# Minimal env to satisfy the strict-env validators on boot.
|
||||
DIGEST_REF="${IMAGE_REF}@${IMAGE_DIGEST}"
|
||||
docker run --rm \
|
||||
--network host \
|
||||
--entrypoint node \
|
||||
-e DATABASE_URL="$DATABASE_URL" \
|
||||
"$DIGEST_REF" \
|
||||
/opt/prisma-cli/node_modules/prisma/build/index.js migrate deploy
|
||||
|
||||
- name: Start exact image
|
||||
run: |
|
||||
DIGEST_REF="${IMAGE_REF}@${IMAGE_DIGEST}"
|
||||
docker run -d --name healthlog-verify \
|
||||
--network host \
|
||||
-e DATABASE_URL='postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public' \
|
||||
-e DATABASE_URL="$DATABASE_URL" \
|
||||
-e ENCRYPTION_KEYS='{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' \
|
||||
-e ENCRYPTION_ACTIVE_KEY_ID=v1 \
|
||||
-e API_TOKEN_HMAC_KEY=verify-hmac-key-32-bytes-padding-x \
|
||||
-e API_TOKEN_HMAC_KEY=cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc \
|
||||
-e AUTH_RP_NAME=HealthLog \
|
||||
-e AUTH_RP_ID=localhost \
|
||||
-e AUTH_RP_ORIGIN=http://localhost:3000 \
|
||||
-e NODE_ENV=production \
|
||||
-e HEALTHLOG_PROCESS_TYPE=web \
|
||||
-e PORT=3000 \
|
||||
"${{ steps.pull.outputs.image }}"
|
||||
"$DIGEST_REF"
|
||||
|
||||
# Wait up to 60 s for the server to start answering.
|
||||
- name: Verify exact version
|
||||
run: |
|
||||
body=""
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf -o /dev/null http://localhost:3000/api/version; then
|
||||
echo "ready after ${i}*2s"
|
||||
if body="$(curl -sf http://localhost:3000/api/version)"; then
|
||||
echo "Version endpoint ready after $((i * 2))s"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ -z "$body" ]; then
|
||||
echo "::error::Exact image did not expose /api/version within 60 seconds." >&2
|
||||
docker logs healthlog-verify || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
EXPECTED_VERSION="${IMAGE_TAG#v}"
|
||||
echo "$body" | jq .
|
||||
if ! jq -e --arg expected "$EXPECTED_VERSION" '.data.version == $expected' <<< "$body" >/dev/null; then
|
||||
actual="$(jq -r '.data.version // "<missing>"' <<< "$body")"
|
||||
echo "::error::Version mismatch: exact image reports '$actual', expected '$EXPECTED_VERSION' (release tag '$IMAGE_TAG')." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! jq -e --arg expected "$BUILD_SHA" '.data.buildSha == $expected' <<< "$body" >/dev/null; then
|
||||
actual="$(jq -r '.data.buildSha // "<missing>"' <<< "$body")"
|
||||
echo "::error::Build SHA mismatch: exact image reports '$actual', expected '$BUILD_SHA'." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify health
|
||||
run: |
|
||||
for i in $(seq 1 30); do
|
||||
if body="$(curl -sf http://localhost:3000/api/health)" && jq -e '.status == "ok"' <<< "$body" >/dev/null; then
|
||||
echo "$body" | jq .
|
||||
echo "Health endpoint ready after $((i * 2))s"
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "::warning::Container did not become ready within 60 s"
|
||||
echo "::error::Exact image did not report healthy within 60 seconds." >&2
|
||||
docker logs healthlog-verify || true
|
||||
exit 1
|
||||
|
||||
- name: Probe /api/version
|
||||
if: steps.run.outcome == 'success'
|
||||
- name: Container logs
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -e
|
||||
BODY=$(curl -sf http://localhost:3000/api/version)
|
||||
echo "$BODY" | jq .
|
||||
VER=$(echo "$BODY" | jq -r '.data.version // .version')
|
||||
echo "::notice::published image ${{ steps.tag.outputs.tag }} reports version=$VER"
|
||||
|
||||
- name: Probe /api/health
|
||||
if: steps.run.outcome == 'success'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
curl -sf http://localhost:3000/api/health | jq .
|
||||
|
||||
- name: Container logs (always)
|
||||
if: always() && steps.run.outcome == 'success'
|
||||
continue-on-error: true
|
||||
run: docker logs healthlog-verify | tail -100
|
||||
run: docker logs healthlog-verify
|
||||
|
||||
- name: Tear down
|
||||
if: always()
|
||||
continue-on-error: true
|
||||
run: docker rm -f healthlog-verify || true
|
||||
run: docker rm -f healthlog-verify
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -85,3 +85,7 @@ next-env.d.ts
|
||||
|
||||
# Local screenshot/audit harnesses (repo-root for module resolution; never committed)
|
||||
.audit-*.mjs
|
||||
|
||||
|
||||
# Isolated development worktrees
|
||||
/.worktrees/
|
||||
+1369
File diff suppressed because it is too large.
Load diff
@@ -86,9 +86,10 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
|
||||
|
||||
- **UI work follows the design standards.** `.planning/audits/2026-07-05-fable/UI-STANDARDS.md` is the binding design rulebook (card anatomy via TileHeader, spacing scale, text-colour rules — foreground for content, muted for meta only —, token discipline, primitive mapping). Read it before touching any surface; deviations need a written reason in the PR. Where the standards conflict with older in-file comments, the standards win.
|
||||
- **RSC by default.** Server components are the default; add `"use client"` only when a hook, state, or browser API actually needs it.
|
||||
- **Every API route wraps in `apiHandler`.** Verified — zero exceptions in the current tree. Every body-accepting route runs Zod `safeParse` and returns 422 via `returnAllZodIssues` (multi-issue envelope sanitised against echoed input through `sanitiseZodIssues`).
|
||||
- **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` hardcodes the narrow scope so the user-facing endpoint can never mint a wildcard token.
|
||||
- **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.
|
||||
- **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.
|
||||
- **TanStack Query keys live in the centralised factory.** `src/lib/query-keys/` (16 modules, `index.ts` barrel) is the only legal source of `queryKey` / `mutationKey` arrays. The in-repo ESLint rule `healthlog/queryKey-factory` (set to `error`) flags any bare array. Same-key + different `queryFn` shape silently poisons the cache; the factory prevents it. Every read unwraps `(await res.json()).data` from the envelope.
|
||||
@@ -165,7 +166,7 @@ The native SwiftUI client lives in a separate repository and rides public beta v
|
||||
- The `clientManaged` opt-in on `PATCH /api/auth/me/notification-prefs` suppresses server-side `MEDICATION_REMINDER` APNs for clients that manage their own local reminders. The cron skips dose-due APNs when the flag is `true` and emits a `medication_reminder.suppressed_client_managed` wide-event annotation per skip.
|
||||
- `push_attempts` table holds the last 90 days of APNS / Web-Push / Telegram / NTFY delivery attempts (channel + eventType + result + reason + createdAt). The admin diagnostic endpoint at `/api/admin/notifications/diagnostic` surfaces masked device tokens + channel state + last 20 attempts for the calling user.
|
||||
- Any request / response schema change must regen `docs/api/openapi.yaml` and commit the result. CI fails on drift.
|
||||
- The iOS app authenticates via password/passkey only (`src/lib/auth/native-client.ts`) — there is no OIDC flow for native clients yet. `OIDC_ONLY=true` is enforced server-side on those same routes, so enabling it locks every iOS user out of sign-in until a native SSO flow ships. Don't recommend it to an operator running the iOS app.
|
||||
- The iOS app signs in via the native OIDC SSO handoff **or** password/passkey. The native SSO leg: the app opens `GET /api/auth/oidc/login?client=native&code_challenge=<S256>` in an `ASWebAuthenticationSession`, the callback returns to the fixed compiled-in scheme `healthlog://oidc-callback` with a one-time `code=hlh_…` (or an `mfa_ticket` when the account has a second factor), and the app exchanges the code at `POST /api/auth/oidc/native/token` for the standard native bundle (`src/lib/auth/oidc-native-handoff.ts`). The `healthlog://` scheme is a compile-time constant — no `redirect_uri` param, no env allowlist (the open-redirect class is removed by construction). The token pair NEVER rides a URL; only the opaque handoff code / MFA ticket do. The SSO login can never satisfy `requireFreshMfa` step-up — the native transport is Bearer and `requireFreshMfa` is cookie-only, and even the web path sets `mfaVerifiedAt` only from the app's own factor. `OIDC_ONLY=true` is now supported for iOS but requires the app build that ships this flow; an older password/passkey-only build is locked out. The MFA-verify and `/api/auth/refresh` routes that complete/rotate an OIDC login are deliberately not OIDC-only-blocked.
|
||||
|
||||
## DO-NOTs
|
||||
|
||||
|
||||
+21
-19
@@ -1,14 +1,15 @@
|
||||
# ── Stage 1: Dependencies ──────────────────────────────────
|
||||
FROM node:22-alpine AS deps
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS deps
|
||||
RUN corepack enable && corepack prepare pnpm@11.15.1 --activate
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY scripts/prepare.mjs scripts/prepare.mjs
|
||||
RUN pnpm install --frozen-lockfile --prod=false
|
||||
|
||||
# ── Stage 2: Build ─────────────────────────────────────────
|
||||
FROM node:22-alpine AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@11.15.1 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# v1.4.43 B11 — version build-arg threaded into the layer cache key.
|
||||
@@ -45,17 +46,15 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN pnpm build
|
||||
|
||||
# ── Stage 3: Production runner ─────────────────────────────
|
||||
FROM node:22-alpine AS runner
|
||||
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner
|
||||
# `tzdata` is required so Europe/Berlin schedules (pg-boss cron, locale-aware
|
||||
# timestamp formatting) resolve to the actual offset instead of silently
|
||||
# falling back to UTC on Alpine images that ship without it.
|
||||
RUN apk add --no-cache tzdata && \
|
||||
corepack enable && corepack prepare pnpm@latest --activate
|
||||
RUN apk add --no-cache tzdata
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_PATH="/opt/pg-boss/node_modules"
|
||||
ENV TZ=Europe/Berlin
|
||||
|
||||
# v1.4.43 B11 — forward NEXT_PUBLIC_APP_VERSION from the builder stage
|
||||
@@ -80,6 +79,10 @@ RUN addgroup --system --gid 1001 nodejs && \
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
# Next's standalone output must carry the externalized worker dependencies.
|
||||
# Prisma's pure-JS adapter stays bundled; only modules loaded through native
|
||||
# Node resolution belong in this runtime assertion.
|
||||
RUN node -e "require.resolve('pg-boss'); require.resolve('pg')"
|
||||
|
||||
# @napi-rs/canvas (document PDF rasterization): Next's file tracer copies the
|
||||
# native binary's package into the standalone tree but NOT the pnpm symlinks that
|
||||
@@ -123,20 +126,19 @@ COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
|
||||
COPY --from=builder /app/src/generated ./src/generated
|
||||
|
||||
# Install all worker runtime dependencies in a shared prefix.
|
||||
# NODE_PATH makes them available to Node.js module resolution.
|
||||
# These packages are listed in next.config.ts serverExternalPackages
|
||||
# and are NOT bundled by Next.js — they must exist at runtime.
|
||||
RUN mkdir -p /opt/pg-boss && \
|
||||
cd /opt/pg-boss && \
|
||||
npm init -y && \
|
||||
npm install --omit=dev pg-boss@12.18 @prisma/adapter-pg@7.8 pg@8.20
|
||||
|
||||
# Install Prisma CLI + engines for runtime migrations (isolated from Next standalone tree)
|
||||
# Install the migration CLI, its config dependency, and a pinned launcher for
|
||||
# the maintenance scripts already shipped in the standalone tree. The Prisma
|
||||
# config loads dotenv from /app, so expose the isolated copy there before
|
||||
# stripping npm/Corepack and their caches from the runtime surface.
|
||||
RUN mkdir -p /opt/prisma-cli && \
|
||||
cd /opt/prisma-cli && \
|
||||
npm init -y && \
|
||||
npm install --omit=dev prisma@7.8 @prisma/engines@7.8
|
||||
npm install --omit=dev prisma@7.8 @prisma/engines@7.8 tsx@4.23.1 dotenv@17.4.2 && \
|
||||
ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \
|
||||
ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv && \
|
||||
ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma && \
|
||||
rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack /root/.npm && \
|
||||
rm -f /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack /usr/local/bin/pnpm /usr/local/bin/pnpx
|
||||
|
||||
# v1.4.27 B3 — offline GeoLite2 databases for IP→location and IP→ASN
|
||||
# lookups. The MMDB files live in `/opt/geolite2/` and are read by
|
||||
|
||||
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include the shared rootMainFiles baseline. Measured at v1.27.15 + the mobile/PWA fix wave: / 417, /insights 402, /measurements 405, /insights/mood 419, total 2683, baseline 129 — budgets carry ~10-15% headroom so the gate catches step changes (a statically imported ~100 KB catalog, a ~90 KB duplicated library chunk), not noise. Raise a number consciously, in the PR that pays the cost, with the reason in the PR body.",
|
||||
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include the shared rootMainFiles baseline. Measured at v1.32.0 after the correctness/UX architecture wave: / 425, /insights 411, /measurements 412, /insights/mood 428, total 2982, baseline 129. Route caps retain focused headroom; the aggregate cap allows normal chunk noise while still catching a new ~90 KB duplicated library. Message catalogs and duplicate Recharts chunks have dedicated structural guards.",
|
||||
"routesKbGz": {
|
||||
"/page": 460,
|
||||
"/insights/page": 445,
|
||||
"/measurements/page": 445,
|
||||
"/insights/mood/page": 460
|
||||
},
|
||||
"totalClientKbGz": 2950,
|
||||
"totalClientKbGz": 3050,
|
||||
"maxRechartsChunks": 1
|
||||
}
|
||||
+2
-2
@@ -227,7 +227,7 @@ services:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/version || exit 1",
|
||||
"wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
@@ -245,7 +245,7 @@ services:
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: healthlog-worker
|
||||
# environment:
|
||||
# DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public"
|
||||
# DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public&connection_limit=${DB_CONNECTION_LIMIT:-20}&pool_timeout=${DB_POOL_TIMEOUT:-20}"
|
||||
# NODE_ENV: production
|
||||
# ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
|
||||
# ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
|
||||
|
||||
+43
-1
@@ -9,7 +9,7 @@ set -e
|
||||
# Validating here guarantees a clear, deploy-platform-agnostic
|
||||
# failure message before we touch the database.
|
||||
missing=""
|
||||
for var in DATABASE_URL ENCRYPTION_KEY API_TOKEN_HMAC_KEY; do
|
||||
for var in DATABASE_URL API_TOKEN_HMAC_KEY; do
|
||||
eval "value=\$$var"
|
||||
if [ -z "$value" ]; then
|
||||
missing="$missing $var"
|
||||
@@ -21,6 +21,48 @@ if [ -n "$missing" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ENCRYPTION_KEYS supersedes the legacy single key, so validate the same
|
||||
# fail-closed shape the runtime crypto loader accepts before touching the DB.
|
||||
if ! node -e '
|
||||
const validKey = (value) => {
|
||||
if (typeof value !== "string") return false;
|
||||
if (/^[0-9a-fA-F]{64}$/.test(value)) return true;
|
||||
if (!/^[A-Za-z0-9+/=]+$/.test(value)) return false;
|
||||
try { return Buffer.from(value, "base64").length === 32; }
|
||||
catch { return false; }
|
||||
};
|
||||
const rawKeyring = (process.env.ENCRYPTION_KEYS || "").trim();
|
||||
if (rawKeyring) {
|
||||
let keyring;
|
||||
try { keyring = JSON.parse(rawKeyring); }
|
||||
catch { throw new Error("ENCRYPTION_KEYS is invalid JSON"); }
|
||||
if (!keyring || typeof keyring !== "object" || Array.isArray(keyring)) {
|
||||
throw new Error("ENCRYPTION_KEYS is invalid: expected an object map");
|
||||
}
|
||||
const entries = Object.entries(keyring);
|
||||
if (
|
||||
entries.length === 0 ||
|
||||
entries.some(([id, value]) =>
|
||||
!/^[A-Za-z0-9_-]{1,32}$/.test(id) || !validKey(value)
|
||||
)
|
||||
) {
|
||||
throw new Error("ENCRYPTION_KEYS is invalid: every entry must be a valid 32-byte key");
|
||||
}
|
||||
const activeId =
|
||||
(process.env.ENCRYPTION_ACTIVE_KEY_ID || "").trim() ||
|
||||
(entries.length === 1 ? entries[0][0] : "");
|
||||
if (!activeId || !Object.prototype.hasOwnProperty.call(keyring, activeId)) {
|
||||
throw new Error("ENCRYPTION_ACTIVE_KEY_ID must name an entry in ENCRYPTION_KEYS");
|
||||
}
|
||||
} else if (!validKey((process.env.ENCRYPTION_KEY || "").trim())) {
|
||||
throw new Error("ENCRYPTION_KEY is missing or invalid");
|
||||
}
|
||||
'; then
|
||||
echo "HealthLog: ERROR — encryption configuration is invalid." >&2
|
||||
echo "HealthLog: set a valid ENCRYPTION_KEY or ENCRYPTION_KEYS with ENCRYPTION_ACTIVE_KEY_ID." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Wait for PostgreSQL ──────────────────────────────────────
|
||||
MAX_RETRIES=30
|
||||
RETRY=0
|
||||
|
||||
+3117
-750
File diff suppressed because it is too large.
Load diff
@@ -112,29 +112,50 @@ mapped set (workout routes, ECG voltage traces) is counted in
|
||||
### Cumulative HK type → daily aggregate
|
||||
|
||||
Cumulative quantity types — steps, active energy, walking/running
|
||||
distance, flights climbed — get **collapsed into one row per
|
||||
user-local day** to match the iOS daily-aggregation convention.
|
||||
distance, flights climbed, time in daylight, and fall count — get
|
||||
**collapsed into one estimated row per user-local day**. `export.xml`
|
||||
contains source-specific records, not HealthKit's canonical daily
|
||||
statistics, so the importer does not sum different devices or apps
|
||||
into a source-blind total.
|
||||
|
||||
- Records are grouped by type, local day, and a SHA-256 hash of the
|
||||
available source name, source version, and device attributes.
|
||||
Values sum within each identified source-day; the largest
|
||||
source-day subtotal is selected deterministically. If every source
|
||||
attribute is absent, records remain separate rather than being
|
||||
trusted as one source. Raw source and device labels are not
|
||||
persisted.
|
||||
- The row records `EXPORT_XML_SOURCE_MAX` provenance, its distinct
|
||||
contributor count, and the selected non-identifying source hash.
|
||||
- External ID format: `stats:<HKType>:<YYYY-MM-DD>` (e.g.
|
||||
`stats:HKQuantityTypeIdentifierStepCount:2026-05-15`).
|
||||
- The day boundary respects the user's timezone preference. The
|
||||
worker defaults to `Europe/Berlin` when no preference is set
|
||||
(`src/lib/jobs/apple-health-import-worker.ts:132-133`).
|
||||
- A second import of the same archive re-folds the same days onto
|
||||
the same external IDs — the values update in place rather than
|
||||
duplicating.
|
||||
(`src/lib/jobs/apple-health-import-worker.ts`).
|
||||
- XML intervals are not reconstructed or split. A record crossing
|
||||
midnight is assigned using its mapped timestamp and remains covered
|
||||
by the explicit estimate contract.
|
||||
- A second import re-folds the same days onto the same external IDs.
|
||||
Equal-authority estimates update idempotently rather than duplicate.
|
||||
|
||||
The terminal job result exposes
|
||||
`cumulativeEstimates: { days, rows }`; the web import card warns when
|
||||
one or more cumulative days used this estimate path.
|
||||
|
||||
### Re-import idempotency
|
||||
|
||||
The two-axis idempotency story:
|
||||
|
||||
1. **File-level** — re-uploading the same `export.zip` (same SHA-256
|
||||
of bytes) short-circuits to the previous job before the parser
|
||||
even starts.
|
||||
of bytes) short-circuits to the previous job only when that job
|
||||
used the current parser revision. Existing jobs are revision 1;
|
||||
new jobs use revision 2, so a pre-fix successful archive can be
|
||||
deliberately processed again under the corrected aggregation.
|
||||
2. **Record-level** — when an older `export.zip` is re-exported from
|
||||
iOS (same history, slightly newer device timestamps), every
|
||||
record's external ID stays stable. The upsert path matches on
|
||||
`(userId, externalId)` and updates instead of inserting.
|
||||
record's external ID stays stable. The serialized reconciliation
|
||||
path matches the canonical identity and updates instead of
|
||||
inserting.
|
||||
|
||||
Practically: a user who exports monthly and re-imports each archive
|
||||
sees fresh data merged in without duplicates. The job result line
|
||||
@@ -150,14 +171,34 @@ is identical to the user-facing flow.
|
||||
|
||||
The `ImportJob` row carries `triggeredByAdminId = admin.id` so the
|
||||
status endpoint admits both the target user and the triggering
|
||||
admin. Idempotency is scoped by target user — an admin re-uploading
|
||||
the same archive for the same user resolves to the previous job.
|
||||
admin. Idempotency is scoped by target user and parser revision — an
|
||||
admin re-uploading the same archive for the same user resolves to the
|
||||
previous current-revision job.
|
||||
|
||||
Typical use: migrating a friend's history into their HealthLog
|
||||
account when they cannot run the import themselves, or backfilling
|
||||
a household member's data after the admin received the `export.zip`
|
||||
out of band.
|
||||
|
||||
## Aggregate authority and recovery
|
||||
|
||||
Native HealthKit daily statistics submitted by the iOS batch endpoint
|
||||
are canonical and carry `HEALTHKIT_STATISTICS` provenance. XML
|
||||
source-maximum estimates carry `EXPORT_XML_SOURCE_MAX`. Both use the
|
||||
same serialized reconciler with this ordering:
|
||||
|
||||
`LEGACY_UNKNOWN < EXPORT_XML_SOURCE_MAX < HEALTHKIT_STATISTICS`
|
||||
|
||||
A later XML import cannot overwrite native statistics. Native
|
||||
statistics repair an XML estimate or legacy row in either arrival
|
||||
order, and every material reconciled update or resurrection increments
|
||||
`syncVersion`. Existing Apple Health `stats:*` rows are migrated to
|
||||
`LEGACY_UNKNOWN`; they are never guessed or heuristically promoted.
|
||||
To recover an already-corrupted legacy total, the user must re-upload
|
||||
the original archive under parser revision 2 or sync native HealthKit
|
||||
statistics. Without either input, the correct per-source total cannot
|
||||
be reconstructed from the stored aggregate.
|
||||
|
||||
## Source-priority interaction
|
||||
|
||||
HealthLog's source ladder is **APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻
|
||||
@@ -169,12 +210,12 @@ max). The defaults live in `src/lib/validations/source-priority.ts:205-220`.
|
||||
|
||||
Concrete consequences:
|
||||
|
||||
- If you already sync steps from a Withings ScanWatch and also import
|
||||
Apple Health, the Apple-Health-aggregated stream wins for the day —
|
||||
iOS HealthKit folds the watch sensor + iPhone motion data into a
|
||||
single canonical stream, so it is the most complete cumulative
|
||||
source. The Withings rows stay in the database as an audit trail
|
||||
but drop out of the per-day aggregation.
|
||||
- Native HealthKit cumulative statistics are the canonical Apple
|
||||
Health stream for a day and outrank both XML estimates and Withings
|
||||
cumulative rows. An XML upload chooses the largest source subtotal
|
||||
only; it never claims to reproduce HealthKit's device de-duplication.
|
||||
Lower-priority rows stay in the database as an audit trail but drop
|
||||
out of the displayed per-day aggregation.
|
||||
- If you weigh yourself on a Withings scale and also import Apple
|
||||
Health (which received the same reading via the Health Mate iOS
|
||||
app), the Withings row wins for display. Apple Health is
|
||||
|
||||
@@ -84,9 +84,9 @@ pnpm dlx tsx scripts/restore-backup.ts \
|
||||
/tmp/restored.json
|
||||
```
|
||||
|
||||
The production standalone image strips `tsx`, so a bare
|
||||
`pnpm tsx scripts/...` fails inside the container — always invoke
|
||||
one-shot scripts via `pnpm dlx tsx`.
|
||||
Run this command from a source checkout with the production backup variables
|
||||
exported. The script imports the full application dependency graph, which the
|
||||
minimal production image does not expose as an operator scripting environment.
|
||||
|
||||
The script downloads the object, decrypts it, and writes the JSON dump
|
||||
to disk. Importing the JSON back into a HealthLog instance is left to
|
||||
|
||||
+14
-15
@@ -46,24 +46,23 @@ metadata (`medication_schedules`):
|
||||
Always. The default mode only reports and never mutates:
|
||||
|
||||
```bash
|
||||
# inside the app container (or any checkout) — DATABASE_URL must point
|
||||
# at the target database
|
||||
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts
|
||||
# inside the production app container — DATABASE_URL is inherited
|
||||
healthlog-tsx scripts/repair-intake-anomalies.ts
|
||||
|
||||
# scoped to one account
|
||||
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --user <userId>
|
||||
healthlog-tsx scripts/repair-intake-anomalies.ts --user <userId>
|
||||
|
||||
# source-checkout equivalent
|
||||
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts
|
||||
```
|
||||
|
||||
The script is self-contained: its only import is `pg` and it reads
|
||||
`DATABASE_URL` straight from the environment (no dotenv, no `@/` path
|
||||
alias, no `src/` imports). That matters in the production container —
|
||||
the standalone image ships no project `node_modules`, so the previous
|
||||
revision died there with `Cannot find module 'dotenv/config'`. Inside
|
||||
the image `pg` resolves via `NODE_PATH=/opt/pg-boss/node_modules`; in a
|
||||
checkout it resolves from the project `node_modules`; the
|
||||
`--package pg --package tsx` pins cover any environment with neither.
|
||||
Use `pnpm dlx`, not bare `pnpm tsx` — the standalone image also strips
|
||||
`tsx`.
|
||||
alias, no `src/` imports). The production image provides the pinned
|
||||
`healthlog-tsx` launcher and resolves `pg` from its standalone runtime tree
|
||||
without retaining a package manager. In a source checkout, `pg` resolves from
|
||||
the project `node_modules`; `pnpm dlx --package pg --package tsx ...` also
|
||||
works in an environment where neither dependency is installed.
|
||||
|
||||
The dry-run prints every duplicate group (which row would be kept, which
|
||||
would be tombstoned), a table of implausible rows (id, medication,
|
||||
@@ -74,7 +73,7 @@ would write), and a table of stale-anchor pending rows.
|
||||
## Applying the fix
|
||||
|
||||
```bash
|
||||
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
|
||||
healthlog-tsx scripts/repair-intake-anomalies.ts --fix
|
||||
```
|
||||
|
||||
`--fix` does five things:
|
||||
@@ -92,7 +91,7 @@ pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
|
||||
in explicitly:
|
||||
|
||||
```bash
|
||||
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
|
||||
healthlog-tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
|
||||
```
|
||||
|
||||
`--tombstone-implausible` is refused without `--fix`.
|
||||
@@ -139,7 +138,7 @@ slot anchors:
|
||||
The default run only **reports** the proposals. Apply them explicitly:
|
||||
|
||||
```bash
|
||||
pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --backfill-eras
|
||||
healthlog-tsx scripts/repair-intake-anomalies.ts --backfill-eras
|
||||
```
|
||||
|
||||
Review the dry-run table first — the inference is a heuristic. A wrongly
|
||||
|
||||
@@ -47,11 +47,11 @@ echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
|
||||
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
|
||||
```
|
||||
|
||||
| Variable | Purpose | Rotation cost |
|
||||
| -------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
|
||||
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
|
||||
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens before storage | Invalidates every issued `hlk_*` token |
|
||||
| Variable | Purpose | Rotation cost |
|
||||
| -------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
|
||||
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
|
||||
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens and web session cookies before storage | Invalidates every issued `hlk_*` token and signs out every browser session |
|
||||
|
||||
You do **not** edit `DATABASE_URL` on the bundled `docker-compose.yml`
|
||||
stack: compose constructs the connection string inside the `app`
|
||||
|
||||
@@ -65,10 +65,10 @@ Still in **Settings → MCP → Tokens**:
|
||||
3. **Copy the `hlk_…` value now** — it is shown exactly once and cannot
|
||||
be retrieved again. Tokens default to a 90-day lifetime.
|
||||
|
||||
The token never carries the `*` wildcard and never the
|
||||
`medication:ingest` scope the generic `/api/tokens` mints — it is the
|
||||
least-privilege MCP scope and nothing more. The same list shows
|
||||
last-used time and lets you revoke any token.
|
||||
The token never carries the `*` wildcard and never a medication-ingest
|
||||
scope — it is the least-privilege MCP scope and nothing more, and it is
|
||||
bound to the MCP surface: it is refused on every REST route. The same
|
||||
list shows last-used time and lets you revoke any token.
|
||||
|
||||
Cloud connectors that use OAuth (next section) mint the same
|
||||
`health:read` scope automatically, so for Claude.ai / ChatGPT you do not
|
||||
|
||||
+846
-114
File diff suppressed because it is too large.
Load diff
@@ -195,7 +195,11 @@ test.describe("achievements page renders all four card states", () => {
|
||||
|
||||
// Page heading present
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Achievements" }),
|
||||
page.getByRole("main").getByRole("heading", {
|
||||
name: "Achievements",
|
||||
level: 1,
|
||||
exact: true,
|
||||
}),
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Unlocked public — title visible
|
||||
|
||||
@@ -121,4 +121,222 @@ test.describe("Settings → AI provider dropdown UX (B2)", () => {
|
||||
await expect(page.locator(`[data-chain-row="${slug}"]`)).toBeVisible();
|
||||
}
|
||||
});
|
||||
test("provider editors submit once by Enter or click and isolate connection tests", async ({
|
||||
page,
|
||||
}) => {
|
||||
const patchBodies: Array<Record<string, unknown>> = [];
|
||||
let releasePendingPatch: () => void = () => undefined;
|
||||
let holdNextPatch = false;
|
||||
let testConnectionRequests = 0;
|
||||
let providerState: Record<string, unknown> = {
|
||||
provider: null,
|
||||
model: null,
|
||||
baseUrl: null,
|
||||
responseTimeoutSeconds: null,
|
||||
hasAnthropicKey: false,
|
||||
anthropicKeyPreview: null,
|
||||
hasLocalKey: false,
|
||||
hasOpenaiKey: false,
|
||||
openaiKeyPreview: null,
|
||||
};
|
||||
|
||||
await page.route("**/api/insights/settings", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
codexStatus: "disconnected",
|
||||
codexConnectedAt: null,
|
||||
hasAdminKey: false,
|
||||
codexOauthConfigured: true,
|
||||
privacyMode: "aggregated",
|
||||
lastInsightAt: null,
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/user/ai-provider", async (route) => {
|
||||
if (route.request().method() === "PATCH") {
|
||||
const body = route.request().postDataJSON() as Record<string, unknown>;
|
||||
patchBodies.push(body);
|
||||
providerState = { ...providerState, ...body };
|
||||
if (holdNextPatch) {
|
||||
holdNextPatch = false;
|
||||
await new Promise<void>((resolve) => {
|
||||
releasePendingPatch = resolve;
|
||||
});
|
||||
}
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: providerState, error: null }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/insights/provider-chain", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
activeProvider: "codex",
|
||||
cachedActiveProvider: null,
|
||||
configuredChain: [
|
||||
{ providerType: "codex", available: true },
|
||||
{ providerType: "openai", available: true },
|
||||
{ providerType: "anthropic", available: true },
|
||||
{ providerType: "local", available: true },
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/ai/test", (route) => {
|
||||
testConnectionRequests += 1;
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { ok: true, providerType: "local", model: "test-model" },
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const providerHydrated = page.waitForResponse("**/api/user/ai-provider");
|
||||
await page.goto("/settings/ai?provider=openai", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await providerHydrated;
|
||||
|
||||
const select = page.getByTestId("ai-active-provider-select");
|
||||
|
||||
async function assertEnterAndClickSubmitOnce({
|
||||
provider,
|
||||
formTestId,
|
||||
inputId,
|
||||
enterValue,
|
||||
clickValue,
|
||||
expectedField,
|
||||
}: {
|
||||
provider: "openai" | "anthropic" | "local";
|
||||
formTestId: string;
|
||||
inputId: string;
|
||||
enterValue: string;
|
||||
clickValue: string;
|
||||
expectedField: string;
|
||||
}) {
|
||||
await select.selectOption(provider);
|
||||
const form = page.getByTestId(formTestId);
|
||||
await expect(form).toHaveJSProperty("tagName", "FORM");
|
||||
const input = page.locator(`#${inputId}`);
|
||||
const save = form.locator('button[type="submit"]');
|
||||
await expect(save).toHaveCount(1);
|
||||
|
||||
await input.fill(enterValue);
|
||||
const enterBaseline = patchBodies.length;
|
||||
await input.press("Enter");
|
||||
await expect.poll(() => patchBodies.length).toBe(enterBaseline + 1);
|
||||
await page.waitForTimeout(50);
|
||||
expect(patchBodies).toHaveLength(enterBaseline + 1);
|
||||
expect(patchBodies.at(-1)?.[expectedField]).toBe(enterValue);
|
||||
|
||||
await input.fill(clickValue);
|
||||
const clickBaseline = patchBodies.length;
|
||||
await save.click();
|
||||
await expect.poll(() => patchBodies.length).toBe(clickBaseline + 1);
|
||||
await page.waitForTimeout(50);
|
||||
expect(patchBodies).toHaveLength(clickBaseline + 1);
|
||||
expect(patchBodies.at(-1)?.[expectedField]).toBe(clickValue);
|
||||
}
|
||||
|
||||
await assertEnterAndClickSubmitOnce({
|
||||
provider: "openai",
|
||||
formTestId: "ai-provider-config-openai",
|
||||
inputId: "ai-openai-key",
|
||||
enterValue: "sk-enter-openai",
|
||||
clickValue: "sk-click-openai",
|
||||
expectedField: "openaiKey",
|
||||
});
|
||||
await assertEnterAndClickSubmitOnce({
|
||||
provider: "anthropic",
|
||||
formTestId: "ai-provider-config-anthropic",
|
||||
inputId: "ai-anthropic-key",
|
||||
enterValue: "sk-ant-enter",
|
||||
clickValue: "sk-ant-click",
|
||||
expectedField: "anthropicKey",
|
||||
});
|
||||
await assertEnterAndClickSubmitOnce({
|
||||
provider: "local",
|
||||
formTestId: "ai-provider-config-local",
|
||||
inputId: "ai-local-base-url",
|
||||
enterValue: "http://localhost:11434/v1",
|
||||
clickValue: "http://localhost:11435/v1",
|
||||
expectedField: "baseUrl",
|
||||
});
|
||||
|
||||
const timeoutForm = page.locator("form").filter({
|
||||
has: page.locator("#ai-response-timeout"),
|
||||
});
|
||||
const timeoutInput = page.locator("#ai-response-timeout");
|
||||
const timeoutSave = timeoutForm.locator('button[type="submit"]');
|
||||
await expect(timeoutForm).toHaveCount(1);
|
||||
await expect(timeoutSave).toHaveCount(1);
|
||||
|
||||
await timeoutInput.fill("9");
|
||||
const invalidTimeoutBaseline = patchBodies.length;
|
||||
await timeoutInput.press("Enter");
|
||||
await expect(timeoutForm.locator("p.text-destructive")).toBeVisible();
|
||||
expect(patchBodies).toHaveLength(invalidTimeoutBaseline);
|
||||
|
||||
await timeoutInput.fill("180");
|
||||
const timeoutEnterBaseline = patchBodies.length;
|
||||
await timeoutInput.press("Enter");
|
||||
await expect.poll(() => patchBodies.length).toBe(timeoutEnterBaseline + 1);
|
||||
await page.waitForTimeout(50);
|
||||
expect(patchBodies).toHaveLength(timeoutEnterBaseline + 1);
|
||||
expect(patchBodies.at(-1)?.responseTimeoutSeconds).toBe(180);
|
||||
|
||||
await timeoutInput.fill("240");
|
||||
const timeoutClickBaseline = patchBodies.length;
|
||||
await timeoutSave.click();
|
||||
await expect.poll(() => patchBodies.length).toBe(timeoutClickBaseline + 1);
|
||||
await page.waitForTimeout(50);
|
||||
expect(patchBodies).toHaveLength(timeoutClickBaseline + 1);
|
||||
expect(patchBodies.at(-1)?.responseTimeoutSeconds).toBe(240);
|
||||
|
||||
holdNextPatch = true;
|
||||
await page.locator("#ai-local-base-url").fill("http://localhost:11436/v1");
|
||||
const pendingBaseline = patchBodies.length;
|
||||
await page
|
||||
.getByTestId("ai-provider-config-local")
|
||||
.evaluate((form: HTMLFormElement) => {
|
||||
form.requestSubmit();
|
||||
form.requestSubmit();
|
||||
});
|
||||
await expect.poll(() => patchBodies.length).toBe(pendingBaseline + 1);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("ai-provider-config-local")
|
||||
.locator('button[type="submit"]'),
|
||||
).toBeDisabled();
|
||||
await page.waitForTimeout(50);
|
||||
expect(patchBodies).toHaveLength(pendingBaseline + 1);
|
||||
|
||||
const testConnection = page.getByTestId("ai-test-active-provider");
|
||||
await expect(testConnection).toHaveAttribute("type", "button");
|
||||
await testConnection.click();
|
||||
await expect.poll(() => testConnectionRequests).toBe(1);
|
||||
expect(patchBodies).toHaveLength(pendingBaseline + 1);
|
||||
|
||||
releasePendingPatch();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("ai-provider-config-local")
|
||||
.locator('button[type="submit"]'),
|
||||
).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
const CONVERSATION_ID = "conversation-rename-e2e";
|
||||
const INITIAL_TITLE = "Morning check-in";
|
||||
const RENAMED_TITLE = "Weekly health review";
|
||||
|
||||
test.describe("Coach conversation rename", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test("renames by keyboard once and survives reload on desktop and mobile", async ({
|
||||
page,
|
||||
}) => {
|
||||
let persistedTitle = INITIAL_TITLE;
|
||||
let patchCount = 0;
|
||||
|
||||
await page.route(
|
||||
/\/api\/insights\/chat(?:\/[^?]+)?(?:\?|$)/,
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
if (
|
||||
request.method() === "PATCH" &&
|
||||
url.pathname === `/api/insights/chat/${CONVERSATION_ID}`
|
||||
) {
|
||||
patchCount += 1;
|
||||
const payload = JSON.parse(request.postData() ?? "{}") as {
|
||||
title: string;
|
||||
};
|
||||
persistedTitle = payload.title;
|
||||
await delay(150);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { id: CONVERSATION_ID, title: persistedTitle },
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
conversations: [
|
||||
{
|
||||
id: CONVERSATION_ID,
|
||||
title: persistedTitle,
|
||||
createdAt: "2026-07-20T10:00:00.000Z",
|
||||
updatedAt: "2026-07-20T10:00:00.000Z",
|
||||
messageCount: 2,
|
||||
fenced: false,
|
||||
attachments: [],
|
||||
documentTitle: null,
|
||||
},
|
||||
],
|
||||
nextCursor: null,
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto("/coach/conversations", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
const item = page.locator('[data-slot="coach-conversations-item"]');
|
||||
await expect(item).toContainText(INITIAL_TITLE, { timeout: 10_000 });
|
||||
|
||||
const renameButton = item.locator(
|
||||
'[data-slot="coach-conversation-rename"]',
|
||||
);
|
||||
await renameButton.click();
|
||||
const input = item.locator('[data-slot="coach-conversation-rename-input"]');
|
||||
await expect(input).toBeFocused();
|
||||
|
||||
await input.fill("Discard this draft");
|
||||
await input.press("Escape");
|
||||
await expect(input).toHaveCount(0);
|
||||
await expect(item).toContainText(INITIAL_TITLE);
|
||||
expect(patchCount).toBe(0);
|
||||
|
||||
await renameButton.click();
|
||||
const activeInput = item.locator(
|
||||
'[data-slot="coach-conversation-rename-input"]',
|
||||
);
|
||||
await activeInput.fill(` ${RENAMED_TITLE} `);
|
||||
await activeInput.evaluate((element) => {
|
||||
element.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
|
||||
);
|
||||
element.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
|
||||
);
|
||||
});
|
||||
|
||||
await expect(activeInput).toBeDisabled();
|
||||
await expect(item).toContainText(RENAMED_TITLE);
|
||||
await expect(activeInput).toHaveCount(0, { timeout: 10_000 });
|
||||
expect(patchCount).toBe(1);
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-conversations-item"]'),
|
||||
).toContainText(RENAMED_TITLE, { timeout: 10_000 });
|
||||
expect(patchCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -93,7 +93,14 @@ test.describe("settings AI codex device flow (mocked)", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// Hydration gate — the settings query is fired by the same client
|
||||
// boundary that renders the Codex form, so its response proves React
|
||||
// has attached the CTA's click handler. On mobile the SSR-painted button
|
||||
// can otherwise be clicked before hydration and the event is lost.
|
||||
const codexFormHydrated = page.waitForResponse("**/api/insights/settings");
|
||||
|
||||
await page.goto("/settings/ai", { waitUntil: "domcontentloaded" });
|
||||
await codexFormHydrated;
|
||||
|
||||
// Step 1 — CTA visible
|
||||
const cta = page.getByRole("button", {
|
||||
|
||||
+23
-16
@@ -283,11 +283,18 @@ test.describe("document vault — AI assist + content search", () => {
|
||||
await expect(hint).toBeVisible();
|
||||
const indexAll = page.locator('[data-slot="content-index-all"]');
|
||||
await expect(indexAll).toBeVisible();
|
||||
await indexAll.click();
|
||||
|
||||
await expect(
|
||||
page.getByText("Indexing 5 document(s) in the background."),
|
||||
).toBeVisible();
|
||||
const reindexRequest = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" &&
|
||||
new URL(request.url()).pathname === "/api/documents/inbound/reindex",
|
||||
);
|
||||
await Promise.all([
|
||||
reindexRequest,
|
||||
expect(
|
||||
page.getByText("Indexing 5 document(s) in the background."),
|
||||
).toBeVisible(),
|
||||
indexAll.click(),
|
||||
]);
|
||||
expect(reindexCalls).toBe(1);
|
||||
});
|
||||
|
||||
@@ -351,24 +358,24 @@ test.describe("document vault — AI assist + content search", () => {
|
||||
expect(indexHadJsonBody).toBe(false);
|
||||
});
|
||||
|
||||
// ── (h) Provenance: a provider-read document is marked "Read by AI" ──────
|
||||
// ── (h) The detail view does not label how the text was extracted ────────
|
||||
|
||||
test("a vision-indexed document surfaces the AI-read provenance", async ({
|
||||
test("a vision-indexed document carries no extraction-provenance line", async ({
|
||||
page,
|
||||
}) => {
|
||||
// CONTENT_DOC_ID is seeded with source "vision" (an AI provider read it).
|
||||
// No mocks: the real detail GET threads the provenance through. v1.28.38 —
|
||||
// the vault card no longer carries the AI-read glyph (it looked wrong on
|
||||
// the card face); the provenance now lives as a calm muted meta line in the
|
||||
// document DETAIL view. Deep-link straight to the document so the sheet
|
||||
// opens deterministically (the vault grid is virtualized).
|
||||
// CONTENT_DOC_ID is seeded with source "vision". The detail view used to
|
||||
// print a "Read by AI" meta line under the summary; it told the reader
|
||||
// nothing they could act on — how the text came out of the page is our
|
||||
// plumbing, not their business — so it is gone. This test keeps it gone:
|
||||
// the sheet still opens on the deep link, and the line stays absent.
|
||||
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
const marker = sheet.locator('[data-slot="document-detail-ai-read"]');
|
||||
await expect(marker).toBeVisible();
|
||||
await expect(marker).toHaveText("Read by AI");
|
||||
await expect(
|
||||
sheet.locator('[data-slot="document-detail-ai-read"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(sheet.getByText("Read by AI")).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ── (f) Text-mode refuses a non-image before any OCR/upload ──────────────
|
||||
|
||||
+158
-1
@@ -99,6 +99,42 @@ async function runAxe(page: Page) {
|
||||
return blocking;
|
||||
}
|
||||
|
||||
async function mockDocumentCoach(page: Page): Promise<void> {
|
||||
const fulfil = (
|
||||
route: Parameters<Parameters<Page["route"]>[1]>[0],
|
||||
data: unknown,
|
||||
) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data, error: null }),
|
||||
});
|
||||
|
||||
await page.route("**/api/documents/inbound/usage", (route) =>
|
||||
fulfil(route, {
|
||||
usedBytes: 4096,
|
||||
quotaBytes: 1_073_741_824,
|
||||
maxFileBytes: DEFAULT_MAX_FILE_BYTES,
|
||||
acceptedExtensions: [".pdf", ".png", ".jpg", ".jpeg", ".webp"],
|
||||
linkedEpisodes: [],
|
||||
assistAvailable: true,
|
||||
contentIndex: { enabled: true, indexedCount: 1, totalCount: 1 },
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/documents/inbound/capability", (route) =>
|
||||
fulfil(route, {
|
||||
available: true,
|
||||
mode: "vision",
|
||||
reason: null,
|
||||
pdfSupported: true,
|
||||
egress: "external",
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/auth/me/documents-auto-ai-read", (route) =>
|
||||
fulfil(route, { documentsAutoAiRead: false }),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("document vault", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
@@ -191,6 +227,121 @@ test.describe("document vault", () => {
|
||||
await expect(page).not.toHaveURL(/doc=/);
|
||||
});
|
||||
|
||||
test("ordinary card selection owns doc history and preserves unrelated query state", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&view=compact&q=MRT`);
|
||||
await expect(openButton(page, "MRT Knie")).toBeVisible();
|
||||
await openButton(page, "MRT Knie").click();
|
||||
const detail = page.getByRole("dialog", { name: "MRT Knie" });
|
||||
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("doc"))
|
||||
.toBe(MRT_DOC_ID);
|
||||
expect(new URL(page.url()).searchParams.get("episode")).toBe(
|
||||
KNIE_EPISODE_ID,
|
||||
);
|
||||
expect(new URL(page.url()).searchParams.get("view")).toBe("compact");
|
||||
expect(new URL(page.url()).searchParams.get("q")).toBe("MRT");
|
||||
|
||||
await page.goBack();
|
||||
await expect(detail).not.toBeVisible();
|
||||
expect(new URL(page.url()).searchParams.get("doc")).toBeNull();
|
||||
expect(new URL(page.url()).searchParams.get("view")).toBe("compact");
|
||||
|
||||
await page.goForward();
|
||||
await expect(detail).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(detail).not.toBeVisible();
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("doc"))
|
||||
.toBeNull();
|
||||
expect(new URL(page.url()).searchParams.get("episode")).toBe(
|
||||
KNIE_EPISODE_ID,
|
||||
);
|
||||
expect(new URL(page.url()).searchParams.get("view")).toBe("compact");
|
||||
expect(new URL(page.url()).searchParams.get("q")).toBe("MRT");
|
||||
});
|
||||
|
||||
test("Coach close and overlay dismissal consume the document handoff", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockDocumentCoach(page);
|
||||
await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&view=compact&q=MRT`);
|
||||
await expect(openButton(page, "MRT Knie")).toBeVisible();
|
||||
await openButton(page, "MRT Knie").click();
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("doc"))
|
||||
.toBe(MRT_DOC_ID);
|
||||
|
||||
const detail = page.getByRole("dialog", { name: "MRT Knie" });
|
||||
await detail.locator('[data-slot="document-chat-open"]').click();
|
||||
const drawer = page.locator('[data-slot="coach-drawer"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
await expect(detail).not.toBeVisible();
|
||||
|
||||
await drawer.locator('[data-slot="coach-drawer-close"]').click();
|
||||
await expect(drawer).not.toBeVisible();
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("doc"))
|
||||
.toBeNull();
|
||||
expect(new URL(page.url()).searchParams.get("episode")).toBe(
|
||||
KNIE_EPISODE_ID,
|
||||
);
|
||||
expect(new URL(page.url()).searchParams.get("view")).toBe("compact");
|
||||
expect(new URL(page.url()).searchParams.get("q")).toBe("MRT");
|
||||
await expect(detail).not.toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
await expect(detail).not.toBeVisible();
|
||||
expect(new URL(page.url()).searchParams.get("doc")).toBeNull();
|
||||
|
||||
await expect(openButton(page, "MRT Knie")).toBeVisible();
|
||||
await openButton(page, "MRT Knie").click();
|
||||
await expect(detail).toBeVisible();
|
||||
await detail.locator('[data-slot="document-chat-open"]').click();
|
||||
await expect(drawer).toBeVisible();
|
||||
await page
|
||||
.locator('[data-slot="sheet-overlay"]')
|
||||
.last()
|
||||
.click({ position: { x: 2, y: 2 } });
|
||||
await expect(drawer).not.toBeVisible();
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("doc"))
|
||||
.toBeNull();
|
||||
await expect(detail).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("Ask Coach maximize then browser back restores the selected document", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockDocumentCoach(page);
|
||||
await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&view=compact&q=MRT`);
|
||||
await expect(openButton(page, "MRT Knie")).toBeVisible();
|
||||
await openButton(page, "MRT Knie").click();
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("doc"))
|
||||
.toBe(MRT_DOC_ID);
|
||||
|
||||
const detail = page.getByRole("dialog", { name: "MRT Knie" });
|
||||
await detail.locator('[data-slot="document-chat-open"]').click();
|
||||
const drawer = page.locator('[data-slot="coach-drawer"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
await drawer.locator('[data-slot="coach-drawer-maximize"]').click();
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe("/coach");
|
||||
expect(new URL(page.url()).searchParams.get("doc")).toBe(MRT_DOC_ID);
|
||||
|
||||
await page.goBack();
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe("/documents");
|
||||
await expect(detail).toBeVisible();
|
||||
const restoredUrl = new URL(page.url());
|
||||
expect(restoredUrl.searchParams.get("doc")).toBe(MRT_DOC_ID);
|
||||
expect(restoredUrl.searchParams.get("episode")).toBe(KNIE_EPISODE_ID);
|
||||
expect(restoredUrl.searchParams.get("view")).toBe("compact");
|
||||
expect(restoredUrl.searchParams.get("q")).toBe("MRT");
|
||||
});
|
||||
|
||||
test("the condition page links into the vault and back", async ({ page }) => {
|
||||
await page.goto(`/illness/${KNIE_EPISODE_ID}`);
|
||||
const card = page.locator('[data-slot="episode-documents-card"]');
|
||||
@@ -210,10 +361,16 @@ test.describe("document vault", () => {
|
||||
await page.goto("/documents");
|
||||
await uploadViaPicker(page, `${title}.pdf`, uniquePdf(title));
|
||||
await page.getByRole("searchbox", { name: "Search documents" }).fill(title);
|
||||
// The uploaded card is optimistic and can paint before the 200 ms search
|
||||
// draft is committed to the URL. Opening it earlier races that pending
|
||||
// replace against the detail's `?doc=` history entry and closes the sheet.
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).searchParams.get("q"))
|
||||
.toBe(title);
|
||||
await expect(openButton(page, `${title}.pdf`)).toBeVisible();
|
||||
|
||||
await openButton(page, `${title}.pdf`).click();
|
||||
const sheet = page.getByRole("dialog");
|
||||
const sheet = page.getByRole("dialog", { name: `${title}.pdf` });
|
||||
await expect(sheet).toBeVisible();
|
||||
await sheet.getByRole("button", { name: "Delete" }).click();
|
||||
|
||||
|
||||
@@ -29,6 +29,12 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
|
||||
}) => {
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
|
||||
// A visible server-rendered link is not yet an interactive React link.
|
||||
// Wait for this client component's hydration boundary before scrolling or
|
||||
// clicking so the pointer gesture cannot race React replacing the anchor.
|
||||
const tabStrip = page.locator("[data-slot='insights-tab-strip']");
|
||||
await expect(tabStrip).toHaveAttribute("data-hydrated", "true");
|
||||
|
||||
// v1.25.1 — the app-wide scroll-to-top resets the `#main-content`
|
||||
// scroll container on every route change (auth-shell), with a
|
||||
// `window.scrollTo(0,0)` fallback for the body-scrolled shells. The
|
||||
@@ -53,18 +59,24 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
|
||||
window.scrollTo({ top: 600, behavior: "auto" });
|
||||
}
|
||||
});
|
||||
await expect.poll(readScrollTop).toBeGreaterThan(100);
|
||||
|
||||
// Click the first available sub-page pill.
|
||||
const subPagePill = page
|
||||
.locator("[data-slot='insights-tab-strip-pill']")
|
||||
.nth(1);
|
||||
// Recovery is always present and remains a direct link across responsive
|
||||
// pill grouping. Target its route contract instead of a mutable list index.
|
||||
const subPagePill = tabStrip.locator(
|
||||
"[data-slot='insights-tab-strip-pill'][href='/insights/recovery']",
|
||||
);
|
||||
await expect(subPagePill).toBeVisible();
|
||||
await subPagePill.click();
|
||||
await page.waitForURL(/\/insights\//);
|
||||
await expect(page).toHaveURL(/\/insights\/recovery\/?$/);
|
||||
|
||||
// Back to the mother page via the overview pill (pill index 0).
|
||||
await page.locator("[data-slot='insights-tab-strip-pill']").first().click();
|
||||
await page.waitForURL(/\/insights\/?$/);
|
||||
// Back to the mother page through the overview pill.
|
||||
const overviewPill = tabStrip.locator(
|
||||
"[data-slot='insights-tab-strip-pill'][href='/insights']",
|
||||
);
|
||||
await expect(overviewPill).toBeVisible();
|
||||
await overviewPill.click();
|
||||
await expect(page).toHaveURL(/\/insights\/?$/);
|
||||
|
||||
// The shell's route-change effect resets the scroll on the next tick;
|
||||
// `useScrollResetOnRoute` defers to `requestAnimationFrame`. Wait for it
|
||||
@@ -91,6 +103,12 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
|
||||
}) => {
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
const nav = page.locator("[data-slot='insights-tab-strip']");
|
||||
const innerScroller = nav.locator(
|
||||
"[data-slot='insights-tab-strip-scroller']",
|
||||
);
|
||||
await expect(nav).toHaveAttribute("data-hydrated", "true");
|
||||
await expect(innerScroller).toBeVisible();
|
||||
|
||||
const navOverflowX = await nav.evaluate(
|
||||
(el) => getComputedStyle(el).overflowX,
|
||||
);
|
||||
@@ -100,7 +118,6 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
|
||||
expect(navOverflowX).not.toBe("auto");
|
||||
expect(navOverflowX).not.toBe("scroll");
|
||||
|
||||
const innerScroller = nav.locator("> div > div").first();
|
||||
const innerOverflowX = await innerScroller.evaluate(
|
||||
(el) => getComputedStyle(el).overflowX,
|
||||
);
|
||||
|
||||
@@ -20,8 +20,9 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
/**
|
||||
* v1.5.4 — Medication wizard end-to-end — daily cadence.
|
||||
*
|
||||
* Walks the 7-step daily path through the modal dialog. The dialog
|
||||
* mounts when `/medications/new` redirects to `/medications?new=1`.
|
||||
* Walks the 7-step daily path through the modal dialog. The shared helper
|
||||
* opens `/medications?new=1` directly so cadence coverage is independent of
|
||||
* the legacy `/medications/new` redirect.
|
||||
* The DE label surface (the maintainer's primary locale) is the assertion
|
||||
* surface — "Schritt N von 7" tracks the visible counter.
|
||||
*/
|
||||
|
||||
@@ -120,8 +120,9 @@ export async function stubDashboardAnalytics(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the wizard dialog by hitting `/medications/new` (which
|
||||
* redirects to `/medications?new=1` and opens the create dialog).
|
||||
* Open the wizard directly on `/medications?new=1`. The redirect from the
|
||||
* legacy `/medications/new` route is a separate concern; these cadence specs
|
||||
* exercise the wizard without racing that server navigation.
|
||||
*/
|
||||
export async function openCreateWizard(page: Page): Promise<void> {
|
||||
// Pin the German locale for the wizard flow. The app default locale is
|
||||
@@ -136,12 +137,14 @@ export async function openCreateWizard(page: Page): Promise<void> {
|
||||
{
|
||||
name: "healthlog-locale",
|
||||
value: "de",
|
||||
url: page.url().startsWith("http")
|
||||
? new URL(page.url()).origin
|
||||
: "http://localhost:3000",
|
||||
url: new URL(process.env.E2E_BASE_URL ?? "http://localhost:3000").origin,
|
||||
},
|
||||
]);
|
||||
await page.goto("/medications/new", { waitUntil: "domcontentloaded" });
|
||||
await page.goto("/medications?new=1", { waitUntil: "load" });
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname === "/medications" && !url.searchParams.has("new"),
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await expect(
|
||||
page.locator('[data-slot="medication-wizard-dialog"]'),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -33,6 +33,29 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
test.describe("medication wizard — one-shot", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test("does not replay the one-shot URL flag after history traversal", async ({
|
||||
page,
|
||||
}) => {
|
||||
await stubDashboardAnalytics(page);
|
||||
await openCreateWizard(page);
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(
|
||||
page.locator('[data-slot="medication-wizard-dialog"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.locator('a[href="/"]:visible').first().click();
|
||||
await page.waitForURL((url) => url.pathname === "/");
|
||||
await page.goBack();
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname === "/medications" && !url.searchParams.has("new"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.locator('[data-slot="medication-wizard-dialog"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("creates a single-dose medication end-to-end", async ({ page }) => {
|
||||
test.slow();
|
||||
await stubDashboardAnalytics(page);
|
||||
|
||||
@@ -206,7 +206,7 @@ test.describe("mobile-viewport smoke", () => {
|
||||
// 1) The center capture action opens the capture picker.
|
||||
await page.getByTestId("bottom-nav-capture").click();
|
||||
await expect(page.getByTestId("capture-picker-options")).toBeVisible();
|
||||
for (const kind of ["measurement", "medication", "mood"]) {
|
||||
for (const kind of ["measurement", "medication", "mood", "water"]) {
|
||||
await expect(page.getByTestId(`capture-picker-${kind}`)).toBeVisible();
|
||||
}
|
||||
// Dismiss the picker before opening the hub.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
test.describe("mood timestamp capture", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test("keeps Time visible while details are collapsed and submits the selected instant", async ({
|
||||
page,
|
||||
}) => {
|
||||
let submitted: Record<string, unknown> | null = null;
|
||||
|
||||
await page.route("**/api/mood-entries*", async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
submitted = route.request().postDataJSON() as Record<string, unknown>;
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: { id: "created-mood" }, error: null }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { entries: [], meta: { total: 0, limit: 50, offset: 0 } },
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/mood", { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "Add", exact: true }).click();
|
||||
await page.getByRole("radio", { name: "Amazing" }).click();
|
||||
|
||||
const details = page.getByRole("button", { name: "Note & details" });
|
||||
await expect(details).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
const timestamp = page.locator('[data-slot="date-time-field"]');
|
||||
await expect(timestamp).toBeVisible();
|
||||
const dateInput = timestamp.locator(
|
||||
'[data-slot="date-field"] input[type="text"]',
|
||||
);
|
||||
const timeInput = timestamp.locator(
|
||||
'[data-slot="time-field"] input[type="text"]',
|
||||
);
|
||||
await dateInput.fill("2026-01-15");
|
||||
await dateInput.blur();
|
||||
await timeInput.fill("09:45");
|
||||
await timeInput.blur();
|
||||
|
||||
await page.getByRole("button", { name: "Save", exact: true }).click();
|
||||
|
||||
await expect.poll(() => submitted).not.toBeNull();
|
||||
expect(submitted).toMatchObject({
|
||||
mood: "SUPER_GUT",
|
||||
moodLoggedAt: "2026-01-15T08:45:00.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -94,9 +94,27 @@ test.describe("Settings → Export & Import", () => {
|
||||
}) => {
|
||||
// v1.18.0 (S5) — the panel (and its disclosure) live on the
|
||||
// dedicated Gesundheitsakte section now.
|
||||
await page.route("**/api/share-links", async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
await route.continue();
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
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;
|
||||
const toggle = page.getByTestId("health-record-included-data-toggle");
|
||||
await expect(toggle).toBeVisible({ timeout: 10_000 });
|
||||
// Collapsed on first render — the checklist panel is absent.
|
||||
|
||||
@@ -198,3 +198,44 @@ test.describe("/settings/integrations Pixel-5 layout", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Apple Health guidance", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test("opens on Integrations and reaches the one-shot import fallback", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/integrations/healthkit", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { entries: [], lastSyncedAt: null },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/settings/integrations");
|
||||
|
||||
const card = page.getByTestId("apple-health-card");
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toContainText("HealthLog iOS app");
|
||||
await expect(card).toContainText("not a web or OAuth connection");
|
||||
await expect(page.getByTestId("apple-health-status")).toHaveAttribute(
|
||||
"data-state",
|
||||
"setup",
|
||||
);
|
||||
|
||||
const importLink = page.getByTestId("apple-health-import-link");
|
||||
await expect(importLink).toHaveAttribute(
|
||||
"href",
|
||||
"/settings/export#settings-section-import-title",
|
||||
);
|
||||
await page.goto("/settings/export#settings-section-import-title");
|
||||
await expect(page).toHaveURL(
|
||||
/\/settings\/export#settings-section-import-title$/,
|
||||
);
|
||||
await expect(page.getByTestId("import-card-apple-health")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -97,6 +97,8 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
// that tries to click a header / sidebar / quick-add button times
|
||||
// out (50+ failed CI runs since v1.4.13). Specs that need the
|
||||
// tour can opt back in by mocking `/api/auth/me`.
|
||||
// Nutrients is intentionally opt-in in production. The shared fixture
|
||||
// enables it because the water-capture specs exercise that real gate.
|
||||
await pool.query(
|
||||
`INSERT INTO users
|
||||
(id, username, email, password_hash, role,
|
||||
@@ -105,14 +107,16 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
codex_connection_status,
|
||||
insights_privacy_mode,
|
||||
onboarding_completed_at,
|
||||
onboarding_tour_completed)
|
||||
onboarding_tour_completed,
|
||||
module_preferences_json)
|
||||
VALUES ($1, $2, $3, $4, $5,
|
||||
$6, $6,
|
||||
180, $7, 'MALE',
|
||||
'disconnected',
|
||||
'aggregated',
|
||||
$6,
|
||||
true)
|
||||
true,
|
||||
'{"nutrients":true}'::jsonb)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
@@ -129,7 +133,8 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
insights_cached_at = NULL,
|
||||
insights_cached_text = NULL,
|
||||
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
|
||||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed`,
|
||||
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
|
||||
module_preferences_json = EXCLUDED.module_preferences_json`,
|
||||
[
|
||||
cuid(),
|
||||
E2E_USER.username,
|
||||
@@ -141,6 +146,19 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
|
||||
],
|
||||
);
|
||||
|
||||
// Repeated local E2E runs reuse the same seeded account and database.
|
||||
// Its owner-scoped share-link bucket lasts an hour, so otherwise the third
|
||||
// run can start above the 20-operation ceiling and fail before exercising
|
||||
// the share flow. Reset only this fixture user's bucket; never touch
|
||||
// unrelated rate-limit state in a developer database.
|
||||
await pool.query(
|
||||
`DELETE FROM rate_limits
|
||||
WHERE key = 'share-link:' || (
|
||||
SELECT id::text FROM users WHERE username = $1
|
||||
)`,
|
||||
[E2E_USER.username],
|
||||
);
|
||||
|
||||
// Opt the seed account into cycle tracking. The user is seeded as
|
||||
// `MALE`, and v1.18.0 resolves the `cycle` module through
|
||||
// `isCycleEnabled(gender, CycleProfile)` — NULL `cycleTrackingEnabled`
|
||||
|
||||
@@ -57,7 +57,14 @@ interface CreatedShare {
|
||||
async function createShareWithDocs(page: Page): Promise<CreatedShare> {
|
||||
const label = `${SHARE_DOC_PREFIX} ${Date.now()}`;
|
||||
|
||||
const sharingDataReady = page.waitForResponse(
|
||||
(response) =>
|
||||
response.status() === 200 &&
|
||||
response.request().method() === "GET" &&
|
||||
new URL(response.url()).pathname === "/api/share-links",
|
||||
);
|
||||
await page.goto("/settings/sharing");
|
||||
await sharingDataReady;
|
||||
await page.locator("#share-label").fill(label);
|
||||
|
||||
// Open the document picker and isolate the seeded trio.
|
||||
@@ -69,13 +76,18 @@ async function createShareWithDocs(page: Page): Promise<CreatedShare> {
|
||||
// Exactly the three share-fixture documents match the prefix.
|
||||
const rows = list.getByRole("button");
|
||||
await expect(rows).toHaveCount(3);
|
||||
for (let i = 0; i < 3; i++) await rows.nth(i).click();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const row = rows.nth(i);
|
||||
await row.click();
|
||||
await expect(row).toHaveAttribute("aria-pressed", "true");
|
||||
}
|
||||
await page.getByRole("button", { name: "Done" }).click();
|
||||
|
||||
// Three chips staged before create.
|
||||
await expect(
|
||||
page.getByTestId("share-attached-chips").getByRole("listitem"),
|
||||
).toHaveCount(3);
|
||||
await expect(page.getByRole("button", { name: "Create link" })).toBeEnabled();
|
||||
|
||||
await page.getByRole("button", { name: "Create link" }).click();
|
||||
|
||||
@@ -150,9 +162,14 @@ test.describe("clinician document sharing", () => {
|
||||
clinician.getByRole("heading", { name: "Documents" }),
|
||||
).toBeVisible();
|
||||
// Class A image → inline <img> at the token-scoped serve route.
|
||||
await expect(
|
||||
clinician.locator(`img[src*="/d/${SHARE_JPEG_DOC_ID}"]`),
|
||||
).toBeVisible();
|
||||
const inlineImage = clinician.getByRole("img", {
|
||||
name: "Share e2e scan",
|
||||
});
|
||||
await expect(inlineImage).toBeVisible();
|
||||
await expect(inlineImage).toHaveAttribute(
|
||||
"src",
|
||||
new RegExp(`/d/${SHARE_JPEG_DOC_ID}$`),
|
||||
);
|
||||
// Class A PDF → inline <iframe> at the same route family.
|
||||
await expect(
|
||||
clinician.locator(`iframe[src*="/d/${SHARE_PDF_DOC_ID}"]`),
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
|
||||
import { E2E_USER, STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
/**
|
||||
* v1.31.0 — Today reacts in place.
|
||||
*
|
||||
* The milestone's actual moment: new data lands, and the ALREADY-OPEN
|
||||
* dashboard starts reading differently on the cadence it already runs. No new
|
||||
* poll, no push, no socket — a state change, not a notification.
|
||||
*
|
||||
* The spec drives that end-to-end against the real read path:
|
||||
*
|
||||
* 1. load the dashboard with nothing fresh — no chip, day is provisional;
|
||||
* 2. land last night's sleep through the REAL batch endpoint, and the
|
||||
* arrival marker the spine's worker would have written;
|
||||
* 3. surface the tab's real visibility-change signal — the browser event
|
||||
* TanStack Query's focus manager consumes for
|
||||
* `refetchOnWindowFocus: "always"` (alongside the 120 s foreground poll);
|
||||
* using that existing trigger keeps the test inside the real cadence
|
||||
* while staying well under the poll interval;
|
||||
* 4. assert the chip appeared and the phase flipped — in place, no reload.
|
||||
*
|
||||
* The marker is seeded directly rather than waited for from pg-boss: whether
|
||||
* the queue drains inside the e2e web server is the SPINE's contract and has
|
||||
* its own tests. What this spec owns is everything downstream of a marker
|
||||
* existing — the read path, the DTO, and the surface.
|
||||
*
|
||||
* Assertions anchor on `data-slot` / `data-phase`, never on visible text:
|
||||
* responsive `sm:hidden` classes have broken `getByText` in this repo before.
|
||||
*/
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
/** The user's local day, in the same profile-tz space the digest files under. */
|
||||
function localDayKey(at: Date, timeZone: string): string {
|
||||
// en-CA renders ISO-ordered YYYY-MM-DD, which is exactly the key shape.
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(at);
|
||||
}
|
||||
|
||||
test("a fresh arrival surfaces the just-in chip and flips the day in place", async ({
|
||||
page,
|
||||
}) => {
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
test.skip(!dbUrl, "DATABASE_URL is required to seed the arrival marker");
|
||||
|
||||
const pool = new pg.Pool({ connectionString: dbUrl });
|
||||
|
||||
try {
|
||||
const { rows } = await pool.query<{ id: string; timezone: string | null }>(
|
||||
"SELECT id, timezone FROM users WHERE username = $1",
|
||||
[E2E_USER.username],
|
||||
);
|
||||
expect(rows.length, "seeded e2e user must exist").toBe(1);
|
||||
const userId = rows[0].id;
|
||||
const timezone = rows[0].timezone ?? "UTC";
|
||||
|
||||
// A clean slate: no marker, and last night's sleep not yet in. Both are
|
||||
// preconditions for the "before" assertion to mean anything.
|
||||
await pool.query("DELETE FROM arrival_reactions WHERE user_id = $1", [
|
||||
userId,
|
||||
]);
|
||||
await pool.query(
|
||||
"UPDATE users SET morning_digest_refreshed_on = NULL WHERE id = $1",
|
||||
[userId],
|
||||
);
|
||||
|
||||
// ── BEFORE ────────────────────────────────────────────────────────────
|
||||
// The globally-seeded account is intentionally empty. Wait until the
|
||||
// digest settles, then prove there is no stale hero or arrival chip.
|
||||
await page.goto("/");
|
||||
await expect(page.locator('[data-slot="today-hero-skeleton"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
const hero = page.locator('[data-slot="today-hero"]');
|
||||
await expect(hero).toHaveCount(0);
|
||||
|
||||
// ── NEW DATA LANDS ────────────────────────────────────────────────────
|
||||
// Last night's sleep through the real ingest route, on the session the
|
||||
// storage state already carries.
|
||||
const now = new Date();
|
||||
const wokeAt = new Date(now.getTime() - 60 * 60_000);
|
||||
const fellAsleepAt = new Date(wokeAt.getTime() - 7 * 60 * 60_000);
|
||||
|
||||
const batch = await page.request.post("/api/measurements/batch", {
|
||||
data: {
|
||||
entries: [
|
||||
{
|
||||
hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
|
||||
value: 420,
|
||||
unit: "min",
|
||||
startDate: fellAsleepAt.toISOString(),
|
||||
endDate: wokeAt.toISOString(),
|
||||
externalId: `e2e-just-in-${now.getTime()}`,
|
||||
sleepStage: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
batch.ok(),
|
||||
`sleep batch must be accepted (got ${batch.status()})`,
|
||||
).toBe(true);
|
||||
|
||||
// The marker the spine's worker writes on a salient arrival, plus the
|
||||
// morning-refresh stamp it rides alongside — the two rows that together
|
||||
// make the day final and the arrival news.
|
||||
const localDate = localDayKey(now, timezone);
|
||||
await pool.query(
|
||||
`INSERT INTO arrival_reactions
|
||||
(id, user_id, kind, local_date, occurred_at, arrived_at, created_at)
|
||||
VALUES ($1, $2, 'sleep_night', $3, $4, NOW(), NOW())
|
||||
ON CONFLICT (user_id, kind, local_date) DO UPDATE
|
||||
SET occurred_at = EXCLUDED.occurred_at,
|
||||
arrived_at = NOW()`,
|
||||
[`c${now.getTime()}justin000000`, userId, localDate, wokeAt],
|
||||
);
|
||||
await pool.query(
|
||||
"UPDATE users SET morning_digest_refreshed_on = $2 WHERE id = $1",
|
||||
[userId, localDate],
|
||||
);
|
||||
|
||||
// The tab becomes visible — the browser signal TanStack Query's focus
|
||||
// manager consumes. The digest refetches in place; the page never reloads.
|
||||
await page.evaluate(() =>
|
||||
window.dispatchEvent(new Event("visibilitychange")),
|
||||
);
|
||||
|
||||
await expect(hero.locator('[data-slot="today-hero-just-in"]')).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(hero).toHaveAttribute("data-phase", "final", {
|
||||
timeout: 20_000,
|
||||
});
|
||||
// Sleep is in, so the provisional freshness note is gone with it.
|
||||
await expect(
|
||||
hero.locator('[data-slot="today-hero-sleep-pending"]'),
|
||||
).toHaveCount(0);
|
||||
// The chip names the arrival kind — the stable attribute, not the copy.
|
||||
await expect(
|
||||
hero.locator('[data-slot="today-hero-just-in"]'),
|
||||
).toHaveAttribute("data-just-in-kind", "sleep_night");
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
});
|
||||
@@ -279,6 +279,7 @@ export function buildMockSnapshot(
|
||||
nextDueAt: null,
|
||||
nextDueOverdue: false,
|
||||
nextDueMedicationName: null,
|
||||
nextDueMedicationId: null,
|
||||
},
|
||||
healthScore: options.briefing
|
||||
? { score: 84, band: "green", delta: -2, restMode: null }
|
||||
|
||||
@@ -5,21 +5,14 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
/**
|
||||
* v1.4.27 MB6 — `/measurements?add=<TYPE>` consumer.
|
||||
*
|
||||
* The insights empty-state CTAs link here with a `?add=<TYPE>` query
|
||||
* param. The page reads the param during render, opens the
|
||||
* Add-Measurement primitive, pre-selects the matching type, then
|
||||
* `router.replace("/measurements")` strips the query so the back
|
||||
* button drops the user on the bare list instead of re-opening the
|
||||
* dialog.
|
||||
* Insight metric actions link here with `?add=<TYPE>` and an optional,
|
||||
* allowlisted `returnTo`. The page opens the responsive capture primitive,
|
||||
* pre-selects the matching type, and strips the query without losing the
|
||||
* one-shot return context.
|
||||
*
|
||||
* Three contracts under test:
|
||||
* 1. The primitive opens on first paint.
|
||||
* 2. The form's type combobox shows the requested type.
|
||||
* 3. The URL settles on `/measurements` (no `?add=`).
|
||||
*
|
||||
* Pixel 5 is the load-bearing project — every CTA hop originates from
|
||||
* the mobile-first empty-state surfaces. Desktop Chrome covers the
|
||||
* regression path through the dashboard quick-entry.
|
||||
* The browser contract covers the populated-page action, legacy preselection,
|
||||
* failure retention, success return, and rejection of an external destination.
|
||||
* Playwright runs every case in both the desktop Chromium and Pixel 5 projects.
|
||||
*/
|
||||
test.describe("v1.4.27 — /measurements?add= consumer", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
@@ -49,6 +42,57 @@ test.describe("v1.4.27 — /measurements?add= consumer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("a populated metric action opens the matching preselected capture", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route(/\/api\/analytics(\?|$)/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
summaries: {
|
||||
WEIGHT: {
|
||||
latest: 78.4,
|
||||
count: 4,
|
||||
min: 77.9,
|
||||
max: 78.8,
|
||||
mean: 78.3,
|
||||
median: 78.25,
|
||||
},
|
||||
},
|
||||
bpInTargetPct: null,
|
||||
glucoseByContext: {},
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto("/insights/weight", { waitUntil: "domcontentloaded" });
|
||||
// The action is server-rendered before React can handle client navigation.
|
||||
// Pin the click to the Insights surface's existing hydration contract so
|
||||
// it cannot race hydration and get replayed as a no-op.
|
||||
await expect(
|
||||
page.locator('[data-slot="insights-tab-strip"]'),
|
||||
).toHaveAttribute("data-hydrated", "true");
|
||||
|
||||
const capture = page.locator('[data-slot="metric-add-reading"]');
|
||||
await expect(capture).toBeVisible();
|
||||
await expect(capture).toHaveAttribute(
|
||||
"href",
|
||||
"/measurements?add=WEIGHT&returnTo=%2Finsights%2Fweight",
|
||||
);
|
||||
await capture.click();
|
||||
await expect(page).toHaveURL(/\/measurements(?:\?|$)/);
|
||||
|
||||
const content = page.locator('[data-slot="responsive-sheet-content"]');
|
||||
await expect(content).toBeVisible();
|
||||
await expect(content.getByRole("combobox").first()).toContainText(
|
||||
/weight|gewicht/i,
|
||||
);
|
||||
});
|
||||
|
||||
for (const { param, label } of [
|
||||
{ param: "WEIGHT", label: /weight|gewicht/i },
|
||||
{ param: "BLOOD_PRESSURE", label: /blood pressure|blutdruck/i },
|
||||
@@ -76,6 +120,117 @@ test.describe("v1.4.27 — /measurements?add= consumer", () => {
|
||||
});
|
||||
}
|
||||
|
||||
test("keeps the one-shot sheet and entered values when the write fails", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/measurements*", async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: null, error: "Write failed" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
|
||||
await page.goto("/measurements?add=WEIGHT&returnTo=%2Finsights%2Fweight", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
const content = page.locator('[data-slot="responsive-sheet-content"]');
|
||||
await expect(content).toBeVisible();
|
||||
await expect(content.getByRole("combobox").first()).toContainText(
|
||||
/weight|gewicht/i,
|
||||
);
|
||||
await content.locator("#value").fill("78.4");
|
||||
await content.locator("#notes").fill("Keep this note");
|
||||
await page
|
||||
.getByRole("button", { name: /save|speichern/i })
|
||||
.last()
|
||||
.click();
|
||||
|
||||
await expect(content.getByRole("alert")).toBeVisible();
|
||||
await expect(content.locator("#value")).toHaveValue("78.4");
|
||||
await expect(content.locator("#notes")).toHaveValue("Keep this note");
|
||||
await expect(content).toBeVisible();
|
||||
expect(new URL(page.url()).pathname).toBe("/measurements");
|
||||
});
|
||||
|
||||
test("returns to the validated metric route only after a successful write", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/measurements*", async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { id: "contextual-e2e", success: true },
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
|
||||
await page.goto("/measurements?add=WEIGHT&returnTo=%2Finsights%2Fweight", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
const content = page.locator('[data-slot="responsive-sheet-content"]');
|
||||
await expect(content.getByRole("combobox").first()).toContainText(
|
||||
/weight|gewicht/i,
|
||||
);
|
||||
await content.locator("#value").fill("78.4");
|
||||
await Promise.all([
|
||||
page.waitForURL((url) => url.pathname === "/insights/weight"),
|
||||
page
|
||||
.getByRole("button", { name: /save|speichern/i })
|
||||
.last()
|
||||
.click(),
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects an external return target after a successful write", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/measurements*", async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { id: "contextual-e2e", success: true },
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fallback();
|
||||
});
|
||||
|
||||
await page.goto(
|
||||
"/measurements?add=WEIGHT&returnTo=https%3A%2F%2Fevil.example%2Fsteal",
|
||||
{ waitUntil: "domcontentloaded" },
|
||||
);
|
||||
const appOrigin = new URL(page.url()).origin;
|
||||
|
||||
const content = page.locator('[data-slot="responsive-sheet-content"]');
|
||||
await content.locator("#value").fill("78.4");
|
||||
await page
|
||||
.getByRole("button", { name: /save|speichern/i })
|
||||
.last()
|
||||
.click();
|
||||
|
||||
await expect(content).toHaveCount(0);
|
||||
const settled = new URL(page.url());
|
||||
expect(settled.origin).toBe(appOrigin);
|
||||
expect(settled.pathname).toBe("/measurements");
|
||||
});
|
||||
|
||||
test("unknown ?add value is dropped silently and the page renders empty", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import {
|
||||
mockDashboardSnapshot,
|
||||
WEIGHT_ONLY_SUMMARIES,
|
||||
} from "./utils/mock-dashboard-snapshot";
|
||||
|
||||
test.describe("water capture", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test("opens globally, waits for success, and retains failed input", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await mockDashboardSnapshot(page, { summaries: WEIGHT_ONLY_SUMMARIES });
|
||||
await page.route(/\/api\/analytics(\?|$)/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { summaries: {}, bpInTargetPct: null, glucoseByContext: {} },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route("**/api/mood/analytics", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { entries: [], summary: { count: 0 } },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
let writeCount = 0;
|
||||
let confirmSuccess: (() => void) | undefined;
|
||||
const successConfirmed = new Promise<void>((resolve) => {
|
||||
confirmSuccess = resolve;
|
||||
});
|
||||
await page.route("**/api/nutrients/water", async (route) => {
|
||||
writeCount += 1;
|
||||
if (writeCount === 1) {
|
||||
await successConfirmed;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: { amountMl: 250 }, error: null }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 500,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: null, error: "write failed" }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const openWater = async (toastMayOverlap = false) => {
|
||||
if (testInfo.project.name === "chromium-mobile") {
|
||||
const capture = page.getByTestId("bottom-nav-capture");
|
||||
if (toastMayOverlap) {
|
||||
await capture.dispatchEvent("click");
|
||||
} else {
|
||||
await capture.click();
|
||||
}
|
||||
const option = page.getByTestId("capture-picker-water");
|
||||
await expect(option).toBeVisible();
|
||||
const box = await option.boundingBox();
|
||||
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
if (toastMayOverlap) {
|
||||
await option.dispatchEvent("click");
|
||||
} else {
|
||||
await option.click();
|
||||
}
|
||||
} else {
|
||||
await page.locator('[data-tour-id="dashboard-quick-add"]').click();
|
||||
await page.getByRole("menuitem", { name: "Log water" }).click();
|
||||
}
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Add water" }),
|
||||
).toBeVisible();
|
||||
if (testInfo.project.name === "chromium-mobile") {
|
||||
const actions = page
|
||||
.getByRole("dialog")
|
||||
.locator(
|
||||
'[data-slot="water-quick-add-chips"] button, form button[type="submit"]',
|
||||
);
|
||||
await expect(actions).toHaveCount(4);
|
||||
for (const action of await actions.all()) {
|
||||
await expect(action).toHaveAccessibleName(/.+/);
|
||||
const actionBox = await action.boundingBox();
|
||||
expect(actionBox?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await openWater();
|
||||
const amount = page.getByRole("spinbutton", {
|
||||
name: "Custom amount (mL)",
|
||||
});
|
||||
await amount.fill("250");
|
||||
const submit = page
|
||||
.getByRole("dialog")
|
||||
.getByRole("button", { name: "Add", exact: true });
|
||||
await amount.press("Enter");
|
||||
|
||||
await expect(submit).toBeDisabled();
|
||||
await expect(amount).toHaveValue("250");
|
||||
await expect(page.getByText("250 mL water added.")).toHaveCount(0);
|
||||
|
||||
confirmSuccess?.();
|
||||
await expect(page.getByText("250 mL water added.")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Add water" })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
expect(writeCount).toBe(1);
|
||||
|
||||
await openWater(true);
|
||||
const failedAmount = page.getByRole("spinbutton", {
|
||||
name: "Custom amount (mL)",
|
||||
});
|
||||
await expect(failedAmount).toHaveValue("");
|
||||
await failedAmount.fill("375");
|
||||
await page
|
||||
.getByRole("dialog")
|
||||
.getByRole("button", { name: "Add", exact: true })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByText("Couldn't save that — try again."),
|
||||
).toBeVisible();
|
||||
await expect(failedAmount).toHaveValue("375");
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Add water" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("375 mL water added.")).toHaveCount(0);
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
interface MockWorkout {
|
||||
id: string;
|
||||
sportType: string;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
durationSec: number;
|
||||
distanceM: null;
|
||||
activeEnergyKcal: null;
|
||||
avgHr: null;
|
||||
maxHr: null;
|
||||
source: string;
|
||||
externalId: string;
|
||||
}
|
||||
|
||||
function workout(id: number): MockWorkout {
|
||||
return {
|
||||
id: `workout-${id}`,
|
||||
sportType: "running",
|
||||
startedAt: "2026-07-20T08:00:00.000Z",
|
||||
endedAt: "2026-07-20T08:30:00.000Z",
|
||||
durationSec: 1800,
|
||||
distanceM: null,
|
||||
activeEnergyKcal: null,
|
||||
avgHr: null,
|
||||
maxHr: null,
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: `external-${id}`,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("workout history pagination", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test("loads canonical rows beyond 100 once without duplicate ids", async ({
|
||||
page,
|
||||
}) => {
|
||||
const firstPage = Array.from({ length: 100 }, (_, index) =>
|
||||
workout(index + 1),
|
||||
);
|
||||
const secondPage = [
|
||||
workout(100),
|
||||
...Array.from({ length: 99 }, (_, index) => workout(index + 101)),
|
||||
];
|
||||
const thirdPage = [workout(200), workout(201)];
|
||||
const observedOffsets: number[] = [];
|
||||
let releaseFirstPage!: () => void;
|
||||
const firstPageGate = new Promise<void>((resolve) => {
|
||||
releaseFirstPage = resolve;
|
||||
});
|
||||
let releaseSecondPage!: () => void;
|
||||
const secondPageGate = new Promise<void>((resolve) => {
|
||||
releaseSecondPage = resolve;
|
||||
});
|
||||
|
||||
await page.route(/\/api\/workouts(?:\?|$)/, async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const limit = Number(url.searchParams.get("limit") ?? "50");
|
||||
const offset = Number(url.searchParams.get("offset") ?? "0");
|
||||
|
||||
if (limit !== 100) {
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
workouts: firstPage.slice(0, limit),
|
||||
meta: {
|
||||
total: 201,
|
||||
limit,
|
||||
offset,
|
||||
droppedDuplicates: 0,
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
observedOffsets.push(offset);
|
||||
if (offset === 0) await firstPageGate;
|
||||
if (offset === 100) await secondPageGate;
|
||||
const workouts =
|
||||
offset === 0 ? firstPage : offset === 100 ? secondPage : thirdPage;
|
||||
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
workouts,
|
||||
meta: {
|
||||
total: 201,
|
||||
limit,
|
||||
offset,
|
||||
droppedDuplicates: 0,
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/insights/workouts");
|
||||
await expect(page.locator('[data-slot="workouts-loading"]')).toBeVisible();
|
||||
await expect(page.locator('[data-slot="workout-list-row"]')).toHaveCount(0);
|
||||
releaseFirstPage();
|
||||
|
||||
const rows = page.locator('[data-slot="workout-list-row"]');
|
||||
await expect(rows).toHaveCount(100);
|
||||
await expect(page.locator('[data-slot="workouts-loading"]')).toHaveCount(0);
|
||||
|
||||
const loadMore = page.getByRole("button", { name: "Load more workouts" });
|
||||
await loadMore.click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Loading more workouts" }),
|
||||
).toBeDisabled();
|
||||
releaseSecondPage();
|
||||
|
||||
await expect(
|
||||
page.locator('a[href="/insights/workouts/workout-101"]'),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
page.locator('a[href="/insights/workouts/workout-100"]'),
|
||||
).toHaveCount(1);
|
||||
await expect(rows).toHaveCount(199);
|
||||
|
||||
await page.getByRole("button", { name: "Load more workouts" }).click();
|
||||
await expect(
|
||||
page.locator('a[href="/insights/workouts/workout-201"]'),
|
||||
).toHaveCount(1);
|
||||
await expect(rows).toHaveCount(201);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Load more workouts" }),
|
||||
).toHaveCount(0);
|
||||
expect(observedOffsets).toEqual([0, 100, 200]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it } from "vitest";
|
||||
import { RuleTester } from "eslint";
|
||||
import rule from "../api-fetch-required.js";
|
||||
|
||||
RuleTester.describe = describe;
|
||||
RuleTester.it = it;
|
||||
RuleTester.itOnly = it.only;
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
ecmaVersion: 2023,
|
||||
sourceType: "module",
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
});
|
||||
|
||||
ruleTester.run("api-fetch-required", rule, {
|
||||
valid: [
|
||||
{
|
||||
code: "apiGet(endpoint); apiFetchRaw(externalUrl);",
|
||||
filename: "/repo/src/components/example.tsx",
|
||||
},
|
||||
{
|
||||
code: 'fetch("/api/server-prefetch");',
|
||||
filename: "/repo/src/app/server-page.tsx",
|
||||
},
|
||||
{
|
||||
code: 'fetch("https://example.test/data");',
|
||||
filename: "/repo/src/lib/server-only.ts",
|
||||
},
|
||||
{
|
||||
code: "fetch(path, init);",
|
||||
filename: "/repo/src/lib/api/api-fetch.ts",
|
||||
},
|
||||
{
|
||||
code: 'fetch("/api/mock");',
|
||||
filename: "/repo/src/components/__tests__/example.test.tsx",
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: "fetch(endpoint);",
|
||||
filename: "/repo/src/components/example.tsx",
|
||||
errors: [{ messageId: "rawApiFetch" }],
|
||||
},
|
||||
{
|
||||
code: "window.fetch(`/api/${id}`);",
|
||||
filename: "/repo/src/components/example.tsx",
|
||||
errors: [{ messageId: "rawApiFetch" }],
|
||||
},
|
||||
{
|
||||
code: 'globalThis.fetch("https://example.test/data");',
|
||||
filename: "/repo/src/hooks/use-example.ts",
|
||||
errors: [{ messageId: "rawApiFetch" }],
|
||||
},
|
||||
{
|
||||
code: '"use client"; self.fetch(url);',
|
||||
filename: "/repo/src/app/client-page.tsx",
|
||||
errors: [{ messageId: "rawApiFetch" }],
|
||||
},
|
||||
{
|
||||
code: '"use client"; fetch(endpoint);',
|
||||
filename: "/repo/src/lib/i18n/context.tsx",
|
||||
errors: [{ messageId: "rawApiFetch" }],
|
||||
},
|
||||
{
|
||||
code: 'window["fetch"](endpoint);',
|
||||
filename: "/repo/src/components/example.tsx",
|
||||
errors: [{ messageId: "rawApiFetch" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, it } from "vitest";
|
||||
import { RuleTester } from "eslint";
|
||||
import plugin from "../queryKey-factory.js";
|
||||
|
||||
RuleTester.describe = describe;
|
||||
RuleTester.it = it;
|
||||
RuleTester.itOnly = it.only;
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
ecmaVersion: 2023,
|
||||
sourceType: "module",
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const rule = plugin.rules["queryKey-factory"];
|
||||
|
||||
ruleTester.run("queryKey-factory", rule, {
|
||||
valid: [
|
||||
{
|
||||
code: "useQuery({ queryKey: queryKeys.example(id) });",
|
||||
filename: "/repo/src/components/example.tsx",
|
||||
},
|
||||
{
|
||||
code: "const key = queryKeys.example(id); useQuery({ queryKey: key });",
|
||||
filename: "/repo/src/hooks/use-example.ts",
|
||||
},
|
||||
{
|
||||
code: 'export const example = () => ["example"];',
|
||||
filename: "/repo/src/lib/query-keys/example.ts",
|
||||
},
|
||||
{
|
||||
code: 'useQuery({ queryKey: ["fixture"] });',
|
||||
filename: "/repo/src/components/__tests__/example.test.tsx",
|
||||
},
|
||||
{
|
||||
code: 'useQuery({ queryKey: ["server-prefetch"] });',
|
||||
filename: "/repo/src/app/server-page.tsx",
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: 'useQuery({ queryKey: ["unguarded-component"] });',
|
||||
filename: "/repo/src/components/custom-metrics/example.tsx",
|
||||
errors: [{ messageId: "bareArray" }],
|
||||
},
|
||||
{
|
||||
code: 'useMutation({ mutationKey: ["unguarded-hook"] });',
|
||||
filename: "/repo/src/hooks/use-example.ts",
|
||||
errors: [{ messageId: "bareArray" }],
|
||||
},
|
||||
{
|
||||
code: '"use client"; useQuery({ queryKey: ["client-app"] });',
|
||||
filename: "/repo/src/app/previously-unguarded/page.tsx",
|
||||
errors: [{ messageId: "bareArray" }],
|
||||
},
|
||||
{
|
||||
code: '"use client"; useQuery({ queryKey: ["client-lib"] });',
|
||||
filename: "/repo/src/lib/client-state.tsx",
|
||||
errors: [{ messageId: "bareArray" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,35 +1,24 @@
|
||||
/**
|
||||
* @fileoverview ESLint rule — client-side `/api/...` calls must route
|
||||
* through the typed `apiFetch` wrapper.
|
||||
* @fileoverview ESLint rule — client-side requests must route through the
|
||||
* typed API-fetch entry points.
|
||||
*
|
||||
* `src/lib/api/api-fetch.ts` owns the `.ok` check, the
|
||||
* `{ data, error, meta? }` envelope unwrap, and the `ApiError` shape
|
||||
* (message via `readError`, `status`, error-side `meta`). A raw
|
||||
* `fetch("/api/…")` in a component re-implements that contract by hand
|
||||
* — the exact drift this wrapper retired — so this rule flags it at
|
||||
* authoring time and in CI.
|
||||
* A raw `fetch(...)` in a client module can hide a same-origin API request
|
||||
* behind a variable, endpoint map, or conditional. Checking only literal
|
||||
* `/api/...` arguments therefore leaves the envelope and error contract open
|
||||
* to bypass. This rule rejects every bare Fetch API call in the client surface,
|
||||
* regardless of argument shape.
|
||||
*
|
||||
* Complements `healthlog/safe-fetch-required`: that rule covers
|
||||
* OUTBOUND egress (absolute / variable-sourced URLs) and exempts
|
||||
* same-origin relative paths; this rule covers exactly those same-origin
|
||||
* `/api/...` paths inside the client surface.
|
||||
* Use `apiGet` / `apiPost` / `apiPut` / `apiPatch` / `apiDelete` for envelope
|
||||
* JSON. Use `apiFetchEnvelope` when success metadata is required, and
|
||||
* `apiFetchRaw` for deliberate Response-level work such as external requests,
|
||||
* downloads, streams, beacons, or manual status branching.
|
||||
*
|
||||
* Allowed:
|
||||
* - `apiFetch` / verb helpers / `apiFetchEnvelope` / `apiFetchRaw` —
|
||||
* distinct identifiers, never match.
|
||||
* - calls inside `src/lib/api/api-fetch.ts` — the wrapper's own `fetch`.
|
||||
* - test files (`*.test.ts(x)`, `__tests__/`, `__mocks__/`) — they
|
||||
* mock or assert against `fetch` directly.
|
||||
*
|
||||
* The check is a syntactic `CallExpression` match against a bare
|
||||
* `fetch(` callee (Identifier `fetch`) and `globalThis.fetch(` /
|
||||
* `window.fetch(` / `self.fetch(` member forms, where the first
|
||||
* argument is a string literal or template literal whose head starts
|
||||
* with `/api/`. Variable-sourced or absolute URLs are the
|
||||
* safe-fetch-required rule's territory and stay out of scope here.
|
||||
* Components and hooks are client-facing by convention. Other `src/` modules
|
||||
* are guarded when they declare top-level `"use client"`; `src/lib/queries/`
|
||||
* remains an explicit client-transitive root. Server-only modules, tests,
|
||||
* mocks, and the wrapper implementation are exempt.
|
||||
*
|
||||
* @see src/lib/api/api-fetch.ts
|
||||
* @see eslint-plugins/healthlog/safe-fetch-required.js
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
@@ -39,20 +28,9 @@
|
||||
// `String#includes`, mirroring the safe-fetch-required convention.
|
||||
const EXEMPT_FILES = ["src/lib/api/api-fetch.ts"];
|
||||
|
||||
// Enforce across the client-facing source roots. `src/lib/` stays out:
|
||||
// server-side lib code never self-calls `/api/` routes, and the
|
||||
// safe-fetch rule already covers its outbound calls. `src/hooks/` is
|
||||
// client-only by construction (v1.16.4) — its `/api/` reads route
|
||||
// through the wrapper like every component. `src/lib/queries/` is the
|
||||
// one `src/lib/` exception: it holds client-only TanStack-Query hooks
|
||||
// (every file is `"use client"`), so its `/api/` reads route through the
|
||||
// wrapper too.
|
||||
const ENFORCED_ROOTS = [
|
||||
"src/components/",
|
||||
"src/app/",
|
||||
"src/hooks/",
|
||||
"src/lib/queries/",
|
||||
];
|
||||
// Components and hooks are client-facing by convention. Query modules are
|
||||
// client-transitive even if a future refactor moves the directive to a barrel.
|
||||
const CLIENT_ROOTS = ["src/components/", "src/hooks/", "src/lib/queries/"];
|
||||
|
||||
function toPosix(filename) {
|
||||
return filename.replace(/\\/g, "/");
|
||||
@@ -67,12 +45,17 @@ function isTestFile(posix) {
|
||||
);
|
||||
}
|
||||
|
||||
function isEnforced(filename) {
|
||||
function isEnforced(filename, sourceCode) {
|
||||
const posix = toPosix(filename);
|
||||
if (!ENFORCED_ROOTS.some((root) => posix.includes(root))) return false;
|
||||
if (!posix.startsWith("src/") && !posix.includes("/src/")) return false;
|
||||
if (EXEMPT_FILES.some((f) => posix.includes(f))) return false;
|
||||
if (isTestFile(posix)) return false;
|
||||
return true;
|
||||
if (CLIENT_ROOTS.some((root) => posix.includes(root))) return true;
|
||||
return sourceCode.ast.body.some(
|
||||
(statement) =>
|
||||
statement.type === "ExpressionStatement" &&
|
||||
statement.directive === "use client",
|
||||
);
|
||||
}
|
||||
|
||||
/** @type {import("eslint").Rule.RuleModule} */
|
||||
@@ -81,49 +64,40 @@ const apiFetchRequiredRule = {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Same-origin /api/ calls must route through the apiFetch wrapper (src/lib/api/api-fetch.ts).",
|
||||
"Client-side requests must route through the apiFetch wrapper family (src/lib/api/api-fetch.ts).",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
rawApiFetch:
|
||||
'Raw fetch("/api/…") re-implements the envelope unwrap + error contract by hand. Import { apiFetch } (or a verb helper / apiFetchRaw) from "@/lib/api/api-fetch" and call it instead.',
|
||||
"Raw fetch() bypasses the client request contract. Use an apiFetch envelope helper, or apiFetchRaw for deliberate Response-level/external requests.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const filename = context.filename ?? context.getFilename?.();
|
||||
if (!filename || !isEnforced(filename)) {
|
||||
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
||||
if (!filename || !isEnforced(filename, sourceCode)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
function isFetchCallee(callee) {
|
||||
// Bare `fetch(...)`.
|
||||
if (callee.type === "Identifier" && callee.name === "fetch") return true;
|
||||
// `globalThis.fetch(...)` / `window.fetch(...)` / `self.fetch(...)`.
|
||||
// `globalThis.fetch(...)` / `window.fetch(...)` / `self.fetch(...)`,
|
||||
// including their static computed forms (`window["fetch"](...)`).
|
||||
if (
|
||||
callee.type === "MemberExpression" &&
|
||||
!callee.computed &&
|
||||
callee.property.type === "Identifier" &&
|
||||
callee.property.name === "fetch" &&
|
||||
callee.object.type === "Identifier" &&
|
||||
(callee.object.name === "globalThis" ||
|
||||
callee.object.name === "window" ||
|
||||
callee.object.name === "self")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isApiPath(arg) {
|
||||
if (!arg) return false;
|
||||
const isApiHead = (head) => head.startsWith("/api/");
|
||||
// `fetch("/api/…")`
|
||||
if (arg.type === "Literal" && typeof arg.value === "string") {
|
||||
return isApiHead(arg.value);
|
||||
}
|
||||
// `fetch(`/api/${id}`)` — inspect the template's first cooked chunk.
|
||||
if (arg.type === "TemplateLiteral" && arg.quasis.length > 0) {
|
||||
return isApiHead(arg.quasis[0].value.cooked ?? "");
|
||||
const propertyName =
|
||||
!callee.computed && callee.property.type === "Identifier"
|
||||
? callee.property.name
|
||||
: callee.computed && callee.property.type === "Literal"
|
||||
? callee.property.value
|
||||
: null;
|
||||
return propertyName === "fetch";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -131,7 +105,6 @@ const apiFetchRequiredRule = {
|
||||
return {
|
||||
CallExpression(node) {
|
||||
if (!isFetchCallee(node.callee)) return;
|
||||
if (!isApiPath(node.arguments[0])) return;
|
||||
context.report({ node, messageId: "rawApiFetch" });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,96 +1,57 @@
|
||||
/**
|
||||
* @fileoverview ESLint rule — `queryKey` / `mutationKey` literal-array
|
||||
* factory enforcement.
|
||||
* factory enforcement across the complete client tree.
|
||||
*
|
||||
* v1.4.41 W-PROCESS-DOCS — promoted from the test-guard substitute
|
||||
* (`src/lib/__tests__/query-keys.test.ts`, factory-bypass guard added
|
||||
* in v1.4.40 W-RSC) to a real ESLint rule. The test-guard substitute
|
||||
* stays in place as a belt-and-braces safety net for repo-walk
|
||||
* coverage of files the ESLint config might miss; this rule fires at
|
||||
* authoring time inside the IDE and at lint CI time.
|
||||
* Every TanStack key must come from `src/lib/query-keys/`. The rule flags
|
||||
* object-property `queryKey: [ … ]` and `mutationKey: [ … ]` values in
|
||||
* components, hooks, client query modules, and any source module declaring
|
||||
* top-level `"use client"`.
|
||||
*
|
||||
* The rule flags any object-property `queryKey: [ … ]` or
|
||||
* `mutationKey: [ … ]` whose value is a bare ArrayExpression — i.e.
|
||||
* the bypass shape that drove audit-H1 in `.planning/round-v1439-
|
||||
* arch-qa-frontend.md`. Acceptable shapes:
|
||||
* Accepted call-site shapes include direct factory calls, identifiers already
|
||||
* built from the factory, and conditionals selecting factory calls. Tests and
|
||||
* the factory definition directory are exempt because they intentionally
|
||||
* construct literal fixtures and tuples.
|
||||
*
|
||||
* - `queryKey: queryKeys.something(arg)` — factory call
|
||||
* - `queryKey: someLocal` — indirect identifier
|
||||
* - `queryKey: cond ? queryKeys.a() : queryKeys.b()` — conditional via factory
|
||||
*
|
||||
* Files outside `WHITELISTED_FILES` are exempt entirely — the
|
||||
* test-guard substitute holds the "guarded surface" line until a
|
||||
* future wave extends both the guard and this whitelist together.
|
||||
*
|
||||
* @see src/lib/query-keys/ — factory home (do not lint as offender)
|
||||
* @see src/lib/__tests__/query-keys.test.ts — test-guard substitute
|
||||
* @see .planning/phase-W-PROCESS-DOCS-v1441-report.md
|
||||
* @see src/lib/query-keys/
|
||||
* @see src/lib/__tests__/query-keys.test.ts
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
// Files that are ALLOWED to declare a literal-array queryKey. Mirrors
|
||||
// the test-guard substitute's `guardedRoots` allowlist inverted: every
|
||||
// file under those roots MUST use the factory, every file outside is
|
||||
// exempt for now. Path matches use `String#includes` semantics against
|
||||
// the absolute filename — keep entries as posix-style suffix paths.
|
||||
const FACTORY_HOME_FILES = [
|
||||
// The factory's own unit-test file constructs literal keys to assert
|
||||
// the factory output shape.
|
||||
"src/lib/__tests__/query-keys.test.ts",
|
||||
];
|
||||
|
||||
// The factory definition files are allowed to declare literal keys —
|
||||
// that's the whole point of the factory. The factory is split into
|
||||
// per-feature files under this directory (barrel at index.ts).
|
||||
const FACTORY_HOME_DIRECTORY = "src/lib/query-keys";
|
||||
|
||||
// Directories under which any `queryKey: [ … ]` literal is an error.
|
||||
// Extend in lockstep with the test-guard substitute's `guardedRoots`.
|
||||
const GUARDED_DIRECTORIES = [
|
||||
"src/components/charts",
|
||||
"src/components/comparison",
|
||||
// v1.4.41 W-FRONTEND-FACTORY — auth + notifications migrated to the
|
||||
// factory. Mirror the test-guard's guardedRoots so IDE/lint CI fail
|
||||
// at the same boundary the unit-test walker does.
|
||||
"src/app/auth",
|
||||
"src/app/notifications",
|
||||
// v1.4.42 W3-QUERYKEY-LONGTAIL — the settings / medications /
|
||||
// admin / hooks surface is now factory-only. Every long-tail
|
||||
// bare-literal flagged by audit-H1 routes through `queryKeys.<entry>()`.
|
||||
"src/components/settings",
|
||||
"src/components/medications",
|
||||
"src/components/admin",
|
||||
"src/hooks",
|
||||
];
|
||||
// Components and hooks are client-facing by convention. Query modules are
|
||||
// client-transitive even if a future refactor moves the directive to a barrel.
|
||||
const CLIENT_ROOTS = ["src/components/", "src/hooks/", "src/lib/queries/"];
|
||||
|
||||
// Individual guarded files outside the directories above.
|
||||
const GUARDED_FILES = [
|
||||
"src/app/page.tsx",
|
||||
"src/hooks/use-auth.ts",
|
||||
// v1.4.41 W-FRONTEND-FACTORY — about-section migrated.
|
||||
"src/components/settings/about-section.tsx",
|
||||
// v1.4.42 W3-QUERYKEY-LONGTAIL — medications + targets pages now
|
||||
// route every read through the factory.
|
||||
"src/app/medications/page.tsx",
|
||||
"src/app/medications/[id]/history/page.tsx",
|
||||
"src/app/targets/page.tsx",
|
||||
];
|
||||
function toPosix(filename) {
|
||||
return filename.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function isGuarded(filename) {
|
||||
// Factory home is never an offender even if it shows up under a
|
||||
// guarded directory by accident.
|
||||
for (const home of FACTORY_HOME_FILES) {
|
||||
if (filename.endsWith(home)) return false;
|
||||
function isTestFile(posix) {
|
||||
return (
|
||||
/\.(?:test|spec)\.[cm]?[jt]sx?$/.test(posix) ||
|
||||
posix.includes("/__tests__/") ||
|
||||
posix.includes("/__mocks__/")
|
||||
);
|
||||
}
|
||||
|
||||
function isGuarded(filename, sourceCode) {
|
||||
const posix = toPosix(filename);
|
||||
if (!posix.startsWith("src/") && !posix.includes("/src/")) return false;
|
||||
if (
|
||||
posix.startsWith(`${FACTORY_HOME_DIRECTORY}/`) ||
|
||||
posix.includes(`/${FACTORY_HOME_DIRECTORY}/`)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (filename.includes(`/${FACTORY_HOME_DIRECTORY}/`)) return false;
|
||||
for (const dir of GUARDED_DIRECTORIES) {
|
||||
if (filename.includes(`/${dir}/`)) return true;
|
||||
}
|
||||
for (const file of GUARDED_FILES) {
|
||||
if (filename.endsWith(file)) return true;
|
||||
}
|
||||
return false;
|
||||
if (isTestFile(posix)) return false;
|
||||
if (CLIENT_ROOTS.some((root) => posix.includes(root))) return true;
|
||||
return sourceCode.ast.body.some(
|
||||
(statement) =>
|
||||
statement.type === "ExpressionStatement" &&
|
||||
statement.directive === "use client",
|
||||
);
|
||||
}
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
@@ -99,7 +60,7 @@ const queryKeyFactoryRule = {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow literal-array `queryKey` / `mutationKey` declarations in guarded files. Use `queryKeys.<entry>()` from `src/lib/query-keys/` instead.",
|
||||
"Disallow literal-array `queryKey` / `mutationKey` declarations in client modules. Use `queryKeys.<entry>()` from `src/lib/query-keys/` instead.",
|
||||
recommended: false,
|
||||
},
|
||||
messages: {
|
||||
@@ -110,7 +71,8 @@ const queryKeyFactoryRule = {
|
||||
},
|
||||
create(context) {
|
||||
const filename = context.filename ?? context.getFilename?.();
|
||||
if (!filename || !isGuarded(filename)) {
|
||||
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
||||
if (!filename || !isGuarded(filename, sourceCode)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
+65
-15
@@ -13,22 +13,22 @@ const eslintConfig = defineConfig([
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
// v1.4.41 — ignore local tool worktrees so a worktree on an older
|
||||
// commit can't poison `pnpm lint` locally.
|
||||
// CI never sees this path; the rule is for the dev environment.
|
||||
// v1.31.4 — ignore local tool worktrees so a worktree on another
|
||||
// commit cannot poison `pnpm lint` locally.
|
||||
// CI never sees these paths; the rules protect the dev environment.
|
||||
".claude/**",
|
||||
".worktrees/**",
|
||||
// The project-local ESLint rule plugin is CommonJS (require/module
|
||||
// .exports) by ESLint convention; it is not application source and
|
||||
// is not linted by the app's TypeScript ruleset.
|
||||
"eslint-plugins/**",
|
||||
"coverage/**",
|
||||
"playwright-report/**",
|
||||
"test-results/**",
|
||||
]),
|
||||
// v1.4.41 — custom rule that flags any bare-array
|
||||
// `queryKey: [ … ]` / `mutationKey: [ … ]` declaration inside the
|
||||
// files the queryKeys factory currently guards. Promotes the earlier
|
||||
// v1.4.40 test-guard substitute to a real ESLint rule so IDE + CI
|
||||
// both fail fast on a factory bypass. See
|
||||
// `eslint-plugins/healthlog/queryKey-factory.js` for the
|
||||
// whitelist + rationale.
|
||||
// Factory enforcement for every client module: all components/hooks plus
|
||||
// `"use client"` app/lib modules. Tests and `src/lib/query-keys/**` own
|
||||
// literal fixtures/tuples and are exempt inside the rule.
|
||||
{
|
||||
plugins: { healthlog: healthlogPlugin },
|
||||
rules: {
|
||||
@@ -38,11 +38,11 @@ const eslintConfig = defineConfig([
|
||||
// requirePublicHost connect-time pin). The wrapper internals and
|
||||
// test files are exempt; see the rule file for the allowlist.
|
||||
"healthlog/safe-fetch-required": "error",
|
||||
// v1.16.4 — same-origin `/api/...` calls in the client surface
|
||||
// (src/components + src/app) must route through the typed apiFetch
|
||||
// wrapper (`src/lib/api/api-fetch.ts`): one `.ok` check, one
|
||||
// envelope unwrap, one ApiError shape. Wrapper internals and test
|
||||
// files are exempt; see the rule file for the allowlist.
|
||||
// Every raw fetch in the complete client surface is rejected regardless
|
||||
// of whether the endpoint is a literal, template, variable, or map
|
||||
// lookup. Envelope JSON uses the verb helpers; deliberate Response-level
|
||||
// work and non-API external calls use the explicit `apiFetchRaw` escape
|
||||
// hatch. Server-only modules, wrapper internals, and tests are exempt.
|
||||
"healthlog/api-fetch-required": "error",
|
||||
// v1.26.0 — the design-consistency wave locked the app's colour
|
||||
// vocabulary onto semantic tokens. Raw Tailwind palette utilities
|
||||
@@ -77,6 +77,56 @@ const eslintConfig = defineConfig([
|
||||
"healthlog/spacing-scale": "error",
|
||||
},
|
||||
},
|
||||
// v1.28.17 — every recharts-rendering component funnels through the
|
||||
// shared `chart-runtime.ts` barrel (see its file header): pointing N
|
||||
// `next/dynamic` boundaries at N different modules mints a separate
|
||||
// ~312 KB recharts chunk per boundary, a regression the project already
|
||||
// paid for once. Direct `from "recharts"` imports outside the barrel's
|
||||
// own static import graph previously only tripped the bundle-budget
|
||||
// gate at PR-CI; this rule catches the same mistake at local lint,
|
||||
// mirroring how `healthlog/safe-fetch-required` scopes its own
|
||||
// wrapper-internals exemption by file list.
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
ignores: [
|
||||
"src/components/charts/chart-runtime.ts",
|
||||
"src/components/admin/host-metrics-chart.tsx",
|
||||
"src/components/charts/health-chart.tsx",
|
||||
"src/components/charts/medication-compliance-chart.tsx",
|
||||
"src/components/charts/mood-chart.tsx",
|
||||
"src/components/charts/nutrient-daily-bar-chart.tsx",
|
||||
"src/components/charts/scatter-correlation-chart.tsx",
|
||||
"src/components/custom-metrics/custom-metric-chart.tsx",
|
||||
"src/components/cycle/bbt-chart.tsx",
|
||||
"src/components/insights/derived/delta-sparkline.tsx",
|
||||
"src/components/insights/intraday-pulse-chart.tsx",
|
||||
"src/components/insights/mood/mood-distribution-chart.tsx",
|
||||
"src/components/insights/mood/mood-time-of-day-chart.tsx",
|
||||
"src/components/insights/mood/mood-weekday-chart.tsx",
|
||||
"src/components/insights/sleep-stage-stacked-bar.tsx",
|
||||
"src/components/labs/lab-biomarker-chart.tsx",
|
||||
"src/components/medications/detail/efficacy/efficacy-chart.tsx",
|
||||
"src/components/medications/dose-strength-curve.tsx",
|
||||
"src/components/medications/drug-level-chart.tsx",
|
||||
"src/components/mental-health/assessment-history-chart.tsx",
|
||||
"src/components/charts/workout-hr-chart.tsx",
|
||||
"src/components/charts/workout-elevation-chart.tsx",
|
||||
],
|
||||
rules: {
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: "recharts",
|
||||
message:
|
||||
"Import chart components through @/components/charts/chart-runtime (the shared dynamic-import barrel), not recharts directly — a second static import mints a duplicate recharts chunk. Add the component to chart-runtime.ts's export list if it's a new chart.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -47,7 +47,6 @@
|
||||
".next/**",
|
||||
"**/__mocks__/**",
|
||||
"e2e/setup/test-helpers.ts",
|
||||
"src/components/charts/compliance-line-chart.tsx",
|
||||
"src/lib/logging/index.ts"
|
||||
],
|
||||
"ignoreDependencies": [
|
||||
|
||||
@@ -16,6 +16,24 @@
|
||||
# Per project policy (`--no-verify` is banned), prefer fixing the
|
||||
# finding over bypassing the hook.
|
||||
|
||||
# Commit-message guard: the project voice rule (CLAUDE.md, "Voice and
|
||||
# privacy") states that commits are authored by the maintainer's git
|
||||
# identity alone and carry no tooling co-author trailer or session link.
|
||||
# Editors and CLIs add those trailers by default, and a message that
|
||||
# picks one up is only removable from the object store by a history
|
||||
# rewrite — so the check belongs here, before the commit exists, not in
|
||||
# a review pass afterwards.
|
||||
commit-msg:
|
||||
commands:
|
||||
no-tooling-trailer:
|
||||
run: |
|
||||
if grep -qiE '^(Co-Authored-By:.*(claude|anthropic|copilot|gpt|gemini)|Claude-Session:|Generated with)' "{1}"; then
|
||||
echo "ERROR: the commit message carries a tooling co-author trailer or session link." >&2
|
||||
echo "Project policy: commits are authored by the maintainer's git identity alone." >&2
|
||||
echo "Remove the offending line(s) from the message and commit again." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pre-commit:
|
||||
commands:
|
||||
gitleaks:
|
||||
|
||||
+384
-75
@@ -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",
|
||||
@@ -42,7 +43,70 @@
|
||||
"hidePassword": "Passwort verbergen",
|
||||
"learnMore": "Mehr erfahren",
|
||||
"dismiss": "Schließen",
|
||||
"moreInfo": "Mehr Informationen"
|
||||
"moreInfo": "Mehr Informationen",
|
||||
"durationHoursMinutes": "{hours} Std. {minutes} Min.",
|
||||
"durationMinutes": "{minutes} Min."
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
"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." },
|
||||
"create": {
|
||||
"invalid": "Prüfe den Stimmungseintrag und versuche es erneut."
|
||||
},
|
||||
"update": {
|
||||
"invalid": "Prüfe den Stimmungseintrag und versuche es erneut."
|
||||
},
|
||||
"duplicate_timestamp": "Für diesen Zeitpunkt ist bereits ein Stimmungseintrag vorhanden.",
|
||||
"not_found": "Dieser Stimmungseintrag ist nicht mehr verfügbar.",
|
||||
"ratedFactor": {
|
||||
"out_of_range": "Eine Faktorbewertung liegt außerhalb des zulässigen Bereichs."
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"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.",
|
||||
"create": {
|
||||
"invalid": "Prüfe die Biomarker-Angaben und versuche es erneut."
|
||||
},
|
||||
"update": {
|
||||
"invalid": "Prüfe die Biomarker-Angaben und versuche es erneut.",
|
||||
"referenceRangeInvalid": "Der untere Referenzwert darf den oberen Wert nicht überschreiten."
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"invalid": "Prüfe den Laborwert und versuche es erneut.",
|
||||
"linkedFieldsImmutable": "Bearbeite den Biomarker, um Name, Einheit, Panel oder Referenzbereich zu ändern.",
|
||||
"qualitativeExpected": "Gib für diesen qualitativen Laborwert ein Textergebnis ein.",
|
||||
"numericExpected": "Gib für diesen numerischen Laborwert eine Zahl ein.",
|
||||
"referenceRangeInvalid": "Der untere Referenzwert darf den oberen Wert nicht überschreiten."
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"disclaimer": {
|
||||
"invalid": "Bestätige den medizinischen Hinweis bitte erneut."
|
||||
},
|
||||
"tour": {
|
||||
"invalid": "Der Tour-Fortschritt konnte nicht gespeichert werden."
|
||||
},
|
||||
"complete": {
|
||||
"invalid": "Prüfe die Profildaten und versuche es erneut."
|
||||
},
|
||||
"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.",
|
||||
"completed": "Das Onboarding ist bereits abgeschlossen.",
|
||||
"outOfOrder": "Dein Onboarding-Fortschritt hat sich geändert. Lade die Seite neu und fahre fort.",
|
||||
"concurrent": "Dein Onboarding-Fortschritt hat sich geändert. Lade die Seite neu und fahre fort."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Gesundheitsbericht",
|
||||
@@ -91,7 +155,7 @@
|
||||
"colShare": "Anteil",
|
||||
"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: Europe/Berlin | Erstellt: {timestamp}",
|
||||
"footerSource": "Quelle: HealthLog | Zeitzone: {timezone} | Erstellt: {timestamp}",
|
||||
"typeWeight": "Körpergewicht",
|
||||
"typeBpSys": "Systolischer Blutdruck",
|
||||
"typeBpDia": "Diastolischer Blutdruck",
|
||||
@@ -141,7 +205,7 @@
|
||||
"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 deinen erfassten Signalen berechnet werden (0–100). Sie sind keine klinischen Beurteilungen oder Diagnosen.",
|
||||
"wellnessDisclaimer": "Dies sind beschreibende Wellness-Werte, die aus den erfassten Signalen berechnet werden (0–100). Sie sind keine klinischen Beurteilungen oder Diagnosen.",
|
||||
"wellnessRecovery": "Erholung",
|
||||
"wellnessStress": "Stress",
|
||||
"wellnessStrain": "Belastung",
|
||||
@@ -228,7 +292,7 @@
|
||||
"logout": "Abmelden",
|
||||
"login": "Anmelden",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"theme": "Darstellung",
|
||||
"theme": "Design",
|
||||
"themeSystem": "System",
|
||||
"themeDark": "Dunkel",
|
||||
"themeLight": "Hell",
|
||||
@@ -253,7 +317,9 @@
|
||||
"medication": "Medikament",
|
||||
"medicationDescription": "Eine Dosis als genommen markieren",
|
||||
"mood": "Stimmung",
|
||||
"moodDescription": "Stimmung und Tags erfassen"
|
||||
"moodDescription": "Stimmung und Tags erfassen",
|
||||
"water": "Wasser",
|
||||
"waterDescription": "Wasseraufnahme schnell hinzufügen"
|
||||
},
|
||||
"cycle": "Zyklus",
|
||||
"labs": "Laborwerte",
|
||||
@@ -295,6 +361,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.",
|
||||
@@ -356,6 +425,7 @@
|
||||
"quickAddMeasurement": "Messung erfassen",
|
||||
"quickAddMood": "Stimmung erfassen",
|
||||
"quickAddMedicationIntake": "Einnahme erfassen",
|
||||
"quickAddWater": "Wasser erfassen",
|
||||
"customizeDashboard": "Dashboard anpassen",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Medikamenteneinnahme erfassen",
|
||||
@@ -385,6 +455,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",
|
||||
@@ -400,12 +471,13 @@
|
||||
"staleHint": "Letzter Wert vor {count} T.",
|
||||
"staleHintWeeksOne": "Letzter Wert vor 1 Woche",
|
||||
"staleHintWeeksOther": "Letzter Wert vor {count} Wochen",
|
||||
"staleHintWeeksFew": "Letzter Wert vor {count} Wochen",
|
||||
"staleHintMonthsOne": "Letzter Wert vor 1 Monat",
|
||||
"staleHintMonthsOther": "Letzter Wert vor {count} Monaten",
|
||||
"staleHintMonthsFew": "Letzter Wert vor {count} Monaten",
|
||||
"recentWorkouts": {
|
||||
"title": "Letzte Workouts",
|
||||
"viewAll": "Alle anzeigen",
|
||||
"loading": "Workouts werden geladen…",
|
||||
"empty": {
|
||||
"title": "Noch keine Workouts",
|
||||
"cta": "Öffne die Apple-Health-App auf dem iPhone, damit Workouts hier erscheinen."
|
||||
@@ -422,7 +494,9 @@
|
||||
"steps": "Schritte",
|
||||
"totalBodyWater": "Gesamtkörperwasser",
|
||||
"boneMass": "Knochenmasse",
|
||||
"oxygenSaturation": "Sauerstoffsättigung"
|
||||
"oxygenSaturation": "Sauerstoffsättigung",
|
||||
"mood": "Stimmung",
|
||||
"bmi": "BMI"
|
||||
},
|
||||
"unit": {
|
||||
"weight": "kg",
|
||||
@@ -434,7 +508,9 @@
|
||||
"steps": "Schritte",
|
||||
"totalBodyWater": "kg",
|
||||
"boneMass": "kg",
|
||||
"oxygenSaturation": "%"
|
||||
"oxygenSaturation": "%",
|
||||
"mood": "/5",
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -575,7 +651,7 @@
|
||||
"bulkDeleteSuccess": "{count} Messungen gelöscht",
|
||||
"bulkDeleteError": "Fehler beim Löschen der ausgewählten Messungen",
|
||||
"bulkDeleteConfirmTitle": "{count} Messungen löschen?",
|
||||
"bulkDeleteConfirmBody": "Die {count} ausgewählten Messungen werden endgültig gelöscht.",
|
||||
"bulkDeleteConfirmBody": "Die {count} ausgewählten Messungen werden gelöscht. Direkt danach kannst du das kurz rückgängig machen.",
|
||||
"typePhq9Score": "PHQ-9-Wert",
|
||||
"typeGad7Score": "GAD-7-Wert",
|
||||
"typeGripStrength": "Griffstärke",
|
||||
@@ -587,9 +663,27 @@
|
||||
"typeWho5Score": "WHO-5-Wert",
|
||||
"typeSciScore": "SCI-Wert"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Was HealthLog erfassen kann",
|
||||
"description": "Alle Messwert-Bereiche, die HealthLog unterstützt — auch die, für die noch keine Daten vorliegen — und was sie liefert.",
|
||||
"statusPresent": "Erfasst",
|
||||
"statusAbsent": "Noch nicht erfasst",
|
||||
"statusModuleOff": "Aus",
|
||||
"loadError": "Erfassungsstatus konnte nicht geladen werden.",
|
||||
"connectCta": "Quelle verbinden",
|
||||
"viewCta": "Ansehen",
|
||||
"ecg": {
|
||||
"source": "Einkanal-EKG-Streifen von einer kompatiblen Uhr — aktuell Withings ScanWatch — werden automatisch synchronisiert."
|
||||
},
|
||||
"watchdog": {
|
||||
"title": "Automatische Überwachung",
|
||||
"description": "HealthLog beobachtet außerdem still im Hintergrund — Benachrichtigungen zu unregelmäßigem Rhythmus und Atemstörungen von einer verbundenen Uhr, Warnungen bei Abweichungen von deiner Baseline sowie auffällige Veränderungen zwischen deinen letzten beiden Laborwerten — und zeigt eine Karte nur, wenn etwas auffällt. Ist mindestens eine dieser Quellen verbunden, ist keine Karte ein gutes Zeichen, kein fehlendes Feature; ist noch keine verbunden, wird schlicht noch nichts überwacht."
|
||||
}
|
||||
},
|
||||
"mood": {
|
||||
"title": "Stimmung",
|
||||
"subtitle": "Stimmungsverlauf und Einträge",
|
||||
"mentalWellbeingLink": "Check-in zum seelischen Wohlbefinden machen",
|
||||
"addEntry": "Hinzufügen",
|
||||
"editEntry": "Eintrag bearbeiten",
|
||||
"moodLevel": "Stimmung",
|
||||
@@ -705,7 +799,7 @@
|
||||
"bulkDeleteSuccess": "{count} Einträge gelöscht",
|
||||
"bulkDeleteError": "Fehler beim Löschen der ausgewählten Einträge",
|
||||
"bulkDeleteConfirmTitle": "{count} Einträge löschen?",
|
||||
"bulkDeleteConfirmBody": "Die {count} ausgewählten Stimmungseinträge werden endgültig gelöscht.",
|
||||
"bulkDeleteConfirmBody": "Die {count} ausgewählten Stimmungseinträge werden gelöscht. Direkt danach kannst du das kurz rückgängig machen.",
|
||||
"customize": "Stimmungs-Tags anpassen",
|
||||
"addTagInline": "Tag hinzufügen",
|
||||
"manage": {
|
||||
@@ -1403,7 +1497,10 @@
|
||||
"confirmAction": "Löschen",
|
||||
"toast": "Einnahme gelöscht",
|
||||
"failed": "Einnahme konnte nicht gelöscht werden."
|
||||
}
|
||||
},
|
||||
"viewRecent90": "Letzte 90 Tage",
|
||||
"viewAll": "Komplette Historie",
|
||||
"viewToggleLabel": "Verlaufsbereich"
|
||||
},
|
||||
"verlauf": {
|
||||
"loading": "Verlauf wird geladen…",
|
||||
@@ -1659,7 +1756,10 @@
|
||||
"packagingDefaultPackLabel": "Standard-Packungsgröße (Einheiten)",
|
||||
"packagingDefaultPackHelper": "Vorbefüllung beim Erfassen neuer Packungen (1–1000). Leer lassen, wenn keine üblich ist.",
|
||||
"packagingSuccess": "Packungsangaben gespeichert",
|
||||
"packagingFailed": "Speichern fehlgeschlagen"
|
||||
"packagingFailed": "Speichern fehlgeschlagen",
|
||||
"addManufacturerLabel": "Hersteller",
|
||||
"addDoseStrengthLabel": "Wirkstärke",
|
||||
"addDoseStrengthHint": "Wie auf dem Behälter aufgedruckt, z. B. 5 mg/0,5 ml."
|
||||
},
|
||||
"erweitert": {
|
||||
"group": {
|
||||
@@ -1785,13 +1885,14 @@
|
||||
"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?",
|
||||
"placeholder": "Verfolgen anhand…",
|
||||
"apply": "Übernehmen",
|
||||
"reset": "Auf Standard zurücksetzen"
|
||||
"reset": "Auf Standard zurücksetzen",
|
||||
"error": "Diese Änderung konnte nicht gespeichert werden. Bitte erneut versuchen."
|
||||
},
|
||||
"beforeAfter": {
|
||||
"summary": "Ø {before} davor → Ø {after} seitdem – eine Veränderung von {delta}.",
|
||||
@@ -1806,7 +1907,10 @@
|
||||
"subtitle": "Was mit den Zielwerten deiner Medikamente zeitlich zusammenfällt – nur ein Zusammenhang, kein Urteil.",
|
||||
"link": "Wirkung öffnen",
|
||||
"empty": "Noch nicht genug Daten, um deine Medikamente mit ihren Zielwerten in Beziehung zu setzen."
|
||||
}
|
||||
},
|
||||
"scaleAbsolute": "Absolut",
|
||||
"scalePercentSinceStart": "% seit Start",
|
||||
"scaleToggleLabel": "Diagrammskala"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -1955,6 +2059,61 @@
|
||||
"notAvailableForCompliance": "Vergleich für Therapietreue nicht verfügbar"
|
||||
},
|
||||
"insights": {
|
||||
"correlation": {
|
||||
"direction": {
|
||||
"higher": "höher",
|
||||
"lower": "niedriger"
|
||||
},
|
||||
"weekdayDirection": {
|
||||
"above": "über",
|
||||
"below": "unter"
|
||||
},
|
||||
"channelFactor": "die Bewertung von {factor}",
|
||||
"channel": {
|
||||
"WEIGHT": "das Gewicht",
|
||||
"BLOOD_GLUCOSE": "der Blutzucker",
|
||||
"BLOOD_PRESSURE_SYS": "der systolische Blutdruck",
|
||||
"BLOOD_PRESSURE_DIA": "der diastolische Blutdruck",
|
||||
"ACTIVITY_STEPS": "die tägliche Schrittzahl",
|
||||
"SLEEP_DURATION": "die Schlafdauer",
|
||||
"HEART_RATE_VARIABILITY": "die Herzratenvariabilität",
|
||||
"RESTING_HEART_RATE": "die Ruheherzfrequenz",
|
||||
"TIME_IN_DAYLIGHT": "die Zeit im Tageslicht",
|
||||
"MOOD": "die Stimmung",
|
||||
"PULSE": "der Ruhepuls",
|
||||
"MEDICATION_COMPLIANCE": "die Medikamententreue",
|
||||
"SYMPTOM_SEVERITY": "die Symptomstärke",
|
||||
"ENV_TEMP_MEAN": "die Tagestemperatur",
|
||||
"ENV_TEMP_MIN": "die nächtliche Tiefsttemperatur",
|
||||
"ENV_SUNSHINE": "der Sonnenschein",
|
||||
"ENV_DAYLIGHT": "das Tageslicht",
|
||||
"ENV_PRECIP": "der Niederschlag",
|
||||
"ENV_PRESSURE_MEAN": "der Luftdruck",
|
||||
"ENV_PRESSURE_DELTA": "die Luftdruckschwankung am Tag"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Wenn {behaviour} höher liegt, fällt {outcome} am Folgetag in deinen Daten tendenziell {direction} aus — ein Muster, das Beachtung verdient, keine Ursache.",
|
||||
"moderate": "Wenn {behaviour} höher liegt, scheint {outcome} am Folgetag nach bisheriger Datenlage eher {direction} auszufallen — ein Muster, das Beachtung verdient, keine Ursache.",
|
||||
"faint": "Wenn {behaviour} höher liegt, gibt es in deinen Daten allenfalls einen schwachen Hinweis darauf, dass {outcome} am Folgetag {direction} ausfällt — zu gering, um sich darauf zu verlassen, niemals eine Ursache."
|
||||
},
|
||||
"lab": {
|
||||
"default": "Höhere {lab}-Werte gehen in deinen Daten damit einher, dass {outcome} im selben Zeitraum {direction} ausfällt — ein Zusammenhang, den du ärztlich im Blick behalten solltest, niemals eine Ursache.",
|
||||
"faint": "Höhere {lab}-Werte deuten in deinen Daten allenfalls schwach darauf hin, dass {outcome} im selben Zeitraum {direction} ausfällt — zu gering, um sich darauf zu verlassen, ein Zusammenhang, den du ärztlich abklären lassen solltest, niemals eine Ursache."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "Höhere Medikamententreue geht mit niedrigeren systolischen Werten einher — ein Muster, das Beachtung verdient.",
|
||||
"surprising": "Medikamententreue und systolischer Wert bewegen sich in deinen Daten gemeinsam — überraschend; sprich mit deiner Ärztin oder deinem Arzt, bevor du etwas änderst.",
|
||||
"none": "Medikamententreue und systolischer Wert bewegen sich in diesem Zeitraum nicht deutlich gemeinsam."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "Tage mit schlechterer Stimmung gehen mit höherem Ruhepuls einher — ein Muster, das Beachtung verdient.",
|
||||
"surprising": "Tage mit besserer Stimmung gehen in deinen Daten mit höherem Ruhepuls einher — überraschend.",
|
||||
"none": "Stimmung und Ruhepuls bewegen sich in diesem Zeitraum nicht deutlich gemeinsam."
|
||||
},
|
||||
"weightWeekday": "Das Gewicht am {weekday} liegt {delta} kg {direction} deinem Durchschnitt der übrigen Tage — ein Muster, das Beachtung verdient."
|
||||
}
|
||||
},
|
||||
"emptyTitle": "Noch nicht genug Daten",
|
||||
"emptyDescription": "Insights erscheinen, sobald du ein paar Messwerte erfasst hast. Beginne mit Gewicht oder Blutdruck.",
|
||||
"emptyAddMeasurement": "Messwert erfassen",
|
||||
@@ -2193,6 +2352,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",
|
||||
@@ -2240,7 +2400,16 @@
|
||||
"historySearchPlaceholder": "Unterhaltungen durchsuchen…",
|
||||
"historyEmpty": "Deine Unterhaltungen erscheinen hier. Stell eine Frage, um zu starten.",
|
||||
"historyDeleteAria": "Unterhaltung löschen",
|
||||
"historyDeleteConfirm": "Diese Unterhaltung löschen? Das lässt sich nicht rückgängig machen.",
|
||||
"historyDeleted": "Unterhaltung gelöscht",
|
||||
"rename": {
|
||||
"action": "Unterhaltung umbenennen",
|
||||
"input": "Titel der Unterhaltung",
|
||||
"save": "Titel speichern",
|
||||
"cancel": "Umbenennen abbrechen",
|
||||
"error": "Der Titel der Unterhaltung konnte nicht gespeichert werden."
|
||||
},
|
||||
"historySearchEmpty": "Keine Unterhaltungen entsprechen deiner Suche.",
|
||||
"historyLoadingMore": "Mehr wird geladen…",
|
||||
"sourcesTitle": "Worauf ich zugreife",
|
||||
"sourcesFresh": "Aktuell",
|
||||
"sourcesStale": "Veraltet",
|
||||
@@ -2377,10 +2546,6 @@
|
||||
"settingsClustersHint": "Wähle, welche Datencluster der Coach lesen darf. Mehr Daten bedeuten reichhaltigere Antworten und einen größeren Prompt.",
|
||||
"settingsClusterNoData": "Noch keine Daten",
|
||||
"sourcesMemberCount": "{count} Werte",
|
||||
"dictate": "Diktieren",
|
||||
"dictateStop": "Diktat beenden",
|
||||
"voiceUnsupported": "Spracheingabe wird in diesem Browser nicht unterstützt",
|
||||
"dictateError": "Spracheingabe konnte nicht gestartet werden. Prüfe die Mikrofonberechtigung und versuche es erneut.",
|
||||
"showConversations": "Unterhaltungen anzeigen",
|
||||
"hideConversations": "Unterhaltungen ausblenden",
|
||||
"heroGreeting": "Frage mich etwas zu deinen Daten",
|
||||
@@ -2470,17 +2635,22 @@
|
||||
"documentsAttachedOne": "1 Dokument angehängt",
|
||||
"documentsAttachedOther": "{count} Dokumente angehängt",
|
||||
"railBadge": "Dokument-Chat (abgeschottet)",
|
||||
"uploadError": "Upload fehlgeschlagen. Bitte versuche es erneut."
|
||||
"uploadError": "Upload fehlgeschlagen. Bitte versuche es erneut.",
|
||||
"notFoundError": "Dieser Dokumentlink ist nicht mehr gültig – es wird ein normaler Chat gestartet."
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "gerade eben",
|
||||
"relativeMinutesAgoOne": "vor {count} Minute",
|
||||
"relativeMinutesAgoOther": "vor {count} Minuten",
|
||||
"relativeMinutesAgoFew": "vor {count} Minuten",
|
||||
"relativeHoursAgoOne": "vor {count} Stunde",
|
||||
"relativeHoursAgoOther": "vor {count} Stunden",
|
||||
"relativeHoursAgoFew": "vor {count} Stunden",
|
||||
"relativeDaysAgoOne": "vor {count} Tag",
|
||||
"relativeDaysAgoOther": "vor {count} Tagen",
|
||||
"relativeDaysAgoFew": "vor {count} Tagen",
|
||||
"navOverview": "Übersicht",
|
||||
"navCatalog": "Alle Messwerte",
|
||||
"navBloodPressure": "Blutdruck",
|
||||
"navWeight": "Gewicht",
|
||||
"navPulse": "Puls",
|
||||
@@ -2511,9 +2681,9 @@
|
||||
"label": "Aktivität",
|
||||
"header": "Aktivität & Mobilität"
|
||||
},
|
||||
"cardiovascularParent": {
|
||||
"label": "Herz-Kreislauf",
|
||||
"header": "Herz-Kreislauf-Marker"
|
||||
"heartParent": {
|
||||
"label": "Herz",
|
||||
"header": "Herzgesundheit"
|
||||
},
|
||||
"hearingParent": {
|
||||
"label": "Gehör",
|
||||
@@ -2638,11 +2808,11 @@
|
||||
"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.",
|
||||
"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, daher ist niedriger meist besser.",
|
||||
"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",
|
||||
@@ -2672,7 +2842,7 @@
|
||||
"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, und Zeit im Freien ist mit der Augengesundheit verbunden.",
|
||||
"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",
|
||||
@@ -2684,7 +2854,7 @@
|
||||
"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.",
|
||||
"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",
|
||||
@@ -2732,7 +2902,8 @@
|
||||
},
|
||||
"valuesBack": "Zurück zu Insights",
|
||||
"valuesBackToMetric": "Zurück zu {metric}",
|
||||
"scoresBack": "Zurück zu Insights"
|
||||
"scoresBack": "Zurück zu Insights",
|
||||
"ecgDescription": "Einkanal-EKG-Aufzeichnungen deines Geräts, jeweils mit dem Ergebnis des Geräts. HealthLog zeigt die Kurve und die Aufzeichnung – die Kurve wird nicht interpretiert."
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Schlaf",
|
||||
@@ -2771,9 +2942,9 @@
|
||||
"debt": {
|
||||
"title": "Schlafschuld",
|
||||
"value": "{hours} Std. {minutes} Min. zu wenig",
|
||||
"caption": "Kumuliertes Defizit der letzten {nights} Nächte",
|
||||
"caption": "Offene Bilanz der letzten {nights} Nächte",
|
||||
"clearValue": "Ausgeglichen",
|
||||
"clearCaption": "Kein aufgelaufenes Defizit über die letzten {nights} Nächte",
|
||||
"clearCaption": "Nichts offen über die letzten {nights} Nächte",
|
||||
"learning": "Dein Schlaf wird noch erfasst — {counted} Nächte bisher, noch {remaining} für eine verlässliche Bilanz.",
|
||||
"computedInfo": "Das ist HealthLogs eigene Schätzung: eine gleitende Bilanz aus Bedarf minus Schlaf über die letzten Nächte, die Aufholschlaf wieder abbaut. Sie kann vom nativen Schlafdefizit eines Wearables abweichen, das ein eigenes Bedarfsmodell nutzt."
|
||||
},
|
||||
@@ -2870,13 +3041,15 @@
|
||||
"sleep": {
|
||||
"title": "Noch keine Schlafwerte",
|
||||
"description": "Sobald Schlafdaten vorliegen (manuell oder via Withings / Apple Health), erscheint hier der nächtliche Stadien-Verlauf.",
|
||||
"cta": "Withings verbinden"
|
||||
"cta": "Quelle verbinden"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "Workouts",
|
||||
"navRecovery": "Erholung",
|
||||
"workouts": {
|
||||
"title": "Workouts",
|
||||
"askWhy": "Warum?",
|
||||
"askWhyPrompt": "Warum ist diese Einheit so ausgefallen? Erkläre mir meine eigenen Werte dazu.",
|
||||
"description": "Letzte Läufe, Touren, Spaziergänge und Krafteinheiten — quellenübergreifend entdoppelt zwischen Apple Watch und Withings.",
|
||||
"emptyState": {
|
||||
"title": "Noch keine Workouts",
|
||||
@@ -2905,7 +3078,24 @@
|
||||
"hrChartTitle": "Herzfrequenz",
|
||||
"hrChartUnavailable": "Sekundengenaue Herzfrequenz-Werte werden für dieses Workout nicht gespeichert.",
|
||||
"notFound": "Workout nicht gefunden.",
|
||||
"loading": "Workout wird geladen…"
|
||||
"loading": "Workout wird geladen…",
|
||||
"hrFromPulse": "Aus deinen Herzfrequenzdaten rund um diese Einheit",
|
||||
"hrAxisMinutes": "Min.",
|
||||
"sportAverageLabel": "Dein aktueller Durchschnitt:",
|
||||
"zonesTitle": "Belastungszonen",
|
||||
"zonesCaption": "Wie sich deine Belastung auf die Herzfrequenzzonen verteilt hat.",
|
||||
"zonesCaptionDevice": "Zeit pro Zone laut deinem Gerät.",
|
||||
"exportGpx": "GPX exportieren",
|
||||
"elevationTitle": "Höhe",
|
||||
"splitsTitle": "Kilometerzeiten",
|
||||
"splitsKm": "km",
|
||||
"splitsTime": "Zeit",
|
||||
"splitsPace": "Tempo",
|
||||
"thatDayTitle": "An diesem Tag",
|
||||
"dayPulse": "Puls",
|
||||
"daySleep": "Schlaf",
|
||||
"dayMood": "Stimmung",
|
||||
"insightTitle": "Aktivitäts-Einblick"
|
||||
},
|
||||
"sport": {
|
||||
"walking": "Gehen",
|
||||
@@ -2928,7 +3118,11 @@
|
||||
"crossTraining": "Cross-Training",
|
||||
"mixedCardio": "Mixed Cardio",
|
||||
"other": "Sonstiges"
|
||||
}
|
||||
},
|
||||
"loadError": "Deine Workouts konnten nicht geladen werden",
|
||||
"loadMore": "Weitere Workouts laden",
|
||||
"loadingMore": "Weitere Workouts werden geladen",
|
||||
"loadMoreError": "Weitere Workouts konnten nicht geladen werden"
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Blutzucker",
|
||||
@@ -3238,6 +3432,8 @@
|
||||
},
|
||||
"navWalkingSpeed": "Gehgeschwindigkeit",
|
||||
"mood": {
|
||||
"logLink": "Stimmung eintragen",
|
||||
"mentalWellbeingLink": "Check-in zum seelischen Wohlbefinden machen",
|
||||
"heatmapTitle": "Stimmungskalender",
|
||||
"heatmapNoEntry": "Kein Eintrag",
|
||||
"legendGreat": "Super",
|
||||
@@ -3525,7 +3721,9 @@
|
||||
"sectionTitle": "Deine Gesundheitswerte",
|
||||
"sectionSubtitle": "Zusammengesetzte Werte für Erholung, Stress und Belastung aus deinen verbundenen Geräten",
|
||||
"loadError": "Deine Wellness-Werte konnten gerade nicht geladen werden.",
|
||||
"recovery": "Erholung",
|
||||
"loadingLabel": "Deine Gesundheitswerte werden geladen",
|
||||
"recovery": "Erholungs-Score",
|
||||
"recoveryCrossLink": "Erholungs- und Belastungsdaten deines Wearables ansehen",
|
||||
"stress": "Stress",
|
||||
"strain": "Belastung"
|
||||
},
|
||||
@@ -3696,6 +3894,7 @@
|
||||
"recovery": {
|
||||
"title": "Erholung",
|
||||
"description": "Wie gut sich dein Körper erholt hat – die Erholungs-, Belastungs- und Load-Signale, die dein Wearable über Nacht aufzeichnet.",
|
||||
"scoreCrossLink": "Den zusammengesetzten Erholungs-Score ansehen",
|
||||
"emptyTitle": "Noch keine Erholungsdaten",
|
||||
"empty": "Noch keine Erholungsdaten. Verbinde ein WHOOP-, Polar- oder Oura-Konto, dann füllen sich diese Werte, sobald dein Gerät synchronisiert.",
|
||||
"block": {
|
||||
@@ -3866,13 +4065,18 @@
|
||||
"recordingsMany": "{count} Aufzeichnungen vorhanden.",
|
||||
"latestResult": "Letztes Geräte-Ergebnis: {result}.",
|
||||
"cta": "Aufzeichnungen ansehen"
|
||||
}
|
||||
},
|
||||
"emptyTitle": "Noch keine EKG-Aufzeichnungen",
|
||||
"emptyDescription": "EKG-Aufzeichnungen werden von einem kompatiblen Einkanal-Gerät synchronisiert. Sobald dein Gerät eine Aufzeichnung überträgt, erscheint sie hier.",
|
||||
"emptyCta": "Gerät verbinden"
|
||||
},
|
||||
"intradayPulse": {
|
||||
"title": "Der Verlauf deines Tages",
|
||||
"caption": "Dein Puls über den Tag, gemittelt in 10-Minuten-Schritten.",
|
||||
"hourlyNote": "Stündlicher Durchschnitt für diesen Tag — aktuelle Tage zeigen das volle 10-Minuten-Detail.",
|
||||
"empty": "Für diesen Tag noch nicht genug Pulswerte, um den Tagesverlauf zu zeigen.",
|
||||
"coverageOne": "Basierend auf 1 Messung über {hours}h.",
|
||||
"coverageMany": "Basierend auf {count} Messungen über {hours}h.",
|
||||
"baseline": "Ruhe",
|
||||
"today": "Heute",
|
||||
"previousDay": "Vorheriger Tag",
|
||||
@@ -3883,7 +4087,8 @@
|
||||
"evening": "Eine anhaltend erhöhte Ruhephase heute Abend — möglicherweise etwas Anspannung.",
|
||||
"night": "Eine anhaltend erhöhte Ruhephase in der Nacht — möglicherweise etwas Anspannung."
|
||||
}
|
||||
}
|
||||
},
|
||||
"navEcg": "EKG"
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Default",
|
||||
@@ -3982,7 +4187,7 @@
|
||||
},
|
||||
"wrapUp": {
|
||||
"title": "Das war die Tour",
|
||||
"body": "Du kannst jedes Modul jederzeit einzeln erneut ansehen („Diese Tour zeigen“ auf jeder Seite) oder die komplette Tour unter Einstellungen → Erweitert neu starten."
|
||||
"body": "Diese Führung läuft einmalig – sie öffnet sich nicht von selbst erneut. Alles Gezeigte bleibt erhalten, und du kannst Funktionen jederzeit unter Einstellungen → Module ein- oder ausschalten."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4010,7 +4215,7 @@
|
||||
},
|
||||
"source": {
|
||||
"title": "Quelle verbinden",
|
||||
"body": "Werte automatisch übernehmen oder weiter manuell eintragen. Hier wird nichts für dich eingerichtet – jede Karte zeigt nur, wo du sie verbindest.",
|
||||
"body": "Werte automatisch übernehmen oder weiter manuell eintragen. Hier wird nichts für dich eingerichtet – jede Karte zeigt nur, wo du sie verbindest. Geräte-Quellen brauchen eine kostenlose Entwickler-App auf deinem eigenen Konto (etwa 5 Minuten); die Anleitung auf jeder Karte führt dich durch.",
|
||||
"recommended": "Empfohlen",
|
||||
"manual": {
|
||||
"title": "Manuelle Eingabe",
|
||||
@@ -4018,12 +4223,14 @@
|
||||
},
|
||||
"withings": {
|
||||
"title": "Withings",
|
||||
"body": "Waagen, Blutdruckmesser und Tracker von Withings synchronisieren automatisch.",
|
||||
"cta": "Withings verbinden"
|
||||
"body": "Waagen, Blutdruckmesser und Tracker von Withings synchronisieren automatisch, sobald verbunden.",
|
||||
"cta": "Withings verbinden",
|
||||
"setupCta": "In den Einstellungen einrichten",
|
||||
"setupHint": "Braucht zuerst deine eigene, kostenlose Withings-Entwickler-App – etwa 5 Minuten, einmalig."
|
||||
},
|
||||
"more": {
|
||||
"title": "Weitere Quellen",
|
||||
"body": "Verbinde eine davon in den Einstellungen, wann immer du bereit bist.",
|
||||
"body": "Verbinde eine davon in den Einstellungen, wann immer du bereit bist – die meisten brauchen zuerst eine kostenlose Entwickler-App auf deinem eigenen Konto.",
|
||||
"appleHealth": "Apple Health",
|
||||
"whoop": "WHOOP",
|
||||
"oura": "Oura",
|
||||
@@ -4055,7 +4262,8 @@
|
||||
"learning": "Manche Einblicke – Glukose, Schlafrhythmus, Blutdrucktrends, Zyklus – schärfen sich in den ersten ein bis zwei Wochen, sobald sich deine Daten ansammeln. Leere Kacheln heißen jetzt nur, dass wir deine Basislinie noch lernen.",
|
||||
"connectCta": "Gerät verbinden",
|
||||
"logCta": "Ersten Wert eintragen",
|
||||
"importCta": "Bestehende Daten importieren"
|
||||
"importCta": "Bestehende Daten importieren",
|
||||
"modulesCta": "Module auswählen"
|
||||
},
|
||||
"welcomeBack": {
|
||||
"title": "Willkommen zurück",
|
||||
@@ -4165,7 +4373,16 @@
|
||||
"disableError": "Forschungsmodus konnte nicht deaktiviert werden. Bitte erneut versuchen."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Einstellungs-Bereiche"
|
||||
"sectionsNav": "Einstellungs-Bereiche",
|
||||
"groups": {
|
||||
"account": "Konto",
|
||||
"yourData": "Deine Daten",
|
||||
"display": "Anzeige",
|
||||
"healthProfile": "Gesundheitsprofil",
|
||||
"ai": "KI",
|
||||
"connectivity": "Konnektivität",
|
||||
"system": "System"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
@@ -4350,7 +4567,8 @@
|
||||
},
|
||||
"fullBackup": {
|
||||
"title": "Voll-Backup",
|
||||
"description": "Eine einzige JSON-Datei mit allen Daten — gleiches Format wie das wöchentliche Auto-Backup. Praktisch zum Selbst-Wiederherstellen via Admin-Upload.",
|
||||
"description": "Alle deine Daten in einer JSON-Datei — Messwerte, Medikamente, Stimmung, Zyklus, Laborwerte, Krankheitsepisoden, Allergien, Familienanamnese und Workouts. Gleiches Format wie das wöchentliche Auto-Backup.",
|
||||
"scopeNote": "Dokumentmetadaten und die KI-Zusammenfassung, falls vorhanden, sind als Klartext enthalten; die Originaldateien der Dokumente sowie GPS-Routen und Einzelmesswerte von Workouts sind es nicht — lade diese separat aus dem Dokumententresor bzw. dem Workout-Export herunter.",
|
||||
"encryptToggle": "Mit einer Passphrase verschlüsseln",
|
||||
"passphrasePlaceholder": "Passphrase (mind. 12 Zeichen)",
|
||||
"passphraseHelp": "Leitet den Archivschlüssel per Argon2id ab und versiegelt ihn mit AES-256-GCM. Speichere die Datei mit der Endung .hlx.",
|
||||
@@ -4381,6 +4599,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.",
|
||||
"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…",
|
||||
"unpacking": "Archiv wird entpackt…",
|
||||
@@ -4510,7 +4729,7 @@
|
||||
"description": "Strukturierte Datensätze zu Allergien und Familienanamnese."
|
||||
},
|
||||
"environment": {
|
||||
"title": "Umwelt",
|
||||
"title": "Standort & Wetter",
|
||||
"subtitle": "Wetter, Tageslicht und Standort-Kontext.",
|
||||
"disabledHint": "Aktiviere das Umwelt-Modul, um einen Heimatort festzulegen und tägliches Wetter und Tageslicht abzurufen.",
|
||||
"searchPlaceholder": "Stadt suchen",
|
||||
@@ -4539,7 +4758,9 @@
|
||||
"description": "Füge einen Ort und einen Zeitraum hinzu – eine Reise oder einen früheren Abschnitt deiner Verlaufsdaten. Der Zeitraum überschreibt für seine Tage deinen Wohnort, damit das Wetter zu deinem tatsächlichen Aufenthalt passt. So korrigierst du die Vergangenheit.",
|
||||
"none": "Noch keine Ortszeiträume.",
|
||||
"add": "{label} hinzufügen",
|
||||
"remove": "Ortszeitraum entfernen"
|
||||
"remove": "Ortszeitraum entfernen",
|
||||
"removeConfirmTitle": "Diese Reise entfernen?",
|
||||
"removeConfirmBody": "Messwerte aus diesem Zeitraum bleiben erhalten; nur der Reise-Eintrag verschwindet."
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Vergangene Tage nacherfassen",
|
||||
@@ -4820,7 +5041,10 @@
|
||||
"surfaced": "Angezeigt",
|
||||
"done": "Erledigt",
|
||||
"dismissed": "Verworfen"
|
||||
}
|
||||
},
|
||||
"deleteConfirmTitle": "Diese Erinnerung löschen?",
|
||||
"deleteConfirmBody": "Sie wird nicht mehr ausgelöst. An bereits erfassten Daten ändert sich nichts.",
|
||||
"deleteConfirmAction": "Löschen"
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "Über mich",
|
||||
@@ -4866,6 +5090,16 @@
|
||||
"honesty": "Der gemeinsame Zugang ist ein einzelnes angemeldetes KI-Konto, das der Betreiber für alle auf diesem Server verbunden hat — nicht dein eigener Schlüssel. Anfragen darüber werden vom Anbieter standardmäßig zum Training genutzt und von keiner Auftragsverarbeitungsvereinbarung gedeckt, die Stunden- und Wochenlimits des Kontos teilst du dir mit allen anderen, die zugestimmt haben, und deine eigenen Anbieter werden weiterhin zuerst versucht. Aktiviere es nur, wenn du diesen Kompromiss akzeptierst.",
|
||||
"confirm": "Verstanden — gemeinsame Verbindung nutzen",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"consent": {
|
||||
"title": "KI-Einwilligung",
|
||||
"activeSince": "Erteilt am {date}. Deine Gesundheitsdaten dürfen für die KI-Funktionen an den eingestellten Anbieter gesendet werden.",
|
||||
"withdrawn": "Widerrufen. Es werden keine Gesundheitsdaten an einen KI-Anbieter gesendet; die KI-Funktionen bleiben aus.",
|
||||
"withdraw": "Einwilligung widerrufen",
|
||||
"confirmBody": "Die KI-Funktionen sind sofort abgeschaltet. Bereits erfasste Daten werden nicht gelöscht — dafür gibt es Daten & Privatsphäre.",
|
||||
"confirmWithdraw": "Widerrufen",
|
||||
"regrantHint": "Um die KI-Funktionen wieder zu nutzen, schalte das automatische Lesen von Dokumenten ein oder erteile die Einwilligung in der App.",
|
||||
"error": "Der Widerruf konnte nicht gespeichert werden. Bitte versuche es erneut."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -5052,7 +5286,8 @@
|
||||
"resumeError": "Wieder verbinden fehlgeschlagen"
|
||||
},
|
||||
"apiTokens": "API-Tokens",
|
||||
"apiTokensDescription": "Erstelle API-Tokens für die externe Medikamenten-Einnahme (z.B. Shortcuts, Automationen).",
|
||||
"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.",
|
||||
"tokenNamePlaceholder": "Token-Name (z.B. iPhone Shortcut)",
|
||||
"tokenCreated": "Token erstellt — kopiere ihn jetzt! Er wird nicht erneut angezeigt.",
|
||||
"tokenCopy": "Token kopieren",
|
||||
@@ -5257,7 +5492,7 @@
|
||||
"statusRevoked": "Widerrufen",
|
||||
"statusExpired": "Abgelaufen",
|
||||
"inactiveTitle": "Widerrufen und abgelaufen ({count})",
|
||||
"shownOnce": "Nur einmal sichtbar. Erfassen Sie QR-Code und Passphrase jetzt — beide lassen sich später nicht wiederherstellen.",
|
||||
"shownOnce": "Nur einmal sichtbar. Erfasse QR-Code und Passphrase jetzt — beide lassen sich später nicht wiederherstellen.",
|
||||
"qrLabel": "QR-Code",
|
||||
"qrAlt": "QR-Code mit Freigabelink und Passphrase",
|
||||
"linkLabel": "Link",
|
||||
@@ -5312,7 +5547,7 @@
|
||||
"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",
|
||||
@@ -5383,6 +5618,12 @@
|
||||
"ouraDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
|
||||
"nightscoutDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der",
|
||||
"integrationCredentialsHint": "Wo du diese Zugangsdaten findest und wie du deine eigene App registrierst, steht in der Einrichtungsanleitung oben.",
|
||||
"integrationRedirectGuide": {
|
||||
"intro": "Die Verbindung läuft über deine eigene {provider}-Entwickler-App – etwa 5 Minuten Einrichtung:",
|
||||
"step1": "Erstelle eine App im {provider}-Entwicklerportal.",
|
||||
"step2": "Setze deren Weiterleitungs-/Callback-URL auf:",
|
||||
"step3": "Trage die dort erhaltene Client-ID und das Client-Secret oben ein."
|
||||
},
|
||||
"security": {
|
||||
"sessionsTitle": "Aktive Sitzungen",
|
||||
"sessionsDescription": "Geräte und Browser, die aktuell bei deinem Konto angemeldet sind.",
|
||||
@@ -5478,8 +5719,16 @@
|
||||
"revokeAllDone": "{count} vertrauenswürdige(s) Gerät(e) entfernt.",
|
||||
"unnamed": "Unbekanntes Gerät",
|
||||
"current": "Dieses Gerät",
|
||||
"expires": "Vertraut bis {date}"
|
||||
}
|
||||
"expires": "Vertraut bis {date}",
|
||||
"revokeConfirmTitle": "Diesem Gerät nicht mehr vertrauen?",
|
||||
"revokeConfirmBody": "Es fragt beim nächsten Anmelden wieder nach deinem zweiten Faktor.",
|
||||
"revokeAllConfirmTitle": "Keinem Gerät mehr vertrauen?",
|
||||
"revokeAllConfirmBody": "Jedes Gerät fragt beim nächsten Anmelden wieder nach deinem zweiten Faktor, auch dieses."
|
||||
},
|
||||
"revokeSessionConfirmTitle": "Dieses Gerät abmelden?",
|
||||
"revokeSessionConfirmBody": "{device} muss sich neu anmelden. An den dort erfassten Daten ändert sich nichts.",
|
||||
"signOutEverywhereConfirmTitle": "Alle anderen Geräte abmelden?",
|
||||
"signOutEverywhereConfirmBody": "Jedes Gerät außer diesem muss sich neu anmelden. Dieses Gerät bleibt angemeldet. An deinen erfassten Daten ändert sich nichts."
|
||||
},
|
||||
"privacy": {
|
||||
"intro": {
|
||||
@@ -5579,10 +5828,34 @@
|
||||
"rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.",
|
||||
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
|
||||
},
|
||||
"appleHealth": {
|
||||
"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"
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin hat hier keine direkte Verbindung. Verbinde über Apple Health (iOS) oder Google Health Connect (Android). Garmins proprietäre Werte — Body Battery, Training Load, HRV-Status, VO₂max — werden von Garmin an keine der beiden Plattformen weitergegeben.",
|
||||
"docsLink": "Zur Anleitung"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Glukose-Referenzband",
|
||||
"description": "Gegen welches Band deine Blutzuckerwerte bewertet werden.",
|
||||
"enable": "Diabetes-Zielband verwenden",
|
||||
"explainer": "Aus werden Werte gegen das allgemeine nicht-diabetische Referenzband bewertet. An werden sie gegen das engere glykämische Zielband der ADA Standards of Care bewertet, und der Coach begründet seine Glukose-Aussagen auf diesem Band. Deine eigenen Zielwert-Überschreibungen unten haben weiterhin Vorrang.",
|
||||
"disclaimer": "Das wählt ein Referenzband aus. Es ist keine Diagnose, wird nie aus einem Messwert abgeleitet, und die App handelt klinisch nicht danach. Welche Zielwerte für dich gelten, klärst du mit deiner Ärztin oder deinem Arzt.",
|
||||
"error": "Die Einstellung konnte nicht gespeichert werden."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -6023,6 +6296,7 @@
|
||||
"colAction": "Aktion",
|
||||
"colDuration": "Dauer",
|
||||
"colTraceId": "Trace",
|
||||
"viewDetails": "Details anzeigen für {action} um {timestamp}",
|
||||
"showing": "{count} von bis zu {bufferMax} gepufferten Events angezeigt",
|
||||
"empty": "Puffer ist leer",
|
||||
"emptyDescription": "Es passen noch keine Events zu den Filtern. Nach etwas Traffic auf Aktualisieren tippen.",
|
||||
@@ -6062,7 +6336,7 @@
|
||||
"restore": "Wiederherstellen",
|
||||
"restoreAria": "Backup für {username} wiederherstellen",
|
||||
"restoreTitle": "Aus Backup wiederherstellen",
|
||||
"restoreDescription": "Dies ersetzt ALLE aktuellen Daten von {username} mit dem Snapshot von {when}. Vorhandene Messungen, Medikamente, Einnahmen, Stimmungseinträge, Benachrichtigungs-Kanäle, Push-Abos und Telegram-Lösch-Aufträge werden entfernt.",
|
||||
"restoreDescription": "Dies ersetzt ALLE aktuellen Daten von {username} mit dem Snapshot von {when}. Vorhandene Messungen, Medikamente, Einnahmen, Stimmungseinträge, Benachrichtigungs-Kanäle, Push-Abos und Telegram-Lösch-Aufträge werden entfernt. Falls im Snapshot enthalten, überschreiben instanzweite Einstellungen die Konfiguration der gesamten Installation.",
|
||||
"restorePromptLabel": "Tippe RESTORE zum Fortfahren",
|
||||
"restoreConfirm": "Jetzt wiederherstellen",
|
||||
"restoreInProgress": "Wiederherstellung läuft…",
|
||||
@@ -6643,9 +6917,9 @@
|
||||
"hypertensionGrade1": "Hypertonie Grad 1",
|
||||
"hypertensionGrade2": "Hypertonie Grad 2",
|
||||
"hypertensionGrade3": "Hypertonie Grad 3",
|
||||
"bradycardia": "Bradykardie",
|
||||
"belowTypicalRange": "Unter dem typischen Bereich",
|
||||
"elevated": "Erhöht",
|
||||
"tachycardia": "Tachykardie",
|
||||
"aboveTypicalRange": "Über dem typischen Bereich",
|
||||
"significantlyLow": "Deutlich niedrig",
|
||||
"slightlyLow": "Leicht niedrig",
|
||||
"onTarget": "Im Zielbereich",
|
||||
@@ -6810,7 +7084,7 @@
|
||||
"title": "Dokumente",
|
||||
"untitled": "Unbenanntes Dokument",
|
||||
"download": "Herunterladen",
|
||||
"attachmentHint": "Diese Datei wird auf dein Gerät heruntergeladen (keine Vorschau in der Seite).",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
@@ -7320,7 +7594,7 @@
|
||||
"referenceLabel": "Referenz",
|
||||
"readingsCount": "{count} Messungen",
|
||||
"deleteConfirmTitle": "Laborwert löschen?",
|
||||
"deleteConfirmDescription": "Dieser Laborwert wird dauerhaft gelöscht.",
|
||||
"deleteConfirmDescription": "Dieser Laborwert wird gelöscht. Direkt danach kannst du das kurz rückgängig machen.",
|
||||
"deletedToast": "Wert gelöscht",
|
||||
"deleteError": "Wert konnte nicht gelöscht werden",
|
||||
"loadError": "Laborwerte konnten nicht geladen werden",
|
||||
@@ -7351,6 +7625,7 @@
|
||||
"notePlaceholder": "Optionaler Kontext zu diesem Wert",
|
||||
"requiredError": "Bitte einen Wert eingeben.",
|
||||
"save": "Speichern",
|
||||
"saveAndAddAnother": "Speichern & nächsten Wert",
|
||||
"savedToast": "Wert gespeichert",
|
||||
"saveError": "Wert konnte nicht gespeichert werden",
|
||||
"biomarker": "Biomarker",
|
||||
@@ -7798,7 +8073,7 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Diese Episode löschen?",
|
||||
"body": "„{label}“ und alle zugehörigen Tageseinträge werden entfernt. Dies kann nicht rückgängig gemacht werden."
|
||||
"body": "„{label}“ und alle zugehörigen Tageseinträge werden entfernt. Direkt danach kannst du das kurz rückgängig machen."
|
||||
},
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "Ruheherzfrequenz",
|
||||
@@ -7881,6 +8156,11 @@
|
||||
"review_due": "Check-in fällig",
|
||||
"reviewed": "Ausgewertet"
|
||||
}
|
||||
},
|
||||
"refusal": {
|
||||
"outOfScope": "Ich kann nur bei den Gesundheitsdaten in deinem Log helfen — Messwerte, Medikamente, Stimmung, Trends. Fragen außerhalb dieses Themas beantworte ich nicht.",
|
||||
"promptInjection": "In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen — formuliere deine Frage bitte in diesem Rahmen neu.",
|
||||
"conversationPoisoned": "Eine frühere Nachricht in dieser Unterhaltung enthält Anweisungen, die meine Vorgaben überschreiben sollen. Beginne bitte eine neue Unterhaltung."
|
||||
}
|
||||
},
|
||||
"safety": {
|
||||
@@ -7892,14 +8172,14 @@
|
||||
"bpLowDoctor": "Zwei aktuelle Messungen hatten einen systolischen Wert unter {systolic} mmHg. Wenn dir schwindelig oder schummrig ist, setz oder leg dich hin und kontaktiere deine Ärztin oder deinen Arzt, wenn es anhält.",
|
||||
"bpLowEmergency": "Zwei aktuelle Messungen hatten einen systolischen Wert unter {systolic} mmHg und du hast Beschwerden angegeben. Bei Ohnmachtsgefühl, Verwirrtheit oder kalter, feuchter Haut sofort den Notruf wählen.",
|
||||
"glucoseLowTitle": "Niedriger Blutzucker",
|
||||
"glucoseLowDoctor": "Zwei aktuelle Messungen lagen unter 70 mg/dL ({value} mg/dL). Nimm schnell wirkende Kohlenhydrate, miss in 15 Minuten erneut und kontaktiere deine Ärztin oder deinen Arzt, wenn die Tiefs anhalten.",
|
||||
"glucoseLowEmergency": "Zwei aktuelle Messungen lagen unter 70 mg/dL ({value} mg/dL) und du hast Beschwerden angegeben. Behandle jetzt mit schnellem Zucker; wenn das nicht geht oder dir sehr unwohl ist, wähle den Notruf.",
|
||||
"glucoseLowDoctor": "Zwei aktuelle Messungen lagen unter {threshold} {unit} ({value} {unit}). Nimm schnell wirkende Kohlenhydrate, miss in 15 Minuten erneut und kontaktiere deine Ärztin oder deinen Arzt, wenn die Tiefs anhalten.",
|
||||
"glucoseLowEmergency": "Zwei aktuelle Messungen lagen unter {threshold} {unit} ({value} {unit}) und du hast Beschwerden angegeben. Behandle jetzt mit schnellem Zucker; wenn das nicht geht oder dir sehr unwohl ist, wähle den Notruf.",
|
||||
"glucoseLowSevereTitle": "Sehr niedriger Blutzucker",
|
||||
"glucoseLowSevereDoctor": "Zwei aktuelle Messungen lagen unter 54 mg/dL ({value} mg/dL). Behandle sofort mit schnell wirkenden Kohlenhydraten, miss in 15 Minuten erneut und kontaktiere deine Ärztin oder deinen Arzt.",
|
||||
"glucoseLowSevereEmergency": "Zwei aktuelle Messungen lagen unter 54 mg/dL ({value} mg/dL) und du hast Beschwerden angegeben. Behandle jetzt; wenn du nichts bei dir behalten kannst oder dir sehr unwohl ist, wähle den Notruf.",
|
||||
"glucoseLowSevereDoctor": "Zwei aktuelle Messungen lagen unter {threshold} {unit} ({value} {unit}). Behandle sofort mit schnell wirkenden Kohlenhydraten, miss in 15 Minuten erneut und kontaktiere deine Ärztin oder deinen Arzt.",
|
||||
"glucoseLowSevereEmergency": "Zwei aktuelle Messungen lagen unter {threshold} {unit} ({value} {unit}) und du hast Beschwerden angegeben. Behandle jetzt; wenn du nichts bei dir behalten kannst oder dir sehr unwohl ist, wähle den Notruf.",
|
||||
"glucoseHighTitle": "Sehr hoher Blutzucker",
|
||||
"glucoseHighDoctor": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher. Miss erneut, trink ausreichend und kontaktiere möglichst bald deine Ärztin oder deinen Arzt, wenn es so hoch bleibt.",
|
||||
"glucoseHighEmergency": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher und du hast Beschwerden angegeben. Bei Erbrechen, schneller Atmung oder Verwirrtheit sofort den Notruf wählen."
|
||||
"glucoseHighDoctor": "Zwei aktuelle Messungen lagen bei {value} {unit} oder höher. Miss erneut, trink ausreichend und kontaktiere möglichst bald deine Ärztin oder deinen Arzt, wenn es so hoch bleibt.",
|
||||
"glucoseHighEmergency": "Zwei aktuelle Messungen lagen bei {value} {unit} oder höher und du hast Beschwerden angegeben. Bei Erbrechen, schneller Atmung oder Verwirrtheit sofort den Notruf wählen."
|
||||
}
|
||||
},
|
||||
"version": {
|
||||
@@ -7912,6 +8192,7 @@
|
||||
"mentalHealth": {
|
||||
"pageTitle": "Seelisches Wohlbefinden",
|
||||
"pageDescription": "Vier validierte, freiwillige Selbsttests — PHQ-9 (Stimmung), GAD-7 (Sorgen), WHO-5 (Wohlbefinden) und SCI (Schlaf) — ergänzend zu deinem Stimmungstagebuch. Aussagekräftig ist der Verlauf über die Zeit, nicht ein einzelner Wert.",
|
||||
"moodLink": "Deinen Stimmungsverlauf ansehen",
|
||||
"attributionLabel": "Quelle",
|
||||
"attributionShow": "Quelle und Lizenz für {instrument}",
|
||||
"choosePrompt": "Check-in auswählen",
|
||||
@@ -8036,6 +8317,12 @@
|
||||
"nummerGegenKummer": {
|
||||
"name": "Nummer gegen Kummer (für junge Menschen)"
|
||||
},
|
||||
"telefonSeelsorgeAt": {
|
||||
"name": "Telefonseelsorge Österreich (rund um die Uhr, kostenlos, vertraulich)"
|
||||
},
|
||||
"dargeboteneHand": {
|
||||
"name": "Die Dargebotene Hand (rund um die Uhr, kostenlos, vertraulich)"
|
||||
},
|
||||
"krisenchat": {
|
||||
"name": "krisenchat (24/7-Chat, junge Menschen)"
|
||||
},
|
||||
@@ -8126,7 +8413,9 @@
|
||||
"finally": "Finally …"
|
||||
}
|
||||
},
|
||||
"openDetail": "Details anzeigen"
|
||||
"openDetail": "Details anzeigen",
|
||||
"historyLoadError": "Dein Testverlauf konnte nicht geladen werden",
|
||||
"historyLoadErrorHint": "Das heißt nicht, dass keine Ergebnisse vorliegen — die Liste konnte nicht abgerufen werden. Versuche es erneut."
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "Das sind deine eigenen Angaben, keine ärztliche Diagnose. HealthLog stellt keine Diagnose, behandelt nicht und deutet nichts.",
|
||||
@@ -8289,7 +8578,9 @@
|
||||
"openLabel": "{title} öffnen",
|
||||
"attachmentBadge": "Nur Download",
|
||||
"uploading": "Wird hochgeladen…",
|
||||
"dismissFailed": "Fehlgeschlagenen Upload ausblenden"
|
||||
"dismissFailed": "Fehlgeschlagenen Upload ausblenden",
|
||||
"processing": "Wird verarbeitet…",
|
||||
"ready": "Bereit"
|
||||
},
|
||||
"error": {
|
||||
"fileTooLarge": "Diese Datei ist größer als das Limit von {maxSize}.",
|
||||
@@ -8331,6 +8622,16 @@
|
||||
},
|
||||
"detail": {
|
||||
"title": "Dokumentdetails",
|
||||
"summary": {
|
||||
"title": "Zusammenfassung",
|
||||
"pending": "Die Zusammenfassung wird erstellt.",
|
||||
"none": "Für dieses Dokument wurde noch keine Zusammenfassung erstellt.",
|
||||
"withheld": "Eine Zusammenfassung wurde erstellt, hat die Sicherheitsprüfung aber nicht bestanden und wird deshalb nicht angezeigt. Du kannst es erneut versuchen oder das Dokument selbst lesen.",
|
||||
"unavailable": "Für dieses Dokument konnte keine Zusammenfassung erstellt werden.",
|
||||
"generate": "Zusammenfassung erstellen",
|
||||
"regenerate": "Neu erstellen",
|
||||
"generating": "Wird erstellt…"
|
||||
},
|
||||
"loadError": "Dieses Dokument konnte nicht geladen werden.",
|
||||
"saveError": "Die Änderung konnte nicht gespeichert werden.",
|
||||
"deleteError": "Das Dokument konnte nicht gelöscht werden.",
|
||||
@@ -8345,7 +8646,8 @@
|
||||
"noConditions": "Mit keiner Erkrankung verknüpft",
|
||||
"previewUnavailable": "Keine Inline-Vorschau für diesen Dateityp",
|
||||
"uploadedMeta": "Hinzugefügt am {date} · {size}",
|
||||
"share": "Teilen"
|
||||
"share": "Teilen",
|
||||
"labValuesLink": "{count} Laborwerte ansehen"
|
||||
},
|
||||
"dropOverlay": {
|
||||
"title": "Zum Hochladen ablegen",
|
||||
@@ -8410,7 +8712,6 @@
|
||||
"close": "Ausblenden"
|
||||
},
|
||||
"ai": {
|
||||
"statusAiRead": "Von KI gelesen",
|
||||
"readDone": "Die KI hat dein Dokument gelesen.",
|
||||
"read": "Mit KI auslesen",
|
||||
"reread": "Erneut lesen",
|
||||
@@ -8566,6 +8867,7 @@
|
||||
"quickAddCustomPlaceholder": "250",
|
||||
"quickAddSubmit": "Hinzufügen",
|
||||
"quickAddError": "Konnte nicht gespeichert werden — versuch's noch mal.",
|
||||
"quickAddSuccess": "{amount} ml Wasser hinzugefügt.",
|
||||
"editTotal": "Heutige Summe bearbeiten"
|
||||
},
|
||||
"caffeine": {
|
||||
@@ -8576,14 +8878,15 @@
|
||||
"rowMeta": "{days} von {window} Tagen · Richtwert {value} {unit} (EFSA)",
|
||||
"rowMetaNoRef": "{days} von {window} Tagen",
|
||||
"footer": "{tracked} von {total} erfassten Mikronährstoffen haben Daten in diesem Zeitraum.",
|
||||
"emptyTitle": "Noch keine Mikronährstoffdaten synchronisiert"
|
||||
"emptyTitle": "Noch keine Mikronährstoffdaten synchronisiert",
|
||||
"loadError": "Deine Nährstoffdaten konnten nicht geladen werden"
|
||||
},
|
||||
"sparseNote": "Ein niedriger Wert bedeutet hier meist lückenhafte Erfassung, keinen Mangel — die Tagessummen aus Apple Health spiegeln wider, was deine Apps erfasst haben, nicht die gesamte Nahrungsaufnahme."
|
||||
},
|
||||
"daily": {
|
||||
"line": {
|
||||
"score": "Dein Gesundheitsscore heute liegt bei {score}.",
|
||||
"allClear": "Heute braucht nichts deine Aufmerksamkeit — alles verläuft normal."
|
||||
"allClear": "Heute braucht nichts deine Aufmerksamkeit."
|
||||
},
|
||||
"push": {
|
||||
"title": "Dein Morgenüberblick",
|
||||
@@ -8592,8 +8895,11 @@
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
"title": "Medikament fällig",
|
||||
"body": "Heute ist eine Dosis fällig.",
|
||||
"bodyNamed": "{name} ist heute fällig."
|
||||
"body": "Eine Dosis ist bald fällig.",
|
||||
"bodyNamed": "{name} ist bald fällig.",
|
||||
"overdueTitle": "Medikament überfällig",
|
||||
"overdueBody": "Eine Dosis ist überfällig.",
|
||||
"overdueBodyNamed": "{name} ist überfällig."
|
||||
},
|
||||
"preventiveCare": {
|
||||
"title": "Vorsorge fällig",
|
||||
@@ -8607,7 +8913,9 @@
|
||||
"coachCheckin": {
|
||||
"title": "Zeit, einen Plan anzuschauen",
|
||||
"body": "Es ist etwa eine Woche her, seit du diesen Plan gesetzt hast — behalte ihn, passe ihn an oder lass ihn los. Ganz ohne Druck.",
|
||||
"bodyPlan": "Vor etwa einer Woche hast du dir vorgenommen: „{plan}“. Behalte es, passe es an oder lass es los — ganz ohne Druck."
|
||||
"bodyPlan": "Vor etwa einer Woche hast du dir vorgenommen: „{plan}“. Behalte es, passe es an oder lass es los — ganz ohne Druck.",
|
||||
"adjustPrompt": "Ich möchte meinen Check-in-Plan anpassen.",
|
||||
"adjustPromptPlan": "Ich möchte diesen Plan anpassen: „{plan}“"
|
||||
},
|
||||
"tensionWindow": {
|
||||
"title": "Vielleicht eine angespannte Phase",
|
||||
@@ -8645,7 +8953,8 @@
|
||||
"deltaVsBaseline": "{delta} ggü. deinem Ausgangswert",
|
||||
"deltaFlat": "Im Bereich deines Ausgangswerts",
|
||||
"sleepPending": "Der Schlaf von letzter Nacht fehlt noch – der heutige Wert ist vorläufig.",
|
||||
"readFullBriefing": "Vollständiges Briefing lesen",
|
||||
"justIn": "Gerade eingetroffen",
|
||||
"justInAt": "Gerade eingetroffen · {time}",
|
||||
"worthALook": "Einen Blick wert",
|
||||
"allClear": "Heute braucht nichts deine Aufmerksamkeit.",
|
||||
"ringLink": "{metric}-Details öffnen"
|
||||
|
||||
+371
-72
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Save",
|
||||
"conflictReloaded": "Reloaded from the server. Please make your change again.",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"undo": "Undo",
|
||||
@@ -42,7 +43,60 @@
|
||||
"hidePassword": "Hide password",
|
||||
"learnMore": "Learn more",
|
||||
"dismiss": "Dismiss",
|
||||
"moreInfo": "More information"
|
||||
"moreInfo": "More information",
|
||||
"durationHoursMinutes": "{hours}h {minutes}m",
|
||||
"durationMinutes": "{minutes}m"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
"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." },
|
||||
"duplicate_timestamp": "A mood entry already exists for this time.",
|
||||
"not_found": "This mood entry is no longer available.",
|
||||
"ratedFactor": {
|
||||
"out_of_range": "A factor rating is outside its allowed range."
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"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." },
|
||||
"update": {
|
||||
"invalid": "Check the biomarker details and try again.",
|
||||
"referenceRangeInvalid": "The lower reference value must not exceed the upper value."
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"invalid": "Check the lab result and try again.",
|
||||
"linkedFieldsImmutable": "Edit the biomarker to change its name, unit, panel, or reference range.",
|
||||
"qualitativeExpected": "Enter a text result for this qualitative reading.",
|
||||
"numericExpected": "Enter a number for this numeric reading.",
|
||||
"referenceRangeInvalid": "The lower reference value must not exceed the upper value."
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"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." },
|
||||
"step": {
|
||||
"rateLimited": "Too many attempts. Please wait and try again.",
|
||||
"invalid": "This onboarding step could not be saved.",
|
||||
"completed": "Onboarding is already complete.",
|
||||
"outOfOrder": "Your onboarding progress changed. Refresh and continue.",
|
||||
"concurrent": "Your onboarding progress changed. Refresh and continue."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Health Report",
|
||||
@@ -91,7 +145,7 @@
|
||||
"colShare": "Share",
|
||||
"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: Europe/Berlin | Created: {timestamp}",
|
||||
"footerSource": "Source: HealthLog | Timezone: {timezone} | Created: {timestamp}",
|
||||
"typeWeight": "Body weight",
|
||||
"typeBpSys": "Systolic blood pressure",
|
||||
"typeBpDia": "Diastolic blood pressure",
|
||||
@@ -228,7 +282,7 @@
|
||||
"logout": "Sign out",
|
||||
"login": "Sign in",
|
||||
"notifications": "Notifications",
|
||||
"theme": "Appearance",
|
||||
"theme": "Theme",
|
||||
"themeSystem": "System",
|
||||
"themeDark": "Dark",
|
||||
"themeLight": "Light",
|
||||
@@ -253,7 +307,9 @@
|
||||
"medication": "Medication",
|
||||
"medicationDescription": "Mark a dose as taken",
|
||||
"mood": "Mood",
|
||||
"moodDescription": "Log your mood and tags"
|
||||
"moodDescription": "Log your mood and tags",
|
||||
"water": "Water",
|
||||
"waterDescription": "Quickly add your water intake"
|
||||
},
|
||||
"cycle": "Cycle",
|
||||
"labs": "Labs",
|
||||
@@ -295,6 +351,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.",
|
||||
@@ -356,6 +415,7 @@
|
||||
"quickAddMeasurement": "Log measurement",
|
||||
"quickAddMood": "Log mood",
|
||||
"quickAddMedicationIntake": "Log medication intake",
|
||||
"quickAddWater": "Log water",
|
||||
"customizeDashboard": "Customize dashboard",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Log medication intake",
|
||||
@@ -385,6 +445,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",
|
||||
@@ -400,12 +461,13 @@
|
||||
"staleHint": "Last reading {count} d ago",
|
||||
"staleHintWeeksOne": "Last reading 1 week ago",
|
||||
"staleHintWeeksOther": "Last reading {count} weeks ago",
|
||||
"staleHintWeeksFew": "Last reading {count} weeks ago",
|
||||
"staleHintMonthsOne": "Last reading 1 month ago",
|
||||
"staleHintMonthsOther": "Last reading {count} months ago",
|
||||
"staleHintMonthsFew": "Last reading {count} months ago",
|
||||
"recentWorkouts": {
|
||||
"title": "Recent workouts",
|
||||
"viewAll": "View all",
|
||||
"loading": "Loading workouts…",
|
||||
"empty": {
|
||||
"title": "No workouts yet",
|
||||
"cta": "Open Apple Health on your iPhone to sync workouts here."
|
||||
@@ -422,7 +484,9 @@
|
||||
"steps": "Steps",
|
||||
"totalBodyWater": "Total Body Water",
|
||||
"boneMass": "Bone Mass",
|
||||
"oxygenSaturation": "Oxygen Saturation"
|
||||
"oxygenSaturation": "Oxygen Saturation",
|
||||
"mood": "Mood",
|
||||
"bmi": "BMI"
|
||||
},
|
||||
"unit": {
|
||||
"weight": "kg",
|
||||
@@ -434,7 +498,9 @@
|
||||
"steps": "steps",
|
||||
"totalBodyWater": "kg",
|
||||
"boneMass": "kg",
|
||||
"oxygenSaturation": "%"
|
||||
"oxygenSaturation": "%",
|
||||
"mood": "/5",
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -575,7 +641,7 @@
|
||||
"bulkDeleteSuccess": "{count} measurements deleted",
|
||||
"bulkDeleteError": "Error deleting the selected measurements",
|
||||
"bulkDeleteConfirmTitle": "Delete {count} measurements?",
|
||||
"bulkDeleteConfirmBody": "The {count} selected measurements will be permanently deleted.",
|
||||
"bulkDeleteConfirmBody": "The {count} selected measurements will be deleted. You can undo this for a short moment afterwards.",
|
||||
"typePhq9Score": "PHQ-9 score",
|
||||
"typeGad7Score": "GAD-7 score",
|
||||
"typeGripStrength": "Grip strength",
|
||||
@@ -587,9 +653,27 @@
|
||||
"typeWho5Score": "WHO-5 score",
|
||||
"typeSciScore": "SCI score"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "What HealthLog can track",
|
||||
"description": "Every metric domain HealthLog supports, whether or not you have data for it yet — and what would supply it.",
|
||||
"statusPresent": "Tracked",
|
||||
"statusAbsent": "Not tracked yet",
|
||||
"statusModuleOff": "Off",
|
||||
"loadError": "Could not load your tracking status.",
|
||||
"connectCta": "Connect a source",
|
||||
"viewCta": "View",
|
||||
"ecg": {
|
||||
"source": "Single-lead ECG strips from a compatible watch — Withings ScanWatch today — synced automatically."
|
||||
},
|
||||
"watchdog": {
|
||||
"title": "Automatic screening",
|
||||
"description": "HealthLog also watches quietly in the background — irregular-rhythm and breathing-disturbance notifications from a connected watch, baseline-drift health-status alerts, and notable shifts between your two most recent lab panels — and surfaces a card only when something stands out. With at least one of those sources connected, no card showing is a good sign, not a missing feature; with none connected yet, there's simply nothing being watched."
|
||||
}
|
||||
},
|
||||
"mood": {
|
||||
"title": "Mood",
|
||||
"subtitle": "Mood tracker and entries",
|
||||
"mentalWellbeingLink": "Take a mental-wellbeing check-in",
|
||||
"addEntry": "Add",
|
||||
"editEntry": "Edit entry",
|
||||
"moodLevel": "Mood",
|
||||
@@ -705,7 +789,7 @@
|
||||
"bulkDeleteSuccess": "{count} entries deleted",
|
||||
"bulkDeleteError": "Error deleting the selected entries",
|
||||
"bulkDeleteConfirmTitle": "Delete {count} entries?",
|
||||
"bulkDeleteConfirmBody": "The {count} selected mood entries will be permanently deleted.",
|
||||
"bulkDeleteConfirmBody": "The {count} selected mood entries will be deleted. You can undo this for a short moment afterwards.",
|
||||
"customize": "Customize mood tags",
|
||||
"addTagInline": "Add tag",
|
||||
"manage": {
|
||||
@@ -1403,7 +1487,10 @@
|
||||
"confirmAction": "Delete",
|
||||
"toast": "Intake deleted",
|
||||
"failed": "Could not delete the intake."
|
||||
}
|
||||
},
|
||||
"viewRecent90": "Last 90 days",
|
||||
"viewAll": "Full history",
|
||||
"viewToggleLabel": "History range"
|
||||
},
|
||||
"verlauf": {
|
||||
"loading": "Loading history…",
|
||||
@@ -1659,7 +1746,10 @@
|
||||
"packagingDefaultPackLabel": "Default pack size (units)",
|
||||
"packagingDefaultPackHelper": "Prefills the quantity when registering new packs (1–1000). Leave empty if none applies.",
|
||||
"packagingSuccess": "Packaging details saved",
|
||||
"packagingFailed": "Saving failed"
|
||||
"packagingFailed": "Saving failed",
|
||||
"addManufacturerLabel": "Manufacturer",
|
||||
"addDoseStrengthLabel": "Strength",
|
||||
"addDoseStrengthHint": "As printed on the container, e.g. 5 mg/0.5 ml."
|
||||
},
|
||||
"erweitert": {
|
||||
"group": {
|
||||
@@ -1785,13 +1875,14 @@
|
||||
"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?",
|
||||
"placeholder": "Track against…",
|
||||
"apply": "Apply",
|
||||
"reset": "Reset to default"
|
||||
"reset": "Reset to default",
|
||||
"error": "Could not save that change. Try again."
|
||||
},
|
||||
"beforeAfter": {
|
||||
"summary": "Ø {before} before → Ø {after} since — a change of {delta}.",
|
||||
@@ -1806,7 +1897,10 @@
|
||||
"subtitle": "What lines up with the measurements your medications target — association only, never a verdict.",
|
||||
"link": "Open the effect view",
|
||||
"empty": "Not enough data yet to relate your medications to their target measurements."
|
||||
}
|
||||
},
|
||||
"scaleAbsolute": "Absolute",
|
||||
"scalePercentSinceStart": "% since start",
|
||||
"scaleToggleLabel": "Chart scale"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -1955,6 +2049,61 @@
|
||||
"notAvailableForCompliance": "Comparison N/A for adherence"
|
||||
},
|
||||
"insights": {
|
||||
"correlation": {
|
||||
"direction": {
|
||||
"higher": "higher",
|
||||
"lower": "lower"
|
||||
},
|
||||
"weekdayDirection": {
|
||||
"above": "above",
|
||||
"below": "below"
|
||||
},
|
||||
"channelFactor": "rated {factor}",
|
||||
"channel": {
|
||||
"WEIGHT": "weight",
|
||||
"BLOOD_GLUCOSE": "blood glucose",
|
||||
"BLOOD_PRESSURE_SYS": "systolic blood pressure",
|
||||
"BLOOD_PRESSURE_DIA": "diastolic blood pressure",
|
||||
"ACTIVITY_STEPS": "daily steps",
|
||||
"SLEEP_DURATION": "sleep duration",
|
||||
"HEART_RATE_VARIABILITY": "heart-rate variability",
|
||||
"RESTING_HEART_RATE": "resting heart rate",
|
||||
"TIME_IN_DAYLIGHT": "time in daylight",
|
||||
"MOOD": "mood",
|
||||
"PULSE": "resting pulse",
|
||||
"MEDICATION_COMPLIANCE": "medication compliance",
|
||||
"SYMPTOM_SEVERITY": "symptom severity",
|
||||
"ENV_TEMP_MEAN": "daily temperature",
|
||||
"ENV_TEMP_MIN": "overnight low temperature",
|
||||
"ENV_SUNSHINE": "sunshine",
|
||||
"ENV_DAYLIGHT": "daylight",
|
||||
"ENV_PRECIP": "precipitation",
|
||||
"ENV_PRESSURE_MEAN": "barometric pressure",
|
||||
"ENV_PRESSURE_DELTA": "intraday pressure swing"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Higher {behaviour} tends to go with {direction} next-day {outcome} in your data — a pattern worth watching, not a cause.",
|
||||
"moderate": "Higher {behaviour} looks like it goes with {direction} next-day {outcome} in your data, on the evidence so far — a pattern worth watching, not a cause.",
|
||||
"faint": "Higher {behaviour} shows a faint hint, if anything, of {direction} next-day {outcome} in your data — too small to lean on, never a cause."
|
||||
},
|
||||
"lab": {
|
||||
"default": "Higher {lab} readings line up with {direction} {outcome} over the same periods in your data — an association worth watching with your clinician, never a cause.",
|
||||
"faint": "Higher {lab} readings show a faint hint, if anything, of {direction} {outcome} over the same periods in your data — too small to lean on, an association to raise with your clinician, never a cause."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "Higher medication compliance lines up with lower systolic readings — a pattern worth watching.",
|
||||
"surprising": "Compliance and systolic move together in your data — surprising; talk to your doctor before adjusting anything.",
|
||||
"none": "Compliance and systolic do not move together strongly in this window."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "Lower-mood days line up with higher resting pulse — a pattern worth watching.",
|
||||
"surprising": "Higher-mood days line up with higher resting pulse in your data — surprising.",
|
||||
"none": "Mood and resting pulse do not move together strongly in this window."
|
||||
},
|
||||
"weightWeekday": "{weekday} weights run {delta} kg {direction} your other-day average — a pattern worth watching."
|
||||
}
|
||||
},
|
||||
"noAnalysisYet": "No assessment yet.",
|
||||
"assessmentPreparing": "Preparing your assessment…",
|
||||
"emptyTitle": "Not enough data yet",
|
||||
@@ -2193,6 +2342,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",
|
||||
@@ -2240,7 +2390,16 @@
|
||||
"historySearchPlaceholder": "Search conversations…",
|
||||
"historyEmpty": "Your conversations will appear here. Ask anything to start.",
|
||||
"historyDeleteAria": "Delete conversation",
|
||||
"historyDeleteConfirm": "Delete this conversation? This cannot be undone.",
|
||||
"historyDeleted": "Conversation deleted",
|
||||
"rename": {
|
||||
"action": "Rename conversation",
|
||||
"input": "Conversation title",
|
||||
"save": "Save title",
|
||||
"cancel": "Cancel rename",
|
||||
"error": "Conversation title could not be saved."
|
||||
},
|
||||
"historySearchEmpty": "No conversations match your search.",
|
||||
"historyLoadingMore": "Loading more…",
|
||||
"sourcesTitle": "What I can see",
|
||||
"sourcesFresh": "Fresh",
|
||||
"sourcesStale": "Stale",
|
||||
@@ -2377,10 +2536,6 @@
|
||||
"settingsClustersHint": "Pick which clusters of your data the Coach can read. More data means richer answers and a larger prompt.",
|
||||
"settingsClusterNoData": "No data yet",
|
||||
"sourcesMemberCount": "{count} metrics",
|
||||
"dictate": "Dictate",
|
||||
"dictateStop": "Stop dictation",
|
||||
"voiceUnsupported": "Voice input is not supported in this browser",
|
||||
"dictateError": "Could not start voice input. Check microphone permission and try again.",
|
||||
"showConversations": "Show conversations",
|
||||
"hideConversations": "Hide conversations",
|
||||
"heroGreeting": "Ask me anything about your data",
|
||||
@@ -2470,17 +2625,22 @@
|
||||
"documentsAttachedOne": "1 document attached",
|
||||
"documentsAttachedOther": "{count} documents attached",
|
||||
"railBadge": "Fenced document chat",
|
||||
"uploadError": "Upload failed. Please try again."
|
||||
"uploadError": "Upload failed. Please try again.",
|
||||
"notFoundError": "That document link is no longer valid — starting a normal chat instead."
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "just now",
|
||||
"relativeMinutesAgoOne": "{count} minute ago",
|
||||
"relativeMinutesAgoOther": "{count} minutes ago",
|
||||
"relativeMinutesAgoFew": "{count} minutes ago",
|
||||
"relativeHoursAgoOne": "{count} hour ago",
|
||||
"relativeHoursAgoOther": "{count} hours ago",
|
||||
"relativeHoursAgoFew": "{count} hours ago",
|
||||
"relativeDaysAgoOne": "{count} day ago",
|
||||
"relativeDaysAgoOther": "{count} days ago",
|
||||
"relativeDaysAgoFew": "{count} days ago",
|
||||
"navOverview": "Overview",
|
||||
"navCatalog": "All metrics",
|
||||
"navBloodPressure": "Blood Pressure",
|
||||
"navWeight": "Weight",
|
||||
"navPulse": "Pulse",
|
||||
@@ -2511,9 +2671,9 @@
|
||||
"label": "Activity",
|
||||
"header": "Activity & mobility"
|
||||
},
|
||||
"cardiovascularParent": {
|
||||
"label": "Cardiovascular",
|
||||
"header": "Cardiovascular markers"
|
||||
"heartParent": {
|
||||
"label": "Heart",
|
||||
"header": "Heart health"
|
||||
},
|
||||
"hearingParent": {
|
||||
"label": "Hearing",
|
||||
@@ -2638,11 +2798,11 @@
|
||||
"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.",
|
||||
"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, 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.",
|
||||
"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",
|
||||
@@ -2672,7 +2832,7 @@
|
||||
"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, and time outdoors is linked to eye health.",
|
||||
"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",
|
||||
@@ -2684,7 +2844,7 @@
|
||||
"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.",
|
||||
"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",
|
||||
@@ -2732,7 +2892,8 @@
|
||||
},
|
||||
"valuesBack": "Back to insights",
|
||||
"valuesBackToMetric": "Back to {metric}",
|
||||
"scoresBack": "Back to insights"
|
||||
"scoresBack": "Back to insights",
|
||||
"ecgDescription": "Single-lead ECG recordings from your device, each with the device's own result. HealthLog shows the trace and the recording — it does not interpret the waveform."
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sleep",
|
||||
@@ -2771,9 +2932,9 @@
|
||||
"debt": {
|
||||
"title": "Sleep debt",
|
||||
"value": "{hours}h {minutes}m short",
|
||||
"caption": "Cumulative shortfall over the last {nights} nights",
|
||||
"caption": "Outstanding balance over the last {nights} nights",
|
||||
"clearValue": "All caught up",
|
||||
"clearCaption": "No accumulated shortfall over the last {nights} nights",
|
||||
"clearCaption": "Nothing outstanding over the last {nights} nights",
|
||||
"learning": "Still learning your sleep — {counted} nights so far, {remaining} more to read a reliable balance.",
|
||||
"computedInfo": "This is HealthLog's own estimate: a rolling balance of need minus sleep over recent nights, with catch-up sleep paying it down. It can differ from a wearable's native sleep-debt figure, which uses its own need model."
|
||||
},
|
||||
@@ -2870,13 +3031,15 @@
|
||||
"sleep": {
|
||||
"title": "No sleep entries yet",
|
||||
"description": "Once sleep data lands (manual or via Withings / Apple Health), the per-night stage breakdown will appear here.",
|
||||
"cta": "Connect Withings"
|
||||
"cta": "Connect a source"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "Workouts",
|
||||
"navRecovery": "Recovery",
|
||||
"workouts": {
|
||||
"title": "Workouts",
|
||||
"askWhy": "Ask why",
|
||||
"askWhyPrompt": "Why did this session read the way it did? Walk me through my own numbers from it.",
|
||||
"description": "Recent runs, rides, walks and strength sessions, deduped across Apple Watch and Withings sources.",
|
||||
"emptyState": {
|
||||
"title": "No workouts yet",
|
||||
@@ -2905,7 +3068,24 @@
|
||||
"hrChartTitle": "Heart rate",
|
||||
"hrChartUnavailable": "Per-second heart-rate samples are not stored for this workout.",
|
||||
"notFound": "Workout not found.",
|
||||
"loading": "Loading workout…"
|
||||
"loading": "Loading workout…",
|
||||
"hrFromPulse": "From your heart-rate data around this session",
|
||||
"hrAxisMinutes": "min",
|
||||
"sportAverageLabel": "Your recent average:",
|
||||
"zonesTitle": "Effort zones",
|
||||
"zonesCaption": "How your effort was distributed across heart-rate zones.",
|
||||
"zonesCaptionDevice": "Time in zone as reported by your device.",
|
||||
"exportGpx": "Export GPX",
|
||||
"elevationTitle": "Elevation",
|
||||
"splitsTitle": "Splits",
|
||||
"splitsKm": "km",
|
||||
"splitsTime": "Time",
|
||||
"splitsPace": "Pace",
|
||||
"thatDayTitle": "That day",
|
||||
"dayPulse": "Pulse",
|
||||
"daySleep": "Sleep",
|
||||
"dayMood": "Mood",
|
||||
"insightTitle": "Activity insight"
|
||||
},
|
||||
"sport": {
|
||||
"walking": "Walking",
|
||||
@@ -2928,7 +3108,11 @@
|
||||
"crossTraining": "Cross training",
|
||||
"mixedCardio": "Mixed cardio",
|
||||
"other": "Other"
|
||||
}
|
||||
},
|
||||
"loadError": "Your workouts could not be loaded",
|
||||
"loadMore": "Load more workouts",
|
||||
"loadingMore": "Loading more workouts",
|
||||
"loadMoreError": "More workouts could not be loaded"
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Blood Glucose",
|
||||
@@ -3238,6 +3422,8 @@
|
||||
},
|
||||
"navWalkingSpeed": "Walking Speed",
|
||||
"mood": {
|
||||
"logLink": "Log a mood entry",
|
||||
"mentalWellbeingLink": "Take a mental-wellbeing check-in",
|
||||
"heatmapTitle": "Mood calendar",
|
||||
"heatmapNoEntry": "No entry",
|
||||
"legendGreat": "Great",
|
||||
@@ -3525,7 +3711,9 @@
|
||||
"sectionTitle": "Your health scores",
|
||||
"sectionSubtitle": "Composite recovery, stress and strain read from your connected devices",
|
||||
"loadError": "Could not load your wellness scores right now.",
|
||||
"recovery": "Recovery",
|
||||
"loadingLabel": "Loading your health scores",
|
||||
"recovery": "Recovery score",
|
||||
"recoveryCrossLink": "See your wearable's recovery & strain data",
|
||||
"stress": "Stress",
|
||||
"strain": "Strain"
|
||||
},
|
||||
@@ -3696,6 +3884,7 @@
|
||||
"recovery": {
|
||||
"title": "Recovery",
|
||||
"description": "How well your body has bounced back — the recovery, strain and load signals your wearable records overnight.",
|
||||
"scoreCrossLink": "See the composite recovery score",
|
||||
"emptyTitle": "No recovery data yet",
|
||||
"empty": "No recovery data yet. Connect a WHOOP, Polar or Oura account and these scores fill in once your device syncs.",
|
||||
"block": {
|
||||
@@ -3866,13 +4055,18 @@
|
||||
"recordingsMany": "{count} recordings on file.",
|
||||
"latestResult": "Latest device result: {result}.",
|
||||
"cta": "View recordings"
|
||||
}
|
||||
},
|
||||
"emptyTitle": "No ECG recordings yet",
|
||||
"emptyDescription": "ECG recordings sync from a compatible single-lead device. Once your device uploads a recording, it appears here.",
|
||||
"emptyCta": "Connect a device"
|
||||
},
|
||||
"intradayPulse": {
|
||||
"title": "Your day's shape",
|
||||
"caption": "Your heart rate through the day, averaged in 10-minute steps.",
|
||||
"hourlyNote": "Showing this day's hourly average — recent days show the full 10-minute detail.",
|
||||
"empty": "Not enough heart-rate readings for this day to show its shape yet.",
|
||||
"coverageOne": "Based on 1 reading across {hours}h.",
|
||||
"coverageMany": "Based on {count} readings across {hours}h.",
|
||||
"baseline": "Resting",
|
||||
"today": "Today",
|
||||
"previousDay": "Previous day",
|
||||
@@ -3883,7 +4077,8 @@
|
||||
"evening": "A sustained elevated-at-rest stretch this evening — possibly some tension.",
|
||||
"night": "A sustained elevated-at-rest stretch overnight — possibly some tension."
|
||||
}
|
||||
}
|
||||
},
|
||||
"navEcg": "ECG"
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Default",
|
||||
@@ -3982,7 +4177,7 @@
|
||||
},
|
||||
"wrapUp": {
|
||||
"title": "That’s the tour",
|
||||
"body": "You can revisit any module on its own at any time (“Show this tour” on every page) or restart the whole tour under Settings → Advanced."
|
||||
"body": "This walkthrough runs once — it won't reopen on its own. Everything it covered stays put, and you can turn features on or off any time under Settings → Modules."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4010,7 +4205,7 @@
|
||||
},
|
||||
"source": {
|
||||
"title": "Connect a source",
|
||||
"body": "Bring in measurements automatically, or stay on manual entry. Nothing here is configured for you — each card just shows where to connect it.",
|
||||
"body": "Bring in measurements automatically, or stay on manual entry. Nothing here is configured for you — each card just shows where to connect it. Device sources need a free developer app registered on your own account (about 5 minutes); the setup guide on each card walks through it.",
|
||||
"recommended": "Recommended",
|
||||
"manual": {
|
||||
"title": "Manual entry",
|
||||
@@ -4018,12 +4213,14 @@
|
||||
},
|
||||
"withings": {
|
||||
"title": "Withings",
|
||||
"body": "Scales, blood-pressure cuffs and trackers from Withings sync automatically.",
|
||||
"cta": "Connect Withings"
|
||||
"body": "Scales, blood-pressure cuffs and trackers from Withings sync automatically once connected.",
|
||||
"cta": "Connect Withings",
|
||||
"setupCta": "Set up in Settings",
|
||||
"setupHint": "Needs your own free Withings developer app first — about 5 minutes, one time."
|
||||
},
|
||||
"more": {
|
||||
"title": "More sources",
|
||||
"body": "Connect any of these in Settings whenever you're ready.",
|
||||
"body": "Connect any of these in Settings whenever you're ready — most need a free developer app registered on your own account first.",
|
||||
"appleHealth": "Apple Health",
|
||||
"whoop": "WHOOP",
|
||||
"oura": "Oura",
|
||||
@@ -4055,7 +4252,8 @@
|
||||
"learning": "Some insights — glucose, sleep rhythm, blood-pressure trends, cycle — sharpen over the first week or two as your data builds up. Empty tiles now just mean we're still learning your baseline.",
|
||||
"connectCta": "Connect a device",
|
||||
"logCta": "Log your first measurement",
|
||||
"importCta": "Import existing history"
|
||||
"importCta": "Import existing history",
|
||||
"modulesCta": "Choose your modules"
|
||||
},
|
||||
"welcomeBack": {
|
||||
"title": "Welcome back",
|
||||
@@ -4165,7 +4363,16 @@
|
||||
"disableError": "Could not disable Research Mode. Please try again."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Settings sections"
|
||||
"sectionsNav": "Settings sections",
|
||||
"groups": {
|
||||
"account": "Account",
|
||||
"yourData": "Your data",
|
||||
"display": "Display",
|
||||
"healthProfile": "Health profile",
|
||||
"ai": "AI",
|
||||
"connectivity": "Connectivity",
|
||||
"system": "System"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
@@ -4350,7 +4557,8 @@
|
||||
},
|
||||
"fullBackup": {
|
||||
"title": "Full backup",
|
||||
"description": "Single JSON file with everything — same shape as the weekly auto-backup. Useful for self-restore via admin upload.",
|
||||
"description": "Every record you own in one JSON file — measurements, medications, mood, cycle, labs, illness episodes, allergies, family history, and workouts. Same shape as the weekly auto-backup.",
|
||||
"scopeNote": "Document metadata and the AI-generated summary, if one exists, are included as plain text; original document files and workout GPS/sample data are not — download those separately from the vault or the workout export.",
|
||||
"encryptToggle": "Encrypt with a passphrase",
|
||||
"passphrasePlaceholder": "Passphrase (min. 12 characters)",
|
||||
"passphraseHelp": "Derives the archive key with Argon2id and seals it with AES-256-GCM. Save the file with a .hlx extension.",
|
||||
@@ -4381,6 +4589,7 @@
|
||||
"failed": "The import failed.",
|
||||
"rowsImported": "{count} rows imported so far",
|
||||
"doneSummary": "Done — {imported} of {read} records imported, {skipped} clinical records skipped.",
|
||||
"estimateWarning": "Cumulative totals for {count} day(s) were estimated from the largest export source. Native Apple Health statistics remain authoritative.",
|
||||
"phase": {
|
||||
"queued": "Queued…",
|
||||
"unpacking": "Unpacking the archive…",
|
||||
@@ -4510,7 +4719,7 @@
|
||||
"description": "Structured allergies and family history records."
|
||||
},
|
||||
"environment": {
|
||||
"title": "Environment",
|
||||
"title": "Location & weather",
|
||||
"subtitle": "Weather, daylight, and location context.",
|
||||
"disabledHint": "Turn on the Environment module to set a home location and pull in daily weather and daylight.",
|
||||
"searchPlaceholder": "Search for a city",
|
||||
@@ -4539,7 +4748,9 @@
|
||||
"description": "Add a place and a date range — a trip, or a stretch of earlier history. The period overrides your home for the days it covers, so weather matches where you actually were. This is how you correct the past.",
|
||||
"none": "No location periods yet.",
|
||||
"add": "Add {label}",
|
||||
"remove": "Remove location period"
|
||||
"remove": "Remove location period",
|
||||
"removeConfirmTitle": "Remove this trip?",
|
||||
"removeConfirmBody": "Readings taken during it stay in your record; only the trip entry goes."
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Backfill past days",
|
||||
@@ -4820,7 +5031,10 @@
|
||||
"surfaced": "Surfaced",
|
||||
"done": "Done",
|
||||
"dismissed": "Dismissed"
|
||||
}
|
||||
},
|
||||
"deleteConfirmTitle": "Delete this reminder?",
|
||||
"deleteConfirmBody": "It stops being raised. Anything you have already recorded is untouched.",
|
||||
"deleteConfirmAction": "Delete"
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "About me",
|
||||
@@ -4866,6 +5080,16 @@
|
||||
"honesty": "The shared access is a single signed-in AI account the operator connected for everyone on this server — not your own key. Requests you make on it are trained on by the provider by default and no data-processing agreement covers them, the account's hourly and weekly limits are shared with every other user who opted in, and your own configured providers are still tried first. Turn it on only if you accept this trade.",
|
||||
"confirm": "I understand — use the shared connection",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"consent": {
|
||||
"title": "AI consent",
|
||||
"activeSince": "Given on {date}. Your health data may be sent to the configured provider for the AI features.",
|
||||
"withdrawn": "Withdrawn. No health data is sent to an AI provider; the AI features stay switched off.",
|
||||
"withdraw": "Withdraw consent",
|
||||
"confirmBody": "The AI features stop working straight away. Nothing already recorded is deleted — use Data & privacy to export or erase your record.",
|
||||
"confirmWithdraw": "Withdraw",
|
||||
"regrantHint": "To use the AI features again, switch on automatic document reading or grant consent from the app.",
|
||||
"error": "The withdrawal could not be saved. Please try again."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -5052,7 +5276,8 @@
|
||||
"resumeError": "Reconnect failed"
|
||||
},
|
||||
"apiTokens": "API Tokens",
|
||||
"apiTokensDescription": "Create API tokens for external medication intake (e.g., Shortcuts, automations).",
|
||||
"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.",
|
||||
"tokenNamePlaceholder": "Token name (e.g., iPhone Shortcut)",
|
||||
"tokenCreated": "Token created — copy it now! It won't be shown again.",
|
||||
"tokenCopy": "Copy token",
|
||||
@@ -5312,7 +5537,7 @@
|
||||
"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",
|
||||
@@ -5383,6 +5608,12 @@
|
||||
"ouraDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
|
||||
"nightscoutDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the",
|
||||
"integrationCredentialsHint": "Where to find these credentials and how to register your own app is covered in the setup guide above.",
|
||||
"integrationRedirectGuide": {
|
||||
"intro": "This connects through your own {provider} developer app — about 5 minutes to set up:",
|
||||
"step1": "Create an app in the {provider} developer portal.",
|
||||
"step2": "Set its redirect / callback URL to:",
|
||||
"step3": "Paste the client ID and client secret it gives you above."
|
||||
},
|
||||
"security": {
|
||||
"sessionsTitle": "Active sessions",
|
||||
"sessionsDescription": "Devices and browsers currently signed in to your account.",
|
||||
@@ -5478,8 +5709,16 @@
|
||||
"revokeAllDone": "Forgot {count} trusted device(s).",
|
||||
"unnamed": "Unknown device",
|
||||
"current": "This device",
|
||||
"expires": "Trusted until {date}"
|
||||
}
|
||||
"expires": "Trusted until {date}",
|
||||
"revokeConfirmTitle": "Stop trusting this device?",
|
||||
"revokeConfirmBody": "It will ask for your second factor again the next time you sign in from it.",
|
||||
"revokeAllConfirmTitle": "Stop trusting every device?",
|
||||
"revokeAllConfirmBody": "Every device will ask for your second factor again the next time you sign in from it, including this one."
|
||||
},
|
||||
"revokeSessionConfirmTitle": "Sign this device out?",
|
||||
"revokeSessionConfirmBody": "{device} will need to sign in again. Nothing recorded on it is affected.",
|
||||
"signOutEverywhereConfirmTitle": "Sign out every other device?",
|
||||
"signOutEverywhereConfirmBody": "Every device except this one will need to sign in again. This device stays signed in. Nothing you have recorded is affected."
|
||||
},
|
||||
"privacy": {
|
||||
"intro": {
|
||||
@@ -5579,10 +5818,34 @@
|
||||
"rate_limited": "Too many connection attempts. Wait a moment and try again.",
|
||||
"generic": "Unknown error while connecting. Please start the connection again."
|
||||
},
|
||||
"appleHealth": {
|
||||
"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"
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin has no direct connection here. Connect via Apple Health (iOS) or Google Health Connect (Android). Garmin's proprietary scores — Body Battery, Training Load, HRV Status, VO₂ max — are not shared by Garmin to either platform.",
|
||||
"docsLink": "Read the guide"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Glucose reference band",
|
||||
"description": "Which band your blood-glucose readings are judged against.",
|
||||
"enable": "Use the diabetes goal band",
|
||||
"explainer": "Off, readings are judged against the general non-diabetic reference band. On, they are judged against the tighter glycaemic goal band from the ADA Standards of Care, and the Coach grounds its glucose remarks on that band instead. Your own target overrides below still take precedence.",
|
||||
"disclaimer": "This selects a reference band. It is not a diagnosis, it is never inferred from a reading, and the app takes no clinical action on it. Which targets apply to you is a question for your clinician.",
|
||||
"error": "Could not save the setting."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -6023,6 +6286,7 @@
|
||||
"colAction": "Action",
|
||||
"colDuration": "Duration",
|
||||
"colTraceId": "Trace",
|
||||
"viewDetails": "View details for {action} at {timestamp}",
|
||||
"showing": "Showing {count} of up to {bufferMax} buffered events",
|
||||
"empty": "Buffer is empty",
|
||||
"emptyDescription": "No events match the current filters yet. Hit refresh after triggering some traffic.",
|
||||
@@ -6062,7 +6326,7 @@
|
||||
"restore": "Restore",
|
||||
"restoreAria": "Restore from backup for {username}",
|
||||
"restoreTitle": "Restore from backup",
|
||||
"restoreDescription": "This will replace ALL current data for {username} with the snapshot from {when}. Existing measurements, medications, intake events, mood entries, notification channels, push subscriptions and queued Telegram deletions will be deleted.",
|
||||
"restoreDescription": "This will replace ALL current data for {username} with the snapshot from {when}. Existing measurements, medications, intake events, mood entries, notification channels, push subscriptions and queued Telegram deletions will be deleted. If included in the snapshot, instance-wide settings overwrite the configuration for the entire installation.",
|
||||
"restorePromptLabel": "Type RESTORE to continue",
|
||||
"restoreConfirm": "Restore now",
|
||||
"restoreInProgress": "Restoring…",
|
||||
@@ -6643,9 +6907,9 @@
|
||||
"hypertensionGrade1": "Hypertension Grade 1",
|
||||
"hypertensionGrade2": "Hypertension Grade 2",
|
||||
"hypertensionGrade3": "Hypertension Grade 3",
|
||||
"bradycardia": "Bradycardia",
|
||||
"belowTypicalRange": "Below typical range",
|
||||
"elevated": "Elevated",
|
||||
"tachycardia": "Tachycardia",
|
||||
"aboveTypicalRange": "Above typical range",
|
||||
"significantlyLow": "Significantly low",
|
||||
"slightlyLow": "Slightly low",
|
||||
"onTarget": "On target",
|
||||
@@ -7320,7 +7584,7 @@
|
||||
"referenceLabel": "Reference",
|
||||
"readingsCount": "{count} readings",
|
||||
"deleteConfirmTitle": "Delete lab result?",
|
||||
"deleteConfirmDescription": "This lab result will be permanently deleted.",
|
||||
"deleteConfirmDescription": "This lab result will be deleted. You can undo this for a short moment afterwards.",
|
||||
"deletedToast": "Result deleted",
|
||||
"deleteError": "Could not delete the result",
|
||||
"loadError": "Could not load lab results",
|
||||
@@ -7351,6 +7615,7 @@
|
||||
"notePlaceholder": "Optional context for this result",
|
||||
"requiredError": "Enter a value.",
|
||||
"save": "Save",
|
||||
"saveAndAddAnother": "Save & add another",
|
||||
"savedToast": "Result saved",
|
||||
"saveError": "Could not save the result",
|
||||
"biomarker": "Biomarker",
|
||||
@@ -7798,7 +8063,7 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Delete this episode?",
|
||||
"body": "“{label}” and all its day-logs will be removed. This cannot be undone."
|
||||
"body": "“{label}” and all its day-logs will be removed. You can undo this for a short moment afterwards."
|
||||
},
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "Resting heart rate",
|
||||
@@ -7881,6 +8146,11 @@
|
||||
"review_due": "Check-in due",
|
||||
"reviewed": "Reviewed"
|
||||
}
|
||||
},
|
||||
"refusal": {
|
||||
"outOfScope": "I can only help with the health metrics in your log — measurements, medications, mood, trends. I cannot answer questions outside that scope.",
|
||||
"promptInjection": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.",
|
||||
"conversationPoisoned": "An earlier message in this conversation contains wording that overrides my instructions. Please start a new conversation."
|
||||
}
|
||||
},
|
||||
"safety": {
|
||||
@@ -7892,14 +8162,14 @@
|
||||
"bpLowDoctor": "Two recent readings had a systolic below {systolic} mmHg. If you feel dizzy or faint, sit or lie down, and contact your doctor if it continues.",
|
||||
"bpLowEmergency": "Two recent readings had a systolic below {systolic} mmHg and you noted symptoms. If you feel faint, confused, or have cold clammy skin, call emergency services now.",
|
||||
"glucoseLowTitle": "Low blood sugar",
|
||||
"glucoseLowDoctor": "Two recent readings were below 70 mg/dL ({value} mg/dL). Take fast-acting carbohydrate, re-check in 15 minutes, and contact your doctor if lows continue.",
|
||||
"glucoseLowEmergency": "Two recent readings were below 70 mg/dL ({value} mg/dL) and you noted symptoms. Treat now with fast-acting sugar; if you cannot, or you feel very unwell, call emergency services.",
|
||||
"glucoseLowDoctor": "Two recent readings were below {threshold} {unit} ({value} {unit}). Take fast-acting carbohydrate, re-check in 15 minutes, and contact your doctor if lows continue.",
|
||||
"glucoseLowEmergency": "Two recent readings were below {threshold} {unit} ({value} {unit}) and you noted symptoms. Treat now with fast-acting sugar; if you cannot, or you feel very unwell, call emergency services.",
|
||||
"glucoseLowSevereTitle": "Very low blood sugar",
|
||||
"glucoseLowSevereDoctor": "Two recent readings were below 54 mg/dL ({value} mg/dL). Treat immediately with fast-acting carbohydrate, re-check in 15 minutes, and contact your doctor.",
|
||||
"glucoseLowSevereEmergency": "Two recent readings were below 54 mg/dL ({value} mg/dL) and you noted symptoms. Treat now; if you cannot keep food down or feel very unwell, call emergency services.",
|
||||
"glucoseLowSevereDoctor": "Two recent readings were below {threshold} {unit} ({value} {unit}). Treat immediately with fast-acting carbohydrate, re-check in 15 minutes, and contact your doctor.",
|
||||
"glucoseLowSevereEmergency": "Two recent readings were below {threshold} {unit} ({value} {unit}) and you noted symptoms. Treat now; if you cannot keep food down or feel very unwell, call emergency services.",
|
||||
"glucoseHighTitle": "Very high blood sugar",
|
||||
"glucoseHighDoctor": "Two recent readings were {value} mg/dL or higher. Re-check, hydrate, and contact your doctor as soon as possible if it stays this high.",
|
||||
"glucoseHighEmergency": "Two recent readings were {value} mg/dL or higher and you noted symptoms. If you have vomiting, fast breathing, or confusion, call emergency services now."
|
||||
"glucoseHighDoctor": "Two recent readings were {value} {unit} or higher. Re-check, hydrate, and contact your doctor as soon as possible if it stays this high.",
|
||||
"glucoseHighEmergency": "Two recent readings were {value} {unit} or higher and you noted symptoms. If you have vomiting, fast breathing, or confusion, call emergency services now."
|
||||
}
|
||||
},
|
||||
"version": {
|
||||
@@ -7912,6 +8182,7 @@
|
||||
"mentalHealth": {
|
||||
"pageTitle": "Mental wellbeing",
|
||||
"pageDescription": "Validated, opt-in self-checks — PHQ-9 (mood), GAD-7 (worry), WHO-5 (well-being) and SCI (sleep) — that sit beside your mood tracking. The value is the trend over time, not any single number.",
|
||||
"moodLink": "View your mood history",
|
||||
"attributionLabel": "Attribution",
|
||||
"attributionShow": "Source and licence for {instrument}",
|
||||
"choosePrompt": "Choose a check-in",
|
||||
@@ -8036,6 +8307,12 @@
|
||||
"nummerGegenKummer": {
|
||||
"name": "Nummer gegen Kummer (for young people)"
|
||||
},
|
||||
"telefonSeelsorgeAt": {
|
||||
"name": "Telefonseelsorge Österreich (24/7, free, confidential)"
|
||||
},
|
||||
"dargeboteneHand": {
|
||||
"name": "Die Dargebotene Hand (24/7, free, confidential)"
|
||||
},
|
||||
"krisenchat": {
|
||||
"name": "krisenchat (24/7 chat, young people)"
|
||||
},
|
||||
@@ -8126,7 +8403,9 @@
|
||||
"finally": "Finally …"
|
||||
}
|
||||
},
|
||||
"openDetail": "Show details"
|
||||
"openDetail": "Show details",
|
||||
"historyLoadError": "Your assessment history could not be loaded",
|
||||
"historyLoadErrorHint": "This does not mean you have no results — the list could not be fetched. Try again."
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "These are your own records, not a medical diagnosis. HealthLog does not diagnose, treat, or interpret them.",
|
||||
@@ -8289,7 +8568,9 @@
|
||||
"openLabel": "Open {title}",
|
||||
"attachmentBadge": "Download only",
|
||||
"uploading": "Uploading…",
|
||||
"dismissFailed": "Dismiss failed upload"
|
||||
"dismissFailed": "Dismiss failed upload",
|
||||
"processing": "Processing…",
|
||||
"ready": "Ready"
|
||||
},
|
||||
"error": {
|
||||
"fileTooLarge": "This file is larger than the {maxSize} limit.",
|
||||
@@ -8331,6 +8612,16 @@
|
||||
},
|
||||
"detail": {
|
||||
"title": "Document details",
|
||||
"summary": {
|
||||
"title": "Summary",
|
||||
"pending": "The summary is being prepared.",
|
||||
"none": "No summary has been generated for this document yet.",
|
||||
"withheld": "A summary was generated but did not pass the safety check, so it is not shown. You can try again, or read the document itself.",
|
||||
"unavailable": "No summary could be generated for this document.",
|
||||
"generate": "Generate summary",
|
||||
"regenerate": "Generate again",
|
||||
"generating": "Generating…"
|
||||
},
|
||||
"loadError": "Couldn't load this document.",
|
||||
"saveError": "Couldn't save the change.",
|
||||
"deleteError": "Couldn't delete the document.",
|
||||
@@ -8345,7 +8636,8 @@
|
||||
"noConditions": "Not linked to a condition",
|
||||
"previewUnavailable": "No inline preview for this file type",
|
||||
"uploadedMeta": "Added {date} · {size}",
|
||||
"share": "Share"
|
||||
"share": "Share",
|
||||
"labValuesLink": "View {count} lab values"
|
||||
},
|
||||
"dropOverlay": {
|
||||
"title": "Drop files to upload",
|
||||
@@ -8410,7 +8702,6 @@
|
||||
"close": "Hide"
|
||||
},
|
||||
"ai": {
|
||||
"statusAiRead": "Read by AI",
|
||||
"readDone": "The AI read your document.",
|
||||
"read": "Read with AI",
|
||||
"reread": "Read again",
|
||||
@@ -8566,6 +8857,7 @@
|
||||
"quickAddCustomPlaceholder": "250",
|
||||
"quickAddSubmit": "Add",
|
||||
"quickAddError": "Couldn't save that — try again.",
|
||||
"quickAddSuccess": "{amount} mL water added.",
|
||||
"editTotal": "Edit today's total"
|
||||
},
|
||||
"caffeine": {
|
||||
@@ -8576,14 +8868,15 @@
|
||||
"rowMeta": "{days} of {window} days · ref {value} {unit} (EFSA)",
|
||||
"rowMetaNoRef": "{days} of {window} days",
|
||||
"footer": "{tracked} of {total} tracked micronutrients have data in this window.",
|
||||
"emptyTitle": "No micronutrient data synced yet"
|
||||
"emptyTitle": "No micronutrient data synced yet",
|
||||
"loadError": "Your nutrient data could not be loaded"
|
||||
},
|
||||
"sparseNote": "A low number here usually means sparse logging, not a deficiency — Apple Health day totals reflect what your apps logged, not total dietary intake."
|
||||
},
|
||||
"daily": {
|
||||
"line": {
|
||||
"score": "Your health score today is {score}.",
|
||||
"allClear": "Nothing needs your attention today — everything's tracking normally."
|
||||
"allClear": "Nothing needs your attention today."
|
||||
},
|
||||
"push": {
|
||||
"title": "Your morning briefing",
|
||||
@@ -8592,8 +8885,11 @@
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
"title": "Medication due",
|
||||
"body": "A dose is due today.",
|
||||
"bodyNamed": "{name} is due today."
|
||||
"body": "A dose is due soon.",
|
||||
"bodyNamed": "{name} is due soon.",
|
||||
"overdueTitle": "Medication overdue",
|
||||
"overdueBody": "A dose is past due.",
|
||||
"overdueBodyNamed": "{name} is past due."
|
||||
},
|
||||
"preventiveCare": {
|
||||
"title": "Check-up due",
|
||||
@@ -8607,7 +8903,9 @@
|
||||
"coachCheckin": {
|
||||
"title": "Time to check in on a plan",
|
||||
"body": "It's been about a week since you set this plan — keep it, adjust it, or let it go. No pressure either way.",
|
||||
"bodyPlan": "About a week ago you set: “{plan}”. Keep it, adjust it, or let it go — no pressure either way."
|
||||
"bodyPlan": "About a week ago you set: “{plan}”. Keep it, adjust it, or let it go — no pressure either way.",
|
||||
"adjustPrompt": "I'd like to adjust my check-in plan.",
|
||||
"adjustPromptPlan": "I'd like to adjust this plan: “{plan}”"
|
||||
},
|
||||
"tensionWindow": {
|
||||
"title": "Possible tension today",
|
||||
@@ -8645,7 +8943,8 @@
|
||||
"deltaVsBaseline": "{delta} vs your baseline",
|
||||
"deltaFlat": "In line with your baseline",
|
||||
"sleepPending": "Last night's sleep isn't in yet — today's read is provisional.",
|
||||
"readFullBriefing": "Read the full briefing",
|
||||
"justIn": "Just in",
|
||||
"justInAt": "Just in · {time}",
|
||||
"worthALook": "Worth a look",
|
||||
"allClear": "Nothing needs your attention today.",
|
||||
"ringLink": "Open {metric} details"
|
||||
|
||||
+382
-71
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Guardar",
|
||||
"conflictReloaded": "Recargado desde el servidor. Vuelve a aplicar tu cambio.",
|
||||
"cancel": "Cancelar",
|
||||
"delete": "Eliminar",
|
||||
"undo": "Deshacer",
|
||||
@@ -42,7 +43,72 @@
|
||||
"hidePassword": "Ocultar contraseña",
|
||||
"learnMore": "Más información",
|
||||
"dismiss": "Descartar",
|
||||
"moreInfo": "Más información"
|
||||
"moreInfo": "Más información",
|
||||
"durationHoursMinutes": "{hours} h {minutes} min",
|
||||
"durationMinutes": "{minutes} min"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
"create": {
|
||||
"invalid": "Comprueba los datos de la medición e inténtalo de nuevo."
|
||||
},
|
||||
"duplicate_timestamp": "Ya existe una medición para esta hora."
|
||||
},
|
||||
"mood": {
|
||||
"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."
|
||||
},
|
||||
"update": {
|
||||
"invalid": "Comprueba la entrada de ánimo e inténtalo de nuevo."
|
||||
},
|
||||
"duplicate_timestamp": "Ya existe una entrada de ánimo para esta hora.",
|
||||
"not_found": "Esta entrada de ánimo ya no está disponible.",
|
||||
"ratedFactor": {
|
||||
"out_of_range": "Una valoración está fuera del intervalo permitido."
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"create": {
|
||||
"invalid": "Comprueba el resultado de laboratorio e inténtalo de nuevo."
|
||||
},
|
||||
"result": {
|
||||
"notFound": "Este resultado de laboratorio ya no está disponible."
|
||||
},
|
||||
"biomarker": {
|
||||
"notFound": "Este biomarcador ya no está disponible.",
|
||||
"duplicate": "Ya existe un biomarcador con este nombre.",
|
||||
"create": {
|
||||
"invalid": "Comprueba los datos del biomarcador e inténtalo de nuevo."
|
||||
},
|
||||
"update": {
|
||||
"invalid": "Comprueba los datos del biomarcador e inténtalo de nuevo.",
|
||||
"referenceRangeInvalid": "El valor de referencia inferior no puede superar al superior."
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"invalid": "Comprueba el resultado de laboratorio e inténtalo de nuevo.",
|
||||
"linkedFieldsImmutable": "Edita el biomarcador para cambiar su nombre, unidad, panel o intervalo de referencia.",
|
||||
"qualitativeExpected": "Introduce un resultado de texto para esta lectura cualitativa.",
|
||||
"numericExpected": "Introduce un número para esta lectura numérica.",
|
||||
"referenceRangeInvalid": "El valor de referencia inferior no puede superar al superior."
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"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." },
|
||||
"step": {
|
||||
"rateLimited": "Demasiados intentos. Espera e inténtalo de nuevo.",
|
||||
"invalid": "No se pudo guardar este paso de la introducción.",
|
||||
"completed": "La introducción ya está completada.",
|
||||
"outOfOrder": "Tu progreso ha cambiado. Actualiza la página y continúa.",
|
||||
"concurrent": "Tu progreso ha cambiado. Actualiza la página y continúa."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Informe de salud",
|
||||
@@ -91,7 +157,7 @@
|
||||
"colShare": "Porcentaje",
|
||||
"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: Europe/Berlin | Creado: {timestamp}",
|
||||
"footerSource": "Fuente: HealthLog | Zona horaria: {timezone} | Creado: {timestamp}",
|
||||
"typeWeight": "Peso corporal",
|
||||
"typeBpSys": "Tensión arterial sistólica",
|
||||
"typeBpDia": "Tensión arterial diastólica",
|
||||
@@ -228,7 +294,7 @@
|
||||
"logout": "Cerrar sesión",
|
||||
"login": "Iniciar sesión",
|
||||
"notifications": "Notificaciones",
|
||||
"theme": "Apariencia",
|
||||
"theme": "Tema",
|
||||
"themeSystem": "Sistema",
|
||||
"themeDark": "Oscuro",
|
||||
"themeLight": "Claro",
|
||||
@@ -253,7 +319,9 @@
|
||||
"medication": "Medicamento",
|
||||
"medicationDescription": "Marcar una dosis como tomada",
|
||||
"mood": "Estado de ánimo",
|
||||
"moodDescription": "Registrar estado de ánimo y etiquetas"
|
||||
"moodDescription": "Registra tu estado de ánimo y etiquetas",
|
||||
"water": "Agua",
|
||||
"waterDescription": "Añade rápidamente tu consumo de agua"
|
||||
},
|
||||
"cycle": "Ciclo",
|
||||
"labs": "Análisis",
|
||||
@@ -295,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.",
|
||||
@@ -356,6 +427,7 @@
|
||||
"quickAddMeasurement": "Registrar medición",
|
||||
"quickAddMood": "Registrar estado de ánimo",
|
||||
"quickAddMedicationIntake": "Registrar toma",
|
||||
"quickAddWater": "Registrar agua",
|
||||
"customizeDashboard": "Personalizar panel",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Registrar toma de medicación",
|
||||
@@ -385,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",
|
||||
@@ -400,12 +473,13 @@
|
||||
"staleHint": "Último valor hace {count} d",
|
||||
"staleHintWeeksOne": "Último valor hace 1 semana",
|
||||
"staleHintWeeksOther": "Último valor hace {count} semanas",
|
||||
"staleHintWeeksFew": "Último valor hace {count} semanas",
|
||||
"staleHintMonthsOne": "Último valor hace 1 mes",
|
||||
"staleHintMonthsOther": "Último valor hace {count} meses",
|
||||
"staleHintMonthsFew": "Último valor hace {count} meses",
|
||||
"recentWorkouts": {
|
||||
"title": "Entrenamientos recientes",
|
||||
"viewAll": "Ver todos",
|
||||
"loading": "Cargando entrenamientos…",
|
||||
"empty": {
|
||||
"title": "Aún no hay entrenamientos",
|
||||
"cta": "Abre la app Apple Salud en el iPhone para sincronizar los entrenamientos."
|
||||
@@ -422,7 +496,9 @@
|
||||
"steps": "Pasos",
|
||||
"totalBodyWater": "Agua corporal total",
|
||||
"boneMass": "Masa ósea",
|
||||
"oxygenSaturation": "Saturación de oxígeno"
|
||||
"oxygenSaturation": "Saturación de oxígeno",
|
||||
"mood": "Estado de ánimo",
|
||||
"bmi": "IMC"
|
||||
},
|
||||
"unit": {
|
||||
"weight": "kg",
|
||||
@@ -434,7 +510,9 @@
|
||||
"steps": "pasos",
|
||||
"totalBodyWater": "kg",
|
||||
"boneMass": "kg",
|
||||
"oxygenSaturation": "%"
|
||||
"oxygenSaturation": "%",
|
||||
"mood": "/5",
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -575,7 +653,7 @@
|
||||
"bulkDeleteSuccess": "{count} mediciones eliminadas",
|
||||
"bulkDeleteError": "Error al eliminar las mediciones seleccionadas",
|
||||
"bulkDeleteConfirmTitle": "¿Eliminar {count} mediciones?",
|
||||
"bulkDeleteConfirmBody": "Las {count} mediciones seleccionadas se eliminarán de forma permanente.",
|
||||
"bulkDeleteConfirmBody": "Las {count} mediciones seleccionadas se eliminarán. Justo después podrás deshacerlo durante un breve momento.",
|
||||
"typePhq9Score": "Puntuación PHQ-9",
|
||||
"typeGad7Score": "Puntuación GAD-7",
|
||||
"typeGripStrength": "Fuerza de agarre",
|
||||
@@ -587,9 +665,27 @@
|
||||
"typeWho5Score": "Puntuación WHO-5",
|
||||
"typeSciScore": "Puntuación SCI"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Lo que HealthLog puede registrar",
|
||||
"description": "Todos los ámbitos de métricas que admite HealthLog, tengas datos o no todavía, y qué los proporciona.",
|
||||
"statusPresent": "Registrado",
|
||||
"statusAbsent": "Aún sin registrar",
|
||||
"statusModuleOff": "Desactivado",
|
||||
"loadError": "No se pudo cargar el estado de seguimiento.",
|
||||
"connectCta": "Conectar una fuente",
|
||||
"viewCta": "Ver",
|
||||
"ecg": {
|
||||
"source": "Tiras de ECG de una derivación desde un reloj compatible — hoy, Withings ScanWatch — sincronizadas automáticamente."
|
||||
},
|
||||
"watchdog": {
|
||||
"title": "Supervisión automática",
|
||||
"description": "HealthLog también vigila en segundo plano — notificaciones de ritmo irregular y alteraciones respiratorias de un reloj conectado, alertas de desviación de tu valor de referencia, y cambios destacables entre tus dos últimos paneles de laboratorio — y solo muestra una tarjeta cuando algo destaca. Si al menos una de esas fuentes está conectada, que no aparezca ninguna tarjeta es una buena señal, no una función ausente; si todavía no hay ninguna conectada, sencillamente no se está vigilando nada."
|
||||
}
|
||||
},
|
||||
"mood": {
|
||||
"title": "Estado de ánimo",
|
||||
"subtitle": "Tendencia y registros de estado de ánimo",
|
||||
"mentalWellbeingLink": "Hacer un chequeo de bienestar mental",
|
||||
"addEntry": "Añadir",
|
||||
"editEntry": "Editar entrada",
|
||||
"moodLevel": "Estado de ánimo",
|
||||
@@ -705,7 +801,7 @@
|
||||
"bulkDeleteSuccess": "{count} entradas eliminadas",
|
||||
"bulkDeleteError": "Error al eliminar las entradas seleccionadas",
|
||||
"bulkDeleteConfirmTitle": "¿Eliminar {count} entradas?",
|
||||
"bulkDeleteConfirmBody": "Las {count} entradas de estado de ánimo seleccionadas se eliminarán de forma permanente.",
|
||||
"bulkDeleteConfirmBody": "Las {count} entradas de estado de ánimo seleccionadas se eliminarán. Justo después podrás deshacerlo durante un breve momento.",
|
||||
"customize": "Personalizar etiquetas de ánimo",
|
||||
"addTagInline": "Añadir etiqueta",
|
||||
"manage": {
|
||||
@@ -1403,7 +1499,10 @@
|
||||
"confirmAction": "Eliminar",
|
||||
"toast": "Toma eliminada",
|
||||
"failed": "No se pudo eliminar la toma."
|
||||
}
|
||||
},
|
||||
"viewRecent90": "Últimos 90 días",
|
||||
"viewAll": "Historial completo",
|
||||
"viewToggleLabel": "Rango del historial"
|
||||
},
|
||||
"verlauf": {
|
||||
"loading": "Cargando historial …",
|
||||
@@ -1659,7 +1758,10 @@
|
||||
"packagingDefaultPackLabel": "Tamaño de envase predeterminado (unidades)",
|
||||
"packagingDefaultPackHelper": "Rellena la cantidad al registrar envases nuevos (1–1000). Déjalo vacío si no aplica.",
|
||||
"packagingSuccess": "Datos del envase guardados",
|
||||
"packagingFailed": "Error al guardar"
|
||||
"packagingFailed": "Error al guardar",
|
||||
"addManufacturerLabel": "Fabricante",
|
||||
"addDoseStrengthLabel": "Concentración",
|
||||
"addDoseStrengthHint": "Tal como aparece impreso en el envase, p. ej. 5 mg/0,5 ml."
|
||||
},
|
||||
"erweitert": {
|
||||
"group": {
|
||||
@@ -1785,13 +1887,14 @@
|
||||
"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?",
|
||||
"placeholder": "Seguir según…",
|
||||
"apply": "Aplicar",
|
||||
"reset": "Restablecer al valor predeterminado"
|
||||
"reset": "Restablecer al valor predeterminado",
|
||||
"error": "No se pudo guardar ese cambio. Inténtalo de nuevo."
|
||||
},
|
||||
"beforeAfter": {
|
||||
"summary": "Ø {before} antes → Ø {after} desde entonces: un cambio de {delta}.",
|
||||
@@ -1806,7 +1909,10 @@
|
||||
"subtitle": "Lo que coincide con las mediciones que persiguen tus medicamentos: solo asociación, nunca un veredicto.",
|
||||
"link": "Abrir la vista de efecto",
|
||||
"empty": "Aún no hay suficientes datos para relacionar tus medicamentos con sus mediciones objetivo."
|
||||
}
|
||||
},
|
||||
"scaleAbsolute": "Absoluto",
|
||||
"scalePercentSinceStart": "% desde el inicio",
|
||||
"scaleToggleLabel": "Escala del gráfico"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -1955,6 +2061,61 @@
|
||||
"notAvailableForCompliance": "Comparación no disponible para el cumplimiento"
|
||||
},
|
||||
"insights": {
|
||||
"correlation": {
|
||||
"direction": {
|
||||
"higher": "subir",
|
||||
"lower": "bajar"
|
||||
},
|
||||
"weekdayDirection": {
|
||||
"above": "por encima de",
|
||||
"below": "por debajo de"
|
||||
},
|
||||
"channelFactor": "la valoración de {factor}",
|
||||
"channel": {
|
||||
"WEIGHT": "el peso",
|
||||
"BLOOD_GLUCOSE": "la glucosa en sangre",
|
||||
"BLOOD_PRESSURE_SYS": "la presión sistólica",
|
||||
"BLOOD_PRESSURE_DIA": "la presión diastólica",
|
||||
"ACTIVITY_STEPS": "los pasos diarios",
|
||||
"SLEEP_DURATION": "la duración del sueño",
|
||||
"HEART_RATE_VARIABILITY": "la variabilidad de la frecuencia cardíaca",
|
||||
"RESTING_HEART_RATE": "la frecuencia cardíaca en reposo",
|
||||
"TIME_IN_DAYLIGHT": "el tiempo con luz natural",
|
||||
"MOOD": "el estado de ánimo",
|
||||
"PULSE": "el pulso en reposo",
|
||||
"MEDICATION_COMPLIANCE": "la adherencia a la medicación",
|
||||
"SYMPTOM_SEVERITY": "la intensidad de los síntomas",
|
||||
"ENV_TEMP_MEAN": "la temperatura diaria",
|
||||
"ENV_TEMP_MIN": "la temperatura mínima nocturna",
|
||||
"ENV_SUNSHINE": "las horas de sol",
|
||||
"ENV_DAYLIGHT": "la luz natural",
|
||||
"ENV_PRECIP": "las precipitaciones",
|
||||
"ENV_PRESSURE_MEAN": "la presión atmosférica",
|
||||
"ENV_PRESSURE_DELTA": "la variación de presión del día"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Cuando {behaviour} es mayor, {outcome} del día siguiente tiende a {direction} en tus datos — un patrón que conviene observar, no una causa.",
|
||||
"moderate": "Cuando {behaviour} es mayor, {outcome} del día siguiente parece tender a {direction}, según los datos disponibles — un patrón que conviene observar, no una causa.",
|
||||
"faint": "Cuando {behaviour} es mayor, apenas se insinúa —si acaso— que {outcome} del día siguiente tienda a {direction} en tus datos — demasiado leve para fiarse, nunca una causa."
|
||||
},
|
||||
"lab": {
|
||||
"default": "Unos valores de {lab} más altos coinciden con que {outcome} tienda a {direction} en los mismos periodos de tus datos — una asociación que conviene vigilar con tu médico, nunca una causa.",
|
||||
"faint": "Unos valores de {lab} más altos apenas insinúan —si acaso— que {outcome} tienda a {direction} en los mismos periodos de tus datos — demasiado leve para fiarse, una asociación que conviene comentar con tu médico, nunca una causa."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "Una mayor adherencia a la medicación coincide con valores sistólicos más bajos — un patrón que conviene observar.",
|
||||
"surprising": "La adherencia y la sistólica se mueven juntas en tus datos — sorprendente; consulta con tu médico antes de cambiar nada.",
|
||||
"none": "La adherencia y la sistólica no se mueven juntas de forma marcada en este periodo."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "Los días de peor ánimo coinciden con un pulso en reposo más alto — un patrón que conviene observar.",
|
||||
"surprising": "Los días de mejor ánimo coinciden con un pulso en reposo más alto en tus datos — sorprendente.",
|
||||
"none": "El ánimo y el pulso en reposo no se mueven juntos de forma marcada en este periodo."
|
||||
},
|
||||
"weightWeekday": "El peso de los {weekday} está {delta} kg {direction} tu media de los demás días — un patrón que conviene observar."
|
||||
}
|
||||
},
|
||||
"noAnalysisYet": "Aún no hay valoración.",
|
||||
"assessmentPreparing": "Preparando tu valoración…",
|
||||
"emptyTitle": "Aún no hay datos suficientes",
|
||||
@@ -2193,6 +2354,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",
|
||||
@@ -2240,7 +2402,16 @@
|
||||
"historySearchPlaceholder": "Buscar en las conversaciones…",
|
||||
"historyEmpty": "Tus conversaciones aparecen aquí. Lanza una pregunta para empezar.",
|
||||
"historyDeleteAria": "Eliminar conversación",
|
||||
"historyDeleteConfirm": "¿Eliminar esta conversación? No se puede deshacer.",
|
||||
"historyDeleted": "Conversación eliminada",
|
||||
"rename": {
|
||||
"action": "Renombrar conversación",
|
||||
"input": "Título de la conversación",
|
||||
"save": "Guardar título",
|
||||
"cancel": "Cancelar cambio de nombre",
|
||||
"error": "No se pudo guardar el título de la conversación."
|
||||
},
|
||||
"historySearchEmpty": "Ninguna conversación coincide con tu búsqueda.",
|
||||
"historyLoadingMore": "Cargando más…",
|
||||
"sourcesTitle": "A qué accedo",
|
||||
"sourcesFresh": "Actuales",
|
||||
"sourcesStale": "Desactualizados",
|
||||
@@ -2377,10 +2548,6 @@
|
||||
"settingsClustersHint": "Elige qué grupos de datos puede leer el Coach. Más datos significan respuestas más ricas y un prompt mayor.",
|
||||
"settingsClusterNoData": "Aún no hay datos",
|
||||
"sourcesMemberCount": "{count} métricas",
|
||||
"dictate": "Dictar",
|
||||
"dictateStop": "Detener el dictado",
|
||||
"voiceUnsupported": "La entrada de voz no es compatible con este navegador",
|
||||
"dictateError": "No se pudo iniciar la entrada de voz. Comprueba el permiso del micrófono e inténtalo de nuevo.",
|
||||
"showConversations": "Mostrar conversaciones",
|
||||
"hideConversations": "Ocultar conversaciones",
|
||||
"heroGreeting": "Pregúntame lo que quieras sobre tus datos",
|
||||
@@ -2470,17 +2637,22 @@
|
||||
"documentsAttachedOne": "1 documento adjunto",
|
||||
"documentsAttachedOther": "{count} documentos adjuntos",
|
||||
"railBadge": "Chat de documento aislado",
|
||||
"uploadError": "Error al subir. Inténtalo de nuevo."
|
||||
"uploadError": "Error al subir. Inténtalo de nuevo.",
|
||||
"notFoundError": "Ese enlace de documento ya no es válido: se iniciará un chat normal."
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "ahora mismo",
|
||||
"relativeMinutesAgoOne": "hace {count} minuto",
|
||||
"relativeMinutesAgoOther": "hace {count} minutos",
|
||||
"relativeMinutesAgoFew": "hace {count} minutos",
|
||||
"relativeHoursAgoOne": "hace {count} hora",
|
||||
"relativeHoursAgoOther": "hace {count} horas",
|
||||
"relativeHoursAgoFew": "hace {count} horas",
|
||||
"relativeDaysAgoOne": "hace {count} día",
|
||||
"relativeDaysAgoOther": "hace {count} días",
|
||||
"relativeDaysAgoFew": "hace {count} días",
|
||||
"navOverview": "Resumen",
|
||||
"navCatalog": "Todas las métricas",
|
||||
"navBloodPressure": "Presión arterial",
|
||||
"navWeight": "Peso",
|
||||
"navPulse": "Pulso",
|
||||
@@ -2511,9 +2683,9 @@
|
||||
"label": "Actividad",
|
||||
"header": "Actividad y movilidad"
|
||||
},
|
||||
"cardiovascularParent": {
|
||||
"label": "Cardiovascular",
|
||||
"header": "Marcadores cardiovasculares"
|
||||
"heartParent": {
|
||||
"label": "Corazón",
|
||||
"header": "Salud cardíaca"
|
||||
},
|
||||
"hearingParent": {
|
||||
"label": "Audición",
|
||||
@@ -2638,11 +2810,11 @@
|
||||
"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.",
|
||||
"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, por lo que cuanto más baja, mejor.",
|
||||
"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",
|
||||
@@ -2672,7 +2844,7 @@
|
||||
"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, y el tiempo al aire libre se asocia con la salud ocular.",
|
||||
"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",
|
||||
@@ -2684,7 +2856,7 @@
|
||||
"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.",
|
||||
"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",
|
||||
@@ -2732,7 +2904,8 @@
|
||||
},
|
||||
"valuesBack": "Volver a Insights",
|
||||
"valuesBackToMetric": "Volver a {metric}",
|
||||
"scoresBack": "Volver a Insights"
|
||||
"scoresBack": "Volver a Insights",
|
||||
"ecgDescription": "Registros de ECG de una derivación de tu dispositivo, cada uno con el resultado del propio dispositivo. HealthLog muestra el trazado y el registro, pero no interpreta la onda."
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sueño",
|
||||
@@ -2771,9 +2944,9 @@
|
||||
"debt": {
|
||||
"title": "Deuda de sueño",
|
||||
"value": "{hours} h {minutes} min de déficit",
|
||||
"caption": "Déficit acumulado de las últimas {nights} noches",
|
||||
"caption": "Balance pendiente de las últimas {nights} noches",
|
||||
"clearValue": "Al día",
|
||||
"clearCaption": "Sin déficit acumulado en las últimas {nights} noches",
|
||||
"clearCaption": "Nada pendiente en las últimas {nights} noches",
|
||||
"learning": "Aún estamos midiendo tu sueño — {counted} noches hasta ahora, faltan {remaining} para un balance fiable.",
|
||||
"computedInfo": "Esta es la estimación propia de HealthLog: un balance móvil de la necesidad menos el sueño de las últimas noches, que el sueño de recuperación va reduciendo. Puede diferir de la cifra de deuda de sueño nativa de un wearable, que usa su propio modelo de necesidad."
|
||||
},
|
||||
@@ -2870,13 +3043,15 @@
|
||||
"sleep": {
|
||||
"title": "Aún no hay registros de sueño",
|
||||
"description": "Una vez disponibles los datos de sueño (manual o vía Withings / Apple Health), aquí aparecerá el desglose por fases.",
|
||||
"cta": "Conectar Withings"
|
||||
"cta": "Conectar una fuente"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "Entrenamientos",
|
||||
"navRecovery": "Recuperación",
|
||||
"workouts": {
|
||||
"title": "Entrenamientos",
|
||||
"askWhy": "Por qué",
|
||||
"askWhyPrompt": "¿Por qué salió así esta sesión? Explícame mis propios datos de ella.",
|
||||
"description": "Carreras, salidas en bici, paseos y sesiones de fuerza recientes, deduplicados entre Apple Watch y Withings.",
|
||||
"emptyState": {
|
||||
"title": "Aún no hay entrenamientos",
|
||||
@@ -2905,7 +3080,24 @@
|
||||
"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…"
|
||||
"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:",
|
||||
"zonesTitle": "Zonas de esfuerzo",
|
||||
"zonesCaption": "Cómo se distribuyó tu esfuerzo entre las zonas de frecuencia cardíaca.",
|
||||
"zonesCaptionDevice": "Tiempo por zona según tu dispositivo.",
|
||||
"exportGpx": "Exportar GPX",
|
||||
"elevationTitle": "Altitud",
|
||||
"splitsTitle": "Parciales",
|
||||
"splitsKm": "km",
|
||||
"splitsTime": "Tiempo",
|
||||
"splitsPace": "Ritmo",
|
||||
"thatDayTitle": "Ese día",
|
||||
"dayPulse": "Pulso",
|
||||
"daySleep": "Sueño",
|
||||
"dayMood": "Ánimo",
|
||||
"insightTitle": "Análisis de la actividad"
|
||||
},
|
||||
"sport": {
|
||||
"walking": "Caminar",
|
||||
@@ -2928,7 +3120,11 @@
|
||||
"crossTraining": "Entreno cruzado",
|
||||
"mixedCardio": "Cardio mixto",
|
||||
"other": "Otro"
|
||||
}
|
||||
},
|
||||
"loadError": "No se pudieron cargar tus entrenamientos",
|
||||
"loadMore": "Cargar más entrenamientos",
|
||||
"loadingMore": "Cargando más entrenamientos",
|
||||
"loadMoreError": "No se pudieron cargar más entrenamientos"
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Glucosa en sangre",
|
||||
@@ -3238,6 +3434,8 @@
|
||||
},
|
||||
"navWalkingSpeed": "Velocidad al andar",
|
||||
"mood": {
|
||||
"logLink": "Registrar un estado de ánimo",
|
||||
"mentalWellbeingLink": "Hacer un chequeo de bienestar mental",
|
||||
"heatmapTitle": "Calendario de ánimo",
|
||||
"heatmapNoEntry": "Sin registro",
|
||||
"legendGreat": "Genial",
|
||||
@@ -3525,7 +3723,9 @@
|
||||
"sectionTitle": "Tus indicadores de salud",
|
||||
"sectionSubtitle": "Valores combinados de recuperación, estrés y esfuerzo leídos de tus dispositivos conectados",
|
||||
"loadError": "No se pudieron cargar tus puntuaciones de bienestar en este momento.",
|
||||
"recovery": "Recuperación",
|
||||
"loadingLabel": "Cargando tus indicadores de salud",
|
||||
"recovery": "Puntuación de recuperación",
|
||||
"recoveryCrossLink": "Ver los datos de recuperación y esfuerzo de tu wearable",
|
||||
"stress": "Estrés",
|
||||
"strain": "Esfuerzo"
|
||||
},
|
||||
@@ -3696,6 +3896,7 @@
|
||||
"recovery": {
|
||||
"title": "Recuperación",
|
||||
"description": "Cómo se ha recuperado tu cuerpo: las señales de recuperación, esfuerzo y carga que tu wearable registra durante la noche.",
|
||||
"scoreCrossLink": "Ver la puntuación de recuperación combinada",
|
||||
"emptyTitle": "Aún no hay datos de recuperación",
|
||||
"empty": "Aún no hay datos de recuperación. Conecta una cuenta de WHOOP, Polar u Oura y estas puntuaciones se completarán cuando tu dispositivo sincronice.",
|
||||
"block": {
|
||||
@@ -3866,13 +4067,18 @@
|
||||
"recordingsMany": "{count} registros guardados.",
|
||||
"latestResult": "Último resultado del dispositivo: {result}.",
|
||||
"cta": "Ver registros"
|
||||
}
|
||||
},
|
||||
"emptyTitle": "Aún no hay registros de ECG",
|
||||
"emptyDescription": "Los registros de ECG se sincronizan desde un dispositivo de una derivación compatible. En cuanto tu dispositivo suba un registro, aparecerá aquí.",
|
||||
"emptyCta": "Conectar un dispositivo"
|
||||
},
|
||||
"intradayPulse": {
|
||||
"title": "La forma de tu día",
|
||||
"caption": "Tu frecuencia cardíaca a lo largo del día, promediada en tramos de 10 minutos.",
|
||||
"hourlyNote": "Mostrando el promedio por hora de este día — los días recientes muestran el detalle completo de 10 minutos.",
|
||||
"empty": "Aún no hay suficientes lecturas para este día para mostrar su forma.",
|
||||
"coverageOne": "Basado en 1 lectura a lo largo de {hours}h.",
|
||||
"coverageMany": "Basado en {count} lecturas a lo largo de {hours}h.",
|
||||
"baseline": "Reposo",
|
||||
"today": "Hoy",
|
||||
"previousDay": "Día anterior",
|
||||
@@ -3883,7 +4089,8 @@
|
||||
"evening": "Un tramo elevado en reposo al anochecer; posiblemente algo de tensión.",
|
||||
"night": "Un tramo elevado en reposo por la noche; posiblemente algo de tensión."
|
||||
}
|
||||
}
|
||||
},
|
||||
"navEcg": "ECG"
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Por defecto",
|
||||
@@ -3982,7 +4189,7 @@
|
||||
},
|
||||
"wrapUp": {
|
||||
"title": "Esto es la visita",
|
||||
"body": "Puedes volver a ver cualquier módulo por separado cuando quieras («Mostrar esta visita» en cada página) o reiniciar la visita completa en Ajustes → Avanzado."
|
||||
"body": "Esta visita guiada se muestra una sola vez, no volverá a abrirse por sí sola. Todo lo que has visto sigue disponible, y puedes activar o desactivar funciones cuando quieras en Ajustes → Módulos."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4010,7 +4217,7 @@
|
||||
},
|
||||
"source": {
|
||||
"title": "Conectar una fuente",
|
||||
"body": "Importa las mediciones automáticamente o sigue con la entrada manual. Aquí no se configura nada por ti: cada tarjeta solo muestra dónde conectarla.",
|
||||
"body": "Importa las mediciones automáticamente o sigue con la entrada manual. Aquí no se configura nada por ti: cada tarjeta solo muestra dónde conectarla. Las fuentes de dispositivo necesitan una app de desarrollador gratuita en tu propia cuenta (unos 5 minutos); la guía de cada tarjeta te lo explica.",
|
||||
"recommended": "Recomendado",
|
||||
"manual": {
|
||||
"title": "Entrada manual",
|
||||
@@ -4018,12 +4225,14 @@
|
||||
},
|
||||
"withings": {
|
||||
"title": "Withings",
|
||||
"body": "Las básculas, los tensiómetros y los trackers de Withings se sincronizan automáticamente.",
|
||||
"cta": "Conectar Withings"
|
||||
"body": "Las básculas, los tensiómetros y los trackers de Withings se sincronizan automáticamente una vez conectados.",
|
||||
"cta": "Conectar Withings",
|
||||
"setupCta": "Configurar en Ajustes",
|
||||
"setupHint": "Necesita primero tu propia app de desarrollador de Withings, gratuita — unos 5 minutos, una sola vez."
|
||||
},
|
||||
"more": {
|
||||
"title": "Más fuentes",
|
||||
"body": "Conecta cualquiera de ellas en Ajustes cuando quieras.",
|
||||
"body": "Conecta cualquiera de ellas en Ajustes cuando quieras — la mayoría necesita antes una app de desarrollador gratuita en tu propia cuenta.",
|
||||
"appleHealth": "Apple Health",
|
||||
"whoop": "WHOOP",
|
||||
"oura": "Oura",
|
||||
@@ -4055,7 +4264,8 @@
|
||||
"learning": "Algunas conclusiones —glucosa, ritmo de sueño, tendencias de presión arterial, ciclo— se afinan durante la primera semana o dos a medida que se acumulan tus datos. Las casillas vacías ahora solo significan que aún estamos aprendiendo tu línea base.",
|
||||
"connectCta": "Conectar un dispositivo",
|
||||
"logCta": "Registra tu primera medición",
|
||||
"importCta": "Importar historial existente"
|
||||
"importCta": "Importar historial existente",
|
||||
"modulesCta": "Elige tus módulos"
|
||||
},
|
||||
"welcomeBack": {
|
||||
"title": "Bienvenido de nuevo",
|
||||
@@ -4165,7 +4375,16 @@
|
||||
"disableError": "No se pudo desactivar el modo Investigación. Inténtalo de nuevo."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Secciones de ajustes"
|
||||
"sectionsNav": "Secciones de ajustes",
|
||||
"groups": {
|
||||
"account": "Cuenta",
|
||||
"yourData": "Tus datos",
|
||||
"display": "Visualización",
|
||||
"healthProfile": "Perfil de salud",
|
||||
"ai": "IA",
|
||||
"connectivity": "Conectividad",
|
||||
"system": "Sistema"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
@@ -4350,7 +4569,8 @@
|
||||
},
|
||||
"fullBackup": {
|
||||
"title": "Copia completa",
|
||||
"description": "Un único archivo JSON con todo — el mismo formato que la copia automática semanal. Útil para restaurar tú mismo mediante la carga de administrador.",
|
||||
"description": "Todos tus datos en un único archivo JSON: mediciones, medicamentos, estado de ánimo, ciclo, analíticas, episodios de enfermedad, alergias, antecedentes familiares y entrenamientos. Mismo formato que la copia automática semanal.",
|
||||
"scopeNote": "Los metadatos de los documentos y el resumen de IA, si existe, se incluyen en texto plano; los archivos originales de los documentos y los datos de rutas GPS/muestras de los entrenamientos no están incluidos — descárgalos por separado desde el archivo de documentos o la exportación de entrenamientos.",
|
||||
"encryptToggle": "Cifrar con una contraseña",
|
||||
"passphrasePlaceholder": "Contraseña (mín. 12 caracteres)",
|
||||
"passphraseHelp": "Deriva la clave del archivo con Argon2id y lo sella con AES-256-GCM. Guarda el archivo con la extensión .hlx.",
|
||||
@@ -4381,6 +4601,7 @@
|
||||
"failed": "La importación falló.",
|
||||
"rowsImported": "{count} filas importadas hasta ahora",
|
||||
"doneSummary": "Listo: {imported} de {read} registros importados, {skipped} registros clínicos omitidos.",
|
||||
"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…",
|
||||
"unpacking": "Descomprimiendo el archivo…",
|
||||
@@ -4510,7 +4731,7 @@
|
||||
"description": "Registros estructurados de alergias y antecedentes familiares."
|
||||
},
|
||||
"environment": {
|
||||
"title": "Entorno",
|
||||
"title": "Ubicación y clima",
|
||||
"subtitle": "Contexto de tiempo, luz diurna y ubicación.",
|
||||
"disabledHint": "Activa el módulo Entorno para definir una ubicación principal e incorporar el tiempo y las horas de luz diarias.",
|
||||
"searchPlaceholder": "Buscar una ciudad",
|
||||
@@ -4539,7 +4760,9 @@
|
||||
"description": "Añade un lugar y un rango de fechas: un viaje o un tramo de tu historial anterior. El periodo anula tu hogar en los días que cubre, para que el tiempo coincida con donde estuviste realmente. Así corriges el pasado.",
|
||||
"none": "Aún no hay periodos de ubicación.",
|
||||
"add": "Añadir {label}",
|
||||
"remove": "Eliminar periodo de ubicación"
|
||||
"remove": "Eliminar periodo de ubicación",
|
||||
"removeConfirmTitle": "¿Quitar este viaje?",
|
||||
"removeConfirmBody": "Las mediciones de ese periodo permanecen; solo desaparece la entrada del viaje."
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Rellenar días pasados",
|
||||
@@ -4820,7 +5043,10 @@
|
||||
"surfaced": "Mostrado",
|
||||
"done": "Hecho",
|
||||
"dismissed": "Descartado"
|
||||
}
|
||||
},
|
||||
"deleteConfirmTitle": "¿Eliminar este recordatorio?",
|
||||
"deleteConfirmBody": "Deja de aparecer. Lo que ya has registrado no cambia.",
|
||||
"deleteConfirmAction": "Eliminar"
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "Sobre mí",
|
||||
@@ -4866,6 +5092,16 @@
|
||||
"honesty": "El acceso compartido es una única cuenta de IA con sesión iniciada que el operador conectó para todos en este servidor, no tu propia clave. Las solicitudes que hagas se usan de forma predeterminada para entrenar los modelos del proveedor y ningún acuerdo de tratamiento de datos las cubre, los límites por hora y semanales de la cuenta se comparten con todos los demás que se han unido, y tus propios proveedores se prueban primero. Actívalo solo si aceptas este compromiso.",
|
||||
"confirm": "Entendido: usar la conexión compartida",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"consent": {
|
||||
"title": "Consentimiento de IA",
|
||||
"activeSince": "Otorgado el {date}. Tus datos de salud pueden enviarse al proveedor configurado para las funciones de IA.",
|
||||
"withdrawn": "Retirado. No se envían datos de salud a ningún proveedor de IA; las funciones de IA permanecen desactivadas.",
|
||||
"withdraw": "Retirar el consentimiento",
|
||||
"confirmBody": "Las funciones de IA dejan de funcionar de inmediato. No se borra nada de lo ya registrado: usa Datos y privacidad para exportar o eliminar tu historial.",
|
||||
"confirmWithdraw": "Retirar",
|
||||
"regrantHint": "Para volver a usar las funciones de IA, activa la lectura automática de documentos u otorga el consentimiento desde la aplicación.",
|
||||
"error": "No se pudo guardar la retirada. Inténtalo de nuevo."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -5052,7 +5288,8 @@
|
||||
"resumeError": "Reconexión fallida"
|
||||
},
|
||||
"apiTokens": "Tokens de API",
|
||||
"apiTokensDescription": "Crea tokens de API para registrar tomas de medicación externas (p. ej., Atajos, automatizaciones).",
|
||||
"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.",
|
||||
"tokenNamePlaceholder": "Nombre del token (p. ej., Atajo de iPhone)",
|
||||
"tokenCreated": "Token creado — ¡cópialo ahora! No se mostrará de nuevo.",
|
||||
"tokenCopy": "Copiar token",
|
||||
@@ -5312,7 +5549,7 @@
|
||||
"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",
|
||||
@@ -5383,6 +5620,12 @@
|
||||
"ouraDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
|
||||
"nightscoutDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la",
|
||||
"integrationCredentialsHint": "Dónde encontrar estas credenciales y cómo registrar tu propia aplicación se explica en la guía de configuración de arriba.",
|
||||
"integrationRedirectGuide": {
|
||||
"intro": "Esto se conecta mediante tu propia app de desarrollador de {provider} — unos 5 minutos de configuración:",
|
||||
"step1": "Crea una app en el portal de desarrolladores de {provider}.",
|
||||
"step2": "Configura su URL de redirección/callback en:",
|
||||
"step3": "Pega arriba el ID de cliente y el secreto de cliente que te da."
|
||||
},
|
||||
"security": {
|
||||
"sessionsTitle": "Sesiones activas",
|
||||
"sessionsDescription": "Dispositivos y navegadores con sesión iniciada en tu cuenta.",
|
||||
@@ -5478,8 +5721,16 @@
|
||||
"revokeAllDone": "Se olvidaron {count} dispositivo(s) de confianza.",
|
||||
"unnamed": "Dispositivo desconocido",
|
||||
"current": "Este dispositivo",
|
||||
"expires": "De confianza hasta el {date}"
|
||||
}
|
||||
"expires": "De confianza hasta el {date}",
|
||||
"revokeConfirmTitle": "¿Dejar de confiar en este dispositivo?",
|
||||
"revokeConfirmBody": "Volverá a pedir tu segundo factor la próxima vez que inicies sesión desde él.",
|
||||
"revokeAllConfirmTitle": "¿Dejar de confiar en todos los dispositivos?",
|
||||
"revokeAllConfirmBody": "Todos los dispositivos volverán a pedir tu segundo factor la próxima vez que inicies sesión, incluido este."
|
||||
},
|
||||
"revokeSessionConfirmTitle": "¿Cerrar la sesión de este dispositivo?",
|
||||
"revokeSessionConfirmBody": "{device} tendrá que iniciar sesión de nuevo. Lo registrado en él no se ve afectado.",
|
||||
"signOutEverywhereConfirmTitle": "¿Cerrar la sesión en los demás dispositivos?",
|
||||
"signOutEverywhereConfirmBody": "Todos los dispositivos salvo este tendrán que iniciar sesión de nuevo. Este seguirá con la sesión abierta. Nada de lo que has registrado se ve afectado."
|
||||
},
|
||||
"privacy": {
|
||||
"intro": {
|
||||
@@ -5579,10 +5830,34 @@
|
||||
"rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.",
|
||||
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
|
||||
},
|
||||
"appleHealth": {
|
||||
"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"
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin no tiene conexión directa aquí. Conéctalo a través de Apple Health (iOS) o Google Health Connect (Android). Las métricas propias de Garmin — Body Battery, Training Load, estado de HRV, VO₂ máx.— no las comparte Garmin con ninguna de las dos plataformas.",
|
||||
"docsLink": "Leer la guía"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Banda de referencia de glucosa",
|
||||
"description": "Con qué banda se evalúan tus valores de glucosa en sangre.",
|
||||
"enable": "Usar la banda objetivo para diabetes",
|
||||
"explainer": "Desactivado, los valores se evalúan con la banda de referencia general no diabética. Activado, se evalúan con la banda objetivo glucémica más estrecha de los ADA Standards of Care, y el Coach fundamenta sus comentarios sobre glucosa en esa banda. Tus propios valores objetivo personalizados de abajo siguen teniendo prioridad.",
|
||||
"disclaimer": "Esto selecciona una banda de referencia. No es un diagnóstico, nunca se deduce de una medición y la aplicación no actúa clínicamente en consecuencia. Qué objetivos se aplican a ti es una pregunta para tu profesional sanitario.",
|
||||
"error": "No se pudo guardar el ajuste."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -6023,6 +6298,7 @@
|
||||
"colAction": "Acción",
|
||||
"colDuration": "Duración",
|
||||
"colTraceId": "Trace",
|
||||
"viewDetails": "Ver detalles de {action} a las {timestamp}",
|
||||
"showing": "Mostrando {count} de hasta {bufferMax} eventos en búfer",
|
||||
"empty": "El búfer está vacío",
|
||||
"emptyDescription": "Aún no hay eventos que coincidan con los filtros actuales. Pulsa actualizar tras generar algo de tráfico.",
|
||||
@@ -6643,9 +6919,9 @@
|
||||
"hypertensionGrade1": "Hipertensión grado 1",
|
||||
"hypertensionGrade2": "Hipertensión grado 2",
|
||||
"hypertensionGrade3": "Hipertensión grado 3",
|
||||
"bradycardia": "Bradicardia",
|
||||
"belowTypicalRange": "Por debajo del rango típico",
|
||||
"elevated": "Elevado",
|
||||
"tachycardia": "Taquicardia",
|
||||
"aboveTypicalRange": "Por encima del rango típico",
|
||||
"significantlyLow": "Significativamente bajo",
|
||||
"slightlyLow": "Ligeramente bajo",
|
||||
"onTarget": "En rango objetivo",
|
||||
@@ -7320,7 +7596,7 @@
|
||||
"referenceLabel": "Referencia",
|
||||
"readingsCount": "{count} mediciones",
|
||||
"deleteConfirmTitle": "¿Eliminar resultado de laboratorio?",
|
||||
"deleteConfirmDescription": "Este resultado de laboratorio se eliminará de forma permanente.",
|
||||
"deleteConfirmDescription": "Este resultado de laboratorio se eliminará. Justo después podrás deshacerlo durante un breve momento.",
|
||||
"deletedToast": "Resultado eliminado",
|
||||
"deleteError": "No se pudo eliminar el resultado",
|
||||
"loadError": "No se pudieron cargar los resultados",
|
||||
@@ -7351,6 +7627,7 @@
|
||||
"notePlaceholder": "Contexto opcional para este resultado",
|
||||
"requiredError": "Introduce un valor.",
|
||||
"save": "Guardar",
|
||||
"saveAndAddAnother": "Guardar y añadir otro",
|
||||
"savedToast": "Resultado guardado",
|
||||
"saveError": "No se pudo guardar el resultado",
|
||||
"biomarker": "Biomarcador",
|
||||
@@ -7798,7 +8075,7 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "¿Eliminar este episodio?",
|
||||
"body": "«{label}» y todos sus registros diarios se eliminarán. Esta acción no se puede deshacer."
|
||||
"body": "«{label}» y todos sus registros diarios se eliminarán. Justo después podrás deshacerlo durante un breve momento."
|
||||
},
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "Frecuencia cardíaca en reposo",
|
||||
@@ -7881,6 +8158,11 @@
|
||||
"review_due": "Revisión pendiente",
|
||||
"reviewed": "Revisado"
|
||||
}
|
||||
},
|
||||
"refusal": {
|
||||
"outOfScope": "Solo puedo ayudarte con los datos de salud de tu registro: mediciones, medicamentos, estado de ánimo, tendencias. No puedo responder preguntas fuera de ese ámbito.",
|
||||
"promptInjection": "He detectado instrucciones que intentan anular mis directrices. Soy el Coach de HealthLog y solo resumo tus propios datos de salud; reformula tu pregunta en esos términos, por favor.",
|
||||
"conversationPoisoned": "Un mensaje anterior de esta conversación contiene instrucciones que intentan anular mis directrices. Empieza una conversación nueva, por favor."
|
||||
}
|
||||
},
|
||||
"safety": {
|
||||
@@ -7892,14 +8174,14 @@
|
||||
"bpLowDoctor": "Dos lecturas recientes tuvieron una sistólica por debajo de {systolic} mmHg. Si te mareas o te sientes débil, siéntate o recuéstate y contacta a tu médico si continúa.",
|
||||
"bpLowEmergency": "Dos lecturas recientes tuvieron una sistólica por debajo de {systolic} mmHg e indicaste síntomas. Si te sientes a punto de desmayar, confuso o con piel fría y húmeda, llama a emergencias ya.",
|
||||
"glucoseLowTitle": "Glucosa baja",
|
||||
"glucoseLowDoctor": "Dos lecturas recientes estuvieron por debajo de 70 mg/dL ({value} mg/dL). Toma carbohidratos de acción rápida, vuelve a medir en 15 minutos y contacta a tu médico si los bajones continúan.",
|
||||
"glucoseLowEmergency": "Dos lecturas recientes estuvieron por debajo de 70 mg/dL ({value} mg/dL) e indicaste síntomas. Trátalo ahora con azúcar rápida; si no puedes o te sientes muy mal, llama a emergencias.",
|
||||
"glucoseLowDoctor": "Dos lecturas recientes estuvieron por debajo de {threshold} {unit} ({value} {unit}). Toma carbohidratos de acción rápida, vuelve a medir en 15 minutos y contacta a tu médico si los bajones continúan.",
|
||||
"glucoseLowEmergency": "Dos lecturas recientes estuvieron por debajo de {threshold} {unit} ({value} {unit}) e indicaste síntomas. Trátalo ahora con azúcar rápida; si no puedes o te sientes muy mal, llama a emergencias.",
|
||||
"glucoseLowSevereTitle": "Glucosa muy baja",
|
||||
"glucoseLowSevereDoctor": "Dos lecturas recientes estuvieron por debajo de 54 mg/dL ({value} mg/dL). Trátalo de inmediato con carbohidratos de acción rápida, vuelve a medir en 15 minutos y contacta a tu médico.",
|
||||
"glucoseLowSevereEmergency": "Dos lecturas recientes estuvieron por debajo de 54 mg/dL ({value} mg/dL) e indicaste síntomas. Trátalo ahora; si no retienes alimentos o te sientes muy mal, llama a emergencias.",
|
||||
"glucoseLowSevereDoctor": "Dos lecturas recientes estuvieron por debajo de {threshold} {unit} ({value} {unit}). Trátalo de inmediato con carbohidratos de acción rápida, vuelve a medir en 15 minutos y contacta a tu médico.",
|
||||
"glucoseLowSevereEmergency": "Dos lecturas recientes estuvieron por debajo de {threshold} {unit} ({value} {unit}) e indicaste síntomas. Trátalo ahora; si no retienes alimentos o te sientes muy mal, llama a emergencias.",
|
||||
"glucoseHighTitle": "Glucosa muy alta",
|
||||
"glucoseHighDoctor": "Dos lecturas recientes fueron de {value} mg/dL o más. Vuelve a medir, hidrátate y contacta a tu médico cuanto antes si sigue así de alta.",
|
||||
"glucoseHighEmergency": "Dos lecturas recientes fueron de {value} mg/dL o más e indicaste síntomas. Si tienes vómitos, respiración rápida o confusión, llama a emergencias ya."
|
||||
"glucoseHighDoctor": "Dos lecturas recientes fueron de {value} {unit} o más. Vuelve a medir, hidrátate y contacta a tu médico cuanto antes si sigue así de alta.",
|
||||
"glucoseHighEmergency": "Dos lecturas recientes fueron de {value} {unit} o más e indicaste síntomas. Si tienes vómitos, respiración rápida o confusión, llama a emergencias ya."
|
||||
}
|
||||
},
|
||||
"version": {
|
||||
@@ -7912,6 +8194,7 @@
|
||||
"mentalHealth": {
|
||||
"pageTitle": "Bienestar mental",
|
||||
"pageDescription": "Cuatro autoevaluaciones validadas y opcionales — PHQ-9 (estado de ánimo), GAD-7 (preocupación), WHO-5 (bienestar) y SCI (sueño) — junto a tu registro de ánimo. Lo valioso es la tendencia en el tiempo, no un único número.",
|
||||
"moodLink": "Ver tu historial de estado de ánimo",
|
||||
"attributionLabel": "Fuente",
|
||||
"attributionShow": "Fuente y licencia de {instrument}",
|
||||
"choosePrompt": "Elige una revisión",
|
||||
@@ -8036,6 +8319,12 @@
|
||||
"nummerGegenKummer": {
|
||||
"name": "Nummer gegen Kummer (para jóvenes)"
|
||||
},
|
||||
"telefonSeelsorgeAt": {
|
||||
"name": "Telefonseelsorge Österreich (24/7, gratuito y confidencial)"
|
||||
},
|
||||
"dargeboteneHand": {
|
||||
"name": "Die Dargebotene Hand (24/7, gratuito y confidencial)"
|
||||
},
|
||||
"krisenchat": {
|
||||
"name": "krisenchat (chat 24/7, para jóvenes)"
|
||||
},
|
||||
@@ -8126,7 +8415,9 @@
|
||||
"finally": "Finally …"
|
||||
}
|
||||
},
|
||||
"openDetail": "Ver detalles"
|
||||
"openDetail": "Ver detalles",
|
||||
"historyLoadError": "No se pudo cargar tu historial de evaluaciones",
|
||||
"historyLoadErrorHint": "Esto no significa que no tengas resultados: no se pudo obtener la lista. Vuelve a intentarlo."
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "Son tus propios registros, no un diagnóstico médico. HealthLog no diagnostica, no trata ni los interpreta.",
|
||||
@@ -8289,7 +8580,9 @@
|
||||
"openLabel": "Abrir {title}",
|
||||
"attachmentBadge": "Solo descarga",
|
||||
"uploading": "Subiendo…",
|
||||
"dismissFailed": "Descartar subida fallida"
|
||||
"dismissFailed": "Descartar subida fallida",
|
||||
"processing": "Procesando…",
|
||||
"ready": "Listo"
|
||||
},
|
||||
"error": {
|
||||
"fileTooLarge": "Este archivo supera el límite de {maxSize}.",
|
||||
@@ -8331,6 +8624,16 @@
|
||||
},
|
||||
"detail": {
|
||||
"title": "Detalles del documento",
|
||||
"summary": {
|
||||
"title": "Resumen",
|
||||
"pending": "Se está preparando el resumen.",
|
||||
"none": "Todavía no se ha generado ningún resumen para este documento.",
|
||||
"withheld": "Se generó un resumen, pero no superó la comprobación de seguridad, así que no se muestra. Puedes intentarlo de nuevo o leer el documento.",
|
||||
"unavailable": "No se pudo generar ningún resumen para este documento.",
|
||||
"generate": "Generar resumen",
|
||||
"regenerate": "Volver a generar",
|
||||
"generating": "Generando…"
|
||||
},
|
||||
"loadError": "No se pudo cargar este documento.",
|
||||
"saveError": "No se pudo guardar el cambio.",
|
||||
"deleteError": "No se pudo eliminar el documento.",
|
||||
@@ -8345,7 +8648,8 @@
|
||||
"noConditions": "Sin vincular a una afección",
|
||||
"previewUnavailable": "Sin vista previa integrada para este tipo de archivo",
|
||||
"uploadedMeta": "Añadido el {date} · {size}",
|
||||
"share": "Compartir"
|
||||
"share": "Compartir",
|
||||
"labValuesLink": "Ver {count} valores de laboratorio"
|
||||
},
|
||||
"dropOverlay": {
|
||||
"title": "Suelta los archivos para subirlos",
|
||||
@@ -8410,7 +8714,6 @@
|
||||
"close": "Ocultar"
|
||||
},
|
||||
"ai": {
|
||||
"statusAiRead": "Leído por IA",
|
||||
"readDone": "La IA leyó tu documento.",
|
||||
"read": "Leer con IA",
|
||||
"reread": "Leer de nuevo",
|
||||
@@ -8566,6 +8869,7 @@
|
||||
"quickAddCustomPlaceholder": "250",
|
||||
"quickAddSubmit": "Añadir",
|
||||
"quickAddError": "No se pudo guardar. Inténtalo de nuevo.",
|
||||
"quickAddSuccess": "Se añadieron {amount} mL de agua.",
|
||||
"editTotal": "Editar el total de hoy"
|
||||
},
|
||||
"caffeine": {
|
||||
@@ -8576,14 +8880,15 @@
|
||||
"rowMeta": "{days} de {window} días · ref. {value} {unit} (EFSA)",
|
||||
"rowMetaNoRef": "{days} de {window} días",
|
||||
"footer": "{tracked} de {total} micronutrientes registrados tienen datos en este periodo.",
|
||||
"emptyTitle": "Aún no hay datos de micronutrientes sincronizados"
|
||||
"emptyTitle": "Aún no hay datos de micronutrientes sincronizados",
|
||||
"loadError": "No se pudieron cargar tus datos de nutrientes"
|
||||
},
|
||||
"sparseNote": "Un valor bajo aquí suele significar un registro escaso, no una deficiencia: los totales diarios de Apple Health reflejan lo que registraron tus apps, no la ingesta dietética total."
|
||||
},
|
||||
"daily": {
|
||||
"line": {
|
||||
"score": "Tu puntuación de salud hoy es {score}.",
|
||||
"allClear": "Hoy nada requiere tu atención: todo evoluciona con normalidad."
|
||||
"allClear": "Hoy nada necesita tu atención."
|
||||
},
|
||||
"push": {
|
||||
"title": "Tu resumen matinal",
|
||||
@@ -8592,8 +8897,11 @@
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
"title": "Medicación pendiente",
|
||||
"body": "Hoy toca una dosis.",
|
||||
"bodyNamed": "{name} toca hoy."
|
||||
"body": "Pronto toca una dosis.",
|
||||
"bodyNamed": "Pronto toca {name}.",
|
||||
"overdueTitle": "Medicación atrasada",
|
||||
"overdueBody": "Una dosis está atrasada.",
|
||||
"overdueBodyNamed": "{name} está atrasado."
|
||||
},
|
||||
"preventiveCare": {
|
||||
"title": "Revisión pendiente",
|
||||
@@ -8607,7 +8915,9 @@
|
||||
"coachCheckin": {
|
||||
"title": "Es buen momento para revisar un plan",
|
||||
"body": "Ha pasado alrededor de una semana desde que definiste este plan: consérvalo, ajústalo o déjalo ir. Sin presión.",
|
||||
"bodyPlan": "Hace aproximadamente una semana te propusiste: «{plan}». Consérvalo, ajústalo o déjalo ir, sin presión."
|
||||
"bodyPlan": "Hace aproximadamente una semana te propusiste: «{plan}». Consérvalo, ajústalo o déjalo ir, sin presión.",
|
||||
"adjustPrompt": "Me gustaría ajustar mi plan de seguimiento.",
|
||||
"adjustPromptPlan": "Me gustaría ajustar este plan: «{plan}»"
|
||||
},
|
||||
"tensionWindow": {
|
||||
"title": "Quizá algo de tensión hoy",
|
||||
@@ -8645,7 +8955,8 @@
|
||||
"deltaVsBaseline": "{delta} frente a tu valor de referencia",
|
||||
"deltaFlat": "En línea con tu valor de referencia",
|
||||
"sleepPending": "El sueño de anoche aún no ha llegado; la lectura de hoy es provisional.",
|
||||
"readFullBriefing": "Leer el informe completo",
|
||||
"justIn": "Recién llegado",
|
||||
"justInAt": "Recién llegado · {time}",
|
||||
"worthALook": "Merece un vistazo",
|
||||
"allClear": "Hoy nada necesita tu atención.",
|
||||
"ringLink": "Abrir detalles de {metric}"
|
||||
|
||||
+384
-71
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Enregistrer",
|
||||
"conflictReloaded": "Rechargé depuis le serveur. Refais ta modification.",
|
||||
"cancel": "Annuler",
|
||||
"delete": "Supprimer",
|
||||
"undo": "Annuler",
|
||||
@@ -42,7 +43,74 @@
|
||||
"hidePassword": "Masquer le mot de passe",
|
||||
"learnMore": "En savoir plus",
|
||||
"dismiss": "Ignorer",
|
||||
"moreInfo": "Plus d'informations"
|
||||
"moreInfo": "Plus d'informations",
|
||||
"durationHoursMinutes": "{hours} h {minutes} min",
|
||||
"durationMinutes": "{minutes} min"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
"create": {
|
||||
"invalid": "Vérifiez les données de la mesure et réessayez."
|
||||
},
|
||||
"duplicate_timestamp": "Une mesure existe déjà pour cette heure."
|
||||
},
|
||||
"mood": {
|
||||
"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." },
|
||||
"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": {
|
||||
"out_of_range": "Une évaluation est hors de la plage autorisée."
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"create": {
|
||||
"invalid": "Vérifiez le résultat de laboratoire et réessayez."
|
||||
},
|
||||
"result": {
|
||||
"notFound": "Ce résultat de laboratoire n’est plus disponible."
|
||||
},
|
||||
"biomarker": {
|
||||
"notFound": "Ce biomarqueur n’est plus disponible.",
|
||||
"duplicate": "Un biomarqueur portant ce nom existe déjà.",
|
||||
"create": {
|
||||
"invalid": "Vérifiez les informations du biomarqueur et réessayez."
|
||||
},
|
||||
"update": {
|
||||
"invalid": "Vérifiez les informations du biomarqueur et réessayez.",
|
||||
"referenceRangeInvalid": "La valeur de référence basse ne doit pas dépasser la valeur haute."
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"invalid": "Vérifiez le résultat de laboratoire et réessayez.",
|
||||
"linkedFieldsImmutable": "Modifiez le biomarqueur pour changer son nom, son unité, son panel ou sa plage de référence.",
|
||||
"qualitativeExpected": "Saisissez un résultat textuel pour cette mesure qualitative.",
|
||||
"numericExpected": "Saisissez un nombre pour cette mesure numérique.",
|
||||
"referenceRangeInvalid": "La valeur de référence basse ne doit pas dépasser la valeur haute."
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"disclaimer": {
|
||||
"invalid": "Veuillez confirmer à nouveau l’avertissement médical."
|
||||
},
|
||||
"tour": {
|
||||
"invalid": "La progression de la visite n’a pas pu être enregistrée."
|
||||
},
|
||||
"complete": {
|
||||
"invalid": "Vérifiez les informations du profil et réessayez."
|
||||
},
|
||||
"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.",
|
||||
"completed": "L’accueil est déjà terminé.",
|
||||
"outOfOrder": "Votre progression a changé. Actualisez la page et continuez.",
|
||||
"concurrent": "Votre progression a changé. Actualisez la page et continuez."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Rapport de santé",
|
||||
@@ -91,7 +159,7 @@
|
||||
"colShare": "Part",
|
||||
"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 : Europe/Berlin | Créé : {timestamp}",
|
||||
"footerSource": "Source : HealthLog | Fuseau horaire : {timezone} | Créé : {timestamp}",
|
||||
"typeWeight": "Poids corporel",
|
||||
"typeBpSys": "Tension artérielle systolique",
|
||||
"typeBpDia": "Tension artérielle diastolique",
|
||||
@@ -228,7 +296,7 @@
|
||||
"logout": "Se déconnecter",
|
||||
"login": "Se connecter",
|
||||
"notifications": "Notifications",
|
||||
"theme": "Apparence",
|
||||
"theme": "Thème",
|
||||
"themeSystem": "Système",
|
||||
"themeDark": "Sombre",
|
||||
"themeLight": "Clair",
|
||||
@@ -253,7 +321,9 @@
|
||||
"medication": "Médicament",
|
||||
"medicationDescription": "Marquer une dose comme prise",
|
||||
"mood": "Humeur",
|
||||
"moodDescription": "Enregistrer l'humeur et les tags"
|
||||
"moodDescription": "Enregistrez votre humeur et des étiquettes",
|
||||
"water": "Eau",
|
||||
"waterDescription": "Ajoutez rapidement votre consommation d’eau"
|
||||
},
|
||||
"cycle": "Cycle",
|
||||
"labs": "Analyses",
|
||||
@@ -295,6 +365,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.",
|
||||
@@ -356,6 +429,7 @@
|
||||
"quickAddMeasurement": "Saisir une mesure",
|
||||
"quickAddMood": "Saisir une humeur",
|
||||
"quickAddMedicationIntake": "Saisir une prise",
|
||||
"quickAddWater": "Saisir de l’eau",
|
||||
"customizeDashboard": "Personnaliser le tableau de bord",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Saisir une prise de médicament",
|
||||
@@ -385,6 +459,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",
|
||||
@@ -400,12 +475,13 @@
|
||||
"staleHint": "Dernière valeur il y a {count} j",
|
||||
"staleHintWeeksOne": "Dernière valeur il y a 1 semaine",
|
||||
"staleHintWeeksOther": "Dernière valeur il y a {count} semaines",
|
||||
"staleHintWeeksFew": "Dernière valeur il y a {count} semaines",
|
||||
"staleHintMonthsOne": "Dernière valeur il y a 1 mois",
|
||||
"staleHintMonthsOther": "Dernière valeur il y a {count} mois",
|
||||
"staleHintMonthsFew": "Dernière valeur il y a {count} mois",
|
||||
"recentWorkouts": {
|
||||
"title": "Entraînements récents",
|
||||
"viewAll": "Tout voir",
|
||||
"loading": "Chargement des entraînements…",
|
||||
"empty": {
|
||||
"title": "Aucun entraînement",
|
||||
"cta": "Ouvre l'app Apple Santé sur ton iPhone pour synchroniser les entraînements."
|
||||
@@ -422,7 +498,9 @@
|
||||
"steps": "Pas",
|
||||
"totalBodyWater": "Eau corporelle totale",
|
||||
"boneMass": "Masse osseuse",
|
||||
"oxygenSaturation": "Saturation en oxygène"
|
||||
"oxygenSaturation": "Saturation en oxygène",
|
||||
"mood": "Humeur",
|
||||
"bmi": "IMC"
|
||||
},
|
||||
"unit": {
|
||||
"weight": "kg",
|
||||
@@ -434,7 +512,9 @@
|
||||
"steps": "pas",
|
||||
"totalBodyWater": "kg",
|
||||
"boneMass": "kg",
|
||||
"oxygenSaturation": "%"
|
||||
"oxygenSaturation": "%",
|
||||
"mood": "/5",
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -575,7 +655,7 @@
|
||||
"bulkDeleteSuccess": "{count} mesures supprimées",
|
||||
"bulkDeleteError": "Erreur lors de la suppression des mesures sélectionnées",
|
||||
"bulkDeleteConfirmTitle": "Supprimer {count} mesures ?",
|
||||
"bulkDeleteConfirmBody": "Les {count} mesures sélectionnées seront définitivement supprimées.",
|
||||
"bulkDeleteConfirmBody": "Les {count} mesures sélectionnées seront supprimées. Vous pourrez annuler cette action pendant un court instant juste après.",
|
||||
"typePhq9Score": "Score PHQ-9",
|
||||
"typeGad7Score": "Score GAD-7",
|
||||
"typeGripStrength": "Force de préhension",
|
||||
@@ -587,9 +667,27 @@
|
||||
"typeWho5Score": "Score WHO-5",
|
||||
"typeSciScore": "Score SCI"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Ce que HealthLog peut suivre",
|
||||
"description": "Tous les domaines de mesures pris en charge par HealthLog, que tu aies déjà des données ou non — et ce qui les fournirait.",
|
||||
"statusPresent": "Suivi",
|
||||
"statusAbsent": "Pas encore suivi",
|
||||
"statusModuleOff": "Désactivé",
|
||||
"loadError": "Impossible de charger l'état de suivi.",
|
||||
"connectCta": "Connecter une source",
|
||||
"viewCta": "Voir",
|
||||
"ecg": {
|
||||
"source": "Tracés ECG à une dérivation depuis une montre compatible — Withings ScanWatch aujourd'hui — synchronisés automatiquement."
|
||||
},
|
||||
"watchdog": {
|
||||
"title": "Surveillance automatique",
|
||||
"description": "HealthLog surveille aussi discrètement en arrière-plan — notifications de rythme irrégulier et de perturbations respiratoires envoyées par une montre connectée, alertes d'écart par rapport à ta valeur de référence, ainsi que des changements notables entre tes deux derniers bilans de laboratoire — et n'affiche une carte que si quelque chose se démarque. Si au moins une de ces sources est connectée, l'absence de carte est bon signe, pas une fonctionnalité manquante ; si aucune ne l'est encore, c'est simplement qu'il n'y a rien à surveiller."
|
||||
}
|
||||
},
|
||||
"mood": {
|
||||
"title": "Humeur",
|
||||
"subtitle": "Tendance et entrées d’humeur",
|
||||
"mentalWellbeingLink": "Faire un bilan de bien-être mental",
|
||||
"addEntry": "Ajouter",
|
||||
"editEntry": "Modifier l’entrée",
|
||||
"moodLevel": "Humeur",
|
||||
@@ -705,7 +803,7 @@
|
||||
"bulkDeleteSuccess": "{count} entrées supprimées",
|
||||
"bulkDeleteError": "Erreur lors de la suppression des entrées sélectionnées",
|
||||
"bulkDeleteConfirmTitle": "Supprimer {count} entrées ?",
|
||||
"bulkDeleteConfirmBody": "Les {count} entrées d'humeur sélectionnées seront définitivement supprimées.",
|
||||
"bulkDeleteConfirmBody": "Les {count} entrées d'humeur sélectionnées seront supprimées. Vous pourrez annuler cette action pendant un court instant juste après.",
|
||||
"customize": "Personnaliser les étiquettes d’humeur",
|
||||
"addTagInline": "Ajouter une étiquette",
|
||||
"manage": {
|
||||
@@ -1403,7 +1501,10 @@
|
||||
"confirmAction": "Supprimer",
|
||||
"toast": "Prise supprimée",
|
||||
"failed": "Impossible de supprimer la prise."
|
||||
}
|
||||
},
|
||||
"viewRecent90": "90 derniers jours",
|
||||
"viewAll": "Historique complet",
|
||||
"viewToggleLabel": "Plage de l'historique"
|
||||
},
|
||||
"verlauf": {
|
||||
"loading": "Chargement de l'historique …",
|
||||
@@ -1659,7 +1760,10 @@
|
||||
"packagingDefaultPackLabel": "Taille de boîte par défaut (unités)",
|
||||
"packagingDefaultPackHelper": "Préremplit la quantité lors de l’enregistrement de nouvelles boîtes (1–1000). Laisse vide si aucune ne s’applique.",
|
||||
"packagingSuccess": "Conditionnement enregistré",
|
||||
"packagingFailed": "Échec de l’enregistrement"
|
||||
"packagingFailed": "Échec de l’enregistrement",
|
||||
"addManufacturerLabel": "Fabricant",
|
||||
"addDoseStrengthLabel": "Dosage",
|
||||
"addDoseStrengthHint": "Tel qu'imprimé sur le contenant, par ex. 5 mg/0,5 ml."
|
||||
},
|
||||
"erweitert": {
|
||||
"group": {
|
||||
@@ -1785,13 +1889,14 @@
|
||||
"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 ?",
|
||||
"placeholder": "Suivre selon…",
|
||||
"apply": "Appliquer",
|
||||
"reset": "Réinitialiser par défaut"
|
||||
"reset": "Réinitialiser par défaut",
|
||||
"error": "Impossible d'enregistrer cette modification. Réessaie."
|
||||
},
|
||||
"beforeAfter": {
|
||||
"summary": "Ø {before} avant → Ø {after} depuis — un écart de {delta}.",
|
||||
@@ -1806,7 +1911,10 @@
|
||||
"subtitle": "Ce qui coïncide avec les mesures visées par vos médicaments — association seulement, jamais un verdict.",
|
||||
"link": "Ouvrir la vue Effet",
|
||||
"empty": "Pas encore assez de données pour relier vos médicaments à leurs mesures cibles."
|
||||
}
|
||||
},
|
||||
"scaleAbsolute": "Absolu",
|
||||
"scalePercentSinceStart": "% depuis le début",
|
||||
"scaleToggleLabel": "Échelle du graphique"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -1955,6 +2063,61 @@
|
||||
"notAvailableForCompliance": "Comparaison indisponible pour l’observance"
|
||||
},
|
||||
"insights": {
|
||||
"correlation": {
|
||||
"direction": {
|
||||
"higher": "augmenter",
|
||||
"lower": "diminuer"
|
||||
},
|
||||
"weekdayDirection": {
|
||||
"above": "au-dessus de",
|
||||
"below": "en dessous de"
|
||||
},
|
||||
"channelFactor": "l'évaluation de {factor}",
|
||||
"channel": {
|
||||
"WEIGHT": "le poids",
|
||||
"BLOOD_GLUCOSE": "la glycémie",
|
||||
"BLOOD_PRESSURE_SYS": "la pression systolique",
|
||||
"BLOOD_PRESSURE_DIA": "la pression diastolique",
|
||||
"ACTIVITY_STEPS": "le nombre de pas quotidien",
|
||||
"SLEEP_DURATION": "la durée de sommeil",
|
||||
"HEART_RATE_VARIABILITY": "la variabilité de la fréquence cardiaque",
|
||||
"RESTING_HEART_RATE": "la fréquence cardiaque au repos",
|
||||
"TIME_IN_DAYLIGHT": "le temps passé à la lumière du jour",
|
||||
"MOOD": "l'humeur",
|
||||
"PULSE": "le pouls au repos",
|
||||
"MEDICATION_COMPLIANCE": "l'observance des médicaments",
|
||||
"SYMPTOM_SEVERITY": "la sévérité des symptômes",
|
||||
"ENV_TEMP_MEAN": "la température quotidienne",
|
||||
"ENV_TEMP_MIN": "la température minimale nocturne",
|
||||
"ENV_SUNSHINE": "l'ensoleillement",
|
||||
"ENV_DAYLIGHT": "la lumière du jour",
|
||||
"ENV_PRECIP": "les précipitations",
|
||||
"ENV_PRESSURE_MEAN": "la pression atmosphérique",
|
||||
"ENV_PRESSURE_DELTA": "la variation de pression sur la journée"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Quand {behaviour} monte, {outcome} du lendemain tend à {direction} dans tes données — une tendance à surveiller, pas une cause.",
|
||||
"moderate": "Quand {behaviour} monte, {outcome} du lendemain semble avoir tendance à {direction}, au vu des données actuelles — une tendance à surveiller, pas une cause.",
|
||||
"faint": "Quand {behaviour} monte, tout au plus se dessine un faible indice que {outcome} du lendemain tende à {direction} dans tes données — trop ténu pour s'y fier, jamais une cause."
|
||||
},
|
||||
"lab": {
|
||||
"default": "Des valeurs de {lab} plus élevées vont de pair avec {outcome} qui tend à {direction} sur les mêmes périodes dans tes données — une association à surveiller avec ton médecin, jamais une cause.",
|
||||
"faint": "Des valeurs de {lab} plus élevées laissent tout au plus entrevoir que {outcome} tende à {direction} sur les mêmes périodes dans tes données — trop ténu pour s'y fier, une association à évoquer avec ton médecin, jamais une cause."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "Une meilleure observance des médicaments va de pair avec des valeurs systoliques plus basses — une tendance à surveiller.",
|
||||
"surprising": "L'observance et la systolique évoluent ensemble dans tes données — surprenant ; parles-en à ton médecin avant de changer quoi que ce soit.",
|
||||
"none": "L'observance et la systolique n'évoluent pas nettement ensemble sur cette période."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "Les jours de moral plus bas vont de pair avec un pouls au repos plus élevé — une tendance à surveiller.",
|
||||
"surprising": "Les jours de meilleur moral vont de pair avec un pouls au repos plus élevé dans tes données — surprenant.",
|
||||
"none": "L'humeur et le pouls au repos n'évoluent pas nettement ensemble sur cette période."
|
||||
},
|
||||
"weightWeekday": "Le poids du {weekday} est {delta} kg {direction} ta moyenne des autres jours — une tendance à surveiller."
|
||||
}
|
||||
},
|
||||
"noAnalysisYet": "Pas encore d’évaluation.",
|
||||
"assessmentPreparing": "Préparation de votre évaluation…",
|
||||
"emptyTitle": "Pas encore assez de données",
|
||||
@@ -2193,6 +2356,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",
|
||||
@@ -2240,7 +2404,16 @@
|
||||
"historySearchPlaceholder": "Rechercher dans les conversations…",
|
||||
"historyEmpty": "Tes conversations s’affichent ici. Lance une question pour commencer.",
|
||||
"historyDeleteAria": "Supprimer la conversation",
|
||||
"historyDeleteConfirm": "Supprimer cette conversation ? Action irréversible.",
|
||||
"historyDeleted": "Conversation supprimée",
|
||||
"rename": {
|
||||
"action": "Renommer la conversation",
|
||||
"input": "Titre de la conversation",
|
||||
"save": "Enregistrer le titre",
|
||||
"cancel": "Annuler le renommage",
|
||||
"error": "Impossible d’enregistrer le titre de la conversation."
|
||||
},
|
||||
"historySearchEmpty": "Aucune conversation ne correspond à ta recherche.",
|
||||
"historyLoadingMore": "Chargement…",
|
||||
"sourcesTitle": "Ce à quoi j’accède",
|
||||
"sourcesFresh": "À jour",
|
||||
"sourcesStale": "Obsolètes",
|
||||
@@ -2377,10 +2550,6 @@
|
||||
"settingsClustersHint": "Choisissez les groupes de données que le Coach peut lire. Plus de données, des réponses plus riches et un prompt plus grand.",
|
||||
"settingsClusterNoData": "Pas encore de données",
|
||||
"sourcesMemberCount": "{count} mesures",
|
||||
"dictate": "Dicter",
|
||||
"dictateStop": "Arrêter la dictée",
|
||||
"voiceUnsupported": "La saisie vocale n'est pas prise en charge par ce navigateur",
|
||||
"dictateError": "Impossible de démarrer la saisie vocale. Vérifiez l'autorisation du microphone et réessayez.",
|
||||
"showConversations": "Afficher les conversations",
|
||||
"hideConversations": "Masquer les conversations",
|
||||
"heroGreeting": "Pose-moi une question sur tes données",
|
||||
@@ -2470,17 +2639,22 @@
|
||||
"documentsAttachedOne": "1 document joint",
|
||||
"documentsAttachedOther": "{count} documents joints",
|
||||
"railBadge": "Chat de document cloisonné",
|
||||
"uploadError": "Échec du téléversement. Veuillez réessayer."
|
||||
"uploadError": "Échec du téléversement. Veuillez réessayer.",
|
||||
"notFoundError": "Ce lien de document n'est plus valide : une conversation normale démarre à la place."
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "à l’instant",
|
||||
"relativeMinutesAgoOne": "il y a {count} minute",
|
||||
"relativeMinutesAgoOther": "il y a {count} minutes",
|
||||
"relativeMinutesAgoFew": "il y a {count} minutes",
|
||||
"relativeHoursAgoOne": "il y a {count} heure",
|
||||
"relativeHoursAgoOther": "il y a {count} heures",
|
||||
"relativeHoursAgoFew": "il y a {count} heures",
|
||||
"relativeDaysAgoOne": "il y a {count} jour",
|
||||
"relativeDaysAgoOther": "il y a {count} jours",
|
||||
"relativeDaysAgoFew": "il y a {count} jours",
|
||||
"navOverview": "Vue d'ensemble",
|
||||
"navCatalog": "Toutes les mesures",
|
||||
"navBloodPressure": "Tension artérielle",
|
||||
"navWeight": "Poids",
|
||||
"navPulse": "Pouls",
|
||||
@@ -2511,9 +2685,9 @@
|
||||
"label": "Activité",
|
||||
"header": "Activité et mobilité"
|
||||
},
|
||||
"cardiovascularParent": {
|
||||
"label": "Cardiovasculaire",
|
||||
"header": "Marqueurs cardiovasculaires"
|
||||
"heartParent": {
|
||||
"label": "Cœur",
|
||||
"header": "Santé cardiaque"
|
||||
},
|
||||
"hearingParent": {
|
||||
"label": "Audition",
|
||||
@@ -2638,11 +2812,11 @@
|
||||
"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.",
|
||||
"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 : plus elle est basse, mieux c'est.",
|
||||
"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",
|
||||
@@ -2672,7 +2846,7 @@
|
||||
"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, et le temps passé dehors est lié à la santé oculaire.",
|
||||
"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",
|
||||
@@ -2684,7 +2858,7 @@
|
||||
"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.",
|
||||
"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",
|
||||
@@ -2732,7 +2906,8 @@
|
||||
},
|
||||
"valuesBack": "Retour aux Insights",
|
||||
"valuesBackToMetric": "Retour à {metric}",
|
||||
"scoresBack": "Retour aux Insights"
|
||||
"scoresBack": "Retour aux Insights",
|
||||
"ecgDescription": "Enregistrements ECG à dérivation unique de votre appareil, chacun accompagné du résultat de l'appareil. HealthLog affiche le tracé et l'enregistrement, mais n'interprète pas la courbe."
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sommeil",
|
||||
@@ -2771,9 +2946,9 @@
|
||||
"debt": {
|
||||
"title": "Dette de sommeil",
|
||||
"value": "{hours} h {minutes} min de déficit",
|
||||
"caption": "Déficit cumulé sur les {nights} dernières nuits",
|
||||
"caption": "Solde restant sur les {nights} dernières nuits",
|
||||
"clearValue": "À jour",
|
||||
"clearCaption": "Aucun déficit cumulé sur les {nights} dernières nuits",
|
||||
"clearCaption": "Rien en attente sur les {nights} dernières nuits",
|
||||
"learning": "Votre sommeil est encore en cours d'analyse — {counted} nuits jusqu'ici, encore {remaining} pour un bilan fiable.",
|
||||
"computedInfo": "Il s'agit de l'estimation propre à HealthLog : un solde glissant entre le besoin et le sommeil des dernières nuits, que le sommeil de récupération réduit. Elle peut différer de la dette de sommeil native d'un wearable, qui utilise son propre modèle de besoin."
|
||||
},
|
||||
@@ -2870,13 +3045,15 @@
|
||||
"sleep": {
|
||||
"title": "Aucune donnée de sommeil",
|
||||
"description": "Dès que des données de sommeil sont disponibles (manuel ou via Withings / Apple Health), la répartition par stade apparaîtra ici.",
|
||||
"cta": "Connecter Withings"
|
||||
"cta": "Connecter une source"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "Entraînements",
|
||||
"navRecovery": "Récupération",
|
||||
"workouts": {
|
||||
"title": "Entraînements",
|
||||
"askWhy": "Pourquoi",
|
||||
"askWhyPrompt": "Pourquoi cette séance s'est-elle passée ainsi ? Explique-moi mes propres chiffres.",
|
||||
"description": "Courses, sorties à vélo, marches et séances de musculation récentes, dédupliquées entre Apple Watch et Withings.",
|
||||
"emptyState": {
|
||||
"title": "Aucun entraînement",
|
||||
@@ -2905,7 +3082,24 @@
|
||||
"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…"
|
||||
"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 :",
|
||||
"zonesTitle": "Zones d'effort",
|
||||
"zonesCaption": "Répartition de votre effort entre les zones de fréquence cardiaque.",
|
||||
"zonesCaptionDevice": "Temps par zone indiqué par votre appareil.",
|
||||
"exportGpx": "Exporter le GPX",
|
||||
"elevationTitle": "Dénivelé",
|
||||
"splitsTitle": "Temps par kilomètre",
|
||||
"splitsKm": "km",
|
||||
"splitsTime": "Temps",
|
||||
"splitsPace": "Allure",
|
||||
"thatDayTitle": "Ce jour-là",
|
||||
"dayPulse": "Pouls",
|
||||
"daySleep": "Sommeil",
|
||||
"dayMood": "Humeur",
|
||||
"insightTitle": "Analyse de l'activité"
|
||||
},
|
||||
"sport": {
|
||||
"walking": "Marche",
|
||||
@@ -2928,7 +3122,11 @@
|
||||
"crossTraining": "Cross-training",
|
||||
"mixedCardio": "Cardio mixte",
|
||||
"other": "Autre"
|
||||
}
|
||||
},
|
||||
"loadError": "Tes séances n'ont pas pu être chargées",
|
||||
"loadMore": "Charger plus de séances",
|
||||
"loadingMore": "Chargement de séances supplémentaires",
|
||||
"loadMoreError": "Les séances supplémentaires n'ont pas pu être chargées"
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Glycémie",
|
||||
@@ -3238,6 +3436,8 @@
|
||||
},
|
||||
"navWalkingSpeed": "Vitesse de marche",
|
||||
"mood": {
|
||||
"logLink": "Enregistrer une humeur",
|
||||
"mentalWellbeingLink": "Faire un bilan de bien-être mental",
|
||||
"heatmapTitle": "Calendrier d'humeur",
|
||||
"heatmapNoEntry": "Aucune entrée",
|
||||
"legendGreat": "Excellent",
|
||||
@@ -3525,7 +3725,9 @@
|
||||
"sectionTitle": "Tes indicateurs de santé",
|
||||
"sectionSubtitle": "Valeurs combinées de récupération, de stress et d'effort issues de tes appareils connectés",
|
||||
"loadError": "Impossible de charger vos scores de bien-être pour le moment.",
|
||||
"recovery": "Récupération",
|
||||
"loadingLabel": "Chargement de tes indicateurs de santé",
|
||||
"recovery": "Score de récupération",
|
||||
"recoveryCrossLink": "Voir les données de récupération et d'effort de votre wearable",
|
||||
"stress": "Stress",
|
||||
"strain": "Effort"
|
||||
},
|
||||
@@ -3696,6 +3898,7 @@
|
||||
"recovery": {
|
||||
"title": "Récupération",
|
||||
"description": "À quel point votre corps a récupéré : les signaux de récupération, de charge et d'effort que votre wearable enregistre la nuit.",
|
||||
"scoreCrossLink": "Voir le score de récupération composite",
|
||||
"emptyTitle": "Pas encore de données de récupération",
|
||||
"empty": "Pas encore de données de récupération. Connectez un compte WHOOP, Polar ou Oura et ces scores se rempliront dès que votre appareil se synchronise.",
|
||||
"block": {
|
||||
@@ -3866,13 +4069,18 @@
|
||||
"recordingsMany": "{count} enregistrements disponibles.",
|
||||
"latestResult": "Dernier résultat de l’appareil : {result}.",
|
||||
"cta": "Voir les enregistrements"
|
||||
}
|
||||
},
|
||||
"emptyTitle": "Aucun enregistrement ECG pour l'instant",
|
||||
"emptyDescription": "Les enregistrements ECG se synchronisent depuis un appareil à dérivation unique compatible. Dès que votre appareil transfère un enregistrement, il apparaît ici.",
|
||||
"emptyCta": "Connecter un appareil"
|
||||
},
|
||||
"intradayPulse": {
|
||||
"title": "Le rythme de votre journée",
|
||||
"caption": "Votre fréquence cardiaque au fil de la journée, moyennée par tranches de 10 minutes.",
|
||||
"hourlyNote": "Moyenne horaire affichée pour cette journée — les jours récents affichent le détail complet par tranches de 10 minutes.",
|
||||
"empty": "Pas encore assez de mesures pour cette journée pour en afficher le rythme.",
|
||||
"coverageOne": "Basé sur 1 mesure sur {hours}h.",
|
||||
"coverageMany": "Basé sur {count} mesures sur {hours}h.",
|
||||
"baseline": "Repos",
|
||||
"today": "Aujourd'hui",
|
||||
"previousDay": "Jour précédent",
|
||||
@@ -3883,7 +4091,8 @@
|
||||
"evening": "Une période élevée au repos ce soir — peut-être un peu de tension.",
|
||||
"night": "Une période élevée au repos cette nuit — peut-être un peu de tension."
|
||||
}
|
||||
}
|
||||
},
|
||||
"navEcg": "ECG"
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Par défaut",
|
||||
@@ -3982,7 +4191,7 @@
|
||||
},
|
||||
"wrapUp": {
|
||||
"title": "C’est la fin de la visite",
|
||||
"body": "Vous pouvez revoir chaque module séparément à tout moment (« Afficher cette visite » sur chaque page) ou relancer la visite complète dans Réglages → Avancé."
|
||||
"body": "Cette visite guidée ne s'affiche qu'une fois — elle ne se rouvrira pas d'elle-même. Tout ce qu'elle a montré reste en place, et vous pouvez activer ou désactiver des fonctionnalités à tout moment dans Réglages → Modules."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4010,7 +4219,7 @@
|
||||
},
|
||||
"source": {
|
||||
"title": "Connecter une source",
|
||||
"body": "Importez les mesures automatiquement ou conservez la saisie manuelle. Rien n'est configuré à votre place : chaque carte indique simplement où la connecter.",
|
||||
"body": "Importez les mesures automatiquement ou conservez la saisie manuelle. Rien n'est configuré à votre place : chaque carte indique simplement où la connecter. Les sources d'appareil nécessitent une application développeur gratuite sur votre propre compte (environ 5 minutes) ; le guide de chaque carte vous accompagne.",
|
||||
"recommended": "Recommandé",
|
||||
"manual": {
|
||||
"title": "Saisie manuelle",
|
||||
@@ -4018,12 +4227,14 @@
|
||||
},
|
||||
"withings": {
|
||||
"title": "Withings",
|
||||
"body": "Balances, tensiomètres et trackers Withings se synchronisent automatiquement.",
|
||||
"cta": "Connecter Withings"
|
||||
"body": "Balances, tensiomètres et trackers Withings se synchronisent automatiquement une fois connectés.",
|
||||
"cta": "Connecter Withings",
|
||||
"setupCta": "Configurer dans les Réglages",
|
||||
"setupHint": "Nécessite d'abord votre propre application développeur Withings, gratuite — environ 5 minutes, une seule fois."
|
||||
},
|
||||
"more": {
|
||||
"title": "Autres sources",
|
||||
"body": "Connectez l'une d'elles dans les Réglages quand vous le souhaitez.",
|
||||
"body": "Connectez l'une d'elles dans les Réglages quand vous le souhaitez — la plupart nécessitent d'abord une application développeur gratuite sur votre propre compte.",
|
||||
"appleHealth": "Apple Health",
|
||||
"whoop": "WHOOP",
|
||||
"oura": "Oura",
|
||||
@@ -4055,7 +4266,8 @@
|
||||
"learning": "Certaines analyses — glycémie, rythme de sommeil, tendances de tension, cycle — s'affinent au cours de la première semaine ou deux à mesure que vos données s'accumulent. Des tuiles vides signifient simplement que nous apprenons encore votre référence.",
|
||||
"connectCta": "Connecter un appareil",
|
||||
"logCta": "Enregistrez votre première mesure",
|
||||
"importCta": "Importer un historique existant"
|
||||
"importCta": "Importer un historique existant",
|
||||
"modulesCta": "Choisir vos modules"
|
||||
},
|
||||
"welcomeBack": {
|
||||
"title": "Bon retour",
|
||||
@@ -4165,7 +4377,16 @@
|
||||
"disableError": "Impossible de désactiver le mode Recherche. Veuillez réessayer."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Sections des réglages"
|
||||
"sectionsNav": "Sections des réglages",
|
||||
"groups": {
|
||||
"account": "Compte",
|
||||
"yourData": "Vos données",
|
||||
"display": "Affichage",
|
||||
"healthProfile": "Profil de santé",
|
||||
"ai": "IA",
|
||||
"connectivity": "Connectivité",
|
||||
"system": "Système"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
@@ -4350,7 +4571,8 @@
|
||||
},
|
||||
"fullBackup": {
|
||||
"title": "Sauvegarde complète",
|
||||
"description": "Un seul fichier JSON contenant tout — même format que la sauvegarde automatique hebdomadaire. Utile pour une restauration personnelle via le téléversement administrateur.",
|
||||
"description": "Toutes vos données dans un seul fichier JSON : mesures, médicaments, humeur, cycle, analyses de laboratoire, épisodes de maladie, allergies, antécédents familiaux et entraînements. Même format que la sauvegarde automatique hebdomadaire.",
|
||||
"scopeNote": "Les métadonnées des documents et le résumé IA, s'il existe, sont inclus en texte clair ; les fichiers originaux des documents ainsi que les tracés GPS/données d'échantillons des entraînements ne le sont pas — téléchargez-les séparément depuis le coffre de documents ou l'export des entraînements.",
|
||||
"encryptToggle": "Chiffrer avec une phrase secrète",
|
||||
"passphrasePlaceholder": "Phrase secrète (12 caractères min.)",
|
||||
"passphraseHelp": "Dérive la clé de l'archive avec Argon2id et la scelle avec AES-256-GCM. Enregistrez le fichier avec l'extension .hlx.",
|
||||
@@ -4381,6 +4603,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.",
|
||||
"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…",
|
||||
"unpacking": "Décompression de l'archive…",
|
||||
@@ -4510,7 +4733,7 @@
|
||||
"description": "Dossiers structurés des allergies et des antécédents familiaux."
|
||||
},
|
||||
"environment": {
|
||||
"title": "Environnement",
|
||||
"title": "Localisation et météo",
|
||||
"subtitle": "Contexte météo, lumière du jour et lieu.",
|
||||
"disabledHint": "Activez le module Environnement pour définir un lieu de référence et intégrer la météo et la durée du jour quotidiennes.",
|
||||
"searchPlaceholder": "Rechercher une ville",
|
||||
@@ -4539,7 +4762,9 @@
|
||||
"description": "Ajoutez un lieu et une plage de dates : un voyage ou une portion de votre historique passé. La période remplace votre domicile pour les jours qu'elle couvre, afin que la météo corresponde à l'endroit où vous étiez réellement. C'est ainsi que vous corrigez le passé.",
|
||||
"none": "Aucune période de localisation pour l'instant.",
|
||||
"add": "Ajouter {label}",
|
||||
"remove": "Supprimer la période de localisation"
|
||||
"remove": "Supprimer la période de localisation",
|
||||
"removeConfirmTitle": "Retirer ce voyage ?",
|
||||
"removeConfirmBody": "Les mesures de cette période restent dans ton dossier ; seule l'entrée du voyage disparaît."
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Récupérer les jours passés",
|
||||
@@ -4820,7 +5045,10 @@
|
||||
"surfaced": "Affiché",
|
||||
"done": "Terminé",
|
||||
"dismissed": "Ignoré"
|
||||
}
|
||||
},
|
||||
"deleteConfirmTitle": "Supprimer ce rappel ?",
|
||||
"deleteConfirmBody": "Il ne se déclenchera plus. Ce que tu as déjà enregistré reste intact.",
|
||||
"deleteConfirmAction": "Supprimer"
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "À propos de moi",
|
||||
@@ -4866,6 +5094,16 @@
|
||||
"honesty": "L'accès partagé est un unique compte IA connecté que l'opérateur a relié pour tout le monde sur ce serveur, et non votre propre clé. Les requêtes que vous y faites servent par défaut à entraîner les modèles du fournisseur et aucun accord de traitement des données ne les couvre, les limites horaires et hebdomadaires du compte sont partagées avec tous les autres qui ont accepté, et vos propres fournisseurs sont essayés en premier. Ne l'activez que si vous acceptez ce compromis.",
|
||||
"confirm": "J'ai compris — utiliser la connexion partagée",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"consent": {
|
||||
"title": "Consentement IA",
|
||||
"activeSince": "Donné le {date}. Tes données de santé peuvent être transmises au fournisseur configuré pour les fonctions IA.",
|
||||
"withdrawn": "Retiré. Aucune donnée de santé n'est transmise à un fournisseur IA ; les fonctions IA restent désactivées.",
|
||||
"withdraw": "Retirer le consentement",
|
||||
"confirmBody": "Les fonctions IA s'arrêtent immédiatement. Rien de ce qui est déjà enregistré n'est supprimé — utilise Données et confidentialité pour exporter ou effacer ton dossier.",
|
||||
"confirmWithdraw": "Retirer",
|
||||
"regrantHint": "Pour réutiliser les fonctions IA, active la lecture automatique des documents ou donne ton consentement depuis l'application.",
|
||||
"error": "Le retrait n'a pas pu être enregistré. Réessaie."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -5052,7 +5290,8 @@
|
||||
"resumeError": "Échec de la reconnexion"
|
||||
},
|
||||
"apiTokens": "Jetons API",
|
||||
"apiTokensDescription": "Créez des jetons API pour enregistrer des prises de médicaments externes (par exemple Raccourcis, automatisations).",
|
||||
"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.",
|
||||
"tokenNamePlaceholder": "Nom du jeton (par exemple Raccourci iPhone)",
|
||||
"tokenCreated": "Jeton créé — copiez-le maintenant ! Il ne sera plus affiché.",
|
||||
"tokenCopy": "Copier le jeton",
|
||||
@@ -5312,7 +5551,7 @@
|
||||
"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",
|
||||
@@ -5383,6 +5622,12 @@
|
||||
"ouraDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
|
||||
"nightscoutDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le",
|
||||
"integrationCredentialsHint": "Où trouver ces identifiants et comment enregistrer ta propre application est expliqué dans le guide de configuration ci-dessus.",
|
||||
"integrationRedirectGuide": {
|
||||
"intro": "Cette connexion passe par votre propre application développeur {provider} — environ 5 minutes de configuration :",
|
||||
"step1": "Créez une application dans le portail développeur {provider}.",
|
||||
"step2": "Réglez son URL de redirection/callback sur :",
|
||||
"step3": "Collez ci-dessus l'ID client et le secret client fournis."
|
||||
},
|
||||
"security": {
|
||||
"sessionsTitle": "Sessions actives",
|
||||
"sessionsDescription": "Appareils et navigateurs actuellement connectés à votre compte.",
|
||||
@@ -5478,8 +5723,16 @@
|
||||
"revokeAllDone": "{count} appareil(s) de confiance oublié(s).",
|
||||
"unnamed": "Appareil inconnu",
|
||||
"current": "Cet appareil",
|
||||
"expires": "De confiance jusqu'au {date}"
|
||||
}
|
||||
"expires": "De confiance jusqu'au {date}",
|
||||
"revokeConfirmTitle": "Ne plus faire confiance à cet appareil ?",
|
||||
"revokeConfirmBody": "Il redemandera ton deuxième facteur à la prochaine connexion depuis cet appareil.",
|
||||
"revokeAllConfirmTitle": "Ne plus faire confiance à aucun appareil ?",
|
||||
"revokeAllConfirmBody": "Chaque appareil redemandera ton deuxième facteur à la prochaine connexion, celui-ci compris."
|
||||
},
|
||||
"revokeSessionConfirmTitle": "Déconnecter cet appareil ?",
|
||||
"revokeSessionConfirmBody": "{device} devra se reconnecter. Ce qui y est enregistré n'est pas affecté.",
|
||||
"signOutEverywhereConfirmTitle": "Déconnecter tous les autres appareils ?",
|
||||
"signOutEverywhereConfirmBody": "Chaque appareil sauf celui-ci devra se reconnecter. Celui-ci reste connecté. Rien de ce que tu as enregistré n'est affecté."
|
||||
},
|
||||
"privacy": {
|
||||
"intro": {
|
||||
@@ -5579,10 +5832,34 @@
|
||||
"rate_limited": "Trop de tentatives de connexion. Attends un instant et réessaie.",
|
||||
"generic": "Erreur inconnue lors de la connexion. Relance la connexion."
|
||||
},
|
||||
"appleHealth": {
|
||||
"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"
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin n'a pas de connexion directe ici. Connecte-le via Apple Health (iOS) ou Google Health Connect (Android). Les scores propriétaires de Garmin — Body Battery, Training Load, statut VFC, VO₂ max — ne sont partagés par Garmin avec aucune des deux plateformes.",
|
||||
"docsLink": "Lire le guide"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Plage de référence du glucose",
|
||||
"description": "La plage à laquelle vos glycémies sont comparées.",
|
||||
"enable": "Utiliser la plage cible diabète",
|
||||
"explainer": "Désactivé, les valeurs sont comparées à la plage de référence générale non diabétique. Activé, elles sont comparées à la plage cible glycémique plus stricte des ADA Standards of Care, et le Coach fonde ses remarques sur le glucose sur cette plage. Vos propres valeurs cibles personnalisées ci-dessous restent prioritaires.",
|
||||
"disclaimer": "Ceci sélectionne une plage de référence. Ce n'est pas un diagnostic, ce n'est jamais déduit d'une mesure, et l'application n'en tire aucune conséquence clinique. Les cibles qui s'appliquent à vous relèvent de votre médecin.",
|
||||
"error": "Le réglage n'a pas pu être enregistré."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -6023,6 +6300,7 @@
|
||||
"colAction": "Action",
|
||||
"colDuration": "Durée",
|
||||
"colTraceId": "Trace",
|
||||
"viewDetails": "Voir les détails de {action} à {timestamp}",
|
||||
"showing": "Affichage de {count} sur jusqu'à {bufferMax} événements en mémoire tampon",
|
||||
"empty": "Le tampon est vide",
|
||||
"emptyDescription": "Aucun événement ne correspond encore aux filtres actuels. Cliquez sur Actualiser après avoir généré du trafic.",
|
||||
@@ -6643,9 +6921,9 @@
|
||||
"hypertensionGrade1": "Hypertension grade 1",
|
||||
"hypertensionGrade2": "Hypertension grade 2",
|
||||
"hypertensionGrade3": "Hypertension grade 3",
|
||||
"bradycardia": "Bradycardie",
|
||||
"belowTypicalRange": "En dessous de la plage habituelle",
|
||||
"elevated": "Élevé",
|
||||
"tachycardia": "Tachycardie",
|
||||
"aboveTypicalRange": "Au-dessus de la plage habituelle",
|
||||
"significantlyLow": "Nettement bas",
|
||||
"slightlyLow": "Légèrement bas",
|
||||
"onTarget": "Dans la plage cible",
|
||||
@@ -7320,7 +7598,7 @@
|
||||
"referenceLabel": "Référence",
|
||||
"readingsCount": "{count} mesures",
|
||||
"deleteConfirmTitle": "Supprimer le résultat d'analyse ?",
|
||||
"deleteConfirmDescription": "Ce résultat d'analyse sera définitivement supprimé.",
|
||||
"deleteConfirmDescription": "Ce résultat d'analyse sera supprimé. Vous pourrez annuler cette action pendant un court instant juste après.",
|
||||
"deletedToast": "Résultat supprimé",
|
||||
"deleteError": "Impossible de supprimer le résultat",
|
||||
"loadError": "Impossible de charger les résultats",
|
||||
@@ -7351,6 +7629,7 @@
|
||||
"notePlaceholder": "Contexte facultatif pour ce résultat",
|
||||
"requiredError": "Saisissez une valeur.",
|
||||
"save": "Enregistrer",
|
||||
"saveAndAddAnother": "Enregistrer et ajouter un autre",
|
||||
"savedToast": "Résultat enregistré",
|
||||
"saveError": "Impossible d'enregistrer le résultat",
|
||||
"biomarker": "Biomarqueur",
|
||||
@@ -7798,7 +8077,7 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Supprimer cet épisode ?",
|
||||
"body": "« {label} » et tous ses relevés quotidiens seront supprimés. Cette action est irréversible."
|
||||
"body": "« {label} » et tous ses relevés quotidiens seront supprimés. Vous pourrez annuler cette action pendant un court instant juste après."
|
||||
},
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "Fréquence cardiaque au repos",
|
||||
@@ -7881,6 +8160,11 @@
|
||||
"review_due": "Point d'étape dû",
|
||||
"reviewed": "Évalué"
|
||||
}
|
||||
},
|
||||
"refusal": {
|
||||
"outOfScope": "Je peux uniquement t'aider avec les données de santé de ton journal : mesures, médicaments, humeur, tendances. Je ne réponds pas aux questions en dehors de ce cadre.",
|
||||
"promptInjection": "J'ai repéré des instructions qui cherchent à contourner mes consignes. Je suis le Coach HealthLog et je résume uniquement tes propres données de santé — reformule ta question dans ce cadre, s'il te plaît.",
|
||||
"conversationPoisoned": "Un message précédent de cette conversation contient des instructions qui cherchent à contourner mes consignes. Démarre une nouvelle conversation, s'il te plaît."
|
||||
}
|
||||
},
|
||||
"safety": {
|
||||
@@ -7892,14 +8176,14 @@
|
||||
"bpLowDoctor": "Deux mesures récentes avaient une systolique inférieure à {systolic} mmHg. Si tu te sens étourdi ou faible, assieds-toi ou allonge-toi et contacte ton médecin si cela persiste.",
|
||||
"bpLowEmergency": "Deux mesures récentes avaient une systolique inférieure à {systolic} mmHg et tu as signalé des symptômes. Si tu te sens près de t'évanouir, confus ou as la peau froide et moite, appelle les secours immédiatement.",
|
||||
"glucoseLowTitle": "Glycémie basse",
|
||||
"glucoseLowDoctor": "Deux mesures récentes étaient en dessous de 70 mg/dL ({value} mg/dL). Prends des glucides à action rapide, remesure dans 15 minutes et contacte ton médecin si les baisses persistent.",
|
||||
"glucoseLowEmergency": "Deux mesures récentes étaient en dessous de 70 mg/dL ({value} mg/dL) et tu as signalé des symptômes. Traite maintenant avec du sucre rapide ; si tu ne peux pas ou te sens très mal, appelle les secours.",
|
||||
"glucoseLowDoctor": "Deux mesures récentes étaient en dessous de {threshold} {unit} ({value} {unit}). Prends des glucides à action rapide, remesure dans 15 minutes et contacte ton médecin si les baisses persistent.",
|
||||
"glucoseLowEmergency": "Deux mesures récentes étaient en dessous de {threshold} {unit} ({value} {unit}) et tu as signalé des symptômes. Traite maintenant avec du sucre rapide ; si tu ne peux pas ou te sens très mal, appelle les secours.",
|
||||
"glucoseLowSevereTitle": "Glycémie très basse",
|
||||
"glucoseLowSevereDoctor": "Deux mesures récentes étaient en dessous de 54 mg/dL ({value} mg/dL). Traite immédiatement avec des glucides à action rapide, remesure dans 15 minutes et contacte ton médecin.",
|
||||
"glucoseLowSevereEmergency": "Deux mesures récentes étaient en dessous de 54 mg/dL ({value} mg/dL) et tu as signalé des symptômes. Traite maintenant ; si tu ne gardes pas la nourriture ou te sens très mal, appelle les secours.",
|
||||
"glucoseLowSevereDoctor": "Deux mesures récentes étaient en dessous de {threshold} {unit} ({value} {unit}). Traite immédiatement avec des glucides à action rapide, remesure dans 15 minutes et contacte ton médecin.",
|
||||
"glucoseLowSevereEmergency": "Deux mesures récentes étaient en dessous de {threshold} {unit} ({value} {unit}) et tu as signalé des symptômes. Traite maintenant ; si tu ne gardes pas la nourriture ou te sens très mal, appelle les secours.",
|
||||
"glucoseHighTitle": "Glycémie très élevée",
|
||||
"glucoseHighDoctor": "Deux mesures récentes étaient à {value} mg/dL ou plus. Remesure, hydrate-toi et contacte ton médecin dès que possible si cela reste aussi élevé.",
|
||||
"glucoseHighEmergency": "Deux mesures récentes étaient à {value} mg/dL ou plus et tu as signalé des symptômes. En cas de vomissements, de respiration rapide ou de confusion, appelle les secours immédiatement."
|
||||
"glucoseHighDoctor": "Deux mesures récentes étaient à {value} {unit} ou plus. Remesure, hydrate-toi et contacte ton médecin dès que possible si cela reste aussi élevé.",
|
||||
"glucoseHighEmergency": "Deux mesures récentes étaient à {value} {unit} ou plus et tu as signalé des symptômes. En cas de vomissements, de respiration rapide ou de confusion, appelle les secours immédiatement."
|
||||
}
|
||||
},
|
||||
"version": {
|
||||
@@ -7912,6 +8196,7 @@
|
||||
"mentalHealth": {
|
||||
"pageTitle": "Bien-être mental",
|
||||
"pageDescription": "Quatre auto-évaluations validées et facultatives — PHQ-9 (humeur), GAD-7 (inquiétude), WHO-5 (bien-être) et SCI (sommeil) — en complément de ton suivi de l'humeur. Ce qui compte, c'est la tendance dans le temps, pas un chiffre isolé.",
|
||||
"moodLink": "Voir ton historique d'humeur",
|
||||
"attributionLabel": "Source",
|
||||
"attributionShow": "Source et licence pour {instrument}",
|
||||
"choosePrompt": "Choisis un bilan",
|
||||
@@ -8036,6 +8321,12 @@
|
||||
"nummerGegenKummer": {
|
||||
"name": "Nummer gegen Kummer (pour les jeunes)"
|
||||
},
|
||||
"telefonSeelsorgeAt": {
|
||||
"name": "Telefonseelsorge Österreich (24h/24, 7j/7, gratuit et confidentiel)"
|
||||
},
|
||||
"dargeboteneHand": {
|
||||
"name": "Die Dargebotene Hand (24h/24, 7j/7, gratuit et confidentiel)"
|
||||
},
|
||||
"krisenchat": {
|
||||
"name": "krisenchat (chat 24h/24, 7j/7, pour les jeunes)"
|
||||
},
|
||||
@@ -8126,7 +8417,9 @@
|
||||
"finally": "Finally …"
|
||||
}
|
||||
},
|
||||
"openDetail": "Voir les détails"
|
||||
"openDetail": "Voir les détails",
|
||||
"historyLoadError": "Ton historique d'évaluations n'a pas pu être chargé",
|
||||
"historyLoadErrorHint": "Cela ne veut pas dire que tu n'as aucun résultat : la liste n'a pas pu être récupérée. Réessaie."
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "Ce sont vos propres informations, pas un diagnostic médical. HealthLog ne diagnostique pas, ne traite pas et ne les interprète pas.",
|
||||
@@ -8289,7 +8582,9 @@
|
||||
"openLabel": "Ouvrir {title}",
|
||||
"attachmentBadge": "Téléchargement uniquement",
|
||||
"uploading": "Téléversement…",
|
||||
"dismissFailed": "Ignorer le téléversement échoué"
|
||||
"dismissFailed": "Ignorer le téléversement échoué",
|
||||
"processing": "Traitement en cours…",
|
||||
"ready": "Prêt"
|
||||
},
|
||||
"error": {
|
||||
"fileTooLarge": "Ce fichier dépasse la limite de {maxSize}.",
|
||||
@@ -8331,6 +8626,16 @@
|
||||
},
|
||||
"detail": {
|
||||
"title": "Détails du document",
|
||||
"summary": {
|
||||
"title": "Résumé",
|
||||
"pending": "Le résumé est en cours de préparation.",
|
||||
"none": "Aucun résumé n’a encore été généré pour ce document.",
|
||||
"withheld": "Un résumé a été généré mais n’a pas passé le contrôle de sécurité, il n’est donc pas affiché. Vous pouvez réessayer ou lire le document lui-même.",
|
||||
"unavailable": "Aucun résumé n’a pu être généré pour ce document.",
|
||||
"generate": "Générer le résumé",
|
||||
"regenerate": "Regénérer",
|
||||
"generating": "Génération…"
|
||||
},
|
||||
"loadError": "Impossible de charger ce document.",
|
||||
"saveError": "Impossible d'enregistrer la modification.",
|
||||
"deleteError": "Impossible de supprimer le document.",
|
||||
@@ -8345,7 +8650,8 @@
|
||||
"noConditions": "Lié à aucune affection",
|
||||
"previewUnavailable": "Pas d'aperçu intégré pour ce type de fichier",
|
||||
"uploadedMeta": "Ajouté le {date} · {size}",
|
||||
"share": "Partager"
|
||||
"share": "Partager",
|
||||
"labValuesLink": "Voir {count} valeurs de laboratoire"
|
||||
},
|
||||
"dropOverlay": {
|
||||
"title": "Déposez les fichiers pour les téléverser",
|
||||
@@ -8410,7 +8716,6 @@
|
||||
"close": "Masquer"
|
||||
},
|
||||
"ai": {
|
||||
"statusAiRead": "Lu par l’IA",
|
||||
"readDone": "L’IA a lu votre document.",
|
||||
"read": "Lire avec l’IA",
|
||||
"reread": "Relire",
|
||||
@@ -8566,6 +8871,7 @@
|
||||
"quickAddCustomPlaceholder": "250",
|
||||
"quickAddSubmit": "Ajouter",
|
||||
"quickAddError": "Échec de l'enregistrement — réessayez.",
|
||||
"quickAddSuccess": "{amount} mL d’eau ajoutés.",
|
||||
"editTotal": "Modifier le total du jour"
|
||||
},
|
||||
"caffeine": {
|
||||
@@ -8576,14 +8882,15 @@
|
||||
"rowMeta": "{days} sur {window} jours · réf. {value} {unit} (EFSA)",
|
||||
"rowMetaNoRef": "{days} sur {window} jours",
|
||||
"footer": "{tracked} des {total} micronutriments suivis ont des données sur cette période.",
|
||||
"emptyTitle": "Aucune donnée de micronutriments synchronisée pour l'instant"
|
||||
"emptyTitle": "Aucune donnée de micronutriments synchronisée pour l'instant",
|
||||
"loadError": "Tes données de nutriments n'ont pas pu être chargées"
|
||||
},
|
||||
"sparseNote": "Une valeur basse ici signifie généralement un suivi partiel, pas une carence — les totaux journaliers Apple Health reflètent ce que vos applis ont enregistré, pas l'apport alimentaire total."
|
||||
},
|
||||
"daily": {
|
||||
"line": {
|
||||
"score": "Ton score de santé aujourd'hui est de {score}.",
|
||||
"allClear": "Rien ne requiert ton attention aujourd'hui — tout évolue normalement."
|
||||
"allClear": "Rien ne requiert ton attention aujourd'hui."
|
||||
},
|
||||
"push": {
|
||||
"title": "Votre résumé du matin",
|
||||
@@ -8592,8 +8899,11 @@
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
"title": "Médicament à prendre",
|
||||
"body": "Une dose est prévue aujourd'hui.",
|
||||
"bodyNamed": "{name} est prévu aujourd'hui."
|
||||
"body": "Une dose est bientôt à prendre.",
|
||||
"bodyNamed": "{name} est bientôt à prendre.",
|
||||
"overdueTitle": "Médicament en retard",
|
||||
"overdueBody": "Une dose est en retard.",
|
||||
"overdueBodyNamed": "{name} est en retard."
|
||||
},
|
||||
"preventiveCare": {
|
||||
"title": "Examen à faire",
|
||||
@@ -8607,7 +8917,9 @@
|
||||
"coachCheckin": {
|
||||
"title": "Il est temps de revoir un plan",
|
||||
"body": "Cela fait environ une semaine que vous avez défini ce plan : gardez-le, ajustez-le ou laissez-le. Sans pression.",
|
||||
"bodyPlan": "Il y a environ une semaine, vous vous étiez fixé : « {plan} ». Gardez-le, ajustez-le ou laissez-le, sans pression."
|
||||
"bodyPlan": "Il y a environ une semaine, vous vous étiez fixé : « {plan} ». Gardez-le, ajustez-le ou laissez-le, sans pression.",
|
||||
"adjustPrompt": "J'aimerais ajuster mon plan de suivi.",
|
||||
"adjustPromptPlan": "J'aimerais ajuster ce plan : « {plan} »"
|
||||
},
|
||||
"tensionWindow": {
|
||||
"title": "Peut-être un peu de tension",
|
||||
@@ -8645,7 +8957,8 @@
|
||||
"deltaVsBaseline": "{delta} par rapport à ta référence",
|
||||
"deltaFlat": "Conforme à ta référence",
|
||||
"sleepPending": "Le sommeil de la nuit dernière n'est pas encore arrivé — la lecture du jour est provisoire.",
|
||||
"readFullBriefing": "Lire le briefing complet",
|
||||
"justIn": "Vient d'arriver",
|
||||
"justInAt": "Vient d'arriver · {time}",
|
||||
"worthALook": "À regarder",
|
||||
"allClear": "Rien ne requiert ton attention aujourd'hui.",
|
||||
"ringLink": "Ouvrir les détails de {metric}"
|
||||
|
||||
+374
-71
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Salva",
|
||||
"conflictReloaded": "Ricaricato dal server. Applica di nuovo la tua modifica.",
|
||||
"cancel": "Annulla",
|
||||
"delete": "Elimina",
|
||||
"undo": "Annulla",
|
||||
@@ -42,7 +43,64 @@
|
||||
"hidePassword": "Nascondi password",
|
||||
"learnMore": "Scopri di più",
|
||||
"dismiss": "Ignora",
|
||||
"moreInfo": "Maggiori informazioni"
|
||||
"moreInfo": "Maggiori informazioni",
|
||||
"durationHoursMinutes": "{hours} h {minutes} min",
|
||||
"durationMinutes": "{minutes} min"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
"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." },
|
||||
"duplicate_timestamp": "Esiste già una voce dell’umore per questo orario.",
|
||||
"not_found": "Questa voce dell’umore non è più disponibile.",
|
||||
"ratedFactor": {
|
||||
"out_of_range": "Una valutazione non rientra nell’intervallo consentito."
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"create": {
|
||||
"invalid": "Controlla il risultato di laboratorio e riprova."
|
||||
},
|
||||
"result": {
|
||||
"notFound": "Questo risultato di laboratorio non è più disponibile."
|
||||
},
|
||||
"biomarker": {
|
||||
"notFound": "Questo biomarcatore non è più disponibile.",
|
||||
"duplicate": "Esiste già un biomarcatore con questo nome.",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"invalid": "Controlla il risultato di laboratorio e riprova.",
|
||||
"linkedFieldsImmutable": "Modifica il biomarcatore per cambiare nome, unità, pannello o intervallo di riferimento.",
|
||||
"qualitativeExpected": "Inserisci un risultato testuale per questa lettura qualitativa.",
|
||||
"numericExpected": "Inserisci un numero per questa lettura numerica.",
|
||||
"referenceRangeInvalid": "Il valore di riferimento inferiore non può superare quello superiore."
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"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." },
|
||||
"step": {
|
||||
"rateLimited": "Troppi tentativi. Attendi e riprova.",
|
||||
"invalid": "Non è stato possibile salvare questo passaggio introduttivo.",
|
||||
"completed": "La procedura introduttiva è già completata.",
|
||||
"outOfOrder": "I tuoi progressi sono cambiati. Aggiorna la pagina e continua.",
|
||||
"concurrent": "I tuoi progressi sono cambiati. Aggiorna la pagina e continua."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Rapporto sanitario",
|
||||
@@ -91,7 +149,7 @@
|
||||
"colShare": "Quota",
|
||||
"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: Europe/Berlin | Creato: {timestamp}",
|
||||
"footerSource": "Fonte: HealthLog | Fuso orario: {timezone} | Creato: {timestamp}",
|
||||
"typeWeight": "Peso corporeo",
|
||||
"typeBpSys": "Pressione arteriosa sistolica",
|
||||
"typeBpDia": "Pressione arteriosa diastolica",
|
||||
@@ -228,7 +286,7 @@
|
||||
"logout": "Esci",
|
||||
"login": "Accedi",
|
||||
"notifications": "Notifiche",
|
||||
"theme": "Aspetto",
|
||||
"theme": "Tema",
|
||||
"themeSystem": "Sistema",
|
||||
"themeDark": "Scuro",
|
||||
"themeLight": "Chiaro",
|
||||
@@ -253,7 +311,9 @@
|
||||
"medication": "Farmaco",
|
||||
"medicationDescription": "Segna una dose come assunta",
|
||||
"mood": "Umore",
|
||||
"moodDescription": "Registra umore e tag"
|
||||
"moodDescription": "Registra umore e tag",
|
||||
"water": "Acqua",
|
||||
"waterDescription": "Aggiungi rapidamente l’acqua bevuta"
|
||||
},
|
||||
"cycle": "Ciclo",
|
||||
"labs": "Esami",
|
||||
@@ -295,6 +355,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.",
|
||||
@@ -356,6 +419,7 @@
|
||||
"quickAddMeasurement": "Registra misurazione",
|
||||
"quickAddMood": "Registra umore",
|
||||
"quickAddMedicationIntake": "Registra assunzione",
|
||||
"quickAddWater": "Registra acqua",
|
||||
"customizeDashboard": "Personalizza dashboard",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Registra assunzione del farmaco",
|
||||
@@ -385,6 +449,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",
|
||||
@@ -400,12 +465,13 @@
|
||||
"staleHint": "Ultimo valore {count} g fa",
|
||||
"staleHintWeeksOne": "Ultimo valore 1 settimana fa",
|
||||
"staleHintWeeksOther": "Ultimo valore {count} settimane fa",
|
||||
"staleHintWeeksFew": "Ultimo valore {count} settimane fa",
|
||||
"staleHintMonthsOne": "Ultimo valore 1 mese fa",
|
||||
"staleHintMonthsOther": "Ultimo valore {count} mesi fa",
|
||||
"staleHintMonthsFew": "Ultimo valore {count} mesi fa",
|
||||
"recentWorkouts": {
|
||||
"title": "Allenamenti recenti",
|
||||
"viewAll": "Vedi tutti",
|
||||
"loading": "Caricamento allenamenti…",
|
||||
"empty": {
|
||||
"title": "Nessun allenamento",
|
||||
"cta": "Apri l'app Salute di Apple sull'iPhone per sincronizzare gli allenamenti."
|
||||
@@ -422,7 +488,9 @@
|
||||
"steps": "Passi",
|
||||
"totalBodyWater": "Acqua corporea totale",
|
||||
"boneMass": "Massa ossea",
|
||||
"oxygenSaturation": "Saturazione di ossigeno"
|
||||
"oxygenSaturation": "Saturazione di ossigeno",
|
||||
"mood": "Umore",
|
||||
"bmi": "IMC"
|
||||
},
|
||||
"unit": {
|
||||
"weight": "kg",
|
||||
@@ -434,7 +502,9 @@
|
||||
"steps": "passi",
|
||||
"totalBodyWater": "kg",
|
||||
"boneMass": "kg",
|
||||
"oxygenSaturation": "%"
|
||||
"oxygenSaturation": "%",
|
||||
"mood": "/5",
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -575,7 +645,7 @@
|
||||
"bulkDeleteSuccess": "{count} misurazioni eliminate",
|
||||
"bulkDeleteError": "Errore durante l'eliminazione delle misurazioni selezionate",
|
||||
"bulkDeleteConfirmTitle": "Eliminare {count} misurazioni?",
|
||||
"bulkDeleteConfirmBody": "Le {count} misurazioni selezionate verranno eliminate definitivamente.",
|
||||
"bulkDeleteConfirmBody": "Le {count} misurazioni selezionate verranno eliminate. Subito dopo potrai annullare l’operazione per un breve momento.",
|
||||
"typePhq9Score": "Punteggio PHQ-9",
|
||||
"typeGad7Score": "Punteggio GAD-7",
|
||||
"typeGripStrength": "Forza di presa",
|
||||
@@ -587,9 +657,27 @@
|
||||
"typeWho5Score": "Punteggio WHO-5",
|
||||
"typeSciScore": "Punteggio SCI"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Cosa può monitorare HealthLog",
|
||||
"description": "Tutti gli ambiti di metriche supportati da HealthLog, che tu abbia già dei dati oppure no — e cosa li fornirebbe.",
|
||||
"statusPresent": "Monitorato",
|
||||
"statusAbsent": "Non ancora monitorato",
|
||||
"statusModuleOff": "Disattivato",
|
||||
"loadError": "Impossibile caricare lo stato di monitoraggio.",
|
||||
"connectCta": "Collega una fonte",
|
||||
"viewCta": "Visualizza",
|
||||
"ecg": {
|
||||
"source": "Tracciati ECG a una derivazione da un orologio compatibile — oggi Withings ScanWatch — sincronizzati automaticamente."
|
||||
},
|
||||
"watchdog": {
|
||||
"title": "Monitoraggio automatico",
|
||||
"description": "HealthLog osserva anche silenziosamente in background — notifiche di ritmo irregolare e disturbi respiratori da un orologio connesso, avvisi di scostamento dalla tua base di riferimento, e variazioni degne di nota tra i tuoi due ultimi pannelli di laboratorio — mostrando una scheda solo quando qualcosa emerge. Se almeno una di queste fonti è collegata, nessuna scheda visibile è un buon segno, non una funzione mancante; se non ne è ancora collegata nessuna, semplicemente non c'è ancora nulla sotto controllo."
|
||||
}
|
||||
},
|
||||
"mood": {
|
||||
"title": "Umore",
|
||||
"subtitle": "Andamento e voci dell’umore",
|
||||
"mentalWellbeingLink": "Fai un check-in sul benessere mentale",
|
||||
"addEntry": "Aggiungi",
|
||||
"editEntry": "Modifica voce",
|
||||
"moodLevel": "Umore",
|
||||
@@ -705,7 +793,7 @@
|
||||
"bulkDeleteSuccess": "{count} voci eliminate",
|
||||
"bulkDeleteError": "Errore durante l'eliminazione delle voci selezionate",
|
||||
"bulkDeleteConfirmTitle": "Eliminare {count} voci?",
|
||||
"bulkDeleteConfirmBody": "Le {count} voci dell'umore selezionate verranno eliminate definitivamente.",
|
||||
"bulkDeleteConfirmBody": "Le {count} voci dell'umore selezionate verranno eliminate. Subito dopo potrai annullare l’operazione per un breve momento.",
|
||||
"customize": "Personalizza i tag dell’umore",
|
||||
"addTagInline": "Aggiungi tag",
|
||||
"manage": {
|
||||
@@ -1403,7 +1491,10 @@
|
||||
"confirmAction": "Elimina",
|
||||
"toast": "Assunzione eliminata",
|
||||
"failed": "Impossibile eliminare l’assunzione."
|
||||
}
|
||||
},
|
||||
"viewRecent90": "Ultimi 90 giorni",
|
||||
"viewAll": "Storico completo",
|
||||
"viewToggleLabel": "Intervallo dello storico"
|
||||
},
|
||||
"verlauf": {
|
||||
"loading": "Caricamento cronologia …",
|
||||
@@ -1659,7 +1750,10 @@
|
||||
"packagingDefaultPackLabel": "Dimensione confezione predefinita (unità)",
|
||||
"packagingDefaultPackHelper": "Precompila la quantità quando registri nuove confezioni (1–1000). Lascia vuoto se non applicabile.",
|
||||
"packagingSuccess": "Dati confezione salvati",
|
||||
"packagingFailed": "Salvataggio non riuscito"
|
||||
"packagingFailed": "Salvataggio non riuscito",
|
||||
"addManufacturerLabel": "Produttore",
|
||||
"addDoseStrengthLabel": "Dosaggio",
|
||||
"addDoseStrengthHint": "Come stampato sul contenitore, ad es. 5 mg/0,5 ml."
|
||||
},
|
||||
"erweitert": {
|
||||
"group": {
|
||||
@@ -1785,13 +1879,14 @@
|
||||
"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?",
|
||||
"placeholder": "Monitora in base a…",
|
||||
"apply": "Applica",
|
||||
"reset": "Ripristina predefinito"
|
||||
"reset": "Ripristina predefinito",
|
||||
"error": "Impossibile salvare questa modifica. Riprova."
|
||||
},
|
||||
"beforeAfter": {
|
||||
"summary": "Ø {before} prima → Ø {after} da allora — una variazione di {delta}.",
|
||||
@@ -1806,7 +1901,10 @@
|
||||
"subtitle": "Ciò che coincide con le misurazioni a cui mirano i tuoi farmaci: solo associazione, mai un verdetto.",
|
||||
"link": "Apri la vista Effetto",
|
||||
"empty": "Ancora non ci sono dati sufficienti per collegare i tuoi farmaci alle loro misurazioni obiettivo."
|
||||
}
|
||||
},
|
||||
"scaleAbsolute": "Assoluto",
|
||||
"scalePercentSinceStart": "% dall'inizio",
|
||||
"scaleToggleLabel": "Scala del grafico"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -1955,6 +2053,61 @@
|
||||
"notAvailableForCompliance": "Confronto non disponibile per l’aderenza"
|
||||
},
|
||||
"insights": {
|
||||
"correlation": {
|
||||
"direction": {
|
||||
"higher": "salire",
|
||||
"lower": "scendere"
|
||||
},
|
||||
"weekdayDirection": {
|
||||
"above": "sopra",
|
||||
"below": "sotto"
|
||||
},
|
||||
"channelFactor": "la valutazione di {factor}",
|
||||
"channel": {
|
||||
"WEIGHT": "il peso",
|
||||
"BLOOD_GLUCOSE": "la glicemia",
|
||||
"BLOOD_PRESSURE_SYS": "la pressione sistolica",
|
||||
"BLOOD_PRESSURE_DIA": "la pressione diastolica",
|
||||
"ACTIVITY_STEPS": "i passi giornalieri",
|
||||
"SLEEP_DURATION": "la durata del sonno",
|
||||
"HEART_RATE_VARIABILITY": "la variabilità della frequenza cardiaca",
|
||||
"RESTING_HEART_RATE": "la frequenza cardiaca a riposo",
|
||||
"TIME_IN_DAYLIGHT": "il tempo alla luce del giorno",
|
||||
"MOOD": "l'umore",
|
||||
"PULSE": "il polso a riposo",
|
||||
"MEDICATION_COMPLIANCE": "l'aderenza ai farmaci",
|
||||
"SYMPTOM_SEVERITY": "la gravità dei sintomi",
|
||||
"ENV_TEMP_MEAN": "la temperatura giornaliera",
|
||||
"ENV_TEMP_MIN": "la temperatura minima notturna",
|
||||
"ENV_SUNSHINE": "il soleggiamento",
|
||||
"ENV_DAYLIGHT": "la luce del giorno",
|
||||
"ENV_PRECIP": "le precipitazioni",
|
||||
"ENV_PRESSURE_MEAN": "la pressione atmosferica",
|
||||
"ENV_PRESSURE_DELTA": "l'oscillazione di pressione nella giornata"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Quando {behaviour} sale, {outcome} del giorno dopo tende a {direction} nei tuoi dati — un andamento da osservare, non una causa.",
|
||||
"moderate": "Quando {behaviour} sale, {outcome} del giorno dopo sembra tendere a {direction}, per quanto emerge finora — un andamento da osservare, non una causa.",
|
||||
"faint": "Quando {behaviour} sale, al più si intravede un debole indizio che {outcome} del giorno dopo tenda a {direction} nei tuoi dati — troppo lieve per farci affidamento, mai una causa."
|
||||
},
|
||||
"lab": {
|
||||
"default": "Valori di {lab} più alti si accompagnano al fatto che {outcome} tende a {direction} negli stessi periodi dei tuoi dati — un'associazione da tenere d'occhio con il tuo medico, mai una causa.",
|
||||
"faint": "Valori di {lab} più alti lasciano al più intravedere che {outcome} tenda a {direction} negli stessi periodi dei tuoi dati — troppo lieve per farci affidamento, un'associazione da discutere con il tuo medico, mai una causa."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "Una maggiore aderenza ai farmaci si accompagna a valori sistolici più bassi — un andamento da osservare.",
|
||||
"surprising": "L'aderenza e la sistolica si muovono insieme nei tuoi dati — sorprendente; parlane con il tuo medico prima di modificare qualcosa.",
|
||||
"none": "L'aderenza e la sistolica non si muovono insieme in modo marcato in questo periodo."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "I giorni di umore più basso si accompagnano a un polso a riposo più alto — un andamento da osservare.",
|
||||
"surprising": "I giorni di umore migliore si accompagnano a un polso a riposo più alto nei tuoi dati — sorprendente.",
|
||||
"none": "L'umore e il polso a riposo non si muovono insieme in modo marcato in questo periodo."
|
||||
},
|
||||
"weightWeekday": "Il peso del {weekday} è {delta} kg {direction} la tua media degli altri giorni — un andamento da osservare."
|
||||
}
|
||||
},
|
||||
"noAnalysisYet": "Ancora nessuna valutazione.",
|
||||
"assessmentPreparing": "Preparazione della valutazione…",
|
||||
"emptyTitle": "Dati ancora insufficienti",
|
||||
@@ -2193,6 +2346,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",
|
||||
@@ -2240,7 +2394,16 @@
|
||||
"historySearchPlaceholder": "Cerca nelle conversazioni…",
|
||||
"historyEmpty": "Le tue conversazioni compaiono qui. Fai una domanda per iniziare.",
|
||||
"historyDeleteAria": "Elimina conversazione",
|
||||
"historyDeleteConfirm": "Eliminare questa conversazione? Non si può annullare.",
|
||||
"historyDeleted": "Conversazione eliminata",
|
||||
"rename": {
|
||||
"action": "Rinomina conversazione",
|
||||
"input": "Titolo della conversazione",
|
||||
"save": "Salva titolo",
|
||||
"cancel": "Annulla rinomina",
|
||||
"error": "Impossibile salvare il titolo della conversazione."
|
||||
},
|
||||
"historySearchEmpty": "Nessuna conversazione corrisponde alla tua ricerca.",
|
||||
"historyLoadingMore": "Caricamento…",
|
||||
"sourcesTitle": "A cosa accedo",
|
||||
"sourcesFresh": "Aggiornate",
|
||||
"sourcesStale": "Datate",
|
||||
@@ -2377,10 +2540,6 @@
|
||||
"settingsClustersHint": "Scegli quali gruppi di dati il Coach può leggere. Più dati significano risposte più ricche e un prompt più grande.",
|
||||
"settingsClusterNoData": "Ancora nessun dato",
|
||||
"sourcesMemberCount": "{count} metriche",
|
||||
"dictate": "Detta",
|
||||
"dictateStop": "Interrompi dettatura",
|
||||
"voiceUnsupported": "L'inserimento vocale non è supportato in questo browser",
|
||||
"dictateError": "Impossibile avviare l'inserimento vocale. Controlla l'autorizzazione del microfono e riprova.",
|
||||
"showConversations": "Mostra conversazioni",
|
||||
"hideConversations": "Nascondi conversazioni",
|
||||
"heroGreeting": "Chiedimi qualcosa sui tuoi dati",
|
||||
@@ -2470,17 +2629,22 @@
|
||||
"documentsAttachedOne": "1 documento allegato",
|
||||
"documentsAttachedOther": "{count} documenti allegati",
|
||||
"railBadge": "Chat documento isolata",
|
||||
"uploadError": "Caricamento non riuscito. Riprova."
|
||||
"uploadError": "Caricamento non riuscito. Riprova.",
|
||||
"notFoundError": "Quel link al documento non è più valido: verrà avviata una chat normale."
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "proprio ora",
|
||||
"relativeMinutesAgoOne": "{count} minuto fa",
|
||||
"relativeMinutesAgoOther": "{count} minuti fa",
|
||||
"relativeMinutesAgoFew": "{count} minuti fa",
|
||||
"relativeHoursAgoOne": "{count} ora fa",
|
||||
"relativeHoursAgoOther": "{count} ore fa",
|
||||
"relativeHoursAgoFew": "{count} ore fa",
|
||||
"relativeDaysAgoOne": "{count} giorno fa",
|
||||
"relativeDaysAgoOther": "{count} giorni fa",
|
||||
"relativeDaysAgoFew": "{count} giorni fa",
|
||||
"navOverview": "Panoramica",
|
||||
"navCatalog": "Tutte le metriche",
|
||||
"navBloodPressure": "Pressione arteriosa",
|
||||
"navWeight": "Peso",
|
||||
"navPulse": "Polso",
|
||||
@@ -2511,9 +2675,9 @@
|
||||
"label": "Attività",
|
||||
"header": "Attività e mobilità"
|
||||
},
|
||||
"cardiovascularParent": {
|
||||
"label": "Cardiovascolare",
|
||||
"header": "Marcatori cardiovascolari"
|
||||
"heartParent": {
|
||||
"label": "Cuore",
|
||||
"header": "Salute del cuore"
|
||||
},
|
||||
"hearingParent": {
|
||||
"label": "Udito",
|
||||
@@ -2638,11 +2802,11 @@
|
||||
"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.",
|
||||
"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, quindi più basso è in genere meglio.",
|
||||
"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",
|
||||
@@ -2672,7 +2836,7 @@
|
||||
"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, e il tempo all'aperto è collegato alla salute degli occhi.",
|
||||
"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",
|
||||
@@ -2684,7 +2848,7 @@
|
||||
"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.",
|
||||
"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",
|
||||
@@ -2732,7 +2896,8 @@
|
||||
},
|
||||
"valuesBack": "Torna a Insights",
|
||||
"valuesBackToMetric": "Torna a {metric}",
|
||||
"scoresBack": "Torna a Insights"
|
||||
"scoresBack": "Torna a Insights",
|
||||
"ecgDescription": "Registrazioni ECG a singola derivazione dal tuo dispositivo, ciascuna con il risultato del dispositivo. HealthLog mostra il tracciato e la registrazione, ma non interpreta l'onda."
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sonno",
|
||||
@@ -2771,9 +2936,9 @@
|
||||
"debt": {
|
||||
"title": "Debito di sonno",
|
||||
"value": "{hours} h {minutes} min in meno",
|
||||
"caption": "Deficit cumulato nelle ultime {nights} notti",
|
||||
"caption": "Bilancio in sospeso nelle ultime {nights} notti",
|
||||
"clearValue": "In pari",
|
||||
"clearCaption": "Nessun deficit accumulato nelle ultime {nights} notti",
|
||||
"clearCaption": "Niente in sospeso nelle ultime {nights} notti",
|
||||
"learning": "Stiamo ancora misurando il tuo sonno — {counted} notti finora, ne mancano {remaining} per un bilancio affidabile.",
|
||||
"computedInfo": "Questa è la stima propria di HealthLog: un bilancio mobile tra fabbisogno e sonno delle ultime notti, che il sonno di recupero riduce. Può differire dal dato di debito di sonno nativo di un dispositivo indossabile, che usa un proprio modello di fabbisogno."
|
||||
},
|
||||
@@ -2870,13 +3035,15 @@
|
||||
"sleep": {
|
||||
"title": "Nessuna voce di sonno",
|
||||
"description": "Quando saranno disponibili dati sul sonno (manuale o tramite Withings / Apple Health), qui apparirà la suddivisione per fase.",
|
||||
"cta": "Collega Withings"
|
||||
"cta": "Collega una fonte"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "Allenamenti",
|
||||
"navRecovery": "Recupero",
|
||||
"workouts": {
|
||||
"title": "Allenamenti",
|
||||
"askWhy": "Perché",
|
||||
"askWhyPrompt": "Perché questa sessione è andata così? Spiegami i miei dati di questa sessione.",
|
||||
"description": "Corse, uscite in bici, camminate e sessioni di forza recenti, deduplicati tra Apple Watch e Withings.",
|
||||
"emptyState": {
|
||||
"title": "Nessun allenamento",
|
||||
@@ -2905,7 +3072,24 @@
|
||||
"hrChartTitle": "Frequenza cardiaca",
|
||||
"hrChartUnavailable": "I campioni di frequenza cardiaca al secondo non vengono salvati per questo allenamento.",
|
||||
"notFound": "Allenamento non trovato.",
|
||||
"loading": "Caricamento allenamento…"
|
||||
"loading": "Caricamento allenamento…",
|
||||
"hrFromPulse": "Dai tuoi dati di frequenza cardiaca intorno a questa sessione",
|
||||
"hrAxisMinutes": "min",
|
||||
"sportAverageLabel": "La tua media recente:",
|
||||
"zonesTitle": "Zone di sforzo",
|
||||
"zonesCaption": "Come si è distribuito il tuo sforzo tra le zone di frequenza cardiaca.",
|
||||
"zonesCaptionDevice": "Tempo per zona riportato dal tuo dispositivo.",
|
||||
"exportGpx": "Esporta GPX",
|
||||
"elevationTitle": "Dislivello",
|
||||
"splitsTitle": "Parziali",
|
||||
"splitsKm": "km",
|
||||
"splitsTime": "Tempo",
|
||||
"splitsPace": "Ritmo",
|
||||
"thatDayTitle": "Quel giorno",
|
||||
"dayPulse": "Battito",
|
||||
"daySleep": "Sonno",
|
||||
"dayMood": "Umore",
|
||||
"insightTitle": "Analisi dell'attività"
|
||||
},
|
||||
"sport": {
|
||||
"walking": "Camminata",
|
||||
@@ -2928,7 +3112,11 @@
|
||||
"crossTraining": "Cross-training",
|
||||
"mixedCardio": "Cardio misto",
|
||||
"other": "Altro"
|
||||
}
|
||||
},
|
||||
"loadError": "Non è stato possibile caricare i tuoi allenamenti",
|
||||
"loadMore": "Carica altri allenamenti",
|
||||
"loadingMore": "Caricamento di altri allenamenti",
|
||||
"loadMoreError": "Non è stato possibile caricare altri allenamenti"
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Glicemia",
|
||||
@@ -3238,6 +3426,8 @@
|
||||
},
|
||||
"navWalkingSpeed": "Velocità di cammino",
|
||||
"mood": {
|
||||
"logLink": "Registra un umore",
|
||||
"mentalWellbeingLink": "Fai un check-in sul benessere mentale",
|
||||
"heatmapTitle": "Calendario dell'umore",
|
||||
"heatmapNoEntry": "Nessun dato",
|
||||
"legendGreat": "Ottimo",
|
||||
@@ -3525,7 +3715,9 @@
|
||||
"sectionTitle": "I tuoi indicatori di salute",
|
||||
"sectionSubtitle": "Valori combinati di recupero, stress e sforzo letti dai tuoi dispositivi connessi",
|
||||
"loadError": "Al momento non è stato possibile caricare i tuoi punteggi di benessere.",
|
||||
"recovery": "Recupero",
|
||||
"loadingLabel": "Caricamento dei tuoi indicatori di salute",
|
||||
"recovery": "Punteggio di recupero",
|
||||
"recoveryCrossLink": "Vedi i dati di recupero e sforzo del tuo wearable",
|
||||
"stress": "Stress",
|
||||
"strain": "Sforzo"
|
||||
},
|
||||
@@ -3696,6 +3888,7 @@
|
||||
"recovery": {
|
||||
"title": "Recupero",
|
||||
"description": "Quanto bene il tuo corpo si è ripreso: i segnali di recupero, sforzo e carico che il tuo wearable registra durante la notte.",
|
||||
"scoreCrossLink": "Vedi il punteggio di recupero composito",
|
||||
"emptyTitle": "Ancora nessun dato di recupero",
|
||||
"empty": "Ancora nessun dato di recupero. Collega un account WHOOP, Polar o Oura e questi punteggi si popoleranno appena il dispositivo si sincronizza.",
|
||||
"block": {
|
||||
@@ -3866,13 +4059,18 @@
|
||||
"recordingsMany": "{count} registrazioni disponibili.",
|
||||
"latestResult": "Ultimo risultato del dispositivo: {result}.",
|
||||
"cta": "Vedi registrazioni"
|
||||
}
|
||||
},
|
||||
"emptyTitle": "Ancora nessuna registrazione ECG",
|
||||
"emptyDescription": "Le registrazioni ECG si sincronizzano da un dispositivo a singola derivazione compatibile. Non appena il dispositivo carica una registrazione, comparirà qui.",
|
||||
"emptyCta": "Collega un dispositivo"
|
||||
},
|
||||
"intradayPulse": {
|
||||
"title": "L'andamento della tua giornata",
|
||||
"caption": "La tua frequenza cardiaca durante il giorno, mediata in intervalli di 10 minuti.",
|
||||
"hourlyNote": "Media oraria per questo giorno — i giorni recenti mostrano il dettaglio completo a 10 minuti.",
|
||||
"empty": "Non ci sono ancora abbastanza misurazioni per questo giorno per mostrarne l'andamento.",
|
||||
"coverageOne": "Basato su 1 misurazione in {hours}h.",
|
||||
"coverageMany": "Basato su {count} misurazioni in {hours}h.",
|
||||
"baseline": "Riposo",
|
||||
"today": "Oggi",
|
||||
"previousDay": "Giorno precedente",
|
||||
@@ -3883,7 +4081,8 @@
|
||||
"evening": "Un tratto elevato a riposo stasera — forse un po' di tensione.",
|
||||
"night": "Un tratto elevato a riposo durante la notte — forse un po' di tensione."
|
||||
}
|
||||
}
|
||||
},
|
||||
"navEcg": "ECG"
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Predefinito",
|
||||
@@ -3982,7 +4181,7 @@
|
||||
},
|
||||
"wrapUp": {
|
||||
"title": "Questa è la visita guidata",
|
||||
"body": "Puoi rivedere ogni modulo singolarmente in qualsiasi momento («Mostra questa visita» su ogni pagina) o riavviare l’intera visita da Impostazioni → Avanzate."
|
||||
"body": "Questa visita guidata viene mostrata una sola volta: non si riaprirà da sola. Tutto ciò che hai visto resta al suo posto e puoi attivare o disattivare le funzioni quando vuoi da Impostazioni → Moduli."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4010,7 +4209,7 @@
|
||||
},
|
||||
"source": {
|
||||
"title": "Connetti una sorgente",
|
||||
"body": "Importa le misurazioni in automatico oppure resta sull'inserimento manuale. Qui non viene configurato nulla al posto tuo: ogni scheda mostra solo dove collegarla.",
|
||||
"body": "Importa le misurazioni in automatico oppure resta sull'inserimento manuale. Qui non viene configurato nulla al posto tuo: ogni scheda mostra solo dove collegarla. Le fonti su dispositivo richiedono un'app sviluppatore gratuita sul tuo account (circa 5 minuti); la guida su ogni scheda ti accompagna.",
|
||||
"recommended": "Consigliato",
|
||||
"manual": {
|
||||
"title": "Inserimento manuale",
|
||||
@@ -4018,12 +4217,14 @@
|
||||
},
|
||||
"withings": {
|
||||
"title": "Withings",
|
||||
"body": "Bilance, misuratori di pressione e tracker Withings si sincronizzano automaticamente.",
|
||||
"cta": "Connetti Withings"
|
||||
"body": "Bilance, misuratori di pressione e tracker Withings si sincronizzano automaticamente una volta collegati.",
|
||||
"cta": "Connetti Withings",
|
||||
"setupCta": "Configura nelle Impostazioni",
|
||||
"setupHint": "Richiede prima la tua app sviluppatore Withings gratuita — circa 5 minuti, una tantum."
|
||||
},
|
||||
"more": {
|
||||
"title": "Altre fonti",
|
||||
"body": "Collega una di queste nelle Impostazioni quando vuoi.",
|
||||
"body": "Collega una di queste nelle Impostazioni quando vuoi — la maggior parte richiede prima un'app sviluppatore gratuita sul tuo account.",
|
||||
"appleHealth": "Apple Health",
|
||||
"whoop": "WHOOP",
|
||||
"oura": "Oura",
|
||||
@@ -4055,7 +4256,8 @@
|
||||
"learning": "Alcune analisi — glicemia, ritmo del sonno, tendenze della pressione, ciclo — si affinano nella prima settimana o due man mano che i tuoi dati si accumulano. Le caselle vuote ora indicano solo che stiamo ancora imparando il tuo riferimento.",
|
||||
"connectCta": "Collega un dispositivo",
|
||||
"logCta": "Registra la tua prima misurazione",
|
||||
"importCta": "Importa la cronologia esistente"
|
||||
"importCta": "Importa la cronologia esistente",
|
||||
"modulesCta": "Scegli i tuoi moduli"
|
||||
},
|
||||
"welcomeBack": {
|
||||
"title": "Bentornato",
|
||||
@@ -4165,7 +4367,16 @@
|
||||
"disableError": "Impossibile disattivare la modalità Ricerca. Riprova."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Sezioni delle impostazioni"
|
||||
"sectionsNav": "Sezioni delle impostazioni",
|
||||
"groups": {
|
||||
"account": "Account",
|
||||
"yourData": "I tuoi dati",
|
||||
"display": "Visualizzazione",
|
||||
"healthProfile": "Profilo sanitario",
|
||||
"ai": "IA",
|
||||
"connectivity": "Connettività",
|
||||
"system": "Sistema"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
@@ -4350,7 +4561,8 @@
|
||||
},
|
||||
"fullBackup": {
|
||||
"title": "Backup completo",
|
||||
"description": "Un unico file JSON con tutto — stesso formato del backup automatico settimanale. Utile per il ripristino autonomo tramite il caricamento da amministratore.",
|
||||
"description": "Tutti i tuoi dati in un unico file JSON: misurazioni, farmaci, umore, ciclo, esami di laboratorio, episodi di malattia, allergie, anamnesi familiare e allenamenti. Stesso formato del backup automatico settimanale.",
|
||||
"scopeNote": "I metadati dei documenti e il riepilogo IA, se presente, sono inclusi in chiaro; i file originali dei documenti e i dati di percorso GPS/campionamento degli allenamenti non lo sono — scaricali separatamente dall'archivio documenti o dall'esportazione degli allenamenti.",
|
||||
"encryptToggle": "Cifra con una passphrase",
|
||||
"passphrasePlaceholder": "Passphrase (min. 12 caratteri)",
|
||||
"passphraseHelp": "Deriva la chiave dell'archivio con Argon2id e lo sigilla con AES-256-GCM. Salva il file con estensione .hlx.",
|
||||
@@ -4381,6 +4593,7 @@
|
||||
"failed": "L'importazione non è riuscita.",
|
||||
"rowsImported": "{count} righe importate finora",
|
||||
"doneSummary": "Fatto: {imported} di {read} record importati, {skipped} record clinici saltati.",
|
||||
"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…",
|
||||
"unpacking": "Estrazione dell'archivio…",
|
||||
@@ -4510,7 +4723,7 @@
|
||||
"description": "Record strutturati di allergie e anamnesi familiare."
|
||||
},
|
||||
"environment": {
|
||||
"title": "Ambiente",
|
||||
"title": "Posizione e meteo",
|
||||
"subtitle": "Contesto meteo, luce del giorno e posizione.",
|
||||
"disabledHint": "Attiva il modulo Ambiente per impostare una posizione principale e integrare il meteo e le ore di luce giornaliere.",
|
||||
"searchPlaceholder": "Cerca una città",
|
||||
@@ -4539,7 +4752,9 @@
|
||||
"description": "Aggiungi un luogo e un intervallo di date: un viaggio o un tratto della tua cronologia passata. Il periodo prevale sulla tua residenza nei giorni che copre, così il meteo corrisponde a dove ti trovavi davvero. È così che correggi il passato.",
|
||||
"none": "Nessun periodo di posizione.",
|
||||
"add": "Aggiungi {label}",
|
||||
"remove": "Rimuovi periodo di posizione"
|
||||
"remove": "Rimuovi periodo di posizione",
|
||||
"removeConfirmTitle": "Rimuovere questo viaggio?",
|
||||
"removeConfirmBody": "Le misurazioni di quel periodo restano nel tuo archivio; sparisce solo la voce del viaggio."
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Recupera i giorni passati",
|
||||
@@ -4820,7 +5035,10 @@
|
||||
"surfaced": "Mostrato",
|
||||
"done": "Fatto",
|
||||
"dismissed": "Ignorato"
|
||||
}
|
||||
},
|
||||
"deleteConfirmTitle": "Eliminare questo promemoria?",
|
||||
"deleteConfirmBody": "Non verrà più mostrato. Quanto hai già registrato resta invariato.",
|
||||
"deleteConfirmAction": "Elimina"
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "Su di me",
|
||||
@@ -4866,6 +5084,16 @@
|
||||
"honesty": "L'accesso condiviso è un unico account IA con accesso eseguito che l'operatore ha collegato per tutti su questo server, non la tua chiave. Le richieste che vi effettui vengono usate per impostazione predefinita per addestrare i modelli del provider e nessun accordo sul trattamento dei dati le copre, i limiti orari e settimanali dell'account sono condivisi con tutti gli altri che hanno aderito, e i tuoi provider vengono comunque provati per primi. Attivalo solo se accetti questo compromesso.",
|
||||
"confirm": "Ho capito — usa la connessione condivisa",
|
||||
"cancel": "Annulla"
|
||||
},
|
||||
"consent": {
|
||||
"title": "Consenso IA",
|
||||
"activeSince": "Concesso il {date}. I tuoi dati sanitari possono essere inviati al provider configurato per le funzioni IA.",
|
||||
"withdrawn": "Revocato. Nessun dato sanitario viene inviato a un provider IA; le funzioni IA restano disattivate.",
|
||||
"withdraw": "Revoca il consenso",
|
||||
"confirmBody": "Le funzioni IA si fermano subito. Nulla di quanto già registrato viene eliminato — usa Dati e privacy per esportare o cancellare il tuo archivio.",
|
||||
"confirmWithdraw": "Revoca",
|
||||
"regrantHint": "Per usare di nuovo le funzioni IA, attiva la lettura automatica dei documenti o concedi il consenso dall'app.",
|
||||
"error": "Non è stato possibile salvare la revoca. Riprova."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -5052,7 +5280,8 @@
|
||||
"resumeError": "Riconnessione fallita"
|
||||
},
|
||||
"apiTokens": "Token API",
|
||||
"apiTokensDescription": "Crea token API per registrare assunzioni di farmaci esterne (ad es. Comandi rapidi, automazioni).",
|
||||
"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.",
|
||||
"tokenNamePlaceholder": "Nome del token (ad es. Comando rapido iPhone)",
|
||||
"tokenCreated": "Token creato — copialo ora! Non verrà mostrato di nuovo.",
|
||||
"tokenCopy": "Copia token",
|
||||
@@ -5312,7 +5541,7 @@
|
||||
"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",
|
||||
@@ -5383,6 +5612,12 @@
|
||||
"ouraDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
|
||||
"nightscoutDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la",
|
||||
"integrationCredentialsHint": "Dove trovare queste credenziali e come registrare la tua app è spiegato nella guida alla configurazione qui sopra.",
|
||||
"integrationRedirectGuide": {
|
||||
"intro": "Questa connessione passa dalla tua app sviluppatore {provider} — circa 5 minuti di configurazione:",
|
||||
"step1": "Crea un'app nel portale sviluppatori {provider}.",
|
||||
"step2": "Imposta il suo URL di reindirizzamento/callback su:",
|
||||
"step3": "Incolla qui sopra l'ID client e il client secret ricevuti."
|
||||
},
|
||||
"security": {
|
||||
"sessionsTitle": "Sessioni attive",
|
||||
"sessionsDescription": "Dispositivi e browser attualmente connessi al tuo account.",
|
||||
@@ -5478,8 +5713,16 @@
|
||||
"revokeAllDone": "Dimenticati {count} dispositivo/i attendibile/i.",
|
||||
"unnamed": "Dispositivo sconosciuto",
|
||||
"current": "Questo dispositivo",
|
||||
"expires": "Attendibile fino al {date}"
|
||||
}
|
||||
"expires": "Attendibile fino al {date}",
|
||||
"revokeConfirmTitle": "Non fidarsi più di questo dispositivo?",
|
||||
"revokeConfirmBody": "Al prossimo accesso da questo dispositivo chiederà di nuovo il secondo fattore.",
|
||||
"revokeAllConfirmTitle": "Non fidarsi più di alcun dispositivo?",
|
||||
"revokeAllConfirmBody": "Ogni dispositivo chiederà di nuovo il secondo fattore al prossimo accesso, questo compreso."
|
||||
},
|
||||
"revokeSessionConfirmTitle": "Disconnettere questo dispositivo?",
|
||||
"revokeSessionConfirmBody": "{device} dovrà accedere di nuovo. Quanto registrato su di esso non viene toccato.",
|
||||
"signOutEverywhereConfirmTitle": "Disconnettere tutti gli altri dispositivi?",
|
||||
"signOutEverywhereConfirmBody": "Ogni dispositivo tranne questo dovrà accedere di nuovo. Questo resta connesso. Nulla di ciò che hai registrato viene toccato."
|
||||
},
|
||||
"privacy": {
|
||||
"intro": {
|
||||
@@ -5579,10 +5822,34 @@
|
||||
"rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.",
|
||||
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
|
||||
},
|
||||
"appleHealth": {
|
||||
"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"
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin non ha una connessione diretta qui. Collegalo tramite Apple Health (iOS) o Google Health Connect (Android). I punteggi proprietari di Garmin — Body Battery, Training Load, stato HRV, VO₂ max — non vengono condivisi da Garmin con nessuna delle due piattaforme.",
|
||||
"docsLink": "Leggi la guida"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Banda di riferimento della glicemia",
|
||||
"description": "La banda con cui vengono valutati i tuoi valori glicemici.",
|
||||
"enable": "Usa la banda obiettivo per il diabete",
|
||||
"explainer": "Disattivato, i valori vengono valutati con la banda di riferimento generale non diabetica. Attivato, vengono valutati con la banda obiettivo glicemica più stretta degli ADA Standards of Care, e il Coach fonda su quella banda le sue osservazioni sulla glicemia. I tuoi valori obiettivo personalizzati qui sotto hanno comunque la precedenza.",
|
||||
"disclaimer": "Questo seleziona una banda di riferimento. Non è una diagnosi, non viene mai dedotto da una misurazione e l'app non agisce clinicamente di conseguenza. Quali obiettivi valgano per te è una domanda per il tuo medico.",
|
||||
"error": "Non è stato possibile salvare l'impostazione."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -6023,6 +6290,7 @@
|
||||
"colAction": "Azione",
|
||||
"colDuration": "Durata",
|
||||
"colTraceId": "Trace",
|
||||
"viewDetails": "Visualizza i dettagli di {action} alle {timestamp}",
|
||||
"showing": "Visualizzati {count} di un massimo di {bufferMax} eventi nel buffer",
|
||||
"empty": "Il buffer è vuoto",
|
||||
"emptyDescription": "Nessun evento corrisponde ancora ai filtri attuali. Premi Aggiorna dopo aver generato un po' di traffico.",
|
||||
@@ -6643,9 +6911,9 @@
|
||||
"hypertensionGrade1": "Ipertensione grado 1",
|
||||
"hypertensionGrade2": "Ipertensione grado 2",
|
||||
"hypertensionGrade3": "Ipertensione grado 3",
|
||||
"bradycardia": "Bradicardia",
|
||||
"belowTypicalRange": "Sotto il range tipico",
|
||||
"elevated": "Elevato",
|
||||
"tachycardia": "Tachicardia",
|
||||
"aboveTypicalRange": "Sopra il range tipico",
|
||||
"significantlyLow": "Significativamente basso",
|
||||
"slightlyLow": "Lievemente basso",
|
||||
"onTarget": "Nel range obiettivo",
|
||||
@@ -7320,7 +7588,7 @@
|
||||
"referenceLabel": "Riferimento",
|
||||
"readingsCount": "{count} misurazioni",
|
||||
"deleteConfirmTitle": "Eliminare il risultato di laboratorio?",
|
||||
"deleteConfirmDescription": "Questo risultato di laboratorio verrà eliminato definitivamente.",
|
||||
"deleteConfirmDescription": "Questo risultato di laboratorio verrà eliminato. Subito dopo potrai annullare l’operazione per un breve momento.",
|
||||
"deletedToast": "Risultato eliminato",
|
||||
"deleteError": "Impossibile eliminare il risultato",
|
||||
"loadError": "Impossibile caricare i risultati",
|
||||
@@ -7351,6 +7619,7 @@
|
||||
"notePlaceholder": "Contesto facoltativo per questo risultato",
|
||||
"requiredError": "Inserisci un valore.",
|
||||
"save": "Salva",
|
||||
"saveAndAddAnother": "Salva e aggiungi un altro",
|
||||
"savedToast": "Risultato salvato",
|
||||
"saveError": "Impossibile salvare il risultato",
|
||||
"biomarker": "Biomarcatore",
|
||||
@@ -7798,7 +8067,7 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Eliminare questo episodio?",
|
||||
"body": "«{label}» e tutti i relativi registri giornalieri verranno rimossi. L'operazione non può essere annullata."
|
||||
"body": "«{label}» e tutti i relativi registri giornalieri verranno rimossi. Subito dopo potrai annullare l’operazione per un breve momento."
|
||||
},
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "Frequenza cardiaca a riposo",
|
||||
@@ -7881,6 +8150,11 @@
|
||||
"review_due": "Verifica in scadenza",
|
||||
"reviewed": "Valutato"
|
||||
}
|
||||
},
|
||||
"refusal": {
|
||||
"outOfScope": "Posso aiutarti solo con i dati di salute del tuo registro: misurazioni, farmaci, umore, tendenze. Non rispondo a domande al di fuori di questo ambito.",
|
||||
"promptInjection": "Ho notato istruzioni che cercano di aggirare le mie direttive. Sono il Coach di HealthLog e riassumo soltanto i tuoi dati di salute — riformula la domanda in questi termini, per favore.",
|
||||
"conversationPoisoned": "Un messaggio precedente di questa conversazione contiene istruzioni che cercano di aggirare le mie direttive. Inizia una nuova conversazione, per favore."
|
||||
}
|
||||
},
|
||||
"safety": {
|
||||
@@ -7892,14 +8166,14 @@
|
||||
"bpLowDoctor": "Due misurazioni recenti avevano una sistolica sotto {systolic} mmHg. Se ti senti stordito o debole, siediti o sdraiati e contatta il medico se continua.",
|
||||
"bpLowEmergency": "Due misurazioni recenti avevano una sistolica sotto {systolic} mmHg e hai segnalato sintomi. Se senti che stai per svenire, sei confuso o hai la pelle fredda e sudata, chiama subito i soccorsi.",
|
||||
"glucoseLowTitle": "Glicemia bassa",
|
||||
"glucoseLowDoctor": "Due misurazioni recenti erano sotto 70 mg/dL ({value} mg/dL). Assumi carboidrati ad azione rapida, rimisura tra 15 minuti e contatta il medico se i cali continuano.",
|
||||
"glucoseLowEmergency": "Due misurazioni recenti erano sotto 70 mg/dL ({value} mg/dL) e hai segnalato sintomi. Tratta subito con zucchero rapido; se non puoi o ti senti molto male, chiama i soccorsi.",
|
||||
"glucoseLowDoctor": "Due misurazioni recenti erano sotto {threshold} {unit} ({value} {unit}). Assumi carboidrati ad azione rapida, rimisura tra 15 minuti e contatta il medico se i cali continuano.",
|
||||
"glucoseLowEmergency": "Due misurazioni recenti erano sotto {threshold} {unit} ({value} {unit}) e hai segnalato sintomi. Tratta subito con zucchero rapido; se non puoi o ti senti molto male, chiama i soccorsi.",
|
||||
"glucoseLowSevereTitle": "Glicemia molto bassa",
|
||||
"glucoseLowSevereDoctor": "Due misurazioni recenti erano sotto 54 mg/dL ({value} mg/dL). Tratta subito con carboidrati ad azione rapida, rimisura tra 15 minuti e contatta il medico.",
|
||||
"glucoseLowSevereEmergency": "Due misurazioni recenti erano sotto 54 mg/dL ({value} mg/dL) e hai segnalato sintomi. Tratta subito; se non trattieni il cibo o ti senti molto male, chiama i soccorsi.",
|
||||
"glucoseLowSevereDoctor": "Due misurazioni recenti erano sotto {threshold} {unit} ({value} {unit}). Tratta subito con carboidrati ad azione rapida, rimisura tra 15 minuti e contatta il medico.",
|
||||
"glucoseLowSevereEmergency": "Due misurazioni recenti erano sotto {threshold} {unit} ({value} {unit}) e hai segnalato sintomi. Tratta subito; se non trattieni il cibo o ti senti molto male, chiama i soccorsi.",
|
||||
"glucoseHighTitle": "Glicemia molto alta",
|
||||
"glucoseHighDoctor": "Due misurazioni recenti erano di {value} mg/dL o superiori. Rimisura, idratati e contatta il medico al più presto se resta così alta.",
|
||||
"glucoseHighEmergency": "Due misurazioni recenti erano di {value} mg/dL o superiori e hai segnalato sintomi. In caso di vomito, respiro affannoso o confusione, chiama subito i soccorsi."
|
||||
"glucoseHighDoctor": "Due misurazioni recenti erano di {value} {unit} o superiori. Rimisura, idratati e contatta il medico al più presto se resta così alta.",
|
||||
"glucoseHighEmergency": "Due misurazioni recenti erano di {value} {unit} o superiori e hai segnalato sintomi. In caso di vomito, respiro affannoso o confusione, chiama subito i soccorsi."
|
||||
}
|
||||
},
|
||||
"version": {
|
||||
@@ -7912,6 +8186,7 @@
|
||||
"mentalHealth": {
|
||||
"pageTitle": "Benessere mentale",
|
||||
"pageDescription": "Quattro autovalutazioni validate e facoltative — PHQ-9 (umore), GAD-7 (preoccupazione), WHO-5 (benessere) e SCI (sonno) — accanto al tuo diario dell'umore. Ciò che conta è l'andamento nel tempo, non un singolo numero.",
|
||||
"moodLink": "Vedi il tuo andamento dell'umore",
|
||||
"attributionLabel": "Fonte",
|
||||
"attributionShow": "Fonte e licenza di {instrument}",
|
||||
"choosePrompt": "Scegli un check-in",
|
||||
@@ -8036,6 +8311,12 @@
|
||||
"nummerGegenKummer": {
|
||||
"name": "Nummer gegen Kummer (per i giovani)"
|
||||
},
|
||||
"telefonSeelsorgeAt": {
|
||||
"name": "Telefonseelsorge Österreich (24/7, gratuito e riservato)"
|
||||
},
|
||||
"dargeboteneHand": {
|
||||
"name": "Die Dargebotene Hand (24/7, gratuito e riservato)"
|
||||
},
|
||||
"krisenchat": {
|
||||
"name": "krisenchat (chat 24/7, per i giovani)"
|
||||
},
|
||||
@@ -8126,7 +8407,9 @@
|
||||
"finally": "Finally …"
|
||||
}
|
||||
},
|
||||
"openDetail": "Mostra dettagli"
|
||||
"openDetail": "Mostra dettagli",
|
||||
"historyLoadError": "Non è stato possibile caricare la cronologia dei test",
|
||||
"historyLoadErrorHint": "Questo non significa che non ci siano risultati: non è stato possibile recuperare l'elenco. Riprova."
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "Sono i tuoi dati, non una diagnosi medica. HealthLog non diagnostica, non cura e non li interpreta.",
|
||||
@@ -8289,7 +8572,9 @@
|
||||
"openLabel": "Apri {title}",
|
||||
"attachmentBadge": "Solo download",
|
||||
"uploading": "Caricamento…",
|
||||
"dismissFailed": "Ignora caricamento non riuscito"
|
||||
"dismissFailed": "Ignora caricamento non riuscito",
|
||||
"processing": "Elaborazione…",
|
||||
"ready": "Pronto"
|
||||
},
|
||||
"error": {
|
||||
"fileTooLarge": "Questo file supera il limite di {maxSize}.",
|
||||
@@ -8331,6 +8616,16 @@
|
||||
},
|
||||
"detail": {
|
||||
"title": "Dettagli del documento",
|
||||
"summary": {
|
||||
"title": "Riepilogo",
|
||||
"pending": "Il riepilogo è in preparazione.",
|
||||
"none": "Non è ancora stato generato alcun riepilogo per questo documento.",
|
||||
"withheld": "Un riepilogo è stato generato ma non ha superato il controllo di sicurezza, quindi non viene mostrato. Puoi riprovare o leggere il documento stesso.",
|
||||
"unavailable": "Non è stato possibile generare un riepilogo per questo documento.",
|
||||
"generate": "Genera riepilogo",
|
||||
"regenerate": "Genera di nuovo",
|
||||
"generating": "Generazione…"
|
||||
},
|
||||
"loadError": "Impossibile caricare questo documento.",
|
||||
"saveError": "Impossibile salvare la modifica.",
|
||||
"deleteError": "Impossibile eliminare il documento.",
|
||||
@@ -8345,7 +8640,8 @@
|
||||
"noConditions": "Non collegato a una patologia",
|
||||
"previewUnavailable": "Nessuna anteprima integrata per questo tipo di file",
|
||||
"uploadedMeta": "Aggiunto il {date} · {size}",
|
||||
"share": "Condividi"
|
||||
"share": "Condividi",
|
||||
"labValuesLink": "Vedi {count} valori di laboratorio"
|
||||
},
|
||||
"dropOverlay": {
|
||||
"title": "Rilascia i file per caricarli",
|
||||
@@ -8410,7 +8706,6 @@
|
||||
"close": "Nascondi"
|
||||
},
|
||||
"ai": {
|
||||
"statusAiRead": "Letto dall’IA",
|
||||
"readDone": "L’IA ha letto il tuo documento.",
|
||||
"read": "Leggi con l’IA",
|
||||
"reread": "Leggi di nuovo",
|
||||
@@ -8566,6 +8861,7 @@
|
||||
"quickAddCustomPlaceholder": "250",
|
||||
"quickAddSubmit": "Aggiungi",
|
||||
"quickAddError": "Salvataggio non riuscito. Riprova.",
|
||||
"quickAddSuccess": "Aggiunti {amount} mL di acqua.",
|
||||
"editTotal": "Modifica il totale di oggi"
|
||||
},
|
||||
"caffeine": {
|
||||
@@ -8576,14 +8872,15 @@
|
||||
"rowMeta": "{days} di {window} giorni · rif. {value} {unit} (EFSA)",
|
||||
"rowMetaNoRef": "{days} di {window} giorni",
|
||||
"footer": "{tracked} di {total} micronutrienti monitorati hanno dati in questo periodo.",
|
||||
"emptyTitle": "Nessun dato sui micronutrienti ancora sincronizzato"
|
||||
"emptyTitle": "Nessun dato sui micronutrienti ancora sincronizzato",
|
||||
"loadError": "Non è stato possibile caricare i tuoi dati sui nutrienti"
|
||||
},
|
||||
"sparseNote": "Un valore basso qui di solito indica una registrazione poco frequente, non una carenza — i totali giornalieri di Apple Health riflettono ciò che le tue app hanno registrato, non l'apporto alimentare totale."
|
||||
},
|
||||
"daily": {
|
||||
"line": {
|
||||
"score": "Il tuo punteggio di salute oggi è {score}.",
|
||||
"allClear": "Oggi nulla richiede la tua attenzione — tutto procede normalmente."
|
||||
"allClear": "Oggi nulla richiede la tua attenzione."
|
||||
},
|
||||
"push": {
|
||||
"title": "Il tuo riepilogo mattutino",
|
||||
@@ -8592,8 +8889,11 @@
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
"title": "Farmaco da assumere",
|
||||
"body": "Oggi è prevista una dose.",
|
||||
"bodyNamed": "{name} è previsto oggi."
|
||||
"body": "Una dose è prevista a breve.",
|
||||
"bodyNamed": "{name} è previsto a breve.",
|
||||
"overdueTitle": "Farmaco in ritardo",
|
||||
"overdueBody": "Una dose è in ritardo.",
|
||||
"overdueBodyNamed": "{name} è in ritardo."
|
||||
},
|
||||
"preventiveCare": {
|
||||
"title": "Controllo da fare",
|
||||
@@ -8607,7 +8907,9 @@
|
||||
"coachCheckin": {
|
||||
"title": "È il momento di rivedere un piano",
|
||||
"body": "È passata circa una settimana da quando hai definito questo piano: mantienilo, modificalo o lascialo andare. Senza pressioni.",
|
||||
"bodyPlan": "Circa una settimana fa ti eri proposto: «{plan}». Mantienilo, modificalo o lascialo andare, senza pressioni."
|
||||
"bodyPlan": "Circa una settimana fa ti eri proposto: «{plan}». Mantienilo, modificalo o lascialo andare, senza pressioni.",
|
||||
"adjustPrompt": "Vorrei modificare il mio piano di controllo.",
|
||||
"adjustPromptPlan": "Vorrei modificare questo piano: «{plan}»"
|
||||
},
|
||||
"tensionWindow": {
|
||||
"title": "Forse un po' di tensione oggi",
|
||||
@@ -8645,7 +8947,8 @@
|
||||
"deltaVsBaseline": "{delta} rispetto al tuo valore di riferimento",
|
||||
"deltaFlat": "In linea con il tuo valore di riferimento",
|
||||
"sleepPending": "Il sonno di stanotte non è ancora arrivato: la lettura di oggi è provvisoria.",
|
||||
"readFullBriefing": "Leggi il briefing completo",
|
||||
"justIn": "Appena arrivato",
|
||||
"justInAt": "Appena arrivato · {time}",
|
||||
"worthALook": "Da tenere d'occhio",
|
||||
"allClear": "Oggi nulla richiede la tua attenzione.",
|
||||
"ringLink": "Apri i dettagli di {metric}"
|
||||
|
||||
+368
-71
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"save": "Zapisz",
|
||||
"conflictReloaded": "Odświeżono z serwera. Wprowadź zmianę jeszcze raz.",
|
||||
"cancel": "Anuluj",
|
||||
"delete": "Usuń",
|
||||
"undo": "Cofnij",
|
||||
@@ -42,7 +43,58 @@
|
||||
"hidePassword": "Ukryj hasło",
|
||||
"learnMore": "Dowiedz się więcej",
|
||||
"dismiss": "Odrzuć",
|
||||
"moreInfo": "Więcej informacji"
|
||||
"moreInfo": "Więcej informacji",
|
||||
"durationHoursMinutes": "{hours} godz. {minutes} min",
|
||||
"durationMinutes": "{minutes} min"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
"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." },
|
||||
"duplicate_timestamp": "Wpis nastroju dla tego czasu już istnieje.",
|
||||
"not_found": "Ten wpis nastroju nie jest już dostępny.",
|
||||
"ratedFactor": {
|
||||
"out_of_range": "Ocena czynnika jest poza dozwolonym zakresem."
|
||||
}
|
||||
},
|
||||
"labs": {
|
||||
"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." },
|
||||
"update": {
|
||||
"invalid": "Sprawdź dane biomarkera i spróbuj ponownie.",
|
||||
"referenceRangeInvalid": "Dolna wartość referencyjna nie może przekraczać górnej."
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"invalid": "Sprawdź wynik badania i spróbuj ponownie.",
|
||||
"linkedFieldsImmutable": "Edytuj biomarker, aby zmienić nazwę, jednostkę, panel lub zakres referencyjny.",
|
||||
"qualitativeExpected": "Wprowadź wynik tekstowy dla tego odczytu jakościowego.",
|
||||
"numericExpected": "Wprowadź liczbę dla tego odczytu ilościowego.",
|
||||
"referenceRangeInvalid": "Dolna wartość referencyjna nie może przekraczać górnej."
|
||||
}
|
||||
},
|
||||
"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." },
|
||||
"step": {
|
||||
"rateLimited": "Zbyt wiele prób. Zaczekaj i spróbuj ponownie.",
|
||||
"invalid": "Nie udało się zapisać tego kroku wprowadzenia.",
|
||||
"completed": "Wprowadzenie zostało już ukończone.",
|
||||
"outOfOrder": "Twój postęp się zmienił. Odśwież stronę i kontynuuj.",
|
||||
"concurrent": "Twój postęp się zmienił. Odśwież stronę i kontynuuj."
|
||||
}
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
"title": "Raport zdrowotny",
|
||||
@@ -91,7 +143,7 @@
|
||||
"colShare": "Udział",
|
||||
"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: Europe/Berlin | Utworzono: {timestamp}",
|
||||
"footerSource": "Źródło: HealthLog | Strefa czasowa: {timezone} | Utworzono: {timestamp}",
|
||||
"typeWeight": "Masa ciała",
|
||||
"typeBpSys": "Ciśnienie skurczowe",
|
||||
"typeBpDia": "Ciśnienie rozkurczowe",
|
||||
@@ -228,7 +280,7 @@
|
||||
"logout": "Wyloguj",
|
||||
"login": "Zaloguj",
|
||||
"notifications": "Powiadomienia",
|
||||
"theme": "Wygląd",
|
||||
"theme": "Motyw",
|
||||
"themeSystem": "Systemowy",
|
||||
"themeDark": "Ciemny",
|
||||
"themeLight": "Jasny",
|
||||
@@ -253,7 +305,9 @@
|
||||
"medication": "Lek",
|
||||
"medicationDescription": "Oznacz dawkę jako przyjętą",
|
||||
"mood": "Nastrój",
|
||||
"moodDescription": "Zapisz nastrój i tagi"
|
||||
"moodDescription": "Zapisz nastrój i tagi",
|
||||
"water": "Woda",
|
||||
"waterDescription": "Szybko dodaj ilość wypitej wody"
|
||||
},
|
||||
"cycle": "Cykl",
|
||||
"labs": "Wyniki badań",
|
||||
@@ -295,6 +349,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.",
|
||||
@@ -356,6 +413,7 @@
|
||||
"quickAddMeasurement": "Zarejestruj pomiar",
|
||||
"quickAddMood": "Zarejestruj nastrój",
|
||||
"quickAddMedicationIntake": "Zarejestruj przyjęcie",
|
||||
"quickAddWater": "Zapisz wodę",
|
||||
"customizeDashboard": "Dostosuj pulpit",
|
||||
"medicationIntakeQuickAdd": {
|
||||
"sheetTitle": "Zarejestruj przyjęcie leku",
|
||||
@@ -385,6 +443,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",
|
||||
@@ -400,12 +459,13 @@
|
||||
"staleHint": "Ostatni odczyt {count} d temu",
|
||||
"staleHintWeeksOne": "Ostatni odczyt 1 tydzień temu",
|
||||
"staleHintWeeksOther": "Ostatni odczyt {count} tygodni temu",
|
||||
"staleHintWeeksFew": "Ostatni odczyt {count} tygodnie temu",
|
||||
"staleHintMonthsOne": "Ostatni odczyt 1 miesiąc temu",
|
||||
"staleHintMonthsOther": "Ostatni odczyt {count} miesięcy temu",
|
||||
"staleHintMonthsFew": "Ostatni odczyt {count} miesiące temu",
|
||||
"recentWorkouts": {
|
||||
"title": "Ostatnie treningi",
|
||||
"viewAll": "Zobacz wszystkie",
|
||||
"loading": "Wczytywanie treningów…",
|
||||
"empty": {
|
||||
"title": "Brak treningów",
|
||||
"cta": "Otwórz aplikację Apple Zdrowie na iPhonie, aby zsynchronizować treningi."
|
||||
@@ -422,7 +482,9 @@
|
||||
"steps": "Kroki",
|
||||
"totalBodyWater": "Całkowita woda w organizmie",
|
||||
"boneMass": "Masa kostna",
|
||||
"oxygenSaturation": "Saturacja tlenem"
|
||||
"oxygenSaturation": "Saturacja tlenem",
|
||||
"mood": "Nastrój",
|
||||
"bmi": "BMI"
|
||||
},
|
||||
"unit": {
|
||||
"weight": "kg",
|
||||
@@ -434,7 +496,9 @@
|
||||
"steps": "kroki",
|
||||
"totalBodyWater": "kg",
|
||||
"boneMass": "kg",
|
||||
"oxygenSaturation": "%"
|
||||
"oxygenSaturation": "%",
|
||||
"mood": "/5",
|
||||
"bmi": "kg/m²"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -575,7 +639,7 @@
|
||||
"bulkDeleteSuccess": "Usunięto pomiary: {count}",
|
||||
"bulkDeleteError": "Błąd podczas usuwania wybranych pomiarów",
|
||||
"bulkDeleteConfirmTitle": "Usunąć pomiary ({count})?",
|
||||
"bulkDeleteConfirmBody": "Wybrane pomiary ({count}) zostaną trwale usunięte.",
|
||||
"bulkDeleteConfirmBody": "Wybrane pomiary ({count}) zostaną usunięte. Zaraz po usunięciu możesz to przez chwilę cofnąć.",
|
||||
"typePhq9Score": "Wynik PHQ-9",
|
||||
"typeGad7Score": "Wynik GAD-7",
|
||||
"typeGripStrength": "Siła chwytu",
|
||||
@@ -587,9 +651,27 @@
|
||||
"typeWho5Score": "Wynik WHO-5",
|
||||
"typeSciScore": "Wynik SCI"
|
||||
},
|
||||
"metricCatalog": {
|
||||
"title": "Co HealthLog potrafi śledzić",
|
||||
"description": "Wszystkie obszary metryk obsługiwane przez HealthLog — również te, dla których nie masz jeszcze danych — oraz co je dostarcza.",
|
||||
"statusPresent": "Śledzone",
|
||||
"statusAbsent": "Jeszcze nieśledzone",
|
||||
"statusModuleOff": "Wyłączone",
|
||||
"loadError": "Nie udało się załadować statusu śledzenia.",
|
||||
"connectCta": "Połącz źródło",
|
||||
"viewCta": "Zobacz",
|
||||
"ecg": {
|
||||
"source": "Jednoodprowadzeniowe zapisy EKG z kompatybilnego zegarka — obecnie Withings ScanWatch — synchronizowane automatycznie."
|
||||
},
|
||||
"watchdog": {
|
||||
"title": "Automatyczny nadzór",
|
||||
"description": "HealthLog dyskretnie obserwuje też dane w tle — powiadomienia o nieregularnym rytmie i zaburzeniach oddychania z podłączonego zegarka, alerty o odchyleniu od Twojej wartości bazowej oraz zauważalne zmiany między dwoma ostatnimi panelami badań laboratoryjnych — i pokazuje kartę tylko wtedy, gdy coś zwraca uwagę. Jeśli podłączone jest co najmniej jedno z tych źródeł, brak karty to dobry znak, a nie brakująca funkcja; jeśli żadne nie jest jeszcze podłączone, po prostu nic nie jest jeszcze monitorowane."
|
||||
}
|
||||
},
|
||||
"mood": {
|
||||
"title": "Nastrój",
|
||||
"subtitle": "Trend i wpisy nastroju",
|
||||
"mentalWellbeingLink": "Wykonaj sprawdzian dobrostanu psychicznego",
|
||||
"addEntry": "Dodaj",
|
||||
"editEntry": "Edytuj wpis",
|
||||
"moodLevel": "Nastrój",
|
||||
@@ -705,7 +787,7 @@
|
||||
"bulkDeleteSuccess": "Usunięto wpisy: {count}",
|
||||
"bulkDeleteError": "Błąd podczas usuwania wybranych wpisów",
|
||||
"bulkDeleteConfirmTitle": "Usunąć wpisy ({count})?",
|
||||
"bulkDeleteConfirmBody": "Wybrane wpisy nastroju ({count}) zostaną trwale usunięte.",
|
||||
"bulkDeleteConfirmBody": "Wybrane wpisy nastroju ({count}) zostaną usunięte. Zaraz po usunięciu możesz to przez chwilę cofnąć.",
|
||||
"customize": "Dostosuj tagi nastroju",
|
||||
"addTagInline": "Dodaj tag",
|
||||
"manage": {
|
||||
@@ -1403,7 +1485,10 @@
|
||||
"confirmAction": "Usuń",
|
||||
"toast": "Przyjęcie usunięte",
|
||||
"failed": "Nie udało się usunąć przyjęcia."
|
||||
}
|
||||
},
|
||||
"viewRecent90": "Ostatnie 90 dni",
|
||||
"viewAll": "Pełna historia",
|
||||
"viewToggleLabel": "Zakres historii"
|
||||
},
|
||||
"verlauf": {
|
||||
"loading": "Wczytywanie historii …",
|
||||
@@ -1659,7 +1744,10 @@
|
||||
"packagingDefaultPackLabel": "Domyślny rozmiar opakowania (jednostki)",
|
||||
"packagingDefaultPackHelper": "Wstępnie wypełnia ilość przy dodawaniu nowych opakowań (1–1000). Zostaw puste, jeśli nie dotyczy.",
|
||||
"packagingSuccess": "Zapisano dane opakowania",
|
||||
"packagingFailed": "Zapis nie powiódł się"
|
||||
"packagingFailed": "Zapis nie powiódł się",
|
||||
"addManufacturerLabel": "Producent",
|
||||
"addDoseStrengthLabel": "Moc",
|
||||
"addDoseStrengthHint": "Zgodnie z nadrukiem na opakowaniu, np. 5 mg/0,5 ml."
|
||||
},
|
||||
"erweitert": {
|
||||
"group": {
|
||||
@@ -1785,13 +1873,14 @@
|
||||
"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?",
|
||||
"placeholder": "Śledź według…",
|
||||
"apply": "Zastosuj",
|
||||
"reset": "Przywróć domyślne"
|
||||
"reset": "Przywróć domyślne",
|
||||
"error": "Nie udało się zapisać tej zmiany. Spróbuj ponownie."
|
||||
},
|
||||
"beforeAfter": {
|
||||
"summary": "Ø {before} przedtem → Ø {after} od tego czasu — zmiana o {delta}.",
|
||||
@@ -1806,7 +1895,10 @@
|
||||
"subtitle": "Co zbiega się w czasie z pomiarami, na które celują Twoje leki — tylko powiązanie, nigdy werdykt.",
|
||||
"link": "Otwórz widok działania",
|
||||
"empty": "Za mało danych, aby powiązać Twoje leki z docelowymi pomiarami."
|
||||
}
|
||||
},
|
||||
"scaleAbsolute": "Bezwzględne",
|
||||
"scalePercentSinceStart": "% od początku",
|
||||
"scaleToggleLabel": "Skala wykresu"
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
@@ -1955,6 +2047,61 @@
|
||||
"notAvailableForCompliance": "Porównanie niedostępne dla przestrzegania"
|
||||
},
|
||||
"insights": {
|
||||
"correlation": {
|
||||
"direction": {
|
||||
"higher": "wzrostu",
|
||||
"lower": "spadku"
|
||||
},
|
||||
"weekdayDirection": {
|
||||
"above": "powyżej",
|
||||
"below": "poniżej"
|
||||
},
|
||||
"channelFactor": "ocena czynnika {factor}",
|
||||
"channel": {
|
||||
"WEIGHT": "waga",
|
||||
"BLOOD_GLUCOSE": "poziom cukru we krwi",
|
||||
"BLOOD_PRESSURE_SYS": "ciśnienie skurczowe",
|
||||
"BLOOD_PRESSURE_DIA": "ciśnienie rozkurczowe",
|
||||
"ACTIVITY_STEPS": "dzienna liczba kroków",
|
||||
"SLEEP_DURATION": "długość snu",
|
||||
"HEART_RATE_VARIABILITY": "zmienność rytmu serca",
|
||||
"RESTING_HEART_RATE": "spoczynkowe tętno",
|
||||
"TIME_IN_DAYLIGHT": "czas w świetle dziennym",
|
||||
"MOOD": "nastrój",
|
||||
"PULSE": "tętno spoczynkowe",
|
||||
"MEDICATION_COMPLIANCE": "przyjmowanie leków",
|
||||
"SYMPTOM_SEVERITY": "nasilenie objawów",
|
||||
"ENV_TEMP_MEAN": "temperatura dobowa",
|
||||
"ENV_TEMP_MIN": "nocna temperatura minimalna",
|
||||
"ENV_SUNSHINE": "nasłonecznienie",
|
||||
"ENV_DAYLIGHT": "światło dzienne",
|
||||
"ENV_PRECIP": "opady",
|
||||
"ENV_PRESSURE_MEAN": "ciśnienie atmosferyczne",
|
||||
"ENV_PRESSURE_DELTA": "dobowa zmiana ciśnienia"
|
||||
},
|
||||
"daily": {
|
||||
"high": "Gdy {behaviour} rośnie, {outcome} następnego dnia zwykle wykazuje tendencję do {direction} w Twoich danych — wzorzec wart obserwacji, nie przyczyna.",
|
||||
"moderate": "Gdy {behaviour} rośnie, {outcome} następnego dnia wydaje się wykazywać tendencję do {direction}, na podstawie dostępnych danych — wzorzec wart obserwacji, nie przyczyna.",
|
||||
"faint": "Gdy {behaviour} rośnie, w Twoich danych pojawia się co najwyżej słaba wskazówka, że {outcome} następnego dnia wykazuje tendencję do {direction} — zbyt słaba, by na niej polegać, nigdy przyczyna."
|
||||
},
|
||||
"lab": {
|
||||
"default": "Wyższe wartości {lab} idą w parze z tym, że {outcome} wykazuje tendencję do {direction} w tych samych okresach w Twoich danych — zależność, którą warto obserwować z lekarzem, nigdy przyczyna.",
|
||||
"faint": "Wyższe wartości {lab} co najwyżej słabo sugerują, że {outcome} wykazuje tendencję do {direction} w tych samych okresach w Twoich danych — zbyt słabo, by na tym polegać, zależność do omówienia z lekarzem, nigdy przyczyna."
|
||||
},
|
||||
"hypothesis": {
|
||||
"bpCompliance": {
|
||||
"support": "Lepsze przyjmowanie leków idzie w parze z niższymi wartościami skurczowymi — wzorzec wart obserwacji.",
|
||||
"surprising": "Przyjmowanie leków i ciśnienie skurczowe zmieniają się razem w Twoich danych — zaskakujące; skonsultuj się z lekarzem, zanim coś zmienisz.",
|
||||
"none": "Przyjmowanie leków i ciśnienie skurczowe nie zmieniają się wyraźnie razem w tym okresie."
|
||||
},
|
||||
"moodPulse": {
|
||||
"support": "Dni z gorszym nastrojem idą w parze z wyższym tętnem spoczynkowym — wzorzec wart obserwacji.",
|
||||
"surprising": "Dni z lepszym nastrojem idą w parze z wyższym tętnem spoczynkowym w Twoich danych — zaskakujące.",
|
||||
"none": "Nastrój i tętno spoczynkowe nie zmieniają się wyraźnie razem w tym okresie."
|
||||
},
|
||||
"weightWeekday": "Waga w {weekday} jest {delta} kg {direction} Twojej średniej z pozostałych dni — wzorzec wart obserwacji."
|
||||
}
|
||||
},
|
||||
"noAnalysisYet": "Brak oceny.",
|
||||
"assessmentPreparing": "Przygotowywanie oceny…",
|
||||
"emptyTitle": "Jeszcze za mało danych",
|
||||
@@ -2193,6 +2340,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",
|
||||
@@ -2240,7 +2388,16 @@
|
||||
"historySearchPlaceholder": "Szukaj w rozmowach…",
|
||||
"historyEmpty": "Twoje rozmowy pojawią się tutaj. Zadaj pytanie, aby zacząć.",
|
||||
"historyDeleteAria": "Usuń rozmowę",
|
||||
"historyDeleteConfirm": "Usunąć tę rozmowę? Tego nie da się cofnąć.",
|
||||
"historyDeleted": "Rozmowa usunięta",
|
||||
"rename": {
|
||||
"action": "Zmień nazwę rozmowy",
|
||||
"input": "Tytuł rozmowy",
|
||||
"save": "Zapisz tytuł",
|
||||
"cancel": "Anuluj zmianę nazwy",
|
||||
"error": "Nie udało się zapisać tytułu rozmowy."
|
||||
},
|
||||
"historySearchEmpty": "Żadna rozmowa nie pasuje do wyszukiwania.",
|
||||
"historyLoadingMore": "Wczytywanie…",
|
||||
"sourcesTitle": "Do czego mam dostęp",
|
||||
"sourcesFresh": "Aktualne",
|
||||
"sourcesStale": "Nieaktualne",
|
||||
@@ -2377,10 +2534,6 @@
|
||||
"settingsClustersHint": "Wybierz, które grupy danych Coach może czytać. Więcej danych to bogatsze odpowiedzi i większy prompt.",
|
||||
"settingsClusterNoData": "Brak danych",
|
||||
"sourcesMemberCount": "{count} wskaźników",
|
||||
"dictate": "Dyktuj",
|
||||
"dictateStop": "Zatrzymaj dyktowanie",
|
||||
"voiceUnsupported": "Wprowadzanie głosowe nie jest obsługiwane w tej przeglądarce",
|
||||
"dictateError": "Nie udało się uruchomić wprowadzania głosowego. Sprawdź uprawnienia mikrofonu i spróbuj ponownie.",
|
||||
"showConversations": "Pokaż rozmowy",
|
||||
"hideConversations": "Ukryj rozmowy",
|
||||
"heroGreeting": "Zapytaj mnie o cokolwiek na temat twoich danych",
|
||||
@@ -2470,17 +2623,22 @@
|
||||
"documentsAttachedOne": "1 załączony dokument",
|
||||
"documentsAttachedOther": "Załączone dokumenty: {count}",
|
||||
"railBadge": "Odizolowany czat dokumentu",
|
||||
"uploadError": "Przesyłanie nie powiodło się. Spróbuj ponownie."
|
||||
"uploadError": "Przesyłanie nie powiodło się. Spróbuj ponownie.",
|
||||
"notFoundError": "Ten link do dokumentu jest już nieprawidłowy – zamiast tego rozpoczynamy zwykły czat."
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "przed chwilą",
|
||||
"relativeMinutesAgoOne": "{count} minutę temu",
|
||||
"relativeMinutesAgoOther": "{count} minut temu",
|
||||
"relativeMinutesAgoFew": "{count} minuty temu",
|
||||
"relativeHoursAgoOne": "{count} godzinę temu",
|
||||
"relativeHoursAgoOther": "{count} godzin temu",
|
||||
"relativeHoursAgoFew": "{count} godziny temu",
|
||||
"relativeDaysAgoOne": "{count} dzień temu",
|
||||
"relativeDaysAgoOther": "{count} dni temu",
|
||||
"relativeDaysAgoFew": "{count} dni temu",
|
||||
"navOverview": "Przegląd",
|
||||
"navCatalog": "Wszystkie metryki",
|
||||
"navBloodPressure": "Ciśnienie krwi",
|
||||
"navWeight": "Waga",
|
||||
"navPulse": "Tętno",
|
||||
@@ -2511,9 +2669,9 @@
|
||||
"label": "Aktywność",
|
||||
"header": "Aktywność i mobilność"
|
||||
},
|
||||
"cardiovascularParent": {
|
||||
"label": "Sercowo-naczyniowe",
|
||||
"header": "Markery sercowo-naczyniowe"
|
||||
"heartParent": {
|
||||
"label": "Serce",
|
||||
"header": "Zdrowie serca"
|
||||
},
|
||||
"hearingParent": {
|
||||
"label": "Słuch",
|
||||
@@ -2638,11 +2796,11 @@
|
||||
"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.",
|
||||
"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, więc im niżej, tym lepiej.",
|
||||
"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",
|
||||
@@ -2672,7 +2830,7 @@
|
||||
"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, a czas na świeżym powietrzu wiąże się ze zdrowiem oczu.",
|
||||
"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",
|
||||
@@ -2684,7 +2842,7 @@
|
||||
"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.",
|
||||
"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",
|
||||
@@ -2732,7 +2890,8 @@
|
||||
},
|
||||
"valuesBack": "Powrót do Insights",
|
||||
"valuesBackToMetric": "Powrót do {metric}",
|
||||
"scoresBack": "Powrót do Insights"
|
||||
"scoresBack": "Powrót do Insights",
|
||||
"ecgDescription": "Zapisy EKG jednoodprowadzeniowe z Twojego urządzenia, każdy z wynikiem samego urządzenia. HealthLog pokazuje przebieg i zapis — nie interpretuje krzywej."
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sen",
|
||||
@@ -2771,9 +2930,9 @@
|
||||
"debt": {
|
||||
"title": "Dług snu",
|
||||
"value": "{hours} godz. {minutes} min niedoboru",
|
||||
"caption": "Skumulowany niedobór z ostatnich {nights} nocy",
|
||||
"caption": "Pozostały bilans z ostatnich {nights} nocy",
|
||||
"clearValue": "Na bieżąco",
|
||||
"clearCaption": "Brak skumulowanego niedoboru w ostatnich {nights} nocach",
|
||||
"clearCaption": "Nic do nadrobienia w ostatnich {nights} nocach",
|
||||
"learning": "Wciąż analizujemy Twój sen — {counted} nocy do tej pory, jeszcze {remaining}, aby uzyskać wiarygodny bilans.",
|
||||
"computedInfo": "To własne oszacowanie HealthLog: ruchomy bilans potrzeby snu minus sen z ostatnich nocy, który sen nadrabiający zmniejsza. Może różnić się od natywnego wskaźnika długu snu z urządzenia, które korzysta z własnego modelu zapotrzebowania."
|
||||
},
|
||||
@@ -2870,13 +3029,15 @@
|
||||
"sleep": {
|
||||
"title": "Brak danych o śnie",
|
||||
"description": "Gdy pojawią się dane o śnie (ręcznie lub przez Withings / Apple Health), wyświetli się tu rozkład faz na noc.",
|
||||
"cta": "Połącz Withings"
|
||||
"cta": "Połącz źródło"
|
||||
}
|
||||
},
|
||||
"navWorkouts": "Treningi",
|
||||
"navRecovery": "Regeneracja",
|
||||
"workouts": {
|
||||
"title": "Treningi",
|
||||
"askWhy": "Dlaczego",
|
||||
"askWhyPrompt": "Dlaczego ta sesja wypadła w ten sposób? Wyjaśnij mi moje własne wyniki.",
|
||||
"description": "Ostatnie biegi, jazdy, marsze i treningi siłowe — zdeduplikowane między Apple Watch a Withings.",
|
||||
"emptyState": {
|
||||
"title": "Brak treningów",
|
||||
@@ -2905,7 +3066,24 @@
|
||||
"hrChartTitle": "Tętno",
|
||||
"hrChartUnavailable": "Próbki tętna co sekundę nie są zapisywane dla tego treningu.",
|
||||
"notFound": "Nie znaleziono treningu.",
|
||||
"loading": "Wczytywanie treningu…"
|
||||
"loading": "Wczytywanie treningu…",
|
||||
"hrFromPulse": "Na podstawie Twoich danych tętna z okolic tej sesji",
|
||||
"hrAxisMinutes": "min",
|
||||
"sportAverageLabel": "Twoja ostatnia średnia:",
|
||||
"zonesTitle": "Strefy wysiłku",
|
||||
"zonesCaption": "Jak Twój wysiłek rozłożył się na strefy tętna.",
|
||||
"zonesCaptionDevice": "Czas w strefach według Twojego urządzenia.",
|
||||
"exportGpx": "Eksportuj GPX",
|
||||
"elevationTitle": "Wznios",
|
||||
"splitsTitle": "Międzyczasy",
|
||||
"splitsKm": "km",
|
||||
"splitsTime": "Czas",
|
||||
"splitsPace": "Tempo",
|
||||
"thatDayTitle": "Tego dnia",
|
||||
"dayPulse": "Tętno",
|
||||
"daySleep": "Sen",
|
||||
"dayMood": "Nastrój",
|
||||
"insightTitle": "Analiza aktywności"
|
||||
},
|
||||
"sport": {
|
||||
"walking": "Marsz",
|
||||
@@ -2928,7 +3106,11 @@
|
||||
"crossTraining": "Cross-trening",
|
||||
"mixedCardio": "Cardio mieszane",
|
||||
"other": "Inne"
|
||||
}
|
||||
},
|
||||
"loadError": "Nie udało się wczytać treningów",
|
||||
"loadMore": "Wczytaj więcej treningów",
|
||||
"loadingMore": "Wczytywanie kolejnych treningów",
|
||||
"loadMoreError": "Nie udało się wczytać kolejnych treningów"
|
||||
},
|
||||
"bloodGlucose": {
|
||||
"title": "Poziom glukozy",
|
||||
@@ -3238,6 +3420,8 @@
|
||||
},
|
||||
"navWalkingSpeed": "Prędkość chodu",
|
||||
"mood": {
|
||||
"logLink": "Zapisz nastrój",
|
||||
"mentalWellbeingLink": "Wykonaj sprawdzian dobrostanu psychicznego",
|
||||
"heatmapTitle": "Kalendarz nastroju",
|
||||
"heatmapNoEntry": "Brak wpisu",
|
||||
"legendGreat": "Świetnie",
|
||||
@@ -3525,7 +3709,9 @@
|
||||
"sectionTitle": "Twoje wskaźniki zdrowia",
|
||||
"sectionSubtitle": "Złożone wartości regeneracji, stresu i obciążenia odczytane z Twoich połączonych urządzeń",
|
||||
"loadError": "Nie udało się teraz wczytać Twoich wyników dobrostanu.",
|
||||
"recovery": "Regeneracja",
|
||||
"loadingLabel": "Wczytywanie Twoich wskaźników zdrowia",
|
||||
"recovery": "Wynik regeneracji",
|
||||
"recoveryCrossLink": "Zobacz dane regeneracji i obciążenia z Twojego urządzenia",
|
||||
"stress": "Stres",
|
||||
"strain": "Obciążenie"
|
||||
},
|
||||
@@ -3696,6 +3882,7 @@
|
||||
"recovery": {
|
||||
"title": "Regeneracja",
|
||||
"description": "Jak dobrze Twój organizm się zregenerował – sygnały regeneracji, obciążenia i load, które Twoje urządzenie rejestruje w nocy.",
|
||||
"scoreCrossLink": "Zobacz łączny wynik regeneracji",
|
||||
"emptyTitle": "Brak danych o regeneracji",
|
||||
"empty": "Brak danych o regeneracji. Połącz konto WHOOP, Polar lub Oura, a te wyniki uzupełnią się po synchronizacji urządzenia.",
|
||||
"block": {
|
||||
@@ -3866,13 +4053,18 @@
|
||||
"recordingsMany": "Zapisy w kartotece: {count}.",
|
||||
"latestResult": "Ostatni wynik urządzenia: {result}.",
|
||||
"cta": "Zobacz zapisy"
|
||||
}
|
||||
},
|
||||
"emptyTitle": "Brak zapisów EKG",
|
||||
"emptyDescription": "Zapisy EKG synchronizują się z kompatybilnego urządzenia jednoodprowadzeniowego. Gdy urządzenie prześle zapis, pojawi się tutaj.",
|
||||
"emptyCta": "Połącz urządzenie"
|
||||
},
|
||||
"intradayPulse": {
|
||||
"title": "Przebieg Twojego dnia",
|
||||
"caption": "Twoje tętno w ciągu dnia, uśrednione w 10-minutowych odcinkach.",
|
||||
"hourlyNote": "Pokazano średnią godzinową dla tego dnia — ostatnie dni pokazują pełny, 10-minutowy szczegół.",
|
||||
"empty": "Za mało pomiarów z tego dnia, aby pokazać jego przebieg.",
|
||||
"coverageOne": "Na podstawie 1 pomiaru w ciągu {hours}h.",
|
||||
"coverageMany": "Na podstawie {count} pomiarów w ciągu {hours}h.",
|
||||
"baseline": "Spoczynek",
|
||||
"today": "Dziś",
|
||||
"previousDay": "Poprzedni dzień",
|
||||
@@ -3883,7 +4075,8 @@
|
||||
"evening": "Utrzymujący się podwyższony spoczynek wieczorem — możliwe napięcie.",
|
||||
"night": "Utrzymujący się podwyższony spoczynek w nocy — możliwe napięcie."
|
||||
}
|
||||
}
|
||||
},
|
||||
"navEcg": "EKG"
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Domyślne",
|
||||
@@ -3982,7 +4175,7 @@
|
||||
},
|
||||
"wrapUp": {
|
||||
"title": "To koniec przewodnika",
|
||||
"body": "Każdy moduł możesz obejrzeć osobno w dowolnej chwili („Pokaż ten przewodnik” na każdej stronie) lub uruchomić cały przewodnik ponownie w Ustawienia → Zaawansowane."
|
||||
"body": "Ten przewodnik pokazuje się tylko raz — nie otworzy się ponownie sam. Wszystko, co pokazał, zostaje na miejscu, a funkcje możesz włączać i wyłączać w dowolnym momencie w Ustawienia → Moduły."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4010,7 +4203,7 @@
|
||||
},
|
||||
"source": {
|
||||
"title": "Połącz źródło",
|
||||
"body": "Pobieraj pomiary automatycznie lub zostań przy wpisywaniu ręcznym. Nic nie jest tu konfigurowane za Ciebie — każda karta pokazuje tylko, gdzie to połączyć.",
|
||||
"body": "Pobieraj pomiary automatycznie lub zostań przy wpisywaniu ręcznym. Nic nie jest tu konfigurowane za Ciebie — każda karta pokazuje tylko, gdzie to połączyć. Źródła sprzętowe wymagają darmowej aplikacji deweloperskiej na Twoim własnym koncie (około 5 minut); przewodnik na każdej karcie prowadzi krok po kroku.",
|
||||
"recommended": "Polecane",
|
||||
"manual": {
|
||||
"title": "Wpis ręczny",
|
||||
@@ -4018,12 +4211,14 @@
|
||||
},
|
||||
"withings": {
|
||||
"title": "Withings",
|
||||
"body": "Wagi, ciśnieniomierze i trackery Withings synchronizują się automatycznie.",
|
||||
"cta": "Połącz Withings"
|
||||
"body": "Wagi, ciśnieniomierze i trackery Withings synchronizują się automatycznie po połączeniu.",
|
||||
"cta": "Połącz Withings",
|
||||
"setupCta": "Skonfiguruj w Ustawieniach",
|
||||
"setupHint": "Najpierw wymaga własnej, darmowej aplikacji deweloperskiej Withings — około 5 minut, jednorazowo."
|
||||
},
|
||||
"more": {
|
||||
"title": "Więcej źródeł",
|
||||
"body": "Połącz dowolne z nich w Ustawieniach, gdy zechcesz.",
|
||||
"body": "Połącz dowolne z nich w Ustawieniach, gdy zechcesz — większość wymaga najpierw darmowej aplikacji deweloperskiej na Twoim własnym koncie.",
|
||||
"appleHealth": "Apple Health",
|
||||
"whoop": "WHOOP",
|
||||
"oura": "Oura",
|
||||
@@ -4055,7 +4250,8 @@
|
||||
"learning": "Niektóre wnioski — glukoza, rytm snu, trendy ciśnienia, cykl — wyostrzają się w ciągu pierwszego tygodnia lub dwóch, w miarę gromadzenia danych. Puste kafelki oznaczają teraz tylko, że wciąż poznajemy Twój punkt odniesienia.",
|
||||
"connectCta": "Połącz urządzenie",
|
||||
"logCta": "Zapisz pierwszy pomiar",
|
||||
"importCta": "Importuj istniejącą historię"
|
||||
"importCta": "Importuj istniejącą historię",
|
||||
"modulesCta": "Wybierz swoje moduły"
|
||||
},
|
||||
"welcomeBack": {
|
||||
"title": "Witaj ponownie",
|
||||
@@ -4165,7 +4361,16 @@
|
||||
"disableError": "Nie udało się wyłączyć trybu Badawczego. Spróbuj ponownie."
|
||||
},
|
||||
"shell": {
|
||||
"sectionsNav": "Sekcje ustawień"
|
||||
"sectionsNav": "Sekcje ustawień",
|
||||
"groups": {
|
||||
"account": "Konto",
|
||||
"yourData": "Twoje dane",
|
||||
"display": "Wyświetlanie",
|
||||
"healthProfile": "Profil zdrowotny",
|
||||
"ai": "AI",
|
||||
"connectivity": "Łączność",
|
||||
"system": "System"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"account": {
|
||||
@@ -4350,7 +4555,8 @@
|
||||
},
|
||||
"fullBackup": {
|
||||
"title": "Pełna kopia zapasowa",
|
||||
"description": "Pojedynczy plik JSON ze wszystkim — taki sam format jak cotygodniowa automatyczna kopia. Przydatny do samodzielnego przywracania przez przesłanie w panelu administratora.",
|
||||
"description": "Wszystkie Twoje dane w jednym pliku JSON — pomiary, leki, nastrój, cykl, wyniki badań laboratoryjnych, epizody chorobowe, alergie, wywiad rodzinny i treningi. Taki sam format jak cotygodniowa automatyczna kopia.",
|
||||
"scopeNote": "Metadane dokumentów oraz podsumowanie AI, jeśli istnieje, są uwzględnione jako czysty tekst; oryginalne pliki dokumentów oraz dane tras GPS/próbek treningów nie są uwzględnione — pobierz je osobno z archiwum dokumentów lub eksportu treningów.",
|
||||
"encryptToggle": "Zaszyfruj hasłem-frazą",
|
||||
"passphrasePlaceholder": "Hasło-fraza (min. 12 znaków)",
|
||||
"passphraseHelp": "Wyprowadza klucz archiwum za pomocą Argon2id i pieczętuje je AES-256-GCM. Zapisz plik z rozszerzeniem .hlx.",
|
||||
@@ -4381,6 +4587,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.",
|
||||
"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…",
|
||||
"unpacking": "Rozpakowywanie archiwum…",
|
||||
@@ -4510,7 +4717,7 @@
|
||||
"description": "Ustrukturyzowane rekordy alergii i wywiadu rodzinnego."
|
||||
},
|
||||
"environment": {
|
||||
"title": "Środowisko",
|
||||
"title": "Lokalizacja i pogoda",
|
||||
"subtitle": "Kontekst pogody, światła dziennego i lokalizacji.",
|
||||
"disabledHint": "Włącz moduł Środowisko, aby ustawić lokalizację domową i pobierać dzienną pogodę oraz długość dnia.",
|
||||
"searchPlaceholder": "Szukaj miasta",
|
||||
@@ -4539,7 +4746,9 @@
|
||||
"description": "Dodaj miejsce i zakres dat: podróż lub fragment wcześniejszej historii. Okres zastępuje Twój dom w dniach, które obejmuje, aby pogoda odpowiadała miejscu, w którym naprawdę byłeś. W ten sposób korygujesz przeszłość.",
|
||||
"none": "Brak okresów lokalizacji.",
|
||||
"add": "Dodaj {label}",
|
||||
"remove": "Usuń okres lokalizacji"
|
||||
"remove": "Usuń okres lokalizacji",
|
||||
"removeConfirmTitle": "Usunąć tę podróż?",
|
||||
"removeConfirmBody": "Pomiary z tego okresu pozostają w historii; znika tylko wpis podróży."
|
||||
},
|
||||
"backfill": {
|
||||
"title": "Uzupełnij minione dni",
|
||||
@@ -4820,7 +5029,10 @@
|
||||
"surfaced": "Wyświetlone",
|
||||
"done": "Gotowe",
|
||||
"dismissed": "Odrzucone"
|
||||
}
|
||||
},
|
||||
"deleteConfirmTitle": "Usunąć to przypomnienie?",
|
||||
"deleteConfirmBody": "Przestanie się pojawiać. Zapisane wcześniej dane pozostają bez zmian.",
|
||||
"deleteConfirmAction": "Usuń"
|
||||
},
|
||||
"aboutMe": {
|
||||
"title": "O mnie",
|
||||
@@ -4866,6 +5078,16 @@
|
||||
"honesty": "Wspólny dostęp to jedno zalogowane konto AI, które operator podłączył dla wszystkich na tym serwerze — nie Twój własny klucz. Zapytania, które przez nie wysyłasz, są domyślnie wykorzystywane do trenowania modeli dostawcy i nie obejmuje ich żadna umowa powierzenia przetwarzania danych, godzinowe i tygodniowe limity konta dzielisz z wszystkimi innymi, którzy się zgodzili, a Twoi właśni dostawcy są próbowani jako pierwsi. Włącz to tylko, jeśli akceptujesz ten kompromis.",
|
||||
"confirm": "Rozumiem — użyj wspólnego połączenia",
|
||||
"cancel": "Anuluj"
|
||||
},
|
||||
"consent": {
|
||||
"title": "Zgoda na AI",
|
||||
"activeSince": "Udzielona {date}. Twoje dane zdrowotne mogą być wysyłane do skonfigurowanego dostawcy na potrzeby funkcji AI.",
|
||||
"withdrawn": "Wycofana. Żadne dane zdrowotne nie są wysyłane do dostawcy AI; funkcje AI pozostają wyłączone.",
|
||||
"withdraw": "Wycofaj zgodę",
|
||||
"confirmBody": "Funkcje AI przestają działać natychmiast. Nic z już zapisanych danych nie zostaje usunięte — użyj sekcji Dane i prywatność, aby wyeksportować lub usunąć swoją historię.",
|
||||
"confirmWithdraw": "Wycofaj",
|
||||
"regrantHint": "Aby ponownie korzystać z funkcji AI, włącz automatyczne czytanie dokumentów lub udziel zgody w aplikacji.",
|
||||
"error": "Nie udało się zapisać wycofania. Spróbuj ponownie."
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -5052,7 +5274,8 @@
|
||||
"resumeError": "Ponowne połączenie nieudane"
|
||||
},
|
||||
"apiTokens": "Tokeny API",
|
||||
"apiTokensDescription": "Twórz tokeny API do zewnętrznego rejestrowania przyjęć leków (np. Skróty, automatyzacje).",
|
||||
"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.",
|
||||
"tokenNamePlaceholder": "Nazwa tokena (np. Skrót iPhone)",
|
||||
"tokenCreated": "Token utworzony — skopiuj go teraz! Nie zostanie pokazany ponownie.",
|
||||
"tokenCopy": "Kopiuj token",
|
||||
@@ -5312,7 +5535,7 @@
|
||||
"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",
|
||||
@@ -5383,6 +5606,12 @@
|
||||
"ouraDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
|
||||
"nightscoutDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z",
|
||||
"integrationCredentialsHint": "Gdzie znaleźć te dane logowania i jak zarejestrować własną aplikację, opisano w przewodniku konfiguracji powyżej.",
|
||||
"integrationRedirectGuide": {
|
||||
"intro": "To połączenie działa przez Twoją własną aplikację deweloperską {provider} — około 5 minut konfiguracji:",
|
||||
"step1": "Utwórz aplikację w portalu deweloperskim {provider}.",
|
||||
"step2": "Ustaw jej URL przekierowania/callback na:",
|
||||
"step3": "Wklej powyżej otrzymany identyfikator klienta i sekret klienta."
|
||||
},
|
||||
"security": {
|
||||
"sessionsTitle": "Aktywne sesje",
|
||||
"sessionsDescription": "Urządzenia i przeglądarki obecnie zalogowane na Twoje konto.",
|
||||
@@ -5478,8 +5707,16 @@
|
||||
"revokeAllDone": "Zapomniano {count} zaufane urządzenie/a.",
|
||||
"unnamed": "Nieznane urządzenie",
|
||||
"current": "To urządzenie",
|
||||
"expires": "Zaufane do {date}"
|
||||
}
|
||||
"expires": "Zaufane do {date}",
|
||||
"revokeConfirmTitle": "Przestać ufać temu urządzeniu?",
|
||||
"revokeConfirmBody": "Przy następnym logowaniu z tego urządzenia ponownie poprosi o drugi składnik.",
|
||||
"revokeAllConfirmTitle": "Przestać ufać wszystkim urządzeniom?",
|
||||
"revokeAllConfirmBody": "Każde urządzenie przy następnym logowaniu ponownie poprosi o drugi składnik, łącznie z tym."
|
||||
},
|
||||
"revokeSessionConfirmTitle": "Wylogować to urządzenie?",
|
||||
"revokeSessionConfirmBody": "{device} będzie musiało zalogować się ponownie. Zapisane na nim dane pozostają bez zmian.",
|
||||
"signOutEverywhereConfirmTitle": "Wylogować wszystkie pozostałe urządzenia?",
|
||||
"signOutEverywhereConfirmBody": "Każde urządzenie poza tym będzie musiało zalogować się ponownie. To urządzenie pozostaje zalogowane. Zapisane dane pozostają bez zmian."
|
||||
},
|
||||
"privacy": {
|
||||
"intro": {
|
||||
@@ -5579,10 +5816,34 @@
|
||||
"rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.",
|
||||
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
|
||||
},
|
||||
"appleHealth": {
|
||||
"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"
|
||||
},
|
||||
"garminInfo": {
|
||||
"title": "Garmin",
|
||||
"body": "Garmin nie ma tutaj bezpośredniego połączenia. Połącz przez Apple Health (iOS) lub Google Health Connect (Android). Zastrzeżone wskaźniki Garmina — Body Battery, Training Load, status HRV, VO₂ max — nie są udostępniane przez Garmina żadnej z tych platform.",
|
||||
"docsLink": "Przeczytaj przewodnik"
|
||||
},
|
||||
"glucoseReference": {
|
||||
"title": "Zakres referencyjny glukozy",
|
||||
"description": "Do jakiego zakresu porównywane są twoje wyniki glukozy we krwi.",
|
||||
"enable": "Użyj zakresu docelowego dla cukrzycy",
|
||||
"explainer": "Wyłączone — wyniki są oceniane względem ogólnego zakresu referencyjnego dla osób bez cukrzycy. Włączone — są oceniane względem węższego zakresu docelowego glikemii z ADA Standards of Care, a Coach opiera na nim swoje uwagi o glukozie. Twoje własne wartości docelowe poniżej nadal mają pierwszeństwo.",
|
||||
"disclaimer": "To wybiera zakres referencyjny. Nie jest to diagnoza, nigdy nie jest wywnioskowane z pomiaru, a aplikacja nie podejmuje na tej podstawie działań klinicznych. O to, jakie cele dotyczą ciebie, zapytaj swojego lekarza.",
|
||||
"error": "Nie udało się zapisać ustawienia."
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
@@ -6023,6 +6284,7 @@
|
||||
"colAction": "Akcja",
|
||||
"colDuration": "Czas trwania",
|
||||
"colTraceId": "Trace",
|
||||
"viewDetails": "Zobacz szczegóły dla {action} o {timestamp}",
|
||||
"showing": "Wyświetlono {count} z maksymalnie {bufferMax} buforowanych zdarzeń",
|
||||
"empty": "Bufor jest pusty",
|
||||
"emptyDescription": "Żadne zdarzenie nie pasuje jeszcze do bieżących filtrów. Kliknij Odśwież po wygenerowaniu ruchu.",
|
||||
@@ -6643,9 +6905,9 @@
|
||||
"hypertensionGrade1": "Nadciśnienie 1 stopnia",
|
||||
"hypertensionGrade2": "Nadciśnienie 2 stopnia",
|
||||
"hypertensionGrade3": "Nadciśnienie 3 stopnia",
|
||||
"bradycardia": "Bradykardia",
|
||||
"belowTypicalRange": "Poniżej typowego zakresu",
|
||||
"elevated": "Podwyższone",
|
||||
"tachycardia": "Tachykardia",
|
||||
"aboveTypicalRange": "Powyżej typowego zakresu",
|
||||
"significantlyLow": "Znacząco niskie",
|
||||
"slightlyLow": "Lekko niskie",
|
||||
"onTarget": "W zakresie docelowym",
|
||||
@@ -7320,7 +7582,7 @@
|
||||
"referenceLabel": "Norma",
|
||||
"readingsCount": "{count} pomiarów",
|
||||
"deleteConfirmTitle": "Usunąć wynik laboratoryjny?",
|
||||
"deleteConfirmDescription": "Ten wynik laboratoryjny zostanie trwale usunięty.",
|
||||
"deleteConfirmDescription": "Ten wynik laboratoryjny zostanie usunięty. Zaraz po usunięciu możesz to przez chwilę cofnąć.",
|
||||
"deletedToast": "Wynik usunięty",
|
||||
"deleteError": "Nie udało się usunąć wyniku",
|
||||
"loadError": "Nie udało się wczytać wyników",
|
||||
@@ -7351,6 +7613,7 @@
|
||||
"notePlaceholder": "Opcjonalny kontekst tego wyniku",
|
||||
"requiredError": "Podaj wartość.",
|
||||
"save": "Zapisz",
|
||||
"saveAndAddAnother": "Zapisz i dodaj kolejny",
|
||||
"savedToast": "Wynik zapisany",
|
||||
"saveError": "Nie udało się zapisać wyniku",
|
||||
"biomarker": "Biomarker",
|
||||
@@ -7798,7 +8061,7 @@
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Usunąć ten epizod?",
|
||||
"body": "„{label}” oraz wszystkie powiązane wpisy dzienne zostaną usunięte. Tej operacji nie można cofnąć."
|
||||
"body": "„{label}” oraz wszystkie powiązane wpisy dzienne zostaną usunięte. Zaraz po usunięciu możesz to przez chwilę cofnąć."
|
||||
},
|
||||
"vital": {
|
||||
"RESTING_HEART_RATE": "Spoczynkowe tętno",
|
||||
@@ -7881,6 +8144,11 @@
|
||||
"review_due": "Przegląd do zrobienia",
|
||||
"reviewed": "Oceniony"
|
||||
}
|
||||
},
|
||||
"refusal": {
|
||||
"outOfScope": "Mogę pomóc wyłącznie z danymi zdrowotnymi z Twojego dziennika: pomiarami, lekami, nastrojem, trendami. Na pytania spoza tego zakresu nie odpowiadam.",
|
||||
"promptInjection": "Zauważyłem polecenia, które próbują obejść moje wytyczne. Jestem Coachem HealthLog i podsumowuję wyłącznie Twoje własne dane zdrowotne — sformułuj pytanie w tych ramach.",
|
||||
"conversationPoisoned": "Wcześniejsza wiadomość w tej rozmowie zawiera polecenia, które próbują obejść moje wytyczne. Rozpocznij nową rozmowę."
|
||||
}
|
||||
},
|
||||
"safety": {
|
||||
@@ -7892,14 +8160,14 @@
|
||||
"bpLowDoctor": "Dwa ostatnie pomiary miały ciśnienie skurczowe poniżej {systolic} mmHg. Jeśli kręci ci się w głowie lub czujesz osłabienie, usiądź lub połóż się i skontaktuj się z lekarzem, jeśli to się utrzymuje.",
|
||||
"bpLowEmergency": "Dwa ostatnie pomiary miały ciśnienie skurczowe poniżej {systolic} mmHg i zgłosiłeś objawy. Jeśli czujesz, że możesz zemdleć, jesteś zdezorientowany lub masz zimną, lepką skórę, natychmiast zadzwoń po pomoc.",
|
||||
"glucoseLowTitle": "Niski poziom cukru",
|
||||
"glucoseLowDoctor": "Dwa ostatnie pomiary były poniżej 70 mg/dL ({value} mg/dL). Przyjmij szybko działające węglowodany, zmierz ponownie za 15 minut i skontaktuj się z lekarzem, jeśli spadki się utrzymują.",
|
||||
"glucoseLowEmergency": "Dwa ostatnie pomiary były poniżej 70 mg/dL ({value} mg/dL) i zgłosiłeś objawy. Lecz to teraz szybkim cukrem; jeśli nie możesz lub czujesz się bardzo źle, zadzwoń po pomoc.",
|
||||
"glucoseLowDoctor": "Dwa ostatnie pomiary były poniżej {threshold} {unit} ({value} {unit}). Przyjmij szybko działające węglowodany, zmierz ponownie za 15 minut i skontaktuj się z lekarzem, jeśli spadki się utrzymują.",
|
||||
"glucoseLowEmergency": "Dwa ostatnie pomiary były poniżej {threshold} {unit} ({value} {unit}) i zgłosiłeś objawy. Lecz to teraz szybkim cukrem; jeśli nie możesz lub czujesz się bardzo źle, zadzwoń po pomoc.",
|
||||
"glucoseLowSevereTitle": "Bardzo niski poziom cukru",
|
||||
"glucoseLowSevereDoctor": "Dwa ostatnie pomiary były poniżej 54 mg/dL ({value} mg/dL). Lecz natychmiast szybko działającymi węglowodanami, zmierz ponownie za 15 minut i skontaktuj się z lekarzem.",
|
||||
"glucoseLowSevereEmergency": "Dwa ostatnie pomiary były poniżej 54 mg/dL ({value} mg/dL) i zgłosiłeś objawy. Lecz to teraz; jeśli nie utrzymujesz pokarmu lub czujesz się bardzo źle, zadzwoń po pomoc.",
|
||||
"glucoseLowSevereDoctor": "Dwa ostatnie pomiary były poniżej {threshold} {unit} ({value} {unit}). Lecz natychmiast szybko działającymi węglowodanami, zmierz ponownie za 15 minut i skontaktuj się z lekarzem.",
|
||||
"glucoseLowSevereEmergency": "Dwa ostatnie pomiary były poniżej {threshold} {unit} ({value} {unit}) i zgłosiłeś objawy. Lecz to teraz; jeśli nie utrzymujesz pokarmu lub czujesz się bardzo źle, zadzwoń po pomoc.",
|
||||
"glucoseHighTitle": "Bardzo wysoki poziom cukru",
|
||||
"glucoseHighDoctor": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej. Zmierz ponownie, nawodnij się i jak najszybciej skontaktuj się z lekarzem, jeśli pozostaje tak wysoki.",
|
||||
"glucoseHighEmergency": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej i zgłosiłeś objawy. W razie wymiotów, przyspieszonego oddechu lub dezorientacji natychmiast zadzwoń po pomoc."
|
||||
"glucoseHighDoctor": "Dwa ostatnie pomiary wyniosły {value} {unit} lub więcej. Zmierz ponownie, nawodnij się i jak najszybciej skontaktuj się z lekarzem, jeśli pozostaje tak wysoki.",
|
||||
"glucoseHighEmergency": "Dwa ostatnie pomiary wyniosły {value} {unit} lub więcej i zgłosiłeś objawy. W razie wymiotów, przyspieszonego oddechu lub dezorientacji natychmiast zadzwoń po pomoc."
|
||||
}
|
||||
},
|
||||
"version": {
|
||||
@@ -7912,6 +8180,7 @@
|
||||
"mentalHealth": {
|
||||
"pageTitle": "Dobrostan psychiczny",
|
||||
"pageDescription": "Cztery zwalidowane, dobrowolne testy — PHQ-9 (nastrój), GAD-7 (lęk), WHO-5 (samopoczucie) i SCI (sen) — obok Twojego dziennika nastroju. Liczy się trend w czasie, a nie pojedynczy wynik.",
|
||||
"moodLink": "Zobacz historię swojego nastroju",
|
||||
"attributionLabel": "Źródło",
|
||||
"attributionShow": "Źródło i licencja: {instrument}",
|
||||
"choosePrompt": "Wybierz sprawdzenie",
|
||||
@@ -8036,6 +8305,12 @@
|
||||
"nummerGegenKummer": {
|
||||
"name": "Nummer gegen Kummer (dla młodych ludzi)"
|
||||
},
|
||||
"telefonSeelsorgeAt": {
|
||||
"name": "Telefonseelsorge Österreich (całodobowo, bezpłatnie i poufnie)"
|
||||
},
|
||||
"dargeboteneHand": {
|
||||
"name": "Die Dargebotene Hand (całodobowo, bezpłatnie i poufnie)"
|
||||
},
|
||||
"krisenchat": {
|
||||
"name": "krisenchat (czat całodobowy, dla młodych ludzi)"
|
||||
},
|
||||
@@ -8126,7 +8401,9 @@
|
||||
"finally": "Finally …"
|
||||
}
|
||||
},
|
||||
"openDetail": "Pokaż szczegóły"
|
||||
"openDetail": "Pokaż szczegóły",
|
||||
"historyLoadError": "Nie udało się wczytać historii testów",
|
||||
"historyLoadErrorHint": "To nie znaczy, że nie masz wyników — nie udało się pobrać listy. Spróbuj ponownie."
|
||||
},
|
||||
"records": {
|
||||
"disclaimer": "To Twoje własne wpisy, a nie diagnoza lekarska. HealthLog nie diagnozuje, nie leczy i ich nie interpretuje.",
|
||||
@@ -8289,7 +8566,9 @@
|
||||
"openLabel": "Otwórz {title}",
|
||||
"attachmentBadge": "Tylko do pobrania",
|
||||
"uploading": "Przesyłanie…",
|
||||
"dismissFailed": "Odrzuć nieudane przesyłanie"
|
||||
"dismissFailed": "Odrzuć nieudane przesyłanie",
|
||||
"processing": "Przetwarzanie…",
|
||||
"ready": "Gotowe"
|
||||
},
|
||||
"error": {
|
||||
"fileTooLarge": "Ten plik przekracza limit {maxSize}.",
|
||||
@@ -8331,6 +8610,16 @@
|
||||
},
|
||||
"detail": {
|
||||
"title": "Szczegóły dokumentu",
|
||||
"summary": {
|
||||
"title": "Podsumowanie",
|
||||
"pending": "Podsumowanie jest przygotowywane.",
|
||||
"none": "Dla tego dokumentu nie wygenerowano jeszcze podsumowania.",
|
||||
"withheld": "Podsumowanie zostało wygenerowane, ale nie przeszło kontroli bezpieczeństwa, więc nie jest wyświetlane. Możesz spróbować ponownie lub przeczytać sam dokument.",
|
||||
"unavailable": "Nie udało się wygenerować podsumowania dla tego dokumentu.",
|
||||
"generate": "Wygeneruj podsumowanie",
|
||||
"regenerate": "Wygeneruj ponownie",
|
||||
"generating": "Generowanie…"
|
||||
},
|
||||
"loadError": "Nie udało się wczytać tego dokumentu.",
|
||||
"saveError": "Nie udało się zapisać zmiany.",
|
||||
"deleteError": "Nie udało się usunąć dokumentu.",
|
||||
@@ -8345,7 +8634,8 @@
|
||||
"noConditions": "Niepowiązany ze schorzeniem",
|
||||
"previewUnavailable": "Brak podglądu dla tego typu pliku",
|
||||
"uploadedMeta": "Dodano {date} · {size}",
|
||||
"share": "Udostępnij"
|
||||
"share": "Udostępnij",
|
||||
"labValuesLink": "Zobacz {count} wyników laboratoryjnych"
|
||||
},
|
||||
"dropOverlay": {
|
||||
"title": "Upuść pliki, aby je przesłać",
|
||||
@@ -8410,7 +8700,6 @@
|
||||
"close": "Ukryj"
|
||||
},
|
||||
"ai": {
|
||||
"statusAiRead": "Odczytane przez AI",
|
||||
"readDone": "AI odczytało Twój dokument.",
|
||||
"read": "Odczytaj z AI",
|
||||
"reread": "Odczytaj ponownie",
|
||||
@@ -8566,6 +8855,7 @@
|
||||
"quickAddCustomPlaceholder": "250",
|
||||
"quickAddSubmit": "Dodaj",
|
||||
"quickAddError": "Nie udało się zapisać — spróbuj ponownie.",
|
||||
"quickAddSuccess": "Dodano {amount} mL wody.",
|
||||
"editTotal": "Edytuj dzisiejszą sumę"
|
||||
},
|
||||
"caffeine": {
|
||||
@@ -8576,14 +8866,15 @@
|
||||
"rowMeta": "{days} z {window} dni · zalecane {value} {unit} (EFSA)",
|
||||
"rowMetaNoRef": "{days} z {window} dni",
|
||||
"footer": "{tracked} z {total} śledzonych mikroskładników ma dane w tym okresie.",
|
||||
"emptyTitle": "Brak jeszcze zsynchronizowanych danych o mikroskładnikach"
|
||||
"emptyTitle": "Brak jeszcze zsynchronizowanych danych o mikroskładnikach",
|
||||
"loadError": "Nie udało się wczytać danych o składnikach odżywczych"
|
||||
},
|
||||
"sparseNote": "Niska wartość zwykle oznacza tu rzadkie zapisywanie, a nie niedobór — dzienne sumy z Apple Health odzwierciedlają to, co zapisały twoje aplikacje, a nie całkowite spożycie w diecie."
|
||||
},
|
||||
"daily": {
|
||||
"line": {
|
||||
"score": "Twój dzisiejszy wynik zdrowia to {score}.",
|
||||
"allClear": "Dziś nic nie wymaga Twojej uwagi — wszystko przebiega normalnie."
|
||||
"allClear": "Dziś nic nie wymaga Twojej uwagi."
|
||||
},
|
||||
"push": {
|
||||
"title": "Twoje poranne podsumowanie",
|
||||
@@ -8592,8 +8883,11 @@
|
||||
"item": {
|
||||
"doseWindow": {
|
||||
"title": "Lek do przyjęcia",
|
||||
"body": "Dziś przypada dawka.",
|
||||
"bodyNamed": "{name} przypada dziś."
|
||||
"body": "Dawka wkrótce do przyjęcia.",
|
||||
"bodyNamed": "{name} wkrótce do przyjęcia.",
|
||||
"overdueTitle": "Lek zaległy",
|
||||
"overdueBody": "Dawka jest zaległa.",
|
||||
"overdueBodyNamed": "{name} jest zaległy."
|
||||
},
|
||||
"preventiveCare": {
|
||||
"title": "Badanie do wykonania",
|
||||
@@ -8607,7 +8901,9 @@
|
||||
"coachCheckin": {
|
||||
"title": "Czas przyjrzeć się planowi",
|
||||
"body": "Minął mniej więcej tydzień, odkąd ustaliłeś ten plan — zachowaj go, dostosuj lub odpuść. Bez presji.",
|
||||
"bodyPlan": "Około tygodnia temu postanowiłeś: „{plan}”. Zachowaj to, dostosuj lub odpuść — bez presji."
|
||||
"bodyPlan": "Około tygodnia temu postanowiłeś: „{plan}”. Zachowaj to, dostosuj lub odpuść — bez presji.",
|
||||
"adjustPrompt": "Chciałbym dostosować mój plan kontrolny.",
|
||||
"adjustPromptPlan": "Chciałbym dostosować ten plan: „{plan}”"
|
||||
},
|
||||
"tensionWindow": {
|
||||
"title": "Może chwila napięcia dzisiaj",
|
||||
@@ -8645,7 +8941,8 @@
|
||||
"deltaVsBaseline": "{delta} wzgl. Twojej wartości bazowej",
|
||||
"deltaFlat": "Zgodnie z Twoją wartością bazową",
|
||||
"sleepPending": "Sen z ostatniej nocy jeszcze nie dotarł — dzisiejszy odczyt jest wstępny.",
|
||||
"readFullBriefing": "Przeczytaj pełny briefing",
|
||||
"justIn": "Właśnie dotarło",
|
||||
"justInAt": "Właśnie dotarło · {time}",
|
||||
"worthALook": "Warto sprawdzić",
|
||||
"allClear": "Dziś nic nie wymaga Twojej uwagi.",
|
||||
"ringLink": "Otwórz szczegóły: {metric}"
|
||||
|
||||
@@ -231,7 +231,6 @@ const nextConfig: NextConfig = {
|
||||
serverExternalPackages: [
|
||||
"@prisma/client",
|
||||
"pg-boss",
|
||||
"@prisma/adapter-pg",
|
||||
"pg",
|
||||
// Document PDF handling MUST run the real, un-bundled modules. When
|
||||
// Turbopack bundles pdfjs-dist into the server chunks, its runtime
|
||||
|
||||
+8
-18
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.29.3",
|
||||
"version": "1.32.26",
|
||||
"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",
|
||||
@@ -22,9 +22,9 @@
|
||||
"passkey"
|
||||
],
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"packageManager": "pnpm@11.15.1",
|
||||
"scripts": {
|
||||
"prepare": "lefthook install || echo 'lefthook install skipped (no git repo or binary unavailable)'",
|
||||
"prepare": "node scripts/prepare.mjs",
|
||||
"dev": "next dev",
|
||||
"predev": "node scripts/generate-i18n-boot.mjs",
|
||||
"prebuild": "node scripts/generate-sw-version.mjs && node scripts/generate-i18n-boot.mjs",
|
||||
@@ -59,7 +59,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@napi-rs/canvas": "0.1.80",
|
||||
"@napi-rs/canvas": "1.0.2",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@parse/node-apn": "^8.1.0",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
@@ -79,7 +79,7 @@
|
||||
"jspdf-autotable": "^5.0.8",
|
||||
"lucide-react": "^1.18.0",
|
||||
"mmdb-lib": "^3.0.2",
|
||||
"next": "16.2.10",
|
||||
"next": "16.2.11",
|
||||
"next-themes": "^0.4.6",
|
||||
"nodemailer": "9.0.3",
|
||||
"otpauth": "^9.5.1",
|
||||
@@ -87,7 +87,7 @@
|
||||
"pg-boss": "^12.26.0",
|
||||
"prisma": "^7.8.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"radix-ui": "^1.6.0",
|
||||
"radix-ui": "^1.6.2",
|
||||
"react": "19.2.7",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "19.2.7",
|
||||
@@ -125,25 +125,15 @@
|
||||
"knip": "^6.16.1",
|
||||
"lefthook": "^2.1.10",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"pdfjs-dist": "6.1.200",
|
||||
"pg": "^8.22.0",
|
||||
"prettier": "^3.9.1",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"prettier-plugin-tailwindcss": "^0.8.1",
|
||||
"shadcn": "^4.13.0",
|
||||
"tailwindcss": "^4",
|
||||
"testcontainers": "^12.0.3",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^6",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"prisma",
|
||||
"@prisma/engines"
|
||||
],
|
||||
"overrides": {
|
||||
"hono@<4.12.25": "^4.12.25",
|
||||
"picomatch@<4.0.4": "^4.0.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,13 +22,13 @@ export default defineConfig({
|
||||
expect: { timeout: 10_000 },
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
failOnFlakyTests: !!process.env.CI,
|
||||
// Two retries on CI: under a loaded shared runner, DOM-settle-gated
|
||||
// assertions (wizard step transitions, list refetch → card provenance,
|
||||
// disclosure toggles) intermittently sample a mid-transition frame. A
|
||||
// rotating single test failed each run while 211 passed; a second retry
|
||||
// absorbs that transient contention without masking a real failure (a true
|
||||
// break fails all three attempts). The default expect timeout is also
|
||||
// lifted from 5s → 10s for the same settle headroom.
|
||||
// retry distinguishes transient contention from a persistent break, while
|
||||
// failOnFlakyTests keeps either outcome visible to CI. The default expect
|
||||
// timeout is lifted from 5s → 10s for the same settle headroom.
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
reporter: process.env.CI
|
||||
@@ -95,7 +95,7 @@ export default defineConfig({
|
||||
webServer: process.env.E2E_SKIP_WEB_SERVER
|
||||
? undefined
|
||||
: {
|
||||
command: "pnpm exec next start --port 3000",
|
||||
command: `mkdir -p .next/standalone/.next/static .next/standalone/public && cp -R .next/static/. .next/standalone/.next/static && cp -R public/. .next/standalone/public && PORT=3000 HOSTNAME=127.0.0.1 ${JSON.stringify(process.execPath)} .next/standalone/server.js`,
|
||||
url: "http://localhost:3000/api/version",
|
||||
timeout: 60_000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
@@ -103,6 +103,10 @@ export default defineConfig({
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
...process.env,
|
||||
// Native document rendering is fail-soft and outside the browser
|
||||
// suite. Disable it here so queued local thumbnail jobs cannot
|
||||
// crash the shared server inside Skia during unrelated scenarios.
|
||||
NATIVE_CANVAS: "off",
|
||||
// The dashboard RSC wrapper server-prefetches the snapshot into
|
||||
// the first HTML (HydrationBoundary). Playwright's route mocks
|
||||
// only see CLIENT fetches, so an SSR-embedded snapshot would
|
||||
|
||||
Generated
+953
-813
File diff suppressed because it is too large.
Load diff
+23
-3
@@ -1,3 +1,23 @@
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
overrides:
|
||||
"dompurify@<=3.4.10": "^3.4.11"
|
||||
"postcss@<8.5.10": "^8.5.10"
|
||||
"@hono/node-server@<1.19.13": "^1.19.13"
|
||||
"hono@<4.12.25": "^4.12.25"
|
||||
"picomatch@<4.0.4": "^4.0.4"
|
||||
"body-parser@<2.3.0": "^2.3.0"
|
||||
"fast-uri@<3.1.3": "^3.1.3"
|
||||
"sharp@<0.35.0": "^0.35.3"
|
||||
|
||||
allowBuilds:
|
||||
"@prisma/engines": true
|
||||
prisma: true
|
||||
core-js: false
|
||||
cpu-features: false
|
||||
esbuild: false
|
||||
lefthook: false
|
||||
msw: false
|
||||
protobufjs: false
|
||||
sharp: false
|
||||
ssh2: false
|
||||
tesseract.js: false
|
||||
unrs-resolver: false
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Persist a short plain-language summary of an uploaded document.
|
||||
--
|
||||
-- Adds two nullable columns to `inbound_documents`:
|
||||
-- - `summary_encrypted` — AES-256-GCM ciphertext of a 3-4 sentence
|
||||
-- plain-language description of WHAT the document is, generated ONCE in
|
||||
-- the background right after upload when the user's `documents_auto_ai_read`
|
||||
-- opt-in is ON (that flag is also the standing AI-egress consent). NULL
|
||||
-- when auto-read is OFF, no provider is configured, or generation has not
|
||||
-- run yet.
|
||||
-- - `summary_generated_at` — when that background summary was generated
|
||||
-- (NULL until it runs).
|
||||
--
|
||||
-- STORAGE CONVENTION: Bytes (`bytea`), matching the dominant free-text
|
||||
-- encrypted-note convention already in the schema (the `encrypt()` ciphertext
|
||||
-- string stored UTF-8 as `bytea`, the shape the rotation script's
|
||||
-- `rotateBytesColumn` already covers). The column is registered in
|
||||
-- ENCRYPTED_COLUMNS + the rotation script in the same change.
|
||||
--
|
||||
-- ADDITIVE ONLY: existing rows keep NULL summaries; the on-demand transient
|
||||
-- summary route is unchanged.
|
||||
ALTER TABLE "inbound_documents"
|
||||
ADD COLUMN IF NOT EXISTS "summary_encrypted" BYTEA,
|
||||
ADD COLUMN IF NOT EXISTS "summary_generated_at" TIMESTAMP(3);
|
||||
@@ -0,0 +1,103 @@
|
||||
-- v1.29.x — backfill non-canonical Strava `Workout.sport_type` values.
|
||||
--
|
||||
-- Root cause (the same class the 0247 WHOOP migration fixed): Strava was the
|
||||
-- second workout integration that never normalised its sport to HealthLog's
|
||||
-- canonical `WorkoutSportType` enum (see `workoutSportTypeEnum` in
|
||||
-- src/lib/validations/workout.ts). `mapActivity()` in
|
||||
-- src/lib/strava/client.ts wrote Strava's raw PascalCase `sport_type` (or
|
||||
-- the legacy `type`, e.g. "Run", "Ride", "VirtualRide", "TrailRun") — or the
|
||||
-- literal fallback "workout" when neither field was present — straight into
|
||||
-- `Workout.sport_type`. Existing Strava rows therefore carry values the
|
||||
-- `/insights/workouts` list can't map to an icon/label, AND the read-time
|
||||
-- canonical picker in src/lib/sources/pick-canonical-workout.ts treats any
|
||||
-- two non-canonical ("generic") rows as sport-compatible — a same-source
|
||||
-- brick session (a Strava "Ride" ending 15:00 immediately followed by a
|
||||
-- Strava "Run" starting 15:03) could cluster into one and silently drop a
|
||||
-- leg. `mapStravaSportType()` (src/lib/strava/sport-map.ts) fixes the write
|
||||
-- path going forward; this migration re-derives every EXISTING Strava
|
||||
-- `Workout` row from the same table so historical data and the live mapper
|
||||
-- never drift apart.
|
||||
--
|
||||
-- The WHEN arms below were generated FROM `STRAVA_SPORT_TABLE` in
|
||||
-- src/lib/strava/sport-map.ts (one JS-side script run against that exact
|
||||
-- table, not hand-transcribed). The match expression lower-cases the stored
|
||||
-- column and strips every character outside `[a-zA-Z0-9]`, mirroring
|
||||
-- `normaliseStravaSportKey()` in sport-map.ts exactly, so "TrailRun",
|
||||
-- "trailrun", and any stray-separator variant all resolve to the same key —
|
||||
-- and so the pre-fix literal fallback "workout" resolves to the same row as
|
||||
-- Strava's own "Workout" catch-all.
|
||||
--
|
||||
-- Anything neither arm recognises falls to `ELSE 'other'` — same fallback
|
||||
-- `mapStravaSportType()` uses, never leaves a row on a raw Strava label.
|
||||
--
|
||||
-- Idempotent: the WHERE clause excludes rows whose `sport_type` is ALREADY
|
||||
-- one of the 20 canonical values, so a re-run touches zero rows once the
|
||||
-- first run has completed. Safe to re-run after a partial failure.
|
||||
UPDATE "workouts"
|
||||
SET "sport_type" = CASE
|
||||
lower(regexp_replace(trim("sport_type"), '[^a-zA-Z0-9]+', '', 'g'))
|
||||
WHEN 'alpineski' THEN 'other'
|
||||
WHEN 'backcountryski' THEN 'other'
|
||||
WHEN 'badminton' THEN 'tennis'
|
||||
WHEN 'basketball' THEN 'basketball'
|
||||
WHEN 'canoeing' THEN 'rowing'
|
||||
WHEN 'cricket' THEN 'other'
|
||||
WHEN 'crossfit' THEN 'crossTraining'
|
||||
WHEN 'dance' THEN 'dance'
|
||||
WHEN 'ebikeride' THEN 'cycling'
|
||||
WHEN 'elliptical' THEN 'elliptical'
|
||||
WHEN 'emountainbikeride' THEN 'cycling'
|
||||
WHEN 'golf' THEN 'golf'
|
||||
WHEN 'gravelride' THEN 'cycling'
|
||||
WHEN 'handcycle' THEN 'cycling'
|
||||
WHEN 'highintensityintervaltraining' THEN 'hiit'
|
||||
WHEN 'hike' THEN 'hiking'
|
||||
WHEN 'iceskate' THEN 'other'
|
||||
WHEN 'inlineskate' THEN 'other'
|
||||
WHEN 'kayaking' THEN 'rowing'
|
||||
WHEN 'kitesurf' THEN 'other'
|
||||
WHEN 'mountainbikeride' THEN 'cycling'
|
||||
WHEN 'nordicski' THEN 'other'
|
||||
WHEN 'padel' THEN 'tennis'
|
||||
WHEN 'physicaltherapy' THEN 'mindAndBody'
|
||||
WHEN 'pickleball' THEN 'tennis'
|
||||
WHEN 'pilates' THEN 'mindAndBody'
|
||||
WHEN 'racquetball' THEN 'tennis'
|
||||
WHEN 'ride' THEN 'cycling'
|
||||
WHEN 'rockclimbing' THEN 'crossTraining'
|
||||
WHEN 'rollerski' THEN 'other'
|
||||
WHEN 'rowing' THEN 'rowing'
|
||||
WHEN 'run' THEN 'running'
|
||||
WHEN 'sail' THEN 'other'
|
||||
WHEN 'skateboard' THEN 'other'
|
||||
WHEN 'snowboard' THEN 'other'
|
||||
WHEN 'snowshoe' THEN 'hiking'
|
||||
WHEN 'soccer' THEN 'soccer'
|
||||
WHEN 'squash' THEN 'tennis'
|
||||
WHEN 'stairstepper' THEN 'stairClimber'
|
||||
WHEN 'standuppaddling' THEN 'rowing'
|
||||
WHEN 'surfing' THEN 'other'
|
||||
WHEN 'swim' THEN 'swimming'
|
||||
WHEN 'tabletennis' THEN 'tennis'
|
||||
WHEN 'tennis' THEN 'tennis'
|
||||
WHEN 'trailrun' THEN 'running'
|
||||
WHEN 'velomobile' THEN 'cycling'
|
||||
WHEN 'virtualride' THEN 'cycling'
|
||||
WHEN 'virtualrow' THEN 'rowing'
|
||||
WHEN 'virtualrun' THEN 'running'
|
||||
WHEN 'volleyball' THEN 'other'
|
||||
WHEN 'walk' THEN 'walking'
|
||||
WHEN 'weighttraining' THEN 'strength'
|
||||
WHEN 'wheelchair' THEN 'other'
|
||||
WHEN 'windsurf' THEN 'other'
|
||||
WHEN 'workout' THEN 'other'
|
||||
WHEN 'yoga' THEN 'yoga'
|
||||
ELSE 'other'
|
||||
END
|
||||
WHERE "source" = 'STRAVA'
|
||||
AND "sport_type" NOT IN (
|
||||
'walking', 'running', 'cycling', 'hiking', 'swimming', 'rowing',
|
||||
'elliptical', 'stairClimber', 'yoga', 'mindAndBody', 'strength', 'hiit',
|
||||
'dance', 'golf', 'tennis', 'basketball', 'soccer', 'crossTraining',
|
||||
'mixedCardio', 'other'
|
||||
);
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Native OIDC SSO session handoff (iOS#49).
|
||||
--
|
||||
-- One-time handoff codes bridging the browser-context OIDC callback to the
|
||||
-- cookie-less native token exchange. The web OIDC flow ends in a session
|
||||
-- cookie a Bearer-transport native client can never consume; this table lets
|
||||
-- the callback mint an opaque, PKCE-locked, single-use code that the app
|
||||
-- exchanges for the standard native token bundle at
|
||||
-- POST /api/auth/oidc/native/token.
|
||||
--
|
||||
-- The raw code (`hlh_<…>`) rides ONLY the custom-scheme redirect; only its
|
||||
-- HMAC-SHA256 hash is stored, mirroring the refresh-token / MFA-challenge
|
||||
-- hash-at-rest posture. `issued_refresh_token_hash` records the pair minted at
|
||||
-- exchange so a replay (a second presentation after consumption) can revoke
|
||||
-- exactly that family member — refresh-token reuse-detection reach-back.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "oidc_native_handoffs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"code_hash" TEXT NOT NULL,
|
||||
"code_challenge" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"consumed_at" TIMESTAMP(3),
|
||||
"issued_refresh_token_hash" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"ip_address" TEXT,
|
||||
"user_agent" TEXT,
|
||||
CONSTRAINT "oidc_native_handoffs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex — the exchange loads a row by the code's hash; unique so a hash
|
||||
-- collision can never resolve to two live codes.
|
||||
CREATE UNIQUE INDEX "oidc_native_handoffs_code_hash_key"
|
||||
ON "oidc_native_handoffs" ("code_hash");
|
||||
|
||||
-- CreateIndex — per-user audit scans + the FK-cascade delete path.
|
||||
CREATE INDEX "oidc_native_handoffs_user_id_idx"
|
||||
ON "oidc_native_handoffs" ("user_id");
|
||||
|
||||
-- CreateIndex — the daily cleanup sweep filters on `expires_at`.
|
||||
CREATE INDEX "oidc_native_handoffs_expires_at_idx"
|
||||
ON "oidc_native_handoffs" ("expires_at");
|
||||
|
||||
-- AddForeignKey — deleting a user cascades their handoff rows away.
|
||||
ALTER TABLE "oidc_native_handoffs"
|
||||
ADD CONSTRAINT "oidc_native_handoffs_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- v1.30.12 perf — index the two medication-intake read shapes the dashboard
|
||||
-- "meds today" builder fires that could not index-seek before: the sibling
|
||||
-- reads filtering (user_id, scheduled_for) without medication_id, and the
|
||||
-- per-medication last-taken group-by that scanned the whole intake history.
|
||||
-- Additive; no data change.
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "medication_intake_events_user_id_scheduled_for_idx" ON "medication_intake_events"("user_id", "scheduled_for");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "medication_intake_events_user_id_medication_id_taken_at_idx" ON "medication_intake_events"("user_id", "medication_id", "taken_at");
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Record what happened to a document's summary.
|
||||
--
|
||||
-- `summary_encrypted` alone cannot tell four outcomes apart: a document nobody
|
||||
-- ever enqueued, one whose provider is missing, one the safety screen withheld,
|
||||
-- and one still in flight all leave it NULL. The detail view read that NULL as
|
||||
-- "still generating" and said so forever.
|
||||
--
|
||||
-- Additive and backfill-free: NONE is the default and is the truthful value for
|
||||
-- every pre-existing row, because none of them were ever attempted. Rows that
|
||||
-- already carry a summary are moved to READY below.
|
||||
|
||||
CREATE TYPE "document_summary_state" AS ENUM (
|
||||
'NONE',
|
||||
'PENDING',
|
||||
'READY',
|
||||
'WITHHELD',
|
||||
'UNAVAILABLE'
|
||||
);
|
||||
|
||||
ALTER TABLE "inbound_documents"
|
||||
ADD COLUMN "summary_state" "document_summary_state" NOT NULL DEFAULT 'NONE';
|
||||
|
||||
-- A document that already holds a summary is READY, not NONE.
|
||||
UPDATE "inbound_documents"
|
||||
SET "summary_state" = 'READY'
|
||||
WHERE "summary_encrypted" IS NOT NULL;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Session cookies carry a purpose-generated secret rather than the row's cuid
|
||||
-- primary key.
|
||||
--
|
||||
-- A cuid is built for collision-resistant identity, not for unguessability: it
|
||||
-- embeds a timestamp and a monotonic counter and does not come from a CSPRNG.
|
||||
-- A session cookie is a bearer credential and must. The cookie now carries a
|
||||
-- 32-byte random token (`hls_<hex>`) and only its HMAC-SHA256 hash is stored,
|
||||
-- matching how api_tokens and refresh_tokens already hold their secrets.
|
||||
--
|
||||
-- Additive and nullable ON PURPOSE: every session row that exists at deploy
|
||||
-- time keeps NULL here and stays resolvable by its id, so the release signs
|
||||
-- nobody out. Those rows cannot have their sliding expiry extended, so the
|
||||
-- id-as-credential path drains itself within the 30-day session lifetime with
|
||||
-- no operator action. Postgres permits many NULLs under a unique index, so the
|
||||
-- constraint below does not conflict with the backfill-free transition.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "sessions" ADD COLUMN "token_hash" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "sessions_token_hash_key" ON "sessions"("token_hash");
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Carton labelling for an inventory container: manufacturer + printed strength.
|
||||
--
|
||||
-- The native client's pen list uses these two as its headline and subhead, so
|
||||
-- a container created on the web could not be shown there without fabricating
|
||||
-- an empty card. Nothing on the server held them, so the client kept its own
|
||||
-- copy locally and no back-fill was possible.
|
||||
--
|
||||
-- Both are additive and nullable. Every pre-existing row is truthfully NULL:
|
||||
-- the values were never collected, and a plain supply row (a blister pack, a
|
||||
-- bottle) has no use for either. No backfill, no default, no rewrite of the
|
||||
-- table's existing contents.
|
||||
ALTER TABLE "medication_inventory_items"
|
||||
ADD COLUMN "manufacturer" TEXT,
|
||||
ADD COLUMN "dose_strength" TEXT;
|
||||
@@ -0,0 +1,53 @@
|
||||
-- The data-arrival spine's durable reaction marker.
|
||||
--
|
||||
-- One row per (user, arrival kind, local day), written by the `data-arrival`
|
||||
-- worker after a SALIENT ingest. A backfill never reaches here: the seam
|
||||
-- classifier drops historical and future-dated samples before anything is
|
||||
-- enqueued, so a ten-year import writes nothing to this table.
|
||||
--
|
||||
-- The unique index is the point of the table, not decoration. It carries three
|
||||
-- separate contracts at once:
|
||||
-- 1. the "just in" marker every reaction surface reads,
|
||||
-- 2. the at-most-one-generated-line per kind per day claim — the worker
|
||||
-- inserts with ON CONFLICT DO NOTHING, so a lost race simply means
|
||||
-- another worker already owns today's line,
|
||||
-- 3. the audit trail of what the spine actually reacted to.
|
||||
--
|
||||
-- `line_encrypted` is AES-256-GCM at rest, following the
|
||||
-- `insight_narratives.encrypted_content` precedent. It is NULLABLE on purpose:
|
||||
-- the marker is the feature and the sentence is garnish, so a provider-less
|
||||
-- install fills the table normally and simply never sets this column.
|
||||
--
|
||||
-- Rows are pruned after 14 days by the daily `arrival-reaction-cleanup` job.
|
||||
|
||||
CREATE TABLE "arrival_reactions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"local_date" TEXT NOT NULL,
|
||||
"occurred_at" TIMESTAMP(3) NOT NULL,
|
||||
"ref_id" TEXT,
|
||||
"line_encrypted" BYTEA,
|
||||
"generated_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "arrival_reactions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- The once-per-kind-per-local-day claim. Do NOT widen this to include
|
||||
-- `ref_id`: the day scope IS the throttle.
|
||||
CREATE UNIQUE INDEX "arrival_reactions_user_id_kind_local_date_key"
|
||||
ON "arrival_reactions" ("user_id", "kind", "local_date");
|
||||
|
||||
-- The reaction surfaces read "today's rows for this user".
|
||||
CREATE INDEX "arrival_reactions_user_id_local_date_idx"
|
||||
ON "arrival_reactions" ("user_id", "local_date");
|
||||
|
||||
-- The nightly retention sweep deletes by age.
|
||||
CREATE INDEX "arrival_reactions_created_at_idx"
|
||||
ON "arrival_reactions" ("created_at");
|
||||
|
||||
ALTER TABLE "arrival_reactions"
|
||||
ADD CONSTRAINT "arrival_reactions_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,52 @@
|
||||
-- The per-workout Activity Insight paragraph.
|
||||
--
|
||||
-- Its own table rather than a column on `workouts` because a workout delete is
|
||||
-- a HARD delete: the FK below cascades, so the paragraph can never outlive the
|
||||
-- session it describes. Keeping it out of `workouts` also keeps the generation
|
||||
-- provenance off the row every list read selects from.
|
||||
--
|
||||
-- Rows are written ONLY by the `workout-insight-generate` worker, dispatched by
|
||||
-- the data-arrival spine when a workout genuinely lands. No read path creates
|
||||
-- one, so every workout that predates this migration stays without a row — and
|
||||
-- renders no card — forever, at zero provider cost.
|
||||
--
|
||||
-- `paragraph_encrypted` is AES-256-GCM at rest, following the
|
||||
-- `insight_narratives.encrypted_content` precedent, and is registered in
|
||||
-- `src/lib/crypto/encrypted-columns.ts` so key rotation covers it.
|
||||
--
|
||||
-- `input_hash` fingerprints the deterministic evidence the paragraph was built
|
||||
-- from together with the prompt version: a device re-post of the same session
|
||||
-- hashes identically and is refused before any provider is resolved.
|
||||
|
||||
CREATE TABLE "workout_insights" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"workout_id" TEXT NOT NULL,
|
||||
"paragraph_encrypted" BYTEA NOT NULL,
|
||||
"input_hash" TEXT NOT NULL,
|
||||
"prompt_version" TEXT NOT NULL,
|
||||
"provider_type" TEXT,
|
||||
"locale" TEXT NOT NULL,
|
||||
"generated_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "workout_insights_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- One paragraph per workout. This is also the second half of the
|
||||
-- double-post defence: the singleton queue key collapses a burst, and this
|
||||
-- constraint makes the claim durable even if two workers do run.
|
||||
CREATE UNIQUE INDEX "workout_insights_workout_id_key"
|
||||
ON "workout_insights"("workout_id");
|
||||
|
||||
-- The daily-cap count: rows for one user inside a generated_at window.
|
||||
CREATE INDEX "workout_insights_user_id_generated_at_idx"
|
||||
ON "workout_insights"("user_id", "generated_at");
|
||||
|
||||
ALTER TABLE "workout_insights"
|
||||
ADD CONSTRAINT "workout_insights_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "workout_insights"
|
||||
ADD CONSTRAINT "workout_insights_workout_id_fkey"
|
||||
FOREIGN KEY ("workout_id") REFERENCES "workouts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Durable arrival freshness plus ownership and spend linkage for
|
||||
-- arrival-reaction provider attempts.
|
||||
ALTER TABLE "arrival_reactions"
|
||||
ADD COLUMN "arrived_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD COLUMN "generation_claim_id" TEXT,
|
||||
ADD COLUMN "generation_claimed_at" TIMESTAMP(3),
|
||||
ADD COLUMN "generation_reserved_tokens" INTEGER,
|
||||
ADD COLUMN "generation_budget_date_key" TEXT,
|
||||
ADD COLUMN "generation_provider_invoked_at" TIMESTAMP(3);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Preserve the original landing time for markers that predate `arrived_at`.
|
||||
-- Migration 0260 must add the NOT NULL column with a default; this follow-up
|
||||
-- restores historical rows to their actual first-arrival timestamp before the
|
||||
-- application starts serving the release.
|
||||
UPDATE "arrival_reactions"
|
||||
SET "arrived_at" = "created_at";
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Durable single-spend ownership and local-day capacity reservations for
|
||||
-- per-workout Activity Insight generation. Existing insight rows remain valid
|
||||
-- and are counted by the worker until a claim row exists for their workout.
|
||||
CREATE TABLE "workout_insight_generation_claims" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"workout_id" TEXT NOT NULL,
|
||||
"local_date" TEXT NOT NULL,
|
||||
"claim_id" TEXT,
|
||||
"claimed_at" TIMESTAMP(3),
|
||||
"provider_invoked_at" TIMESTAMP(3),
|
||||
"completed_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "workout_insight_generation_claims_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "workout_insight_generation_claims_workout_id_key"
|
||||
ON "workout_insight_generation_claims"("workout_id");
|
||||
|
||||
CREATE UNIQUE INDEX "workout_insight_generation_claims_claim_id_key"
|
||||
ON "workout_insight_generation_claims"("claim_id");
|
||||
|
||||
CREATE INDEX "workout_insight_generation_claims_user_id_local_date_idx"
|
||||
ON "workout_insight_generation_claims"("user_id", "local_date");
|
||||
|
||||
ALTER TABLE "workout_insight_generation_claims"
|
||||
ADD CONSTRAINT "workout_insight_generation_claims_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "workout_insight_generation_claims"
|
||||
ADD CONSTRAINT "workout_insight_generation_claims_workout_id_fkey"
|
||||
FOREIGN KEY ("workout_id") REFERENCES "workouts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Aggregate provenance makes the shared Apple Health `stats:*` identity
|
||||
-- authority-aware. Existing rows cannot be classified safely from their stored
|
||||
-- shape, so they remain repairable but are never promoted heuristically.
|
||||
CREATE TYPE "measurement_aggregation_provenance" AS ENUM (
|
||||
'HEALTHKIT_STATISTICS',
|
||||
'EXPORT_XML_SOURCE_MAX',
|
||||
'LEGACY_UNKNOWN'
|
||||
);
|
||||
|
||||
ALTER TABLE "measurements"
|
||||
ADD COLUMN "aggregation_provenance" "measurement_aggregation_provenance",
|
||||
ADD COLUMN "aggregation_contributor_count" INTEGER,
|
||||
ADD COLUMN "aggregation_selected_source_hash" TEXT;
|
||||
|
||||
UPDATE "measurements"
|
||||
SET "aggregation_provenance" = 'LEGACY_UNKNOWN'
|
||||
WHERE "source" = 'APPLE_HEALTH'
|
||||
AND "external_id" LIKE 'stats:%';
|
||||
|
||||
-- Revision 1 denotes rows created under the pre-authority parser. New binaries
|
||||
-- write revision 2 explicitly; the default stays legacy-safe during rollouts.
|
||||
ALTER TABLE "import_jobs"
|
||||
ADD COLUMN "parser_revision" INTEGER NOT NULL DEFAULT 1;
|
||||
|
||||
UPDATE "import_jobs"
|
||||
SET "parser_revision" = 1;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- WHOOP provider identities are globally owned by one local account.
|
||||
-- Existing duplicate identities keep the earliest connection; ties are broken
|
||||
-- by the primary key so every database chooses the same winner.
|
||||
ALTER TABLE "whoop_connections"
|
||||
ALTER COLUMN "whoop_user_id" DROP NOT NULL;
|
||||
|
||||
WITH ranked_connections AS (
|
||||
SELECT
|
||||
"id",
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY "whoop_user_id"
|
||||
ORDER BY "created_at" ASC, "id" ASC
|
||||
) AS owner_rank
|
||||
FROM "whoop_connections"
|
||||
WHERE "whoop_user_id" IS NOT NULL
|
||||
)
|
||||
UPDATE "whoop_connections" AS connection
|
||||
SET
|
||||
"whoop_user_id" = NULL,
|
||||
"updated_at" = CURRENT_TIMESTAMP
|
||||
FROM ranked_connections AS ranked
|
||||
WHERE connection."id" = ranked."id"
|
||||
AND ranked.owner_rank > 1;
|
||||
|
||||
CREATE UNIQUE INDEX "whoop_connections_whoop_user_id_key"
|
||||
ON "whoop_connections"("whoop_user_id");
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE "withings_connections"
|
||||
ADD COLUMN "webhook_subscription_state" JSONB,
|
||||
ADD COLUMN "webhook_subscription_retry_at" TIMESTAMP(3);
|
||||
|
||||
-- Existing connections have no durable outcome yet. Mark them due so the
|
||||
-- hourly repair reconciles each category; status 294 is idempotent success.
|
||||
UPDATE "withings_connections"
|
||||
SET "webhook_subscription_retry_at" = CURRENT_TIMESTAMP;
|
||||
|
||||
CREATE INDEX "withings_connections_webhook_subscription_retry_at_idx"
|
||||
ON "withings_connections"("webhook_subscription_retry_at");
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Durable medication-intake imports keep the validated payload and monotonic
|
||||
-- progress in Postgres so queue retries and process restarts cannot duplicate
|
||||
-- intake or inventory side effects. This is additive and preserves all rows.
|
||||
CREATE TABLE "medication_intake_import_jobs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"medication_id" TEXT NOT NULL,
|
||||
"pg_boss_job_id" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'queued',
|
||||
"payload" JSONB NOT NULL,
|
||||
"progress" JSONB NOT NULL DEFAULT '{}',
|
||||
"result" JSONB,
|
||||
"failure_reason" TEXT,
|
||||
"heartbeat_at" TIMESTAMP(3),
|
||||
"started_at" TIMESTAMP(3),
|
||||
"completed_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "medication_intake_import_jobs_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "medication_intake_import_jobs_status_check"
|
||||
CHECK ("status" IN ('queued', 'running', 'done', 'failed')),
|
||||
CONSTRAINT "medication_intake_import_jobs_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "medication_intake_import_jobs_medication_id_fkey"
|
||||
FOREIGN KEY ("medication_id") REFERENCES "medications"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "medication_intake_import_jobs_pg_boss_job_id_key"
|
||||
ON "medication_intake_import_jobs"("pg_boss_job_id");
|
||||
CREATE INDEX "medication_intake_import_jobs_user_id_created_at_idx"
|
||||
ON "medication_intake_import_jobs"("user_id", "created_at");
|
||||
CREATE INDEX "medication_intake_import_jobs_medication_id_created_at_idx"
|
||||
ON "medication_intake_import_jobs"("medication_id", "created_at");
|
||||
CREATE INDEX "medication_intake_import_jobs_status_heartbeat_at_idx"
|
||||
ON "medication_intake_import_jobs"("status", "heartbeat_at");
|
||||
@@ -0,0 +1,40 @@
|
||||
-- v1.30.34 — token-bound step-up elevations.
|
||||
--
|
||||
-- The Bearer-transport equivalent of `sessions.mfa_verified_at`. A row is
|
||||
-- minted only after a password or primary-passkey re-proof, is bound to the
|
||||
-- exact API token that re-proved, is single-use (`consumed_at`), and expires
|
||||
-- within minutes. Only the HMAC of the opaque secret is stored.
|
||||
|
||||
CREATE TABLE "step_up_elevations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"api_token_id" TEXT NOT NULL,
|
||||
"token_hash" TEXT NOT NULL,
|
||||
"method" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"consumed_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "step_up_elevations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "step_up_elevations_token_hash_key" ON "step_up_elevations"("token_hash");
|
||||
|
||||
-- At most ONE redeemable elevation per token, enforced by the database rather
|
||||
-- than by the mint's delete-then-create ordering. Without it two concurrent
|
||||
-- mints can interleave so that both rows survive, or the second mint's delete
|
||||
-- strands the value the first mint already handed to the client.
|
||||
CREATE UNIQUE INDEX "step_up_elevations_live_per_token_key"
|
||||
ON "step_up_elevations"("api_token_id") WHERE "consumed_at" IS NULL;
|
||||
|
||||
CREATE INDEX "step_up_elevations_user_id_idx" ON "step_up_elevations"("user_id");
|
||||
CREATE INDEX "step_up_elevations_api_token_id_idx" ON "step_up_elevations"("api_token_id");
|
||||
CREATE INDEX "step_up_elevations_expires_at_idx" ON "step_up_elevations"("expires_at");
|
||||
|
||||
ALTER TABLE "step_up_elevations" ADD CONSTRAINT "step_up_elevations_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Cascade on the token binding: revoking or deleting a token must never leave a
|
||||
-- redeemable elevation behind.
|
||||
ALTER TABLE "step_up_elevations" ADD CONSTRAINT "step_up_elevations_api_token_id_fkey"
|
||||
FOREIGN KEY ("api_token_id") REFERENCES "api_tokens"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -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";
|
||||
+409
-37
@@ -658,6 +658,10 @@ model User {
|
||||
// skips the second factor (factor 2 only) within the window; the password
|
||||
// is still required. Revoked when the second factor is removed.
|
||||
trustedDevices TrustedDevice[]
|
||||
// v1.30.34 — short-lived, token-bound step-up elevations. The Bearer-transport
|
||||
// equivalent of `Session.mfaVerifiedAt`: a token client re-proves a factor and
|
||||
// receives one single-use elevation good for one MFA-management call.
|
||||
stepUpElevations StepUpElevation[]
|
||||
measurements Measurement[]
|
||||
medications Medication[]
|
||||
intakeEvents MedicationIntakeEvent[]
|
||||
@@ -667,6 +671,9 @@ model User {
|
||||
// authorization for an MCP client, audience-bound to health:read/health:write.
|
||||
mcpOAuthConnections McpOAuthConnection[]
|
||||
refreshTokens RefreshToken[]
|
||||
// v1.30.x — one-time native OIDC SSO handoff codes. Each row bridges the
|
||||
// browser-context OIDC callback to the cookie-less native token exchange.
|
||||
oidcNativeHandoffs OidcNativeHandoff[]
|
||||
auditLogs AuditLog[]
|
||||
withingsConnection WithingsConnection?
|
||||
// v1.4.47 W6 — short-lived `(nonce → userId)` ledger backing the
|
||||
@@ -734,6 +741,7 @@ model User {
|
||||
// pg-boss job state so the polling endpoint can read live per-
|
||||
// phase progress without round-tripping through pg-boss internals.
|
||||
importJobs ImportJob[]
|
||||
medicationIntakeImportJobs MedicationIntakeImportJob[]
|
||||
// v1.5.0 — persistent measurement rollup table. Carries the
|
||||
// count/min/max/mean/sd/slope/r2 stats per (type, granularity,
|
||||
// bucketStart) so the analytics + comprehensive read paths bypass
|
||||
@@ -789,6 +797,12 @@ model User {
|
||||
// v1.11.0 W3 — one durable period-narrative row per (period, locale).
|
||||
// Delete/regenerate-clean; the generated prose is AES-256-GCM at rest.
|
||||
insightNarratives InsightNarrative[]
|
||||
// v1.31.0 — the data-arrival spine's durable per-day reaction marker. One
|
||||
// row per (kind, local day); the unique index IS the once-per-kind-per-day
|
||||
// claim every reaction surface reads. Retained 14 days.
|
||||
arrivalReactions ArrivalReaction[]
|
||||
workoutInsights WorkoutInsight[]
|
||||
workoutInsightGenerationClaims WorkoutInsightGenerationClaim[]
|
||||
// v1.17.1 — preventive-care / measurement (Vorsorge) reminders. One row
|
||||
// per user-created reminder ("BP every 7 days", "annual blood panel").
|
||||
// Server computes the canonical `nextDueAt`; the cron fires through the
|
||||
@@ -1020,6 +1034,16 @@ model Passkey {
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
// v1.30.32 — HMAC-SHA256 of the session cookie's secret. The cookie carries
|
||||
// a 32-byte CSPRNG token (`hls_<hex>`), never this row's cuid: a cuid is
|
||||
// collision-resistant, not unguessable (embedded timestamp + counter), and a
|
||||
// session identifier is a bearer credential. Hash-at-rest mirrors the
|
||||
// ApiToken / RefreshToken posture — a database read yields no usable cookie.
|
||||
//
|
||||
// Nullable for the transition only: rows minted before this column existed
|
||||
// carry NULL and are still resolvable by id until they expire. See the
|
||||
// resolution comment in `src/lib/auth/session.ts`.
|
||||
tokenHash String? @unique @map("token_hash")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
ipAddress String? @map("ip_address")
|
||||
@@ -1573,6 +1597,14 @@ enum SleepStage {
|
||||
@@map("sleep_stage")
|
||||
}
|
||||
|
||||
enum MeasurementAggregationProvenance {
|
||||
HEALTHKIT_STATISTICS
|
||||
EXPORT_XML_SOURCE_MAX
|
||||
LEGACY_UNKNOWN
|
||||
|
||||
@@map("measurement_aggregation_provenance")
|
||||
}
|
||||
|
||||
model Measurement {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
@@ -1606,6 +1638,18 @@ model Measurement {
|
||||
// sample's `metadata.HKWasUserEntered`, Withings model code, …).
|
||||
// Stored as a string so the ingest path can replay it back unchanged.
|
||||
externalSourceVersion String? @map("external_source_version")
|
||||
// Authority of an aggregate stored under a `stats:*` identity. NULL for
|
||||
// ordinary point/sample rows. Native HealthKit statistics outrank the
|
||||
// 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.
|
||||
glucoseContext GlucoseContext? @map("glucose_context")
|
||||
// v1.4.23 — per-stage sleep label. NULL for non-sleep rows; non-NULL
|
||||
@@ -1922,6 +1966,8 @@ model Workout {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
route WorkoutRoute?
|
||||
samples WorkoutSamples?
|
||||
insight WorkoutInsight?
|
||||
insightGenerationClaim WorkoutInsightGenerationClaim?
|
||||
|
||||
/// Ingest dedup. NULL externalId is distinct so two manual
|
||||
/// entries on the same day never collide here.
|
||||
@@ -2012,6 +2058,75 @@ model WorkoutSamples {
|
||||
@@map("workout_samples")
|
||||
}
|
||||
|
||||
// ─── Per-workout Activity Insight (v1.31.0) ─────────────────
|
||||
//
|
||||
// The grounded paragraph the workout-detail seam renders. Its OWN model
|
||||
// rather than a column on `Workout` for one concrete reason: workout deletes
|
||||
// are HARD deletes (see the note on the `Workout` unique above), so the row
|
||||
// has to disappear with its workout. A column would go with it too, but a
|
||||
// separate model also keeps the generation provenance (`inputHash`,
|
||||
// `promptVersion`, `providerType`) off the hot workout row that every list
|
||||
// read selects from, and gives the daily-cap count an index of its own.
|
||||
//
|
||||
// Written ONLY by the `workout-insight-generate` worker, which the arrival
|
||||
// spine dispatches when a workout genuinely LANDS. Nothing on a read path
|
||||
// ever creates one: a workout with no row renders no card, and that includes
|
||||
// every workout that predates this model.
|
||||
model WorkoutInsight {
|
||||
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
workoutId String @unique @map("workout_id")
|
||||
/// AES-256-GCM ciphertext of the generated paragraph. Follows the
|
||||
/// `InsightNarrative.encryptedContent` precedent, NOT the plaintext pattern
|
||||
/// the status cards use. Registered in `src/lib/crypto/encrypted-columns.ts`
|
||||
/// and rotated by `scripts/rotate-encryption-key.ts`.
|
||||
paragraphEncrypted Bytes @map("paragraph_encrypted")
|
||||
/// Fingerprint of the deterministic evidence the paragraph was generated
|
||||
/// from, plus the prompt version. A re-sync of the same session hashes
|
||||
/// identically and costs nothing.
|
||||
inputHash String @map("input_hash")
|
||||
promptVersion String @map("prompt_version")
|
||||
providerType String? @map("provider_type")
|
||||
/// The UI locale the paragraph was WRITTEN in. Provenance only: a later
|
||||
/// language switch does not regenerate, because a read path may never
|
||||
/// trigger a provider call. Stored so that is visible rather than guessed.
|
||||
locale String
|
||||
generatedAt DateTime @map("generated_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
workout Workout @relation(fields: [workoutId], references: [id], onDelete: Cascade)
|
||||
|
||||
/// The per-day cap count reads this: rows for one user in a generatedAt
|
||||
/// window.
|
||||
@@index([userId, generatedAt])
|
||||
@@map("workout_insights")
|
||||
}
|
||||
|
||||
// Durable ownership and local-day capacity reservation for workout insight
|
||||
// generation. The row is created before expensive evidence work. A live
|
||||
// `claimId` owns the pre-provider lease; `providerInvokedAt` is the terminal
|
||||
// boundary after which retries must never spend again.
|
||||
model WorkoutInsightGenerationClaim {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
workoutId String @unique @map("workout_id")
|
||||
localDate String @map("local_date")
|
||||
claimId String? @unique @map("claim_id")
|
||||
claimedAt DateTime? @map("claimed_at")
|
||||
providerInvokedAt DateTime? @map("provider_invoked_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
workout Workout @relation(fields: [workoutId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, localDate])
|
||||
@@map("workout_insight_generation_claims")
|
||||
}
|
||||
|
||||
// ─── Strain personal anchor cache (v1.10.3) ─────────────────
|
||||
//
|
||||
// Server-internal per-(user, day) cache that lets the nightly Strain
|
||||
@@ -2309,6 +2424,7 @@ model Medication {
|
||||
|
||||
schedules MedicationSchedule[]
|
||||
intakeEvents MedicationIntakeEvent[]
|
||||
intakeImportJobs MedicationIntakeImportJob[]
|
||||
phaseConfig ReminderPhaseConfig?
|
||||
telegramMessages TelegramReminderMessage[]
|
||||
doseChanges MedicationDoseChange[]
|
||||
@@ -2730,6 +2846,12 @@ model MedicationIntakeEvent {
|
||||
// v1.7.0 sync — keyset support for the `/api/sync/changes` intake feed
|
||||
// (`where userId` ordered by `(updatedAt asc, id asc)`).
|
||||
@@index([userId, updatedAt, id])
|
||||
// v1.30.12 perf — the dashboard "meds today" read fires sibling queries that
|
||||
// filter `(userId, scheduledFor)` WITHOUT `medicationId` (the composite above
|
||||
// cannot range-seek those) and a per-medication `_max(takenAt)` group-by that
|
||||
// scanned the whole intake history. These two let both index-seek instead.
|
||||
@@index([userId, scheduledFor])
|
||||
@@index([userId, medicationId, takenAt])
|
||||
@@map("medication_intake_events")
|
||||
}
|
||||
|
||||
@@ -2852,6 +2974,18 @@ model MedicationInventoryItem {
|
||||
/// The carton-printed expiry date the user enters at creation.
|
||||
printedExpiry DateTime? @map("printed_expiry")
|
||||
purchasedAt DateTime? @map("purchased_at")
|
||||
/// Marketing-authorisation holder / maker printed on the carton. Free
|
||||
/// text: the field is a label the user reads back, not a lookup key, and
|
||||
/// there is no authority list a self-hoster could be held to. Nullable
|
||||
/// because every pre-existing row predates it and plain supply rows
|
||||
/// (blisters, bottles) have no use for it.
|
||||
manufacturer String?
|
||||
/// Strength as printed on the container, e.g. "5 mg/0.5 ml". Free text
|
||||
/// and deliberately NOT parsed into a number + unit: pens state strength
|
||||
/// per dose, per ml, or per cartridge depending on the product, and a
|
||||
/// forced numeric split would lose which of the three it was. Nullable
|
||||
/// for the same reason as `manufacturer`.
|
||||
doseStrength String? @map("dose_strength")
|
||||
/// Free-text note, application-layer max 200 chars.
|
||||
notes String?
|
||||
/// v1.25 — AES-256-GCM ciphertext for `notes`. The plaintext column
|
||||
@@ -3084,12 +3218,71 @@ model ApiToken {
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull)
|
||||
// v1.30.34 — step-up elevations bound to this token. Cascade-deleted with it.
|
||||
stepUpElevations StepUpElevation[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([mcpConnectionId])
|
||||
@@map("api_tokens")
|
||||
}
|
||||
|
||||
// v1.30.34 — a step-up elevation: the Bearer-transport answer to
|
||||
// `Session.mfaVerifiedAt`.
|
||||
//
|
||||
// The web gates second-factor management on a freshness stamp written onto a
|
||||
// session row. A token client has no session row, so it had no way to express
|
||||
// "the human holding this device just re-proved a factor" and the whole
|
||||
// MFA-management surface was unreachable from a native client.
|
||||
//
|
||||
// A row here is that proof, and it is deliberately the weakest credential in
|
||||
// the tree:
|
||||
// - it is minted ONLY after a password or primary-passkey re-proof, so
|
||||
// presenting a stolen token gains nothing;
|
||||
// - `apiTokenId` binds it to the exact token that re-proved, so a second
|
||||
// token — the same user's included — cannot redeem it;
|
||||
// - `consumedAt` makes it single-use, claimed by a conditional UPDATE so two
|
||||
// concurrent redemptions cannot both win;
|
||||
// - `expiresAt` caps it at minutes, matching the cookie path's step-up window.
|
||||
//
|
||||
// Only the hash of the opaque `hle_` secret is stored, so a table dump yields
|
||||
// nothing redeemable — the same posture as ApiToken, RefreshToken, and Session.
|
||||
// Revoking the token strands every elevation bound to it by construction:
|
||||
// redemption runs behind Bearer resolution, which refuses a revoked token.
|
||||
model StepUpElevation {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
// The `ApiToken` row this elevation is bound to. Cascade so a deleted token
|
||||
// cannot leave a redeemable elevation behind.
|
||||
apiTokenId String @map("api_token_id")
|
||||
// HMAC-SHA256 (`hashToken`) of the raw `hle_<64 hex>` secret. Unique so a
|
||||
// redemption is a single indexed lookup and a collision cannot alias two rows.
|
||||
tokenHash String @unique @map("token_hash")
|
||||
// Which factor was re-proved: "password", "totp", "webauthn", or "passkey".
|
||||
// This IS an authorisation input, not telemetry — see `FRESH_FACTOR_METHODS`
|
||||
// in `@/lib/auth/step-up`. Only totp / webauthn / passkey satisfy a
|
||||
// fresh-factor gate; password reaches what a plain cookie session reaches
|
||||
// and stops there.
|
||||
method String
|
||||
expiresAt DateTime @map("expires_at")
|
||||
consumedAt DateTime? @map("consumed_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
apiToken ApiToken @relation(fields: [apiTokenId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([apiTokenId])
|
||||
@@index([expiresAt])
|
||||
// NOTE: migration 0267 additionally creates a PARTIAL unique index —
|
||||
// `(api_token_id) WHERE consumed_at IS NULL` — so at most one elevation per
|
||||
// token is ever redeemable. Prisma cannot express a partial index, and a
|
||||
// plain `@@unique([apiTokenId])` would be wrong: it would also forbid minting
|
||||
// a second elevation after the first was legitimately consumed. The
|
||||
// constraint therefore lives only in the migration, and the mint below
|
||||
// handles its unique violation explicitly.
|
||||
@@map("step_up_elevations")
|
||||
}
|
||||
|
||||
// MCP OAuth connection — the revocable, theft-detectable anchor for a remote
|
||||
// connector's refresh-token chain (Phase 3 security fix, H2).
|
||||
//
|
||||
@@ -3214,6 +3407,49 @@ model RefreshToken {
|
||||
@@map("refresh_tokens")
|
||||
}
|
||||
|
||||
// v1.30.x — one-time native OIDC handoff code. Bridges the browser-context
|
||||
// OIDC callback to the cookie-less native token exchange. The raw value
|
||||
// `hlh_<…>` rides ONLY the custom-scheme redirect (healthlog://oidc-callback);
|
||||
// only its HMAC-SHA256 hash (hashToken, keyed by API_TOKEN_HMAC_KEY) is stored,
|
||||
// so a database dump reconstructs nothing. Single-use via the guarded consume
|
||||
// (`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")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
consumedAt DateTime? @map("consumed_at")
|
||||
/// hashToken of the refresh token issued at exchange — replay containment
|
||||
/// revokes exactly this family member (+ its paired access token).
|
||||
issuedRefreshTokenHash String? @map("issued_refresh_token_hash")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
ipAddress String? @map("ip_address")
|
||||
userAgent String? @map("user_agent")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
@@map("oidc_native_handoffs")
|
||||
}
|
||||
|
||||
// ─── Withings ──────────────────────────────────────────────
|
||||
|
||||
model WithingsConnection {
|
||||
@@ -3239,11 +3475,17 @@ model WithingsConnection {
|
||||
/// and re-syncs with the corrected END stamp, then sets this so the discovery
|
||||
/// query drops the connection. Null = backfill not yet run.
|
||||
sleepTimelineBackfillAt DateTime? @map("sleep_timeline_backfill_at")
|
||||
/// v1.32 — durable per-category webhook subscription outcomes. The JSON
|
||||
/// payload is versioned and validated before use; the timestamp mirrors the
|
||||
/// earliest transient/pending category retry for indexed hourly discovery.
|
||||
webhookSubscriptionState Json? @map("webhook_subscription_state")
|
||||
webhookSubscriptionRetryAt DateTime? @map("webhook_subscription_retry_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([webhookSubscriptionRetryAt])
|
||||
@@map("withings_connections")
|
||||
}
|
||||
|
||||
@@ -3293,7 +3535,7 @@ model WithingsOAuthState {
|
||||
model WhoopConnection {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique @map("user_id")
|
||||
whoopUserId String @map("whoop_user_id")
|
||||
whoopUserId String? @unique @map("whoop_user_id")
|
||||
accessToken String @map("access_token") // encrypted at app level
|
||||
refreshToken String @map("refresh_token") // encrypted at app level
|
||||
tokenExpiresAt DateTime @map("token_expires_at")
|
||||
@@ -4145,6 +4387,9 @@ model ImportJob {
|
||||
/// kick-off endpoint short-circuit a re-upload of the exact same
|
||||
/// file back to the existing job id without re-queueing.
|
||||
uploadSha256 String? @map("upload_sha256")
|
||||
/// Parser semantics revision. Existing archives migrated at revision 1 can
|
||||
/// be deliberately reprocessed when the current parser revision advances.
|
||||
parserRevision Int @default(1) @map("parser_revision")
|
||||
/// Apple's `ExportDate` attribute string-formatted, parsed during
|
||||
/// the unpack phase. NULL until the parser sees it.
|
||||
exportedAt DateTime? @map("exported_at")
|
||||
@@ -4171,6 +4416,33 @@ model ImportJob {
|
||||
@@map("import_jobs")
|
||||
}
|
||||
|
||||
// Durable medication-intake CSV imports. The validated, normalized payload
|
||||
// stays in Postgres so retries never depend on request-process memory.
|
||||
model MedicationIntakeImportJob {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
medicationId String @map("medication_id")
|
||||
pgBossJobId String? @unique @map("pg_boss_job_id")
|
||||
status String @default("queued")
|
||||
payload Json
|
||||
progress Json @default("{}")
|
||||
result Json?
|
||||
failureReason String? @map("failure_reason")
|
||||
heartbeatAt DateTime? @map("heartbeat_at")
|
||||
startedAt DateTime? @map("started_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, createdAt])
|
||||
@@index([medicationId, createdAt])
|
||||
@@index([status, heartbeatAt])
|
||||
@@map("medication_intake_import_jobs")
|
||||
}
|
||||
|
||||
// ─── Rate Limiting ──────────────────────────────────────────
|
||||
|
||||
model RateLimit {
|
||||
@@ -5186,7 +5458,8 @@ model InsightNarrative {
|
||||
userId String @map("user_id")
|
||||
/// "week" | "month".
|
||||
period String
|
||||
/// Resolved UI locale of the prose ("de" | "en").
|
||||
/// Resolved UI locale of the prose — any shipped UI locale, not just de/en:
|
||||
/// the prose is generated per locale, so the row is keyed by it.
|
||||
locale String
|
||||
/// UTC YYYY-MM-DD boundary the narrative was generated for.
|
||||
dateKey String @map("date_key")
|
||||
@@ -5207,6 +5480,62 @@ model InsightNarrative {
|
||||
@@map("insight_narratives")
|
||||
}
|
||||
|
||||
// ─── Data-arrival spine (v1.31.0) ─────────────────────────────
|
||||
//
|
||||
// One row per (user, arrival kind, local day). The row is written by the
|
||||
// `data-arrival` worker after a SALIENT ingest — never by a backfill, which
|
||||
// the seam classifier drops before anything is enqueued.
|
||||
//
|
||||
// The unique index is load-bearing twice over: it is the "just in" marker the
|
||||
// reaction surfaces read and the durable identity whose newest same-day
|
||||
// revision owns generation. A strictly newer arrival atomically invalidates
|
||||
// stale generation state; equal or older deliveries are deduplicated.
|
||||
model ArrivalReaction {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
/// One of `ARRIVAL_KINDS` (`@/lib/arrivals/types`).
|
||||
kind String
|
||||
/// The user's PROFILE-timezone YYYY-MM-DD this reaction is filed under.
|
||||
localDate String @map("local_date")
|
||||
/// Timestamp of the newest sample that triggered it.
|
||||
occurredAt DateTime @map("occurred_at")
|
||||
/// When the newest sample actually reached HealthLog. Unlike `createdAt`,
|
||||
/// this advances when a later same-kind arrival refreshes the daily marker.
|
||||
arrivedAt DateTime @default(now()) @map("arrived_at")
|
||||
/// Kind-scoped referent: the workout id for `workout`, the panel day for labs.
|
||||
refId String? @map("ref_id")
|
||||
/// AES-256-GCM ciphertext of the generated reaction line. Null until — and
|
||||
/// unless — a line is generated: the marker is the feature, the sentence is
|
||||
/// garnish, and a provider-less install never fills this in. Follows the
|
||||
/// `InsightNarrative.encryptedContent` precedent, NOT the plaintext pattern
|
||||
/// the status cards use. Registered in `scripts/rotate-encryption-key.ts`.
|
||||
lineEncrypted Bytes? @map("line_encrypted")
|
||||
generatedAt DateTime? @map("generated_at")
|
||||
/// Opaque owner token and lease for pre-provider setup. A stale worker may
|
||||
/// reclaim only while `generationProviderInvokedAt` is null.
|
||||
generationClaimId String? @map("generation_claim_id")
|
||||
generationClaimedAt DateTime? @map("generation_claimed_at")
|
||||
/// The token reservation is written in the same transaction as the
|
||||
/// `coach_usage` increment. A reclaimed pre-provider attempt reuses it.
|
||||
generationReservedTokens Int? @map("generation_reserved_tokens")
|
||||
generationBudgetDateKey String? @map("generation_budget_date_key")
|
||||
/// Durable provider boundary. Once set, this marker is terminal unless the
|
||||
/// encrypted line is committed; no retry may invoke a provider again.
|
||||
generationProviderInvokedAt DateTime? @map("generation_provider_invoked_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// One mutable marker per kind and local day. Do not include `refId`: newer
|
||||
// same-day referents replace this row atomically.
|
||||
@@unique([userId, kind, localDate])
|
||||
// The reaction surfaces read "today's rows for this user".
|
||||
@@index([userId, localDate])
|
||||
// The nightly retention sweep deletes by age.
|
||||
@@index([createdAt])
|
||||
@@map("arrival_reactions")
|
||||
}
|
||||
|
||||
// ─── Cycle Tracking (v1.15.0) ─────────────────────────────────
|
||||
//
|
||||
// Self-hosted reproductive-health tracking: period + flow, symptom
|
||||
@@ -6039,6 +6368,31 @@ enum InboundDocumentStatus {
|
||||
@@map("inbound_document_status")
|
||||
}
|
||||
|
||||
/// Outcome of a document's plain-language summary. Recorded so the detail view
|
||||
/// states what happened rather than claiming generation is in progress for a
|
||||
/// job that was never enqueued.
|
||||
///
|
||||
/// WITHHELD deserves a note: the outbound safety screen tripped, so the model's
|
||||
/// prose is discarded and NEVER shown. Only the fact of the refusal is kept, and
|
||||
/// it is deliberately not terminal — the user can ask again, which is the whole
|
||||
/// reason this is a state and not a stored refusal string.
|
||||
enum DocumentSummaryState {
|
||||
/// Never attempted — the opt-in was off at upload, or the document predates
|
||||
/// the feature. The default, so every existing row is correct.
|
||||
NONE
|
||||
/// Enqueued or running.
|
||||
PENDING
|
||||
/// A summary was produced and is stored in `summaryEncrypted`.
|
||||
READY
|
||||
/// Produced, then blocked by the outbound safety screen. Text discarded.
|
||||
WITHHELD
|
||||
/// Attempted and could not produce one — no vision provider, spent budget,
|
||||
/// withdrawn consent, an unreadable file, or a provider error.
|
||||
UNAVAILABLE
|
||||
|
||||
@@map("document_summary_state")
|
||||
}
|
||||
|
||||
/// The FHIR resource an extracted fact maps to (STATED status only): a
|
||||
/// diagnosis → Condition, a measured value → Observation, a medication the
|
||||
/// patient is/was taking → MedicationStatement.
|
||||
@@ -6061,9 +6415,9 @@ enum ExtractedFactStatus {
|
||||
}
|
||||
|
||||
model InboundDocument {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
kind InboundDocumentKind @default(OTHER)
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
kind InboundDocumentKind @default(OTHER)
|
||||
/// User-given label. Deliberately plaintext (like `filename`) so the library
|
||||
/// list can ILIKE-search + ORDER BY in SQL. The document BODY stays
|
||||
/// encrypted; only this short, user-authored title is clear-text. Tradeoff:
|
||||
@@ -6071,18 +6425,18 @@ model InboundDocument {
|
||||
/// cleartext at rest / in backups — the same exposure the pre-existing
|
||||
/// plaintext `filename` already carries. Accepted to keep server-side search
|
||||
/// and sort.
|
||||
title String?
|
||||
title String?
|
||||
/// Original filename as uploaded (sanitised, optional).
|
||||
filename String?
|
||||
filename String?
|
||||
/// Magic-byte-sniffed MIME type the document was stored as.
|
||||
mimeType String @map("mime_type")
|
||||
mimeType String @map("mime_type")
|
||||
/// Stored byte length of the original (pre-encryption) document.
|
||||
byteSize Int @map("byte_size")
|
||||
byteSize Int @map("byte_size")
|
||||
/// AES-256-GCM ciphertext of the raw document bytes (base64 of the binary
|
||||
/// → `encrypt()` string → UTF-8 BYTEA), the `*_encrypted` Bytes convention
|
||||
/// shared with the note columns. The original is sensitive and never
|
||||
/// stored in the clear or logged.
|
||||
contentEncrypted Bytes @map("content_encrypted")
|
||||
contentEncrypted Bytes @map("content_encrypted")
|
||||
/// Lowercase-hex SHA-256 of the PLAINTEXT document bytes, computed at
|
||||
/// upload for same-user duplicate detection. Nullable: rows stored before
|
||||
/// the vault release are not backfilled (only new uploads dedupe). A
|
||||
@@ -6091,40 +6445,58 @@ model InboundDocument {
|
||||
/// CREATE UNIQUE INDEX inbound_documents_user_sha_live
|
||||
/// ON inbound_documents (user_id, content_sha256)
|
||||
/// WHERE deleted_at IS NULL AND content_sha256 IS NOT NULL;
|
||||
contentSha256 String? @map("content_sha256") @db.VarChar(64)
|
||||
contentSha256 String? @map("content_sha256") @db.VarChar(64)
|
||||
/// Storage codec of `contentEncrypted`. "base64v1" = legacy string path
|
||||
/// (base64-of-binary → `encrypt()` string → UTF-8 bytes); "binary2" = the
|
||||
/// binary AES-256-GCM layout written by `encryptBytes()` (no base64
|
||||
/// detour). Explicit column, never header-sniffed; the default keeps every
|
||||
/// pre-existing row correct.
|
||||
contentCodec String @default("base64v1") @map("content_codec")
|
||||
status InboundDocumentStatus @default(STORED)
|
||||
contentCodec String @default("base64v1") @map("content_codec")
|
||||
status InboundDocumentStatus @default(STORED)
|
||||
/// Provider type that ran the extraction (e.g. "anthropic", "openai",
|
||||
/// "local"). Diagnostic only. Null on a store-only document.
|
||||
providerType String? @map("provider_type")
|
||||
providerType String? @map("provider_type")
|
||||
/// The document's stated report/collection date (YYYY-MM-DD at UTC), as
|
||||
/// transcribed by the model — never inferred, never user-set.
|
||||
reportDate DateTime? @map("report_date")
|
||||
reportDate DateTime? @map("report_date")
|
||||
/// Effective filing date the library sorts, groups, and range-filters on
|
||||
/// (editable). Distinct from `reportDate`, which is the model-transcribed
|
||||
/// value. On store it defaults to the upload day (now) when the user does
|
||||
/// not supply one, so a stored row's filing date is never NULL and display
|
||||
/// == sort == filter. It stays user-editable and MAY be cleared back to
|
||||
/// NULL via PATCH (the list then sorts such rows last).
|
||||
documentDate DateTime? @map("document_date")
|
||||
documentDate DateTime? @map("document_date")
|
||||
/// Failure reason when status = FAILED (a short code, never the document).
|
||||
errorReason String? @map("error_reason")
|
||||
errorReason String? @map("error_reason")
|
||||
/// AES-256-GCM ciphertext of a short (3-4 sentence) plain-language summary
|
||||
/// of WHAT the document is, generated ONCE in the background right after
|
||||
/// upload when the user's `documentsAutoAiRead` opt-in is ON (that flag is
|
||||
/// also the standing AI-egress consent). Absent when auto-read is OFF, no
|
||||
/// provider is configured, or generation has not run yet. Descriptive only —
|
||||
/// never a diagnosis. `encrypt()`-string-as-UTF-8 BYTEA, the shared Bytes
|
||||
/// codec every other note column uses; PHI, never logged.
|
||||
summaryEncrypted Bytes? @map("summary_encrypted")
|
||||
/// When the background summary was generated (null until it runs). Surfaced
|
||||
/// as meta beside the summary in the document detail view.
|
||||
summaryGeneratedAt DateTime? @map("summary_generated_at")
|
||||
/// What actually happened to this document's summary, so the detail view can
|
||||
/// tell the four outcomes apart instead of reading a null column as "still
|
||||
/// generating". `summaryEncrypted` alone cannot: a document nobody ever
|
||||
/// enqueued, one whose provider is missing, one the safety screen withheld,
|
||||
/// and one mid-flight all leave it null. NONE is the default, which keeps
|
||||
/// every pre-existing row correct — none of them were ever attempted.
|
||||
summaryState DocumentSummaryState @default(NONE) @map("summary_state")
|
||||
|
||||
facts ExtractedFact[]
|
||||
conditionLinks DocumentConditionLink[]
|
||||
facts ExtractedFact[]
|
||||
conditionLinks DocumentConditionLink[]
|
||||
/// 1:1 blind content-search index (encrypted extracted text + HMAC token
|
||||
/// array). Absent until the document is indexed; cascades on delete.
|
||||
contentIndex DocumentContentIndex?
|
||||
contentIndex DocumentContentIndex?
|
||||
/// 1:1 encrypted preview thumbnail (small JPEG, AES-256-GCM at rest).
|
||||
/// Absent until the background thumbnail job runs; a side table (not a
|
||||
/// column) so the list/detail `omit: { contentEncrypted }` queries never
|
||||
/// drag the blob into the SELECT. Cascades on delete.
|
||||
thumbnail DocumentThumbnail?
|
||||
thumbnail DocumentThumbnail?
|
||||
/// v1.29.x (S7) — coach-conversation attachments (m:n via
|
||||
/// `CoachConversationDocument`). Deleting this document cascades its join rows
|
||||
/// away (its text leaves every conversation's context) but never the
|
||||
@@ -6132,7 +6504,7 @@ model InboundDocument {
|
||||
chatAttachments CoachConversationDocument[]
|
||||
/// share-link memberships (m:n via `ClinicianShareLinkDocument`).
|
||||
/// Cascades: deleting this document removes it from every share it was on.
|
||||
shareLinks ClinicianShareLinkDocument[]
|
||||
shareLinks ClinicianShareLinkDocument[]
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
@@ -6240,14 +6612,14 @@ model DocumentConditionLink {
|
||||
/// versioned key (no schema column), and key rotation re-tokenises from the
|
||||
/// decrypted `textEncrypted`.
|
||||
model DocumentContentIndex {
|
||||
id String @id @default(cuid())
|
||||
documentId String @unique @map("document_id")
|
||||
userId String @map("user_id")
|
||||
id String @id @default(cuid())
|
||||
documentId String @unique @map("document_id")
|
||||
userId String @map("user_id")
|
||||
/// AES-256-GCM ciphertext of the normalised extracted text (capped, first
|
||||
/// ~64 KiB), stored as the `encrypt()`-string-as-UTF-8 Bytes shape shared
|
||||
/// with the Coach columns. The plaintext is recoverable server-side so
|
||||
/// rotation can re-tokenise; the list query never selects it.
|
||||
textEncrypted Bytes @map("text_encrypted")
|
||||
textEncrypted Bytes @map("text_encrypted")
|
||||
/// v1.27.33 (Document vault P4 — chat about a document) — AES-256-GCM
|
||||
/// ciphertext of the VERBATIM extracted text (byte-capped, NOT lowercased or
|
||||
/// de-accented), stored additionally alongside `textEncrypted` so a document
|
||||
@@ -6256,22 +6628,22 @@ model DocumentContentIndex {
|
||||
/// UTF-8 Bytes shape as every other PHI column. Nullable: rows indexed before
|
||||
/// P4 carry NULL until re-indexed, and the chat route falls back to the
|
||||
/// normalised `textEncrypted` when it is absent. The list query never selects it.
|
||||
verbatimTextEncrypted Bytes? @map("verbatim_text_encrypted")
|
||||
verbatimTextEncrypted Bytes? @map("verbatim_text_encrypted")
|
||||
/// Deduped HMAC-SHA256 (truncated hex) of the normalised tokens — the blind
|
||||
/// index. One-way: opaque without the server-held index subkey. GIN-indexed
|
||||
/// (raw SQL in the migration; Prisma cannot express a GIN index).
|
||||
searchTokens String[] @default([]) @map("search_tokens")
|
||||
searchTokens String[] @default([]) @map("search_tokens")
|
||||
/// Provenance of the indexed text: "vision" (provider transcription of the
|
||||
/// stored original), "text-ocr" (browser-OCR text posted by the client),
|
||||
/// "local-pdf" (server-side text-layer extraction, no provider) or "local-ocr"
|
||||
/// (server-side OCR of a scanned document — deferred follow-up). Free String;
|
||||
/// adding a source value needs no migration.
|
||||
source String
|
||||
source String
|
||||
/// Provider type that produced the text (diagnostic only), or null on the
|
||||
/// text-ocr path.
|
||||
providerType String? @map("provider_type")
|
||||
providerType String? @map("provider_type")
|
||||
/// Tokeniser algorithm version — bump to force a re-index on a scheme change.
|
||||
tokenizerVersion String @map("tokenizer_version")
|
||||
tokenizerVersion String @map("tokenizer_version")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
@@ -6292,23 +6664,23 @@ model DocumentContentIndex {
|
||||
/// source, so the preview is metadata-free by construction. Owner-scoped
|
||||
/// serve route only; cascades on delete.
|
||||
model DocumentThumbnail {
|
||||
id String @id @default(cuid())
|
||||
documentId String @unique @map("document_id")
|
||||
userId String @map("user_id")
|
||||
id String @id @default(cuid())
|
||||
documentId String @unique @map("document_id")
|
||||
userId String @map("user_id")
|
||||
/// AES-256-GCM ciphertext of the JPEG preview, stored as the
|
||||
/// `encrypt()`-string-as-UTF-8 Bytes shape (base64 of the JPEG), the same
|
||||
/// codec `DocumentContentIndex.textEncrypted` uses so the standard
|
||||
/// key-rotation walk covers it. A scanned medical preview is PHI — never
|
||||
/// stored in the clear or logged. The list query never selects it.
|
||||
thumbnailEncrypted Bytes @map("thumbnail_encrypted")
|
||||
thumbnailEncrypted Bytes @map("thumbnail_encrypted")
|
||||
/// Decoded thumbnail dimensions (px) — diagnostic + lets a future consumer
|
||||
/// size the tile without decoding the blob.
|
||||
width Int
|
||||
height Int
|
||||
/// Plaintext byte length of the JPEG (pre-encryption), for `Content-Length`.
|
||||
byteSize Int @map("byte_size")
|
||||
byteSize Int @map("byte_size")
|
||||
/// Source MIME the preview was rendered from (diagnostic only).
|
||||
sourceMime String @map("source_mime")
|
||||
sourceMime String @map("source_mime")
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
+67
-15
@@ -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.29.3";
|
||||
/* @sw-version-fallback */ "v1.32.26";
|
||||
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/*`
|
||||
@@ -151,6 +151,26 @@ self.addEventListener("fetch", (event) => {
|
||||
// Only handle same-origin GET requests
|
||||
if (request.method !== "GET" || url.origin !== self.location.origin) return;
|
||||
|
||||
// One-shot auth navigations must reach the server EXACTLY ONCE. The OIDC
|
||||
// login + callback endpoints are top-level browser navigations (not
|
||||
// `fetch`/XHR), so with navigation preload enabled (see `activate`) the
|
||||
// browser has ALREADY dispatched the request by the time this handler runs.
|
||||
// Returning early WITHOUT `event.respondWith()` — the general `/api/` path
|
||||
// below does exactly that — makes the browser ALSO run its own default
|
||||
// navigation fetch, so the request is sent twice. The OIDC authorization
|
||||
// code is single-use: the first callback redeems it and sets the session,
|
||||
// the second fails at the IdP and redirects to
|
||||
// `/auth/login?error=oidc_failed` on an otherwise-successful sign-in.
|
||||
// Consuming the preload response here (or fetching once when there is none)
|
||||
// settles the event with a single network request. These responses are
|
||||
// never cached.
|
||||
if (url.pathname.startsWith("/api/auth/")) {
|
||||
event.respondWith(
|
||||
(async () => (await event.preloadResponse) || fetch(request))(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// API calls. Allowlisted safe GET reads use network-first so the installed
|
||||
// PWA always renders fresh server data online (and falls back to the last
|
||||
// cached copy only when the network fails); everything else (auth,
|
||||
@@ -202,9 +222,12 @@ async function cacheFirst(request, cacheName) {
|
||||
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(cacheName);
|
||||
cache.put(request, response.clone());
|
||||
trimCache(cacheName, MAX_STATIC_ENTRIES);
|
||||
await bestEffortCacheWrite(
|
||||
cacheName,
|
||||
request,
|
||||
response.clone(),
|
||||
MAX_STATIC_ENTRIES,
|
||||
);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
@@ -225,7 +248,6 @@ async function cacheFirst(request, cacheName) {
|
||||
* refusal discipline the idempotency cache uses.
|
||||
*/
|
||||
async function networkFirstApi(request) {
|
||||
const cache = await caches.open(DATA_CACHE);
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
@@ -236,17 +258,21 @@ async function networkFirstApi(request) {
|
||||
.text()
|
||||
.catch(() => undefined);
|
||||
if (isCacheableApiResponse(response, bodyText)) {
|
||||
const toStore = new Response(bodyText, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
});
|
||||
await cache.put(request, toStore);
|
||||
trimCache(DATA_CACHE, MAX_DATA_ENTRIES);
|
||||
await bestEffortCacheWrite(
|
||||
DATA_CACHE,
|
||||
request,
|
||||
new Response(bodyText, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
}),
|
||||
MAX_DATA_ENTRIES,
|
||||
);
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
// Network failed (offline) — serve the last cached copy if we have one.
|
||||
const cache = await caches.open(DATA_CACHE);
|
||||
const cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
@@ -303,9 +329,14 @@ async function networkFirst(event, cacheName) {
|
||||
(event.preloadResponse ? await event.preloadResponse : null) ||
|
||||
(await fetch(request));
|
||||
if (response.ok && isCacheableNavigation(request, response)) {
|
||||
const cache = await caches.open(cacheName);
|
||||
cache.put(request, response.clone());
|
||||
trimCache(cacheName, MAX_PAGE_ENTRIES);
|
||||
event.waitUntil(
|
||||
bestEffortCacheWrite(
|
||||
cacheName,
|
||||
request,
|
||||
response.clone(),
|
||||
MAX_PAGE_ENTRIES,
|
||||
),
|
||||
);
|
||||
}
|
||||
return response;
|
||||
} catch {
|
||||
@@ -336,6 +367,27 @@ async function networkFirst(event, cacheName) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache a successful network response without making CacheStorage
|
||||
* availability part of the fetch contract. Quota, eviction, or trim failures
|
||||
* are intentionally swallowed: caching may improve a later request, but it
|
||||
* must never replace this request's already-successful network response.
|
||||
*/
|
||||
async function bestEffortCacheWrite(
|
||||
cacheName,
|
||||
request,
|
||||
response,
|
||||
maxEntries,
|
||||
) {
|
||||
try {
|
||||
const cache = await caches.open(cacheName);
|
||||
await cache.put(request, response);
|
||||
await trimCache(cacheName, maxEntries);
|
||||
} catch {
|
||||
// CacheStorage is an optional optimization.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim cache to maxEntries by removing oldest entries. `keys()` returns
|
||||
* insertion order, so one read + a single pass over the excess prefix
|
||||
|
||||
+7
-1
@@ -4,11 +4,17 @@ One-shot operator and build scripts. These are not part of the running applicati
|
||||
|
||||
## How to run them
|
||||
|
||||
From a source checkout:
|
||||
|
||||
```bash
|
||||
pnpm dlx tsx scripts/<file>.ts
|
||||
```
|
||||
|
||||
Use `pnpm dlx tsx`, not bare `pnpm tsx`. The production standalone image strips `tsx`, so the bare invocation fails inside the container. `scripts/` is excluded from the project typecheck.
|
||||
The production image intentionally removes npm, pnpm, and Corepack. Only
|
||||
container-safe scripts named by an operator runbook use its pinned
|
||||
`healthlog-tsx` launcher; scripts that import the full application dependency
|
||||
graph must run from a source checkout. `scripts/` is excluded from the project
|
||||
typecheck.
|
||||
|
||||
## What lives here
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
* exit-code behaviour is best left to a smoke test in CI (deferred to
|
||||
* v1.4.43).
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { checkEnv, parseEnvFile } from "../check-env";
|
||||
|
||||
@@ -252,3 +254,39 @@ describe("checkEnv — optional groups (informational)", () => {
|
||||
expect(apnsKeyId.required).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("environment inventory", () => {
|
||||
it("lists every optional AI runtime variable in compose, both examples, and the manifest", () => {
|
||||
const repositoryRoot = join(__dirname, "../..");
|
||||
const variableNames = [
|
||||
"ALLOW_LOCAL_AI_PRIVATE_HOSTS",
|
||||
"INSIGHTS_MAX_TOKENS",
|
||||
];
|
||||
const compose = readFileSync(
|
||||
join(repositoryRoot, "docker-compose.yml"),
|
||||
"utf8",
|
||||
);
|
||||
const exampleEnvironments = [
|
||||
readFileSync(join(repositoryRoot, ".env.example"), "utf8"),
|
||||
readFileSync(join(repositoryRoot, ".env.production.example"), "utf8"),
|
||||
];
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(join(repositoryRoot, "scripts/env-manifest.json"), "utf8"),
|
||||
) as {
|
||||
groups: Array<{ variables: Array<{ name: string }> }>;
|
||||
};
|
||||
const manifestNames = manifest.groups.flatMap((group) =>
|
||||
group.variables.map((variable) => variable.name),
|
||||
);
|
||||
|
||||
for (const variableName of variableNames) {
|
||||
expect(compose).toMatch(new RegExp(`^\\s*${variableName}:`, "m"));
|
||||
for (const exampleEnvironment of exampleEnvironments) {
|
||||
expect(exampleEnvironment).toMatch(
|
||||
new RegExp(`^# ${variableName}=`, "m"),
|
||||
);
|
||||
}
|
||||
expect(manifestNames).toContain(variableName);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
import integrationConfig from "../../vitest.integration.config.mts";
|
||||
import unitConfig from "../../vitest.config.mts";
|
||||
|
||||
function repositoryRoot(): string {
|
||||
const testPath = expect.getState().testPath;
|
||||
if (!testPath) throw new Error("Vitest did not expose the current test path");
|
||||
return join(dirname(testPath), "../..");
|
||||
}
|
||||
|
||||
function readRepoFile(path: string): string {
|
||||
return readFileSync(join(repositoryRoot(), path), "utf8");
|
||||
}
|
||||
|
||||
type WorkflowStep = {
|
||||
if?: string;
|
||||
uses?: string;
|
||||
with?: Record<string, string | number>;
|
||||
};
|
||||
|
||||
type WorkflowJob = {
|
||||
env?: Record<string, string>;
|
||||
steps?: WorkflowStep[];
|
||||
};
|
||||
|
||||
type Workflow = {
|
||||
jobs: Record<string, WorkflowJob>;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("Playwright CI determinism", () => {
|
||||
it("fails CI when a retry resolves a flaky test", async () => {
|
||||
vi.stubEnv("CI", "true");
|
||||
// Re-import after stubbing CI so config conditionals are evaluated as CI.
|
||||
const { default: config } = await import("../../playwright.config");
|
||||
|
||||
expect(config.retries).toBe(2);
|
||||
expect(config.failOnFlakyTests).toBe(true);
|
||||
expect(config.reporter).toEqual([["github"], ["html", { open: "never" }]]);
|
||||
expect(config.webServer).toMatchObject({
|
||||
command: expect.stringContaining(
|
||||
`${JSON.stringify(process.execPath)} .next/standalone/server.js`,
|
||||
),
|
||||
env: expect.objectContaining({
|
||||
NATIVE_CANVAS: "off",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("retains the Playwright report after every workflow outcome", () => {
|
||||
const workflow = parse(
|
||||
readRepoFile(".github/workflows/e2e.yml"),
|
||||
) as Workflow;
|
||||
const upload = workflow.jobs.e2e?.steps?.find((step) =>
|
||||
step.uses?.startsWith("actions/upload-artifact@"),
|
||||
);
|
||||
|
||||
expect(upload).toBeDefined();
|
||||
expect(upload?.if).toBe("always()");
|
||||
expect(upload?.with).toMatchObject({
|
||||
name: "playwright-report",
|
||||
path: "playwright-report/",
|
||||
"retention-days": 7,
|
||||
"if-no-files-found": "ignore",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses production-valid secrets and plain-HTTP cookies", () => {
|
||||
const workflow = parse(
|
||||
readRepoFile(".github/workflows/e2e.yml"),
|
||||
) as Workflow;
|
||||
const env = workflow.jobs.e2e?.env;
|
||||
|
||||
expect(env?.API_TOKEN_HMAC_KEY).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(env?.SESSION_COOKIE_SECURE).toBe("false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("local worktree isolation", () => {
|
||||
it("keeps nested worktree suites out of the root unit run", () => {
|
||||
expect(unitConfig.test?.exclude ?? []).toContain(".worktrees/**");
|
||||
});
|
||||
});
|
||||
|
||||
describe("integration environment isolation", () => {
|
||||
it("registers a worker setup bridge and authoritative test secrets", () => {
|
||||
const test = integrationConfig.test;
|
||||
|
||||
expect(test?.setupFiles ?? []).toContain(
|
||||
"./tests/integration/environment-setup.ts",
|
||||
);
|
||||
expect(test?.env).toMatchObject({
|
||||
TZ: "UTC",
|
||||
ENCRYPTION_KEY:
|
||||
"0000000000000000000000000000000000000000000000000000000000000000",
|
||||
ENCRYPTION_KEYS: "",
|
||||
ENCRYPTION_ACTIVE_KEY_ID: "",
|
||||
API_TOKEN_HMAC_KEY:
|
||||
"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
SESSION_SECRET: "integration-test-session-secret-32-bytes",
|
||||
});
|
||||
expect(
|
||||
existsSync(
|
||||
join(repositoryRoot(), "tests/integration/environment-setup.ts"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("container dependency installation", () => {
|
||||
it("copies the prepare helper before the Git-free frozen install", () => {
|
||||
const dockerfile = readRepoFile("Dockerfile");
|
||||
|
||||
expect(dockerfile).toContain(
|
||||
"COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./\n" +
|
||||
"COPY scripts/prepare.mjs scripts/prepare.mjs\n" +
|
||||
"RUN pnpm install --frozen-lockfile --prod=false",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("production dependency advisory floors", () => {
|
||||
it("pins vulnerable transitive ranges to compatible patched versions", () => {
|
||||
const workspace = parse(readRepoFile("pnpm-workspace.yaml")) as {
|
||||
overrides?: Record<string, string>;
|
||||
};
|
||||
|
||||
expect(workspace.overrides).toMatchObject({
|
||||
"dompurify@<=3.4.10": "^3.4.11",
|
||||
"postcss@<8.5.10": "^8.5.10",
|
||||
"@hono/node-server@<1.19.13": "^1.19.13",
|
||||
"hono@<4.12.25": "^4.12.25",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
|
||||
import {
|
||||
chmodSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const path of temporaryDirectories.splice(0)) {
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
type GitMarker = "absent" | "directory" | "file";
|
||||
|
||||
type PrepareResult = {
|
||||
invocationLog: string | null;
|
||||
result: SpawnSyncReturns<string>;
|
||||
};
|
||||
|
||||
function runPrepareFixture(
|
||||
gitMarker: GitMarker,
|
||||
lefthookStatus = 0,
|
||||
): PrepareResult {
|
||||
const testPath = expect.getState().testPath;
|
||||
if (!testPath) throw new Error("Vitest did not expose the current test path");
|
||||
const root = join(dirname(testPath), "../..");
|
||||
const directory = mkdtempSync(join(tmpdir(), "healthlog-prepare-"));
|
||||
temporaryDirectories.push(directory);
|
||||
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(join(root, "package.json"), "utf8"),
|
||||
) as {
|
||||
scripts: { prepare: string };
|
||||
};
|
||||
const prepareHelper = join(root, "scripts/prepare.mjs");
|
||||
if (existsSync(prepareHelper)) {
|
||||
mkdirSync(join(directory, "scripts"));
|
||||
cpSync(prepareHelper, join(directory, "scripts/prepare.mjs"));
|
||||
}
|
||||
|
||||
if (gitMarker === "directory") {
|
||||
mkdirSync(join(directory, ".git"));
|
||||
} else if (gitMarker === "file") {
|
||||
writeFileSync(
|
||||
join(directory, ".git"),
|
||||
"gitdir: ../shared/.git/worktrees/fixture\n",
|
||||
);
|
||||
}
|
||||
|
||||
const binDirectory = join(directory, "bin");
|
||||
const invocationLog = join(directory, "lefthook.log");
|
||||
mkdirSync(binDirectory);
|
||||
const lefthook = join(
|
||||
binDirectory,
|
||||
process.platform === "win32" ? "lefthook.cmd" : "lefthook",
|
||||
);
|
||||
writeFileSync(
|
||||
lefthook,
|
||||
process.platform === "win32"
|
||||
? '@echo off\r\necho %*>>"%FIXTURE_LEFTHOOK_LOG%"\r\nexit /b %FIXTURE_LEFTHOOK_STATUS%\r\n'
|
||||
: '#!/bin/sh\nprintf \'%s\\n\' "$*" >> "$FIXTURE_LEFTHOOK_LOG"\nexit "$FIXTURE_LEFTHOOK_STATUS"\n',
|
||||
);
|
||||
if (process.platform !== "win32") chmodSync(lefthook, 0o755);
|
||||
|
||||
const result = spawnSync(packageJson.scripts.prepare, {
|
||||
cwd: directory,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDirectory}:${process.env.PATH ?? ""}`,
|
||||
FIXTURE_LEFTHOOK_LOG: invocationLog,
|
||||
FIXTURE_LEFTHOOK_STATUS: String(lefthookStatus),
|
||||
},
|
||||
shell: true,
|
||||
});
|
||||
|
||||
return {
|
||||
result,
|
||||
invocationLog: existsSync(invocationLog)
|
||||
? readFileSync(invocationLog, "utf8").replaceAll("\r\n", "\n")
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("package prepare hook installation", () => {
|
||||
it("enables command-shim execution on Windows only", () => {
|
||||
const testPath = expect.getState().testPath;
|
||||
if (!testPath)
|
||||
throw new Error("Vitest did not expose the current test path");
|
||||
const source = readFileSync(
|
||||
join(dirname(testPath), "../prepare.mjs"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source).toContain('shell: process.platform === "win32"');
|
||||
});
|
||||
|
||||
it("skips hook installation only when Git metadata is absent", () => {
|
||||
const { invocationLog, result } = runPrepareFixture("absent");
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(invocationLog).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["directory", "file"] as const)(
|
||||
"installs hooks when .git is a %s",
|
||||
(gitMarker) => {
|
||||
const { invocationLog, result } = runPrepareFixture(gitMarker);
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(invocationLog).toBe("install --force\n");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["directory", "file"] as const)(
|
||||
"propagates Lefthook failure when .git is a %s",
|
||||
(gitMarker) => {
|
||||
const { invocationLog, result } = runPrepareFixture(gitMarker, 42);
|
||||
|
||||
expect(invocationLog).toBe("install --force\n");
|
||||
expect(result.status).toBe(42);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,473 @@
|
||||
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
function readRepoFile(path: string): string {
|
||||
const testPath = expect.getState().testPath;
|
||||
if (!testPath) throw new Error("Vitest did not expose the current test path");
|
||||
return readFileSync(join(dirname(testPath), "../..", path), "utf8");
|
||||
}
|
||||
type WorkflowStep = {
|
||||
name?: string;
|
||||
run?: string;
|
||||
"continue-on-error"?: boolean;
|
||||
};
|
||||
type WorkflowJob = {
|
||||
needs?: string | string[];
|
||||
outputs?: Record<string, string>;
|
||||
steps?: WorkflowStep[];
|
||||
uses?: string;
|
||||
with?: Record<string, string>;
|
||||
};
|
||||
type Workflow = {
|
||||
on?: {
|
||||
workflow_call?: { inputs?: Record<string, { required?: boolean }> };
|
||||
};
|
||||
jobs: Record<string, WorkflowJob>;
|
||||
};
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const path of temporaryDirectories.splice(0)) {
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function loadWorkflow(name: string): Workflow {
|
||||
return parse(readRepoFile(join(".github/workflows", name))) as Workflow;
|
||||
}
|
||||
|
||||
function stepScript(
|
||||
workflow: Workflow,
|
||||
jobName: string,
|
||||
stepName: string,
|
||||
): string {
|
||||
const step = workflow.jobs[jobName]?.steps?.find(
|
||||
(candidate) => candidate.name === stepName,
|
||||
);
|
||||
if (!step?.run) {
|
||||
throw new Error(`Missing ${jobName}/${stepName} run step`);
|
||||
}
|
||||
return step.run;
|
||||
}
|
||||
|
||||
function runReleasePolicy(
|
||||
overrides: Record<string, string> = {},
|
||||
): SpawnSyncReturns<string> {
|
||||
const directory = mkdtempSync(join(tmpdir(), "healthlog-release-policy-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const binDirectory = join(directory, "bin");
|
||||
mkdirSync(binDirectory);
|
||||
writeFileSync(
|
||||
join(directory, "package.json"),
|
||||
JSON.stringify({ version: overrides.FIXTURE_PACKAGE_VERSION ?? "1.31.4" }),
|
||||
);
|
||||
|
||||
const gitPath = join(binDirectory, "git");
|
||||
writeFileSync(
|
||||
gitPath,
|
||||
`#!/bin/sh
|
||||
if [ "$1" = "fetch" ]; then exit 0; fi
|
||||
if [ "$1" = "merge-base" ] && [ "$FIXTURE_MAIN_ANCESTRY" = "yes" ]; then exit 0; fi
|
||||
exit 1
|
||||
`,
|
||||
);
|
||||
chmodSync(gitPath, 0o755);
|
||||
|
||||
const ghPath = join(binDirectory, "gh");
|
||||
writeFileSync(
|
||||
ghPath,
|
||||
`#!/bin/sh
|
||||
if [ "$FIXTURE_PRIOR_CI" = "yes" ]; then printf '1\\n'; else printf '0\\n'; fi
|
||||
`,
|
||||
);
|
||||
chmodSync(ghPath, 0o755);
|
||||
|
||||
const workflow = loadWorkflow("docker-publish.yml");
|
||||
return spawnSync(
|
||||
"bash",
|
||||
[
|
||||
"-euo",
|
||||
"pipefail",
|
||||
"-c",
|
||||
stepScript(workflow, "release-policy", "Validate release policy"),
|
||||
],
|
||||
{
|
||||
cwd: directory,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDirectory}:${process.env.PATH ?? ""}`,
|
||||
GITHUB_EVENT_NAME: "push",
|
||||
GITHUB_REF_TYPE: "tag",
|
||||
GITHUB_REF_NAME: "v1.31.4",
|
||||
GITHUB_SHA: "0123456789abcdef0123456789abcdef01234567",
|
||||
GITHUB_REPOSITORY: "MBombeck/HealthLog",
|
||||
GH_TOKEN: "fixture-token",
|
||||
FIXTURE_MAIN_ANCESTRY: "yes",
|
||||
FIXTURE_PRIOR_CI: "yes",
|
||||
...overrides,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function runExactPull(resolvedDigest: string): {
|
||||
result: SpawnSyncReturns<string>;
|
||||
commands: string;
|
||||
} {
|
||||
const directory = mkdtempSync(join(tmpdir(), "healthlog-image-pull-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const dockerPath = join(directory, "docker");
|
||||
const commandLog = join(directory, "commands.log");
|
||||
writeFileSync(
|
||||
dockerPath,
|
||||
`#!/bin/sh
|
||||
printf '%s\\n' "$*" >> "$FIXTURE_COMMAND_LOG"
|
||||
if [ "$1" = "pull" ]; then exit 0; fi
|
||||
if [ "$1" = "buildx" ] && [ "$2" = "imagetools" ] && [ "$3" = "inspect" ]; then
|
||||
printf '\"%s\"\\n' "$FIXTURE_RESOLVED_DIGEST"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
`,
|
||||
);
|
||||
chmodSync(dockerPath, 0o755);
|
||||
|
||||
const workflow = loadWorkflow("post-publish-verify.yml");
|
||||
const result = spawnSync(
|
||||
"bash",
|
||||
[
|
||||
"-euo",
|
||||
"pipefail",
|
||||
"-c",
|
||||
stepScript(workflow, "verify", "Pull exact image"),
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${directory}:${process.env.PATH ?? ""}`,
|
||||
IMAGE_REF: "ghcr.io/mbombeck/healthlog",
|
||||
IMAGE_TAG: "v1.31.4",
|
||||
IMAGE_DIGEST:
|
||||
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
FIXTURE_COMMAND_LOG: commandLog,
|
||||
FIXTURE_RESOLVED_DIGEST: resolvedDigest,
|
||||
},
|
||||
},
|
||||
);
|
||||
return {
|
||||
result,
|
||||
commands: readFileSync(commandLog, "utf8"),
|
||||
};
|
||||
}
|
||||
|
||||
function runPromotion(
|
||||
overrides: Record<string, string> = {},
|
||||
): SpawnSyncReturns<string> {
|
||||
const directory = mkdtempSync(join(tmpdir(), "healthlog-promotion-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const sleepPath = join(directory, "sleep");
|
||||
writeFileSync(sleepPath, "#!/bin/sh\nexit 0\n");
|
||||
chmodSync(sleepPath, 0o755);
|
||||
const curlPath = join(directory, "curl");
|
||||
writeFileSync(
|
||||
curlPath,
|
||||
`#!/bin/sh
|
||||
printf '{\"deployment_uuid\":\"fixture\"}\\n__HTTP_STATUS__:%s' "$FIXTURE_HTTP_STATUS"
|
||||
`,
|
||||
);
|
||||
chmodSync(curlPath, 0o755);
|
||||
|
||||
const workflow = loadWorkflow("docker-publish.yml");
|
||||
return spawnSync(
|
||||
"bash",
|
||||
[
|
||||
"-euo",
|
||||
"pipefail",
|
||||
"-c",
|
||||
stepScript(workflow, "promote", "Trigger Coolify deploy"),
|
||||
],
|
||||
{
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${directory}:${process.env.PATH ?? ""}`,
|
||||
COOLIFY_AUTO_DEPLOY: "on",
|
||||
COOLIFY_WEBHOOK: "https://coolify.example/deploy?uuid=fixture",
|
||||
COOLIFY_TOKEN: "fixture-token",
|
||||
IMAGE_TAG: "v1.31.4",
|
||||
IMAGE_DIGEST:
|
||||
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
BUILD_SHA: "0123456789ab",
|
||||
FIXTURE_HTTP_STATUS: "200",
|
||||
...overrides,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("release container inputs", () => {
|
||||
it("pins every Node stage to one reviewed multi-arch digest", () => {
|
||||
const dockerfile = readRepoFile("Dockerfile");
|
||||
const fromLines = dockerfile.match(/^FROM node:[^\n]+$/gm) ?? [];
|
||||
|
||||
expect(fromLines).toHaveLength(3);
|
||||
expect(new Set(fromLines.map((line) => line.split(" AS ")[0]))).toEqual(
|
||||
new Set([
|
||||
"FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("pins pnpm for builds and removes package managers from runtime", () => {
|
||||
const dockerfile = readRepoFile("Dockerfile");
|
||||
const packageJson = JSON.parse(readRepoFile("package.json")) as {
|
||||
packageManager: string;
|
||||
};
|
||||
expect(packageJson.packageManager).toBe("pnpm@11.15.1");
|
||||
expect(dockerfile).not.toContain("pnpm@latest");
|
||||
expect(
|
||||
dockerfile.match(/corepack prepare pnpm@11\.15\.1 --activate/g),
|
||||
).toHaveLength(2);
|
||||
expect(dockerfile).toContain(
|
||||
"rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /root/.cache/node/corepack",
|
||||
);
|
||||
expect(dockerfile).toContain("tsx@4.23.1");
|
||||
expect(dockerfile).toContain(
|
||||
"ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx",
|
||||
);
|
||||
expect(dockerfile).toContain("dotenv@17.4.2");
|
||||
expect(dockerfile).toContain(
|
||||
"ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv",
|
||||
);
|
||||
expect(dockerfile).toContain(
|
||||
"ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps linked worktrees out of the Docker build context", () => {
|
||||
const dockerignore = readRepoFile(".dockerignore");
|
||||
expect(dockerignore.split(/\r?\n/)).toContain(".worktrees");
|
||||
});
|
||||
|
||||
it("bundles Prisma's adapter and verifies only runtime externals", () => {
|
||||
const dockerfile = readRepoFile("Dockerfile");
|
||||
const nextConfig = readRepoFile("next.config.ts");
|
||||
expect(dockerfile).not.toContain("/opt/pg-boss");
|
||||
expect(dockerfile).not.toContain('NODE_PATH="/opt/pg-boss/node_modules"');
|
||||
expect(dockerfile).toMatch(
|
||||
/require\.resolve\(['"]pg-boss['"]\)[\s\S]*require\.resolve\(['"]pg['"]\)/,
|
||||
);
|
||||
expect(dockerfile).not.toContain("require.resolve('@prisma/adapter-pg')");
|
||||
expect(dockerfile).not.toContain("require.resolve('@prisma/client')");
|
||||
expect(nextConfig).not.toMatch(
|
||||
/serverExternalPackages:\s*\[[\s\S]*["']@prisma\/adapter-pg["']/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("release pre-build policy", () => {
|
||||
it("accepts a strict SemVer tag that matches package.json on main with prior CI", () => {
|
||||
const result = runReleasePolicy();
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain("Release policy passed");
|
||||
});
|
||||
|
||||
it.each(["v1.2", "v01.2.3", "v1.2.3-rc.1", "1.2.3"])(
|
||||
"rejects non-strict release tag %s",
|
||||
(tag) => {
|
||||
const result = runReleasePolicy({ GITHUB_REF_NAME: tag });
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain("strict SemVer");
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a tag that does not match package.json", () => {
|
||||
const result = runReleasePolicy({ FIXTURE_PACKAGE_VERSION: "1.31.3" });
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain("package.json version");
|
||||
});
|
||||
|
||||
it("rejects a release commit outside main ancestry", () => {
|
||||
const result = runReleasePolicy({ FIXTURE_MAIN_ANCESTRY: "no" });
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain("ancestor of origin/main");
|
||||
});
|
||||
|
||||
it("rejects a release commit without successful prior quality CI", () => {
|
||||
const result = runReleasePolicy({ FIXTURE_PRIOR_CI: "no" });
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain("successful prior Security & Quality");
|
||||
});
|
||||
|
||||
it("gates every image build on the release policy", () => {
|
||||
const workflow = loadWorkflow("docker-publish.yml");
|
||||
const needs = workflow.jobs.build?.needs;
|
||||
|
||||
expect(Array.isArray(needs) ? needs : [needs]).toContain("release-policy");
|
||||
});
|
||||
});
|
||||
|
||||
describe("exact post-publish verification", () => {
|
||||
it("passes the published ref, tag, and digest into the blocking verifier", () => {
|
||||
const workflow = loadWorkflow("docker-publish.yml");
|
||||
const merge = workflow.jobs.merge;
|
||||
const verify = workflow.jobs["verify-published-image"];
|
||||
|
||||
expect(merge?.outputs).toEqual({
|
||||
image_ref: "${{ steps.published.outputs.image_ref }}",
|
||||
image_tag: "${{ steps.published.outputs.image_tag }}",
|
||||
image_digest: "${{ steps.published.outputs.image_digest }}",
|
||||
build_sha: "${{ steps.published.outputs.build_sha }}",
|
||||
});
|
||||
expect(verify?.uses).toBe("./.github/workflows/post-publish-verify.yml");
|
||||
expect(verify?.needs).toBe("merge");
|
||||
expect(verify?.with).toEqual({
|
||||
image_ref: "${{ needs.merge.outputs.image_ref }}",
|
||||
image_tag: "${{ needs.merge.outputs.image_tag }}",
|
||||
image_digest: "${{ needs.merge.outputs.image_digest }}",
|
||||
build_sha: "${{ needs.merge.outputs.build_sha }}",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires exact image identity inputs for every invocation", () => {
|
||||
const workflow = loadWorkflow("post-publish-verify.yml");
|
||||
const inputs = workflow.on?.workflow_call?.inputs;
|
||||
|
||||
expect(inputs?.image_ref?.required).toBe(true);
|
||||
expect(inputs?.image_tag?.required).toBe(true);
|
||||
expect(inputs?.image_digest?.required).toBe(true);
|
||||
expect(inputs?.build_sha?.required).toBe(true);
|
||||
});
|
||||
|
||||
it("pulls the exact tag and digest when the registry resolves correctly", () => {
|
||||
const digest =
|
||||
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
const { result, commands } = runExactPull(digest);
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(commands).toContain("pull ghcr.io/mbombeck/healthlog:v1.31.4");
|
||||
expect(commands).toContain(
|
||||
"pull ghcr.io/mbombeck/healthlog@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when the published tag resolves to another digest", () => {
|
||||
const { result } = runExactPull(
|
||||
"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
);
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain("digest mismatch");
|
||||
});
|
||||
|
||||
it("keeps pull, migration, start, version, and health checks blocking", () => {
|
||||
const workflow = loadWorkflow("post-publish-verify.yml");
|
||||
const steps = workflow.jobs.verify?.steps ?? [];
|
||||
const requiredSteps = [
|
||||
"Pull exact image",
|
||||
"Run migrations",
|
||||
"Start exact image",
|
||||
"Verify exact version",
|
||||
"Verify health",
|
||||
];
|
||||
|
||||
for (const name of requiredSteps) {
|
||||
const step = steps.find((candidate) => candidate.name === name);
|
||||
expect(step, `missing ${name}`).toBeDefined();
|
||||
expect(step?.["continue-on-error"], name).not.toBe(true);
|
||||
}
|
||||
|
||||
expect(stepScript(workflow, "verify", "Run migrations")).toContain(
|
||||
"${IMAGE_REF}@${IMAGE_DIGEST}",
|
||||
);
|
||||
const startScript = stepScript(workflow, "verify", "Start exact image");
|
||||
expect(startScript).toContain("${IMAGE_REF}@${IMAGE_DIGEST}");
|
||||
expect(startScript).toMatch(/API_TOKEN_HMAC_KEY=[0-9a-f]{64}(?:\s|\\)/);
|
||||
const versionScript = stepScript(
|
||||
workflow,
|
||||
"verify",
|
||||
"Verify exact version",
|
||||
);
|
||||
expect(versionScript).toContain('EXPECTED_VERSION="${IMAGE_TAG#v}"');
|
||||
expect(versionScript).toContain('jq -e --arg expected "$EXPECTED_VERSION"');
|
||||
expect(stepScript(workflow, "verify", "Verify health")).toContain(
|
||||
"jq -e '.status == \"ok\"'",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Coolify promotion policy", () => {
|
||||
it("waits for exact-image verification and keeps enabled promotion blocking", () => {
|
||||
const workflow = loadWorkflow("docker-publish.yml");
|
||||
const promotion = workflow.jobs.promote;
|
||||
const needs = promotion?.needs;
|
||||
const trigger = promotion?.steps?.find(
|
||||
(step) => step.name === "Trigger Coolify deploy",
|
||||
);
|
||||
|
||||
expect(Array.isArray(needs) ? needs : [needs]).toEqual(
|
||||
expect.arrayContaining(["merge", "verify-published-image"]),
|
||||
);
|
||||
expect(trigger).toBeDefined();
|
||||
expect(trigger?.["continue-on-error"]).not.toBe(true);
|
||||
expect(
|
||||
workflow.jobs.merge?.steps?.some(
|
||||
(step) => step.name === "Trigger Coolify deploy",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts an enabled promotion only after a successful webhook response", () => {
|
||||
const result = runPromotion();
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain("Coolify deploy queued");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["webhook", { COOLIFY_WEBHOOK: "" }],
|
||||
["token", { COOLIFY_TOKEN: "" }],
|
||||
])("rejects enabled promotion with missing %s credentials", (_name, env) => {
|
||||
const result = runPromotion(env);
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain("COOLIFY_AUTO_DEPLOY=on");
|
||||
});
|
||||
|
||||
it("fails enabled promotion when Coolify rejects the webhook", () => {
|
||||
const result = runPromotion({ FIXTURE_HTTP_STATUS: "503" });
|
||||
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.stderr).toContain("returned HTTP 503");
|
||||
});
|
||||
|
||||
it("allows explicit disabled promotion without credentials", () => {
|
||||
const result = runPromotion({
|
||||
COOLIFY_AUTO_DEPLOY: "off",
|
||||
COOLIFY_WEBHOOK: "",
|
||||
COOLIFY_TOKEN: "",
|
||||
});
|
||||
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain("COOLIFY_AUTO_DEPLOY=off");
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
{
|
||||
"name": "API_TOKEN_HMAC_KEY",
|
||||
"purpose": "HMAC-SHA256 key for hashing external Bearer tokens. 64 hex chars."
|
||||
"purpose": "HMAC-SHA256 key for hashing external Bearer tokens AND web session cookies. 64 hex chars. Rotating it signs every browser session out on top of invalidating every issued token."
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -117,6 +117,21 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "AI Provider",
|
||||
"description": "Optional runtime policy and output tuning for user-configured AI providers. Each variable stands alone and has a safe default when unset.",
|
||||
"required": false,
|
||||
"variables": [
|
||||
{
|
||||
"name": "ALLOW_LOCAL_AI_PRIVATE_HOSTS",
|
||||
"purpose": "Local-AI SSRF opt-in. Unset rejects private/internal hosts; set an exact comma-separated host allowlist or \"true\" to allow any private host."
|
||||
},
|
||||
{
|
||||
"name": "INSIGHTS_MAX_TOKENS",
|
||||
"purpose": "Daily-briefing output-token ceiling. Default 2500, clamped to 500-8000."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "APNs (iOS push)",
|
||||
"description": "All four core APNS_* vars together. Missing any disables APNs silently — the v1.4.40 AP-2 gap that motivated this manifest. allOrNone trips the synthetic <all-or-none> required row whenever 1-3 of the 4 are set.",
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
if (!existsSync(".git")) {
|
||||
console.log("Skipping Lefthook install: Git metadata is absent");
|
||||
} else {
|
||||
const result = spawnSync("lefthook", ["install", "--force"], {
|
||||
stdio: "inherit",
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
if (result.error) {
|
||||
console.error(`Failed to run Lefthook: ${result.error.message}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
process.exitCode = result.status ?? 1;
|
||||
}
|
||||
}
|
||||
@@ -64,32 +64,25 @@
|
||||
*
|
||||
* SELF-CONTAINED by design (v1.16.0)
|
||||
* ----------------------------------
|
||||
* The production standalone image ships neither the project
|
||||
* `node_modules` tree nor the `@/` path alias, so the previous
|
||||
* `dotenv/config` + `@/lib/db` import chain died with "Cannot find
|
||||
* module" inside the container. This script therefore depends on
|
||||
* exactly ONE package — `pg` — and reads `DATABASE_URL` straight from
|
||||
* the environment (no dotenv). Inside the production image `pg` resolves
|
||||
* via `NODE_PATH=/opt/pg-boss/node_modules` (Dockerfile installs
|
||||
* `pg@8.x` there); in a repo checkout it resolves from the project
|
||||
* `node_modules`. The `pnpm dlx` invocation below supplies `tsx` (the
|
||||
* standalone image strips it) and pins `pg` for any environment where
|
||||
* neither resolution path exists.
|
||||
* The production standalone image does not expose the project package
|
||||
* manager. It provides a pinned `healthlog-tsx` maintenance launcher, while
|
||||
* `pg` resolves from the standalone runtime tree. In a repo checkout, run the
|
||||
* same script through `pnpm dlx --package pg --package tsx tsx ...`.
|
||||
*
|
||||
* Usage
|
||||
* -----
|
||||
* Usage inside the production container
|
||||
* -------------------------------------
|
||||
* # dry-run (DATABASE_URL must point at the target database)
|
||||
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts
|
||||
* healthlog-tsx scripts/repair-intake-anomalies.ts
|
||||
*
|
||||
* # scoped dry-run
|
||||
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --user <id>
|
||||
* healthlog-tsx scripts/repair-intake-anomalies.ts --user <id>
|
||||
*
|
||||
* # apply
|
||||
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
|
||||
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
|
||||
* healthlog-tsx scripts/repair-intake-anomalies.ts --fix
|
||||
* healthlog-tsx scripts/repair-intake-anomalies.ts --fix --tombstone-implausible
|
||||
*
|
||||
* # create the proposed schedule revisions + startsOn fixes (defect 5)
|
||||
* pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --backfill-eras
|
||||
* healthlog-tsx scripts/repair-intake-anomalies.ts --backfill-eras
|
||||
*/
|
||||
import { Client, types } from "pg";
|
||||
|
||||
@@ -425,7 +418,7 @@ async function main(): Promise<void> {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
console.error(
|
||||
`${TAG} DATABASE_URL must be set (no dotenv — export it or prefix ` +
|
||||
`the command: DATABASE_URL='postgresql://…' pnpm dlx …)`,
|
||||
`the command: DATABASE_URL='postgresql://…' healthlog-tsx …)`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -14,9 +14,8 @@
|
||||
* BACKUP_S3_SECRET_KEY=... \
|
||||
* BACKUP_S3_REGION=auto \
|
||||
* BACKUP_ENCRYPTION_KEY=<hex64> \
|
||||
* pnpm tsx scripts/restore-backup.ts 2026-05-08/user-clx123.json.enc /tmp/restored.json
|
||||
* pnpm dlx tsx scripts/restore-backup.ts 2026-05-08/user-clx123.json.enc /tmp/restored.json
|
||||
*/
|
||||
import "dotenv/config";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import {
|
||||
loadOffhostConfig,
|
||||
@@ -82,7 +81,7 @@ async function main() {
|
||||
?.slice("--user-id=".length);
|
||||
if (!key) {
|
||||
console.error(
|
||||
"Usage: pnpm tsx scripts/restore-backup.ts <s3-key> [output-file] [--user-id=<id>]",
|
||||
"Usage: pnpm dlx tsx scripts/restore-backup.ts <s3-key> [output-file] [--user-id=<id>]",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -392,6 +392,30 @@ async function main() {
|
||||
),
|
||||
);
|
||||
|
||||
// ───── v1.31.0 ArrivalReaction."lineEncrypted" (Bytes, nullable) ─────
|
||||
// The data-arrival spine's generated reaction line. Nullable by design — a
|
||||
// provider-less install writes markers with no line at all — and
|
||||
// `rotateBytesColumn` already skips a NULL / zero-length payload, so those
|
||||
// rows are scanned and cleanly left alone.
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"ArrivalReaction",
|
||||
"lineEncrypted",
|
||||
prisma.arrivalReaction,
|
||||
),
|
||||
);
|
||||
|
||||
// ───── v1.31.0 WorkoutInsight."paragraphEncrypted" (Bytes) ─────
|
||||
// The per-workout Activity Insight paragraph. Not nullable — a row exists
|
||||
// only where a paragraph was generated — so every scanned row rotates.
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"WorkoutInsight",
|
||||
"paragraphEncrypted",
|
||||
prisma.workoutInsight,
|
||||
),
|
||||
);
|
||||
|
||||
// ───── v1.18.1 clinical-spine notes (Bytes columns) ─────
|
||||
// "noteEncrypted" (LabResult / IllnessEpisode / IllnessDayLog) +
|
||||
// "contextEncrypted" (Biomarker). Mirror the CoachFact.factEncrypted block.
|
||||
@@ -540,6 +564,18 @@ async function main() {
|
||||
prisma.extractedFact,
|
||||
),
|
||||
);
|
||||
// The short background document summary. Small `encrypt()`-string-as-UTF-8
|
||||
// Bytes payload (never the multi-megabyte original blob — the select is
|
||||
// scoped to id + this column), so the generic Bytes walk covers it. NULL on
|
||||
// rows uploaded with auto-read OFF / no provider — `rotateBytesColumn` skips
|
||||
// those.
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"InboundDocument",
|
||||
"summaryEncrypted",
|
||||
prisma.inboundDocument,
|
||||
),
|
||||
);
|
||||
|
||||
// ───── v1.27.22 document content-search index (Bytes text + re-tokenise) ─────
|
||||
// The blind content index carries TWO coupled artefacts under the index key
|
||||
|
||||
Loaded 100 of 1487 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user