diff --git a/.env.production.example b/.env.production.example index f2a0396d5..c07d9e292 100644 --- a/.env.production.example +++ b/.env.production.example @@ -187,3 +187,31 @@ API_TOKEN_HMAC_KEY="" # $ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build # or a Dockerfile ARG # # NEXT_PUBLIC_DASHBOARD_SNAPSHOT="false" + + +# ----------------------------------------------------------------------------- +# Client-IP resolution -- optional (required behind Cloudflare) +# ----------------------------------------------------------------------------- +# When the stack sits behind Cloudflare, the real visitor IP arrives in the +# `cf-connecting-ip` header; the X-Forwarded-For chain otherwise yields the +# Cloudflare edge IP, so the audit-log overview stores the CF datacentre +# address and the geo lookup resolves the wrong location. Set this to "1" +# ONLY when Cloudflare is in front -- a direct-exposed self-host must leave +# it unset, or an attacker could forge the header. Off by default. +# TRUST_CF_CONNECTING_IP="1" + +# ----------------------------------------------------------------------------- +# IP geolocation lookup -- optional +# ----------------------------------------------------------------------------- +# The admin login/audit overview enriches auth events with a coarse +# "City, CC" location + carrier. The default online provider is ip-api.com. +# Override the base URL to point at a different provider; the response must +# match either the ipwho.is shape (`success` + `country_code`) or the +# ip-api.com shape (`status` + `countryCode`). HTTPS only -- a non-HTTPS URL +# is refused (audit-event IPs must never egress over plaintext HTTP). The +# `/` segment is appended automatically. +# IP_GEO_LOOKUP_URL="https://ip-api.com/json" +# +# Set to "1" to disable all third-party IP egress (GDPR Art. 44). With egress +# off the resolver uses only the offline GeoLite2 MMDB tier, if present. +# IP_GEO_LOOKUP_DISABLED="1" diff --git a/CHANGELOG.md b/CHANGELOG.md index 19a318584..73001dbaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,57 @@ ## [Unreleased] +## [1.19.0] — 2026-06-21 — Coach context, a leaner AI pipeline, new device signals, interactive reminders, and wider self-hosting + +A feature release. The Coach gains the lab values and a direct way in; the daily AI work is restructured to do less and reuse more; several new device signals are captured; reminders become interactive over Telegram; charts and the cycle and sleep surfaces are corrected and polished; and the project ships ready-made templates for one-click self-hosting. Three additive migrations (`0185`–`0187`); no breaking changes. + +### Added + +- The Coach can answer about lab results — the most recent value per biomarker (last twelve months) is provided as grounded context, with reference range, in/out-of-range status, and date. Qualitative results are included; the Coach quotes them verbatim and never invents a value. +- The Coach entry points open directly into the most recent conversation, the same thread across devices, rather than always starting blank. A `?c=` deep-link opens a specific conversation; the new-chat start remains. +- The daily briefing is surfaced on the dashboard as a short spotlight strip, lifting its key signals above the fold for a fresh briefing. +- Mood can be logged straight from a Telegram reminder — a 1–5 inline choice or a short written note — and the reminder chat self-cleans about thirty minutes later so old prompts do not linger or bias the next entry. Mood and measurement reminders also carry calm "remind me later" / "done" actions. +- Withings ECG records the device's atrial-fibrillation screening result and the full ECG waveform (stored encrypted at rest). +- Oura cardiovascular age and resilience are recorded. +- A configured multimodal model reads a scanned lab document directly; where the model is text-only, the on-device OCR path still applies. +- Ready-made self-hosting templates: an Unraid Community Applications template and a Portainer stack template, a hardened `docker-compose.yml`, and a NAS quick-start guide. +- A go-forward aggregated heart-rate upload contract (hourly buckets) so high-frequency clients stop accumulating one row per raw sample. + +### Changed + +- The AI pipeline does less, more coherently: the comprehensive briefing reads a bounded recent window (all-time figures stay accurate); per-domain status summaries generate once on the nightly pass instead of per metric through the day; a shared feature snapshot is computed once and reused; slow movers (weight, BMI) skip regeneration when their inputs are unchanged; illness and cycle state fold into the briefing context at no cost for users without them. Reproducibility, consent, and grounding checks are unchanged throughout. +- Medication remaining-supply is computed server-side and rendered from one canonical value, so the web and the iOS client always agree. +- High-frequency heart rate and blood oxygen are consolidated server-side into daily aggregates (mean with the daily minimum and maximum preserved; a resting-heart-rate value is derived where the source has none), matching the cumulative-metric consolidation already in place. +- Day-level bucketing for recent readings honours the user's timezone, so a late-evening reading lands on the correct local day. +- Sleep debt is a recovering rolling balance over recent nights instead of an ever-growing sum, so it reflects what is owed now and shrinks after a good night. +- The cycle view aligns its columns, uses consistent typography and equally sized toggles, and shows the prediction disclaimer once — clearly, at onboarding — rather than repeated across the feature. +- The sleep card's tiles share one consistent layout, and mood charts paint at full saturation. +- Sleep-night minute values are returned rounded; the notifications page opens on a single heading. + +### Fixed + +- Charts honour the 90-day and All ranges — they were stuck on roughly the last month regardless of the selected range. +- Medication remaining-supply can no longer display a nonsensical negative figure; the canonical readout is floored and a would-be underflow is recorded for diagnosis. +- Notification settings now label the measurement-reminder, medication-intake-sync, and coach-nudge events instead of showing raw keys. + +### Performance + +- Achievements and the dashboard summary serve from a stale-while-revalidate cache, the achievements builder precomputes day boundaries, three charts defer their library, and the analytics aggregate folds its canonical self-join into a single shared expression. + +### Security + +- The ECG waveform is encrypted at rest and covered by the key-rotation script, with a schema-driven test that fails the build if any encrypted column is ever left out of rotation. +- A Telegram interaction is strictly bound to the linked account before it can write, and the chat identifier is redacted from logs. + +### Refactor + +- Oversized modules are split into focused modules behind a barrel with no change in behaviour: the OpenAPI medication and insights registries, medication validations, the import panel, and the account settings section. + +### Deferred + +- Garmin: blocked by an external gate (the developer programme is invitation-only and not accepting new applicants), so there is nothing to build until access opens; only the official hosted instance could pursue it. +- Briefing map-reduce over the per-domain summaries, the cycle/sleep companion-line edge cases, multi-year "All" chart ranges, and the Oura resilience tile remain follow-ups. + ## [1.18.10] — 2026-06-20 — data-loss fix, measurement consolidation, grounding, and the full-page Coach A correctness-and-polish release. It closes a silent data-loss path and a stalled measurement-consolidation job, hardens AI output grounding, and acts on a broad round of UI feedback. Two additive migrations (a covering index and a labs setting); no breaking changes. diff --git a/README.md b/README.md index bd0c053a6..da2570519 100644 --- a/README.md +++ b/README.md @@ -42,18 +42,36 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev). -## Quick start +## Quick start (Docker Compose) ```bash git clone https://github.com/MBombeck/HealthLog.git && cd HealthLog cp .env.example .env + +# Generate the three secrets (each a 64-char hex string = 32 bytes): echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" >> .env echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env + +# LAN / plain-HTTP host (NAS, homelab, Tailscale) — required, or login fails: +echo "SESSION_COOKIE_SECURE=false" >> .env + docker compose up -d ``` -Open **http://localhost:3000** — the first registered user becomes admin. The compose file pulls a pre-built multi-arch image (`amd64` + `arm64`) from [GHCR](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog); no build step required. Behind a reverse proxy, set `NEXT_PUBLIC_APP_URL` and `APP_URL` first — see the [self-hosting guide](https://docs.healthlog.dev/self-hosting/). +Open **http://localhost:3000** (or `http://:3000`) — the first registered user becomes admin. The compose file pulls a pre-built multi-arch image (`amd64` + `arm64`) from [GHCR](https://github.com/MBombeck/HealthLog/pkgs/container/healthlog); no build step required. + +Verify the running version: + +```bash +curl -s http://localhost:3000/api/version # returns version + buildSha + builtAt +``` + +`SESSION_COOKIE_SECURE=false` is the one setting plain-HTTP self-hosts must set: without it the session cookie carries the `Secure` flag, the browser drops it over HTTP, and login silently fails. Leave it unset (or `true`) only when a TLS reverse proxy serves HTTPS — and then set `NEXT_PUBLIC_APP_URL` and `APP_URL` to the public URL first (see the [self-hosting guide](https://docs.healthlog.dev/self-hosting/)). + +Persistent state lives in exactly one named volume (`pgdata`, the Postgres data dir); the app container is stateless, so backing up the database backs up everything. + +NAS and homelab users have ready-made paths: [Unraid Community Applications](docs/self-hosting/unraid.md) and [Portainer app templates](docs/self-hosting/portainer/README.md). ## Self-hosting diff --git a/docker-compose.yml b/docker-compose.yml index f3c518c9c..f28293eee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,22 @@ +# HealthLog — two-container stack (app + Postgres 16). +# +# First run (clean host): +# cp .env.example .env +# echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" >> .env +# echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env +# echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env +# docker compose up -d +# # then open http://:3000 — the first registered user becomes admin. +# +# LAN / plain-HTTP self-hosts (NAS, homelab, Tailscale): set +# SESSION_COOKIE_SECURE=false +# in .env, otherwise the session cookie carries the `Secure` flag over HTTP +# and login silently fails. This is the single most common self-host snag. +# Leave it unset (or true) only when a TLS reverse proxy serves HTTPS. +# +# Persistent state lives in exactly one named volume (`pgdata`, Postgres' +# data dir). The app container is stateless — GeoLite2 MMDBs are baked into +# the image — so a backup of the database is a backup of everything. services: # Single-container default (HEALTHLOG_PROCESS_TYPE=all). Existing # self-host deployments stay working with `docker compose up`. @@ -39,19 +58,39 @@ services: # a free connection before erroring. DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public&connection_limit=${DB_CONNECTION_LIMIT:-20}&pool_timeout=${DB_POOL_TIMEOUT:-20}" NODE_ENV: production + # Encryption at rest (AES-256-GCM). Generate each secret with + # `openssl rand -hex 32` (a 64-char hex string = 32 bytes). ENCRYPTION_KEY + # is the single-key path and is all most self-hosts need. The map form + # (ENCRYPTION_KEYS={"v1":"","v2":""} + ENCRYPTION_ACTIVE_KEY_ID=v2) + # is for key rotation — see docs/ops/encryption-key-rotation.md. Keep these + # values stable and backed up: lose them and the encrypted columns are + # unrecoverable. ENCRYPTION_KEY: "${ENCRYPTION_KEY}" ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}" ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}" API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}" NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-http://localhost:3000}" APP_URL: "${APP_URL:-http://localhost:3000}" - # v1.5.1 session-cookie Secure-flag opt-out. Forwarded explicitly so - # an operator setting SESSION_COOKIE_SECURE=false in .env actually - # reaches the Node process (the compose `environment:` block is a - # whitelist — vars not listed here don't propagate from .env into - # the container, even though docker-compose reads .env for - # `${VAR}` substitution at compose-up time). + # Session-cookie Secure-flag opt-out. SET THIS TO false FOR LAN / plain-HTTP + # self-hosts (http://:3000 with no TLS reverse proxy) — otherwise the + # session cookie carries the `Secure` flag, the browser drops it over HTTP, + # and login silently fails. Unset → the flag tracks NODE_ENV=production + # (i.e. on); set to true to force it. Forwarded explicitly so an operator + # value in .env reaches the Node process (the `environment:` block is a + # whitelist — vars not listed here don't propagate even though + # docker-compose reads .env for `${VAR}` substitution at compose-up time). SESSION_COOKIE_SECURE: "${SESSION_COOKIE_SECURE:-}" + # 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 ip-api.com provider (HTTPS + # only); IP_GEO_LOOKUP_DISABLED=1 stops all third-party IP egress. + # 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:-}" # 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}" diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3b9bacaeb..f424cf858 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.18.10 + version: 1.19.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -1669,6 +1669,7 @@ paths: - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION + - RESILIENCE - in: query name: from schema: @@ -1855,6 +1856,7 @@ paths: - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION + - RESILIENCE value: type: number measuredAt: @@ -2620,7 +2622,10 @@ paths: - Medications summary: List a medication's supply containers description: Returns every inventory item (all states) for the medication, ordered by state, then `expiresAt`, then - `createdAt`. Items count UNITS; divide by the medication's `unitsPerDose` for dose-level figures. + `createdAt`. Items count UNITS; divide by the medication's `unitsPerDose` for dose-level figures. v1.19.0 + (iOS#25) — also returns a server-computed `summary` (the canonical {`unitsRemaining`, `unitsTotal`, + `dosesRemaining`, `dosesTotal`, `expiredUnits`}); clients render it directly rather than re-deriving the Bestand + headline, so web and iOS agree. parameters: - in: path name: id @@ -4954,6 +4959,7 @@ paths: - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION + - RESILIENCE description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." required: true description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." @@ -7206,6 +7212,9 @@ components: description: HealthKit identifier (e.g. `HKQuantityTypeIdentifierBodyMass`). value: type: number + description: Raw HealthKit reading in Apple's native unit; the server applies any canonical scaling at ingest. For an + hourly heart-rate bucket (see `externalId`) this is the hour's AVERAGE bpm — v1.19.0 ships avg-only; min/max + are a documented fast-follow. unit: type: string minLength: 1 @@ -7235,7 +7244,21 @@ components: type: string minLength: 1 maxLength: 120 - description: HKSample.uuid string — the dedup key. + description: >- + Dedup key for the `(userId, type, source, externalId)` composite index. Three shapes: + + • `HKSample.uuid` — a per-sample reading. First-write-wins: a re-post returns `duplicate` and the stored row + is immutable. + + • `stats::` — a per-day cumulative total (Steps, Active Energy, Sleep + Duration, Walking/Running Distance, Flights Climbed). A re-post OVERWRITES the row and returns `updated`. + + • `stats:HKQuantityTypeIdentifierHeartRate:` — v1.19.0 (iOS #34) hourly heart-rate bucket + carrying the hour's AVERAGE bpm as one PULSE row. `` is the ISO-8601 UTC hour boundary + with zeroed minutes/seconds/millis and a trailing `Z` (e.g. `2026-06-21T14:00:00.000Z`). A re-post within + the hour OVERWRITES the row and returns `updated`, so iOS uploads ~24 rows/day instead of one per raw HR + sample. A row that targets this prefix with a malformed hour suffix is `skipped` with reason + `malformed_hr_bucket_id`. externalSourceVersion: type: string minLength: 1 @@ -10508,6 +10531,7 @@ components: - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION + - RESILIENCE value: type: number unit: @@ -10821,6 +10845,7 @@ components: - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION + - RESILIENCE additionalProperties: type: array items: @@ -10897,6 +10922,7 @@ components: - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION + - RESILIENCE value: type: number description: Daily mean (spot metrics) or SUM (cumulative). @@ -10994,6 +11020,7 @@ components: - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION + - RESILIENCE description: Per-type daily series, keyed by MeasurementType. required: - series @@ -11212,6 +11239,7 @@ components: - CARDIO_LOAD - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION + - RESILIENCE value: type: number unit: @@ -11283,25 +11311,46 @@ components: AppleHealthBatchResponse: type: object properties: + processed: + type: integer + minimum: 0 + maximum: 9007199254740991 inserted: type: integer minimum: 0 maximum: 9007199254740991 - duplicate: + updated: + type: integer + minimum: 0 + maximum: 9007199254740991 + duplicates: type: integer minimum: 0 maximum: 9007199254740991 skipped: - type: integer - minimum: 0 - maximum: 9007199254740991 + type: array + items: + type: object + properties: + index: + type: integer + minimum: 0 + maximum: 9007199254740991 + reason: + type: string + required: + - index + - reason + additionalProperties: false entries: type: array items: $ref: "#/components/schemas/AppleHealthBatchEntryResult" required: + - processed - inserted - - duplicate + - updated + - duplicates - skipped - entries additionalProperties: false @@ -11316,8 +11365,12 @@ components: type: string enum: - inserted + - updated - duplicate - skipped + description: "`inserted`/`duplicate` — the row landed (advance the cursor). `updated` — a `stats:` aggregate (per-day + cumulative total or hourly heart-rate bucket) overwrote an existing row. `skipped` — not stored (e.g. + unmappable identifier, out-of-range value, or `malformed_hr_bucket_id`); see `reason`." reason: type: string required: @@ -12794,6 +12847,8 @@ components: type: array items: $ref: "#/components/schemas/MedicationInventoryItem" + summary: + $ref: "#/components/schemas/MedicationSupplySummary" meta: type: object properties: @@ -12806,6 +12861,7 @@ components: additionalProperties: false required: - items + - summary - meta additionalProperties: false error: @@ -12922,6 +12978,36 @@ components: description: One supply container (pen / blister pack / bottle) of a medication. Counts UNITS — the medication's `unitsPerDose` maps units to doses. The intake write paths consume from the open container first, then first-expiry-first-out over unopened stock. + MedicationSupplySummary: + type: object + properties: + unitsRemaining: + type: number + description: Pooled units across available (ACTIVE / IN_USE, units left) containers. Floored at 0 — a corrupt / legacy + negative row can never surface a negative Bestand. + unitsTotal: + type: number + description: Pooled capacity across the same available containers. + dosesRemaining: + type: number + description: "Dose-derived headline: `floor(unitsRemaining / unitsPerDose)` (whole doses; a partial dose is not a dose)." + dosesTotal: + type: number + description: "Dose-derived capacity: `floor(unitsTotal / unitsPerDose)`." + expiredUnits: + type: number + description: Units still sitting in EXPIRED containers — visible to the user as a muted suffix, never folded into the + available headline or the runway estimate. + required: + - unitsRemaining + - unitsTotal + - dosesRemaining + - dosesTotal + - expiredUnits + additionalProperties: false + description: Server-authoritative supply summary for a medication's containers. Computed from the same availability + predicate the medications-list payload and the GLP-1 endpoint use, so every surface agrees on what 'remaining' + means. CreateMedicationInventoryItemResponse: type: object properties: diff --git a/docs/self-hosting/portainer/README.md b/docs/self-hosting/portainer/README.md new file mode 100644 index 000000000..b9da05db8 --- /dev/null +++ b/docs/self-hosting/portainer/README.md @@ -0,0 +1,49 @@ +# HealthLog on Portainer + +HealthLog ships a Portainer **app template** that deploys the app and its +PostgreSQL 16 database as one stack from the project `docker-compose.yml`. + +## Add the template + +1. In Portainer, go to **Settings → App Templates**. +2. Set the URL to the raw template file: + + ``` + https://raw.githubusercontent.com/MBombeck/HealthLog/main/docs/self-hosting/portainer/templates.json + ``` + +3. Save. **HealthLog** now appears under **App Templates**. + +## Deploy + +1. Open **App Templates → HealthLog**. +2. Fill in the variables. Generate each secret with `openssl rand -hex 32` (a + 64-character hex string): + - `POSTGRES_PASSWORD` + - `ENCRYPTION_KEY` — back this up; losing it makes encrypted data + unrecoverable + - `API_TOKEN_HMAC_KEY` + - `SESSION_COOKIE_SECURE` — **set to `false` for plain-HTTP / LAN access**, or + login silently fails. Set to `true` only behind an HTTPS reverse proxy. + - `NEXT_PUBLIC_APP_URL` and `APP_URL` — how you reach the app, e.g. + `http://nas:3000` or `https://healthlog.example.com`. +3. **Deploy the stack**. The app waits for PostgreSQL, runs database migrations + on first boot, then starts serving. +4. Open the app URL. The first registered user becomes the admin. + +Verify the running version: + +```bash +curl -s http://:3000/api/version +``` + +## Notes + +- Persistent state lives in one named volume (`pgdata`, the Postgres data dir); + the app container is stateless. Back up the database to back up everything. +- Pin a release tag (`HEALTHLOG_IMAGE_REF=:vX.Y.Z`) for deliberate upgrades, or + track `:latest`. Skim the + [CHANGELOG](https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md) and + back up before upgrading. Migrations are forward-only. +- For passive discovery you can also submit this template to the community + aggregator at https://github.com/Lissy93/portainer-templates. diff --git a/docs/self-hosting/portainer/templates.json b/docs/self-hosting/portainer/templates.json new file mode 100644 index 000000000..985e01c44 --- /dev/null +++ b/docs/self-hosting/portainer/templates.json @@ -0,0 +1,54 @@ +{ + "version": "3", + "templates": [ + { + "type": 3, + "title": "HealthLog", + "name": "healthlog", + "description": "Self-hosted personal health tracker (PWA + native iOS client): weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications on one encrypted timeline. AES-256-GCM at rest, no vendor cloud, no telemetry. Ships app + PostgreSQL 16 as one stack.", + "note": "Generate each secret with: openssl rand -hex 32 (a 64-char hex string). For plain-HTTP / LAN access set SESSION_COOKIE_SECURE to false, or login fails. Set NEXT_PUBLIC_APP_URL and APP_URL to how you reach the app. Full guide: https://github.com/MBombeck/HealthLog/blob/main/docs/self-hosting/getting-started.md", + "categories": ["Tools", "Other"], + "platform": "linux", + "logo": "https://raw.githubusercontent.com/MBombeck/HealthLog/main/public/logo-512.png", + "repository": { + "url": "https://github.com/MBombeck/HealthLog", + "stackfile": "docker-compose.yml" + }, + "env": [ + { + "name": "POSTGRES_PASSWORD", + "label": "PostgreSQL password", + "description": "Database password. Generate with: openssl rand -hex 32" + }, + { + "name": "ENCRYPTION_KEY", + "label": "Encryption key (data at rest)", + "description": "AES-256-GCM key. Generate with: openssl rand -hex 32. Back this up — losing it makes encrypted data unrecoverable." + }, + { + "name": "API_TOKEN_HMAC_KEY", + "label": "API token HMAC key", + "description": "Generate with: openssl rand -hex 32" + }, + { + "name": "SESSION_COOKIE_SECURE", + "label": "Session cookie Secure flag", + "description": "Set to false for plain-HTTP / LAN access, or login fails. Set to true only behind an HTTPS reverse proxy.", + "default": "false" + }, + { + "name": "NEXT_PUBLIC_APP_URL", + "label": "Public app URL", + "description": "How users reach HealthLog, e.g. http://nas:3000 or https://healthlog.example.com", + "default": "http://localhost:3000" + }, + { + "name": "APP_URL", + "label": "Internal app URL", + "description": "Normally identical to the public app URL.", + "default": "http://localhost:3000" + } + ] + } + ] +} diff --git a/docs/self-hosting/unraid.md b/docs/self-hosting/unraid.md new file mode 100644 index 000000000..062562321 --- /dev/null +++ b/docs/self-hosting/unraid.md @@ -0,0 +1,137 @@ +# HealthLog on Unraid + +HealthLog runs on Unraid in two ways. The **Compose Manager** path is the clean, +self-contained install (app + database as one unit) and is recommended. The +**Community Applications (CA)** template gives you the familiar one-app-at-a-time +Unraid UI, but because CA templates describe a single container you install +PostgreSQL separately and wire the two together. + +HealthLog stores all persistent state in PostgreSQL — the app container itself is +stateless (the offline geo-IP databases are baked into the image). A backup of +the database is a backup of everything. + +## What you need + +- Unraid 6.12 or newer. +- The image: `ghcr.io/mbombeck/healthlog:latest` (multi-arch, amd64 + arm64). +- Three secrets, each generated with `openssl rand -hex 32` (a 64-character hex + string). On Unraid open the terminal (the `>_` icon, top-right) and run the + command three times: + - `POSTGRES_PASSWORD` — the database password + - `ENCRYPTION_KEY` — AES-256-GCM key for data at rest + - `API_TOKEN_HMAC_KEY` — API token hashing key + +Keep `ENCRYPTION_KEY` safe and backed up. If you lose it, the encrypted columns +become unrecoverable. + +--- + +## Recommended: Compose Manager + +This installs the app and its database together, exactly as the project's +`docker-compose.yml` describes. + +1. Install **Compose Manager** from Community Applications (search "Compose + Manager", by dcflachs). +2. **Settings → Docker → Compose Manager → Add New Stack**, name it `healthlog`. +3. Paste the project + [`docker-compose.yml`](https://github.com/MBombeck/HealthLog/blob/main/docker-compose.yml) + into the stack's compose file. +4. Edit the stack's `.env` (the "Edit Stack → env" field) and set: + + ```env + POSTGRES_PASSWORD= + ENCRYPTION_KEY= + API_TOKEN_HMAC_KEY= + SESSION_COOKIE_SECURE=false + NEXT_PUBLIC_APP_URL=http://tower:3000 + APP_URL=http://tower:3000 + ``` + + Replace `tower` with your server's hostname or IP. `SESSION_COOKIE_SECURE=false` + is required for plain-HTTP LAN access — see the note below. + +5. **Compose Up**. The app waits for PostgreSQL, runs database migrations on + first boot, then starts serving. +6. Open `http://tower:3000`. The first account you register becomes the admin. + +Verify the running version: + +```bash +curl -s http://tower:3000/api/version +``` + +--- + +## Alternative: Community Applications template + +Use this if you prefer the per-app Unraid UI. You install PostgreSQL yourself, +then add HealthLog pointed at it. + +### Step 1 — install PostgreSQL 16 + +1. In **Apps** (Community Applications), search for a **PostgreSQL** container + (for example jj9987's `postgresql`). Install version 16. +2. Set: + - `POSTGRES_USER` = `healthlog` + - `POSTGRES_PASSWORD` = your generated password + - `POSTGRES_DB` = `healthlog` +3. Map the Postgres data path to a share on the array (e.g. + `/mnt/user/appdata/healthlog-db`) so the database survives container updates. +4. Note the container's IP (or container name if both run on the same custom + Docker network). + +### Step 2 — add the HealthLog template + +The template lives at +[`docs/self-hosting/unraid/healthlog.xml`](unraid/healthlog.xml). Add it via +**Docker → Add Container → Template** and paste the raw URL, or copy it into +`/boot/config/plugins/dockerMan/templates-user/` and pick it from the template +dropdown. + +Fill in the fields: + +| Field | Value | +| ----------------------- | -------------------------------------------------------------------------------- | +| WebUI Port | `3000` | +| `DATABASE_URL` | `postgresql://healthlog:@:5432/healthlog?schema=public` | +| `ENCRYPTION_KEY` | your generated key | +| `API_TOKEN_HMAC_KEY` | your generated key | +| `SESSION_COOKIE_SECURE` | `false` for plain-HTTP LAN access | +| `NEXT_PUBLIC_APP_URL` | `http://tower:3000` (or your domain) | +| `APP_URL` | same as `NEXT_PUBLIC_APP_URL` | + +Replace `` and `` with the values from Step 1. Apply, +then open `http://tower:3000` — the first registered user becomes the admin. + +The HealthLog container has no persistent volume of its own; all state is in the +PostgreSQL container you installed in Step 1. Back that one up. + +--- + +## The one setting that trips people up + +`SESSION_COOKIE_SECURE=false` is required for plain-HTTP LAN installs +(`http://tower:3000`). Left at its default, the session cookie carries the +`Secure` flag, the browser refuses to send it over HTTP, and login appears to +"do nothing" — the page reloads to the login screen with no error. + +Set `SESSION_COOKIE_SECURE=true` (or leave it unset) only when a TLS reverse +proxy serves HealthLog over HTTPS. In that case also point `NEXT_PUBLIC_APP_URL` +and `APP_URL` at the public `https://` URL. + +## Updates + +Pin a release tag (`ghcr.io/mbombeck/healthlog:vX.Y.Z`) and bump deliberately, +or track `:latest` and let Unraid's update check re-pull. Skim the +[CHANGELOG](https://github.com/MBombeck/HealthLog/blob/main/CHANGELOG.md) before +upgrading, and back up the database first. Migrations are forward-only and run +automatically on container start. + +## Health check + +Both `/api/health` and `/api/version` respond without authentication. +`/api/health` returns `200` when the database and background worker are up +(`503` otherwise) and is the container health probe baked into the image. +`/api/version` returns the version, build SHA, and build timestamp for deploy +verification. diff --git a/docs/self-hosting/unraid/healthlog.xml b/docs/self-hosting/unraid/healthlog.xml new file mode 100644 index 000000000..3ad71c0f5 --- /dev/null +++ b/docs/self-hosting/unraid/healthlog.xml @@ -0,0 +1,64 @@ + + + + HealthLog + ghcr.io/mbombeck/healthlog:latest + https://github.com/MBombeck/HealthLog/pkgs/container/healthlog + bridge + + sh + false + https://github.com/MBombeck/HealthLog/issues + https://github.com/MBombeck/HealthLog + + Self-hosted personal health tracker (PWA + native iOS client): weight, blood + pressure, pulse, blood glucose, body composition, sleep, mood, menstrual + cycle, and medications on one encrypted timeline. AES-256-GCM at rest, no + vendor cloud, no telemetry. + + REQUIRES a separate PostgreSQL 16 container — install one first (CA has + community PostgreSQL templates), create a database and user, then set + DATABASE_URL below. Step-by-step guide: + https://github.com/MBombeck/HealthLog/blob/main/docs/self-hosting/unraid.md + + Generate each required secret with: openssl rand -hex 32 + + LAN / plain-HTTP installs MUST keep SESSION_COOKIE_SECURE set to false, + otherwise the session cookie carries the Secure flag, the browser drops it + over HTTP, and login silently fails. + + Tools: Productivity: Other: + http://[IP]:[PORT:3000]/ + https://raw.githubusercontent.com/MBombeck/HealthLog/main/public/logo-512.png + --security-opt no-new-privileges:true + + + + 3000 + + + + + + + + false + + + + + + + + + diff --git a/messages/de.json b/messages/de.json index 53988cf76..4f600dbab 100644 --- a/messages/de.json +++ b/messages/de.json @@ -301,6 +301,10 @@ "viewSleep": "Schlaf ansehen", "logMeasurement": "Messung erfassen", "viewInsights": "Insights öffnen" + }, + "briefing": { + "title": "Tagesbriefing", + "viewAll": "Insights öffnen" } }, "greetingSalutation": "Hallo, {name}", @@ -540,6 +544,7 @@ "typeCardioLoad": "Kardio-Belastung", "typeSleepScore": "Schlafscore", "typeBodyTemperatureDeviation": "Körpertemperaturabweichung", + "typeResilience": "Resilienz", "sourceComputed": "Berechnet", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", @@ -2082,6 +2087,7 @@ "errorCredentialExpired": "Deine Verbindung zum KI-Anbieter ist abgelaufen. Stelle sie in den Einstellungen wieder her, damit der Coach weiterläuft.", "tagline": "Persönlicher Gesundheitscoach, auf deine Daten gestützt.", "newChat": "Neuer Chat", + "actionsMenu": "Aktionen", "maximizeAriaLabel": "Im Vollbild öffnen", "minimizeAriaLabel": "Zurück zum Seitenfenster", "send": "Senden", @@ -2484,21 +2490,21 @@ "skinTemperatureTitle": "Hauttemperatur", "skinTemperatureBody": "Die Hauttemperatur ist hier die kleine nächtliche Abweichung deiner Handgelenkstemperatur von deinem eigenen Ausgangswert, keine absolute Fiebermessung. Veränderungen können Schlaf, Umgebung, Krankheit oder Zyklusphase widerspiegeln.", "cardioFitnessTitle": "Cardiofitness (VO₂max)", - "cardioFitnessBody": "VO₂max gibt an, wie viel Sauerstoff dein Körper bei maximaler Anstrengung verwerten kann. Es ist der aussagekräftigste Laborwert für die Ausdauerfitness; ein höherer oder steigender Wert spricht für eine gute Herz-Kreislauf-Gesundheit.", + "cardioFitnessBody": "VO₂max gibt an, wie viel Sauerstoff dein Körper bei maximaler Anstrengung verwerten kann — der beste Laborindex der Ausdauerfitness, den Wearables indirekt schätzen. Als allgemeine Orientierung: Fitness ist ein abgestufter Prädiktor der Überlebensrate — jeder zusätzliche MET (etwa 3,5 ml/kg/min) geht mit rund 10–25 % geringerer Sterblichkeit einher (AHA, Circulation 2016). Ein höherer oder steigender Trend ist ein starkes Zeichen für eine gute Herz-Kreislauf-Gesundheit.", "cardioRecoveryTitle": "Erholungspuls", "cardioRecoveryBody": "Der Erholungspuls gibt an, um wie viele Schläge pro Minute dein Puls in der ersten Minute nach dem Ende einer anstrengenden Belastung fällt. Ein größerer Abfall bedeutet, dass sich dein Herz schneller beruhigt — ein Zeichen guter Fitness.", "wristTemperatureTitle": "Handgelenktemperatur", - "wristTemperatureBody": "Die Handgelenktemperatur wird im Schlaf gemessen. Apple zeigt sie als Abweichung von deinem persönlichen Ausgangswert; ein gleichmäßiger Wert ist normal, ein anhaltender Anstieg kann auf Krankheit, intensives Training oder eine Zyklusveränderung hindeuten.", + "wristTemperatureBody": "Die Handgelenktemperatur wird im Schlaf gemessen und als Abweichung von deinem persönlichen Ausgangswert angegeben, nicht als absoluter Wert. Ein gleichmäßiger Wert ist normal, ein anhaltender Anstieg kann auf Krankheit, intensives Training oder eine Zyklusveränderung hindeuten.", "fallsTitle": "Stürze", - "fallsBody": "Hier werden die von deiner Apple Watch erkannten schweren Stürze gezählt. Null ist das Ziel; eine steigende Zahl ist ein Hinweis auf ein erhöhtes Sturzrisiko, das du — besonders im höheren Alter — mit einer Ärztin oder einem Arzt besprechen solltest.", + "fallsBody": "Hier werden die von deinem Wearable erkannten schweren Stürze gezählt. Null ist das Ziel; eine steigende Zahl ist ein Hinweis auf ein erhöhtes Sturzrisiko, das du — besonders im höheren Alter — mit einer Ärztin oder einem Arzt besprechen solltest.", "sixMinuteWalkTitle": "6-Minuten-Gehstrecke", - "sixMinuteWalkBody": "Dies ist Apples Schätzung, wie weit du in sechs Minuten gehen könntest — ein etablierter klinischer Test für Mobilität und kardiopulmonale Ausdauer. Eine gleichbleibende oder steigende Strecke ist ein gutes Zeichen.", + "sixMinuteWalkBody": "Dies ist eine Schätzung, wie weit du in sechs Minuten gehen könntest — ein etablierter klinischer Test für Mobilität und kardiopulmonale Ausdauer. Der Wearable-Wert ist ein Näherungswert, nicht der überwachte klinische Test; aussagekräftig ist der gleichbleibende oder steigende Trend.", "stairAscentSpeedTitle": "Treppensteig-Tempo", "stairAscentSpeedBody": "Dies gibt an, wie schnell du eine Treppe hinaufgehst. Es ist ein empfindlicher Marker für Beinkraft und Mobilität — ein gleichbleibendes oder steigendes Tempo ist gut, ein sinkendes solltest du im Blick behalten.", "stairDescentSpeedTitle": "Treppab-Tempo", "stairDescentSpeedBody": "Dies gibt an, wie schnell du eine Treppe hinabgehst. Zusammen mit dem Steig-Tempo zeigt es Beinkontrolle und Gleichgewicht — ein gleichmäßiges Tempo ist gut, ein sinkendes solltest du im Blick behalten.", "breathingDisturbancesTitle": "Atemaussetzer im Schlaf", - "breathingDisturbancesBody": "Deine Apple Watch kann schätzen, wie oft deine Atmung im Schlaf unterbrochen war. Ein niedriger Index ist besser; ein von Apple als erhöht eingestufter Wert — vor allem über viele Nächte — sollte mit einer Ärztin oder einem Arzt besprochen werden.", + "breathingDisturbancesBody": "Ein Wearable kann schätzen, wie oft deine Atmung im Schlaf unterbrochen war. Ein niedriger Index ist besser; ein als erhöht eingestufter Wert — vor allem über viele Nächte — sollte mit einer Ärztin oder einem Arzt besprochen werden. Es ist ein Screening-Näherungswert, keine Schlafapnoe-Diagnose.", "stepsTitle": "Schritte", "stepsBody": "Die Schrittzahl ist die Gesamtzahl der Schritte, die du täglich gehst. Sie ist das einfachste Maß für Alltagsbewegung, und eine stetige Gewohnheit zählt mehr als ein einzelner großer Tag. Als allgemeine Orientierung fällt das Sterblichkeitsrisiko deutlich Richtung etwa 8.000 Schritte pro Tag, mit weiterem Nutzen bis rund 12.000 (Saint-Maurice JAMA 2020); bei älteren Menschen liegt der optimale Wert niedriger. Die „10.000“ ist ein Werbeslogan, keine Forschung.", "readinessBody": "Die Tagesform ist ein einzelner Tageswert dafür, wie bereit dein Körper für den Tag ist — er verbindet Ruhepuls, Herzfrequenzvariabilität, Schlaf und die Beständigkeit deiner letzten Nächte. Es ist ein Geräte-Näherungswert, der an deinem eigenen Ausgangswert gemessen wird, keine klinische Messung, und zwischen Geräten nicht vergleichbar. Ein höherer Wert heißt erholt und belastbar; ein niedrigerer ist ein sanfter Hinweis, einen Gang zurückzuschalten.", @@ -4703,6 +4709,7 @@ "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." @@ -5682,8 +5689,6 @@ }, "notifications": { "title": "Benachrichtigungen", - "breadcrumbInbox": "Benachrichtigungen", - "breadcrumbChannels": "Benachrichtigungs-Kanäle", "eventColumn": "Ereignis", "subtitle": "Wähle aus, welche Benachrichtigungen du über welchen Kanal erhalten möchtest.", "loadError": "Benachrichtigungseinstellungen konnten nicht geladen werden.", @@ -5780,7 +5785,13 @@ "eventCycleFertileSoon": "Fruchtbares Fenster steht bevor", "eventCycleFertileSoonDesc": "Wird einige Tage vor deinem geschätzten fruchtbaren Fenster gesendet, wenn dein Ziel die Empfängnis ist.", "eventMedicationLowStock": "Medikamentenvorrat niedrig", - "eventMedicationLowStockDesc": "Wenn ein erfasster Vorrat weniger Tage abdeckt als deine Vorratsschwelle" + "eventMedicationLowStockDesc": "Wenn ein erfasster Vorrat weniger Tage abdeckt als deine Vorratsschwelle", + "eventCoachNudge": "Coach-Hinweis", + "eventCoachNudgeDesc": "Gelegentlicher Hinweis deines Coaches, wenn deine jüngsten Verläufe einen Check-in nahelegen.", + "eventMeasurementReminder": "Mess-Erinnerung", + "eventMeasurementReminderDesc": "Wird gesendet, wenn eine erfasste Messung fällig ist.", + "eventMedicationIntakeSync": "Einnahme-Synchronisierung", + "eventMedicationIntakeSyncDesc": "Geräteübergreifende Aktualisierung, wenn eine Dosis protokolliert, eingenommen oder übersprungen wird." }, "i18n": { "maintainershipBanner": { @@ -5906,6 +5917,16 @@ "confirmed": "{name} bestätigt.", "genericConfirmed": "Bestätigt.", "cancelled": "Abgebrochen.", + "moodLogged": "Notiert: {score}/5", + "moodNotePrompt": "Kurze Notiz hinzufügen (optional).", + "moodNoteSaved": "Notiz gespeichert.", + "moodNoteExpired": "Diese Abfrage ist abgelaufen.", + "remindLater": "Erinnerung um {duration} verschoben.", + "measurementDone": "Als erledigt markiert.", + "measurementNotFound": "Erinnerung nicht gefunden.", + "buttonMoodNote": "Notiz…", + "buttonLater": "Später", + "buttonDone": "Erledigt", "noActiveMedications": "Keine aktiven Medikamente gefunden.", "whichMedication": "Welches Medikament eingenommen?", "cancelButton": "Abbrechen", @@ -6271,6 +6292,7 @@ "rawChartModeDescription": "Zeige nur deine erfassten Daten, ohne Prognosen oder Deutung.", "secondarySymptomLabel": "Zweites Fruchtbarkeitszeichen", "secondarySymptomDescription": "Das zweite Zeichen, das für die symptothermale Doppelkontrolle mit deiner Temperatur kombiniert wird. Die meisten beobachten den Zervixschleim; wechsle zur Gebärmutterhals-Beobachtung, wenn du das bevorzugst.", + "secondarySymptomCitation": "Mehr zur Methode (Studie)", "secondarySymptom": { "MUCUS": "Zervixschleim", "CERVIX": "Gebärmutterhals" diff --git a/messages/en.json b/messages/en.json index 7e15661ef..0c7cde8de 100644 --- a/messages/en.json +++ b/messages/en.json @@ -301,6 +301,10 @@ "viewSleep": "View sleep", "logMeasurement": "Log measurement", "viewInsights": "Open Insights" + }, + "briefing": { + "title": "Today’s briefing", + "viewAll": "Open Insights" } }, "greetingSalutation": "Hi, {name}", @@ -540,6 +544,7 @@ "typeCardioLoad": "Cardio load", "typeSleepScore": "Sleep score", "typeBodyTemperatureDeviation": "Body temperature deviation", + "typeResilience": "Resilience", "sourceComputed": "Computed", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", @@ -2082,6 +2087,7 @@ "errorCredentialExpired": "Your AI provider connection has expired. Reconnect it in Settings to keep the Coach running.", "tagline": "Personal health coach, grounded in your data.", "newChat": "New chat", + "actionsMenu": "Actions", "maximizeAriaLabel": "Open in full page", "minimizeAriaLabel": "Back to the drawer", "send": "Send", @@ -2484,21 +2490,21 @@ "skinTemperatureTitle": "Skin temperature", "skinTemperatureBody": "Skin temperature here is the small nightly change in your wrist temperature from your own baseline, not an absolute fever reading. Shifts can track sleep, environment, illness, or menstrual-cycle phase.", "cardioFitnessTitle": "Cardio fitness (VO₂ max)", - "cardioFitnessBody": "VO₂ max is how much oxygen your body can use at peak effort. It is the single best lab marker of aerobic fitness, and a higher or rising value is a strong sign of cardiovascular health.", + "cardioFitnessBody": "VO₂ max is how much oxygen your body can use at peak effort — the best lab index of aerobic fitness, which wearables estimate indirectly. As general guidance, fitness is a graded predictor of survival: each one-MET (about 3.5 mL/kg/min) higher value associates with roughly 10–25% lower mortality (AHA, Circulation 2016). A higher or rising trend is a strong sign of cardiovascular health.", "cardioRecoveryTitle": "Cardio recovery", "cardioRecoveryBody": "Cardio recovery is how many beats per minute your heart rate falls in the first minute after you stop hard exercise. A bigger drop means your heart settles down faster, which is a sign of good fitness.", "wristTemperatureTitle": "Wrist temperature", - "wristTemperatureBody": "Wrist temperature is measured while you sleep. Apple shows it as the difference from your personal baseline; a steady reading is normal, while a sustained rise can hint at illness, intense training, or a cycle change.", + "wristTemperatureBody": "Wrist temperature is measured while you sleep and reported as the difference from your personal baseline, not an absolute value. A steady reading is normal, while a sustained rise can hint at illness, intense training, or a menstrual-cycle change.", "fallsTitle": "Falls", - "fallsBody": "This counts the hard falls your Apple Watch detects. Zero is the goal; a rising count is a mobility-risk signal worth raising with a clinician, especially for older adults.", + "fallsBody": "This counts the hard falls your wearable detects. Zero is the goal; a rising count is a mobility-risk signal worth raising with a clinician, especially for older adults.", "sixMinuteWalkTitle": "Six-minute walk distance", - "sixMinuteWalkBody": "This is Apple's estimate of how far you could walk in six minutes — a long-standing clinical test of mobility and cardiopulmonary endurance. A steady or rising distance is a good sign.", + "sixMinuteWalkBody": "This is an estimate of how far you could walk in six minutes — a long-standing clinical test of mobility and cardiopulmonary endurance. The wearable figure is a proxy, not the supervised clinical test; a steady or rising trend is the useful read.", "stairAscentSpeedTitle": "Stair ascent speed", "stairAscentSpeedBody": "This is how fast you climb a flight of stairs. It is a sensitive marker of leg strength and mobility — a steady or rising pace is a good sign, and a falling one is worth watching.", "stairDescentSpeedTitle": "Stair descent speed", "stairDescentSpeedBody": "This is how fast you go down a flight of stairs. Together with ascent speed it tracks leg control and balance — a steady pace is a good sign, and a falling one is worth watching.", "breathingDisturbancesTitle": "Sleep breathing disturbances", - "breathingDisturbancesBody": "Your Apple Watch can estimate how often your breathing was interrupted during sleep. A lower index is better; a level Apple flags as elevated, especially over many nights, is worth raising with a clinician.", + "breathingDisturbancesBody": "A wearable can estimate how often your breathing was interrupted during sleep. A lower index is better; a level flagged as elevated, especially across many nights, is worth raising with a clinician. It is a screening proxy, not a sleep-apnea diagnosis.", "stepsTitle": "Steps", "stepsBody": "Step count is the total number of steps you take each day. It is the simplest measure of everyday movement, and a steady habit matters more than any single big day. As general guidance, mortality risk falls steeply toward about 8,000 steps a day with continued benefit through roughly 12,000 (Saint-Maurice JAMA 2020); the optimal count is lower for older adults. The '10,000' figure is a marketing slogan, not research.", "readinessBody": "Readiness is a single daily read on how prepared your body is to take on the day, blending resting heart rate, heart-rate variability, sleep, and how steady your recent nights have been. It is a device proxy benchmarked against your own baseline, not a clinical measure, and scores are not comparable across devices. A higher score means recovered and resourced; a lower one is a gentle cue to ease off.", @@ -4703,6 +4709,7 @@ "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." @@ -5682,8 +5689,6 @@ }, "notifications": { "title": "Notifications", - "breadcrumbInbox": "Notifications", - "breadcrumbChannels": "Notification channels", "eventColumn": "Event", "subtitle": "Choose which notifications you want to receive through which channel.", "loadError": "Notification preferences could not be loaded.", @@ -5780,7 +5785,13 @@ "eventCycleFertileSoon": "Fertile window coming up", "eventCycleFertileSoonDesc": "Sent a few days before your estimated fertile window when your goal is trying to conceive.", "eventMedicationLowStock": "Medication stock low", - "eventMedicationLowStockDesc": "When a tracked medication supply covers fewer days than your low-stock threshold" + "eventMedicationLowStockDesc": "When a tracked medication supply covers fewer days than your low-stock threshold", + "eventCoachNudge": "Coach nudge", + "eventCoachNudgeDesc": "Occasional prompt from your Coach when your recent trends warrant a check-in.", + "eventMeasurementReminder": "Measurement reminder", + "eventMeasurementReminderDesc": "Sent when a tracked measurement is due.", + "eventMedicationIntakeSync": "Medication intake sync", + "eventMedicationIntakeSyncDesc": "Cross-device update when a dose is logged, taken or skipped." }, "i18n": { "maintainershipBanner": { @@ -5906,6 +5917,16 @@ "confirmed": "{name} confirmed.", "genericConfirmed": "Confirmed.", "cancelled": "Cancelled.", + "moodLogged": "Logged: {score}/5", + "moodNotePrompt": "Add a short note (optional).", + "moodNoteSaved": "Note saved.", + "moodNoteExpired": "That prompt has expired.", + "remindLater": "Reminder postponed for {duration}.", + "measurementDone": "Marked as done.", + "measurementNotFound": "Reminder not found.", + "buttonMoodNote": "Note…", + "buttonLater": "Later", + "buttonDone": "Done", "noActiveMedications": "No active medications found.", "whichMedication": "Which medication did you take?", "cancelButton": "Cancel", @@ -6276,6 +6297,7 @@ "rawChartModeDescription": "Show only your logged data with no predictions or interpretation.", "secondarySymptomLabel": "Secondary fertility sign", "secondarySymptomDescription": "The second sign paired with your temperature for the symptothermal double-check. Most people track cervical mucus; switch to cervix observation if you prefer.", + "secondarySymptomCitation": "Read about the method (study)", "secondarySymptom": { "MUCUS": "Cervical mucus", "CERVIX": "Cervix observation" diff --git a/messages/es.json b/messages/es.json index f283e8fd7..7899eb0a7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -301,6 +301,10 @@ "viewSleep": "Ver sueño", "logMeasurement": "Registrar medición", "viewInsights": "Abrir Insights" + }, + "briefing": { + "title": "Resumen de hoy", + "viewAll": "Abrir Insights" } }, "greetingSalutation": "Hola, {name}", @@ -540,6 +544,7 @@ "typeCardioLoad": "Carga cardio", "typeSleepScore": "Puntuación de sueño", "typeBodyTemperatureDeviation": "Desviación de temperatura corporal", + "typeResilience": "Resiliencia", "sourceComputed": "Calculado", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", @@ -2082,6 +2087,7 @@ "errorCredentialExpired": "Tu conexión con el proveedor de IA ha caducado. Vuelve a conectarla en Ajustes para que el Coach siga funcionando.", "tagline": "Coach personal de salud, apoyado en tus datos.", "newChat": "Nueva conversación", + "actionsMenu": "Acciones", "maximizeAriaLabel": "Abrir en pantalla completa", "minimizeAriaLabel": "Volver al panel", "send": "Enviar", @@ -2484,21 +2490,21 @@ "skinTemperatureTitle": "Temperatura de la piel", "skinTemperatureBody": "La temperatura de la piel aquí es el pequeño cambio nocturno de la temperatura de tu muñeca respecto a tu propio valor de referencia, no una medición absoluta de fiebre. Las variaciones pueden seguir el sueño, el entorno, una enfermedad o la fase del ciclo menstrual.", "cardioFitnessTitle": "Condición cardiovascular (VO₂ máx)", - "cardioFitnessBody": "El VO₂ máx es la cantidad de oxígeno que tu cuerpo puede usar al máximo esfuerzo. Es el mejor marcador de laboratorio de la forma aeróbica, y un valor más alto o en aumento es una buena señal de salud cardiovascular.", + "cardioFitnessBody": "El VO₂ máx es la cantidad de oxígeno que tu cuerpo puede usar al máximo esfuerzo: el mejor índice de laboratorio de la forma aeróbica, que los wearables estiman de forma indirecta. Como orientación general, la forma física es un predictor gradual de supervivencia: cada MET adicional (unos 3,5 ml/kg/min) se asocia a una mortalidad entre un 10 % y un 25 % menor (AHA, Circulation 2016). Una tendencia más alta o en aumento es una fuerte señal de salud cardiovascular.", "cardioRecoveryTitle": "Recuperación cardiaca", "cardioRecoveryBody": "La recuperación cardiaca es cuántas pulsaciones por minuto baja tu frecuencia cardiaca en el primer minuto tras parar un ejercicio intenso. Una caída mayor significa que tu corazón se calma más rápido, señal de buena forma física.", "wristTemperatureTitle": "Temperatura de muñeca", - "wristTemperatureBody": "La temperatura de muñeca se mide mientras duermes. Apple la muestra como la diferencia respecto a tu valor de referencia; un valor estable es normal, mientras que una subida sostenida puede indicar enfermedad, entrenamiento intenso o un cambio del ciclo.", + "wristTemperatureBody": "La temperatura de muñeca se mide mientras duermes y se indica como la diferencia respecto a tu valor de referencia, no como un valor absoluto. Un valor estable es normal, mientras que una subida sostenida puede indicar enfermedad, entrenamiento intenso o un cambio del ciclo.", "fallsTitle": "Caídas", - "fallsBody": "Esto cuenta las caídas fuertes que detecta tu Apple Watch. El objetivo es cero; un número creciente es una señal de riesgo de movilidad que conviene comentar con un profesional, sobre todo en personas mayores.", + "fallsBody": "Esto cuenta las caídas fuertes que detecta tu wearable. El objetivo es cero; un número creciente es una señal de riesgo de movilidad que conviene comentar con un profesional, sobre todo en personas mayores.", "sixMinuteWalkTitle": "Distancia en marcha de 6 minutos", - "sixMinuteWalkBody": "Es la estimación de Apple de cuánto podrías caminar en seis minutos, una prueba clínica clásica de movilidad y resistencia cardiopulmonar. Una distancia estable o en aumento es una buena señal.", + "sixMinuteWalkBody": "Es una estimación de cuánto podrías caminar en seis minutos, una prueba clínica clásica de movilidad y resistencia cardiopulmonar. El valor del wearable es una aproximación, no la prueba clínica supervisada; lo útil es la tendencia estable o en aumento.", "stairAscentSpeedTitle": "Velocidad subiendo escaleras", "stairAscentSpeedBody": "Es lo rápido que subes un tramo de escaleras. Es un marcador sensible de fuerza de piernas y movilidad: un ritmo estable o en aumento es buena señal y uno que baja conviene vigilar.", "stairDescentSpeedTitle": "Velocidad bajando escaleras", "stairDescentSpeedBody": "Es lo rápido que bajas un tramo de escaleras. Junto con la velocidad de subida refleja el control de piernas y el equilibrio: un ritmo estable es buena señal y uno que baja conviene vigilar.", "breathingDisturbancesTitle": "Alteraciones respiratorias del sueño", - "breathingDisturbancesBody": "Tu Apple Watch puede estimar con qué frecuencia se interrumpió tu respiración mientras dormías. Un índice más bajo es mejor; un nivel que Apple marca como elevado, sobre todo durante muchas noches, conviene comentarlo con un profesional.", + "breathingDisturbancesBody": "Un wearable puede estimar con qué frecuencia se interrumpió tu respiración mientras dormías. Un índice más bajo es mejor; un nivel marcado como elevado, sobre todo durante muchas noches, conviene comentarlo con un profesional. Es una aproximación de cribado, no un diagnóstico de apnea del sueño.", "stepsTitle": "Pasos", "stepsBody": "El recuento de pasos es el número total de pasos que das cada día. Es la medida más sencilla del movimiento cotidiano: un recuento diario más alto suele reflejar un estilo de vida más activo, y un hábito constante importa más que un único día destacado.", "readinessBody": "La preparación es una lectura diaria de lo listo que está tu cuerpo para afrontar el día, combinando la frecuencia cardíaca en reposo, la variabilidad de la frecuencia cardíaca, el sueño y la regularidad de tus últimas noches. Una puntuación más alta indica que tu organismo se ve recuperado y con recursos; una más baja es un aviso suave para bajar el ritmo.", @@ -4703,6 +4709,7 @@ "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." @@ -5682,8 +5689,6 @@ }, "notifications": { "title": "Notificaciones", - "breadcrumbInbox": "Notificaciones", - "breadcrumbChannels": "Canales de notificación", "eventColumn": "Evento", "subtitle": "Elige qué notificaciones recibes y por qué canal.", "loadError": "No se han podido cargar los ajustes de notificación.", @@ -5780,7 +5785,13 @@ "eventCycleFertileSoon": "Ventana fértil próxima", "eventCycleFertileSoonDesc": "Se envía unos días antes de tu ventana fértil estimada cuando tu objetivo es concebir.", "eventMedicationLowStock": "Existencias de medicación bajas", - "eventMedicationLowStockDesc": "Cuando unas existencias registradas cubren menos días que tu umbral de existencias" + "eventMedicationLowStockDesc": "Cuando unas existencias registradas cubren menos días que tu umbral de existencias", + "eventCoachNudge": "Aviso del Coach", + "eventCoachNudgeDesc": "Aviso ocasional de tu Coach cuando tus tendencias recientes justifican una revisión.", + "eventMeasurementReminder": "Recordatorio de medición", + "eventMeasurementReminderDesc": "Se envía cuando una medición registrada está pendiente.", + "eventMedicationIntakeSync": "Sincronización de tomas", + "eventMedicationIntakeSyncDesc": "Actualización entre dispositivos cuando se registra, toma u omite una dosis." }, "i18n": { "maintainershipBanner": { @@ -5906,6 +5917,16 @@ "confirmed": "{name} confirmado.", "genericConfirmed": "Confirmado.", "cancelled": "Cancelado.", + "moodLogged": "Registrado: {score}/5", + "moodNotePrompt": "Añade una nota breve (opcional).", + "moodNoteSaved": "Nota guardada.", + "moodNoteExpired": "Esa solicitud ha caducado.", + "remindLater": "Recordatorio pospuesto {duration}.", + "measurementDone": "Marcado como hecho.", + "measurementNotFound": "Recordatorio no encontrado.", + "buttonMoodNote": "Nota…", + "buttonLater": "Más tarde", + "buttonDone": "Hecho", "noActiveMedications": "No se han encontrado medicamentos activos.", "whichMedication": "¿Qué medicamento has tomado?", "cancelButton": "Cancelar", @@ -6271,6 +6292,7 @@ "rawChartModeDescription": "Muestra solo tus datos registrados, sin predicciones ni interpretación.", "secondarySymptomLabel": "Segundo signo de fertilidad", "secondarySymptomDescription": "El segundo signo que se combina con tu temperatura para la doble comprobación sintotérmica. La mayoría observa el moco cervical; cambia a la observación del cuello uterino si lo prefieres.", + "secondarySymptomCitation": "Más sobre el método (estudio)", "secondarySymptom": { "MUCUS": "Moco cervical", "CERVIX": "Cuello uterino" diff --git a/messages/fr.json b/messages/fr.json index b58eff6cb..e923ab65c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -301,6 +301,10 @@ "viewSleep": "Voir le sommeil", "logMeasurement": "Saisir une mesure", "viewInsights": "Ouvrir Insights" + }, + "briefing": { + "title": "Briefing du jour", + "viewAll": "Ouvrir Insights" } }, "greetingSalutation": "Bonjour, {name}", @@ -540,6 +544,7 @@ "typeCardioLoad": "Charge cardio", "typeSleepScore": "Score de sommeil", "typeBodyTemperatureDeviation": "Écart de température corporelle", + "typeResilience": "Résilience", "sourceComputed": "Calculé", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", @@ -2082,6 +2087,7 @@ "errorCredentialExpired": "Ta connexion au fournisseur d'IA a expiré. Reconnecte-la dans les Réglages pour que le Coach continue de fonctionner.", "tagline": "Coach personnel de santé, ancré sur tes données.", "newChat": "Nouvelle conversation", + "actionsMenu": "Actions", "maximizeAriaLabel": "Ouvrir en pleine page", "minimizeAriaLabel": "Revenir au panneau", "send": "Envoyer", @@ -2484,21 +2490,21 @@ "skinTemperatureTitle": "Température cutanée", "skinTemperatureBody": "La température cutanée correspond ici à la petite variation nocturne de la température de votre poignet par rapport à votre propre référence, et non à une mesure absolue de fièvre. Les variations peuvent suivre le sommeil, l'environnement, une maladie ou la phase du cycle menstruel.", "cardioFitnessTitle": "Capacité cardio (VO₂ max)", - "cardioFitnessBody": "Le VO₂ max correspond à la quantité d'oxygène que votre corps peut utiliser à l'effort maximal. C'est le meilleur marqueur de laboratoire de la forme aérobie, et une valeur plus élevée ou en hausse est un bon signe de santé cardiovasculaire.", + "cardioFitnessBody": "Le VO₂ max correspond à la quantité d'oxygène que votre corps peut utiliser à l'effort maximal — le meilleur indice de laboratoire de la forme aérobie, que les wearables estiment de façon indirecte. À titre indicatif, la condition physique est un prédicteur graduel de survie : chaque MET supplémentaire (environ 3,5 ml/kg/min) est associé à une mortalité inférieure d'environ 10 à 25 % (AHA, Circulation 2016). Une tendance plus élevée ou en hausse est un fort signe de santé cardiovasculaire.", "cardioRecoveryTitle": "Récupération cardiaque", "cardioRecoveryBody": "La récupération cardiaque correspond au nombre de battements par minute dont votre fréquence cardiaque diminue dans la première minute après l'arrêt d'un effort intense. Une baisse plus forte signifie que votre cœur se calme plus vite, signe d'une bonne condition physique.", "wristTemperatureTitle": "Température du poignet", - "wristTemperatureBody": "La température du poignet est mesurée pendant votre sommeil. Apple l'affiche comme l'écart par rapport à votre valeur de référence ; une valeur stable est normale, tandis qu'une hausse durable peut signaler une maladie, un entraînement intense ou un changement de cycle.", + "wristTemperatureBody": "La température du poignet est mesurée pendant votre sommeil et indiquée comme l'écart par rapport à votre valeur de référence, et non comme une valeur absolue. Une valeur stable est normale, tandis qu'une hausse durable peut signaler une maladie, un entraînement intense ou un changement de cycle.", "fallsTitle": "Chutes", - "fallsBody": "Ceci compte les chutes lourdes détectées par votre Apple Watch. L'objectif est zéro ; un nombre en hausse est un signal de risque de mobilité à évoquer avec un professionnel, surtout pour les personnes âgées.", + "fallsBody": "Ceci compte les chutes lourdes détectées par votre wearable. L'objectif est zéro ; un nombre en hausse est un signal de risque de mobilité à évoquer avec un professionnel, surtout pour les personnes âgées.", "sixMinuteWalkTitle": "Distance de marche de 6 minutes", - "sixMinuteWalkBody": "C'est l'estimation d'Apple de la distance que vous pourriez parcourir en six minutes, un test clinique classique de mobilité et d'endurance cardiopulmonaire. Une distance stable ou en hausse est un bon signe.", + "sixMinuteWalkBody": "C'est une estimation de la distance que vous pourriez parcourir en six minutes, un test clinique classique de mobilité et d'endurance cardiopulmonaire. La valeur du wearable est une approximation, pas le test clinique encadré ; c'est la tendance stable ou en hausse qui compte.", "stairAscentSpeedTitle": "Vitesse en montée d'escaliers", "stairAscentSpeedBody": "C'est la vitesse à laquelle vous montez une volée de marches. C'est un marqueur sensible de la force des jambes et de la mobilité : un rythme stable ou en hausse est bon signe, un rythme en baisse mérite attention.", "stairDescentSpeedTitle": "Vitesse en descente d'escaliers", "stairDescentSpeedBody": "C'est la vitesse à laquelle vous descendez une volée de marches. Avec la vitesse de montée, elle reflète le contrôle des jambes et l'équilibre : un rythme stable est bon signe, un rythme en baisse mérite attention.", "breathingDisturbancesTitle": "Troubles respiratoires du sommeil", - "breathingDisturbancesBody": "Votre Apple Watch peut estimer à quelle fréquence votre respiration a été interrompue pendant le sommeil. Un indice plus bas est préférable ; un niveau qu'Apple signale comme élevé, surtout sur plusieurs nuits, mérite d'être évoqué avec un professionnel.", + "breathingDisturbancesBody": "Un wearable peut estimer à quelle fréquence votre respiration a été interrompue pendant le sommeil. Un indice plus bas est préférable ; un niveau signalé comme élevé, surtout sur plusieurs nuits, mérite d'être évoqué avec un professionnel. C'est une approximation de dépistage, pas un diagnostic d'apnée du sommeil.", "stepsTitle": "Pas", "stepsBody": "Le nombre de pas correspond au total de pas que vous faites chaque jour. C'est la mesure la plus simple de l'activité quotidienne : un total journalier plus élevé reflète généralement un mode de vie plus actif, et une habitude régulière compte plus qu'une seule grosse journée.", "readinessBody": "La forme du jour est une lecture quotidienne unique de la disposition de votre corps à affronter la journée, mêlant fréquence cardiaque au repos, variabilité de la fréquence cardiaque, sommeil et régularité de vos dernières nuits. Un score élevé signifie que votre organisme paraît récupéré et disponible ; un score plus bas est une invitation douce à lever le pied.", @@ -4703,6 +4709,7 @@ "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." @@ -5682,8 +5689,6 @@ }, "notifications": { "title": "Notifications", - "breadcrumbInbox": "Notifications", - "breadcrumbChannels": "Canaux de notification", "eventColumn": "Événement", "subtitle": "Choisis quelles notifications tu reçois et par quel canal.", "loadError": "Impossible de charger les paramètres de notification.", @@ -5780,7 +5785,13 @@ "eventCycleFertileSoon": "Fenêtre fertile à venir", "eventCycleFertileSoonDesc": "Envoyé quelques jours avant votre fenêtre fertile estimée lorsque votre objectif est de concevoir.", "eventMedicationLowStock": "Stock de médicament faible", - "eventMedicationLowStockDesc": "Quand un stock suivi couvre moins de jours que votre seuil de stock" + "eventMedicationLowStockDesc": "Quand un stock suivi couvre moins de jours que votre seuil de stock", + "eventCoachNudge": "Rappel du Coach", + "eventCoachNudgeDesc": "Message ponctuel de votre Coach quand vos tendances récentes justifient un point.", + "eventMeasurementReminder": "Rappel de mesure", + "eventMeasurementReminderDesc": "Envoyé lorsqu'une mesure suivie est due.", + "eventMedicationIntakeSync": "Synchronisation des prises", + "eventMedicationIntakeSyncDesc": "Mise à jour entre appareils quand une dose est enregistrée, prise ou ignorée." }, "i18n": { "maintainershipBanner": { @@ -5906,6 +5917,16 @@ "confirmed": "{name} confirmé.", "genericConfirmed": "Confirmé.", "cancelled": "Annulé.", + "moodLogged": "Enregistré : {score}/5", + "moodNotePrompt": "Ajoutez une note brève (facultatif).", + "moodNoteSaved": "Note enregistrée.", + "moodNoteExpired": "Cette invite a expiré.", + "remindLater": "Rappel reporté de {duration}.", + "measurementDone": "Marqué comme fait.", + "measurementNotFound": "Rappel introuvable.", + "buttonMoodNote": "Note…", + "buttonLater": "Plus tard", + "buttonDone": "Fait", "noActiveMedications": "Aucun médicament actif trouvé.", "whichMedication": "Quel médicament as-tu pris ?", "cancelButton": "Annuler", @@ -6271,6 +6292,7 @@ "rawChartModeDescription": "Affiche uniquement tes données enregistrées, sans prévision ni interprétation.", "secondarySymptomLabel": "Deuxième signe de fertilité", "secondarySymptomDescription": "Le deuxième signe associé à ta température pour la double vérification sympto-thermique. La plupart des personnes observent la glaire cervicale ; passe à l'observation du col de l'utérus si tu préfères.", + "secondarySymptomCitation": "En savoir plus sur la méthode (étude)", "secondarySymptom": { "MUCUS": "Glaire cervicale", "CERVIX": "Col de l'utérus" diff --git a/messages/it.json b/messages/it.json index cc2c72640..5a4fece9a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -301,6 +301,10 @@ "viewSleep": "Vedi sonno", "logMeasurement": "Registra misurazione", "viewInsights": "Apri Insights" + }, + "briefing": { + "title": "Briefing di oggi", + "viewAll": "Apri Insights" } }, "greetingSalutation": "Ciao, {name}", @@ -540,6 +544,7 @@ "typeCardioLoad": "Carico cardio", "typeSleepScore": "Punteggio del sonno", "typeBodyTemperatureDeviation": "Deviazione della temperatura corporea", + "typeResilience": "Resilienza", "sourceComputed": "Calcolato", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", @@ -2082,6 +2087,7 @@ "errorCredentialExpired": "La connessione al provider di IA è scaduta. Riconnettila nelle Impostazioni per mantenere attivo il Coach.", "tagline": "Coach personale di salute, basato sui tuoi dati.", "newChat": "Nuova chat", + "actionsMenu": "Azioni", "maximizeAriaLabel": "Apri a pagina intera", "minimizeAriaLabel": "Torna al pannello", "send": "Invia", @@ -2484,21 +2490,21 @@ "skinTemperatureTitle": "Temperatura cutanea", "skinTemperatureBody": "La temperatura cutanea qui è la piccola variazione notturna della temperatura del polso rispetto al tuo valore di riferimento, non una misura assoluta della febbre. Le variazioni possono seguire sonno, ambiente, malattia o fase del ciclo mestruale.", "cardioFitnessTitle": "Forma cardio (VO₂ max)", - "cardioFitnessBody": "Il VO₂ max indica quanto ossigeno il tuo corpo riesce a utilizzare al massimo sforzo. È il miglior marcatore di laboratorio della forma aerobica e un valore più alto o in aumento è un buon segno di salute cardiovascolare.", + "cardioFitnessBody": "Il VO₂ max indica quanto ossigeno il tuo corpo riesce a utilizzare al massimo sforzo: il miglior indice di laboratorio della forma aerobica, che i wearable stimano in modo indiretto. Come orientamento generale, la forma fisica è un predittore graduale della sopravvivenza: ogni MET in più (circa 3,5 ml/kg/min) si associa a una mortalità inferiore di circa il 10–25 % (AHA, Circulation 2016). Una tendenza più alta o in aumento è un forte segno di salute cardiovascolare.", "cardioRecoveryTitle": "Recupero cardiaco", "cardioRecoveryBody": "Il recupero cardiaco è di quanti battiti al minuto cala la frequenza cardiaca nel primo minuto dopo aver interrotto uno sforzo intenso. Un calo maggiore significa che il cuore si calma più in fretta, segno di buona forma fisica.", "wristTemperatureTitle": "Temperatura del polso", - "wristTemperatureBody": "La temperatura del polso viene misurata mentre dormi. Apple la mostra come differenza dal tuo valore di riferimento; un valore stabile è normale, mentre un aumento prolungato può indicare malattia, allenamento intenso o un cambiamento del ciclo.", + "wristTemperatureBody": "La temperatura del polso viene misurata mentre dormi ed è indicata come differenza dal tuo valore di riferimento, non come valore assoluto. Un valore stabile è normale, mentre un aumento prolungato può indicare malattia, allenamento intenso o un cambiamento del ciclo.", "fallsTitle": "Cadute", - "fallsBody": "Qui vengono contate le cadute forti rilevate dal tuo Apple Watch. L'obiettivo è zero; un numero in aumento è un segnale di rischio di mobilità da segnalare a un medico, soprattutto per le persone anziane.", + "fallsBody": "Qui vengono contate le cadute forti rilevate dal tuo wearable. L'obiettivo è zero; un numero in aumento è un segnale di rischio di mobilità da segnalare a un medico, soprattutto per le persone anziane.", "sixMinuteWalkTitle": "Distanza nel cammino di 6 minuti", - "sixMinuteWalkBody": "È la stima di Apple di quanto potresti camminare in sei minuti, un classico test clinico di mobilità e resistenza cardiopolmonare. Una distanza stabile o in aumento è un buon segno.", + "sixMinuteWalkBody": "È una stima di quanto potresti camminare in sei minuti, un classico test clinico di mobilità e resistenza cardiopolmonare. Il valore del wearable è un'approssimazione, non il test clinico supervisionato; ciò che conta è la tendenza stabile o in aumento.", "stairAscentSpeedTitle": "Velocità in salita scale", "stairAscentSpeedBody": "È la velocità con cui sali una rampa di scale. È un marcatore sensibile di forza delle gambe e mobilità: un ritmo stabile o in aumento è un buon segno, mentre uno in calo va tenuto d'occhio.", "stairDescentSpeedTitle": "Velocità in discesa scale", "stairDescentSpeedBody": "È la velocità con cui scendi una rampa di scale. Insieme alla velocità in salita riflette il controllo delle gambe e l'equilibrio: un ritmo stabile è un buon segno, mentre uno in calo va tenuto d'occhio.", "breathingDisturbancesTitle": "Disturbi respiratori nel sonno", - "breathingDisturbancesBody": "Il tuo Apple Watch può stimare quanto spesso la respirazione si è interrotta durante il sonno. Un indice più basso è migliore; un livello che Apple segnala come elevato, soprattutto su molte notti, andrebbe segnalato a un medico.", + "breathingDisturbancesBody": "Un wearable può stimare quanto spesso la respirazione si è interrotta durante il sonno. Un indice più basso è migliore; un livello segnalato come elevato, soprattutto su molte notti, andrebbe segnalato a un medico. È un'approssimazione di screening, non una diagnosi di apnea del sonno.", "stepsTitle": "Passi", "stepsBody": "Il conteggio dei passi è il numero totale di passi che fai ogni giorno. È la misura più semplice del movimento quotidiano: un conteggio giornaliero più alto riflette di solito uno stile di vita più attivo, e un'abitudine costante conta più di un singolo giorno intenso.", "readinessBody": "La prontezza è una lettura giornaliera di quanto il tuo corpo sia pronto ad affrontare la giornata, combinando frequenza cardiaca a riposo, variabilità della frequenza cardiaca, sonno e regolarità delle ultime notti. Un punteggio più alto indica che il tuo organismo appare recuperato e pieno di risorse; uno più basso è un invito gentile a rallentare.", @@ -4703,6 +4709,7 @@ "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." @@ -5682,8 +5689,6 @@ }, "notifications": { "title": "Notifiche", - "breadcrumbInbox": "Notifiche", - "breadcrumbChannels": "Canali di notifica", "eventColumn": "Evento", "subtitle": "Scegli quali notifiche ricevere e su quale canale.", "loadError": "Impossibile caricare le impostazioni di notifica.", @@ -5780,7 +5785,13 @@ "eventCycleFertileSoon": "Finestra fertile in arrivo", "eventCycleFertileSoonDesc": "Inviato alcuni giorni prima della finestra fertile stimata quando il tuo obiettivo è il concepimento.", "eventMedicationLowStock": "Scorte di farmaco in esaurimento", - "eventMedicationLowStockDesc": "Quando una scorta tracciata copre meno giorni della tua soglia di scorte" + "eventMedicationLowStockDesc": "Quando una scorta tracciata copre meno giorni della tua soglia di scorte", + "eventCoachNudge": "Suggerimento del Coach", + "eventCoachNudgeDesc": "Messaggio occasionale del tuo Coach quando gli andamenti recenti richiedono una verifica.", + "eventMeasurementReminder": "Promemoria misurazione", + "eventMeasurementReminderDesc": "Inviato quando una misurazione tracciata è in scadenza.", + "eventMedicationIntakeSync": "Sincronizzazione assunzioni", + "eventMedicationIntakeSyncDesc": "Aggiornamento tra dispositivi quando una dose viene registrata, assunta o saltata." }, "i18n": { "maintainershipBanner": { @@ -5906,6 +5917,16 @@ "confirmed": "{name} confermato.", "genericConfirmed": "Confermato.", "cancelled": "Annullato.", + "moodLogged": "Registrato: {score}/5", + "moodNotePrompt": "Aggiungi una breve nota (facoltativo).", + "moodNoteSaved": "Nota salvata.", + "moodNoteExpired": "Questa richiesta è scaduta.", + "remindLater": "Promemoria posticipato di {duration}.", + "measurementDone": "Contrassegnato come fatto.", + "measurementNotFound": "Promemoria non trovato.", + "buttonMoodNote": "Nota…", + "buttonLater": "Più tardi", + "buttonDone": "Fatto", "noActiveMedications": "Nessun farmaco attivo trovato.", "whichMedication": "Quale farmaco hai assunto?", "cancelButton": "Annulla", @@ -6271,6 +6292,7 @@ "rawChartModeDescription": "Mostra solo i tuoi dati registrati, senza previsioni né interpretazioni.", "secondarySymptomLabel": "Secondo segno di fertilità", "secondarySymptomDescription": "Il secondo segno abbinato alla tua temperatura per il doppio controllo sintotermico. La maggior parte osserva il muco cervicale; passa all'osservazione della cervice se preferisci.", + "secondarySymptomCitation": "Scopri di più sul metodo (studio)", "secondarySymptom": { "MUCUS": "Muco cervicale", "CERVIX": "Cervice" diff --git a/messages/pl.json b/messages/pl.json index f5ea68ef1..e99e307cc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -301,6 +301,10 @@ "viewSleep": "Zobacz sen", "logMeasurement": "Zapisz pomiar", "viewInsights": "Otwórz Insights" + }, + "briefing": { + "title": "Briefing dnia", + "viewAll": "Otwórz Insights" } }, "greetingSalutation": "Cześć, {name}", @@ -540,6 +544,7 @@ "typeCardioLoad": "Obciążenie kardio", "typeSleepScore": "Wynik snu", "typeBodyTemperatureDeviation": "Odchylenie temperatury ciała", + "typeResilience": "Odporność", "sourceComputed": "Obliczone", "sourceWhoop": "WHOOP", "sourceFitbit": "Fitbit", @@ -2082,6 +2087,7 @@ "errorCredentialExpired": "Połączenie z dostawcą AI wygasło. Połącz je ponownie w Ustawieniach, aby Coach działał dalej.", "tagline": "Osobisty coach zdrowia, oparty na twoich danych.", "newChat": "Nowa rozmowa", + "actionsMenu": "Działania", "maximizeAriaLabel": "Otwórz na pełnej stronie", "minimizeAriaLabel": "Powrót do panelu", "send": "Wyślij", @@ -2484,21 +2490,21 @@ "skinTemperatureTitle": "Temperatura skóry", "skinTemperatureBody": "Temperatura skóry to tutaj niewielka nocna zmiana temperatury nadgarstka względem twojej własnej wartości bazowej, a nie bezwzględny pomiar gorączki. Zmiany mogą odzwierciedlać sen, otoczenie, chorobę lub fazę cyklu menstruacyjnego.", "cardioFitnessTitle": "Wydolność krążeniowa (VO₂ max)", - "cardioFitnessBody": "VO₂ max to ilość tlenu, jaką organizm potrafi wykorzystać przy maksymalnym wysiłku. To najlepszy laboratoryjny wskaźnik wydolności tlenowej, a wyższa lub rosnąca wartość świadczy o dobrym zdrowiu układu krążenia.", + "cardioFitnessBody": "VO₂ max to ilość tlenu, jaką organizm potrafi wykorzystać przy maksymalnym wysiłku — najlepszy laboratoryjny wskaźnik wydolności tlenowej, który urządzenia noszone szacują pośrednio. Dla orientacji: sprawność fizyczna jest stopniowanym predyktorem przeżycia — każdy dodatkowy MET (około 3,5 ml/kg/min) wiąże się z mniej więcej 10–25 % niższą śmiertelnością (AHA, Circulation 2016). Wyższy lub rosnący trend to mocny sygnał dobrego zdrowia układu krążenia.", "cardioRecoveryTitle": "Regeneracja serca", "cardioRecoveryBody": "Regeneracja serca to liczba uderzeń na minutę, o jaką spada tętno w pierwszej minucie po zakończeniu intensywnego wysiłku. Większy spadek oznacza, że serce szybciej się uspokaja, co świadczy o dobrej kondycji.", "wristTemperatureTitle": "Temperatura nadgarstka", - "wristTemperatureBody": "Temperatura nadgarstka jest mierzona podczas snu. Apple pokazuje ją jako różnicę względem Twojej wartości bazowej; stabilny odczyt jest normalny, a utrzymujący się wzrost może wskazywać na chorobę, intensywny trening lub zmianę cyklu.", + "wristTemperatureBody": "Temperatura nadgarstka jest mierzona podczas snu i podawana jako różnica względem Twojej wartości bazowej, a nie jako wartość bezwzględna. Stabilny odczyt jest normalny, a utrzymujący się wzrost może wskazywać na chorobę, intensywny trening lub zmianę cyklu.", "fallsTitle": "Upadki", - "fallsBody": "Tutaj liczone są poważne upadki wykryte przez Apple Watch. Celem jest zero; rosnąca liczba to sygnał ryzyka związanego z mobilnością, który warto omówić z lekarzem, zwłaszcza u osób starszych.", + "fallsBody": "Tutaj liczone są poważne upadki wykryte przez urządzenie noszone. Celem jest zero; rosnąca liczba to sygnał ryzyka związanego z mobilnością, który warto omówić z lekarzem, zwłaszcza u osób starszych.", "sixMinuteWalkTitle": "Dystans marszu 6-minutowego", - "sixMinuteWalkBody": "To szacunek Apple, jak daleko mógłbyś przejść w sześć minut — klasyczny test kliniczny mobilności i wydolności krążeniowo-oddechowej. Stały lub rosnący dystans to dobry znak.", + "sixMinuteWalkBody": "To szacunek, jak daleko mógłbyś przejść w sześć minut — klasyczny test kliniczny mobilności i wydolności krążeniowo-oddechowej. Wartość z urządzenia noszonego to przybliżenie, a nie nadzorowany test kliniczny; liczy się stały lub rosnący trend.", "stairAscentSpeedTitle": "Prędkość wchodzenia po schodach", "stairAscentSpeedBody": "To prędkość, z jaką pokonujesz bieg schodów. To czuły wskaźnik siły nóg i mobilności — stałe lub rosnące tempo to dobry znak, a spadające warto obserwować.", "stairDescentSpeedTitle": "Prędkość schodzenia po schodach", "stairDescentSpeedBody": "To prędkość, z jaką schodzisz bieg schodów. Razem z prędkością wchodzenia odzwierciedla kontrolę nóg i równowagę — stałe tempo to dobry znak, a spadające warto obserwować.", "breathingDisturbancesTitle": "Zaburzenia oddychania we śnie", - "breathingDisturbancesBody": "Twój Apple Watch potrafi oszacować, jak często Twój oddech był przerywany podczas snu. Niższy wskaźnik jest lepszy; poziom, który Apple oznacza jako podwyższony — zwłaszcza przez wiele nocy — warto omówić z lekarzem.", + "breathingDisturbancesBody": "Urządzenie noszone potrafi oszacować, jak często Twój oddech był przerywany podczas snu. Niższy wskaźnik jest lepszy; poziom oznaczony jako podwyższony — zwłaszcza przez wiele nocy — warto omówić z lekarzem. To przybliżenie przesiewowe, a nie diagnoza bezdechu sennego.", "stepsTitle": "Kroki", "stepsBody": "Liczba kroków to łączna liczba kroków wykonanych każdego dnia. To najprostsza miara codziennego ruchu — wyższa dzienna liczba zwykle odzwierciedla bardziej aktywny tryb życia, a stały nawyk liczy się bardziej niż pojedynczy intensywny dzień.", "readinessBody": "Gotowość to pojedynczy dzienny odczyt tego, jak przygotowane jest Twoje ciało, by zmierzyć się z dniem — łączy tętno spoczynkowe, zmienność rytmu serca, sen i regularność ostatnich nocy. Wyższy wynik oznacza, że Twój organizm wygląda na wypoczęty i pełen zasobów; niższy to delikatna wskazówka, by zwolnić tempo.", @@ -4703,6 +4709,7 @@ "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." @@ -5682,8 +5689,6 @@ }, "notifications": { "title": "Powiadomienia", - "breadcrumbInbox": "Powiadomienia", - "breadcrumbChannels": "Kanały powiadomień", "eventColumn": "Zdarzenie", "subtitle": "Wybierz, które powiadomienia chcesz otrzymywać i którym kanałem.", "loadError": "Nie udało się załadować ustawień powiadomień.", @@ -5780,7 +5785,13 @@ "eventCycleFertileSoon": "Zbliża się okno płodności", "eventCycleFertileSoonDesc": "Wysyłane kilka dni przed szacowanym oknem płodności, gdy Twoim celem jest poczęcie.", "eventMedicationLowStock": "Niski zapas leku", - "eventMedicationLowStockDesc": "Gdy śledzony zapas leku wystarcza na mniej dni niż twój próg zapasu" + "eventMedicationLowStockDesc": "Gdy śledzony zapas leku wystarcza na mniej dni niż twój próg zapasu", + "eventCoachNudge": "Wskazówka Coacha", + "eventCoachNudgeDesc": "Okazjonalna wskazówka od Coacha, gdy twoje ostatnie trendy wymagają sprawdzenia.", + "eventMeasurementReminder": "Przypomnienie o pomiarze", + "eventMeasurementReminderDesc": "Wysyłane, gdy śledzony pomiar jest wymagany.", + "eventMedicationIntakeSync": "Synchronizacja przyjęć leku", + "eventMedicationIntakeSyncDesc": "Aktualizacja między urządzeniami, gdy dawka zostanie zapisana, przyjęta lub pominięta." }, "i18n": { "maintainershipBanner": { @@ -5906,6 +5917,16 @@ "confirmed": "{name} potwierdzone.", "genericConfirmed": "Potwierdzone.", "cancelled": "Anulowane.", + "moodLogged": "Zapisano: {score}/5", + "moodNotePrompt": "Dodaj krótką notatkę (opcjonalnie).", + "moodNoteSaved": "Notatka zapisana.", + "moodNoteExpired": "Ta prośba wygasła.", + "remindLater": "Przypomnienie przełożone o {duration}.", + "measurementDone": "Oznaczono jako wykonane.", + "measurementNotFound": "Nie znaleziono przypomnienia.", + "buttonMoodNote": "Notatka…", + "buttonLater": "Później", + "buttonDone": "Gotowe", "noActiveMedications": "Nie znaleziono aktywnych leków.", "whichMedication": "Który lek został przyjęty?", "cancelButton": "Anuluj", @@ -6271,6 +6292,7 @@ "rawChartModeDescription": "Pokazuj tylko zapisane dane, bez prognoz i interpretacji.", "secondarySymptomLabel": "Drugi objaw płodności", "secondarySymptomDescription": "Drugi objaw łączony z temperaturą w podwójnej kontroli objawowo-termicznej. Większość obserwuje śluz szyjkowy; przełącz na obserwację szyjki macicy, jeśli wolisz.", + "secondarySymptomCitation": "Więcej o metodzie (badanie)", "secondarySymptom": { "MUCUS": "Śluz szyjkowy", "CERVIX": "Szyjka macicy" diff --git a/package.json b/package.json index de78ab5e0..bbe72a444 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.18.10", + "version": "1.19.0", "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", diff --git a/prisma/migrations/0185_v1190_ecg_waveform/migration.sql b/prisma/migrations/0185_v1190_ecg_waveform/migration.sql new file mode 100644 index 000000000..9ac812065 --- /dev/null +++ b/prisma/migrations/0185_v1190_ecg_waveform/migration.sql @@ -0,0 +1,70 @@ +-- v1.19.0 — full Withings ECG waveform capture. +-- +-- v1.18.11 captured only the device's atrial-fibrillation *verdict* per +-- on-device ECG (one IRREGULAR_RHYTHM_NOTIFICATION measurement EVENT row). +-- This adds the raw ECG **waveform** so a real trace can later be rendered. +-- +-- One additive table, `ecg_recordings`: +-- +-- * `waveform_encrypted` (BYTEA) holds the AES-256-GCM ciphertext of the +-- JSON-encoded micro-volt sample array (Withings Heart `get` `body.signal`) +-- — the `*Encrypted` Bytes convention (the coach_messages.encrypted_content +-- precedent). The waveform NEVER lands as plaintext (fail-closed crypto). +-- * `sampling_frequency` (Hz), `sample_count`, `duration_seconds`, `lead`, +-- and `average_heart_rate` stay plaintext descriptors so a renderer can +-- read them without a per-row decrypt. +-- * `rhythm_classification` snapshots the paired EVENT row's AFib verdict; +-- `measurement_id` FKs back to that row (ON DELETE SET NULL — the trace +-- survives a re-keyed/removed EVENT row). +-- +-- Idempotency: a re-sync of the same recording upserts in place via the +-- unique `(user_id, source, external_recording_id)` (Withings `signalid`, +-- or a `ts-` fallback). +-- +-- Additive; no existing row touched. Idempotent guards (`IF NOT EXISTS` / +-- `DO $$`) so reruns are safe on prod. +-- +-- Reversibility (down): +-- DROP TABLE IF EXISTS "ecg_recordings"; +-- The waveform store is self-contained — no other domain depends on it. + +-- CreateTable +CREATE TABLE IF NOT EXISTS "ecg_recordings" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "source" "measurement_source" NOT NULL DEFAULT 'WITHINGS', + "external_recording_id" TEXT NOT NULL, + "recorded_at" TIMESTAMP(3) NOT NULL, + "waveform_encrypted" BYTEA NOT NULL, + "sampling_frequency" INTEGER NOT NULL, + "sample_count" INTEGER NOT NULL, + "duration_seconds" DOUBLE PRECISION, + "lead" TEXT, + "average_heart_rate" INTEGER, + "rhythm_classification" "rhythm_classification", + "measurement_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ecg_recordings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "ecg_recordings_user_id_source_external_recording_id_key" ON "ecg_recordings"("user_id", "source", "external_recording_id"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "ecg_recordings_user_id_recorded_at_idx" ON "ecg_recordings"("user_id", "recorded_at" DESC); + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "ecg_recordings" ADD CONSTRAINT "ecg_recordings_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "ecg_recordings" ADD CONSTRAINT "ecg_recordings_measurement_id_fkey" FOREIGN KEY ("measurement_id") REFERENCES "measurements"("id") ON DELETE SET NULL ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/prisma/migrations/0186_v1190_oura_resilience/migration.sql b/prisma/migrations/0186_v1190_oura_resilience/migration.sql new file mode 100644 index 000000000..2512f01ea --- /dev/null +++ b/prisma/migrations/0186_v1190_oura_resilience/migration.sql @@ -0,0 +1,25 @@ +-- v1.19.0 — Oura resilience capture (additive). +-- +-- One new `measurement_type` enum value, `RESILIENCE`, so the Oura sync can +-- store the daily resilience level from the `daily_resilience` collection. +-- +-- Oura exposes resilience as a categorical band — limited / adequate / solid / +-- strong / exceptional — describing how well the body copes with cumulative +-- load. Rather than introduce a new categorical column + CHECK constraint, the +-- level is ORDINAL-ENCODED into the existing numeric `value`: +-- +-- limited = 1, adequate = 2, solid = 3, strong = 4, exceptional = 5 +-- +-- The 5-level → ordinal mapping is the single source of truth in +-- `src/lib/oura/client.ts` (RESILIENCE_LEVELS). Unit is the level scale +-- (`level`). Stored as `source = OURA`. An unknown / missing level string mints +-- no row (it is skipped, never coerced to 0). +-- +-- Purely-additive: one enum extension, no backfill, no existing row touched, no +-- new table or column. Idempotent guard (`IF NOT EXISTS`) so reruns are safe on +-- prod. +-- +-- Reversibility (down): Postgres cannot drop a single enum value in place; the +-- value is inert until a row references it, so a down migration is a no-op. + +ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'RESILIENCE'; diff --git a/prisma/migrations/0187_v1190_telegram_prompt_context/migration.sql b/prisma/migrations/0187_v1190_telegram_prompt_context/migration.sql new file mode 100644 index 000000000..aab9f45f5 --- /dev/null +++ b/prisma/migrations/0187_v1190_telegram_prompt_context/migration.sql @@ -0,0 +1,20 @@ +-- v1.19.0 — force-reply correlation for the interactive mood reminder. +-- Maps a bot prompt message to the entry a free-text reply attaches to. +CREATE TABLE "telegram_prompt_contexts" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "chat_id" TEXT NOT NULL, + "prompt_msg_id" INTEGER NOT NULL, + "kind" TEXT NOT NULL, + "ref_id" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "telegram_prompt_contexts_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "telegram_prompt_contexts_chat_id_prompt_msg_id_key" ON "telegram_prompt_contexts"("chat_id", "prompt_msg_id"); + +-- CreateIndex +CREATE INDEX "telegram_prompt_contexts_expires_at_idx" ON "telegram_prompt_contexts"("expires_at"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b98b6f10d..e1ffce14c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -657,6 +657,10 @@ model User { // per-day timeline mirroring the CycleDayLog shape. illnessEpisodes IllnessEpisode[] illnessDayLogs IllnessDayLog[] + // v1.19.0 — full Withings ECG waveform recordings (encrypted at rest). + // The paired AFib verdict still lands as an IRREGULAR_RHYTHM_NOTIFICATION + // Measurement EVENT row (v1.18.11); this holds the raw signal samples. + ecgRecordings EcgRecording[] // v1.7.0 profile — optional patient-identity fields surfaced on the // health-record export cover (PDF) and the FHIR `Patient` resource. @@ -901,6 +905,17 @@ enum MeasurementType { // absolute-reading buckets. Plausible band is small and centred on 0. BODY_TEMPERATURE_DEVIATION // celsius (signed deviation) — Oura `daily_readiness.temperature_deviation`. + // ── v1.19.0 — Oura resilience (additive) ── + // Oura's daily resilience level from the `daily_resilience` collection — a + // categorical band describing how well the body is coping with cumulative + // load: limited / adequate / solid / strong / exceptional. Stored ORDINAL- + // ENCODED into the numeric `value` (limited=1, adequate=2, solid=3, strong=4, + // exceptional=5) so it fits the existing numeric Measurement model with no new + // categorical column or CHECK constraint. Unit is the level scale (`level`). + // The 5-level → ordinal mapping is the single source of truth in + // `src/lib/oura/client.ts` (RESILIENCE_LEVELS). Stored as `source = OURA`. + RESILIENCE // level (ordinal 1–5) — Oura `daily_resilience.level` (limited=1 … exceptional=5). + @@map("measurement_type") } @@ -1070,6 +1085,10 @@ model Measurement { updatedAt DateTime @updatedAt @map("updated_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) + // v1.19.0 — optional back-relation to the full ECG waveform recording(s) + // paired with this EVENT row. SET NULL on the FK side keeps the trace if + // the EVENT row is re-keyed / removed. + ecgRecordings EcgRecording[] // Original measuredAt-based dedup — kept for legacy Withings ingest // and the manual UI's "no duplicate at the same wall-clock" guard. @@ -1130,6 +1149,75 @@ model Measurement { @@map("measurements") } +// ─── ECG waveform recordings (v1.19.0) ───────────────────── +// +// v1.18.11 captured only the device's AFib *verdict* for each on-device +// ECG (one IRREGULAR_RHYTHM_NOTIFICATION Measurement EVENT row). This model +// stores the full raw ECG **waveform** so a real trace can later be rendered. +// +// The waveform is sensitive health data, so the sample array is held +// AES-256-GCM at rest in `waveformEncrypted` (Bytes) — the same Bytes-codec +// precedent as `CoachMessage.encryptedContent`. The plaintext is the JSON +// encoding of the integer micro-volt sample array returned by the Withings +// Heart `get` endpoint (`body.signal`); decrypt → `JSON.parse` → number[]. +// It NEVER lands as plaintext (fail-closed crypto in `src/lib/crypto.ts`). +// +// The non-sensitive descriptors stay plaintext so a future renderer can read +// `samplingFrequency` / `sampleCount` / `durationSeconds` without a per-row +// decrypt: `samplingFrequency` (Hz) pairs with the sample index to recover +// each sample's time offset; `sampleCount` is the array length; `durationSeconds` +// is `sampleCount / samplingFrequency`; `lead` is the recording lead/pathway +// when the source reports it; `averageHeartRate` mirrors the source's reported +// average BPM for the strip when present. +// +// `rhythmClassification` snapshots the same AFib verdict captured on the paired +// Measurement EVENT row so the waveform self-describes without a join, and +// `measurementId` links back to that row (SET NULL — the trace survives a +// re-keyed/removed EVENT row). +// +// Idempotency: `externalRecordingId` is the source's stable per-recording id +// (Withings `signalid`, or a `ts-` fallback when absent). The unique +// `(userId, source, externalRecordingId)` lets a re-sync overwrite the same +// recording in place rather than inserting a duplicate. +// +// RENDERING UI IS OUT OF SCOPE FOR v1.19.0 — this is ingest-only. A trace +// view (and any analytics over the waveform) is a follow-up. +model EcgRecording { + id String @id @default(cuid()) + userId String @map("user_id") + source MeasurementSource @default(WITHINGS) + /// Source's stable per-recording id (Withings `signalid`, or `ts-`). + externalRecordingId String @map("external_recording_id") + /// When the strip was recorded on-device (source timestamp). + recordedAt DateTime @map("recorded_at") + /// AES-256-GCM ciphertext of the JSON-encoded micro-volt sample array. + waveformEncrypted Bytes @map("waveform_encrypted") + /// Sampling rate of the signal in Hz (sample index → time offset). + samplingFrequency Int @map("sampling_frequency") + /// Number of samples in the (decrypted) waveform array. + sampleCount Int @map("sample_count") + /// `sampleCount / samplingFrequency` in seconds; NULL when frequency is 0. + durationSeconds Float? @map("duration_seconds") + /// Recording lead / pathway label when the source reports it (else NULL). + lead String? + /// Source-reported average heart rate (BPM) for the strip, when present. + averageHeartRate Int? @map("average_heart_rate") + /// Snapshot of the device AFib verdict for the paired EVENT row. + rhythmClassification RhythmClassification? @map("rhythm_classification") + /// FK to the IRREGULAR_RHYTHM_NOTIFICATION Measurement EVENT row, when known. + measurementId String? @map("measurement_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + measurement Measurement? @relation(fields: [measurementId], references: [id], onDelete: SetNull) + + // Re-sync overwrites in place rather than inserting a duplicate. + @@unique([userId, source, externalRecordingId]) + @@index([userId, recordedAt(sort: Desc)]) + @@map("ecg_recordings") +} + // ─── Measurement rollups (v1.5.0) ────────────────────────── // // Persistent rollup table — second cache tier behind the 60-s LRU. @@ -2185,6 +2273,33 @@ model TelegramScheduledDeletion { @@map("telegram_scheduled_deletions") } +// v1.19.0 — force-reply correlation for the interactive mood reminder. +// +// When a user taps "Notiz…" on a mood prompt the bot sends a force_reply +// message; the user's free-text reply then arrives as a normal `message` +// whose `reply_to_message.message_id` points back at that prompt. This +// row maps `(chatId, promptMessageId)` to the mood entry the note should +// attach to, so the inbound note handler can resolve "what was this a +// reply to" without trusting any client-supplied id. Rows are short-lived +// (`expiresAt`) and best-effort deleted once consumed; the deletion sweep +// is keyed on `expiresAt` so an unanswered note prompt self-cleans. +model TelegramPromptContext { + id String @id @default(cuid()) + userId String @map("user_id") + chatId String @map("chat_id") + // The bot's prompt message the user is expected to reply to. + promptMsgId Int @map("prompt_msg_id") + // What the reply attaches to. "mood_note" carries `refId = moodEntryId`. + kind String + refId String @map("ref_id") + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + + @@unique([chatId, promptMsgId]) + @@index([expiresAt]) + @@map("telegram_prompt_contexts") +} + // ─── API Tokens (for external ingest) ────────────────────── model ApiToken { diff --git a/public/sw.js b/public/sw.js index d66fd98bc..9b257ceae 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.18.10"; + /* @sw-version-fallback */ "v1.19.0"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/scripts/rotate-encryption-key.ts b/scripts/rotate-encryption-key.ts index 2e2f5acca..aa9a479c5 100644 --- a/scripts/rotate-encryption-key.ts +++ b/scripts/rotate-encryption-key.ts @@ -370,6 +370,19 @@ async function main() { ), ); + // ───── v1.19.0 ECG waveform (Bytes column) ───── + // "waveformEncrypted" holds the JSON-encoded micro-volt sample array in the + // same `encrypt()` ciphertext-string-as-UTF-8 shape the Coach columns use + // (see src/lib/withings/ecg-waveform-codec.ts), so the generic Bytes + // rotation re-stamps it without decoding the waveform. + results.push( + await rotateBytesColumn( + "EcgRecording", + "waveformEncrypted", + prisma.ecgRecording, + ), + ); + console.log("\n=== Rotation summary ==="); let totalRotated = 0; let totalErrors = 0; diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index 0121964f2..36213bace 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -14,7 +14,8 @@ * - 90 days of body composition (fat/lean/muscle mass, total body water, * bone mass, visceral fat, BMI) derived from the same-day weight + body fat * - 90 days of blood glucose in a healthy non-diabetic band (fasting + - * post-meal + bedtime, contexts tagged) + * post-meal + bedtime, contexts tagged) plus a 14-day Nightscout-style CGM + * stream (a reading every 15 min) so the glucose panel renders fully * - 90 days of cardio fitness + vitals (HRV SDNN + RMSSD, SpO2, respiratory * rate, VO2 max, active energy, walking/running distance, flights climbed) * - 90 days of WHOOP-style scores (recovery, day strain, sleep @@ -25,8 +26,13 @@ * compliance, with today scheduled on-track (taken or not-yet-due) * - 90 days of mood entries * - Vorsorge (preventive-care) reminders — upcoming dental + annual physical - * - One lab panel of biomarkers in healthy ranges - * - One resolved illness episode in the past + * - Two lab panels of biomarkers across two dates (quantitative with + * reference ranges + qualitative "negativ" rows) + * - Two illness episodes — a resolved acute cold with a day-by-day symptom + * curve, and an active chronic condition carrying a recent flare + * - Cycle tracking opted in: ~5 observed cycles with biphasic basal body + * temperature, period flow, fertile-window mucus/OPK, per-day symptoms, + * and a cached forward prediction * - An AI-configured Coach with one short sample conversation * - App settings (registration disabled, English locale) * @@ -181,7 +187,14 @@ async function seed() { await client.query("DELETE FROM coach_conversations"); await client.query("DELETE FROM consent_receipts"); await client.query("DELETE FROM insight_narratives"); + await client.query("DELETE FROM illness_symptom_links"); + await client.query("DELETE FROM illness_day_logs"); await client.query("DELETE FROM illness_episodes"); + await client.query("DELETE FROM cycle_symptom_links"); + await client.query("DELETE FROM cycle_day_logs"); + await client.query("DELETE FROM cycle_predictions"); + await client.query("DELETE FROM menstrual_cycles"); + await client.query("DELETE FROM cycle_profiles"); await client.query("DELETE FROM lab_results"); await client.query("DELETE FROM measurement_reminders"); await client.query("DELETE FROM mood_entries"); @@ -469,6 +482,50 @@ async function seed() { } } + // ── Continuous glucose (Nightscout-style CGM stream) ── + // The clinical glucose panel above gives the spot fasting / post-meal / + // bedtime contexts; a real CGM additionally streams a reading every few + // minutes. Seed a dense, realistic 24 h-cycle stream for the most recent + // 14 days (one sample every 15 min) so the glucose feature's CGM / time- + // in-range surfaces render fully. Values follow a healthy circadian curve: + // a calm overnight ~90, a dawn rise, gentle post-meal bumps at ~8/13/19h + // that settle back inside range — every reading stays in the non-diabetic + // band (70–140 mg/dL). Source NIGHTSCOUT with a stable external_id so the + // (userId, type, source, externalId) immutable key makes the stream + // idempotent across re-seeds (the real Nightscout sync's dedup key). The + // measurements_glucose_context_requires_type CHECK forces a non-NULL + // glucose_context on every BLOOD_GLUCOSE row, so each CGM sample is tagged + // RANDOM (a free-running sensor reading has no meal context). + console.log("Creating continuous glucose (CGM) stream..."); + const cgmDays = 14; + const cgmStepMin = 15; + for (let d = cgmDays - 1; d >= 0; d--) { + for (let minute = 0; minute < 24 * 60; minute += cgmStepMin) { + const hour = minute / 60; + // Baseline overnight ~90; dawn phenomenon lifts it slightly toward + // morning. Meal bumps are smooth gaussians centred on 8/13/19h. + const dawn = hour >= 4 && hour <= 8 ? (hour - 4) * 2.5 : 0; + const meal = (centre: number, peak: number, width: number) => + peak * Math.exp(-((hour - centre) ** 2) / (2 * width * width)); + const base = + 90 + dawn + meal(8, 28, 1.1) + meal(13, 34, 1.3) + meal(19, 30, 1.3); + const value = Math.round( + Math.min(140, Math.max(72, base + (Math.random() - 0.5) * 6)), + ); + const at = daysAgoAt(d, Math.floor(hour), minute % 60); + const externalId = `cgm-${formatDate(at)}-${minute}`; + // The measurements_glucose_context_requires_type CHECK requires a + // non-NULL glucose_context on every BLOOD_GLUCOSE row. A free-running + // CGM sample carries no meal context, so RANDOM ("any time, + // non-fasting, non-post-meal") is the correct enum value. + await client.query( + `INSERT INTO measurements (id, user_id, type, value, unit, source, glucose_context, external_id, measured_at, created_at, updated_at) + VALUES ($1, $2, 'BLOOD_GLUCOSE', $3, 'mg/dL', 'NIGHTSCOUT', 'RANDOM', $4, $5, $5, $5)`, + [cuid(), userId, value, externalId, at], + ); + } + } + // ── Cardio fitness + vitals ─────────────── // The dashboard + insights surface these device-tracked vitals. All // recent (full window, latest point today), all in healthy/aspirational @@ -979,18 +1036,27 @@ async function seed() { [cuid(), userId, daysAgo(275), physicalNextDue], ); - // ── Lab panel (biomarkers in healthy ranges) ── - // One grouped panel taken ~10 days ago, every analyte comfortably inside - // its reference range, so the labs surface reads clean. - console.log("Creating lab panel..."); - const panelTakenAt = daysAgo(10); - const labResults: Array<{ + // ── Lab panels (biomarkers across two dates) ── + // Two grouped panels so the labs list / detail / sort / delete all have + // content: a recent annual blood panel (~10 days ago) and an older one + // (~7 months ago) so the per-analyte trend has at least two points, plus a + // couple of qualitative rows ("negativ" / "nicht nachweisbar") on the + // recent panel. Exactly one of value / value_text is set per row per the + // schema's quantitative-vs-qualitative invariant. Numeric analytes sit + // comfortably inside their reference range; the older panel runs slightly + // higher so the improvement reads as real, earned movement. + console.log("Creating lab panels..."); + type QuantLab = { analyte: string; value: number; unit: string; low: number; high: number; - }> = [ + }; + type QualLab = { analyte: string; valueText: string; unit: string }; + + const recentPanelAt = daysAgo(10); + const recentQuant: QuantLab[] = [ { analyte: "Total Cholesterol", value: 178, @@ -1012,32 +1078,498 @@ async function seed() { { analyte: "Vitamin D", value: 42, unit: "ng/mL", low: 30, high: 100 }, { analyte: "TSH", value: 1.8, unit: "mIU/L", low: 0.4, high: 4.0 }, { analyte: "Ferritin", value: 120, unit: "ng/mL", low: 30, high: 400 }, + { analyte: "CRP", value: 0.8, unit: "mg/L", low: 0, high: 3.0 }, + { analyte: "Creatinine", value: 0.9, unit: "mg/dL", low: 0.7, high: 1.3 }, ]; - for (const lab of labResults) { + const recentQual: QualLab[] = [ + { analyte: "Urine Glucose", valueText: "negativ", unit: "" }, + { + analyte: "Hepatitis B Surface Antigen", + valueText: "negativ", + unit: "", + }, + { analyte: "Urine Protein", valueText: "nicht nachweisbar", unit: "" }, + ]; + + // An older draw of the same numeric markers, mostly a touch higher, so the + // per-analyte history renders a trend rather than a single point. + const olderPanelAt = daysAgo(210); + const olderQuant: QuantLab[] = [ + { + analyte: "Total Cholesterol", + value: 196, + unit: "mg/dL", + low: 0, + high: 200, + }, + { analyte: "LDL", value: 119, unit: "mg/dL", low: 0, high: 130 }, + { analyte: "HDL", value: 51, unit: "mg/dL", low: 40, high: 100 }, + { + analyte: "Triglycerides", + value: 128, + unit: "mg/dL", + low: 0, + high: 150, + }, + { analyte: "HbA1c", value: 5.5, unit: "%", low: 4.0, high: 5.6 }, + { analyte: "Vitamin D", value: 31, unit: "ng/mL", low: 30, high: 100 }, + { analyte: "TSH", value: 2.2, unit: "mIU/L", low: 0.4, high: 4.0 }, + { analyte: "Ferritin", value: 88, unit: "ng/mL", low: 30, high: 400 }, + ]; + + const insertQuant = async (panel: string, lab: QuantLab, takenAt: Date) => { await client.query( - `INSERT INTO lab_results (id, user_id, panel, analyte, value, unit, reference_low, reference_high, taken_at, source, created_at, updated_at) - VALUES ($1, $2, 'Annual blood panel', $3, $4, $5, $6, $7, $8, 'MANUAL', NOW(), NOW())`, + `INSERT INTO lab_results (id, user_id, panel, analyte, value, value_text, unit, reference_low, reference_high, taken_at, source, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, NULL, $6, $7, $8, $9, 'MANUAL', NOW(), NOW())`, [ cuid(), userId, + panel, lab.analyte, lab.value, lab.unit, lab.low, lab.high, - panelTakenAt, + takenAt, ], ); + }; + const insertQual = async (panel: string, lab: QualLab, takenAt: Date) => { + await client.query( + `INSERT INTO lab_results (id, user_id, panel, analyte, value, value_text, unit, reference_low, reference_high, taken_at, source, created_at, updated_at) + VALUES ($1, $2, $3, $4, NULL, $5, $6, NULL, NULL, $7, 'MANUAL', NOW(), NOW())`, + [cuid(), userId, panel, lab.analyte, lab.valueText, lab.unit, takenAt], + ); + }; + + for (const lab of recentQuant) { + await insertQuant("Annual blood panel", lab, recentPanelAt); + } + for (const lab of recentQual) { + await insertQual("Annual blood panel", lab, recentPanelAt); + } + for (const lab of olderQuant) { + await insertQuant("Blood panel (last year)", lab, olderPanelAt); + } + const labResults = [...recentQuant, ...recentQual, ...olderQuant]; + + // ── Illness / condition journal ─────────── + // Two episodes so the journal + per-condition timeline both render with + // signal: (1) a short, fully-resolved cold ~7 weeks ago with a day-by-day + // symptom curve, and (2) an active chronic condition (seasonal allergic + // rhinitis) carrying a recent FLARE that hangs off it. Each episode gets + // IllnessDayLog rows (functional impact + optional fever + an encrypted + // note) and per-day symptom links from the seeded catalogue, so the + // retrospective curve + symptom chips have content. Day-log + episode + // notes are AES-256-GCM Bytes (the `*Encrypted` convention), written with + // the same codec the app uses — never plaintext. + console.log("Creating illness / condition journal..."); + + // Resolve a catalogue symptom id by its stable machine key. + const illnessSymptomId = async (key: string): Promise => { + const { rows } = await client.query( + `SELECT id FROM illness_symptoms WHERE key = $1`, + [key], + ); + return rows[0].id as string; + }; + const encBytes = (text: string) => Buffer.from(encryptToBytes(text)); + + // Insert one IllnessDayLog with its symptom links. + const insertIllnessDay = async (params: { + episodeId: string; + date: Date; + functionalImpact: number; + feverC: number | null; + note: string; + symptoms: Array<{ key: string; severity: number }>; + }) => { + const dayLogId = cuid(); + await client.query( + `INSERT INTO illness_day_logs (id, user_id, episode_id, date, functional_impact, fever_c, note_encrypted, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())`, + [ + dayLogId, + userId, + params.episodeId, + formatDate(params.date), + params.functionalImpact, + params.feverC, + encBytes(params.note), + ], + ); + for (const s of params.symptoms) { + await client.query( + `INSERT INTO illness_symptom_links (day_log_id, symptom_id, severity, created_at) + VALUES ($1, $2, $3, NOW())`, + [dayLogId, await illnessSymptomId(s.key), s.severity], + ); + } + }; + + // (1) Resolved acute cold, day 49 → day 43. + const coldId = cuid(); + await client.query( + `INSERT INTO illness_episodes (id, user_id, label, type, lifecycle, onset_at, resolved_at, note_encrypted, created_at, updated_at) + VALUES ($1, $2, 'Common cold', 'INFECTION', 'ACUTE', $3, $4, $5, NOW(), NOW())`, + [ + coldId, + userId, + daysAgo(49), + daysAgo(43), + encBytes("Picked it up after a long-haul flight. Rested it off."), + ], + ); + // A six-day curve: worst on days 2–3, easing toward recovery. + const coldDays: Array<{ + offset: number; + impact: number; + fever: number | null; + note: string; + symptoms: Array<{ key: string; severity: number }>; + }> = [ + { + offset: 49, + impact: 1, + fever: null, + note: "Scratchy throat starting in the evening.", + symptoms: [ + { key: "sore_throat", severity: 2 }, + { key: "fatigue", severity: 1 }, + ], + }, + { + offset: 48, + impact: 2, + fever: 37.8, + note: "Heavy head, runny nose all day.", + symptoms: [ + { key: "runny_nose", severity: 3 }, + { key: "headache", severity: 2 }, + { key: "fatigue", severity: 2 }, + ], + }, + { + offset: 47, + impact: 2, + fever: 38.1, + note: "Worst day — stayed in bed, low fever.", + symptoms: [ + { key: "stuffy_nose", severity: 3 }, + { key: "body_aches", severity: 2 }, + { key: "cough", severity: 2 }, + { key: "fatigue", severity: 3 }, + ], + }, + { + offset: 46, + impact: 1, + fever: 37.4, + note: "Fever broke overnight, congestion lingering.", + symptoms: [ + { key: "stuffy_nose", severity: 2 }, + { key: "cough", severity: 2 }, + ], + }, + { + offset: 44, + impact: 1, + fever: null, + note: "Mostly a dry cough left now.", + symptoms: [{ key: "cough", severity: 1 }], + }, + { + offset: 43, + impact: 0, + fever: null, + note: "Back to normal — calling it resolved.", + symptoms: [], + }, + ]; + for (const d of coldDays) { + await insertIllnessDay({ + episodeId: coldId, + date: daysAgo(d.offset), + functionalImpact: d.impact, + feverC: d.fever, + note: d.note, + symptoms: d.symptoms, + }); } - // ── Resolved illness episode ────────────── - // A short cold ~7 weeks ago, fully recovered, so the illness journal shows - // a clean "resolved" entry rather than an active sickness. - console.log("Creating resolved illness episode..."); + // (2) Active chronic condition + a recent flare hanging off it. + const allergyId = cuid(); await client.query( - `INSERT INTO illness_episodes (id, user_id, label, type, lifecycle, onset_at, resolved_at, created_at, updated_at) - VALUES ($1, $2, 'Common cold', 'INFECTION', 'ACUTE', $3, $4, NOW(), NOW())`, - [cuid(), userId, daysAgo(49), daysAgo(43)], + `INSERT INTO illness_episodes (id, user_id, label, type, lifecycle, onset_at, resolved_at, note_encrypted, created_at, updated_at) + VALUES ($1, $2, 'Seasonal allergic rhinitis', 'ALLERGY', 'CHRONIC_ONGOING', $3, NULL, $4, NOW(), NOW())`, + [ + allergyId, + userId, + daysAgo(400), + encBytes("Tree pollen each spring. Antihistamine helps."), + ], + ); + // The flare: a still-open bout this past week referencing the parent. + const flareId = cuid(); + await client.query( + `INSERT INTO illness_episodes (id, user_id, label, type, lifecycle, onset_at, resolved_at, parent_condition_id, note_encrypted, created_at, updated_at) + VALUES ($1, $2, 'Pollen flare', 'ALLERGY', 'FLARE', $3, NULL, $4, $5, NOW(), NOW())`, + [ + flareId, + userId, + daysAgo(5), + allergyId, + encBytes("High pollen count this week — symptoms back."), + ], + ); + const flareDays: Array<{ + offset: number; + impact: number; + note: string; + symptoms: Array<{ key: string; severity: number }>; + }> = [ + { + offset: 5, + impact: 1, + note: "Itchy eyes and sneezing started today.", + symptoms: [ + { key: "sneezing", severity: 2 }, + { key: "runny_nose", severity: 2 }, + ], + }, + { + offset: 4, + impact: 1, + note: "Pollen high again — runny nose all morning.", + symptoms: [ + { key: "runny_nose", severity: 3 }, + { key: "sneezing", severity: 2 }, + ], + }, + { + offset: 2, + impact: 1, + note: "Antihistamine taking the edge off.", + symptoms: [ + { key: "stuffy_nose", severity: 2 }, + { key: "fatigue", severity: 1 }, + ], + }, + { + offset: 0, + impact: 1, + note: "Still sniffly but manageable.", + symptoms: [{ key: "runny_nose", severity: 1 }], + }, + ]; + for (const d of flareDays) { + await insertIllnessDay({ + episodeId: flareId, + date: daysAgo(d.offset), + functionalImpact: d.impact, + feverC: null, + note: d.note, + symptoms: d.symptoms, + }); + } + + // ── Cycle tracking ──────────────────────── + // The cycle module resolves through isCycleEnabled(gender, CycleProfile): + // a NULL flag derives from gender, so this account explicitly opts in + // (cycle_tracking_enabled = true) — the same posture the e2e seed takes — + // so the cycle vertical renders. Seed ~5 observed cycles of realistic + // length with a full symptothermal day-log: biphasic basal body + // temperature (lower follicular, a sustained ~0.3 °C luteal shift after + // ovulation), period flow on the bleeding days, fertile-window mucus + a + // positive OPK around ovulation, and per-day symptoms. Each BBT also lands + // as a BODY_TEMPERATURE Measurement (the schema's documented dual-write so + // the temperature charts elsewhere have the series). A cached + // CyclePrediction forward-mints the next period + fertile window so the + // prediction surface has signal. All date strings are anchored to the + // user's Europe/Berlin timezone. + console.log("Creating cycle tracking..."); + const CYCLE_TZ = "Europe/Berlin"; + + await client.query( + `INSERT INTO cycle_profiles (id, user_id, goal, cycle_tracking_enabled, typical_cycle_length, typical_period_length, luteal_phase_length, secondary_symptom, prediction_enabled, created_at, updated_at) + VALUES ($1, $2, 'TRYING_TO_CONCEIVE', true, 29, 5, 14, 'MUCUS', true, NOW(), NOW()) + ON CONFLICT (user_id) DO UPDATE SET cycle_tracking_enabled = true, updated_at = NOW()`, + [cuid(), userId], + ); + + // Resolve a cycle-symptom catalogue id by its stable machine key. + const cycleSymptomId = async (key: string): Promise => { + const { rows } = await client.query( + `SELECT id FROM cycle_symptoms WHERE key = $1`, + [key], + ); + return rows[0].id as string; + }; + + // Build cycles backwards from the most recent period start. Lengths jitter + // a little around the 29-day typical so the variability-aware predictor has + // something to chew on. The most recent cycle is left open-ended (no + // endDate) — its next period is what the prediction forecasts. + const cycleLengths = [29, 28, 30, 29, 27]; // most-recent first + const periodLen = 5; + // Most recent period started 8 days ago (so today sits early-follicular, + // a few days after this cycle's bleed) — keeps the wheel mid-cycle. + let cursor = 8; + const cycleStartsAgo: number[] = []; + for (const len of cycleLengths) { + cycleStartsAgo.push(cursor); + cursor += len; + } + + // One Berlin YYYY-MM-DD per day offset. + const berlinDateString = (offset: number): string => + new Intl.DateTimeFormat("en-CA", { + timeZone: CYCLE_TZ, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(daysAgo(offset)); + + for (let c = 0; c < cycleStartsAgo.length; c++) { + const startAgo = cycleStartsAgo[c]; + const length = cycleLengths[c]; + const isMostRecent = c === 0; + // endDate / length only known once the next cycle anchors it. + const endAgo = isMostRecent ? null : cycleStartsAgo[c - 1] + 1; + const ovulationAgo = startAgo - (length - 14); // luteal = 14d + const cycleId = cuid(); + await client.query( + `INSERT INTO menstrual_cycles (id, user_id, start_date, end_date, period_end_date, length_days, ovulation_date, ovulation_confirmed, is_predicted, tz, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, true, false, $8, NOW(), NOW())`, + [ + cycleId, + userId, + berlinDateString(startAgo), + endAgo === null ? null : berlinDateString(endAgo), + berlinDateString(startAgo - (periodLen - 1)), + isMostRecent ? null : length, + berlinDateString(ovulationAgo), + CYCLE_TZ, + ], + ); + + // Day-by-day log across the cycle span (start day back to the day before + // the next period). For the most recent open cycle, only log up to today. + const lastDayAgo = isMostRecent ? 0 : cycleStartsAgo[c - 1] + 1; + for (let dayAgo = startAgo; dayAgo >= lastDayAgo; dayAgo--) { + const cycleDay = startAgo - dayAgo + 1; // 1-based day in cycle + const daysFromOvulation = ovulationAgo - dayAgo; // <0 pre, 0 = ovu + + // Biphasic BBT: ~36.40 °C follicular, rising to ~36.70 °C in the + // luteal phase from the day after ovulation, with small daily noise. + const luteal = cycleDay > length - 14; + const bbt = + Math.round( + ((luteal ? 36.7 : 36.4) + (Math.random() - 0.5) * 0.12) * 100, + ) / 100; + + // Flow on the first `periodLen` days, tapering. + let flow: string | null = null; + if (cycleDay <= periodLen) { + flow = + cycleDay <= 2 ? "MEDIUM" : cycleDay <= 4 ? "LIGHT" : "SPOTTING"; + } + + // Fertile-window mucus + OPK around ovulation. + let mucus: string | null = null; + let opk: string | null = null; + if (daysFromOvulation >= -4 && daysFromOvulation <= 1) { + mucus = + daysFromOvulation >= -1 && daysFromOvulation <= 0 + ? "EGG_WHITE" + : daysFromOvulation === -2 + ? "WATERY" + : "CREAMY"; + opk = daysFromOvulation === -1 ? "POSITIVE_LH_SURGE" : "NEGATIVE"; + } else if (cycleDay > periodLen) { + mucus = luteal ? "STICKY" : "DRY"; + } + + const dayLogId = cuid(); + await client.query( + `INSERT INTO cycle_day_logs (id, user_id, date, cycle_id, flow, basal_body_temp_c, ovulation_test, cervical_mucus, source, tz, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'MANUAL', $9, NOW(), NOW()) + ON CONFLICT (user_id, date) DO NOTHING`, + [ + dayLogId, + userId, + berlinDateString(dayAgo), + cycleId, + flow, + bbt, + opk, + mucus, + CYCLE_TZ, + ], + ); + + // BBT also as a BODY_TEMPERATURE Measurement (schema dual-write). + await client.query( + `INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at) + VALUES ($1, $2, 'BODY_TEMPERATURE', $3, 'celsius', 'MANUAL', $4, $4, $4)`, + [cuid(), userId, bbt, daysAgoAt(dayAgo, 6, 30)], + ); + + // Per-day symptoms: cramps + back pain during the period, mood swings + // + cravings + breast tenderness in the late-luteal PMS window. + const symptoms: Array<{ key: string; severity: number }> = []; + if (cycleDay <= 3) { + symptoms.push({ key: "cramps", severity: cycleDay <= 2 ? 3 : 2 }); + symptoms.push({ key: "back_pain", severity: 2 }); + symptoms.push({ key: "fatigue", severity: 2 }); + } + if (cycleDay > length - 4 && cycleDay <= length) { + symptoms.push({ key: "mood_swings", severity: 2 }); + symptoms.push({ key: "food_cravings", severity: 2 }); + symptoms.push({ key: "breast_tenderness", severity: 2 }); + } + for (const s of symptoms) { + await client.query( + `INSERT INTO cycle_symptom_links (day_log_id, symptom_id, severity, created_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (day_log_id, symptom_id) DO NOTHING`, + [dayLogId, await cycleSymptomId(s.key), s.severity], + ); + } + } + } + + // Cached forward prediction (the stale-while-revalidate cache the read + // surface serves). Next period ~21 days out (most recent cycle started 8 + // days ago, typical 29), with a ±2 day band, a fertile window ~12 days + // before that, and the matching ovulation estimate. + const nextStartAgo = cycleStartsAgo[0] - 29; + await client.query( + `INSERT INTO cycle_predictions (id, user_id, method, next_period_start, next_period_start_low, next_period_start_high, fertile_window_start, fertile_window_end, predicted_ovulation, confidence, cycles_observed, generated_at, created_at, updated_at) + VALUES ($1, $2, 'SYMPTOTHERMAL', $3, $4, $5, $6, $7, $8, 0.82, $9, NOW(), NOW(), NOW()) + ON CONFLICT (user_id) DO UPDATE SET + method = EXCLUDED.method, + next_period_start = EXCLUDED.next_period_start, + next_period_start_low = EXCLUDED.next_period_start_low, + next_period_start_high = EXCLUDED.next_period_start_high, + fertile_window_start = EXCLUDED.fertile_window_start, + fertile_window_end = EXCLUDED.fertile_window_end, + predicted_ovulation = EXCLUDED.predicted_ovulation, + confidence = EXCLUDED.confidence, + cycles_observed = EXCLUDED.cycles_observed, + generated_at = NOW(), + updated_at = NOW()`, + [ + cuid(), + userId, + berlinDateString(nextStartAgo), + berlinDateString(nextStartAgo + 2), + berlinDateString(nextStartAgo - 2), + berlinDateString(nextStartAgo + 17), + berlinDateString(nextStartAgo + 12), + berlinDateString(nextStartAgo + 14), + cycleLengths.length, + ], ); // ── Achievements ───────────────────────── @@ -1566,7 +2098,10 @@ async function seed() { ` Body composition: fat/lean/muscle mass, water, bone, visceral fat, BMI`, ); console.log( - ` Blood glucose: ${glucoseReadings.length} readings/day (fasting + post-meal + bedtime)`, + ` Blood glucose: ${glucoseReadings.length} spot readings/day (fasting + post-meal + bedtime)`, + ); + console.log( + ` CGM: Nightscout-style stream, every ${cgmStepMin} min over ${cgmDays} days`, ); console.log( ` Cardio + vitals: HRV (SDNN + RMSSD), SpO2, resp. rate, VO2max, active energy, distance, flights`, @@ -1583,8 +2118,15 @@ async function seed() { ); console.log(` Mood: ~${Math.round(span * 0.95)} entries`); console.log(` Vorsorge reminders: 2 (dental, annual physical)`); - console.log(` Lab panel: ${labResults.length} biomarkers`); - console.log(` Illness: 1 resolved episode`); + console.log( + ` Lab panels: ${labResults.length} rows across 2 panels (incl. ${recentQual.length} qualitative)`, + ); + console.log( + ` Illness: resolved cold (day-logs) + active chronic condition with a flare`, + ); + console.log( + ` Cycle: ${cycleLengths.length} cycles with BBT, flow, mucus/OPK, symptoms + a forecast`, + ); console.log(` Coach: 1 conversation (${coachTurns.length} messages)`); console.log( ` Baked AI texts: comprehensive + daily briefing, ${statusCards.length} status cards, ${periodNarratives.length} period narratives (en)`, diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts index 62c5997eb..0080f84b5 100644 --- a/src/app/api/dashboard/summary/__tests__/route.test.ts +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -591,7 +591,7 @@ describe("GET /api/dashboard/summary", () => { it("emits the sleep-rhythm DTO (sleep-debt + chronotype) computed from the foundation modules (v1.17.0)", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); // Ten consecutive 6 h (360 min) nights. Need = 420 (40-year-old default in - // beforeEach) → a 60-min deficit per night, 10 nights ≥ the 7-night floor. + // beforeEach) → a 60-min deficit per night, 10 nights ≥ the night floor. const rows = []; for (let d = 1; d <= 10; d++) { const day = String(d).padStart(2, "0"); @@ -627,12 +627,15 @@ describe("GET /api/dashboard/summary", () => { }; }; const { sleepDebt, chronotype } = body.data.sleepRhythm; - // Debt DTO is the computeSleepDebt result: ready, 10 × 60 = 600 min, need - // forwarded as 420 — proves the route reused the module, not a recompute. + // Debt DTO is the computeSleepDebt result: the rolling balance over the + // 5-night window of 60-min-short nights with no surplus to recover → + // 5 × 60 = 300 min, need forwarded as 420 — proves the route reused the + // module, not a recompute. expect(sleepDebt.state).toBe("ready"); - expect(sleepDebt.debtMinutes).toBe(600); + expect(sleepDebt.debtMinutes).toBe(300); expect(sleepDebt.needMinutes).toBe(420); - expect(sleepDebt.nightsCounted).toBe(10); + expect(sleepDebt.nightsCounted).toBe(5); + expect(sleepDebt.windowNights).toBe(5); expect(sleepDebt.nightsUntilReady).toBe(0); // Chronotype DTO carries every wiring field; the calm learning state holds // until enough free-day nights exist (these are all weekday wake days). diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts index 4ef07e50d..93dd872bf 100644 --- a/src/app/api/dashboard/summary/route.ts +++ b/src/app/api/dashboard/summary/route.ts @@ -55,7 +55,7 @@ import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; -import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today"; import { summarizeSleepNights, @@ -328,12 +328,23 @@ export const GET = apiHandler(async () => { // it). const userTz = user.timezone ?? DEFAULT_TIMEZONE; - // v1.4.38 W-F — wrap the whole response in the 60 s analytics LRU. + // v1.4.38 W-F — wrap the whole response in the analytics LRU. // Subsequent iOS polls inside the TTL hit memory; measurement / // mood / medication writes invalidate the user-bucket via the // existing `invalidateUserMeasurements` + the v1.4.38-extended // `invalidateUserMedications` hooks. - const body = await cached( + // + // v1.18.11 (W5 perf) — read via `cachedSwr`. The `analytics` bucket + // already provisions a 1 h `staleTtlMs` window, but this endpoint read + // through plain `cached` and so ignored it: the iOS client polls every + // 60 s, and a plain read past the 60 s fresh TTL re-paid the full cold + // rebuild on every poll. `cachedSwr` serves the prior body instantly + // inside the stale window and warms one background recompute instead. + // Writes still mark/evict the bucket, so a user's own action is + // reflected on the very next read — the window only bounds wall-clock + // drift (the same self-correcting `nextDueOverdue`/tally drift the + // snapshot read already tolerates). + const body = await cachedSwr( caches.analytics as ServerCache< Awaited> >, diff --git a/src/app/api/gamification/achievements/route.ts b/src/app/api/gamification/achievements/route.ts index 8cbb938b5..0661bcbdc 100644 --- a/src/app/api/gamification/achievements/route.ts +++ b/src/app/api/gamification/achievements/route.ts @@ -3,7 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { requireModuleEnabled, resolveModuleMap } from "@/lib/modules/gate"; import { annotate } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; -import { cached, caches, type ServerCache } from "@/lib/cache/server-cache"; +import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { buildAchievementsResult, type AchievementsResult, @@ -72,7 +72,13 @@ export const GET = apiHandler(async (request: NextRequest) => { // a toggle change is reflected on the next read. const moduleMap = await resolveModuleMap(user.id); - const result = await cached( + // v1.18.11 (W5 perf) — read via `cachedSwr`. The bucket carries a + // 10-minute stale window; the app-wide `AchievementUnlockNotifier` polls + // every 2 minutes, so a hard-TTL read always missed and re-paid the cold + // build. SWR serves the prior payload instantly and warms one background + // recompute. Persistence of `pendingUnlocks` stays OUTSIDE this read (see + // below), so a stale-served body never skips an unlock write. + const result = await cachedSwr( caches.achievements as ServerCache, user.id, () => buildAchievementsResult(user, moduleMap), diff --git a/src/app/api/insights/generate/__tests__/route.test.ts b/src/app/api/insights/generate/__tests__/route.test.ts index ff58846b8..355250d4a 100644 --- a/src/app/api/insights/generate/__tests__/route.test.ts +++ b/src/app/api/insights/generate/__tests__/route.test.ts @@ -103,6 +103,20 @@ vi.mock("@/lib/insights/comprehensive-generate", () => ({ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), + // The briefing illness/cycle context resolves module gates through + // `memoizePerRequest`, which reads the active wide-event via `getEvent`. + // Returning no event makes the per-request cache fall through to the + // factory, exercising the real gate path without a request scope. + getEvent: vi.fn(() => undefined), +})); + +// The briefing's illness/cycle context (v1.18.11 P5) is module-gated and +// server-authoritative with its own gate tests; these cases exercise the +// provider/cache path, so stub it to no context to keep the route isolated +// from the cycle/illness DB queries. +vi.mock("@/lib/insights/illness-cycle-briefing", () => ({ + buildBriefingIllnessCycleContext: vi.fn(async () => null), + buildBriefingIllnessCyclePrompt: vi.fn(() => ""), })); vi.mock("@/lib/insights/features", async () => { diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index b979a59d7..bddd9313a 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -9,8 +9,17 @@ import { import { extractFeatures, FeaturesPayloadTooLargeError, + BRIEFING_FEATURE_WINDOW_DAYS, } from "@/lib/insights/features"; import { applyInsightsExcludeFilter } from "@/lib/insights/exclude-filter"; +import { + getCachedFeatures, + withFeatureCacheScope, +} from "@/lib/insights/feature-cache"; +import { + buildBriefingIllnessCycleContext, + buildBriefingIllnessCyclePrompt, +} from "@/lib/insights/illness-cycle-briefing"; import { compactSections } from "@/lib/ai/prompts/compact-sections"; import { detectGlp1Plateau, @@ -338,539 +347,586 @@ export const GET = apiHandler(async (request: NextRequest) => { }); }); -export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); - // v1.4.31 — the advisor + daily briefing surfaces ride this - // endpoint. Operator disables Coach, every advisor consumer - // (briefing card, recommendations grid, hero strip narration) - // empties out. - await requireAssistantSurface("coach"); - const userId = user.id; +export const POST = apiHandler((request: NextRequest) => + // v1.18.11 P3 — open a request-scoped feature cache so the bounded feature + // read is computed once and reused across the downgrade ladder (and any + // sibling consumer) for this request. + withFeatureCacheScope(async () => { + const { user } = await requireAuth(); + // v1.4.31 — the advisor + daily briefing surfaces ride this + // endpoint. Operator disables Coach, every advisor consumer + // (briefing card, recommendations grid, hero strip narration) + // empties out. + await requireAssistantSurface("coach"); + const userId = user.id; - const dbUser = await prisma.user.findUnique({ - where: { id: userId }, - select: { - insightsPrivacyMode: true, - insightsCachedAt: true, - insightsCachedText: true, - // v1.4.36 W3 T3 — per-user opt-out list mirroring the Coach - // settings. Filtered off `features` before serialisation so the - // LLM never sees the excluded blocks. - insightsExcludeMetrics: true, - locale: true, - // v1.10.0 — profile for the derived-signal briefing detector. - dateOfBirth: true, - gender: true, - heightCm: true, - }, - }); + const dbUser = await prisma.user.findUnique({ + where: { id: userId }, + select: { + insightsPrivacyMode: true, + insightsCachedAt: true, + insightsCachedText: true, + // v1.4.36 W3 T3 — per-user opt-out list mirroring the Coach + // settings. Filtered off `features` before serialisation so the + // LLM never sees the excluded blocks. + insightsExcludeMetrics: true, + locale: true, + // v1.10.0 — profile for the derived-signal briefing detector. + dateOfBirth: true, + gender: true, + heightCm: true, + }, + }); - const locale = await resolveServerLocale({ - request, - userLocale: dbUser?.locale ?? user.locale ?? null, - }); + const locale = await resolveServerLocale({ + request, + userLocale: dbUser?.locale ?? user.locale ?? null, + }); - const { data: body, error: jsonError } = await safeJson<{ - force?: unknown; - }>(request, { maxBytes: 16 * 1024 }); - if (jsonError) return jsonError; - const forceRefresh = body.force === true; + const { data: body, error: jsonError } = await safeJson<{ + force?: unknown; + }>(request, { maxBytes: 16 * 1024 }); + if (jsonError) return jsonError; + const forceRefresh = body.force === true; - if ( - !forceRefresh && - dbUser?.insightsCachedAt && - dbUser.insightsCachedText && - Date.now() - dbUser.insightsCachedAt.getTime() < 24 * 60 * 60 * 1000 - ) { - try { - const cached = JSON.parse(dbUser.insightsCachedText); - // v1.4.16 B5c: detect legacy payloads (pre-rationale) so the UI - // can show a "regenerate for new explainability features" CTA. - // We don't auto-regenerate — that would burn rate-limit tokens - // on a cache-hit silently. User-initiated only. - const legacyPayload = isLegacyInsightPayload(cached); - annotate({ - action: { name: "insights.generate" }, - meta: { cached: true, legacyPayload }, - }); - return apiSuccess({ - insights: cached, - cached: true, - cachedAt: dbUser.insightsCachedAt, - legacyPayload, - }); - } catch { - // Invalid cache, regenerate - } - } - - // P13: rate-limit check moved BELOW the cache return so a hit on the - // 24h cache never burns one of the LLM-generation tokens. Otherwise a - // noisy reload loop on the dashboard would lock out real refreshes - // for an hour for no benefit. - // - // v1.4.16 A7: limit raised from 2 → 10 per hour (env-configurable via - // `INSIGHTS_RATE_LIMIT_PER_HOUR`). The 2/hour limit triggered too - // aggressively when a user iterated on settings or regenerated after - // adding a few measurements; 10/hour still cleanly bounds cost while - // staying out of the way for legitimate use. - const limit = resolveInsightsRateLimit(); - const rl = await checkRateLimit(`insights:${userId}`, limit, 60 * 60 * 1000); - if (!rl.allowed) { - return apiError(`Maximum ${limit} insight generations per hour.`, 429); - } - - // v1.4.16 phase B5b: resolve the user's full provider chain so a hard - // failure on the primary (401, 5xx, network) cascades to the next - // configured fallback rather than surfacing a 503/422 to the - // dashboard. Falls back to the single-provider resolution when the - // chain comes back empty AND the legacy `aiProvider` field still - // points at something usable — preserving v1.4.15 behaviour for - // accounts that haven't customised the chain. - const chain = await resolveProviderChain(userId); - if (chain.length === 0) { - // Try the legacy single-provider path one more time so users who - // configured AI before v1.4.16 don't suddenly see the 422. - const legacy = await resolveProvider(userId); - if (legacy.type === "none") { - return apiError( - "No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.", - 422, - ); - } - chain.push({ providerType: "admin-openai", instance: legacy }); - } - - // v1.12.1 — consent gate before any server-managed external egress. - // Required for the operator's global key (`admin-openai`); BYOK / local / - // ChatGPT-OAuth chains stay ungated. Throws ConsentRequiredError → - // apiHandler returns 403 + `consent.ai.required`. - await assertConsentForChain({ userId, chain, surface: "insights" }); - - const includeRaw = dbUser?.insightsPrivacyMode === "raw"; - // v1.4.36 W3 T1 — `extractFeatures` enforces a 5 MB ceiling on the - // serialised payload. If the raw (bucketed) shape ever blows past - // it (regression watch — the v1.4.35 rawMeasurements shape hit - // 25.9 MB on the largest tenant), the helper throws - // `FeaturesPayloadTooLargeError`. We downgrade to the aggregated - // shape rather than 500-ing so the user still gets an insight; the - // annotate event surfaces the regression to ops via the Logflare - // pipeline. - let features: Awaited>; - let payloadDowngraded = false; - let payloadOversizeBytes: number | null = null; - // v1.4.36 QA H1 — extra fallback when even the aggregated shape - // crosses the 5 MB ceiling (very large medication history + multi- - // year context). Strips anthropometrics + medications + every other - // exclude-token-mapped block and tries one more time. On a third - // failure we return 422 with an annotate event for ops visibility - // rather than 500-ing out of an uncaught throw. - const MAX_DOWNGRADE_TOKENS: ReadonlyArray = [ - "anthropometrics", - "medications", - "compliance", - "sleep", - "steps", - "hrv", - "resting_hr", - ]; - let payloadHardDowngraded = false; - try { - features = await extractFeatures(userId, includeRaw); - } catch (err) { - if (err instanceof FeaturesPayloadTooLargeError) { - payloadDowngraded = true; - payloadOversizeBytes = err.sizeBytes; + if ( + !forceRefresh && + dbUser?.insightsCachedAt && + dbUser.insightsCachedText && + Date.now() - dbUser.insightsCachedAt.getTime() < 24 * 60 * 60 * 1000 + ) { try { - features = await extractFeatures(userId, false); - } catch (retryErr) { - if (retryErr instanceof FeaturesPayloadTooLargeError) { - // Aggregated shape ALSO crossed the ceiling. Drop optional - // context blocks (anthropometrics, medications, sleep, - // steps, hrv, resting_hr) via the existing exclude filter - // and try once more. - payloadHardDowngraded = true; - payloadOversizeBytes = retryErr.sizeBytes; - try { - const aggregated = await extractFeatures(userId, false); - features = applyInsightsExcludeFilter( - aggregated, - MAX_DOWNGRADE_TOKENS, - ); - } catch (finalErr) { - // Even the aggregated read itself blew up. Annotate and - // return 422 rather than let the uncaught throw 500 out. - annotate({ - meta: { - insights_payload_too_large: true, - insights_payload_oversize_bytes: - finalErr instanceof FeaturesPayloadTooLargeError - ? finalErr.sizeBytes - : payloadOversizeBytes, - }, - }); - return apiError( - "Your data exceeds the AI payload size limit. Reduce the data window or exclude optional blocks in Settings > AI.", - 422, - ); - } - } else { - throw retryErr; - } + const cached = JSON.parse(dbUser.insightsCachedText); + // v1.4.16 B5c: detect legacy payloads (pre-rationale) so the UI + // can show a "regenerate for new explainability features" CTA. + // We don't auto-regenerate — that would burn rate-limit tokens + // on a cache-hit silently. User-initiated only. + const legacyPayload = isLegacyInsightPayload(cached); + annotate({ + action: { name: "insights.generate" }, + meta: { cached: true, legacyPayload }, + }); + return apiSuccess({ + insights: cached, + cached: true, + cachedAt: dbUser.insightsCachedAt, + legacyPayload, + }); + } catch { + // Invalid cache, regenerate } - } else { - throw err; } - } - // v1.4.36 W3 T3 — apply the user's exclude-metrics opt-out list - // BEFORE serialisation so the model never sees the dropped blocks. - const excludeList = dbUser?.insightsExcludeMetrics ?? []; - features = applyInsightsExcludeFilter(features, excludeList); - // v1.4.36 W3 T4 — drop zero-row blocks so the prompt never carries - // labelled-empty sections (`"sleep": []`, `"medications": []`, - // etc.). Prevents the model from narrating "there are no - // medications in your data" when the user explicitly excluded the - // block. - const compactFeatures = compactSections( - features as unknown as Record, - ); - const featuresJson = JSON.stringify(compactFeatures, null, 2); - if (payloadDowngraded) { - annotate({ - meta: { - insights_features_downgraded: true, - insights_features_oversize_bytes: payloadOversizeBytes, - insights_features_hard_downgraded: payloadHardDowngraded, - }, - }); - } - // v1.4.16 phase B8 — pick up the user's persisted comparison toggle - // and, when active, build a compact prior-period snapshot from the - // `features` summaries that already include `avg30LastMonth` / - // `avg30LastYear` (added in the analytics summarize() helper). The - // narrative is default-on whenever the toggle is on per research §7 - // Q4 — the pulldown is the single affordance. - const comparisonSnapshot = await buildComparisonSnapshotForUser(userId); - // v1.4.25 W4d — GLP-1 plateau detector. Appends a SYSTEM CONTEXT - // block to the user prompt when the active GLP-1 medication has been - // on a stable dose for ≥21 days with weight delta within ±0.5 kg. - // The block instructs the model to emit a glp1_plateau keyFinding - // framed observationally (no dose recommendation per GROUND RULE 13). - // Returns null when no GLP-1 is active OR no plateau condition; - // 99% of users land in the null branch and pay zero token cost. - const plateauContext = await detectGlp1Plateau(userId); - let userPrompt = buildUserPrompt( - featuresJson, - dbUser?.insightsPrivacyMode ?? "aggregated", - locale, - comparisonSnapshot ?? undefined, - ); - if (plateauContext) { - userPrompt += buildGlp1PlateauPrompt(plateauContext, locale); - } - // v1.15.20 — fold the user-authored "about me" self-description - // (Settings → AI) into the briefing as a delimited, user-provided - // SYSTEM CONTEXT block. Null (no text / undecryptable) costs nothing. - const aboutMe = await getSelfContextTextForUser(userId, locale); - if (aboutMe) { - userPrompt += buildAboutMeInsightBlock(aboutMe, locale); - } - // v1.10.0 — fold a notable derived wellness signal (readiness / recovery - // shift, confidence-gated) into the briefing as a SYSTEM CONTEXT block. - // Reads the one derived contract; null (the common case) costs nothing. - const derivedSex = - dbUser?.gender === "MALE" || dbUser?.gender === "FEMALE" - ? (dbUser.gender as "MALE" | "FEMALE") - : null; - const derivedBriefing = await detectDerivedBriefingSignals(userId, { - ageYears: getAgeFromDateOfBirth(dbUser?.dateOfBirth ?? null), - sex: derivedSex, - heightCm: dbUser?.heightCm ?? null, - }); - if (derivedBriefing) { - userPrompt += buildDerivedBriefingPrompt(derivedBriefing, locale); - } + // P13: rate-limit check moved BELOW the cache return so a hit on the + // 24h cache never burns one of the LLM-generation tokens. Otherwise a + // noisy reload loop on the dashboard would lock out real refreshes + // for an hour for no benefit. + // + // v1.4.16 A7: limit raised from 2 → 10 per hour (env-configurable via + // `INSIGHTS_RATE_LIMIT_PER_HOUR`). The 2/hour limit triggered too + // aggressively when a user iterated on settings or regenerated after + // adding a few measurements; 10/hour still cleanly bounds cost while + // staying out of the way for legitimate use. + const limit = resolveInsightsRateLimit(); + const rl = await checkRateLimit( + `insights:${userId}`, + limit, + 60 * 60 * 1000, + ); + if (!rl.allowed) { + return apiError(`Maximum ${limit} insight generations per hour.`, 429); + } - // v1.12.7 (B5) — inject the curated SOURCES block for the metric sections - // this briefing actually carries, so a normative claim can cite a real - // `referenceId` the schema + UI footnote already support. Returns the plain - // prompt unchanged when no applicable metric section is present. - const referenceMetrics = metricsFromPresentSections({ - bloodPressure: features.bloodPressure != null, - weight: features.weight != null, - pulse: features.pulse != null, - mood: features.mood != null, - medication: (features.medications?.length ?? 0) > 0, - }); - - let result; - let workingProviderType: string; - let fallbackHopCount = 0; - try { - const fallback = await runRawCompletionWithFallback({ - userId, - providers: chain, - params: { - // The strict prompt (PROMPT_VERSION 4.20.x) carries GROUND RULE 8 - // — emit a top-level `dailyBriefing` block when the snapshot has - // analysable signal — plus the trendAnnotations and - // storyboardAnnotations rules. The legacy `getInsightsSystemPrompt` - // returned the v1.4.5 `{changed, stable, drivers, …}` shape and - // never asked the model for dailyBriefing, so the hero strip's - // "Re-run analysis" button kept producing a payload with no - // briefing block (Issue 1, v1.4.20 post-deploy). The route already - // tolerates the strict shape via `insightResultSchema.safeParse`'s - // soft fallback to `parsed` and `passthrough()` on the strict - // schema, so switching the prompt does not break legacy callers. - systemPrompt: buildSystemPromptWithReferences(locale, referenceMetrics), - userPrompt, - temperature: 0.3, - maxTokens: 1500, - }, - }); - result = fallback.result; - workingProviderType = fallback.workingProvider.providerType; - fallbackHopCount = fallback.fallbackHops.length; - } catch (e) { - if (e instanceof AllProvidersFailedError) { - annotate({ - meta: { - insights_chain_outcome: "all-failed", - insights_chain_attempts: e.attempts.length, - insights_chain_first_provider: e.attempts[0]?.providerType ?? null, - insights_chain_first_status: e.attempts[0]?.httpStatus ?? null, - }, - }); - // Pick the most specific user-facing message we can. Auth-class - // failures across the entire chain mean every provider's - // credential is bad — keep the v1.4.5 "check your API key" - // wording so the user knows where to act. 429 across the chain - // is rate-limit; 5xx is upstream brown-out; transport-only - // (ECONNRESET, no httpStatus) maps to the generic connection - // hint so users on flaky networks don't see "rate-limited". - const allAuth = e.attempts.every( - (a) => a.httpStatus === 401 || a.httpStatus === 403, - ); - if (allAuth) { + // v1.4.16 phase B5b: resolve the user's full provider chain so a hard + // failure on the primary (401, 5xx, network) cascades to the next + // configured fallback rather than surfacing a 503/422 to the + // dashboard. Falls back to the single-provider resolution when the + // chain comes back empty AND the legacy `aiProvider` field still + // points at something usable — preserving v1.4.15 behaviour for + // accounts that haven't customised the chain. + const chain = await resolveProviderChain(userId); + if (chain.length === 0) { + // Try the legacy single-provider path one more time so users who + // configured AI before v1.4.16 don't suddenly see the 422. + const legacy = await resolveProvider(userId); + if (legacy.type === "none") { return apiError( - "AI provider rejected the request — check your API key in Settings > AI", + "No AI provider configured. Connect ChatGPT in settings or ask your admin to set up an API key.", 422, ); } - const all429 = e.attempts.every((a) => a.httpStatus === 429); - if (all429) { - return apiError("AI provider rate-limited the request", 429); + chain.push({ providerType: "admin-openai", instance: legacy }); + } + + // v1.12.1 — consent gate before any server-managed external egress. + // Required for the operator's global key (`admin-openai`); BYOK / local / + // ChatGPT-OAuth chains stay ungated. Throws ConsentRequiredError → + // apiHandler returns 403 + `consent.ai.required`. + await assertConsentForChain({ userId, chain, surface: "insights" }); + + const includeRaw = dbUser?.insightsPrivacyMode === "raw"; + // v1.4.36 W3 T1 — `extractFeatures` enforces a 5 MB ceiling on the + // serialised payload. If the raw (bucketed) shape ever blows past + // it (regression watch — the v1.4.35 rawMeasurements shape hit + // 25.9 MB on the largest tenant), the helper throws + // `FeaturesPayloadTooLargeError`. We downgrade to the aggregated + // shape rather than 500-ing so the user still gets an insight; the + // annotate event surfaces the regression to ops via the Logflare + // pipeline. + let features: Awaited>; + let payloadDowngraded = false; + let payloadOversizeBytes: number | null = null; + // v1.4.36 QA H1 — extra fallback when even the aggregated shape + // crosses the 5 MB ceiling (very large medication history + multi- + // year context). Strips anthropometrics + medications + every other + // exclude-token-mapped block and tries one more time. On a third + // failure we return 422 with an annotate event for ops visibility + // rather than 500-ing out of an uncaught throw. + const MAX_DOWNGRADE_TOKENS: ReadonlyArray = [ + "anthropometrics", + "medications", + "compliance", + "sleep", + "steps", + "hrv", + "resting_hr", + ]; + let payloadHardDowngraded = false; + // v1.18.11 P1 — bound the briefing's bulk feature read to a recent window + // (all-time extremes are sourced separately, see features.ts). The downgrade + // ladder below stays as the safety net for the rare oversize payload. + const featureWindow = { sinceDays: BRIEFING_FEATURE_WINDOW_DAYS }; + try { + // v1.18.11 P3 — compute-once-per-request: the scope opened around this + // handler shares the bounded feature read across the downgrade ladder. + features = await getCachedFeatures({ + userId, + includeRaw, + sinceDays: featureWindow.sinceDays, + compute: () => extractFeatures(userId, includeRaw, featureWindow), + }); + } catch (err) { + if (err instanceof FeaturesPayloadTooLargeError) { + payloadDowngraded = true; + payloadOversizeBytes = err.sizeBytes; + try { + features = await getCachedFeatures({ + userId, + includeRaw: false, + sinceDays: featureWindow.sinceDays, + compute: () => extractFeatures(userId, false, featureWindow), + }); + } catch (retryErr) { + if (retryErr instanceof FeaturesPayloadTooLargeError) { + // Aggregated shape ALSO crossed the ceiling. Drop optional + // context blocks (anthropometrics, medications, sleep, + // steps, hrv, resting_hr) via the existing exclude filter + // and try once more. + payloadHardDowngraded = true; + payloadOversizeBytes = retryErr.sizeBytes; + try { + const aggregated = await getCachedFeatures({ + userId, + includeRaw: false, + sinceDays: featureWindow.sinceDays, + compute: () => extractFeatures(userId, false, featureWindow), + }); + features = applyInsightsExcludeFilter( + aggregated, + MAX_DOWNGRADE_TOKENS, + ); + } catch (finalErr) { + // Even the aggregated read itself blew up. Annotate and + // return 422 rather than let the uncaught throw 500 out. + annotate({ + meta: { + insights_payload_too_large: true, + insights_payload_oversize_bytes: + finalErr instanceof FeaturesPayloadTooLargeError + ? finalErr.sizeBytes + : payloadOversizeBytes, + }, + }); + return apiError( + "Your data exceeds the AI payload size limit. Reduce the data window or exclude optional blocks in Settings > AI.", + 422, + ); + } + } else { + throw retryErr; + } + } + } else { + throw err; } - const has5xx = e.attempts.some( - (a) => a.httpStatus !== null && a.httpStatus >= 500, - ); - if (has5xx) { + } + // v1.4.36 W3 T3 — apply the user's exclude-metrics opt-out list + // BEFORE serialisation so the model never sees the dropped blocks. + const excludeList = dbUser?.insightsExcludeMetrics ?? []; + features = applyInsightsExcludeFilter(features, excludeList); + // v1.4.36 W3 T4 — drop zero-row blocks so the prompt never carries + // labelled-empty sections (`"sleep": []`, `"medications": []`, + // etc.). Prevents the model from narrating "there are no + // medications in your data" when the user explicitly excluded the + // block. + const compactFeatures = compactSections( + features as unknown as Record, + ); + const featuresJson = JSON.stringify(compactFeatures, null, 2); + if (payloadDowngraded) { + annotate({ + meta: { + insights_features_downgraded: true, + insights_features_oversize_bytes: payloadOversizeBytes, + insights_features_hard_downgraded: payloadHardDowngraded, + }, + }); + } + + // v1.4.16 phase B8 — pick up the user's persisted comparison toggle + // and, when active, build a compact prior-period snapshot from the + // `features` summaries that already include `avg30LastMonth` / + // `avg30LastYear` (added in the analytics summarize() helper). The + // narrative is default-on whenever the toggle is on per research §7 + // Q4 — the pulldown is the single affordance. + const comparisonSnapshot = await buildComparisonSnapshotForUser(userId); + // v1.4.25 W4d — GLP-1 plateau detector. Appends a SYSTEM CONTEXT + // block to the user prompt when the active GLP-1 medication has been + // on a stable dose for ≥21 days with weight delta within ±0.5 kg. + // The block instructs the model to emit a glp1_plateau keyFinding + // framed observationally (no dose recommendation per GROUND RULE 13). + // Returns null when no GLP-1 is active OR no plateau condition; + // 99% of users land in the null branch and pay zero token cost. + const plateauContext = await detectGlp1Plateau(userId); + let userPrompt = buildUserPrompt( + featuresJson, + dbUser?.insightsPrivacyMode ?? "aggregated", + locale, + comparisonSnapshot ?? undefined, + ); + if (plateauContext) { + userPrompt += buildGlp1PlateauPrompt(plateauContext, locale); + } + // v1.15.20 — fold the user-authored "about me" self-description + // (Settings → AI) into the briefing as a delimited, user-provided + // SYSTEM CONTEXT block. Null (no text / undecryptable) costs nothing. + const aboutMe = await getSelfContextTextForUser(userId, locale); + if (aboutMe) { + userPrompt += buildAboutMeInsightBlock(aboutMe, locale); + } + // v1.10.0 — fold a notable derived wellness signal (readiness / recovery + // shift, confidence-gated) into the briefing as a SYSTEM CONTEXT block. + // Reads the one derived contract; null (the common case) costs nothing. + const derivedSex = + dbUser?.gender === "MALE" || dbUser?.gender === "FEMALE" + ? (dbUser.gender as "MALE" | "FEMALE") + : null; + const derivedBriefing = await detectDerivedBriefingSignals(userId, { + ageYears: getAgeFromDateOfBirth(dbUser?.dateOfBirth ?? null), + sex: derivedSex, + heightCm: dbUser?.heightCm ?? null, + }); + if (derivedBriefing) { + userPrompt += buildDerivedBriefingPrompt(derivedBriefing, locale); + } + + // v1.18.11 P5 — fold illness-episode + cycle state into the briefing so a + // mid-illness or mid-cycle user gets context-aware prose. Reuses the same + // server-authoritative builders the Coach assembles; both are module-gated + // and short-circuit to null (zero token cost) for users without them. + const illnessCycleCtx = await buildBriefingIllnessCycleContext( + userId, + dbUser?.gender ?? null, + await resolveUserTimezone(userId), + ); + if (illnessCycleCtx) { + userPrompt += buildBriefingIllnessCyclePrompt(illnessCycleCtx, locale); + } + + // v1.12.7 (B5) — inject the curated SOURCES block for the metric sections + // this briefing actually carries, so a normative claim can cite a real + // `referenceId` the schema + UI footnote already support. Returns the plain + // prompt unchanged when no applicable metric section is present. + const referenceMetrics = metricsFromPresentSections({ + bloodPressure: features.bloodPressure != null, + weight: features.weight != null, + pulse: features.pulse != null, + mood: features.mood != null, + medication: (features.medications?.length ?? 0) > 0, + }); + + let result; + let workingProviderType: string; + let fallbackHopCount = 0; + try { + const fallback = await runRawCompletionWithFallback({ + userId, + providers: chain, + params: { + // The strict prompt (PROMPT_VERSION 4.20.x) carries GROUND RULE 8 + // — emit a top-level `dailyBriefing` block when the snapshot has + // analysable signal — plus the trendAnnotations and + // storyboardAnnotations rules. The legacy `getInsightsSystemPrompt` + // returned the v1.4.5 `{changed, stable, drivers, …}` shape and + // never asked the model for dailyBriefing, so the hero strip's + // "Re-run analysis" button kept producing a payload with no + // briefing block (Issue 1, v1.4.20 post-deploy). The route already + // tolerates the strict shape via `insightResultSchema.safeParse`'s + // soft fallback to `parsed` and `passthrough()` on the strict + // schema, so switching the prompt does not break legacy callers. + systemPrompt: buildSystemPromptWithReferences( + locale, + referenceMetrics, + ), + userPrompt, + temperature: 0.3, + maxTokens: 1500, + }, + }); + result = fallback.result; + workingProviderType = fallback.workingProvider.providerType; + fallbackHopCount = fallback.fallbackHops.length; + } catch (e) { + if (e instanceof AllProvidersFailedError) { + annotate({ + meta: { + insights_chain_outcome: "all-failed", + insights_chain_attempts: e.attempts.length, + insights_chain_first_provider: e.attempts[0]?.providerType ?? null, + insights_chain_first_status: e.attempts[0]?.httpStatus ?? null, + }, + }); + // Pick the most specific user-facing message we can. Auth-class + // failures across the entire chain mean every provider's + // credential is bad — keep the v1.4.5 "check your API key" + // wording so the user knows where to act. 429 across the chain + // is rate-limit; 5xx is upstream brown-out; transport-only + // (ECONNRESET, no httpStatus) maps to the generic connection + // hint so users on flaky networks don't see "rate-limited". + const allAuth = e.attempts.every( + (a) => a.httpStatus === 401 || a.httpStatus === 403, + ); + if (allAuth) { + return apiError( + "AI provider rejected the request — check your API key in Settings > AI", + 422, + ); + } + const all429 = e.attempts.every((a) => a.httpStatus === 429); + if (all429) { + return apiError("AI provider rate-limited the request", 429); + } + const has5xx = e.attempts.some( + (a) => a.httpStatus !== null && a.httpStatus >= 500, + ); + if (has5xx) { + return apiError( + "AI provider temporarily unavailable, try again in a moment", + 503, + ); + } + const allTransport = e.attempts.every( + (a) => a.httpStatus === null || a.httpStatus <= 0, + ); + if (allTransport) { + return apiError( + "AI provider connection failed — check your AI settings", + 422, + ); + } return apiError( "AI provider temporarily unavailable, try again in a moment", 503, ); } - const allTransport = e.attempts.every( - (a) => a.httpStatus === null || a.httpStatus <= 0, - ); - if (allTransport) { + // v1.4.6 T5 mapped only the parse-error branch from 502→422. Non- + // hard provider errors (e.g. a custom 4xx from a self-hosted local + // model) still propagate here. Cloudflare rewrites 5xx to its own + // HTML error page, which breaks `await res.json()` on the + // dashboard. Mirror the v1.4.5 ai/test categorisation. + const err = e as Error & { httpStatus?: number; bodyExcerpt?: string }; + annotate({ + meta: { + insights_provider_error: err.message.slice(0, 500), + insights_provider_status: err.httpStatus ?? null, + insights_provider_body_excerpt: + err.bodyExcerpt?.slice(0, 500) ?? null, + }, + }); + const status = err.httpStatus ?? 0; + if (status === 401 || status === 403) { return apiError( - "AI provider connection failed — check your AI settings", + "AI provider rejected the request — check your API key in Settings > AI", 422, ); } + if (status === 429) { + return apiError("AI provider rate-limited the request", 429); + } + if (status >= 500) { + return apiError( + "AI provider temporarily unavailable, try again in a moment", + 503, + ); + } return apiError( - "AI provider temporarily unavailable, try again in a moment", - 503, - ); - } - // v1.4.6 T5 mapped only the parse-error branch from 502→422. Non- - // hard provider errors (e.g. a custom 4xx from a self-hosted local - // model) still propagate here. Cloudflare rewrites 5xx to its own - // HTML error page, which breaks `await res.json()` on the - // dashboard. Mirror the v1.4.5 ai/test categorisation. - const err = e as Error & { httpStatus?: number; bodyExcerpt?: string }; - annotate({ - meta: { - insights_provider_error: err.message.slice(0, 500), - insights_provider_status: err.httpStatus ?? null, - insights_provider_body_excerpt: err.bodyExcerpt?.slice(0, 500) ?? null, - }, - }); - const status = err.httpStatus ?? 0; - if (status === 401 || status === 403) { - return apiError( - "AI provider rejected the request — check your API key in Settings > AI", + "AI provider connection failed — check your AI settings", 422, ); } - if (status === 429) { - return apiError("AI provider rate-limited the request", 429); + + let insights: InsightResult | Record; + try { + const parsed = JSON.parse(result.content); + // Try new schema first, fall back to raw parsed if validation fails + const validated = insightResultSchema.safeParse(parsed); + insights = validated.success ? validated.data : parsed; + } catch { + // Returning 502 here triggers Cloudflare's HTML error rewrite, which + // breaks `await res.json()` on the client side. 422 stays passthrough + // so the React Query mutation can read the JSON body and surface a + // readable message. Same fix pattern as v1.4.5 ai/test. + return apiError("AI response was not valid JSON", 422); } - if (status >= 500) { - return apiError( - "AI provider temporarily unavailable, try again in a moment", - 503, + + // v1.18.10 (HIGH-1) — daily-briefing number-grounding gate, mirroring the + // recommendations citation cross-check next door. The schema only checks the + // briefing's SHAPE; this asserts every number the briefing prose restates + // traces to a `features.signalsOfDay` figure. On a miss: one corrective + // retry, then drop the briefing block rather than ship a fabricated number. + { + const signals = features.signalsOfDay ?? null; + let ungrounded = findUngroundedBriefingNumbers( + readBriefingBlock(insights), + signals, ); - } - return apiError( - "AI provider connection failed — check your AI settings", - 422, - ); - } - - let insights: InsightResult | Record; - try { - const parsed = JSON.parse(result.content); - // Try new schema first, fall back to raw parsed if validation fails - const validated = insightResultSchema.safeParse(parsed); - insights = validated.success ? validated.data : parsed; - } catch { - // Returning 502 here triggers Cloudflare's HTML error rewrite, which - // breaks `await res.json()` on the client side. 422 stays passthrough - // so the React Query mutation can read the JSON body and surface a - // readable message. Same fix pattern as v1.4.5 ai/test. - return apiError("AI response was not valid JSON", 422); - } - - // v1.18.10 (HIGH-1) — daily-briefing number-grounding gate, mirroring the - // recommendations citation cross-check next door. The schema only checks the - // briefing's SHAPE; this asserts every number the briefing prose restates - // traces to a `features.signalsOfDay` figure. On a miss: one corrective - // retry, then drop the briefing block rather than ship a fabricated number. - { - const signals = features.signalsOfDay ?? null; - let ungrounded = findUngroundedBriefingNumbers( - readBriefingBlock(insights), - signals, - ); - if (ungrounded.length > 0) { - annotate({ - action: { name: "insights.generate.briefing_grounding_retry" }, - meta: { ungroundedCount: ungrounded.length }, - }); - try { - const retry = await runRawCompletionWithFallback({ - userId, - providers: chain, - params: { - systemPrompt: buildSystemPromptWithReferences( - locale, - referenceMetrics, - ), - userPrompt: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`, - temperature: 0.3, - maxTokens: 1500, - }, + if (ungrounded.length > 0) { + annotate({ + action: { name: "insights.generate.briefing_grounding_retry" }, + meta: { ungroundedCount: ungrounded.length }, }); - const parsedRetry = JSON.parse(retry.result.content); - const validatedRetry = insightResultSchema.safeParse(parsedRetry); - const retryInsights = validatedRetry.success - ? validatedRetry.data - : parsedRetry; - ungrounded = findUngroundedBriefingNumbers( - readBriefingBlock(retryInsights), - signals, - ); - if (ungrounded.length === 0) { - insights = retryInsights; - result = retry.result; - workingProviderType = retry.workingProvider.providerType; + try { + const retry = await runRawCompletionWithFallback({ + userId, + providers: chain, + params: { + systemPrompt: buildSystemPromptWithReferences( + locale, + referenceMetrics, + ), + userPrompt: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`, + temperature: 0.3, + maxTokens: 1500, + }, + }); + const parsedRetry = JSON.parse(retry.result.content); + const validatedRetry = insightResultSchema.safeParse(parsedRetry); + const retryInsights = validatedRetry.success + ? validatedRetry.data + : parsedRetry; + ungrounded = findUngroundedBriefingNumbers( + readBriefingBlock(retryInsights), + signals, + ); + if (ungrounded.length === 0) { + insights = retryInsights; + result = retry.result; + workingProviderType = retry.workingProvider.providerType; + } + } catch { + // Retry failed to produce parseable JSON — fall through to the strip. } - } catch { - // Retry failed to produce parseable JSON — fall through to the strip. + } + // Still ungrounded after the retry: strip the briefing rather than ship a + // fabricated figure. The structured recommendations/citations stay. + if (ungrounded.length > 0 && insights && typeof insights === "object") { + (insights as Record).dailyBriefing = null; + annotate({ + action: { name: "insights.generate.briefing_grounding_stripped" }, + meta: { ungroundedCount: ungrounded.length }, + }); } } - // Still ungrounded after the retry: strip the briefing rather than ship a - // fabricated figure. The structured recommendations/citations stay. - if (ungrounded.length > 0 && insights && typeof insights === "object") { - (insights as Record).dailyBriefing = null; - annotate({ - action: { name: "insights.generate.briefing_grounding_stripped" }, - meta: { ungroundedCount: ungrounded.length }, - }); - } - } - await prisma.user.update({ - where: { id: userId }, - data: { - insightsCachedAt: new Date(), - insightsCachedText: JSON.stringify(insights), - // v1.16.8 — store the snapshot fingerprint so the nightly / forced - // regeneration paths can detect "nothing changed" and skip the - // provider call. The user-initiated POST itself stays un-gated: an - // explicit regenerate request is honoured even on unchanged data - // (it is already bounded by the hourly rate limit above). The - // composite shape MUST match the gate in `comprehensive-generate.ts`, - // or every off-request warm after a manual regenerate re-pays a - // full generation on unchanged data. Beyond the features, three - // prompt inputs that can change with NO data change join in: - // - aboutMe (Coach remember / Settings → AI edit), - // - comparisonBaseline (the comparison toggle adds/removes the - // prior-period context block; `null` snapshot means "none"), - // - generationLocale (the language the cached text renders in — - // hashing it makes a locale switch regenerate exactly once, so - // the briefing follows the reader; named `generationLocale` - // because the canonicaliser strips `locale` keys as volatile). - insightsSnapshotHash: hashInsightSnapshot({ - features: compactFeatures, - aboutMe: aboutMe ?? null, - comparisonBaseline: comparisonSnapshot?.baseline ?? "none", - generationLocale: locale, - }), - }, - }); + await prisma.user.update({ + where: { id: userId }, + data: { + insightsCachedAt: new Date(), + insightsCachedText: JSON.stringify(insights), + // v1.16.8 — store the snapshot fingerprint so the nightly / forced + // regeneration paths can detect "nothing changed" and skip the + // provider call. The user-initiated POST itself stays un-gated: an + // explicit regenerate request is honoured even on unchanged data + // (it is already bounded by the hourly rate limit above). The + // composite shape MUST match the gate in `comprehensive-generate.ts`, + // or every off-request warm after a manual regenerate re-pays a + // full generation on unchanged data. Beyond the features, three + // prompt inputs that can change with NO data change join in: + // - aboutMe (Coach remember / Settings → AI edit), + // - comparisonBaseline (the comparison toggle adds/removes the + // prior-period context block; `null` snapshot means "none"), + // - generationLocale (the language the cached text renders in — + // hashing it makes a locale switch regenerate exactly once, so + // the briefing follows the reader; named `generationLocale` + // because the canonicaliser strips `locale` keys as volatile). + insightsSnapshotHash: hashInsightSnapshot({ + features: compactFeatures, + aboutMe: aboutMe ?? null, + comparisonBaseline: comparisonSnapshot?.baseline ?? "none", + generationLocale: locale, + }), + }, + }); - // v1.16.8 — no blanket per-status eviction here any more (nuking ~45 - // cache rows per manual regenerate deleted every hash baseline and was - // the source of the post-regenerate card-regeneration storm). Instead, - // the manual regenerate takes the cards along through the hash-gated - // queue: every scope the user has data for is enqueued, the worker - // forces each generator past its same-day cache read, and the content- - // hash gate regenerates exactly the cards whose data changed while - // refreshing the unchanged ones for free. This is the user's escape - // hatch for a card they can SEE is stale — without it the cards had no - // refresh path until the next nightly warm. - const refillScopes = await enqueueStatusRefillForUser( - userId, - locale === "de" ? "de" : "en", - ); - annotate({ - action: { name: "insights.generate.cards_refill" }, - meta: { scopes: refillScopes }, - }); + // v1.16.8 — no blanket per-status eviction here any more (nuking ~45 + // cache rows per manual regenerate deleted every hash baseline and was + // the source of the post-regenerate card-regeneration storm). Instead, + // the manual regenerate takes the cards along through the hash-gated + // queue: every scope the user has data for is enqueued, the worker + // forces each generator past its same-day cache read, and the content- + // hash gate regenerates exactly the cards whose data changed while + // refreshing the unchanged ones for free. This is the user's escape + // hatch for a card they can SEE is stale — without it the cards had no + // refresh path until the next nightly warm. + const refillScopes = await enqueueStatusRefillForUser( + userId, + locale === "de" ? "de" : "en", + ); + annotate({ + action: { name: "insights.generate.cards_refill" }, + meta: { scopes: refillScopes }, + }); - // v1.7.0 W6 — the dashboard snapshot embeds the pre-generated daily - // briefing read-only; drop it so the next snapshot carries the fresh - // briefing instead of the stale one. - invalidateUserInsights(userId); + // v1.7.0 W6 — the dashboard snapshot embeds the pre-generated daily + // briefing read-only; drop it so the next snapshot carries the fresh + // briefing instead of the stale one. + invalidateUserInsights(userId); - await auditLog("insights.generate", { - userId, - ipAddress: getClientIp(request), - details: { - privacyMode: dbUser?.insightsPrivacyMode, - tokensUsed: result.tokensUsed, - providerType: result.providerType, - chainProviderType: workingProviderType, - fallbackHopCount, - model: result.model, - }, - }); + await auditLog("insights.generate", { + userId, + ipAddress: getClientIp(request), + details: { + privacyMode: dbUser?.insightsPrivacyMode, + tokensUsed: result.tokensUsed, + providerType: result.providerType, + chainProviderType: workingProviderType, + fallbackHopCount, + model: result.model, + }, + }); - annotate({ - action: { name: "insights.generate" }, - meta: { - cached: false, - providerType: result.providerType, - chainProviderType: workingProviderType, - fallbackHopCount, - model: result.model, - tokensUsed: result.tokensUsed, - }, - }); + annotate({ + action: { name: "insights.generate" }, + meta: { + cached: false, + providerType: result.providerType, + chainProviderType: workingProviderType, + fallbackHopCount, + model: result.model, + tokensUsed: result.tokensUsed, + }, + }); - return apiSuccess({ insights, cached: false, legacyPayload: false }); -}); + return apiSuccess({ insights, cached: false, legacyPayload: false }); + }), +); diff --git a/src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts b/src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts new file mode 100644 index 000000000..09f6ceaad --- /dev/null +++ b/src/app/api/measurements/batch/__tests__/hourly-hr-wire.test.ts @@ -0,0 +1,242 @@ +/** + * v1.19.0 (iOS #34) — go-forward aggregated heart-rate wire contract on + * `POST /api/measurements/batch`. + * + * Asserts: + * - a fresh hourly HR bucket inserts; + * - a re-post of the same hour OVERWRITES (status `updated`, not a + * duplicate row); + * - a malformed hourly-HR bucket externalId is `skipped`; + * - the per-sample uuid HR path is unaffected (immutable duplicate); + * - the existing per-day step `stats:` overwrite path is unaffected. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + findMany: vi.fn(), + createMany: vi.fn(), + updateMany: vi.fn(), + }, + $transaction: vi.fn(async (fn: unknown) => { + if (typeof fn === "function") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (fn as any)(prisma as unknown as { measurement: unknown }); + } + }), + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); +vi.mock("@/lib/jobs/pr-detection", () => ({ + enqueuePrDetection: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/jobs/reminder-satisfy", () => ({ + enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMeasurements: vi.fn(), +})); +vi.mock("@/lib/insights/comprehensive-generate", () => ({ + invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), + collapseToTypeDayKeys: vi.fn(() => []), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +const HR_BUCKET_ID = + "stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:00:00.000Z"; + +function makeRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/measurements/batch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function hrBucketEntry(externalId: string, value: number) { + return { + hkIdentifier: "HKQuantityTypeIdentifierHeartRate", + value, + unit: "count/min", + startDate: "2026-06-21T14:00:00.000Z", + endDate: "2026-06-21T14:59:59.000Z", + externalId, + }; +} + +async function readJson(res: Response) { + return (await res.json()) as { + data: { + processed: number; + inserted: number; + updated: number; + duplicates: number; + skipped: { index: number; reason: string }[]; + entries: { index: number; status: string; reason?: string }[]; + }; + }; +} + +beforeEach(() => { + // `clearAllMocks` (not `resetAllMocks`) so the factory `$transaction` + // pass-through implementation survives — the overwrite path sets its + // per-entry status INSIDE the transaction, and a wiped impl would leave + // the result slot undefined and 500 the route. + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 60, + resetAt: Date.now() + 60_000, + }); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([]); + vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 0 }); + vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 1 }); +}); + +describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", () => { + it("inserts a fresh hourly HR bucket as a PULSE row", async () => { + vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 }); + const res = await POST( + makeRequest({ entries: [hrBucketEntry(HR_BUCKET_ID, 72)] }), + ); + expect(res.status).toBe(200); + const { data } = await readJson(res); + expect(data.inserted).toBe(1); + expect(data.updated).toBe(0); + expect(data.entries[0].status).toBe("inserted"); + + // Stored as a PULSE row carrying the hourly average value. + const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0]; + const rows = (createArg as { data: { type: string; value: number }[] }) + .data; + expect(rows[0].type).toBe("PULSE"); + expect(rows[0].value).toBe(72); + }); + + it("overwrites the same hour bucket on a re-post (updated, not duplicate)", async () => { + // The bucket already exists from an earlier within-hour post. + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { + type: "PULSE", + source: "APPLE_HEALTH", + externalId: HR_BUCKET_ID, + }, + ] as never); + + const res = await POST( + makeRequest({ entries: [hrBucketEntry(HR_BUCKET_ID, 78)] }), + ); + expect(res.status).toBe(200); + const { data } = await readJson(res); + expect(data.updated).toBe(1); + expect(data.inserted).toBe(0); + expect(data.duplicates).toBe(0); + expect(data.entries[0].status).toBe("updated"); + + // The overwrite went through updateMany with the new hourly average. + expect(prisma.measurement.updateMany).toHaveBeenCalledTimes(1); + const updateArg = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0]; + expect((updateArg as { data: { value: number } }).data.value).toBe(78); + expect(prisma.measurement.createMany).not.toHaveBeenCalled(); + }); + + it("skips a malformed hourly HR bucket externalId", async () => { + const res = await POST( + makeRequest({ + entries: [ + hrBucketEntry( + "stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:30:00.000Z", + 70, + ), + ], + }), + ); + expect(res.status).toBe(200); + const { data } = await readJson(res); + expect(data.inserted).toBe(0); + expect(data.skipped).toEqual([ + { index: 0, reason: "malformed_hr_bucket_id" }, + ]); + expect(prisma.measurement.createMany).not.toHaveBeenCalled(); + }); + + it("keeps the per-sample uuid HR path immutable (duplicate, not overwrite)", async () => { + const SAMPLE_ID = "B3A1C0DE-0000-4000-8000-000000000000"; + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "PULSE", source: "APPLE_HEALTH", externalId: SAMPLE_ID }, + ] as never); + + const res = await POST( + makeRequest({ entries: [hrBucketEntry(SAMPLE_ID, 99)] }), + ); + expect(res.status).toBe(200); + const { data } = await readJson(res); + expect(data.duplicates).toBe(1); + expect(data.updated).toBe(0); + expect(data.entries[0].status).toBe("duplicate"); + expect(prisma.measurement.updateMany).not.toHaveBeenCalled(); + }); + + it("leaves the per-day step stats overwrite path unaffected", async () => { + const STEP_STATS_ID = "stats:HKQuantityTypeIdentifierStepCount:2026-06-21"; + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { + type: "ACTIVITY_STEPS", + source: "APPLE_HEALTH", + externalId: STEP_STATS_ID, + }, + ] as never); + + const res = await POST( + makeRequest({ + entries: [ + { + hkIdentifier: "HKQuantityTypeIdentifierStepCount", + value: 8421, + unit: "count", + startDate: "2026-06-21T00:00:00.000Z", + endDate: "2026-06-21T23:59:59.000Z", + externalId: STEP_STATS_ID, + }, + ], + }), + ); + expect(res.status).toBe(200); + const { data } = await readJson(res); + expect(data.updated).toBe(1); + expect(data.entries[0].status).toBe("updated"); + }); +}); diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 4cfb40d7d..d56a1d236 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -18,13 +18,20 @@ * HealthKit sample, surface as `duplicate` per entry instead of * hard-failing the batch. * - v1.5.0 issue #213 — entries whose `externalId` starts with - * `stats:` are per-day cumulative totals (Steps, Active Energy, - * Sleep Duration, Walking/Running Distance, Flights Climbed). The - * iOS HealthKit observer re-posts those throughout the day as the - * running total changes. They surface as `updated` (the row's - * value is overwritten) rather than `duplicate` (the new value - * dropped). Sample-class externalIds keep the strict immutable - * `duplicate` contract. + * `stats:` are aggregate rows the iOS HealthKit observer re-posts as + * the underlying window fills. Two granularities ride the same + * overwrite mechanism: + * · per-day cumulative totals — `stats::` (Steps, + * Active Energy, Sleep Duration, Walking/Running Distance, + * Flights Climbed); + * · v1.19.0 (iOS #34) hourly heart-rate buckets — + * `stats:HKQuantityTypeIdentifierHeartRate:` + * carrying the hour's AVERAGE bpm as one PULSE row, so iOS stops + * uploading one row per raw HR sample. + * Both surface as `updated` (the row's value is overwritten) rather + * than `duplicate` (the new value dropped). Sample-class externalIds + * (every other prefix) keep the strict immutable `duplicate` + * contract. */ import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -40,7 +47,11 @@ import { safeJson, } from "@/lib/api-response"; import { withIdempotency } from "@/lib/idempotency"; -import { mapAppleHealthEntry } from "@/lib/measurements/apple-health-mapping"; +import { + mapAppleHealthEntry, + isHourlyHeartRateStatsExternalId, + targetsHourlyHeartRateBucket, +} from "@/lib/measurements/apple-health-mapping"; import { MEASURED_AT_TOLERANCE_MS, isMergeableSource, @@ -268,6 +279,28 @@ async function postBatch(request: NextRequest): Promise { continue; } + // v1.19.0 (iOS #34) — go-forward aggregated heart-rate wire + // contract. A `stats:HKQuantityTypeIdentifierHeartRate:` row + // is the hourly-average PULSE bucket; it rides the generic `stats:` + // overwrite path below so a within-hour re-post (the running mean + // shifts) replaces the value instead of duplicating. Reject a row + // that targets the HR-bucket prefix but carries a malformed hour + // suffix — a garbage suffix would mint an un-overwriteable row that + // the next hour's re-post can't collapse onto. A well-formed bucket + // (and every non-HR `stats:` / per-sample `uuid` externalId) passes + // through unchanged. + if ( + targetsHourlyHeartRateBucket(entry.externalId) && + !isHourlyHeartRateStatsExternalId(entry.externalId) + ) { + results[index] = { + index, + status: "skipped", + reason: "malformed_hr_bucket_id", + }; + continue; + } + prepared.push({ index, entry, diff --git a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts index a0b0ea611..78f4f33ed 100644 --- a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts @@ -122,6 +122,51 @@ describe("GET /api/medications/[id]/inventory", () => { expect(body.data.items).toHaveLength(2); expect(body.data.meta.total).toBe(2); }); + + // v1.19.0 (iOS#25) — the GET response carries a server-computed supply + // summary so the detail-page clients render the canonical Bestand + // instead of re-deriving it. ACTIVE / IN_USE with units pool in; + // EXPIRED surfaces separately and never counts as available. + it("returns a server-computed supply summary (dose-derived, expired separate)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + userId: "user-1", + unitsPerDose: 2, + } as never); + vi.mocked(prisma.medicationInventoryItem.findMany).mockResolvedValue([ + { id: "inv-1", state: "IN_USE", unitsTotal: 4, unitsRemaining: 3 }, + { id: "inv-2", state: "ACTIVE", unitsTotal: 4, unitsRemaining: 4 }, + { id: "inv-3", state: "EXPIRED", unitsTotal: 4, unitsRemaining: 4 }, + { id: "inv-4", state: "USED_UP", unitsTotal: 4, unitsRemaining: 0 }, + ] as never); + + const res = await GET( + new NextRequest("http://localhost/api/medications/med-1/inventory"), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + summary: { + unitsRemaining: number; + unitsTotal: number; + dosesRemaining: number; + dosesTotal: number; + expiredUnits: number; + }; + }; + }; + // 7 available units / 2 per dose = 3 doses; capacity 8 units = 4 doses; + // 4 units sit in the expired container (visible, never available). + expect(body.data.summary).toEqual({ + unitsRemaining: 7, + unitsTotal: 8, + dosesRemaining: 3, + dosesTotal: 4, + expiredUnits: 4, + }); + }); }); describe("POST /api/medications/[id]/inventory", () => { diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts index 68978f3c1..a5ce6d0f2 100644 --- a/src/app/api/medications/[id]/inventory/route.ts +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -30,6 +30,7 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { createInventoryItemSchema } from "@/lib/validations/medication"; import { buildCreateInventoryInput, + buildSupplySummary, serializeInventoryItem, } from "@/lib/medications/inventory/service"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; @@ -48,10 +49,27 @@ export const GET = apiHandler( const guard = await assertMedicationOwnership(id, user.id); if (guard) return guard; - const items = await prisma.medicationInventoryItem.findMany({ - where: { userId: user.id, medicationId: id }, - orderBy: [{ state: "asc" }, { expiresAt: "asc" }, { createdAt: "asc" }], - }); + const [items, medication] = await Promise.all([ + prisma.medicationInventoryItem.findMany({ + where: { userId: user.id, medicationId: id }, + orderBy: [{ state: "asc" }, { expiresAt: "asc" }, { createdAt: "asc" }], + }), + prisma.medication.findUnique({ + where: { id }, + select: { unitsPerDose: true }, + }), + ]); + + const serialized = items.map(serializeInventoryItem); + // v1.19.0 (iOS#25) — the canonical supply summary is computed HERE, + // server-side, through the one source of truth (`summariseSupply`). + // The detail-page client renders these ready figures instead of + // re-deriving them in the browser, so web and iOS read identical + // numbers from the same DTO. + const summary = buildSupplySummary( + serialized, + medication?.unitsPerDose ? Number(medication.unitsPerDose) : 1, + ); annotate({ action: { name: "medication.inventory.list" }, @@ -59,7 +77,8 @@ export const GET = apiHandler( }); return apiSuccess({ - items: items.map(serializeInventoryItem), + items: serialized, + summary, meta: { total: items.length }, }); }, diff --git a/src/app/api/sleep/rhythm/__tests__/route.test.ts b/src/app/api/sleep/rhythm/__tests__/route.test.ts index a817425b4..f8e3ee7b5 100644 --- a/src/app/api/sleep/rhythm/__tests__/route.test.ts +++ b/src/app/api/sleep/rhythm/__tests__/route.test.ts @@ -132,10 +132,12 @@ describe("GET /api/sleep/rhythm", () => { const res = await callGet(req()); const body = await res.json(); expect(body.data.sleepDebt.state).toBe("ready"); - expect(body.data.sleepDebt.nightsCounted).toBe(10); + // v1.19.0 — the rolling balance counts the most recent 5-night window. + expect(body.data.sleepDebt.nightsCounted).toBe(5); expect(body.data.sleepDebt.nightsUntilReady).toBe(0); - // 10 nights × 60-min deficit = 600 min cumulative debt. - expect(body.data.sleepDebt.debtMinutes).toBe(600); + // 5-night window × 60-min nightly deficit (all short, no catch-up) = 300 min + // running balance. + expect(body.data.sleepDebt.debtMinutes).toBe(300); expect(body.data.sleepDebt.needMinutes).toBe(420); }); }); diff --git a/src/app/api/telegram/webhook/__tests__/route.test.ts b/src/app/api/telegram/webhook/__tests__/route.test.ts index 301322e33..634857667 100644 --- a/src/app/api/telegram/webhook/__tests__/route.test.ts +++ b/src/app/api/telegram/webhook/__tests__/route.test.ts @@ -28,10 +28,36 @@ vi.mock("@/lib/db", () => ({ telegramScheduledDeletion: { createMany: vi.fn(), }, + telegramPromptContext: { + create: vi.fn(), + findUnique: vi.fn(), + delete: vi.fn(), + }, + moodReminderDispatch: { + deleteMany: vi.fn(), + }, + measurementReminder: { + findFirst: vi.fn(), + update: vi.fn(), + }, $transaction: vi.fn(), }, })); +vi.mock("@/lib/mood/create-from-telegram", () => ({ + logTelegramMood: vi.fn(), + attachTelegramMoodNote: vi.fn(), +})); + +vi.mock("@/lib/telegram-cleanup", () => ({ + scheduleTelegramAutoDelete: vi.fn().mockResolvedValue(undefined), + TELEGRAM_AUTO_DELETE_DELAY_MS: 30 * 60 * 1000, +})); + +vi.mock("@/lib/measurement-reminders/satisfy", () => ({ + satisfyReminder: vi.fn().mockResolvedValue({ satisfied: true }), +})); + vi.mock("@/lib/crypto", () => ({ decrypt: vi.fn((value: string) => `decrypted:${value}`), })); @@ -77,6 +103,12 @@ import { sendTelegramMessage, } from "@/lib/telegram"; import { checkRateLimit } from "@/lib/rate-limit"; +import { + logTelegramMood, + attachTelegramMoodNote, +} from "@/lib/mood/create-from-telegram"; +import { scheduleTelegramAutoDelete } from "@/lib/telegram-cleanup"; +import { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; const ORIGINAL_SECRET = process.env.TELEGRAM_WEBHOOK_SECRET; @@ -155,6 +187,23 @@ beforeEach(() => { messageId: 999, ok: true, } as never); + // v1.19.0 interactive mood + measurement defaults. + vi.mocked(logTelegramMood).mockResolvedValue({ + created: true, + moodEntryId: "mood-1", + score: 4, + } as never); + vi.mocked(attachTelegramMoodNote).mockResolvedValue(true as never); + vi.mocked(scheduleTelegramAutoDelete).mockResolvedValue(undefined as never); + vi.mocked(satisfyReminder).mockResolvedValue({ satisfied: true } as never); + vi.mocked(prisma.telegramPromptContext.create).mockResolvedValue({} as never); + vi.mocked(prisma.telegramPromptContext.findUnique).mockResolvedValue(null); + vi.mocked(prisma.telegramPromptContext.delete).mockResolvedValue({} as never); + vi.mocked(prisma.moodReminderDispatch.deleteMany).mockResolvedValue({ + count: 0, + } as never); + vi.mocked(prisma.measurementReminder.findFirst).mockResolvedValue(null); + vi.mocked(prisma.measurementReminder.update).mockResolvedValue({} as never); }); afterEach(() => { @@ -834,3 +883,177 @@ describe("Telegram webhook — GET verification", () => { expect(res.status).toBe(401); }); }); + +describe("Telegram webhook — interactive mood (v1.19.0)", () => { + it("logs a MoodEntry on a 1–5 tap, scoped to the linked chat's user", async () => { + const res = await POST(tgRequest(callbackUpdate("mood:4"))); + expect(res.status).toBe(200); + expect(logTelegramMood).toHaveBeenCalledTimes(1); + const arg = vi.mocked(logTelegramMood).mock.calls[0][0]; + // userId resolved from the linked chat (TG_USER), never the payload. + expect(arg.userId).toBe("user-1"); + expect(arg.score).toBe(4); + // Stable per-tap idempotency id keyed on chat + message + score. + expect(arg.externalId).toContain("telegram:mood:7777:555:4"); + // Calm callback toast, prompt deleted (no chat echo of the score). + expect(answerTelegramCallbackQuery).toHaveBeenCalled(); + expect(deleteMessage).toHaveBeenCalledWith( + "decrypted:ENC(token-blob)", + "7777", + 555, + ); + }); + + it("rejects an out-of-range mood score without writing", async () => { + const res = await POST(tgRequest(callbackUpdate("mood:9"))); + expect(res.status).toBe(200); + expect(logTelegramMood).not.toHaveBeenCalled(); + }); + + it("rejects a mood tap from an unlinked / foreign chat", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValueOnce(null); + const res = await POST( + tgRequest( + callbackUpdate("mood:5", { + from: { id: 4242 }, + message: { message_id: 1, chat: { id: 4242 } }, + }), + ), + ); + expect(res.status).toBe(200); + expect(logTelegramMood).not.toHaveBeenCalled(); + expect(answerTelegramCallbackQuery).not.toHaveBeenCalled(); + }); + + it("offers a force_reply note prompt and records its prompt context", async () => { + vi.mocked(sendTelegramMessage).mockResolvedValueOnce({ + messageId: 4321, + ok: true, + } as never); + await POST(tgRequest(callbackUpdate("mood:3"))); + const sendArgs = vi.mocked(sendTelegramMessage).mock.calls[0]; + // force_reply markup opens the user's reply box pre-targeted at the bot. + expect(sendArgs[3]).toMatchObject({ + replyMarkup: { force_reply: true }, + }); + expect(prisma.telegramPromptContext.create).toHaveBeenCalledTimes(1); + const ctx = vi.mocked(prisma.telegramPromptContext.create).mock.calls[0][0]; + expect(ctx.data).toMatchObject({ + userId: "user-1", + chatId: "7777", + promptMsgId: 4321, + kind: "mood_note", + refId: "mood-1", + }); + // The note prompt is scheduled to self-clean (~30 min). + expect(scheduleTelegramAutoDelete).toHaveBeenCalledWith( + "user-1", + "7777", + [4321], + ); + }); + + it("attaches a free-text reply to the just-logged mood entry", async () => { + vi.mocked(prisma.telegramPromptContext.findUnique).mockResolvedValueOnce({ + id: "ctx-1", + userId: "user-1", + kind: "mood_note", + refId: "mood-1", + expiresAt: new Date(Date.now() + 60_000), + } as never); + const res = await POST( + tgRequest({ + update_id: 900, + message: { + message_id: 60, + text: "Slept badly", + chat: { id: 7777 }, + reply_to_message: { message_id: 4321 }, + }, + }), + ); + expect(res.status).toBe(200); + expect(attachTelegramMoodNote).toHaveBeenCalledWith({ + userId: "user-1", + moodEntryId: "mood-1", + note: "Slept badly", + }); + // The prompt context row is consumed so a reply can't be replayed. + expect(prisma.telegramPromptContext.delete).toHaveBeenCalledWith({ + where: { id: "ctx-1" }, + }); + // Note, prompt, and confirmation are scheduled to self-clean. + expect(scheduleTelegramAutoDelete).toHaveBeenCalled(); + }); + + it("does not attach a note when the reply context belongs to another user", async () => { + vi.mocked(prisma.telegramPromptContext.findUnique).mockResolvedValueOnce({ + id: "ctx-2", + userId: "someone-else", + kind: "mood_note", + refId: "mood-99", + expiresAt: new Date(Date.now() + 60_000), + } as never); + const res = await POST( + tgRequest({ + update_id: 901, + message: { + message_id: 61, + text: "leak attempt", + chat: { id: 7777 }, + reply_to_message: { message_id: 4321 }, + }, + }), + ); + expect(res.status).toBe(200); + expect(attachTelegramMoodNote).not.toHaveBeenCalled(); + }); + + it("mood_later clears today's dispatch ledger and deletes the prompt", async () => { + const res = await POST(tgRequest(callbackUpdate("mood_later:120"))); + expect(res.status).toBe(200); + expect(prisma.moodReminderDispatch.deleteMany).toHaveBeenCalledTimes(1); + expect(deleteMessage).toHaveBeenCalled(); + expect(logTelegramMood).not.toHaveBeenCalled(); + }); +}); + +describe("Telegram webhook — interactive measurement reminder (v1.19.0)", () => { + it("measure_done satisfies the reminder via the shared primitive", async () => { + vi.mocked(prisma.measurementReminder.findFirst).mockResolvedValueOnce({ + id: "rem-1", + intervalDays: 7, + rrule: null, + anchorDate: null, + notifyHour: 9, + lastSatisfiedAt: null, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + } as never); + const res = await POST(tgRequest(callbackUpdate("measure_done:rem-1"))); + expect(res.status).toBe(200); + expect(satisfyReminder).toHaveBeenCalledTimes(1); + expect(deleteMessage).toHaveBeenCalled(); + }); + + it("measure_done on a foreign reminder is a no-op", async () => { + vi.mocked(prisma.measurementReminder.findFirst).mockResolvedValueOnce(null); + const res = await POST(tgRequest(callbackUpdate("measure_done:rem-x"))); + expect(res.status).toBe(200); + expect(satisfyReminder).not.toHaveBeenCalled(); + }); + + it("measure_later pushes nextDueAt forward (one-shot snooze)", async () => { + vi.mocked(prisma.measurementReminder.findFirst).mockResolvedValueOnce({ + id: "rem-2", + } as never); + const res = await POST( + tgRequest(callbackUpdate("measure_later:rem-2:180")), + ); + expect(res.status).toBe(200); + expect(prisma.measurementReminder.update).toHaveBeenCalledTimes(1); + const arg = vi.mocked(prisma.measurementReminder.update).mock.calls[0][0]; + expect(arg.where).toEqual({ id: "rem-2" }); + expect(arg.data.nextDueAt).toBeInstanceOf(Date); + expect(deleteMessage).toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/telegram/webhook/route.ts b/src/app/api/telegram/webhook/route.ts index 9d480846d..5c25ed80e 100644 --- a/src/app/api/telegram/webhook/route.ts +++ b/src/app/api/telegram/webhook/route.ts @@ -27,6 +27,12 @@ import { import { localHmAsUtc } from "@/lib/tz/local-day"; import { wallClockInTz } from "@/lib/tz/wall-clock"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; +import { + logTelegramMood, + attachTelegramMoodNote, +} from "@/lib/mood/create-from-telegram"; +import { scheduleTelegramAutoDelete } from "@/lib/telegram-cleanup"; +import { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; interface TelegramUpdate { update_id: number; @@ -34,6 +40,9 @@ interface TelegramUpdate { message_id?: number; text?: string; chat?: { id: number | string }; + reply_to_message?: { + message_id?: number; + }; }; callback_query?: { id: string; @@ -96,26 +105,15 @@ async function cleanupReminderTracking(medicationId: string): Promise { } } -const AUTO_DELETE_DELAY_MS = 60 * 60 * 1000; // 1 hour - +// v1.19.0 — the auto-delete window dropped from 1 h to ~30 min and moved to +// the shared `scheduleTelegramAutoDelete` helper so the inbound (free-text) +// and outbound (unanswered prompt) paths agree on the window. async function scheduleAutoDelete( userId: string, chatId: string, messageIds: number[], ): Promise { - const deleteAfter = new Date(Date.now() + AUTO_DELETE_DELAY_MS); - try { - await prisma.telegramScheduledDeletion.createMany({ - data: messageIds.map((messageId) => ({ - userId, - chatId, - messageId, - deleteAfter, - })), - }); - } catch { - // Best-effort - } + await scheduleTelegramAutoDelete(userId, chatId, messageIds); } async function findTelegramUser(chatId: string) { @@ -677,6 +675,183 @@ async function handleCallback(update: TelegramUpdate) { callback.id, t("telegram.cancelled"), ); + } else if (data.startsWith("mood:")) { + // v1.19.0 — log a mood entry 1–5 from a MOOD_REMINDER prompt tap. + // The score is the ONLY accepted value; the userId is resolved from + // the linked chat above, never from the payload. Confirmation is a + // calm callback toast — no chat echo (restraint). + const score = parseInt(data.slice("mood:".length).trim(), 10); + if (!Number.isInteger(score) || score < 1 || score > 5) { + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.errorInvalidAction"), + ); + return; + } + + const msgId = messageId ?? "na"; + // Stable per-tap dedup id (Telegram redelivers non-200 updates). The + // mood-create helper upserts on `(userId, source, externalId)`. + const externalId = `telegram:mood:${chatId}:${msgId}:${score}`.slice( + 0, + 120, + ); + + const moodResult = await logTelegramMood({ + userId: user.id, + score, + tz: user.timezone, + externalId, + }); + + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.moodLogged", { score: String(score) }), + ); + // Delete the prompt immediately on a tap; the unanswered-prompt + // scheduled deletion (written at send time) is then a harmless no-op. + if (messageId) { + await deleteMessage(botToken, chatId, messageId); + } + + // Offer an optional note via force_reply. The reply (when it arrives) + // is correlated back to this mood entry through TelegramPromptContext. + const notePrompt = await sendTelegramMessage( + botToken, + chatId, + t("telegram.moodNotePrompt"), + { + replyMarkup: { + force_reply: true, + input_field_placeholder: t("telegram.buttonMoodNote"), + }, + }, + ); + if (notePrompt.ok && notePrompt.messageId) { + try { + await prisma.telegramPromptContext.create({ + data: { + userId: user.id, + chatId, + promptMsgId: notePrompt.messageId, + kind: "mood_note", + refId: moodResult.moodEntryId, + // Force-reply context lifespan: the same ~30-min window as the + // deletion sweep. A reply after this is treated as expired. + expiresAt: new Date(Date.now() + 30 * 60 * 1000), + }, + }); + } catch { + // Best-effort — without the context row the note simply can't bind. + } + // The note prompt itself self-cleans on the ~30-min sweep whether or + // not the user replies. + await scheduleAutoDelete(user.id, chatId, [notePrompt.messageId]); + } + } else if (data.startsWith("mood_later")) { + // v1.19.0 — restrained "remind me later" for the mood nudge. Clears + // today's dispatch ledger so the next cron tick (inside the user's + // reminder-hour window) re-fires the prompt, mirroring how the + // medication snooze lets the tick re-remind. The prompt is deleted now. + const tz = user.timezone || DEFAULT_TIMEZONE; + const parts = wallClockInTz(new Date(), tz); + const localDate = `${parts.year.toString().padStart(4, "0")}-${parts.month + .toString() + .padStart(2, "0")}-${parts.day.toString().padStart(2, "0")}`; + try { + await prisma.moodReminderDispatch.deleteMany({ + where: { userId: user.id, date: localDate }, + }); + } catch { + // Best-effort — a failure just means the day's nudge isn't re-armed. + } + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.remindLater", { duration: t("telegram.snoozeOneHour") }), + ); + if (messageId) { + await deleteMessage(botToken, chatId, messageId); + } + } else if (data.startsWith("measure_done:")) { + // v1.19.0 — mark a Vorsorge/measurement reminder done. No value + // capture (restraint) — satisfy the cadence via the shared primitive. + const reminderId = data.slice("measure_done:".length).trim(); + const reminder = await prisma.measurementReminder.findFirst({ + where: { id: reminderId, userId: user.id, deletedAt: null }, + select: { + id: true, + intervalDays: true, + rrule: true, + anchorDate: true, + notifyHour: true, + lastSatisfiedAt: true, + createdAt: true, + }, + }); + if (!reminder) { + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.measurementNotFound"), + ); + return; + } + const tz = user.timezone || DEFAULT_TIMEZONE; + await satisfyReminder(prisma, reminder, tz, new Date()); + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.measurementDone"), + ); + if (messageId) { + await deleteMessage(botToken, chatId, messageId); + } + } else if (data.startsWith("measure_later:")) { + // v1.19.0 — postpone a measurement reminder by N minutes (one-shot + // nudge). Pushes `nextDueAt` forward; the cron re-fires once due AND in + // the user's notify-hour window. Mirrors the medication snooze. + const parts = data.split(":"); + const reminderId = parts[1]; + const minutes = parseInt(parts[2] ?? "180", 10); + if (!reminderId || !Number.isFinite(minutes)) { + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.errorInvalidAction"), + ); + return; + } + const reminder = await prisma.measurementReminder.findFirst({ + where: { id: reminderId, userId: user.id, deletedAt: null }, + select: { id: true }, + }); + if (!reminder) { + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.measurementNotFound"), + ); + return; + } + await prisma.measurementReminder.update({ + where: { id: reminder.id }, + data: { nextDueAt: new Date(Date.now() + minutes * 60000) }, + }); + const duration = + minutes <= 60 + ? t("telegram.snoozeOneHour") + : t("telegram.snoozeThreeHours"); + await answerTelegramCallbackQuery( + botToken, + callback.id, + t("telegram.remindLater", { duration }), + ); + if (messageId) { + await deleteMessage(botToken, chatId, messageId); + } } else { await answerTelegramCallbackQuery( botToken, @@ -698,6 +873,69 @@ async function handleTextMessage(update: TelegramUpdate) { const locale = resolveBotLocale(user.locale); const { t } = getServerTranslator(locale); + // v1.19.0 — force-reply note capture. When this message replies to a + // tracked mood-note prompt, attach the text to the just-logged mood entry + // and clean up both messages. The context row is keyed on + // `(chatId, promptMsgId)` and scoped to THIS user, so a reply can only + // ever attach to that user's own entry. + const replyToId = message?.reply_to_message?.message_id; + if (replyToId !== undefined) { + const context = await prisma.telegramPromptContext.findUnique({ + where: { + chatId_promptMsgId: { chatId, promptMsgId: replyToId }, + }, + select: { + id: true, + userId: true, + kind: true, + refId: true, + expiresAt: true, + }, + }); + if (context && context.userId === user.id && context.kind === "mood_note") { + // Consume the context row regardless of outcome so a reply can't be + // replayed onto the entry twice. + await prisma.telegramPromptContext + .delete({ where: { id: context.id } }) + .catch(() => {}); + const userMsgId = message?.message_id; + if (context.expiresAt.getTime() < Date.now()) { + const resp = await sendTelegramMessage( + botToken, + chatId, + t("telegram.moodNoteExpired"), + ); + const toDelete = [userMsgId, replyToId, resp.messageId].filter( + (id): id is number => id != null, + ); + if (toDelete.length > 0) { + await scheduleAutoDelete(user.id, chatId, toDelete); + } + return; + } + const attached = await attachTelegramMoodNote({ + userId: user.id, + moodEntryId: context.refId, + note: text, + }); + const resp = await sendTelegramMessage( + botToken, + chatId, + attached + ? t("telegram.moodNoteSaved") + : t("telegram.errorInvalidAction"), + ); + // Self-clean the note, the prompt, and the confirmation in ~30 min. + const toDelete = [userMsgId, replyToId, resp.messageId].filter( + (id): id is number => id != null, + ); + if (toDelete.length > 0) { + await scheduleAutoDelete(user.id, chatId, toDelete); + } + return; + } + } + // "Help" / start: accept English + German keyword aliases independent of locale if ( /^\/help\b/i.test(text) || diff --git a/src/app/coach/page.tsx b/src/app/coach/page.tsx index bc02ea74a..0274097d2 100644 --- a/src/app/coach/page.tsx +++ b/src/app/coach/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; +import { Suspense, useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; @@ -31,7 +31,46 @@ import { useDisableCoach } from "@/hooks/use-disable-coach"; * drawer" minimize control is removed (pointless on the dedicated page — * the separate bottom-right FAB drawer is the compact surface). The chat * claims the full content width and the viewport height below the top bar. + * + * v1.18.11 (W11) — ChatGPT-style IA: the page drops its own header bar + * AND the top conversation-history strip entirely. The composer is the + * single control hub (leading `+` actions menu = new chat + open + * conversations, settings deep-link, mic, send); conversation history is a + * left slide-in drawer opened from that menu. The composer keeps one + * constant, centred, max-width-capped column across the new-chat hero and + * the active conversation. All of that lives in ``'s + * page branch, so the page stays a thin gating + sizing wrapper. + * + * v1.18.11 (W11, #67) — the page is now a deep-link target INTO a + * conversation. A `?c=` query param opens that specific thread on + * mount; absent it, the page auto-opens the user's most-recent thread + * (server-authoritative `updatedAt desc` head of the rail, so it lands the + * same thread on web + mobile). Both feed ``'s existing + * selection state — the dashboard Coach entry navigates straight into the + * live conversation instead of a blank landing. `?c=new` (or no thread at + * all) keeps the new-chat hero. The search-param read sits in a Suspense + * child so the client-bailout never de-opts the route. */ +function CoachPageBody() { + const searchParams = useSearchParams(); + // `?c=new` is an explicit "start a fresh chat" escape hatch; any other + // value is treated as a conversation id to open. A blank/absent param + // falls through to most-recent auto-open. + const rawC = searchParams.get("c"); + const deepLinkedId = rawC && rawC !== "new" ? rawC : null; + + return ( + + ); +} + export default function CoachPage() { const router = useRouter(); const launch = useCoachLaunch(); @@ -79,13 +118,11 @@ export default function CoachPage() { // is hidden, so the page reclaims the full height below the TopBar. className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]" > - ( -

