diff --git a/CHANGELOG.md b/CHANGELOG.md index e99571f23..90ca2058e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,74 @@ ## [Unreleased] +## [1.34.3] — 2026-07-31 + +A release about surfaces that knew something and did not say it. + +### Added + +- The workout page now shows the day around a session instead of linking away + from it: the day's pulse curve, the night before, and that day's mood, on the + page. It also offers the previous session of the same sport in one tap, says + how many workouts the list holds, and lets a sport narrow it. +- When a nutrient sync arrives and nothing can be stored, the nutrients page now + says so and why, in plain sentences, instead of showing the same empty page + someone would see who never synced at all. + +### Changed + +- The health score appears once. It is a single card below the greeting with the + breakdown behind one expander, rather than a compact copy in the greeting plus + nine full-width blocks underneath. Scores with too little input leave the grid + but keep their name and the reason they are not scored. +- The Coach input is one row tall until you type. It used to reserve a second row + for its buttons before anything had been written. +- Card headings sit closer to their content. A card with a title and no + description reserved space for a description that was never there, which + affected 45 of the 48 cards in the app. + +### Fixed + +- A profile save no longer discards everything because one field is invalid. + Valid fields are saved, rejected ones are named, and if nothing can be saved + the message says which field blocked it. Validator wording no longer reaches + the screen. +- A failed read on the nutrients page is no longer shown as "no data yet". + +### Contract + +- Share-link selection now publishes which leaves belong to which group and which + group is the sensitive one. The server always knew; a client that could not see + it had to withhold "select all" rather than risk sending psychometric scores in + a single tap. +- The dashboard snapshot schema declares three fields the route has been sending + all along. Because that schema is closed, a strictly generated client rejected + the real response. +- Rhythm events carry their own six-value classification, separate from the + three-value one on ECG rows. +- Six routes that clients consume were added to the published specification, + including sixteen skip reasons enumerated from the source rather than + transcribed. Three schemas were named in the specification's own prose while + never being emitted; all three are now published. + +### Operations + +- The optional worker in the bundled compose file inherited 14 of the app's 62 + environment variables, missing every mail, push and notification value. It is + the process that sends notifications, so a split deployment ran a worker that + silently sent nothing. Both services now share one definition. +- Bounded log rotation on both containers, dropped capabilities and a memory + limit on the app, with the reasoning recorded, including why the database gets + neither. +- Every nutrient ingest call is now recorded, not only the ones that stored a + row. The single durable trace used to be conditional on success, so a batch + that was entirely rejected looked exactly like a batch that never arrived. +- The build carries its own memory ceiling, so a local run, CI and the published + image all invoke it the same way. +- The self-hosting notes warn that a pinned image reference stays pinned, and name + the two checks that catch a deploy which reports success while serving the + previous release. + ## [1.34.2] — 2026-07-30 Point release. One workout that arrived more than once, and several surfaces diff --git a/docker-compose.yml b/docker-compose.yml index f07a0b262..503e87ed8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,199 @@ # a backup of everything. The optional GeoLite2 databases are either baked # into the image at build time or mounted read-only from the host and # pointed at with GEOLITE2_DIR — either way they are replaceable, not state. + +# Shared web/worker environment. `app` and the optional `app-worker` below +# both merge this anchor with `<<: *healthlog_env` instead of each keeping +# their own copy of the list. A hand-copied second list is exactly how the +# app-worker example drifted before: `app` grew to 62 variables over time +# while the worker example stayed at 14, missing every SMTP_*, VAPID_*, +# APNS_*, INSIGHTS_MAX_TOKENS and ALLOW_LOCAL_AI_PRIVATE_HOSTS entry — so a +# deployed worker (the process that sends notifications and runs the AI +# jobs) silently couldn't email, push, or send APNs, and had no local-AI +# host allowlist. One shared block makes that drift structurally +# impossible. HEALTHLOG_PROCESS_TYPE is the one value that's meant to +# differ per service and is overridden after the merge. +x-healthlog-env: &healthlog_env + # connection_limit is set explicitly: Prisma's own default is + # cpu_count*2+1, which starves the combined web+worker process + # (HEALTHLOG_PROCESS_TYPE=all) under background AI/rollup load. Default + # 20 suits a small host; raise DB_CONNECTION_LIMIT on a busy one (keep it + # below the database's max_connections, leaving headroom for pg-boss, + # migrations, and backups). pool_timeout is the seconds a query waits for + # a free connection before erroring. + DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public&connection_limit=${DB_CONNECTION_LIMIT:-20}&pool_timeout=${DB_POOL_TIMEOUT:-20}" + NODE_ENV: production + # Encryption at rest (AES-256-GCM). Generate each secret with + # `openssl rand -hex 32` (a 64-char hex string = 32 bytes). ENCRYPTION_KEY + # is the single-key path and is all most self-hosts need. The map form + # (ENCRYPTION_KEYS={"v1":"","v2":""} + ENCRYPTION_ACTIVE_KEY_ID=v2) + # is for key rotation — see docs/ops/encryption-key-rotation.md. Keep these + # values stable and backed up: lose them and the encrypted columns are + # unrecoverable. + ENCRYPTION_KEY: "${ENCRYPTION_KEY}" + ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}" + ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}" + API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}" + NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-http://localhost:3000}" + APP_URL: "${APP_URL:-http://localhost:3000}" + # Session-cookie Secure-flag opt-out. SET THIS TO false FOR LAN / plain-HTTP + # self-hosts (http://:3000 with no TLS reverse proxy) — otherwise the + # session cookie carries the `Secure` flag, the browser drops it over HTTP, + # and login silently fails. Unset → the flag tracks NODE_ENV=production + # (i.e. on); set to true to force it. Forwarded explicitly so an operator + # value in .env reaches the Node process (the `environment:` block is a + # whitelist — vars not listed here don't propagate even though + # docker-compose reads .env for `${VAR}` substitution at compose-up time). + SESSION_COOKIE_SECURE: "${SESSION_COOKIE_SECURE:-}" + NATIVE_CANVAS: "${NATIVE_CANVAS:-}" + WITHINGS_WEBHOOK_SECRET: "${WITHINGS_WEBHOOK_SECRET:-}" + # v1.18.11 (W3) — client-IP resolution + geo lookup. Behind Cloudflare + # the real visitor IP is in `cf-connecting-ip`; set + # TRUST_CF_CONNECTING_IP=1 ONLY when Cloudflare is in front (off by + # default — a direct-exposed host must not trust a forgeable header). + # IP_GEO_LOOKUP_URL overrides the default ipwho.is provider (HTTPS by + # default); IP_GEO_LOOKUP_DISABLED=1 stops all third-party IP egress. + # IP_GEO_ALLOW_INSECURE=true permits a plain-HTTP provider URL (e.g. the + # free ip-api.com endpoint) — off by default, an explicit operator choice. + # Listed here so operator values in .env reach the container (the + # `environment:` block is a whitelist — vars not listed never propagate). + TRUST_CF_CONNECTING_IP: "${TRUST_CF_CONNECTING_IP:-}" + IP_GEO_LOOKUP_URL: "${IP_GEO_LOOKUP_URL:-}" + IP_GEO_LOOKUP_DISABLED: "${IP_GEO_LOOKUP_DISABLED:-}" + IP_GEO_ALLOW_INSECURE: "${IP_GEO_ALLOW_INSECURE:-}" + # Directory the offline GeoLite2 databases are read from. Default + # `/opt/geolite2` — the path the published image bakes its own copy + # into, if the build had a MaxMind licence key. Point this at a + # directory you mount yourself (see the `volumes:` block below) to + # bring your own GeoLite2-City.mmdb + GeoLite2-ASN.mmdb, refreshed + # with `geoipupdate` on your own schedule. Listed here so an operator + # value in .env reaches the container (the `environment:` block is a + # whitelist). See docs/self-hosting/geolite2.md. + GEOLITE2_DIR: "${GEOLITE2_DIR:-/opt/geolite2}" + # all = bundle web + worker in this container (default, single-host) + # web = serve HTTP only; pair with a separate app-worker service + HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}" + BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}" + BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}" + BACKUP_S3_BUCKET: "${BACKUP_S3_BUCKET:-}" + BACKUP_S3_ACCESS_KEY: "${BACKUP_S3_ACCESS_KEY:-}" + BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}" + BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}" + BACKUP_RETENTION_DAYS: "${BACKUP_RETENTION_DAYS:-30}" + # Per-export ceiling on MedicationAdministration rows in the FHIR + # health-record Bundle. Bounded 1..50000, default 5000. Listed here so + # an operator override in .env actually reaches the container (the + # `environment:` block is a whitelist). + FHIR_MAX_MEDICATION_ADMINISTRATIONS: "${FHIR_MAX_MEDICATION_ADMINISTRATIONS:-}" + # WHOOP integration (optional). The app client id/secret are per-user + # BYO-keys stored encrypted in the DB, so there is no + # WHOOP_CLIENT_ID/SECRET env. WHOOP_WEBHOOK_SECRET is the instance-level + # path-segment + HMAC-verification secret for the webhook; + # WHOOP_REDIRECT_URI overrides the derived + # `${NEXT_PUBLIC_APP_URL}/api/whoop/callback` when the public URL + # differs. Listed here so an operator override in .env reaches the + # container (the `environment:` block is a whitelist). + WHOOP_WEBHOOK_SECRET: "${WHOOP_WEBHOOK_SECRET:-}" + WHOOP_REDIRECT_URI: "${WHOOP_REDIRECT_URI:-}" + # v1.17.0 (F4) — Polar AccessLink + Oura Cloud OAuth (shared app from + # env). Both pairs together enable each connect button; an unset pair + # leaves that integration disabled. The optional *_REDIRECT_URI overrides + # the derived `${NEXT_PUBLIC_APP_URL}/api/{polar,oura}/callback`. Listed + # here so operator values in .env reach the container (the `environment:` + # block is a whitelist). + POLAR_CLIENT_ID: "${POLAR_CLIENT_ID:-}" + POLAR_CLIENT_SECRET: "${POLAR_CLIENT_SECRET:-}" + POLAR_REDIRECT_URI: "${POLAR_REDIRECT_URI:-}" + OURA_CLIENT_ID: "${OURA_CLIENT_ID:-}" + OURA_CLIENT_SECRET: "${OURA_CLIENT_SECRET:-}" + OURA_REDIRECT_URI: "${OURA_REDIRECT_URI:-}" + # v1.28.x — Strava OAuth (shared app from env; per-user BYO keys override). + # Both together enable the connect button; an unset pair leaves it + # disabled. STRAVA_REDIRECT_URI overrides the derived + # `${NEXT_PUBLIC_APP_URL}/api/strava/callback`. Listed here so operator + # values in .env reach the container (the `environment:` block is a + # whitelist). + STRAVA_CLIENT_ID: "${STRAVA_CLIENT_ID:-}" + STRAVA_CLIENT_SECRET: "${STRAVA_CLIENT_SECRET:-}" + STRAVA_REDIRECT_URI: "${STRAVA_REDIRECT_URI:-}" + # OIDC SSO login (self-hosted IdP: Authentik/Keycloak/Authelia/etc). + # All three of ISSUER_URL/CLIENT_ID/CLIENT_SECRET together enable the + # "Sign in with SSO" button; set none to leave it disabled. OIDC_ONLY + # hides password/passkey login entirely, but only takes effect when + # the three above are also fully set — a half-configured group never + # locks every user out. It IS enforced server-side, and there is no + # OIDC flow for the native iOS client yet — enabling it locks iOS + # users out of sign-in entirely. Listed here so operator values in + # .env reach the container (the `environment:` block is a whitelist). + OIDC_ISSUER_URL: "${OIDC_ISSUER_URL:-}" + OIDC_CLIENT_ID: "${OIDC_CLIENT_ID:-}" + OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}" + OIDC_SCOPES: "${OIDC_SCOPES:-}" + OIDC_BUTTON_LABEL: "${OIDC_BUTTON_LABEL:-}" + OIDC_ONLY: "${OIDC_ONLY:-}" + # Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates + # POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the + # deployment-dashboard URL (e.g. the Coolify UI) linked in + # deploy-failure notifications. Listed here so operator values in + # .env reach the container (the `environment:` block is a whitelist). + DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}" + DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}" + # Optional replay hardening: when "true", the deploy webhook + # requires a fresh X-Deploy-Webhook-Timestamp header (±5 min). + # See docs/ops/deploy.md. + DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP: "${DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP:-}" + # TLS leaf SPKI-change alarm baseline (optional). The native client + # pins the served TLS leaf; this is the known-good pin set the monitor + # alarms against on a rotation. Comma-separated for the dual-pin + # renewal window. Listed here so an operator value in .env reaches the + # container (the `environment:` block is a whitelist). See + # docs/ops/tls-cert-pin.md. + TLS_LEAF_SPKI_PINS: "${TLS_LEAF_SPKI_PINS:-}" + # Local-AI SSRF opt-in (optional). The local AI provider rejects + # private/internal hosts by default; set this to an exact host allowlist + # (e.g. "ollama.lan,10.0.0.5") or "true" for any private host, intended + # for a self-hosted Ollama / LM Studio. Listed here so an operator value + # in .env reaches the container (the `environment:` block is a whitelist). + # See docs/integrations/ai-providers.md. + ALLOW_LOCAL_AI_PRIVATE_HOSTS: "${ALLOW_LOCAL_AI_PRIVATE_HOSTS:-}" + # Daily-briefing output-token ceiling (optional; default 2500, clamped + # 500-8000). Raise when a verbose model's briefing JSON is cut off. + # Listed here so an operator value in .env reaches the container (the + # `environment:` block is a whitelist). See docs/integrations/ai-providers.md. + INSIGHTS_MAX_TOKENS: "${INSIGHTS_MAX_TOKENS:-}" + # v1.17.1 — SMTP transport for the email notification channel (optional, + # all-or-none: HOST + PORT + FROM enable it; USER/PASS optional; SECURE + # toggles implicit TLS). Listed here so operator values in .env reach the + # container (the `environment:` block is a whitelist — vars not listed + # never propagate from .env, even with `${VAR}` substitution at + # compose-up). Per-user recipient is stored in the DB, not here. + SMTP_HOST: "${SMTP_HOST:-}" + SMTP_PORT: "${SMTP_PORT:-}" + SMTP_FROM: "${SMTP_FROM:-}" + SMTP_USER: "${SMTP_USER:-}" + SMTP_PASS: "${SMTP_PASS:-}" + SMTP_SECURE: "${SMTP_SECURE:-}" + # v1.17.1 — Web Push VAPID keys (optional). The admin Settings panel stores + # these in the DB (the supported path); these env vars are the fallback the + # loader reads when the DB pair is unset. Listed here so the documented env + # path reaches the container under the bundled compose. See + # docs/self-hosting/notifications.md. + VAPID_PUBLIC_KEY: "${VAPID_PUBLIC_KEY:-}" + VAPID_PRIVATE_KEY: "${VAPID_PRIVATE_KEY:-}" + VAPID_SUBJECT: "${VAPID_SUBJECT:-}" + # APNs for a self-hosted native iOS build (optional, env-only — no admin UI + # for the .p8). APNS_KEY_B64 is the escape-free base64 form preferred over + # APNS_KEY/APNS_KEY_FILE; the key must be scoped "Sandbox & Production". + # PWA users need none of this (Web Push covers them). See + # docs/self-hosting/notifications.md. + APNS_KEY_B64: "${APNS_KEY_B64:-}" + APNS_KEY_ID: "${APNS_KEY_ID:-}" + APNS_TEAM_ID: "${APNS_TEAM_ID:-}" + APNS_BUNDLE_ID: "${APNS_BUNDLE_ID:-}" + # Set "true" only if your own iOS build carries Apple's Critical Alerts + # entitlement; otherwise urgent alerts use the time-sensitive level. + APNS_CRITICAL_ENTITLEMENT: "${APNS_CRITICAL_ENTITLEMENT:-}" + services: # Single-container default (HEALTHLOG_PROCESS_TYPE=all). Existing # self-host deployments stay working with `docker compose up`. @@ -45,205 +238,52 @@ services: container_name: healthlog-app # The app runs as the unprivileged `nextjs` user and never needs a # setuid/setgid escalation; blocking privilege gain caps what a - # compromised process inside the container can do. + # compromised process inside the container can do. cap_drop removes + # every Linux capability on top of that — the process doesn't need any + # of them either. read_only:true is deliberately NOT set: the app + # writes to a Next.js runtime cache, and turning the filesystem + # read-only needs testing against that first, it's a not-yet, not an + # oversight. security_opt: - no-new-privileges:true + cap_drop: + - ALL ports: - "3000:3000" environment: - # connection_limit is set explicitly: Prisma's own default is - # cpu_count*2+1, which starves the combined web+worker process - # (HEALTHLOG_PROCESS_TYPE=all) under background AI/rollup load. Default - # 20 suits a small host; raise DB_CONNECTION_LIMIT on a busy one (keep it - # below the database's max_connections, leaving headroom for pg-boss, - # migrations, and backups). pool_timeout is the seconds a query waits for - # a free connection before erroring. - DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public&connection_limit=${DB_CONNECTION_LIMIT:-20}&pool_timeout=${DB_POOL_TIMEOUT:-20}" - NODE_ENV: production - # Encryption at rest (AES-256-GCM). Generate each secret with - # `openssl rand -hex 32` (a 64-char hex string = 32 bytes). ENCRYPTION_KEY - # is the single-key path and is all most self-hosts need. The map form - # (ENCRYPTION_KEYS={"v1":"","v2":""} + ENCRYPTION_ACTIVE_KEY_ID=v2) - # is for key rotation — see docs/ops/encryption-key-rotation.md. Keep these - # values stable and backed up: lose them and the encrypted columns are - # unrecoverable. - ENCRYPTION_KEY: "${ENCRYPTION_KEY}" - ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}" - ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}" - API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}" - NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-http://localhost:3000}" - APP_URL: "${APP_URL:-http://localhost:3000}" - # Session-cookie Secure-flag opt-out. SET THIS TO false FOR LAN / plain-HTTP - # self-hosts (http://:3000 with no TLS reverse proxy) — otherwise the - # session cookie carries the `Secure` flag, the browser drops it over HTTP, - # and login silently fails. Unset → the flag tracks NODE_ENV=production - # (i.e. on); set to true to force it. Forwarded explicitly so an operator - # value in .env reaches the Node process (the `environment:` block is a - # whitelist — vars not listed here don't propagate even though - # docker-compose reads .env for `${VAR}` substitution at compose-up time). - SESSION_COOKIE_SECURE: "${SESSION_COOKIE_SECURE:-}" - NATIVE_CANVAS: "${NATIVE_CANVAS:-}" - WITHINGS_WEBHOOK_SECRET: "${WITHINGS_WEBHOOK_SECRET:-}" - # v1.18.11 (W3) — client-IP resolution + geo lookup. Behind Cloudflare - # the real visitor IP is in `cf-connecting-ip`; set - # TRUST_CF_CONNECTING_IP=1 ONLY when Cloudflare is in front (off by - # default — a direct-exposed host must not trust a forgeable header). - # IP_GEO_LOOKUP_URL overrides the default ipwho.is provider (HTTPS by - # default); IP_GEO_LOOKUP_DISABLED=1 stops all third-party IP egress. - # IP_GEO_ALLOW_INSECURE=true permits a plain-HTTP provider URL (e.g. the - # free ip-api.com endpoint) — off by default, an explicit operator choice. - # Listed here so operator values in .env reach the container (the - # `environment:` block is a whitelist — vars not listed never propagate). - TRUST_CF_CONNECTING_IP: "${TRUST_CF_CONNECTING_IP:-}" - IP_GEO_LOOKUP_URL: "${IP_GEO_LOOKUP_URL:-}" - IP_GEO_LOOKUP_DISABLED: "${IP_GEO_LOOKUP_DISABLED:-}" - IP_GEO_ALLOW_INSECURE: "${IP_GEO_ALLOW_INSECURE:-}" - # Directory the offline GeoLite2 databases are read from. Default - # `/opt/geolite2` — the path the published image bakes its own copy - # into, if the build had a MaxMind licence key. Point this at a - # directory you mount yourself (see the `volumes:` block below) to - # bring your own GeoLite2-City.mmdb + GeoLite2-ASN.mmdb, refreshed - # with `geoipupdate` on your own schedule. Listed here so an operator - # value in .env reaches the container (the `environment:` block is a - # whitelist). See docs/self-hosting/geolite2.md. - GEOLITE2_DIR: "${GEOLITE2_DIR:-/opt/geolite2}" - # all = bundle web + worker in this container (default, single-host) - # web = serve HTTP only; pair with a separate app-worker service - HEALTHLOG_PROCESS_TYPE: "${HEALTHLOG_PROCESS_TYPE:-all}" - BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}" - BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}" - BACKUP_S3_BUCKET: "${BACKUP_S3_BUCKET:-}" - BACKUP_S3_ACCESS_KEY: "${BACKUP_S3_ACCESS_KEY:-}" - BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}" - BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}" - BACKUP_RETENTION_DAYS: "${BACKUP_RETENTION_DAYS:-30}" - # Per-export ceiling on MedicationAdministration rows in the FHIR - # health-record Bundle. Bounded 1..50000, default 5000. Listed here so - # an operator override in .env actually reaches the container (the - # `environment:` block is a whitelist). - FHIR_MAX_MEDICATION_ADMINISTRATIONS: "${FHIR_MAX_MEDICATION_ADMINISTRATIONS:-}" - # WHOOP integration (optional). The app client id/secret are per-user - # BYO-keys stored encrypted in the DB, so there is no - # WHOOP_CLIENT_ID/SECRET env. WHOOP_WEBHOOK_SECRET is the instance-level - # path-segment + HMAC-verification secret for the webhook; - # WHOOP_REDIRECT_URI overrides the derived - # `${NEXT_PUBLIC_APP_URL}/api/whoop/callback` when the public URL - # differs. Listed here so an operator override in .env reaches the - # container (the `environment:` block is a whitelist). - WHOOP_WEBHOOK_SECRET: "${WHOOP_WEBHOOK_SECRET:-}" - WHOOP_REDIRECT_URI: "${WHOOP_REDIRECT_URI:-}" - # v1.17.0 (F4) — Polar AccessLink + Oura Cloud OAuth (shared app from - # env). Both pairs together enable each connect button; an unset pair - # leaves that integration disabled. The optional *_REDIRECT_URI overrides - # the derived `${NEXT_PUBLIC_APP_URL}/api/{polar,oura}/callback`. Listed - # here so operator values in .env reach the container (the `environment:` - # block is a whitelist). - POLAR_CLIENT_ID: "${POLAR_CLIENT_ID:-}" - POLAR_CLIENT_SECRET: "${POLAR_CLIENT_SECRET:-}" - POLAR_REDIRECT_URI: "${POLAR_REDIRECT_URI:-}" - OURA_CLIENT_ID: "${OURA_CLIENT_ID:-}" - OURA_CLIENT_SECRET: "${OURA_CLIENT_SECRET:-}" - OURA_REDIRECT_URI: "${OURA_REDIRECT_URI:-}" - # v1.28.x — Strava OAuth (shared app from env; per-user BYO keys override). - # Both together enable the connect button; an unset pair leaves it - # disabled. STRAVA_REDIRECT_URI overrides the derived - # `${NEXT_PUBLIC_APP_URL}/api/strava/callback`. Listed here so operator - # values in .env reach the container (the `environment:` block is a - # whitelist). - STRAVA_CLIENT_ID: "${STRAVA_CLIENT_ID:-}" - STRAVA_CLIENT_SECRET: "${STRAVA_CLIENT_SECRET:-}" - STRAVA_REDIRECT_URI: "${STRAVA_REDIRECT_URI:-}" - # OIDC SSO login (self-hosted IdP: Authentik/Keycloak/Authelia/etc). - # All three of ISSUER_URL/CLIENT_ID/CLIENT_SECRET together enable the - # "Sign in with SSO" button; set none to leave it disabled. OIDC_ONLY - # hides password/passkey login entirely, but only takes effect when - # the three above are also fully set — a half-configured group never - # locks every user out. It IS enforced server-side, and there is no - # OIDC flow for the native iOS client yet — enabling it locks iOS - # users out of sign-in entirely. Listed here so operator values in - # .env reach the container (the `environment:` block is a whitelist). - OIDC_ISSUER_URL: "${OIDC_ISSUER_URL:-}" - OIDC_CLIENT_ID: "${OIDC_CLIENT_ID:-}" - OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}" - OIDC_SCOPES: "${OIDC_SCOPES:-}" - OIDC_BUTTON_LABEL: "${OIDC_BUTTON_LABEL:-}" - OIDC_ONLY: "${OIDC_ONLY:-}" - # Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates - # POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the - # deployment-dashboard URL (e.g. the Coolify UI) linked in - # deploy-failure notifications. Listed here so operator values in - # .env reach the container (the `environment:` block is a whitelist). - DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}" - DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}" - # Optional replay hardening: when "true", the deploy webhook - # requires a fresh X-Deploy-Webhook-Timestamp header (±5 min). - # See docs/ops/deploy.md. - DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP: "${DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP:-}" - # TLS leaf SPKI-change alarm baseline (optional). The native client - # pins the served TLS leaf; this is the known-good pin set the monitor - # alarms against on a rotation. Comma-separated for the dual-pin - # renewal window. Listed here so an operator value in .env reaches the - # container (the `environment:` block is a whitelist). See - # docs/ops/tls-cert-pin.md. - TLS_LEAF_SPKI_PINS: "${TLS_LEAF_SPKI_PINS:-}" - # Local-AI SSRF opt-in (optional). The local AI provider rejects - # private/internal hosts by default; set this to an exact host allowlist - # (e.g. "ollama.lan,10.0.0.5") or "true" for any private host, intended - # for a self-hosted Ollama / LM Studio. Listed here so an operator value - # in .env reaches the container (the `environment:` block is a whitelist). - # See docs/integrations/ai-providers.md. - ALLOW_LOCAL_AI_PRIVATE_HOSTS: "${ALLOW_LOCAL_AI_PRIVATE_HOSTS:-}" - # Daily-briefing output-token ceiling (optional; default 2500, clamped - # 500-8000). Raise when a verbose model's briefing JSON is cut off. - # Listed here so an operator value in .env reaches the container (the - # `environment:` block is a whitelist). See docs/integrations/ai-providers.md. - INSIGHTS_MAX_TOKENS: "${INSIGHTS_MAX_TOKENS:-}" - # v1.17.1 — SMTP transport for the email notification channel (optional, - # all-or-none: HOST + PORT + FROM enable it; USER/PASS optional; SECURE - # toggles implicit TLS). Listed here so operator values in .env reach the - # container (the `environment:` block is a whitelist — vars not listed - # never propagate from .env, even with `${VAR}` substitution at - # compose-up). Per-user recipient is stored in the DB, not here. - SMTP_HOST: "${SMTP_HOST:-}" - SMTP_PORT: "${SMTP_PORT:-}" - SMTP_FROM: "${SMTP_FROM:-}" - SMTP_USER: "${SMTP_USER:-}" - SMTP_PASS: "${SMTP_PASS:-}" - SMTP_SECURE: "${SMTP_SECURE:-}" - # v1.17.1 — Web Push VAPID keys (optional). The admin Settings panel stores - # these in the DB (the supported path); these env vars are the fallback the - # loader reads when the DB pair is unset. Listed here so the documented env - # path reaches the container under the bundled compose. See - # docs/self-hosting/notifications.md. - VAPID_PUBLIC_KEY: "${VAPID_PUBLIC_KEY:-}" - VAPID_PRIVATE_KEY: "${VAPID_PRIVATE_KEY:-}" - VAPID_SUBJECT: "${VAPID_SUBJECT:-}" - # APNs for a self-hosted native iOS build (optional, env-only — no admin UI - # for the .p8). APNS_KEY_B64 is the escape-free base64 form preferred over - # APNS_KEY/APNS_KEY_FILE; the key must be scoped "Sandbox & Production". - # PWA users need none of this (Web Push covers them). See - # docs/self-hosting/notifications.md. - APNS_KEY_B64: "${APNS_KEY_B64:-}" - APNS_KEY_ID: "${APNS_KEY_ID:-}" - APNS_TEAM_ID: "${APNS_TEAM_ID:-}" - APNS_BUNDLE_ID: "${APNS_BUNDLE_ID:-}" - # Set "true" only if your own iOS build carries Apple's Critical Alerts - # entitlement; otherwise urgent alerts use the time-sensitive level. - APNS_CRITICAL_ENTITLEMENT: "${APNS_CRITICAL_ENTITLEMENT:-}" + <<: *healthlog_env # Bring your own GeoLite2 databases. The published image only carries # MMDBs if the build had a MaxMind licence key, so on `:latest` the # offline tier is usually absent and every login IP goes to the online # provider instead. Create a free MaxMind account, pull # GeoLite2-City.mmdb + GeoLite2-ASN.mmdb with `geoipupdate`, mount the - # directory read-only, and set GEOLITE2_DIR to the container path. + # directory read-only, and uncomment below — the container path here + # matches GEOLITE2_DIR's default (/opt/geolite2), so this works as-is + # once uncommented. Mounting at a different container path also works, + # just also set GEOLITE2_DIR in .env to match it. # Steps: docs/self-hosting/geolite2.md. # # volumes: - # - ./geolite2:/var/lib/geolite2:ro + # - ./geolite2:/opt/geolite2:ro depends_on: db: condition: service_healthy restart: unless-stopped + # Bounded log rotation: the default json-file driver keeps every line + # forever, which is exactly what fills the disk on the small NAS / + # homelab hosts this file targets. 10m x 3 caps it at 30MB. + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + # Conservative memory ceiling for a small host. HEALTHLOG_PROCESS_TYPE=all + # bundles the AI/rollup worker into this same process, and unbounded that + # workload can grow large enough under load to starve Postgres of memory + # on a shared host. 1g is a starting point, not a measured ceiling — raise + # it with APP_MEMORY_LIMIT in .env if the app gets OOM-killed under real + # load. + mem_limit: "${APP_MEMORY_LIMIT:-1g}" healthcheck: test: [ @@ -265,25 +305,23 @@ services: # context: . # dockerfile: Dockerfile # container_name: healthlog-worker + # security_opt: + # - no-new-privileges:true + # cap_drop: + # - ALL # environment: - # DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public&connection_limit=${DB_CONNECTION_LIMIT:-20}&pool_timeout=${DB_POOL_TIMEOUT:-20}" - # NODE_ENV: production - # ENCRYPTION_KEY: "${ENCRYPTION_KEY}" - # ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}" - # ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}" - # API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}" - # APP_URL: "${APP_URL:-http://localhost:3000}" + # <<: *healthlog_env # HEALTHLOG_PROCESS_TYPE: "worker" - # BACKUP_ENCRYPTION_KEY: "${BACKUP_ENCRYPTION_KEY:-}" - # BACKUP_S3_ENDPOINT: "${BACKUP_S3_ENDPOINT:-}" - # BACKUP_S3_BUCKET: "${BACKUP_S3_BUCKET:-}" - # BACKUP_S3_ACCESS_KEY: "${BACKUP_S3_ACCESS_KEY:-}" - # BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}" - # BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}" # depends_on: # db: # condition: service_healthy # restart: unless-stopped + # logging: + # driver: json-file + # options: + # max-size: "10m" + # max-file: "3" + # mem_limit: "${WORKER_MEMORY_LIMIT:-1g}" db: # Pinned to the concrete minor instead of the floating `16-alpine` @@ -294,6 +332,13 @@ services: # restore; a major-version bump (17+) is not. image: postgres:16.14-alpine container_name: healthlog-db + # cap_drop: [ALL] is deliberately NOT applied here (unlike `app`): the + # official Postgres entrypoint starts as root, chowns the data + # directory, and drops privileges to the `postgres` user itself, which + # needs CAP_CHOWN/CAP_SETUID/CAP_SETGID to do. Dropping all capabilities + # makes that startup fail outright (verified locally — the container + # exits with "failed switching to 'postgres': operation not permitted"). + # no-new-privileges still blocks privilege escalation beyond that. security_opt: - no-new-privileges:true environment: @@ -308,6 +353,19 @@ services: timeout: 5s retries: 5 restart: unless-stopped + # No `mem_limit` here, deliberately, and it is not an oversight. Bounding + # Postgres is the more dangerous half of the pair: an app container the + # kernel kills restarts clean, a database killed mid-write does not. With + # only `app` bounded the app is what gives way first under host pressure, + # which is the failure order we want. Raise APP_MEMORY_LIMIT before + # reaching for a limit here. + # Bounded log rotation, same reasoning as `app`: default json-file grows + # without bound and fills the disk on a small NAS/homelab host. + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" volumes: pgdata: diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 5938e2f0b..25fb37174 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.34.2 + version: 1.34.3 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -4473,6 +4473,156 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/medications/intake/dose-history-import: + post: + tags: + - Medications + summary: Import a dose history exported from another medication tracker + description: "Reads a ten-column CSV export covering a whole regimen (multiple medications in one file), matching each + row against the caller's existing medications by name — a medication is NEVER created from an import file. + `text/csv` carries the actual dose history (`Date`/`Scheduled Date` in separate columns, mapped onto + `takenAt`/`scheduledFor`). `application/json` is accepted at the content-type check but the documented JSON + export shape always refuses at 422 (`json_carries_no_intake_time`): it has no field for when a dose was actually + taken, and inferring one from the scheduled time would manufacture an on-time history the file never claimed. + Body capped at 16 MB and 30 000 importable rows. `?dryRun=1` parses, matches, and returns the file verdict + WITHOUT writing. A real submission queues the write onto the same background job the per-medication import uses + (chunked, heartbeated, resumable) and returns 202 with a `statusUrl` to poll. Shares the 5/hour `import:` rate + bucket with every other bulk import surface. Auth via cookie or Bearer." + parameters: + - name: dryRun + in: query + required: false + schema: + type: string + enum: + - "1" + - "true" + description: When `1` / `true`, parse + match + return the file verdict without queuing a write. + requestBody: + required: true + content: + text/csv: + schema: + type: string + application/json: + schema: + type: string + responses: + "200": + description: Dry-run verdict (nothing written). + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationDoseHistoryImportDryRunEnvelope" + "202": + description: Import queued. + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationDoseHistoryImportQueuedEnvelope" + "401": *a1 + "409": + description: A dose-history import is already in progress. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "413": + description: File exceeds the 16 MB limit. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "415": + description: Content-Type must be text/csv or application/json. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: "The file could not be read (see `MedicationDoseHistoryImportFatalReason` for the closed `errorCode` set), + or it holds more than 30 000 importable rows (`errorCode: too_many_rows`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": *a4 + "503": + description: Background worker is not available. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/medications/{id}/intake/import/{jobId}/status: + get: + tags: + - Medications + summary: Poll a per-medication intake-import job + description: Returns the queued/running/finished state of a per-medication intake-import job, scoped to the caller's own + medication. A job created by the account-wide dose-history import (which carries no `medicationId`) never + resolves through this path — poll its own `statusUrl` instead. v1.33.0 widened `result.skipReasons[].reason` / + `progress.skipDetails[].reason` to the full 16-value `MedicationImportSkipReason` set. A foreign or unknown + job/medication id is sealed as 404. Auth via cookie or Bearer. + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: jobId + schema: + type: string + required: true + responses: + "200": + description: Current job status. + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" + "401": *a1 + "404": + description: No such job for this medication (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/medications/intake/dose-history-import/{jobId}/status: + get: + tags: + - Medications + summary: Poll an account-wide dose-history-import job + description: "Returns the queued/running/finished state of a dose-history-import job created by `POST + /api/medications/intake/dose-history-import` — the `statusUrl` that create response carries. Identical response + projection to the per-medication `/api/medications/{id}/intake/import/{jobId}/status` (both read through the + same `readMedicationIntakeImportJob` helper); this path narrows to the account-wide job (`medicationId: null`), + so a per-medication job id 404s here and vice versa. A foreign or unknown job id is sealed as 404. Auth via + cookie or Bearer." + parameters: + - in: path + name: jobId + schema: + type: string + required: true + responses: + "200": + description: Current job status. + content: + application/json: + schema: + $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" + "401": *a1 + "404": + description: No such job for this account (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/user/ai-provider: get: tags: @@ -5699,6 +5849,48 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/auth/me/report-selection: + get: + tags: + - Auth + summary: Read the owner's saved doctor-report selection + description: "Returns the saved report-scope profile, or `profile: null` when the account has never saved one (or the + stored blob no longer parses against the current leaf catalogue). Replaces the retired `GET + /api/auth/me/doctor-report-prefs`. Auth via cookie or Bearer." + responses: + "200": + description: The saved profile, or null. + content: + application/json: + schema: + $ref: "#/components/schemas/GetReportSelectionResponseEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + put: + tags: + - Auth + summary: Replace the owner's saved doctor-report selection + description: Replace, not merge — the body states the whole scope or it is not a scope; a leaf omitted from the body is + a leaf removed from the saved profile. An unknown leaf id is refused with a 422 that names it. The saved + selection is also what the FHIR REST face and the MCP doctor-visit surfaces replay for a caller that cannot ask + a human, so every write is audit-logged. Auth via cookie or Bearer. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SavedReportProfile" + responses: + "200": + description: The saved profile, echoed back. + content: + application/json: + schema: + $ref: "#/components/schemas/PutReportSelectionResponseEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/insights/chat: get: tags: @@ -7743,6 +7935,31 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /api/insights/rhythm-events: + get: + tags: + - Insights + summary: Device-flagged rhythm/HR/steadiness event timeline + description: "v1.10.0 (WX-B) — the authenticated user's timeline of device-flagged EVENT rows: irregular-rhythm / + high-HR / low-HR / walking-steadiness / breathing-disturbance notifications the user's wearable (Apple Watch / + Withings ScanWatch) already produced and synced. AWARENESS / SCREENING of the DEVICE's own decision — HealthLog + stores and reflects ONLY the classification result the device's certified on-device algorithm emitted, verbatim; + it never re-classifies and never produces a HealthLog diagnosis. `classification` carries the full six-value + verdict set (the three ECG verdicts plus the two walking-steadiness severities plus the neutral FIRED verdict) — + a distinct, wider enum than the three-value one on GET /api/insights/ecg. Data-availability-gated: an account + with no event rows returns `hasEvents: false`. Module-gated on `insights` and the operator `insightStatus` + assistant surface; no LLM call. Auth via cookie or Bearer." + responses: + "200": + description: The device-flagged event timeline (possibly empty). + content: + application/json: + schema: + $ref: "#/components/schemas/RhythmEventsResponseEnvelope" + "401": *a1 + "403": *a5 + "422": *a3 + "429": *a4 /api/insights/correlations: get: tags: @@ -11822,6 +12039,134 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/oura/sync: + post: + tags: + - Integrations + summary: Trigger an on-demand Oura sync + description: "Runs the same sync entry point the hourly cron uses. No body: the window is derived from the newest row + already held, not a client-supplied lookback. Rate-limited 5 requests / 60s per user." + responses: + "200": + description: Sync run complete. + content: + application/json: + schema: + $ref: "#/components/schemas/OuraSyncEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + "502": + description: The sync failed upstream. The classified failure is already recorded on the `oura` ledger; the response + body carries no upstream detail. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/polar/sync: + post: + tags: + - Integrations + summary: Trigger an on-demand Polar sync + description: "Runs both Polar legs (vitals then workouts) through the same entry point the hourly poll uses, each with + its own failure recording. No body: there is no full-history arm to call. Rate-limited 5 requests / 60s per + user." + responses: + "200": + description: Sync run complete. + content: + application/json: + schema: + $ref: "#/components/schemas/PolarSyncEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + "502": + description: The sync failed upstream. The classified failure is already recorded on the `polar` ledger; the response + body carries no upstream detail. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/strava/sync: + post: + tags: + - Integrations + summary: Trigger an on-demand Strava sync + description: "Incremental from the stored Strava cursor (`stravaLastActivityAt` minus an overlap) — a manual run picks + up wherever the last one stopped. No body and no full-history arm; deep history belongs to the connect-time + backfill queue. `failed` is always `false` on a 200: the underlying walk rethrows on the first row error rather + than settling partially, so reaching a 200 means every fetched activity was written. Rate-limited 5 requests / + 60s per user." + responses: + "200": + description: Sync run complete. + content: + application/json: + schema: + $ref: "#/components/schemas/StravaSyncEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + "502": + description: The sync failed upstream. The classified failure is already recorded on the `strava` ledger; the response + body carries no upstream detail. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/nightscout/sync: + post: + tags: + - Integrations + summary: Trigger an on-demand Nightscout sync + description: "Pulls the recent SGV window from the configured Nightscout instance. No body: there is no full-history arm + to call. The error body is always a fixed message, never the caught error's own — a Nightscout base URL carries + its API token as a query parameter, so an upstream message could leak it. Rate-limited 5 requests / 60s per + user." + responses: + "200": + description: Sync run complete. + content: + application/json: + schema: + $ref: "#/components/schemas/NightscoutSyncEnvelope" + "401": *a1 + "422": + description: The configured Nightscout origin is private (RFC 1918 / loopback / link-local) and the server operator has + not approved private-origin access for this deployment. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": *a4 + "502": + description: The sync failed upstream. The classified failure is already recorded on the `nightscout` ledger; the + response body carries no upstream detail. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/google-health/sync/status: + get: + tags: + - Integrations + summary: Read the caller's Google Health sync progress + description: "Returns only the authenticated user's own bounded current/most-recent sync run; there is no way to address + another run or another connection's progress by query parameter (query parameters are ignored by construction). + Every field is re-clamped and re-checked against its closed enum on read, so a malformed or legacy stored blob + degrades to safe defaults (e.g. `state: \"failed\"`, `resource: \"unknown\"`) rather than reaching the client + verbatim. Auth via cookie or Bearer." + responses: + "200": + description: "The current/most-recent run, or `data: null` when none has ever run." + content: + application/json: + schema: + $ref: "#/components/schemas/GoogleHealthSyncStatusEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/daily/digest: get: tags: @@ -13666,6 +14011,40 @@ components: `null` switches the alert off. `baseUpdatedAt` is the optional optimistic-concurrency token: echo it to have the server refuse (409) a write that would silently revert a category another writer set from a fresher read (e.g. iOS `medication.clientManaged`). Omit it for the legacy unconditional write." + SavedReportProfile: + type: object + properties: + v: + type: number + const: 2 + leaves: + maxItems: 91 + type: array + items: + type: string + minLength: 1 + maxLength: 64 + format: + type: string + enum: + - pdf + - fhir + - package + rangeDays: + type: integer + minimum: 1 + maximum: 365 + includeCharts: + type: boolean + required: + - v + - leaves + - format + - rangeDays + - includeCharts + additionalProperties: false + description: "A saved doctor-report scope: which leaves are included plus the export format, trailing window and chart + toggle. Replace-only — there is no partial-merge write." FencedCoachChatRequest: type: object properties: @@ -16071,8 +16450,24 @@ components: groups: type: array items: - type: string - description: Selection group ids, in presentation order. + type: object + properties: + id: + type: string + description: Group id. + leaves: + type: array + items: + type: string + description: Member leaf ids, in the order the panel renders them. + sensitive: + description: "Present and true only on the fenced tier: no single control may switch on more than one of its leaves." + type: boolean + required: + - id + - leaves + additionalProperties: false + description: Selection groups, in presentation order, each carrying its member leaf ids. leaves: type: array items: @@ -16119,6 +16514,8 @@ components: type: boolean documentCount: type: number + documentOnly: + type: boolean needsReselection: type: boolean allowFhirApi: @@ -16163,6 +16560,7 @@ components: - rangeEnd - protected - documentCount + - documentOnly - needsReselection - allowFhirApi - resourceTypes @@ -16214,6 +16612,8 @@ components: type: boolean documentCount: type: number + documentOnly: + type: boolean needsReselection: type: boolean allowFhirApi: @@ -16245,6 +16645,7 @@ components: - rangeEnd - protected - documentCount + - documentOnly - needsReselection - allowFhirApi - resourceTypes @@ -19567,6 +19968,8 @@ components: 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)))$ + dayKey: + type: string durationSec: type: integer minimum: 0 @@ -19670,11 +20073,16 @@ components: - type: "null" canonicalId: type: string + previousWorkoutId: + anyOf: + - type: string + - type: "null" required: - id - sportType - startedAt - endedAt + - dayKey - durationSec - distanceM - activeEnergyKcal @@ -19695,6 +20103,7 @@ components: - sportContext - aiInsight - canonicalId + - previousWorkoutId additionalProperties: false WorkoutRouteGeometry: type: object @@ -22185,6 +22594,359 @@ components: - data - error additionalProperties: false + MedicationDoseHistoryImportDryRunEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationDoseHistoryImportResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationDoseHistoryImportResponse: + type: object + properties: + dryRun: + type: boolean + jobId: + anyOf: + - type: string + - type: "null" + description: Null on a dry run (nothing was queued). + status: + description: Present only on a real (non-dry-run) submission. + type: string + const: queued + statusUrl: + anyOf: + - type: string + - type: "null" + description: Relative poll path (`/api/medications/intake/dose-history-import/{jobId}/status`). Null on a dry run. + file: + $ref: "#/components/schemas/MedicationDoseHistoryImportFile" + required: + - dryRun + - jobId + - statusUrl + - file + additionalProperties: false + description: The file-level verdict, plus the queued job pointer on a real submission. A dry run (`?dryRun=1`) returns + the same `file` verdict with `jobId`/`statusUrl` null and no `status` field — parses, matches, and reports + without writing anything. + MedicationDoseHistoryImportFile: + type: object + properties: + rowsRead: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Data rows read from the file, refusals included. + queued: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Rows that parsed and matched a medication. + refused: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Total rows that did not queue, across every reason. + refusedByReason: + type: array + items: + $ref: "#/components/schemas/MedicationImportSkipGroup" + description: One entry per reason that actually occurred, count descending then reason. + unmatchedMedications: + maxItems: 50 + type: array + items: + type: string + description: Medication names the file used that match nothing on the record, deduplicated, first-seen order, capped at + 50. + ambiguousMedications: + maxItems: 50 + type: array + items: + type: string + description: Names that match more than one medication, so no single one can be meant. Capped at 50. + mirroredMedications: + maxItems: 50 + type: array + items: + type: string + description: Names that match only a medication mirrored from an external list. Capped at 50. + unknownColumns: + maxItems: 50 + type: array + items: + type: string + description: Header cells the importer has no verdict for. Capped at 50. + codingsNotRead: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Rows whose `Codings` cell held something left unread. + fromArchivedMedications: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Rows the file marked as belonging to a medication archived in the source. + required: + - rowsRead + - queued + - refused + - refusedByReason + - unmatchedMedications + - ambiguousMedications + - mirroredMedications + - unknownColumns + - codingsNotRead + - fromArchivedMedications + additionalProperties: false + MedicationImportSkipGroup: + type: object + properties: + reason: + $ref: "#/components/schemas/MedicationImportSkipReason" + count: + type: integer + minimum: 1 + maximum: 9007199254740991 + required: + - reason + - count + additionalProperties: false + MedicationImportSkipReason: + type: string + enum: + - duplicate_in_file + - already_recorded + - medication_not_found + - medication_ambiguous + - medication_is_mirrored + - status_no_dose_information + - status_reminder_event + - status_notification_not_sent + - status_unknown + - missing_timestamp + - missing_timezone_offset + - unreadable_timestamp + - implausible_timestamp + - missing_medication + - unreadable_dosage + - unreadable_row + description: "Why one source row did not become an intake row. Closed 16-value set: duplicate_in_file, already_recorded, + medication_not_found, medication_ambiguous, medication_is_mirrored, status_no_dose_information, + status_reminder_event, status_notification_not_sent, status_unknown, missing_timestamp, missing_timezone_offset, + unreadable_timestamp, implausible_timestamp, missing_medication, unreadable_dosage, unreadable_row." + MedicationDoseHistoryImportQueuedEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationDoseHistoryImportResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationIntakeImportJobStatusEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MedicationIntakeImportJobStatusResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MedicationIntakeImportJobStatusResponse: + type: object + properties: + jobId: + type: string + status: + type: string + enum: + - queued + - running + - done + - failed + description: queued — admitted, not yet picked up. running — the worker is walking entries. done — finished (see + `result` for the outcome, even a fully-refused run). failed — the worker could not complete the run (see + `failureReason`). + progress: + $ref: "#/components/schemas/MedicationImportProgress" + result: + anyOf: + - $ref: "#/components/schemas/MedicationImportResult" + - type: "null" + description: Set only once `status` is `done`; null before that. + failureReason: + anyOf: + - type: string + - type: "null" + description: Set only when `status` is `failed`; null otherwise. + createdAt: + 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)))$ + startedAt: + 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: When the worker picked the job up; null while still queued. + completedAt: + 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: When the job reached `done` or `failed`; null while active. + required: + - jobId + - status + - progress + - result + - failureReason + - createdAt + - startedAt + - completedAt + additionalProperties: false + description: Poll target for a queued intake-import job — shared by the per-medication import and the account-wide + dose-history import, so a client polls both through one shape. + MedicationImportProgress: + type: object + properties: + processed: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Entries the worker has walked so far, in file order. + total: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Entries queued for this job. + imported: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Entries that have reached the database so far. + skippedByReason: + type: object + propertyNames: + $ref: "#/components/schemas/MedicationImportSkipReason" + additionalProperties: + type: integer + minimum: 1 + maximum: 9007199254740991 + description: Running skip tally by reason. A reason with no skips yet is absent from the object, never present as 0. + skipDetails: + description: Same bounded detail list as the finished `result`. + type: array + items: + $ref: "#/components/schemas/MedicationImportSkipDetail" + skippedDetailsOmitted: + type: integer + minimum: 0 + maximum: 9007199254740991 + touchedDays: + type: array + items: + type: string + description: Local calendar days (YYYY-MM-DD) touched so far, driving the chunked rollup recompute behind the write. + rollupProcessed: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Of `touchedDays`, how many have had their rollup recomputed. + required: + - processed + - total + - imported + - skippedByReason + - touchedDays + - rollupProcessed + additionalProperties: false + description: "In-flight job progress, written whole at admission and advanced in place by the worker. Present from the + moment the job is created (queued), so `processed: 0, total: N` is the shape a client sees immediately after + kickoff, not an empty object." + MedicationImportSkipDetail: + type: object + properties: + line: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: 1-based source-file row ordinal. + reason: + $ref: "#/components/schemas/MedicationImportSkipReason" + required: + - line + - reason + additionalProperties: false + description: One refused source row, reduced to the ordinal + reason — never the row's own content (no medication name, + timestamp, or dosage text). + MedicationImportResult: + type: object + properties: + imported: + type: integer + minimum: 0 + maximum: 9007199254740991 + skipped: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Total entries that did not become a row. + skipReasons: + type: array + items: + $ref: "#/components/schemas/MedicationImportSkipGroup" + description: One entry per reason that actually occurred, count descending then reason — never the same sentence + repeated per row. Empty when nothing was skipped. + skipDetails: + description: First bounded source-row details (capped at 200). Absent on a legacy job run before this field existed. + type: array + items: + $ref: "#/components/schemas/MedicationImportSkipDetail" + skippedDetailsOmitted: + description: Count of additional skipped rows beyond the `skipDetails` cap, counted without retaining their contents. + Absent when nothing was omitted. + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - imported + - skipped + - skipReasons + additionalProperties: false + description: The finished job's outcome. Null while the job is still queued or running — `status` + `progress` are the + fields to read meanwhile. GetAiProviderResponse: type: object properties: @@ -24639,6 +25401,61 @@ components: - data - error additionalProperties: false + GetReportSelectionResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/GetReportSelectionResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GetReportSelectionResponse: + type: object + properties: + profile: + anyOf: + - $ref: "#/components/schemas/SavedReportProfile" + - type: "null" + description: The saved profile, or null when the account has never saved one, or when the stored blob no longer parses + against the current leaf catalogue. Null is never resolved to a default scope server-side — the client + applies its own named template and shows that as a visible choice. + required: + - profile + additionalProperties: false + PutReportSelectionResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/PutReportSelectionResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + PutReportSelectionResponse: + type: object + properties: + profile: + $ref: "#/components/schemas/SavedReportProfile" + required: + - profile + additionalProperties: false CoachConversationsPageResponse: type: object properties: @@ -26851,6 +27668,199 @@ components: `walkingDoubleSupportPercentage`, `environmentalAudioExposure`, `headphoneAudioExposure`, `activeEnergyBurned`). Each entry carries `value`, `measuredAt` (ISO8601), and the canonical `unit`. Types the user has never logged are omitted." + targetBands: + type: object + properties: + bpTargets: + anyOf: + - type: object + properties: + sysLow: + type: number + sysHigh: + type: number + diaLow: + type: number + diaHigh: + type: number + required: + - sysLow + - sysHigh + - diaLow + - diaHigh + additionalProperties: false + - type: "null" + description: Personalised blood-pressure target numbers. Null when the profile has no date of birth. + bpSysRange: + anyOf: + - type: object + properties: + greenMin: + type: number + greenMax: + type: number + orangeMin: + type: number + orangeMax: + type: number + required: + - greenMin + - greenMax + - orangeMin + - orangeMax + additionalProperties: false + - type: "null" + description: Systolic traffic-light range. Null when the profile has no date of birth. + bpDiaRange: + anyOf: + - type: object + properties: + greenMin: + type: number + greenMax: + type: number + orangeMin: + type: number + orangeMax: + type: number + required: + - greenMin + - greenMax + - orangeMin + - orangeMax + additionalProperties: false + - type: "null" + description: Diastolic traffic-light range. Null when the profile has no date of birth. + pulseDisplayRange: + type: object + properties: + greenMin: + type: number + greenMax: + type: number + orangeMin: + type: number + orangeMax: + type: number + required: + - greenMin + - greenMax + - orangeMin + - orangeMax + additionalProperties: false + description: Resting-pulse display range. Always present — falls back to the AHA reference range with no date of birth. + pulseBands: + type: array + items: + type: object + properties: + min: + type: number + max: + type: number + color: + type: string + opacity: + type: number + strokeOpacity: + type: number + required: + - min + - max + - color + additionalProperties: false + description: Resting-pulse chart bands. Always present — AHA fallback with no date of birth. + bodyFatRange: + type: object + properties: + min: + type: number + max: + type: number + required: + - min + - max + additionalProperties: false + description: Gender-aware body-fat target range. Always present. + bodyFatBands: + type: array + items: + type: object + properties: + min: + type: number + max: + type: number + color: + type: string + opacity: + type: number + strokeOpacity: + type: number + required: + - min + - max + - color + additionalProperties: false + description: Body-fat chart bands. Always present. + weightRange: + anyOf: + - type: object + properties: + greenMin: + type: number + greenMax: + type: number + orangeMin: + type: number + orangeMax: + type: number + required: + - greenMin + - greenMax + - orangeMin + - orangeMax + additionalProperties: false + - type: "null" + description: "Weight traffic-light range: the user's own target band when set, else the height-derived WHO band, else + null (no height and no target)." + weightBands: + anyOf: + - type: array + items: + type: object + properties: + min: + type: number + max: + type: number + color: + type: string + opacity: + type: number + strokeOpacity: + type: number + required: + - min + - max + - color + additionalProperties: false + - type: "null" + description: Weight chart bands, from the same range. Null when `weightRange` is null. + required: + - bpTargets + - bpSysRange + - bpDiaRange + - pulseDisplayRange + - pulseBands + - bodyFatRange + - bodyFatBands + - weightRange + - weightBands + additionalProperties: false + description: Server-computed band / target math resolved from the user's profile facts, so the client reads these + numbers instead of recomputing them. Every sub-field is null only when the driving profile fact is missing + (no date of birth for blood pressure, no height and no weight target for weight). tiles: type: object properties: @@ -27150,6 +28160,144 @@ components: - episodeCount additionalProperties: false - type: "null" + tension: + description: "Locale-agnostic tension verdict: present only when the readiness composite's contributors disagree. + `positive`/`negative` carry readiness contributor keys the client maps to localised labels. Null on + a coherent day or when a clinical red-flag suppresses it. Optional so older cached snapshots without + the field stay valid." + anyOf: + - type: object + properties: + band: + type: string + enum: + - green + - yellow + - red + positive: + type: array + items: + type: string + enum: + - rhr + - hrv + - sleep + - respiratory + - mood + negative: + type: array + items: + type: string + enum: + - rhr + - hrv + - sleep + - respiratory + - mood + required: + - band + - positive + - negative + additionalProperties: false + - type: "null" + returnToBand: + description: "Locale-agnostic return-to-baseline event: present only when a salient metric (resting heart rate, HRV, + respiratory rate, or weight) has come back inside the user's own personal range after a genuine + prior out-of-band run. At most one, the most salient. `metricType` is the measurement type the + client maps to a localised metric name; `daysInside` is how long it has now sat back inside range. + Null otherwise. Optional so older cached snapshots without the field stay valid." + anyOf: + - type: object + properties: + metricType: + 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 + - RESILIENCE + - PHQ9_SCORE + - GAD7_SCORE + - GRIP_STRENGTH + - PAIN_NRS + - WAIST_CIRCUMFERENCE + - WAIST_TO_HEIGHT + - WHO5_SCORE + - SCI_SCORE + daysInside: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + required: + - metricType + - daysInside + additionalProperties: false + - type: "null" required: - score - band @@ -27217,6 +28365,23 @@ components: type: string additionalProperties: {} - type: "null" + briefingMemory: + description: "The briefing recall + forward-look: `recall` is the prior period's narrative headline, `forward` points + ahead to the most salient trend drift (or a calm 'holding steady' line). Both are already-localised prose + the client renders verbatim. Null when no prior narrative is on file or no briefing is shown. Optional so + older cached snapshots without the field stay valid." + anyOf: + - type: object + properties: + recall: + type: string + forward: + type: string + required: + - recall + - forward + additionalProperties: false + - type: "null" briefingState: type: string enum: @@ -27240,6 +28405,7 @@ components: - layout - layoutCatalogue - metricStates + - targetBands - tiles - extras - medsToday @@ -28508,6 +29674,119 @@ components: narrowed in the query where — a foreign / unknown id 404s. The waveform is AES-256-GCM at rest, decrypted through the fail-closed codec. HealthLog does not interpret the trace, measure intervals, annotate beats, or emit a verdict of its own. no-store. + RhythmEventsResponseEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/RhythmEventsResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + RhythmEventsResponse: + type: object + properties: + events: + type: array + items: + $ref: "#/components/schemas/RhythmEventListItem" + description: The user's device-flagged events, newest first (capped at 200). + hasEvents: + type: boolean + description: False for an account with no event rows — the client un-mounts the whole surface (data-availability floor). + required: + - events + - hasEvents + additionalProperties: false + description: Timeline of device-flagged EVENT rows (irregular-rhythm / high-HR / low-HR / walking-steadiness / + breathing-disturbance) the user's wearable (Apple Watch / Withings ScanWatch) already produced and synced. + AWARENESS / SCREENING of the DEVICE's own decision — HealthLog stores and reflects the classification result + verbatim, never a raw waveform, and never re-classifies. Read-only; no LLM call. + RhythmEventListItem: + type: object + properties: + id: + type: string + description: Measurement row id (cuid). + type: + type: string + enum: + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT + description: The HealthKit category the device flagged. IRREGULAR_RHYTHM_NOTIFICATION + (`HKCategoryTypeIdentifierIrregularHeartRhythmEvent` + ScanWatch AFib screening), HIGH_HEART_RATE_EVENT / + LOW_HEART_RATE_EVENT (a sustained heart rate above / below the user's configured threshold while apparently + inactive), WALKING_STEADINESS_EVENT (`HKCategoryTypeIdentifierAppleWalkingSteadinessEvent`; the severity + rides in `classification`), BREATHING_DISTURBANCE_EVENT (`HKCategoryTypeIdentifierSleepApneaEvent` + + `AppleSleepingBreathingDisturbances`, an elevated breathing-disturbance / possible sleep-apnea screening + signal). + classification: + anyOf: + - type: string + enum: + - IRREGULAR + - NOT_DETECTED + - INCONCLUSIVE + - LOW + - VERY_LOW + - FIRED + - type: "null" + description: The RECORDING DEVICE's own verdict, surfaced verbatim. IRREGULAR / NOT_DETECTED / INCONCLUSIVE are the + ECG-style rhythm verdicts (device flagged a possible irregular rhythm / algorithm ran and found nothing / + signal too poor to classify) and occur on an `IRREGULAR_RHYTHM_NOTIFICATION` row. LOW / VERY_LOW are + walking-steadiness severities and occur on a `WALKING_STEADINESS_EVENT` row. FIRED is the neutral "event + occurred" verdict with no severity gradient, and occurs on a `HIGH_HEART_RATE_EVENT` / + `LOW_HEART_RATE_EVENT` / `BREATHING_DISTURBANCE_EVENT` row. Null when the source reported none. HealthLog + never re-classifies — this is the device's certified on-device result. Distinct from + `EcgRecordingListItem.classification` / `EcgDetailResponse.classification` above, which are scoped to the + three ECG-only values. + occurredAt: + 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: Device-reported event time (`measuredAt`). + source: + type: string + enum: + - MANUAL + - WITHINGS + - IMPORT + - APPLE_HEALTH + - COMPUTED + - WHOOP + - FITBIT + - NIGHTSCOUT + - POLAR + - OURA + - TELEGRAM + - MCP + - GOOGLE_HEALTH + - STRAVA + deviceType: + anyOf: + - type: string + - type: "null" + description: Device class that reported the event (`watch | band | ring | phone | scale | other | unknown`); null when + the source omitted it (treated as `unknown`). + required: + - id + - type + - classification + - occurredAt + - source + - deviceType + additionalProperties: false CorrelationDiscoveryResponseEnvelope: type: object properties: @@ -28591,6 +29870,18 @@ components: qValue: type: number description: Benjamini-Hochberg FDR-adjusted q-value (≤ the surface threshold). + shrunkR: + type: number + description: Sample-size-shrunk effect (r shrunk toward null by depth), used for ranking and the effect-size floor. + Display still uses raw `r`. + tier: + type: string + enum: + - high + - moderate + - faint + description: "Phrasing-confidence tier the narration honours: `faint` is hedged, `high` is stated plainly, `moderate` + sits between. A below-floor pair never reaches this list." interpretation: type: string description: Conservative, descriptive interpretation — never causal. @@ -28625,6 +29916,8 @@ components: - r - pValue - qValue + - shrunkR + - tier - interpretation - lagDays additionalProperties: false @@ -33396,9 +34689,28 @@ components: - latestAmount - daysWithData additionalProperties: false + lastAttempt: + anyOf: + - type: object + properties: + at: + 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)))$ + topReason: + type: string + required: + - at + - topReason + additionalProperties: false + - type: "null" + description: Non-null only when `nutrients` is empty AND the most recent nutrient-batch call landed nothing — the most + common skip reason from that call, so the client can say a sync arrived and why instead of a bare empty + state. required: - windowDays - nutrients + - lastAttempt additionalProperties: false description: "Per-nutrient window summary in catalog order: latest synced day + total, and the count of days carrying data inside the window. Nutrients without data in the window are omitted." @@ -33680,6 +34992,231 @@ components: - data - error additionalProperties: false + OuraSyncEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ProviderSyncOutcome" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ProviderSyncOutcome: + type: object + properties: + imported: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Rows that reached the database this run. + failed: + type: boolean + description: True when any part of the run did not land — a row, a collection, a leg. + outcome: + type: string + enum: + - empty + - failed + - partial + - success + description: "`empty` — nothing new, nothing refused. `failed` — nothing written, something refused. `partial` — some + written, some refused. `success` — everything written." + required: + - imported + - failed + - outcome + additionalProperties: false + description: The result of an on-demand provider sync trigger. No request body; the sync window is derived server-side + from the newest row already held, never a client-supplied lookback. + PolarSyncEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ProviderSyncOutcome" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + StravaSyncEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ProviderSyncOutcome" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + NightscoutSyncEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ProviderSyncOutcome" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GoogleHealthSyncStatusEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/GoogleHealthSyncStatusResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GoogleHealthSyncStatusResponse: + anyOf: + - type: object + properties: + runId: + type: string + description: Truncated to 128 chars. + state: + type: string + enum: + - in_progress + - complete + - partial + - zero + - truncated + - failed + - interrupted + description: Falls back to `failed` when the stored value is not one of this closed set. + startedAt: + type: string + description: ISO instant, or empty string when unset. + updatedAt: + type: string + description: ISO instant, or empty string when unset. + terminalAt: + description: ISO instant the run reached a terminal state. Present only once the run has finished; absent while `state` + is `in_progress`. + type: string + imported: + type: integer + minimum: 0 + maximum: 9007199254740991 + failed: + type: boolean + resources: + maxItems: 16 + type: array + items: + $ref: "#/components/schemas/GoogleHealthResourceOutcome" + description: Per-collection outcome for this run, capped at 16 entries. + required: + - runId + - state + - startedAt + - updatedAt + - imported + - failed + - resources + additionalProperties: false + - type: "null" + description: The caller's current/most-recent Google Health sync run, or null when none has ever run. `data` carries + this shape directly (not nested under a named key). + GoogleHealthResourceOutcome: + type: object + properties: + resource: + type: string + description: Resource collection name, lower-cased and slug-sanitised (`[^a-z0-9-]` stripped), capped at 48 chars. + `unknown` when the stored value was not a string. + pages: + type: integer + minimum: 0 + maximum: 9007199254740991 + fetched: + type: integer + minimum: 0 + maximum: 9007199254740991 + mapped: + type: integer + minimum: 0 + maximum: 9007199254740991 + written: + type: integer + minimum: 0 + maximum: 9007199254740991 + status: + type: string + enum: + - pending + - complete + - partial + - empty + - truncated + - failed + description: Falls back to `failed` when the stored value is not one of this closed set. + durationMs: + type: integer + minimum: 0 + maximum: 9007199254740991 + truncated: + type: boolean + reasonCode: + anyOf: + - type: string + enum: + - collection_failed + - token_failed + - upsert_failed + - rollup_failed + - existing_page_limit + - type: "null" + description: Null when the stored value is not one of this closed set, or when the resource did not fail. + required: + - resource + - pages + - fetched + - mapped + - written + - status + - durationMs + - truncated + - reasonCode + additionalProperties: false DailyDigestEnvelope: type: object properties: @@ -34480,6 +36017,115 @@ components: description: Server-shaped medication row returned by GET / POST / PUT endpoints. Carries the v1.5 course-window fields (`startsOn`, `endsOn`, `oneShot`) at the medication level and the per-schedule cadence fields on the nested `schedules` array. + MedicationDoseHistoryImportFatalReason: + type: string + enum: + - empty_file + - missing_required_columns + - unreadable_json + - json_not_an_array + - json_carries_no_intake_time + description: Whole-file refusal reason on the 422 `errorCode`. empty_file — no data rows. missing_required_columns — the + CSV header is missing a column the parser needs. unreadable_json — the body did not parse as JSON. + json_not_an_array — parsed JSON is not an array (and no nested array was found under any key). + json_carries_no_intake_time — well-formed JSON export input, refused by design (see above). + CoachPrefs: + type: object + properties: + tone: + default: warm + type: string + enum: + - warm + - neutral + - concise + verbosity: + default: default + type: string + enum: + - brief + - default + - detailed + excludeMetrics: + default: [] + maxItems: 11 + type: array + items: + type: string + enum: + - bp + - weight + - pulse + - mood + - compliance + - hrv + - sleep + - resting_hr + - steps + - medications + - anthropometrics + showEvidenceByDefault: + default: false + type: boolean + defaultWindow: + default: allTime + type: string + enum: + - last7days + - last30days + - last90days + - allTime + dataClusters: + maxItems: 10 + type: array + items: + type: string + enum: + - cardio + - body + - activity + - workouts + - sleep + - mood + - glucose + - medication + - mobility + - environment + reminderSuggestions: + type: object + properties: + enabled: + default: true + type: boolean + stopped: + default: false + type: boolean + dismissedCadences: + default: [] + maxItems: 32 + type: array + items: + type: string + maxLength: 64 + lastSuggestedAt: + default: null + anyOf: + - type: string + maxLength: 40 + - type: "null" + required: + - enabled + - stopped + - dismissedCadences + - lastSuggestedAt + additionalProperties: false + required: + - tone + - verbosity + - excludeMetrics + - showEvidenceByDefault + - defaultWindow + additionalProperties: false securitySchemes: bearerAuth: type: http diff --git a/docs/ops/deploy.md b/docs/ops/deploy.md index 7e2065897..c162169cd 100644 --- a/docs/ops/deploy.md +++ b/docs/ops/deploy.md @@ -131,6 +131,23 @@ without touching the file: - `HEALTHLOG_IMAGE_REF=:v1.16.5` — pin to a release tag (still mutable). - `HEALTHLOG_IMAGE_REF=@sha256:` — pin to the immutable digest. +> **A pin is sticky, and a stale pin is silent.** Once set, every later deploy +> ships that reference until someone changes it. The deploy still reports +> success, the tag still builds, the image still publishes, and the running +> instance stays on the old release. If you pin, bumping the pin is part of +> shipping, not an afterthought. If you would rather not carry that step, leave +> the variable unset: `docker-publish.yml` rotates `:latest` on every release tag +> without a pre-release suffix, so the git tag stays the single place a version is +> declared. +> +> Two checks after any deploy, because the first alone can be served from a +> cache: `/api/version` must report the version and commit you shipped, and the +> running container's image must be the one you expect +> (`docker inspect --format '{{.Config.Image}}'`). A deploy that +> finishes suspiciously fast is a signal rather than good news. Also watch for a +> DUPLICATE `HEALTHLOG_IMAGE_REF` entry: Coolify accepts two rows with the same +> key without warning, and which one wins is not defined. + ### Promote a release to a pinned digest `scripts/promote-digest.ts` automates the whole promotion after diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index bcce6030e..03b200203 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -111,6 +111,8 @@ const workoutDetail = { }, aiInsight: null, canonicalId: A11Y_WORKOUT_ID, + dayKey: "2026-07-20", + previousWorkoutId: null, }; const medication = { @@ -363,6 +365,44 @@ async function installA11yMocks(page: Page) { }), ); + // The workout detail page's day context reads the day's pulse shape and + // the night the person woke from into it. + await page.route("**/api/insights/pulse/intraday*", (route) => + fulfilJson(route, { + dateKey: "2026-07-20", + bucketMinutes: 10, + series: [ + { startMinute: 420, mean: 62, count: 6, min: 58, max: 70 }, + { startMinute: 430, mean: 118, count: 6, min: 96, max: 141 }, + ], + baseline: 58, + baselineSource: "resting", + tension: null, + resolution: "dense", + }), + ); + + await page.route("**/api/sleep/night*", (route) => + fulfilJson(route, { + night: "2026-07-20", + main: { + night: "2026-07-20", + source: "APPLE_HEALTH", + start: "2026-07-19T22:10:00.000Z", + end: "2026-07-20T05:45:00.000Z", + asleepMinutes: 415, + inBedMinutes: 455, + awakeMinutes: 40, + awakenings: 2, + reconstructed: false, + stages: {}, + segments: [], + sourceDiscrepancy: null, + }, + naps: [], + }), + ); + await page.route("**/api/workouts**", (route) => { const url = new URL(route.request().url()); if (url.pathname === `/api/workouts/${A11Y_WORKOUT_ID}`) { diff --git a/e2e/settings-overscroll.spec.ts b/e2e/settings-overscroll.spec.ts index dc7ab9ec3..ecc4d8434 100644 --- a/e2e/settings-overscroll.spec.ts +++ b/e2e/settings-overscroll.spec.ts @@ -68,12 +68,22 @@ test.describe("settings + admin vertical over-scroll guard", () => { // long when the page is ready and too short on a loaded runner, and // it never says what it is waiting for. Gate on the element the // measurement reads instead. - await settleBeforeMeasure(page, page.locator("#main-content")); + const wrapperLocator = page.locator( + '#main-content [data-slot="main-content-wrapper"]', + ); + await settleBeforeMeasure(page, wrapperLocator); const dims = await page.evaluate(() => { const main = document.getElementById("main-content"); if (!main) return null; - const wrapper = main.firstElementChild as HTMLElement | null; + // Selected by the stable `data-slot` (auth-shell.tsx), not by + // birth order: `firstElementChild` would resolve to whatever + // lazily-mounted sibling (portal, toast, hydrating region) landed + // first, silently measuring the wrong subtree instead of the + // real content wrapper. + const wrapper = main.querySelector( + '[data-slot="main-content-wrapper"]', + ); if (!wrapper) return null; // Lowest content edge: max CONTENT-box bottom over the wrapper's // visible, non-fixed descendants, in the scroll container's diff --git a/e2e/v1341-navigation-and-insights-mobile.spec.ts b/e2e/v1341-navigation-and-insights-mobile.spec.ts index 2c446c677..f01782f7d 100644 --- a/e2e/v1341-navigation-and-insights-mobile.spec.ts +++ b/e2e/v1341-navigation-and-insights-mobile.spec.ts @@ -21,25 +21,38 @@ test.describe("v1.34.1 navigation geometry", () => { const heading = page.getByRole("heading", { level: 1 }); const sidebar = page.locator("aside").first(); + // The wrapper carries a stable `data-slot` (auth-shell.tsx) so both + // reads below measure the same DOM node instead of resolving + // `firstElementChild` twice — a lazily-mounted sibling (portal, + // toast, hydrating region) landing ahead of it between the two + // reads used to substitute a different element into the second + // measurement, producing an unrelated number rather than a + // drifted one. Resolving the handle once removes the second + // resolution step entirely. + const wrapper = page.locator('[data-slot="main-content-wrapper"]'); await settleBeforeMeasure(page, heading); await expect(sidebar).toBeVisible(); + await expect(wrapper).toBeVisible(); + const wrapperHandle = await wrapper.elementHandle(); + if (!wrapperHandle) { + throw new Error("main content wrapper not found"); + } - const before = await page.evaluate(() => { + const before = await page.evaluate((wrapperEl) => { const main = document.getElementById("main-content"); const heading = main?.querySelector("h1"); const aside = main?.querySelector("aside"); - const wrapper = main?.firstElementChild; - if (!main || !heading || !aside || !wrapper) return null; + if (!main || !heading || !aside || !wrapperEl) return null; return { headingTop: heading.getBoundingClientRect().top, asideTop: aside.getBoundingClientRect().top, - wrapperLeft: wrapper.getBoundingClientRect().left, + wrapperLeft: wrapperEl.getBoundingClientRect().left, mainWidth: main.clientWidth, documentScrollable: document.documentElement.scrollHeight > document.documentElement.clientHeight + 1, }; - }); + }, wrapperHandle); expect(before).not.toBeNull(); if (!before) return; @@ -51,26 +64,33 @@ test.describe("v1.34.1 navigation geometry", () => { await page.locator("#main-content").evaluate((main) => { main.scrollTop = Math.min(320, main.scrollHeight - main.clientHeight); }); - await page.waitForFunction(() => { + // Gate on the wrapper being present and laid out (not just on + // scroll state) — the measurement below reads it, so the wait + // must cover everything that measurement depends on. + await page.waitForFunction((wrapperEl) => { const main = document.getElementById("main-content"); + if (!main) return true; + const wrapperReady = + !!wrapperEl && + wrapperEl.isConnected && + wrapperEl.getBoundingClientRect().width > 0; return ( - !main || - main.scrollTop > 0 || - main.scrollHeight <= main.clientHeight + wrapperReady && + (main.scrollTop > 0 || main.scrollHeight <= main.clientHeight) ); - }); + }, wrapperHandle); - const after = await page.evaluate(() => { + const after = await page.evaluate((wrapperEl) => { const main = document.getElementById("main-content"); const aside = main?.querySelector("aside"); - const wrapper = main?.firstElementChild; - if (!main || !aside || !wrapper) return null; + if (!main || !aside || !wrapperEl) return null; return { asideTop: aside.getBoundingClientRect().top, - wrapperLeft: wrapper.getBoundingClientRect().left, + wrapperLeft: wrapperEl.getBoundingClientRect().left, mainWidth: main.clientWidth, }; - }); + }, wrapperHandle); + await wrapperHandle.dispose(); expect(after).not.toBeNull(); if (!after) return; diff --git a/messages/de.json b/messages/de.json index b3e350ba5..510ee0524 100644 --- a/messages/de.json +++ b/messages/de.json @@ -120,6 +120,13 @@ "outOfOrder": "Dein Onboarding-Fortschritt hat sich geändert. Lade die Seite neu und fahre fort.", "concurrent": "Dein Onboarding-Fortschritt hat sich geändert. Lade die Seite neu und fahre fort." } + }, + "profile": { + "update": { + "nothingSaved": "Es wurde nichts gespeichert. Bitte das markierte Feld korrigieren und erneut versuchen.", + "invalidBody": "Die Profilaktualisierung konnte nicht gelesen werden.", + "emailInUse": "Diese E-Mail-Adresse wird bereits verwendet." + } } }, "doctorReport": { @@ -2262,6 +2269,9 @@ "red": "Mindestens ein Bereich liegt außerhalb des Referenzbereichs" }, "insufficient": "{count} geeignete Bereiche. Der Score benötigt mindestens drei, darunter einen gemessenen physiologischen Bereich.", + "anatomyToggle": "Wie dieser Score zustande kommt", + "notScored": "Noch nicht bewertet ({count})", + "method": "Der Score ist der gleich gewichtete Mittelwert aller Säulen mit ausreichend aktuellen Daten, auf einer Skala von 0 bis 100. Jede Säule wird an einem veröffentlichten Referenzbereich gemessen, und die schwächste Säule kann den Gesamtbereich bestimmen. Eine Säule ohne ausreichende Daten bleibt außen vor und zählt nicht als null.", "bandSetter": "Gesamtstufe bestimmt durch {pillar}", "versionAndNoise": "Methodenversion {version}. Schwelle für den Wochenvergleich: {noise} Punkte.", "methodVersion": "Methodenversion {version}.", @@ -2320,7 +2330,6 @@ "insufficientHistory": "Nicht genügend aktuelle geeignete Daten", "unavailable": "Dieser Bereich kann derzeit nicht bewertet werden" }, - "pillarInsufficient": "Kein Bereichsscore: {reason}", "tension": "Die Bereitschaftssignale unterscheiden sich. Unterstützend: {positive}. Belastet: {negative}.", "returnToBand": "{metric} liegt seit {days} Tagen wieder im üblichen Bereich.", "weightGoal": { @@ -3100,9 +3109,15 @@ "splitsTime": "Zeit", "splitsPace": "Tempo", "thatDayTitle": "An diesem Tag", - "dayPulse": "Puls", - "daySleep": "Schlaf", - "dayMood": "Stimmung", + "daySleepTitle": "Die Nacht davor", + "daySleepNone": "Für diese Nacht ist kein Schlaf erfasst", + "daySleepAwakeningsOne": "{count}× aufgewacht", + "daySleepAwakeningsMany": "{count}× aufgewacht", + "dayMoodTitle": "Stimmung an diesem Tag", + "dayMoodNone": "An diesem Tag ist keine Stimmung erfasst", + "sleepTrendsLink": "Schlaf-Trends", + "moodHistoryLink": "Stimmungsverlauf", + "previousSessionLink": "Vorherige Einheit", "insightTitle": "Aktivitäts-Einblick" }, "sport": { @@ -3131,7 +3146,13 @@ "loadError": "Deine Workouts konnten nicht geladen werden", "loadMore": "Weitere Workouts laden", "loadingMore": "Weitere Workouts werden geladen", - "loadMoreError": "Weitere Workouts konnten nicht geladen werden" + "loadMoreError": "Weitere Workouts konnten nicht geladen werden", + "countOne": "{count} Workout", + "countOther": "{count} Workouts", + "countFew": "{count} Workouts", + "filterLabel": "Nach Sportart filtern", + "filterAll": "Alle Sportarten", + "filterEmpty": "Keine Einheiten dieser Sportart in deinem Verlauf." }, "bloodGlucose": { "title": "Blutzucker", @@ -4906,6 +4927,8 @@ "timezoneHint": "Wird für Diagrammachsen, Erinnerungszeiten, Export-Zeitstempel und den Coach-Kontext verwendet. Gespeicherte Daten bleiben unverändert.", "timezoneInvalid": "Keine gültige IANA-Zeitzone.", "profileSaved": "Profil gespeichert", + "profilePartiallySaved": "Profil gespeichert, aber {field} konnte nicht aktualisiert werden.", + "profileNothingSaved": "Es wurde nichts gespeichert. Bitte {field} korrigieren und erneut versuchen.", "avatar": { "title": "Profilfoto", "description": "Lade ein Foto hoch, das auf deinem eigenen Server gespeichert wird. Es erscheint überall in der App anstelle eines externen Avatar-Dienstes.", @@ -5643,7 +5666,6 @@ "cycleTracking": { "title": "Zyklus-Tracking", "description": "Verfolge deinen Menstruationszyklus, Vorhersagen, Symptome und Phasen-Insights. Standardmäßig an für als weiblich eingestellte Konten; für alle verfügbar.", - "disclaimer": "Vorhersagen und Phasen sind beschreibende Schätzungen aus deinen erfassten Daten – keine medizinische Beratung und keine Verhütungsmethode. Sie zeigen nie an, dass ungeschützter Geschlechtsverkehr sicher ist. Die Genauigkeit steigt, je mehr Zyklen du erfasst. Wende dich für Verhütung oder medizinische Entscheidungen an eine medizinische Fachperson.", "enable": "Zyklus-Tracking aktivieren", "manage": "Zyklus-Tracking öffnen", "error": "Zyklus-Tracking konnte nicht aktualisiert werden." @@ -9088,7 +9110,17 @@ "moduleOffDescription": "Aktiviere das Nährstoffe-Modul, um Flüssigkeits- und Vitamin-/Mineralstoffaufnahme hier zu sehen. Synchronisierte Werte werden Teil der Aufzeichnung, die der Coach lesen kann.", "moduleOffCta": "Nährstoffe aktivieren", "emptyTitle": "Noch keine Nährstoffdaten", - "emptyDescription": "Daten kommen aus der Synchronisierung der Gesundheits-App deines Telefons oder einem manuellen Wassereintrag." + "emptyDescription": "Daten kommen aus der Synchronisierung der Gesundheits-App deines Telefons oder einem manuellen Wassereintrag.", + "loadError": "Deine Nährstoffdaten konnten nicht geladen werden", + "lastAttemptTitle": "Eine Synchronisierung ist angekommen, aber nichts konnte gespeichert werden", + "lastAttemptAt": "Letzter Versuch: {date}", + "lastAttemptReason": { + "unitMismatch": "Die übermittelten Werte entsprachen nicht den Einheiten, die diese App für diese Nährstoffe erwartet, daher konnten sie nicht gespeichert werden.", + "valueOutOfRange": "Die übermittelten Mengen lagen höher, als diese App plausibel speichern kann, daher konnten sie nicht gesichert werden.", + "dayInvalid": "Die Einträge trugen ein Datum, das der Server nicht akzeptieren konnte. Das kann passieren, wenn Uhrzeit oder Zeitzone auf der sendenden Seite falsch eingestellt sind — das lohnt sich zu prüfen.", + "upsertFailed": "Diese Einträge haben den Server erreicht, aber ein Problem auf unserer Seite hat verhindert, dass sie gespeichert wurden. Du musst nichts weiter tun.", + "unknown": "Diese Einträge sind angekommen, konnten aber nicht gespeichert werden." + } }, "hydration": { "referenceMeta": "Richtwert {value} {unit}/Tag · EFSA", diff --git a/messages/en.json b/messages/en.json index 989eea5b8..8c12b07f3 100644 --- a/messages/en.json +++ b/messages/en.json @@ -120,6 +120,13 @@ "outOfOrder": "Your onboarding progress changed. Refresh and continue.", "concurrent": "Your onboarding progress changed. Refresh and continue." } + }, + "profile": { + "update": { + "nothingSaved": "Nothing was saved. Check the highlighted field and try again.", + "invalidBody": "The profile update could not be read.", + "emailInUse": "This email address is already in use." + } } }, "doctorReport": { @@ -2262,6 +2269,9 @@ "red": "One or more pillars are outside the reference range" }, "insufficient": "{count} eligible pillars. The score needs at least three, including a measured physiological pillar.", + "anatomyToggle": "How this score comes about", + "notScored": "Not scored yet ({count})", + "method": "The score is the equal-weighted average of every pillar that has enough recent data, on a 0 to 100 scale. Each pillar is graded against a published reference band, and the weakest pillar can set the overall band. A pillar without enough data is left out rather than counted as a zero.", "bandSetter": "Overall band set by {pillar}", "versionAndNoise": "Method version {version}. Weekly comparison floor: {noise} points.", "methodVersion": "Method version {version}.", @@ -2320,7 +2330,6 @@ "insufficientHistory": "Not enough recent eligible data", "unavailable": "This pillar is not currently eligible" }, - "pillarInsufficient": "No pillar score: {reason}", "tension": "Readiness signals differ. Supportive: {positive}. Under strain: {negative}.", "returnToBand": "{metric} has been back inside your usual range for {days} days.", "weightGoal": { @@ -3100,9 +3109,15 @@ "splitsTime": "Time", "splitsPace": "Pace", "thatDayTitle": "That day", - "dayPulse": "Pulse", - "daySleep": "Sleep", - "dayMood": "Mood", + "daySleepTitle": "The night before", + "daySleepNone": "No sleep recorded for that night", + "daySleepAwakeningsOne": "{count} awakening", + "daySleepAwakeningsMany": "{count} awakenings", + "dayMoodTitle": "Mood that day", + "dayMoodNone": "No mood logged that day", + "sleepTrendsLink": "Sleep trends", + "moodHistoryLink": "Mood history", + "previousSessionLink": "Previous session", "insightTitle": "Activity insight" }, "sport": { @@ -3131,7 +3146,13 @@ "loadError": "Your workouts could not be loaded", "loadMore": "Load more workouts", "loadingMore": "Loading more workouts", - "loadMoreError": "More workouts could not be loaded" + "loadMoreError": "More workouts could not be loaded", + "countOne": "{count} workout", + "countOther": "{count} workouts", + "countFew": "{count} workouts", + "filterLabel": "Filter by sport", + "filterAll": "All sports", + "filterEmpty": "No sessions of this sport in your history." }, "bloodGlucose": { "title": "Blood Glucose", @@ -4906,6 +4927,8 @@ "timezoneHint": "Used for chart axis labels, reminder times, export timestamps, and Coach context. Stored data is unchanged.", "timezoneInvalid": "Not a valid IANA timezone.", "profileSaved": "Profile saved", + "profilePartiallySaved": "Profile saved, but {field} could not be updated.", + "profileNothingSaved": "Nothing was saved. Fix {field} and try again.", "avatar": { "title": "Profile photo", "description": "Upload a photo stored on your own server. It appears across the app in place of an external avatar service.", @@ -5643,7 +5666,6 @@ "cycleTracking": { "title": "Cycle tracking", "description": "Track your menstrual cycle, predictions, symptoms, and phase insights. On by default for accounts set to female; available to everyone.", - "disclaimer": "Predictions and phases are descriptive estimates from your logged data — not medical advice and not a contraceptive method. They never indicate that it is safe to have unprotected sex. Accuracy improves as you log more cycles. For contraception or any medical decision, consult a healthcare professional.", "enable": "Enable cycle tracking", "manage": "Open cycle tracking", "error": "Couldn't update cycle tracking." @@ -9088,7 +9110,17 @@ "moduleOffDescription": "Turn on the nutrients module to see hydration and vitamin/mineral intake here. Synced intake becomes part of the record the Coach can read.", "moduleOffCta": "Turn on nutrients", "emptyTitle": "No nutrient data yet", - "emptyDescription": "Data arrives from your phone's health app sync or a manual water entry." + "emptyDescription": "Data arrives from your phone's health app sync or a manual water entry.", + "loadError": "Your nutrient data could not be loaded", + "lastAttemptTitle": "A sync arrived, but nothing could be stored", + "lastAttemptAt": "Last attempt: {date}", + "lastAttemptReason": { + "unitMismatch": "The values that arrived didn't match the units this app expects for these nutrients, so they couldn't be stored.", + "valueOutOfRange": "The amounts that arrived were higher than this app can plausibly store, so they couldn't be saved.", + "dayInvalid": "The entries carried a date the server couldn't accept. This can happen when the clock or time zone on the sending side is wrong — it's worth checking.", + "upsertFailed": "These entries reached the server, but a problem on this end kept them from being saved. There's nothing you need to do.", + "unknown": "These entries arrived but couldn't be stored." + } }, "hydration": { "referenceMeta": "Reference intake {value} {unit}/day · EFSA", diff --git a/messages/es.json b/messages/es.json index d245c47c4..e4d6838cd 100644 --- a/messages/es.json +++ b/messages/es.json @@ -120,6 +120,13 @@ "outOfOrder": "Tu progreso ha cambiado. Actualiza la página y continúa.", "concurrent": "Tu progreso ha cambiado. Actualiza la página y continúa." } + }, + "profile": { + "update": { + "nothingSaved": "No se guardó nada. Revisa el campo marcado e inténtalo de nuevo.", + "invalidBody": "No se pudo leer la actualización del perfil.", + "emailInUse": "Esta dirección de correo ya está en uso." + } } }, "doctorReport": { @@ -2262,6 +2269,9 @@ "red": "Uno o más pilares están fuera del intervalo de referencia" }, "insufficient": "{count} pilares aptos. La puntuación necesita al menos tres, incluido un pilar fisiológico medido.", + "anatomyToggle": "Cómo se forma esta puntuación", + "notScored": "Aún sin puntuar ({count})", + "method": "La puntuación es la media con igual peso de todos los pilares que tienen suficientes datos recientes, en una escala de 0 a 100. Cada pilar se compara con un rango de referencia publicado, y el pilar más débil puede fijar el rango general. Un pilar sin datos suficientes queda fuera en lugar de contar como cero.", "bandSetter": "Categoría general determinada por {pillar}", "versionAndNoise": "Versión del método {version}. Umbral de comparación semanal: {noise} puntos.", "methodVersion": "Versión del método {version}.", @@ -2320,7 +2330,6 @@ "insufficientHistory": "No hay suficientes datos recientes aptos", "unavailable": "Este pilar no reúne los requisitos actualmente" }, - "pillarInsufficient": "Sin puntuación del pilar: {reason}", "tension": "Las señales de preparación difieren. Favorables: {positive}. Con tensión: {negative}.", "returnToBand": "{metric} lleva {days} días de nuevo dentro de tu intervalo habitual.", "weightGoal": { @@ -3100,9 +3109,15 @@ "splitsTime": "Tiempo", "splitsPace": "Ritmo", "thatDayTitle": "Ese día", - "dayPulse": "Pulso", - "daySleep": "Sueño", - "dayMood": "Ánimo", + "daySleepTitle": "La noche anterior", + "daySleepNone": "No hay sueño registrado esa noche", + "daySleepAwakeningsOne": "{count} despertar", + "daySleepAwakeningsMany": "{count} despertares", + "dayMoodTitle": "Ánimo ese día", + "dayMoodNone": "No hay ánimo registrado ese día", + "sleepTrendsLink": "Tendencias de sueño", + "moodHistoryLink": "Historial de ánimo", + "previousSessionLink": "Sesión anterior", "insightTitle": "Análisis de la actividad" }, "sport": { @@ -3131,7 +3146,13 @@ "loadError": "No se pudieron cargar tus entrenamientos", "loadMore": "Cargar más entrenamientos", "loadingMore": "Cargando más entrenamientos", - "loadMoreError": "No se pudieron cargar más entrenamientos" + "loadMoreError": "No se pudieron cargar más entrenamientos", + "countOne": "{count} entrenamiento", + "countOther": "{count} entrenamientos", + "countFew": "{count} entrenamientos", + "filterLabel": "Filtrar por deporte", + "filterAll": "Todos los deportes", + "filterEmpty": "No hay sesiones de este deporte en tu historial." }, "bloodGlucose": { "title": "Glucosa en sangre", @@ -4906,6 +4927,8 @@ "timezoneHint": "Se usa para las etiquetas de los ejes de los gráficos, las horas de recordatorio, las marcas de tiempo de exportación y el contexto del Coach. Los datos almacenados no cambian.", "timezoneInvalid": "No es una zona horaria IANA válida.", "profileSaved": "Perfil guardado", + "profilePartiallySaved": "Perfil guardado, pero no se pudo actualizar {field}.", + "profileNothingSaved": "No se guardó nada. Corrige {field} e inténtalo de nuevo.", "avatar": { "title": "Foto de perfil", "description": "Sube una foto que se almacena en tu propio servidor. Aparece en toda la aplicación en lugar de un servicio de avatares externo.", @@ -5643,7 +5666,6 @@ "cycleTracking": { "title": "Seguimiento del ciclo", "description": "Registra tu ciclo menstrual, predicciones, síntomas e información de fases. Activado por defecto en cuentas configuradas como femeninas; disponible para todos.", - "disclaimer": "Las predicciones y las fases son estimaciones descriptivas a partir de tus datos registrados, no son consejo médico ni un método anticonceptivo. Nunca indican que sea seguro tener relaciones sin protección. La precisión mejora a medida que registras más ciclos. Para la anticoncepción o cualquier decisión médica, consulta a un profesional sanitario.", "enable": "Activar el seguimiento del ciclo", "manage": "Abrir seguimiento del ciclo", "error": "No se pudo actualizar el seguimiento del ciclo." @@ -9088,7 +9110,17 @@ "moduleOffDescription": "Activa el módulo de nutrientes para ver aquí la hidratación y la ingesta de vitaminas/minerales. Los datos sincronizados pasarán a formar parte del registro que el Coach puede leer.", "moduleOffCta": "Activar nutrientes", "emptyTitle": "Aún no hay datos de nutrientes", - "emptyDescription": "Los datos llegan desde la sincronización de la app de salud de tu teléfono o desde una entrada manual de agua." + "emptyDescription": "Los datos llegan desde la sincronización de la app de salud de tu teléfono o desde una entrada manual de agua.", + "loadError": "No se pudieron cargar tus datos de nutrientes", + "lastAttemptTitle": "Se recibió una sincronización, pero no se pudo guardar nada", + "lastAttemptAt": "Último intento: {date}", + "lastAttemptReason": { + "unitMismatch": "Los valores recibidos no coincidían con las unidades que esta app espera para estos nutrientes, así que no se pudieron guardar.", + "valueOutOfRange": "Las cantidades recibidas eran superiores a lo que esta app puede almacenar de forma plausible, así que no se pudieron guardar.", + "dayInvalid": "Las entradas llevaban una fecha que el servidor no pudo aceptar. Esto puede ocurrir cuando el reloj o la zona horaria del lado que envía los datos están mal configurados; merece la pena revisarlo.", + "upsertFailed": "Estas entradas llegaron al servidor, pero un problema de nuestro lado impidió guardarlas. No tienes que hacer nada.", + "unknown": "Estas entradas llegaron, pero no se pudieron guardar." + } }, "hydration": { "referenceMeta": "Ingesta de referencia {value} {unit}/día · EFSA", diff --git a/messages/fr.json b/messages/fr.json index 9044978ac..3310a4e0d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -120,6 +120,13 @@ "outOfOrder": "Votre progression a changé. Actualisez la page et continuez.", "concurrent": "Votre progression a changé. Actualisez la page et continuez." } + }, + "profile": { + "update": { + "nothingSaved": "Rien n'a été enregistré. Corrigez le champ signalé et réessayez.", + "invalidBody": "La mise à jour du profil n'a pas pu être lue.", + "emailInUse": "Cette adresse e-mail est déjà utilisée." + } } }, "doctorReport": { @@ -2262,6 +2269,9 @@ "red": "Un ou plusieurs piliers sont hors de la plage de référence" }, "insufficient": "{count} piliers admissibles. Le score en requiert au moins trois, dont un pilier physiologique mesuré.", + "anatomyToggle": "Comment ce score est établi", + "notScored": "Pas encore évalué ({count})", + "method": "Le score est la moyenne à poids égal de tous les piliers disposant d'assez de données récentes, sur une échelle de 0 à 100. Chaque pilier est comparé à une plage de référence publiée, et le pilier le plus faible peut déterminer la plage globale. Un pilier sans données suffisantes est écarté plutôt que compté comme un zéro.", "bandSetter": "Catégorie globale fixée par {pillar}", "versionAndNoise": "Version de la méthode {version}. Seuil de comparaison hebdomadaire : {noise} points.", "methodVersion": "Version de la méthode {version}.", @@ -2320,7 +2330,6 @@ "insufficientHistory": "Pas assez de données récentes admissibles", "unavailable": "Ce pilier n'est pas admissible actuellement" }, - "pillarInsufficient": "Aucun score pour ce pilier : {reason}", "tension": "Les signaux de préparation diffèrent. Favorables : {positive}. Sous tension : {negative}.", "returnToBand": "{metric} est revenu dans ta plage habituelle depuis {days} jours.", "weightGoal": { @@ -3100,9 +3109,15 @@ "splitsTime": "Temps", "splitsPace": "Allure", "thatDayTitle": "Ce jour-là", - "dayPulse": "Pouls", - "daySleep": "Sommeil", - "dayMood": "Humeur", + "daySleepTitle": "La nuit précédente", + "daySleepNone": "Aucun sommeil enregistré cette nuit-là", + "daySleepAwakeningsOne": "{count} réveil", + "daySleepAwakeningsMany": "{count} réveils", + "dayMoodTitle": "Humeur ce jour-là", + "dayMoodNone": "Aucune humeur enregistrée ce jour-là", + "sleepTrendsLink": "Tendances du sommeil", + "moodHistoryLink": "Historique d'humeur", + "previousSessionLink": "Séance précédente", "insightTitle": "Analyse de l'activité" }, "sport": { @@ -3131,7 +3146,13 @@ "loadError": "Tes séances n'ont pas pu être chargées", "loadMore": "Charger plus de séances", "loadingMore": "Chargement de séances supplémentaires", - "loadMoreError": "Les séances supplémentaires n'ont pas pu être chargées" + "loadMoreError": "Les séances supplémentaires n'ont pas pu être chargées", + "countOne": "{count} séance", + "countOther": "{count} séances", + "countFew": "{count} séances", + "filterLabel": "Filtrer par sport", + "filterAll": "Tous les sports", + "filterEmpty": "Aucune séance de ce sport dans votre historique." }, "bloodGlucose": { "title": "Glycémie", @@ -4906,6 +4927,8 @@ "timezoneHint": "Utilisé pour les libellés des axes des graphiques, les heures de rappel, les horodatages d’export et le contexte du Coach. Les données stockées restent inchangées.", "timezoneInvalid": "Fuseau horaire IANA non valide.", "profileSaved": "Profil enregistré", + "profilePartiallySaved": "Profil enregistré, mais {field} n'a pas pu être mis à jour.", + "profileNothingSaved": "Rien n'a été enregistré. Corrigez {field} et réessayez.", "avatar": { "title": "Photo de profil", "description": "Téléverse une photo stockée sur ton propre serveur. Elle apparaît dans toute l'application à la place d'un service d'avatar externe.", @@ -5643,7 +5666,6 @@ "cycleTracking": { "title": "Suivi du cycle", "description": "Suivez votre cycle menstruel, les prédictions, les symptômes et les analyses de phases. Activé par défaut pour les comptes définis comme féminins ; disponible pour tous.", - "disclaimer": "Les prévisions et les phases sont des estimations descriptives issues de tes données enregistrées : ce ne sont ni un avis médical ni une méthode de contraception. Elles n'indiquent jamais qu'un rapport non protégé est sans risque. La précision s'améliore à mesure que tu enregistres plus de cycles. Pour la contraception ou toute décision médicale, consulte un professionnel de santé.", "enable": "Activer le suivi du cycle", "manage": "Ouvrir le suivi du cycle", "error": "Impossible de mettre à jour le suivi du cycle." @@ -9088,7 +9110,17 @@ "moduleOffDescription": "Activez le module nutriments pour voir ici l'hydratation et les apports en vitamines/minéraux. Les données synchronisées feront partie du dossier que le Coach peut lire.", "moduleOffCta": "Activer les nutriments", "emptyTitle": "Pas encore de données nutritionnelles", - "emptyDescription": "Les données proviennent de la synchronisation de l'appli santé de votre téléphone ou d'une saisie manuelle d'eau." + "emptyDescription": "Les données proviennent de la synchronisation de l'appli santé de votre téléphone ou d'une saisie manuelle d'eau.", + "loadError": "Tes données de nutriments n'ont pas pu être chargées", + "lastAttemptTitle": "Une synchronisation est arrivée, mais rien n'a pu être enregistré", + "lastAttemptAt": "Dernière tentative : {date}", + "lastAttemptReason": { + "unitMismatch": "Les valeurs reçues ne correspondaient pas aux unités attendues par l'appli pour ces nutriments, elles n'ont donc pas pu être enregistrées.", + "valueOutOfRange": "Les quantités reçues étaient supérieures à ce que l'appli peut raisonnablement enregistrer, elles n'ont donc pas pu être sauvegardées.", + "dayInvalid": "Les entrées portaient une date que le serveur n'a pas pu accepter. Cela peut arriver quand l'horloge ou le fuseau horaire du côté qui envoie les données est incorrect — cela vaut la peine de vérifier.", + "upsertFailed": "Ces entrées sont arrivées jusqu'au serveur, mais un problème de notre côté a empêché leur enregistrement. Tu n'as rien à faire.", + "unknown": "Ces entrées sont arrivées mais n'ont pas pu être enregistrées." + } }, "hydration": { "referenceMeta": "Apport de référence {value} {unit}/jour · EFSA", diff --git a/messages/it.json b/messages/it.json index fd21b5691..752f960dd 100644 --- a/messages/it.json +++ b/messages/it.json @@ -120,6 +120,13 @@ "outOfOrder": "I tuoi progressi sono cambiati. Aggiorna la pagina e continua.", "concurrent": "I tuoi progressi sono cambiati. Aggiorna la pagina e continua." } + }, + "profile": { + "update": { + "nothingSaved": "Non è stato salvato nulla. Controlla il campo evidenziato e riprova.", + "invalidBody": "Impossibile leggere l'aggiornamento del profilo.", + "emailInUse": "Questo indirizzo email è già in uso." + } } }, "doctorReport": { @@ -2262,6 +2269,9 @@ "red": "Uno o più pilastri sono fuori dall'intervallo di riferimento" }, "insufficient": "{count} pilastri idonei. Il punteggio ne richiede almeno tre, incluso un pilastro fisiologico misurato.", + "anatomyToggle": "Come nasce questo punteggio", + "notScored": "Non ancora valutato ({count})", + "method": "Il punteggio è la media a peso uguale di tutti i pilastri con dati recenti sufficienti, su una scala da 0 a 100. Ogni pilastro viene confrontato con un intervallo di riferimento pubblicato e il pilastro più debole può determinare la fascia complessiva. Un pilastro senza dati sufficienti resta escluso invece di contare come zero.", "bandSetter": "Fascia complessiva determinata da {pillar}", "versionAndNoise": "Versione del metodo {version}. Soglia del confronto settimanale: {noise} punti.", "methodVersion": "Versione del metodo {version}.", @@ -2320,7 +2330,6 @@ "insufficientHistory": "Dati recenti idonei insufficienti", "unavailable": "Questo pilastro non è attualmente idoneo" }, - "pillarInsufficient": "Nessun punteggio per il pilastro: {reason}", "tension": "I segnali di preparazione differiscono. Favorevoli: {positive}. Sotto sforzo: {negative}.", "returnToBand": "{metric} è di nuovo nel tuo intervallo abituale da {days} giorni.", "weightGoal": { @@ -3100,9 +3109,15 @@ "splitsTime": "Tempo", "splitsPace": "Ritmo", "thatDayTitle": "Quel giorno", - "dayPulse": "Battito", - "daySleep": "Sonno", - "dayMood": "Umore", + "daySleepTitle": "La notte prima", + "daySleepNone": "Nessun sonno registrato quella notte", + "daySleepAwakeningsOne": "{count} risveglio", + "daySleepAwakeningsMany": "{count} risvegli", + "dayMoodTitle": "Umore di quel giorno", + "dayMoodNone": "Nessun umore registrato quel giorno", + "sleepTrendsLink": "Andamento del sonno", + "moodHistoryLink": "Cronologia dell'umore", + "previousSessionLink": "Sessione precedente", "insightTitle": "Analisi dell'attività" }, "sport": { @@ -3131,7 +3146,13 @@ "loadError": "Non è stato possibile caricare i tuoi allenamenti", "loadMore": "Carica altri allenamenti", "loadingMore": "Caricamento di altri allenamenti", - "loadMoreError": "Non è stato possibile caricare altri allenamenti" + "loadMoreError": "Non è stato possibile caricare altri allenamenti", + "countOne": "{count} allenamento", + "countOther": "{count} allenamenti", + "countFew": "{count} allenamenti", + "filterLabel": "Filtra per sport", + "filterAll": "Tutti gli sport", + "filterEmpty": "Nessuna sessione di questo sport nella tua cronologia." }, "bloodGlucose": { "title": "Glicemia", @@ -4906,6 +4927,8 @@ "timezoneHint": "Usato per le etichette degli assi dei grafici, gli orari dei promemoria, gli orari di esportazione e il contesto del Coach. I dati memorizzati restano invariati.", "timezoneInvalid": "Non è un fuso orario IANA valido.", "profileSaved": "Profilo salvato", + "profilePartiallySaved": "Profilo salvato, ma non è stato possibile aggiornare {field}.", + "profileNothingSaved": "Non è stato salvato nulla. Correggi {field} e riprova.", "avatar": { "title": "Foto del profilo", "description": "Carica una foto archiviata sul tuo server. Appare in tutta l'app al posto di un servizio di avatar esterno.", @@ -5643,7 +5666,6 @@ "cycleTracking": { "title": "Monitoraggio del ciclo", "description": "Monitora il ciclo mestruale, le previsioni, i sintomi e gli approfondimenti sulle fasi. Attivo per impostazione predefinita per gli account impostati come femminili; disponibile per tutti.", - "disclaimer": "Le previsioni e le fasi sono stime descrittive basate sui dati che registri: non sono un parere medico né un metodo contraccettivo. Non indicano mai che sia sicuro avere rapporti non protetti. La precisione migliora man mano che registri più cicli. Per la contraccezione o qualsiasi decisione medica, rivolgiti a un professionista sanitario.", "enable": "Attiva il monitoraggio del ciclo", "manage": "Apri il monitoraggio del ciclo", "error": "Impossibile aggiornare il monitoraggio del ciclo." @@ -9088,7 +9110,17 @@ "moduleOffDescription": "Attiva il modulo nutrienti per vedere qui idratazione e apporto di vitamine/minerali. I dati sincronizzati diventano parte dei dati che il Coach può leggere.", "moduleOffCta": "Attiva nutrienti", "emptyTitle": "Ancora nessun dato sui nutrienti", - "emptyDescription": "I dati arrivano dalla sincronizzazione dell'app salute del telefono o da una voce di acqua inserita manualmente." + "emptyDescription": "I dati arrivano dalla sincronizzazione dell'app salute del telefono o da una voce di acqua inserita manualmente.", + "loadError": "Non è stato possibile caricare i tuoi dati sui nutrienti", + "lastAttemptTitle": "È arrivata una sincronizzazione, ma non è stato possibile salvare nulla", + "lastAttemptAt": "Ultimo tentativo: {date}", + "lastAttemptReason": { + "unitMismatch": "I valori ricevuti non corrispondevano alle unità che questa app si aspetta per questi nutrienti, quindi non è stato possibile salvarli.", + "valueOutOfRange": "Le quantità ricevute erano superiori a quanto questa app può plausibilmente memorizzare, quindi non è stato possibile salvarle.", + "dayInvalid": "Le voci riportavano una data che il server non ha potuto accettare. Questo può succedere quando l'orologio o il fuso orario dal lato che invia i dati non sono corretti: vale la pena controllare.", + "upsertFailed": "Queste voci hanno raggiunto il server, ma un problema da questa parte ne ha impedito il salvataggio. Non devi fare nulla.", + "unknown": "Queste voci sono arrivate ma non è stato possibile salvarle." + } }, "hydration": { "referenceMeta": "Apporto di riferimento {value} {unit}/giorno · EFSA", diff --git a/messages/pl.json b/messages/pl.json index 9d48e9cab..a997b9966 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -120,6 +120,13 @@ "outOfOrder": "Twój postęp się zmienił. Odśwież stronę i kontynuuj.", "concurrent": "Twój postęp się zmienił. Odśwież stronę i kontynuuj." } + }, + "profile": { + "update": { + "nothingSaved": "Nic nie zostało zapisane. Sprawdź oznaczone pole i spróbuj ponownie.", + "invalidBody": "Nie udało się odczytać aktualizacji profilu.", + "emailInUse": "Ten adres e-mail jest już używany." + } } }, "doctorReport": { @@ -2262,6 +2269,9 @@ "red": "Co najmniej jeden filar jest poza zakresem referencyjnym" }, "insufficient": "{count} kwalifikujące się filary. Wynik wymaga co najmniej trzech, w tym zmierzonego filaru fizjologicznego.", + "anatomyToggle": "Jak powstaje ten wynik", + "notScored": "Jeszcze bez oceny ({count})", + "method": "Wynik to średnia o równych wagach ze wszystkich filarów, które mają wystarczająco dużo aktualnych danych, w skali od 0 do 100. Każdy filar jest porównywany z opublikowanym zakresem referencyjnym, a najsłabszy filar może wyznaczyć ogólny zakres. Filar bez wystarczających danych jest pomijany, a nie liczony jako zero.", "bandSetter": "Ogólny zakres wyznacza {pillar}", "versionAndNoise": "Wersja metody {version}. Próg porównania tygodniowego: {noise} pkt.", "methodVersion": "Wersja metody {version}.", @@ -2320,7 +2330,6 @@ "insufficientHistory": "Za mało aktualnych danych spełniających kryteria", "unavailable": "Ten filar obecnie nie spełnia kryteriów" }, - "pillarInsufficient": "Brak wyniku filaru: {reason}", "tension": "Sygnały gotowości są rozbieżne. Sprzyjające: {positive}. Obciążone: {negative}.", "returnToBand": "{metric} od {days} dni ponownie mieści się w typowym zakresie.", "weightGoal": { @@ -3100,9 +3109,15 @@ "splitsTime": "Czas", "splitsPace": "Tempo", "thatDayTitle": "Tego dnia", - "dayPulse": "Tętno", - "daySleep": "Sen", - "dayMood": "Nastrój", + "daySleepTitle": "Noc wcześniej", + "daySleepNone": "Brak zapisanego snu tej nocy", + "daySleepAwakeningsOne": "{count} przebudzenie", + "daySleepAwakeningsMany": "{count} przebudzeń", + "dayMoodTitle": "Nastrój tego dnia", + "dayMoodNone": "Brak zapisanego nastroju tego dnia", + "sleepTrendsLink": "Trendy snu", + "moodHistoryLink": "Historia nastroju", + "previousSessionLink": "Poprzedni trening", "insightTitle": "Analiza aktywności" }, "sport": { @@ -3131,7 +3146,13 @@ "loadError": "Nie udało się wczytać treningów", "loadMore": "Wczytaj więcej treningów", "loadingMore": "Wczytywanie kolejnych treningów", - "loadMoreError": "Nie udało się wczytać kolejnych treningów" + "loadMoreError": "Nie udało się wczytać kolejnych treningów", + "countOne": "{count} trening", + "countOther": "{count} treningów", + "countFew": "{count} treningi", + "filterLabel": "Filtruj według sportu", + "filterAll": "Wszystkie sporty", + "filterEmpty": "Brak treningów tego sportu w Twojej historii." }, "bloodGlucose": { "title": "Poziom glukozy", @@ -4906,6 +4927,8 @@ "timezoneHint": "Używane do etykiet osi wykresów, godzin przypomnień, znaczników czasu eksportu i kontekstu Coacha. Zapisane dane pozostają niezmienione.", "timezoneInvalid": "To nie jest prawidłowa strefa czasowa IANA.", "profileSaved": "Profil zapisany", + "profilePartiallySaved": "Profil zapisany, ale nie udało się zaktualizować pola {field}.", + "profileNothingSaved": "Nic nie zostało zapisane. Popraw {field} i spróbuj ponownie.", "avatar": { "title": "Zdjęcie profilowe", "description": "Prześlij zdjęcie przechowywane na własnym serwerze. Pojawia się w całej aplikacji zamiast zewnętrznej usługi awatarów.", @@ -5643,7 +5666,6 @@ "cycleTracking": { "title": "Śledzenie cyklu", "description": "Śledź swój cykl menstruacyjny, prognozy, objawy i analizy faz. Domyślnie włączone dla kont ustawionych jako kobiece; dostępne dla wszystkich.", - "disclaimer": "Prognozy i fazy to opisowe szacunki na podstawie zarejestrowanych danych — nie są poradą medyczną ani metodą antykoncepcji. Nigdy nie wskazują, że współżycie bez zabezpieczenia jest bezpieczne. Dokładność rośnie wraz z liczbą zarejestrowanych cykli. W sprawach antykoncepcji lub decyzji medycznych skonsultuj się z pracownikiem ochrony zdrowia.", "enable": "Włącz śledzenie cyklu", "manage": "Otwórz śledzenie cyklu", "error": "Nie udało się zaktualizować śledzenia cyklu." @@ -9088,7 +9110,17 @@ "moduleOffDescription": "Włącz moduł składników odżywczych, aby zobaczyć tu nawodnienie i spożycie witamin/minerałów. Zsynchronizowane dane staną się częścią zapisu, który może odczytać Coach.", "moduleOffCta": "Włącz składniki odżywcze", "emptyTitle": "Brak jeszcze danych o składnikach odżywczych", - "emptyDescription": "Dane pochodzą z synchronizacji aplikacji zdrowia telefonu lub z ręcznego wpisu wody." + "emptyDescription": "Dane pochodzą z synchronizacji aplikacji zdrowia telefonu lub z ręcznego wpisu wody.", + "loadError": "Nie udało się wczytać danych o składnikach odżywczych", + "lastAttemptTitle": "Synchronizacja dotarła, ale nic nie udało się zapisać", + "lastAttemptAt": "Ostatnia próba: {date}", + "lastAttemptReason": { + "unitMismatch": "Otrzymane wartości nie pasowały do jednostek, których ta aplikacja oczekuje dla tych składników odżywczych, więc nie udało się ich zapisać.", + "valueOutOfRange": "Otrzymane ilości były wyższe, niż ta aplikacja może wiarygodnie zapisać, więc nie udało się ich zapisać.", + "dayInvalid": "Wpisy zawierały datę, której serwer nie mógł zaakceptować. Może się to zdarzyć, gdy zegar lub strefa czasowa po stronie wysyłającej są nieprawidłowe — warto to sprawdzić.", + "upsertFailed": "Te wpisy dotarły do serwera, ale problem po naszej stronie uniemożliwił ich zapisanie. Nie musisz nic robić.", + "unknown": "Te wpisy dotarły, ale nie udało się ich zapisać." + } }, "hydration": { "referenceMeta": "Zalecane spożycie {value} {unit}/dzień · EFSA", diff --git a/package.json b/package.json index 7e94e47ee..c993e4965 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.34.2", + "version": "1.34.3", "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", @@ -28,7 +28,7 @@ "dev": "next dev", "predev": "node scripts/generate-i18n-boot.mjs", "prebuild": "node scripts/generate-sw-version.mjs && node scripts/generate-i18n-boot.mjs", - "build": "next build", + "build": "NODE_OPTIONS=--max-old-space-size=8192 next build", "analyze": "ANALYZE=1 next build", "bundle-report": "node scripts/check-bundle-budget.mjs", "bundle-check": "node scripts/check-bundle-budget.mjs --check", diff --git a/public/sw.js b/public/sw.js index c247424d4..3f1789a63 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.34.2"; + /* @sw-version-fallback */ "v1.34.3"; 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/*` diff --git a/src/__tests__/v1341-locale-parity.test.ts b/src/__tests__/v1341-locale-parity.test.ts index 69c39370b..19ac817c4 100644 --- a/src/__tests__/v1341-locale-parity.test.ts +++ b/src/__tests__/v1341-locale-parity.test.ts @@ -37,6 +37,12 @@ const REQUIRED_KEYS = [ "settings.syncOutcome.partial", "settings.syncOutcome.failed", // Retained score, records, and navigation contracts. + // `insights.healthScore.method` was listed obsolete in v1.34.1 because its + // only reference was `METRIC_PROVENANCE.HEALTH_SCORE.methodKey`, which the + // card then passed as `method={null}` — a dangling reference, not a dead + // one. The score card now renders that method paragraph in its disclosure + // footer, so the key is a live contract again. + "insights.healthScore.method", "insights.healthScore.bandSetter", "insights.healthScore.versionAndNoise", "insights.healthScore.methodVersion", @@ -53,7 +59,6 @@ const REQUIRED_KEYS = [ const OBSOLETE_KEYS = [ "insights.healthScore.algorithmChanged", "insights.healthScore.dismissAlgorithmChanged", - "insights.healthScore.method", "insights.healthScore.composition", "documents.detail.summary.regenerate", "records.profileFacts.correct", diff --git a/src/app/__tests__/insights-polish.test.ts b/src/app/__tests__/insights-polish.test.ts index 93ff804fc..b225b623d 100644 --- a/src/app/__tests__/insights-polish.test.ts +++ b/src/app/__tests__/insights-polish.test.ts @@ -268,22 +268,42 @@ describe("v1.16.8 — insights overview cold paint", () => { expect(src).not.toMatch(/useDashboardDerived\([^)]*data/); }); - it("reserves the health-score column while the analytics payload is pending", () => { + it("reserves the health-score card's footprint while the payload is pending", () => { const src = load(INSIGHTS_PATH); - // The hero receives the pending flag so the score card's slot holds - // its footprint from first paint (skeleton in ``). + // The pinned slot holds the collapsed card's footprint from first paint, + // so the resolved card is a swap rather than a push. expect(src).toMatch( - /healthScorePending=\{analyticsQuery\.isPending && !analytics\}/, + /analyticsQuery\.isPending && !analytics \? \(\s* { + it("pins the health score under the hero, outside the section registry", () => { + const src = load(INSIGHTS_PATH); + // An id a saved layout has never seen merges in as default-INVISIBLE, so + // registering the score would delete it for every customised page. It is + // also no longer inside the briefing branch, which used to take the whole + // breakdown down with the flag. + const hero = src.indexOf(" { const heroSrc = readFileSync( join(ROOT, "src/components/insights/hero-strip.tsx"), "utf8", ); - expect(heroSrc).toContain('data-slot="health-score-card-skeleton"'); - // The two-column split keys on score-or-pending, not score alone. - expect(heroSrc).toMatch(/\(healthScore \|\| healthScorePending\) &&/); + expect(heroSrc).not.toContain("HealthScoreCard"); + expect(heroSrc).not.toContain("health-score-card-skeleton"); + expect(heroSrc).not.toContain("healthScorePending"); }); }); diff --git a/src/app/api/auth/profile/route.ts b/src/app/api/auth/profile/route.ts index 8b9d17347..515a3e272 100644 --- a/src/app/api/auth/profile/route.ts +++ b/src/app/api/auth/profile/route.ts @@ -1,6 +1,7 @@ import { apiSuccess, apiError, + apiValidationError, getClientIp, safeJson, } from "@/lib/api-response"; @@ -19,7 +20,10 @@ export const PUT = apiHandler(async (request: NextRequest) => { const result = await applyProfileUpdate(user.id, body, getClientIp(request)); if (!result.ok) { - return apiError(result.message, result.status); + const meta = result.errorCode ? { errorCode: result.errorCode } : undefined; + return result.issues + ? apiValidationError(result.message, result.issues, result.status, meta) + : apiError(result.message, result.status, meta); } annotate({ action: { name: "auth.profile.update" } }); @@ -37,5 +41,6 @@ export const PUT = apiHandler(async (request: NextRequest) => { insurerName: result.user.insurerName, insurerIkNumber: result.user.insurerIkNumber, hasInsuranceNumber: result.user.hasInsuranceNumber, + ...(result.rejectedFields ? { rejectedFields: result.rejectedFields } : {}), }); }); diff --git a/src/app/api/meta/capabilities/__tests__/route.test.ts b/src/app/api/meta/capabilities/__tests__/route.test.ts index e4102b843..1a068b6d0 100644 --- a/src/app/api/meta/capabilities/__tests__/route.test.ts +++ b/src/app/api/meta/capabilities/__tests__/route.test.ts @@ -61,6 +61,7 @@ import { SHARE_LINK_MAX_DAYS } from "@/lib/validations/clinician-share-link"; import { ALL_LEAF_IDS, REPORT_GROUP_ORDER, + SENSITIVE_GROUP_ID, } from "@/lib/report-selection/catalogue"; const SESSION_OK = { @@ -96,7 +97,7 @@ type CapabilitiesBody = { maxDays: number; reportDownload: string[]; selectionVersion: number; - groups: string[]; + groups: { id: string; leaves: string[]; sensitive?: boolean }[]; leaves: string[]; }; }; @@ -187,12 +188,37 @@ describe("GET /api/meta/capabilities — drift guards", () => { // that was never built. expect(body.data.share.reportDownload).toEqual(["fhir", "pdf"]); expect(body.data.share.selectionVersion).toBe(2); - expect(body.data.share.groups).toEqual([...REPORT_GROUP_ORDER]); + expect(body.data.share.groups.map((g) => g.id)).toEqual([ + ...REPORT_GROUP_ORDER, + ]); expect([...body.data.share.leaves].sort()).toEqual( [...ALL_LEAF_IDS].sort(), ); }); + it("share.groups carries membership: every leaf in exactly one group, fence flagged once", async () => { + const res = await call(); + const body = (await res.json()) as CapabilitiesBody; + + const seen = new Set(); + for (const group of body.data.share.groups) { + for (const leaf of group.leaves) { + expect(seen.has(leaf), `${leaf} appears in more than one group`).toBe( + false, + ); + seen.add(leaf); + } + } + // Every leaf in the flat set is in exactly one group, and no group holds + // a leaf absent from the flat set — the invariant that makes a native + // client's fence trustworthy. + expect([...seen].sort()).toEqual([...body.data.share.leaves].sort()); + + const sensitiveGroups = body.data.share.groups.filter((g) => g.sensitive); + expect(sensitiveGroups).toHaveLength(1); + expect(sensitiveGroups[0]?.id).toBe(SENSITIVE_GROUP_ID); + }); + it("quantityTypes are sourced from the HealthKit ingest mapping", async () => { const res = await call(); const body = (await res.json()) as CapabilitiesBody; diff --git a/src/app/api/meta/capabilities/route.ts b/src/app/api/meta/capabilities/route.ts index b9ce59410..64d99ad44 100644 --- a/src/app/api/meta/capabilities/route.ts +++ b/src/app/api/meta/capabilities/route.ts @@ -46,10 +46,7 @@ import { FHIR_SEARCH_PARAMS, } from "@/lib/fhir/rest"; import { SHARE_LINK_MAX_DAYS } from "@/lib/validations/clinician-share-link"; -import { - ALL_LEAF_IDS, - REPORT_GROUP_ORDER, -} from "@/lib/report-selection/catalogue"; +import { ALL_LEAF_IDS, SHARE_GROUPS } from "@/lib/report-selection/catalogue"; export const dynamic = "force-dynamic"; @@ -135,7 +132,12 @@ export const GET = apiHandler(async () => { maxDays: SHARE_LINK_MAX_DAYS, reportDownload: ["fhir", "pdf"], selectionVersion: 2, - groups: REPORT_GROUP_ORDER, + // Each group carries its own member leaves, and the fenced tier carries + // `sensitive: true` — the membership the panel, the PDF and the guard + // tests already enforce, published so the native client can mirror the + // fence instead of guessing which leaves it may bundle behind one + // control. `leaves` stays the flat total set for back-compat. + groups: SHARE_GROUPS, leaves: ALL_LEAF_IDS, }, }); diff --git a/src/app/api/nutrients/__tests__/route.test.ts b/src/app/api/nutrients/__tests__/route.test.ts index 5e8ae5db3..a13a1c089 100644 --- a/src/app/api/nutrients/__tests__/route.test.ts +++ b/src/app/api/nutrients/__tests__/route.test.ts @@ -96,7 +96,15 @@ describe("GET /api/nutrients", () => { const res = await GET(getReq()); expect(res.status).toBe(200); const body = (await res.json()) as OverviewResponse; - expect(body.data).toEqual({ windowDays: 14, nutrients: [] }); + // `lastAttempt` is null, not absent: a fresh account has no ingest in the + // ledger, and the empty page says "nothing yet" rather than naming a failure + // that never happened. Kept as an exact shape check so a field cannot appear + // on this response without someone deciding it should. + expect(body.data).toEqual({ + windowDays: 14, + nutrients: [], + lastAttempt: null, + }); }); it("folds rows into catalog-ordered summaries with latest day/amount and day counts", async () => { diff --git a/src/app/api/nutrients/batch/__tests__/route.test.ts b/src/app/api/nutrients/batch/__tests__/route.test.ts index b985d25c7..09dba1152 100644 --- a/src/app/api/nutrients/batch/__tests__/route.test.ts +++ b/src/app/api/nutrients/batch/__tests__/route.test.ts @@ -387,7 +387,12 @@ describe("POST /api/nutrients/batch — write semantics", () => { }); }); - it("writes the audit-ledger breadcrumb only when at least one row landed", async () => { + it("writes the audit-ledger breadcrumb on every call, landed rows or not", async () => { + // This used to fire only when `inserted + updated > 0`, which made the one + // durable trace of an ingest conditional on its success. A batch where every + // entry was rejected then looked exactly like a batch that never arrived, + // and on an empty table the two were indistinguishable from the record — + // which is what made "does the client even post?" unanswerable. await POST( postReq({ entries: [ @@ -395,7 +400,10 @@ describe("POST /api/nutrients/batch — write semantics", () => { ], }), ); - expect(auditLog).not.toHaveBeenCalled(); + expect(auditLog).toHaveBeenCalledWith( + "nutrient.batch.ingest", + expect.objectContaining({ userId: "user-1" }), + ); await POST( postReq({ @@ -404,10 +412,7 @@ describe("POST /api/nutrients/batch — write semantics", () => { ], }), ); - expect(auditLog).toHaveBeenCalledWith( - "nutrient.batch.ingest", - expect.objectContaining({ userId: "user-1" }), - ); + expect(auditLog).toHaveBeenCalledTimes(2); }); it("a failed bulk insert marks only the insert group skipped, without touching the update group", async () => { diff --git a/src/app/api/nutrients/batch/route.ts b/src/app/api/nutrients/batch/route.ts index 68e6ce217..cdfadcc57 100644 --- a/src/app/api/nutrients/batch/route.ts +++ b/src/app/api/nutrients/batch/route.ts @@ -306,17 +306,40 @@ async function postBatch(request: NextRequest): Promise { } } + // v1.34.3 — per-reason skip breakdown, durable AND ephemeral. Computed + // once from the closed `reason` set (never raw exception text — see the + // upsert `catch` blocks above), so this carries only counts and reason + // codes, never a per-entry health value or anything user-typed. + const skippedByReason: Record = {}; + for (const s of skipped) { + skippedByReason[s.reason] = (skippedByReason[s.reason] ?? 0) + 1; + } + + // v1.34.3 — audit UNCONDITIONALLY. This used to fire only when + // `inserted + updated > 0`, which made it the ONLY durable trace of the + // call — so a batch where every entry was skipped (say, a systemic unit + // mismatch) left the exact same empty AuditLog record as a phone that + // never posted at all. An operator investigating an empty + // `nutrient_intake_days` table could not tell "the client never called" + // from "the client called and every entry was rejected" without that + // record. Mirrors `measurement.batch.ingest` / `workout.batch.ingest` + // (unconditional, counts-only, no per-entry health values) plus the + // aggregate skip-reason breakdown so "did it post, and why did nothing + // land" is answerable from the durable row alone, without a Wide-Event + // trail that (unlike this table) isn't guaranteed to still exist. + await auditLog("nutrient.batch.ingest", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + processed: entries.length, + inserted, + updated, + skipped: skipped.length, + skippedByReason, + }, + }); + if (inserted + updated > 0) { - await auditLog("nutrient.batch.ingest", { - userId: user.id, - ipAddress: getClientIp(request), - details: { - processed: entries.length, - inserted, - updated, - skipped: skipped.length, - }, - }); // v1.30.3 (QA F9) — a landed row changes the dashboard's water/nutrient // tile; without this the snapshot kept serving the pre-sync figure // until the analytics fresh TTL lapsed (~3 min). Mirrors the manual @@ -333,6 +356,7 @@ async function postBatch(request: NextRequest): Promise { inserted, updated, skipped: skipped.length, + skipped_by_reason: skippedByReason, }, }); diff --git a/src/app/api/nutrients/route.ts b/src/app/api/nutrients/route.ts index 2cd5d8824..1e22c5a27 100644 --- a/src/app/api/nutrients/route.ts +++ b/src/app/api/nutrients/route.ts @@ -7,6 +7,12 @@ * does the server hold". Catalog order, no pagination (≤ 26 rows). * Module-gated like the ingest path: 403 `module.disabled` when the * opt-in `nutrients` module is off. `userId` is narrowed from auth. + * + * `lastAttempt` (v1.34.3): non-null only when the window is empty AND the + * most recent `nutrient.batch.ingest` audit row shows a call that landed + * nothing, so `/insights/nutrients` can say a sync arrived and why instead + * of a bare "no data yet". See the lookup below for the AuditLog-retention + * coupling this introduces. */ import { NextRequest } from "next/server"; @@ -21,6 +27,70 @@ import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format"; export const dynamic = "force-dynamic"; +/** Highest-count skip reason; ties break on this fixed declaration order. */ +const SKIP_REASON_PRIORITY = [ + "unit_mismatch", + "value_out_of_range", + "day_invalid", + "upsert_failed", +] as const; + +interface LastIngestDetails { + inserted: number; + updated: number; + skipped: number; + skippedByReason: Record; +} + +/** + * `AuditLog.details` is a free-form JSON string; parse it back into the + * shape `nutrient.batch.ingest` (`src/app/api/nutrients/batch/route.ts`) + * writes, rejecting anything that doesn't match rather than trusting the + * blob. + */ +function parseLastIngestDetails(raw: string | null): LastIngestDetails | null { + if (!raw) return null; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object") return null; + const obj = parsed as Record; + const { inserted, updated, skipped } = obj; + if ( + typeof inserted !== "number" || + typeof updated !== "number" || + typeof skipped !== "number" + ) { + return null; + } + const skippedByReason: Record = {}; + const rawByReason = obj.skippedByReason; + if (rawByReason && typeof rawByReason === "object") { + for (const [reason, count] of Object.entries( + rawByReason as Record, + )) { + if (typeof count === "number") skippedByReason[reason] = count; + } + } + return { inserted, updated, skipped, skippedByReason }; +} + +function pickTopSkipReason(byReason: Record): string | null { + let best: string | null = null; + let bestCount = 0; + for (const reason of SKIP_REASON_PRIORITY) { + const count = byReason[reason] ?? 0; + if (count > bestCount) { + best = reason; + bestCount = count; + } + } + return best; +} + export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); @@ -106,6 +176,51 @@ export const GET = apiHandler(async (request: NextRequest) => { }; }); + // The account holder's only signal that a sync ever arrived is this + // lookup — the read path stays honest instead of a bare "no data yet" + // when a batch actually landed and every entry was rejected. Gated on + // the zero-rows path ONLY: an AuditLog read must never run on the happy + // path just to serve a read endpoint. COUPLING: this depends on the row + // still existing — `cleanupOldAuditLogs` (`src/lib/jobs/audit-log-cleanup.ts`) + // prunes AuditLog nightly past `AUDIT_LOG_RETENTION_DAYS` (365 days by + // default), so a dormant account whose last failed attempt ages past + // that window silently loses the explanation and falls back to the + // plain empty state — anyone changing that retention job should know a + // user-facing message depends on it. + let lastAttempt: { at: string; topReason: string } | null = null; + if (nutrients.length === 0) { + // Fail soft, deliberately. This lookup exists only to explain an empty + // page; it is not the page. If it throws — a ledger read that errors, a + // shape nobody expected — the nutrients read must still answer, with the + // plain empty state rather than a 500. An explanation that can take down + // the thing it explains is worse than no explanation. + try { + const lastIngest = await prisma.auditLog.findFirst({ + where: { userId: user.id, action: "nutrient.batch.ingest" }, + orderBy: { createdAt: "desc" }, + select: { createdAt: true, details: true }, + }); + const details = parseLastIngestDetails(lastIngest?.details ?? null); + // Only surface a reason when the last attempt landed NOTHING: a + // succeeded attempt whose day just falls outside this window is a + // different, unrelated situation — showing a failure notice over it + // would be the very false claim this feature exists to prevent. + if (details && details.inserted === 0 && details.updated === 0) { + const topReason = pickTopSkipReason(details.skippedByReason); + if (topReason) { + lastAttempt = { at: lastIngest!.createdAt.toISOString(), topReason }; + } + } + } catch (err) { + // Recorded, never rendered: the operator sees it, the person sees the + // ordinary empty state. + annotate({ + action: { name: "nutrient.intake.last_attempt_lookup_failed" }, + meta: { reason: err instanceof Error ? err.name : "unknown" }, + }); + } + } + annotate({ action: { name: "nutrient.intake.read" }, meta: { @@ -115,5 +230,5 @@ export const GET = apiHandler(async (request: NextRequest) => { }, }); - return apiSuccess({ windowDays: days, nutrients }); + return apiSuccess({ windowDays: days, nutrients, lastAttempt }); }); diff --git a/src/app/api/user/profile/__tests__/route.test.ts b/src/app/api/user/profile/__tests__/route.test.ts index b628b0550..f771581db 100644 --- a/src/app/api/user/profile/__tests__/route.test.ts +++ b/src/app/api/user/profile/__tests__/route.test.ts @@ -368,13 +368,73 @@ describe("PATCH /api/user/profile", () => { expect(body.data.gender).toBe("OTHER"); }); - it("still refuses an unsupported gender, with a message that names the field", async () => { + it("still refuses an unsupported gender alone, with a message that names the field", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - const res = await PATCH(req({ timeFormat: "H24", gender: "diverse" })); + const res = await PATCH(req({ gender: "diverse" })); expect(res.status).toBe(422); expect(prisma.user.update).not.toHaveBeenCalled(); - const body = (await res.json()) as { error: string }; - expect(body.error).toContain("Gender must be MALE, FEMALE or OTHER"); + const body = (await res.json()) as { + error: string; + details?: { issues?: Array<{ path: string; message: string }> }; + }; + // The top-level message is a sentence for a person — it names the + // field but never repeats the validator's own enum-literal prose. + expect(body.error).toMatch(/nothing was saved/i); + expect(body.error).toContain("gender"); expect(body.error).not.toContain("Invalid option"); + // The specific reason is relocated, not deleted. + expect(body.details?.issues?.[0]).toMatchObject({ path: "gender" }); + }); + + // Reported regression: a rejected `gender` took a sibling `timeFormat` + // edit down with it because the whole PATCH was one transaction. The + // schema has no cross-field dependency, so the valid field must now + // land even when `gender` is rejected in the same body. + it("persists a valid sibling field even when gender in the same body is unsupported", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.update).mockResolvedValue({ + id: "user-1", + username: "testuser", + displayName: null, + email: null, + role: "USER", + heightCm: null, + dateOfBirth: null, + gender: null, + timezone: "Europe/Berlin", + locale: null, + timeFormat: "H24", + dateFormat: "AUTO", + moodReminderEnabled: false, + fullName: null, + insurerName: null, + insurerIkNumber: null, + insuranceNumberEncrypted: null, + } as never); + + const res = await PATCH(req({ timeFormat: "H24", gender: "diverse" })); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ timeFormat: "H24" }), + }), + ); + const arg = vi.mocked(prisma.user.update).mock.calls[0][0] as { + data: Record; + }; + expect("gender" in arg.data).toBe(false); + const body = (await res.json()) as { + error: string | null; + data: { + timeFormat: string; + rejectedFields?: Array<{ path: string }>; + }; + }; + expect(body.error).toBeNull(); + expect(body.data.timeFormat).toBe("H24"); + expect(body.data.rejectedFields).toEqual([ + expect.objectContaining({ path: "gender" }), + ]); }); }); diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index d3c211840..d6e6f6a71 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -11,6 +11,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, } from "@/lib/api-response"; @@ -101,7 +102,10 @@ export const PATCH = apiHandler(async (request: NextRequest) => { const result = await applyProfileUpdate(user.id, body, getClientIp(request)); if (!result.ok) { - return apiError(result.message, result.status); + const meta = result.errorCode ? { errorCode: result.errorCode } : undefined; + return result.issues + ? apiValidationError(result.message, result.issues, result.status, meta) + : apiError(result.message, result.status, meta); } annotate({ action: { name: "user.profile.update" } }); @@ -122,5 +126,6 @@ export const PATCH = apiHandler(async (request: NextRequest) => { insurerName: result.user.insurerName, insurerIkNumber: result.user.insurerIkNumber, hasInsuranceNumber: result.user.hasInsuranceNumber, + ...(result.rejectedFields ? { rejectedFields: result.rejectedFields } : {}), }); }); diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index 439dbe5f2..d6c348171 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -41,6 +41,8 @@ import { import { computeSplits } from "@/lib/workouts/splits"; import { buildSportContext } from "@/lib/workouts/sport-context"; import type { RouteCoordinate } from "@/lib/workouts/route-svg"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { userDayKey } from "@/lib/tz/format"; type RouteParams = { params: Promise<{ id: string }> }; @@ -129,6 +131,14 @@ export const GET = apiHandler( where: { id: user.id }, select: { sourcePriorityJson: true, dateOfBirth: true }, }); + // The local calendar day this session belongs to. Resolved here, in the + // user's timezone, so the day linkage stays server-authoritative — a + // client that derived it from `startedAt` would fall back to the browser + // zone and put a late-evening session on the wrong day whenever the two + // disagree. + const timezone = await resolveUserTimezone(user.id); + const dayKey = userDayKey(row.startedAt, timezone); + const canonicalCluster = pickCanonicalWorkoutRows( clusterRows, userRow?.sourcePriorityJson ?? null, @@ -201,6 +211,47 @@ export const GET = apiHandler( row.id, ); + // The session before this one in the same sport. "How does this compare + // to last time?" is the most likely next question after reading a + // workout, and today it costs a trip back to the list plus a scan. + // Scoped to rows that start before this cluster's slot, so a twin of THIS + // session can never be offered as the previous one, and run through the + // same canonical picker so the link lands on the row the list shows. + const previousRows = await prisma.workout.findMany({ + where: { + userId: user.id, + sportType: row.sportType, + startedAt: { lt: slotStart }, + }, + orderBy: [{ startedAt: "desc" }, { id: "desc" }], + // Enough to survive a run of same-session twins and re-sends without + // reading the whole history; the picker collapses them to a handful. + take: 20, + select: { + id: true, + source: true, + startedAt: true, + sportType: true, + durationSec: true, + avgHeartRate: true, + maxHeartRate: true, + metadata: true, + }, + }); + // The picker takes rows in ascending order and does not promise to hand + // them back sorted, so pick the newest survivor explicitly. + const previousWorkoutId = + pickCanonicalWorkoutRows( + [...previousRows].reverse(), + userRow?.sourcePriorityJson ?? null, + ).reduce<{ id: string; startedAt: Date } | null>( + (newest, candidate) => + newest === null || candidate.startedAt > newest.startedAt + ? candidate + : newest, + null, + )?.id ?? null; + const route = row.route ? { geometry: row.route.geometry, @@ -250,6 +301,10 @@ export const GET = apiHandler( sportType: row.sportType, startedAt: row.startedAt, endedAt: row.endedAt, + // `YYYY-MM-DD` in the user's timezone. Additive; it addresses the + // day-scoped reads (intraday pulse, that night's sleep, the day's + // mood) the detail surface renders around the session. + dayKey, durationSec: row.durationSec, distanceM: row.totalDistanceM, activeEnergyKcal: row.totalEnergyKcal, @@ -281,6 +336,10 @@ export const GET = apiHandler( // winner. `canonicalId === id` when the requested row already // is the winner. canonicalId: canonical?.id ?? row.id, + // The canonical previous session in this sport, or null when this is + // the first one on record. Additive; a client that does not read it is + // unaffected. + previousWorkoutId, }); }, ); diff --git a/src/app/api/workouts/__tests__/detail.test.ts b/src/app/api/workouts/__tests__/detail.test.ts index 28981227b..05bd9a6fe 100644 --- a/src/app/api/workouts/__tests__/detail.test.ts +++ b/src/app/api/workouts/__tests__/detail.test.ts @@ -15,7 +15,10 @@ * - the optional `route` field flows through GeoJSON geometry when a * `WorkoutRoute` row is attached, and is null when missing; * - `aiInsight` is a pure READ: it serves a stored paragraph when one - * exists, null when none does, and never generates one. + * exists, null when none does, and never generates one; + * - `dayKey` dates the session in the user's timezone, so a session that + * starts late in the UTC evening lands on the local day the person + * actually trained on. */ import { afterEach, describe, expect, it, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; @@ -65,6 +68,7 @@ import { GET } from "../[id]/route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { isModuleEnabled, requireModuleEnabled } from "@/lib/modules/gate"; +import { invalidateUserTimezone } from "@/lib/tz/resolver"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -125,6 +129,9 @@ describe("GET /api/workouts/{id}", () => { enabled: true, } as never); vi.mocked(isModuleEnabled).mockResolvedValue(true); + // The timezone resolver caches per user for 60 s in process; drop the + // entry so a test that pins a zone is not served the previous one. + invalidateUserTimezone(SESSION_OK.user.id); }); it("returns the workout with iOS-contract field names", async () => { @@ -151,6 +158,97 @@ describe("GET /api/workouts/{id}", () => { expect(body.data.maxHr).toBe(170); expect(body.data.canonicalId).toBe("w-1"); expect(body.data.route).toBeNull(); + // Nothing older on record: absence, not a link to nowhere. + expect(body.data.previousWorkoutId).toBeNull(); + }); + + it("points previousWorkoutId at the canonical session before this one", async () => { + vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce( + BASE_ROW as never, + ); + vi.mocked(prisma.workout.findMany) + // 1) the deep-link cluster + .mockResolvedValueOnce([ + { + id: "w-1", + source: "APPLE_HEALTH", + startedAt: BASE_ROW.startedAt, + sportType: "RUNNING", + }, + ] as never) + // 2) the sport-context baseline + .mockResolvedValueOnce([] as never) + // 3) the older sessions, newest first + .mockResolvedValueOnce([ + { + id: "w-0", + source: "APPLE_HEALTH", + startedAt: new Date("2026-05-13T07:00:00Z"), + sportType: "RUNNING", + durationSec: 1800, + avgHeartRate: null, + maxHeartRate: null, + metadata: null, + }, + // The same session as seen by the second watch. It must never win + // the link — the list would never show it. + { + id: "w-0-twin", + source: "WITHINGS", + startedAt: new Date("2026-05-13T07:01:00Z"), + sportType: "RUNNING", + durationSec: 1800, + avgHeartRate: null, + maxHeartRate: null, + metadata: null, + }, + { + id: "w-older", + source: "APPLE_HEALTH", + startedAt: new Date("2026-05-10T07:00:00Z"), + sportType: "RUNNING", + durationSec: 1800, + avgHeartRate: null, + maxHeartRate: null, + metadata: null, + }, + ] as never); + + const res = await GET(makeRequest(), makeParams("w-1")); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.data.previousWorkoutId).toBe("w-0"); + }); + + it("dates the session by the user's timezone, not by UTC", async () => { + // 20:00 UTC is already the NEXT calendar day in Auckland. A client that + // sliced the ISO timestamp would read 2026-05-15 and then pull the wrong + // day's pulse, sleep and mood around the session. + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + sourcePriorityJson: null, + dateOfBirth: null, + timezone: "Pacific/Auckland", + } as never); + vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({ + ...BASE_ROW, + startedAt: new Date("2026-05-15T20:00:00Z"), + endedAt: new Date("2026-05-15T20:30:00Z"), + } as never); + vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ + { + id: "w-1", + source: "APPLE_HEALTH", + startedAt: new Date("2026-05-15T20:00:00Z"), + sportType: "RUNNING", + }, + ] as never); + + const res = await GET(makeRequest(), makeParams("w-1")); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.data.dayKey).toBe("2026-05-16"); }); it("enriches the Apple winner with only matched WHOOP HR and zones", async () => { diff --git a/src/app/insights/__tests__/assessment-spine-order.test.ts b/src/app/insights/__tests__/assessment-spine-order.test.ts index dd121c8a5..66fb9f720 100644 --- a/src/app/insights/__tests__/assessment-spine-order.test.ts +++ b/src/app/insights/__tests__/assessment-spine-order.test.ts @@ -176,11 +176,11 @@ describe("assessment presentation contract", () => { ); expect(cardSource).toContain( - 'className="animate-insight-in gap-1.5 py-3 md:py-4"', + 'className="animate-insight-in gap-2 py-3 md:py-4"', ); expect(cardSource).toContain('className="text-foreground text-sm"'); expect(moodSource).toContain( - 'className="animate-insight-in gap-1.5 py-3 md:py-4"', + 'className="animate-insight-in gap-2 py-3 md:py-4"', ); expect(moodSource).toContain('data-slot="insight-assessment"'); expect(moodBodySource).toContain( diff --git a/src/app/insights/nutrients/page.tsx b/src/app/insights/nutrients/page.tsx index 5f123e74c..0790865e5 100644 --- a/src/app/insights/nutrients/page.tsx +++ b/src/app/insights/nutrients/page.tsx @@ -6,18 +6,42 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { HydrationCard } from "@/components/insights/nutrients/hydration-card"; import { CaffeineCard } from "@/components/insights/nutrients/caffeine-card"; import { MicronutrientsCard } from "@/components/insights/nutrients/micronutrients-card"; import { useAuth } from "@/hooks/use-auth"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { apiGet, apiPatch } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; import type { NutrientIntakeOverview } from "@/components/insights/nutrients/types"; const WINDOW_DAYS = 30; +/** + * `nutrient.batch.ingest`'s closed skip-reason codes + * (`src/app/api/nutrients/batch/route.ts`) mapped to a human sentence — + * never render the raw code. A reason with no useful action for the + * account holder says plainly that the entries couldn't be stored rather + * than inventing advice; `dayInvalid` is the one case with something + * genuinely worth checking (a clock/time-zone mismatch on the sending + * side), so it says so. + */ +const LAST_ATTEMPT_REASON_KEYS: Record = { + unit_mismatch: "nutrients.page.lastAttemptReason.unitMismatch", + value_out_of_range: "nutrients.page.lastAttemptReason.valueOutOfRange", + day_invalid: "nutrients.page.lastAttemptReason.dayInvalid", + upsert_failed: "nutrients.page.lastAttemptReason.upsertFailed", +}; + +function lastAttemptReasonKey(reason: string): string { + return ( + LAST_ATTEMPT_REASON_KEYS[reason] ?? + "nutrients.page.lastAttemptReason.unknown" + ); +} + /** * `/insights/nutrients` — hydration + micronutrients (v1.29). * @@ -33,13 +57,32 @@ const WINDOW_DAYS = 30; * on the device is not visible consent to a server / Coach holding * a supplement pattern); this page just makes the toggle * discoverable in context instead of buried in Settings. - * - module on, zero rows anywhere in the window → one EmptyState - * explaining where data comes from (phone health-app sync or - * manual water entry) instead of three near-empty cards. + * - module on, the overview read failed → a `QueryErrorCard` with a + * retry. This branch has to sit ABOVE the empty-state check below: + * `overview.data` is `undefined` on a failed read exactly like it is + * before the first response lands, so without this the page told a + * self-hoster who granted every HealthKit permission "no nutrient + * data yet" when the read had actually failed — a guess dressed up + * as an honest absence. `` reads this SAME + * `queryKeys.nutrientIntake(WINDOW_DAYS)` query and already renders + * its own error card, but this page's old empty-state branch + * short-circuited before that card ever mounted. + * - module on, zero rows anywhere in the window, AND the server found a + * recent sync attempt that landed nothing (`overview.data.lastAttempt`, + * from `GET /api/nutrients` — see that route for the AuditLog lookup + * and its retention coupling) → an EmptyState naming the attempt and + * the reason, not the generic copy. A batch CAN arrive and have every + * entry rejected (wrong unit, an implausible amount, a bad date, a + * write failure); without this branch that read the same as nothing + * ever syncing. + * - module on, zero rows, no recorded attempt → the generic EmptyState + * explaining where data comes from (a health-app sync or a manual + * water entry) instead of three near-empty cards. * - otherwise → the three-card spine. */ export default function InsightsNutrientsPage() { const { t } = useTranslations(); + const fmt = useFormatters(); const { user } = useAuth(); const queryClient = useQueryClient(); @@ -83,22 +126,54 @@ export default function InsightsNutrientsPage() { ); } - const hasAnyData = (overview.data?.nutrients.length ?? 0) > 0; - if (!overview.isLoading && !hasAnyData) { + if (overview.isError) { return ( - } - title={t("nutrients.page.emptyTitle")} - description={t("nutrients.page.emptyDescription")} + void overview.refetch()} /> ); } + const hasAnyData = (overview.data?.nutrients.length ?? 0) > 0; + if (!overview.isLoading && !hasAnyData) { + const lastAttempt = overview.data?.lastAttempt ?? null; + return ( + + {lastAttempt ? ( + } + title={t("nutrients.page.lastAttemptTitle")} + description={ + <> + {t(lastAttemptReasonKey(lastAttempt.topReason))} + + {t("nutrients.page.lastAttemptAt", { + date: fmt.dateTime(lastAttempt.at), + })} + + + } + /> + ) : ( + } + title={t("nutrients.page.emptyTitle")} + description={t("nutrients.page.emptyDescription")} + /> + )} + + ); + } + return ( ), "daily-briefing": flags.briefing ? ( - <> - - {analytics?.healthScore ? ( - - ) : null} - + ) : null, vitals: , trends: ( @@ -558,28 +556,38 @@ export default function InsightsPageClient() { briefing={briefingPayload} updatedAt={heroStripUpdatedAt} userName={heroGreetingName} - healthScore={analytics?.healthScore ?? undefined} - // v1.16.8 — reserve the score card's column while the thick - // analytics payload is in flight. Every overview query - // (comprehensive, advisor, analytics, derived batch, layout) - // already fires in parallel at mount; the "greeting first, - // score card later" stagger came from the right column not - // existing until the slowest payload resolved. `isPending` - // settles false on success AND error, so a no-score account - // collapses the column exactly once. - healthScorePending={analyticsQuery.isPending && !analytics} // v1.18.9 (#4) — the hero subtitle is the cached briefing paragraph; // when it can never refresh (no AI provider) pair the "Generated // " line with a discreet connect-provider hint, matching // the briefing card footer. Only when a briefing is actually shown. noProviderStale={briefingPayload !== null && !advisor.hasProvider} - // v1.21.2 (A5 / A6) — server-resolved Tension Verdict + return-to- - // baseline off the dashboard snapshot. The hero localises the keys and - // forwards them to the score card; null leaves the card quiet. - tension={heroTension} - returnToBand={heroReturnToBand} /> + {/* The Health Score is PINNED here, between the greeting and the + customizable region, and is deliberately NOT a registry section: an + id a saved layout has never seen merges in as default-INVISIBLE + (`orderedVisibleSectionIds`), so registering it would silently delete + the score for every account that has ever customised this page. It + also used to render inside the daily-briefing branch, which meant an + instance with the briefing switched off lost the whole breakdown. + `isPending` settles false on success AND error, so a no-score account + drops the reserve exactly once. */} + {analyticsQuery.isPending && !analytics ? ( + + ) : analytics?.healthScore ? ( + void analyticsQuery.refetch()} + /> + ) : null} + {/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the overview (section show/hide + order) and sorting the nav pills now live ONLY in Settings → Insights, reached via the top-right cog on the diff --git a/src/app/insights/workouts/[id]/page.tsx b/src/app/insights/workouts/[id]/page.tsx index 7798f06a3..2780caf84 100644 --- a/src/app/insights/workouts/[id]/page.tsx +++ b/src/app/insights/workouts/[id]/page.tsx @@ -21,7 +21,7 @@ import { WorkoutDetailZones, WorkoutDetailRoute, WorkoutDetailSplits, - WorkoutDetailDayLinks, + WorkoutDetailDayContext, WorkoutInsightCard, } from "@/components/insights/workout-detail"; @@ -31,7 +31,12 @@ import { * Layout, top to bottom (mobile-first single column): * hero header → reserved Activity-Insight seam (renders nothing today) * → stats grid + sport-average line → HR curve → effort zones → GPS - * route → per-km splits → "that day" cross-links → coach launch. + * route → per-km splits → "that day" → coach launch. + * + * "That day" answers the last question the page is for: what the rest of + * the day looked like around the session. It renders the day's own pulse + * shape, that night's sleep and the day's mood inline, all addressed by + * the server-resolved `dayKey`. * * Every data-less section returns `null` (hide, don't render empty), so * an aggregates-only workout (a Strava ride with no wearable, a manual @@ -70,7 +75,9 @@ export default function InsightsWorkoutDetailPage({ return ( - + {/* 2026-07-17 UX-flows audit F6-1 — `workouts` narrows the snapshot the first coach turn reads. v1.31.0 — `workoutId` pins THIS session's own numbers as one additional snapshot section, so diff --git a/src/app/insights/workouts/page-client.tsx b/src/app/insights/workouts/page-client.tsx index 13e13e4d1..cf0d18691 100644 --- a/src/app/insights/workouts/page-client.tsx +++ b/src/app/insights/workouts/page-client.tsx @@ -1,7 +1,9 @@ "use client"; +import { useMemo, useState } from "react"; import { Activity, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { useInfiniteWorkouts } from "@/hooks/use-workouts"; import { useModulePageGuard } from "@/hooks/use-module-page-guard"; @@ -31,12 +33,20 @@ import { QueryErrorCard } from "@/components/ui/query-error-card"; * workout-entry form today), * - the deduped list otherwise. Each row links to * `/insights/workouts/[id]` for the detail surface. + * + * A sport filter narrows the list server-side through the `sportType` + * parameter the route and the hook already accept. The chips offer only + * sports the account actually has, captured from the unfiltered read, so + * choosing one never empties the row it was chosen from. The RSC prefetch + * seeds the unfiltered key only, so a filtered view costs one client fetch. */ export default function InsightsWorkoutsPageClient() { - const { t } = useTranslations(); + const { t, tCount } = useTranslations(); const { ready } = useModulePageGuard("workouts"); + const [sportType, setSportType] = useState(undefined); const { workouts, + total, isLoading, isEmpty, isError, @@ -45,7 +55,26 @@ export default function InsightsWorkoutsPageClient() { hasNextPage, fetchNextPage, refetch, - } = useInfiniteWorkouts({ limit: 100 }); + } = useInfiniteWorkouts({ limit: 100, sportType }); + + // The chip row's vocabulary comes from the UNFILTERED read: deriving it + // from the filtered list would collapse the row to the one sport already + // chosen, leaving no way back. With no filter active this resolves to the + // very same query key as the list above, so the two share one query and + // the row costs nothing; under a filter it keeps serving that cache. + const { workouts: allSports } = useInfiniteWorkouts({ limit: 100 }); + const sportOptions = useMemo( + () => Array.from(new Set(allSports.map((w) => w.sportType))).sort(), + [allSports], + ); + + const sportLabel = (sport: string) => { + // A sport enum the bundle has no copy for renders as its canonical + // string rather than a raw key — same fallback the list rows use. + const key = `insights.workouts.sport.${sport}`; + const label = t(key); + return label === key ? sport : label; + }; // v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account. if (!ready) { @@ -77,7 +106,7 @@ export default function InsightsWorkoutsPageClient() { - ) : isEmpty ? ( + ) : isEmpty && sportType === undefined ? ( } title={t("insights.workouts.emptyState.title")} @@ -85,15 +114,67 @@ export default function InsightsWorkoutsPageClient() { cta={null} coachPrefill="I haven't logged any workouts yet — why does tracking them matter, and what should I focus on first?" /> - ) : workouts.length > 0 ? ( - - ) : null} + ) : ( + <> + {sportOptions.length > 1 ? ( +
+ {[undefined, ...sportOptions].map((sport) => { + const active = sport === sportType; + return ( + + ); + })} +
+ ) : null} + {workouts.length > 0 ? ( + <> +

+ {tCount("insights.workouts.count", total)} +

+ + + ) : ( +

+ {t("insights.workouts.filterEmpty")} +

+ )} + + )}
); } diff --git a/src/components/__tests__/failed-read-never-empty.test.tsx b/src/components/__tests__/failed-read-never-empty.test.tsx index 8b4eaf6ff..cbf79ed4e 100644 --- a/src/components/__tests__/failed-read-never-empty.test.tsx +++ b/src/components/__tests__/failed-read-never-empty.test.tsx @@ -32,8 +32,11 @@ vi.mock("@/lib/api/api-fetch", () => ({ })); vi.mock("@/hooks/use-auth", () => ({ + // `modules.nutrients: true` so the `/insights/nutrients` page tests below + // reach their query instead of short-circuiting on the module-off branch; + // no other surface in this file reads `user.modules`. useAuth: () => ({ - user: { gender: "FEMALE", modules: {} }, + user: { gender: "FEMALE", modules: { nutrients: true } }, isAuthenticated: true, isLoading: false, }), @@ -41,6 +44,7 @@ vi.mock("@/hooks/use-auth", () => ({ import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing"; import { MicronutrientsCard } from "@/components/insights/nutrients/micronutrients-card"; +import InsightsNutrientsPage from "@/app/insights/nutrients/page"; function makeClient() { return new QueryClient({ @@ -81,6 +85,11 @@ function render(node: React.ReactNode, client: QueryClient): string { ); } +/** React's SSR serialiser escapes `'` as `'` in text nodes. */ +function htmlEscaped(text: string): string { + return text.replace(/'/g, "'"); +} + describe("mental wellbeing — screening history", () => { const mh = en.mentalHealth; @@ -140,6 +149,85 @@ describe("micronutrients card", () => { }); }); +describe("/insights/nutrients page — overview read shares the micronutrients queryKey", () => { + const np = en.nutrients.page; + + // The page's overview query and `` read the exact same + // `queryKeys.nutrientIntake(30)` cache entry. Before this branch existed, a + // failed read left `overview.data` undefined — indistinguishable from the + // pre-response state — so the page's empty-state check fired first and + // returned "no nutrient data yet" without ever mounting the three cards + // below (including ``'s own, already-correct error + // card). + it("renders the page-level error card, not the empty state, when the overview read fails", async () => { + const client = makeClient(); + await seedFailure(client, queryKeys.nutrientIntake(30)); + const html = render(, client); + + expect(html).toContain('data-slot="query-error-card"'); + expect(html).toContain(np.loadError); + expect(html).toContain(en.common.retry); + expect(html).not.toContain(np.emptyTitle); + // The three-card spine must not mount alongside the error notice. + expect(html).not.toContain('data-slot="nutrients-hydration-card"'); + expect(html).not.toContain('data-slot="nutrients-micronutrients-card"'); + }); + + it("still renders the honest empty state when the window genuinely has no rows", () => { + const client = makeClient(); + client.setQueryData(queryKeys.nutrientIntake(30), { + nutrients: [], + lastAttempt: null, + }); + const html = render(, client); + + expect(html).not.toContain('data-slot="query-error-card"'); + expect(html).toContain(np.emptyTitle); + expect(html).not.toContain(np.lastAttemptTitle); + }); + + // `GET /api/nutrients` returns `lastAttempt` non-null only when the window + // is empty AND the most recent `nutrient.batch.ingest` audit row shows a + // call that landed nothing — a batch CAN arrive and have every entry + // rejected, which used to read on this page exactly like nothing ever + // synced. The reason must render as the mapped human sentence, never the + // raw wire code. + it("names the sync attempt and its reason instead of the generic empty state", () => { + const client = makeClient(); + client.setQueryData(queryKeys.nutrientIntake(30), { + nutrients: [], + lastAttempt: { + at: "2026-07-15T09:30:00.000Z", + topReason: "unit_mismatch", + }, + }); + const html = render(, client); + + expect(html).toContain(np.lastAttemptTitle); + expect(html).toContain(htmlEscaped(np.lastAttemptReason.unitMismatch)); + // The date line renders around a formatted timestamp; assert the fixed + // prefix rather than the locale-formatted date itself. + expect(html).toContain(np.lastAttemptAt.split("{date}")[0]); + expect(html).not.toContain(np.emptyTitle); + expect(html).not.toContain("unit_mismatch"); + }); + + it("falls back to the generic reason sentence for an unmapped code", () => { + const client = makeClient(); + client.setQueryData(queryKeys.nutrientIntake(30), { + nutrients: [], + lastAttempt: { + at: "2026-07-15T09:30:00.000Z", + topReason: "some_future_reason", + }, + }); + const html = render(, client); + + expect(html).toContain(np.lastAttemptTitle); + expect(html).toContain(htmlEscaped(np.lastAttemptReason.unknown)); + }); +}); + /** * The remaining surfaces in the sweep are pages whose render depends on the * router / module gate, which the SSR-only convention cannot drive. Pin their @@ -155,6 +243,11 @@ describe("read-failure branch is wired on every swept surface", () => { "src/components/custom-metrics/custom-metric-values-list.tsx", "src/app/achievements/page.tsx", "src/components/insights/nutrients/micronutrients-card.tsx", + // The page shares `MicronutrientsCard`'s exact queryKey and used to + // short-circuit to the "no nutrient data yet" EmptyState on a failed + // read, before that card's own (already-correct) error branch ever + // got a chance to mount. + "src/app/insights/nutrients/page.tsx", // A failed comprehensive read used to fall through to "you haven't added // any medications yet" — with a CTA inviting someone on a full daily // regimen to re-enter what the app already holds. diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx index 3faa061f7..0316245bd 100644 --- a/src/components/insights/__tests__/health-score-card.test.tsx +++ b/src/components/insights/__tests__/health-score-card.test.tsx @@ -1,13 +1,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; -import { I18nProvider } from "@/lib/i18n/context"; +import { I18nProvider, useTranslations } from "@/lib/i18n/context"; import type { Locale } from "@/lib/i18n/config"; -import type { HealthScoreReport } from "@/lib/analytics/score/types"; +import type { + HealthScoreReport, + ScorePillarResult, +} from "@/lib/analytics/score/types"; import { HealthScoreCard, + HealthScoreCardSkeleton, markAlgorithmNoticeDismissed, } from "../health-score-card"; +import { + pillarDetailLines, + pillarObservedText, + type Translate, +} from "../health-score-pillar-detail"; + let mockAuthUser: { unitPreference: string; glucoseUnit: string } = { unitPreference: "metric", glucoseUnit: "mg/dL", @@ -32,12 +42,148 @@ const coverage = { missing: ["ACTIVITY", "SLEEP"], }; -function render(report: HealthScoreReport, locale: Locale = "en") { +function render(node: React.ReactNode, locale: Locale = "en"): string { return renderToStaticMarkup( + {node}, + ); +} + +/** + * The pillar detail lines live behind a Radix popover, which only mounts its + * body once opened, so they are asserted through the pure formatter the card + * feeds the popover. Lifting the live translator out of a throwaway render + * keeps those assertions on the SAME strings the card shows rather than a + * second copy of the copy. + */ +function translator(locale: Locale = "en"): Translate { + let captured: Translate | null = null; + function Probe() { + captured = useTranslations().t; + return null; + } + renderToStaticMarkup( - + , ); + if (!captured) throw new Error("translator probe did not run"); + return captured; +} + +const BLOOD_PRESSURE: ScorePillarResult = { + id: "BLOOD_PRESSURE", + domain: "cardiometabolic", + result: { + status: "ok", + value: { + score: 82, + observed: { + value: 126, + unit: "mmHg", + label: "126/78 mmHg", + asOf: "2026-07-27T08:00:00.000Z", + sources: ["MANUAL"], + }, + reference: { + kind: "clinical-threshold", + low: 120, + high: 129, + label: "120 to 129/70 to 79 mmHg", + source: "ESH 2023", + }, + personalReference: { + kind: "guideline-band", + low: 115, + high: 125, + label: "115 to 125/65 to 75 mmHg", + source: "personal target", + }, + noiseFloor: 1, + deltaEligible: true, + deltaIdentity: "graded_bp_pair_series", + }, + coverage: { + requiredInputs: 12, + presentInputs: 12, + historyDays: 28, + missing: [], + }, + confidence: { score: 100, band: "high" }, + provenance, + }, +}; + +const ACTIVITY_BELOW_FLOOR: ScorePillarResult = { + id: "ACTIVITY", + domain: "activity", + result: { + status: "insufficient", + coverage: { + requiredInputs: 21, + presentInputs: 7, + historyDays: 7, + missing: ["14 days"], + }, + provenance: { ...provenance, inputs: ["ACTIVITY"], windowDays: 28 }, + reason: "below_day_floor_or_stale", + }, +}; + +const FASTING_GLUCOSE: ScorePillarResult = { + id: "GLYCAEMIA", + domain: "cardiometabolic", + result: { + status: "ok", + value: { + score: 80, + observed: { + value: 100, + unit: "mg/dL", + label: "Fasting glucose", + asOf: "2026-07-27T08:00:00.000Z", + sources: ["MANUAL"], + }, + reference: { + kind: "clinical-threshold", + low: 70, + high: 99, + label: "70 to 99 mg/dL fasting", + source: "ADA 2026", + }, + noiseFloor: 1, + deltaEligible: true, + deltaIdentity: "fasting_glucose", + }, + coverage: { + requiredInputs: 1, + presentInputs: 1, + historyDays: 1, + missing: [], + }, + confidence: { score: 100, band: "high" }, + provenance, + }, +}; + +function gatedPillar( + id: ScorePillarResult["id"], + reason: string, +): ScorePillarResult { + return { + id, + domain: "wellbeing", + result: { + status: "insufficient", + coverage: { + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: [], + }, + provenance: { ...provenance, inputs: [id] }, + reason, + }, + }; } function report(overrides: Partial = {}): HealthScoreReport { @@ -48,65 +194,7 @@ function report(overrides: Partial = {}): HealthScoreReport { provenance, reason: "three_domains_required", }, - pillars: [ - { - id: "BLOOD_PRESSURE", - domain: "cardiometabolic", - result: { - status: "ok", - value: { - score: 82, - observed: { - value: 126, - unit: "mmHg", - label: "126/78 mmHg", - asOf: "2026-07-27T08:00:00.000Z", - sources: ["MANUAL"], - }, - reference: { - kind: "clinical-threshold", - low: 120, - high: 129, - label: "120 to 129/70 to 79 mmHg", - source: "ESH 2023", - }, - personalReference: { - kind: "guideline-band", - low: 115, - high: 125, - label: "115 to 125/65 to 75 mmHg", - source: "personal target", - }, - noiseFloor: 1, - deltaEligible: true, - deltaIdentity: "graded_bp_pair_series", - }, - coverage: { - requiredInputs: 12, - presentInputs: 12, - historyDays: 28, - missing: [], - }, - confidence: { score: 100, band: "high" }, - provenance, - }, - }, - { - id: "ACTIVITY", - domain: "activity", - result: { - status: "insufficient", - coverage: { - requiredInputs: 21, - presentInputs: 7, - historyDays: 7, - missing: ["14 days"], - }, - provenance: { ...provenance, inputs: ["ACTIVITY"], windowDays: 28 }, - reason: "below_day_floor_or_stale", - }, - }, - ], + pillars: [BLOOD_PRESSURE, ACTIVITY_BELOW_FLOOR], delta: null, deltaReason: "no_current_score", scoreVersion: 2, @@ -158,208 +246,345 @@ function scoredReport( }); } +/** The markup between the disclosure region's open tag and the card's end. */ +function anatomyRegion(html: string): string { + const start = html.indexOf('data-slot="health-score-anatomy-region"'); + expect(start).toBeGreaterThan(-1); + return html.slice(start); +} + beforeEach(() => { mockAuthUser = { unitPreference: "metric", glucoseUnit: "mg/dL" }; }); -describe("", () => { - it("keeps below-floor pillars visible without a composite headline", () => { - const html = render(report()); - expect(html).toContain('data-status="insufficient"'); - expect(html).toContain('data-contributor="BLOOD_PRESSURE"'); - expect(html).toContain('data-contributor="ACTIVITY"'); - expect(html).toContain("Not enough recent eligible data"); - expect(html).not.toContain("below_day_floor_or_stale"); - expect(html).toContain("Your target: 115 to 125/65 to 75 mmHg"); - expect(html).not.toContain('data-slot="score-ring"'); +describe(" disclosure", () => { + it("opens collapsed: the breakdown is present but hidden and out of tab order", () => { + const html = render(); + expect(html).toMatch( + /aria-expanded="false"[^>]*data-slot="health-score-anatomy-toggle"/, + ); + // The trigger points at the region it controls, and the region is `hidden` + // rather than unmounted, so opening it is a paint, not a fetch. + const controls = html.match(/aria-controls="([^"]+)"/); + expect(controls).not.toBeNull(); + expect(html).toContain(`id="${controls![1]}" hidden=""`); + expect(anatomyRegion(html)).toContain('data-slot="health-score-pillars"'); }); - it("shows personal goal progress as explicitly unscored context", () => { + it("keeps the score face outside the disclosure", () => { + const html = render(); + const face = html.slice( + 0, + html.indexOf('data-slot="health-score-anatomy-toggle"'), + ); + expect(face).toContain('data-slot="health-score-band"'); + expect(face).toContain('data-slot="health-score-delta"'); + expect(face).toContain(">86<"); + // The pillar grid and the method footer belong to the opened region only. + expect(face).not.toContain('data-slot="health-score-pillars"'); + expect(face).not.toContain('data-slot="health-score-method"'); + }); +}); + +describe(" pillars", () => { + it("grids the scored pillars only and never draws a gated one as a cell", () => { + const html = render(); + const region = anatomyRegion(html); + expect(region).toContain("sm:grid-cols-2"); + expect(region).toContain("lg:grid-cols-3"); + expect(region).toContain('data-pillar="BLOOD_PRESSURE" data-status="ok"'); + expect(region).not.toContain('data-pillar="ACTIVITY" data-status="ok"'); + // Blood pressure scores 82, so its impact bar is band-green at 82%. + expect(region).toContain("width:82%"); + expect(region).toContain("bg-success"); + }); + + it("names every gated pillar with its reason instead of scoring it zero", () => { + const html = render(); + const region = anatomyRegion(html); + expect(region).toContain('data-slot="health-score-not-scored"'); + expect(region).toContain("Not scored yet (1)"); + expect(region).toContain('data-reason="below_day_floor_or_stale"'); + expect(region).toContain("Not enough recent eligible data"); + expect(region).not.toContain("below_day_floor_or_stale<"); + }); + + it("treats a failed read as an error with a retry, never as absence", () => { + const retry = vi.fn(); const html = render( - report({ - weightGoal: { - status: "ok", - value: { - currentKg: 81, - target: { min: 74, max: 78 }, - distanceKg: 3, - deltaKg: 1, - asOf: "2026-07-27T08:00:00.000Z", - source: "MANUAL", - }, - coverage: { - requiredInputs: 2, - presentInputs: 2, - historyDays: 1, - missing: [], + , + ); + const region = anatomyRegion(html); + expect(region).toContain('data-slot="health-score-pillar-error"'); + expect(region).toContain('role="alert"'); + expect(region).toContain('data-slot="health-score-pillar-retry"'); + expect(region).toContain("Data could not be loaded"); + // A failed read is NOT absence, so it stays out of the "not scored" list. + expect(region).toContain("Not scored yet (1)"); + expect(region).not.toContain('data-reason="read_failed"'); + }); + + it("leads the gated list with safety signposting", () => { + const html = render( + , + ); + const region = anatomyRegion(html); + expect(region).toContain("Not scored yet (2)"); + const crisis = region.indexOf('data-reason="crisis_signposting"'); + const activity = region.indexOf('data-reason="below_day_floor_or_stale"'); + expect(crisis).toBeGreaterThan(-1); + expect(crisis).toBeLessThan(activity); + expect(region).toContain("Safety guidance is shown instead of a score"); + }); +}); + +describe(" composite states", () => { + it("shows a provisional ring and the learning gate when the composite is gated", () => { + const html = render(); + expect(html).toMatch( + /data-slot="health-score-card"[^>]*data-status="insufficient"/, + ); + expect(html).toContain('data-slot="health-score-insufficient"'); + expect(html).toContain( + "The score needs at least three, including a measured physiological pillar.", + ); + // No band sentence and no fabricated number in the face. + expect(html).not.toContain('data-slot="health-score-band"'); + expect(html).toContain("Method version 2"); + }); + + it("explains a missing comparison where the trend would be", () => { + const html = render(); + expect(html).toContain('data-slot="health-score-delta-reason"'); + expect(html).toContain( + "No comparison because the current window is not eligible.", + ); + expect(html).not.toContain('data-slot="health-score-delta"'); + }); + + it("annotates the number with Rest Mode and the ambient context lines", () => { + const html = render( + , + ); + const face = html.slice( + 0, + html.indexOf('data-slot="health-score-anatomy-toggle"'), + ); + expect(face).toContain("Rest Mode active since 2026-07-20"); + expect(face).toContain('data-slot="health-score-tension"'); + expect(face).toContain('data-slot="health-score-return-to-band"'); + // The contributor key and the metric type are localised, never raw. + expect(face).toContain("Resting heart rate"); + expect(face).not.toContain("RESTING_HEART_RATE"); + expect(face).not.toContain(">rhr<"); + }); + + it("keeps a one-sided tension quiet", () => { + const html = render( + , + ); + expect(html).not.toContain('data-slot="health-score-tension"'); + }); +}); + +describe(" footer", () => { + it("carries the band setter, the version and the cited method", () => { + const html = render(); + const region = anatomyRegion(html); + expect(region).toContain('data-slot="health-score-method"'); + expect(region).toContain("Overall band set by none"); + expect(region).toContain("Method version 2. Weekly comparison floor"); + expect(region).toContain('data-slot="provenance-explainer-method"'); + expect(region).toContain("equal-weighted average"); + }); + + it("shows the personal weight goal as explicitly unscored context", () => { + const html = render( + , + ); + const region = anatomyRegion(html); + expect(region).toContain('data-slot="health-score-weight-goal"'); + // The caption sits on the type scale, never the bespoke 11 px it carried + // while the goal was a standalone card. + expect(region).toContain( + '

Weight-goal progress is personal context and does not affect the Health Score.

', ); - expect(html).toContain('data-slot="health-score-weight-goal"'); - expect(html).toContain('data-status="ok"'); - expect(html).toContain("does not affect the Health Score"); }); it("renders the personal weight goal in the account unit system", () => { mockAuthUser = { unitPreference: "imperial", glucoseUnit: "mg/dL" }; const html = render( - report({ - weightGoal: { - status: "ok", - value: { - currentKg: 81, - target: { min: 74, max: 78 }, - distanceKg: 3, - deltaKg: 1, - asOf: "2026-07-27T08:00:00.000Z", - source: "MANUAL", + , ); expect(html).toContain("178.6 lb now"); expect(html).toContain("Goal 163.1 to 172 lb"); - expect(html).toContain("6.6 lb"); expect(html).not.toContain("81 kg"); }); +}); - it("renders fasting glucose and its reference in the account unit", () => { - mockAuthUser = { unitPreference: "metric", glucoseUnit: "mmol/L" }; - const base = report(); +describe("pillar detail formatting", () => { + it("renders the observed line in the account glucose unit", () => { const html = render( - report({ - pillars: [ - { - id: "GLYCAEMIA", - domain: "cardiometabolic", - result: { - status: "ok", - value: { - score: 80, - observed: { - value: 100, - unit: "mg/dL", - label: "Fasting glucose", - asOf: "2026-07-27T08:00:00.000Z", - sources: ["MANUAL"], - }, - reference: { - kind: "clinical-threshold", - low: 70, - high: 99, - label: "70 to 99 mg/dL fasting", - source: "ADA 2026", - }, - noiseFloor: 1, - deltaEligible: true, - deltaIdentity: "fasting_glucose", - }, - coverage: { - requiredInputs: 1, - presentInputs: 1, - historyDays: 1, - missing: [], - }, - confidence: { score: 100, band: "high" }, - provenance, - }, - }, - ...base.pillars.slice(1), - ], - }), + , ); - expect(html).toContain("Fasting glucose: 5.5 mmol/L"); - expect(html).toContain("3.9 to 5.5 mmol/L fasting"); - expect(html).not.toContain("100 mg/dL"); - expect(html).not.toContain("70 to 99 mg/dL"); + // The card mounts with mmol/L accounts too; the visible observed line is + // the one always on screen. + expect(html).toContain("Observed: Fasting glucose: 100 mg/dL"); + + const mmol = pillarObservedText(FASTING_GLUCOSE, { + t: translator(), + glucoseUnit: "mmol/L", + }); + expect(mmol).toBe("Fasting glucose: 5.5 mmol/L"); }); - it("shows one headline score without algorithm, method, or composition copy", () => { - const html = render( - scoredReport({ - algorithmNotice: { - itemKey: "health_score_algorithm:2", - dismissed: false, + it("converts the reference band behind the popover into the account unit", () => { + const lines = pillarDetailLines(FASTING_GLUCOSE, { + t: translator(), + glucoseUnit: "mmol/L", + }); + expect(lines.join("\n")).toContain("3.9 to 5.5 mmol/L fasting"); + expect(lines.join("\n")).not.toContain("70 to 99 mg/dL"); + }); + + it("keeps the reference, the personal target and the source together", () => { + const lines = pillarDetailLines(BLOOD_PRESSURE, { + t: translator(), + glucoseUnit: "mg/dL", + }); + expect(lines).toHaveLength(3); + expect(lines[0]).toContain("ESH 2023"); + expect(lines[1]).toContain("Your target: 115 to 125/65 to 75 mmHg"); + expect(lines[2]).toContain("Source: MANUAL"); + }); + + it("localises the structured activity labels and references", () => { + const activity: ScorePillarResult = { + id: "ACTIVITY", + domain: "activity", + result: { + status: "ok", + value: { + score: 80, + observed: { + value: 8_000, + unit: "steps/day", + label: "8,000 steps/day", + asOf: "2026-07-27T00:00:00.000Z", + sources: ["APPLE_HEALTH"], + }, + reference: { + kind: "guideline-band", + low: 0, + high: 10_000, + label: "benefit plateaus by 10,000 steps/day", + source: "Paluch 2022", + }, + noiseFloor: 1, + deltaEligible: true, + deltaIdentity: "ACTIVITY_STEPS", }, - }), - ); - - expect(html.match(/>86 { - const base = report(); - const html = render( - report({ - pillars: [ - { - id: "ACTIVITY", - domain: "activity", - result: { - status: "ok", - value: { - score: 80, - observed: { - value: 8_000, - unit: "steps/day", - label: "8,000 steps/day", - asOf: "2026-07-27T00:00:00.000Z", - sources: ["APPLE_HEALTH"], - }, - reference: { - kind: "guideline-band", - low: 0, - high: 10_000, - label: "benefit plateaus by 10,000 steps/day", - source: "Paluch 2022", - }, - noiseFloor: 1, - deltaEligible: true, - deltaIdentity: "ACTIVITY_STEPS", - }, - coverage: { - requiredInputs: 21, - presentInputs: 21, - historyDays: 21, - missing: [], - }, - confidence: { score: 100, band: "high" }, - provenance, - }, - }, - ...base.pillars.slice(1), - ], - }), - "de", - ); - expect(html).toContain("Schritte pro Tag"); - expect(html).toContain("Der Nutzen flacht"); - expect(html).toContain("Leitlinienbereich"); - expect(html).not.toContain("benefit plateaus by"); - expect(html).not.toContain("guideline-band"); +describe("", () => { + it("reserves the collapsed footprint and announces nothing", () => { + const html = render(); + expect(html).toContain('data-slot="health-score-card-skeleton"'); + expect(html).toContain('aria-hidden="true"'); + // The `md` ring is a fixed 168 px box; the reserve matches it exactly. + expect(html).toContain("size-[168px]"); }); +}); + +describe("markAlgorithmNoticeDismissed", () => { it("patches the cached analytics report after the notice is dismissed", () => { const payload = { healthScore: report({ diff --git a/src/components/insights/__tests__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx index c06d080d0..65e3dc1e8 100644 --- a/src/components/insights/__tests__/hero-strip.test.tsx +++ b/src/components/insights/__tests__/hero-strip.test.tsx @@ -3,7 +3,6 @@ import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; import { HeroStrip } from "../hero-strip"; import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema"; -import type { HealthScoreReport } from "@/lib/analytics/score/types"; // The "generated" freshness line reads the profile timezone via `useAuth`; stub // it so the SSR render does not reach for a QueryClient the test omits. @@ -41,54 +40,6 @@ const sampleBriefing: DailyBriefingPayload = { const morningLocal = new Date("2026-05-10T07:00:00Z"); // 09:00 Berlin const afternoonLocal = new Date("2026-05-10T12:00:00Z"); // 14:00 Berlin const eveningLocal = new Date("2026-05-10T18:00:00Z"); // 20:00 Berlin -const SCORE_REPORT: HealthScoreReport = { - composite: { - status: "ok", - value: { - score: 86, - band: "green", - bandSetter: null, - composition: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], - noiseFloor: 3, - scoreVersion: 2, - }, - coverage: { - requiredInputs: 3, - presentInputs: 3, - historyDays: 28, - missing: [], - }, - confidence: { score: 100, band: "high" }, - provenance: { - inputs: ["BLOOD_PRESSURE", "ACTIVITY", "SLEEP"], - source: "live", - windowDays: 90, - computedAt: "2026-05-10T07:00:00.000Z", - }, - }, - pillars: [], - delta: 5, - deltaReason: null, - scoreVersion: 2, - weightGoal: { - status: "insufficient", - coverage: { - requiredInputs: 2, - presentInputs: 0, - historyDays: 0, - missing: ["weight reading", "personal weight target"], - }, - provenance: { - inputs: ["WEIGHT"], - source: "none", - windowDays: 90, - computedAt: "2026-05-10T07:00:00.000Z", - }, - reason: "no_personal_goal", - }, - algorithmNotice: null, -}; - describe("", () => { it("renders the slot wrapper + Dracula gradient utility", () => { const html = render(); @@ -287,126 +238,35 @@ describe("", () => { expect(html).not.toContain('data-slot="insights-hero-strip-weekly-banner"'); }); - // ── B5 — Health Score panel ──────────────────────────────────────── - it("does NOT render the Health Score panel when healthScore is omitted", () => { + // ── B5 — the Health Score left the hero band ────────────────────── + // The score used to mount twice: a compact face in this band and the full + // breakdown below it. The breakdown is now one pinned card under the band + // (`page-client.tsx`), so the hero carries no score surface at all — no + // panel, no reserved column, no skeleton. + it("never renders a Health Score surface in the band", () => { const html = render(); expect(html).not.toContain('data-slot="health-score-card"'); + expect(html).not.toContain('data-slot="health-score-card-compact"'); + expect(html).not.toContain('data-slot="health-score-card-skeleton"'); }); - it("renders the Health Score panel when the score is supplied", () => { - const html = render( - , - ); - expect(html).toMatch(/data-slot="health-score-card-compact"/); - expect(html).toMatch(/data-band="green"/); - expect(html).toContain(">86<"); + it("keeps the greeting on the full width with no reserved second column", () => { + // With the panel gone the band is single-column on every breakpoint; the + // `md:`/`lg:` split + stretch contract it carried for the score column + // must not come back, or the greeting reflows around an empty slot. + const html = render(); + expect(html).not.toContain("md:flex-row"); + expect(html).not.toContain("lg:flex-row"); + expect(html).not.toContain("md:items-stretch"); + expect(html).not.toContain("md:basis-[22rem]"); }); it("does not render any Ask-the-Coach affordance in the hero band", () => { // v1.4.27 F8 — the inline HSC Ask-the-Coach button was retired. // v1.18.7 — the hero action-row coach button was removed too; the - // Coach is the bottom-right drawer, not a hero affordance. Neither - // the score card nor the band surfaces a coach button. - const html = render( - , - ); + // Coach is the bottom-right drawer, not a hero affordance. + const html = render(); expect(html).not.toContain('data-slot="health-score-card-ask-coach"'); expect(html).not.toContain('data-slot="insights-hero-strip-action-coach"'); }); - - it("uses the lg row layout when healthScore is supplied (smoke test on container class)", () => { - const html = render( - , - ); - // The wrapper picks up the `lg:flex-row` modifier when the score - // is present so the panel sits beside the title block on desktop. - expect(html).toContain("lg:flex-row"); - }); - - // ── v1.4.28 R3c-Insights — equal-height contract (FB-H1/H2) ─────── - it("stretches the row's cross-axis when the HealthScore card mounts (md+/lg+)", () => { - // Per Inv-4 the right column painted 75-110 px shorter than the - // left column on desktop. Switching the parent flex row from - // `items-start` to `items-stretch` gives the card a stretched - // shell to grow into. The class is load-bearing for FB-H1/H2 — - // if a future refactor reverts to `items-start` the height gap - // returns. - const html = render( - , - ); - expect(html).toContain("md:items-stretch"); - expect(html).toContain("lg:items-stretch"); - expect(html).not.toContain("md:items-start"); - }); - - it("does NOT stretch the row when healthScore is omitted", () => { - // Pin the negative case so a future refactor can't blanket-apply - // the stretch contract on the no-score layout (no right column - // means nothing to stretch toward). - const html = render(); - expect(html).not.toContain("md:items-stretch"); - }); - - // ── v1.16.8 — score-column reservation while analytics is pending ── - it("reserves the score column with a skeleton while the payload is pending", () => { - const html = render( - , - ); - // The placeholder mirrors the card's column footprint so the band - // paints at its final geometry from the first frame. - expect(html).toContain('data-slot="health-score-card-skeleton"'); - expect(html).toContain("md:basis-[22rem]"); - expect(html).toContain('aria-hidden="true"'); - // The real card stays absent until the payload lands. - expect(html).not.toContain('data-slot="health-score-card" '); - }); - - it("keeps the two-column split active while pending", () => { - const html = render( - , - ); - expect(html).toContain("md:flex-row"); - expect(html).toContain("md:items-stretch"); - }); - - it("drops the skeleton once the payload resolves without a score", () => { - const html = render( - , - ); - expect(html).not.toContain('data-slot="health-score-card-skeleton"'); - expect(html).not.toContain("md:items-stretch"); - }); - - it("renders the real card, never the skeleton, when a score is present", () => { - const html = render( - , - ); - expect(html).toContain('data-slot="health-score-card-compact"'); - expect(html).not.toContain('data-slot="health-score-card-skeleton"'); - }); }); diff --git a/src/components/insights/__tests__/insight-status-card.test.tsx b/src/components/insights/__tests__/insight-status-card.test.tsx index 85ac75a17..b4f723c17 100644 --- a/src/components/insights/__tests__/insight-status-card.test.tsx +++ b/src/components/insights/__tests__/insight-status-card.test.tsx @@ -188,7 +188,7 @@ describe("", () => { expect(html).toContain("py-3"); expect(html).toContain("md:py-4"); } - expect(populated).toContain("gap-1.5"); + expect(populated).toContain("gap-2"); expect(populated).toContain("space-y-1.5"); expect(populated).toContain("py-3"); expect(populated).toContain("md:py-4"); diff --git a/src/components/insights/__tests__/workout-detail.test.tsx b/src/components/insights/__tests__/workout-detail.test.tsx index 9375a2ab1..aa6306131 100644 --- a/src/components/insights/__tests__/workout-detail.test.tsx +++ b/src/components/insights/__tests__/workout-detail.test.tsx @@ -1,5 +1,6 @@ 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 { @@ -9,7 +10,7 @@ import { WorkoutDetailHrSection, WorkoutDetailZones, WorkoutDetailSplits, - WorkoutDetailDayLinks, + WorkoutDetailDayContext, } from "../workout-detail"; import { WorkoutInsightCard } from "../workout-detail/insight-slot"; import type { WorkoutDetailPayload } from "@/hooks/use-workouts"; @@ -28,6 +29,23 @@ function render(node: React.ReactNode) { ); } +/** + * For the one section that reads: the day context queries sleep and mood, + * so it needs a client in the tree. Retries off and no network in this + * environment, so every query stays pending and the section renders its + * loading state — which is exactly what the structural assertions want. + */ +function renderWithQueryClient(node: React.ReactNode) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return renderToStaticMarkup( + + {node} + , + ); +} + function expectSingleSectionHeading(html: string, title: string) { const headings = Array.from( html.matchAll(/]*)?>(.*?)<\/h2>/g), @@ -41,6 +59,7 @@ const FIXTURE: WorkoutDetailPayload = { sportType: "running", startedAt: "2026-05-15T07:00:00Z", endedAt: "2026-05-15T07:30:00Z", + dayKey: "2026-05-15", durationSec: 1800, distanceM: 5200, activeEnergyKcal: 320, @@ -61,6 +80,7 @@ const FIXTURE: WorkoutDetailPayload = { sportContext: null, aiInsight: null, canonicalId: "w-1", + previousWorkoutId: null, }; describe("", () => { @@ -85,6 +105,18 @@ describe("", () => { expect(html).toContain("Pace"); }); + it("links to the previous session only when the server resolved one", () => { + expect(render()).not.toContain( + 'data-slot="workout-detail-previous-link"', + ); + + const html = render( + , + ); + expect(html).toContain('href="/insights/workouts/w-0"'); + expect(html).toContain("Previous session"); + }); + it("omits tiles for null fields", () => { const minimal: WorkoutDetailPayload = { ...FIXTURE, @@ -267,14 +299,42 @@ describe("", () => { }); }); -describe("", () => { - it("renders the that-day navigation links", () => { - const html = render(); +describe("", () => { + it("renders the day's own signals under the that-day heading", () => { + const html = renderWithQueryClient( + , + ); expectSingleSectionHeading(html, "That day"); - expect(html).toContain('data-slot="workout-detail-day-links"'); - expect(html).toContain('href="/insights/pulse"'); + expect(html).toContain('data-slot="workout-detail-day-context"'); + expect(html).toContain('data-slot="workout-detail-day-sleep"'); + expect(html).toContain('data-slot="workout-detail-day-mood"'); + // The section is dated by the heading's meta, not by the links. + expect(html).toContain("Fri, 05/15"); + }); + + it("never reads a pending query as an absent night", () => { + // The queries are disabled here (no session), so nothing has answered. + // "No sleep recorded" would be a claim the section cannot make. + const html = renderWithQueryClient( + , + ); + expect(html).not.toContain("No sleep recorded for that night"); + expect(html).not.toContain("No mood logged that day"); + expect(html).toContain('data-slot="skeleton"'); + }); + + it("labels its outbound links as the undated surfaces they lead to", () => { + const html = renderWithQueryClient( + , + ); expect(html).toContain('href="/insights/sleep"'); - expect(html).toContain('href="/insights/mood"'); + expect(html).toContain("Sleep trends"); + expect(html).toContain('href="/mood"'); + expect(html).toContain("Mood history"); + // The dead "that day" chips are gone: the pulse page cannot take a + // date, so nothing here pretends it can. + expect(html).not.toContain('href="/insights/pulse"'); + expect(html).not.toContain('href="/insights/mood"'); }); }); diff --git a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx index 891d6264c..1ee7ef2c9 100644 --- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx @@ -228,7 +228,7 @@ describe("", () => { expect(html).toContain('data-slot="coach-input-send"'); }); - it("puts the textarea above a split control row on phones", () => { + it("keeps the textarea and its controls on one row at every width", () => { const html = render( ", () => { const controls = html.match( /]*data-slot="coach-input-controls"[^>]*>/, ); - expect(controls?.[0]).toMatch(/\bw-full\b/); - expect(controls?.[0]).toMatch(/\bjustify-between\b/); - expect(controls?.[0]).toMatch(/\bsm:w-auto\b/); - expect(controls?.[0]).not.toMatch(/\bsm:contents\b/); + // The controls sit BESIDE the textarea, not under it. Stacking them made an + // empty composer two rows tall — a text line plus a 44 px tap-target row — + // which is roughly a tenth of a phone screen before anyone has typed. The + // controls must therefore never claim the full width or push themselves + // onto their own line. + expect(controls?.[0]).not.toMatch(/\bw-full\b/); + expect(controls?.[0]).not.toMatch(/\bjustify-between\b/); + expect(controls?.[0]).toMatch(/\bshrink-0\b/); + + // And the row itself is a row at every width, bottom-aligned so the send + // button stays beside the last line as the textarea grows. + const shell = html.match(/]*class="[^"]*\bflex-row\b[^"]*"[^>]*>/); + expect(shell?.[0]).toMatch(/\bitems-end\b/); + expect(shell?.[0]).not.toMatch(/\bflex-col\b/); }); it("sizes the hub actions trigger to the 44px tap-target floor on phones", () => { diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index 123e4f97c..a17d49b7e 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -419,9 +419,17 @@ export function CoachInput({ "border-border/60 bg-muted/40 group rounded-2xl border", "shadow-sm transition-colors", "focus-within:border-primary/50 focus-within:ring-primary/50 focus-within:bg-background focus-within:ring-2", - // Mobile uses two rows. Desktop keeps the same DOM and visual order - // (textarea, actions, Send) so keyboard focus never jumps backwards. - "flex flex-col gap-1.5 p-1.5 sm:flex-row sm:items-end", + // One row at every width. Mobile used to stack the controls under the + // textarea, which made an EMPTY composer two rows tall: a 44 px line + // plus a 44 px touch-target row plus padding, around a tenth of a + // phone screen before anyone had typed anything. The stacking was + // there to protect keyboard focus order, but that argument does not + // hold: focus follows DOM order, and the DOM order (textarea, + // actions, Send) is identical in both layouts. Nothing jumps. + // + // `items-end` keeps the controls beside the LAST line as the textarea + // grows, so the send button stays where the thumb left it. + "flex flex-row items-end gap-1.5 p-1.5", )} >