diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 000000000..d0310907a --- /dev/null +++ b/.trivyignore @@ -0,0 +1,14 @@ +# Trivy vulnerability ignore list. +# One CVE id per line. Only suppress a finding that has NO upstream fix we +# can apply; never suppress one with a fixed version available — bump instead. +# Each entry names what it covers and the condition to re-check / remove it. + +# CVE-2026-33671 (picomatch ReDoS, HIGH) — flagged only in the npm CLI's +# OWN bundled copy that ships inside the Node base image +# (usr/local/lib/node_modules/npm/node_modules/picomatch, v4.0.3), used at +# image-build time by npm, not by our runtime. Our application dependency +# tree pins picomatch >= 4.0.4 via a pnpm override, so the running app is +# not affected and the path is not reachable at runtime. Re-check when the +# Node base image ships an npm build whose bundled picomatch is >= 4.0.4, +# then drop this entry. +CVE-2026-33671 diff --git a/CHANGELOG.md b/CHANGELOG.md index c54228ffd..d73bcbf86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,49 @@ ## [Unreleased] +## [1.18.6] — 2026-06-18 — offline reads, grounded guidance, and one consistent app + +A large release that makes the app genuinely useful offline, gives every metric cited reference context, grounds the AI Coach and Daily Briefing in that same context, adds responsible safety alerts for clinically urgent readings, and brings the newer modules (preventive care, illness, labs) and the whole settings area in line with the established patterns. A guided tour now explains what each module does and how they connect. No breaking changes; all schema changes are additive. + +### Added + +- **A module-by-module guided tour.** After onboarding, a short tour walks each module — what it does, one capability you might miss, and how it feeds the others — so the whole app makes sense on day one. Replayable any time from Settings, or per module from its page. +- **Cited reference ranges everywhere.** Metrics now carry plain-language explainers and the general reference range they sit in (blood pressure, resting heart rate, glucose, blood oxygen, temperature, and more), framed as general guidance and sourced from recognised guidelines — never a diagnosis. Blood pressure follows the ESH range. The blood-pressure detail now also shows pulse pressure and mean arterial pressure, derived from your readings. +- **A Coach that knows the ranges.** The AI Coach and Daily Briefing are now grounded in those cited ranges, so a comment about a value reflects where it sits in the general range — still general guidance, never a diagnosis, with no third-party attribution. +- **Proactive Coach check-ins you can actually see.** A proactive nudge now appears as a message in your Coach conversation (not only as a push), with a discreet unread dot on the Coach button that clears when you open it — so it works even without notifications set up. +- **Responsible safety alerts.** A confirmed, re-measured reading in a clinically urgent range (very high or low blood pressure, low or high glucose) now raises an alert that says to re-check or contact your doctor — never a diagnosis, and only for the modules you use. +- **A diabetes setting.** An explicit opt-in switches glucose targets to the tighter clinical goal range; it is never inferred from your readings. +- **Per-module settings for preventive care, illness and labs.** Each gets its own settings page to reorder items and switch between card and list view; labs additionally lets you manage biomarkers and choose the sort order. +- **Mark a reminder done.** A measurement reminder can be completed server-side (for the apps), instead of only being dismissed locally. +- **Real offline reads.** Opened without a connection, the installed app shows your last synced dashboard and recent data with a "showing your last synced data" note, rather than blank placeholders. Changes you make offline still save when you reconnect. + +### Changed + +- **Preventive-care cards match the medication cards.** A clear "measure now" action, the interval as a chip, and progress toward the next due date — with measurement-appropriate wording (no more "intake" for a blood-pressure check) and the same calm, neutral card. +- **One consistent "add" and a settings wrench** across preventive care, illness and labs; the illness journal's resolved entries are collapsed by default. +- **Every Settings and Admin page now has a clear heading and description**, in one shared frame, with the side menu lined up to the content — so the whole area reads as one piece. The AI section is now labelled "AI provider", and module availability has its own admin page. +- **No more AI dead-end.** When no AI provider is connected, onboarding and the Coach/Insights/Briefing screens explain what the feature does and how to set it up, instead of a bare error. +- **Clearer sleep, recovery and insights.** The estimated sleep-stage timeline is shown only when it reflects measured stages (with an honest note otherwise); the chronotype reads as labelled text; the sleep-quality assessment is more useful; the recovery page is de-cluttered; and insight cards link through to their detail pages, including steps. +- **A faster dashboard.** All charts load in one batched request instead of one per chart, with target ranges computed on the server. +- **Fewer disclaimers, one clear acknowledgment.** The repeated "not medical advice" banners are gone in favour of a single acknowledgment at onboarding (reachable any time in Settings). +- **Mobile polish.** Small tap targets were brought up to a comfortable size, and a global reduced-motion safeguard quiets animation when your device asks for it. + +### Fixed + +- **Reduced motion no longer hides content.** Charts and list rows that animate in now stay visible when the device requests reduced motion, instead of disappearing. +- **Read-after-write.** A reading or medication you just added now reliably appears in its list. +- **A timezone bug** that anchored the local day at UTC midnight for one intake path. +- **Contrast.** The preventive-care action button now meets AA contrast in dark mode. + +### Security + +- **The offline cache is hardened.** It stores only a safe, read-only allowlist (never anything tied to sign-in or sensitive AI conversations), is bound to the signed-in account, and is wiped when the session ends — not only on explicit sign-out. +- The container scan ignores a known `picomatch` advisory (CVE-2026-33671) that exists only in the npm CLI bundled inside the base image, not in the app's own dependencies (pinned to the fixed version). + +### Operator note + +- Additive migrations only (`0173`–`0177`: tour progress, diabetes opt-in, disclaimer acknowledgment, Coach last-seen, a Coach-message index). The optional `APNS_CRITICAL_ENTITLEMENT` env var (introduced in 1.18.4) gates native critical alerts; urgent alerts ship as time-sensitive without it. The service-worker cache version bumps with the release, so clients refresh their offline cache automatically on update. + ## [1.18.5] — 2026-06-18 — filter by value, a tidier advanced panel, and a titration timeline for injectables A small polish release. You can now filter your readings by value, the advanced medication settings collapse into sections instead of one long page, and injectable medications show their dose-escalation plan as a timeline. No breaking changes; no migration. diff --git a/CLAUDE.md b/CLAUDE.md index b50def4aa..eeb174169 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Working notes for anyone (human or otherwise) editing this repository. Skim befo ## What HealthLog is -Self-hosted personal-health-tracking PWA: weight, blood pressure, pulse, body composition, glucose, sleep, mood, medication compliance. Withings + Apple Health sync, multi-provider AI Insights (BYOK or local), doctor-report PDF, native iOS client in public beta. Single `docker compose up`, Postgres-backed, AES-256-GCM at rest. PolyForm Noncommercial 1.0.0 (AGPL-3.0 through v1.15.18). Current line: v1.12.x. +Self-hosted personal-health-tracking PWA: weight, blood pressure, pulse, body composition, glucose, sleep, mood, medication compliance. Withings + Apple Health sync, multi-provider AI Insights (BYOK or local), doctor-report PDF, native iOS client in public beta. Single `docker compose up`, Postgres-backed, AES-256-GCM at rest. PolyForm Noncommercial 1.0.0 (AGPL-3.0 through v1.15.18). Current line: v1.18.x. ## Voice and privacy @@ -37,8 +37,8 @@ Prefer patch bumps. A release dominated by bug fixes plus additive features with | Runtime | Node 22 (Alpine) + pnpm 10.31 | Node version fixed only by the Dockerfile base image; no `.nvmrc` or `engines`. | | Framework | Next.js 16.2.6 — App Router, `output: "standalone"` | SWC strips `console.*` in prod except `error/warn`. | | Language | TypeScript 6, `strict: true`, `moduleResolution: "bundler"` | Path alias `@/*` → `./src/*`. `scripts/` excluded from typecheck. | -| UI | React 19.2.5 (exact), Tailwind 4, shadcn/ui (new-york style, zinc base), Radix, Lucide | Recharts 3 for charts — stays; replacement requires explicit approval. | -| Persistence | PostgreSQL 16 + Prisma 7.8 — 61 models | Generated client at `src/generated/prisma`. Production image ships a separate `/opt/prisma-cli` install + `/opt/pg-boss` runtime install (see `Dockerfile:74-83`). | +| UI | React 19 (exact pin), Tailwind 4, shadcn/ui (new-york style, zinc base), Radix, Lucide | Recharts 3 for charts — stays; replacement requires explicit approval. | +| Persistence | PostgreSQL 16 + Prisma 7.8 — 80 models | Generated client at `src/generated/prisma`. Production image ships a separate `/opt/prisma-cli` install + `/opt/pg-boss` runtime install (see `Dockerfile:93-100`). | | Queue | pg-boss 12.18 | Cron + retry semantics live in Postgres tables; workers under `src/lib/jobs/`. | | Auth | `@simplewebauthn/server` 13, `@node-rs/argon2` 2 | Passkey + Argon2id password, server-side sessions in Postgres. | | Notifications | `@parse/node-apn` 8 (APNs), `web-push` 3 (VAPID), raw fetch for Telegram + ntfy | Per-channel `recordPushAttempt` row, hard-reject classification. | @@ -71,8 +71,9 @@ handler body Zod parse (`safeParse` + `returnAllZodIssue │ business logic, `annotate({ action, meta })`, │ `auditLog()` for sensitive ops. ▼ -src/lib/api-response.ts `apiSuccess(data, meta?)` / `apiError(msg, status, - │ meta?)` — every response is `{ data, error, meta? }`. +src/lib/api-response.ts `apiSuccess(data, status?)` → `{ data, error: null }`; + │ `apiError(msg, status, meta?)` threads `meta` on the + │ error path only — `{ data: null, error, meta? }`. ▼ src/proxy.ts security headers attach, request-id echoes. ▼ @@ -87,9 +88,9 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file: - **Every API route wraps in `apiHandler`.** Verified — zero exceptions in the current tree. Every body-accepting route runs Zod `safeParse` and returns 422 via `returnAllZodIssues` (multi-issue envelope sanitised against echoed input through `sanitiseZodIssues`). - **`userId` is always narrowed from session or Bearer.** No route accepts `userId` as a body field — it comes from `requireAuth()` and feeds the Prisma `where`. The batch endpoints (`/api/measurements/batch`, `/api/mood-entries/bulk`, `/api/medications/intake/bulk`, `/api/workouts/batch`) don't even declare a `userId` field in their Zod schemas. - **No mass assignment.** Every `prisma.X.{create,update}({ data: ... })` builds its `data` object field-by-field from `parsed.data`, never by spreading the parsed object whole. `tokens.create` hardcodes the narrow scope so the user-facing endpoint can never mint a wildcard token. -- **`requireAdmin()` is cookie-only.** Bearer tokens — even with `["*"]` scope — cannot reach admin endpoints. This is a structural boundary at `src/lib/api-handler.ts:414-431`, not a runtime check that future code can soften. -- **Raw SQL is parameter-bound or whitelist-spliced.** Every `$queryRaw` uses tagged-template parameters. Every `$queryRawUnsafe` / `$executeRawUnsafe` either passes positional `$N` placeholders with an argument array OR splices a value already asserted against a closed enum + regex (see `src/lib/rollups/measurement-rollups.ts:330-343`). Inline comments document the whitelist at the splice point. -- **TanStack Query keys live in the centralised factory.** `src/lib/query-keys.ts` is the only legal source of `queryKey` / `mutationKey` arrays. The in-repo ESLint rule `healthlog/queryKey-factory` (set to `error`) flags any bare array. Same-key + different `queryFn` shape silently poisons the cache; the factory prevents it. Every read unwraps `(await res.json()).data` from the envelope. +- **`requireAdmin()` is cookie-only.** Bearer tokens — even with `["*"]` scope — cannot reach admin endpoints. This is a structural boundary in `requireAdmin()` (`src/lib/api-handler.ts`), not a runtime check that future code can soften. +- **Raw SQL is parameter-bound or whitelist-spliced.** Every `$queryRaw` uses tagged-template parameters. Every `$queryRawUnsafe` / `$executeRawUnsafe` either passes positional `$N` placeholders with an argument array OR splices a value already asserted against a closed enum + regex (see the whitelist-splice block in `src/lib/rollups/measurement-rollups.ts`, around the `$queryRawUnsafe` calls). Inline comments document the whitelist at the splice point. +- **TanStack Query keys live in the centralised factory.** `src/lib/query-keys/` (16 modules, `index.ts` barrel) is the only legal source of `queryKey` / `mutationKey` arrays. The in-repo ESLint rule `healthlog/queryKey-factory` (set to `error`) flags any bare array. Same-key + different `queryFn` shape silently poisons the cache; the factory prevents it. Every read unwraps `(await res.json()).data` from the envelope. - **`annotate()` on every interesting code path.** Wide-event observability. Action names follow `..` (`coach.budget.exceeded`, `measurement.batch.ingest`). Don't free-text in `meta` — pin the shape so dashboards stay stable. - **i18n keys exist for every `t()` call.** `i18n-call-site-coverage.test.ts` walks every `.ts(x)` under `src/`, extracts every `t("ns.key")` literal, and asserts each key resolves in `messages/en.json`. `i18n-locale-integrity.test.ts` propagates the EN guarantee across `de / en / es / fr / it / pl`. If a guard fails, fix the bundle, don't suppress the test. - **OpenAPI registry stays in sync.** Zod schemas carry `.meta()` annotations under `src/lib/openapi/`; `pnpm openapi:generate` emits `docs/api/openapi.yaml`. CI fails on drift. Re-run the generator after touching a request / response schema and commit the YAML alongside the Zod change. @@ -112,7 +113,7 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file: | Concern | File | What it owns | |---|---|---| | HTTP edge | `src/proxy.ts` | Public-path allowlist, demo-mode block, onboarding redirect, request-id + CSP nonce, every security header, worker-only refusal. | -| API kit | `src/lib/api-handler.ts` (490 LOC) | `apiHandler` wrapper, `requireAuth` / `requireAdmin`, idempotency plumbing, GlitchTip forwarder. | +| API kit | `src/lib/api-handler.ts` | `apiHandler` wrapper, `requireAuth` / `requireAdmin`, idempotency plumbing, GlitchTip forwarder. | | Response envelope | `src/lib/api-response.ts` | `apiSuccess` / `apiError`, `safeJson` (now with opt-in `maxBytes`), trusted-proxy IP resolver, `returnAllZodIssues` + `sanitiseZodIssues`. | | Crypto at rest | `src/lib/crypto.ts` | AES-256-GCM with versioned key ids, `extractKeyId`, fail-closed loader, rotation primitives. Rotation CLI: `scripts/rotate-encryption-key.ts`. | | Session | `src/lib/auth/session.ts` + `src/lib/auth/secure-cookie.ts` | Postgres-backed sessions, sliding 30-day expiry, `shouldEmitSecureCookie()` the one source of truth for the `Secure` flag (every cookie setter routes through it). | @@ -120,11 +121,11 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file: | Bearer tokens | `src/lib/auth/hmac.ts` + `src/lib/auth/issue-token.ts` | HMAC-SHA256 hashing under `API_TOKEN_HMAC_KEY`; no plaintext path; `lastUsedAt` updated fire-and-forget. | | Rollup tier | `src/lib/rollups/` | DAY / WEEK / MONTH / YEAR pre-aggregations. Read-swap pattern: try the rollup, fall back to live SQL only on coverage miss. Boot-time `rollup-full-backfill` queue handles new accounts. | | Compliance | `src/lib/analytics/compliance.ts` | Cadence-aware medication compliance — daysOfWeek + intervalWeeks honoured across the eight call sites that surface a rate (Coach prompt, BP-status gate, dashboard pillar, …). | -| AI providers | `src/lib/ai/provider-chain.ts` + `src/lib/ai/{openai,anthropic,local,codex,mock}-client.ts` | Five providers, hand-rolled fetch over the documented wire (no vendor SDKs). Mock is not registered in `resolveProvider()` — production cannot reach it. | +| AI providers | `src/lib/ai/provider.ts` (`resolveProvider`) + `src/lib/ai/provider-chain.ts` (`PROVIDER_CHAIN_TYPES`) + `src/lib/ai/{openai,anthropic,local,codex,mock}-client.ts` | Five providers, hand-rolled fetch over the documented wire (no vendor SDKs). Mock is excluded structurally by the `PROVIDER_CHAIN_TYPES` allowlist — production cannot reach it. | | Notification dispatcher | `src/lib/notifications/dispatcher.ts` + `src/lib/notifications/senders/` | APNs → Telegram → ntfy → Web Push cascade, hard-reject classification, `push_attempts` ledger with 90-day retention. | | Coach | `src/app/api/insights/chat/route.ts` + `src/lib/ai/coach/` | SSE stream, budget gate + per-user rate gate, refusal detector, snapshot builder, message persistence with `encryptedContent` Bytes column. | -| OpenAPI | `src/lib/openapi/registry.ts` + `src/lib/openapi/routes.ts` | Source of truth for `docs/api/openapi.yaml`. | -| Schema | `prisma/schema.prisma` (61 models) | `cuid()` PKs, `snake_case` columns via `@map`, encrypted columns mostly under `*Encrypted` (search the file). | +| OpenAPI | `src/lib/openapi/registry.ts` + `src/lib/openapi/routes/` (23 modules, `index.ts` barrel) | Source of truth for `docs/api/openapi.yaml`. | +| Schema | `prisma/schema.prisma` (80 models) | `cuid()` PKs, `snake_case` columns via `@map`, encrypted columns mostly under `*Encrypted` (search the file). | | Compose | `docker-compose.yml` | `app` + `db`. Env-var whitelist under `environment:` — vars not listed never reach the container. `pull_policy: always` is load-bearing. | `.planning/codebase/arch.md` carries the full annotated walk for every section. @@ -134,7 +135,7 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file: The v1.5.2 audit produced `.planning/security-audit-v1.5.2.md` and a stack of per-domain findings under `.planning/security-audit-findings/`. The patterns worth preserving on future churn: - **Encryption: fail closed everywhere.** Missing keys, malformed JSON, unknown key ids — every path throws rather than silently writing plaintext. Operators rotating keys must keep the legacy entry in `ENCRYPTION_KEYS` until the rotation script reports zero remaining legacy rows. The script covers every `*Encrypted` column + `CoachMessage.encryptedContent` (Bytes) + `IntegrationStatus.lastError`. -- **SSRF: input-time `isPublicUrl` is the floor.** The helper in `src/lib/validations/notifications.ts:64` catches every IPv4 / IPv6 alt-notation class (octal / hex / decimal IPv4, IPv4-mapped IPv6, IPv6 ULA, CGNAT, link-local, loopback, metadata). Use it for every outbound URL that comes from a user / admin input. DNS rebinding and the redirect-follow gap remain as architectural follow-ups (issues #217 + #218). Until those land, every NEW outbound `fetch()` that touches a user-controlled host must pin `redirect: "manual"` and an `AbortSignal.timeout`. +- **SSRF: input-time `isPublicUrl` is the floor.** The helper `isPublicUrl()` in `src/lib/validations/notifications.ts` catches every IPv4 / IPv6 alt-notation class (octal / hex / decimal IPv4, IPv4-mapped IPv6, IPv6 ULA, CGNAT, link-local, loopback, metadata). Use it for every outbound URL that comes from a user / admin input. DNS rebinding and the redirect-follow gap remain as architectural follow-ups (issues #217 + #218). Until those land, every NEW outbound `fetch()` that touches a user-controlled host must pin `redirect: "manual"` and an `AbortSignal.timeout`. - **Outbound egress goes through `safeFetch`.** `src/lib/safe-fetch.ts` is the one documented egress entry (`redirect: "manual"` + `AbortSignal.timeout(15_000)` defaults; opt into the connect-time DNS-rebinding pin with `requirePublicHost: true` for any operator- or user-supplied host). The in-repo ESLint rule `healthlog/safe-fetch-required` (set to `error`) bans raw `fetch(` under `src/lib/` + `src/app/` outside the wrapper internals and test files; same-origin relative-path (`/api/…`) client fetches are exempt by construction. - **Rate limits live in Postgres.** `src/lib/rate-limit.ts` uses a single atomic SQL upsert returning `{count, reset_at}`. Multi-instance correctness is structural. Bucket-key convention: `:${identifier}` where identifier is `userId` for authenticated routes and the trusted-proxy IP for anonymous. Anonymous auth surfaces use `checkAuthSurfaceRateLimit(...)` so a trust-violation collapses every anonymous caller into a single tight bucket rather than a free-for-all under the unknown-IP bucket. - **Webhooks: shared-secret + `timingSafeEqual` + per-source rate limit BEFORE secret verification.** Withings, Telegram, moodLog, the Coolify deploy webhook, and the CSP report endpoint all follow this exact pattern. The Withings path-segment secret is scrubbed from `http.path` / `http.route` by `redactSecrets()` via the `PATH_SECRET_PATHS` registry in `src/lib/logging/redact.ts:40`. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2490f1275..166748f5d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.18.5 + version: 1.18.6 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -1443,6 +1443,55 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/measurements/series-batch: + get: + tags: + - Measurements + summary: Batched daily series (dashboard fetch coalescing) + description: Returns the rollup-backed daily series for every requested MeasurementType in ONE response, so the + dashboard chart row does a single round-trip instead of one self-fetch per chart. Each per-type series is + byte-identical with `GET /api/measurements?aggregate=daily&source=rollup` for the same window. SLEEP_DURATION is + not served here (it rides the per-night reconstruction at `/api/measurements/series?kind=sleep`). Auth via + cookie or Bearer. + parameters: + - in: query + name: types + schema: + description: Comma-separated list of MeasurementType values (1..16). Each is read as a daily series through the rollup + tier. + type: string + minLength: 1 + required: true + description: Comma-separated list of MeasurementType values (1..16). Each is read as a daily series through the rollup + tier. + - in: query + name: from + schema: + description: Window start (ISO-8601 instant). + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: true + description: Window start (ISO-8601 instant). + - in: query + name: to + schema: + description: Window end (ISO-8601 instant). + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + required: true + description: Window end (ISO-8601 instant). + responses: + "200": + description: Per-type daily series. + content: + application/json: + schema: + $ref: "#/components/schemas/GetMeasurementsSeriesBatchResponse" + "401": *a1 + "422": *a3 + "429": *a4 /api/measurements/series: get: tags: @@ -1788,6 +1837,8 @@ paths: minLength: 1 maxLength: 32 - type: "null" + symptomsPresent: + type: boolean required: - type - value @@ -3121,6 +3172,48 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/auth/me/diabetes: + get: + tags: + - Auth + summary: Read per-user diabetes opt-in flag + description: Returns the user's per-account diabetes opt-in flag. Default `false`. When `true`, the glucose target + resolver applies the tighter ADA glycemic GOAL bands (fasting 80–130, postprandial < 180) instead of the general + non-diabetic bands. Powers the Settings "I have diabetes / clinician glucose targets" toggle. Never inferred + from a reading; asserts no diagnosis. + responses: + "200": + description: Resolved flag. + content: + application/json: + schema: + $ref: "#/components/schemas/GetDiabetesResponse" + "401": *a1 + "422": *a3 + "429": *a4 + patch: + tags: + - Auth + summary: Toggle per-user diabetes opt-in flag + description: Sets the per-account diabetes opt-in flag. Idempotent — the DB write fires even when the value matches so + the audit-log row mirrors the API call. Always returns the resolved next-state. `userId` is never accepted from + the body. Rate-limit 60/min per user. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DiabetesFlag" + responses: + "200": + description: Resolved next-state echoed back for optimistic-update consumers. + content: + application/json: + schema: + $ref: "#/components/schemas/PatchDiabetesResponse" + "401": *a1 + "422": *a3 + "429": *a4 /api/auth/me/modules: get: tags: @@ -4292,6 +4385,47 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/insights/coach/nudge-status: + get: + tags: + - Insights + summary: Whether an unopened Coach message is waiting + description: v1.18.6 (CCH-03) — server-authoritative unread signal for the Coach FAB. `unread` is true when the caller's + newest Coach ASSISTANT message (a proactive nudge or any reply) is newer than `User.coachLastSeenAt`; a user who + has never opened the Coach reads an existing nudge as unread exactly once. `nudgedAt` carries that newest + assistant-message timestamp (null when none exists) so the client can key a local seen-mirror on a stable value. + Coach-gated (`requireAssistantSurface("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner + is narrowed from the session. + responses: + "200": + description: The current unread state. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachNudgeStatus" + "401": *a1 + "422": *a3 + "429": *a4 + /api/insights/coach/seen: + post: + tags: + - Insights + summary: Mark the Coach as opened (clear the unread dot) + description: v1.18.6 (CCH-03) — opening the Coach (drawer or full page) stamps `User.coachLastSeenAt = now()`, so `GET + /api/insights/coach/nudge-status` then reports no assistant message newer than the stamp and the FAB drops the + unread dot. Server-authoritative, so the cleared state follows the caller across web + iOS rather than just the + opening device. No request body — the timestamp is server-minted, so a client can never backdate the stamp to + suppress a future nudge. Coach-gated; a disabled surface 403s. Auth via cookie or Bearer. + responses: + "200": + description: The Coach was marked opened; the stamp is echoed back. + content: + application/json: + schema: + $ref: "#/components/schemas/CoachSeenResponse" + "401": *a1 + "422": *a3 + "429": *a4 /api/coach/about-me: get: tags: @@ -5773,6 +5907,33 @@ paths: "404": *a7 "422": *a3 "429": *a4 + /api/measurement-reminders/{id}/complete: + post: + tags: + - MeasurementReminders + summary: Complete a Vorsorge reminder (v1.18.6) + description: "Explicit user-action completion (iOS #23): the app marks a reminder done server-side instead of only + dismissing it locally. Routes through the same satisfaction primitive as the cron auto-resolve — stamps + lastSatisfiedAt = now, re-anchors nextDueAt, fires no notification. Idempotent: completing an already-completed + / auto-satisfied reminder returns 200 with completed=false. Owner-scoped." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Completion applied (completed=true) or already-satisfied no-op (completed=false); reminder carries the + canonical post-completion DTO. + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" + "401": *a1 + "404": *a7 + "422": *a3 + "429": *a4 /api/labs: get: tags: @@ -6700,6 +6861,54 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/onboarding/disclaimer: + post: + tags: + - Onboarding + summary: Acknowledge the one-time medical disclaimer + description: "Stamps the user's medical-disclaimer acknowledgment. Idempotent: a repeat acknowledgment of the same + version refreshes the timestamp. The body version is a freshness signal so a stale shell cannot record copy it + never rendered; the server persists its own canonical version." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DisclaimerAckRequest" + responses: + "200": + description: Disclaimer acknowledged. + content: + application/json: + schema: + $ref: "#/components/schemas/DisclaimerAckEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/onboarding/tour: + post: + tags: + - Onboarding + summary: Update module-tour completion + resume point + description: Persists the module-tour state. The client posts a fire-and-forget `progress` checkpoint on each step so a + reload resumes at the right module, and a terminal `completed:true` with `outcome` when the tour ends. + `completed:false` is a replay reset that also clears the resume point. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/TourUpdateRequest" + responses: + "200": + description: Tour state updated. + content: + application/json: + schema: + $ref: "#/components/schemas/TourUpdateEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 components: schemas: PasskeyLoginVerifyRequest: @@ -7595,6 +7804,16 @@ components: required: - disableCoach description: Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates. + DiabetesFlag: + type: object + properties: + hasDiabetes: + type: boolean + required: + - hasDiabetes + description: Per-account diabetes opt-in (v1.18.6). `true` selects the ADA glycemic GOAL bands (fasting 80–130, + postprandial < 180) for glucose targets instead of the general non-diabetic bands. A user-declared preference + only — never inferred from a value, never a diagnosis. ModulePrefsPatchRequest: type: object properties: @@ -8030,6 +8249,67 @@ components: type: string required: - ids + DisclaimerAckRequest: + type: object + properties: + version: + type: string + minLength: 1 + maxLength: 64 + description: The disclaimer copy version the client rendered. A freshness signal only — the server pins and persists its + own canonical version. + required: + - version + description: Acknowledge the one-time medical disclaimer shown at onboarding. + TourUpdateRequest: + type: object + properties: + completed: + description: Flip the coarse completion flag. `false` is a replay reset and clears the stored progress point. + type: boolean + outcome: + description: Informational — distinguishes reaching the end from a skip. + type: string + enum: + - completed + - skipped + progress: + description: Mid-tour resume checkpoint. May arrive alone or with `completed`. + $ref: "#/components/schemas/TourProgress" + description: Update the module-tour state. Provide `completed` and/or `progress`. + TourProgress: + type: object + properties: + lastStopId: + anyOf: + - type: string + minLength: 1 + maxLength: 64 + - type: "null" + completedStopIds: + default: [] + maxItems: 64 + type: array + items: + type: string + minLength: 1 + maxLength: 64 + status: + type: string + enum: + - in_progress + - completed + - skipped + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + required: + - lastStopId + - status + - updatedAt + description: Resumable module-tour progress point. `lastStopId` seeds the resume index; `status` is the running/terminal + state. MedicationTreatmentClass: type: string enum: @@ -10175,6 +10455,277 @@ components: - updatedAt additionalProperties: false description: A soft-deleted medication intake, keyed on server `id`. Apply before upserts within the domain page. + GetMeasurementsSeriesBatchResponse: + type: object + properties: + data: + $ref: "#/components/schemas/MeasurementsSeriesBatchResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MeasurementsSeriesBatchResponse: + type: object + properties: + series: + type: object + propertyNames: + type: string + enum: + - WEIGHT + - BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA + - PULSE + - BODY_FAT + - SLEEP_DURATION + - ACTIVITY_STEPS + - BLOOD_GLUCOSE + - TOTAL_BODY_WATER + - BONE_MASS + - OXYGEN_SATURATION + - HEART_RATE_VARIABILITY + - RESTING_HEART_RATE + - ACTIVE_ENERGY_BURNED + - FLIGHTS_CLIMBED + - WALKING_RUNNING_DISTANCE + - VO2_MAX + - BODY_TEMPERATURE + - FAT_FREE_MASS + - FAT_MASS + - MUSCLE_MASS + - SKIN_TEMPERATURE + - PULSE_WAVE_VELOCITY + - VASCULAR_AGE + - VISCERAL_FAT + - AUDIO_EXPOSURE_ENV + - AUDIO_EXPOSURE_HEADPHONE + - TIME_IN_DAYLIGHT + - WALKING_STEADINESS + - AUDIO_EXPOSURE_EVENT + - RESPIRATORY_RATE + - BODY_MASS_INDEX + - LEAN_BODY_MASS + - WALKING_HEART_RATE_AVERAGE + - WALKING_ASYMMETRY + - WALKING_DOUBLE_SUPPORT + - WALKING_STEP_LENGTH + - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT + - RECOVERY_SCORE + - STRESS_SCORE + - STRAIN_SCORE + - HRV_RMSSD + - DAY_STRAIN + - WORKOUT_STRAIN + - SLEEP_PERFORMANCE + - SLEEP_EFFICIENCY + - SLEEP_CONSISTENCY + - SLEEP_NEED + - ENERGY_EXPENDITURE_KJ + - AVERAGE_HEART_RATE + - MAX_HEART_RATE + - SLEEP_DISTURBANCE_COUNT + - ANS_CHARGE + - CARDIO_LOAD + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION + additionalProperties: + type: array + items: + type: object + properties: + type: + type: string + enum: + - WEIGHT + - BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA + - PULSE + - BODY_FAT + - SLEEP_DURATION + - ACTIVITY_STEPS + - BLOOD_GLUCOSE + - TOTAL_BODY_WATER + - BONE_MASS + - OXYGEN_SATURATION + - HEART_RATE_VARIABILITY + - RESTING_HEART_RATE + - ACTIVE_ENERGY_BURNED + - FLIGHTS_CLIMBED + - WALKING_RUNNING_DISTANCE + - VO2_MAX + - BODY_TEMPERATURE + - FAT_FREE_MASS + - FAT_MASS + - MUSCLE_MASS + - SKIN_TEMPERATURE + - PULSE_WAVE_VELOCITY + - VASCULAR_AGE + - VISCERAL_FAT + - AUDIO_EXPOSURE_ENV + - AUDIO_EXPOSURE_HEADPHONE + - TIME_IN_DAYLIGHT + - WALKING_STEADINESS + - AUDIO_EXPOSURE_EVENT + - RESPIRATORY_RATE + - BODY_MASS_INDEX + - LEAN_BODY_MASS + - WALKING_HEART_RATE_AVERAGE + - WALKING_ASYMMETRY + - WALKING_DOUBLE_SUPPORT + - WALKING_STEP_LENGTH + - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT + - RECOVERY_SCORE + - STRESS_SCORE + - STRAIN_SCORE + - HRV_RMSSD + - DAY_STRAIN + - WORKOUT_STRAIN + - SLEEP_PERFORMANCE + - SLEEP_EFFICIENCY + - SLEEP_CONSISTENCY + - SLEEP_NEED + - ENERGY_EXPENDITURE_KJ + - AVERAGE_HEART_RATE + - MAX_HEART_RATE + - SLEEP_DISTURBANCE_COUNT + - ANS_CHARGE + - CARDIO_LOAD + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION + value: + type: number + description: Daily mean (spot metrics) or SUM (cumulative). + measuredAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: Day-bucket start (ISO-8601). + count: + description: Underlying raw-row count for the bucket. + type: integer + minimum: 0 + maximum: 9007199254740991 + minValue: + description: Per-day minimum (spot metrics only; absent for cumulative). + anyOf: + - type: number + - type: "null" + maxValue: + description: Per-day maximum (spot metrics only; absent for cumulative). + anyOf: + - type: number + - type: "null" + required: + - type + - value + - measuredAt + additionalProperties: false + required: + - WEIGHT + - BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA + - PULSE + - BODY_FAT + - SLEEP_DURATION + - ACTIVITY_STEPS + - BLOOD_GLUCOSE + - TOTAL_BODY_WATER + - BONE_MASS + - OXYGEN_SATURATION + - HEART_RATE_VARIABILITY + - RESTING_HEART_RATE + - ACTIVE_ENERGY_BURNED + - FLIGHTS_CLIMBED + - WALKING_RUNNING_DISTANCE + - VO2_MAX + - BODY_TEMPERATURE + - FAT_FREE_MASS + - FAT_MASS + - MUSCLE_MASS + - SKIN_TEMPERATURE + - PULSE_WAVE_VELOCITY + - VASCULAR_AGE + - VISCERAL_FAT + - AUDIO_EXPOSURE_ENV + - AUDIO_EXPOSURE_HEADPHONE + - TIME_IN_DAYLIGHT + - WALKING_STEADINESS + - AUDIO_EXPOSURE_EVENT + - RESPIRATORY_RATE + - BODY_MASS_INDEX + - LEAN_BODY_MASS + - WALKING_HEART_RATE_AVERAGE + - WALKING_ASYMMETRY + - WALKING_DOUBLE_SUPPORT + - WALKING_STEP_LENGTH + - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT + - RECOVERY_SCORE + - STRESS_SCORE + - STRAIN_SCORE + - HRV_RMSSD + - DAY_STRAIN + - WORKOUT_STRAIN + - SLEEP_PERFORMANCE + - SLEEP_EFFICIENCY + - SLEEP_CONSISTENCY + - SLEEP_NEED + - ENERGY_EXPENDITURE_KJ + - AVERAGE_HEART_RATE + - MAX_HEART_RATE + - SLEEP_DISTURBANCE_COUNT + - ANS_CHARGE + - CARDIO_LOAD + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION + description: Per-type daily series, keyed by MeasurementType. + required: + - series + additionalProperties: false + description: Batched daily series — one rollup-backed series per requested type. GetMeasurementsSeriesResponse: type: object properties: @@ -13218,6 +13769,40 @@ components: - data - error additionalProperties: false + GetDiabetesResponse: + type: object + properties: + data: + $ref: "#/components/schemas/DiabetesFlagOutput" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + PatchDiabetesResponse: + type: object + properties: + data: + $ref: "#/components/schemas/DiabetesFlagOutput" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false GetModulesResponse: type: object properties: @@ -15269,6 +15854,64 @@ components: - data - error additionalProperties: false + CoachNudgeStatus: + type: object + properties: + data: + type: object + properties: + nudgedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: Timestamp of the newest Coach assistant message; null when none exists. + unread: + type: boolean + description: True when that message is newer than the last time the caller opened the Coach. + required: + - nudgedAt + - unread + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CoachSeenResponse: + type: object + properties: + data: + type: object + properties: + seenAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: The server-minted open timestamp. + required: + - seenAt + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false GetCoachAboutMeResponse: type: object properties: @@ -18017,6 +18660,37 @@ components: - data - error additionalProperties: false + MeasurementReminderCompleteEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MeasurementReminderCompletion" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MeasurementReminderCompletion: + type: object + properties: + completed: + type: boolean + reminder: + $ref: "#/components/schemas/MeasurementReminderDTO" + required: + - completed + - reminder + additionalProperties: false + description: Result of an explicit reminder completion. completed=true when this call advanced lastSatisfiedAt; + completed=false when an earlier satisfy or a matching reading had already fulfilled the current cycle + (idempotent no-op). reminder is the canonical post-completion DTO. ListLabResultsEnvelope: type: object properties: @@ -19043,6 +19717,64 @@ components: description: "Cross-episode retrospective summary over the trailing window: episode + resolved counts, the typical (median) recovery gap (null below the min-sample floor — withholds a thin claim), a recurrence-by-month tally, and a per-type breakdown. Retrospective only." + DisclaimerAckEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DisclaimerAckResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DisclaimerAckResponse: + type: object + properties: + acknowledgedVersion: + type: string + description: The canonical disclaimer version the server stamped. + required: + - acknowledgedVersion + additionalProperties: false + description: The persisted disclaimer acknowledgment version. + TourUpdateEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/TourUpdateResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + TourUpdateResponse: + type: object + properties: + onboardingTourCompleted: + type: boolean + progress: + anyOf: + - $ref: "#/components/schemas/TourProgressOutput" + - type: "null" + required: + - onboardingTourCompleted + - progress + additionalProperties: false + description: The persisted completion flag and resume point after the write. MedicationScheduleOutput: type: object properties: @@ -19183,6 +19915,17 @@ components: - disableCoach additionalProperties: false description: Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates. + DiabetesFlagOutput: + type: object + properties: + hasDiabetes: + type: boolean + required: + - hasDiabetes + additionalProperties: false + description: Per-account diabetes opt-in (v1.18.6). `true` selects the ADA glycemic GOAL bands (fasting 80–130, + postprandial < 180) for glucose targets instead of the general non-diabetic bands. A user-declared preference + only — never inferred from a value, never a diagnosis. InsightsLayoutBodyOutput: type: object properties: @@ -19314,6 +20057,41 @@ components: compatibility and are normalised to their English equivalents before persisting; GET responses always carry the canonical English ids. Section ids are English-only. The legacy tile ids are deprecated and will be removed in a future major version." + TourProgressOutput: + type: object + properties: + lastStopId: + anyOf: + - type: string + minLength: 1 + maxLength: 64 + - type: "null" + completedStopIds: + default: [] + maxItems: 64 + type: array + items: + type: string + minLength: 1 + maxLength: 64 + status: + type: string + enum: + - in_progress + - completed + - skipped + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$ + required: + - lastStopId + - completedStopIds + - status + - updatedAt + additionalProperties: false + description: Resumable module-tour progress point. `lastStopId` seeds the resume index; `status` is the running/terminal + state. Medication: type: object properties: diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts index 2a86bc52d..0a4891df2 100644 --- a/e2e/dashboard.spec.ts +++ b/e2e/dashboard.spec.ts @@ -98,10 +98,53 @@ test.describe("authenticated dashboard render", () => { }), ); + // v1.18.6 — the dashboard now batches every visible chart's daily + // series into ONE `/api/measurements/series-batch` round-trip and + // hands each chart its slice via `preloadedSeries`. The route is a + // sub-path of `/api/measurements`, which the `**/api/measurements*` + // glob below does NOT match (Playwright's `*` does not cross `/`), so + // without an explicit stub the request fell through to the real + // backend. The seed user has no measurements, so the route returned + // every requested type as an EMPTY array — and an empty-but-present + // slice satisfies the chart's preloaded-coverage check, so each chart + // rendered the batched empty slice instead of self-fetching. Mock the + // endpoint with the same populated set so the charts paint. + await page.route(/\/api\/measurements\/series-batch(\?|$)/, (route) => { + const url = new URL(route.request().url()); + const types = (url.searchParams.get("types") ?? "WEIGHT").split(","); + const series: Record< + string, + Array<{ type: string; value: number; measuredAt: string; count: number }> + > = {}; + for (const type of types) { + const baseValue = + type === "BLOOD_PRESSURE_SYS" + ? 124 + : type === "BLOOD_PRESSURE_DIA" + ? 80 + : type === "PULSE" + ? 70 + : 78.5; + series[type] = Array.from({ length: 10 }, (_, i) => ({ + type, + value: baseValue + (i % 3) - 1, + measuredAt: new Date(Date.now() - i * 86_400_000).toISOString(), + count: 1, + })); + } + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ data: { series }, error: null }), + }); + }); + // The chart's per-type fetch reads `json.data.measurements` and // expects a `meta.total`. Return a small dataset so the chart has // points to plot — otherwise Recharts mounts a placeholder instead // of the recharts-wrapper SVG and the assertion below times out. + // (Self-fetch fallback for any chart not covered by the batch above, + // e.g. a range-tab change that drops batched coverage.) await page.route("**/api/measurements*", (route) => { const url = new URL(route.request().url()); const type = url.searchParams.get("type") ?? "WEIGHT"; diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts index cdc207dd8..e1f626569 100644 --- a/e2e/utils/mock-dashboard-snapshot.ts +++ b/e2e/utils/mock-dashboard-snapshot.ts @@ -8,7 +8,79 @@ import type { DataSummary } from "@/lib/analytics/trends"; import type { DashboardSnapshot, DashboardLayoutCatalogueEntry, + DashboardTargetBands, } from "@/lib/dashboard/snapshot"; +import { getBpTargets } from "@/lib/analytics/bp-targets"; +import { + buildTrafficLightBands, + buildTrafficRange, + buildWeightBandsFromHeight, + buildWeightRangeFromHeight, + getBodyFatTargetRange, +} from "@/lib/analytics/value-bands"; +import { + getAgeFromDateOfBirth, + getPersonalizedPulseTarget, +} from "@/lib/analytics/pulse-targets"; + +// v1.18.6 — resolve the mock's band block from the same pure helpers the +// server builder uses, so the e2e fixture matches the real DTO shape. +function mockTargetBands(): DashboardTargetBands { + const dob = new Date("1990-01-01T00:00:00.000Z"); + const bpTargets = getBpTargets(dob); + const pulseTarget = getPersonalizedPulseTarget( + getAgeFromDateOfBirth(dob), + "MALE", + ); + const bodyFatRange = getBodyFatTargetRange("MALE"); + return { + bpTargets, + bpSysRange: bpTargets + ? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh) + : null, + bpDiaRange: bpTargets + ? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh) + : null, + pulseDisplayRange: { + greenMin: pulseTarget.greenMin, + greenMax: pulseTarget.greenMax, + orangeMin: pulseTarget.orangeMin, + orangeMax: pulseTarget.orangeMax, + }, + pulseBands: [ + { min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 }, + { + min: pulseTarget.orangeMin, + max: pulseTarget.greenMin, + color: "#ffb86c", + opacity: 0.18, + }, + { + min: pulseTarget.greenMin, + max: pulseTarget.greenMax, + color: "#50fa7b", + opacity: 0.2, + }, + { + min: pulseTarget.greenMax, + max: pulseTarget.orangeMax, + color: "#ffb86c", + opacity: 0.18, + }, + { min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 }, + ].filter((b) => b.max > b.min), + bodyFatRange, + bodyFatBands: buildTrafficLightBands(bodyFatRange.min, bodyFatRange.max, { + lowerBound: 2, + upperBound: 55, + }), + weightRange: buildWeightRangeFromHeight(180), + weightBands: buildWeightBandsFromHeight(180, { + lowerBound: 30, + upperBound: 250, + }), + }; +} /** * v1.7.2 — shared mock for `GET /api/dashboard/snapshot`. @@ -138,6 +210,7 @@ export function buildMockSnapshot( layout: DEFAULT_DASHBOARD_LAYOUT, layoutCatalogue: buildLayoutCatalogue(), metricStates: {}, + targetBands: mockTargetBands(), tiles: { summaries, lastSeenByType, diff --git a/e2e/v1427-coach-launch.spec.ts b/e2e/v1427-coach-launch.spec.ts index 9616b30b0..dd1573888 100644 --- a/e2e/v1427-coach-launch.spec.ts +++ b/e2e/v1427-coach-launch.spec.ts @@ -5,17 +5,17 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; /** * Coach launch surfaces on `/insights/{slug}` sub-pages. * - * Contracts under test (v1.16.8): + * Contracts under test (v1.18.6, CCH-04): * - * 1. The floating Coach FAB (`data-slot="coach-fab"`) is a permanent - * launcher on every authenticated page. An unread Coach-initiated + * 1. The floating Coach FAB (`data-slot="coach-fab"`) is the single + * Coach launcher on every authenticated page. The per-metric inline + * launch icon the sub-page header used to mount was retired in + * CCH-04 — the FAB is now the only entry. An unread Coach-initiated * nudge (`GET /api/insights/coach/nudge-status`) paints an unread * dot (`data-slot="coach-fab-unread"`) on its corner; with nothing - * unread the FAB renders without the dot. The inline launch the - * sub-page shell mounts in the header - * (`data-slot="coach-launch-icon"`) stays the contextual entry. + * unread the FAB renders without the dot. * - * 2. Clicking the inline launch opens the Coach drawer — as a + * 2. Clicking the FAB opens the Coach drawer in place — as a * bottom-sheet (`data-variant="bottom-sheet"`) on `=sm`. * @@ -27,16 +27,16 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * tray; it hands off to the full-page route `/coach`. * * The three sub-pages covered (blutdruck, gewicht, schlaf) span the - * empty-state branch (no data → CTA + Coach launch) and the populated - * branch (with data → full chart + Coach launch in the header). + * empty-state branch (no data → CTA) and the populated branch (with + * data → full chart). The FAB launcher is present on both. */ test.describe("Coach launch surfaces on insights sub-pages", () => { test.use({ storageState: STORAGE_STATE_PATH }); test.beforeEach(async ({ page }) => { // Populated analytics so each sub-page paints the data branch - // (`hasMetricData` true) — the inline Coach launch is mounted in - // the sub-page header by the shell. + // (`hasMetricData` true). The Coach FAB is the launcher on every + // authenticated page regardless of the data branch. // Regex form matches the slim slice the dashboard split fires // alongside the thick request. await page.route(/\/api\/analytics(\?|$)/, (route) => @@ -126,21 +126,23 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { }); for (const slug of ["blutdruck", "gewicht", "schlaf"] as const) { - test(`/insights/${slug} mounts the inline Coach launch and the dot-free FAB on Pixel 5`, async ({ + test(`/insights/${slug} mounts the dot-free Coach FAB on Pixel 5`, async ({ page, }, testInfo) => { test.skip( testInfo.project.name !== "chromium-mobile", - "mobile inline-launch branch", + "mobile FAB-launcher branch", ); await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" }); - // The header launch icon stays the contextual entry point. - const icon = page.locator('[data-slot="coach-launch-icon"]').first(); - await expect(icon).toBeVisible({ timeout: 10_000 }); + // CCH-04 — the per-metric inline launch icon is retired; no header + // launcher renders on the sub-page. + await expect( + page.locator('[data-slot="coach-launch-icon"]'), + ).toHaveCount(0); - // The FAB is a permanent launcher; with no unread nudge it + // The FAB is the permanent launcher; with no unread nudge it // renders without the unread dot. const fab = page.locator('[data-slot="coach-fab"]'); await expect(fab).toBeVisible({ timeout: 10_000 }); @@ -149,18 +151,19 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { ).toHaveCount(0); }); - test(`/insights/${slug} mounts the inline Coach launch and the dot-free FAB on Desktop Chrome`, async ({ + test(`/insights/${slug} mounts the dot-free Coach FAB on Desktop Chrome`, async ({ page, }, testInfo) => { test.skip( testInfo.project.name !== "chromium-desktop", - "desktop inline-launch branch", + "desktop FAB-launcher branch", ); await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" }); - const icon = page.locator('[data-slot="coach-launch-icon"]').first(); - await expect(icon).toBeVisible({ timeout: 10_000 }); + await expect( + page.locator('[data-slot="coach-launch-icon"]'), + ).toHaveCount(0); const fab = page.locator('[data-slot="coach-fab"]'); await expect(fab).toBeVisible({ timeout: 10_000 }); @@ -170,7 +173,7 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { }); } - test("clicking the inline Coach launch opens the drawer as a bottom-sheet on Pixel 5", async ({ + test("clicking the Coach FAB opens the drawer as a bottom-sheet on Pixel 5", async ({ page, }, testInfo) => { test.skip( @@ -178,26 +181,26 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { "drawer bottom-sheet branch is mobile-only", ); - // Hydration gate — the status query is fired by the sub-page - // boundary itself, so its response proves React has hydrated the - // page and attached the launch icon's click handler. Clicking the - // SSR-painted icon any earlier is silently lost (CI flake). - const pageHydrated = page.waitForResponse( - /\/api\/insights\/blood-pressure-status/, + // Hydration gate — the nudge-status query is fired by the FAB + // component itself, so its response proves the FAB's click handler + // is attached. Clicking the SSR-painted FAB any earlier is silently + // lost (CI flake). + const fabHydrated = page.waitForResponse( + /\/api\/insights\/coach\/nudge-status/, ); await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" }); - await pageHydrated; + await fabHydrated; - const icon = page.locator('[data-slot="coach-launch-icon"]').first(); - await expect(icon).toBeVisible({ timeout: 10_000 }); - await icon.click(); + const fab = page.locator('[data-slot="coach-fab"]'); + await expect(fab).toBeVisible({ timeout: 10_000 }); + await fab.click(); const drawer = page.locator('[data-slot="coach-drawer"]'); await expect(drawer).toBeVisible({ timeout: 10_000 }); await expect(drawer).toHaveAttribute("data-variant", "bottom-sheet"); }); - test("clicking the inline Coach launch opens the drawer as a side-sheet on Desktop Chrome", async ({ + test("clicking the Coach FAB opens the drawer as a side-sheet on Desktop Chrome", async ({ page, }, testInfo) => { test.skip( @@ -206,15 +209,15 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { ); // Hydration gate — see the bottom-sheet branch above. - const pageHydrated = page.waitForResponse( - /\/api\/insights\/blood-pressure-status/, + const fabHydrated = page.waitForResponse( + /\/api\/insights\/coach\/nudge-status/, ); await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" }); - await pageHydrated; + await fabHydrated; - const icon = page.locator('[data-slot="coach-launch-icon"]').first(); - await expect(icon).toBeVisible({ timeout: 10_000 }); - await icon.click(); + const fab = page.locator('[data-slot="coach-fab"]'); + await expect(fab).toBeVisible({ timeout: 10_000 }); + await fab.click(); const drawer = page.locator('[data-slot="coach-drawer"]'); await expect(drawer).toBeVisible({ timeout: 10_000 }); @@ -289,15 +292,15 @@ test.describe("Coach launch surfaces on insights sub-pages", () => { ); // Hydration gate — see the bottom-sheet test above. - const pageHydrated = page.waitForResponse( - /\/api\/insights\/blood-pressure-status/, + const fabHydrated = page.waitForResponse( + /\/api\/insights\/coach\/nudge-status/, ); await page.goto("/insights/blutdruck", { waitUntil: "domcontentloaded" }); - await pageHydrated; + await fabHydrated; - const icon = page.locator('[data-slot="coach-launch-icon"]').first(); - await expect(icon).toBeVisible({ timeout: 10_000 }); - await icon.click(); + const fab = page.locator('[data-slot="coach-fab"]'); + await expect(fab).toBeVisible({ timeout: 10_000 }); + await fab.click(); const drawer = page.locator('[data-slot="coach-drawer"]'); await expect(drawer).toBeVisible({ timeout: 10_000 }); diff --git a/messages/de.json b/messages/de.json index 945324bc9..321cf3dc0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -18,7 +18,6 @@ "optional": "Optional", "active": "Aktiv", "inactive": "Inaktiv", - "enabled": "Aktiviert", "disabled": "Deaktiviert", "networkError": "Netzwerkfehler", "unknownError": "Unbekannter Fehler", @@ -74,7 +73,6 @@ "colTaken": "Eingenommen", "colSkipped": "Übersprungen", "colMissed": "Verpasst", - "colTotal": "Gesamt", "colExpected": "Erwartet", "colComplianceRate": "Therapietreue", "moodTitle": "Stimmungsverlauf", @@ -191,7 +189,8 @@ "backToDashboard": "Zurück zum Dashboard" }, "offlineBanner": { - "message": "Keine Verbindung — Änderungen werden gespeichert, sobald du wieder online bist." + "message": "Keine Verbindung — Änderungen werden gespeichert, sobald du wieder online bist.", + "showingCached": "Zeigt deine zuletzt synchronisierten Daten." }, "demoBanner": { "message": "Demo-Modus — Änderungen werden nicht gespeichert." @@ -201,14 +200,12 @@ "measurements": "Messungen", "medications": "Medikamente", "insights": "Insights", - "targets": "Zielwerte", "achievements": "Erfolge", "settings": "Einstellungen", "bugreport": "Bugreport", "admin": "Admin-Konsole", "logout": "Abmelden", "login": "Anmelden", - "about": "Über HealthLog", "notifications": "Benachrichtigungen", "theme": "Darstellung", "themeSystem": "System", @@ -218,13 +215,11 @@ "userMenu": "Benutzermenü", "accountSettings": "Kontoeinstellungen", "mood": "Stimmung", - "workouts": "Workouts", "coach": "Coach", "skipToContent": "Zum Inhalt springen", "sidebar": "Sidebar", "mainNavigation": "Hauptnavigation", "mobileNavigation": "Mobile Navigation", - "breadcrumb": "Brotkrumen-Navigation", "loadingScreen": "Laden…", "more": "Mehr", "moreSheetTitle": "Mehr", @@ -241,7 +236,6 @@ }, "cycle": "Zyklus", "labs": "Laborwerte", - "recovery": "Erholung", "vorsorge": "Vorsorge", "illness": "Krankheit" }, @@ -329,7 +323,6 @@ "sleepShort": "Schlaf", "stepsShort": "Schritte", "vo2MaxShort": "VO₂ max", - "bpInTargetShort": "BD-Ziel", "bpInTargetWindow90": "BD-Ziel · 90 T", "bpInTargetWindowDynamic": "BD-Ziel · {days} T", "bpInTargetCollecting": "sammelt noch Daten", @@ -383,7 +376,6 @@ "staleHintWeeksOther": "Letzter Wert vor {count} Wochen", "staleHintMonthsOne": "Letzter Wert vor 1 Monat", "staleHintMonthsOther": "Letzter Wert vor {count} Monaten", - "coachCta": "Frag den Coach", "recentWorkouts": { "title": "Letzte Workouts", "viewAll": "Alle anzeigen", @@ -422,7 +414,7 @@ }, "measurements": { "title": "Messungen", - "subtitle": "Gewicht, Blutdruck, Puls und mehr", + "subtitle": "Werte erfassen und die vollständige Historie einsehen. Trends und Auswertungen findest du unter Auswertungen.", "addMeasurement": "Hinzufügen", "editMeasurement": "Messwert bearbeiten", "type": "Typ", @@ -498,7 +490,6 @@ "invalidTimestamp": "Bitte einen gültigen Zeitpunkt wählen.", "loginRequired": "Bitte anmelden, um Messwerte zu sehen.", "allTypes": "Alle Typen", - "filterByType": "Nach Typ filtern", "measurementCount": "{count} Messwerte", "emptyTitle": "Noch keine Messwerte", "emptyDescription": "Erfasse Gewicht, Blutdruck, Puls oder andere Werte, um Trends zu erkennen.", @@ -549,7 +540,6 @@ "typeCardioLoad": "Kardio-Belastung", "typeSleepScore": "Schlafscore", "typeBodyTemperatureDeviation": "Körpertemperaturabweichung", - "filterBySource": "Nach Quelle filtern", "sourceComputed": "Berechnet", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", @@ -669,7 +659,6 @@ "factorSadness": "Traurigkeit", "praise": "Lob" }, - "filterBySource": "Nach Quelle filtern", "sourceManual": "Manuell", "sourceWeb": "Web", "sourceTelegram": "Telegram", @@ -740,8 +729,6 @@ "subtitle": "Medikation, Zeitpläne & Einnahmen", "loginRequired": "Bitte anmelden, um Medikamente zu verwalten.", "addMedication": "Hinzufügen", - "editMedication": "Medikament bearbeiten", - "newMedication": "Neues Medikament", "firstMedication": "Erstes Medikament anlegen", "addChoice": { "logIntake": "Einnahme erfassen", @@ -777,16 +764,9 @@ "loadFailed": "Medikamente konnten nicht geladen werden", "retryLoad": "Erneut laden", "name": "Name", - "dose": "Dosis", - "scheduleFrom": "Von", - "scheduleTo": "Bis", - "scheduleLabel": "Bezeichnung", - "scheduleDays": "Tage", - "scheduleDaily": "Täglich", "inactive": "Pausiert", "intakeHistory": "Einnahme-Verlauf", "openDetailPage": "Medikament-Detailseite öffnen", - "deleteConfirm": "Medikament löschen?", "compliance": "Therapietreue", "importIntakes": "Einnahmen importieren", "importDescription": "JSON-Array mit Einnahme-Daten einfügen. Erwartetes Format:", @@ -801,19 +781,7 @@ "importInvalidSkipped": "{count} ungültige Einträge übersprungen", "importFailed": "Import fehlgeschlagen", "importInvalidFormat": "Ungültiges JSON-Format", - "apiEndpointTitle": "API-Endpoint: {name}", - "apiEndpointDescription": "Nutze diesen Endpoint, um Einnahmen per API zu erfassen (z.B. via iPhone-Kurzbefehl oder cron-Job).", - "apiEndpointActive": "API-Endpoint aktiv", - "apiEndpointActivated": "API-Endpoint aktiviert", - "apiEndpointDeactivated": "API-Endpoint deaktiviert", - "apiTokenCount": "Aktiv ({count} Token)", - "apiToken": "API-Token", - "apiTokenActiveHint": "Endpoint ist aktiv. Vorhandene Tokens bleiben gültig, können aber nicht erneut im Klartext angezeigt werden.", - "apiTokenActivateHint": "Aktiviere den Endpoint, um einen neuen Token zu erstellen.", - "apiTokenOnceHint": "Der Token wird nur bei der Erstellung einmal im Klartext angezeigt.", "requestExample": "Request-Beispiel", - "statusLoadFailed": "Status konnte nicht geladen werden", - "changeFailed": "Ändern fehlgeschlagen", "categoryBloodPressure": "Blutdrucksenker", "categoryVitamin": "Vitamine", "categoryThyroid": "Schilddrüse", @@ -846,21 +814,15 @@ "lastDoseDaysAgo": "Letzte Dosis vor {count} Tagen", "overdue": "Überfällig", "veryOverdue": "Stark überfällig", - "compliance7d": "Therapietreue (7 Tage)", - "compliance30d": "Therapietreue (30 Tage)", "complianceWindow": "Therapietreue ({days} Tage)", "complianceUnavailable": "Therapietreue konnte nicht geladen werden", "complianceInfo": "Anteil der erfassten an den erwarteten Dosen. Wöchentliche Pläne zählen wochenweise.", "complianceInfoLabel": "Was bedeutet Therapietreue?", - "complianceDoses": "{count} Dosen", "lastIntake": "Letzte Einnahme:", "nextIntake": "Nächste Einnahme:", "nextIntakeOverdue": "Überfällig · {time} — noch nachholbar", "dayStreak": "Tage Serie", "dayStreakOne": "Tag Serie", - "cycleNextDoseToday": "Nächste Dosis heute", - "cycleNextDoseTomorrow": "Nächste Dosis morgen", - "cycleNextDoseInDays": "Nächste Dosis in {days} Tagen", "cycleDueToday": "Heute fällig", "cycleOverdue": "Überfällig", "cycleNoClosedCycles": "Noch keine abgeschlossenen Dosiszyklen", @@ -872,40 +834,10 @@ "intakeUndo": "Rückgängig", "intakeUndone": "Eintrag zurückgenommen", "intakeUndoFailed": "Rückgängig fehlgeschlagen – im Verlauf korrigieren", - "weekdaySunday": "Sonntag", - "weekdayMonday": "Montag", - "weekdayTuesday": "Dienstag", - "weekdayWednesday": "Mittwoch", - "weekdayThursday": "Donnerstag", - "weekdayFriday": "Freitag", - "weekdaySaturday": "Samstag", - "weekdaySundayPlural": "Sonntags", - "weekdayMondayPlural": "Montags", - "weekdayTuesdayPlural": "Dienstags", - "weekdayWednesdayPlural": "Mittwochs", - "weekdayThursdayPlural": "Donnerstags", - "weekdayFridayPlural": "Freitags", - "weekdaySaturdayPlural": "Samstags", - "formType": "Typ", - "formDose": "Dosis", - "formUnit": "Einheit", - "formSchedule": "Zeitfenster", - "formTreatmentClass": "Behandlungsklasse", - "formTreatmentClassHelp": "Wähle „GLP-1-Injektion“ für wöchentliche Spritzen wie Mounjaro, Ozempic, Wegovy, Zepbound. Schaltet die Körperkarte und das GLP-1-Logbuch frei.", - "treatmentClassGeneric": "Standard-Medikament", - "treatmentClassGlp1": "GLP-1-Injektion", - "glp1WeeklyPresetTitle": "Einmal wöchentlich am …", - "glp1WeeklyPresetHelp": "Die meisten GLP-1-Rezeptoragonisten werden einmal pro Woche injiziert. Tippe einen Tag, um den Rhythmus zu setzen.", - "glp1WeeklyPresetSuggest": "Letzten gewählten Wochentag übernehmen", - "glp1DosesPerUnit": "Dosen pro Pen / Ampulle", - "glp1DosesPerUnitPlaceholder": "z. B. 4 für einen Mounjaro KwikPen", - "glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy Einmal-Pen = 1. Leer lassen, wenn keine Dosis-pro-Pen-Verfolgung gewünscht ist.", "glp1LastInjection": "{label}", - "glp1LastInjectionWithSite": "{label} · {site}", "glp1NextInjectionDays": "{label} (in {days} Tagen)", "glp1NextInjectionToday": "heute", "glp1NextInjectionTomorrow": "morgen", - "glp1RotationLast": "Letzte Stelle: {site}", "glp1RotationSuggested": "Empfohlen: {site}", "glp1LogSideEffect": "Nebenwirkung loggen", "sideEffects": { @@ -1260,8 +1192,7 @@ "add": "Weiteren Zeitplan hinzufügen", "edit": "Bearbeiten", "remove": "Entfernen", - "removeDisabled": "Mindestens ein Zeitplan ist erforderlich.", - "empty": "Noch kein Zeitplan." + "removeDisabled": "Mindestens ein Zeitplan ist erforderlich." } }, "errors": { @@ -1305,38 +1236,6 @@ "sideEffectTagAppetiteLoss": "Appetitlosigkeit", "sideEffectTagHeartburn": "Sodbrennen", "sideEffectTagHeadache": "Kopfschmerzen", - "newSchedule": "Neues Zeitfenster", - "removeSchedule": "Zeitfenster entfernen", - "advancedEditing": "Erweiterte Bearbeitung", - "advancedEditingHide": "Erweiterte Bearbeitung ausblenden", - "scheduleInterval": "Intervall", - "scheduleEveryWeek": "jede Woche", - "scheduleEveryNWeeks": "alle {n} Wochen", - "scheduleIntervalPrefix": "Intervall:", - "nextSchedulePrefix": "Nächstes Zeitfenster:", - "nextScheduleDaily": "täglich", - "importIntakesAction": "Einnahmen importieren", - "apiEndpointAction": "API-Endpunkt", - "pause": "Pausieren", - "activate": "Aktivieren", - "disableNotifications": "Benachrichtigungen deaktivieren", - "enableNotifications": "Benachrichtigungen aktivieren", - "purgeRecords": "Alle Aufzeichnungen löschen", - "formReset": "Formular zurücksetzen", - "createMedication": "Medikament anlegen", - "deleteConfirmDescription": "Das Medikament und die gesamte Einnahme-Historie werden unwiderruflich gelöscht.", - "purgeConfirmTitle": "Alle Aufzeichnungen löschen?", - "purgeConfirmDescription": "Alle Einnahme-Aufzeichnungen für dieses Medikament werden unwiderruflich gelöscht. Das Medikament selbst bleibt bestehen und startet ohne Verlaufsdaten.", - "saveError": "Fehler beim Speichern", - "deleteError": "Fehler beim Löschen", - "purgeError": "Fehler beim Löschen der Aufzeichnungen", - "invalidTimestamp": "Bitte einen gültigen Zeitpunkt wählen.", - "noIntakesYet": "Noch keine Einnahmen vorhanden", - "actions": "Aktionen", - "pageInfo": "Seite {page} von {total}", - "previousPage": "Vorherige Seite", - "nextPage": "Nächste Seite", - "loginRequiredIntakes": "Bitte anmelden, um Einnahmen zu sehen.", "phaseConfig": "Erinnerungsphasen", "phaseConfigDescription": "Konfiguriere die Zeitpunkte für Erinnerungen.", "phaseGreen": "Grün", @@ -1345,11 +1244,8 @@ "phaseRed": "Rot", "phaseMinutes": "Min", "phasePercent": "%", - "phaseBeforeEnd": "vor Ende", - "phaseAfterEnd": "nach Ende", "phaseResetDefaults": "Auf Defaults zurücksetzen", "phaseSaved": "Erinnerungsphasen gespeichert", - "phaseReset": "Auf Defaults zurückgesetzt", "sourceWeb": "Web", "sourceApi": "iOS-App", "sourceReminder": "Erinnerung", @@ -1369,16 +1265,6 @@ "intakeHistoryPageInfo": "Seite {page} von {total} · {count} Einträge", "intakeHistoryPrev": "Zurück", "intakeHistoryNext": "Weiter", - "unitDrops": "Tropfen", - "unitTablets": "Tablette(n)", - "unitCapsules": "Kapsel(n)", - "unitPieces": "Stück", - "namePlaceholder": "z.B. Ibuprofen", - "dosePlaceholder": "z.B. 400", - "labelPlaceholder": "z.B. Morgens", - "defaultDose": "Standard", - "dayDeactivate": "{day} deaktivieren", - "dayActivate": "{day} aktivieren", "glp1": { "drug": { "tirzepatide": { @@ -1635,8 +1521,6 @@ "historyLabel": "Einnahmeverlauf ansehen" }, "history": { - "back": "Zurück zum Medikament", - "subtitle": "Vollständiger Einnahmeverlauf", "plannedSuffix": "geplant" }, "tabs": { @@ -2008,7 +1892,8 @@ "bmiEmptyAction": "Account-Einstellungen öffnen", "noAnalysisYet": "Noch keine Einschätzung vorhanden.", "assessmentPreparing": "Einschätzung wird vorbereitet…", - "noProviderConfigured": "Einschätzung nicht verfügbar.", + "noProviderConfigured": "Verbinde einen KI-Anbieter, um hier eine Einschätzung zu deinen Werten zu sehen.", + "noProviderAction": "Zu den KI-Einstellungen", "startAnalysis": "Einschätzung starten", "refreshAnalysis": "Einschätzung aktualisieren", "keyTakeaway": "Das Wichtigste", @@ -2081,11 +1966,11 @@ "progressAria": "Gesundheitsscore-Fortschritt", "deltaVsLastWeek": "{delta} im Vergleich zur Vorwoche", "deltaUnavailable": "Noch keine Historie — schau nächste Woche wieder vorbei.", + "restModePaused": "Während einer Krankheitsphase pausiert — dein Score wird heute nicht bewertet.", "componentBp": "Blutdruck", "componentWeight": "Gewicht", "componentMood": "Stimmung", "componentCompliance": "Therapietreue", - "disclaimer": "Orientierungshilfe — keine ärztliche Bewertung.", "deltaExplainer": { "trigger": "Was bedeutet diese Veränderung?", "title": "So wird die Veränderung berechnet", @@ -2123,7 +2008,7 @@ "emptyAction": "Briefing erstellen", "loadingLabel": "Briefing wird zusammengestellt", "noProviderTitle": "Kein KI-Anbieter verbunden", - "noProviderDescription": "Verbinde einen KI-Anbieter in den Einstellungen, um tägliche Briefings zu erhalten.", + "noProviderDescription": "Das Tagesbriefing fasst deine Werte in Klartext zusammen. Dafür braucht HealthLog einen KI-Anbieter — nutze deinen eigenen Schlüssel (OpenAI, Anthropic) oder ein lokales Modell. Deine Daten bleiben dabei bei dir.", "noProviderAction": "Zu den KI-Einstellungen" }, "trendsRow": { @@ -2143,7 +2028,8 @@ "distance": "Geh- und Laufstrecke der letzten 30 Tage.", "vo2_max": "VO₂max der letzten 30 Tage.", "body_temp": "Körpertemperatur der letzten 30 Tage." - } + }, + "viewDetail": "{metric} im Detail" }, "vo2Max": { "statLatest": "Aktuell", @@ -2165,7 +2051,6 @@ }, "correlationRow": { "title": "Zusammenhänge", - "disclaimer": "Zusammenhänge sind beobachtend, nicht kausal — sprich mit deinem Arzt, wenn der Blutdruck hoch ist.", "experimentCta": "7-Tage-Experiment starten", "experimentTooltip": "Bald verfügbar", "emptyTitle": "Mehr Daten nötig, um diesen Zusammenhang zu zeigen", @@ -2188,6 +2073,8 @@ "refusalInjection": "In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen — formuliere deine Frage bitte in diesem Rahmen neu.", "errorBudget": "Du hast das heutige Coach-Limit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.", "errorProvider": "Der Coach konnte gerade keinen Auswertungs-Anbieter erreichen. Bitte versuche es in einem Moment erneut.", + "errorNoProvider": "Der Coach liest deine erfassten Gesundheitsdaten und beantwortet Fragen dazu (keine medizinische Beratung). Dafür braucht HealthLog einen KI-Anbieter — verbinde deinen eigenen Schlüssel oder ein lokales Modell.", + "errorNoProviderAction": "KI-Anbieter verbinden", "errorNetwork": "Keine Internetverbindung — versuche es erneut, sobald du online bist.", "prefsSaveError": "Coach-Einstellungen konnten nicht gespeichert werden. Bitte versuche es erneut.", "errorCredentialExpired": "Deine Verbindung zum KI-Anbieter ist abgelaufen. Stelle sie in den Einstellungen wieder her, damit der Coach weiterläuft.", @@ -2507,7 +2394,7 @@ "explainer": { "trigger": "Was ist {title}?", "bloodPressureTitle": "Blutdruck", - "bloodPressureBody": "Der Blutdruck ist die Kraft, die das Blut auf die Gefäßwände ausübt, angegeben als systolisch zu diastolisch (Spitzenwert beim Herzschlag über dem Ruhewert dazwischen). Dauerhaft hohe Werte belasten Herz und Gefäße auf Dauer.", + "bloodPressureBody": "Der Blutdruck ist die Kraft, die das Blut auf die Gefäßwände ausübt, angegeben als systolisch zu diastolisch (Spitzenwert beim Herzschlag über dem Ruhewert dazwischen). Dauerhaft hohe Werte belasten Herz und Gefäße auf Dauer. Als allgemeine Orientierung gilt nach europäischer Leitlinie (ESH 2023) unter 120/80 als optimal und ab 140/90 als Bluthochdruck; die US-Leitlinie (ACC/AHA 2017) setzt mit 130–139/80–89 (Stadium 1) eine strengere Grenze. Einordnung immer aus einer ruhigen, sitzenden Messung, nie aus einem einzelnen Wert.", "pulseTitle": "Puls", "pulseBody": "Der Puls gibt an, wie oft das Herz pro Minute schlägt. Eine Einzelmessung spiegelt den Moment wider – in Ruhe liegt er meist bei 60–100 Schlägen pro Minute und steigt natürlicherweise bei Bewegung, Stress oder Koffein.", "weightTitle": "Gewicht", @@ -2525,7 +2412,7 @@ "hrvTitle": "Herzfrequenzvariabilität (HRV)", "hrvBody": "Die HRV ist die kleine Schwankung der Zeit zwischen aufeinanderfolgenden Herzschlägen. Eine höhere Variabilität spiegelt meist ein gut erholtes, anpassungsfähiges Nervensystem wider, ein anhaltender Rückgang kann auf Erschöpfung, Stress oder mangelnde Erholung hinweisen.", "oxygenSaturationTitle": "Sauerstoffsättigung (SpO₂)", - "oxygenSaturationBody": "Die SpO₂ ist der Prozentsatz der roten Blutkörperchen, die Sauerstoff transportieren. Werte von 95–100 % sind in Ruhe normal; Werte, die dauerhaft unter 90 % liegen, verdienen Aufmerksamkeit, kurze nächtliche Abfälle sind jedoch häufig.", + "oxygenSaturationBody": "Die SpO₂ ist der Prozentsatz der roten Blutkörperchen, die Sauerstoff transportieren. Als allgemeine Orientierung sind Werte von 95–100 % in Ruhe normal (StatPearls/NIH 2023); Werte dauerhaft unter 90 % verdienen Aufmerksamkeit, kurze nächtliche Abfälle sind häufig und über ~98 % bringt nichts. Optische Sensoren können die wahre Sättigung bei dunklerer Haut überschätzen (US FDA 2024) — ein einzelner normnaher Wert ist daher keine Entwarnung; bei Sorge warm und ruhig erneut messen.", "bodyTemperatureTitle": "Körpertemperatur", "bodyTemperatureBody": "Die Körpertemperatur spiegelt deinen Kernwärmezustand wider, normalerweise um 37 °C mit kleinen Tagesschwankungen. Ein anhaltender Anstieg kann auf eine Infektion hindeuten; nächtliche Messungen am Handgelenk erfassen vor allem relative Änderungen, keinen klinischen Wert.", "activeEnergyTitle": "Aktive Energie", @@ -2597,14 +2484,26 @@ "breathingDisturbancesTitle": "Atemaussetzer im Schlaf", "breathingDisturbancesBody": "Deine Apple Watch kann schätzen, wie oft deine Atmung im Schlaf unterbrochen war. Ein niedriger Index ist besser; ein von Apple als erhöht eingestufter Wert — vor allem über viele Nächte — sollte mit einer Ärztin oder einem Arzt besprochen werden.", "stepsTitle": "Schritte", - "stepsBody": "Die Schrittzahl ist die Gesamtzahl der Schritte, die du täglich gehst. Sie ist das einfachste Maß für Alltagsbewegung — eine höhere Tageszahl spiegelt meist einen aktiveren Lebensstil wider, und eine stetige Gewohnheit zählt mehr als ein einzelner großer Tag.", - "readinessBody": "Die Tagesform ist ein einzelner Tageswert dafür, wie bereit dein Körper für den Tag ist — er verbindet Ruhepuls, Herzfrequenzvariabilität, Schlaf und die Beständigkeit deiner letzten Nächte. Ein höherer Wert heißt erholt und belastbar; ein niedrigerer ist ein sanfter Hinweis, einen Gang zurückzuschalten.", + "stepsBody": "Die Schrittzahl ist die Gesamtzahl der Schritte, die du täglich gehst. Sie ist das einfachste Maß für Alltagsbewegung, und eine stetige Gewohnheit zählt mehr als ein einzelner großer Tag. Als allgemeine Orientierung fällt das Sterblichkeitsrisiko deutlich Richtung etwa 8.000 Schritte pro Tag, mit weiterem Nutzen bis rund 12.000 (Saint-Maurice JAMA 2020); bei älteren Menschen liegt der optimale Wert niedriger. Die „10.000“ ist ein Werbeslogan, keine Forschung.", + "readinessBody": "Die Tagesform ist ein einzelner Tageswert dafür, wie bereit dein Körper für den Tag ist — er verbindet Ruhepuls, Herzfrequenzvariabilität, Schlaf und die Beständigkeit deiner letzten Nächte. Es ist ein Geräte-Näherungswert, der an deinem eigenen Ausgangswert gemessen wird, keine klinische Messung, und zwischen Geräten nicht vergleichbar. Ein höherer Wert heißt erholt und belastbar; ein niedrigerer ist ein sanfter Hinweis, einen Gang zurückzuschalten.", "sleepScoreBody": "Dein Schlafscore fasst eine ganze Nacht in einer Zahl zusammen — aus Dauer, Effizienz, Regelmäßigkeit und der Verteilung von Leicht-, Tief- und REM-Schlaf. Achte eher auf das Muster über Wochen als auf einen perfekten Wert in einer einzelnen Nacht.", - "recoveryBody": "Die Erholung zeigt, wie gut sich dein Körper regeneriert hat — gestützt auf nächtliche Signale wie Herzfrequenzvariabilität und Ruhepuls. 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.", + "recoveryBody": "Die Erholung zeigt, wie gut sich dein Körper regeneriert hat — gestützt auf nächtliche Signale wie Herzfrequenzvariabilität und Ruhepuls. Es ist ein geräteabgeleiteter Näherungswert an deinem eigenen Ausgangswert, keine klinische Messung, und zwischen Geräten nicht vergleichbar. Ein hoher Wert ist grünes Licht, mehr zu geben; ein niedrigerer deutet an, dass dein System noch aufbaut.", + "strainBody": "Die Belastung erfasst, wie viel kardiovaskuläre und körperliche Last du deinem Körper zugemutet hast — von Workouts bis zu einem aktiven Tag auf den Beinen. Es ist ein geräteabgeleiteter Index, gemessen an deinem eigenen üblichen Aufwand, keine klinische Messung, und der Kennwert ist zwischen Geräten nicht vergleichbar.", + "stressBody": "Stress ist hier eine Geräte-Heuristik aus Mustern der Herzfrequenzvariabilität — keine Cortisol-Messung und nicht, wie du dich bewusst fühlst, und auch kein klinischer Stress-Score. Lies ihn an deinem eigenen Ausgangswert; zwischen Geräten ist er nicht vergleichbar. Ein niedrigerer Wert deutet auf einen ruhigen, gut regulierten Zustand hin; ein höherer ist eine Einladung, Erholung einzuplanen.", "recoveryPageTitle": "Erholung", - "recoveryPageBody": "Die Erholung fasst die nächtlichen Signale deines Wearables zusammen – Recharge, Belastung, Load sowie deine Herzfrequenz- und Energiewerte des Tages – und zeigt, wie gut sich dein Körper erholt hat. Achte eher auf das Muster über mehrere Tage als auf einen einzelnen Wert; ein schwächerer Wert ist eine Einladung, kürzerzutreten, kein Alarm." + "recoveryPageBody": "Die Erholung fasst die nächtlichen Signale deines Wearables zusammen – Recharge, Belastung, Load sowie deine Herzfrequenz- und Energiewerte des Tages – und zeigt, wie gut sich dein Körper erholt hat. Achte eher auf das Muster über mehrere Tage als auf einen einzelnen Wert; ein schwächerer Wert ist eine Einladung, kürzerzutreten, kein Alarm.", + "pulsePressureTitle": "Pulsdruck", + "pulsePressureBody": "Der Pulsdruck ist die Differenz zwischen deinem systolischen und diastolischen Wert (systolisch minus diastolisch). Als allgemeine Orientierung sind etwa 40 mmHg in Ruhe typisch; eine große Differenz über rund 60 mmHg im mittleren Alter kann auf versteifte Arterien hinweisen, eine kleine Differenz unter etwa 25 mmHg kann mit niedrigem Schlagvolumen einhergehen (StatPearls/NIH 2023). Ein Gesprächsanlass, keine Diagnose.", + "meanArterialPressureTitle": "Mittlerer arterieller Druck", + "meanArterialPressureBody": "Der mittlere arterielle Druck ist der durchschnittliche Druck, den deine Organe über einen Herzschlag sehen – grob der diastolische Wert plus ein Drittel der Differenz zum systolischen. Als allgemeine Orientierung liegt ein Ruhewert bei etwa 70–100 mmHg (StatPearls/NIH 2023). Die oft genannte Grenze von ~60 mmHg ist ein Konzept der Akutmedizin, kein Alarm für die Selbstmessung.", + "cycleEducationBody": "Dein Zyklus durchläuft vier hormongesteuerte Phasen, und seine Länge und Regelmäßigkeit sind ein gut zugängliches Fenster auf die allgemeine Gesundheit. Als allgemeine Orientierung dauern Zyklen bei Erwachsenen typischerweise 21–35 Tage (klinische Referenzen nennen 24–38), mit einer Blutung von rund fünf Tagen (ACOG 2015). Die Lutealphase ist der stabile Anker, daher schätzt man den Eisprung am besten durch Rückrechnen von der nächsten erwarteten Periode – nie über einen festen „Tag 14“. Ein vorhergesagtes fruchtbares Fenster ist eine Schätzung, die mehrere erfasste Zyklen braucht und ungefähr bleibt, und die Basaltemperatur bestätigt den Eisprung im Nachhinein, statt ihn vorherzusagen. Dies ist keine Verhütungsmethode.", + "sleepHygieneBody": "Einige evidenzbasierte Gewohnheiten unterstützen den Schlaf: gleichbleibende Zubett- und Aufstehzeiten auch am Wochenende, helles Morgenlicht und gedämpfte Abende, ein kühles, dunkles und ruhiges Zimmer sowie der Verzicht auf Koffein am Nachmittag, späten Alkohol und große späte Mahlzeiten (CDC 2024). Regelmäßigkeit sagt die Gesundheit mindestens so stark voraus wie die Dauer, und die Gesamtschlafzeit eines Wearables ist weit verlässlicher als seine Aufteilung in Tief-, Leicht- und REM-Schlaf.", + "referenceGuidance": "Allgemeine Orientierung, keine medizinische Beratung. Referenzbereiche sind Populationswerte, die sich mit Alter, Geschlecht und Erkrankungen verschieben – dein eigener Verlauf über die Zeit führt die Einordnung." + }, + "bpDerived": { + "pulsePressure": "Der Pulsdruck (die Differenz zwischen deinen Werten) beträgt etwa {value} mmHg – rund 40 ist in Ruhe typisch.", + "meanArterialPressure": "Der mittlere arterielle Druck (der Durchschnitt, den deine Organe sehen) liegt bei etwa {value} mmHg, normalerweise um 70–100.", + "caveat": "Diese Werte werden als allgemeine Orientierung aus deiner letzten Messung abgeleitet, nicht als Diagnose." }, "stats": { "label": "Zusammenfassende Statistik", @@ -2641,7 +2540,8 @@ "asleepTotal": "Geschlafen {value}", "inBedTotal": "Im Bett {value}", "awakenings": "{count} Wachphasen", - "source": "Quelle: {source}" + "source": "Quelle: {source}", + "estimateNote": "Die Phasen-Zeiten sind geschätzt — diese Quelle liefert Summen je Phase, keinen gemessenen Verlauf." }, "stages": { "deep": "Tief", @@ -2668,7 +2568,7 @@ "chronotype": { "title": "Chronotyp", "learning": "Dein Rhythmus wird noch erfasst — {counted} von {total} freien Nächten. Ein Chronotyp braucht ein paar weckerfreie Morgen, um aussagekräftig zu sein.", - "midSleepCaption": "Schlafmitte an freien Tagen gegen {clock}", + "midSleepCaption": "Deine natürliche Schlafmitte an freien Tagen liegt etwa um {clock}.", "advancedToggle": "Mehr Details", "socialJetlagValue": "{hours} Std. {minutes} Min. sozialer Jetlag", "socialJetlagCaption": "Der Unterschied zwischen deinem Schlaf an Arbeits- und freien Tagen — wie eine kleine wöchentliche Zeitzonenverschiebung.", @@ -2681,7 +2581,8 @@ "intermediate": "Mischtyp", "late": "Abendtyp", "extreme_late": "Ausgeprägter Abendtyp" - } + }, + "cornerLabel": "Chronotyp" }, "rhythm": { "loadError": "Dein Schlafrhythmus konnte gerade nicht geladen werden. Er erscheint wieder, sobald die Verbindung steht." @@ -2825,6 +2726,7 @@ "title": "Glukose-Überblick", "subtitle": "Ein Blick auf deine letzten {days} Tage, aus {count} Messungen.", "spotCaveat": "Das sind Schätzungen aus einzelnen Stichproben-Messungen, kein Profil eines kontinuierlichen Sensors – lies sie als Richtung, nicht als klinisches Urteil.", + "labsLink": "Laborergebnisse? HbA1c und Nüchternglukose aus einer Blutabnahme trägst du unter Labor ein.", "learningTitle": "Lerne deine Glukose noch kennen", "learningBody": "Bisher sind das {count} über {days}. Mit etwas mehr Verlauf wird das Bild klarer – mess weiter oder lass es ein verbundenes Gerät ergänzen.", "learningBodyEmpty": "Sobald ein paar Messungen vorliegen – von dir erfasst oder von einem Gerät synchronisiert – nehmen Zeit-im-Zielbereich, Durchschnitt und Schwankung hier Gestalt an.", @@ -3542,9 +3444,6 @@ "target": { "heading": "Ziel" }, - "disclaimer": { - "footer": "HealthLog beschreibt deine eigenen erfassten Daten — es ist keine klinische Bewertung und keine Diagnose. Sprich mit deiner Ärztin oder deinem Arzt über alles, was dich beunruhigt." - }, "settings": { "overviewTitle": "Übersicht anordnen", "overviewDescription": "Abschnitte ein- oder ausblenden und ihre Reihenfolge auf der Insights-Übersicht festlegen." @@ -3572,25 +3471,34 @@ }, "sleepQuality": { "title": "Schlafqualität", - "subtitle": "Nächtliche Werte deines Wearables" + "subtitle": "Nächtliche Werte deines Wearables", + "assessment": { + "metric": { + "SLEEP_SCORE": "dein Gesamt-Schlafscore", + "SLEEP_EFFICIENCY": "die Schlafeffizienz", + "SLEEP_PERFORMANCE": "die Schlafleistung", + "SLEEP_CONSISTENCY": "die Schlafkonstanz" + }, + "grade": { + "excellent": "ausgezeichnet", + "good": "solide", + "fair": "durchschnittlich", + "low": "eher niedrig" + }, + "clause": "Über die letzten 30 Nächte ist {metric} mit {value} {grade}.", + "closing": { + "excellent": "Behalte die Routine bei, die dich hierher bringt.", + "good": "Konstantere Zubett- und Aufstehzeiten sind meist der nächste Gewinn.", + "fair": "Ein konstantes Schlaffenster hebt diesen Wert erfahrungsgemäß am stärksten.", + "low": "Ein festes Herunterfahren am Abend und eine feste Aufstehzeit sind die ersten Hebel." + } + } }, "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" - }, "block": { "ansChargeExplainer": "Wie aufgeladen dein autonomes Nervensystem in den Tag startet – ein Recharge-Wert aus deinen nächtlichen Herz- und Atemsignalen.", "dayStrainExplainer": "Die kardiovaskuläre Tagesbelastung auf der Belastungsskala deines Geräts. Lies sie im Verhältnis zu deiner Erholung, nicht als Ziel für sich.", @@ -3608,8 +3516,6 @@ }, "thresholds": { "defaultLabel": "Default", - "autoModeLabel": "Auto", - "overrideModeLabel": "Überschrieben", "overrideToggleLabel": "Eigene Werte", "resetAction": "Auf Default zurücksetzen", "resetAllAction": "Alle auf Default zurücksetzen", @@ -3646,31 +3552,72 @@ "back": "Zurück", "skip": "Tour überspringen", "done": "Verstanden", - "restart": "Tour neu starten", - "restartHint": "Zeige die Dashboard-Tour beim nächsten Aufruf erneut.", - "restartConfirmation": "Die Tour startet bei deinem nächsten Dashboard-Besuch erneut.", + "restart": "Modul-Tour neu starten", + "restartHint": "Zeigt die geführte Modul-Tour beim nächsten Dashboard-Besuch von vorne.", + "restartConfirmation": "Die Modul-Tour startet bei deinem nächsten Dashboard-Besuch von vorne.", "steps": { - "tileStrip": { - "title": "Deine Tageswerte", - "body": "Die Kachel-Leiste zeigt den letzten Wert und den 7-Tage-Trend für jede Metrik — Gewicht, Blutdruck, Puls, Stimmung. Was du nicht brauchst, blendest du in Einstellungen → Dashboard aus." + "dashboardOverview": { + "title": "Dein Dashboard", + "body": "Das Dashboard bündelt alle Module auf einen Blick. Halte eine Kachel lange gedrückt, um sie neu anzuordnen oder auszublenden (Einstellungen → Dashboard-Layout). Jede Kachel ist nur so aussagekräftig wie die Daten dahinter — die folgenden Module füllen sie." }, "quickAdd": { - "title": "In zwei Taps erfasst", - "body": "Über den Hinzufügen-Button kannst du Messungen oder Stimmungseinträge anlegen, ohne das Dashboard zu verlassen. Das Formular merkt sich deinen letzten Kontext und Tags." + "title": "In Sekunden erfasst", + "body": "Über „+ Hinzufügen“ trägst du jeden Messwert in Sekunden ein — Gewicht, Blutdruck, Puls, Glukose. Versteckte Stärke: Werte lassen sich nachträglich datieren. Alles, was du hier einträgst, fließt in Trends, Health-Score und die KI-Insights." + }, + "measurements": { + "title": "Messwerte & Zielbereiche", + "body": "Messwerte zeigt Verlauf und Zielbereiche pro Metrik. Hidden Gem: Zielbereiche pro Wert definieren, damit Abweichungen farblich auffallen. Diese Reihen sind die Grundlage für Korrelationen im Coach und den Arztbericht." + }, + "medications": { + "title": "Medikamente & Einnahmen", + "body": "Medikamente verwaltet Zeitpläne, Einnahmen, Bestand und Titration. Versteckt: wiederkehrende Pläne (RRULE/rollierend) und Bestandswarnungen. Deine Einnahmequote speist die Compliance-Säule im Dashboard und den Kontext der KI." + }, + "labs": { + "title": "Laborwerte", + "body": "Labore erfasst Biomarker mit Referenzbereichen — Blutbild, Cholesterin, HbA1c. Stärke: Import und FHIR-Export. Auffällige Laborwerte tauchen als Belege in den Insights und im Arztbericht wieder auf." + }, + "illness": { + "title": "Krankheits-Journal", + "body": "Das Krankheits-Journal dokumentiert akute Episoden und chronische Diagnosen samt Schüben. Versteckt: die Korrelation einer Episode mit deinen Messwerten und der Ruhemodus, der Reminder pausiert. So versteht der Coach, warum Werte zeitweise abweichen." + }, + "vorsorge": { + "title": "Vorsorge", + "body": "Vorsorge erinnert an Untersuchungen und Impfungen mit eigenem Intervall. Tipp: „Erledigt“ kann direkt eine Messung oder ein Laborergebnis anlegen. Fällige Vorsorgen erscheinen als Reminder und im Arztbericht." + }, + "cycle": { + "title": "Zyklus", + "body": "Die Zyklus-Verfolgung protokolliert Phasen und Symptome. Versteckt: Symptome lassen sich Stimmung und Schlaf gegenüberstellen. Der Zyklus liefert Kontext für Schwankungen, die sonst unerklärlich wären." + }, + "mood": { + "title": "Stimmung", + "body": "Stimmung erfasst dein Befinden und Tags in zwei Sekunden. Stärke: eigene Tags. Stimmungsverläufe korrelieren mit Schlaf, Medikamenten und Zyklus — der Coach erkennt Muster." }, "insights": { - "title": "Automatische Einblicke", - "body": "Insights verwandelt deine Werte in eine klare Gesundheits-Zusammenfassung, mit Einordnung von Blutdruck, Gewicht und Stimmung. Eigener Provider-Key oder Standard — du entscheidest." + "title": "Insights", + "body": "Insights ist das Herzstück: belegbasierte, dynamische Auswertungen statt generischer Tipps — jede Aussage verweist auf deine echten Daten. Versteckt: das Layout ist anpassbar. Insights zieht alles zusammen, was die Module oben liefern." + }, + "coach": { + "title": "Coach", + "body": "Der Coach beantwortet Fragen zu deinen Daten im Dialog und nutzt Anamnese, Medikamente und Laborwerte als Kontext. Den KI-Anbieter wählst du selbst (eigener Schlüssel oder lokal). Er rechnet nichts neu — er liest dieselben Zahlen wie das Dashboard." }, "integrations": { - "title": "Geräte verbinden", - "body": "In den Einstellungen verbindest du Withings (Auto-Sync für Gewicht, Blutdruck, Puls), moodLog oder deinen Auswertungs-Provider. Alles bleibt optional und verschlüsselt." + "title": "Integrationen", + "body": "Integrationen synchronisieren Withings, WHOOP, Polar, Oura und Apple Health automatisch. So füllen sich Gewicht, Schlaf und Erholung ohne manuelles Tippen — und nähren wiederum Insights und Coach." + }, + "export": { + "title": "Arztbericht", + "body": "Der Arztbericht bündelt Messwerte, Medikamente, Laborwerte und Vorsorge als PDF oder FHIR für den Termin. Hier laufen alle Module zusammen — ein vollständiger Bericht entsteht nur bei gepflegten Daten." }, "achievements": { - "title": "Streaks und Badges sammeln", - "body": "Die Achievements-Seite trackt tägliche Logging-Streaks, Blutdruck-Meilensteine und Konsistenz-Abzeichen. Ein kleiner Anreiz, dranzubleiben." + "title": "Erfolge", + "body": "Erfolge belohnen Konsistenz — Streaks und Meilensteine. Ein kleiner Motivations-Loop, der das Dranbleiben leichter macht." + }, + "wrapUp": { + "title": "Das war die Tour", + "body": "Du kannst jedes Modul jederzeit einzeln erneut ansehen („Diese Tour zeigen“ auf jeder Seite) oder die komplette Tour unter Einstellungen → Erweitert neu starten." } - } + }, + "moduleTrigger": "Diese Tour zeigen" }, "shell": { "back": "Zurück", @@ -3741,13 +3688,20 @@ "learning": "Manche Einblicke – Glukose, Schlafrhythmus, Blutdrucktrends, Zyklus – schärfen sich in den ersten ein bis zwei Wochen, sobald sich deine Daten ansammeln. Leere Kacheln heißen jetzt nur, dass wir deine Basislinie noch lernen.", "connectCta": "Gerät verbinden", "logCta": "Ersten Wert eintragen", - "importCta": "Bestehende Daten importieren" + "importCta": "Bestehende Daten importieren", + "aiTitle": "KI-Einblicke aktivieren (optional)", + "aiBody": "Coach, Tagesbriefing und Einschätzungen fassen deine Werte in Klartext zusammen. Dafür verbindest du deinen eigenen KI-Schlüssel (OpenAI, Anthropic) oder ein lokales Modell — deine Daten bleiben bei dir.", + "aiCta": "KI-Anbieter verbinden" }, "welcomeBack": { "title": "Willkommen zurück", "body": "Das Onboarding hast du bereits abgeschlossen. Möchtest du etwas anpassen, findest du alles in den Einstellungen.", "cta": "Zum Dashboard" }, + "disclaimer": { + "acknowledge": "Mir ist klar: HealthLog ist ein privates Tracking-Tool, keine klinische Bewertung und keine Diagnose. Bei Sorgen wende ich mich an meine Ärztin oder meinen Arzt, nicht an diese App.", + "learnMore": "Mehr erfahren" + }, "welcome": { "title": "Willkommen bei HealthLog", "carouselLabel": "Begrüßungs-Karussell", @@ -3829,18 +3783,21 @@ "sections": { "account": { "title": "Konto", - "description": "Profil, Passwort, Passkeys und deine Onboarding-Tour." + "description": "Profil, Passwort, Passkeys und deine Onboarding-Tour.", + "subtitle": "Dein Profil, deine Identität und deine Anmeldedaten." }, "integrations": { "title": "Integrationen", - "description": "Verbundene Dienste, Zustellkanäle und die Quellen-Priorität an einem Ort." + "description": "Verbundene Dienste, Zustellkanäle und die Quellen-Priorität an einem Ort.", + "subtitle": "Datenquellen und Geräte verbinden und verwalten." }, "notifications": { "title": "Benachrichtigungen", "description": "Alle Erinnerungen an einem Ort — was wann erinnert wird. Die Zustellkanäle liegen unter Integrationen.", "channelsHint": "Telegram, ntfy, Web-Push, Webhook und E-Mail richtest du jetzt unter", "channelsHintLink": "Integrationen → Kanäle", - "inboxLink": "Posteingang öffnen" + "inboxLink": "Posteingang öffnen", + "subtitle": "Wähle, welche Erinnerungen du erhältst." }, "layout": { "title": "Layout", @@ -3853,15 +3810,18 @@ "insights": { "title": "Insights", "description": "Reihenfolge der Übersicht und der Navigations-Pills." - } + }, + "subtitle": "Passe an, wie Dashboard und Insights angeordnet sind." }, "dashboard": { "title": "Dashboard", - "description": "Kachel-Layout und Reihenfolge." + "description": "Kachel-Layout und Reihenfolge.", + "subtitle": "Ordne die Kacheln auf deinem Dashboard an." }, "thresholds": { "title": "Zielwerte", - "description": "Pro Metrik dein eigener Zielbereich für gesunde Werte." + "description": "Pro Metrik dein eigener Zielbereich für gesunde Werte.", + "subtitle": "Lege deine persönlichen Zielbereiche je Messgröße fest." }, "sources": { "title": "Quellen-Priorität", @@ -3920,19 +3880,23 @@ "scale": "Waage", "other": "Sonstige", "unknown": "Unbekannt" - } + }, + "subtitle": "Lege fest, welche Quelle gewinnt, wenn zwei dieselbe Messgröße liefern." }, "ai": { - "title": "KI-Auswertungen", - "description": "Provider, Modell und API-Key für die Auswertungen — dazu dein „Über mich“-Kontext." + "title": "KI-Anbieter", + "description": "Provider, Modell und API-Key für die Auswertungen — dazu dein „Über mich“-Kontext.", + "subtitle": "Wähle deinen KI-Anbieter und hinterlege deinen eigenen Schlüssel." }, "api": { "title": "API & Tokens", - "description": "Bearer-Tokens für eigene Skripte und Apps — erfasse Messwerte oder Einnahmen von überall." + "description": "Bearer-Tokens für eigene Skripte und Apps — erfasse Messwerte oder Einnahmen von überall.", + "subtitle": "API-Endpunkte und Zugriffstoken verwalten." }, "advanced": { "title": "Erweitert", - "description": "Opt-in-Schalter für den Forschungsmodus sowie der Notausgang zum unwiderruflichen Löschen aller Gesundheitsdaten." + "description": "Opt-in-Schalter für den Forschungsmodus sowie der Notausgang zum unwiderruflichen Löschen aller Gesundheitsdaten.", + "subtitle": "Research-Modus, Daten zurücksetzen und Konto löschen." }, "export": { "title": "Export & Import", @@ -4033,27 +3997,33 @@ "resultSummary": "{inserted} neu importiert und {updated} aktualisiert, {skipped} übersprungen.", "rowError": "Zeile {line}: {reason}" } - } + }, + "subtitle": "Exportiere deine Daten oder importiere aus einer Sicherung." }, "about": { "title": "Über diese App", - "description": "Version, Lizenz, Links." + "description": "Version, Lizenz, Links.", + "subtitle": "Version, Lizenz und Projektlinks." }, "sharing": { "title": "Freigabe", - "description": "Erstelle einen schreibgeschützten Link, um deine Gesundheitsakte mit einer Ärztin oder einem Arzt zu teilen." + "description": "Erstelle einen schreibgeschützten Link, um deine Gesundheitsakte mit einer Ärztin oder einem Arzt zu teilen.", + "subtitle": "Teile schreibgeschützten Zugriff auf deine Daten." }, "insights": { "title": "Insights", - "description": "Reihenfolge der Übersicht und der Navigations-Pills." + "description": "Reihenfolge der Übersicht und der Navigations-Pills.", + "subtitle": "Ordne die Übersichts-Bereiche und die Reihenfolge der Pills an." }, "medications": { "title": "Medikamente", - "description": "Listenansicht, Reihenfolge und Injektionsstellen deiner Medikamente." + "description": "Listenansicht, Reihenfolge und Injektionsstellen deiner Medikamente.", + "subtitle": "Verwalte Ansicht und Reihenfolge deiner Medikamentenliste." }, "mood": { "title": "Stimmungs-Tags", - "description": "Gruppen, Tags und archivierte Tags der Stimmungs-Auswahl." + "description": "Gruppen, Tags und archivierte Tags der Stimmungs-Auswahl.", + "subtitle": "Organisiere deine Stimmungs-Tags, Gruppen und Auswahlreihenfolge." }, "modules": { "title": "Module", @@ -4063,26 +4033,24 @@ "title": "Was du trackst", "description": "Optionale Bereiche. Deaktivierte Module verschwinden aus Navigation, Kacheln und Auswertungen — deine Daten bleiben erhalten." }, - "core": { - "title": "Immer aktiv", - "description": "Die Grundlagen von HealthLog. Diese Bereiche lassen sich nicht abschalten.", - "note": "Kernbereich — immer aktiv" - }, - "description": "Lege fest, was HealthLog für dich erfasst. Schalte einzelne Bereiche ein oder aus — die Navigation, Kacheln und Auswertungen passen sich sofort an.", "manageIn": "In {section} verwalten", - "operatorDisabled": "Serverweit deaktiviert" + "operatorDisabled": "Serverweit deaktiviert", + "subtitle": "Lege fest, welche Tracking-Bereiche HealthLog überall anzeigt." }, "gesundheitsakte": { "title": "Gesundheitsakte", - "description": "Vollständige Gesundheitsakte als PDF, FHIR R4 oder kombiniertes ZIP-Paket exportieren." + "description": "Vollständige Gesundheitsakte als PDF, FHIR R4 oder kombiniertes ZIP-Paket exportieren.", + "subtitle": "Exportiere eine vollständige, arzttaugliche Gesundheitsakte." }, "coach": { "title": "Coach", - "description": "Coach aktivieren, Ton und Umfang, gespeicherte Erinnerungen." + "description": "Coach aktivieren, Ton und Umfang, gespeicherte Erinnerungen.", + "subtitle": "Stelle den Coach, seine proaktiven Hinweise und sein Gedächtnis ein." }, "channels": { "title": "Kanäle", - "description": "Zustellung über Telegram, ntfy, Web-Push, Webhook und E-Mail." + "description": "Zustellung über Telegram, ntfy, Web-Push, Webhook und E-Mail.", + "subtitle": "Wohin Erinnerungen und Hinweise zugestellt werden." } }, "profile": "Profil", @@ -4579,9 +4547,7 @@ "changelog": "Changelog", "docs": "Dokumentation", "linksHeading": "Quellen & Dokumentation", - "newerAvailable": "Neue Version: {tag}", - "tourReplay": "Tour ansehen", - "tourReplayHint": "Zeige die Dashboard-Tour — praktisch, falls du sie beim ersten Besuch übersprungen hast." + "newerAvailable": "Neue Version: {tag}" }, "testConnection": { "test": "Verbindung testen", @@ -4964,7 +4930,6 @@ "modules": { "title": "Modulverfügbarkeit", "description": "Schalte ein Modul für den gesamten Server ab. Ein hier deaktiviertes Modul ist für jedes Konto aus – unabhängig von den persönlichen Einstellungen. Kerndaten (Gewicht, Blutdruck, Puls, Medikamente) sind immer aktiv und lassen sich nicht abschalten.", - "disabledHint": "Serverweit deaktiviert", "saveError": "Modulverfügbarkeit konnte nicht aktualisiert werden." }, "bugReportRepo": "Repository", @@ -5343,11 +5308,14 @@ }, "about": { "title": "Über", - "subtitle": "Version, Lizenz, Quellcode und Update-Prüfung." + "subtitle": "Version, Lizenz und Update-Status." + }, + "module-availability": { + "title": "Modulverfügbarkeit", + "subtitle": "Module für jedes Konto auf diesem Server aktivieren oder deaktivieren." } }, "carrier": "Anbieter", - "carrierUnknown": "Unbekannter Anbieter", "carrierUnavailableGeoFallback": "{location} — Anbieter nicht verfügbar" }, "achievements": { @@ -5679,7 +5647,6 @@ "title": "Benachrichtigungen", "breadcrumbInbox": "Benachrichtigungen", "breadcrumbChannels": "Benachrichtigungs-Kanäle", - "breadcrumbSettings": "Einstellungen", "eventColumn": "Ereignis", "subtitle": "Wähle aus, welche Benachrichtigungen du über welchen Kanal erhalten möchtest.", "loadError": "Benachrichtigungseinstellungen konnten nicht geladen werden.", @@ -5796,7 +5763,6 @@ "dotNearRange": "nahe am Bereich", "dotOutOfRange": "außerhalb", "dotNotLogged": "nicht erfasst", - "insufficientData": "Noch zu wenige Daten — bitte weiter erfassen.", "relativeDay": { "today": "heute", "yesterday": "gestern", @@ -5808,7 +5774,6 @@ } }, "edit": { - "openLabel": "Zielbereich für {metric} bearbeiten", "title": "Zielbereich bearbeiten — {metric}", "description": "Passt den Zielbereich an, der in Charts, Insights und im Arztbericht verwendet wird. Werte in {unit}.", "derivedHint": "Diese Karte wird aus anderen Metriken abgeleitet. Bearbeite stattdessen die zugrundeliegenden Ziele (z. B. Blutdruck oder Gewicht).", @@ -5824,13 +5789,6 @@ "saveError": "Zielbereich konnte nicht gespeichert werden", "resetSuccess": "Zielbereich auf Standard zurückgesetzt" }, - "mood": { - "stability": { - "stable": "stabil", - "variable": "schwankend", - "highlyVariable": "sehr schwankend" - } - }, "currentValue": "Aktuell: {value} {unit}", "targetRangeValue": "Zielbereich: {min}–{max} {unit}", "sourceLabel": "Quelle: {source}", @@ -5936,7 +5894,6 @@ }, "lowStockReminders": { "title": "Vorrat niedrig: {medName}", - "body": "Dein Vorrat an {medName} reicht noch etwa {days} Tage ({units} Einheiten übrig).", "bodyToday": "Dein Vorrat an {medName} reicht keinen ganzen Tag mehr ({units} Einheiten übrig).", "bodyReorder": "Dein Vorrat an {medName} ({units} Einheiten) ist um den {runsOutOn} aufgebraucht — bestelle bis zum {reorderBy} nach.", "bodyLastDose": "Dein Vorrat an {medName} ({units} Einheiten) reicht noch etwa für die letzte Dosis. Aufgebraucht um den {runsOutOn}." @@ -6044,7 +6001,6 @@ "description": "Wie sich jeder Messwert zwischen deiner Luteal- und Follikelphase vergleicht – auf deinen eigenen Daten.", "pairLabel": "{metric} · Luteal vs. Follikel", "detail": "Luteal {lutealAvg} {unit} ({lutealDays} Tage) vs. Follikel {follicularAvg} {unit} ({follicularDays} Tage).", - "footer": "Das sind Zusammenhänge in deinen eigenen Daten, keine Ursachen. Nur Muster, die einen Signifikanztest bestehen, werden gezeigt.", "metricRestingHeartRate": "Ruhepuls", "metricHrv": "Herzfrequenzvariabilität", "metricSleepDuration": "Schlafdauer", @@ -6068,10 +6024,6 @@ "confidenceHigh": "Hohe Sicherheit" }, "title": "Zyklus-Erkenntnisse", - "empty": "Phasen-Erkenntnisse erscheinen hier, sobald du genug Zyklen erfasst hast. Sie vergleichen deine Werte über die Zyklusphasen hinweg, mit offen gezeigter Statistik.", - "sampleSize": "n = {n}", - "effectSize": "Effektstärke {value}", - "qValue": "q = {value}", "symptomPatternsTitle": "Symptommuster nach Phase", "symptomPatternsEmpty": "Erfasse Symptome über einen Zyklus, um zu sehen, in welcher Phase sie sich häufen.", "symptomMostlyIn": "Meist in {phase} ({count}/{total} Tage)" @@ -6091,7 +6043,6 @@ "LUTEAL": "Das Progesteron führt durch die zweite Zyklushälfte, und die Basaltemperatur liegt meist etwas höher als zuvor im Zyklus. Manche bemerken im Lauf der Tage Veränderungen bei Schlaf, Appetit, Stimmung oder Energie, während andere durchgehend ausgeglichen bleiben. Das Muster ist persönlich und sieht von Monat zu Monat oft anders aus." }, "commonHere": "Das bemerkst du hier oft", - "trackNudge": "Heute eintragen", "stillLearning": "Lerne deinen Zyklus noch kennen. Trage weiter ein, dann füllt sich das mit Mustern aus deinen eigenen Tagen." }, "insightsSummary": { @@ -6184,10 +6135,7 @@ "prevMonth": "Vorheriger Monat", "nextMonth": "Nächster Monat", "loading": "Kalender wird geladen…", - "empty": "Erfasse Daten, damit sich dein Kalender füllt.", "learning": "Wir lernen deinen Zyklus – erfasse mindestens drei volle Zyklen, dann füllen sich fruchtbares Fenster, Eisprung und Phasen. Bis dahin zeigt der Kalender nur, was du erfasst hast.", - "today": "Heute", - "dayAria": "{date}", "legendSymptom": "Symptome", "legendOvulationConfirmed": "Bestätigter Eisprung", "flowSpotting": "Schmierblutung", @@ -6198,11 +6146,9 @@ "predictions": { "title": "Was als Nächstes kommt", "nextPeriod": "Nächste Periode", - "nextPeriodRange": "Etwa am {date}", "nextPeriodWindow": "{low} – {high}", "fertileWindow": "Fruchtbares Fenster", "ovulation": "Geschätzter Eisprung", - "confidence": "Sicherheit", "confidenceHigh": "Hoch", "confidenceMedium": "Mittel", "confidenceLow": "Niedrig", @@ -6238,7 +6184,6 @@ "sheet": { "title": "Tag erfassen", "description": "Erfasse Stärke, Symptome und Notizen von heute.", - "date": "Datum", "flow": "Stärke", "symptoms": "Symptome", "temperature": "Basaltemperatur", @@ -6258,10 +6203,7 @@ "save": "Speichern", "saving": "Speichern…", "cancel": "Abbrechen", - "saved": "Tag gespeichert", "saveError": "Konnte nicht gespeichert werden. Versuch es erneut.", - "periodStarted": "Periodenbeginn erfasst", - "clear": "Keine", "intermenstrualBleeding": "Schmierblutung zwischen Perioden", "pregnancyTest": "Schwangerschaftstest", "progesteroneTest": "Progesterontest", @@ -6277,8 +6219,6 @@ }, "settings": { "title": "Zyklus-Einstellungen", - "enable": "Zyklus-Tracking", - "enableDescription": "Schalte den Zyklus-Tab für dein Konto ein oder aus.", "typicalCycleLength": "Typische Zykluslänge (Tage)", "typicalPeriodLength": "Typische Periodenlänge (Tage)", "lutealPhaseLength": "Länge der Lutealphase (Tage)", @@ -6297,30 +6237,14 @@ "encryptionDescription": "Verschlüssele absichtsverratende Felder (Schwangerschaftstests, Verhütung, Geschlechtsverkehr) ruhend, zusätzlich zu den stets verschlüsselten Notizen.", "discreet": "Diskrete Benachrichtigungen", "discreetDescription": "Verbirg Zyklusdetails im Benachrichtigungstext auf deinem Sperrbildschirm.", - "dataTitle": "Deine Daten", - "dataDescription": "Deine Zyklusdaten liegen nur auf diesem Server. Exportiere sie jederzeit oder lösche sie endgültig.", - "export": "Meine Zyklusdaten exportieren", - "deleteData": "Meine Zyklusdaten löschen", "saved": "Einstellungen gespeichert", "saveError": "Konnte nicht gespeichert werden. Versuch es erneut.", "save": "Speichern", - "purgeTitle": "Alle Zyklusdaten löschen", - "purgeDescription": "Entfernt dauerhaft jeden erfassten Tag, Zyklus, jede Vorhersage und jeden Erinnerungseintrag. Kann nicht rückgängig gemacht werden.", - "purgeButton": "Alle Zyklusdaten löschen", - "purgeConfirmTitle": "Alle Zyklusdaten löschen?", - "purgeConfirmBody": "Dies löscht dauerhaft jede Periode, jeden Tageseintrag, jedes Symptom und jede Vorhersage. Dies kann nicht rückgängig gemacht werden.", - "purgeConfirm": "Alle Zyklusdaten dauerhaft löschen?", - "purgeCancel": "Abbrechen", - "purgeSuccess": "Alle Zyklusdaten gelöscht.", - "purgeError": "Zyklusdaten konnten nicht gelöscht werden.", "reminders": "Erinnerungen", "remindersDescription": "Perioden-Erinnerungen pro Kanal in den Benachrichtigungseinstellungen aktivieren.", "remindersLink": "Benachrichtigungseinstellungen", "fertileReminder": "Erinnerung an das fruchtbare Fenster", - "fertileReminderDescription": "Erhalte einige Tage vor deinem geschätzten fruchtbaren Fenster einen Hinweis. Standardmäßig aus – aktiviere sie pro Kanal in den Benachrichtigungseinstellungen.", - "purgeData": "Zyklusdaten löschen", - "purgeConfirmAction": "Ja, alles löschen", - "purgeDone": "Zyklusdaten gelöscht." + "fertileReminderDescription": "Erhalte einige Tage vor deinem geschätzten fruchtbaren Fenster einen Hinweis. Standardmäßig aus – aktiviere sie pro Kanal in den Benachrichtigungseinstellungen." }, "testResult": { "POSITIVE": "Positiv", @@ -6472,17 +6396,29 @@ "pushLocation": "(bei {location})", "edit": "Bearbeiten", "originCoach": "Coach", - "enabledToggleAria": "Diese Erinnerung aktivieren oder deaktivieren", "until": "Bis {date}", "selfPlanned": "Selbstgeplant", "captureValue": "Wert erfassen", + "measureNow": "Jetzt messen", + "nextDueLabel": "Nächste Fälligkeit:", + "lastDoneLabel": "Zuletzt erledigt:", + "customize": "Vorsorge anpassen", + "adherence": { + "label": "Im Intervall", + "onTrack": "Im Plan", + "due": "Jetzt fällig" + }, + "settings": { + "title": "Vorsorge anpassen", + "description": "Ansicht, Reihenfolge und einzelne Erinnerungen verwalten.", + "back": "Zurück zur Vorsorge" + }, "capture": { "title": "Messwert erfassen", "description": "Wert eintragen; die Erinnerung wird als erledigt markiert." }, "manage": { - "menuItem": "Anpassen", - "title": "Erinnerungen anpassen", + "title": "Erinnerungen verwalten", "description": "Einzelne Erinnerungen aktivieren oder deaktivieren.", "empty": "Noch keine Erinnerungen zum Anpassen." }, @@ -6491,6 +6427,17 @@ "openLink": "Vorsorge öffnen" } }, + "moduleList": { + "viewToggleLabel": "Ansicht", + "viewCards": "Kartenansicht", + "viewList": "Listenansicht", + "viewHeading": "Ansicht", + "viewDescription": "Lege fest, ob die Einträge als Karten oder als kompakte Liste angezeigt werden.", + "reorder": { + "heading": "Reihenfolge", + "empty": "Noch keine Einträge zum Sortieren." + } + }, "labs": { "title": "Laborwerte", "subtitle": "Blutwerte und Biomarker", @@ -6501,36 +6448,36 @@ "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} Messungen", - "deleteLatest": "Letzten Wert löschen", "deleteConfirmTitle": "Laborwert löschen?", "deleteConfirmDescription": "Dieser Laborwert wird dauerhaft gelöscht.", "deletedToast": "Wert gelöscht", "deleteError": "Wert konnte nicht gelöscht werden", "loadError": "Laborwerte konnten nicht geladen werden", + "customize": "Laborwerte anpassen", + "settings": { + "title": "Laborwerte anpassen", + "description": "Ansicht, Sortierung und Biomarker verwalten.", + "back": "Zurück zu den Laborwerten" + }, + "sort": { + "heading": "Sortierung", + "label": "Reihenfolge der Werte", + "recentDesc": "Neueste Messung zuerst", + "recentAsc": "Älteste Messung zuerst", + "manual": "Manuell sortieren", + "hint": "Bei manueller Sortierung kannst du die Biomarker unten in deine eigene Reihenfolge bringen." + }, "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": "Bitte einen Wert eingeben.", - "rangeOrderError": "Die untere Referenz darf nicht über der oberen liegen.", "save": "Speichern", "savedToast": "Wert gespeichert", "saveError": "Wert konnte nicht gespeichert werden", @@ -6567,9 +6514,7 @@ } }, "biomarker": { - "manage": "Biomarker", "manageTitle": "Biomarker", - "manageSheetDescription": "Lege die Marker an, die du verfolgst. Jeder wird einmal mit Einheit und Referenzbereich eingerichtet.", "managerDescription": "Marker einmal definieren — Einheit und Bereich gelten für jede Messung.", "define": "Anlegen", "defineFirst": "Ersten Biomarker anlegen", @@ -6699,18 +6644,6 @@ "label": "Arztbericht", "description": "PDF- und FHIR-Export für deine Praxis." }, - "weight": { - "label": "Gewicht", - "description": "Gewicht und Körperzusammensetzung." - }, - "bloodPressure": { - "label": "Blutdruck", - "description": "Systolisch und diastolisch." - }, - "pulse": { - "label": "Puls", - "description": "Ruhepuls und Herzfrequenz." - }, "medications": { "label": "Medikamente", "description": "Pläne, Einnahmen und Compliance." @@ -6723,7 +6656,12 @@ "illness": { "title": "Krankheitstagebuch", "subtitle": "Ein rückblickendes Protokoll deiner Krankheits- und Beschwerde-Episoden.", - "disclaimer": "Ein persönliches Tagebuch — kein Medizinprodukt. Es stellt keine Diagnose und keine Prognose.", + "customize": "Krankheitstagebuch anpassen", + "settings": { + "title": "Krankheitstagebuch anpassen", + "description": "Ansicht und Reihenfolge der Episoden verwalten.", + "back": "Zurück zum Krankheitstagebuch" + }, "newEpisode": "Neue Episode", "logDay": "Tag erfassen", "markRecovered": "Als genesen markieren", @@ -6823,7 +6761,6 @@ "countOther": "Du hast in diesem Zeitraum {count} Episoden erfasst.", "typicalGap": "Typische Erholungslücke: {days} Tage zwischen Besserung und normalisierten Werten.", "typicalGapLearning": "Noch ein paar abgeschlossene Episoden und hier erscheint eine typische Erholungslücke.", - "byMonth": "Wann du Episoden erfasst", "none": "In diesem Zeitraum keine Episoden erfasst." }, "section": { @@ -6893,5 +6830,24 @@ "reminderSuggestionsLabel": "Kadenz-Vorschläge", "reminderSuggestionsDescription": "Lass den Coach gelegentlich eine evidenzbasierte Erinnerung anbieten, eine Metrik regelmäßiger zu messen." } + }, + "safety": { + "floor": { + "bpHighTitle": "Sehr hoher Blutdruck", + "bpHighDoctor": "Zwei aktuelle Messungen lagen bei {systolic}/{diastolic} mmHg oder höher. Ruhe dich aus, miss in einigen Minuten erneut und kontaktiere möglichst bald deine Ärztin oder deinen Arzt, wenn es so hoch bleibt.", + "bpHighEmergency": "Zwei aktuelle Messungen lagen bei {systolic}/{diastolic} mmHg oder höher und du hast Beschwerden angegeben. Bei Brustschmerz, Atemnot, Schwäche oder Seh- oder Sprachstörungen sofort den Notruf wählen.", + "bpLowTitle": "Niedriger Blutdruck", + "bpLowDoctor": "Zwei aktuelle Messungen hatten einen systolischen Wert unter {systolic} mmHg. Wenn dir schwindelig oder schummrig ist, setz oder leg dich hin und kontaktiere deine Ärztin oder deinen Arzt, wenn es anhält.", + "bpLowEmergency": "Zwei aktuelle Messungen hatten einen systolischen Wert unter {systolic} mmHg und du hast Beschwerden angegeben. Bei Ohnmachtsgefühl, Verwirrtheit oder kalter, feuchter Haut sofort den Notruf wählen.", + "glucoseLowTitle": "Niedriger Blutzucker", + "glucoseLowDoctor": "Zwei aktuelle Messungen lagen unter 70 mg/dL ({value} mg/dL). Nimm schnell wirkende Kohlenhydrate, miss in 15 Minuten erneut und kontaktiere deine Ärztin oder deinen Arzt, wenn die Tiefs anhalten.", + "glucoseLowEmergency": "Zwei aktuelle Messungen lagen unter 70 mg/dL ({value} mg/dL) und du hast Beschwerden angegeben. Behandle jetzt mit schnellem Zucker; wenn das nicht geht oder dir sehr unwohl ist, wähle den Notruf.", + "glucoseLowSevereTitle": "Sehr niedriger Blutzucker", + "glucoseLowSevereDoctor": "Zwei aktuelle Messungen lagen unter 54 mg/dL ({value} mg/dL). Behandle sofort mit schnell wirkenden Kohlenhydraten, miss in 15 Minuten erneut und kontaktiere deine Ärztin oder deinen Arzt.", + "glucoseLowSevereEmergency": "Zwei aktuelle Messungen lagen unter 54 mg/dL ({value} mg/dL) und du hast Beschwerden angegeben. Behandle jetzt; wenn du nichts bei dir behalten kannst oder dir sehr unwohl ist, wähle den Notruf.", + "glucoseHighTitle": "Sehr hoher Blutzucker", + "glucoseHighDoctor": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher. Miss erneut, trink ausreichend und kontaktiere möglichst bald deine Ärztin oder deinen Arzt, wenn es so hoch bleibt.", + "glucoseHighEmergency": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher und du hast Beschwerden angegeben. Bei Erbrechen, schneller Atmung oder Verwirrtheit sofort den Notruf wählen." + } } } diff --git a/messages/en.json b/messages/en.json index 9285cc541..2dd3fb4f3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -18,7 +18,6 @@ "optional": "Optional", "active": "Active", "inactive": "Inactive", - "enabled": "Enabled", "disabled": "Disabled", "networkError": "Network error", "unknownError": "Unknown error", @@ -74,7 +73,6 @@ "colTaken": "Taken", "colSkipped": "Skipped", "colMissed": "Missed", - "colTotal": "Total", "colExpected": "Expected", "colComplianceRate": "Adherence rate", "moodTitle": "Mood trajectory", @@ -191,7 +189,8 @@ "backToDashboard": "Back to dashboard" }, "offlineBanner": { - "message": "No connection — your changes will save once you're back online." + "message": "No connection — your changes will save once you're back online.", + "showingCached": "Showing your last synced data." }, "demoBanner": { "message": "Demo mode — changes are not saved." @@ -201,14 +200,12 @@ "measurements": "Measurements", "medications": "Medications", "insights": "Insights", - "targets": "Targets", "achievements": "Achievements", "settings": "Settings", "bugreport": "Bug Report", "admin": "Admin Console", "logout": "Sign out", "login": "Sign in", - "about": "About HealthLog", "notifications": "Notifications", "theme": "Appearance", "themeSystem": "System", @@ -218,13 +215,11 @@ "userMenu": "User menu", "accountSettings": "Account settings", "mood": "Mood", - "workouts": "Workouts", "coach": "Coach", "skipToContent": "Skip to content", "sidebar": "Sidebar", "mainNavigation": "Main navigation", "mobileNavigation": "Mobile navigation", - "breadcrumb": "Breadcrumb navigation", "loadingScreen": "Loading…", "more": "More", "moreSheetTitle": "More", @@ -241,7 +236,6 @@ }, "cycle": "Cycle", "labs": "Labs", - "recovery": "Recovery", "vorsorge": "Preventive Care", "illness": "Illness" }, @@ -329,7 +323,6 @@ "sleepShort": "Sleep", "stepsShort": "Steps", "vo2MaxShort": "VO₂ max", - "bpInTargetShort": "BP in Target", "bpInTargetWindow90": "BP in Target · 90d", "bpInTargetWindowDynamic": "BP in Target · {days}d", "bpInTargetCollecting": "collecting data", @@ -383,7 +376,6 @@ "staleHintWeeksOther": "Last reading {count} weeks ago", "staleHintMonthsOne": "Last reading 1 month ago", "staleHintMonthsOther": "Last reading {count} months ago", - "coachCta": "Ask the coach", "recentWorkouts": { "title": "Recent workouts", "viewAll": "View all", @@ -422,7 +414,7 @@ }, "measurements": { "title": "Measurements", - "subtitle": "Weight, blood pressure, pulse and more", + "subtitle": "Log readings and browse the full history. Trends and analysis live under Insights.", "addMeasurement": "Add", "editMeasurement": "Edit measurement", "type": "Type", @@ -498,7 +490,6 @@ "invalidTimestamp": "Please select a valid time.", "loginRequired": "Please sign in to view measurements.", "allTypes": "All types", - "filterByType": "Filter by type", "measurementCount": "{count} measurements", "emptyTitle": "No measurements yet", "emptyDescription": "Log your weight, blood pressure, pulse, or other vitals to start tracking trends.", @@ -549,7 +540,6 @@ "typeCardioLoad": "Cardio load", "typeSleepScore": "Sleep score", "typeBodyTemperatureDeviation": "Body temperature deviation", - "filterBySource": "Filter by source", "sourceComputed": "Computed", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", @@ -669,7 +659,6 @@ "factorSadness": "sadness", "praise": "praise" }, - "filterBySource": "Filter by source", "sourceManual": "Manual", "sourceWeb": "Web", "sourceTelegram": "Telegram", @@ -740,8 +729,6 @@ "subtitle": "Medications, schedules & intake", "loginRequired": "Please sign in to manage medications.", "addMedication": "Add", - "editMedication": "Edit medication", - "newMedication": "New medication", "firstMedication": "Add first medication", "addChoice": { "logIntake": "Log an intake", @@ -777,16 +764,9 @@ "loadFailed": "Could not load medications", "retryLoad": "Retry", "name": "Name", - "dose": "Dose", - "scheduleFrom": "From", - "scheduleTo": "To", - "scheduleLabel": "Label", - "scheduleDays": "Days", - "scheduleDaily": "Daily", "inactive": "Paused", "intakeHistory": "Intake history", "openDetailPage": "Open medication detail page", - "deleteConfirm": "Delete medication?", "compliance": "Adherence", "importIntakes": "Import intakes", "importDescription": "Paste a JSON array with intake data. Expected format:", @@ -801,19 +781,7 @@ "importInvalidSkipped": "{count} invalid entries skipped", "importFailed": "Import failed", "importInvalidFormat": "Invalid JSON format", - "apiEndpointTitle": "API Endpoint: {name}", - "apiEndpointDescription": "Use this endpoint to record intakes via API (e.g., iPhone Shortcut or cron job).", - "apiEndpointActive": "API endpoint active", - "apiEndpointActivated": "API endpoint activated", - "apiEndpointDeactivated": "API endpoint deactivated", - "apiTokenCount": "Active ({count} tokens)", - "apiToken": "API Token", - "apiTokenActiveHint": "Endpoint is active. Existing tokens remain valid but cannot be shown in plain text again.", - "apiTokenActivateHint": "Activate the endpoint to create a new token.", - "apiTokenOnceHint": "The token is only shown in plain text once at creation.", "requestExample": "Request example", - "statusLoadFailed": "Could not load status", - "changeFailed": "Change failed", "categoryBloodPressure": "Blood Pressure", "categoryVitamin": "Vitamins", "categoryThyroid": "Thyroid", @@ -846,21 +814,15 @@ "lastDoseDaysAgo": "Last dose {count} days ago", "overdue": "Overdue", "veryOverdue": "Very overdue", - "compliance7d": "Adherence (7 days)", - "compliance30d": "Adherence (30 days)", "complianceWindow": "Adherence ({days} days)", "complianceUnavailable": "Adherence could not be loaded", "complianceInfo": "Share of expected doses you logged. Weekly schedules count week by week.", "complianceInfoLabel": "What does adherence mean?", - "complianceDoses": "{count} doses", "lastIntake": "Last intake:", "nextIntake": "Next intake:", "nextIntakeOverdue": "Overdue · {time} — can still be taken", "dayStreak": "day streak", "dayStreakOne": "day streak", - "cycleNextDoseToday": "Next dose today", - "cycleNextDoseTomorrow": "Next dose tomorrow", - "cycleNextDoseInDays": "Next dose in {days} days", "cycleDueToday": "Due today", "cycleOverdue": "Overdue", "cycleNoClosedCycles": "No closed dose cycles yet", @@ -872,40 +834,10 @@ "intakeUndo": "Undo", "intakeUndone": "Entry reverted", "intakeUndoFailed": "Could not undo — open the history to fix it", - "weekdaySunday": "Sunday", - "weekdayMonday": "Monday", - "weekdayTuesday": "Tuesday", - "weekdayWednesday": "Wednesday", - "weekdayThursday": "Thursday", - "weekdayFriday": "Friday", - "weekdaySaturday": "Saturday", - "weekdaySundayPlural": "Sundays", - "weekdayMondayPlural": "Mondays", - "weekdayTuesdayPlural": "Tuesdays", - "weekdayWednesdayPlural": "Wednesdays", - "weekdayThursdayPlural": "Thursdays", - "weekdayFridayPlural": "Fridays", - "weekdaySaturdayPlural": "Saturdays", - "formType": "Type", - "formDose": "Dose", - "formUnit": "Unit", - "formSchedule": "Schedule", - "formTreatmentClass": "Treatment class", - "formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker and the GLP-1 logbook below.", - "treatmentClassGeneric": "Standard medication", - "treatmentClassGlp1": "GLP-1 injection", - "glp1WeeklyPresetTitle": "Once weekly on …", - "glp1WeeklyPresetHelp": "Most GLP-1 receptor agonists are once-weekly. Tap a day to set the cadence.", - "glp1WeeklyPresetSuggest": "Use my last weekday choice", - "glp1DosesPerUnit": "Doses per pen / vial", - "glp1DosesPerUnitPlaceholder": "e.g. 4 for a Mounjaro KwikPen", - "glp1DosesPerUnitHelp": "Optional — Mounjaro KwikPen = 4, Ozempic 0.25/0.5 mg = 4, Wegovy single-dose pen = 1. Leave blank if you don't track per-pen dosing.", "glp1LastInjection": "{label}", - "glp1LastInjectionWithSite": "{label} · {site}", "glp1NextInjectionDays": "{label} (in {days} days)", "glp1NextInjectionToday": "today", "glp1NextInjectionTomorrow": "tomorrow", - "glp1RotationLast": "Last site: {site}", "glp1RotationSuggested": "Recommended next: {site}", "glp1LogSideEffect": "Log side effect", "sideEffects": { @@ -1260,8 +1192,7 @@ "add": "Add another schedule", "edit": "Edit", "remove": "Remove", - "removeDisabled": "At least one schedule is required.", - "empty": "No schedule yet." + "removeDisabled": "At least one schedule is required." } }, "errors": { @@ -1305,38 +1236,6 @@ "sideEffectTagAppetiteLoss": "appetite-loss", "sideEffectTagHeartburn": "heartburn", "sideEffectTagHeadache": "headache", - "newSchedule": "New schedule", - "removeSchedule": "Remove schedule", - "advancedEditing": "Advanced editing", - "advancedEditingHide": "Hide advanced editing", - "scheduleInterval": "Interval", - "scheduleEveryWeek": "every week", - "scheduleEveryNWeeks": "every {n} weeks", - "scheduleIntervalPrefix": "Interval:", - "nextSchedulePrefix": "Next schedule:", - "nextScheduleDaily": "daily", - "importIntakesAction": "Import intakes", - "apiEndpointAction": "API endpoint", - "pause": "Pause", - "activate": "Activate", - "disableNotifications": "Disable notifications", - "enableNotifications": "Enable notifications", - "purgeRecords": "Delete all records", - "formReset": "Reset form", - "createMedication": "Create medication", - "deleteConfirmDescription": "The medication and all intake history will be permanently deleted.", - "purgeConfirmTitle": "Delete all records?", - "purgeConfirmDescription": "All intake records for this medication will be permanently deleted. The medication itself will remain and start without history data.", - "saveError": "Error saving", - "deleteError": "Error deleting", - "purgeError": "Error deleting records", - "invalidTimestamp": "Please select a valid time.", - "noIntakesYet": "No intakes recorded yet", - "actions": "Actions", - "pageInfo": "Page {page} of {total}", - "previousPage": "Previous page", - "nextPage": "Next page", - "loginRequiredIntakes": "Please sign in to view intakes.", "phaseConfig": "Reminder phases", "phaseConfigDescription": "Configure timing for reminders.", "phaseGreen": "Green", @@ -1345,11 +1244,8 @@ "phaseRed": "Red", "phaseMinutes": "Min", "phasePercent": "%", - "phaseBeforeEnd": "before end", - "phaseAfterEnd": "after end", "phaseResetDefaults": "Reset to defaults", "phaseSaved": "Reminder phases saved", - "phaseReset": "Reset to defaults", "sourceWeb": "Web", "sourceApi": "iOS App", "sourceReminder": "Reminder", @@ -1369,16 +1265,6 @@ "intakeHistoryPageInfo": "Page {page} of {total} · {count} entries", "intakeHistoryPrev": "Previous", "intakeHistoryNext": "Next", - "unitDrops": "Drops", - "unitTablets": "Tablet(s)", - "unitCapsules": "Capsule(s)", - "unitPieces": "Piece(s)", - "namePlaceholder": "e.g. Ibuprofen", - "dosePlaceholder": "e.g. 400", - "labelPlaceholder": "e.g. Morning", - "defaultDose": "Default", - "dayDeactivate": "Deactivate {day}", - "dayActivate": "Activate {day}", "glp1": { "drug": { "tirzepatide": { @@ -1635,8 +1521,6 @@ "historyLabel": "View intake history" }, "history": { - "back": "Back to medication", - "subtitle": "Full intake history", "plannedSuffix": "planned" }, "tabs": { @@ -2008,7 +1892,8 @@ "bmiEmptyTitle": "Set your height to see BMI", "bmiEmptyDescription": "BMI needs your height. Add it under Settings → Account.", "bmiEmptyAction": "Open account settings", - "noProviderConfigured": "Assessment unavailable.", + "noProviderConfigured": "Connect an AI provider to see an assessment of your readings here.", + "noProviderAction": "Open AI settings", "startAnalysis": "Start assessment", "refreshAnalysis": "Refresh assessment", "keyTakeaway": "Key Takeaway", @@ -2081,11 +1966,11 @@ "progressAria": "Health Score progress", "deltaVsLastWeek": "{delta} vs last week", "deltaUnavailable": "No history yet — check back next week.", + "restModePaused": "Paused during illness — your score isn't being judged today.", "componentBp": "BP", "componentWeight": "Weight", "componentMood": "Mood", "componentCompliance": "Adherence", - "disclaimer": "Indicative — not a clinical assessment.", "deltaExplainer": { "trigger": "What does this delta mean?", "title": "How the delta is computed", @@ -2123,7 +2008,7 @@ "emptyAction": "Generate briefing", "loadingLabel": "Synthesising today's briefing", "noProviderTitle": "No AI provider connected", - "noProviderDescription": "Connect an AI provider in settings to receive daily briefings.", + "noProviderDescription": "The daily briefing turns your readings into plain-language summary. HealthLog needs an AI provider for that — use your own key (OpenAI, Anthropic) or a local model. Your data stays with you.", "noProviderAction": "Open AI settings" }, "trendsRow": { @@ -2143,7 +2028,8 @@ "distance": "Walking and running distance over the last 30 days.", "vo2_max": "VO₂ max over the last 30 days.", "body_temp": "Body temperature over the last 30 days." - } + }, + "viewDetail": "View {metric} details" }, "vo2Max": { "statLatest": "Latest", @@ -2165,7 +2051,6 @@ }, "correlationRow": { "title": "Relationships", - "disclaimer": "Relationships are observational, not causal — talk to your doctor if BP runs high.", "experimentCta": "Try a 7-day experiment", "experimentTooltip": "Coming soon", "emptyTitle": "Need more data to see this relationship", @@ -2188,6 +2073,8 @@ "refusalInjection": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.", "errorBudget": "You've reached today's Coach usage limit. The budget refreshes at midnight UTC.", "errorProvider": "The Coach could not reach an Insights provider just now. Please try again in a moment.", + "errorNoProvider": "The Coach reads your logged health data and answers questions about it (not medical advice). HealthLog needs an AI provider for that — connect your own key or a local model.", + "errorNoProviderAction": "Connect an AI provider", "errorNetwork": "No internet connection — try again once you're back online.", "prefsSaveError": "Couldn't save your Coach preferences. Please try again.", "errorCredentialExpired": "Your AI provider connection has expired. Reconnect it in Settings to keep the Coach running.", @@ -2507,7 +2394,7 @@ "explainer": { "trigger": "What is {title}?", "bloodPressureTitle": "Blood pressure", - "bloodPressureBody": "Blood pressure is the force your blood exerts on the artery walls, written as systolic over diastolic (the peak during a heartbeat over the resting value between beats). Consistently high readings strain the heart and blood vessels over time.", + "bloodPressureBody": "Blood pressure is the force your blood exerts on the artery walls, written as systolic over diastolic (the peak during a heartbeat over the resting value between beats). Consistently high readings strain the heart and blood vessels over time. As general guidance, European guidance (ESH 2023) calls under 120/80 optimal and 140/90 or above hypertension; US guidance (ACC/AHA 2017) sets a stricter line, labelling 130–139/80–89 as Stage 1. Categorise from a calm seated reading, never a single number.", "pulseTitle": "Pulse", "pulseBody": "Your pulse is how many times your heart beats per minute. A spot reading reflects what you were doing in the moment — at rest it is typically 60–100 beats per minute, and it rises naturally with movement, stress, or caffeine.", "weightTitle": "Weight", @@ -2521,19 +2408,19 @@ "medicationsTitle": "Medication adherence", "medicationsBody": "Adherence is how reliably you take your medication as prescribed, shown here as the share of expected doses you actually logged. Steady intake is often what makes a treatment work as intended.", "restingHrTitle": "Resting heart rate", - "restingHrBody": "Resting heart rate is your heart rate when you are completely at rest, usually measured overnight. A lower resting rate generally points to better cardiovascular fitness, and a rising trend can be an early sign of stress or illness.", + "restingHrBody": "Resting heart rate is your heart rate when you are completely at rest, usually measured overnight — distinct from your pulse during activity. A lower resting rate generally points to better cardiovascular fitness, and a rising trend can be an early sign of stress or illness. As general guidance, a normal adult resting rate is 60–100 beats per minute (AHA 2024); a trained heart can sit benignly at 40–60.", "hrvTitle": "Heart rate variability (HRV)", "hrvBody": "HRV is the tiny variation in time between consecutive heartbeats. Higher variability usually reflects a well-recovered, adaptable nervous system, while a sustained drop can signal fatigue, stress, or insufficient recovery.", "oxygenSaturationTitle": "Oxygen saturation (SpO₂)", - "oxygenSaturationBody": "SpO₂ is the percentage of your red blood cells carrying oxygen. Readings of 95–100% are normal at rest; values that stay below 90% warrant attention, though brief nightly dips are common.", + "oxygenSaturationBody": "SpO₂ is the percentage of your red blood cells carrying oxygen. As general guidance, readings of 95–100% are normal at rest (StatPearls/NIH 2023); values that stay below 90% warrant attention, though brief nightly dips are common and above ~98% adds no benefit. Optical readers can over-read true saturation in people with darker skin (US FDA 2024), so a single near-normal reading is not a clearance — retake when warm and still if concerned.", "bodyTemperatureTitle": "Body temperature", - "bodyTemperatureBody": "Body temperature reflects your core thermal state, normally around 37 °C with small daily swings. A sustained rise can indicate infection, while overnight wrist readings mainly track relative changes rather than a clinical value.", + "bodyTemperatureBody": "Body temperature reflects your core thermal state. As general guidance, the population mean is about 36.6 °C — not 37.0 °C — with a normal oral range of roughly 35.7–37.4 °C and a fever from 38.0 °C (J Gen Intern Med 2019). It swings about 0.5 °C across the day, and sites differ by up to ~1 °C, so tag where you measured. Overnight wrist readings track relative change, not a clinical fever value.", "activeEnergyTitle": "Active energy", "activeEnergyBody": "Active energy is the calories you burn through movement, on top of the baseline your body uses at rest. It is a direct measure of how physically active your day was.", "workoutsTitle": "Workouts", "workoutsBody": "Workouts are the structured exercise sessions your devices record, with type, duration, and energy. Tracking them shows how regular training accumulates over weeks and how it relates to your other health signals.", "respiratoryRateTitle": "Respiratory rate", - "respiratoryRateBody": "Respiratory rate is the number of breaths you take per minute. At rest most adults breathe roughly twelve to twenty times a minute; a sustained rise can accompany illness, exertion, or stress.", + "respiratoryRateBody": "Respiratory rate is the number of breaths you take per minute. As general guidance, at rest most adults breathe 12–20 times a minute (American Lung Association); it is meaningful only truly at rest or asleep. A sustained rise of about +3 above your own nightly baseline can be an early illness signal — clinical escalation cutoffs are hospital tools, not self-alarms.", "bodyWaterTitle": "Total body water", "bodyWaterBody": "Total body water is the share of your body weight made up of fluid, inside and around your cells. It shifts with hydration, food, and activity, so the trend over weeks tells you more than any single reading.", "boneMassTitle": "Bone mass", @@ -2545,7 +2432,7 @@ "muscleMassTitle": "Muscle mass", "muscleMassBody": "Muscle mass is the estimated weight of the muscle in your body. More muscle supports strength, metabolism, and mobility, and it tends to need active effort to preserve as you age.", "visceralFatTitle": "Visceral fat", - "visceralFatBody": "Visceral fat is the fat stored deep in the abdomen around your organs. Unlike fat under the skin, a high level is closely linked to heart disease and type 2 diabetes, so lower is generally better.", + "visceralFatBody": "Visceral fat is the fat stored deep in the abdomen around your organs. Unlike fat under the skin, a high level is closely linked to heart disease and type 2 diabetes, so lower is generally better. The scale shows a unitless rating (about 1–12 is the healthy range, 13 and up elevated); this is a separate scale from the imaging cm² thresholds clinicians use and is not interchangeable with them.", "leanBodyMassTitle": "Lean body mass", "leanBodyMassBody": "Lean body mass is your total weight minus body fat — muscle, bone, organs, and water combined. Tracking it alongside weight shows whether changes come from muscle or from fat.", "flightsClimbedTitle": "Flights climbed", @@ -2565,7 +2452,7 @@ "walkingSpeedTitle": "Walking speed", "walkingSpeedBody": "Walking speed is how fast you move on a level walk. It is a well-studied marker of overall health and mobility, and a steady or rising pace is a good sign.", "pulseWaveVelocityTitle": "Pulse wave velocity", - "pulseWaveVelocityBody": "Pulse wave velocity measures how fast the pressure wave from each heartbeat travels along your arteries. A faster wave indicates stiffer arteries, which is linked to higher cardiovascular risk.", + "pulseWaveVelocityBody": "Pulse wave velocity measures how fast the pressure wave from each heartbeat travels along your arteries. A faster wave indicates stiffer arteries, which is linked to higher cardiovascular risk. As general guidance, European clinical reference flags carotid-femoral velocity above 10 m/s (ESC/ESH 2018) — but a consumer estimate is a proxy, not interchangeable with that clinical measurement.", "vascularAgeTitle": "Vascular age", "vascularAgeBody": "Vascular age estimates how old your arteries behave compared with your actual age, based on arterial stiffness. A vascular age below your real age suggests healthier blood vessels.", "environmentalAudioTitle": "Environmental sound levels", @@ -2577,7 +2464,7 @@ "daylightTitle": "Time in daylight", "daylightBody": "Time in daylight is how long you spend in outdoor light each day. Regular daylight supports a healthy sleep-wake rhythm and mood, and time outdoors is linked to eye health.", "bloodGlucoseTitle": "Blood glucose", - "bloodGlucoseBody": "Blood glucose is the amount of sugar in your blood, the body's main energy source. It rises after eating and settles between meals; readings that stay high over time can signal diabetes or prediabetes.", + "bloodGlucoseBody": "Blood glucose is the amount of sugar in your blood, the body's main energy source. It rises after eating and settles between meals. As general guidance for adults without diabetes, a fasting reading under 100 mg/dL is normal, 100–125 is prediabetes, and 126 or above falls in the diabetes range (ADA 2024). A single high reading does not diagnose — a clinician confirms with a repeat test, and people managing diabetes use different, clinician-set targets.", "skinTemperatureTitle": "Skin temperature", "skinTemperatureBody": "Skin temperature here is the small nightly change in your wrist temperature from your own baseline, not an absolute fever reading. Shifts can track sleep, environment, illness, or menstrual-cycle phase.", "cardioFitnessTitle": "Cardio fitness (VO₂ max)", @@ -2597,14 +2484,26 @@ "breathingDisturbancesTitle": "Sleep breathing disturbances", "breathingDisturbancesBody": "Your Apple Watch can estimate how often your breathing was interrupted during sleep. A lower index is better; a level Apple flags as elevated, especially over many nights, is worth raising with a clinician.", "stepsTitle": "Steps", - "stepsBody": "Step count is the total number of steps you take each day. It is the simplest measure of everyday movement — a higher daily count generally reflects a more active lifestyle, and a steady habit matters more than any single big day.", - "readinessBody": "Readiness is a single daily read on how prepared your body is to take on the day, blending resting heart rate, heart-rate variability, sleep, and how steady your recent nights have been. A higher score means recovered and resourced; a lower one is a gentle cue to ease off.", + "stepsBody": "Step count is the total number of steps you take each day. It is the simplest measure of everyday movement, and a steady habit matters more than any single big day. As general guidance, mortality risk falls steeply toward about 8,000 steps a day with continued benefit through roughly 12,000 (Saint-Maurice JAMA 2020); the optimal count is lower for older adults. The '10,000' figure is a marketing slogan, not research.", + "readinessBody": "Readiness is a single daily read on how prepared your body is to take on the day, blending resting heart rate, heart-rate variability, sleep, and how steady your recent nights have been. It is a device proxy benchmarked against your own baseline, not a clinical measure, and scores are not comparable across devices. A higher score means recovered and resourced; a lower one is a gentle cue to ease off.", "sleepScoreBody": "Your sleep score distils a whole night into one number — duration, efficiency, regularity, and the balance of light, deep, and REM. Watch the pattern across weeks rather than chasing a perfect score on any single night.", - "recoveryBody": "Recovery reflects how well your body has bounced back, drawing on overnight signals like heart-rate variability and resting heart rate. 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.", + "recoveryBody": "Recovery reflects how well your body has bounced back, drawing on overnight signals like heart-rate variability and resting heart rate. It is a device-derived proxy read against your own baseline, not a clinical measure, and is not comparable across devices. A strong score is a green light to push; a softer one suggests your system is still rebuilding.", + "strainBody": "Strain captures how much cardiovascular and physical load you have put on your body, from workouts to an active day on your feet. It is a device-derived index judged against your own typical effort, not a clinical measure, and the headline number is not comparable across devices.", + "stressBody": "Stress here is a device heuristic derived from heart-rate-variability patterns, not a cortisol measurement or how you consciously feel — and not a clinical stress score. Read it against your own baseline; it is not comparable across devices. A lower score points to a calm, well-regulated state; a higher one is an invitation to build in recovery.", "recoveryPageTitle": "Recovery", - "recoveryPageBody": "Recovery brings together the overnight signals your wearable records — recharge, strain, load and your day's heart-rate and energy totals — to show how well your body has bounced back. Read the pattern across days rather than any single number; a softer reading is an invitation to ease off, not an alarm." + "recoveryPageBody": "Recovery brings together the overnight signals your wearable records — recharge, strain, load and your day's heart-rate and energy totals — to show how well your body has bounced back. Read the pattern across days rather than any single number; a softer reading is an invitation to ease off, not an alarm.", + "pulsePressureTitle": "Pulse pressure", + "pulsePressureBody": "Pulse pressure is the gap between your systolic and diastolic readings (systolic minus diastolic). As general guidance, about 40 mmHg is typical at rest; a wide gap above roughly 60 mmHg in middle age can reflect stiffening arteries, while a narrow gap under about 25 mmHg can accompany low stroke volume (StatPearls/NIH 2023). It is a discussion prompt, not a diagnosis.", + "meanArterialPressureTitle": "Mean arterial pressure", + "meanArterialPressureBody": "Mean arterial pressure is the average pressure your organs see across a heartbeat, roughly the diastolic plus a third of the gap to systolic. As general guidance, a resting value sits around 70–100 mmHg (StatPearls/NIH 2023). The often-cited ~60 mmHg floor is an acute-care concept, not a consumer self-alarm.", + "cycleEducationBody": "Your cycle runs through four phases driven by shifting hormones, and its length and regularity are an accessible window on overall health. As general guidance, adult cycles typically run 21–35 days (clinical references cite 24–38), with bleeding around five days (ACOG 2015). The luteal phase is the steady anchor, so ovulation is best estimated by counting back from your next expected period — never a fixed 'day 14'. A predicted fertile window is an estimate that needs several recorded cycles and stays approximate, and basal body temperature confirms ovulation after the fact rather than predicting it. This is not a contraceptive method.", + "sleepHygieneBody": "A few evidence-based habits support sleep: keep consistent bed and wake times including weekends, get bright morning daylight and dim your evenings, keep the room cool, dark and quiet, and avoid afternoon caffeine, late alcohol and large late meals (CDC 2024). Regularity predicts health at least as strongly as duration, and a wearable's total sleep time is far more trustworthy than its deep, light and REM split.", + "referenceGuidance": "General guidance, not medical advice. Reference ranges are population anchors that shift with your age, sex and health conditions — your own trend over time leads the read." + }, + "bpDerived": { + "pulsePressure": "Pulse pressure (the gap between your readings) is about {value} mmHg — roughly 40 is typical at rest.", + "meanArterialPressure": "Mean arterial pressure (the average your organs see) is about {value} mmHg, normally around 70–100.", + "caveat": "These are derived from your latest reading as general guidance, not a diagnosis." }, "stats": { "label": "Summary statistics", @@ -2641,7 +2540,8 @@ "asleepTotal": "Asleep {value}", "inBedTotal": "In bed {value}", "awakenings": "{count} awakenings", - "source": "Source: {source}" + "source": "Source: {source}", + "estimateNote": "Stage timing is estimated — this source reports totals per stage, not a measured timeline." }, "stages": { "deep": "Deep", @@ -2668,7 +2568,7 @@ "chronotype": { "title": "Chronotype", "learning": "Still learning your rhythm — {counted} of {total} free nights. A chronotype needs a few alarm-free mornings before it means anything.", - "midSleepCaption": "Mid-sleep on free days around {clock}", + "midSleepCaption": "Your natural sleep midpoint on free days lands around {clock}.", "advancedToggle": "Advanced", "socialJetlagValue": "{hours}h {minutes}m social jetlag", "socialJetlagCaption": "The gap between your work-day and free-day sleep timing — like a small weekly time-zone shift.", @@ -2681,7 +2581,8 @@ "intermediate": "Intermediate type", "late": "Evening type", "extreme_late": "Strong evening type" - } + }, + "cornerLabel": "Chronotype" }, "rhythm": { "loadError": "Your sleep rhythm couldn't load just now. It'll reappear once the connection settles." @@ -2825,6 +2726,7 @@ "title": "Glucose overview", "subtitle": "A read on your last {days} days, from {count} readings.", "spotCaveat": "These are spot-reading estimates from individual measurements, not a continuous-monitor profile — read them as a direction of travel rather than a clinical verdict.", + "labsLink": "Lab results? Log a blood-draw HbA1c or fasting glucose under Labs.", "learningTitle": "Still learning your glucose", "learningBody": "So far that's {count} over {days}. A little more history and a fuller picture comes into focus — keep logging, or let a connected device fill it in.", "learningBodyEmpty": "Once a handful of readings come in — logged by you or synced from a device — your time-in-range, average, and variability take shape here.", @@ -3542,9 +3444,6 @@ "target": { "heading": "Target" }, - "disclaimer": { - "footer": "HealthLog describes your own tracked data — it is not a clinical assessment or diagnosis. Talk to your doctor about anything that concerns you." - }, "settings": { "overviewTitle": "Arrange the overview", "overviewDescription": "Show or hide sections and set their order on the Insights overview." @@ -3572,25 +3471,34 @@ }, "sleepQuality": { "title": "Sleep quality", - "subtitle": "Nightly scores from your wearable" + "subtitle": "Nightly scores from your wearable", + "assessment": { + "metric": { + "SLEEP_SCORE": "your overall sleep score", + "SLEEP_EFFICIENCY": "sleep efficiency", + "SLEEP_PERFORMANCE": "sleep performance", + "SLEEP_CONSISTENCY": "sleep consistency" + }, + "grade": { + "excellent": "excellent", + "good": "solid", + "fair": "fair", + "low": "on the low side" + }, + "clause": "Over the last 30 nights {metric} is {grade} at {value}.", + "closing": { + "excellent": "Keep the routine that is getting you here.", + "good": "A steadier bed and wake time is the usual next gain.", + "fair": "Protecting a consistent sleep window tends to lift this most.", + "low": "A regular wind-down and a fixed wake time are the first levers to pull." + } + } }, "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" - }, "block": { "ansChargeExplainer": "How charged your autonomic nervous system is for the day ahead — a recharge reading drawn from your overnight heart and breathing signals.", "dayStrainExplainer": "The day's whole-body cardiovascular load on your device's strain scale. Read it against your recovery, not as a goal on its own.", @@ -3608,8 +3516,6 @@ }, "thresholds": { "defaultLabel": "Default", - "autoModeLabel": "Auto", - "overrideModeLabel": "Override", "overrideToggleLabel": "Custom range", "resetAction": "Reset to default", "resetAllAction": "Reset all to default", @@ -3646,31 +3552,72 @@ "back": "Back", "skip": "Skip tour", "done": "Got it", - "restart": "Restart onboarding tour", - "restartHint": "Replay the dashboard walkthrough from the start.", - "restartConfirmation": "Tour will restart on your next dashboard visit.", + "restart": "Restart module tour", + "restartHint": "Replays the guided module tour from the start on your next dashboard visit.", + "restartConfirmation": "The module tour will restart from the beginning on your next dashboard visit.", "steps": { - "tileStrip": { - "title": "Your daily numbers", - "body": "The tile strip shows the latest reading and a 7-day trend for every metric you log — weight, blood pressure, pulse, mood. Hide what you don't need from Settings → Dashboard." + "dashboardOverview": { + "title": "Your dashboard", + "body": "The dashboard pulls every module together at a glance. Long-press a tile to reorder or hide it (Settings → Dashboard layout). Each tile is only as telling as the data behind it — the modules ahead fill them." }, "quickAdd": { - "title": "Log anything in two taps", - "body": "Use the Add button to drop a measurement or mood entry without leaving the dashboard. The form remembers your last context and tags." + "title": "Logged in seconds", + "body": "Use “+ Add” to log any reading in seconds — weight, blood pressure, pulse, glucose. Hidden strength: backdate readings you forgot to enter. Everything you log here feeds trends, the health score, and the AI insights." + }, + "measurements": { + "title": "Readings & target ranges", + "body": "Measurements shows the history and target range for each metric. Hidden gem: set a target range per value so deviations stand out in colour. These series are the basis for the Coach’s correlations and the doctor report." + }, + "medications": { + "title": "Medications & intakes", + "body": "Medications manages schedules, intakes, stock, and titration. Hidden: recurring plans (RRULE/rolling) and low-stock warnings. Your intake rate feeds the compliance pillar on the dashboard and the AI’s context." + }, + "labs": { + "title": "Lab values", + "body": "Labs captures biomarkers with reference ranges — blood count, cholesterol, HbA1c. Strength: import and FHIR export. Out-of-range lab values resurface as evidence in the insights and the doctor report." + }, + "illness": { + "title": "Illness journal", + "body": "The illness journal records acute episodes and chronic diagnoses with their flares. Hidden: correlate an episode with your readings, and Rest Mode that pauses reminders. That’s how the Coach understands why values drift for a while." + }, + "vorsorge": { + "title": "Preventive care", + "body": "Preventive care reminds you of check-ups and vaccinations on their own interval. Tip: “Done” can create a reading or a lab result directly. Due items appear as reminders and in the doctor report." + }, + "cycle": { + "title": "Cycle", + "body": "Cycle tracking logs phases and symptoms. Hidden: compare symptoms against mood and sleep. The cycle gives context for swings that would otherwise look unexplained." + }, + "mood": { + "title": "Mood", + "body": "Mood captures how you feel plus tags in two seconds. Strength: your own tags. Mood trends correlate with sleep, medications, and cycle — the Coach spots the patterns." }, "insights": { - "title": "Automatic insights", - "body": "Insights turns your readings into a plain-English health summary, with classifications for blood pressure, weight trend and mood. Bring your own provider key or use the built-in default." + "title": "Insights", + "body": "Insights is the heart of it: evidence-based, dynamic analysis instead of generic tips — every statement points at your real data. Hidden: the layout is customizable. Insights pulls together everything the modules above provide." + }, + "coach": { + "title": "Coach", + "body": "The Coach answers questions about your data in a dialogue, using your history, medications, and lab values as context. Pick your AI provider yourself (your own key or local). It recomputes nothing — it reads the same numbers as the dashboard." }, "integrations": { - "title": "Connect your devices", - "body": "Open Settings to link Withings (auto-sync weight, BP and pulse), moodLog, or your Insights provider. Everything stays optional and encrypted — and under Settings → Modules you can turn whole areas like sleep, mood or achievements on or off to keep HealthLog focused on what you track." + "title": "Integrations", + "body": "Integrations sync Withings, WHOOP, Polar, Oura, and Apple Health automatically. Weight, sleep, and recovery fill in without manual typing — and feed the insights and Coach in turn." + }, + "export": { + "title": "Doctor report", + "body": "The doctor report bundles readings, medications, lab values, and preventive care as PDF or FHIR for the appointment. This is where every module converges — a complete report only comes from well-kept data." }, "achievements": { - "title": "Earn streaks and badges", - "body": "The Achievements page tracks daily logging streaks, blood-pressure milestones and consistency badges. A small nudge to keep going." + "title": "Achievements", + "body": "Achievements reward consistency — streaks and milestones. A small motivation loop that makes sticking with it easier." + }, + "wrapUp": { + "title": "That’s the tour", + "body": "You can revisit any module on its own at any time (“Show this tour” on every page) or restart the whole tour under Settings → Advanced." } - } + }, + "moduleTrigger": "Show this tour" }, "shell": { "back": "Back", @@ -3741,13 +3688,20 @@ "learning": "Some insights — glucose, sleep rhythm, blood-pressure trends, cycle — sharpen over the first week or two as your data builds up. Empty tiles now just mean we're still learning your baseline.", "connectCta": "Connect a device", "logCta": "Log your first measurement", - "importCta": "Import existing history" + "importCta": "Import existing history", + "aiTitle": "Turn on AI Insights (optional)", + "aiBody": "Coach, the daily briefing and assessments turn your readings into plain language. For that you connect your own AI key (OpenAI, Anthropic) or a local model — your data stays with you.", + "aiCta": "Connect an AI provider" }, "welcomeBack": { "title": "Welcome back", "body": "You've already finished onboarding. To adjust anything, head to Settings — everything you set up is editable from there.", "cta": "Open dashboard" }, + "disclaimer": { + "acknowledge": "I understand: HealthLog is a private tracking tool, not a clinical assessment or diagnosis. If I'm worried I'll talk to my doctor, not this app.", + "learnMore": "Learn more" + }, "welcome": { "title": "Welcome to HealthLog", "carouselLabel": "Welcome carousel", @@ -3829,18 +3783,21 @@ "sections": { "account": { "title": "Account", - "description": "Profile, password, passkeys, and your onboarding tour." + "description": "Profile, password, passkeys, and your onboarding tour.", + "subtitle": "Your profile, identity, and sign-in details." }, "integrations": { "title": "Integrations", - "description": "Connected services, delivery channels, and source priority in one place." + "description": "Connected services, delivery channels, and source priority in one place.", + "subtitle": "Connect and manage your data sources and devices." }, "notifications": { "title": "Notifications", "description": "Every reminder in one place — what gets reminded and when. The delivery channels live under Integrations.", "channelsHint": "Telegram, ntfy, Web Push, Webhook, and Email are now set up under", "channelsHintLink": "Integrations → Channels", - "inboxLink": "Open the inbox" + "inboxLink": "Open the inbox", + "subtitle": "Choose which reminders you receive." }, "layout": { "title": "Layout", @@ -3853,15 +3810,18 @@ "insights": { "title": "Insights", "description": "Overview sections and navigation-pill order." - } + }, + "subtitle": "Personalize how your dashboard and insights are arranged." }, "dashboard": { "title": "Dashboard", - "description": "Tile layout and order." + "description": "Tile layout and order.", + "subtitle": "Arrange the tiles on your dashboard." }, "thresholds": { "title": "Targets", - "description": "Per metric: your own target range for healthy values." + "description": "Per metric: your own target range for healthy values.", + "subtitle": "Set your personal target ranges per metric." }, "sources": { "title": "Source priority", @@ -3920,19 +3880,23 @@ "scale": "Scale", "other": "Other", "unknown": "Unknown" - } + }, + "subtitle": "Decide which source wins when two report the same metric." }, "ai": { - "title": "AI Insights", - "description": "Provider, model, and API key for the insights — plus your “about me” context." + "title": "AI provider", + "description": "Provider, model, and API key for the insights — plus your “about me” context.", + "subtitle": "Choose your AI provider and bring your own key." }, "api": { "title": "API & Tokens", - "description": "Bearer tokens for your own scripts and apps — log measurements or medication intake from anywhere." + "description": "Bearer tokens for your own scripts and apps — log measurements or medication intake from anywhere.", + "subtitle": "Manage API endpoints and access tokens." }, "advanced": { "title": "Advanced", - "description": "Opt-in Research Mode toggle plus the account-wide danger zone for wiping every health record." + "description": "Opt-in Research Mode toggle plus the account-wide danger zone for wiping every health record.", + "subtitle": "Research mode, data reset, and account deletion." }, "export": { "title": "Export & Import", @@ -4033,27 +3997,33 @@ "resultSummary": "Imported {inserted} new and updated {updated}, {skipped} skipped.", "rowError": "Line {line}: {reason}" } - } + }, + "subtitle": "Export your data or import from a backup." }, "about": { "title": "About this app", - "description": "Version, license, links." + "description": "Version, license, links.", + "subtitle": "Version, license, and project links." }, "sharing": { "title": "Sharing", - "description": "Create a read-only link to share your health record with a clinician." + "description": "Create a read-only link to share your health record with a clinician.", + "subtitle": "Share read-only access to your data." }, "insights": { "title": "Insights", - "description": "Overview and navigation-pill order." + "description": "Overview and navigation-pill order.", + "subtitle": "Arrange your overview sections and pill order." }, "medications": { "title": "Medications", - "description": "List view, order, and injection sites for your medications." + "description": "List view, order, and injection sites for your medications.", + "subtitle": "Manage your medication list view and order." }, "mood": { "title": "Mood tags", - "description": "Groups, tags, and archived tags for the mood picker." + "description": "Groups, tags, and archived tags for the mood picker.", + "subtitle": "Organize your mood tags, groups, and picker order." }, "modules": { "title": "Modules", @@ -4063,26 +4033,24 @@ "title": "What you track", "description": "Optional areas. Disabled modules drop out of the navigation, tiles, and insights — your data stays untouched." }, - "core": { - "title": "Always on", - "description": "HealthLog's foundations. These areas can't be turned off.", - "note": "Core area — always on" - }, - "description": "Choose what HealthLog tracks for you. Turn individual areas on or off — the navigation, tiles, and insights adapt right away.", "manageIn": "Manage in {section}", - "operatorDisabled": "Disabled server-wide" + "operatorDisabled": "Disabled server-wide", + "subtitle": "Choose which tracking areas HealthLog shows across the app." }, "gesundheitsakte": { "title": "Health record", - "description": "Export your full health record as PDF, FHIR R4, or a combined ZIP package." + "description": "Export your full health record as PDF, FHIR R4, or a combined ZIP package.", + "subtitle": "Export a complete, clinician-ready health record." }, "coach": { "title": "Coach", - "description": "Enable the Coach, tone and scope, stored memories." + "description": "Enable the Coach, tone and scope, stored memories.", + "subtitle": "Tune the Coach, its proactive nudges, and its memory." }, "channels": { "title": "Channels", - "description": "Telegram, ntfy, Web Push, Webhook, and Email delivery." + "description": "Telegram, ntfy, Web Push, Webhook, and Email delivery.", + "subtitle": "Where reminders and alerts are delivered." } }, "profile": "Profile", @@ -4579,9 +4547,7 @@ "changelog": "Changelog", "docs": "Documentation", "linksHeading": "Sources & docs", - "newerAvailable": "Newer version: {tag}", - "tourReplay": "Replay the tour", - "tourReplayHint": "Watch the dashboard walkthrough — handy if you skipped it on first visit." + "newerAvailable": "Newer version: {tag}" }, "testConnection": { "test": "Test connection", @@ -4964,7 +4930,6 @@ "modules": { "title": "Module availability", "description": "Turn a module off for the whole server. A module disabled here is off for every account regardless of personal preference. Core domains (weight, blood pressure, pulse, medications) are always on and cannot be disabled.", - "disabledHint": "Disabled server-wide", "saveError": "Could not update module availability." }, "bugReportRepo": "Repository", @@ -5343,11 +5308,14 @@ }, "about": { "title": "About", - "subtitle": "Version, license, source, and update check." + "subtitle": "Version, license, and update status." + }, + "module-availability": { + "title": "Module availability", + "subtitle": "Turn modules on or off for every account on this server." } }, "carrier": "Carrier", - "carrierUnknown": "Unknown carrier", "carrierUnavailableGeoFallback": "{location} — carrier unavailable" }, "achievements": { @@ -5679,7 +5647,6 @@ "title": "Notifications", "breadcrumbInbox": "Notifications", "breadcrumbChannels": "Notification channels", - "breadcrumbSettings": "Settings", "eventColumn": "Event", "subtitle": "Choose which notifications you want to receive through which channel.", "loadError": "Notification preferences could not be loaded.", @@ -5796,7 +5763,6 @@ "dotNearRange": "near range", "dotOutOfRange": "out of range", "dotNotLogged": "not logged", - "insufficientData": "Not enough data yet — keep logging.", "relativeDay": { "today": "today", "yesterday": "yesterday", @@ -5808,7 +5774,6 @@ } }, "edit": { - "openLabel": "Edit target range for {metric}", "title": "Edit target range — {metric}", "description": "Adjust the in-range band used across charts, insights and the doctor report. Values are in {unit}.", "derivedHint": "This card is derived from other metrics. Edit the underlying targets (e.g. blood pressure or weight) instead.", @@ -5824,13 +5789,6 @@ "saveError": "Could not save the target range", "resetSuccess": "Target range reset to default" }, - "mood": { - "stability": { - "stable": "stable", - "variable": "variable", - "highlyVariable": "highly variable" - } - }, "currentValue": "Current: {value} {unit}", "targetRangeValue": "Target: {min}–{max} {unit}", "sourceLabel": "Source: {source}", @@ -5936,7 +5894,6 @@ }, "lowStockReminders": { "title": "Low supply: {medName}", - "body": "{medName}: {units} units left — enough for about {days} more days.", "bodyToday": "{medName}: {units} units left — less than one day of supply.", "bodyReorder": "{medName}: {units} units left. Supply runs out around {runsOutOn} — reorder by {reorderBy}.", "bodyLastDose": "{medName}: {units} units left — about your final dose. Supply runs out around {runsOutOn}." @@ -6044,7 +6001,6 @@ "description": "How each measurement compares between your luteal and follicular phases, on your own data.", "pairLabel": "{metric} · luteal vs follicular", "detail": "Luteal {lutealAvg} {unit} ({lutealDays} days) vs follicular {follicularAvg} {unit} ({follicularDays} days).", - "footer": "These are associations in your own data, not causes. Only patterns that clear a significance check are shown.", "metricRestingHeartRate": "Resting heart rate", "metricHrv": "Heart-rate variability", "metricSleepDuration": "Sleep duration", @@ -6068,10 +6024,6 @@ "confidenceHigh": "High confidence" }, "title": "Cycle insights", - "empty": "Phase insights appear here once you have enough logged cycles. They compare your metrics across cycle phases, with the statistics shown openly.", - "sampleSize": "n = {n}", - "effectSize": "Effect size {value}", - "qValue": "q = {value}", "symptomPatternsTitle": "Symptom patterns by phase", "symptomPatternsEmpty": "Log symptoms across a cycle to see which phase they cluster in.", "symptomMostlyIn": "Mostly in {phase} ({count}/{total} days)" @@ -6091,7 +6043,6 @@ "LUTEAL": "Progesterone leads the second half and basal body temperature usually settles a little higher than earlier in the cycle. Some people notice shifts in sleep, appetite, mood or energy as the days go on, while others feel steady throughout. The pattern is personal, and it often looks different from one month to the next." }, "commonHere": "You often notice this here", - "trackNudge": "Log today", "stillLearning": "Still learning your cycle. Keep logging and this fills in with patterns from your own days." }, "insightsSummary": { @@ -6184,10 +6135,7 @@ "prevMonth": "Previous month", "nextMonth": "Next month", "loading": "Loading your calendar…", - "empty": "Start logging to see your calendar fill in.", "learning": "Learning your cycle — log at least three full cycles and the fertile window, ovulation and phases fill in. Until then the calendar shows only what you've logged.", - "today": "Today", - "dayAria": "{date}", "legendSymptom": "Symptoms", "legendOvulationConfirmed": "Confirmed ovulation", "flowSpotting": "spotting", @@ -6198,11 +6146,9 @@ "predictions": { "title": "What comes next", "nextPeriod": "Next period", - "nextPeriodRange": "Around {date}", "nextPeriodWindow": "{low} – {high}", "fertileWindow": "Fertile window", "ovulation": "Estimated ovulation", - "confidence": "Confidence", "confidenceHigh": "High", "confidenceMedium": "Medium", "confidenceLow": "Low", @@ -6238,7 +6184,6 @@ "sheet": { "title": "Log your day", "description": "Capture today's flow, symptoms, and notes.", - "date": "Date", "flow": "Flow", "symptoms": "Symptoms", "temperature": "Basal body temperature", @@ -6265,10 +6210,7 @@ "save": "Save", "saving": "Saving…", "cancel": "Cancel", - "saved": "Day saved", "saveError": "Could not save. Try again.", - "periodStarted": "Period start logged", - "clear": "None", "temperatureSection": "Temperature & ovulation", "intimacySection": "Intimacy & contraception", "testsSection": "Tests", @@ -6282,8 +6224,6 @@ }, "settings": { "title": "Cycle settings", - "enable": "Cycle tracking", - "enableDescription": "Turn the cycle tab on or off for your account.", "typicalCycleLength": "Typical cycle length (days)", "typicalPeriodLength": "Typical period length (days)", "lutealPhaseLength": "Luteal phase length (days)", @@ -6302,30 +6242,14 @@ "encryptionDescription": "Encrypt intent-revealing fields (pregnancy tests, contraception, intercourse) at rest, on top of the always-encrypted notes.", "discreet": "Discreet notifications", "discreetDescription": "Hide cycle details from notification text on your lock screen.", - "dataTitle": "Your data", - "dataDescription": "Your cycle data lives only on this server. Export it any time, or delete it for good.", - "export": "Export my cycle data", - "deleteData": "Delete my cycle data", "saved": "Settings saved", "saveError": "Could not save. Try again.", "save": "Save", - "purgeTitle": "Delete all cycle data", - "purgeDescription": "Permanently removes every logged day, cycle, prediction, and reminder record. This cannot be undone.", - "purgeButton": "Delete all cycle data", - "purgeConfirmTitle": "Delete all cycle data?", - "purgeConfirmBody": "This permanently deletes every period, day-log, symptom, and prediction. This cannot be undone.", - "purgeConfirm": "Delete all cycle data permanently?", - "purgeCancel": "Cancel", - "purgeSuccess": "All cycle data deleted.", - "purgeError": "Couldn't delete cycle data.", "reminders": "Reminders", "remindersDescription": "Turn period reminders on per channel in notification settings.", "remindersLink": "Notification settings", "fertileReminder": "Fertile-window reminder", - "fertileReminderDescription": "Get a heads-up a few days before your estimated fertile window. Off by default — turn it on per channel in notification settings.", - "purgeData": "Delete cycle data", - "purgeConfirmAction": "Yes, delete everything", - "purgeDone": "Cycle data deleted." + "fertileReminderDescription": "Get a heads-up a few days before your estimated fertile window. Off by default — turn it on per channel in notification settings." }, "contraceptive": { "NONE": "None", @@ -6472,17 +6396,29 @@ "pushLocation": "(at {location})", "edit": "Edit", "originCoach": "Coach", - "enabledToggleAria": "Enable or disable this reminder", "until": "Until {date}", "selfPlanned": "Self-planned", "captureValue": "Log value", + "measureNow": "Measure now", + "nextDueLabel": "Next due:", + "lastDoneLabel": "Last done:", + "customize": "Customize preventive care", + "adherence": { + "label": "Within interval", + "onTrack": "On track", + "due": "Due now" + }, + "settings": { + "title": "Customize preventive care", + "description": "Manage the view, order, and individual reminders.", + "back": "Back to preventive care" + }, "capture": { "title": "Log measurement", "description": "Enter the reading; the reminder is marked done." }, "manage": { - "menuItem": "Customize", - "title": "Customize reminders", + "title": "Manage reminders", "description": "Enable or disable individual reminders.", "empty": "No reminders to customize yet." }, @@ -6491,6 +6427,17 @@ "openLink": "Open preventive care" } }, + "moduleList": { + "viewToggleLabel": "View", + "viewCards": "Card view", + "viewList": "List view", + "viewHeading": "View", + "viewDescription": "Choose whether entries show as cards or as a compact list.", + "reorder": { + "heading": "Order", + "empty": "No entries to reorder yet." + } + }, "labs": { "title": "Lab results", "subtitle": "Bloodwork and biomarker readings", @@ -6501,36 +6448,36 @@ "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", "deleteConfirmTitle": "Delete lab result?", "deleteConfirmDescription": "This lab result will be permanently deleted.", "deletedToast": "Result deleted", "deleteError": "Could not delete the result", "loadError": "Could not load lab results", + "customize": "Customize labs", + "settings": { + "title": "Customize labs", + "description": "Manage the view, sort order, and biomarkers.", + "back": "Back to labs" + }, + "sort": { + "heading": "Sort order", + "label": "Order of values", + "recentDesc": "Most recent reading first", + "recentAsc": "Oldest reading first", + "manual": "Sort manually", + "hint": "With manual sorting you can drag the biomarkers below into your own order." + }, "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": "Enter a value.", - "rangeOrderError": "The reference low must not exceed the reference high.", "save": "Save", "savedToast": "Result saved", "saveError": "Could not save the result", @@ -6567,9 +6514,7 @@ } }, "biomarker": { - "manage": "Biomarkers", "manageTitle": "Biomarkers", - "manageSheetDescription": "Define the markers you track. Each is set up once with its unit and reference range.", "managerDescription": "Define a marker once — its unit and range are reused for every reading.", "define": "Define", "defineFirst": "Define your first biomarker", @@ -6699,18 +6644,6 @@ "label": "Doctor report", "description": "PDF and FHIR export for your practice." }, - "weight": { - "label": "Weight", - "description": "Weight and body composition." - }, - "bloodPressure": { - "label": "Blood pressure", - "description": "Systolic and diastolic." - }, - "pulse": { - "label": "Pulse", - "description": "Resting pulse and heart rate." - }, "medications": { "label": "Medications", "description": "Schedules, intakes, and compliance." @@ -6723,7 +6656,12 @@ "illness": { "title": "Illness journal", "subtitle": "A retrospective log of your illness and condition episodes.", - "disclaimer": "A personal journal — not a medical device. It does not diagnose or predict.", + "customize": "Customize illness journal", + "settings": { + "title": "Customize illness journal", + "description": "Manage the view and order of episodes.", + "back": "Back to the illness journal" + }, "newEpisode": "New episode", "logDay": "Log a day", "markRecovered": "Mark recovered", @@ -6823,7 +6761,6 @@ "countOther": "You logged {count} episodes in this period.", "typicalGap": "Typical recovery gap: {days} days between feeling better and your numbers settling.", "typicalGapLearning": "A few more resolved episodes and a typical recovery gap will appear here.", - "byMonth": "When you tend to log episodes", "none": "No episodes logged in this period." }, "section": { @@ -6893,5 +6830,24 @@ "reminderSuggestionsLabel": "Cadence suggestions", "reminderSuggestionsDescription": "Let the Coach offer an occasional, evidence-based reminder to measure a metric more regularly." } + }, + "safety": { + "floor": { + "bpHighTitle": "Very high blood pressure", + "bpHighDoctor": "Two recent readings were {systolic}/{diastolic} mmHg or higher. Rest, re-check in a few minutes, and contact your doctor as soon as possible if it stays this high.", + "bpHighEmergency": "Two recent readings were {systolic}/{diastolic} mmHg or higher and you noted symptoms. If you have chest pain, breathlessness, weakness, or vision or speech changes, call emergency services now.", + "bpLowTitle": "Low blood pressure", + "bpLowDoctor": "Two recent readings had a systolic below {systolic} mmHg. If you feel dizzy or faint, sit or lie down, and contact your doctor if it continues.", + "bpLowEmergency": "Two recent readings had a systolic below {systolic} mmHg and you noted symptoms. If you feel faint, confused, or have cold clammy skin, call emergency services now.", + "glucoseLowTitle": "Low blood sugar", + "glucoseLowDoctor": "Two recent readings were below 70 mg/dL ({value} mg/dL). Take fast-acting carbohydrate, re-check in 15 minutes, and contact your doctor if lows continue.", + "glucoseLowEmergency": "Two recent readings were below 70 mg/dL ({value} mg/dL) and you noted symptoms. Treat now with fast-acting sugar; if you cannot, or you feel very unwell, call emergency services.", + "glucoseLowSevereTitle": "Very low blood sugar", + "glucoseLowSevereDoctor": "Two recent readings were below 54 mg/dL ({value} mg/dL). Treat immediately with fast-acting carbohydrate, re-check in 15 minutes, and contact your doctor.", + "glucoseLowSevereEmergency": "Two recent readings were below 54 mg/dL ({value} mg/dL) and you noted symptoms. Treat now; if you cannot keep food down or feel very unwell, call emergency services.", + "glucoseHighTitle": "Very high blood sugar", + "glucoseHighDoctor": "Two recent readings were {value} mg/dL or higher. Re-check, hydrate, and contact your doctor as soon as possible if it stays this high.", + "glucoseHighEmergency": "Two recent readings were {value} mg/dL or higher and you noted symptoms. If you have vomiting, fast breathing, or confusion, call emergency services now." + } } } diff --git a/messages/es.json b/messages/es.json index 4eb44003b..e5a4a8a3d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -18,7 +18,6 @@ "optional": "Opcional", "active": "Activo", "inactive": "Inactivo", - "enabled": "Activado", "disabled": "Desactivado", "networkError": "Error de red", "unknownError": "Error desconocido", @@ -74,7 +73,6 @@ "colTaken": "Tomada", "colSkipped": "Omitida", "colMissed": "Perdida", - "colTotal": "Total", "colExpected": "Esperadas", "colComplianceRate": "Tasa de cumplimiento", "moodTitle": "Evolución del estado de ánimo", @@ -191,7 +189,8 @@ "backToDashboard": "Volver al panel" }, "offlineBanner": { - "message": "Sin conexión — tus cambios se guardarán cuando vuelvas a estar en línea." + "message": "Sin conexión — tus cambios se guardarán cuando vuelvas a estar en línea.", + "showingCached": "Mostrando tus últimos datos sincronizados." }, "demoBanner": { "message": "Modo demo — los cambios no se guardan." @@ -201,14 +200,12 @@ "measurements": "Mediciones", "medications": "Medicamentos", "insights": "Insights", - "targets": "Objetivos", "achievements": "Logros", "settings": "Ajustes", "bugreport": "Reporte de bug", "admin": "Consola de administración", "logout": "Cerrar sesión", "login": "Iniciar sesión", - "about": "Acerca de HealthLog", "notifications": "Notificaciones", "theme": "Apariencia", "themeSystem": "Sistema", @@ -218,13 +215,11 @@ "userMenu": "Menú de usuario", "accountSettings": "Ajustes de cuenta", "mood": "Estado de ánimo", - "workouts": "Entrenamientos", "coach": "Coach", "skipToContent": "Saltar al contenido", "sidebar": "Barra lateral", "mainNavigation": "Navegación principal", "mobileNavigation": "Navegación móvil", - "breadcrumb": "Ruta de navegación", "loadingScreen": "Cargando…", "more": "Más", "moreSheetTitle": "Más", @@ -241,7 +236,6 @@ }, "cycle": "Ciclo", "labs": "Análisis", - "recovery": "Recuperación", "vorsorge": "Prevención", "illness": "Enfermedad" }, @@ -329,7 +323,6 @@ "sleepShort": "Sueño", "stepsShort": "Pasos", "vo2MaxShort": "VO₂ máx", - "bpInTargetShort": "PA-objetivo", "bpInTargetWindow90": "PA-objetivo · 90 d", "bpInTargetWindowDynamic": "PA-objetivo · {days} d", "bpInTargetCollecting": "recopilando datos", @@ -383,7 +376,6 @@ "staleHintWeeksOther": "Último valor hace {count} semanas", "staleHintMonthsOne": "Último valor hace 1 mes", "staleHintMonthsOther": "Último valor hace {count} meses", - "coachCta": "Pregunta al coach", "recentWorkouts": { "title": "Entrenamientos recientes", "viewAll": "Ver todos", @@ -422,7 +414,7 @@ }, "measurements": { "title": "Mediciones", - "subtitle": "Peso, tensión arterial, pulso y más", + "subtitle": "Registra lecturas y consulta el historial completo. Las tendencias y los análisis están en Análisis.", "addMeasurement": "Añadir", "editMeasurement": "Editar medición", "type": "Tipo", @@ -498,7 +490,6 @@ "invalidTimestamp": "Selecciona una fecha y hora válidas.", "loginRequired": "Inicia sesión para ver las mediciones.", "allTypes": "Todos los tipos", - "filterByType": "Filtrar por tipo", "measurementCount": "{count} mediciones", "emptyTitle": "Aún no hay mediciones", "emptyDescription": "Registra peso, tensión arterial, pulso u otros valores para detectar tendencias.", @@ -549,7 +540,6 @@ "typeCardioLoad": "Carga cardio", "typeSleepScore": "Puntuación de sueño", "typeBodyTemperatureDeviation": "Desviación de temperatura corporal", - "filterBySource": "Filtrar por fuente", "sourceComputed": "Calculado", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", @@ -669,7 +659,6 @@ "factorSadness": "tristeza", "praise": "elogio" }, - "filterBySource": "Filtrar por fuente", "sourceManual": "Manual", "sourceWeb": "Web", "sourceTelegram": "Telegram", @@ -740,8 +729,6 @@ "subtitle": "Medicación, horarios y tomas", "loginRequired": "Inicia sesión para gestionar medicamentos.", "addMedication": "Añadir", - "editMedication": "Editar medicamento", - "newMedication": "Nuevo medicamento", "firstMedication": "Añadir el primer medicamento", "addChoice": { "logIntake": "Registrar una toma", @@ -777,16 +764,9 @@ "loadFailed": "No se han podido cargar los medicamentos", "retryLoad": "Reintentar", "name": "Nombre", - "dose": "Dosis", - "scheduleFrom": "Desde", - "scheduleTo": "Hasta", - "scheduleLabel": "Etiqueta", - "scheduleDays": "Días", - "scheduleDaily": "A diario", "inactive": "En pausa", "intakeHistory": "Historial de tomas", "openDetailPage": "Abrir la página de detalle del medicamento", - "deleteConfirm": "¿Eliminar medicamento?", "compliance": "Cumplimiento", "importIntakes": "Importar tomas", "importDescription": "Pega un array JSON con los datos de tomas. Formato esperado:", @@ -801,19 +781,7 @@ "importInvalidSkipped": "{count} entradas no válidas omitidas", "importFailed": "Falló la importación", "importInvalidFormat": "Formato JSON no válido", - "apiEndpointTitle": "Endpoint de API: {name}", - "apiEndpointDescription": "Usa este endpoint para registrar tomas a través de la API (p. ej., con un Atajo de iPhone o un cron job).", - "apiEndpointActive": "Endpoint de API activo", - "apiEndpointActivated": "Endpoint de API activado", - "apiEndpointDeactivated": "Endpoint de API desactivado", - "apiTokenCount": "Activo ({count} tokens)", - "apiToken": "Token de API", - "apiTokenActiveHint": "El endpoint está activo. Los tokens existentes siguen siendo válidos, pero no se pueden mostrar de nuevo en texto plano.", - "apiTokenActivateHint": "Activa el endpoint para crear un nuevo token.", - "apiTokenOnceHint": "El token solo se muestra en texto plano una vez, al crearlo.", "requestExample": "Ejemplo de solicitud", - "statusLoadFailed": "No se pudo cargar el estado", - "changeFailed": "Falló el cambio", "categoryBloodPressure": "Presión arterial", "categoryVitamin": "Vitaminas", "categoryThyroid": "Tiroides", @@ -846,21 +814,15 @@ "lastDoseDaysAgo": "Última dosis hace {count} días", "overdue": "Vencida", "veryOverdue": "Muy vencida", - "compliance7d": "Cumplimiento 7 días", - "compliance30d": "Cumplimiento 30 días", "complianceWindow": "Cumplimiento {days} días", "complianceUnavailable": "No se pudo cargar el cumplimiento", "complianceInfo": "Proporción de dosis registradas respecto a las esperadas. Las pautas semanales se cuentan semana a semana.", "complianceInfoLabel": "¿Qué significa el cumplimiento?", - "complianceDoses": "{count} dosis", "lastIntake": "Última toma:", "nextIntake": "Próxima toma:", "nextIntakeOverdue": "Atrasada · {time} — aún se puede tomar", "dayStreak": "días de racha", "dayStreakOne": "día de racha", - "cycleNextDoseToday": "Próxima dosis hoy", - "cycleNextDoseTomorrow": "Próxima dosis mañana", - "cycleNextDoseInDays": "Próxima dosis en {days} días", "cycleDueToday": "Pendiente hoy", "cycleOverdue": "Atrasada", "cycleNoClosedCycles": "Aún no hay ciclos de dosis completados", @@ -872,40 +834,10 @@ "intakeUndo": "Deshacer", "intakeUndone": "Entrada revertida", "intakeUndoFailed": "No se pudo deshacer; corrígelo en el historial", - "weekdaySunday": "Domingo", - "weekdayMonday": "Lunes", - "weekdayTuesday": "Martes", - "weekdayWednesday": "Miércoles", - "weekdayThursday": "Jueves", - "weekdayFriday": "Viernes", - "weekdaySaturday": "Sábado", - "weekdaySundayPlural": "Domingos", - "weekdayMondayPlural": "Lunes", - "weekdayTuesdayPlural": "Martes", - "weekdayWednesdayPlural": "Miércoles", - "weekdayThursdayPlural": "Jueves", - "weekdayFridayPlural": "Viernes", - "weekdaySaturdayPlural": "Sábados", - "formType": "Tipo", - "formDose": "Dosis", - "formUnit": "Unidad", - "formSchedule": "Franja horaria", - "formTreatmentClass": "Clase de tratamiento", - "formTreatmentClassHelp": "Selecciona \"Inyección GLP-1\" para inyecciones semanales como Mounjaro, Ozempic, Wegovy, Zepbound. Activa la tarjeta corporal y el registro GLP-1.", - "treatmentClassGeneric": "Medicamento estándar", - "treatmentClassGlp1": "Inyección GLP-1", - "glp1WeeklyPresetTitle": "Una vez por semana, en…", - "glp1WeeklyPresetHelp": "La mayoría de los agonistas del receptor GLP-1 se inyectan una vez por semana. Toca un día para fijar el ritmo.", - "glp1WeeklyPresetSuggest": "Reutilizar el último día elegido", - "glp1DosesPerUnit": "Dosis por pluma / vial", - "glp1DosesPerUnitPlaceholder": "p. ej. 4 para una pluma Mounjaro KwikPen", - "glp1DosesPerUnitHelp": "Opcional — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy pluma de un solo uso = 1. Déjalo vacío si no quieres llevar el conteo de dosis por pluma.", "glp1LastInjection": "{label}", - "glp1LastInjectionWithSite": "{label} · {site}", "glp1NextInjectionDays": "{label} (en {days} días)", "glp1NextInjectionToday": "hoy", "glp1NextInjectionTomorrow": "mañana", - "glp1RotationLast": "Último sitio: {site}", "glp1RotationSuggested": "Sugerido: {site}", "glp1LogSideEffect": "Registrar efecto secundario", "sideEffects": { @@ -1260,8 +1192,7 @@ "add": "Añadir otro horario", "edit": "Editar", "remove": "Quitar", - "removeDisabled": "Se requiere al menos un horario.", - "empty": "Aún no hay horario." + "removeDisabled": "Se requiere al menos un horario." } }, "errors": { @@ -1305,38 +1236,6 @@ "sideEffectTagAppetiteLoss": "Pérdida de apetito", "sideEffectTagHeartburn": "Ardor de estómago", "sideEffectTagHeadache": "Dolor de cabeza", - "newSchedule": "Nueva franja horaria", - "removeSchedule": "Quitar franja horaria", - "advancedEditing": "Edición avanzada", - "advancedEditingHide": "Ocultar edición avanzada", - "scheduleInterval": "Intervalo", - "scheduleEveryWeek": "cada semana", - "scheduleEveryNWeeks": "cada {n} semanas", - "scheduleIntervalPrefix": "Intervalo:", - "nextSchedulePrefix": "Próxima franja:", - "nextScheduleDaily": "a diario", - "importIntakesAction": "Importar tomas", - "apiEndpointAction": "Endpoint de API", - "pause": "Pausar", - "activate": "Activar", - "disableNotifications": "Desactivar notificaciones", - "enableNotifications": "Activar notificaciones", - "purgeRecords": "Eliminar registros", - "formReset": "Restablecer formulario", - "createMedication": "Crear medicamento", - "deleteConfirmDescription": "Este medicamento y todas sus tomas se eliminarán de forma permanente.", - "purgeConfirmTitle": "¿Eliminar todas las tomas?", - "purgeConfirmDescription": "Se eliminarán de forma permanente todas las tomas registradas para este medicamento.", - "saveError": "Error al guardar", - "deleteError": "Error al eliminar", - "purgeError": "Error al limpiar los registros", - "invalidTimestamp": "Selecciona una fecha y hora válidas.", - "noIntakesYet": "Aún no hay tomas registradas.", - "actions": "Acciones", - "pageInfo": "Página {page} de {total}", - "previousPage": "Página anterior", - "nextPage": "Página siguiente", - "loginRequiredIntakes": "Inicia sesión para ver el historial de tomas.", "phaseConfig": "Fases de recordatorio", "phaseConfigDescription": "Configura los momentos de los recordatorios.", "phaseGreen": "Verde", @@ -1345,11 +1244,8 @@ "phaseRed": "Rojo", "phaseMinutes": "Mín", "phasePercent": "%", - "phaseBeforeEnd": "antes del final", - "phaseAfterEnd": "después del final", "phaseResetDefaults": "Restablecer valores por defecto", "phaseSaved": "Fases de recordatorio guardadas", - "phaseReset": "Restablecidas a valores por defecto", "sourceWeb": "Web", "sourceApi": "App de iOS", "sourceReminder": "Recordatorio", @@ -1369,16 +1265,6 @@ "intakeHistoryPageInfo": "Página {page} de {total} · {count} entradas", "intakeHistoryPrev": "Anterior", "intakeHistoryNext": "Siguiente", - "unitDrops": "gotas", - "unitTablets": "comprimidos", - "unitCapsules": "cápsulas", - "unitPieces": "unidades", - "namePlaceholder": "p. ej. Ramipril", - "dosePlaceholder": "p. ej. 5 mg", - "labelPlaceholder": "p. ej. Por la mañana", - "defaultDose": "Dosis estándar", - "dayDeactivate": "Desactivar {day}", - "dayActivate": "Activar {day}", "glp1": { "drug": { "tirzepatide": { @@ -1635,8 +1521,6 @@ "historyLabel": "Ver historial de tomas" }, "history": { - "back": "Volver al medicamento", - "subtitle": "Historial completo de tomas", "plannedSuffix": "planificado" }, "tabs": { @@ -2008,7 +1892,8 @@ "bmiEmptyTitle": "Añade tu altura para calcular el IMC", "bmiEmptyDescription": "El IMC necesita tu altura. Guárdala en Ajustes → Cuenta.", "bmiEmptyAction": "Abrir ajustes de cuenta", - "noProviderConfigured": "Valoración no disponible.", + "noProviderConfigured": "Conecta un proveedor de IA para ver aquí una valoración de tus lecturas.", + "noProviderAction": "Abrir ajustes de IA", "startAnalysis": "Iniciar valoración", "refreshAnalysis": "Actualizar valoración", "keyTakeaway": "Lo más importante", @@ -2081,11 +1966,11 @@ "progressAria": "Progreso de la puntuación de salud", "deltaVsLastWeek": "{delta} respecto a la semana anterior", "deltaUnavailable": "Aún no hay historial — vuelve la semana que viene.", + "restModePaused": "En pausa durante la enfermedad — hoy tu puntuación no se evalúa.", "componentBp": "PA", "componentWeight": "Peso", "componentMood": "Estado de ánimo", "componentCompliance": "Cumplimiento", - "disclaimer": "Orientación — no es valoración médica.", "deltaExplainer": { "trigger": "¿Qué significa este cambio?", "title": "Cómo se calcula el cambio", @@ -2123,7 +2008,7 @@ "emptyAction": "Generar resumen", "loadingLabel": "Preparando el resumen", "noProviderTitle": "Ningún proveedor de IA conectado", - "noProviderDescription": "Conecta un proveedor de IA en los ajustes para recibir resúmenes diarios.", + "noProviderDescription": "El briefing diario resume tus lecturas en lenguaje claro. HealthLog necesita un proveedor de IA para ello: usa tu propia clave (OpenAI, Anthropic) o un modelo local. Tus datos siguen siendo tuyos.", "noProviderAction": "Abrir ajustes de IA" }, "trendsRow": { @@ -2143,7 +2028,8 @@ "distance": "Distancia caminando y corriendo de los últimos 30 días.", "vo2_max": "VO₂ máx de los últimos 30 días.", "body_temp": "Temperatura corporal de los últimos 30 días." - } + }, + "viewDetail": "Ver detalles de {metric}" }, "vo2Max": { "statLatest": "Actual", @@ -2165,7 +2051,6 @@ }, "correlationRow": { "title": "Correlaciones", - "disclaimer": "Las correlaciones son observacionales, no causales — habla con tu médico si tu tensión es alta.", "experimentCta": "Iniciar experimento de 7 días", "experimentTooltip": "Próximamente", "emptyTitle": "Necesitamos más datos para mostrar esta correlación", @@ -2188,6 +2073,8 @@ "refusalInjection": "Tu mensaje contiene instrucciones que pretenden sobrescribir mis indicaciones. Soy el coach de HealthLog y solo resumo tus propios datos de salud — reformula tu pregunta dentro de ese marco.", "errorBudget": "Has alcanzado el límite diario del coach. El cupo se reinicia a medianoche (UTC).", "errorProvider": "El coach no ha podido contactar con un proveedor de análisis ahora mismo. Inténtalo en un momento.", + "errorNoProvider": "El coach lee tus datos de salud registrados y responde preguntas sobre ellos (no es consejo médico). HealthLog necesita un proveedor de IA para ello: conecta tu propia clave o un modelo local.", + "errorNoProviderAction": "Conectar un proveedor de IA", "errorNetwork": "Sin conexión a Internet — inténtalo de nuevo cuando vuelvas a estar en línea.", "prefsSaveError": "No se pudieron guardar tus preferencias del Coach. Inténtalo de nuevo.", "errorCredentialExpired": "Tu conexión con el proveedor de IA ha caducado. Vuelve a conectarla en Ajustes para que el Coach siga funcionando.", @@ -2604,7 +2491,19 @@ "strainBody": "El esfuerzo capta cuánta carga cardiovascular y física le has impuesto a tu cuerpo, desde los entrenamientos hasta un día activo de pie. Cuando ya hay suficiente historial, se valora frente a tu propio esfuerzo habitual, no frente a un estándar ajeno.", "stressBody": "El estrés aquí es una estimación de la carga fisiológica sobre tu sistema nervioso, leída sobre todo a partir de patrones de variabilidad de la frecuencia cardíaca y no de cómo te sientes conscientemente. Una puntuación más baja apunta a un estado calmado y bien regulado; una más alta es una invitación a incorporar recuperación.", "recoveryPageTitle": "Recuperación", - "recoveryPageBody": "La recuperación reúne las señales nocturnas que registra tu wearable —recarga, esfuerzo, carga y los totales de frecuencia cardiaca y energía del día— para mostrar cómo se ha recuperado tu cuerpo. Fíjate en el patrón a lo largo de los días más que en un solo número; un valor más bajo es una invitación a bajar el ritmo, no una alarma." + "recoveryPageBody": "La recuperación reúne las señales nocturnas que registra tu wearable —recarga, esfuerzo, carga y los totales de frecuencia cardiaca y energía del día— para mostrar cómo se ha recuperado tu cuerpo. Fíjate en el patrón a lo largo de los días más que en un solo número; un valor más bajo es una invitación a bajar el ritmo, no una alarma.", + "pulsePressureTitle": "Presión de pulso", + "pulsePressureBody": "La presión de pulso es la diferencia entre tu valor sistólico y diastólico (sistólico menos diastólico). Como orientación general, unos 40 mmHg es lo típico en reposo; una diferencia amplia por encima de unos 60 mmHg en la mediana edad puede reflejar arterias más rígidas, mientras que una diferencia estrecha por debajo de unos 25 mmHg puede acompañar a un bajo volumen sistólico (StatPearls/NIH 2023). Es un motivo de conversación, no un diagnóstico.", + "meanArterialPressureTitle": "Presión arterial media", + "meanArterialPressureBody": "La presión arterial media es la presión promedio que ven tus órganos a lo largo de un latido, aproximadamente la diastólica más un tercio de la diferencia con la sistólica. Como orientación general, un valor en reposo ronda los 70–100 mmHg (StatPearls/NIH 2023). El umbral de ~60 mmHg que se suele citar es un concepto de cuidados agudos, no una alarma para el autocontrol.", + "cycleEducationBody": "Tu ciclo atraviesa cuatro fases impulsadas por hormonas, y su duración y regularidad son una ventana accesible a la salud general. Como orientación general, los ciclos en personas adultas suelen durar 21–35 días (las referencias clínicas citan 24–38), con un sangrado de unos cinco días (ACOG 2015). La fase lútea es el ancla estable, así que la ovulación se estima mejor contando hacia atrás desde tu próxima regla prevista, nunca con un «día 14» fijo. Una ventana fértil predicha es una estimación que necesita varios ciclos registrados y sigue siendo aproximada, y la temperatura basal confirma la ovulación a posteriori en lugar de predecirla. No es un método anticonceptivo.", + "sleepHygieneBody": "Algunos hábitos con respaldo de evidencia favorecen el sueño: mantener horarios constantes de acostarse y levantarse incluso los fines de semana, recibir luz brillante por la mañana y atenuar las noches, una habitación fresca, oscura y silenciosa, y evitar la cafeína por la tarde, el alcohol tardío y las comidas copiosas a última hora (CDC 2024). La regularidad predice la salud al menos tanto como la duración, y el tiempo total de sueño de un wearable es mucho más fiable que su reparto entre sueño profundo, ligero y REM.", + "referenceGuidance": "Orientación general, no consejo médico. Los rangos de referencia son valores poblacionales que cambian con tu edad, sexo y condiciones de salud; tu propia tendencia a lo largo del tiempo guía la lectura." + }, + "bpDerived": { + "pulsePressure": "La presión de pulso (la diferencia entre tus valores) es de unos {value} mmHg; alrededor de 40 es lo típico en reposo.", + "meanArterialPressure": "La presión arterial media (el promedio que ven tus órganos) es de unos {value} mmHg, normalmente entre 70 y 100.", + "caveat": "Estos valores se derivan de tu última lectura como orientación general, no como diagnóstico." }, "stats": { "label": "Estadísticas resumidas", @@ -2641,7 +2540,8 @@ "asleepTotal": "Dormido {value}", "inBedTotal": "En la cama {value}", "awakenings": "{count} despertares", - "source": "Fuente: {source}" + "source": "Fuente: {source}", + "estimateNote": "Los tiempos de cada fase son estimados: esta fuente informa totales por fase, no una cronología medida." }, "stages": { "deep": "Profundo", @@ -2668,7 +2568,7 @@ "chronotype": { "title": "Cronotipo", "learning": "Aún estamos aprendiendo tu ritmo — {counted} de {total} noches libres. Un cronotipo necesita varias mañanas sin despertador para tener sentido.", - "midSleepCaption": "Punto medio del sueño en días libres en torno a las {clock}", + "midSleepCaption": "El punto medio natural de tu sueño en días libres ronda las {clock}.", "advancedToggle": "Avanzado", "socialJetlagValue": "{hours} h {minutes} min de jet lag social", "socialJetlagCaption": "La diferencia entre el horario de sueño laboral y el de los días libres — como un pequeño cambio de huso horario semanal.", @@ -2681,7 +2581,8 @@ "intermediate": "Intermedio", "late": "Vespertino", "extreme_late": "Vespertino marcado" - } + }, + "cornerLabel": "Cronotipo" }, "rhythm": { "loadError": "Tu ritmo de sueño no pudo cargarse ahora mismo. Reaparecerá en cuanto se estabilice la conexión." @@ -2825,6 +2726,7 @@ "title": "Resumen de glucosa", "subtitle": "Una lectura de tus últimos {days} días, a partir de {count} mediciones.", "spotCaveat": "Son estimaciones a partir de mediciones puntuales, no un perfil de un sensor continuo: léelas como una tendencia, no como un veredicto clínico.", + "labsLink": "¿Resultados de laboratorio? Registra una HbA1c o glucosa en ayunas de una analítica en Laboratorio.", "learningTitle": "Aún conociendo tu glucosa", "learningBody": "De momento son {count} en {days}. Con algo más de historial el panorama se aclara: sigue registrando o deja que lo complete un dispositivo conectado.", "learningBodyEmpty": "En cuanto lleguen unas cuantas mediciones —registradas por ti o sincronizadas desde un dispositivo— tu tiempo en rango, promedio y variabilidad tomarán forma aquí.", @@ -3542,9 +3444,6 @@ "target": { "heading": "Objetivo" }, - "disclaimer": { - "footer": "HealthLog describe tus propios datos registrados — no es una evaluación clínica ni un diagnóstico. Habla con tu médico sobre cualquier cosa que te preocupe." - }, "settings": { "overviewTitle": "Organizar la vista general", "overviewDescription": "Muestra u oculta secciones y define su orden en la vista general de Análisis." @@ -3572,25 +3471,34 @@ }, "sleepQuality": { "title": "Calidad del sueño", - "subtitle": "Puntuaciones nocturnas de tu wearable" + "subtitle": "Puntuaciones nocturnas de tu wearable", + "assessment": { + "metric": { + "SLEEP_SCORE": "tu puntuación global de sueño", + "SLEEP_EFFICIENCY": "la eficiencia del sueño", + "SLEEP_PERFORMANCE": "el rendimiento del sueño", + "SLEEP_CONSISTENCY": "la regularidad del sueño" + }, + "grade": { + "excellent": "excelente", + "good": "sólida", + "fair": "aceptable", + "low": "más bien baja" + }, + "clause": "En las últimas 30 noches {metric} es {grade}, en {value}.", + "closing": { + "excellent": "Mantén la rutina que te trae hasta aquí.", + "good": "Acostarte y levantarte a horas más estables suele ser la siguiente mejora.", + "fair": "Proteger una franja de sueño constante es lo que más suele subir esto.", + "low": "Una rutina de relajación regular y una hora fija para despertar son las primeras palancas." + } + } }, "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" - }, "block": { "ansChargeExplainer": "Cuán cargado está tu sistema nervioso autónomo para el día — una lectura de recarga obtenida de tus señales nocturnas de corazón y respiración.", "dayStrainExplainer": "La carga cardiovascular del día en la escala de esfuerzo de tu dispositivo. Léela en relación con tu recuperación, no como una meta en sí.", @@ -3608,8 +3516,6 @@ }, "thresholds": { "defaultLabel": "Por defecto", - "autoModeLabel": "Auto", - "overrideModeLabel": "Personalizado", "overrideToggleLabel": "Valores propios", "resetAction": "Restablecer valor por defecto", "resetAllAction": "Restablecer todos los valores", @@ -3646,31 +3552,72 @@ "back": "Atrás", "skip": "Saltar tour", "done": "Entendido", - "restart": "Reiniciar tour", - "restartHint": "Vuelve a mostrar el tour del panel la próxima vez.", - "restartConfirmation": "El tour se reiniciará en tu próxima visita al panel.", + "restart": "Reiniciar la visita de módulos", + "restartHint": "Vuelve a reproducir la visita guiada de módulos desde el principio en tu próxima visita al panel.", + "restartConfirmation": "La visita de módulos se reiniciará desde el principio en tu próxima visita al panel.", "steps": { - "tileStrip": { - "title": "Tus valores del día", - "body": "La fila de tarjetas muestra el último valor y la tendencia de 7 días de cada métrica — peso, tensión arterial, pulso, ánimo. Oculta lo que no necesites en Ajustes → Panel." + "dashboardOverview": { + "title": "Tu panel", + "body": "El panel reúne todos los módulos de un vistazo. Mantén pulsada una tarjeta para reordenarla u ocultarla (Ajustes → Diseño del panel). Cada tarjeta vale tanto como los datos que tiene detrás: los módulos siguientes los rellenan." }, "quickAdd": { - "title": "Registrado en dos toques", - "body": "Con el botón Añadir puedes registrar mediciones o entradas de ánimo sin salir del panel. El formulario recuerda tu último contexto y etiquetas." + "title": "Registrado en segundos", + "body": "Con «+ Añadir» registras cualquier medición en segundos: peso, tensión, pulso, glucosa. Fuerza oculta: fechar valores olvidados. Todo lo que registras aquí alimenta las tendencias, la puntuación de salud y las ideas de IA." + }, + "measurements": { + "title": "Mediciones y rangos objetivo", + "body": "Mediciones muestra el historial y el rango objetivo de cada métrica. Joya oculta: define un rango por valor para que las desviaciones resalten en color. Estas series son la base de las correlaciones del Coach y del informe médico." + }, + "medications": { + "title": "Medicación y tomas", + "body": "Medicación gestiona horarios, tomas, existencias y titulación. Oculto: planes recurrentes (RRULE/rotativos) y avisos de poco stock. Tu tasa de tomas alimenta el pilar de cumplimiento del panel y el contexto de la IA." + }, + "labs": { + "title": "Valores de laboratorio", + "body": "Laboratorio recoge biomarcadores con rangos de referencia: hemograma, colesterol, HbA1c. Fuerza: importación y exportación FHIR. Los valores fuera de rango reaparecen como pruebas en las ideas y en el informe médico." + }, + "illness": { + "title": "Diario de enfermedades", + "body": "El diario de enfermedades registra episodios agudos y diagnósticos crónicos con sus brotes. Oculto: correlacionar un episodio con tus mediciones y el Modo descanso que pausa los recordatorios. Así el Coach entiende por qué los valores varían un tiempo." + }, + "vorsorge": { + "title": "Prevención", + "body": "Prevención te recuerda revisiones y vacunas con su propio intervalo. Consejo: «Hecho» puede crear directamente una medición o un resultado de laboratorio. Lo pendiente aparece como recordatorio y en el informe médico." + }, + "cycle": { + "title": "Ciclo", + "body": "El seguimiento del ciclo registra fases y síntomas. Oculto: comparar síntomas con el ánimo y el sueño. El ciclo aporta contexto a variaciones que de otro modo parecerían inexplicables." + }, + "mood": { + "title": "Ánimo", + "body": "Ánimo capta cómo te sientes y etiquetas en dos segundos. Fuerza: tus propias etiquetas. Las tendencias de ánimo se correlacionan con el sueño, la medicación y el ciclo: el Coach detecta los patrones." }, "insights": { - "title": "Insights automáticos", - "body": "Insights convierte tus valores en un resumen claro de salud, con interpretación de tensión arterial, peso y ánimo. Clave propia o por defecto — tú eliges." + "title": "Ideas", + "body": "Ideas es el corazón: análisis dinámico y basado en pruebas en lugar de consejos genéricos; cada afirmación apunta a tus datos reales. Oculto: el diseño es personalizable. Ideas reúne todo lo que aportan los módulos anteriores." + }, + "coach": { + "title": "Coach", + "body": "El Coach responde preguntas sobre tus datos en diálogo, usando tu historial, medicación y valores de laboratorio como contexto. Tú eliges el proveedor de IA (tu propia clave o local). No recalcula nada: lee las mismas cifras que el panel." }, "integrations": { - "title": "Conectar dispositivos", - "body": "En Ajustes conectas Withings (sincronización automática de peso, tensión y pulso), moodLog o tu proveedor de análisis. Todo es opcional y queda cifrado." + "title": "Integraciones", + "body": "Las integraciones sincronizan Withings, WHOOP, Polar, Oura y Apple Health automáticamente. Peso, sueño y recuperación se rellenan sin teclear, y a su vez alimentan las ideas y el Coach." + }, + "export": { + "title": "Informe médico", + "body": "El informe médico reúne mediciones, medicación, valores de laboratorio y prevención en PDF o FHIR para la cita. Aquí convergen todos los módulos: un informe completo solo sale de datos bien cuidados." }, "achievements": { - "title": "Acumula rachas e insignias", - "body": "La página de Logros lleva la cuenta de rachas de registro diario, hitos de tensión arterial e insignias de constancia. Un pequeño empujón para no abandonar." + "title": "Logros", + "body": "Los logros premian la constancia: rachas e hitos. Un pequeño bucle de motivación que facilita seguir adelante." + }, + "wrapUp": { + "title": "Esto es la visita", + "body": "Puedes volver a ver cualquier módulo por separado cuando quieras («Mostrar esta visita» en cada página) o reiniciar la visita completa en Ajustes → Avanzado." } - } + }, + "moduleTrigger": "Mostrar esta visita" }, "shell": { "back": "Atrás", @@ -3741,13 +3688,20 @@ "learning": "Algunas conclusiones —glucosa, ritmo de sueño, tendencias de presión arterial, ciclo— se afinan durante la primera semana o dos a medida que se acumulan tus datos. Las casillas vacías ahora solo significan que aún estamos aprendiendo tu línea base.", "connectCta": "Conectar un dispositivo", "logCta": "Registra tu primera medición", - "importCta": "Importar historial existente" + "importCta": "Importar historial existente", + "aiTitle": "Activar Insights con IA (opcional)", + "aiBody": "El coach, el briefing diario y las valoraciones convierten tus lecturas en lenguaje claro. Para ello conectas tu propia clave de IA (OpenAI, Anthropic) o un modelo local: tus datos siguen siendo tuyos.", + "aiCta": "Conectar un proveedor de IA" }, "welcomeBack": { "title": "Bienvenido de nuevo", "body": "Ya completaste la configuración inicial. Para cambiar algo, abre Ajustes — todo lo que configuraste se puede editar desde ahí.", "cta": "Abrir el panel" }, + "disclaimer": { + "acknowledge": "Lo entiendo: HealthLog es una herramienta privada de seguimiento, no una valoración clínica ni un diagnóstico. Si me preocupa algo, hablaré con mi médico, no con esta app.", + "learnMore": "Más información" + }, "welcome": { "title": "Bienvenido a HealthLog", "carouselLabel": "Carrusel de bienvenida", @@ -3829,18 +3783,21 @@ "sections": { "account": { "title": "Cuenta", - "description": "Perfil, contraseña, claves de acceso y tu recorrido de incorporación." + "description": "Perfil, contraseña, claves de acceso y tu recorrido de incorporación.", + "subtitle": "Tu perfil, identidad y datos de acceso." }, "integrations": { "title": "Integraciones", - "description": "Servicios conectados, canales de entrega y prioridad de fuentes en un solo lugar." + "description": "Servicios conectados, canales de entrega y prioridad de fuentes en un solo lugar.", + "subtitle": "Conecta y gestiona tus fuentes de datos y dispositivos." }, "notifications": { "title": "Notificaciones", "description": "Todos los recordatorios en un solo lugar: qué se recuerda y cuándo. Los canales de entrega están en Integraciones.", "channelsHint": "Telegram, ntfy, Web Push, Webhook y correo se configuran ahora en", "channelsHintLink": "Integraciones → Canales", - "inboxLink": "Abrir la bandeja de entrada" + "inboxLink": "Abrir la bandeja de entrada", + "subtitle": "Elige qué recordatorios recibes." }, "layout": { "title": "Diseño", @@ -3853,15 +3810,18 @@ "insights": { "title": "Análisis", "description": "Orden de la vista general y de las pestañas de navegación." - } + }, + "subtitle": "Personaliza cómo se organizan tu panel e insights." }, "dashboard": { "title": "Panel", - "description": "Diseño y orden de los mosaicos." + "description": "Diseño y orden de los mosaicos.", + "subtitle": "Organiza los mosaicos de tu panel." }, "thresholds": { "title": "Objetivos", - "description": "Por métrica: tu propio rango objetivo de valores saludables." + "description": "Por métrica: tu propio rango objetivo de valores saludables.", + "subtitle": "Define tus rangos objetivo personales por métrica." }, "sources": { "title": "Prioridad de fuentes", @@ -3920,19 +3880,23 @@ "scale": "Báscula", "other": "Otro", "unknown": "Desconocido" - } + }, + "subtitle": "Decide qué fuente prevalece cuando dos informan la misma métrica." }, "ai": { - "title": "Análisis con IA", - "description": "Proveedor, modelo y clave API para los análisis, además de tu contexto «sobre mí»." + "title": "Proveedor de IA", + "description": "Proveedor, modelo y clave API para los análisis, además de tu contexto «sobre mí».", + "subtitle": "Elige tu proveedor de IA y usa tu propia clave." }, "api": { "title": "API & Tokens", - "description": "Tokens Bearer para tus propios scripts y apps — registra mediciones o tomas de medicación desde cualquier lugar." + "description": "Tokens Bearer para tus propios scripts y apps — registra mediciones o tomas de medicación desde cualquier lugar.", + "subtitle": "Gestiona los endpoints de la API y los tokens de acceso." }, "advanced": { "title": "Avanzado", - "description": "Activador opt-in del modo Investigación y zona de peligro para borrar irrevocablemente todos los registros de salud." + "description": "Activador opt-in del modo Investigación y zona de peligro para borrar irrevocablemente todos los registros de salud.", + "subtitle": "Modo investigación, restablecer datos y eliminar cuenta." }, "export": { "title": "Exportar e importar", @@ -4033,27 +3997,33 @@ "resultSummary": "{inserted} nuevas importadas y {updated} actualizadas, {skipped} omitidas.", "rowError": "Línea {line}: {reason}" } - } + }, + "subtitle": "Exporta tus datos o importa desde una copia de seguridad." }, "about": { "title": "Acerca de esta app", - "description": "Versión, licencia, enlaces." + "description": "Versión, licencia, enlaces.", + "subtitle": "Versión, licencia y enlaces del proyecto." }, "sharing": { "title": "Compartir", - "description": "Crea un enlace de solo lectura para compartir tu historial de salud con un profesional médico." + "description": "Crea un enlace de solo lectura para compartir tu historial de salud con un profesional médico.", + "subtitle": "Comparte acceso de solo lectura a tus datos." }, "insights": { "title": "Análisis", - "description": "Orden de la vista general y de las pestañas de navegación." + "description": "Orden de la vista general y de las pestañas de navegación.", + "subtitle": "Organiza las secciones de tu resumen y el orden de las píldoras." }, "medications": { "title": "Medicamentos", - "description": "Vista de lista, orden y zonas de inyección de tus medicamentos." + "description": "Vista de lista, orden y zonas de inyección de tus medicamentos.", + "subtitle": "Gestiona la vista y el orden de tu lista de medicamentos." }, "mood": { "title": "Etiquetas de ánimo", - "description": "Grupos, etiquetas y etiquetas archivadas del selector de ánimo." + "description": "Grupos, etiquetas y etiquetas archivadas del selector de ánimo.", + "subtitle": "Organiza tus etiquetas de ánimo, grupos y orden del selector." }, "modules": { "title": "Módulos", @@ -4063,26 +4033,24 @@ "title": "Qué registras", "description": "Áreas opcionales. Los módulos desactivados desaparecen de la navegación, los paneles y los análisis; tus datos se conservan." }, - "core": { - "title": "Siempre activos", - "description": "Los pilares de HealthLog. Estas áreas no se pueden desactivar.", - "note": "Área esencial — siempre activa" - }, - "description": "Elige qué registra HealthLog por ti. Activa o desactiva áreas concretas: la navegación, los paneles y los análisis se adaptan al instante.", "manageIn": "Gestionar en {section}", - "operatorDisabled": "Desactivado en todo el servidor" + "operatorDisabled": "Desactivado en todo el servidor", + "subtitle": "Elige qué áreas de seguimiento muestra HealthLog en la app." }, "gesundheitsakte": { "title": "Historial médico", - "description": "Exporta tu historial médico completo como PDF, FHIR R4 o un paquete ZIP combinado." + "description": "Exporta tu historial médico completo como PDF, FHIR R4 o un paquete ZIP combinado.", + "subtitle": "Exporta un historial de salud completo, listo para el médico." }, "coach": { "title": "Coach", - "description": "Activa el Coach, tono y alcance, recuerdos guardados." + "description": "Activa el Coach, tono y alcance, recuerdos guardados.", + "subtitle": "Ajusta el Coach, sus avisos proactivos y su memoria." }, "channels": { "title": "Canales", - "description": "Envío por Telegram, ntfy, Web Push, Webhook y correo electrónico." + "description": "Envío por Telegram, ntfy, Web Push, Webhook y correo electrónico.", + "subtitle": "Dónde se entregan los recordatorios y avisos." } }, "profile": "Perfil", @@ -4579,9 +4547,7 @@ "changelog": "Changelog", "docs": "Documentación", "linksHeading": "Fuentes y documentación", - "newerAvailable": "Nueva versión: {tag}", - "tourReplay": "Volver a ver el recorrido", - "tourReplayHint": "Mira el recorrido del panel — útil si lo saltaste en la primera visita." + "newerAvailable": "Nueva versión: {tag}" }, "testConnection": { "test": "Probar conexión", @@ -4964,7 +4930,6 @@ "modules": { "title": "Disponibilidad de módulos", "description": "Desactiva un módulo para todo el servidor. Un módulo desactivado aquí queda apagado para todas las cuentas, sin importar la preferencia personal. Los dominios principales (peso, presión arterial, pulso, medicación) están siempre activos y no se pueden desactivar.", - "disabledHint": "Desactivado en todo el servidor", "saveError": "No se pudo actualizar la disponibilidad de módulos." }, "bugReportRepo": "Repositorio", @@ -5343,11 +5308,14 @@ }, "about": { "title": "Acerca de", - "subtitle": "Versión, licencia, código fuente y comprobación de actualizaciones." + "subtitle": "Versión, licencia y estado de actualizaciones." + }, + "module-availability": { + "title": "Disponibilidad de módulos", + "subtitle": "Activa o desactiva módulos para todas las cuentas de este servidor." } }, "carrier": "Operador", - "carrierUnknown": "Operador desconocido", "carrierUnavailableGeoFallback": "{location} — operador no disponible" }, "achievements": { @@ -5679,7 +5647,6 @@ "title": "Notificaciones", "breadcrumbInbox": "Notificaciones", "breadcrumbChannels": "Canales de notificación", - "breadcrumbSettings": "Ajustes", "eventColumn": "Evento", "subtitle": "Elige qué notificaciones recibes y por qué canal.", "loadError": "No se han podido cargar los ajustes de notificación.", @@ -5796,7 +5763,6 @@ "dotNearRange": "cerca del rango", "dotOutOfRange": "fuera", "dotNotLogged": "no registrado", - "insufficientData": "Aún hay pocos datos — sigue registrando.", "relativeDay": { "today": "Hoy", "yesterday": "Ayer", @@ -5808,7 +5774,6 @@ } }, "edit": { - "openLabel": "Editar el rango objetivo de {metric}", "title": "Editar rango objetivo — {metric}", "description": "Ajusta el rango objetivo usado en gráficas, insights y el informe médico. Valores en {unit}.", "derivedHint": "Esta tarjeta se calcula a partir de otras métricas. Edita en su lugar los objetivos subyacentes (p. ej. tensión arterial o peso).", @@ -5824,13 +5789,6 @@ "saveError": "No se ha podido guardar el rango objetivo", "resetSuccess": "Rango objetivo restablecido por defecto" }, - "mood": { - "stability": { - "stable": "estable", - "variable": "variable", - "highlyVariable": "muy variable" - } - }, "currentValue": "Actual: {value} {unit}", "targetRangeValue": "Rango objetivo: {min}–{max} {unit}", "sourceLabel": "Fuente: {source}", @@ -5936,7 +5894,6 @@ }, "lowStockReminders": { "title": "Quedan pocas existencias: {medName}", - "body": "{medName}: quedan {units} unidades — para unos {days} días más.", "bodyToday": "{medName}: quedan {units} unidades — menos de un día de suministro.", "bodyReorder": "{medName}: quedan {units} unidades. Las existencias se agotan hacia el {runsOutOn} — vuelve a pedir antes del {reorderBy}.", "bodyLastDose": "{medName}: quedan {units} unidades — alrededor de tu última dosis. Las existencias se agotan hacia el {runsOutOn}." @@ -6044,7 +6001,6 @@ "description": "Cómo se compara cada medición entre tus fases lútea y folicular, con tus propios datos.", "pairLabel": "{metric} · lútea vs. folicular", "detail": "Lútea {lutealAvg} {unit} ({lutealDays} días) vs. folicular {follicularAvg} {unit} ({follicularDays} días).", - "footer": "Son asociaciones en tus propios datos, no causas. Solo se muestran los patrones que superan una prueba de significación.", "metricRestingHeartRate": "Frecuencia cardíaca en reposo", "metricHrv": "Variabilidad de la frecuencia cardíaca", "metricSleepDuration": "Duración del sueño", @@ -6068,10 +6024,6 @@ "confidenceHigh": "Confianza alta" }, "title": "Análisis del ciclo", - "empty": "Los análisis por fase aparecen aquí cuando tienes suficientes ciclos registrados. Comparan tus métricas entre fases del ciclo, mostrando la estadística abiertamente.", - "sampleSize": "n = {n}", - "effectSize": "Tamaño del efecto {value}", - "qValue": "q = {value}", "symptomPatternsTitle": "Patrones de síntomas por fase", "symptomPatternsEmpty": "Registra síntomas a lo largo de un ciclo para ver en qué fase se concentran.", "symptomMostlyIn": "Sobre todo en {phase} ({count}/{total} días)" @@ -6091,7 +6043,6 @@ "LUTEAL": "La progesterona guía la segunda mitad y la temperatura basal suele asentarse algo más alta que al inicio del ciclo. Algunas personas notan cambios en el sueño, el apetito, el ánimo o la energía a medida que pasan los días, mientras que otras se sienten estables todo el tiempo. El patrón es personal y a menudo se ve distinto de un mes a otro." }, "commonHere": "Esto lo notas a menudo aquí", - "trackNudge": "Registrar hoy", "stillLearning": "Aún estamos conociendo tu ciclo. Sigue registrando y esto se irá completando con patrones de tus propios días." }, "insightsSummary": { @@ -6184,10 +6135,7 @@ "prevMonth": "Mes anterior", "nextMonth": "Mes siguiente", "loading": "Cargando tu calendario…", - "empty": "Empieza a registrar para ver tu calendario.", "learning": "Estamos aprendiendo tu ciclo: registra al menos tres ciclos completos y se completarán la ventana fértil, la ovulación y las fases. Hasta entonces, el calendario solo muestra lo que has registrado.", - "today": "Hoy", - "dayAria": "{date}", "legendSymptom": "Síntomas", "legendOvulationConfirmed": "Ovulación confirmada", "flowSpotting": "manchado", @@ -6198,11 +6146,9 @@ "predictions": { "title": "Qué viene después", "nextPeriod": "Próximo periodo", - "nextPeriodRange": "Alrededor del {date}", "nextPeriodWindow": "{low} – {high}", "fertileWindow": "Ventana fértil", "ovulation": "Ovulación estimada", - "confidence": "Confianza", "confidenceHigh": "Alta", "confidenceMedium": "Media", "confidenceLow": "Baja", @@ -6238,7 +6184,6 @@ "sheet": { "title": "Registrar tu día", "description": "Captura el flujo, los síntomas y las notas de hoy.", - "date": "Fecha", "flow": "Flujo", "symptoms": "Síntomas", "temperature": "Temperatura basal", @@ -6258,10 +6203,7 @@ "save": "Guardar", "saving": "Guardando…", "cancel": "Cancelar", - "saved": "Día guardado", "saveError": "No se pudo guardar. Inténtalo de nuevo.", - "periodStarted": "Inicio del periodo registrado", - "clear": "Ninguno", "intermenstrualBleeding": "Manchado entre periodos", "pregnancyTest": "Prueba de embarazo", "progesteroneTest": "Prueba de progesterona", @@ -6277,8 +6219,6 @@ }, "settings": { "title": "Ajustes del ciclo", - "enable": "Seguimiento del ciclo", - "enableDescription": "Activa o desactiva la pestaña del ciclo en tu cuenta.", "typicalCycleLength": "Duración típica del ciclo (días)", "typicalPeriodLength": "Duración típica del periodo (días)", "lutealPhaseLength": "Duración de la fase lútea (días)", @@ -6297,30 +6237,14 @@ "encryptionDescription": "Cifra en reposo los campos que revelan intención (test de embarazo, anticoncepción, relaciones), además de las notas siempre cifradas.", "discreet": "Notificaciones discretas", "discreetDescription": "Oculta los detalles del ciclo en el texto de las notificaciones de tu pantalla de bloqueo.", - "dataTitle": "Tus datos", - "dataDescription": "Tus datos del ciclo viven solo en este servidor. Expórtalos cuando quieras o elimínalos para siempre.", - "export": "Exportar mis datos del ciclo", - "deleteData": "Eliminar mis datos del ciclo", "saved": "Ajustes guardados", "saveError": "No se pudo guardar. Inténtalo de nuevo.", "save": "Guardar", - "purgeTitle": "Eliminar todos los datos del ciclo", - "purgeDescription": "Elimina de forma permanente cada día registrado, ciclo, predicción y registro de recordatorio. No se puede deshacer.", - "purgeButton": "Eliminar todos los datos del ciclo", - "purgeConfirmTitle": "¿Eliminar todos los datos del ciclo?", - "purgeConfirmBody": "Esto elimina de forma permanente todos los periodos, registros diarios, síntomas y predicciones. No se puede deshacer.", - "purgeConfirm": "¿Eliminar permanentemente todos los datos del ciclo?", - "purgeCancel": "Cancelar", - "purgeSuccess": "Todos los datos del ciclo eliminados.", - "purgeError": "No se pudieron eliminar los datos del ciclo.", "reminders": "Recordatorios", "remindersDescription": "Activa los recordatorios de período por canal en los ajustes de notificaciones.", "remindersLink": "Ajustes de notificaciones", "fertileReminder": "Recordatorio de ventana fértil", - "fertileReminderDescription": "Recibe un aviso unos días antes de tu ventana fértil estimada. Desactivado por defecto: actívalo por canal en los ajustes de notificaciones.", - "purgeData": "Eliminar datos del ciclo", - "purgeConfirmAction": "Sí, eliminar todo", - "purgeDone": "Datos del ciclo eliminados." + "fertileReminderDescription": "Recibe un aviso unos días antes de tu ventana fértil estimada. Desactivado por defecto: actívalo por canal en los ajustes de notificaciones." }, "testResult": { "POSITIVE": "Positivo", @@ -6472,17 +6396,29 @@ "pushLocation": "(en {location})", "edit": "Editar", "originCoach": "Coach", - "enabledToggleAria": "Activar o desactivar este recordatorio", "until": "Hasta {date}", "selfPlanned": "Autoplanificado", "captureValue": "Registrar valor", + "measureNow": "Medir ahora", + "nextDueLabel": "Próximo vencimiento:", + "lastDoneLabel": "Última vez:", + "customize": "Personalizar prevención", + "adherence": { + "label": "Dentro del intervalo", + "onTrack": "Al día", + "due": "Toca ahora" + }, + "settings": { + "title": "Personalizar prevención", + "description": "Gestiona la vista, el orden y los recordatorios individuales.", + "back": "Volver a prevención" + }, "capture": { "title": "Registrar medición", "description": "Introduce el valor; el recordatorio se marca como hecho." }, "manage": { - "menuItem": "Personalizar", - "title": "Personalizar recordatorios", + "title": "Gestionar recordatorios", "description": "Activa o desactiva recordatorios individuales.", "empty": "Aún no hay recordatorios para personalizar." }, @@ -6491,6 +6427,17 @@ "openLink": "Abrir prevención" } }, + "moduleList": { + "viewToggleLabel": "Vista", + "viewCards": "Vista de tarjetas", + "viewList": "Vista de lista", + "viewHeading": "Vista", + "viewDescription": "Elige si las entradas se muestran como tarjetas o como lista compacta.", + "reorder": { + "heading": "Orden", + "empty": "Aún no hay entradas para ordenar." + } + }, "labs": { "title": "Resultados de análisis", "subtitle": "Analíticas de sangre y biomarcadores", @@ -6501,36 +6448,36 @@ "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} mediciones", - "deleteLatest": "Eliminar el último valor", "deleteConfirmTitle": "¿Eliminar resultado de laboratorio?", "deleteConfirmDescription": "Este resultado de laboratorio se eliminará de forma permanente.", "deletedToast": "Resultado eliminado", "deleteError": "No se pudo eliminar el resultado", "loadError": "No se pudieron cargar los resultados", + "customize": "Personalizar laboratorio", + "settings": { + "title": "Personalizar laboratorio", + "description": "Gestiona la vista, el orden y los biomarcadores.", + "back": "Volver al laboratorio" + }, + "sort": { + "heading": "Orden", + "label": "Orden de los valores", + "recentDesc": "Medición más reciente primero", + "recentAsc": "Medición más antigua primero", + "manual": "Ordenar manualmente", + "hint": "Con el orden manual puedes arrastrar los biomarcadores de abajo en tu propio orden." + }, "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": "Introduce un valor.", - "rangeOrderError": "La referencia mínima no puede superar la máxima.", "save": "Guardar", "savedToast": "Resultado guardado", "saveError": "No se pudo guardar el resultado", @@ -6567,9 +6514,7 @@ } }, "biomarker": { - "manage": "Biomarcadores", "manageTitle": "Biomarcadores", - "manageSheetDescription": "Define los marcadores que sigues. Cada uno se configura una vez con su unidad y rango de referencia.", "managerDescription": "Define un marcador una vez: su unidad y rango se reutilizan en cada medición.", "define": "Definir", "defineFirst": "Define tu primer biomarcador", @@ -6699,18 +6644,6 @@ "label": "Informe médico", "description": "Exportación PDF y FHIR para tu consulta." }, - "weight": { - "label": "Peso", - "description": "Peso y composición corporal." - }, - "bloodPressure": { - "label": "Presión arterial", - "description": "Sistólica y diastólica." - }, - "pulse": { - "label": "Pulso", - "description": "Pulso en reposo y frecuencia cardiaca." - }, "medications": { "label": "Medicamentos", "description": "Pautas, tomas y cumplimiento." @@ -6723,7 +6656,12 @@ "illness": { "title": "Diario de enfermedad", "subtitle": "Un registro retrospectivo de tus episodios de enfermedad y dolencias.", - "disclaimer": "Un diario personal, no un dispositivo médico. No diagnostica ni predice.", + "customize": "Personalizar diario de enfermedad", + "settings": { + "title": "Personalizar diario de enfermedad", + "description": "Gestiona la vista y el orden de los episodios.", + "back": "Volver al diario de enfermedad" + }, "newEpisode": "Nuevo episodio", "logDay": "Registrar un día", "markRecovered": "Marcar como recuperado", @@ -6823,7 +6761,6 @@ "countOther": "Registraste {count} episodios en este período.", "typicalGap": "Brecha de recuperación típica: {days} días entre sentirte mejor y la estabilización de tus valores.", "typicalGapLearning": "Con unos pocos episodios resueltos más aparecerá aquí una brecha de recuperación típica.", - "byMonth": "Cuándo sueles registrar episodios", "none": "Sin episodios registrados en este período." }, "section": { @@ -6893,5 +6830,24 @@ "reminderSuggestionsLabel": "Sugerencias de frecuencia", "reminderSuggestionsDescription": "Permite que el Coach ofrezca de vez en cuando un recordatorio basado en evidencia para medir una métrica con más regularidad." } + }, + "safety": { + "floor": { + "bpHighTitle": "Presión arterial muy alta", + "bpHighDoctor": "Dos lecturas recientes fueron de {systolic}/{diastolic} mmHg o más. Descansa, vuelve a medir en unos minutos y contacta a tu médico cuanto antes si sigue así de alta.", + "bpHighEmergency": "Dos lecturas recientes fueron de {systolic}/{diastolic} mmHg o más e indicaste síntomas. Si tienes dolor en el pecho, falta de aire, debilidad o cambios en la visión o el habla, llama a emergencias ya.", + "bpLowTitle": "Presión arterial baja", + "bpLowDoctor": "Dos lecturas recientes tuvieron una sistólica por debajo de {systolic} mmHg. Si te mareas o te sientes débil, siéntate o recuéstate y contacta a tu médico si continúa.", + "bpLowEmergency": "Dos lecturas recientes tuvieron una sistólica por debajo de {systolic} mmHg e indicaste síntomas. Si te sientes a punto de desmayar, confuso o con piel fría y húmeda, llama a emergencias ya.", + "glucoseLowTitle": "Glucosa baja", + "glucoseLowDoctor": "Dos lecturas recientes estuvieron por debajo de 70 mg/dL ({value} mg/dL). Toma carbohidratos de acción rápida, vuelve a medir en 15 minutos y contacta a tu médico si los bajones continúan.", + "glucoseLowEmergency": "Dos lecturas recientes estuvieron por debajo de 70 mg/dL ({value} mg/dL) e indicaste síntomas. Trátalo ahora con azúcar rápida; si no puedes o te sientes muy mal, llama a emergencias.", + "glucoseLowSevereTitle": "Glucosa muy baja", + "glucoseLowSevereDoctor": "Dos lecturas recientes estuvieron por debajo de 54 mg/dL ({value} mg/dL). Trátalo de inmediato con carbohidratos de acción rápida, vuelve a medir en 15 minutos y contacta a tu médico.", + "glucoseLowSevereEmergency": "Dos lecturas recientes estuvieron por debajo de 54 mg/dL ({value} mg/dL) e indicaste síntomas. Trátalo ahora; si no retienes alimentos o te sientes muy mal, llama a emergencias.", + "glucoseHighTitle": "Glucosa muy alta", + "glucoseHighDoctor": "Dos lecturas recientes fueron de {value} mg/dL o más. Vuelve a medir, hidrátate y contacta a tu médico cuanto antes si sigue así de alta.", + "glucoseHighEmergency": "Dos lecturas recientes fueron de {value} mg/dL o más e indicaste síntomas. Si tienes vómitos, respiración rápida o confusión, llama a emergencias ya." + } } } diff --git a/messages/fr.json b/messages/fr.json index 4a61eb47d..a9b43fc82 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -18,7 +18,6 @@ "optional": "Facultatif", "active": "Actif", "inactive": "Inactif", - "enabled": "Activé", "disabled": "Désactivé", "networkError": "Erreur réseau", "unknownError": "Erreur inconnue", @@ -74,7 +73,6 @@ "colTaken": "Pris", "colSkipped": "Sauté", "colMissed": "Manqué", - "colTotal": "Total", "colExpected": "Attendues", "colComplianceRate": "Taux d'observance", "moodTitle": "Évolution de l’humeur", @@ -191,7 +189,8 @@ "backToDashboard": "Retour au tableau de bord" }, "offlineBanner": { - "message": "Pas de connexion — tes modifications seront enregistrées dès que tu seras de nouveau en ligne." + "message": "Pas de connexion — tes modifications seront enregistrées dès que tu seras de nouveau en ligne.", + "showingCached": "Affichage de tes dernières données synchronisées." }, "demoBanner": { "message": "Mode démo — les modifications ne sont pas enregistrées." @@ -201,14 +200,12 @@ "measurements": "Mesures", "medications": "Médicaments", "insights": "Insights", - "targets": "Objectifs", "achievements": "Succès", "settings": "Paramètres", "bugreport": "Rapport de bug", "admin": "Console d’administration", "logout": "Se déconnecter", "login": "Se connecter", - "about": "À propos de HealthLog", "notifications": "Notifications", "theme": "Apparence", "themeSystem": "Système", @@ -218,13 +215,11 @@ "userMenu": "Menu utilisateur", "accountSettings": "Paramètres du compte", "mood": "Humeur", - "workouts": "Entraînements", "coach": "Coach", "skipToContent": "Aller au contenu", "sidebar": "Barre latérale", "mainNavigation": "Navigation principale", "mobileNavigation": "Navigation mobile", - "breadcrumb": "Fil d'Ariane", "loadingScreen": "Chargement…", "more": "Plus", "moreSheetTitle": "Plus", @@ -241,7 +236,6 @@ }, "cycle": "Cycle", "labs": "Analyses", - "recovery": "Récupération", "vorsorge": "Prévention", "illness": "Maladie" }, @@ -329,7 +323,6 @@ "sleepShort": "Sommeil", "stepsShort": "Pas", "vo2MaxShort": "VO₂ max", - "bpInTargetShort": "TA-cible", "bpInTargetWindow90": "TA-cible · 90 j", "bpInTargetWindowDynamic": "TA-cible · {days} j", "bpInTargetCollecting": "collecte de données", @@ -383,7 +376,6 @@ "staleHintWeeksOther": "Dernière valeur il y a {count} semaines", "staleHintMonthsOne": "Dernière valeur il y a 1 mois", "staleHintMonthsOther": "Dernière valeur il y a {count} mois", - "coachCta": "Demande au coach", "recentWorkouts": { "title": "Entraînements récents", "viewAll": "Tout voir", @@ -422,7 +414,7 @@ }, "measurements": { "title": "Mesures", - "subtitle": "Poids, tension artérielle, pouls et plus", + "subtitle": "Saisis tes mesures et consulte l'historique complet. Les tendances et analyses sont dans Analyses.", "addMeasurement": "Ajouter", "editMeasurement": "Modifier la mesure", "type": "Type", @@ -498,7 +490,6 @@ "invalidTimestamp": "Choisis une date et une heure valides.", "loginRequired": "Connecte-toi pour voir tes mesures.", "allTypes": "Tous les types", - "filterByType": "Filtrer par type", "measurementCount": "{count} mesures", "emptyTitle": "Aucune mesure pour l’instant", "emptyDescription": "Saisis du poids, de la tension, du pouls ou d’autres valeurs pour repérer les tendances.", @@ -549,7 +540,6 @@ "typeCardioLoad": "Charge cardio", "typeSleepScore": "Score de sommeil", "typeBodyTemperatureDeviation": "Écart de température corporelle", - "filterBySource": "Filtrer par source", "sourceComputed": "Calculé", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", @@ -669,7 +659,6 @@ "factorSadness": "tristesse", "praise": "compliment" }, - "filterBySource": "Filtrer par source", "sourceManual": "Manuel", "sourceWeb": "Web", "sourceTelegram": "Telegram", @@ -740,8 +729,6 @@ "subtitle": "Médication, horaires et prises", "loginRequired": "Connecte-toi pour gérer tes médicaments.", "addMedication": "Ajouter", - "editMedication": "Modifier le médicament", - "newMedication": "Nouveau médicament", "firstMedication": "Ajouter le premier médicament", "addChoice": { "logIntake": "Enregistrer une prise", @@ -777,16 +764,9 @@ "loadFailed": "Impossible de charger les médicaments", "retryLoad": "Réessayer", "name": "Nom", - "dose": "Dose", - "scheduleFrom": "De", - "scheduleTo": "À", - "scheduleLabel": "Libellé", - "scheduleDays": "Jours", - "scheduleDaily": "Quotidien", "inactive": "En pause", "intakeHistory": "Historique des prises", "openDetailPage": "Ouvrir la page de détail du médicament", - "deleteConfirm": "Supprimer le médicament ?", "compliance": "Observance", "importIntakes": "Importer les prises", "importDescription": "Collez un tableau JSON contenant les données de prises. Format attendu :", @@ -801,19 +781,7 @@ "importInvalidSkipped": "{count} entrées non valides ignorées", "importFailed": "Échec de l’importation", "importInvalidFormat": "Format JSON non valide", - "apiEndpointTitle": "Point de terminaison API : {name}", - "apiEndpointDescription": "Utilisez ce point de terminaison pour enregistrer des prises via l’API (par exemple un Raccourci iPhone ou une tâche cron).", - "apiEndpointActive": "Point de terminaison API actif", - "apiEndpointActivated": "Point de terminaison API activé", - "apiEndpointDeactivated": "Point de terminaison API désactivé", - "apiTokenCount": "Actif ({count} jetons)", - "apiToken": "Jeton API", - "apiTokenActiveHint": "Le point de terminaison est actif. Les jetons existants restent valides mais ne peuvent plus être affichés en texte clair.", - "apiTokenActivateHint": "Activez le point de terminaison pour créer un nouveau jeton.", - "apiTokenOnceHint": "Le jeton n’est affiché en texte clair qu’une seule fois, lors de sa création.", "requestExample": "Exemple de requête", - "statusLoadFailed": "Impossible de charger le statut", - "changeFailed": "Échec de la modification", "categoryBloodPressure": "Tension artérielle", "categoryVitamin": "Vitamines", "categoryThyroid": "Thyroïde", @@ -846,21 +814,15 @@ "lastDoseDaysAgo": "Dernière dose il y a {count} jours", "overdue": "En retard", "veryOverdue": "Très en retard", - "compliance7d": "Observance 7 jours", - "compliance30d": "Observance 30 jours", "complianceWindow": "Observance {days} jours", "complianceUnavailable": "Impossible de charger l'observance", "complianceInfo": "Part des doses attendues que vous avez enregistrées. Les plans hebdomadaires comptent semaine par semaine.", "complianceInfoLabel": "Que signifie l'observance ?", - "complianceDoses": "{count} doses", "lastIntake": "Dernière prise :", "nextIntake": "Prochaine prise :", "nextIntakeOverdue": "En retard · {time} — encore rattrapable", "dayStreak": "jours de série", "dayStreakOne": "jour de série", - "cycleNextDoseToday": "Prochaine dose aujourd'hui", - "cycleNextDoseTomorrow": "Prochaine dose demain", - "cycleNextDoseInDays": "Prochaine dose dans {days} jours", "cycleDueToday": "À prendre aujourd'hui", "cycleOverdue": "En retard", "cycleNoClosedCycles": "Aucun cycle de dose terminé pour l'instant", @@ -872,40 +834,10 @@ "intakeUndo": "Annuler", "intakeUndone": "Entrée annulée", "intakeUndoFailed": "Annulation impossible — corrigez dans l'historique", - "weekdaySunday": "Dimanche", - "weekdayMonday": "Lundi", - "weekdayTuesday": "Mardi", - "weekdayWednesday": "Mercredi", - "weekdayThursday": "Jeudi", - "weekdayFriday": "Vendredi", - "weekdaySaturday": "Samedi", - "weekdaySundayPlural": "Dimanches", - "weekdayMondayPlural": "Lundis", - "weekdayTuesdayPlural": "Mardis", - "weekdayWednesdayPlural": "Mercredis", - "weekdayThursdayPlural": "Jeudis", - "weekdayFridayPlural": "Vendredis", - "weekdaySaturdayPlural": "Samedis", - "formType": "Type", - "formDose": "Dose", - "formUnit": "Unité", - "formSchedule": "Fenêtre horaire", - "formTreatmentClass": "Classe de traitement", - "formTreatmentClassHelp": "Choisis « Injection GLP-1 » pour les injections hebdomadaires comme Mounjaro, Ozempic, Wegovy, Zepbound. Active la carte corps et le journal GLP-1.", - "treatmentClassGeneric": "Médicament standard", - "treatmentClassGlp1": "Injection GLP-1", - "glp1WeeklyPresetTitle": "Une fois par semaine, le…", - "glp1WeeklyPresetHelp": "La plupart des agonistes du récepteur GLP-1 sont injectés une fois par semaine. Touche un jour pour fixer le rythme.", - "glp1WeeklyPresetSuggest": "Reprendre le dernier jour choisi", - "glp1DosesPerUnit": "Doses par stylo / flacon", - "glp1DosesPerUnitPlaceholder": "p. ex. 4 pour un stylo Mounjaro KwikPen", - "glp1DosesPerUnitHelp": "Optionnel — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy stylo à usage unique = 1. Laisse vide si tu ne veux pas suivre la dose par stylo.", "glp1LastInjection": "{label}", - "glp1LastInjectionWithSite": "{label} · {site}", "glp1NextInjectionDays": "{label} (dans {days} jours)", "glp1NextInjectionToday": "aujourd’hui", "glp1NextInjectionTomorrow": "demain", - "glp1RotationLast": "Dernier site : {site}", "glp1RotationSuggested": "Conseillé : {site}", "glp1LogSideEffect": "Consigner un effet secondaire", "sideEffects": { @@ -1260,8 +1192,7 @@ "add": "Ajouter un autre horaire", "edit": "Modifier", "remove": "Retirer", - "removeDisabled": "Au moins un horaire est requis.", - "empty": "Pas encore d’horaire." + "removeDisabled": "Au moins un horaire est requis." } }, "errors": { @@ -1305,38 +1236,6 @@ "sideEffectTagAppetiteLoss": "Perte d’appétit", "sideEffectTagHeartburn": "Brûlures d’estomac", "sideEffectTagHeadache": "Maux de tête", - "newSchedule": "Nouvelle fenêtre horaire", - "removeSchedule": "Supprimer la fenêtre", - "advancedEditing": "Édition avancée", - "advancedEditingHide": "Masquer l’édition avancée", - "scheduleInterval": "Intervalle", - "scheduleEveryWeek": "chaque semaine", - "scheduleEveryNWeeks": "toutes les {n} semaines", - "scheduleIntervalPrefix": "Intervalle :", - "nextSchedulePrefix": "Prochaine fenêtre :", - "nextScheduleDaily": "quotidien", - "importIntakesAction": "Importer des prises", - "apiEndpointAction": "Endpoint API", - "pause": "Mettre en pause", - "activate": "Activer", - "disableNotifications": "Désactiver les notifications", - "enableNotifications": "Activer les notifications", - "purgeRecords": "Purger les enregistrements", - "formReset": "Réinitialiser le formulaire", - "createMedication": "Créer le médicament", - "deleteConfirmDescription": "Ce médicament et toutes ses prises seront supprimés définitivement.", - "purgeConfirmTitle": "Supprimer toutes les prises ?", - "purgeConfirmDescription": "Toutes les prises enregistrées pour ce médicament seront supprimées définitivement.", - "saveError": "Erreur lors de l’enregistrement", - "deleteError": "Erreur lors de la suppression", - "purgeError": "Erreur lors de la purge", - "invalidTimestamp": "Choisis une date et une heure valides.", - "noIntakesYet": "Aucune prise enregistrée pour l’instant.", - "actions": "Actions", - "pageInfo": "Page {page} sur {total}", - "previousPage": "Page précédente", - "nextPage": "Page suivante", - "loginRequiredIntakes": "Connecte-toi pour voir l’historique des prises.", "phaseConfig": "Phases de rappel", "phaseConfigDescription": "Configure les moments des rappels.", "phaseGreen": "Vert", @@ -1345,11 +1244,8 @@ "phaseRed": "Rouge", "phaseMinutes": "Min", "phasePercent": "%", - "phaseBeforeEnd": "avant la fin", - "phaseAfterEnd": "après la fin", "phaseResetDefaults": "Rétablir les valeurs par défaut", "phaseSaved": "Phases de rappel enregistrées", - "phaseReset": "Réinitialisées par défaut", "sourceWeb": "Web", "sourceApi": "App iOS", "sourceReminder": "Rappel", @@ -1369,16 +1265,6 @@ "intakeHistoryPageInfo": "Page {page} sur {total} · {count} entrées", "intakeHistoryPrev": "Précédent", "intakeHistoryNext": "Suivant", - "unitDrops": "gouttes", - "unitTablets": "comprimés", - "unitCapsules": "gélules", - "unitPieces": "pièces", - "namePlaceholder": "p. ex. Ramipril", - "dosePlaceholder": "p. ex. 5 mg", - "labelPlaceholder": "p. ex. Le matin", - "defaultDose": "Dose standard", - "dayDeactivate": "Désactiver {day}", - "dayActivate": "Activer {day}", "glp1": { "drug": { "tirzepatide": { @@ -1635,8 +1521,6 @@ "historyLabel": "Voir l'historique des prises" }, "history": { - "back": "Retour au médicament", - "subtitle": "Historique complet des prises", "plannedSuffix": "prévu" }, "tabs": { @@ -2008,7 +1892,8 @@ "bmiEmptyTitle": "Ajoute ta taille pour l’IMC", "bmiEmptyDescription": "L’IMC nécessite ta taille. Renseigne-la dans Paramètres → Compte.", "bmiEmptyAction": "Ouvrir les paramètres du compte", - "noProviderConfigured": "Évaluation indisponible.", + "noProviderConfigured": "Connecte un fournisseur d'IA pour voir ici une évaluation de tes mesures.", + "noProviderAction": "Ouvrir les réglages IA", "startAnalysis": "Lancer l’évaluation", "refreshAnalysis": "Actualiser l’évaluation", "keyTakeaway": "L’essentiel", @@ -2081,11 +1966,11 @@ "progressAria": "Progression du score de santé", "deltaVsLastWeek": "{delta} par rapport à la semaine précédente", "deltaUnavailable": "Pas encore d’historique — reviens la semaine prochaine.", + "restModePaused": "En pause pendant la maladie — ton score n'est pas évalué aujourd'hui.", "componentBp": "TA", "componentWeight": "Poids", "componentMood": "Humeur", "componentCompliance": "Observance", - "disclaimer": "Repère — pas un avis médical.", "deltaExplainer": { "trigger": "Que signifie cet écart ?", "title": "Comment l'écart est calculé", @@ -2123,7 +2008,7 @@ "emptyAction": "Générer le briefing", "loadingLabel": "Préparation du briefing", "noProviderTitle": "Aucun fournisseur d'IA connecté", - "noProviderDescription": "Connecte un fournisseur d'IA dans les réglages pour recevoir des briefings quotidiens.", + "noProviderDescription": "Le briefing quotidien résume tes mesures en langage clair. HealthLog a besoin d'un fournisseur d'IA pour cela : utilise ta propre clé (OpenAI, Anthropic) ou un modèle local. Tes données restent chez toi.", "noProviderAction": "Ouvrir les réglages IA" }, "trendsRow": { @@ -2143,7 +2028,8 @@ "distance": "Distance de marche et de course des 30 derniers jours.", "vo2_max": "VO₂ max des 30 derniers jours.", "body_temp": "Température corporelle des 30 derniers jours." - } + }, + "viewDetail": "Voir le détail de {metric}" }, "vo2Max": { "statLatest": "Actuel", @@ -2165,7 +2051,6 @@ }, "correlationRow": { "title": "Corrélations", - "disclaimer": "Les corrélations sont observationnelles, pas causales — parle à ton médecin si ta tension est élevée.", "experimentCta": "Lancer une expérience de 7 jours", "experimentTooltip": "Bientôt disponible", "emptyTitle": "Il faut plus de données pour afficher cette corrélation", @@ -2188,6 +2073,8 @@ "refusalInjection": "Ton message contient des instructions qui cherchent à remplacer mes consignes. Je suis le coach HealthLog et je ne résume que tes propres données de santé — reformule ta question dans ce cadre.", "errorBudget": "Tu as atteint la limite quotidienne du coach. Le quota se réinitialise à minuit (UTC).", "errorProvider": "Le coach n’a pas pu joindre un fournisseur d’analyse maintenant. Réessaie dans un instant.", + "errorNoProvider": "Le coach lit tes données de santé enregistrées et répond aux questions à leur sujet (pas un avis médical). HealthLog a besoin d'un fournisseur d'IA pour cela : connecte ta propre clé ou un modèle local.", + "errorNoProviderAction": "Connecter un fournisseur d'IA", "errorNetwork": "Pas de connexion Internet — réessaie une fois reconnecté.", "prefsSaveError": "Impossible d’enregistrer vos préférences du Coach. Réessayez.", "errorCredentialExpired": "Ta connexion au fournisseur d'IA a expiré. Reconnecte-la dans les Réglages pour que le Coach continue de fonctionner.", @@ -2604,7 +2491,19 @@ "strainBody": "La charge mesure l'effort cardiovasculaire et physique que vous avez imposé à votre corps, des entraînements à une journée active debout. Dès qu'il y a assez d'historique, elle est évaluée par rapport à votre propre effort habituel, pas à un standard extérieur.", "stressBody": "Le stress est ici une estimation de la charge physiologique sur votre système nerveux, lue surtout à partir des schémas de variabilité de la fréquence cardiaque et non de ce que vous ressentez consciemment. Un score plus bas indique un état calme et bien régulé ; un score plus élevé invite à prévoir de la récupération.", "recoveryPageTitle": "Récupération", - "recoveryPageBody": "La récupération rassemble les signaux nocturnes enregistrés par votre wearable — recharge, effort, charge ainsi que vos totaux de fréquence cardiaque et d'énergie du jour — pour montrer à quel point votre corps a récupéré. Observez la tendance sur plusieurs jours plutôt qu'un seul chiffre ; une valeur plus faible invite à lever le pied, ce n'est pas une alarme." + "recoveryPageBody": "La récupération rassemble les signaux nocturnes enregistrés par votre wearable — recharge, effort, charge ainsi que vos totaux de fréquence cardiaque et d'énergie du jour — pour montrer à quel point votre corps a récupéré. Observez la tendance sur plusieurs jours plutôt qu'un seul chiffre ; une valeur plus faible invite à lever le pied, ce n'est pas une alarme.", + "pulsePressureTitle": "Pression pulsée", + "pulsePressureBody": "La pression pulsée est l'écart entre votre valeur systolique et diastolique (systolique moins diastolique). À titre indicatif général, environ 40 mmHg est typique au repos ; un écart large au-dessus d'environ 60 mmHg à l'âge moyen peut refléter des artères rigidifiées, tandis qu'un écart étroit en dessous d'environ 25 mmHg peut accompagner un faible volume d'éjection (StatPearls/NIH 2023). C'est un sujet de discussion, pas un diagnostic.", + "meanArterialPressureTitle": "Pression artérielle moyenne", + "meanArterialPressureBody": "La pression artérielle moyenne est la pression moyenne que voient vos organes sur un battement, soit grosso modo la diastolique plus un tiers de l'écart avec la systolique. À titre indicatif général, une valeur au repos se situe autour de 70–100 mmHg (StatPearls/NIH 2023). Le seuil de ~60 mmHg souvent cité est un concept de soins aigus, pas une alarme pour l'autosurveillance.", + "cycleEducationBody": "Votre cycle traverse quatre phases pilotées par les hormones, et sa durée et sa régularité sont une fenêtre accessible sur la santé globale. À titre indicatif général, les cycles chez l'adulte durent typiquement 21–35 jours (les références cliniques citent 24–38), avec des règles d'environ cinq jours (ACOG 2015). La phase lutéale est l'ancrage stable, donc l'ovulation s'estime au mieux en comptant à rebours depuis vos prochaines règles prévues, jamais à un « jour 14 » fixe. Une fenêtre fertile prédite est une estimation qui demande plusieurs cycles enregistrés et reste approximative, et la température basale confirme l'ovulation après coup plutôt qu'elle ne la prédit. Ce n'est pas une méthode de contraception.", + "sleepHygieneBody": "Quelques habitudes fondées sur les preuves favorisent le sommeil : des heures de coucher et de lever régulières, week-ends compris, une lumière vive le matin et des soirées tamisées, une chambre fraîche, sombre et calme, et éviter la caféine l'après-midi, l'alcool tardif et les repas copieux tard le soir (CDC 2024). La régularité prédit la santé au moins autant que la durée, et le temps de sommeil total d'un wearable est bien plus fiable que sa répartition entre sommeil profond, léger et paradoxal.", + "referenceGuidance": "Indication générale, pas un avis médical. Les plages de référence sont des repères de population qui évoluent avec votre âge, votre sexe et vos conditions de santé ; votre propre tendance dans le temps guide la lecture." + }, + "bpDerived": { + "pulsePressure": "La pression pulsée (l'écart entre vos valeurs) est d'environ {value} mmHg ; environ 40 est typique au repos.", + "meanArterialPressure": "La pression artérielle moyenne (la moyenne que voient vos organes) est d'environ {value} mmHg, normalement autour de 70–100.", + "caveat": "Ces valeurs sont dérivées de votre dernière mesure à titre indicatif général, pas comme un diagnostic." }, "stats": { "label": "Statistiques récapitulatives", @@ -2641,7 +2540,8 @@ "asleepTotal": "Sommeil {value}", "inBedTotal": "Au lit {value}", "awakenings": "{count} réveils", - "source": "Source : {source}" + "source": "Source : {source}", + "estimateNote": "Les durées des phases sont estimées : cette source indique des totaux par phase, pas une chronologie mesurée." }, "stages": { "deep": "Profond", @@ -2668,7 +2568,7 @@ "chronotype": { "title": "Chronotype", "learning": "Votre rythme est encore en cours d'analyse — {counted} sur {total} nuits libres. Un chronotype a besoin de quelques matins sans réveil pour avoir du sens.", - "midSleepCaption": "Milieu de sommeil les jours libres vers {clock}", + "midSleepCaption": "Le milieu naturel de votre sommeil les jours de repos se situe vers {clock}.", "advancedToggle": "Avancé", "socialJetlagValue": "{hours} h {minutes} min de jet lag social", "socialJetlagCaption": "L'écart entre vos horaires de sommeil les jours travaillés et les jours libres — comme un petit décalage horaire hebdomadaire.", @@ -2681,7 +2581,8 @@ "intermediate": "Type intermédiaire", "late": "Type du soir", "extreme_late": "Type du soir marqué" - } + }, + "cornerLabel": "Chronotype" }, "rhythm": { "loadError": "Votre rythme de sommeil n'a pas pu se charger pour l'instant. Il réapparaîtra dès que la connexion sera rétablie." @@ -2825,6 +2726,7 @@ "title": "Aperçu du glucose", "subtitle": "Un point sur vos {days} derniers jours, à partir de {count} mesures.", "spotCaveat": "Ce sont des estimations issues de mesures ponctuelles, pas le profil d’un capteur continu : lisez-les comme une tendance, non comme un verdict clinique.", + "labsLink": "Résultats de laboratoire ? Saisis une HbA1c ou une glycémie à jeun issue d'une prise de sang dans Laboratoire.", "learningTitle": "Votre glucose se découvre encore", "learningBody": "Pour l’instant, cela fait {count} sur {days}. Avec un peu plus d’historique, le tableau se précise : continuez à enregistrer ou laissez un appareil connecté compléter.", "learningBodyEmpty": "Dès que quelques mesures arrivent — saisies par vous ou synchronisées depuis un appareil — votre temps dans la cible, votre moyenne et votre variabilité prennent forme ici.", @@ -3542,9 +3444,6 @@ "target": { "heading": "Objectif" }, - "disclaimer": { - "footer": "HealthLog décrit vos propres données suivies — il ne s'agit pas d'une évaluation clinique ni d'un diagnostic. Parlez à votre médecin de tout ce qui vous préoccupe." - }, "settings": { "overviewTitle": "Organiser la vue d'ensemble", "overviewDescription": "Affichez ou masquez des sections et définissez leur ordre sur la vue d'ensemble des Analyses." @@ -3572,25 +3471,34 @@ }, "sleepQuality": { "title": "Qualité du sommeil", - "subtitle": "Scores nocturnes de votre wearable" + "subtitle": "Scores nocturnes de votre wearable", + "assessment": { + "metric": { + "SLEEP_SCORE": "votre score de sommeil global", + "SLEEP_EFFICIENCY": "l’efficacité du sommeil", + "SLEEP_PERFORMANCE": "la performance de sommeil", + "SLEEP_CONSISTENCY": "la régularité du sommeil" + }, + "grade": { + "excellent": "excellente", + "good": "solide", + "fair": "correcte", + "low": "plutôt faible" + }, + "clause": "Sur les 30 dernières nuits, {metric} est {grade}, à {value}.", + "closing": { + "excellent": "Gardez la routine qui vous mène jusque-là.", + "good": "Des heures de coucher et de lever plus stables sont souvent le prochain gain.", + "fair": "Protéger une fenêtre de sommeil constante est ce qui fait le plus progresser ce point.", + "low": "Un rituel d’endormissement régulier et une heure de réveil fixe sont les premiers leviers." + } + } }, "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" - }, "block": { "ansChargeExplainer": "À quel point votre système nerveux autonome est rechargé pour la journée — une mesure de recharge issue de vos signaux nocturnes de cœur et de respiration.", "dayStrainExplainer": "La charge cardiovasculaire de la journée sur l'échelle d'effort de votre appareil. Lisez-la au regard de votre récupération, pas comme un objectif en soi.", @@ -3608,8 +3516,6 @@ }, "thresholds": { "defaultLabel": "Par défaut", - "autoModeLabel": "Auto", - "overrideModeLabel": "Personnalisé", "overrideToggleLabel": "Valeurs personnelles", "resetAction": "Restaurer la valeur par défaut", "resetAllAction": "Tout restaurer aux valeurs par défaut", @@ -3646,31 +3552,72 @@ "back": "Précédent", "skip": "Passer la visite", "done": "Compris", - "restart": "Relancer la visite", - "restartHint": "Affiche à nouveau la visite du tableau de bord à la prochaine ouverture.", - "restartConfirmation": "La visite redémarrera lors de ta prochaine visite du tableau de bord.", + "restart": "Relancer la visite des modules", + "restartHint": "Rejoue la visite guidée des modules depuis le début lors de votre prochaine ouverture du tableau de bord.", + "restartConfirmation": "La visite des modules redémarrera depuis le début à votre prochaine ouverture du tableau de bord.", "steps": { - "tileStrip": { - "title": "Tes valeurs du jour", - "body": "La barre de tuiles affiche la dernière valeur et la tendance 7 jours de chaque métrique — poids, tension, pouls, humeur. Masque ce qui ne te sert pas dans Paramètres → Tableau de bord." + "dashboardOverview": { + "title": "Votre tableau de bord", + "body": "Le tableau de bord rassemble tous les modules d’un coup d’œil. Appuyez longuement sur une tuile pour la réorganiser ou la masquer (Réglages → Disposition du tableau de bord). Une tuile ne vaut que par les données derrière elle — les modules suivants les remplissent." }, "quickAdd": { - "title": "Saisi en deux tapotements", - "body": "Via le bouton Ajouter, tu saisis des mesures ou des humeurs sans quitter le tableau de bord. Le formulaire mémorise le dernier contexte et les étiquettes." + "title": "Saisi en quelques secondes", + "body": "Avec « + Ajouter », saisissez n’importe quelle mesure en quelques secondes — poids, tension, pouls, glycémie. Atout caché : antidater les valeurs oubliées. Tout ce que vous saisissez ici alimente les tendances, le score santé et les analyses IA." + }, + "measurements": { + "title": "Mesures et plages cibles", + "body": "Mesures affiche l’historique et la plage cible de chaque métrique. Astuce cachée : définir une plage par valeur pour que les écarts ressortent en couleur. Ces séries sont la base des corrélations du Coach et du rapport médical." + }, + "medications": { + "title": "Médicaments et prises", + "body": "Médicaments gère plannings, prises, stock et titration. Caché : plans récurrents (RRULE/glissants) et alertes de stock bas. Votre taux de prise alimente le pilier d’observance du tableau de bord et le contexte de l’IA." + }, + "labs": { + "title": "Valeurs de laboratoire", + "body": "Laboratoire recense les biomarqueurs avec plages de référence — hémogramme, cholestérol, HbA1c. Atout : import et export FHIR. Les valeurs hors plage réapparaissent comme preuves dans les analyses et le rapport médical." + }, + "illness": { + "title": "Journal des maladies", + "body": "Le journal des maladies consigne les épisodes aigus et les diagnostics chroniques avec leurs poussées. Caché : corréler un épisode à vos mesures et le Mode repos qui suspend les rappels. Le Coach comprend ainsi pourquoi les valeurs dérivent un temps." + }, + "vorsorge": { + "title": "Prévention", + "body": "Prévention vous rappelle examens et vaccins selon leur propre intervalle. Astuce : « Fait » peut créer directement une mesure ou un résultat de laboratoire. Les échéances apparaissent en rappels et dans le rapport médical." + }, + "cycle": { + "title": "Cycle", + "body": "Le suivi du cycle consigne phases et symptômes. Caché : comparer les symptômes à l’humeur et au sommeil. Le cycle donne du contexte aux variations qui paraîtraient autrement inexpliquées." + }, + "mood": { + "title": "Humeur", + "body": "Humeur saisit votre ressenti et des tags en deux secondes. Atout : vos propres tags. Les tendances d’humeur se corrèlent au sommeil, aux médicaments et au cycle — le Coach repère les schémas." }, "insights": { - "title": "Insights automatiques", - "body": "Insights transforme tes valeurs en un résumé santé clair, avec un classement de la tension, du poids et de l’humeur. Clé personnelle ou par défaut — c’est toi qui choisis." + "title": "Analyses", + "body": "Analyses, c’est le cœur : une analyse dynamique et fondée sur des preuves plutôt que des conseils génériques — chaque affirmation pointe vos données réelles. Caché : la disposition est personnalisable. Analyses réunit tout ce que les modules ci-dessus fournissent." + }, + "coach": { + "title": "Coach", + "body": "Le Coach répond à vos questions sur vos données en dialogue, en utilisant antécédents, médicaments et résultats comme contexte. Vous choisissez le fournisseur IA (votre clé ou local). Il ne recalcule rien — il lit les mêmes chiffres que le tableau de bord." }, "integrations": { - "title": "Connecter des appareils", - "body": "Dans les paramètres, tu connectes Withings (synchronisation automatique du poids, de la tension et du pouls), moodLog ou ton fournisseur d’analyse. Tout reste optionnel et chiffré." + "title": "Intégrations", + "body": "Les intégrations synchronisent Withings, WHOOP, Polar, Oura et Apple Santé automatiquement. Poids, sommeil et récupération se remplissent sans saisie, et alimentent à leur tour les analyses et le Coach." + }, + "export": { + "title": "Rapport médical", + "body": "Le rapport médical regroupe mesures, médicaments, valeurs de laboratoire et prévention en PDF ou FHIR pour le rendez-vous. Tous les modules convergent ici — un rapport complet ne vient que de données bien tenues." }, "achievements": { - "title": "Collecter séries et badges", - "body": "La page Succès suit les séries de saisie quotidienne, les paliers de tension et les badges de constance. Un petit coup de pouce pour rester régulier." + "title": "Réussites", + "body": "Les réussites récompensent la régularité — séries et jalons. Une petite boucle de motivation qui aide à tenir bon." + }, + "wrapUp": { + "title": "C’est la fin de la visite", + "body": "Vous pouvez revoir chaque module séparément à tout moment (« Afficher cette visite » sur chaque page) ou relancer la visite complète dans Réglages → Avancé." } - } + }, + "moduleTrigger": "Afficher cette visite" }, "shell": { "back": "Retour", @@ -3741,13 +3688,20 @@ "learning": "Certaines analyses — glycémie, rythme de sommeil, tendances de tension, cycle — s'affinent au cours de la première semaine ou deux à mesure que vos données s'accumulent. Des tuiles vides signifient simplement que nous apprenons encore votre référence.", "connectCta": "Connecter un appareil", "logCta": "Enregistrez votre première mesure", - "importCta": "Importer un historique existant" + "importCta": "Importer un historique existant", + "aiTitle": "Activer les Insights IA (facultatif)", + "aiBody": "Le coach, le briefing quotidien et les évaluations traduisent tes mesures en langage clair. Pour cela, connecte ta propre clé IA (OpenAI, Anthropic) ou un modèle local — tes données restent chez toi.", + "aiCta": "Connecter un fournisseur d'IA" }, "welcomeBack": { "title": "Bon retour", "body": "Vous avez déjà terminé la configuration. Pour modifier un élément, ouvrez les paramètres — tout reste éditable.", "cta": "Ouvrir le tableau de bord" }, + "disclaimer": { + "acknowledge": "Je comprends : HealthLog est un outil de suivi privé, pas une évaluation clinique ni un diagnostic. En cas d'inquiétude, je consulte mon médecin, pas cette application.", + "learnMore": "En savoir plus" + }, "welcome": { "title": "Bienvenue dans HealthLog", "carouselLabel": "Carrousel de bienvenue", @@ -3829,18 +3783,21 @@ "sections": { "account": { "title": "Compte", - "description": "Profil, mot de passe, clés d’accès et votre visite guidée." + "description": "Profil, mot de passe, clés d’accès et votre visite guidée.", + "subtitle": "Votre profil, votre identité et vos identifiants." }, "integrations": { "title": "Intégrations", - "description": "Services connectés, canaux de diffusion et priorité des sources au même endroit." + "description": "Services connectés, canaux de diffusion et priorité des sources au même endroit.", + "subtitle": "Connectez et gérez vos sources de données et appareils." }, "notifications": { "title": "Notifications", "description": "Tous les rappels au même endroit — quoi et quand. Les canaux de diffusion se trouvent dans Intégrations.", "channelsHint": "Telegram, ntfy, Web Push, Webhook et e-mail se configurent désormais dans", "channelsHintLink": "Intégrations → Canaux", - "inboxLink": "Ouvrir la boîte de réception" + "inboxLink": "Ouvrir la boîte de réception", + "subtitle": "Choisissez les rappels que vous recevez." }, "layout": { "title": "Disposition", @@ -3853,15 +3810,18 @@ "insights": { "title": "Analyses", "description": "Ordre de la vue d'ensemble et des onglets de navigation." - } + }, + "subtitle": "Personnalisez l'agencement de votre tableau de bord et de vos insights." }, "dashboard": { "title": "Tableau de bord", - "description": "Disposition et ordre des tuiles." + "description": "Disposition et ordre des tuiles.", + "subtitle": "Organisez les tuiles de votre tableau de bord." }, "thresholds": { "title": "Objectifs", - "description": "Par métrique : votre propre plage cible pour des valeurs saines." + "description": "Par métrique : votre propre plage cible pour des valeurs saines.", + "subtitle": "Définissez vos plages cibles personnelles par mesure." }, "sources": { "title": "Priorité des sources", @@ -3920,19 +3880,23 @@ "scale": "Balance", "other": "Autre", "unknown": "Inconnu" - } + }, + "subtitle": "Choisissez la source prioritaire en cas de doublon de mesure." }, "ai": { - "title": "Analyses IA", - "description": "Fournisseur, modèle et clé API pour les analyses, ainsi que votre contexte « à propos de moi »." + "title": "Fournisseur d'IA", + "description": "Fournisseur, modèle et clé API pour les analyses, ainsi que votre contexte « à propos de moi ».", + "subtitle": "Choisissez votre fournisseur d'IA et utilisez votre propre clé." }, "api": { "title": "API & Tokens", - "description": "Jetons Bearer pour vos propres scripts et applis — enregistrez des mesures ou des prises de médicaments depuis n’importe où." + "description": "Jetons Bearer pour vos propres scripts et applis — enregistrez des mesures ou des prises de médicaments depuis n’importe où.", + "subtitle": "Gérez les points d'accès API et les jetons d'accès." }, "advanced": { "title": "Avancé", - "description": "Bouton opt-in du mode Recherche et zone dangereuse pour effacer irrévocablement toutes les données de santé." + "description": "Bouton opt-in du mode Recherche et zone dangereuse pour effacer irrévocablement toutes les données de santé.", + "subtitle": "Mode recherche, réinitialisation des données et suppression du compte." }, "export": { "title": "Export et import", @@ -4033,27 +3997,33 @@ "resultSummary": "{inserted} nouvelles importées et {updated} mises à jour, {skipped} ignorées.", "rowError": "Ligne {line} : {reason}" } - } + }, + "subtitle": "Exportez vos données ou importez depuis une sauvegarde." }, "about": { "title": "À propos de cette app", - "description": "Version, licence, liens." + "description": "Version, licence, liens.", + "subtitle": "Version, licence et liens du projet." }, "sharing": { "title": "Partage", - "description": "Créez un lien en lecture seule pour partager votre dossier de santé avec un professionnel de santé." + "description": "Créez un lien en lecture seule pour partager votre dossier de santé avec un professionnel de santé.", + "subtitle": "Partagez un accès en lecture seule à vos données." }, "insights": { "title": "Analyses", - "description": "Ordre de la vue d'ensemble et des onglets de navigation." + "description": "Ordre de la vue d'ensemble et des onglets de navigation.", + "subtitle": "Organisez les sections d'aperçu et l'ordre des pastilles." }, "medications": { "title": "Médicaments", - "description": "Vue liste, ordre et sites d’injection de vos médicaments." + "description": "Vue liste, ordre et sites d’injection de vos médicaments.", + "subtitle": "Gérez l'affichage et l'ordre de votre liste de médicaments." }, "mood": { "title": "Étiquettes d’humeur", - "description": "Groupes, étiquettes et étiquettes archivées du sélecteur d’humeur." + "description": "Groupes, étiquettes et étiquettes archivées du sélecteur d’humeur.", + "subtitle": "Organisez vos tags d'humeur, groupes et ordre du sélecteur." }, "modules": { "title": "Modules", @@ -4063,26 +4033,24 @@ "title": "Ce que vous suivez", "description": "Domaines optionnels. Les modules désactivés disparaissent de la navigation, des tuiles et des analyses ; vos données sont conservées." }, - "core": { - "title": "Toujours actifs", - "description": "Les fondations de HealthLog. Ces domaines ne peuvent pas être désactivés.", - "note": "Domaine essentiel — toujours actif" - }, - "description": "Choisissez ce que HealthLog suit pour vous. Activez ou désactivez chaque domaine : la navigation, les tuiles et les analyses s'adaptent aussitôt.", "manageIn": "Gérer dans {section}", - "operatorDisabled": "Désactivé sur tout le serveur" + "operatorDisabled": "Désactivé sur tout le serveur", + "subtitle": "Choisissez les domaines de suivi affichés par HealthLog." }, "gesundheitsakte": { "title": "Dossier médical", - "description": "Exportez votre dossier médical complet au format PDF, FHIR R4 ou en paquet ZIP combiné." + "description": "Exportez votre dossier médical complet au format PDF, FHIR R4 ou en paquet ZIP combiné.", + "subtitle": "Exportez un dossier de santé complet, prêt pour le médecin." }, "coach": { "title": "Coach", - "description": "Activez le Coach, le ton et la portée, les souvenirs enregistrés." + "description": "Activez le Coach, le ton et la portée, les souvenirs enregistrés.", + "subtitle": "Réglez le Coach, ses suggestions proactives et sa mémoire." }, "channels": { "title": "Canaux", - "description": "Distribution via Telegram, ntfy, Web Push, Webhook et e-mail." + "description": "Distribution via Telegram, ntfy, Web Push, Webhook et e-mail.", + "subtitle": "Où sont envoyés les rappels et alertes." } }, "profile": "Profil", @@ -4579,9 +4547,7 @@ "changelog": "Changelog", "docs": "Documentation", "linksHeading": "Sources et documentation", - "newerAvailable": "Nouvelle version : {tag}", - "tourReplay": "Revoir la visite", - "tourReplayHint": "Voir la visite guidée du tableau de bord — pratique si tu l'as sautée à la première visite." + "newerAvailable": "Nouvelle version : {tag}" }, "testConnection": { "test": "Tester la connexion", @@ -4964,7 +4930,6 @@ "modules": { "title": "Disponibilité des modules", "description": "Désactivez un module pour tout le serveur. Un module désactivé ici est coupé pour chaque compte, quelle que soit la préférence personnelle. Les domaines essentiels (poids, tension, pouls, médicaments) restent toujours actifs et ne peuvent pas être désactivés.", - "disabledHint": "Désactivé sur tout le serveur", "saveError": "Impossible de mettre à jour la disponibilité des modules." }, "bugReportRepo": "Dépôt", @@ -5343,11 +5308,14 @@ }, "about": { "title": "À propos", - "subtitle": "Version, licence, code source et vérification des mises à jour." + "subtitle": "Version, licence et état des mises à jour." + }, + "module-availability": { + "title": "Disponibilité des modules", + "subtitle": "Activez ou désactivez les modules pour tous les comptes de ce serveur." } }, "carrier": "Opérateur", - "carrierUnknown": "Opérateur inconnu", "carrierUnavailableGeoFallback": "{location} — opérateur indisponible" }, "achievements": { @@ -5679,7 +5647,6 @@ "title": "Notifications", "breadcrumbInbox": "Notifications", "breadcrumbChannels": "Canaux de notification", - "breadcrumbSettings": "Paramètres", "eventColumn": "Événement", "subtitle": "Choisis quelles notifications tu reçois et par quel canal.", "loadError": "Impossible de charger les paramètres de notification.", @@ -5796,7 +5763,6 @@ "dotNearRange": "proche de la plage", "dotOutOfRange": "hors plage", "dotNotLogged": "non saisi", - "insufficientData": "Pas encore assez de données — continue à saisir.", "relativeDay": { "today": "Aujourd'hui", "yesterday": "Hier", @@ -5808,7 +5774,6 @@ } }, "edit": { - "openLabel": "Modifier la plage cible de {metric}", "title": "Modifier la plage cible — {metric}", "description": "Ajuste la plage cible utilisée dans les graphiques, les insights et le rapport médical. Valeurs en {unit}.", "derivedHint": "Cette carte est dérivée d’autres métriques. Modifie plutôt les objectifs sous-jacents (p. ex. tension ou poids).", @@ -5824,13 +5789,6 @@ "saveError": "Impossible d’enregistrer la plage cible", "resetSuccess": "Plage cible réinitialisée" }, - "mood": { - "stability": { - "stable": "stable", - "variable": "variable", - "highlyVariable": "très variable" - } - }, "currentValue": "Actuel : {value} {unit}", "targetRangeValue": "Plage cible : {min}–{max} {unit}", "sourceLabel": "Source : {source}", @@ -5936,7 +5894,6 @@ }, "lowStockReminders": { "title": "Stock faible : {medName}", - "body": "{medName} : {units} unités restantes — environ {days} jours de réserve.", "bodyToday": "{medName} : {units} unités restantes — moins d’un jour de réserve.", "bodyReorder": "{medName} : {units} unités restantes. Le stock sera épuisé vers le {runsOutOn} — recommandez avant le {reorderBy}.", "bodyLastDose": "{medName} : {units} unités restantes — environ votre dernière dose. Le stock sera épuisé vers le {runsOutOn}." @@ -6044,7 +6001,6 @@ "description": "Comment chaque mesure se compare entre tes phases lutéale et folliculaire, sur tes propres données.", "pairLabel": "{metric} · lutéale vs folliculaire", "detail": "Lutéale {lutealAvg} {unit} ({lutealDays} jours) vs folliculaire {follicularAvg} {unit} ({follicularDays} jours).", - "footer": "Ce sont des associations dans tes propres données, pas des causes. Seuls les schémas qui passent un test de significativité sont affichés.", "metricRestingHeartRate": "Fréquence cardiaque au repos", "metricHrv": "Variabilité de la fréquence cardiaque", "metricSleepDuration": "Durée du sommeil", @@ -6068,10 +6024,6 @@ "confidenceHigh": "Confiance élevée" }, "title": "Analyses du cycle", - "empty": "Les analyses par phase apparaissent ici une fois assez de cycles enregistrés. Elles comparent tes mesures entre les phases du cycle, avec les statistiques affichées ouvertement.", - "sampleSize": "n = {n}", - "effectSize": "Taille d'effet {value}", - "qValue": "q = {value}", "symptomPatternsTitle": "Tendances des symptômes par phase", "symptomPatternsEmpty": "Notez vos symptômes sur un cycle pour voir dans quelle phase ils se regroupent.", "symptomMostlyIn": "Surtout en {phase} ({count}/{total} jours)" @@ -6091,7 +6043,6 @@ "LUTEAL": "La progestérone mène la seconde moitié et la température basale se stabilise en général un peu plus haut qu'au début du cycle. Certaines personnes remarquent des variations du sommeil, de l'appétit, de l'humeur ou de l'énergie au fil des jours, tandis que d'autres se sentent stables tout du long. Le schéma est personnel, et il se présente souvent différemment d'un mois à l'autre." }, "commonHere": "Tu le remarques souvent ici", - "trackNudge": "Noter aujourd'hui", "stillLearning": "On apprend encore ton cycle. Continue à noter et cela se remplira avec les tendances de tes propres journées." }, "insightsSummary": { @@ -6184,10 +6135,7 @@ "prevMonth": "Mois précédent", "nextMonth": "Mois suivant", "loading": "Chargement de ton calendrier…", - "empty": "Commence à enregistrer pour remplir ton calendrier.", "learning": "Nous apprenons ton cycle — enregistre au moins trois cycles complets et la fenêtre fertile, l'ovulation et les phases se compléteront. D'ici là, le calendrier n'affiche que ce que tu as enregistré.", - "today": "Aujourd'hui", - "dayAria": "{date}", "legendSymptom": "Symptômes", "legendOvulationConfirmed": "Ovulation confirmée", "flowSpotting": "spotting", @@ -6198,11 +6146,9 @@ "predictions": { "title": "Ce qui arrive ensuite", "nextPeriod": "Prochaines règles", - "nextPeriodRange": "Vers le {date}", "nextPeriodWindow": "{low} – {high}", "fertileWindow": "Fenêtre fertile", "ovulation": "Ovulation estimée", - "confidence": "Confiance", "confidenceHigh": "Élevée", "confidenceMedium": "Moyenne", "confidenceLow": "Faible", @@ -6238,7 +6184,6 @@ "sheet": { "title": "Enregistrer ta journée", "description": "Saisis le flux, les symptômes et les notes du jour.", - "date": "Date", "flow": "Flux", "symptoms": "Symptômes", "temperature": "Température basale", @@ -6258,10 +6203,7 @@ "save": "Enregistrer", "saving": "Enregistrement…", "cancel": "Annuler", - "saved": "Journée enregistrée", "saveError": "Enregistrement impossible. Réessaie.", - "periodStarted": "Début des règles enregistré", - "clear": "Aucun", "intermenstrualBleeding": "Saignements entre les règles", "pregnancyTest": "Test de grossesse", "progesteroneTest": "Test de progestérone", @@ -6277,8 +6219,6 @@ }, "settings": { "title": "Réglages du cycle", - "enable": "Suivi du cycle", - "enableDescription": "Active ou désactive l'onglet cycle pour ton compte.", "typicalCycleLength": "Durée typique du cycle (jours)", "typicalPeriodLength": "Durée typique des règles (jours)", "lutealPhaseLength": "Durée de la phase lutéale (jours)", @@ -6297,30 +6237,14 @@ "encryptionDescription": "Chiffre au repos les champs révélant l'intention (tests de grossesse, contraception, rapports), en plus des notes toujours chiffrées.", "discreet": "Notifications discrètes", "discreetDescription": "Masque les détails du cycle dans le texte des notifications sur ton écran verrouillé.", - "dataTitle": "Tes données", - "dataDescription": "Tes données de cycle ne vivent que sur ce serveur. Exporte-les quand tu veux, ou supprime-les définitivement.", - "export": "Exporter mes données de cycle", - "deleteData": "Supprimer mes données de cycle", "saved": "Réglages enregistrés", "saveError": "Enregistrement impossible. Réessaie.", "save": "Enregistrer", - "purgeTitle": "Supprimer toutes les données de cycle", - "purgeDescription": "Supprime définitivement chaque jour enregistré, cycle, prédiction et rappel. Action irréversible.", - "purgeButton": "Supprimer toutes les données de cycle", - "purgeConfirmTitle": "Supprimer toutes les données de cycle ?", - "purgeConfirmBody": "Cela supprime définitivement chaque règle, journal quotidien, symptôme et prédiction. Cette action est irréversible.", - "purgeConfirm": "Supprimer définitivement toutes les données de cycle ?", - "purgeCancel": "Annuler", - "purgeSuccess": "Toutes les données de cycle ont été supprimées.", - "purgeError": "Impossible de supprimer les données de cycle.", "reminders": "Rappels", "remindersDescription": "Activez les rappels de règles par canal dans les paramètres de notification.", "remindersLink": "Paramètres de notification", "fertileReminder": "Rappel de fenêtre fertile", - "fertileReminderDescription": "Recevez un avertissement quelques jours avant votre fenêtre fertile estimée. Désactivé par défaut : activez-le par canal dans les paramètres de notification.", - "purgeData": "Supprimer les données de cycle", - "purgeConfirmAction": "Oui, tout supprimer", - "purgeDone": "Données de cycle supprimées." + "fertileReminderDescription": "Recevez un avertissement quelques jours avant votre fenêtre fertile estimée. Désactivé par défaut : activez-le par canal dans les paramètres de notification." }, "testResult": { "POSITIVE": "Positif", @@ -6472,17 +6396,29 @@ "pushLocation": "(chez {location})", "edit": "Modifier", "originCoach": "Coach", - "enabledToggleAria": "Activer ou désactiver ce rappel", "until": "Jusqu'au {date}", "selfPlanned": "Auto-planifié", "captureValue": "Enregistrer la valeur", + "measureNow": "Mesurer maintenant", + "nextDueLabel": "Prochaine échéance :", + "lastDoneLabel": "Dernière fois :", + "customize": "Personnaliser la prévention", + "adherence": { + "label": "Dans l'intervalle", + "onTrack": "Dans les temps", + "due": "À faire maintenant" + }, + "settings": { + "title": "Personnaliser la prévention", + "description": "Gérer l'affichage, l'ordre et les rappels individuels.", + "back": "Retour à la prévention" + }, "capture": { "title": "Enregistrer une mesure", "description": "Saisissez la valeur ; le rappel est marqué comme fait." }, "manage": { - "menuItem": "Personnaliser", - "title": "Personnaliser les rappels", + "title": "Gérer les rappels", "description": "Activer ou désactiver des rappels individuels.", "empty": "Aucun rappel à personnaliser pour l'instant." }, @@ -6491,6 +6427,17 @@ "openLink": "Ouvrir la prévention" } }, + "moduleList": { + "viewToggleLabel": "Affichage", + "viewCards": "Vue en cartes", + "viewList": "Vue en liste", + "viewHeading": "Affichage", + "viewDescription": "Choisissez si les entrées s'affichent en cartes ou en liste compacte.", + "reorder": { + "heading": "Ordre", + "empty": "Aucune entrée à réordonner pour l'instant." + } + }, "labs": { "title": "Résultats d'analyses", "subtitle": "Analyses sanguines et biomarqueurs", @@ -6501,36 +6448,36 @@ "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} mesures", - "deleteLatest": "Supprimer la dernière valeur", "deleteConfirmTitle": "Supprimer le résultat d'analyse ?", "deleteConfirmDescription": "Ce résultat d'analyse sera définitivement supprimé.", "deletedToast": "Résultat supprimé", "deleteError": "Impossible de supprimer le résultat", "loadError": "Impossible de charger les résultats", + "customize": "Personnaliser les analyses", + "settings": { + "title": "Personnaliser les analyses", + "description": "Gérer l'affichage, le tri et les biomarqueurs.", + "back": "Retour aux analyses" + }, + "sort": { + "heading": "Tri", + "label": "Ordre des valeurs", + "recentDesc": "Mesure la plus récente d'abord", + "recentAsc": "Mesure la plus ancienne d'abord", + "manual": "Trier manuellement", + "hint": "En tri manuel, vous pouvez glisser les biomarqueurs ci-dessous dans votre propre ordre." + }, "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": "Saisissez une valeur.", - "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", @@ -6567,9 +6514,7 @@ } }, "biomarker": { - "manage": "Biomarqueurs", "manageTitle": "Biomarqueurs", - "manageSheetDescription": "Définissez les marqueurs que vous suivez. Chacun est configuré une fois avec son unité et sa plage de référence.", "managerDescription": "Définissez un marqueur une fois — son unité et sa plage sont réutilisées pour chaque mesure.", "define": "Définir", "defineFirst": "Définir votre premier biomarqueur", @@ -6699,18 +6644,6 @@ "label": "Rapport médical", "description": "Export PDF et FHIR pour votre cabinet." }, - "weight": { - "label": "Poids", - "description": "Poids et composition corporelle." - }, - "bloodPressure": { - "label": "Tension artérielle", - "description": "Systolique et diastolique." - }, - "pulse": { - "label": "Pouls", - "description": "Pouls au repos et fréquence cardiaque." - }, "medications": { "label": "Médicaments", "description": "Schémas, prises et observance." @@ -6723,7 +6656,12 @@ "illness": { "title": "Journal de maladie", "subtitle": "Un suivi rétrospectif de vos épisodes de maladie et d'affections.", - "disclaimer": "Un journal personnel, pas un dispositif médical. Il ne diagnostique ni ne prédit.", + "customize": "Personnaliser le journal de maladie", + "settings": { + "title": "Personnaliser le journal de maladie", + "description": "Gérer l'affichage et l'ordre des épisodes.", + "back": "Retour au journal de maladie" + }, "newEpisode": "Nouvel épisode", "logDay": "Enregistrer un jour", "markRecovered": "Marquer comme rétabli", @@ -6823,7 +6761,6 @@ "countOther": "Tu as enregistré {count} épisodes sur cette période.", "typicalGap": "Écart de récupération typique : {days} jours entre le mieux-être et la stabilisation de tes valeurs.", "typicalGapLearning": "Encore quelques épisodes résolus et un écart de récupération typique apparaîtra ici.", - "byMonth": "Quand tu enregistres des épisodes", "none": "Aucun épisode enregistré sur cette période." }, "section": { @@ -6893,5 +6830,24 @@ "reminderSuggestionsLabel": "Suggestions de fréquence", "reminderSuggestionsDescription": "Laissez le Coach proposer de temps en temps un rappel fondé sur des preuves pour mesurer une donnée plus régulièrement." } + }, + "safety": { + "floor": { + "bpHighTitle": "Tension artérielle très élevée", + "bpHighDoctor": "Deux mesures récentes étaient à {systolic}/{diastolic} mmHg ou plus. Repose-toi, remesure dans quelques minutes et contacte ton médecin dès que possible si cela reste aussi élevé.", + "bpHighEmergency": "Deux mesures récentes étaient à {systolic}/{diastolic} mmHg ou plus et tu as signalé des symptômes. En cas de douleur thoracique, d'essoufflement, de faiblesse ou de troubles de la vision ou de la parole, appelle les secours immédiatement.", + "bpLowTitle": "Tension artérielle basse", + "bpLowDoctor": "Deux mesures récentes avaient une systolique inférieure à {systolic} mmHg. Si tu te sens étourdi ou faible, assieds-toi ou allonge-toi et contacte ton médecin si cela persiste.", + "bpLowEmergency": "Deux mesures récentes avaient une systolique inférieure à {systolic} mmHg et tu as signalé des symptômes. Si tu te sens près de t'évanouir, confus ou as la peau froide et moite, appelle les secours immédiatement.", + "glucoseLowTitle": "Glycémie basse", + "glucoseLowDoctor": "Deux mesures récentes étaient en dessous de 70 mg/dL ({value} mg/dL). Prends des glucides à action rapide, remesure dans 15 minutes et contacte ton médecin si les baisses persistent.", + "glucoseLowEmergency": "Deux mesures récentes étaient en dessous de 70 mg/dL ({value} mg/dL) et tu as signalé des symptômes. Traite maintenant avec du sucre rapide ; si tu ne peux pas ou te sens très mal, appelle les secours.", + "glucoseLowSevereTitle": "Glycémie très basse", + "glucoseLowSevereDoctor": "Deux mesures récentes étaient en dessous de 54 mg/dL ({value} mg/dL). Traite immédiatement avec des glucides à action rapide, remesure dans 15 minutes et contacte ton médecin.", + "glucoseLowSevereEmergency": "Deux mesures récentes étaient en dessous de 54 mg/dL ({value} mg/dL) et tu as signalé des symptômes. Traite maintenant ; si tu ne gardes pas la nourriture ou te sens très mal, appelle les secours.", + "glucoseHighTitle": "Glycémie très élevée", + "glucoseHighDoctor": "Deux mesures récentes étaient à {value} mg/dL ou plus. Remesure, hydrate-toi et contacte ton médecin dès que possible si cela reste aussi élevé.", + "glucoseHighEmergency": "Deux mesures récentes étaient à {value} mg/dL ou plus et tu as signalé des symptômes. En cas de vomissements, de respiration rapide ou de confusion, appelle les secours immédiatement." + } } } diff --git a/messages/it.json b/messages/it.json index 386325fff..672aaa46a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -18,7 +18,6 @@ "optional": "Opzionale", "active": "Attivo", "inactive": "Inattivo", - "enabled": "Abilitato", "disabled": "Disabilitato", "networkError": "Errore di rete", "unknownError": "Errore sconosciuto", @@ -74,7 +73,6 @@ "colTaken": "Assunta", "colSkipped": "Saltata", "colMissed": "Persa", - "colTotal": "Totale", "colExpected": "Attese", "colComplianceRate": "Tasso di aderenza", "moodTitle": "Andamento dell’umore", @@ -191,7 +189,8 @@ "backToDashboard": "Torna alla dashboard" }, "offlineBanner": { - "message": "Nessuna connessione — le tue modifiche verranno salvate appena torni online." + "message": "Nessuna connessione — le tue modifiche verranno salvate appena torni online.", + "showingCached": "Vengono mostrati i tuoi ultimi dati sincronizzati." }, "demoBanner": { "message": "Modalità demo — le modifiche non vengono salvate." @@ -201,14 +200,12 @@ "measurements": "Misurazioni", "medications": "Farmaci", "insights": "Insights", - "targets": "Obiettivi", "achievements": "Traguardi", "settings": "Impostazioni", "bugreport": "Segnalazione bug", "admin": "Console di amministrazione", "logout": "Esci", "login": "Accedi", - "about": "Informazioni su HealthLog", "notifications": "Notifiche", "theme": "Aspetto", "themeSystem": "Sistema", @@ -218,13 +215,11 @@ "userMenu": "Menu utente", "accountSettings": "Impostazioni account", "mood": "Umore", - "workouts": "Allenamenti", "coach": "Coach", "skipToContent": "Vai al contenuto", "sidebar": "Barra laterale", "mainNavigation": "Navigazione principale", "mobileNavigation": "Navigazione mobile", - "breadcrumb": "Navigazione breadcrumb", "loadingScreen": "Caricamento…", "more": "Altro", "moreSheetTitle": "Altro", @@ -241,7 +236,6 @@ }, "cycle": "Ciclo", "labs": "Esami", - "recovery": "Recupero", "vorsorge": "Prevenzione", "illness": "Malattia" }, @@ -329,7 +323,6 @@ "sleepShort": "Sonno", "stepsShort": "Passi", "vo2MaxShort": "VO₂ max", - "bpInTargetShort": "PA-target", "bpInTargetWindow90": "PA-target · 90 gg", "bpInTargetWindowDynamic": "PA-target · {days} gg", "bpInTargetCollecting": "raccolta dati in corso", @@ -383,7 +376,6 @@ "staleHintWeeksOther": "Ultimo valore {count} settimane fa", "staleHintMonthsOne": "Ultimo valore 1 mese fa", "staleHintMonthsOther": "Ultimo valore {count} mesi fa", - "coachCta": "Chiedi al coach", "recentWorkouts": { "title": "Allenamenti recenti", "viewAll": "Vedi tutti", @@ -422,7 +414,7 @@ }, "measurements": { "title": "Misurazioni", - "subtitle": "Peso, pressione arteriosa, pulsazioni e altro", + "subtitle": "Registra le misurazioni e consulta lo storico completo. Tendenze e analisi sono in Analisi.", "addMeasurement": "Aggiungi", "editMeasurement": "Modifica misurazione", "type": "Tipo", @@ -498,7 +490,6 @@ "invalidTimestamp": "Scegli una data e un’ora valide.", "loginRequired": "Accedi per vedere le misurazioni.", "allTypes": "Tutti i tipi", - "filterByType": "Filtra per tipo", "measurementCount": "{count} misurazioni", "emptyTitle": "Ancora nessuna misurazione", "emptyDescription": "Registra peso, pressione, pulsazioni o altri valori per individuare i trend.", @@ -549,7 +540,6 @@ "typeCardioLoad": "Carico cardio", "typeSleepScore": "Punteggio del sonno", "typeBodyTemperatureDeviation": "Deviazione della temperatura corporea", - "filterBySource": "Filtra per fonte", "sourceComputed": "Calcolato", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", @@ -669,7 +659,6 @@ "factorSadness": "tristezza", "praise": "elogio" }, - "filterBySource": "Filtra per fonte", "sourceManual": "Manuale", "sourceWeb": "Web", "sourceTelegram": "Telegram", @@ -740,8 +729,6 @@ "subtitle": "Farmaci, orari e assunzioni", "loginRequired": "Accedi per gestire i farmaci.", "addMedication": "Aggiungi", - "editMedication": "Modifica farmaco", - "newMedication": "Nuovo farmaco", "firstMedication": "Aggiungi il primo farmaco", "addChoice": { "logIntake": "Registra un'assunzione", @@ -777,16 +764,9 @@ "loadFailed": "Impossibile caricare i farmaci", "retryLoad": "Riprova", "name": "Nome", - "dose": "Dose", - "scheduleFrom": "Da", - "scheduleTo": "A", - "scheduleLabel": "Etichetta", - "scheduleDays": "Giorni", - "scheduleDaily": "Quotidiano", "inactive": "In pausa", "intakeHistory": "Cronologia assunzioni", "openDetailPage": "Apri la pagina di dettaglio del farmaco", - "deleteConfirm": "Eliminare il farmaco?", "compliance": "Aderenza", "importIntakes": "Importa assunzioni", "importDescription": "Incolla un array JSON con i dati delle assunzioni. Formato previsto:", @@ -801,19 +781,7 @@ "importInvalidSkipped": "{count} voci non valide ignorate", "importFailed": "Importazione non riuscita", "importInvalidFormat": "Formato JSON non valido", - "apiEndpointTitle": "Endpoint API: {name}", - "apiEndpointDescription": "Usa questo endpoint per registrare assunzioni tramite API (ad es. con un Comando rapido iPhone o un cron job).", - "apiEndpointActive": "Endpoint API attivo", - "apiEndpointActivated": "Endpoint API attivato", - "apiEndpointDeactivated": "Endpoint API disattivato", - "apiTokenCount": "Attivo ({count} token)", - "apiToken": "Token API", - "apiTokenActiveHint": "L’endpoint è attivo. I token esistenti restano validi ma non possono essere mostrati di nuovo in chiaro.", - "apiTokenActivateHint": "Attiva l’endpoint per creare un nuovo token.", - "apiTokenOnceHint": "Il token viene mostrato in chiaro solo una volta, alla creazione.", "requestExample": "Esempio di richiesta", - "statusLoadFailed": "Impossibile caricare lo stato", - "changeFailed": "Modifica non riuscita", "categoryBloodPressure": "Pressione arteriosa", "categoryVitamin": "Vitamine", "categoryThyroid": "Tiroide", @@ -846,21 +814,15 @@ "lastDoseDaysAgo": "Ultima dose {count} giorni fa", "overdue": "In ritardo", "veryOverdue": "Molto in ritardo", - "compliance7d": "Aderenza 7 giorni", - "compliance30d": "Aderenza 30 giorni", "complianceWindow": "Aderenza {days} giorni", "complianceUnavailable": "Impossibile caricare l'aderenza", "complianceInfo": "Quota delle dosi attese che hai registrato. I piani settimanali contano settimana per settimana.", "complianceInfoLabel": "Cosa significa aderenza?", - "complianceDoses": "{count} dosi", "lastIntake": "Ultima assunzione:", "nextIntake": "Prossima assunzione:", "nextIntakeOverdue": "In ritardo · {time} — ancora recuperabile", "dayStreak": "giorni di serie", "dayStreakOne": "giorno di serie", - "cycleNextDoseToday": "Prossima dose oggi", - "cycleNextDoseTomorrow": "Prossima dose domani", - "cycleNextDoseInDays": "Prossima dose tra {days} giorni", "cycleDueToday": "In scadenza oggi", "cycleOverdue": "In ritardo", "cycleNoClosedCycles": "Ancora nessun ciclo di dose completato", @@ -872,40 +834,10 @@ "intakeUndo": "Annulla", "intakeUndone": "Voce annullata", "intakeUndoFailed": "Impossibile annullare; correggi nella cronologia", - "weekdaySunday": "Domenica", - "weekdayMonday": "Lunedì", - "weekdayTuesday": "Martedì", - "weekdayWednesday": "Mercoledì", - "weekdayThursday": "Giovedì", - "weekdayFriday": "Venerdì", - "weekdaySaturday": "Sabato", - "weekdaySundayPlural": "Domeniche", - "weekdayMondayPlural": "Lunedì", - "weekdayTuesdayPlural": "Martedì", - "weekdayWednesdayPlural": "Mercoledì", - "weekdayThursdayPlural": "Giovedì", - "weekdayFridayPlural": "Venerdì", - "weekdaySaturdayPlural": "Sabati", - "formType": "Tipo", - "formDose": "Dose", - "formUnit": "Unità", - "formSchedule": "Fascia oraria", - "formTreatmentClass": "Classe di trattamento", - "formTreatmentClassHelp": "Scegli \"Iniezione GLP-1\" per iniezioni settimanali come Mounjaro, Ozempic, Wegovy, Zepbound. Attiva la card del corpo e il diario GLP-1.", - "treatmentClassGeneric": "Farmaco standard", - "treatmentClassGlp1": "Iniezione GLP-1", - "glp1WeeklyPresetTitle": "Una volta a settimana, di…", - "glp1WeeklyPresetHelp": "La maggior parte degli agonisti del recettore GLP-1 viene iniettata una volta a settimana. Tocca un giorno per impostare il ritmo.", - "glp1WeeklyPresetSuggest": "Riprendi l’ultimo giorno scelto", - "glp1DosesPerUnit": "Dosi per penna / fiala", - "glp1DosesPerUnitPlaceholder": "es. 4 per una penna Mounjaro KwikPen", - "glp1DosesPerUnitHelp": "Opzionale — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy penna monouso = 1. Lascia vuoto se non vuoi monitorare le dosi per penna.", "glp1LastInjection": "{label}", - "glp1LastInjectionWithSite": "{label} · {site}", "glp1NextInjectionDays": "{label} (tra {days} giorni)", "glp1NextInjectionToday": "oggi", "glp1NextInjectionTomorrow": "domani", - "glp1RotationLast": "Ultima sede: {site}", "glp1RotationSuggested": "Suggerita: {site}", "glp1LogSideEffect": "Registra effetto collaterale", "sideEffects": { @@ -1260,8 +1192,7 @@ "add": "Aggiungi un altro programma", "edit": "Modifica", "remove": "Rimuovi", - "removeDisabled": "È richiesto almeno un programma.", - "empty": "Ancora nessun programma." + "removeDisabled": "È richiesto almeno un programma." } }, "errors": { @@ -1305,38 +1236,6 @@ "sideEffectTagAppetiteLoss": "Perdita di appetito", "sideEffectTagHeartburn": "Bruciore di stomaco", "sideEffectTagHeadache": "Mal di testa", - "newSchedule": "Nuova fascia oraria", - "removeSchedule": "Rimuovi fascia oraria", - "advancedEditing": "Modifica avanzata", - "advancedEditingHide": "Nascondi modifica avanzata", - "scheduleInterval": "Intervallo", - "scheduleEveryWeek": "ogni settimana", - "scheduleEveryNWeeks": "ogni {n} settimane", - "scheduleIntervalPrefix": "Intervallo:", - "nextSchedulePrefix": "Prossima fascia:", - "nextScheduleDaily": "quotidiano", - "importIntakesAction": "Importa assunzioni", - "apiEndpointAction": "Endpoint API", - "pause": "Metti in pausa", - "activate": "Attiva", - "disableNotifications": "Disattiva le notifiche", - "enableNotifications": "Attiva le notifiche", - "purgeRecords": "Elimina i record", - "formReset": "Reimposta modulo", - "createMedication": "Crea farmaco", - "deleteConfirmDescription": "Questo farmaco e tutte le sue assunzioni saranno eliminati in modo definitivo.", - "purgeConfirmTitle": "Eliminare tutte le assunzioni?", - "purgeConfirmDescription": "Tutte le assunzioni registrate per questo farmaco saranno eliminate in modo definitivo.", - "saveError": "Errore durante il salvataggio", - "deleteError": "Errore durante l’eliminazione", - "purgeError": "Errore durante l’eliminazione dei record", - "invalidTimestamp": "Scegli una data e un’ora valide.", - "noIntakesYet": "Ancora nessuna assunzione registrata.", - "actions": "Azioni", - "pageInfo": "Pagina {page} di {total}", - "previousPage": "Pagina precedente", - "nextPage": "Pagina successiva", - "loginRequiredIntakes": "Accedi per vedere la cronologia delle assunzioni.", "phaseConfig": "Fasi di promemoria", "phaseConfigDescription": "Configura i momenti dei promemoria.", "phaseGreen": "Verde", @@ -1345,11 +1244,8 @@ "phaseRed": "Rosso", "phaseMinutes": "Min", "phasePercent": "%", - "phaseBeforeEnd": "prima della fine", - "phaseAfterEnd": "dopo la fine", "phaseResetDefaults": "Ripristina predefiniti", "phaseSaved": "Fasi di promemoria salvate", - "phaseReset": "Ripristinate ai valori predefiniti", "sourceWeb": "Web", "sourceApi": "App iOS", "sourceReminder": "Promemoria", @@ -1369,16 +1265,6 @@ "intakeHistoryPageInfo": "Pagina {page} di {total} · {count} voci", "intakeHistoryPrev": "Precedente", "intakeHistoryNext": "Successiva", - "unitDrops": "gocce", - "unitTablets": "compresse", - "unitCapsules": "capsule", - "unitPieces": "pezzi", - "namePlaceholder": "es. Ramipril", - "dosePlaceholder": "es. 5 mg", - "labelPlaceholder": "es. Al mattino", - "defaultDose": "Dose standard", - "dayDeactivate": "Disattiva {day}", - "dayActivate": "Attiva {day}", "glp1": { "drug": { "tirzepatide": { @@ -1635,8 +1521,6 @@ "historyLabel": "Visualizza cronologia assunzioni" }, "history": { - "back": "Torna al farmaco", - "subtitle": "Cronologia completa delle assunzioni", "plannedSuffix": "pianificato" }, "tabs": { @@ -2008,7 +1892,8 @@ "bmiEmptyTitle": "Aggiungi l’altezza per il BMI", "bmiEmptyDescription": "Per il BMI serve la tua altezza. Salvala in Impostazioni → Account.", "bmiEmptyAction": "Apri impostazioni account", - "noProviderConfigured": "Valutazione non disponibile.", + "noProviderConfigured": "Collega un provider IA per vedere qui una valutazione delle tue misurazioni.", + "noProviderAction": "Apri impostazioni IA", "startAnalysis": "Avvia valutazione", "refreshAnalysis": "Aggiorna valutazione", "keyTakeaway": "Ciò che conta", @@ -2081,11 +1966,11 @@ "progressAria": "Avanzamento del punteggio di salute", "deltaVsLastWeek": "{delta} rispetto alla settimana scorsa", "deltaUnavailable": "Ancora nessuno storico — ripassa la prossima settimana.", + "restModePaused": "In pausa durante la malattia — oggi il tuo punteggio non viene valutato.", "componentBp": "PA", "componentWeight": "Peso", "componentMood": "Umore", "componentCompliance": "Aderenza", - "disclaimer": "Indicazione — non è una valutazione medica.", "deltaExplainer": { "trigger": "Cosa significa questa variazione?", "title": "Come si calcola la variazione", @@ -2123,7 +2008,7 @@ "emptyAction": "Genera briefing", "loadingLabel": "Preparazione del briefing", "noProviderTitle": "Nessun provider IA collegato", - "noProviderDescription": "Collega un provider IA nelle impostazioni per ricevere briefing giornalieri.", + "noProviderDescription": "Il briefing giornaliero riassume le tue misurazioni in linguaggio chiaro. HealthLog ha bisogno di un provider IA per questo: usa la tua chiave (OpenAI, Anthropic) o un modello locale. I tuoi dati restano tuoi.", "noProviderAction": "Apri impostazioni IA" }, "trendsRow": { @@ -2143,7 +2028,8 @@ "distance": "Distanza a piedi e di corsa degli ultimi 30 giorni.", "vo2_max": "VO₂ max degli ultimi 30 giorni.", "body_temp": "Temperatura corporea degli ultimi 30 giorni." - } + }, + "viewDetail": "Vedi i dettagli di {metric}" }, "vo2Max": { "statLatest": "Attuale", @@ -2165,7 +2051,6 @@ }, "correlationRow": { "title": "Correlazioni", - "disclaimer": "Le correlazioni sono osservazionali, non causali — parla con il medico se la pressione è alta.", "experimentCta": "Avvia esperimento di 7 giorni", "experimentTooltip": "In arrivo", "emptyTitle": "Servono più dati per mostrare questa correlazione", @@ -2188,6 +2073,8 @@ "refusalInjection": "Nel tuo messaggio ci sono istruzioni che cercano di sovrascrivere le mie indicazioni. Sono il coach di HealthLog e riassumo solo i tuoi dati di salute — riformula la domanda in questo perimetro.", "errorBudget": "Hai raggiunto il limite giornaliero del coach. Il budget si reimposta a mezzanotte (UTC).", "errorProvider": "Il coach non è riuscito a contattare un provider di analisi ora. Riprova fra poco.", + "errorNoProvider": "Il coach legge i tuoi dati di salute registrati e risponde a domande su di essi (non è consulenza medica). HealthLog ha bisogno di un provider IA per questo: collega la tua chiave o un modello locale.", + "errorNoProviderAction": "Collega un provider IA", "errorNetwork": "Nessuna connessione Internet — riprova quando sei di nuovo online.", "prefsSaveError": "Impossibile salvare le preferenze del Coach. Riprova.", "errorCredentialExpired": "La connessione al provider di IA è scaduta. Riconnettila nelle Impostazioni per mantenere attivo il Coach.", @@ -2604,7 +2491,19 @@ "strainBody": "Lo sforzo misura quanto carico cardiovascolare e fisico hai imposto al tuo corpo, dagli allenamenti a una giornata attiva in piedi. Quando c'è abbastanza storico, viene valutato rispetto al tuo sforzo abituale, non rispetto a uno standard esterno.", "stressBody": "Lo stress qui è una stima del carico fisiologico sul tuo sistema nervoso, letto soprattutto dagli schemi di variabilità della frequenza cardiaca e non da come ti senti consapevolmente. Un punteggio più basso indica uno stato calmo e ben regolato; uno più alto è un invito a inserire del recupero.", "recoveryPageTitle": "Recupero", - "recoveryPageBody": "Il recupero riunisce i segnali notturni registrati dal tuo wearable — ricarica, sforzo, carico e i totali di frequenza cardiaca ed energia della giornata — per mostrare quanto bene il tuo corpo si è ripreso. Guarda l'andamento su più giorni piuttosto che un singolo numero; un valore più basso è un invito a rallentare, non un allarme." + "recoveryPageBody": "Il recupero riunisce i segnali notturni registrati dal tuo wearable — ricarica, sforzo, carico e i totali di frequenza cardiaca ed energia della giornata — per mostrare quanto bene il tuo corpo si è ripreso. Guarda l'andamento su più giorni piuttosto che un singolo numero; un valore più basso è un invito a rallentare, non un allarme.", + "pulsePressureTitle": "Pressione differenziale", + "pulsePressureBody": "La pressione differenziale è la differenza tra il valore sistolico e quello diastolico (sistolica meno diastolica). Come orientamento generale, circa 40 mmHg è tipico a riposo; un divario ampio sopra i 60 mmHg circa nella mezza età può riflettere arterie più rigide, mentre un divario stretto sotto i 25 mmHg circa può accompagnare una bassa gittata sistolica (StatPearls/NIH 2023). È uno spunto di discussione, non una diagnosi.", + "meanArterialPressureTitle": "Pressione arteriosa media", + "meanArterialPressureBody": "La pressione arteriosa media è la pressione media che i tuoi organi vedono in un battito, all'incirca la diastolica più un terzo del divario con la sistolica. Come orientamento generale, un valore a riposo si aggira sui 70–100 mmHg (StatPearls/NIH 2023). La soglia di ~60 mmHg spesso citata è un concetto di terapia intensiva, non un allarme per l'automisurazione.", + "cycleEducationBody": "Il tuo ciclo attraversa quattro fasi guidate dagli ormoni, e la sua durata e regolarità sono una finestra accessibile sulla salute generale. Come orientamento generale, nei adulti i cicli durano tipicamente 21–35 giorni (i riferimenti clinici citano 24–38), con un sanguinamento di circa cinque giorni (ACOG 2015). La fase luteale è l'ancora stabile, quindi l'ovulazione si stima meglio contando a ritroso dalla prossima mestruazione attesa, mai con un «giorno 14» fisso. Una finestra fertile prevista è una stima che richiede più cicli registrati e resta approssimativa, e la temperatura basale conferma l'ovulazione a posteriori anziché prevederla. Non è un metodo contraccettivo.", + "sleepHygieneBody": "Alcune abitudini basate sull'evidenza favoriscono il sonno: orari costanti per coricarsi e svegliarsi anche nel weekend, luce intensa al mattino e serate soffuse, una stanza fresca, buia e silenziosa, ed evitare la caffeina nel pomeriggio, l'alcol a tarda ora e i pasti abbondanti a fine giornata (CDC 2024). La regolarità predice la salute almeno quanto la durata, e il tempo di sonno totale di un wearable è molto più affidabile della sua suddivisione tra sonno profondo, leggero e REM.", + "referenceGuidance": "Orientamento generale, non un consiglio medico. Gli intervalli di riferimento sono valori di popolazione che cambiano con la tua età, il sesso e le condizioni di salute; il tuo andamento nel tempo guida la lettura." + }, + "bpDerived": { + "pulsePressure": "La pressione differenziale (il divario tra i tuoi valori) è di circa {value} mmHg; circa 40 è tipico a riposo.", + "meanArterialPressure": "La pressione arteriosa media (la media che vedono i tuoi organi) è di circa {value} mmHg, normalmente intorno a 70–100.", + "caveat": "Questi valori sono derivati dalla tua ultima misurazione come orientamento generale, non come diagnosi." }, "stats": { "label": "Statistiche riepilogative", @@ -2641,7 +2540,8 @@ "asleepTotal": "Sonno {value}", "inBedTotal": "A letto {value}", "awakenings": "{count} risvegli", - "source": "Fonte: {source}" + "source": "Fonte: {source}", + "estimateNote": "I tempi delle fasi sono stimati: questa sorgente riporta i totali per fase, non una cronologia misurata." }, "stages": { "deep": "Profondo", @@ -2668,7 +2568,7 @@ "chronotype": { "title": "Cronotipo", "learning": "Stiamo ancora imparando il tuo ritmo — {counted} di {total} notti libere. Un cronotipo richiede alcune mattine senza sveglia per avere significato.", - "midSleepCaption": "Metà del sonno nei giorni liberi intorno alle {clock}", + "midSleepCaption": "Il punto medio naturale del tuo sonno nei giorni liberi è intorno alle {clock}.", "advancedToggle": "Avanzate", "socialJetlagValue": "{hours} h {minutes} min di jet lag sociale", "socialJetlagCaption": "La differenza tra gli orari del sonno nei giorni lavorativi e in quelli liberi — come un piccolo cambio di fuso orario settimanale.", @@ -2681,7 +2581,8 @@ "intermediate": "Tipo intermedio", "late": "Tipo serale", "extreme_late": "Tipo serale marcato" - } + }, + "cornerLabel": "Cronotipo" }, "rhythm": { "loadError": "Non è stato possibile caricare il tuo ritmo del sonno in questo momento. Riapparirà non appena la connessione sarà stabile." @@ -2825,6 +2726,7 @@ "title": "Panoramica della glicemia", "subtitle": "Una lettura dei tuoi ultimi {days} giorni, da {count} misurazioni.", "spotCaveat": "Sono stime da singole misurazioni puntuali, non il profilo di un sensore continuo: leggile come una tendenza, non come un verdetto clinico.", + "labsLink": "Risultati di laboratorio? Registra una HbA1c o la glicemia a digiuno da un prelievo in Laboratorio.", "learningTitle": "Stiamo ancora imparando la tua glicemia", "learningBody": "Per ora sono {count} su {days}. Con un po’ più di storico il quadro si chiarisce: continua a registrare o lascia che lo completi un dispositivo connesso.", "learningBodyEmpty": "Appena arrivano alcune misurazioni — registrate da te o sincronizzate da un dispositivo — il tempo nel range, la media e la variabilità prendono forma qui.", @@ -3542,9 +3444,6 @@ "target": { "heading": "Obiettivo" }, - "disclaimer": { - "footer": "HealthLog descrive i tuoi dati registrati — non è una valutazione clinica né una diagnosi. Parla con il tuo medico di qualsiasi cosa ti preoccupi." - }, "settings": { "overviewTitle": "Organizza la panoramica", "overviewDescription": "Mostra o nascondi le sezioni e definisci il loro ordine nella panoramica degli Approfondimenti." @@ -3572,25 +3471,34 @@ }, "sleepQuality": { "title": "Qualità del sonno", - "subtitle": "Punteggi notturni del tuo wearable" + "subtitle": "Punteggi notturni del tuo wearable", + "assessment": { + "metric": { + "SLEEP_SCORE": "il tuo punteggio di sonno complessivo", + "SLEEP_EFFICIENCY": "l’efficienza del sonno", + "SLEEP_PERFORMANCE": "la prestazione del sonno", + "SLEEP_CONSISTENCY": "la regolarità del sonno" + }, + "grade": { + "excellent": "eccellente", + "good": "solida", + "fair": "discreta", + "low": "piuttosto bassa" + }, + "clause": "Nelle ultime 30 notti {metric} è {grade}, a {value}.", + "closing": { + "excellent": "Mantieni la routine che ti porta fin qui.", + "good": "Orari di sonno e risveglio più stabili sono di solito il prossimo guadagno.", + "fair": "Proteggere una finestra di sonno costante è ciò che alza di più questo valore.", + "low": "Una routine serale regolare e un orario di sveglia fisso sono le prime leve." + } + } }, "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" - }, "block": { "ansChargeExplainer": "Quanto è carico il tuo sistema nervoso autonomo per la giornata — una lettura di ricarica ricavata dai segnali notturni di cuore e respiro.", "dayStrainExplainer": "Il carico cardiovascolare della giornata sulla scala di sforzo del tuo dispositivo. Leggilo in rapporto al tuo recupero, non come obiettivo a sé.", @@ -3608,8 +3516,6 @@ }, "thresholds": { "defaultLabel": "Predefinito", - "autoModeLabel": "Auto", - "overrideModeLabel": "Personalizzato", "overrideToggleLabel": "Valori personali", "resetAction": "Ripristina predefinito", "resetAllAction": "Ripristina tutti ai predefiniti", @@ -3646,31 +3552,72 @@ "back": "Indietro", "skip": "Salta tour", "done": "Ho capito", - "restart": "Riavvia tour", - "restartHint": "Mostra di nuovo il tour della dashboard alla prossima apertura.", - "restartConfirmation": "Il tour ripartirà alla tua prossima visita alla dashboard.", + "restart": "Riavvia la visita ai moduli", + "restartHint": "Riproduce la visita guidata ai moduli dall’inizio al tuo prossimo accesso alla dashboard.", + "restartConfirmation": "La visita ai moduli ripartirà dall’inizio al tuo prossimo accesso alla dashboard.", "steps": { - "tileStrip": { - "title": "I tuoi valori di oggi", - "body": "La striscia di tile mostra l’ultimo valore e il trend a 7 giorni di ogni metrica — peso, pressione, pulsazioni, umore. Nascondi ciò che non ti serve in Impostazioni → Dashboard." + "dashboardOverview": { + "title": "La tua dashboard", + "body": "La dashboard riunisce tutti i moduli a colpo d’occhio. Tieni premuta una scheda per riordinarla o nasconderla (Impostazioni → Layout dashboard). Una scheda vale quanto i dati che ha dietro: i moduli successivi li riempiono." }, "quickAdd": { - "title": "Registrato in due tap", - "body": "Con il pulsante Aggiungi registri misurazioni o voci di umore senza lasciare la dashboard. Il modulo ricorda il contesto e i tag più recenti." + "title": "Registrato in pochi secondi", + "body": "Con « + Aggiungi » registri qualsiasi misurazione in pochi secondi — peso, pressione, polso, glicemia. Forza nascosta: retrodatare i valori dimenticati. Tutto ciò che inserisci qui alimenta tendenze, punteggio salute e analisi IA." + }, + "measurements": { + "title": "Misurazioni e intervalli obiettivo", + "body": "Misurazioni mostra lo storico e l’intervallo obiettivo per ogni metrica. Chicca nascosta: definire un intervallo per valore così gli scostamenti risaltano a colori. Queste serie sono la base delle correlazioni del Coach e del referto medico." + }, + "medications": { + "title": "Farmaci e assunzioni", + "body": "Farmaci gestisce orari, assunzioni, scorte e titolazione. Nascosto: piani ricorrenti (RRULE/scorrevoli) e avvisi di scorte basse. La tua aderenza alimenta il pilastro di compliance nella dashboard e il contesto dell’IA." + }, + "labs": { + "title": "Valori di laboratorio", + "body": "Laboratorio raccoglie i biomarcatori con intervalli di riferimento — emocromo, colesterolo, HbA1c. Forza: importazione ed esportazione FHIR. I valori fuori intervallo riemergono come prove nelle analisi e nel referto medico." + }, + "illness": { + "title": "Diario delle malattie", + "body": "Il diario delle malattie registra episodi acuti e diagnosi croniche con le loro riacutizzazioni. Nascosto: correlare un episodio alle tue misurazioni e la Modalità riposo che mette in pausa i promemoria. Così il Coach capisce perché i valori variano per un periodo." + }, + "vorsorge": { + "title": "Prevenzione", + "body": "Prevenzione ti ricorda controlli e vaccinazioni con un proprio intervallo. Suggerimento: «Fatto» può creare direttamente una misurazione o un risultato di laboratorio. Le scadenze compaiono come promemoria e nel referto medico." + }, + "cycle": { + "title": "Ciclo", + "body": "Il monitoraggio del ciclo registra fasi e sintomi. Nascosto: confrontare i sintomi con umore e sonno. Il ciclo dà contesto alle variazioni che altrimenti sembrerebbero inspiegabili." + }, + "mood": { + "title": "Umore", + "body": "Umore cattura come ti senti e dei tag in due secondi. Forza: i tuoi tag personalizzati. Gli andamenti dell’umore si correlano con sonno, farmaci e ciclo — il Coach coglie gli schemi." }, "insights": { - "title": "Insights automatici", - "body": "Insights trasforma i tuoi valori in un riepilogo chiaro della salute, con inquadramento di pressione, peso e umore. Chiave personale o predefinita — scegli tu." + "title": "Analisi", + "body": "Analisi è il cuore: analisi dinamica e basata su prove invece di consigli generici — ogni affermazione punta ai tuoi dati reali. Nascosto: il layout è personalizzabile. Analisi raccoglie tutto ciò che forniscono i moduli sopra." + }, + "coach": { + "title": "Coach", + "body": "Il Coach risponde alle domande sui tuoi dati in dialogo, usando anamnesi, farmaci e valori di laboratorio come contesto. Scegli tu il fornitore IA (chiave tua o locale). Non ricalcola nulla: legge gli stessi numeri della dashboard." }, "integrations": { - "title": "Collegare dispositivi", - "body": "Dalle impostazioni colleghi Withings (sincronizzazione automatica di peso, pressione e pulsazioni), moodLog o il tuo provider di analisi. Tutto resta opzionale e cifrato." + "title": "Integrazioni", + "body": "Le integrazioni sincronizzano Withings, WHOOP, Polar, Oura e Apple Salute automaticamente. Peso, sonno e recupero si riempiono senza digitare e a loro volta alimentano analisi e Coach." + }, + "export": { + "title": "Referto medico", + "body": "Il referto medico raccoglie misurazioni, farmaci, valori di laboratorio e prevenzione in PDF o FHIR per la visita. Qui convergono tutti i moduli: un referto completo nasce solo da dati ben curati." }, "achievements": { - "title": "Accumula serie e badge", - "body": "La pagina Traguardi tiene traccia delle serie di registrazioni giornaliere, dei traguardi di pressione e dei badge di costanza. Un piccolo incentivo per non mollare." + "title": "Traguardi", + "body": "I traguardi premiano la costanza — serie e tappe. Un piccolo ciclo di motivazione che rende più facile non mollare." + }, + "wrapUp": { + "title": "Questa è la visita guidata", + "body": "Puoi rivedere ogni modulo singolarmente in qualsiasi momento («Mostra questa visita» su ogni pagina) o riavviare l’intera visita da Impostazioni → Avanzate." } - } + }, + "moduleTrigger": "Mostra questa visita" }, "shell": { "back": "Indietro", @@ -3741,13 +3688,20 @@ "learning": "Alcune analisi — glicemia, ritmo del sonno, tendenze della pressione, ciclo — si affinano nella prima settimana o due man mano che i tuoi dati si accumulano. Le caselle vuote ora indicano solo che stiamo ancora imparando il tuo riferimento.", "connectCta": "Collega un dispositivo", "logCta": "Registra la tua prima misurazione", - "importCta": "Importa la cronologia esistente" + "importCta": "Importa la cronologia esistente", + "aiTitle": "Attiva gli Insight IA (facoltativo)", + "aiBody": "Il coach, il briefing giornaliero e le valutazioni trasformano le tue misurazioni in linguaggio chiaro. Per farlo colleghi la tua chiave IA (OpenAI, Anthropic) o un modello locale: i tuoi dati restano tuoi.", + "aiCta": "Collega un provider IA" }, "welcomeBack": { "title": "Bentornato", "body": "Hai già completato l'onboarding. Per modificare qualcosa apri le impostazioni — tutto ciò che hai configurato è ancora modificabile.", "cta": "Apri la dashboard" }, + "disclaimer": { + "acknowledge": "Ho capito: HealthLog è uno strumento di monitoraggio privato, non una valutazione clinica né una diagnosi. Se sono preoccupato ne parlo con il medico, non con questa app.", + "learnMore": "Scopri di più" + }, "welcome": { "title": "Benvenuto in HealthLog", "carouselLabel": "Carosello di benvenuto", @@ -3829,18 +3783,21 @@ "sections": { "account": { "title": "Account", - "description": "Profilo, password, passkey e il tour di benvenuto." + "description": "Profilo, password, passkey e il tour di benvenuto.", + "subtitle": "Il tuo profilo, la tua identità e i dati di accesso." }, "integrations": { "title": "Integrazioni", - "description": "Servizi collegati, canali di consegna e priorità delle fonti in un unico posto." + "description": "Servizi collegati, canali di consegna e priorità delle fonti in un unico posto.", + "subtitle": "Collega e gestisci le tue fonti di dati e i dispositivi." }, "notifications": { "title": "Notifiche", "description": "Tutti i promemoria in un unico posto — cosa e quando. I canali di recapito si trovano in Integrazioni.", "channelsHint": "Telegram, ntfy, Web Push, Webhook ed e-mail ora si configurano in", "channelsHintLink": "Integrazioni → Canali", - "inboxLink": "Apri la posta in arrivo" + "inboxLink": "Apri la posta in arrivo", + "subtitle": "Scegli quali promemoria ricevere." }, "layout": { "title": "Layout", @@ -3853,15 +3810,18 @@ "insights": { "title": "Approfondimenti", "description": "Ordine della panoramica e delle schede di navigazione." - } + }, + "subtitle": "Personalizza la disposizione di dashboard e insights." }, "dashboard": { "title": "Cruscotto", - "description": "Layout e ordine dei riquadri." + "description": "Layout e ordine dei riquadri.", + "subtitle": "Disponi i riquadri della tua dashboard." }, "thresholds": { "title": "Obiettivi", - "description": "Per metrica: il tuo intervallo target per valori sani." + "description": "Per metrica: il tuo intervallo target per valori sani.", + "subtitle": "Imposta i tuoi intervalli target personali per metrica." }, "sources": { "title": "Priorità delle fonti", @@ -3920,19 +3880,23 @@ "scale": "Bilancia", "other": "Altro", "unknown": "Sconosciuto" - } + }, + "subtitle": "Decidi quale fonte prevale quando due riportano la stessa metrica." }, "ai": { - "title": "Analisi IA", - "description": "Provider, modello e chiave API per gli approfondimenti, oltre al tuo contesto «su di me»." + "title": "Provider IA", + "description": "Provider, modello e chiave API per gli approfondimenti, oltre al tuo contesto «su di me».", + "subtitle": "Scegli il tuo provider IA e usa la tua chiave." }, "api": { "title": "API & Tokens", - "description": "Token Bearer per i tuoi script e le tue app — registra misurazioni o assunzioni di farmaci da ovunque." + "description": "Token Bearer per i tuoi script e le tue app — registra misurazioni o assunzioni di farmaci da ovunque.", + "subtitle": "Gestisci gli endpoint API e i token di accesso." }, "advanced": { "title": "Avanzate", - "description": "Interruttore opt-in della modalità Ricerca e zona pericolosa per cancellare in modo irrevocabile tutti i dati di salute." + "description": "Interruttore opt-in della modalità Ricerca e zona pericolosa per cancellare in modo irrevocabile tutti i dati di salute.", + "subtitle": "Modalità ricerca, ripristino dati ed eliminazione account." }, "export": { "title": "Esporta e importa", @@ -4033,27 +3997,33 @@ "resultSummary": "{inserted} nuove importate e {updated} aggiornate, {skipped} saltate.", "rowError": "Riga {line}: {reason}" } - } + }, + "subtitle": "Esporta i tuoi dati o importa da un backup." }, "about": { "title": "Informazioni su questa app", - "description": "Versione, licenza, link." + "description": "Versione, licenza, link.", + "subtitle": "Versione, licenza e link al progetto." }, "sharing": { "title": "Condivisione", - "description": "Crea un link di sola lettura per condividere la tua cartella clinica con un medico." + "description": "Crea un link di sola lettura per condividere la tua cartella clinica con un medico.", + "subtitle": "Condividi l'accesso in sola lettura ai tuoi dati." }, "insights": { "title": "Approfondimenti", - "description": "Ordine della panoramica e delle schede di navigazione." + "description": "Ordine della panoramica e delle schede di navigazione.", + "subtitle": "Disponi le sezioni della panoramica e l'ordine dei pill." }, "medications": { "title": "Farmaci", - "description": "Vista elenco, ordine e siti di iniezione dei tuoi farmaci." + "description": "Vista elenco, ordine e siti di iniezione dei tuoi farmaci.", + "subtitle": "Gestisci vista e ordine del tuo elenco di farmaci." }, "mood": { "title": "Tag dell’umore", - "description": "Gruppi, tag e tag archiviati del selettore dell’umore." + "description": "Gruppi, tag e tag archiviati del selettore dell’umore.", + "subtitle": "Organizza i tag dell'umore, i gruppi e l'ordine del selettore." }, "modules": { "title": "Moduli", @@ -4063,26 +4033,24 @@ "title": "Cosa monitori", "description": "Ambiti opzionali. I moduli disattivati spariscono da navigazione, riquadri e analisi; i tuoi dati restano intatti." }, - "core": { - "title": "Sempre attivi", - "description": "Le fondamenta di HealthLog. Questi ambiti non possono essere disattivati.", - "note": "Ambito di base — sempre attivo" - }, - "description": "Scegli cosa registra HealthLog per te. Attiva o disattiva i singoli ambiti: navigazione, riquadri e analisi si adattano subito.", "manageIn": "Gestisci in {section}", - "operatorDisabled": "Disattivato a livello di server" + "operatorDisabled": "Disattivato a livello di server", + "subtitle": "Scegli quali aree di monitoraggio HealthLog mostra nell'app." }, "gesundheitsakte": { "title": "Cartella clinica", - "description": "Esporta la tua cartella clinica completa in PDF, FHIR R4 o in un pacchetto ZIP combinato." + "description": "Esporta la tua cartella clinica completa in PDF, FHIR R4 o in un pacchetto ZIP combinato.", + "subtitle": "Esporta una cartella clinica completa, pronta per il medico." }, "coach": { "title": "Coach", - "description": "Attiva il Coach, tono e ambito, ricordi salvati." + "description": "Attiva il Coach, tono e ambito, ricordi salvati.", + "subtitle": "Regola il Coach, i suoi suggerimenti proattivi e la sua memoria." }, "channels": { "title": "Canali", - "description": "Recapito tramite Telegram, ntfy, Web Push, Webhook ed e-mail." + "description": "Recapito tramite Telegram, ntfy, Web Push, Webhook ed e-mail.", + "subtitle": "Dove vengono recapitati promemoria e avvisi." } }, "profile": "Profilo", @@ -4579,9 +4547,7 @@ "changelog": "Changelog", "docs": "Documentazione", "linksHeading": "Fonti e documentazione", - "newerAvailable": "Nuova versione: {tag}", - "tourReplay": "Rivedi il tour", - "tourReplayHint": "Guarda il tour della dashboard — utile se l'hai saltato alla prima visita." + "newerAvailable": "Nuova versione: {tag}" }, "testConnection": { "test": "Prova connessione", @@ -4964,7 +4930,6 @@ "modules": { "title": "Disponibilità dei moduli", "description": "Disattiva un modulo per l'intero server. Un modulo disattivato qui è spento per ogni account, indipendentemente dalle preferenze personali. I domini principali (peso, pressione, polso, farmaci) sono sempre attivi e non possono essere disattivati.", - "disabledHint": "Disattivato a livello di server", "saveError": "Impossibile aggiornare la disponibilità dei moduli." }, "bugReportRepo": "Repository", @@ -5343,11 +5308,14 @@ }, "about": { "title": "Informazioni", - "subtitle": "Versione, licenza, codice sorgente e verifica degli aggiornamenti." + "subtitle": "Versione, licenza e stato degli aggiornamenti." + }, + "module-availability": { + "title": "Disponibilità dei moduli", + "subtitle": "Attiva o disattiva i moduli per tutti gli account su questo server." } }, "carrier": "Operatore", - "carrierUnknown": "Operatore sconosciuto", "carrierUnavailableGeoFallback": "{location} — operatore non disponibile" }, "achievements": { @@ -5679,7 +5647,6 @@ "title": "Notifiche", "breadcrumbInbox": "Notifiche", "breadcrumbChannels": "Canali di notifica", - "breadcrumbSettings": "Impostazioni", "eventColumn": "Evento", "subtitle": "Scegli quali notifiche ricevere e su quale canale.", "loadError": "Impossibile caricare le impostazioni di notifica.", @@ -5796,7 +5763,6 @@ "dotNearRange": "vicino al range", "dotOutOfRange": "fuori range", "dotNotLogged": "non registrato", - "insufficientData": "Ancora pochi dati — continua a registrare.", "relativeDay": { "today": "Oggi", "yesterday": "Ieri", @@ -5808,7 +5774,6 @@ } }, "edit": { - "openLabel": "Modifica il range obiettivo per {metric}", "title": "Modifica range obiettivo — {metric}", "description": "Regola il range obiettivo usato in grafici, insights e referto medico. Valori in {unit}.", "derivedHint": "Questa card è derivata da altre metriche. Modifica gli obiettivi sottostanti (es. pressione o peso).", @@ -5824,13 +5789,6 @@ "saveError": "Impossibile salvare il range obiettivo", "resetSuccess": "Range obiettivo ripristinato" }, - "mood": { - "stability": { - "stable": "stabile", - "variable": "variabile", - "highlyVariable": "molto variabile" - } - }, "currentValue": "Attuale: {value} {unit}", "targetRangeValue": "Range obiettivo: {min}–{max} {unit}", "sourceLabel": "Fonte: {source}", @@ -5936,7 +5894,6 @@ }, "lowStockReminders": { "title": "Scorte in esaurimento: {medName}", - "body": "{medName}: {units} unità rimaste — bastano per circa {days} giorni.", "bodyToday": "{medName}: {units} unità rimaste — meno di un giorno di scorta.", "bodyReorder": "{medName}: {units} unità rimaste. Le scorte finiscono intorno al {runsOutOn} — riordina entro il {reorderBy}.", "bodyLastDose": "{medName}: {units} unità rimaste — circa la tua ultima dose. Le scorte finiscono intorno al {runsOutOn}." @@ -6044,7 +6001,6 @@ "description": "Come si confronta ogni misurazione tra la fase luteale e quella follicolare, sui tuoi dati.", "pairLabel": "{metric} · luteale vs follicolare", "detail": "Luteale {lutealAvg} {unit} ({lutealDays} giorni) vs follicolare {follicularAvg} {unit} ({follicularDays} giorni).", - "footer": "Sono associazioni nei tuoi dati, non cause. Vengono mostrati solo gli schemi che superano un test di significatività.", "metricRestingHeartRate": "Frequenza cardiaca a riposo", "metricHrv": "Variabilità della frequenza cardiaca", "metricSleepDuration": "Durata del sonno", @@ -6068,10 +6024,6 @@ "confidenceHigh": "Affidabilità alta" }, "title": "Analisi del ciclo", - "empty": "Le analisi per fase compaiono qui quando hai registrato abbastanza cicli. Confrontano le tue misure tra le fasi del ciclo, con la statistica mostrata apertamente.", - "sampleSize": "n = {n}", - "effectSize": "Dimensione dell'effetto {value}", - "qValue": "q = {value}", "symptomPatternsTitle": "Pattern dei sintomi per fase", "symptomPatternsEmpty": "Registra i sintomi durante un ciclo per vedere in quale fase si concentrano.", "symptomMostlyIn": "Soprattutto in {phase} ({count}/{total} giorni)" @@ -6091,7 +6043,6 @@ "LUTEAL": "Il progesterone guida la seconda metà e la temperatura basale di solito si assesta un po' più in alto rispetto all'inizio del ciclo. Alcune persone notano cambiamenti nel sonno, nell'appetito, nell'umore o nell'energia con il passare dei giorni, mentre altre si sentono stabili per tutto il tempo. Lo schema è personale e spesso appare diverso da un mese all'altro." }, "commonHere": "Questo lo noti spesso qui", - "trackNudge": "Registra oggi", "stillLearning": "Stiamo ancora imparando il tuo ciclo. Continua a registrare e questo si completerà con gli schemi delle tue giornate." }, "insightsSummary": { @@ -6184,10 +6135,7 @@ "prevMonth": "Mese precedente", "nextMonth": "Mese successivo", "loading": "Caricamento del calendario…", - "empty": "Inizia a registrare per riempire il calendario.", "learning": "Stiamo imparando il tuo ciclo: registra almeno tre cicli completi e si completeranno la finestra fertile, l'ovulazione e le fasi. Fino ad allora il calendario mostra solo ciò che hai registrato.", - "today": "Oggi", - "dayAria": "{date}", "legendSymptom": "Sintomi", "legendOvulationConfirmed": "Ovulazione confermata", "flowSpotting": "spotting", @@ -6198,11 +6146,9 @@ "predictions": { "title": "Cosa arriva dopo", "nextPeriod": "Prossimo ciclo", - "nextPeriodRange": "Intorno al {date}", "nextPeriodWindow": "{low} – {high}", "fertileWindow": "Finestra fertile", "ovulation": "Ovulazione stimata", - "confidence": "Affidabilità", "confidenceHigh": "Alta", "confidenceMedium": "Media", "confidenceLow": "Bassa", @@ -6238,7 +6184,6 @@ "sheet": { "title": "Registra la tua giornata", "description": "Annota flusso, sintomi e note di oggi.", - "date": "Data", "flow": "Flusso", "symptoms": "Sintomi", "temperature": "Temperatura basale", @@ -6258,10 +6203,7 @@ "save": "Salva", "saving": "Salvataggio…", "cancel": "Annulla", - "saved": "Giornata salvata", "saveError": "Impossibile salvare. Riprova.", - "periodStarted": "Inizio del ciclo registrato", - "clear": "Nessuno", "intermenstrualBleeding": "Spotting tra i cicli", "pregnancyTest": "Test di gravidanza", "progesteroneTest": "Test del progesterone", @@ -6277,8 +6219,6 @@ }, "settings": { "title": "Impostazioni del ciclo", - "enable": "Monitoraggio del ciclo", - "enableDescription": "Attiva o disattiva la scheda ciclo per il tuo account.", "typicalCycleLength": "Durata tipica del ciclo (giorni)", "typicalPeriodLength": "Durata tipica delle mestruazioni (giorni)", "lutealPhaseLength": "Durata della fase luteale (giorni)", @@ -6297,30 +6237,14 @@ "encryptionDescription": "Cifra a riposo i campi che rivelano l'intenzione (test di gravidanza, contraccezione, rapporti), oltre alle note sempre cifrate.", "discreet": "Notifiche discrete", "discreetDescription": "Nascondi i dettagli del ciclo nel testo delle notifiche sulla schermata di blocco.", - "dataTitle": "I tuoi dati", - "dataDescription": "I tuoi dati del ciclo vivono solo su questo server. Esportali quando vuoi, o eliminali per sempre.", - "export": "Esporta i miei dati del ciclo", - "deleteData": "Elimina i miei dati del ciclo", "saved": "Impostazioni salvate", "saveError": "Impossibile salvare. Riprova.", "save": "Salva", - "purgeTitle": "Elimina tutti i dati del ciclo", - "purgeDescription": "Rimuove definitivamente ogni giorno registrato, ciclo, previsione e promemoria. Operazione irreversibile.", - "purgeButton": "Elimina tutti i dati del ciclo", - "purgeConfirmTitle": "Eliminare tutti i dati del ciclo?", - "purgeConfirmBody": "Questo elimina definitivamente ogni mestruazione, registro giornaliero, sintomo e previsione. L'operazione è irreversibile.", - "purgeConfirm": "Eliminare definitivamente tutti i dati del ciclo?", - "purgeCancel": "Annulla", - "purgeSuccess": "Tutti i dati del ciclo eliminati.", - "purgeError": "Impossibile eliminare i dati del ciclo.", "reminders": "Promemoria", "remindersDescription": "Attiva i promemoria del ciclo per canale nelle impostazioni di notifica.", "remindersLink": "Impostazioni di notifica", "fertileReminder": "Promemoria finestra fertile", - "fertileReminderDescription": "Ricevi un avviso alcuni giorni prima della tua finestra fertile stimata. Disattivato per impostazione predefinita: attivalo per canale nelle impostazioni di notifica.", - "purgeData": "Elimina i dati del ciclo", - "purgeConfirmAction": "Sì, elimina tutto", - "purgeDone": "Dati del ciclo eliminati." + "fertileReminderDescription": "Ricevi un avviso alcuni giorni prima della tua finestra fertile stimata. Disattivato per impostazione predefinita: attivalo per canale nelle impostazioni di notifica." }, "testResult": { "POSITIVE": "Positivo", @@ -6472,17 +6396,29 @@ "pushLocation": "(presso {location})", "edit": "Modifica", "originCoach": "Coach", - "enabledToggleAria": "Attiva o disattiva questo promemoria", "until": "Fino al {date}", "selfPlanned": "Autopianificato", "captureValue": "Registra valore", + "measureNow": "Misura ora", + "nextDueLabel": "Prossima scadenza:", + "lastDoneLabel": "Ultima volta:", + "customize": "Personalizza prevenzione", + "adherence": { + "label": "Nell'intervallo", + "onTrack": "In linea", + "due": "Da fare ora" + }, + "settings": { + "title": "Personalizza prevenzione", + "description": "Gestisci la vista, l'ordine e i singoli promemoria.", + "back": "Torna alla prevenzione" + }, "capture": { "title": "Registra misurazione", "description": "Inserisci il valore; il promemoria viene segnato come fatto." }, "manage": { - "menuItem": "Personalizza", - "title": "Personalizza promemoria", + "title": "Gestisci promemoria", "description": "Attiva o disattiva singoli promemoria.", "empty": "Nessun promemoria da personalizzare." }, @@ -6491,6 +6427,17 @@ "openLink": "Apri prevenzione" } }, + "moduleList": { + "viewToggleLabel": "Vista", + "viewCards": "Vista a schede", + "viewList": "Vista a elenco", + "viewHeading": "Vista", + "viewDescription": "Scegli se le voci appaiono come schede o come elenco compatto.", + "reorder": { + "heading": "Ordine", + "empty": "Nessuna voce da riordinare." + } + }, "labs": { "title": "Risultati degli esami", "subtitle": "Analisi del sangue e biomarcatori", @@ -6501,36 +6448,36 @@ "emptyDescription": "Registra esami come HbA1c, LDL o ferritina per seguirli nel tempo e condividerli con il tuo medico.", "referenceLabel": "Riferimento", "readingsCount": "{count} misurazioni", - "deleteLatest": "Elimina l'ultimo valore", "deleteConfirmTitle": "Eliminare il risultato di laboratorio?", "deleteConfirmDescription": "Questo risultato di laboratorio verrà eliminato definitivamente.", "deletedToast": "Risultato eliminato", "deleteError": "Impossibile eliminare il risultato", "loadError": "Impossibile caricare i risultati", + "customize": "Personalizza esami", + "settings": { + "title": "Personalizza esami", + "description": "Gestisci la vista, l'ordinamento e i biomarcatori.", + "back": "Torna agli esami" + }, + "sort": { + "heading": "Ordinamento", + "label": "Ordine dei valori", + "recentDesc": "Misurazione più recente prima", + "recentAsc": "Misurazione più vecchia prima", + "manual": "Ordina manualmente", + "hint": "Con l'ordinamento manuale puoi trascinare i biomarcatori qui sotto nel tuo ordine." + }, "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": "Inserisci un valore.", - "rangeOrderError": "Il riferimento minimo non può superare quello massimo.", "save": "Salva", "savedToast": "Risultato salvato", "saveError": "Impossibile salvare il risultato", @@ -6567,9 +6514,7 @@ } }, "biomarker": { - "manage": "Biomarcatori", "manageTitle": "Biomarcatori", - "manageSheetDescription": "Definisci i marcatori che segui. Ognuno si imposta una volta con la sua unità e l'intervallo di riferimento.", "managerDescription": "Definisci un marcatore una volta — unità e intervallo valgono per ogni misurazione.", "define": "Definisci", "defineFirst": "Definisci il tuo primo biomarcatore", @@ -6699,18 +6644,6 @@ "label": "Referto medico", "description": "Esportazione PDF e FHIR per il tuo studio." }, - "weight": { - "label": "Peso", - "description": "Peso e composizione corporea." - }, - "bloodPressure": { - "label": "Pressione", - "description": "Sistolica e diastolica." - }, - "pulse": { - "label": "Battito", - "description": "Battito a riposo e frequenza cardiaca." - }, "medications": { "label": "Farmaci", "description": "Schemi, assunzioni e aderenza." @@ -6723,7 +6656,12 @@ "illness": { "title": "Diario delle malattie", "subtitle": "Un registro retrospettivo dei tuoi episodi di malattia e disturbi.", - "disclaimer": "Un diario personale, non un dispositivo medico. Non diagnostica né prevede.", + "customize": "Personalizza diario delle malattie", + "settings": { + "title": "Personalizza diario delle malattie", + "description": "Gestisci la vista e l'ordine degli episodi.", + "back": "Torna al diario delle malattie" + }, "newEpisode": "Nuovo episodio", "logDay": "Registra un giorno", "markRecovered": "Segna come guarito", @@ -6823,7 +6761,6 @@ "countOther": "Hai registrato {count} episodi in questo periodo.", "typicalGap": "Divario di recupero tipico: {days} giorni tra il sentirti meglio e la stabilizzazione dei valori.", "typicalGapLearning": "Ancora qualche episodio risolto e qui apparirà un divario di recupero tipico.", - "byMonth": "Quando tendi a registrare episodi", "none": "Nessun episodio registrato in questo periodo." }, "section": { @@ -6893,5 +6830,24 @@ "reminderSuggestionsLabel": "Suggerimenti di cadenza", "reminderSuggestionsDescription": "Lascia che il Coach proponga ogni tanto un promemoria basato sull'evidenza per misurare una metrica più regolarmente." } + }, + "safety": { + "floor": { + "bpHighTitle": "Pressione molto alta", + "bpHighDoctor": "Due misurazioni recenti erano di {systolic}/{diastolic} mmHg o superiori. Riposa, rimisura tra qualche minuto e contatta il medico al più presto se resta così alta.", + "bpHighEmergency": "Due misurazioni recenti erano di {systolic}/{diastolic} mmHg o superiori e hai segnalato sintomi. In caso di dolore al petto, affanno, debolezza o disturbi della vista o del linguaggio, chiama subito i soccorsi.", + "bpLowTitle": "Pressione bassa", + "bpLowDoctor": "Due misurazioni recenti avevano una sistolica sotto {systolic} mmHg. Se ti senti stordito o debole, siediti o sdraiati e contatta il medico se continua.", + "bpLowEmergency": "Due misurazioni recenti avevano una sistolica sotto {systolic} mmHg e hai segnalato sintomi. Se senti che stai per svenire, sei confuso o hai la pelle fredda e sudata, chiama subito i soccorsi.", + "glucoseLowTitle": "Glicemia bassa", + "glucoseLowDoctor": "Due misurazioni recenti erano sotto 70 mg/dL ({value} mg/dL). Assumi carboidrati ad azione rapida, rimisura tra 15 minuti e contatta il medico se i cali continuano.", + "glucoseLowEmergency": "Due misurazioni recenti erano sotto 70 mg/dL ({value} mg/dL) e hai segnalato sintomi. Tratta subito con zucchero rapido; se non puoi o ti senti molto male, chiama i soccorsi.", + "glucoseLowSevereTitle": "Glicemia molto bassa", + "glucoseLowSevereDoctor": "Due misurazioni recenti erano sotto 54 mg/dL ({value} mg/dL). Tratta subito con carboidrati ad azione rapida, rimisura tra 15 minuti e contatta il medico.", + "glucoseLowSevereEmergency": "Due misurazioni recenti erano sotto 54 mg/dL ({value} mg/dL) e hai segnalato sintomi. Tratta subito; se non trattieni il cibo o ti senti molto male, chiama i soccorsi.", + "glucoseHighTitle": "Glicemia molto alta", + "glucoseHighDoctor": "Due misurazioni recenti erano di {value} mg/dL o superiori. Rimisura, idratati e contatta il medico al più presto se resta così alta.", + "glucoseHighEmergency": "Due misurazioni recenti erano di {value} mg/dL o superiori e hai segnalato sintomi. In caso di vomito, respiro affannoso o confusione, chiama subito i soccorsi." + } } } diff --git a/messages/pl.json b/messages/pl.json index 853a30a39..b4ccb8202 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -18,7 +18,6 @@ "optional": "Opcjonalne", "active": "Aktywne", "inactive": "Nieaktywne", - "enabled": "Włączone", "disabled": "Wyłączone", "networkError": "Błąd sieci", "unknownError": "Nieznany błąd", @@ -74,7 +73,6 @@ "colTaken": "Przyjęte", "colSkipped": "Pominięte", "colMissed": "Pominięte", - "colTotal": "Razem", "colExpected": "Oczekiwane", "colComplianceRate": "Wskaźnik przestrzegania", "moodTitle": "Przebieg nastroju", @@ -191,7 +189,8 @@ "backToDashboard": "Powrót do panelu" }, "offlineBanner": { - "message": "Brak połączenia — twoje zmiany zostaną zapisane, gdy ponownie pojawisz się online." + "message": "Brak połączenia — twoje zmiany zostaną zapisane, gdy ponownie pojawisz się online.", + "showingCached": "Wyświetlane są ostatnio zsynchronizowane dane." }, "demoBanner": { "message": "Tryb demo — zmiany nie są zapisywane." @@ -201,14 +200,12 @@ "measurements": "Pomiary", "medications": "Leki", "insights": "Insights", - "targets": "Cele", "achievements": "Osiągnięcia", "settings": "Ustawienia", "bugreport": "Zgłoszenie błędu", "admin": "Konsola administratora", "logout": "Wyloguj", "login": "Zaloguj", - "about": "O HealthLog", "notifications": "Powiadomienia", "theme": "Wygląd", "themeSystem": "Systemowy", @@ -218,13 +215,11 @@ "userMenu": "Menu użytkownika", "accountSettings": "Ustawienia konta", "mood": "Nastrój", - "workouts": "Treningi", "coach": "Coach", "skipToContent": "Przejdź do treści", "sidebar": "Pasek boczny", "mainNavigation": "Nawigacja główna", "mobileNavigation": "Nawigacja mobilna", - "breadcrumb": "Nawigacja okruszkami", "loadingScreen": "Ładowanie…", "more": "Więcej", "moreSheetTitle": "Więcej", @@ -241,7 +236,6 @@ }, "cycle": "Cykl", "labs": "Wyniki badań", - "recovery": "Regeneracja", "vorsorge": "Profilaktyka", "illness": "Choroba" }, @@ -329,7 +323,6 @@ "sleepShort": "Sen", "stepsShort": "Kroki", "vo2MaxShort": "VO₂ max", - "bpInTargetShort": "RR-cel", "bpInTargetWindow90": "RR-cel · 90 d", "bpInTargetWindowDynamic": "RR-cel · {days} d", "bpInTargetCollecting": "zbieranie danych", @@ -383,7 +376,6 @@ "staleHintWeeksOther": "Ostatni odczyt {count} tygodni temu", "staleHintMonthsOne": "Ostatni odczyt 1 miesiąc temu", "staleHintMonthsOther": "Ostatni odczyt {count} miesięcy temu", - "coachCta": "Zapytaj coacha", "recentWorkouts": { "title": "Ostatnie treningi", "viewAll": "Zobacz wszystkie", @@ -422,7 +414,7 @@ }, "measurements": { "title": "Pomiary", - "subtitle": "Waga, ciśnienie tętnicze, tętno i więcej", + "subtitle": "Zapisuj pomiary i przeglądaj pełną historię. Trendy i analizy znajdziesz w Analizach.", "addMeasurement": "Dodaj", "editMeasurement": "Edytuj pomiar", "type": "Typ", @@ -498,7 +490,6 @@ "invalidTimestamp": "Wybierz poprawną datę i godzinę.", "loginRequired": "Zaloguj się, aby zobaczyć pomiary.", "allTypes": "Wszystkie typy", - "filterByType": "Filtruj według typu", "measurementCount": "{count} pomiarów", "emptyTitle": "Brak pomiarów", "emptyDescription": "Zarejestruj wagę, ciśnienie, tętno lub inne wartości, aby zauważyć trendy.", @@ -549,7 +540,6 @@ "typeCardioLoad": "Obciążenie kardio", "typeSleepScore": "Wynik snu", "typeBodyTemperatureDeviation": "Odchylenie temperatury ciała", - "filterBySource": "Filtruj według źródła", "sourceComputed": "Obliczone", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", @@ -669,7 +659,6 @@ "factorSadness": "smutek", "praise": "pochwała" }, - "filterBySource": "Filtruj według źródła", "sourceManual": "Ręcznie", "sourceWeb": "Web", "sourceTelegram": "Telegram", @@ -740,8 +729,6 @@ "subtitle": "Leki, harmonogramy i przyjęcia", "loginRequired": "Zaloguj się, aby zarządzać lekami.", "addMedication": "Dodaj", - "editMedication": "Edytuj lek", - "newMedication": "Nowy lek", "firstMedication": "Dodaj pierwszy lek", "addChoice": { "logIntake": "Zarejestruj przyjęcie", @@ -777,16 +764,9 @@ "loadFailed": "Nie udało się załadować leków", "retryLoad": "Spróbuj ponownie", "name": "Nazwa", - "dose": "Dawka", - "scheduleFrom": "Od", - "scheduleTo": "Do", - "scheduleLabel": "Etykieta", - "scheduleDays": "Dni", - "scheduleDaily": "Codziennie", "inactive": "Wstrzymany", "intakeHistory": "Historia przyjęć", "openDetailPage": "Otwórz stronę szczegółów leku", - "deleteConfirm": "Usunąć lek?", "compliance": "Przestrzeganie zaleceń", "importIntakes": "Importuj przyjęcia", "importDescription": "Wklej tablicę JSON z danymi przyjęć. Oczekiwany format:", @@ -801,19 +781,7 @@ "importInvalidSkipped": "{count} nieprawidłowych wpisów pominięto", "importFailed": "Import nie powiódł się", "importInvalidFormat": "Nieprawidłowy format JSON", - "apiEndpointTitle": "Punkt końcowy API: {name}", - "apiEndpointDescription": "Użyj tego punktu końcowego do rejestrowania przyjęć przez API (np. Skrót iPhone lub zadanie cron).", - "apiEndpointActive": "Punkt końcowy API aktywny", - "apiEndpointActivated": "Punkt końcowy API aktywowany", - "apiEndpointDeactivated": "Punkt końcowy API dezaktywowany", - "apiTokenCount": "Aktywny ({count} tokenów)", - "apiToken": "Token API", - "apiTokenActiveHint": "Punkt końcowy jest aktywny. Istniejące tokeny pozostają ważne, ale nie można ich ponownie pokazać jako zwykły tekst.", - "apiTokenActivateHint": "Aktywuj punkt końcowy, aby utworzyć nowy token.", - "apiTokenOnceHint": "Token jest pokazywany jako zwykły tekst tylko raz, przy tworzeniu.", "requestExample": "Przykład żądania", - "statusLoadFailed": "Nie udało się wczytać statusu", - "changeFailed": "Zmiana nie powiodła się", "categoryBloodPressure": "Ciśnienie krwi", "categoryVitamin": "Witaminy", "categoryThyroid": "Tarczyca", @@ -846,21 +814,15 @@ "lastDoseDaysAgo": "Ostatnia dawka {count} dni temu", "overdue": "Po terminie", "veryOverdue": "Mocno po terminie", - "compliance7d": "Przestrzeganie 7 dni", - "compliance30d": "Przestrzeganie 30 dni", "complianceWindow": "Przestrzeganie {days} dni", "complianceUnavailable": "Nie udało się wczytać danych o przestrzeganiu", "complianceInfo": "Udział zarejestrowanych dawek względem oczekiwanych. Plany tygodniowe liczone są tydzień po tygodniu.", "complianceInfoLabel": "Co oznacza przestrzeganie zaleceń?", - "complianceDoses": "Dawki: {count}", "lastIntake": "Ostatnie przyjęcie:", "nextIntake": "Następne przyjęcie:", "nextIntakeOverdue": "Zaległa · {time} — wciąż można przyjąć", "dayStreak": "dni serii", "dayStreakOne": "dzień serii", - "cycleNextDoseToday": "Następna dawka dzisiaj", - "cycleNextDoseTomorrow": "Następna dawka jutro", - "cycleNextDoseInDays": "Następna dawka za {days} dni", "cycleDueToday": "Termin dzisiaj", "cycleOverdue": "Zaległa", "cycleNoClosedCycles": "Brak zakończonych cykli dawkowania", @@ -872,40 +834,10 @@ "intakeUndo": "Cofnij", "intakeUndone": "Wpis cofnięty", "intakeUndoFailed": "Nie udało się cofnąć — popraw w historii", - "weekdaySunday": "Niedziela", - "weekdayMonday": "Poniedziałek", - "weekdayTuesday": "Wtorek", - "weekdayWednesday": "Środa", - "weekdayThursday": "Czwartek", - "weekdayFriday": "Piątek", - "weekdaySaturday": "Sobota", - "weekdaySundayPlural": "Niedziele", - "weekdayMondayPlural": "Poniedziałki", - "weekdayTuesdayPlural": "Wtorki", - "weekdayWednesdayPlural": "Środy", - "weekdayThursdayPlural": "Czwartki", - "weekdayFridayPlural": "Piątki", - "weekdaySaturdayPlural": "Soboty", - "formType": "Typ", - "formDose": "Dawka", - "formUnit": "Jednostka", - "formSchedule": "Okno czasowe", - "formTreatmentClass": "Klasa terapii", - "formTreatmentClassHelp": "Wybierz „Iniekcja GLP-1” dla cotygodniowych iniekcji jak Mounjaro, Ozempic, Wegovy, Zepbound. Odblokowuje kartę ciała i dziennik GLP-1.", - "treatmentClassGeneric": "Lek standardowy", - "treatmentClassGlp1": "Iniekcja GLP-1", - "glp1WeeklyPresetTitle": "Raz w tygodniu, w…", - "glp1WeeklyPresetHelp": "Większość agonistów receptora GLP-1 podawana jest raz w tygodniu. Stuknij dzień, aby ustawić rytm.", - "glp1WeeklyPresetSuggest": "Przejmij ostatnio wybrany dzień", - "glp1DosesPerUnit": "Dawki na pen / fiolkę", - "glp1DosesPerUnitPlaceholder": "np. 4 dla pena Mounjaro KwikPen", - "glp1DosesPerUnitHelp": "Opcjonalne — Mounjaro KwikPen = 4, Ozempic 0,25/0,5 mg = 4, Wegovy pen jednorazowy = 1. Zostaw puste, jeśli nie chcesz śledzić dawek na pen.", "glp1LastInjection": "{label}", - "glp1LastInjectionWithSite": "{label} · {site}", "glp1NextInjectionDays": "{label} (za {days} dni)", "glp1NextInjectionToday": "dziś", "glp1NextInjectionTomorrow": "jutro", - "glp1RotationLast": "Ostatnie miejsce: {site}", "glp1RotationSuggested": "Sugerowane: {site}", "glp1LogSideEffect": "Zapisz działanie niepożądane", "sideEffects": { @@ -1260,8 +1192,7 @@ "add": "Dodaj kolejny harmonogram", "edit": "Edytuj", "remove": "Usuń", - "removeDisabled": "Wymagany jest co najmniej jeden harmonogram.", - "empty": "Brak harmonogramu." + "removeDisabled": "Wymagany jest co najmniej jeden harmonogram." } }, "errors": { @@ -1305,38 +1236,6 @@ "sideEffectTagAppetiteLoss": "Utrata apetytu", "sideEffectTagHeartburn": "Zgaga", "sideEffectTagHeadache": "Ból głowy", - "newSchedule": "Nowe okno czasowe", - "removeSchedule": "Usuń okno czasowe", - "advancedEditing": "Edycja zaawansowana", - "advancedEditingHide": "Ukryj edycję zaawansowaną", - "scheduleInterval": "Interwał", - "scheduleEveryWeek": "co tydzień", - "scheduleEveryNWeeks": "co {n} tygodni", - "scheduleIntervalPrefix": "Interwał:", - "nextSchedulePrefix": "Następne okno:", - "nextScheduleDaily": "codziennie", - "importIntakesAction": "Importuj przyjęcia", - "apiEndpointAction": "Endpoint API", - "pause": "Wstrzymaj", - "activate": "Aktywuj", - "disableNotifications": "Wyłącz powiadomienia", - "enableNotifications": "Włącz powiadomienia", - "purgeRecords": "Usuń zapisy", - "formReset": "Resetuj formularz", - "createMedication": "Utwórz lek", - "deleteConfirmDescription": "Ten lek i wszystkie jego przyjęcia zostaną nieodwracalnie usunięte.", - "purgeConfirmTitle": "Usunąć wszystkie przyjęcia?", - "purgeConfirmDescription": "Wszystkie zarejestrowane przyjęcia tego leku zostaną nieodwracalnie usunięte.", - "saveError": "Błąd zapisu", - "deleteError": "Błąd usuwania", - "purgeError": "Błąd czyszczenia rekordów", - "invalidTimestamp": "Wybierz poprawną datę i godzinę.", - "noIntakesYet": "Brak zarejestrowanych przyjęć.", - "actions": "Akcje", - "pageInfo": "Strona {page} z {total}", - "previousPage": "Poprzednia strona", - "nextPage": "Następna strona", - "loginRequiredIntakes": "Zaloguj się, aby zobaczyć historię przyjęć.", "phaseConfig": "Fazy przypomnień", "phaseConfigDescription": "Skonfiguruj momenty przypomnień.", "phaseGreen": "Zielony", @@ -1345,11 +1244,8 @@ "phaseRed": "Czerwony", "phaseMinutes": "Min", "phasePercent": "%", - "phaseBeforeEnd": "przed końcem", - "phaseAfterEnd": "po końcu", "phaseResetDefaults": "Przywróć wartości domyślne", "phaseSaved": "Fazy przypomnień zapisane", - "phaseReset": "Przywrócono wartości domyślne", "sourceWeb": "Web", "sourceApi": "Aplikacja iOS", "sourceReminder": "Przypomnienie", @@ -1369,16 +1265,6 @@ "intakeHistoryPageInfo": "Strona {page} z {total} · {count} wpisów", "intakeHistoryPrev": "Poprzednia", "intakeHistoryNext": "Następna", - "unitDrops": "kropli", - "unitTablets": "tabletek", - "unitCapsules": "kapsułek", - "unitPieces": "sztuk", - "namePlaceholder": "np. Ramipril", - "dosePlaceholder": "np. 5 mg", - "labelPlaceholder": "np. Rano", - "defaultDose": "Dawka standardowa", - "dayDeactivate": "Wyłącz {day}", - "dayActivate": "Włącz {day}", "glp1": { "drug": { "tirzepatide": { @@ -1635,8 +1521,6 @@ "historyLabel": "Zobacz historię przyjęć" }, "history": { - "back": "Powrót do leku", - "subtitle": "Pełna historia przyjęć", "plannedSuffix": "zaplanowane" }, "tabs": { @@ -2008,7 +1892,8 @@ "bmiEmptyTitle": "Dodaj wzrost, aby obliczyć BMI", "bmiEmptyDescription": "BMI wymaga twojego wzrostu. Zapisz go w Ustawieniach → Konto.", "bmiEmptyAction": "Otwórz ustawienia konta", - "noProviderConfigured": "Ocena niedostępna.", + "noProviderConfigured": "Połącz dostawcę AI, aby zobaczyć tutaj ocenę swoich pomiarów.", + "noProviderAction": "Otwórz ustawienia AI", "startAnalysis": "Uruchom ocenę", "refreshAnalysis": "Odśwież ocenę", "keyTakeaway": "Najważniejsze", @@ -2081,11 +1966,11 @@ "progressAria": "Postęp wyniku zdrowia", "deltaVsLastWeek": "{delta} w porównaniu z ubiegłym tygodniem", "deltaUnavailable": "Brak historii — zajrzyj w przyszłym tygodniu.", + "restModePaused": "Wstrzymane podczas choroby — Twój wynik nie jest dziś oceniany.", "componentBp": "RR", "componentWeight": "Waga", "componentMood": "Nastrój", "componentCompliance": "Przestrzeganie", - "disclaimer": "Wskazówka — nie ocena medyczna.", "deltaExplainer": { "trigger": "Co oznacza ta zmiana?", "title": "Jak liczona jest ta zmiana", @@ -2123,7 +2008,7 @@ "emptyAction": "Wygeneruj briefing", "loadingLabel": "Przygotowanie briefingu", "noProviderTitle": "Brak połączonego dostawcy AI", - "noProviderDescription": "Połącz dostawcę AI w ustawieniach, aby otrzymywać codzienne briefingi.", + "noProviderDescription": "Codzienny briefing podsumowuje Twoje pomiary prostym językiem. HealthLog potrzebuje do tego dostawcy AI — użyj własnego klucza (OpenAI, Anthropic) lub modelu lokalnego. Twoje dane pozostają u Ciebie.", "noProviderAction": "Otwórz ustawienia AI" }, "trendsRow": { @@ -2143,7 +2028,8 @@ "distance": "Dystans marszu i biegu z ostatnich 30 dni.", "vo2_max": "VO₂ max z ostatnich 30 dni.", "body_temp": "Temperatura ciała z ostatnich 30 dni." - } + }, + "viewDetail": "Zobacz szczegóły: {metric}" }, "vo2Max": { "statLatest": "Aktualne", @@ -2165,7 +2051,6 @@ }, "correlationRow": { "title": "Korelacje", - "disclaimer": "Korelacje są obserwacyjne, nie przyczynowe — porozmawiaj z lekarzem, jeśli ciśnienie jest wysokie.", "experimentCta": "Uruchom 7-dniowy eksperyment", "experimentTooltip": "Wkrótce", "emptyTitle": "Potrzeba więcej danych, aby pokazać tę korelację", @@ -2188,6 +2073,8 @@ "refusalInjection": "Twoja wiadomość zawiera instrukcje, które próbują nadpisać moje wytyczne. Jestem coachem HealthLog i podsumowuję wyłącznie twoje własne dane zdrowotne — przeformułuj pytanie w tych ramach.", "errorBudget": "Osiągnięto dzienny limit coacha. Limit resetuje się o północy (UTC).", "errorProvider": "Coach nie mógł teraz skontaktować się z dostawcą analizy. Spróbuj za chwilę.", + "errorNoProvider": "Coach czyta Twoje zapisane dane zdrowotne i odpowiada na pytania o nie (to nie porada medyczna). HealthLog potrzebuje do tego dostawcy AI — połącz własny klucz lub model lokalny.", + "errorNoProviderAction": "Połącz dostawcę AI", "errorNetwork": "Brak połączenia z Internetem — spróbuj ponownie, gdy wrócisz online.", "prefsSaveError": "Nie udało się zapisać ustawień Coacha. Spróbuj ponownie.", "errorCredentialExpired": "Połączenie z dostawcą AI wygasło. Połącz je ponownie w Ustawieniach, aby Coach działał dalej.", @@ -2604,7 +2491,19 @@ "strainBody": "Obciążenie ujmuje, ile wysiłku sercowo-naczyniowego i fizycznego nałożyłeś na swoje ciało — od treningów po aktywny dzień na nogach. Gdy zbierze się wystarczająco danych, jest oceniane względem Twojego własnego typowego wysiłku, a nie cudzej miary.", "stressBody": "Stres jest tu szacunkiem fizjologicznego obciążenia Twojego układu nerwowego, odczytywanym głównie ze wzorców zmienności rytmu serca, a nie z tego, jak się świadomie czujesz. Niższy wynik wskazuje na spokojny, dobrze regulowany stan; wyższy to zachęta, by wpleść regenerację.", "recoveryPageTitle": "Regeneracja", - "recoveryPageBody": "Regeneracja łączy nocne sygnały rejestrowane przez Twoje urządzenie – recharge, obciążenie, load oraz dzienne sumy tętna i energii – aby pokazać, jak dobrze Twój organizm się zregenerował. Patrz na wzorzec w ciągu kilku dni, a nie na pojedynczą liczbę; niższy wynik to zaproszenie do zwolnienia tempa, a nie alarm." + "recoveryPageBody": "Regeneracja łączy nocne sygnały rejestrowane przez Twoje urządzenie – recharge, obciążenie, load oraz dzienne sumy tętna i energii – aby pokazać, jak dobrze Twój organizm się zregenerował. Patrz na wzorzec w ciągu kilku dni, a nie na pojedynczą liczbę; niższy wynik to zaproszenie do zwolnienia tempa, a nie alarm.", + "pulsePressureTitle": "Ciśnienie tętna", + "pulsePressureBody": "Ciśnienie tętna to różnica między wartością skurczową a rozkurczową (skurczowe minus rozkurczowe). Jako ogólna wskazówka, około 40 mmHg jest typowe w spoczynku; szeroka różnica powyżej około 60 mmHg w średnim wieku może odzwierciedlać usztywnienie tętnic, a wąska różnica poniżej około 25 mmHg może towarzyszyć niskiej objętości wyrzutowej (StatPearls/NIH 2023). To powód do rozmowy, nie diagnoza.", + "meanArterialPressureTitle": "Średnie ciśnienie tętnicze", + "meanArterialPressureBody": "Średnie ciśnienie tętnicze to przeciętne ciśnienie, jakie widzą Twoje narządy w trakcie uderzenia serca, w przybliżeniu rozkurczowe plus jedna trzecia różnicy do skurczowego. Jako ogólna wskazówka, wartość spoczynkowa mieści się w okolicy 70–100 mmHg (StatPearls/NIH 2023). Często przywoływany próg ~60 mmHg to pojęcie z medycyny ostrej, nie alarm do samodzielnego pomiaru.", + "cycleEducationBody": "Twój cykl przebiega przez cztery fazy sterowane hormonami, a jego długość i regularność to dostępne okno na ogólny stan zdrowia. Jako ogólna wskazówka, u osób dorosłych cykle trwają zwykle 21–35 dni (źródła kliniczne podają 24–38), z krwawieniem około pięciu dni (ACOG 2015). Faza lutealna jest stałą kotwicą, dlatego owulację najlepiej szacować, licząc wstecz od następnej spodziewanej miesiączki, nigdy według stałego „14. dnia”. Przewidziane okno płodne to oszacowanie, które wymaga kilku zarejestrowanych cykli i pozostaje przybliżone, a podstawowa temperatura ciała potwierdza owulację po fakcie, a nie ją przewiduje. To nie jest metoda antykoncepcji.", + "sleepHygieneBody": "Kilka opartych na dowodach nawyków wspiera sen: stałe pory kładzenia się i wstawania także w weekendy, jasne światło rano i przyciemnione wieczory, chłodna, ciemna i cicha sypialnia oraz unikanie kofeiny po południu, alkoholu późnym wieczorem i obfitych posiłków przed snem (CDC 2024). Regularność przewiduje zdrowie co najmniej tak silnie jak długość snu, a całkowity czas snu z urządzenia jest znacznie bardziej wiarygodny niż jego podział na sen głęboki, lekki i REM.", + "referenceGuidance": "Ogólna wskazówka, nie porada medyczna. Zakresy referencyjne to wartości populacyjne, które zmieniają się wraz z Twoim wiekiem, płcią i stanem zdrowia; to Twój własny trend w czasie prowadzi odczyt." + }, + "bpDerived": { + "pulsePressure": "Ciśnienie tętna (różnica między Twoimi wartościami) wynosi około {value} mmHg – około 40 jest typowe w spoczynku.", + "meanArterialPressure": "Średnie ciśnienie tętnicze (średnia, jaką widzą Twoje narządy) wynosi około {value} mmHg, zwykle w okolicy 70–100.", + "caveat": "Te wartości są wyliczone z ostatniego pomiaru jako ogólna wskazówka, nie jako diagnoza." }, "stats": { "label": "Statystyki podsumowujące", @@ -2641,7 +2540,8 @@ "asleepTotal": "Sen {value}", "inBedTotal": "W łóżku {value}", "awakenings": "{count} przebudzeń", - "source": "Źródło: {source}" + "source": "Źródło: {source}", + "estimateNote": "Czasy faz są szacowane — to źródło podaje sumy dla każdej fazy, a nie zmierzony przebieg." }, "stages": { "deep": "Głęboki", @@ -2668,7 +2568,7 @@ "chronotype": { "title": "Chronotyp", "learning": "Wciąż poznajemy Twój rytm — {counted} z {total} wolnych nocy. Chronotyp wymaga kilku poranków bez budzika, aby coś znaczył.", - "midSleepCaption": "Środek snu w dni wolne około {clock}", + "midSleepCaption": "Naturalny środek Twojego snu w dni wolne przypada około {clock}.", "advancedToggle": "Zaawansowane", "socialJetlagValue": "{hours} godz. {minutes} min jet lagu społecznego", "socialJetlagCaption": "Różnica między porą snu w dni robocze i wolne — jak niewielka cotygodniowa zmiana strefy czasowej.", @@ -2681,7 +2581,8 @@ "intermediate": "Typ pośredni", "late": "Typ wieczorny", "extreme_late": "Wyraźny typ wieczorny" - } + }, + "cornerLabel": "Chronotyp" }, "rhythm": { "loadError": "Nie udało się teraz wczytać Twojego rytmu snu. Pojawi się ponownie, gdy połączenie się ustabilizuje." @@ -2825,6 +2726,7 @@ "title": "Przegląd glukozy", "subtitle": "Spojrzenie na ostatnie {days} dni, na podstawie {count} pomiarów.", "spotCaveat": "To szacunki z pojedynczych pomiarów punktowych, a nie profil z czujnika ciągłego — traktuj je jako kierunek, nie jako diagnozę.", + "labsLink": "Wyniki laboratoryjne? HbA1c lub glukozę na czczo z pobrania krwi zapisz w sekcji Laboratorium.", "learningTitle": "Wciąż poznajemy Twoją glukozę", "learningBody": "Na razie to {count} w ciągu {days}. Przy nieco dłuższej historii obraz się wyostrza — rejestruj dalej lub pozwól uzupełnić to podłączonemu urządzeniu.", "learningBodyEmpty": "Gdy pojawi się kilka pomiarów — wpisanych przez Ciebie lub zsynchronizowanych z urządzenia — czas w zakresie, średnia i zmienność nabiorą tu kształtu.", @@ -3542,9 +3444,6 @@ "target": { "heading": "Cel" }, - "disclaimer": { - "footer": "HealthLog opisuje Twoje własne zarejestrowane dane — nie jest oceną kliniczną ani diagnozą. Porozmawiaj z lekarzem o wszystkim, co Cię niepokoi." - }, "settings": { "overviewTitle": "Uporządkuj przegląd", "overviewDescription": "Pokaż lub ukryj sekcje i ustaw ich kolejność w przeglądzie Analiz." @@ -3572,25 +3471,34 @@ }, "sleepQuality": { "title": "Jakość snu", - "subtitle": "Nocne wyniki z Twojego urządzenia" + "subtitle": "Nocne wyniki z Twojego urządzenia", + "assessment": { + "metric": { + "SLEEP_SCORE": "Twój ogólny wynik snu", + "SLEEP_EFFICIENCY": "efektywność snu", + "SLEEP_PERFORMANCE": "wydajność snu", + "SLEEP_CONSISTENCY": "regularność snu" + }, + "grade": { + "excellent": "doskonała", + "good": "solidna", + "fair": "przeciętna", + "low": "raczej niska" + }, + "clause": "W ciągu ostatnich 30 nocy {metric} jest {grade} i wynosi {value}.", + "closing": { + "excellent": "Trzymaj się rutyny, która Cię tu doprowadziła.", + "good": "Bardziej stałe pory zasypiania i wstawania to zwykle następny zysk.", + "fair": "Utrzymanie stałego okna snu zwykle podnosi ten wynik najbardziej.", + "low": "Regularne wyciszenie wieczorem i stała pora pobudki to pierwsze dźwignie." + } + } }, "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" - }, "block": { "ansChargeExplainer": "Jak naładowany jest Twój autonomiczny układ nerwowy na dany dzień — odczyt regeneracji oparty na nocnych sygnałach serca i oddechu.", "dayStrainExplainer": "Dzienne obciążenie sercowo-naczyniowe na skali wysiłku Twojego urządzenia. Odczytuj je w odniesieniu do regeneracji, nie jako cel sam w sobie.", @@ -3608,8 +3516,6 @@ }, "thresholds": { "defaultLabel": "Domyślne", - "autoModeLabel": "Auto", - "overrideModeLabel": "Niestandardowe", "overrideToggleLabel": "Własne wartości", "resetAction": "Przywróć wartość domyślną", "resetAllAction": "Przywróć wszystkie wartości domyślne", @@ -3646,31 +3552,72 @@ "back": "Wstecz", "skip": "Pomiń tour", "done": "Rozumiem", - "restart": "Uruchom tour ponownie", - "restartHint": "Pokaż tour panelu przy następnym wejściu.", - "restartConfirmation": "Tour ruszy ponownie przy twojej następnej wizycie w panelu.", + "restart": "Uruchom przewodnik modułów ponownie", + "restartHint": "Odtwarza przewodnik po modułach od początku przy następnej wizycie na pulpicie.", + "restartConfirmation": "Przewodnik po modułach uruchomi się od początku przy następnej wizycie na pulpicie.", "steps": { - "tileStrip": { - "title": "Twoje wartości dzienne", - "body": "Pasek kafelków pokazuje ostatnią wartość i 7-dniowy trend dla każdej metryki — waga, ciśnienie, tętno, nastrój. Niepotrzebne wyłączysz w Ustawienia → Panel." + "dashboardOverview": { + "title": "Twój pulpit", + "body": "Pulpit zbiera wszystkie moduły na jednym ekranie. Przytrzymaj kafelek, aby go przestawić lub ukryć (Ustawienia → Układ pulpitu). Kafelek jest wart tyle, ile dane za nim — kolejne moduły je wypełniają." }, "quickAdd": { - "title": "Zarejestrowane w dwóch stuknięciach", - "body": "Przyciskiem Dodaj zapisujesz pomiary lub nastroje bez opuszczania panelu. Formularz zapamiętuje ostatni kontekst i tagi." + "title": "Zapisane w kilka sekund", + "body": "Przez „+ Dodaj” zapiszesz każdy pomiar w sekundy — wagę, ciśnienie, tętno, glukozę. Ukryta moc: datowanie wstecz zapomnianych wartości. Wszystko, co tu wpiszesz, zasila trendy, wynik zdrowia i analizy AI." + }, + "measurements": { + "title": "Pomiary i zakresy docelowe", + "body": "Pomiary pokazują historię i zakres docelowy każdej metryki. Ukryta perełka: ustaw zakres na wartość, aby odchylenia rzucały się w oczy kolorem. Te serie są podstawą korelacji Coacha i raportu dla lekarza." + }, + "medications": { + "title": "Leki i przyjęcia", + "body": "Leki zarządzają harmonogramami, przyjęciami, zapasem i titracją. Ukryte: plany cykliczne (RRULE/przesuwne) i ostrzeżenia o niskim zapasie. Twój wskaźnik przyjęć zasila filar stosowania na pulpicie oraz kontekst AI." + }, + "labs": { + "title": "Wyniki laboratoryjne", + "body": "Laboratorium gromadzi biomarkery z zakresami referencyjnymi — morfologia, cholesterol, HbA1c. Atut: import i eksport FHIR. Wartości poza zakresem wracają jako dowody w analizach i w raporcie dla lekarza." + }, + "illness": { + "title": "Dziennik chorób", + "body": "Dziennik chorób zapisuje ostre epizody i przewlekłe diagnozy wraz z zaostrzeniami. Ukryte: korelacja epizodu z pomiarami oraz Tryb odpoczynku wstrzymujący przypomnienia. Dzięki temu Coach rozumie, czemu wartości przez chwilę odbiegają." + }, + "vorsorge": { + "title": "Profilaktyka", + "body": "Profilaktyka przypomina o badaniach i szczepieniach z własnym interwałem. Wskazówka: „Wykonane” może od razu utworzyć pomiar lub wynik laboratoryjny. Terminy pojawiają się jako przypomnienia i w raporcie dla lekarza." + }, + "cycle": { + "title": "Cykl", + "body": "Śledzenie cyklu zapisuje fazy i objawy. Ukryte: zestawienie objawów z nastrojem i snem. Cykl daje kontekst wahaniom, które inaczej wyglądałyby na niewyjaśnione." + }, + "mood": { + "title": "Nastrój", + "body": "Nastrój zapisuje samopoczucie i tagi w dwie sekundy. Atut: własne tagi. Trendy nastroju korelują ze snem, lekami i cyklem — Coach dostrzega wzorce." }, "insights": { - "title": "Automatyczne insights", - "body": "Insights zamienia twoje wartości w przejrzysty przegląd zdrowia, z oceną ciśnienia, wagi i nastroju. Własny klucz lub domyślny — ty decydujesz." + "title": "Analizy", + "body": "Analizy to serce aplikacji: dynamiczna analiza oparta na dowodach zamiast ogólnych porad — każde stwierdzenie wskazuje na Twoje realne dane. Ukryte: układ można dostosować. Analizy łączą wszystko, co dostarczają moduły powyżej." + }, + "coach": { + "title": "Coach", + "body": "Coach odpowiada na pytania o Twoje dane w dialogu, korzystając z wywiadu, leków i wyników jako kontekstu. Dostawcę AI wybierasz sam (własny klucz lub lokalnie). Niczego nie przelicza — czyta te same liczby co pulpit." }, "integrations": { - "title": "Połącz urządzenia", - "body": "W ustawieniach łączysz Withings (auto-sync wagi, ciśnienia i tętna), moodLog lub dostawcę analizy. Wszystko zostaje opcjonalne i zaszyfrowane." + "title": "Integracje", + "body": "Integracje synchronizują Withings, WHOOP, Polar, Oura i Apple Health automatycznie. Waga, sen i regeneracja uzupełniają się bez wpisywania, a z kolei zasilają analizy i Coacha." + }, + "export": { + "title": "Raport dla lekarza", + "body": "Raport dla lekarza łączy pomiary, leki, wyniki laboratoryjne i profilaktykę w PDF lub FHIR na wizytę. Tu zbiegają się wszystkie moduły — pełny raport powstaje tylko z zadbanych danych." }, "achievements": { - "title": "Zbieraj serie i odznaki", - "body": "Strona Osiągnięć śledzi codzienne serie zapisów, kamienie milowe ciśnienia i odznaki konsekwencji. Drobna motywacja do trzymania się planu." + "title": "Osiągnięcia", + "body": "Osiągnięcia nagradzają konsekwencję — passy i kamienie milowe. Mała pętla motywacji, dzięki której łatwiej wytrwać." + }, + "wrapUp": { + "title": "To koniec przewodnika", + "body": "Każdy moduł możesz obejrzeć osobno w dowolnej chwili („Pokaż ten przewodnik” na każdej stronie) lub uruchomić cały przewodnik ponownie w Ustawienia → Zaawansowane." } - } + }, + "moduleTrigger": "Pokaż ten przewodnik" }, "shell": { "back": "Wstecz", @@ -3741,13 +3688,20 @@ "learning": "Niektóre wnioski — glukoza, rytm snu, trendy ciśnienia, cykl — wyostrzają się w ciągu pierwszego tygodnia lub dwóch, w miarę gromadzenia danych. Puste kafelki oznaczają teraz tylko, że wciąż poznajemy Twój punkt odniesienia.", "connectCta": "Połącz urządzenie", "logCta": "Zapisz pierwszy pomiar", - "importCta": "Importuj istniejącą historię" + "importCta": "Importuj istniejącą historię", + "aiTitle": "Włącz Insighty AI (opcjonalnie)", + "aiBody": "Coach, codzienny briefing i oceny zamieniają Twoje pomiary w prosty język. W tym celu łączysz własny klucz AI (OpenAI, Anthropic) lub model lokalny — Twoje dane pozostają u Ciebie.", + "aiCta": "Połącz dostawcę AI" }, "welcomeBack": { "title": "Witaj ponownie", "body": "Wstępne kroki masz już za sobą. Aby coś zmienić, otwórz ustawienia — wszystko, co skonfigurowałeś, da się tam edytować.", "cta": "Otwórz pulpit" }, + "disclaimer": { + "acknowledge": "Rozumiem: HealthLog to prywatne narzędzie do śledzenia, a nie ocena kliniczna ani diagnoza. Gdy się niepokoję, rozmawiam z lekarzem, a nie z tą aplikacją.", + "learnMore": "Dowiedz się więcej" + }, "welcome": { "title": "Witaj w HealthLog", "carouselLabel": "Karuzela powitalna", @@ -3829,18 +3783,21 @@ "sections": { "account": { "title": "Konto", - "description": "Profil, hasło, klucze dostępu i przewodnik wprowadzający." + "description": "Profil, hasło, klucze dostępu i przewodnik wprowadzający.", + "subtitle": "Twój profil, tożsamość i dane logowania." }, "integrations": { "title": "Integracje", - "description": "Połączone usługi, kanały dostarczania i priorytet źródeł w jednym miejscu." + "description": "Połączone usługi, kanały dostarczania i priorytet źródeł w jednym miejscu.", + "subtitle": "Połącz i zarządzaj źródłami danych oraz urządzeniami." }, "notifications": { "title": "Powiadomienia", "description": "Wszystkie przypomnienia w jednym miejscu — co i kiedy. Kanały dostarczania znajdują się w Integracjach.", "channelsHint": "Telegram, ntfy, Web Push, Webhook i e-mail konfigurujesz teraz w", "channelsHintLink": "Integracje → Kanały", - "inboxLink": "Otwórz skrzynkę odbiorczą" + "inboxLink": "Otwórz skrzynkę odbiorczą", + "subtitle": "Wybierz, które przypomnienia otrzymujesz." }, "layout": { "title": "Układ", @@ -3853,15 +3810,18 @@ "insights": { "title": "Analizy", "description": "Kolejność przeglądu i pigułek nawigacyjnych." - } + }, + "subtitle": "Dostosuj układ pulpitu i analiz." }, "dashboard": { "title": "Pulpit", - "description": "Układ i kolejność kafelków." + "description": "Układ i kolejność kafelków.", + "subtitle": "Rozmieść kafelki na pulpicie." }, "thresholds": { "title": "Cele", - "description": "Dla każdej metryki: własny zakres docelowy zdrowych wartości." + "description": "Dla każdej metryki: własny zakres docelowy zdrowych wartości.", + "subtitle": "Ustaw osobiste zakresy docelowe dla każdej metryki." }, "sources": { "title": "Priorytet źródeł", @@ -3920,19 +3880,23 @@ "scale": "Waga", "other": "Inne", "unknown": "Nieznane" - } + }, + "subtitle": "Zdecyduj, które źródło wygrywa, gdy dwa podają tę samą metrykę." }, "ai": { - "title": "Analizy AI", - "description": "Dostawca, model i klucz API do analiz, a także Twój kontekst „o mnie”." + "title": "Dostawca AI", + "description": "Dostawca, model i klucz API do analiz, a także Twój kontekst „o mnie”.", + "subtitle": "Wybierz dostawcę AI i użyj własnego klucza." }, "api": { "title": "API & Tokens", - "description": "Tokeny Bearer dla własnych skryptów i aplikacji — zapisuj pomiary lub przyjęcia leków z dowolnego miejsca." + "description": "Tokeny Bearer dla własnych skryptów i aplikacji — zapisuj pomiary lub przyjęcia leków z dowolnego miejsca.", + "subtitle": "Zarządzaj punktami końcowymi API i tokenami dostępu." }, "advanced": { "title": "Zaawansowane", - "description": "Opt-in przełącznika trybu Badawczego oraz strefa zagrożenia umożliwiająca nieodwracalne wymazanie wszystkich danych zdrowotnych." + "description": "Opt-in przełącznika trybu Badawczego oraz strefa zagrożenia umożliwiająca nieodwracalne wymazanie wszystkich danych zdrowotnych.", + "subtitle": "Tryb badawczy, reset danych i usunięcie konta." }, "export": { "title": "Eksport i import", @@ -4033,27 +3997,33 @@ "resultSummary": "Zaimportowano {inserted} nowych i zaktualizowano {updated}, pominięto {skipped}.", "rowError": "Wiersz {line}: {reason}" } - } + }, + "subtitle": "Wyeksportuj dane lub zaimportuj z kopii zapasowej." }, "about": { "title": "O tej aplikacji", - "description": "Wersja, licencja, linki." + "description": "Wersja, licencja, linki.", + "subtitle": "Wersja, licencja i linki do projektu." }, "sharing": { "title": "Udostępnianie", - "description": "Utwórz link tylko do odczytu, aby udostępnić swoją dokumentację zdrowotną lekarzowi." + "description": "Utwórz link tylko do odczytu, aby udostępnić swoją dokumentację zdrowotną lekarzowi.", + "subtitle": "Udostępnij dostęp tylko do odczytu do swoich danych." }, "insights": { "title": "Analizy", - "description": "Kolejność przeglądu i pigułek nawigacyjnych." + "description": "Kolejność przeglądu i pigułek nawigacyjnych.", + "subtitle": "Rozmieść sekcje przeglądu i kolejność etykiet." }, "medications": { "title": "Leki", - "description": "Widok listy, kolejność i miejsca wstrzyknięć Twoich leków." + "description": "Widok listy, kolejność i miejsca wstrzyknięć Twoich leków.", + "subtitle": "Zarządzaj widokiem i kolejnością listy leków." }, "mood": { "title": "Tagi nastroju", - "description": "Grupy, tagi i zarchiwizowane tagi w wyborze nastroju." + "description": "Grupy, tagi i zarchiwizowane tagi w wyborze nastroju.", + "subtitle": "Uporządkuj tagi nastroju, grupy i kolejność wyboru." }, "modules": { "title": "Moduły", @@ -4063,26 +4033,24 @@ "title": "Co śledzisz", "description": "Obszary opcjonalne. Wyłączone moduły znikają z nawigacji, kafelków i analiz — Twoje dane pozostają nienaruszone." }, - "core": { - "title": "Zawsze aktywne", - "description": "Podstawy HealthLog. Tych obszarów nie można wyłączyć.", - "note": "Obszar podstawowy — zawsze aktywny" - }, - "description": "Wybierz, co HealthLog ma dla Ciebie śledzić. Włączaj i wyłączaj poszczególne obszary — nawigacja, kafelki i analizy dostosują się od razu.", "manageIn": "Zarządzaj w {section}", - "operatorDisabled": "Wyłączone na całym serwerze" + "operatorDisabled": "Wyłączone na całym serwerze", + "subtitle": "Wybierz, które obszary śledzenia pokazuje HealthLog w aplikacji." }, "gesundheitsakte": { "title": "Dokumentacja zdrowotna", - "description": "Wyeksportuj pełną dokumentację zdrowotną jako PDF, FHIR R4 lub łączony pakiet ZIP." + "description": "Wyeksportuj pełną dokumentację zdrowotną jako PDF, FHIR R4 lub łączony pakiet ZIP.", + "subtitle": "Eksportuj kompletną dokumentację zdrowotną gotową dla lekarza." }, "coach": { "title": "Coach", - "description": "Włącz Coacha, ton i zakres, zapisane wspomnienia." + "description": "Włącz Coacha, ton i zakres, zapisane wspomnienia.", + "subtitle": "Dostosuj Coacha, jego proaktywne podpowiedzi i pamięć." }, "channels": { "title": "Kanały", - "description": "Dostarczanie przez Telegram, ntfy, Web Push, Webhook i e-mail." + "description": "Dostarczanie przez Telegram, ntfy, Web Push, Webhook i e-mail.", + "subtitle": "Gdzie dostarczane są przypomnienia i powiadomienia." } }, "profile": "Profil", @@ -4579,9 +4547,7 @@ "changelog": "Changelog", "docs": "Dokumentacja", "linksHeading": "Źródła i dokumentacja", - "newerAvailable": "Nowa wersja: {tag}", - "tourReplay": "Obejrzyj samouczek", - "tourReplayHint": "Zobacz przewodnik po panelu — przydatne, jeśli pominąłeś go przy pierwszej wizycie." + "newerAvailable": "Nowa wersja: {tag}" }, "testConnection": { "test": "Testuj połączenie", @@ -4964,7 +4930,6 @@ "modules": { "title": "Dostępność modułów", "description": "Wyłącz moduł dla całego serwera. Moduł wyłączony tutaj jest nieaktywny dla każdego konta, niezależnie od preferencji użytkownika. Domeny podstawowe (waga, ciśnienie, tętno, leki) są zawsze włączone i nie można ich wyłączyć.", - "disabledHint": "Wyłączone na całym serwerze", "saveError": "Nie udało się zaktualizować dostępności modułów." }, "bugReportRepo": "Repozytorium", @@ -5343,11 +5308,14 @@ }, "about": { "title": "O programie", - "subtitle": "Wersja, licencja, kod źródłowy i sprawdzanie aktualizacji." + "subtitle": "Wersja, licencja i stan aktualizacji." + }, + "module-availability": { + "title": "Dostępność modułów", + "subtitle": "Włącz lub wyłącz moduły dla wszystkich kont na tym serwerze." } }, "carrier": "Operator", - "carrierUnknown": "Nieznany operator", "carrierUnavailableGeoFallback": "{location} — operator niedostępny" }, "achievements": { @@ -5679,7 +5647,6 @@ "title": "Powiadomienia", "breadcrumbInbox": "Powiadomienia", "breadcrumbChannels": "Kanały powiadomień", - "breadcrumbSettings": "Ustawienia", "eventColumn": "Zdarzenie", "subtitle": "Wybierz, które powiadomienia chcesz otrzymywać i którym kanałem.", "loadError": "Nie udało się załadować ustawień powiadomień.", @@ -5796,7 +5763,6 @@ "dotNearRange": "blisko zakresu", "dotOutOfRange": "poza zakresem", "dotNotLogged": "nie zarejestrowano", - "insufficientData": "Za mało danych — rejestruj dalej.", "relativeDay": { "today": "Dzisiaj", "yesterday": "Wczoraj", @@ -5808,7 +5774,6 @@ } }, "edit": { - "openLabel": "Edytuj zakres docelowy dla {metric}", "title": "Edytuj zakres docelowy — {metric}", "description": "Dostosuj zakres docelowy używany w wykresach, insights i raporcie medycznym. Wartości w {unit}.", "derivedHint": "Ta karta jest wyprowadzona z innych metryk. Edytuj cele bazowe (np. ciśnienie lub waga).", @@ -5824,13 +5789,6 @@ "saveError": "Nie udało się zapisać zakresu docelowego", "resetSuccess": "Zakres docelowy przywrócono do domyślnego" }, - "mood": { - "stability": { - "stable": "stabilny", - "variable": "zmienny", - "highlyVariable": "bardzo zmienny" - } - }, "currentValue": "Aktualnie: {value} {unit}", "targetRangeValue": "Zakres docelowy: {min}–{max} {unit}", "sourceLabel": "Źródło: {source}", @@ -5936,7 +5894,6 @@ }, "lowStockReminders": { "title": "Niski zapas: {medName}", - "body": "{medName}: pozostało {units} jednostek — wystarczy na około {days} dni.", "bodyToday": "{medName}: pozostało {units} jednostek — mniej niż jeden dzień zapasu.", "bodyReorder": "{medName}: pozostało {units} jednostek. Zapas skończy się około {runsOutOn} — zamów ponownie do {reorderBy}.", "bodyLastDose": "{medName}: pozostało {units} jednostek — to mniej więcej Twoja ostatnia dawka. Zapas skończy się około {runsOutOn}." @@ -6044,7 +6001,6 @@ "description": "Jak każdy pomiar wypada między fazą lutealną a folikularną, na Twoich danych.", "pairLabel": "{metric} · lutealna vs folikularna", "detail": "Lutealna {lutealAvg} {unit} ({lutealDays} dni) vs folikularna {follicularAvg} {unit} ({follicularDays} dni).", - "footer": "To powiązania w Twoich danych, nie przyczyny. Pokazywane są tylko wzorce, które przechodzą test istotności.", "metricRestingHeartRate": "Tętno spoczynkowe", "metricHrv": "Zmienność rytmu serca", "metricSleepDuration": "Czas snu", @@ -6068,10 +6024,6 @@ "confidenceHigh": "Wysoka pewność" }, "title": "Analizy cyklu", - "empty": "Analizy faz pojawią się tutaj, gdy zapiszesz wystarczająco wiele cykli. Porównują twoje wskaźniki między fazami cyklu, z otwarcie pokazaną statystyką.", - "sampleSize": "n = {n}", - "effectSize": "Wielkość efektu {value}", - "qValue": "q = {value}", "symptomPatternsTitle": "Wzorce objawów wg fazy", "symptomPatternsEmpty": "Zapisuj objawy przez cały cykl, aby zobaczyć, w której fazie się grupują.", "symptomMostlyIn": "Głównie w fazie {phase} ({count}/{total} dni)" @@ -6091,7 +6043,6 @@ "LUTEAL": "Progesteron prowadzi przez drugą połowę cyklu, a podstawowa temperatura ciała zwykle ustala się nieco wyżej niż wcześniej. Niektóre osoby zauważają z dnia na dzień zmiany w śnie, apetycie, nastroju czy energii, podczas gdy inne czują się stabilnie przez cały czas. Ten wzorzec jest indywidualny i często wygląda inaczej z miesiąca na miesiąc." }, "commonHere": "To często zauważasz tutaj", - "trackNudge": "Zapisz dziś", "stillLearning": "Wciąż poznajemy Twój cykl. Zapisuj dalej, a to wypełni się wzorcami z Twoich własnych dni." }, "insightsSummary": { @@ -6184,10 +6135,7 @@ "prevMonth": "Poprzedni miesiąc", "nextMonth": "Następny miesiąc", "loading": "Ładowanie kalendarza…", - "empty": "Zacznij zapisywać, aby wypełnić kalendarz.", "learning": "Uczymy się Twojego cyklu — zapisz co najmniej trzy pełne cykle, a okno płodne, owulacja i fazy się uzupełnią. Do tego czasu kalendarz pokazuje tylko to, co zapisałaś.", - "today": "Dziś", - "dayAria": "{date}", "legendSymptom": "Objawy", "legendOvulationConfirmed": "Potwierdzona owulacja", "flowSpotting": "plamienie", @@ -6198,11 +6146,9 @@ "predictions": { "title": "Co dalej", "nextPeriod": "Następna miesiączka", - "nextPeriodRange": "Około {date}", "nextPeriodWindow": "{low} – {high}", "fertileWindow": "Okno płodne", "ovulation": "Szacowana owulacja", - "confidence": "Pewność", "confidenceHigh": "Wysoka", "confidenceMedium": "Średnia", "confidenceLow": "Niska", @@ -6238,7 +6184,6 @@ "sheet": { "title": "Zapisz swój dzień", "description": "Zapisz dzisiejsze krwawienie, objawy i notatki.", - "date": "Data", "flow": "Krwawienie", "symptoms": "Objawy", "temperature": "Podstawowa temperatura ciała", @@ -6258,10 +6203,7 @@ "save": "Zapisz", "saving": "Zapisywanie…", "cancel": "Anuluj", - "saved": "Dzień zapisany", "saveError": "Nie udało się zapisać. Spróbuj ponownie.", - "periodStarted": "Zapisano początek miesiączki", - "clear": "Brak", "intermenstrualBleeding": "Plamienie między okresami", "pregnancyTest": "Test ciążowy", "progesteroneTest": "Test progesteronu", @@ -6277,8 +6219,6 @@ }, "settings": { "title": "Ustawienia cyklu", - "enable": "Śledzenie cyklu", - "enableDescription": "Włącz lub wyłącz zakładkę cyklu dla swojego konta.", "typicalCycleLength": "Typowa długość cyklu (dni)", "typicalPeriodLength": "Typowa długość miesiączki (dni)", "lutealPhaseLength": "Długość fazy lutealnej (dni)", @@ -6297,30 +6237,14 @@ "encryptionDescription": "Szyfruj w spoczynku pola ujawniające intencje (testy ciążowe, antykoncepcja, współżycie), obok zawsze szyfrowanych notatek.", "discreet": "Dyskretne powiadomienia", "discreetDescription": "Ukryj szczegóły cyklu w treści powiadomień na ekranie blokady.", - "dataTitle": "Twoje dane", - "dataDescription": "Twoje dane cyklu istnieją tylko na tym serwerze. Eksportuj je w każdej chwili lub usuń na zawsze.", - "export": "Eksportuj moje dane cyklu", - "deleteData": "Usuń moje dane cyklu", "saved": "Ustawienia zapisane", "saveError": "Nie udało się zapisać. Spróbuj ponownie.", "save": "Zapisz", - "purgeTitle": "Usuń wszystkie dane cyklu", - "purgeDescription": "Trwale usuwa każdy zapisany dzień, cykl, prognozę i przypomnienie. Tej operacji nie można cofnąć.", - "purgeButton": "Usuń wszystkie dane cyklu", - "purgeConfirmTitle": "Usunąć wszystkie dane cyklu?", - "purgeConfirmBody": "Spowoduje to trwałe usunięcie każdej miesiączki, wpisu dziennego, objawu i prognozy. Tej operacji nie można cofnąć.", - "purgeConfirm": "Trwale usunąć wszystkie dane cyklu?", - "purgeCancel": "Anuluj", - "purgeSuccess": "Usunięto wszystkie dane cyklu.", - "purgeError": "Nie udało się usunąć danych cyklu.", "reminders": "Przypomnienia", "remindersDescription": "Włącz przypomnienia o okresie dla każdego kanału w ustawieniach powiadomień.", "remindersLink": "Ustawienia powiadomień", "fertileReminder": "Przypomnienie o oknie płodności", - "fertileReminderDescription": "Otrzymaj powiadomienie kilka dni przed szacowanym oknem płodności. Domyślnie wyłączone – włącz je dla każdego kanału w ustawieniach powiadomień.", - "purgeData": "Usuń dane cyklu", - "purgeConfirmAction": "Tak, usuń wszystko", - "purgeDone": "Dane cyklu usunięte." + "fertileReminderDescription": "Otrzymaj powiadomienie kilka dni przed szacowanym oknem płodności. Domyślnie wyłączone – włącz je dla każdego kanału w ustawieniach powiadomień." }, "testResult": { "POSITIVE": "Pozytywny", @@ -6472,17 +6396,29 @@ "pushLocation": "(u {location})", "edit": "Edytuj", "originCoach": "Coach", - "enabledToggleAria": "Włącz lub wyłącz to przypomnienie", "until": "Do {date}", "selfPlanned": "Zaplanowane samodzielnie", "captureValue": "Zapisz wartość", + "measureNow": "Zmierz teraz", + "nextDueLabel": "Następny termin:", + "lastDoneLabel": "Ostatnio wykonano:", + "customize": "Dostosuj profilaktykę", + "adherence": { + "label": "W interwale", + "onTrack": "Zgodnie z planem", + "due": "Termin teraz" + }, + "settings": { + "title": "Dostosuj profilaktykę", + "description": "Zarządzaj widokiem, kolejnością i poszczególnymi przypomnieniami.", + "back": "Powrót do profilaktyki" + }, "capture": { "title": "Zapisz pomiar", "description": "Wpisz wartość; przypomnienie zostanie oznaczone jako wykonane." }, "manage": { - "menuItem": "Dostosuj", - "title": "Dostosuj przypomnienia", + "title": "Zarządzaj przypomnieniami", "description": "Włącz lub wyłącz poszczególne przypomnienia.", "empty": "Brak przypomnień do dostosowania." }, @@ -6491,6 +6427,17 @@ "openLink": "Otwórz profilaktykę" } }, + "moduleList": { + "viewToggleLabel": "Widok", + "viewCards": "Widok kart", + "viewList": "Widok listy", + "viewHeading": "Widok", + "viewDescription": "Wybierz, czy pozycje mają być pokazywane jako karty czy jako zwarta lista.", + "reorder": { + "heading": "Kolejność", + "empty": "Brak pozycji do uporządkowania." + } + }, "labs": { "title": "Wyniki badań", "subtitle": "Badania krwi i biomarkery", @@ -6501,36 +6448,36 @@ "emptyDescription": "Zapisuj badania takie jak HbA1c, LDL czy ferrytyna, aby śledzić je w czasie i udostępniać lekarzowi.", "referenceLabel": "Norma", "readingsCount": "{count} pomiarów", - "deleteLatest": "Usuń ostatni wynik", "deleteConfirmTitle": "Usunąć wynik laboratoryjny?", "deleteConfirmDescription": "Ten wynik laboratoryjny zostanie trwale usunięty.", "deletedToast": "Wynik usunięty", "deleteError": "Nie udało się usunąć wyniku", "loadError": "Nie udało się wczytać wyników", + "customize": "Dostosuj wyniki badań", + "settings": { + "title": "Dostosuj wyniki badań", + "description": "Zarządzaj widokiem, sortowaniem i biomarkerami.", + "back": "Powrót do wyników badań" + }, + "sort": { + "heading": "Sortowanie", + "label": "Kolejność wartości", + "recentDesc": "Najnowszy pomiar najpierw", + "recentAsc": "Najstarszy pomiar najpierw", + "manual": "Sortuj ręcznie", + "hint": "Przy sortowaniu ręcznym możesz przeciągnąć biomarkery poniżej we własnej kolejności." + }, "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": "Podaj wartość.", - "rangeOrderError": "Norma dolna nie może przekraczać górnej.", "save": "Zapisz", "savedToast": "Wynik zapisany", "saveError": "Nie udało się zapisać wyniku", @@ -6567,9 +6514,7 @@ } }, "biomarker": { - "manage": "Biomarkery", "manageTitle": "Biomarkery", - "manageSheetDescription": "Zdefiniuj śledzone markery. Każdy ustawiasz raz wraz z jednostką i zakresem referencyjnym.", "managerDescription": "Zdefiniuj marker raz — jednostka i zakres są używane dla każdego pomiaru.", "define": "Zdefiniuj", "defineFirst": "Zdefiniuj pierwszy biomarker", @@ -6699,18 +6644,6 @@ "label": "Raport dla lekarza", "description": "Eksport PDF i FHIR dla Twojej przychodni." }, - "weight": { - "label": "Waga", - "description": "Waga i skład ciała." - }, - "bloodPressure": { - "label": "Ciśnienie krwi", - "description": "Skurczowe i rozkurczowe." - }, - "pulse": { - "label": "Tętno", - "description": "Tętno spoczynkowe i rytm serca." - }, "medications": { "label": "Leki", "description": "Schematy, przyjęcia i zgodność." @@ -6723,7 +6656,12 @@ "illness": { "title": "Dziennik chorób", "subtitle": "Retrospektywny zapis Twoich epizodów chorób i dolegliwości.", - "disclaimer": "Osobisty dziennik — nie wyrób medyczny. Nie diagnozuje ani nie przewiduje.", + "customize": "Dostosuj dziennik chorób", + "settings": { + "title": "Dostosuj dziennik chorób", + "description": "Zarządzaj widokiem i kolejnością epizodów.", + "back": "Powrót do dziennika chorób" + }, "newEpisode": "Nowy epizod", "logDay": "Zapisz dzień", "markRecovered": "Oznacz jako wyleczone", @@ -6823,7 +6761,6 @@ "countOther": "W tym okresie zapisałeś {count} epizodów.", "typicalGap": "Typowa luka rekonwalescencji: {days} dni między poprawą a ustabilizowaniem wartości.", "typicalGapLearning": "Jeszcze kilka zakończonych epizodów i pojawi się tu typowa luka rekonwalescencji.", - "byMonth": "Kiedy zwykle zapisujesz epizody", "none": "Brak epizodów zapisanych w tym okresie." }, "section": { @@ -6893,5 +6830,24 @@ "reminderSuggestionsLabel": "Sugestie częstotliwości", "reminderSuggestionsDescription": "Pozwól Coachowi od czasu do czasu zaproponować oparte na dowodach przypomnienie, aby mierzyć daną wartość bardziej regularnie." } + }, + "safety": { + "floor": { + "bpHighTitle": "Bardzo wysokie ciśnienie", + "bpHighDoctor": "Dwa ostatnie pomiary wyniosły {systolic}/{diastolic} mmHg lub więcej. Odpocznij, zmierz ponownie za kilka minut i jak najszybciej skontaktuj się z lekarzem, jeśli pozostaje tak wysokie.", + "bpHighEmergency": "Dwa ostatnie pomiary wyniosły {systolic}/{diastolic} mmHg lub więcej i zgłosiłeś objawy. W razie bólu w klatce piersiowej, duszności, osłabienia lub zaburzeń widzenia czy mowy natychmiast zadzwoń po pomoc.", + "bpLowTitle": "Niskie ciśnienie", + "bpLowDoctor": "Dwa ostatnie pomiary miały ciśnienie skurczowe poniżej {systolic} mmHg. Jeśli kręci ci się w głowie lub czujesz osłabienie, usiądź lub połóż się i skontaktuj się z lekarzem, jeśli to się utrzymuje.", + "bpLowEmergency": "Dwa ostatnie pomiary miały ciśnienie skurczowe poniżej {systolic} mmHg i zgłosiłeś objawy. Jeśli czujesz, że możesz zemdleć, jesteś zdezorientowany lub masz zimną, lepką skórę, natychmiast zadzwoń po pomoc.", + "glucoseLowTitle": "Niski poziom cukru", + "glucoseLowDoctor": "Dwa ostatnie pomiary były poniżej 70 mg/dL ({value} mg/dL). Przyjmij szybko działające węglowodany, zmierz ponownie za 15 minut i skontaktuj się z lekarzem, jeśli spadki się utrzymują.", + "glucoseLowEmergency": "Dwa ostatnie pomiary były poniżej 70 mg/dL ({value} mg/dL) i zgłosiłeś objawy. Lecz to teraz szybkim cukrem; jeśli nie możesz lub czujesz się bardzo źle, zadzwoń po pomoc.", + "glucoseLowSevereTitle": "Bardzo niski poziom cukru", + "glucoseLowSevereDoctor": "Dwa ostatnie pomiary były poniżej 54 mg/dL ({value} mg/dL). Lecz natychmiast szybko działającymi węglowodanami, zmierz ponownie za 15 minut i skontaktuj się z lekarzem.", + "glucoseLowSevereEmergency": "Dwa ostatnie pomiary były poniżej 54 mg/dL ({value} mg/dL) i zgłosiłeś objawy. Lecz to teraz; jeśli nie utrzymujesz pokarmu lub czujesz się bardzo źle, zadzwoń po pomoc.", + "glucoseHighTitle": "Bardzo wysoki poziom cukru", + "glucoseHighDoctor": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej. Zmierz ponownie, nawodnij się i jak najszybciej skontaktuj się z lekarzem, jeśli pozostaje tak wysoki.", + "glucoseHighEmergency": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej i zgłosiłeś objawy. W razie wymiotów, przyspieszonego oddechu lub dezorientacji natychmiast zadzwoń po pomoc." + } } } diff --git a/package.json b/package.json index 6b434e375..ca1f3164d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.18.5", + "version": "1.18.6", "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", @@ -73,11 +73,11 @@ "mmdb-lib": "^3.0.2", "next": "16.2.6", "next-themes": "^0.4.6", + "nodemailer": "^9.0.1", "p-limit": "^7.3.0", "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", @@ -87,7 +87,7 @@ "sax": "^1.6.0", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", - "undici": "^8.4.1", + "undici": "^8.5.0", "web-push": "^3.6.7", "yaml": "^2.8.4", "zod": "^4.4.3", diff --git a/playwright.config.ts b/playwright.config.ts index 1ee341f56..1c9773571 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -33,6 +33,17 @@ export default defineConfig({ trace: "retain-on-failure", screenshot: "only-on-failure", video: "retain-on-failure", + // v1.18.6 — block the service worker in the test context. The + // production build registers `/sw.js`, whose v1.18.6 data branch + // serves allowlisted `/api/*` GET reads network-first. A worker- + // originated `fetch` is NOT subject to Playwright's `page.route` + // interception, so the SW would re-fetch the real (empty) backend + // and serve that — bypassing every per-spec `route.fulfill` mock and + // breaking read-after-write assertions (a just-created row never + // surfaced). Blocking the worker keeps the route mocks authoritative + // without weakening the shipped SW behaviour. Real users keep the + // offline data cache; only the test harness opts out. + serviceWorkers: "block", // HealthLog ships dark mode as the default (Dracula theme) — `globals.css` // sets `color-scheme: dark` on the root and the `` defaults // to "system". Playwright's stock context is `colorScheme: "light"`, which diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15afc4d70..a1db36f88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,8 +82,8 @@ importers: 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 + specifier: ^9.0.1 + version: 9.0.1 p-limit: specifier: ^7.3.0 version: 7.3.0 @@ -124,8 +124,8 @@ importers: specifier: ^3.6.0 version: 3.6.0 undici: - specifier: ^8.4.1 - version: 8.4.1 + specifier: ^8.5.0 + version: 8.5.0 web-push: specifier: ^3.6.7 version: 3.6.7 @@ -5196,8 +5196,8 @@ packages: resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} engines: {node: '>=18'} - nodemailer@9.0.0: - resolution: {integrity: sha512-tbPTid7d/p9jAA8CRZ3iomvrMaST0o6NYuY7v6JQZHpPRZ61mLFSPKYd7342NtOFuej9/+L48SOIxwfu2uDvtw==} + nodemailer@9.0.1: + resolution: {integrity: sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==} engines: {node: '>=6.0.0'} non-error@0.1.0: @@ -6345,8 +6345,8 @@ packages: resolution: {integrity: sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==} engines: {node: '>=20.18.1'} - undici@8.4.1: - resolution: {integrity: sha512-RNHlB4fxZK0IrkhBsxhlbx7s8kFWwr7rzzOqj5nvZugw3ig3RsB7KW3zVlV0eu8POl+rx5d1hmL7rRg0z1owow==} + undici@8.5.0: + resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==} engines: {node: '>=22.19.0'} unicorn-magic@0.3.0: @@ -11834,7 +11834,7 @@ snapshots: node-releases@2.0.46: {} - nodemailer@9.0.0: {} + nodemailer@9.0.1: {} non-error@0.1.0: {} @@ -13233,7 +13233,7 @@ snapshots: undici@7.26.0: {} - undici@8.4.1: {} + undici@8.5.0: {} unicorn-magic@0.3.0: {} diff --git a/prisma/migrations/0173_v1186_tour_progress/migration.sql b/prisma/migrations/0173_v1186_tour_progress/migration.sql new file mode 100644 index 000000000..498a859f5 --- /dev/null +++ b/prisma/migrations/0173_v1186_tour_progress/migration.sql @@ -0,0 +1,22 @@ +-- v1.18.6 — resumable module-tour progress. +-- +-- A single nullable JSONB column on `users` carrying the fine-grained +-- resume point for the module-by-module guided tour. Follows the +-- established per-user JSON-column convention (`dashboard_widgets_json`, +-- `insights_layout_json`, `coach_prefs_json`). +-- +-- Shape (validated by `src/lib/onboarding/tour-progress.ts`): +-- { "lastStopId": string | null, +-- "completedStopIds": string[], +-- "status": "in_progress" | "completed" | "skipped", +-- "updatedAt": string (ISO 8601) } +-- +-- NULL = the user has not started the module tour. The coarse +-- `onboarding_tour_completed` boolean stays the launcher's auto-launch +-- gate; this column is purely the resume point and is cleared back to +-- NULL on a Settings replay so the tour genuinely restarts. +-- +-- Additive + nullable: every existing account keeps NULL and the +-- application resolver treats NULL as "not started". + +ALTER TABLE "users" ADD COLUMN "onboarding_tour_progress_json" JSONB; diff --git a/prisma/migrations/0174_v1186_diabetes_opt_in/migration.sql b/prisma/migrations/0174_v1186_diabetes_opt_in/migration.sql new file mode 100644 index 000000000..eaaa0fce8 --- /dev/null +++ b/prisma/migrations/0174_v1186_diabetes_opt_in/migration.sql @@ -0,0 +1,15 @@ +-- v1.18.6 — diabetes opt-in for clinician glucose targets. +-- +-- One additive, defaulted column on `users` so a user can declare "I have +-- diabetes / clinician glucose targets". When `true`, the glucose target +-- resolver applies the tighter ADA glycemic GOAL bands (fasting 80–130, +-- postprandial < 180) instead of the general non-diabetic normal bands. +-- +-- This is NEVER auto-inferred from a reading and is NEVER a diagnosis — it is +-- a user-declared preference that only selects which target band the user is +-- judged against. The default `false` backfills every existing row onto the +-- general bands (the current behaviour), so the migration is a no-op for +-- anyone who never opts in. + +ALTER TABLE "users" + ADD COLUMN "has_diabetes" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/0175_v1186_disclaimer_ack/migration.sql b/prisma/migrations/0175_v1186_disclaimer_ack/migration.sql new file mode 100644 index 000000000..389c580ec --- /dev/null +++ b/prisma/migrations/0175_v1186_disclaimer_ack/migration.sql @@ -0,0 +1,18 @@ +-- v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. +-- +-- Two nullable columns on `users` recording that the user acknowledged, once +-- at the start of onboarding, that HealthLog is a private tracking tool — not +-- a clinical assessment or diagnosis — and that they should talk to a doctor +-- if worried. The `*_version` pins exactly which copy was read (mirrors the +-- `research_mode_acknowledged_*` pair), so a future material wording change +-- can re-prompt without a schema change. +-- +-- Both nullable with no default: NULL = never acknowledged. The prompt only +-- gates fresh onboarding, so existing accounts (backfilled to NULL) are never +-- re-walled. This replaces the per-page / per-chart disclaimer banners that +-- were removed app-wide in the same release; the acknowledged legal text +-- stays reachable on the public privacy page. + +ALTER TABLE "users" + ADD COLUMN "disclaimer_acknowledged_at" TIMESTAMP(3), + ADD COLUMN "disclaimer_acknowledged_version" TEXT; diff --git a/prisma/migrations/0176_v1186_coach_last_seen/migration.sql b/prisma/migrations/0176_v1186_coach_last_seen/migration.sql new file mode 100644 index 000000000..c784fd21b --- /dev/null +++ b/prisma/migrations/0176_v1186_coach_last_seen/migration.sql @@ -0,0 +1,15 @@ +-- v1.18.6 (CCH-03) — Coach "last seen" stamp for the FAB unread dot. +-- +-- A single nullable timestamp on `users`. The proactive Coach nudge now +-- lands as an ASSISTANT message in the conversation rail (CCH-02); the +-- Coach FAB shows a discreet unread dot when the newest Coach message is +-- newer than this stamp, and clears it once the user opens the Coach +-- (the open writes the current time here via POST +-- /api/insights/coach/seen). +-- +-- NULL backfills every existing row to "never opened" — harmless: the +-- dot only paints when an actual newer Coach message exists, so a user +-- with no proactive nudge yet sees nothing. No data migration needed. + +ALTER TABLE "users" + ADD COLUMN "coach_last_seen_at" TIMESTAMP(3); diff --git a/prisma/migrations/0177_v1186_coach_message_role_index/migration.sql b/prisma/migrations/0177_v1186_coach_message_role_index/migration.sql new file mode 100644 index 000000000..1bdaed2d3 --- /dev/null +++ b/prisma/migrations/0177_v1186_coach_message_role_index/migration.sql @@ -0,0 +1,24 @@ +-- v1.18.6 (L1) — covering index for the Coach assistant-message scans. +-- +-- Two hot reads walk a user's assistant messages newest-first: +-- * the global Coach FAB poll (`GET /api/insights/coach/nudge-status`) — +-- the newest assistant message across the caller's conversations, used +-- to decide whether the unread dot paints; and +-- * the proactive-nudge cron's frequency gate (CCH-02 / M1) — the newest +-- `provider_type = 'nudge'` assistant message inside the rolling window. +-- +-- The pre-existing `(conversation_id, created_at)` index does not cover the +-- `role` / `provider_type` filter, so Postgres gathered every assistant row +-- in the user's threads and sorted. `coach_messages` grows unbounded, and the +-- FAB poll fires on every authenticated page with refetch-on-focus. +-- +-- This index orders by `created_at DESC` after `(conversation_id, role)` so +-- the planner can do an index-backed per-conversation merge for the +-- newest-first reads. Additive; no data touched. +-- +-- Reversibility (down): +-- DROP INDEX IF EXISTS "coach_messages_conversation_id_role_created_at_idx"; + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "coach_messages_conversation_id_role_created_at_idx" + ON "coach_messages"("conversation_id", "role", "created_at" DESC); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a69270678..c2f620a3c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -193,6 +193,13 @@ model User { moodTagLayoutJson Json? @map("mood_tag_layout_json") // Blood-glucose display unit preference. Canonical storage is mg/dL. glucoseUnit String? @map("glucose_unit") // "mg/dL" | "mmol/L" | null = default "mg/dL" + // v1.18.6 — explicit "I have diabetes / clinician glucose targets" opt-in. + // When `true`, the glucose target resolver applies the tighter ADA glycemic + // GOAL bands (fasting 80–130, postprandial < 180) instead of the general + // non-diabetic normal bands. NEVER auto-inferred from values, NEVER a + // diagnosis — purely a user-declared preference that selects which target + // band the user is judged against. Default `false`. + hasDiabetes Boolean @default(false) @map("has_diabetes") // v1.7.0 unit pref — global metric/imperial display preference. // Canonical storage stays SI; this only selects the display-time // transform branch (km/h vs mph, km vs mi). Null = default "metric". @@ -256,6 +263,33 @@ model User { // Settings → Account. onboardingTourCompleted Boolean @default(false) @map("onboarding_tour_completed") + // v1.18.6 — resumable module-tour progress. The coarse boolean above + // stays as the "did the user ever finish/dismiss" flag the launcher + // gates on; this column carries the fine-grained resume point so a + // mid-tour reload reopens at the right module. Follows the per-user + // JSON-column convention (`dashboardWidgetsJson` / `insightsLayoutJson` + // / `coachPrefsJson`). Shape (validated by + // `src/lib/onboarding/tour-progress.ts`): + // { lastStopId: string | null, + // completedStopIds: string[], + // status: "in_progress" | "completed" | "skipped", + // updatedAt: string (ISO) } + // Null = the user has not started the module tour (fresh account or a + // legacy account that only carries the boolean flag). Cleared back to + // null on a Settings replay so the tour genuinely restarts. + onboardingTourProgressJson Json? @map("onboarding_tour_progress_json") + + // v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. Shown + // once at the start of onboarding: HealthLog is a private tracking tool, + // not a clinical assessment or diagnosis; if worried, talk to a doctor. + // Mirrors the `researchModeAcknowledged*` pair — the version pins exactly + // which copy the user read, so a future material wording change can ask + // again. Null = never acknowledged (legacy accounts read as never-asked; + // the prompt only gates fresh onboarding, so they are not re-walled). The + // acknowledged legal text stays reachable on the public privacy page. + disclaimerAcknowledgedAt DateTime? @map("disclaimer_acknowledged_at") + disclaimerAcknowledgedVersion String? @map("disclaimer_acknowledged_version") + // HealthKit (iOS) integration config — list of per-metric direction toggles. // Shape: { entries: [{ id, kind, direction, enabled }], lastSyncedAt } healthKitConfigJson Json? @map("healthkit_config_json") @@ -430,6 +464,18 @@ model User { // `GET /api/auth/me` as a `modules: { : boolean }` map. modulePreferencesJson Json? @map("module_preferences_json") + // v1.18.6 (CCH-03) — "Coach last seen" stamp powering the unread dot + // on the Coach FAB. The proactive nudge now lands as an ASSISTANT + // message in the conversation rail (CCH-02); the FAB shows a discreet + // unread dot when the newest Coach message is newer than this stamp, + // and clears it when the user opens the Coach (drawer or page, which + // writes the current time here via `POST /api/insights/coach/seen`). + // Null = never opened the Coach, so any existing message reads as + // unread once. Server-authoritative so the signal is consistent across + // web + iOS; the FAB keeps a local mirror only as an instant-paint + // optimisation. + coachLastSeenAt DateTime? @map("coach_last_seen_at") + // v1.5.5 — self-hosted avatar storage. // // Replaces the Gravatar third-party leak (the old `getGravatarUrl` @@ -3586,6 +3632,12 @@ model CoachMessage { feedback RecommendationFeedback[] @@index([conversationId, createdAt]) + // v1.18.6 (L1) — the FAB nudge-status poll and the cron's persisted-nudge + // frequency gate both scan a user's assistant messages newest-first + // (optionally filtered to provider_type = 'nudge'). This index lets the + // planner do an index-backed per-conversation merge instead of gathering + + // sorting every assistant row in the user's threads on each global poll. + @@index([conversationId, role, createdAt(sort: Desc)]) @@map("coach_messages") } diff --git a/public/manifest.json b/public/manifest.json index 0b6c6de85..399b9f08a 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -6,7 +6,8 @@ "scope": "/", "start_url": "/", "display": "standalone", - "display_override": ["standalone"], + "display_override": ["standalone", "minimal-ui"], + "categories": ["health", "medical", "lifestyle"], "theme_color": "#282a36", "background_color": "#282a36", "icons": [ diff --git a/public/sw.js b/public/sw.js index 32d3f1aa6..fb3bad369 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,11 +36,72 @@ 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.18.4"; + /* @sw-version-fallback */ "v1.18.6"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; +// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` +// reads. Network-first: online callers always get the fresh server response +// (and the cache is refreshed behind it), so a read-after-write never serves a +// stale pre-mutation list. Offline (the network fetch throws) the LAST cached +// dashboard/series is served instead of empty skeletons forever. +// +// v1.18.6.x — reverted from stale-while-revalidate, which served the cached +// pre-mutation copy first and only revalidated in the background, so a list +// read straight after a create/update showed the old (or empty) data until the +// next paint. Network-first restores read-after-write freshness while keeping +// the offline fallback. +const DATA_CACHE = `healthlog-data-${CACHE_VERSION}`; const MAX_STATIC_ENTRIES = 150; const MAX_PAGE_ENTRIES = 30; +const MAX_DATA_ENTRIES = 60; + +// Safe-GET read allowlist. ONLY idempotent reads that render the core views +// and carry NO secret-shaped body. Auth, mutations and token endpoints are +// never listed — and `isCacheableApiResponse` is a second, body-level guard +// (mirrors the idempotency cache's `hlk_`/`hlr_`/`sk-` refusal) so a future +// endpoint that slipped onto the list can still never persist a secret. +// Matched by exact path or `/` / `?` prefix. +// v1.18.6 — AI/clinical narrative surfaces (`/api/insights`, `/api/analytics`) +// are deliberately NOT cached: `/api/insights` prefix-matched the Coach chat +// endpoint into the disk cache, and neither belongs on a self-hosted health +// PWA's durable storage. Only the dashboard snapshot, the widget layout, the +// measurement reads (incl. the batched daily series), the medication reads, +// and the version probe are eligible. +const API_READ_ALLOWLIST = [ + "/api/dashboard/snapshot", + "/api/dashboard/widgets", + "/api/measurements", + "/api/medications", + "/api/version", +]; + +// Defence in depth: even an allowlisted path is refused if its pathname looks +// like an auth/token/secret surface. +const API_DENY_RE = /\/(auth|tokens?|sessions?|login|password|webauthn)(\/|$|\?)/i; + +function isAllowlistedApiRead(pathname) { + if (API_DENY_RE.test(pathname)) return false; + return API_READ_ALLOWLIST.some( + (p) => + pathname === p || + pathname.startsWith(p + "/") || + pathname.startsWith(p + "?"), + ); +} + +// Body-level secret refusal: never persist a body carrying a token/secret- +// shaped pattern even if the path was allowlisted by mistake. +const SECRET_BODY_RE = /(hlk_|hlr_|sk-)[A-Za-z0-9_-]/; + +function isCacheableApiResponse(response, bodyText) { + if (!response || !response.ok) return false; + const cacheControl = response.headers.get("Cache-Control") || ""; + if (/no-store/i.test(cacheControl)) return false; + if (typeof bodyText === "string" && SECRET_BODY_RE.test(bodyText)) { + return false; + } + return true; +} // App shell files to precache on install const PRECACHE_URLS = ["/", "/logo-192.png", "/logo-512.png", "/favicon.svg"]; @@ -64,7 +125,10 @@ self.addEventListener("activate", (event) => { .then((keys) => Promise.all( keys - .filter((k) => k !== STATIC_CACHE && k !== PAGE_CACHE) + .filter( + (k) => + k !== STATIC_CACHE && k !== PAGE_CACHE && k !== DATA_CACHE, + ) .map((k) => caches.delete(k)), ), ), @@ -87,8 +151,16 @@ self.addEventListener("fetch", (event) => { // Only handle same-origin GET requests if (request.method !== "GET" || url.origin !== self.location.origin) return; - // API calls — network only (never serve stale data) - if (url.pathname.startsWith("/api/")) return; + // API calls. Allowlisted safe GET reads use network-first so the installed + // PWA always renders fresh server data online (and falls back to the last + // cached copy only when the network fails); everything else (auth, + // mutations, anything not on the list) stays network-only. + if (url.pathname.startsWith("/api/")) { + if (isAllowlistedApiRead(url.pathname)) { + event.respondWith(networkFirstApi(request)); + } + return; + } // Next.js static assets — cache-first (immutable hashed filenames) if (url.pathname.startsWith("/_next/static/")) { @@ -126,6 +198,57 @@ async function cacheFirst(request, cacheName) { return response; } +/** + * Network-first for allowlisted safe GET `/api/*` reads. + * + * Always tries the network first so an online caller gets the fresh server + * response — critical for read-after-write: a list fetched straight after a + * create/update must reflect the mutation, not a stale pre-mutation copy. On a + * cacheable response the data cache is refreshed behind it. Only when the + * network fails (offline) is the last cached copy served; with neither, a JSON + * 503 lets the client's TanStack Query cache / IndexedDB persister supply data. + * + * Caching is gated twice: `isAllowlistedApiRead` already filtered the path, + * and `isCacheableApiResponse` inspects the actual body so a `no-store` or + * secret-shaped (`hlk_`/`hlr_`/`sk-`) response is never persisted — the same + * refusal discipline the idempotency cache uses. + */ +async function networkFirstApi(request) { + const cache = await caches.open(DATA_CACHE); + + try { + const response = await fetch(request); + // Read the body once to body-screen it, then reconstruct a fresh Response + // so both the cache.put and the return value have an unconsumed body. + const bodyText = await response + .clone() + .text() + .catch(() => undefined); + if (isCacheableApiResponse(response, bodyText)) { + const toStore = new Response(bodyText, { + status: response.status, + statusText: response.statusText, + headers: response.headers, + }); + await cache.put(request, toStore); + trimCache(DATA_CACHE, MAX_DATA_ENTRIES); + } + return response; + } catch { + // Network failed (offline) — serve the last cached copy if we have one. + const cached = await cache.match(request); + if (cached) return cached; + + return new Response( + JSON.stringify({ data: null, error: "offline", meta: { offline: true } }), + { + status: 503, + headers: { "Content-Type": "application/json; charset=utf-8" }, + }, + ); + } +} + /** * Privacy gate for the navigation page cache. Most app routes are * client-fetch-only shells with no server-rendered PII (health JSON loads diff --git a/src/__tests__/i18n-drift-guard.test.ts b/src/__tests__/i18n-drift-guard.test.ts index 74a9cccdd..4910dc39b 100644 --- a/src/__tests__/i18n-drift-guard.test.ts +++ b/src/__tests__/i18n-drift-guard.test.ts @@ -68,7 +68,7 @@ const REQUIRED_KEY_SETS: Array<{ }> = [ { label: "admin.carrier (B3 — login carrier chip)", - required: ["admin.carrier", "admin.carrierUnknown"], + required: ["admin.carrier"], }, { label: "insights.emptyState.* (B4 — per-metric empty states)", diff --git a/src/__tests__/i18n-reverse-coverage.test.ts b/src/__tests__/i18n-reverse-coverage.test.ts new file mode 100644 index 000000000..be205b095 --- /dev/null +++ b/src/__tests__/i18n-reverse-coverage.test.ts @@ -0,0 +1,232 @@ +/** + * v1.18.6 — reverse i18n coverage guard. + * + * The forward `i18n-call-site-coverage.test.ts` asserts every `t("ns.key")` + * call site resolves to a leaf in `messages/en.json`. This test asserts the + * REVERSE: every leaf in `messages/en.json` is reachable from at least one + * call site, so orphan keys can't accumulate in the bundle unnoticed. + * + * "Reachable" recognises the key-construction patterns the codebase actually + * uses: + * - a literal `t("a.b.c")` (hyphens allowed — admin section slugs use them); + * - a `t("a.b")` that returns a subtree, covering every descendant leaf; + * - a dynamic key built inside a `t(`…`)` template, e.g. the per-metric + * `${i18nPrefix}.title` insight pages, `cycle.calendar.flow${LEVEL}`, or + * the `medications.site${Suffix}` label keys — captured by the static + * leading-literal prefix and/or trailing-literal suffix of the template; + * - a key string assembled outside the call (`return `medications.site${s}`` + * in `describeInjectionSite`) and passed to `t()`, recognised when the + * template's leading literal is rooted at a real top-level namespace; + * - an explicit allowlist for keys resolved through a DB column. + * + * Dynamic detection is scoped to `t(`…`)` and to namespace-rooted key + * templates only — NOT to every backtick string — so a cache key or + * wide-event action name that happens to start with a namespace word doesn't + * mask a genuinely dead key. + * + * If this fails after adding a key, either wire a call site, or — for a key + * resolved through a runtime value the static scan can't see — add its prefix + * to `DYNAMIC_ALLOWLIST_PREFIXES` with a one-line note on why. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +const ROOT = join(__dirname, "../.."); +const SRC = join(ROOT, "src"); +const EN_BUNDLE_PATH = join(ROOT, "messages/en.json"); + +const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]); + +/** + * Keys resolved through a runtime value no static scan can see. Each entry is + * a leaf or a namespace prefix that is always live. + * - `mood.tag` / `mood.tagCategory`: the catalogue is DB-driven; the label key + * is built from a `MoodTag.messageKey` / `MoodTagCategory.messageKey` column, + * so the leaf never appears as a string literal in the tree. + */ +const DYNAMIC_ALLOWLIST_PREFIXES = ["mood.tag", "mood.tagCategory"] as const; + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + if (SKIP_DIRS.has(name)) continue; + const p = join(dir, name); + const stat = statSync(p); + if (stat.isDirectory()) { + walk(p, out); + } else if (name.endsWith(".ts") || name.endsWith(".tsx")) { + out.push(p); + } + } + return out; +} + +function leafPaths(obj: unknown, prefix: string, out: string[]): void { + if (!obj || typeof obj !== "object") return; + for (const [k, v] of Object.entries(obj as Record)) { + const path = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === "object" && !Array.isArray(v)) { + leafPaths(v, path, out); + } else { + out.push(path); + } + } +} + +function nodePaths(obj: unknown, prefix: string, out: Set): void { + if (!obj || typeof obj !== "object") return; + for (const [k, v] of Object.entries(obj as Record)) { + const path = prefix ? `${prefix}.${k}` : k; + out.add(path); + if (v && typeof v === "object" && !Array.isArray(v)) { + nodePaths(v, path, out); + } + } +} + +interface Reference { + /** Literal `t("…")` keys. */ + literalKeys: Set; + /** Dotted bases (`base.${…}` / a full string-literal node path): cover the subtree. */ + dottedBases: Set; + /** Dot-less concat prefixes (`medications.site${…}`): cover any leaf that string-starts with them. */ + concatPrefixes: Set; + /** Trailing static suffixes of a template key (`${…}.title`). */ + dynamicSuffixes: Set; +} + +function collectReferences( + allNodes: Set, + topNamespaces: Set, +): Reference { + const allText = walk(SRC) + .map((f) => readFileSync(f, "utf8")) + .join("\n"); + + const literalKeys = new Set(); + const literalKeyRe = + /(?(); + const concatPrefixes = new Set(); + const dynamicSuffixes = new Set(); + + const ingestTemplate = (content: string) => { + const open = content.indexOf("${"); + if (open > 0) { + const lit = content.slice(0, open); + if (lit.includes(".")) { + if (lit.endsWith(".")) dottedBases.add(lit.slice(0, -1)); + else concatPrefixes.add(lit); + } + } + const close = content.lastIndexOf("}"); + if (close !== -1 && close < content.length - 1) { + const tail = content.slice(close + 1).replace(/^\./, ""); + if (tail) dynamicSuffixes.add(tail); + } + }; + + // Templates passed directly to t(`…`). + const tTemplateRe = /(? leaf === p || leaf.startsWith(`${p}.`), + ); +} + +describe("i18n reverse coverage", () => { + it("every key in messages/en.json has a call site or is allowlisted", () => { + const en = JSON.parse(readFileSync(EN_BUNDLE_PATH, "utf8")); + const leaves: string[] = []; + leafPaths(en, "", leaves); + expect(leaves.length).toBeGreaterThan(1000); + + const allNodes = new Set(); + nodePaths(en, "", allNodes); + const topNamespaces = new Set(Object.keys(en)); + const ref = collectReferences(allNodes, topNamespaces); + // Sanity: the static scan must see the bulk of the bundle. + expect(ref.literalKeys.size).toBeGreaterThan(1000); + + const orphans = leaves.filter( + (leaf) => !isAllowlisted(leaf) && !isReferenced(leaf, ref), + ); + + if (orphans.length > 0) { + const report = orphans.map((k) => ` ❌ ${k}`).join("\n"); + throw new Error( + `Found ${orphans.length} key(s) in messages/en.json with no call site:\n${report}\n\n` + + "Wire a call site, or add a DB-driven prefix to DYNAMIC_ALLOWLIST_PREFIXES.", + ); + } + }); +}); diff --git a/src/__tests__/sw-security-hardening.test.ts b/src/__tests__/sw-security-hardening.test.ts index c082bb60e..4814e6f76 100644 --- a/src/__tests__/sw-security-hardening.test.ts +++ b/src/__tests__/sw-security-hardening.test.ts @@ -85,7 +85,8 @@ describe("public/manifest.json PWA identity hygiene (F-7, 2026-05-16)", () => { const manifest = JSON.parse(raw) as Record; expect(manifest.scope).toBe("/"); expect(manifest.id).toBe("/?source=pwa"); - expect(manifest.display_override).toEqual(["standalone"]); + expect(manifest.display_override).toEqual(["standalone", "minimal-ui"]); + expect(manifest.categories).toEqual(["health", "medical", "lifestyle"]); }); it("keeps the existing display/start_url contract", () => { diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index 513927772..f0a9448c3 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -34,6 +34,7 @@ import { Badge } from "@/components/ui/badge"; import { EmptyState } from "@/components/ui/empty-state"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; +import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger"; import { formatDate } from "@/lib/format"; import { useAchievementsQuery } from "@/lib/queries/use-achievements-query"; import { @@ -317,13 +318,21 @@ export default function AchievementsPage() { return (
-
-

- {t("achievements.title")} -

-

- {t("achievements.subtitle")} -

+
+
+

+ {t("achievements.title")} +

+

+ {t("achievements.subtitle")} +

+
+
+ +
diff --git a/src/app/admin/[section]/renderer.tsx b/src/app/admin/[section]/renderer.tsx index 8bd9e34be..4adc26c77 100644 --- a/src/app/admin/[section]/renderer.tsx +++ b/src/app/admin/[section]/renderer.tsx @@ -94,18 +94,32 @@ export function AdminSectionRenderer({ // v1.18.1 — the former ai-quality, assistant, and coach-feedback // sections fold into one Coach area: surface toggles + operator // config first, then the two feedback-quality tables. + // v1.18.6 (W9) — server-wide module availability moved out to its own + // `module-availability` section: it gates EVERY module, not just the + // coach, so it no longer belongs stacked under Coach. return ( - ); + case "module-availability": + // v1.18.6 (W9) — operator-side server-wide module on/off, its own + // admin section (was stacked under Coach). Reconciled name: the + // user-facing "Module" settings pick from what is available here. + return ( + + + + ); case "feedback": return ( ); case "about": - // v1.4.36 W4e — About section reused as-is from the settings - // surface. The component owns its own heading + cards layout - // so no SectionFrame wrapper. - // v1.18.1 E5 — hide the onboarding "Replay tour" card on the admin - // surface; it's a user-facing affordance with no admin-side use. - return ; + // v1.4.36 W4e — About section reused as-is from the settings surface. + // v1.18.6 (W9) — route it through `SectionFrame` like every other admin + // section so it gets the same visible heading + subtitle (the one admin + // page that was bypassing the frame and showing no heading). + return ( + + + + ); default: slug satisfies never; return null; diff --git a/src/app/api/auth/me/diabetes/route.ts b/src/app/api/auth/me/diabetes/route.ts new file mode 100644 index 000000000..2b3e48e15 --- /dev/null +++ b/src/app/api/auth/me/diabetes/route.ts @@ -0,0 +1,119 @@ +/** + * v1.18.6 — per-user diabetes opt-in endpoint. + * + * GET /api/auth/me/diabetes — current flag. + * PATCH /api/auth/me/diabetes — body `{ hasDiabetes: boolean }`. + * + * The flag is an explicit, user-declared preference ("I have diabetes / + * clinician glucose targets"). When `true`, the glucose target resolver + * applies the tighter ADA glycemic GOAL bands (fasting 80–130, postprandial + * < 180) instead of the general non-diabetic normal bands. It is NEVER + * inferred from a reading and asserts NO diagnosis — it only selects which + * target band a glucose reading is judged against. + * + * Mirrors the `disable-coach` / `unit-preference` per-user-scalar pattern: + * 60/min rate limit, Zod safeParse → 422 via `returnAllZodIssues`, audit-log + * row, field-by-field write (no mass assignment). Idempotent — the endpoint + * always returns the resolved next state so the client can hard-set the + * optimistic update without an extra round-trip. + */ +import { z } from "zod"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; + +const patchBodySchema = z.object({ + hasDiabetes: z.boolean(), +}); + +export const dynamic = "force-dynamic"; + +const PATCH_RATE_LIMIT = 60; +const PATCH_WINDOW_MS = 60_000; + +type DiabetesResponse = { + hasDiabetes: boolean; +}; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "auth.me.diabetes.get" } }); + + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { hasDiabetes: true }, + }); + const payload: DiabetesResponse = { + hasDiabetes: row?.hasDiabetes ?? false, + }; + return apiSuccess(payload); +}); + +export const PATCH = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit( + `diabetes:patch:${user.id}`, + PATCH_RATE_LIMIT, + PATCH_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + // The body is a single boolean — bound the parse so a malformed or + // oversized payload is rejected before it is materialised. + const { data: body, error: jsonError } = await safeJson(req, { + maxBytes: 1024, + }); + if (jsonError) return jsonError; + + const parsed = patchBodySchema.safeParse(body); + if (!parsed.success) { + annotate({ action: { name: "auth.me.diabetes.patch.invalid_shape" } }); + return returnAllZodIssues(parsed.error, 422); + } + + const next = parsed.data.hasDiabetes; + + const previous = await prisma.user.findUnique({ + where: { id: user.id }, + select: { hasDiabetes: true }, + }); + + await prisma.user.update({ + where: { id: user.id }, + data: { hasDiabetes: next }, + }); + + await auditLog("user.diabetes.update", { + userId: user.id, + ipAddress: getClientIp(req), + details: { + previous: previous?.hasDiabetes ?? false, + next, + }, + }); + + annotate({ + action: { name: "auth.me.diabetes.patch" }, + meta: { hasDiabetes: next }, + }); + + const payload: DiabetesResponse = { hasDiabetes: next }; + return apiSuccess(payload); +}); diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index f7fec1a52..434f29221 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -10,6 +10,7 @@ import { resolveModuleMap, getOperatorModuleAvailability, } from "@/lib/modules/gate"; +import { parseTourProgress } from "@/lib/onboarding/tour-progress"; export const dynamic = "force-dynamic"; @@ -70,6 +71,15 @@ export const GET = apiHandler(async () => { timezone: user.timezone, onboardingCompletedAt: user.onboardingCompletedAt, onboardingTourCompleted: user.onboardingTourCompleted, + // v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. Null + // when never acknowledged; the onboarding welcome gate reads this to + // decide whether to require the acknowledgment before "Get started". + disclaimerAcknowledgedAt: user.disclaimerAcknowledgedAt, + // v1.18.6 — resumable module-tour progress. Null when the user has + // not started the tour; otherwise the resume point the launcher + // seeds its index from. Fail-soft parse: a corrupt blob degrades to + // null ("start from the top") rather than 500-ing the /me read. + onboardingTourProgress: parseTourProgress(user.onboardingTourProgressJson), // v1.5.5 — self-hosted avatar. Replaces the Gravatar leak; the // URL is relative so PWA + native clients render identically // and the `?v={updatedAtMs}` suffix busts the browser cache on diff --git a/src/app/api/illness/insights/route.ts b/src/app/api/illness/insights/route.ts index 48960abdd..938ebf3b4 100644 --- a/src/app/api/illness/insights/route.ts +++ b/src/app/api/illness/insights/route.ts @@ -11,6 +11,7 @@ * contributes no gap rather than a wrong one. Born-gated + owner-scoped. */ import { NextRequest } from "next/server"; +import pLimit from "p-limit"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; @@ -29,6 +30,9 @@ import { resolveUserTimezone } from "@/lib/measurements/consolidation-base"; const MS_PER_DAY = 24 * 60 * 60 * 1000; /** Cap the per-episode gap computations so a heavy history can't fan out. */ const MAX_GAP_COMPUTATIONS = 24; +/** Run the per-episode correlations under a bounded concurrency budget so + * the up-to-24 candidates don't serialise into ~500 round-trips. */ +const EPISODE_GAP_CONCURRENCY = 4; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -69,19 +73,24 @@ export const GET = apiHandler(async (request: NextRequest) => { const gapCandidates = rows .filter((r) => r.resolvedAt !== null && r.lifecycle !== "CHRONIC_ONGOING") .slice(0, MAX_GAP_COMPUTATIONS); - const gapById = new Map(); - for (const r of gapCandidates) { - const derived = await computeEpisodeCorrelation( - user.id, - { id: r.id, onsetAt: r.onsetAt, resolvedAt: r.resolvedAt, lifecycle: r.lifecycle }, - tz, - now, - ); - gapById.set( - r.id, - derived.status === "ok" ? derived.value.recoveryGapDays : null, - ); - } + const limit = pLimit(EPISODE_GAP_CONCURRENCY); + const gapEntries = await Promise.all( + gapCandidates.map((r) => + limit(async (): Promise<[string, number | null]> => { + const derived = await computeEpisodeCorrelation( + user.id, + { id: r.id, onsetAt: r.onsetAt, resolvedAt: r.resolvedAt, lifecycle: r.lifecycle }, + tz, + now, + ); + return [ + r.id, + derived.status === "ok" ? derived.value.recoveryGapDays : null, + ]; + }), + ), + ); + const gapById = new Map(gapEntries); const episodes: RetrospectiveEpisode[] = rows.map((r) => ({ id: r.id, diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 812c38b80..93311440c 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -393,9 +393,18 @@ The user's message answers this clarifying question from their self-context ques React briefly and personally to the answer; do not repeat the question and do not ask it again. ` : ""; + // v1.18.6 (W7) — citation-aware reference-range grounding for the metrics + // present in this snapshot. Deterministic + brand-free; framed as general + // guidance, never a diagnosis. Appended after the SNAPSHOT so it is fully + // inspectable and the model reads the published population bands + the + // user's placement. Omitted entirely when no present metric is covered by + // the reference backbone (the builder returns null). + const groundingBlock = snapshot.referenceGrounding + ? `\n${snapshot.referenceGrounding}\n` + : ""; const userPrompt = `SNAPSHOT ${snapshot.snapshotJson || "(no metric data in this user's log yet)"} -${guidedBlock} +${groundingBlock}${guidedBlock} CONVERSATION ${transcript} diff --git a/src/app/api/insights/coach/nudge-status/route.ts b/src/app/api/insights/coach/nudge-status/route.ts index a4aad8dd6..48d9d58c6 100644 --- a/src/app/api/insights/coach/nudge-status/route.ts +++ b/src/app/api/insights/coach/nudge-status/route.ts @@ -1,18 +1,26 @@ /** - * GET /api/insights/coach/nudge-status — has the proactive Coach nudge - * something the caller has not engaged with yet? + * GET /api/insights/coach/nudge-status — is there an unread Coach + * message the caller has not opened yet? * - * v1.16.1 — feeds the floating Coach bubble: the bubble renders only - * while an unseen Coach-initiated nudge exists, instead of being a - * permanent FAB. "Unseen" is derived, not stored: the latest successful - * `COACH_NUDGE` dispatch (the `push_attempts` ledger the nudge cron - * already anchors its frequency cap on) counts as read once the user - * has sent a Coach message after it. The client additionally remembers - * a local "seen" stamp so opening the chat without sending also clears - * the bubble on that device. + * v1.18.6 (CCH-03) — the proactive Coach nudge now lands as a real + * ASSISTANT message in the conversation rail (CCH-02), not as a + * notification-only dispatch. The unread signal moved with it: instead + * of anchoring on the `push_attempts` ledger (which is empty when no + * push channel is configured, so the nudge was invisible), the status + * compares the newest Coach assistant message against the + * server-authoritative `User.coachLastSeenAt` stamp. * - * No new table, no migration: both timestamps come from existing rows, - * scoped to the caller. + * `unread` is true when an assistant message exists that is newer than + * the last time the user opened the Coach (drawer or page, which writes + * `coachLastSeenAt` via `POST /api/insights/coach/seen`). A user who has + * never opened the Coach reads any existing nudge as unread exactly + * once. Server-authoritative so the signal is consistent across web + + * iOS; the FAB keeps a local mirror only as an instant-paint + * optimisation. + * + * `nudgedAt` carries the newest assistant-message timestamp so the FAB's + * local seen-stamp keys on a stable value (kept for the existing client + * contract). */ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess } from "@/lib/api-response"; @@ -23,23 +31,26 @@ export const GET = apiHandler(async () => { const { user } = await requireAuth(); await requireAssistantSurface("coach"); - const [lastNudge, lastUserMessage] = await Promise.all([ - prisma.pushAttempt.findFirst({ - where: { userId: user.id, eventType: "COACH_NUDGE", result: "ok" }, + // The newest assistant message across the caller's conversations — the + // proactive nudge writes one, and so does every normal Coach reply. We + // intentionally key on assistant (not user) messages: the unread dot + // means "the Coach said something", and the open-stamp clears it. + const [lastAssistant, seen] = await Promise.all([ + prisma.coachMessage.findFirst({ + where: { role: "assistant", conversation: { userId: user.id } }, orderBy: { createdAt: "desc" }, select: { createdAt: true }, }), - prisma.coachMessage.findFirst({ - where: { role: "user", conversation: { userId: user.id } }, - orderBy: { createdAt: "desc" }, - select: { createdAt: true }, + prisma.user.findUnique({ + where: { id: user.id }, + select: { coachLastSeenAt: true }, }), ]); - const nudgedAt = lastNudge?.createdAt ?? null; + const nudgedAt = lastAssistant?.createdAt ?? null; + const lastSeenAt = seen?.coachLastSeenAt ?? null; const unread = - nudgedAt !== null && - (lastUserMessage === null || lastUserMessage.createdAt < nudgedAt); + nudgedAt !== null && (lastSeenAt === null || lastSeenAt < nudgedAt); return apiSuccess({ nudgedAt: nudgedAt ? nudgedAt.toISOString() : null, diff --git a/src/app/api/insights/coach/seen/route.ts b/src/app/api/insights/coach/seen/route.ts new file mode 100644 index 000000000..603edafb7 --- /dev/null +++ b/src/app/api/insights/coach/seen/route.ts @@ -0,0 +1,33 @@ +/** + * POST /api/insights/coach/seen — mark every Coach message as read. + * + * v1.18.6 (CCH-03) — opening the Coach (drawer or full page) clears the + * unread dot on the FAB. The open stamps `User.coachLastSeenAt = now`; + * the FAB's `GET /api/insights/coach/nudge-status` then reads no + * assistant message newer than the stamp and drops the dot. Server- + * authoritative so the cleared state follows the user across web + iOS, + * not just the device that opened the Coach. + * + * No request body — the timestamp is server-minted from `now()` so a + * client can never backdate the stamp to suppress a future nudge. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { requireAssistantSurface } from "@/lib/feature-flags"; + +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + await requireAssistantSurface("coach"); + + const seenAt = new Date(); + await prisma.user.update({ + where: { id: user.id }, + data: { coachLastSeenAt: seenAt }, + }); + + return apiSuccess({ seenAt: seenAt.toISOString() }); +}); + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index 4b83a14b6..7c2a2a1ad 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -1,146 +1,26 @@ -import { prisma } from "@/lib/db"; +/** + * `GET /api/insights/targets` — per-metric target tiles + consistency strips. + * + * Thin handler: auth → `cachedSwr(buildTargetsResponse)` → `apiSuccess`. The + * domain function lives in `@/lib/targets/build-response` so the ~1.4k-line + * walk can be unit-tested without the route shell. + */ import { apiSuccess } from "@/lib/api-response"; -import { - classifyBMI, - classifyBP, - classifySleepDuration, - classifyBodyFat, - classifySteps, - getWeightRange, - getSleepDurationRange, - getStepsRange, - getBpTargetsByAge, -} from "@/lib/analytics/classifications"; -import { - buildComplianceMedicationContext, - calculateCompliance, - lastNonSkippedTakenAt, - SCHEDULE_COMPLIANCE_SELECT, -} from "@/lib/analytics/compliance"; -import { pairByTimestamp } from "@/lib/analytics/correlations"; -import { - reconstructSleepNights, - type SleepStageRow, -} from "@/lib/analytics/sleep-night"; -import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target"; -import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; -import { - classifyPulseByTarget, - getPersonalizedPulseTarget, -} from "@/lib/analytics/pulse-targets"; -import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; -import pLimit from "p-limit"; -import type { - MeasurementType, - GlucoseContext, -} from "@/generated/prisma/client"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers"; -import { - getEffectiveRange, - type ThresholdOverridesJson, -} from "@/lib/analytics/effective-range"; -import { getBodyFatTargetRange } from "@/lib/analytics/value-bands"; -import { thresholdMetricForContext, resolveGlucoseUnit } from "@/lib/glucose"; -import { - ensureUserMoodRollupsFresh, - readMoodDayRollups, -} from "@/lib/rollups/mood-rollups"; +import { buildTargetsResponse } from "@/lib/targets/build-response"; export const dynamic = "force-dynamic"; -function getAge(dateOfBirth: Date): number { - const today = new Date(); - let age = today.getFullYear() - dateOfBirth.getFullYear(); - const monthDiff = today.getMonth() - dateOfBirth.getMonth(); - if ( - monthDiff < 0 || - (monthDiff === 0 && today.getDate() < dateOfBirth.getDate()) - ) { - age--; - } - return age; -} - -interface TargetItem { - type: string; - label: string; - current: number | null; - average30: number | null; - trend: "up" | "down" | "stable" | null; - unit: string; - range: { min: number; max: number } | null; - classification: { category: string; color: string } | null; - source: string; - /** - * v1.4.25 W3e — consistency strip support. Berlin-tz day buckets over - * the last 7 days. `daysInRange7d` is the count of days whose mean - * reading landed inside the target's green band; `daysLogged7d` is - * how many of those days had at least one reading (so the strip can - * render filled / hollow / dimmed dots without ambiguity). - * - * `lastMetGoalAt` is the most recent Berlin-tz day whose mean reading - * was in the green band (ISO date YYYY-MM-DD), or null if the user - * has not hit goal in the last 30 days. - * - * `streakDays` is the count of consecutive Berlin days ending today - * where the day's mean was in the green band. Capped at 365. - * - * `insufficientData` is true when the target has fewer than 3 - * readings over the last 30 days OR fewer than 1 day of data in the - * last 7 days. The page hides the strip + percentage when this is - * true. - */ - daysInRange7d: number; - daysLogged7d: number; - daysInRange30d: number; - daysLogged30d: number; - lastMetGoalAt: string | null; - streakDays: number; - insufficientData: boolean; - /** - * v1.4.25 W3e — per-day classification of the last 7 Berlin-tz days - * for the consistency strip. Index 0 is six days ago, index 6 is - * today. `null` slots represent days with no readings; non-null - * slots represent days whose mean reading landed in the named band. - */ - consistency7d: ReadonlyArray<"in" | "near" | "out" | null>; - details?: { - medications?: Array<{ - name: string; - compliance7: number; - compliance30: number; - }>; - }; -} - -interface TargetPageSummary { - targetsMetThisWeek: number; - totalTargets: number; - streakHighlight: { metric: string; days: number } | null; -} - -/** - * v1.4.36 W1 — `/api/insights/targets` body, lifted out of the route - * handler so `cached()` can wrap it. The handler attaches the - * bfcache-friendly Cache-Control header afterward. 60 s TTL keyed on - * the user id matches the analytics cache so multiple Insights mounts - * inside a minute all hit a warm cache. Invalidated alongside the - * analytics bucket on measurement / mood / medication writes. - */ -type AuthedUser = Awaited>["user"]; - export const GET = apiHandler(async () => { const { user } = await requireAuth(); - // v1.16.8 — stale-while-revalidate read. The cold build is the - // multi-query walk below; past the 60 s fresh TTL the prior body now - // serves immediately while ONE background rebuild warms the cell, - // instead of every Insights mount more than a minute apart re-paying - // the >1 s compute inline. Writes still hard-evict the bucket - // (`invalidateUser*` → `deleteByPrefix`), so user actions always - // reflect on the next read. + // v1.16.8 — stale-while-revalidate read. The cold build is the multi-query + // walk in `buildTargetsResponse`; past the 60 s fresh TTL the prior body + // serves immediately while ONE background rebuild warms the cell. Writes + // hard-evict the bucket (`invalidateUser*` → `deleteByPrefix`), so user + // actions always reflect on the next read. const body = await cachedSwr( caches.insightsTargets as ServerCache< Awaited> @@ -153,1411 +33,3 @@ export const GET = apiHandler(async () => { response.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE); return response; }); - -async function buildTargetsResponse(user: AuthedUser) { - const userId = user.id; - - // v1.4.25 W7b — every per-day bucket key in this route resolves - // against the user's display timezone so a Pacific/Auckland user gets - // their Auckland-day streaks, their Auckland-day "last met goal" date, - // and their Auckland-day consistency strip. Falls back to - // Europe/Berlin when the column is somehow missing (defensive — the - // schema's NOT NULL default normally pins it). - const userTz = user.timezone ?? DEFAULT_TIMEZONE; - const dayKey = (d: Date): string => userDayKey(d, userTz); - - // Fetch latest measurements for each type - // v1.11.5 — SLEEP_DURATION is handled by a dedicated per-night - // reconstruction read below (the generic per-type average treats each - // stage row as an independent reading and would inflate the night total), - // so it is intentionally excluded from this generic set. - const types: MeasurementType[] = [ - "WEIGHT", - "BLOOD_PRESSURE_SYS", - "BLOOD_PRESSURE_DIA", - "PULSE", - // v1.15.12 A2 — the "Resting pulse" tile scores RESTING_HEART_RATE - // (Apple's clean daily resting figure) against the resting band, - // falling back to a low-percentile PULSE proxy. Pull the resting rows - // alongside so the consistency strip uses resting data, not raw HR - // polluted by workout samples. - "RESTING_HEART_RATE", - "BODY_FAT", - "ACTIVITY_STEPS", - ]; - - const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000); - - // Mood-rollup warm-up fires before the parallel read below so a legacy - // account converges onto the rollup tier on a subsequent request; the - // read itself never blocks on the warm-up (fire-and-forget). - void ensureUserMoodRollupsFresh(userId); - // Five-year window mirrors `/api/mood/analytics` so existing rollup - // coverage is consulted; the trailing 30-day filter happens in JS - // because the consistency strip already walks per-day buckets. - const moodRollupWindowMs = 5 * 365 * 24 * 60 * 60 * 1000; - const moodSince = new Date(Date.now() - moodRollupWindowMs); - - // v1.16.8 — ONE parallel round of every independent read. The cold - // build used to await eight queries strictly in sequence (profile → - // measurements → latest-per-type → sleep → medications → intakes → - // mood → glucose), stacking their round-trip latencies into the >1 s - // wall-clock the production trace showed. All of them are independent - // (the intake read depends only on the medication ids, so the pair - // chains inside its own slot); fan them out together and the cold - // build pays roughly the slowest query instead of the sum. The - // fan-out is bounded by the project's `p-limit(4)` discipline (parity - // with the derived batch route) so one cold build never claims eight - // connections from the shared Prisma pool at once. - const limit = pLimit(4); - const [ - dbUser, - recentMeasurements, - latestEverByType, - sleepStageRows, - medicationBundle, - moodRollups, - latestMoodEntry, - glucoseRows, - ] = await Promise.all([ - // User profile (height / age / gender / thresholds / source priority). - limit(() => - prisma.user.findUnique({ - where: { id: userId }, - select: { - heightCm: true, - dateOfBirth: true, - gender: true, - glucoseUnit: true, - thresholdsJson: true, - // v1.11.5 — needed to collapse a dual-source sleep night to one - // canonical source before reconstructing per-night asleep totals. - sourcePriorityJson: true, - }, - }), - ), - // All measurements in the last 30 days for the generic type set. - // Soft-delete filter mirrors the W-DELETED pattern: tombstoned rows - // must not contribute to tile averages / latest values once iOS sync - // starts emitting deletions. - limit(() => - prisma.measurement.findMany({ - where: { - userId, - type: { in: types }, - measuredAt: { gte: thirtyDaysAgo }, - deletedAt: null, - }, - orderBy: { measuredAt: "desc" }, - select: { type: true, value: true, measuredAt: true }, - }), - ), - // Absolute latest measurement per type within a one-year floor. - // - // Prisma's `distinct` does not compile to Postgres `DISTINCT ON` — the - // driver dedups after pulling the rows, so an unbounded scan on a - // 347 k-row tenant pulls every measurement back to Node only to drop - // all but seven (audit Critical Finding #2, v1.4.40). The 365-day - // floor caps the planner-side walk to the existing - // `(user_id, type, measured_at)` index range; the trade-off is that a - // user who hasn't measured a metric in over a year sees the tile's - // `current` value as null instead of pulling a stale year-old reading - // forward — which is the correct behaviour for the consistency strip - // (the strip already gates on "fewer than 3 readings in 30 days" → - // insufficientData). - limit(() => - prisma.measurement.findMany({ - where: { - userId, - type: { in: types }, - measuredAt: { gte: oneYearAgo }, - deletedAt: null, - }, - orderBy: { measuredAt: "desc" }, - distinct: ["type"], - select: { type: true, value: true }, - }), - ), - // Sleep stage rows for the per-night reconstruction (section 4). - limit(() => - prisma.measurement.findMany({ - where: { - userId, - type: "SLEEP_DURATION", - measuredAt: { gte: thirtyDaysAgo }, - deletedAt: null, - }, - orderBy: { measuredAt: "asc" }, - // Writer-level collapse: two HealthKit apps behind one source (watch - // stages vs phone in-bed) must not blend into one night. - select: { - value: true, - measuredAt: true, - sleepStage: true, - source: true, - deviceType: true, - }, - }), - ), - // Active medications + their 30-day intake events (section 8). The - // intake read depends on the medication ids, so the pair chains - // inside this slot while riding the outer fan-out. - limit(async () => { - const activeMedications = await prisma.medication.findMany({ - // v1.16.11 — as-needed (PRN) medications never surface a - // compliance rate (no expected doses). - where: { userId, active: true, asNeeded: false }, - // v1.15.20 — schedules through the shared compliance select so the - // configured per-dose windows reach this surface like every other. - include: { - schedules: { select: SCHEDULE_COMPLIANCE_SELECT }, - // v1.16.3 — archived schedule eras for era-aware compliance. - scheduleRevisions: { orderBy: { validFrom: "asc" } }, - }, - orderBy: { name: "asc" }, - }); - if (activeMedications.length === 0) { - return { activeMedications, intakeEvents: [] }; - } - const intakeEvents = await prisma.medicationIntakeEvent.findMany({ - where: { - userId, - // v1.7.0 sync — exclude tombstoned rows. - deletedAt: null, - medicationId: { - in: activeMedications.map((medication) => medication.id), - }, - scheduledFor: { gte: thirtyDaysAgo }, - }, - orderBy: { scheduledFor: "desc" }, - select: { - medicationId: true, - takenAt: true, - skipped: true, - scheduledFor: true, - }, - }); - return { activeMedications, intakeEvents }; - }), - // Mood day rollups (section 9). - limit(() => readMoodDayRollups(userId, moodSince)), - // Latest raw mood entry — bounded `take: 1` via the index. - limit(() => - prisma.moodEntry.findFirst({ - // v1.7.0 sync — exclude tombstoned rows. - where: { userId, deletedAt: null }, - orderBy: { moodLoggedAt: "desc" }, - select: { score: true, moodLoggedAt: true }, - }), - ), - // Glucose rows (section 10). Same one-year floor as the - // latest-per-type read above — the previous unbounded scan pulled a - // heavy logger's full history back to Node on every cold build; a - // context whose newest reading is over a year old now reports no - // card, matching the per-type tile posture. - limit(() => - prisma.measurement.findMany({ - where: { - userId, - type: "BLOOD_GLUCOSE", - measuredAt: { gte: oneYearAgo }, - deletedAt: null, - }, - orderBy: { measuredAt: "desc" }, - select: { value: true, measuredAt: true, glucoseContext: true }, - }), - ), - ]); - - const age = dbUser?.dateOfBirth ? getAge(new Date(dbUser.dateOfBirth)) : null; - const gender = (dbUser?.gender as "MALE" | "FEMALE" | null) ?? null; - const heightCm = dbUser?.heightCm ?? null; - - const latestByType: Record = {}; - const avg30ByType: Record = {}; - - for (const t of types) { - const latest = latestEverByType.find((row) => row.type === t); - latestByType[t] = latest?.value ?? null; - - const recentOfType = recentMeasurements.filter((m) => m.type === t); - if (recentOfType.length > 0) { - const sum = recentOfType.reduce((acc, m) => acc + m.value, 0); - avg30ByType[t] = Math.round((sum / recentOfType.length) * 10) / 10; - } else { - avg30ByType[t] = null; - } - } - - // Compute trend (compare first half of 30-day data to second half) - function computeTrend( - type: MeasurementType, - ): "up" | "down" | "stable" | null { - const data = recentMeasurements - .filter((m) => m.type === type) - .sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()); - if (data.length < 4) return null; - - const mid = Math.floor(data.length / 2); - const firstHalf = data.slice(0, mid); - const secondHalf = data.slice(mid); - - const avgFirst = - firstHalf.reduce((s, m) => s + m.value, 0) / firstHalf.length; - const avgSecond = - secondHalf.reduce((s, m) => s + m.value, 0) / secondHalf.length; - - const diff = avgSecond - avgFirst; - const threshold = avgFirst * 0.02; // 2% change threshold - - if (diff > threshold) return "up"; - if (diff < -threshold) return "down"; - return "stable"; - } - - // -------------------------------------------------------------------- - // v1.4.25 W3e — consistency helper. Buckets readings by Berlin-tz day - // and classifies each day's mean against the target's green band. - // Single helper shared by every target type so the rule stays stable - // (mean reading per day → in-range / near-range / out-of-range). - // -------------------------------------------------------------------- - const last7DayKeys: string[] = []; - for (let i = 6; i >= 0; i--) { - const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000); - last7DayKeys.push(dayKey(d)); - } - - type DayBand = "in" | "near" | "out"; - - interface ConsistencyOutput { - daysInRange7d: number; - daysLogged7d: number; - daysInRange30d: number; - daysLogged30d: number; - lastMetGoalAt: string | null; - streakDays: number; - insufficientData: boolean; - consistency7d: ReadonlyArray; - } - - const EMPTY_CONSISTENCY: ConsistencyOutput = { - daysInRange7d: 0, - daysLogged7d: 0, - daysInRange30d: 0, - daysLogged30d: 0, - lastMetGoalAt: null, - streakDays: 0, - insufficientData: true, - consistency7d: [null, null, null, null, null, null, null] as const, - }; - - /** - * Bucket a series of `{ measuredAt, value }` events by Berlin-tz day, - * classify each day's mean against the target band, and roll up the - * 7-day strip + 30-day stats + recency + streak. - * - * `classify(meanValue)` returns the band the day's mean falls into. - * Pass `null` to mean "no band — skip this day for classification". - */ - function rollupConsistency( - events: ReadonlyArray<{ measuredAt: Date; value: number }>, - classify: (mean: number) => DayBand | null, - totalReadingsThreshold = 3, - ): ConsistencyOutput { - if (events.length < totalReadingsThreshold) { - // Cold-start path. Still emit the per-day strip if we have any - // recent readings, but mark insufficient so the UI hides the %. - const partial = computeStrip(events, classify); - return { - ...EMPTY_CONSISTENCY, - consistency7d: partial.consistency7d, - daysLogged7d: partial.daysLogged7d, - daysInRange7d: partial.daysInRange7d, - insufficientData: true, - }; - } - - const byDay = new Map(); - for (const ev of events) { - const day = dayKey(ev.measuredAt); - const bucket = byDay.get(day) ?? { sum: 0, count: 0 }; - bucket.sum += ev.value; - bucket.count += 1; - byDay.set(day, bucket); - } - - const stripPart = computeStrip(events, classify); - - // 30-day stats: walk the last 30 Berlin-tz day keys. - let daysInRange30d = 0; - let daysLogged30d = 0; - for (let i = 29; i >= 0; i--) { - const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000); - const key = dayKey(d); - const bucket = byDay.get(key); - if (!bucket) continue; - daysLogged30d += 1; - const mean = bucket.sum / bucket.count; - if (classify(mean) === "in") daysInRange30d += 1; - } - - // lastMetGoalAt: walk back from today until a day is in range. - let lastMetGoalAt: string | null = null; - for (let i = 0; i < 30; i++) { - const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000); - const key = dayKey(d); - const bucket = byDay.get(key); - if (!bucket) continue; - const mean = bucket.sum / bucket.count; - if (classify(mean) === "in") { - lastMetGoalAt = key; - break; - } - } - - // streakDays: consecutive days ending today that are in-range. A - // day with no reading breaks the streak (the user did not meet the - // goal that day, even if today is in-range). - let streakDays = 0; - for (let i = 0; i < 365; i++) { - const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000); - const key = dayKey(d); - const bucket = byDay.get(key); - if (!bucket) break; - const mean = bucket.sum / bucket.count; - if (classify(mean) !== "in") break; - streakDays += 1; - } - - // insufficientData: < 3 readings total OR < 1 day in the last 7. - const insufficientData = - events.length < totalReadingsThreshold || stripPart.daysLogged7d < 1; - - return { - daysInRange7d: stripPart.daysInRange7d, - daysLogged7d: stripPart.daysLogged7d, - daysInRange30d, - daysLogged30d, - lastMetGoalAt, - streakDays, - insufficientData, - consistency7d: stripPart.consistency7d, - }; - } - - function computeStrip( - events: ReadonlyArray<{ measuredAt: Date; value: number }>, - classify: (mean: number) => DayBand | null, - ): { - consistency7d: ReadonlyArray; - daysInRange7d: number; - daysLogged7d: number; - } { - const byDay = new Map(); - for (const ev of events) { - const day = dayKey(ev.measuredAt); - const bucket = byDay.get(day) ?? { sum: 0, count: 0 }; - bucket.sum += ev.value; - bucket.count += 1; - byDay.set(day, bucket); - } - const strip: Array = []; - let daysInRange7d = 0; - let daysLogged7d = 0; - for (const key of last7DayKeys) { - const bucket = byDay.get(key); - if (!bucket) { - strip.push(null); - continue; - } - daysLogged7d += 1; - const mean = bucket.sum / bucket.count; - const band = classify(mean); - strip.push(band); - if (band === "in") daysInRange7d += 1; - } - return { consistency7d: strip, daysInRange7d, daysLogged7d }; - } - - /** - * Helper: classify a value against a target range with an orange band - * symmetrical to the green band (the same heuristic RangeBar uses - * client-side). When `min`/`max` is null we treat the input as - * unclassifiable and return null. - */ - function makeRangeClassifier( - range: { min: number; max: number } | null, - options?: { orangeMin?: number; orangeMax?: number }, - ): (mean: number) => DayBand | null { - if (!range) return () => null; - const span = range.max - range.min; - const orangeMin = options?.orangeMin ?? range.min - span * 0.3; - const orangeMax = options?.orangeMax ?? range.max + span * 0.3; - return (mean: number) => { - if (mean >= range.min && mean <= range.max) return "in"; - if (mean >= orangeMin && mean <= orangeMax) return "near"; - return "out"; - }; - } - - /** - * Per-day BP-in-target classifier: a day is "in" if every BP pair on - * that day (sys+dia matched within 5 min) passes `isBpReadingInTarget`. - * "near" if at least one pair passes; "out" otherwise. - * - * Returns a function (day-key, dayEvents) → band. We pre-compute the - * by-day map once because the route already paginates BP twice (sys - * + dia) and we want to avoid quadratic work. - */ - function buildBpPairsByDay(): Map { - if (!bpRange) return new Map(); - const sysByDay = new Map>(); - const diaByDay = new Map>(); - for (const m of recentMeasurements) { - if (m.type === "BLOOD_PRESSURE_SYS") { - const k = dayKey(m.measuredAt); - const arr = sysByDay.get(k) ?? []; - arr.push({ date: m.measuredAt, value: m.value }); - sysByDay.set(k, arr); - } else if (m.type === "BLOOD_PRESSURE_DIA") { - const k = dayKey(m.measuredAt); - const arr = diaByDay.get(k) ?? []; - arr.push({ date: m.measuredAt, value: m.value }); - diaByDay.set(k, arr); - } - } - const out = new Map(); - const allKeys = new Set([...sysByDay.keys(), ...diaByDay.keys()]); - for (const key of allKeys) { - const sys = sysByDay.get(key) ?? []; - const dia = diaByDay.get(key) ?? []; - const pairs = pairByTimestamp(sys, dia, 5 * 60 * 1000); - if (pairs.length === 0) { - out.set(key, null); - continue; - } - const inCount = pairs.filter((p) => - isBpReadingInTarget(p.a, p.b, bpRange), - ).length; - if (inCount === pairs.length) out.set(key, "in"); - else if (inCount > 0) out.set(key, "near"); - else out.set(key, "out"); - } - return out; - } - - // We need bpRange before the per-target loop runs so the helpers above - // can use it; reorder the local so it's available. - const bpRange = age != null ? getBpTargetsByAge(age, gender) : null; - - /** - * Roll up consistency for a target whose bands come from a pre-mapped - * `dayBandByKey` map (used for BP-in-target and medication compliance - * which have day-level classification rather than mean-classification). - */ - function rollupFromDayMap( - dayBandByKey: Map, - totalDaysThreshold = 3, - ): ConsistencyOutput { - const loggedKeys = Array.from(dayBandByKey.entries()).filter( - ([, v]) => v !== null, - ); - if (loggedKeys.length < totalDaysThreshold) { - // Cold-start: still emit the strip. - const strip: Array = last7DayKeys.map( - (k) => dayBandByKey.get(k) ?? null, - ); - const daysLogged7d = strip.filter((b) => b !== null).length; - const daysInRange7d = strip.filter((b) => b === "in").length; - return { - ...EMPTY_CONSISTENCY, - consistency7d: strip, - daysLogged7d, - daysInRange7d, - insufficientData: true, - }; - } - - const strip: Array = last7DayKeys.map( - (k) => dayBandByKey.get(k) ?? null, - ); - const daysLogged7d = strip.filter((b) => b !== null).length; - const daysInRange7d = strip.filter((b) => b === "in").length; - - let daysInRange30d = 0; - let daysLogged30d = 0; - for (let i = 29; i >= 0; i--) { - const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000); - const key = dayKey(d); - const band = dayBandByKey.get(key); - if (!band) continue; - daysLogged30d += 1; - if (band === "in") daysInRange30d += 1; - } - - let lastMetGoalAt: string | null = null; - for (let i = 0; i < 30; i++) { - const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000); - const key = dayKey(d); - if (dayBandByKey.get(key) === "in") { - lastMetGoalAt = key; - break; - } - } - - let streakDays = 0; - for (let i = 0; i < 365; i++) { - const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000); - const key = dayKey(d); - const band = dayBandByKey.get(key); - if (band === undefined || band === null) break; - if (band !== "in") break; - streakDays += 1; - } - - return { - daysInRange7d, - daysLogged7d, - daysInRange30d, - daysLogged30d, - lastMetGoalAt, - streakDays, - insufficientData: loggedKeys.length < totalDaysThreshold, - consistency7d: strip, - }; - } - - // Memo: pre-compute the BP-pairs day map once. - const bpPairsByDay = buildBpPairsByDay(); - - // Build target items - const targets: TargetItem[] = []; - - // 1. Weight - const weightRange = heightCm ? getWeightRange(heightCm) : null; - let weightClassification: { category: string; color: string } | null = null; - if (latestByType.WEIGHT != null && heightCm) { - const heightM = heightCm / 100; - const bmi = latestByType.WEIGHT / (heightM * heightM); - const cls = classifyBMI(bmi); - weightClassification = { category: cls.category, color: cls.color }; - } - { - const consistency = rollupConsistency( - recentMeasurements.filter((m) => m.type === "WEIGHT"), - makeRangeClassifier(weightRange), - ); - targets.push({ - type: "WEIGHT", - label: "Weight", - current: latestByType.WEIGHT ?? null, - average30: avg30ByType.WEIGHT ?? null, - trend: computeTrend("WEIGHT"), - unit: "kg", - range: weightRange, - classification: weightClassification, - source: "WHO BMI", - ...consistency, - }); - } - - // 2. Blood Pressure (sys/dia combined) - // `bpRange` is hoisted to the helpers block above so the BP-pairs - // day-map can use it; do not redeclare here. - let bpClassification: { category: string; color: string } | null = null; - if ( - latestByType.BLOOD_PRESSURE_SYS != null && - latestByType.BLOOD_PRESSURE_DIA != null - ) { - const cls = classifyBP( - latestByType.BLOOD_PRESSURE_SYS, - latestByType.BLOOD_PRESSURE_DIA, - ); - bpClassification = { category: cls.category, color: cls.color }; - } - { - // Per-day BP classifier reuses the sys+dia paired classifier from - // `bpPairsByDay`. This is the right semantic for "in target band" - // because a systolic in range with a diastolic out of range is - // still an out-of-target reading. - const consistency = rollupFromDayMap(bpPairsByDay); - targets.push({ - type: "BLOOD_PRESSURE", - label: "Blood pressure", - current: latestByType.BLOOD_PRESSURE_SYS ?? null, - average30: avg30ByType.BLOOD_PRESSURE_SYS ?? null, - trend: computeTrend("BLOOD_PRESSURE_SYS"), - unit: "mmHg", - range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null, - classification: bpClassification, - source: "ESH 2023", - ...consistency, - // Extra fields for diastolic - } as TargetItem); - } - - // 2b. Blood pressure target-hit rate over 30 days - if (bpRange) { - const sysPoints = recentMeasurements - .filter((measurement) => measurement.type === "BLOOD_PRESSURE_SYS") - .map((measurement) => ({ - date: measurement.measuredAt, - value: measurement.value, - })); - const diaPoints = recentMeasurements - .filter((measurement) => measurement.type === "BLOOD_PRESSURE_DIA") - .map((measurement) => ({ - date: measurement.measuredAt, - value: measurement.value, - })); - - const bpPairs = pairByTimestamp(sysPoints, diaPoints, 5 * 60 * 1000); - // v1.4.16 A2 — one-sided ceiling semantics with hypotension - // floor. See lib/analytics/bp-in-target.ts. - const bpInTargetCount = bpPairs.filter((pair) => - isBpReadingInTarget(pair.a, pair.b, bpRange), - ).length; - const bpInTargetRate = - bpPairs.length > 0 - ? Math.round((bpInTargetCount / bpPairs.length) * 100 * 10) / 10 - : null; - - // BP-in-target consistency mirrors the BP pair-by-day map above: - // a day is "in" when every pair on that day clears the BP target. - const inTargetConsistency = rollupFromDayMap(bpPairsByDay); - targets.push({ - type: "BLOOD_PRESSURE_IN_TARGET", - label: "Blood pressure on target", - current: bpInTargetRate, - average30: bpInTargetRate, - trend: null, - unit: "%", - range: { min: 70, max: 100 }, - classification: - bpInTargetRate != null - ? bpInTargetRate >= 70 - ? { category: "Good", color: "#50fa7b" } - : bpInTargetRate >= 40 - ? { category: "Moderate", color: "#f1fa8c" } - : { category: "Low", color: "#ff5555" } - : null, - source: "ESH 2023", - ...inTargetConsistency, - }); - } - - // 3. Resting pulse - // - // v1.15.12 A2 — judge the RESTING band against a RESTING series, not - // the raw PULSE stream (which Apple Health fills with workout HR). We - // prefer RESTING_HEART_RATE; when the user has none we fall back to a - // low-percentile-of-daily-PULSE proxy. The resting series drives the - // tile's current / average / consistency / classification so a workout - // burst no longer reads "outside target". - const pulseTarget = getPersonalizedPulseTarget(age, gender); - { - const restingResolved = resolveRestingPulseSeries({ - restingSamples: recentMeasurements - .filter((m) => m.type === "RESTING_HEART_RATE") - .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), - pulseSamples: recentMeasurements - .filter((m) => m.type === "PULSE") - .map((m) => ({ measuredAt: m.measuredAt, value: m.value })), - dayKeyOf: dayKey, - }); - const restingEvents = restingResolved.series; - // Current = most recent resting value; average30 = resting mean. - const restingCurrent = - restingEvents.length > 0 - ? restingEvents[restingEvents.length - 1].value - : null; - const restingAvg30 = - restingEvents.length > 0 - ? Math.round( - (restingEvents.reduce((s, e) => s + e.value, 0) / - restingEvents.length) * - 10, - ) / 10 - : null; - - let pulseClassification: { category: string; color: string } | null = null; - if (restingCurrent != null) { - const cls = classifyPulseByTarget(restingCurrent, pulseTarget); - pulseClassification = { category: cls.category, color: cls.color }; - } - - const pulseRange = { - min: pulseTarget.greenMin, - max: pulseTarget.greenMax, - }; - const consistency = rollupConsistency( - restingEvents, - makeRangeClassifier(pulseRange, { - orangeMin: pulseTarget.orangeMin, - orangeMax: pulseTarget.orangeMax, - }), - ); - // Trend over the resting series (first-half vs second-half mean). - let restingTrend: "up" | "down" | "stable" | null = null; - if (restingEvents.length >= 4) { - const sorted = [...restingEvents].sort( - (a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(), - ); - const mid = Math.floor(sorted.length / 2); - const avgFirst = - sorted.slice(0, mid).reduce((s, e) => s + e.value, 0) / mid; - const avgSecond = - sorted.slice(mid).reduce((s, e) => s + e.value, 0) / - (sorted.length - mid); - const diff = avgSecond - avgFirst; - const threshold = avgFirst * 0.02; - restingTrend = - diff > threshold ? "up" : diff < -threshold ? "down" : "stable"; - } - - targets.push({ - type: "PULSE", - label: "Resting pulse", - current: restingCurrent, - average30: restingAvg30, - trend: restingTrend, - unit: "bpm", - range: pulseRange, - classification: pulseClassification, - source: - restingResolved.which === "proxy" - ? `${pulseTarget.source} (estimated from heart rate)` - : pulseTarget.source, - ...consistency, - }); - } - - // 4. Sleep Duration - // - // v1.11.5 (deferred v1.11.4 QA L3) — SLEEP_DURATION is stored one row per - // STAGE per night in MINUTES. The generic per-type path treated every stage - // row as an independent reading, summing IN_BED + AWAKE + the bare ASLEEP - // aggregate + the granular CORE/DEEP/REM rows over 30 days and dividing by - // the row count — and then fed those MINUTES straight into a target range - // expressed in HOURS. The result was a bogus ~16 h/day average. Reconstruct - // per-night TIME-ASLEEP through the shared helper (which dedups dual-source - // nights, excludes IN_BED/AWAKE, and counts only the granular partition when - // present), convert to HOURS, then classify and roll up consistency on the - // per-night hour totals so this surface matches the dashboard / series / - // overview headline. - const sleepRange = getSleepDurationRange(); - { - // `sleepStageRows` resolved in the parallel read at the top. - const nights = reconstructSleepNights( - sleepStageRows as SleepStageRow[], - userTz, - dbUser?.sourcePriorityJson ?? null, - ) - .filter((n) => n.asleepMinutes > 0) - .sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()); - - const round1 = (n: number): number => Math.round(n * 100) / 100; - // One event per night carrying the night's asleep HOURS — the right unit - // for the 7–9 h target band and for the per-day consistency bucketing. - const sleepEvents = nights.map((n) => ({ - measuredAt: n.measuredAt, - value: round1(n.asleepMinutes / 60), - })); - - const latestNight = nights.length > 0 ? nights[nights.length - 1] : null; - const currentSleepHours = latestNight - ? round1(latestNight.asleepMinutes / 60) - : null; - const avgSleepHours = - sleepEvents.length > 0 - ? round1( - sleepEvents.reduce((s, e) => s + e.value, 0) / sleepEvents.length, - ) - : null; - - let sleepClassification: { category: string; color: string } | null = null; - if (currentSleepHours != null) { - const cls = classifySleepDuration(currentSleepHours); - sleepClassification = { category: cls.category, color: cls.color }; - } - - // Trend over the per-night hour series (first vs second half), mirroring - // `computeTrend` but on reconstructed nights rather than raw stage rows. - let sleepTrend: "up" | "down" | "stable" | null = null; - if (sleepEvents.length >= 4) { - const mid = Math.floor(sleepEvents.length / 2); - const firstHalf = sleepEvents.slice(0, mid); - const secondHalf = sleepEvents.slice(mid); - const avgFirst = - firstHalf.reduce((s, e) => s + e.value, 0) / firstHalf.length; - const avgSecond = - secondHalf.reduce((s, e) => s + e.value, 0) / secondHalf.length; - const diff = avgSecond - avgFirst; - const threshold = avgFirst * 0.02; - if (diff > threshold) sleepTrend = "up"; - else if (diff < -threshold) sleepTrend = "down"; - else sleepTrend = "stable"; - } - - const consistency = rollupConsistency( - sleepEvents, - makeRangeClassifier(sleepRange), - ); - targets.push({ - type: "SLEEP_DURATION", - label: "Sleep duration", - current: currentSleepHours, - average30: avgSleepHours, - trend: sleepTrend, - unit: "h", - range: sleepRange, - classification: sleepClassification, - source: "AASM/SRS", - ...consistency, - }); - } - - // 5. BMI (derived from weight + height) - if (heightCm) { - const heightM = heightCm / 100; - const heightSq = heightM * heightM; - const latestWeight = latestByType.WEIGHT ?? null; - const currentBmi = - latestWeight != null - ? Math.round((latestWeight / heightSq) * 10) / 10 - : null; - - // Compute 30-day average BMI from average weight - const avgWeight = avg30ByType.WEIGHT ?? null; - const avgBmi = - avgWeight != null ? Math.round((avgWeight / heightSq) * 10) / 10 : null; - - let bmiClassification: { category: string; color: string } | null = null; - if (currentBmi != null) { - const cls = classifyBMI(currentBmi); - bmiClassification = { category: cls.category, color: cls.color }; - } - - // BMI consistency derives from the weight series: divide each - // bucket by height\u00B2 so the day's mean BMI is classified against - // the BMI range bar (18.5 \u2014 24.9). - const bmiRange = { min: 18.5, max: 24.9 }; - const bmiEvents = recentMeasurements - .filter((m) => m.type === "WEIGHT") - .map((m) => ({ measuredAt: m.measuredAt, value: m.value / heightSq })); - const consistency = rollupConsistency( - bmiEvents, - makeRangeClassifier(bmiRange), - ); - targets.push({ - type: "BMI", - label: "BMI", - current: currentBmi, - average30: avgBmi, - trend: computeTrend("WEIGHT"), // BMI trend follows weight trend - unit: "kg/m\u00B2", - range: bmiRange, - classification: bmiClassification, - source: "WHO", - ...consistency, - }); - } - - // 6. Body Fat - let bodyFatClassification: { category: string; color: string } | null = null; - if (latestByType.BODY_FAT != null && age != null) { - const cls = classifyBodyFat(latestByType.BODY_FAT, gender, age); - bodyFatClassification = { category: cls.category, color: cls.color }; - } - // Single source of truth for the body-fat green band lives in - // `value-bands.ts`. v1.3.3 had three different hardcoded ranges across - // value-bands.ts / targets/route.ts / classifications.ts; the v1.4 - // marathon consolidated them onto `getBodyFatTargetRange` (ACE - // fitness + acceptable bands). - const bodyFatRange = age != null ? getBodyFatTargetRange(gender) : null; - { - const consistency = rollupConsistency( - recentMeasurements.filter((m) => m.type === "BODY_FAT"), - makeRangeClassifier(bodyFatRange), - ); - targets.push({ - type: "BODY_FAT", - label: "Body fat", - current: latestByType.BODY_FAT ?? null, - average30: avg30ByType.BODY_FAT ?? null, - trend: computeTrend("BODY_FAT"), - unit: "%", - range: bodyFatRange, - classification: bodyFatClassification, - source: "ACE", - ...consistency, - }); - } - - // 7. Activity Steps - const stepsRange = getStepsRange(); - let stepsClassification: { category: string; color: string } | null = null; - if (avg30ByType.ACTIVITY_STEPS != null) { - const cls = classifySteps(avg30ByType.ACTIVITY_STEPS); - stepsClassification = { category: cls.category, color: cls.color }; - } - { - const consistency = rollupConsistency( - recentMeasurements.filter((m) => m.type === "ACTIVITY_STEPS"), - makeRangeClassifier(stepsRange), - ); - targets.push({ - type: "ACTIVITY_STEPS", - label: "Steps/day", - current: latestByType.ACTIVITY_STEPS ?? null, - average30: avg30ByType.ACTIVITY_STEPS ?? null, - trend: computeTrend("ACTIVITY_STEPS"), - unit: "steps", - range: stepsRange, - classification: stepsClassification, - // WHO publishes activity *time* (150–300 min/wk moderate), - // not a step quota. The closest peer-reviewed dose-response - // for the 8 000–15 000 band is Saint-Maurice JAMA 2020. The - // AI prompts at src/lib/ai/prompts/base-system.ts and - // src/lib/ai/prompts/general-status.ts already enforce this - // attribution; this surface label was the last "WHO" mislabel - // in the codebase. - source: "Saint-Maurice JAMA 2020", - ...consistency, - }); - } - - // 8. Medication Compliance (average across active medications) — - // `activeMedications` + their 30-day `intakeEvents` resolved in the - // parallel read at the top. - const { activeMedications, intakeEvents } = medicationBundle; - - if (activeMedications.length > 0) { - const medicationStats = activeMedications.map((medication) => { - const medicationEvents = intakeEvents.filter( - (event) => event.medicationId === medication.id, - ); - // v1.7.0 SB-SCHED-2 — engine-routed denominator (userTz resolved - // above at the route entry). - const medicationContext = buildComplianceMedicationContext( - medication, - lastNonSkippedTakenAt(medicationEvents), - userTz, - ); - const compliance7 = calculateCompliance( - medicationEvents, - medication.schedules, - 7, - medication.createdAt, - { medicationContext }, - ); - const compliance30 = calculateCompliance( - medicationEvents, - medication.schedules, - 30, - medication.createdAt, - { medicationContext }, - ); - - return { - name: medication.name, - compliance7: compliance7.rate, - compliance30: compliance30.rate, - totalExpected7: compliance7.totalExpected, - taken7: compliance7.taken, - totalExpected30: compliance30.totalExpected, - taken30: compliance30.taken, - }; - }); - - const totalExpected7 = medicationStats.reduce( - (sum, medication) => sum + medication.totalExpected7, - 0, - ); - const totalTaken7 = medicationStats.reduce( - (sum, medication) => sum + medication.taken7, - 0, - ); - const totalExpected30 = medicationStats.reduce( - (sum, medication) => sum + medication.totalExpected30, - 0, - ); - const totalTaken30 = medicationStats.reduce( - (sum, medication) => sum + medication.taken30, - 0, - ); - - const complianceRate7 = - totalExpected7 > 0 - ? Math.round( - (Math.min(1, totalTaken7 / totalExpected7) * 100 + Number.EPSILON) * - 10, - ) / 10 - : null; - const complianceRate30 = - totalExpected30 > 0 - ? Math.round( - (Math.min(1, totalTaken30 / totalExpected30) * 100 + - Number.EPSILON) * - 10, - ) / 10 - : complianceRate7; - - // Per-day compliance: for each Berlin-tz day, classify as "in" if - // every scheduled dose that day was taken; "near" if at least one - // was taken; "out" if all were skipped/missed; null if there were - // no scheduled doses (the user's regimen excluded that day). - const dayCountsByKey = new Map< - string, - { taken: number; expected: number } - >(); - for (const event of intakeEvents) { - const key = dayKey(event.scheduledFor); - const cur = dayCountsByKey.get(key) ?? { taken: 0, expected: 0 }; - cur.expected += 1; - if (event.takenAt && !event.skipped) cur.taken += 1; - dayCountsByKey.set(key, cur); - } - const dayBandByKey = new Map(); - for (const [key, counts] of dayCountsByKey.entries()) { - if (counts.expected === 0) { - dayBandByKey.set(key, null); - continue; - } - const ratio = counts.taken / counts.expected; - if (ratio >= 0.99) dayBandByKey.set(key, "in"); - else if (ratio >= 0.5) dayBandByKey.set(key, "near"); - else dayBandByKey.set(key, "out"); - } - const consistency = rollupFromDayMap(dayBandByKey); - targets.push({ - type: "MEDICATION_COMPLIANCE", - label: "Medication compliance", - current: complianceRate7, - average30: complianceRate30, - trend: null, - unit: "%", - range: { min: 90, max: 100 }, - classification: - complianceRate7 != null - ? complianceRate7 >= 90 - ? { category: "Very good", color: "#50fa7b" } - : complianceRate7 >= 70 - ? { category: "Good", color: "#f1fa8c" } - : { category: "Low", color: "#ff5555" } - : null, - source: "7-day", - details: { - medications: medicationStats.map((medication) => ({ - name: medication.name, - compliance7: medication.compliance7, - compliance30: medication.compliance30, - })), - }, - ...consistency, - }); - } - - // 9. Mood targets (if mood data exists) - // - // v1.4.40 \u2014 swap the unbounded `prisma.moodEntry.findMany({ where: { - // userId } })` for the persistent mood-rollup DAY tier (audit Critical - // Finding #2). The tile reads: - // - per-day mean over the trailing 30 days (avg30, trend, stability) - // - the consistency strip (in/near/out band per day) - // - the latest score for the "current" reading + classification - // - // Rollup row = `{bucketStart, mean, count, minScore, maxScore}` per - // calendar day. Per-day mean replaces the per-entry score series the - // legacy path passed to `rollupConsistency` \u2014 the helper buckets by - // day-key anyway, so feeding one row per day with the pre-computed - // mean is mathematically identical to feeding N raw entries that - // collapse to the same mean. - // - // The "latest score" needs a single raw entry (the newest one), so we - // pull it with `take: 1` \u2014 bounded by the index, never an unbounded - // scan. The "\u2265 3 entries threshold" gate uses the rollup count sum so - // the cold-start branch stays consistent with the legacy semantics - // (which counted raw entries, not raw days). - // - // Coverage-fallback: when the user has mood entries but zero rollup - // rows yet (legacy account before the boot-time backfill caught up) - // we run the legacy live walk once and fire the warm-up so the next - // request lands on the rollup tier. Same posture as - // `/api/mood/analytics`. The warm-up trigger + the `moodRollups` / - // `latestMoodEntry` reads moved into the parallel round at the top. - const thirtyDaysAgoMood = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - - // Build a per-day event list for the consistency strip + 30-day stats. - // Use the rollup tier when populated; fall back to a bounded raw walk - // when the user has mood entries but zero rollup coverage yet. - let moodDailyEvents: Array<{ measuredAt: Date; value: number }>; - let moodEntryCount: number; - let moodSumScores: number; - let moodVarianceN: number; - let moodSumSquares: number; - if (moodRollups.length > 0) { - moodEntryCount = moodRollups.reduce((s, r) => s + r.count, 0); - moodDailyEvents = moodRollups - .filter((r) => r.bucketStart >= thirtyDaysAgoMood) - .map((r) => ({ measuredAt: r.bucketStart, value: r.mean })); - moodSumScores = moodDailyEvents.reduce((s, e) => s + e.value, 0); - moodVarianceN = moodDailyEvents.length; - moodSumSquares = moodDailyEvents.reduce((s, e) => s + e.value * e.value, 0); - } else if (latestMoodEntry !== null) { - // Coverage-fallback: rollups empty but raw entries exist. Pull the - // trailing 30-day window only (instead of every entry ever) \u2014 that's - // the bound the legacy code was missing. - const recentRawMood = await prisma.moodEntry.findMany({ - // v1.7.0 sync — exclude tombstoned rows. - where: { - userId, - deletedAt: null, - moodLoggedAt: { gte: thirtyDaysAgoMood }, - }, - orderBy: { moodLoggedAt: "asc" }, - select: { score: true, moodLoggedAt: true }, - }); - moodEntryCount = recentRawMood.length; - moodDailyEvents = recentRawMood.map((e) => ({ - measuredAt: e.moodLoggedAt, - value: e.score, - })); - moodSumScores = recentRawMood.reduce((s, e) => s + e.score, 0); - moodVarianceN = recentRawMood.length; - moodSumSquares = recentRawMood.reduce((s, e) => s + e.score * e.score, 0); - } else { - moodEntryCount = 0; - moodDailyEvents = []; - moodSumScores = 0; - moodVarianceN = 0; - moodSumSquares = 0; - } - - if (moodEntryCount >= 3) { - const latestMoodScore = latestMoodEntry?.score ?? null; - - const moodAvg30 = - moodDailyEvents.length > 0 - ? Math.round((moodSumScores / moodDailyEvents.length) * 10) / 10 - : null; - - // Mood trend: compare first half to second half of last 30 days. - // The rollup tier carries one DataPoint per day (the daily mean), - // matching the legacy comparison semantics on single-entry days. - let moodTrend: "up" | "down" | "stable" | null = null; - if (moodDailyEvents.length >= 4) { - const sorted = [...moodDailyEvents].sort( - (a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(), - ); - const mid = Math.floor(sorted.length / 2); - const firstHalf = sorted.slice(0, mid); - const secondHalf = sorted.slice(mid); - const avgFirst = - firstHalf.reduce((s, e) => s + e.value, 0) / firstHalf.length; - const avgSecond = - secondHalf.reduce((s, e) => s + e.value, 0) / secondHalf.length; - const diff = avgSecond - avgFirst; - if (diff > 0.2) moodTrend = "up"; - else if (diff < -0.2) moodTrend = "down"; - else moodTrend = "stable"; - } - - const moodClassification = - latestMoodScore != null - ? latestMoodScore >= 3.5 - ? { category: "Good", color: "#50fa7b" } - : latestMoodScore >= 2 - ? { category: "Moderate", color: "#f1fa8c" } - : { category: "Low", color: "#ff5555" } - : null; - - const moodRange = { min: 3.5, max: 5 }; - const moodConsistency = rollupConsistency( - moodDailyEvents, - makeRangeClassifier(moodRange, { orangeMin: 2, orangeMax: 5 }), - ); - targets.push({ - type: "MOOD_SCORE", - label: "Mood", - current: latestMoodScore, - average30: moodAvg30, - trend: moodTrend, - unit: "/ 5", - range: moodRange, - classification: moodClassification, - source: "moodLog", - ...moodConsistency, - }); - - // Mood stability: standard deviation of the recent series. The - // rollup tier surfaces one mean per day, which is the right - // resolution for "is the user's mood stable across days" \u2014 a power - // user logging multiple times per day with a wide intraday spread - // still gets a "stable" badge if the daily mean barely moves. The - // legacy path conflated intraday variance with day-to-day stability - // by passing per-entry scores; the rollup-tier semantic is closer - // to what the UI label suggests. - if (moodVarianceN >= 5) { - const seriesMean = moodSumScores / moodVarianceN; - const variance = moodSumSquares / moodVarianceN - seriesMean * seriesMean; - const stdDev = Math.round(Math.sqrt(Math.max(0, variance)) * 100) / 100; - - const stabilityClassification = - stdDev <= 0.5 - ? { category: "Very stable", color: "#50fa7b" } - : stdDev <= 1.0 - ? { category: "Stable", color: "#f1fa8c" } - : { category: "Fluctuating", color: "#ff5555" }; - - // Mood stability is computed from the same recent-mood window; - // consistency mirrors MOOD_SCORE so the strip aligns with the - // user's logging cadence rather than introducing a synthetic - // "\u03C3 in range per day" rule that would be hard to reason about. - targets.push({ - type: "MOOD_STABILITY", - label: "Mood stability", - current: stdDev, - average30: stdDev, - trend: null, - unit: "\u03C3", - range: { min: 0, max: 0.5 }, - classification: stabilityClassification, - source: "moodLog", - ...moodConsistency, - }); - } - } - - // 10. Blood glucose — one card per logged context. - const glucoseContexts: GlucoseContext[] = [ - "FASTING", - "POSTPRANDIAL", - "RANDOM", - "BEDTIME", - ]; - const glucoseUnit = resolveGlucoseUnit(dbUser?.glucoseUnit ?? null); - const overrides = (dbUser?.thresholdsJson ?? - null) as ThresholdOverridesJson | null; - const profileForRange = { - heightCm, - dateOfBirth: dbUser?.dateOfBirth ?? null, - gender: dbUser?.gender ?? null, - }; - // `glucoseRows` resolved (one-year floor) in the parallel read at the top. - const thirtyDaysAgoGlucose = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - - const labelKeyByContext: Record = { - FASTING: "targets.glucoseFasting", - POSTPRANDIAL: "targets.glucosePostprandial", - RANDOM: "targets.glucoseRandom", - BEDTIME: "targets.glucoseBedtime", - }; - - for (const ctx of glucoseContexts) { - const ctxRows = glucoseRows.filter((r) => r.glucoseContext === ctx); - if (ctxRows.length === 0) continue; - const latest = ctxRows[0].value; - const recent = ctxRows.filter((r) => r.measuredAt >= thirtyDaysAgoGlucose); - const avg30 = - recent.length > 0 - ? Math.round( - (recent.reduce((s, r) => s + r.value, 0) / recent.length) * 10, - ) / 10 - : null; - - const metric = thresholdMetricForContext(ctx); - const eff = getEffectiveRange(metric, profileForRange, overrides); - const range = eff.range - ? { min: eff.range.greenMin, max: eff.range.greenMax } - : null; - - let classification: { category: string; color: string } | null = null; - if (range) { - if (latest >= range.min && latest <= range.max) { - classification = { category: "Optimal", color: "#50fa7b" }; - } else if ( - eff.range && - latest >= eff.range.orangeMin && - latest <= eff.range.orangeMax - ) { - classification = { category: "Elevated", color: "#f1fa8c" }; - } else { - classification = { category: "High", color: "#ff5555" }; - } - } - - const consistency = rollupConsistency( - recent.map((r) => ({ measuredAt: r.measuredAt, value: r.value })), - makeRangeClassifier( - range, - eff.range - ? { orangeMin: eff.range.orangeMin, orangeMax: eff.range.orangeMax } - : undefined, - ), - ); - targets.push({ - type: `BLOOD_GLUCOSE_${ctx}`, - label: labelKeyByContext[ctx], - current: latest, - average30: avg30, - trend: null, - unit: glucoseUnit, - range, - classification, - source: eff.isOverride ? "Custom" : "ADA 2024 / DDG", - ...consistency, - }); - } - - // -------------------------------------------------------------------- - // v1.4.25 W3e — page summary. "X of Y targets met this week" is the - // single most-asked-about glanceable answer. We define "met this - // week" as the target having `daysInRange7d >= 4` AND not flagged as - // `insufficientData`. The streak highlight surfaces the metric with - // the longest current ≥ 3 day streak; nothing renders client-side - // when no target hits the bar. - // -------------------------------------------------------------------- - const targetsMetThisWeek = targets.filter( - (target) => !target.insufficientData && target.daysInRange7d >= 4, - ).length; - let streakHighlight: TargetPageSummary["streakHighlight"] = null; - for (const target of targets) { - if (target.streakDays < 3) continue; - if (!streakHighlight || target.streakDays > streakHighlight.days) { - streakHighlight = { metric: target.type, days: target.streakDays }; - } - } - const pageSummary: TargetPageSummary = { - targetsMetThisWeek, - totalTargets: targets.length, - streakHighlight, - }; - - annotate({ - action: { name: "insights.targets" }, - meta: { - targetCount: targets.length, - targetsMetThisWeek, - streakHighlightMetric: streakHighlight?.metric ?? null, - }, - }); - - return { - targets, - pageSummary, - // Extra diastolic data for BP display - bpDiastolic: { - current: latestByType.BLOOD_PRESSURE_DIA ?? null, - average30: avg30ByType.BLOOD_PRESSURE_DIA ?? null, - range: bpRange ? { min: bpRange.diaLow, max: bpRange.diaHigh } : null, - }, - profile: { - heightCm, - age, - gender, - glucoseUnit, - }, - }; -} diff --git a/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts b/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts new file mode 100644 index 000000000..78778d154 --- /dev/null +++ b/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts @@ -0,0 +1,133 @@ +/** + * v1.18.6 — explicit-completion route (iOS #23 follow-up). + * + * Covers: marks done via the shared primitive, idempotent no-op surfaces + * completed=false, owner-scoped 404 on a cross-user / tombstoned id, and the + * structural no-double-notify guarantee (the route delegates to + * `satisfyReminder` and never reaches the notification dispatcher). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +const satisfyReminderMock = vi.fn(); +vi.mock("@/lib/measurement-reminders/satisfy", () => ({ + satisfyReminder: (...args: unknown[]) => satisfyReminderMock(...args), +})); + +const findFirstMock = vi.fn(); +const findUserMock = vi.fn(); +const findUniqueOrThrowMock = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { + measurementReminder: { + findFirst: (...a: unknown[]) => findFirstMock(...a), + findUniqueOrThrow: (...a: unknown[]) => findUniqueOrThrowMock(...a), + }, + user: { findUnique: (...a: unknown[]) => findUserMock(...a) }, + }, +})); + +import { POST } from "../route"; + +const ROW = { + id: "r1", + userId: "u1", + label: "Blutdruck messen", + measurementType: "BLOOD_PRESSURE_SYS", + intervalDays: 7, + rrule: null, + anchorDate: null, + endsOn: null, + origin: "VORSORGE", + notifyHour: 9, + location: null, + nextDueAt: new Date("2026-06-25T07:00:00Z"), + lastSatisfiedAt: null, + enabled: true, + createdAt: new Date("2026-06-01T00:00:00Z"), + updatedAt: new Date("2026-06-18T00:00:00Z"), + deletedAt: null, +}; + +function makeRequest(): NextRequest { + return new NextRequest("http://localhost/api/measurement-reminders/r1/complete", { + method: "POST", + }); +} + +const params = { params: Promise.resolve({ id: "r1" }) }; + +beforeEach(() => { + vi.clearAllMocks(); + findUserMock.mockResolvedValue({ timezone: "Europe/Berlin" }); +}); + +describe("POST /api/measurement-reminders/[id]/complete", () => { + it("marks the reminder done via the shared primitive and returns completed=true", async () => { + findFirstMock.mockResolvedValue(ROW); + satisfyReminderMock.mockResolvedValue({ + satisfied: true, + nextDueAt: new Date("2026-06-25T07:00:00Z"), + }); + findUniqueOrThrowMock.mockResolvedValue({ + ...ROW, + lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"), + }); + + const res = await POST(makeRequest(), params); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.error).toBeNull(); + expect(body.data.completed).toBe(true); + expect(body.data.reminder.id).toBe("r1"); + expect(body.data.reminder.lastSatisfiedAt).toBe("2026-06-18T08:00:00.000Z"); + // The route delegates to the ONE shared satisfaction primitive. + expect(satisfyReminderMock).toHaveBeenCalledTimes(1); + }); + + it("is idempotent: an already-satisfied reminder is a 200 no-op with completed=false", async () => { + findFirstMock.mockResolvedValue({ + ...ROW, + lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"), + }); + // Forward-only guard inside the primitive returns satisfied=false. + satisfyReminderMock.mockResolvedValue({ satisfied: false, nextDueAt: null }); + findUniqueOrThrowMock.mockResolvedValue({ + ...ROW, + lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"), + }); + + const res = await POST(makeRequest(), params); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.completed).toBe(false); + expect(body.data.reminder.id).toBe("r1"); + }); + + it("is owner-scoped: a cross-user reminder 404s and never satisfies", async () => { + findFirstMock.mockResolvedValue({ ...ROW, userId: "someone-else" }); + + const res = await POST(makeRequest(), params); + expect(res.status).toBe(404); + expect(satisfyReminderMock).not.toHaveBeenCalled(); + }); + + it("404s a tombstoned / missing reminder", async () => { + findFirstMock.mockResolvedValue(null); + + const res = await POST(makeRequest(), params); + expect(res.status).toBe(404); + expect(satisfyReminderMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/measurement-reminders/[id]/complete/route.ts b/src/app/api/measurement-reminders/[id]/complete/route.ts new file mode 100644 index 000000000..408bf12c8 --- /dev/null +++ b/src/app/api/measurement-reminders/[id]/complete/route.ts @@ -0,0 +1,81 @@ +/** + * v1.18.6 — explicit "complete" for a Vorsorge reminder (iOS #23 follow-up). + * + * The user-action equivalent of the cron auto-satisfy: the iOS app (and web) + * call this when the user actively marks a measurement reminder done, rather + * than only dismissing the notification locally. It routes through the ONE + * shared `satisfyReminder` primitive — the same code the cron auto-resolve + * and the eventful ingest worker use — so it: + * + * - stamps `lastSatisfiedAt = now` and re-anchors the server-authoritative + * `nextDueAt` past now (one engine, no duplicated reschedule logic); + * - fires NO notification of its own (the primitive only writes the row; + * the dispatcher is never invoked on this path); + * - is idempotent via the primitive's forward-only guard — completing an + * already-completed / auto-satisfied reminder is a no-op that still + * returns 200, with `completed: false` so the client can tell the second + * tap apart from the first. + * + * Distinct from `satisfy` only in that the response surfaces the `completed` + * flag the explicit-action flow benefits from; the underlying effect is the + * shared satisfaction primitive either way. + */ +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 { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; +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(); + // Forward-only: a completion strictly after any prior satisfy advances + // the row; a completion at/behind the last satisfy is a no-op. Either + // way no notification is emitted — the primitive only stamps + reschedules. + const result = await satisfyReminder(prisma, existing, timezone, now); + + const updated = await prisma.measurementReminder.findUniqueOrThrow({ + where: { id }, + }); + + await auditLog("measurementReminder.complete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { reminderId: id, completed: result.satisfied }, + }); + + annotate({ + action: { name: "measurement-reminders.complete" }, + meta: { reminderId: id, completed: result.satisfied }, + }); + + return apiSuccess({ + completed: result.satisfied, + reminder: toMeasurementReminderDto(updated), + }); + }, +); diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 2345089d2..0aac4a53c 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -30,15 +30,13 @@ import { withIdempotency } from "@/lib/idempotency"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; +import { runSafetyFloorCheck } from "@/lib/illness/safety-floor-check"; import { recomputeBucketsForMeasurement, collapseToTypeDayKeys, - recomputeUserRollups, } from "@/lib/rollups/measurement-rollups"; -import { - collapseRollupRowsBySource, - loadUserSourcePriority, -} from "@/lib/rollups/measurement-read"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { readDailySeries } from "@/lib/measurements/daily-series-read"; import { reconstructSleepSessions, pickMainNightAndNaps, @@ -350,186 +348,23 @@ export const GET = apiHandler(async (request: NextRequest) => { // populator hasn't caught up still sees a correct chart on its // first render. if (source === "rollup" && aggregate === "daily" && type && from && to) { + // v1.18.6 — the rollup-daily read (per-source collapse + coverage + // probe + inline fold + live-SQL fallback) moved to the shared + // `readDailySeries` reader so the batched series endpoint + // (`/api/measurements/series-batch`) reads through the SAME path + // instead of duplicating this SQL. Behaviour is unchanged: the rows + // returned here are byte-identical with the pre-extraction branch. const cap = Math.min(limit, BUCKET_CAP.daily); - // v1.11.1 — rollup rows are per source; collapse overlapping sources to the - // ladder-canonical reading per day before the chart consumes them, so a - // dual-source vital paints one line, not two stacked points per day. const priorityJson = await loadUserSourcePriority(user.id); - const rollupRows = collapseRollupRowsBySource( - await prisma.measurementRollup.findMany({ - where: { - userId: user.id, - type: type as MeasurementType, - granularity: "DAY", - bucketStart: { gte: from, lte: to }, - }, - orderBy: { bucketStart: "asc" }, - // Fetch beyond the bucket cap pre-collapse — N sources per day can - // exceed `cap` rows before the per-day collapse reduces them. - select: { - type: true, - source: true, - bucketStart: true, - mean: true, - count: true, - // v1.4.39 W-SUM — the writer now populates `sum_value` on - // every fold. The cumulative metric path consumes the column - // directly instead of reconstructing `mean * count`; the - // legacy fallback below covers the boot-backfill convergence - // window when an existing bucket pre-dates v1.4.39. - sumValue: true, - minValue: true, - maxValue: true, - }, - }), - type as MeasurementType, + const measurements = await readDailySeries({ + userId: user.id, + type: type as MeasurementType, + from, + to, + limit, priorityJson, - ).slice(0, cap); - // v1.4.39.2 — coverage-mismatch fallback. v1.4.39.1 wired the - // rollup write hook into the previously-bypassed Withings sync, - // /api/import, and admin-restore paths, and widened the boot-time - // backfill discovery to per-(user, type, day). Together those - // changes ensure new writes and post-deploy restarts converge the - // rollup tier for every measurement type the user has logged. They - // do NOT, however, guarantee convergence inside a single request - // when the boot backfill has not yet finished folding the user's - // historic rows: a chart query lands first, the rollup table still - // carries 2 rows for a 30-day BP_SYS window, and the route's pre- - // v1.4.39.2 "rollup wins on length > 0" short-circuit returns the - // sparse rollup. The chart trips its `< 3 daily points` empty-state - // even though `measurements` holds the full 30 days. - // - // This branch closes that gap. When the rollup row count is - // suspiciously low for the requested window (≥ 7 days asked and - // < 3 rollup rows present), we probe `measurements` for the actual - // distinct-day count over the same window. If `measurements` - // carries strictly more days than the rollup, the rollup is - // under-converged: we fold the (user, type, DAY, [from, to]) - // partition inline, re-read the rollup, and return the refreshed - // rows. The inline fold is bounded by the window + type so it is - // small (~30 buckets × one type for the dashboard chart's 30-day - // window) and finishes well inside the request budget. - // - // The probe runs only on suspicious rollups so the covered-tenant - // happy path stays a single indexed read. The maintainer's tenant on a 30-day - // BP_SYS window pre-fix returned 2 rollup rows and hit `total: 2` - // in the live trace; post-fix the probe finds ~30 distinct days in - // `measurements`, folds them, and the chart paints all 30. - const windowMs = to.getTime() - from.getTime(); - const windowDays = Math.ceil(windowMs / 86_400_000); - const SUSPICIOUS_ROW_FLOOR = 3; - const SUSPICIOUS_WINDOW_DAYS = 7; - let effectiveRows = rollupRows; - let coverageFallbackFired = false; - if ( - rollupRows.length > 0 && - rollupRows.length < SUSPICIOUS_ROW_FLOOR && - windowDays >= SUSPICIOUS_WINDOW_DAYS - ) { - // Cheap probe — one indexed range scan over - // `(user_id, type, measured_at)`. Returns the count of distinct - // calendar days the live table holds for the window. - const liveDayCountRows = await prisma.$queryRaw>` - SELECT COUNT(DISTINCT date_trunc('day', m."measured_at"))::bigint AS days - FROM measurements m - WHERE m."user_id" = ${user.id} - AND m."type" = ${type}::measurement_type - AND m."measured_at" >= ${from} - AND m."measured_at" <= ${to} - AND m."deleted_at" IS NULL - `; - const liveDays = Number(liveDayCountRows[0]?.days ?? BigInt(0)); - if (liveDays > rollupRows.length) { - coverageFallbackFired = true; - try { - // Inline fold of the (user, type, DAY, window) partition. - // Bounded by the window so it stays small even on a - // power-user account; the upsert is idempotent against the - // composite primary key. - await recomputeUserRollups(user.id, { - types: [type as MeasurementType], - granularities: ["DAY"], - from, - to, - }); - // Re-read so this request returns the converged rows. The - // next chart paint hits the same warm rollup without paying - // the fallback cost. v1.11.1 — collapse per-source rows to the - // canonical reading per day, same as the initial read. - effectiveRows = collapseRollupRowsBySource( - await prisma.measurementRollup.findMany({ - where: { - userId: user.id, - type: type as MeasurementType, - granularity: "DAY", - bucketStart: { gte: from, lte: to }, - }, - orderBy: { bucketStart: "asc" }, - select: { - type: true, - source: true, - bucketStart: true, - mean: true, - count: true, - sumValue: true, - minValue: true, - maxValue: true, - }, - }), - type as MeasurementType, - priorityJson, - ).slice(0, cap); - } catch (err) { - // Best-effort — fall back to the legacy live aggregate path - // below if the fold itself failed so the chart paints - // something sensible regardless. `annotate` carries the - // surface so ops can spot a silent populator regression. - const message = err instanceof Error ? err.message : String(err); - annotate({ - meta: { - rollup_coverage_fallback_failed: true, - rollup_coverage_fallback_error: message, - type, - }, - }); - // Drop into the live `date_trunc` branch below. - effectiveRows = []; - } - } - } - if (effectiveRows.length > 0) { - // Cumulative metrics (steps, active energy, distance, flights, - // daylight) store `mean` per bucket but the chart needs the - // daily SUM. v1.4.39 W-SUM — read `sumValue` directly; fall back - // to `mean * count` only when the legacy NULL surfaces (pre- - // v1.4.39 rows still waiting for the boot-time backfill to - // converge). `mean * count` is algebraically equivalent to - // SUM(value) for a single-source day because AVG = SUM / COUNT. - const useSum = - type != null && CUMULATIVE_HK_TYPES.has(type as MeasurementType); - const measurements = effectiveRows.map((r) => ({ - type: r.type, - value: useSum ? (r.sumValue ?? r.mean * r.count) : r.mean, - measuredAt: r.bucketStart.toISOString(), - count: r.count, - // v1.8.5 — per-day min / max so the chart can shade an - // Apple-Health-style range band around the daily mean line. - // Omitted for cumulative metrics (steps, distance, …) where the - // daily value is a SUM, not a central tendency, so an intra-day - // spread band carries no meaning. - minValue: useSum ? undefined : r.minValue, - maxValue: useSum ? undefined : r.maxValue, - })); - annotate({ - action: { name: "measurement.list" }, - meta: { - total: measurements.length, - type, - aggregate: "daily", - source: "rollup", - coverage_fallback: coverageFallbackFired, - }, - }); + }); + if (measurements.length > 0) { return apiSuccess({ measurements, meta: { @@ -540,10 +375,11 @@ export const GET = apiHandler(async (request: NextRequest) => { }, }); } - // Fall through to the live aggregate path when the rollup is - // empty for the requested window — covers brand-new accounts + - // the race between a recent write hook and the read, and the - // coverage-fallback inline-fold-failed branch above. + // `readDailySeries` already falls back to the live `date_trunc` + // aggregate on a rollup-coverage miss, so an empty result here means + // the window genuinely holds no rows. Fall through to the shared + // live aggregate branch below to keep the response shape identical + // for the truly-empty case. } if (aggregate && aggregate !== "raw" && from && to) { @@ -941,6 +777,25 @@ async function postMeasurement(request: NextRequest) { // reminders against the just-landed readings now. Fire-and-forget. void enqueueReminderSatisfy(user.id).catch(() => {}); + // v1.18.6 — absolute clinical safety-floor check on the just-written + // readings (confirm-gated, module-gated, never diagnoses). The combined + // BP form lands both arms here, so the BP floor sees a whole reading. + // The transient per-entry `symptomsPresent` flag (ORed across the batch) + // lifts a confirmed breach to the symptom-coupled emergency copy. + // Fire-and-forget — a notification failure never fails the write. + void runSafetyFloorCheck({ + userId: user.id, + written: results.map((r) => ({ + type: r.type, + value: r.value, + measuredAt: r.measuredAt, + glucoseContext: r.glucoseContext, + })), + symptomsPresent: parsed.data.measurements.some( + (m) => m.symptomsPresent === true, + ), + }).catch(() => {}); + // v1.5.0 — refresh the persistent rollup table for every distinct // (type, day) the batch touched so the next analytics / coach read // hits the cache rather than falling through to live aggregation. @@ -1001,8 +856,16 @@ async function postMeasurement(request: NextRequest) { return returnAllZodIssues(parsed.error, 422); } - const { type, value, measuredAt, notes, source, glucoseContext, deviceType } = - parsed.data; + const { + type, + value, + measuredAt, + notes, + source, + glucoseContext, + deviceType, + symptomsPresent, + } = parsed.data; // Handle unique constraint violation let measurement; @@ -1058,6 +921,25 @@ async function postMeasurement(request: NextRequest) { // against the just-landed reading now. Fire-and-forget. void enqueueReminderSatisfy(user.id).catch(() => {}); + // v1.18.6 — absolute clinical safety-floor check (confirm-gated, + // module-gated, never diagnoses). A single-entry POST carries only one arm + // of a BP reading, so the BP floor only fires through the combined-form / + // batch path; a lone glucose reading is evaluated here. The transient + // `symptomsPresent` flag lifts a confirmed breach to the emergency copy. + // Fire-and-forget — a notification failure never fails the write. + void runSafetyFloorCheck({ + userId: user.id, + written: [ + { + type: measurement.type, + value: measurement.value, + measuredAt: measurement.measuredAt, + glucoseContext: measurement.glucoseContext, + }, + ], + symptomsPresent, + }).catch(() => {}); + // v1.5.0 — refresh the persistent rollup row for the affected // (type, day) tuple. Runs inline so the next read of the // comprehensive / analytics surface is correct without waiting on diff --git a/src/app/api/measurements/series-batch/__tests__/route.test.ts b/src/app/api/measurements/series-batch/__tests__/route.test.ts new file mode 100644 index 000000000..9e465acfb --- /dev/null +++ b/src/app/api/measurements/series-batch/__tests__/route.test.ts @@ -0,0 +1,140 @@ +/** + * v1.18.6 — `GET /api/measurements/series-batch` unit + parity tests. + * + * The endpoint reads each requested type through the SHARED + * `readDailySeries` reader — the exact same reader the single-type + * `GET /api/measurements?aggregate=daily&source=rollup` route delegates + * to (see `src/app/api/measurements/route.ts`). The parity guarantee is + * therefore structural: both routes call `readDailySeries`, so the rows + * are identical by construction. These tests pin that the batch route + * (a) validates its query, (b) calls the reader once per requested type + * with the right window, and (c) returns each type's reader output + * verbatim under `series[type]`. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/logging/context", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, annotate: vi.fn() }; +}); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); +vi.mock("@/lib/rollups/measurement-read", () => ({ + loadUserSourcePriority: vi.fn(async () => null), +})); +vi.mock("@/lib/measurements/daily-series-read", () => ({ + readDailySeries: vi.fn(), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { readDailySeries } from "@/lib/measurements/daily-series-read"; + +const SESSION_OK = { + session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "u", role: "USER" as const }, +}; + +const FROM = "2026-05-01T00:00:00.000Z"; +const TO = "2026-05-31T23:59:59.000Z"; + +function req(query: string): NextRequest { + return new NextRequest( + `http://localhost/api/measurements/series-batch?${query}`, + ); +} + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("GET /api/measurements/series-batch", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await GET(req(`types=WEIGHT&from=${FROM}&to=${TO}`)); + expect(res.status).toBe(401); + }); + + it("returns 422 when types is missing", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await GET(req(`from=${FROM}&to=${TO}`)); + expect(res.status).toBe(422); + }); + + it("returns 422 for an unknown type", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await GET(req(`types=GARBAGE&from=${FROM}&to=${TO}`)); + expect(res.status).toBe(422); + }); + + it("reads each type through readDailySeries and keys the result by type", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const weightRows = [ + { type: "WEIGHT", value: 80, measuredAt: FROM, count: 1 }, + ]; + const bpRows = [ + { type: "BLOOD_PRESSURE_SYS", value: 126, measuredAt: FROM, count: 1 }, + ]; + vi.mocked(readDailySeries).mockImplementation(async ({ type }) => { + if (type === "WEIGHT") return weightRows as never; + if (type === "BLOOD_PRESSURE_SYS") return bpRows as never; + return [] as never; + }); + + const res = await GET( + req(`types=WEIGHT,BLOOD_PRESSURE_SYS&from=${FROM}&to=${TO}`), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.series.WEIGHT).toEqual(weightRows); + expect(body.data.series.BLOOD_PRESSURE_SYS).toEqual(bpRows); + + // One reader call per unique type, with the parsed window. + expect(vi.mocked(readDailySeries)).toHaveBeenCalledTimes(2); + const call = vi.mocked(readDailySeries).mock.calls[0][0]; + expect(call.userId).toBe("user-1"); + expect(call.from.toISOString()).toBe(FROM); + expect(call.to.toISOString()).toBe(TO); + }); + + it("de-dupes a repeated type to one reader call", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(readDailySeries).mockResolvedValue([] as never); + const res = await GET(req(`types=WEIGHT,WEIGHT&from=${FROM}&to=${TO}`)); + expect(res.status).toBe(200); + expect(vi.mocked(readDailySeries)).toHaveBeenCalledTimes(1); + }); + + it("isolates a single type's failure to an empty slice, others still return", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const weightRows = [ + { type: "WEIGHT", value: 80, measuredAt: FROM, count: 1 }, + ]; + vi.mocked(readDailySeries).mockImplementation(async ({ type }) => { + if (type === "WEIGHT") return weightRows as never; + throw new Error("transient db reject"); + }); + + const res = await GET( + req(`types=WEIGHT,BLOOD_PRESSURE_SYS&from=${FROM}&to=${TO}`), + ); + // One type rejecting must NOT 500 the whole batch. + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data.series.WEIGHT).toEqual(weightRows); + expect(body.data.series.BLOOD_PRESSURE_SYS).toEqual([]); + }); +}); diff --git a/src/app/api/measurements/series-batch/route.ts b/src/app/api/measurements/series-batch/route.ts new file mode 100644 index 000000000..00f1ace89 --- /dev/null +++ b/src/app/api/measurements/series-batch/route.ts @@ -0,0 +1,109 @@ +/** + * v1.18.6 — GET /api/measurements/series-batch + * + * Batched, rollup-backed daily-series endpoint. Returns ALL requested + * measurement types in ONE response so the dashboard's chart row does a + * single round-trip instead of one self-fetch per chart (audit finding + * #2 — the per-chart fan-out). + * + * Query: `types` (comma-list of `MeasurementType`), `from`, `to`. Always + * reads `aggregate=daily` through the rollup tier (`source=rollup`) via + * the shared `readDailySeries` reader — the SAME path + * `GET /api/measurements?aggregate=daily&source=rollup` uses, so the + * per-type series are byte-identical between the two endpoints. Internal + * per-type reads are capped with `p-limit` to protect the shared Prisma + * pool. + * + * Sleep is deliberately NOT included: SLEEP_DURATION rides a per-night + * stage reconstruction (`/api/measurements/series?kind=sleep`), not the + * rollup tier, so the dashboard sleep chart stays on its existing + * `/series` call. Passing `SLEEP_DURATION` here yields an empty series + * for that key (the rollup carries no useful per-night collapse) — the + * client keeps sleep on the dedicated route. + */ +import { NextRequest } from "next/server"; +import pLimit from "p-limit"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { returnAllZodIssues } from "@/lib/api-response"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; +import { + readDailySeries, + type DailySeriesRow, +} from "@/lib/measurements/daily-series-read"; +import { + seriesBatchQuerySchema, + SERIES_BATCH_MAX_TYPES, +} from "@/lib/validations/series-batch"; +import type { MeasurementType } from "@/generated/prisma/client"; + +/** + * Concurrency cap for the per-type rollup reads. Mirrors the W-POOL + * `p-limit(4)` discipline applied across the analytics fan-out so a + * wide dashboard never packs the shared Prisma pool. + */ +const SERIES_BATCH_CONCURRENCY = 4; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const params = Object.fromEntries(request.nextUrl.searchParams); + const parsed = seriesBatchQuerySchema.safeParse(params); + if (!parsed.success) { + return returnAllZodIssues(parsed.error, 422); + } + + const { types, from, to } = parsed.data; + // De-dupe while preserving order so a caller repeating a type doesn't + // double-read it. + const uniqueTypes = Array.from(new Set(types)) as MeasurementType[]; + + // Load the source-priority ladder ONCE and thread it across every type + // so the batched reader doesn't re-query it per type. + const priorityJson = await loadUserSourcePriority(user.id); + + // Per-type isolation: a single type's transient DB reject degrades to an + // empty slice for THAT type only, never a 500 that blanks every chart in + // the row. The failing chart self-fetches through its existing fallback. + const limit = pLimit(SERIES_BATCH_CONCURRENCY); + let degradedTypes = 0; + const entries = await Promise.all( + uniqueTypes.map((type) => + limit(async (): Promise => { + try { + const rows = await readDailySeries({ + userId: user.id, + type, + from, + to, + priorityJson, + }); + return [type, rows] as const; + } catch { + degradedTypes += 1; + return [type, []] as const; + } + }), + ), + ); + + const series: Record = {}; + for (const [type, rows] of entries) { + series[type] = rows; + } + + annotate({ + action: { name: "measurement.series.batch" }, + meta: { + type_count: uniqueTypes.length, + total: entries.reduce((sum, [, rows]) => sum + rows.length, 0), + degraded_types: degradedTypes, + }, + }); + + return apiSuccess({ series }); +}); + +// Re-export the cap so the OpenAPI table + tests share the constant. +export { SERIES_BATCH_MAX_TYPES }; diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 14a53b392..2490e4d98 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -34,7 +34,7 @@ import { expectedSlotCountForDay, lastNonSkippedTakenAt, } from "@/lib/analytics/compliance"; -import { getUserTodayBounds } from "@/lib/tz/local-day"; +import { getUserTodayBounds, startOfLocalDayInTz } from "@/lib/tz/local-day"; import { projectTodayIntakesAndRecompute } from "@/lib/medications/scheduling/project-today-intakes"; import { applyCanonicalSlotWrite, @@ -84,19 +84,6 @@ const updateSchema = z.object({ .optional(), }); -function startOfDayInTz(date: Date, tz: string): Date { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone: tz, - year: "numeric", - month: "2-digit", - day: "2-digit", - }).formatToParts(date); - const y = parts.find((p) => p.type === "year")?.value ?? "1970"; - const m = parts.find((p) => p.type === "month")?.value ?? "01"; - const d = parts.find((p) => p.type === "day")?.value ?? "01"; - return new Date(`${y}-${m}-${d}T00:00:00.000Z`); -} - export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -138,7 +125,7 @@ export const GET = apiHandler(async (request: NextRequest) => { const userTz = user.timezone ?? DEFAULT_TIMEZONE; if (scope === "today") { - const todayStart = startOfDayInTz(new Date(), userTz); + const todayStart = startOfLocalDayInTz(new Date(), userTz); const todayEnd = new Date(todayStart.getTime() + 86_400_000); // v1.4.39 W-SERVER-FIX — project pending intake rows for every diff --git a/src/app/api/onboarding/disclaimer/route.ts b/src/app/api/onboarding/disclaimer/route.ts new file mode 100644 index 000000000..5422adb60 --- /dev/null +++ b/src/app/api/onboarding/disclaimer/route.ts @@ -0,0 +1,61 @@ +import { z } from "zod/v4"; + +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { DISCLAIMER_VERSION } from "@/lib/onboarding/disclaimer"; + +/** + * v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. + * + * POST { version: string } + * → stamps `users.disclaimer_acknowledged_at = now()` + + * `disclaimer_acknowledged_version = `. Idempotent: a repeat + * acknowledgment of the same version simply refreshes the timestamp. + * + * Replaces the per-page / per-chart disclaimer banners removed app-wide in + * the same release. The acknowledged legal text stays reachable on the public + * privacy page. Never auto-set; the onboarding welcome step is the one writer. + */ + +const bodySchema = z.object({ + // Echoed back from the client so a stale shell cannot silently record an + // acknowledgment of copy it never rendered. Bounded; the server pins the + // canonical version it persists regardless. + version: z.string().min(1).max(64), +}); + +export const POST = apiHandler(async (request) => { + const { user } = await requireAuth(); + + const { data: rawBody, error: jsonError } = await safeJson(request, { + maxBytes: 4 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = z.safeParse(bodySchema, rawBody); + if (!parsed.success) { + return apiError("Invalid input", 422); + } + + // The server pins the canonical version it stamps — the body version is + // only a freshness signal, never the source of truth. + await prisma.user.update({ + where: { id: user.id }, + data: { + disclaimerAcknowledgedAt: new Date(), + disclaimerAcknowledgedVersion: DISCLAIMER_VERSION, + }, + }); + + annotate({ + action: { name: "onboarding.disclaimer.acknowledge" }, + meta: { + version: DISCLAIMER_VERSION, + clientVersion: parsed.data.version, + }, + }); + + return apiSuccess({ acknowledgedVersion: DISCLAIMER_VERSION }); +}); diff --git a/src/app/api/onboarding/tour/route.ts b/src/app/api/onboarding/tour/route.ts index 9e9b72a4a..8d88d408a 100644 --- a/src/app/api/onboarding/tour/route.ts +++ b/src/app/api/onboarding/tour/route.ts @@ -5,30 +5,45 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { tourProgressSchema } from "@/lib/onboarding/tour-progress"; +import { Prisma } from "@/generated/prisma/client"; /** * v1.4.15 Phase B5 — onboarding tour completion endpoint. + * v1.18.6 — extended with the resumable module-tour progress point. * - * Two operations on a single resource: + * Operations on a single resource: * * POST { completed: true, outcome: "completed" | "skipped" } * → flips `users.onboarding_tour_completed` to true. Auto-fires - * when the user reaches the end of the spotlight tour OR - * explicitly dismisses it. + * when the user reaches the end of the module tour OR explicitly + * dismisses it. * * POST { completed: false } * → resets the flag so the user can replay the tour from - * Settings → Account → "Restart onboarding tour". + * Settings → Advanced → "Modul-Tour neu starten". Also CLEARS + * `onboarding_tour_progress_json` back to null so the replay + * genuinely starts from the first module. * - * The `outcome` field is informational — it lands in the structured - * Wide Event annotation so analytics can tell completion from - * dismiss without writing two columns. Same DB write either way. + * POST { progress: { lastStopId, completedStopIds, status, updatedAt } } + * → fire-and-forget mid-tour checkpoint so a reload resumes at the + * right module. May arrive WITH `completed` (the terminal + * checkpoint sends both) or alone (every `nextStep`). + * + * `outcome` is informational — it lands in the structured Wide Event + * annotation so analytics can tell completion from dismiss without a + * second column. Same DB write either way. */ -const tourBodySchema = z.object({ - completed: z.boolean(), - outcome: z.enum(["completed", "skipped"]).optional(), -}); +const tourBodySchema = z + .object({ + completed: z.boolean().optional(), + outcome: z.enum(["completed", "skipped"]).optional(), + progress: tourProgressSchema.optional(), + }) + .refine((b) => b.completed !== undefined || b.progress !== undefined, { + message: "Provide `completed` and/or `progress`.", + }); export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -43,18 +58,36 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Invalid input", 422); } - await prisma.user.update({ - where: { id: user.id }, - data: { onboardingTourCompleted: parsed.data.completed }, - }); + // Build the update field-by-field (no mass assignment). `completed` + // drives the coarse boolean; a replay (`completed:false`) wipes the + // resume point; a progress checkpoint writes the JSON column. + const data: Prisma.UserUpdateInput = {}; + if (parsed.data.completed !== undefined) { + data.onboardingTourCompleted = parsed.data.completed; + if (parsed.data.completed === false) { + // Replay — start the resume point over. + data.onboardingTourProgressJson = Prisma.JsonNull; + } + } + if (parsed.data.progress !== undefined) { + data.onboardingTourProgressJson = parsed.data.progress; + } + + await prisma.user.update({ where: { id: user.id }, data }); annotate({ action: { name: "onboarding.tour.update" }, meta: { - completed: parsed.data.completed, + completed: parsed.data.completed ?? null, outcome: parsed.data.outcome ?? null, + progressStatus: parsed.data.progress?.status ?? null, + lastStopId: parsed.data.progress?.lastStopId ?? null, }, }); - return apiSuccess({ onboardingTourCompleted: parsed.data.completed }); + return apiSuccess({ + onboardingTourCompleted: + parsed.data.completed ?? user.onboardingTourCompleted, + progress: parsed.data.progress ?? null, + }); }); diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx index 23363d12e..9ffb5a694 100644 --- a/src/app/coach/page.tsx +++ b/src/app/coach/page.tsx @@ -6,6 +6,7 @@ import { Minimize2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation"; +import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger"; import { useTranslations } from "@/lib/i18n/context"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; @@ -61,15 +62,36 @@ export default function CoachPage() { return (
`'s shared `mx-auto max-w-screen-xl px-4 + // pt-6 pb-20 md:px-6` content frame, which boxed the chat into the + // same narrow column every dashboard card uses and left a tall dead + // band below the card (the `pb-20` FAB gutter, pointless here since + // the FAB hides on `/coach`). Break out of that frame: negative + // margins cancel the container's horizontal + top/bottom padding so + // the chat claims the full content width and the viewport height, + // then a light inner padding keeps it off the window edge. The card + // itself drops its rounded border on small screens (edge-to-edge, + // app-like) and keeps the contained card look from `sm+`. + // + // v1.18.1 (W-COACH-UI C1/C3) — the card formerly capped at + // `100dvh-13rem`, leaving wasted space below it; we now fill the + // reclaimed height. + className={ + // Cancel the AuthShell container's padding (`-mt-6` top, + // `-mb-20` bottom, `-mx-4 md:-mx-6` sides) so the surface is + // full-bleed, then re-pad lightly. The height is the full + // viewport minus the top bar (`4rem`) and the small gutters. + "-mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-4rem)] min-h-[32rem] flex-col md:-mx-6 " + + "px-2 pt-2 pb-3 sm:px-4 sm:pt-4 sm:pb-4" + } > +
{title} )} leadingHeaderActions={ - + <> + + + } - /> + /> +
); } diff --git a/src/app/globals.css b/src/app/globals.css index 417fb9536..80d94a203 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -96,6 +96,7 @@ * - info → dracula-cyan (neutral informational note) */ --color-success: var(--success); + --color-success-foreground: var(--success-foreground); --color-warning: var(--warning); --color-info: var(--info); @@ -155,6 +156,17 @@ --warning: var(--dracula-orange); --info: var(--dracula-cyan); + /* + * Foreground for glyphs/labels painted on a solid `bg-success` fill. The + * raw Dracula green (`#50fa7b`) is a bright tone, so white-on-green reads + * ~1.37:1 in dark mode — well under AA. The dark theme below pairs it with + * the near-black Dracula background (`#282a36`, ~10.4:1); the light theme + * keeps white on the darker light-mode green (`#14720a`, ~6.1:1). Mirrors + * the `--primary-foreground` pattern. Default here keeps the dark-theme + * value so an un-themed root never falls back to an unreadable pairing. + */ + --success-foreground: #282a36; + --radius: 0.625rem; } @@ -212,6 +224,9 @@ --status-pill-near: #ffb86c; --status-pill-out: #ff8a8a; + /* Dark glyph on the bright Dracula-green fill — ~10.4:1 on `#50fa7b`. */ + --success-foreground: #282a36; + /* * Dose accent in dark mode — the exact Tailwind `text-purple-400` * (`oklch(0.714 0.203 305.504)`) the cards used before, so the dark card @@ -297,6 +312,8 @@ --success: #14720a; --warning: #a34d14; --info: #036a96; + /* White glyph on the darker light-mode green — ~6.1:1 on `#14720a`. */ + --success-foreground: #ffffff; --border: #cfcfde; --input: #cfcfde; --ring: #644ac9; @@ -889,3 +906,32 @@ animation: none; } } + +/* ── Global reduced-motion backstop ────────────────────────────────── + * Defensive catch-all so any animation/transition we forget to gate + * per-component still collapses to near-instant when the user has asked + * for reduced motion. The per-component blocks above stay — they carry + * tailored static fallbacks (static dots, full-height bars). This is the + * floor, not a replacement. Near-zero (not 0) keeps JS animation-end / + * transition-end listeners firing so state machines don't stall. + * + * `animation-fill-mode: forwards` + `animation-delay: 0ms` are load-bearing: + * several entrance animations rest at `opacity: 0` and only become visible + * through their reveal keyframe. Without holding the end state (and without + * zeroing the stagger delay) the near-zero animation completes and the + * element snaps back to its hidden base style — leaving charts and list + * rows permanently invisible under reduced motion. Forwards pins the final + * (revealed) frame; zero delay reveals immediately. Per-component blocks + * above that set `animation: none` are unaffected (no animation to fill). */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + animation-fill-mode: forwards !important; + animation-delay: 0ms !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index 7cb330f84..ea184b33c 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -170,6 +170,32 @@ export default function InsightsBlutdruckPage() { average). */} + {/* v1.18.6 — free derived BP context: pulse pressure (SBP − DBP) and + mean arterial pressure ((SBP + 2·DBP)/3), computed from the most + recent reading. Pure derivation of stored BP — no schema, no extra + fetch. Calm, cited, non-alarming. */} + {(() => { + const sbp = sysSummary?.latest ?? null; + const dbp = diaSummary?.latest ?? null; + if (sbp == null || dbp == null || sbp <= dbp) return null; + const pp = Math.round(sbp - dbp); + const map = Math.round((sbp + 2 * dbp) / 3); + return ( +

+ {t("insights.subPage.bpDerived.pulsePressure", { + value: pp, + })}{" "} + {t("insights.subPage.bpDerived.meanArterialPressure", { + value: map, + })}{" "} + {t("insights.subPage.bpDerived.caveat")} +

+ ); + })()} + {/* v1.12.4 — Einschätzung is the last content block; the stat strip (rendered by the shell) closes the spine below it. */} (null); @@ -41,7 +35,7 @@ export default function LabsPage() { ); const pull = usePullToRefresh({ onRefresh: refreshVisible, - disabled: dialogOpen || manageOpen, + disabled: dialogOpen, }); useEffect(() => { @@ -63,7 +57,10 @@ export default function LabsPage() {
-

+

{t("labs.title")}

@@ -71,34 +68,32 @@ export default function LabsPage() {

+ + {/* v1.18.6 (MOD-01) — the wrench is the module's customize entry + point, left of the primary Add and linking to the Labs settings + page (view, sort order, biomarker CRUD + reorder). Mirrors the + medication page header's wrench glyph + slot + 44px tap floor. */} + - {/* Secondary actions fold into one overflow kebab beside the primary - Add, mirroring the medication-card header (one cluster, not a - two-button row). */} - - - - - - setManageOpen(true)}> - - {t("labs.biomarker.manage")} - - -
@@ -121,15 +116,6 @@ export default function LabsPage() { /> - - - - setDialogOpen(true)} />
); diff --git a/src/app/measurements/page.tsx b/src/app/measurements/page.tsx index 1689ed852..7e72ff863 100644 --- a/src/app/measurements/page.tsx +++ b/src/app/measurements/page.tsx @@ -16,6 +16,7 @@ import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Plus, Loader2 } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "@/lib/i18n/context"; +import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger"; export default function MeasurementsPage() { const { isAuthenticated, isLoading } = useAuth(); @@ -110,7 +111,10 @@ export default function MeasurementsPage() {
-

+

{t("measurements.title")}

{/* v1.4.34 IW-G — subtitle now stays visible on mobile so @@ -120,13 +124,16 @@ export default function MeasurementsPage() { {t("measurements.subtitle")}

- +
+ + +
-

+

{t("medications.title")}

{/* v1.4.34 IW-G — subtitle stays visible on mobile so the @@ -338,6 +342,7 @@ export default function MedicationsPage() {

+ {/* v1.16.11 (#316) — "Alle fälligen einnehmen". Contextual, not a permanent fixture: it renders only while at least TWO medications are currently due (a single due dose is the diff --git a/src/app/mood/page.tsx b/src/app/mood/page.tsx index 8d959076e..9a8485974 100644 --- a/src/app/mood/page.tsx +++ b/src/app/mood/page.tsx @@ -15,6 +15,7 @@ import { Plus, Loader2, Wrench } from "lucide-react"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; import { useTranslations } from "@/lib/i18n/context"; +import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger"; export default function MoodPage() { const { isAuthenticated, isLoading } = useAuth(); @@ -65,7 +66,10 @@ export default function MoodPage() {
-

+

{t("mood.title")}

{/* v1.4.34 IW-G — subtitle stays visible on mobile so the @@ -75,6 +79,7 @@ export default function MoodPage() {

+ {/* v1.17 — the wrench is the one customize entry point: it links to /settings/mood, which owns the tag groups, custom tags, visibility, and picker order. Same glyph, slot (left diff --git a/src/app/page.tsx b/src/app/page.tsx index c58eca290..d021b42b3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -52,7 +52,6 @@ import { TrendCardSkeleton } from "@/components/charts/trend-card-skeleton"; import { TrendHint } from "@/components/charts/trend-hint"; import { summaryToTrend7Delta } from "@/lib/analytics/trend-delta"; import { GettingStartedChecklist } from "@/components/onboarding/getting-started-checklist"; -import { TourLauncher } from "@/components/onboarding/tour-launcher"; import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card"; import { RecentWorkoutsTile } from "@/components/dashboard/recent-workouts-tile"; import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsorge-dashboard-card"; @@ -106,129 +105,23 @@ import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import type { DataSummary } from "@/lib/analytics/trends"; import { mergeSlimAndThickAnalytics } from "@/lib/analytics/merge-slim-thick"; import { isWindowSufficient } from "@/lib/analytics/window-confidence"; -import { getBpTargets } from "@/lib/analytics/bp-targets"; -import { - buildTrafficLightBands, - buildTrafficRange, - buildWeightBandsFromHeight, - buildWeightRangeFromHeight, - getBodyFatTargetRange, -} from "@/lib/analytics/value-bands"; -import { - getAgeFromDateOfBirth, - getPersonalizedPulseTarget, -} from "@/lib/analytics/pulse-targets"; +import { buildDashboardBands } from "@/lib/dashboard/bands"; import { apiGet } from "@/lib/api/api-fetch"; -/** - * v1.7.0 — first-paint gate for the dashboard tile strip. - * - * `primaryLoading` must come from whichever query actually drives the - * tiles: the snapshot cell by default, the slim analytics cell when - * `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false`. A disabled TanStack query reports - * `isLoading: false` (idle fetch status), so keying off the wrong - * source flashes the empty state for the whole fetch. Pure + exported - * so the gate has direct unit coverage without mounting the page. - */ -export function resolveDashboardFirstPaintGate(input: { - trendCardCount: number; - chartCount: number; - configuredTileCount: number; - primaryLoading: boolean; -}): { showTileStripSkeleton: boolean; showEmptyState: boolean } { - const showTileStripSkeleton = - input.trendCardCount === 0 && - input.primaryLoading && - input.configuredTileCount > 0; - const showEmptyState = - input.trendCardCount === 0 && - input.chartCount === 0 && - !showTileStripSkeleton && - !input.primaryLoading; - return { showTileStripSkeleton, showEmptyState }; -} - -/** - * v1.16.8 — widget ids that actually paint a tile in the strip on the - * web dashboard, mirrored from the per-id render blocks below. - * `medications` / `recentWorkouts` / `achievements` carry NO strip tile - * (chart-row cards only), and the iOS-pin-only ids have no web render - * path at all — counting any of them over-reserved the loading - * silhouette and made the strip reshuffle when the data landed. - */ -const TILE_CAPABLE_WIDGET_IDS = new Set([ - "weight", - "bp", - "pulse", - "bodyFat", - "mood", - "sleep", - "steps", - "glucose", - "bpInTarget", - "vo2Max", -]); - -/** - * v1.16.8 — silhouette count for the tile-strip skeleton: one card per - * tile-capable, tile-visible widget. `bp` paints TWO tiles (sys + dia, - * see the render block below) so it counts double; `glucose` can fan - * out to one tile per logged context, but the contexts are unknown - * until the snapshot lands, so it reserves one card (the sane floor). - * Pure + exported for direct unit coverage. - */ -export function resolveConfiguredTileCount(layout: DashboardLayout): number { - let count = 0; - for (const widget of layout.widgets) { - if (!TILE_CAPABLE_WIDGET_IDS.has(widget.id)) continue; - if (!(widget.tileVisible ?? widget.visible)) continue; - count += widget.id === "bp" ? 2 : 1; - } - return count; -} - -/** - * v1.16.8 — widget ids with a chart-row surface on the web dashboard, - * mirrored from the `charts[]` entries below. The achievements + - * recent-workouts cards stay out: they self-skeleton, carry no - * chart-shaped footprint, and gate on `layoutResolved`. - */ -const CHART_CAPABLE_WIDGET_IDS = new Set([ - "weight", - "bp", - "pulse", - "bodyFat", - "mood", - "sleep", - "steps", - "medications", - // v1.18.2 — Vorsorge preventive-care summary card (chart-row only). - "vorsorge", -]); - -/** - * v1.16.8 — expected chart-row card count while the snapshot is still - * in flight. Every chart gate below needs `count > 0` from snapshot - * data, so the cold page used to render NO chart row at all and then - * grow ~1000 px when the snapshot landed. The best available signal - * before data arrives is the layout config (the user's saved layout - * when cached, the default otherwise): one card per chart-visible - * widget, plus the BMI card that rides the weight gate when the - * profile carries a height. Pure + exported for direct unit coverage. - */ -export function resolveChartRowPlaceholderCount( - layout: DashboardLayout, - opts?: { hasHeightCm?: boolean }, -): number { - let count = 0; - for (const widget of layout.widgets) { - if (!CHART_CAPABLE_WIDGET_IDS.has(widget.id)) continue; - if (!widget.visible) continue; - count += 1; - if (widget.id === "weight" && opts?.hasHeightCm) count += 1; - } - return count; -} +// v1.18.6 — the pure first-paint / skeleton-reservation gates moved to +// `@/components/dashboard/dashboard-gates`. Re-exported here so the +// existing `../page` unit-test imports stay valid while `page.tsx` thins +// to an orchestrator. +export { + resolveDashboardFirstPaintGate, + resolveConfiguredTileCount, + resolveChartRowPlaceholderCount, +} from "@/components/dashboard/dashboard-gates"; +import { + resolveDashboardFirstPaintGate, + resolveConfiguredTileCount, + resolveChartRowPlaceholderCount, +} from "@/components/dashboard/dashboard-gates"; export default function DashboardPage() { const { isAuthenticated, user } = useAuth(); @@ -621,23 +514,42 @@ export default function DashboardPage() { RANDOM: "targets.glucoseRandom", BEDTIME: "targets.glucoseBedtime", }; - const bpTargets = - user?.dateOfBirth != null ? getBpTargets(new Date(user.dateOfBirth)) : null; - const pulseAge = getAgeFromDateOfBirth(user?.dateOfBirth ?? null); - const pulseTarget = getPersonalizedPulseTarget( - pulseAge, - (user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null, - ); - const bodyFatRange = getBodyFatTargetRange(user?.gender); - const weightRange = user?.heightCm - ? buildWeightRangeFromHeight(user.heightCm) - : null; - const weightBands = user?.heightCm - ? buildWeightBandsFromHeight(user.heightCm, { - lowerBound: 30, - upperBound: 250, - }) + // v1.18.6 — band / target math is computed SERVER-side in the snapshot + // DTO (`targetBands`) so the client stops recomputing it from the + // profile (audit finding #3). When snapshot mode is on, read the + // resolved numbers straight from `snapshotQuery.data.targetBands`; the + // `!snapshotEnabled` branch keeps the legacy client-compute path so the + // non-snapshot fallback still works. The two produce byte-identical + // numbers (the server calls the SAME helpers — see + // `buildTargetBands` + its parity test). + const serverBands = snapshotEnabled + ? snapshotQuery.data?.targetBands : undefined; + // Resolve the bands through the SAME `buildDashboardBands` helper the + // server snapshot uses, so the snapshot path and the client fallback + // are byte-identical by construction (no second inline copy of the + // band math to drift). Always compute the client copy from the profile + // — it is pure + cheap — so a still-loading snapshot frame keeps real + // bands rather than blank charts; `serverBands ?? clientBands` then + // prefers the authoritative server numbers the moment they arrive. + // Inputs only change with profile facts, so memoise the band math — it + // ran on every dashboard render (and got discarded by `serverBands ??` + // in the snapshot steady state) before this. Keyed on the `user` object + // identity so the React Compiler can preserve the memo (a narrower + // property list trips `preserve-manual-memoization`). + const clientBands = useMemo( + () => + buildDashboardBands({ + dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null, + gender: (user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null, + heightCm: user?.heightCm ?? null, + }), + [user], + ); + const bands = serverBands ?? clientBands; + const bpTargets = bands.bpTargets; + const weightRange = bands.weightRange; + const weightBands = bands.weightBands ?? undefined; const bpTargetZones = bpTargets ? [ { @@ -660,48 +572,83 @@ export default function DashboardPage() { }, ] : undefined; - const bpSysRange = bpTargets - ? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh) - : null; - const bpDiaRange = bpTargets - ? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh) - : null; - const pulseDisplayRange = { - greenMin: pulseTarget.greenMin, - greenMax: pulseTarget.greenMax, - orangeMin: pulseTarget.orangeMin, - orangeMax: pulseTarget.orangeMax, - }; - const pulseBands = [ - { min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 }, - { - min: pulseTarget.orangeMin, - max: pulseTarget.greenMin, - color: "#ffb86c", - opacity: 0.18, + const bpSysRange = bands.bpSysRange; + const bpDiaRange = bands.bpDiaRange; + const pulseDisplayRange = bands.pulseDisplayRange; + const pulseBands = bands.pulseBands; + const bodyFatBands = bands.bodyFatBands; + + // v1.18.6 — ONE batched fetch for every visible non-sleep chart series + // (audit finding #2). Pre-fix, each `` fired its own + // `/api/measurements?...&source=rollup` round-trip, so a 6-8 chart + // dashboard issued 6-8 parallel requests on load. The batched endpoint + // returns all of them in one response; each chart reads its slice via + // `preloadedSeries` instead of self-fetching. Sleep stays on its + // dedicated per-night `/series` adapter (not rollup-backed), so it is + // never included here and the sleep chart keeps its own fetch. + // + // The window matches the charts' default 30-point view (the dashboard + // mounts them with no range-tab interaction); a chart whose window + // widens past this slice (range tab / comparison) drops batched + // coverage and self-fetches for the new window — see `usePreloaded`. + const batchTypeSet = new Set(); + if (showWeightChart) batchTypeSet.add("WEIGHT"); + if (showBpCharts) { + batchTypeSet.add("BLOOD_PRESSURE_SYS"); + batchTypeSet.add("BLOOD_PRESSURE_DIA"); + } + if (showPulseChart) + batchTypeSet.add(hasRestingHr ? "RESTING_HEART_RATE" : "PULSE"); + if (showBodyFatChart) batchTypeSet.add("BODY_FAT"); + if (showStepsChart) batchTypeSet.add("ACTIVITY_STEPS"); + const batchChartTypes = Array.from(batchTypeSet); + + // Stable day-bucketed window so the cache key is stable across the day + // (mirrors the chart's own ISO-window stability contract). Computed + // once per mount via lazy state so a re-render never re-keys the batch. + const [batchWindow] = useState(() => { + const to = new Date(); + to.setHours(23, 59, 59, 999); + const from = new Date(to.getTime() - 31 * 86_400_000); + return { from: from.toISOString(), to: to.toISOString() }; + }); + + const { data: batchedSeries } = useQuery({ + queryKey: queryKeys.chartSeriesBatch( + batchChartTypes.join(","), + batchWindow.from, + batchWindow.to, + ), + enabled: + isAuthenticated && mounted && batchChartTypes.length > 0, + staleTime: 60_000, + gcTime: 5 * 60_000, + refetchOnWindowFocus: false, + queryFn: async () => { + const sp = new URLSearchParams(); + sp.set("types", batchChartTypes.join(",")); + sp.set("from", batchWindow.from); + sp.set("to", batchWindow.to); + const res = await apiGet<{ + series: Record< + string, + Array<{ + value: number; + measuredAt: string; + count?: number; + minValue?: number | null; + maxValue?: number | null; + }> + >; + }>(`/api/measurements/series-batch?${sp}`); + return res.series; }, - { - min: pulseTarget.greenMin, - max: pulseTarget.greenMax, - color: "#50fa7b", - opacity: 0.2, - }, - { - min: pulseTarget.greenMax, - max: pulseTarget.orangeMax, - color: "#ffb86c", - opacity: 0.18, - }, - { min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 }, - ].filter((band) => band.max > band.min); - const bodyFatBands = buildTrafficLightBands( - bodyFatRange.min, - bodyFatRange.max, - { - lowerBound: 2, - upperBound: 55, - }, - ); + }); + + // Per-chart slices in the chart's `MeasurementApiRow` shape. The batched + // rows already carry `measuredAt` / `value` / `count` / min / max — the + // exact fields the chart's bucketing loop reads. + const preloadedSeries = batchedSeries; // v1.7.0 — the primary data source differs by flag. In snapshot // mode `analyticsSlimQuery` is `enabled: false`, and a disabled // TanStack query reports `fetchStatus: "idle"` → `isLoading` is @@ -752,15 +699,10 @@ export default function DashboardPage() { /> ))} - {/* v1.4.15 Phase B5 — spotlight tour for first-time users. - * Self-gates on `user.onboardingTourCompleted` (DB flag) plus - * a session-storage dismiss guard. We pass `ready=true` only - * after analytics has resolved — the tour anchors to the tile - * strip and we don't want the cutout snapping to a 0×0 - * placeholder before tiles render. The launcher mounts a no-op - * `null` when the user has already seen the tour, so this - * line is free for established users. */} - + {/* v1.18.6 — the spotlight tour launcher moved to the app-shell + * (`AuthShell`) so its overlay survives the cross-page + * `router.push`es the module tour makes. The dashboard no longer + * mounts it. */} {/* Quick Entry Sheets — bottom-sheet on ` markChartReady("weight-chart")} + preloadedSeries={preloadedSeries} chartKey="weight" types={["WEIGHT"]} title={t("dashboard.weight")} @@ -1242,6 +1185,7 @@ export default function DashboardPage() { markChartReady("bmi-chart")} + preloadedSeries={preloadedSeries} chartKey="bmi" types={["WEIGHT"]} title={t("targets.bmi")} @@ -1271,6 +1215,7 @@ export default function DashboardPage() { markChartReady("bp-chart")} + preloadedSeries={preloadedSeries} chartKey="bp" types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]} title={t("dashboard.bloodPressure")} @@ -1294,6 +1239,7 @@ export default function DashboardPage() { markChartReady("pulse-chart")} + preloadedSeries={preloadedSeries} chartKey="pulse" // v1.15.12 A2 — chart the RESTING series against the // resting band when available; otherwise chart raw heart @@ -1320,6 +1266,7 @@ export default function DashboardPage() { markChartReady("bodyFat-chart")} + preloadedSeries={preloadedSeries} chartKey="bodyFat" types={["BODY_FAT"]} title={t("dashboard.bodyFat")} @@ -1380,6 +1327,7 @@ export default function DashboardPage() { markChartReady("steps-chart")} + preloadedSeries={preloadedSeries} chartKey="steps" types={["ACTIVITY_STEPS"]} title={t("dashboard.steps") ?? "Steps"} diff --git a/src/app/settings/[section]/page.tsx b/src/app/settings/[section]/page.tsx index 78115b334..b5295c215 100644 --- a/src/app/settings/[section]/page.tsx +++ b/src/app/settings/[section]/page.tsx @@ -1,5 +1,5 @@ import { notFound } from "next/navigation"; -import type { JSX } from "react"; +import type { JSX, ReactNode } from "react"; import { AccountSection } from "@/components/settings/account-section"; import { AboutSection } from "@/components/settings/about-section"; @@ -29,6 +29,9 @@ import { type SettingsSectionSlug, } from "@/components/settings/section-slugs"; import { SettingsShell } from "@/components/settings/settings-shell"; +import { SettingsSectionFrame } from "@/components/settings/settings-section-frame"; +import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link"; +import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger"; /** * Dynamic settings section route. Each of the `SETTINGS_SECTION_SLUGS` @@ -71,6 +74,50 @@ const SECTION_COMPONENTS: Record< advanced: AdvancedSection, }; +// v1.18.6 (W9) — the two Layout-hub child editors render a "← back to hub" +// link above their heading. The shared `` exposes a +// `topSlot` for exactly this; the link lives here so the section bodies stay +// pure card content. +const HUB_CHILD_SLUGS: ReadonlySet = new Set([ + "dashboard", + "insights", +]); + +/** Optional content rendered above the canonical heading, per slug. */ +function frameTopSlot(slug: SettingsSectionSlug): ReactNode { + if (HUB_CHILD_SLUGS.has(slug)) { + return ( + + ); + } + return undefined; +} + +/** Optional inline content rendered at the top-right of the heading row. */ +function frameHeadingAccessory(slug: SettingsSectionSlug): ReactNode { + // v1.18.6 (W9) — the module guided-tour re-entry triggers used to live in + // a `justify-end` header inside the section body; they move to the frame's + // heading row so the heading itself is the spotlight anchor. + if (slug === "gesundheitsakte") { + return ( + + + + ); + } + if (slug === "integrations") { + return ( + + + + ); + } + return undefined; +} + interface PageProps { // Next.js 16 made route `params` an async Promise. We `await` it before use. params: Promise<{ section: string }>; @@ -91,8 +138,17 @@ export default async function SettingsSectionPage({ params }: PageProps) { // guards the slug, but a future slug added to `SETTINGS_SECTION_SLUGS` // without a wired component would otherwise crash silently — placeholder // surfaces the gap visually instead. + // v1.18.6 (W9) — every section now renders inside the shared + // ``, which emits the one visible heading + subtitle + // each page must show (the section bodies no longer carry their own). const body = SectionComponent ? ( - + + + ) : ( ); diff --git a/src/app/settings/illness/page.tsx b/src/app/settings/illness/page.tsx new file mode 100644 index 000000000..b6b37b890 --- /dev/null +++ b/src/app/settings/illness/page.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Loader2 } from "lucide-react"; + +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; +import { useTranslations } from "@/lib/i18n/context"; +import { ModuleSettingsFrame } from "@/components/module-list/module-settings-frame"; +import { IllnessSettings } from "@/components/illness/illness-settings"; + +/** + * v1.18.6 (W8 / MOD-03) — the Illness module's own settings page. Dedicated + * static route (wins over `/settings/[section]`); reached from the wrench + * beside the Illness page's "hinzufügen" button. + */ +export default function IllnessSettingsPage() { + const { isLoading } = useAuth(); + const mounted = useMounted(); + const { t } = useTranslations(); + + if (!mounted || isLoading) { + return ( +
+ +
+ ); + } + + return ( + + + + ); +} diff --git a/src/app/settings/labs/page.tsx b/src/app/settings/labs/page.tsx new file mode 100644 index 000000000..5805b5fa9 --- /dev/null +++ b/src/app/settings/labs/page.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Loader2 } from "lucide-react"; + +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; +import { useTranslations } from "@/lib/i18n/context"; +import { ModuleSettingsFrame } from "@/components/module-list/module-settings-frame"; +import { LabsSettings } from "@/components/labs/labs-settings"; + +/** + * v1.18.6 (W8 / MOD-03 + MOD-04) — the Labs module's own settings page. + * Dedicated static route (wins over `/settings/[section]`); reached from the + * wrench beside the Labs page's "hinzufügen" button. + */ +export default function LabsSettingsPage() { + const { isLoading } = useAuth(); + const mounted = useMounted(); + const { t } = useTranslations(); + + if (!mounted || isLoading) { + return ( +
+ +
+ ); + } + + return ( + + + + ); +} diff --git a/src/app/settings/vorsorge/page.tsx b/src/app/settings/vorsorge/page.tsx new file mode 100644 index 000000000..147b3d51f --- /dev/null +++ b/src/app/settings/vorsorge/page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { Loader2 } from "lucide-react"; + +import { useAuth } from "@/hooks/use-auth"; +import { useMounted } from "@/hooks/use-mounted"; +import { useTranslations } from "@/lib/i18n/context"; +import { ModuleSettingsFrame } from "@/components/module-list/module-settings-frame"; +import { VorsorgeSettings } from "@/components/measurement-reminders/vorsorge-settings"; + +/** + * v1.18.6 (W8 / MOD-03) — the Vorsorge module's own settings page. + * + * A dedicated static route that wins over the dynamic `/settings/[section]` + * router (static segments take precedence in Next.js), so it adds a + * per-module settings surface without touching the parallel-phase-owned + * `section-slugs` registry or `settings-shell`. Reached from the wrench + * beside the Vorsorge page's "hinzufügen" button. + */ +export default function VorsorgeSettingsPage() { + const { isLoading } = useAuth(); + const mounted = useMounted(); + const { t } = useTranslations(); + + if (!mounted || isLoading) { + return ( +
+ +
+ ); + } + + return ( + + + + ); +} diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index 70924d726..4b44785cf 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -18,6 +18,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { Bell, + Blocks, Database, FileText, Info, @@ -39,6 +40,7 @@ import { cn } from "@/lib/utils"; import { scrollBehaviorForUser } from "@/lib/motion"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; +import { SectionNavHeadingSpacer } from "@/components/settings/section-nav-heading-spacer"; import { isAdminSectionSlug, type AdminSectionSlug } from "./section-slugs"; interface AdminSection { @@ -82,6 +84,13 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [ titleKey: "admin.section.coach.title", icon: Sparkles, }, + // v1.18.6 (W9) — server-wide module availability split out of Coach into + // its own entry, sitting next to it. + { + slug: "module-availability", + titleKey: "admin.section.module-availability.title", + icon: Blocks, + }, { slug: "feedback", titleKey: "admin.section.feedback.title", @@ -276,6 +285,9 @@ export function AdminShell({ active, children }: AdminShellProps) { className="hidden md:block" >
+ {/* v1.18.6 (W9) — align the first nav item with the top of the + first card, matching the settings shell. */} +
  • { "sleepShort", "stepsShort", "vo2MaxShort", - "bpInTargetShort", ]; // Resolved at test time so the assertion runs against the current diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index aef13400e..c26b57a24 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -233,6 +233,23 @@ interface HealthChartProps { * idempotent; non-dashboard mounts simply omit it. */ onDataReady?: () => void; + /** + * v1.18.6 — pre-fetched daily series, keyed by `MeasurementType`. When + * the dashboard batches every visible chart's series into ONE + * `/api/measurements/series-batch` round-trip (audit finding #2), it + * passes each chart its slice here. When supplied, the chart's queryFn + * consumes these rows for the covered types instead of issuing its own + * per-type `/api/measurements` fetch. Omitted on non-dashboard mounts + * (insights sub-pages, mini cards) which keep self-fetching. + * SLEEP_DURATION is never pre-fetched (it rides the per-night `/series` + * adapter), so a sleep chart self-fetches that branch regardless. + * + * The rows must already be scoped to the chart's active fetch window; + * the prop is keyed into the query cache so a range-tab change (wider + * window) re-fetches cleanly rather than blending a batched slice with + * a self-fetched one. + */ + preloadedSeries?: Record; } interface ChartDataPoint { @@ -251,8 +268,13 @@ interface MeasurementApiRow { // path (non-cumulative metrics only). Drive the Apple-Health-style // range band shaded around the mean line. Absent on the raw-row path // (windows ≤ 7 days) and on cumulative metrics. - minValue?: number; - maxValue?: number; + // v1.18.6 — `null` accepted alongside `undefined` so the batched + // series endpoint's row shape (which carries `number | null`) feeds + // `preloadedSeries` directly. The bucketing loop already guards each + // with a `typeof … === "number"` check, so a null is ignored exactly + // like an absent value. + minValue?: number | null; + maxValue?: number | null; // v1.4.43 W2-CHART-GATE — the rollup / daily-aggregate paths return // the underlying raw-row count per bucket. Used downstream to gate // the "more days needed" empty-state copy on the actual measurement @@ -537,6 +559,7 @@ export function HealthChart({ onVisibleStats, titleIcon, onDataReady, + preloadedSeries, }: HealthChartProps) { const { isAuthenticated, user } = useAuth(); const { t, locale } = useTranslations(); @@ -620,6 +643,20 @@ export function HealthChart({ }; }, [rangePoints, effectiveCompareBaseline]); + // v1.18.6 — honour the dashboard's batched slice ONLY for the + // daily-aggregate window (`windowDays > 7`, the rollup path) and ONLY + // when EVERY non-sleep type the chart renders is present in the slice. + // SLEEP_DURATION is never batched (per-night `/series`), so a chart + // that includes it always self-fetches. A range-tab change to a ≤7-day + // window (raw rows) drops batched coverage and self-fetches. + const usePreloaded = useMemo(() => { + if (!preloadedSeries) return false; + if (fetchWindow.windowDays <= 7) return false; + return types.every( + (type) => type === "SLEEP_DURATION" || preloadedSeries[type] != null, + ); + }, [preloadedSeries, fetchWindow.windowDays, types]); + const { data, isLoading, isError, refetch } = useQuery({ // v1.4.40 W-RSC — route the chart-data key through `queryKeys.chartData` // so the `["chart-data"]` prefix lands in `measurementDependentKeys` @@ -649,6 +686,14 @@ export function HealthChart({ // v1.7.0 — re-key when the display scale changes so a km/h chart // never reads a m/s-scaled sibling out of the cache. valueScale, + // v1.18.6 — discriminate a batched-slice render from a self-fetch + // ONLY on the dashboard mounts that pass `preloadedSeries`. When the + // prop is absent (insights sub-pages, mini cards) the empty string + // keeps the factory tuple byte-identical with the pre-v1.18.6 + // 8-element key, so those callers' cache layout is unchanged. On a + // dashboard mount the discriminator flips "preloaded" → "fetch" when + // a range-tab change drops batched coverage, re-fetching cleanly. + preloadedSeries ? (usePreloaded ? "preloaded" : "fetch") : "", ), // v1.4.28 FB-D2 — cache the bounded window for a minute so tab // navigation between insights sub-pages does not re-fire every @@ -760,16 +805,30 @@ export function HealthChart({ typeParams.set("source", "rollup"); } - // v1.16.8 — a failed per-type fetch rejects the whole query - // instead of silently dropping the series: the swallowed-catch - // variant cached the partial/empty result as fresh success, so - // an outage painted the empty state (or a half chart) with no - // retry path. Rejecting lets TanStack's retry semantics and the - // in-card error state below do their jobs. - const data = await apiGet<{ measurements?: MeasurementApiRow[] }>( - `/api/measurements?${typeParams}`, - ); - const page = data?.measurements ?? []; + // v1.18.6 — when the dashboard handed this chart a batched slice + // for the type, fold those rows instead of a per-type fetch. The + // rows came from `/api/measurements/series-batch`, which reads + // through the SAME rollup path the per-type daily fetch hits, so + // the bucketed result is byte-identical. `usePreloaded` already + // gated the window (`> 7`) + per-type coverage above; default to + // an empty slice (paints the no-data card) rather than fetching, + // so a covered chart never issues a network request. + const page = + usePreloaded && preloadedSeries + ? (preloadedSeries[type] ?? []) + : await (async () => { + // v1.16.8 — a failed per-type fetch rejects the whole + // query instead of silently dropping the series: the + // swallowed-catch variant cached the partial/empty result + // as fresh success, so an outage painted the empty state + // (or a half chart) with no retry path. Rejecting lets + // TanStack's retry semantics and the in-card error state + // below do their jobs. + const data = await apiGet<{ + measurements?: MeasurementApiRow[]; + }>(`/api/measurements?${typeParams}`); + return data?.measurements ?? []; + })(); for (const measurement of page) { // v1.7.0 — fold the display-time scale into the raw value at diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 35e831be1..381316336 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useTranslations } from "@/lib/i18n/context"; +import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger"; import { cn } from "@/lib/utils"; import { CycleRing } from "./cycle-ring"; import { BbtChart } from "./bbt-chart"; @@ -139,20 +140,26 @@ export function CycleView() {
    -

    +

    {t("cycle.title")}

    {t("cycle.subtitle")}

    - +
    + + +
    {/* Desktop: ring (left) + tabs (right). Single column below lg. */} diff --git a/src/components/dashboard/dashboard-gates.ts b/src/components/dashboard/dashboard-gates.ts new file mode 100644 index 000000000..3b74ff113 --- /dev/null +++ b/src/components/dashboard/dashboard-gates.ts @@ -0,0 +1,119 @@ +/** + * v1.18.6 — dashboard first-paint / skeleton-reservation gates. + * + * Extracted verbatim from `src/app/page.tsx` so the page component reads + * as a thinner orchestrator and these pure helpers (already unit-tested) + * live in one cohesive module. `page.tsx` re-exports them so the existing + * `../page` test imports stay valid. + */ +import type { DashboardLayout } from "@/lib/dashboard-layout"; + +/** + * v1.7.0 — first-paint gate for the dashboard tile strip. + * + * `primaryLoading` must come from whichever query actually drives the + * tiles: the snapshot cell by default, the slim analytics cell when + * `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false`. A disabled TanStack query reports + * `isLoading: false` (idle fetch status), so keying off the wrong + * source flashes the empty state for the whole fetch. Pure + exported + * so the gate has direct unit coverage without mounting the page. + */ +export function resolveDashboardFirstPaintGate(input: { + trendCardCount: number; + chartCount: number; + configuredTileCount: number; + primaryLoading: boolean; +}): { showTileStripSkeleton: boolean; showEmptyState: boolean } { + const showTileStripSkeleton = + input.trendCardCount === 0 && + input.primaryLoading && + input.configuredTileCount > 0; + const showEmptyState = + input.trendCardCount === 0 && + input.chartCount === 0 && + !showTileStripSkeleton && + !input.primaryLoading; + return { showTileStripSkeleton, showEmptyState }; +} + +/** + * v1.16.8 — widget ids that actually paint a tile in the strip on the + * web dashboard, mirrored from the per-id render blocks below. + * `medications` / `recentWorkouts` / `achievements` carry NO strip tile + * (chart-row cards only), and the iOS-pin-only ids have no web render + * path at all — counting any of them over-reserved the loading + * silhouette and made the strip reshuffle when the data landed. + */ +const TILE_CAPABLE_WIDGET_IDS = new Set([ + "weight", + "bp", + "pulse", + "bodyFat", + "mood", + "sleep", + "steps", + "glucose", + "bpInTarget", + "vo2Max", +]); + +/** + * v1.16.8 — silhouette count for the tile-strip skeleton: one card per + * tile-capable, tile-visible widget. `bp` paints TWO tiles (sys + dia, + * see the render block below) so it counts double; `glucose` can fan + * out to one tile per logged context, but the contexts are unknown + * until the snapshot lands, so it reserves one card (the sane floor). + * Pure + exported for direct unit coverage. + */ +export function resolveConfiguredTileCount(layout: DashboardLayout): number { + let count = 0; + for (const widget of layout.widgets) { + if (!TILE_CAPABLE_WIDGET_IDS.has(widget.id)) continue; + if (!(widget.tileVisible ?? widget.visible)) continue; + count += widget.id === "bp" ? 2 : 1; + } + return count; +} + +/** + * v1.16.8 — widget ids with a chart-row surface on the web dashboard, + * mirrored from the `charts[]` entries below. The achievements + + * recent-workouts cards stay out: they self-skeleton, carry no + * chart-shaped footprint, and gate on `layoutResolved`. + */ +const CHART_CAPABLE_WIDGET_IDS = new Set([ + "weight", + "bp", + "pulse", + "bodyFat", + "mood", + "sleep", + "steps", + "medications", + // v1.18.2 — Vorsorge preventive-care summary card (chart-row only). + "vorsorge", +]); + +/** + * v1.16.8 — expected chart-row card count while the snapshot is still + * in flight. Every chart gate below needs `count > 0` from snapshot + * data, so the cold page used to render NO chart row at all and then + * grow ~1000 px when the snapshot landed. The best available signal + * before data arrives is the layout config (the user's saved layout + * when cached, the default otherwise): one card per chart-visible + * widget, plus the BMI card that rides the weight gate when the + * profile carries a height. Pure + exported for direct unit coverage. + */ +export function resolveChartRowPlaceholderCount( + layout: DashboardLayout, + opts?: { hasHeightCm?: boolean }, +): number { + let count = 0; + for (const widget of layout.widgets) { + if (!CHART_CAPABLE_WIDGET_IDS.has(widget.id)) continue; + if (!widget.visible) continue; + count += 1; + if (widget.id === "weight" && opts?.hasHeightCm) count += 1; + } + return count; +} diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index 79aa15331..54ce87021 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -23,6 +23,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useTranslations } from "@/lib/i18n/context"; +import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; import { getHourForTimeZone } from "@/components/dashboard/range-display"; @@ -96,6 +97,8 @@ export function DashboardHeader({ ) : null}
    + {/* v1.18.6 — re-enter the guided module tour from the dashboard. */} + {/* Customize shortcut to the dashboard-customization settings (the tile/layout editor at /settings/dashboard). Sits to the left of the add button as a monochrome ghost icon, with diff --git a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx index 451b8af0a..85c65068c 100644 --- a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx +++ b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx @@ -6,6 +6,7 @@ import { join } from "node:path"; import { I18nProvider } from "@/lib/i18n/context"; import { makeFormatters } from "@/lib/format-locale"; import type { DashboardSnapshot } from "@/lib/dashboard/snapshot"; +import { buildTargetBands } from "@/lib/dashboard/snapshot"; import type { MedsTodayBlock } from "@/lib/dashboard/meds-today"; import type { DataSummary } from "@/lib/analytics/trends"; import { DashboardHero } from "../dashboard-hero"; @@ -92,6 +93,11 @@ function baseSnapshot( layout: { version: 1, widgets: [] }, layoutCatalogue: [], metricStates: {}, + targetBands: buildTargetBands({ + dateOfBirth: null, + gender: null, + heightCm: null, + }), tiles: { summaries: {}, lastSeenByType: {}, diff --git a/src/components/data-list/sortable-head.tsx b/src/components/data-list/sortable-head.tsx index c02c0ac12..44730b408 100644 --- a/src/components/data-list/sortable-head.tsx +++ b/src/components/data-list/sortable-head.tsx @@ -32,7 +32,7 @@ export function SortableHead({ + onEdit(episode)} + onResolve={ + active && !isChronic ? () => onResolve(episode.id) : undefined + } + resolving={resolving} + /> +
    + + + ); + } + return ( @@ -169,6 +225,8 @@ function EpisodeGroup({ onEdit, onResolve, resolving, + view, + collapsible = false, }: { title: string; parents: IllnessEpisodeDTO[]; @@ -177,26 +235,74 @@ function EpisodeGroup({ onEdit: (episode: IllnessEpisodeDTO) => void; onResolve: (id: string) => void; resolving: boolean; + view: "cards" | "list"; + /** + * v1.18.6 (MOD-05) — the resolved group collapses by default behind a + * disclosure trigger so a long recovered history never pushes the active + * conditions below the fold. + */ + collapsible?: boolean; }) { + // Hooks must run unconditionally; bail on the empty render afterwards. + const [open, setOpen] = useState(false); + const gridId = useId(); if (parents.length === 0) return null; + + const grid = ( +
    + {parents.map((episode) => ( + + ))} +
    + ); + + if (collapsible) { + return ( +
    + + {open ? grid : null} +
    + ); + } + return (

    {title}

    -
    - {parents.map((episode) => ( - - ))} -
    + {grid}
    ); } @@ -205,6 +311,7 @@ export function IllnessView() { const { t } = useTranslations(); const { data: episodes, isLoading } = useIllnessEpisodes(true); const resolve = useResolveEpisode(); + const { prefs } = useModuleListPrefs("illness"); const today = todayLocal(); const [newOpen, setNewOpen] = useState(false); @@ -247,8 +354,14 @@ export function IllnessView() { } } - return { activeParents, resolvedParents, childrenByParent }; - }, [episodes]); + return { + // v1.18.6 (MOD-03) — honour the user's persisted manual order within + // each status group (ids absent from the order sort after the block). + activeParents: applyOrder(activeParents, prefs.order, (e) => e.id), + resolvedParents: applyOrder(resolvedParents, prefs.order, (e) => e.id), + childrenByParent, + }; + }, [episodes, prefs.order]); const hasEpisodes = (episodes?.length ?? 0) > 0; @@ -256,23 +369,49 @@ export function IllnessView() {
    -

    +

    {t("illness.title")}

    {t("illness.subtitle")}

    - +
    + + {/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the + Illness settings page (view + reorder). */} + + {/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every + other module, not the bespoke "neue Episode". */} + +
    -

    {t("illness.disclaimer")}

    + {/* v1.18.6 (DISC-01) — the per-page medical disclaimer line is removed; + the one-time acknowledgment now lives at onboarding and the legal + text stays reachable on the public privacy page. */} {isLoading ? (
    @@ -290,6 +429,7 @@ export function IllnessView() { parents={grouped.activeParents} childrenByParent={grouped.childrenByParent} resolving={resolve.isPending} + view={prefs.view} onLogDay={(id) => setLogEpisodeId(id)} onEdit={(e) => setEditEpisode(e)} onResolve={(id) => resolve.mutate(id)} @@ -299,6 +439,8 @@ export function IllnessView() { parents={grouped.resolvedParents} childrenByParent={grouped.childrenByParent} resolving={resolve.isPending} + view={prefs.view} + collapsible onLogDay={(id) => setLogEpisodeId(id)} onEdit={(e) => setEditEpisode(e)} onResolve={(id) => resolve.mutate(id)} diff --git a/src/components/insights/__tests__/device-score-surfaces.test.tsx b/src/components/insights/__tests__/device-score-surfaces.test.tsx index 73ae8e968..0018b2cab 100644 --- a/src/components/insights/__tests__/device-score-surfaces.test.tsx +++ b/src/components/insights/__tests__/device-score-surfaces.test.tsx @@ -206,7 +206,7 @@ describe(" data-gating", () => { // Routed through the shared (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"'); + expect(html).not.toContain('data-slot="recovery-block-DAY_STRAIN"'); }); it("renders a canonical chart block for the present signal", () => { @@ -218,13 +218,18 @@ describe(" data-gating", () => { }), ); const html = render(); - expect(html).toContain('data-slot="recovery-group-strain"'); + // v1.18.6 — the per-group sub-headings are gone; present blocks render as + // one flat stack. The data gate now reads at the block level. expect(html).toContain('data-slot="recovery-block-DAY_STRAIN"'); // B3 — the redundant "Recovery score" cross-link block is gone; this // surface is reached from the overview already. expect(html).not.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"'); + // The recharge group's only signal (ANS charge) has no data → its block + // is dropped. + expect(html).not.toContain('data-slot="recovery-block-ANS_CHARGE"'); + // The removed sub-headings must not reappear. + expect(html).not.toContain('data-slot="recovery-group-strain"'); + expect(html).not.toContain("Strain & load"); // Empty note must not show when at least one signal is present. expect(html).not.toContain('data-slot="recovery-empty"'); }); diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx index ade8459ed..af2a04019 100644 --- a/src/components/insights/__tests__/health-score-card.test.tsx +++ b/src/components/insights/__tests__/health-score-card.test.tsx @@ -192,7 +192,11 @@ describe("", () => { expect(moodSliceMatch?.[1]).toBe("—"); }); - it("renders the disclaimer", () => { + it("v1.4.27 B1 — no longer mounts an inline Ask-the-Coach button (hero strip carries the CTA)", () => { + // The hero strip's existing "Ask the coach" action covers this + // surface; the card-internal duplicate button retired. v1.18.6 (L13) + // dropped the now-dead `onAskCoach` prop too — the card renders no + // Coach affordance at all. const html = ssr( ", () => { delta={null} />, ); - expect(html).toMatch(/data-slot="health-score-card-disclaimer"/); - expect(html).toContain("Indicative"); - }); - - it("v1.4.27 B1 — no longer mounts an inline Ask-the-Coach button (hero strip carries the CTA)", () => { - // The hero strip's existing "Ask the coach" action covers this - // surface; the card-internal duplicate button retired. The - // onAskCoach prop stays on the type signature so callers don't - // break, but the card no longer renders it. - const omitted = ssr( - , - ); - const supplied = ssr( - {}} - />, - ); - expect(omitted).not.toContain('data-slot="health-score-card-ask-coach"'); - expect(supplied).not.toContain('data-slot="health-score-card-ask-coach"'); + expect(html).not.toContain('data-slot="health-score-card-ask-coach"'); }); @@ -239,13 +216,11 @@ describe("", () => { band="green" components={baseComponents} delta={5} - onAskCoach={() => {}} />, "de", ); expect(html).toContain("Gesundheitsscore"); expect(html).toContain("im Vergleich zur Vorwoche"); - expect(html).toContain("Orientierungshilfe"); // v1.4.27 B1 — the inline Coach button retired; hero strip carries // the action now. expect(html).not.toContain("Coach fragen"); @@ -308,27 +283,6 @@ describe("", () => { expect(cardOpen?.[0]).toContain("flex-col"); }); - it("pins the disclaimer to the bottom of the stretched card via mt-auto", () => { - // Without `mt-auto` the disclaimer rides directly under the - // provenance accordion and leaves the recovered space below the - // disclaimer instead of between the body and the footer. The - // FB-H2 contract is "score number on top, disclaimer on bottom, - // recovered space in the middle". - const html = ssr( - , - ); - const disclaimerOpen = html.match( - /]*data-slot="health-score-card-disclaimer"[^>]*>/, - ); - expect(disclaimerOpen).not.toBeNull(); - expect(disclaimerOpen?.[0]).toContain("mt-auto"); - }); - // v1.4.37 W4a item 1 — the inner column switched from `flex // flex-col gap-3` to a 7-row grid so the slack collects on row 6 // (the provenance accordion) instead of clumping under the diff --git a/src/components/insights/__tests__/insight-status-card.test.tsx b/src/components/insights/__tests__/insight-status-card.test.tsx index 6ecabe3fc..d55bf0954 100644 --- a/src/components/insights/__tests__/insight-status-card.test.tsx +++ b/src/components/insights/__tests__/insight-status-card.test.tsx @@ -80,7 +80,11 @@ describe("", () => { text="ignored when hasProvider is false" />, ); - expect(html).toContain("Assessment unavailable."); + // v1.18.6 — the no-provider tile is a guided-setup explainer + a + // Settings link, not a bare "unavailable" line. + expect(html).toContain("Connect an AI provider"); + expect(html).toContain("Open AI settings"); + expect(html).toContain("/settings/ai"); expect(html).not.toContain("ignored when hasProvider is false"); }); diff --git a/src/components/insights/__tests__/sleep-hypnogram.test.tsx b/src/components/insights/__tests__/sleep-hypnogram.test.tsx index a04a0ea17..6452cd205 100644 --- a/src/components/insights/__tests__/sleep-hypnogram.test.tsx +++ b/src/components/insights/__tests__/sleep-hypnogram.test.tsx @@ -127,6 +127,30 @@ describe(" — timeline bar visibility", () => { expect(render(session)).toContain('role="img"'); }); + it("suppresses the timeline bar for a SYNTHESISED (reconstructed) night", () => { + // WHOOP / Polar report per-stage TOTALS and the server lays the segments + // out in a fixed physiological order — the timeline would invent a phase + // progression that was never measured. So even with distinct, well-timed + // spans, a `reconstructed` night drops the bar and keeps the breakdown. + const session: SleepHypnogramSession = { + ...baseSession, + source: "WHOOP", + reconstructed: true, + }; + const html = render(session); + expect(html).not.toContain('role="img"'); + expect(html).toContain('data-slot="sleep-hypnogram-breakdown"'); + expect(html).toContain('data-slot="sleep-hypnogram-estimate-note"'); + }); + + it("keeps the timeline bar for a measured (non-reconstructed) night", () => { + // An Apple Health night carries real per-stage onsets, so the stepped bar + // is genuine and stays. The estimate note never appears. + const html = render({ ...baseSession, reconstructed: false }); + expect(html).toContain('role="img"'); + expect(html).not.toContain('data-slot="sleep-hypnogram-estimate-note"'); + }); + it("collapses to the breakdown footer for a degenerate single-instant session", () => { // Every segment stamped on ONE instant — no real timeline, so only the // breakdown footer, no bar. diff --git a/src/components/insights/__tests__/trends-row.test.tsx b/src/components/insights/__tests__/trends-row.test.tsx index 05f33aaa0..34e1fe848 100644 --- a/src/components/insights/__tests__/trends-row.test.tsx +++ b/src/components/insights/__tests__/trends-row.test.tsx @@ -284,4 +284,22 @@ describe("", () => { expect(html).toContain("Moderate confidence"); expect(html).toContain("Low confidence"); }); + + // ── v1.18.6 — detail-page cross-links (the Insights→steps gap) ────── + it("links each metric card to its detail page", () => { + const html = render(); + // The default BP / weight / mood triple each link to their sub-page. + expect(html).toContain('data-slot="trends-row-detail-link"'); + expect(html).toContain('href="/insights/blood-pressure"'); + expect(html).toContain('href="/insights/weight"'); + expect(html).toContain('href="/insights/mood"'); + }); + + it("offers the steps detail link when the briefing flags steps", () => { + // Steps has a routed sub-page but the overview never linked to it; a + // briefing finding on steps now charts it AND drills into /insights/steps. + const html = render(); + expect(html).toMatch(/data-metric="steps"/); + expect(html).toContain('href="/insights/steps"'); + }); }); diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index fc6cea16c..e09507a2f 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -662,13 +662,20 @@ describe("errorCodeToI18nKey", () => { for (const code of [ "coach.provider.unavailable", "coach.provider.empty", - "coach.provider.none", "coach.stream", ]) { expect(errorCodeToI18nKey(code)).toBe("insights.coach.errorProvider"); } }); + // v1.18.6 — a "no provider configured anywhere" turn is a setup gap, + // not a transient failure, so it carries its own guided-setup copy. + it("maps the no-provider case to its dedicated guided-setup copy", () => { + expect(errorCodeToI18nKey("coach.provider.none")).toBe( + "insights.coach.errorNoProvider", + ); + }); + // v1.4.43 QoL (M6) — `coach.network` no longer collapses to the // generic provider copy; the user needs the actionable offline hint. it("maps coach.network to the dedicated offline key", () => { diff --git a/src/components/insights/coach-panel/coach-drawer-body.tsx b/src/components/insights/coach-panel/coach-drawer-body.tsx index f59461b02..1c677b86f 100644 --- a/src/components/insights/coach-panel/coach-drawer-body.tsx +++ b/src/components/insights/coach-panel/coach-drawer-body.tsx @@ -78,7 +78,18 @@ export function CoachDrawerBody({

    {t("insights.coach.historyTitle")}

    -
    {historyRail}
    + {/* v1.18.6 (CCH-01) — the wrapping `overflow-y-auto` div here + was the broken left scrollbar: `` is itself a + `flex h-full flex-col` with its OWN `overflow-y-auto` list, + so this nested it inside a second scroll container. The + outer container scrolled the whole rail (search field + included) against an `h-full` child whose height resolved to + its content, leaving the inner list's `flex-1 min-h-0` + without a bounded track — the scrollbar rendered against an + indeterminate height and read as half-broken. Drop the outer + scroll: the rail keeps a fixed search header and its single + internal list-scroll is the only scroll region. */} +
    {historyRail}
    )} diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 59f0755e2..6dae240d0 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -1,6 +1,7 @@ "use client"; import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react"; +import Link from "next/link"; import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; import { @@ -168,9 +169,14 @@ export function errorCodeToI18nKey(code: string): string { // (auth-class failure). The next action is "reconnect", not "try // again later", so it carries its own copy pointing at Settings. return "insights.coach.errorCredentialExpired"; + case "coach.provider.none": + // v1.18.6 — no AI provider is configured anywhere, so "try again + // in a moment" is the wrong instruction (it will 422 forever). Send + // the user to guided setup with a one-line explainer + a Settings + // link rendered alongside the bubble copy. + return "insights.coach.errorNoProvider"; case "coach.provider.unavailable": case "coach.provider.empty": - case "coach.provider.none": case "coach.stream": return "insights.coach.errorProvider"; default: @@ -357,6 +363,13 @@ export function MessageThread({ data-slot="coach-message-thread" className={cn( "flex h-full flex-col gap-4 overflow-y-auto px-4 py-4 sm:px-6", + // v1.18.6 (CCH-01) — on the now full-width page surface a thread + // stretched edge-to-edge sprawled the prose to an unreadable + // measure. Centre the content on a comfortable max width (Claude- + // like) via an `mx-auto` inner gutter; the scrollbar still rides + // the surface edge. The drawer surface is already narrow, so the + // cap only bites on the wide page surface. + "[&>*]:mx-auto [&>*]:w-full [&>*]:max-w-3xl", "scroll-smooth", )} > @@ -613,6 +626,19 @@ function ChatBubble({ {safeError && content && (

    {safeError}

    )} + {/* v1.18.6 — a "no provider configured anywhere" turn is a + setup gap, not a transient failure: surface a direct link to + Settings → AI so the Coach guides the user into BYOK / local + setup instead of inviting an endless retry. */} + {errorCode === "coach.provider.none" && ( + + {t("insights.coach.errorNoProviderAction")} + + )} {/* v1.12.0 — collapse the whole provenance block ("what was included") behind one disclosure, collapsed by default. The source chips used to render fully expanded above the diff --git a/src/components/insights/glucose/glucose-advanced-disclosure.tsx b/src/components/insights/glucose/glucose-advanced-disclosure.tsx index 5be77f4ed..66d74d63d 100644 --- a/src/components/insights/glucose/glucose-advanced-disclosure.tsx +++ b/src/components/insights/glucose/glucose-advanced-disclosure.tsx @@ -63,7 +63,7 @@ export function GlucoseAdvancedDisclosure({ aria-expanded={open} aria-controls={regionId} data-slot="glucose-advanced-toggle" - className="text-muted-foreground hover:text-foreground flex min-h-11 w-full items-center justify-between gap-2 text-sm font-medium transition-colors" + className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex min-h-11 w-full items-center justify-between gap-2 rounded-sm text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none" > {t("insights.bloodGlucose.clinical.advanced.toggle")} +
    + +
    ); @@ -225,11 +229,34 @@ export function GlucoseClinicalPanel() { byContext={byContext} /> ) : null} + + ); } +/** + * v1.18.6 — signpost the measurement-vs-lab mental model. This panel + * estimates A1c / GMI from spot or CGM readings over time; a lab-ordered + * HbA1c or fasting-glucose result is a dated panel value that lives under + * Labs. The cross-link keeps the two surfaces referencing each other so a + * user knows which door to use rather than treating them as rivals. + */ +function LabsCrossLink() { + const { t } = useTranslations(); + return ( + +
    ); diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index 9b71bd770..025c01545 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -90,6 +90,12 @@ interface HeroStripProps { band: HealthScoreCardProps["band"]; components: HealthScoreCardProps["components"]; delta: HealthScoreCardProps["delta"]; + /** + * v1.18.6 — value-free Rest Mode annotation off the analytics + * payload. When present + active the score card states it is paused + * during illness (suppressed, not penalised). + */ + restMode?: { active: boolean } | null; } | null; /** * v1.16.8 — true while the analytics payload that carries @@ -346,16 +352,7 @@ export function HeroStrip({ components={healthScore.components} delta={healthScore.delta} moodEnabled={moodEnabled} - onAskCoach={ - // v1.4.37 W5 — short-circuit the prop drilling even - // though `HealthScoreCard` retired its inline button in - // v1.4.27. A future re-addition can't accidentally - // surface a Coach affordance from this card while the - // operator has the flag off. - coachEnabled && onAskCoach - ? (prefill: string) => onAskCoach(prefill) - : undefined - } + restModeActive={healthScore.restMode?.active ?? false} /> )}
diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx index b0a5b2dfc..a4682ed07 100644 --- a/src/components/insights/insight-status-card.tsx +++ b/src/components/insights/insight-status-card.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import Link from "next/link"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; @@ -144,14 +145,21 @@ export function InsightStatusCard({ if (!hasProvider) { return ( - + - +

{t("insights.noProviderConfigured")}

+ + {t("insights.noProviderAction")} +
); diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index d6b7cacb8..027f9dd02 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -3,7 +3,6 @@ import { useMemo, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; -import { useTranslations } from "@/lib/i18n/context"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; @@ -52,7 +51,6 @@ interface ComprehensivePayload { } export function InsightsLayoutShell({ children }: { children: ReactNode }) { - const { t } = useTranslations(); const { isAuthenticated, user } = useAuth(); // v1.4.33 F18 — gate the advisor POST on the operator's assistant // feature flag. Pre-fix, every /insights mount fired POST @@ -160,24 +158,11 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { modules={user?.modules} /> {children} - {/* v1.12.6 — SINGLE SOURCE of the generic Insights disclaimer. - The "describes your own data, not a clinical assessment / diagnosis" - sentence used to repeat across the overview, the metric tiles, the - mood page, and the medications page. It now lives here once, as a - page-level footer under every `/insights/*` route. Metric-SPECIFIC - caveats (health score, correlations, mood relations, derived cards, - rhythm events) stay on their own cards — those convey real, - non-generic information. Other waves remove the generic line from the - overview / mood / medications pages; this footer is where it lives. - v1.18.0 — Coach moved to the standalone `/coach` route (its own - disclaimer), so this footer now renders under every `/insights/*` - route unconditionally. */} -

- {t("insights.disclaimer.footer")} -

+ {/* v1.18.6 (DISC-01) — the page-level generic medical disclaimer is + removed. The "HealthLog is a private tracking tool, not a clinical + assessment or diagnosis" acknowledgment now lives once at the start + of onboarding, and the legal text stays reachable on the public + privacy page. */}
); } diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index 3897468e7..67c74ab16 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -19,6 +19,7 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { useTranslations } from "@/lib/i18n/context"; +import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger"; import { cn } from "@/lib/utils"; import { prefersReducedMotion } from "@/lib/charts/reduced-motion"; import { @@ -656,6 +657,7 @@ function InsightsTabStripImpl({ return (

{explainer} diff --git a/src/components/insights/recovery/recovery-section.tsx b/src/components/insights/recovery/recovery-section.tsx index 9fa4ba138..701442858 100644 --- a/src/components/insights/recovery/recovery-section.tsx +++ b/src/components/insights/recovery/recovery-section.tsx @@ -14,7 +14,6 @@ import { useInsightsAnalytics } from "@/hooks/use-insights-analytics"; import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import { useTranslations } from "@/lib/i18n/context"; import { EmptyState } from "@/components/ui/empty-state"; -import { SectionHeading } from "@/components/insights/section-heading"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { RestModeBanner } from "@/components/insights/rest-mode-banner"; import type { RestModeAnnotation } from "@/lib/analytics/health-score"; @@ -36,9 +35,6 @@ interface BlockSpec { interface BlockGroup { key: string; - headingIcon: typeof Activity; - title: string; - subtitle: string; blocks: BlockSpec[]; } @@ -84,9 +80,6 @@ export function RecoverySection() { const groups: BlockGroup[] = [ { key: "recharge", - headingIcon: Battery, - title: t("insights.recovery.recharge.title"), - subtitle: t("insights.recovery.recharge.subtitle"), blocks: [ { type: "ANS_CHARGE", @@ -103,9 +96,6 @@ export function RecoverySection() { }, { key: "strain", - headingIcon: Gauge, - title: t("insights.recovery.strain.title"), - subtitle: t("insights.recovery.strain.subtitle"), blocks: [ { type: "DAY_STRAIN", @@ -143,9 +133,6 @@ export function RecoverySection() { }, { key: "cardio", - headingIcon: HeartPulse, - title: t("insights.recovery.cardio.title"), - subtitle: t("insights.recovery.cardio.subtitle"), blocks: [ { type: "AVERAGE_HEART_RATE", @@ -190,10 +177,6 @@ export function RecoverySection() { if (isLoading || summaries == null) { return (

-
); @@ -210,51 +193,36 @@ export function RecoverySection() { ); } + // v1.18.6 — the per-group sub-headings ("Recharge" / "Strain & load" / + // "Daily heart & energy") are gone: read next to the page's "Erholung" + // heading they were redundant chrome. The groups still drive ORDER + the + // data gate (a group with no present block contributes nothing); their + // present blocks now render as one flat, evenly-spaced stack, each block + // carrying its own title + explainer so nothing loses context. + const presentBlocks = groups.flatMap((group) => + group.blocks.filter((block) => (summaries[block.type]?.count ?? 0) > 0), + ); + return (
{/* v1.18.1 — calm Rest Mode cue at the head of the recovery surface. Self-gating: renders nothing unless an episode is active. */} - {groups.map((group) => { - const present = group.blocks.filter( - (block) => (summaries[block.type]?.count ?? 0) > 0, - ); - if (present.length === 0) return null; - return ( -
- - {group.subtitle} -

- } - /> -
- {present.map((block) => ( - - ))} -
-
- ); - })} + {presentBlocks.map((block) => ( + + ))}
); } diff --git a/src/components/insights/sleep-hypnogram.tsx b/src/components/insights/sleep-hypnogram.tsx index 27b341252..7e6797140 100644 --- a/src/components/insights/sleep-hypnogram.tsx +++ b/src/components/insights/sleep-hypnogram.tsx @@ -58,6 +58,17 @@ export interface SleepHypnogramSession { awakenings: number; stages: Record; segments: SleepHypnogramSegment[]; + /** + * `true` when the winning source has no measured per-stage onset + * timestamps and the server synthesised a contiguous timeline in a fixed + * physiological order (WHOOP / Polar). The "timeline" of such a night is + * not a real hypnogram — it would read as one long Core block followed by + * one Deep block, never the phase-to-phase progression a measured night + * shows — so we never draw the misleading stepped bar for it and fall back + * to the per-stage breakdown alone. A real-series source (Apple Health, + * Withings, Fitbit) is `false` and keeps the timeline. + */ + reconstructed?: boolean; } export interface SleepHypnogramProps { @@ -216,10 +227,16 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) { // breakdown footer. (Gating on the END instant instead hid the bar for an // ordinary multi-stage night whose segments happened to share a right // edge — the regression this restores.) + // A SYNTHESISED night (WHOOP / Polar) has no measured stage onsets — the + // server laid its segments out in a fixed physiological order, so drawing + // the stepped bar would invent a phase progression that was never recorded + // (the "Core block then Deep block, no real breakdown" the bar reads as). + // For those we keep the per-stage breakdown + totals, which ARE real, and + // drop the misleading timeline entirely. Real-series sources keep the bar. const hasTrack = + session.reconstructed !== true && spans.length > 0 && - (spans.length === 1 || - new Set(spans.map((s) => s.x1)).size > 1); + (spans.length === 1 || new Set(spans.map((s) => s.x1)).size > 1); const hasBreakdown = breakdown.length > 0; return ( @@ -251,6 +268,18 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) { })} ) : null} + {/* When the source reports stage TOTALS but no measured onsets + (WHOOP / Polar) the stepped timeline is suppressed — drawing one + would invent a phase order that was never recorded. Surface a calm + note so the breakdown below doesn't read as a missing chart. */} + {session.reconstructed === true && hasBreakdown ? ( + + {t("insights.sleep.hypnogram.estimateNote")} + + ) : null}
diff --git a/src/components/insights/sleep/__tests__/sleep-quality-assessment.test.ts b/src/components/insights/sleep/__tests__/sleep-quality-assessment.test.ts new file mode 100644 index 000000000..ec27b8be4 --- /dev/null +++ b/src/components/insights/sleep/__tests__/sleep-quality-assessment.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; + +import { buildSleepQualityAssessment } from "../sleep-quality-assessment"; + +/** + * v1.18.6 — the deterministic sleep-quality grading that backs the grounded + * "Einschätzung" when no AI narrative exists. Pins the band thresholds and the + * worst-grade-wins overall, and the "skip ungradable metrics" contract. + */ +describe("buildSleepQualityAssessment", () => { + it("grades efficiency on the clinical 90 / 85 / 75 floors", () => { + expect( + buildSleepQualityAssessment([{ type: "SLEEP_EFFICIENCY", value: 92 }]) + ?.lead.grade, + ).toBe("excellent"); + expect( + buildSleepQualityAssessment([{ type: "SLEEP_EFFICIENCY", value: 86 }]) + ?.lead.grade, + ).toBe("good"); + expect( + buildSleepQualityAssessment([{ type: "SLEEP_EFFICIENCY", value: 78 }]) + ?.lead.grade, + ).toBe("fair"); + expect( + buildSleepQualityAssessment([{ type: "SLEEP_EFFICIENCY", value: 60 }]) + ?.lead.grade, + ).toBe("low"); + }); + + it("grades 0–100 scores on the 90 / 85 / 70 split", () => { + expect( + buildSleepQualityAssessment([{ type: "SLEEP_SCORE", value: 95 }])?.lead + .grade, + ).toBe("excellent"); + expect( + buildSleepQualityAssessment([{ type: "SLEEP_SCORE", value: 72 }])?.lead + .grade, + ).toBe("fair"); + }); + + it("opens on the headline score and keeps worst-grade-wins overall", () => { + const a = buildSleepQualityAssessment([ + { type: "SLEEP_CONSISTENCY", value: 60 }, + { type: "SLEEP_SCORE", value: 95 }, + { type: "SLEEP_EFFICIENCY", value: 92 }, + ]); + expect(a?.lead.type).toBe("SLEEP_SCORE"); + // The low consistency pulls the overall down even though the score is high. + expect(a?.overall).toBe("low"); + expect(a?.rest.length).toBe(2); + }); + + it("skips metrics it has no recognised band for, and returns null on none", () => { + // SLEEP_NEED (minutes) + disturbance count are not gradable here. + expect( + buildSleepQualityAssessment([ + { type: "SLEEP_NEED", value: 480 }, + { type: "SLEEP_DISTURBANCE_COUNT", value: 3 }, + ]), + ).toBeNull(); + }); + + it("ignores non-finite values", () => { + expect( + buildSleepQualityAssessment([{ type: "SLEEP_SCORE", value: Number.NaN }]), + ).toBeNull(); + }); +}); diff --git a/src/components/insights/sleep/__tests__/sleep-quality-grounded-note.test.tsx b/src/components/insights/sleep/__tests__/sleep-quality-grounded-note.test.tsx new file mode 100644 index 000000000..f02cfc52d --- /dev/null +++ b/src/components/insights/sleep/__tests__/sleep-quality-grounded-note.test.tsx @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { SleepQualityGroundedNote } from "../sleep-quality-grounded-note"; +import type { DataSummary } from "@/lib/analytics/trends"; + +/** + * v1.18.6 — the grounded sleep-quality note that fills the assessment slot + * when no AI narrative is available. Pins that it builds substantive copy from + * the user's own averages and that it self-suppresses when the AI is present. + */ +function summary(avg30: number): DataSummary { + return { + count: 30, + latest: avg30, + avg7: avg30, + avg30, + } as unknown as DataSummary; +} + +function render(node: React.ReactNode, locale: "en" | "de" = "en") { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + const summaries = { + SLEEP_SCORE: summary(82), + SLEEP_EFFICIENCY: summary(88), + }; + + it("renders a substantive, grounded assessment when the AI is absent", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="sleep-quality-grounded-note"'); + // The headline score (82 → fair on the 90/85/70 split) leads the read. + expect(html).toContain("overall sleep score"); + expect(html).toContain("fair"); + expect(html).toContain("82"); + }); + + it("renders nothing when the AI assessment is present", () => { + const html = render( + , + ); + expect(html).toBe(""); + }); + + it("renders nothing when no quality metric is gradable", () => { + const html = render( + , + ); + expect(html).toBe(""); + }); +}); diff --git a/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx index 7e1dc23ae..ffa8e2572 100644 --- a/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx +++ b/src/components/insights/sleep/__tests__/sleep-rhythm-cards.test.tsx @@ -110,6 +110,14 @@ describe("", () => { expect(html).toContain("04:30"); }); + it("renders the band as a labelled top-right corner readout, not a badge", () => { + const html = render(); + // The labelled corner carries both the "Chronotype" label and the value. + expect(html).toContain('data-slot="chronotype-corner"'); + expect(html).toContain("Chronotype"); + expect(html).toContain("Intermediate type"); + }); + it("inherits the standard Card rhythm (no compact override)", () => { const learning = render(); const ready = render(); diff --git a/src/components/insights/sleep/chronotype-card.tsx b/src/components/insights/sleep/chronotype-card.tsx index 1932e7b92..e7ddfa94e 100644 --- a/src/components/insights/sleep/chronotype-card.tsx +++ b/src/components/insights/sleep/chronotype-card.tsx @@ -5,7 +5,6 @@ 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"; @@ -67,31 +66,44 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) { return ( -
- - - {t("insights.sleep.chronotype.title")} - + {/* The resolved chronotype rides the top-right corner as a labelled + readout — a small "Chronotyp" label over the band value — rather + than a badge buried in the body. The title stays left; the value + is the thing the eye should land on first. */} +
+
+ + + {t("insights.sleep.chronotype.title")} + +
+
+ + {t("insights.sleep.chronotype.cornerLabel")} + + + {bandLabel} + +
-
- - {bandLabel} - - {msfClock && ( -

- {t("insights.sleep.chronotype.midSleepCaption", { - clock: msfClock, - })} -

- )} -
+ {msfClock && ( +

+ {t("insights.sleep.chronotype.midSleepCaption", { + clock: msfClock, + })} +

+ )} {showAdvanced && ( -
+
{chronotype.socialJetlagMinutes != null && (() => { const { hours, minutes } = hoursMinutes( diff --git a/src/components/insights/sleep/sleep-quality-assessment.ts b/src/components/insights/sleep/sleep-quality-assessment.ts new file mode 100644 index 000000000..b2799a3d3 --- /dev/null +++ b/src/components/insights/sleep/sleep-quality-assessment.ts @@ -0,0 +1,136 @@ +/** + * v1.18.6 — deterministic, data-grounded sleep-quality assessment. + * + * The AI "Einschätzung" under the sleep-quality block reads "no assessment + * yet / unavailable" until a provider has run (and never at all on an account + * with no AI provider). That left the most universally useful read — the one + * the user's OWN numbers already support — blank. This pure helper turns the + * sleep-quality summaries that the section already has in hand into a short, + * grounded sentence: it names the night-over-night averages and grades them + * against recognised reference ranges, with NO provider call and NO recompute + * of a stored value (it only reads the server-authoritative averages). + * + * Grading thresholds are the conventional consumer-sleep-platform bands: + * - Sleep efficiency: ≥ 85 % is the textbook "good sleeper" floor (AASM); + * ≥ 90 % is excellent, < 75 % is poor. + * - Performance / consistency / headline score: 0–100, graded on the + * standard 85 / 70 split most rings use (good / fair / low). + * The copy is intentionally cautious — it describes, it never diagnoses. + * + * Kept free of React + i18n so the grading is unit-testable in isolation; the + * component maps the returned shape onto localised strings. + */ + +export type QualityGrade = "excellent" | "good" | "fair" | "low"; + +export interface SleepQualityMetricRead { + /** Registry/measurement type, e.g. `SLEEP_EFFICIENCY`. */ + type: string; + /** The averaged value the section already holds (avg30 ?? avg7 ?? latest). */ + value: number; +} + +export interface SleepQualityFinding { + type: string; + value: number; + grade: QualityGrade; +} + +export interface SleepQualityAssessment { + /** The lead metric the assessment opens on (best-known quality signal). */ + lead: SleepQualityFinding; + /** The remaining graded metrics, most-informative first. */ + rest: SleepQualityFinding[]; + /** Worst grade across the present metrics — drives the closing nudge. */ + overall: QualityGrade; +} + +/** Percent-scaled 0–100 metrics graded on the 90 / 85 / 70 ring split. */ +const PERCENT_METRICS = new Set([ + "SLEEP_SCORE", + "SLEEP_PERFORMANCE", + "SLEEP_CONSISTENCY", +]); + +/** Efficiency uses the clinical 90 / 85 / 75 floors rather than the ring split. */ +function gradeEfficiency(pct: number): QualityGrade { + if (pct >= 90) return "excellent"; + if (pct >= 85) return "good"; + if (pct >= 75) return "fair"; + return "low"; +} + +function gradeScore(value: number): QualityGrade { + if (value >= 90) return "excellent"; + if (value >= 85) return "good"; + if (value >= 70) return "fair"; + return "low"; +} + +/** + * Grade one quality metric. Returns null for a metric the helper has no + * recognised band for (e.g. raw disturbance counts or sleep-need minutes), + * so the assessment never grades a value against a scale it does not fit. + */ +function gradeMetric(type: string, value: number): QualityGrade | null { + if (type === "SLEEP_EFFICIENCY") return gradeEfficiency(value); + if (PERCENT_METRICS.has(type)) return gradeScore(value); + return null; +} + +/** Worst grade wins — a single low signal pulls the overall read down. */ +const GRADE_RANK: Record = { + excellent: 3, + good: 2, + fair: 1, + low: 0, +}; + +/** + * Lead-metric preference: the headline score reads first when present, then + * efficiency, then performance, then consistency. Keeps the opening sentence + * anchored on the most holistic signal the night carries. + */ +const LEAD_ORDER = [ + "SLEEP_SCORE", + "SLEEP_EFFICIENCY", + "SLEEP_PERFORMANCE", + "SLEEP_CONSISTENCY", +] as const; + +/** + * Build a deterministic assessment from the present quality metrics. Returns + * null when none of the supplied metrics is gradable, so the caller falls back + * to its existing copy rather than printing an empty read. + */ +export function buildSleepQualityAssessment( + reads: readonly SleepQualityMetricRead[], +): SleepQualityAssessment | null { + const findings: SleepQualityFinding[] = []; + for (const r of reads) { + if (!Number.isFinite(r.value)) continue; + const grade = gradeMetric(r.type, r.value); + if (grade == null) continue; + findings.push({ type: r.type, value: r.value, grade }); + } + if (findings.length === 0) return null; + + const leadIndex = (type: string): number => { + const i = LEAD_ORDER.indexOf(type as (typeof LEAD_ORDER)[number]); + return i === -1 ? Number.MAX_SAFE_INTEGER : i; + }; + const sorted = [...findings].sort( + (a, b) => leadIndex(a.type) - leadIndex(b.type), + ); + + const overall = sorted.reduce( + (worst, f) => (GRADE_RANK[f.grade] < GRADE_RANK[worst] ? f.grade : worst), + "excellent", + ); + + return { + lead: sorted[0], + rest: sorted.slice(1), + overall, + }; +} diff --git a/src/components/insights/sleep/sleep-quality-grounded-note.tsx b/src/components/insights/sleep/sleep-quality-grounded-note.tsx new file mode 100644 index 000000000..11a1000a3 --- /dev/null +++ b/src/components/insights/sleep/sleep-quality-grounded-note.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { Moon } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { TileHeader } from "@/components/insights/tile-header"; +import type { DataSummary } from "@/lib/analytics/trends"; +import { + buildSleepQualityAssessment, + type QualityGrade, + type SleepQualityFinding, +} from "./sleep-quality-assessment"; + +/** + * v1.18.6 — the grounded sleep-quality "Einschätzung". + * + * The AI assessment under the sleep-quality block reads "no assessment yet" + * until a provider has run, and stays blank forever on an account with no AI + * provider. This card fills that gap with a substantive read built ENTIRELY + * from the user's own quality averages (efficiency / performance / consistency + * / headline score) graded against recognised reference bands — no provider, + * no recompute of a stored value. It describes; it does not diagnose. + * + * Mounted only when the AI assessment is NOT present (`showWhenAiAbsent`), so + * the richer AI narrative still wins when it exists and the user never sees + * two assessments stacked. + */ + +const QUALITY_TYPES = [ + "SLEEP_SCORE", + "SLEEP_EFFICIENCY", + "SLEEP_PERFORMANCE", + "SLEEP_CONSISTENCY", +] as const; + +/** Map a finding to its localised "{metric} is {grade} at {value}" clause. */ +function clauseFor( + finding: SleepQualityFinding, + t: (key: string, vars?: Record) => string, +): string { + const metricLabel = t(`insights.sleepQuality.assessment.metric.${finding.type}`); + const gradeLabel = t(`insights.sleepQuality.assessment.grade.${finding.grade}`); + // Efficiency / performance / consistency are percentages; the headline score + // is a 0–100 index. Both read naturally as a rounded integer. + const value = Math.round(finding.value); + return t("insights.sleepQuality.assessment.clause", { + metric: metricLabel, + grade: gradeLabel, + value, + }); +} + +export interface SleepQualityGroundedNoteProps { + /** The analytics summaries the section already holds (no extra fetch). */ + summaries: Record | undefined; + /** + * Whether the AI assessment is absent. The grounded note only renders when + * the AI narrative is not there, so the two never stack. + */ + showWhenAiAbsent: boolean; +} + +export function SleepQualityGroundedNote({ + summaries, + showWhenAiAbsent, +}: SleepQualityGroundedNoteProps) { + const { t } = useTranslations(); + + if (!showWhenAiAbsent || !summaries) return null; + + const reads = QUALITY_TYPES.map((type) => { + const s = summaries[type]; + const value = s?.avg30 ?? s?.avg7 ?? s?.latest ?? null; + return value == null + ? null + : ({ type, value } as { type: string; value: number }); + }).filter((r): r is { type: string; value: number } => r !== null); + + const assessment = buildSleepQualityAssessment(reads); + if (!assessment) return null; + + const clauses = [assessment.lead, ...assessment.rest].map((f) => + clauseFor(f, t), + ); + const closing = t( + `insights.sleepQuality.assessment.closing.${assessment.overall as QualityGrade}`, + ); + + return ( + + + + + +

+ {clauses.join(" ")} {closing} +

+
+
+ ); +} + +function QualityNoteIcon({ className }: { className?: string }) { + return
- } - enabled={hasSleepScore} - /> + ); } + +/** + * The quality block's "Einschätzung". The richer AI narrative wins when it + * exists; until then (or forever, on an account with no AI provider) a + * deterministic note grounded in the user's own quality averages fills the + * slot, so the assessment is never the bare "no analysis yet" line. + * + * Both reads are gated on the headline sleep score having data — the same gate + * the AI route used — so the assessment only surfaces with a quality series to + * describe. The grounded note reads the SLEEP_SCORE status query (already + * warmed by ``) to know whether the AI text is absent, so it + * adds no second round-trip and the two never stack. + */ +function SleepQualityAssessmentBlock({ + summaries, +}: { + summaries: Record; +}) { + const hasSleepScore = (summaries.SLEEP_SCORE?.count ?? 0) > 0; + const { data: status, isLoading: statusLoading } = useInsightMetricStatus( + "SLEEP_SCORE", + hasSleepScore, + ); + + // The AI narrative is "present" when there is text OR the worker is actively + // assembling one (preparing / revalidating). In those cases the AI card owns + // the slot. The grounded note only steps in once the status query has SETTLED + // with no narrative — so it never flashes ahead of a warm AI assessment. + const aiAbsent = + !statusLoading && + !status?.text && + status?.preparing !== true && + status?.revalidating !== true; + + return ( + <> + } + // Suppress the AI card's static "no analysis yet" / "no provider" + // empty states — the grounded note covers that ground with real + // content. The AI card still renders its text / preparing states. + enabled={hasSleepScore && !aiAbsent} + /> + + + ); +} diff --git a/src/components/insights/sub-page-shell.tsx b/src/components/insights/sub-page-shell.tsx index b2d71f045..d658f3de9 100644 --- a/src/components/insights/sub-page-shell.tsx +++ b/src/components/insights/sub-page-shell.tsx @@ -3,11 +3,16 @@ import { useEffect, useRef, type ReactNode } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { ListOrdered } from "lucide-react"; +import { Info, ListOrdered } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { CoachLaunchButton } from "@/components/insights/coach-launch-button"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { TargetAdjustButton } from "@/components/insights/target-adjust-button"; import { TargetAdjustProvider } from "@/lib/insights/target-adjust-context"; import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route"; @@ -102,13 +107,12 @@ export interface SubPageShellProps { /** * v1.8.6 — opt-in Coach launch in the page header. * - * When true, the shell mounts an icon-only `` at - * the top-right of the header, vertically aligned with the heading, so - * "Coach fragen" reads as a header action rather than a foot-of-page - * button. Every routed category page passes it; the mother page and - * empty-state branches omit it. The button self-gates on the Coach - * feature flag + per-user opt-out, so a disabled-Coach tenant shows - * nothing. + * v1.18.6 (CCH-04) — RETIRED as a rendered control. With the Coach FAB + * anchored bottom-right on every authenticated page, the per-metric + * Coach icon was a redundant second entry point, so the shell no longer + * paints anything for it. The prop stays on the signature (every routed + * category page still passes it) to avoid a churny rename across ~14 + * call sites; it is intentionally inert. */ coachLaunch?: boolean; /** @@ -133,7 +137,10 @@ export function SubPageShell({ focusOnMount = false, statStrip, diversityNudge, - coachLaunch = false, + // v1.18.6 (CCH-04) — `coachLaunch` is intentionally not destructured: + // the prop is still accepted on the type (call sites pass it) but the + // shell no longer renders a Coach control. The FAB is the single Coach + // entry point app-wide. showAllValuesType, children, }: SubPageShellProps) { @@ -264,7 +271,12 @@ export function SubPageShell({ ) : null} - {coachLaunch ? : null} + {/* v1.18.6 (CCH-04) — the per-metric Coach launch icon is + gone. With the Coach FAB anchored bottom-right on every + authenticated page, a second Coach affordance in each + metric header was redundant. The `coachLaunch` prop is + still accepted (every metric page passes it) but no longer + paints a control — the FAB is the single Coach entry. */} {/* v1.16.8 — the v1.16.4 customise cog is gone: the sticky tab strip above already carries the same control on every insights surface, so the header copy was a duplicate. */} @@ -291,6 +303,33 @@ export function SubPageShell({ className="text-muted-foreground text-sm leading-relaxed" > {t(`insights.subPage.explainer.${explainerMetric}Body`)} + {/* v1.18.6 — a discreet general-guidance tooltip travels with + every metric definition. The reference bands in the body + above are population anchors, not medical advice; this + caption pins that framing without crowding the prose. */} + + + + + + + {t("insights.subPage.explainer.referenceGuidance")} + + +

) : null} {description && !explainerMetric ? ( diff --git a/src/components/insights/suggested-prompts.tsx b/src/components/insights/suggested-prompts.tsx index 5fe2abe2f..85b57b39a 100644 --- a/src/components/insights/suggested-prompts.tsx +++ b/src/components/insights/suggested-prompts.tsx @@ -85,14 +85,14 @@ export function SuggestedPrompts({ type="button" onClick={() => onPick(prompt)} data-slot="insights-suggested-prompts-chip" - // v1.8.5 W4a — denser chips. The tap target stays comfortable - // (`min-h-9` ≈ 36 px, the WCAG/iOS floor) but the horizontal - // padding + type size drop a notch so the two prompts pack into - // fewer rows and the left column stops towering over the - // Health-Score card. + // v1.8.5 W4a — denser chips. The horizontal padding + type size + // drop a notch so the two prompts pack into fewer rows and the + // left column stops towering over the Health-Score card. The tap + // target clears the 44 px iOS/WCAG floor on phones (`min-h-11`) + // and relaxes to the denser 36 px from `sm` up. className={cn( "border-dracula-purple/18 hover:border-dracula-purple/40 hover:text-foreground", - "text-muted-foreground inline-flex min-h-9 items-center gap-1.5", + "text-muted-foreground inline-flex min-h-11 items-center gap-1.5 sm:min-h-9", "rounded-full border bg-transparent px-3 py-1.5 text-xs", "transition-colors focus-visible:ring-2 focus-visible:outline-none", "focus-visible:ring-dracula-purple/50", diff --git a/src/components/insights/trends-row.tsx b/src/components/insights/trends-row.tsx index 5b9bcac4b..5bc1c51b3 100644 --- a/src/components/insights/trends-row.tsx +++ b/src/components/insights/trends-row.tsx @@ -1,7 +1,8 @@ "use client"; import dynamic from "next/dynamic"; -import { TrendingUp } from "lucide-react"; +import Link from "next/link"; +import { ArrowRight, TrendingUp } from "lucide-react"; import { useTranslations } from "@/lib/i18n/context"; import { useAuth } from "@/hooks/use-auth"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; @@ -301,6 +302,22 @@ export function TrendsRow({ text={t(config.captionKey)} /> )} + {/* v1.18.6 — drill-in link to the metric's detail page. The + overview charted these metrics but never offered a way INTO + their detail pages — notably the steps page, which exists and + is used but was unreachable from Insights. The link sits below + the caption so the chart keeps its own interactions. */} + {config.detailHref ? ( + + {t("insights.trendsRow.viewDetail", { metric: title })} +
); })} diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index d53b69a68..7c31e6351 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -20,6 +20,7 @@ import { formatReferenceRange } from "@/lib/labs/reference-range"; import { formatLabValue } from "@/lib/labs/format-value"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; +import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs"; import { LabTrendSparkline } from "./lab-trend-sparkline"; import { ReferenceRangeBadge } from "./reference-range-badge"; @@ -79,6 +80,7 @@ function groupReadings(results: LabResultDto[]): MarkerGroup[] { export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { const { t } = useTranslations(); + const { prefs } = useModuleListPrefs("labs"); const listKey = queryKeys.labResultsList({ biomarkerId: undefined, @@ -96,10 +98,18 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { apiGet("/api/labs?limit=500&sortDir=desc"), }); - const groups = useMemo( - () => groupReadings(data?.results ?? []), - [data?.results], - ); + const groups = useMemo(() => { + const base = groupReadings(data?.results ?? []); + // v1.18.6 (MOD-04) — honour the user's Labs sort choice. `groupReadings` + // already returns most-recent-first; `recentAsc` reverses, and `manual` + // applies the persisted biomarker order (legacy un-linked groups, which + // carry no biomarkerId, sort after the ordered block). + if (prefs.sortDir === "recentAsc") return [...base].reverse(); + if (prefs.sortDir === "manual") { + return applyOrder(base, prefs.order, (g) => g.biomarkerId ?? g.key); + } + return base; + }, [data?.results, prefs.sortDir, prefs.order]); // The list caps at 500 rows server-side (the `limit` ceiling). Surface a // calm "showing latest N of M" hint when the cap truncates so the count is @@ -149,6 +159,65 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { ); } + // v1.18.6 (MOD-03) — compact list view: one bordered card holding tight + // divided rows instead of a card per biomarker. The card view stays the + // default; this is the denser alternative the settings toggle selects. + if (prefs.view === "list") { + return ( +
+ {truncated ? ( +

+ {t("labs.showingLatestOf", { shown, total })} +

+ ) : null} + + + {groups.map((group) => { + const row = ( +
+
+
+ + {group.analyte} + + +
+
+ + {formatLabValue(group.latest.value)}{" "} + {group.latest.unit} + + {formatDate(group.latest.takenAt)} +
+
+
+ r.value)} + /> + {group.biomarkerId ? ( + + ) : null} +
+
+ ); + return group.biomarkerId ? ( + + {row} + + ) : ( +
{row}
+ ); + })} +
+
+
+ ); + } + return (
{truncated ? ( diff --git a/src/components/labs/labs-settings.tsx b/src/components/labs/labs-settings.tsx new file mode 100644 index 000000000..176dec090 --- /dev/null +++ b/src/components/labs/labs-settings.tsx @@ -0,0 +1,115 @@ +"use client"; + +/** + * v1.18.6 (W8 / MOD-03 + MOD-04) — the Labs module's own settings page body. + * + * Reached from the wrench beside the page's "hinzufügen" button. Lets the + * user: + * - pick the card-vs-list page view (MOD-03), + * - choose the sort order (by most-recent reading, descending / ascending, + * or manual) — MOD-04, + * - reorder biomarkers when sort = manual (MOD-03/04), + * - and create / edit / delete biomarkers via the existing + * `BiomarkerManager` (MOD-04). + * View / sort / order persist client-side via `useModuleListPrefs("labs")`. + */ +import { useQuery } from "@tanstack/react-query"; + +import { NativeSelect } from "@/components/ui/native-select"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; +import { apiGet } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; +import { ModuleViewToggle } from "@/components/module-list/module-view-toggle"; +import { + ModuleOrderEditor, + type ReorderItem, +} from "@/components/module-list/module-order-editor"; +import { + applyOrder, + useModuleListPrefs, + type ModuleSortDir, +} from "@/lib/module-list-prefs"; + +import { BiomarkerManager } from "./biomarker-manager"; +import type { BiomarkerDto, BiomarkerListResponse } from "./types"; + +export function LabsSettings() { + const { t } = useTranslations(); + const { prefs, setView, setOrder, setSortDir } = useModuleListPrefs("labs"); + + const { data, isLoading } = useQuery({ + queryKey: queryKeys.biomarkers(), + queryFn: () => apiGet("/api/biomarkers"), + }); + const markers: BiomarkerDto[] = data?.biomarkers ?? []; + + const ordered = applyOrder(markers, prefs.order, (m) => m.id); + const reorderItems: ReorderItem[] = ordered.map((m) => ({ + id: m.id, + name: m.name, + secondary: m.panel ? `${m.unit} · ${m.panel}` : m.unit, + })); + + const manual = prefs.sortDir === "manual"; + + return ( +
+
+
+

+ {t("moduleList.viewHeading")} +

+ +
+

+ {t("moduleList.viewDescription")} +

+
+ +
+

{t("labs.sort.heading")}

+
+ + setSortDir(e.target.value as ModuleSortDir)} + > + + + + +

+ {t("labs.sort.hint")} +

+
+
+ + {manual ? ( +
+

+ {t("moduleList.reorder.heading")} +

+ {isLoading ? ( +
+ {Array.from({ length: 3 }, (_, i) => ( + + ))} +
+ ) : ( + + )} +
+ ) : null} + +
+

+ {t("labs.biomarker.manageTitle")} +

+ +
+
+ ); +} diff --git a/src/components/layout/__tests__/offline-banner.test.tsx b/src/components/layout/__tests__/offline-banner.test.tsx index a70a35ae9..af43498ce 100644 --- a/src/components/layout/__tests__/offline-banner.test.tsx +++ b/src/components/layout/__tests__/offline-banner.test.tsx @@ -13,6 +13,7 @@ import { join } from "node:path"; import { describe, it, expect } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; import { OfflineBanner } from "../offline-banner"; @@ -27,8 +28,14 @@ function loadMessages(locale: string): { offlineBanner: { message: string } } { } function render(node: React.ReactNode, locale: "en" | "de" = "en") { + // v1.18.6 — the banner now reads the cached dashboard snapshot to decide + // whether to append the "showing last synced data" hint, so it needs a + // QueryClient in context. + const queryClient = new QueryClient(); return renderToStaticMarkup( - {node}, + + {node} + , ); } diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index d95f9a2bc..8e3eba3da 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -7,7 +7,9 @@ import { AchievementUnlockNotifier } from "@/components/gamification/achievement import { MaintainershipBanner } from "@/components/i18n/maintainership-banner"; import { LayoutCoachFab } from "@/components/insights/layout-coach-fab"; import { LayoutCoachMount } from "@/components/insights/layout-coach-mount"; +import { TourLauncher } from "@/components/onboarding/tour-launcher"; import { useAuth } from "@/hooks/use-auth"; +import { clearOfflineCachesForSessionEnd } from "@/lib/pwa/query-persister"; import { useTranslations } from "@/lib/i18n/context"; import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context"; import { BottomNav } from "./bottom-nav"; @@ -91,6 +93,13 @@ export function AuthShell({ useEffect(() => { if (!isLoading && !isAuthenticated && !isPublicPage) { + // Session END (expired / invalidated cookie → `/api/auth/me` 401). Wipe + // every client-side cache that can hold the previous account's health + // data before bouncing to login, so it never leaks to the next account + // on a shared device. Logout does the same from `useLogout`; this covers + // the expiry path that logout never runs through. Best-effort; the + // redirect proceeds regardless. + void clearOfflineCachesForSessionEnd(); router.replace("/auth/login"); } }, [isLoading, isAuthenticated, isPublicPage, router]); @@ -272,6 +281,13 @@ export function AuthShell({
+ {/* v1.18.6 — the module-tour launcher lives at the shell level so its + overlay survives the cross-page `router.push`es the tour makes. It + self-gates: it only auto-opens on the dashboard for a user who has + not finished/dismissed the tour, and otherwise renders null. Stays + out of demo mode (the tour PATCHes `/api/onboarding/tour`, which the + proxy blocks for demo visitors). */} + {!demoMode && } {/* v1.16.13 — the Coach FAB stays hidden in demo mode. Its send hits `/api/insights/chat`, which is not in the proxy's `DEMO_MUTATION_ALLOWLIST`, so a demo visitor who tapped it and sent diff --git a/src/components/layout/offline-banner.tsx b/src/components/layout/offline-banner.tsx index 23c0cff15..f95a247ec 100644 --- a/src/components/layout/offline-banner.tsx +++ b/src/components/layout/offline-banner.tsx @@ -2,8 +2,10 @@ import { useEffect, useState } from "react"; import { WifiOff } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; /** * v1.4.43 QoL (M5) — slim banner that surfaces "you're offline" while @@ -25,6 +27,7 @@ import { useTranslations } from "@/lib/i18n/context"; */ export function OfflineBanner() { const { t } = useTranslations(); + const queryClient = useQueryClient(); // Initialise to `true` so the banner stays hidden during SSR + the // first client render. The effect below flips it true/false after // mount based on `navigator.onLine`. (We do NOT read navigator on @@ -62,6 +65,12 @@ export function OfflineBanner() { if (isOnline) return null; + // v1.18.6 — when an offline install is showing last-synced data (the SW's + // allowlisted read cache + the IndexedDB query persister hydrated the + // dashboard snapshot), say so honestly rather than implying a blank screen. + const hasCachedData = + queryClient.getQueryData(queryKeys.dashboardSnapshot()) != null; + return (
); } diff --git a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx index bc60d3b41..748b7999e 100644 --- a/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx +++ b/src/components/measurement-reminders/__tests__/vorsorge-section.test.tsx @@ -73,9 +73,10 @@ describe(" loading + empty", () => { expect(html).not.toContain('data-slot="vorsorge-loading"'); }); - it("renders a 'Log value' action for a measurement-linked reminder", () => { - // v1.18.2 — a typed reminder's primary action opens the value-entry - // form, surfaced as a "Log value" button (not a silent checkmark). + it("renders a 'Measure now' action for a measurement-linked reminder", () => { + // v1.18.2 — a typed reminder's primary action opens the value-entry form. + // v1.18.6 (MOD-06) — surfaced as a green "Measure now" button (a + // measurement is not an intake), not a silent checkmark. const reminder: MeasurementReminder = { id: "linked", label: "Measure blood pressure", @@ -95,7 +96,7 @@ describe(" loading + empty", () => { } as MeasurementReminder; remindersMock.mockReturnValue({ data: [reminder], isLoading: false }); const html = render(); - expect(html).toContain("Log value"); + expect(html).toContain("Measure now"); expect(html).not.toContain(">Done<"); }); diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx index eb814c05b..fef044321 100644 --- a/src/components/measurement-reminders/vorsorge-section.tsx +++ b/src/components/measurement-reminders/vorsorge-section.tsx @@ -29,9 +29,19 @@ * card so the card carries a single med-style kebab). */ import { useState } from "react"; -import { CalendarClock, CheckCircle2, Plus, Settings2 } from "lucide-react"; +import Link from "next/link"; +import { + Activity, + CalendarClock, + CheckCircle2, + Plus, + Wrench, +} from "lucide-react"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs"; +import { ModuleTourTrigger } from "@/components/onboarding/module-tour-trigger"; import { SettingsCardHeader } from "@/components/settings/_card-header"; import { AlertDialog, @@ -58,12 +68,11 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { MoreVertical, Pencil, Trash2 } from "lucide-react"; import { NativeSelect } from "@/components/ui/native-select"; +import { Progress } from "@/components/ui/progress"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Skeleton } from "@/components/ui/skeleton"; -import { Switch } from "@/components/ui/switch"; import { MeasurementForm } from "@/components/measurements/measurement-form"; import { MedicationCardHeader } from "@/components/medications/MedicationCardHeader"; -import { MedicationNextLastSlot } from "@/components/medications/card-parts/medication-next-last-slot"; import { useMeasurementReminders, useMeasurementReminderMutations, @@ -203,15 +212,15 @@ export function VorsorgeSection({ const { data: reminders, isLoading } = useMeasurementReminders(enabled); const { create, update, remove, satisfy } = useMeasurementReminderMutations(); + // v1.18.6 (MOD-03) — page view (cards/list) + manual order persist + // client-side; the settings page writes them, the page reads them. + const { prefs } = useModuleListPrefs("vorsorge"); // `null` = sheet closed; "new" = create; a reminder = edit pre-filled. const [editing, setEditing] = useState( null, ); const [form, setForm] = useState(EMPTY_FORM); - // The customize ("wrench") sheet — re-homes the per-reminder enable - // toggles that no longer live on the card itself. - const [manageOpen, setManageOpen] = useState(false); // The value-capture sheet: holds the reminder whose measurement we are // entering. Non-null ⇒ the MeasurementForm sheet is open for that row. const [capturing, setCapturing] = useState(null); @@ -327,7 +336,8 @@ export function VorsorgeSection({ const cadenceCustom = form.cadenceChoice === CADENCE_CUSTOM; const cadenceRrule = form.cadenceChoice === CADENCE_RRULE; - // The shared primary add-entry affordance — identical in both variants. + // v1.18.6 (MOD-02) — the primary add affordance reads "hinzufügen" like + // every other module's add button. const addButton = ( ); - // v1.18.2 — wrench/customize affordance, mirroring the labs page: a - // ghost icon button beside Add opening the manage sheet. + // v1.18.6 (MOD-01) — the wrench links to the Vorsorge settings page (view, + // reorder, per-reminder enable toggles), left of the Add button — the + // canonical medication-page pattern. The old kebab "Anpassen" sheet is + // gone (MOD-07); its toggles moved to the settings page. const wrenchButton = ( - - - - - - setManageOpen(true)}> - - {t("measurementReminders.manage.menuItem")} - - - + ); return ( @@ -370,6 +377,7 @@ export function VorsorgeSection({

{t("measurementReminders.sectionTitle")} @@ -379,8 +387,9 @@ export function VorsorgeSection({

- {addButton} + {wrenchButton} + {addButton}
) : ( @@ -631,42 +640,6 @@ export function VorsorgeSection({ )} - {/* v1.18.2 — customize sheet: re-homes the per-reminder enable/disable - toggles (moved off the card so it carries a single med-style kebab). */} - - {(reminders?.length ?? 0) === 0 ? ( -

- {t("measurementReminders.manage.empty")} -

- ) : ( -
    - {reminders?.map((reminder) => ( -
  • - - {resolveReminderLabel(reminder, t)} - - - update.mutate({ id: reminder.id, body: { enabled: next } }) - } - disabled={update.isPending} - aria-label={t("measurementReminders.enabledToggleAria")} - /> -
  • - ))} -
- )} -
- {isLoading && (
- {t("measurementReminders.addButton")} + {t("common.add")} } /> )} {!isLoading && (reminders?.length ?? 0) > 0 && ( -
    - {reminders?.map((reminder) => ( -
  • - onPrimaryAction(reminder)} - onRemove={() => remove.mutate(reminder.id)} - onEdit={() => openEdit(reminder)} - busy={ - satisfy.isPending || remove.isPending || update.isPending - } - /> -
  • - ))} +
      + {applyOrder(reminders ?? [], prefs.order, (r) => r.id).map( + (reminder) => ( +
    • + onPrimaryAction(reminder)} + onRemove={() => remove.mutate(reminder.id)} + onEdit={() => openEdit(reminder)} + busy={ + satisfy.isPending || remove.isPending || update.isPending + } + /> +
    • + ), + )}
    )} ); } +/** + * v1.18.6 (MOD-06) — progress through the current interval toward the next + * due date, as a 0–100 percentage. Honest "are we on track" gamification + * that reuses the medication card's `Progress` building block without + * fabricating a streak the reminder data cannot support: 100 % (or beyond) + * means due/overdue now, lower means time still remains in the window. + * Returns null when there is no interval to measure against (RRULE / unset). + */ +function intervalProgress( + reminder: MeasurementReminder, + now: number, +): number | null { + if (reminder.intervalDays == null || reminder.nextDueAt == null) return null; + const dueMs = new Date(reminder.nextDueAt).getTime(); + if (Number.isNaN(dueMs)) return null; + const windowMs = reminder.intervalDays * DAY_MS; + const startMs = dueMs - windowMs; + const elapsed = now - startMs; + return Math.max(0, Math.min(100, Math.round((elapsed / windowMs) * 100))); +} + function VorsorgeCard({ reminder, now, + view, onPrimaryAction, onRemove, onEdit, @@ -729,6 +733,7 @@ function VorsorgeCard({ }: { reminder: MeasurementReminder; now: number; + view: "cards" | "list"; onPrimaryAction: () => void; onRemove: () => void; onEdit: () => void; @@ -748,6 +753,12 @@ function VorsorgeCard({ : ""; const isCoach = reminder.origin === "COACH"; const isLinked = reminder.measurementType != null; + // Due now / overdue ⇒ the action button takes the green "do it now" tone. + // The CARD stays neutral (no tint) per the project rule — only the action + // button goes green; the surface follows the medication-card grammar. + const isDue = + due.key === "nextDue.today" || due.key === "overdueByDays"; + const progress = intervalProgress(reminder, now); // Category-style header badge: the measurement label, or "self-planned" // for a free-text exam — mirroring the medication card's class badge. @@ -755,21 +766,22 @@ function VorsorgeCard({ ? t(`measurementReminders.types.${reminder.measurementType}`) : t("measurementReminders.selfPlanned"); - const stateBadges = - isCoach || !reminder.enabled ? ( - <> - {isCoach && ( - - {t("measurementReminders.originCoach")} - - )} - {!reminder.enabled && ( - - {t("measurementReminders.disabledBadge")} - - )} - - ) : null; + // v1.18.6 (MOD-06) — the cadence renders as a chip beside the item name + // (the med-card cadence-chip convention), alongside the provenance / + // disabled state badges. + const stateBadges = ( + <> + {cadence && {cadence}} + {isCoach && ( + {t("measurementReminders.originCoach")} + )} + {!reminder.enabled && ( + + {t("measurementReminders.disabledBadge")} + + )} + + ); // Single med-style kebab: Edit + Delete. The Delete item is the shared // confirm-on-delete control rendered as a full-width menu row. @@ -803,95 +815,188 @@ function VorsorgeCard({ ); - // Next/last block in the med-card grammar. The "next" value carries the - // discreet neutral due badge + cadence; the "last" value is the last - // satisfied date. - const nextValue = ( - - - {t(`measurementReminders.${due.key}`, { days: due.days })} - - {cadence && {cadence}} - + // v1.18.6 (MOD-06) — the green "Jetzt messen" / mark-done action. Green only + // on the action button (never the card surface). When not due it stays the + // calm default tone so the green reads as "now is the time". `bg-success` + // pairs with the theme-aware `text-success-foreground` (dark glyph in dark, + // white in light) so the label clears WCAG AA in both themes. + const primaryButton = ( + ); + + const deleteDialog = ( + + + + + {t("measurementReminders.deleteConfirmTitle")} + + + {t("measurementReminders.deleteConfirmDescription")} + + + + {t("common.cancel")} + + {t("measurementReminders.delete")} + + + + + ); + + // v1.18.6 (MOD-03) — compact list row variant. + if (view === "list") { + return ( + <> + + +
    +
    + + {resolveReminderLabel(reminder, t)} + + {cadence && {cadence}} +
    +
    + + {t(`measurementReminders.${due.key}`, { days: due.days })} + + {categoryLabel} +
    +
    +
    + + {headerActions} +
    +
    +
    + {deleteDialog} + + ); + } + + // Next/last block in the med-card grammar, but with MEASUREMENT wording + // (MOD-06: never "Einnahme" / intake — a measurement is not an intake). The + // "next" value carries the discreet neutral due badge; the "last" value is + // the last satisfied date. const lastValue = reminder.lastSatisfiedAt ? fmt.date(new Date(reminder.lastSatisfiedAt)) : null; + const nextLastSlot = ( +
    +
    + + {t("measurementReminders.nextDueLabel")} + + + + {t(`measurementReminders.${due.key}`, { days: due.days })} + + +
    + {lastValue && ( +
    + + {t("measurementReminders.lastDoneLabel")} + + {lastValue} +
    + )} +
    + ); return ( <> - {/* NEUTRAL card — no status-driven colour. Due state reads only through - the discreet badge below. Shares the medication card shell + tokens. */} - - - - + {/* NEUTRAL card — no status-driven colour. Due state reads only through + the discreet badge + the green action button below (MOD-06). Shares + the medication card shell + tokens. */} + + + + {nextLastSlot} - {reminder.endsOn && ( -

    - {t("measurementReminders.until", { - date: fmt.date(new Date(reminder.endsOn)), - })} -

    - )} - {reminder.location && ( -

    - {t("measurementReminders.location.prefix", { - location: reminder.location, - })} -

    - )} + {/* v1.18.6 (MOD-06) — light gamification: progress through the + current interval toward next-due, reusing the medication card's + Progress building block. Honest "on track / due" signal without + a fabricated streak; hidden for RRULE / unscheduled reminders. */} + {progress != null && ( +
    +
    + {t("measurementReminders.adherence.label")} + + {isDue + ? t("measurementReminders.adherence.due") + : t("measurementReminders.adherence.onTrack")} + +
    + +
    + )} - {/* Bottom-pinned single primary action — branches on the link state: - a self-planned exam marks done; a measurement-linked reminder - opens the value-entry form. */} -
    - -
    -
    -
    + {reminder.endsOn && ( +

    + {t("measurementReminders.until", { + date: fmt.date(new Date(reminder.endsOn)), + })} +

    + )} + {reminder.location && ( +

    + {t("measurementReminders.location.prefix", { + location: reminder.location, + })} +

    + )} - - - - - {t("measurementReminders.deleteConfirmTitle")} - - - {t("measurementReminders.deleteConfirmDescription")} - - - - {t("common.cancel")} - - {t("measurementReminders.delete")} - - - - + {/* Bottom-pinned single primary action — green when due (MOD-06). */} +
    {primaryButton}
    +
    +
    + + {deleteDialog} ); } diff --git a/src/components/measurement-reminders/vorsorge-settings.tsx b/src/components/measurement-reminders/vorsorge-settings.tsx new file mode 100644 index 000000000..1d2820fa0 --- /dev/null +++ b/src/components/measurement-reminders/vorsorge-settings.tsx @@ -0,0 +1,131 @@ +"use client"; + +/** + * v1.18.6 (W8 / MOD-03) — the Vorsorge module's own settings page body. + * + * Reached from the wrench beside the page's "hinzufügen" button. Lets the + * user reorder reminders + pick the card-vs-list page view, and re-homes the + * per-reminder enable/disable toggles that used to live in the page's + * "Anpassen" sheet. Order + view persist client-side via + * `useModuleListPrefs("vorsorge")`; the toggles persist through the existing + * reminder PATCH route. + */ +import { Card, CardContent } from "@/components/ui/card"; +import { Switch } from "@/components/ui/switch"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useTranslations } from "@/lib/i18n/context"; +import { ModuleViewToggle } from "@/components/module-list/module-view-toggle"; +import { + ModuleOrderEditor, + type ReorderItem, +} from "@/components/module-list/module-order-editor"; +import { applyOrder, useModuleListPrefs } from "@/lib/module-list-prefs"; +import { + useMeasurementReminders, + useMeasurementReminderMutations, + type MeasurementReminder, +} from "@/hooks/use-measurement-reminders"; + +function resolveLabel( + reminder: MeasurementReminder, + t: (key: string) => string, +): string { + if (reminder.origin === "COACH") { + const translated = t(reminder.label); + return translated === reminder.label ? reminder.label : translated; + } + return reminder.label; +} + +export function VorsorgeSettings() { + const { t } = useTranslations(); + const { data: reminders, isLoading } = useMeasurementReminders(true); + const { update } = useMeasurementReminderMutations(); + const { prefs, setView, setOrder } = useModuleListPrefs("vorsorge"); + + const ordered = applyOrder(reminders ?? [], prefs.order, (r) => r.id); + const reorderItems: ReorderItem[] = ordered.map((r) => ({ + id: r.id, + name: resolveLabel(r, t), + secondary: r.measurementType + ? t(`measurementReminders.types.${r.measurementType}`) + : t("measurementReminders.selfPlanned"), + })); + + return ( +
    +
    +
    +

    {t("moduleList.viewHeading")}

    + +
    +

    + {t("moduleList.viewDescription")} +

    +
    + +
    +

    + {t("moduleList.reorder.heading")} +

    + {isLoading ? ( +
    + {Array.from({ length: 3 }, (_, i) => ( + + ))} +
    + ) : ( + + )} +
    + +
    +

    + {t("measurementReminders.manage.title")} +

    +

    + {t("measurementReminders.manage.description")} +

    + {isLoading ? ( + + ) : (reminders?.length ?? 0) === 0 ? ( +

    + {t("measurementReminders.manage.empty")} +

    + ) : ( + + + {ordered.map((reminder) => ( +
    + + {resolveLabel(reminder, t)} + + + update.mutate({ + id: reminder.id, + body: { enabled: next }, + }) + } + disabled={update.isPending} + // L3 — label each row's Switch by its own name span so SR + // users hear the reminder name (plus the switch role's + // on/off state) instead of N identical generic labels. + aria-labelledby={`vorsorge-reminder-label-${reminder.id}`} + /> +
    + ))} +
    +
    + )} +
    +
    + ); +} diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx index 875f561c4..e94561ad5 100644 --- a/src/components/medications/dose-history-ledger.tsx +++ b/src/components/medications/dose-history-ledger.tsx @@ -669,7 +669,7 @@ function LedgerRowItem({ + + + {item.name} + + {item.secondary ? ( + + {item.secondary} + + ) : null} + + + +
+ ); +} diff --git a/src/components/module-list/module-settings-frame.tsx b/src/components/module-list/module-settings-frame.tsx new file mode 100644 index 000000000..4d2959b19 --- /dev/null +++ b/src/components/module-list/module-settings-frame.tsx @@ -0,0 +1,42 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link"; +import { SettingsSectionFrame } from "@/components/settings/settings-section-frame"; + +/** + * v1.18.6 (W8 / MOD-03) — heading frame for the per-module settings pages + * (Vorsorge / Illness / Labs). + * + * Delegates to the shared `SettingsSectionFrame` so every settings page — the + * 19 shell sections and these three per-module pages — renders one identical + * heading frame. These pages stay out of the slug registry (their static + * routes win over `/settings/[section]`), so they drive the frame's explicit + * title/subtitle mode and supply the "return to the module" back-link through + * the frame's `topSlot`, which sits above the heading. + */ +export function ModuleSettingsFrame({ + title, + description, + backHref, + backLabelKey, + children, +}: { + title: string; + description: string; + /** Where the "← back" link returns to — the module's own page. */ + backHref: string; + backLabelKey: string; + children: ReactNode; +}) { + return ( + } + > + {children} + + ); +} diff --git a/src/components/module-list/module-view-toggle.tsx b/src/components/module-list/module-view-toggle.tsx new file mode 100644 index 000000000..2fc25c0cb --- /dev/null +++ b/src/components/module-list/module-view-toggle.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { LayoutGrid, List as ListIcon } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import type { ModuleListView } from "@/lib/module-list-prefs"; + +/** + * v1.18.6 (W8 / MOD-03) — icon-only card/list view toggle shared by the + * Vorsorge / Illness / Labs settings pages. Mirrors the medication module's + * `MedicationViewToggle` grammar (two ghost segments, the active one filled, + * `aria-pressed` per segment) but switches between a card grid and a compact + * list rather than cards-vs-table, so the label set is `moduleList.view*`. + */ +interface ModuleViewToggleProps { + view: ModuleListView; + onChange: (view: ModuleListView) => void; +} + +export function ModuleViewToggle({ view, onChange }: ModuleViewToggleProps) { + const { t } = useTranslations(); + + const segments = [ + { + value: "cards" as const, + label: t("moduleList.viewCards"), + icon: LayoutGrid, + }, + { + value: "list" as const, + label: t("moduleList.viewList"), + icon: ListIcon, + }, + ]; + + return ( +
+ {segments.map(({ value, label, icon: Icon }) => { + const active = view === value; + return ( + + ); + })} +
+ ); +} diff --git a/src/components/onboarding/DoneScreen.tsx b/src/components/onboarding/DoneScreen.tsx index 9b924a123..5e02f3716 100644 --- a/src/components/onboarding/DoneScreen.tsx +++ b/src/components/onboarding/DoneScreen.tsx @@ -1,9 +1,18 @@ "use client"; import Link from "next/link"; -import { CheckCircle2, FileUp, PlusCircle, Plug } from "lucide-react"; +import { useEffect } from "react"; +import { + CheckCircle2, + FileUp, + PlusCircle, + Plug, + Sparkles, +} from "lucide-react"; import { Button } from "@/components/ui/button"; +import { useAuth } from "@/hooks/use-auth"; +import { setTourReferrer } from "@/components/onboarding/tour-launcher"; import { useTranslations } from "@/lib/i18n/context"; /** @@ -18,10 +27,21 @@ import { useTranslations } from "@/lib/i18n/context"; * through `next/link` so the regular client-side navigation kicks in * (the proxy redirect was cleared with the same step-API write that * landed the user here). + * + * v1.18.6 — on mount we write the per-user wizard-return marker so the + * shell-level `` auto-opens the module tour immediately + * on the next dashboard arrival (sequenced after the post-wizard + * grace), instead of waiting out the 24 h fallback. Whichever exit the + * user picks from this screen, the next `/` mount finds the marker. */ export function DoneScreen() { const { t } = useTranslations(); + const { user } = useAuth(); + + useEffect(() => { + if (user?.id) setTourReferrer(user.id); + }, [user?.id]); return (
+ {/* v1.18.6 — surface the AI Insights / Coach / briefing setup at + the highest-intent moment. The flagship feature needs a BYOK or + local provider that the rest of onboarding never mentions, so a + fresh user otherwise discovers it as a cold error. This card is + skippable by construction — it is an optional deep-link, not a + gate. */} +
+ +

+ {t("onboarding.done.aiTitle")} +

+

+ {t("onboarding.done.aiBody")} +

+ + {t("onboarding.done.aiCta")} + +
+
+ {/* v1.18.6 (DISC-02) — one-time medical-disclaimer acknowledgment. This + replaces the per-page / per-chart disclaimer banners removed app-wide + in the same release; the full legal text stays reachable on the + public privacy page (link below). */} +
+ setAcknowledged(next === true)} + className="mt-0.5" + /> + +
+
+ ); +} diff --git a/src/components/onboarding/tour-launcher.tsx b/src/components/onboarding/tour-launcher.tsx index 3095542df..c76b6c0d1 100644 --- a/src/components/onboarding/tour-launcher.tsx +++ b/src/components/onboarding/tour-launcher.tsx @@ -47,11 +47,13 @@ * manual replay always works. */ -import { useEffect, useState } from "react"; +import { usePathname } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { apiPost } from "@/lib/api/api-fetch"; +import type { TourStopId } from "@/lib/onboarding/tour-state"; import { OnboardingTour } from "./tour"; const POST_WIZARD_GRACE_MS = 1500; @@ -90,6 +92,16 @@ export function shouldAutoLaunchTour(args: { onboardingTourCompleted: boolean; onboardingCompletedAt: string | null; nowMs: number; + /** + * v1.18.6 — true when the user has just returned from the wizard's + * DoneScreen this session (the `tourReferrerKey` marker). A fresh + * user finishing anamnesis should get the module tour IMMEDIATELY — + * the post-wizard grace below still sequences it after the toast. + * The old 24 h delay over-corrected and removed first-run capability + * education entirely; it now only applies to a user who SKIPPED the + * wizard (no referrer marker, completion ≥ 24 h ago). + */ + justFromWizard?: boolean; }): boolean { if (args.onboardingTourCompleted) return false; if (args.onboardingCompletedAt == null) return false; @@ -98,6 +110,9 @@ export function shouldAutoLaunchTour(args: { // unparseable case as "no completion yet" so a malformed payload // never auto-launches the tour into an unsuspecting brand-new user. if (Number.isNaN(completedMs)) return false; + // Fresh-from-anamnesis: launch now (sequenced after the post-wizard + // grace by the launcher), no 24 h wait. + if (args.justFromWizard) return true; return args.nowMs - completedMs >= TOUR_AUTOLAUNCH_DELAY_MS; } @@ -189,6 +204,22 @@ function readAndClearForceLaunch(userId: string): boolean { * does not consult sessionStorage at all (the gating effect waits * for `inputsReady` first). */ +/** + * v1.18.6 — write the wizard-return marker. Called from the wizard's + * `DoneScreen` on mount so the launcher knows the user JUST finished + * anamnesis and should get the module tour immediately (sequenced after + * the post-wizard grace) rather than after the 24 h fallback delay. + * Per-user keyed so a shared browser keeps each identity's state apart. + */ +export function setTourReferrer(userId: string): void { + if (typeof window === "undefined") return; + try { + window.sessionStorage.setItem(tourReferrerKey(userId), "1"); + } catch { + /* ignore — sandboxed iframes etc. */ + } +} + function readAndClearJustFromWizard(userId: string): boolean { if (typeof window === "undefined") return false; try { @@ -233,24 +264,30 @@ function clearSessionDismissed(userId: string) { interface TourLauncherProps { /** - * The dashboard passes a sentinel — true once analytics has - * resolved at least once. Without this gate the spotlight would - * try to anchor to tiles that haven't rendered yet, and Recharts - * widgets defer mounting too. This is a simple, race-free signal - * the dashboard already computes. + * v1.18.6 — optional readiness sentinel. The dashboard used to pass + * `true` once analytics resolved so the spotlight didn't anchor to a + * 0×0 tile. The overlay now polls for its anchor (bounded retry), so + * the gate is no longer load-bearing; it defaults to `true` for the + * shell-level mount. The launcher still only auto-opens on the + * dashboard route, where the first stop's anchor lives. */ - ready: boolean; + ready?: boolean; } -export function TourLauncher({ ready }: TourLauncherProps) { +export function TourLauncher({ ready = true }: TourLauncherProps) { const { user, isAuthenticated, isLoading } = useAuth(); const queryClient = useQueryClient(); + const pathname = usePathname(); // `null` = decision not yet made; `true` = render the tour; // `false` = don't render. We only flip null→true ONCE per mount; // closing the tour transitions true→false and we never re-open // unless the restart event fires. const [showTour, setShowTour] = useState(null); + // v1.18.6 — when the tour opens for a single-module re-entry + // ("Diese Tour zeigen" on a module page) this carries the stop id so + // the overlay renders just that card. `null` = the full tour. + const [singleStop, setSingleStop] = useState(null); // Track post-wizard deferral as a boolean so the render path stays // pure (no Date.now() comparisons). The effect below reads / clears it. const [deferredFromWizard, setDeferredFromWizard] = useState(false); @@ -275,7 +312,14 @@ export function TourLauncher({ ready }: TourLauncherProps) { // tour on their next page-load — acceptable for a one-shot welcome). const [mountedAtMs] = useState(() => Date.now()); - const inputsReady = !isLoading && isAuthenticated && !!user && ready; + // v1.18.6 — the launcher now mounts at the app-shell level so the + // overlay survives the cross-page `router.push`es the tour makes. It + // must only AUTO-open on the dashboard, where the first stop's anchor + // lives. A manual re-entry / restart can fire from any route (handled + // by the event listeners below, which don't consult this gate). + const onDashboard = pathname === "/"; + const inputsReady = + !isLoading && isAuthenticated && !!user && ready && onDashboard; // The set-state-in-render pattern (mirrors `account-section.tsx`'s // `seededUserId` — React-recommended for "compute initial state // from props once they arrive"). Pure: branches on prop values @@ -289,6 +333,10 @@ export function TourLauncher({ ready }: TourLauncherProps) { decidedFor.flag !== user.onboardingTourCompleted) ) { setDecidedFor({ userId: user.id, flag: user.onboardingTourCompleted }); + // v1.18.6 — consume the wizard-return marker once so the + // fresh-from-anamnesis path launches immediately (sequenced after + // the post-wizard grace) instead of waiting 24 h. + const justFromWizard = readAndClearJustFromWizard(user.id); // sessionStorage reads happen via per-user keyed helpers, so a // browser shared by two HealthLog users (admin impersonation, // family laptop, etc.) keeps each user's tour state independent. @@ -309,17 +357,21 @@ export function TourLauncher({ ready }: TourLauncherProps) { // user expectation ("I clicked replay, now show me the tour"). if (readAndClearForceLaunch(user.id)) { setShowTour(true); - } else if (!shouldAutoLaunchTour({ - onboardingTourCompleted: user.onboardingTourCompleted, - onboardingCompletedAt: user.onboardingCompletedAt, - nowMs: mountedAtMs, - })) { + } else if ( + !shouldAutoLaunchTour({ + onboardingTourCompleted: user.onboardingTourCompleted, + onboardingCompletedAt: user.onboardingCompletedAt, + nowMs: mountedAtMs, + justFromWizard, + }) + ) { setShowTour(false); } else if (readSessionDismissed(user.id)) { setShowTour(false); - } else if (readAndClearJustFromWizard(user.id)) { - // Mark "deferred"; the effect below schedules a fixed-duration - // timer + flips `showTour` from inside the timeout callback + } else if (justFromWizard) { + // Fresh from anamnesis — defer past the post-wizard grace so the + // green-tick toast doesn't double-overlay, then open. The effect + // below schedules the timer and flips `showTour` in its callback // (which the set-state-in-effect rule allows). setShowTour(false); setDeferredFromWizard(true); @@ -344,13 +396,15 @@ export function TourLauncher({ ready }: TourLauncherProps) { return () => window.clearTimeout(id); }, [deferredFromWizard, user?.id]); - // Listen for explicit restart events from Settings → Account. + // Listen for explicit restart events from Settings → Advanced. A + // restart opens the FULL tour from the top (singleStop cleared). // setState inside a window-event listener is fine under the // set-state-in-effect rule. const userIdForRestart = user?.id; useEffect(() => { function onRestart() { if (userIdForRestart) clearSessionDismissed(userIdForRestart); + setSingleStop(null); setShowTour(true); } window.addEventListener("healthlog:tour-restart", onRestart); @@ -359,17 +413,55 @@ export function TourLauncher({ ready }: TourLauncherProps) { }; }, [userIdForRestart]); + // v1.18.6 — per-module "Diese Tour zeigen" re-entry. A module page + // dispatches `healthlog:module-tour` with `{ stopId }`; the launcher + // opens the overlay narrowed to that single card on the current page + // (no cross-page navigation, no completion flip). + useEffect(() => { + function onModuleTour(e: Event) { + const detail = (e as CustomEvent<{ stopId?: string }>).detail; + if (!detail?.stopId) return; + setSingleStop(detail.stopId as TourStopId); + setShowTour(true); + } + window.addEventListener("healthlog:module-tour", onModuleTour); + return () => { + window.removeEventListener("healthlog:module-tour", onModuleTour); + }; + }, []); + + // v1.18.6 — stable progress callback so the overlay's checkpoint effect + // fires on a real step transition, not on every launcher re-render. + const handleProgress = useCallback( + (progress: { lastStopId: string | null; completedStopIds: string[]; status: "in_progress" | "completed" | "skipped" }) => { + void apiPost("/api/onboarding/tour", { + progress: { ...progress, updatedAt: new Date().toISOString() }, + }).catch(() => {}); + }, + [], + ); + if (showTour !== true) return null; - // v1.18.0 B5 — drop the achievements tour stop when the account has the - // achievements module turned off. Reads the per-user module map the same - // way every other client gate does (`modules?. !== false`, - // default-on). - const includeAchievements = tourIncludesAchievements(user?.modules); + // The resolved module map gates which stops appear (default-on) and + // keeps the "Schritt n/total" counter honest. + const modules = user?.modules; + // v1.18.6 — resume the full tour from the persisted checkpoint. The + // single-stop re-entry ignores the resume point (it opens its card). + const resumeFromStopId = singleStop + ? null + : (user?.onboardingTourProgress?.lastStopId ?? null); return ( { // Optimistic: hide the overlay immediately, persist in the // background. The flag is idempotent so re-fires are safe; @@ -378,7 +470,13 @@ export function TourLauncher({ ready }: TourLauncherProps) { // for the rest of the session, and the next refetch of // `/api/auth/me` (which the user gets on any nav) will // surface any divergence. + const wasSingleStop = singleStop !== null; setShowTour(false); + setSingleStop(null); + // A single-stop re-entry must NOT flip the global completion + // flag or write the session-dismiss guard — it's one card, not + // "the tour". Just close. + if (wasSingleStop) return; if (user?.id) writeSessionDismissed(user.id); try { await apiPost("/api/onboarding/tour", { completed: true, outcome }); diff --git a/src/components/onboarding/tour.tsx b/src/components/onboarding/tour.tsx index ae350d47e..852828568 100644 --- a/src/components/onboarding/tour.tsx +++ b/src/components/onboarding/tour.tsx @@ -36,6 +36,7 @@ * `skipped` to the API + audit log). */ +import { usePathname, useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; @@ -43,26 +44,46 @@ import { useTranslations } from "@/lib/i18n/context"; import { buildTourStops, currentStop, + deriveProgress, initTourState, isTourFinished, nextStep, prevStep, skipTour, stepCounter, + type TourModuleMap, + type TourState, type TourStop, + type TourStopId, } from "@/lib/onboarding/tour-state"; interface TourProps { /** - * Whether the achievements page exists and should be a tour stop. - * The launcher passes `false` if B4 hasn't shipped to skip the - * stop without renumbering. + * Resolved module map (`GET /api/auth/me`'s `modules`). Stops whose + * `requiresModule` resolves to `false` are dropped; the counter total + * tracks the resolved list so "Schritt n/total" stays honest. */ - includeAchievements?: boolean; + modules?: TourModuleMap; /** - * Called when the tour finishes (Done OR Skip OR Esc OR Backdrop - * click). Receives the terminal outcome so the launcher can audit - * the difference. + * v1.18.6 — open at this stop id (the persisted resume point). When + * the id is absent from the resolved list the tour starts at the top. + */ + resumeFromStopId?: string | null; + /** + * v1.18.6 — narrow the tour to a single module card (the per-page + * "Diese Tour zeigen" re-entry). When set, the overlay shows just + * that stop with a Done button and no cross-page navigation. + */ + filterToStop?: TourStopId; + /** + * v1.18.6 — fire-and-forget progress checkpoint on every step change + * + terminal. The launcher PATCHes it so a reload resumes correctly. + * Not called for the single-stop re-entry (`filterToStop`). + */ + onProgress?: (progress: ReturnType) => void; + /** + * Called when the tour finishes (Done OR Skip OR Esc). Receives the + * terminal outcome so the launcher can audit the difference. */ onClose: (outcome: "completed" | "skipped") => void; } @@ -216,56 +237,125 @@ const COLLISION_PAD = 8; const SHEET_BREAKPOINT = 480; export function OnboardingTour({ - includeAchievements = true, + modules, + resumeFromStopId, + filterToStop, + onProgress, onClose, }: TourProps) { const { t } = useTranslations(); + const router = useRouter(); + const pathname = usePathname(); const stops = useMemo( - () => buildTourStops({ includeAchievements }), - [includeAchievements], + () => buildTourStops({ modules, filterToStop }), + [modules, filterToStop], + ); + // Single-stop re-entry never resumes from a checkpoint — it opens on + // the one card the page asked for. + const [state, setState] = useState(() => + initTourState(stops, filterToStop ? null : resumeFromStopId), ); - const [state, setState] = useState(() => initTourState(stops)); const [rect, setRect] = useState(null); const stop = currentStop(state); - // Re-measure on step change, viewport resize, and scroll. We intentionally - // *don't* MutationObserver the whole document — the targets are stable in - // the layout and re-measuring on rAF after scroll/resize is plenty. + // v1.18.6 — cross-page step change. A stop on another route triggers a + // `router.push` first; the overlay survives the navigation because it is + // mounted at the app-shell level (not inside the dashboard). After the + // route settles we must wait for the new page's anchor to MOUNT before + // measuring — anchoring against a not-yet-rendered (0×0) target would snap + // the spotlight to centre-screen. We poll on rAF with a bounded retry, then + // give up to the centred fallback so the tour never blocks indefinitely. + // + // We intentionally *don't* MutationObserver the whole document — the + // targets are stable once mounted; bounded rAF polling on step change plus + // re-measuring on scroll/resize is plenty. useEffect(() => { if (!stop) return; + if (typeof document === "undefined") return; + let raf = 0; + let cancelled = false; + const reduceMotion = + typeof window !== "undefined" && + window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; + + // Navigate to the stop's route if we're not already there. Same-route + // stops (the two dashboard stops while on `/`, or the wrap-up which has + // no route) skip the push. `filterToStop` re-entry never navigates — it + // runs on the page the user is already viewing. + if (!filterToStop && stop.route && stop.route !== pathname) { + router.push(stop.route); + } + + const measureNow = () => { + if (cancelled) return; + setRect(measureTarget(stop.targetId)); + }; const measure = () => { cancelAnimationFrame(raf); - raf = requestAnimationFrame(() => { - setRect(measureTarget(stop.targetId)); - }); + raf = requestAnimationFrame(measureNow); }; - // Bring the target into view BEFORE the first measurement of this - // step — anchors low on the page (e.g. the Settings nav item) can - // sit entirely below the fold, and positioning against an - // off-screen rect put the popover under the viewport bottom where - // its Next button was unreachable. `block: "center"` keeps room on - // both sides for the flipped placement; the rAF in `measure()` - // runs after the instant scroll so we measure post-scroll - // coordinates. Optional-chained: jsdom and older engines lack it. - if (stop.targetId && typeof document !== "undefined") { - document - .querySelector( - `[data-tour-id="${CSS.escape(stop.targetId)}"]`, - ) - ?.scrollIntoView?.({ block: "center", inline: "nearest" }); - } - measure(); + + // Wait for the anchor to mount (after navigation it isn't in the DOM on + // the first frame). Bounded poll: ~40 frames (~650 ms) before falling + // back to the centred tooltip via `measureTarget` returning null. + let attempts = 0; + const MAX_ATTEMPTS = 40; + const settleAndMeasure = () => { + if (cancelled) return; + const el = stop.targetId + ? document.querySelector( + `[data-tour-id="${CSS.escape(stop.targetId)}"]`, + ) + : null; + if (el) { + // Bring the anchor into view BEFORE measuring — anchors below the + // fold positioned the popover under the viewport bottom otherwise. + // Reduced-motion users get an instant jump, not a smooth scroll. + el.scrollIntoView?.({ + block: "center", + inline: "nearest", + behavior: reduceMotion ? "auto" : "smooth", + }); + measure(); + return; + } + // Centred / route-less stop (wrap-up): measure immediately. + if (!stop.targetId) { + measureNow(); + return; + } + attempts += 1; + if (attempts < MAX_ATTEMPTS) { + raf = requestAnimationFrame(settleAndMeasure); + } else { + // Anchor never mounted — centre the tooltip rather than block. + measureNow(); + } + }; + settleAndMeasure(); + window.addEventListener("resize", measure); window.addEventListener("scroll", measure, true); return () => { + cancelled = true; cancelAnimationFrame(raf); window.removeEventListener("resize", measure); window.removeEventListener("scroll", measure, true); }; - }, [stop]); + // `pathname` is intentionally a dep: after the route settles the effect + // re-runs and the anchor poll finds the now-mounted target. + }, [stop, filterToStop, router, pathname]); + + // v1.18.6 — fire-and-forget progress checkpoint on every step / terminal + // transition so a mid-tour reload resumes at the right module. Skipped for + // the single-stop re-entry (it isn't "the tour", just one card). + useEffect(() => { + if (filterToStop || !onProgress) return; + onProgress(deriveProgress(state)); + }, [state, filterToStop, onProgress]); // Focus management — moves focus to the primary action when the // step changes so keyboard users can hit Enter to advance. The diff --git a/src/components/providers.tsx b/src/components/providers.tsx index dfba33019..ad4e28da3 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -23,6 +23,12 @@ import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar"; import { isDashboardSnapshotEnabled } from "@/lib/dashboard/snapshot-flag"; import { prefetchDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot"; import { prefetchMedicationsList } from "@/lib/queries/prefetch-medications"; +import { + restorePersistedQueryCache, + startPersistingQueryCache, +} from "@/lib/pwa/query-persister"; + +const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? ""; // ── Theme Context ──────────────────────────────────── @@ -147,6 +153,31 @@ function DashboardSnapshotPreloader() { return null; } +// ── Offline query persistence ──────────────────────── +// +// v1.18.6 — hydrate the last-synced query cache from IndexedDB before the +// first authenticated paint, then debounce-persist successful reads back. +// Combined with the service worker's allowlisted stale-while-revalidate API +// branch, an installed PWA opened offline renders last-known data instead of +// empty skeletons. Build-version + age gated; cleared on logout. +function QueryPersistenceBridge() { + const queryClient = useQueryClient(); + useEffect(() => { + let stop: (() => void) | undefined; + let cancelled = false; + void restorePersistedQueryCache(queryClient, SHELL_VERSION).finally(() => { + if (!cancelled) { + stop = startPersistingQueryCache(queryClient, SHELL_VERSION); + } + }); + return () => { + cancelled = true; + stop?.(); + }; + }, [queryClient]); + return null; +} + // ── Root Providers ─────────────────────────────────── export function Providers({ @@ -181,6 +212,7 @@ export function Providers({ initialMessages={initialMessages} > + {children} diff --git a/src/components/settings/__tests__/coach-section.test.tsx b/src/components/settings/__tests__/coach-section.test.tsx index 16f2f34d9..921dae86a 100644 --- a/src/components/settings/__tests__/coach-section.test.tsx +++ b/src/components/settings/__tests__/coach-section.test.tsx @@ -31,12 +31,17 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; +import { SettingsSectionFrame } from "../settings-section-frame"; import { CoachSection } from "../coach-section"; function render(locale: "en" | "de" = "en") { + // v1.18.6 (W9) — the visible heading lives in the shared frame the route + // wraps the section in; render through it so the heading id is present. return renderToStaticMarkup( - + + + , ); } @@ -51,6 +56,9 @@ describe(" — SSR smoke", () => { // v1.18.1 (D7) — polarity flipped to activate/default-on. expect(html).toContain("Activate Coach"); expect(html).toContain("Show the Coach button and drawer. On by default"); + // v1.18.6 (W9) — the proactive Coach nudge card moved here from + // Benachrichtigungen; its anchor renders under the enabled-coach gate. + expect(html).toContain('id="coach-nudge"'); // Raw key never leaks past i18n. expect(html).not.toContain("settings.sections.coach."); expect(html).not.toContain("settings.coach.activate."); diff --git a/src/components/settings/__tests__/disable-coach-card.test.tsx b/src/components/settings/__tests__/disable-coach-card.test.tsx index 495fa9a59..aaf77d38b 100644 --- a/src/components/settings/__tests__/disable-coach-card.test.tsx +++ b/src/components/settings/__tests__/disable-coach-card.test.tsx @@ -63,6 +63,7 @@ function buildUser(disableCoach: boolean): AuthUser { timezone: "Europe/Berlin", onboardingCompletedAt: null, onboardingTourCompleted: true, + onboardingTourProgress: null, avatarUrl: null, glucoseUnit: null, unitPreference: "metric", diff --git a/src/components/settings/__tests__/export-section.test.tsx b/src/components/settings/__tests__/export-section.test.tsx index 264277863..bc648c7d2 100644 --- a/src/components/settings/__tests__/export-section.test.tsx +++ b/src/components/settings/__tests__/export-section.test.tsx @@ -44,6 +44,7 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; +import { SettingsSectionFrame } from "../settings-section-frame"; import { ExportSection } from "../export-section"; function render(node: React.ReactElement, locale: "en" | "de" = "en") { @@ -54,7 +55,13 @@ function render(node: React.ReactElement, locale: "en" | "de" = "en") { describe(" — SSR smoke", () => { it("renders the section heading + description", () => { - const html = render(); + // v1.18.6 (W9) — the page heading comes from the shared frame the route + // wraps the section in. + const html = render( + + + , + ); // v1.15.7 — the section was relabelled "Export & Import" (issue #281). expect(html).toContain("Export & Import"); // Raw key never leaks past i18n. diff --git a/src/components/settings/__tests__/gesundheitsakte-section.test.tsx b/src/components/settings/__tests__/gesundheitsakte-section.test.tsx index 57ab31493..baee6ffba 100644 --- a/src/components/settings/__tests__/gesundheitsakte-section.test.tsx +++ b/src/components/settings/__tests__/gesundheitsakte-section.test.tsx @@ -29,6 +29,7 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; +import { SettingsSectionFrame } from "../settings-section-frame"; import { GesundheitsakteSection } from "../gesundheitsakte-section"; function render(node: React.ReactElement, locale: "en" | "de" = "en") { @@ -38,8 +39,14 @@ function render(node: React.ReactElement, locale: "en" | "de" = "en") { } describe(" — SSR smoke", () => { - it("renders the section description and the health-record panel", () => { - const html = render(); + it("renders the section heading and the health-record panel", () => { + // v1.18.6 (W9) — the visible heading (with the historic id) comes from + // the shared frame the route wraps the section in. + const html = render( + + + , + ); expect(html).toContain('id="settings-section-gesundheitsakte-title"'); expect(html).toContain('data-testid="health-record-export-panel"'); // Raw key never leaks past i18n. diff --git a/src/components/settings/__tests__/medications-section.test.tsx b/src/components/settings/__tests__/medications-section.test.tsx index 7fc9b3d70..7bc97a732 100644 --- a/src/components/settings/__tests__/medications-section.test.tsx +++ b/src/components/settings/__tests__/medications-section.test.tsx @@ -65,12 +65,17 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; +import { SettingsSectionFrame } from "../settings-section-frame"; import { MedicationsSection } from "../medications-section"; function render(locale: "en" | "de" = "en"): string { + // v1.18.6 (W9) — the visible heading (with the historic id) comes from the + // shared frame the route wraps the section in. return renderToStaticMarkup( - + + + , ); } diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx index af1183e3b..cb97fa27b 100644 --- a/src/components/settings/__tests__/modules-section.test.tsx +++ b/src/components/settings/__tests__/modules-section.test.tsx @@ -20,11 +20,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; import type { AuthUser } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; -import { - MODULE_KEYS, - CORE_DOMAIN_KEYS, - moduleDelegatesTo, -} from "@/lib/modules/registry"; +import { MODULE_KEYS, moduleDelegatesTo } from "@/lib/modules/registry"; // Capture the latest useMutation config + a shared mutate spy so we can // invoke `mutationFn` / `onSuccess` by hand (the SSR pass can't fire a @@ -46,9 +42,9 @@ vi.mock("@tanstack/react-query", () => ({ }, })); -const authSpy = vi.fn<() => { user: AuthUser | null; isAuthenticated: boolean }>( - () => ({ user: buildUser({}), isAuthenticated: true }), -); +const authSpy = vi.fn< + () => { user: AuthUser | null; isAuthenticated: boolean } +>(() => ({ user: buildUser({}), isAuthenticated: true })); vi.mock("@/hooks/use-auth", async () => { const actual = await vi.importActual( @@ -69,6 +65,7 @@ function buildUser(modules: AuthUser["modules"]): AuthUser { timezone: "Europe/Berlin", onboardingCompletedAt: null, onboardingTourCompleted: true, + onboardingTourProgress: null, avatarUrl: null, glucoseUnit: null, unitPreference: "metric", @@ -108,7 +105,10 @@ afterEach(() => { }); describe("", () => { - it("renders a Switch for every non-delegated toggleable module, a managed deep-link for delegated ones, and locked switches for core", () => { + it("renders a Switch for every non-delegated toggleable module and a managed deep-link for delegated ones", () => { + // v1.18.6 (W9) — the "Always on" core-domains card was removed (a domain + // that can't be turned off doesn't need listing), so this section now + // only renders the toggleable modules. const html = render(); for (const key of MODULE_KEYS) { if (moduleDelegatesTo(key) !== undefined) { @@ -125,8 +125,16 @@ describe("", () => { ); } } - for (const key of CORE_DOMAIN_KEYS) { - expect(html, `core switch ${key}`).toContain(`id="module-toggle-${key}"`); + }); + + it("no longer renders the 'Always on' core-domains card", () => { + // v1.18.6 (W9) — the read-only core card (weight / BP / pulse) was + // removed; the core domains are always active regardless. + const html = render(); + for (const key of ["weight", "bloodPressure", "pulse"]) { + expect(html, `core ${key} not rendered`).not.toContain( + `id="module-toggle-${key}"`, + ); } }); @@ -151,18 +159,6 @@ describe("", () => { expect(moodTag).toMatch(/data-state="checked"/); }); - it("renders the core domains locked (checked + disabled)", () => { - const html = render(); - for (const key of CORE_DOMAIN_KEYS) { - const tag = html.match( - new RegExp(`]*id="module-toggle-${key}"[^>]*>`), - )?.[0]; - expect(tag, `core ${key}`).toBeDefined(); - expect(tag).toMatch(/data-state="checked"/); - expect(tag).toMatch(/disabled/); - } - }); - it("PATCHes only the flipped key as a disabled-allowlist entry", async () => { render(); expect(lastMutation?.mutationFn).toBeTypeOf("function"); diff --git a/src/components/settings/__tests__/mood-section.test.tsx b/src/components/settings/__tests__/mood-section.test.tsx index 8f3975704..5f949344b 100644 --- a/src/components/settings/__tests__/mood-section.test.tsx +++ b/src/components/settings/__tests__/mood-section.test.tsx @@ -131,12 +131,17 @@ vi.mock("@/hooks/use-auth", () => ({ })); import { I18nProvider } from "@/lib/i18n/context"; +import { SettingsSectionFrame } from "../settings-section-frame"; import { MoodSection } from "../mood-section"; function render(locale: "en" | "de" = "en"): string { + // v1.18.6 (W9) — the visible heading (with the historic id) comes from the + // shared frame the route wraps the section in. return renderToStaticMarkup( - + + + , ); } diff --git a/src/components/settings/__tests__/notifications-section-module-gating.test.tsx b/src/components/settings/__tests__/notifications-section-module-gating.test.tsx index 4cc5cd46b..7b7831108 100644 --- a/src/components/settings/__tests__/notifications-section-module-gating.test.tsx +++ b/src/components/settings/__tests__/notifications-section-module-gating.test.tsx @@ -2,10 +2,13 @@ * v1.18.0 (S4) — module-gated per-type visibility on the consolidated * "Benachrichtigungen" reminder-types home. * - * The mood reminder shows only when the `mood` module is enabled; the Coach - * nudge only when `coach` is enabled. Low-stock maps to the medications CORE - * domain (always shown). The gate fails OPEN, so a `/me` payload without a - * module map keeps every card visible. + * The mood reminder shows only when the `mood` module is enabled. Low-stock + * maps to the medications domain (shown unless explicitly disabled). The gate + * fails OPEN, so a `/me` payload without a module map keeps every card + * visible. + * + * v1.18.6 (W9) — the proactive Coach nudge card moved to Settings → Coach, so + * it no longer appears on this screen. */ import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; @@ -47,30 +50,23 @@ function render(modules?: Record) { } describe(" module-gated reminder types", () => { - it("shows mood + coach cards when no module map (fails open)", () => { + it("shows mood + low-stock cards when no module map (fails open); no coach nudge", () => { const html = render(undefined); expect(html).toContain('id="mood-reminder"'); - expect(html).toContain('id="coach-nudge"'); expect(html).toContain('id="low-stock"'); + // v1.18.6 (W9) — the coach nudge moved to Settings → Coach. + expect(html).not.toContain('id="coach-nudge"'); }); it("hides the mood card when the mood module is disabled", () => { const html = render({ mood: false }); expect(html).not.toContain('id="mood-reminder"'); - // Coach + low-stock are unaffected. - expect(html).toContain('id="coach-nudge"'); + // Low-stock is unaffected. expect(html).toContain('id="low-stock"'); }); - it("hides the coach card when the coach module is disabled", () => { - const html = render({ coach: false }); - expect(html).not.toContain('id="coach-nudge"'); - expect(html).toContain('id="mood-reminder"'); - expect(html).toContain('id="low-stock"'); - }); - - it("always shows low-stock (medications is a CORE domain)", () => { - const html = render({ mood: false, coach: false }); + it("always shows low-stock unless medications is disabled", () => { + const html = render({ mood: false }); expect(html).toContain('id="low-stock"'); expect(html).not.toContain('id="mood-reminder"'); expect(html).not.toContain('id="coach-nudge"'); @@ -82,9 +78,9 @@ describe(" module-gated reminder types", () => { const html = render({}); expect(html).not.toContain('data-slot="notifications-channels-cross-link"'); expect(html).not.toContain('data-slot="notifications-inbox-cross-link"'); - // The three reminder-type cards remain. + // The remaining reminder-type cards (no coach nudge here anymore). expect(html).toContain('id="mood-reminder"'); expect(html).toContain('id="low-stock"'); - expect(html).toContain('id="coach-nudge"'); + expect(html).not.toContain('id="coach-nudge"'); }); }); diff --git a/src/components/settings/__tests__/sections-i18n-parity.test.ts b/src/components/settings/__tests__/sections-i18n-parity.test.ts index f85254a4d..d51d75cfc 100644 --- a/src/components/settings/__tests__/sections-i18n-parity.test.ts +++ b/src/components/settings/__tests__/sections-i18n-parity.test.ts @@ -2,11 +2,11 @@ * v1.4.16 phase B6 — settings i18n parity. * * Every slug declared in `SETTINGS_SECTION_SLUGS` MUST have a - * `settings.sections..title` AND a `settings.sections..description` - * resolved in both English and German. The audit doc - * (`docs/audit/v1416-settings-audit.md`) calls this out as the canonical - * shape — the sidebar, the page `

`, and the page subtitle all read - * from these two keys, so a missing key paints a raw key in production. + * `settings.sections..title` AND a `settings.sections..subtitle` + * resolved in both English and German. The sidebar reads `.title`; the shared + * `` reads `.title` + `.subtitle` for the visible page + * heading + subheading (v1.18.6 W9), so a missing key paints a raw key in + * production. */ import { describe, expect, it } from "vitest"; @@ -46,43 +46,39 @@ describe("settings sections — i18n parity (B6)", () => { expect(deValue?.length ?? 0).toBeGreaterThan(0); }); - it(`every locale resolves settings.sections.${slug}.description`, () => { + it(`every locale resolves settings.sections.${slug}.subtitle`, () => { const enValue = lookup(en as Bag, [ "settings", "sections", slug, - "description", + "subtitle", ]); const deValue = lookup(de as Bag, [ "settings", "sections", slug, - "description", + "subtitle", ]); - expect(enValue, `EN missing description for ${slug}`).toBeTypeOf( - "string", - ); + expect(enValue, `EN missing subtitle for ${slug}`).toBeTypeOf("string"); expect(enValue?.length ?? 0).toBeGreaterThan(0); - expect(deValue, `DE missing description for ${slug}`).toBeTypeOf( - "string", - ); + expect(deValue, `DE missing subtitle for ${slug}`).toBeTypeOf("string"); expect(deValue?.length ?? 0).toBeGreaterThan(0); }); } - it("EN and DE description for every slug are different (catches accidental copy-paste)", () => { + it("EN and DE subtitle for every slug are different (catches accidental copy-paste)", () => { for (const slug of SETTINGS_SECTION_SLUGS) { const enValue = lookup(en as Bag, [ "settings", "sections", slug, - "description", + "subtitle", ]); const deValue = lookup(de as Bag, [ "settings", "sections", slug, - "description", + "subtitle", ]); // It's legitimate for some titles to coincide between locales when // the proper noun is the same (e.g. "Dashboard", "API & Tokens"). @@ -90,7 +86,7 @@ describe("settings sections — i18n parity (B6)", () => { // EN value into the DE bag is a bug. expect( deValue, - `DE description for ${slug} is identical to EN — needs translation`, + `DE subtitle for ${slug} is identical to EN — needs translation`, ).not.toBe(enValue); } }); diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index 1eddc6cca..34ac19d7e 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -87,6 +87,8 @@ vi.mock("@/components/settings/thresholds-editor-section", () => ({ ThresholdsEditorSection: () =>
, })); import { I18nProvider } from "@/lib/i18n/context"; +import { SettingsSectionFrame } from "../settings-section-frame"; +import type { SettingsSectionSlug } from "../section-slugs"; import { AboutSection } from "../about-section"; import { AccountSection } from "../account-section"; import { AdvancedSection } from "../advanced-section"; @@ -108,10 +110,25 @@ function render(node: React.ReactElement, locale: "en" | "de" = "en") { ); } +// v1.18.6 (W9) — the visible page heading + subtitle moved out of the section +// bodies into the shared `` that the route wraps every +// section in. The heading-level smoke checks render the body THROUGH the frame +// (mirroring production) so they assert what the user actually sees. +function renderFramed( + slug: SettingsSectionSlug, + node: React.ReactElement, + locale: "en" | "de" = "en", +) { + return render( + {node}, + locale, + ); +} + describe("settings sections — SSR smoke", () => { it(" renders the Account heading and Profile card", () => { - const html = render(); - // Section title resolves via the i18n provider. + const html = renderFramed("account", ); + // Section title resolves via the frame + i18n provider. expect(html).toContain("Account"); // Profile card heading is also painted. expect(html).toContain("Profile"); @@ -119,12 +136,13 @@ describe("settings sections — SSR smoke", () => { expect(html).not.toContain("settings.sections."); }); - it(" exposes the Restart-tour button (moved from Account)", () => { - // v1.18.1 (D1) — the "Restart onboarding tour" card moved from Settings → - // Account into Settings → Advanced (a maintenance/reset action). + it(" exposes the single Restart-tour card", () => { + // v1.18.1 (D1) — the tour-replay card moved from Account into Advanced. + // v1.18.6 — relabelled "Restart module tour" and consolidated as the one + // full-tour replay home (the About duplicate was removed). const html = render(); expect(html).toContain('data-testid="settings-restart-tour"'); - expect(html).toContain("Restart onboarding tour"); + expect(html).toContain("Restart module tour"); // No raw i18n key leaks. expect(html).not.toContain("onboarding.tour."); }); @@ -135,7 +153,7 @@ describe("settings sections — SSR smoke", () => { }); it(" renders version + license + sources/docs cards (no Updates panel)", () => { - const html = render(); + const html = renderFramed("about", ); expect(html).toContain("About"); // v1.4.36 W4f — the dedicated "Updates" card with its manual // "Check now" button is gone. The 24 h auto-check still runs and @@ -148,57 +166,42 @@ describe("settings sections — SSR smoke", () => { expect(html).not.toContain("settings.about."); }); - it(" exposes a Replay-tour button for the W5 chain gate", () => { + it(" no longer carries a tour-replay card (consolidated to Advanced)", () => { + // v1.18.6 — the tour replay lives on one home in Settings → Advanced; + // the About duplicate was removed. Per-module "Diese Tour zeigen" + // affordances cover in-context re-entry. const html = render(); - // v1.4.47 W5 — the spotlight tour now auto-launches only ≥ 24 h - // after the wizard finishes (see `shouldAutoLaunchTour` in - // `components/onboarding/tour-launcher.tsx`). A first-day user who - // *does* want the tour needs a discoverable manual trigger, and - // Settings → About is the surface every user can find. Pin both - // the data-testid and the visible copy so a future refactor can't - // silently drop the button. - expect(html).toContain('data-testid="about-replay-tour"'); - expect(html).toContain("Replay the tour"); - // No raw i18n key leaks past the i18n layer. - expect(html).not.toContain("settings.about.tourReplay"); + expect(html).not.toContain('data-testid="about-replay-tour"'); + expect(html).not.toContain("Replay the tour"); }); - it(" renders the German Replay-tour label end-to-end", () => { - // project-voice + i18n parity: the W5 spec explicitly names this - // surface "Tour ansehen" in German. Lock the resolved copy so a - // future locale-bundle drift doesn't silently fall back to English. - const html = render(, "de"); - expect(html).toContain("Tour ansehen"); - }); - - it(" renders the AI Insights heading", () => { - const html = render(); - // v1.8.7.1 — section renamed to "AI Insights" so the AI nature of the - // surface is explicit (was the bare "Insights"). The section H1 carries - // the only heading. - expect(html).toContain("AI Insights"); + it(" renders the AI provider heading", () => { + const html = renderFramed("ai", ); + // v1.18.6 (W9) — section renamed to "AI provider"; the visible heading + // now comes from the shared frame, which keeps the historic id. + expect(html).toContain("AI provider"); expect(html).toContain("settings-section-ai-title"); }); it(" renders Withings card title", () => { - const html = render(); + const html = renderFramed("integrations", ); expect(html).toContain("Integrations"); expect(html).toContain("Withings"); }); it(" renders the single reminder-types home", () => { - const html = render(); - // v1.18.1 (D0/D5) — the screen is lean: sr-only h1 only, no section blurb, - // no channels/inbox cross-links, and no embedded Vorsorge editor. + const html = renderFramed("notifications", ); + // v1.18.6 (W9) — the visible heading comes from the frame; the proactive + // Coach nudge moved to Settings → Coach, so it no longer renders here. expect(html).toContain("settings-section-notifications-title"); expect(html).not.toContain("Notification channels"); expect(html).not.toContain('data-slot="notifications-channels-cross-link"'); expect(html).not.toContain('data-slot="notifications-inbox-cross-link"'); - // The mood + coach cards fail OPEN (mock user carries no module map), - // so all three reminder-type cards render. + // The mood card fails OPEN (mock user carries no module map), so both + // remaining reminder-type cards render. The coach-nudge card is gone. expect(html).toContain('id="mood-reminder"'); - expect(html).toContain('id="coach-nudge"'); expect(html).toContain('id="low-stock"'); + expect(html).not.toContain('id="coach-nudge"'); // No raw i18n key leaks past the provider. expect(html).not.toContain("settings.sections.notifications."); }); @@ -207,32 +210,31 @@ describe("settings sections — SSR smoke", () => { // v1.4.3 split: the per-metric thresholds (Persönliche Zielwerte) moved // out into their own settings section under `/settings/thresholds`, // so the dashboard panel is now layout-only. Verifying the heading - // says "Dashboard" (not "Übersicht" — the v1.4.2 placeholder) and the - // layout slot still mounts. - const html = render(); + // (supplied by the frame) says "Dashboard" and the layout slot mounts. + const html = renderFramed("dashboard", ); expect(html).toContain("Dashboard"); expect(html).toContain('data-testid="dashboard-layout"'); expect(html).not.toContain('data-testid="thresholds"'); }); it(" renders endpoints + tokens cards", () => { - const html = render(); + const html = renderFramed("api", ); expect(html).toContain("API & Tokens"); }); it(" renders the danger-zone card only (export moved to /settings/export)", () => { - const html = render(); + const html = renderFramed("advanced", ); // v1.4.16 phase B7: every export path moved out into the dedicated // `` so what remains is the irreversible delete-all - // path. The section heading + dangerZone surface are the only - // primary surfaces here now. + // path. The heading (supplied by the frame) + dangerZone surface are the + // only primary surfaces here now. expect(html).toContain("Advanced"); expect(html).not.toContain("settings.export"); expect(html).not.toContain("doctorReport"); }); it(" resolves about.* keys in German", () => { - const html = render(, "de"); + const html = renderFramed("about", , "de"); // v1.4.36 W4f — "Updates" panel + "Jetzt prüfen" button removed; // the surviving German copy is the section title + the Sources // & docs heading + the version line. @@ -248,7 +250,7 @@ describe("settings sections — SSR smoke", () => { // `` (was ``). // v1.8.7.1 — Sources split back onto its own page, so this wrapper // renders the target-range editor only. - const html = render(); + const html = renderFramed("thresholds", ); // v1.8.7.1: section renamed to "Targets". expect(html).toContain("Targets"); expect(html).toContain('data-testid="thresholds-editor"'); @@ -257,7 +259,7 @@ describe("settings sections — SSR smoke", () => { }); it(" resolves the German title", () => { - const html = render(, "de"); + const html = renderFramed("thresholds", , "de"); // v1.8.7.1 — German title is "Zielwerte" (Targets only). expect(html).toContain("Zielwerte"); expect(html).toContain('data-testid="thresholds-editor"'); diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index ce27d54d2..275a4ec9f 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -319,9 +319,9 @@ describe("", () => { expect(html).toContain('href="/settings/mood"'); // Targets keeps its German nav entry ("Zielwerte"). expect(html).toContain("Zielwerte"); - // v1.8.7.1 — the AI Insights section is named "KI-Auswertungen" in - // German (the "KI" prefix makes the AI nature explicit). - expect(html).toContain("KI-Auswertungen"); + // v1.18.6 (W9) — the AI section is named "KI-Anbieter" in German: the + // page is about the provider / BYOK, not an "Auswertung". + expect(html).toContain("KI-Anbieter"); // API & Tokens is identical in both locales (proper noun + ampersand) expect(html).toContain("API & Tokens"); expect(html).toContain("Erweitert"); diff --git a/src/components/settings/__tests__/time-format-select.test.tsx b/src/components/settings/__tests__/time-format-select.test.tsx index 848d95017..2bb55ac6b 100644 --- a/src/components/settings/__tests__/time-format-select.test.tsx +++ b/src/components/settings/__tests__/time-format-select.test.tsx @@ -41,6 +41,7 @@ function buildUser(timeFormat: TimeFormatPreference): AuthUser { timezone: "Europe/Berlin", onboardingCompletedAt: null, onboardingTourCompleted: true, + onboardingTourProgress: null, avatarUrl: null, glucoseUnit: null, unitPreference: "metric", diff --git a/src/components/settings/__tests__/unit-preference-select.test.tsx b/src/components/settings/__tests__/unit-preference-select.test.tsx index 13430adf1..c5e0a3b13 100644 --- a/src/components/settings/__tests__/unit-preference-select.test.tsx +++ b/src/components/settings/__tests__/unit-preference-select.test.tsx @@ -44,6 +44,7 @@ function buildUser(unitPreference: "metric" | "imperial"): AuthUser { timezone: "Europe/Berlin", onboardingCompletedAt: null, onboardingTourCompleted: true, + onboardingTourProgress: null, avatarUrl: null, glucoseUnit: null, unitPreference, diff --git a/src/components/settings/about-section.tsx b/src/components/settings/about-section.tsx index e79e452f8..787c5b46a 100644 --- a/src/components/settings/about-section.tsx +++ b/src/components/settings/about-section.tsx @@ -5,7 +5,6 @@ import { useQuery } from "@tanstack/react-query"; import { ArrowUpCircle, BookOpen, - Compass, ExternalLink, GitBranch, Info, @@ -15,9 +14,7 @@ import { import { Button } from "@/components/ui/button"; import { SettingsCardHeader } from "@/components/settings/_card-header"; -import { useAuth } from "@/hooks/use-auth"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; -import { restartOnboardingTour } from "@/lib/onboarding/tour-restart"; import { queryKeys } from "@/lib/query-keys"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; @@ -118,25 +115,9 @@ export function UpdateBadge({ ); } -export interface AboutSectionProps { - /** - * v1.18.1 E5 — suppress the "Replay tour" card. The admin → Über - * surface reuses this component but the onboarding tour is a - * user-facing affordance with no place in the admin console, so the - * admin renderer passes `hideTourReplay`. The Settings → About surface - * leaves it on. - */ - hideTourReplay?: boolean; -} - -export function AboutSection({ hideTourReplay = false }: AboutSectionProps = {}) { +export function AboutSection() { const { t } = useTranslations(); const fmt = useFormatters(); - // Pull the auth user — only needed so the "Replay the tour" button - // can stamp a per-user force-launch marker into sessionStorage. The - // hook is cheap (a cached `/api/auth/me` read) and every settings - // page already lives behind the same auth boundary. - const { user } = useAuth(); const { data: version, isLoading } = useQuery({ queryKey: queryKeys.apiVersion(), @@ -147,45 +128,6 @@ export function AboutSection({ hideTourReplay = false }: AboutSectionProps = {}) staleTime: Infinity, }); - // v1.4.47 W5 — onboarding chain-gate: the spotlight tour now auto- - // launches only ≥ 24 h after the wizard finishes (see - // `shouldAutoLaunchTour` in `components/onboarding/tour-launcher.tsx`). - // The carousel + tour no longer stack into ~90 s of forced onboarding - // on first visit. The tradeoff: a first-day user who *does* want the - // tour needs a discoverable manual trigger. Settings → About is the - // surface every user can find (every locale puts "About" right on the - // settings shell), so it carries the replay button next to the - // sources/docs links. Settings → Account still has its own - // "Restart onboarding tour" — both buttons share the same flow. - const [replayingTour, setReplayingTour] = useState(false); - // v1.4.48 M6c — text + type collapsed into one discriminated state so - // the two values can never drift (a "success" message rendered in the - // destructive colour was the latent bug class). - const [tourFeedback, setTourFeedback] = useState<{ - text: string; - type: "success" | "error"; - } | null>(null); - - async function handleReplayTour() { - setReplayingTour(true); - setTourFeedback(null); - // v1.4.48 M6b — both Settings → Account and Settings → About now - // delegate to the shared `restartOnboardingTour()` worker so the - // server flip + force-launch marker + window event live in one - // place. The helper returns a discriminated result; this surface - // only owns the translation + feedback rendering. - const result = await restartOnboardingTour(user?.id); - if (result.ok) { - setTourFeedback({ - text: t("onboarding.tour.restartConfirmation"), - type: "success", - }); - } else { - setTourFeedback({ text: t(result.messageKey), type: "error" }); - } - setReplayingTour(false); - } - // v1.4.36 W4f — the explicit "Check for updates" button is gone; // only the 24 h auto-check stays. When the auto-check reports // `newer_available`, a subtle upward-arrow badge appears next to @@ -239,17 +181,12 @@ export function AboutSection({ hideTourReplay = false }: AboutSectionProps = {}) }, [version?.version]); /* eslint-enable react-hooks/exhaustive-deps, react-hooks/set-state-in-effect */ + // v1.18.6 (W9) — the visible heading + subtitle now come from the section + // frame that wraps this component: `` on the settings + // route and the admin renderer's `SectionFrame` on `/admin/about`. The body + // is the version / license / sources cards. return ( -
-
-

- {t("settings.sections.about.title")} -

-
- +
{/* Identity card — version + license inline. The v1.4.2 layout stacked the license under the version inside its own boxed Badge, which read as a separate field even though the two values belong @@ -364,63 +301,10 @@ export function AboutSection({ hideTourReplay = false }: AboutSectionProps = {})
)} - {/* v1.4.47 W5 — Replay tour card. The dashboard spotlight - tour now auto-launches only ≥ 24 h after the wizard finishes, - so a first-day user who wants to see it still needs a - discoverable manual trigger. About is the surface every user - can find (it's the standard "where am I?" stop in the - settings shell), so the button lives here next to version + - links. Mirrors the stack-on-mobile / right-align-on-desktop - contract used by the Account section's "Restart onboarding - tour" card so the two surfaces feel consistent. - - v1.18.1 E5 — hidden on the admin → Über surface (`hideTourReplay`): - the onboarding tour is a user-facing affordance with no place in - the admin console. */} - {!hideTourReplay && ( -
-
-
- -
-

- {t("settings.about.tourReplay")} -

-

- {t("settings.about.tourReplayHint")} -

-
-
- -
- {tourFeedback && ( -

- {tourFeedback.text} -

- )} -
- )} + {/* v1.18.6 — the tour-replay card consolidated onto a single home in + Settings → Advanced ("Modul-Tour neu starten"). The About surface + no longer duplicates it; per-module "Diese Tour zeigen" affordances + on every module page cover in-context re-entry. */} {/* v1.4.36 W4f — the dedicated "Updates" card with the manual "Check for updates" button is gone. The 24 h auto- @@ -432,6 +316,6 @@ export function AboutSection({ hideTourReplay = false }: AboutSectionProps = {}) button were collectively never the point of the surface — the only signal users ever cared about was "is a newer release out?", and the badge answers that with no clicks. */} -
+
); } diff --git a/src/components/settings/account-section.tsx b/src/components/settings/account-section.tsx index bcd976592..bddb12312 100644 --- a/src/components/settings/account-section.tsx +++ b/src/components/settings/account-section.tsx @@ -367,20 +367,10 @@ export function AccountSection() { if (!user) return null; + // v1.18.6 (W9) — the visible heading + subtitle now come from the shared + // `` in the route; this body is the profile cards. return ( -
- {/* v1.18.1 (D0) — the explanatory section blurb was dropped so every - settings section starts at the same vertical height; the sr-only - heading stays for the accessibility tree. */} -
-

- {t("settings.sections.account.title")} -

-
- +
{/* Profile card */} {/* Profile photo card */} @@ -760,7 +750,7 @@ export function AccountSection() { -
+

); } diff --git a/src/components/settings/advanced-section.tsx b/src/components/settings/advanced-section.tsx index 98462ca15..b8710d71f 100644 --- a/src/components/settings/advanced-section.tsx +++ b/src/components/settings/advanced-section.tsx @@ -51,26 +51,16 @@ import { apiFetchRaw } from "@/lib/api/api-fetch"; * dialog. */ export function AdvancedSection() { - const { t } = useTranslations(); - + // v1.18.6 (W9) — the visible heading + subtitle now come from the shared + // `` in the route; this body is the advanced cards. return ( -
- {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */} -
-

- {t("settings.sections.advanced.title")} -

-
- +
{/* v1.18.1 (D1) — "Tour neu starten" relocated here from Account. */} -
+
); } diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx index 2ee988267..8cd89e9a7 100644 --- a/src/components/settings/ai-section.tsx +++ b/src/components/settings/ai-section.tsx @@ -21,10 +21,8 @@ import { AiInsightsCard } from "@/components/settings/ai/ai-insights-card"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; -import { useTranslations } from "@/lib/i18n/context"; export function AiSection() { - const { t } = useTranslations(); const { isAuthenticated } = useAuth(); // v1.16.6 — the auth query can resolve before this boundary // hydrates; every `disabled={!isAuthenticated}` binding in the child @@ -35,19 +33,8 @@ export function AiSection() { const mounted = useMounted(); const authed = mounted && isAuthenticated; - return ( -
- {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */} -
-

- {t("settings.sections.ai.title")} -

-
- - - - {/* v1.18.1 (D8) — the "About me" context moved to Settings → Account - (under Profil, before Zyklus-Tracking). */} -
- ); + // v1.18.6 (W9) — the visible heading + subtitle now come from the shared + // `` in the route; this body is the single provider + // card. (The "About me" context moved to Settings → Account in v1.18.1 D8.) + return ; } diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx index 8580b14cc..417b081de 100644 --- a/src/components/settings/api-section.tsx +++ b/src/components/settings/api-section.tsx @@ -37,19 +37,13 @@ interface ApiTokenInfo { } export function ApiSection() { - const { t } = useTranslations(); - + // v1.18.6 (W9) — the visible heading + subtitle now come from the shared + // `` in the route; this body is the API cards. return ( -
-
-

- {t("settings.sections.api.title")} -

-
- +
-
+
); } diff --git a/src/components/settings/channels-section.tsx b/src/components/settings/channels-section.tsx index 65da12c95..ebceea87e 100644 --- a/src/components/settings/channels-section.tsx +++ b/src/components/settings/channels-section.tsx @@ -8,30 +8,13 @@ * delivery provider — the same family as a connected device — and now reads as * a first-class settings home rather than a tab hidden behind Integrationen. * - * The content is the existing ``; this wrapper only - * owns the section frame (sr-only heading scaffold matching every other - * settings section). + * The content is the existing ``. v1.18.6 (W9) — + * the visible heading + subtitle now come from the shared + * `` in the route; this wrapper is the panel only. */ import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel"; -import { useTranslations } from "@/lib/i18n/context"; export function ChannelsSection() { - const { t } = useTranslations(); - - return ( -
- {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */} -
-

- {t("settings.sections.channels.title")} -

-
- - -
- ); + return ; } diff --git a/src/components/settings/coach-section.tsx b/src/components/settings/coach-section.tsx index 2d732bad2..7bdc78e9e 100644 --- a/src/components/settings/coach-section.tsx +++ b/src/components/settings/coach-section.tsx @@ -10,25 +10,31 @@ * 1. Disable Coach — the per-user opt-out (kept first so a user who * wants the surface gone finds it immediately). * 2. Coach preferences — tone, verbosity, data scope (`useCoachPrefs`). - * 3. Coach memory — durable-fact review + forget controls. + * 3. Proactive nudge — the daily-nudge opt-out + per-group frequency + * (moved here from Settings → Benachrichtigungen in + * v1.18.6: it is a Coach setting, not a generic + * notification). + * 4. Coach memory — durable-fact review + forget controls. * - * The nav entry is module-gated on `coach`. The preference + memory cards - * keep their own content gate on `!user.disableCoach` (mirrored from the + * The nav entry is module-gated on `coach`. The preference + nudge + memory + * cards keep their own content gate on `!user.disableCoach` (mirrored from the * old AI section): hiding the Coach hides its tuning + memory controls too. * * The "about me" context lives in the AI section, not here — the daily * briefing reads it too, so it is not a Coach-only setting. + * + * v1.18.6 (W9) — the visible heading + subtitle now come from the shared + * `` in the route; this body is the Coach cards only. */ import { DisableCoachCard } from "@/components/settings/ai/disable-coach-card"; import { CoachMemorySection } from "@/components/settings/coach-memory-section"; +import { CoachNudgeCard } from "@/components/settings/coach-nudge-card"; import { CoachPrefsSection } from "@/components/settings/coach-prefs-section"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; -import { useTranslations } from "@/lib/i18n/context"; export function CoachSection() { - const { t } = useTranslations(); const { isAuthenticated, user } = useAuth(); // Mirror the AI section's hydration-safe gating: the auth query can // resolve before this boundary hydrates, so gate the child props on @@ -42,22 +48,20 @@ export function CoachSection() { const coachEnabled = !mounted || !user?.disableCoach; return ( -
- {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */} -
-

- {t("settings.sections.coach.title")} -

-
- +
{coachEnabled && } + {/* Proactive Coach nudge — moved here from Benachrichtigungen (v1.18.6). + Keeps the `#coach-nudge` anchor so existing deep links resolve. */} + {coachEnabled && ( +
+ +
+ )} + {coachEnabled && } -
+
); } diff --git a/src/components/settings/dashboard-section.tsx b/src/components/settings/dashboard-section.tsx index 70fa97477..8342fbb7c 100644 --- a/src/components/settings/dashboard-section.tsx +++ b/src/components/settings/dashboard-section.tsx @@ -1,29 +1,12 @@ "use client"; import { DashboardLayoutSection } from "@/components/settings/dashboard-layout-section"; -import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link"; -import { useTranslations } from "@/lib/i18n/context"; +/** + * v1.18.6 (W9) — the visible heading + subtitle now come from the shared + * ``, and the "← back to hub" link rides the frame's + * `topSlot`. The body is the layout customizer only. + */ export function DashboardSection() { - const { t } = useTranslations(); - - return ( -
-
- -

- {t("settings.sections.dashboard.title")} -

- {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */} -
- - -
- ); + return ; } diff --git a/src/components/settings/export-section.tsx b/src/components/settings/export-section.tsx index f6cf3d354..4116dd584 100644 --- a/src/components/settings/export-section.tsx +++ b/src/components/settings/export-section.tsx @@ -70,17 +70,11 @@ async function downloadFromUrl(url: string, filename: string): Promise { export function ExportSection() { const { t } = useTranslations(); + // v1.18.6 (W9) — the visible page heading + subtitle now come from the + // shared `` in the route; the inner `

` keeps its + // own "other options" subsection label. return ( -
-
-

- {t("settings.sections.export.title")} -

-
- +
{/* v1.18.0 (S5) — the full health-record export moved to its own top-level "Gesundheitsakte" section. This page keeps the generic CSV/JSON data-out paths and the import surface. */} @@ -111,7 +105,7 @@ export function ExportSection() { `export.zip` and the generic JSON paths. The export routes had backends with no UI until now. */} -
+ ); } diff --git a/src/components/settings/gesundheitsakte-section.tsx b/src/components/settings/gesundheitsakte-section.tsx index dfa9d047d..72ee9adac 100644 --- a/src/components/settings/gesundheitsakte-section.tsx +++ b/src/components/settings/gesundheitsakte-section.tsx @@ -18,26 +18,10 @@ */ import { HealthRecordExportPanel } from "@/components/settings/health-record-export-panel"; -import { useTranslations } from "@/lib/i18n/context"; export function GesundheitsakteSection() { - const { t } = useTranslations(); - return ( -
- {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */} -
-

- {t("settings.sections.gesundheitsakte.title")} -

-
- - -
- ); + // v1.18.6 (W9) — the visible heading + subtitle and the module tour-replay + // trigger now live in the shared `` in the route; the + // body is the health-record export panel only. + return ; } diff --git a/src/components/settings/insights-section.tsx b/src/components/settings/insights-section.tsx index ffc434e4b..2fa49fa9d 100644 --- a/src/components/settings/insights-section.tsx +++ b/src/components/settings/insights-section.tsx @@ -2,8 +2,6 @@ import { InsightsOverviewArrangeSection } from "@/components/settings/insights-overview-arrange-section"; import { InsightsPillOrderSection } from "@/components/settings/insights-pill-order-section"; -import { SettingsHubBackLink } from "@/components/settings/settings-hub-back-link"; -import { useTranslations } from "@/lib/i18n/context"; /** * v1.15.18 — the "Insights" settings section (analogous to the Dashboard @@ -15,28 +13,16 @@ import { useTranslations } from "@/lib/i18n/context"; * * Both write through the same `/api/insights/layout` contract the tab strip * and the overview share, so a save here repaints both surfaces in lockstep. + * + * v1.18.6 (W9) — the visible "Insights" heading + subtitle now come from the + * shared ``, and the "← back to hub" link rides the + * frame's `topSlot`. The body is the two arrange cards only. */ export function InsightsSection() { - const { t } = useTranslations(); - return ( -
-
- -

- {t("settings.sections.insights.title")} -

- {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */} -
- +
-
+ ); } diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index 6ed4792c8..ef21a48bf 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -16,7 +16,6 @@ */ import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel"; -import { useTranslations } from "@/lib/i18n/context"; export { parseOAuthOutcome, @@ -24,21 +23,8 @@ export { } from "@/components/settings/integrations/connections-panel"; export function IntegrationsSection() { - const { t } = useTranslations(); - - return ( -
- {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */} -
-

- {t("settings.sections.integrations.title")} -

-
- - -
- ); + // v1.18.6 (W9) — the visible heading + subtitle and the module tour-replay + // trigger now live in the shared `` in the route; the + // body is the connections panel only. + return ; } diff --git a/src/components/settings/layout-section.tsx b/src/components/settings/layout-section.tsx index a636c39af..e5ad4528c 100644 --- a/src/components/settings/layout-section.tsx +++ b/src/components/settings/layout-section.tsx @@ -53,47 +53,38 @@ const LAYOUT_LINKS: ReadonlyArray = [ export function LayoutSection() { const { t } = useTranslations(); + // v1.18.6 (W9) — the visible heading + subtitle now come from the shared + // `` in the route; this body is the hub link list. return ( -
-
-

- {t("settings.sections.layout.title")} -

-
- -
    - {LAYOUT_LINKS.map((link) => { - const Icon = link.icon; - return ( -
  • - -
  • - ); - })} -
-
+
    + {LAYOUT_LINKS.map((link) => { + const Icon = link.icon; + return ( +
  • + +
  • + ); + })} +
); } diff --git a/src/components/settings/medications-section.tsx b/src/components/settings/medications-section.tsx index c19d5c261..a1421aba9 100644 --- a/src/components/settings/medications-section.tsx +++ b/src/components/settings/medications-section.tsx @@ -73,17 +73,10 @@ export function MedicationsSection() { ), ].map((m) => ({ id: m.id, name: m.name, dose: m.dose, active: m.active })); + // v1.18.6 (W9) — the visible heading + subtitle now come from the shared + // `` in the route; this body is the medication cards. return ( -
-
-

- {t("settings.sections.medications.title")} -

-
- +
{/* View preference — cards vs table. The shared header toggle writes optimistically, so there is no Save button here. */}
-
+ ); } diff --git a/src/components/settings/modules-section.tsx b/src/components/settings/modules-section.tsx index 95f1ae25b..d52ed2507 100644 --- a/src/components/settings/modules-section.tsx +++ b/src/components/settings/modules-section.tsx @@ -32,12 +32,9 @@ import { Droplet, Dumbbell, FileText, - HeartPulse, - Lock, MessageCircleHeart, Moon, Pill, - Scale, Smile, Sparkles, TestTube, @@ -52,10 +49,8 @@ import { useTranslations } from "@/lib/i18n/context"; import { apiPatch } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; import { - CORE_DOMAIN_KEYS, MODULE_REGISTRY, moduleDelegatesTo, - type CoreDomainKey, type ModuleKey, } from "@/lib/modules/registry"; @@ -77,13 +72,6 @@ const MODULE_ICONS: Record = { doctorReport: FileText, }; -/** Neutral Lucide glyph per core (always-on) domain. */ -const CORE_ICONS: Record = { - weight: Scale, - bloodPressure: HeartPulse, - pulse: Activity, -}; - export function ModulesSection() { const { t } = useTranslations(); const { user } = useAuth(); @@ -113,95 +101,65 @@ export function ModulesSection() { }, }); + // v1.18.6 (W9) — the visible heading + subtitle now come from the shared + // `` (the former muted-`

` intro folded into the + // frame's standard subtitle). The "Module immer aktiv" core-domains card + // was removed: a domain that can't be turned off doesn't need to be listed. return ( -

- {/* v1.18.1 (D0) — section blurb dropped for consistent top alignment. */} -
-

- {t("settings.sections.modules.title")} -

-
- - {/* Toggleable modules. v1.18.1 (D2) — the explanatory line above the - first card dropped; the per-row labels carry the meaning. */} -
- -
- {(Object.keys(MODULE_REGISTRY) as ModuleKey[]).map((key) => { - const def = MODULE_REGISTRY[key]; - // Default-on: a module is enabled unless explicitly `false`. - const enabled = modules[key] !== false; - // Delegated modules (cycle/coach) are owned elsewhere — never a - // live toggle here; deep-link to their real control instead. - const delegated = moduleDelegatesTo(key) !== undefined; - // Operator turned this module off server-wide: no per-user - // toggle can re-enable it, so show a read-only note. Delegated - // rows already deep-link, so this only matters for owned keys. - const operatorDisabled = - !delegated && moduleAvailability[key] === false; - return ( - toggle.mutate({ key, enabled: next })} - /> - ); - })} -
-
- - {/* Core domains — always on, read-only */} -
- -
- {CORE_DOMAIN_KEYS.map((key) => ( +
+ + {/* v1.18.6 — restore the one-line "what this section does" hint. */} +

+ {t("settings.sections.modules.toggleable.description")} +

+
+ {(Object.keys(MODULE_REGISTRY) as ModuleKey[]).map((key) => { + const def = MODULE_REGISTRY[key]; + // Default-on: a module is enabled unless explicitly `false`. + const enabled = modules[key] !== false; + // Delegated modules (cycle/coach) are owned elsewhere — never a + // live toggle here; deep-link to their real control instead. + const delegated = moduleDelegatesTo(key) !== undefined; + // Operator turned this module off server-wide: no per-user + // toggle can re-enable it, so show a read-only note. Delegated + // rows already deep-link, so this only matters for owned keys. + const operatorDisabled = + !delegated && moduleAvailability[key] === false; + return ( toggle.mutate({ key, enabled: next })} /> - ))} -
+ ); + })}
-
+ ); } diff --git a/src/components/settings/mood-section.tsx b/src/components/settings/mood-section.tsx index 5f31a7b58..dff7c7019 100644 --- a/src/components/settings/mood-section.tsx +++ b/src/components/settings/mood-section.tsx @@ -40,17 +40,10 @@ export function MoodSection() { ); + // v1.18.6 (W9) — the visible heading + subtitle now come from the shared + // `` in the route; this body is the mood-tag cards. return ( -
-
-

- {t("settings.sections.mood.title")} -

-
- +
{/* Groups — picker group order + own groups. */}
)}
-
+ ); } diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx index ff979d0c5..edeeb8df3 100644 --- a/src/components/settings/notifications-section.tsx +++ b/src/components/settings/notifications-section.tsx @@ -1,8 +1,6 @@ "use client"; import { useAuth } from "@/hooks/use-auth"; -import { useTranslations } from "@/lib/i18n/context"; -import { CoachNudgeCard } from "@/components/settings/coach-nudge-card"; import { LowStockCard } from "@/components/settings/low-stock-card"; import { MoodReminderCard } from "@/components/settings/mood-reminder-card"; @@ -17,44 +15,31 @@ import { MoodReminderCard } from "@/components/settings/mood-reminder-card"; * * Each reminder-type card is shown ONLY when its module is enabled, read from * the resolved `useAuth().user.modules` map (the same `/auth/me` map nav and - * Insights pills gate off). Mood → `mood`, Coach nudge → `coach`, low-stock - * runway → `medications` (a toggleable fail-open module since D3, so a missing - * key reads as enabled and a stale `/me` payload never blanks a card). + * Insights pills gate off). Mood → `mood`, low-stock runway → `medications` + * (a toggleable fail-open module since D3, so a missing key reads as enabled + * and a stale `/me` payload never blanks a card). * * v1.18.1 (D5) — the page is intentionally lean: the section blurb, the * channels / inbox cross-links, and the embedded Vorsorge (preventive-care) * editor were all removed. Vorsorge has its own `/vorsorge` page, so embedding * it here was a duplicate. Delivery CHANNELS (Telegram / ntfy / Web Push / * Webhook / Email) live under Settings → Integrations → Channels. + * + * v1.18.6 (W9) — the visible heading + subtitle now come from the shared + * ``. The proactive-Coach nudge card moved to Settings → + * Coach (it is a Coach setting, not a generic notification), so this screen + * carries the mood + low-stock reminder cards only. */ export function NotificationsSection() { - const { t } = useTranslations(); const { isAuthenticated, user } = useAuth(); // v1.18.0 (S4) — module-gated per-type visibility. Fail OPEN (`!== false`) // so a stale `/me` payload without the module map keeps every card visible. const moodEnabled = user?.modules?.mood !== false; - const coachEnabled = user?.modules?.coach !== false; const medsEnabled = user?.modules?.medications !== false; return ( -
- {/* v1.18.1 (D0/D5) — the section blurb and the channels / inbox cross- - links were dropped: the header now starts at the same height as every - other section and the page carries only the reminder-type cards. */} -
-

- {t("settings.sections.notifications.title")} -

-
- - {/* v1.18.1 (D5) — the Vorsorge (preventive-care) block was removed; it - has its own /vorsorge page, so embedding the editor here was a - duplicate. This screen keeps the three reminder-type cards below. */} - +
{/* Mood check-in reminder — only when the mood module is enabled. */} {moodEnabled ? (
@@ -69,13 +54,6 @@ export function NotificationsSection() {
) : null} - - {/* Proactive Coach nudge — only when the coach module is enabled. */} - {coachEnabled ? ( -
- -
- ) : null} -
+ ); } diff --git a/src/components/settings/section-nav-heading-spacer.tsx b/src/components/settings/section-nav-heading-spacer.tsx new file mode 100644 index 000000000..e8d7c13e6 --- /dev/null +++ b/src/components/settings/section-nav-heading-spacer.tsx @@ -0,0 +1,32 @@ +/** + * `` — invisible spacer that reserves exactly the + * height of a section's heading block (h1 + subtitle) plus the `space-y-6` + * gap that separates the heading from the first card. + * + * v1.18.6 (W9) — Marc's symmetry ask: the left settings / admin menu's first + * item must line up with the TOP OF THE FIRST CARD, not with the page + * heading. The page heading lives in the content column above the first card, + * so the sidebar needs to start one heading-block lower. Replicating the + * exact heading markup (same `text-2xl` h1 + `text-sm` subtitle) as a hidden, + * `aria-hidden` block keeps the offset pixel-accurate across locales and + * line-wrapping — a hard-coded `pt-[…]` would drift the moment a title or + * subtitle wraps to a second line. + * + * It is only rendered on `md+` (the desktop sticky sidebar); the mobile chip + * strip has no heading-to-align-with. + * + * v1.18.6 (L11) — pass the active section's resolved `title` so the h1 line + * wraps identically when a locale's title runs to two lines, rather than + * reserving a single ` ` line. The subtitle copy lives per-page in the + * section frame and is not threaded up here, so the spacer reserves one + * subtitle line; a subtitle that wraps to two lines in a given locale is the + * residual known limitation the original comment called out. + */ +export function SectionNavHeadingSpacer({ title }: { title?: string }) { + return ( + + ); +} diff --git a/src/components/settings/settings-section-frame.tsx b/src/components/settings/settings-section-frame.tsx new file mode 100644 index 000000000..067bd3b17 --- /dev/null +++ b/src/components/settings/settings-section-frame.tsx @@ -0,0 +1,114 @@ +"use client"; + +/** + * `` — the canonical page-frame for every Settings + * section. The settings twin of the admin renderer's `SectionFrame` + * (`src/app/admin/[section]/renderer.tsx`): it emits the one visible + * heading + subheading every page must show, in the reference style used by + * the Admin Konsole → Übersicht landing. + * + * Before this primitive every settings section hand-rolled its own + * `sr-only` `

`, so no settings page showed a visible heading and the + * left-menu-to-first-card alignment drifted page to page. Routing all 19 + * sections through one frame fixes both: a constant-height heading block + * above the first card, and a real visible `h1 + subtitle`. + * + * + * {cards} + * + * + * Title comes from `settings.sections..title`; subtitle from + * `settings.sections..subtitle`. The `

` keeps the historic + * `settings-section--title` id so the section's own `aria-labelledby` + * linkage — and the spotlight-tour anchors that reference it — stay intact. + * + * `topSlot` renders ABOVE the heading (used by the Layout-hub child + * editors for their "← back to hub" link, which must sit above the title). + * `headingAccessory` renders inline at the top-right of the heading row + * (used by Gesundheitsakte for its guided-tour re-entry trigger). + */ + +import * as React from "react"; + +import { useTranslations } from "@/lib/i18n/context"; +import type { SettingsSectionSlug } from "./section-slugs"; + +interface SettingsSectionFrameBaseProps { + /** Optional content rendered above the heading (e.g. a back-to-hub link). */ + topSlot?: React.ReactNode; + /** Optional content rendered inline at the top-right of the heading row + * (e.g. a guided-tour re-entry trigger). */ + headingAccessory?: React.ReactNode; + children: React.ReactNode; +} + +/** + * Slug mode (the 19 shell sections): title + subtitle come from + * `settings.sections..{title,subtitle}` and the `

` keeps the + * historic `settings-section--title` id for `aria-labelledby` and + * spotlight-tour anchors. + */ +interface SettingsSectionFrameSlugProps extends SettingsSectionFrameBaseProps { + slug: SettingsSectionSlug; +} + +/** + * Explicit mode (the per-module settings pages — Vorsorge / Illness / Labs — + * which deliberately stay out of the slug registry because their static + * routes win over `/settings/[section]`): caller passes resolved strings and + * an optional heading id. This lets `ModuleSettingsFrame` delegate here so + * every settings page shares one heading frame. + */ +interface SettingsSectionFrameExplicitProps + extends SettingsSectionFrameBaseProps { + title: string; + subtitle: string; + /** Heading element id (defaults to a stable slug-less id). */ + titleId?: string; +} + +export type SettingsSectionFrameProps = + | SettingsSectionFrameSlugProps + | SettingsSectionFrameExplicitProps; + +function isSlugProps( + props: SettingsSectionFrameProps, +): props is SettingsSectionFrameSlugProps { + return "slug" in props; +} + +export function SettingsSectionFrame(props: SettingsSectionFrameProps) { + const { t } = useTranslations(); + const { topSlot, headingAccessory, children } = props; + + let title: string; + let subtitle: string; + let titleId: string; + if (isSlugProps(props)) { + titleId = `settings-section-${props.slug}-title`; + title = t(`settings.sections.${props.slug}.title`); + subtitle = t(`settings.sections.${props.slug}.subtitle`); + } else { + titleId = props.titleId ?? "settings-section-title"; + title = props.title; + subtitle = props.subtitle; + } + + return ( +
+ {topSlot ?
{topSlot}
: null} +
+
+

+ {title} +

+

{subtitle}

+
+ {headingAccessory ? ( +
{headingAccessory}
+ ) : null} +
+ {children} +
+ ); +} diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index e47b640a4..fd6c3464e 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -48,6 +48,7 @@ import { isSettingsSectionSlug, type SettingsSectionSlug, } from "./section-slugs"; +import { SectionNavHeadingSpacer } from "./section-nav-heading-spacer"; // Re-export so existing call-sites importing from this module keep working. // New server-side call-sites (e.g. `generateStaticParams()`) should import @@ -259,8 +260,10 @@ export function SettingsShell({ active, children }: SettingsShellProps) { // no `moduleGate` (global / CORE) are always shown. const modules = user?.modules; const visibleSections = SETTINGS_SECTIONS.filter( - (section) => - !section.moduleGate || modules?.[section.moduleGate] !== false, + (section) => !section.moduleGate || modules?.[section.moduleGate] !== false, + ); + const activeSection = visibleSections.find( + (section) => section.slug === activeSlug, ); // v1.4.33 IW4 — keep the active chip in view inside the horizontal @@ -358,6 +361,15 @@ export function SettingsShell({ active, children }: SettingsShellProps) { className="hidden md:block" >
+ {/* v1.18.6 (W9) — push the first nav item down to the top of the + first card (level with the page heading's following gap), so + the menu reads symmetric with the content column. Feed the + active title so the spacer's h1 wraps the same way (L11). */} +
    {visibleSections.map((section) => { const isActive = section.slug === activeSlug; diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx index 077b6de04..9a714b156 100644 --- a/src/components/settings/sources-section.tsx +++ b/src/components/settings/sources-section.tsx @@ -278,31 +278,13 @@ export function SourcesSection() { return Object.keys(priority.deviceTypePriority).length; }, [priority]); + // v1.18.6 (W9) — the visible heading + subtitle now come from the shared + // `` (the off-style muted intro folded into the + // standard subtitle). The quiet cross-link back to Integrationen — where + // the sources this ladder ranks are added / removed — stays inside the + // card, below the ladder. return ( -
    - {/* v1.18.1 (D0/D4) — standalone left-side entry again. The section - blurb is dropped for consistent top alignment; the cross-link back - to Integrationen (where the sources this ladder ranks are added / - removed) stays as a quiet pointer. */} -
    -

    - {t("settings.sections.sources.title")} -

    -

    - {t("settings.sections.sources.integrationsHint")}{" "} - - {t("settings.sections.sources.integrationsHintLink")} - -

    -
    - +
    )} + + {/* v1.18.6 (W9) — quiet pointer back to Integrationen, where the + sources this ladder ranks are connected / removed. Folded into + the card so the page top stays the standard heading + subtitle. */} +

    + {t("settings.sections.sources.integrationsHint")}{" "} + + {t("settings.sections.sources.integrationsHintLink")} + +

    -
    +
); } diff --git a/src/components/settings/thresholds-section.tsx b/src/components/settings/thresholds-section.tsx index 7dd154044..2b94ab727 100644 --- a/src/components/settings/thresholds-section.tsx +++ b/src/components/settings/thresholds-section.tsx @@ -1,7 +1,6 @@ "use client"; import { ThresholdsEditorSection } from "@/components/settings/thresholds-editor-section"; -import { useTranslations } from "@/lib/i18n/context"; /** * `` — route-level wrapper for `/settings/thresholds` @@ -16,7 +15,8 @@ import { useTranslations } from "@/lib/i18n/context"; * flows, so each concern is self-contained on its own page. * * Visual structure on this page: - * 1. Section header (title + description). + * 1. Section heading + subtitle — supplied by the shared + * `` in the route (v1.18.6 W9). * 2. `` — per-metric range inputs. The editor * card keeps its own affordance row (icon, reset-all button). * @@ -31,21 +31,6 @@ import { useTranslations } from "@/lib/i18n/context"; * the names stop clashing. */ export function ThresholdsSection() { - const { t } = useTranslations(); - - return ( -
-
-

- {t("settings.sections.thresholds.title")} -

-
- - {/* Per-metric threshold ranges. */} - -
- ); + // Per-metric threshold ranges; the heading is supplied by the route frame. + return ; } diff --git a/src/components/ui/native-select.tsx b/src/components/ui/native-select.tsx index 97f7b33a9..5cfac9a66 100644 --- a/src/components/ui/native-select.tsx +++ b/src/components/ui/native-select.tsx @@ -59,13 +59,14 @@ const NATIVE_SELECT_CLASS = export type NativeSelectProps = React.SelectHTMLAttributes; -export const NativeSelect = React.forwardRef< - HTMLSelectElement, - NativeSelectProps ->(function NativeSelect({ className, children, style, ...props }, ref) { +export function NativeSelect({ + className, + children, + style, + ...props +}: NativeSelectProps) { return (