{title}

- )} - /> + {/* `useSearchParams` requires a Suspense boundary so the client-search + bailout never opts the whole route out of static optimisation. */} + + + ); } diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index e6b01e62a..ad69df7a3 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -7,13 +7,7 @@ import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import Link from "next/link"; -import { - Bell, - ChevronRight, - Loader2, - Settings, - AlertCircle, -} from "lucide-react"; +import { Bell, Loader2, Settings, AlertCircle } from "lucide-react"; import { EVENT_DEFAULT_ENABLED, type EventType, @@ -41,38 +35,6 @@ interface PreferencesData { eventTypes: string[]; } -/** - * Breadcrumb strip for the notification surfaces. v1.4.33 IW7 introduced - * this so the inbox (`/notifications`) and the channel-config section - * (`/settings/notifications`) never read as the same page — the two names - * used to collide as plain "Notifications" in both nav labels. The crumb - * sits above the h1 on every render branch of this page (loading, - * unauthenticated, error, empty, populated) so the user always sees the - * disambiguation, even when the body content is a fallback state. - */ -function NotificationsBreadcrumb({ t }: { t: (key: string) => string }) { - return ( - - ); -} - /** Maps SCREAMING_SNAKE event type to translation key, e.g. MEDICATION_REMINDER → eventMedicationReminder */ function eventTranslationKey(eventType: string): string { return ( @@ -167,7 +129,6 @@ export default function NotificationsPage() { if (!isAuthenticated) { return (
-

{t("notifications.title")}

@@ -185,7 +146,6 @@ export default function NotificationsPage() { if (isError) { return (
-

{t("notifications.title")} @@ -208,7 +168,6 @@ export default function NotificationsPage() { if (activeChannels.length === 0) { return (
-

{t("notifications.title")} @@ -237,7 +196,6 @@ export default function NotificationsPage() { return (
-

{t("notifications.title")} diff --git a/src/app/page.tsx b/src/app/page.tsx index 0c27f4df8..e610e4940 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -66,6 +66,14 @@ const DASHBOARD_QUERY_OPTS = { refetchOnWindowFocus: false, } as const; +// v1.19.0 — day-span the batched dashboard series (`series-batch`) fetches. +// Threaded to every chart as `preloadedCoverageDays` so a chart reads the +// batched slice ONLY for a range tab whose window fits within it (7 / 30); +// the 90 / All tabs exceed it and self-fetch the wider window. Pre-fix the +// chart trusted the batch for any window > 7 days, so 90 / All silently +// re-rendered this ~30-day slice. Keep this in lockstep with `batchWindow`. +const BATCH_COVERAGE_DAYS = 31; + // v1.16.8 — the loaders retry a rejected chunk import once (a lazy // import caches its rejection permanently, so a transient 404 from a // stale shell used to brick the card for the session) and each mount @@ -609,7 +617,7 @@ export default function DashboardPage() { const [batchWindow] = useState(() => { const to = new Date(); to.setHours(23, 59, 59, 999); - const from = new Date(to.getTime() - 31 * 86_400_000); + const from = new Date(to.getTime() - BATCH_COVERAGE_DAYS * 86_400_000); return { from: from.toISOString(), to: to.toISOString() }; }); @@ -1164,6 +1172,7 @@ export default function DashboardPage() { key="weight-chart" onDataReady={() => markChartReady("weight-chart")} preloadedSeries={preloadedSeries} + preloadedCoverageDays={BATCH_COVERAGE_DAYS} chartKey="weight" types={["WEIGHT"]} title={t("dashboard.weight")} @@ -1185,6 +1194,7 @@ export default function DashboardPage() { key="bmi-chart" onDataReady={() => markChartReady("bmi-chart")} preloadedSeries={preloadedSeries} + preloadedCoverageDays={BATCH_COVERAGE_DAYS} chartKey="bmi" types={["WEIGHT"]} title={t("targets.bmi")} @@ -1215,6 +1225,7 @@ export default function DashboardPage() { key="bp-chart" onDataReady={() => markChartReady("bp-chart")} preloadedSeries={preloadedSeries} + preloadedCoverageDays={BATCH_COVERAGE_DAYS} chartKey="bp" types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]} title={t("dashboard.bloodPressure")} @@ -1239,6 +1250,7 @@ export default function DashboardPage() { key="pulse-chart" onDataReady={() => markChartReady("pulse-chart")} preloadedSeries={preloadedSeries} + preloadedCoverageDays={BATCH_COVERAGE_DAYS} chartKey="pulse" // v1.15.12 A2 — chart the RESTING series against the // resting band when available; otherwise chart raw heart @@ -1266,6 +1278,7 @@ export default function DashboardPage() { key="bodyFat-chart" onDataReady={() => markChartReady("bodyFat-chart")} preloadedSeries={preloadedSeries} + preloadedCoverageDays={BATCH_COVERAGE_DAYS} chartKey="bodyFat" types={["BODY_FAT"]} title={t("dashboard.bodyFat")} @@ -1327,6 +1340,7 @@ export default function DashboardPage() { key="steps-chart" onDataReady={() => markChartReady("steps-chart")} preloadedSeries={preloadedSeries} + preloadedCoverageDays={BATCH_COVERAGE_DAYS} chartKey="steps" types={["ACTIVITY_STEPS"]} title={t("dashboard.steps") ?? "Steps"} diff --git a/src/components/charts/__tests__/health-chart-preloaded-coverage.test.tsx b/src/components/charts/__tests__/health-chart-preloaded-coverage.test.tsx new file mode 100644 index 000000000..b763bd522 --- /dev/null +++ b/src/components/charts/__tests__/health-chart-preloaded-coverage.test.tsx @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * v1.19.0 W11b — preloaded-coverage guard. + * + * The dashboard batches a ~30-day series slice and hands each chart its + * portion via `preloadedSeries`. Pre-fix the chart consumed that slice for + * ANY range tab wider than 7 days, so selecting the 90 / All tab silently + * re-rendered the 30-day batched slice instead of fetching the wider + * window — the user picked "90 days" and still saw ~30. + * + * The fix threads `preloadedCoverageDays` (the batch's actual day-span) and + * reads the preloaded slice ONLY when the requested window fits within it. + * A wider tab falls through to a real fetch. + * + * These tests drive the queryFn through `windowOverride` (which pins the + * range at mount, observable in `renderToStaticMarkup`) and assert on the + * fetch the queryFn does — or does not — issue. + */ + +const fetchCalls: string[] = []; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: ({ + queryFn, + queryKey, + }: { + queryKey: unknown[]; + queryFn: () => Promise; + }) => { + (globalThis as unknown as { __lastQueryKey?: unknown[] }).__lastQueryKey = + queryKey; + void queryFn(); + return { data: [], isLoading: false }; + }, + useQueryClient: () => ({ + cancelQueries: () => Promise.resolve(), + getQueryData: () => undefined, + setQueryData: () => undefined, + invalidateQueries: () => Promise.resolve(), + }), + useMutation: () => ({ mutate: () => undefined, isPending: false }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + isAuthenticated: true, + user: null, + isLoading: false, + }), +})); + +beforeEach(() => { + fetchCalls.length = 0; + global.fetch = vi.fn(async (url: RequestInfo | URL) => { + fetchCalls.push(String(url)); + return new Response( + JSON.stringify({ data: { measurements: [], meta: { total: 0 } } }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }) as typeof fetch; +}); + +// A 30-day batched slice keyed by type, in the chart's `MeasurementApiRow` +// shape. Its mere presence used to satisfy `usePreloaded` for any > 7-day +// window regardless of how wide the active tab was. +const preloadedSlice = { + WEIGHT: [{ measuredAt: "2026-06-01T08:00:00.000Z", value: 82 }], +}; + +describe(" — preloaded-coverage guard (W11b)", () => { + it("reads the preloaded slice (no fetch) when the window fits coverage", async () => { + const { I18nProvider } = await import("@/lib/i18n/context"); + const { HealthChart } = await import("../health-chart"); + + renderToStaticMarkup( + + + , + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + // 30-day window ≤ 31-day coverage → preloaded slice is consumed, the + // queryFn issues no `/api/measurements` network request. + const measurementsUrl = fetchCalls.find((u) => + u.includes("/api/measurements"), + ); + expect(measurementsUrl).toBeUndefined(); + + // Read-mode discriminator pins "preloaded" in the cache key. + const key = (globalThis as unknown as { __lastQueryKey?: unknown[] }) + .__lastQueryKey; + expect(key![key!.length - 1]).toBe("preloaded"); + }); + + it("fetches the wider window (does NOT read preloaded) when the tab exceeds coverage", async () => { + const { I18nProvider } = await import("@/lib/i18n/context"); + const { HealthChart } = await import("../health-chart"); + + renderToStaticMarkup( + + + , + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + // 90-day window > 31-day coverage → the chart must fetch the real + // window rather than re-render the 30-day batched slice. + const measurementsUrl = fetchCalls.find((u) => + u.includes("/api/measurements"), + ); + expect(measurementsUrl).toBeDefined(); + expect(measurementsUrl!).toMatch(/type=WEIGHT/); + expect(measurementsUrl!).toMatch(/aggregate=daily/); + + // The cache-key read-mode discriminator must flip to "fetch" so the + // batched-slice entry and the self-fetched entry never collide. + const key = (globalThis as unknown as { __lastQueryKey?: unknown[] }) + .__lastQueryKey; + expect(key![key!.length - 1]).toBe("fetch"); + }); + + it("fetches when no coverage is declared, even with a preloaded slice", async () => { + // A legacy mount that supplies `preloadedSeries` without + // `preloadedCoverageDays` must not be trusted blindly — coverage + // defaults to 0 so any > 7-day window self-fetches. + const { I18nProvider } = await import("@/lib/i18n/context"); + const { HealthChart } = await import("../health-chart"); + + renderToStaticMarkup( + + + , + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const measurementsUrl = fetchCalls.find((u) => + u.includes("/api/measurements"), + ); + expect(measurementsUrl).toBeDefined(); + }); +}); diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index 305b927fa..38b37e72f 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -84,6 +84,17 @@ const TIME_RANGES_KEYS = [ }, ] as const; +/** + * v1.19.0 — day-span the "All" range tab (`rangePoints === 0`) fetches. + * A generous fixed bound (~10 years) that is clearly larger than any + * other range tab, so "All" means "all of my history" rather than the + * old silent 365-day truncation. Fixed (not per-account derived) so the + * fetch-window cache key stays stable across the session. The server's + * daily-aggregate reader still caps the returned bucket count, so this + * only widens the requested window, never the render cost. + */ +const ALL_RANGE_DAYS = 3650; + interface HealthChartProps { types: string[]; title: string; @@ -250,6 +261,19 @@ interface HealthChartProps { * a self-fetched one. */ preloadedSeries?: Record; + /** + * v1.19.0 — the day-span the `preloadedSeries` slice actually covers + * (the window the dashboard's batched fetch requested). The chart reads + * the preloaded slice ONLY when the active range tab's window fits + * within this coverage; a wider tab (90 / All on a 30-day batch) falls + * through to a real fetch for the requested window instead of silently + * re-rendering the narrow batched slice. + * + * Required whenever `preloadedSeries` is supplied. Mount sites set it to + * the exact day-span their batch fetches (the dashboard batch spans 31 + * days). Omitted on self-fetching mounts. + */ + preloadedCoverageDays?: number; } interface ChartDataPoint { @@ -560,6 +584,7 @@ export function HealthChart({ titleIcon, onDataReady, preloadedSeries, + preloadedCoverageDays, }: HealthChartProps) { const { isAuthenticated, user } = useAuth(); const { t, locale } = useTranslations(); @@ -619,12 +644,19 @@ export function HealthChart({ // Comparison overlays (lastMonth / lastYear) extend the `from` // boundary backwards by the shift distance so the prior-period // slice rides the same fetch and `shiftDailySeriesForward` finds - // its input. "All" range (rangePoints === 0) defaults to a - // 365-day window; the chart's existing all-time UX already - // re-renders against whatever window we hand it. + // its input. + // + // v1.19.0 — "All" range (rangePoints === 0) requests `ALL_RANGE_DAYS` + // (~10 years) rather than the old 365-day cap, which silently truncated + // any account holding more than a year of history. The window is a + // generous fixed bound (not derived per-account) so the cache key stays + // stable across the session; the daily-aggregate reader caps the + // RETURNED bucket count at `BUCKET_CAP.daily` (365), so the chart still + // paints at most ~365 daily buckets — see the report's note on raising + // that server-side ceiling for true multi-year coverage. const fetchWindow = useMemo(() => { const to = new Date(); - const windowDays = rangePoints > 0 ? rangePoints : 365; + const windowDays = rangePoints > 0 ? rangePoints : ALL_RANGE_DAYS; const compareShift = effectiveCompareBaseline === "lastMonth" ? 30 @@ -649,13 +681,24 @@ export function HealthChart({ // SLEEP_DURATION is never batched (per-night `/series`), so a chart // that includes it always self-fetches. A range-tab change to a ≤7-day // window (raw rows) drops batched coverage and self-fetches. + // + // v1.19.0 — also require that the requested window FITS within the + // batch's actual coverage. The dashboard batches only a ~30-day slice; + // pre-fix, picking the 90 / All tab kept reading that 30-day slice + // (`usePreloaded` never compared windows), so the chart showed ~30 days + // no matter the tab. The mount now threads `preloadedCoverageDays`; a + // tab wider than the coverage falls through to a real fetch for the + // requested window. Without an explicit coverage (legacy mount), treat + // it as 0 so any preloaded slice is bypassed rather than trusted blindly. const usePreloaded = useMemo(() => { if (!preloadedSeries) return false; if (fetchWindow.windowDays <= 7) return false; + const coverage = preloadedCoverageDays ?? 0; + if (fetchWindow.windowDays > coverage) return false; return types.every( (type) => type === "SLEEP_DURATION" || preloadedSeries[type] != null, ); - }, [preloadedSeries, fetchWindow.windowDays, types]); + }, [preloadedSeries, preloadedCoverageDays, fetchWindow.windowDays, types]); const { data, isLoading, isError, refetch } = useQuery({ // v1.4.40 W-RSC — route the chart-data key through `queryKeys.chartData` diff --git a/src/components/cycle/__tests__/predictions-panel.test.tsx b/src/components/cycle/__tests__/predictions-panel.test.tsx index c1b91adb1..a10a12819 100644 --- a/src/components/cycle/__tests__/predictions-panel.test.tsx +++ b/src/components/cycle/__tests__/predictions-panel.test.tsx @@ -37,33 +37,19 @@ const emptyHistory: CycleHistoryResponse = { }; describe("", () => { - it("renders the next-period range and shows the disclaimer under the AVOID_PREGNANCY goal", () => { + it("renders the next-period range without repeating the non-medical disclaimer", () => { + // The disclaimer now lives once at the cycle-tracking on-ramp, never beneath + // the prediction surfaces, so the panel must not render it for any goal. const html = render( , - ); - expect(html).toContain('data-slot="cycle-disclaimer"'); - expect(html).toContain("Estimates only, not medical advice."); - // The range (not a single date) renders. - expect(html).toContain("–"); - }); - - it("suppresses the generic estimate disclaimer for non-AVOID_PREGNANCY goals", () => { - const html = render( - , ); expect(html).not.toContain('data-slot="cycle-disclaimer"'); expect(html).not.toContain("Estimates only, not medical advice."); - // The range still renders. + // The range (not a single date) renders. expect(html).toContain("–"); }); diff --git a/src/components/cycle/cycle-disclaimer.tsx b/src/components/cycle/cycle-disclaimer.tsx deleted file mode 100644 index 371fcd9eb..000000000 --- a/src/components/cycle/cycle-disclaimer.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client"; - -import { cn } from "@/lib/utils"; - -/** - * v1.15.0 — the fixed non-medical disclaimer for every prediction / fertility - * surface. Mirrors the GLP-1 drug-level-estimate disclaimer pattern - * (`DrugLevelChart`): a muted, border-left note that stays attached wherever - * an estimate renders. The copy is descriptive-not-a-contraceptive, - * not-medical-advice — never a green/safe claim. The text comes from the - * server (`cycle.prediction.disclaimer`, surfaced on the prediction DTO) so - * the web + iOS + the engine speak the same words. - */ -export function CycleDisclaimer({ - text, - className, -}: { - text: string; - className?: string; -}) { - return ( -

- {text} -

- ); -} diff --git a/src/components/cycle/cycle-settings.tsx b/src/components/cycle/cycle-settings.tsx index 2ee30642f..ce0aec3dd 100644 --- a/src/components/cycle/cycle-settings.tsx +++ b/src/components/cycle/cycle-settings.tsx @@ -194,7 +194,18 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) { ))}

- {t("cycle.settings.secondarySymptomDescription")} + {t("cycle.settings.secondarySymptomDescription")}{" "} + {/* Primary source for the symptothermal double-check method: the + German NFP prospective cohort (Frank-Herrmann et al., Human + Reproduction 2007). Plain anchor — no markdown renderer. */} + + {t("cycle.settings.secondarySymptomCitation")} +

@@ -220,21 +231,11 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) { {/* Reminders — the per-channel toggles live on the notifications page; point users there rather than duplicating the toggle state here. */} -
-
-

- {t("cycle.settings.reminders")} -

-

- {t("cycle.settings.remindersDescription")} -

-
- -
+ {/* Fertile-window reminder — surfaced ONLY under the conception goal. The toggle itself is the default-OFF per-channel CYCLE_FERTILE_SOON @@ -242,21 +243,11 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) { it, so the affordance never appears (and fertile language never shows) for the other goals (the inclusive-framing rule). */} {goal === "TRYING_TO_CONCEIVE" ? ( -
-
-

- {t("cycle.settings.fertileReminder")} -

-

- {t("cycle.settings.fertileReminderDescription")} -

-
- -
+ ) : null} {/* Cycle data export + delete live with the main Settings → Export and @@ -306,7 +297,7 @@ function ToggleRow({ -

{description}

+

{description}

+
+

{title}

+

{description}

+
+ +

+ ); +} + function PriorField({ id, label, diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 43a63ac41..7489fd3c1 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -12,7 +12,6 @@ import { cn } from "@/lib/utils"; import { CycleRing } from "./cycle-ring"; import { BbtChart } from "./bbt-chart"; import { CycleCalendar } from "./cycle-calendar"; -import { CycleDisclaimer } from "./cycle-disclaimer"; import { CycleHistoryChart } from "./cycle-history-chart"; import { LogDaySheet } from "./log-day-sheet"; import { PhaseEducationCard } from "./phase-education-card"; @@ -118,22 +117,11 @@ export function CycleView() { const loading = calendar.isLoading && !calendar.data; const calendarError = calendar.isError && !calendar.data; const tileHue = wheel.phase ? PHASE_HUE[wheel.phase] : undefined; - // AVOID_PREGNANCY surfaces the fertile window, so it must show the stronger - // "not a contraceptive method" caveat. Prefer the server-resolved - // prediction.disclaimer (already goal-correct); fall back to the goal-derived - // key for the no-prediction calendar tab (QA H-1). const goal = calendar.data?.profile.goal; // Honesty-gate inputs for the phase-education card (Clue precedent): the // predictive phase framing only shows when prediction is on, not in // raw-chart mode, and at least three cycles are observed. const calProfile = calendar.data?.profile; - const disclaimerText = - calendar.data?.prediction?.disclaimer ?? - t( - goal === "AVOID_PREGNANCY" - ? "cycle.disclaimer" - : "cycle.prediction.disclaimer", - ); return (
@@ -162,8 +150,13 @@ export function CycleView() { {/* Desktop: ring (left) + tabs (right). Single column below lg. */}
- {/* Wheel + compact phase card — grouped so they stick together. */} -
+ {/* Wheel + compact phase card — grouped so they stick together. The + `lg:mt-15` (3.75rem) drops the column so the wheel tile's top edge + lines up with the calendar / predictions content rather than the + tab strip: TabsList `h-9` (2.25rem) + the Tabs root `gap-2` (0.5rem) + + the content's `mt-4` (1rem). Below `lg` the column stacks first and + the offset collapses to zero. */} +
{/* Wheel — the signature ring on the premium wellness-tile surface. `data-revealed` must sit on an ANCESTOR of `.wellness-tile-rise` — the keyframe selector is `[data-revealed="true"] @@ -180,7 +173,7 @@ export function CycleView() { : undefined } className={cn( - "wellness-tile flex flex-col items-center gap-3 rounded-xl px-6 py-6", + "wellness-tile flex flex-col items-center gap-3 rounded-xl px-5 py-5", play && "wellness-tile-rise", )} > @@ -321,9 +314,6 @@ export function CycleView() { )} - {!loading && !calendarError ? ( - - ) : null} @@ -339,8 +329,6 @@ export function CycleView() { prediction={calendar.data?.prediction ?? null} rawChartMode={calendar.data?.profile.rawChartMode ?? false} history={history.data} - fallbackDisclaimer={disclaimerText} - goal={goal} /> @@ -129,8 +112,6 @@ export function PredictionsPanel({ ) : null} )} - - {disclaimer ? : null} diff --git a/src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx b/src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx new file mode 100644 index 000000000..8c72401be --- /dev/null +++ b/src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx @@ -0,0 +1,180 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { DailyBriefing } from "@/lib/ai/schema"; +import { BriefingSpotlight } from "../briefing-spotlight"; + +/** + * `` contract: + * + * - renders nothing unless the briefing is FRESH (`ready` + not stale) + * and carries at least one signal or finding; + * - prefers `signalsOfDay`, falling back to `keyFindings`; + * - caps the strip at 3 rows; + * - every row links to `/insights` and carries the tone-keyed accent; + * - headlines render as plain text children (no HTML / markdown). + */ + +function render( + props: React.ComponentProps, + locale: "en" | "de" = "en", +) { + return renderToStaticMarkup( + + + , + ); +} + +function briefing(overrides: Partial = {}): DailyBriefing { + return { + paragraph: "x", + signalsOfDay: null, + keyFindings: [], + ...overrides, + }; +} + +describe("", () => { + it("renders nothing when no briefing is present", () => { + expect( + render({ + briefing: null, + briefingState: "ready", + briefingStale: false, + }), + ).toBe(""); + }); + + it("renders nothing for a stale briefing", () => { + expect( + render({ + briefing: briefing({ + keyFindings: [ + { + tone: "watch", + headline: "BP creeping up", + detail: "d", + delta: "+6 mmHg", + sourceWindow: "30d", + sourceMetric: "bp", + }, + ], + }), + briefingState: "ready", + briefingStale: true, + }), + ).toBe(""); + }); + + it("renders nothing while the briefing is still preparing", () => { + expect( + render({ + briefing: briefing({ + keyFindings: [ + { + tone: "info", + headline: "h", + detail: "d", + delta: null, + sourceWindow: "30d", + sourceMetric: "weight", + }, + ], + }), + briefingState: "preparing", + briefingStale: false, + }), + ).toBe(""); + }); + + it("surfaces the present-focused signals and links each row to /insights", () => { + const html = render({ + briefing: briefing({ + signalsOfDay: [ + { + sourceMetric: "sleep", + tone: "watch", + headline: "Short on sleep this week", + nudge: "Aim for an earlier night", + delta: "-1.2 h", + }, + ], + keyFindings: [ + { + tone: "good", + headline: "should not appear when signals exist", + detail: "d", + delta: null, + sourceWindow: "30d", + sourceMetric: "bp", + }, + ], + }), + briefingState: "ready", + briefingStale: false, + }); + expect(html).toContain('data-slot="dashboard-briefing-spotlight"'); + expect(html).toContain("Short on sleep this week"); + expect(html).toContain('href="/insights"'); + expect(html).toContain("-1.2 h"); + // Signals win over key findings when present. + expect(html).not.toContain("should not appear when signals exist"); + }); + + it("falls back to key findings when no signals were generated", () => { + const html = render({ + briefing: briefing({ + signalsOfDay: [], + keyFindings: [ + { + tone: "watch", + headline: "Blood pressure trending up", + detail: "d", + delta: "+8 mmHg", + sourceWindow: "30d", + sourceMetric: "bp", + }, + ], + }), + briefingState: "ready", + briefingStale: false, + }); + expect(html).toContain("Blood pressure trending up"); + }); + + it("caps the strip at three rows", () => { + const html = render({ + briefing: briefing({ + signalsOfDay: [ + { + sourceMetric: "bp", + tone: "info", + headline: "one", + nudge: "n", + delta: null, + }, + { + sourceMetric: "weight", + tone: "info", + headline: "two", + nudge: "n", + delta: null, + }, + { + sourceMetric: "mood", + tone: "info", + headline: "three", + nudge: "n", + delta: null, + }, + ], + }), + briefingState: "ready", + briefingStale: false, + }); + const rows = html.match(/data-slot="dashboard-briefing-spotlight-row"/g); + expect(rows).toHaveLength(3); + }); +}); diff --git a/src/components/dashboard/hero/briefing-spotlight.tsx b/src/components/dashboard/hero/briefing-spotlight.tsx new file mode 100644 index 000000000..ae222e0b8 --- /dev/null +++ b/src/components/dashboard/hero/briefing-spotlight.tsx @@ -0,0 +1,219 @@ +"use client"; + +/** + * v1.18.11 — dashboard briefing spotlight. + * + * The daily briefing previously surfaced on the dashboard ONLY as the + * hero's rung-8 verdict — a single sentence that almost always lost the + * verdict slot to a routine state (an upcoming dose, a weight drift, the + * all-quiet fallback). A fresh, model-authored briefing was effectively + * invisible above the fold. + * + * This strip raises the briefing's prominence WITHOUT touching its + * content or generation: it lifts the briefing's own present-focused + * "Signals of the day" (falling back to the longer-horizon key findings) + * straight onto the dashboard hero, so the freshest read is always one + * glance away. The verdict ladder is unchanged — a real BP crisis still + * leads the verdict line; this band sits below it. + * + * Design contract: + * - reuses the established tone-bar + metric-icon row language from the + * `/insights` daily-briefing card so the two surfaces read identically; + * - self-gating — renders nothing unless a FRESH briefing (`ready`, not + * stale) carries at least one signal or finding, so a missing / + * stale / disabled briefing never leaves an empty band; + * - rows are tappable, routing to `/insights` for the full briefing; + * - plain text children only (no HTML / markdown) per the repo-wide + * no-markdown-renderer rule; headlines pass through `stripChartTokens` + * to defend against a stray chart token leaking into the prose. + * + * Pure presentational: the caller resolves the briefing from the + * server-authoritative snapshot and passes the already-validated payload. + */ +import Link from "next/link"; +import { + Activity, + Flame, + Footprints, + Heart, + HeartPulse, + Mountain, + Moon, + Pill, + Route, + Scale, + Smile, + Sparkles, + Thermometer, + Wind, + Zap, +} from "lucide-react"; +import { ListRow } from "@/components/ui/list-row"; +import { useTranslations } from "@/lib/i18n/context"; +import { stripChartTokens } from "@/lib/insights/chart-tokens"; +import { cn } from "@/lib/utils"; +import type { DailyBriefing, DailyBriefingKeyFinding } from "@/lib/ai/schema"; + +/** + * Max rows the spotlight surfaces. The briefing caps `signalsOfDay` at 3 + * and `keyFindings` at 5; the dashboard strip stays a glance, so it shows + * at most the top 3 — the user opens `/insights` for the full list. + */ +const MAX_SPOTLIGHT_ROWS = 3; + +const METRIC_ICON: Record< + DailyBriefingKeyFinding["sourceMetric"], + React.ComponentType<{ className?: string }> +> = { + bp: Heart, + weight: Scale, + pulse: Activity, + mood: Smile, + compliance: Pill, + hrv: Wind, + sleep: Moon, + resting_hr: HeartPulse, + steps: Footprints, + active_energy: Flame, + flights: Mountain, + distance: Route, + vo2_max: Zap, + body_temp: Thermometer, + glp1_plateau: Pill, + readiness: Sparkles, + recovery: HeartPulse, +}; + +const TONE_BAR_CLASSNAME: Record = { + good: "bg-success", + watch: "bg-warning", + info: "bg-info", +}; + +const TONE_TEXT_CLASSNAME: Record = { + good: "text-success", + watch: "text-warning", + info: "text-info", +}; + +/** One spotlight row, normalised from a signal or a key finding. */ +interface SpotlightRow { + sourceMetric: DailyBriefingKeyFinding["sourceMetric"]; + tone: DailyBriefingKeyFinding["tone"]; + headline: string; + delta: string | null; +} + +/** + * Choose the rows to spotlight. Prefers the present-focused + * `signalsOfDay` (the rebuilt briefing's lead); falls back to the + * longer-horizon `keyFindings` when no signals were generated. Returns an + * empty array when neither carries content (the caller then renders + * nothing). + */ +function selectSpotlightRows(briefing: DailyBriefing): SpotlightRow[] { + const signals = briefing.signalsOfDay ?? []; + if (signals.length > 0) { + return signals.slice(0, MAX_SPOTLIGHT_ROWS).map((s) => ({ + sourceMetric: s.sourceMetric, + tone: s.tone, + headline: s.headline, + delta: s.delta, + })); + } + return briefing.keyFindings.slice(0, MAX_SPOTLIGHT_ROWS).map((f) => ({ + sourceMetric: f.sourceMetric, + tone: f.tone, + headline: f.headline, + delta: f.delta, + })); +} + +export function BriefingSpotlight({ + briefing, + briefingState, + briefingStale, +}: { + briefing: DailyBriefing | null; + briefingState: string; + briefingStale: boolean; +}) { + const { t } = useTranslations(); + + // Fresh-only gate: a ready, non-stale briefing with at least one row. + // A stale / preparing / disabled briefing falls through to nothing so + // the band never sits empty or narrates a days-old read as current. + if (briefingState !== "ready" || briefingStale || briefing === null) { + return null; + } + const rows = selectSpotlightRows(briefing); + if (rows.length === 0) return null; + + return ( +
+
+

+ {t("dashboard.hero.briefing.title")} +

+ + {t("dashboard.hero.briefing.viewAll")} + +
+
+ {rows.map((row, index) => { + const Icon = METRIC_ICON[row.sourceMetric]; + return ( + + + + ); + })} +
+
+ ); +} diff --git a/src/components/dashboard/hero/dashboard-hero.tsx b/src/components/dashboard/hero/dashboard-hero.tsx index d13db1963..36863ac82 100644 --- a/src/components/dashboard/hero/dashboard-hero.tsx +++ b/src/components/dashboard/hero/dashboard-hero.tsx @@ -48,6 +48,7 @@ import { Pill } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ScoreRing } from "@/components/insights/derived/score-ring"; import { RestModeBanner } from "@/components/insights/rest-mode-banner"; +import { BriefingSpotlight } from "@/components/dashboard/hero/briefing-spotlight"; import { resolveDashboardVerdict, type DashboardVerdictVariant, @@ -325,6 +326,18 @@ export function DashboardHero({ untinted so it frames the score without alarming. Closes the web↔iOS parity gap: iOS already mirrors `score.restMode`. */} + {/* v1.18.11 — briefing spotlight. Lifts the fresh briefing's own + signals onto the hero so the daily read is always above the + fold, not buried behind the rung-8 verdict. Self-gating: renders + nothing unless a fresh (ready, non-stale) briefing carries + content. The rung-8 `briefing` verdict above still fires when no + higher rung claims the verdict line; this band is additive and + does not change which sentence the verdict shows. */} +
); diff --git a/src/components/insights/__tests__/recommendation-card.test.tsx b/src/components/insights/__tests__/recommendation-card.test.tsx index bd8ad333a..952f1068f 100644 --- a/src/components/insights/__tests__/recommendation-card.test.tsx +++ b/src/components/insights/__tests__/recommendation-card.test.tsx @@ -33,6 +33,29 @@ vi.mock("@/components/charts/mood-chart", () => ({ ), })); +// v1.18.11 (W5 perf) — the rationale charts now route through `next/dynamic` +// so recharts is off the insights first-load JS. Under `renderToStaticMarkup` +// a `dynamic(..., { ssr:false })` boundary paints its loading skeleton (it +// never resolves the lazy chunk synchronously). For the chart-SELECTION +// assertions below we stub `next/dynamic` to a passthrough that renders the +// (already-mocked) underlying chart component eagerly, so the test still +// verifies the card picks the right chart kind for a metric. Production keeps +// the real lazy boundary + skeleton. +vi.mock("next/dynamic", async () => { + const health = await import("@/components/charts/health-chart"); + const mood = await import("@/components/charts/mood-chart"); + return { + default: (loader: unknown) => { + // Identify which chart the loader targets by its source text. + const src = String(loader); + const Comp = ( + src.includes("mood-chart") ? mood.MoodChart : health.HealthChart + ) as React.ComponentType>; + return (props: Record) => ; + }, + }; +}); + vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ data: null, isLoading: false }), useMutation: () => ({ diff --git a/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx b/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx new file mode 100644 index 000000000..fd9894a48 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx @@ -0,0 +1,138 @@ +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 { CoachConversation } from "../coach-conversation"; +import type { + CoachConversationsPage, + CoachConversationDetailDTO, +} from "@/lib/ai/coach/types"; + +/** + * v1.18.11 (W11, #67) — the Coach page is a deep-link target INTO a + * conversation. These tests pin the selection contract on the shared + * `` page surface: + * + * - `initialConversationId` opens that exact thread on mount (the `?c=` + * deep-link the dashboard Coach entry carries). + * - `autoOpenMostRecent` opens the server-authoritative most-recent thread + * (the `updatedAt desc` head of the rail list) when no id is pinned. + * - With neither, the surface keeps the new-chat hero. + * + * Selection is observed through the rendered output: an open thread paints + * the docked page composer (`coach-page-composer`) and NOT the new-chat hero + * (`coach-hero`). The cache is seeded so SSR renders the resolved state in a + * single pass — no real network call. + */ + +const RAIL: CoachConversationsPage = { + conversations: [ + { + id: "recent", + title: "Most recent thread", + createdAt: "2026-06-20T08:00:00.000Z", + updatedAt: "2026-06-20T09:00:00.000Z", + messageCount: 3, + }, + { + id: "older", + title: "Older thread", + createdAt: "2026-06-18T08:00:00.000Z", + updatedAt: "2026-06-18T08:05:00.000Z", + messageCount: 2, + }, + ], + nextCursor: null, +}; + +function detail(id: string, title: string): CoachConversationDetailDTO { + return { + id, + title, + createdAt: "2026-06-20T08:00:00.000Z", + updatedAt: "2026-06-20T09:00:00.000Z", + messages: [ + { + id: `${id}-m1`, + role: "user", + content: `question in ${title}`, + createdAt: "2026-06-20T08:00:00.000Z", + metricSource: null, + tokensUsed: null, + }, + ], + } as unknown as CoachConversationDetailDTO; +} + +function makeClient(): QueryClient { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + client.setQueryData(["coachConversations"], RAIL); + client.setQueryData( + ["coachConversation", "recent"], + detail("recent", "Most recent thread"), + ); + client.setQueryData( + ["coachConversation", "older"], + detail("older", "Older thread"), + ); + // The composer's pending-questions query — keep it empty so the hero gate + // is decided purely by the active conversation id. + client.setQueryData(["coach-about-me", "questions"], { questions: [] }); + return client; +} + +function render(node: React.ReactNode, client: QueryClient): string { + return renderToStaticMarkup( + + {node} + , + ); +} + +describe(" page deep-link (#67)", () => { + it("opens a specific thread when initialConversationId is set", () => { + const html = render( + , + makeClient(), + ); + // The pinned thread is open: docked composer, no new-chat hero. + expect(html).toContain('data-slot="coach-page-composer"'); + expect(html).not.toContain('data-slot="coach-hero"'); + }); + + it("auto-opens the most-recent thread when none is pinned", () => { + const html = render( + , + makeClient(), + ); + // Resolves to the rail's updatedAt-desc head — thread open, hero gone. + expect(html).toContain('data-slot="coach-page-composer"'); + expect(html).not.toContain('data-slot="coach-hero"'); + }); + + it("keeps the new-chat hero when there is nothing to open", () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + client.setQueryData(["coachConversations"], { + conversations: [], + nextCursor: null, + }); + client.setQueryData(["coach-about-me", "questions"], { questions: [] }); + const html = render( + , + client, + ); + expect(html).toContain('data-slot="coach-hero"'); + }); + + it("does not auto-open when autoOpenMostRecent is off (new chat)", () => { + const html = render(, makeClient()); + // No pin, no auto-open → the surface stays on the new-chat hero even + // though recent conversations exist in the cache. + expect(html).toContain('data-slot="coach-hero"'); + }); +}); diff --git a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx index eb15e9b62..6f113d0c4 100644 --- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx @@ -222,6 +222,65 @@ describe("", () => { expect(handler).toHaveBeenCalledWith("typed"); }); + it("omits the control-hub action row by default (drawer composer)", () => { + // v1.18.11 (W11) — the hub is page-only; without `showHub` the composer + // stays the single-row drawer layout (mic + send on one baseline) and + // grows no actions menu or settings deep-link. + const html = render( + {}} onSubmit={() => {}} />, + ); + expect(html).not.toContain('data-slot="coach-input-hub"'); + expect(html).not.toContain('data-slot="coach-input-actions"'); + expect(html).not.toContain('data-slot="coach-input-settings"'); + // Mic + send still render in the single-row layout. + expect(html).toContain('data-slot="coach-input-mic"'); + expect(html).toContain('data-slot="coach-input-send"'); + }); + + it("renders the control-hub action row with showHub (page composer)", () => { + // v1.18.11 (W11) — the page composer is the control hub: a `+` actions + // menu (new chat + open conversations) and a settings deep-link sit on + // the action row alongside the mic + send. + const html = render( + {}} + onSubmit={() => {}} + showHub + onNewChat={() => {}} + onOpenHistory={() => {}} + />, + ); + expect(html).toContain('data-slot="coach-input-hub"'); + expect(html).toContain('data-slot="coach-input-actions"'); + // The settings gear deep-links to Settings → AI (not an in-chat sheet). + const settings = html.match( + /]*data-slot="coach-input-settings"[^>]*>/, + ); + expect(settings?.[0]).toContain('href="/settings/ai"'); + // Mic + send remain present in the hub layout. + expect(html).toContain('data-slot="coach-input-mic"'); + expect(html).toContain('data-slot="coach-input-send"'); + }); + + it("sizes the hub actions trigger to the 44px tap-target floor on phones", () => { + const html = render( + {}} + onSubmit={() => {}} + showHub + onNewChat={() => {}} + onOpenHistory={() => {}} + />, + ); + const actions = html.match( + /]*data-slot="coach-input-actions"[^>]*>/, + ); + expect(actions?.[0]).toMatch(/\bsize-11\b/); + expect(actions?.[0]).toMatch(/\bsm:size-9\b/); + }); + it("renders the textarea at rows=1 initial state (W5 auto-grow baseline)", () => { // v1.4.25 W5 — Claude-web-style auto-grow. SSR baseline is a // single-line textarea; the client-side `useEffect` measures diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index cbe6afae1..b166d6334 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -7,6 +7,12 @@ import { Plus, Settings, Sparkles } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -31,7 +37,11 @@ import { MobileRailTray } from "./mobile-rail-tray"; import { SelfContextAdoptOffer } from "./self-context-adopt-offer"; import { SourcesRail } from "./sources-rail"; import { useResettableValue } from "./use-resettable-value"; -import { useCoachConversation, useSendCoachMessage } from "./use-coach"; +import { + useCoachConversation, + useCoachConversations, + useSendCoachMessage, +} from "./use-coach"; /** * v1.12.0 (Coach v2 #6) — shared chat surface for both the Coach drawer @@ -106,6 +116,27 @@ export interface CoachConversationProps { className?: string; /** data-variant on the root, surfaced for e2e + styling hooks. */ surface: "drawer" | "page"; + /** + * v1.18.11 (W11, #67) — open a specific conversation on mount. The page + * surface reads it from the `?c=` deep-link the dashboard Coach entry + * carries, so a tap lands the user directly in that thread instead of a + * blank new chat. Seeds the same `currentConversationId` the history + * drawer drives, so the drawer's selection stays the single source of + * truth; the URL param is just one more way to set it. Null/undefined + * leaves the selection alone (new-chat, or auto-most-recent below). + */ + initialConversationId?: string | null; + /** + * v1.18.11 (W11, #67) — when no `initialConversationId` is given, open + * the user's MOST-RECENT conversation once the shared rail list resolves. + * This is what makes the dashboard Coach entry land the user IN their + * conversation cross-device: "most recent" is the server-authoritative + * `updatedAt desc` head of `GET /api/insights/chat`, identical on web and + * mobile — no client-only state. An account with no conversations falls + * through to the new-chat hero. Resolves exactly once per mount so a later + * "new chat" or thread switch is never overridden. + */ + autoOpenMostRecent?: boolean; } export function CoachConversation({ @@ -119,18 +150,22 @@ export function CoachConversation({ onRequestFullView, className, surface, + initialConversationId, + autoOpenMostRecent = false, }: CoachConversationProps) { const { t } = useTranslations(); const [currentConversationId, setCurrentConversationId] = useState< string | null - >(null); + >(initialConversationId ?? null); const [historyTrayOpen, setHistoryTrayOpen] = useState(false); const [sourcesTrayOpen, setSourcesTrayOpen] = useState(false); - // v1.18.7 (W-coach C-UI) — page surface only: the inline conversation - // rail is collapsed by default for a calm, prompt-first surface. The - // rail-tray-strip toggle (lg+) opens it; the rail heading closes it. - const [historyRailOpen, setHistoryRailOpen] = useState(false); + // v1.18.11 (W11) — page surface only: conversation history is a LEFT + // slide-in drawer (overlay on every viewport, ChatGPT-style), opened from + // the composer's `+` actions menu. Replaces the old inline collapsible + // rail + top rail-tray strip; the top header bar is gone entirely on the + // page so the composer is the single control hub. + const [historyDrawerOpen, setHistoryDrawerOpen] = useState(false); const [inputValue, setInputValue] = useResettableValue(prefill ?? ""); // v1.16.4 — self-context backflow: `pendingAdopt` raises a quiet // offer to fold a clarifying-question answer back into the @@ -179,6 +214,26 @@ export function CoachConversation({ }, }); + // v1.18.11 (W11, #67) — auto-open the most-recent conversation when the + // surface mounts with no explicit selection. Only fetches the rail list + // when the behaviour is requested AND no deep-linked id was supplied, so + // the drawer's default new-chat mount pays nothing for it. + const autoOpenEnabled = autoOpenMostRecent && !initialConversationId; + const { conversations: railConversations } = + useCoachConversations(autoOpenEnabled); + // Resolve once per mount via the in-render setState pattern (the + // `react-hooks/set-state-in-effect` rule rejects setState inside an effect + // when the source is a query result; see `coach-prefs-section.tsx`). The + // latch flips the first time the rail list arrives so a subsequent "new + // chat" or thread switch is never clobbered by a late list refetch. + const [autoOpenResolved, setAutoOpenResolved] = useState(false); + if (autoOpenEnabled && !autoOpenResolved && railConversations.length > 0) { + setAutoOpenResolved(true); + // Server-authoritative `updatedAt desc` head = the same thread the rail + // shows first, identical on every device. + setCurrentConversationId(railConversations[0].id); + } + // Hand the chrome an imperative reset so the drawer can abort the // in-flight SSE stream + clear the thread on close. Re-registered when // the closed-over state setters / send hook change identity so the @@ -288,6 +343,11 @@ export function CoachConversation({ // hero (centred) OR docked at the bottom of the conversation without // forking any composer logic (dictation, auto-grow, send/stop, the // guided-question placeholder all behave identically in both spots). + // + // v1.18.11 (W11) — on the page surface the composer is the control hub: + // it grows a leading `+` actions menu (new chat + open conversations) and + // a settings deep-link. The drawer keeps its own header for those, so the + // hub is page-only. const composerNode = ( setHistoryDrawerOpen(true)} /> ); @@ -323,6 +386,132 @@ export function CoachConversation({ pendingQuestions.length === 0 && !pendingAdopt; + // v1.18.11 (W11) — the docked composer column: the quiet adopt offer and + // the guided-questions entry card stack above the live composer. Shared by + // the drawer body (via `CoachDrawerBody`) and the page surface (rendered + // directly), so the two surfaces stay byte-identical above the composer. + const composerStack = ( +
+ {/* v1.16.4 — quiet adopt-into-self-context offer once a clarifying + question has been answered. Self-removes after settle; v1.16.5 + reports the outcome back into the guided machine for the closing + summary. */} + {pendingAdopt && !send.isStreaming ? ( + setPendingAdopt(null)} + onSettled={(adoption) => + dispatchGuided({ + type: "ADOPTION_SETTLED", + index: pendingAdopt.guidedIndex, + adoption, + }) + } + /> + ) : null} + {/* v1.16.5 — guided clarifying-questions entry card (V2 of the + v1.16.0 chips). Offers the in-chat sequence while questions pend + and the flow hasn't started. */} + {guided.phase === "idle" && pendingQuestions.length > 0 ? ( + + dispatchGuided({ + type: "START", + questions: pendingQuestions, + }) + } + onLater={() => dispatchGuided({ type: "LATER" })} + onDismissAll={() => + dismissQuestions.mutate(undefined, { + onSuccess: () => dispatchGuided({ type: "LATER" }), + }) + } + /> + ) : null} + {composerNode} +
+ ); + + // v1.18.11 (W11) — page surface: the conversation history is a LEFT + // slide-in drawer (overlay on every viewport), opened from the composer's + // `+` actions menu. Reuses the shared `` + conversation-list + // query; respects `prefers-reduced-motion` (the Sheet primitive disables + // its slide there). Selecting a conversation closes the drawer. + const historyDrawer = ( + + + + + {t("insights.coach.historyTitle")} + + +
+ { + // v1.16.5 — switching conversations drops the guided session; + // unanswered questions stay pending. + setCurrentConversationId(id); + setHistoryDrawerOpen(false); + setPendingAdopt(null); + dispatchGuided({ type: "RESET" }); + }} + /> +
+
+
+ ); + + // v1.18.11 (W11) — the PAGE surface drops the top header bar and the + // rail-tray strip entirely. The composer is the single control hub; the + // thread (`[&>*]:max-w-2xl` inner gutter) and the docked composer + // (`mx-auto max-w-2xl`) share ONE centred, max-width-capped column so the + // composer never changes width between the new-chat hero and an active + // conversation. The drawer surface keeps its own header + body chrome + // (handled below) untouched. + if (surface === "page") { + return ( +
+ {heroActive ? ( + + ) : ( + <> +
+ +
+ {/* Docked composer — the SAME centred, capped column as the + thread (and the hero composer), so the width is constant + across the new-chat → conversation transition. */} +
+
{composerStack}
+
+ + )} + {historyDrawer} +
+ ); + } + return (
- {heroActive ? ( - - ) : ( - setHistoryRailOpen((open) => !open) - : undefined - } - historyRail={ - surface === "page" ? ( - ` band already renders the - // "Conversations"

; suppress the rail's own

so the - // heading is not stacked twice on the page surface. - hideHeading - onSelect={(id) => { - // v1.16.5 — switching conversations drops the guided - // session; unanswered questions stay pending. - setCurrentConversationId(id); - setPendingAdopt(null); - dispatchGuided({ type: "RESET" }); - }} - /> - ) : undefined - } - thread={ - - } - composer={ -
- {/* v1.16.4 — quiet adopt-into-self-context offer once a - clarifying question has been answered. Self-removes - after settle; v1.16.5 reports the outcome back into the - guided machine for the closing summary. */} - {pendingAdopt && !send.isStreaming ? ( - setPendingAdopt(null)} - onSettled={(adoption) => - dispatchGuided({ - type: "ADOPTION_SETTLED", - index: pendingAdopt.guidedIndex, - adoption, - }) - } - /> - ) : null} - {/* v1.16.5 — guided clarifying-questions entry card (V2 of - the v1.16.0 chips). Offers the in-chat sequence while - questions pend and the flow hasn't started. */} - {guided.phase === "idle" && pendingQuestions.length > 0 ? ( - - dispatchGuided({ - type: "START", - questions: pendingQuestions, - }) - } - onLater={() => dispatchGuided({ type: "LATER" })} - onDismissAll={() => - dismissQuestions.mutate(undefined, { - onSuccess: () => dispatchGuided({ type: "LATER" }), - }) - } - /> - ) : null} - {composerNode} -
- } - onHistoryClick={ - surface === "drawer" && onRequestFullView - ? onRequestFullView - : () => setHistoryTrayOpen(true) - } - onOpenSourcesTray={() => setSourcesTrayOpen(true)} - /> - )} + {/* Drawer surface keeps its existing body chrome: thread + rail-tray + strip + docked composer, with the conversation list + sources as + mobile trays. The "Conversations" affordance hands off to the + full-page route (in-panel left tray kept breaking inside the + sheet). The page surface is handled above and never reaches here. */} + + } + composer={composerStack} + onHistoryClick={onRequestFullView ?? (() => setHistoryTrayOpen(true))} + onOpenSourcesTray={() => setSourcesTrayOpen(true)} + /> void; + /** + * v1.18.11 — open the left conversation-history drawer. Wired into the + * `+` actions menu. Required when `showHub` is set. + */ + onOpenHistory?: () => void; } /** @@ -172,6 +212,9 @@ export function CoachInput({ inputId = "coach-composer-textarea", autoFocusOnOpen = false, placeholder, + showHub = false, + onNewChat, + onOpenHistory, }: CoachInputProps) { const { t, locale } = useTranslations(); const textareaRef = useRef(null); @@ -336,6 +379,79 @@ export function CoachInput({ [canSubmit, onSubmit], ); + // v1.18.11 — the dictation mic. Lifted to a const so it can sit in the + // single-row composer (drawer) OR in the hub action row (page) without + // duplicating the wiring. + const micButton = ( + + ); + + // v1.18.11 — the send / stop control. Same const-lift rationale as the + // mic: one definition, two mount points. + const sendButton = + isStreaming && onCancel ? ( + // While a reply streams, swap the send button for a Stop control bound + // to the abort handler so the user can interrupt a long or off-track + // reply instead of waiting it out. + + ) : ( + + ); + return (
{/* v1.16.1 / v1.18.7 — modern chat-app composer: a single rounded - field with the textarea flanked by a dictation mic (left) and - the send / stop control (right) on the same baseline. - `items-end` keeps the controls pinned to the input's last line - as it grows. Enter sends, Shift+Enter inserts a newline. */} + field. The drawer surface keeps the textarea flanked by the mic + (left) and send / stop (right) on one baseline. The page surface + (`showHub`) stacks the textarea over a control-hub action row so + the composer carries the conversation controls ChatGPT-style. + `items-end` keeps the single-row controls pinned to the input's + last line as it grows. Enter sends, Shift+Enter inserts a newline. */}
{/* v1.18.10 (W4) — the mic always renders so it stays discoverable. When the browser lacks the Web Speech API (or during SSR) it is DISABLED with an explanatory tooltip rather than vanishing or - sitting as a dead control that does nothing on tap. */} - + sitting as a dead control that does nothing on tap. In the hub + layout the mic moves into the action row below the textarea. */} + {!showHub && micButton}