Merge pull request #334 from MBombeck/release/v1.17.1

Release v1.17.1 — preventive care, lab results, and more of your data
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-15 02:21:17 +02:00
commit 0241714bd4
249 files changed
+20963 -978

No files matched your search

+41 -1
View File
@@ -74,16 +74,56 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# OURA_REDIRECT_URI=""
# -----------------------------------------------------------------------------
# Web Push -- VAPID keys (optional; the admin panel is the easier path)
# -----------------------------------------------------------------------------
# Web Push needs NO Apple account and no paid service -- just a VAPID keypair.
# The EASIEST path is the admin panel: /admin -> Web Push VAPID -> "Generate
# keys" mints and stores the pair for you (private key encrypted at rest). Use
# these env vars only if you prefer env-config over the DB. The loader reads
# DB first, then env (src/lib/notifications/vapid-config.ts). NOTE: these are
# NOT in docker-compose.yml's `environment:` whitelist -- if you go the env
# route under compose, add them there too or the values never reach the
# container (see docs/self-hosting/getting-started.md Troubleshooting).
# VAPID_PUBLIC_KEY=""
# VAPID_PRIVATE_KEY=""
# VAPID_SUBJECT="mailto:you@example.com"
# -----------------------------------------------------------------------------
# APNs -- iOS push (optional, all-or-none)
# -----------------------------------------------------------------------------
# Only the native iOS app uses APNs, and it needs a paid Apple Developer
# Program membership. Web Push (above) needs NO Apple account -- it covers
# every browser and the installed PWA on iPhone. Leave this whole block unset
# unless you ship the native app. Key-source precedence when more than one is
# set: APNS_KEY_B64 > APNS_KEY > APNS_KEY_FILE.
# APNS_KEY_ID=""
# APNS_TEAM_ID=""
# APNS_BUNDLE_ID=""
# APNS_KEY="" # OR APNS_KEY_FILE -- either one satisfies the manifest
# APNS_KEY_B64="" # PEM body base64-encoded -- recommended (escapes newlines)
# APNS_KEY="" # OR APNS_KEY (raw PEM) OR APNS_KEY_FILE -- one satisfies the manifest
# APNS_KEY_FILE=""
# -----------------------------------------------------------------------------
# SMTP -- email notification channel (optional, all-or-none)
# -----------------------------------------------------------------------------
# Operator SMTP transport for the email channel. SMTP_HOST + SMTP_PORT +
# SMTP_FROM together enable it; SMTP_USER/SMTP_PASS are optional (omit for an
# unauthenticated relay). SMTP_SECURE=true uses implicit TLS (port 465);
# unset/false uses STARTTLS (port 587). Per-user recipient address is set in
# Settings -> Notifications, not here. Missing the core trio disables the
# channel silently. Listed in docker-compose.yml's environment whitelist so
# these reach the container.
# SMTP_HOST=""
# SMTP_PORT="587"
# SMTP_FROM="HealthLog <noreply@example.com>"
# SMTP_USER=""
# SMTP_PASS=""
# SMTP_SECURE="false"
# -----------------------------------------------------------------------------
# Deploy webhook -- Coolify -> HealthLog auto-deploy feedback
# -----------------------------------------------------------------------------
+39
View File
@@ -2,6 +2,45 @@
## [Unreleased]
## [1.17.1] — 2026-06-15 — preventive care, lab results, and more of the data you already have
This release closes the loop from tracking to acting. Preventive-care reminders tell you when to measure or check what, structured lab results give your bloodwork a home, and a new recovery view surfaces signals that were already being collected but never shown. Sleep timelines now read in real clock times, marking a dose on one device clears its reminder on the others within seconds, and self-hosters gain a generic webhook channel, email, one-tap Web Push setup and a proper notifications guide. Every new number is computed once on the server and read the same way on the dashboard, the coach, the doctor-report and the companion app. No breaking changes.
### Added
- **Preventive-care reminders.** Set a reminder to measure your blood pressure on a cadence or to schedule an annual blood panel, with a clear next-due date and where to do it. A matching measurement marks it done on its own; the rest you tick off. Reminders reach you over every notification channel, and the cards stay calm — status is a quiet badge, never an alarming colour.
- **Structured lab results.** Record bloodwork and biomarkers with their reference range, see each one trend over time, and carry them into the doctor-report PDF and the FHIR export. An out-of-range value is shown plainly, not in red.
- **A recovery view, and sleep quality in depth.** A new recovery page gathers strain, training load and autonomic-charge readings, and the sleep page gains an efficiency, performance and sleep-score block — metrics a connected device was already sending that nothing surfaced before. Each appears only once it has data and stays calm until it has enough to be sure.
- **Import and backdated entry.** Import a CSV of past measurements with a previewed, per-row result and unit conversion, and log an entry with a past date and time — the cold-start escape hatches for bringing existing history in.
- **Onboarding that does what it says.** A short, skippable health-baseline step, and the goals you pick now actually seed your dashboard.
- **Polar and Oura credentials in the browser.** Both connect with your own developer-app credentials entered in settings, like the other integrations — no environment file needed.
- **More ways to be notified.** A generic webhook channel reaches a self-hosted notifier or a chat service, an email channel sends over your own SMTP server, and an operator can see delivery health across every channel. Web Push keys can be generated in one click from the admin panel.
### Changed
- **Sleep reads in real clock times.** Per-stage rows now carry their own start and end, so the "last night" timeline lays out across the night instead of stacking — measured where the device reports stage timing, and an honestly-labelled reconstruction where it only reports stage totals. Nights logged by more than one source resolve to a single total.
- **A dose taken on one device clears the others in seconds.** Marking a dose sends a silent sync to your other devices, so a lock-screen reminder ends without waiting for the next app open.
- **More of a connected device's data flows in.** Readiness contributors, body-temperature deviation, blood-oxygen, a sleep score and autonomic-charge and training-load now come through where the source provides them. Body weight is never taken from a wearable strap.
- **One product across every screen.** Desktop and mobile navigation now tell the same story, the coach has a single home, the layout and reminder settings each gather under one hub, and every integration card links to its setup guide.
### Fixed
- Sleep nights from some sources were stamped at the wrong instant and sat shifted earlier in the day; corrected, with a one-time backfill that re-syncs affected nights.
- The doctor-report sleep figure now matches the dashboard and the companion app, reading the same reconstructed per-night total as every other surface.
- Polish across the new surfaces: consistent loading and empty states, design-token colours, responsive grids, and a calm confirmation for regenerating Web Push keys.
### Security
- The webhook channel pins a public host and refuses a private or loopback address unless explicitly allowed; webhook secrets, SMTP credentials and the Web Push private key are kept out of any recorded error; the key-generation and delivery-health endpoints are reachable only from an authenticated admin session, never a token.
### Self-hosting
- A notifications guide spells out that no Apple account is needed — Web Push, Telegram, ntfy, the new webhook and email all work without one — alongside a backup-and-restore callout and a clearer note on which variables must be whitelisted to reach the container.
### Operator note
- Migrations 0162–0166 are additive. A boot-time backfill re-syncs sleep nights from the affected sources to the corrected timeline once; it is idempotent and bounded.
## [1.17.0] — 2026-06-14 — clinical depth for glucose and sleep, and three new sources
This release builds new depth on the coherent foundation laid over the v1.16 line. Blood glucose gains a clinical panel, sleep gains a debt and chronotype reading, and three new data sources — Nightscout, Polar and Oura — join Withings, WHOOP, Fitbit and Apple Health. Every new metric is computed once on the server and read the same way on the dashboard, the coach, the doctor-report and the companion app, and each holds back a confident reading behind a calm "still learning" state until it has enough data. No breaking changes.
+29
View File
@@ -107,6 +107,35 @@ services:
# container (the `environment:` block is a whitelist). See
# docs/ops/tls-cert-pin.md.
TLS_LEAF_SPKI_PINS: "${TLS_LEAF_SPKI_PINS:-}"
# v1.17.1 — SMTP transport for the email notification channel (optional,
# all-or-none: HOST + PORT + FROM enable it; USER/PASS optional; SECURE
# toggles implicit TLS). Listed here so operator values in .env reach the
# container (the `environment:` block is a whitelist — vars not listed
# never propagate from .env, even with `${VAR}` substitution at
# compose-up). Per-user recipient is stored in the DB, not here.
SMTP_HOST: "${SMTP_HOST:-}"
SMTP_PORT: "${SMTP_PORT:-}"
SMTP_FROM: "${SMTP_FROM:-}"
SMTP_USER: "${SMTP_USER:-}"
SMTP_PASS: "${SMTP_PASS:-}"
SMTP_SECURE: "${SMTP_SECURE:-}"
# v1.17.1 — Web Push VAPID keys (optional). The admin Settings panel stores
# these in the DB (the supported path); these env vars are the fallback the
# loader reads when the DB pair is unset. Listed here so the documented env
# path reaches the container under the bundled compose. See
# docs/self-hosting/notifications.md.
VAPID_PUBLIC_KEY: "${VAPID_PUBLIC_KEY:-}"
VAPID_PRIVATE_KEY: "${VAPID_PRIVATE_KEY:-}"
VAPID_SUBJECT: "${VAPID_SUBJECT:-}"
# APNs for a self-hosted native iOS build (optional, env-only — no admin UI
# for the .p8). APNS_KEY_B64 is the escape-free base64 form preferred over
# APNS_KEY/APNS_KEY_FILE; the key must be scoped "Sandbox & Production".
# PWA users need none of this (Web Push covers them). See
# docs/self-hosting/notifications.md.
APNS_KEY_B64: "${APNS_KEY_B64:-}"
APNS_KEY_ID: "${APNS_KEY_ID:-}"
APNS_TEAM_ID: "${APNS_TEAM_ID:-}"
APNS_BUNDLE_ID: "${APNS_BUNDLE_ID:-}"
depends_on:
db:
condition: service_healthy
+1095 -1
View File
File diff suppressed because it is too large. Load diff
+49
View File
@@ -147,6 +147,7 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build
| Next step | File |
| --------- | ---- |
| TLS + reverse-proxy configuration | `docs/self-hosting/reverse-proxy.md` |
| Push notifications — Web Push, Telegram, ntfy, APNs | `docs/self-hosting/notifications.md` |
| Web/worker process split for horizontal scale | `docs/self-hosting/scaling.md` |
| Off-host encrypted backups to S3/R2/B2 | `docs/ops/backup-restore.md` |
| Encryption-key rotation procedure | `docs/ops/encryption-key-rotation.md` |
@@ -174,3 +175,51 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build
- **OAuth callbacks loop back to localhost.** Confirm `APP_URL` and
`NEXT_PUBLIC_APP_URL` both point at the public hostname, not at
`localhost`, and restart `app` after the change.
- **An env var in `.env` seems ignored / a feature stays "off".** The
compose `environment:` block is a **whitelist** — a variable set in
`.env` only reaches the container if `docker-compose.yml` lists it
under `environment:`. Vars not listed never propagate, even with
`${VAR}` substitution at compose-up. If a setting looks configured but
is silently dead, check the var is in that block (add it if you run a
custom compose); the supported alternative for most secrets (VAPID,
GitHub token, GlitchTip DSN) is the **admin panel**, which stores them
in the database and sidesteps the whitelist entirely.
- **A new image doesn't pick up / `:latest` looks stale.** `docker-compose.yml`
sets `pull_policy: always`, which is load-bearing: without it Docker
re-uses the cached `:latest` digest and silently skips the registry
round-trip on `compose up`. If you pin a custom compose, keep
`pull_policy: always` on the `app` service, and verify the running
build with `GET /api/version` (it returns `version` + `buildSha`).
## Backup and restore
Your database is the only stateful piece — back it up, and back up the
key that decrypts it.
**Local snapshot (the common single-box case).** Dump the `db` service
with `pg_dump`:
```bash
# The bundled db service uses user `healthlog` and database `healthlog`.
docker compose exec -T db pg_dump -U healthlog healthlog \
| gzip > healthlog-$(date +%F).sql.gz
```
Restore into a fresh database the same way:
```bash
gunzip -c healthlog-2026-01-01.sql.gz \
| docker compose exec -T db psql -U healthlog healthlog
```
> **Back up your `ENCRYPTION_KEYS` / `ENCRYPTION_KEY` alongside the dump.**
> HealthLog encrypts sensitive columns at rest (AES-256-GCM). A database
> dump **without the encryption key is unrecoverable** — the encrypted
> data cannot be read back. Store the key (and any retired keys still in
> the rotation map) somewhere separate from the dump but equally durable.
> Losing the key means losing the encrypted data, full stop.
For off-host encrypted backups to S3/R2/B2 see
[`docs/ops/backup-restore.md`](../ops/backup-restore.md); for rotating the
encryption key safely (keep the old key until zero rows remain on it) see
[`docs/ops/encryption-key-rotation.md`](../ops/encryption-key-rotation.md).
+212
View File
@@ -0,0 +1,212 @@
# Notifications — push channels for a self-host
HealthLog delivers reminders (medication doses, mood check-ins, personal
records) and a few operational alerts through a small cascade of push
channels. The single message to take away: **you do not need an Apple
Developer account or APNs to get push.** Three of the four channels are
free, work in the browser or in a messaging app you already have, and
cover every platform a self-hoster runs. APNs is only for the optional
native iOS app.
When an event fires, the dispatcher fans it out to every channel a user
has enabled, in a fixed order — APNs first, then Telegram, then ntfy,
then Web Push as the universal fallback
(`src/lib/notifications/dispatcher.ts`). Each channel is best-effort and
independent: a user can enable one, two, or all four, and a failure on
one never blocks the others.
## TL;DR — which channel needs what
| Channel | What it needs | Cost | Where it works |
| --- | --- | --- | --- |
| **Web Push** | A VAPID keypair (one command, or paste into the admin panel) | Free | Any modern desktop/Android browser; the installed PWA on iPhone (iOS 16.4+, after *Add to Home Screen*) |
| **Telegram** | A bot token from @BotFather + each user's chat ID | Free | Anywhere the Telegram app runs |
| **ntfy** | A topic on `ntfy.sh` or your own ntfy server | Free | The ntfy app (iOS/Android) or any browser |
| **APNs** | An Apple Developer account + a `.p8` push key | Apple Developer Program (paid) | The native iOS app only (TestFlight / App Store) |
Web Push is the recommended default — it needs no third-party account,
just a keypair you generate yourself once.
## Web Push (recommended default)
Web Push delivers notifications straight to a browser — desktop or
mobile — even when the HealthLog tab is closed, as long as the browser
or installed PWA is alive. It is the broadest-reach channel and the one
to set up first.
### 1. Generate and store a VAPID keypair
VAPID is the signing scheme that lets a push service trust your server.
You also choose a **subject** — a `mailto:` address the push service can
contact about your sender, e.g. `mailto:you@example.com`.
The server loads VAPID config from the database first and falls back to
environment variables (`src/lib/notifications/vapid-config.ts`). The admin
panel is the easiest path because it survives without touching `.env`.
**Admin panel — Generate keys (easiest).** Sign in as the admin user,
open `/admin`, and find the **Web Push VAPID** card
(`src/components/admin/web-push-vapid-section.tsx`). Click **Generate
keys**: the server mints a fresh keypair, stores the private key encrypted
at rest with your `ENCRYPTION_KEY`, seeds a placeholder subject, and fills
in the public key. Edit the subject to your real `mailto:` address and
save. The card shows a "configured" badge once all three fields are set —
no shell, no copy-paste.
> If a keypair already exists, **Generate keys** asks you to confirm before
> replacing it. Regenerating invalidates every existing browser
> subscription, so each device has to re-subscribe afterwards. Only
> regenerate when you mean to.
**Admin panel — paste an existing pair.** If you already have a keypair
(e.g. copied from another deployment), paste the public key, the private
key, and the subject into the same card and save instead of generating.
**CLI — generate a pair yourself.** You can also mint one with the bundled
`web-push` CLI and paste the result into the card or your `.env`:
```bash
npx web-push generate-vapid-keys
```
That prints a **public key** and a **private key** (both Base64URL).
### 2. Store the keys via environment variables (alternative)
If you would rather keep
secrets out of the database, set them in `.env` / the compose
`environment:` block instead:
```env
VAPID_PUBLIC_KEY="BElx...(public key)"
VAPID_PRIVATE_KEY="...(private key)"
VAPID_SUBJECT="mailto:you@example.com"
```
The database values win when both are present. The loader also accepts a
few aliases (`WEB_PUSH_VAPID_PUBLIC_KEY`, `WEB_PUSH_PUBLIC_KEY`, and the
private/subject equivalents) so a config copied from another deployment
keeps working.
### 3. Subscribe a device
Each user enables Web Push for their own browser from
`/settings/notifications` — the **Browser Push** card has a Subscribe
button, and a Test button to fire a one-off push once subscribed. The
browser prompts for notification permission on first subscribe; once
granted, that browser receives every enabled event.
### iPhone caveat — Add to Home Screen first
Safari on iPhone supports Web Push only from **iOS 16.4 or newer**, and
only for a site that has been **installed to the Home Screen as a PWA**.
A regular Safari tab cannot subscribe. So on iPhone:
1. Open your HealthLog instance in Safari.
2. Tap the Share button, then **Add to Home Screen**.
3. Launch HealthLog from the new Home Screen icon (not from the Safari
tab).
4. Open `/settings/notifications` inside that installed app and tap
Subscribe; accept the permission prompt.
Android and desktop browsers have no such step — Subscribe and grant the
permission, and push works.
## Telegram
Telegram delivers reminders as bot messages, with inline action buttons
on medication reminders (Taken / snooze / skip). It is a good fit when
you already live in Telegram or want push without running a browser.
1. In Telegram, open **@BotFather**, create a bot, and copy the **bot
token** (it looks like `123456:ABC-DEF...`).
2. Send `/start` to your new bot so it can message you.
3. Find your **chat ID** — send a message to **@userinfobot**, or read it
from the Bot API.
4. In HealthLog open `/settings/notifications`, fill the Telegram card's
**Bot Token** and **Chat ID**, toggle it on, and save.
One bot can serve every user on the instance; each user pairs their own
chat ID. The token is encrypted at rest.
## ntfy
[ntfy](https://ntfy.sh) is the most self-hoster-native option: a tiny
pub/sub push relay you can use hosted (`ntfy.sh`) or run yourself. You
subscribe to a **topic** in the ntfy app (iOS/Android) or a browser, and
HealthLog publishes to that topic.
1. Pick a hard-to-guess topic name (the topic *is* the access control on
public `ntfy.sh` — anyone who knows the string can read it, so treat
it like a secret).
2. Subscribe to that topic in the ntfy app or at `https://ntfy.sh/<topic>`.
3. In HealthLog open `/settings/notifications`, set the ntfy card's
**Server URL** (defaults to `https://ntfy.sh`; point it at your own
server if you self-host ntfy) and **Topic**, then save.
If your ntfy server requires auth, an access token can be attached; the
server URL is validated as a public host and dialled through the egress
guard (`src/lib/notifications/senders/ntfy.ts`).
## APNs — native iOS app only
Apple Push Notification service is used by **only** the native SwiftUI
iOS app (TestFlight / App Store build). PWA and browser users never touch
it. Setting it up requires a paid **Apple Developer Program** membership.
You do **not** need APNs to give iPhone users push: the installed PWA
gets every reminder over Web Push (see the iPhone caveat above). APNs adds
one Apple-platform exclusive the PWA cannot offer — the lock-screen Live
Activity — plus the tighter native delivery path. Reminders themselves
arrive on both.
If you do run the native app and want server-driven push, set the APNs
manifest (`src/lib/notifications/senders/apns.ts`) — three identifiers
plus exactly one key source:
| Variable | Purpose |
| --- | --- |
| `APNS_KEY_ID` | The `.p8` key's Key ID |
| `APNS_TEAM_ID` | Your Apple Developer Team ID |
| `APNS_BUNDLE_ID` | The app's bundle identifier |
| `APNS_KEY_B64` | The `.p8` PEM, base64-encoded (recommended key source) |
| `APNS_KEY` | OR the raw PEM body inline |
| `APNS_KEY_FILE` | OR a path to the `.p8` file |
APNs is **all-or-none**: either set the three IDs plus one key source, or
leave the whole block unset. A partial config is rejected so the channel
never silently half-works (`scripts/check-env.ts`). When more than one key
source is present, the precedence is `APNS_KEY_B64` > `APNS_KEY` >
`APNS_KEY_FILE`.
Two gotchas worth knowing before you blame a stale token:
- **Base64 the key.** Pasting a raw `.p8` PEM through some env-file
pipelines mangles its line breaks. `APNS_KEY_B64` (the PEM body,
base64-encoded) sidesteps that entirely and is the recommended source.
- **Scope the key "Sandbox & Production".** In the Apple Developer
Portal, a push key defaults to Sandbox-only. A Sandbox-only key fails
on production (TestFlight / App Store) device tokens with
`BadEnvironmentKeyInToken`. Confirm the key shows **Sandbox &
Production**; a wrongly scoped key cannot be reconfigured — re-issue it.
> **`clientManaged` is iOS-app-only.** The native iOS app can opt to run
> its own local medication reminders and ask the server to stop sending
> the duplicate `MEDICATION_REMINDER` push (the `clientManaged` flag on
> `PATCH /api/auth/me/notification-prefs`). This is a native-app contract:
> a web/PWA-only self-hoster never needs it and there is no toggle for it
> in the web UI. If you only run the PWA, ignore it entirely — your
> medication reminders come from the server cron over the channels above.
## Which channel should I pick?
| Your setup | Recommended channel |
| --- | --- |
| PWA on Android or desktop | **Web Push** — generate a VAPID keypair, subscribe, done. |
| PWA on an iPhone | **Web Push**, but install the PWA via *Add to Home Screen* first (iOS 16.4+). Reminders work; the lock-screen Live Activity needs the native app. |
| You want the native iOS app's full experience | **APNs** (paid Apple Developer account) *plus* Web Push as a fallback for any non-iOS device. |
| Headless / no browser, or you live in chat | **Telegram** or **ntfy** — neither needs a browser, both are free. ntfy is the most self-host-native; Telegram adds inline reminder action buttons. |
Mixing channels is fine and common — e.g. Web Push on the laptop and
ntfy on the phone. Each user configures their own set from
`/settings/notifications`.
+312 -10
View File
@@ -151,7 +151,8 @@
"sleep": "Schlafdauer",
"cycle": "Menstruationszyklus",
"empty": "Für den gewählten Zeitraum liegen noch keine Daten vor. Der Bericht enthält dennoch das Deckblatt.",
"unavailableHint": "Keine Daten in diesem Zeitraum"
"unavailableHint": "Keine Daten in diesem Zeitraum",
"labs": "Laborwerte"
},
"insurer": "Krankenkasse",
"insuranceNumber": "Versichertennr.",
@@ -182,7 +183,13 @@
"FOLLICULAR": "Follikelphase",
"OVULATORY": "Ovulation",
"LUTEAL": "Lutealphase"
}
},
"labsTitle": "Laborwerte",
"labsColAnalyte": "Biomarker",
"labsColValue": "Wert",
"labsColReference": "Referenz",
"labsColStatus": "Status",
"labsColDate": "Datum"
},
"errorBoundary": {
"title": "Da ist etwas schiefgelaufen",
@@ -221,6 +228,7 @@
"accountSettings": "Kontoeinstellungen",
"mood": "Stimmung",
"workouts": "Workouts",
"coach": "Coach",
"skipToContent": "Zum Inhalt springen",
"sidebar": "Sidebar",
"mainNavigation": "Hauptnavigation",
@@ -240,7 +248,10 @@
"mood": "Stimmung",
"moodDescription": "Stimmung und Tags erfassen"
},
"cycle": "Zyklus"
"cycle": "Zyklus",
"labs": "Laborwerte",
"recovery": "Erholung",
"vorsorge": "Vorsorge"
},
"auth": {
"login": "Anmelden",
@@ -482,6 +493,7 @@
"pulseLabel": "Puls (bpm)",
"valueWithUnit": "Wert ({unit})",
"timestamp": "Zeitpunkt",
"timestampBackdateHint": "Datum und Uhrzeit anpassen, um einen vergangenen Wert zu erfassen.",
"notesPlaceholder": "z.B. nach dem Essen",
"formReset": "Formular zurücksetzen",
"saveError": "Fehler beim Speichern",
@@ -541,6 +553,10 @@
"typeAverageHeartRate": "Durchschnittlicher Puls",
"typeMaxHeartRate": "Maximaler Puls",
"typeSleepDisturbanceCount": "Schlafstörungen",
"typeAnsCharge": "ANS-Ladung",
"typeCardioLoad": "Kardio-Belastung",
"typeSleepScore": "Schlafscore",
"typeBodyTemperatureDeviation": "Körpertemperaturabweichung",
"filterBySource": "Nach Quelle filtern",
"sourceComputed": "Berechnet",
"sourceWhoop": "WHOOP",
@@ -2619,7 +2635,9 @@
"sleepScoreBody": "Dein Schlafscore fasst eine ganze Nacht in einer Zahl zusammen — aus Dauer, Effizienz, Regelmäßigkeit und der Verteilung von Leicht-, Tief- und REM-Schlaf. Achte eher auf das Muster über Wochen als auf einen perfekten Wert in einer einzelnen Nacht.",
"recoveryBody": "Die Erholung zeigt, wie gut sich dein Körper regeneriert hat — gestützt auf nächtliche Signale wie Herzfrequenzvariabilität und Ruhepuls. Ein hoher Wert ist grünes Licht, mehr zu geben; ein niedrigerer deutet an, dass dein System noch aufbaut.",
"strainBody": "Die Belastung erfasst, wie viel kardiovaskuläre und körperliche Last du deinem Körper zugemutet hast — von Workouts bis zu einem aktiven Tag auf den Beinen. Sobald genügend Verlauf vorliegt, wird sie an deinem eigenen üblichen Aufwand gemessen, nicht an einem fremden Maßstab.",
"stressBody": "Stress ist hier eine Schätzung der körperlichen Last auf deinem Nervensystem, vor allem aus Mustern der Herzfrequenzvariabilität abgelesen — nicht daraus, wie du dich bewusst fühlst. Ein niedrigerer Wert deutet auf einen ruhigen, gut regulierten Zustand hin; ein höherer ist eine Einladung, Erholung einzuplanen."
"stressBody": "Stress ist hier eine Schätzung der körperlichen Last auf deinem Nervensystem, vor allem aus Mustern der Herzfrequenzvariabilität abgelesen — nicht daraus, wie du dich bewusst fühlst. Ein niedrigerer Wert deutet auf einen ruhigen, gut regulierten Zustand hin; ein höherer ist eine Einladung, Erholung einzuplanen.",
"recoveryPageTitle": "Erholung",
"recoveryPageBody": "Die Erholung fasst die nächtlichen Signale deines Wearables zusammen – Recharge, Belastung, Load sowie deine Herzfrequenz- und Energiewerte des Tages – und zeigt, wie gut sich dein Körper erholt hat. Achte eher auf das Muster über mehrere Tage als auf einen einzelnen Wert; ein schwächerer Wert ist eine Einladung, kürzerzutreten, kein Alarm."
},
"stats": {
"label": "Zusammenfassende Statistik",
@@ -3569,7 +3587,46 @@
"customize": "Insights anpassen",
"regenerateRateLimited": "Aktualisierungslimit erreicht — bitte später erneut versuchen.",
"regenerateUnavailable": "Keine neue Analyse erhalten — bitte später erneut versuchen.",
"regenerateError": "Aktualisierung läuft noch — bitte gleich erneut versuchen."
"regenerateError": "Aktualisierung läuft noch — bitte gleich erneut versuchen.",
"deviceScore": {
"learning": "Deine Basislinie wird noch gelernt – ein paar weitere Nächte vervollständigen den Verlauf.",
"average": "30-Tage-Durchschnitt {value}",
"unitScore": "Punkte",
"unitMinutes": "Min.",
"unitBpm": "bpm",
"unitKj": "kJ"
},
"sleepQuality": {
"title": "Schlafqualität",
"subtitle": "Nächtliche Werte deines Wearables"
},
"recovery": {
"title": "Erholung",
"description": "Wie gut sich dein Körper erholt hat – die Erholungs-, Belastungs- und Load-Signale, die dein Wearable über Nacht aufzeichnet.",
"emptyTitle": "Noch keine Erholungsdaten",
"empty": "Noch keine Erholungsdaten. Verbinde ein WHOOP-, Polar- oder Oura-Konto, dann füllen sich diese Werte, sobald dein Gerät synchronisiert.",
"recharge": {
"title": "Recharge",
"subtitle": "Ladung des autonomen Nervensystems"
},
"strain": {
"title": "Belastung & Load",
"subtitle": "Tägliche kardiovaskuläre Belastung"
},
"cardio": {
"title": "Herz & Energie pro Tag",
"subtitle": "Herz- und Energiewerte des ganzen Tages"
},
"scoreLink": {
"title": "Erholungs-Score",
"subtitle": "Sieh dir an, wie sich der nächtliche Score zusammensetzt",
"cta": "Ansehen"
}
},
"bodyTempDeviation": {
"title": "Körpertemperatur-Abweichung",
"subtitle": "Nächtliche Abweichung von deiner Basislinie"
}
},
"thresholds": {
"defaultLabel": "Default",
@@ -3648,7 +3705,7 @@
},
"goals": {
"title": "Was möchtest du erfassen?",
"body": "Wähle die Bereiche, die dir wichtig sind – das hilft dir zu entscheiden, was du verfolgen und als Nächstes einrichten willst.",
"body": "Wähle die Bereiche, die dir wichtig sind – wir zeigen sie zuerst auf deinem Dashboard an, und sie helfen dir zu entscheiden, was du als Nächstes einrichtest.",
"helpHint": "Ein bis drei Schwerpunkte funktionieren erfahrungsgemäß am besten – mehr geht aber jederzeit.",
"options": {
"weightManagement": "Gewicht im Griff",
@@ -3733,6 +3790,17 @@
"title": "Erkenne das Muster",
"body": "Tageszusammenfassungen, Trend-Diagramme und ein Coach, der zeigt was sich verändert hat — alles auf deinem Gerät."
}
},
"anamnesis": {
"title": "Zu deiner Gesundheit (optional)",
"subtitle": "Etwas Kontext, damit HealthLog deine Werte fairer einordnet.",
"intro": "Teile so viel oder so wenig, wie du möchtest – jedes Feld ist optional und jederzeit in den Einstellungen änderbar.",
"conditionsLabel": "Bestehende Erkrankungen",
"conditionsPlaceholder": "z. B. Bluthochdruck, Typ-2-Diabetes",
"conditionsHint": "Hilft HealthLog, deine Werte im Kontext einzuordnen, statt Erwartbares zu melden.",
"allergiesLabel": "Allergien oder Unverträglichkeiten",
"allergiesPlaceholder": "z. B. Penicillin, Laktose",
"privacyNote": "Verschlüsselt gespeichert und nur für besseren Kontext genutzt. Medikamente fragen wir hier nicht ab – die kannst du jederzeit auf der Medikamenten-Seite ergänzen."
}
},
"gettingStarted": {
@@ -3793,6 +3861,27 @@
"title": "Benachrichtigungen",
"description": "Live-Übersicht aller konfigurierten Kanäle."
},
"layout": {
"title": "Layout & Personalisierung",
"description": "Lege fest, wie deine App aufgebaut ist — ein Ort für Dashboard, Insights, Medikamente und Stimmung.",
"backToHub": "Zurück zu Layout & Personalisierung",
"dashboard": {
"title": "Dashboard",
"description": "Kachel-Layout und Reihenfolge."
},
"insights": {
"title": "Insights",
"description": "Reihenfolge der Übersicht und der Navigations-Pills."
},
"medications": {
"title": "Medikamente",
"description": "Listenansicht und Reihenfolge der Medikamente."
},
"mood": {
"title": "Stimmungs-Tags",
"description": "Gruppen, Tags und Reihenfolge der Tag-Auswahl."
}
},
"dashboard": {
"title": "Dashboard",
"description": "Kachel-Layout und Reihenfolge."
@@ -3920,6 +4009,7 @@
"import": {
"heading": "Import",
"description": "Importiere Daten aus einem Apple-Health-Export oder einer JSON-Datei. Bereits vorhandene Einträge werden übersprungen, ein erneuter Import ist also unbedenklich.",
"charCount": "{used} / {max} Zeichen",
"appleHealth": {
"title": "Apple Health",
"description": "Lade die export.zip aus der Health-App auf dem iPhone hoch (Profil → Alle Gesundheitsdaten exportieren).",
@@ -3958,6 +4048,27 @@
"failed": "Der Import ist fehlgeschlagen. Bitte erneut versuchen.",
"readFailed": "Diese Datei konnte nicht gelesen werden.",
"resultSummary": "{measurements} Messwerte und {moods} Stimmungseinträge importiert, {skipped} übersprungen."
},
"csv": {
"title": "CSV-Datei",
"description": "Messwerte aus einer Tabelle oder einem Messgerät-Export importieren. Eine Zeile pro Wert.",
"pasteLabel": "CSV einfügen",
"fileInputLabel": "CSV-Datei zum Importieren auswählen",
"schemaHint": "Spalten: type, value, unit, measuredAt (mit Zeitzonen-Offset) sowie optional glucoseContext, notes, externalId.",
"docsLink": "Spaltenschema und Beispiel ansehen.",
"uploadFile": "Datei hochladen",
"downloadExample": "Beispiel herunterladen",
"preview": "Vorschau",
"import": "Importieren",
"empty": "Bitte vor dem Import mindestens eine CSV-Zeile hinzufügen.",
"rateLimited": "Zu viele Importe in dieser Stunde (Limit 5). Bitte später erneut versuchen.",
"tooLarge": "Diese Datei ist zu groß (Limit 16 MB).",
"invalidPayload": "Die CSV konnte nicht gelesen werden. Bitte die Kopfzeile prüfen und erneut versuchen.",
"failed": "Der Import ist fehlgeschlagen. Bitte erneut versuchen.",
"readFailed": "Diese Datei konnte nicht gelesen werden.",
"previewSummary": "Vorschau: {inserted} würden importiert, {skipped} übersprungen.",
"resultSummary": "{inserted} neu importiert und {updated} aktualisiert, {skipped} übersprungen.",
"rowError": "Zeile {line}: {reason}"
}
}
},
@@ -3980,6 +4091,36 @@
"mood": {
"title": "Stimmungs-Tags",
"description": "Gruppen, Tags und Reihenfolge der Tag-Auswahl."
},
"reminders": {
"title": "Erinnerungen & Benachrichtigungen",
"description": "Ein Ort für jede Erinnerung — woran und wann erinnert wird — und die Kanäle, die sie zustellen.",
"categoriesGroup": "Erinnerungen",
"channelsGroup": "Zustellkanäle",
"medication": {
"title": "Medikamenten-Erinnerungen",
"description": "Einnahmezeiten und Zeitfenster, je Medikament eingestellt."
},
"vorsorge": {
"title": "Vorsorge",
"description": "Wiederkehrende Untersuchungen und Messungen, inklusive Ort."
},
"mood": {
"title": "Stimmungs-Check-in",
"description": "Eine tägliche Erinnerung, dein Befinden zu erfassen."
},
"lowStock": {
"title": "Reichweite bei Restbestand",
"description": "Werde gewarnt, bevor ein Medikament zur Neige geht."
},
"coach": {
"title": "Coach-Hinweis",
"description": "Proaktive Impulse vom KI-Coach."
},
"channels": {
"title": "Benachrichtigungskanäle",
"description": "APNs, Telegram, ntfy, Web Push, Webhook und E-Mail."
}
}
},
"profile": "Profil",
@@ -4067,6 +4208,17 @@
"ntfyTopic": "Topic",
"ntfyAuthToken": "Auth-Token (optional)",
"ntfyAuthTokenHint": "Nur nötig bei privaten Topics mit Zugangsschutz.",
"webhook": "Webhook",
"webhookDescription": "Benachrichtigungen per POST an eine beliebige URL — Gotify, Discord, Slack, Matrix oder Home Assistant.",
"webhookEnable": "Webhook aktivieren",
"webhookUrl": "Webhook-URL",
"webhookUrlHint": "Die vollständige URL für den POST. Muss öffentlich erreichbar sein.",
"webhookHeaderName": "Header-Name (optional)",
"webhookHeaderValue": "Header-Wert (optional)",
"email": "E-Mail",
"emailDescription": "Warnungen und Erinnerungen per E-Mail empfangen.",
"emailEnable": "E-Mail aktivieren",
"emailRecipient": "Empfängeradresse",
"webPush": "Browser-Push",
"webPushDescription": "Erhalte Benachrichtigungen direkt im Browser, auch wenn HealthLog nicht geöffnet ist.",
"webPushNotSupported": "Dein Browser unterstützt keine Push-Benachrichtigungen.",
@@ -4315,6 +4467,18 @@
"whoopConnect": "Mit WHOOP verbinden",
"whoopNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um WHOOP zu verbinden.",
"whoopBackfillInProgress": "Dein WHOOP-Verlauf wird im Hintergrund importiert…",
"whoopOauthConnected": "WHOOP verbunden — die erste Synchronisierung startet im Hintergrund.",
"whoopOauthFailed": "WHOOP-Verbindung fehlgeschlagen",
"whoopOauthError": {
"csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.",
"state": "Die Antwort von WHOOP passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.",
"expired": "Die Verbindungsanfrage ist abgelaufen, bevor sie abgeschlossen wurde. Bitte starte die Verbindung erneut.",
"cross_user": "Die Antwort gehört zu einer Anfrage eines anderen Kontos. Bitte starte die Verbindung aus deinem eigenen Konto erneut.",
"nocode": "WHOOP hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im WHOOP-Dialog.",
"nocreds": "WHOOP ist nicht konfiguriert. Hinterlege oben deine WHOOP-OAuth-Zugangsdaten und versuche es erneut.",
"token": "Der Token-Austausch mit WHOOP ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
},
"fitbit": "Google Health",
"fitbitTag": "Fitbit & Pixel",
"fitbitDescription": "Verbinde Google Health, um deine Fitbit- und Pixel-Watch-Werte zu synchronisieren — Gewicht, Körperfett, Ruhepuls, Sauerstoffsättigung, Herzfrequenzvariabilität und mehr.",
@@ -4342,6 +4506,18 @@
"fitbitConnect": "Mit Google Health verbinden",
"fitbitNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Google Health zu verbinden.",
"fitbitBackfillInProgress": "Dein Google-Health-Verlauf wird im Hintergrund importiert…",
"fitbitOauthConnected": "Google Health verbunden — die erste Synchronisierung startet im Hintergrund.",
"fitbitOauthFailed": "Google-Health-Verbindung fehlgeschlagen",
"fitbitOauthError": {
"csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.",
"state": "Die Antwort von Google passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.",
"expired": "Die Verbindungsanfrage ist abgelaufen, bevor sie abgeschlossen wurde. Bitte starte die Verbindung erneut.",
"cross_user": "Die Antwort gehört zu einer Anfrage eines anderen Kontos. Bitte starte die Verbindung aus deinem eigenen Konto erneut.",
"nocode": "Google hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Google-Dialog.",
"nocreds": "Google Health ist nicht konfiguriert. Hinterlege oben deine Google-OAuth-Zugangsdaten und versuche es erneut.",
"token": "Der Token-Austausch mit Google ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
},
"integrations": {
"withings": {
"reconnect": {
@@ -4537,7 +4713,8 @@
"includeAiSummary": "KI-Zusammenfassung anhängen (nicht klinisch validiert)",
"fhirNote": "Die strukturierte Ausgabe folgt dem FHIR-R4-Standard (ePA-kompatibel).",
"generate": "Erstellen",
"error": "Export fehlgeschlagen ({code})."
"error": "Export fehlgeschlagen ({code}).",
"labs": "Laborwerte"
},
"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.",
@@ -4603,7 +4780,14 @@
"polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen und Tagesaktivität zu synchronisieren.",
"polarViewData": "Schlaf & Erholung ansehen",
"polarHelp": "Melde dich bei Polar an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.",
"polarUnavailable": "Polar ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Polar-OAuth-Zugangsdaten zu hinterlegen.",
"polarCredentials": "API-Zugangsdaten",
"polarCredentialsHelp": "Optional: Registriere deine eigene Polar-AccessLink-App und füge hier Client ID und Client Secret ein. Leer lassen, um die gemeinsame Polar-App des Servers zu nutzen.",
"polarClientId": "Client ID",
"polarClientSecret": "Client Secret",
"polarCredentialsSaved": "Zugangsdaten gespeichert",
"polarCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen",
"polarSaveCredentials": "Speichern",
"polarUnavailable": "Polar ist auf diesem Server nicht konfiguriert. Füge oben deine eigenen Polar-OAuth-Zugangsdaten hinzu oder bitte deinen Administrator, die gemeinsame App zu konfigurieren.",
"polarConnect": "Polar verbinden",
"polarDisconnect": "Trennen",
"polarDisconnectTitle": "Polar trennen?",
@@ -4624,7 +4808,14 @@
"ouraDescription": "Verbinde dein Oura-Konto, um Readiness, Schlafphasen, HRV und Tagesaktivität zu synchronisieren.",
"ouraViewData": "Schlaf & Erholung ansehen",
"ouraHelp": "Melde dich bei Oura an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.",
"ouraUnavailable": "Oura ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Oura-OAuth-Zugangsdaten zu hinterlegen.",
"ouraCredentials": "API-Zugangsdaten",
"ouraCredentialsHelp": "Optional: Registriere deine eigene Oura-App und füge hier Client ID und Client Secret ein. Leer lassen, um die gemeinsame Oura-App des Servers zu nutzen.",
"ouraClientId": "Client ID",
"ouraClientSecret": "Client Secret",
"ouraCredentialsSaved": "Zugangsdaten gespeichert",
"ouraCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen",
"ouraSaveCredentials": "Speichern",
"ouraUnavailable": "Oura ist auf diesem Server nicht konfiguriert. Füge oben deine eigenen Oura-OAuth-Zugangsdaten hinzu oder bitte deinen Administrator, die gemeinsame App zu konfigurieren.",
"ouraConnect": "Oura verbinden",
"ouraDisconnect": "Trennen",
"ouraDisconnectTitle": "Oura trennen?",
@@ -4640,7 +4831,8 @@
"token": "Der Token-Austausch mit Oura ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
"rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.",
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
}
},
"integrationSetupGuide": "Einrichtungsanleitung"
},
"admin": {
"title": "Administration",
@@ -4763,6 +4955,13 @@
"webPushVapidPrivateKeyPlaceholder": "Optional: neuen Private Key eintragen",
"webPushVapidSubject": "VAPID Subject",
"webPushVapidSubjectPlaceholder": "mailto:deine-adresse@example.com",
"webPushVapidGenerate": "Schlüssel erzeugen",
"webPushVapidGenerating": "Wird erzeugt…",
"webPushVapidGenerated": "VAPID-Schlüssel erzeugt. Betreff speichern, um abzuschließen.",
"webPushVapidGenerateFailed": "VAPID-Schlüssel konnten nicht erzeugt werden.",
"webPushVapidGenerateConfirm": "VAPID-Schlüssel sind bereits konfiguriert. Ein neues Schlüsselpaar macht alle Browser-Push-Abos ungültig — jedes Gerät muss sich neu anmelden. Fortfahren?",
"webPushVapidGenerateConfirmTitle": "VAPID-Schlüssel neu erzeugen?",
"webPushVapidRegenerate": "Neu erzeugen",
"configured": "Konfiguriert",
"monitoringTestSuccess": "Testevent wurde gesendet",
"monitoringTestFailed": "Monitoring-Test fehlgeschlagen",
@@ -4930,6 +5129,15 @@
"authTokenRevoke": "Token widerrufen",
"medicationReminders": "Medikamentenerinnerungen",
"medicationRemindersDescription": "Schwellenwerte für die Farbübergänge der Erinnerungs-Badges auf den Medikamentenkarten.",
"notificationHealth": {
"title": "Zustellungsstatus",
"description": "Zustellungsergebnisse aller Nutzer der letzten 24 Stunden.",
"empty": "In diesem Zeitraum wurden keine Benachrichtigungen gesendet.",
"ok": "OK",
"error": "Fehlgeschlagen",
"skipped": "Übersprungen",
"autoDisabled": "Automatisch deaktivierte Kanäle"
},
"reminderLateMinutes": "Spät-Schwellenwert (Minuten)",
"reminderLateMinutesDescription": "Minuten nach Fenster-Ende bis der Badge von Gelb auf Orange wechselt.",
"reminderMissedMinutes": "Verpasst-Schwellenwert (Minuten)",
@@ -6208,5 +6416,99 @@
"deleteN": "Löschen ({count})",
"clearSelection": "Auswahl aufheben",
"selectionBarLabel": "Auswahlaktionen"
},
"measurementReminders": {
"sectionTitle": "Vorsorge",
"sectionDescription": "Erinnerungen, was wann und wo zu messen oder zu prüfen ist.",
"addButton": "Erinnerung hinzufügen",
"markDone": "Erledigt",
"delete": "Löschen",
"disabledBadge": "Aus",
"nextDue": {
"none": "Nicht geplant",
"today": "Heute fällig",
"tomorrow": "Morgen fällig",
"inDays": "In {days} Tagen"
},
"overdueByDays": "Überfällig seit {days} Tagen",
"cadence": {
"everyNDays": "Alle {days} Tage",
"custom": "Eigener Zeitplan"
},
"location": {
"prefix": "Bei: {location}"
},
"types": {
"WEIGHT": "Gewicht",
"BLOOD_PRESSURE_SYS": "Blutdruck",
"PULSE": "Puls",
"BLOOD_GLUCOSE": "Blutzucker",
"OXYGEN_SATURATION": "Sauerstoffsättigung",
"BODY_FAT": "Körperfett",
"BODY_TEMPERATURE": "Körpertemperatur"
},
"empty": {
"title": "Noch keine Erinnerungen",
"description": "Füge eine Erinnerung hinzu, um z. B. regelmäßig den Blutdruck zu messen oder ein jährliches Blutbild zu planen."
},
"form": {
"label": "Bezeichnung",
"labelPlaceholder": "z. B. Blutdruck messen",
"kind": "Art",
"kindType": "Mit einer Messung verknüpft",
"kindFreeText": "Freitext-Prüfung",
"measurementType": "Messwert",
"cadence": "Wiederholung",
"notifyHour": "Erinnern um",
"location": "Wo (optional)",
"locationPlaceholder": "z. B. Hausarzt, Labor",
"save": "Speichern"
},
"pushTitle": "Vorsorge-Erinnerung",
"pushBody": "Fällig: {label}",
"pushLocation": "(bei {location})"
},
"labs": {
"title": "Laborwerte",
"subtitle": "Blutwerte und Biomarker",
"addResult": "Hinzufügen",
"addDescription": "Erfasse einen Biomarker aus deinem Laborbefund.",
"addFirst": "Ersten Wert erfassen",
"emptyTitle": "Noch keine Laborwerte",
"emptyDescription": "Erfasse Blutwerte wie HbA1c, LDL oder Ferritin, um sie über die Zeit zu verfolgen und mit deiner Ärztin oder deinem Arzt zu teilen.",
"referenceLabel": "Referenz",
"readingsCount": "{count} Werte",
"deleteLatest": "Letzten Wert löschen",
"deletedToast": "Wert gelöscht",
"deleteError": "Wert konnte nicht gelöscht werden",
"loadError": "Laborwerte konnten nicht geladen werden",
"range": {
"inRange": "Im Bereich",
"below": "Unter Bereich",
"above": "Über Bereich"
},
"form": {
"analyte": "Biomarker",
"analytePlaceholder": "z. B. HbA1c, LDL, Ferritin",
"value": "Wert",
"unit": "Einheit",
"unitPlaceholder": "z. B. mg/dl, %, ng/ml",
"takenAt": "Probendatum",
"optionalGroup": "Optional",
"panel": "Panel",
"panelPlaceholder": "z. B. Großes Blutbild",
"referenceLow": "Referenz unten",
"referenceHigh": "Referenz oben",
"referenceLowPlaceholder": "Untere Grenze",
"referenceHighPlaceholder": "Obere Grenze",
"referenceHint": "Übernimm den Referenzbereich aus deinem Laborbefund. Beide Grenzen sind optional.",
"note": "Notiz",
"notePlaceholder": "Optionaler Kontext zu diesem Wert",
"requiredError": "Biomarker, Wert und Einheit sind erforderlich.",
"rangeOrderError": "Die untere Referenz darf nicht über der oberen liegen.",
"save": "Speichern",
"savedToast": "Wert gespeichert",
"saveError": "Wert konnte nicht gespeichert werden"
}
}
}
+313 -11
View File
@@ -151,7 +151,8 @@
"sleep": "Sleep duration",
"cycle": "Menstrual cycle",
"empty": "No data was recorded in this date range yet. The report will still include the cover page.",
"unavailableHint": "No data in this period"
"unavailableHint": "No data in this period",
"labs": "Lab results"
},
"insurer": "Health insurer",
"insuranceNumber": "Insurance no.",
@@ -182,7 +183,13 @@
"FOLLICULAR": "Follicular",
"OVULATORY": "Ovulatory",
"LUTEAL": "Luteal"
}
},
"labsTitle": "Lab results",
"labsColAnalyte": "Biomarker",
"labsColValue": "Value",
"labsColReference": "Reference",
"labsColStatus": "Status",
"labsColDate": "Date"
},
"errorBoundary": {
"title": "Something went wrong",
@@ -221,6 +228,7 @@
"accountSettings": "Account settings",
"mood": "Mood",
"workouts": "Workouts",
"coach": "Coach",
"skipToContent": "Skip to content",
"sidebar": "Sidebar",
"mainNavigation": "Main navigation",
@@ -240,7 +248,10 @@
"mood": "Mood",
"moodDescription": "Log your mood and tags"
},
"cycle": "Cycle"
"cycle": "Cycle",
"labs": "Labs",
"recovery": "Recovery",
"vorsorge": "Preventive Care"
},
"auth": {
"login": "Sign in",
@@ -482,6 +493,7 @@
"pulseLabel": "Pulse (bpm)",
"valueWithUnit": "Value ({unit})",
"timestamp": "Time",
"timestampBackdateHint": "Adjust the date and time to log a past reading.",
"notesPlaceholder": "e.g. after a meal",
"formReset": "Reset form",
"saveError": "Error saving",
@@ -541,6 +553,10 @@
"typeAverageHeartRate": "Average heart rate",
"typeMaxHeartRate": "Maximum heart rate",
"typeSleepDisturbanceCount": "Sleep disturbances",
"typeAnsCharge": "ANS charge",
"typeCardioLoad": "Cardio load",
"typeSleepScore": "Sleep score",
"typeBodyTemperatureDeviation": "Body temperature deviation",
"filterBySource": "Filter by source",
"sourceComputed": "Computed",
"sourceWhoop": "WHOOP",
@@ -2619,7 +2635,9 @@
"sleepScoreBody": "Your sleep score distils a whole night into one number — duration, efficiency, regularity, and the balance of light, deep, and REM. Watch the pattern across weeks rather than chasing a perfect score on any single night.",
"recoveryBody": "Recovery reflects how well your body has bounced back, drawing on overnight signals like heart-rate variability and resting heart rate. A strong score is a green light to push; a softer one suggests your system is still rebuilding.",
"strainBody": "Strain captures how much cardiovascular and physical load you have put on your body, from workouts to an active day on your feet. Once you have enough history it is judged against your own typical effort, not someone else's baseline.",
"stressBody": "Stress here is an estimate of physiological load on your nervous system, read mainly from heart-rate-variability patterns rather than how you consciously feel. A lower score points to a calm, well-regulated state; a higher one is an invitation to build in recovery."
"stressBody": "Stress here is an estimate of physiological load on your nervous system, read mainly from heart-rate-variability patterns rather than how you consciously feel. A lower score points to a calm, well-regulated state; a higher one is an invitation to build in recovery.",
"recoveryPageTitle": "Recovery",
"recoveryPageBody": "Recovery brings together the overnight signals your wearable records — recharge, strain, load and your day's heart-rate and energy totals — to show how well your body has bounced back. Read the pattern across days rather than any single number; a softer reading is an invitation to ease off, not an alarm."
},
"stats": {
"label": "Summary statistics",
@@ -3569,7 +3587,46 @@
"customize": "Customize Insights",
"regenerateRateLimited": "Refresh limit reached — please try again later.",
"regenerateUnavailable": "No fresh analysis arrived — please try again later.",
"regenerateError": "Still refreshing — please try again in a moment."
"regenerateError": "Still refreshing — please try again in a moment.",
"deviceScore": {
"learning": "Still learning your baseline — a few more nights will fill in the trend.",
"average": "30-day average {value}",
"unitScore": "score",
"unitMinutes": "min",
"unitBpm": "bpm",
"unitKj": "kJ"
},
"sleepQuality": {
"title": "Sleep quality",
"subtitle": "Nightly scores from your wearable"
},
"recovery": {
"title": "Recovery",
"description": "How well your body has bounced back — the recovery, strain and load signals your wearable records overnight.",
"emptyTitle": "No recovery data yet",
"empty": "No recovery data yet. Connect a WHOOP, Polar or Oura account and these scores fill in once your device syncs.",
"recharge": {
"title": "Recharge",
"subtitle": "Autonomic-nervous-system charge"
},
"strain": {
"title": "Strain & load",
"subtitle": "Daily cardiovascular load"
},
"cardio": {
"title": "Daily heart & energy",
"subtitle": "Whole-day cardiac and energy totals"
},
"scoreLink": {
"title": "Recovery score",
"subtitle": "See how the nightly score breaks down",
"cta": "View"
}
},
"bodyTempDeviation": {
"title": "Body temperature deviation",
"subtitle": "Overnight delta from your baseline"
}
},
"thresholds": {
"defaultLabel": "Default",
@@ -3648,7 +3705,7 @@
},
"goals": {
"title": "What would you like to track?",
"body": "Pick the areas you care about — it helps you decide what to track and set up next.",
"body": "Pick the areas you care about — we'll surface them first on your dashboard, and they help you decide what to set up next.",
"helpHint": "One to three priorities tend to work best — you can always add more later.",
"options": {
"weightManagement": "Weight management",
@@ -3733,6 +3790,17 @@
"title": "See the pattern",
"body": "Daily summaries, trend charts, and a coach that surfaces what changed — all on your own device."
}
},
"anamnesis": {
"title": "About your health (optional)",
"subtitle": "A little context so HealthLog reads your numbers more fairly.",
"intro": "Share as much or as little as you like — every field is optional and you can edit it anytime in Settings.",
"conditionsLabel": "Ongoing conditions",
"conditionsPlaceholder": "e.g. high blood pressure, type 2 diabetes",
"conditionsHint": "Helps HealthLog frame your readings in context rather than flagging the expected.",
"allergiesLabel": "Allergies or intolerances",
"allergiesPlaceholder": "e.g. penicillin, lactose",
"privacyNote": "Stored encrypted and used only to give you better context. We don't ask for your medications here — add those anytime from the Medications page."
}
},
"gettingStarted": {
@@ -3793,6 +3861,27 @@
"title": "Notifications",
"description": "Live overview of every configured channel."
},
"layout": {
"title": "Layout & Personalization",
"description": "Arrange how your app is laid out — one home for the dashboard, insights, medications, and mood.",
"backToHub": "Back to Layout & Personalization",
"dashboard": {
"title": "Dashboard",
"description": "Tile layout and order."
},
"insights": {
"title": "Insights",
"description": "Overview sections and navigation-pill order."
},
"medications": {
"title": "Medications",
"description": "List view and medication order."
},
"mood": {
"title": "Mood tags",
"description": "Groups, tags, and order of the mood picker."
}
},
"dashboard": {
"title": "Dashboard",
"description": "Tile layout and order."
@@ -3919,7 +4008,8 @@
},
"import": {
"heading": "Import",
"description": "Bring data into HealthLog from an Apple Health export or a JSON file. Imports skip rows that already exist, so re-running one is safe.",
"description": "Bring data into HealthLog from an Apple Health export, a JSON file, or a CSV. Imports skip rows that already exist, so re-running one is safe.",
"charCount": "{used} / {max} characters",
"appleHealth": {
"title": "Apple Health",
"description": "Upload the export.zip from the Health app on iPhone (Profile → Export All Health Data).",
@@ -3958,6 +4048,27 @@
"failed": "The import failed. Please try again.",
"readFailed": "Couldn't read that file.",
"resultSummary": "Imported {measurements} measurements and {moods} mood entries, {skipped} skipped."
},
"csv": {
"title": "CSV file",
"description": "Import measurements from a spreadsheet or meter export. One row per reading.",
"pasteLabel": "Paste CSV",
"fileInputLabel": "Choose a CSV file to import",
"schemaHint": "Columns: type, value, unit, measuredAt (with timezone offset), and optional glucoseContext, notes, externalId.",
"docsLink": "See the column schema and an example.",
"uploadFile": "Upload file",
"downloadExample": "Download example",
"preview": "Preview",
"import": "Import",
"empty": "Add at least one CSV row before importing.",
"rateLimited": "Too many imports this hour (limit 5). Please try again later.",
"tooLarge": "That file is too large (limit 16 MB).",
"invalidPayload": "The CSV could not be parsed. Check the header row and try again.",
"failed": "The import failed. Please try again.",
"readFailed": "Couldn't read that file.",
"previewSummary": "Preview: {inserted} would import, {skipped} skipped.",
"resultSummary": "Imported {inserted} new and updated {updated}, {skipped} skipped.",
"rowError": "Line {line}: {reason}"
}
}
},
@@ -3980,6 +4091,36 @@
"mood": {
"title": "Mood tags",
"description": "Groups, tags, and order of the mood picker."
},
"reminders": {
"title": "Reminders & Notifications",
"description": "One home for every reminder — what gets reminded and when — plus the channels that deliver them.",
"categoriesGroup": "Reminders",
"channelsGroup": "Delivery channels",
"medication": {
"title": "Medication reminders",
"description": "Dose times and windows, configured on each medication."
},
"vorsorge": {
"title": "Preventive care",
"description": "Recurring check-ups and measurements, with where to go."
},
"mood": {
"title": "Mood check-in",
"description": "A daily nudge to log how you feel."
},
"lowStock": {
"title": "Low-stock runway",
"description": "Get warned before a medication runs out."
},
"coach": {
"title": "Coach nudge",
"description": "Proactive prompts from the AI coach."
},
"channels": {
"title": "Notification channels",
"description": "APNs, Telegram, ntfy, Web Push, Webhook, and Email."
}
}
},
"profile": "Profile",
@@ -4067,6 +4208,17 @@
"ntfyTopic": "Topic",
"ntfyAuthToken": "Auth Token (optional)",
"ntfyAuthTokenHint": "Only needed for private topics with access control.",
"webhook": "Webhook",
"webhookDescription": "POST notifications to any URL — Gotify, Discord, Slack, Matrix, or Home Assistant.",
"webhookEnable": "Enable webhook",
"webhookUrl": "Webhook URL",
"webhookUrlHint": "The full URL to POST to. Must be publicly reachable.",
"webhookHeaderName": "Header name (optional)",
"webhookHeaderValue": "Header value (optional)",
"email": "Email",
"emailDescription": "Receive alerts and reminders by email.",
"emailEnable": "Enable email",
"emailRecipient": "Recipient address",
"webPush": "Browser Push",
"webPushDescription": "Receive notifications directly in your browser, even when HealthLog is not open.",
"webPushNotSupported": "Your browser does not support push notifications.",
@@ -4315,6 +4467,18 @@
"whoopConnect": "Connect with WHOOP",
"whoopNoCredentials": "Please enter your API credentials above to connect WHOOP.",
"whoopBackfillInProgress": "Importing your WHOOP history in the background…",
"whoopOauthConnected": "WHOOP connected — the first sync starts in the background.",
"whoopOauthFailed": "WHOOP connection failed",
"whoopOauthError": {
"csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.",
"state": "The response from WHOOP did not match any open connection request. Please start the connection again.",
"expired": "The connection request expired before it completed. Please start the connection again.",
"cross_user": "The response belongs to a request from a different account. Please restart the connection from your own account.",
"nocode": "WHOOP did not return an authorization code. Try again and allow access in the WHOOP dialog.",
"nocreds": "WHOOP is not configured. Add your WHOOP OAuth credentials above and try again.",
"token": "The token exchange with WHOOP failed. Please start the connection again.",
"generic": "Unknown error while connecting. Please start the connection again."
},
"fitbit": "Google Health",
"fitbitTag": "Fitbit & Pixel",
"fitbitDescription": "Connect Google Health to sync your Fitbit and Pixel Watch metrics — weight, body fat, resting heart rate, blood oxygen, heart-rate variability and more.",
@@ -4342,6 +4506,18 @@
"fitbitConnect": "Connect with Google Health",
"fitbitNoCredentials": "Please enter your API credentials above to connect Google Health.",
"fitbitBackfillInProgress": "Importing your Google Health history in the background…",
"fitbitOauthConnected": "Google Health connected — the first sync starts in the background.",
"fitbitOauthFailed": "Google Health connection failed",
"fitbitOauthError": {
"csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.",
"state": "The response from Google did not match any open connection request. Please start the connection again.",
"expired": "The connection request expired before it completed. Please start the connection again.",
"cross_user": "The response belongs to a request from a different account. Please restart the connection from your own account.",
"nocode": "Google did not return an authorization code. Try again and allow access in the Google dialog.",
"nocreds": "Google Health is not configured. Add your Google OAuth credentials above and try again.",
"token": "The token exchange with Google failed. Please start the connection again.",
"generic": "Unknown error while connecting. Please start the connection again."
},
"integrations": {
"withings": {
"reconnect": {
@@ -4537,7 +4713,8 @@
"includeAiSummary": "Append AI summary (not clinically validated)",
"fhirNote": "The structured output follows the FHIR R4 standard (ePA-compatible).",
"generate": "Generate",
"error": "Export failed ({code})."
"error": "Export failed ({code}).",
"labs": "Lab results"
},
"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.",
@@ -4603,7 +4780,14 @@
"polarDescription": "Connect your Polar account to sync Nightly Recharge recovery, sleep stages, and daily activity.",
"polarViewData": "View your sleep & recovery",
"polarHelp": "Sign in with Polar to grant access. Data is pulled in the background once an hour.",
"polarUnavailable": "Polar is not configured on this server. Ask your administrator to add the Polar OAuth credentials.",
"polarCredentials": "API Credentials",
"polarCredentialsHelp": "Optional: register your own Polar AccessLink app and paste its Client ID and Client Secret here. Leave blank to use the server's shared Polar app.",
"polarClientId": "Client ID",
"polarClientSecret": "Client Secret",
"polarCredentialsSaved": "Credentials saved",
"polarCredentialsSavedPlaceholder": "Saved — enter new to replace",
"polarSaveCredentials": "Save",
"polarUnavailable": "Polar is not configured on this server. Add your own Polar OAuth credentials above, or ask your administrator to configure the shared app.",
"polarConnect": "Connect Polar",
"polarDisconnect": "Disconnect",
"polarDisconnectTitle": "Disconnect Polar?",
@@ -4624,7 +4808,14 @@
"ouraDescription": "Connect your Oura account to sync readiness, sleep stages, HRV, and daily activity.",
"ouraViewData": "View your sleep & recovery",
"ouraHelp": "Sign in with Oura to grant access. Data is pulled in the background once an hour.",
"ouraUnavailable": "Oura is not configured on this server. Ask your administrator to add the Oura OAuth credentials.",
"ouraCredentials": "API Credentials",
"ouraCredentialsHelp": "Optional: register your own Oura app and paste its Client ID and Client Secret here. Leave blank to use the server's shared Oura app.",
"ouraClientId": "Client ID",
"ouraClientSecret": "Client Secret",
"ouraCredentialsSaved": "Credentials saved",
"ouraCredentialsSavedPlaceholder": "Saved — enter new to replace",
"ouraSaveCredentials": "Save",
"ouraUnavailable": "Oura is not configured on this server. Add your own Oura OAuth credentials above, or ask your administrator to configure the shared app.",
"ouraConnect": "Connect Oura",
"ouraDisconnect": "Disconnect",
"ouraDisconnectTitle": "Disconnect Oura?",
@@ -4640,7 +4831,8 @@
"token": "The token exchange with Oura failed. Please start the connection again.",
"rate_limited": "Too many connection attempts. Wait a moment and try again.",
"generic": "Unknown error while connecting. Please start the connection again."
}
},
"integrationSetupGuide": "Setup guide"
},
"admin": {
"title": "Administration",
@@ -4763,6 +4955,13 @@
"webPushVapidPrivateKeyPlaceholder": "Optional: enter a new private key",
"webPushVapidSubject": "VAPID subject",
"webPushVapidSubjectPlaceholder": "mailto:you@example.com",
"webPushVapidGenerate": "Generate keys",
"webPushVapidGenerating": "Generating…",
"webPushVapidGenerated": "VAPID keys generated. Save the subject to finish.",
"webPushVapidGenerateFailed": "Could not generate VAPID keys.",
"webPushVapidGenerateConfirm": "VAPID keys are already configured. Generating a new pair invalidates every browser push subscription — each device must re-subscribe. Continue?",
"webPushVapidGenerateConfirmTitle": "Regenerate VAPID keys?",
"webPushVapidRegenerate": "Regenerate",
"configured": "Configured",
"monitoringTestSuccess": "Test event sent",
"monitoringTestFailed": "Monitoring test failed",
@@ -4930,6 +5129,15 @@
"authTokenRevoke": "Token revoked",
"medicationReminders": "Medication Reminders",
"medicationRemindersDescription": "Thresholds for reminder badge color transitions on medication cards.",
"notificationHealth": {
"title": "Notification health",
"description": "Delivery results across all users over the last 24 hours.",
"empty": "No notifications sent in this window.",
"ok": "OK",
"error": "Failed",
"skipped": "Skipped",
"autoDisabled": "Auto-disabled channels"
},
"reminderLateMinutes": "Late threshold (minutes)",
"reminderLateMinutesDescription": "Minutes after window end until the badge changes from yellow to orange.",
"reminderMissedMinutes": "Missed threshold (minutes)",
@@ -6208,5 +6416,99 @@
"deleteN": "Delete ({count})",
"clearSelection": "Clear selection",
"selectionBarLabel": "Selection actions"
},
"measurementReminders": {
"sectionTitle": "Preventive care",
"sectionDescription": "Reminders for what to measure or check, when, and where.",
"addButton": "Add reminder",
"markDone": "Done",
"delete": "Delete",
"disabledBadge": "Off",
"nextDue": {
"none": "Not scheduled",
"today": "Due today",
"tomorrow": "Due tomorrow",
"inDays": "In {days} days"
},
"overdueByDays": "Overdue by {days} days",
"cadence": {
"everyNDays": "Every {days} days",
"custom": "Custom schedule"
},
"location": {
"prefix": "At: {location}"
},
"types": {
"WEIGHT": "Weight",
"BLOOD_PRESSURE_SYS": "Blood pressure",
"PULSE": "Pulse",
"BLOOD_GLUCOSE": "Blood glucose",
"OXYGEN_SATURATION": "Oxygen saturation",
"BODY_FAT": "Body fat",
"BODY_TEMPERATURE": "Body temperature"
},
"empty": {
"title": "No reminders yet",
"description": "Add a reminder to measure your blood pressure on a cadence, or to schedule an annual blood panel."
},
"form": {
"label": "Label",
"labelPlaceholder": "e.g. Measure blood pressure",
"kind": "Type",
"kindType": "Linked to a measurement",
"kindFreeText": "Free-text check",
"measurementType": "Measurement",
"cadence": "Repeat",
"notifyHour": "Remind at",
"location": "Where (optional)",
"locationPlaceholder": "e.g. GP, Lab",
"save": "Save"
},
"pushTitle": "Preventive-care reminder",
"pushBody": "Time for: {label}",
"pushLocation": "(at {location})"
},
"labs": {
"title": "Lab results",
"subtitle": "Bloodwork and biomarker readings",
"addResult": "Add",
"addDescription": "Record a biomarker reading from a lab report.",
"addFirst": "Add your first result",
"emptyTitle": "No lab results yet",
"emptyDescription": "Record bloodwork like HbA1c, LDL or ferritin to track it over time and share it with your doctor.",
"referenceLabel": "Reference",
"readingsCount": "{count} readings",
"deleteLatest": "Delete latest reading",
"deletedToast": "Result deleted",
"deleteError": "Could not delete the result",
"loadError": "Could not load lab results",
"range": {
"inRange": "In range",
"below": "Below range",
"above": "Above range"
},
"form": {
"analyte": "Biomarker",
"analytePlaceholder": "e.g. HbA1c, LDL, Ferritin",
"value": "Value",
"unit": "Unit",
"unitPlaceholder": "e.g. mg/dL, %, ng/mL",
"takenAt": "Sample date",
"optionalGroup": "Optional",
"panel": "Panel",
"panelPlaceholder": "e.g. Complete blood count",
"referenceLow": "Reference low",
"referenceHigh": "Reference high",
"referenceLowPlaceholder": "Lower bound",
"referenceHighPlaceholder": "Upper bound",
"referenceHint": "Copy the reference range from your lab report. Either bound is optional.",
"note": "Note",
"notePlaceholder": "Optional context for this result",
"requiredError": "Biomarker, value and unit are required.",
"rangeOrderError": "The reference low must not exceed the reference high.",
"save": "Save",
"savedToast": "Result saved",
"saveError": "Could not save the result"
}
}
}
+312 -10
View File
@@ -151,7 +151,8 @@
"sleep": "Duración del sueño",
"cycle": "Ciclo menstrual",
"empty": "No hay datos para el periodo seleccionado. El informe se generará con la portada.",
"unavailableHint": "Sin datos en este periodo"
"unavailableHint": "Sin datos en este periodo",
"labs": "Resultados de análisis"
},
"insurer": "Seguro médico",
"insuranceNumber": "N.º de asegurado",
@@ -182,7 +183,13 @@
"FOLLICULAR": "Folicular",
"OVULATORY": "Ovulatoria",
"LUTEAL": "Lútea"
}
},
"labsTitle": "Resultados de análisis",
"labsColAnalyte": "Biomarcador",
"labsColValue": "Valor",
"labsColReference": "Referencia",
"labsColStatus": "Estado",
"labsColDate": "Fecha"
},
"errorBoundary": {
"title": "Algo ha salido mal",
@@ -221,6 +228,7 @@
"accountSettings": "Ajustes de cuenta",
"mood": "Estado de ánimo",
"workouts": "Entrenamientos",
"coach": "Coach",
"skipToContent": "Saltar al contenido",
"sidebar": "Barra lateral",
"mainNavigation": "Navegación principal",
@@ -240,7 +248,10 @@
"mood": "Estado de ánimo",
"moodDescription": "Registrar estado de ánimo y etiquetas"
},
"cycle": "Ciclo"
"cycle": "Ciclo",
"labs": "Análisis",
"recovery": "Recuperación",
"vorsorge": "Prevención"
},
"auth": {
"login": "Iniciar sesión",
@@ -482,6 +493,7 @@
"pulseLabel": "Pulso (lpm)",
"valueWithUnit": "Valor ({unit})",
"timestamp": "Hora",
"timestampBackdateHint": "Ajusta la fecha y la hora para registrar una lectura pasada.",
"notesPlaceholder": "p. ej. después de comer",
"formReset": "Restablecer formulario",
"saveError": "Error al guardar",
@@ -541,6 +553,10 @@
"typeAverageHeartRate": "Frecuencia cardíaca media",
"typeMaxHeartRate": "Frecuencia cardíaca máxima",
"typeSleepDisturbanceCount": "Interrupciones del sueño",
"typeAnsCharge": "Carga del SNA",
"typeCardioLoad": "Carga cardio",
"typeSleepScore": "Puntuación de sueño",
"typeBodyTemperatureDeviation": "Desviación de temperatura corporal",
"filterBySource": "Filtrar por fuente",
"sourceComputed": "Calculado",
"sourceWhoop": "WHOOP",
@@ -2619,7 +2635,9 @@
"sleepScoreBody": "Tu puntuación de sueño resume toda una noche en un solo número — duración, eficiencia, regularidad y el equilibrio entre sueño ligero, profundo y REM. Fíjate en el patrón a lo largo de las semanas en lugar de perseguir una puntuación perfecta una sola noche.",
"recoveryBody": "La recuperación refleja lo bien que tu cuerpo se ha repuesto, a partir de señales nocturnas como la variabilidad de la frecuencia cardíaca y la frecuencia en reposo. Una puntuación alta es luz verde para exigirte; una más baja sugiere que tu organismo aún se está reconstruyendo.",
"strainBody": "El esfuerzo capta cuánta carga cardiovascular y física le has impuesto a tu cuerpo, desde los entrenamientos hasta un día activo de pie. Cuando ya hay suficiente historial, se valora frente a tu propio esfuerzo habitual, no frente a un estándar ajeno.",
"stressBody": "El estrés aquí es una estimación de la carga fisiológica sobre tu sistema nervioso, leída sobre todo a partir de patrones de variabilidad de la frecuencia cardíaca y no de cómo te sientes conscientemente. Una puntuación más baja apunta a un estado calmado y bien regulado; una más alta es una invitación a incorporar recuperación."
"stressBody": "El estrés aquí es una estimación de la carga fisiológica sobre tu sistema nervioso, leída sobre todo a partir de patrones de variabilidad de la frecuencia cardíaca y no de cómo te sientes conscientemente. Una puntuación más baja apunta a un estado calmado y bien regulado; una más alta es una invitación a incorporar recuperación.",
"recoveryPageTitle": "Recuperación",
"recoveryPageBody": "La recuperación reúne las señales nocturnas que registra tu wearable —recarga, esfuerzo, carga y los totales de frecuencia cardiaca y energía del día— para mostrar cómo se ha recuperado tu cuerpo. Fíjate en el patrón a lo largo de los días más que en un solo número; un valor más bajo es una invitación a bajar el ritmo, no una alarma."
},
"stats": {
"label": "Estadísticas resumidas",
@@ -3569,7 +3587,46 @@
"customize": "Personalizar Análisis",
"regenerateRateLimited": "Límite de actualizaciones alcanzado: inténtalo de nuevo más tarde.",
"regenerateUnavailable": "No llegó un análisis nuevo: inténtalo de nuevo más tarde.",
"regenerateError": "Aún se está actualizando: inténtalo de nuevo en un momento."
"regenerateError": "Aún se está actualizando: inténtalo de nuevo en un momento.",
"deviceScore": {
"learning": "Aún estamos aprendiendo tu línea base: unas noches más completarán la tendencia.",
"average": "Media de 30 días {value}",
"unitScore": "puntos",
"unitMinutes": "min",
"unitBpm": "ppm",
"unitKj": "kJ"
},
"sleepQuality": {
"title": "Calidad del sueño",
"subtitle": "Puntuaciones nocturnas de tu wearable"
},
"recovery": {
"title": "Recuperación",
"description": "Cómo se ha recuperado tu cuerpo: las señales de recuperación, esfuerzo y carga que tu wearable registra durante la noche.",
"emptyTitle": "Aún no hay datos de recuperación",
"empty": "Aún no hay datos de recuperación. Conecta una cuenta de WHOOP, Polar u Oura y estas puntuaciones se completarán cuando tu dispositivo sincronice.",
"recharge": {
"title": "Recarga",
"subtitle": "Carga del sistema nervioso autónomo"
},
"strain": {
"title": "Esfuerzo y carga",
"subtitle": "Carga cardiovascular diaria"
},
"cardio": {
"title": "Corazón y energía del día",
"subtitle": "Totales cardiacos y de energía de todo el día"
},
"scoreLink": {
"title": "Puntuación de recuperación",
"subtitle": "Mira cómo se descompone la puntuación nocturna",
"cta": "Ver"
}
},
"bodyTempDeviation": {
"title": "Desviación de la temperatura corporal",
"subtitle": "Variación nocturna respecto a tu línea base"
}
},
"thresholds": {
"defaultLabel": "Por defecto",
@@ -3648,7 +3705,7 @@
},
"goals": {
"title": "¿Qué te gustaría registrar?",
"body": "Elige las áreas que te interesan: te ayuda a decidir qué seguir y qué configurar a continuación.",
"body": "Elige las áreas que te interesan: las mostraremos primero en tu panel y te ayudarán a decidir qué configurar a continuación.",
"helpHint": "Una a tres prioridades suelen funcionar mejor — siempre podrás añadir más más adelante.",
"options": {
"weightManagement": "Control del peso",
@@ -3733,6 +3790,17 @@
"title": "Descubre los patrones",
"body": "Resúmenes diarios, gráficas de tendencias y un coach que destaca lo que cambió — todo en tu propio dispositivo."
}
},
"anamnesis": {
"title": "Sobre tu salud (opcional)",
"subtitle": "Un poco de contexto para que HealthLog interprete tus valores de forma más justa.",
"intro": "Comparte lo que quieras: cada campo es opcional y puedes editarlo cuando quieras en Ajustes.",
"conditionsLabel": "Afecciones actuales",
"conditionsPlaceholder": "p. ej. hipertensión, diabetes tipo 2",
"conditionsHint": "Ayuda a HealthLog a interpretar tus lecturas en contexto en lugar de marcar lo esperado.",
"allergiesLabel": "Alergias o intolerancias",
"allergiesPlaceholder": "p. ej. penicilina, lactosa",
"privacyNote": "Se almacena cifrado y solo se usa para darte mejor contexto. Aquí no te pedimos tu medicación: puedes añadirla cuando quieras desde la página de Medicación."
}
},
"gettingStarted": {
@@ -3793,6 +3861,27 @@
"title": "Notificaciones",
"description": "Vista en vivo de cada canal configurado."
},
"layout": {
"title": "Diseño y personalización",
"description": "Organiza cómo está dispuesta tu app — un único lugar para el panel, los análisis, los medicamentos y el ánimo.",
"backToHub": "Volver a Diseño y personalización",
"dashboard": {
"title": "Panel",
"description": "Diseño y orden de los mosaicos."
},
"insights": {
"title": "Análisis",
"description": "Orden de la vista general y de las pestañas de navegación."
},
"medications": {
"title": "Medicamentos",
"description": "Vista de lista y orden de los medicamentos."
},
"mood": {
"title": "Etiquetas de ánimo",
"description": "Grupos, etiquetas y orden del selector de ánimo."
}
},
"dashboard": {
"title": "Panel",
"description": "Diseño y orden de los mosaicos."
@@ -3920,6 +4009,7 @@
"import": {
"heading": "Importar",
"description": "Importa datos a HealthLog desde una exportación de Apple Health o un archivo JSON. La importación omite las filas que ya existen, así que repetirla es seguro.",
"charCount": "{used} / {max} caracteres",
"appleHealth": {
"title": "Apple Health",
"description": "Sube el archivo export.zip de la app Salud del iPhone (Perfil → Exportar todos los datos de salud).",
@@ -3958,6 +4048,27 @@
"failed": "La importación falló. Inténtalo de nuevo.",
"readFailed": "No se pudo leer ese archivo.",
"resultSummary": "{measurements} mediciones y {moods} entradas de ánimo importadas, {skipped} omitidas."
},
"csv": {
"title": "Archivo CSV",
"description": "Importa mediciones desde una hoja de cálculo o la exportación de un medidor. Una fila por lectura.",
"pasteLabel": "Pegar CSV",
"fileInputLabel": "Elegir un archivo CSV para importar",
"schemaHint": "Columnas: type, value, unit, measuredAt (con desfase de zona horaria) y opcionales glucoseContext, notes, externalId.",
"docsLink": "Ver el esquema de columnas y un ejemplo.",
"uploadFile": "Subir archivo",
"downloadExample": "Descargar ejemplo",
"preview": "Vista previa",
"import": "Importar",
"empty": "Añade al menos una fila CSV antes de importar.",
"rateLimited": "Demasiadas importaciones esta hora (límite 5). Inténtalo más tarde.",
"tooLarge": "Ese archivo es demasiado grande (límite 16 MB).",
"invalidPayload": "No se pudo analizar el CSV. Revisa la fila de encabezado e inténtalo de nuevo.",
"failed": "La importación falló. Inténtalo de nuevo.",
"readFailed": "No se pudo leer ese archivo.",
"previewSummary": "Vista previa: {inserted} se importarían, {skipped} omitidas.",
"resultSummary": "{inserted} nuevas importadas y {updated} actualizadas, {skipped} omitidas.",
"rowError": "Línea {line}: {reason}"
}
}
},
@@ -3980,6 +4091,36 @@
"mood": {
"title": "Etiquetas de ánimo",
"description": "Grupos, etiquetas y orden del selector de ánimo."
},
"reminders": {
"title": "Recordatorios y notificaciones",
"description": "Un único lugar para cada recordatorio — qué se recuerda y cuándo — y los canales que lo entregan.",
"categoriesGroup": "Recordatorios",
"channelsGroup": "Canales de entrega",
"medication": {
"title": "Recordatorios de medicación",
"description": "Horas y ventanas de dosis, configuradas en cada medicamento."
},
"vorsorge": {
"title": "Cuidado preventivo",
"description": "Revisiones y mediciones periódicas, con el lugar al que acudir."
},
"mood": {
"title": "Registro de ánimo",
"description": "Un aviso diario para registrar cómo te sientes."
},
"lowStock": {
"title": "Margen por bajo stock",
"description": "Recibe un aviso antes de que se acabe un medicamento."
},
"coach": {
"title": "Sugerencia del coach",
"description": "Indicaciones proactivas del coach de IA."
},
"channels": {
"title": "Canales de notificación",
"description": "APNs, Telegram, ntfy, Web Push, Webhook y correo."
}
}
},
"profile": "Perfil",
@@ -4067,6 +4208,17 @@
"ntfyTopic": "Topic",
"ntfyAuthToken": "Token de autenticación (opcional)",
"ntfyAuthTokenHint": "Solo necesario para temas privados con control de acceso.",
"webhook": "Webhook",
"webhookDescription": "Envía notificaciones por POST a cualquier URL: Gotify, Discord, Slack, Matrix o Home Assistant.",
"webhookEnable": "Activar webhook",
"webhookUrl": "URL del webhook",
"webhookUrlHint": "La URL completa para el POST. Debe ser accesible públicamente.",
"webhookHeaderName": "Nombre de cabecera (opcional)",
"webhookHeaderValue": "Valor de cabecera (opcional)",
"email": "Correo electrónico",
"emailDescription": "Recibe alertas y recordatorios por correo electrónico.",
"emailEnable": "Activar correo electrónico",
"emailRecipient": "Dirección del destinatario",
"webPush": "Push del navegador",
"webPushDescription": "Recibe notificaciones directamente en tu navegador, incluso cuando HealthLog no está abierto.",
"webPushNotSupported": "Tu navegador no admite notificaciones push.",
@@ -4315,6 +4467,18 @@
"whoopConnect": "Conectar con WHOOP",
"whoopNoCredentials": "Introduce tus credenciales de API arriba para conectar WHOOP.",
"whoopBackfillInProgress": "Importando tu historial de WHOOP en segundo plano…",
"whoopOauthConnected": "WHOOP conectado: la primera sincronización empieza en segundo plano.",
"whoopOauthFailed": "Error al conectar con WHOOP",
"whoopOauthError": {
"csrf1": "La verificación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.",
"state": "La respuesta de WHOOP no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.",
"expired": "La solicitud de conexión caducó antes de completarse. Inicia la conexión de nuevo.",
"cross_user": "La respuesta pertenece a una solicitud de otra cuenta. Reinicia la conexión desde tu propia cuenta.",
"nocode": "WHOOP no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de WHOOP.",
"nocreds": "WHOOP no está configurado. Añade tus credenciales OAuth de WHOOP arriba e inténtalo de nuevo.",
"token": "El intercambio de token con WHOOP falló. Inicia la conexión de nuevo.",
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
},
"fitbit": "Google Health",
"fitbitTag": "Fitbit y Pixel",
"fitbitDescription": "Conecta Google Health para sincronizar tus métricas de Fitbit y Pixel Watch: peso, grasa corporal, frecuencia cardíaca en reposo, saturación de oxígeno, variabilidad de la frecuencia cardíaca y más.",
@@ -4342,6 +4506,18 @@
"fitbitConnect": "Conectar con Google Health",
"fitbitNoCredentials": "Introduce tus credenciales de API arriba para conectar Google Health.",
"fitbitBackfillInProgress": "Importando tu historial de Google Health en segundo plano…",
"fitbitOauthConnected": "Google Health conectado: la primera sincronización empieza en segundo plano.",
"fitbitOauthFailed": "Error al conectar con Google Health",
"fitbitOauthError": {
"csrf1": "La verificación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.",
"state": "La respuesta de Google no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.",
"expired": "La solicitud de conexión caducó antes de completarse. Inicia la conexión de nuevo.",
"cross_user": "La respuesta pertenece a una solicitud de otra cuenta. Reinicia la conexión desde tu propia cuenta.",
"nocode": "Google no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Google.",
"nocreds": "Google Health no está configurado. Añade tus credenciales OAuth de Google arriba e inténtalo de nuevo.",
"token": "El intercambio de token con Google falló. Inicia la conexión de nuevo.",
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
},
"integrations": {
"withings": {
"reconnect": {
@@ -4537,7 +4713,8 @@
"includeAiSummary": "Añadir resumen de IA (no validado clínicamente)",
"fhirNote": "La salida estructurada sigue el estándar FHIR R4 (compatible con ePA).",
"generate": "Generar",
"error": "Error en la exportación ({code})."
"error": "Error en la exportación ({code}).",
"labs": "Resultados de análisis"
},
"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.",
@@ -4603,7 +4780,14 @@
"polarDescription": "Conecta tu cuenta de Polar para sincronizar la recuperación Nightly Recharge, las fases de sueño y la actividad diaria.",
"polarViewData": "Ver tu sueño y recuperación",
"polarHelp": "Inicia sesión en Polar para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.",
"polarUnavailable": "Polar no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Polar.",
"polarCredentials": "Credenciales de API",
"polarCredentialsHelp": "Opcional: registra tu propia app de Polar AccessLink y pega aquí su Client ID y Client Secret. Déjalo en blanco para usar la app compartida del servidor.",
"polarClientId": "Client ID",
"polarClientSecret": "Client Secret",
"polarCredentialsSaved": "Credenciales guardadas",
"polarCredentialsSavedPlaceholder": "Guardado — introduce nuevas para reemplazar",
"polarSaveCredentials": "Guardar",
"polarUnavailable": "Polar no está configurado en este servidor. Añade arriba tus propias credenciales OAuth de Polar, o pide a tu administrador que configure la app compartida.",
"polarConnect": "Conectar Polar",
"polarDisconnect": "Desconectar",
"polarDisconnectTitle": "¿Desconectar Polar?",
@@ -4624,7 +4808,14 @@
"ouraDescription": "Conecta tu cuenta de Oura para sincronizar readiness, fases de sueño, VFC y actividad diaria.",
"ouraViewData": "Ver tu sueño y recuperación",
"ouraHelp": "Inicia sesión en Oura para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.",
"ouraUnavailable": "Oura no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Oura.",
"ouraCredentials": "Credenciales de API",
"ouraCredentialsHelp": "Opcional: registra tu propia app de Oura y pega aquí su Client ID y Client Secret. Déjalo en blanco para usar la app compartida del servidor.",
"ouraClientId": "Client ID",
"ouraClientSecret": "Client Secret",
"ouraCredentialsSaved": "Credenciales guardadas",
"ouraCredentialsSavedPlaceholder": "Guardado — introduce nuevas para reemplazar",
"ouraSaveCredentials": "Guardar",
"ouraUnavailable": "Oura no está configurado en este servidor. Añade arriba tus propias credenciales OAuth de Oura, o pide a tu administrador que configure la app compartida.",
"ouraConnect": "Conectar Oura",
"ouraDisconnect": "Desconectar",
"ouraDisconnectTitle": "¿Desconectar Oura?",
@@ -4640,7 +4831,8 @@
"token": "El intercambio de token con Oura falló. Inicia la conexión de nuevo.",
"rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.",
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
}
},
"integrationSetupGuide": "Guía de configuración"
},
"admin": {
"title": "Administration",
@@ -4763,6 +4955,13 @@
"webPushVapidPrivateKeyPlaceholder": "Opcional: introduce una nueva clave privada",
"webPushVapidSubject": "Subject de VAPID",
"webPushVapidSubjectPlaceholder": "mailto:you@example.com",
"webPushVapidGenerate": "Generar claves",
"webPushVapidGenerating": "Generando…",
"webPushVapidGenerated": "Claves VAPID generadas. Guarda el asunto para terminar.",
"webPushVapidGenerateFailed": "No se pudieron generar las claves VAPID.",
"webPushVapidGenerateConfirm": "Las claves VAPID ya están configuradas. Generar un par nuevo invalida todas las suscripciones push del navegador: cada dispositivo debe volver a suscribirse. ¿Continuar?",
"webPushVapidGenerateConfirmTitle": "¿Regenerar las claves VAPID?",
"webPushVapidRegenerate": "Regenerar",
"configured": "Configurado",
"monitoringTestSuccess": "Evento de prueba enviado",
"monitoringTestFailed": "La prueba de monitorización falló",
@@ -4930,6 +5129,15 @@
"authTokenRevoke": "Token revocado",
"medicationReminders": "Recordatorios de medicación",
"medicationRemindersDescription": "Umbrales para los cambios de color de las insignias de recordatorio en las tarjetas de medicación.",
"notificationHealth": {
"title": "Estado de entrega",
"description": "Resultados de entrega de todos los usuarios en las últimas 24 horas.",
"empty": "No se enviaron notificaciones en este periodo.",
"ok": "OK",
"error": "Fallidas",
"skipped": "Omitidas",
"autoDisabled": "Canales desactivados automáticamente"
},
"reminderLateMinutes": "Umbral de retraso (minutos)",
"reminderLateMinutesDescription": "Minutos tras el fin de la ventana hasta que la insignia cambia de amarillo a naranja.",
"reminderMissedMinutes": "Umbral de omisión (minutos)",
@@ -6208,5 +6416,99 @@
"deleteN": "Eliminar ({count})",
"clearSelection": "Borrar selección",
"selectionBarLabel": "Acciones de selección"
},
"measurementReminders": {
"sectionTitle": "Prevención",
"sectionDescription": "Recordatorios de qué medir o revisar, cuándo y dónde.",
"addButton": "Añadir recordatorio",
"markDone": "Hecho",
"delete": "Eliminar",
"disabledBadge": "Apagado",
"nextDue": {
"none": "Sin programar",
"today": "Vence hoy",
"tomorrow": "Vence mañana",
"inDays": "En {days} días"
},
"overdueByDays": "Vencido hace {days} días",
"cadence": {
"everyNDays": "Cada {days} días",
"custom": "Programación personalizada"
},
"location": {
"prefix": "En: {location}"
},
"types": {
"WEIGHT": "Peso",
"BLOOD_PRESSURE_SYS": "Presión arterial",
"PULSE": "Pulso",
"BLOOD_GLUCOSE": "Glucosa en sangre",
"OXYGEN_SATURATION": "Saturación de oxígeno",
"BODY_FAT": "Grasa corporal",
"BODY_TEMPERATURE": "Temperatura corporal"
},
"empty": {
"title": "Aún no hay recordatorios",
"description": "Añade un recordatorio para medir la presión arterial con regularidad o programar un análisis de sangre anual."
},
"form": {
"label": "Etiqueta",
"labelPlaceholder": "p. ej. Medir la presión arterial",
"kind": "Tipo",
"kindType": "Vinculado a una medición",
"kindFreeText": "Comprobación de texto libre",
"measurementType": "Medición",
"cadence": "Repetir",
"notifyHour": "Recordar a las",
"location": "Dónde (opcional)",
"locationPlaceholder": "p. ej. Médico, Laboratorio",
"save": "Guardar"
},
"pushTitle": "Recordatorio de prevención",
"pushBody": "Toca: {label}",
"pushLocation": "(en {location})"
},
"labs": {
"title": "Resultados de análisis",
"subtitle": "Analíticas de sangre y biomarcadores",
"addResult": "Añadir",
"addDescription": "Registra un biomarcador a partir de un informe de laboratorio.",
"addFirst": "Añade tu primer resultado",
"emptyTitle": "Aún no hay resultados",
"emptyDescription": "Registra analíticas como HbA1c, LDL o ferritina para seguirlas a lo largo del tiempo y compartirlas con tu médico.",
"referenceLabel": "Referencia",
"readingsCount": "{count} valores",
"deleteLatest": "Eliminar el último valor",
"deletedToast": "Resultado eliminado",
"deleteError": "No se pudo eliminar el resultado",
"loadError": "No se pudieron cargar los resultados",
"range": {
"inRange": "En rango",
"below": "Por debajo",
"above": "Por encima"
},
"form": {
"analyte": "Biomarcador",
"analytePlaceholder": "p. ej. HbA1c, LDL, Ferritina",
"value": "Valor",
"unit": "Unidad",
"unitPlaceholder": "p. ej. mg/dL, %, ng/mL",
"takenAt": "Fecha de la muestra",
"optionalGroup": "Opcional",
"panel": "Panel",
"panelPlaceholder": "p. ej. Hemograma completo",
"referenceLow": "Referencia mínima",
"referenceHigh": "Referencia máxima",
"referenceLowPlaceholder": "Límite inferior",
"referenceHighPlaceholder": "Límite superior",
"referenceHint": "Copia el rango de referencia de tu informe de laboratorio. Cualquiera de los límites es opcional.",
"note": "Nota",
"notePlaceholder": "Contexto opcional para este resultado",
"requiredError": "El biomarcador, el valor y la unidad son obligatorios.",
"rangeOrderError": "La referencia mínima no puede superar la máxima.",
"save": "Guardar",
"savedToast": "Resultado guardado",
"saveError": "No se pudo guardar el resultado"
}
}
}
+312 -10
View File
@@ -151,7 +151,8 @@
"sleep": "Durée du sommeil",
"cycle": "Cycle menstruel",
"empty": "Pas de données sur la période sélectionnée. Le rapport conservera la page de garde.",
"unavailableHint": "Pas de données sur cette période"
"unavailableHint": "Pas de données sur cette période",
"labs": "Résultats d'analyses"
},
"insurer": "Assurance maladie",
"insuranceNumber": "N° d’assuré",
@@ -182,7 +183,13 @@
"FOLLICULAR": "Folliculaire",
"OVULATORY": "Ovulatoire",
"LUTEAL": "Lutéale"
}
},
"labsTitle": "Résultats d'analyses",
"labsColAnalyte": "Biomarqueur",
"labsColValue": "Valeur",
"labsColReference": "Référence",
"labsColStatus": "Statut",
"labsColDate": "Date"
},
"errorBoundary": {
"title": "Une erreur est survenue",
@@ -221,6 +228,7 @@
"accountSettings": "Paramètres du compte",
"mood": "Humeur",
"workouts": "Entraînements",
"coach": "Coach",
"skipToContent": "Aller au contenu",
"sidebar": "Barre latérale",
"mainNavigation": "Navigation principale",
@@ -240,7 +248,10 @@
"mood": "Humeur",
"moodDescription": "Enregistrer l'humeur et les tags"
},
"cycle": "Cycle"
"cycle": "Cycle",
"labs": "Analyses",
"recovery": "Récupération",
"vorsorge": "Prévention"
},
"auth": {
"login": "Se connecter",
@@ -482,6 +493,7 @@
"pulseLabel": "Pouls (bpm)",
"valueWithUnit": "Valeur ({unit})",
"timestamp": "Heure",
"timestampBackdateHint": "Ajustez la date et l'heure pour enregistrer un relevé passé.",
"notesPlaceholder": "p. ex. après le repas",
"formReset": "Réinitialiser le formulaire",
"saveError": "Erreur lors de l’enregistrement",
@@ -541,6 +553,10 @@
"typeAverageHeartRate": "Fréquence cardiaque moyenne",
"typeMaxHeartRate": "Fréquence cardiaque maximale",
"typeSleepDisturbanceCount": "Perturbations du sommeil",
"typeAnsCharge": "Charge SNA",
"typeCardioLoad": "Charge cardio",
"typeSleepScore": "Score de sommeil",
"typeBodyTemperatureDeviation": "Écart de température corporelle",
"filterBySource": "Filtrer par source",
"sourceComputed": "Calculé",
"sourceWhoop": "WHOOP",
@@ -2619,7 +2635,9 @@
"sleepScoreBody": "Votre score de sommeil résume toute une nuit en un seul chiffre — durée, efficacité, régularité des horaires et équilibre entre sommeil léger, profond et paradoxal. Observez la tendance sur plusieurs semaines plutôt que de courir après un score parfait sur une seule nuit.",
"recoveryBody": "La récupération reflète la qualité du rétablissement de votre corps, à partir de signaux nocturnes comme la variabilité de la fréquence cardiaque et la fréquence au repos. Un score élevé est un feu vert pour pousser ; un score plus bas suggère que votre organisme se reconstruit encore.",
"strainBody": "La charge mesure l'effort cardiovasculaire et physique que vous avez imposé à votre corps, des entraînements à une journée active debout. Dès qu'il y a assez d'historique, elle est évaluée par rapport à votre propre effort habituel, pas à un standard extérieur.",
"stressBody": "Le stress est ici une estimation de la charge physiologique sur votre système nerveux, lue surtout à partir des schémas de variabilité de la fréquence cardiaque et non de ce que vous ressentez consciemment. Un score plus bas indique un état calme et bien régulé ; un score plus élevé invite à prévoir de la récupération."
"stressBody": "Le stress est ici une estimation de la charge physiologique sur votre système nerveux, lue surtout à partir des schémas de variabilité de la fréquence cardiaque et non de ce que vous ressentez consciemment. Un score plus bas indique un état calme et bien régulé ; un score plus élevé invite à prévoir de la récupération.",
"recoveryPageTitle": "Récupération",
"recoveryPageBody": "La récupération rassemble les signaux nocturnes enregistrés par votre wearable — recharge, effort, charge ainsi que vos totaux de fréquence cardiaque et d'énergie du jour — pour montrer à quel point votre corps a récupéré. Observez la tendance sur plusieurs jours plutôt qu'un seul chiffre ; une valeur plus faible invite à lever le pied, ce n'est pas une alarme."
},
"stats": {
"label": "Statistiques récapitulatives",
@@ -3569,7 +3587,46 @@
"customize": "Personnaliser les Analyses",
"regenerateRateLimited": "Limite d'actualisation atteinte — réessayez plus tard.",
"regenerateUnavailable": "Aucune nouvelle analyse reçue — réessayez plus tard.",
"regenerateError": "Actualisation en cours — réessayez dans un instant."
"regenerateError": "Actualisation en cours — réessayez dans un instant.",
"deviceScore": {
"learning": "Nous apprenons encore votre référence : quelques nuits de plus compléteront la tendance.",
"average": "Moyenne sur 30 jours {value}",
"unitScore": "points",
"unitMinutes": "min",
"unitBpm": "bpm",
"unitKj": "kJ"
},
"sleepQuality": {
"title": "Qualité du sommeil",
"subtitle": "Scores nocturnes de votre wearable"
},
"recovery": {
"title": "Récupération",
"description": "À quel point votre corps a récupéré : les signaux de récupération, de charge et d'effort que votre wearable enregistre la nuit.",
"emptyTitle": "Pas encore de données de récupération",
"empty": "Pas encore de données de récupération. Connectez un compte WHOOP, Polar ou Oura et ces scores se rempliront dès que votre appareil se synchronise.",
"recharge": {
"title": "Recharge",
"subtitle": "Charge du système nerveux autonome"
},
"strain": {
"title": "Effort et charge",
"subtitle": "Charge cardiovasculaire quotidienne"
},
"cardio": {
"title": "Cœur et énergie du jour",
"subtitle": "Totaux cardiaques et énergétiques de la journée"
},
"scoreLink": {
"title": "Score de récupération",
"subtitle": "Voyez comment se décompose le score nocturne",
"cta": "Voir"
}
},
"bodyTempDeviation": {
"title": "Écart de température corporelle",
"subtitle": "Écart nocturne par rapport à votre référence"
}
},
"thresholds": {
"defaultLabel": "Par défaut",
@@ -3648,7 +3705,7 @@
},
"goals": {
"title": "Que souhaitez-vous suivre ?",
"body": "Sélectionnez les domaines qui comptent pour vous : cela vous aide à décider quoi suivre et configurer ensuite.",
"body": "Sélectionnez les domaines qui comptent pour vous : nous les afficherons en premier sur votre tableau de bord, et ils vous aident à décider quoi configurer ensuite.",
"helpHint": "Un à trois axes prioritaires fonctionnent généralement le mieux, mais vous pourrez en ajouter plus tard.",
"options": {
"weightManagement": "Gestion du poids",
@@ -3733,6 +3790,17 @@
"title": "Repérez les tendances",
"body": "Résumés quotidiens, graphiques de tendances et un coach qui met en avant ce qui a changé — le tout sur votre appareil."
}
},
"anamnesis": {
"title": "À propos de votre santé (facultatif)",
"subtitle": "Un peu de contexte pour que HealthLog interprète vos valeurs plus justement.",
"intro": "Partagez autant ou aussi peu que vous le souhaitez : chaque champ est facultatif et modifiable à tout moment dans les Réglages.",
"conditionsLabel": "Affections en cours",
"conditionsPlaceholder": "p. ex. hypertension, diabète de type 2",
"conditionsHint": "Aide HealthLog à replacer vos mesures dans leur contexte plutôt que de signaler l'attendu.",
"allergiesLabel": "Allergies ou intolérances",
"allergiesPlaceholder": "p. ex. pénicilline, lactose",
"privacyNote": "Stocké chiffré et utilisé uniquement pour un meilleur contexte. Nous ne demandons pas vos médicaments ici — ajoutez-les quand vous voulez depuis la page Médicaments."
}
},
"gettingStarted": {
@@ -3793,6 +3861,27 @@
"title": "Notifications",
"description": "Aperçu en direct de chaque canal configuré."
},
"layout": {
"title": "Disposition et personnalisation",
"description": "Organisez la disposition de votre application — un seul endroit pour le tableau de bord, les analyses, les médicaments et l’humeur.",
"backToHub": "Retour à Disposition et personnalisation",
"dashboard": {
"title": "Tableau de bord",
"description": "Disposition et ordre des tuiles."
},
"insights": {
"title": "Analyses",
"description": "Ordre de la vue d'ensemble et des onglets de navigation."
},
"medications": {
"title": "Médicaments",
"description": "Vue de la liste et ordre des médicaments."
},
"mood": {
"title": "Étiquettes d’humeur",
"description": "Groupes, étiquettes et ordre du sélecteur d’humeur."
}
},
"dashboard": {
"title": "Tableau de bord",
"description": "Disposition et ordre des tuiles."
@@ -3920,6 +4009,7 @@
"import": {
"heading": "Import",
"description": "Importez des données dans HealthLog depuis un export Apple Santé ou un fichier JSON. L'import ignore les lignes déjà présentes : le relancer est sans risque.",
"charCount": "{used} / {max} caractères",
"appleHealth": {
"title": "Apple Santé",
"description": "Importez le fichier export.zip de l'app Santé sur iPhone (Profil → Exporter toutes les données de santé).",
@@ -3958,6 +4048,27 @@
"failed": "L'import a échoué. Veuillez réessayer.",
"readFailed": "Impossible de lire ce fichier.",
"resultSummary": "{measurements} mesures et {moods} entrées d'humeur importées, {skipped} ignorées."
},
"csv": {
"title": "Fichier CSV",
"description": "Importez des mesures depuis un tableur ou l'export d'un appareil. Une ligne par relevé.",
"pasteLabel": "Coller le CSV",
"fileInputLabel": "Choisir un fichier CSV à importer",
"schemaHint": "Colonnes : type, value, unit, measuredAt (avec décalage horaire) et, en option, glucoseContext, notes, externalId.",
"docsLink": "Voir le schéma des colonnes et un exemple.",
"uploadFile": "Téléverser un fichier",
"downloadExample": "Télécharger un exemple",
"preview": "Aperçu",
"import": "Importer",
"empty": "Ajoutez au moins une ligne CSV avant d'importer.",
"rateLimited": "Trop d'imports cette heure (limite 5). Veuillez réessayer plus tard.",
"tooLarge": "Ce fichier est trop volumineux (limite 16 Mo).",
"invalidPayload": "Le CSV n'a pas pu être analysé. Vérifiez la ligne d'en-tête et réessayez.",
"failed": "L'import a échoué. Veuillez réessayer.",
"readFailed": "Impossible de lire ce fichier.",
"previewSummary": "Aperçu : {inserted} seraient importées, {skipped} ignorées.",
"resultSummary": "{inserted} nouvelles importées et {updated} mises à jour, {skipped} ignorées.",
"rowError": "Ligne {line} : {reason}"
}
}
},
@@ -3980,6 +4091,36 @@
"mood": {
"title": "Étiquettes d’humeur",
"description": "Groupes, étiquettes et ordre du sélecteur d’humeur."
},
"reminders": {
"title": "Rappels et notifications",
"description": "Un seul endroit pour chaque rappel — quoi et quand — ainsi que les canaux qui les délivrent.",
"categoriesGroup": "Rappels",
"channelsGroup": "Canaux de diffusion",
"medication": {
"title": "Rappels de médication",
"description": "Heures et fenêtres de prise, configurées sur chaque médicament."
},
"vorsorge": {
"title": "Prévention",
"description": "Bilans et mesures récurrents, avec le lieu où se rendre."
},
"mood": {
"title": "Suivi de l'humeur",
"description": "Un rappel quotidien pour noter votre ressenti."
},
"lowStock": {
"title": "Marge de stock faible",
"description": "Soyez averti avant qu'un médicament ne s'épuise."
},
"coach": {
"title": "Suggestion du coach",
"description": "Invitations proactives du coach IA."
},
"channels": {
"title": "Canaux de notification",
"description": "APNs, Telegram, ntfy, Web Push, Webhook et e-mail."
}
}
},
"profile": "Profil",
@@ -4067,6 +4208,17 @@
"ntfyTopic": "Topic",
"ntfyAuthToken": "Jeton d’authentification (facultatif)",
"ntfyAuthTokenHint": "Nécessaire uniquement pour les sujets privés avec contrôle d’accès.",
"webhook": "Webhook",
"webhookDescription": "Envoyer les notifications par POST vers n’importe quelle URL — Gotify, Discord, Slack, Matrix ou Home Assistant.",
"webhookEnable": "Activer le webhook",
"webhookUrl": "URL du webhook",
"webhookUrlHint": "L’URL complète pour le POST. Doit être accessible publiquement.",
"webhookHeaderName": "Nom d’en-tête (facultatif)",
"webhookHeaderValue": "Valeur d’en-tête (facultatif)",
"email": "E-mail",
"emailDescription": "Recevoir les alertes et les rappels par e-mail.",
"emailEnable": "Activer l’e-mail",
"emailRecipient": "Adresse du destinataire",
"webPush": "Push navigateur",
"webPushDescription": "Recevez des notifications directement dans votre navigateur, même lorsque HealthLog n’est pas ouvert.",
"webPushNotSupported": "Votre navigateur ne prend pas en charge les notifications push.",
@@ -4315,6 +4467,18 @@
"whoopConnect": "Se connecter avec WHOOP",
"whoopNoCredentials": "Veuillez saisir vos identifiants API ci-dessus pour connecter WHOOP.",
"whoopBackfillInProgress": "Importation de votre historique WHOOP en arrière-plan…",
"whoopOauthConnected": "WHOOP connecté — la première synchronisation démarre en arrière-plan.",
"whoopOauthFailed": "Échec de la connexion à WHOOP",
"whoopOauthError": {
"csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était absent. Veuillez relancer la connexion dans le même navigateur.",
"state": "La réponse de WHOOP ne correspondait à aucune demande de connexion en cours. Veuillez relancer la connexion.",
"expired": "La demande de connexion a expiré avant d'aboutir. Veuillez relancer la connexion.",
"cross_user": "La réponse appartient à une demande d'un autre compte. Veuillez relancer la connexion depuis votre propre compte.",
"nocode": "WHOOP n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre WHOOP.",
"nocreds": "WHOOP n'est pas configuré. Ajoutez vos identifiants OAuth WHOOP ci-dessus et réessayez.",
"token": "L'échange de jeton avec WHOOP a échoué. Veuillez relancer la connexion.",
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
},
"fitbit": "Google Health",
"fitbitTag": "Fitbit et Pixel",
"fitbitDescription": "Connectez Google Health pour synchroniser vos données Fitbit et Pixel Watch : poids, masse grasse, fréquence cardiaque au repos, saturation en oxygène, variabilité de la fréquence cardiaque et plus encore.",
@@ -4342,6 +4506,18 @@
"fitbitConnect": "Se connecter à Google Health",
"fitbitNoCredentials": "Veuillez saisir vos identifiants d'API ci-dessus pour connecter Google Health.",
"fitbitBackfillInProgress": "Importation de votre historique Google Health en arrière-plan…",
"fitbitOauthConnected": "Google Health connecté — la première synchronisation démarre en arrière-plan.",
"fitbitOauthFailed": "Échec de la connexion à Google Health",
"fitbitOauthError": {
"csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était absent. Veuillez relancer la connexion dans le même navigateur.",
"state": "La réponse de Google ne correspondait à aucune demande de connexion en cours. Veuillez relancer la connexion.",
"expired": "La demande de connexion a expiré avant d'aboutir. Veuillez relancer la connexion.",
"cross_user": "La réponse appartient à une demande d'un autre compte. Veuillez relancer la connexion depuis votre propre compte.",
"nocode": "Google n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Google.",
"nocreds": "Google Health n'est pas configuré. Ajoutez vos identifiants OAuth Google ci-dessus et réessayez.",
"token": "L'échange de jeton avec Google a échoué. Veuillez relancer la connexion.",
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
},
"integrations": {
"withings": {
"reconnect": {
@@ -4537,7 +4713,8 @@
"includeAiSummary": "Ajouter le résumé IA (non validé cliniquement)",
"fhirNote": "La sortie structurée suit la norme FHIR R4 (compatible ePA).",
"generate": "Générer",
"error": "Échec de l’export ({code})."
"error": "Échec de l’export ({code}).",
"labs": "Résultats d'analyses"
},
"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.",
@@ -4603,7 +4780,14 @@
"polarDescription": "Connectez votre compte Polar pour synchroniser la récupération Nightly Recharge, les phases de sommeil et l'activité quotidienne.",
"polarViewData": "Voir votre sommeil et récupération",
"polarHelp": "Connectez-vous à Polar pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.",
"polarUnavailable": "Polar n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Polar.",
"polarCredentials": "Identifiants API",
"polarCredentialsHelp": "Facultatif : enregistrez votre propre application Polar AccessLink et collez ici son Client ID et son Client Secret. Laissez vide pour utiliser l'application Polar partagée du serveur.",
"polarClientId": "Client ID",
"polarClientSecret": "Client Secret",
"polarCredentialsSaved": "Identifiants enregistrés",
"polarCredentialsSavedPlaceholder": "Enregistré — saisir de nouveaux pour remplacer",
"polarSaveCredentials": "Enregistrer",
"polarUnavailable": "Polar n'est pas configuré sur ce serveur. Ajoutez vos propres identifiants OAuth Polar ci-dessus, ou demandez à votre administrateur de configurer l'application partagée.",
"polarConnect": "Connecter Polar",
"polarDisconnect": "Déconnecter",
"polarDisconnectTitle": "Déconnecter Polar ?",
@@ -4624,7 +4808,14 @@
"ouraDescription": "Connectez votre compte Oura pour synchroniser le readiness, les phases de sommeil, la VFC et l'activité quotidienne.",
"ouraViewData": "Voir votre sommeil et récupération",
"ouraHelp": "Connectez-vous à Oura pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.",
"ouraUnavailable": "Oura n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Oura.",
"ouraCredentials": "Identifiants API",
"ouraCredentialsHelp": "Facultatif : enregistrez votre propre application Oura et collez ici son Client ID et son Client Secret. Laissez vide pour utiliser l'application Oura partagée du serveur.",
"ouraClientId": "Client ID",
"ouraClientSecret": "Client Secret",
"ouraCredentialsSaved": "Identifiants enregistrés",
"ouraCredentialsSavedPlaceholder": "Enregistré — saisir de nouveaux pour remplacer",
"ouraSaveCredentials": "Enregistrer",
"ouraUnavailable": "Oura n'est pas configuré sur ce serveur. Ajoutez vos propres identifiants OAuth Oura ci-dessus, ou demandez à votre administrateur de configurer l'application partagée.",
"ouraConnect": "Connecter Oura",
"ouraDisconnect": "Déconnecter",
"ouraDisconnectTitle": "Déconnecter Oura ?",
@@ -4640,7 +4831,8 @@
"token": "L'échange de jeton avec Oura a échoué. Veuillez relancer la connexion.",
"rate_limited": "Trop de tentatives de connexion. Patientez un instant et réessayez.",
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
}
},
"integrationSetupGuide": "Guide de configuration"
},
"admin": {
"title": "Administration",
@@ -4763,6 +4955,13 @@
"webPushVapidPrivateKeyPlaceholder": "Facultatif : saisissez une nouvelle clé privée",
"webPushVapidSubject": "Subject VAPID",
"webPushVapidSubjectPlaceholder": "mailto:you@example.com",
"webPushVapidGenerate": "Générer les clés",
"webPushVapidGenerating": "Génération…",
"webPushVapidGenerated": "Clés VAPID générées. Enregistrez le sujet pour terminer.",
"webPushVapidGenerateFailed": "Impossible de générer les clés VAPID.",
"webPushVapidGenerateConfirm": "Les clés VAPID sont déjà configurées. Générer une nouvelle paire invalide tous les abonnements push du navigateur — chaque appareil doit se réabonner. Continuer ?",
"webPushVapidGenerateConfirmTitle": "Régénérer les clés VAPID ?",
"webPushVapidRegenerate": "Régénérer",
"configured": "Configuré",
"monitoringTestSuccess": "Événement de test envoyé",
"monitoringTestFailed": "Échec du test de surveillance",
@@ -4930,6 +5129,15 @@
"authTokenRevoke": "Jeton révoqué",
"medicationReminders": "Rappels de médicaments",
"medicationRemindersDescription": "Seuils des transitions de couleur des badges de rappel sur les fiches de médicaments.",
"notificationHealth": {
"title": "État de distribution",
"description": "Résultats de distribution de tous les utilisateurs sur les dernières 24 heures.",
"empty": "Aucune notification envoyée sur cette période.",
"ok": "OK",
"error": "Échecs",
"skipped": "Ignorées",
"autoDisabled": "Canaux désactivés automatiquement"
},
"reminderLateMinutes": "Seuil de retard (minutes)",
"reminderLateMinutesDescription": "Minutes après la fin de la fenêtre avant que le badge passe du jaune à l'orange.",
"reminderMissedMinutes": "Seuil d'oubli (minutes)",
@@ -6208,5 +6416,99 @@
"deleteN": "Supprimer ({count})",
"clearSelection": "Effacer la sélection",
"selectionBarLabel": "Actions de sélection"
},
"measurementReminders": {
"sectionTitle": "Prévention",
"sectionDescription": "Rappels de ce qu'il faut mesurer ou vérifier, quand et où.",
"addButton": "Ajouter un rappel",
"markDone": "Terminé",
"delete": "Supprimer",
"disabledBadge": "Désactivé",
"nextDue": {
"none": "Non planifié",
"today": "À faire aujourd'hui",
"tomorrow": "À faire demain",
"inDays": "Dans {days} jours"
},
"overdueByDays": "En retard de {days} jours",
"cadence": {
"everyNDays": "Tous les {days} jours",
"custom": "Planning personnalisé"
},
"location": {
"prefix": "Chez : {location}"
},
"types": {
"WEIGHT": "Poids",
"BLOOD_PRESSURE_SYS": "Tension artérielle",
"PULSE": "Pouls",
"BLOOD_GLUCOSE": "Glycémie",
"OXYGEN_SATURATION": "Saturation en oxygène",
"BODY_FAT": "Masse grasse",
"BODY_TEMPERATURE": "Température corporelle"
},
"empty": {
"title": "Aucun rappel pour l'instant",
"description": "Ajoutez un rappel pour mesurer régulièrement votre tension ou planifier un bilan sanguin annuel."
},
"form": {
"label": "Libellé",
"labelPlaceholder": "ex. Mesurer la tension",
"kind": "Type",
"kindType": "Lié à une mesure",
"kindFreeText": "Vérification libre",
"measurementType": "Mesure",
"cadence": "Répétition",
"notifyHour": "Rappeler à",
"location": "Où (facultatif)",
"locationPlaceholder": "ex. Médecin, Laboratoire",
"save": "Enregistrer"
},
"pushTitle": "Rappel de prévention",
"pushBody": "C'est l'heure : {label}",
"pushLocation": "(chez {location})"
},
"labs": {
"title": "Résultats d'analyses",
"subtitle": "Analyses sanguines et biomarqueurs",
"addResult": "Ajouter",
"addDescription": "Enregistrez un biomarqueur à partir d'un compte rendu de laboratoire.",
"addFirst": "Ajouter votre premier résultat",
"emptyTitle": "Aucun résultat pour l'instant",
"emptyDescription": "Enregistrez des analyses comme l'HbA1c, le LDL ou la ferritine pour les suivre dans le temps et les partager avec votre médecin.",
"referenceLabel": "Référence",
"readingsCount": "{count} valeurs",
"deleteLatest": "Supprimer la dernière valeur",
"deletedToast": "Résultat supprimé",
"deleteError": "Impossible de supprimer le résultat",
"loadError": "Impossible de charger les résultats",
"range": {
"inRange": "Dans la norme",
"below": "Sous la norme",
"above": "Au-dessus de la norme"
},
"form": {
"analyte": "Biomarqueur",
"analytePlaceholder": "ex. HbA1c, LDL, Ferritine",
"value": "Valeur",
"unit": "Unité",
"unitPlaceholder": "ex. mg/dL, %, ng/mL",
"takenAt": "Date du prélèvement",
"optionalGroup": "Facultatif",
"panel": "Panel",
"panelPlaceholder": "ex. Numération formule sanguine",
"referenceLow": "Référence basse",
"referenceHigh": "Référence haute",
"referenceLowPlaceholder": "Borne inférieure",
"referenceHighPlaceholder": "Borne supérieure",
"referenceHint": "Reprenez l'intervalle de référence de votre compte rendu. Chaque borne est facultative.",
"note": "Note",
"notePlaceholder": "Contexte facultatif pour ce résultat",
"requiredError": "Le biomarqueur, la valeur et l'unité sont obligatoires.",
"rangeOrderError": "La référence basse ne doit pas dépasser la référence haute.",
"save": "Enregistrer",
"savedToast": "Résultat enregistré",
"saveError": "Impossible d'enregistrer le résultat"
}
}
}
+312 -10
View File
@@ -151,7 +151,8 @@
"sleep": "Durata del sonno",
"cycle": "Ciclo mestruale",
"empty": "Nessun dato per il periodo selezionato. Il referto manterrà comunque la copertina.",
"unavailableHint": "Nessun dato in questo periodo"
"unavailableHint": "Nessun dato in questo periodo",
"labs": "Risultati degli esami"
},
"insurer": "Assicurazione sanitaria",
"insuranceNumber": "N. assicurato",
@@ -182,7 +183,13 @@
"FOLLICULAR": "Follicolare",
"OVULATORY": "Ovulatoria",
"LUTEAL": "Luteale"
}
},
"labsTitle": "Risultati degli esami",
"labsColAnalyte": "Biomarcatore",
"labsColValue": "Valore",
"labsColReference": "Riferimento",
"labsColStatus": "Stato",
"labsColDate": "Data"
},
"errorBoundary": {
"title": "Qualcosa è andato storto",
@@ -221,6 +228,7 @@
"accountSettings": "Impostazioni account",
"mood": "Umore",
"workouts": "Allenamenti",
"coach": "Coach",
"skipToContent": "Vai al contenuto",
"sidebar": "Barra laterale",
"mainNavigation": "Navigazione principale",
@@ -240,7 +248,10 @@
"mood": "Umore",
"moodDescription": "Registra umore e tag"
},
"cycle": "Ciclo"
"cycle": "Ciclo",
"labs": "Esami",
"recovery": "Recupero",
"vorsorge": "Prevenzione"
},
"auth": {
"login": "Accedi",
@@ -482,6 +493,7 @@
"pulseLabel": "Pulsazioni (bpm)",
"valueWithUnit": "Valore ({unit})",
"timestamp": "Ora",
"timestampBackdateHint": "Regola data e ora per registrare una lettura passata.",
"notesPlaceholder": "es. dopo il pasto",
"formReset": "Reimposta modulo",
"saveError": "Errore durante il salvataggio",
@@ -541,6 +553,10 @@
"typeAverageHeartRate": "Frequenza cardiaca media",
"typeMaxHeartRate": "Frequenza cardiaca massima",
"typeSleepDisturbanceCount": "Disturbi del sonno",
"typeAnsCharge": "Carica SNA",
"typeCardioLoad": "Carico cardio",
"typeSleepScore": "Punteggio del sonno",
"typeBodyTemperatureDeviation": "Deviazione della temperatura corporea",
"filterBySource": "Filtra per fonte",
"sourceComputed": "Calcolato",
"sourceWhoop": "WHOOP",
@@ -2619,7 +2635,9 @@
"sleepScoreBody": "Il tuo punteggio del sonno riassume un'intera notte in un solo numero — durata, efficienza, regolarità degli orari ed equilibrio tra sonno leggero, profondo e REM. Osserva l'andamento nell'arco delle settimane invece di rincorrere un punteggio perfetto in una singola notte.",
"recoveryBody": "Il recupero riflette quanto bene il tuo corpo si è ripreso, basandosi su segnali notturni come la variabilità della frequenza cardiaca e la frequenza a riposo. Un punteggio alto è il via libera per spingere; uno più basso suggerisce che il tuo organismo si sta ancora ricostruendo.",
"strainBody": "Lo sforzo misura quanto carico cardiovascolare e fisico hai imposto al tuo corpo, dagli allenamenti a una giornata attiva in piedi. Quando c'è abbastanza storico, viene valutato rispetto al tuo sforzo abituale, non rispetto a uno standard esterno.",
"stressBody": "Lo stress qui è una stima del carico fisiologico sul tuo sistema nervoso, letto soprattutto dagli schemi di variabilità della frequenza cardiaca e non da come ti senti consapevolmente. Un punteggio più basso indica uno stato calmo e ben regolato; uno più alto è un invito a inserire del recupero."
"stressBody": "Lo stress qui è una stima del carico fisiologico sul tuo sistema nervoso, letto soprattutto dagli schemi di variabilità della frequenza cardiaca e non da come ti senti consapevolmente. Un punteggio più basso indica uno stato calmo e ben regolato; uno più alto è un invito a inserire del recupero.",
"recoveryPageTitle": "Recupero",
"recoveryPageBody": "Il recupero riunisce i segnali notturni registrati dal tuo wearable — ricarica, sforzo, carico e i totali di frequenza cardiaca ed energia della giornata — per mostrare quanto bene il tuo corpo si è ripreso. Guarda l'andamento su più giorni piuttosto che un singolo numero; un valore più basso è un invito a rallentare, non un allarme."
},
"stats": {
"label": "Statistiche riepilogative",
@@ -3569,7 +3587,46 @@
"customize": "Personalizza gli Approfondimenti",
"regenerateRateLimited": "Limite di aggiornamento raggiunto — riprova più tardi.",
"regenerateUnavailable": "Nessuna nuova analisi ricevuta — riprova più tardi.",
"regenerateError": "Aggiornamento ancora in corso — riprova tra un momento."
"regenerateError": "Aggiornamento ancora in corso — riprova tra un momento.",
"deviceScore": {
"learning": "Stiamo ancora imparando la tua linea di base: qualche notte in più completerà l'andamento.",
"average": "Media a 30 giorni {value}",
"unitScore": "punti",
"unitMinutes": "min",
"unitBpm": "bpm",
"unitKj": "kJ"
},
"sleepQuality": {
"title": "Qualità del sonno",
"subtitle": "Punteggi notturni del tuo wearable"
},
"recovery": {
"title": "Recupero",
"description": "Quanto bene il tuo corpo si è ripreso: i segnali di recupero, sforzo e carico che il tuo wearable registra durante la notte.",
"emptyTitle": "Ancora nessun dato di recupero",
"empty": "Ancora nessun dato di recupero. Collega un account WHOOP, Polar o Oura e questi punteggi si popoleranno appena il dispositivo si sincronizza.",
"recharge": {
"title": "Ricarica",
"subtitle": "Carica del sistema nervoso autonomo"
},
"strain": {
"title": "Sforzo e carico",
"subtitle": "Carico cardiovascolare giornaliero"
},
"cardio": {
"title": "Cuore ed energia del giorno",
"subtitle": "Totali cardiaci ed energetici dell'intera giornata"
},
"scoreLink": {
"title": "Punteggio di recupero",
"subtitle": "Vedi come si scompone il punteggio notturno",
"cta": "Apri"
}
},
"bodyTempDeviation": {
"title": "Deviazione della temperatura corporea",
"subtitle": "Scostamento notturno dalla tua linea di base"
}
},
"thresholds": {
"defaultLabel": "Predefinito",
@@ -3648,7 +3705,7 @@
},
"goals": {
"title": "Cosa vuoi monitorare?",
"body": "Scegli gli ambiti che ti interessano: ti aiuta a decidere cosa monitorare e configurare in seguito.",
"body": "Scegli gli ambiti che ti interessano: li mostreremo per primi nella tua dashboard e ti aiutano a decidere cosa configurare in seguito.",
"helpHint": "Da una a tre priorità funzionano di solito meglio — potrai aggiungerne altre in qualsiasi momento.",
"options": {
"weightManagement": "Gestione del peso",
@@ -3733,6 +3790,17 @@
"title": "Scopri lo schema",
"body": "Riepiloghi giornalieri, grafici di tendenza e un coach che evidenzia i cambiamenti — tutto sul tuo dispositivo."
}
},
"anamnesis": {
"title": "Sulla tua salute (facoltativo)",
"subtitle": "Un po' di contesto perché HealthLog interpreti i tuoi valori in modo più equo.",
"intro": "Condividi quanto vuoi: ogni campo è facoltativo e modificabile in qualsiasi momento nelle Impostazioni.",
"conditionsLabel": "Condizioni in corso",
"conditionsPlaceholder": "es. ipertensione, diabete di tipo 2",
"conditionsHint": "Aiuta HealthLog a inquadrare le tue misurazioni nel contesto invece di segnalare l'atteso.",
"allergiesLabel": "Allergie o intolleranze",
"allergiesPlaceholder": "es. penicillina, lattosio",
"privacyNote": "Memorizzato cifrato e usato solo per darti un contesto migliore. Qui non chiediamo i tuoi farmaci: aggiungili quando vuoi dalla pagina Farmaci."
}
},
"gettingStarted": {
@@ -3793,6 +3861,27 @@
"title": "Notifiche",
"description": "Panoramica in tempo reale di ogni canale configurato."
},
"layout": {
"title": "Layout e personalizzazione",
"description": "Organizza la disposizione della tua app — un unico posto per cruscotto, approfondimenti, farmaci e umore.",
"backToHub": "Torna a Layout e personalizzazione",
"dashboard": {
"title": "Cruscotto",
"description": "Layout e ordine dei riquadri."
},
"insights": {
"title": "Approfondimenti",
"description": "Ordine della panoramica e delle schede di navigazione."
},
"medications": {
"title": "Farmaci",
"description": "Vista elenco e ordine dei farmaci."
},
"mood": {
"title": "Tag dell’umore",
"description": "Gruppi, tag e ordine del selettore dell’umore."
}
},
"dashboard": {
"title": "Cruscotto",
"description": "Layout e ordine dei riquadri."
@@ -3920,6 +4009,7 @@
"import": {
"heading": "Importa",
"description": "Importa dati in HealthLog da un'esportazione di Apple Salute o da un file JSON. L'importazione salta le righe già presenti, quindi ripeterla è sicuro.",
"charCount": "{used} / {max} caratteri",
"appleHealth": {
"title": "Apple Salute",
"description": "Carica il file export.zip dall'app Salute su iPhone (Profilo → Esporta tutti i dati sanitari).",
@@ -3958,6 +4048,27 @@
"failed": "L'importazione non è riuscita. Riprova.",
"readFailed": "Impossibile leggere il file.",
"resultSummary": "{measurements} misurazioni e {moods} voci dell'umore importate, {skipped} saltate."
},
"csv": {
"title": "File CSV",
"description": "Importa misurazioni da un foglio di calcolo o dall'esportazione di un dispositivo. Una riga per lettura.",
"pasteLabel": "Incolla CSV",
"fileInputLabel": "Scegli un file CSV da importare",
"schemaHint": "Colonne: type, value, unit, measuredAt (con offset del fuso orario) e, facoltative, glucoseContext, notes, externalId.",
"docsLink": "Vedi lo schema delle colonne e un esempio.",
"uploadFile": "Carica file",
"downloadExample": "Scarica esempio",
"preview": "Anteprima",
"import": "Importa",
"empty": "Aggiungi almeno una riga CSV prima di importare.",
"rateLimited": "Troppe importazioni in questa ora (limite 5). Riprova più tardi.",
"tooLarge": "Questo file è troppo grande (limite 16 MB).",
"invalidPayload": "Impossibile analizzare il CSV. Controlla la riga di intestazione e riprova.",
"failed": "L'importazione non è riuscita. Riprova.",
"readFailed": "Impossibile leggere questo file.",
"previewSummary": "Anteprima: {inserted} verrebbero importate, {skipped} saltate.",
"resultSummary": "{inserted} nuove importate e {updated} aggiornate, {skipped} saltate.",
"rowError": "Riga {line}: {reason}"
}
}
},
@@ -3980,6 +4091,36 @@
"mood": {
"title": "Tag dell’umore",
"description": "Gruppi, tag e ordine del selettore dell’umore."
},
"reminders": {
"title": "Promemoria e notifiche",
"description": "Un'unica sede per ogni promemoria — cosa ricordare e quando — e i canali che li recapitano.",
"categoriesGroup": "Promemoria",
"channelsGroup": "Canali di consegna",
"medication": {
"title": "Promemoria dei farmaci",
"description": "Orari e finestre delle dosi, impostati su ogni farmaco."
},
"vorsorge": {
"title": "Prevenzione",
"description": "Controlli e misurazioni ricorrenti, con il luogo dove recarsi."
},
"mood": {
"title": "Check-in dell'umore",
"description": "Un promemoria quotidiano per registrare come ti senti."
},
"lowStock": {
"title": "Autonomia scorte",
"description": "Ricevi un avviso prima che un farmaco finisca."
},
"coach": {
"title": "Suggerimento del coach",
"description": "Stimoli proattivi dal coach IA."
},
"channels": {
"title": "Canali di notifica",
"description": "APNs, Telegram, ntfy, Web Push, Webhook ed e-mail."
}
}
},
"profile": "Profilo",
@@ -4067,6 +4208,17 @@
"ntfyTopic": "Topic",
"ntfyAuthToken": "Token di autenticazione (facoltativo)",
"ntfyAuthTokenHint": "Necessario solo per topic privati con controllo degli accessi.",
"webhook": "Webhook",
"webhookDescription": "Invia le notifiche via POST a qualsiasi URL — Gotify, Discord, Slack, Matrix o Home Assistant.",
"webhookEnable": "Attiva webhook",
"webhookUrl": "URL del webhook",
"webhookUrlHint": "L’URL completo per il POST. Deve essere raggiungibile pubblicamente.",
"webhookHeaderName": "Nome intestazione (opzionale)",
"webhookHeaderValue": "Valore intestazione (opzionale)",
"email": "Email",
"emailDescription": "Ricevi avvisi e promemoria via email.",
"emailEnable": "Attiva email",
"emailRecipient": "Indirizzo del destinatario",
"webPush": "Push del browser",
"webPushDescription": "Ricevi notifiche direttamente nel browser, anche quando HealthLog non è aperto.",
"webPushNotSupported": "Il tuo browser non supporta le notifiche push.",
@@ -4315,6 +4467,18 @@
"whoopConnect": "Connetti con WHOOP",
"whoopNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere WHOOP.",
"whoopBackfillInProgress": "Importazione dello storico WHOOP in background…",
"whoopOauthConnected": "WHOOP collegato — la prima sincronizzazione parte in background.",
"whoopOauthFailed": "Connessione a WHOOP non riuscita",
"whoopOauthError": {
"csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.",
"state": "La risposta di WHOOP non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.",
"expired": "La richiesta di connessione è scaduta prima di completarsi. Riavvia la connessione.",
"cross_user": "La risposta appartiene a una richiesta di un altro account. Riavvia la connessione dal tuo account.",
"nocode": "WHOOP non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di WHOOP.",
"nocreds": "WHOOP non è configurato. Aggiungi le tue credenziali OAuth di WHOOP qui sopra e riprova.",
"token": "Lo scambio del token con WHOOP è fallito. Riavvia la connessione.",
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
},
"fitbit": "Google Health",
"fitbitTag": "Fitbit e Pixel",
"fitbitDescription": "Collega Google Health per sincronizzare i dati di Fitbit e Pixel Watch: peso, massa grassa, frequenza cardiaca a riposo, saturazione di ossigeno, variabilità della frequenza cardiaca e altro.",
@@ -4342,6 +4506,18 @@
"fitbitConnect": "Connetti con Google Health",
"fitbitNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Google Health.",
"fitbitBackfillInProgress": "Importazione dello storico Google Health in background…",
"fitbitOauthConnected": "Google Health collegato — la prima sincronizzazione parte in background.",
"fitbitOauthFailed": "Connessione a Google Health non riuscita",
"fitbitOauthError": {
"csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.",
"state": "La risposta di Google non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.",
"expired": "La richiesta di connessione è scaduta prima di completarsi. Riavvia la connessione.",
"cross_user": "La risposta appartiene a una richiesta di un altro account. Riavvia la connessione dal tuo account.",
"nocode": "Google non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di Google.",
"nocreds": "Google Health non è configurato. Aggiungi le tue credenziali OAuth di Google qui sopra e riprova.",
"token": "Lo scambio del token con Google è fallito. Riavvia la connessione.",
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
},
"integrations": {
"withings": {
"reconnect": {
@@ -4537,7 +4713,8 @@
"includeAiSummary": "Aggiungi riepilogo IA (non validato clinicamente)",
"fhirNote": "L’output strutturato segue lo standard FHIR R4 (compatibile ePA).",
"generate": "Genera",
"error": "Esportazione non riuscita ({code})."
"error": "Esportazione non riuscita ({code}).",
"labs": "Risultati degli esami"
},
"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.",
@@ -4603,7 +4780,14 @@
"polarDescription": "Collega il tuo account Polar per sincronizzare il recupero Nightly Recharge, le fasi del sonno e l'attività giornaliera.",
"polarViewData": "Vedi sonno e recupero",
"polarHelp": "Accedi a Polar per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.",
"polarUnavailable": "Polar non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Polar.",
"polarCredentials": "Credenziali API",
"polarCredentialsHelp": "Facoltativo: registra la tua app Polar AccessLink e incolla qui il suo Client ID e Client Secret. Lascia vuoto per usare l'app Polar condivisa del server.",
"polarClientId": "Client ID",
"polarClientSecret": "Client Secret",
"polarCredentialsSaved": "Credenziali salvate",
"polarCredentialsSavedPlaceholder": "Salvato — inserisci nuove per sostituire",
"polarSaveCredentials": "Salva",
"polarUnavailable": "Polar non è configurato su questo server. Aggiungi sopra le tue credenziali OAuth di Polar oppure chiedi al tuo amministratore di configurare l'app condivisa.",
"polarConnect": "Collega Polar",
"polarDisconnect": "Disconnetti",
"polarDisconnectTitle": "Disconnettere Polar?",
@@ -4624,7 +4808,14 @@
"ouraDescription": "Collega il tuo account Oura per sincronizzare readiness, fasi del sonno, HRV e attività giornaliera.",
"ouraViewData": "Vedi sonno e recupero",
"ouraHelp": "Accedi a Oura per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.",
"ouraUnavailable": "Oura non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Oura.",
"ouraCredentials": "Credenziali API",
"ouraCredentialsHelp": "Facoltativo: registra la tua app Oura e incolla qui il suo Client ID e Client Secret. Lascia vuoto per usare l'app Oura condivisa del server.",
"ouraClientId": "Client ID",
"ouraClientSecret": "Client Secret",
"ouraCredentialsSaved": "Credenziali salvate",
"ouraCredentialsSavedPlaceholder": "Salvato — inserisci nuove per sostituire",
"ouraSaveCredentials": "Salva",
"ouraUnavailable": "Oura non è configurato su questo server. Aggiungi sopra le tue credenziali OAuth di Oura oppure chiedi al tuo amministratore di configurare l'app condivisa.",
"ouraConnect": "Collega Oura",
"ouraDisconnect": "Disconnetti",
"ouraDisconnectTitle": "Disconnettere Oura?",
@@ -4640,7 +4831,8 @@
"token": "Lo scambio del token con Oura è fallito. Riavvia la connessione.",
"rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.",
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
}
},
"integrationSetupGuide": "Guida alla configurazione"
},
"admin": {
"title": "Administration",
@@ -4763,6 +4955,13 @@
"webPushVapidPrivateKeyPlaceholder": "Facoltativo: inserisci una nuova chiave privata",
"webPushVapidSubject": "Subject VAPID",
"webPushVapidSubjectPlaceholder": "mailto:you@example.com",
"webPushVapidGenerate": "Genera chiavi",
"webPushVapidGenerating": "Generazione…",
"webPushVapidGenerated": "Chiavi VAPID generate. Salva l'oggetto per completare.",
"webPushVapidGenerateFailed": "Impossibile generare le chiavi VAPID.",
"webPushVapidGenerateConfirm": "Le chiavi VAPID sono già configurate. Generare una nuova coppia invalida tutte le sottoscrizioni push del browser: ogni dispositivo deve ri-sottoscriversi. Continuare?",
"webPushVapidGenerateConfirmTitle": "Rigenerare le chiavi VAPID?",
"webPushVapidRegenerate": "Rigenera",
"configured": "Configurato",
"monitoringTestSuccess": "Evento di prova inviato",
"monitoringTestFailed": "Test di monitoraggio non riuscito",
@@ -4930,6 +5129,15 @@
"authTokenRevoke": "Token revocato",
"medicationReminders": "Promemoria dei farmaci",
"medicationRemindersDescription": "Soglie per i cambi di colore dei badge di promemoria sulle schede dei farmaci.",
"notificationHealth": {
"title": "Stato di consegna",
"description": "Risultati di consegna di tutti gli utenti nelle ultime 24 ore.",
"empty": "Nessuna notifica inviata in questo periodo.",
"ok": "OK",
"error": "Non riuscite",
"skipped": "Saltate",
"autoDisabled": "Canali disattivati automaticamente"
},
"reminderLateMinutes": "Soglia di ritardo (minuti)",
"reminderLateMinutesDescription": "Minuti dopo la fine della finestra prima che il badge passi da giallo ad arancione.",
"reminderMissedMinutes": "Soglia di mancata assunzione (minuti)",
@@ -6208,5 +6416,99 @@
"deleteN": "Elimina ({count})",
"clearSelection": "Cancella selezione",
"selectionBarLabel": "Azioni di selezione"
},
"measurementReminders": {
"sectionTitle": "Prevenzione",
"sectionDescription": "Promemoria su cosa misurare o controllare, quando e dove.",
"addButton": "Aggiungi promemoria",
"markDone": "Fatto",
"delete": "Elimina",
"disabledBadge": "Disattivato",
"nextDue": {
"none": "Non pianificato",
"today": "In scadenza oggi",
"tomorrow": "In scadenza domani",
"inDays": "Tra {days} giorni"
},
"overdueByDays": "In ritardo di {days} giorni",
"cadence": {
"everyNDays": "Ogni {days} giorni",
"custom": "Pianificazione personalizzata"
},
"location": {
"prefix": "Presso: {location}"
},
"types": {
"WEIGHT": "Peso",
"BLOOD_PRESSURE_SYS": "Pressione sanguigna",
"PULSE": "Battito",
"BLOOD_GLUCOSE": "Glicemia",
"OXYGEN_SATURATION": "Saturazione di ossigeno",
"BODY_FAT": "Grasso corporeo",
"BODY_TEMPERATURE": "Temperatura corporea"
},
"empty": {
"title": "Ancora nessun promemoria",
"description": "Aggiungi un promemoria per misurare regolarmente la pressione o pianificare un esame del sangue annuale."
},
"form": {
"label": "Etichetta",
"labelPlaceholder": "es. Misurare la pressione",
"kind": "Tipo",
"kindType": "Collegato a una misurazione",
"kindFreeText": "Controllo a testo libero",
"measurementType": "Misurazione",
"cadence": "Ripeti",
"notifyHour": "Ricorda alle",
"location": "Dove (facoltativo)",
"locationPlaceholder": "es. Medico, Laboratorio",
"save": "Salva"
},
"pushTitle": "Promemoria di prevenzione",
"pushBody": "È ora di: {label}",
"pushLocation": "(presso {location})"
},
"labs": {
"title": "Risultati degli esami",
"subtitle": "Analisi del sangue e biomarcatori",
"addResult": "Aggiungi",
"addDescription": "Registra un biomarcatore da un referto di laboratorio.",
"addFirst": "Aggiungi il primo risultato",
"emptyTitle": "Ancora nessun risultato",
"emptyDescription": "Registra esami come HbA1c, LDL o ferritina per seguirli nel tempo e condividerli con il tuo medico.",
"referenceLabel": "Riferimento",
"readingsCount": "{count} valori",
"deleteLatest": "Elimina l'ultimo valore",
"deletedToast": "Risultato eliminato",
"deleteError": "Impossibile eliminare il risultato",
"loadError": "Impossibile caricare i risultati",
"range": {
"inRange": "Nella norma",
"below": "Sotto la norma",
"above": "Sopra la norma"
},
"form": {
"analyte": "Biomarcatore",
"analytePlaceholder": "es. HbA1c, LDL, Ferritina",
"value": "Valore",
"unit": "Unità",
"unitPlaceholder": "es. mg/dL, %, ng/mL",
"takenAt": "Data del prelievo",
"optionalGroup": "Facoltativo",
"panel": "Pannello",
"panelPlaceholder": "es. Emocromo completo",
"referenceLow": "Riferimento minimo",
"referenceHigh": "Riferimento massimo",
"referenceLowPlaceholder": "Limite inferiore",
"referenceHighPlaceholder": "Limite superiore",
"referenceHint": "Copia l'intervallo di riferimento dal referto. Ciascun limite è facoltativo.",
"note": "Nota",
"notePlaceholder": "Contesto facoltativo per questo risultato",
"requiredError": "Biomarcatore, valore e unità sono obbligatori.",
"rangeOrderError": "Il riferimento minimo non può superare quello massimo.",
"save": "Salva",
"savedToast": "Risultato salvato",
"saveError": "Impossibile salvare il risultato"
}
}
}
+312 -10
View File
@@ -151,7 +151,8 @@
"sleep": "Czas snu",
"cycle": "Cykl menstruacyjny",
"empty": "Brak danych dla wybranego okresu. Raport zachowa stronę tytułową.",
"unavailableHint": "Brak danych w tym okresie"
"unavailableHint": "Brak danych w tym okresie",
"labs": "Wyniki badań"
},
"insurer": "Ubezpieczyciel zdrowotny",
"insuranceNumber": "Nr ubezpieczonego",
@@ -182,7 +183,13 @@
"FOLLICULAR": "Folikularna",
"OVULATORY": "Owulacyjna",
"LUTEAL": "Lutealna"
}
},
"labsTitle": "Wyniki badań",
"labsColAnalyte": "Biomarker",
"labsColValue": "Wartość",
"labsColReference": "Norma",
"labsColStatus": "Status",
"labsColDate": "Data"
},
"errorBoundary": {
"title": "Coś poszło nie tak",
@@ -221,6 +228,7 @@
"accountSettings": "Ustawienia konta",
"mood": "Nastrój",
"workouts": "Treningi",
"coach": "Coach",
"skipToContent": "Przejdź do treści",
"sidebar": "Pasek boczny",
"mainNavigation": "Nawigacja główna",
@@ -240,7 +248,10 @@
"mood": "Nastrój",
"moodDescription": "Zapisz nastrój i tagi"
},
"cycle": "Cykl"
"cycle": "Cykl",
"labs": "Wyniki badań",
"recovery": "Regeneracja",
"vorsorge": "Profilaktyka"
},
"auth": {
"login": "Zaloguj się",
@@ -482,6 +493,7 @@
"pulseLabel": "Tętno (uderz./min)",
"valueWithUnit": "Wartość ({unit})",
"timestamp": "Czas",
"timestampBackdateHint": "Dostosuj datę i godzinę, aby zapisać odczyt z przeszłości.",
"notesPlaceholder": "np. po jedzeniu",
"formReset": "Resetuj formularz",
"saveError": "Błąd zapisu",
@@ -541,6 +553,10 @@
"typeAverageHeartRate": "Średnie tętno",
"typeMaxHeartRate": "Maksymalne tętno",
"typeSleepDisturbanceCount": "Zakłócenia snu",
"typeAnsCharge": "Ładunek ANS",
"typeCardioLoad": "Obciążenie kardio",
"typeSleepScore": "Wynik snu",
"typeBodyTemperatureDeviation": "Odchylenie temperatury ciała",
"filterBySource": "Filtruj według źródła",
"sourceComputed": "Obliczone",
"sourceWhoop": "WHOOP",
@@ -2619,7 +2635,9 @@
"sleepScoreBody": "Twój wynik snu streszcza całą noc w jednej liczbie — długość, efektywność, regularność pór oraz proporcje snu lekkiego, głębokiego i REM. Obserwuj wzorzec w ujęciu tygodni, zamiast gonić za idealnym wynikiem jednej nocy.",
"recoveryBody": "Regeneracja odzwierciedla, jak dobrze Twoje ciało się odbudowało — opiera się na nocnych sygnałach, takich jak zmienność rytmu serca i tętno spoczynkowe. Wysoki wynik to zielone światło, by przyspieszyć; niższy sugeruje, że organizm wciąż się regeneruje.",
"strainBody": "Obciążenie ujmuje, ile wysiłku sercowo-naczyniowego i fizycznego nałożyłeś na swoje ciało — od treningów po aktywny dzień na nogach. Gdy zbierze się wystarczająco danych, jest oceniane względem Twojego własnego typowego wysiłku, a nie cudzej miary.",
"stressBody": "Stres jest tu szacunkiem fizjologicznego obciążenia Twojego układu nerwowego, odczytywanym głównie ze wzorców zmienności rytmu serca, a nie z tego, jak się świadomie czujesz. Niższy wynik wskazuje na spokojny, dobrze regulowany stan; wyższy to zachęta, by wpleść regenerację."
"stressBody": "Stres jest tu szacunkiem fizjologicznego obciążenia Twojego układu nerwowego, odczytywanym głównie ze wzorców zmienności rytmu serca, a nie z tego, jak się świadomie czujesz. Niższy wynik wskazuje na spokojny, dobrze regulowany stan; wyższy to zachęta, by wpleść regenerację.",
"recoveryPageTitle": "Regeneracja",
"recoveryPageBody": "Regeneracja łączy nocne sygnały rejestrowane przez Twoje urządzenie – recharge, obciążenie, load oraz dzienne sumy tętna i energii – aby pokazać, jak dobrze Twój organizm się zregenerował. Patrz na wzorzec w ciągu kilku dni, a nie na pojedynczą liczbę; niższy wynik to zaproszenie do zwolnienia tempa, a nie alarm."
},
"stats": {
"label": "Statystyki podsumowujące",
@@ -3569,7 +3587,46 @@
"customize": "Dostosuj Analizy",
"regenerateRateLimited": "Osiągnięto limit odświeżeń — spróbuj ponownie później.",
"regenerateUnavailable": "Nie otrzymano nowej analizy — spróbuj ponownie później.",
"regenerateError": "Trwa odświeżanie — spróbuj ponownie za chwilę."
"regenerateError": "Trwa odświeżanie — spróbuj ponownie za chwilę.",
"deviceScore": {
"learning": "Wciąż poznajemy Twoją linię bazową – kilka kolejnych nocy uzupełni trend.",
"average": "Średnia z 30 dni {value}",
"unitScore": "pkt",
"unitMinutes": "min",
"unitBpm": "ud./min",
"unitKj": "kJ"
},
"sleepQuality": {
"title": "Jakość snu",
"subtitle": "Nocne wyniki z Twojego urządzenia"
},
"recovery": {
"title": "Regeneracja",
"description": "Jak dobrze Twój organizm się zregenerował – sygnały regeneracji, obciążenia i load, które Twoje urządzenie rejestruje w nocy.",
"emptyTitle": "Brak danych o regeneracji",
"empty": "Brak danych o regeneracji. Połącz konto WHOOP, Polar lub Oura, a te wyniki uzupełnią się po synchronizacji urządzenia.",
"recharge": {
"title": "Recharge",
"subtitle": "Ładowanie autonomicznego układu nerwowego"
},
"strain": {
"title": "Obciążenie i load",
"subtitle": "Dzienne obciążenie sercowo-naczyniowe"
},
"cardio": {
"title": "Serce i energia w ciągu dnia",
"subtitle": "Całodobowe sumy sercowe i energetyczne"
},
"scoreLink": {
"title": "Wynik regeneracji",
"subtitle": "Zobacz, jak rozkłada się nocny wynik",
"cta": "Zobacz"
}
},
"bodyTempDeviation": {
"title": "Odchylenie temperatury ciała",
"subtitle": "Nocne odchylenie od Twojej linii bazowej"
}
},
"thresholds": {
"defaultLabel": "Domyślne",
@@ -3648,7 +3705,7 @@
},
"goals": {
"title": "Co chcesz śledzić?",
"body": "Wybierz obszary, które są dla Ciebie ważne — to pomaga zdecydować, co śledzić i co skonfigurować w następnej kolejności.",
"body": "Wybierz obszary, które są dla Ciebie ważne — pokażemy je najpierw na pulpicie i pomogą Ci zdecydować, co skonfigurować dalej.",
"helpHint": "Od jednego do trzech priorytetów zwykle sprawdza się najlepiej — możesz dodać kolejne w dowolnej chwili.",
"options": {
"weightManagement": "Kontrola wagi",
@@ -3733,6 +3790,17 @@
"title": "Zobacz wzorce",
"body": "Codzienne podsumowania, wykresy trendów i coach, który wskazuje co się zmieniło — wszystko na Twoim urządzeniu."
}
},
"anamnesis": {
"title": "O Twoim zdrowiu (opcjonalnie)",
"subtitle": "Trochę kontekstu, aby HealthLog rzetelniej odczytywał Twoje wyniki.",
"intro": "Podziel się tym, czym chcesz — każde pole jest opcjonalne i możesz je zmienić w dowolnej chwili w Ustawieniach.",
"conditionsLabel": "Aktualne schorzenia",
"conditionsPlaceholder": "np. nadciśnienie, cukrzyca typu 2",
"conditionsHint": "Pomaga HealthLog ujmować Twoje pomiary w kontekście, zamiast oznaczać to, czego należy się spodziewać.",
"allergiesLabel": "Alergie lub nietolerancje",
"allergiesPlaceholder": "np. penicylina, laktoza",
"privacyNote": "Przechowywane w postaci zaszyfrowanej i używane tylko po to, by dać Ci lepszy kontekst. Nie pytamy tu o leki — dodasz je w dowolnej chwili na stronie Leki."
}
},
"gettingStarted": {
@@ -3793,6 +3861,27 @@
"title": "Powiadomienia",
"description": "Podgląd na żywo każdego skonfigurowanego kanału."
},
"layout": {
"title": "Układ i personalizacja",
"description": "Ustal, jak rozłożona jest Twoja aplikacja — jedno miejsce dla pulpitu, analiz, leków i nastroju.",
"backToHub": "Powrót do Układ i personalizacja",
"dashboard": {
"title": "Pulpit",
"description": "Układ i kolejność kafelków."
},
"insights": {
"title": "Analizy",
"description": "Kolejność przeglądu i pigułek nawigacyjnych."
},
"medications": {
"title": "Leki",
"description": "Widok listy i kolejność leków."
},
"mood": {
"title": "Tagi nastroju",
"description": "Grupy, tagi i kolejność w wyborze nastroju."
}
},
"dashboard": {
"title": "Pulpit",
"description": "Układ i kolejność kafelków."
@@ -3920,6 +4009,7 @@
"import": {
"heading": "Import",
"description": "Zaimportuj dane do HealthLog z eksportu Apple Health lub pliku JSON. Import pomija wiersze, które już istnieją, więc ponowne uruchomienie jest bezpieczne.",
"charCount": "{used} / {max} znaków",
"appleHealth": {
"title": "Apple Health",
"description": "Prześlij plik export.zip z aplikacji Zdrowie na iPhonie (Profil → Eksportuj wszystkie dane zdrowotne).",
@@ -3958,6 +4048,27 @@
"failed": "Import nie powiódł się. Spróbuj ponownie.",
"readFailed": "Nie można odczytać tego pliku.",
"resultSummary": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju, pominięto {skipped}."
},
"csv": {
"title": "Plik CSV",
"description": "Importuj pomiary z arkusza lub eksportu z urządzenia. Jeden wiersz na odczyt.",
"pasteLabel": "Wklej CSV",
"fileInputLabel": "Wybierz plik CSV do zaimportowania",
"schemaHint": "Kolumny: type, value, unit, measuredAt (z przesunięciem strefy czasowej) oraz opcjonalnie glucoseContext, notes, externalId.",
"docsLink": "Zobacz schemat kolumn i przykład.",
"uploadFile": "Prześlij plik",
"downloadExample": "Pobierz przykład",
"preview": "Podgląd",
"import": "Importuj",
"empty": "Dodaj co najmniej jeden wiersz CSV przed importem.",
"rateLimited": "Zbyt wiele importów w tej godzinie (limit 5). Spróbuj ponownie później.",
"tooLarge": "Ten plik jest za duży (limit 16 MB).",
"invalidPayload": "Nie udało się przetworzyć pliku CSV. Sprawdź wiersz nagłówka i spróbuj ponownie.",
"failed": "Import nie powiódł się. Spróbuj ponownie.",
"readFailed": "Nie udało się odczytać tego pliku.",
"previewSummary": "Podgląd: {inserted} zostanie zaimportowanych, pominięto {skipped}.",
"resultSummary": "Zaimportowano {inserted} nowych i zaktualizowano {updated}, pominięto {skipped}.",
"rowError": "Wiersz {line}: {reason}"
}
}
},
@@ -3980,6 +4091,36 @@
"mood": {
"title": "Tagi nastroju",
"description": "Grupy, tagi i kolejność w wyborze nastroju."
},
"reminders": {
"title": "Przypomnienia i powiadomienia",
"description": "Jedno miejsce dla każdego przypomnienia — o czym i kiedy — oraz kanały, które je dostarczają.",
"categoriesGroup": "Przypomnienia",
"channelsGroup": "Kanały dostarczania",
"medication": {
"title": "Przypomnienia o lekach",
"description": "Godziny i okna dawek, ustawiane przy każdym leku."
},
"vorsorge": {
"title": "Profilaktyka",
"description": "Cykliczne badania i pomiary wraz z miejscem, gdzie się udać."
},
"mood": {
"title": "Zapis nastroju",
"description": "Codzienne przypomnienie, by zapisać samopoczucie."
},
"lowStock": {
"title": "Zapas na wyczerpaniu",
"description": "Otrzymaj ostrzeżenie, zanim lek się skończy."
},
"coach": {
"title": "Podpowiedź trenera",
"description": "Proaktywne wskazówki od trenera AI."
},
"channels": {
"title": "Kanały powiadomień",
"description": "APNs, Telegram, ntfy, Web Push, Webhook i e-mail."
}
}
},
"profile": "Profil",
@@ -4067,6 +4208,17 @@
"ntfyTopic": "Topic",
"ntfyAuthToken": "Token uwierzytelniania (opcjonalny)",
"ntfyAuthTokenHint": "Wymagany tylko dla prywatnych tematów z kontrolą dostępu.",
"webhook": "Webhook",
"webhookDescription": "Wysyłaj powiadomienia metodą POST na dowolny URL — Gotify, Discord, Slack, Matrix lub Home Assistant.",
"webhookEnable": "Włącz webhook",
"webhookUrl": "Adres URL webhooka",
"webhookUrlHint": "Pełny adres URL dla żądania POST. Musi być publicznie dostępny.",
"webhookHeaderName": "Nazwa nagłówka (opcjonalnie)",
"webhookHeaderValue": "Wartość nagłówka (opcjonalnie)",
"email": "E-mail",
"emailDescription": "Otrzymuj alerty i przypomnienia e-mailem.",
"emailEnable": "Włącz e-mail",
"emailRecipient": "Adres odbiorcy",
"webPush": "Push przeglądarki",
"webPushDescription": "Otrzymuj powiadomienia bezpośrednio w przeglądarce, nawet gdy HealthLog nie jest otwarty.",
"webPushNotSupported": "Twoja przeglądarka nie obsługuje powiadomień push.",
@@ -4315,6 +4467,18 @@
"whoopConnect": "Połącz z WHOOP",
"whoopNoCredentials": "Wprowadź powyżej swoje dane API, aby połączyć WHOOP.",
"whoopBackfillInProgress": "Importowanie historii WHOOP w tle…",
"whoopOauthConnected": "WHOOP połączony — pierwsza synchronizacja startuje w tle.",
"whoopOauthFailed": "Połączenie z WHOOP nie powiodło się",
"whoopOauthError": {
"csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.",
"state": "Odpowiedź z WHOOP nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.",
"expired": "Żądanie połączenia wygasło przed zakończeniem. Rozpocznij połączenie ponownie.",
"cross_user": "Odpowiedź należy do żądania z innego konta. Rozpocznij połączenie ponownie z własnego konta.",
"nocode": "WHOOP nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie WHOOP.",
"nocreds": "WHOOP nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth WHOOP i spróbuj ponownie.",
"token": "Wymiana tokena z WHOOP nie powiodła się. Rozpocznij połączenie ponownie.",
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
},
"fitbit": "Google Health",
"fitbitTag": "Fitbit i Pixel",
"fitbitDescription": "Połącz Google Health, aby synchronizować dane z Fitbita i Pixel Watch — wagę, tkankę tłuszczową, tętno spoczynkowe, saturację, zmienność rytmu serca i więcej.",
@@ -4342,6 +4506,18 @@
"fitbitConnect": "Połącz z Google Health",
"fitbitNoCredentials": "Wprowadź powyżej swoje dane uwierzytelniające API, aby połączyć Google Health.",
"fitbitBackfillInProgress": "Importowanie historii Google Health w tle…",
"fitbitOauthConnected": "Google Health połączony — pierwsza synchronizacja startuje w tle.",
"fitbitOauthFailed": "Połączenie z Google Health nie powiodło się",
"fitbitOauthError": {
"csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.",
"state": "Odpowiedź z Google nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.",
"expired": "Żądanie połączenia wygasło przed zakończeniem. Rozpocznij połączenie ponownie.",
"cross_user": "Odpowiedź należy do żądania z innego konta. Rozpocznij połączenie ponownie z własnego konta.",
"nocode": "Google nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Google.",
"nocreds": "Google Health nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth Google i spróbuj ponownie.",
"token": "Wymiana tokena z Google nie powiodła się. Rozpocznij połączenie ponownie.",
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
},
"integrations": {
"withings": {
"reconnect": {
@@ -4537,7 +4713,8 @@
"includeAiSummary": "Dołącz podsumowanie AI (niezweryfikowane klinicznie)",
"fhirNote": "Dane ustrukturyzowane są zgodne ze standardem FHIR R4 (kompatybilne z ePA).",
"generate": "Generuj",
"error": "Eksport nie powiódł się ({code})."
"error": "Eksport nie powiódł się ({code}).",
"labs": "Wyniki badań"
},
"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.",
@@ -4603,7 +4780,14 @@
"polarDescription": "Połącz konto Polar, aby synchronizować regenerację Nightly Recharge, fazy snu i aktywność dzienną.",
"polarViewData": "Zobacz sen i regenerację",
"polarHelp": "Zaloguj się w Polar, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.",
"polarUnavailable": "Polar nie jest skonfigurowany na tym serwerze. Poproś administratora o dodanie danych OAuth Polar.",
"polarCredentials": "Dane API",
"polarCredentialsHelp": "Opcjonalnie: zarejestruj własną aplikację Polar AccessLink i wklej tutaj jej Client ID oraz Client Secret. Pozostaw puste, aby użyć współdzielonej aplikacji Polar serwera.",
"polarClientId": "Client ID",
"polarClientSecret": "Client Secret",
"polarCredentialsSaved": "Dane zapisane",
"polarCredentialsSavedPlaceholder": "Zapisano — wpisz nowe, aby zastąpić",
"polarSaveCredentials": "Zapisz",
"polarUnavailable": "Polar nie jest skonfigurowany na tym serwerze. Dodaj powyżej własne dane OAuth Polar lub poproś administratora o skonfigurowanie współdzielonej aplikacji.",
"polarConnect": "Połącz Polar",
"polarDisconnect": "Rozłącz",
"polarDisconnectTitle": "Rozłączyć Polar?",
@@ -4624,7 +4808,14 @@
"ouraDescription": "Połącz konto Oura, aby synchronizować readiness, fazy snu, HRV i aktywność dzienną.",
"ouraViewData": "Zobacz sen i regenerację",
"ouraHelp": "Zaloguj się w Oura, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.",
"ouraUnavailable": "Oura nie jest skonfigurowana na tym serwerze. Poproś administratora o dodanie danych OAuth Oura.",
"ouraCredentials": "Dane API",
"ouraCredentialsHelp": "Opcjonalnie: zarejestruj własną aplikację Oura i wklej tutaj jej Client ID oraz Client Secret. Pozostaw puste, aby użyć współdzielonej aplikacji Oura serwera.",
"ouraClientId": "Client ID",
"ouraClientSecret": "Client Secret",
"ouraCredentialsSaved": "Dane zapisane",
"ouraCredentialsSavedPlaceholder": "Zapisano — wpisz nowe, aby zastąpić",
"ouraSaveCredentials": "Zapisz",
"ouraUnavailable": "Oura nie jest skonfigurowana na tym serwerze. Dodaj powyżej własne dane OAuth Oura lub poproś administratora o skonfigurowanie współdzielonej aplikacji.",
"ouraConnect": "Połącz Oura",
"ouraDisconnect": "Rozłącz",
"ouraDisconnectTitle": "Rozłączyć Oura?",
@@ -4640,7 +4831,8 @@
"token": "Wymiana tokena z Oura nie powiodła się. Rozpocznij połączenie ponownie.",
"rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.",
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
}
},
"integrationSetupGuide": "Przewodnik konfiguracji"
},
"admin": {
"title": "Administracja",
@@ -4763,6 +4955,13 @@
"webPushVapidPrivateKeyPlaceholder": "Opcjonalnie: wprowadź nowy klucz prywatny",
"webPushVapidSubject": "Subject VAPID",
"webPushVapidSubjectPlaceholder": "mailto:you@example.com",
"webPushVapidGenerate": "Wygeneruj klucze",
"webPushVapidGenerating": "Generowanie…",
"webPushVapidGenerated": "Klucze VAPID wygenerowane. Zapisz temat, aby zakończyć.",
"webPushVapidGenerateFailed": "Nie udało się wygenerować kluczy VAPID.",
"webPushVapidGenerateConfirm": "Klucze VAPID są już skonfigurowane. Wygenerowanie nowej pary unieważnia wszystkie subskrypcje push w przeglądarce — każde urządzenie musi zasubskrybować ponownie. Kontynuować?",
"webPushVapidGenerateConfirmTitle": "Wygenerować nowe klucze VAPID?",
"webPushVapidRegenerate": "Wygeneruj ponownie",
"configured": "Skonfigurowano",
"monitoringTestSuccess": "Wysłano zdarzenie testowe",
"monitoringTestFailed": "Test monitorowania nie powiódł się",
@@ -4930,6 +5129,15 @@
"authTokenRevoke": "Token unieważniony",
"medicationReminders": "Przypomnienia o lekach",
"medicationRemindersDescription": "Progi zmian koloru oznaczeń przypomnień na kartach leków.",
"notificationHealth": {
"title": "Stan dostarczania",
"description": "Wyniki dostarczania wszystkich użytkowników z ostatnich 24 godzin.",
"empty": "W tym okresie nie wysłano żadnych powiadomień.",
"ok": "OK",
"error": "Nieudane",
"skipped": "Pominięte",
"autoDisabled": "Automatycznie wyłączone kanały"
},
"reminderLateMinutes": "Próg opóźnienia (minuty)",
"reminderLateMinutesDescription": "Minuty po końcu okna, zanim oznaczenie zmieni kolor z żółtego na pomarańczowy.",
"reminderMissedMinutes": "Próg pominięcia (minuty)",
@@ -6208,5 +6416,99 @@
"deleteN": "Usuń ({count})",
"clearSelection": "Wyczyść zaznaczenie",
"selectionBarLabel": "Akcje zaznaczenia"
},
"measurementReminders": {
"sectionTitle": "Profilaktyka",
"sectionDescription": "Przypomnienia, co, kiedy i gdzie zmierzyć lub sprawdzić.",
"addButton": "Dodaj przypomnienie",
"markDone": "Gotowe",
"delete": "Usuń",
"disabledBadge": "Wył.",
"nextDue": {
"none": "Niezaplanowane",
"today": "Termin dzisiaj",
"tomorrow": "Termin jutro",
"inDays": "Za {days} dni"
},
"overdueByDays": "Zaległe od {days} dni",
"cadence": {
"everyNDays": "Co {days} dni",
"custom": "Własny harmonogram"
},
"location": {
"prefix": "U: {location}"
},
"types": {
"WEIGHT": "Waga",
"BLOOD_PRESSURE_SYS": "Ciśnienie krwi",
"PULSE": "Tętno",
"BLOOD_GLUCOSE": "Glukoza we krwi",
"OXYGEN_SATURATION": "Saturacja tlenem",
"BODY_FAT": "Tkanka tłuszczowa",
"BODY_TEMPERATURE": "Temperatura ciała"
},
"empty": {
"title": "Brak przypomnień",
"description": "Dodaj przypomnienie, aby regularnie mierzyć ciśnienie krwi lub zaplanować coroczne badanie krwi."
},
"form": {
"label": "Etykieta",
"labelPlaceholder": "np. Zmierz ciśnienie krwi",
"kind": "Typ",
"kindType": "Powiązane z pomiarem",
"kindFreeText": "Sprawdzenie tekstowe",
"measurementType": "Pomiar",
"cadence": "Powtarzaj",
"notifyHour": "Przypomnij o",
"location": "Gdzie (opcjonalnie)",
"locationPlaceholder": "np. Lekarz, Laboratorium",
"save": "Zapisz"
},
"pushTitle": "Przypomnienie profilaktyczne",
"pushBody": "Czas na: {label}",
"pushLocation": "(u {location})"
},
"labs": {
"title": "Wyniki badań",
"subtitle": "Badania krwi i biomarkery",
"addResult": "Dodaj",
"addDescription": "Zapisz biomarker z wyniku laboratoryjnego.",
"addFirst": "Dodaj pierwszy wynik",
"emptyTitle": "Brak wyników badań",
"emptyDescription": "Zapisuj badania takie jak HbA1c, LDL czy ferrytyna, aby śledzić je w czasie i udostępniać lekarzowi.",
"referenceLabel": "Norma",
"readingsCount": "{count} wyników",
"deleteLatest": "Usuń ostatni wynik",
"deletedToast": "Wynik usunięty",
"deleteError": "Nie udało się usunąć wyniku",
"loadError": "Nie udało się wczytać wyników",
"range": {
"inRange": "W normie",
"below": "Poniżej normy",
"above": "Powyżej normy"
},
"form": {
"analyte": "Biomarker",
"analytePlaceholder": "np. HbA1c, LDL, ferrytyna",
"value": "Wartość",
"unit": "Jednostka",
"unitPlaceholder": "np. mg/dl, %, ng/ml",
"takenAt": "Data pobrania",
"optionalGroup": "Opcjonalne",
"panel": "Panel",
"panelPlaceholder": "np. Morfologia krwi",
"referenceLow": "Norma dolna",
"referenceHigh": "Norma górna",
"referenceLowPlaceholder": "Dolna granica",
"referenceHighPlaceholder": "Górna granica",
"referenceHint": "Przepisz zakres referencyjny z wyniku laboratoryjnego. Każda z granic jest opcjonalna.",
"note": "Notatka",
"notePlaceholder": "Opcjonalny kontekst tego wyniku",
"requiredError": "Biomarker, wartość i jednostka są wymagane.",
"rangeOrderError": "Norma dolna nie może przekraczać górnej.",
"save": "Zapisz",
"savedToast": "Wynik zapisany",
"saveError": "Nie udało się zapisać wyniku"
}
}
}
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.17.0",
"version": "1.17.1",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -77,6 +77,7 @@
"pg-boss": "^12.18.2",
"prisma": "^7.8.0",
"qrcode": "^1.5.4",
"nodemailer": "^9.0.0",
"radix-ui": "^1.4.3",
"react": "19.2.7",
"react-dom": "19.2.7",
@@ -100,6 +101,7 @@
"@tailwindcss/postcss": "^4",
"@testcontainers/postgresql": "^12.0.1",
"@types/node": "^25.9.2",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.20.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
+25 -6
View File
@@ -77,6 +77,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
nodemailer:
specifier: ^9.0.0
version: 9.0.0
p-limit:
specifier: ^7.3.0
version: 7.3.0
@@ -153,6 +156,9 @@ importers:
'@types/node':
specifier: ^25.9.2
version: 25.9.2
'@types/nodemailer':
specifier: ^8.0.1
version: 8.0.1
'@types/pg':
specifier: ^8.20.0
version: 8.20.0
@@ -2857,6 +2863,9 @@ packages:
'@types/node@25.9.2':
resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==}
'@types/nodemailer@8.0.1':
resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==}
'@types/pako@2.0.4':
resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==}
@@ -5187,6 +5196,10 @@ packages:
resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==}
engines: {node: '>=18'}
nodemailer@9.0.0:
resolution: {integrity: sha512-tbPTid7d/p9jAA8CRZ3iomvrMaST0o6NYuY7v6JQZHpPRZ61mLFSPKYd7342NtOFuej9/+L48SOIxwfu2uDvtw==}
engines: {node: '>=6.0.0'}
non-error@0.1.0:
resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==}
engines: {node: '>=20'}
@@ -9335,13 +9348,13 @@ snapshots:
'@types/docker-modem@3.0.6':
dependencies:
'@types/node': 25.9.2
'@types/node': 18.19.130
'@types/ssh2': 1.15.5
'@types/dockerode@4.0.1':
dependencies:
'@types/docker-modem': 3.0.6
'@types/node': 25.9.2
'@types/node': 18.19.130
'@types/ssh2': 1.15.5
'@types/estree@1.0.8': {}
@@ -9360,6 +9373,10 @@ snapshots:
dependencies:
undici-types: 7.24.6
'@types/nodemailer@8.0.1':
dependencies:
'@types/node': 18.19.130
'@types/pako@2.0.4': {}
'@types/pg@8.20.0':
@@ -9389,15 +9406,15 @@ snapshots:
'@types/set-cookie-parser@2.4.10':
dependencies:
'@types/node': 25.9.2
'@types/node': 18.19.130
'@types/ssh2-streams@0.1.13':
dependencies:
'@types/node': 25.9.2
'@types/node': 18.19.130
'@types/ssh2@0.5.52':
dependencies:
'@types/node': 25.9.2
'@types/node': 18.19.130
'@types/ssh2-streams': 0.1.13
'@types/ssh2@1.15.5':
@@ -11823,6 +11840,8 @@ snapshots:
node-releases@2.0.46: {}
nodemailer@9.0.0: {}
non-error@0.1.0: {}
normalize-path@3.0.0: {}
@@ -12244,7 +12263,7 @@ snapshots:
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.1
'@types/node': 25.9.2
'@types/node': 18.19.130
long: 5.3.2
proxy-addr@2.0.7:
@@ -0,0 +1,118 @@
-- v1.17.1 — schema foundation for per-user Polar/Oura BYO-app credentials,
-- preventive-care (Vorsorge) measurement reminders, a minimal structured lab
-- store, and the iOS Live Activity push-token channel (#22).
--
-- Purely-additive: four new nullable columns on `users` (encrypted client
-- credentials), one new nullable column on `devices`, and two new tables. No
-- backfill, no existing row touched, no enum change (Polar/Oura/Nightscout
-- measurement sources already landed in migration 0160).
--
-- 1. `users` += Polar + Oura BYO-app client id/secret (encrypted at app
-- level, stored as TEXT), mirroring the WHOOP/Fitbit credential columns.
-- 2. `devices` += `live_activity_push_token` for the iOS Live Activity
-- update/end APNs channel (#22). NULL when no active Live Activity.
-- 3. `measurement_reminders` — preventive-care reminders. Cadence is a
-- rolling `interval_days` OR an RFC-5545 `rrule`; the recurrence engine
-- drives `next_due_at` (server-authoritative). Completion target is an
-- optional `measurement_type` (auto-resolve) or a free-text `label`.
-- Soft-deleted.
-- 4. `lab_results` — one analyte per row, optional panel grouping + optional
-- reference range, optional encrypted note. Pairs with the Vorsorge
-- "annual blood panel" reminder so a result has somewhere to land.
-- Soft-deleted.
--
-- Idempotent guards (`IF NOT EXISTS`) make reruns safe. Forward-only.
--
-- Reversibility: every column drops with `DROP COLUMN IF EXISTS`; both tables
-- drop with `DROP TABLE IF EXISTS`. No data is rewritten.
-- ── 1. users — Polar + Oura BYO-app client credentials (encrypted) ─────
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "polar_client_id_encrypted" TEXT;
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "polar_client_secret_encrypted" TEXT;
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "oura_client_id_encrypted" TEXT;
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "oura_client_secret_encrypted" TEXT;
-- ── 2. devices — iOS Live Activity push token (#22) ────────────────────
ALTER TABLE "devices"
ADD COLUMN IF NOT EXISTS "live_activity_push_token" TEXT;
-- ── 3. measurement_reminders — preventive-care (Vorsorge) reminders ────
CREATE TABLE IF NOT EXISTS "measurement_reminders" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"label" TEXT NOT NULL,
"measurement_type" "measurement_type",
"interval_days" INTEGER,
"rrule" TEXT,
"anchor_date" TIMESTAMP(3),
"notify_hour" INTEGER NOT NULL DEFAULT 9,
"location" TEXT,
"next_due_at" TIMESTAMP(3),
"last_satisfied_at" TIMESTAMP(3),
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "measurement_reminders_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "measurement_reminders_user_id_deleted_at_idx"
ON "measurement_reminders" ("user_id", "deleted_at");
CREATE INDEX IF NOT EXISTS "measurement_reminders_user_id_next_due_at_idx"
ON "measurement_reminders" ("user_id", "next_due_at");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'measurement_reminders_user_id_fkey'
) THEN
ALTER TABLE "measurement_reminders"
ADD CONSTRAINT "measurement_reminders_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
-- ── 4. lab_results — minimal structured lab store ──────────────────────
CREATE TABLE IF NOT EXISTS "lab_results" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"panel" TEXT,
"analyte" TEXT NOT NULL,
"value" DOUBLE PRECISION NOT NULL,
"unit" TEXT NOT NULL,
"reference_low" DOUBLE PRECISION,
"reference_high" DOUBLE PRECISION,
"taken_at" TIMESTAMP(3) NOT NULL,
"source" TEXT NOT NULL DEFAULT 'MANUAL',
"note_encrypted" BYTEA,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "lab_results_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "lab_results_user_id_analyte_taken_at_idx"
ON "lab_results" ("user_id", "analyte", "taken_at");
CREATE INDEX IF NOT EXISTS "lab_results_user_id_deleted_at_idx"
ON "lab_results" ("user_id", "deleted_at");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'lab_results_user_id_fkey'
) THEN
ALTER TABLE "lab_results"
ADD CONSTRAINT "lab_results_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
@@ -0,0 +1,20 @@
-- v1.17.1 — one-shot sleep-timeline backfill markers.
--
-- WHOOP's v2 API returns only per-stage sleep DURATION totals (no onset
-- timestamps), and the pre-fix mapper stamped all five stage totals on the one
-- sleep-END instant. The hypnogram then reconstructed every stage as a span
-- touching the night's right edge — the stacked-bar artefact with no clock
-- times. The fix reconstructs an ordered, contiguous per-segment timeline.
--
-- Withings stamped each sleep segment with its START while every reader treats
-- `measured_at` as the segment END, shifting each night one segment-length
-- earlier. The fix stamps the END.
--
-- Both fixes change the stored row shape / instant, so existing rows must be
-- re-synced. The boot-time backfill (per connection) deletes the affected
-- SLEEP_DURATION rows, re-syncs with the corrected mapper, re-folds the rollup
-- tier, and stamps `sleep_timeline_backfill_at` so the discovery query drops
-- the connection. Null = backfill not yet run; the marker makes the one-shot
-- idempotent across reboots.
ALTER TABLE "whoop_connections" ADD COLUMN "sleep_timeline_backfill_at" TIMESTAMP(3);
ALTER TABLE "withings_connections" ADD COLUMN "sleep_timeline_backfill_at" TIMESTAMP(3);
@@ -0,0 +1,15 @@
-- v1.17.1 — persist the onboarding goal selection.
--
-- The GoalsChipPicker step let the user toggle goal slugs, but both
-- Skip and Next discarded the selection — the picker was decorative.
-- This adds the `onboarding_goals` text array the dead code already
-- referenced, so the selection can seed dashboard tile pinning and
-- reminder suggestions.
--
-- Purely-additive: one new column on `users`, NOT NULL with a `'{}'`
-- (empty array) default, so every existing row keeps "no preference"
-- without a backfill pass. The column is a personalization seed, not a
-- clinical target store.
ALTER TABLE "users"
ADD COLUMN "onboarding_goals" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,20 @@
-- v1.17.1 — Oura coverage completion (additive).
--
-- Two new `measurement_type` enum values so the Oura sync can store signals it
-- already fetches (or now fetches) but had no home for:
--
-- 1. `SLEEP_SCORE` — Oura's headline 0–100 Sleep Score from the
-- `daily_sleep` collection (distinct from the per-stage `sleep` detail).
-- Kept apart from the WHOOP `SLEEP_PERFORMANCE` / `SLEEP_EFFICIENCY`
-- sub-scores so two vendors' headline numbers never share a bucket.
-- 2. `BODY_TEMPERATURE_DEVIATION` — Oura's nightly body-temperature deviation
-- from the user's personal baseline (`daily_readiness.temperature_deviation`),
-- a SIGNED °C offset (illness / luteal-phase / stress signal). It is NOT an
-- absolute reading, so it cannot share the BODY_TEMPERATURE /
-- SKIN_TEMPERATURE / WRIST_TEMPERATURE absolute-temperature buckets.
--
-- Purely-additive: two enum extensions, no backfill, no existing row touched,
-- no new tables or columns. Both stored as `source = OURA`.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'SLEEP_SCORE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'BODY_TEMPERATURE_DEVIATION';
@@ -0,0 +1,20 @@
-- v1.17.1 — Polar pull-completeness: two native Polar signals get their own
-- MeasurementType so they stop being read off the wire and dropped at the mapper.
--
-- 1. `ANS_CHARGE` — Polar Nightly Recharge `ans_charge`, the HRV-based
-- autonomic-nervous-system charge. The primary differentiated recovery
-- component, distinct from the 1–6 recovery band already mapped to
-- RECOVERY_SCORE.
-- 2. `CARDIO_LOAD` — Polar Training Load Pro `cardio_load`, the device-native
-- cardiovascular-strain figure (Polar's WHOOP-day-strain analogue). Kept
-- distinct from DAY_STRAIN (WHOOP 0–21) and the COMPUTED STRAIN_SCORE so
-- each native value keeps its own bucket and scale.
--
-- Purely additive: two enum-extensions on `measurement_type`. No backfill, no
-- column change, no existing row touched. Idempotent (`IF NOT EXISTS`); reruns
-- are safe. Forward-only — Postgres cannot remove an enum value, and with no
-- rows carrying them before the app ships they are inert until the Polar sync
-- writes them.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'ANS_CHARGE';
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'CARDIO_LOAD';
+175
View File
@@ -127,12 +127,26 @@ model User {
// tokens are stored encrypted.
polarAccessTokenEncrypted String? @map("polar_access_token_encrypted")
polarUserIdEncrypted String? @map("polar_user_id_encrypted")
// v1.17.1 — Polar BYO-app client credentials (W1c, per-user, encrypted at
// app level). Mirrors the WHOOP/Fitbit `clientId`/`clientSecret` columns 1:1
// so a self-hoster pastes their own Polar AccessLink app's client id/secret
// into Settings before the OAuth connect flow runs. Stored encrypted as a
// string via `src/lib/crypto.ts`, matching every other `*Encrypted` column.
polarClientIdEncrypted String? @map("polar_client_id_encrypted")
polarClientSecretEncrypted String? @map("polar_client_secret_encrypted")
// v1.17.0 — Oura OAuth credentials (F4, per-user, encrypted at app level).
// Mirrors the WHOOP credential columns; OAuth access/refresh tokens stored
// encrypted.
ouraAccessTokenEncrypted String? @map("oura_access_token_encrypted")
ouraRefreshTokenEncrypted String? @map("oura_refresh_token_encrypted")
// v1.17.1 — Oura BYO-app client credentials (W1c, per-user, encrypted at app
// level). Mirrors the WHOOP/Fitbit `clientId`/`clientSecret` columns 1:1 so a
// self-hoster pastes their own Oura Cloud app's client id/secret into Settings
// before the OAuth connect flow runs. Stored encrypted as a string via
// `src/lib/crypto.ts`, matching every other `*Encrypted` column.
ouraClientIdEncrypted String? @map("oura_client_id_encrypted")
ouraClientSecretEncrypted String? @map("oura_client_secret_encrypted")
// moodLog integration (per-user, encrypted)
moodLogUrlEncrypted String? @map("mood_log_url_encrypted")
@@ -222,6 +236,15 @@ model User {
// round-trip — the column stays at 4 for completed users)
// Null = legacy pre-W14b user; the wizard treats null as 0 on mount.
onboardingStep Int @default(0) @map("onboarding_step")
// v1.17.1 — goal slugs chosen during onboarding (GoalsChipPicker).
// Seeds dashboard tile pinning + reminder suggestions; NOT a clinical
// target store (targets are fully derived from height/age/gender +
// clinical refs in /api/insights/targets). Empty [] = skipped / no
// preference. The slug set is the closed enum
// `ONBOARDING_GOAL_SLUGS` in src/lib/onboarding/goals.ts; a String[]
// (not a relation) mirrors `insightsExcludeMetrics` precedent — the
// set is small and fixed, never queried relationally.
onboardingGoals String[] @default([]) @map("onboarding_goals")
// v1.4.15 Phase B5: tracks whether the user has finished (or
// explicitly dismissed) the spotlight tour that overlays the
// dashboard on first arrival. Distinct from the wizard at
@@ -527,6 +550,15 @@ model User {
// v1.11.0 W3 — one durable period-narrative row per (period, locale).
// Delete/regenerate-clean; the generated prose is AES-256-GCM at rest.
insightNarratives InsightNarrative[]
// v1.17.1 — preventive-care / measurement (Vorsorge) reminders. One row
// per user-created reminder ("BP every 7 days", "annual blood panel").
// Server computes the canonical `nextDueAt`; the cron fires through the
// existing notification dispatcher. Soft-deleted.
measurementReminders MeasurementReminder[]
// v1.17.1 — minimal structured lab store (LDL, HbA1c, ferritin, TSH, …)
// that pairs with the Vorsorge "annual blood panel" reminder so a result
// has somewhere to land. Soft-deleted.
labResults LabResult[]
// v1.7.0 profile — optional patient-identity fields surfaced on the
// health-record export cover (PDF) and the FHIR `Patient` resource.
@@ -742,6 +774,29 @@ enum MeasurementType {
MAX_HEART_RATE // bpm — WHOOP `cycle.score.max_heart_rate`. The day's whole-cycle peak heart rate.
SLEEP_DISTURBANCE_COUNT // count — WHOOP `sleep.score.stage_summary.disturbance_count`. Per-night tally of sleep disturbances.
// ── v1.17.1 — Polar Nightly Recharge + Training Load Pro coverage (additive) ──
// Two native Polar signals with no existing home. ANS_CHARGE is the HRV-based
// autonomic-nervous-system charge component of Nightly Recharge — Polar's most
// differentiated recovery signal, distinct from the 1–6 recovery band that
// already maps to RECOVERY_SCORE. CARDIO_LOAD is Polar's Training Load Pro
// cardiovascular-strain figure (its WHOOP-day-strain analogue); kept distinct
// from DAY_STRAIN (WHOOP's 0–21 scale) and the COMPUTED STRAIN_SCORE proxy so
// each device-native value keeps its own bucket and native scale.
ANS_CHARGE // score — Polar Nightly Recharge `ans_charge`. The HRV-based autonomic-nervous-system charge (typically ~-10..+10 around the personal baseline; higher = more recovered). Descriptive, not a clinical assessment.
CARDIO_LOAD // score — Polar Training Load Pro `cardio_load`. The session/day cardiovascular load (TRIMP-lineage strain). Distinct from WHOOP DAY_STRAIN (0–21) and the COMPUTED STRAIN_SCORE so the device-native value never shares a bucket.
// ── v1.17.1 — Oura coverage completion (additive) ──
// Oura's headline 0–100 Sleep Score from the `daily_sleep` collection
// (distinct from the per-stage `sleep` detail). Stored as `source = OURA`,
// unit `score`. Kept apart from SLEEP_PERFORMANCE / SLEEP_EFFICIENCY (WHOOP
// sub-scores) so two vendors' headline numbers never share a bucket.
SLEEP_SCORE // score (0–100) — Oura `daily_sleep.score`. Per-night sleep quality headline.
// Oura nightly body-temperature deviation from the user's personal baseline
// (`daily_readiness.temperature_deviation`), in °C. NOT an absolute
// temperature — a signed offset (illness / luteal-phase / stress signal),
// so it cannot share the BODY_TEMPERATURE / SKIN_TEMPERATURE / WRIST_TEMPERATURE
// absolute-reading buckets. Plausible band is small and centred on 0.
BODY_TEMPERATURE_DEVIATION // celsius (signed deviation) — Oura `daily_readiness.temperature_deviation`.
@@map("measurement_type")
}
@@ -2150,6 +2205,13 @@ model WithingsConnection {
/// has never re-authed since v1.4.25 — the UI surfaces a banner
/// inviting them to reconnect.
scope String? @map("scope")
/// v1.17.1 — one-shot marker for the sleep-stamp backfill. Pre-fix Withings
/// sleep rows were stamped with the segment START; the reader treats
/// `measuredAt` as the END, shifting every night one segment-length earlier.
/// The boot-time backfill deletes the affected WITHINGS / SLEEP_DURATION rows
/// and re-syncs with the corrected END stamp, then sets this so the discovery
/// query drops the connection. Null = backfill not yet run.
sleepTimelineBackfillAt DateTime? @map("sleep_timeline_backfill_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -2226,6 +2288,13 @@ model WhoopConnection {
/// Set once the self-converging history backfill walks every collection to
/// completion. Null = backfill still in flight (or never started).
backfillCompletedAt DateTime? @map("backfill_completed_at")
/// v1.17.1 — one-shot marker for the sleep-timeline backfill. Pre-fix WHOOP
/// sleep rows stamped all five stage totals on the one sleep-END instant, so
/// the hypnogram stacked them on the night's right edge. The boot-time
/// backfill deletes the legacy summary rows and re-syncs the reconstructed
/// per-segment timeline, then sets this so the discovery query drops the
/// connection. Null = backfill not yet run.
sleepTimelineBackfillAt DateTime? @map("sleep_timeline_backfill_at")
/// Max heart rate from the WHOOP body-measurement profile — a profile
/// constant, not a time series, so it lives here rather than as a
/// Measurement row.
@@ -3090,6 +3159,14 @@ model Device {
/// echoed in v1.7.0; cron suppression stays user-level (APNs fans out to
/// all the user's devices and iOS dedupes locally).
medicationDelivery String? @map("medication_delivery")
/// v1.17.1 (#22) — APNs push token for the iOS Live Activity update/end
/// channel. ActivityKit issues a per-Activity push token distinct from the
/// device APNs token; the server stores the most recent one so the worker
/// can push a Live Activity content-state update or an end signal. NULL when
/// the device has no active Live Activity. The iOS client owns the
/// ActivityKit lifecycle and registers/clears this token; no server-side
/// behaviour hangs off it beyond addressing the update push.
liveActivityPushToken String? @map("live_activity_push_token")
lastSeen DateTime @default(now()) @map("last_seen")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -3105,6 +3182,104 @@ model Device {
@@map("devices")
}
// ─── Measurement / Vorsorge reminders ────────────────────────
/// v1.17.1 — preventive-care ("Vorsorge") reminders: "measure BP every 7
/// days", "annual blood panel", "log weight weekly". Reuses the existing
/// medication-reminder cadence vocabulary (`intervalDays` rolling cadence OR
/// an RFC-5545 `rrule`) so the recurrence engine drives the due-date math, and
/// rides the existing notification dispatcher + `push_attempts` ledger. The
/// completion target is either a `measurementType` (auto-resolved when a
/// matching measurement lands) or a free-text `label` for non-measurement
/// Vorsorge that only resolves on a manual check-off. `nextDueAt` is
/// server-computed + stored (server-authoritative — iOS consumes the resolved
/// value, never recomputes). Soft-deleted.
model MeasurementReminder {
id String @id @default(cuid())
userId String @map("user_id")
/// User-facing copy: "Blutdruck messen", "Großes Blutbild".
label String
/// Auto-resolve target. NULL = free-text / checklist Vorsorge (a blood
/// panel, a dermatology check) with no measurement to match against; those
/// resolve only on a manual satisfy. Non-NULL = the cron advances
/// `lastSatisfiedAt` when a matching Measurement of this type lands.
measurementType MeasurementType? @map("measurement_type")
/// Rolling cadence: fire every N days (7 / 30 / 365). Maps to the recurrence
/// engine's `rollingIntervalDays`. Mutually exclusive with `rrule` in
/// practice; both NULL is a one-shot reminder anchored on `anchorDate`.
intervalDays Int? @map("interval_days")
/// Optional RFC-5545 recurrence (e.g. `FREQ=YEARLY`). Maps straight through
/// the same recurrence engine the medication schedules use.
rrule String?
/// First-due anchor; the resolver falls back to `lastSatisfiedAt ?? createdAt`
/// when NULL.
anchorDate DateTime? @map("anchor_date")
/// Local hour (0–23) to fire the nudge, mirroring the mood-reminder hour.
/// DST-safe windowing is applied at the cron via the existing TZ helpers.
notifyHour Int @default(9) @map("notify_hour")
/// Free-text "wo": "Hausarzt", "Labor Dr. X". Optional.
location String?
/// Server-computed canonical next-due instant (server-authoritative).
nextDueAt DateTime? @map("next_due_at")
/// When the reminder was last fulfilled (a matching measurement or a manual
/// satisfy). Re-anchors the next-due computation.
lastSatisfiedAt DateTime? @map("last_satisfied_at")
/// Master on/off for this reminder. Distinct from soft-delete: a disabled
/// reminder is retained + editable but never fires.
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, deletedAt])
@@index([userId, nextDueAt])
@@map("measurement_reminders")
}
// ─── Lab results ─────────────────────────────────────────────
/// v1.17.1 — minimal structured lab store pairing with the Vorsorge "annual
/// blood panel" reminder. HealthLog has no first-class blood-biomarker type
/// beyond glucose, so a self-hoster who gets a panel back had nowhere to record
/// LDL / HbA1c / ferritin / TSH. This is the lean storage row: one analyte per
/// row, optional panel grouping, optional reference range. The doctor-report /
/// charting wiring lands in sibling work — this model only owns the shape.
/// Soft-deleted.
model LabResult {
id String @id @default(cuid())
userId String @map("user_id")
/// Optional grouping label, e.g. "Großes Blutbild". NULL = standalone value.
panel String?
/// The biomarker name, e.g. "LDL", "HbA1c", "Ferritin".
analyte String
value Float
unit String
/// Optional reference-range bounds as reported by the lab.
referenceLow Float? @map("reference_low")
referenceHigh Float? @map("reference_high")
/// When the sample was taken (UTC instant; wall-clock anchoring is the
/// reader's concern per the schema's UTC-only convention).
takenAt DateTime @map("taken_at")
/// Provenance: "MANUAL" today; import paths set their own source string.
source String @default("MANUAL")
/// Optional free-text note, AES-256-GCM encrypted at rest via
/// `src/lib/crypto.ts` (Bytes column, `*Encrypted` suffix per convention).
noteEncrypted Bytes? @map("note_encrypted")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
/// Soft-delete tombstone. NULL = live; non-NULL = deleted at the instant.
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, analyte, takenAt])
@@index([userId, deletedAt])
@@map("lab_results")
}
// ─── User Achievements ───────────────────────────────────────
model UserAchievement {
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.17.0";
/* @sw-version-fallback */ "v1.17.1";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
const MAX_STATIC_ENTRIES = 150;
+20
View File
@@ -137,6 +137,26 @@
}
]
},
{
"name": "SMTP (email channel)",
"description": "Operator SMTP transport for the email notification channel (v1.17.1). All of SMTP_HOST, SMTP_PORT, SMTP_FROM together enable the channel; SMTP_USER/SMTP_PASS are optional (unauthenticated relay). Missing any of the three core vars disables email silently — the channel is never offered and the Settings card hides itself.",
"required": false,
"allOrNone": true,
"variables": [
{
"name": "SMTP_HOST",
"purpose": "SMTP server hostname, e.g. smtp.example.com."
},
{
"name": "SMTP_PORT",
"purpose": "SMTP port. 587 for STARTTLS (SMTP_SECURE unset/false), 465 for implicit TLS (SMTP_SECURE=true)."
},
{
"name": "SMTP_FROM",
"purpose": "From address for outbound mail, e.g. \"HealthLog <noreply@example.com>\"."
}
]
},
{
"name": "Off-host backups",
"description": "Required ALL-OR-NONE: any of these without the rest disables backups silently.",
@@ -134,6 +134,14 @@ function isChannelConfigPresent(
if (type === "NTFY") {
return typeof parsed.topic === "string" && parsed.topic.length > 0;
}
if (type === "WEBHOOK") {
return typeof parsed.url === "string" && parsed.url.length > 0;
}
if (type === "EMAIL") {
return (
typeof parsed.recipient === "string" && parsed.recipient.length > 0
);
}
return false;
} catch {
return false;
@@ -0,0 +1,155 @@
/**
* v1.17.1 — coverage for the operator-wide notification-health admin route.
*
* Pins:
* * 401 unauthenticated / 403 non-admin (requireAdmin is cookie-only),
* * the groupBy(channel, result) aggregate folds into per-channel
* ok/error/skipped/total rows,
* * auto-disabled channels (enabled=false + disabledReason not null) are
* surfaced with a count,
* * the window is clamped to 1..168 hours (default 24).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
pushAttempt: { groupBy: vi.fn() },
notificationChannel: { groupBy: vi.fn() },
},
}));
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return { ...actual, requireAdmin: vi.fn() };
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
eventStorage: { run: <T>(_e: unknown, fn: () => Promise<T>) => fn() },
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin, HttpError } from "@/lib/api-handler";
import { NextRequest } from "next/server";
const ADMIN_CTX = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" },
} as never;
function req(hours?: string): NextRequest {
const url = hours
? `http://localhost/api/admin/notifications/health?hours=${hours}`
: "http://localhost/api/admin/notifications/health";
return new NextRequest(url);
}
interface HealthEnvelope {
data: {
windowHours: number;
channels: Array<{
channel: string;
ok: number;
error: number;
skipped: number;
total: number;
}>;
autoDisabledChannels: Array<{ type: string; count: number }>;
};
error: string | null;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.pushAttempt.groupBy).mockResolvedValue([] as never);
vi.mocked(prisma.notificationChannel.groupBy).mockResolvedValue([] as never);
});
describe("GET /api/admin/notifications/health — auth gates", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(requireAdmin).mockRejectedValue(
new HttpError(401, "Not authenticated"),
);
const res = await GET(req());
expect(res.status).toBe(401);
});
it("returns 403 for a non-admin", async () => {
vi.mocked(requireAdmin).mockRejectedValue(
new HttpError(403, "Admin access required"),
);
const res = await GET(req());
expect(res.status).toBe(403);
});
});
describe("GET /api/admin/notifications/health — aggregate", () => {
beforeEach(() => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
});
it("folds groupBy rows into per-channel ok/error/skipped/total", async () => {
vi.mocked(prisma.pushAttempt.groupBy).mockResolvedValue([
{ channel: "NTFY", result: "ok", _count: { _all: 40 } },
{ channel: "NTFY", result: "error", _count: { _all: 12 } },
{ channel: "EMAIL", result: "ok", _count: { _all: 5 } },
{ channel: "EMAIL", result: "skipped", _count: { _all: 2 } },
] as never);
const res = await GET(req());
expect(res.status).toBe(200);
const body = (await res.json()) as HealthEnvelope;
const ntfy = body.data.channels.find((c) => c.channel === "NTFY");
expect(ntfy).toEqual({
channel: "NTFY",
ok: 40,
error: 12,
skipped: 0,
total: 52,
});
const email = body.data.channels.find((c) => c.channel === "EMAIL");
expect(email).toEqual({
channel: "EMAIL",
ok: 5,
error: 0,
skipped: 2,
total: 7,
});
// Sorted alphabetically.
expect(body.data.channels.map((c) => c.channel)).toEqual(["EMAIL", "NTFY"]);
});
it("surfaces auto-disabled channels with a count", async () => {
vi.mocked(prisma.notificationChannel.groupBy).mockResolvedValue([
{ type: "WEBHOOK", _count: { _all: 3 } },
] as never);
const res = await GET(req());
const body = (await res.json()) as HealthEnvelope;
expect(body.data.autoDisabledChannels).toEqual([
{ type: "WEBHOOK", count: 3 },
]);
});
it("defaults the window to 24h and clamps out-of-range values", async () => {
let res = await GET(req("999"));
let body = (await res.json()) as HealthEnvelope;
expect(body.data.windowHours).toBe(24);
res = await GET(req("0"));
body = (await res.json()) as HealthEnvelope;
expect(body.data.windowHours).toBe(24);
res = await GET(req("48"));
body = (await res.json()) as HealthEnvelope;
expect(body.data.windowHours).toBe(48);
});
});
@@ -0,0 +1,103 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
export const dynamic = "force-dynamic";
/**
* v1.17.1 — operator-wide notification delivery health.
*
* The per-self diagnostic (`/api/admin/notifications/diagnostic`) only shows
* the calling admin's own account. On a multi-user instance the operator
* cannot answer "are anyone's pushes failing?" without DB shell. This route
* does a fleet-wide `groupBy(channel, result)` over the trailing window of the
* `push_attempts` ledger, plus a list of channels currently auto-disabled
* (across ALL users), so the operator can spot a flapping upstream that
* silently disabled a channel after five failures.
*
* Admin-only (cookie auth, never Bearer) by construction via `requireAdmin()`.
* The aggregate is anonymous: it counts channel×result, it never names a user.
*/
interface ChannelHealthRow {
channel: string;
ok: number;
error: number;
skipped: number;
total: number;
}
interface DisabledChannelRow {
type: string;
count: number;
}
interface HealthPayload {
windowHours: number;
since: string;
channels: ChannelHealthRow[];
autoDisabledChannels: DisabledChannelRow[];
}
export const GET = apiHandler(async (request) => {
await requireAdmin();
annotate({ action: { name: "admin.notifications.health" } });
// Window is operator-tunable via ?hours=, clamped 1..168 (7 days). The
// `(channel, result, created_at)`-friendly index on push_attempts keeps the
// groupBy bounded regardless of instance lifetime volume.
const url = new URL(request.url);
const hoursRaw = Number(url.searchParams.get("hours"));
const windowHours =
Number.isFinite(hoursRaw) && hoursRaw >= 1 && hoursRaw <= 168
? Math.floor(hoursRaw)
: 24;
const since = new Date(Date.now() - windowHours * 60 * 60 * 1000);
const [grouped, disabled] = await Promise.all([
prisma.pushAttempt.groupBy({
by: ["channel", "result"],
where: { createdAt: { gte: since } },
_count: { _all: true },
}),
// Channels disabled with an automated reason. A manual disable leaves
// `disabledReason` NULL (see schema comment), so a non-null reason on a
// disabled channel is the auto-disable signal we surface to the operator.
prisma.notificationChannel.groupBy({
by: ["type"],
where: { enabled: false, disabledReason: { not: null } },
_count: { _all: true },
}),
]);
const byChannel = new Map<string, ChannelHealthRow>();
for (const row of grouped) {
const existing =
byChannel.get(row.channel) ??
({ channel: row.channel, ok: 0, error: 0, skipped: 0, total: 0 } satisfies ChannelHealthRow);
const count = row._count._all;
if (row.result === "ok") existing.ok += count;
else if (row.result === "error") existing.error += count;
else if (row.result === "skipped") existing.skipped += count;
existing.total += count;
byChannel.set(row.channel, existing);
}
const channels = Array.from(byChannel.values()).sort((a, b) =>
a.channel.localeCompare(b.channel),
);
const autoDisabledChannels: DisabledChannelRow[] = disabled
.map((d) => ({ type: d.type, count: d._count._all }))
.sort((a, b) => a.type.localeCompare(b.type));
const payload: HealthPayload = {
windowHours,
since: since.toISOString(),
channels,
autoDisabledChannels,
};
return apiSuccess(payload);
});
@@ -0,0 +1,192 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
h: T,
): T => h,
requireAdmin: vi.fn(),
};
});
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((value: string) => `enc(${value})`),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateAppSettings: vi.fn(),
}));
vi.mock("web-push", () => ({
generateVAPIDKeys: vi.fn(() => ({
publicKey: "GENERATED_PUBLIC",
privateKey: "GENERATED_PRIVATE",
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin, HttpError } from "@/lib/api-handler";
import { encrypt } from "@/lib/crypto";
import { auditLog } from "@/lib/auth/audit";
const ADMIN_CTX = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "admin-1", username: "admin", role: "ADMIN" } as never,
};
function jsonReq(body: unknown): NextRequest {
return new NextRequest(
"http://localhost/api/admin/settings/web-push-vapid/generate",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
},
);
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
id: "singleton",
} as never);
});
describe("POST /api/admin/settings/web-push-vapid/generate", () => {
it("rejects with 401 when no session (admin-gated)", async () => {
vi.mocked(requireAdmin).mockRejectedValue(
new HttpError(401, "Not authenticated"),
);
await expect(POST(jsonReq({}))).rejects.toThrow("Not authenticated");
});
it("rejects with 403 for a non-admin (cookie-only boundary)", async () => {
vi.mocked(requireAdmin).mockRejectedValue(
new HttpError(403, "Admin access required"),
);
await expect(POST(jsonReq({}))).rejects.toThrow("Admin access required");
});
it("generates a keypair and stores the private key encrypted", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
const res = await POST(jsonReq({}));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
webPushVapidPublicKey: string;
webPushVapidSubject: string;
webPushVapidConfigured: boolean;
};
};
expect(body.data.webPushVapidPublicKey).toBe("GENERATED_PUBLIC");
expect(body.data.webPushVapidConfigured).toBe(true);
// Default placeholder subject seeded so the keypair is immediately valid.
expect(body.data.webPushVapidSubject).toBe("mailto:admin@example.com");
// Private key encrypted before persistence; never returned in the body.
expect(encrypt).toHaveBeenCalledWith("GENERATED_PRIVATE");
expect(JSON.stringify(body.data)).not.toContain("GENERATED_PRIVATE");
const upsert = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
expect(upsert?.where).toEqual({ id: "singleton" });
expect(upsert?.update).toMatchObject({
webPushVapidPublicKey: "GENERATED_PUBLIC",
webPushVapidPrivateKeyEncrypted: "enc(GENERATED_PRIVATE)",
});
});
it("never logs the plaintext private key in the audit detail", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
await POST(jsonReq({}));
expect(auditLog).toHaveBeenCalled();
const details = vi.mocked(auditLog).mock.calls[0]?.[1]?.details as Record<
string,
unknown
>;
expect(JSON.stringify(details)).not.toContain("GENERATED_PRIVATE");
expect(details.webPushVapidPrivateKeyUpdated).toBe(true);
expect(details.replacedExisting).toBe(false);
});
it("refuses with 409 when keys already exist and force is absent", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
webPushVapidPublicKey: "OLD_PUBLIC",
webPushVapidPrivateKeyEncrypted: "OLD_ENC",
webPushVapidSubject: "mailto:old@example.com",
} as never);
const res = await POST(jsonReq({}));
expect(res.status).toBe(409);
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
});
it("overwrites existing keys when force is true and keeps the subject", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
webPushVapidPublicKey: "OLD_PUBLIC",
webPushVapidPrivateKeyEncrypted: "OLD_ENC",
webPushVapidSubject: "mailto:keep@example.com",
} as never);
const res = await POST(jsonReq({ force: true }));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { webPushVapidSubject: string };
};
// Existing subject preserved when no override is supplied.
expect(body.data.webPushVapidSubject).toBe("mailto:keep@example.com");
const details = vi.mocked(auditLog).mock.calls[0]?.[1]?.details as Record<
string,
unknown
>;
expect(details.replacedExisting).toBe(true);
});
it("rejects an invalid subject override with 422", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
const res = await POST(jsonReq({ subject: "not-a-mailto" }));
expect(res.status).toBe(422);
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
});
it("returns 415 when content-type is not JSON", async () => {
const r = new NextRequest(
"http://localhost/api/admin/settings/web-push-vapid/generate",
{
method: "POST",
headers: { "content-type": "text/plain" },
body: "{}",
},
);
const res = await POST(r);
expect(res.status).toBe(415);
});
});
@@ -0,0 +1,132 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { invalidateAppSettings } from "@/lib/cache/invalidate";
import { z } from "zod/v4";
import { NextRequest } from "next/server";
export const dynamic = "force-dynamic";
const generateSchema = z
.object({
// Overwrite guard — the client must opt in to replacing an existing
// keypair. Regenerating invalidates every browser subscription signed
// against the old public key, so the UI confirms before sending this.
force: z.boolean().optional(),
// Optional subject override. When omitted the existing subject is kept,
// or a neutral placeholder seeded so the keypair is immediately usable.
subject: z
.string()
.refine((v) => !v.trim() || /^mailto:.+@.+$/.test(v.trim()), {
message: "subject must be in mailto:address@example.com format",
})
.optional(),
})
.strict();
/**
* POST /api/admin/settings/web-push-vapid/generate
*
* Server-side VAPID keypair generation — the self-hoster DX win. Calls
* `web-push`'s `generateVAPIDKeys()`, encrypts the private key at rest
* (`src/lib/crypto.ts`), and persists the pair onto the AppSettings
* singleton. Returns only the public key + subject; the private key never
* leaves the server and is never logged (the egress redactor covers the
* encrypted blob, and the audit detail carries only a "generated" marker).
*
* Cookie-only via `requireAdmin()` — a Bearer token can never reach it.
*
* Overwrite guard: if a keypair already exists, the call refuses with 409
* unless `force: true` is supplied. Regenerating invalidates existing
* browser subscriptions, so the operator must opt in.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.webPushVapid.generate" } });
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = generateSchema.safeParse(body ?? {});
if (!parsed.success) {
return apiError("Invalid request", 422);
}
const existing = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: {
webPushVapidPublicKey: true,
webPushVapidPrivateKeyEncrypted: true,
webPushVapidSubject: true,
},
});
const alreadyConfigured = Boolean(
existing?.webPushVapidPublicKey &&
existing?.webPushVapidPrivateKeyEncrypted,
);
if (alreadyConfigured && parsed.data.force !== true) {
// Overwrite guard — surface the existing public key so the UI can warn
// the operator that regenerating invalidates current subscriptions.
return apiError(
"VAPID keys already configured. Regenerating invalidates existing browser subscriptions. Resend with force to replace them.",
409,
);
}
// Lazy import — mirrors the sender so a build without `web-push` still
// type-checks. `generateVAPIDKeys()` returns Base64URL public + private.
const webpush = await import("web-push");
const { publicKey, privateKey } = webpush.generateVAPIDKeys();
// Subject precedence: explicit override → existing subject → placeholder.
// The placeholder keeps the keypair immediately valid; the admin edits
// the real contact address inline in the card afterwards.
const subjectOverride = parsed.data.subject?.trim();
const subject =
(subjectOverride && subjectOverride.length > 0
? subjectOverride
: existing?.webPushVapidSubject) || "mailto:admin@example.com";
await prisma.appSettings.upsert({
where: { id: "singleton" },
update: {
webPushVapidPublicKey: publicKey,
webPushVapidPrivateKeyEncrypted: encrypt(privateKey),
webPushVapidSubject: subject,
},
create: {
id: "singleton",
webPushVapidPublicKey: publicKey,
webPushVapidPrivateKeyEncrypted: encrypt(privateKey),
webPushVapidSubject: subject,
},
});
invalidateAppSettings();
await auditLog("admin.web_push_vapid.generate", {
userId: user.id,
ipAddress: getClientIp(request),
// Never the private key — only a marker that a fresh pair was minted
// and whether it replaced an existing one.
details: {
webPushVapidPrivateKeyUpdated: true,
replacedExisting: alreadyConfigured,
webPushVapidSubject: subject,
},
});
// Public key + subject only; the private key stays server-side.
return apiSuccess({
webPushVapidPublicKey: publicKey,
webPushVapidSubject: subject,
webPushVapidConfigured: true,
});
});
@@ -91,6 +91,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => {
compliance: true,
sleep: false,
cycle: false,
labs: true,
};
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
expect(res.status).toBe(200);
@@ -114,6 +115,7 @@ describe("PUT /api/auth/me/doctor-report-prefs", () => {
compliance: false,
sleep: false,
cycle: false,
labs: false,
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
doctorReportPrefsJson: current,
@@ -103,6 +103,9 @@ describe("GET /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
});
});
@@ -135,6 +138,9 @@ describe("GET /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
});
});
@@ -169,6 +175,9 @@ describe("GET /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
});
});
});
@@ -214,6 +223,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
});
expect(prisma.user.update).toHaveBeenCalledWith({
@@ -235,6 +247,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
},
},
});
@@ -260,6 +275,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
},
next: {
medication: {
@@ -277,6 +295,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
},
changed: ["medication"],
}),
@@ -347,6 +368,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
},
},
});
@@ -382,6 +406,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
});
expect(prisma.user.update).toHaveBeenCalledWith({
@@ -403,6 +430,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
},
},
});
@@ -443,6 +473,9 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
nudgeRoutine: true,
nudgeFrequency: "weekly",
},
measurementReminder: {
clientManaged: false,
},
},
},
});
@@ -250,6 +250,52 @@ describe("POST /api/devices", () => {
);
expect(res.status).toBe(201);
});
// v1.17.1 (#22) — Live Activity push-token registration.
it("persists liveActivityPushToken on create when supplied", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(
req({
token: "abcd1234efgh5678",
bundleId: "io.healthlog.app",
liveActivityPushToken: "cafebabe".repeat(8),
}),
);
expect(res.status).toBe(201);
const data = vi.mocked(prisma.device.create).mock.calls[0][0].data;
expect(data.liveActivityPushToken).toBe("cafebabe".repeat(8));
});
it("clears liveActivityPushToken on re-register when null is sent", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.device.findUnique).mockResolvedValue({
id: "dev-1",
userId: "user-1",
token: "abcd1234efgh5678",
} as never);
const res = await POST(
req({
token: "abcd1234efgh5678",
bundleId: "io.healthlog.app",
liveActivityPushToken: null,
}),
);
expect(res.status).toBe(201);
const data = vi.mocked(prisma.device.update).mock.calls[0][0].data;
expect(data.liveActivityPushToken).toBeNull();
});
it("rejects a non-hex liveActivityPushToken with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(
req({
token: "abcd1234efgh5678",
bundleId: "io.healthlog.app",
liveActivityPushToken: "not-hex-!!!",
}),
);
expect(res.status).toBe(422);
});
});
describe("POST /api/devices — 422 multi-issue envelope (v1.4.43 W6)", () => {
+19
View File
@@ -63,6 +63,19 @@ const deviceSchema = z
// server APNs for this device; "client" forces local. Stored +
// echoed; cron suppression stays user-level.
medicationDelivery: z.enum(["server", "client"]).nullable().optional(),
// v1.17.1 (#22) — ActivityKit issues a per-Activity push token distinct
// from the device APNs token. The iOS client registers the current one
// here so the server can address a Live Activity update / end push on a
// medication-intake mutation; it sends `null` to clear the token when no
// Activity is running. Hex, like the APNs token. Only touched when the
// field is present so an ordinary re-register keeps the prior value.
liveActivityPushToken: z
.string()
.min(8)
.max(256)
.regex(/^[A-Fa-f0-9]+$/, "liveActivityPushToken must be hex")
.nullable()
.optional(),
})
.refine(
(v) =>
@@ -146,6 +159,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
apnsToken,
apnsEnvironment,
medicationDelivery,
liveActivityPushToken,
} = parsed.data;
// APNs-token collision lookup. Two cases:
@@ -212,6 +226,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
// v1.7.0 — only touch the override when the client sends the
// field, so a re-register that omits it keeps the prior value.
...(medicationDelivery !== undefined && { medicationDelivery }),
// v1.17.1 (#22) — same omit-keeps-prior rule; `null` explicitly
// clears the token when the Activity ends.
...(liveActivityPushToken !== undefined && { liveActivityPushToken }),
lastSeen: new Date(),
},
select: { id: true },
@@ -231,6 +248,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
apnsEnvironment: apnsEnvironment ?? null,
// v1.7.0 — per-device delivery override (null = inherit default).
...(medicationDelivery !== undefined && { medicationDelivery }),
// v1.17.1 (#22) — Live Activity push token (null = no Activity).
...(liveActivityPushToken !== undefined && { liveActivityPushToken }),
},
select: { id: true },
});
@@ -9,6 +9,7 @@ vi.mock("@/lib/db", () => ({
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
labResult: { findMany: vi.fn() },
user: { findUnique: vi.fn(), update: vi.fn() },
auditLog: { create: vi.fn() },
},
@@ -71,6 +72,7 @@ function setEmptyDataMocks() {
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "testuser",
dateOfBirth: null,
@@ -17,6 +17,7 @@ vi.mock("@/lib/db", () => ({
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
labResult: { findMany: vi.fn() },
user: { findUnique: vi.fn(), update: vi.fn() },
},
}));
@@ -74,6 +75,7 @@ beforeEach(() => {
[] as never,
);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.labResult.findMany).mockResolvedValue([] as never);
// First findUnique = aggregator profile select; second = route KVNR select.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "sample",
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(), update: vi.fn() },
fitbitConnection: { delete: vi.fn() },
},
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() }));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
apiError: (error: string, status: number) => ({ data: null, error, status }),
safeJson: async (req: NextRequest) => {
try {
return { data: await req.json(), error: null };
} catch {
return { data: null, error: { status: 400 } };
}
},
}));
import { PUT, DELETE } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { markDisconnected } from "@/lib/integrations/status";
const userUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
function req(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/fitbit/credentials", {
method: "PUT",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
describe("/api/fitbit/credentials", () => {
beforeEach(() => vi.clearAllMocks());
it("PUT 422s on missing fields", async () => {
const res = (await (
PUT as unknown as (r: NextRequest) => Promise<{ status: number }>
)(req({ clientId: "" }))) as { status: number };
expect(res.status).toBe(422);
expect(userUpdate).not.toHaveBeenCalled();
});
it("DELETE clears credentials, audits the teardown, and parks the ledger (04-L1 parity)", async () => {
userUpdate.mockResolvedValue({});
(
prisma.fitbitConnection.delete as ReturnType<typeof vi.fn>
).mockResolvedValue({});
const res = (await (DELETE as unknown as () => Promise<{ data: unknown }>)())
.data as { deleted: boolean };
expect(res.deleted).toBe(true);
expect(userUpdate).toHaveBeenCalledWith({
where: { id: "u1" },
data: {
fitbitClientIdEncrypted: null,
fitbitClientSecretEncrypted: null,
},
});
expect(auditLog).toHaveBeenCalledWith("fitbit.credentials.delete", {
userId: "u1",
});
expect(markDisconnected).toHaveBeenCalledWith("u1", "fitbit");
});
});
+8
View File
@@ -1,8 +1,10 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { markDisconnected } from "@/lib/integrations/status";
import { fitbitCredentialsSchema } from "@/lib/validations/fitbit";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -60,6 +62,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
/**
* Delete Fitbit credentials and the active connection.
*
* Audits the teardown and parks the integration ledger at `disconnected` for
* parity with the Polar / Oura credential-DELETE so a sensitive op leaves a
* uniform audit trail and the status snapshot does not linger at a stale state.
*/
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
@@ -76,6 +82,8 @@ export const DELETE = apiHandler(async () => {
fitbitClientSecretEncrypted: null,
},
});
await auditLog("fitbit.credentials.delete", { userId: user.id });
await markDisconnected(user.id, "fitbit");
return apiSuccess({ deleted: true });
});
+144 -2
View File
@@ -10,8 +10,8 @@ vi.mock("@/lib/api-handler", () => ({
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { create: vi.fn() },
moodEntry: { create: vi.fn() },
measurement: { create: vi.fn(), upsert: vi.fn() },
moodEntry: { create: vi.fn(), upsert: vi.fn() },
},
}));
@@ -241,3 +241,145 @@ describe("POST /api/import — measurement rollup hook (v1.4.39.1)", () => {
expect(recomputeBucketsForMeasurement).not.toHaveBeenCalled();
});
});
// v1.17.1 — two real bugs the data-portability audit found in the JSON
// import. Regression guards so a future refactor can't silently re-open
// them.
describe("POST /api/import — entry-instant bound (v1.17.1)", () => {
beforeEach(() => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 4,
resetAt: new Date(Date.now() + 1000),
} as never);
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
});
it("rejects a future-dated measurement (previously accepted)", async () => {
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
const response = await POST(
new NextRequest("http://localhost/api/import", {
method: "POST",
body: JSON.stringify({
measurements: [
{ type: "WEIGHT", value: 80, unit: "kg", measuredAt: future },
],
}),
headers: { "content-type": "application/json" },
}),
);
expect(response.status).toBe(422);
expect(vi.mocked(prisma.measurement.create)).not.toHaveBeenCalled();
});
it("rejects a pre-1900 measurement", async () => {
const response = await POST(
new NextRequest("http://localhost/api/import", {
method: "POST",
body: JSON.stringify({
measurements: [
{
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: "1899-01-01T00:00:00.000Z",
},
],
}),
headers: { "content-type": "application/json" },
}),
);
expect(response.status).toBe(422);
});
it("accepts a backdated (past) measurement", async () => {
const response = await POST(
new NextRequest("http://localhost/api/import", {
method: "POST",
body: JSON.stringify({
measurements: [
{
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: "2020-01-01T08:00:00.000Z",
},
],
}),
headers: { "content-type": "application/json" },
}),
);
expect(response.status).toBeLessThan(400);
expect(vi.mocked(prisma.measurement.create)).toHaveBeenCalledTimes(1);
});
});
describe("POST /api/import — measurement externalId dedup (v1.17.1)", () => {
beforeEach(() => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 4,
resetAt: new Date(Date.now() + 1000),
} as never);
});
it("upserts (not creates) when a measurement carries an externalId", async () => {
vi.mocked(prisma.measurement.upsert).mockResolvedValue({} as never);
const response = await POST(
new NextRequest("http://localhost/api/import", {
method: "POST",
body: JSON.stringify({
measurements: [
{
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: "2026-05-01T08:00:00.000Z",
externalId: "scale-row-42",
},
],
}),
headers: { "content-type": "application/json" },
}),
);
expect(response.status).toBeLessThan(400);
expect(vi.mocked(prisma.measurement.upsert)).toHaveBeenCalledTimes(1);
expect(vi.mocked(prisma.measurement.create)).not.toHaveBeenCalled();
const arg = vi.mocked(prisma.measurement.upsert).mock.calls[0][0];
expect(arg.where).toEqual({
userId_type_source_externalId: {
userId: "u-1",
type: "WEIGHT",
source: "IMPORT",
externalId: "scale-row-42",
},
});
});
it("still creates (first-write-wins) when no externalId is present", async () => {
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
const response = await POST(
new NextRequest("http://localhost/api/import", {
method: "POST",
body: JSON.stringify({
measurements: [
{
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: "2026-05-01T08:00:00.000Z",
},
],
}),
headers: { "content-type": "application/json" },
}),
);
expect(response.status).toBeLessThan(400);
expect(vi.mocked(prisma.measurement.create)).toHaveBeenCalledTimes(1);
expect(vi.mocked(prisma.measurement.upsert)).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,189 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({
user: { id: "u-1" },
session: { id: "s-1" },
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { create: vi.fn(), upsert: vi.fn() },
},
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/rollups/measurement-rollups", async () => {
const actual = await vi.importActual<
typeof import("@/lib/rollups/measurement-rollups")
>("@/lib/rollups/measurement-rollups");
return {
...actual,
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
};
});
import { NextRequest } from "next/server";
import { POST } from "../route";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
const HEADER = "type,value,unit,measuredAt,glucoseContext,notes,externalId";
function csvRequest(csv: string, dryRun = false) {
return new NextRequest(
`http://localhost/api/import/csv${dryRun ? "?dryRun=1" : ""}`,
{
method: "POST",
body: csv,
headers: { "content-type": "text/csv" },
},
);
}
interface CsvEnvelope {
data: {
inserted: number;
updated: number;
skipped: number;
total: number;
dryRun: boolean;
rows: Array<{ line: number; status: string; reason?: string }>;
} | null;
error: string | null;
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 4,
resetAt: new Date(Date.now() + 1000),
} as never);
});
describe("POST /api/import/csv — rate limit", () => {
it("returns 429 against the shared import bucket when exhausted", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: new Date(Date.now() + 1000),
} as never);
const res = await POST(csvRequest([HEADER, ""].join("\n")));
expect(res.status).toBe(429);
expect(checkRateLimit).toHaveBeenCalledWith(
expect.stringContaining("import:u-1"),
5,
60 * 60 * 1000,
);
});
});
describe("POST /api/import/csv — fatal header error", () => {
it("returns 422 when a required column is missing", async () => {
const res = await POST(csvRequest("type,value,unit\nWEIGHT,80,kg"));
expect(res.status).toBe(422);
expect(vi.mocked(prisma.measurement.create)).not.toHaveBeenCalled();
});
});
describe("POST /api/import/csv — write loop + per-row envelope", () => {
it("inserts valid rows, skips invalid ones, returns per-row status", async () => {
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
const res = await POST(
csvRequest(
[
HEADER,
"WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,morning,", // inserted
"NOPE,1,kg,2026-05-01T08:00:00Z,,,", // skipped unknown_type
"WEIGHT,80,kg,2026-05-01T08:00:00,,,", // skipped missing offset
].join("\n"),
),
);
expect(res.status).toBe(200);
const body = (await res.json()) as CsvEnvelope;
expect(body.data?.inserted).toBe(1);
expect(body.data?.skipped).toBe(2);
expect(body.data?.total).toBe(3);
expect(body.data?.dryRun).toBe(false);
const reasons = body.data?.rows.map((r) => r.reason);
expect(reasons).toContain("unknown_type");
expect(reasons).toContain("missing_timezone_offset");
// One create call for the single valid row.
expect(vi.mocked(prisma.measurement.create)).toHaveBeenCalledTimes(1);
// Rollup re-fold fired for the touched (type, day).
expect(vi.mocked(recomputeBucketsForMeasurement)).toHaveBeenCalled();
});
it("dryRun previews without writing and reports projected inserts", async () => {
const res = await POST(
csvRequest(
[HEADER, "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,"].join("\n"),
true,
),
);
expect(res.status).toBe(200);
const body = (await res.json()) as CsvEnvelope;
expect(body.data?.dryRun).toBe(true);
expect(body.data?.inserted).toBe(1);
expect(body.data?.rows[0].status).toBe("inserted");
expect(vi.mocked(prisma.measurement.create)).not.toHaveBeenCalled();
expect(vi.mocked(recomputeBucketsForMeasurement)).not.toHaveBeenCalled();
});
it("upserts an externalId row and surfaces it as updated when it already existed", async () => {
const created = new Date("2026-05-01T08:00:00Z");
const bumped = new Date("2026-05-02T08:00:00Z");
vi.mocked(prisma.measurement.upsert).mockResolvedValue({
createdAt: created,
updatedAt: bumped,
} as never);
const res = await POST(
csvRequest(
[HEADER, "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,ext-1"].join("\n"),
),
);
const body = (await res.json()) as CsvEnvelope;
expect(body.data?.updated).toBe(1);
expect(body.data?.inserted).toBe(0);
expect(body.data?.rows[0].status).toBe("updated");
expect(vi.mocked(prisma.measurement.upsert)).toHaveBeenCalledTimes(1);
});
it("counts a unique-constraint duplicate as skipped/duplicate", async () => {
vi.mocked(prisma.measurement.create).mockRejectedValue(
new Error("Unique constraint failed"),
);
const res = await POST(
csvRequest(
[HEADER, "WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,,"].join("\n"),
),
);
const body = (await res.json()) as CsvEnvelope;
expect(body.data?.inserted).toBe(0);
expect(body.data?.skipped).toBe(1);
expect(body.data?.rows[0]).toMatchObject({
status: "skipped",
reason: "duplicate",
});
});
});
+254
View File
@@ -0,0 +1,254 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
import {
parseCsvMeasurements,
type CsvRowResult,
} from "@/lib/import/csv-measurements";
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/rollups/measurement-rollups";
import type {
MeasurementType,
GlucoseContext,
} from "@/generated/prisma/client";
/**
* CSV measurement import (v1.17.1).
*
* The cold-start escape hatch for self-hosters migrating from a spreadsheet
* or another tracker. Reuses the `/api/import` write loop + rollup re-fold;
* the parse + per-row validation + unit conversion lives in the pure
* `parseCsvMeasurements` module so it is unit-tested without a database.
*
* Body is `text/csv` (or `text/plain`) raw text — NOT JSON. Capped at 16 MB
* (the same ceiling as the JSON import; a 16 MB CSV is > 100 000 rows). Rows
* beyond the 10 000 ceiling are rejected so the rollup re-fold stays bounded.
*
* `?dryRun=1` parses + validates + returns the per-row status envelope
* WITHOUT writing — a trust win for a bulk cold-start import.
*
* Response: `{ inserted, updated, skipped, total, dryRun, rows: [{line,
* status, reason}] }`, mirroring the batch route's per-entry envelope.
*/
const MAX_CSV_BYTES = 16 * 1024 * 1024;
const MAX_ROWS = 10000;
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "import.csv.upload" } });
const dryRun =
new URL(request.url).searchParams.get("dryRun") === "1" ||
new URL(request.url).searchParams.get("dryRun") === "true";
// Same blast-radius reasoning as the JSON import — share the bucket so a
// CSV upload counts against the same 5/hour quota.
const rl = await checkRateLimit(`import:${user.id}`, 5, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximum 5 imports per hour", 429);
}
// Raw text read with an explicit byte cap — `safeJson` is JSON-only, so we
// enforce the same `maxBytes` ceiling here against the Content-Length and
// the materialised body.
const declaredLength = Number(request.headers.get("content-length") ?? "0");
if (Number.isFinite(declaredLength) && declaredLength > MAX_CSV_BYTES) {
return apiError("CSV exceeds the 16 MB limit", 413);
}
let text: string;
try {
text = await request.text();
} catch {
return apiError("Could not read the request body", 400);
}
if (Buffer.byteLength(text, "utf8") > MAX_CSV_BYTES) {
return apiError("CSV exceeds the 16 MB limit", 413);
}
const parsed = parseCsvMeasurements(text);
if (parsed.fatal) {
return apiError(parsed.fatal.message, 422);
}
const okRows = parsed.rows.filter(
(r): r is CsvRowResult & { row: NonNullable<CsvRowResult["row"]> } =>
r.status === "ok" && r.row !== undefined,
);
if (okRows.length > MAX_ROWS) {
return apiError(
`CSV has ${okRows.length} valid rows — the limit is ${MAX_ROWS} per import`,
422,
);
}
const userId = user.id;
let inserted = 0;
let updated = 0;
let skipped = parsed.rows.filter((r) => r.status === "skipped").length;
const touchedMeasurements: Array<{
type: MeasurementType;
measuredAt: Date;
}> = [];
// Per-line write outcome for rows that reached the DB. Keyed by source line
// so the response envelope can surface `inserted` / `updated` / `duplicate`
// distinctly without polluting the parse-time `CsvRowReason`.
const writeOutcome = new Map<number, "inserted" | "updated" | "duplicate">();
if (!dryRun) {
for (const result of okRows) {
const m = result.row;
try {
if (m.externalId) {
// Idempotent re-import keyed on the source-stable id — re-uploading
// the same file updates in place rather than minting duplicates.
const res = await prisma.measurement.upsert({
where: {
userId_type_source_externalId: {
userId,
type: m.type as MeasurementType,
source: "IMPORT",
externalId: m.externalId,
},
},
update: {
value: m.value,
unit: m.unit,
measuredAt: m.measuredAt,
notes: m.notes ?? null,
glucoseContext: (m.glucoseContext as GlucoseContext | undefined) ?? null,
},
create: {
userId,
type: m.type as MeasurementType,
value: m.value,
unit: m.unit,
source: "IMPORT",
externalId: m.externalId,
measuredAt: m.measuredAt,
notes: m.notes ?? null,
glucoseContext: (m.glucoseContext as GlucoseContext | undefined) ?? null,
},
select: { createdAt: true, updatedAt: true },
});
// A fresh create has createdAt === updatedAt; an in-place update
// bumps updatedAt past createdAt.
if (res.updatedAt.getTime() > res.createdAt.getTime()) {
updated++;
writeOutcome.set(result.line, "updated");
} else {
inserted++;
writeOutcome.set(result.line, "inserted");
}
} else {
await prisma.measurement.create({
data: {
userId,
type: m.type as MeasurementType,
value: m.value,
unit: m.unit,
source: "IMPORT",
measuredAt: m.measuredAt,
notes: m.notes ?? null,
glucoseContext: (m.glucoseContext as GlucoseContext | undefined) ?? null,
},
});
inserted++;
writeOutcome.set(result.line, "inserted");
}
touchedMeasurements.push({
type: m.type as MeasurementType,
measuredAt: m.measuredAt,
});
} catch {
// Unique-constraint violation (a NULL-externalId duplicate against an
// existing row) — count as skipped rather than failing the file.
skipped++;
writeOutcome.set(result.line, "duplicate");
}
}
// One bounded rollup re-fold per touched (type, day) — a 10 000-row CSV
// pays at most ~N (type, day) recomputes, not 10 000 per-row hooks.
// Best-effort: a populator hiccup never fails the importer.
if (touchedMeasurements.length > 0) {
try {
const keys = collapseToTypeDayKeys(touchedMeasurements);
for (const k of keys) {
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
}
} catch (err) {
annotate({
meta: {
measurement_rollup_csv_import_failed: true,
measurement_rollup_csv_import_error:
err instanceof Error ? err.message : String(err),
},
});
}
}
await auditLog("import.csv.upload", {
userId,
ipAddress: getClientIp(request),
details: { inserted, updated, skipped },
});
} else {
// Dry-run preview: no write ran, so report the projected outcome —
// every valid parse-`ok` row would insert (an externalId row could land
// as an update, but that needs a DB read we deliberately skip here).
inserted = okRows.length;
}
const summary = {
inserted,
updated,
skipped,
total: parsed.rows.length,
dryRun,
// Per-row status, mirroring the batch route's per-entry envelope. The
// normalised payload is stripped — the client only needs line + status.
// Parse-time `ok` rows are refined by the write outcome: a row that hit a
// unique-constraint duplicate becomes `skipped`/`duplicate`; an upsert
// that landed on an existing row becomes `updated`. In `dryRun` mode no
// write ran, so every parse-`ok` row stays `inserted` (the projected
// outcome).
rows: parsed.rows.map((r) => {
if (r.status === "skipped") {
return {
line: r.line,
status: "skipped" as const,
...(r.reason ? { reason: r.reason } : {}),
};
}
const outcome = writeOutcome.get(r.line);
if (outcome === "duplicate") {
return { line: r.line, status: "skipped" as const, reason: "duplicate" };
}
if (outcome === "updated") {
return { line: r.line, status: "updated" as const };
}
return { line: r.line, status: "inserted" as const };
}),
};
annotate({
meta: {
import_csv_inserted: inserted,
import_csv_updated: updated,
import_csv_skipped: skipped,
import_csv_dry_run: dryRun,
},
});
return apiSuccess(summary);
});
+70 -16
View File
@@ -17,6 +17,7 @@ import {
measurementTypeEnum,
glucoseContextEnum,
} from "@/lib/validations/measurement";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
import {
collapseToTypeDayKeys,
@@ -32,10 +33,23 @@ const measurementSchema = z
type: measurementTypeEnum,
value: z.number(),
unit: z.string(),
measuredAt: z.string().datetime(),
// v1.17.1 — share the canonical entry-instant bound used by the single
// POST + edit paths (v1.17 W1b). Previously a bare `z.string().datetime()`
// with no ceiling, so a crafted JSON import could forward-date a reading.
// `validateEntryInstant` rejects any future instant beyond a 5-min skew
// tolerance and any instant before 1900; the field transforms to a `Date`.
measuredAt: validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
),
glucoseContext: glucoseContextEnum.optional(),
source: z.string().optional(),
notes: z.string().optional(),
// v1.17.1 — optional source-stable id. When present, the import upserts on
// `(userId, type, source=IMPORT, externalId)` so a re-import of the same
// export is idempotent rather than minting a duplicate set — mirrors the
// mood path. Absent → first-write-wins create with a NULL externalId
// (distinct in the unique key), which DOES duplicate on re-import.
externalId: z.string().min(1).max(120).optional(),
})
.refine(
(data) => validateMeasurementRange(data.type, data.value) === null,
@@ -47,7 +61,11 @@ const moodEntrySchema = z.object({
mood: z.enum(["SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG"]),
score: z.number().int().min(1).max(5),
tags: z.string().optional(),
loggedAt: z.string().datetime().optional(),
// v1.17.1 — same entry-instant bound as measurements; an imported mood
// log cannot be forward-dated. Optional + transforms to a `Date` when set.
loggedAt: validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
).optional(),
// v1.12.1 — optional source-stable id (e.g. a Daylio row id). When
// present, the import upserts on `(userId, source, externalId)` so a
// re-import of the same export is idempotent rather than minting a
@@ -106,19 +124,55 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (data.measurements?.length) {
for (const m of data.measurements) {
try {
const measuredAt = new Date(m.measuredAt);
await prisma.measurement.create({
data: {
userId,
type: m.type,
value: m.value,
unit: m.unit,
source: "IMPORT",
measuredAt,
notes: m.notes || null,
glucoseContext: m.glucoseContext ?? null,
},
});
// `measuredAt` is already a `Date` (validateEntryInstant transform).
const measuredAt = m.measuredAt;
if (m.externalId) {
// v1.17.1 — idempotent re-import keyed on the source-stable id, so
// re-uploading the same export updates in place rather than minting
// a duplicate. Mirrors the mood path. The unique key already exists
// (`userId_type_source_externalId`, schema.prisma:942).
await prisma.measurement.upsert({
where: {
userId_type_source_externalId: {
userId,
type: m.type,
source: "IMPORT",
externalId: m.externalId,
},
},
update: {
value: m.value,
unit: m.unit,
measuredAt,
notes: m.notes || null,
glucoseContext: m.glucoseContext ?? null,
},
create: {
userId,
type: m.type,
value: m.value,
unit: m.unit,
source: "IMPORT",
externalId: m.externalId,
measuredAt,
notes: m.notes || null,
glucoseContext: m.glucoseContext ?? null,
},
});
} else {
await prisma.measurement.create({
data: {
userId,
type: m.type,
value: m.value,
unit: m.unit,
source: "IMPORT",
measuredAt,
notes: m.notes || null,
glucoseContext: m.glucoseContext ?? null,
},
});
}
touchedMeasurements.push({
type: m.type as MeasurementType,
measuredAt,
@@ -135,7 +189,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (data.moodEntries?.length) {
for (const e of data.moodEntries) {
try {
const loggedAt = e.loggedAt ? new Date(e.loggedAt) : new Date();
const loggedAt = e.loggedAt ?? new Date();
if (e.externalId) {
// v1.12.1 — idempotent re-import keyed on the source-stable id.
// A second import of the same export updates the row in place
@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
withingsConnection: { findUnique: vi.fn(async () => null) },
whoopConnection: { findUnique: vi.fn(async () => null) },
fitbitConnection: { findUnique: vi.fn(async () => null) },
moodEntry: { count: vi.fn(async () => 0) },
},
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
}));
const ledger: Record<string, unknown> = {
state: "connected",
lastSuccessAt: null,
lastAttemptAt: null,
lastError: null,
};
vi.mock("@/lib/integrations/status", () => ({
getIntegrationStatus: vi.fn(async (_u: string, integration: string) => ({
integration,
...ledger,
})),
getPersistentFailureThreshold: () => 5,
}));
vi.mock("@/lib/withings/client", () => ({ hasActivityScope: () => false }));
vi.mock("@/lib/moodlog-secret", () => ({ readMoodLogSecret: () => null }));
const polarAvailable = vi.fn(async () => true);
const ouraAvailable = vi.fn(async () => false);
vi.mock("@/lib/polar/credentials", () => ({
getPolarClientCredentials: () => polarAvailable(),
}));
vi.mock("@/lib/oura/credentials", () => ({
getOuraClientCredentials: () => ouraAvailable(),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
type Entry = {
integration: string;
connected?: boolean;
configured?: boolean;
available?: boolean;
hasOwnCredentials?: boolean;
};
async function fetchEntries(): Promise<Entry[]> {
const res = (await (GET as unknown as () => Promise<{ data: unknown }>)())
.data as { integrations: Entry[] };
return res.integrations;
}
describe("/api/integrations/status — Polar/Oura fold (04-M2)", () => {
beforeEach(() => {
vi.clearAllMocks();
polarAvailable.mockResolvedValue(true);
ouraAvailable.mockResolvedValue(false);
});
it("includes polar + oura entries on the consolidated envelope", async () => {
userFind.mockResolvedValue({
polarAccessTokenEncrypted: "tok",
polarClientIdEncrypted: "id",
polarClientSecretEncrypted: "sec",
ouraAccessTokenEncrypted: null,
ouraClientIdEncrypted: null,
ouraClientSecretEncrypted: null,
});
const entries = await fetchEntries();
const keys = entries.map((e) => e.integration);
expect(keys).toEqual(
expect.arrayContaining(["polar", "oura"]),
);
const polar = entries.find((e) => e.integration === "polar")!;
expect(polar.connected).toBe(true);
expect(polar.configured).toBe(true);
expect(polar.available).toBe(true);
expect(polar.hasOwnCredentials).toBe(true);
const oura = entries.find((e) => e.integration === "oura")!;
expect(oura.connected).toBe(false);
expect(oura.available).toBe(false);
expect(oura.hasOwnCredentials).toBe(false);
});
it("reports polar disconnected when no access token is stored", async () => {
userFind.mockResolvedValue({
polarAccessTokenEncrypted: null,
polarClientIdEncrypted: null,
polarClientSecretEncrypted: null,
});
const entries = await fetchEntries();
const polar = entries.find((e) => e.integration === "polar")!;
expect(polar.connected).toBe(false);
expect(polar.configured).toBe(false);
});
});
+51
View File
@@ -24,6 +24,8 @@ import {
getPersistentFailureThreshold,
type IntegrationKey,
} from "@/lib/integrations/status";
import { getOuraClientCredentials } from "@/lib/oura/credentials";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
import { hasActivityScope } from "@/lib/withings/client";
import { readMoodLogSecret } from "@/lib/moodlog-secret";
@@ -38,16 +40,25 @@ export const GET = apiHandler(async () => {
moodLogStatus,
whoopStatus,
fitbitStatus,
polarStatus,
ouraStatus,
dbUser,
withingsConn,
whoopConn,
fitbitConn,
moodLogEntryCount,
// v1.17.1 — `available` reports whether usable OAuth credentials resolve
// (per-user BYO first, then the shared env app), mirroring the per-card
// /api/<provider>/status the consolidated envelope now replaces.
polarAvailable,
ouraAvailable,
] = await Promise.all([
getIntegrationStatus(user.id, "withings"),
getIntegrationStatus(user.id, "moodlog"),
getIntegrationStatus(user.id, "whoop"),
getIntegrationStatus(user.id, "fitbit"),
getIntegrationStatus(user.id, "polar"),
getIntegrationStatus(user.id, "oura"),
prisma.user.findUnique({
where: { id: user.id },
select: {
@@ -57,6 +68,12 @@ export const GET = apiHandler(async () => {
whoopClientSecretEncrypted: true,
fitbitClientIdEncrypted: true,
fitbitClientSecretEncrypted: true,
polarAccessTokenEncrypted: true,
polarClientIdEncrypted: true,
polarClientSecretEncrypted: true,
ouraAccessTokenEncrypted: true,
ouraClientIdEncrypted: true,
ouraClientSecretEncrypted: true,
moodLogUrlEncrypted: true,
moodLogApiKeyEncrypted: true,
moodLogEnabled: true,
@@ -95,6 +112,8 @@ export const GET = apiHandler(async () => {
prisma.moodEntry.count({
where: { userId: user.id, deletedAt: null },
}),
getPolarClientCredentials(user.id).then((c) => !!c),
getOuraClientCredentials(user.id).then((c) => !!c),
]);
const now = Date.now();
@@ -164,6 +183,28 @@ export const GET = apiHandler(async () => {
: null,
backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null,
} satisfies IntegrationViewModel & FitbitExtras,
{
...polarStatus,
// `connected` = a stored access token; `configured` mirrors it (the
// OAuth card has no separate "credentials saved but disconnected" view
// beyond `hasOwnCredentials`). The card greys out the connect button
// when no usable credentials resolve (`available`).
connected: !!dbUser?.polarAccessTokenEncrypted,
configured: !!dbUser?.polarAccessTokenEncrypted,
available: polarAvailable,
hasOwnCredentials:
!!dbUser?.polarClientIdEncrypted &&
!!dbUser?.polarClientSecretEncrypted,
} satisfies IntegrationViewModel & OAuthProviderExtras,
{
...ouraStatus,
connected: !!dbUser?.ouraAccessTokenEncrypted,
configured: !!dbUser?.ouraAccessTokenEncrypted,
available: ouraAvailable,
hasOwnCredentials:
!!dbUser?.ouraClientIdEncrypted &&
!!dbUser?.ouraClientSecretEncrypted,
} satisfies IntegrationViewModel & OAuthProviderExtras,
],
});
});
@@ -214,3 +255,13 @@ interface FitbitExtras {
tokenExpired: boolean | null;
backfillCompleted: boolean | null;
}
// v1.17.1 — Polar / Oura fold into the consolidated envelope. They carry the
// per-user BYO-key flags the shared OAuth card reads instead of the dedicated
// /api/<provider>/status round-trip the page used to fire per card.
interface OAuthProviderExtras {
connected: boolean;
configured: boolean;
available: boolean;
hasOwnCredentials: boolean;
}
+188
View File
@@ -0,0 +1,188 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { decryptNoteFromBytes, encryptNoteToBytes } from "@/lib/labs/store";
import { annotate } from "@/lib/logging/context";
import {
classifyReferenceRange,
updateLabResultSchema,
} from "@/lib/validations/labs";
/**
* v1.17.1 — single lab-result resource (`/api/labs/{id}`).
*
* GET returns the row including its decrypted note (the detail view is the
* one place the plaintext note surfaces). PUT applies a partial edit
* (`data` built field-by-field; an explicit `null` clears `panel` / `note` /
* a reference bound, an omitted key leaves it untouched). DELETE soft-deletes
* by stamping `deletedAt`. Cross-user rows surface as 404 (existence sealed).
*/
type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const row = await prisma.labResult.findFirst({
where: { id, deletedAt: null },
});
if (!row || row.userId !== user.id) {
return apiError("Lab result not found", 404);
}
annotate({ action: { name: "labs.get" }, meta: { labResultId: id } });
return apiSuccess({
id: row.id,
panel: row.panel,
analyte: row.analyte,
value: row.value,
unit: row.unit,
referenceLow: row.referenceLow,
referenceHigh: row.referenceHigh,
takenAt: row.takenAt.toISOString(),
source: row.source,
note: row.noteEncrypted
? decryptNoteFromBytes(row.noteEncrypted)
: null,
rangeStatus: classifyReferenceRange(
row.value,
row.referenceLow,
row.referenceHigh,
),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
},
);
export const PUT = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const existing = await prisma.labResult.findFirst({
where: { id, deletedAt: null },
});
if (!existing || existing.userId !== user.id) {
return apiError("Lab result not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateLabResultSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "labs.update.validation-failed" },
meta: { issue_count: parsed.error.issues.length, labResultId: id },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "labs.update.validation-failed",
details: JSON.stringify({ issues: auditIssues, labResultId: id }),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const d = parsed.data;
// Build `data` field-by-field. `undefined` → leave the column untouched;
// an explicit `null` on `panel` / `note` / a reference bound clears it.
const data: Record<string, unknown> = {};
if (d.panel !== undefined) data.panel = d.panel;
if (d.analyte !== undefined) data.analyte = d.analyte;
if (d.value !== undefined) data.value = d.value;
if (d.unit !== undefined) data.unit = d.unit;
if (d.referenceLow !== undefined) data.referenceLow = d.referenceLow;
if (d.referenceHigh !== undefined) data.referenceHigh = d.referenceHigh;
if (d.takenAt !== undefined) data.takenAt = d.takenAt;
if (d.note !== undefined) {
data.noteEncrypted = d.note ? encryptNoteToBytes(d.note) : null;
}
const updated = await prisma.labResult.update({ where: { id }, data });
await auditLog("labResult.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { labResultId: id },
});
annotate({ action: { name: "labs.update" }, meta: { labResultId: id } });
return apiSuccess({
id: updated.id,
panel: updated.panel,
analyte: updated.analyte,
value: updated.value,
unit: updated.unit,
referenceLow: updated.referenceLow,
referenceHigh: updated.referenceHigh,
takenAt: updated.takenAt.toISOString(),
source: updated.source,
hasNote: updated.noteEncrypted !== null,
rangeStatus: classifyReferenceRange(
updated.value,
updated.referenceLow,
updated.referenceHigh,
),
createdAt: updated.createdAt.toISOString(),
updatedAt: updated.updatedAt.toISOString(),
});
},
);
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const existing = await prisma.labResult.findFirst({
where: { id, deletedAt: null },
});
if (!existing || existing.userId !== user.id) {
return apiError("Lab result not found", 404);
}
// Soft-delete: stamp `deletedAt`. Every read filters `deletedAt: null`,
// so the row is invisible from here on. A re-delete is idempotent.
await prisma.labResult.update({
where: { id },
data: { deletedAt: new Date() },
});
await auditLog("labResult.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { labResultId: id, analyte: existing.analyte },
});
annotate({ action: { name: "labs.delete" }, meta: { labResultId: id } });
return apiSuccess({ deleted: true });
},
);
+188
View File
@@ -0,0 +1,188 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { withIdempotency } from "@/lib/idempotency";
import { encryptNoteToBytes } from "@/lib/labs/store";
import { annotate } from "@/lib/logging/context";
import {
classifyReferenceRange,
createLabResultSchema,
listLabResultsSchema,
} from "@/lib/validations/labs";
/**
* v1.17.1 — structured lab-result store (`/api/labs`).
*
* GET lists the caller's live results with optional analyte / panel / date
* filters. POST records a single reading. `userId` is always narrowed from
* the session — never a body field — and the write `data` object is built
* field-by-field (no mass assignment). The free-text note, when present, is
* AES-256-GCM encrypted into the `noteEncrypted` Bytes column before write.
*/
// The serialised row never echoes the encrypted note bytes back; the badge
// status is computed server-side so the client renders a coherent, neutral
// in/out-of-range verdict without re-deriving the rule.
function serialiseLabResult(row: {
id: string;
panel: string | null;
analyte: string;
value: number;
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
takenAt: Date;
source: string;
noteEncrypted: Uint8Array | null;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: row.id,
panel: row.panel,
analyte: row.analyte,
value: row.value,
unit: row.unit,
referenceLow: row.referenceLow,
referenceHigh: row.referenceHigh,
takenAt: row.takenAt.toISOString(),
source: row.source,
hasNote: row.noteEncrypted !== null,
rangeStatus: classifyReferenceRange(
row.value,
row.referenceLow,
row.referenceHigh,
),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const params = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listLabResultsSchema.safeParse(params);
if (!parsed.success) {
annotate({
action: { name: "labs.list.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422);
}
const { analyte, panel, from, to, limit, offset, sortDir } = parsed.data;
const where = {
userId: user.id,
deletedAt: null,
...(analyte && { analyte }),
...(panel && { panel }),
...(from || to
? {
takenAt: {
...(from && { gte: from }),
...(to && { lte: to }),
},
}
: {}),
};
const [rows, total] = await Promise.all([
prisma.labResult.findMany({
where,
orderBy: { takenAt: sortDir },
take: limit,
skip: offset,
}),
prisma.labResult.count({ where }),
]);
annotate({
action: { name: "labs.list" },
meta: { total, limit, offset },
});
return apiSuccess({
results: rows.map(serialiseLabResult),
meta: { total, limit, offset },
});
});
export const POST = apiHandler(withIdempotency<[NextRequest]>(postLabResult));
async function postLabResult(request: NextRequest) {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = createLabResultSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "labs.create.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
// Free-text `analyte` / `unit` / `note` could land verbatim in a Zod
// issue message — strip values from the audit-ledger breadcrumb.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "labs.create.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const { panel, analyte, value, unit, referenceLow, referenceHigh, takenAt, note } =
parsed.data;
// Field-by-field assignment — never spread `parsed.data`. `source` is
// hardcoded "MANUAL" for this user-facing path; import paths set their own.
const created = await prisma.labResult.create({
data: {
userId: user.id,
panel: panel ?? null,
analyte,
value,
unit,
referenceLow: referenceLow ?? null,
referenceHigh: referenceHigh ?? null,
takenAt,
source: "MANUAL",
noteEncrypted: note ? encryptNoteToBytes(note) : null,
},
});
await auditLog("labResult.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: { labResultId: created.id, analyte: created.analyte },
});
annotate({
action: { name: "labs.create" },
meta: { labResultId: created.id },
});
return apiSuccess(serialiseLabResult(created), 201);
}
@@ -0,0 +1,209 @@
/**
* v1.17.1 — Vorsorge (measurement) reminder by id: get + patch + delete.
*
* PATCH re-derives the server-authoritative `nextDueAt` after applying
* the (mutually-exclusive cadence) edit. DELETE soft-deletes (tombstone)
* for parity with the rest of the tree.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { updateMeasurementReminderSchema } from "@/lib/validations/measurement-reminders";
import {
computeReminderNextDueAt,
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
type RouteParams = { params: Promise<{ id: string }> };
const DEFAULT_TIMEZONE = "Europe/Berlin";
async function resolveTimezone(userId: string): Promise<string> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
});
return row?.timezone || DEFAULT_TIMEZONE;
}
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const reminder = await prisma.measurementReminder.findFirst({
where: { id, deletedAt: null },
});
if (!reminder || reminder.userId !== user.id) {
return apiError("Measurement reminder not found", 404);
}
annotate({
action: { name: "measurement-reminders.get" },
meta: { reminderId: id },
});
return apiSuccess(toMeasurementReminderDto(reminder));
},
);
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const existing = await prisma.measurementReminder.findFirst({
where: { id, deletedAt: null },
});
if (!existing || existing.userId !== user.id) {
return apiError("Measurement reminder not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateMeasurementReminderSchema.safeParse(body);
if (!parsed.success) {
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "measurement-reminders.update.validation-failed" },
meta: { issue_count: issues.length, reminderId: id },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurement-reminders.update.validation-failed",
details: JSON.stringify({ issues: auditIssues, reminderId: id }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const data = parsed.data;
// Field-by-field — no mass assignment.
const updateData: Record<string, unknown> = {};
if (data.label !== undefined) updateData.label = data.label;
if (data.measurementType !== undefined) {
updateData.measurementType = data.measurementType;
}
if (data.intervalDays !== undefined) {
updateData.intervalDays = data.intervalDays;
// Setting one cadence clears the other so the engine reads exactly
// one dispatch family.
if (data.intervalDays !== null && data.rrule === undefined) {
updateData.rrule = null;
}
}
if (data.rrule !== undefined) {
updateData.rrule = data.rrule;
if (data.rrule !== null && data.intervalDays === undefined) {
updateData.intervalDays = null;
}
}
if (data.anchorDate !== undefined) {
updateData.anchorDate =
data.anchorDate != null ? new Date(data.anchorDate) : null;
}
if (data.notifyHour !== undefined) updateData.notifyHour = data.notifyHour;
if (data.location !== undefined) updateData.location = data.location;
if (data.enabled !== undefined) updateData.enabled = data.enabled;
// Recompute next-due against the merged cadence. Floor the search at
// the last-satisfied instant (or now) so a cadence edit re-anchors
// cleanly off the user's last fulfilment.
const timezone = await resolveTimezone(user.id);
const now = new Date();
const merged: ReminderScheduleInput = {
intervalDays:
(updateData.intervalDays as number | null | undefined) ??
existing.intervalDays,
rrule: (updateData.rrule as string | null | undefined) ?? existing.rrule,
anchorDate:
(updateData.anchorDate as Date | null | undefined) ??
existing.anchorDate,
notifyHour:
(updateData.notifyHour as number | undefined) ?? existing.notifyHour,
lastSatisfiedAt: existing.lastSatisfiedAt,
createdAt: existing.createdAt,
};
const after =
existing.lastSatisfiedAt && existing.lastSatisfiedAt > now
? existing.lastSatisfiedAt
: now;
updateData.nextDueAt = computeReminderNextDueAt(merged, timezone, after);
const updated = await prisma.measurementReminder.update({
where: { id },
data: updateData,
});
await auditLog("measurementReminder.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: { reminderId: id },
});
annotate({
action: { name: "measurement-reminders.update" },
meta: { reminderId: id },
});
return apiSuccess(toMeasurementReminderDto(updated));
},
);
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const existing = await prisma.measurementReminder.findUnique({
where: { id },
});
if (!existing || existing.userId !== user.id) {
return apiError("Measurement reminder not found", 404);
}
// Soft-delete (tombstone) parity with the rest of the tree. A
// re-delete of an already-tombstoned row is idempotent.
await prisma.measurementReminder.update({
where: { id },
data: { deletedAt: new Date() },
});
await auditLog("measurementReminder.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: { reminderId: id },
});
annotate({
action: { name: "measurement-reminders.delete" },
meta: { reminderId: id },
});
return apiSuccess({ deleted: true });
},
);
@@ -0,0 +1,73 @@
/**
* v1.17.1 — manual "Erledigt" for a Vorsorge reminder.
*
* Stamps `lastSatisfiedAt = now` and recomputes the server-authoritative
* `nextDueAt` past now. Free-text (no measurementType) reminders resolve
* ONLY through this path; typed reminders auto-resolve in the cron when a
* matching reading lands, but a manual satisfy still works for them.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import {
computeReminderNextDueAt,
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
type RouteParams = { params: Promise<{ id: string }> };
const DEFAULT_TIMEZONE = "Europe/Berlin";
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const existing = await prisma.measurementReminder.findFirst({
where: { id, deletedAt: null },
});
if (!existing || existing.userId !== user.id) {
return apiError("Measurement reminder not found", 404);
}
const userRow = await prisma.user.findUnique({
where: { id: user.id },
select: { timezone: true },
});
const timezone = userRow?.timezone || DEFAULT_TIMEZONE;
const now = new Date();
const scheduleInput: ReminderScheduleInput = {
intervalDays: existing.intervalDays,
rrule: existing.rrule,
anchorDate: existing.anchorDate,
notifyHour: existing.notifyHour,
lastSatisfiedAt: now,
createdAt: existing.createdAt,
};
const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now);
const updated = await prisma.measurementReminder.update({
where: { id },
data: { lastSatisfiedAt: now, nextDueAt },
});
await auditLog("measurementReminder.satisfy", {
userId: user.id,
ipAddress: getClientIp(request),
details: { reminderId: id },
});
annotate({
action: { name: "measurement-reminders.satisfy" },
meta: { reminderId: id },
});
return apiSuccess(toMeasurementReminderDto(updated));
},
);
+137
View File
@@ -0,0 +1,137 @@
/**
* v1.17.1 — Vorsorge (measurement) reminders CRUD: list + create.
*
* Server-authoritative `nextDueAt` is computed via the canonical
* medication recurrence engine so web ↔ iOS read identical numbers.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { withIdempotency } from "@/lib/idempotency";
import { createMeasurementReminderSchema } from "@/lib/validations/measurement-reminders";
import {
computeReminderNextDueAt,
type ReminderScheduleInput,
} from "@/lib/measurement-reminders/scheduling";
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
const DEFAULT_TIMEZONE = "Europe/Berlin";
async function resolveTimezone(userId: string): Promise<string> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { timezone: true },
});
return row?.timezone || DEFAULT_TIMEZONE;
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const reminders = await prisma.measurementReminder.findMany({
where: { userId: user.id, deletedAt: null },
// Most-urgent first; a null next-due (uncomputable / disabled) sinks
// to the end so the actionable items float to the top.
orderBy: [{ nextDueAt: { sort: "asc", nulls: "last" } }, { createdAt: "asc" }],
});
annotate({
action: { name: "measurement-reminders.list" },
meta: { total: reminders.length },
});
return apiSuccess(reminders.map(toMeasurementReminderDto));
});
async function postReminder(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = createMeasurementReminderSchema.safeParse(body);
if (!parsed.success) {
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "measurement-reminders.create.validation-failed" },
meta: { issue_count: issues.length },
});
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurement-reminders.create.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const data = parsed.data;
const timezone = await resolveTimezone(user.id);
const now = new Date();
const anchorDate =
data.anchorDate != null ? new Date(data.anchorDate) : null;
// Server-authoritative next-due. No satisfy yet → anchors on
// anchorDate ?? createdAt (createdAt ≈ now for a fresh row).
const scheduleInput: ReminderScheduleInput = {
intervalDays: data.intervalDays ?? null,
rrule: data.rrule ?? null,
anchorDate,
notifyHour: data.notifyHour,
lastSatisfiedAt: null,
createdAt: now,
};
const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now);
// Field-by-field — no mass assignment.
const created = await prisma.measurementReminder.create({
data: {
userId: user.id,
label: data.label,
measurementType: data.measurementType ?? null,
intervalDays: data.intervalDays ?? null,
rrule: data.rrule ?? null,
anchorDate,
notifyHour: data.notifyHour,
location: data.location ?? null,
enabled: data.enabled,
nextDueAt,
},
});
await auditLog("measurementReminder.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: { reminderId: created.id },
});
annotate({
action: { name: "measurement-reminders.create" },
meta: { reminderId: created.id, hasType: data.measurementType != null },
});
return apiSuccess(toMeasurementReminderDto(created), 201);
}
export const POST = apiHandler(withIdempotency<[NextRequest]>(postReminder));
@@ -53,6 +53,13 @@ vi.mock("@/lib/medications/inventory/consumption", () => ({
restoreForIntake: vi.fn().mockResolvedValue(undefined),
}));
// v1.17.1 (#22) — silent cross-device intake sync. Mocked so the route
// test asserts the wiring (called with the right slot + origin token)
// without reaching the APNs senders.
vi.mock("@/lib/notifications/medication-intake-sync", () => ({
dispatchMedicationIntakeSync: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
@@ -75,6 +82,7 @@ import {
consumeForIntake,
restoreForIntake,
} from "@/lib/medications/inventory/consumption";
import { dispatchMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
const SESSION_OK = {
@@ -388,6 +396,44 @@ describe("POST /api/medications/intake", () => {
expect(consumeForIntake).not.toHaveBeenCalled();
});
// ── v1.17.1 (#22) — silent cross-device intake sync wiring ──────────
it("dispatches the silent intake sync with the affected slot + origin device", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const slot = new Date("2026-05-18T10:00:00.000Z");
vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue({
id: "e1",
userId: "user-1",
medicationId: "m1",
scheduledFor: slot,
} as never);
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({
id: "e1",
skipped: true,
takenAt: null,
scheduledFor: slot,
} as never);
const r = new NextRequest("http://localhost/api/medications/intake", {
method: "POST",
headers: {
"content-type": "application/json",
"x-device-id": "origin-device-token",
},
body: JSON.stringify({ intakeId: "e1", status: "skipped" }),
});
const res = await POST(r);
expect(res.status).toBe(200);
expect(dispatchMedicationIntakeSync).toHaveBeenCalledTimes(1);
expect(dispatchMedicationIntakeSync).toHaveBeenCalledWith({
userId: "user-1",
medicationId: "m1",
scheduledFor: "2026-05-18T10:00:00.000Z",
originDeviceToken: "origin-device-token",
});
});
// ── v1.16.10 — inventory consumption seams ──────────────────────────
it("taken (no slot move) consumes inventory exactly once on the toggled row", async () => {
@@ -68,6 +68,7 @@ import {
type InjectionSiteValue,
} from "@/lib/validations/medication";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
import { dispatchMedicationIntakeSyncBulk } from "@/lib/notifications/medication-intake-sync";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
@@ -239,6 +240,14 @@ async function postBulk(request: NextRequest): Promise<Response> {
string,
{ medicationId: string; dayKey: string }
>();
// v1.17.1 (#22) — distinct `(medicationId, scheduledFor)` slots whose
// state actually changed in this batch (a row inserted / updated), so the
// silent cross-device sync fires once per affected slot — not once per
// row, and not for pending echoes / duplicates that changed nothing.
const syncSlots = new Map<
string,
{ medicationId: string; scheduledFor: string }
>();
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
@@ -583,6 +592,18 @@ async function postBulk(request: NextRequest): Promise<Response> {
medicationId: entry.medicationId,
dayKey,
});
// v1.17.1 (#22) — collect the slot for the silent cross-device sync
// only when the row's state actually changed (inserted / updated). A
// pending echo, duplicate, or no-downgrade no-op leaves the visible
// dose state untouched, so it must not wake the user's other devices.
const lastStatus = results[results.length - 1]?.status;
if (lastStatus === "inserted" || lastStatus === "updated") {
const scheduledForIso = effectiveScheduledFor.toISOString();
syncSlots.set(`${entry.medicationId}|${scheduledForIso}`, {
medicationId: entry.medicationId,
scheduledFor: scheduledForIso,
});
}
} catch (err: unknown) {
// P2002 = unique-constraint violation. Two shapes reach here:
// 1. an idempotencyKey collision on the unscheduled/PRN insert →
@@ -679,6 +700,19 @@ async function postBulk(request: NextRequest): Promise<Response> {
}
}
// v1.17.1 (#22) — silent cross-device intake sync. One push per device
// per distinct affected slot (the map de-dupes), excluding the
// originating device (`X-Device-Id` = registered `Device.token`).
// APNs-only, best-effort, fire-and-forget: the canonical rows are already
// persisted, so a sync-push miss never affects the batch response.
if (syncSlots.size > 0) {
void dispatchMedicationIntakeSyncBulk({
userId: user.id,
slots: Array.from(syncSlots.values()),
originDeviceToken: request.headers.get("x-device-id"),
});
}
return apiSuccess({
processed: entries.length,
inserted,
+14
View File
@@ -50,6 +50,7 @@ import {
injectionSiteEnum,
type InjectionSiteValue,
} from "@/lib/validations/medication";
import { dispatchMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
const querySchema = z.object({
scope: z.enum(["today", "compliance"]),
@@ -684,5 +685,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
// v1.17.1 (#22) — silent cross-device intake sync. Wake the user's OTHER
// iOS devices so a running Live Activity / Home-Screen widget reconciles
// the dose state changed here. APNs-only, best-effort, fire-and-forget:
// the canonical row is already persisted, so a sync-push miss never
// affects the response. The originating device (the one that POSTed) is
// excluded via its `X-Device-Id` (= registered `Device.token`).
void dispatchMedicationIntakeSync({
userId: user.id,
medicationId: existing.medicationId,
scheduledFor: (movedTo ?? existing.scheduledFor).toISOString(),
originDeviceToken: request.headers.get("x-device-id"),
});
return apiSuccess(updated);
});
+12 -156
View File
@@ -1,177 +1,33 @@
import { prisma } from "@/lib/db";
import { apiSuccess } from "@/lib/api-response";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import {
ensureUserMoodRollupsFresh,
readMoodDayRollups,
} from "@/lib/rollups/mood-rollups";
buildMoodDailySeries,
type MoodDailySeries,
} from "@/lib/analytics/mood-series";
export const dynamic = "force-dynamic";
/**
* v1.4.39 W-MOOD — five-year window for the rollup-tier read.
*
* Mirrors the legacy "unbounded findMany" semantics in practice: the
* legacy code passed every mood the user had ever logged into Node.
* The rollup tier stores one row per day, so a five-year cap is
* cheap (at most ~1 800 rows) and still covers every user the
* product has historic data for.
*/
const ROLLUP_WINDOW_MS = 5 * 365 * 24 * 60 * 60 * 1000;
/** Aggregate multiple mood entries per day into daily averages. */
function aggregateDailyAverages(
records: Array<{ date: string; score: number }>,
) {
const byDay = new Map<string, { sum: number; count: number }>();
for (const record of records) {
const current = byDay.get(record.date) ?? { sum: 0, count: 0 };
current.sum += record.score;
current.count += 1;
byDay.set(record.date, current);
}
return Array.from(byDay.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([day, stats]) => ({
date: day,
score: Math.round((stats.sum / stats.count) * 100) / 100,
samples: stats.count,
}));
}
interface MoodAnalyticsResult {
entries: Array<{ date: string; score: number; samples: number }>;
summary: ReturnType<typeof summarize>;
entryCount: number;
}
/**
* Format the rollup bucket's UTC `bucket_start` as a YYYY-MM-DD
* label. The legacy live path emitted the row's TZ-anchored `date`
* column; the rollup tier anchors on UTC midnight (same convention
* as the measurement rollup tier). For tenants within ±3 h of UTC
* (Berlin year-round) the two labels agree on every entry whose
* timestamp doesn't straddle the UTC boundary — i.e. every realistic
* mood log, which a human submits during waking hours.
*
* Trade-off (QA Specialist-H1, v1.4.39): on DST fall-back nights the
* UTC anchor and the user's local wall-clock day-key can diverge by
* one calendar day. Example: `2025-10-25T23:30:00Z` is 00:30 local
* in Europe/Berlin on `2025-10-26` (one hour after the fall-back
* transition); the rollup row is keyed on `2025-10-25` (UTC) while
* the legacy live-fallback path would emit `2025-10-26`. This is
* pinned by the route-parity DST test. v1.5 per-user-tz bucketing
* (audit P7) anchors the rollup on the same day-key the legacy path
* uses, closing the gap.
*/
function utcDayLabel(d: Date): string {
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const dd = String(d.getUTCDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
async function buildMoodAnalyticsResponse(
userId: string,
): Promise<MoodAnalyticsResult> {
// v1.4.39 W-MOOD — fire-and-forget warm-up so the next cold mount
// for this user lands on the rollup tier even when the boot-time
// backfill hasn't reached them yet.
void ensureUserMoodRollupsFresh(userId);
const since = new Date(Date.now() - ROLLUP_WINDOW_MS);
const rollups = await readMoodDayRollups(userId, since);
if (rollups.length > 0) {
// Fast path — one DAY-rollup row per calendar day. The legacy
// `aggregateDailyAverages` collapse is unnecessary because the
// rollup already carries the daily mean; we recreate its output
// shape directly so the response stays byte-compatible.
const entries = rollups
.map((r) => ({
date: utcDayLabel(r.bucketStart),
score: Math.round(r.mean * 100) / 100,
samples: r.count,
}))
.sort((a, b) => a.date.localeCompare(b.date));
// Summarize feeds the slope7/30/90 windows. One DataPoint per
// day (mean) is the right resolution — the legacy path passed
// per-entry points, but the slope-window outputs match because
// a power user's mood is typically 1/day. Multi-entry days
// contribute their daily mean exactly once, which is the same
// semantic the dashboard tile renders.
const dataPoints: DataPoint[] = rollups.map((r) => ({
date: r.bucketStart,
value: r.mean,
}));
const summary = summarize(dataPoints);
const entryCount = rollups.reduce((s, r) => s + r.count, 0);
annotate({ meta: { mood_analytics_path: "rollup" } });
return { entries, summary, entryCount };
}
// Coverage-fallback — the user has no rollup rows yet. Probe for
// raw mood entries; when the table is empty for this user we
// return the same empty envelope the legacy path emitted. When
// mood entries exist but rollups don't (legacy account before the
// boot-backfill has caught up), we run the legacy live walk ONCE
// so the request still gets a correct response — the warm-up
// fired above will mint the rollups for the next request.
const moodEntries = await prisma.moodEntry.findMany({
// v1.7.0 sync — exclude tombstoned rows from the analytics fallback.
where: { userId, deletedAt: null },
orderBy: { moodLoggedAt: "asc" },
select: { date: true, score: true, moodLoggedAt: true },
});
const entries = aggregateDailyAverages(
moodEntries.map((e) => ({ date: e.date, score: e.score })),
);
// QA UX-H1 (v1.4.39): feed `summarize()` per-day means, not per-entry
// scores. The rollup fast-path emits one DataPoint per calendar day
// (the rollup's `mean`); the live fallback used to pass every raw
// entry, which silently shifted `summary.count / latest / min / max
// / mean / avg7 / avg30 / slope30` on power-user multi-entry days.
// Pre-aggregating through `aggregateDailyAverages` keeps the two
// branches byte-identical on multi-entry days too. Date anchor is
// local-noon for the day so the slope x-axis spans whole-day units —
// mirrors the dashboard tile's intuition (one number per day).
const dataPoints: DataPoint[] = entries.map((e) => ({
date: new Date(`${e.date}T12:00:00.000Z`),
value: e.score,
}));
const summary = summarize(dataPoints);
annotate({
meta: {
mood_analytics_path: moodEntries.length === 0 ? "rollup" : "live",
},
});
return { entries, summary, entryCount: moodEntries.length };
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// v1.17.1 — single mood engine: the route and the dashboard snapshot
// both read through `buildMoodDailySeries`, so the dashboard tile, the
// insights mood sparkline, and the iOS client all see the same number.
const result = await cached(
caches.moodAnalytics as ServerCache<MoodAnalyticsResult>,
caches.moodAnalytics as ServerCache<MoodDailySeries>,
user.id,
() => buildMoodAnalyticsResponse(user.id),
() => buildMoodDailySeries(user.id),
annotate,
);
annotate({
action: { name: "mood.analytics" },
meta: { entryCount: result.entryCount },
meta: {
entryCount: result.entryCount,
mood_analytics_path: result.source,
},
});
return apiSuccess({ entries: result.entries, summary: result.summary });
@@ -21,6 +21,7 @@ vi.mock("@/lib/db", () => ({
updateMany: vi.fn(),
},
},
toJson: (v: unknown) => v,
}));
vi.mock("@/lib/auth/session", () => ({
@@ -249,3 +250,147 @@ describe("POST /api/onboarding/step — rate limit", () => {
expect(res.status).toBe(429);
});
});
describe("POST /api/onboarding/step — goal persistence (v1.17.1)", () => {
it("persists the selected goal slugs on the step-2 submit", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 1,
onboardingCompletedAt: null,
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
onboardingStep: 2,
onboardingCompletedAt: null,
} as never);
const res = await POST(
req({ step: 2, goals: ["weight-management", "bp-tracking"] }),
);
expect(res.status).toBe(200);
const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data;
expect(data).toMatchObject({
onboardingStep: 2,
onboardingGoals: ["weight-management", "bp-tracking"],
});
});
it("persists an explicit empty array on skip (no preference)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 1,
onboardingCompletedAt: null,
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
onboardingStep: 2,
onboardingCompletedAt: null,
} as never);
await POST(req({ step: 2, goals: [] }));
const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data;
expect(data).toMatchObject({ onboardingGoals: [] });
});
it("rejects an unknown goal slug with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(req({ step: 2, goals: ["not-a-real-goal"] }));
expect(res.status).toBe(422);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("does NOT write onboardingGoals when the field is omitted", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 1,
onboardingCompletedAt: null,
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
onboardingStep: 2,
onboardingCompletedAt: null,
} as never);
await POST(req({ step: 2 }));
const data = vi.mocked(prisma.user.updateMany).mock.calls[0][0].data;
expect(data).not.toHaveProperty("onboardingGoals");
});
});
describe("POST /api/onboarding/step — dashboard seeding on completion", () => {
it("seeds the dashboard from stored goals when dashboardWidgetsJson is null", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// 1st findUnique = the fresh step-read; 2nd = the seed-read.
vi.mocked(prisma.user.findUnique)
.mockResolvedValueOnce({
onboardingStep: 3,
onboardingCompletedAt: null,
} as never)
.mockResolvedValueOnce({
onboardingGoals: ["glucose-tracking"],
dashboardWidgetsJson: null,
} as never);
// 1st updateMany = the step claim; 2nd = the gated seed write.
vi.mocked(prisma.user.updateMany)
.mockResolvedValueOnce({ count: 1 } as never)
.mockResolvedValueOnce({ count: 1 } as never);
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
onboardingStep: 4,
onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"),
} as never);
const res = await POST(req({ step: 4 }));
expect(res.status).toBe(200);
// The second updateMany is the seed; its WHERE pins the null gate
// and its data carries a dashboard layout.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(2);
const seedCall = vi.mocked(prisma.user.updateMany).mock.calls[1][0];
expect(seedCall.data).toHaveProperty("dashboardWidgetsJson");
expect(seedCall.where).toHaveProperty("dashboardWidgetsJson");
});
it("does NOT seed when dashboardWidgetsJson is already set (gate holds)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique)
.mockResolvedValueOnce({
onboardingStep: 3,
onboardingCompletedAt: null,
} as never)
.mockResolvedValueOnce({
onboardingGoals: ["glucose-tracking"],
dashboardWidgetsJson: { version: 1, widgets: [] },
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
onboardingStep: 4,
onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"),
} as never);
const res = await POST(req({ step: 4 }));
expect(res.status).toBe(200);
// Only the step-claim updateMany fired — the seed was gated out
// because the user already has a layout.
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
});
it("does NOT seed when stored goals are empty (no preference)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique)
.mockResolvedValueOnce({
onboardingStep: 3,
onboardingCompletedAt: null,
} as never)
.mockResolvedValueOnce({
onboardingGoals: [],
dashboardWidgetsJson: null,
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
onboardingStep: 4,
onboardingCompletedAt: new Date("2026-06-14T12:00:00Z"),
} as never);
await POST(req({ step: 4 }));
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
});
});
+58 -3
View File
@@ -10,9 +10,14 @@ import {
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { setOnboardingPendingCookie } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { prisma, toJson } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import {
ONBOARDING_GOAL_SLUGS,
buildGoalSeededDashboardLayout,
} from "@/lib/onboarding/goals";
/**
* v1.4.25 W14b — POST /api/onboarding/step.
@@ -42,6 +47,16 @@ import { checkRateLimit } from "@/lib/rate-limit";
const stepBodySchema = z.object({
step: z.number().int().min(1).max(4),
// v1.17.1 — optional goal slugs from the GoalsChipPicker (step 2
// submit). Validated against the closed slug enum so an unknown slug
// 422s; deduped + capped at the full set size. Persisted to
// `User.onboardingGoals` (field-by-field, no mass assignment) and on
// completion (step 4) seeds the dashboard layout when the user never
// customized it. Omitted leaves the stored goals untouched.
goals: z
.array(z.enum(ONBOARDING_GOAL_SLUGS))
.max(ONBOARDING_GOAL_SLUGS.length)
.optional(),
});
export const POST = apiHandler(async (request: NextRequest) => {
@@ -74,7 +89,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
return apiError("Invalid step payload", 422);
}
const { step } = parsed.data;
const { step, goals } = parsed.data;
// Re-fetch the fresh User row so concurrent step submissions in
// separate tabs can't race past each other — the session.user
@@ -126,9 +141,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
onboardingCompletedAt: null,
onboardingStep: { in: [current] },
},
// Field-by-field assembly — no mass assignment. `goals` only
// lands when the client sent it (the step-2 submit); it is already
// validated against the closed slug enum by the Zod schema, so the
// array reaching Prisma can never contain an out-of-set value.
data: {
onboardingStep: step,
...(completing ? { onboardingCompletedAt: new Date() } : {}),
...(goals !== undefined ? { onboardingGoals: goals } : {}),
},
});
if (claimed.count !== 1) {
@@ -149,11 +169,41 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
let goalsSeeded = false;
if (completing) {
// Mirror /api/onboarding/complete — clear the proxy-readable
// pending cookie so the next navigation drops the /onboarding
// redirect immediately.
await setOnboardingPendingCookie(false);
// v1.17.1 — seed the dashboard from the stored goal selection.
// ONE-TIME, gated on `dashboardWidgetsJson == null` so a user who
// already arranged tiles is never clobbered. The seed promotes the
// goal-mapped tiles to the top and forces them visible; an empty /
// general-wellness-only selection produces null and leaves the
// column untouched (default layout). The resulting layout is what
// both the web dashboard and the iOS widgets contract read —
// server-authoritative, no client recompute.
const seedRow = await prisma.user.findUnique({
where: { id: user.id },
select: { onboardingGoals: true, dashboardWidgetsJson: true },
});
if (seedRow && seedRow.dashboardWidgetsJson == null) {
const seededLayout = buildGoalSeededDashboardLayout(
seedRow.onboardingGoals,
);
if (seededLayout) {
// updateMany so the `dashboardWidgetsJson: null` precondition
// rides in the WHERE clause — a concurrent Settings → Dashboard
// save that lands first leaves `count = 0` and the seed is
// skipped rather than overwriting the user's fresh layout.
const seeded = await prisma.user.updateMany({
where: { id: user.id, dashboardWidgetsJson: { equals: Prisma.JsonNull } },
data: { dashboardWidgetsJson: toJson(seededLayout) },
});
goalsSeeded = seeded.count === 1;
}
}
}
await auditLog("onboarding.step", {
@@ -167,7 +217,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
annotate({
action: { name: "onboarding.step" },
meta: { outcome: completing ? "completed" : "advanced", step },
meta: {
outcome: completing ? "completed" : "advanced",
step,
...(goals !== undefined ? { goals_count: goals.length } : {}),
...(completing ? { goals_seeded: goalsSeeded } : {}),
},
});
return apiSuccess({
@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/db", () => ({
prisma: { user: { update: vi.fn() } },
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn(async () => null) }));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => ({ setError: vi.fn(), setAuth: vi.fn() }),
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` }));
vi.mock("@/lib/integrations/status", () => ({ markReconnected: vi.fn() }));
const { exchangeMock, getCredsMock, matchMock, verifyMock } = vi.hoisted(() => ({
exchangeMock: vi.fn(),
getCredsMock: vi.fn(),
matchMock: vi.fn(),
verifyMock: vi.fn(),
}));
vi.mock("@/lib/oura/client", () => ({ exchangeCode: exchangeMock }));
vi.mock("@/lib/oura/credentials", () => ({
getOuraClientCredentials: getCredsMock,
}));
vi.mock("@/lib/oauth/signed-state", () => ({
oauthStateCookieName: (p: string) => `${p}_state`,
stateMatchesCookie: matchMock,
verifySignedState: verifyMock,
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getSession } from "@/lib/auth/session";
import type { NextRequest } from "next/server";
const userUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
function makeReq(opts: { code?: string; state?: string; cookie?: string }): NextRequest {
const params = new URLSearchParams();
if (opts.code) params.set("code", opts.code);
if (opts.state) params.set("state", opts.state);
return {
url: `https://app.example/api/oura/callback?${params}`,
cookies: {
get: (name: string) =>
name === "oura_state" && opts.cookie ? { value: opts.cookie } : undefined,
},
} as unknown as NextRequest;
}
beforeEach(() => {
vi.clearAllMocks();
matchMock.mockReturnValue(true);
verifyMock.mockReturnValue({ userId: "u1" });
getCredsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
exchangeMock.mockResolvedValue({ access_token: "at", refresh_token: "rt" });
userUpdate.mockResolvedValue({});
(getSession as ReturnType<typeof vi.fn>).mockResolvedValue(null);
});
describe("GET /api/oura/callback", () => {
it("rejects a cookie/state mismatch as csrf1 before any exchange", async () => {
matchMock.mockReturnValue(false);
const res = await GET(makeReq({ code: "c", state: "S", cookie: "X" }));
expect(res.headers.get("location")).toContain("reason=csrf1");
expect(exchangeMock).not.toHaveBeenCalled();
});
it("rejects an unverifiable signed state", async () => {
verifyMock.mockReturnValue(null);
const res = await GET(makeReq({ code: "c", state: "S", cookie: "S" }));
expect(res.headers.get("location")).toContain("reason=state");
expect(exchangeMock).not.toHaveBeenCalled();
});
it("rejects when the verified userId mismatches a live session (cross_user)", async () => {
(getSession as ReturnType<typeof vi.fn>).mockResolvedValue({
user: { id: "attacker" },
});
const res = await GET(makeReq({ code: "c", state: "S", cookie: "S" }));
expect(res.headers.get("location")).toContain("reason=cross_user");
expect(exchangeMock).not.toHaveBeenCalled();
});
it("falls back to nocreds when no BYO/env credentials resolve", async () => {
getCredsMock.mockResolvedValue(null);
const res = await GET(makeReq({ code: "c", state: "S", cookie: "S" }));
expect(res.headers.get("location")).toContain("reason=nocreds");
expect(exchangeMock).not.toHaveBeenCalled();
});
it("exchanges the code, persists encrypted tokens, and audits on success", async () => {
const res = await GET(makeReq({ code: "auth-code", state: "S", cookie: "S" }));
expect(exchangeMock).toHaveBeenCalledWith("auth-code", {
clientId: "c",
clientSecret: "s",
});
const data = userUpdate.mock.calls[0]![0].data;
expect(data.ouraAccessTokenEncrypted).toBe("enc:at");
expect(data.ouraRefreshTokenEncrypted).toBe("enc:rt");
expect(auditLog).toHaveBeenCalledWith("oura.connect", { userId: "u1" });
expect(res.headers.get("location")).toContain("oura=connected");
});
});
+3 -2
View File
@@ -4,7 +4,8 @@ import { getSession } from "@/lib/auth/session";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import { exchangeCode, getOuraCredentials } from "@/lib/oura/client";
import { exchangeCode } from "@/lib/oura/client";
import { getOuraClientCredentials } from "@/lib/oura/credentials";
import {
oauthStateCookieName,
stateMatchesCookie,
@@ -65,7 +66,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!code) return ERR("nocode");
try {
const creds = getOuraCredentials();
const creds = await getOuraClientCredentials(userId);
if (!creds) return ERR("nocreds");
const tokens = await exchangeCode(code, creds);
+6 -3
View File
@@ -3,7 +3,8 @@ import { apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAuthorizationUrl, getOuraCredentials } from "@/lib/oura/client";
import { getAuthorizationUrl } from "@/lib/oura/client";
import { getOuraClientCredentials } from "@/lib/oura/credentials";
import {
OAUTH_STATE_TTL_MS,
mintSignedState,
@@ -14,7 +15,9 @@ import { NextResponse } from "next/server";
/**
* v1.17.0 (F4) — redirect the user to the Oura consent screen.
*
* Full OAuth from env (`OURA_CLIENT_ID` / `OURA_CLIENT_SECRET`). CSRF via the
* Credentials resolve DB-first then env (`getOuraClientCredentials`): a user's
* BYO Oura client id/secret wins, falling back to the shared env app
* (`OURA_CLIENT_ID` / `OURA_CLIENT_SECRET`) when none is stored. CSRF via the
* stateless signed state (`src/lib/oauth/signed-state.ts`): the same token is
* the `state` URL param AND an httpOnly cookie. Rate-limited per user.
*/
@@ -40,7 +43,7 @@ export const GET = apiHandler(async () => {
);
}
const creds = getOuraCredentials();
const creds = await getOuraClientCredentials(user.id);
if (!creds) {
return apiError("Oura integration is not configured on this server.", 400);
}
@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() }));
const { storeMock, clearMock } = vi.hoisted(() => ({
storeMock: vi.fn(),
clearMock: vi.fn(),
}));
vi.mock("@/lib/oura/credentials", () => ({
storeOuraClientCredentials: storeMock,
clearOuraClientCredentials: clearMock,
}));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
apiError: (error: string, status: number) => ({ data: null, error, status }),
safeJson: vi.fn(),
}));
import { GET, PUT, DELETE } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { markDisconnected } from "@/lib/integrations/status";
import { safeJson } from "@/lib/api-response";
import type { NextRequest } from "next/server";
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const userUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
const safeJsonMock = safeJson as ReturnType<typeof vi.fn>;
type RouteResult = { data: unknown; error: string | null; status: number };
beforeEach(() => {
vi.clearAllMocks();
userFind.mockResolvedValue(null);
userUpdate.mockResolvedValue({});
storeMock.mockResolvedValue(undefined);
clearMock.mockResolvedValue(undefined);
});
describe("GET /api/oura/credentials", () => {
it("reports hasCredentials true only when both columns are set", async () => {
userFind.mockResolvedValue({
ouraClientIdEncrypted: "enc:id",
ouraClientSecretEncrypted: "enc:secret",
});
const res = (await (GET as unknown as () => Promise<RouteResult>)())!;
expect(res.data).toEqual({ hasCredentials: true });
});
it("reports false when a column is missing", async () => {
userFind.mockResolvedValue({ ouraClientIdEncrypted: "enc:id" });
const res = (await (GET as unknown as () => Promise<RouteResult>)())!;
expect(res.data).toEqual({ hasCredentials: false });
});
});
describe("PUT /api/oura/credentials", () => {
const put = PUT as unknown as (r: NextRequest) => Promise<RouteResult>;
const req = {} as NextRequest;
it("422s when the body fails the zod schema", async () => {
safeJsonMock.mockResolvedValue({ data: { clientId: "" }, error: null });
const res = await put(req);
expect(res.status).toBe(422);
expect(storeMock).not.toHaveBeenCalled();
});
it("encrypts and stores a valid client id/secret", async () => {
safeJsonMock.mockResolvedValue({
data: { clientId: "cid", clientSecret: "csecret" },
error: null,
});
const res = await put(req);
expect(res.status).toBe(200);
expect(storeMock).toHaveBeenCalledWith("u1", "cid", "csecret");
});
it("propagates the safeJson error envelope", async () => {
const jsonError = { data: null, error: "too big", status: 413 };
safeJsonMock.mockResolvedValue({ data: null, error: jsonError });
const res = await put(req);
expect(res).toBe(jsonError);
expect(storeMock).not.toHaveBeenCalled();
});
});
describe("DELETE /api/oura/credentials", () => {
const del = DELETE as unknown as () => Promise<RouteResult>;
it("audits + marks disconnected when a live token was present", async () => {
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "enc:tok" });
const res = await del();
expect(res.data).toEqual({ deleted: true });
expect(clearMock).toHaveBeenCalledWith("u1");
expect(userUpdate).toHaveBeenCalled();
expect(auditLog).toHaveBeenCalledWith("oura.credentials.delete", {
userId: "u1",
});
expect(markDisconnected).toHaveBeenCalledWith("u1", "oura");
});
it("skips audit + ledger update when nothing was connected", async () => {
userFind.mockResolvedValue({ ouraAccessTokenEncrypted: null });
const res = await del();
expect(res.data).toEqual({ deleted: true });
expect(clearMock).toHaveBeenCalledWith("u1");
expect(auditLog).not.toHaveBeenCalled();
expect(markDisconnected).not.toHaveBeenCalled();
});
});
+99
View File
@@ -0,0 +1,99 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { markDisconnected } from "@/lib/integrations/status";
import {
storeOuraClientCredentials,
clearOuraClientCredentials,
} from "@/lib/oura/credentials";
import { ouraCredentialsSchema } from "@/lib/validations/oura";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
/**
* v1.17.1 — per-user Oura BYO-key credentials.
*
* Whether the user has their own Oura client id/secret stored. The
* connect/callback/status routes resolve credentials DB-first then env, so an
* unset pair simply falls back to the shared env app.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "oura.credentials.get" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
ouraClientIdEncrypted: true,
ouraClientSecretEncrypted: true,
},
});
return apiSuccess({
hasCredentials:
!!dbUser?.ouraClientIdEncrypted && !!dbUser?.ouraClientSecretEncrypted,
});
});
/**
* Save Oura OAuth client credentials (encrypted at rest).
*/
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "oura.credentials.update" } });
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const result = z.safeParse(ouraCredentialsSchema, body);
if (!result.success) {
return apiError("Client ID and Client Secret are required", 422);
}
await storeOuraClientCredentials(
user.id,
result.data.clientId,
result.data.clientSecret,
);
return apiSuccess({ updated: true });
});
/**
* Delete Oura credentials and the active connection.
*
* Deleting the BYO credentials drops a live connection (a token minted against
* the deleted app is now orphaned), so when an access token was present this
* mirrors `/api/oura/disconnect`: audit the event and park the integration
* ledger at `disconnected` rather than leaving it stale at its last state.
*/
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "oura.credentials.delete" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { ouraAccessTokenEncrypted: true },
});
const wasConnected = !!dbUser?.ouraAccessTokenEncrypted;
await prisma.user.update({
where: { id: user.id },
data: {
ouraAccessTokenEncrypted: null,
ouraRefreshTokenEncrypted: null,
},
});
await clearOuraClientCredentials(user.id);
if (wasConnected) {
await auditLog("oura.credentials.delete", { userId: user.id });
await markDisconnected(user.id, "oura");
}
return apiSuccess({ deleted: true });
});
@@ -15,8 +15,11 @@ vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null }),
}));
vi.mock("@/lib/oura/client", () => ({
getOuraCredentials: vi.fn(() => ({ clientId: "c", clientSecret: "s" })),
vi.mock("@/lib/oura/credentials", () => ({
getOuraClientCredentials: vi.fn(async () => ({
clientId: "c",
clientSecret: "s",
})),
}));
vi.mock("@/lib/integrations/status", () => ({
@@ -32,10 +35,10 @@ vi.mock("@/lib/integrations/status", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getOuraCredentials } from "@/lib/oura/client";
import { getOuraClientCredentials } from "@/lib/oura/credentials";
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const credsMock = getOuraCredentials as ReturnType<typeof vi.fn>;
const credsMock = getOuraClientCredentials as ReturnType<typeof vi.fn>;
type Body = {
connected: boolean;
@@ -49,7 +52,7 @@ const call = () =>
describe("GET /api/oura/status", () => {
beforeEach(() => {
vi.clearAllMocks();
credsMock.mockReturnValue({ clientId: "c", clientSecret: "s" });
credsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
});
it("reports not-connected but available", async () => {
+21 -6
View File
@@ -2,15 +2,18 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { getOuraCredentials } from "@/lib/oura/client";
import { getOuraClientCredentials } from "@/lib/oura/credentials";
import { getIntegrationStatus } from "@/lib/integrations/status";
/**
* v1.17.0 (F4) — Oura connection status for the current user.
*
* `available` reports whether the server has the shared OAuth app configured.
* `available` reports whether usable OAuth credentials resolve for this user —
* per-user BYO keys (v1.17.1) first, then the shared env app.
* `hasOwnCredentials` reports whether the user has stored their own BYO pair.
* `connected` is whether a token is stored. The ledger snapshot comes off the
* shared `oura` IntegrationKey. The access / refresh tokens are NEVER returned.
* shared `oura` IntegrationKey. The access / refresh tokens + client secret are
* NEVER returned.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
@@ -18,14 +21,25 @@ export const GET = apiHandler(async () => {
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { ouraAccessTokenEncrypted: true },
select: {
ouraAccessTokenEncrypted: true,
ouraClientIdEncrypted: true,
ouraClientSecretEncrypted: true,
},
});
const available = !!getOuraCredentials();
const available = !!(await getOuraClientCredentials(user.id));
const connected = !!dbUser?.ouraAccessTokenEncrypted;
const hasOwnCredentials =
!!dbUser?.ouraClientIdEncrypted && !!dbUser?.ouraClientSecretEncrypted;
if (!connected) {
return apiSuccess({ connected: false, configured: false, available });
return apiSuccess({
connected: false,
configured: false,
available,
hasOwnCredentials,
});
}
const status = await getIntegrationStatus(user.id, "oura");
@@ -34,6 +48,7 @@ export const GET = apiHandler(async () => {
connected: true,
configured: true,
available,
hasOwnCredentials,
state: status.state,
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const {
stateMatchesCookieMock,
verifySignedStateMock,
getSessionMock,
exchangeCodeMock,
registerUserMock,
getCredsMock,
markReconnectedMock,
} = vi.hoisted(() => ({
stateMatchesCookieMock: vi.fn(),
verifySignedStateMock: vi.fn(),
getSessionMock: vi.fn(),
exchangeCodeMock: vi.fn(),
registerUserMock: vi.fn(),
getCredsMock: vi.fn(),
markReconnectedMock: vi.fn(),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/db", () => ({ prisma: { user: { update: vi.fn() } } }));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: () => ({ setAuth: vi.fn(), setError: vi.fn() }),
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` }));
vi.mock("@/lib/auth/session", () => ({ getSession: getSessionMock }));
vi.mock("@/lib/polar/client", () => ({
exchangeCode: exchangeCodeMock,
registerUser: registerUserMock,
}));
vi.mock("@/lib/polar/credentials", () => ({
getPolarClientCredentials: getCredsMock,
}));
vi.mock("@/lib/oauth/signed-state", () => ({
oauthStateCookieName: () => "polar_oauth_state",
stateMatchesCookie: stateMatchesCookieMock,
verifySignedState: verifySignedStateMock,
}));
vi.mock("@/lib/integrations/status", () => ({
markReconnected: markReconnectedMock,
}));
import { GET } from "../route";
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
function makeReq(): Parameters<typeof GET>[0] {
return {
url: "https://app.example/api/polar/callback?code=abc&state=s",
cookies: { get: () => ({ value: "s" }) },
} as unknown as Parameters<typeof GET>[0];
}
const run = GET as unknown as (req: ReturnType<typeof makeReq>) => Promise<{
status: number;
headers: { get(k: string): string | null };
}>;
beforeEach(() => {
vi.clearAllMocks();
stateMatchesCookieMock.mockReturnValue(true);
verifySignedStateMock.mockReturnValue({ userId: "u1" });
getSessionMock.mockResolvedValue(null);
getCredsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
exchangeCodeMock.mockResolvedValue({ access_token: "tok", x_user_id: 42 });
registerUserMock.mockResolvedValue(undefined);
});
describe("GET /api/polar/callback", () => {
it("completes the handshake and redirects to connected", async () => {
const res = await run(makeReq());
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain("polar=connected");
expect(markReconnectedMock).toHaveBeenCalledWith("u1", "polar");
});
it("rejects a CSRF cookie/param mismatch before exchanging the code", async () => {
stateMatchesCookieMock.mockReturnValue(false);
const res = await run(makeReq());
expect(res.headers.get("location")).toContain("reason=csrf1");
expect(exchangeCodeMock).not.toHaveBeenCalled();
});
it("rejects a cross-user session completing another user's handshake", async () => {
getSessionMock.mockResolvedValue({ user: { id: "other" } });
const res = await run(makeReq());
expect(res.headers.get("location")).toContain("reason=cross_user");
expect(exchangeCodeMock).not.toHaveBeenCalled();
});
it("rejects an off-spec token body missing x_user_id", async () => {
exchangeCodeMock.mockResolvedValue({ access_token: "tok" });
const res = await run(makeReq());
expect(res.headers.get("location")).toContain("reason=token");
expect(registerUserMock).not.toHaveBeenCalled();
});
});
+3 -6
View File
@@ -4,11 +4,8 @@ import { getSession } from "@/lib/auth/session";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import {
exchangeCode,
getPolarCredentials,
registerUser,
} from "@/lib/polar/client";
import { exchangeCode, registerUser } from "@/lib/polar/client";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
import {
oauthStateCookieName,
stateMatchesCookie,
@@ -75,7 +72,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (!code) return ERR("nocode");
try {
const creds = getPolarCredentials();
const creds = await getPolarClientCredentials(userId);
if (!creds) return ERR("nocreds");
const tokens = await exchangeCode(code, creds);
@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const { rateLimitMock, getCredsMock, mintMock, authUrlMock } = vi.hoisted(
() => ({
rateLimitMock: vi.fn(),
getCredsMock: vi.fn(),
mintMock: vi.fn(() => "signed-state"),
authUrlMock: vi.fn(() => "https://flow.polar.com/oauth2/authorization?x=1"),
}),
);
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
vi.mock("@/lib/api-response", () => ({
apiError: (error: string, status: number) => ({ data: null, error, status }),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/secure-cookie", () => ({
shouldEmitSecureCookie: () => true,
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: rateLimitMock }));
vi.mock("@/lib/polar/client", () => ({ getAuthorizationUrl: authUrlMock }));
vi.mock("@/lib/polar/credentials", () => ({
getPolarClientCredentials: getCredsMock,
}));
vi.mock("@/lib/oauth/signed-state", () => ({
OAUTH_STATE_TTL_MS: 600_000,
mintSignedState: mintMock,
oauthStateCookieName: () => "polar_oauth_state",
}));
import { GET } from "../route";
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
const run = GET as unknown as () => Promise<{
status: number;
headers: { get(k: string): string | null };
cookies: { get(k: string): { value: string } | undefined };
}>;
beforeEach(() => {
vi.clearAllMocks();
rateLimitMock.mockResolvedValue({ allowed: true });
getCredsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
});
describe("GET /api/polar/connect", () => {
it("redirects to the consent screen and sets the state cookie", async () => {
const res = await run();
expect(res.headers.get("location")).toContain("flow.polar.com");
expect(res.cookies.get("polar_oauth_state")?.value).toBe("signed-state");
});
it("redirects to an error when the bucket is exhausted", async () => {
rateLimitMock.mockResolvedValue({ allowed: false });
const res = await run();
expect(res.headers.get("location")).toContain("reason=rate_limited");
expect(getCredsMock).not.toHaveBeenCalled();
});
it("returns 400 when neither BYO nor env credentials resolve", async () => {
getCredsMock.mockResolvedValue(null);
const res = (await run()) as unknown as { status: number };
expect(res.status).toBe(400);
});
});
+9 -7
View File
@@ -3,7 +3,8 @@ import { apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAuthorizationUrl, getPolarCredentials } from "@/lib/polar/client";
import { getAuthorizationUrl } from "@/lib/polar/client";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
import {
OAUTH_STATE_TTL_MS,
mintSignedState,
@@ -14,11 +15,12 @@ import { NextResponse } from "next/server";
/**
* v1.17.0 (F4) — redirect the user to the Polar AccessLink consent screen.
*
* Full OAuth from env: the shared client id/secret come from `POLAR_CLIENT_ID`
* / `POLAR_CLIENT_SECRET` (not per-user BYO). The CSRF defence is a stateless
* signed state (`src/lib/oauth/signed-state.ts`): the same token is the `state`
* URL param AND an httpOnly cookie, and the callback enforces both signature
* validity and a byte-exact cookie match.
* BYO-key OAuth: the client id/secret resolve DB-first then env via
* `getPolarClientCredentials` — a user's own AccessLink app when stored,
* otherwise the shared `POLAR_CLIENT_ID` / `POLAR_CLIENT_SECRET`. The CSRF
* defence is a stateless signed state (`src/lib/oauth/signed-state.ts`): the
* same token is the `state` URL param AND an httpOnly cookie, and the callback
* enforces both signature validity and a byte-exact cookie match.
*
* Rate-limited per user so a session can't spam the consent redirect.
*/
@@ -44,7 +46,7 @@ export const GET = apiHandler(async () => {
);
}
const creds = getPolarCredentials();
const creds = await getPolarClientCredentials(user.id);
if (!creds) {
return apiError(
"Polar integration is not configured on this server.",
@@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const { storeMock, clearMock, auditMock, markDisconnectedMock } = vi.hoisted(
() => ({
storeMock: vi.fn(),
clearMock: vi.fn(),
auditMock: vi.fn(),
markDisconnectedMock: vi.fn(),
}),
);
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn(), update: vi.fn() } },
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: auditMock }));
vi.mock("@/lib/integrations/status", () => ({
markDisconnected: markDisconnectedMock,
}));
vi.mock("@/lib/polar/credentials", () => ({
storePolarClientCredentials: storeMock,
clearPolarClientCredentials: clearMock,
}));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
apiError: (error: string, status: number) => ({ data: null, error, status }),
safeJson: vi.fn(async (req: { _body: unknown }) => ({
data: req._body,
error: null,
})),
}));
import { PUT, DELETE } from "../route";
import { prisma } from "@/lib/db";
type RouteResult = { data: unknown; error: string | null; status: number };
const userUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
});
describe("PUT /api/polar/credentials", () => {
it("stores credentials and audit-logs the mutation", async () => {
const req = { _body: { clientId: "cid", clientSecret: "csecret" } };
const put = PUT as unknown as (r: typeof req) => Promise<RouteResult>;
const res = await put(req);
expect(res.status).toBe(200);
expect(storeMock).toHaveBeenCalledWith("u1", "cid", "csecret");
expect(auditMock).toHaveBeenCalledWith("polar.credentials.update", {
userId: "u1",
});
});
it("rejects an invalid body with a 422", async () => {
const req = { _body: { clientId: "" } };
const put = PUT as unknown as (r: typeof req) => Promise<RouteResult>;
const res = await put(req);
expect(res.status).toBe(422);
expect(storeMock).not.toHaveBeenCalled();
});
});
describe("DELETE /api/polar/credentials", () => {
it("clears creds + token and parks the ledger at disconnected", async () => {
const del = DELETE as unknown as () => Promise<RouteResult>;
const res = await del();
expect(res.status).toBe(200);
// The active token + member id are nulled.
expect(userUpdate).toHaveBeenCalledWith({
where: { id: "u1" },
data: { polarAccessTokenEncrypted: null, polarUserIdEncrypted: null },
});
expect(clearMock).toHaveBeenCalledWith("u1");
expect(auditMock).toHaveBeenCalledWith("polar.credentials.delete", {
userId: "u1",
});
expect(markDisconnectedMock).toHaveBeenCalledWith("u1", "polar");
});
});
+91
View File
@@ -0,0 +1,91 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { markDisconnected } from "@/lib/integrations/status";
import {
storePolarClientCredentials,
clearPolarClientCredentials,
} from "@/lib/polar/credentials";
import { polarCredentialsSchema } from "@/lib/validations/polar";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
/**
* v1.17.1 — per-user Polar BYO-key credentials.
*
* Whether the user has their own Polar AccessLink client id/secret stored. The
* connect/callback/status routes resolve credentials DB-first then env, so an
* unset pair simply falls back to the shared env app.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "polar.credentials.get" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
polarClientIdEncrypted: true,
polarClientSecretEncrypted: true,
},
});
return apiSuccess({
hasCredentials:
!!dbUser?.polarClientIdEncrypted && !!dbUser?.polarClientSecretEncrypted,
});
});
/**
* Save Polar OAuth client credentials (encrypted at rest).
*/
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "polar.credentials.update" } });
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const result = z.safeParse(polarCredentialsSchema, body);
if (!result.success) {
return apiError("Client ID and Client Secret are required", 422);
}
await storePolarClientCredentials(
user.id,
result.data.clientId,
result.data.clientSecret,
);
await auditLog("polar.credentials.update", { userId: user.id });
return apiSuccess({ updated: true });
});
/**
* Delete Polar credentials and the active connection.
*
* Removing the BYO client id/secret also drops the live grant: a token minted
* under the user's own AccessLink app would be orphaned once the keys are gone.
* Parks the integration ledger at `disconnected` for parity with the dedicated
* disconnect route so the status snapshot does not linger at a stale state.
*/
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "polar.credentials.delete" } });
await prisma.user.update({
where: { id: user.id },
data: {
polarAccessTokenEncrypted: null,
polarUserIdEncrypted: null,
},
});
await clearPolarClientCredentials(user.id);
await auditLog("polar.credentials.delete", { userId: user.id });
await markDisconnected(user.id, "polar");
return apiSuccess({ deleted: true });
});
@@ -15,8 +15,11 @@ vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null }),
}));
vi.mock("@/lib/polar/client", () => ({
getPolarCredentials: vi.fn(() => ({ clientId: "c", clientSecret: "s" })),
vi.mock("@/lib/polar/credentials", () => ({
getPolarClientCredentials: vi.fn(async () => ({
clientId: "c",
clientSecret: "s",
})),
}));
vi.mock("@/lib/integrations/status", () => ({
@@ -32,10 +35,10 @@ vi.mock("@/lib/integrations/status", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getPolarCredentials } from "@/lib/polar/client";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const credsMock = getPolarCredentials as ReturnType<typeof vi.fn>;
const credsMock = getPolarClientCredentials as ReturnType<typeof vi.fn>;
type Body = {
connected: boolean;
@@ -49,7 +52,7 @@ const call = () =>
describe("GET /api/polar/status", () => {
beforeEach(() => {
vi.clearAllMocks();
credsMock.mockReturnValue({ clientId: "c", clientSecret: "s" });
credsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
});
it("reports not-connected but available when env is set and no token stored", async () => {
@@ -60,7 +63,7 @@ describe("GET /api/polar/status", () => {
});
it("reports available=false when the server has no Polar app configured", async () => {
credsMock.mockReturnValue(null);
credsMock.mockResolvedValue(null);
userFind.mockResolvedValue(null);
const res = await call();
expect(res.available).toBe(false);
+23 -9
View File
@@ -2,17 +2,19 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { getPolarCredentials } from "@/lib/polar/client";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
import { getIntegrationStatus } from "@/lib/integrations/status";
/**
* v1.17.0 (F4) — Polar connection status for the current user.
*
* `available` reports whether the server has the shared OAuth app configured
* (env client id/secret) so the card can grey out the connect button when the
* operator hasn't set it up. `connected` is whether a token is stored. The
* ledger snapshot (state / lastSuccessAt / lastError) comes off the shared
* `polar` IntegrationKey. The access token + member id are NEVER returned.
* `available` reports whether usable OAuth credentials resolve for this user —
* per-user BYO keys (v1.17.1) first, then the shared env app — so the card can
* grey out the connect button when neither is set. `hasOwnCredentials` reports
* whether the user has stored their own BYO pair (drives the saved-placeholder
* UI). `connected` is whether a token is stored. The ledger snapshot (state /
* lastSuccessAt / lastError) comes off the shared `polar` IntegrationKey. The
* access token, member id, and client secret are NEVER returned.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
@@ -20,14 +22,25 @@ export const GET = apiHandler(async () => {
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { polarAccessTokenEncrypted: true },
select: {
polarAccessTokenEncrypted: true,
polarClientIdEncrypted: true,
polarClientSecretEncrypted: true,
},
});
const available = !!getPolarCredentials();
const available = !!(await getPolarClientCredentials(user.id));
const connected = !!dbUser?.polarAccessTokenEncrypted;
const hasOwnCredentials =
!!dbUser?.polarClientIdEncrypted && !!dbUser?.polarClientSecretEncrypted;
if (!connected) {
return apiSuccess({ connected: false, configured: false, available });
return apiSuccess({
connected: false,
configured: false,
available,
hasOwnCredentials,
});
}
const status = await getIntegrationStatus(user.id, "polar");
@@ -36,6 +49,7 @@ export const GET = apiHandler(async () => {
connected: true,
configured: true,
available,
hasOwnCredentials,
state: status.state,
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
+82
View File
@@ -0,0 +1,82 @@
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { emailSettingsSchema } from "@/lib/validations/notifications";
import { encrypt, decrypt } from "@/lib/crypto";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { isEmailConfigured } from "@/lib/notifications/senders/email-config";
/**
* Email channel config (v1.17.1).
* GET: per-user recipient + opt-in, plus `smtpConfigured` so the UI can hide
* the card on an instance where the operator hasn't set `SMTP_*`.
* PUT: upsert recipient + enabled.
*
* The SMTP transport (host/port/auth/from) is operator-supplied via env; only
* the recipient address is per-user, so SMTP credentials never enter a user's
* encrypted channel blob.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const smtpConfigured = isEmailConfigured();
const channel = await prisma.notificationChannel.findUnique({
where: { userId_type: { userId: user.id, type: "EMAIL" } },
});
if (!channel) {
return apiSuccess({ enabled: false, recipient: "", smtpConfigured });
}
const config = JSON.parse(decrypt(channel.config)) as { recipient: string };
annotate({ action: { name: "settings.email.get" } });
return apiSuccess({
enabled: channel.enabled,
recipient: config.recipient,
smtpConfigured,
});
});
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
if (!isEmailConfigured()) {
return apiError("Email is not configured on this instance", 400);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = emailSettingsSchema.safeParse(body);
if (!parsed.success) return apiError("Invalid data", 422);
const { recipient, enabled } = parsed.data;
if (enabled && !recipient) {
return apiError("A recipient address is required when email is enabled", 422);
}
const config = JSON.stringify({ recipient: recipient || "" });
const encryptedConfig = encrypt(config);
await prisma.notificationChannel.upsert({
where: { userId_type: { userId: user.id, type: "EMAIL" } },
create: {
userId: user.id,
type: "EMAIL",
enabled,
config: encryptedConfig,
},
update: { enabled, config: encryptedConfig },
});
annotate({ action: { name: "settings.email.update" }, meta: { enabled } });
return apiSuccess({ saved: true });
});
+54
View File
@@ -0,0 +1,54 @@
import { prisma } from "@/lib/db";
import { apiSuccess, apiError } from "@/lib/api-response";
import { decrypt } from "@/lib/crypto";
import { sendViaEmail } from "@/lib/notifications/senders/email";
import type { EmailChannelConfig } from "@/lib/notifications/types";
import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { isEmailConfigured } from "@/lib/notifications/senders/email-config";
/**
* POST: send a test email to the configured recipient.
*/
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
if (!isEmailConfigured()) {
return apiError("Email is not configured on this instance", 400);
}
const rl = await checkRateLimit(`email-test:${user.id}`, 5, 5 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximum 5 tests in 5 minutes", 429);
}
const channel = await prisma.notificationChannel.findUnique({
where: { userId_type: { userId: user.id, type: "EMAIL" } },
});
if (!channel) {
return apiError("Email is not configured", 400);
}
const config = JSON.parse(decrypt(channel.config)) as EmailChannelConfig;
if (!config.recipient) {
return apiError("A recipient address is required", 400);
}
const result = await sendViaEmail(config, {
eventType: "SYSTEM_ALERT",
userId: user.id,
title: "HealthLog Test",
message: "HealthLog: Connection successful! Email notifications are active.",
});
if (!result.ok) {
return apiError("Failed to send test email", 500);
}
annotate({ action: { name: "settings.email.test" }, meta: { success: true } });
return apiSuccess({ sent: true });
});
+106
View File
@@ -0,0 +1,106 @@
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { webhookSettingsSchema } from "@/lib/validations/notifications";
import { encrypt, decrypt } from "@/lib/crypto";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
/**
* Generic-webhook channel config (v1.17.1).
* GET: current config (header value redacted — only presence flagged).
* PUT: upsert config.
*
* Covers Gotify / Discord / Slack / Matrix-bridge / Home Assistant in one
* channel: the user supplies a public URL and an optional shared-secret
* header. SSRF is enforced at input time (`isPublicUrl` in the schema) and
* again at dispatch time (`safeFetch({ requirePublicHost: true })`).
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const channel = await prisma.notificationChannel.findUnique({
where: { userId_type: { userId: user.id, type: "WEBHOOK" } },
});
if (!channel) {
return apiSuccess({
enabled: false,
url: "",
headerName: "",
hasHeaderValue: false,
});
}
const config = JSON.parse(decrypt(channel.config)) as {
url: string;
headerName?: string;
headerValue?: string;
};
annotate({ action: { name: "settings.webhook.get" } });
return apiSuccess({
enabled: channel.enabled,
url: config.url,
headerName: config.headerName ?? "",
hasHeaderValue: !!config.headerValue,
});
});
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = webhookSettingsSchema.safeParse(body);
if (!parsed.success) return apiError("Invalid data", 422);
const { url, headerName, headerValue, enabled } = parsed.data;
if (enabled && !url) {
return apiError("Webhook URL is required when the webhook is enabled", 422);
}
// Preserve an existing header value when the client sends an empty one (the
// GET path never returns the secret, so a save round-trip would otherwise
// wipe it). A non-empty value replaces it.
let nextHeaderValue = headerValue || undefined;
if (!nextHeaderValue) {
const existing = await prisma.notificationChannel.findUnique({
where: { userId_type: { userId: user.id, type: "WEBHOOK" } },
});
if (existing) {
const prev = JSON.parse(decrypt(existing.config)) as {
headerValue?: string;
};
nextHeaderValue = prev.headerValue || undefined;
}
}
const config = JSON.stringify({
url: url || "",
...(headerName ? { headerName } : {}),
...(nextHeaderValue ? { headerValue: nextHeaderValue } : {}),
});
const encryptedConfig = encrypt(config);
await prisma.notificationChannel.upsert({
where: { userId_type: { userId: user.id, type: "WEBHOOK" } },
create: {
userId: user.id,
type: "WEBHOOK",
enabled,
config: encryptedConfig,
},
update: { enabled, config: encryptedConfig },
});
annotate({ action: { name: "settings.webhook.update" }, meta: { enabled } });
return apiSuccess({ saved: true });
});
@@ -0,0 +1,52 @@
import { prisma } from "@/lib/db";
import { apiSuccess, apiError } from "@/lib/api-response";
import { decrypt } from "@/lib/crypto";
import { sendViaWebhook } from "@/lib/notifications/senders/webhook";
import type { WebhookChannelConfig } from "@/lib/notifications/types";
import { checkRateLimit } from "@/lib/rate-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
/**
* POST: send a test notification via the configured generic webhook.
*/
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
const rl = await checkRateLimit(`webhook-test:${user.id}`, 5, 5 * 60 * 1000);
if (!rl.allowed) {
return apiError("Maximum 5 tests in 5 minutes", 429);
}
const channel = await prisma.notificationChannel.findUnique({
where: { userId_type: { userId: user.id, type: "WEBHOOK" } },
});
if (!channel) {
return apiError("Webhook is not configured", 400);
}
const config = JSON.parse(decrypt(channel.config)) as WebhookChannelConfig;
if (!config.url) {
return apiError("Webhook URL is required", 400);
}
const result = await sendViaWebhook(config, {
eventType: "SYSTEM_ALERT",
userId: user.id,
title: "HealthLog Test",
message: "HealthLog: Connection successful! Webhook notifications are active.",
});
if (!result.ok) {
return apiError("Failed to send test message", 500);
}
annotate({
action: { name: "settings.webhook.test" },
meta: { success: true },
});
return apiSuccess({ sent: true });
});
+9
View File
@@ -12,6 +12,11 @@
* priority ladder — so two sources' timelines never overlay. A view over the
* existing per-stage `SLEEP_DURATION` rows; no schema, no new type.
*
* `reconstructed` (per session) is true when the winning source has no
* per-stage onset timestamps and the server synthesised a contiguous timeline
* (WHOOP) — the client renders the hypnogram but labels it an approximate
* layout. A real-series source (Apple Health / Withings / Fitbit) is false.
*
* `date` omitted → the most recent night in the trailing-year window.
*
* Auth: cookie session OR Bearer token (`requireAuth`). Soft-delete-filtered.
@@ -64,6 +69,10 @@ function serializeSession(s: SleepSession) {
inBedMinutes: s.inBedMinutes === null ? null : Math.round(s.inBedMinutes),
awakeMinutes: s.awakeMinutes === null ? null : Math.round(s.awakeMinutes),
awakenings: s.awakenings,
// iOS #18 — `reconstructed` is true when the source has no per-stage onset
// timestamps and the server synthesised a contiguous timeline (WHOOP). iOS
// labels the hypnogram as an approximate layout; it never recomputes.
reconstructed: s.reconstructed,
// iOS #18 — round the per-stage map too; the underlying totals sum
// second-precision segments and otherwise serialise as e.g. 88.4999.
stages: Object.fromEntries(
@@ -15,6 +15,8 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` }));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() }));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
@@ -30,6 +32,8 @@ vi.mock("@/lib/api-response", () => ({
import { GET, PUT, DELETE } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { markDisconnected } from "@/lib/integrations/status";
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const userUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
@@ -91,4 +95,16 @@ describe("/api/whoop/credentials", () => {
},
});
});
it("DELETE audits the teardown and parks the ledger (04-L1 parity)", async () => {
userUpdate.mockResolvedValue({});
(prisma.whoopConnection.delete as ReturnType<typeof vi.fn>).mockResolvedValue(
{},
);
await (DELETE as unknown as () => Promise<unknown>)();
expect(auditLog).toHaveBeenCalledWith("whoop.credentials.delete", {
userId: "u1",
});
expect(markDisconnected).toHaveBeenCalledWith("u1", "whoop");
});
});
+8
View File
@@ -1,8 +1,10 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { markDisconnected } from "@/lib/integrations/status";
import { whoopCredentialsSchema } from "@/lib/validations/whoop";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -58,6 +60,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
/**
* Delete WHOOP credentials and the active connection.
*
* Audits the teardown and parks the integration ledger at `disconnected` for
* parity with the Polar / Oura credential-DELETE so a sensitive op leaves a
* uniform audit trail and the status snapshot does not linger at a stale state.
*/
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
@@ -74,6 +80,8 @@ export const DELETE = apiHandler(async () => {
whoopClientSecretEncrypted: null,
},
});
await auditLog("whoop.credentials.delete", { userId: user.id });
await markDisconnected(user.id, "whoop");
return apiSuccess({ deleted: true });
});
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { RecoverySection } from "@/components/insights/recovery/recovery-section";
/**
* v1.17.1 — `/insights/recovery`.
*
* The render host for the WHOOP / Polar device-native recovery + strain scores
* that were stored end-to-end but had no page: day strain, workout strain, ANS
* charge, cardio load, and the day's whole-cycle average / max heart rate and
* kilojoule energy expenditure. The composite RECOVERY_SCORE keeps its anatomy
* detail at `/insights/scores/recovery`; this page cross-links to it and
* surrounds it with the raw signals it never surfaced.
*
* Every block is data-gated inside `<RecoverySection>`, so an account without a
* strap / ring sees only the calm empty note. Server-authoritative: the tiles
* render stored values, never a recompute.
*/
export default function InsightsRecoveryPage() {
const { t } = useTranslations();
return (
<SubPageShell
title={t("insights.recovery.title")}
description={t("insights.recovery.description")}
explainerMetric="recoveryPage"
coachLaunch
>
<RecoverySection />
</SubPageShell>
);
}
+22 -1
View File
@@ -1,8 +1,10 @@
"use client";
import { Thermometer } from "lucide-react";
import { Thermometer, ThermometerSnowflake } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { ConnectedDeviceScoreTile } from "@/components/insights/device-score-tile";
/**
* v1.7.0 — `/insights/skin-temperature`.
@@ -11,8 +13,14 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page
* the chart daily-aggregates through the rollup read path. Empty-state
* carries no manual-entry CTA — the series arrives from Apple Health or
* Withings sync.
*
* v1.17.1 — the Oura overnight body-temperature DEVIATION (a signed delta from
* the personal baseline, an illness / cycle signal) joins this metabolic page
* as a gated tile beneath the chart. It only mounts when the Oura series has
* data, so a non-Oura account renders the page byte-identically.
*/
export default function InsightsHauttemperaturPage() {
const { t } = useTranslations();
return (
<HealthKitMetricPage
measurementType="SKIN_TEMPERATURE"
@@ -27,6 +35,19 @@ export default function InsightsHauttemperaturPage() {
emptyStateIcon={<Thermometer className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any skin temperature yet — what does this metric tell me about my health, and how do I improve it?"
afterChart={
<ConnectedDeviceScoreTile
type="BODY_TEMPERATURE_DEVIATION"
title={t("measurements.typeBodyTemperatureDeviation")}
icon={ThermometerSnowflake}
color="#8be9fd"
unit="°C"
fractionDigits={2}
sectionTitle={t("insights.bodyTempDeviation.title")}
sectionIcon={ThermometerSnowflake}
sectionSubtitle={t("insights.bodyTempDeviation.subtitle")}
/>
}
/>
);
}
+11
View File
@@ -13,6 +13,7 @@ import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { SleepOverview } from "@/components/insights/sleep-overview";
import { SleepRhythmSection } from "@/components/insights/sleep/sleep-rhythm-section";
import { SleepQualitySection } from "@/components/insights/sleep/sleep-quality-section";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
@@ -89,6 +90,16 @@ export default function InsightsSchlafPage() {
*/}
<SleepRhythmSection enabled={!isEmpty} />
{/*
v1.17.1 — "Sleep quality" block. Surfaces the WHOOP / Oura nightly
sleep-quality scores (efficiency / performance / consistency / need /
disturbance count + the Oura sleep score) that were ingested but never
rendered. Each tile is data-gated, so the block is invisible for
non-wearable users and collapses metric-by-metric. Server-authoritative
— the tiles render stored values.
*/}
<SleepQualitySection enabled={!isEmpty} />
<MetricTargetSummary slug="sleep" />
{/*
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2, Plus } from "lucide-react";
import { LabForm } from "@/components/labs/lab-form";
import { LabList } from "@/components/labs/lab-list";
import { Button } from "@/components/ui/button";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
export default function LabsPage() {
const { isAuthenticated, isLoading } = useAuth();
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
const { t } = useTranslations();
const [dialogOpen, setDialogOpen] = useState(false);
const refreshVisible = useCallback(
() => queryClient.invalidateQueries({ type: "active" }),
[queryClient],
);
const pull = usePullToRefresh({
onRefresh: refreshVisible,
disabled: dialogOpen,
});
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push("/auth/login");
}
}, [isLoading, isAuthenticated, router]);
if (!mounted || isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
</div>
);
}
return (
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">
{t("labs.title")}
</h1>
<p className="text-muted-foreground text-xs sm:text-sm">
{t("labs.subtitle")}
</p>
</div>
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 shrink-0 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("labs.addResult")}
</Button>
</div>
<ResponsiveSheet
open={dialogOpen}
onOpenChange={setDialogOpen}
title={t("labs.addResult")}
description={t("labs.addDescription")}
>
<LabForm
onSuccess={() => {
setDialogOpen(false);
queryClient.invalidateQueries({
queryKey: queryKeys.labResults(),
});
}}
onCancel={() => setDialogOpen(false)}
/>
</ResponsiveSheet>
<LabList onAddFirst={() => setDialogOpen(true)} />
</div>
);
}
+8 -2
View File
@@ -10,9 +10,11 @@ import { DashboardSection } from "@/components/settings/dashboard-section";
import { ExportSection } from "@/components/settings/export-section";
import { InsightsSection } from "@/components/settings/insights-section";
import { IntegrationsSection } from "@/components/settings/integrations-section";
import { LayoutSection } from "@/components/settings/layout-section";
import { MedicationsSection } from "@/components/settings/medications-section";
import { MoodSection } from "@/components/settings/mood-section";
import { NotificationsSection } from "@/components/settings/notifications-section";
import { RemindersSection } from "@/components/settings/reminders-section";
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
// v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are two
// separate sections again. `ThresholdsSection` renders the target-range
@@ -28,8 +30,10 @@ import {
import { SettingsShell } from "@/components/settings/settings-shell";
/**
* Dynamic settings section route. Each of the eight `SETTINGS_SECTION_SLUGS`
* is pre-rendered at build via `generateStaticParams()` so the URLs are
* Dynamic settings section route. Each of the `SETTINGS_SECTION_SLUGS`
* (16 today — 12 nav-visible sections plus the four routable-but-hidden
* Layout child editors) is pre-rendered at build via
* `generateStaticParams()` so the URLs are
* statically known to Next.js, while the `dynamicParams = false` flag below
* tells the router to 404 (instead of attempting on-demand rendering) for any
* slug not in the list — which is exactly what `notFound()` would do at
@@ -51,6 +55,8 @@ const SECTION_COMPONENTS: Record<
ai: AiSection,
integrations: IntegrationsSection,
notifications: NotificationsSection,
reminders: RemindersSection,
layout: LayoutSection,
dashboard: DashboardSection,
insights: InsightsSection,
medications: MedicationsSection,
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { Loader2 } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section";
/**
* v1.17.1 — Vorsorge (preventive-care) reminders page. The dedicated
* feature surface for "wann muss ich was wo machen". Auth-gated; the
* section component owns the list + create flow.
*/
export default function VorsorgePage() {
const { isAuthenticated, isLoading } = useAuth();
const mounted = useMounted();
if (!mounted || isLoading) {
return (
<div className="flex min-h-[40vh] items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
);
}
return (
<div className="mx-auto w-full max-w-2xl px-4 py-6">
<VorsorgeSection enabled={isAuthenticated} />
</div>
);
}
+107 -3
View File
@@ -1,8 +1,15 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Activity, Bell, CheckCircle2, Loader2, XCircle } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Activity,
Bell,
CheckCircle2,
HeartPulse,
Loader2,
XCircle,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -10,8 +17,102 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { useAdminSettings, useUpdateSettings } from "./_shared";
import { apiPost } from "@/lib/api/api-fetch";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
interface NotificationHealth {
windowHours: number;
since: string;
channels: Array<{
channel: string;
ok: number;
error: number;
skipped: number;
total: number;
}>;
autoDisabledChannels: Array<{ type: string; count: number }>;
}
/**
* v1.17.1 — operator-wide notification delivery-health panel. A single
* `groupBy(channel, result)` over the trailing 24h of `push_attempts` plus the
* count of auto-disabled channels across all users. Lets the operator answer
* "are anyone's pushes failing?" without DB shell.
*/
function NotificationHealthPanel() {
const { t } = useTranslations();
const windowHours = 24;
const { data, isLoading } = useQuery({
queryKey: queryKeys.adminNotificationHealth(windowHours),
queryFn: async () =>
apiGet<NotificationHealth>(
`/api/admin/notifications/health?hours=${windowHours}`,
),
});
return (
<div className="bg-card border-border mt-6 rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
icon={HeartPulse}
title={t("admin.notificationHealth.title")}
description={t("admin.notificationHealth.description")}
/>
<div className="mt-4 pl-7">
{isLoading && (
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
{data && data.channels.length === 0 && (
<p className="text-muted-foreground text-sm">
{t("admin.notificationHealth.empty")}
</p>
)}
{data && data.channels.length > 0 && (
<div className="space-y-2">
{data.channels.map((c) => (
<div
key={c.channel}
className="flex items-center justify-between text-sm"
>
<span className="font-medium">{c.channel}</span>
<span className="flex items-center gap-3 text-xs">
<span className="text-success dark:text-green-400">
{t("admin.notificationHealth.ok")}: {c.ok}
</span>
<span className="text-destructive dark:text-red-400">
{t("admin.notificationHealth.error")}: {c.error}
</span>
<span className="text-muted-foreground">
{t("admin.notificationHealth.skipped")}: {c.skipped}
</span>
</span>
</div>
))}
</div>
)}
{data && data.autoDisabledChannels.length > 0 && (
<div className="mt-4 space-y-1">
<h2 className="text-sm font-medium">
{t("admin.notificationHealth.autoDisabled")}
</h2>
<div className="flex flex-wrap gap-2">
{data.autoDisabledChannels.map((d) => (
<Badge key={d.type} variant="secondary">
{d.type} ({d.count})
</Badge>
))}
</div>
</div>
)}
</div>
</div>
);
}
export function RemindersSection() {
const { t } = useTranslations();
@@ -92,6 +193,7 @@ export function RemindersSection() {
});
return (
<>
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
{/* v1.4.22 D / F-58 — icon swap so the section header matches
the admin-shell nav tile (Bell). Clock now lives on the
@@ -295,5 +397,7 @@ export function RemindersSection() {
</div>
)}
</div>
<NotificationHealthPanel />
</>
);
}
+107 -1
View File
@@ -1,12 +1,26 @@
"use client";
import { useState } from "react";
import { BellRing, Loader2 } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { BellRing, KeyRound, Loader2 } from "lucide-react";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import {
ConfiguredBadge,
PasswordInput,
@@ -16,6 +30,7 @@ import {
export function WebPushVapidSection() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const { data: settings } = useAdminSettings();
const updateSettings = useUpdateSettings();
const [webPushVapidPublicKeyDraft, setWebPushVapidPublicKeyDraft] = useState<
@@ -26,6 +41,12 @@ export function WebPushVapidSection() {
const [webPushVapidSubjectDraft, setWebPushVapidSubjectDraft] = useState<
string | null
>(null);
const [generating, setGenerating] = useState(false);
// Overwrite guard: the first generate returns 409 when keys already exist;
// we surface the project's AlertDialog (not a native window.confirm) before
// retrying with force, since regenerating invalidates every live Web-Push
// subscription.
const [overwriteConfirmOpen, setOverwriteConfirmOpen] = useState(false);
const webPushVapidPublicKeyValue =
webPushVapidPublicKeyDraft ?? settings?.webPushVapidPublicKey ?? "";
@@ -52,6 +73,51 @@ export function WebPushVapidSection() {
});
}
async function generateVapidKeys(force: boolean) {
setGenerating(true);
try {
const res = await apiFetchRaw(
"/api/admin/settings/web-push-vapid/generate",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(force ? { force: true } : {}),
},
);
if (res.status === 409) {
// Overwrite guard — existing keys would be replaced. Surface the
// in-app confirm dialog (regenerating invalidates current
// subscriptions); the dialog action retries with force.
setOverwriteConfirmOpen(true);
return;
}
if (!res.ok) {
toast.error(t("admin.webPushVapidGenerateFailed"));
return;
}
const json = (await res.json()) as {
data: { webPushVapidPublicKey: string; webPushVapidSubject: string };
};
// The private key was minted and encrypted server-side; only the
// public key + subject come back. Populate the visible fields and
// leave the private-key input empty (it stays "configured").
setWebPushVapidPublicKeyDraft(json.data.webPushVapidPublicKey);
setWebPushVapidSubjectDraft(json.data.webPushVapidSubject);
setWebPushVapidPrivateKeyDraft("");
await queryClient.invalidateQueries({
queryKey: queryKeys.adminSettings(),
});
toast.success(t("admin.webPushVapidGenerated"));
} catch {
toast.error(t("admin.webPushVapidGenerateFailed"));
} finally {
setGenerating(false);
}
}
return (
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
<SettingsCardHeader
@@ -122,6 +188,21 @@ export function WebPushVapidSection() {
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void generateVapidKeys(false)}
disabled={generating || updateSettings.isPending}
>
{generating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="h-3.5 w-3.5" />
)}
{generating
? t("admin.webPushVapidGenerating")
: t("admin.webPushVapidGenerate")}
</Button>
<Button
size="sm"
onClick={saveWebPushVapidSettings}
@@ -133,6 +214,31 @@ export function WebPushVapidSection() {
{t("common.save")}
</Button>
</div>
<AlertDialog
open={overwriteConfirmOpen}
onOpenChange={setOverwriteConfirmOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.webPushVapidGenerateConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("admin.webPushVapidGenerateConfirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => void generateVapidKeys(true)}
>
{t("admin.webPushVapidRegenerate")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -87,6 +87,11 @@ interface SectionAvailability {
// the cycle toggle. Present so `keyof DoctorReportPrefs` indexing
// typechecks. Optional — the availability endpoint may not provide it.
cycle?: boolean;
// v1.17.1 — labs section. Like cycle, not rendered by this legacy dialog;
// the flagship export panel surfaces the toggle. Optional — the
// availability endpoint may not provide it. Present so the
// `keyof DoctorReportPrefs` indexing typechecks.
labs?: boolean;
}
export interface DoctorReportSubmitPayload {
@@ -547,6 +552,9 @@ const SECTION_LABEL_KEYS: Record<keyof DoctorReportPrefs, string> = {
// v1.15.0 — not rendered by this legacy dialog (SECTION_ORDER omits it);
// present so the `keyof DoctorReportPrefs` record stays exhaustive.
cycle: "doctorReport.sections.cycle",
// v1.17.1 — labs section; the flagship export panel drives the toggle,
// present here so the `keyof DoctorReportPrefs` record stays exhaustive.
labs: "doctorReport.sections.labs",
};
/**
@@ -0,0 +1,228 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { DataSummary } from "@/lib/analytics/trends";
/**
* v1.17.1 — the WHOOP / Oura / Polar device-native score surfaces.
*
* `<SleepQualitySection>` (sleep page) and `<RecoverySection>`
* (`/insights/recovery`) surface metrics that were ingested end-to-end but
* never rendered. The contract under test is the DATA-GATING: a tile / section
* appears ONLY when its metric has stored readings, so a non-wearable account
* never sees an empty card. `<DeviceScoreTile>` carries the same gate plus a
* sparse-data `<LearningGate>` swap.
*/
// The mini chart is `next/dynamic`-imported; SSR renders the stub.
vi.mock("next/dynamic", () => ({
default: () => {
const Stub = () => <div data-slot="mini-chart-stub" />;
Stub.displayName = "MiniChartStub";
return Stub;
},
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { timezone: "UTC" },
isAuthenticated: true,
}),
}));
const analyticsMock = vi.fn();
vi.mock("@/hooks/use-insights-analytics", () => ({
useInsightsAnalytics: () => analyticsMock(),
}));
import { DeviceScoreTile } from "../device-score-tile";
import { SleepQualitySection } from "../sleep/sleep-quality-section";
import { RecoverySection } from "../recovery/recovery-section";
function render(node: React.ReactNode) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
function summary(over: Partial<DataSummary>): DataSummary {
return {
count: 0,
latest: null,
min: null,
max: null,
mean: null,
median: null,
avg7: null,
avg30: null,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
...over,
};
}
function analyticsWith(summaries: Record<string, DataSummary>) {
return {
data: { summaries },
isLoading: false,
isEmpty: false,
error: null,
refetch: () => {},
};
}
function analyticsLoading() {
return {
data: undefined,
isLoading: true,
isEmpty: false,
error: null,
refetch: () => {},
};
}
beforeEach(() => {
analyticsMock.mockReset();
});
describe("<DeviceScoreTile> gating", () => {
it("renders nothing when the metric has no readings", () => {
const html = render(
<DeviceScoreTile
type="SLEEP_EFFICIENCY"
summary={summary({ count: 0 })}
title="Sleep efficiency"
icon={() => null}
color="#50fa7b"
unit="%"
/>,
);
expect(html).toBe("");
});
it("shows the latest reading + sparkline once it has enough data", () => {
const html = render(
<DeviceScoreTile
type="SLEEP_EFFICIENCY"
summary={summary({ count: 12, latest: 91, mean: 88 })}
title="Sleep efficiency"
icon={() => null}
color="#50fa7b"
unit="%"
/>,
);
expect(html).toContain('data-slot="device-score-tile"');
expect(html).toContain('data-slot="device-score-latest"');
expect(html).toContain("91");
expect(html).toContain('data-slot="mini-chart-stub"');
// No learning gate once past the threshold.
expect(html).not.toContain('data-slot="device-score-learning"');
});
it("swaps the sparkline for the LearningGate while the series is sparse", () => {
const html = render(
<DeviceScoreTile
type="DAY_STRAIN"
summary={summary({ count: 2, latest: 9, mean: 8 })}
title="Day strain"
icon={() => null}
color="#ffb86c"
/>,
);
expect(html).toContain('data-slot="device-score-learning"');
expect(html).not.toContain('data-slot="mini-chart-stub"');
});
});
describe("device-score loading skeletons", () => {
it("paints the shared Skeleton grid for sleep-quality while loading", () => {
analyticsMock.mockReturnValue(analyticsLoading());
const html = render(<SleepQualitySection enabled />);
expect(html).toContain('data-slot="sleep-quality-loading"');
expect(html).toContain('data-slot="device-score-grid-skeleton"');
expect(html).toContain('data-slot="skeleton"');
// No real tiles while the slice is still loading.
expect(html).not.toContain('data-slot="device-score-tile"');
});
it("paints the shared Skeleton grid for recovery while loading", () => {
analyticsMock.mockReturnValue(analyticsLoading());
const html = render(<RecoverySection />);
expect(html).toContain('data-slot="recovery-loading"');
expect(html).toContain('data-slot="device-score-grid-skeleton"');
expect(html).toContain('data-slot="skeleton"');
expect(html).not.toContain('data-slot="recovery-empty"');
});
});
describe("<SleepQualitySection> data-gating", () => {
it("collapses to nothing when no sleep-quality metric has data", () => {
analyticsMock.mockReturnValue(
analyticsWith({ SLEEP_DURATION: summary({ count: 30 }) }),
);
const html = render(<SleepQualitySection enabled />);
expect(html).not.toContain('data-slot="sleep-quality-section"');
});
it("renders only the metrics that have readings", () => {
analyticsMock.mockReturnValue(
analyticsWith({
SLEEP_EFFICIENCY: summary({ count: 20, latest: 92, mean: 90 }),
SLEEP_SCORE: summary({ count: 0 }),
}),
);
const html = render(<SleepQualitySection enabled />);
expect(html).toContain('data-slot="sleep-quality-section"');
expect(html).toContain('data-metric="SLEEP_EFFICIENCY"');
expect(html).not.toContain('data-metric="SLEEP_SCORE"');
});
it("renders nothing when disabled", () => {
analyticsMock.mockReturnValue(
analyticsWith({ SLEEP_EFFICIENCY: summary({ count: 20 }) }),
);
const html = render(<SleepQualitySection enabled={false} />);
expect(html).toBe("");
});
});
describe("<RecoverySection> data-gating", () => {
it("shows the calm empty note when there is no recovery data at all", () => {
analyticsMock.mockReturnValue(
analyticsWith({ SLEEP_DURATION: summary({ count: 10 }) }),
);
const html = render(<RecoverySection />);
expect(html).toContain('data-slot="recovery-empty"');
// Routed through the shared <EmptyState> (dashed bordered card), not the
// old hand-rolled div — the empty title comes from the unified primitive.
expect(html).toContain("border-dashed");
expect(html).not.toContain('data-slot="recovery-group-strain"');
});
it("renders the strain group and cross-links the recovery score when present", () => {
analyticsMock.mockReturnValue(
analyticsWith({
DAY_STRAIN: summary({ count: 14, latest: 12, mean: 11 }),
RECOVERY_SCORE: summary({ count: 14, latest: 66, mean: 60 }),
ANS_CHARGE: summary({ count: 0 }),
}),
);
const html = render(<RecoverySection />);
expect(html).toContain('data-slot="recovery-group-strain"');
expect(html).toContain('data-metric="DAY_STRAIN"');
expect(html).toContain('data-slot="recovery-score-link"');
// The recharge group (ANS charge only) has no data → hidden.
expect(html).not.toContain('data-slot="recovery-group-recharge"');
// Empty note must not show when at least one signal is present.
expect(html).not.toContain('data-slot="recovery-empty"');
});
});
@@ -4,6 +4,7 @@ import type { ReactNode } from "react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { LearningGate } from "@/components/ui/learning-gate";
import type {
DerivedConfidence,
DerivedCoverage,
@@ -180,13 +181,13 @@ export function ScoreAnatomyView({
</div>
{insufficient ? (
<p
data-slot="score-anatomy-insufficient"
className="text-muted-foreground rounded-lg border border-dashed px-4 py-6 text-center text-xs"
>
{insufficientNote ??
t("insights.derived.anatomy.insufficient")}
</p>
<LearningGate
variant="bordered"
bodySlot="score-anatomy-insufficient"
message={
insufficientNote ?? t("insights.derived.anatomy.insufficient")
}
/>
) : (
<div data-slot="score-anatomy-contributors" className="space-y-3">
<p className="text-muted-foreground text-[11px] font-semibold tracking-wide uppercase">
@@ -6,6 +6,7 @@ import { TrendingUp, TrendingDown, Minus } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { LearningGate } from "@/components/ui/learning-gate";
import { CoverageMeter } from "@/components/insights/derived/coverage-meter";
import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer";
import { METRIC_PROVENANCE } from "@/components/insights/derived/standards";
@@ -237,25 +238,18 @@ export function TrajectoryForecastCard({
return (
<div className={className}>
<CardShell provenance={data.provenance} compact={compact}>
<p
className={cn(
"text-muted-foreground",
compact ? "text-xs leading-snug" : "text-sm",
)}
data-slot="trajectory-insufficient"
>
{t("insights.derived.trajectory.insufficient")}
</p>
{daysNeeded > 0 ? (
<p
className="text-muted-foreground text-xs leading-snug"
data-slot="trajectory-days-needed"
>
{t("insights.derived.trajectory.daysNeeded", {
count: daysNeeded,
})}
</p>
) : null}
<LearningGate
compact={compact}
bodySlot="trajectory-insufficient"
message={t("insights.derived.trajectory.insufficient")}
caveat={
daysNeeded > 0
? t("insights.derived.trajectory.daysNeeded", {
count: daysNeeded,
})
: undefined
}
/>
<CoverageMeter coverage={data.coverage} />
</CardShell>
</div>
@@ -0,0 +1,49 @@
import {
Card,
CardAction,
CardContent,
CardHeader,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
/**
* v1.17.1 — loading placeholder that mirrors {@link DeviceScoreTile}'s
* layout so the device-score grids (recovery + sleep-quality) and the lab
* list paint the same tile shape while their analytics slice loads, instead
* of popping in or showing a bespoke centred spinner. One header row (title +
* latest readout) over a sparkline block, all via the shared `Skeleton`
* primitive (which carries `motion-reduce:animate-none` for free).
*/
export function DeviceScoreTileSkeleton() {
return (
<Card data-slot="device-score-tile-skeleton" aria-hidden="true">
<CardHeader>
<Skeleton className="h-4 w-32" />
<CardAction>
<Skeleton className="h-5 w-12" />
</CardAction>
</CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-[120px] w-full" />
<Skeleton className="h-3 w-24" />
</CardContent>
</Card>
);
}
/**
* A `count`-wide grid of {@link DeviceScoreTileSkeleton}s, matching the
* `grid gap-4 sm:grid-cols-2` the device-score sections render their tiles in.
*/
export function DeviceScoreGridSkeleton({ count = 2 }: { count?: number }) {
return (
<div
data-slot="device-score-grid-skeleton"
className="grid gap-4 sm:grid-cols-2"
>
{Array.from({ length: count }, (_, i) => (
<DeviceScoreTileSkeleton key={i} />
))}
</div>
);
}
@@ -0,0 +1,214 @@
"use client";
import type { ComponentType } from "react";
import { useAuth } from "@/hooks/use-auth";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import type { DataSummary } from "@/lib/analytics/trends";
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { LearningGate } from "@/components/ui/learning-gate";
import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic";
import { SectionHeading } from "@/components/insights/section-heading";
/**
* v1.17.1 — calm, data-gated readout for a device-native score that has no
* generic assessment registry entry.
*
* The WHOOP / Oura / Polar score family (sleep performance, day strain, ANS
* charge, …) is ingested end-to-end and lit in the AI chart-token allowlist,
* but no page renders it. These metrics are deliberately OUTSIDE the
* `metric-status-registry` (they're vendor-rolled-up scores, not signals the
* app re-derives), so they carry no `<MetricStatusCard>`. This tile is the
* surface for them: a title, the latest reading, the trailing-window mean, and
* a small sparkline — server-authoritative throughout (it reads the stored
* `summaries[type]` slice; it never recomputes).
*
* Calm posture (per the maintainer's standing card rules): one neutral card,
* never an alarming colour, never a green-when-good / red-when-bad tint. The
* caller passes the single chart line colour; the card chrome stays neutral.
*
* Data-gating is the caller's job: mount this tile only when
* `summary.count > 0` (the parent section already reads the shared analytics
* payload, so this avoids a second fetch and keeps the empty surface clean for
* non-wearable users). When the series is present but still sparse
* (`count < learningThreshold`), the tile swaps the sparkline for a
* `<LearningGate>` so a two-night-old strap never paints a jagged
* two-point line that reads as a trend.
*/
export interface DeviceScoreTileProps {
/** The MeasurementType backing the readout + sparkline. */
type: string;
/** The stored summary slice for `type` (count / latest / mean). */
summary: DataSummary | null | undefined;
/** Localized tile title (e.g. the `measurements.type*` label). */
title: string;
/** Leading glyph. */
icon: ComponentType<{ className?: string }>;
/** Single chart-line colour. Never used for card chrome. */
color: string;
/** Unit suffix on the readout + chart (e.g. "%", "bpm", "kJ", "score"). */
unit?: string;
/** Optional decimal precision for the readout. Defaults to 0. */
fractionDigits?: number;
/**
* Below this many readings the tile shows the `<LearningGate>` instead of a
* sparkline. Defaults to 3 — enough points for a line to read honestly.
*/
learningThreshold?: number;
className?: string;
}
export function DeviceScoreTile({
type,
summary,
title,
icon: Icon,
color,
unit,
fractionDigits = 0,
learningThreshold = 3,
className,
}: DeviceScoreTileProps) {
const { t } = useTranslations();
const { user } = useAuth();
const count = summary?.count ?? 0;
if (count === 0) return null;
const latest = summary?.latest ?? null;
const mean = summary?.mean ?? null;
const isLearning = count < learningThreshold;
const fmt = (value: number) =>
value.toLocaleString(undefined, {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
});
return (
<Card
data-slot="device-score-tile"
data-metric={type}
className={className}
>
<CardHeader>
<CardTitle className="flex min-w-0 items-center gap-2 text-sm">
<Icon
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span className="truncate">{title}</span>
</CardTitle>
{latest != null ? (
<CardAction
data-slot="device-score-latest"
className="text-foreground self-baseline text-lg font-semibold tabular-nums"
>
{fmt(latest)}
{unit ? (
<span className="text-muted-foreground ml-1 text-xs font-normal">
{unit}
</span>
) : null}
</CardAction>
) : null}
</CardHeader>
<CardContent className="space-y-3">
{isLearning ? (
<LearningGate
compact
bodySlot="device-score-learning"
message={t("insights.deviceScore.learning")}
/>
) : (
<>
<div className="h-[120px] [--chart-height:96px]">
<HealthChartDynamicMini
types={[type]}
title={title}
colors={[color]}
unit={unit ?? ""}
mini
userTimezone={user?.timezone}
/>
</div>
{mean != null ? (
<p
data-slot="device-score-mean"
className="text-muted-foreground text-xs"
>
{t("insights.deviceScore.average", { value: fmt(mean) })}
{unit ? ` ${unit}` : ""}
</p>
) : null}
</>
)}
</CardContent>
</Card>
);
}
export interface ConnectedDeviceScoreTileProps
extends Omit<DeviceScoreTileProps, "summary"> {
/** Optional section heading rendered above the tile when it has data. */
sectionTitle?: string;
/** Optional Lucide glyph for the section heading. */
sectionIcon?: ComponentType<{ className?: string }>;
/** Optional section subtitle pinned to the heading's right edge. */
sectionSubtitle?: string;
}
/**
* v1.17.1 — a {@link DeviceScoreTile} that reads its own summary from the
* shared `["analytics", "summaries"]` slice, so it can be dropped onto a page
* that uses the `HealthKitMetricPage` scaffold (which owns its own analytics
* read internally) without threading a summary prop down. Reuses the same
* cache the host page already populated — no extra round-trip. Returns null
* until the data lands and when the metric has no readings, so a host page
* stays byte-identical for users without that device signal.
*/
export function ConnectedDeviceScoreTile({
type,
sectionTitle,
sectionIcon,
sectionSubtitle,
...tileProps
}: ConnectedDeviceScoreTileProps) {
const { data } = useInsightsAnalytics("SLEEP_DURATION");
const summary = data?.summaries?.[type];
if ((summary?.count ?? 0) === 0) return null;
const tile = (
<DeviceScoreTile type={type} summary={summary} {...tileProps} />
);
if (!sectionTitle || !sectionIcon) return tile;
return (
<section
data-slot="connected-device-score"
data-metric={type}
className="space-y-3"
>
<SectionHeading
icon={sectionIcon}
title={sectionTitle}
action={
sectionSubtitle ? (
<p className="text-muted-foreground text-xs">{sectionSubtitle}</p>
) : undefined
}
/>
{tile}
</section>
);
}
@@ -4,6 +4,7 @@ import { Activity, Droplet, TrendingUp } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LearningGate } from "@/components/ui/learning-gate";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
@@ -99,18 +100,19 @@ export function GlucoseClinicalPanel() {
{t("insights.bloodGlucose.clinical.learningTitle")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-muted-foreground text-sm" data-slot="glucose-learning-body">
{hasAny
? t("insights.bloodGlucose.clinical.learningBody", {
count: readingsLabel,
days: daysLabel,
})
: t("insights.bloodGlucose.clinical.learningBodyEmpty")}
</p>
<p className="text-muted-foreground/80 text-xs">
{t("insights.bloodGlucose.clinical.spotCaveat")}
</p>
<CardContent>
<LearningGate
bodySlot="glucose-learning-body"
message={
hasAny
? t("insights.bloodGlucose.clinical.learningBody", {
count: readingsLabel,
days: daysLabel,
})
: t("insights.bloodGlucose.clinical.learningBodyEmpty")
}
caveat={t("insights.bloodGlucose.clinical.spotCaveat")}
/>
</CardContent>
</Card>
);
@@ -0,0 +1,240 @@
"use client";
import Link from "next/link";
import {
Activity,
Battery,
Flame,
Gauge,
Heart,
HeartPulse,
TrendingUp,
} from "lucide-react";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { EmptyState } from "@/components/ui/empty-state";
import { SectionHeading } from "@/components/insights/section-heading";
import { DeviceScoreTile } from "@/components/insights/device-score-tile";
import { DeviceScoreGridSkeleton } from "@/components/insights/device-score-tile-skeleton";
interface TileSpec {
type: string;
title: string;
icon: typeof Activity;
color: string;
unit?: string;
fractionDigits?: number;
}
interface TileGroup {
key: string;
headingIcon: typeof Activity;
title: string;
subtitle: string;
tiles: TileSpec[];
}
/**
* v1.17.1 — body of the `/insights/recovery` sub-page.
*
* The home for the WHOOP / Polar device-native recovery + strain scores that
* were ingested but had no render host: day strain, workout strain, ANS charge,
* cardio load, plus the day's whole-cycle average / max heart rate and the
* kilojoule energy-expenditure figure. The composite RECOVERY_SCORE keeps its
* own anatomy view at `/insights/scores/recovery`; this page cross-links to it
* rather than duplicating the ring, and surrounds it with the raw device
* signals it never showed.
*
* Each tile is data-gated (`<DeviceScoreTile>` returns null at count 0) and
* each group hides when none of its tiles has data, so a non-wearable account
* sees only the calm empty state below. Reads the shared analytics summaries
* slice (no extra round-trip); server-authoritative throughout.
*/
export function RecoverySection() {
const { t } = useTranslations();
const { data, isLoading } = useInsightsAnalytics("SLEEP_DURATION");
const summaries = data?.summaries;
const groups: TileGroup[] = [
{
key: "recharge",
headingIcon: Battery,
title: t("insights.recovery.recharge.title"),
subtitle: t("insights.recovery.recharge.subtitle"),
tiles: [
{
type: "ANS_CHARGE",
title: t("measurements.typeAnsCharge"),
icon: Battery,
color: "#50fa7b",
unit: t("insights.deviceScore.unitScore"),
fractionDigits: 1,
},
],
},
{
key: "strain",
headingIcon: Gauge,
title: t("insights.recovery.strain.title"),
subtitle: t("insights.recovery.strain.subtitle"),
tiles: [
{
type: "DAY_STRAIN",
title: t("measurements.typeDayStrain"),
icon: Gauge,
color: "#ffb86c",
unit: t("insights.deviceScore.unitScore"),
fractionDigits: 1,
},
{
type: "WORKOUT_STRAIN",
title: t("measurements.typeWorkoutStrain"),
icon: Activity,
color: "#ff79c6",
unit: t("insights.deviceScore.unitScore"),
fractionDigits: 1,
},
{
type: "CARDIO_LOAD",
title: t("measurements.typeCardioLoad"),
icon: TrendingUp,
color: "#bd93f9",
unit: t("insights.deviceScore.unitScore"),
},
],
},
{
key: "cardio",
headingIcon: HeartPulse,
title: t("insights.recovery.cardio.title"),
subtitle: t("insights.recovery.cardio.subtitle"),
tiles: [
{
type: "AVERAGE_HEART_RATE",
title: t("measurements.typeAverageHeartRate"),
icon: Heart,
color: "#8be9fd",
unit: t("insights.deviceScore.unitBpm"),
},
{
type: "MAX_HEART_RATE",
title: t("measurements.typeMaxHeartRate"),
icon: HeartPulse,
color: "#ff5555",
unit: t("insights.deviceScore.unitBpm"),
},
{
type: "ENERGY_EXPENDITURE_KJ",
title: t("measurements.typeEnergyExpenditureKj"),
icon: Flame,
color: "#f1fa8c",
unit: t("insights.deviceScore.unitKj"),
},
],
},
];
const hasAny =
summaries != null &&
groups.some((group) =>
group.tiles.some((tile) => (summaries[tile.type]?.count ?? 0) > 0),
);
const hasRecoveryScore = (summaries?.RECOVERY_SCORE?.count ?? 0) > 0;
if (isLoading || summaries == null) {
return (
<div className="space-y-3" data-slot="recovery-loading">
<SectionHeading
icon={Battery}
title={t("insights.recovery.recharge.title")}
/>
<DeviceScoreGridSkeleton count={2} />
</div>
);
}
if (!hasAny && !hasRecoveryScore) {
return (
<EmptyState
data-slot="recovery-empty"
icon={<Heart className="size-6" />}
title={t("insights.recovery.emptyTitle")}
description={t("insights.recovery.empty")}
/>
);
}
return (
<div className="space-y-8" data-slot="recovery-section">
{hasRecoveryScore ? (
<Link
href="/insights/scores/recovery"
data-slot="recovery-score-link"
className="bg-card border-border hover:bg-accent/40 flex items-center justify-between gap-3 rounded-xl border p-4 transition-colors"
>
<span className="flex min-w-0 items-center gap-2">
<Heart
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span className="min-w-0">
<span className="block text-sm font-medium">
{t("insights.recovery.scoreLink.title")}
</span>
<span className="text-muted-foreground block text-xs">
{t("insights.recovery.scoreLink.subtitle")}
</span>
</span>
</span>
<span className="text-muted-foreground shrink-0 text-xs">
{t("insights.recovery.scoreLink.cta")}
</span>
</Link>
) : null}
{groups.map((group) => {
const present =
summaries == null
? []
: group.tiles.filter(
(tile) => (summaries[tile.type]?.count ?? 0) > 0,
);
if (present.length === 0) return null;
return (
<section
key={group.key}
data-slot={`recovery-group-${group.key}`}
className="space-y-3"
>
<SectionHeading
icon={group.headingIcon}
title={group.title}
action={
<p className="text-muted-foreground text-xs">
{group.subtitle}
</p>
}
/>
<div className="grid gap-4 sm:grid-cols-2">
{present.map((tile) => (
<DeviceScoreTile
key={tile.type}
type={tile.type}
summary={summaries?.[tile.type]}
title={tile.title}
icon={tile.icon}
color={tile.color}
unit={tile.unit}
fractionDigits={tile.fractionDigits}
/>
))}
</div>
</section>
);
})}
</div>
);
}
@@ -102,6 +102,41 @@ describe("<SleepRhythmSection>", () => {
expect(html).toContain("md:py-6");
});
it("places the two cards side by side when both carry settled data", () => {
rhythmMock.mockReturnValue({
data: READY_DTO,
isLoading: false,
isError: false,
});
const html = render(<SleepRhythmSection enabled />);
// Two-up on large screens, single column below.
expect(html).toContain("lg:grid-cols-2");
});
it("keeps a lone data-bearing card full width when the other is still learning", () => {
rhythmMock.mockReturnValue({
data: {
...READY_DTO,
// Sleep-debt settled, chronotype still learning → no two-up grid.
chronotype: {
state: "learning",
msfMinutes: null,
msfScMinutes: null,
band: null,
socialJetlagMinutes: null,
freeNightsCounted: 2,
workNightsCounted: 5,
freeNightsUntilReady: 4,
},
} satisfies SleepRhythmDto,
isLoading: false,
isError: false,
});
const html = render(<SleepRhythmSection enabled />);
expect(html).toContain("grid-cols-1");
expect(html).not.toContain("lg:grid-cols-2");
});
it("renders nothing when disabled", () => {
rhythmMock.mockReturnValue({
data: undefined,
@@ -6,6 +6,7 @@ import { Clock, ChevronDown, ChevronUp } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { LearningGate } from "@/components/ui/learning-gate";
import type { ChronotypeDto } from "./use-sleep-rhythm";
/** Minutes-of-day → "HH:MM" wall-clock label (24 h, zero-padded). */
@@ -48,12 +49,12 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
{t("insights.sleep.chronotype.learning", {
<LearningGate
message={t("insights.sleep.chronotype.learning", {
counted: chronotype.freeNightsCounted,
total: need,
})}
</p>
/>
</CardContent>
</Card>
);
@@ -4,6 +4,7 @@ import { Moon } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LearningGate } from "@/components/ui/learning-gate";
import type { SleepDebtDto } from "./use-sleep-rhythm";
/** Whole hours + minutes from a minute total, for the headline figure. */
@@ -36,12 +37,12 @@ export function SleepDebtCard({ debt }: { debt: SleepDebtDto }) {
</div>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
{t("insights.sleep.debt.learning", {
<LearningGate
message={t("insights.sleep.debt.learning", {
counted: debt.nightsCounted,
remaining: debt.nightsUntilReady,
})}
</p>
/>
</CardContent>
</Card>
);
@@ -0,0 +1,129 @@
"use client";
import { Activity, Gauge, Moon, Repeat, Target, Waves } from "lucide-react";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { SectionHeading } from "@/components/insights/section-heading";
import { DeviceScoreTile } from "@/components/insights/device-score-tile";
import { DeviceScoreGridSkeleton } from "@/components/insights/device-score-tile-skeleton";
/**
* v1.17.1 — "Sleep quality" block on `/insights/sleep`.
*
* Surfaces the WHOOP / Oura nightly sleep-quality scores that were ingested
* end-to-end but rendered nowhere: efficiency, performance, consistency, need,
* disturbance count, and the Oura headline sleep score. Each tile is gated on
* the metric having data (`<DeviceScoreTile>` returns null at count 0), so a
* non-wearable user never sees an empty card and the whole section collapses
* when none of the six has readings.
*
* Reads the shared `["analytics", "summaries"]` slice — the same cache the
* page's duration gate already populated, so this adds no network round-trip.
* Server-authoritative: the tiles render the stored values, never a recompute.
*/
export function SleepQualitySection({ enabled }: { enabled: boolean }) {
const { t } = useTranslations();
const { data, isLoading } = useInsightsAnalytics("SLEEP_DURATION");
if (!enabled) return null;
const summaries = data?.summaries;
// While the shared slice loads, paint the tile-shaped Skeleton grid (same
// primitive the recovery + labs surfaces use) rather than nothing, so the
// section does not pop in. Once data lands the section either renders its
// present tiles or collapses to null — an in-page sub-section never shows a
// standalone empty card (the page-level empties on /insights/recovery and
// /labs own that treatment via <EmptyState>).
if (isLoading || !summaries) {
return (
<section data-slot="sleep-quality-loading" className="space-y-3">
<SectionHeading icon={Moon} title={t("insights.sleepQuality.title")} />
<DeviceScoreGridSkeleton count={2} />
</section>
);
}
const tiles: Array<{
type: string;
title: string;
icon: typeof Moon;
color: string;
unit?: string;
}> = [
{
type: "SLEEP_SCORE",
title: t("measurements.typeSleepScore"),
icon: Moon,
color: "#bd93f9",
unit: t("insights.deviceScore.unitScore"),
},
{
type: "SLEEP_PERFORMANCE",
title: t("measurements.typeSleepPerformance"),
icon: Target,
color: "#8be9fd",
unit: "%",
},
{
type: "SLEEP_EFFICIENCY",
title: t("measurements.typeSleepEfficiency"),
icon: Gauge,
color: "#50fa7b",
unit: "%",
},
{
type: "SLEEP_CONSISTENCY",
title: t("measurements.typeSleepConsistency"),
icon: Repeat,
color: "#f1fa8c",
unit: "%",
},
{
type: "SLEEP_NEED",
title: t("measurements.typeSleepNeed"),
icon: Waves,
color: "#ffb86c",
unit: t("insights.deviceScore.unitMinutes"),
},
{
type: "SLEEP_DISTURBANCE_COUNT",
title: t("measurements.typeSleepDisturbanceCount"),
icon: Activity,
color: "#ff79c6",
},
];
const present = tiles.filter(
(tile) => (summaries[tile.type]?.count ?? 0) > 0,
);
if (present.length === 0) return null;
return (
<section data-slot="sleep-quality-section" className="space-y-3">
<SectionHeading
icon={Moon}
title={t("insights.sleepQuality.title")}
action={
<p className="text-muted-foreground text-xs">
{t("insights.sleepQuality.subtitle")}
</p>
}
/>
<div className="grid gap-4 sm:grid-cols-2">
{present.map((tile) => (
<DeviceScoreTile
key={tile.type}
type={tile.type}
summary={summaries[tile.type]}
title={tile.title}
icon={tile.icon}
color={tile.color}
unit={tile.unit}
/>
))}
</div>
</section>
);
}
@@ -2,6 +2,7 @@
import { useAuth } from "@/hooks/use-auth";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useSleepRhythm } from "./use-sleep-rhythm";
import { SleepDebtCard } from "./sleep-debt-card";
@@ -52,8 +53,22 @@ export function SleepRhythmSection({ enabled }: { enabled: boolean }) {
);
}
// Both cards are compact; when each carries a settled (non-learning) readout
// they sit two-up. A lone card with data spans full width so it never leaves
// a half-width orphan with dead space beside it (mirrors the mood Einordnung
// tiles). The cards' own learning/partial states stay stacked full-width.
const debtHasData = data.sleepDebt.state === "ready";
const chronotypeHasData =
data.chronotype.state === "ready" && data.chronotype.band != null;
const bothHaveData = debtHasData && chronotypeHasData;
return (
<div className="space-y-4">
<div
className={cn(
"grid gap-4",
bothHaveData ? "lg:grid-cols-2 lg:items-start" : "grid-cols-1",
)}
>
<SleepDebtCard debt={data.sleepDebt} />
<ChronotypeCard chronotype={data.chronotype} />
</div>
@@ -0,0 +1,44 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.17.1 — the lab list's loading affordance.
*
* Audit 02-H2/L2: the list used a bespoke centred `<Loader2>` (a different
* spinner size than the page above it). The fix paints a tile-shaped
* `Skeleton` stack via the shared primitive so labs / recovery / sleep-quality
* share one loading dialect. On first synchronous render the query is pending,
* so the skeleton branch shows.
*/
vi.mock("@/lib/api/api-fetch", () => ({
// Never resolves within the synchronous SSR render → query stays loading.
apiGet: () => new Promise(() => {}),
apiDelete: vi.fn(),
}));
import { LabList } from "../lab-list";
function render(node: React.ReactNode) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
describe("<LabList> loading", () => {
it("paints the shared Skeleton stack while loading", () => {
const html = render(<LabList />);
expect(html).toContain('data-slot="lab-list-loading"');
expect(html).toContain('data-slot="skeleton"');
// No bespoke spinner anymore.
expect(html).not.toContain("animate-spin");
});
});
+228
View File
@@ -0,0 +1,228 @@
"use client";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { DateTimeInput } from "@/components/ui/date-input";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import type { LabResultDto } from "./types";
const NOTE_MAX_LENGTH = 2000;
function defaultTakenAtValue() {
const now = new Date();
const offset = now.getTimezoneOffset();
const local = new Date(now.getTime() - offset * 60 * 1000);
return local.toISOString().slice(0, 16);
}
/** Parse a free-text decimal that may use a comma separator. */
function parseDecimal(raw: string): number | null {
const trimmed = raw.trim().replace(",", ".");
if (trimmed === "") return null;
const n = Number(trimmed);
return Number.isFinite(n) ? n : null;
}
interface LabFormProps {
onSuccess?: (created: LabResultDto) => void;
onCancel?: () => void;
}
/**
* v1.17.1 — manual lab-result entry.
*
* Progressive disclosure: the four essentials (analyte, value, unit, date)
* are always visible; the optional reference range, panel grouping, and
* note sit below under a clearly-labelled "optional" heading so a quick
* single-value entry stays a four-field affair.
*/
export function LabForm({ onSuccess, onCancel }: LabFormProps) {
const { t } = useTranslations();
const [analyte, setAnalyte] = useState("");
const [value, setValue] = useState("");
const [unit, setUnit] = useState("");
const [takenAt, setTakenAt] = useState(defaultTakenAtValue);
const [panel, setPanel] = useState("");
const [refLow, setRefLow] = useState("");
const [refHigh, setRefHigh] = useState("");
const [note, setNote] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
const numericValue = parseDecimal(value);
if (!analyte.trim() || numericValue === null || !unit.trim()) {
setError(t("labs.form.requiredError"));
return;
}
const low = parseDecimal(refLow);
const high = parseDecimal(refHigh);
if (low !== null && high !== null && low > high) {
setError(t("labs.form.rangeOrderError"));
return;
}
setSubmitting(true);
try {
const created = await apiPost<LabResultDto>("/api/labs", {
analyte: analyte.trim(),
value: numericValue,
unit: unit.trim(),
takenAt: new Date(takenAt).toISOString(),
...(panel.trim() ? { panel: panel.trim() } : {}),
...(low !== null ? { referenceLow: low } : {}),
...(high !== null ? { referenceHigh: high } : {}),
...(note.trim() ? { note: note.trim() } : {}),
});
toast.success(t("labs.form.savedToast"));
onSuccess?.(created);
} catch (err) {
const message =
err instanceof ApiError ? err.message : t("labs.form.saveError");
setError(message);
} finally {
setSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="lab-analyte">{t("labs.form.analyte")}</Label>
<Input
id="lab-analyte"
value={analyte}
onChange={(e) => setAnalyte(e.target.value)}
placeholder={t("labs.form.analytePlaceholder")}
maxLength={120}
autoFocus
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="lab-value">{t("labs.form.value")}</Label>
<Input
id="lab-value"
inputMode="decimal"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="0.0"
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="lab-unit">{t("labs.form.unit")}</Label>
<Input
id="lab-unit"
value={unit}
onChange={(e) => setUnit(e.target.value)}
placeholder={t("labs.form.unitPlaceholder")}
maxLength={40}
required
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="lab-takenAt">{t("labs.form.takenAt")}</Label>
<DateTimeInput
id="lab-takenAt"
value={takenAt}
onChange={(e) => setTakenAt(e.target.value)}
max={defaultTakenAtValue()}
required
/>
</div>
</div>
<fieldset className="space-y-4 rounded-lg border border-dashed p-4">
<legend className="text-muted-foreground px-1 text-xs font-medium">
{t("labs.form.optionalGroup")}
</legend>
<div className="space-y-1.5">
<Label htmlFor="lab-panel">{t("labs.form.panel")}</Label>
<Input
id="lab-panel"
value={panel}
onChange={(e) => setPanel(e.target.value)}
placeholder={t("labs.form.panelPlaceholder")}
maxLength={120}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="lab-refLow">{t("labs.form.referenceLow")}</Label>
<Input
id="lab-refLow"
inputMode="decimal"
value={refLow}
onChange={(e) => setRefLow(e.target.value)}
placeholder={t("labs.form.referenceLowPlaceholder")}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="lab-refHigh">{t("labs.form.referenceHigh")}</Label>
<Input
id="lab-refHigh"
inputMode="decimal"
value={refHigh}
onChange={(e) => setRefHigh(e.target.value)}
placeholder={t("labs.form.referenceHighPlaceholder")}
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("labs.form.referenceHint")}
</p>
<div className="space-y-1.5">
<Label htmlFor="lab-note">{t("labs.form.note")}</Label>
<Textarea
id="lab-note"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder={t("labs.form.notePlaceholder")}
maxLength={NOTE_MAX_LENGTH}
rows={2}
/>
</div>
</fieldset>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
<div className="flex justify-end gap-2">
{onCancel ? (
<Button
type="button"
variant="ghost"
onClick={onCancel}
disabled={submitting}
>
{t("common.cancel")}
</Button>
) : null}
<Button type="submit" disabled={submitting}>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("labs.form.save")}
</Button>
</div>
</form>
);
}
+214
View File
@@ -0,0 +1,214 @@
"use client";
import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FlaskConical, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { formatDate } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { LabTrendSparkline } from "./lab-trend-sparkline";
import { ReferenceRangeBadge } from "./reference-range-badge";
import type { LabResultDto, LabResultListResponse } from "./types";
interface AnalyteGroup {
analyte: string;
panel: string | null;
unit: string;
/** All readings for this analyte, oldest → newest. */
readings: LabResultDto[];
latest: LabResultDto;
}
/** Reduce the flat result list into per-analyte groups (case-insensitive). */
function groupByAnalyte(results: LabResultDto[]): AnalyteGroup[] {
const byKey = new Map<string, LabResultDto[]>();
for (const r of results) {
const key = r.analyte.toLowerCase();
const existing = byKey.get(key);
if (existing) existing.push(r);
else byKey.set(key, [r]);
}
const groups: AnalyteGroup[] = [];
for (const readings of byKey.values()) {
// Ascending by takenAt so the sparkline reads left→right in time and
// the last element is the latest.
const ordered = [...readings].sort(
(a, b) =>
new Date(a.takenAt).getTime() - new Date(b.takenAt).getTime(),
);
const latest = ordered[ordered.length - 1];
groups.push({
analyte: latest.analyte,
panel: latest.panel,
unit: latest.unit,
readings: ordered,
latest,
});
}
// Group order: most-recently-updated analyte first.
return groups.sort(
(a, b) =>
new Date(b.latest.takenAt).getTime() -
new Date(a.latest.takenAt).getTime(),
);
}
function formatValue(value: number): string {
// Trim trailing zeros for whole numbers; keep up to 2 decimals otherwise.
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(2)));
}
export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const listKey = queryKeys.labResultsList({
analyte: undefined,
panel: undefined,
from: undefined,
to: undefined,
page: 0,
sortDir: "desc",
});
const { data, isLoading, isError } = useQuery({
queryKey: listKey,
queryFn: async () => {
const res = await apiGet<LabResultListResponse>(
"/api/labs?limit=500&sortDir=desc",
);
return res;
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => apiDelete(`/api/labs/${id}`),
onSuccess: () => {
toast.success(t("labs.deletedToast"));
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
},
onError: () => toast.error(t("labs.deleteError")),
});
const groups = useMemo(
() => groupByAnalyte(data?.results ?? []),
[data?.results],
);
if (isLoading) {
return (
<div className="space-y-3" data-slot="lab-list-loading">
{Array.from({ length: 3 }, (_, i) => (
<Card key={i} aria-hidden="true">
<CardContent className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 space-y-2">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-56" />
</div>
<Skeleton className="h-8 w-24 self-end sm:self-center" />
</CardContent>
</Card>
))}
</div>
);
}
if (isError) {
return (
<p className="text-destructive py-8 text-center text-sm">
{t("labs.loadError")}
</p>
);
}
if (groups.length === 0) {
return (
<EmptyState
icon={<FlaskConical className="size-6" />}
title={t("labs.emptyTitle")}
description={t("labs.emptyDescription")}
action={
onAddFirst ? (
<Button onClick={onAddFirst}>{t("labs.addFirst")}</Button>
) : undefined
}
/>
);
}
return (
<div className="space-y-3">
{groups.map((group) => (
<Card key={group.analyte.toLowerCase()}>
<CardHeader>
<CardTitle className="flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm">
<span className="font-medium">{group.analyte}</span>
{group.panel ? (
<span className="text-muted-foreground text-xs font-normal">
{group.panel}
</span>
) : null}
<ReferenceRangeBadge status={group.latest.rangeStatus} />
</CardTitle>
<CardAction className="flex items-center gap-3">
<LabTrendSparkline values={group.readings.map((r) => r.value)} />
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive"
aria-label={t("labs.deleteLatest")}
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(group.latest.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</CardAction>
</CardHeader>
<CardContent>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-sm">
<span className="text-foreground font-semibold tabular-nums">
{formatValue(group.latest.value)} {group.latest.unit}
</span>
{group.latest.referenceLow !== null ||
group.latest.referenceHigh !== null ? (
<span className="text-xs">
{t("labs.referenceLabel")}{" "}
{group.latest.referenceLow !== null &&
group.latest.referenceHigh !== null
? `${formatValue(group.latest.referenceLow)}–${formatValue(group.latest.referenceHigh)}`
: group.latest.referenceHigh !== null
? `≤ ${formatValue(group.latest.referenceHigh)}`
: `≥ ${formatValue(group.latest.referenceLow as number)}`}
</span>
) : null}
<span className="text-xs">
{formatDate(group.latest.takenAt)}
</span>
{group.readings.length > 1 ? (
<span className="text-xs">
{t("labs.readingsCount", {
count: group.readings.length,
})}
</span>
) : null}
</div>
</CardContent>
</Card>
))}
</div>
);
}
@@ -0,0 +1,61 @@
"use client";
/**
* v1.17.1 — minimal inline trend sparkline for an analyte's readings.
*
* A dependency-free SVG polyline (the project defers Recharts for tiny
* inline trends; this matches the doctor-report PDF's vector-polyline
* approach). Rendered only when an analyte has ≥ 2 readings. Stroke uses
* the neutral `currentColor` so it inherits the calm muted-foreground tone
* of its row — no status colour, in keeping with the no-alarming-colour
* ethos.
*
* `values` are passed oldest → newest.
*/
export function LabTrendSparkline({
values,
width = 72,
height = 20,
}: {
values: number[];
width?: number;
height?: number;
}) {
if (values.length < 2) return null;
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
const stepX = width / (values.length - 1);
const pad = 2;
const usableH = height - pad * 2;
const points = values
.map((v, i) => {
const x = i * stepX;
// Invert Y so a higher value sits higher on screen.
const y = pad + usableH - ((v - min) / span) * usableH;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className="text-muted-foreground/70 overflow-visible"
aria-hidden
role="presentation"
>
<polyline
points={points}
fill="none"
stroke="currentColor"
strokeWidth={1.25}
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
);
}
@@ -0,0 +1,52 @@
"use client";
import { ArrowDown, ArrowUp, Minus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useTranslations } from "@/lib/i18n/context";
import type { ReferenceRangeStatus } from "@/lib/validations/labs";
/**
* v1.17.1 — reference-range badge.
*
* Renders the server-computed `rangeStatus` as a calm, informative marker.
* The project's no-alarming-colour ethos is a hard rule here: an
* out-of-range value is NOT painted red. It reads as a neutral `secondary`
* badge with a quiet direction arrow (↓ below / ↑ above), exactly the same
* weight as the in-range and unknown states. The number leaving the
* reference window is information a clinician scans — not an alarm to the
* user, who may be perfectly aware of (and managing) the value.
*
* `unknown` (the lab reported no usable bounds) renders nothing — there is
* no verdict to show, and an empty badge would be noise.
*/
export function ReferenceRangeBadge({
status,
}: {
status: ReferenceRangeStatus;
}) {
const { t } = useTranslations();
if (status === "unknown") return null;
if (status === "in-range") {
return (
<Badge variant="outline" className="text-muted-foreground">
<Minus aria-hidden />
{t("labs.range.inRange")}
</Badge>
);
}
// Below / above — both neutral `secondary`, distinguished only by the
// arrow direction and label. No red, no amber.
const Icon = status === "below" ? ArrowDown : ArrowUp;
const label =
status === "below" ? t("labs.range.below") : t("labs.range.above");
return (
<Badge variant="secondary">
<Icon aria-hidden />
{label}
</Badge>
);
}
Loaded 100 of 249 files, more files were not shown because too many files have changed in this diff. Show more