mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 20:32:02 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
744e8e37ec | ||
|
|
e2917cb012 | ||
|
|
9106489196 | ||
|
|
cc927f7921 | ||
|
|
ad471a5110 | ||
|
|
fc7386a364 | ||
|
|
879833b372 | ||
|
|
ed5d453431 | ||
|
|
eb63cd3df3 | ||
|
|
a87d6b94e4 | ||
|
|
1c91abcf2a | ||
|
|
ada9006d9c | ||
|
|
f0f2caab79 | ||
|
|
bfdf0a12a0 | ||
|
|
107a111545 | ||
|
|
b13721cc97 | ||
|
|
94415e468e | ||
|
|
6d1587531f | ||
|
|
170862fdf8 | ||
|
|
09af60303d | ||
|
|
7857aa3dd2 | ||
|
|
d880385635 | ||
|
|
8c316ee3b0 | ||
|
|
4feac6b7e4 | ||
|
|
18cde65af7 | ||
|
|
7fa2866154 | ||
|
|
ca8781867b | ||
|
|
2fcd2e75f5 | ||
|
|
0a8c6e307c | ||
|
|
eff7b42348 | ||
|
|
555138110c | ||
|
|
f65181bbf2 | ||
|
|
141c083ff6 | ||
|
|
3694efe6e8 | ||
|
|
03925ddece | ||
|
|
f1145c0e91 | ||
|
|
2c6a9f8165 | ||
|
|
4a0ad1fc64 | ||
|
|
5f8e191e2b | ||
|
|
e9906c8111 | ||
|
|
a9ed2ed661 | ||
|
|
2472579059 | ||
|
|
bf5052bb01 | ||
|
|
d81ab69dc8 | ||
|
|
2123c63af4 | ||
|
|
4364f3b509 | ||
|
|
746e002104 | ||
|
|
bd8a4e7990 | ||
|
|
58b793ee4a | ||
|
|
5827ef58cb | ||
|
|
78b76d2ac5 | ||
|
|
708d012d28 | ||
|
|
ee845946fa | ||
|
|
a9aa0b8531 | ||
|
|
b1b2e22eeb | ||
|
|
16b0c349f2 | ||
|
|
e33911e2d4 | ||
|
|
2717297dae | ||
|
|
3ea6bde8a3 | ||
|
|
d5f75d2e03 | ||
|
|
04615d1bc7 | ||
|
|
ecf4f06870 | ||
|
|
aeb72d1a08 | ||
|
|
ae82b3e484 | ||
|
|
be90c4b9ad | ||
|
|
9652047e8e | ||
|
|
28af138691 | ||
|
|
9e8b536ff6 | ||
|
|
60f347bf8d | ||
|
|
f4d5114dcd | ||
|
|
a8e7325a08 | ||
|
|
cfeb98f0a7 | ||
|
|
9b3cb5ec7e | ||
|
|
a4bb4947f9 | ||
|
|
b868a72a91 | ||
|
|
712c5707ee | ||
|
|
0ec4a16153 | ||
|
|
3f757f7fc2 | ||
|
|
2c91dc7b75 | ||
|
|
4038cfcffe | ||
|
|
a357332a32 | ||
|
|
068cb168d9 | ||
|
|
38867439f3 | ||
|
|
d1e6befa5c | ||
|
|
90586d6279 | ||
|
|
b99b07ad38 | ||
|
|
9ecd798119 | ||
|
|
cd1eaa1a4e | ||
|
|
c72715bb62 | ||
|
|
68517240e1 | ||
|
|
3f9fd9095f | ||
|
|
c1f30466e7 | ||
|
|
38b12607c2 | ||
|
|
308d9c9d6d | ||
|
|
5d2ae7732e | ||
|
|
ca4425b2e0 | ||
|
|
795898bb0a | ||
|
|
a3b34bc0cf | ||
|
|
ac51cfdc18 | ||
|
|
3ecfa3a898 | ||
|
|
6e61713fc6 | ||
|
|
4935a9b000 | ||
|
|
0e9fa7bf80 | ||
|
|
e5b873833a | ||
|
|
6c44d4a2c1 | ||
|
|
0fe2ac6d51 | ||
|
|
bdacd78b0a | ||
|
|
6a2762ec35 | ||
|
|
0b10932c53 | ||
|
|
146867b09f | ||
|
|
b10569dc44 | ||
|
|
bd0b284201 | ||
|
|
baa1ff250c | ||
|
|
78d3478ad2 | ||
|
|
d5580917cc | ||
|
|
fcf5114620 | ||
|
|
a5c894b58e | ||
|
|
6bc51249ba | ||
|
|
f29ac6d96e | ||
|
|
ad693cd862 | ||
|
|
7e7702d6c9 | ||
|
|
fef88787e9 | ||
|
|
b5e98533b8 | ||
|
|
0f7ca61e2f | ||
|
|
5de9ddb0cc | ||
|
|
6ce09cc76d | ||
|
|
cf649f72ce | ||
|
|
b99a015266 | ||
|
|
84e592b17e | ||
|
|
1fcc5ba96b | ||
|
|
93790aa043 | ||
|
|
5771b36899 | ||
|
|
d660279c97 | ||
|
|
ddb08b048a | ||
|
|
b3f61d0c75 | ||
|
|
eea6385bc5 | ||
|
|
b1cc1f86f5 | ||
|
|
16fdc75513 | ||
|
|
65bbedd83d | ||
|
|
65cfa2b83a | ||
|
|
8dced00188 | ||
|
|
c2808a9c70 | ||
|
|
0e3a8f9cd3 | ||
|
|
96f0b225fd | ||
|
|
35800becbb |
No files matched your search
@@ -31,7 +31,7 @@ jobs:
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Gitleaks secret scan
|
||||
uses: gitleaks/gitleaks-action@v2
|
||||
uses: gitleaks/gitleaks-action@v3
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ regexes = [
|
||||
'''idempotency-key''',
|
||||
# Allow the placeholder prefix used in OpenAPI examples + docs.
|
||||
'''hlk_example''',
|
||||
# i18n caption keys for the briefing-driven trend charts are dotted message
|
||||
# keys, not secrets — one trips the generic-api-key entropy heuristic.
|
||||
'''insights\.trendsRow\.caption''',
|
||||
]
|
||||
|
||||
# Specific commit fingerprints from prior false-positive findings (kept for reproducibility).
|
||||
|
||||
+108
-254
@@ -1,270 +1,124 @@
|
||||
# HealthLog v1.4.24 — roadmap
|
||||
# HealthLog — roadmap
|
||||
|
||||
Milestone: **v1.4.24 — pre-iOS final polish** (not yet kicked off)
|
||||
Latest tag at start: v1.4.23 (live in prod)
|
||||
Current line: **v1.9.0** (shipping — Insights time-ranges, deeper mood, medication
|
||||
drug-coding into FHIR, advanced-settings rebuild, connection-pool stability).
|
||||
Trunk: `main`, always releasable. Release model in CLAUDE.md.
|
||||
|
||||
Carry-over candidates already captured in
|
||||
`.planning/v1423-backlog.md`:
|
||||
|
||||
- Pearson incomplete-beta replacement — v1.4.23 W5 H6 raised
|
||||
`MIN_PAIRED_N` from 14 → 20 as a conservative surfacing-gate
|
||||
patch. Land the rigorous incomplete-beta or normal-approx
|
||||
replacement before v1.5/v1.6 auto-correlation discovery ships.
|
||||
- OpenAPI drift gate flip from warn-only to hard-fail — generator
|
||||
covers ~880 lines vs the legacy hand-maintained spec's 5 468;
|
||||
complete registry coverage in parallel so the lockstep happens
|
||||
before the iOS DTO codegen is downloading the spec
|
||||
automatically.
|
||||
- Settings-cog vs per-message-controls UX consolidation — Design
|
||||
pushback during W6 raised concern that the dual surface
|
||||
duplicates intent. Wait on first-week thumbs data, decide
|
||||
whether per-user prompt prefs drift from per-message ratings.
|
||||
- `coach-prefs.test.ts` integration `NextRequest` URL mock
|
||||
regression — pre-existing failure that predates v1.4.23.
|
||||
Investigate the harness's `cookies()` mock interaction with
|
||||
`NextRequest`'s URL parsing.
|
||||
- Sec-MED-1 + LOW cluster — intra-batch dedup accounting
|
||||
(Sec-LOW-1), idempotency 422 retry hint in the OpenAPI 422
|
||||
description (Sec-LOW-2), APNs key-file path redaction in
|
||||
wide-event meta (Sec-LOW-3), refresh-token failure audit
|
||||
`userId` extension (Sec-LOW-4).
|
||||
- S-05 simplify deferral (Session B) — touched too many call
|
||||
sites for a single reconcile commit.
|
||||
|
||||
Reserved next strategic milestones:
|
||||
|
||||
- **v1.5** — iOS app + Apple Health integration + per-metric APNs
|
||||
alerts. Refreshed strategic plan with concrete file paths at
|
||||
`.planning/phase-W6-v1423-product-lead-review.md`. P1 is now a
|
||||
~5-day iOS Swift sprint because every server-side contract
|
||||
landed in v1.4.23. Per-user timezone foundation
|
||||
(`.planning/feature-user-timezone.md`, issue #167) is now scoped
|
||||
into v1.4.25 — Marc directive 2026-05-14, packs the iOS-launch
|
||||
prep into the v1.4.25 polish window instead of slipping it.
|
||||
- **v1.6+** — Auto-correlation discovery (FDR-controlled), Coach
|
||||
full-page route at `/insights/coach`, conversation-driven goal
|
||||
setting.
|
||||
Next milestone: **v1.10.0 — derived / synthesized wellness metrics + provenance + docs**.
|
||||
|
||||
---
|
||||
|
||||
## Previous milestone — v1.4.23 (completed 2026-05-11)
|
||||
## v1.10.0 — derived metrics, provenance, docs, discoverability
|
||||
|
||||
| Wave | Goal | State |
|
||||
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
|
||||
| 1 | Research — Apple Health type mapping, APNs library decision (`@parse/node-apn`), OpenAPI tooling choice (`zod-openapi` + `yaml@^2`) | done |
|
||||
| 2 | Apple Health foundation — F1 `MeasurementType` extension + sleep-stage enum + composite dedup index, F2 `APPLE_HEALTH` source + UI badge, F3 `POST /api/measurements/batch` + sleep-stage analytics aggregation | done |
|
||||
| 3 | APNs scaffolding — F4 `@parse/node-apn` sender + dispatcher cascade rewire, `Device` columns + paired CHECK constraint, `POST /api/devices` apnsToken acceptance + cross-user-hijack guard | done |
|
||||
| 4 | OpenAPI generator + Coach metric slot + native-auth hardening + Coolify runbook — F5 `zod-openapi` registry + warn-only CI gate, F6 nine HealthKit categories in strict schema + PROMPT_VERSION 4.23.0, F7 per-device refresh-token replay-detection + device-management endpoints, F8 Coolify runbook + `::notice::` line | done |
|
||||
| 5 | Hygiene (H1-H7) — sentinel parser malformed-enum annotation, analytics chunked-paged aggregate, drawer controlled-prop refactor, per-user prompt prefs surface, `medication_schedules.days_of_week` deploy, Pearson n≥20 gate, Coach feedback observation view | done |
|
||||
| 6 | Multi-agent QA + reconcile — six reviews (code, security, design, senior-dev, simplify, product-lead); 1 CRIT + 9 HIGH + 4 simplify + 3 MED applied; LOWs triaged into v1423-backlog | done |
|
||||
| 7 | Release v1.4.23 — bump, CHANGELOG, release-merge, tag, GHCR, Coolify deploy, /api/version=1.4.23, smoke, GH release, docs + landing sync, brief | done |
|
||||
The headline is derived wellness metrics that are honest on the data HealthLog
|
||||
actually has — daily snapshots, not continuous streams. The governing constraint:
|
||||
HRV is a single nightly SDNN value, there is no continuous HR, no EDA, no raw ECG/PPG,
|
||||
and the nightly drain tombstones per-sample rows to a daily mean. So anything built on
|
||||
intraday physiology is out of scope; day-scale deviation-from-personal-baseline
|
||||
statistics are the sweet spot. Full synthesis in
|
||||
`.planning/v1.10-derived-metrics-PROPOSAL.md`, implementation plan in
|
||||
`.planning/v1.9.1-derived-metrics-IMPLEMENTATION-PLAN.md` (targeted at v1.10.0, not
|
||||
1.9.1), research reports under `.planning/v1.10-research/`.
|
||||
|
||||
Milestone completed 2026-05-11 — v1.4.23 LIVE in prod.
|
||||
Release brief: `docs/audit/v1423-summary.md`.
|
||||
Backlog seeded to `.planning/v1423-backlog.md`. v1.5 strategic plan refresh
|
||||
at `.planning/phase-W6-v1423-product-lead-review.md`.
|
||||
### Ordering (data-availability first)
|
||||
|
||||
A derived metric appears only when its required inputs exist; composites degrade
|
||||
gracefully and never present a headline number secretly computed from one of N signals.
|
||||
Precedent: the Personal Health Score null-redistributes missing pillars.
|
||||
(`.planning/v1.10-research/DESIGN-PRINCIPLE-data-availability.md`.)
|
||||
|
||||
1. **Per-metric Vitals dashboard** (flagship) — each available signal shows its own
|
||||
personal-baseline card; absent signals don't render, so it scales 1→30 metrics with
|
||||
no data assumptions. Pure rolling stats over inputs we 100% have, plus a multi-signal
|
||||
early-strain flag. Build this first.
|
||||
2. **Pre-computed easy wins** — age/sex-adjusted reference ranges (cross-cutting
|
||||
enabler: `dateOfBirth` / `gender` are stored but unused for norms), Fitness Age /
|
||||
cardio band from VO2max, Vascular Age framing from PWV. Render only when the device
|
||||
value exists.
|
||||
3. **Composites** — Sleep Score and a Readiness / Wellness index (extending the Personal
|
||||
Health Score), each with a minimum-inputs threshold, reweighting around missing
|
||||
inputs, a visible coverage / confidence indicator, and a "track BP/HRV to sharpen
|
||||
this" nudge.
|
||||
4. **Broader correlation discovery** — expand the fixed-hypothesis correlation engine
|
||||
into an FDR-controlled behaviour↔outcome discovery engine, surfacing only pairs with
|
||||
enough paired n (the existing n≥20 gate pattern).
|
||||
|
||||
Out of scope by construction (require sensing we deliberately don't retain): Body
|
||||
Battery, all-day / real-time Stress, Strain / Training Load, a faithful Recovery score,
|
||||
AFib / ECG derivation, breathing-disturbance / AHI, Oura Resilience, minute-by-minute
|
||||
time-in-zone. State this plainly so we never over-promise; surface a device-computed
|
||||
value as a passthrough where one exists, never re-derive it.
|
||||
|
||||
### Provenance / transparency (acceptance criterion, not an afterthought)
|
||||
|
||||
Every derived metric openly states what it's based on and links the basis:
|
||||
- exact inputs + method / formula (plain-language + the math),
|
||||
- why this method (rationale),
|
||||
- a citation / link to the underlying authority (IEEE / RFC / WHO / LOINC /
|
||||
peer-reviewed studies / Wikipedia — whichever is right),
|
||||
- confidence / limitations stated honestly (SDNN≠RMSSD, snapshot-not-continuous,
|
||||
consumer-sensor validity caveats),
|
||||
- rendered through the existing collapsible provenance / explainer pattern — no
|
||||
black-box number.
|
||||
|
||||
Needs a shared provenance / citation data model so each derived metric carries its
|
||||
sources, surfaced via the explainer component everywhere it appears.
|
||||
(`.planning/v1.10-research/REQUIREMENT-docs-and-provenance.md`.)
|
||||
|
||||
### Documentation audit + continuation
|
||||
|
||||
- Audit the docs site (`healthlog-docs`), in-repo `docs/`, README, and the OpenAPI
|
||||
contract for accuracy vs the shipped state — much drifted across the v1.8.x → v1.9.0
|
||||
arc.
|
||||
- Continue / extend docs to cover the new derived metrics with the same provenance:
|
||||
meaning, computation, linked standard, required data, graceful-degradation behaviour.
|
||||
- This `ROADMAP.md` / `STATE.md` refresh folds into the audit.
|
||||
|
||||
### Discoverability / growth
|
||||
|
||||
A discoverability / growth workstream stands alongside the milestone (scope to be
|
||||
shaped during planning).
|
||||
|
||||
---
|
||||
|
||||
## Previous milestone — v1.4.22 (completed 2026-05-10T22:43:50+02:00)
|
||||
## Standing deferred items
|
||||
|
||||
| Wave | Goal | State |
|
||||
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- |
|
||||
| 1 | Research + probe — Playwright PROD probe (BD-Zielbereich 5th attempt, api-tokens 5th attempt, metric token leaks, Targets page brainstorm); health-coach prompt research | done |
|
||||
| 2 | Insights surface polish — A1 BD framing, A2 BD-Kachel parity, A3 comparison-toggle global, A4 grid normalisation, A5 Muster rename + tabs above hero, A6 token-leak fix | done |
|
||||
| 3 | Coach polish — B1 prompt rewrite (PROMPT_VERSION 4.20.2 → 4.22.0), B2 collapsible evidence, B3 Gravatar parity, B4 disclaimer move, B5 settings cog removal | done |
|
||||
| 4 | Other surfaces + backlog cleanup — C1 Zielwerte sparkline + Δ-vs-last-month, C2 api-tokens 5th attempt, C3 Coolify auto-deploy runbook, C4 AuthShell flicker → proxy.ts, C5 node-26-alpine deferred, D backlog wave (12 items) | done |
|
||||
| 5 | Multi-agent QA + Product-Lead — code, security, design, senior-dev, simplify, product-lead; 0 CRIT, 7 HIGH all applied inline, ~6 MED applied, rest deferred | done |
|
||||
| 6 | Release v1.4.22 — bump, CHANGELOG, release-merge, tag, GHCR, host-side retag deploy (Coolify secrets still missing), /api/version=1.4.22, smoke, GH release, docs+landing sync, brief | done |
|
||||
Carried forward; pick up as they become relevant or fold into a milestone.
|
||||
|
||||
Milestone completed 2026-05-10T22:43:50+02:00 — v1.4.22 LIVE in prod.
|
||||
Release brief: `docs/audit/v1422-summary.md`.
|
||||
Image digest:
|
||||
`sha256:865154614303fdc362ee3941776f73ec0f60e1f16112ec272a75cbbe28e2cffb`.
|
||||
Backlog seeded to `.planning/v1422-backlog.md`. v1.5 strategic plan at
|
||||
`.planning/phase-W5-v1422-product-lead-review.md`.
|
||||
- **BYOK AI fallback** — resilience gap: no provider fallback is configured, so a single
|
||||
provider outage stalls assessment generation.
|
||||
- **`MedicationAdministration` per-export cap tuning** — the per-dose administration
|
||||
emission added in v1.9.0 needs a sensible cap for high-volume histories.
|
||||
- **SNOMED route / site upgrade** — route-of-administration + injection-site could carry
|
||||
SNOMED codes rather than the current local enum.
|
||||
- **German bfarm ATC URI behind a locale toggle** — the export codes ATC against the WHO
|
||||
system; a German deployment may prefer the bfarm URI.
|
||||
|
||||
---
|
||||
|
||||
## Previous patch — v1.4.21 (completed 2026-05-10T17:46+00:00)
|
||||
## Shipped (coarse — CHANGELOG.md is the record)
|
||||
|
||||
| Phase | Goal | State |
|
||||
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- |
|
||||
| F | Same-day patch on top of v1.4.20 — Daily Briefing regenerate fix, Coach day-level snapshot, duplicate streaming bubble, drawer header, scope picker UI | done |
|
||||
| E | Release v1.4.21 — develop → main release-merge, bump, CHANGELOG, tag, GHCR, host-side retag fallback, /api/version=1.4.21, smoke, GH release, brief | done |
|
||||
|
||||
Patch completed 2026-05-10T17:46+00:00 — v1.4.21 LIVE in prod.
|
||||
Release brief: `docs/audit/v1421-summary.md`.
|
||||
Image digest:
|
||||
`sha256:4e818d44702c3581a14d6480a953fd20d16cbbaf21c41e0c778c07340d3c4b1c`.
|
||||
|
||||
---
|
||||
|
||||
## Previous milestone — v1.4.20 (completed 2026-05-10T16:49:25Z)
|
||||
|
||||
| Phase | Goal | State |
|
||||
| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
|
||||
| F0 | Bootstrap — commit dangling v1.4.19 reports, scaffold STATE+ROADMAP | done |
|
||||
| F1 | Branch model — long-lived `develop` from `main` HEAD; GHCR builds on `main` + `v*` only | done |
|
||||
| F2 | Document branch + release model — CONTRIBUTING.md + docs site mirror page | done |
|
||||
| FX | User-facing artifact cleanup — PII (no real names, no health figures), internal jargon, German leaks; CHANGELOG + docs/audit + GH releases + docs + landing | done |
|
||||
| F5 | Best-practice GitHub repo audit — CODE_OF_CONDUCT.md, issue + PR templates, dependabot expansion, package.json metadata | done |
|
||||
| F6 | Multi-agent QA on new docs — 38 docs pages cross-checked vs deployed state, CRIT + HIGH inline | done |
|
||||
| B1 | Hero strip + Daily Briefing + Suggested-prompts | done |
|
||||
| B2 | AI Coach drawer + SSE streaming + encrypted persistence | done |
|
||||
| B3 | Correlation discovery + Trends row with AI annotations | done |
|
||||
| B4 | Weekly Report + Storyboard markers + Mobile passes | done |
|
||||
| B5 | Personal Health Score (composite 0–100, 3 bands) | done |
|
||||
| D | Multi-agent QA — code, security, design, senior-dev, simplify, product-lead; 13 HIGH + 6 MED + 5 simplify-yes inline; 22 MED + 16 LOW deferred | done |
|
||||
| E | Release v1.4.20 — develop → main release-merge, bump, CHANGELOG, tag, GHCR, host-side retag deploy fallback, /api/version=1.4.20, smoke, docs+landing sync | done |
|
||||
|
||||
Milestone completed 2026-05-10T16:49:25Z — v1.4.20 LIVE in prod.
|
||||
Release brief: `docs/audit/v1420-summary.md`. Backlog seeded to
|
||||
`.planning/v1421-backlog.md`. v1.5 strategic plan at
|
||||
`.planning/phase-D-v1420-product-lead-review.md`.
|
||||
|
||||
---
|
||||
|
||||
## Previous milestone — v1.4.19 (completed 2026-05-10T12:39:59Z)
|
||||
|
||||
| Phase | Goal | State |
|
||||
| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
|
||||
| 0 | Bootstrap — STATE+ROADMAP for v1.4.19 | done |
|
||||
| A1 | BD-Zielbereich constant 50% (4th attempt) — live-DB root-cause + integration test | done |
|
||||
| A2 | Charts mobile audit — universal X-tick density helper, mobile-first stacked headers across HealthChart / MoodChart / MedicationComplianceChart | done |
|
||||
| A3 | `/insights` polish — Comparison-toggle relocated, single page-level refresh, BP/Weight tile strip removed, raw-token leak guarded | done |
|
||||
| A4 | AI prompt — GROUND RULE 7 forbids default-positivity opener, PROMPT_VERSION 4.16.1 → 4.19.0 | done |
|
||||
| A5 | Settings/Integrations status-UI — `<IntegrationStatusPill>` consolidates Withings + Mood Log, divider parity, locale-aware relative-time | done |
|
||||
| A6 | Settings mobile audit — input heights equalised at 36 px, action buttons standardised, Sprache select hoisted to its own row | done |
|
||||
| A7 | Admin polish — feedback tab strip scrollbar, api-tokens 4th-attempt truncate+tooltip, Einklappen toggle removed, Zielwerte i18n | done |
|
||||
| A8 | Quality-of-life audit — 78 findings prioritised CRITICAL / HIGH / MED / LOW | done |
|
||||
| B | Apply A8 — 6/6 CRITICAL + 21/25 HIGH inline; 31 MED + 16 LOW carried to v1.4.20 backlog | done |
|
||||
| D | Multi-agent QA (5 reviewers) + Product-Lead → v1.5 redesign plan filed | done |
|
||||
| E1-E3 | Release v1.4.19 — tag, GHCR build green, host-side retag deploy via fallback (Coolify deploy hung, retried via SSH), GH release, docs+landing sync | done |
|
||||
|
||||
Milestone completed 2026-05-10T12:39:59Z — v1.4.19 LIVE in prod.
|
||||
Release brief: `docs/audit/v1419-summary.md`. Backlog seeded to
|
||||
`.planning/v1420-backlog.md` (carry-over) and v1.5 strategic items in
|
||||
`.planning/v15-backlog.md` + `.planning/phase-D-v1419-product-lead-review.md`.
|
||||
|
||||
---
|
||||
|
||||
## Previous milestone — v1.4.18 (completed 2026-05-10T11:45+02:00)
|
||||
|
||||
| Phase | Goal | State |
|
||||
| ----- | ------------------------------------------------------------------------------------------------------------ | ----- |
|
||||
| 0 | Bootstrap — STATE+ROADMAP for v1.4.18 | done |
|
||||
| A1 | BD-Zielbereich tile — 7T/30T sub-values render real numbers (currently "—" even with data) | done |
|
||||
| A2 | `/admin/api-tokens` table scrollbar (3rd attempt) — Playwright live-verify against prod, fix actual offender | done |
|
||||
| A3 | Chart visual revert + per-chart toggles — drop gradient/emoji/auto-mean, ship 3 opt-in overlay toggles | done |
|
||||
| B1 | Achievements expansion — research + 15-25 new, hidden Easter-eggs, lock filter, hidden cards | done |
|
||||
| D | Multi-agent QA + Product-Lead — code-reviewer, security, design, senior, simplify, product | done |
|
||||
| E | Release v1.4.18 — bump, CHANGELOG, tag, GHCR, deploy, /api/version=1.4.18, smoke, docs+landing sync | done |
|
||||
|
||||
Marathon completed 2026-05-10T11:45+02:00 — v1.4.18 LIVE in prod.
|
||||
Full report: `docs/audit/v1418-summary.md`. Backlog seeded to
|
||||
`.planning/v1419-backlog.md` (tactical) and `.planning/v15-backlog.md`
|
||||
|
||||
- `.planning/phase-D-v1418-product-lead-review.md` (strategic).
|
||||
|
||||
---
|
||||
|
||||
## Previous milestone — v1.4.17 hotfix (live 2026-05-10T07:58+00:00)
|
||||
|
||||
| Phase | Goal | State |
|
||||
| ------ | ---------------------------------------------------------------------------------------------------------- | ----- |
|
||||
| Hotfix | `/insights` TypeError on legacy cached blob — `isLegacyInsightPayload()` flag + advisor card short-circuit | done |
|
||||
| Audit | `git grep -nE '\.replace\(' src/` — 82 hits scanned, crash site is the only fragile case | done |
|
||||
| E | Release v1.4.17 — bump, tag, GHCR, host-side retag deploy, GH release | done |
|
||||
|
||||
Hotfix completed 2026-05-10T07:58+00:00 — v1.4.17 LIVE in prod.
|
||||
Detailed report: `.planning/phase-v1417-hotfix-report.md`.
|
||||
|
||||
---
|
||||
|
||||
## Previous milestone — v1.4.16 (completed 2026-05-10T04:05+02:00)
|
||||
|
||||
| Phase | Goal | State |
|
||||
| ----- | ----------------------------------------------------------------------------------------------------- | ----- |
|
||||
| 0 | Bootstrap — STATE+ROADMAP for v1.4.16 | done |
|
||||
| A1 | Sidebar admin-expand bug — Admin nav from non-admin route + Gravatar dropdown side-effects | done |
|
||||
| A2 | BD-Zielbereich real-fix (regression from v1.4.15 A4) — root-cause + E2E | done |
|
||||
| A3 | `/admin/api-tokens` table responsive — card-list mobile fallback | done |
|
||||
| A4 | "7-Tage-Schnitt" → "7-Tage-Trend" DE + indicator on ALL charts | done |
|
||||
| A5 | Top-tile-selector real-fix — widget-id enum drift fixed | done |
|
||||
| A6 | Medication-chart 7d-trend + target-range — match other charts | done |
|
||||
| A7 | AI Generator rate-limit 10/h + cache-invalidate-on-new | done |
|
||||
| A8 | Umlaute encoding bug + login-overview UTF-8 audit + long-window split-half delta | done |
|
||||
| B1a | Charts visual leap — gradients, baseline, rich tooltip, mood emoji glyphs | done |
|
||||
| B1b | Insights surface visual leap — page hero, recs grid, summary typography, dashboard preview | done |
|
||||
| B2 | AI provider settings UX cleanup — single pulldown-driven form, fallback chain editor | done |
|
||||
| B3 | Admin System-Status host-load chart — CPU/memory/disk-io graph last 2h | done |
|
||||
| B4 | Admin logs visibility deepening — filterable audit log + structured wide-event tail | done |
|
||||
| B5a | Medical-reference grounding — AHA / ESC / ESH / WHO bundle + UI citations | done |
|
||||
| B5b | Multi-provider redundancy — try-each-on-hard-failure, configurable fallback order | done |
|
||||
| B5c | Per-recommendation explainability — WHY / WINDOW / CITATIONS card with mini-chart | done |
|
||||
| B5d | Confidence score per recommendation — deterministic 0-100 from sufficiency / recency / signal | done |
|
||||
| B5e | User-feedback loop — thumbs persisted to RecommendationFeedback + daily aggregator | done |
|
||||
| B6 | Settings naming-audit — stringency, consistency, no double naming | done |
|
||||
| B7 | Settings → Export menu (Arztbrief consolidated) — new `/settings/export` route | done |
|
||||
| B8 | Extended comparison views — Vormonat / Vorjahr overlay across charts + tiles + insights | done |
|
||||
| C | Catch-up — 5 of 8 deferred HIGH + 3 of 5 mobile MED + docker-publish drop qemu-arm64 | done |
|
||||
| D | Multi-agent QA + Product-Lead — code-review, security, design, senior, simplify, product-lead | done |
|
||||
| E1-E3 | Release v1.4.16 — bump, tag, GHCR (both green), host-side retag deploy, GH release, docs+landing sync | done |
|
||||
|
||||
Marathon completed 2026-05-10T04:05+02:00 — v1.4.16 LIVE in prod.
|
||||
Full report: `docs/audit/v1416-summary.md`. v1.5 backlog seeded at
|
||||
`.planning/v15-backlog.md`.
|
||||
|
||||
---
|
||||
|
||||
## Previous milestone — v1.4.15 (completed 2026-05-09T22:50+02:00)
|
||||
|
||||
| Phase | Goal | State |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------------ | ----- |
|
||||
| 0 | Bootstrap — STATE+ROADMAP for v1.4.15 | done |
|
||||
| A1 | Nav conditionals + sidebar context-awareness | done |
|
||||
| A2 | `/admin` overview redesign + `/admin/api-tokens` responsive | done |
|
||||
| A3 | Quick-add labels + Stimmung-Card mobile + onboarding flicker | done |
|
||||
| A4 | Dashboard analytics fixes (BD-Zielbereich, Medikamente graph, Stimmung agg, 7-Tage-Trend, top-tile selector) | done |
|
||||
| A5 | Mobile UX audit + chart scroll-lockup fix | done |
|
||||
| B-mobile | Mobile audit fix-application (5 commits, 2/2 CRITICAL, 6/8 HIGH) | done |
|
||||
| B1 | Backup completeness — restore, download, upload, audit, docs | done |
|
||||
| B2 | Withings + moodLog sync robustness — status UI, refresh rotation, telegram on fail | done |
|
||||
| B3 | Notification reliability — auto-disable on 410, exp backoff, status visible | done |
|
||||
| B4 | Achievements UI — `/achievements` page + dashboard card | done |
|
||||
| B5 | Onboarding tour first-run — spotlight, Esc/arrows, restart from Settings | done |
|
||||
| B6 | Doctor-report v2 — configurable date range, practice-name on cover | done |
|
||||
| C1 | AI/Codex hardening — provider abstraction, JSON schema, citations, scope, slug-drift defense | done |
|
||||
| C2 | Auto-deployment — Coolify webhook on GHCR push, failure-notify | done |
|
||||
| C3 | CI/e2e reliability audit — root-cause flaky specs, docker-publish cache fix | done |
|
||||
| C4 | i18n coverage audit — EN+DE parity, non-empty test guards | done |
|
||||
| C5 | Empty-states audit — 13 surfaces upgraded | done |
|
||||
| D | Multi-agent QA (5 reviewers + reconcile) | done |
|
||||
| E1–E3 | Release v1.4.15 — bump, tag, GHCR, host-side retag deploy, docs+landing sync, Marc-Brief | done |
|
||||
|
||||
Marathon completed 2026-05-09T22:50+02:00 — v1.4.15 LIVE in prod.
|
||||
Full report: `docs/audit/v1415-summary.md`.
|
||||
|
||||
---
|
||||
|
||||
## Previous milestone — v1.5 (relabelled v1.4.14, completed 2026-05-09)
|
||||
|
||||
| Phase | Goal | State |
|
||||
| ----- | -------------------------------------------------------------------------------------------------------------------------------------- | ----- |
|
||||
| 0 | Bootstrap — STATE+ROADMAP, git/CI/prod sanity, single chore commit | done |
|
||||
| 1 | Verify Codex-OAuth end-to-end (device-start shape, prod logs, gpt-5.3-codex slug) | done |
|
||||
| 2 | v1.4.6 deferred backlog — T2.1..T2.7 (wipe scope, DST math, 5xx → 422/503, status-card test, redact regex, Backups view, /admin/users) | done |
|
||||
| 3 | End-to-end test coverage — authed dashboard, add-measurement, KI flow, doctor PDF, insights, mobile smoke, axe-core extended | done |
|
||||
| 4 | Performance audit — Playwright capture, `docs/audit/v15-performance.md`, top-3 wins inline | done |
|
||||
| 4b | Admin Panel refactor — `/admin/[section]` dynamic routes mirror Settings; sidebar Admin group; legacy hash redirects; bundle-size win | done |
|
||||
| 5 | UX polish — design-review top-10 friction points, CRITICAL/HIGH triage | done |
|
||||
| 6 | Multi-agent QA (parallel) — code-reviewer, security, visual/UX, simplify | done |
|
||||
| 7 | Pre-release verification — typecheck / lint / format / test / integration / build / e2e | done |
|
||||
| 8 | Release v1.4.14 — rebrand patch deploy (host-side retag, GHCR `:1.4.14`, image digest `0ced46004a54…`) | done |
|
||||
| 9 | Docs + landing site sync to v1.4.14 | done |
|
||||
| 10 | Backfill GitHub releases v1.4.7..v1.4.13 from CHANGELOG | done |
|
||||
| 11 | Final summary `docs/audit/v1414-summary.md` with Marc-Brief | done |
|
||||
|
||||
Marathon completed 2026-05-09T18:35+02:00 — v1.4.14 LIVE in prod.
|
||||
Full report: `docs/audit/v1414-summary.md`.
|
||||
- **v1.9.0** (2026-06-02) — Insights time-ranges + period-over-period deltas, deeper
|
||||
mood insights, medication ATC + RxNorm into the FHIR export (+ MedicationAdministration
|
||||
per dose, Coverage-from-bare-KVNR, Composition narrative), advanced-settings rebuild,
|
||||
inline targets (`/targets` retired), connection-pool sizing fix, decoupled insight
|
||||
warm. Migration `0103`.
|
||||
- **v1.8.7.1** (2026-06-02) — assessment on every HealthKit metric page, one-tap
|
||||
pre-generation.
|
||||
- **v1.8.x** (2026-05-31 → 06-02) — Insights big release (reliable assessments, graded
|
||||
compression, embedded targets, explainers, English slugs), compliance + intake-slot
|
||||
medical fixes, injection-site tracking, FHIR codesystem + Coverage, instant
|
||||
assessments, GLP-1 blood-level chart.
|
||||
- **v1.7.x** (2026-05-31) — health-record PDF + FHIR R4 export, PRN + cyclic schedules,
|
||||
unified dashboard snapshot + nightly insight pre-gen, offline sync delta feed, full
|
||||
HealthKit chart coverage, Coach data clustering.
|
||||
- **v1.6.0** (2026-05-30) — medication editor overhaul + route of administration,
|
||||
one-time injection, today-tile read-flip, profile-photo upload.
|
||||
- **v1.5.x** (2026-05-24 → 29) — native iOS client public beta + Apple Health sync,
|
||||
medication scheduling (RRULE / rolling / one-shot), step consolidation, `safeFetch`
|
||||
egress hardening, avatar storage, `SESSION_COOKIE_SECURE` through compose.
|
||||
- **v1.4.24 → v1.4.50** (2026-05-11 → 24) — rollup tiers + perf, APNs + `push_attempts`,
|
||||
reminder suppression, self-healing stale-shell, localisation, MoodLog reverse-sync.
|
||||
- **v1.4.23** (2026-05-11) — pre-iOS backend foundation + hygiene (Apple Health enum +
|
||||
batch ingest + APNs scaffolding + OpenAPI generator). Earlier history archived.
|
||||
</content>
|
||||
+95
-223
@@ -1,245 +1,117 @@
|
||||
# v1.4.23 marathon — state log
|
||||
# State log — where we are now
|
||||
|
||||
Status: SHIPPED — v1.4.23 LIVE in prod
|
||||
Last update: 2026-05-11 (release wave)
|
||||
Status: **v1.9.0 shipping** — on branch `release/v1.9.0`, Draft PR #240 → `main`,
|
||||
about to tag + docker-publish + deploy.
|
||||
Last update: 2026-06-02
|
||||
|
||||
> Previous milestone: v1.4.22 live (image digest
|
||||
> `sha256:865154614303…`, `/api/version=1.4.22`).
|
||||
> v1.4.23 = pre-iOS prep + hygiene. Backend foundation so the iOS
|
||||
> Health app can ship in v1.5 without contract churn, plus 7
|
||||
> hygiene items from `.planning/v1422-backlog.md`.
|
||||
> Single trunk: `main` is always releasable. `develop` is retired (archived as the
|
||||
> tag `archive/develop-pre-v1.5`; never recreate it). Releases cut on a short-lived
|
||||
> `release/vX.Y.Z` branch → PR → merge → tag → `docker-publish.yml` → manual deploy →
|
||||
> verify `/api/version` on every target. See CLAUDE.md for the full model.
|
||||
|
||||
## Wave 1 — Research (single agent, three streams)
|
||||
---
|
||||
|
||||
- [ ] Apple Health `HKQuantityTypeIdentifier` → `MeasurementType`
|
||||
mapping, with units + canonical aggregation rules
|
||||
- [ ] APNs Node library decision (`apn` vs `@parse/node-apn` vs
|
||||
raw HTTP/2) with maintenance / type-safety / footprint pros/cons
|
||||
- [ ] OpenAPI 3.1 generator tooling for Next.js routes
|
||||
(`@asteasolutions/zod-to-openapi`, `ts-rest`, `next-rest`,
|
||||
hand-rolled)
|
||||
- Detailed report: `.planning/phase-W1-v1423-research.md`
|
||||
## v1.9.0 — what it carries
|
||||
|
||||
## Wave 2 — Apple Health foundation (F1+F2+F3)
|
||||
Insights time-ranges + period-over-period deltas, deeper mood insights, medication
|
||||
drug-coding into the FHIR export, an advanced-settings rebuild, an inline-targets
|
||||
move, and a stability fix to the connection pool.
|
||||
|
||||
### F1 — `MeasurementType` enum extension
|
||||
### Added
|
||||
|
||||
- [x] Added `HEART_RATE_VARIABILITY`, `RESTING_HEART_RATE`,
|
||||
`ACTIVE_ENERGY_BURNED`, `FLIGHTS_CLIMBED`,
|
||||
`WALKING_RUNNING_DISTANCE`, `VO2_MAX`, `BODY_TEMPERATURE`
|
||||
(W1 recommended reusing `ACTIVITY_STEPS` + `SLEEP_DURATION`
|
||||
verbatim; the iOS DTO already shipped `BODY_TEMPERATURE` so
|
||||
that joined the additive set — net new = 7)
|
||||
- [x] Prisma migration `0036_apple_health_measurement_types`
|
||||
- [x] Sleep unit shifted from hours to minutes; new `SleepStage`
|
||||
enum + nullable `Measurement.sleepStage` column (CHECK
|
||||
constraint scopes it to SLEEP_DURATION rows)
|
||||
- [x] Composite unique index `(user_id, type, source, external_id)`
|
||||
— Apple Health batch dedup key
|
||||
- [x] Unit conversions in
|
||||
`src/lib/measurements/apple-health-mapping.ts` (16 unit tests)
|
||||
- **Selectable time ranges on the Insights pages** — week / month / quarter / year
|
||||
range pills per metric, with a period-over-period delta (the change in the average,
|
||||
stated plainly with its direction).
|
||||
- **Deeper mood insights** — time-of-day pattern (highest / lowest), a stability read,
|
||||
and correlation cards against weight and blood pressure. Every correlation is
|
||||
paired-n gated (only shown when there are enough paired days to mean anything).
|
||||
- **Standard drug codes on a medication** — an optional ATC and RxNorm code (entered,
|
||||
never guessed; migration `0103` adds the nullable `atc_code` / `rxnorm_code`).
|
||||
These flow into the health-record export: the `MedicationStatement` codes the drug
|
||||
with the WHO ATC system + RxNorm alongside the plain name, and the export adds a
|
||||
`MedicationAdministration` for every dose actually taken or skipped.
|
||||
- **`scripts/assert-deploy.ts`** — a deploy-verification script that checks a target
|
||||
reports the expected version after a release.
|
||||
|
||||
### F2 — `MeasurementSource` enum expansion
|
||||
### Changed
|
||||
|
||||
- [x] `APPLE_HEALTH` appended to the enum
|
||||
- [x] UI badge in measurement-list (Dracula-pink chip) + bilingual
|
||||
`measurements.sourceAppleHealth` keys + admin restore-route
|
||||
enum guard sync
|
||||
- **Medication advanced-settings page rebuilt** — import / intake-import / export in
|
||||
their own group; the external-API endpoints listed one by one with collapsible
|
||||
request examples; tidier layout.
|
||||
- **Targets edited inline** — the standalone `/targets` page is retired; a metric's
|
||||
target range is set from the metric itself.
|
||||
- **Health-record export emits insurer Coverage from a bare KVNR** (not only with a
|
||||
full insurer organisation present) and carries a top-level Composition narrative
|
||||
summary.
|
||||
|
||||
### F3 — `POST /api/measurements/batch`
|
||||
### Fixed
|
||||
|
||||
- [x] New route at `src/app/api/measurements/batch/route.ts`,
|
||||
idempotent via `Idempotency-Key`
|
||||
- [x] Validates batch ≤ 500 entries (returns 422
|
||||
`coach.batch.too_large`)
|
||||
- [x] Dedupes via `(userId, type, source, externalId)` composite
|
||||
- [x] Returns per-entry status (`inserted` / `duplicate` /
|
||||
`skipped`) + idempotency-replay-safe
|
||||
- [x] Integration test (`tests/integration/measurements-batch.test.ts`,
|
||||
6 cases including idempotency replay)
|
||||
- [x] Sleep-stage aggregation in `/api/analytics`:
|
||||
per-Berlin-day summary + `sleepStages` block
|
||||
(`tests/integration/analytics-sleep-stages.test.ts`)
|
||||
- Detailed report: `.planning/phase-W2-v1423-report.md`
|
||||
- **App stays responsive under load** — `DB_CONNECTION_LIMIT` is now sized for real
|
||||
concurrency (the 9 default could starve while a background insight warm and a
|
||||
foreground request competed, surfacing as the self-recovering "server not
|
||||
responding"); tunable for larger self-hosts.
|
||||
- **Background insight warm decoupled + sync-burst invalidation debounced** — a warm
|
||||
no longer blocks a page, and a burst of Apple Health / Withings sync no longer
|
||||
triggers a storm of regenerations.
|
||||
- **Medication card rows hold an equal height** ("last dose" / "next dose" alignment).
|
||||
- **Glucose entered in mmol/L converts correctly** in the editor.
|
||||
- **Scrollbar-gutter reserved** — admin + global layout no longer shifts when a
|
||||
scrollbar appears.
|
||||
- **Six legacy `*-status` routes documented** in the OpenAPI contract.
|
||||
|
||||
## Wave 3 — APNs scaffolding (F4)
|
||||
---
|
||||
|
||||
- [x] Library install + lock-file (`@parse/node-apn@8.1.0`,
|
||||
W1 stream-2 decision)
|
||||
- [x] `src/lib/notifications/senders/apns.ts` — `sendApnsPush()`
|
||||
one-shot helper plus `sendViaApns(userId, payload)` dispatcher
|
||||
entry mirroring the web-push contract
|
||||
- [x] Env-var contract: `APNS_KEY_ID`, `APNS_TEAM_ID`,
|
||||
`APNS_BUNDLE_ID`, one of `APNS_KEY` / `APNS_KEY_FILE`,
|
||||
optional `APNS_PRODUCTION` — documented in `.env.example`,
|
||||
all-or-none guard with one-shot warning when partially set
|
||||
- [x] `Device` Prisma model extension via migration
|
||||
`0037_apns_device_columns`: nullable `apnsToken` +
|
||||
`apnsEnvironment`, paired CHECK constraint, single-column
|
||||
index on `apns_token` for the dispatcher fan-out lookup
|
||||
- [x] Dispatcher wiring: `APNS` joins the cascade as channel-type
|
||||
4; cascade order is now deterministic
|
||||
(APNs → Telegram → ntfy → Web Push)
|
||||
- [x] Mock provider at `src/lib/notifications/senders/__mocks__/apns.ts`
|
||||
with queued per-token responses + recorded calls
|
||||
- [x] Integration test
|
||||
`tests/integration/apns-dispatch.test.ts` — round-trip,
|
||||
hard-reject + cleanup, cascade fall-through (3 tests)
|
||||
- [x] `POST /api/devices` accepts paired `apnsToken` +
|
||||
`apnsEnvironment`, enforces hex format, applies the
|
||||
cross-user-hijack guard at the APNs-token layer
|
||||
(409 `apns_token_owned_by_other_user`)
|
||||
- Detailed report: `.planning/phase-W3-v1423-report.md`
|
||||
## What is live (the release arc since v1.4.23)
|
||||
|
||||
## Wave 4 — OpenAPI + Coach schema + native auth + Coolify
|
||||
Coarse summary; CHANGELOG.md is the accurate per-release record.
|
||||
|
||||
### F5 — OpenAPI 3.1 generator + CI gate
|
||||
- **v1.4.24 → v1.4.50** — perf + rollup tiers (measurement / mood / compliance /
|
||||
cumulative-sum rollups, read-swap with live fallback, auto-converging boot
|
||||
backfill), APNs delivery + per-channel test endpoint + `push_attempts` ledger,
|
||||
server-side reminder suppression (`clientManaged`), self-healing stale-shell after
|
||||
deploy, full localisation push, MoodLog reverse-sync.
|
||||
- **v1.5.x** — native iOS client public beta + Apple Health sync (`POST /api/measurements/batch`,
|
||||
per-day cumulative `stats:` overwrite), medication scheduling (RRULE / rolling /
|
||||
one-shot lifecycle, creation wizard → modal-dialog compose-mode), per-day-cumulative
|
||||
step consolidation, `safeFetch` egress hardening, self-hosted avatar storage,
|
||||
`SESSION_COOKIE_SECURE` plumbed through compose.
|
||||
- **v1.6.0** — medication editor overhaul + route of administration (`deliveryForm`,
|
||||
migration `0088` ORAL backfill), one-time injection, today-tile read-flip onto the
|
||||
canonical recurrence engine, profile-photo upload.
|
||||
- **v1.7.x** — health-record export (enriched PDF + HL7 FHIR R4 bundle), PRN + cyclic
|
||||
schedules, unified dashboard snapshot + nightly insight pre-generation, offline sync
|
||||
delta feed (tombstones), full HealthKit chart coverage + display-unit preference,
|
||||
Coach data clustering, multi-time compliance fixes.
|
||||
- **v1.8.x** — Insights big release (reliable data-driven assessments, graded
|
||||
compression, embedded targets, explainers, English slugs + tile-id aliases),
|
||||
multi-time compliance, one-intake-row-per-dose-slot medical fix, injection-site
|
||||
tracking, FHIR codesystem for HealthKit-only metrics, FHIR `Coverage`, instant
|
||||
assessments (stale-while-revalidate), GLP-1 blood-level chart.
|
||||
- **v1.8.7.1** — a plain-language assessment on every HealthKit metric page (~30
|
||||
metrics), one-tap pre-generation of every assessment.
|
||||
|
||||
- [x] Tool wired (W1 decision — `zod-openapi` (samchungy) + `yaml@^2`)
|
||||
- [x] `pnpm openapi:generate` script emits
|
||||
`docs/api/openapi.yaml` from Zod schemas
|
||||
- [x] CI step compares generated vs committed; PR warns on drift
|
||||
(`continue-on-error: true` — flips to hard-fail in v1.4.24+)
|
||||
- [x] Legacy hand-maintained spec preserved at
|
||||
`docs/api/openapi-v1422-legacy.yaml` during the incremental
|
||||
migration
|
||||
apps01 + demo are LIVE on the latest tag; v1.9.0 deploy is pending the tag.
|
||||
|
||||
### F6 — Coach + Daily Briefing schema slot for new metrics
|
||||
---
|
||||
|
||||
- [x] `aiInsightResponseSchema.dailyBriefing.keyFindings[].sourceMetric`
|
||||
enum extends to include the 9 Apple Health categories
|
||||
(hrv, sleep, resting_hr, steps, active_energy, flights,
|
||||
distance, vo2_max, body_temp)
|
||||
- [x] `trendAnnotations` schema mirrors the same additive enum
|
||||
- [x] `coach/snapshot.ts` queries the new measurement types when
|
||||
scope toggles them on; web-only accounts pay zero extra SQL
|
||||
- [x] `CoachProvenance.metrics` + `counts` extended symmetrically
|
||||
- [x] PROMPT_VERSION 4.22.0 → 4.23.0 with new GROUND RULE 12
|
||||
("treat Apple Health categories as silent when absent")
|
||||
- [x] EN + DE prompt bodies, OUTPUT FORMAT block, and i18n strings
|
||||
all updated
|
||||
## Deferred set (carry-forward from v1.9.0)
|
||||
|
||||
### F7 — Native API hardening (refresh-token per-device)
|
||||
| Item | Where it goes | Note |
|
||||
|---|---|---|
|
||||
| Derived / synthesized wellness metrics | **v1.10.0** | per-metric Vitals dashboard first, then composites with coverage/confidence gating. Plans in `.planning/v1.10-derived-metrics-PROPOSAL.md` + `.planning/v1.9.1-derived-metrics-IMPLEMENTATION-PLAN.md` (targeted at v1.10.0) + `.planning/v1.10-research/*`. |
|
||||
| Docs / provenance audit | **v1.10.0** | docs site + in-repo `docs/` + README + OpenAPI drifted across the v1.8.x → v1.9.0 arc; audit + continue, and surface every derived metric's sources via the explainer pattern. Requirement: `.planning/v1.10-research/REQUIREMENT-docs-and-provenance.md`. |
|
||||
| Discoverability / growth workstream | **v1.10.0** | standing alongside the derived-metrics milestone. |
|
||||
| BYOK AI fallback | open | resilience gap — no provider fallback is configured, so a single provider outage stalls assessment generation. |
|
||||
| `MedicationAdministration` per-export cap tuning | open | the per-dose administration emission needs a sensible cap for high-volume histories. |
|
||||
| SNOMED route / site upgrade | open | route-of-administration + injection-site could carry SNOMED codes rather than the current local enum. |
|
||||
| German bfarm ATC URI behind a locale toggle | open | the export codes ATC against the WHO system; a German deployment may prefer the bfarm URI — gate behind a locale toggle. |
|
||||
|
||||
- [x] Refresh-token reuse-detection switches from "revoke all" to
|
||||
per-device-token revocation (legacy null-deviceId tokens fall
|
||||
back to user-wide revoke — safety hatch)
|
||||
- [x] New route `GET /api/auth/me/devices` lists active devices
|
||||
with last-seen, label, channels, isCurrent marker
|
||||
- [x] New route `DELETE /api/auth/me/devices/[id]` revokes one device
|
||||
(refresh + access tokens + Device row)
|
||||
- [x] Alternate `DELETE /api/devices/[id]` for the iOS token-rotation
|
||||
cleanup path (mirror of the auth/me variant)
|
||||
- [x] Ownership-boundary 404 on cross-user attempts
|
||||
---
|
||||
|
||||
### F8 — Coolify auto-deploy fix for real
|
||||
## Next milestone — v1.10.0
|
||||
|
||||
- [x] `COOLIFY_WEBHOOK` + `COOLIFY_TOKEN` GitHub repo secrets
|
||||
documented (maintainer action; see
|
||||
`.planning/coolify-auto-deploy-howto.md` for the verbatim
|
||||
recipe)
|
||||
- [x] Coolify "Watch image registry for new digests" UI toggle
|
||||
documented as the load-bearing piece (maintainer action — can't
|
||||
be flipped from CI)
|
||||
- [x] Workflow step gains a `::notice::` line so future runs surface
|
||||
the deploy timestamp + sha without opening the verbose log
|
||||
- [x] Verification recipe (`curl /api/version | jq .data.version`) + host-side fallback documented inline
|
||||
|
||||
## Wave 5 — Hygiene (H1-H7)
|
||||
|
||||
- [x] H1 sentinel parser malformed-enum hardening
|
||||
(`SentinelParseResult.malformedEntries[]` + per-line typed
|
||||
reasons; chat route splits `coach.keyvalues.parse_partial` from
|
||||
the full-block `coach.keyvalues.parse_failed`)
|
||||
- [x] H2 analytics-route unbounded `findMany` pagination
|
||||
(cursor-paged 5 000-row chunks via `fetchBpSeriesChunked`,
|
||||
`analytics.bp_in_target.row_count` wide-event meta for slow-
|
||||
query attribution; integration test seeds 6 000 rows across a
|
||||
chunk boundary)
|
||||
- [x] H3 `<CoachDrawer key={prefill}>` controlled-prop refactor
|
||||
(`useResettableValue` hook + pure `nextResettableValue` helper;
|
||||
`key={prefill}` removed from the parent mount)
|
||||
- [x] H4 per-user prompt-tuning surface (Coach settings cog return)
|
||||
(`User.coachPrefsJson` migration `0038_coach_prefs`; new
|
||||
`GET/PUT /api/auth/me/coach-prefs`; settings cog opens a
|
||||
right-edge `<Sheet>`; system-prompt prepends a per-user
|
||||
OVERRIDE; snapshot reads prefs BEFORE measurement queries so
|
||||
`excludeMetrics` filters before the snapshot lands)
|
||||
- [x] H5 schema drift on `medication_schedules.days_of_week`
|
||||
(decision: deploy — column is referenced by 9 source files
|
||||
across 4 user-visible surfaces; migration
|
||||
`0039_medication_schedule_days_of_week`, NULL = daily)
|
||||
- [x] H6 Pearson p-value normal-approx replacement
|
||||
(raised `MIN_PAIRED_N` from 14 → 20 — conservative patch;
|
||||
rigorous incomplete-beta queued as a v1.4.24 candidate)
|
||||
- [x] H7 Coach helpful/unhelpful first-week observation view
|
||||
(polymorphic `RecommendationFeedback.target_type` migration
|
||||
`0040_recommendation_feedback_target_type`; new POST
|
||||
`/api/insights/chat/messages/:id/feedback`; aggregator slice
|
||||
`buildCoachFeedbackBuckets` by (promptVersion, tone, verbosity);
|
||||
admin section `/admin/coach-feedback` with the H7 i18n bundle
|
||||
both EN + DE)
|
||||
|
||||
W5 commits (8): 58ae9bc, fa07748, 9413d29, 3f60c81, 0eda1de, 1faee95,
|
||||
05c7f14, plus this STATE tick.
|
||||
|
||||
Test deltas: 2191 → 2223 unit (+32). Integration suite added 3
|
||||
files: `coach-prefs`, `coach-feedback`, plus the 6000-row chunk
|
||||
boundary regression in the existing `bp-in-target` test.
|
||||
|
||||
OpenAPI registry coverage: +3 routes (GET / PUT
|
||||
`/api/auth/me/coach-prefs`, POST
|
||||
`/api/insights/chat/messages/{id}/feedback`).
|
||||
|
||||
## Wave 6 — Multi-agent QA + Product-Lead review
|
||||
|
||||
- [x] code-reviewer
|
||||
- [x] security review
|
||||
- [x] design / UX review
|
||||
- [x] senior-dev review
|
||||
- [x] simplify
|
||||
- [x] Product Lead — v1.5 P1 (iOS first launch) plan refresh with
|
||||
concrete file paths now that backend contracts exist
|
||||
- [x] Reconcile — 1 CRITICAL + all 9 HIGH + 4 of 5 simplify + 3 MED
|
||||
applied; remainder triaged into `.planning/v1423-backlog.md`
|
||||
|
||||
W6 reconcile commits (11): 5486507, 13977bc, d5202e5, a2dfc5e (Session A
|
||||
— CRIT + HIGH 1-3); 3ee6dab, dfffe6b, d25e50e (Session B — HIGH 4 +
|
||||
S-02-S-04 simplify + HIGH 6); deadc73 (Session D — HIGH 5); 650f150
|
||||
(Session E — HIGH 7); 1a46bfe (Session F — MED cluster); plus this
|
||||
STATE tick.
|
||||
|
||||
Detailed reconcile report: `.planning/phase-W6-v1423-reconcile-report.md`.
|
||||
Deferred items: `.planning/v1423-backlog.md` (settings-cog vs
|
||||
per-message-controls debate, rigorous Pearson incomplete-beta, surplus
|
||||
MEDs, all LOWs, S-05 simplify, pre-existing `coach-prefs.test.ts` mock
|
||||
issue, sandbox `git commit` no-op).
|
||||
|
||||
Test deltas: W5 ended at 2223 unit. Session A added the `revoke.test.ts`
|
||||
|
||||
- `apns-dispatch` device tests landing at 2227. Session E ended at 2235.
|
||||
Session F added the forged-X-Device-Id regression — final at 2236 unit
|
||||
(+45 across the W5+W6 reconcile arc).
|
||||
|
||||
## Wave 7 — Release v1.4.23
|
||||
|
||||
- [x] Pre-release verify (typecheck / lint / openapi:check / 2236
|
||||
unit / 110 of 112 integration — 2 failures are the pre-existing
|
||||
`coach-prefs.test.ts` `NextRequest` URL mock carryover from
|
||||
v1.4.22, documented in `v1423-backlog.md`, non-blocking)
|
||||
- [x] Format sweep commit (`f1e6630` prettier sweep on `develop`) +
|
||||
W1 research + W5/W6 review packs commit (`766a9ae`) +
|
||||
OpenAPI regen (`4183552`)
|
||||
- [x] Bump `package.json` 1.4.22 → 1.4.23 + CHANGELOG (`d2331d9`
|
||||
`chore(release): v1.4.23`)
|
||||
- [x] Release-merge develop → main (`0dc0e16 Release v1.4.23`)
|
||||
- [x] Tag + push v1.4.23
|
||||
- [x] GHCR build green
|
||||
- [x] Coolify deploy + `/api/version=1.4.23` confirmed
|
||||
- [x] Production smoke (gated routes 307; `/api/version=1.4.23`)
|
||||
- [x] GH release published
|
||||
- [x] Docs site + landing site sync (image pins 1.4.22 → 1.4.23 +
|
||||
ai-insights v1.4.23 callout; softwareVersion JSON-LD bump)
|
||||
- [x] `docs/audit/v1423-summary.md` release brief
|
||||
- [x] v1.5 P1 plan refresh recorded
|
||||
(`.planning/phase-W6-v1423-product-lead-review.md` section C)
|
||||
Derived / synthesized wellness metrics, honest on the daily-snapshot data HealthLog
|
||||
actually stores; full provenance + standards-linking; full docs audit;
|
||||
discoverability / growth. See `ROADMAP.md`.
|
||||
</content>
|
||||
</invoke>
|
||||
@@ -0,0 +1,82 @@
|
||||
# v1.8.5 — server → iOS: injection-site capture on intake
|
||||
|
||||
**Direction:** server → iOS
|
||||
**Status:** additive, non-breaking. iOS adoption is optional.
|
||||
|
||||
## What changed
|
||||
|
||||
Injection-site tracking is now real on the server. The `injectionSite`
|
||||
column on the intake event existed but was never written; v1.8.5 makes
|
||||
the capture + rotation path end-to-end.
|
||||
|
||||
### 1. New optional request field: `injectionSite`
|
||||
|
||||
`POST /api/medications/{id}/intake`, `POST /api/medications/intake`
|
||||
(status toggle), and `POST /api/medications/intake/bulk` now accept an
|
||||
optional `injectionSite` on a **taken** write. Enum values:
|
||||
|
||||
```
|
||||
ABDOMEN_LEFT, ABDOMEN_RIGHT, ABDOMEN_UPPER_LEFT, ABDOMEN_UPPER_RIGHT,
|
||||
THIGH_LEFT, THIGH_RIGHT, UPPER_ARM_LEFT, UPPER_ARM_RIGHT
|
||||
```
|
||||
|
||||
The field is **optional everywhere** — omit it and the dose records
|
||||
exactly as before. There is no behaviour change for any existing client
|
||||
that never sends it. (It is the same enum the response already echoes via
|
||||
the GLP-1 detail endpoint, so the type is already known to the client.)
|
||||
|
||||
### 2. Server-side gating + validation (important for iOS UX)
|
||||
|
||||
The server only persists `injectionSite` when ALL hold:
|
||||
|
||||
- the dose is being recorded **taken** (never on a skip),
|
||||
- the medication is `deliveryForm === "INJECTION"`,
|
||||
- the medication has `trackInjectionSites === true`,
|
||||
- the submitted site is in the medication's **effective allowed set**.
|
||||
|
||||
The effective allowed set = the per-medication `allowedInjectionSites`
|
||||
(empty = all eight) **minus** the user-level
|
||||
`globalExcludedInjectionSites` deny-list. **Deny always wins**: a site the
|
||||
user globally excluded is never valid even if a medication lists it as
|
||||
preferred.
|
||||
|
||||
A submitted site outside the effective set:
|
||||
- per-medication + status-toggle routes → **422**
|
||||
`errorCode: "medications.intake.injection_site.disallowed"`.
|
||||
- bulk route → that entry is reported `status: "skipped"`,
|
||||
`reason: "injection_site_not_allowed"` (the batch still succeeds).
|
||||
|
||||
A site sent for a non-injection / tracking-off medication, or on a skip,
|
||||
is silently dropped (the dose still records, no error).
|
||||
|
||||
iOS should only offer the picker when the medication is an injection with
|
||||
tracking enabled, and restrict the choices to the effective allowed set,
|
||||
so the 422 path stays an edge case (stale UI / race).
|
||||
|
||||
### 3. New medication fields (read + write)
|
||||
|
||||
`GET /api/medications` and `GET /api/medications/{id}` now echo:
|
||||
|
||||
- `trackInjectionSites: boolean` (default `false`)
|
||||
- `allowedInjectionSites: InjectionSite[]` (default `[]` = no restriction)
|
||||
|
||||
`POST /api/medications` + `PUT /api/medications/{id}` accept both as
|
||||
optional fields (field-by-field; `false` / `[]` are valid explicit
|
||||
values — `false` deactivates tracking, `[]` clears the per-med list).
|
||||
|
||||
### 4. New user-level endpoint
|
||||
|
||||
`GET` / `PATCH /api/auth/me/injection-site-prefs`
|
||||
|
||||
```
|
||||
{ "globalExcludedInjectionSites": InjectionSite[] }
|
||||
```
|
||||
|
||||
The PATCH hard-sets the deny-list (empty array clears it). 60/min rate
|
||||
limit, same envelope as `unit-preference`.
|
||||
|
||||
## Contract source
|
||||
|
||||
All of the above is in `docs/api/openapi.yaml` (regenerated this
|
||||
release). No field was removed or renamed; every addition is optional /
|
||||
defaulted. Existing iOS builds keep working untouched.
|
||||
@@ -0,0 +1,67 @@
|
||||
# v1.8.5 — server folds high-frequency mean metrics nightly (informational, no iOS change)
|
||||
|
||||
Direction: server → iOS. Status: informational + optional. No breaking
|
||||
change. iOS needs zero changes for correctness.
|
||||
|
||||
## What changed on the server
|
||||
|
||||
The server now consolidates the high-frequency HealthKit "spot" metrics
|
||||
into one canonical daily row per metric per day, server-side, on a nightly
|
||||
cron — the same treatment cumulative metrics (steps, energy, distance)
|
||||
already get. The daily reduction is the **MEAN** of the day's samples,
|
||||
written at local-noon under the existing
|
||||
`stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>` externalId shape, and the
|
||||
per-sample rows are soft-deleted (tombstoned, pruned later).
|
||||
|
||||
v1.8.5 extends the consolidated set to cover the gait/mobility metrics that
|
||||
previously piled up at sampling granularity:
|
||||
|
||||
- `WalkingAsymmetryPercentage` (WALKING_ASYMMETRY)
|
||||
- `WalkingDoubleSupportPercentage` (WALKING_DOUBLE_SUPPORT)
|
||||
- `AppleWalkingSteadiness` (WALKING_STEADINESS)
|
||||
- `WalkingHeartRateAverage` (WALKING_HEART_RATE_AVERAGE)
|
||||
|
||||
joining the metrics already folded since v1.7.0: `RespiratoryRate`,
|
||||
`EnvironmentalAudioExposure`, `HeadphoneAudioExposure`, `WalkingSpeed`,
|
||||
`WalkingStepLength`.
|
||||
|
||||
The fold now runs on the nightly maintenance tick (alongside the cumulative
|
||||
drain), not just at worker boot. A 36-hour grace window keeps the current
|
||||
day's in-flight watch syncs raw so the live "today" view stays current.
|
||||
|
||||
## What this means for iOS
|
||||
|
||||
**Nothing is required.** Keep sending these metrics as per-sample rows with
|
||||
opaque UUID externalIds exactly as today. The server folds them nightly;
|
||||
the daily read path averages over the single consolidated row, so the
|
||||
metric pages and detail history read consistently with no client change.
|
||||
|
||||
Existing accumulated per-sample rows are back-consolidated automatically on
|
||||
the first deploy by the boot-time discovery pass — no migration, no manual
|
||||
backfill.
|
||||
|
||||
## Pulse is unchanged — still raw
|
||||
|
||||
Regular heart-rate / `PULSE` is deliberately NOT consolidated. It is a
|
||||
discrete clinical reading and feeds correlation + scatter analytics, which
|
||||
read raw PULSE rows. Keep posting PULSE per-sample as today. Its display
|
||||
already daily-averages via the read-path AVG; storage stays raw.
|
||||
|
||||
## Optional future optimisation (not required, not yet scheduled)
|
||||
|
||||
If iOS later wants to cut upload volume for these mean metrics, it MAY send
|
||||
them pre-aggregated as a daily value under the same `stats:` shape it
|
||||
already uses for cumulative metrics — e.g.
|
||||
`stats:HKQuantityTypeIdentifierWalkingSpeed:2026-06-01`. The server's batch
|
||||
route already routes any `stats:*` externalId to the idempotent overwrite
|
||||
path, so no server change is needed to accept it.
|
||||
|
||||
One caveat if iOS adopts this: for a mean metric the daily `stats:` value
|
||||
must carry the **average**, not the sum. Compute it on-device with
|
||||
`HKStatisticsCollectionQuery` using the `discreteAverage` option (NOT
|
||||
`cumulativeSum`, which is correct only for the cumulative metrics). The
|
||||
server-side nightly fold remains the safety net for any client not on that
|
||||
contract, so this stays a pure bandwidth optimisation rather than a
|
||||
correctness dependency.
|
||||
|
||||
No `docs/api/openapi.yaml` change — the batch contract is unchanged.
|
||||
+147
@@ -1,5 +1,152 @@
|
||||
# Changelog
|
||||
|
||||
## [1.9.2] — 2026-06-02 — Document the medication compliance endpoint
|
||||
|
||||
### Changed
|
||||
|
||||
- **The medication compliance endpoint is now in the public API contract.** `GET /api/medications/{id}/compliance` was missing from the OpenAPI document, so a client generating its types from the contract could mistake the separate cadence endpoint (a different shape) for it. The route is now documented with its exact response — the 7- and 30-day adherence summaries, the per-day compliance grid, and the two-row display block — with the field a client should read for the headline 30-day rate and how to draw the per-day history. No behaviour change; the handler was already correct.
|
||||
|
||||
## [1.9.1] — 2026-06-02 — No assessment warm on a page visit
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Opening the Insights overview no longer regenerates every assessment.** A returning visit fired a one-per-session background warm that asked the provider to regenerate the comprehensive briefing and every category and metric assessment at once — a burst that could run for minutes and make the whole app feel unresponsive while it ran, recovering on its own once it finished. Assessments are kept fresh by the nightly pass, and each metric's text refreshes gently on its own when you open it; the "prepare assessments" button still regenerates everything on demand. A page visit now only reads the cached text.
|
||||
|
||||
## [1.9.0] — 2026-06-02 — Insights time-ranges, deeper mood, medication coding, stability
|
||||
|
||||
### Added
|
||||
|
||||
- **A time range you can choose on the Insights pages.** Each metric now carries range pills (week, month, quarter, year) and shows how the period compares with the one before it — the change in your average, stated plainly with its direction — so a trend reads at a glance instead of by eye.
|
||||
- **Deeper mood insights.** The mood page now surfaces your time-of-day pattern (when your mood tends to sit highest and lowest), a stability read, and correlation cards that line mood up against weight and blood pressure — each shown only when there are enough paired days to mean something.
|
||||
- **Standard drug codes on a medication.** A medication can carry an ATC and an RxNorm code (entered, never guessed). These flow into the health-record export: the exported `MedicationStatement` now codes the drug with the WHO ATC system and RxNorm alongside the plain name, and the export adds a `MedicationAdministration` for every dose you actually took or skipped, so a clinician's system can read the record without relying on free text.
|
||||
- **A deploy-verification script** (`scripts/assert-deploy.ts`) that checks a target reports the expected version after a release.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The medication advanced-settings page is rebuilt.** Import, intake-import and export sit in their own group, the external-API endpoints are listed one by one with collapsible request examples, and the layout is tidied so the technical settings read clearly instead of crowding together.
|
||||
- **Targets are edited inline where they belong.** The separate `/targets` page is retired — you set a metric's target range from the metric itself, with no detour to a standalone editor.
|
||||
- **The health-record export emits insurer coverage from a bare insurance number**, not only when a full insurer organisation is present, and carries a top-level narrative summary.
|
||||
- **Notification settings wording** is tightened for clarity.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The app stays responsive under load.** The database connection pool was sized far too small by default and could starve while a background insight warm and a foreground request competed, which showed up as the occasional "server not responding" that recovered on its own; the pool is now sized for real concurrency and tunable for larger self-hosts.
|
||||
- **A background insight warm no longer blocks a page**, and a burst of Apple Health / Withings sync no longer triggers a storm of regenerations — the warm is decoupled and the invalidation is debounced.
|
||||
- **The medication card rows stay aligned.** "Last dose" and "next dose" could break onto different heights and look misaligned; the rows now hold an equal height.
|
||||
- **Glucose entered in mmol/L converts correctly** in the editor instead of being stored against the wrong unit.
|
||||
- **The admin and global layout no longer shifts** when a scrollbar appears, by reserving the gutter.
|
||||
- **The legacy status endpoints are documented** — six older `*-status` routes were missing from the API contract and are now included.
|
||||
|
||||
## [1.8.7.1] — 2026-06-02 — Assessments for every HealthKit metric, one-tap pre-generation
|
||||
|
||||
### Added
|
||||
|
||||
- **A plain-language assessment on every HealthKit metric page.** The HealthKit metric pages (resting heart rate, heart-rate variability, blood oxygen, glucose, body composition, gait, audio exposure, sleep, and the rest) showed charts but no written assessment, because the core metrics' bespoke per-metric prompts don't scale to thirty more. Each page now carries an assessment, generated from the metric's own normal range and the direction a healthy value moves, anchored on your individual baseline — the same treatment the core metrics already had. A metric with no data keeps its existing insufficient-data note.
|
||||
- **One-tap pre-generation of every assessment.** A button on the Insights overview generates all assessments in the background so they are ready immediately the next time you open them. The overview also warms a cold cache on its own, and the nightly pass keeps everything fresh — so a page opens to its assessment instead of a wait.
|
||||
|
||||
### Changed
|
||||
|
||||
- **"Auswertungen" is now "KI-Auswertungen" / "AI Insights"** in the settings heading and the menu, so it's clear the section is AI-generated.
|
||||
- **Targets and Sources are two separate settings pages** instead of one combined page, each with its own heading.
|
||||
- **The back link sits above the heading** on a metric's detail page.
|
||||
- **Dropdown fields have symmetric padding**, so the chevron no longer crowds the right edge.
|
||||
- **The Insights layout accepts the full metric set**, so a saved layout (order and visibility) can include every metric page, not just the core ones.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Assessments generate again.** The assessment provider's accepted model identifiers had rotated, so generation failed and an assessment could sit on "preparing" indefinitely; the model ladder is updated and falls through to the current identifiers.
|
||||
- **The Active Energy assessment loads.** Its page requested an assessment under a mismatched identifier and silently fell back to "no analysis yet"; the identifier is corrected and now type-checked, so the same class of mismatch is caught at build time.
|
||||
- **The source-priority editor is fully translated** for Spanish, French, Italian and Polish — it was English-only after the settings split.
|
||||
|
||||
## [1.8.7] — 2026-06-02 — Instant insight assessments, GLP-1 blood-level chart, tile + caption polish
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Insight assessments are instant again.** A category's assessment was being re-prepared on almost every visit: the constant Apple Health / Withings sync kept evicting the cached text, and a miss only ever deleted-and-waited. Assessments now serve the last good text immediately (stale-while-revalidate) and refresh in the background — "preparing" shows only when an assessment has genuinely never been generated. The background refresh is also debounced per metric, so a sync burst no longer triggers a storm of regenerations, and it warms only the active language (halving the work).
|
||||
- **The dashboard tiles never truncate the value.** In a dense tile strip the number could clip or wrap mid-value (e.g. "130 mmHg" showing as "13"); the value now always renders in full and the unit yields the space instead.
|
||||
- **GLP-1 medications show the drug-level-in-blood estimate by default.** The estimated concentration curve was hidden behind the Research-Mode opt-in and below the dose-strength curve; for GLP-1 medications it now shows by default, above the dose-strength curve, with the "educational estimate, not a measurement" disclaimer kept clearly attached.
|
||||
- **The Trends captions read consistently.** Metrics without an advisor note previously rendered their caption in a different, plainer style that broke the row; every Trends caption now uses the same treatment.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The mood assessment sits directly under the first chart** on the mood insights page.
|
||||
|
||||
## [1.8.6] — 2026-06-01 — Compliance two-row return, insights polish, targets deprecation
|
||||
|
||||
### Added
|
||||
|
||||
- **Mood narrative takeaways above the charts** — a ranked feed of plain-language findings (the weekday your mood dips, the overall trend, the tags that lift or lower it, your in-target share, your logging streak, the weekend effect), now drawing on the structured tags as well as free-text ones.
|
||||
- **A GLP-1 dose-strength curve** that plots the titration history (2.5 → 5 → 7.5 mg …) in place of the previous empty therapy-course block.
|
||||
- **A FHIR `Coverage` resource in the health-record export**, with an optional insurer institution number (IKNR) captured on the profile — making the insurer machine-resolvable for German systems.
|
||||
- **An optional `source` on the measurements batch endpoint**, so a client backfilling manually-entered history can tag it correctly instead of defaulting to Apple Health.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Medication compliance returns to two rows for every medication.** The cadence-aware timeline is gone; the two windows now scale to how often a medication is due — a daily medication keeps 7-day and 30-day, a rarely-taken one steps up to longer windows (up to twelve months) so both rows stay meaningful and the layout stays steady.
|
||||
- **The medication advanced settings are a single, calmer column** with a symmetric danger zone (matched delete-history / delete-medication actions), replacing the cramped two-column layout.
|
||||
- **The medication wizard uses a jumpable dot stepper** with a "next step" hint; in edit mode every step is reachable in one tap.
|
||||
- **Insights category headers are quieter.** The question-mark circle is gone (the metric definition stays inline beneath the heading), the measurement-diversity hint moved to a heading lightbulb with the explanation on hover, the assessment border is de-emphasised, and "Coach fragen" moved to the header as an icon.
|
||||
- **Target ranges are now edited inline from Insights.** The Targets page is deprecated — it carries a notice, is removed from the menu, and will be retired in a future release; editing happens where you read the trend.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Every briefing-driven Trend card now shows its caption** — additive metrics no longer render a chart with empty space below it.
|
||||
- **A long mood note no longer stretches the entries table** — it truncates with the full text on hover.
|
||||
- **The mood in-target share is shown once**, not duplicated between the headline tile and the takeaway feed.
|
||||
- **Medication compliance no longer counts days before a medication existed**, so a newly-added medication's expected-dose figures reflect its real age.
|
||||
|
||||
## [1.8.5] — 2026-06-01 — Insights reference panels, mood depth, injection tracking
|
||||
|
||||
### Added
|
||||
|
||||
- **A target reference panel on every Insights category page.** Each metric now shows the target band, where the latest reading sits inside it, a plain-language status, the guideline source behind the range, the 30-day average and a seven-day consistency strip — the same reference the Targets page carries, now on the page where you read the trend. Blood glucose surfaces one panel per context (fasting, post-meal, random, bedtime).
|
||||
- **A statistics strip per metric** — minimum, maximum, median and mean — plus a dedicated page that lists every underlying reading with edit and delete, reachable from each Insights category.
|
||||
- **Injection-site tracking and rotation.** Opt-in per medication (injections only): record the site after a dose, see the history on a body map, and get a rotation suggestion. Sites can be restricted per medication, and an account-level exclusion always wins.
|
||||
- **Structured mood tags and a per-entry note**, with a notes timeline so the context behind a mood is kept alongside the score.
|
||||
- **A richer mood page** — calendar heatmap, score distribution, weekday pattern, and correlations with sleep and activity.
|
||||
- **A measurement-diversity nudge** that suggests logging at other times or days when readings cluster, for a fuller picture.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The medication compliance display adapts to how often a dose is due** — a percentage for frequent schedules, a dose-by-dose adherence timeline for sparse ones, so an every-few-weeks medication reads sensibly.
|
||||
- **The Trends section now follows the daily briefing**, charting the metrics the briefing actually flags rather than a fixed set.
|
||||
- **Tightened the Insights category pages and rebalanced the overview** for a denser, calmer layout.
|
||||
- **Gait and mobility metrics from Apple Health are consolidated to a daily mean overnight**, while discrete clinical readings (weight, blood pressure, glucose, mood, pulse) stay raw.
|
||||
- **The health-record FHIR export codes HealthKit-only metrics under a dedicated code system** instead of the LOINC namespace, for FHIR R4 conformance and alignment with the native client.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A rolling-interval medication now surfaces its first dose at the start date** — including when that date is already in the past — so the dashboard and reminders no longer skip the opening dose.
|
||||
- **The medication wizard no longer shows each time-of-day suggestion twice.**
|
||||
- **Deleting a medication no longer hangs on a spinner.**
|
||||
- **Mood entries with tags save atomically** — the entry and its tags commit together, and create and update return the saved tags immediately rather than after a refresh.
|
||||
- **Accessibility:** the injection-site picker has a visible focus state and screen-reader labels; mood tag chips and the status pill meet the contrast and minimum tap-target guidelines.
|
||||
|
||||
## [1.8.4] — 2026-06-01 — Medication card accuracy + Insights polish
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The medication card shows the correct next intake for every schedule.** Flexible "interval after the last dose" and one-time medications previously showed a next-intake derived from a simplified day-of-week rule that ignored the interval and the last logged dose — so a medication taken every few weeks proposed tomorrow and never moved after a dose was logged. Both the standard and the injection card now read the schedule engine's computed next-due, so the next intake is right on creation and advances the moment a dose is recorded.
|
||||
- **Insights status polling is bounded.** When a model is configured but generation stalls, the per-category assessment card no longer polls indefinitely on an open page — it stops after a fixed ceiling and shows its static state.
|
||||
- **Clean deploys no longer hit a missing settings column.** Four `app_settings` columns existed in the schema but had only ever been applied ad-hoc, so a migrations-only deploy could fail on first read; an idempotent migration now adds them (a no-op on instances that already have them).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Logging a dose confirms itself.** Marking a medication taken or skipped now shows a brief confirmation, and the card's next-intake updates immediately.
|
||||
- **Each Insights category explains itself inline.** The short metric definition now also appears as a caption under the category heading, alongside the existing question-mark tooltip.
|
||||
- **Medication history shows where each dose was logged** — web, the iOS app, a reminder, or an import — as a small origin label on each entry.
|
||||
|
||||
## [1.8.3] — 2026-06-01 — Insights no longer block the interface
|
||||
|
||||
Opening an Insights category and switching between them could freeze the whole interface for several seconds while the per-category assessment was generated. The interface now stays responsive throughout.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Switching Insights categories no longer freezes the interface.** Each category's short assessment is now served from cache as an immediate, read-only response; when it is not yet ready the page shows a brief preparing state and the assessment is generated in the background, so navigation and taps stay responsive. The client request is capped, so a slow or unreachable model can never block the interface — the worst case is a preparing state, never a frozen screen.
|
||||
- **The overnight pre-generation warms the assessment actually shown.** The nightly warm pass keyed its cache differently from what the page requests, so a returning visit often fell back to an on-demand generation; it now warms the languages the interface uses and runs even when the daily summary was already cached, so a returning visit lands on a ready assessment.
|
||||
- **A stalled model no longer re-blocks every visit.** A generation timeout is briefly remembered so the next visits return immediately instead of retrying the slow path each time.
|
||||
- **Streamed loading states.** Insights and the dashboard now render a skeleton while their first segment loads, rather than waiting on a blank transition.
|
||||
|
||||
## [1.8.2] — 2026-06-01 — One intake row per dose slot
|
||||
|
||||
A multi-time medication could end up with two entries for the same dose — one still open, one marked taken — for a dose the user had not taken. This release guarantees a single entry per scheduled dose.
|
||||
|
||||
@@ -18,11 +18,13 @@ If a draft commit message or release note would violate one of the three, rewrit
|
||||
|
||||
## Branch and release model
|
||||
|
||||
- `main` is release-only. Every commit on `main` is either a release commit (`chore(release): vX.Y.Z`), the matching tag push, or a maintenance commit (docs, gitignore, audit findings) that the maintainer chose to apply directly.
|
||||
- Feature work targets the `develop` branch via topic branches (`fix/X`, `feat/X`), merged into `develop` through PRs with green CI.
|
||||
- Releases merge `develop` → `main` (squash or merge-commit, document each), then a tag push (`v1.X.Y`) triggers the `docker-publish.yml` workflow.
|
||||
- Hotfixes branch from `main`, merge back into both `main` and `develop`.
|
||||
- Never force-push `main`. Never skip hooks (`--no-verify`). Never `--no-gpg-sign` unless explicitly asked.
|
||||
`main` is the single trunk and is always releasable. There is no long-lived `develop` branch — the former one was retired and archived as the tag `archive/develop-pre-v1.5` (it carried no work absent from `main`; a long-lived integration branch has no role in a single-maintainer shop and only accrued back-merge debt). This is trunk-based development with release tags: short-lived branches off `main`, work always converges back onto `main`, and the immutable `vX.Y.Z` image built from a tag on `main` is the one source of truth — including for the OpenAPI contract the iOS client consumes.
|
||||
|
||||
- **Trunk.** `main` always builds, passes CI, and is deployable. Every commit on it is a release commit (`chore(release): vX.Y.Z`), a release-branch merge, or a maintenance commit (docs, gitignore, audit findings) applied directly.
|
||||
- **A release** is built on a short-lived `release/vX.Y.Z` branch cut off `main` — built wave-by-wave, then opened as a PR `release/vX.Y.Z` → `main` with green CI, merged (merge-commit), and tagged `vX.Y.Z`. The tag push triggers `docker-publish.yml` (GHCR multi-arch + `:latest`). Deploy is manual (see `docs/ops/deploy.md`); after a deploy, verify `/api/version` (it returns `version` + `buildSha` + `builtAt`) on every target, since the `:latest` pull is the source of truth, not the queued deploy status. Delete the release branch after merge.
|
||||
- **Small standalone changes** (a single fix, a docs touch) can go through a short-lived `fix/X` / `feat/X` branch + PR, or directly onto `main` when the maintainer chooses — `main` is the target either way.
|
||||
- **Hotfixes** branch from `main` and merge straight back to `main`. There is no second long-lived line to forward-port to, so the old back-merge debt cannot recur by construction.
|
||||
- Never force-push `main` or any release branch. Never skip hooks (`--no-verify`). Never `--no-gpg-sign` unless explicitly asked.
|
||||
|
||||
## Semver
|
||||
|
||||
|
||||
+8
-1
@@ -18,7 +18,14 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public"
|
||||
# connection_limit is set explicitly: Prisma's own default is
|
||||
# cpu_count*2+1, which starves the combined web+worker process
|
||||
# (HEALTHLOG_PROCESS_TYPE=all) under background AI/rollup load. Default
|
||||
# 20 suits a small host; raise DB_CONNECTION_LIMIT on a busy one (keep it
|
||||
# below the database's max_connections, leaving headroom for pg-boss,
|
||||
# migrations, and backups). pool_timeout is the seconds a query waits for
|
||||
# a free connection before erroring.
|
||||
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:-}"
|
||||
|
||||
+1461
-2
File diff suppressed because it is too large.
Load diff
@@ -59,3 +59,20 @@ roll back:
|
||||
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
|
||||
commit, and re-deploy. Never `migrate resolve --rolled-back` against
|
||||
production unless you've taken a fresh `pg_dump` first.
|
||||
|
||||
## Verify the served version
|
||||
|
||||
The queued-deploy status is **not** the source of truth — the version
|
||||
`/api/version` actually answers is. A BuildKit layer-cache hit can
|
||||
re-ship the prior image even when Coolify reports a clean deploy, so
|
||||
always confirm the served build after a deploy:
|
||||
|
||||
```sh
|
||||
# Asserts prod + demo serve 1.9.0, prints each target's buildSha,
|
||||
# exits non-zero on a mismatch or timeout after a bounded retry loop.
|
||||
pnpm dlx tsx scripts/assert-deploy.ts 1.9.0
|
||||
```
|
||||
|
||||
Pass `--only=prod` / `--only=demo` to check a single target, or
|
||||
`--attempts=N --interval=ms` to widen the retry window while a slow
|
||||
pull settles.
|
||||
@@ -217,10 +217,16 @@ export async function expectStep(
|
||||
displayIndex: number,
|
||||
totalSteps?: number,
|
||||
): Promise<void> {
|
||||
const pattern = totalSteps
|
||||
? new RegExp(`Schritt ${displayIndex} von ${totalSteps}`, "i")
|
||||
: new RegExp(`Schritt ${displayIndex} von \\d+`, "i");
|
||||
await expect(page.getByText(pattern)).toBeVisible({ timeout: 5_000 });
|
||||
// The "Schritt X von Y" caption is mobile-only (`sm:hidden`) since the dot
|
||||
// stepper landed, so it is hidden on the desktop e2e viewport. Assert the
|
||||
// viewport-independent root data-attributes the dialog always carries.
|
||||
const root = page.locator('[data-slot="medication-wizard-dialog"]');
|
||||
await expect(root).toHaveAttribute("data-display-step", String(displayIndex), {
|
||||
timeout: 5_000,
|
||||
});
|
||||
if (totalSteps != null) {
|
||||
await expect(root).toHaveAttribute("data-total-steps", String(totalSteps));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -37,6 +37,7 @@ function summary(partial: Partial<DataSummary>): DataSummary {
|
||||
min: null,
|
||||
max: null,
|
||||
mean: null,
|
||||
median: null,
|
||||
avg7: null,
|
||||
avg30: null,
|
||||
slope7: null,
|
||||
|
||||
@@ -8,13 +8,13 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
*
|
||||
* Two contracts under test:
|
||||
*
|
||||
* 1. Every routed Insights sub-page mounts a `<CoachLaunchButton>`.
|
||||
* The component renders BOTH a sticky-bottom FAB
|
||||
* (`data-slot="coach-launch-fab"`, visible on `<lg`) and an
|
||||
* inline action (`data-slot="coach-launch-inline"`, visible on
|
||||
* `lg+`). The DOM carries both nodes; CSS picks the visible one.
|
||||
* The spec asserts the FAB renders on Pixel-5 and the inline
|
||||
* action renders on Desktop Chrome.
|
||||
* 1. Every routed Insights sub-page mounts a Coach launch surface.
|
||||
* The layout renders a sticky-bottom FAB
|
||||
* (`data-slot="coach-launch-fab"`, visible on `<lg`); v1.8.6 moved
|
||||
* the per-page action into the sub-page header as an icon-only
|
||||
* button (`data-slot="coach-launch-icon"`, mounted by the shell at
|
||||
* heading height across breakpoints). The spec asserts the FAB
|
||||
* renders on Pixel-5 and the header icon renders on Desktop Chrome.
|
||||
*
|
||||
* 2. Opening the Coach drawer on `<sm` mounts it as a bottom-sheet
|
||||
* (`data-variant="bottom-sheet"`). On `>=sm` it mounts as a
|
||||
@@ -121,28 +121,23 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
|
||||
.locator('[data-slot="coach-launch-fab"]')
|
||||
.first();
|
||||
await expect(fab).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The inline branch is in the DOM but hidden via `hidden lg:inline-flex`.
|
||||
const inline = page.locator('[data-slot="coach-launch-inline"]').first();
|
||||
await expect(inline).toBeHidden();
|
||||
});
|
||||
|
||||
test(`/insights/${slug} mounts the Coach launch button on Desktop Chrome (inline branch)`, async ({
|
||||
test(`/insights/${slug} mounts the header Coach icon on Desktop Chrome`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
// Desktop Chrome viewport is 1280 — `lg` (1024) is matched so the
|
||||
// inline branch wins; the FAB is hidden by `lg:hidden`.
|
||||
// layout FAB is hidden by `lg:hidden`; the header icon the shell
|
||||
// mounts is the Coach entry on the page itself.
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-desktop",
|
||||
"desktop inline branch",
|
||||
"desktop header-icon branch",
|
||||
);
|
||||
|
||||
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
|
||||
|
||||
const inline = page
|
||||
.locator('[data-slot="coach-launch-inline"]')
|
||||
.first();
|
||||
await expect(inline).toBeVisible({ timeout: 10_000 });
|
||||
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
|
||||
await expect(icon).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const fab = page.locator('[data-slot="coach-launch-fab"]').first();
|
||||
await expect(fab).toBeHidden();
|
||||
@@ -168,7 +163,7 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
|
||||
await expect(drawer).toHaveAttribute("data-variant", "bottom-sheet");
|
||||
});
|
||||
|
||||
test("clicking the inline action opens the Coach drawer as a side-sheet on Desktop Chrome", async ({
|
||||
test("clicking the header Coach icon opens the Coach drawer as a side-sheet on Desktop Chrome", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
@@ -178,11 +173,9 @@ test.describe("v1.4.27 — Coach reachability on insights sub-pages", () => {
|
||||
|
||||
await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const inline = page
|
||||
.locator('[data-slot="coach-launch-inline"]')
|
||||
.first();
|
||||
await expect(inline).toBeVisible({ timeout: 10_000 });
|
||||
await inline.click();
|
||||
const icon = page.locator('[data-slot="coach-launch-icon"]').first();
|
||||
await expect(icon).toBeVisible({ timeout: 10_000 });
|
||||
await icon.click();
|
||||
|
||||
const drawer = page.locator('[data-slot="coach-drawer"]');
|
||||
await expect(drawer).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
+247
-85
@@ -461,7 +461,49 @@
|
||||
"deleteConfirmTitle": "Eintrag löschen?",
|
||||
"deleteConfirmDescription": "Dieser Stimmungseintrag wird unwiderruflich gelöscht.",
|
||||
"pageInfo": "Seite {page} von {total}",
|
||||
"loginRequired": "Bitte anmelden, um Stimmungseinträge zu sehen."
|
||||
"loginRequired": "Bitte anmelden, um Stimmungseinträge zu sehen.",
|
||||
"note": "Notiz",
|
||||
"notePlaceholder": "Notiz dazu, wie du dich fühlst…",
|
||||
"tagPicker": "Tags",
|
||||
"tagCategory": {
|
||||
"feelings": "Gefühle",
|
||||
"sleep": "Schlaf",
|
||||
"health": "Gesundheit",
|
||||
"social": "Soziales",
|
||||
"work": "Arbeit"
|
||||
},
|
||||
"tag": {
|
||||
"happy": "glücklich",
|
||||
"excited": "aufgeregt",
|
||||
"grateful": "dankbar",
|
||||
"relaxed": "entspannt",
|
||||
"content": "zufrieden",
|
||||
"tired": "müde",
|
||||
"unsure": "unsicher",
|
||||
"bored": "gelangweilt",
|
||||
"tense": "angespannt",
|
||||
"angry": "wütend",
|
||||
"stressed": "gestresst",
|
||||
"sad": "traurig",
|
||||
"sleptWell": "gut geschlafen",
|
||||
"sleptOk": "mäßig geschlafen",
|
||||
"sleptPoorly": "schlecht geschlafen",
|
||||
"earlyNight": "früh im Bett",
|
||||
"workedOut": "trainiert",
|
||||
"ateWell": "gesund gegessen",
|
||||
"hydrated": "Wasser getrunken",
|
||||
"walked": "spazieren",
|
||||
"alcohol": "Alkohol",
|
||||
"family": "Familie",
|
||||
"friends": "Freunde",
|
||||
"party": "Party",
|
||||
"alone": "allein",
|
||||
"productive": "produktiv",
|
||||
"overtime": "Überstunden",
|
||||
"dayOff": "Feierabend",
|
||||
"travel": "Reise",
|
||||
"sickDay": "Krankheitstag"
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
"title": "Medikamente",
|
||||
@@ -544,11 +586,14 @@
|
||||
"veryOverdue": "Stark überfällig",
|
||||
"compliance7d": "7-Tage-Compliance",
|
||||
"compliance30d": "30-Tage-Compliance",
|
||||
"complianceWindow": "{days}-Tage-Compliance",
|
||||
"lastIntake": "Letzte Einnahme:",
|
||||
"nextIntake": "Nächste Einnahme:",
|
||||
"dayStreak": "Tage Serie",
|
||||
"taken": "Genommen",
|
||||
"skipped": "Übersprungen",
|
||||
"intakeToastTaken": "{name} als genommen erfasst",
|
||||
"intakeToastSkipped": "{name} übersprungen",
|
||||
"weekdaySunday": "Sonntag",
|
||||
"weekdayMonday": "Montag",
|
||||
"weekdayTuesday": "Dienstag",
|
||||
@@ -759,7 +804,11 @@
|
||||
"back": "Zurück",
|
||||
"next": "Weiter",
|
||||
"save": "Speichern",
|
||||
"saveEdit": "Änderungen speichern"
|
||||
"saveEdit": "Änderungen speichern",
|
||||
"nextHint": "Weiter: {step}",
|
||||
"reviewHint": "Weiter: Speichern",
|
||||
"jumpFirst": "Zum ersten Schritt springen",
|
||||
"jumpLast": "Zur Übersicht und zum Speichern springen"
|
||||
},
|
||||
"nl": {
|
||||
"button": "Beschreiben"
|
||||
@@ -769,11 +818,13 @@
|
||||
"title": "Wie heißt das Medikament?",
|
||||
"subline": "Genau so wie es auf der Packung steht.",
|
||||
"label": "Name",
|
||||
"placeholder": "z. B. Ramipril"
|
||||
"placeholder": "z. B. Ramipril",
|
||||
"short": "Name"
|
||||
},
|
||||
"step2": {
|
||||
"title": "Was für ein Medikament ist das?",
|
||||
"subline": "Wir nutzen das für passende Vorlagen und Auswertungen."
|
||||
"subline": "Wir nutzen das für passende Vorlagen und Auswertungen.",
|
||||
"short": "Art"
|
||||
},
|
||||
"step3": {
|
||||
"title": "Wie hoch ist die Dosis?",
|
||||
@@ -805,15 +856,18 @@
|
||||
"dosesPerUnitPlaceholder": "z. B. 4",
|
||||
"dosesPerUnitHint": "Dosen pro Pen / Ampulle — leer lassen, wenn kein Bestand getrackt wird.",
|
||||
"injectionSiteToggle": "Injektionsstellen-Rotation",
|
||||
"injectionSiteHint": "Optionaler Rotationshelfer. Die Stelle jeder Dosis wird beim Eintragen der Einnahme erfasst."
|
||||
"injectionSiteHint": "Optionaler Rotationshelfer. Die Stelle jeder Dosis wird beim Eintragen der Einnahme erfasst.",
|
||||
"short": "Dosis"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Über welchen Zeitraum nimmst du es?",
|
||||
"subline": "Heute reicht ein Startdatum. Ein Ende kannst du später setzen."
|
||||
"subline": "Heute reicht ein Startdatum. Ein Ende kannst du später setzen.",
|
||||
"short": "Zeitraum"
|
||||
},
|
||||
"step5": {
|
||||
"title": "Wie oft nimmst du es?",
|
||||
"subline": "Wähle die Regelmäßigkeit — die Details kommen im nächsten Schritt."
|
||||
"subline": "Wähle die Regelmäßigkeit — die Details kommen im nächsten Schritt.",
|
||||
"short": "Takt"
|
||||
},
|
||||
"step6": {
|
||||
"title": "Wie sieht das im Detail aus?",
|
||||
@@ -829,7 +883,8 @@
|
||||
"rollingDays": {
|
||||
"label": "Alle",
|
||||
"suffix": "Tage ab der letzten Einnahme"
|
||||
}
|
||||
},
|
||||
"short": "Details"
|
||||
},
|
||||
"step7": {
|
||||
"title": "Wann nimmst du es?",
|
||||
@@ -840,14 +895,16 @@
|
||||
"noon": "Mittags",
|
||||
"evening": "Abends",
|
||||
"night": "Nachts"
|
||||
}
|
||||
},
|
||||
"short": "Zeiten"
|
||||
},
|
||||
"step8": {
|
||||
"title": "Fertig — soll HealthLog dich erinnern?",
|
||||
"subline": "Du kannst alles unten nochmal prüfen, bevor du speicherst.",
|
||||
"remindersLabel": "Erinnerungen aktiv",
|
||||
"remindersDescription": "Push, Telegram oder ntfy — je nach deinen Einstellungen.",
|
||||
"multiScheduleNote": "Dieses Medikament hat mehrere Zeitpläne. Den Detail-Editor öffnen wir in einer späteren Version."
|
||||
"multiScheduleNote": "Dieses Medikament hat mehrere Zeitpläne. Den Detail-Editor öffnen wir in einer späteren Version.",
|
||||
"short": "Fertig"
|
||||
}
|
||||
},
|
||||
"classRow": {
|
||||
@@ -923,26 +980,17 @@
|
||||
"submitFailed": "Medikament konnte nicht gespeichert werden — bitte erneut versuchen."
|
||||
}
|
||||
},
|
||||
"titration": {
|
||||
"section": "Titrationsstufen",
|
||||
"loading": "Lade Titrationsdaten",
|
||||
"loadFailed": "Titrationsdaten konnten nicht geladen werden.",
|
||||
"drugLadderHeader": "Standard-Schema (EMA-Referenz)",
|
||||
"typicalWeeksOnStep": "≥ {weeks} Wo. typisch",
|
||||
"youAreHere": "Hier stehst du",
|
||||
"nextStepCaption": "Nächste Stufe (typisch): {dose} mg nach ~{weeks} Wochen auf der aktuellen Stufe.",
|
||||
"ceilingMessage": "Du bist auf der höchsten Standardstufe.",
|
||||
"escalationDueHint": "Du bist seit {weeks} Wochen auf dieser Stufe; das Schema sieht eine Steigerung typischerweise nach ~{typical} Wochen vor.",
|
||||
"currentStepDwell": "Seit {weeks} Wochen auf dieser Stufe",
|
||||
"doseUnitMg": "mg",
|
||||
"emptyState": "Noch keine Dosis-Historie. Erfasse eine Dosis, um deine Position im Schema zu sehen.",
|
||||
"nonStandardDose": "Aktuelle Dosis liegt außerhalb des Standard-Schemas.",
|
||||
"disclaimer": "EMA-Referenzwerte. Sprich mit deiner Ärztin oder deinem Arzt, bevor du Dosen änderst.",
|
||||
"emaSourceCta": "EMA-Quelle",
|
||||
"stepLabel": "Stufe {n}"
|
||||
"doseStrength": {
|
||||
"title": "Dosisstärke im Zeitverlauf",
|
||||
"axisLabel": "Erfasste Dosis ({unit})",
|
||||
"tooltipLabel": "Dosis",
|
||||
"currentDose": "Aktuelle Dosis: {dose} {unit}",
|
||||
"noCurrentDose": "Noch keine Dosis erfasst.",
|
||||
"empty": "Noch keine Titrations-Historie. Erfasse eine Dosisänderung, um die Stärkekurve zu sehen."
|
||||
},
|
||||
"injectionSiteBodyOutlineAriaLabel": "Körperumriss",
|
||||
"injectionSitePickerAriaLabel": "Injektionsstelle auswählen",
|
||||
"injectionSiteRecommendedAriaLabel": "{site} – empfohlene nächste Stelle",
|
||||
"siteAbdomenLeft": "Bauch, unten links",
|
||||
"siteAbdomenRight": "Bauch, unten rechts",
|
||||
"siteAbdomenUpperLeft": "Bauch, oben links",
|
||||
@@ -1004,10 +1052,11 @@
|
||||
"phaseResetDefaults": "Auf Defaults zurücksetzen",
|
||||
"phaseSaved": "Erinnerungsphasen gespeichert",
|
||||
"phaseReset": "Auf Defaults zurückgesetzt",
|
||||
"sourceWeb": "Webseite",
|
||||
"sourceApi": "Externe API",
|
||||
"sourceReminder": "Telegram / Erinnerung",
|
||||
"sourceImport": "CSV-Import",
|
||||
"sourceWeb": "Web",
|
||||
"sourceApi": "iOS-App",
|
||||
"sourceReminder": "Erinnerung",
|
||||
"sourceImport": "Import",
|
||||
"intakeSourceVia": "über {origin}",
|
||||
"intakeHistoryEmptyTitle": "Noch keine Einnahmen erfasst",
|
||||
"intakeHistoryEmptyDescription": "Trage eine Einnahme auf der Tagesübersicht ein, um den Verlauf aufzubauen.",
|
||||
"intakeHistoryEmptyAction": "Zur Tagesübersicht",
|
||||
@@ -1078,11 +1127,6 @@
|
||||
"emptyState": "Noch keine Einnahmen erfasst.",
|
||||
"emptyStateCta": "Trage eine Dosis ein, damit die geschätzte Verlaufskurve Gestalt annimmt.",
|
||||
"estimateNote": "Edukative Schätzung aus EMA-publizierter Populations-Pharmakokinetik. Keine Messung.",
|
||||
"gatedTitle": "Forschungsmodus ist aus",
|
||||
"gatedBody": "Aktiviere den Forschungsmodus in den Einstellungen, um die geschätzte Wirkstoffkurve zu sehen. Die Kurve ist opt-in, weil sie eine Lernhilfe und keine klinische Messung ist.",
|
||||
"gatedStaleTitle": "Disclaimer muss neu bestätigt werden",
|
||||
"gatedStaleBody": "Der Disclaimer des Forschungsmodus wurde seit deiner letzten Bestätigung aktualisiert. Lies ihn in den Einstellungen erneut und bestätige, um die Kurve wieder einzublenden.",
|
||||
"gatedCta": "Forschungsmodus in den Einstellungen öffnen",
|
||||
"unknownDrug": "{name} ist nicht im EMA-zugelassenen GLP-1-Katalog enthalten. HealthLog kann dafür keine Populations-Pharmakokinetik-Kurve erzeugen."
|
||||
}
|
||||
},
|
||||
@@ -1170,6 +1214,14 @@
|
||||
"unit": "Minuten",
|
||||
"saved": "Erinnerungsfenster gespeichert",
|
||||
"failed": "Erinnerungsfenster konnte nicht gespeichert werden."
|
||||
},
|
||||
"codes": {
|
||||
"label": "Klinische Codes (ATC / RxNorm)",
|
||||
"note": "Optional. Werden beim FHIR-Export der Gesundheitsakte verwendet. Codes werden genau wie eingegeben übernommen — nie geraten.",
|
||||
"atcLabel": "ATC",
|
||||
"rxNormLabel": "RxNorm",
|
||||
"saved": "Klinische Codes gespeichert",
|
||||
"failed": "Klinische Codes konnten nicht gespeichert werden."
|
||||
}
|
||||
},
|
||||
"zone": {
|
||||
@@ -1235,7 +1287,7 @@
|
||||
"data": "Daten",
|
||||
"reminders": "Erinnerungen",
|
||||
"lifecycle": "Lebenszyklus",
|
||||
"danger": "Gefahrenzone"
|
||||
"externalApi": "Externe API"
|
||||
},
|
||||
"dataPortability": {
|
||||
"import": {
|
||||
@@ -1258,10 +1310,18 @@
|
||||
"history": {
|
||||
"back": "Zurück zum Medikament",
|
||||
"subtitle": "Vollständiger Einnahmeverlauf",
|
||||
"plannedSuffix": "geplant",
|
||||
"drugLevelDisclosure": "Geschätzte Wirkstoffkurve anzeigen"
|
||||
"plannedSuffix": "geplant"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logInjectionSiteTitle": "Injektionsstelle erfassen",
|
||||
"logInjectionSiteDescription": "Wo hast du {name} gespritzt? Optional — du kannst dies überspringen.",
|
||||
"logInjectionSiteSkip": "Überspringen",
|
||||
"logInjectionSiteConfirm": "Stelle speichern",
|
||||
"logInjectionSiteNoneAvailable": "Keine Injektionsstelle verfügbar — alle Stellen sind in deinen Einstellungen ausgeschlossen.",
|
||||
"trackInjectionSitesToggle": "Injektionsstellen erfassen",
|
||||
"trackInjectionSitesHint": "Nach einer eingenommenen Dosis nach der verwendeten Stelle fragen und die nächste Rotation vorschlagen.",
|
||||
"allowedSitesLabel": "Erlaubte Stellen",
|
||||
"allowedSitesHint": "Alle ausgewählt lassen für keine Einschränkung. Global ausgeschlossene Stellen werden nie angeboten."
|
||||
},
|
||||
"charts": {
|
||||
"loadingLabel": "Diagramm wird geladen…",
|
||||
@@ -1272,6 +1332,13 @@
|
||||
"bodyFat": "Körperfett",
|
||||
"sleep": "Schlaf",
|
||||
"steps": "Schritte",
|
||||
"hrv": "HRV",
|
||||
"restingHeartRate": "Ruhepuls",
|
||||
"activeEnergy": "Aktive Energie",
|
||||
"flights": "Treppen",
|
||||
"distance": "Geh- + Laufstrecke",
|
||||
"vo2Max": "VO₂max",
|
||||
"bodyTemperature": "Körpertemperatur",
|
||||
"noData": "Keine Daten im gewählten Zeitraum",
|
||||
"bucketWeekly": "Wochendurchschnitt",
|
||||
"bucketMonthly": "Monatsdurchschnitt",
|
||||
@@ -1336,7 +1403,17 @@
|
||||
"personalBaseline": "Dein Mittel",
|
||||
"deltaVsBaseline": "{delta} vs. dein Mittel",
|
||||
"deltaUnchanged": "entspricht deinem Mittel",
|
||||
"deltaVsTarget": "{delta} vs. Ziel"
|
||||
"deltaVsTarget": "{delta} vs. Ziel",
|
||||
"weekdaysFull": {
|
||||
"mon": "Montag",
|
||||
"tue": "Dienstag",
|
||||
"wed": "Mittwoch",
|
||||
"thu": "Donnerstag",
|
||||
"fri": "Freitag",
|
||||
"sat": "Samstag",
|
||||
"sun": "Sonntag"
|
||||
},
|
||||
"rangeBand": "Bereich"
|
||||
},
|
||||
"chart": {
|
||||
"overlay": {
|
||||
@@ -1382,6 +1459,7 @@
|
||||
"bmiEmptyDescription": "Für den BMI wird deine Körpergröße benötigt. Hinterlege sie unter Einstellungen → Account.",
|
||||
"bmiEmptyAction": "Account-Einstellungen öffnen",
|
||||
"noAnalysisYet": "Noch keine Analyse vorhanden.",
|
||||
"assessmentPreparing": "Einschätzung wird vorbereitet…",
|
||||
"noProviderConfigured": "Auswertung nicht verfügbar.",
|
||||
"startAnalysis": "Analyse starten",
|
||||
"refreshAnalysis": "Analyse aktualisieren",
|
||||
@@ -1432,6 +1510,8 @@
|
||||
"heroRegenerating": "Wird generiert",
|
||||
"regenerateAnalysis": "Analyse neu starten",
|
||||
"regenerateSuccess": "Analyse wurde neu erstellt",
|
||||
"warmAssessments": "Auswertungen vorbereiten",
|
||||
"warmStarted": "Auswertungen werden im Hintergrund erstellt",
|
||||
"heroFallbackSubtitle": "Täglicher Blick auf deine Trends — direkt aus den Zahlen, die du protokollierst.",
|
||||
"heroGreetingMorning": "Guten Morgen",
|
||||
"heroGreetingAfternoon": "Guten Tag",
|
||||
@@ -1498,7 +1578,22 @@
|
||||
},
|
||||
"trendsRow": {
|
||||
"title": "Trends",
|
||||
"subtitle": "Die letzten 30 Tage auf einen Blick"
|
||||
"subtitle": "Die letzten 30 Tage auf einen Blick",
|
||||
"caption": {
|
||||
"bp": "Systolische und diastolische Werte der letzten 30 Tage.",
|
||||
"weight": "Gewichtsverlauf der letzten 30 Tage.",
|
||||
"pulse": "Ruhepuls der letzten 30 Tage.",
|
||||
"mood": "Täglicher Stimmungswert der letzten 30 Tage.",
|
||||
"sleep": "Nächtliche Schlafdauer der letzten 30 Tage.",
|
||||
"steps": "Tägliche Schrittzahl der letzten 30 Tage.",
|
||||
"hrv": "Herzratenvariabilität der letzten 30 Tage.",
|
||||
"resting_hr": "Ruheherzfrequenz der letzten 30 Tage.",
|
||||
"active_energy": "Aktiv verbrauchte Energie der letzten 30 Tage.",
|
||||
"flights": "Erklommene Stockwerke der letzten 30 Tage.",
|
||||
"distance": "Geh- und Laufstrecke der letzten 30 Tage.",
|
||||
"vo2_max": "VO₂max der letzten 30 Tage.",
|
||||
"body_temp": "Körpertemperatur der letzten 30 Tage."
|
||||
}
|
||||
},
|
||||
"vo2Max": {
|
||||
"statLatest": "Aktuell",
|
||||
@@ -1515,6 +1610,23 @@
|
||||
"confidenceLow": "Geringe Konfidenz",
|
||||
"pendingLabel": "Trend-Kommentar wird generiert"
|
||||
},
|
||||
"range": {
|
||||
"label": "Zeitraum",
|
||||
"noPriorData": "keine Daten im Vorzeitraum",
|
||||
"option": {
|
||||
"7d": "7T",
|
||||
"30d": "30T",
|
||||
"90d": "90T",
|
||||
"1y": "1J"
|
||||
},
|
||||
"priorLabel": {
|
||||
"7d": "ggü. vorherigen 7T",
|
||||
"30d": "ggü. vorherigen 30T",
|
||||
"90d": "ggü. vorherigen 90T",
|
||||
"1y": "ggü. Vorjahr"
|
||||
},
|
||||
"noChange": "keine Änderung"
|
||||
},
|
||||
"correlationRow": {
|
||||
"title": "Zusammenhänge",
|
||||
"subtitle": "Vorab definierte Hypothesen, gegated auf n ≥ 14 und p < 0,05",
|
||||
@@ -1883,7 +1995,23 @@
|
||||
"bloodGlucoseBody": "Der Blutzucker ist die Zuckermenge in deinem Blut, die wichtigste Energiequelle des Körpers. Er steigt nach dem Essen und pendelt sich zwischen den Mahlzeiten ein; dauerhaft hohe Werte können auf Diabetes oder Prädiabetes hinweisen.",
|
||||
"skinTemperatureTitle": "Hauttemperatur",
|
||||
"skinTemperatureBody": "Die Hauttemperatur ist hier die kleine nächtliche Abweichung deiner Handgelenkstemperatur von deinem eigenen Ausgangswert, keine absolute Fiebermessung. Veränderungen können Schlaf, Umgebung, Krankheit oder Zyklusphase widerspiegeln."
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"label": "Zusammenfassende Statistik",
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"median": "Median",
|
||||
"mean": "Mittelwert"
|
||||
},
|
||||
"showAllValues": "Alle Werte anzeigen",
|
||||
"valuesPageTitle": "{metric} — alle Werte",
|
||||
"valuesPageDescription": "Jeder einzelne Wert hinter dieser Kennzahl. Einträge lassen sich direkt bearbeiten oder entfernen.",
|
||||
"diversity": {
|
||||
"trigger": "Messhinweis",
|
||||
"weekday": "Die meisten deiner {metric}-Werte fallen auf einen Wochentag. Messungen an anderen Tagen ergeben ein vollständigeres Bild.",
|
||||
"timeOfDay": "Die meisten deiner {metric}-Werte fallen auf eine ähnliche Tageszeit. Unterschiedliche Zeiten ergeben ein vollständigeres Bild."
|
||||
},
|
||||
"valuesBack": "Zurück zu Insights"
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Schlaf",
|
||||
@@ -2264,7 +2392,75 @@
|
||||
"description": "Werte erscheinen hier, sobald Apple Health synchronisiert."
|
||||
}
|
||||
},
|
||||
"navWalkingSpeed": "Gehgeschwindigkeit"
|
||||
"navWalkingSpeed": "Gehgeschwindigkeit",
|
||||
"mood": {
|
||||
"heatmapTitle": "Stimmungskalender",
|
||||
"heatmapNoEntry": "Kein Eintrag",
|
||||
"legendGreat": "Super",
|
||||
"legendGood": "Gut",
|
||||
"legendOkay": "Okay",
|
||||
"legendLow": "Niedrig",
|
||||
"distributionTitle": "Stimmungsverteilung",
|
||||
"distributionDaysUnit": "Tage",
|
||||
"weekdayTitle": "Durchschnitt nach Wochentag",
|
||||
"weekdayNoData": "Keine Daten",
|
||||
"weekdayBestWorst": "Bester Tag: {best} · Schwerster Tag: {worst}",
|
||||
"tagsTitle": "Tags und Auslöser",
|
||||
"tagAvgMood": "Durchschnittliche Stimmung mit diesem Tag",
|
||||
"correlationsTitle": "Stimmung und andere Werte",
|
||||
"correlationsDescription": "Wie sich deine Stimmung am selben Tag mit Schlaf, Aktivität und Puls bewegt.",
|
||||
"correlation": {
|
||||
"sleepTitle": "Stimmung vs. Schlaf",
|
||||
"stepsTitle": "Stimmung vs. Aktivität",
|
||||
"pulseTitle": "Stimmung vs. Puls",
|
||||
"sleepAxis": "Schlaf (Min.)",
|
||||
"stepsAxis": "Schritte",
|
||||
"pulseAxis": "Puls (bpm)",
|
||||
"strengthStrong": "Stark",
|
||||
"strengthModerate": "Moderat",
|
||||
"strengthWeak": "Schwach",
|
||||
"strengthNone": "Keine",
|
||||
"source": "{n} gepaarte Tage · r = {r}",
|
||||
"emptyTitle": "Noch nicht genug gepaarte Daten",
|
||||
"emptyDescription": "Erfasse Stimmung und diesen Wert an denselben Tagen, um einen Zusammenhang zu sehen.",
|
||||
"weightTitle": "Stimmung vs. Gewicht",
|
||||
"bloodPressureTitle": "Stimmung vs. Blutdruck",
|
||||
"weightAxis": "Gewicht",
|
||||
"bloodPressureAxis": "Systolisch (mmHg)"
|
||||
},
|
||||
"structuredTagsTitle": "Tag-Auswertung",
|
||||
"inTargetValue": "{pct} %",
|
||||
"inTargetCaption": "der letzten Tage im guten Stimmungsbereich",
|
||||
"narrative": {
|
||||
"title": "Das fällt auf",
|
||||
"weekdayDip": "Deine Stimmung sinkt am stärksten am {weekday} – {delta} unter deinem Wochenschnitt.",
|
||||
"weekdayPeak": "{weekday} ist durchgehend dein bester Tag mit einem Schnitt von {value}.",
|
||||
"trendUp": "Deine Stimmung steigt über die letzten {days} Tage (+{delta} gegenüber dem Vorzeitraum).",
|
||||
"trendDown": "Deine Stimmung sinkt über die letzten {days} Tage (-{delta} gegenüber dem Vorzeitraum).",
|
||||
"weekendUp": "Am Wochenende geht es dir besser (+{delta} gegenüber Wochentagen).",
|
||||
"weekendDown": "Am Wochenende ist deine Stimmung niedriger (-{delta} gegenüber Wochentagen).",
|
||||
"tagLift": "Tage mit „{tag}“ liegen im Schnitt {delta} über deiner allgemeinen Stimmung.",
|
||||
"tagDrop": "Tage mit „{tag}“ liegen {delta} unter deinem Durchschnitt.",
|
||||
"inTarget": "Du warst an {pct} % der letzten 30 Tage in deinem guten Stimmungsbereich.",
|
||||
"streak": "Du hast deine Stimmung {days} Tage in Folge erfasst – weiter so.",
|
||||
"timeOfDay": "Am {bucket} fühlst du dich am besten, im Schnitt {value}."
|
||||
},
|
||||
"timeOfDay": {
|
||||
"title": "Stimmung nach Tageszeit",
|
||||
"morning": "Morgen",
|
||||
"afternoon": "Nachmittag",
|
||||
"evening": "Abend",
|
||||
"night": "Nacht",
|
||||
"bestCaption": "Am {bucket} fühlst du dich tendenziell am besten."
|
||||
},
|
||||
"stability": {
|
||||
"value": "{score}/100",
|
||||
"bandVerySteady": "Deine Stimmung war sehr gleichmäßig",
|
||||
"bandSteady": "Deine Stimmung war recht gleichmäßig",
|
||||
"bandVariable": "Deine Stimmung war merklich schwankend",
|
||||
"bandVeryVariable": "Deine Stimmung hat zuletzt stark geschwankt"
|
||||
}
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Default",
|
||||
@@ -2476,7 +2672,7 @@
|
||||
"description": "Withings, moodLog und weitere verbundene Dienste."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Benachrichtigungs-Kanäle",
|
||||
"title": "Benachrichtigungen",
|
||||
"description": "Live-Übersicht aller konfigurierten Kanäle."
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -2484,13 +2680,12 @@
|
||||
"description": "Kachel-Layout und Reihenfolge."
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Zielwerte & Quellen",
|
||||
"description": "Pro Metrik: eigener Zielbereich und welche Quelle führt, wenn mehrere am selben Tag denselben Wert liefern."
|
||||
"title": "Zielwerte",
|
||||
"description": "Pro Metrik dein eigener Zielbereich für gesunde Werte."
|
||||
},
|
||||
"sources": {
|
||||
"title": "Quellen",
|
||||
"description": "Lege fest, welche Quelle führt, wenn mehrere am selben Tag dieselbe Metrik liefern.",
|
||||
"redirectNotice": "Quellen sind jetzt Teil von „Zielwerte & Quellen“.",
|
||||
"cardTitle": "Quellen-Priorität pro Metrik",
|
||||
"help": "Wenn mehrere Quellen denselben Wert am selben Tag liefern (z. B. Withings + Apple Health beide 1.200 Schritte), wird die oberste Quelle als kanonisch verwendet. Andere Quellen bleiben gespeichert für die Audit-Spur.",
|
||||
"resetDefaults": "Auf Standard zurücksetzen",
|
||||
@@ -2540,7 +2735,7 @@
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
"title": "Auswertungen",
|
||||
"title": "KI-Auswertungen",
|
||||
"description": "Provider, Modell, API-Key."
|
||||
},
|
||||
"api": {
|
||||
@@ -3022,7 +3217,9 @@
|
||||
"fhirNote": "Die strukturierte Ausgabe folgt dem FHIR-R4-Standard (ePA-kompatibel).",
|
||||
"generate": "Erstellen",
|
||||
"error": "Export fehlgeschlagen ({code})."
|
||||
}
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen",
|
||||
"globalExcludedInjectionSitesHint": "Hier gelistete Stellen werden für keine Medikation angeboten und beim Erfassen einer Dosis abgelehnt — auch wenn eine Medikation sie bevorzugt."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -3843,8 +4040,6 @@
|
||||
}
|
||||
},
|
||||
"targets": {
|
||||
"title": "Zielwerte",
|
||||
"introText": "Deine aktuellen Werte im Vergleich zu empfohlenen Zielbereichen. Quellen der Leitlinien sind auf jeder Karte verlinkt.",
|
||||
"bmi": "BMI",
|
||||
"average30d": "30-Tage-Schnitt:",
|
||||
"consistency": {
|
||||
@@ -3866,23 +4061,11 @@
|
||||
"sixDaysAgo": "vor 6 Tagen"
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"lastMet": "Zuletzt im Ziel: {when}",
|
||||
"streak": "{count} Tage in Folge"
|
||||
},
|
||||
"relativeDay": {
|
||||
"today": "heute",
|
||||
"yesterday": "gestern",
|
||||
"daysAgo": "vor {count} Tagen"
|
||||
},
|
||||
"summary": {
|
||||
"weekTitle": "{met} von {total} Zielwerten diese Woche erreicht",
|
||||
"weekTitleAll": "Alle {total} Zielwerte diese Woche erreicht",
|
||||
"streakHighlight": "{count} Tage in Folge: {metric}"
|
||||
},
|
||||
"coach": {
|
||||
"cta": "Coach fragen"
|
||||
},
|
||||
"edit": {
|
||||
"openLabel": "Zielbereich für {metric} bearbeiten",
|
||||
"title": "Zielbereich bearbeiten — {metric}",
|
||||
@@ -3914,31 +4097,10 @@
|
||||
"aboveTarget": "{delta} {unit} über Zielbereich",
|
||||
"belowTarget": "{delta} {unit} unter Zielbereich",
|
||||
"diastolic": "Diastolisch",
|
||||
"noMeasurementYet": "Noch keine Messungen",
|
||||
"noMeasurementData": "Keine Messdaten verfügbar. Füge Messwerte hinzu, um deine Ziele zu sehen.",
|
||||
"profileIncomplete": "Profil unvollständig",
|
||||
"profileIncompleteHeight": "Trage deine Körpergröße im Profil ein, um BMI-Ziele zu aktivieren.",
|
||||
"profileIncompleteAge": "Trage dein Geburtsdatum im Profil ein, um altersspezifische Ziele zu aktivieren.",
|
||||
"profileIncompleteHeightAge": "Trage Körpergröße und Geburtsdatum im Profil ein, um personalisierte Ziele zu sehen.",
|
||||
"compliancePerMedication": "Einnahmetreue pro Medikament",
|
||||
"glucoseFasting": "Glukose — nüchtern",
|
||||
"glucosePostprandial": "Glukose — postprandial",
|
||||
"glucoseRandom": "Glukose — beliebig",
|
||||
"glucoseBedtime": "Glukose — vor dem Schlafen",
|
||||
"loadError": "Zielwerte konnten nicht geladen werden",
|
||||
"label": {
|
||||
"WEIGHT": "Gewicht",
|
||||
"BLOOD_PRESSURE": "Blutdruck",
|
||||
"BLOOD_PRESSURE_IN_TARGET": "Blutdruck im Zielbereich",
|
||||
"PULSE": "Ruhepuls",
|
||||
"SLEEP_DURATION": "Schlafdauer",
|
||||
"BMI": "BMI",
|
||||
"BODY_FAT": "Körperfett",
|
||||
"ACTIVITY_STEPS": "Schritte/Tag",
|
||||
"MEDICATION_COMPLIANCE": "Einnahmetreue",
|
||||
"MOOD_SCORE": "Stimmung",
|
||||
"MOOD_STABILITY": "Stimmungsstabilität"
|
||||
},
|
||||
"status": {
|
||||
"underweight": "Untergewicht",
|
||||
"normal": "Normal",
|
||||
|
||||
+247
-85
@@ -461,7 +461,49 @@
|
||||
"deleteConfirmTitle": "Delete entry?",
|
||||
"deleteConfirmDescription": "This mood entry will be permanently deleted.",
|
||||
"pageInfo": "Page {page} of {total}",
|
||||
"loginRequired": "Please sign in to view mood entries."
|
||||
"loginRequired": "Please sign in to view mood entries.",
|
||||
"note": "Note",
|
||||
"notePlaceholder": "Add a note about how you're feeling…",
|
||||
"tagPicker": "Tags",
|
||||
"tagCategory": {
|
||||
"feelings": "Feelings",
|
||||
"sleep": "Sleep",
|
||||
"health": "Health",
|
||||
"social": "Social",
|
||||
"work": "Work"
|
||||
},
|
||||
"tag": {
|
||||
"happy": "happy",
|
||||
"excited": "excited",
|
||||
"grateful": "grateful",
|
||||
"relaxed": "relaxed",
|
||||
"content": "content",
|
||||
"tired": "tired",
|
||||
"unsure": "unsure",
|
||||
"bored": "bored",
|
||||
"tense": "tense",
|
||||
"angry": "angry",
|
||||
"stressed": "stressed",
|
||||
"sad": "sad",
|
||||
"sleptWell": "slept well",
|
||||
"sleptOk": "slept okay",
|
||||
"sleptPoorly": "slept poorly",
|
||||
"earlyNight": "early night",
|
||||
"workedOut": "worked out",
|
||||
"ateWell": "ate well",
|
||||
"hydrated": "hydrated",
|
||||
"walked": "walked",
|
||||
"alcohol": "alcohol",
|
||||
"family": "family",
|
||||
"friends": "friends",
|
||||
"party": "party",
|
||||
"alone": "alone",
|
||||
"productive": "productive",
|
||||
"overtime": "overtime",
|
||||
"dayOff": "day off",
|
||||
"travel": "travel",
|
||||
"sickDay": "sick day"
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
"title": "Medications",
|
||||
@@ -544,11 +586,14 @@
|
||||
"veryOverdue": "Very overdue",
|
||||
"compliance7d": "7-day compliance",
|
||||
"compliance30d": "30-day compliance",
|
||||
"complianceWindow": "{days}-day compliance",
|
||||
"lastIntake": "Last intake:",
|
||||
"nextIntake": "Next intake:",
|
||||
"dayStreak": "day streak",
|
||||
"taken": "Taken",
|
||||
"skipped": "Skipped",
|
||||
"intakeToastTaken": "{name} recorded as taken",
|
||||
"intakeToastSkipped": "{name} skipped",
|
||||
"weekdaySunday": "Sunday",
|
||||
"weekdayMonday": "Monday",
|
||||
"weekdayTuesday": "Tuesday",
|
||||
@@ -759,7 +804,11 @@
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"save": "Save",
|
||||
"saveEdit": "Save changes"
|
||||
"saveEdit": "Save changes",
|
||||
"nextHint": "Next: {step}",
|
||||
"reviewHint": "Next: Save",
|
||||
"jumpFirst": "Jump to the first step",
|
||||
"jumpLast": "Jump to review and save"
|
||||
},
|
||||
"nl": {
|
||||
"button": "Describe"
|
||||
@@ -769,11 +818,13 @@
|
||||
"title": "What's the medication called?",
|
||||
"subline": "Exactly as it appears on the box.",
|
||||
"label": "Name",
|
||||
"placeholder": "e.g. Ramipril"
|
||||
"placeholder": "e.g. Ramipril",
|
||||
"short": "Name"
|
||||
},
|
||||
"step2": {
|
||||
"title": "What kind of medication is it?",
|
||||
"subline": "We use this for the right templates and analytics."
|
||||
"subline": "We use this for the right templates and analytics.",
|
||||
"short": "Type"
|
||||
},
|
||||
"step3": {
|
||||
"title": "What's the dose?",
|
||||
@@ -805,15 +856,18 @@
|
||||
"dosesPerUnitPlaceholder": "e.g. 4",
|
||||
"dosesPerUnitHint": "Doses per pen / vial — leave empty for no inventory tracking.",
|
||||
"injectionSiteToggle": "Injection-site rotation",
|
||||
"injectionSiteHint": "Optional rotation guide. The site for each dose is recorded when you log the intake."
|
||||
"injectionSiteHint": "Optional rotation guide. The site for each dose is recorded when you log the intake.",
|
||||
"short": "Dose"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Over what period are you taking it?",
|
||||
"subline": "A start date is enough for today. You can add an end date later."
|
||||
"subline": "A start date is enough for today. You can add an end date later.",
|
||||
"short": "When"
|
||||
},
|
||||
"step5": {
|
||||
"title": "How often do you take it?",
|
||||
"subline": "Pick the cadence — the details follow on the next step."
|
||||
"subline": "Pick the cadence — the details follow on the next step.",
|
||||
"short": "Cadence"
|
||||
},
|
||||
"step6": {
|
||||
"title": "What does that look like in detail?",
|
||||
@@ -829,7 +883,8 @@
|
||||
"rollingDays": {
|
||||
"label": "Every",
|
||||
"suffix": "days from the last intake"
|
||||
}
|
||||
},
|
||||
"short": "Detail"
|
||||
},
|
||||
"step7": {
|
||||
"title": "When do you take it?",
|
||||
@@ -840,14 +895,16 @@
|
||||
"noon": "Noon",
|
||||
"evening": "Evening",
|
||||
"night": "Night"
|
||||
}
|
||||
},
|
||||
"short": "Times"
|
||||
},
|
||||
"step8": {
|
||||
"title": "All done — should HealthLog remind you?",
|
||||
"subline": "Review everything below before you save.",
|
||||
"remindersLabel": "Reminders on",
|
||||
"remindersDescription": "Push, Telegram or ntfy — based on your settings.",
|
||||
"multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release."
|
||||
"multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release.",
|
||||
"short": "Done"
|
||||
}
|
||||
},
|
||||
"classRow": {
|
||||
@@ -923,26 +980,17 @@
|
||||
"submitFailed": "Could not save the medication — please retry."
|
||||
}
|
||||
},
|
||||
"titration": {
|
||||
"section": "Titration ladder",
|
||||
"loading": "Loading titration data",
|
||||
"loadFailed": "Titration data failed to load.",
|
||||
"drugLadderHeader": "Standard ladder (EMA reference)",
|
||||
"typicalWeeksOnStep": "≥ {weeks} wks typical",
|
||||
"youAreHere": "You are here",
|
||||
"nextStepCaption": "Next step (typical): {dose} mg after ~{weeks} weeks on the current step.",
|
||||
"ceilingMessage": "You are at the top of the standard ladder.",
|
||||
"escalationDueHint": "You've been on this step for {weeks} weeks; the ladder typically steps up around {typical} weeks.",
|
||||
"currentStepDwell": "On this step for {weeks} weeks",
|
||||
"doseUnitMg": "mg",
|
||||
"emptyState": "No dose history yet. Log a dose to see where you are on the ladder.",
|
||||
"nonStandardDose": "Current dose is outside the standard ladder buckets.",
|
||||
"disclaimer": "EMA reference values. Talk to your doctor before changing doses.",
|
||||
"emaSourceCta": "EMA source",
|
||||
"stepLabel": "Step {n}"
|
||||
"doseStrength": {
|
||||
"title": "Dose strength over time",
|
||||
"axisLabel": "Recorded dose ({unit})",
|
||||
"tooltipLabel": "Dose",
|
||||
"currentDose": "Current dose: {dose} {unit}",
|
||||
"noCurrentDose": "No dose recorded yet.",
|
||||
"empty": "No titration history yet. Log a dose change to see the strength curve."
|
||||
},
|
||||
"injectionSiteBodyOutlineAriaLabel": "Body outline",
|
||||
"injectionSitePickerAriaLabel": "Injection site picker",
|
||||
"injectionSiteRecommendedAriaLabel": "{site} — recommended next site",
|
||||
"siteAbdomenLeft": "Abdomen, lower left",
|
||||
"siteAbdomenRight": "Abdomen, lower right",
|
||||
"siteAbdomenUpperLeft": "Abdomen, upper left",
|
||||
@@ -1004,10 +1052,11 @@
|
||||
"phaseResetDefaults": "Reset to defaults",
|
||||
"phaseSaved": "Reminder phases saved",
|
||||
"phaseReset": "Reset to defaults",
|
||||
"sourceWeb": "Website",
|
||||
"sourceApi": "External API",
|
||||
"sourceReminder": "Telegram / Reminder",
|
||||
"sourceImport": "CSV Import",
|
||||
"sourceWeb": "Web",
|
||||
"sourceApi": "iOS App",
|
||||
"sourceReminder": "Reminder",
|
||||
"sourceImport": "Import",
|
||||
"intakeSourceVia": "via {origin}",
|
||||
"intakeHistoryEmptyTitle": "No intakes recorded yet",
|
||||
"intakeHistoryEmptyDescription": "Log a dose on the daily intake page to start building the history.",
|
||||
"intakeHistoryEmptyAction": "Open daily intake",
|
||||
@@ -1078,11 +1127,6 @@
|
||||
"emptyState": "No intake events yet.",
|
||||
"emptyStateCta": "Log a dose to see the estimated level take shape.",
|
||||
"estimateNote": "Educational estimate from EMA-published population pharmacokinetics. Not a measurement.",
|
||||
"gatedTitle": "Research Mode is off",
|
||||
"gatedBody": "Turn on Research Mode in Settings to see the estimated drug-level curve. The curve is opt-in because it is a learning aid, not a clinical measurement.",
|
||||
"gatedStaleTitle": "Disclaimer needs re-acknowledgment",
|
||||
"gatedStaleBody": "The Research Mode disclaimer was updated since you last acknowledged it. Re-read and confirm in Settings to bring the chart back.",
|
||||
"gatedCta": "Open Research Mode in Settings",
|
||||
"unknownDrug": "{name} is not in the EMA-approved GLP-1 catalog, so HealthLog cannot render a population-PK curve for it."
|
||||
}
|
||||
},
|
||||
@@ -1170,6 +1214,14 @@
|
||||
"unit": "minutes",
|
||||
"saved": "Reminder window saved",
|
||||
"failed": "Could not save the reminder window."
|
||||
},
|
||||
"codes": {
|
||||
"label": "Clinical codes (ATC / RxNorm)",
|
||||
"note": "Optional. Used on the FHIR health-record export. Codes are taken exactly as entered — never guessed.",
|
||||
"atcLabel": "ATC",
|
||||
"rxNormLabel": "RxNorm",
|
||||
"saved": "Clinical codes saved",
|
||||
"failed": "Could not save the clinical codes."
|
||||
}
|
||||
},
|
||||
"zone": {
|
||||
@@ -1235,7 +1287,7 @@
|
||||
"data": "Data",
|
||||
"reminders": "Reminders",
|
||||
"lifecycle": "Lifecycle",
|
||||
"danger": "Danger zone"
|
||||
"externalApi": "External API"
|
||||
},
|
||||
"dataPortability": {
|
||||
"import": {
|
||||
@@ -1258,10 +1310,18 @@
|
||||
"history": {
|
||||
"back": "Back to medication",
|
||||
"subtitle": "Full intake history",
|
||||
"plannedSuffix": "planned",
|
||||
"drugLevelDisclosure": "Show estimated drug-level curve"
|
||||
"plannedSuffix": "planned"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logInjectionSiteTitle": "Log injection site",
|
||||
"logInjectionSiteDescription": "Where did you inject {name}? Optional — you can skip this.",
|
||||
"logInjectionSiteSkip": "Skip",
|
||||
"logInjectionSiteConfirm": "Save site",
|
||||
"logInjectionSiteNoneAvailable": "No injection site is available — every site is excluded in your settings.",
|
||||
"trackInjectionSitesToggle": "Track injection sites",
|
||||
"trackInjectionSitesHint": "After a dose is taken, ask which site was used and suggest the next rotation.",
|
||||
"allowedSitesLabel": "Allowed sites",
|
||||
"allowedSitesHint": "Leave all selected for no restriction. Sites you excluded globally are never offered."
|
||||
},
|
||||
"charts": {
|
||||
"loadingLabel": "Loading chart…",
|
||||
@@ -1272,6 +1332,13 @@
|
||||
"bodyFat": "Body Fat",
|
||||
"sleep": "Sleep",
|
||||
"steps": "Steps",
|
||||
"hrv": "HRV",
|
||||
"restingHeartRate": "Resting heart rate",
|
||||
"activeEnergy": "Active energy",
|
||||
"flights": "Flights climbed",
|
||||
"distance": "Walking + running distance",
|
||||
"vo2Max": "VO₂ max",
|
||||
"bodyTemperature": "Body temperature",
|
||||
"noData": "No data in selected time range",
|
||||
"bucketWeekly": "Weekly avg",
|
||||
"bucketMonthly": "Monthly avg",
|
||||
@@ -1336,7 +1403,17 @@
|
||||
"personalBaseline": "Your normal",
|
||||
"deltaVsBaseline": "{delta} vs. your normal",
|
||||
"deltaUnchanged": "matches your normal",
|
||||
"deltaVsTarget": "{delta} vs. target"
|
||||
"deltaVsTarget": "{delta} vs. target",
|
||||
"weekdaysFull": {
|
||||
"mon": "Monday",
|
||||
"tue": "Tuesday",
|
||||
"wed": "Wednesday",
|
||||
"thu": "Thursday",
|
||||
"fri": "Friday",
|
||||
"sat": "Saturday",
|
||||
"sun": "Sunday"
|
||||
},
|
||||
"rangeBand": "Range"
|
||||
},
|
||||
"chart": {
|
||||
"overlay": {
|
||||
@@ -1376,6 +1453,7 @@
|
||||
},
|
||||
"insights": {
|
||||
"noAnalysisYet": "No analysis yet.",
|
||||
"assessmentPreparing": "Preparing your assessment…",
|
||||
"emptyTitle": "Not enough data yet",
|
||||
"emptyDescription": "Insights light up once you've logged a handful of measurements. Start with weight or blood pressure.",
|
||||
"emptyAddMeasurement": "Add a measurement",
|
||||
@@ -1432,6 +1510,8 @@
|
||||
"heroRegenerating": "Regenerating",
|
||||
"regenerateAnalysis": "Re-run analysis",
|
||||
"regenerateSuccess": "Analysis refreshed",
|
||||
"warmAssessments": "Prepare assessments",
|
||||
"warmStarted": "Assessments are being prepared in the background",
|
||||
"heroFallbackSubtitle": "A daily read of your trends, drawn straight from the numbers you've logged.",
|
||||
"heroGreetingMorning": "Good morning",
|
||||
"heroGreetingAfternoon": "Good afternoon",
|
||||
@@ -1498,7 +1578,22 @@
|
||||
},
|
||||
"trendsRow": {
|
||||
"title": "Trends",
|
||||
"subtitle": "Last 30 days at a glance"
|
||||
"subtitle": "Last 30 days at a glance",
|
||||
"caption": {
|
||||
"bp": "Systolic and diastolic readings over the last 30 days.",
|
||||
"weight": "Body-weight trend over the last 30 days.",
|
||||
"pulse": "Resting pulse over the last 30 days.",
|
||||
"mood": "Daily mood score over the last 30 days.",
|
||||
"sleep": "Nightly sleep duration over the last 30 days.",
|
||||
"steps": "Daily step count over the last 30 days.",
|
||||
"hrv": "Heart-rate variability over the last 30 days.",
|
||||
"resting_hr": "Resting heart rate over the last 30 days.",
|
||||
"active_energy": "Active energy burned over the last 30 days.",
|
||||
"flights": "Flights of stairs climbed over the last 30 days.",
|
||||
"distance": "Walking and running distance over the last 30 days.",
|
||||
"vo2_max": "VO₂ max over the last 30 days.",
|
||||
"body_temp": "Body temperature over the last 30 days."
|
||||
}
|
||||
},
|
||||
"vo2Max": {
|
||||
"statLatest": "Latest",
|
||||
@@ -1515,6 +1610,23 @@
|
||||
"confidenceLow": "Low confidence",
|
||||
"pendingLabel": "Generating trend commentary"
|
||||
},
|
||||
"range": {
|
||||
"label": "Time range",
|
||||
"noPriorData": "no prior-period data",
|
||||
"option": {
|
||||
"7d": "7d",
|
||||
"30d": "30d",
|
||||
"90d": "90d",
|
||||
"1y": "1y"
|
||||
},
|
||||
"priorLabel": {
|
||||
"7d": "vs prior 7d",
|
||||
"30d": "vs prior 30d",
|
||||
"90d": "vs prior 90d",
|
||||
"1y": "vs prior year"
|
||||
},
|
||||
"noChange": "no change"
|
||||
},
|
||||
"correlationRow": {
|
||||
"title": "Relationships",
|
||||
"subtitle": "Pre-defined hypotheses, gated on n ≥ 14 and p < 0.05",
|
||||
@@ -1883,7 +1995,23 @@
|
||||
"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; readings that stay high over time can signal diabetes or prediabetes.",
|
||||
"skinTemperatureTitle": "Skin temperature",
|
||||
"skinTemperatureBody": "Skin temperature here is the small nightly change in your wrist temperature from your own baseline, not an absolute fever reading. Shifts can track sleep, environment, illness, or menstrual-cycle phase."
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"label": "Summary statistics",
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"median": "Median",
|
||||
"mean": "Mean"
|
||||
},
|
||||
"showAllValues": "Show all readings",
|
||||
"valuesPageTitle": "{metric} — all readings",
|
||||
"valuesPageDescription": "Every reading behind this metric. Edit or remove an entry directly.",
|
||||
"diversity": {
|
||||
"trigger": "Measurement tip",
|
||||
"weekday": "Most of your {metric} readings land on one weekday. Logging on other days gives a fuller picture.",
|
||||
"timeOfDay": "Most of your {metric} readings land at a similar time of day. Varying the time gives a fuller picture."
|
||||
},
|
||||
"valuesBack": "Back to insights"
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sleep",
|
||||
@@ -2264,7 +2392,75 @@
|
||||
"description": "Walking-speed readings appear here once Apple Health syncs."
|
||||
}
|
||||
},
|
||||
"navWalkingSpeed": "Walking Speed"
|
||||
"navWalkingSpeed": "Walking Speed",
|
||||
"mood": {
|
||||
"heatmapTitle": "Mood calendar",
|
||||
"heatmapNoEntry": "No entry",
|
||||
"legendGreat": "Great",
|
||||
"legendGood": "Good",
|
||||
"legendOkay": "Okay",
|
||||
"legendLow": "Low",
|
||||
"distributionTitle": "Mood distribution",
|
||||
"distributionDaysUnit": "days",
|
||||
"weekdayTitle": "Average by weekday",
|
||||
"weekdayNoData": "No data",
|
||||
"weekdayBestWorst": "Best day: {best} · Toughest day: {worst}",
|
||||
"tagsTitle": "Tags and triggers",
|
||||
"tagAvgMood": "Average mood when tagged",
|
||||
"correlationsTitle": "Mood and other metrics",
|
||||
"correlationsDescription": "How your mood moves with sleep, activity, and pulse on the same day.",
|
||||
"correlation": {
|
||||
"sleepTitle": "Mood vs. sleep",
|
||||
"stepsTitle": "Mood vs. activity",
|
||||
"pulseTitle": "Mood vs. pulse",
|
||||
"sleepAxis": "Sleep (min)",
|
||||
"stepsAxis": "Steps",
|
||||
"pulseAxis": "Pulse (bpm)",
|
||||
"strengthStrong": "Strong",
|
||||
"strengthModerate": "Moderate",
|
||||
"strengthWeak": "Weak",
|
||||
"strengthNone": "None",
|
||||
"source": "{n} paired days · r = {r}",
|
||||
"emptyTitle": "Not enough paired data yet",
|
||||
"emptyDescription": "Log mood and this metric on the same days to see a correlation.",
|
||||
"weightTitle": "Mood vs. weight",
|
||||
"bloodPressureTitle": "Mood vs. blood pressure",
|
||||
"weightAxis": "Weight",
|
||||
"bloodPressureAxis": "Systolic (mmHg)"
|
||||
},
|
||||
"structuredTagsTitle": "Tag breakdown",
|
||||
"inTargetValue": "{pct}%",
|
||||
"inTargetCaption": "of recent days in your good-mood range",
|
||||
"narrative": {
|
||||
"title": "What stands out",
|
||||
"weekdayDip": "Your mood dips most on {weekday} — {delta} below your week.",
|
||||
"weekdayPeak": "{weekday} is consistently your best day, averaging {value}.",
|
||||
"trendUp": "Your mood is trending up over the last {days} days (+{delta} vs the prior period).",
|
||||
"trendDown": "Your mood is trending down over the last {days} days (-{delta} vs the prior period).",
|
||||
"weekendUp": "You feel better on weekends (+{delta} vs weekdays).",
|
||||
"weekendDown": "Your mood runs lower on weekends (-{delta} vs weekdays).",
|
||||
"tagLift": "Days tagged \"{tag}\" average {delta} above your overall mood.",
|
||||
"tagDrop": "Days tagged \"{tag}\" run {delta} below your average.",
|
||||
"inTarget": "You've been in your good-mood range {pct}% of the last 30 days.",
|
||||
"streak": "You've logged your mood {days} days in a row — keep it up.",
|
||||
"timeOfDay": "You feel best in the {bucket}, averaging {value}."
|
||||
},
|
||||
"timeOfDay": {
|
||||
"title": "Mood by time of day",
|
||||
"morning": "Morning",
|
||||
"afternoon": "Afternoon",
|
||||
"evening": "Evening",
|
||||
"night": "Night",
|
||||
"bestCaption": "You tend to feel best in the {bucket}."
|
||||
},
|
||||
"stability": {
|
||||
"value": "{score}/100",
|
||||
"bandVerySteady": "Your mood has been very steady",
|
||||
"bandSteady": "Your mood has been fairly steady",
|
||||
"bandVariable": "Your mood has been noticeably variable",
|
||||
"bandVeryVariable": "Your mood has swung a lot lately"
|
||||
}
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Default",
|
||||
@@ -2476,7 +2672,7 @@
|
||||
"description": "Withings, moodLog, and other connected services."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notification channels",
|
||||
"title": "Notifications",
|
||||
"description": "Live overview of every configured channel."
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -2484,13 +2680,12 @@
|
||||
"description": "Tile layout and order."
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Targets & Sources",
|
||||
"description": "Per metric: your own target range and which source wins when more than one records the same value on the same day."
|
||||
"title": "Targets",
|
||||
"description": "Per metric: your own target range for healthy values."
|
||||
},
|
||||
"sources": {
|
||||
"title": "Sources",
|
||||
"description": "Pick which ingest source wins when more than one records the same metric for the same day.",
|
||||
"redirectNotice": "Sources are now part of “Targets & Sources”.",
|
||||
"cardTitle": "Source priority by metric",
|
||||
"help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.",
|
||||
"resetDefaults": "Reset to defaults",
|
||||
@@ -2540,7 +2735,7 @@
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
"title": "Insights",
|
||||
"title": "AI Insights",
|
||||
"description": "Provider, model, key."
|
||||
},
|
||||
"api": {
|
||||
@@ -3022,7 +3217,9 @@
|
||||
"fhirNote": "The structured output follows the FHIR R4 standard (ePA-compatible).",
|
||||
"generate": "Generate",
|
||||
"error": "Export failed ({code})."
|
||||
}
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Globally excluded injection sites",
|
||||
"globalExcludedInjectionSitesHint": "Sites listed here are never offered for any medication and rejected when logging a dose, even if a medication prefers them."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -3843,8 +4040,6 @@
|
||||
}
|
||||
},
|
||||
"targets": {
|
||||
"title": "Targets",
|
||||
"introText": "Your current values compared to recommended target ranges. Guideline sources are linked on each card.",
|
||||
"bmi": "BMI",
|
||||
"average30d": "30-day average:",
|
||||
"consistency": {
|
||||
@@ -3866,23 +4061,11 @@
|
||||
"sixDaysAgo": "6 days ago"
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"lastMet": "Last met goal: {when}",
|
||||
"streak": "{count}-day streak"
|
||||
},
|
||||
"relativeDay": {
|
||||
"today": "today",
|
||||
"yesterday": "yesterday",
|
||||
"daysAgo": "{count} days ago"
|
||||
},
|
||||
"summary": {
|
||||
"weekTitle": "{met} of {total} targets met this week",
|
||||
"weekTitleAll": "All {total} targets met this week",
|
||||
"streakHighlight": "{count}-day streak: {metric}"
|
||||
},
|
||||
"coach": {
|
||||
"cta": "Ask Coach"
|
||||
},
|
||||
"edit": {
|
||||
"openLabel": "Edit target range for {metric}",
|
||||
"title": "Edit target range — {metric}",
|
||||
@@ -3914,31 +4097,10 @@
|
||||
"aboveTarget": "{delta} {unit} above target",
|
||||
"belowTarget": "{delta} {unit} below target",
|
||||
"diastolic": "Diastolic",
|
||||
"noMeasurementYet": "No measurements yet",
|
||||
"noMeasurementData": "No measurement data available. Add some measurements to see your targets.",
|
||||
"profileIncomplete": "Profile incomplete",
|
||||
"profileIncompleteHeight": "Add your height in profile settings to enable BMI targets.",
|
||||
"profileIncompleteAge": "Add your date of birth in profile settings for age-specific targets.",
|
||||
"profileIncompleteHeightAge": "Add your height and date of birth in profile settings for personalized targets.",
|
||||
"compliancePerMedication": "Compliance per medication",
|
||||
"glucoseFasting": "Glucose — fasting",
|
||||
"glucosePostprandial": "Glucose — post-meal",
|
||||
"glucoseRandom": "Glucose — random",
|
||||
"glucoseBedtime": "Glucose — bedtime",
|
||||
"loadError": "Failed to load targets",
|
||||
"label": {
|
||||
"WEIGHT": "Weight",
|
||||
"BLOOD_PRESSURE": "Blood pressure",
|
||||
"BLOOD_PRESSURE_IN_TARGET": "Blood pressure on target",
|
||||
"PULSE": "Resting pulse",
|
||||
"SLEEP_DURATION": "Sleep duration",
|
||||
"BMI": "BMI",
|
||||
"BODY_FAT": "Body fat",
|
||||
"ACTIVITY_STEPS": "Steps/day",
|
||||
"MEDICATION_COMPLIANCE": "Medication compliance",
|
||||
"MOOD_SCORE": "Mood",
|
||||
"MOOD_STABILITY": "Mood stability"
|
||||
},
|
||||
"status": {
|
||||
"underweight": "Underweight",
|
||||
"normal": "Normal",
|
||||
|
||||
+275
-113
@@ -461,7 +461,49 @@
|
||||
"deleteConfirmTitle": "¿Eliminar entrada?",
|
||||
"deleteConfirmDescription": "Esta entrada de estado de ánimo se eliminará de forma permanente.",
|
||||
"pageInfo": "Página {page} de {total}",
|
||||
"loginRequired": "Inicia sesión para ver tus estados de ánimo."
|
||||
"loginRequired": "Inicia sesión para ver tus estados de ánimo.",
|
||||
"note": "Nota",
|
||||
"notePlaceholder": "Añade una nota sobre cómo te sientes…",
|
||||
"tagPicker": "Etiquetas",
|
||||
"tagCategory": {
|
||||
"feelings": "Sentimientos",
|
||||
"sleep": "Sueño",
|
||||
"health": "Salud",
|
||||
"social": "Social",
|
||||
"work": "Trabajo"
|
||||
},
|
||||
"tag": {
|
||||
"happy": "feliz",
|
||||
"excited": "emocionado",
|
||||
"grateful": "agradecido",
|
||||
"relaxed": "relajado",
|
||||
"content": "satisfecho",
|
||||
"tired": "cansado",
|
||||
"unsure": "inseguro",
|
||||
"bored": "aburrido",
|
||||
"tense": "tenso",
|
||||
"angry": "enfadado",
|
||||
"stressed": "estresado",
|
||||
"sad": "triste",
|
||||
"sleptWell": "dormí bien",
|
||||
"sleptOk": "dormí regular",
|
||||
"sleptPoorly": "dormí mal",
|
||||
"earlyNight": "me acosté pronto",
|
||||
"workedOut": "entrené",
|
||||
"ateWell": "comí bien",
|
||||
"hydrated": "bebí agua",
|
||||
"walked": "caminé",
|
||||
"alcohol": "alcohol",
|
||||
"family": "familia",
|
||||
"friends": "amigos",
|
||||
"party": "fiesta",
|
||||
"alone": "solo",
|
||||
"productive": "productivo",
|
||||
"overtime": "horas extra",
|
||||
"dayOff": "día libre",
|
||||
"travel": "viaje",
|
||||
"sickDay": "día de baja"
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
"title": "Medicamentos",
|
||||
@@ -544,11 +586,14 @@
|
||||
"veryOverdue": "Muy vencida",
|
||||
"compliance7d": "Cumplimiento 7 días",
|
||||
"compliance30d": "Cumplimiento 30 días",
|
||||
"complianceWindow": "Cumplimiento {days} días",
|
||||
"lastIntake": "Última toma:",
|
||||
"nextIntake": "Próxima toma:",
|
||||
"dayStreak": "días de racha",
|
||||
"taken": "Tomada",
|
||||
"skipped": "Omitida",
|
||||
"intakeToastTaken": "{name} registrada como tomada",
|
||||
"intakeToastSkipped": "{name} omitida",
|
||||
"weekdaySunday": "Domingo",
|
||||
"weekdayMonday": "Lunes",
|
||||
"weekdayTuesday": "Martes",
|
||||
@@ -759,7 +804,11 @@
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"save": "Save",
|
||||
"saveEdit": "Save changes"
|
||||
"saveEdit": "Save changes",
|
||||
"nextHint": "Siguiente: {step}",
|
||||
"reviewHint": "Siguiente: Guardar",
|
||||
"jumpFirst": "Ir al primer paso",
|
||||
"jumpLast": "Ir a revisar y guardar"
|
||||
},
|
||||
"nl": {
|
||||
"button": "Describe"
|
||||
@@ -769,11 +818,13 @@
|
||||
"title": "What's the medication called?",
|
||||
"subline": "Exactly as it appears on the box.",
|
||||
"label": "Name",
|
||||
"placeholder": "e.g. Ramipril"
|
||||
"placeholder": "e.g. Ramipril",
|
||||
"short": "Nombre"
|
||||
},
|
||||
"step2": {
|
||||
"title": "What kind of medication is it?",
|
||||
"subline": "We use this for the right templates and analytics."
|
||||
"subline": "We use this for the right templates and analytics.",
|
||||
"short": "Tipo"
|
||||
},
|
||||
"step3": {
|
||||
"title": "What's the dose?",
|
||||
@@ -805,15 +856,18 @@
|
||||
"dosesPerUnitPlaceholder": "p. ej. 4",
|
||||
"dosesPerUnitHint": "Dosis por pluma / vial — déjalo vacío para no controlar el inventario.",
|
||||
"injectionSiteToggle": "Rotación del sitio de inyección",
|
||||
"injectionSiteHint": "Guía de rotación opcional. El sitio de cada dosis se registra al anotar la toma."
|
||||
"injectionSiteHint": "Guía de rotación opcional. El sitio de cada dosis se registra al anotar la toma.",
|
||||
"short": "Dosis"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Over what period are you taking it?",
|
||||
"subline": "A start date is enough for today. You can add an end date later."
|
||||
"subline": "A start date is enough for today. You can add an end date later.",
|
||||
"short": "Cuándo"
|
||||
},
|
||||
"step5": {
|
||||
"title": "How often do you take it?",
|
||||
"subline": "Pick the cadence — the details follow on the next step."
|
||||
"subline": "Pick the cadence — the details follow on the next step.",
|
||||
"short": "Frecuencia"
|
||||
},
|
||||
"step6": {
|
||||
"title": "What does that look like in detail?",
|
||||
@@ -829,7 +883,8 @@
|
||||
"rollingDays": {
|
||||
"label": "Every",
|
||||
"suffix": "days from the last intake"
|
||||
}
|
||||
},
|
||||
"short": "Detalle"
|
||||
},
|
||||
"step7": {
|
||||
"title": "When do you take it?",
|
||||
@@ -840,14 +895,16 @@
|
||||
"noon": "Noon",
|
||||
"evening": "Evening",
|
||||
"night": "Night"
|
||||
}
|
||||
},
|
||||
"short": "Horas"
|
||||
},
|
||||
"step8": {
|
||||
"title": "All done — should HealthLog remind you?",
|
||||
"subline": "Review everything below before you save.",
|
||||
"remindersLabel": "Reminders on",
|
||||
"remindersDescription": "Push, Telegram or ntfy — based on your settings.",
|
||||
"multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release."
|
||||
"multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release.",
|
||||
"short": "Listo"
|
||||
}
|
||||
},
|
||||
"classRow": {
|
||||
@@ -923,26 +980,17 @@
|
||||
"submitFailed": "Could not save the medication — please retry."
|
||||
}
|
||||
},
|
||||
"titration": {
|
||||
"section": "Escalado de titulación",
|
||||
"loading": "Cargando datos de titulación",
|
||||
"loadFailed": "No se pudieron cargar los datos de titulación.",
|
||||
"drugLadderHeader": "Escalado estándar (referencia EMA)",
|
||||
"typicalWeeksOnStep": "≥ {weeks} sem. típico",
|
||||
"youAreHere": "Estás aquí",
|
||||
"nextStepCaption": "Siguiente escalón (típico): {dose} mg tras ~{weeks} semanas en el escalón actual.",
|
||||
"ceilingMessage": "Estás en el último escalón estándar.",
|
||||
"escalationDueHint": "Llevas {weeks} semanas en este escalón; el esquema suele avanzar tras ~{typical} semanas.",
|
||||
"currentStepDwell": "En este escalón desde hace {weeks} semanas",
|
||||
"doseUnitMg": "mg",
|
||||
"emptyState": "Aún no hay historial de dosis. Registra una dosis para ver tu posición en el escalado.",
|
||||
"nonStandardDose": "La dosis actual está fuera de los escalones estándar.",
|
||||
"disclaimer": "Valores de referencia EMA. Consulta a tu médico antes de cambiar la dosis.",
|
||||
"emaSourceCta": "Fuente EMA",
|
||||
"stepLabel": "Escalón {n}"
|
||||
"doseStrength": {
|
||||
"title": "Dosis a lo largo del tiempo",
|
||||
"axisLabel": "Dosis registrada ({unit})",
|
||||
"tooltipLabel": "Dosis",
|
||||
"currentDose": "Dosis actual: {dose} {unit}",
|
||||
"noCurrentDose": "Aún no hay dosis registrada.",
|
||||
"empty": "Aún no hay historial de titulación. Registra un cambio de dosis para ver la curva de dosis."
|
||||
},
|
||||
"injectionSiteBodyOutlineAriaLabel": "Silueta del cuerpo",
|
||||
"injectionSitePickerAriaLabel": "Selector de sitio de inyección",
|
||||
"injectionSiteRecommendedAriaLabel": "{site}: sitio siguiente recomendado",
|
||||
"siteAbdomenLeft": "Abdomen, inferior izquierdo",
|
||||
"siteAbdomenRight": "Abdomen, inferior derecho",
|
||||
"siteAbdomenUpperLeft": "Abdomen, superior izquierdo",
|
||||
@@ -1005,9 +1053,10 @@
|
||||
"phaseSaved": "Fases de recordatorio guardadas",
|
||||
"phaseReset": "Restablecidas a valores por defecto",
|
||||
"sourceWeb": "Web",
|
||||
"sourceApi": "API",
|
||||
"sourceApi": "App de iOS",
|
||||
"sourceReminder": "Recordatorio",
|
||||
"sourceImport": "Importación",
|
||||
"intakeSourceVia": "vía {origin}",
|
||||
"intakeHistoryEmptyTitle": "Aún no hay tomas",
|
||||
"intakeHistoryEmptyDescription": "En cuanto registres una toma, aparecerá aquí con la hora y la fuente.",
|
||||
"intakeHistoryEmptyAction": "Registrar toma",
|
||||
@@ -1078,11 +1127,6 @@
|
||||
"emptyState": "Sin tomas registradas todavía.",
|
||||
"emptyStateCta": "Registra una dosis para ver la curva estimada cobrar forma.",
|
||||
"estimateNote": "Estimación educativa a partir de la farmacocinética poblacional publicada por la EMA. No es una medición.",
|
||||
"gatedTitle": "Modo Investigación desactivado",
|
||||
"gatedBody": "Activa el modo Investigación en los ajustes para ver la curva estimada del nivel de fármaco. La curva es de inclusión voluntaria porque es una ayuda de aprendizaje, no una medición clínica.",
|
||||
"gatedStaleTitle": "El disclaimer requiere reconfirmación",
|
||||
"gatedStaleBody": "El disclaimer del modo Investigación se actualizó desde tu última confirmación. Relelo en los ajustes y confirma de nuevo para volver a ver la curva.",
|
||||
"gatedCta": "Abrir el modo Investigación en los ajustes",
|
||||
"unknownDrug": "{name} no está en el catálogo de GLP-1 aprobado por la EMA, por lo que HealthLog no puede trazar una curva de farmacocinética poblacional."
|
||||
}
|
||||
},
|
||||
@@ -1170,6 +1214,14 @@
|
||||
"unit": "minutes",
|
||||
"saved": "Reminder window saved",
|
||||
"failed": "Could not save the reminder window."
|
||||
},
|
||||
"codes": {
|
||||
"label": "Códigos clínicos (ATC / RxNorm)",
|
||||
"note": "Opcional. Se usan en la exportación FHIR del historial de salud. Los códigos se toman tal cual se introducen — nunca se adivinan.",
|
||||
"atcLabel": "ATC",
|
||||
"rxNormLabel": "RxNorm",
|
||||
"saved": "Códigos clínicos guardados",
|
||||
"failed": "No se pudieron guardar los códigos clínicos."
|
||||
}
|
||||
},
|
||||
"zone": {
|
||||
@@ -1235,7 +1287,7 @@
|
||||
"data": "Datos",
|
||||
"reminders": "Recordatorios",
|
||||
"lifecycle": "Ciclo de vida",
|
||||
"danger": "Zona de peligro"
|
||||
"externalApi": "API externa"
|
||||
},
|
||||
"dataPortability": {
|
||||
"import": {
|
||||
@@ -1258,10 +1310,18 @@
|
||||
"history": {
|
||||
"back": "Volver al medicamento",
|
||||
"subtitle": "Historial completo de tomas",
|
||||
"plannedSuffix": "planificado",
|
||||
"drugLevelDisclosure": "Mostrar curva estimada del principio activo"
|
||||
"plannedSuffix": "planificado"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logInjectionSiteTitle": "Registrar sitio de inyección",
|
||||
"logInjectionSiteDescription": "¿Dónde te inyectaste {name}? Opcional: puedes omitirlo.",
|
||||
"logInjectionSiteSkip": "Omitir",
|
||||
"logInjectionSiteConfirm": "Guardar sitio",
|
||||
"logInjectionSiteNoneAvailable": "No hay ningún sitio de inyección disponible: todos los sitios están excluidos en tu configuración.",
|
||||
"trackInjectionSitesToggle": "Registrar sitios de inyección",
|
||||
"trackInjectionSitesHint": "Tras administrar una dosis, pregunta qué sitio se usó y sugiere la siguiente rotación.",
|
||||
"allowedSitesLabel": "Sitios permitidos",
|
||||
"allowedSitesHint": "Deja todos seleccionados para no aplicar restricciones. Los sitios que excluiste globalmente nunca se ofrecen."
|
||||
},
|
||||
"charts": {
|
||||
"loadingLabel": "Cargando la gráfica…",
|
||||
@@ -1272,6 +1332,13 @@
|
||||
"bodyFat": "Grasa corporal",
|
||||
"sleep": "Sueño",
|
||||
"steps": "Pasos",
|
||||
"hrv": "VFC",
|
||||
"restingHeartRate": "FC en reposo",
|
||||
"activeEnergy": "Energía activa",
|
||||
"flights": "Pisos subidos",
|
||||
"distance": "Distancia a pie + corriendo",
|
||||
"vo2Max": "VO₂ máx",
|
||||
"bodyTemperature": "Temperatura corporal",
|
||||
"noData": "Sin datos en el periodo seleccionado",
|
||||
"bucketWeekly": "Media semanal",
|
||||
"bucketMonthly": "Media mensual",
|
||||
@@ -1336,7 +1403,17 @@
|
||||
"personalBaseline": "Tu media",
|
||||
"deltaVsBaseline": "{delta} vs. tu media",
|
||||
"deltaUnchanged": "coincide con tu media",
|
||||
"deltaVsTarget": "{delta} vs. objetivo"
|
||||
"deltaVsTarget": "{delta} vs. objetivo",
|
||||
"weekdaysFull": {
|
||||
"mon": "Lunes",
|
||||
"tue": "Martes",
|
||||
"wed": "Miércoles",
|
||||
"thu": "Jueves",
|
||||
"fri": "Viernes",
|
||||
"sat": "Sábado",
|
||||
"sun": "Domingo"
|
||||
},
|
||||
"rangeBand": "Rango"
|
||||
},
|
||||
"chart": {
|
||||
"overlay": {
|
||||
@@ -1376,6 +1453,7 @@
|
||||
},
|
||||
"insights": {
|
||||
"noAnalysisYet": "Aún no hay análisis.",
|
||||
"assessmentPreparing": "Preparando tu evaluación…",
|
||||
"emptyTitle": "Aún no hay datos suficientes",
|
||||
"emptyDescription": "Los insights aparecen en cuanto registres algunas mediciones. Empieza por peso o tensión arterial.",
|
||||
"emptyAddMeasurement": "Registrar medición",
|
||||
@@ -1432,6 +1510,8 @@
|
||||
"heroRegenerating": "Generando",
|
||||
"regenerateAnalysis": "Volver a iniciar el análisis",
|
||||
"regenerateSuccess": "Análisis regenerado",
|
||||
"warmAssessments": "Preparar evaluaciones",
|
||||
"warmStarted": "Las evaluaciones se están preparando en segundo plano",
|
||||
"heroFallbackSubtitle": "Una mirada diaria a tus tendencias — directamente de los datos que registras.",
|
||||
"heroGreetingMorning": "Buenos días",
|
||||
"heroGreetingAfternoon": "Buenas tardes",
|
||||
@@ -1498,7 +1578,22 @@
|
||||
},
|
||||
"trendsRow": {
|
||||
"title": "Tendencias",
|
||||
"subtitle": "Los últimos 30 días de un vistazo"
|
||||
"subtitle": "Los últimos 30 días de un vistazo",
|
||||
"caption": {
|
||||
"bp": "Valores sistólicos y diastólicos de los últimos 30 días.",
|
||||
"weight": "Evolución del peso corporal de los últimos 30 días.",
|
||||
"pulse": "Pulso en reposo de los últimos 30 días.",
|
||||
"mood": "Puntuación diaria del estado de ánimo de los últimos 30 días.",
|
||||
"sleep": "Duración del sueño nocturno de los últimos 30 días.",
|
||||
"steps": "Recuento diario de pasos de los últimos 30 días.",
|
||||
"hrv": "Variabilidad de la frecuencia cardíaca de los últimos 30 días.",
|
||||
"resting_hr": "Frecuencia cardíaca en reposo de los últimos 30 días.",
|
||||
"active_energy": "Energía activa quemada en los últimos 30 días.",
|
||||
"flights": "Pisos subidos en los últimos 30 días.",
|
||||
"distance": "Distancia caminando y corriendo de los últimos 30 días.",
|
||||
"vo2_max": "VO₂ máx de los últimos 30 días.",
|
||||
"body_temp": "Temperatura corporal de los últimos 30 días."
|
||||
}
|
||||
},
|
||||
"vo2Max": {
|
||||
"statLatest": "Actual",
|
||||
@@ -1515,6 +1610,23 @@
|
||||
"confidenceLow": "Confianza baja",
|
||||
"pendingLabel": "Generando comentario de tendencia"
|
||||
},
|
||||
"range": {
|
||||
"label": "Período",
|
||||
"noPriorData": "sin datos del período anterior",
|
||||
"option": {
|
||||
"7d": "7d",
|
||||
"30d": "30d",
|
||||
"90d": "90d",
|
||||
"1y": "1a"
|
||||
},
|
||||
"priorLabel": {
|
||||
"7d": "vs 7d anteriores",
|
||||
"30d": "vs 30d anteriores",
|
||||
"90d": "vs 90d anteriores",
|
||||
"1y": "vs año anterior"
|
||||
},
|
||||
"noChange": "sin cambios"
|
||||
},
|
||||
"correlationRow": {
|
||||
"title": "Correlaciones",
|
||||
"subtitle": "Hipótesis predefinidas, con umbral n ≥ 14 y p < 0,05",
|
||||
@@ -1883,7 +1995,23 @@
|
||||
"bloodGlucoseBody": "La glucosa en sangre es la cantidad de azúcar en tu sangre, la principal fuente de energía del cuerpo. Sube tras comer y se estabiliza entre comidas; valores que se mantienen altos con el tiempo pueden indicar diabetes o prediabetes.",
|
||||
"skinTemperatureTitle": "Temperatura de la piel",
|
||||
"skinTemperatureBody": "La temperatura de la piel aquí es el pequeño cambio nocturno de la temperatura de tu muñeca respecto a tu propio valor de referencia, no una medición absoluta de fiebre. Las variaciones pueden seguir el sueño, el entorno, una enfermedad o la fase del ciclo menstrual."
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"label": "Estadísticas resumidas",
|
||||
"min": "Mín",
|
||||
"max": "Máx",
|
||||
"median": "Mediana",
|
||||
"mean": "Media"
|
||||
},
|
||||
"showAllValues": "Mostrar todas las mediciones",
|
||||
"valuesPageTitle": "{metric} — todas las mediciones",
|
||||
"valuesPageDescription": "Cada medición detrás de esta métrica. Edita o elimina una entrada directamente.",
|
||||
"diversity": {
|
||||
"trigger": "Consejo de medición",
|
||||
"weekday": "La mayoría de tus mediciones de {metric} caen en un mismo día de la semana. Registrar en otros días ofrece una imagen más completa.",
|
||||
"timeOfDay": "La mayoría de tus mediciones de {metric} caen a una hora similar del día. Variar la hora ofrece una imagen más completa."
|
||||
},
|
||||
"valuesBack": "Volver a Insights"
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sueño",
|
||||
@@ -2264,7 +2392,75 @@
|
||||
"description": "Las lecturas aparecen aquí cuando Apple Health sincroniza."
|
||||
}
|
||||
},
|
||||
"navWalkingSpeed": "Velocidad al andar"
|
||||
"navWalkingSpeed": "Velocidad al andar",
|
||||
"mood": {
|
||||
"heatmapTitle": "Calendario de ánimo",
|
||||
"heatmapNoEntry": "Sin registro",
|
||||
"legendGreat": "Genial",
|
||||
"legendGood": "Bien",
|
||||
"legendOkay": "Regular",
|
||||
"legendLow": "Bajo",
|
||||
"distributionTitle": "Distribución del ánimo",
|
||||
"distributionDaysUnit": "días",
|
||||
"weekdayTitle": "Promedio por día de la semana",
|
||||
"weekdayNoData": "Sin datos",
|
||||
"weekdayBestWorst": "Mejor día: {best} · Día más difícil: {worst}",
|
||||
"tagsTitle": "Etiquetas y desencadenantes",
|
||||
"tagAvgMood": "Ánimo promedio con esta etiqueta",
|
||||
"correlationsTitle": "Ánimo y otras métricas",
|
||||
"correlationsDescription": "Cómo se mueve tu ánimo con el sueño, la actividad y el pulso el mismo día.",
|
||||
"correlation": {
|
||||
"sleepTitle": "Ánimo vs. sueño",
|
||||
"stepsTitle": "Ánimo vs. actividad",
|
||||
"pulseTitle": "Ánimo vs. pulso",
|
||||
"sleepAxis": "Sueño (min)",
|
||||
"stepsAxis": "Pasos",
|
||||
"pulseAxis": "Pulso (lpm)",
|
||||
"strengthStrong": "Fuerte",
|
||||
"strengthModerate": "Moderada",
|
||||
"strengthWeak": "Débil",
|
||||
"strengthNone": "Ninguna",
|
||||
"source": "{n} días emparejados · r = {r}",
|
||||
"emptyTitle": "Aún no hay suficientes datos emparejados",
|
||||
"emptyDescription": "Registra el ánimo y esta métrica los mismos días para ver una correlación.",
|
||||
"weightTitle": "Ánimo vs. peso",
|
||||
"bloodPressureTitle": "Ánimo vs. presión arterial",
|
||||
"weightAxis": "Peso",
|
||||
"bloodPressureAxis": "Sistólica (mmHg)"
|
||||
},
|
||||
"structuredTagsTitle": "Desglose de etiquetas",
|
||||
"inTargetValue": "{pct} %",
|
||||
"inTargetCaption": "de los últimos días en tu rango de buen ánimo",
|
||||
"narrative": {
|
||||
"title": "Lo que destaca",
|
||||
"weekdayDip": "Tu ánimo baja más el {weekday}: {delta} por debajo de tu semana.",
|
||||
"weekdayPeak": "El {weekday} es siempre tu mejor día, con una media de {value}.",
|
||||
"trendUp": "Tu ánimo va al alza en los últimos {days} días (+{delta} frente al periodo anterior).",
|
||||
"trendDown": "Tu ánimo va a la baja en los últimos {days} días (-{delta} frente al periodo anterior).",
|
||||
"weekendUp": "Te sientes mejor los fines de semana (+{delta} frente a entre semana).",
|
||||
"weekendDown": "Tu ánimo es más bajo los fines de semana (-{delta} frente a entre semana).",
|
||||
"tagLift": "Los días con la etiqueta «{tag}» promedian {delta} por encima de tu ánimo general.",
|
||||
"tagDrop": "Los días con la etiqueta «{tag}» quedan {delta} por debajo de tu media.",
|
||||
"inTarget": "Has estado en tu rango de buen ánimo el {pct} % de los últimos 30 días.",
|
||||
"streak": "Has registrado tu ánimo {days} días seguidos: sigue así.",
|
||||
"timeOfDay": "Te sientes mejor por la {bucket}, con una media de {value}."
|
||||
},
|
||||
"timeOfDay": {
|
||||
"title": "Estado de ánimo por hora del día",
|
||||
"morning": "Mañana",
|
||||
"afternoon": "Tarde",
|
||||
"evening": "Noche",
|
||||
"night": "Madrugada",
|
||||
"bestCaption": "Sueles sentirte mejor por la {bucket}."
|
||||
},
|
||||
"stability": {
|
||||
"value": "{score}/100",
|
||||
"bandVerySteady": "Tu estado de ánimo ha sido muy estable",
|
||||
"bandSteady": "Tu estado de ánimo ha sido bastante estable",
|
||||
"bandVariable": "Tu estado de ánimo ha sido notablemente variable",
|
||||
"bandVeryVariable": "Tu estado de ánimo ha fluctuado mucho últimamente"
|
||||
}
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Por defecto",
|
||||
@@ -2476,7 +2672,7 @@
|
||||
"description": "Withings, moodLog, and other connected services."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Canales de notificación",
|
||||
"title": "Notificaciones",
|
||||
"description": "Live overview of every configured channel."
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -2484,63 +2680,62 @@
|
||||
"description": "Tile layout and order."
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Objetivos y fuentes",
|
||||
"description": "Por métrica: tu propio rango objetivo y qué fuente prevalece cuando varias registran el mismo valor el mismo día."
|
||||
"title": "Objetivos",
|
||||
"description": "Por métrica: tu propio rango objetivo de valores saludables."
|
||||
},
|
||||
"sources": {
|
||||
"title": "Sources",
|
||||
"description": "Pick which ingest source wins when more than one records the same metric for the same day.",
|
||||
"redirectNotice": "Las fuentes ahora forman parte de «Objetivos y fuentes».",
|
||||
"cardTitle": "Source priority by metric",
|
||||
"help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.",
|
||||
"resetDefaults": "Reset to defaults",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"saveSuccess": "Source priority saved.",
|
||||
"saveError": "Saving the source priority failed.",
|
||||
"resetSuccess": "Source priority reset to defaults.",
|
||||
"perMetricToggle": "Per-metric overrides ({count})",
|
||||
"perMetricHelp": "Metrics you have customised away from the default ladder. Use this list to roll any single metric back to its constant default without touching the rest.",
|
||||
"perMetricEmpty": "No per-metric overrides set — every metric uses the global ladder above.",
|
||||
"title": "Fuentes",
|
||||
"description": "Elige qué fuente prevalece cuando varias registran la misma métrica el mismo día.",
|
||||
"cardTitle": "Prioridad de fuentes por métrica",
|
||||
"help": "Cuando más de una fuente entrega el mismo valor el mismo día (p. ej. Withings y Apple Health informan ambos 1200 pasos), la fuente superior cuenta como la lectura canónica. Las demás fuentes se conservan para la traza de auditoría.",
|
||||
"resetDefaults": "Restablecer valores predeterminados",
|
||||
"moveUp": "Subir",
|
||||
"moveDown": "Bajar",
|
||||
"saveSuccess": "Prioridad de fuentes guardada.",
|
||||
"saveError": "No se pudo guardar la prioridad de fuentes.",
|
||||
"resetSuccess": "Prioridad de fuentes restablecida a los valores predeterminados.",
|
||||
"perMetricToggle": "Ajustes por métrica ({count})",
|
||||
"perMetricHelp": "Métricas que has personalizado respecto al orden predeterminado. Usa esta lista para devolver cualquier métrica concreta a su valor predeterminado sin tocar las demás.",
|
||||
"perMetricEmpty": "No hay ajustes por métrica — todas las métricas usan el orden global de arriba.",
|
||||
"resetMetric": "Restablecer",
|
||||
"deviceTypeToggle": "Device-type priority ({count})",
|
||||
"deviceTypeHelp": "Within a source, which device wins. When an Apple Watch and an iPhone both record steps, the higher-ranked device-type counts as the canonical reading.",
|
||||
"deviceTypeDefault": "Default device-type ladder",
|
||||
"resetDeviceTypes": "Reset device-types",
|
||||
"deviceTypeToggle": "Prioridad por tipo de dispositivo ({count})",
|
||||
"deviceTypeHelp": "Dentro de una fuente, qué dispositivo prevalece. Cuando un Apple Watch y un iPhone registran ambos los pasos, el tipo de dispositivo mejor clasificado cuenta como la lectura canónica.",
|
||||
"deviceTypeDefault": "Orden predeterminado por tipo de dispositivo",
|
||||
"resetDeviceTypes": "Restablecer tipos de dispositivo",
|
||||
"metrics": {
|
||||
"steps": "Pasos",
|
||||
"activeEnergy": "Active energy",
|
||||
"walkingRunningDistance": "Walking + running distance",
|
||||
"flightsClimbed": "Flights climbed",
|
||||
"activeEnergy": "Energía activa",
|
||||
"walkingRunningDistance": "Distancia a pie + corriendo",
|
||||
"flightsClimbed": "Pisos subidos",
|
||||
"sleep": "Sueño",
|
||||
"weight": "Peso",
|
||||
"bloodPressure": "Presión arterial",
|
||||
"pulse": "Pulso",
|
||||
"bodyFat": "Grasa corporal",
|
||||
"bodyTemperature": "Body temperature",
|
||||
"spo2": "Oxygen saturation",
|
||||
"hrv": "Heart-rate variability",
|
||||
"bodyTemperature": "Temperatura corporal",
|
||||
"spo2": "Saturación de oxígeno",
|
||||
"hrv": "Variabilidad de la frecuencia cardíaca",
|
||||
"restingHeartRate": "Frecuencia cardíaca en reposo",
|
||||
"vo2Max": "VO2 max"
|
||||
"vo2Max": "VO2 máx."
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
"APPLE_HEALTH": "Apple Health",
|
||||
"MANUAL": "Manual entry",
|
||||
"MANUAL": "Entrada manual",
|
||||
"IMPORT": "Importar"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Watch",
|
||||
"band": "Band",
|
||||
"ring": "Ring",
|
||||
"phone": "Phone",
|
||||
"scale": "Scale",
|
||||
"other": "Other",
|
||||
"watch": "Reloj",
|
||||
"band": "Pulsera",
|
||||
"ring": "Anillo",
|
||||
"phone": "Teléfono",
|
||||
"scale": "Báscula",
|
||||
"other": "Otro",
|
||||
"unknown": "Desconocido"
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
"title": "Insights",
|
||||
"title": "AI Insights",
|
||||
"description": "Provider, model, key."
|
||||
},
|
||||
"api": {
|
||||
@@ -3022,7 +3217,9 @@
|
||||
"fhirNote": "La salida estructurada sigue el estándar FHIR R4 (compatible con ePA).",
|
||||
"generate": "Generar",
|
||||
"error": "Error en la exportación ({code})."
|
||||
}
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente",
|
||||
"globalExcludedInjectionSitesHint": "Los sitios listados aquí no se ofrecen para ningún medicamento y se rechazan al registrar una dosis, aunque un medicamento los prefiera."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -3843,8 +4040,6 @@
|
||||
}
|
||||
},
|
||||
"targets": {
|
||||
"title": "Valores objetivo",
|
||||
"introText": "Tus valores actuales frente a los rangos objetivo recomendados. Las fuentes de las guías clínicas están enlazadas en cada tarjeta.",
|
||||
"bmi": "IMC",
|
||||
"average30d": "Media 30 días:",
|
||||
"consistency": {
|
||||
@@ -3866,23 +4061,11 @@
|
||||
"sixDaysAgo": "hace 6 días"
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"lastMet": "Última vez en objetivo: {when}",
|
||||
"streak": "{count} días seguidos"
|
||||
},
|
||||
"relativeDay": {
|
||||
"today": "Hoy",
|
||||
"yesterday": "Ayer",
|
||||
"daysAgo": "hace {count} días"
|
||||
},
|
||||
"summary": {
|
||||
"weekTitle": "{met} de {total} objetivos cumplidos esta semana",
|
||||
"weekTitleAll": "Todo{total} targets met this week",
|
||||
"streakHighlight": "{count} días seguidos: {metric}"
|
||||
},
|
||||
"coach": {
|
||||
"cta": "Preguntar al coach"
|
||||
},
|
||||
"edit": {
|
||||
"openLabel": "Editar el rango objetivo de {metric}",
|
||||
"title": "Editar rango objetivo — {metric}",
|
||||
@@ -3914,31 +4097,10 @@
|
||||
"aboveTarget": "{delta} {unit} por encima del rango",
|
||||
"belowTarget": "{delta} {unit} por debajo del rango",
|
||||
"diastolic": "Diastólica",
|
||||
"noMeasurementYet": "Aún no hay mediciones",
|
||||
"noMeasurementData": "No hay datos de mediciones. Añade mediciones para ver tus objetivos.",
|
||||
"profileIncomplete": "Perfil incompleto",
|
||||
"profileIncompleteHeight": "Indica tu altura en el perfil para activar los objetivos de IMC.",
|
||||
"profileIncompleteAge": "Indica tu fecha de nacimiento en el perfil para activar los objetivos por edad.",
|
||||
"profileIncompleteHeightAge": "Indica altura y fecha de nacimiento en el perfil para ver objetivos personalizados.",
|
||||
"compliancePerMedication": "Cumplimiento por medicamento",
|
||||
"glucoseFasting": "Glucemia — en ayunas",
|
||||
"glucosePostprandial": "Glucemia — posprandial",
|
||||
"glucoseRandom": "Glucemia — cualquiera",
|
||||
"glucoseBedtime": "Glucemia — antes de dormir",
|
||||
"loadError": "No se han podido cargar los valores objetivo",
|
||||
"label": {
|
||||
"WEIGHT": "Peso",
|
||||
"BLOOD_PRESSURE": "Presión arterial",
|
||||
"BLOOD_PRESSURE_IN_TARGET": "Tensión arterial en rango",
|
||||
"PULSE": "Pulso en reposo",
|
||||
"SLEEP_DURATION": "Duración del sueño",
|
||||
"BMI": "IMC",
|
||||
"BODY_FAT": "Grasa corporal",
|
||||
"ACTIVITY_STEPS": "Pasos/día",
|
||||
"MEDICATION_COMPLIANCE": "Cumplimiento",
|
||||
"MOOD_SCORE": "Estado de ánimo",
|
||||
"MOOD_STABILITY": "Estabilidad del ánimo"
|
||||
},
|
||||
"status": {
|
||||
"underweight": "Peso insuficiente",
|
||||
"normal": "Normal",
|
||||
|
||||
+273
-111
@@ -461,7 +461,49 @@
|
||||
"deleteConfirmTitle": "Supprimer l’entrée ?",
|
||||
"deleteConfirmDescription": "Cette entrée d’humeur sera supprimée définitivement.",
|
||||
"pageInfo": "Page {page} sur {total}",
|
||||
"loginRequired": "Connecte-toi pour voir tes entrées d’humeur."
|
||||
"loginRequired": "Connecte-toi pour voir tes entrées d’humeur.",
|
||||
"note": "Note",
|
||||
"notePlaceholder": "Ajoutez une note sur votre ressenti…",
|
||||
"tagPicker": "Étiquettes",
|
||||
"tagCategory": {
|
||||
"feelings": "Émotions",
|
||||
"sleep": "Sommeil",
|
||||
"health": "Santé",
|
||||
"social": "Social",
|
||||
"work": "Travail"
|
||||
},
|
||||
"tag": {
|
||||
"happy": "heureux",
|
||||
"excited": "enthousiaste",
|
||||
"grateful": "reconnaissant",
|
||||
"relaxed": "détendu",
|
||||
"content": "satisfait",
|
||||
"tired": "fatigué",
|
||||
"unsure": "incertain",
|
||||
"bored": "ennuyé",
|
||||
"tense": "tendu",
|
||||
"angry": "en colère",
|
||||
"stressed": "stressé",
|
||||
"sad": "triste",
|
||||
"sleptWell": "bien dormi",
|
||||
"sleptOk": "sommeil moyen",
|
||||
"sleptPoorly": "mal dormi",
|
||||
"earlyNight": "couché tôt",
|
||||
"workedOut": "fait du sport",
|
||||
"ateWell": "bien mangé",
|
||||
"hydrated": "bu de l'eau",
|
||||
"walked": "marché",
|
||||
"alcohol": "alcool",
|
||||
"family": "famille",
|
||||
"friends": "amis",
|
||||
"party": "fête",
|
||||
"alone": "seul",
|
||||
"productive": "productif",
|
||||
"overtime": "heures sup",
|
||||
"dayOff": "jour de repos",
|
||||
"travel": "voyage",
|
||||
"sickDay": "jour de maladie"
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
"title": "Médicaments",
|
||||
@@ -544,11 +586,14 @@
|
||||
"veryOverdue": "Très en retard",
|
||||
"compliance7d": "Observance 7 jours",
|
||||
"compliance30d": "Observance 30 jours",
|
||||
"complianceWindow": "Observance {days} jours",
|
||||
"lastIntake": "Dernière prise :",
|
||||
"nextIntake": "Prochaine prise :",
|
||||
"dayStreak": "jours de série",
|
||||
"taken": "Pris",
|
||||
"skipped": "Sauté",
|
||||
"intakeToastTaken": "{name} enregistré comme pris",
|
||||
"intakeToastSkipped": "{name} sauté",
|
||||
"weekdaySunday": "Dimanche",
|
||||
"weekdayMonday": "Lundi",
|
||||
"weekdayTuesday": "Mardi",
|
||||
@@ -759,7 +804,11 @@
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"save": "Save",
|
||||
"saveEdit": "Save changes"
|
||||
"saveEdit": "Save changes",
|
||||
"nextHint": "Suivant : {step}",
|
||||
"reviewHint": "Suivant : Enregistrer",
|
||||
"jumpFirst": "Aller à la première étape",
|
||||
"jumpLast": "Aller à la vérification et à l’enregistrement"
|
||||
},
|
||||
"nl": {
|
||||
"button": "Describe"
|
||||
@@ -769,11 +818,13 @@
|
||||
"title": "What's the medication called?",
|
||||
"subline": "Exactly as it appears on the box.",
|
||||
"label": "Name",
|
||||
"placeholder": "e.g. Ramipril"
|
||||
"placeholder": "e.g. Ramipril",
|
||||
"short": "Nom"
|
||||
},
|
||||
"step2": {
|
||||
"title": "What kind of medication is it?",
|
||||
"subline": "We use this for the right templates and analytics."
|
||||
"subline": "We use this for the right templates and analytics.",
|
||||
"short": "Type"
|
||||
},
|
||||
"step3": {
|
||||
"title": "What's the dose?",
|
||||
@@ -805,15 +856,18 @@
|
||||
"dosesPerUnitPlaceholder": "p. ex. 4",
|
||||
"dosesPerUnitHint": "Doses par stylo / flacon — laisser vide pour ne pas suivre le stock.",
|
||||
"injectionSiteToggle": "Rotation des sites d'injection",
|
||||
"injectionSiteHint": "Guide de rotation facultatif. Le site de chaque dose est enregistré lors de la saisie de la prise."
|
||||
"injectionSiteHint": "Guide de rotation facultatif. Le site de chaque dose est enregistré lors de la saisie de la prise.",
|
||||
"short": "Dose"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Over what period are you taking it?",
|
||||
"subline": "A start date is enough for today. You can add an end date later."
|
||||
"subline": "A start date is enough for today. You can add an end date later.",
|
||||
"short": "Quand"
|
||||
},
|
||||
"step5": {
|
||||
"title": "How often do you take it?",
|
||||
"subline": "Pick the cadence — the details follow on the next step."
|
||||
"subline": "Pick the cadence — the details follow on the next step.",
|
||||
"short": "Fréquence"
|
||||
},
|
||||
"step6": {
|
||||
"title": "What does that look like in detail?",
|
||||
@@ -829,7 +883,8 @@
|
||||
"rollingDays": {
|
||||
"label": "Every",
|
||||
"suffix": "days from the last intake"
|
||||
}
|
||||
},
|
||||
"short": "Détail"
|
||||
},
|
||||
"step7": {
|
||||
"title": "When do you take it?",
|
||||
@@ -840,14 +895,16 @@
|
||||
"noon": "Noon",
|
||||
"evening": "Evening",
|
||||
"night": "Night"
|
||||
}
|
||||
},
|
||||
"short": "Heures"
|
||||
},
|
||||
"step8": {
|
||||
"title": "All done — should HealthLog remind you?",
|
||||
"subline": "Review everything below before you save.",
|
||||
"remindersLabel": "Reminders on",
|
||||
"remindersDescription": "Push, Telegram or ntfy — based on your settings.",
|
||||
"multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release."
|
||||
"multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release.",
|
||||
"short": "Fini"
|
||||
}
|
||||
},
|
||||
"classRow": {
|
||||
@@ -923,26 +980,17 @@
|
||||
"submitFailed": "Could not save the medication — please retry."
|
||||
}
|
||||
},
|
||||
"titration": {
|
||||
"section": "Paliers de titration",
|
||||
"loading": "Chargement des données de titration",
|
||||
"loadFailed": "Échec du chargement des données de titration.",
|
||||
"drugLadderHeader": "Paliers standard (référence EMA)",
|
||||
"typicalWeeksOnStep": "≥ {weeks} sem. typique",
|
||||
"youAreHere": "Vous êtes ici",
|
||||
"nextStepCaption": "Palier suivant (typique) : {dose} mg après ~{weeks} semaines au palier actuel.",
|
||||
"ceilingMessage": "Vous êtes au sommet du palier standard.",
|
||||
"escalationDueHint": "Vous êtes à ce palier depuis {weeks} semaines ; le schéma prévoit une progression typique après ~{typical} semaines.",
|
||||
"currentStepDwell": "À ce palier depuis {weeks} semaines",
|
||||
"doseUnitMg": "mg",
|
||||
"emptyState": "Aucun historique de dose. Enregistrez une dose pour voir votre position dans le schéma.",
|
||||
"nonStandardDose": "La dose actuelle est en dehors des paliers standard.",
|
||||
"disclaimer": "Valeurs de référence EMA. Consultez votre médecin avant toute modification de dose.",
|
||||
"emaSourceCta": "Source EMA",
|
||||
"stepLabel": "Palier {n}"
|
||||
"doseStrength": {
|
||||
"title": "Dose au fil du temps",
|
||||
"axisLabel": "Dose enregistrée ({unit})",
|
||||
"tooltipLabel": "Dose",
|
||||
"currentDose": "Dose actuelle : {dose} {unit}",
|
||||
"noCurrentDose": "Aucune dose enregistrée pour l'instant.",
|
||||
"empty": "Aucun historique de titration. Enregistrez un changement de dose pour voir la courbe de dose."
|
||||
},
|
||||
"injectionSiteBodyOutlineAriaLabel": "Silhouette du corps",
|
||||
"injectionSitePickerAriaLabel": "Sélecteur de site d'injection",
|
||||
"injectionSiteRecommendedAriaLabel": "{site} — prochain site recommandé",
|
||||
"siteAbdomenLeft": "Abdomen, en bas à gauche",
|
||||
"siteAbdomenRight": "Abdomen, en bas à droite",
|
||||
"siteAbdomenUpperLeft": "Abdomen, en haut à gauche",
|
||||
@@ -1005,9 +1053,10 @@
|
||||
"phaseSaved": "Phases de rappel enregistrées",
|
||||
"phaseReset": "Réinitialisées par défaut",
|
||||
"sourceWeb": "Web",
|
||||
"sourceApi": "API",
|
||||
"sourceApi": "App iOS",
|
||||
"sourceReminder": "Rappel",
|
||||
"sourceImport": "Import",
|
||||
"intakeSourceVia": "via {origin}",
|
||||
"intakeHistoryEmptyTitle": "Aucune prise pour l’instant",
|
||||
"intakeHistoryEmptyDescription": "Dès que tu enregistreras une prise, elle apparaîtra ici avec l’heure et la source.",
|
||||
"intakeHistoryEmptyAction": "Enregistrer une prise",
|
||||
@@ -1078,11 +1127,6 @@
|
||||
"emptyState": "Aucune prise enregistrée.",
|
||||
"emptyStateCta": "Enregistrez une dose pour voir la courbe estimée prendre forme.",
|
||||
"estimateNote": "Estimation éducative à partir de la pharmacocinétique de population publiée par l'EMA. Pas une mesure.",
|
||||
"gatedTitle": "Mode Recherche désactivé",
|
||||
"gatedBody": "Activez le mode Recherche dans les paramètres pour voir la courbe estimée du niveau de médicament. La courbe est en opt-in parce que c'est un outil d'apprentissage, pas une mesure clinique.",
|
||||
"gatedStaleTitle": "Le disclaimer doit être reconfirmé",
|
||||
"gatedStaleBody": "Le disclaimer du mode Recherche a été mis à jour depuis votre dernière confirmation. Relisez-le dans les paramètres et confirmez à nouveau pour réafficher la courbe.",
|
||||
"gatedCta": "Ouvrir le mode Recherche dans les paramètres",
|
||||
"unknownDrug": "{name} n'est pas dans le catalogue GLP-1 approuvé par l'EMA, donc HealthLog ne peut pas tracer de courbe de pharmacocinétique de population."
|
||||
}
|
||||
},
|
||||
@@ -1170,6 +1214,14 @@
|
||||
"unit": "minutes",
|
||||
"saved": "Reminder window saved",
|
||||
"failed": "Could not save the reminder window."
|
||||
},
|
||||
"codes": {
|
||||
"label": "Codes cliniques (ATC / RxNorm)",
|
||||
"note": "Facultatif. Utilisés lors de l'export FHIR du dossier de santé. Les codes sont repris tels qu'ils sont saisis — jamais devinés.",
|
||||
"atcLabel": "ATC",
|
||||
"rxNormLabel": "RxNorm",
|
||||
"saved": "Codes cliniques enregistrés",
|
||||
"failed": "Impossible d'enregistrer les codes cliniques."
|
||||
}
|
||||
},
|
||||
"zone": {
|
||||
@@ -1235,7 +1287,7 @@
|
||||
"data": "Données",
|
||||
"reminders": "Rappels",
|
||||
"lifecycle": "Cycle de vie",
|
||||
"danger": "Zone de danger"
|
||||
"externalApi": "API externe"
|
||||
},
|
||||
"dataPortability": {
|
||||
"import": {
|
||||
@@ -1258,10 +1310,18 @@
|
||||
"history": {
|
||||
"back": "Retour au médicament",
|
||||
"subtitle": "Historique complet des prises",
|
||||
"plannedSuffix": "prévu",
|
||||
"drugLevelDisclosure": "Afficher la courbe estimée du principe actif"
|
||||
"plannedSuffix": "prévu"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logInjectionSiteTitle": "Enregistrer le site d'injection",
|
||||
"logInjectionSiteDescription": "Où avez-vous injecté {name} ? Facultatif — vous pouvez l'ignorer.",
|
||||
"logInjectionSiteSkip": "Ignorer",
|
||||
"logInjectionSiteConfirm": "Enregistrer le site",
|
||||
"logInjectionSiteNoneAvailable": "Aucun site d'injection disponible — tous les sites sont exclus dans vos réglages.",
|
||||
"trackInjectionSitesToggle": "Suivre les sites d'injection",
|
||||
"trackInjectionSitesHint": "Après la prise d'une dose, demander quel site a été utilisé et suggérer la prochaine rotation.",
|
||||
"allowedSitesLabel": "Sites autorisés",
|
||||
"allowedSitesHint": "Laissez tout sélectionné pour aucune restriction. Les sites exclus globalement ne sont jamais proposés."
|
||||
},
|
||||
"charts": {
|
||||
"loadingLabel": "Chargement du graphique…",
|
||||
@@ -1272,6 +1332,13 @@
|
||||
"bodyFat": "Masse grasse",
|
||||
"sleep": "Sommeil",
|
||||
"steps": "Pas",
|
||||
"hrv": "VFC",
|
||||
"restingHeartRate": "FC au repos",
|
||||
"activeEnergy": "Énergie active",
|
||||
"flights": "Étages gravis",
|
||||
"distance": "Distance marche + course",
|
||||
"vo2Max": "VO₂ max",
|
||||
"bodyTemperature": "Température corporelle",
|
||||
"noData": "Aucune donnée sur la période sélectionnée",
|
||||
"bucketWeekly": "Moyenne hebdomadaire",
|
||||
"bucketMonthly": "Moyenne mensuelle",
|
||||
@@ -1336,7 +1403,17 @@
|
||||
"personalBaseline": "Ta moyenne",
|
||||
"deltaVsBaseline": "{delta} vs. ta moyenne",
|
||||
"deltaUnchanged": "correspond à ta moyenne",
|
||||
"deltaVsTarget": "{delta} vs. cible"
|
||||
"deltaVsTarget": "{delta} vs. cible",
|
||||
"weekdaysFull": {
|
||||
"mon": "Lundi",
|
||||
"tue": "Mardi",
|
||||
"wed": "Mercredi",
|
||||
"thu": "Jeudi",
|
||||
"fri": "Vendredi",
|
||||
"sat": "Samedi",
|
||||
"sun": "Dimanche"
|
||||
},
|
||||
"rangeBand": "Plage"
|
||||
},
|
||||
"chart": {
|
||||
"overlay": {
|
||||
@@ -1376,6 +1453,7 @@
|
||||
},
|
||||
"insights": {
|
||||
"noAnalysisYet": "Pas encore d’analyse.",
|
||||
"assessmentPreparing": "Préparation de votre évaluation…",
|
||||
"emptyTitle": "Pas encore assez de données",
|
||||
"emptyDescription": "Les insights apparaissent dès que tu auras saisi quelques mesures. Commence par le poids ou la tension.",
|
||||
"emptyAddMeasurement": "Saisir une mesure",
|
||||
@@ -1432,6 +1510,8 @@
|
||||
"heroRegenerating": "Génération en cours",
|
||||
"regenerateAnalysis": "Relancer l’analyse",
|
||||
"regenerateSuccess": "Analyse regénérée",
|
||||
"warmAssessments": "Préparer les évaluations",
|
||||
"warmStarted": "Les évaluations sont en cours de préparation en arrière-plan",
|
||||
"heroFallbackSubtitle": "Un coup d’œil quotidien sur tes tendances — directement issu des chiffres que tu saisis.",
|
||||
"heroGreetingMorning": "Bonjour",
|
||||
"heroGreetingAfternoon": "Bonjour",
|
||||
@@ -1498,7 +1578,22 @@
|
||||
},
|
||||
"trendsRow": {
|
||||
"title": "Tendances",
|
||||
"subtitle": "Les 30 derniers jours en un coup d’œil"
|
||||
"subtitle": "Les 30 derniers jours en un coup d’œil",
|
||||
"caption": {
|
||||
"bp": "Valeurs systoliques et diastoliques des 30 derniers jours.",
|
||||
"weight": "Évolution du poids corporel sur les 30 derniers jours.",
|
||||
"pulse": "Pouls au repos des 30 derniers jours.",
|
||||
"mood": "Score d'humeur quotidien des 30 derniers jours.",
|
||||
"sleep": "Durée du sommeil nocturne des 30 derniers jours.",
|
||||
"steps": "Nombre de pas quotidiens des 30 derniers jours.",
|
||||
"hrv": "Variabilité de la fréquence cardiaque des 30 derniers jours.",
|
||||
"resting_hr": "Fréquence cardiaque au repos des 30 derniers jours.",
|
||||
"active_energy": "Énergie active dépensée sur les 30 derniers jours.",
|
||||
"flights": "Étages gravis sur les 30 derniers jours.",
|
||||
"distance": "Distance de marche et de course des 30 derniers jours.",
|
||||
"vo2_max": "VO₂ max des 30 derniers jours.",
|
||||
"body_temp": "Température corporelle des 30 derniers jours."
|
||||
}
|
||||
},
|
||||
"vo2Max": {
|
||||
"statLatest": "Actuel",
|
||||
@@ -1515,6 +1610,23 @@
|
||||
"confidenceLow": "Confiance faible",
|
||||
"pendingLabel": "Commentaire de tendance en cours de génération"
|
||||
},
|
||||
"range": {
|
||||
"label": "Période",
|
||||
"noPriorData": "aucune donnée sur la période précédente",
|
||||
"option": {
|
||||
"7d": "7j",
|
||||
"30d": "30j",
|
||||
"90d": "90j",
|
||||
"1y": "1an"
|
||||
},
|
||||
"priorLabel": {
|
||||
"7d": "vs 7j précédents",
|
||||
"30d": "vs 30j précédents",
|
||||
"90d": "vs 90j précédents",
|
||||
"1y": "vs année précédente"
|
||||
},
|
||||
"noChange": "aucun changement"
|
||||
},
|
||||
"correlationRow": {
|
||||
"title": "Corrélations",
|
||||
"subtitle": "Hypothèses prédéfinies, validées si n ≥ 14 et p < 0,05",
|
||||
@@ -1883,7 +1995,23 @@
|
||||
"bloodGlucoseBody": "La glycémie est la quantité de sucre dans votre sang, principale source d'énergie de l'organisme. Elle augmente après les repas et se stabilise entre eux ; des valeurs durablement élevées peuvent signaler un diabète ou un prédiabète.",
|
||||
"skinTemperatureTitle": "Température cutanée",
|
||||
"skinTemperatureBody": "La température cutanée correspond ici à la petite variation nocturne de la température de votre poignet par rapport à votre propre référence, et non à une mesure absolue de fièvre. Les variations peuvent suivre le sommeil, l'environnement, une maladie ou la phase du cycle menstruel."
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"label": "Statistiques récapitulatives",
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"median": "Médiane",
|
||||
"mean": "Moyenne"
|
||||
},
|
||||
"showAllValues": "Afficher toutes les mesures",
|
||||
"valuesPageTitle": "{metric} — toutes les mesures",
|
||||
"valuesPageDescription": "Chaque mesure derrière cette métrique. Modifiez ou supprimez une entrée directement.",
|
||||
"diversity": {
|
||||
"trigger": "Conseil de mesure",
|
||||
"weekday": "La plupart de vos mesures de {metric} tombent le même jour de la semaine. Enregistrer d'autres jours donne une image plus complète.",
|
||||
"timeOfDay": "La plupart de vos mesures de {metric} tombent à une heure similaire de la journée. Varier l'heure donne une image plus complète."
|
||||
},
|
||||
"valuesBack": "Retour aux Insights"
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sommeil",
|
||||
@@ -2264,7 +2392,75 @@
|
||||
"description": "Les relevés apparaissent ici dès qu’Apple Health se synchronise."
|
||||
}
|
||||
},
|
||||
"navWalkingSpeed": "Vitesse de marche"
|
||||
"navWalkingSpeed": "Vitesse de marche",
|
||||
"mood": {
|
||||
"heatmapTitle": "Calendrier d'humeur",
|
||||
"heatmapNoEntry": "Aucune entrée",
|
||||
"legendGreat": "Excellent",
|
||||
"legendGood": "Bien",
|
||||
"legendOkay": "Correct",
|
||||
"legendLow": "Bas",
|
||||
"distributionTitle": "Répartition de l'humeur",
|
||||
"distributionDaysUnit": "jours",
|
||||
"weekdayTitle": "Moyenne par jour de la semaine",
|
||||
"weekdayNoData": "Aucune donnée",
|
||||
"weekdayBestWorst": "Meilleur jour : {best} · Jour le plus difficile : {worst}",
|
||||
"tagsTitle": "Étiquettes et déclencheurs",
|
||||
"tagAvgMood": "Humeur moyenne avec cette étiquette",
|
||||
"correlationsTitle": "Humeur et autres mesures",
|
||||
"correlationsDescription": "Comment votre humeur évolue avec le sommeil, l'activité et le pouls le même jour.",
|
||||
"correlation": {
|
||||
"sleepTitle": "Humeur vs. sommeil",
|
||||
"stepsTitle": "Humeur vs. activité",
|
||||
"pulseTitle": "Humeur vs. pouls",
|
||||
"sleepAxis": "Sommeil (min)",
|
||||
"stepsAxis": "Pas",
|
||||
"pulseAxis": "Pouls (bpm)",
|
||||
"strengthStrong": "Forte",
|
||||
"strengthModerate": "Modérée",
|
||||
"strengthWeak": "Faible",
|
||||
"strengthNone": "Aucune",
|
||||
"source": "{n} jours appariés · r = {r}",
|
||||
"emptyTitle": "Pas encore assez de données appariées",
|
||||
"emptyDescription": "Enregistrez l'humeur et cette mesure les mêmes jours pour voir une corrélation.",
|
||||
"weightTitle": "Humeur vs. poids",
|
||||
"bloodPressureTitle": "Humeur vs. tension artérielle",
|
||||
"weightAxis": "Poids",
|
||||
"bloodPressureAxis": "Systolique (mmHg)"
|
||||
},
|
||||
"structuredTagsTitle": "Répartition des étiquettes",
|
||||
"inTargetValue": "{pct} %",
|
||||
"inTargetCaption": "des derniers jours dans votre plage de bonne humeur",
|
||||
"narrative": {
|
||||
"title": "Ce qui ressort",
|
||||
"weekdayDip": "Votre humeur baisse surtout le {weekday} — {delta} sous votre semaine.",
|
||||
"weekdayPeak": "Le {weekday} est toujours votre meilleur jour, avec une moyenne de {value}.",
|
||||
"trendUp": "Votre humeur progresse sur les {days} derniers jours (+{delta} par rapport à la période précédente).",
|
||||
"trendDown": "Votre humeur recule sur les {days} derniers jours (-{delta} par rapport à la période précédente).",
|
||||
"weekendUp": "Vous vous sentez mieux le week-end (+{delta} par rapport à la semaine).",
|
||||
"weekendDown": "Votre humeur est plus basse le week-end (-{delta} par rapport à la semaine).",
|
||||
"tagLift": "Les jours marqués « {tag} » dépassent votre humeur générale de {delta} en moyenne.",
|
||||
"tagDrop": "Les jours marqués « {tag} » sont {delta} sous votre moyenne.",
|
||||
"inTarget": "Vous avez été dans votre plage de bonne humeur {pct} % des 30 derniers jours.",
|
||||
"streak": "Vous avez noté votre humeur {days} jours d'affilée — continuez.",
|
||||
"timeOfDay": "Vous vous sentez mieux le {bucket}, en moyenne {value}."
|
||||
},
|
||||
"timeOfDay": {
|
||||
"title": "Humeur selon l'heure de la journée",
|
||||
"morning": "Matin",
|
||||
"afternoon": "Après-midi",
|
||||
"evening": "Soir",
|
||||
"night": "Nuit",
|
||||
"bestCaption": "Vous vous sentez généralement mieux le {bucket}."
|
||||
},
|
||||
"stability": {
|
||||
"value": "{score}/100",
|
||||
"bandVerySteady": "Votre humeur a été très stable",
|
||||
"bandSteady": "Votre humeur a été plutôt stable",
|
||||
"bandVariable": "Votre humeur a été nettement variable",
|
||||
"bandVeryVariable": "Votre humeur a beaucoup fluctué récemment"
|
||||
}
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Par défaut",
|
||||
@@ -2476,7 +2672,7 @@
|
||||
"description": "Withings, moodLog, and other connected services."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Canaux de notification",
|
||||
"title": "Notifications",
|
||||
"description": "Live overview of every configured channel."
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -2484,63 +2680,62 @@
|
||||
"description": "Tile layout and order."
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Objectifs et sources",
|
||||
"description": "Par métrique : votre propre plage cible et quelle source prévaut quand plusieurs enregistrent la même valeur le même jour."
|
||||
"title": "Objectifs",
|
||||
"description": "Par métrique : votre propre plage cible pour des valeurs saines."
|
||||
},
|
||||
"sources": {
|
||||
"title": "Sources",
|
||||
"description": "Pick which ingest source wins when more than one records the same metric for the same day.",
|
||||
"redirectNotice": "Les sources font désormais partie de « Objectifs et sources ».",
|
||||
"cardTitle": "Source priority by metric",
|
||||
"help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.",
|
||||
"resetDefaults": "Reset to defaults",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"saveSuccess": "Source priority saved.",
|
||||
"saveError": "Saving the source priority failed.",
|
||||
"resetSuccess": "Source priority reset to defaults.",
|
||||
"perMetricToggle": "Per-metric overrides ({count})",
|
||||
"perMetricHelp": "Metrics you have customised away from the default ladder. Use this list to roll any single metric back to its constant default without touching the rest.",
|
||||
"perMetricEmpty": "No per-metric overrides set — every metric uses the global ladder above.",
|
||||
"description": "Choisissez quelle source prévaut quand plusieurs enregistrent la même métrique le même jour.",
|
||||
"cardTitle": "Priorité des sources par métrique",
|
||||
"help": "Lorsque plusieurs sources fournissent la même valeur le même jour (p. ex. Withings et Apple Health indiquent tous deux 1 200 pas), la source en tête compte comme la lecture canonique. Les autres sources restent enregistrées pour la piste d’audit.",
|
||||
"resetDefaults": "Rétablir les valeurs par défaut",
|
||||
"moveUp": "Monter",
|
||||
"moveDown": "Descendre",
|
||||
"saveSuccess": "Priorité des sources enregistrée.",
|
||||
"saveError": "Échec de l’enregistrement de la priorité des sources.",
|
||||
"resetSuccess": "Priorité des sources rétablie aux valeurs par défaut.",
|
||||
"perMetricToggle": "Réglages par métrique ({count})",
|
||||
"perMetricHelp": "Métriques que vous avez personnalisées par rapport à l’ordre par défaut. Utilisez cette liste pour rétablir une métrique donnée à sa valeur par défaut sans toucher aux autres.",
|
||||
"perMetricEmpty": "Aucun réglage par métrique — chaque métrique utilise l’ordre global ci-dessus.",
|
||||
"resetMetric": "Réinitialiser",
|
||||
"deviceTypeToggle": "Device-type priority ({count})",
|
||||
"deviceTypeHelp": "Within a source, which device wins. When an Apple Watch and an iPhone both record steps, the higher-ranked device-type counts as the canonical reading.",
|
||||
"deviceTypeDefault": "Default device-type ladder",
|
||||
"resetDeviceTypes": "Reset device-types",
|
||||
"deviceTypeToggle": "Priorité par type d’appareil ({count})",
|
||||
"deviceTypeHelp": "Au sein d’une source, quel appareil l’emporte. Lorsqu’une Apple Watch et un iPhone enregistrent tous deux les pas, le type d’appareil le mieux classé compte comme la lecture canonique.",
|
||||
"deviceTypeDefault": "Ordre par défaut des types d’appareil",
|
||||
"resetDeviceTypes": "Réinitialiser les types d’appareil",
|
||||
"metrics": {
|
||||
"steps": "Pas",
|
||||
"activeEnergy": "Active energy",
|
||||
"walkingRunningDistance": "Walking + running distance",
|
||||
"flightsClimbed": "Flights climbed",
|
||||
"activeEnergy": "Énergie active",
|
||||
"walkingRunningDistance": "Distance marche + course",
|
||||
"flightsClimbed": "Étages gravis",
|
||||
"sleep": "Sommeil",
|
||||
"weight": "Poids",
|
||||
"bloodPressure": "Tension artérielle",
|
||||
"pulse": "Pouls",
|
||||
"bodyFat": "Masse grasse",
|
||||
"bodyTemperature": "Body temperature",
|
||||
"spo2": "Oxygen saturation",
|
||||
"hrv": "Heart-rate variability",
|
||||
"bodyTemperature": "Température corporelle",
|
||||
"spo2": "Saturation en oxygène",
|
||||
"hrv": "Variabilité de la fréquence cardiaque",
|
||||
"restingHeartRate": "Fréquence cardiaque au repos",
|
||||
"vo2Max": "VO2 max"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
"APPLE_HEALTH": "Apple Health",
|
||||
"MANUAL": "Manual entry",
|
||||
"MANUAL": "Saisie manuelle",
|
||||
"IMPORT": "Importer"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Watch",
|
||||
"band": "Band",
|
||||
"ring": "Ring",
|
||||
"phone": "Phone",
|
||||
"scale": "Scale",
|
||||
"other": "Other",
|
||||
"watch": "Montre",
|
||||
"band": "Bracelet",
|
||||
"ring": "Bague",
|
||||
"phone": "Téléphone",
|
||||
"scale": "Balance",
|
||||
"other": "Autre",
|
||||
"unknown": "Inconnu"
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
"title": "Insights",
|
||||
"title": "AI Insights",
|
||||
"description": "Provider, model, key."
|
||||
},
|
||||
"api": {
|
||||
@@ -3022,7 +3217,9 @@
|
||||
"fhirNote": "La sortie structurée suit la norme FHIR R4 (compatible ePA).",
|
||||
"generate": "Générer",
|
||||
"error": "Échec de l’export ({code})."
|
||||
}
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement",
|
||||
"globalExcludedInjectionSitesHint": "Les sites listés ici ne sont proposés pour aucun médicament et sont refusés lors de l'enregistrement d'une dose, même si un médicament les privilégie."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -3843,8 +4040,6 @@
|
||||
}
|
||||
},
|
||||
"targets": {
|
||||
"title": "Valeurs cibles",
|
||||
"introText": "Tes valeurs actuelles face aux plages cibles recommandées. Les sources des recommandations sont liées sur chaque carte.",
|
||||
"bmi": "IMC",
|
||||
"average30d": "Moy. 30 jours :",
|
||||
"consistency": {
|
||||
@@ -3866,23 +4061,11 @@
|
||||
"sixDaysAgo": "il y a 6 jours"
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"lastMet": "Dernier objectif atteint : {when}",
|
||||
"streak": "{count} jours d’affilée"
|
||||
},
|
||||
"relativeDay": {
|
||||
"today": "Aujourd'hui",
|
||||
"yesterday": "Hier",
|
||||
"daysAgo": "il y a {count} jours"
|
||||
},
|
||||
"summary": {
|
||||
"weekTitle": "{met} cibles sur {total} atteintes cette semaine",
|
||||
"weekTitleAll": "Tout{total} targets met this week",
|
||||
"streakHighlight": "{count} jours d’affilée : {metric}"
|
||||
},
|
||||
"coach": {
|
||||
"cta": "Demander au coach"
|
||||
},
|
||||
"edit": {
|
||||
"openLabel": "Modifier la plage cible de {metric}",
|
||||
"title": "Modifier la plage cible — {metric}",
|
||||
@@ -3914,31 +4097,10 @@
|
||||
"aboveTarget": "{delta} {unit} au-dessus de la plage",
|
||||
"belowTarget": "{delta} {unit} en dessous de la plage",
|
||||
"diastolic": "Diastolique",
|
||||
"noMeasurementYet": "Pas encore de mesures",
|
||||
"noMeasurementData": "Pas de données de mesure. Ajoute des mesures pour voir tes objectifs.",
|
||||
"profileIncomplete": "Profil incomplet",
|
||||
"profileIncompleteHeight": "Renseigne ta taille dans le profil pour activer les objectifs IMC.",
|
||||
"profileIncompleteAge": "Renseigne ta date de naissance dans le profil pour activer les objectifs liés à l’âge.",
|
||||
"profileIncompleteHeightAge": "Renseigne taille et date de naissance dans le profil pour voir des objectifs personnalisés.",
|
||||
"compliancePerMedication": "Observance par médicament",
|
||||
"glucoseFasting": "Glycémie — à jeun",
|
||||
"glucosePostprandial": "Glycémie — postprandiale",
|
||||
"glucoseRandom": "Glycémie — indifférente",
|
||||
"glucoseBedtime": "Glycémie — avant le coucher",
|
||||
"loadError": "Impossible de charger les valeurs cibles",
|
||||
"label": {
|
||||
"WEIGHT": "Poids",
|
||||
"BLOOD_PRESSURE": "Tension artérielle",
|
||||
"BLOOD_PRESSURE_IN_TARGET": "Tension dans la cible",
|
||||
"PULSE": "Pouls au repos",
|
||||
"SLEEP_DURATION": "Durée du sommeil",
|
||||
"BMI": "IMC",
|
||||
"BODY_FAT": "Masse grasse",
|
||||
"ACTIVITY_STEPS": "Pas/jour",
|
||||
"MEDICATION_COMPLIANCE": "Observance",
|
||||
"MOOD_SCORE": "Humeur",
|
||||
"MOOD_STABILITY": "Stabilité de l’humeur"
|
||||
},
|
||||
"status": {
|
||||
"underweight": "Insuffisance pondérale",
|
||||
"normal": "Normal",
|
||||
|
||||
+274
-112
@@ -461,7 +461,49 @@
|
||||
"deleteConfirmTitle": "Eliminare la voce?",
|
||||
"deleteConfirmDescription": "Questa voce dell’umore sarà eliminata in modo definitivo.",
|
||||
"pageInfo": "Pagina {page} di {total}",
|
||||
"loginRequired": "Accedi per vedere le voci dell’umore."
|
||||
"loginRequired": "Accedi per vedere le voci dell’umore.",
|
||||
"note": "Nota",
|
||||
"notePlaceholder": "Aggiungi una nota su come ti senti…",
|
||||
"tagPicker": "Etichette",
|
||||
"tagCategory": {
|
||||
"feelings": "Emozioni",
|
||||
"sleep": "Sonno",
|
||||
"health": "Salute",
|
||||
"social": "Sociale",
|
||||
"work": "Lavoro"
|
||||
},
|
||||
"tag": {
|
||||
"happy": "felice",
|
||||
"excited": "entusiasta",
|
||||
"grateful": "grato",
|
||||
"relaxed": "rilassato",
|
||||
"content": "soddisfatto",
|
||||
"tired": "stanco",
|
||||
"unsure": "incerto",
|
||||
"bored": "annoiato",
|
||||
"tense": "teso",
|
||||
"angry": "arrabbiato",
|
||||
"stressed": "stressato",
|
||||
"sad": "triste",
|
||||
"sleptWell": "dormito bene",
|
||||
"sleptOk": "dormito così così",
|
||||
"sleptPoorly": "dormito male",
|
||||
"earlyNight": "a letto presto",
|
||||
"workedOut": "allenato",
|
||||
"ateWell": "mangiato bene",
|
||||
"hydrated": "bevuto acqua",
|
||||
"walked": "camminato",
|
||||
"alcohol": "alcol",
|
||||
"family": "famiglia",
|
||||
"friends": "amici",
|
||||
"party": "festa",
|
||||
"alone": "da solo",
|
||||
"productive": "produttivo",
|
||||
"overtime": "straordinari",
|
||||
"dayOff": "giorno libero",
|
||||
"travel": "viaggio",
|
||||
"sickDay": "giorno di malattia"
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
"title": "Farmaci",
|
||||
@@ -544,11 +586,14 @@
|
||||
"veryOverdue": "Molto in ritardo",
|
||||
"compliance7d": "Aderenza 7 giorni",
|
||||
"compliance30d": "Aderenza 30 giorni",
|
||||
"complianceWindow": "Aderenza {days} giorni",
|
||||
"lastIntake": "Ultima assunzione:",
|
||||
"nextIntake": "Prossima assunzione:",
|
||||
"dayStreak": "giorni di serie",
|
||||
"taken": "Assunta",
|
||||
"skipped": "Saltata",
|
||||
"intakeToastTaken": "{name} registrata come assunta",
|
||||
"intakeToastSkipped": "{name} saltata",
|
||||
"weekdaySunday": "Domenica",
|
||||
"weekdayMonday": "Lunedì",
|
||||
"weekdayTuesday": "Martedì",
|
||||
@@ -759,7 +804,11 @@
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"save": "Save",
|
||||
"saveEdit": "Save changes"
|
||||
"saveEdit": "Save changes",
|
||||
"nextHint": "Avanti: {step}",
|
||||
"reviewHint": "Avanti: Salva",
|
||||
"jumpFirst": "Vai al primo passaggio",
|
||||
"jumpLast": "Vai a revisione e salvataggio"
|
||||
},
|
||||
"nl": {
|
||||
"button": "Describe"
|
||||
@@ -769,11 +818,13 @@
|
||||
"title": "What's the medication called?",
|
||||
"subline": "Exactly as it appears on the box.",
|
||||
"label": "Name",
|
||||
"placeholder": "e.g. Ramipril"
|
||||
"placeholder": "e.g. Ramipril",
|
||||
"short": "Nome"
|
||||
},
|
||||
"step2": {
|
||||
"title": "What kind of medication is it?",
|
||||
"subline": "We use this for the right templates and analytics."
|
||||
"subline": "We use this for the right templates and analytics.",
|
||||
"short": "Tipo"
|
||||
},
|
||||
"step3": {
|
||||
"title": "What's the dose?",
|
||||
@@ -805,15 +856,18 @@
|
||||
"dosesPerUnitPlaceholder": "es. 4",
|
||||
"dosesPerUnitHint": "Dosi per penna / fiala — lascia vuoto per non tracciare le scorte.",
|
||||
"injectionSiteToggle": "Rotazione del sito di iniezione",
|
||||
"injectionSiteHint": "Guida alla rotazione facoltativa. Il sito di ogni dose viene registrato al momento della registrazione dell'assunzione."
|
||||
"injectionSiteHint": "Guida alla rotazione facoltativa. Il sito di ogni dose viene registrato al momento della registrazione dell'assunzione.",
|
||||
"short": "Dose"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Over what period are you taking it?",
|
||||
"subline": "A start date is enough for today. You can add an end date later."
|
||||
"subline": "A start date is enough for today. You can add an end date later.",
|
||||
"short": "Quando"
|
||||
},
|
||||
"step5": {
|
||||
"title": "How often do you take it?",
|
||||
"subline": "Pick the cadence — the details follow on the next step."
|
||||
"subline": "Pick the cadence — the details follow on the next step.",
|
||||
"short": "Frequenza"
|
||||
},
|
||||
"step6": {
|
||||
"title": "What does that look like in detail?",
|
||||
@@ -829,7 +883,8 @@
|
||||
"rollingDays": {
|
||||
"label": "Every",
|
||||
"suffix": "days from the last intake"
|
||||
}
|
||||
},
|
||||
"short": "Dettaglio"
|
||||
},
|
||||
"step7": {
|
||||
"title": "When do you take it?",
|
||||
@@ -840,14 +895,16 @@
|
||||
"noon": "Noon",
|
||||
"evening": "Evening",
|
||||
"night": "Night"
|
||||
}
|
||||
},
|
||||
"short": "Orari"
|
||||
},
|
||||
"step8": {
|
||||
"title": "All done — should HealthLog remind you?",
|
||||
"subline": "Review everything below before you save.",
|
||||
"remindersLabel": "Reminders on",
|
||||
"remindersDescription": "Push, Telegram or ntfy — based on your settings.",
|
||||
"multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release."
|
||||
"multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release.",
|
||||
"short": "Fatto"
|
||||
}
|
||||
},
|
||||
"classRow": {
|
||||
@@ -923,26 +980,17 @@
|
||||
"submitFailed": "Could not save the medication — please retry."
|
||||
}
|
||||
},
|
||||
"titration": {
|
||||
"section": "Scala di titolazione",
|
||||
"loading": "Caricamento dati di titolazione",
|
||||
"loadFailed": "Impossibile caricare i dati di titolazione.",
|
||||
"drugLadderHeader": "Scala standard (riferimento EMA)",
|
||||
"typicalWeeksOnStep": "≥ {weeks} sett. tipico",
|
||||
"youAreHere": "Sei qui",
|
||||
"nextStepCaption": "Prossimo gradino (tipico): {dose} mg dopo ~{weeks} settimane al gradino attuale.",
|
||||
"ceilingMessage": "Sei al gradino più alto della scala standard.",
|
||||
"escalationDueHint": "Sei su questo gradino da {weeks} settimane; lo schema prevede un avanzamento tipico dopo ~{typical} settimane.",
|
||||
"currentStepDwell": "Su questo gradino da {weeks} settimane",
|
||||
"doseUnitMg": "mg",
|
||||
"emptyState": "Nessuna cronologia delle dosi. Registra una dose per vedere la tua posizione sulla scala.",
|
||||
"nonStandardDose": "La dose attuale è fuori dai gradini standard.",
|
||||
"disclaimer": "Valori di riferimento EMA. Consulta il tuo medico prima di cambiare la dose.",
|
||||
"emaSourceCta": "Fonte EMA",
|
||||
"stepLabel": "Gradino {n}"
|
||||
"doseStrength": {
|
||||
"title": "Dose nel tempo",
|
||||
"axisLabel": "Dose registrata ({unit})",
|
||||
"tooltipLabel": "Dose",
|
||||
"currentDose": "Dose attuale: {dose} {unit}",
|
||||
"noCurrentDose": "Nessuna dose ancora registrata.",
|
||||
"empty": "Nessuna cronologia di titolazione. Registra un cambio di dose per vedere la curva della dose."
|
||||
},
|
||||
"injectionSiteBodyOutlineAriaLabel": "Sagoma del corpo",
|
||||
"injectionSitePickerAriaLabel": "Selettore del sito di iniezione",
|
||||
"injectionSiteRecommendedAriaLabel": "{site} — prossimo sito consigliato",
|
||||
"siteAbdomenLeft": "Addome, basso a sinistra",
|
||||
"siteAbdomenRight": "Addome, basso a destra",
|
||||
"siteAbdomenUpperLeft": "Addome, alto a sinistra",
|
||||
@@ -1005,9 +1053,10 @@
|
||||
"phaseSaved": "Fasi di promemoria salvate",
|
||||
"phaseReset": "Ripristinate ai valori predefiniti",
|
||||
"sourceWeb": "Web",
|
||||
"sourceApi": "API",
|
||||
"sourceApi": "App iOS",
|
||||
"sourceReminder": "Promemoria",
|
||||
"sourceImport": "Importazione",
|
||||
"intakeSourceVia": "tramite {origin}",
|
||||
"intakeHistoryEmptyTitle": "Ancora nessuna assunzione",
|
||||
"intakeHistoryEmptyDescription": "Appena registrerai un’assunzione, comparirà qui con orario e sorgente.",
|
||||
"intakeHistoryEmptyAction": "Registra un’assunzione",
|
||||
@@ -1078,11 +1127,6 @@
|
||||
"emptyState": "Nessuna assunzione registrata.",
|
||||
"emptyStateCta": "Registra una dose per vedere prendere forma la curva stimata.",
|
||||
"estimateNote": "Stima educativa dalla farmacocinetica di popolazione pubblicata dall'EMA. Non è una misura.",
|
||||
"gatedTitle": "Modalità Ricerca disattivata",
|
||||
"gatedBody": "Attiva la modalità Ricerca nelle impostazioni per vedere la curva stimata del livello del farmaco. La curva è opt-in perché è uno strumento di apprendimento, non una misura clinica.",
|
||||
"gatedStaleTitle": "Il disclaimer richiede una nuova conferma",
|
||||
"gatedStaleBody": "Il disclaimer della modalità Ricerca è stato aggiornato dall'ultima tua conferma. Rileggilo nelle impostazioni e conferma di nuovo per rivedere la curva.",
|
||||
"gatedCta": "Apri la modalità Ricerca nelle impostazioni",
|
||||
"unknownDrug": "{name} non è nel catalogo GLP-1 approvato dall'EMA, quindi HealthLog non può tracciare una curva di farmacocinetica di popolazione."
|
||||
}
|
||||
},
|
||||
@@ -1170,6 +1214,14 @@
|
||||
"unit": "minutes",
|
||||
"saved": "Reminder window saved",
|
||||
"failed": "Could not save the reminder window."
|
||||
},
|
||||
"codes": {
|
||||
"label": "Codici clinici (ATC / RxNorm)",
|
||||
"note": "Facoltativo. Usati nell'esportazione FHIR della cartella sanitaria. I codici vengono presi esattamente come inseriti — mai indovinati.",
|
||||
"atcLabel": "ATC",
|
||||
"rxNormLabel": "RxNorm",
|
||||
"saved": "Codici clinici salvati",
|
||||
"failed": "Impossibile salvare i codici clinici."
|
||||
}
|
||||
},
|
||||
"zone": {
|
||||
@@ -1235,7 +1287,7 @@
|
||||
"data": "Dati",
|
||||
"reminders": "Promemoria",
|
||||
"lifecycle": "Ciclo di vita",
|
||||
"danger": "Zona pericolosa"
|
||||
"externalApi": "API esterna"
|
||||
},
|
||||
"dataPortability": {
|
||||
"import": {
|
||||
@@ -1258,10 +1310,18 @@
|
||||
"history": {
|
||||
"back": "Torna al farmaco",
|
||||
"subtitle": "Cronologia completa delle assunzioni",
|
||||
"plannedSuffix": "pianificato",
|
||||
"drugLevelDisclosure": "Mostra la curva stimata del principio attivo"
|
||||
"plannedSuffix": "pianificato"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logInjectionSiteTitle": "Registra il sito di iniezione",
|
||||
"logInjectionSiteDescription": "Dove hai iniettato {name}? Facoltativo — puoi saltarlo.",
|
||||
"logInjectionSiteSkip": "Salta",
|
||||
"logInjectionSiteConfirm": "Salva il sito",
|
||||
"logInjectionSiteNoneAvailable": "Nessun sito di iniezione disponibile: tutti i siti sono esclusi nelle tue impostazioni.",
|
||||
"trackInjectionSitesToggle": "Registra i siti di iniezione",
|
||||
"trackInjectionSitesHint": "Dopo l'assunzione di una dose, chiedi quale sito è stato usato e suggerisci la rotazione successiva.",
|
||||
"allowedSitesLabel": "Siti consentiti",
|
||||
"allowedSitesHint": "Lascia tutti selezionati per nessuna restrizione. I siti esclusi globalmente non vengono mai proposti."
|
||||
},
|
||||
"charts": {
|
||||
"loadingLabel": "Caricamento del grafico…",
|
||||
@@ -1272,6 +1332,13 @@
|
||||
"bodyFat": "Massa grassa",
|
||||
"sleep": "Sonno",
|
||||
"steps": "Passi",
|
||||
"hrv": "HRV",
|
||||
"restingHeartRate": "FC a riposo",
|
||||
"activeEnergy": "Energia attiva",
|
||||
"flights": "Piani saliti",
|
||||
"distance": "Distanza camminata + corsa",
|
||||
"vo2Max": "VO₂ max",
|
||||
"bodyTemperature": "Temperatura corporea",
|
||||
"noData": "Nessun dato nel periodo selezionato",
|
||||
"bucketWeekly": "Media settimanale",
|
||||
"bucketMonthly": "Media mensile",
|
||||
@@ -1336,7 +1403,17 @@
|
||||
"personalBaseline": "La tua media",
|
||||
"deltaVsBaseline": "{delta} vs. la tua media",
|
||||
"deltaUnchanged": "corrisponde alla tua media",
|
||||
"deltaVsTarget": "{delta} vs. obiettivo"
|
||||
"deltaVsTarget": "{delta} vs. obiettivo",
|
||||
"weekdaysFull": {
|
||||
"mon": "Lunedì",
|
||||
"tue": "Martedì",
|
||||
"wed": "Mercoledì",
|
||||
"thu": "Giovedì",
|
||||
"fri": "Venerdì",
|
||||
"sat": "Sabato",
|
||||
"sun": "Domenica"
|
||||
},
|
||||
"rangeBand": "Intervallo"
|
||||
},
|
||||
"chart": {
|
||||
"overlay": {
|
||||
@@ -1376,6 +1453,7 @@
|
||||
},
|
||||
"insights": {
|
||||
"noAnalysisYet": "Ancora nessuna analisi.",
|
||||
"assessmentPreparing": "Preparazione della valutazione…",
|
||||
"emptyTitle": "Dati ancora insufficienti",
|
||||
"emptyDescription": "Gli insights compaiono appena registri qualche misurazione. Inizia da peso o pressione.",
|
||||
"emptyAddMeasurement": "Registra misurazione",
|
||||
@@ -1432,6 +1510,8 @@
|
||||
"heroRegenerating": "Generazione in corso",
|
||||
"regenerateAnalysis": "Riavvia l’analisi",
|
||||
"regenerateSuccess": "Analisi rigenerata",
|
||||
"warmAssessments": "Prepara le valutazioni",
|
||||
"warmStarted": "Le valutazioni vengono preparate in background",
|
||||
"heroFallbackSubtitle": "Uno sguardo quotidiano ai tuoi trend — direttamente dai numeri che registri.",
|
||||
"heroGreetingMorning": "Buongiorno",
|
||||
"heroGreetingAfternoon": "Buon pomeriggio",
|
||||
@@ -1498,7 +1578,22 @@
|
||||
},
|
||||
"trendsRow": {
|
||||
"title": "Tendenze",
|
||||
"subtitle": "Gli ultimi 30 giorni a colpo d’occhio"
|
||||
"subtitle": "Gli ultimi 30 giorni a colpo d’occhio",
|
||||
"caption": {
|
||||
"bp": "Valori sistolici e diastolici degli ultimi 30 giorni.",
|
||||
"weight": "Andamento del peso corporeo degli ultimi 30 giorni.",
|
||||
"pulse": "Battito a riposo degli ultimi 30 giorni.",
|
||||
"mood": "Punteggio giornaliero dell'umore degli ultimi 30 giorni.",
|
||||
"sleep": "Durata del sonno notturno degli ultimi 30 giorni.",
|
||||
"steps": "Conteggio giornaliero dei passi degli ultimi 30 giorni.",
|
||||
"hrv": "Variabilità della frequenza cardiaca degli ultimi 30 giorni.",
|
||||
"resting_hr": "Frequenza cardiaca a riposo degli ultimi 30 giorni.",
|
||||
"active_energy": "Energia attiva bruciata negli ultimi 30 giorni.",
|
||||
"flights": "Piani saliti negli ultimi 30 giorni.",
|
||||
"distance": "Distanza a piedi e di corsa degli ultimi 30 giorni.",
|
||||
"vo2_max": "VO₂ max degli ultimi 30 giorni.",
|
||||
"body_temp": "Temperatura corporea degli ultimi 30 giorni."
|
||||
}
|
||||
},
|
||||
"vo2Max": {
|
||||
"statLatest": "Attuale",
|
||||
@@ -1515,6 +1610,23 @@
|
||||
"confidenceLow": "Confidenza bassa",
|
||||
"pendingLabel": "Commento di trend in generazione"
|
||||
},
|
||||
"range": {
|
||||
"label": "Periodo",
|
||||
"noPriorData": "nessun dato nel periodo precedente",
|
||||
"option": {
|
||||
"7d": "7g",
|
||||
"30d": "30g",
|
||||
"90d": "90g",
|
||||
"1y": "1a"
|
||||
},
|
||||
"priorLabel": {
|
||||
"7d": "vs 7g precedenti",
|
||||
"30d": "vs 30g precedenti",
|
||||
"90d": "vs 90g precedenti",
|
||||
"1y": "vs anno precedente"
|
||||
},
|
||||
"noChange": "nessuna variazione"
|
||||
},
|
||||
"correlationRow": {
|
||||
"title": "Correlazioni",
|
||||
"subtitle": "Ipotesi predefinite, vincolate a n ≥ 14 e p < 0,05",
|
||||
@@ -1883,7 +1995,23 @@
|
||||
"bloodGlucoseBody": "La glicemia è la quantità di zucchero nel sangue, la principale fonte di energia del corpo. Sale dopo i pasti e si stabilizza tra un pasto e l'altro; valori che restano alti nel tempo possono indicare diabete o prediabete.",
|
||||
"skinTemperatureTitle": "Temperatura cutanea",
|
||||
"skinTemperatureBody": "La temperatura cutanea qui è la piccola variazione notturna della temperatura del polso rispetto al tuo valore di riferimento, non una misura assoluta della febbre. Le variazioni possono seguire sonno, ambiente, malattia o fase del ciclo mestruale."
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"label": "Statistiche riepilogative",
|
||||
"min": "Min",
|
||||
"max": "Max",
|
||||
"median": "Mediana",
|
||||
"mean": "Media"
|
||||
},
|
||||
"showAllValues": "Mostra tutte le misurazioni",
|
||||
"valuesPageTitle": "{metric} — tutte le misurazioni",
|
||||
"valuesPageDescription": "Ogni misurazione dietro questa metrica. Modifica o rimuovi una voce direttamente.",
|
||||
"diversity": {
|
||||
"trigger": "Suggerimento di misurazione",
|
||||
"weekday": "La maggior parte delle tue misurazioni di {metric} cade in un solo giorno della settimana. Registrare in altri giorni offre un quadro più completo.",
|
||||
"timeOfDay": "La maggior parte delle tue misurazioni di {metric} cade in un orario simile della giornata. Variare l'orario offre un quadro più completo."
|
||||
},
|
||||
"valuesBack": "Torna a Insights"
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sonno",
|
||||
@@ -2264,7 +2392,75 @@
|
||||
"description": "Le letture appaiono qui quando Apple Health si sincronizza."
|
||||
}
|
||||
},
|
||||
"navWalkingSpeed": "Velocità di cammino"
|
||||
"navWalkingSpeed": "Velocità di cammino",
|
||||
"mood": {
|
||||
"heatmapTitle": "Calendario dell'umore",
|
||||
"heatmapNoEntry": "Nessun dato",
|
||||
"legendGreat": "Ottimo",
|
||||
"legendGood": "Buono",
|
||||
"legendOkay": "Discreto",
|
||||
"legendLow": "Basso",
|
||||
"distributionTitle": "Distribuzione dell'umore",
|
||||
"distributionDaysUnit": "giorni",
|
||||
"weekdayTitle": "Media per giorno della settimana",
|
||||
"weekdayNoData": "Nessun dato",
|
||||
"weekdayBestWorst": "Giorno migliore: {best} · Giorno più difficile: {worst}",
|
||||
"tagsTitle": "Tag e fattori scatenanti",
|
||||
"tagAvgMood": "Umore medio con questo tag",
|
||||
"correlationsTitle": "Umore e altre metriche",
|
||||
"correlationsDescription": "Come il tuo umore si muove con sonno, attività e battito nello stesso giorno.",
|
||||
"correlation": {
|
||||
"sleepTitle": "Umore vs. sonno",
|
||||
"stepsTitle": "Umore vs. attività",
|
||||
"pulseTitle": "Umore vs. battito",
|
||||
"sleepAxis": "Sonno (min)",
|
||||
"stepsAxis": "Passi",
|
||||
"pulseAxis": "Battito (bpm)",
|
||||
"strengthStrong": "Forte",
|
||||
"strengthModerate": "Moderata",
|
||||
"strengthWeak": "Debole",
|
||||
"strengthNone": "Nessuna",
|
||||
"source": "{n} giorni appaiati · r = {r}",
|
||||
"emptyTitle": "Non ci sono ancora abbastanza dati appaiati",
|
||||
"emptyDescription": "Registra umore e questa metrica negli stessi giorni per vedere una correlazione.",
|
||||
"weightTitle": "Umore vs. peso",
|
||||
"bloodPressureTitle": "Umore vs. pressione sanguigna",
|
||||
"weightAxis": "Peso",
|
||||
"bloodPressureAxis": "Sistolica (mmHg)"
|
||||
},
|
||||
"structuredTagsTitle": "Analisi etichette",
|
||||
"inTargetValue": "{pct} %",
|
||||
"inTargetCaption": "degli ultimi giorni nel tuo intervallo di buon umore",
|
||||
"narrative": {
|
||||
"title": "Cosa risalta",
|
||||
"weekdayDip": "Il tuo umore cala di più il {weekday}: {delta} sotto la tua settimana.",
|
||||
"weekdayPeak": "Il {weekday} è sempre il tuo giorno migliore, con una media di {value}.",
|
||||
"trendUp": "Il tuo umore è in crescita negli ultimi {days} giorni (+{delta} rispetto al periodo precedente).",
|
||||
"trendDown": "Il tuo umore è in calo negli ultimi {days} giorni (-{delta} rispetto al periodo precedente).",
|
||||
"weekendUp": "Ti senti meglio nel weekend (+{delta} rispetto ai giorni feriali).",
|
||||
"weekendDown": "Il tuo umore è più basso nel weekend (-{delta} rispetto ai giorni feriali).",
|
||||
"tagLift": "I giorni con l'etichetta «{tag}» superano in media il tuo umore generale di {delta}.",
|
||||
"tagDrop": "I giorni con l'etichetta «{tag}» restano {delta} sotto la tua media.",
|
||||
"inTarget": "Sei stato nel tuo intervallo di buon umore il {pct} % degli ultimi 30 giorni.",
|
||||
"streak": "Hai registrato il tuo umore per {days} giorni di fila — continua così.",
|
||||
"timeOfDay": "Ti senti meglio di {bucket}, con una media di {value}."
|
||||
},
|
||||
"timeOfDay": {
|
||||
"title": "Umore per fascia oraria",
|
||||
"morning": "Mattina",
|
||||
"afternoon": "Pomeriggio",
|
||||
"evening": "Sera",
|
||||
"night": "Notte",
|
||||
"bestCaption": "Tendi a sentirti meglio di {bucket}."
|
||||
},
|
||||
"stability": {
|
||||
"value": "{score}/100",
|
||||
"bandVerySteady": "Il tuo umore è stato molto stabile",
|
||||
"bandSteady": "Il tuo umore è stato abbastanza stabile",
|
||||
"bandVariable": "Il tuo umore è stato notevolmente variabile",
|
||||
"bandVeryVariable": "Il tuo umore ha oscillato molto di recente"
|
||||
}
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Predefinito",
|
||||
@@ -2476,7 +2672,7 @@
|
||||
"description": "Withings, moodLog, and other connected services."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Canali di notifica",
|
||||
"title": "Notifiche",
|
||||
"description": "Live overview of every configured channel."
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -2484,63 +2680,62 @@
|
||||
"description": "Tile layout and order."
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Obiettivi e fonti",
|
||||
"description": "Per metrica: il tuo intervallo target e quale fonte prevale quando più di una registra lo stesso valore lo stesso giorno."
|
||||
"title": "Obiettivi",
|
||||
"description": "Per metrica: il tuo intervallo target per valori sani."
|
||||
},
|
||||
"sources": {
|
||||
"title": "Sources",
|
||||
"description": "Pick which ingest source wins when more than one records the same metric for the same day.",
|
||||
"redirectNotice": "Le fonti ora fanno parte di «Obiettivi e fonti».",
|
||||
"cardTitle": "Source priority by metric",
|
||||
"help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.",
|
||||
"resetDefaults": "Reset to defaults",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"saveSuccess": "Source priority saved.",
|
||||
"saveError": "Saving the source priority failed.",
|
||||
"resetSuccess": "Source priority reset to defaults.",
|
||||
"perMetricToggle": "Per-metric overrides ({count})",
|
||||
"perMetricHelp": "Metrics you have customised away from the default ladder. Use this list to roll any single metric back to its constant default without touching the rest.",
|
||||
"perMetricEmpty": "No per-metric overrides set — every metric uses the global ladder above.",
|
||||
"title": "Fonti",
|
||||
"description": "Scegli quale fonte prevale quando più di una registra la stessa metrica lo stesso giorno.",
|
||||
"cardTitle": "Priorità delle fonti per metrica",
|
||||
"help": "Quando più fonti forniscono lo stesso valore nello stesso giorno (ad es. Withings e Apple Health riportano entrambe 1.200 passi), la fonte in cima conta come lettura canonica. Le altre fonti restano memorizzate per la traccia di controllo.",
|
||||
"resetDefaults": "Ripristina i valori predefiniti",
|
||||
"moveUp": "Sposta su",
|
||||
"moveDown": "Sposta giù",
|
||||
"saveSuccess": "Priorità delle fonti salvata.",
|
||||
"saveError": "Salvataggio della priorità delle fonti non riuscito.",
|
||||
"resetSuccess": "Priorità delle fonti ripristinata ai valori predefiniti.",
|
||||
"perMetricToggle": "Personalizzazioni per metrica ({count})",
|
||||
"perMetricHelp": "Metriche che hai personalizzato rispetto all’ordine predefinito. Usa questo elenco per riportare una singola metrica al suo valore predefinito senza toccare le altre.",
|
||||
"perMetricEmpty": "Nessuna personalizzazione per metrica — ogni metrica usa l’ordine globale qui sopra.",
|
||||
"resetMetric": "Reimposta",
|
||||
"deviceTypeToggle": "Device-type priority ({count})",
|
||||
"deviceTypeHelp": "Within a source, which device wins. When an Apple Watch and an iPhone both record steps, the higher-ranked device-type counts as the canonical reading.",
|
||||
"deviceTypeDefault": "Default device-type ladder",
|
||||
"resetDeviceTypes": "Reset device-types",
|
||||
"deviceTypeToggle": "Priorità per tipo di dispositivo ({count})",
|
||||
"deviceTypeHelp": "All’interno di una fonte, quale dispositivo prevale. Quando un Apple Watch e un iPhone registrano entrambi i passi, il tipo di dispositivo più in alto conta come lettura canonica.",
|
||||
"deviceTypeDefault": "Ordine predefinito per tipo di dispositivo",
|
||||
"resetDeviceTypes": "Reimposta i tipi di dispositivo",
|
||||
"metrics": {
|
||||
"steps": "Passi",
|
||||
"activeEnergy": "Active energy",
|
||||
"walkingRunningDistance": "Walking + running distance",
|
||||
"flightsClimbed": "Flights climbed",
|
||||
"activeEnergy": "Energia attiva",
|
||||
"walkingRunningDistance": "Distanza a piedi + corsa",
|
||||
"flightsClimbed": "Piani saliti",
|
||||
"sleep": "Sonno",
|
||||
"weight": "Peso",
|
||||
"bloodPressure": "Pressione arteriosa",
|
||||
"pulse": "Polso",
|
||||
"bodyFat": "Massa grassa",
|
||||
"bodyTemperature": "Body temperature",
|
||||
"spo2": "Oxygen saturation",
|
||||
"hrv": "Heart-rate variability",
|
||||
"bodyTemperature": "Temperatura corporea",
|
||||
"spo2": "Saturazione di ossigeno",
|
||||
"hrv": "Variabilità della frequenza cardiaca",
|
||||
"restingHeartRate": "Frequenza cardiaca a riposo",
|
||||
"vo2Max": "VO2 max"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
"APPLE_HEALTH": "Apple Health",
|
||||
"MANUAL": "Manual entry",
|
||||
"MANUAL": "Inserimento manuale",
|
||||
"IMPORT": "Importa"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Watch",
|
||||
"band": "Band",
|
||||
"ring": "Ring",
|
||||
"phone": "Phone",
|
||||
"scale": "Scale",
|
||||
"other": "Other",
|
||||
"watch": "Orologio",
|
||||
"band": "Bracciale",
|
||||
"ring": "Anello",
|
||||
"phone": "Telefono",
|
||||
"scale": "Bilancia",
|
||||
"other": "Altro",
|
||||
"unknown": "Sconosciuto"
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
"title": "Insights",
|
||||
"title": "AI Insights",
|
||||
"description": "Provider, model, key."
|
||||
},
|
||||
"api": {
|
||||
@@ -3022,7 +3217,9 @@
|
||||
"fhirNote": "L’output strutturato segue lo standard FHIR R4 (compatibile ePA).",
|
||||
"generate": "Genera",
|
||||
"error": "Esportazione non riuscita ({code})."
|
||||
}
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente",
|
||||
"globalExcludedInjectionSitesHint": "I siti elencati qui non vengono proposti per alcun farmaco e vengono rifiutati al momento della registrazione di una dose, anche se un farmaco li preferisce."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -3843,8 +4040,6 @@
|
||||
}
|
||||
},
|
||||
"targets": {
|
||||
"title": "Valori obiettivo",
|
||||
"introText": "I tuoi valori attuali rispetto agli intervalli obiettivo consigliati. Le fonti delle linee guida sono collegate su ogni card.",
|
||||
"bmi": "IMC",
|
||||
"average30d": "Media 30 giorni:",
|
||||
"consistency": {
|
||||
@@ -3866,23 +4061,11 @@
|
||||
"sixDaysAgo": "6 giorni fa"
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"lastMet": "Ultima volta in obiettivo: {when}",
|
||||
"streak": "{count} giorni di fila"
|
||||
},
|
||||
"relativeDay": {
|
||||
"today": "Oggi",
|
||||
"yesterday": "Ieri",
|
||||
"daysAgo": "{count} giorni fa"
|
||||
},
|
||||
"summary": {
|
||||
"weekTitle": "{met} di {total} obiettivi raggiunti questa settimana",
|
||||
"weekTitleAll": "Tutto{total} targets met this week",
|
||||
"streakHighlight": "{count} giorni di fila: {metric}"
|
||||
},
|
||||
"coach": {
|
||||
"cta": "Chiedi al coach"
|
||||
},
|
||||
"edit": {
|
||||
"openLabel": "Modifica il range obiettivo per {metric}",
|
||||
"title": "Modifica range obiettivo — {metric}",
|
||||
@@ -3914,31 +4097,10 @@
|
||||
"aboveTarget": "{delta} {unit} sopra il range",
|
||||
"belowTarget": "{delta} {unit} sotto il range",
|
||||
"diastolic": "Diastolica",
|
||||
"noMeasurementYet": "Ancora nessuna misurazione",
|
||||
"noMeasurementData": "Nessun dato di misurazione. Aggiungi misurazioni per vedere gli obiettivi.",
|
||||
"profileIncomplete": "Profilo incompleto",
|
||||
"profileIncompleteHeight": "Inserisci la tua altezza nel profilo per attivare gli obiettivi BMI.",
|
||||
"profileIncompleteAge": "Inserisci la tua data di nascita nel profilo per attivare gli obiettivi legati all’età.",
|
||||
"profileIncompleteHeightAge": "Inserisci altezza e data di nascita nel profilo per vedere obiettivi personalizzati.",
|
||||
"compliancePerMedication": "Aderenza per farmaco",
|
||||
"glucoseFasting": "Glicemia — a digiuno",
|
||||
"glucosePostprandial": "Glicemia — postprandiale",
|
||||
"glucoseRandom": "Glicemia — qualsiasi",
|
||||
"glucoseBedtime": "Glicemia — prima di coricarsi",
|
||||
"loadError": "Impossibile caricare i valori obiettivo",
|
||||
"label": {
|
||||
"WEIGHT": "Peso",
|
||||
"BLOOD_PRESSURE": "Pressione arteriosa",
|
||||
"BLOOD_PRESSURE_IN_TARGET": "Pressione nel range",
|
||||
"PULSE": "Pulsazioni a riposo",
|
||||
"SLEEP_DURATION": "Durata del sonno",
|
||||
"BMI": "IMC",
|
||||
"BODY_FAT": "Massa grassa",
|
||||
"ACTIVITY_STEPS": "Passi/giorno",
|
||||
"MEDICATION_COMPLIANCE": "Aderenza",
|
||||
"MOOD_SCORE": "Umore",
|
||||
"MOOD_STABILITY": "Stabilità umore"
|
||||
},
|
||||
"status": {
|
||||
"underweight": "Sottopeso",
|
||||
"normal": "Normal",
|
||||
|
||||
+274
-112
@@ -461,7 +461,49 @@
|
||||
"deleteConfirmTitle": "Usunąć wpis?",
|
||||
"deleteConfirmDescription": "Ten wpis nastroju zostanie nieodwracalnie usunięty.",
|
||||
"pageInfo": "Strona {page} z {total}",
|
||||
"loginRequired": "Zaloguj się, aby zobaczyć wpisy nastroju."
|
||||
"loginRequired": "Zaloguj się, aby zobaczyć wpisy nastroju.",
|
||||
"note": "Notatka",
|
||||
"notePlaceholder": "Dodaj notatkę o swoim samopoczuciu…",
|
||||
"tagPicker": "Tagi",
|
||||
"tagCategory": {
|
||||
"feelings": "Uczucia",
|
||||
"sleep": "Sen",
|
||||
"health": "Zdrowie",
|
||||
"social": "Społeczne",
|
||||
"work": "Praca"
|
||||
},
|
||||
"tag": {
|
||||
"happy": "szczęśliwy",
|
||||
"excited": "podekscytowany",
|
||||
"grateful": "wdzięczny",
|
||||
"relaxed": "zrelaksowany",
|
||||
"content": "zadowolony",
|
||||
"tired": "zmęczony",
|
||||
"unsure": "niepewny",
|
||||
"bored": "znudzony",
|
||||
"tense": "spięty",
|
||||
"angry": "zły",
|
||||
"stressed": "zestresowany",
|
||||
"sad": "smutny",
|
||||
"sleptWell": "dobrze spałem",
|
||||
"sleptOk": "sen średni",
|
||||
"sleptPoorly": "źle spałem",
|
||||
"earlyNight": "wcześnie spać",
|
||||
"workedOut": "trening",
|
||||
"ateWell": "zdrowo zjadłem",
|
||||
"hydrated": "piłem wodę",
|
||||
"walked": "spacer",
|
||||
"alcohol": "alkohol",
|
||||
"family": "rodzina",
|
||||
"friends": "znajomi",
|
||||
"party": "impreza",
|
||||
"alone": "sam",
|
||||
"productive": "produktywny",
|
||||
"overtime": "nadgodziny",
|
||||
"dayOff": "dzień wolny",
|
||||
"travel": "podróż",
|
||||
"sickDay": "dzień chorobowy"
|
||||
}
|
||||
},
|
||||
"medications": {
|
||||
"title": "Leki",
|
||||
@@ -544,11 +586,14 @@
|
||||
"veryOverdue": "Mocno po terminie",
|
||||
"compliance7d": "Przestrzeganie 7 dni",
|
||||
"compliance30d": "Przestrzeganie 30 dni",
|
||||
"complianceWindow": "Przestrzeganie {days} dni",
|
||||
"lastIntake": "Ostatnie przyjęcie:",
|
||||
"nextIntake": "Następne przyjęcie:",
|
||||
"dayStreak": "dni serii",
|
||||
"taken": "Przyjęte",
|
||||
"skipped": "Pominięte",
|
||||
"intakeToastTaken": "{name} oznaczone jako przyjęte",
|
||||
"intakeToastSkipped": "{name} pominięte",
|
||||
"weekdaySunday": "Niedziela",
|
||||
"weekdayMonday": "Poniedziałek",
|
||||
"weekdayTuesday": "Wtorek",
|
||||
@@ -759,7 +804,11 @@
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"save": "Save",
|
||||
"saveEdit": "Save changes"
|
||||
"saveEdit": "Save changes",
|
||||
"nextHint": "Dalej: {step}",
|
||||
"reviewHint": "Dalej: Zapisz",
|
||||
"jumpFirst": "Przejdź do pierwszego kroku",
|
||||
"jumpLast": "Przejdź do podsumowania i zapisu"
|
||||
},
|
||||
"nl": {
|
||||
"button": "Describe"
|
||||
@@ -769,11 +818,13 @@
|
||||
"title": "What's the medication called?",
|
||||
"subline": "Exactly as it appears on the box.",
|
||||
"label": "Name",
|
||||
"placeholder": "e.g. Ramipril"
|
||||
"placeholder": "e.g. Ramipril",
|
||||
"short": "Nazwa"
|
||||
},
|
||||
"step2": {
|
||||
"title": "What kind of medication is it?",
|
||||
"subline": "We use this for the right templates and analytics."
|
||||
"subline": "We use this for the right templates and analytics.",
|
||||
"short": "Typ"
|
||||
},
|
||||
"step3": {
|
||||
"title": "What's the dose?",
|
||||
@@ -805,15 +856,18 @@
|
||||
"dosesPerUnitPlaceholder": "np. 4",
|
||||
"dosesPerUnitHint": "Dawki na wstrzykiwacz / fiolkę — zostaw puste, aby nie śledzić zapasów.",
|
||||
"injectionSiteToggle": "Rotacja miejsc wstrzyknięcia",
|
||||
"injectionSiteHint": "Opcjonalny przewodnik rotacji. Miejsce każdej dawki zapisywane jest przy rejestrowaniu przyjęcia."
|
||||
"injectionSiteHint": "Opcjonalny przewodnik rotacji. Miejsce każdej dawki zapisywane jest przy rejestrowaniu przyjęcia.",
|
||||
"short": "Dawka"
|
||||
},
|
||||
"step4": {
|
||||
"title": "Over what period are you taking it?",
|
||||
"subline": "A start date is enough for today. You can add an end date later."
|
||||
"subline": "A start date is enough for today. You can add an end date later.",
|
||||
"short": "Kiedy"
|
||||
},
|
||||
"step5": {
|
||||
"title": "How often do you take it?",
|
||||
"subline": "Pick the cadence — the details follow on the next step."
|
||||
"subline": "Pick the cadence — the details follow on the next step.",
|
||||
"short": "Częstotliwość"
|
||||
},
|
||||
"step6": {
|
||||
"title": "What does that look like in detail?",
|
||||
@@ -829,7 +883,8 @@
|
||||
"rollingDays": {
|
||||
"label": "Every",
|
||||
"suffix": "days from the last intake"
|
||||
}
|
||||
},
|
||||
"short": "Szczegóły"
|
||||
},
|
||||
"step7": {
|
||||
"title": "When do you take it?",
|
||||
@@ -840,14 +895,16 @@
|
||||
"noon": "Noon",
|
||||
"evening": "Evening",
|
||||
"night": "Night"
|
||||
}
|
||||
},
|
||||
"short": "Godziny"
|
||||
},
|
||||
"step8": {
|
||||
"title": "All done — should HealthLog remind you?",
|
||||
"subline": "Review everything below before you save.",
|
||||
"remindersLabel": "Reminders on",
|
||||
"remindersDescription": "Push, Telegram or ntfy — based on your settings.",
|
||||
"multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release."
|
||||
"multiScheduleNote": "This medication has multiple schedules. The detailed editor returns in a later release.",
|
||||
"short": "Gotowe"
|
||||
}
|
||||
},
|
||||
"classRow": {
|
||||
@@ -923,26 +980,17 @@
|
||||
"submitFailed": "Could not save the medication — please retry."
|
||||
}
|
||||
},
|
||||
"titration": {
|
||||
"section": "Schemat titracji",
|
||||
"loading": "Wczytywanie danych titracji",
|
||||
"loadFailed": "Nie udało się wczytać danych titracji.",
|
||||
"drugLadderHeader": "Standardowy schemat (referencja EMA)",
|
||||
"typicalWeeksOnStep": "≥ {weeks} tyg. typowo",
|
||||
"youAreHere": "Jesteś tutaj",
|
||||
"nextStepCaption": "Następny stopień (typowo): {dose} mg po ~{weeks} tygodniach na obecnym stopniu.",
|
||||
"ceilingMessage": "Jesteś na najwyższym stopniu schematu standardowego.",
|
||||
"escalationDueHint": "Jesteś na tym stopniu od {weeks} tygodni; schemat zwykle przewiduje przejście wyżej po ~{typical} tygodniach.",
|
||||
"currentStepDwell": "Na tym stopniu od {weeks} tygodni",
|
||||
"doseUnitMg": "mg",
|
||||
"emptyState": "Brak historii dawek. Zarejestruj dawkę, aby zobaczyć swoją pozycję w schemacie.",
|
||||
"nonStandardDose": "Aktualna dawka jest poza standardowymi stopniami schematu.",
|
||||
"disclaimer": "Wartości referencyjne EMA. Skonsultuj się z lekarzem przed zmianą dawki.",
|
||||
"emaSourceCta": "Źródło EMA",
|
||||
"stepLabel": "Stopień {n}"
|
||||
"doseStrength": {
|
||||
"title": "Dawka w czasie",
|
||||
"axisLabel": "Zapisana dawka ({unit})",
|
||||
"tooltipLabel": "Dawka",
|
||||
"currentDose": "Aktualna dawka: {dose} {unit}",
|
||||
"noCurrentDose": "Nie zapisano jeszcze żadnej dawki.",
|
||||
"empty": "Brak historii titracji. Zapisz zmianę dawki, aby zobaczyć krzywą dawki."
|
||||
},
|
||||
"injectionSiteBodyOutlineAriaLabel": "Sylwetka ciała",
|
||||
"injectionSitePickerAriaLabel": "Wybór miejsca iniekcji",
|
||||
"injectionSiteRecommendedAriaLabel": "{site} — zalecane następne miejsce",
|
||||
"siteAbdomenLeft": "Brzuch, dolny lewy",
|
||||
"siteAbdomenRight": "Brzuch, dolny prawy",
|
||||
"siteAbdomenUpperLeft": "Brzuch, górny lewy",
|
||||
@@ -1005,9 +1053,10 @@
|
||||
"phaseSaved": "Fazy przypomnień zapisane",
|
||||
"phaseReset": "Przywrócono wartości domyślne",
|
||||
"sourceWeb": "Web",
|
||||
"sourceApi": "API",
|
||||
"sourceApi": "Aplikacja iOS",
|
||||
"sourceReminder": "Przypomnienie",
|
||||
"sourceImport": "Import",
|
||||
"intakeSourceVia": "przez {origin}",
|
||||
"intakeHistoryEmptyTitle": "Brak przyjęć",
|
||||
"intakeHistoryEmptyDescription": "Gdy tylko zarejestrujesz przyjęcie, pojawi się tu wraz z godziną i źródłem.",
|
||||
"intakeHistoryEmptyAction": "Zarejestruj przyjęcie",
|
||||
@@ -1078,11 +1127,6 @@
|
||||
"emptyState": "Brak zarejestrowanych przyjęć.",
|
||||
"emptyStateCta": "Zarejestruj dawkę, aby zobaczyć kształtującą się krzywą szacunkową.",
|
||||
"estimateNote": "Edukacyjne oszacowanie z opublikowanej przez EMA farmakokinetyki populacyjnej. To nie jest pomiar.",
|
||||
"gatedTitle": "Tryb Badawczy wyłączony",
|
||||
"gatedBody": "Włącz tryb Badawczy w Ustawieniach, aby zobaczyć szacowaną krzywą poziomu leku. Krzywa jest opt-in, ponieważ to pomoc edukacyjna, a nie pomiar kliniczny.",
|
||||
"gatedStaleTitle": "Disclaimer wymaga ponownego potwierdzenia",
|
||||
"gatedStaleBody": "Disclaimer trybu Badawczego został zaktualizowany od czasu twojego ostatniego potwierdzenia. Przeczytaj go ponownie w Ustawieniach i potwierdź, aby krzywa znów się pojawiła.",
|
||||
"gatedCta": "Otwórz tryb Badawczy w Ustawieniach",
|
||||
"unknownDrug": "{name} nie znajduje się w katalogu GLP-1 zatwierdzonym przez EMA, więc HealthLog nie może wyrysować krzywej farmakokinetyki populacyjnej."
|
||||
}
|
||||
},
|
||||
@@ -1170,6 +1214,14 @@
|
||||
"unit": "minutes",
|
||||
"saved": "Reminder window saved",
|
||||
"failed": "Could not save the reminder window."
|
||||
},
|
||||
"codes": {
|
||||
"label": "Kody kliniczne (ATC / RxNorm)",
|
||||
"note": "Opcjonalne. Używane w eksporcie FHIR dokumentacji zdrowotnej. Kody są zapisywane dokładnie tak, jak wprowadzone — nigdy nie są zgadywane.",
|
||||
"atcLabel": "ATC",
|
||||
"rxNormLabel": "RxNorm",
|
||||
"saved": "Kody kliniczne zapisane",
|
||||
"failed": "Nie udało się zapisać kodów klinicznych."
|
||||
}
|
||||
},
|
||||
"zone": {
|
||||
@@ -1235,7 +1287,7 @@
|
||||
"data": "Dane",
|
||||
"reminders": "Przypomnienia",
|
||||
"lifecycle": "Cykl życia",
|
||||
"danger": "Strefa zagrożenia"
|
||||
"externalApi": "Zewnętrzne API"
|
||||
},
|
||||
"dataPortability": {
|
||||
"import": {
|
||||
@@ -1258,10 +1310,18 @@
|
||||
"history": {
|
||||
"back": "Powrót do leku",
|
||||
"subtitle": "Pełna historia przyjęć",
|
||||
"plannedSuffix": "zaplanowane",
|
||||
"drugLevelDisclosure": "Pokaż szacowaną krzywą stężenia leku"
|
||||
"plannedSuffix": "zaplanowane"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logInjectionSiteTitle": "Zarejestruj miejsce iniekcji",
|
||||
"logInjectionSiteDescription": "Gdzie wstrzyknięto {name}? Opcjonalnie — możesz to pominąć.",
|
||||
"logInjectionSiteSkip": "Pomiń",
|
||||
"logInjectionSiteConfirm": "Zapisz miejsce",
|
||||
"logInjectionSiteNoneAvailable": "Brak dostępnego miejsca iniekcji — wszystkie miejsca są wykluczone w Twoich ustawieniach.",
|
||||
"trackInjectionSitesToggle": "Rejestruj miejsca iniekcji",
|
||||
"trackInjectionSitesHint": "Po przyjęciu dawki zapytaj, którego miejsca użyto, i zaproponuj kolejną rotację.",
|
||||
"allowedSitesLabel": "Dozwolone miejsca",
|
||||
"allowedSitesHint": "Pozostaw wszystkie zaznaczone, aby nie stosować ograniczeń. Miejsca wykluczone globalnie nigdy nie są proponowane."
|
||||
},
|
||||
"charts": {
|
||||
"loadingLabel": "Ładowanie wykresu…",
|
||||
@@ -1272,6 +1332,13 @@
|
||||
"bodyFat": "Tkanka tłuszczowa",
|
||||
"sleep": "Sen",
|
||||
"steps": "Kroki",
|
||||
"hrv": "HRV",
|
||||
"restingHeartRate": "Tętno spoczynkowe",
|
||||
"activeEnergy": "Energia czynna",
|
||||
"flights": "Pokonane piętra",
|
||||
"distance": "Dystans marsz + bieg",
|
||||
"vo2Max": "VO₂ max",
|
||||
"bodyTemperature": "Temperatura ciała",
|
||||
"noData": "Brak danych w wybranym okresie",
|
||||
"bucketWeekly": "Średnia tygodniowa",
|
||||
"bucketMonthly": "Średnia miesięczna",
|
||||
@@ -1336,7 +1403,17 @@
|
||||
"personalBaseline": "Twoja średnia",
|
||||
"deltaVsBaseline": "{delta} vs. twoja średnia",
|
||||
"deltaUnchanged": "odpowiada twojej średniej",
|
||||
"deltaVsTarget": "{delta} vs. cel"
|
||||
"deltaVsTarget": "{delta} vs. cel",
|
||||
"weekdaysFull": {
|
||||
"mon": "Poniedziałek",
|
||||
"tue": "Wtorek",
|
||||
"wed": "Środa",
|
||||
"thu": "Czwartek",
|
||||
"fri": "Piątek",
|
||||
"sat": "Sobota",
|
||||
"sun": "Niedziela"
|
||||
},
|
||||
"rangeBand": "Zakres"
|
||||
},
|
||||
"chart": {
|
||||
"overlay": {
|
||||
@@ -1376,6 +1453,7 @@
|
||||
},
|
||||
"insights": {
|
||||
"noAnalysisYet": "Brak analizy.",
|
||||
"assessmentPreparing": "Przygotowywanie oceny…",
|
||||
"emptyTitle": "Jeszcze za mało danych",
|
||||
"emptyDescription": "Insights pojawią się, gdy zarejestrujesz kilka pomiarów. Zacznij od wagi lub ciśnienia.",
|
||||
"emptyAddMeasurement": "Zarejestruj pomiar",
|
||||
@@ -1432,6 +1510,8 @@
|
||||
"heroRegenerating": "Generowanie",
|
||||
"regenerateAnalysis": "Uruchom analizę ponownie",
|
||||
"regenerateSuccess": "Analiza wygenerowana ponownie",
|
||||
"warmAssessments": "Przygotuj analizy",
|
||||
"warmStarted": "Analizy są przygotowywane w tle",
|
||||
"heroFallbackSubtitle": "Codzienne spojrzenie na twoje trendy — prosto z liczb, które rejestrujesz.",
|
||||
"heroGreetingMorning": "Dzień dobry",
|
||||
"heroGreetingAfternoon": "Dzień dobry",
|
||||
@@ -1498,7 +1578,22 @@
|
||||
},
|
||||
"trendsRow": {
|
||||
"title": "Trendy",
|
||||
"subtitle": "Ostatnie 30 dni w skrócie"
|
||||
"subtitle": "Ostatnie 30 dni w skrócie",
|
||||
"caption": {
|
||||
"bp": "Wartości skurczowe i rozkurczowe z ostatnich 30 dni.",
|
||||
"weight": "Trend masy ciała z ostatnich 30 dni.",
|
||||
"pulse": "Tętno spoczynkowe z ostatnich 30 dni.",
|
||||
"mood": "Dzienna ocena nastroju z ostatnich 30 dni.",
|
||||
"sleep": "Długość snu nocnego z ostatnich 30 dni.",
|
||||
"steps": "Dzienna liczba kroków z ostatnich 30 dni.",
|
||||
"hrv": "Zmienność rytmu serca z ostatnich 30 dni.",
|
||||
"resting_hr": "Spoczynkowe tętno z ostatnich 30 dni.",
|
||||
"active_energy": "Aktywnie spalona energia z ostatnich 30 dni.",
|
||||
"flights": "Pokonane piętra z ostatnich 30 dni.",
|
||||
"distance": "Dystans marszu i biegu z ostatnich 30 dni.",
|
||||
"vo2_max": "VO₂ max z ostatnich 30 dni.",
|
||||
"body_temp": "Temperatura ciała z ostatnich 30 dni."
|
||||
}
|
||||
},
|
||||
"vo2Max": {
|
||||
"statLatest": "Aktualne",
|
||||
@@ -1515,6 +1610,23 @@
|
||||
"confidenceLow": "Niska pewność",
|
||||
"pendingLabel": "Komentarz trendu jest generowany"
|
||||
},
|
||||
"range": {
|
||||
"label": "Okres",
|
||||
"noPriorData": "brak danych z poprzedniego okresu",
|
||||
"option": {
|
||||
"7d": "7d",
|
||||
"30d": "30d",
|
||||
"90d": "90d",
|
||||
"1y": "1r"
|
||||
},
|
||||
"priorLabel": {
|
||||
"7d": "vs poprzednie 7d",
|
||||
"30d": "vs poprzednie 30d",
|
||||
"90d": "vs poprzednie 90d",
|
||||
"1y": "vs poprzedni rok"
|
||||
},
|
||||
"noChange": "bez zmian"
|
||||
},
|
||||
"correlationRow": {
|
||||
"title": "Korelacje",
|
||||
"subtitle": "Hipotezy zdefiniowane wcześniej, z progiem n ≥ 14 i p < 0,05",
|
||||
@@ -1883,7 +1995,23 @@
|
||||
"bloodGlucoseBody": "Poziom glukozy we krwi to ilość cukru we krwi, głównego źródła energii organizmu. Rośnie po jedzeniu i ustala się między posiłkami; wartości utrzymujące się wysoko mogą sygnalizować cukrzycę lub stan przedcukrzycowy.",
|
||||
"skinTemperatureTitle": "Temperatura skóry",
|
||||
"skinTemperatureBody": "Temperatura skóry to tutaj niewielka nocna zmiana temperatury nadgarstka względem twojej własnej wartości bazowej, a nie bezwzględny pomiar gorączki. Zmiany mogą odzwierciedlać sen, otoczenie, chorobę lub fazę cyklu menstruacyjnego."
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"label": "Statystyki podsumowujące",
|
||||
"min": "Min",
|
||||
"max": "Maks",
|
||||
"median": "Mediana",
|
||||
"mean": "Średnia"
|
||||
},
|
||||
"showAllValues": "Pokaż wszystkie pomiary",
|
||||
"valuesPageTitle": "{metric} — wszystkie pomiary",
|
||||
"valuesPageDescription": "Każdy pomiar stojący za tą metryką. Edytuj lub usuń wpis bezpośrednio.",
|
||||
"diversity": {
|
||||
"trigger": "Wskazówka pomiarowa",
|
||||
"weekday": "Większość Twoich pomiarów {metric} przypada na jeden dzień tygodnia. Pomiary w inne dni dają pełniejszy obraz.",
|
||||
"timeOfDay": "Większość Twoich pomiarów {metric} przypada o podobnej porze dnia. Różne pory dają pełniejszy obraz."
|
||||
},
|
||||
"valuesBack": "Powrót do Insights"
|
||||
},
|
||||
"sleep": {
|
||||
"title": "Sen",
|
||||
@@ -2264,7 +2392,75 @@
|
||||
"description": "Odczyty pojawią się tutaj po synchronizacji Apple Health."
|
||||
}
|
||||
},
|
||||
"navWalkingSpeed": "Prędkość chodu"
|
||||
"navWalkingSpeed": "Prędkość chodu",
|
||||
"mood": {
|
||||
"heatmapTitle": "Kalendarz nastroju",
|
||||
"heatmapNoEntry": "Brak wpisu",
|
||||
"legendGreat": "Świetnie",
|
||||
"legendGood": "Dobrze",
|
||||
"legendOkay": "W porządku",
|
||||
"legendLow": "Nisko",
|
||||
"distributionTitle": "Rozkład nastroju",
|
||||
"distributionDaysUnit": "dni",
|
||||
"weekdayTitle": "Średnia wg dnia tygodnia",
|
||||
"weekdayNoData": "Brak danych",
|
||||
"weekdayBestWorst": "Najlepszy dzień: {best} · Najtrudniejszy dzień: {worst}",
|
||||
"tagsTitle": "Tagi i wyzwalacze",
|
||||
"tagAvgMood": "Średni nastrój z tym tagiem",
|
||||
"correlationsTitle": "Nastrój a inne wskaźniki",
|
||||
"correlationsDescription": "Jak Twój nastrój zmienia się wraz ze snem, aktywnością i tętnem tego samego dnia.",
|
||||
"correlation": {
|
||||
"sleepTitle": "Nastrój a sen",
|
||||
"stepsTitle": "Nastrój a aktywność",
|
||||
"pulseTitle": "Nastrój a tętno",
|
||||
"sleepAxis": "Sen (min)",
|
||||
"stepsAxis": "Kroki",
|
||||
"pulseAxis": "Tętno (bpm)",
|
||||
"strengthStrong": "Silna",
|
||||
"strengthModerate": "Umiarkowana",
|
||||
"strengthWeak": "Słaba",
|
||||
"strengthNone": "Brak",
|
||||
"source": "{n} sparowanych dni · r = {r}",
|
||||
"emptyTitle": "Za mało sparowanych danych",
|
||||
"emptyDescription": "Rejestruj nastrój i ten wskaźnik w te same dni, aby zobaczyć korelację.",
|
||||
"weightTitle": "Nastrój vs. waga",
|
||||
"bloodPressureTitle": "Nastrój vs. ciśnienie krwi",
|
||||
"weightAxis": "Waga",
|
||||
"bloodPressureAxis": "Skurczowe (mmHg)"
|
||||
},
|
||||
"structuredTagsTitle": "Podział tagów",
|
||||
"inTargetValue": "{pct}%",
|
||||
"inTargetCaption": "ostatnich dni w zakresie dobrego nastroju",
|
||||
"narrative": {
|
||||
"title": "Co się wyróżnia",
|
||||
"weekdayDip": "Twój nastrój najbardziej spada w {weekday} — {delta} poniżej twojego tygodnia.",
|
||||
"weekdayPeak": "{weekday} to zawsze twój najlepszy dzień, średnio {value}.",
|
||||
"trendUp": "Twój nastrój rośnie w ciągu ostatnich {days} dni (+{delta} względem poprzedniego okresu).",
|
||||
"trendDown": "Twój nastrój spada w ciągu ostatnich {days} dni (-{delta} względem poprzedniego okresu).",
|
||||
"weekendUp": "W weekendy czujesz się lepiej (+{delta} względem dni roboczych).",
|
||||
"weekendDown": "Twój nastrój jest niższy w weekendy (-{delta} względem dni roboczych).",
|
||||
"tagLift": "Dni z tagiem „{tag}” są średnio o {delta} powyżej twojego ogólnego nastroju.",
|
||||
"tagDrop": "Dni z tagiem „{tag}” są o {delta} poniżej twojej średniej.",
|
||||
"inTarget": "Przez {pct}% ostatnich 30 dni byłeś w zakresie dobrego nastroju.",
|
||||
"streak": "Rejestrujesz nastrój od {days} dni z rzędu — tak trzymaj.",
|
||||
"timeOfDay": "Najlepiej czujesz się {bucket}, średnio {value}."
|
||||
},
|
||||
"timeOfDay": {
|
||||
"title": "Nastrój według pory dnia",
|
||||
"morning": "Rano",
|
||||
"afternoon": "Popołudnie",
|
||||
"evening": "Wieczór",
|
||||
"night": "Noc",
|
||||
"bestCaption": "Zwykle najlepiej czujesz się {bucket}."
|
||||
},
|
||||
"stability": {
|
||||
"value": "{score}/100",
|
||||
"bandVerySteady": "Twój nastrój był bardzo stabilny",
|
||||
"bandSteady": "Twój nastrój był dość stabilny",
|
||||
"bandVariable": "Twój nastrój był zauważalnie zmienny",
|
||||
"bandVeryVariable": "Twój nastrój ostatnio mocno się wahał"
|
||||
}
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"defaultLabel": "Domyślne",
|
||||
@@ -2476,7 +2672,7 @@
|
||||
"description": "Withings, moodLog, and other connected services."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Kanały powiadomień",
|
||||
"title": "Powiadomienia",
|
||||
"description": "Live overview of every configured channel."
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -2484,63 +2680,62 @@
|
||||
"description": "Tile layout and order."
|
||||
},
|
||||
"thresholds": {
|
||||
"title": "Cele i źródła",
|
||||
"description": "Dla każdej metryki: własny zakres docelowy i które źródło wygrywa, gdy kilka rejestruje tę samą wartość tego samego dnia."
|
||||
"title": "Cele",
|
||||
"description": "Dla każdej metryki: własny zakres docelowy zdrowych wartości."
|
||||
},
|
||||
"sources": {
|
||||
"title": "Sources",
|
||||
"description": "Pick which ingest source wins when more than one records the same metric for the same day.",
|
||||
"redirectNotice": "Źródła są teraz częścią „Cele i źródła”.",
|
||||
"cardTitle": "Source priority by metric",
|
||||
"help": "When more than one source delivers the same value on the same day (e.g. Withings and Apple Health both report 1,200 steps), the top source counts as the canonical reading. Other sources stay stored for the audit trail.",
|
||||
"resetDefaults": "Reset to defaults",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"saveSuccess": "Source priority saved.",
|
||||
"saveError": "Saving the source priority failed.",
|
||||
"resetSuccess": "Source priority reset to defaults.",
|
||||
"perMetricToggle": "Per-metric overrides ({count})",
|
||||
"perMetricHelp": "Metrics you have customised away from the default ladder. Use this list to roll any single metric back to its constant default without touching the rest.",
|
||||
"perMetricEmpty": "No per-metric overrides set — every metric uses the global ladder above.",
|
||||
"title": "Źródła",
|
||||
"description": "Wybierz, które źródło wygrywa, gdy kilka rejestruje tę samą metrykę tego samego dnia.",
|
||||
"cardTitle": "Priorytet źródeł według metryki",
|
||||
"help": "Gdy więcej niż jedno źródło dostarcza tę samą wartość tego samego dnia (np. Withings i Apple Health raportują po 1200 kroków), źródło na górze liczy się jako odczyt kanoniczny. Pozostałe źródła pozostają zapisane na potrzeby ścieżki audytu.",
|
||||
"resetDefaults": "Przywróć wartości domyślne",
|
||||
"moveUp": "W górę",
|
||||
"moveDown": "W dół",
|
||||
"saveSuccess": "Zapisano priorytet źródeł.",
|
||||
"saveError": "Zapisanie priorytetu źródeł nie powiodło się.",
|
||||
"resetSuccess": "Przywrócono domyślny priorytet źródeł.",
|
||||
"perMetricToggle": "Ustawienia dla poszczególnych metryk ({count})",
|
||||
"perMetricHelp": "Metryki dostosowane względem domyślnej kolejności. Użyj tej listy, aby przywrócić pojedynczą metrykę do wartości domyślnej, nie zmieniając pozostałych.",
|
||||
"perMetricEmpty": "Brak indywidualnych ustawień — każda metryka korzysta z globalnej kolejności powyżej.",
|
||||
"resetMetric": "Resetuj",
|
||||
"deviceTypeToggle": "Device-type priority ({count})",
|
||||
"deviceTypeHelp": "Within a source, which device wins. When an Apple Watch and an iPhone both record steps, the higher-ranked device-type counts as the canonical reading.",
|
||||
"deviceTypeDefault": "Default device-type ladder",
|
||||
"resetDeviceTypes": "Reset device-types",
|
||||
"deviceTypeToggle": "Priorytet według typu urządzenia ({count})",
|
||||
"deviceTypeHelp": "W obrębie źródła decyduje, które urządzenie wygrywa. Gdy Apple Watch i iPhone rejestrują kroki, wyżej sklasyfikowany typ urządzenia liczy się jako odczyt kanoniczny.",
|
||||
"deviceTypeDefault": "Domyślna kolejność typów urządzeń",
|
||||
"resetDeviceTypes": "Resetuj typy urządzeń",
|
||||
"metrics": {
|
||||
"steps": "Kroki",
|
||||
"activeEnergy": "Active energy",
|
||||
"walkingRunningDistance": "Walking + running distance",
|
||||
"flightsClimbed": "Flights climbed",
|
||||
"activeEnergy": "Aktywna energia",
|
||||
"walkingRunningDistance": "Dystans marszu + biegu",
|
||||
"flightsClimbed": "Pokonane piętra",
|
||||
"sleep": "Sen",
|
||||
"weight": "Waga",
|
||||
"bloodPressure": "Ciśnienie krwi",
|
||||
"pulse": "Tętno",
|
||||
"bodyFat": "Tkanka tłuszczowa",
|
||||
"bodyTemperature": "Body temperature",
|
||||
"spo2": "Oxygen saturation",
|
||||
"hrv": "Heart-rate variability",
|
||||
"bodyTemperature": "Temperatura ciała",
|
||||
"spo2": "Saturacja tlenem",
|
||||
"hrv": "Zmienność rytmu serca",
|
||||
"restingHeartRate": "Tętno spoczynkowe",
|
||||
"vo2Max": "VO2 max"
|
||||
},
|
||||
"sourceLabels": {
|
||||
"WITHINGS": "Withings",
|
||||
"APPLE_HEALTH": "Apple Health",
|
||||
"MANUAL": "Manual entry",
|
||||
"MANUAL": "Wpis ręczny",
|
||||
"IMPORT": "Importuj"
|
||||
},
|
||||
"deviceLabels": {
|
||||
"watch": "Watch",
|
||||
"band": "Band",
|
||||
"ring": "Ring",
|
||||
"phone": "Phone",
|
||||
"scale": "Scale",
|
||||
"other": "Other",
|
||||
"watch": "Zegarek",
|
||||
"band": "Opaska",
|
||||
"ring": "Pierścień",
|
||||
"phone": "Telefon",
|
||||
"scale": "Waga",
|
||||
"other": "Inne",
|
||||
"unknown": "Nieznane"
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
"title": "Insights",
|
||||
"title": "AI Insights",
|
||||
"description": "Provider, model, key."
|
||||
},
|
||||
"api": {
|
||||
@@ -3022,7 +3217,9 @@
|
||||
"fhirNote": "Dane ustrukturyzowane są zgodne ze standardem FHIR R4 (kompatybilne z ePA).",
|
||||
"generate": "Generuj",
|
||||
"error": "Eksport nie powiódł się ({code})."
|
||||
}
|
||||
},
|
||||
"globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji",
|
||||
"globalExcludedInjectionSitesHint": "Miejsca wymienione tutaj nie są proponowane dla żadnego leku i są odrzucane przy rejestrowaniu dawki, nawet jeśli lek je preferuje."
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
@@ -3843,8 +4040,6 @@
|
||||
}
|
||||
},
|
||||
"targets": {
|
||||
"title": "Wartości docelowe",
|
||||
"introText": "Twoje aktualne wartości na tle zalecanych zakresów docelowych. Źródła wytycznych podlinkowane na każdej karcie.",
|
||||
"bmi": "BMI",
|
||||
"average30d": "Średnia 30-dniowa:",
|
||||
"consistency": {
|
||||
@@ -3866,23 +4061,11 @@
|
||||
"sixDaysAgo": "6 dni temu"
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"lastMet": "Ostatnio w celu: {when}",
|
||||
"streak": "{count} dni z rzędu"
|
||||
},
|
||||
"relativeDay": {
|
||||
"today": "Dzisiaj",
|
||||
"yesterday": "Wczoraj",
|
||||
"daysAgo": "{count} dni temu"
|
||||
},
|
||||
"summary": {
|
||||
"weekTitle": "{met} z {total} celów osiągniętych w tym tygodniu",
|
||||
"weekTitleAll": "Wszystko{total} targets met this week",
|
||||
"streakHighlight": "{count} dni z rzędu: {metric}"
|
||||
},
|
||||
"coach": {
|
||||
"cta": "Zapytaj coacha"
|
||||
},
|
||||
"edit": {
|
||||
"openLabel": "Edytuj zakres docelowy dla {metric}",
|
||||
"title": "Edytuj zakres docelowy — {metric}",
|
||||
@@ -3914,31 +4097,10 @@
|
||||
"aboveTarget": "{delta} {unit} powyżej zakresu",
|
||||
"belowTarget": "{delta} {unit} poniżej zakresu",
|
||||
"diastolic": "Rozkurczowe",
|
||||
"noMeasurementYet": "Brak pomiarów",
|
||||
"noMeasurementData": "Brak danych pomiarowych. Dodaj pomiary, aby zobaczyć cele.",
|
||||
"profileIncomplete": "Profil niekompletny",
|
||||
"profileIncompleteHeight": "Wpisz swój wzrost w profilu, aby aktywować cele BMI.",
|
||||
"profileIncompleteAge": "Wpisz datę urodzenia w profilu, aby aktywować cele zależne od wieku.",
|
||||
"profileIncompleteHeightAge": "Wpisz wzrost i datę urodzenia w profilu, aby zobaczyć spersonalizowane cele.",
|
||||
"compliancePerMedication": "Przestrzeganie według leku",
|
||||
"glucoseFasting": "Glikemia — na czczo",
|
||||
"glucosePostprandial": "Glikemia — po posiłku",
|
||||
"glucoseRandom": "Glikemia — dowolna",
|
||||
"glucoseBedtime": "Glikemia — przed snem",
|
||||
"loadError": "Nie udało się załadować wartości docelowych",
|
||||
"label": {
|
||||
"WEIGHT": "Waga",
|
||||
"BLOOD_PRESSURE": "Ciśnienie krwi",
|
||||
"BLOOD_PRESSURE_IN_TARGET": "Ciśnienie w zakresie",
|
||||
"PULSE": "Tętno spoczynkowe",
|
||||
"SLEEP_DURATION": "Czas snu",
|
||||
"BMI": "BMI",
|
||||
"BODY_FAT": "Tkanka tłuszczowa",
|
||||
"ACTIVITY_STEPS": "Kroki/dzień",
|
||||
"MEDICATION_COMPLIANCE": "Przestrzeganie",
|
||||
"MOOD_SCORE": "Nastrój",
|
||||
"MOOD_STABILITY": "Stabilność nastroju"
|
||||
},
|
||||
"status": {
|
||||
"underweight": "Niedowaga",
|
||||
"normal": "Normal",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.8.2",
|
||||
"version": "1.9.2",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- 0098_app_settings_global_toggles_drift_fix
|
||||
-- Four `app_settings` columns have been on `prisma/schema.prisma` since the
|
||||
-- per-channel global notification toggles + admin default-locale work but
|
||||
-- never landed in the migration history (applied via `prisma db push` to
|
||||
-- existing environments but not committed as a migration). The drift is
|
||||
-- invisible on a database kept in sync via `db push`, but a database built
|
||||
-- from migrations alone is missing the columns — and the admin settings
|
||||
-- read path (`/api/admin/settings`) selects `default_locale`, so a fresh
|
||||
-- deploy reports `column "default_locale" does not exist` on first read.
|
||||
--
|
||||
-- Same class of drift as 0025_user_locale_drift_fix. Each add uses
|
||||
-- `IF NOT EXISTS` with the schema default, so the migration is a safe no-op
|
||||
-- against any environment already carrying the columns and a clean add
|
||||
-- against one built strictly from this migrations directory (CI test
|
||||
-- containers, fresh deploys, demo seeds).
|
||||
ALTER TABLE "app_settings"
|
||||
ADD COLUMN IF NOT EXISTS "default_locale" TEXT NOT NULL DEFAULT 'de',
|
||||
ADD COLUMN IF NOT EXISTS "telegram_global" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN IF NOT EXISTS "ntfy_global" BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN IF NOT EXISTS "web_push_global" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- v1.8.5 — per-medication injection-site tracking.
|
||||
--
|
||||
-- Makes injection-site tracking + rotation real. Prior to this release
|
||||
-- the `injection_site` column on `medication_intake_events` was read in
|
||||
-- several surfaces but never written; tracking was effectively dead.
|
||||
--
|
||||
-- Two additive, defaulted columns on `medications`:
|
||||
--
|
||||
-- - `track_injection_sites` — per-medication opt-in. Only meaningful
|
||||
-- (and only offered in the UI) when `delivery_form = 'INJECTION'`.
|
||||
-- Default false: the post-dose site prompt fires only when this is
|
||||
-- true, and the prompt is always skippable. Deactivatable any time.
|
||||
--
|
||||
-- - `allowed_injection_sites` — per-medication allowed / preferred
|
||||
-- sites. Empty = no per-medication restriction (every site offered).
|
||||
-- The effective pickable set is this list MINUS the user-level
|
||||
-- `users.global_excluded_injection_sites` deny-list (deny wins). The
|
||||
-- intake write path validates a submitted site against the effective
|
||||
-- set and rejects (422) a disallowed value.
|
||||
--
|
||||
-- Both columns are additive + defaulted; no backfill is required. The
|
||||
-- `injection_site` enum type already exists (migration 0046).
|
||||
--
|
||||
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe.
|
||||
--
|
||||
-- Reversibility:
|
||||
-- ALTER TABLE "medications" DROP COLUMN IF EXISTS "track_injection_sites";
|
||||
-- ALTER TABLE "medications" DROP COLUMN IF EXISTS "allowed_injection_sites";
|
||||
-- A roll-back loses the opt-in flag + the per-medication site list; the
|
||||
-- application reads tolerate a missing column / default-empty value.
|
||||
|
||||
ALTER TABLE "medications"
|
||||
ADD COLUMN IF NOT EXISTS "track_injection_sites" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS "allowed_injection_sites" "injection_site"[] NOT NULL DEFAULT ARRAY[]::"injection_site"[];
|
||||
@@ -0,0 +1,30 @@
|
||||
-- v1.8.5 — user-level global injection-site exclusion.
|
||||
--
|
||||
-- A per-user deny-list of injection sites. Sites listed here are never
|
||||
-- offered for ANY medication's injection-site picker, and the intake
|
||||
-- write path rejects (422) a submitted site that lands on this list —
|
||||
-- even when the per-medication `medications.allowed_injection_sites`
|
||||
-- lists it as preferred. Deny always wins.
|
||||
--
|
||||
-- Typical use: a user develops lipohypertrophy at one site and wants it
|
||||
-- excluded across every injectable they track, without editing each
|
||||
-- medication's allowed list individually.
|
||||
--
|
||||
-- Single additive, defaulted enum-array column on `users`. Default
|
||||
-- `'{}'` = no global exclusion (the pre-v1.8.5 behaviour). No backfill.
|
||||
-- The `injection_site` enum type already exists (migration 0046).
|
||||
--
|
||||
-- Idempotent guard (`IF NOT EXISTS`) makes reruns safe.
|
||||
--
|
||||
-- Reversibility:
|
||||
-- ALTER TABLE "users" DROP COLUMN IF EXISTS "global_excluded_injection_sites";
|
||||
-- A roll-back loses the global deny-list (every site becomes pickable
|
||||
-- again, subject to each medication's own allowed list); the
|
||||
-- application reads tolerate a missing column / default-empty value.
|
||||
--
|
||||
-- No index — reads are per-user at request time (`WHERE id = $1`),
|
||||
-- matching `insights_exclude_metrics` and the other display-pref
|
||||
-- columns on the same table.
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "global_excluded_injection_sites" "injection_site"[] NOT NULL DEFAULT ARRAY[]::"injection_site"[];
|
||||
@@ -0,0 +1,252 @@
|
||||
-- v1.8.5 — structured mood-tag taxonomy.
|
||||
--
|
||||
-- Adds a global Category -> Tag catalog plus a per-entry join, modelled
|
||||
-- on the standalone mood diary's structured taxonomy. Purely additive:
|
||||
-- the legacy flat `mood_entries.tags` JSON column is untouched and keeps
|
||||
-- working for every existing row, the GLP-1 quick-tag chips, and the
|
||||
-- Telegram / MoodLog sync. The structured taxonomy is a second, richer
|
||||
-- capture surface; the tag-frequency / lift breakdown folds the two
|
||||
-- axes together.
|
||||
--
|
||||
-- `label_key` columns carry an i18n message key (resolved client-side
|
||||
-- against all six locales) rather than a hard-coded label, so the seeded
|
||||
-- catalog renders localised everywhere. The seed at the bottom uses
|
||||
-- deterministic ids + ON CONFLICT upsert so re-running is idempotent.
|
||||
--
|
||||
-- Reversibility (down):
|
||||
-- DROP TABLE IF EXISTS "mood_entry_tag_links";
|
||||
-- DROP TABLE IF EXISTS "mood_tags";
|
||||
-- DROP TABLE IF EXISTS "mood_tag_categories";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "mood_tag_categories" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"label_key" TEXT NOT NULL,
|
||||
"icon" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "mood_tag_categories_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "mood_tags" (
|
||||
"id" TEXT NOT NULL,
|
||||
"category_id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"label_key" TEXT NOT NULL,
|
||||
"icon" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "mood_tags_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "mood_entry_tag_links" (
|
||||
"mood_entry_id" TEXT NOT NULL,
|
||||
"mood_tag_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "mood_entry_tag_links_pkey" PRIMARY KEY ("mood_entry_id","mood_tag_id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "mood_tag_categories_key_key" ON "mood_tag_categories"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "mood_tags_key_key" ON "mood_tags"("key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "mood_tags_category_id_sort_order_idx" ON "mood_tags"("category_id", "sort_order");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "mood_entry_tag_links_mood_tag_id_idx" ON "mood_entry_tag_links"("mood_tag_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mood_tags" ADD CONSTRAINT "mood_tags_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "mood_tag_categories"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mood_entry_tag_links" ADD CONSTRAINT "mood_entry_tag_links_mood_entry_id_fkey" FOREIGN KEY ("mood_entry_id") REFERENCES "mood_entries"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "mood_entry_tag_links" ADD CONSTRAINT "mood_entry_tag_links_mood_tag_id_fkey" FOREIGN KEY ("mood_tag_id") REFERENCES "mood_tags"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Seed the default taxonomy (idempotent upsert by `key`). Mirrors
|
||||
-- `src/lib/mood/tag-taxonomy.ts` — keep the two in sync if either
|
||||
-- changes.
|
||||
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mtc_feelings', 'feelings', 'mood.tagCategory.feelings', 'Heart', 0, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_happy', 'mtc_feelings', 'happy', 'mood.tag.happy', 'Smile', 0, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_excited', 'mtc_feelings', 'excited', 'mood.tag.excited', 'Zap', 1, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_grateful', 'mtc_feelings', 'grateful', 'mood.tag.grateful', 'HandHeart', 2, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_relaxed', 'mtc_feelings', 'relaxed', 'mood.tag.relaxed', 'CloudSun', 3, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_content', 'mtc_feelings', 'content', 'mood.tag.content', 'ThumbsUp', 4, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_tired', 'mtc_feelings', 'tired', 'mood.tag.tired', 'Moon', 5, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_unsure', 'mtc_feelings', 'unsure', 'mood.tag.unsure', 'HelpCircle', 6, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_bored', 'mtc_feelings', 'bored', 'mood.tag.bored', 'Meh', 7, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_tense', 'mtc_feelings', 'tense', 'mood.tag.tense', 'AlertTriangle', 8, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_angry', 'mtc_feelings', 'angry', 'mood.tag.angry', 'Flame', 9, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_stressed', 'mtc_feelings', 'stressed', 'mood.tag.stressed', 'Brain', 10, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_sad', 'mtc_feelings', 'sad', 'mood.tag.sad', 'Frown', 11, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mtc_sleep', 'sleep', 'mood.tagCategory.sleep', 'BedDouble', 1, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_slept_well', 'mtc_sleep', 'slept_well', 'mood.tag.sleptWell', 'Moon', 0, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_slept_ok', 'mtc_sleep', 'slept_ok', 'mood.tag.sleptOk', 'CloudMoon', 1, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_slept_poorly', 'mtc_sleep', 'slept_poorly', 'mood.tag.sleptPoorly', 'MoonStar', 2, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_early_night', 'mtc_sleep', 'early_night', 'mood.tag.earlyNight', 'Clock', 3, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mtc_health', 'health', 'mood.tagCategory.health', 'HeartPulse', 2, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_worked_out', 'mtc_health', 'worked_out', 'mood.tag.workedOut', 'Dumbbell', 0, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_ate_well', 'mtc_health', 'ate_well', 'mood.tag.ateWell', 'Apple', 1, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_hydrated', 'mtc_health', 'hydrated', 'mood.tag.hydrated', 'GlassWater', 2, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_walked', 'mtc_health', 'walked', 'mood.tag.walked', 'Footprints', 3, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_alcohol', 'mtc_health', 'alcohol', 'mood.tag.alcohol', 'Wine', 4, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mtc_social', 'social', 'mood.tagCategory.social', 'Users', 3, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_family', 'mtc_social', 'family', 'mood.tag.family', 'Home', 0, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_friends', 'mtc_social', 'friends', 'mood.tag.friends', 'Users', 1, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_party', 'mtc_social', 'party', 'mood.tag.party', 'PartyPopper', 2, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_alone', 'mtc_social', 'alone', 'mood.tag.alone', 'User', 3, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tag_categories" ("id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mtc_work', 'work', 'mood.tagCategory.work', 'Briefcase', 4, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_productive', 'mtc_work', 'productive', 'mood.tag.productive', 'CheckCircle', 0, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_overtime', 'mtc_work', 'overtime', 'mood.tag.overtime', 'Clock', 1, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_day_off', 'mtc_work', 'day_off', 'mood.tag.dayOff', 'LogOut', 2, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_travel', 'mtc_work', 'travel', 'mood.tag.travel', 'Plane', 3, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
|
||||
INSERT INTO "mood_tags" ("id", "category_id", "key", "label_key", "icon", "sort_order", "is_active")
|
||||
VALUES ('mt_sick_day', 'mtc_work', 'sick_day', 'mood.tag.sickDay', 'Thermometer', 4, true)
|
||||
ON CONFLICT ("key") DO UPDATE SET
|
||||
"category_id" = EXCLUDED."category_id", "label_key" = EXCLUDED."label_key", "icon" = EXCLUDED."icon", "sort_order" = EXCLUDED."sort_order";
|
||||
@@ -0,0 +1,32 @@
|
||||
-- v1.8.6 — optional German insurer institution number (IKNR) on the user
|
||||
-- profile.
|
||||
--
|
||||
-- The IKNR ("Institutionskennzeichen", 9 digits) identifies the statutory
|
||||
-- or private health insurer. It sits next to the existing plaintext
|
||||
-- `insurer_name` and the encrypted `insurance_number_encrypted` (KVNR).
|
||||
-- Unlike the KVNR — a per-person quasi-identifier kept AES-256-GCM
|
||||
-- encrypted at rest — the IKNR is a public registry number for the
|
||||
-- institution, so it is stored plaintext, matching `insurer_name`.
|
||||
--
|
||||
-- Surfaced on the health-record FHIR export as the contained payor
|
||||
-- `Organization` identifier (system `http://fhir.de/sid/arge-ik/iknr`)
|
||||
-- inside the new `Coverage` resource.
|
||||
--
|
||||
-- Single additive, nullable, NULL-defaulted column on `users`. No
|
||||
-- backfill — pre-v1.8.6 rows read NULL (no IKNR captured yet), which the
|
||||
-- exporter treats identically to an absent value.
|
||||
--
|
||||
-- Idempotent guard (`IF NOT EXISTS`) makes reruns safe. Forward-only.
|
||||
--
|
||||
-- Reversibility:
|
||||
-- ALTER TABLE "users" DROP COLUMN IF EXISTS "insurer_ik_number";
|
||||
-- A roll-back loses the captured IKNR; the FHIR export simply emits the
|
||||
-- payor `Organization` with a name only (no identifier), and the
|
||||
-- application reads tolerate a missing column / NULL value.
|
||||
--
|
||||
-- No index — reads are per-user at request time (`WHERE id = $1`),
|
||||
-- matching `insurer_name` and the other display-identity columns on the
|
||||
-- same table.
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "insurer_ik_number" TEXT;
|
||||
@@ -0,0 +1,38 @@
|
||||
-- v1.9.0 — optional drug-classification codes on the medication row.
|
||||
--
|
||||
-- Two additive, nullable, NULL-defaulted columns on `medications`:
|
||||
--
|
||||
-- * `atc_code` — the WHO ATC active-substance class (e.g. "A10BX10").
|
||||
-- Emitted on the FHIR `MedicationStatement` /
|
||||
-- `MedicationAdministration` `medicationCodeableConcept`
|
||||
-- under the system URI `http://www.whocc.no/atc`.
|
||||
-- * `rxnorm_code` — the RxNorm RxCUI (US identifier). Secondary coding
|
||||
-- under `http://www.nlm.nih.gov/research/umls/rxnorm`.
|
||||
--
|
||||
-- Both are user/clinician-asserted, never machine-guessed: a wrong code
|
||||
-- is a clinical-safety problem, an absent code is merely a (conformant)
|
||||
-- text-only concept. The application validates the format on write
|
||||
-- (ATC `^[A-Z]\d{2}[A-Z]{2}\d{2}$`, RxCUI `^\d+$`) and 422s on a
|
||||
-- malformed value; the DB column is a plain nullable TEXT field.
|
||||
--
|
||||
-- No backfill — pre-v1.9.0 rows read NULL, which the exporter treats
|
||||
-- identically to an absent value (the medicationCodeableConcept stays
|
||||
-- text-only, the pre-v1.9.0 shape).
|
||||
--
|
||||
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
|
||||
--
|
||||
-- Reversibility:
|
||||
-- ALTER TABLE "medications" DROP COLUMN IF EXISTS "atc_code";
|
||||
-- ALTER TABLE "medications" DROP COLUMN IF EXISTS "rxnorm_code";
|
||||
-- A roll-back loses the captured codes; the FHIR export falls back to the
|
||||
-- text-only medicationCodeableConcept and the application reads tolerate a
|
||||
-- missing column / NULL value.
|
||||
--
|
||||
-- No index — reads are per-user at request time (`WHERE user_id = $1`),
|
||||
-- matching the other display-identity columns on the same table.
|
||||
|
||||
ALTER TABLE "medications"
|
||||
ADD COLUMN IF NOT EXISTS "atc_code" TEXT;
|
||||
|
||||
ALTER TABLE "medications"
|
||||
ADD COLUMN IF NOT EXISTS "rxnorm_code" TEXT;
|
||||
@@ -43,6 +43,13 @@ model User {
|
||||
// behaviour for every existing row.
|
||||
insightsExcludeMetrics String[] @default([]) @map("insights_exclude_metrics")
|
||||
|
||||
// v1.8.5 — user-level injection-site deny-list. Sites listed here are
|
||||
// never offered for ANY medication's injection-site picker and the
|
||||
// intake write path 422s a submitted site that lands on this list,
|
||||
// even when the per-medication `allowedInjectionSites` lists it as
|
||||
// preferred (deny always wins). Default `[]` = no global exclusion.
|
||||
globalExcludedInjectionSites InjectionSite[] @default([]) @map("global_excluded_injection_sites")
|
||||
|
||||
// Telegram notifications (per-user)
|
||||
telegramBotToken String? @map("telegram_bot_token") // AES-256-GCM encrypted
|
||||
telegramChatId String? @map("telegram_chat_id")
|
||||
@@ -411,6 +418,16 @@ model User {
|
||||
insurerName String? @map("insurer_name")
|
||||
insuranceNumberEncrypted String? @map("insurance_number_encrypted")
|
||||
|
||||
// v1.8.6 profile — optional German insurer institution number (IKNR,
|
||||
// 9 digits). Surfaced on the FHIR `Coverage` resource's contained
|
||||
// payor `Organization` (system `http://fhir.de/sid/arge-ik/iknr`).
|
||||
// Stored plaintext, matching `insurerName` — the IKNR is a public
|
||||
// registry identifier, not a quasi-identifier like the KVNR, so it
|
||||
// does not warrant the encrypted column treatment. Optional; an unset
|
||||
// field collapses the Coverage payor identifier exactly like an
|
||||
// unset `insurerName` collapses its name.
|
||||
insurerIkNumber String? @map("insurer_ik_number")
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
@@ -917,6 +934,19 @@ model Medication {
|
||||
/// A one-time injection is `oneShot = true` + `deliveryForm =
|
||||
/// INJECTION`. The migration backfills ORAL for every existing row.
|
||||
deliveryForm MedicationDeliveryForm @default(ORAL) @map("delivery_form")
|
||||
/// v1.8.5 — per-medication injection-site tracking opt-in. Only
|
||||
/// meaningful (and only offered in the UI) when `deliveryForm ===
|
||||
/// INJECTION`. Default false: after marking a dose taken the client
|
||||
/// prompts for the site only when this is true, and the prompt is
|
||||
/// always skippable (never blocking). Deactivatable at any time.
|
||||
trackInjectionSites Boolean @default(false) @map("track_injection_sites")
|
||||
/// v1.8.5 — per-medication allowed / preferred injection sites. Empty
|
||||
/// = no per-medication restriction (every site is offered). The
|
||||
/// effective pickable set is this list MINUS the user-level
|
||||
/// `globalExcludedInjectionSites` deny-list (deny always wins). The
|
||||
/// intake write path validates a submitted site against the effective
|
||||
/// set and 422s on a disallowed value.
|
||||
allowedInjectionSites InjectionSite[] @default([]) @map("allowed_injection_sites")
|
||||
/// v1.7.0 — iOS Live Activity opt-in for this medication's reminders.
|
||||
/// Default false; existing rows backfill false. The server stores +
|
||||
/// echoes the flag; the iOS client owns the ActivityKit lifecycle. No
|
||||
@@ -927,6 +957,18 @@ model Medication {
|
||||
/// stores the preference only — it does not itself authorise anything
|
||||
/// server-side.
|
||||
criticalAlarmEnabled Boolean @default(false) @map("critical_alarm_enabled")
|
||||
/// v1.9.0 — optional ATC classification code (active-substance class,
|
||||
/// e.g. `A10BX10`). User/clinician-asserted; never machine-guessed on
|
||||
/// the export path. NULL = no code (the FHIR exporter emits a text-only
|
||||
/// `medicationCodeableConcept`, the pre-v1.9.0 behaviour). The WHO URI
|
||||
/// `http://www.whocc.no/atc` is the emitted system. Validated on write
|
||||
/// against `^[A-Z]\d{2}[A-Z]{2}\d{2}$`.
|
||||
atcCode String? @map("atc_code")
|
||||
/// v1.9.0 — optional RxNorm RxCUI (US; secondary coding). Same NULL
|
||||
/// semantics as `atcCode`. Validated on write against `^\d+$`. Emitted
|
||||
/// under `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any
|
||||
/// ATC code, never instead of the free-text `.text` anchor.
|
||||
rxNormCode String? @map("rxnorm_code")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -1540,6 +1582,13 @@ model MoodEntry {
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// v1.8.5 — structured-tag links. Additive alongside the legacy flat
|
||||
// `tags` JSON string: an entry can carry both (a free-text tag the user
|
||||
// typed AND a structured tag they picked from the taxonomy). The
|
||||
// aggregates read whichever is present; existing rows keep their flat
|
||||
// `tags` untouched.
|
||||
tagLinks MoodEntryTagLink[]
|
||||
|
||||
@@unique([userId, date, moodLoggedAt])
|
||||
@@index([userId, date])
|
||||
// v1.7.0 sync — keyset support for the `/api/sync/changes` mood feed
|
||||
@@ -1548,6 +1597,79 @@ model MoodEntry {
|
||||
@@map("mood_entries")
|
||||
}
|
||||
|
||||
// ─── Mood Tag Taxonomy (v1.8.5 W6-tax) ────────────────────────
|
||||
//
|
||||
// Structured, icon-bearing tag catalog modelled on the standalone mood
|
||||
// diary's Category → TagItem shape. Global reference data (one shared
|
||||
// catalog for the deployment, not per-user): a self-hoster's account
|
||||
// inherits the seeded categories the same way the metric-type enum is
|
||||
// shared. `MoodEntryTagLink` is the per-entry join.
|
||||
//
|
||||
// Backward-compat: this is purely ADDITIVE. The legacy flat
|
||||
// `MoodEntry.tags` (a JSON string array of free-text keys) keeps working
|
||||
// unchanged — the GLP-1 quick-tag chips, the Telegram/MoodLog sync, and
|
||||
// every existing row stay on the flat axis. The structured taxonomy is a
|
||||
// second, richer capture surface that the tag-frequency / lift breakdown
|
||||
// folds in next to the flat tags (`mood-aggregates.ts`).
|
||||
//
|
||||
// `labelKey` carries an i18n key (resolved client-side against all six
|
||||
// locales) rather than a hard-coded German label, so the seeded catalog
|
||||
// renders localised everywhere. `icon` is a Lucide icon name rendered by
|
||||
// the capture UI + breakdown.
|
||||
model MoodTagCategory {
|
||||
id String @id @default(cuid())
|
||||
/// Stable machine key (e.g. `feelings`). Unique across the catalog so
|
||||
/// the seed migration can upsert idempotently.
|
||||
key String @unique
|
||||
/// i18n message key for the category label (`mood.tagCategory.<key>`).
|
||||
labelKey String @map("label_key")
|
||||
/// Lucide icon name for the category header.
|
||||
icon String?
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
/// Soft-hide a category without deleting its historical links.
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
tags MoodTag[]
|
||||
|
||||
@@map("mood_tag_categories")
|
||||
}
|
||||
|
||||
model MoodTag {
|
||||
id String @id @default(cuid())
|
||||
categoryId String @map("category_id")
|
||||
/// Stable machine key (e.g. `happy`). Unique across the catalog.
|
||||
key String @unique
|
||||
/// i18n message key for the tag label (`mood.tag.<key>`).
|
||||
labelKey String @map("label_key")
|
||||
/// Lucide icon name for the tag chip.
|
||||
icon String?
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
|
||||
category MoodTagCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
||||
links MoodEntryTagLink[]
|
||||
|
||||
@@index([categoryId, sortOrder])
|
||||
@@map("mood_tags")
|
||||
}
|
||||
|
||||
// Join: which structured tags a mood entry carries. Composite PK keeps
|
||||
// the pairing unique; cascade on both sides so deleting an entry (or a
|
||||
// catalog tag) cleans up its links. Distinct from the flat `tags` JSON
|
||||
// column on `MoodEntry`.
|
||||
model MoodEntryTagLink {
|
||||
moodEntryId String @map("mood_entry_id")
|
||||
moodTagId String @map("mood_tag_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
moodEntry MoodEntry @relation(fields: [moodEntryId], references: [id], onDelete: Cascade)
|
||||
moodTag MoodTag @relation(fields: [moodTagId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([moodEntryId, moodTagId])
|
||||
@@index([moodTagId])
|
||||
@@map("mood_entry_tag_links")
|
||||
}
|
||||
|
||||
// ─── Mood Reminder Dispatch Ledger (v0.5.4 ios-coord) ─────
|
||||
//
|
||||
// Idempotency guard for the daily 22:00 MOOD_REMINDER push. The
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Post-deploy version assertion.
|
||||
*
|
||||
* Polls `/api/version` on production + demo and asserts the running
|
||||
* build's `data.version` matches an expected value, printing each
|
||||
* target's `buildSha`. Exits non-zero on a mismatch or after the retry
|
||||
* window elapses — so a CI step or a manual deploy can fail loudly when
|
||||
* the `:latest` pull served the prior image (the recurring BuildKit
|
||||
* layer-cache pitfall where Coolify reports "deployed" while
|
||||
* `/api/version` still answers the old number).
|
||||
*
|
||||
* Run it after every deploy — the queued-deploy status is not the source
|
||||
* of truth, the served version is:
|
||||
*
|
||||
* pnpm dlx tsx scripts/assert-deploy.ts <expected-version>
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* # assert both prod + demo serve 1.9.0
|
||||
* pnpm dlx tsx scripts/assert-deploy.ts 1.9.0
|
||||
*
|
||||
* # assert prod only
|
||||
* pnpm dlx tsx scripts/assert-deploy.ts 1.9.0 --only=prod
|
||||
*
|
||||
* # widen the retry window (default 24 attempts × 5s = 2 min)
|
||||
* pnpm dlx tsx scripts/assert-deploy.ts 1.9.0 --attempts=60 --interval=5000
|
||||
*
|
||||
* Pure script — strips the leading `v` from the expected version so both
|
||||
* `v1.9.0` and `1.9.0` work. No app-library import, no runtime wiring;
|
||||
* the standalone image never ships this file.
|
||||
*/
|
||||
|
||||
interface Target {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const TARGETS: Record<string, Target> = {
|
||||
prod: { name: "prod", url: "https://healthlog.bombeck.io/api/version" },
|
||||
demo: { name: "demo", url: "https://demo.healthlog.dev/api/version" },
|
||||
};
|
||||
|
||||
interface VersionPayload {
|
||||
version?: string;
|
||||
buildSha?: string | null;
|
||||
builtAt?: string | null;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): {
|
||||
expected: string;
|
||||
attempts: number;
|
||||
intervalMs: number;
|
||||
only: string | null;
|
||||
} {
|
||||
const positional: string[] = [];
|
||||
let attempts = 24;
|
||||
let intervalMs = 5_000;
|
||||
let only: string | null = null;
|
||||
|
||||
for (const arg of argv) {
|
||||
if (arg.startsWith("--attempts=")) {
|
||||
attempts = Number.parseInt(arg.slice("--attempts=".length), 10);
|
||||
} else if (arg.startsWith("--interval=")) {
|
||||
intervalMs = Number.parseInt(arg.slice("--interval=".length), 10);
|
||||
} else if (arg.startsWith("--only=")) {
|
||||
only = arg.slice("--only=".length);
|
||||
} else {
|
||||
positional.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
const expected = (positional[0] ?? "").trim().replace(/^v/, "");
|
||||
if (!expected) {
|
||||
console.error(
|
||||
"usage: pnpm dlx tsx scripts/assert-deploy.ts <expected-version> [--only=prod|demo] [--attempts=N] [--interval=ms]",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
if (!Number.isFinite(attempts) || attempts < 1) attempts = 24;
|
||||
if (!Number.isFinite(intervalMs) || intervalMs < 0) intervalMs = 5_000;
|
||||
|
||||
return { expected, attempts, intervalMs, only };
|
||||
}
|
||||
|
||||
const sleep = (ms: number): Promise<void> =>
|
||||
new Promise((res) => setTimeout(res, ms));
|
||||
|
||||
/** One probe. Returns the parsed payload or null on any transport / shape error. */
|
||||
async function probe(url: string): Promise<VersionPayload | null> {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { data?: VersionPayload } | null;
|
||||
return body?.data ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll one target until its served version matches `expected` or the
|
||||
* retry window elapses. Resolves true on a match, false otherwise.
|
||||
*/
|
||||
async function assertTarget(
|
||||
target: Target,
|
||||
expected: string,
|
||||
attempts: number,
|
||||
intervalMs: number,
|
||||
): Promise<boolean> {
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
const payload = await probe(target.url);
|
||||
const version = payload?.version?.trim() ?? null;
|
||||
if (version === expected) {
|
||||
console.log(
|
||||
`[${target.name}] OK version=${version} buildSha=${payload?.buildSha ?? "—"}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
const seen = version ?? "unreachable";
|
||||
console.log(
|
||||
`[${target.name}] attempt ${attempt}/${attempts}: expected ${expected}, got ${seen}`,
|
||||
);
|
||||
if (attempt < attempts) await sleep(intervalMs);
|
||||
}
|
||||
console.error(
|
||||
`[${target.name}] FAIL: ${target.url} did not report ${expected} within ${attempts} attempts`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const { expected, attempts, intervalMs, only } = parseArgs(
|
||||
process.argv.slice(2),
|
||||
);
|
||||
|
||||
let selected: Target[];
|
||||
if (only) {
|
||||
const picked = TARGETS[only];
|
||||
if (!picked) {
|
||||
console.error(`unknown --only target: ${only} (use prod or demo)`);
|
||||
process.exit(2);
|
||||
}
|
||||
selected = [picked];
|
||||
} else {
|
||||
selected = Object.values(TARGETS);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Asserting version ${expected} on: ${selected.map((t) => t.name).join(", ")}`,
|
||||
);
|
||||
|
||||
// Probe targets in parallel; each runs its own bounded retry loop.
|
||||
const results = await Promise.all(
|
||||
selected.map((t) => assertTarget(t, expected, attempts, intervalMs)),
|
||||
);
|
||||
|
||||
if (results.every((ok) => ok)) {
|
||||
console.log("All targets serve the expected version.");
|
||||
process.exit(0);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
void main();
|
||||
+12
-12
@@ -247,14 +247,14 @@ async function seed() {
|
||||
const takenTime = new Date(date);
|
||||
takenTime.setHours(8, Math.floor(Math.random() * 45), 0);
|
||||
await client.query(
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5)`,
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5, $5)`,
|
||||
[cuid(), userId, med1Id, date, takenTime],
|
||||
);
|
||||
} else {
|
||||
await client.query(
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
|
||||
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4)`,
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4, $4)`,
|
||||
[cuid(), userId, med1Id, date],
|
||||
);
|
||||
}
|
||||
@@ -265,14 +265,14 @@ async function seed() {
|
||||
const takenTime = new Date(date);
|
||||
takenTime.setHours(8, Math.floor(5 + Math.random() * 50), 0);
|
||||
await client.query(
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5)`,
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5, $5)`,
|
||||
[cuid(), userId, med2Id, date, takenTime],
|
||||
);
|
||||
} else {
|
||||
await client.query(
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
|
||||
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4)`,
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4, $4)`,
|
||||
[cuid(), userId, med2Id, date],
|
||||
);
|
||||
}
|
||||
@@ -284,14 +284,14 @@ async function seed() {
|
||||
const takenTime = new Date(date);
|
||||
takenTime.setHours(20, Math.floor(Math.random() * 60), 0);
|
||||
await client.query(
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5)`,
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, false, 'WEB', $5, $5)`,
|
||||
[cuid(), userId, med3Id, date, takenTime],
|
||||
);
|
||||
} else {
|
||||
await client.query(
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at)
|
||||
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4)`,
|
||||
`INSERT INTO medication_intake_events (id, user_id, medication_id, scheduled_for, taken_at, skipped, source, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NULL, true, 'WEB', $4, $4)`,
|
||||
[cuid(), userId, med3Id, date],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.25 W3e — Coach drawer mount + per-card CTA gate.
|
||||
*
|
||||
* Three scenarios:
|
||||
* 1. AI provider configured → per-card "Ask Coach" CTAs render
|
||||
* alongside each card; the drawer is mounted at the page level.
|
||||
* 2. AI provider NOT configured → CTAs are completely absent (no
|
||||
* broken-button state).
|
||||
* 3. v1.4.37 W5 — operator's global Coach flag off → both the CTAs
|
||||
* and the page-level drawer mount vanish. The flag default is
|
||||
* all-on for fresh installs and on any fetch error, so the path
|
||||
* stays open by default (mocked via `useFeatureFlags` below).
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/targets",
|
||||
}));
|
||||
|
||||
let coachFlag = true;
|
||||
vi.mock("@/hooks/use-feature-flags", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/hooks/use-feature-flags")
|
||||
>("@/hooks/use-feature-flags");
|
||||
return {
|
||||
...actual,
|
||||
useFeatureFlags: () => ({
|
||||
...actual.DEFAULT_ASSISTANT_FLAGS,
|
||||
coach: coachFlag,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const sampleData = {
|
||||
targets: [
|
||||
{
|
||||
type: "WEIGHT",
|
||||
label: "Weight",
|
||||
current: 78,
|
||||
average30: 78,
|
||||
trend: "stable",
|
||||
unit: "kg",
|
||||
range: { min: 60, max: 80 },
|
||||
classification: { category: "Normal", color: "#50fa7b" },
|
||||
source: "WHO BMI",
|
||||
daysInRange7d: 5,
|
||||
daysLogged7d: 7,
|
||||
lastMetGoalAt: null,
|
||||
streakDays: 0,
|
||||
insufficientData: false,
|
||||
consistency7d: ["in", "in", "in", "in", "in", "in", "in"],
|
||||
},
|
||||
],
|
||||
pageSummary: {
|
||||
targetsMetThisWeek: 1,
|
||||
totalTargets: 1,
|
||||
streakHighlight: null,
|
||||
},
|
||||
bpDiastolic: { current: null, average30: null, range: null },
|
||||
profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null },
|
||||
};
|
||||
|
||||
let providerChainResponse: { activeProvider: string | null } | null = null;
|
||||
|
||||
vi.mock("@tanstack/react-query", () => {
|
||||
const noopClient = {
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
refetchQueries: vi.fn(),
|
||||
};
|
||||
return {
|
||||
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
||||
if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") {
|
||||
return {
|
||||
data: providerChainResponse,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: sampleData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
},
|
||||
useQueryClient: () => noopClient,
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
reset: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
role: "USER",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import TargetsPage from "../targets/page";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<TargetsPage />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("/targets page — Coach drawer mount + CTA gate", () => {
|
||||
beforeEach(() => {
|
||||
coachFlag = true;
|
||||
});
|
||||
|
||||
it("renders the per-card Coach CTA when an AI provider is configured", () => {
|
||||
providerChainResponse = { activeProvider: "openai" };
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="target-coach-cta"');
|
||||
expect(html).toContain("Ask Coach");
|
||||
});
|
||||
|
||||
it("hides every per-card Coach CTA when no AI provider is configured", () => {
|
||||
providerChainResponse = { activeProvider: null };
|
||||
const html = render();
|
||||
expect(html).not.toContain('data-slot="target-coach-cta"');
|
||||
});
|
||||
|
||||
it("renders nothing different when chainStatus query is still loading", () => {
|
||||
providerChainResponse = null;
|
||||
const html = render();
|
||||
// Same as the "no provider" branch — until we know the provider is
|
||||
// configured we don't render a CTA whose click would 404.
|
||||
expect(html).not.toContain('data-slot="target-coach-cta"');
|
||||
});
|
||||
|
||||
// v1.4.37 W5 — Marc directive: when the operator turns off the
|
||||
// global Coach flag, every Coach affordance on /targets must vanish.
|
||||
// That is BOTH the per-card CTAs (currently gated through `aiEnabled`)
|
||||
// AND the page-level <CoachDrawer> mount — so the SSE machinery,
|
||||
// Sheet portal and source-chip thread never load.
|
||||
it("hides every per-card Coach CTA when the global Coach flag is off", () => {
|
||||
coachFlag = false;
|
||||
providerChainResponse = { activeProvider: "openai" };
|
||||
const html = render();
|
||||
expect(html).not.toContain('data-slot="target-coach-cta"');
|
||||
});
|
||||
|
||||
it("does not mount the CoachDrawer when the global Coach flag is off", () => {
|
||||
coachFlag = false;
|
||||
providerChainResponse = { activeProvider: "openai" };
|
||||
const html = render();
|
||||
// The drawer renders a Sheet that the body markup exposes via a
|
||||
// role + the i18n key it carries. We grep on the localised
|
||||
// composer label that the drawer always paints.
|
||||
expect(html).not.toContain('data-slot="coach-drawer"');
|
||||
expect(html).not.toContain('data-slot="coach-drawer-composer"');
|
||||
});
|
||||
});
|
||||
@@ -1,204 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.19 phase A7 — the maintainer reported the `/targets` (Zielwerte) page
|
||||
* still surfaces English status labels even when the rest of the UI
|
||||
* renders in German: "Low / On Target / Stable / Moderate" stayed in
|
||||
* the source language. The card titles ("Weight", "Blood pressure",
|
||||
* "Body fat" …) had the same issue — server returns English
|
||||
* strings, the page renders them verbatim.
|
||||
*
|
||||
* Translate every classification category and card label on the
|
||||
* client. The server keeps emitting stable English keys (so external
|
||||
* consumers / logs don't churn); the page resolves them to i18n
|
||||
* strings via `useTranslations()`.
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/targets",
|
||||
}));
|
||||
|
||||
const sampleData = {
|
||||
targets: [
|
||||
{
|
||||
type: "WEIGHT",
|
||||
label: "Weight",
|
||||
current: 88.2,
|
||||
average30: 88.6,
|
||||
trend: "stable",
|
||||
unit: "kg",
|
||||
range: { min: 60, max: 80 },
|
||||
classification: { category: "Normal", color: "#50fa7b" },
|
||||
source: "WHO BMI",
|
||||
},
|
||||
{
|
||||
type: "BLOOD_PRESSURE_IN_TARGET",
|
||||
label: "Blood pressure on target",
|
||||
current: 65,
|
||||
average30: 65,
|
||||
trend: null,
|
||||
unit: "%",
|
||||
range: { min: 70, max: 100 },
|
||||
classification: { category: "Moderate", color: "#f1fa8c" },
|
||||
source: "ESH 2023",
|
||||
},
|
||||
{
|
||||
type: "PULSE",
|
||||
label: "Resting pulse",
|
||||
current: 72,
|
||||
average30: 72,
|
||||
trend: "stable",
|
||||
unit: "bpm",
|
||||
range: { min: 60, max: 100 },
|
||||
classification: { category: "On target", color: "#50fa7b" },
|
||||
source: "AHA",
|
||||
},
|
||||
{
|
||||
type: "MOOD_STABILITY",
|
||||
label: "Mood stability",
|
||||
current: 0.6,
|
||||
average30: 0.6,
|
||||
trend: null,
|
||||
unit: "σ",
|
||||
range: { min: 0, max: 0.5 },
|
||||
classification: { category: "Stable", color: "#f1fa8c" },
|
||||
source: "moodLog",
|
||||
},
|
||||
{
|
||||
type: "MEDICATION_COMPLIANCE",
|
||||
label: "Medication compliance",
|
||||
current: 60,
|
||||
average30: 60,
|
||||
trend: null,
|
||||
unit: "%",
|
||||
range: { min: 90, max: 100 },
|
||||
classification: { category: "Low", color: "#ff5555" },
|
||||
source: "7-day",
|
||||
},
|
||||
],
|
||||
bpDiastolic: { current: null, average30: null, range: null },
|
||||
profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null },
|
||||
};
|
||||
|
||||
vi.mock("@tanstack/react-query", () => {
|
||||
const noopClient = {
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
refetchQueries: vi.fn(),
|
||||
};
|
||||
return {
|
||||
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
||||
// v1.4.25 W3e — the targets page now also queries
|
||||
// ["insights", "provider-chain"] to gate the per-card Coach
|
||||
// CTA. Return null for that query (no AI provider configured)
|
||||
// so the CTA hides and the existing test cases keep asserting
|
||||
// the unchanged visual surface.
|
||||
if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") {
|
||||
return {
|
||||
data: null,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: sampleData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
},
|
||||
useQueryClient: () => noopClient,
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
reset: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
role: "USER",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// v1.4.37 W5 — `/targets/page.tsx` now reads `useFeatureFlags` to gate
|
||||
// the Coach drawer + per-card CTAs. The hook resolves QueryClientContext
|
||||
// from `@tanstack/react-query`, and this test mocks the library
|
||||
// without exposing that context. Mock the hook directly with the
|
||||
// all-on default so the i18n assertions below run against the visible
|
||||
// Coach surfaces.
|
||||
vi.mock("@/hooks/use-feature-flags", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/hooks/use-feature-flags")
|
||||
>("@/hooks/use-feature-flags");
|
||||
return {
|
||||
...actual,
|
||||
useFeatureFlags: () => actual.DEFAULT_ASSISTANT_FLAGS,
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import TargetsPage from "../targets/page";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<TargetsPage />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("/targets page — i18n status labels and titles", () => {
|
||||
it("translates card titles to German when the locale is `de`", () => {
|
||||
const html = render("de");
|
||||
expect(html).toContain("Gewicht"); // Weight
|
||||
expect(html).toContain("Ruhepuls"); // Resting pulse
|
||||
expect(html).toContain("Stimmungsstabilität"); // Mood stability
|
||||
expect(html).toContain("Einnahmetreue"); // Medication compliance
|
||||
// Server-emitted English labels must not leak.
|
||||
expect(html).not.toContain("Resting pulse");
|
||||
expect(html).not.toContain("Mood stability");
|
||||
expect(html).not.toContain("Medication compliance");
|
||||
});
|
||||
|
||||
it("translates every status badge category to German", () => {
|
||||
const html = render("de");
|
||||
// the maintainer explicitly named these in his bug report.
|
||||
expect(html).not.toMatch(/>Low</);
|
||||
expect(html).not.toMatch(/>On target</);
|
||||
expect(html).not.toMatch(/>Stable</);
|
||||
expect(html).not.toMatch(/>Moderate</);
|
||||
// Their German renderings must show up instead.
|
||||
expect(html).toContain("Niedrig"); // Low
|
||||
expect(html).toContain("Im Zielbereich"); // On target
|
||||
expect(html).toContain("Stabil"); // Stable
|
||||
expect(html).toContain("Moderat"); // Moderate
|
||||
// "Normal" is identical in EN and DE — the badge should still
|
||||
// render the localised key (`targets.status.normal` → "Normal"),
|
||||
// not the raw server string.
|
||||
expect(html).toContain("Normal");
|
||||
});
|
||||
|
||||
it("English locale still surfaces the original English status labels (no breakage)", () => {
|
||||
const html = render("en");
|
||||
expect(html).toContain("Normal");
|
||||
expect(html).toContain("Moderate");
|
||||
expect(html).toContain("On target");
|
||||
expect(html).toContain("Stable");
|
||||
expect(html).toContain("Low");
|
||||
});
|
||||
});
|
||||
@@ -1,148 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.25 W3e — mobile-first responsive layout audit.
|
||||
*
|
||||
* Marc directive: "mobile layout must be designed alongside desktop,
|
||||
* not as an afterthought". This test pins the Tailwind responsive
|
||||
* utilities so a future refactor cannot quietly collapse the grid
|
||||
* back to the v1.4.22 `sm:grid-cols-2` and remove the lg three-col
|
||||
* row.
|
||||
*
|
||||
* The three breakpoints we care about:
|
||||
* • default (<640px): one column, full-width cards
|
||||
* • sm (640-1023px): two columns
|
||||
* • lg (1024px+): three columns, slightly wider gutters
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/targets",
|
||||
}));
|
||||
|
||||
const sampleData = {
|
||||
targets: [
|
||||
{
|
||||
type: "WEIGHT",
|
||||
label: "Weight",
|
||||
current: 78,
|
||||
average30: 78,
|
||||
trend: "stable",
|
||||
unit: "kg",
|
||||
range: { min: 60, max: 80 },
|
||||
classification: { category: "Normal", color: "#50fa7b" },
|
||||
source: "WHO BMI",
|
||||
daysInRange7d: 5,
|
||||
daysLogged7d: 7,
|
||||
lastMetGoalAt: null,
|
||||
streakDays: 0,
|
||||
insufficientData: false,
|
||||
consistency7d: ["in", "in", "in", "in", "in", "in", "in"],
|
||||
},
|
||||
],
|
||||
pageSummary: {
|
||||
targetsMetThisWeek: 1,
|
||||
totalTargets: 1,
|
||||
streakHighlight: null,
|
||||
},
|
||||
bpDiastolic: { current: null, average30: null, range: null },
|
||||
profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null },
|
||||
};
|
||||
|
||||
vi.mock("@tanstack/react-query", () => {
|
||||
const noopClient = {
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
refetchQueries: vi.fn(),
|
||||
};
|
||||
return {
|
||||
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
||||
if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") {
|
||||
return {
|
||||
data: { activeProvider: "openai" },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: sampleData,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
},
|
||||
useQueryClient: () => noopClient,
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
reset: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
role: "USER",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// v1.4.37 W5 — `/targets/page.tsx` now reads `useFeatureFlags` to gate
|
||||
// the Coach drawer + per-card CTAs. The hook resolves QueryClientContext
|
||||
// from `@tanstack/react-query`, and this test mocks the library
|
||||
// without exposing that context. Mock the hook directly so the page
|
||||
// runs with the all-on default (CTAs + drawer mount visible) the
|
||||
// responsive assertions below pin.
|
||||
vi.mock("@/hooks/use-feature-flags", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/hooks/use-feature-flags")
|
||||
>("@/hooks/use-feature-flags");
|
||||
return {
|
||||
...actual,
|
||||
useFeatureFlags: () => actual.DEFAULT_ASSISTANT_FLAGS,
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import TargetsPage from "../targets/page";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<TargetsPage />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("/targets page — mobile-first responsive grid", () => {
|
||||
it("uses one column by default, two at sm, three at lg", () => {
|
||||
const html = render();
|
||||
expect(html).toMatch(/grid-cols-1/);
|
||||
expect(html).toMatch(/sm:grid-cols-2/);
|
||||
expect(html).toMatch(/lg:grid-cols-3/);
|
||||
});
|
||||
|
||||
it("uses gap-4 at mobile/sm, gap-6 at lg", () => {
|
||||
const html = render();
|
||||
expect(html).toMatch(/\bgap-4\b/);
|
||||
expect(html).toMatch(/lg:gap-6/);
|
||||
});
|
||||
|
||||
it("renders the Coach CTA as an icon-only affordance on every target card", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="target-coach-cta"');
|
||||
expect(html).toContain('aria-label="Ask Coach"');
|
||||
expect(html).toContain("size-10");
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.4.19 phase A7 — `/targets` (Zielwerte) had `space-y-8` between
|
||||
* the page header (h1 + intro) and the first card grid. At Pixel-5
|
||||
* the maintainer reported "relativ viel Platz" wasted between the overview
|
||||
* and the first values. Tighten the rhythm to `space-y-6` (24px)
|
||||
* which matches the rest of the admin / settings pages.
|
||||
*/
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/targets",
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => {
|
||||
const noopClient = {
|
||||
invalidateQueries: vi.fn(),
|
||||
setQueryData: vi.fn(),
|
||||
getQueryData: vi.fn(),
|
||||
refetchQueries: vi.fn(),
|
||||
};
|
||||
return {
|
||||
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
||||
if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") {
|
||||
return {
|
||||
data: null,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: {
|
||||
targets: [],
|
||||
bpDiastolic: { current: null, average30: null, range: null },
|
||||
profile: {
|
||||
heightCm: null,
|
||||
age: null,
|
||||
gender: null,
|
||||
glucoseUnit: null,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
};
|
||||
},
|
||||
useQueryClient: () => noopClient,
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
reset: vi.fn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
id: "u1",
|
||||
username: "marc",
|
||||
email: "marc@example.com",
|
||||
role: "USER",
|
||||
},
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// v1.4.37 W5 — `/targets/page.tsx` now reads `useFeatureFlags` to gate
|
||||
// the Coach drawer + per-card CTAs. The hook resolves QueryClientContext
|
||||
// from `@tanstack/react-query`, and this test mocks the library
|
||||
// without exposing that context. Mock the hook directly with the
|
||||
// all-on default so the spacing assertions below run against the
|
||||
// fully-painted page shape.
|
||||
vi.mock("@/hooks/use-feature-flags", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/hooks/use-feature-flags")
|
||||
>("@/hooks/use-feature-flags");
|
||||
return {
|
||||
...actual,
|
||||
useFeatureFlags: () => actual.DEFAULT_ASSISTANT_FLAGS,
|
||||
};
|
||||
});
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import TargetsPage from "../targets/page";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<TargetsPage />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("/targets page — vertical spacing", () => {
|
||||
it("uses space-y-6 (not space-y-8) between header and card grid", () => {
|
||||
const html = render();
|
||||
// Outer wrapper rhythm. `space-y-8` would leave ~32px of
|
||||
// unnecessary breathing room between the intro paragraph and the
|
||||
// first card; `space-y-6` (24px) brings it in line with the
|
||||
// admin / settings pages.
|
||||
expect(html).toMatch(/<div[^>]*\bspace-y-6\b/);
|
||||
expect(html).not.toMatch(/<div[^>]*\bspace-y-8\b/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* v1.9.0 — single-metric period-over-period range read.
|
||||
*
|
||||
* `GET /api/analytics/range?type=<MeasurementType>&range=7d|30d|90d|1y`
|
||||
* returns the current-window aggregate, the previous comparable window, and
|
||||
* the composed delta. Single metric type by construction — the Insights
|
||||
* metric page is single-metric — so the read is one
|
||||
* `readBestGranularityRollups` covering the trailing `2N` days, sliced into
|
||||
* the two halves. No per-type fan-out, no Prisma-pool burst (the opposite of
|
||||
* the deleted 15-way live walk).
|
||||
*
|
||||
* The cached `/api/analytics?slice=summaries` slice is deliberately left
|
||||
* untouched: adding a range dimension to its single shared cache key would
|
||||
* fragment the hottest cache on the dashboard. This dedicated route carries
|
||||
* its own per-(type, range) cache slot on the client (`queryKeys.analyticsRange`).
|
||||
*
|
||||
* iOS-safe: additive new route, no change to the `/api/analytics` envelope.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import {
|
||||
ANALYTICS_RANGES,
|
||||
computeRangeDelta,
|
||||
type AnalyticsRange,
|
||||
} from "@/lib/analytics/range-delta";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const rangeQuerySchema = z.object({
|
||||
type: measurementTypeEnum,
|
||||
range: z.enum(ANALYTICS_RANGES),
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const parsed = rangeQuerySchema.safeParse({
|
||||
type: request.nextUrl.searchParams.get("type"),
|
||||
range: request.nextUrl.searchParams.get("range"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "analytics.range.invalid-params" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const type = parsed.data.type as MeasurementType;
|
||||
const range = parsed.data.range as AnalyticsRange;
|
||||
|
||||
const result = await computeRangeDelta(user.id, type, range);
|
||||
|
||||
annotate({
|
||||
action: { name: "analytics.range.read" },
|
||||
meta: {
|
||||
type,
|
||||
range,
|
||||
granularity: result.granularity,
|
||||
current_count: result.current.count,
|
||||
previous_count: result.previous.count,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* v1.8.5 — user-level injection-site preferences endpoint.
|
||||
*
|
||||
* GET /api/auth/me/injection-site-prefs
|
||||
* → `{ globalExcludedInjectionSites: InjectionSite[] }`
|
||||
*
|
||||
* PATCH /api/auth/me/injection-site-prefs
|
||||
* Body: `{ globalExcludedInjectionSites: InjectionSite[] }`
|
||||
* → the resolved next state (idempotent hard-set).
|
||||
*
|
||||
* The global exclusion is a per-user deny-list. Sites listed here are
|
||||
* never offered for ANY medication's injection-site picker, and the
|
||||
* intake write path rejects (422) a submitted site that lands on this
|
||||
* list — even when a medication's `allowedInjectionSites` lists it as
|
||||
* preferred (deny always wins).
|
||||
*
|
||||
* Mirrors the `unit-preference` per-user-scalar pattern: 60/min rate
|
||||
* limit, Zod safeParse → 422 via `returnAllZodIssues`, audit-log row,
|
||||
* field-by-field write (no mass assignment).
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
injectionSiteEnum,
|
||||
INJECTION_SITE_VALUES,
|
||||
type InjectionSiteValue,
|
||||
} from "@/lib/validations/medication";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PATCH_RATE_LIMIT = 60;
|
||||
const PATCH_WINDOW_MS = 60_000;
|
||||
|
||||
const patchBodySchema = z.object({
|
||||
// Dedup at write time; cap at the full enum size. An empty array
|
||||
// clears the exclusion.
|
||||
globalExcludedInjectionSites: z
|
||||
.array(injectionSiteEnum)
|
||||
.max(INJECTION_SITE_VALUES.length)
|
||||
.meta({
|
||||
description:
|
||||
"User-level injection-site deny-list. Sites here are never offered for any medication and rejected at intake (deny wins). Empty array clears the exclusion.",
|
||||
}),
|
||||
});
|
||||
|
||||
interface InjectionSitePrefsResponse {
|
||||
globalExcludedInjectionSites: InjectionSiteValue[];
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.injection-site-prefs.get" } });
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { globalExcludedInjectionSites: true },
|
||||
});
|
||||
const payload: InjectionSitePrefsResponse = {
|
||||
globalExcludedInjectionSites: (row?.globalExcludedInjectionSites ??
|
||||
[]) as InjectionSiteValue[],
|
||||
};
|
||||
return apiSuccess(payload);
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`injection-site-prefs:patch:${user.id}`,
|
||||
PATCH_RATE_LIMIT,
|
||||
PATCH_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = patchBodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "auth.me.injection-site-prefs.patch.invalid_shape" },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
// Dedup while preserving canonical order from the enum tuple.
|
||||
const submitted = new Set(parsed.data.globalExcludedInjectionSites);
|
||||
const next = INJECTION_SITE_VALUES.filter((s) => submitted.has(s));
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { globalExcludedInjectionSites: next },
|
||||
});
|
||||
|
||||
await auditLog("user.injection-site-prefs.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { count: next.length },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.me.injection-site-prefs.patch" },
|
||||
meta: { excluded_count: next.length },
|
||||
});
|
||||
|
||||
const payload: InjectionSitePrefsResponse = {
|
||||
globalExcludedInjectionSites: next,
|
||||
};
|
||||
return apiSuccess(payload);
|
||||
});
|
||||
@@ -66,6 +66,7 @@ export const GET = apiHandler(async () => {
|
||||
// v1.7.0 — health-record export identity fields. All optional.
|
||||
fullName: user.fullName ?? null,
|
||||
insurerName: user.insurerName ?? null,
|
||||
insurerIkNumber: user.insurerIkNumber ?? null,
|
||||
insuranceNumber,
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
timezone: result.user.timezone,
|
||||
fullName: result.user.fullName,
|
||||
insurerName: result.user.insurerName,
|
||||
insurerIkNumber: result.user.insurerIkNumber,
|
||||
hasInsuranceNumber: result.user.hasInsuranceNumber,
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,14 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray<string> = [
|
||||
"src/app/api/insights/cards/route.ts",
|
||||
"src/app/api/insights/correlations/route.ts",
|
||||
"src/app/api/insights/medication-compliance-status/route.ts",
|
||||
// v1.8.7.1 — generic per-HealthKit-metric assessment. Gated on the
|
||||
// same `insightStatus` sub-flag as the seven specialised status routes.
|
||||
"src/app/api/insights/metric-status/route.ts",
|
||||
"src/app/api/insights/mood-status/route.ts",
|
||||
// v1.9.0 — on-demand full assessment warm. Warms the same assessment
|
||||
// cards the status routes serve, so it gates on `insightStatus`, not
|
||||
// `coach`: a user with assessments enabled but Coach disabled can warm.
|
||||
"src/app/api/insights/pregenerate/route.ts",
|
||||
"src/app/api/insights/pulse-status/route.ts",
|
||||
"src/app/api/insights/weight-status/route.ts",
|
||||
];
|
||||
|
||||
@@ -23,9 +23,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
const locale = resolveBloodPressureStatusLocale(resolved);
|
||||
|
||||
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
|
||||
// a miss. The GET never awaits the provider, so opening /insights/<metric>
|
||||
// can no longer pin the main thread behind a cold LLM round-trip.
|
||||
const result = await generateBloodPressureStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.blood-pressure-status" } });
|
||||
|
||||
@@ -23,9 +23,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
const locale = resolveBmiStatusLocale(resolved);
|
||||
|
||||
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
|
||||
// a miss. The GET never awaits the provider, so opening /insights/<metric>
|
||||
// can no longer pin the main thread behind a cold LLM round-trip.
|
||||
const result = await generateBmiStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.bmi-status" } });
|
||||
|
||||
@@ -60,6 +60,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
import { __resetAuditDedupMemoForTests } from "@/lib/audit-dedup";
|
||||
import { DEFAULT_INSIGHTS_LAYOUT } from "@/lib/insights-layout";
|
||||
import { SUB_PAGE_SLUGS } from "@/lib/insights/sub-page-metric";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -252,6 +253,111 @@ describe("PUT /api/insights/layout — happy path", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/insights/layout — full metric-slug universe", () => {
|
||||
// v1.8.7.1 — the layout tile-id enum derives from `SUB_PAGE_SLUGS`, so
|
||||
// the long-tail HealthKit + body-composition + mobility + audio slugs
|
||||
// the routed tab strip already exposes are now valid layout ids. iOS
|
||||
// can persist a layout covering the full metric set rather than 422ing
|
||||
// on the ~25 slugs the fixed allow-list did not know about.
|
||||
const NEWLY_ACCEPTED_SLUGS = [
|
||||
"blood-glucose",
|
||||
"skin-temperature",
|
||||
"respiratory-rate",
|
||||
"flights-climbed",
|
||||
"walking-speed",
|
||||
"vascular-age",
|
||||
"headphone-audio",
|
||||
"muscle-mass",
|
||||
"daylight",
|
||||
] as const;
|
||||
|
||||
it("accepts a layout that includes every previously-rejected sub-page slug", async () => {
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const tiles = NEWLY_ACCEPTED_SLUGS.map((id, order) => ({
|
||||
id,
|
||||
visible: order % 2 === 0,
|
||||
order,
|
||||
}));
|
||||
const res = await callPut(makeReq({ version: 1, tiles }));
|
||||
// No 422 — these are all canonical sub-page slugs now.
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { tiles: Array<{ id: string; visible: boolean }> };
|
||||
};
|
||||
const returnedIds = body.data.tiles.map((t) => t.id);
|
||||
for (const slug of NEWLY_ACCEPTED_SLUGS) {
|
||||
expect(returnedIds).toContain(slug);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a layout covering the ENTIRE slug universe in one PUT", async () => {
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const tiles = ["overview", ...SUB_PAGE_SLUGS].map((id, order) => ({
|
||||
id,
|
||||
visible: true,
|
||||
order,
|
||||
}));
|
||||
const res = await callPut(makeReq({ version: 1, tiles }));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as {
|
||||
data: { tiles: Array<{ id: string }> };
|
||||
};
|
||||
const returnedIds = new Set(body.data.tiles.map((t) => t.id));
|
||||
// Every slug the web tab strip enumerates round-trips through the PUT.
|
||||
for (const slug of SUB_PAGE_SLUGS) {
|
||||
expect(returnedIds.has(slug)).toBe(true);
|
||||
}
|
||||
expect(returnedIds.has("overview")).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips the new slugs back through GET after a save", async () => {
|
||||
const savedTiles = NEWLY_ACCEPTED_SLUGS.map((id, order) => ({
|
||||
id,
|
||||
visible: true,
|
||||
order: order + 1,
|
||||
}));
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
insightsLayoutJson: {
|
||||
version: 1,
|
||||
tiles: [{ id: "overview", visible: true, order: 0 }, ...savedTiles],
|
||||
},
|
||||
} as never);
|
||||
|
||||
const res = await callGet();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { tiles: Array<{ id: string }> };
|
||||
};
|
||||
const ids = body.data.tiles.map((t) => t.id);
|
||||
for (const slug of NEWLY_ACCEPTED_SLUGS) {
|
||||
expect(ids).toContain(slug);
|
||||
}
|
||||
});
|
||||
|
||||
it("still rejects a genuinely-unknown id even with the wider set", async () => {
|
||||
const res = await callPut(
|
||||
makeReq({
|
||||
version: 1,
|
||||
tiles: [
|
||||
{ id: "blood-glucose", visible: true, order: 0 },
|
||||
{ id: "not-a-metric-at-all", visible: true, order: 1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<{ path: string }> };
|
||||
};
|
||||
expect(
|
||||
body.details.issues.some((i) => i.path.startsWith("tiles")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/insights/layout — 422 multi-issue envelope", () => {
|
||||
it("surfaces TWO simultaneous validation errors under details.issues", async () => {
|
||||
const res = await callPut(makeReq({ version: 2, tiles: [] }));
|
||||
|
||||
@@ -23,9 +23,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
const locale = resolveMedicationComplianceStatusLocale(resolved);
|
||||
|
||||
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
|
||||
// a miss. The GET never awaits the provider, so opening /insights/<metric>
|
||||
// can no longer pin the main thread behind a cold LLM round-trip.
|
||||
const result = await generateMedicationComplianceStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.medication-compliance-status" } });
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
// Stub the heavy generator so the route test stays isolated from the
|
||||
// real status pipeline.
|
||||
vi.mock("@/lib/insights/metric-status", () => ({
|
||||
generateMetricStatus: vi.fn(async () => ({
|
||||
hasProvider: true,
|
||||
text: "ok",
|
||||
cached: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})),
|
||||
resolveMetricStatusLocale: () => "en",
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { generateMetricStatus } from "@/lib/insights/metric-status";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
|
||||
};
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeReq(metric?: string): NextRequest {
|
||||
const url = new URL("http://localhost/api/insights/metric-status");
|
||||
if (metric !== undefined) url.searchParams.set("metric", metric);
|
||||
return new NextRequest(url);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/metric-status", () => {
|
||||
it("returns 200 + envelope for a valid registered metric", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq("RESTING_HEART_RATE"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { text: string; hasProvider: boolean } | null;
|
||||
error: string | null;
|
||||
};
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data?.text).toBe("ok");
|
||||
expect(generateMetricStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metric: "RESTING_HEART_RATE",
|
||||
userId: "user-1",
|
||||
readOnly: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("422s on an unknown metric without calling the generator", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq("NOT_A_METRIC"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(generateMetricStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s on a missing metric param", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(422);
|
||||
expect(generateMetricStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s when a specialised metric is requested through the generic route", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callGet(makeReq("WEIGHT"));
|
||||
expect(res.status).toBe(422);
|
||||
expect(generateMetricStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("401s when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await callGet(makeReq("SLEEP_DURATION"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("403s + errorCode when insightStatus is disabled", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: true,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: false,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: true,
|
||||
} as never);
|
||||
const res = await callGet(makeReq("SLEEP_DURATION"));
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* v1.8.7.1 — generic per-HealthKit-metric assessment route.
|
||||
*
|
||||
* `GET /api/insights/metric-status?metric=<METRIC_ID>` serves the
|
||||
* data-driven assessment for any metric registered in
|
||||
* `metric-status-registry.ts` (RESTING_HEART_RATE, SLEEP_DURATION, …).
|
||||
* The seven specialised metrics keep their own `*-status` routes; this
|
||||
* single generic route covers the ~30 HealthKit pages without one route
|
||||
* per metric.
|
||||
*
|
||||
* Read-only by construction (v1.8.3 freeze fix): a cache miss enqueues an
|
||||
* out-of-band generation and serves the last-good (stale-while-revalidate)
|
||||
* text, never blocking on the provider. An unknown `metric` 422s via the
|
||||
* closed registry enum.
|
||||
*
|
||||
* Unlike the seven legacy `*-status` routes, this one carries OpenAPI
|
||||
* coverage (the route schema lives in `src/lib/openapi/routes.ts`),
|
||||
* establishing the pattern the legacy routes can be backfilled onto later.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import {
|
||||
generateMetricStatus,
|
||||
resolveMetricStatusLocale,
|
||||
} from "@/lib/insights/metric-status";
|
||||
import {
|
||||
METRIC_STATUS_IDS,
|
||||
type MetricStatusMetricId,
|
||||
} from "@/lib/insights/metric-status-registry";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// Closed enum derived from the registry so the route + registry cannot
|
||||
// drift — an id added to the registry is accepted automatically, and an
|
||||
// unknown id 422s rather than silently resolving to no card.
|
||||
const metricQuerySchema = z.object({
|
||||
metric: z.enum(METRIC_STATUS_IDS as [string, ...string[]]),
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
await requireAssistantSurface("insightStatus");
|
||||
|
||||
const parsed = metricQuerySchema.safeParse({
|
||||
metric: request.nextUrl.searchParams.get("metric"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "insights.metric-status.invalid-metric" },
|
||||
meta: { issue_count: parsed.error.issues.length },
|
||||
});
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
const metric = parsed.data.metric as MetricStatusMetricId;
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolveMetricStatusLocale(resolved);
|
||||
|
||||
const result = await generateMetricStatus({
|
||||
metric,
|
||||
userId: user.id,
|
||||
locale,
|
||||
force: false,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.metric-status" },
|
||||
meta: { metric },
|
||||
});
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
@@ -23,9 +23,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
const locale = resolveMoodStatusLocale(resolved);
|
||||
|
||||
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
|
||||
// a miss. The GET never awaits the provider, so opening /insights/<metric>
|
||||
// can no longer pin the main thread behind a cold LLM round-trip.
|
||||
const result = await generateMoodStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.mood-status" } });
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
appSettings: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
// Always allow the anti-spam bucket so the surface gate is what the test
|
||||
// exercises.
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(async () => ({ allowed: true })),
|
||||
}));
|
||||
|
||||
// Stub the worker enqueue so the route test stays isolated from pg-boss.
|
||||
vi.mock("@/lib/jobs/insight-pregenerate-shared", () => ({
|
||||
enqueueForceWarm: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" },
|
||||
};
|
||||
|
||||
const callPost = POST as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeReq(): NextRequest {
|
||||
return new NextRequest(
|
||||
new URL("http://localhost/api/insights/pregenerate"),
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
|
||||
});
|
||||
|
||||
describe("POST /api/insights/pregenerate", () => {
|
||||
it("enqueues a warm when insightStatus is enabled", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await callPost(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { queued: boolean } | null;
|
||||
error: string | null;
|
||||
};
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data?.queued).toBe(true);
|
||||
expect(enqueueForceWarm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: "user-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("gates on insightStatus, not coach — a coach-disabled user can still warm", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: false,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: true,
|
||||
} as never);
|
||||
const res = await callPost(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
expect(enqueueForceWarm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("403s + errorCode when insightStatus is disabled", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: true,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: false,
|
||||
assistantCorrelationsEnabled: true,
|
||||
assistantHealthScoreExplainerEnabled: true,
|
||||
} as never);
|
||||
const res = await callPost(makeReq());
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus");
|
||||
expect(enqueueForceWarm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("401s when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await callPost(makeReq());
|
||||
expect(res.status).toBe(401);
|
||||
expect(enqueueForceWarm).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* v1.8.7.1 — on-demand full assessment warm.
|
||||
*
|
||||
* `POST /api/insights/pregenerate` enqueues a forced full warm of every
|
||||
* AI assessment for the calling user — the comprehensive insight (daily
|
||||
* briefing), the seven specialised `*-status` cards, and every
|
||||
* data-bearing generic `metric:<ID>` assessment — for the user's active
|
||||
* locale. The heavy generation runs on the worker (the
|
||||
* `insight-pregenerate` queue) so this route returns immediately; the
|
||||
* cards then fill via the existing read-only stale-while-revalidate GETs
|
||||
* without the user waiting on a provider round-trip.
|
||||
*
|
||||
* The forced path bypasses the nightly cron's per-user 20 h budget (so a
|
||||
* user can warm on demand the moment they land on the page), so this
|
||||
* route carries its own short anti-spam bucket — one warm per
|
||||
* `WARM_WINDOW_MS` per user. Empty metrics never reach the provider (the
|
||||
* worker filters to data-bearing types) and the comprehensive generator
|
||||
* no-ops without a configured provider, so a spam-free call on a
|
||||
* provider-less account costs one cheap chain-resolve and no LLM call.
|
||||
*
|
||||
* `userId` is always narrowed from the session / Bearer — never a body
|
||||
* field. Both web and the iOS client can call it; they read the same
|
||||
* cached routes afterward.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { enqueueForceWarm } from "@/lib/jobs/insight-pregenerate-shared";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Anti-spam window — one forced warm per user per 3 minutes. */
|
||||
const WARM_WINDOW_MS = 3 * 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// Gate on the same surface as the read-only status routes: this warms
|
||||
// the assessment cards (the `insightStatus` surface), not the Coach.
|
||||
// A user with assessments enabled but Coach disabled can still warm.
|
||||
await requireAssistantSurface("insightStatus");
|
||||
const userId = user.id;
|
||||
|
||||
// Short per-user bucket so the bypassed nightly budget can't be abused
|
||||
// into a provider-cost amplifier by a tight POST loop. A blocked call
|
||||
// is harmless — the caches are already being warmed by the prior call.
|
||||
const rl = await checkRateLimit(`insights-warm:${userId}`, 1, WARM_WINDOW_MS);
|
||||
if (!rl.allowed) {
|
||||
return apiError("A warm is already in progress. Try again shortly.", 429);
|
||||
}
|
||||
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
});
|
||||
const locale = resolved === "en" ? "en" : "de";
|
||||
|
||||
await enqueueForceWarm({ userId, locale });
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.pregenerate.requested" },
|
||||
meta: { locale },
|
||||
});
|
||||
|
||||
// The work runs on the worker; report that it was accepted, not that it
|
||||
// is done. The client polls the read-only status GETs for the text.
|
||||
return apiSuccess({ queued: true, locale });
|
||||
});
|
||||
@@ -23,9 +23,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
const locale = resolvePulseStatusLocale(resolved);
|
||||
|
||||
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
|
||||
// a miss. The GET never awaits the provider, so opening /insights/<metric>
|
||||
// can no longer pin the main thread behind a cold LLM round-trip.
|
||||
const result = await generatePulseStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.pulse-status" } });
|
||||
|
||||
@@ -23,9 +23,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
const locale = resolveWeightStatusLocale(resolved);
|
||||
|
||||
// v1.8.3 — read-only: serve the cache, enqueue generation out of band on
|
||||
// a miss. The GET never awaits the provider, so opening /insights/<metric>
|
||||
// can no longer pin the main thread behind a cold LLM round-trip.
|
||||
const result = await generateWeightStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.weight-status" } });
|
||||
|
||||
@@ -72,6 +72,17 @@ const MAX_BATCH_ENTRIES = 500;
|
||||
const BATCH_RATE_LIMIT_MAX = 60;
|
||||
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
// v1.8.6 W6 — the set of `source` values this client-facing ingest
|
||||
// route accepts. Deliberately narrower than the full `MeasurementSource`
|
||||
// enum: `WITHINGS` and `IMPORT` are server-owned (the Withings webhook
|
||||
// and the CSV importer mint those rows respectively), so letting an iOS
|
||||
// client forge rows attributed to them would let an authenticated client
|
||||
// pollute the per-source canonical picker with rows the server never saw
|
||||
// from those integrations. The standalone iOS client only needs to tag
|
||||
// its own adopt-on-pair backfill as `MANUAL` vs the default
|
||||
// `APPLE_HEALTH` passthrough, so we accept exactly that pair.
|
||||
const batchSourceEnum = z.enum(["APPLE_HEALTH", "MANUAL"]);
|
||||
|
||||
const batchEntrySchema = z.object({
|
||||
hkIdentifier: z.string().min(1).max(120),
|
||||
value: z.number().finite(),
|
||||
@@ -81,6 +92,14 @@ const batchEntrySchema = z.object({
|
||||
sleepStage: z.number().int().min(0).max(20).optional(),
|
||||
externalId: z.string().min(1).max(120),
|
||||
externalSourceVersion: z.string().min(1).max(120).optional(),
|
||||
// v1.8.6 W6 — optional per-entry source tag. Defaults to
|
||||
// `APPLE_HEALTH` so every pre-W6 caller (web + current iOS) stays
|
||||
// byte-for-byte unchanged. The iOS standalone adopt-on-pair backfill
|
||||
// sends `MANUAL` for rows the user entered by hand on-device so they
|
||||
// are not mis-attributed to HealthKit. `source` is part of the
|
||||
// `(userId, type, source, externalId)` dedup key, so a `MANUAL` row
|
||||
// and an `APPLE_HEALTH` row sharing an externalId are distinct rows.
|
||||
source: batchSourceEnum.optional(),
|
||||
// v1.4.25 W8c — optional device-type tag. The iOS client maps the
|
||||
// `HKDevice.model` of each sample to one of the canonical device
|
||||
// classes (watch | band | ring | phone | scale | other | unknown).
|
||||
@@ -228,7 +247,9 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
type: mapped.type,
|
||||
value: mapped.value,
|
||||
unit: mapped.unit,
|
||||
source: "APPLE_HEALTH",
|
||||
// v1.8.6 W6 — honour the per-entry source tag, defaulting to
|
||||
// `APPLE_HEALTH` when absent so legacy callers are unchanged.
|
||||
source: entry.source ?? "APPLE_HEALTH",
|
||||
measuredAt: mapped.takenAt,
|
||||
externalId: entry.externalId,
|
||||
externalSourceVersion: entry.externalSourceVersion ?? null,
|
||||
@@ -251,31 +272,37 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// (createMany with skipDuplicates does the dedup but doesn't tell
|
||||
// us *which* rows it skipped). We look up existing rows under the
|
||||
// composite unique key first, then createMany the survivors.
|
||||
// v1.8.6 W6 — `source` is now part of the dedup key, so the lookup
|
||||
// matches on (type, source, externalId) per incoming row rather than
|
||||
// a single hardcoded `APPLE_HEALTH`. A `MANUAL` row and an
|
||||
// `APPLE_HEALTH` row sharing an externalId are distinct, so each must
|
||||
// carry its source through the existence probe and the composite key.
|
||||
const incomingKeys = prepared.map((p) => ({
|
||||
type: p.row.type as Prisma.MeasurementCreateManyInput["type"],
|
||||
source: p.row.source as Prisma.MeasurementCreateManyInput["source"],
|
||||
externalId: p.row.externalId as string,
|
||||
}));
|
||||
|
||||
const existing = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
source: "APPLE_HEALTH",
|
||||
OR: incomingKeys.map((k) => ({
|
||||
type: k.type,
|
||||
source: k.source,
|
||||
externalId: k.externalId,
|
||||
})),
|
||||
},
|
||||
select: { type: true, externalId: true },
|
||||
select: { type: true, source: true, externalId: true },
|
||||
});
|
||||
|
||||
const existingSet = new Set(
|
||||
existing.map((row) => `${row.type}::${row.externalId}`),
|
||||
existing.map((row) => `${row.type}::${row.source}::${row.externalId}`),
|
||||
);
|
||||
|
||||
const toInsert: Prisma.MeasurementCreateManyInput[] = [];
|
||||
const toOverwrite: Prepared[] = [];
|
||||
for (const p of prepared) {
|
||||
const key = `${p.row.type}::${p.row.externalId}`;
|
||||
const key = `${p.row.type}::${p.row.source}::${p.row.externalId}`;
|
||||
if (existingSet.has(key)) {
|
||||
// v1.5.0 issue #213 — per-day cumulative `stats:*` rows are
|
||||
// intentionally overwritten on a re-post so today's tile reflects
|
||||
@@ -300,7 +327,10 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
await tx.measurement.updateMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
source: "APPLE_HEALTH",
|
||||
// v1.8.6 W6 — scope the overwrite to the row's own source
|
||||
// so a `stats:*` re-post only touches the matching
|
||||
// (type, source, externalId) row.
|
||||
source: p.row.source as Prisma.MeasurementCreateManyInput["source"],
|
||||
type: p.row.type as MeasurementType,
|
||||
externalId: p.row.externalId as string,
|
||||
},
|
||||
|
||||
@@ -328,6 +328,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// legacy fallback below covers the boot-backfill convergence
|
||||
// window when an existing bucket pre-dates v1.4.39.
|
||||
sumValue: true,
|
||||
minValue: true,
|
||||
maxValue: true,
|
||||
},
|
||||
});
|
||||
// v1.4.39.2 — coverage-mismatch fallback. v1.4.39.1 wired the
|
||||
@@ -417,6 +419,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
mean: true,
|
||||
count: true,
|
||||
sumValue: true,
|
||||
minValue: true,
|
||||
maxValue: true,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -454,6 +458,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
: r.mean,
|
||||
measuredAt: r.bucketStart.toISOString(),
|
||||
count: r.count,
|
||||
// v1.8.5 — per-day min / max so the chart can shade an
|
||||
// Apple-Health-style range band around the daily mean line.
|
||||
// Omitted for cumulative metrics (steps, distance, …) where the
|
||||
// daily value is a SUM, not a central tendency, so an intra-day
|
||||
// spread band carries no meaning.
|
||||
minValue: useSum ? undefined : r.minValue,
|
||||
maxValue: useSum ? undefined : r.maxValue,
|
||||
}));
|
||||
annotate({
|
||||
action: { name: "measurement.list" },
|
||||
|
||||
@@ -115,6 +115,13 @@ async function callRoute(): Promise<Record<string, unknown>> {
|
||||
return body.data.dailyCompliance as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function callRouteData(): Promise<Record<string, unknown>> {
|
||||
const res = await GET(new Request("http://localhost"), ROUTE_PARAMS);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
return body.data as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("GET /api/medications/[id]/compliance — per-slot timing", () => {
|
||||
it("classifies both doses of a single-row twice-daily schedule as on_time (green cell)", async () => {
|
||||
const { start: dayStart } = dayBoundsForOffset(DAY_OFFSET);
|
||||
@@ -239,3 +246,97 @@ describe("GET /api/medications/[id]/compliance — per-slot timing", () => {
|
||||
expect(entry!.onTime).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// v1.8.6 — the `complianceDisplay` block is always two percentage rows; the
|
||||
// server scales the two windows to the dosing cadence. A daily med keeps
|
||||
// 7 / 30 days; a sparse rolling med steps both windows up to the widest rung.
|
||||
// The existing `compliance7` / `compliance30` fields stay untouched.
|
||||
describe("GET /api/medications/[id]/compliance — complianceDisplay", () => {
|
||||
it("daily med → 7 / 30-day windows", async () => {
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
medication([
|
||||
{
|
||||
id: "sched-1",
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
timesOfDay: ["08:00"],
|
||||
daysOfWeek: null,
|
||||
rrule: "FREQ=DAILY",
|
||||
rollingIntervalDays: null,
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
},
|
||||
]) as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
|
||||
const data = await callRouteData();
|
||||
const display = data.complianceDisplay as {
|
||||
shortDays: number;
|
||||
longDays: number;
|
||||
expectedLong: number;
|
||||
minStableDoses: number;
|
||||
short: { rate: number; streak: number };
|
||||
long: { rate: number };
|
||||
};
|
||||
expect(display.shortDays).toBe(7);
|
||||
expect(display.longDays).toBe(30);
|
||||
expect(display.expectedLong).toBeGreaterThanOrEqual(display.minStableDoses);
|
||||
expect(display.short).toBeDefined();
|
||||
expect(display.long).toBeDefined();
|
||||
// No timeline field — the display is two rows only.
|
||||
expect(display).not.toHaveProperty("mode");
|
||||
// The legacy fields stay on the wire.
|
||||
expect(data.compliance7).toBeDefined();
|
||||
expect(data.compliance30).toBeDefined();
|
||||
});
|
||||
|
||||
it("35-day-interval med → steps the windows up past 7 / 30 days", async () => {
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
{
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
createdAt: new Date(Date.now() - 400 * 24 * 60 * 60 * 1000),
|
||||
startsOn: new Date(Date.now() - 380 * 24 * 60 * 60 * 1000),
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
schedules: [
|
||||
{
|
||||
id: "sched-1",
|
||||
windowStart: "10:00",
|
||||
windowEnd: "11:00",
|
||||
timesOfDay: ["10:00"],
|
||||
daysOfWeek: null,
|
||||
rrule: null,
|
||||
rollingIntervalDays: 35,
|
||||
reminderGraceMinutes: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
cyclicOnWeeks: null,
|
||||
cyclicOffWeeks: null,
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([
|
||||
{
|
||||
takenAt: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000),
|
||||
skipped: false,
|
||||
scheduledFor: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const data = await callRouteData();
|
||||
const display = data.complianceDisplay as {
|
||||
shortDays: number;
|
||||
longDays: number;
|
||||
};
|
||||
// A 35-day cadence can't clear four expected doses in a 30-day window, so
|
||||
// the ladder steps the short window beyond 7 days and the long beyond 30.
|
||||
expect(display.shortDays).toBeGreaterThan(7);
|
||||
expect(display.longDays).toBeGreaterThan(30);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import {
|
||||
buildComplianceDisplay,
|
||||
buildComplianceMedicationContext,
|
||||
calculateCompliance,
|
||||
classifyIntakeTiming,
|
||||
@@ -141,6 +142,18 @@ export const GET = apiHandler(
|
||||
{ medicationContext },
|
||||
);
|
||||
|
||||
// v1.8.6 — the two-row compliance display. The card always shows two
|
||||
// percentage rows; the server scales the two windows to the dosing
|
||||
// cadence (dense meds keep 7 / 30 days, sparse meds step both windows
|
||||
// up) and computes each row's rate over the chosen span. `compliance7`
|
||||
// / `compliance30` above are untouched — iOS + the Health Score read
|
||||
// them verbatim.
|
||||
const complianceDisplay = buildComplianceDisplay(
|
||||
mapped,
|
||||
medication.schedules,
|
||||
medicationContext,
|
||||
);
|
||||
|
||||
// Build daily compliance map for heatmap/line chart (90 days)
|
||||
const now = new Date();
|
||||
const dailyCompliance: Record<string, DailyComplianceEntry> = {};
|
||||
@@ -273,9 +286,19 @@ export const GET = apiHandler(
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { compliance7: compliance7.rate, compliance30: compliance30.rate },
|
||||
meta: {
|
||||
compliance7: compliance7.rate,
|
||||
compliance30: compliance30.rate,
|
||||
complianceShortDays: complianceDisplay.shortDays,
|
||||
complianceLongDays: complianceDisplay.longDays,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ compliance7, compliance30, dailyCompliance });
|
||||
return apiSuccess({
|
||||
compliance7,
|
||||
compliance30,
|
||||
dailyCompliance,
|
||||
complianceDisplay,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
@@ -13,6 +14,8 @@ import {
|
||||
intakeSchema,
|
||||
listIntakeEventsSchema,
|
||||
} from "@/lib/validations/medication";
|
||||
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { consumeOneDose } from "@/lib/medications/inventory/service";
|
||||
import { reconcileOneShotState } from "@/lib/medications/lifecycle";
|
||||
@@ -76,7 +79,51 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { scheduledFor, takenAt, skipped, idempotencyKey } = parsed.data;
|
||||
const { scheduledFor, takenAt, skipped, idempotencyKey, injectionSite } =
|
||||
parsed.data;
|
||||
|
||||
// v1.8.5 — resolve + server-validate the optional injection site. Load
|
||||
// the medication's delivery form + tracking opt-in + per-medication
|
||||
// allowed sites, plus the user's global exclusion deny-list. A site
|
||||
// outside the effective allowed set is a hard 422; a site on a
|
||||
// non-injection / tracking-off med (or a skip) is silently dropped.
|
||||
let resolvedInjectionSite: InjectionSiteKey | null = null;
|
||||
if (injectionSite !== undefined) {
|
||||
const [med, userRow] = await Promise.all([
|
||||
prisma.medication.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
deliveryForm: true,
|
||||
trackInjectionSites: true,
|
||||
allowedInjectionSites: true,
|
||||
},
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { globalExcludedInjectionSites: true },
|
||||
}),
|
||||
]);
|
||||
const resolution = resolveInjectionSiteForWrite({
|
||||
submitted: injectionSite,
|
||||
taken: !skipped,
|
||||
deliveryForm: med?.deliveryForm ?? "ORAL",
|
||||
trackInjectionSites: med?.trackInjectionSites ?? false,
|
||||
allowedInjectionSites: (med?.allowedInjectionSites ??
|
||||
[]) as InjectionSiteKey[],
|
||||
globalExcludedInjectionSites: (userRow?.globalExcludedInjectionSites ??
|
||||
[]) as InjectionSiteKey[],
|
||||
});
|
||||
if (resolution.kind === "disallowed") {
|
||||
annotate({
|
||||
action: { name: "medication.intake.injection_site.disallowed" },
|
||||
meta: { medication_id: id, site: resolution.site },
|
||||
});
|
||||
return apiError("Injection site is not allowed for this medication", 422, {
|
||||
errorCode: "medications.intake.injection_site.disallowed",
|
||||
});
|
||||
}
|
||||
resolvedInjectionSite = resolution.site;
|
||||
}
|
||||
|
||||
const resolvedTakenAt = skipped ? null : (takenAt ?? new Date());
|
||||
const incomingScheduledFor = scheduledFor ?? takenAt ?? new Date();
|
||||
@@ -163,6 +210,9 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
isExplicitSkip,
|
||||
idempotencyKey: idempotencyKey ?? null,
|
||||
createSource: "WEB",
|
||||
// v1.8.5 — resolved + validated site (null unless a tracking-on
|
||||
// injection taken write supplied an allowed site).
|
||||
injectionSite: resolvedInjectionSite,
|
||||
});
|
||||
event = applied.row;
|
||||
consumedTransition = applied.consumedTransition;
|
||||
@@ -186,6 +236,10 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
skipped,
|
||||
source: "WEB",
|
||||
idempotencyKey: idempotencyKey ?? null,
|
||||
// v1.8.5 — site only on a resolved taken-injection write.
|
||||
...(resolvedInjectionSite !== null && {
|
||||
injectionSite: resolvedInjectionSite,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
// Reset snooze when medication is taken
|
||||
|
||||
@@ -130,10 +130,14 @@ export const PUT = apiHandler(
|
||||
treatmentClass,
|
||||
dosesPerUnit,
|
||||
deliveryForm,
|
||||
trackInjectionSites,
|
||||
allowedInjectionSites,
|
||||
active,
|
||||
notificationsEnabled,
|
||||
liveActivityEnabled,
|
||||
criticalAlarmEnabled,
|
||||
atcCode,
|
||||
rxNormCode,
|
||||
schedules,
|
||||
startsOn,
|
||||
endsOn,
|
||||
@@ -222,11 +226,20 @@ export const PUT = apiHandler(
|
||||
...(treatmentClass !== undefined && { treatmentClass }),
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
...(deliveryForm !== undefined && { deliveryForm }),
|
||||
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
|
||||
// sites. Field-by-field; `false` / `[]` are valid explicit values
|
||||
// (deactivate tracking / clear the per-med restriction).
|
||||
...(trackInjectionSites !== undefined && { trackInjectionSites }),
|
||||
...(allowedInjectionSites !== undefined && { allowedInjectionSites }),
|
||||
...(active !== undefined && { active }),
|
||||
...(notificationsEnabled !== undefined && { notificationsEnabled }),
|
||||
// v1.7.0 — iOS reminder flags, field-by-field.
|
||||
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
|
||||
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
|
||||
// v1.9.0 — optional drug-classification codes (ATC / RxNorm).
|
||||
// Field-by-field; `null` is a valid explicit value (clears the code).
|
||||
...(atcCode !== undefined && { atcCode }),
|
||||
...(rxNormCode !== undefined && { rxNormCode }),
|
||||
// v1.5 scheduling primitives — pass-through when supplied.
|
||||
// `startsOn` / `endsOn` are `Date | null | undefined` (the
|
||||
// schema lets the user clear them explicitly with null).
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
/**
|
||||
* v1.4.25 W19f — titration-ladder route tests.
|
||||
*
|
||||
* Mirrors the W19e cadence-route test fixture: external dependencies
|
||||
* are mocked at module boundaries (prisma, session, logging
|
||||
* transport) so each test pins one contract case. The pure ladder
|
||||
* helpers are covered separately in
|
||||
* `src/lib/medications/titration/__tests__/ladder.test.ts`.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
function makeMed(
|
||||
overrides: Partial<{
|
||||
name: string;
|
||||
dose: string;
|
||||
treatmentClass: "GLP1" | "GENERIC";
|
||||
userId: string;
|
||||
doseChanges: Array<{ effectiveFrom: Date; doseValue: number }>;
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
id: "med-1",
|
||||
name: overrides.name ?? "Mounjaro",
|
||||
dose: overrides.dose ?? "5 mg",
|
||||
treatmentClass: overrides.treatmentClass ?? ("GLP1" as const),
|
||||
userId: overrides.userId ?? "user-1",
|
||||
createdAt: new Date(),
|
||||
doseChanges: overrides.doseChanges ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/medications/[id]/titration", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/titration"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 404 when the medication is not owned by the caller", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
makeMed({ userId: "OTHER" }) as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/titration"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when the medication is not GLP-1", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
makeMed({ treatmentClass: "GENERIC" }) as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/titration"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when the medication brand is not in the GLP-1 catalog", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
makeMed({ name: "MysteryDrug" }) as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/titration"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns the ladder + current step for a Mounjaro user on 5 mg", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
makeMed({
|
||||
name: "Mounjaro",
|
||||
dose: "5 mg",
|
||||
doseChanges: [
|
||||
{
|
||||
effectiveFrom: new Date(Date.now() - 6 * 7 * 24 * 60 * 60 * 1000),
|
||||
doseValue: 2.5,
|
||||
},
|
||||
{
|
||||
effectiveFrom: new Date(Date.now() - 2 * 7 * 24 * 60 * 60 * 1000),
|
||||
doseValue: 5,
|
||||
},
|
||||
],
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/titration"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.data.drugId).toBe("tirzepatide");
|
||||
expect(json.data.ladder.length).toBe(6);
|
||||
expect(json.data.currentStep.doseMg).toBe(5);
|
||||
expect(json.data.currentStepIndex).toBe(1);
|
||||
expect(json.data.weeksOnCurrentStep).toBe(2);
|
||||
expect(json.data.nextStep.doseMg).toBe(7.5);
|
||||
expect(json.data.escalationDue).toBe(false);
|
||||
});
|
||||
|
||||
it("returns a null current step when the user dose is outside any ladder bucket", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
makeMed({
|
||||
name: "Mounjaro",
|
||||
dose: "6 mg", // outside the ±10 % window of 5 and 7.5
|
||||
doseChanges: [],
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/titration"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.data.currentStep).toBeNull();
|
||||
expect(json.data.currentStepIndex).toBeNull();
|
||||
expect(json.data.nextStep).toBeNull();
|
||||
expect(json.data.escalationDue).toBe(false);
|
||||
});
|
||||
|
||||
it("returns a null next-step at the ladder ceiling", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
makeMed({
|
||||
name: "Mounjaro",
|
||||
dose: "15 mg",
|
||||
doseChanges: [
|
||||
{
|
||||
effectiveFrom: new Date(Date.now() - 5 * 7 * 24 * 60 * 60 * 1000),
|
||||
doseValue: 15,
|
||||
},
|
||||
],
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/titration"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.data.currentStep.doseMg).toBe(15);
|
||||
expect(json.data.nextStep).toBeNull();
|
||||
expect(json.data.escalationDue).toBe(false);
|
||||
});
|
||||
|
||||
it("flags escalationDue=true past the EMA dwell-time on a non-ceiling step", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
makeMed({
|
||||
name: "Mounjaro",
|
||||
dose: "2.5 mg",
|
||||
doseChanges: [
|
||||
{
|
||||
// Six weeks ago, past tirzepatide's 4-week interval.
|
||||
effectiveFrom: new Date(Date.now() - 6 * 7 * 24 * 60 * 60 * 1000),
|
||||
doseValue: 2.5,
|
||||
},
|
||||
],
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/titration"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.data.currentStep.doseMg).toBe(2.5);
|
||||
expect(json.data.weeksOnCurrentStep).toBe(6);
|
||||
expect(json.data.escalationDue).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* v1.4.25 W19f — GLP-1 titration-ladder read endpoint.
|
||||
*
|
||||
* GET /api/medications/[id]/titration
|
||||
* - returns the EMA-reference ladder for the medication's drug,
|
||||
* the user's current step (matched within ±10 % tolerance to
|
||||
* their latest dose), how long they've been on that step, the
|
||||
* immediate-next step (if any), and the observational
|
||||
* escalation-due flag.
|
||||
*
|
||||
* Pure computation — no writes. Delegates math to the
|
||||
* `src/lib/medications/titration/ladder` module so the route, the
|
||||
* Coach snapshot (future wave), and the detail-page section all read
|
||||
* the same numbers.
|
||||
*
|
||||
* Auth: cookie-session via requireAuth(); medication is verified to
|
||||
* belong to the caller before any read. Defence-in-depth: returns
|
||||
* 404 for non-GLP-1 medications (the UI already gates on
|
||||
* `treatmentClass === "GLP1"`).
|
||||
*/
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
findDrugByBrand,
|
||||
findDrugIdByBrand,
|
||||
} from "@/lib/medications/glp1-knowledge";
|
||||
import { parseDoseMgOrNull } from "@/lib/medications/dose-string";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import {
|
||||
escalationDue,
|
||||
findCurrentStep,
|
||||
ladderFromRecord,
|
||||
nextStep,
|
||||
weeksOnCurrentStep,
|
||||
} from "@/lib/medications/titration/ladder";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper so
|
||||
// the 404 leak shape stays consistent across every medication
|
||||
// sub-route.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const med = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
doseChanges: { orderBy: { effectiveFrom: "asc" } },
|
||||
},
|
||||
});
|
||||
if (!med) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
if (med.treatmentClass !== "GLP1") {
|
||||
// Defence-in-depth: the UI already gates the section on this
|
||||
// class, but the route refuses to surface the ladder for a
|
||||
// non-GLP-1 row so we never leak a misleading "you are here"
|
||||
// on a generic medication.
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
// Resolve the GLP-1 drug record from the medication name. Same
|
||||
// path the DrugLevelChart uses; null when the medication name
|
||||
// doesn't map to a catalog brand (e.g. user typed a generic
|
||||
// INN instead of a brand). v1.4.25 W21 Fix-N — both lookups go
|
||||
// through the shared helpers in glp1-knowledge.
|
||||
const record = findDrugByBrand(med.name);
|
||||
const drugId = findDrugIdByBrand(med.name);
|
||||
if (!record || !drugId) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
const ladder = ladderFromRecord(record);
|
||||
|
||||
// Resolve the user's most-recent dose. Prefer the
|
||||
// MedicationDoseChange stream (the history of record); fall back
|
||||
// to the legacy free-text `medication.dose` if the stream is empty.
|
||||
const latestChange =
|
||||
med.doseChanges.length > 0
|
||||
? med.doseChanges[med.doseChanges.length - 1]
|
||||
: null;
|
||||
const latestDoseMg = latestChange
|
||||
? latestChange.doseValue
|
||||
: parseDoseMgOrNull(med.dose);
|
||||
|
||||
const currentStep = findCurrentStep(drugId, latestDoseMg);
|
||||
const next = nextStep(drugId, currentStep);
|
||||
const asOf = new Date();
|
||||
const weeks = weeksOnCurrentStep(
|
||||
drugId,
|
||||
currentStep,
|
||||
med.doseChanges.map((dc) => ({
|
||||
effectiveFrom: dc.effectiveFrom,
|
||||
doseValue: dc.doseValue,
|
||||
})),
|
||||
asOf,
|
||||
);
|
||||
const due = escalationDue(drugId, currentStep, weeks);
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.titration",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
drug_id: drugId,
|
||||
step_index: currentStep?.stepIndex ?? null,
|
||||
weeks_on_step: weeks,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
drugId,
|
||||
drugInn: record.inn,
|
||||
ladder,
|
||||
currentStep,
|
||||
currentStepIndex: currentStep?.stepIndex ?? null,
|
||||
weeksOnCurrentStep: weeks,
|
||||
nextStep: next,
|
||||
escalationDue: due,
|
||||
sourceEMA: record.sourceEMA,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -51,6 +51,12 @@ import {
|
||||
applyCanonicalSlotWrite,
|
||||
resolveSlotInstantForWrite,
|
||||
} from "@/lib/medications/scheduling/slot-upsert";
|
||||
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
|
||||
import {
|
||||
injectionSiteEnum,
|
||||
type InjectionSiteValue,
|
||||
} from "@/lib/validations/medication";
|
||||
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
|
||||
|
||||
const MAX_ENTRIES_PER_BATCH = 500;
|
||||
const BATCH_RATE_LIMIT_MAX = 60;
|
||||
@@ -68,6 +74,11 @@ const bulkEntrySchema = z.object({
|
||||
.optional(),
|
||||
skipped: z.boolean().optional().default(false),
|
||||
idempotencyKey: z.string().min(1).max(128).optional(),
|
||||
// v1.8.5 — optional per-entry injection site. Validated against the
|
||||
// medication's effective allowed set; a disallowed site marks the
|
||||
// entry skipped (`injection_site_not_allowed`) without failing the
|
||||
// whole batch, matching the bulk endpoint's per-entry contract.
|
||||
injectionSite: injectionSiteEnum.optional(),
|
||||
});
|
||||
|
||||
const bulkPayloadSchema = z.object({
|
||||
@@ -157,9 +168,35 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
const medicationIds = Array.from(new Set(entries.map((e) => e.medicationId)));
|
||||
const ownedMedications = await prisma.medication.findMany({
|
||||
where: { id: { in: medicationIds }, userId: user.id },
|
||||
select: { id: true },
|
||||
// v1.8.5 — pull the injection-tracking fields so a per-entry
|
||||
// `injectionSite` can be resolved + validated without an extra
|
||||
// per-entry read.
|
||||
select: {
|
||||
id: true,
|
||||
deliveryForm: true,
|
||||
trackInjectionSites: true,
|
||||
allowedInjectionSites: true,
|
||||
},
|
||||
});
|
||||
const ownedSet = new Set(ownedMedications.map((m) => m.id));
|
||||
const medById = new Map(ownedMedications.map((m) => [m.id, m]));
|
||||
|
||||
// v1.8.5 — the user's global exclusion deny-list, loaded once for the
|
||||
// whole batch (it is user-scoped, not per-medication). Only read when
|
||||
// at least one entry actually carries an `injectionSite`, so the hot
|
||||
// sync path (no site) pays for zero extra round-trips.
|
||||
const batchHasInjectionSite = entries.some(
|
||||
(e) => e.injectionSite !== undefined,
|
||||
);
|
||||
let globalExcluded: InjectionSiteValue[] = [];
|
||||
if (batchHasInjectionSite) {
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { globalExcludedInjectionSites: true },
|
||||
});
|
||||
globalExcluded = (userRow?.globalExcludedInjectionSites ??
|
||||
[]) as InjectionSiteValue[];
|
||||
}
|
||||
|
||||
const results: EntryResult[] = [];
|
||||
let inserted = 0;
|
||||
@@ -209,6 +246,31 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
const isExplicitTaken = !entry.skipped && entry.takenAt !== undefined;
|
||||
const isExplicitSkip = entry.skipped === true;
|
||||
|
||||
// v1.8.5 — resolve + validate the optional per-entry injection
|
||||
// site. A disallowed site marks the entry skipped without failing
|
||||
// the batch (per-entry contract); a non-injection / tracking-off /
|
||||
// non-taken entry silently drops it.
|
||||
let resolvedInjectionSite: InjectionSiteKey | null = null;
|
||||
if (entry.injectionSite !== undefined) {
|
||||
const med = medById.get(entry.medicationId);
|
||||
const resolution = resolveInjectionSiteForWrite({
|
||||
submitted: entry.injectionSite,
|
||||
taken: isExplicitTaken,
|
||||
deliveryForm: med?.deliveryForm ?? "ORAL",
|
||||
trackInjectionSites: med?.trackInjectionSites ?? false,
|
||||
allowedInjectionSites: (med?.allowedInjectionSites ??
|
||||
[]) as InjectionSiteKey[],
|
||||
globalExcludedInjectionSites: globalExcluded as InjectionSiteKey[],
|
||||
});
|
||||
if (resolution.kind === "disallowed") {
|
||||
const reason = "injection_site_not_allowed";
|
||||
skipped.push({ index: i, reason });
|
||||
results.push({ index: i, status: "skipped", reason });
|
||||
continue;
|
||||
}
|
||||
resolvedInjectionSite = resolution.site;
|
||||
}
|
||||
|
||||
if (canonicalSlot) {
|
||||
// Scheduled dose — converge onto the one canonical slot row through
|
||||
// the shared upsert: H1 deterministic selection, C2 no-downgrade
|
||||
@@ -226,6 +288,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
isExplicitSkip,
|
||||
idempotencyKey: entry.idempotencyKey ?? null,
|
||||
createSource: "API",
|
||||
injectionSite: resolvedInjectionSite,
|
||||
});
|
||||
if (applied.noDowngradeNoOp) {
|
||||
// C2 — pending echo onto an already-actioned slot. Report it as a
|
||||
@@ -253,6 +316,10 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
skipped: entry.skipped,
|
||||
source: "API",
|
||||
idempotencyKey: entry.idempotencyKey ?? null,
|
||||
// v1.8.5 — site only on a resolved taken-injection entry.
|
||||
...(resolvedInjectionSite !== null && {
|
||||
injectionSite: resolvedInjectionSite,
|
||||
}),
|
||||
},
|
||||
});
|
||||
inserted += 1;
|
||||
|
||||
@@ -36,6 +36,11 @@ import {
|
||||
type ComplianceBucket as RollupComplianceBucket,
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes";
|
||||
import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write";
|
||||
import {
|
||||
injectionSiteEnum,
|
||||
type InjectionSiteValue,
|
||||
} from "@/lib/validations/medication";
|
||||
|
||||
const querySchema = z.object({
|
||||
scope: z.enum(["today", "compliance"]),
|
||||
@@ -53,6 +58,10 @@ const updateSchema = z.object({
|
||||
.datetime({ offset: true })
|
||||
.transform((s) => new Date(s))
|
||||
.optional(),
|
||||
// v1.8.5 — optional injection site captured alongside a "taken"
|
||||
// status toggle. Validated server-side against the medication's
|
||||
// effective allowed set; ignored for skipped / snoozed.
|
||||
injectionSite: injectionSiteEnum.optional(),
|
||||
});
|
||||
|
||||
function startOfDayInTz(date: Date, tz: string): Date {
|
||||
@@ -314,17 +323,58 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { intakeId, status, takenAt, snoozedUntil } = parsed.data;
|
||||
const { intakeId, status, takenAt, snoozedUntil, injectionSite } =
|
||||
parsed.data;
|
||||
|
||||
// v1.7.0 sync — a tombstoned intake 404s on a status toggle; the
|
||||
// `deletedAt: null` filter refuses to mutate a soft-deleted row.
|
||||
const existing = await prisma.medicationIntakeEvent.findFirst({
|
||||
where: { id: intakeId, deletedAt: null },
|
||||
include: {
|
||||
medication: {
|
||||
select: {
|
||||
deliveryForm: true,
|
||||
trackInjectionSites: true,
|
||||
allowedInjectionSites: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Intake event not found", 404);
|
||||
}
|
||||
|
||||
// v1.8.5 — resolve + server-validate the optional injection site for a
|
||||
// "taken" toggle. A site outside the medication's effective allowed
|
||||
// set is a hard 422; non-injection / tracking-off / non-taken drops it.
|
||||
let resolvedInjectionSite: InjectionSiteValue | null = null;
|
||||
if (injectionSite !== undefined) {
|
||||
const userRow = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { globalExcludedInjectionSites: true },
|
||||
});
|
||||
const resolution = resolveInjectionSiteForWrite({
|
||||
submitted: injectionSite,
|
||||
taken: status === "taken",
|
||||
deliveryForm: existing.medication.deliveryForm,
|
||||
trackInjectionSites: existing.medication.trackInjectionSites,
|
||||
allowedInjectionSites:
|
||||
existing.medication.allowedInjectionSites as InjectionSiteValue[],
|
||||
globalExcludedInjectionSites: (userRow?.globalExcludedInjectionSites ??
|
||||
[]) as InjectionSiteValue[],
|
||||
});
|
||||
if (resolution.kind === "disallowed") {
|
||||
annotate({
|
||||
action: { name: "medication.intake.injection_site.disallowed" },
|
||||
meta: { medication_id: existing.medicationId, site: resolution.site },
|
||||
});
|
||||
return apiError("Injection site is not allowed for this medication", 422, {
|
||||
errorCode: "medications.intake.injection_site.disallowed",
|
||||
});
|
||||
}
|
||||
resolvedInjectionSite = resolution.site;
|
||||
}
|
||||
|
||||
const userTzForHook = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
let updated;
|
||||
@@ -338,6 +388,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
takenAt: takenAt ?? new Date(),
|
||||
skipped: false,
|
||||
syncVersion: { increment: 1 },
|
||||
// v1.8.5 — persist the resolved site on the taken branch.
|
||||
...(resolvedInjectionSite !== null && {
|
||||
injectionSite: resolvedInjectionSite,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
prisma.medication.update({
|
||||
|
||||
@@ -155,9 +155,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
treatmentClass,
|
||||
dosesPerUnit,
|
||||
deliveryForm,
|
||||
trackInjectionSites,
|
||||
allowedInjectionSites,
|
||||
notificationsEnabled,
|
||||
liveActivityEnabled,
|
||||
criticalAlarmEnabled,
|
||||
atcCode,
|
||||
rxNormCode,
|
||||
schedules,
|
||||
startsOn,
|
||||
endsOn,
|
||||
@@ -219,9 +223,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
// v1.6.0 — route of administration; Prisma defaults to ORAL when omitted.
|
||||
...(deliveryForm !== undefined && { deliveryForm }),
|
||||
// v1.8.5 — injection-site tracking opt-in + per-medication allowed
|
||||
// sites; Prisma defaults to false / [] when omitted.
|
||||
...(trackInjectionSites !== undefined && { trackInjectionSites }),
|
||||
...(allowedInjectionSites !== undefined && { allowedInjectionSites }),
|
||||
// v1.7.0 — iOS reminder flags; Prisma defaults both to false.
|
||||
...(liveActivityEnabled !== undefined && { liveActivityEnabled }),
|
||||
...(criticalAlarmEnabled !== undefined && { criticalAlarmEnabled }),
|
||||
// v1.9.0 — optional drug-classification codes (ATC / RxNorm).
|
||||
// Validated for format by Zod; stored verbatim when present.
|
||||
...(atcCode !== undefined && { atcCode }),
|
||||
...(rxNormCode !== undefined && { rxNormCode }),
|
||||
// v1.5 — wizard's reminders toggle now ships through the create
|
||||
// payload (was orphaned in the initial diff). Prisma defaults to
|
||||
// true when omitted, matching the legacy form's behaviour.
|
||||
|
||||
@@ -18,6 +18,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import { replaceTagLinks } from "@/lib/mood/tag-links";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -133,10 +134,38 @@ export const PUT = apiHandler(
|
||||
// monotonic value and paired clients reconcile higher-wins.
|
||||
updateData.syncVersion = { increment: 1 };
|
||||
|
||||
const entry = await prisma.moodEntry.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
// v1.8.5 — update the entry and replace its structured-tag links in
|
||||
// one transaction. A tag-link failure must roll the entry update back
|
||||
// too — otherwise a client retry on the 5xx double-applies the edit
|
||||
// (and the `syncVersion` increment). The tx client is threaded through
|
||||
// the helper so both writes commit (or abort) together.
|
||||
const { entry, persistedTagKeys } = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const updated = await tx.moodEntry.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// Full replacement of the structured-tag link set when `tagKeys`
|
||||
// is present in the body. `null` clears every link; an omitted
|
||||
// field leaves the links untouched.
|
||||
if (data.tagKeys !== undefined) {
|
||||
await replaceTagLinks(id, data.tagKeys ?? [], tx);
|
||||
}
|
||||
|
||||
// v1.8.5 — read the persisted link keys back so the update
|
||||
// response mirrors the list GET shape exactly.
|
||||
const links = await tx.moodEntryTagLink.findMany({
|
||||
where: { moodEntryId: id },
|
||||
select: { moodTag: { select: { key: true } } },
|
||||
});
|
||||
|
||||
return {
|
||||
entry: updated,
|
||||
persistedTagKeys: links.map((link) => link.moodTag.key),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
await auditLog("moodEntry.update", {
|
||||
userId: user.id,
|
||||
@@ -177,7 +206,14 @@ export const PUT = apiHandler(
|
||||
});
|
||||
}
|
||||
|
||||
return apiSuccess({ ...entry, tags: parseTags(entry.tags) });
|
||||
return apiSuccess({
|
||||
...entry,
|
||||
tags: parseTags(entry.tags),
|
||||
// v1.8.5 — surface the persisted structured-tag keys so a client
|
||||
// hydrating from the update response renders the tag set without a
|
||||
// refetch (shape-matches the list GET).
|
||||
tagKeys: persistedTagKeys,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -4,6 +4,19 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// v1.8.5 — the POST happy path writes the entry + its structured-tag
|
||||
// links inside one `$transaction`. The mock hands the same client object
|
||||
// to the interactive-transaction callback so the route's tx-scoped writes
|
||||
// (entry create + tag-link read-back) resolve against the same fakes.
|
||||
const txClient = {
|
||||
moodEntry: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
moodEntryTagLink: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
};
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
moodEntry: {
|
||||
@@ -11,10 +24,20 @@ vi.mock("@/lib/db", () => ({
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
create: vi.fn(),
|
||||
},
|
||||
moodEntryTagLink: {
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
$transaction: vi.fn(
|
||||
async (fn: (tx: typeof txClient) => unknown) => fn(txClient),
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/mood/tag-links", () => ({
|
||||
createTagLinks: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -31,6 +54,9 @@ vi.mock("@/lib/cache/invalidate", () => ({
|
||||
vi.mock("@/lib/rollups/mood-rollups", () => ({
|
||||
recomputeMoodBucketsForEntry: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/moodlog/push", () => ({
|
||||
pushMoodEntriesToMoodLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -47,6 +73,9 @@ vi.mock("next/headers", () => ({
|
||||
import { GET, POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { createTagLinks } from "@/lib/mood/tag-links";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -69,6 +98,20 @@ beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
|
||||
// Re-establish the interactive-transaction passthrough after the reset.
|
||||
vi.mocked(prisma.$transaction).mockImplementation(
|
||||
async (fn: unknown) => (fn as (tx: typeof txClient) => unknown)(txClient),
|
||||
);
|
||||
txClient.moodEntryTagLink.findMany.mockResolvedValue([]);
|
||||
vi.mocked(createTagLinks).mockResolvedValue(undefined);
|
||||
// `reset` blanks the post-commit best-effort mocks; restore the promise
|
||||
// returns so `recompute(...)` awaits and `push(...).catch()` is callable.
|
||||
vi.mocked(recomputeMoodBucketsForEntry).mockResolvedValue(undefined);
|
||||
vi.mocked(pushMoodEntriesToMoodLog).mockResolvedValue({
|
||||
pushed: 0,
|
||||
skipped: 0,
|
||||
status: "ok",
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
@@ -174,3 +217,79 @@ describe("POST /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/mood-entries — entry + tag-links transaction (v1.8.5)", () => {
|
||||
const VALID_BODY = {
|
||||
mood: "GUT",
|
||||
moodLoggedAt: "2026-06-01T08:00:00.000Z",
|
||||
tagKeys: ["happy"],
|
||||
};
|
||||
|
||||
it("writes the entry + tag links through the transaction client", async () => {
|
||||
txClient.moodEntry.create.mockResolvedValue({
|
||||
id: "mood-1",
|
||||
tags: null,
|
||||
moodLoggedAt: new Date(VALID_BODY.moodLoggedAt),
|
||||
mood: "GUT",
|
||||
note: null,
|
||||
source: "MANUAL",
|
||||
date: "2026-06-01",
|
||||
});
|
||||
|
||||
const res = await POST(postReq(VALID_BODY));
|
||||
expect(res.status).toBe(201);
|
||||
// The tag-link write runs against the same `$transaction` client that
|
||||
// created the entry, so both commit (or roll back) together.
|
||||
expect(createTagLinks).toHaveBeenCalledWith("mood-1", ["happy"], txClient);
|
||||
});
|
||||
|
||||
it("returns the persisted tagKeys in the create response", async () => {
|
||||
txClient.moodEntry.create.mockResolvedValue({
|
||||
id: "mood-1",
|
||||
tags: null,
|
||||
moodLoggedAt: new Date(VALID_BODY.moodLoggedAt),
|
||||
mood: "GUT",
|
||||
note: null,
|
||||
source: "MANUAL",
|
||||
date: "2026-06-01",
|
||||
});
|
||||
txClient.moodEntryTagLink.findMany.mockResolvedValue([
|
||||
{ moodTag: { key: "happy" } },
|
||||
]);
|
||||
|
||||
const res = await POST(postReq(VALID_BODY));
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as {
|
||||
data: { id: string; tagKeys: string[] };
|
||||
};
|
||||
// Read-after-write parity: the create response carries the same
|
||||
// `tagKeys` the list GET surfaces.
|
||||
expect(body.data.tagKeys).toEqual(["happy"]);
|
||||
});
|
||||
|
||||
it("rolls the entry back when the tag-link write fails (no commit)", async () => {
|
||||
txClient.moodEntry.create.mockResolvedValue({
|
||||
id: "mood-2",
|
||||
tags: null,
|
||||
moodLoggedAt: new Date(VALID_BODY.moodLoggedAt),
|
||||
mood: "GUT",
|
||||
note: null,
|
||||
source: "MANUAL",
|
||||
date: "2026-06-01",
|
||||
});
|
||||
// A failing tag-link write must propagate out of the transaction so
|
||||
// Prisma aborts the entry create — the route surfaces it as a 5xx and
|
||||
// the entry is never committed (no audit row, no rollup recompute).
|
||||
vi.mocked(createTagLinks).mockRejectedValueOnce(new Error("link write"));
|
||||
|
||||
const res = await POST(postReq(VALID_BODY));
|
||||
expect(res.status).toBe(500);
|
||||
|
||||
const { auditLog } = await import("@/lib/auth/audit");
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
const { recomputeMoodBucketsForEntry } = await import(
|
||||
"@/lib/rollups/mood-rollups"
|
||||
);
|
||||
expect(recomputeMoodBucketsForEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import { pushMoodEntriesToMoodLog } from "@/lib/moodlog/push";
|
||||
import { createTagLinks } from "@/lib/mood/tag-links";
|
||||
|
||||
function parseTags(tags: string | null): string[] {
|
||||
if (!tags) return [];
|
||||
@@ -90,6 +91,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
orderBy: { [sortBy]: sortDir },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
// v1.8.5 — include the structured-tag link keys so the edit form
|
||||
// can pre-populate the taxonomy picker.
|
||||
include: {
|
||||
tagLinks: {
|
||||
select: { moodTag: { select: { key: true } } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.moodEntry.count({ where }),
|
||||
]);
|
||||
@@ -99,9 +107,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
meta: { total, limit, offset },
|
||||
});
|
||||
|
||||
const entriesWithParsedTags = entries.map((e) => ({
|
||||
const entriesWithParsedTags = entries.map(({ tagLinks, ...e }) => ({
|
||||
...e,
|
||||
tags: parseTags(e.tags),
|
||||
// v1.8.5 — flat list of structured-tag keys attached to the entry.
|
||||
tagKeys: tagLinks.map((link) => link.moodTag.key),
|
||||
}));
|
||||
|
||||
return apiSuccess({
|
||||
@@ -147,7 +157,7 @@ async function postMoodEntry(request: NextRequest) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const { mood, tags, note, moodLoggedAt, source } = parsed.data;
|
||||
const { mood, tags, tagKeys, note, moodLoggedAt, source } = parsed.data;
|
||||
// v1.4.25 W7b (Decision A) — anchor the `date` string to the user's
|
||||
// current displayTimezone and store the resolved zone on the row.
|
||||
// Legacy rows with `tz IS NULL` continue to read as Europe/Berlin
|
||||
@@ -157,19 +167,47 @@ async function postMoodEntry(request: NextRequest) {
|
||||
const score = getScoreForMood(mood);
|
||||
|
||||
try {
|
||||
const entry = await prisma.moodEntry.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
date,
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
tags: tags ? JSON.stringify(tags) : null,
|
||||
note: note ?? null,
|
||||
source: source ?? "MANUAL",
|
||||
moodLoggedAt,
|
||||
// v1.8.5 — write the entry and its structured-tag links in one
|
||||
// transaction. The links are user-intended content, not a cache, so a
|
||||
// tag-link failure must roll the entry back too — otherwise a client
|
||||
// retry on the 5xx mints a duplicate entry. The tx client is threaded
|
||||
// through the helper so both writes commit (or abort) together.
|
||||
const { entry, persistedTagKeys } = await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const created = await tx.moodEntry.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
date,
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
tags: tags ? JSON.stringify(tags) : null,
|
||||
note: note ?? null,
|
||||
source: source ?? "MANUAL",
|
||||
moodLoggedAt,
|
||||
},
|
||||
});
|
||||
|
||||
if (tagKeys && tagKeys.length > 0) {
|
||||
// Unknown keys are dropped inside the helper (the catalog is the
|
||||
// source of truth).
|
||||
await createTagLinks(created.id, tagKeys, tx);
|
||||
}
|
||||
|
||||
// v1.8.5 — read the persisted link keys back so the create
|
||||
// response mirrors the list GET shape exactly (unknown keys
|
||||
// already filtered out).
|
||||
const links = await tx.moodEntryTagLink.findMany({
|
||||
where: { moodEntryId: created.id },
|
||||
select: { moodTag: { select: { key: true } } },
|
||||
});
|
||||
|
||||
return {
|
||||
entry: created,
|
||||
persistedTagKeys: links.map((link) => link.moodTag.key),
|
||||
};
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
await auditLog("moodEntry.create", {
|
||||
userId: user.id,
|
||||
@@ -225,7 +263,17 @@ async function postMoodEntry(request: NextRequest) {
|
||||
// rejection in the Next.js runtime.
|
||||
});
|
||||
|
||||
return apiSuccess({ ...entry, tags: parseTags(entry.tags) }, 201);
|
||||
return apiSuccess(
|
||||
{
|
||||
...entry,
|
||||
tags: parseTags(entry.tags),
|
||||
// v1.8.5 — surface the persisted structured-tag keys so a client
|
||||
// hydrating from the create response renders the tag set without a
|
||||
// refetch (shape-matches the list GET).
|
||||
tagKeys: persistedTagKeys,
|
||||
},
|
||||
201,
|
||||
);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import {
|
||||
fetchMoodAggregates,
|
||||
type MoodAggregates,
|
||||
} from "@/lib/insights/mood-aggregates";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* v1.8.5 — pre-computed mood-insights aggregates for the Mood Insights
|
||||
* page (heatmap, distribution, weekday pattern, tag breakdown,
|
||||
* cross-metric correlations, summary headline).
|
||||
*
|
||||
* The page stays a client component for the interactive line chart but
|
||||
* reads the heavy aggregates from here: the compute runs server-side
|
||||
* once per 60-s cache window, the browser only ever receives pre-shaped
|
||||
* data (no raw mood rows). This follows the v1.8.3 anti-freeze posture
|
||||
* — a cheap cached read, never a synchronous LLM call.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const result = await cached(
|
||||
caches.moodInsights as ServerCache<MoodAggregates>,
|
||||
user.id,
|
||||
() => fetchMoodAggregates(user.id),
|
||||
annotate,
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "mood.insights.read" },
|
||||
meta: {
|
||||
total_entries: result.summary.totalEntries,
|
||||
heatmap_window_days: result.heatmap.windowDays,
|
||||
tag_count: result.tags.length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* v1.8.5 — structured mood-tag taxonomy catalog.
|
||||
*
|
||||
* Returns the active Category -> Tag tree the mood-logging form renders
|
||||
* as a pick-from-catalog capture surface. Global reference data (one
|
||||
* shared catalog for the deployment, seeded by migration 0101), so the
|
||||
* read carries no per-user filter and no encryption. The `labelKey`
|
||||
* fields resolve client-side against the active locale.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
// Auth-gated like every app route — the catalog is not public, but it
|
||||
// is identical for every authenticated user.
|
||||
await requireAuth();
|
||||
|
||||
const categories = await prisma.moodTagCategory.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: {
|
||||
key: true,
|
||||
labelKey: true,
|
||||
icon: true,
|
||||
tags: {
|
||||
where: { isActive: true },
|
||||
orderBy: { sortOrder: "asc" },
|
||||
select: { key: true, labelKey: true, icon: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "mood.tags.catalog.read" },
|
||||
meta: { category_count: categories.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ categories });
|
||||
});
|
||||
@@ -81,6 +81,33 @@ describe("GET /api/user/profile", () => {
|
||||
expect(body.data.heightCm).toBe(180);
|
||||
expect(body.data.locale).toBe("de");
|
||||
});
|
||||
|
||||
it("echoes the insurer IK number on GET", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
username: "marc",
|
||||
displayName: null,
|
||||
email: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
locale: null,
|
||||
timezone: "Europe/Berlin",
|
||||
moodReminderEnabled: false,
|
||||
fullName: null,
|
||||
insurerName: "Example Insurer",
|
||||
insurerIkNumber: "101234567",
|
||||
insuranceNumberEncrypted: null,
|
||||
} as never);
|
||||
|
||||
const res = await callGet(makeGetReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { insurerIkNumber: string | null; insurerName: string | null };
|
||||
};
|
||||
expect(body.data.insurerIkNumber).toBe("101234567");
|
||||
expect(body.data.insurerName).toBe("Example Insurer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/user/profile", () => {
|
||||
@@ -132,4 +159,45 @@ describe("PATCH /api/user/profile", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a valid IKNR and echoes it back", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
id: "user-1",
|
||||
username: "marc",
|
||||
displayName: null,
|
||||
email: null,
|
||||
role: "USER",
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
timezone: "Europe/Berlin",
|
||||
locale: null,
|
||||
moodReminderEnabled: false,
|
||||
fullName: null,
|
||||
insurerName: null,
|
||||
insurerIkNumber: "101234567",
|
||||
insuranceNumberEncrypted: null,
|
||||
} as never);
|
||||
|
||||
const res = await PATCH(req({ insurerIkNumber: "101234567" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ insurerIkNumber: "101234567" }),
|
||||
}),
|
||||
);
|
||||
const body = (await res.json()) as {
|
||||
data: { insurerIkNumber: string | null };
|
||||
};
|
||||
expect(body.data.insurerIkNumber).toBe("101234567");
|
||||
});
|
||||
|
||||
it("returns 422 for a malformed IKNR", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await PATCH(req({ insurerIkNumber: "abc" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,7 @@ export const GET = apiHandler(async () => {
|
||||
moodReminderEnabled: true,
|
||||
fullName: true,
|
||||
insurerName: true,
|
||||
insurerIkNumber: true,
|
||||
insuranceNumberEncrypted: true,
|
||||
},
|
||||
});
|
||||
@@ -66,6 +67,7 @@ export const GET = apiHandler(async () => {
|
||||
moodReminderEnabled: dbUser?.moodReminderEnabled ?? false,
|
||||
fullName: dbUser?.fullName ?? null,
|
||||
insurerName: dbUser?.insurerName ?? null,
|
||||
insurerIkNumber: dbUser?.insurerIkNumber ?? null,
|
||||
insuranceNumber,
|
||||
});
|
||||
});
|
||||
@@ -95,6 +97,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
moodReminderEnabled: result.user.moodReminderEnabled,
|
||||
fullName: result.user.fullName,
|
||||
insurerName: result.user.insurerName,
|
||||
insurerIkNumber: result.user.insurerIkNumber,
|
||||
hasInsuranceNumber: result.user.hasInsuranceNumber,
|
||||
});
|
||||
});
|
||||
@@ -97,6 +97,18 @@
|
||||
--color-success: var(--success);
|
||||
--color-warning: var(--warning);
|
||||
--color-info: var(--info);
|
||||
|
||||
/*
|
||||
* Status-pill text tokens. The target status pill paints coloured text
|
||||
* on a 10% same-hue tint; the raw Dracula green/orange/red would fail
|
||||
* WCAG AA (4.5:1) for the "out" tone on the muted reference panel in
|
||||
* dark mode and for every tone on a white card in light mode. These
|
||||
* theme-aware tokens land each tone at or above AA on both `bg-card`
|
||||
* and `bg-muted/30` in both themes while keeping the Dracula feel.
|
||||
*/
|
||||
--color-status-pill-in: var(--status-pill-in);
|
||||
--color-status-pill-near: var(--status-pill-near);
|
||||
--color-status-pill-out: var(--status-pill-out);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -167,6 +179,16 @@
|
||||
--sidebar-border: #44475a;
|
||||
--sidebar-ring: #bd93f9;
|
||||
|
||||
/*
|
||||
* Status-pill text in dark mode. "in" / "near" keep the Dracula
|
||||
* green / orange (both clear AA on the muted panel); "out" lifts off
|
||||
* the raw `#ff5555`, which lands at 3.99:1 on `bg-muted/30`, to a
|
||||
* lighter Dracula red that clears 4.5:1 on both card and panel.
|
||||
*/
|
||||
--status-pill-in: #50fa7b;
|
||||
--status-pill-near: #ffb86c;
|
||||
--status-pill-out: #ff8a8a;
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@@ -212,6 +234,17 @@
|
||||
--sidebar-border: #cfcfde;
|
||||
--sidebar-ring: #644ac9;
|
||||
|
||||
/*
|
||||
* Status-pill text in light mode (Alucard). The bright Dracula
|
||||
* green / orange / red read 1.2–2.7:1 on a near-white tint; these
|
||||
* darker tones — the same family already used for `--success` /
|
||||
* `--warning` / the light-mode reds — clear AA on both the white card
|
||||
* and the muted reference panel.
|
||||
*/
|
||||
--status-pill-in: #14720a;
|
||||
--status-pill-near: #a34d14;
|
||||
--status-pill-out: #b32d1e;
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@@ -219,6 +252,24 @@
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
/*
|
||||
* Reserve the scrollbar gutter on the document root ONLY for the
|
||||
* body-scrolled shells (login / register / onboarding / standalone legal
|
||||
* pages), where the document itself overflows. Those keep a fixed content
|
||||
* width whether or not the page paints the bar, so any `mx-auto` content
|
||||
* does not recentre a few px sideways on navigation. `AuthShell` sets
|
||||
* `data-scroll="body"` on `<html>` for exactly those branches.
|
||||
*
|
||||
* The authenticated app shell is height-locked (`flex h-dvh`) and scrolls
|
||||
* inside its own `<main>`, which reserves its own gutter. Applying this
|
||||
* rule globally there reserved a SECOND, never-painted gutter on classic
|
||||
* (non-overlay) scrollbar platforms (Windows / Linux) — a ~15px dead band.
|
||||
* Scoping it to the body-scrolled branch leaves exactly one gutter per
|
||||
* branch.
|
||||
*/
|
||||
html[data-scroll="body"] {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
@@ -5,14 +5,20 @@ import { join } from "node:path";
|
||||
/**
|
||||
* v1.8.0 — explainer-metric key guard.
|
||||
*
|
||||
* `<MetricExplainer>` builds its i18n lookups from a template literal:
|
||||
* The sub-page shell builds its i18n lookup from a template literal off
|
||||
* the `explainerMetric` prop:
|
||||
*
|
||||
* t(`insights.subPage.explainer.${metric}Title`)
|
||||
* t(`insights.subPage.explainer.${metric}Body`)
|
||||
* t(`insights.subPage.explainer.${metric}Body`) // inline definition
|
||||
*
|
||||
* v1.8.6 — the round `?` popover that also read the `<metric>Title` leaf
|
||||
* is gone; the shell now renders only the inline `Body` caption. The
|
||||
* `Title` leaf is still pinned here so the bundle keeps a stable, fully
|
||||
* populated key pair per metric (the locale-integrity guard propagates
|
||||
* it across the other five locales).
|
||||
*
|
||||
* The static `i18n-call-site-coverage.test.ts` only sees literal
|
||||
* `t("…")` calls, so a typo in an `explainerMetric=` prop would resolve
|
||||
* to a missing key and paint the raw key string into the popover — with
|
||||
* to a missing key and paint the raw key string under the heading — with
|
||||
* no CI signal. This test closes that gap the same way the
|
||||
* Health-Score provenance key tests pin their dynamic keys: it walks
|
||||
* every `explainerMetric="…"` value used under `src/app/insights/**`
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function InsightsActiveEnergyPage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="ACTIVE_ENERGY_BURNED"
|
||||
statusMetric="ACTIVE_ENERGY"
|
||||
insightMetric="ACTIVE_ENERGY_BURNED"
|
||||
chartKey="activeEnergy"
|
||||
i18nPrefix="insights.activeEnergy"
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function InsightsLaermereignissePage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="AUDIO_EXPOSURE_EVENT"
|
||||
statusMetric="AUDIO_EXPOSURE_EVENT"
|
||||
insightMetric="AUDIO_EXPOSURE_EVENT"
|
||||
chartKey="audioExposureEvent"
|
||||
i18nPrefix="insights.audioExposureEvent"
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function InsightsBlutzuckerPage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="BLOOD_GLUCOSE"
|
||||
statusMetric="BLOOD_GLUCOSE"
|
||||
insightMetric="BLOOD_GLUCOSE"
|
||||
chartKey="bloodGlucose"
|
||||
i18nPrefix="insights.bloodGlucose"
|
||||
@@ -25,6 +26,7 @@ export default function InsightsBlutzuckerPage() {
|
||||
yAxisUnit="mg/dL"
|
||||
emptyStateIcon={<Droplet className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
targetSummarySlug="blood-glucose"
|
||||
coachPrefill="I haven't logged any blood glucose yet — what does this metric tell me about my health, and how do I improve it?"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
@@ -101,6 +100,7 @@ export default function InsightsBlutdruckPage() {
|
||||
title={t("insights.bloodPressureSectionTitle")}
|
||||
description={t("insights.subPage.blutdruckDescription")}
|
||||
explainerMetric="bloodPressure"
|
||||
coachLaunch
|
||||
>
|
||||
<HealthChartDynamic
|
||||
chartKey="bp"
|
||||
@@ -124,9 +124,8 @@ export default function InsightsBlutdruckPage() {
|
||||
cached={status?.cached ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
preparing={status?.preparing ?? false}
|
||||
/>
|
||||
|
||||
<CoachLaunchButton />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -110,6 +110,7 @@ export default function InsightsBmiPage() {
|
||||
title={t("insights.bmiSectionTitle")}
|
||||
description={t("insights.subPage.bmiDescription")}
|
||||
explainerMetric="bmi"
|
||||
coachLaunch
|
||||
>
|
||||
<HealthChartDynamic
|
||||
chartKey="bmi"
|
||||
@@ -133,9 +134,8 @@ export default function InsightsBmiPage() {
|
||||
cached={status?.cached ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
preparing={status?.preparing ?? false}
|
||||
/>
|
||||
|
||||
<CoachLaunchButton />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export default function InsightsBodyTemperaturePage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="BODY_TEMPERATURE"
|
||||
statusMetric="BODY_TEMPERATURE"
|
||||
insightMetric="BODY_TEMPERATURE"
|
||||
chartKey="bodyTemperature"
|
||||
i18nPrefix="insights.bodyTemperature"
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function InsightsKoerperwasserPage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="TOTAL_BODY_WATER"
|
||||
statusMetric="TOTAL_BODY_WATER"
|
||||
insightMetric="TOTAL_BODY_WATER"
|
||||
chartKey="totalBodyWater"
|
||||
i18nPrefix="insights.totalBodyWater"
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function InsightsKnochenmassePage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="BONE_MASS"
|
||||
statusMetric="BONE_MASS"
|
||||
insightMetric="BONE_MASS"
|
||||
chartKey="boneMass"
|
||||
i18nPrefix="insights.boneMass"
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function InsightsTageslichtPage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="TIME_IN_DAYLIGHT"
|
||||
statusMetric="TIME_IN_DAYLIGHT"
|
||||
insightMetric="TIME_IN_DAYLIGHT"
|
||||
chartKey="timeInDaylight"
|
||||
i18nPrefix="insights.timeInDaylight"
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function InsightsDoppelstandphasePage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="WALKING_DOUBLE_SUPPORT"
|
||||
statusMetric="WALKING_DOUBLE_SUPPORT"
|
||||
insightMetric="WALKING_DOUBLE_SUPPORT"
|
||||
chartKey="walkingDoubleSupport"
|
||||
i18nPrefix="insights.walkingDoubleSupport"
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function InsightsLaermbelastungPage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="AUDIO_EXPOSURE_ENV"
|
||||
statusMetric="AUDIO_EXPOSURE_ENV"
|
||||
insightMetric="AUDIO_EXPOSURE_ENV"
|
||||
chartKey="audioExposureEnv"
|
||||
i18nPrefix="insights.audioExposureEnv"
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function InsightsFettfreieMassePage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="FAT_FREE_MASS"
|
||||
statusMetric="FAT_FREE_MASS"
|
||||
insightMetric="FAT_FREE_MASS"
|
||||
chartKey="fatFreeMass"
|
||||
i18nPrefix="insights.fatFreeMass"
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function InsightsFettmassePage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="FAT_MASS"
|
||||
statusMetric="FAT_MASS"
|
||||
insightMetric="FAT_MASS"
|
||||
chartKey="fatMass"
|
||||
i18nPrefix="insights.fatMass"
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function InsightsStockwerkePage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="FLIGHTS_CLIMBED"
|
||||
statusMetric="FLIGHTS_CLIMBED"
|
||||
insightMetric="FLIGHTS_CLIMBED"
|
||||
chartKey="flightsClimbed"
|
||||
i18nPrefix="insights.flightsClimbed"
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function InsightsKopfhoererpegelPage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="AUDIO_EXPOSURE_HEADPHONE"
|
||||
statusMetric="AUDIO_EXPOSURE_HEADPHONE"
|
||||
insightMetric="AUDIO_EXPOSURE_HEADPHONE"
|
||||
chartKey="audioExposureHeadphone"
|
||||
i18nPrefix="insights.audioExposureHeadphone"
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function InsightsHrvPage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="HEART_RATE_VARIABILITY"
|
||||
statusMetric="HEART_RATE_VARIABILITY"
|
||||
insightMetric="HEART_RATE_VARIABILITY"
|
||||
chartKey="hrv"
|
||||
i18nPrefix="insights.hrv"
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function InsightsMagermassePage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="LEAN_BODY_MASS"
|
||||
statusMetric="LEAN_BODY_MASS"
|
||||
insightMetric="LEAN_BODY_MASS"
|
||||
chartKey="leanBodyMass"
|
||||
i18nPrefix="insights.leanBodyMass"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* v1.8.3 — route-level loading skeleton for every `/insights/*` segment.
|
||||
*
|
||||
* Defense-in-depth for the navigation-freeze class: any RSC await on an
|
||||
* insights route (now or in a future refactor) streams this skeleton
|
||||
* instead of blocking the transition. The geometry mirrors `SubPageShell`
|
||||
* (heading + chart card + assessment card) so the post-load swap reflows
|
||||
* by less than a row. Pure server component — no client hooks, no data
|
||||
* fetch; Next.js renders it instantly while the segment streams.
|
||||
*/
|
||||
export default function InsightsLoading() {
|
||||
return (
|
||||
<div data-slot="insights-subpage-loading" className="space-y-6">
|
||||
<header className="space-y-2">
|
||||
<div className="bg-muted h-7 w-48 animate-pulse rounded motion-reduce:animate-none" />
|
||||
<div className="bg-muted/70 h-4 w-72 animate-pulse rounded motion-reduce:animate-none" />
|
||||
</header>
|
||||
|
||||
{/* Chart card */}
|
||||
<div className="bg-card rounded-xl border p-4">
|
||||
<div className="bg-muted mb-4 h-4 w-32 animate-pulse rounded motion-reduce:animate-none" />
|
||||
<div className="bg-muted/60 h-56 w-full animate-pulse rounded-lg motion-reduce:animate-none" />
|
||||
</div>
|
||||
|
||||
{/* Assessment card */}
|
||||
<div className="bg-card space-y-2 rounded-xl border p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-muted h-5 w-5 animate-pulse rounded motion-reduce:animate-none" />
|
||||
<div className="bg-muted h-4 w-40 animate-pulse rounded motion-reduce:animate-none" />
|
||||
</div>
|
||||
<div className="bg-muted h-3.5 w-full animate-pulse rounded motion-reduce:animate-none" />
|
||||
<div className="bg-muted h-3.5 w-11/12 animate-pulse rounded motion-reduce:animate-none" />
|
||||
<div className="bg-muted h-3.5 w-9/12 animate-pulse rounded motion-reduce:animate-none" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { Loader2, Pill } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { nextStatusPollInterval } from "@/hooks/use-insight-status";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -58,8 +59,21 @@ interface MedicationComplianceStatusData {
|
||||
medications: Array<{ medicationId: string; text: string }>;
|
||||
cached: boolean;
|
||||
updatedAt: string | null;
|
||||
// v1.8.3 — read-only route returns preparing:true with summary:null on a
|
||||
// cache miss while the worker warms the assessment; the card polls until
|
||||
// it lands. See `use-insight-status.ts` for the shared rationale.
|
||||
preparing?: boolean;
|
||||
// v1.9.0 — last-good narrative served while a refresh is in flight; the
|
||||
// card keeps polling (bounded) so the open page upgrades in-session.
|
||||
revalidating?: boolean;
|
||||
}
|
||||
|
||||
// v1.8.3 — client ceiling on the round-trip, mirroring the shared
|
||||
// `use-insight-status` hook so the medication-compliance card never awaits
|
||||
// an uncapped LLM round-trip on navigation. The preparing-poll cadence and
|
||||
// its attempt ceiling come from the shared `nextStatusPollInterval` helper.
|
||||
const MED_STATUS_TIMEOUT_MS = 8_000;
|
||||
|
||||
interface MedicationDailyData {
|
||||
expected: number;
|
||||
taken: number;
|
||||
@@ -91,15 +105,35 @@ export default function InsightsMedikamentePage() {
|
||||
const { data: status, isLoading: isStatusLoading } = useQuery({
|
||||
queryKey: queryKeys.insightsMedicationComplianceStatus(locale),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/insights/medication-compliance-status?locale=${locale}`,
|
||||
const controller = new AbortController();
|
||||
const timeoutHandle = setTimeout(
|
||||
() => controller.abort(),
|
||||
MED_STATUS_TIMEOUT_MS,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as MedicationComplianceStatusData;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/insights/medication-compliance-status?locale=${locale}`,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed");
|
||||
const json = await res.json();
|
||||
return json.data as MedicationComplianceStatusData;
|
||||
} finally {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60 * 1000,
|
||||
retry: 0,
|
||||
// v1.8.4 — bounded preparing poll: stop after the shared attempt
|
||||
// ceiling so a persistently failing generation can't poll an open
|
||||
// page forever.
|
||||
refetchInterval: (query) =>
|
||||
nextStatusPollInterval(
|
||||
query.state.data?.preparing,
|
||||
query.state.dataUpdateCount,
|
||||
query.state.data?.revalidating,
|
||||
),
|
||||
});
|
||||
|
||||
const medications = comprehensive?.medications ?? [];
|
||||
@@ -157,6 +191,7 @@ export default function InsightsMedikamentePage() {
|
||||
title={t("insights.medicationCompliance")}
|
||||
description={t("insights.subPage.medikamenteDescription")}
|
||||
explainerMetric="medications"
|
||||
coachLaunch
|
||||
>
|
||||
<div
|
||||
className={
|
||||
@@ -253,14 +288,13 @@ export default function InsightsMedikamentePage() {
|
||||
cached={status?.cached ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
preparing={status?.preparing ?? false}
|
||||
/>
|
||||
|
||||
{/* v1.4.25 W4d — GLP-1 therapy timeline. Self-hides for users
|
||||
without an active GLP-1 medication, so the page collapses
|
||||
back to the compliance grid for everyone else. */}
|
||||
<TherapyTimeline />
|
||||
|
||||
<CoachLaunchButton />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,10 +12,10 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { MoodInsightsSections } from "@/components/insights/mood/mood-insights-sections";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
|
||||
/**
|
||||
@@ -98,6 +98,7 @@ export default function InsightsStimmungPage() {
|
||||
title={t("insights.moodSectionTitle")}
|
||||
description={t("insights.subPage.stimmungDescription")}
|
||||
explainerMetric="mood"
|
||||
coachLaunch
|
||||
>
|
||||
<MoodChart
|
||||
chartKey="mood"
|
||||
@@ -105,8 +106,9 @@ export default function InsightsStimmungPage() {
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
|
||||
<MetricTargetSummary slug="mood" />
|
||||
|
||||
{/* v1.8.7 — the AI assessment reads best directly under the first
|
||||
chart: the reader sees the trend, then the narration of it,
|
||||
before the calendar / distribution / correlation breakdowns. */}
|
||||
<InsightStatusCard
|
||||
title={t("insights.assessmentTitle")}
|
||||
icon={<Smile className="h-5 w-5" />}
|
||||
@@ -115,9 +117,12 @@ export default function InsightsStimmungPage() {
|
||||
cached={status?.cached ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
preparing={status?.preparing ?? false}
|
||||
/>
|
||||
|
||||
<CoachLaunchButton />
|
||||
<MetricTargetSummary slug="mood" />
|
||||
|
||||
<MoodInsightsSections />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export default function InsightsMuskelmassePage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="MUSCLE_MASS"
|
||||
statusMetric="MUSCLE_MASS"
|
||||
insightMetric="MUSCLE_MASS"
|
||||
chartKey="muscleMass"
|
||||
i18nPrefix="insights.muscleMass"
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function InsightsOxygenSaturationPage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="OXYGEN_SATURATION"
|
||||
statusMetric="OXYGEN_SATURATION"
|
||||
insightMetric="OXYGEN_SATURATION"
|
||||
chartKey="oxygenSaturation"
|
||||
i18nPrefix="insights.oxygenSaturation"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { TrendingUp } from "lucide-react";
|
||||
import { TrendingUp, Sparkles } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { HeroStrip } from "@/components/insights/hero-strip";
|
||||
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
|
||||
import { useInsightsWarm } from "@/components/insights/use-insights-warm";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
// v1.4.41 W-ORG — shared shape lives in `src/types/analytics.ts` as
|
||||
@@ -150,6 +151,13 @@ export default function InsightsPage() {
|
||||
const analyticsQuery = useAnalyticsQuery();
|
||||
const analytics = analyticsQuery.data as AnalyticsData | undefined;
|
||||
|
||||
// The "prepare assessments" button below re-warms every assessment on
|
||||
// demand. There is no warm-on-mount: the nightly cron (04:30) keeps the
|
||||
// caches warm and the per-metric status GETs revalidate gently on their
|
||||
// own (stale-while-revalidate), so opening the overview only reads cached
|
||||
// text — it never fans out a full provider warm on a page visit.
|
||||
const { warm, isWarming } = useInsightsWarm();
|
||||
|
||||
// Empty-state shortcut — only paint once the comprehensive query has
|
||||
// resolved AND reported zero measurements. While it's in-flight we
|
||||
// fall through to the streamed shell so the user gets the hero +
|
||||
@@ -210,6 +218,18 @@ export default function InsightsPage() {
|
||||
healthScore={analytics?.healthScore ?? undefined}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={warm}
|
||||
disabled={isWarming}
|
||||
>
|
||||
<Sparkles className="size-4" />
|
||||
{t("insights.warmAssessments")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{flags.briefing && (
|
||||
<DailyBriefing
|
||||
briefing={briefingPayload}
|
||||
@@ -225,6 +245,7 @@ export default function InsightsPage() {
|
||||
)}
|
||||
|
||||
<TrendsRow
|
||||
briefing={briefingPayload}
|
||||
annotations={advisor.payload?.trendAnnotations ?? null}
|
||||
loading={advisor.isLoading || advisor.isRegenerating}
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function InsightsPulswellengeschwindigkeitPage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="PULSE_WAVE_VELOCITY"
|
||||
statusMetric="PULSE_WAVE_VELOCITY"
|
||||
insightMetric="PULSE_WAVE_VELOCITY"
|
||||
chartKey="pulseWaveVelocity"
|
||||
i18nPrefix="insights.pulseWaveVelocity"
|
||||
|
||||
@@ -10,9 +10,10 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { Vo2MaxChartRow } from "@/components/insights/vo2-max-chart-row";
|
||||
@@ -114,6 +115,21 @@ export default function InsightsPulsPage() {
|
||||
title={t("insights.pulseSectionTitle")}
|
||||
description={t("insights.subPage.pulsDescription")}
|
||||
explainerMetric="pulse"
|
||||
statStrip={
|
||||
<MetricStatStrip
|
||||
summary={analytics?.summaries?.PULSE ?? null}
|
||||
unit="bpm"
|
||||
/>
|
||||
}
|
||||
diversityNudge={
|
||||
<MeasurementDiversityNudge
|
||||
measurementType="PULSE"
|
||||
metricLabel={t("insights.pulseSectionTitle")}
|
||||
timeZone={user?.timezone ?? undefined}
|
||||
/>
|
||||
}
|
||||
coachLaunch
|
||||
showAllValuesType="PULSE"
|
||||
>
|
||||
<HealthChartDynamic
|
||||
chartKey="pulse"
|
||||
@@ -136,6 +152,7 @@ export default function InsightsPulsPage() {
|
||||
cached={status?.cached ?? false}
|
||||
updatedAt={status?.updatedAt ?? null}
|
||||
loading={isStatusLoading}
|
||||
preparing={status?.preparing ?? false}
|
||||
/>
|
||||
|
||||
{/* v1.4.25 W16a — VO2 max sits on the cardio sub-page because it
|
||||
@@ -149,8 +166,6 @@ export default function InsightsPulsPage() {
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
|
||||
<CoachLaunchButton />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export default function InsightsAtemfrequenzPage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="RESPIRATORY_RATE"
|
||||
statusMetric="RESPIRATORY_RATE"
|
||||
insightMetric="RESPIRATORY_RATE"
|
||||
chartKey="respiratoryRate"
|
||||
i18nPrefix="insights.respiratoryRate"
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function InsightsRestingHrPage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="RESTING_HEART_RATE"
|
||||
statusMetric="RESTING_HEART_RATE"
|
||||
insightMetric="RESTING_HEART_RATE"
|
||||
chartKey="restingHr"
|
||||
i18nPrefix="insights.restingHr"
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function InsightsHauttemperaturPage() {
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="SKIN_TEMPERATURE"
|
||||
statusMetric="SKIN_TEMPERATURE"
|
||||
insightMetric="SKIN_TEMPERATURE"
|
||||
chartKey="skinTemperature"
|
||||
i18nPrefix="insights.skinTemperature"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Moon } from "lucide-react";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { MetricStatusCard } from "@/components/insights/metric-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SleepOverview } from "@/components/insights/sleep-overview";
|
||||
@@ -62,24 +62,25 @@ export default function InsightsSchlafPage() {
|
||||
title={t("insights.sleep.title")}
|
||||
description={t("insights.sleep.description")}
|
||||
explainerMetric="sleep"
|
||||
coachLaunch
|
||||
>
|
||||
<SleepOverview />
|
||||
|
||||
<MetricTargetSummary slug="sleep" />
|
||||
|
||||
{/*
|
||||
v1.4.28 (BK-UI-StatusSchlaf) — the six sibling sub-pages all
|
||||
mount `<InsightStatusCard>` underneath the chart so the user
|
||||
sees a written per-section assessment. Sleep has no
|
||||
`/api/insights/sleep-status` route yet (the v1.4.23 schema
|
||||
landed the data but the assessment-generation pass was deferred
|
||||
to the v1.5 iOS sprint where the Apple-Health sleep snapshot
|
||||
will inform the prompt). No per-section assessment yet; the
|
||||
structural slot waits for v1.5 where the route + hook key will
|
||||
wire in.
|
||||
v1.8.7.1 — the per-section sleep assessment lands via the generic
|
||||
metric-status route (`?metric=SLEEP_DURATION`), closing the slot
|
||||
the v1.4.28 deferral left open. The shared `<MetricStatusCard>`
|
||||
owns the hook + card; only the icon differs from the HealthKit
|
||||
scaffold (Moon vs Sparkles). The card mounts only on this
|
||||
data-bearing branch, so a source-less account never fetches.
|
||||
*/}
|
||||
|
||||
<CoachLaunchButton />
|
||||
<MetricStatusCard
|
||||
metric="SLEEP_DURATION"
|
||||
icon={<Moon className="h-5 w-5" />}
|
||||
enabled={!isEmpty}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 342 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user