mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
84 files changed
+6728
-1573
No files matched your search
@@ -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
|
||||
|
||||
+253
-195
@@ -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":"<hex>","v2":"<hex>"} + 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://<host>: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":"<hex>","v2":"<hex>"} + 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://<host>: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:
|
||||
+1649
-3
File diff suppressed because it is too large.
Load diff
@@ -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:<digest>` — 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 <container> --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
|
||||
|
||||
@@ -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}`) {
|
||||
|
||||
@@ -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<HTMLElement>(
|
||||
'[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
|
||||
|
||||
@@ -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;
|
||||
|
||||
+39
-7
@@ -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",
|
||||
|
||||
+39
-7
@@ -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",
|
||||
|
||||
+39
-7
@@ -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",
|
||||
|
||||
+39
-7
@@ -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",
|
||||
|
||||
+39
-7
@@ -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",
|
||||
|
||||
+39
-7
@@ -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",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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/*`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 `<HeroStrip>`).
|
||||
// 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*<HealthScoreCardSkeleton/,
|
||||
);
|
||||
});
|
||||
|
||||
it("hero strip renders the score-card skeleton in the reserved column", () => {
|
||||
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("<HeroStrip");
|
||||
const score = src.indexOf("<HealthScoreCard");
|
||||
const region = src.indexOf("orderedSectionIds.map");
|
||||
expect(hero).toBeGreaterThan(-1);
|
||||
expect(score).toBeGreaterThan(hero);
|
||||
expect(score).toBeLessThan(region);
|
||||
expect(src).not.toMatch(/"health-score":/);
|
||||
const briefingBranch = src.slice(
|
||||
src.indexOf('"daily-briefing": flags.briefing'),
|
||||
src.indexOf("vitals: <VitalsDashboard"),
|
||||
);
|
||||
expect(briefingBranch).not.toContain("HealthScoreCard");
|
||||
});
|
||||
|
||||
it("hero strip carries no score surface at all", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
});
|
||||
@@ -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<string>();
|
||||
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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -306,17 +306,40 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<string, number> = {};
|
||||
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<Response> {
|
||||
inserted,
|
||||
updated,
|
||||
skipped: skipped.length,
|
||||
skipped_by_reason: skippedByReason,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* `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<string, unknown>;
|
||||
const { inserted, updated, skipped } = obj;
|
||||
if (
|
||||
typeof inserted !== "number" ||
|
||||
typeof updated !== "number" ||
|
||||
typeof skipped !== "number"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const skippedByReason: Record<string, number> = {};
|
||||
const rawByReason = obj.skippedByReason;
|
||||
if (rawByReason && typeof rawByReason === "object") {
|
||||
for (const [reason, count] of Object.entries(
|
||||
rawByReason as Record<string, unknown>,
|
||||
)) {
|
||||
if (typeof count === "number") skippedByReason[reason] = count;
|
||||
}
|
||||
}
|
||||
return { inserted, updated, skipped, skippedByReason };
|
||||
}
|
||||
|
||||
function pickTopSkipReason(byReason: Record<string, number>): 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 });
|
||||
});
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
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" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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. `<MicronutrientsCard>` 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 (
|
||||
<SubPageShell
|
||||
title={t("nutrients.page.title")}
|
||||
description={t("nutrients.page.description")}
|
||||
>
|
||||
<EmptyState
|
||||
icon={<Leaf className="size-6" />}
|
||||
title={t("nutrients.page.emptyTitle")}
|
||||
description={t("nutrients.page.emptyDescription")}
|
||||
<QueryErrorCard
|
||||
title={t("nutrients.page.loadError")}
|
||||
onRetry={() => void overview.refetch()}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const hasAnyData = (overview.data?.nutrients.length ?? 0) > 0;
|
||||
if (!overview.isLoading && !hasAnyData) {
|
||||
const lastAttempt = overview.data?.lastAttempt ?? null;
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("nutrients.page.title")}
|
||||
description={t("nutrients.page.description")}
|
||||
>
|
||||
{lastAttempt ? (
|
||||
<EmptyState
|
||||
icon={<Leaf className="size-6" />}
|
||||
title={t("nutrients.page.lastAttemptTitle")}
|
||||
description={
|
||||
<>
|
||||
{t(lastAttemptReasonKey(lastAttempt.topReason))}
|
||||
<span className="mt-1 block text-xs">
|
||||
{t("nutrients.page.lastAttemptAt", {
|
||||
date: fmt.dateTime(lastAttempt.at),
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<Leaf className="size-6" />}
|
||||
title={t("nutrients.page.emptyTitle")}
|
||||
description={t("nutrients.page.emptyDescription")}
|
||||
/>
|
||||
)}
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("nutrients.page.title")}
|
||||
|
||||
@@ -26,7 +26,10 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HeroStrip } from "@/components/insights/hero-strip";
|
||||
import { HealthScoreCard } from "@/components/insights/health-score-card";
|
||||
import {
|
||||
HealthScoreCard,
|
||||
HealthScoreCardSkeleton,
|
||||
} from "@/components/insights/health-score-card";
|
||||
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
@@ -475,41 +478,36 @@ export default function InsightsPageClient() {
|
||||
/>
|
||||
),
|
||||
"daily-briefing": flags.briefing ? (
|
||||
<>
|
||||
<DailyBriefing
|
||||
briefing={briefingPayload}
|
||||
updatedAt={heroStripUpdatedAt}
|
||||
loading={advisor.isLoading}
|
||||
onRegenerate={advisor.regenerate}
|
||||
regenerating={advisor.isRegenerating}
|
||||
// v1.15.20 — the read path reports a 422 (no provider configured)
|
||||
// as its own outcome; swap the futile regenerate CTA for a quiet
|
||||
// Settings → AI hint instead of an eternal "preparing" loop.
|
||||
noProvider={advisor.readOutcome === "no-provider"}
|
||||
// v1.18.9 (#4) — a (stale) cached briefing is shown but no provider
|
||||
// is connected, so it can never refresh. Pair the relative-age
|
||||
// footer with a discreet connect-provider hint. Only fires when a
|
||||
// briefing is actually rendered (the empty-state path owns the
|
||||
// `noProvider` branch above).
|
||||
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
|
||||
// v1.25 — the last generation attempt failed. On a held briefing this
|
||||
// adds a discreet "couldn't refresh — retry" footer hint; on an empty
|
||||
// card it swaps the generic empty state for an honest "couldn't
|
||||
// generate" one. Suppressed when no provider is configured (that hint
|
||||
// owns the surface and a retry would be futile).
|
||||
generationFailed={advisor.generationFailed && advisor.hasProvider}
|
||||
// v1.25.3 — failure class points the empty-state hint at the right
|
||||
// lever (raise the response timeout vs re-check the provider).
|
||||
generationFailureClass={advisor.generationFailureClass}
|
||||
// v1.28.28 (#470) — the grounding gate stripped the last regenerated
|
||||
// briefing; the card explains the omission instead of "no briefing
|
||||
// yet" (which made the regenerate button read as doing nothing).
|
||||
omittedReason={advisor.briefingOmittedReason}
|
||||
/>
|
||||
{analytics?.healthScore ? (
|
||||
<HealthScoreCard report={analytics.healthScore} className="mt-6" />
|
||||
) : null}
|
||||
</>
|
||||
<DailyBriefing
|
||||
briefing={briefingPayload}
|
||||
updatedAt={heroStripUpdatedAt}
|
||||
loading={advisor.isLoading}
|
||||
onRegenerate={advisor.regenerate}
|
||||
regenerating={advisor.isRegenerating}
|
||||
// v1.15.20 — the read path reports a 422 (no provider configured)
|
||||
// as its own outcome; swap the futile regenerate CTA for a quiet
|
||||
// Settings → AI hint instead of an eternal "preparing" loop.
|
||||
noProvider={advisor.readOutcome === "no-provider"}
|
||||
// v1.18.9 (#4) — a (stale) cached briefing is shown but no provider
|
||||
// is connected, so it can never refresh. Pair the relative-age
|
||||
// footer with a discreet connect-provider hint. Only fires when a
|
||||
// briefing is actually rendered (the empty-state path owns the
|
||||
// `noProvider` branch above).
|
||||
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
|
||||
// v1.25 — the last generation attempt failed. On a held briefing this
|
||||
// adds a discreet "couldn't refresh — retry" footer hint; on an empty
|
||||
// card it swaps the generic empty state for an honest "couldn't
|
||||
// generate" one. Suppressed when no provider is configured (that hint
|
||||
// owns the surface and a retry would be futile).
|
||||
generationFailed={advisor.generationFailed && advisor.hasProvider}
|
||||
// v1.25.3 — failure class points the empty-state hint at the right
|
||||
// lever (raise the response timeout vs re-check the provider).
|
||||
generationFailureClass={advisor.generationFailureClass}
|
||||
// v1.28.28 (#470) — the grounding gate stripped the last regenerated
|
||||
// briefing; the card explains the omission instead of "no briefing
|
||||
// yet" (which made the regenerate button read as doing nothing).
|
||||
omittedReason={advisor.briefingOmittedReason}
|
||||
/>
|
||||
) : null,
|
||||
vitals: <VitalsDashboard batch={dashboardDerived} layout={layout} />,
|
||||
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
|
||||
// <relative>" 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 ? (
|
||||
<HealthScoreCardSkeleton />
|
||||
) : analytics?.healthScore ? (
|
||||
<HealthScoreCard
|
||||
report={analytics.healthScore}
|
||||
// v1.21.2 (A5 / A6) — server-resolved Tension Verdict + return-to-
|
||||
// baseline off the dashboard snapshot. The card localises the keys;
|
||||
// null leaves it quiet.
|
||||
tension={heroTension}
|
||||
returnToBand={heroReturnToBand}
|
||||
// A pillar whose read FAILED is an error row inside the card, and a
|
||||
// retry there refetches the payload that carries every pillar.
|
||||
onRetry={() => 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
|
||||
|
||||
@@ -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 (
|
||||
<SubPageShell
|
||||
title={t("insights.workouts.title")}
|
||||
description={t("insights.workouts.description")}
|
||||
// No description. The one this page used to carry belongs to the LIST
|
||||
// ("Recent runs, rides, walks…, deduped…") and describes a surface
|
||||
// this is not — a detail page must not claim to be the list.
|
||||
backLink={
|
||||
<BackLink
|
||||
href="/insights/workouts"
|
||||
@@ -112,7 +119,7 @@ export default function InsightsWorkoutDetailPage({
|
||||
<WorkoutDetailZones workout={data} />
|
||||
<WorkoutDetailRoute workout={data} />
|
||||
<WorkoutDetailSplits workout={data} />
|
||||
<WorkoutDetailDayLinks workout={data} />
|
||||
<WorkoutDetailDayContext workout={data} />
|
||||
{/* 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
|
||||
|
||||
@@ -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<string | undefined>(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() {
|
||||
<Skeleton className="h-14 w-full rounded-lg" />
|
||||
<Skeleton className="h-14 w-full rounded-lg" />
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
) : isEmpty && sportType === undefined ? (
|
||||
<MetricEmptyState
|
||||
icon={<Activity className="size-6" />}
|
||||
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 ? (
|
||||
<WorkoutList
|
||||
workouts={workouts}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isFetchNextPageError={isFetchNextPageError}
|
||||
onLoadMore={fetchNextPage}
|
||||
/>
|
||||
) : null}
|
||||
) : (
|
||||
<>
|
||||
{sportOptions.length > 1 ? (
|
||||
<div
|
||||
data-slot="workout-sport-filter"
|
||||
role="group"
|
||||
aria-label={t("insights.workouts.filterLabel")}
|
||||
// Borderless page-level blocks track the shell heading's own
|
||||
// card inset, so the row, the count line and the h1 above them
|
||||
// share one left edge.
|
||||
className="flex flex-wrap gap-2 px-4 md:px-6"
|
||||
>
|
||||
{[undefined, ...sportOptions].map((sport) => {
|
||||
const active = sport === sportType;
|
||||
return (
|
||||
<button
|
||||
key={sport ?? "all"}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => setSportType(sport)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring inline-flex min-h-11 items-center rounded-full px-3 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none sm:min-h-8",
|
||||
active
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{sport === undefined
|
||||
? t("insights.workouts.filterAll")
|
||||
: sportLabel(sport)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{workouts.length > 0 ? (
|
||||
<>
|
||||
<p
|
||||
data-slot="workout-list-count"
|
||||
className="text-muted-foreground px-4 text-xs md:px-6"
|
||||
>
|
||||
{tCount("insights.workouts.count", total)}
|
||||
</p>
|
||||
<WorkoutList
|
||||
workouts={workouts}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isFetchNextPageError={isFetchNextPageError}
|
||||
onLoadMore={fetchNextPage}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p
|
||||
data-slot="workout-list-filter-empty"
|
||||
className="text-muted-foreground px-4 text-sm md:px-6"
|
||||
>
|
||||
{t("insights.workouts.filterEmpty")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
@@ -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 `<MicronutrientsCard>` 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 `<MicronutrientsCard>`'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(<InsightsNutrientsPage />, 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(<InsightsNutrientsPage />, 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(<InsightsNutrientsPage />, 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(<InsightsNutrientsPage />, 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.
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<HealthScoreCard report={report} />
|
||||
<Probe />
|
||||
</I18nProvider>,
|
||||
);
|
||||
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> = {}): HealthScoreReport {
|
||||
@@ -48,65 +194,7 @@ function report(overrides: Partial<HealthScoreReport> = {}): 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("<HealthScoreCard>", () => {
|
||||
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("<HealthScoreCard> disclosure", () => {
|
||||
it("opens collapsed: the breakdown is present but hidden and out of tab order", () => {
|
||||
const html = render(<HealthScoreCard report={scoredReport()} />);
|
||||
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(<HealthScoreCard report={scoredReport()} />);
|
||||
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("<HealthScoreCard> pillars", () => {
|
||||
it("grids the scored pillars only and never draws a gated one as a cell", () => {
|
||||
const html = render(<HealthScoreCard report={scoredReport()} />);
|
||||
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(<HealthScoreCard report={scoredReport()} />);
|
||||
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: [],
|
||||
<HealthScoreCard
|
||||
report={scoredReport({
|
||||
pillars: [
|
||||
BLOOD_PRESSURE,
|
||||
gatedPillar("SLEEP", "read_failed"),
|
||||
ACTIVITY_BELOW_FLOOR,
|
||||
],
|
||||
})}
|
||||
onRetry={retry}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<HealthScoreCard
|
||||
report={scoredReport({
|
||||
pillars: [
|
||||
BLOOD_PRESSURE,
|
||||
ACTIVITY_BELOW_FLOOR,
|
||||
gatedPillar("WELLBEING", "crisis_signposting"),
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
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("<HealthScoreCard> composite states", () => {
|
||||
it("shows a provisional ring and the learning gate when the composite is gated", () => {
|
||||
const html = render(<HealthScoreCard report={report()} />);
|
||||
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(<HealthScoreCard report={report()} />);
|
||||
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(
|
||||
<HealthScoreCard
|
||||
report={scoredReport({
|
||||
restMode: { active: true, since: "2026-07-20", episodeCount: 1 },
|
||||
})}
|
||||
tension={{ band: "yellow", positive: ["sleep"], negative: ["rhr"] }}
|
||||
returnToBand={{ metricType: "RESTING_HEART_RATE", daysInside: 6 }}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<HealthScoreCard
|
||||
report={scoredReport()}
|
||||
tension={{ band: "yellow", positive: ["sleep"], negative: [] }}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="health-score-tension"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<HealthScoreCard> footer", () => {
|
||||
it("carries the band setter, the version and the cited method", () => {
|
||||
const html = render(<HealthScoreCard report={scoredReport()} />);
|
||||
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(
|
||||
<HealthScoreCard
|
||||
report={scoredReport({
|
||||
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: [],
|
||||
},
|
||||
confidence: { score: 100, band: "high" },
|
||||
provenance,
|
||||
},
|
||||
confidence: { score: 100, band: "high" },
|
||||
provenance,
|
||||
},
|
||||
}),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
'<p class="text-muted-foreground mt-2 text-xs">Weight-goal progress is personal context and does not affect the Health Score.</p>',
|
||||
);
|
||||
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",
|
||||
<HealthScoreCard
|
||||
report={scoredReport({
|
||||
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: [],
|
||||
},
|
||||
confidence: { score: 100, band: "high" },
|
||||
provenance,
|
||||
},
|
||||
coverage: {
|
||||
requiredInputs: 2,
|
||||
presentInputs: 2,
|
||||
historyDays: 1,
|
||||
missing: [],
|
||||
},
|
||||
confidence: { score: 100, band: "high" },
|
||||
provenance,
|
||||
},
|
||||
}),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
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),
|
||||
],
|
||||
}),
|
||||
<HealthScoreCard report={scoredReport({ pillars: [FASTING_GLUCOSE] })} />,
|
||||
);
|
||||
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</g)).toHaveLength(1);
|
||||
expect(html).not.toContain('data-slot="health-score-algorithm-notice"');
|
||||
expect(html).not.toContain("cardiometabolic reference ranges");
|
||||
expect(html).not.toContain("Equal-weight average of eligible pillars");
|
||||
expect(html).not.toContain("The lowest pillar sets the overall band");
|
||||
expect(html).not.toContain("Included:");
|
||||
|
||||
// Presentation cleanup must not sever score identity or source detail.
|
||||
expect(html).toContain("Method version 2");
|
||||
expect(html).toContain('data-slot="health-score-pillars"');
|
||||
expect(html).toContain("ESH 2023");
|
||||
expect(html).toContain('data-slot="health-score-delta"');
|
||||
coverage: {
|
||||
requiredInputs: 21,
|
||||
presentInputs: 21,
|
||||
historyDays: 21,
|
||||
missing: [],
|
||||
},
|
||||
confidence: { score: 100, band: "high" },
|
||||
provenance,
|
||||
},
|
||||
};
|
||||
const de = { t: translator("de"), glucoseUnit: "mg/dL" as const };
|
||||
expect(pillarObservedText(activity, de)).toContain("Schritte pro Tag");
|
||||
const lines = pillarDetailLines(activity, de).join("\n");
|
||||
expect(lines).toContain("Der Nutzen flacht");
|
||||
expect(lines).toContain("Leitlinienbereich");
|
||||
expect(lines).not.toContain("benefit plateaus by");
|
||||
expect(lines).not.toContain("guideline-band");
|
||||
});
|
||||
});
|
||||
|
||||
it("localises structured activity labels, references, and reasons", () => {
|
||||
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("<HealthScoreCardSkeleton>", () => {
|
||||
it("reserves the collapsed footprint and announces nothing", () => {
|
||||
const html = render(<HealthScoreCardSkeleton />);
|
||||
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({
|
||||
|
||||
@@ -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("<HeroStrip>", () => {
|
||||
it("renders the slot wrapper + Dracula gradient utility", () => {
|
||||
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
@@ -287,126 +238,35 @@ describe("<HeroStrip>", () => {
|
||||
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(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
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(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
healthScore={SCORE_REPORT}
|
||||
/>,
|
||||
);
|
||||
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(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
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(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
healthScore={SCORE_REPORT}
|
||||
/>,
|
||||
);
|
||||
// Coach is the bottom-right drawer, not a hero affordance.
|
||||
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
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(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
healthScore={SCORE_REPORT}
|
||||
/>,
|
||||
);
|
||||
// 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(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
healthScore={SCORE_REPORT}
|
||||
/>,
|
||||
);
|
||||
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(<HeroStrip briefing={null} now={morningLocal} />);
|
||||
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(
|
||||
<HeroStrip briefing={null} now={morningLocal} healthScorePending />,
|
||||
);
|
||||
// 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(
|
||||
<HeroStrip briefing={null} now={morningLocal} healthScorePending />,
|
||||
);
|
||||
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(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
healthScorePending={false}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={morningLocal}
|
||||
healthScorePending
|
||||
healthScore={SCORE_REPORT}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="health-score-card-compact"');
|
||||
expect(html).not.toContain('data-slot="health-score-card-skeleton"');
|
||||
});
|
||||
});
|
||||
@@ -188,7 +188,7 @@ describe("<InsightStatusCard>", () => {
|
||||
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");
|
||||
|
||||
@@ -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(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function expectSingleSectionHeading(html: string, title: string) {
|
||||
const headings = Array.from(
|
||||
html.matchAll(/<h2(?:\s[^>]*)?>(.*?)<\/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("<WorkoutDetailHeader>", () => {
|
||||
@@ -85,6 +105,18 @@ describe("<WorkoutDetailStats>", () => {
|
||||
expect(html).toContain("Pace");
|
||||
});
|
||||
|
||||
it("links to the previous session only when the server resolved one", () => {
|
||||
expect(render(<WorkoutDetailStats workout={FIXTURE} />)).not.toContain(
|
||||
'data-slot="workout-detail-previous-link"',
|
||||
);
|
||||
|
||||
const html = render(
|
||||
<WorkoutDetailStats workout={{ ...FIXTURE, previousWorkoutId: "w-0" }} />,
|
||||
);
|
||||
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("<WorkoutDetailSplits>", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("<WorkoutDetailDayLinks>", () => {
|
||||
it("renders the that-day navigation links", () => {
|
||||
const html = render(<WorkoutDetailDayLinks workout={FIXTURE} />);
|
||||
describe("<WorkoutDetailDayContext>", () => {
|
||||
it("renders the day's own signals under the that-day heading", () => {
|
||||
const html = renderWithQueryClient(
|
||||
<WorkoutDetailDayContext workout={FIXTURE} />,
|
||||
);
|
||||
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(
|
||||
<WorkoutDetailDayContext workout={FIXTURE} />,
|
||||
);
|
||||
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(
|
||||
<WorkoutDetailDayContext workout={FIXTURE} />,
|
||||
);
|
||||
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"');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -228,7 +228,7 @@ describe("<CoachInput>", () => {
|
||||
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(
|
||||
<CoachInput
|
||||
value=""
|
||||
@@ -258,10 +258,20 @@ describe("<CoachInput>", () => {
|
||||
const controls = html.match(
|
||||
/<div[^>]*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(/<div[^>]*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", () => {
|
||||
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
@@ -447,7 +455,7 @@ export function CoachInput({
|
||||
/>
|
||||
<div
|
||||
data-slot="coach-input-controls"
|
||||
className="order-2 flex w-full items-center justify-between gap-1.5 sm:w-auto sm:shrink-0 sm:justify-start"
|
||||
className="order-2 flex shrink-0 items-center gap-1.5"
|
||||
>
|
||||
<div
|
||||
data-slot="coach-input-leading"
|
||||
|
||||
@@ -1,20 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { ScoreAnatomyView } from "@/components/insights/derived/score-anatomy-view";
|
||||
import { useId, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { InfoPopover } from "@/components/ui/info-popover";
|
||||
import { LearningGate } from "@/components/ui/learning-gate";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import {
|
||||
BAND_PROGRESS_CLASS,
|
||||
bandForScore,
|
||||
clampScore,
|
||||
} from "@/components/insights/derived/band-tokens";
|
||||
import { CoverageMeter } from "@/components/insights/derived/coverage-meter";
|
||||
import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer";
|
||||
import { ScoreRing } from "@/components/insights/derived/score-ring";
|
||||
import { METRIC_PROVENANCE } from "@/components/insights/derived/standards";
|
||||
import {
|
||||
pillarDetailLines,
|
||||
pillarObservedText,
|
||||
type PillarDetailContext,
|
||||
} from "@/components/insights/health-score-pillar-detail";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import { resolveGlucoseUnit } from "@/lib/glucose";
|
||||
import type {
|
||||
HealthScoreReport,
|
||||
PillarReference,
|
||||
ScorePillarId,
|
||||
ScoreBand,
|
||||
ScorePillarResult,
|
||||
} from "@/lib/analytics/score/types";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ScoreRing } from "@/components/insights/derived/score-ring";
|
||||
|
||||
/**
|
||||
* The Health Score surface: one card, one score, one place.
|
||||
*
|
||||
* Pre-rework the score had two mounts — a compact face in the hero band and a
|
||||
* nine-block stack further down the overview that only rendered while the
|
||||
* daily-briefing flag was on. The stack is now a single progressive-disclosure
|
||||
* card pinned directly under the hero, and the hero band is the full-width
|
||||
* greeting again.
|
||||
*
|
||||
* Collapsed reads as the score face: the ring, the band sentence, the weekly
|
||||
* delta (or the honest reason there is none), Rest Mode, the two ambient
|
||||
* context lines, and the coverage meter. Expanded adds the pillar grid, the
|
||||
* named list of pillars that are not scored yet, the personal weight goal, and
|
||||
* the method footer with its cited standard.
|
||||
*
|
||||
* Absence stays absence: a pillar the server gated as `insufficient` is listed
|
||||
* by name with its reason instead of being drawn as a zero, and a pillar whose
|
||||
* read FAILED is a visible error row with a retry, never silence.
|
||||
*/
|
||||
|
||||
const PILLAR_LABEL_KEY: Record<ScorePillarId, string> = {
|
||||
BLOOD_PRESSURE: "insights.healthScore.pillar.bloodPressure",
|
||||
@@ -43,6 +82,41 @@ const REASON_KEY: Record<string, string> = {
|
||||
stale: "insufficientHistory",
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.21.2 (A5) — readiness contributor keys the Tension Verdict surfaces.
|
||||
* Kept as a local string union so the client never imports the server
|
||||
* derived-engine types. Localised through the existing
|
||||
* `insights.derived.composite.READINESS.component.{key}` labels.
|
||||
*/
|
||||
export type ReadinessContributorKey =
|
||||
"rhr" | "hrv" | "sleep" | "respiratory" | "mood";
|
||||
|
||||
/**
|
||||
* v1.21.2 (A5) — readiness contributor key → existing localised label key.
|
||||
* Reuses the score-anatomy contributor labels so the Tension Verdict reads the
|
||||
* same names the wellness ring's anatomy view renders — no new keys.
|
||||
*/
|
||||
const TENSION_CONTRIBUTOR_LABEL_KEY: Record<ReadinessContributorKey, string> = {
|
||||
rhr: "insights.derived.composite.READINESS.component.rhr",
|
||||
hrv: "insights.derived.composite.READINESS.component.hrv",
|
||||
sleep: "insights.derived.composite.READINESS.component.sleep",
|
||||
respiratory: "insights.derived.composite.READINESS.component.respiratory",
|
||||
mood: "insights.derived.composite.READINESS.component.mood",
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.21.2 (A6) — `MeasurementType` → existing localised metric-name key
|
||||
* (`measurements.type*`). Only the salient deviation vitals the
|
||||
* return-to-baseline detector scans need an entry; an unmapped type falls back
|
||||
* to a prettified raw form so a new type never blanks.
|
||||
*/
|
||||
const RETURN_METRIC_LABEL_KEY: Record<string, string> = {
|
||||
RESTING_HEART_RATE: "measurements.typeRestingHeartRate",
|
||||
HEART_RATE_VARIABILITY: "measurements.typeHeartRateVariability",
|
||||
RESPIRATORY_RATE: "measurements.typeRespiratoryRate",
|
||||
WEIGHT: "measurements.typeWeight",
|
||||
};
|
||||
|
||||
export function markAlgorithmNoticeDismissed(
|
||||
payload: unknown,
|
||||
itemKey: string,
|
||||
@@ -76,381 +150,407 @@ export function markAlgorithmNoticeDismissed(
|
||||
};
|
||||
}
|
||||
|
||||
/** The reason a pillar was gated, or null when it carries a score. */
|
||||
function gateReason(pillar: ScorePillarResult): string | null {
|
||||
return pillar.result.status === "insufficient" ? pillar.result.reason : null;
|
||||
}
|
||||
|
||||
export interface HealthScoreCardProps {
|
||||
report: HealthScoreReport;
|
||||
/**
|
||||
* v1.21.2 (A5) — Tension Verdict, server-resolved + locale-agnostic. `band`
|
||||
* is the readiness composite's band; `positive` / `negative` carry the
|
||||
* readiness contributor KEYS. Null on a coherent day (or under a clinical
|
||||
* red-flag suppress).
|
||||
*/
|
||||
tension?: {
|
||||
band: ScoreBand;
|
||||
positive: string[];
|
||||
negative: string[];
|
||||
positive: ReadinessContributorKey[];
|
||||
negative: ReadinessContributorKey[];
|
||||
} | null;
|
||||
returnToBand?: { metricLabel: string; daysInside: number } | null;
|
||||
/** Keep only the score face when the card is used above the fold. */
|
||||
compact?: boolean;
|
||||
/**
|
||||
* v1.21.2 (A6) — return-to-baseline, server-resolved + locale-agnostic.
|
||||
* `metricType` is a `MeasurementType` this card maps to its localised metric
|
||||
* name. Null when no salient metric returned from a genuine prior
|
||||
* out-of-band run.
|
||||
*/
|
||||
returnToBand?: { metricType: string; daysInside: number } | null;
|
||||
/** Retry for a pillar whose read failed — wired to the analytics refetch. */
|
||||
onRetry?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function HealthScoreCard({
|
||||
report,
|
||||
tension,
|
||||
returnToBand,
|
||||
compact = false,
|
||||
tension = null,
|
||||
returnToBand = null,
|
||||
onRetry,
|
||||
className,
|
||||
}: HealthScoreCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit);
|
||||
const [open, setOpen] = useState(false);
|
||||
const regionId = useId();
|
||||
|
||||
const detail: PillarDetailContext = {
|
||||
t,
|
||||
glucoseUnit: resolveGlucoseUnit(user?.glucoseUnit),
|
||||
};
|
||||
|
||||
function reasonText(reason: string): string {
|
||||
const key = REASON_KEY[reason] ?? "unavailable";
|
||||
return t(`insights.healthScore.reason.${key}`);
|
||||
}
|
||||
|
||||
function referenceKindText(reference: PillarReference): string {
|
||||
return t(
|
||||
`insights.healthScore.referenceKind.${reference.kind === "clinical-threshold" ? "clinical" : reference.kind === "population-percentile" ? "population" : "guideline"}`,
|
||||
);
|
||||
}
|
||||
|
||||
function formatReference(
|
||||
reference: PillarReference,
|
||||
label: string,
|
||||
source = reference.source,
|
||||
): string {
|
||||
return t("insights.healthScore.referenceDetail", {
|
||||
label,
|
||||
source,
|
||||
kind: referenceKindText(reference),
|
||||
low: reference.low ?? t("insights.healthScore.openBound"),
|
||||
high: reference.high ?? t("insights.healthScore.openBound"),
|
||||
});
|
||||
}
|
||||
|
||||
function observedText(pillar: ScorePillarResult): string {
|
||||
if (pillar.result.status !== "ok") return "";
|
||||
const observed = pillar.result.value.observed;
|
||||
if (pillar.id === "GLYCAEMIA" && observed.unit === "mg/dL") {
|
||||
return t("insights.healthScore.fastingObserved", {
|
||||
value: convertGlucose(observed.value, glucoseUnit),
|
||||
unit: glucoseUnit,
|
||||
});
|
||||
}
|
||||
if (pillar.id === "ACTIVITY") {
|
||||
return t("insights.healthScore.observedActivity", {
|
||||
value: observed.value,
|
||||
});
|
||||
}
|
||||
if (pillar.id === "SLEEP") {
|
||||
return t("insights.healthScore.observedSleep", {
|
||||
value: observed.value,
|
||||
});
|
||||
}
|
||||
if (pillar.id === "ADIPOSITY") {
|
||||
return t("insights.healthScore.observedAdiposity", {
|
||||
value: observed.value,
|
||||
});
|
||||
}
|
||||
if (pillar.id === "FITNESS") {
|
||||
return t("insights.healthScore.observedFitness", {
|
||||
value: observed.value,
|
||||
unit: observed.unit,
|
||||
});
|
||||
}
|
||||
return observed.label;
|
||||
}
|
||||
|
||||
function scoredReferenceText(pillar: ScorePillarResult): string {
|
||||
if (pillar.result.status !== "ok") return "";
|
||||
const value = pillar.result.value;
|
||||
const reference = value.reference;
|
||||
if (pillar.id === "GLYCAEMIA" && value.observed.unit === "mg/dL") {
|
||||
const low =
|
||||
reference.low == null
|
||||
? null
|
||||
: convertGlucose(reference.low, glucoseUnit);
|
||||
const high =
|
||||
reference.high == null
|
||||
? null
|
||||
: convertGlucose(reference.high, glucoseUnit);
|
||||
const converted = { ...reference, low, high };
|
||||
return formatReference(
|
||||
converted,
|
||||
t("insights.healthScore.fastingReference", {
|
||||
low: low ?? "",
|
||||
high: high ?? "",
|
||||
unit: glucoseUnit,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const label =
|
||||
pillar.id === "ACTIVITY"
|
||||
? t("insights.healthScore.referenceActivity", {
|
||||
high: reference.high ?? "",
|
||||
})
|
||||
: pillar.id === "SLEEP"
|
||||
? t("insights.healthScore.referenceSleep", {
|
||||
low: reference.low ?? "",
|
||||
})
|
||||
: pillar.id === "ADIPOSITY"
|
||||
? t("insights.healthScore.referenceAdiposity", {
|
||||
high: reference.high ?? "",
|
||||
})
|
||||
: pillar.id === "FITNESS"
|
||||
? t("insights.healthScore.referenceFitness", {
|
||||
low: reference.low ?? "",
|
||||
high: reference.high ?? "",
|
||||
unit: value.observed.unit,
|
||||
})
|
||||
: pillar.id === "GLYCAEMIA"
|
||||
? t("insights.healthScore.referenceHba1c", {
|
||||
low: reference.low ?? "",
|
||||
high: reference.high ?? "",
|
||||
})
|
||||
: reference.label;
|
||||
return formatReference(
|
||||
reference,
|
||||
label,
|
||||
pillar.id === "LIPIDS"
|
||||
? t("insights.healthScore.laboratoryReferenceSource")
|
||||
: reference.source,
|
||||
);
|
||||
}
|
||||
const composite = report.composite;
|
||||
const eligibleCount = report.pillars.filter(
|
||||
(pillar) => pillar.result.status === "ok",
|
||||
).length;
|
||||
const contributors = report.pillars.map((pillar) => ({
|
||||
key: pillar.id,
|
||||
label: t(PILLAR_LABEL_KEY[pillar.id]),
|
||||
value: pillar.result.status === "ok" ? pillar.result.value.score : null,
|
||||
weight:
|
||||
pillar.result.status === "ok" && eligibleCount > 0
|
||||
? 1 / eligibleCount
|
||||
: 0,
|
||||
}));
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
data-slot="health-score-card-compact"
|
||||
className={cn(
|
||||
"border-border/70 bg-card/80 flex min-w-0 flex-col items-center justify-center gap-2 rounded-xl border p-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ScoreRing
|
||||
score={composite.status === "ok" ? composite.value.score : null}
|
||||
band={composite.status === "ok" ? composite.value.band : undefined}
|
||||
size="md"
|
||||
flat
|
||||
label={t("insights.healthScore.label")}
|
||||
/>
|
||||
{composite.status === "ok" ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(`insights.healthScore.band.${composite.value.band}`)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.healthScore.insufficient", {
|
||||
const scored = report.pillars.filter((p) => p.result.status === "ok");
|
||||
const readFailed = report.pillars.filter(
|
||||
(p) => gateReason(p) === "read_failed",
|
||||
);
|
||||
// A failed read is not absence, so it never joins the "not scored yet" list;
|
||||
// it gets its own error row above. Safety copy leads the list — signposting
|
||||
// is content, never buried meta.
|
||||
const notScored = report.pillars
|
||||
.filter((p) => {
|
||||
const reason = gateReason(p);
|
||||
return reason !== null && reason !== "read_failed";
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(gateReason(b) === "crisis_signposting") -
|
||||
Number(gateReason(a) === "crisis_signposting"),
|
||||
);
|
||||
|
||||
// Only forwarded when a real disagreement is present on BOTH sides; the
|
||||
// server resolver already enforces that, but the guard keeps the card from
|
||||
// rendering a one-sided line.
|
||||
const tensionLine =
|
||||
tension && tension.positive.length > 0 && tension.negative.length > 0
|
||||
? t("insights.healthScore.tension", {
|
||||
positive: tension.positive
|
||||
.map((k) => t(TENSION_CONTRIBUTOR_LABEL_KEY[k]))
|
||||
.join(", "),
|
||||
negative: tension.negative
|
||||
.map((k) => t(TENSION_CONTRIBUTOR_LABEL_KEY[k]))
|
||||
.join(", "),
|
||||
})
|
||||
: null;
|
||||
|
||||
const returnToBandLine = returnToBand
|
||||
? t("insights.healthScore.returnToBand", {
|
||||
metric: RETURN_METRIC_LABEL_KEY[returnToBand.metricType]
|
||||
? t(RETURN_METRIC_LABEL_KEY[returnToBand.metricType])
|
||||
: returnToBand.metricType.replace(/_/g, " ").toLowerCase(),
|
||||
days: returnToBand.daysInside,
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
data-slot="health-score-card"
|
||||
data-status={composite.status}
|
||||
className={cn("animate-insight-in", className)}
|
||||
>
|
||||
<CardHeader>
|
||||
<TileHeader title={t("insights.healthScore.label")} titleAs="h2" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* The score face. The ring keeps its own identity block; everything
|
||||
that qualifies the number sits beside it, so a person reads the
|
||||
score and the reason for it in one pass. */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="flex justify-center sm:justify-start">
|
||||
<ScoreRing
|
||||
score={composite.status === "ok" ? composite.value.score : null}
|
||||
band={
|
||||
composite.status === "ok" ? composite.value.band : undefined
|
||||
}
|
||||
size="md"
|
||||
label={t("insights.derived.anatomy.outOf")}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
{composite.status === "ok" ? (
|
||||
<p
|
||||
data-slot="health-score-band"
|
||||
className="text-foreground text-sm"
|
||||
>
|
||||
{t(`insights.healthScore.band.${composite.value.band}`)}
|
||||
</p>
|
||||
) : null}
|
||||
{report.delta != null ? (
|
||||
<p
|
||||
data-slot="health-score-delta"
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
>
|
||||
{t("insights.healthScore.delta", {
|
||||
delta: report.delta > 0 ? `+${report.delta}` : report.delta,
|
||||
})}
|
||||
</p>
|
||||
) : report.deltaReason ? (
|
||||
<p
|
||||
data-slot="health-score-delta-reason"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t(`insights.healthScore.deltaReason.${report.deltaReason}`)}
|
||||
</p>
|
||||
) : null}
|
||||
{report.restMode?.active ? (
|
||||
<p
|
||||
data-slot="health-score-rest-mode"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("insights.healthScore.restMode", {
|
||||
since:
|
||||
report.restMode.since ?? t("insights.healthScore.none"),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{tensionLine ? (
|
||||
<p
|
||||
data-slot="health-score-tension"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{tensionLine}
|
||||
</p>
|
||||
) : null}
|
||||
{returnToBandLine ? (
|
||||
<p
|
||||
data-slot="health-score-return-to-band"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{returnToBandLine}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{composite.status !== "ok" ? (
|
||||
<LearningGate
|
||||
variant="bordered"
|
||||
bodySlot="health-score-insufficient"
|
||||
message={t("insights.healthScore.insufficient", {
|
||||
count: composite.coverage.presentInputs,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
data-slot="health-score-card"
|
||||
className={cn("flex min-w-0 flex-col gap-2", className)}
|
||||
>
|
||||
<ScoreAnatomyView
|
||||
title={t("insights.healthScore.label")}
|
||||
score={composite.status === "ok" ? composite.value.score : null}
|
||||
band={composite.status === "ok" ? composite.value.band : undefined}
|
||||
caption={
|
||||
composite.status === "ok"
|
||||
? t(`insights.healthScore.band.${composite.value.band}`)
|
||||
: undefined
|
||||
}
|
||||
contributors={contributors}
|
||||
coverage={composite.coverage}
|
||||
confidence={composite.status === "ok" ? composite.confidence : null}
|
||||
provenance={composite.provenance}
|
||||
method={null}
|
||||
insufficient={composite.status !== "ok"}
|
||||
insufficientNote={
|
||||
composite.status === "insufficient"
|
||||
? t("insights.healthScore.insufficient", {
|
||||
count: composite.coverage.presentInputs,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
showProvisionalRing={false}
|
||||
className="border-border/70 bg-card/80"
|
||||
/>
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{composite.status === "ok" ||
|
||||
report.deltaReason ||
|
||||
report.restMode?.active ? (
|
||||
<div className="border-border/70 bg-card/80 space-y-2 rounded-xl border p-4">
|
||||
{composite.status === "ok" ? (
|
||||
<>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.healthScore.bandSetter", {
|
||||
pillar: composite.value.bandSetter
|
||||
? t(PILLAR_LABEL_KEY[composite.value.bandSetter])
|
||||
: t("insights.healthScore.none"),
|
||||
})}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.healthScore.versionAndNoise", {
|
||||
version: report.scoreVersion,
|
||||
noise: composite.value.noiseFloor,
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.healthScore.methodVersion", {
|
||||
version: report.scoreVersion,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{report.delta != null ? (
|
||||
<p
|
||||
data-slot="health-score-delta"
|
||||
className="text-muted-foreground text-xs tabular-nums"
|
||||
>
|
||||
{t("insights.healthScore.delta", {
|
||||
delta: report.delta > 0 ? `+${report.delta}` : report.delta,
|
||||
})}
|
||||
</p>
|
||||
) : report.deltaReason ? (
|
||||
<p
|
||||
data-slot="health-score-delta-reason"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t(`insights.healthScore.deltaReason.${report.deltaReason}`)}
|
||||
</p>
|
||||
) : null}
|
||||
{report.restMode?.active ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.healthScore.restMode", {
|
||||
since: report.restMode.since ?? t("insights.healthScore.none"),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<CoverageMeter
|
||||
coverage={composite.coverage}
|
||||
confidence={
|
||||
composite.status === "ok" ? composite.confidence : undefined
|
||||
}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
<ul className="space-y-2" data-slot="health-score-pillars">
|
||||
{report.pillars.map((pillar) => (
|
||||
<li
|
||||
key={pillar.id}
|
||||
data-pillar={pillar.id}
|
||||
data-status={pillar.result.status}
|
||||
className="border-border/70 bg-card/80 rounded-xl border p-4"
|
||||
{/* Progressive disclosure. No Collapsible primitive exists in the UI
|
||||
kit, so this is the same accessible button + region shape the
|
||||
glucose panel ships: `aria-expanded` on the trigger,
|
||||
`aria-controls` + `hidden` on the region. */}
|
||||
<div className="border-border border-t pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
aria-controls={regionId}
|
||||
data-slot="health-score-anatomy-toggle"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex min-h-11 w-full items-center justify-between gap-2 rounded-sm text-sm font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-foreground text-sm font-semibold">
|
||||
{t(PILLAR_LABEL_KEY[pillar.id])}
|
||||
</p>
|
||||
{pillar.result.status === "ok" ? (
|
||||
<>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("insights.healthScore.observed", {
|
||||
value: observedText(pillar),
|
||||
})}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("insights.healthScore.reference", {
|
||||
value: scoredReferenceText(pillar),
|
||||
})}
|
||||
</p>
|
||||
{pillar.result.value.personalReference ? (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("insights.healthScore.personalReference", {
|
||||
value: formatReference(
|
||||
pillar.result.value.personalReference,
|
||||
pillar.result.value.personalReference.label,
|
||||
t("insights.healthScore.personalTargetSource"),
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("insights.healthScore.source", {
|
||||
source:
|
||||
pillar.result.value.observed.sources.join(", ") ||
|
||||
pillar.result.provenance.source,
|
||||
date: pillar.result.value.observed.asOf,
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("insights.healthScore.pillarInsufficient", {
|
||||
reason: reasonText(pillar.result.reason),
|
||||
})}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("insights.healthScore.source", {
|
||||
source: pillar.result.provenance.source,
|
||||
date: pillar.result.provenance.computedAt,
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{pillar.result.status === "ok" ? (
|
||||
<span className="text-foreground text-lg font-semibold tabular-nums">
|
||||
{Math.round(pillar.result.value.score)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<CoverageMeter
|
||||
coverage={pillar.result.coverage}
|
||||
confidence={
|
||||
pillar.result.status === "ok"
|
||||
? pillar.result.confidence
|
||||
: undefined
|
||||
}
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
<span>{t("insights.healthScore.anatomyToggle")}</span>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform",
|
||||
open && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</button>
|
||||
|
||||
<WeightGoalRow report={report} />
|
||||
{tension || returnToBand ? (
|
||||
<div
|
||||
data-slot="health-score-independent-context"
|
||||
className="border-border/70 bg-card/80 space-y-2 rounded-xl border p-4"
|
||||
>
|
||||
{tension ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.healthScore.tension", {
|
||||
positive: tension.positive.join(", "),
|
||||
negative: tension.negative.join(", "),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{returnToBand ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.healthScore.returnToBand", {
|
||||
metric: returnToBand.metricLabel,
|
||||
days: returnToBand.daysInside,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<div
|
||||
id={regionId}
|
||||
hidden={!open}
|
||||
data-slot="health-score-anatomy-region"
|
||||
className="space-y-4 pt-3"
|
||||
>
|
||||
{scored.length > 0 ? (
|
||||
<ul
|
||||
data-slot="health-score-pillars"
|
||||
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{scored.map((pillar) => (
|
||||
<PillarCell key={pillar.id} pillar={pillar} detail={detail} />
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{readFailed.map((pillar) => (
|
||||
<QueryErrorRow
|
||||
key={pillar.id}
|
||||
slot="health-score-pillar-error"
|
||||
retrySlot="health-score-pillar-retry"
|
||||
message={
|
||||
<>
|
||||
{t(PILLAR_LABEL_KEY[pillar.id])}
|
||||
{": "}
|
||||
{reasonText("read_failed")}
|
||||
</>
|
||||
}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
))}
|
||||
|
||||
{notScored.length > 0 ? (
|
||||
<div data-slot="health-score-not-scored" className="space-y-2">
|
||||
<p className="text-muted-foreground text-xs font-semibold tracking-wide uppercase">
|
||||
{t("insights.healthScore.notScored", {
|
||||
count: notScored.length,
|
||||
})}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{notScored.map((pillar) => {
|
||||
const reason = gateReason(pillar) ?? "";
|
||||
const crisis = reason === "crisis_signposting";
|
||||
return (
|
||||
<li
|
||||
key={pillar.id}
|
||||
data-pillar={pillar.id}
|
||||
data-reason={reason}
|
||||
className="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:gap-2"
|
||||
>
|
||||
<span className="text-foreground text-xs font-medium">
|
||||
{t(PILLAR_LABEL_KEY[pillar.id])}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs",
|
||||
crisis
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{reasonText(reason)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<WeightGoalRow report={report} />
|
||||
|
||||
<div data-slot="health-score-method" className="space-y-1">
|
||||
{composite.status === "ok" ? (
|
||||
<>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.healthScore.bandSetter", {
|
||||
pillar: composite.value.bandSetter
|
||||
? t(PILLAR_LABEL_KEY[composite.value.bandSetter])
|
||||
: t("insights.healthScore.none"),
|
||||
})}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.healthScore.versionAndNoise", {
|
||||
version: report.scoreVersion,
|
||||
noise: composite.value.noiseFloor,
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.healthScore.methodVersion", {
|
||||
version: report.scoreVersion,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<ProvenanceExplainer
|
||||
provenance={composite.provenance}
|
||||
method={t(METRIC_PROVENANCE.HEALTH_SCORE.methodKey)}
|
||||
standard={METRIC_PROVENANCE.HEALTH_SCORE.standard}
|
||||
className="block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One scored pillar: name + score, a band-tinted impact bar, and the observed
|
||||
* line with its detail popover and coverage meter. The reference band, the
|
||||
* personal target and the source live in the popover — the same strings as
|
||||
* before, one tap away instead of four permanent lines per pillar.
|
||||
*/
|
||||
function PillarCell({
|
||||
pillar,
|
||||
detail,
|
||||
}: {
|
||||
pillar: ScorePillarResult;
|
||||
detail: PillarDetailContext;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
if (pillar.result.status !== "ok") return null;
|
||||
const score = clampScore(pillar.result.value.score);
|
||||
const band = bandForScore(score);
|
||||
const lines = pillarDetailLines(pillar, detail);
|
||||
|
||||
return (
|
||||
<li
|
||||
data-pillar={pillar.id}
|
||||
data-status="ok"
|
||||
className="border-border/70 space-y-2 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-foreground min-w-0 text-sm font-medium">
|
||||
{t(PILLAR_LABEL_KEY[pillar.id])}
|
||||
</span>
|
||||
<span className="text-foreground text-lg font-semibold tabular-nums">
|
||||
{Math.round(score)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="bg-muted/40 h-1.5 w-full overflow-hidden rounded-full"
|
||||
role="presentation"
|
||||
>
|
||||
<div
|
||||
data-slot="health-score-pillar-bar"
|
||||
className={cn("h-full rounded-full", BAND_PROGRESS_CLASS[band])}
|
||||
style={{ width: `${score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-muted-foreground min-w-0 text-xs">
|
||||
{t("insights.healthScore.observed", {
|
||||
value: pillarObservedText(pillar, detail),
|
||||
})}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<InfoPopover
|
||||
content={lines.map((line) => (
|
||||
<span key={line} className="block">
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
bodyDataSlot="health-score-pillar-detail"
|
||||
/>
|
||||
<CoverageMeter
|
||||
coverage={pillar.result.coverage}
|
||||
confidence={pillar.result.confidence}
|
||||
size="sm"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The personal weight goal: an inner tile inside the disclosure, deliberately
|
||||
* NOT a pillar cell, because it is context the user set for themselves and it
|
||||
* never moves the score.
|
||||
*/
|
||||
function WeightGoalRow({ report }: { report: HealthScoreReport }) {
|
||||
const { t } = useTranslations();
|
||||
const goal = report.weightGoal;
|
||||
@@ -460,9 +560,9 @@ function WeightGoalRow({ report }: { report: HealthScoreReport }) {
|
||||
<div
|
||||
data-slot="health-score-weight-goal"
|
||||
data-status={goal.status}
|
||||
className="border-border/70 bg-card/80 rounded-xl border p-4"
|
||||
className="border-border/70 rounded-lg border p-3"
|
||||
>
|
||||
<p className="text-foreground text-sm font-semibold">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("insights.healthScore.weightGoal.title")}
|
||||
</p>
|
||||
{goal.status === "ok" ? (
|
||||
@@ -502,9 +602,46 @@ function WeightGoalRow({ report }: { report: HealthScoreReport }) {
|
||||
size="sm"
|
||||
className="mt-3"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-2 text-[11px]">
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
{t("insights.healthScore.weightGoal.notScored")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pinned slot's reserve while the analytics payload is in flight. Mirrors
|
||||
* the COLLAPSED footprint — header, ring, face lines, coverage meter, trigger
|
||||
* row — so first paint lands at the card's real height and the resolved card
|
||||
* is a swap rather than a push. Decorative: the card announces nothing while
|
||||
* it loads.
|
||||
*/
|
||||
export function HealthScoreCardSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<Card
|
||||
data-slot="health-score-card-skeleton"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
{/* The `md` ring paints at a fixed 168 px box; the reserve matches it
|
||||
exactly so the swap moves nothing. */}
|
||||
<Skeleton className="size-[168px] shrink-0 self-center rounded-full sm:self-auto" />
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-40" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-3 w-28" />
|
||||
<div className="border-border border-t pt-3">
|
||||
<Skeleton className="h-11 w-full rounded-sm" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { convertGlucose, type GlucoseUnit } from "@/lib/glucose";
|
||||
import type {
|
||||
PillarReference,
|
||||
ScorePillarResult,
|
||||
} from "@/lib/analytics/score/types";
|
||||
|
||||
/**
|
||||
* Pillar display formatting for the Health Score card.
|
||||
*
|
||||
* The card renders one observed line per scored pillar and hides the
|
||||
* reference band, the personal target and the source behind the cell's info
|
||||
* popover. A Radix popover only mounts its body once it is opened, so keeping
|
||||
* that formatting inside the component would put the account-unit conversion
|
||||
* (fasting glucose in mg/dL vs mmol/L) beyond the reach of a render test.
|
||||
* These are pure functions over the report DTO plus the caller's translator,
|
||||
* so both the card and the tests read the exact same strings.
|
||||
*
|
||||
* Client-safe: the score types and the glucose conversion are both pure.
|
||||
*/
|
||||
|
||||
export type Translate = (
|
||||
key: string,
|
||||
params?: Record<string, string | number>,
|
||||
) => string;
|
||||
|
||||
export interface PillarDetailContext {
|
||||
t: Translate;
|
||||
/** Account glucose unit; the fasting pillar is converted into it. */
|
||||
glucoseUnit: GlucoseUnit;
|
||||
}
|
||||
|
||||
function referenceKindText(reference: PillarReference, t: Translate): string {
|
||||
return t(
|
||||
`insights.healthScore.referenceKind.${
|
||||
reference.kind === "clinical-threshold"
|
||||
? "clinical"
|
||||
: reference.kind === "population-percentile"
|
||||
? "population"
|
||||
: "guideline"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
function formatReference(
|
||||
reference: PillarReference,
|
||||
label: string,
|
||||
t: Translate,
|
||||
source = reference.source,
|
||||
): string {
|
||||
return t("insights.healthScore.referenceDetail", {
|
||||
label,
|
||||
source,
|
||||
kind: referenceKindText(reference, t),
|
||||
low: reference.low ?? t("insights.healthScore.openBound"),
|
||||
high: reference.high ?? t("insights.healthScore.openBound"),
|
||||
});
|
||||
}
|
||||
|
||||
/** The one always-visible line: what was actually measured. */
|
||||
export function pillarObservedText(
|
||||
pillar: ScorePillarResult,
|
||||
{ t, glucoseUnit }: PillarDetailContext,
|
||||
): string {
|
||||
if (pillar.result.status !== "ok") return "";
|
||||
const observed = pillar.result.value.observed;
|
||||
if (pillar.id === "GLYCAEMIA" && observed.unit === "mg/dL") {
|
||||
return t("insights.healthScore.fastingObserved", {
|
||||
value: convertGlucose(observed.value, glucoseUnit),
|
||||
unit: glucoseUnit,
|
||||
});
|
||||
}
|
||||
if (pillar.id === "ACTIVITY") {
|
||||
return t("insights.healthScore.observedActivity", {
|
||||
value: observed.value,
|
||||
});
|
||||
}
|
||||
if (pillar.id === "SLEEP") {
|
||||
return t("insights.healthScore.observedSleep", { value: observed.value });
|
||||
}
|
||||
if (pillar.id === "ADIPOSITY") {
|
||||
return t("insights.healthScore.observedAdiposity", {
|
||||
value: observed.value,
|
||||
});
|
||||
}
|
||||
if (pillar.id === "FITNESS") {
|
||||
return t("insights.healthScore.observedFitness", {
|
||||
value: observed.value,
|
||||
unit: observed.unit,
|
||||
});
|
||||
}
|
||||
return observed.label;
|
||||
}
|
||||
|
||||
/** The scored reference band, already converted into the account unit. */
|
||||
function pillarReferenceText(
|
||||
pillar: ScorePillarResult,
|
||||
{ t, glucoseUnit }: PillarDetailContext,
|
||||
): string {
|
||||
if (pillar.result.status !== "ok") return "";
|
||||
const value = pillar.result.value;
|
||||
const reference = value.reference;
|
||||
if (pillar.id === "GLYCAEMIA" && value.observed.unit === "mg/dL") {
|
||||
const low =
|
||||
reference.low == null ? null : convertGlucose(reference.low, glucoseUnit);
|
||||
const high =
|
||||
reference.high == null
|
||||
? null
|
||||
: convertGlucose(reference.high, glucoseUnit);
|
||||
return formatReference(
|
||||
{ ...reference, low, high },
|
||||
t("insights.healthScore.fastingReference", {
|
||||
low: low ?? "",
|
||||
high: high ?? "",
|
||||
unit: glucoseUnit,
|
||||
}),
|
||||
t,
|
||||
);
|
||||
}
|
||||
const label =
|
||||
pillar.id === "ACTIVITY"
|
||||
? t("insights.healthScore.referenceActivity", {
|
||||
high: reference.high ?? "",
|
||||
})
|
||||
: pillar.id === "SLEEP"
|
||||
? t("insights.healthScore.referenceSleep", {
|
||||
low: reference.low ?? "",
|
||||
})
|
||||
: pillar.id === "ADIPOSITY"
|
||||
? t("insights.healthScore.referenceAdiposity", {
|
||||
high: reference.high ?? "",
|
||||
})
|
||||
: pillar.id === "FITNESS"
|
||||
? t("insights.healthScore.referenceFitness", {
|
||||
low: reference.low ?? "",
|
||||
high: reference.high ?? "",
|
||||
unit: value.observed.unit,
|
||||
})
|
||||
: pillar.id === "GLYCAEMIA"
|
||||
? t("insights.healthScore.referenceHba1c", {
|
||||
low: reference.low ?? "",
|
||||
high: reference.high ?? "",
|
||||
})
|
||||
: reference.label;
|
||||
return formatReference(
|
||||
reference,
|
||||
label,
|
||||
t,
|
||||
pillar.id === "LIPIDS"
|
||||
? t("insights.healthScore.laboratoryReferenceSource")
|
||||
: reference.source,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The lines behind the cell's info popover: the scored reference, the
|
||||
* user-authored target when one exists, and the source with its as-of date.
|
||||
* Ordered as they render; an absent personal target contributes no line
|
||||
* rather than an empty one.
|
||||
*/
|
||||
export function pillarDetailLines(
|
||||
pillar: ScorePillarResult,
|
||||
ctx: PillarDetailContext,
|
||||
): string[] {
|
||||
if (pillar.result.status !== "ok") return [];
|
||||
const { t } = ctx;
|
||||
const value = pillar.result.value;
|
||||
const lines = [
|
||||
t("insights.healthScore.reference", {
|
||||
value: pillarReferenceText(pillar, ctx),
|
||||
}),
|
||||
];
|
||||
if (value.personalReference) {
|
||||
lines.push(
|
||||
t("insights.healthScore.personalReference", {
|
||||
value: formatReference(
|
||||
value.personalReference,
|
||||
value.personalReference.label,
|
||||
t,
|
||||
t("insights.healthScore.personalTargetSource"),
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
t("insights.healthScore.source", {
|
||||
source:
|
||||
value.observed.sources.join(", ") || pillar.result.provenance.source,
|
||||
date: value.observed.asOf,
|
||||
}),
|
||||
);
|
||||
return lines;
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
useTranslations,
|
||||
useFormatters,
|
||||
@@ -13,19 +12,8 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { hourInTz, DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HealthScoreCard } from "./health-score-card";
|
||||
import type { HealthScoreReport, ScoreBand } from "@/lib/analytics/score/types";
|
||||
import type { DailyBriefing as DailyBriefingPayload } from "@/lib/ai/schema";
|
||||
|
||||
/**
|
||||
* v1.21.2 (A5) — readiness contributor keys the Tension Verdict surfaces. Kept
|
||||
* as a local string union so the client never imports the server derived-engine
|
||||
* types. Localised through the existing
|
||||
* `insights.derived.composite.READINESS.component.{key}` labels.
|
||||
*/
|
||||
export type ReadinessContributorKey =
|
||||
"rhr" | "hrv" | "sleep" | "respiratory" | "mood";
|
||||
|
||||
/**
|
||||
* Insights redesign hero strip.
|
||||
*
|
||||
@@ -43,8 +31,14 @@ export type ReadinessContributorKey =
|
||||
* chip strip were removed from the band. The Coach lives in the
|
||||
* bottom-right drawer (mounted by the insights layout shell); the
|
||||
* overview now leads with the present-focused daily briefing, not a
|
||||
* coach prompt. The hero is greeting + subtitle + baseline meta + the
|
||||
* optional right-side Health Score panel.
|
||||
* coach prompt.
|
||||
*
|
||||
* The right-side Health Score panel is gone too. The score had two mounts,
|
||||
* a compact face here and the full breakdown below; the breakdown is now one
|
||||
* progressive-disclosure card pinned directly under this band, which needs
|
||||
* the full page width when it opens. The hero is back to the full-width
|
||||
* greeting + subtitle + baseline meta — the shape it already had for an
|
||||
* account without a score.
|
||||
*
|
||||
* Pure presentational — the page owns the briefing data.
|
||||
*/
|
||||
@@ -74,45 +68,6 @@ interface HeroStripProps {
|
||||
* footer hint below.
|
||||
*/
|
||||
noProviderStale?: boolean;
|
||||
/** Complete transparent Health Score report from the analytics route. */
|
||||
healthScore?: HealthScoreReport | null;
|
||||
/**
|
||||
* v1.21.2 (A5) — Tension Verdict, server-resolved + locale-agnostic. `band`
|
||||
* is the readiness composite's band; `positive` / `negative` carry the
|
||||
* readiness contributor KEYS (`rhr` / `hrv` / `sleep` / `respiratory` /
|
||||
* `mood`). The hero localises each key through the existing
|
||||
* `insights.derived.composite.READINESS.component.*` labels before handing the
|
||||
* score card its already-localised strings. Null on a coherent day (or under a
|
||||
* clinical red-flag suppress). The score card carries it only when the score
|
||||
* is present.
|
||||
*/
|
||||
tension?: {
|
||||
band: ScoreBand;
|
||||
positive: ReadinessContributorKey[];
|
||||
negative: ReadinessContributorKey[];
|
||||
} | null;
|
||||
/**
|
||||
* v1.21.2 (A6) — return-to-baseline, server-resolved + locale-agnostic.
|
||||
* `metricType` is a `MeasurementType` the hero maps to its localised metric
|
||||
* name (the existing `measurements.type*` keys). Null when no salient metric
|
||||
* returned from a genuine prior out-of-band run.
|
||||
*/
|
||||
returnToBand?: {
|
||||
metricType: string;
|
||||
daysInside: number;
|
||||
} | null;
|
||||
/**
|
||||
* v1.16.8 — true while the analytics payload that carries
|
||||
* `healthScore` is still in flight. The right column then reserves the
|
||||
* score card's footprint with a skeleton, so the hero band paints at
|
||||
* roughly its final size from the first frame and the card landing is
|
||||
* a swap, not a push. Pre-fix the column collapsed while loading and
|
||||
* the whole overview reflowed when the (slow, thick-slice) analytics
|
||||
* response arrived — the "greeting first, score card later" stagger.
|
||||
* When the payload resolves WITHOUT a score (no data yet) the column
|
||||
* collapses once, which is the legacy no-score shape.
|
||||
*/
|
||||
healthScorePending?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,42 +88,12 @@ function resolveGreetingKey(hour: number): string {
|
||||
return "insights.heroGreetingEvening";
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.21.2 (A5) — readiness contributor key → existing localised label key.
|
||||
* Reuses the score-anatomy contributor labels so the Tension Verdict reads the
|
||||
* same names the wellness ring's anatomy view renders — no new keys.
|
||||
*/
|
||||
const TENSION_CONTRIBUTOR_LABEL_KEY: Record<ReadinessContributorKey, string> = {
|
||||
rhr: "insights.derived.composite.READINESS.component.rhr",
|
||||
hrv: "insights.derived.composite.READINESS.component.hrv",
|
||||
sleep: "insights.derived.composite.READINESS.component.sleep",
|
||||
respiratory: "insights.derived.composite.READINESS.component.respiratory",
|
||||
mood: "insights.derived.composite.READINESS.component.mood",
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.21.2 (A6) — `MeasurementType` → existing localised metric-name key
|
||||
* (`measurements.type*`). Only the salient deviation vitals the
|
||||
* return-to-baseline detector scans need an entry; an unmapped type falls back
|
||||
* to a prettified raw form so a new type never blanks.
|
||||
*/
|
||||
const RETURN_METRIC_LABEL_KEY: Record<string, string> = {
|
||||
RESTING_HEART_RATE: "measurements.typeRestingHeartRate",
|
||||
HEART_RATE_VARIABILITY: "measurements.typeHeartRateVariability",
|
||||
RESPIRATORY_RATE: "measurements.typeRespiratoryRate",
|
||||
WEIGHT: "measurements.typeWeight",
|
||||
};
|
||||
|
||||
export function HeroStrip({
|
||||
briefing,
|
||||
updatedAt,
|
||||
userName,
|
||||
now,
|
||||
healthScore,
|
||||
healthScorePending = false,
|
||||
noProviderStale = false,
|
||||
tension = null,
|
||||
returnToBand = null,
|
||||
}: HeroStripProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -177,33 +102,6 @@ export function HeroStrip({
|
||||
// Berlin), so the today/yesterday boundary and the clock can never split.
|
||||
const displayTz = useDisplayTimezone();
|
||||
|
||||
// v1.21.2 (A5) — localise the Tension Verdict's contributor keys into the
|
||||
// card's already-localised `{ positive, negative }` shape. Only forwarded when
|
||||
// a real disagreement is present on BOTH sides; the resolver already enforces
|
||||
// that, but the guard keeps the card from rendering a one-sided line.
|
||||
const tensionProp =
|
||||
tension && tension.positive.length > 0 && tension.negative.length > 0
|
||||
? {
|
||||
band: tension.band,
|
||||
positive: tension.positive.map((k) =>
|
||||
t(TENSION_CONTRIBUTOR_LABEL_KEY[k]),
|
||||
),
|
||||
negative: tension.negative.map((k) =>
|
||||
t(TENSION_CONTRIBUTOR_LABEL_KEY[k]),
|
||||
),
|
||||
}
|
||||
: null;
|
||||
|
||||
// v1.21.2 (A6) — localise the return-to-baseline metric into the card's
|
||||
// already-localised `metricLabel`.
|
||||
const returnToBandProp = returnToBand
|
||||
? {
|
||||
metricLabel: RETURN_METRIC_LABEL_KEY[returnToBand.metricType]
|
||||
? t(RETURN_METRIC_LABEL_KEY[returnToBand.metricType])
|
||||
: returnToBand.metricType.replace(/_/g, " ").toLowerCase(),
|
||||
daysInside: returnToBand.daysInside,
|
||||
}
|
||||
: null;
|
||||
// Greeting hour in the user's configured zone, not the browser's — a
|
||||
// traveller / VPN user / wrong device clock otherwise sees the wrong
|
||||
// salutation. Mirrors the dashboard hero (`hourInTimezone(now, userTz)`).
|
||||
@@ -236,157 +134,86 @@ export function HeroStrip({
|
||||
"relative isolate overflow-hidden rounded-xl px-4 py-4 md:px-6 md:py-6",
|
||||
)}
|
||||
>
|
||||
{/*
|
||||
* v1.4.20 phase B5 — split layout. On `md+` (v1.4.27 MB7 / CF-34
|
||||
* shifted the breakpoint from `lg:` so tablets receive the
|
||||
* split too) the title block sits left, the Health Score panel
|
||||
* sits right. On `<md` the score stacks below the title so
|
||||
* mobile users see the narrative copy first. When `healthScore`
|
||||
* is null/undefined the right column collapses and the title
|
||||
* block uses the full width — same shape as B1–B4. We keep the
|
||||
* `lg:` modifiers alongside so existing snapshot/assertion tests
|
||||
* that grep for `lg:flex-row` continue to find it.
|
||||
*/}
|
||||
{/*
|
||||
* v1.4.28 R3c-Insights — switch the row's cross-axis alignment
|
||||
* from `items-start` to `items-stretch` so the right-column
|
||||
* HealthScore card grows to match the left column's natural
|
||||
* height (greeting + subtitle + baseline meta + action row +
|
||||
* suggested-prompts strip). Per Inv-4 the card was painting
|
||||
* 75-110 px shorter than the left column on desktop; the
|
||||
* stretch contract pulls the card's bottom edge down to the
|
||||
* "Wirkt mein Medikament?" chip. The card itself owns
|
||||
* `h-full` + `mt-auto` on its disclaimer to redistribute the
|
||||
* recovered space without visual jank.
|
||||
*/}
|
||||
{/* v1.16.8 — the split keys on `reserveScoreColumn`, not on the
|
||||
resolved payload alone: while the analytics query is in flight
|
||||
the right column already holds the score card's footprint (the
|
||||
skeleton below), so the band's two-column geometry is stable
|
||||
from first paint. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-4",
|
||||
(healthScore || healthScorePending) &&
|
||||
"md:flex-row md:items-stretch md:gap-6 lg:flex-row lg:items-stretch lg:gap-6",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Header glyph stays in the foreground tier — UI-STANDARDS §1
|
||||
{/* One column. The band carried a right-hand score panel from v1.4.20
|
||||
until the score moved to its own pinned card below; with the panel
|
||||
gone the greeting owns the full width on every breakpoint, which is
|
||||
the shape this band already had for an account without a score. */}
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Header glyph stays in the foreground tier — UI-STANDARDS §1
|
||||
reserves primary/accent for actions, not decorative header
|
||||
icons. */}
|
||||
<Sparkles
|
||||
className="text-foreground h-5 w-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h1
|
||||
data-slot="insights-hero-strip-greeting"
|
||||
className="text-2xl leading-tight font-bold tracking-tight sm:text-3xl"
|
||||
>
|
||||
{greeting}
|
||||
</h1>
|
||||
</div>
|
||||
{/* v1.22 (W6) — the briefing paragraph renders through ProseBlocks
|
||||
<Sparkles
|
||||
className="text-foreground h-5 w-5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h1
|
||||
data-slot="insights-hero-strip-greeting"
|
||||
className="text-2xl leading-tight font-bold tracking-tight sm:text-3xl"
|
||||
>
|
||||
{greeting}
|
||||
</h1>
|
||||
</div>
|
||||
{/* v1.22 (W6) — the briefing paragraph renders through ProseBlocks
|
||||
so a multi-paragraph verdict-first briefing shows real paragraph
|
||||
spacing (still plain text children, no markdown). */}
|
||||
<div
|
||||
data-slot="insights-hero-strip-subtitle"
|
||||
className="text-foreground max-w-3xl text-sm"
|
||||
>
|
||||
<ProseBlocks text={subtitle} />
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span data-slot="insights-hero-strip-baseline">
|
||||
{t("insights.heroPersonalBaseline")}
|
||||
</span>
|
||||
{generatedLine && (
|
||||
<>
|
||||
<span aria-hidden="true" className="opacity-50">
|
||||
·
|
||||
</span>
|
||||
<span data-slot="insights-hero-strip-generated">
|
||||
{generatedLine}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{/* v1.18.9 (#4) — the subtitle is a cached briefing that can
|
||||
<div
|
||||
data-slot="insights-hero-strip-subtitle"
|
||||
className="text-foreground max-w-3xl text-sm"
|
||||
>
|
||||
<ProseBlocks text={subtitle} />
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<span data-slot="insights-hero-strip-baseline">
|
||||
{t("insights.heroPersonalBaseline")}
|
||||
</span>
|
||||
{generatedLine && (
|
||||
<>
|
||||
<span aria-hidden="true" className="opacity-50">
|
||||
·
|
||||
</span>
|
||||
<span data-slot="insights-hero-strip-generated">
|
||||
{generatedLine}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{/* v1.18.9 (#4) — the subtitle is a cached briefing that can
|
||||
never refresh (no AI provider). Append a discreet hint +
|
||||
Settings → AI link so the relative age above reads as
|
||||
intentionally held, consistent with the briefing card. */}
|
||||
{noProviderStale && (
|
||||
<>
|
||||
<span aria-hidden="true" className="opacity-50">
|
||||
·
|
||||
</span>
|
||||
<span
|
||||
data-slot="insights-hero-strip-stale-no-provider"
|
||||
className="inline-flex flex-wrap items-center gap-x-1.5"
|
||||
{noProviderStale && (
|
||||
<>
|
||||
<span aria-hidden="true" className="opacity-50">
|
||||
·
|
||||
</span>
|
||||
<span
|
||||
data-slot="insights-hero-strip-stale-no-provider"
|
||||
className="inline-flex flex-wrap items-center gap-x-1.5"
|
||||
>
|
||||
<span>{t("insights.dailyBriefing.staleNoProviderHint")}</span>
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
data-slot="insights-hero-strip-stale-no-provider-link"
|
||||
className="text-foreground/80 hover:text-foreground underline underline-offset-2"
|
||||
>
|
||||
<span>
|
||||
{t("insights.dailyBriefing.staleNoProviderHint")}
|
||||
</span>
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
data-slot="insights-hero-strip-stale-no-provider-link"
|
||||
className="text-foreground/80 hover:text-foreground underline underline-offset-2"
|
||||
>
|
||||
{t("insights.dailyBriefing.noProviderAction")}
|
||||
</Link>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{t("insights.dailyBriefing.noProviderAction")}
|
||||
</Link>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* v1.18.7 — the overview "Ask the coach" action row + the
|
||||
* guided-questions chip strip were removed from the hero band.
|
||||
* The Coach is the bottom-right drawer (mounted in the insights
|
||||
* layout shell); the overview leads with the present-focused
|
||||
* daily briefing, not a coach prompt. The drawer itself is
|
||||
* untouched — only the hero entry points are gone, so the band
|
||||
* is now greeting + subtitle + baseline meta.
|
||||
*/}
|
||||
</div>
|
||||
|
||||
{!healthScore && healthScorePending && (
|
||||
/* v1.16.8 — layout-stable placeholder mirroring the score
|
||||
card's column footprint (same basis / stretch classes as
|
||||
`<HealthScoreCard>`): label row, headline number, progress
|
||||
bar, four component rows. Decorative — the card announces
|
||||
nothing while loading. */
|
||||
<div
|
||||
data-slot="health-score-card-skeleton"
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"bg-card/65 border-border/60 rounded-xl border p-4 shadow-sm backdrop-blur-sm md:p-6",
|
||||
"w-full md:shrink-0 md:grow-0 md:basis-[22rem] xl:basis-[26rem]",
|
||||
"flex h-full min-h-64 flex-col gap-3.5",
|
||||
)}
|
||||
>
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-12 w-28" />
|
||||
<Skeleton className="h-2 w-full rounded-full" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
<div className="space-y-2.5 border-t pt-3.5">
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{healthScore && (
|
||||
<HealthScoreCard
|
||||
report={healthScore}
|
||||
tension={tensionProp}
|
||||
returnToBand={returnToBandProp}
|
||||
compact
|
||||
className="w-full md:shrink-0 md:grow-0 md:basis-[22rem] xl:basis-[26rem]"
|
||||
/>
|
||||
)}
|
||||
{/*
|
||||
* v1.18.7 — the overview "Ask the coach" action row + the
|
||||
* guided-questions chip strip were removed from the hero band.
|
||||
* The Coach is the bottom-right drawer (mounted in the insights
|
||||
* layout shell); the overview leads with the present-focused
|
||||
* daily briefing, not a coach prompt. The drawer itself is
|
||||
* untouched — only the hero entry points are gone, so the band
|
||||
* is now greeting + subtitle + baseline meta.
|
||||
*/}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -264,7 +264,7 @@ export function InsightStatusCard({
|
||||
// v1.12.2 — stable hook for the spine guard test, which asserts the
|
||||
// assessment is the LAST content block on every bespoke metric page.
|
||||
data-slot="insight-assessment"
|
||||
// Compact card density (`gap-1.5 py-3 md:py-4`) — the sanctioned tight
|
||||
// Compact card density (`gap-2 py-3 md:py-4`) — the sanctioned tight
|
||||
// tier, so the assessment shares one header→body rhythm with the
|
||||
// target summary, the read strip, and the stat strip on the same
|
||||
// spine. Earlier bespoke values (`gap-1.5` + `md:py-5`) predate the
|
||||
@@ -272,7 +272,7 @@ export function InsightStatusCard({
|
||||
// lands on every status variant so the gap is consistent across
|
||||
// states. (The one-time purple left accent stays dropped — the plain
|
||||
// card border carries enough separation.)
|
||||
className="animate-insight-in gap-1.5 py-3 md:py-4"
|
||||
className="animate-insight-in gap-2 py-3 md:py-4"
|
||||
>
|
||||
<CardHeader>
|
||||
{/* v1.11.5 — the top-right "cached" label was removed: it surfaced
|
||||
|
||||
@@ -77,11 +77,19 @@ const AXIS_TICKS = [0, 360, 720, 1080, 1440];
|
||||
* hourly day (it needs per-sample resolution to stay honest). A failed fetch
|
||||
* renders `<QueryErrorCard>` with a retry action instead of falling through
|
||||
* to the empty-day copy.
|
||||
*
|
||||
* `initialDateKey` pins the first day shown, for callers that already know
|
||||
* which day they are talking about — the workout detail page seeds it with
|
||||
* the session's `dayKey` so the chart opens on the day of the workout. The
|
||||
* navigator still works from there, so "the day before" stays one tap away.
|
||||
* Absent, the view opens on today and keeps tracking it.
|
||||
*/
|
||||
export function IntradayPulseChart({
|
||||
userTimezone,
|
||||
initialDateKey,
|
||||
}: {
|
||||
userTimezone?: string;
|
||||
initialDateKey?: string;
|
||||
}) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
@@ -90,8 +98,11 @@ export function IntradayPulseChart({
|
||||
const todayKey = userDayKey(new Date(), tz);
|
||||
// `null` tracks "today" live (so a session left open across midnight keeps
|
||||
// following the current day); a non-null value pins the viewed day once
|
||||
// the user has navigated away from today.
|
||||
const [selectedDateKey, setSelectedDateKey] = useState<string | null>(null);
|
||||
// the user has navigated away from today — or from the start, when the
|
||||
// caller seeded a day of its own.
|
||||
const [selectedDateKey, setSelectedDateKey] = useState<string | null>(
|
||||
initialDateKey ?? null,
|
||||
);
|
||||
const dateKey = selectedDateKey ?? todayKey;
|
||||
const isToday = dateKey === todayKey;
|
||||
|
||||
|
||||
@@ -258,7 +258,7 @@ export function MetricStatStrip({
|
||||
data-multi-series="true"
|
||||
role="group"
|
||||
aria-label={groupLabel ?? t("insights.subPage.stats.label")}
|
||||
className="gap-1.5 py-3 md:py-4"
|
||||
className="gap-2 py-3 md:py-4"
|
||||
>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-4 md:grid-cols-2">
|
||||
@@ -291,7 +291,7 @@ export function MetricStatStrip({
|
||||
? `${t("insights.subPage.stats.label")} — ${seriesLabel}`
|
||||
: t("insights.subPage.stats.label")
|
||||
}
|
||||
className="gap-1.5 py-3 md:py-4"
|
||||
className="gap-2 py-3 md:py-4"
|
||||
>
|
||||
<CardContent>
|
||||
<SeriesBlock
|
||||
|
||||
@@ -326,7 +326,7 @@ function TargetReferencePanel({
|
||||
<Card
|
||||
data-slot="metric-target-summary"
|
||||
data-target-type={target.type}
|
||||
className="gap-1.5 py-3 md:py-4"
|
||||
className="gap-2 py-3 md:py-4"
|
||||
>
|
||||
{/* Row 1: the canonical tile header — a single-word "Ziel" / "Target"
|
||||
heading (the range numbers live on the range bar below, so the
|
||||
|
||||
@@ -285,10 +285,10 @@ export function MoodInsightsSections({
|
||||
<Card
|
||||
aria-live="polite"
|
||||
data-slot="insight-assessment"
|
||||
// v1.13.1 — match the canonical `gap-1.5` + `pb-1` heading-to-body
|
||||
// v1.13.1 — match the canonical `gap-2` + `pb-1` heading-to-body
|
||||
// rhythm the other assessment cards use, so the heading sits tight
|
||||
// above its body instead of floating ~16-24 px above it.
|
||||
className="animate-insight-in gap-1.5 py-3 md:py-4"
|
||||
className="animate-insight-in gap-2 py-3 md:py-4"
|
||||
>
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
|
||||
@@ -29,7 +29,20 @@ export interface NutrientOverviewRow {
|
||||
daysWithData: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-null only when `nutrients` is empty AND the most recent
|
||||
* `nutrient.batch.ingest` audit row shows a call that landed nothing —
|
||||
* `topReason` is the most common of the closed skip-reason codes
|
||||
* (`unit_mismatch` | `value_out_of_range` | `day_invalid` |
|
||||
* `upsert_failed`) from that call.
|
||||
*/
|
||||
export interface NutrientLastIngestAttempt {
|
||||
at: string;
|
||||
topReason: string;
|
||||
}
|
||||
|
||||
export interface NutrientIntakeOverview {
|
||||
windowDays: number;
|
||||
nutrients: NutrientOverviewRow[];
|
||||
lastAttempt: NutrientLastIngestAttempt | null;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, CalendarDays, Moon, Smile } from "lucide-react";
|
||||
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { SectionHeading } from "@/components/insights/section-heading";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
import { useMoodDay } from "@/hooks/use-mood-day";
|
||||
import { useSleepNight } from "@/hooks/use-sleep-night";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
import { MOOD_LABEL_KEYS } from "@/lib/mood/labels";
|
||||
|
||||
// Through the shared chart-runtime boundary, never a direct import — a
|
||||
// second `from "recharts"` entry point mints its own copy of the library's
|
||||
// chunk. Same pattern the pulse page uses for this very chart.
|
||||
const IntradayPulseChart = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/chart-runtime").then((mod) => ({
|
||||
default: mod.IntradayPulseChart,
|
||||
})),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
|
||||
const LINK_CLASS =
|
||||
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex shrink-0 items-center gap-1.5 text-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none";
|
||||
|
||||
const ROW_CLASS = "flex items-start gap-3 py-2";
|
||||
|
||||
const GLYPH_CLASS = "text-muted-foreground size-4 shrink-0";
|
||||
|
||||
export interface WorkoutDetailDayContextProps {
|
||||
workout: WorkoutDetailPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* "That day" — the day around the session, rendered here rather than
|
||||
* linked to.
|
||||
*
|
||||
* After reading one workout the question is comparative and simultaneous:
|
||||
* how did the pulse settle afterwards, how had the person slept going into
|
||||
* it, how did the day feel. A comparison needs both halves on one screen,
|
||||
* so this section shows the day's own signals instead of handing off to
|
||||
* three aggregate-trend pages that carry no date.
|
||||
*
|
||||
* Every read is addressed by `workout.dayKey`, the local calendar day the
|
||||
* server resolved from `startedAt` in the user's timezone. Nothing here
|
||||
* derives a day from a timestamp.
|
||||
*
|
||||
* Absence and failure stay distinct: a night with nothing recorded and a
|
||||
* day with no mood entry each read as one muted sentence, while a failed
|
||||
* read gets `QueryErrorRow` with a retry. The two outbound links are
|
||||
* undated and named for what their targets actually deliver — trends and
|
||||
* history, not "that day".
|
||||
*
|
||||
* Module gating: the intraday tile mounts only when `insights` is on and
|
||||
* the sleep row only when `sleep` is on, so a switched-off module never
|
||||
* paints as a failed request. Mood has no module gate, so the section
|
||||
* always carries at least one part and the heading is never an orphan.
|
||||
*/
|
||||
export function WorkoutDetailDayContext({
|
||||
workout,
|
||||
}: WorkoutDetailDayContextProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const insightsEnabled = useModuleEnabled("insights");
|
||||
const sleepEnabled = useModuleEnabled("sleep");
|
||||
|
||||
const sleep = useSleepNight(workout.dayKey, isAuthenticated && sleepEnabled);
|
||||
const mood = useMoodDay(workout.dayKey, isAuthenticated);
|
||||
|
||||
const night = sleep.data?.main ?? null;
|
||||
const moodEntries = mood.entries ?? [];
|
||||
|
||||
return (
|
||||
<section data-slot="workout-detail-day-context" className="space-y-3">
|
||||
<SectionHeading
|
||||
icon={CalendarDays}
|
||||
// `SubPageShell` tracks the card body edge for its own heading
|
||||
// rather than the container edge, so this heading takes the same
|
||||
// responsive card inset and every text run on the page shares one x.
|
||||
className="px-4 md:px-6"
|
||||
title={t("insights.workouts.detail.thatDayTitle")}
|
||||
action={
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{fmt.dateWithWeekdaySmart(new Date(workout.startedAt))}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{insightsEnabled ? (
|
||||
<IntradayPulseChart
|
||||
userTimezone={user?.timezone}
|
||||
initialDateKey={workout.dayKey}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Card className="gap-2 py-3 md:py-4" data-slot="workout-detail-day-card">
|
||||
<CardContent>
|
||||
<div className="divide-border divide-y">
|
||||
{sleepEnabled ? (
|
||||
<div className={ROW_CLASS} data-slot="workout-detail-day-sleep">
|
||||
<Moon className={GLYPH_CLASS} aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.workouts.detail.daySleepTitle")}
|
||||
</p>
|
||||
{sleep.isError ? (
|
||||
// A failed read is not an absent night.
|
||||
<QueryErrorRow
|
||||
onRetry={sleep.refetch}
|
||||
slot="workout-detail-day-sleep-error"
|
||||
/>
|
||||
) : sleep.data === undefined ? (
|
||||
// Nothing has answered yet. The absence line below is
|
||||
// only ever shown for an answer that HAD no night.
|
||||
<Skeleton className="h-4 w-32" />
|
||||
) : night ? (
|
||||
<p className="text-sm">
|
||||
{formatDurationMinutes(night.asleepMinutes, t)}
|
||||
{night.awakenings > 0
|
||||
? ` · ${
|
||||
night.awakenings === 1
|
||||
? t(
|
||||
"insights.workouts.detail.daySleepAwakeningsOne",
|
||||
{ count: night.awakenings },
|
||||
)
|
||||
: t(
|
||||
"insights.workouts.detail.daySleepAwakeningsMany",
|
||||
{ count: night.awakenings },
|
||||
)
|
||||
}`
|
||||
: null}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.workouts.detail.daySleepNone")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href="/insights/sleep"
|
||||
data-slot="workout-detail-day-sleep-link"
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
{t("insights.workouts.detail.sleepTrendsLink")}
|
||||
<ArrowRight className="size-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={ROW_CLASS} data-slot="workout-detail-day-mood">
|
||||
<Smile className={GLYPH_CLASS} aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.workouts.detail.dayMoodTitle")}
|
||||
</p>
|
||||
{mood.isError ? (
|
||||
<QueryErrorRow
|
||||
onRetry={mood.refetch}
|
||||
slot="workout-detail-day-mood-error"
|
||||
/>
|
||||
) : mood.entries === undefined ? (
|
||||
<Skeleton className="h-4 w-32" />
|
||||
) : moodEntries.length > 0 ? (
|
||||
// User data, so foreground — never muted.
|
||||
<ul className="flex flex-wrap gap-x-3 gap-y-1 text-sm">
|
||||
{moodEntries.map((entry) => (
|
||||
<li key={entry.id}>
|
||||
{MOOD_LABEL_KEYS[entry.mood]
|
||||
? t(MOOD_LABEL_KEYS[entry.mood])
|
||||
: entry.mood}
|
||||
<span className="text-muted-foreground ml-1.5 text-xs tabular-nums">
|
||||
{fmt.time(new Date(entry.moodLoggedAt))}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.workouts.detail.dayMoodNone")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href="/mood"
|
||||
data-slot="workout-detail-day-mood-link"
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
{t("insights.workouts.detail.moodHistoryLink")}
|
||||
<ArrowRight className="size-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Activity, Moon, Smile } from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
|
||||
interface DayLink {
|
||||
href: string;
|
||||
labelKey: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const LINKS: DayLink[] = [
|
||||
{
|
||||
href: "/insights/pulse",
|
||||
labelKey: "insights.workouts.detail.dayPulse",
|
||||
icon: Activity,
|
||||
},
|
||||
{
|
||||
href: "/insights/sleep",
|
||||
labelKey: "insights.workouts.detail.daySleep",
|
||||
icon: Moon,
|
||||
},
|
||||
{
|
||||
href: "/insights/mood",
|
||||
labelKey: "insights.workouts.detail.dayMood",
|
||||
icon: Smile,
|
||||
},
|
||||
];
|
||||
|
||||
export interface WorkoutDetailDayLinksProps {
|
||||
workout: WorkoutDetailPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* "That day" — a plain inline link row to the day's other signals
|
||||
* (intraday pulse, that night's sleep, mood). No reads; just navigation
|
||||
* so the workout sits in the context of the rest of the day.
|
||||
*/
|
||||
export function WorkoutDetailDayLinks({ workout }: WorkoutDetailDayLinksProps) {
|
||||
const { t } = useTranslations();
|
||||
void workout;
|
||||
|
||||
return (
|
||||
<Card data-slot="workout-detail-day-links">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={CalendarDays}
|
||||
title={t("insights.workouts.detail.thatDayTitle")}
|
||||
titleAs="h2"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{LINKS.map(({ href, labelKey, icon: Icon }) => (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className="border-border hover:bg-muted inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors"
|
||||
>
|
||||
<Icon
|
||||
className="text-muted-foreground size-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t(labelKey)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Barrel for the workout-detail surface (#67). The former single
|
||||
* `workout-detail.tsx` grew past a comfortable size once the HR curve,
|
||||
* zones, route SVG, splits and day-links landed, so it was split into
|
||||
* zones, route SVG, splits and day context landed, so it was split into
|
||||
* this directory. Import path stays `@/components/insights/workout-detail`.
|
||||
*/
|
||||
export { WorkoutDetailHeader } from "./header";
|
||||
@@ -10,7 +10,7 @@ export { WorkoutDetailHrSection } from "./hr-section";
|
||||
export { WorkoutDetailZones } from "./zones";
|
||||
export { WorkoutDetailRoute } from "./route-map";
|
||||
export { WorkoutDetailSplits } from "./splits";
|
||||
export { WorkoutDetailDayLinks } from "./day-links";
|
||||
export { WorkoutDetailDayContext } from "./day-context";
|
||||
export {
|
||||
WorkoutInsightCard,
|
||||
type WorkoutActivityInsightData,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowRight,
|
||||
Flame,
|
||||
Footprints,
|
||||
HeartPulse,
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
|
||||
@@ -157,12 +160,12 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
|
||||
return (
|
||||
<Card data-slot="workout-detail-stats">
|
||||
<CardHeader>
|
||||
<h2
|
||||
data-slot="card-title"
|
||||
className="text-base leading-none font-semibold"
|
||||
>
|
||||
{t("insights.workouts.detail.statsTitle")}
|
||||
</h2>
|
||||
{/* Icon-less `TileHeader`, not a hand-rolled h2 — the standards
|
||||
allow exactly three header patterns and this card is a tile. */}
|
||||
<TileHeader
|
||||
title={t("insights.workouts.detail.statsTitle")}
|
||||
titleAs="h2"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div
|
||||
@@ -175,14 +178,32 @@ export function WorkoutDetailStats({ workout }: WorkoutDetailStatsProps) {
|
||||
<StatTile key={tile.label} {...tile} />
|
||||
))}
|
||||
</div>
|
||||
{averageLine ? (
|
||||
<p
|
||||
data-slot="workout-detail-sport-average"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("insights.workouts.detail.sportAverageLabel")}{" "}
|
||||
<span className="tabular-nums">{averageLine}</span>
|
||||
</p>
|
||||
{averageLine || workout.previousWorkoutId ? (
|
||||
// One row, per the inline-action shape: the sentence wraps
|
||||
// internally, the link stays beside it.
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{averageLine ? (
|
||||
<p
|
||||
data-slot="workout-detail-sport-average"
|
||||
className="text-muted-foreground min-w-0 flex-1 text-xs"
|
||||
>
|
||||
{t("insights.workouts.detail.sportAverageLabel")}{" "}
|
||||
<span className="tabular-nums">{averageLine}</span>
|
||||
</p>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1" />
|
||||
)}
|
||||
{workout.previousWorkoutId ? (
|
||||
<Link
|
||||
href={`/insights/workouts/${workout.previousWorkoutId}`}
|
||||
data-slot="workout-detail-previous-link"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex shrink-0 items-center gap-1.5 text-xs underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
{t("insights.workouts.detail.previousSessionLink")}
|
||||
<ArrowRight className="size-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -85,7 +85,7 @@ export function WorkoutDetailZones({ workout }: WorkoutDetailZonesProps) {
|
||||
{formatMinutes(z.seconds)}
|
||||
</span>
|
||||
{z.lowBpm != null ? (
|
||||
<span className="text-muted-foreground text-[10px] tabular-nums">
|
||||
<span className="text-muted-foreground text-2xs tabular-nums">
|
||||
{z.lowBpm}
|
||||
{z.highBpm != null ? `–${z.highBpm}` : "+"}
|
||||
</span>
|
||||
|
||||
@@ -335,7 +335,10 @@ export function AuthShell({
|
||||
the desktop bottom-6 anchor), so the last line of content
|
||||
can always scroll out from under the floating button.
|
||||
*/}
|
||||
<div className="mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6">
|
||||
<div
|
||||
data-slot="main-content-wrapper"
|
||||
className="mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
describeRejectedProfileField,
|
||||
resolveInitialTimezone,
|
||||
} from "../account-section-utils";
|
||||
|
||||
describe("describeRejectedProfileField", () => {
|
||||
const t = (key: string) => `translated:${key}`;
|
||||
|
||||
it("resolves a known field path to its existing form label", () => {
|
||||
const label = describeRejectedProfileField(
|
||||
[{ path: "gender", code: "invalid_value" }],
|
||||
t,
|
||||
);
|
||||
expect(label).toBe("translated:settings.gender");
|
||||
});
|
||||
|
||||
it("uses the first rejected field when several are present", () => {
|
||||
const label = describeRejectedProfileField(
|
||||
[
|
||||
{ path: "heightCm", code: "too_small" },
|
||||
{ path: "gender", code: "invalid_value" },
|
||||
],
|
||||
t,
|
||||
);
|
||||
expect(label).toBe("translated:settings.height");
|
||||
});
|
||||
|
||||
it("falls back to the raw path for a field this screen has no label for", () => {
|
||||
const label = describeRejectedProfileField(
|
||||
[{ path: "moodReminderEnabled", code: "invalid_type" }],
|
||||
t,
|
||||
);
|
||||
expect(label).toBe("moodReminderEnabled");
|
||||
});
|
||||
|
||||
it("returns null for an empty or missing list", () => {
|
||||
expect(describeRejectedProfileField([], t)).toBeNull();
|
||||
expect(describeRejectedProfileField(undefined, t)).toBeNull();
|
||||
});
|
||||
|
||||
// Regression guard for the underlying bug: this helper is the only
|
||||
// thing account-section renders after a rejection, so it must never
|
||||
// pass a raw Zod `message` (which could carry enum literal syntax)
|
||||
// straight through — only `path`, resolved to a pre-existing label.
|
||||
it("never echoes the issue's own `message`, even when one is present", () => {
|
||||
const label = describeRejectedProfileField(
|
||||
[
|
||||
{
|
||||
path: "gender",
|
||||
code: "invalid_value",
|
||||
message: 'Invalid option: expected one of "MALE"|"FEMALE"|"OTHER"',
|
||||
},
|
||||
],
|
||||
t,
|
||||
);
|
||||
expect(label).not.toMatch(/expected one of/i);
|
||||
expect(label).toBe("translated:settings.gender");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveInitialTimezone", () => {
|
||||
it("keeps an explicitly stored non-default zone", () => {
|
||||
expect(resolveInitialTimezone("America/New_York", "Europe/Berlin")).toBe(
|
||||
"America/New_York",
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-seeds the browser zone when the stored value is still the default", () => {
|
||||
expect(resolveInitialTimezone("Europe/Berlin", "Asia/Tokyo")).toBe(
|
||||
"Asia/Tokyo",
|
||||
);
|
||||
});
|
||||
|
||||
it("stays on the default when the browser zone is also the default", () => {
|
||||
expect(resolveInitialTimezone("Europe/Berlin", "Europe/Berlin")).toBe(
|
||||
"Europe/Berlin",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the default when nothing is stored", () => {
|
||||
expect(resolveInitialTimezone(null, "Europe/Berlin")).toBe("Europe/Berlin");
|
||||
});
|
||||
});
|
||||
@@ -43,8 +43,13 @@ export function resolveInitialTimezone(
|
||||
/**
|
||||
* v1.16.4 — settings status hints store the i18n KEY (+ params), not
|
||||
* the translated string: a locale switch re-renders the hint in the
|
||||
* new language instead of freezing the old-language snapshot. Server-
|
||||
* provided error text (which has no key) rides `text` verbatim.
|
||||
* new language instead of freezing the old-language snapshot. The
|
||||
* `text` variant rides a server string verbatim — reserved for
|
||||
* endpoints whose message is already a specific, hand-curated,
|
||||
* person-safe sentence (e.g. the timezone endpoint's own IANA
|
||||
* validation text). It must never carry a generic validator message;
|
||||
* the profile save path resolves `meta.errorCode` into a `key` instead
|
||||
* so nothing server-generated reaches this screen untranslated.
|
||||
*/
|
||||
export type StatusMessage =
|
||||
{ key: string; params?: Record<string, string | number> } | { text: string };
|
||||
@@ -55,3 +60,45 @@ export function statusText(
|
||||
): string {
|
||||
return "key" in msg ? t(msg.key, msg.params) : msg.text;
|
||||
}
|
||||
|
||||
/** A single rejected-field entry from `applyProfileUpdate`'s wire shape
|
||||
* (`details.issues` on failure, `data.rejectedFields` on a partial
|
||||
* success) — `path` is the schema field name, never shown verbatim. */
|
||||
export interface RejectedProfileField {
|
||||
path: string;
|
||||
code: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a rejected field's schema `path` to the same i18n label already
|
||||
* shown next to that input on this screen. The server only ever knows
|
||||
* the field by its schema key (`heightCm`, `gender`, ...) — naming it
|
||||
* in a person-facing sentence is the client's job, using labels that
|
||||
* already exist and are already localized for this form.
|
||||
*/
|
||||
const PROFILE_FIELD_LABEL_KEYS: Record<string, string> = {
|
||||
email: "auth.email",
|
||||
heightCm: "settings.height",
|
||||
dateOfBirth: "settings.dateOfBirth",
|
||||
gender: "settings.gender",
|
||||
fullName: "settings.identity.fullName",
|
||||
insurerName: "settings.identity.insurer",
|
||||
insuranceNumber: "settings.identity.insuranceNumber",
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders the first rejected field's label, falling back to its raw
|
||||
* schema key for a field this screen has no input for (defensive —
|
||||
* every field `applyProfileUpdate` accepts from this form is mapped
|
||||
* above).
|
||||
*/
|
||||
export function describeRejectedProfileField(
|
||||
fields: RejectedProfileField[] | undefined,
|
||||
t: (key: string) => string,
|
||||
): string | null {
|
||||
const first = fields?.[0];
|
||||
if (!first) return null;
|
||||
const labelKey = PROFILE_FIELD_LABEL_KEYS[first.path];
|
||||
return labelKey ? t(labelKey) : first.path;
|
||||
}
|
||||
@@ -38,8 +38,10 @@ import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card";
|
||||
import { detectBrowserTimezone } from "@/lib/tz/format";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import {
|
||||
describeRejectedProfileField,
|
||||
resolveInitialTimezone,
|
||||
statusText,
|
||||
type RejectedProfileField,
|
||||
type StatusMessage,
|
||||
} from "./account-section-utils";
|
||||
import { AvatarSection } from "./avatar-section";
|
||||
@@ -85,9 +87,9 @@ export function AccountSection() {
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveMsg, setSaveMsg] = useState<StatusMessage | null>(null);
|
||||
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
const [saveMsgType, setSaveMsgType] = useState<
|
||||
"success" | "warning" | "error" | null
|
||||
>(null);
|
||||
|
||||
// Password change dialog state.
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
@@ -227,14 +229,34 @@ export function AccountSection() {
|
||||
if (user && timezone && timezone !== user.timezone) {
|
||||
storeTimezone(timezone);
|
||||
}
|
||||
setSaveMsg({ key: "settings.profileSaved" });
|
||||
setSaveMsgType("success");
|
||||
setEmail(savedProfile.email);
|
||||
setFullName(savedProfile.fullName);
|
||||
setInsurerName(savedProfile.insurerName);
|
||||
setInsuranceNumber(savedProfile.insuranceNumber);
|
||||
setProfileSeed(savedProfile);
|
||||
await refetch();
|
||||
const profileJson = (await profileRes.json().catch(() => null)) as {
|
||||
data?: { rejectedFields?: RejectedProfileField[] };
|
||||
} | null;
|
||||
const rejected = profileJson?.data?.rejectedFields;
|
||||
|
||||
if (rejected && rejected.length > 0) {
|
||||
// The write is field-independent: everything but the rejected
|
||||
// field(s) landed. Name the field instead of implying full
|
||||
// success, and reseed the form from what the server actually
|
||||
// persisted — the client's optimistic value for the rejected
|
||||
// field was never written, so it must not read as saved.
|
||||
setSaveMsg({
|
||||
key: "settings.profilePartiallySaved",
|
||||
params: { field: describeRejectedProfileField(rejected, t) ?? "" },
|
||||
});
|
||||
setSaveMsgType("warning");
|
||||
await refetch();
|
||||
setSeededUserId(null);
|
||||
} else {
|
||||
setSaveMsg({ key: "settings.profileSaved" });
|
||||
setSaveMsgType("success");
|
||||
setEmail(savedProfile.email);
|
||||
setFullName(savedProfile.fullName);
|
||||
setInsurerName(savedProfile.insurerName);
|
||||
setInsuranceNumber(savedProfile.insuranceNumber);
|
||||
setProfileSeed(savedProfile);
|
||||
await refetch();
|
||||
}
|
||||
} else if (!tzRes.ok) {
|
||||
// The dedicated tz endpoint owns the IANA validation error
|
||||
// text. Surface its message verbatim so the user sees
|
||||
@@ -252,10 +274,22 @@ export function AccountSection() {
|
||||
}
|
||||
setSaveMsgType("error");
|
||||
} else {
|
||||
const json = await profileRes.json();
|
||||
setSaveMsg(
|
||||
json.error ? { text: json.error } : { key: "settings.savingError" },
|
||||
);
|
||||
// The server's top-level `error` string is intentionally generic
|
||||
// (never a raw validator message); resolve the specific, localized
|
||||
// reason through `meta.errorCode` instead of echoing it verbatim.
|
||||
const json = (await profileRes.json().catch(() => null)) as {
|
||||
meta?: { errorCode?: string };
|
||||
details?: { issues?: RejectedProfileField[] };
|
||||
} | null;
|
||||
const errorCode = json?.meta?.errorCode;
|
||||
const field = describeRejectedProfileField(json?.details?.issues, t);
|
||||
if (errorCode === "profile.update.nothingSaved" && field) {
|
||||
setSaveMsg({ key: "settings.profileNothingSaved", params: { field } });
|
||||
} else if (errorCode) {
|
||||
setSaveMsg({ key: `apiErrors.${errorCode}` });
|
||||
} else {
|
||||
setSaveMsg({ key: "settings.savingError" });
|
||||
}
|
||||
setSaveMsgType("error");
|
||||
}
|
||||
setSaving(false);
|
||||
@@ -512,7 +546,11 @@ export function AccountSection() {
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${
|
||||
saveMsgType === "success" ? "text-success" : "text-destructive"
|
||||
saveMsgType === "success"
|
||||
? "text-success"
|
||||
: saveMsgType === "warning"
|
||||
? "text-warning"
|
||||
: "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{statusText(saveMsg, t)}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { CalendarHeart, Info } from "lucide-react";
|
||||
import { CalendarHeart } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -79,21 +79,6 @@ export function CycleTrackingCard({
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
</div>
|
||||
{/* The single, prominent non-medical disclaimer for the whole cycle
|
||||
vertical. It lives here at the on-ramp — where expectations are
|
||||
set — instead of repeating beneath every prediction surface. */}
|
||||
<div
|
||||
data-slot="cycle-tracking-disclaimer"
|
||||
className="border-border bg-muted/40 flex items-start gap-2.5 rounded-lg border-l-2 p-3"
|
||||
>
|
||||
<Info
|
||||
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-foreground/80 text-xs leading-relaxed">
|
||||
{t("settings.cycleTracking.disclaimer")}
|
||||
</p>
|
||||
</div>
|
||||
{enabled ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* The Card header reserves its second row only when there is one to fill.
|
||||
*
|
||||
* `CardHeader` is a grid. It used to declare `grid-rows-[auto_auto]`
|
||||
* unconditionally, so a card with a title and no `CardDescription` carried an
|
||||
* empty second track and drew the header's `gap-2` against it. Together with the
|
||||
* Card's own `gap-4 md:gap-6` that made the distance from a heading to its body
|
||||
* 32 px on desktop where 24 px was intended. It was visible, it was reported, and
|
||||
* it affected 45 of the 48 files that mount a `CardHeader`.
|
||||
*
|
||||
* Two class declarations have to agree for the fix to hold:
|
||||
*
|
||||
* 1. the second row is conditional on a description being present, and
|
||||
* 2. `CardAction` spans the full row set rather than a hard-coded two.
|
||||
*
|
||||
* The second one is the trap. `row-span-2` against a single explicit row mints
|
||||
* an implicit second row, which brings the gap straight back for every header
|
||||
* that carries an action but no description. Reverting it produces no test
|
||||
* failure anywhere else in the suite, because nothing asserts a `row-span`
|
||||
* class: the regression is DOM geometry, which the unit tests cannot see.
|
||||
*
|
||||
* So this file is the guard. It is deliberately a literal-class assertion, the
|
||||
* same shape `assessment-spine-order.test.ts` already uses, because the question
|
||||
* has an exact answer and an exact answer belongs in a gate rather than in a
|
||||
* reviewer's memory.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SOURCE = readFileSync(
|
||||
join(process.cwd(), "src/components/ui/card.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("CardHeader row reservation", () => {
|
||||
it("reserves the second row only when a description is present", () => {
|
||||
expect(SOURCE).toContain(
|
||||
"has-data-[slot=card-description]:grid-rows-[auto_auto]",
|
||||
);
|
||||
});
|
||||
|
||||
it("never declares the second row unconditionally", () => {
|
||||
// The unconditional form is what drew the phantom gap. It may only ever
|
||||
// appear behind the `has-data-[slot=card-description]:` variant, so the bare
|
||||
// token must not occur on its own.
|
||||
const bare = SOURCE.replace(
|
||||
/has-data-\[slot=card-description\]:grid-rows-\[auto_auto\]/g,
|
||||
"",
|
||||
);
|
||||
expect(bare).not.toContain("grid-rows-[auto_auto]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CardAction row span", () => {
|
||||
it("spans the full row set rather than a hard-coded two", () => {
|
||||
// `row-span-2` would mint an implicit second row in a description-less
|
||||
// header and resurrect the gap the header fix just removed.
|
||||
expect(SOURCE).toContain("row-span-full");
|
||||
expect(SOURCE).not.toContain("row-span-2");
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] md:px-6 [.border-b]:pb-4 md:[.border-b]:pb-6",
|
||||
"@container/card-header grid auto-rows-min items-start gap-2 px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] md:px-6 [.border-b]:pb-4 md:[.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -66,7 +66,7 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
"col-start-2 row-span-full row-start-1 self-start justify-self-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* Every mood entry a person logged on ONE local day, from
|
||||
* `GET /api/mood-entries?from=<day>&to=<day>`.
|
||||
*
|
||||
* The route filters on the mood `date` column, which already IS the local
|
||||
* day key, so no timezone maths happens here. Entries come back newest
|
||||
* first (`sortBy=moodLoggedAt`, `sortDir=desc`, the route's defaults).
|
||||
*/
|
||||
export interface MoodDayEntry {
|
||||
id: string;
|
||||
date: string;
|
||||
/** The mood enum, e.g. `GUT`. Rendered through `MOOD_LABEL_KEYS`. */
|
||||
mood: string;
|
||||
score: number;
|
||||
note: string | null;
|
||||
source: string;
|
||||
moodLoggedAt: string;
|
||||
}
|
||||
|
||||
interface MoodDayResponse {
|
||||
entries: MoodDayEntry[];
|
||||
meta: { total: number };
|
||||
}
|
||||
|
||||
export interface UseMoodDayResult {
|
||||
/** `undefined` until the route has answered; `[]` means the day really
|
||||
* carried no entry. */
|
||||
entries: MoodDayEntry[] | undefined;
|
||||
isError: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one day's mood entries.
|
||||
*
|
||||
* `enabled` is the caller's gate — pass `false` when the user is signed
|
||||
* out, and nothing is fetched. Mood has no module gate, so there is
|
||||
* nothing else to check.
|
||||
*/
|
||||
export function useMoodDay(
|
||||
date: string | undefined,
|
||||
enabled: boolean,
|
||||
): UseMoodDayResult {
|
||||
const { data, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.moodEntriesDay(date ?? ""),
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({
|
||||
from: date as string,
|
||||
to: date as string,
|
||||
limit: "50",
|
||||
});
|
||||
return apiGet<MoodDayResponse>(`/api/mood-entries?${params}`);
|
||||
},
|
||||
enabled: enabled && Boolean(date),
|
||||
staleTime: 5 * 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return {
|
||||
entries: data?.entries,
|
||||
isError,
|
||||
refetch: () => void refetch(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* One reconstructed night from `GET /api/sleep/night?date=YYYY-MM-DD`.
|
||||
*
|
||||
* The date is the WAKE day: the night a person woke from into that day.
|
||||
* The server owns the reconstruction (stage folding, cross-source pick,
|
||||
* WHOOP timeline synthesis) and the day keying, so this hook only carries
|
||||
* the resolved figures across.
|
||||
*
|
||||
* The route is gated on the `sleep` module and answers 403 when it is off.
|
||||
* Callers gate the mount on `useModuleEnabled("sleep")` so a switched-off
|
||||
* module never paints as a failed read.
|
||||
*/
|
||||
export interface SleepNightSegment {
|
||||
stage: string;
|
||||
start: string;
|
||||
end: string;
|
||||
minutes: number;
|
||||
}
|
||||
|
||||
export interface SleepNightSession {
|
||||
/** `YYYY-MM-DD` wake-day key. */
|
||||
night: string;
|
||||
source: string | null;
|
||||
start: string;
|
||||
end: string;
|
||||
asleepMinutes: number;
|
||||
inBedMinutes: number | null;
|
||||
awakeMinutes: number | null;
|
||||
/** Mid-sleep awakenings. `0` is a real answer, not an absence. */
|
||||
awakenings: number;
|
||||
reconstructed: boolean;
|
||||
stages: Record<string, number>;
|
||||
segments: SleepNightSegment[];
|
||||
}
|
||||
|
||||
export interface SleepNightPayload {
|
||||
night: string | null;
|
||||
/** `null` when nothing scorable was recorded for that night. */
|
||||
main: SleepNightSession | null;
|
||||
naps: SleepNightSession[];
|
||||
}
|
||||
|
||||
export interface UseSleepNightResult {
|
||||
/** `undefined` until the route has answered — never confuse it with a
|
||||
* night that carried nothing. */
|
||||
data: SleepNightPayload | undefined;
|
||||
isError: boolean;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the night a person woke from into `date`.
|
||||
*
|
||||
* `enabled` is the caller's gate — pass `false` when the user is signed
|
||||
* out or the sleep module is disabled, and nothing is fetched.
|
||||
*/
|
||||
export function useSleepNight(
|
||||
date: string | undefined,
|
||||
enabled: boolean,
|
||||
): UseSleepNightResult {
|
||||
const { data, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.sleepNight(date),
|
||||
queryFn: () =>
|
||||
apiGet<SleepNightPayload>(
|
||||
`/api/sleep/night?date=${encodeURIComponent(date as string)}`,
|
||||
),
|
||||
enabled: enabled && Boolean(date),
|
||||
staleTime: 5 * 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return { data, isError, refetch: () => void refetch() };
|
||||
}
|
||||
@@ -290,6 +290,14 @@ export type WorkoutActivityInsight = {
|
||||
} | null;
|
||||
|
||||
export interface WorkoutDetailPayload extends WorkoutListEntry {
|
||||
/**
|
||||
* The session's local calendar day, `YYYY-MM-DD`. Computed server-side
|
||||
* from `startedAt` in the user's timezone — never re-derived here, so a
|
||||
* browser in a different zone can't move the workout to another day. It
|
||||
* addresses the day-scoped reads the detail page renders around the
|
||||
* session.
|
||||
*/
|
||||
dayKey: string;
|
||||
minHr: number | null;
|
||||
stepCount: number | null;
|
||||
elevationM: number | null;
|
||||
@@ -311,6 +319,12 @@ export interface WorkoutDetailPayload extends WorkoutListEntry {
|
||||
sportContext: WorkoutSportContextDto | null;
|
||||
aiInsight: WorkoutActivityInsight;
|
||||
canonicalId: string;
|
||||
/**
|
||||
* The canonical session before this one in the same sport, `null` when
|
||||
* this is the first on record. Server-resolved through the same picker
|
||||
* the list uses, so the link never lands on a duplicate row.
|
||||
*/
|
||||
previousWorkoutId: string | null;
|
||||
}
|
||||
|
||||
async function fetchWorkoutDetail(id: string): Promise<WorkoutDetailPayload> {
|
||||
|
||||
@@ -175,6 +175,32 @@ export function returnAllZodIssues(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link returnAllZodIssues}, but for callers that have already
|
||||
* reduced a `ZodError` to a caller-composed, person-safe top-level
|
||||
* `error` sentence and a pre-sanitised issue list (e.g. a shared
|
||||
* write-path helper that validates outside the route handler, such as
|
||||
* `applyProfileUpdate`). The multi-issue envelope (`details.issues`) is
|
||||
* unchanged from `returnAllZodIssues` — this exists so the top-level
|
||||
* string never has to repeat a raw Zod default message (an enum's
|
||||
* `Invalid option: expected one of "A"|"B"|"C"` reaching a person's
|
||||
* screen) while the machine-readable detail is still relocated, not
|
||||
* dropped. `meta.errorCode` lets the web client resolve a localized
|
||||
* sentence via `apiErrors.<errorCode>` (see `localizedApiError`).
|
||||
*/
|
||||
export function apiValidationError(
|
||||
message: string,
|
||||
issues: SanitisedZodIssue[],
|
||||
status: number = 422,
|
||||
meta?: ErrorMeta,
|
||||
): NextResponse {
|
||||
return buildJsonErrorResponse(
|
||||
{ data: null, error: message, details: { issues } },
|
||||
status,
|
||||
meta,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `meta` is additive — clients that ignore it see the unchanged
|
||||
* `{ data: null, error: <string> }` envelope. New callers can pass
|
||||
|
||||
@@ -45,14 +45,25 @@ describe("applyProfileUpdate timezone validation", () => {
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects an unknown zone with 422", async () => {
|
||||
it("rejects an unknown zone with 422, naming the field without echoing raw validator prose", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, {
|
||||
timezone: "Mars/Tharsis",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(422);
|
||||
expect(result.message).toMatch(/Invalid IANA timezone/i);
|
||||
// The specific validator reason is relocated to `issues`, not deleted.
|
||||
expect(result.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
path: "timezone",
|
||||
message: expect.stringMatching(/Invalid IANA timezone/i),
|
||||
}),
|
||||
]);
|
||||
// The top-level message stays a sentence for a person: it names the
|
||||
// field but never repeats the validator's own prose.
|
||||
expect(result.message).toMatch(/nothing was saved/i);
|
||||
expect(result.message).toMatch(/timezone/i);
|
||||
expect(result.message).not.toMatch(/Invalid IANA timezone/i);
|
||||
}
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -126,14 +137,20 @@ describe("applyProfileUpdate insurer IK number", () => {
|
||||
expect("insurerIkNumber" in arg.data).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a non-numeric IKNR with 422", async () => {
|
||||
it("rejects a non-numeric IKNR with 422, relocating the reason to issues", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, {
|
||||
insurerIkNumber: "12345678X",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(422);
|
||||
expect(result.message).toMatch(/IK number/i);
|
||||
expect(result.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
path: "insurerIkNumber",
|
||||
message: expect.stringMatching(/IK number/i),
|
||||
}),
|
||||
]);
|
||||
expect(result.message).toMatch(/nothing was saved/i);
|
||||
}
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -150,3 +167,118 @@ describe("applyProfileUpdate insurer IK number", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyProfileUpdate partial-field save", () => {
|
||||
// Reported regression: the native client sent a lowercase `gender`
|
||||
// alongside a valid `heightCm` in one PUT. Because the whole body was
|
||||
// one `safeParse` and one transaction, the gender rejection took the
|
||||
// height edit down with it — "editing height and gender together lost
|
||||
// both". The profile schema has no cross-field dependency, so the
|
||||
// fix is to salvage every field that DOES validate.
|
||||
it("writes the valid sibling field when another field in the same body is invalid", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, {
|
||||
heightCm: 180,
|
||||
gender: "male", // wrong case — the client-side half of the bug
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.rejectedFields).toEqual([
|
||||
expect.objectContaining({ path: "gender" }),
|
||||
]);
|
||||
}
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
const arg = vi.mocked(prisma.user.update).mock.calls[0][0] as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(arg.data.heightCm).toBe(180);
|
||||
expect("gender" in arg.data).toBe(false);
|
||||
});
|
||||
|
||||
it("omits `rejectedFields` entirely when every touched field is valid", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, {
|
||||
heightCm: 175,
|
||||
gender: "FEMALE",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.rejectedFields).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("writes nothing and reports every rejected field when the whole body is invalid", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, {
|
||||
gender: "male",
|
||||
locale: "fr",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(422);
|
||||
const paths = result.issues?.map((i) => i.path).sort();
|
||||
expect(paths).toEqual(["gender", "locale"]);
|
||||
expect(result.message).toMatch(/nothing was saved/i);
|
||||
}
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyProfileUpdate top-level error message", () => {
|
||||
// The `locale`/`timeFormat`/`dateFormat` enums carry no custom Zod
|
||||
// `error:` message, unlike `gender` — so an invalid value here is the
|
||||
// sharpest regression check available for "does the raw Zod default
|
||||
// ever reach the top-level string a person reads". Zod v4's default
|
||||
// enum message is shaped like:
|
||||
// Invalid option: expected one of "de"|"en"
|
||||
const RAW_ZOD_ENUM_PATTERN = /expected one of/i;
|
||||
|
||||
it("never lets the raw Zod enum message reach the top-level `message`", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, { locale: "fr" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.message).not.toMatch(RAW_ZOD_ENUM_PATTERN);
|
||||
expect(result.message).not.toContain('"de"|"en"');
|
||||
// Relocated, not deleted: the same detail is still available for
|
||||
// whoever needs it (the iOS `details.issues` contract), just not
|
||||
// in the string a person reads first.
|
||||
expect(result.issues?.[0]?.path).toBe("locale");
|
||||
}
|
||||
});
|
||||
|
||||
it("never lets the raw Zod enum message reach `message` for timeFormat either", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, {
|
||||
timeFormat: "12h",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.message).not.toMatch(RAW_ZOD_ENUM_PATTERN);
|
||||
expect(result.issues?.[0]?.path).toBe("timeFormat");
|
||||
}
|
||||
});
|
||||
|
||||
it("carries a stable errorCode so the web client can localize the sentence", async () => {
|
||||
const result = await applyProfileUpdate(USER_ID, { locale: "fr" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errorCode).toBe("profile.update.nothingSaved");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyProfileUpdate email conflict", () => {
|
||||
it("carries an errorCode alongside the specific 409 message", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
id: "someone-else",
|
||||
} as never);
|
||||
|
||||
const result = await applyProfileUpdate(USER_ID, {
|
||||
email: "taken@example.com",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.status).toBe(409);
|
||||
expect(result.errorCode).toBe("profile.update.emailInUse");
|
||||
expect(result.issues).toBeUndefined();
|
||||
}
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+133
-11
@@ -12,11 +12,17 @@ import {
|
||||
invalidateUserMedications,
|
||||
invalidateUserProfile,
|
||||
} from "@/lib/cache/invalidate";
|
||||
import { sanitiseZodIssues, type SanitisedZodIssue } from "@/lib/api-response";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const extendedProfileSchema = profileSchema.extend({
|
||||
displayName: z.string().min(1).max(80).nullable().optional(),
|
||||
locale: z.enum(["de", "en"]).nullable().optional(),
|
||||
locale: z
|
||||
.enum(["de", "en"], {
|
||||
error: "Locale must be de or en.",
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
timezone: z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -26,12 +32,58 @@ const extendedProfileSchema = profileSchema.extend({
|
||||
moodReminderEnabled: z.boolean().optional(),
|
||||
// Hour-cycle display preference. AUTO follows the locale convention,
|
||||
// H12 forces AM/PM, H24 forces 24-hour.
|
||||
timeFormat: z.enum(["AUTO", "H12", "H24"]).optional(),
|
||||
timeFormat: z
|
||||
.enum(["AUTO", "H12", "H24"], {
|
||||
error: "Time format must be AUTO, H12 or H24.",
|
||||
})
|
||||
.optional(),
|
||||
// Date-order display preference. AUTO follows the locale convention,
|
||||
// DMY pins day-month-year, MDY month-day-year, YMD ISO yyyy-MM-dd.
|
||||
dateFormat: z.enum(["AUTO", "DMY", "MDY", "YMD"]).optional(),
|
||||
dateFormat: z
|
||||
.enum(["AUTO", "DMY", "MDY", "YMD"], {
|
||||
error: "Date format must be AUTO, DMY, MDY or YMD.",
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type ExtendedProfileInput = z.infer<typeof extendedProfileSchema>;
|
||||
|
||||
/**
|
||||
* The profile schema has no cross-field `.refine()` — every field is an
|
||||
* independent scalar column. That means a rejected `gender` has no
|
||||
* bearing on whether `heightCm` is safe to write, so a single bad field
|
||||
* no longer has to take every sibling change down with it (the report:
|
||||
* "editing height and gender together lost both").
|
||||
*
|
||||
* Re-validates each key present in `body` against its own field schema
|
||||
* (`extendedProfileSchema.shape[key]`), independent of the others.
|
||||
* Fields that pass land in `valid`; fields that fail land in `issues`
|
||||
* with `path` set to the field name (a field-level `safeParse` reports
|
||||
* an empty path, since it never saw the parent object). Unknown keys
|
||||
* are silently dropped, matching `z.object`'s own default behaviour.
|
||||
*/
|
||||
function splitValidProfileFields(body: Record<string, unknown>): {
|
||||
valid: Partial<ExtendedProfileInput>;
|
||||
issues: SanitisedZodIssue[];
|
||||
} {
|
||||
const shape = extendedProfileSchema.shape as Record<string, z.ZodTypeAny>;
|
||||
const valid: Record<string, unknown> = {};
|
||||
const issues: SanitisedZodIssue[] = [];
|
||||
for (const key of Object.keys(body)) {
|
||||
const fieldSchema = shape[key];
|
||||
if (!fieldSchema) continue;
|
||||
const result = fieldSchema.safeParse(body[key]);
|
||||
if (result.success) {
|
||||
valid[key] = result.data;
|
||||
} else {
|
||||
for (const issue of result.error.issues) {
|
||||
issues.push({ path: key, code: issue.code, message: issue.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { valid: valid as Partial<ExtendedProfileInput>, issues };
|
||||
}
|
||||
|
||||
export interface ApplyProfileResult {
|
||||
ok: true;
|
||||
user: {
|
||||
@@ -58,18 +110,53 @@ export interface ApplyProfileResult {
|
||||
insurerIkNumber: string | null;
|
||||
hasInsuranceNumber: boolean;
|
||||
};
|
||||
/**
|
||||
* Present only when the caller sent at least one invalid field
|
||||
* alongside at least one valid one. The fields named here were NOT
|
||||
* written — everything absent from this list landed. Empty/undefined
|
||||
* means every touched field saved cleanly. The web client resolves
|
||||
* `path` against its own field labels to name the rejected field(s);
|
||||
* the message stays machine-readable, never shown verbatim.
|
||||
*/
|
||||
rejectedFields?: SanitisedZodIssue[];
|
||||
}
|
||||
|
||||
export interface ApplyProfileError {
|
||||
ok: false;
|
||||
status: number;
|
||||
/**
|
||||
* A sentence safe for a person to read — never a raw Zod message (no
|
||||
* enum literals, no validator prose). Native/non-i18n callers that
|
||||
* ignore `errorCode` still get something a person can act on.
|
||||
*/
|
||||
message: string;
|
||||
/**
|
||||
* Stable code the web client resolves through `apiErrors.<errorCode>`
|
||||
* for a localized sentence (see `localizedApiError`). Absent for
|
||||
* non-validation failures (e.g. the 409 email-conflict below) that
|
||||
* already carry a safe, specific message.
|
||||
*/
|
||||
errorCode?: string;
|
||||
/**
|
||||
* Full per-field detail, relocated (not deleted) from the top-level
|
||||
* `message`. Matches the `details.issues` multi-issue envelope the
|
||||
* iOS contract already expects from other write routes.
|
||||
*/
|
||||
issues?: SanitisedZodIssue[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and persist profile updates for `userId`. Returns either an
|
||||
* `ApplyProfileResult` on success or `ApplyProfileError` with a status
|
||||
* code so callers can shape the wire response themselves.
|
||||
*
|
||||
* The profile schema has no cross-field dependency, so an invalid field
|
||||
* never has to block its valid siblings: a full-object parse is tried
|
||||
* first (the common case), and only on failure does the update fall
|
||||
* back to `splitValidProfileFields` to salvage whatever validated. If
|
||||
* NOTHING in the payload validates, nothing is written — an honest
|
||||
* all-or-nothing rejection naming the field that blocked it, rather
|
||||
* than a raw validator string.
|
||||
*/
|
||||
export async function applyProfileUpdate(
|
||||
userId: string,
|
||||
@@ -77,15 +164,44 @@ export async function applyProfileUpdate(
|
||||
ipAddress?: string | null,
|
||||
): Promise<ApplyProfileResult | ApplyProfileError> {
|
||||
const parsed = extendedProfileSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 422,
|
||||
message: parsed.error.issues[0]?.message ?? "Validation error",
|
||||
};
|
||||
|
||||
let data: Partial<ExtendedProfileInput>;
|
||||
let rejectedFields: SanitisedZodIssue[] = [];
|
||||
|
||||
if (parsed.success) {
|
||||
data = parsed.data;
|
||||
} else {
|
||||
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 422,
|
||||
message: "Nothing was saved. The profile update was not an object.",
|
||||
errorCode: "profile.update.invalidBody",
|
||||
issues: sanitiseZodIssues(parsed.error.issues),
|
||||
};
|
||||
}
|
||||
|
||||
const split = splitValidProfileFields(body as Record<string, unknown>);
|
||||
data = split.valid;
|
||||
rejectedFields = split.issues;
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
// Every touched field failed — there is nothing to salvage.
|
||||
// Name the field that blocked it so the person fixes one thing
|
||||
// instead of re-entering everything.
|
||||
const first = rejectedFields[0];
|
||||
return {
|
||||
ok: false,
|
||||
status: 422,
|
||||
message: first
|
||||
? `Nothing was saved. Fix the "${first.path}" field and try again.`
|
||||
: "Nothing was saved. The submitted profile data was invalid.",
|
||||
errorCode: "profile.update.nothingSaved",
|
||||
issues: rejectedFields,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
const normalizedEmail = data.email ? data.email.trim().toLowerCase() : null;
|
||||
|
||||
if (data.email !== undefined && normalizedEmail) {
|
||||
@@ -94,7 +210,12 @@ export async function applyProfileUpdate(
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing && existing.id !== userId) {
|
||||
return { ok: false, status: 409, message: "Email already in use" };
|
||||
return {
|
||||
ok: false,
|
||||
status: 409,
|
||||
message: "Email already in use",
|
||||
errorCode: "profile.update.emailInUse",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,5 +339,6 @@ export async function applyProfileUpdate(
|
||||
insurerIkNumber: updatedUser.insurerIkNumber,
|
||||
hasInsuranceNumber: updatedUser.insuranceNumberEncrypted != null,
|
||||
},
|
||||
...(rejectedFields.length > 0 ? { rejectedFields } : {}),
|
||||
};
|
||||
}
|
||||
@@ -26,6 +26,12 @@ const shareLinkSummary = z.object({
|
||||
// v1.28 — how many documents this share carries. Never the ids, never the
|
||||
// bytes; the share serve route is the only decrypt path.
|
||||
documentCount: z.number(),
|
||||
// v1.28.13 — the frozen documents-only flag, written once at creation and
|
||||
// never updated. True means the link carries NO report scope at all (the
|
||||
// create route forces `selection` empty server-side even if the body sent
|
||||
// one) and the share view renders only the frozen document set, never a
|
||||
// health-record section.
|
||||
documentOnly: z.boolean(),
|
||||
// Whether this link's frozen scope predates the selection model. Every such
|
||||
// link was revoked on upgrade; the sharing list surfaces this so the owner
|
||||
// knows to re-mint rather than wondering why a working link stopped.
|
||||
|
||||
@@ -52,7 +52,11 @@ import { ocrPaths } from "./ocr";
|
||||
import { measurementPaths } from "./measurements";
|
||||
import { measurementReminderPaths } from "./measurement-reminders";
|
||||
import { mentalHealthPaths } from "./mental-health";
|
||||
import { medicationPaths, medicationResource } from "./medications";
|
||||
import {
|
||||
medicationPaths,
|
||||
medicationResource,
|
||||
medicationDoseHistoryImportFatalReasonEnum,
|
||||
} from "./medications";
|
||||
import { metaPaths } from "./meta";
|
||||
import { moodPaths } from "./mood";
|
||||
import { nutrientPaths } from "./nutrients";
|
||||
@@ -61,6 +65,7 @@ import { profilePaths } from "./profile";
|
||||
import { settingsPaths } from "./settings";
|
||||
import { syncPaths } from "./sync";
|
||||
import { workoutPaths } from "./workouts";
|
||||
import { coachPrefsSchema } from "@/lib/validations/coach-prefs";
|
||||
|
||||
export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...cyclePaths,
|
||||
@@ -121,8 +126,24 @@ export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
|
||||
// registration the standalone `Medication` component would never
|
||||
// emit. The iOS codegen reads from `Medication` directly for the
|
||||
// shared Swift struct backing both variants.
|
||||
//
|
||||
// `MedicationDoseHistoryImportFatalReason` lives here for the same reason:
|
||||
// the dose-history-import 422 description names it as the closed set
|
||||
// behind `errorCode`, but `ErrorEnvelope.meta.errorCode` is a bare string
|
||||
// shared by every route, so nothing ever `$ref`s the enum itself. Without
|
||||
// this forced registration the schema the description points to would be
|
||||
// silently absent from the published spec.
|
||||
//
|
||||
// `CoachPrefs` is the same shape: the PUT `/api/auth/me/coach-prefs`
|
||||
// description tells the reader "Body is validated against `CoachPrefs`",
|
||||
// but every actual consumer is a `.extend()` of it (`PutCoachPrefsRequest`,
|
||||
// `CoachPrefsResponse`) — `.extend()` builds a new object, so the base
|
||||
// never carries forward to a $ref on its own.
|
||||
schemas: {
|
||||
Medication: medicationResource,
|
||||
MedicationDoseHistoryImportFatalReason:
|
||||
medicationDoseHistoryImportFatalReasonEnum,
|
||||
CoachPrefs: coachPrefsSchema,
|
||||
},
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
ecgListResponse,
|
||||
ecgDetailQuery,
|
||||
ecgDetailResponse,
|
||||
rhythmEventsResponse,
|
||||
} from "./schemas";
|
||||
|
||||
export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
@@ -492,6 +493,29 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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: dataEnvelope(
|
||||
rhythmEventsResponse,
|
||||
"RhythmEventsResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...moduleDisabledResponse,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/correlations": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
* runtime request parsing, so the wire contract stays single-source.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import {
|
||||
measurementTypeEnum,
|
||||
measurementSourceEnum,
|
||||
} from "@/lib/validations/measurement";
|
||||
import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry";
|
||||
import {
|
||||
DERIVED_METRIC_IDS,
|
||||
@@ -300,6 +303,16 @@ export const discoveredCorrelation = z
|
||||
.describe(
|
||||
"Benjamini-Hochberg FDR-adjusted q-value (≤ the surface threshold).",
|
||||
),
|
||||
shrunkR: z
|
||||
.number()
|
||||
.describe(
|
||||
"Sample-size-shrunk effect (r shrunk toward null by depth), used for ranking and the effect-size floor. Display still uses raw `r`.",
|
||||
),
|
||||
tier: z
|
||||
.enum(["high", "moderate", "faint"])
|
||||
.describe(
|
||||
"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: z
|
||||
.string()
|
||||
.describe("Conservative, descriptive interpretation — never causal."),
|
||||
@@ -893,6 +906,112 @@ export const dashboardSnapshotResponse = z
|
||||
.describe(
|
||||
"Latest reading per chartable metric, keyed by the iOS `MetricKind` raw value (the non-obvious raws: `oxygenSaturation`, `totalBodyWater`, `heartRateVariability`, `bodyMassIndex`, `walkingAsymmetryPercentage`, `walkingDoubleSupportPercentage`, `environmentalAudioExposure`, `headphoneAudioExposure`, `activeEnergyBurned`). Each entry carries `value`, `measuredAt` (ISO8601), and the canonical `unit`. Types the user has never logged are omitted.",
|
||||
),
|
||||
// v1.18.6 — server-computed band / target math (audit finding #3),
|
||||
// resolved from the user's own profile facts (DOB / gender / height /
|
||||
// weight-target override). The dashboard reads these instead of
|
||||
// recomputing client-side; always present (every sub-field falls back
|
||||
// to a neutral default rather than being omitted).
|
||||
targetBands: z
|
||||
.object({
|
||||
bpTargets: z
|
||||
.object({
|
||||
sysLow: z.number(),
|
||||
sysHigh: z.number(),
|
||||
diaLow: z.number(),
|
||||
diaHigh: z.number(),
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"Personalised blood-pressure target numbers. Null when the profile has no date of birth.",
|
||||
),
|
||||
bpSysRange: z
|
||||
.object({
|
||||
greenMin: z.number(),
|
||||
greenMax: z.number(),
|
||||
orangeMin: z.number(),
|
||||
orangeMax: z.number(),
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"Systolic traffic-light range. Null when the profile has no date of birth.",
|
||||
),
|
||||
bpDiaRange: z
|
||||
.object({
|
||||
greenMin: z.number(),
|
||||
greenMax: z.number(),
|
||||
orangeMin: z.number(),
|
||||
orangeMax: z.number(),
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"Diastolic traffic-light range. Null when the profile has no date of birth.",
|
||||
),
|
||||
pulseDisplayRange: z
|
||||
.object({
|
||||
greenMin: z.number(),
|
||||
greenMax: z.number(),
|
||||
orangeMin: z.number(),
|
||||
orangeMax: z.number(),
|
||||
})
|
||||
.describe(
|
||||
"Resting-pulse display range. Always present — falls back to the AHA reference range with no date of birth.",
|
||||
),
|
||||
pulseBands: z
|
||||
.array(
|
||||
z.object({
|
||||
min: z.number(),
|
||||
max: z.number(),
|
||||
color: z.string(),
|
||||
opacity: z.number().optional(),
|
||||
strokeOpacity: z.number().optional(),
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"Resting-pulse chart bands. Always present — AHA fallback with no date of birth.",
|
||||
),
|
||||
bodyFatRange: z
|
||||
.object({ min: z.number(), max: z.number() })
|
||||
.describe("Gender-aware body-fat target range. Always present."),
|
||||
bodyFatBands: z
|
||||
.array(
|
||||
z.object({
|
||||
min: z.number(),
|
||||
max: z.number(),
|
||||
color: z.string(),
|
||||
opacity: z.number().optional(),
|
||||
strokeOpacity: z.number().optional(),
|
||||
}),
|
||||
)
|
||||
.describe("Body-fat chart bands. Always present."),
|
||||
weightRange: z
|
||||
.object({
|
||||
greenMin: z.number(),
|
||||
greenMax: z.number(),
|
||||
orangeMin: z.number(),
|
||||
orangeMax: z.number(),
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"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: z
|
||||
.array(
|
||||
z.object({
|
||||
min: z.number(),
|
||||
max: z.number(),
|
||||
color: z.string(),
|
||||
opacity: z.number().optional(),
|
||||
strokeOpacity: z.number().optional(),
|
||||
}),
|
||||
)
|
||||
.nullable()
|
||||
.describe(
|
||||
"Weight chart bands, from the same range. Null when `weightRange` is null.",
|
||||
),
|
||||
})
|
||||
.describe(
|
||||
"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: z.object({
|
||||
summaries: dataSummaryRecord,
|
||||
lastSeenByType: z.record(z.string(), z.unknown()),
|
||||
@@ -1023,6 +1142,38 @@ export const dashboardSnapshotResponse = z
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
// v1.21.2 (A5) — the Tension Verdict, locale-agnostic. Fires only
|
||||
// when the readiness composite's contributors disagree (>=1
|
||||
// strongly favourable AND >=1 strongly unfavourable); suppressed
|
||||
// (null) under a clinical red-flag so that path dominates.
|
||||
tension: z
|
||||
.object({
|
||||
band: z.enum(["green", "yellow", "red"]),
|
||||
positive: z.array(
|
||||
z.enum(["rhr", "hrv", "sleep", "respiratory", "mood"]),
|
||||
),
|
||||
negative: z.array(
|
||||
z.enum(["rhr", "hrv", "sleep", "respiratory", "mood"]),
|
||||
),
|
||||
})
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"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.",
|
||||
),
|
||||
// v1.21.2 (A6) — return-to-baseline, locale-agnostic. Present only
|
||||
// when a salient metric has come back inside the user's own
|
||||
// personal range after a prior out-of-band run; at most one.
|
||||
returnToBand: z
|
||||
.object({
|
||||
metricType: measurementTypeEnum,
|
||||
daysInside: z.number().int(),
|
||||
})
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"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.",
|
||||
),
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
@@ -1058,6 +1209,19 @@ export const dashboardSnapshotResponse = z
|
||||
"User-selected hero score rings (max 3, `selectedScoreRings` on the dashboard layout), resolved server-side: READINESS / RECOVERY_SCORE / SLEEP_SCORE via the derived engines (module-gated like `/api/insights/derived`); MED_COMPLIANCE is TODAY's dose progress off the snapshot's medsToday tally — `score` is the rounded 0..100 progress, `doses` carries the taken/scheduled pair, the band is progress semantics (green once every scheduled dose is taken, yellow while doses remain, never red), and the ring is absent when no dose is scheduled today. Only rings with data appear, in selection order — a missing entry means no data or a disabled module, never zero. Clients render what arrives and never recompute.",
|
||||
),
|
||||
briefing: z.record(z.string(), z.unknown()).nullable(),
|
||||
// v1.21.2 (A4) — server-resolved recall + forward-look for the
|
||||
// briefing card, rides the snapshot DTO so iOS reads the same
|
||||
// already-localised block the web card renders verbatim.
|
||||
briefingMemory: z
|
||||
.object({
|
||||
recall: z.string(),
|
||||
forward: z.string(),
|
||||
})
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"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.",
|
||||
),
|
||||
briefingState: z.enum(["ready", "preparing", "disabled", "no-provider"]),
|
||||
briefingUpdatedAt: z.string().nullable(),
|
||||
briefingStale: z
|
||||
@@ -1250,3 +1414,73 @@ export const ecgDetailResponse = z
|
||||
description:
|
||||
"One ECG recording's decrypted waveform plus metadata and the DEVICE's verbatim classification. Ownership is 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.",
|
||||
});
|
||||
|
||||
// v1.10.0 — device-flagged event timeline (rhythm-events, WX-B). Deliberately
|
||||
// a SEPARATE enum from `ecgClassification` above: an EVENT row's
|
||||
// `rhythmClassification` carries the FULL RhythmClassification set (the
|
||||
// three ECG verdicts + the two walking-steadiness severities + the neutral
|
||||
// "event occurred" verdict), not just the three ECG-only values. Reusing
|
||||
// `ecgClassification` here would silently drop LOW / VERY_LOW / FIRED for
|
||||
// any client that generates its type from this schema.
|
||||
const rhythmEventType = z
|
||||
.enum([
|
||||
"IRREGULAR_RHYTHM_NOTIFICATION",
|
||||
"HIGH_HEART_RATE_EVENT",
|
||||
"LOW_HEART_RATE_EVENT",
|
||||
"WALKING_STEADINESS_EVENT",
|
||||
"BREATHING_DISTURBANCE_EVENT",
|
||||
])
|
||||
.describe(
|
||||
"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).",
|
||||
);
|
||||
|
||||
const rhythmEventClassification = z
|
||||
.enum([
|
||||
"IRREGULAR",
|
||||
"NOT_DETECTED",
|
||||
"INCONCLUSIVE",
|
||||
"LOW",
|
||||
"VERY_LOW",
|
||||
"FIRED",
|
||||
])
|
||||
.nullable()
|
||||
.describe(
|
||||
"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.",
|
||||
);
|
||||
|
||||
const rhythmEventListItem = z
|
||||
.object({
|
||||
id: z.string().describe("Measurement row id (cuid)."),
|
||||
type: rhythmEventType,
|
||||
classification: rhythmEventClassification,
|
||||
occurredAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.describe("Device-reported event time (`measuredAt`)."),
|
||||
source: measurementSourceEnum,
|
||||
deviceType: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Device class that reported the event (`watch | band | ring | phone | scale | other | unknown`); null when the source omitted it (treated as `unknown`).",
|
||||
),
|
||||
})
|
||||
.meta({ id: "RhythmEventListItem" });
|
||||
|
||||
export const rhythmEventsResponse = z
|
||||
.object({
|
||||
events: z
|
||||
.array(rhythmEventListItem)
|
||||
.describe(
|
||||
"The user's device-flagged events, newest first (capped at 200).",
|
||||
),
|
||||
hasEvents: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"False for an account with no event rows — the client un-mounts the whole surface (data-availability floor).",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "RhythmEventsResponse",
|
||||
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.",
|
||||
});
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { dataEnvelope, stdResponses } from "./shared";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
|
||||
// Mirror the route's `directionEnum` — per-metric sync direction.
|
||||
const healthKitDirectionEnum = z
|
||||
@@ -141,6 +141,111 @@ const healthKitPatchRequest = z
|
||||
"Merge-by-`id` update of the HealthKit metric config. Unknown ids are silently ignored; omitted fields fall back to the stored (or default) value for that entry. Up to 50 entries per call.",
|
||||
});
|
||||
|
||||
// v1.32.28 — on-demand provider sync trigger. Identical response shape
|
||||
// across Oura / Polar / Strava / Nightscout (`resolveSyncOutcome`), so a
|
||||
// client renders one outcome card for all four. `outcome` is the
|
||||
// server-resolved tone, never derived from the HTTP status alone: 200 is the
|
||||
// transport succeeding, not the write — a run where every row was refused
|
||||
// still answers 200 with `outcome: "failed"`.
|
||||
const providerSyncOutcome = z
|
||||
.object({
|
||||
imported: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Rows that reached the database this run."),
|
||||
failed: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True when any part of the run did not land — a row, a collection, a leg.",
|
||||
),
|
||||
outcome: z
|
||||
.enum(["empty", "failed", "partial", "success"])
|
||||
.describe(
|
||||
"`empty` — nothing new, nothing refused. `failed` — nothing written, something refused. `partial` — some written, some refused. `success` — everything written.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "ProviderSyncOutcome",
|
||||
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.",
|
||||
});
|
||||
|
||||
// v1.32.x — Google Health background sync progress. Mirrors
|
||||
// `GoogleHealthResourceOutcome` / the route's `resource()` sanitiser
|
||||
// (`src/app/api/google-health/sync/status/route.ts`): every field is
|
||||
// re-clamped/re-checked against a closed enum on read, so this documents
|
||||
// the SANITISED wire shape, not the raw stored blob.
|
||||
const googleHealthResourceOutcome = z
|
||||
.object({
|
||||
resource: z
|
||||
.string()
|
||||
.describe(
|
||||
"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: z.number().int().min(0),
|
||||
fetched: z.number().int().min(0),
|
||||
mapped: z.number().int().min(0),
|
||||
written: z.number().int().min(0),
|
||||
status: z
|
||||
.enum(["pending", "complete", "partial", "empty", "truncated", "failed"])
|
||||
.describe(
|
||||
"Falls back to `failed` when the stored value is not one of this closed set.",
|
||||
),
|
||||
durationMs: z.number().int().min(0),
|
||||
truncated: z.boolean(),
|
||||
reasonCode: z
|
||||
.enum([
|
||||
"collection_failed",
|
||||
"token_failed",
|
||||
"upsert_failed",
|
||||
"rollup_failed",
|
||||
"existing_page_limit",
|
||||
])
|
||||
.nullable()
|
||||
.describe(
|
||||
"Null when the stored value is not one of this closed set, or when the resource did not fail.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "GoogleHealthResourceOutcome" });
|
||||
|
||||
const googleHealthSyncStatusResponse = z
|
||||
.object({
|
||||
runId: z.string().describe("Truncated to 128 chars."),
|
||||
state: z
|
||||
.enum([
|
||||
"in_progress",
|
||||
"complete",
|
||||
"partial",
|
||||
"zero",
|
||||
"truncated",
|
||||
"failed",
|
||||
"interrupted",
|
||||
])
|
||||
.describe(
|
||||
"Falls back to `failed` when the stored value is not one of this closed set.",
|
||||
),
|
||||
startedAt: z.string().describe("ISO instant, or empty string when unset."),
|
||||
updatedAt: z.string().describe("ISO instant, or empty string when unset."),
|
||||
terminalAt: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"ISO instant the run reached a terminal state. Present only once the run has finished; absent while `state` is `in_progress`.",
|
||||
),
|
||||
imported: z.number().int().min(0),
|
||||
failed: z.boolean(),
|
||||
resources: z
|
||||
.array(googleHealthResourceOutcome)
|
||||
.max(16)
|
||||
.describe("Per-collection outcome for this run, capped at 16 entries."),
|
||||
})
|
||||
.nullable()
|
||||
.meta({
|
||||
id: "GoogleHealthSyncStatusResponse",
|
||||
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).",
|
||||
});
|
||||
|
||||
export const integrationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/integrations/healthkit": {
|
||||
get: {
|
||||
@@ -190,4 +295,131 @@ export const integrationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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: dataEnvelope(providerSyncOutcome, "OuraSyncEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"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: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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: dataEnvelope(providerSyncOutcome, "PolarSyncEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"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: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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: dataEnvelope(providerSyncOutcome, "StravaSyncEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"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: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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: dataEnvelope(
|
||||
providerSyncOutcome,
|
||||
"NightscoutSyncEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"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: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
"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: 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: dataEnvelope(
|
||||
googleHealthSyncStatusResponse,
|
||||
"GoogleHealthSyncStatusEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -3,5 +3,8 @@
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `../index.ts`.
|
||||
*/
|
||||
export { medicationResource } from "./schemas";
|
||||
export {
|
||||
medicationResource,
|
||||
medicationDoseHistoryImportFatalReasonEnum,
|
||||
} from "./schemas";
|
||||
export { medicationPaths } from "./paths";
|
||||
@@ -56,6 +56,8 @@ import {
|
||||
medicationListLayoutResult,
|
||||
doseHistoryQuery,
|
||||
doseHistoryResponse,
|
||||
medicationIntakeImportJobStatusResponse,
|
||||
medicationDoseHistoryImportResponse,
|
||||
} from "./schemas";
|
||||
|
||||
// ── Bulk intake backfill (iOS SyncMode) — mirrors the route's
|
||||
@@ -152,6 +154,17 @@ const bulkIntakeResponse = z
|
||||
})
|
||||
.meta({ id: "BulkMedicationIntakeResponse" });
|
||||
|
||||
// Two paths poll the identical job-status projection — the per-medication
|
||||
// import and the account-wide dose-history import (which narrows to a
|
||||
// `medicationId: null` job through the SAME `readMedicationIntakeImportJob`
|
||||
// helper). Building the envelope once and reusing the object keeps both
|
||||
// `200` responses pointing at one `MedicationIntakeImportJobStatusEnvelope`
|
||||
// component instead of two separately-generated copies.
|
||||
const medicationIntakeImportJobStatusEnvelope = dataEnvelope(
|
||||
medicationIntakeImportJobStatusResponse,
|
||||
"MedicationIntakeImportJobStatusEnvelope",
|
||||
);
|
||||
|
||||
export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/medications/intake/bulk": {
|
||||
post: {
|
||||
@@ -922,4 +935,129 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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: dataEnvelope(
|
||||
medicationDoseHistoryImportResponse,
|
||||
"MedicationDoseHistoryImportDryRunEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"202": {
|
||||
description: "Import queued.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationDoseHistoryImportResponse,
|
||||
"MedicationDoseHistoryImportQueuedEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"409": {
|
||||
description: "A dose-history import is already in progress.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"413": {
|
||||
description: "File exceeds the 16 MB limit.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"415": {
|
||||
description: "Content-Type must be text/csv or application/json.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"503": {
|
||||
description: "Background worker is not available.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
"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: 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.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string(), jobId: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Current job status.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: medicationIntakeImportJobStatusEnvelope,
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"No such job for this medication (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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.",
|
||||
requestParams: {
|
||||
path: z.object({ jobId: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Current job status.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: medicationIntakeImportJobStatusEnvelope,
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"No such job for this account (or owned by another user).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
MEDICATION_ORDER_ID_MAX_LENGTH,
|
||||
MEDICATION_ORDER_MAX_ENTRIES,
|
||||
} from "@/lib/medication-list-layout";
|
||||
import { MEDICATION_IMPORT_SKIP_REASONS } from "@/lib/jobs/medication-intake-import";
|
||||
|
||||
// ── Medications (v1.5 scheduling) ────────────────────────────────────
|
||||
//
|
||||
@@ -929,3 +930,268 @@ export const medicationListLayoutResult = medicationListLayoutSchema
|
||||
description:
|
||||
"Resolved medications list presentation plus the optimistic-concurrency `updatedAt` token.",
|
||||
});
|
||||
|
||||
// ── Intake-import jobs (v1.33.0) — the per-medication `/api/medications/{id}/
|
||||
// intake/import` route and the account-wide `/api/medications/intake/
|
||||
// dose-history-import` route both queue onto `MedicationIntakeImportJob` and
|
||||
// are polled through the SAME status projection, so the shapes below are
|
||||
// shared rather than duplicated per route.
|
||||
|
||||
// The 16-reason closed set a row can be refused for, exactly as declared in
|
||||
// `MEDICATION_IMPORT_SKIP_REASONS`. `duplicate_in_file` / `already_recorded`
|
||||
// are dedup outcomes; `medication_not_found` / `medication_ambiguous` /
|
||||
// `medication_is_mirrored` / `missing_medication` are medication-matching
|
||||
// outcomes; `status_*` are source rows whose own status column said the dose
|
||||
// was never taken (a reminder, an unsent notification, an unrecognised
|
||||
// status); `missing_timestamp` / `missing_timezone_offset` /
|
||||
// `unreadable_timestamp` / `implausible_timestamp` are timestamp problems;
|
||||
// `unreadable_dosage` / `unreadable_row` are malformed cells.
|
||||
export const medicationImportSkipReasonEnum = z
|
||||
.enum(MEDICATION_IMPORT_SKIP_REASONS)
|
||||
.meta({
|
||||
id: "MedicationImportSkipReason",
|
||||
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.",
|
||||
});
|
||||
|
||||
const medicationImportSkipGroup = z
|
||||
.object({
|
||||
reason: medicationImportSkipReasonEnum,
|
||||
count: z.number().int().min(1),
|
||||
})
|
||||
.meta({ id: "MedicationImportSkipGroup" });
|
||||
|
||||
const medicationImportSkipDetail = z
|
||||
.object({
|
||||
line: z.number().int().describe("1-based source-file row ordinal."),
|
||||
reason: medicationImportSkipReasonEnum,
|
||||
})
|
||||
.meta({
|
||||
id: "MedicationImportSkipDetail",
|
||||
description:
|
||||
"One refused source row, reduced to the ordinal + reason — never the row's own content (no medication name, timestamp, or dosage text).",
|
||||
});
|
||||
|
||||
// The terminal, persisted result. Reconstructed through a strict allowlist
|
||||
// (`projectMedicationImportResult`) before it ever reaches a polling
|
||||
// response, so a compromised or legacy JSON blob cannot smuggle anything
|
||||
// past this shape.
|
||||
export const medicationImportResult = z
|
||||
.object({
|
||||
imported: z.number().int().min(0),
|
||||
skipped: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.describe("Total entries that did not become a row."),
|
||||
skipReasons: z
|
||||
.array(medicationImportSkipGroup)
|
||||
.describe(
|
||||
"One entry per reason that actually occurred, count descending then reason — never the same sentence repeated per row. Empty when nothing was skipped.",
|
||||
),
|
||||
skipDetails: z
|
||||
.array(medicationImportSkipDetail)
|
||||
.optional()
|
||||
.describe(
|
||||
"First bounded source-row details (capped at 200). Absent on a legacy job run before this field existed.",
|
||||
),
|
||||
skippedDetailsOmitted: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.describe(
|
||||
"Count of additional skipped rows beyond the `skipDetails` cap, counted without retaining their contents. Absent when nothing was omitted.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "MedicationImportResult",
|
||||
description:
|
||||
"The finished job's outcome. Null while the job is still queued or running — `status` + `progress` are the fields to read meanwhile.",
|
||||
});
|
||||
|
||||
// The in-flight progress blob a running/queued job carries. Written whole at
|
||||
// admission time and advanced by the worker in place; unlike `result` it is
|
||||
// NOT re-validated on read (stored as a bare JSON column), so this documents
|
||||
// the shape the job worker actually writes rather than a server-enforced
|
||||
// contract.
|
||||
export const medicationImportProgress = z
|
||||
.object({
|
||||
processed: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.describe("Entries the worker has walked so far, in file order."),
|
||||
total: z.number().int().min(0).describe("Entries queued for this job."),
|
||||
imported: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.describe("Entries that have reached the database so far."),
|
||||
skippedByReason: z
|
||||
.partialRecord(medicationImportSkipReasonEnum, z.number().int().min(1))
|
||||
.describe(
|
||||
"Running skip tally by reason. A reason with no skips yet is absent from the object, never present as 0.",
|
||||
),
|
||||
skipDetails: z
|
||||
.array(medicationImportSkipDetail)
|
||||
.optional()
|
||||
.describe("Same bounded detail list as the finished `result`."),
|
||||
skippedDetailsOmitted: z.number().int().min(0).optional(),
|
||||
touchedDays: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
"Local calendar days (YYYY-MM-DD) touched so far, driving the chunked rollup recompute behind the write.",
|
||||
),
|
||||
rollupProcessed: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.describe("Of `touchedDays`, how many have had their rollup recomputed."),
|
||||
})
|
||||
.meta({
|
||||
id: "MedicationImportProgress",
|
||||
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.",
|
||||
});
|
||||
|
||||
export const medicationIntakeImportJobStatusResponse = z
|
||||
.object({
|
||||
jobId: z.string(),
|
||||
status: z
|
||||
.enum(["queued", "running", "done", "failed"])
|
||||
.describe(
|
||||
"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: medicationImportProgress,
|
||||
result: medicationImportResult
|
||||
.nullable()
|
||||
.describe("Set only once `status` is `done`; null before that."),
|
||||
failureReason: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Set only when `status` is `failed`; null otherwise."),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
startedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.describe("When the worker picked the job up; null while still queued."),
|
||||
completedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.describe("When the job reached `done` or `failed`; null while active."),
|
||||
})
|
||||
.meta({
|
||||
id: "MedicationIntakeImportJobStatusResponse",
|
||||
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.",
|
||||
});
|
||||
|
||||
// ── Account-wide dose-history import (v1.33.0) ───────────────────────
|
||||
|
||||
// The file-level verdict, identical for a dry run and a real submission.
|
||||
const medicationDoseHistoryImportFile = z
|
||||
.object({
|
||||
rowsRead: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.describe("Data rows read from the file, refusals included."),
|
||||
queued: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.describe("Rows that parsed and matched a medication."),
|
||||
refused: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.describe("Total rows that did not queue, across every reason."),
|
||||
refusedByReason: z
|
||||
.array(medicationImportSkipGroup)
|
||||
.describe(
|
||||
"One entry per reason that actually occurred, count descending then reason.",
|
||||
),
|
||||
unmatchedMedications: z
|
||||
.array(z.string())
|
||||
.max(50)
|
||||
.describe(
|
||||
"Medication names the file used that match nothing on the record, deduplicated, first-seen order, capped at 50.",
|
||||
),
|
||||
ambiguousMedications: z
|
||||
.array(z.string())
|
||||
.max(50)
|
||||
.describe(
|
||||
"Names that match more than one medication, so no single one can be meant. Capped at 50.",
|
||||
),
|
||||
mirroredMedications: z
|
||||
.array(z.string())
|
||||
.max(50)
|
||||
.describe(
|
||||
"Names that match only a medication mirrored from an external list. Capped at 50.",
|
||||
),
|
||||
unknownColumns: z
|
||||
.array(z.string())
|
||||
.max(50)
|
||||
.describe("Header cells the importer has no verdict for. Capped at 50."),
|
||||
codingsNotRead: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.describe("Rows whose `Codings` cell held something left unread."),
|
||||
fromArchivedMedications: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.describe(
|
||||
"Rows the file marked as belonging to a medication archived in the source.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "MedicationDoseHistoryImportFile" });
|
||||
|
||||
export const medicationDoseHistoryImportResponse = z
|
||||
.object({
|
||||
dryRun: z.boolean(),
|
||||
jobId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Null on a dry run (nothing was queued)."),
|
||||
status: z
|
||||
.literal("queued")
|
||||
.optional()
|
||||
.describe("Present only on a real (non-dry-run) submission."),
|
||||
statusUrl: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Relative poll path (`/api/medications/intake/dose-history-import/{jobId}/status`). Null on a dry run.",
|
||||
),
|
||||
file: medicationDoseHistoryImportFile,
|
||||
})
|
||||
.meta({
|
||||
id: "MedicationDoseHistoryImportResponse",
|
||||
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.",
|
||||
});
|
||||
|
||||
// The 5 file-level (whole-submission) refusal reasons — distinct from the
|
||||
// 16 per-row `medicationImportSkipReasonEnum` above. `application/json` is
|
||||
// accepted at the content-type check, but the documented JSON export shape
|
||||
// always ends in `json_carries_no_intake_time`: it carries `scheduledDate` +
|
||||
// `status` + `dosage` but no field for when a dose was actually taken, and
|
||||
// writing the scheduled time as `takenAt` would manufacture an on-time
|
||||
// history the file never claimed. `text/csv` is the only shape that
|
||||
// actually imports.
|
||||
export const medicationDoseHistoryImportFatalReasonEnum = z
|
||||
.enum([
|
||||
"empty_file",
|
||||
"missing_required_columns",
|
||||
"unreadable_json",
|
||||
"json_not_an_array",
|
||||
"json_carries_no_intake_time",
|
||||
])
|
||||
.meta({
|
||||
id: "MedicationDoseHistoryImportFatalReason",
|
||||
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).",
|
||||
});
|
||||
@@ -113,8 +113,25 @@ const capabilitiesResponse = z
|
||||
.int()
|
||||
.describe("Version of the leaf-selection grammar this build speaks."),
|
||||
groups: z
|
||||
.array(z.string())
|
||||
.describe("Selection group ids, in presentation order."),
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().describe("Group id."),
|
||||
leaves: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
"Member leaf ids, in the order the panel renders them.",
|
||||
),
|
||||
sensitive: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Present and true only on the fenced tier: no single control may switch on more than one of its leaves.",
|
||||
),
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"Selection groups, in presentation order, each carrying its member leaf ids.",
|
||||
),
|
||||
leaves: z
|
||||
.array(z.string())
|
||||
.describe(
|
||||
|
||||
@@ -12,6 +12,7 @@ import { notificationPrefsSchema } from "@/lib/validations/notification-prefs";
|
||||
import { sourcePrioritySchema } from "@/lib/validations/source-priority";
|
||||
import { modulePrefsPatchSchema } from "@/lib/validations/modules";
|
||||
import { MODULE_KEYS } from "@/lib/modules/registry";
|
||||
import { savedReportProfileSchema } from "@/lib/report-selection/profile-shape";
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
@@ -199,6 +200,33 @@ const coachPrefsPutRequest = coachPrefsSchema
|
||||
"Coach prompt-tuning preferences to persist, plus the optional optimistic-concurrency `baseUpdatedAt` token. The guarded write covers both `coachPrefsJson` and the mirrored `insightsExcludeMetrics` column atomically. Omit the token for the legacy unconditional write.",
|
||||
});
|
||||
|
||||
// v1.28-era — the owner's saved doctor-report selection (leaf list plus the
|
||||
// render choices the export panel restores). `v` is the report-selection
|
||||
// schema version (currently locked at 2); `leaves` is a closed catalogue of
|
||||
// section/field ids, canonicalised to a fixed order server-side so two
|
||||
// clients that chose the same scope persist the same bytes. This is the ONLY
|
||||
// request body for the PUT — it replaces the stored profile whole, it never
|
||||
// merges.
|
||||
const savedReportProfile = savedReportProfileSchema.meta({
|
||||
id: "SavedReportProfile",
|
||||
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.",
|
||||
});
|
||||
|
||||
const reportSelectionGetResponse = z
|
||||
.object({
|
||||
profile: savedReportProfile
|
||||
.nullable()
|
||||
.describe(
|
||||
"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.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "GetReportSelectionResponse" });
|
||||
|
||||
const reportSelectionPutResponse = z
|
||||
.object({ profile: savedReportProfile })
|
||||
.meta({ id: "PutReportSelectionResponse" });
|
||||
|
||||
// v1.8.6 — profile read/write surface for the native client. The
|
||||
// runtime validation lives in `src/lib/validations/auth.ts`
|
||||
// (`profileSchema`, whose transforms normalise empty → null and run the
|
||||
@@ -850,4 +878,50 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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: dataEnvelope(
|
||||
reportSelectionGetResponse,
|
||||
"GetReportSelectionResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
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: savedReportProfile } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The saved profile, echoed back.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
reportSelectionPutResponse,
|
||||
"PutReportSelectionResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -166,6 +166,13 @@ const workoutDetailResponse = z
|
||||
sportType: z.string(),
|
||||
startedAt: z.iso.datetime({ offset: true }),
|
||||
endedAt: z.iso.datetime({ offset: true }),
|
||||
/**
|
||||
* The local calendar day the session belongs to, `YYYY-MM-DD`, resolved
|
||||
* server-side from `startedAt` in the user's timezone. Clients address
|
||||
* the day-scoped reads with it instead of deriving a day from the
|
||||
* timestamp in their own zone.
|
||||
*/
|
||||
dayKey: z.string(),
|
||||
durationSec: z.number().int().nonnegative(),
|
||||
distanceM: z.number().nullable(),
|
||||
activeEnergyKcal: z.number().nullable(),
|
||||
@@ -192,6 +199,12 @@ const workoutDetailResponse = z
|
||||
// a provider reachable. Reading this endpoint never generates one.
|
||||
aiInsight: workoutActivityInsight.nullable(),
|
||||
canonicalId: z.string(),
|
||||
/**
|
||||
* The canonical session before this one in the same sport, or null when
|
||||
* this is the first on record. Resolved through the same source-priority
|
||||
* picker as the list, so the id always addresses a row the list shows.
|
||||
*/
|
||||
previousWorkoutId: z.string().nullable(),
|
||||
})
|
||||
.meta({ id: "WorkoutDetailResponse" });
|
||||
|
||||
|
||||
@@ -32,6 +32,16 @@ export const moodKeys = {
|
||||
params.sortDir,
|
||||
] as const,
|
||||
|
||||
/**
|
||||
* v1.34.3 — every mood entry logged on ONE local day, read by the
|
||||
* workout detail page's day context. Rides under the `["mood-entries"]`
|
||||
* prefix so `moodDependentKeys` reaches it on a mood write, and carries
|
||||
* its own `"day"` scope segment so it can never share a slot with
|
||||
* `moodEntriesList` — same key with a different response shape silently
|
||||
* poisons the cache.
|
||||
*/
|
||||
moodEntriesDay: (date: string) => ["mood-entries", "day", date] as const,
|
||||
|
||||
moodAnalytics: () => ["mood-analytics"] as const,
|
||||
/**
|
||||
* v1.8.5 — pre-computed mood-insights aggregates (heatmap, distribution,
|
||||
|
||||
@@ -25,7 +25,9 @@ import {
|
||||
REPORT_GROUPS,
|
||||
REPORT_GROUP_LABEL_KEYS,
|
||||
REPORT_GROUP_ORDER,
|
||||
SENSITIVE_GROUP_ID,
|
||||
SENSITIVE_LEAF_IDS,
|
||||
SHARE_GROUPS,
|
||||
STRUCTURED_LEAF_IDS,
|
||||
leafLabelKey,
|
||||
} from "../catalogue";
|
||||
@@ -80,6 +82,49 @@ describe("report selection catalogue", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `SHARE_GROUPS` is what `GET /api/meta/capabilities` publishes as
|
||||
* `share.groups` — the membership a native client needs to mirror the
|
||||
* sensitive-tier fence itself. It is derived from `REPORT_GROUPS`, so these
|
||||
* checks are the invariant that makes that fence trustworthy: every leaf in
|
||||
* the flat `ALL_LEAF_IDS` set lands in exactly one group, no group carries a
|
||||
* leaf the flat set doesn't have, and exactly one group — the one whose id is
|
||||
* `SENSITIVE_GROUP_ID` — carries `sensitive: true`.
|
||||
*
|
||||
* Mutation check (verified red, then reverted): push a leaf into two groups
|
||||
* in `SHARE_GROUPS` by hand → the "exactly one group" assertion goes red
|
||||
* naming the duplicated leaf.
|
||||
*/
|
||||
describe("share-group membership (GET /api/meta/capabilities share.groups)", () => {
|
||||
it("covers exactly the flat leaf set, no leaf in more than one group", () => {
|
||||
const seen = new Set<string>();
|
||||
for (const group of SHARE_GROUPS) {
|
||||
for (const leaf of group.leaves) {
|
||||
expect(seen.has(leaf), `${leaf} appears in more than one group`).toBe(
|
||||
false,
|
||||
);
|
||||
seen.add(leaf);
|
||||
}
|
||||
}
|
||||
expect([...seen].sort()).toEqual([...ALL_LEAF_IDS].sort());
|
||||
});
|
||||
|
||||
it("flags sensitive: true on exactly the fenced group", () => {
|
||||
const flagged = SHARE_GROUPS.filter((g) => g.sensitive);
|
||||
expect(flagged).toHaveLength(1);
|
||||
expect(flagged[0]?.id).toBe(SENSITIVE_GROUP_ID);
|
||||
});
|
||||
|
||||
it("orders groups and leaves identically to REPORT_GROUPS", () => {
|
||||
expect(SHARE_GROUPS.map((g) => g.id)).toEqual(
|
||||
REPORT_GROUPS.map((g) => g.id),
|
||||
);
|
||||
for (const [i, group] of SHARE_GROUPS.entries()) {
|
||||
expect(group.leaves).toEqual(REPORT_GROUPS[i]!.leaves);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("template purity", () => {
|
||||
it("keeps the shipped template clear of the fenced tier", () => {
|
||||
const overlap = STANDARD_TEMPLATE_LEAVES.filter((leaf) =>
|
||||
|
||||
@@ -344,6 +344,28 @@ export const ALL_LEAF_IDS: readonly ReportLeafId[] = REPORT_GROUPS.flatMap(
|
||||
(group) => group.leaves,
|
||||
);
|
||||
|
||||
/**
|
||||
* The wire shape for `GET /api/meta/capabilities` `share.groups`: each group
|
||||
* carries its own member leaves, and the fenced tier carries `sensitive:
|
||||
* true`, derived from `SENSITIVE_GROUP_ID` rather than a second string
|
||||
* literal. A native client that cannot see group membership cannot mirror the
|
||||
* fence (no single control switching on more than one sensitive leaf), so
|
||||
* this — not `REPORT_GROUP_ORDER` alone — is what the endpoint should ship.
|
||||
*
|
||||
* Order matches `REPORT_GROUPS` exactly: group order is `REPORT_GROUP_ORDER`,
|
||||
* leaf order within a group is structured-then-measurement, so this shape and
|
||||
* the flat `ALL_LEAF_IDS` list agree leaf-for-leaf.
|
||||
*/
|
||||
export const SHARE_GROUPS: ReadonlyArray<{
|
||||
id: ReportGroupId;
|
||||
leaves: readonly ReportLeafId[];
|
||||
sensitive?: true;
|
||||
}> = REPORT_GROUPS.map((group) => ({
|
||||
id: group.id,
|
||||
leaves: group.leaves,
|
||||
...(group.id === SENSITIVE_GROUP_ID ? { sensitive: true as const } : {}),
|
||||
}));
|
||||
|
||||
const ALL_LEAF_SET = new Set<string>(ALL_LEAF_IDS);
|
||||
|
||||
/** Whether a raw string is a leaf id this build knows. */
|
||||
|
||||
@@ -100,6 +100,16 @@ export const nutrientOverviewSchema = z
|
||||
daysWithData: z.number().int().min(1),
|
||||
}),
|
||||
),
|
||||
lastAttempt: z
|
||||
.object({
|
||||
at: z.iso.datetime({ offset: true }),
|
||||
topReason: z.string(),
|
||||
})
|
||||
.nullable()
|
||||
.meta({
|
||||
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.",
|
||||
}),
|
||||
})
|
||||
.meta({
|
||||
id: "NutrientIntakeOverview",
|
||||
|
||||
@@ -187,6 +187,49 @@ describe("POST /api/nutrients/batch (real Postgres)", () => {
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
// Before this, `nutrient.batch.ingest` audited only when
|
||||
// `inserted + updated > 0` — the ONLY durable record of the call — so a
|
||||
// batch where every entry was rejected left the exact same empty
|
||||
// AuditLog trail as a phone that never posted at all. An operator could
|
||||
// not tell the two apart from the durable record.
|
||||
it("records an audit-log row even when every entry is rejected", async () => {
|
||||
const { POST } = await import("@/app/api/nutrients/batch/route");
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(1), nutrient: "vitamin_d", unit: "mg", amount: 20 },
|
||||
{ day: recentDay(2), nutrient: "iron", unit: "mg", amount: 999 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const json = (await res.json()) as BatchEnvelope;
|
||||
expect(json.data.inserted).toBe(0);
|
||||
expect(json.data.updated).toBe(0);
|
||||
expect(json.data.skipped).toHaveLength(2);
|
||||
|
||||
const rows = await getPrismaClient().auditLog.findMany({
|
||||
where: { userId: OPTED_IN_USER, action: "nutrient.batch.ingest" },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
const details = JSON.parse(rows[0].details ?? "{}") as {
|
||||
processed: number;
|
||||
inserted: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
skippedByReason: Record<string, number>;
|
||||
};
|
||||
expect(details).toMatchObject({
|
||||
processed: 2,
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
skipped: 2,
|
||||
});
|
||||
expect(details.skippedByReason).toMatchObject({
|
||||
unit_mismatch: 1,
|
||||
value_out_of_range: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses ingest 403 module.disabled for an account that never opted in", async () => {
|
||||
const { POST } = await import("@/app/api/nutrients/batch/route");
|
||||
cookieJar.clear();
|
||||
@@ -258,4 +301,83 @@ describe("GET /api/nutrients (real Postgres)", () => {
|
||||
const res = await GET(new NextRequest("http://localhost/api/nutrients"));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
// The account holder's only signal that a sync ever arrived: when the
|
||||
// window is genuinely empty AND the most recent nutrient-batch call
|
||||
// landed nothing, the read surfaces why instead of a bare empty state.
|
||||
it("surfaces the last failed attempt when the window is genuinely empty", async () => {
|
||||
const { POST } = await import("@/app/api/nutrients/batch/route");
|
||||
const { GET } = await import("@/app/api/nutrients/route");
|
||||
|
||||
await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(1), nutrient: "vitamin_d", unit: "mg", amount: 20 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/nutrients?days=14"),
|
||||
);
|
||||
const json = (await res.json()) as {
|
||||
data: { nutrients: unknown[]; lastAttempt: { topReason: string } | null };
|
||||
};
|
||||
expect(json.data.nutrients).toHaveLength(0);
|
||||
expect(json.data.lastAttempt).toMatchObject({
|
||||
topReason: "unit_mismatch",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits lastAttempt entirely once the window has data", async () => {
|
||||
const { POST } = await import("@/app/api/nutrients/batch/route");
|
||||
const { GET } = await import("@/app/api/nutrients/route");
|
||||
|
||||
await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(1), nutrient: "caffeine", unit: "mg", amount: 250 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/nutrients?days=14"),
|
||||
);
|
||||
const json = (await res.json()) as {
|
||||
data: { nutrients: unknown[]; lastAttempt: unknown };
|
||||
};
|
||||
expect(json.data.nutrients).toHaveLength(1);
|
||||
expect(json.data.lastAttempt).toBeNull();
|
||||
});
|
||||
|
||||
// A last attempt that actually SUCCEEDED must never be reframed as a
|
||||
// failure just because the window is empty for an unrelated reason
|
||||
// (here: the stored row was removed after the fact). Showing a failure
|
||||
// notice over a real success is the exact false claim this feature
|
||||
// exists to prevent.
|
||||
it("stays silent when the last recorded attempt succeeded, even if the window is now empty", async () => {
|
||||
const { POST } = await import("@/app/api/nutrients/batch/route");
|
||||
const { GET } = await import("@/app/api/nutrients/route");
|
||||
|
||||
await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{ day: recentDay(1), nutrient: "caffeine", unit: "mg", amount: 250 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await getPrismaClient().nutrientIntakeDay.deleteMany({
|
||||
where: { userId: OPTED_IN_USER },
|
||||
});
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/nutrients?days=14"),
|
||||
);
|
||||
const json = (await res.json()) as {
|
||||
data: { nutrients: unknown[]; lastAttempt: unknown };
|
||||
};
|
||||
expect(json.data.nutrients).toHaveLength(0);
|
||||
expect(json.data.lastAttempt).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -229,6 +229,32 @@ async function auditReasons(action: string): Promise<string[]> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until an audit reason lands, instead of sleeping a fixed span and hoping.
|
||||
*
|
||||
* The audit write is fire-and-forget, so the old `setTimeout(r, 100)` before
|
||||
* reading the ledger was a race the assertion loses whenever the runner is
|
||||
* busy — which is exactly when CI is slowest and least willing to explain
|
||||
* itself. The failure looked like a broken refusal ("expected [] to include
|
||||
* 'consumed'") when the refusal had in fact worked and only its breadcrumb was
|
||||
* still in flight. On timeout this asserts against the real list, so a genuine
|
||||
* regression still reports what WAS written rather than a bare timeout.
|
||||
*/
|
||||
async function expectAuditReason(
|
||||
action: string,
|
||||
reason: string,
|
||||
timeoutMs = 5_000,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let seen: string[] = [];
|
||||
while (Date.now() < deadline) {
|
||||
seen = await auditReasons(action);
|
||||
if (seen.includes(reason)) return;
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
expect(seen).toContain(reason);
|
||||
}
|
||||
|
||||
// ── E1 — a stolen token, on its own, gains nothing ───────────────────
|
||||
|
||||
describe("E1 — the token alone is not a step-up proof", () => {
|
||||
@@ -261,10 +287,7 @@ describe("E1 — the token alone is not a step-up proof", () => {
|
||||
useElevation(`hle_${"f".repeat(64)}`);
|
||||
|
||||
expect((await callMfaSetup()).status).toBe(401);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(await auditReasons("auth.stepup.elevation.rejected")).toContain(
|
||||
"unknown",
|
||||
);
|
||||
await expectAuditReason("auth.stepup.elevation.rejected", "unknown");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -287,10 +310,7 @@ describe("E2 — an elevation is not portable between tokens", () => {
|
||||
const res = await callMfaSetup();
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(await auditReasons("auth.stepup.elevation.rejected")).toContain(
|
||||
"wrong_token",
|
||||
);
|
||||
await expectAuditReason("auth.stepup.elevation.rejected", "wrong_token");
|
||||
|
||||
// And the legitimate holder's elevation survived the attempt.
|
||||
const row = await getPrismaClient().stepUpElevation.findFirst();
|
||||
@@ -331,10 +351,7 @@ describe("E3 — an elevation is consumed exactly once", () => {
|
||||
expect((await callMfaSetup()).status).toBe(200);
|
||||
expect((await callMfaSetup()).status).toBe(401);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(await auditReasons("auth.stepup.elevation.rejected")).toContain(
|
||||
"consumed",
|
||||
);
|
||||
await expectAuditReason("auth.stepup.elevation.rejected", "consumed");
|
||||
});
|
||||
|
||||
it("lets exactly one of two CONCURRENT redemptions win", async () => {
|
||||
@@ -389,10 +406,7 @@ describe("E4 — an expired elevation is refused", () => {
|
||||
useElevation(elevation);
|
||||
expect((await callMfaSetup()).status).toBe(401);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(await auditReasons("auth.stepup.elevation.rejected")).toContain(
|
||||
"expired",
|
||||
);
|
||||
await expectAuditReason("auth.stepup.elevation.rejected", "expired");
|
||||
});
|
||||
|
||||
it("mints a five-minute window, matching the cookie step-up", async () => {
|
||||
@@ -778,8 +792,8 @@ describe("E10 — a password proof cannot reach the destructive routes", () => {
|
||||
expect(res.status).toBe(401);
|
||||
// No codes were minted, so step 2 of the chain yields nothing.
|
||||
expect(await getPrismaClient().mfaRecoveryCode.count()).toBe(0);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(await auditReasons("auth.stepup.elevation.rejected")).toContain(
|
||||
await expectAuditReason(
|
||||
"auth.stepup.elevation.rejected",
|
||||
"insufficient_factor",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user