Merge release/v1.19.0 into main (v1.19.0)

Release v1.19.0
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-21 13:13:54 +02:00
commit 897573e5fc
185 files changed
+14854 -5260

No files matched your search

+28
View File
@@ -187,3 +187,31 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# $ 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
# `/<ip>` 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"
+51
View File
@@ -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=<id>` 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.
+20 -2
View File
@@ -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://<host>: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
+45 -6
View File
@@ -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://<host>: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":"<hex>","v2":"<hex>"} + ENCRYPTION_ACTIVE_KEY_ID=v2)
# is for key rotation — see docs/ops/encryption-key-rotation.md. Keep these
# values stable and backed up: lose them and the encrypted columns are
# unrecoverable.
ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}"
API_TOKEN_HMAC_KEY: "${API_TOKEN_HMAC_KEY}"
NEXT_PUBLIC_APP_URL: "${NEXT_PUBLIC_APP_URL:-http://localhost:3000}"
APP_URL: "${APP_URL:-http://localhost:3000}"
# 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://<host>:3000 with no TLS reverse proxy) — otherwise the
# session cookie carries the `Secure` flag, the browser drops it over HTTP,
# and login silently fails. Unset → the flag tracks NODE_ENV=production
# (i.e. on); set to true to force it. Forwarded explicitly so an operator
# value in .env reaches the Node process (the `environment:` block is a
# whitelist — vars not listed here don't propagate even though
# docker-compose reads .env for `${VAR}` substitution at compose-up time).
SESSION_COOKIE_SECURE: "${SESSION_COOKIE_SECURE:-}"
# 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}"
+94 -8
View File
@@ -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:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>` — 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:<bucket-start-hour>` — v1.19.0 (iOS #34) hourly heart-rate bucket
carrying the hour's AVERAGE bpm as one PULSE row. `<bucket-start-hour>` 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:
+49
View File
@@ -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://<host>: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.
@@ -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"
}
]
}
]
}
+137
View File
@@ -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=<your openssl rand -hex 32>
ENCRYPTION_KEY=<your openssl rand -hex 32>
API_TOKEN_HMAC_KEY=<your openssl rand -hex 32>
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:<password>@<postgres-host>: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 `<password>` and `<postgres-host>` 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.
+64
View File
@@ -0,0 +1,64 @@
<?xml version="1.0"?>
<!--
HealthLog — Unraid Community Applications template.
CA templates describe ONE container. HealthLog needs PostgreSQL 16, so
install a PostgreSQL container FIRST (CA has community PostgreSQL templates),
create a `healthlog` database + user, then point DATABASE_URL below at it.
Full walkthrough: docs/self-hosting/unraid.md.
LAN installs serve plain HTTP (http://tower:3000). Keep SESSION_COOKIE_SECURE
set to false (the default in this template) or login will silently fail.
-->
<Container version="2">
<Name>HealthLog</Name>
<Repository>ghcr.io/mbombeck/healthlog:latest</Repository>
<Registry>https://github.com/MBombeck/HealthLog/pkgs/container/healthlog</Registry>
<Network>bridge</Network>
<MyIP/>
<Shell>sh</Shell>
<Privileged>false</Privileged>
<Support>https://github.com/MBombeck/HealthLog/issues</Support>
<Project>https://github.com/MBombeck/HealthLog</Project>
<Overview>
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.
</Overview>
<Category>Tools: Productivity: Other:</Category>
<WebUI>http://[IP]:[PORT:3000]/</WebUI>
<Icon>https://raw.githubusercontent.com/MBombeck/HealthLog/main/public/logo-512.png</Icon>
<ExtraParams>--security-opt no-new-privileges:true</ExtraParams>
<DonateText/>
<DonateLink/>
<Config Name="WebUI Port" Target="3000" Default="3000" Mode="tcp" Description="HTTP port HealthLog listens on." Type="Port" Display="always" Required="true" Mask="false">3000</Config>
<Config Name="DATABASE_URL" Target="DATABASE_URL" Default="postgresql://healthlog:CHANGE-ME@POSTGRES-HOST:5432/healthlog?schema=public" Mode="" Description="Connection string for your separately-installed PostgreSQL 16. Replace CHANGE-ME with the DB password and POSTGRES-HOST with the Postgres container IP or name." Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="ENCRYPTION_KEY" Target="ENCRYPTION_KEY" Default="" Mode="" Description="AES-256-GCM key for data at rest. Generate with: openssl rand -hex 32 (64-char hex). Back this up — losing it makes encrypted data unrecoverable." Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="API_TOKEN_HMAC_KEY" Target="API_TOKEN_HMAC_KEY" Default="" Mode="" Description="HMAC key for API token hashing. Generate with: openssl rand -hex 32 (64-char hex)." Type="Variable" Display="always" Required="true" Mask="true"/>
<Config Name="SESSION_COOKIE_SECURE" Target="SESSION_COOKIE_SECURE" Default="false" Mode="" Description="Set to false for LAN / plain-HTTP installs (http://tower:3000) or login fails. Set to true only behind an HTTPS reverse proxy." Type="Variable" Display="always" Required="true" Mask="false">false</Config>
<Config Name="NEXT_PUBLIC_APP_URL" Target="NEXT_PUBLIC_APP_URL" Default="http://tower:3000" Mode="" Description="Public URL users reach HealthLog at. Use the LAN URL (e.g. http://tower:3000) or your HTTPS domain behind a reverse proxy." Type="Variable" Display="always" Required="true" Mask="false"/>
<Config Name="APP_URL" Target="APP_URL" Default="http://tower:3000" Mode="" Description="Internal app base URL. Normally identical to NEXT_PUBLIC_APP_URL." Type="Variable" Display="always" Required="true" Mask="false"/>
<Config Name="ENCRYPTION_KEYS" Target="ENCRYPTION_KEYS" Default="" Mode="" Description="Optional. Key-rotation map, e.g. {&quot;v1&quot;:&quot;&lt;hex&gt;&quot;,&quot;v2&quot;:&quot;&lt;hex&gt;&quot;}. Leave empty unless rotating; use ENCRYPTION_KEY instead." Type="Variable" Display="advanced" Required="false" Mask="true"/>
<Config Name="ENCRYPTION_ACTIVE_KEY_ID" Target="ENCRYPTION_ACTIVE_KEY_ID" Default="" Mode="" Description="Optional. Active key id for the ENCRYPTION_KEYS map (e.g. v2). Leave empty when using ENCRYPTION_KEY." Type="Variable" Display="advanced" Required="false" Mask="false"/>
</Container>
+30 -8
View File
@@ -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": "<b>Welches Medikament eingenommen?</b>",
"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"
+30 -8
View File
@@ -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": "<b>Which medication did you take?</b>",
"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"
+30 -8
View File
@@ -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": "<b>¿Qué medicamento has tomado?</b>",
"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"
+30 -8
View File
@@ -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": "<b>Quel médicament as-tu pris ?</b>",
"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"
+30 -8
View File
@@ -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": "<b>Quale farmaco hai assunto?</b>",
"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"
+30 -8
View File
@@ -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": "<b>Który lek został przyjęty?</b>",
"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"
+1 -1
View File
@@ -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",
@@ -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-<unix>` 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 $$;
@@ -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';
@@ -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");
+115
View File
@@ -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-<unix>` 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-<unix>`).
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 {
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.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/*`
+13
View File
@@ -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;
+566 -24
View File
@@ -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<string> => {
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<string> => {
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)`,
@@ -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).
+14 -3
View File
@@ -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<ReturnType<typeof buildDashboardSummary>>
>,
@@ -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<AchievementsResult>,
user.id,
() => buildAchievementsResult(user, moduleMap),
@@ -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 () => {
File diff suppressed because it is too large. Load diff
@@ -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");
});
});
+41 -8
View File
@@ -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:<HK>:<YYYY-MM-DD>` (Steps,
* Active Energy, Sleep Duration, Walking/Running Distance,
* Flights Climbed);
* · v1.19.0 (iOS #34) hourly heart-rate buckets —
* `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start-hour>`
* 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<Response> {
continue;
}
// v1.19.0 (iOS #34) — go-forward aggregated heart-rate wire
// contract. A `stats:HKQuantityTypeIdentifierHeartRate:<hour>` 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,
@@ -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", () => {
@@ -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 },
});
},
@@ -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);
});
});
@@ -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();
});
});
+253 -15
View File
@@ -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<void> {
}
}
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<void> {
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) ||
+46 -9
View File
@@ -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 `<CoachConversation>`'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=<id>` 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 `<CoachConversation>`'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 (
<CoachConversation
surface="page"
autoFocusComposer
initialConversationId={deepLinkedId}
// Only auto-resolve most-recent when the entry did not pin a thread
// and did not ask for a fresh chat.
autoOpenMostRecent={deepLinkedId === null && rawC !== "new"}
/>
);
}
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)]"
>
<CoachConversation
surface="page"
autoFocusComposer
renderTitle={(title) => (
<h1 className="min-w-0 truncate text-sm font-semibold">{title}</h1>
)}
/>
{/* `useSearchParams` requires a Suspense boundary so the client-search
bailout never opts the whole route out of static optimisation. */}
<Suspense fallback={null}>
<CoachPageBody />
</Suspense>
</div>
);
}
+1 -43
View File
@@ -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 (
<nav aria-label="Breadcrumb" className="text-muted-foreground text-xs">
<ol className="flex items-center gap-1">
<li className="text-foreground font-medium">
{t("notifications.breadcrumbInbox")}
</li>
<li aria-hidden="true">
<ChevronRight className="h-3 w-3" />
</li>
<li>
<Link
href="/settings/notifications"
className="hover:text-foreground underline-offset-2 hover:underline"
>
{t("notifications.breadcrumbChannels")}
</Link>
</li>
</ol>
</nav>
);
}
/** 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 (
<div className="space-y-6">
<NotificationsBreadcrumb t={t} />
<h1 className="text-2xl font-bold tracking-tight">
{t("notifications.title")}
</h1>
@@ -185,7 +146,6 @@ export default function NotificationsPage() {
if (isError) {
return (
<div className="space-y-6">
<NotificationsBreadcrumb t={t} />
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("notifications.title")}
@@ -208,7 +168,6 @@ export default function NotificationsPage() {
if (activeChannels.length === 0) {
return (
<div className="space-y-6">
<NotificationsBreadcrumb t={t} />
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("notifications.title")}
@@ -237,7 +196,6 @@ export default function NotificationsPage() {
return (
<div className="space-y-6">
<NotificationsBreadcrumb t={t} />
<div>
<h1 className="text-2xl font-bold tracking-tight">
{t("notifications.title")}
+15 -1
View File
@@ -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"}
@@ -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<unknown>;
}) => {
(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("<HealthChart> — 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(
<I18nProvider initialLocale="en">
<HealthChart
types={["WEIGHT"]}
title="Weight"
unit="kg"
windowOverride="last30days"
preloadedSeries={preloadedSlice}
preloadedCoverageDays={31}
/>
</I18nProvider>,
);
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(
<I18nProvider initialLocale="en">
<HealthChart
types={["WEIGHT"]}
title="Weight"
unit="kg"
windowOverride="last90days"
preloadedSeries={preloadedSlice}
preloadedCoverageDays={31}
/>
</I18nProvider>,
);
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(
<I18nProvider initialLocale="en">
<HealthChart
types={["WEIGHT"]}
title="Weight"
unit="kg"
windowOverride="last30days"
preloadedSeries={preloadedSlice}
/>
</I18nProvider>,
);
await new Promise((resolve) => setTimeout(resolve, 0));
const measurementsUrl = fetchCalls.find((u) =>
u.includes("/api/measurements"),
);
expect(measurementsUrl).toBeDefined();
});
});
+48 -5
View File
@@ -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<string, MeasurementApiRow[]>;
/**
* 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`
@@ -37,33 +37,19 @@ const emptyHistory: CycleHistoryResponse = {
};
describe("<PredictionsPanel>", () => {
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(
<PredictionsPanel
prediction={basePrediction}
rawChartMode={false}
history={emptyHistory}
goal="AVOID_PREGNANCY"
/>,
);
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(
<PredictionsPanel
prediction={basePrediction}
rawChartMode={false}
history={emptyHistory}
goal="GENERAL_HEALTH"
/>,
);
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("–");
});
-32
View File
@@ -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 (
<p
data-slot="cycle-disclaimer"
className={cn(
"text-foreground/80 border-border bg-muted/40 rounded-md border-l-2 px-3 py-2 text-xs font-medium",
className,
)}
>
{text}
</p>
);
}
+57 -32
View File
@@ -194,7 +194,18 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) {
))}
</NativeSelect>
<p className="text-muted-foreground text-sm">
{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. */}
<a
href="https://pubmed.ncbi.nlm.nih.gov/17314078/"
target="_blank"
rel="noopener noreferrer"
className="text-foreground/80 hover:text-foreground underline underline-offset-2"
>
{t("cycle.settings.secondarySymptomCitation")}
</a>
</p>
</div>
@@ -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. */}
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("cycle.settings.reminders")}
</p>
<p className="text-muted-foreground text-xs">
{t("cycle.settings.remindersDescription")}
</p>
</div>
<Button variant="outline" size="sm" asChild className="shrink-0">
<Link href="/notifications">
{t("cycle.settings.remindersLink")}
</Link>
</Button>
</div>
<LinkRow
title={t("cycle.settings.reminders")}
description={t("cycle.settings.remindersDescription")}
linkLabel={t("cycle.settings.remindersLink")}
/>
{/* 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" ? (
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("cycle.settings.fertileReminder")}
</p>
<p className="text-muted-foreground text-xs">
{t("cycle.settings.fertileReminderDescription")}
</p>
</div>
<Button variant="outline" size="sm" asChild className="shrink-0">
<Link href="/notifications">
{t("cycle.settings.remindersLink")}
</Link>
</Button>
</div>
<LinkRow
title={t("cycle.settings.fertileReminder")}
description={t("cycle.settings.fertileReminderDescription")}
linkLabel={t("cycle.settings.remindersLink")}
/>
) : null}
{/* Cycle data export + delete live with the main Settings → Export and
@@ -306,7 +297,7 @@ function ToggleRow({
<Label htmlFor={id} className="text-sm">
{label}
</Label>
<p className="text-muted-foreground text-xs">{description}</p>
<p className="text-muted-foreground text-sm">{description}</p>
</div>
<Switch
id={id}
@@ -318,6 +309,40 @@ function ToggleRow({
);
}
/**
* A "label + description → go to notifications" row. The long fertile-window
* label collided with its button when both shared one tight flex line; here the
* row stacks on the narrowest screens (button on its own line, full-width tap
* target) and reflows to a clean inline layout from `sm` up, where the text
* column can wrap freely while the button stays top-aligned and never shrinks.
*/
function LinkRow({
title,
description,
linkLabel,
}: {
title: string;
description: string;
linkLabel: string;
}) {
return (
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<div className="min-w-0 space-y-0.5">
<p className="text-sm font-medium">{title}</p>
<p className="text-muted-foreground text-sm">{description}</p>
</div>
<Button
variant="outline"
size="sm"
asChild
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
>
<Link href="/notifications">{linkLabel}</Link>
</Button>
</div>
);
}
function PriorField({
id,
label,
+8 -20
View File
@@ -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 (
<div className="space-y-6">
@@ -162,8 +150,13 @@ export function CycleView() {
{/* Desktop: ring (left) + tabs (right). Single column below lg. */}
<div className="grid gap-6 lg:grid-cols-[minmax(0,22rem)_minmax(0,1fr)] lg:items-start">
{/* Wheel + compact phase card — grouped so they stick together. */}
<div className="flex flex-col gap-4 lg:sticky lg:top-6">
{/* 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. */}
<div className="flex flex-col gap-4 lg:sticky lg:top-6 lg:mt-15">
{/* 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() {
)}
</CardContent>
</Card>
{!loading && !calendarError ? (
<CycleDisclaimer text={disclaimerText} />
) : null}
</TabsContent>
<TabsContent value="predictions" className="mt-4 space-y-4">
@@ -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}
/>
<CycleHistoryChart history={history.data} animate={play} />
<BbtChart
+3 -22
View File
@@ -11,7 +11,6 @@ import {
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useTranslations } from "@/lib/i18n/context";
import { CycleDisclaimer } from "./cycle-disclaimer";
import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens";
import type { CyclePrediction, CycleHistoryResponse } from "./types";
@@ -21,8 +20,9 @@ import type { CyclePrediction, CycleHistoryResponse } from "./types";
* Shows the next-period RANGE (a window, with a confidence pill), the fertile
* window (already goal-gated server-side — the API nulls it unless TTC, so we
* just render what is present), the "still learning" state for < 3 cycles or
* raw-chart mode, the cycle-history stats, and the fixed non-medical
* disclaimer. Never a single dated next-period claim — always the range.
* raw-chart mode, and the cycle-history stats. Never a single dated next-period
* claim — always the range. The non-medical disclaimer is set once at the
* cycle-tracking on-ramp, not repeated beneath every prediction surface.
*/
function formatDate(d: string): string {
@@ -51,32 +51,15 @@ export interface PredictionsPanelProps {
prediction: CyclePrediction | null;
rawChartMode: boolean;
history: CycleHistoryResponse | undefined;
/** Fallback disclaimer when no prediction carries one. */
fallbackDisclaimer?: string;
/** The active cycle goal — only AVOID_PREGNANCY surfaces the (deliberate)
* contraceptive-safety caveat here; every other goal drops the generic
* "predictions are estimates" line so it does not repeat under every graph. */
goal?: string;
}
export function PredictionsPanel({
prediction,
rawChartMode,
history,
fallbackDisclaimer,
goal,
}: PredictionsPanelProps) {
const { t } = useTranslations();
// Only the AVOID_PREGNANCY goal shows a disclaimer here: it surfaces the
// fertile window, so the stronger "not a contraceptive method" caveat is a
// deliberate medical safety note. For all other goals the generic
// estimate-caveat is suppressed (it already lives once with the data).
const disclaimer =
goal === "AVOID_PREGNANCY"
? (prediction?.disclaimer ?? fallbackDisclaimer ?? "")
: "";
return (
<div className="space-y-4">
<Card>
@@ -129,8 +112,6 @@ export function PredictionsPanel({
) : null}
</>
)}
{disclaimer ? <CycleDisclaimer text={disclaimer} /> : null}
</CardContent>
</Card>
@@ -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";
/**
* `<BriefingSpotlight>` 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<typeof BriefingSpotlight>,
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<BriefingSpotlight {...props} />
</I18nProvider>,
);
}
function briefing(overrides: Partial<DailyBriefing> = {}): DailyBriefing {
return {
paragraph: "x",
signalsOfDay: null,
keyFindings: [],
...overrides,
};
}
describe("<BriefingSpotlight>", () => {
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);
});
});
@@ -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<DailyBriefingKeyFinding["tone"], string> = {
good: "bg-success",
watch: "bg-warning",
info: "bg-info",
};
const TONE_TEXT_CLASSNAME: Record<DailyBriefingKeyFinding["tone"], string> = {
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 (
<div data-slot="dashboard-briefing-spotlight" className="space-y-2">
<div className="flex items-center justify-between gap-3">
<p className="text-muted-foreground text-[11px] font-semibold tracking-wide uppercase">
{t("dashboard.hero.briefing.title")}
</p>
<Link
href="/insights"
data-slot="dashboard-briefing-spotlight-link"
className="text-muted-foreground hover:text-foreground text-[11px] underline underline-offset-2"
>
{t("dashboard.hero.briefing.viewAll")}
</Link>
</div>
<div className="space-y-2">
{rows.map((row, index) => {
const Icon = METRIC_ICON[row.sourceMetric];
return (
<ListRow
key={`${row.sourceMetric}-${index}`}
asChild
data-slot="dashboard-briefing-spotlight-row"
data-metric={row.sourceMetric}
className={cn(
"border-border/60 bg-card/40 relative flex items-start gap-3",
"hover:bg-accent/40 transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
)}
>
<Link href="/insights">
<span
aria-hidden="true"
className={cn(
"absolute top-3 bottom-3 left-0 w-[3px] rounded-r",
TONE_BAR_CLASSNAME[row.tone],
)}
/>
<Icon
className={cn(
"mt-0.5 h-4 w-4 shrink-0",
TONE_TEXT_CLASSNAME[row.tone],
)}
aria-hidden="true"
/>
<div className="flex min-w-0 flex-1 items-start justify-between gap-3">
<p className="text-foreground text-sm leading-snug font-medium">
{stripChartTokens(row.headline)}
</p>
{row.delta ? (
<span
data-slot="dashboard-briefing-spotlight-delta"
className={cn(
"shrink-0 text-xs font-semibold tabular-nums",
TONE_TEXT_CLASSNAME[row.tone],
)}
>
{row.delta}
</span>
) : null}
</div>
</Link>
</ListRow>
);
})}
</div>
</div>
);
}
@@ -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`. */}
<RestModeBanner annotation={snapshot.healthScore?.restMode ?? null} />
{/* 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. */}
<BriefingSpotlight
briefing={snapshot.briefing}
briefingState={snapshot.briefingState}
briefingStale={snapshot.briefingStale}
/>
</div>
</section>
);
@@ -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<Record<string, unknown>>;
return (props: Record<string, unknown>) => <Comp {...props} />;
},
};
});
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: null, isLoading: false }),
useMutation: () => ({
@@ -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
* `<CoachConversation>` 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(
<I18nProvider initialLocale="en">
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
describe("<CoachConversation> page deep-link (#67)", () => {
it("opens a specific thread when initialConversationId is set", () => {
const html = render(
<CoachConversation surface="page" initialConversationId="older" />,
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(
<CoachConversation surface="page" autoOpenMostRecent />,
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(
<CoachConversation surface="page" autoOpenMostRecent />,
client,
);
expect(html).toContain('data-slot="coach-hero"');
});
it("does not auto-open when autoOpenMostRecent is off (new chat)", () => {
const html = render(<CoachConversation surface="page" />, 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"');
});
});
@@ -222,6 +222,65 @@ describe("<CoachInput>", () => {
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(
<CoachInput value="" onChange={() => {}} 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(
<CoachInput
value=""
onChange={() => {}}
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(
/<a[^>]*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(
<CoachInput
value=""
onChange={() => {}}
onSubmit={() => {}}
showHub
onNewChat={() => {}}
onOpenHistory={() => {}}
/>,
);
const actions = html.match(
/<button[^>]*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
@@ -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 = (
<CoachInput
value={inputValue}
@@ -302,6 +362,9 @@ export function CoachConversation({
? t("insights.coach.guided.answerPlaceholder")
: undefined
}
showHub={surface === "page"}
onNewChat={handleNewChat}
onOpenHistory={() => 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 = (
<div>
{/* 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 ? (
<SelfContextAdoptOffer
question={pendingAdopt.question}
answer={pendingAdopt.answer}
onDismiss={() => 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 ? (
<GuidedQuestionsCard
count={pendingQuestions.length}
disabled={send.isStreaming || dismissQuestions.isPending}
onStart={() =>
dispatchGuided({
type: "START",
questions: pendingQuestions,
})
}
onLater={() => dispatchGuided({ type: "LATER" })}
onDismissAll={() =>
dismissQuestions.mutate(undefined, {
onSuccess: () => dispatchGuided({ type: "LATER" }),
})
}
/>
) : null}
{composerNode}
</div>
);
// 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 `<HistoryRail>` + conversation-list
// query; respects `prefers-reduced-motion` (the Sheet primitive disables
// its slide there). Selecting a conversation closes the drawer.
const historyDrawer = (
<Sheet open={historyDrawerOpen} onOpenChange={setHistoryDrawerOpen}>
<SheetContent
side="left"
data-slot="coach-history-drawer"
className="w-[88vw] max-w-[340px] p-0"
>
<SheetHeader className="border-border/70 border-b p-3">
<SheetTitle className="text-sm">
{t("insights.coach.historyTitle")}
</SheetTitle>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col">
<HistoryRail
activeId={currentConversationId}
hideHeading
onSelect={(id) => {
// v1.16.5 — switching conversations drops the guided session;
// unanswered questions stay pending.
setCurrentConversationId(id);
setHistoryDrawerOpen(false);
setPendingAdopt(null);
dispatchGuided({ type: "RESET" });
}}
/>
</div>
</SheetContent>
</Sheet>
);
// 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 (
<div
data-slot="coach-conversation"
data-variant={surface}
className={cn("flex min-h-0 flex-1 flex-col", className)}
>
{heroActive ? (
<CoachHero composer={composerNode} />
) : (
<>
<div className="flex min-h-0 flex-1 flex-col">
<MessageThread
conversation={conversation ?? null}
streaming={send.streaming}
optimisticUser={send.optimisticUser}
interleaved={interleaved}
/>
</div>
{/* 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. */}
<div
data-slot="coach-page-composer"
className="shrink-0 px-4 pt-2 pb-3 sm:px-6 sm:pb-4"
>
<div className="mx-auto w-full max-w-2xl">{composerStack}</div>
</div>
</>
)}
{historyDrawer}
</div>
);
}
return (
<div
data-slot="coach-conversation"
@@ -398,94 +587,24 @@ export function CoachConversation({
{trailingHeaderActions}
</header>
{heroActive ? (
<CoachHero composer={composerNode} />
) : (
<CoachDrawerBody
historyOpen={historyRailOpen}
onToggleHistory={
surface === "page"
? () => setHistoryRailOpen((open) => !open)
: undefined
}
historyRail={
surface === "page" ? (
<HistoryRail
activeId={currentConversationId}
// v1.18.1 — the `<aside>` band already renders the
// "Conversations" <h2>; suppress the rail's own <h3> 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={
<MessageThread
conversation={conversation ?? null}
streaming={send.streaming}
optimisticUser={send.optimisticUser}
interleaved={interleaved}
/>
}
composer={
<div>
{/* 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 ? (
<SelfContextAdoptOffer
question={pendingAdopt.question}
answer={pendingAdopt.answer}
onDismiss={() => 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 ? (
<GuidedQuestionsCard
count={pendingQuestions.length}
disabled={send.isStreaming || dismissQuestions.isPending}
onStart={() =>
dispatchGuided({
type: "START",
questions: pendingQuestions,
})
}
onLater={() => dispatchGuided({ type: "LATER" })}
onDismissAll={() =>
dismissQuestions.mutate(undefined, {
onSuccess: () => dispatchGuided({ type: "LATER" }),
})
}
/>
) : null}
{composerNode}
</div>
}
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. */}
<CoachDrawerBody
thread={
<MessageThread
conversation={conversation ?? null}
streaming={send.streaming}
optimisticUser={send.optimisticUser}
interleaved={interleaved}
/>
}
composer={composerStack}
onHistoryClick={onRequestFullView ?? (() => setHistoryTrayOpen(true))}
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
/>
<MobileRailTray
historyOpen={historyTrayOpen}
@@ -9,10 +9,25 @@ import {
useState,
useSyncExternalStore,
} from "react";
import { Loader2, Mic, Send, Square } from "lucide-react";
import Link from "next/link";
import {
Loader2,
MessagesSquare,
Mic,
Plus,
Send,
Settings,
Square,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -44,6 +59,15 @@ import { useTranslations } from "@/lib/i18n/context";
* On a browser without the Web Speech API (or during SSR) it renders
* DISABLED with an explanatory tooltip rather than vanishing or sitting as
* a dead control that silently does nothing on tap.
*
* v1.18.11 (W11): the composer is the conversation's control hub on the
* full-page Coach surface — ChatGPT-style. When `showHub` is set it grows a
* second action row INSIDE the rounded field: a leading `+` actions menu
* (new chat + open conversations), a settings deep-link, the dictation mic,
* and the send / stop control on the same baseline. The drawer surface omits
* `showHub` and keeps the single-row composer (its own header carries new
* chat + settings), so this is purely additive — the existing composer
* behaviour and markup are untouched when the hub is off.
*/
export interface CoachInputProps {
value: string;
@@ -76,6 +100,22 @@ export interface CoachInputProps {
* while a question is live. Falls back to the stock composer copy.
*/
placeholder?: string;
/**
* v1.18.11 — mount the control-hub action row (leading `+` menu +
* settings link) inside the composer card. The page surface sets this;
* the drawer leaves it off and keeps the single-row composer.
*/
showHub?: boolean;
/**
* v1.18.11 — start a fresh conversation. Wired into the `+` actions
* menu. Required when `showHub` is set.
*/
onNewChat?: () => 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<HTMLTextAreaElement | null>(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 = (
<Button
type="button"
size="icon"
variant="ghost"
onClick={toggleDictation}
disabled={disabled || !voiceSupported}
data-slot="coach-input-mic"
data-listening={listening ? "true" : undefined}
data-unsupported={!voiceSupported ? "true" : undefined}
aria-label={micLabel}
aria-pressed={voiceSupported ? listening : undefined}
title={micLabel}
className={cn(
"size-11 shrink-0 rounded-xl transition-colors sm:size-9",
listening
? "text-dracula-pink bg-dracula-pink/10 hover:text-dracula-pink"
: "text-muted-foreground hover:text-foreground",
)}
>
<Mic
className={cn(
"size-4",
listening && "animate-pulse motion-reduce:animate-none",
)}
aria-hidden="true"
/>
</Button>
);
// 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.
<Button
type="button"
size="icon"
variant="outline"
onClick={onCancel}
data-slot="coach-input-stop"
aria-label={t("insights.coach.stop")}
title={t("insights.coach.stop")}
className="size-11 shrink-0 rounded-xl sm:size-9"
>
<Square className="size-3.5 fill-current" aria-hidden="true" />
</Button>
) : (
<Button
type="submit"
size="icon"
disabled={!canSubmit}
data-slot="coach-input-send"
aria-label={t("insights.coach.send")}
title={t("insights.coach.send")}
className="size-11 shrink-0 rounded-xl sm:size-9"
>
{isStreaming ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<Send className="size-4" aria-hidden="true" />
)}
</Button>
);
return (
<form
data-slot="coach-input"
@@ -343,48 +459,26 @@ export function CoachInput({
className="flex flex-col"
>
{/* 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. */}
<div
className={cn(
"border-border/60 bg-muted/40 group rounded-2xl border",
"flex items-end gap-1.5 p-1.5 shadow-sm transition-colors",
"shadow-sm transition-colors",
"focus-within:border-dracula-purple/50 focus-within:ring-dracula-purple/15 focus-within:bg-background focus-within:ring-2",
showHub ? "flex flex-col gap-1 p-2" : "flex items-end gap-1.5 p-1.5",
)}
>
{/* 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. */}
<Button
type="button"
size="icon"
variant="ghost"
onClick={toggleDictation}
disabled={disabled || !voiceSupported}
data-slot="coach-input-mic"
data-listening={listening ? "true" : undefined}
data-unsupported={!voiceSupported ? "true" : undefined}
aria-label={micLabel}
aria-pressed={voiceSupported ? listening : undefined}
title={micLabel}
className={cn(
"size-11 shrink-0 rounded-xl transition-colors sm:size-9",
listening
? "text-dracula-pink bg-dracula-pink/10 hover:text-dracula-pink"
: "text-muted-foreground hover:text-foreground",
)}
>
<Mic
className={cn(
"size-4",
listening && "animate-pulse motion-reduce:animate-none",
)}
aria-hidden="true"
/>
</Button>
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}
<textarea
id={inputId}
ref={textareaRef}
@@ -416,7 +510,7 @@ export function CoachInput({
"min-w-0 flex-1 resize-none bg-transparent text-base leading-relaxed outline-none sm:text-sm",
// Centre the single-line state against the send button so
// the placeholder and the icon share a baseline.
"px-2 py-1.5",
showHub ? "px-2 pt-1.5 pb-0.5" : "px-2 py-1.5",
"max-h-[9.5rem] overflow-auto",
// v1.18.7 — calm, thin scrollbar inside the composer when
// dictation overruns 6 lines (see also the thread/history
@@ -425,42 +519,68 @@ export function CoachInput({
"placeholder:text-muted-foreground disabled:opacity-60",
)}
/>
{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.
<Button
type="button"
size="icon"
variant="outline"
onClick={onCancel}
data-slot="coach-input-stop"
aria-label={t("insights.coach.stop")}
title={t("insights.coach.stop")}
className="size-11 shrink-0 rounded-xl sm:size-9"
>
<Square className="size-3.5 fill-current" aria-hidden="true" />
</Button>
{showHub ? (
// v1.18.11 — the control-hub action row (page surface). Leading
// `+` actions menu (new chat + open conversations) and a settings
// deep-link on the left; the mic + send / stop on the right —
// all on one baseline, ChatGPT-style.
<div data-slot="coach-input-hub" className="flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
disabled={disabled}
data-slot="coach-input-actions"
aria-label={t("insights.coach.actionsMenu")}
title={t("insights.coach.actionsMenu")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0 rounded-xl sm:size-9"
>
<Plus className="size-5 sm:size-4" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="top" className="w-52">
<DropdownMenuItem
data-slot="coach-input-action-new-chat"
onSelect={() => onNewChat?.()}
>
<Plus className="size-4" aria-hidden="true" />
{t("insights.coach.newChat")}
</DropdownMenuItem>
<DropdownMenuItem
data-slot="coach-input-action-history"
onSelect={() => onOpenHistory?.()}
>
<MessagesSquare className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* The Coach preferences live in Settings → AI (one place for
model + behaviour); the gear deep-links there. */}
<Button
asChild
size="icon"
variant="ghost"
data-slot="coach-input-settings"
className="text-muted-foreground hover:text-foreground size-11 shrink-0 rounded-xl sm:size-9"
>
<Link
href="/settings/ai"
aria-label={t("insights.coach.settingsAriaLabel")}
title={t("insights.coach.settingsAriaLabel")}
>
<Settings className="size-4" aria-hidden="true" />
</Link>
</Button>
<div className="ml-auto flex items-center gap-1">
{micButton}
{sendButton}
</div>
</div>
) : (
<Button
type="submit"
size="icon"
disabled={!canSubmit}
data-slot="coach-input-send"
aria-label={t("insights.coach.send")}
title={t("insights.coach.send")}
className="size-11 shrink-0 rounded-xl sm:size-9"
>
{isStreaming ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<Send className="size-4" aria-hidden="true" />
)}
</Button>
sendButton
)}
</div>
</form>
@@ -6,11 +6,11 @@ import { MoodTagBreakdown } from "../mood-tag-breakdown";
import { MoodStructuredTagBreakdown } from "../mood-structured-tag-breakdown";
/**
* v1.16.8 — the tag-breakdown bars carry the same muted treatment as
* the mood Recharts charts (`fillOpacity={0.55}` on distribution /
* weekday / time-of-day). The CSS bars rendered the raw `--dracula-*`
* hues at full opacity, which made the two tag cards shout next to the
* matte charts above them. Pinned: every fill bar ships `opacity-55`.
* v1.19.0 — the muted mood treatment (`opacity-55` on the tag bars,
* `fillOpacity={0.55}` on the Recharts bars) read as a rendering glitch,
* so the bars now paint at full saturation in their level hue. This guard
* keeps the dimming from creeping back: every fill bar carries the level
* colour at full opacity.
*/
function render(node: React.ReactNode) {
@@ -19,8 +19,8 @@ function render(node: React.ReactNode) {
);
}
describe("mood tag bars — muted palette (v1.16.8)", () => {
it("free-text tag breakdown bars render at the shared chart opacity", () => {
describe("mood tag bars — full-saturation palette (v1.19.0)", () => {
it("free-text tag breakdown bars render at full opacity", () => {
const html = render(
<MoodTagBreakdown
tags={[
@@ -32,14 +32,14 @@ describe("mood tag bars — muted palette (v1.16.8)", () => {
const fills = html.match(/class="[^"]*absolute inset-y-0 left-0[^"]*"/g);
expect(fills).toHaveLength(2);
for (const fill of fills ?? []) {
expect(fill).toContain("opacity-55");
expect(fill).not.toContain("opacity-55");
}
// The level hue itself stays a Dracula token.
expect(html).toContain("var(--dracula-red)");
expect(html).toContain("var(--dracula-green)");
});
it("structured tag breakdown bars render at the shared chart opacity", () => {
it("structured tag breakdown bars render at full opacity", () => {
const html = render(
<MoodStructuredTagBreakdown
tags={[
@@ -56,6 +56,6 @@ describe("mood tag bars — muted palette (v1.16.8)", () => {
);
const fills = html.match(/class="[^"]*absolute inset-y-0 left-0[^"]*"/g);
expect(fills).toHaveLength(1);
expect(fills?.[0]).toContain("opacity-55");
expect(fills?.[0]).not.toContain("opacity-55");
});
});
@@ -111,13 +111,8 @@ export function MoodDistributionChart({
// than a missing slot. The coloured bar paints over the track
// for non-empty levels.
background={{ fill: "var(--secondary)", opacity: 0.35, radius: 3 }}
// v1.15.14 — the saturated full-bleed mood hues read as
// "erschlagend"; soften every bar to a muted tint of its level
// colour. The hue (= mood-level semantics) still distinguishes the
// bands, but the fill sits calmly over the card rather than
// shouting. Scoped to this chart only — the shared `--dracula-*`
// mood tokens (heatmap, legend) are untouched.
fillOpacity={0.55}
// v1.19.0 — bars paint at full saturation in their level colour;
// the earlier muted tint read as a rendering glitch.
>
{data.map((row) => (
<Cell
@@ -56,11 +56,10 @@ export function MoodStructuredTagBreakdown({
{row.label ?? t(row.labelKey)}
</span>
<div className="bg-secondary relative h-3 flex-1 overflow-hidden rounded-full">
{/* v1.16.8 — `opacity-55` matches the `fillOpacity={0.55}`
the mood Recharts bars carry (distribution / weekday /
time-of-day), so the tag bars read equally matte. */}
{/* v1.19.0 — full-saturation fill, matching the mood Recharts
bars (the earlier muted tint read as a rendering glitch). */}
<div
className="absolute inset-y-0 left-0 rounded-full opacity-55"
className="absolute inset-y-0 left-0 rounded-full"
style={{
width: `${(row.count / maxCount) * 100}%`,
backgroundColor: colorForScore(row.avgScore),
@@ -41,11 +41,10 @@ export function MoodTagBreakdown({ tags }: { tags: MoodTagRow[] }) {
{row.tag}
</span>
<div className="bg-secondary relative h-3 flex-1 overflow-hidden rounded-full">
{/* v1.16.8 — `opacity-55` matches the `fillOpacity={0.55}`
the mood Recharts bars carry (distribution / weekday /
time-of-day), so the tag bars read equally matte. */}
{/* v1.19.0 — full-saturation fill, matching the mood Recharts
bars (the earlier muted tint read as a rendering glitch). */}
<div
className="absolute inset-y-0 left-0 rounded-full opacity-55"
className="absolute inset-y-0 left-0 rounded-full"
style={{
width: `${(row.count / maxCount) * 100}%`,
backgroundColor: colorForScore(row.avgScore),
@@ -136,11 +136,9 @@ export function MoodWeekdayChart({ weekday }: { weekday: MoodWeekdayRow[] }) {
];
}}
/>
{/* v1.15.14 — soften the saturated mood hues to a muted tint
(the level semantics still distinguish the bars; the fill no
longer shouts). Scoped here — the shared `--dracula-*` mood
tokens used by the heatmap/legend stay untouched. */}
<Bar dataKey="value" radius={[3, 3, 0, 0]} fillOpacity={0.55}>
{/* v1.19.0 — bars paint at full saturation in their level colour;
the earlier muted tint read as a rendering glitch. */}
<Bar dataKey="value" radius={[3, 3, 0, 0]}>
{data.map((row) => (
<Cell
key={row.weekday}
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, type ComponentProps } from "react";
import { ChevronDown, ChevronUp, ExternalLink } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useTranslations } from "@/lib/i18n/context";
@@ -9,8 +9,10 @@ import type {
InsightRecommendation,
InsightRecommendationRationale,
} from "@/lib/ai/types";
import { HealthChart } from "@/components/charts/health-chart";
import { MoodChart } from "@/components/charts/mood-chart";
import dynamic from "next/dynamic";
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { importWithRetry } from "@/lib/retry-import";
import { getMedicalReferenceById } from "@/lib/ai/medical-references";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import type { Locale } from "@/lib/i18n/config";
@@ -21,6 +23,40 @@ import {
} from "./recommendation-feedback";
import { ConfidenceMeter } from "./confidence-meter";
/**
* v1.18.11 (W5 perf) — route both rationale mini-charts through
* `next/dynamic` so recharts no longer rides the main insights surface's
* first-load JS. Each chart only paints inside an expanded rationale block,
* so deferring it costs nothing visible and matches the loading skeleton
* the canonical `health-chart-dynamic.tsx` boundary uses (`mini` here,
* since both charts render with the `mini` prop in the rationale card).
*/
const HealthChartLazy = dynamic(
() =>
importWithRetry(() => import("@/components/charts/health-chart")).then(
(mod) => ({ default: mod.HealthChart }),
),
{ ssr: false, loading: () => <ChartSkeleton mini /> },
);
const HealthChart = (props: ComponentProps<typeof HealthChartLazy>) => (
<ChartErrorBoundary>
<HealthChartLazy {...props} />
</ChartErrorBoundary>
);
const MoodChartLazy = dynamic(
() =>
importWithRetry(() => import("@/components/charts/mood-chart")).then(
(mod) => ({ default: mod.MoodChart }),
),
{ ssr: false, loading: () => <ChartSkeleton mini /> },
);
const MoodChart = (props: ComponentProps<typeof MoodChartLazy>) => (
<ChartErrorBoundary>
<MoodChartLazy {...props} />
</ChartErrorBoundary>
);
/**
* Collapsible recommendation card with severity badge, confidence
* meter, and an expandable rationale block (window / compared-to /
+28 -4
View File
@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
import type { ComponentProps } from "react";
import { Moon } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
@@ -10,12 +11,35 @@ import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import {
SleepStageStackedBar,
type SleepStageBreakdown,
} from "./sleep-stage-stacked-bar";
import dynamic from "next/dynamic";
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { importWithRetry } from "@/lib/retry-import";
import type { SleepStageBreakdown } from "./sleep-stage-stacked-bar";
import { SleepDurationChart } from "./sleep-duration-chart";
// v1.18.11 (W5 perf) — the stage stacked bar is the only recharts consumer
// on this page (the duration chart already routes through
// `health-chart-dynamic`). Defer it through `next/dynamic` so recharts is
// off `/insights/sleep`'s first-load JS; the `<ChartSkeleton>` loading shell
// matches the card the bar paints so the layout stays stable.
const SleepStageStackedBarLazy = dynamic(
() =>
importWithRetry(() => import("./sleep-stage-stacked-bar")).then((mod) => ({
default: mod.SleepStageStackedBar,
})),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
function SleepStageStackedBar(
props: ComponentProps<typeof SleepStageStackedBarLazy>,
) {
return (
<ChartErrorBoundary>
<SleepStageStackedBarLazy {...props} />
</ChartErrorBoundary>
);
}
/**
* v1.4.25 W4c — Sleep insights composition.
*
@@ -80,7 +80,7 @@ describe("<ChronotypeSection>", () => {
expect(html).not.toContain('data-slot="chronotype-error"');
});
it("renders the large band treatment + midpoint clock on a settled DTO", () => {
it("renders the band value + midpoint clock on a settled DTO", () => {
rhythmMock.mockReturnValue({
data: READY_DTO,
isLoading: false,
@@ -90,8 +90,10 @@ describe("<ChronotypeSection>", () => {
expect(html).toContain('data-slot="chronotype-band"');
expect(html).toContain("Evening type");
expect(html).toContain("05:41");
// The section heading carries the chronotype title.
expect(html).toContain('data-slot="section-heading"');
// v1.19.0 — the duplicate standalone section heading is gone; the single
// chronotype title now lives inside the card, uniform with the debt tile.
expect(html).not.toContain('data-slot="section-heading"');
expect(html).toContain('data-slot="chronotype-card"');
});
it("renders nothing when disabled", () => {
@@ -21,16 +21,16 @@ const PARTIAL_DEBT: SleepDebtDto = {
debtMinutes: 0,
needMinutes: 420,
nightsCounted: 3,
windowNights: 14,
nightsUntilReady: 4,
windowNights: 5,
nightsUntilReady: 1,
};
const READY_DEBT: SleepDebtDto = {
state: "ready",
debtMinutes: 600,
debtMinutes: 60,
needMinutes: 420,
nightsCounted: 10,
windowNights: 14,
nightsCounted: 5,
windowNights: 5,
nightsUntilReady: 0,
};
@@ -65,11 +65,11 @@ describe("<SleepDebtCard>", () => {
expect(html).not.toContain("short");
});
it("asserts the cumulative debt in the ready state", () => {
it("asserts the running debt balance in the ready state", () => {
const html = render(<SleepDebtCard debt={READY_DEBT} />);
// 600 min = 10h 0m short.
expect(html).toContain("10h 0m short");
expect(html).toContain("14 nights");
// 60 min = 1h 0m short — the small, recovering balance a normal sleeper sees.
expect(html).toContain("1h 0m short");
expect(html).toContain("5 nights");
});
it("inherits the standard Card rhythm (no compact override)", () => {
@@ -110,10 +110,11 @@ describe("<ChronotypeCard>", () => {
expect(html).toContain("04:30");
});
it("v1.18.7 W-D — renders the band as the large hero label, not a corner readout", () => {
it("v1.19.0 — renders the band as the bold primary value, uniform with the debt tile", () => {
const html = render(<ChronotypeCard chronotype={READY_CHRONO} />);
// The reworked bottom treatment leads with a large band label + the
// natural-midpoint headline. The retired top-right corner readout is gone.
// The band is the primary value at the SAME text-2xl scale the debt tile
// uses for its hours figure; the natural-midpoint clock is the secondary
// line. The retired top-right corner readout is gone.
expect(html).not.toContain('data-slot="chronotype-corner"');
expect(html).toContain('data-slot="chronotype-band"');
expect(html).toContain("Intermediate type");
@@ -42,10 +42,10 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
const READY_DTO: SleepRhythmDto = {
sleepDebt: {
state: "ready",
debtMinutes: 600,
debtMinutes: 60,
needMinutes: 420,
nightsCounted: 10,
windowNights: 14,
nightsCounted: 5,
windowNights: 5,
nightsUntilReady: 0,
},
chronotype: {
@@ -97,8 +97,8 @@ describe("<SleepRhythmSection>", () => {
isError: false,
});
const html = render(<SleepRhythmSection enabled />);
expect(html).toContain("10h 0m short");
// The chronotype band lives in the separate bottom section now, never here.
expect(html).toContain("1h 0m short");
// The chronotype band lives in the separate sibling tile, never here.
expect(html).not.toContain("Intermediate type");
expect(html).not.toContain('data-slot="chronotype-band"');
});
@@ -28,12 +28,16 @@ function hoursMinutes(totalMinutes: number): {
/**
* v1.17.0 — chronotype card (MCTQ MSF / MSFsc band + social jetlag).
*
* v1.18.7 W-D — reworked from a small corner-label card into the PROMINENT
* bottom treatment of the Sleep view: a LARGE band label is the hero, with the
* natural sleep-midpoint clock beneath it, and an "advanced" disclosure that
* reveals social jetlag + the sleep-debt-corrected MSFsc. While `learning` it
* keeps the calm "still learning your rhythm — N of M nights" copy and asserts
* NO band. Warm, grounded copy; mobile-first.
* v1.19.0 — brought to the SAME tile shape as `<SleepDebtCard>` so the two sit
* uniform in the shared grid row: identical card chrome, an icon + `text-base`
* `CardTitle` header, then a `space-y-0.5` value block with a bold primary value
* (`text-2xl font-semibold`) and one secondary `text-xs muted` line. The primary
* value is the chronotype BAND (a label, the slot the debt tile fills with an
* hours figure); the concrete companion — the natural sleep midpoint clock — is
* the secondary line, so both tiles read "bold value + one grounded line" at the
* same scale. The "advanced" disclosure (social jetlag + sleep-debt-corrected
* MSFsc) stays. While `learning` it keeps the calm "still learning your rhythm —
* N of M nights" copy and asserts NO band. Warm, grounded copy; mobile-first.
*/
export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
const { t } = useTranslations();
@@ -78,15 +82,13 @@ export function ChronotypeCard({ chronotype }: { chronotype: ChronotypeDto }) {
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* The band is the hero of the bottom treatment — a large label the
eye lands on first, with the natural sleep-midpoint clock as the
grounding caption beneath it. */}
<div className="space-y-1" data-slot="chronotype-band">
<p className="text-foreground text-2xl font-semibold tracking-tight sm:text-3xl">
{bandLabel}
</p>
{/* Uniform with the sleep-debt tile: a bold primary value (the band) at
the same scale, with the concrete companion (natural sleep midpoint
clock) as the secondary muted line. */}
<div className="space-y-0.5" data-slot="chronotype-band">
<p className="text-2xl font-semibold">{bandLabel}</p>
{msfClock && (
<p className="text-muted-foreground text-sm">
<p className="text-muted-foreground text-xs">
{t("insights.sleep.chronotype.midpointHeadline", {
clock: msfClock,
})}
@@ -1,28 +1,28 @@
"use client";
import { Clock } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { SectionHeading } from "@/components/insights/section-heading";
import { useSleepRhythm } from "./use-sleep-rhythm";
import { ChronotypeCard } from "./chronotype-card";
/**
* v1.18.7 W-D — chronotype as the prominent bottom block of the Sleep view.
* v1.18.7 W-D — chronotype card on the Sleep view.
*
* Split out of `<SleepRhythmSection>` (which now owns only the sleep-debt
* headline) so the chronotype gets a full-width treatment at the end of the
* page: a large band label, the natural sleep-midpoint clock beneath it, and an
* expandable disclosure for social jetlag + MSFsc. Reads the SAME
* server-authoritative DTO via `useSleepRhythm`, so it shares the
* v1.19.0 — the standalone "Chronotyp" `<SectionHeading>` was removed: it
* duplicated the heading the `<ChronotypeCard>` already carries in its own
* `CardTitle`, so "Chronotype" appeared twice and the tile no longer matched
* its sibling (the sleep-debt tile, which has no section heading). The card now
* stands as a peer of `<SleepDebtCard>` inside the shared grid row — same card
* chrome, one heading each, rendered inside the tile.
*
* Reads the SAME server-authoritative DTO via `useSleepRhythm`, so it shares the
* `["sleep-rhythm"]` cache the debt section already warmed — no extra
* round-trip. Gated on `enabled` so a source-less account never fetches.
*
* States mirror the debt section: loading → skeleton, error → quiet inline
* notice, settled → the card (which carries its own calm "still learning"
* copy for the no-nights-yet case, since the route always returns a full DTO).
* notice, settled → the card (which carries its own calm "still learning" copy
* for the no-nights-yet case, since the route always returns a full DTO).
*/
export function ChronotypeSection({ enabled }: { enabled: boolean }) {
const { isAuthenticated } = useAuth();
@@ -33,28 +33,26 @@ export function ChronotypeSection({ enabled }: { enabled: boolean }) {
if (!enabled) return null;
return (
<section data-slot="chronotype-section" className="space-y-3">
<SectionHeading
icon={Clock}
title={t("insights.sleep.chronotype.title")}
if (isError) {
return (
<div
data-slot="chronotype-error"
role="status"
className="bg-card border-border text-muted-foreground rounded-xl border p-4 text-sm"
>
{t("insights.sleep.rhythm.loadError")}
</div>
);
}
if (isLoading || !data) {
return (
<Skeleton
data-slot="chronotype-loading"
className="h-32 w-full rounded-xl"
/>
{isError ? (
<div
data-slot="chronotype-error"
role="status"
className="bg-card border-border text-muted-foreground rounded-xl border p-4 text-sm"
>
{t("insights.sleep.rhythm.loadError")}
</div>
) : isLoading || !data ? (
<Skeleton
data-slot="chronotype-loading"
className="h-32 w-full rounded-xl"
/>
) : (
<ChronotypeCard chronotype={data.chronotype} />
)}
</section>
);
);
}
return <ChronotypeCard chronotype={data.chronotype} />;
}
+27 -1
View File
@@ -18,8 +18,13 @@ import { formatLabValue } from "@/lib/labs/format-value";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import dynamic from "next/dynamic";
import type { ComponentProps } from "react";
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { importWithRetry } from "@/lib/retry-import";
import { BiomarkerForm } from "./biomarker-form";
import { LabBiomarkerChart } from "./lab-biomarker-chart";
import { LabForm } from "./lab-form";
import { LabHistoryList } from "./lab-history-list";
import { ReferenceRangeBadge } from "./reference-range-badge";
@@ -29,6 +34,27 @@ import type {
LabResultListResponse,
} from "./types";
// v1.18.11 (W5 perf) — defer the recharts biomarker chart through
// `next/dynamic` so recharts is off `/labs/[biomarkerId]`'s first-load JS.
// The chart only paints once the reading list resolves; the `<ChartSkeleton>`
// loading shell matches the in-card chart footprint so the layout is stable.
const LabBiomarkerChartLazy = dynamic(
() =>
importWithRetry(() => import("./lab-biomarker-chart")).then((mod) => ({
default: mod.LabBiomarkerChart,
})),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
function LabBiomarkerChart(
props: ComponentProps<typeof LabBiomarkerChartLazy>,
) {
return (
<ChartErrorBoundary>
<LabBiomarkerChartLazy {...props} />
</ChartErrorBoundary>
);
}
/**
* v1.18.1 — per-biomarker detail: heading + current-value badge, the proper
* dashboard-style chart with the reference band, and the editable reading
@@ -120,6 +120,8 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
// ── v1.17.1 — Oura coverage completion ──
SLEEP_SCORE: "measurements.typeSleepScore",
BODY_TEMPERATURE_DEVIATION: "measurements.typeBodyTemperatureDeviation",
// ── v1.19.0 — Oura resilience ──
RESILIENCE: "measurements.typeResilience",
};
export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
@@ -239,6 +241,10 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
// ── v1.17.1 — Oura coverage completion ──
SLEEP_SCORE: Moon,
BODY_TEMPERATURE_DEVIATION: Thermometer,
// ── v1.19.0 — Oura resilience ──
// Gauge reads as the composite "index / level" dial, matching the other
// recovery / score composites (RECOVERY_SCORE, DAY_STRAIN).
RESILIENCE: Gauge,
};
export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
@@ -349,4 +355,8 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
// for the body-temperature deviation.
SLEEP_SCORE: "bg-chart-2/20 text-chart-2",
BODY_TEMPERATURE_DEVIATION: "bg-chart-5/20 text-chart-5",
// ── v1.19.0 — Oura resilience ──
// chart-1 (Dracula purple) marks the derived recovery / score composites as
// their own visual group, matching RECOVERY_SCORE / DAY_STRAIN.
RESILIENCE: "bg-chart-1/20 text-chart-1",
};
@@ -7,12 +7,19 @@
* suffix — matching the list payload and the GLP-1 endpoint instead of
* counting expired stock as supply.
*
* v1.19.0 (iOS#25) — the headline is now server-authoritative: the GET
* response carries a `summary` computed server-side via `summariseSupply`,
* and the component RENDERS it (no client-side derivation). The seed
* builds that summary with the same canonical helper the server uses, so
* these scenarios assert the component renders the server DTO faithfully.
*
* Project convention is SSR-only component tests (`renderToStaticMarkup`).
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { summariseSupply } from "@/lib/medications/inventory/summary";
const useQueryMock = vi.fn();
@@ -35,9 +42,27 @@ function render(node: React.ReactNode): string {
);
}
function seedItems(items: unknown[]) {
function seedItems(
items: Array<{
state: "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
unitsTotal: number;
unitsRemaining: number;
[key: string]: unknown;
}>,
unitsPerDose = 1,
) {
// The server computes the canonical summary; reproduce it here with the
// one source of truth so the seed mirrors the real DTO the route ships.
const summary = summariseSupply(
items.map((i) => ({
state: i.state,
unitsTotal: i.unitsTotal,
unitsRemaining: i.unitsRemaining,
})),
unitsPerDose,
);
useQueryMock.mockReturnValue({
data: { items, meta: { total: items.length } },
data: { items, summary, meta: { total: items.length } },
isLoading: false,
});
}
@@ -72,7 +72,7 @@ import { SideEffectsSection } from "@/components/medications/side-effects-sectio
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { TitrationTimeline } from "@/components/medications/titration-timeline";
import { estimateRunwayDays } from "@/components/medications/detail/supply-runway";
import { summariseSupply } from "@/lib/medications/inventory/summary";
import type { SupplySummary } from "@/lib/medications/inventory/summary";
import { MedicationWizardDialog } from "@/components/medications/wizard/medication-wizard-dialog";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { useQuery } from "@tanstack/react-query";
@@ -335,9 +335,8 @@ export function MedicationDetailTabs({
return await apiGet<{
items: Array<{
state: "ACTIVE" | "IN_USE" | "EXPIRED" | "USED_UP";
unitsTotal: number;
unitsRemaining: number;
}>;
summary?: SupplySummary;
} | null>(`/api/medications/${id}/inventory`);
} catch {
return null;
@@ -348,26 +347,34 @@ export function MedicationDetailTabs({
});
const inventoryItems = Array.isArray(inventory?.items) ? inventory.items : [];
// v1.16.10 — items count UNITS; the supply readout and the runway are
// dose-derived (floor over the pooled units — consumption spills
// across containers, so the pool is the honest dose count). The raw
// unit tally renders as secondary text when a dose spans > 1 unit.
// Availability rides the shared summary helper (ACTIVE / IN_USE with
// units only — the list / GLP-1 semantic); expired stock surfaces as
// a separate muted suffix and never feeds the runway.
// v1.16.12 — guard at > 0, NOT ≥ 1, so a fractional unitsPerDose (½
// tablet per dose) stays fractional instead of halving the dose counts.
const perDose =
medication.unitsPerDose && medication.unitsPerDose > 0
? medication.unitsPerDose
: 1;
// v1.19.0 (iOS#25) — the supply headline is server-authoritative: the
// GET response carries a `summary` computed server-side through the
// shared `summariseSupply` helper (ACTIVE / IN_USE with units only —
// the list / GLP-1 semantic; EXPIRED surfaced separately, never
// available; dose-derived counts floored over the pooled units). The
// Übersicht status row renders these ready figures, so web and iOS read
// identical numbers from the same DTO. The runway estimate is a UI
// projection over the canonical `dosesRemaining`.
const {
unitsRemaining,
unitsTotal,
dosesRemaining,
dosesTotal,
expiredUnits,
} = summariseSupply(inventoryItems, perDose);
} = inventory?.summary ?? {
unitsRemaining: 0,
unitsTotal: 0,
dosesRemaining: 0,
dosesTotal: 0,
expiredUnits: 0,
};
// v1.16.12 — fractional unitsPerDose (½ tablet per dose) drives the
// "show the raw unit tally" toggle below; the canonical figures above
// already account for it server-side.
const perDose =
medication.unitsPerDose && medication.unitsPerDose > 0
? medication.unitsPerDose
: 1;
const runwayDays = estimateRunwayDays(dosesRemaining, medication.schedules);
return (
@@ -76,7 +76,7 @@ import {
apiPost,
apiPut,
} from "@/lib/api/api-fetch";
import { summariseSupply } from "@/lib/medications/inventory/summary";
import type { SupplySummary } from "@/lib/medications/inventory/summary";
/**
* Invalidate every read key whose payload reflects a medication's supply
@@ -127,6 +127,11 @@ interface InventoryItem {
interface InventoryResponse {
items: InventoryItem[];
// v1.19.0 (iOS#25) — server-authoritative supply summary. The headline
// figures are computed server-side via the shared `summariseSupply`
// helper and shipped ready; the client renders them rather than
// re-deriving in the browser, so web and iOS agree on the Bestand.
summary?: SupplySummary;
meta?: { total: number };
}
@@ -232,28 +237,19 @@ export function InventorySection({
}
const items = Array.isArray(data?.items) ? data.items : [];
// Available supply: ACTIVE / IN_USE containers with units left — the
// shared summary helper keeps this row, the Übersicht row, the list
// payload and the GLP-1 endpoint on one predicate. Expired stock is
// never available; it renders as a separate muted suffix.
// Dose-derived headline (floor — a partial dose is not a dose).
// v1.18.3 (iOS#31) — an unknown-units row (null) contributes nothing to
// the available-supply math; it shows "—" in its own row but cannot pad
// the pooled headline. Coalesce null → 0 only for the summary inputs.
const {
unitsRemaining: remainingUnits,
unitsTotal: totalUnits,
dosesRemaining: remaining,
dosesTotal: total,
expiredUnits,
} = summariseSupply(
items.map((item) => ({
state: item.state,
unitsTotal: item.unitsTotal ?? 0,
unitsRemaining: item.unitsRemaining ?? 0,
})),
perDose,
);
// v1.19.0 (iOS#25) — the supply headline is server-authoritative: the
// GET response carries a `summary` computed server-side through the
// shared `summariseSupply` helper (ACTIVE / IN_USE with units left;
// EXPIRED surfaced separately, never available; dose-derived counts
// floored). The client renders these ready figures so web and iOS show
// identical numbers. The per-item rows below still divide by `perDose`
// locally, which is presentation only — the canonical pool is the DTO.
const summary = data?.summary;
const remainingUnits = summary?.unitsRemaining ?? 0;
const totalUnits = summary?.unitsTotal ?? 0;
const remaining = summary?.dosesRemaining ?? 0;
const total = summary?.dosesTotal ?? 0;
const expiredUnits = summary?.expiredUnits ?? 0;
const dialogs = (
<>
@@ -0,0 +1,58 @@
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
/**
* v1.4.37 — silent browser-zone auto-seed for the timezone picker.
*
* Until v1.4.37 the picker carried a "Browser-Zeitzone übernehmen" /
* "Use browser timezone" button so the user could overwrite the
* Europe/Berlin seed with their detected zone on demand. The button
* was visually noisy next to the picker on mobile and almost every
* user wants the browser zone anyway, so the affordance retired and
* the bootstrap effect seeds the form for them.
*
* Rules:
*
* - If the stored value is anything other than the Europe/Berlin
* default, respect it. The user explicitly picked it.
* - If the stored value is the Europe/Berlin default but the
* browser actually IS in Berlin, leave it alone — the picker
* stays on Berlin and the next save is a no-op.
* - If the stored value is the default AND the browser reports a
* non-Berlin zone, pre-fill the picker with the detected zone.
* The form's existing submit handler persists the change on the
* next save; no toast, no banner, no opt-in.
*
* The bootstrap deliberately runs inline during render (the strict
* `react-hooks/set-state-in-effect` rule outlaws setState in an
* effect for this hydration shape), so this helper has to stay
* pure — no DOM access, no `useState`. The detected browser zone is
* passed in by the caller via `detectBrowserTimezone()`.
*/
export function resolveInitialTimezone(
storedTimezone: string | null | undefined,
detectedBrowserTimezone: string,
): string {
const stored = storedTimezone || DEFAULT_TIMEZONE;
const shouldAutoSeed =
stored === DEFAULT_TIMEZONE &&
detectedBrowserTimezone.length > 0 &&
detectedBrowserTimezone !== DEFAULT_TIMEZONE;
return shouldAutoSeed ? detectedBrowserTimezone : stored;
}
/**
* v1.16.4 — settings status hints store the i18n KEY (+ params), not
* the translated string: a locale switch re-renders the hint in the
* new language instead of freezing the old-language snapshot. Server-
* provided error text (which has no key) rides `text` verbatim.
*/
export type StatusMessage =
| { key: string; params?: Record<string, string | number> }
| { text: string };
export function statusText(
msg: StatusMessage,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
return "key" in msg ? t(msg.key, msg.params) : msg.text;
}
@@ -0,0 +1,184 @@
"use client";
import { useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { ImageUp, Loader2, Trash2 } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { statusText, type StatusMessage } from "./account-section-utils";
// Accepted upload types + byte cap mirror the server contract in
// `src/app/api/user/avatar/route.ts` so the client rejects an obvious
// bad file before the round-trip. The server still re-validates by
// magic-byte sniff — this is a UX shortcut, not the security boundary.
const ACCEPTED_AVATAR_TYPES = ["image/jpeg", "image/png", "image/webp"];
const AVATAR_MAX_UPLOAD_BYTES = 2 * 1024 * 1024;
function getAvatarInitials(name: string): string {
return name
.split(/[\s._-]+/)
.slice(0, 2)
.map((s) => s[0]?.toUpperCase() ?? "")
.join("");
}
export function AvatarSection() {
const { t } = useTranslations();
const { user, refetch } = useAuth();
const fileInputRef = useRef<HTMLInputElement>(null);
const [msg, setMsg] = useState<StatusMessage | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const upload = useMutation({
mutationFn: async (file: File) => {
const body = new FormData();
body.append("file", file);
const res = await apiFetchRaw("/api/user/avatar", {
method: "POST",
body,
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
if (res.status === 415)
throw new Error(t("settings.avatar.invalidType"));
if (res.status === 413) throw new Error(t("settings.avatar.tooLarge"));
throw new Error(json.error || t("settings.avatar.error"));
}
},
onSuccess: async () => {
setMsgType("success");
setMsg({ key: "settings.avatar.uploaded" });
await refetch();
},
onError: (err: Error) => {
setMsgType("error");
setMsg({ text: err.message });
},
});
const remove = useMutation({
mutationFn: async () => {
const res = await apiFetchRaw("/api/user/avatar", { method: "DELETE" });
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json.error || t("settings.avatar.error"));
}
},
onSuccess: async () => {
setMsgType("success");
setMsg({ key: "settings.avatar.removed" });
await refetch();
},
onError: (err: Error) => {
setMsgType("error");
setMsg({ text: err.message });
},
});
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
// Reset so re-selecting the same file still fires `onChange`.
e.target.value = "";
if (!file) return;
setMsg(null);
setMsgType(null);
if (!ACCEPTED_AVATAR_TYPES.includes(file.type)) {
setMsgType("error");
setMsg({ key: "settings.avatar.invalidType" });
return;
}
if (file.size > AVATAR_MAX_UPLOAD_BYTES) {
setMsgType("error");
setMsg({ key: "settings.avatar.tooLarge" });
return;
}
upload.mutate(file);
}
if (!user) return null;
const avatarUrl = user.avatarUrl ?? null;
const busy = upload.isPending || remove.isPending;
return (
<SettingsCard>
<SettingsCardHeader
icon={ImageUp}
title={t("settings.avatar.title")}
className="mb-4"
/>
<p className="text-muted-foreground mb-4 pl-7 text-sm">
{t("settings.avatar.description")}
</p>
<div className="flex items-center gap-4 pl-7">
<Avatar className="size-16">
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
<AvatarFallback className="text-base">
{getAvatarInitials(user.username)}
</AvatarFallback>
</Avatar>
<div className="flex flex-wrap gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={handleFile}
/>
<Button
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={busy}
onClick={() => fileInputRef.current?.click()}
>
{upload.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<ImageUp className="h-4 w-4" />
)}
{avatarUrl
? t("settings.avatar.replace")
: t("settings.avatar.upload")}
</Button>
{avatarUrl && (
<Button
variant="ghost"
className="text-destructive min-h-11 sm:min-h-9"
disabled={busy}
onClick={() => {
setMsg(null);
setMsgType(null);
remove.mutate();
}}
>
{remove.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-4 w-4" />
)}
{t("settings.avatar.remove")}
</Button>
)}
</div>
</div>
<p className="text-muted-foreground mt-3 text-xs">
{t("settings.avatar.hint")}
</p>
{msg && (
<p
role="alert"
className={`mt-3 text-sm ${
msgType === "success" ? "text-success" : "text-destructive"
}`}
>
{statusText(msg, t)}
</p>
)}
</SettingsCard>
);
}
@@ -1,32 +1,9 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
ImageUp,
KeyRound,
Loader2,
Save,
Shield,
Trash2,
User,
} from "lucide-react";
import { KeyRound, Loader2, Save, Shield, User } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -42,11 +19,9 @@ import { NativeSelect } from "@/components/ui/native-select";
import { PasswordInput } from "@/components/ui/password-input";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { formatDate } from "@/lib/format";
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
import { useTranslations } from "@/lib/i18n/context";
import { describePasskeyError } from "@/lib/passkey-errors";
import { queryKeys } from "@/lib/query-keys";
import { AboutMeSection } from "@/components/settings/about-me-section";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -54,78 +29,17 @@ import { TimezonePicker } from "@/components/settings/timezone-picker";
import { TimeFormatSelect } from "@/components/settings/time-format-select";
import { UnitPreferenceSelect } from "@/components/settings/unit-preference-select";
import { CycleTrackingCard } from "@/components/settings/cycle-tracking-card";
import { detectBrowserTimezone, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { detectBrowserTimezone } from "@/lib/tz/format";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import {
resolveInitialTimezone,
statusText,
type StatusMessage,
} from "./account-section-utils";
import { PasskeyListSection } from "./passkey-list-section";
import { AvatarSection } from "./avatar-section";
interface PasskeyInfo {
id: string;
name: string;
credentialDeviceType: string;
credentialBackedUp: boolean;
createdAt: string;
}
// v1.4.27 MB7 / CF-52 — the in-file `NATIVE_SELECT_CLASS` constant
// retired; the shared `<NativeSelect>` primitive owns the visual
// contract now. Existing `<select className={NATIVE_SELECT_CLASS}>`
// call sites in this file swapped to `<NativeSelect>` below.
/**
* v1.4.37 — silent browser-zone auto-seed for the timezone picker.
*
* Until v1.4.37 the picker carried a "Browser-Zeitzone übernehmen" /
* "Use browser timezone" button so the user could overwrite the
* Europe/Berlin seed with their detected zone on demand. The button
* was visually noisy next to the picker on mobile and almost every
* user wants the browser zone anyway, so the affordance retired and
* the bootstrap effect seeds the form for them.
*
* Rules:
*
* - If the stored value is anything other than the Europe/Berlin
* default, respect it. The user explicitly picked it.
* - If the stored value is the Europe/Berlin default but the
* browser actually IS in Berlin, leave it alone — the picker
* stays on Berlin and the next save is a no-op.
* - If the stored value is the default AND the browser reports a
* non-Berlin zone, pre-fill the picker with the detected zone.
* The form's existing submit handler persists the change on the
* next save; no toast, no banner, no opt-in.
*
* The bootstrap deliberately runs inline during render (the strict
* `react-hooks/set-state-in-effect` rule outlaws setState in an
* effect for this hydration shape), so this helper has to stay
* pure — no DOM access, no `useState`. The detected browser zone is
* passed in by the caller via `detectBrowserTimezone()`.
*/
export function resolveInitialTimezone(
storedTimezone: string | null | undefined,
detectedBrowserTimezone: string,
): string {
const stored = storedTimezone || DEFAULT_TIMEZONE;
const shouldAutoSeed =
stored === DEFAULT_TIMEZONE &&
detectedBrowserTimezone.length > 0 &&
detectedBrowserTimezone !== DEFAULT_TIMEZONE;
return shouldAutoSeed ? detectedBrowserTimezone : stored;
}
/**
* v1.16.4 — settings status hints store the i18n KEY (+ params), not
* the translated string: a locale switch re-renders the hint in the
* new language instead of freezing the old-language snapshot. Server-
* provided error text (which has no key) rides `text` verbatim.
*/
type StatusMessage =
| { key: string; params?: Record<string, string | number> }
| { text: string };
function statusText(
msg: StatusMessage,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
return "key" in msg ? t(msg.key, msg.params) : msg.text;
}
export { resolveInitialTimezone } from "./account-section-utils";
export function AccountSection() {
const { t, locale, setLocale, pendingLocale } = useTranslations();
@@ -764,422 +678,3 @@ export function AccountSection() {
</div>
);
}
function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [deleteMsg, setDeleteMsg] = useState<string | null>(null);
const { data: passkeys } = useQuery({
queryKey: queryKeys.passkeys(),
queryFn: async () => {
return apiGet<PasskeyInfo[]>("/api/auth/passkeys");
},
enabled: isAuthenticated,
});
const deletePasskey = useMutation({
mutationFn: async (id: string) => {
await apiDelete(`/api/auth/passkeys/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
setDeleteMsg(null);
},
onError: (err: Error) => {
setDeleteMsg(err.message);
},
});
const DEVICE_TYPE_LABELS: Record<string, string> = {
singleDevice: t("settings.singleDevice"),
multiDevice: t("settings.multiDevice"),
};
if (!passkeys || passkeys.length === 0) {
return (
<div>
<h3 className="text-sm font-semibold">{t("settings.passkeys")}</h3>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.noPasskeys")}
</p>
</div>
);
}
return (
<div>
<h3 className="text-sm font-semibold">
{t("settings.registeredPasskeys")}
</h3>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.passkeysDescription")}
</p>
{/* Phase A5 / B-mobile HIGH: the passkey table previously
rendered with `min-w-[620px]` inside `overflow-x-auto`,
which on a 393px viewport hid the right-most column —
including the destructive delete action. Render the desktop
table only at `≥ md`, and at `< md` paint a card-list where
every passkey's name, device type, backup status, created
date, and delete action are all visible without scrolling. */}
{/* v1.4.33 — desktop table flips to card list at `lg` instead
of `md` so iPad portrait (768 px = exactly the `md`
inflection) lands on the card layout. The table needs
~620 px of column width to read; below that it scrolls
horizontally and the destructive delete column is the one
that disappears. */}
<div className="border-border mt-3 hidden overflow-x-auto rounded-lg border lg:block">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("settings.passkeyName")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.passkeyDevice")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.passkeyBackup")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.passkeyCreated")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("settings.passkeyActions")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{passkeys.map((pk, idx) => (
<tr key={pk.id} className={idx % 2 === 0 ? "bg-muted/20" : ""}>
<td className="px-3 py-2 font-medium">{pk.name}</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
pk.credentialDeviceType}
</td>
<td className="px-3 py-2 text-xs">
<Badge
variant={pk.credentialBackedUp ? "secondary" : "outline"}
className="text-[11px]"
>
{pk.credentialBackedUp
? t("settings.backedUp")
: t("common.no")}
</Badge>
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{formatDate(pk.createdAt)}
</td>
<td className="px-3 py-2 text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-destructive h-8 w-8"
disabled={deletePasskey.isPending}
aria-label={t("settings.deletePasskey")}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deletePasskey")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deletePasskeyDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deletePasskey.isPending}
aria-busy={deletePasskey.isPending || undefined}
onClick={() => deletePasskey.mutate(pk.id)}
>
{deletePasskey.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile + tablet card list — < lg only. Each passkey gets
its own card so the delete action stays visible and
tap-targetable (44 px) without the user having to
horizontally scroll through a wide table. v1.4.33 bumped
the breakpoint from `md` to `lg` so iPad portrait (768 px)
stays on the card layout instead of flipping between
layouts on rotation. */}
<ul
className="mt-3 space-y-2 lg:hidden"
data-testid="passkeys-mobile-list"
>
{passkeys.map((pk) => (
<li
key={pk.id}
className="bg-card border-border rounded-lg border p-4"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{pk.name}</p>
{/* v1.4.27 MB7 / CF-75 — promote the device-type from
plain text to an outline Badge so the mobile card
list reads consistent with the desktop table's
"Single-device / Multi-device" column. Sits on the
same chip row as the backup status and date so all
metadata reads as a single horizontal stride. */}
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
<Badge variant="outline" className="text-[11px]">
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
pk.credentialDeviceType}
</Badge>
<Badge
variant={pk.credentialBackedUp ? "secondary" : "outline"}
className="text-[11px]"
>
{pk.credentialBackedUp
? t("settings.backedUp")
: t("common.no")}
</Badge>
<span className="text-muted-foreground">
{formatDate(pk.createdAt)}
</span>
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-destructive min-h-11 min-w-11"
disabled={deletePasskey.isPending}
aria-label={t("settings.deletePasskey")}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deletePasskey")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deletePasskeyDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deletePasskey.isPending}
aria-busy={deletePasskey.isPending || undefined}
onClick={() => deletePasskey.mutate(pk.id)}
>
{deletePasskey.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</li>
))}
</ul>
{deleteMsg && (
<div
role="alert"
className="text-destructive mt-2 flex items-center gap-2 text-sm"
>
<AlertTriangle className="h-4 w-4" />
{deleteMsg}
</div>
)}
</div>
);
}
// Accepted upload types + byte cap mirror the server contract in
// `src/app/api/user/avatar/route.ts` so the client rejects an obvious
// bad file before the round-trip. The server still re-validates by
// magic-byte sniff — this is a UX shortcut, not the security boundary.
const ACCEPTED_AVATAR_TYPES = ["image/jpeg", "image/png", "image/webp"];
const AVATAR_MAX_UPLOAD_BYTES = 2 * 1024 * 1024;
function getAvatarInitials(name: string): string {
return name
.split(/[\s._-]+/)
.slice(0, 2)
.map((s) => s[0]?.toUpperCase() ?? "")
.join("");
}
function AvatarSection() {
const { t } = useTranslations();
const { user, refetch } = useAuth();
const fileInputRef = useRef<HTMLInputElement>(null);
const [msg, setMsg] = useState<StatusMessage | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const upload = useMutation({
mutationFn: async (file: File) => {
const body = new FormData();
body.append("file", file);
const res = await apiFetchRaw("/api/user/avatar", {
method: "POST",
body,
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
if (res.status === 415)
throw new Error(t("settings.avatar.invalidType"));
if (res.status === 413) throw new Error(t("settings.avatar.tooLarge"));
throw new Error(json.error || t("settings.avatar.error"));
}
},
onSuccess: async () => {
setMsgType("success");
setMsg({ key: "settings.avatar.uploaded" });
await refetch();
},
onError: (err: Error) => {
setMsgType("error");
setMsg({ text: err.message });
},
});
const remove = useMutation({
mutationFn: async () => {
const res = await apiFetchRaw("/api/user/avatar", { method: "DELETE" });
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json.error || t("settings.avatar.error"));
}
},
onSuccess: async () => {
setMsgType("success");
setMsg({ key: "settings.avatar.removed" });
await refetch();
},
onError: (err: Error) => {
setMsgType("error");
setMsg({ text: err.message });
},
});
function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
// Reset so re-selecting the same file still fires `onChange`.
e.target.value = "";
if (!file) return;
setMsg(null);
setMsgType(null);
if (!ACCEPTED_AVATAR_TYPES.includes(file.type)) {
setMsgType("error");
setMsg({ key: "settings.avatar.invalidType" });
return;
}
if (file.size > AVATAR_MAX_UPLOAD_BYTES) {
setMsgType("error");
setMsg({ key: "settings.avatar.tooLarge" });
return;
}
upload.mutate(file);
}
if (!user) return null;
const avatarUrl = user.avatarUrl ?? null;
const busy = upload.isPending || remove.isPending;
return (
<SettingsCard>
<SettingsCardHeader
icon={ImageUp}
title={t("settings.avatar.title")}
className="mb-4"
/>
<p className="text-muted-foreground mb-4 pl-7 text-sm">
{t("settings.avatar.description")}
</p>
<div className="flex items-center gap-4 pl-7">
<Avatar className="size-16">
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
<AvatarFallback className="text-base">
{getAvatarInitials(user.username)}
</AvatarFallback>
</Avatar>
<div className="flex flex-wrap gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={handleFile}
/>
<Button
variant="outline"
className="min-h-11 sm:min-h-9"
disabled={busy}
onClick={() => fileInputRef.current?.click()}
>
{upload.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<ImageUp className="h-4 w-4" />
)}
{avatarUrl
? t("settings.avatar.replace")
: t("settings.avatar.upload")}
</Button>
{avatarUrl && (
<Button
variant="ghost"
className="text-destructive min-h-11 sm:min-h-9"
disabled={busy}
onClick={() => {
setMsg(null);
setMsgType(null);
remove.mutate();
}}
>
{remove.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-4 w-4" />
)}
{t("settings.avatar.remove")}
</Button>
)}
</div>
</div>
<p className="text-muted-foreground mt-3 text-xs">
{t("settings.avatar.hint")}
</p>
{msg && (
<p
role="alert"
className={`mt-3 text-sm ${
msgType === "success" ? "text-success" : "text-destructive"
}`}
>
{statusText(msg, t)}
</p>
)}
</SettingsCard>
);
}
@@ -0,0 +1,284 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Loader2, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { formatDate } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
interface PasskeyInfo {
id: string;
name: string;
credentialDeviceType: string;
credentialBackedUp: boolean;
createdAt: string;
}
export function PasskeyListSection({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [deleteMsg, setDeleteMsg] = useState<string | null>(null);
const { data: passkeys } = useQuery({
queryKey: queryKeys.passkeys(),
queryFn: async () => {
return apiGet<PasskeyInfo[]>("/api/auth/passkeys");
},
enabled: isAuthenticated,
});
const deletePasskey = useMutation({
mutationFn: async (id: string) => {
await apiDelete(`/api/auth/passkeys/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() });
setDeleteMsg(null);
},
onError: (err: Error) => {
setDeleteMsg(err.message);
},
});
const DEVICE_TYPE_LABELS: Record<string, string> = {
singleDevice: t("settings.singleDevice"),
multiDevice: t("settings.multiDevice"),
};
if (!passkeys || passkeys.length === 0) {
return (
<div>
<h3 className="text-sm font-semibold">{t("settings.passkeys")}</h3>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.noPasskeys")}
</p>
</div>
);
}
return (
<div>
<h3 className="text-sm font-semibold">
{t("settings.registeredPasskeys")}
</h3>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.passkeysDescription")}
</p>
{/* Phase A5 / B-mobile HIGH: the passkey table previously
rendered with `min-w-[620px]` inside `overflow-x-auto`,
which on a 393px viewport hid the right-most column —
including the destructive delete action. Render the desktop
table only at `≥ md`, and at `< md` paint a card-list where
every passkey's name, device type, backup status, created
date, and delete action are all visible without scrolling. */}
{/* v1.4.33 — desktop table flips to card list at `lg` instead
of `md` so iPad portrait (768 px = exactly the `md`
inflection) lands on the card layout. The table needs
~620 px of column width to read; below that it scrolls
horizontally and the destructive delete column is the one
that disappears. */}
<div className="border-border mt-3 hidden overflow-x-auto rounded-lg border lg:block">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b text-xs">
<th className="px-3 py-2 text-left font-medium">
{t("settings.passkeyName")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.passkeyDevice")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.passkeyBackup")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("settings.passkeyCreated")}
</th>
<th className="px-3 py-2 text-right font-medium">
{t("settings.passkeyActions")}
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{passkeys.map((pk, idx) => (
<tr key={pk.id} className={idx % 2 === 0 ? "bg-muted/20" : ""}>
<td className="px-3 py-2 font-medium">{pk.name}</td>
<td className="text-muted-foreground px-3 py-2 text-xs">
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
pk.credentialDeviceType}
</td>
<td className="px-3 py-2 text-xs">
<Badge
variant={pk.credentialBackedUp ? "secondary" : "outline"}
className="text-[11px]"
>
{pk.credentialBackedUp
? t("settings.backedUp")
: t("common.no")}
</Badge>
</td>
<td className="text-muted-foreground px-3 py-2 text-xs whitespace-nowrap">
{formatDate(pk.createdAt)}
</td>
<td className="px-3 py-2 text-right">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-destructive h-8 w-8"
disabled={deletePasskey.isPending}
aria-label={t("settings.deletePasskey")}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deletePasskey")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deletePasskeyDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deletePasskey.isPending}
aria-busy={deletePasskey.isPending || undefined}
onClick={() => deletePasskey.mutate(pk.id)}
>
{deletePasskey.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile + tablet card list — < lg only. Each passkey gets
its own card so the delete action stays visible and
tap-targetable (44 px) without the user having to
horizontally scroll through a wide table. v1.4.33 bumped
the breakpoint from `md` to `lg` so iPad portrait (768 px)
stays on the card layout instead of flipping between
layouts on rotation. */}
<ul
className="mt-3 space-y-2 lg:hidden"
data-testid="passkeys-mobile-list"
>
{passkeys.map((pk) => (
<li
key={pk.id}
className="bg-card border-border rounded-lg border p-4"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{pk.name}</p>
{/* v1.4.27 MB7 / CF-75 — promote the device-type from
plain text to an outline Badge so the mobile card
list reads consistent with the desktop table's
"Single-device / Multi-device" column. Sits on the
same chip row as the backup status and date so all
metadata reads as a single horizontal stride. */}
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px]">
<Badge variant="outline" className="text-[11px]">
{DEVICE_TYPE_LABELS[pk.credentialDeviceType] ??
pk.credentialDeviceType}
</Badge>
<Badge
variant={pk.credentialBackedUp ? "secondary" : "outline"}
className="text-[11px]"
>
{pk.credentialBackedUp
? t("settings.backedUp")
: t("common.no")}
</Badge>
<span className="text-muted-foreground">
{formatDate(pk.createdAt)}
</span>
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-destructive min-h-11 min-w-11"
disabled={deletePasskey.isPending}
aria-label={t("settings.deletePasskey")}
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deletePasskey")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deletePasskeyDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deletePasskey.isPending}
aria-busy={deletePasskey.isPending || undefined}
onClick={() => deletePasskey.mutate(pk.id)}
>
{deletePasskey.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</li>
))}
</ul>
{deleteMsg && (
<div
role="alert"
className="text-destructive mt-2 flex items-center gap-2 text-sm"
>
<AlertTriangle className="h-4 w-4" />
{deleteMsg}
</div>
)}
</div>
);
}
@@ -2,7 +2,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { CalendarHeart } from "lucide-react";
import { CalendarHeart, Info } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
@@ -79,6 +79,21 @@ export function CycleTrackingCard({
className="mt-0.5 shrink-0"
/>
</div>
{/* The single, prominent non-medical disclaimer for the whole cycle
vertical. It lives here at the on-ramp — where expectations are
set — instead of repeating beneath every prediction surface. */}
<div
data-slot="cycle-tracking-disclaimer"
className="border-border bg-muted/40 flex items-start gap-2.5 rounded-lg border-l-2 p-3"
>
<Info
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<p className="text-foreground/80 text-xs leading-relaxed">
{t("settings.cycleTracking.disclaimer")}
</p>
</div>
{enabled ? (
<Button
variant="outline"
-920
View File
@@ -1,920 +0,0 @@
"use client";
/**
* v1.15.7 — Settings → Export & Import → Import area.
*
* Web UI for the two existing import backends (issue #281: the routes
* shipped without a surface). Two clearly-separated controls:
*
* 1. Apple Health `export.zip` — multipart POST to
* `/api/import/apple-health-export` (field `file`), then poll
* `GET /api/import/apple-health-export/[jobId]/status` on a
* `refetchInterval` while the job runs, stopping on a terminal
* state (`done` / `failed`). The upload streams server-side so a
* multi-GB archive never buffers in the browser beyond the POST.
* Idempotent on the file's SHA-256 — re-uploading the same archive
* merges rather than duplicating.
*
* 2. Generic JSON import — upload a `.json` file OR paste JSON,
* validated client-side for parseability, POSTed to `/api/import`.
* A "Download example" button mints a small valid example as a Blob
* (no static asset). Returns `{ measurements, moodEntries, skipped }`
* counts.
*
* Both controls surface rate-limit (429) and error states cleanly and
* announce progress to assistive tech via `aria-live` regions.
*/
import { useCallback, useId, useRef, useState } from "react";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle2,
Download,
FileJson,
FileSpreadsheet,
Loader2,
Upload,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { SettingsCard } from "@/components/settings/settings-card";
import { Textarea } from "@/components/ui/textarea";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { CSV_EXAMPLE_COLUMNS } from "@/lib/import/csv-measurements";
/**
* Upper bound for the paste textareas, mirroring the 16 MB server-side body
* ceiling on `/api/import` and `/api/import/csv`. Caps an accidental over-paste
* before it ever reaches the route and feeds the live character counter.
*/
const MAX_PASTE_CHARS = 16 * 1024 * 1024;
// ─────────────────────────── Section wrapper ───────────────────────────
export function ImportPanel() {
const { t } = useTranslations();
return (
<section
aria-labelledby="settings-section-import-title"
className="space-y-3"
>
<div className="space-y-1">
<h2
id="settings-section-import-title"
className="text-base font-semibold tracking-tight"
>
{t("settings.sections.export.import.heading")}
</h2>
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.import.description")}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<AppleHealthImportCard />
<JsonImportCard />
<CsvImportCard />
</div>
</section>
);
}
// ─────────────────────────── Card shell ───────────────────────────
interface ImportCardShellProps {
testId: string;
icon: React.ComponentType<{ className?: string }>;
title: string;
description: string;
children: React.ReactNode;
}
function ImportCardShell({
testId,
icon: Icon,
title,
description,
children,
}: ImportCardShellProps) {
return (
<SettingsCard data-testid={testId} className="flex h-full flex-col">
<div className="flex items-center gap-2">
<Icon
className="text-muted-foreground h-5 w-5 shrink-0"
aria-hidden="true"
/>
<h3 className="text-base font-semibold">{title}</h3>
</div>
<p className="text-muted-foreground mt-1 text-xs">{description}</p>
<div className="mt-3 flex flex-1 flex-col gap-3">{children}</div>
</SettingsCard>
);
}
// ─────────────────────────── Apple Health import ───────────────────────────
/** Terminal states the status poll stops on. */
const TERMINAL_STATES: readonly string[] = ["done", "failed"];
interface JobStatus {
jobId: string;
status: string;
progress: {
currentPhase?: string;
recordsRead?: number;
rowsUpserted?: number;
percent?: number | null;
} | null;
result: {
totals?: { recordsRead?: number; rowsUpserted?: number };
clinical?: { skipped?: number };
} | null;
failureReason: string | null;
}
function AppleHealthImportCard() {
const { t } = useTranslations();
const fileInputRef = useRef<HTMLInputElement>(null);
const [jobId, setJobId] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [dragActive, setDragActive] = useState(false);
const dropDescId = useId();
const statusQuery = useQuery({
queryKey: queryKeys.importJobStatus(jobId ?? "none"),
enabled: jobId !== null,
refetchInterval: (query) => {
const data = query.state.data as JobStatus | undefined;
if (data && TERMINAL_STATES.includes(data.status)) return false;
return 2000;
},
queryFn: async (): Promise<JobStatus> => {
return apiGet<JobStatus>(
`/api/import/apple-health-export/${jobId}/status`,
{ credentials: "include" },
);
},
});
const upload = useCallback(
async (file: File) => {
setUploadError(null);
setUploading(true);
setJobId(null);
try {
const form = new FormData();
form.append("file", file);
const res = await apiFetchRaw("/api/import/apple-health-export", {
method: "POST",
credentials: "include",
body: form,
});
if (res.status === 429) {
setUploadError(
t("settings.sections.export.import.appleHealth.rateLimited"),
);
return;
}
if (res.status === 413) {
setUploadError(
t("settings.sections.export.import.appleHealth.tooLarge"),
);
return;
}
if (!res.ok) {
setUploadError(
t("settings.sections.export.import.appleHealth.uploadFailed"),
);
return;
}
const body = (await res.json()).data as {
jobId: string;
status: string;
};
if (!body?.jobId) {
setUploadError(
t("settings.sections.export.import.appleHealth.uploadFailed"),
);
return;
}
setJobId(body.jobId);
} catch {
setUploadError(
t("settings.sections.export.import.appleHealth.uploadFailed"),
);
} finally {
setUploading(false);
}
},
[t],
);
function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) void upload(file);
// Reset so picking the same file twice re-fires the change event.
e.target.value = "";
}
function onDrop(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
setDragActive(false);
const file = e.dataTransfer.files?.[0];
if (file) void upload(file);
}
const status = statusQuery.data ?? null;
const isRunning = status !== null && !TERMINAL_STATES.includes(status.status);
const isDone = status?.status === "done";
const isFailed = status?.status === "failed";
const busy = uploading || isRunning;
const percent = status?.progress?.percent ?? null;
return (
<ImportCardShell
testId="import-card-apple-health"
icon={Upload}
title={t("settings.sections.export.import.appleHealth.title")}
description={t("settings.sections.export.import.appleHealth.description")}
>
{/* Keyboard-operable drop area. The hidden file input does the
actual file selection; the div forwards Enter/Space to it. */}
<div
role="button"
tabIndex={0}
aria-describedby={dropDescId}
aria-disabled={busy}
onClick={() => !busy && fileInputRef.current?.click()}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && !busy) {
e.preventDefault();
fileInputRef.current?.click();
}
}}
onDragOver={(e) => {
e.preventDefault();
if (!busy) setDragActive(true);
}}
onDragLeave={() => setDragActive(false)}
onDrop={(e) => !busy && onDrop(e)}
className={cn(
"border-border bg-muted/20 hover:bg-muted/40 focus-visible:ring-ring/50 flex min-h-24 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border border-dashed p-4 text-center transition-colors focus-visible:ring-2 focus-visible:outline-none",
dragActive && "border-primary bg-primary/5",
busy && "pointer-events-none opacity-60",
)}
>
<Upload className="text-muted-foreground h-5 w-5" aria-hidden="true" />
<span className="text-foreground text-sm font-medium">
{t("settings.sections.export.import.appleHealth.dropLabel")}
</span>
<span id={dropDescId} className="text-muted-foreground text-xs">
{t("settings.sections.export.import.appleHealth.dropHint")}
</span>
</div>
<input
ref={fileInputRef}
type="file"
accept=".zip,application/zip"
className="sr-only"
aria-label={t(
"settings.sections.export.import.appleHealth.fileInputLabel",
)}
onChange={onFileChange}
/>
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.import.appleHealth.idempotencyNote")}
</p>
{/* Live progress / outcome — announced to assistive tech. */}
<div aria-live="polite" className="space-y-2">
{uploading && (
<p className="text-muted-foreground flex items-center gap-2 text-xs">
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
{t("settings.sections.export.import.appleHealth.uploading")}
</p>
)}
{isRunning && (
<div
data-testid="import-apple-health-progress"
className="space-y-1.5"
>
<p className="text-muted-foreground flex items-center gap-2 text-xs">
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
{t(
`settings.sections.export.import.appleHealth.phase.${status?.status ?? "queued"}`,
)}
</p>
{typeof percent === "number" && <Progress value={percent} />}
{typeof status?.progress?.rowsUpserted === "number" && (
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.import.appleHealth.rowsImported", {
count: status.progress.rowsUpserted,
})}
</p>
)}
</div>
)}
{isDone && (
<div
data-testid="import-apple-health-result"
className="text-foreground flex items-start gap-2 text-xs"
>
<CheckCircle2
className="text-success mt-0.5 h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<span>
{t("settings.sections.export.import.appleHealth.doneSummary", {
imported: status?.result?.totals?.rowsUpserted ?? 0,
read: status?.result?.totals?.recordsRead ?? 0,
skipped: status?.result?.clinical?.skipped ?? 0,
})}
</span>
</div>
)}
{(isFailed || uploadError) && (
<p
role="alert"
className="text-destructive flex items-start gap-2 text-sm"
>
<AlertCircle
className="mt-0.5 h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<span>
{uploadError ??
status?.failureReason ??
t("settings.sections.export.import.appleHealth.failed")}
</span>
</p>
)}
</div>
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={busy}
onClick={() => fileInputRef.current?.click()}
data-testid="import-action-apple-health"
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
{t("settings.sections.export.import.appleHealth.choose")}
</Button>
</div>
</ImportCardShell>
);
}
// ─────────────────────────── Generic JSON import ───────────────────────────
/**
* Small valid example payload, minted as a downloadable Blob by the
* "Download example" button and used by the docs. Exported so a test can
* assert it stays a valid import body — the button and the route schema
* must never drift.
*/
export const EXAMPLE_IMPORT = {
measurements: [
{
type: "WEIGHT",
value: 80.5,
unit: "kg",
measuredAt: "2026-05-01T08:00:00.000Z",
source: "manual",
notes: "morning",
},
{
type: "BLOOD_PRESSURE_SYS",
value: 120,
unit: "mmHg",
measuredAt: "2026-05-01T08:05:00.000Z",
},
{
type: "BLOOD_PRESSURE_DIA",
value: 80,
unit: "mmHg",
measuredAt: "2026-05-01T08:05:00.000Z",
},
],
moodEntries: [
{
date: "2026-05-01",
mood: "GUT",
score: 4,
tags: "work,exercise",
},
],
};
interface JsonImportResult {
measurements: number;
moodEntries: number;
skipped: number;
}
/**
* Client-side parse guard for the JSON-import textarea. Returns the
* parsed value when the text is valid JSON, otherwise a failure marker —
* we never POST an unparseable body. Exported for unit testing so the
* guard is covered without a browser.
*/
export function parseImportJson(
text: string,
): { ok: true; value: unknown } | { ok: false } {
try {
return { ok: true, value: JSON.parse(text) };
} catch {
return { ok: false };
}
}
function JsonImportCard() {
const { t } = useTranslations();
const fileInputRef = useRef<HTMLInputElement>(null);
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<JsonImportResult | null>(null);
const textareaId = useId();
async function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setError(null);
setResult(null);
try {
const content = await file.text();
setText(content);
} catch {
setError(t("settings.sections.export.import.json.readFailed"));
}
}
function downloadExample() {
const blob = new Blob([JSON.stringify(EXAMPLE_IMPORT, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "healthlog-import-example.json";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
async function handleImport() {
setError(null);
setResult(null);
const parsed = parseImportJson(text);
if (!parsed.ok) {
setError(t("settings.sections.export.import.json.invalidJson"));
return;
}
const payload = parsed.value;
setBusy(true);
try {
const res = await apiFetchRaw("/api/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload),
});
if (res.status === 429) {
setError(t("settings.sections.export.import.json.rateLimited"));
return;
}
if (res.status === 422) {
const body = await res.json().catch(() => null);
setError(
body?.error ??
t("settings.sections.export.import.json.invalidPayload"),
);
return;
}
if (!res.ok) {
setError(t("settings.sections.export.import.json.failed"));
return;
}
const data = (await res.json()).data as JsonImportResult;
setResult({
measurements: data?.measurements ?? 0,
moodEntries: data?.moodEntries ?? 0,
skipped: data?.skipped ?? 0,
});
} catch {
setError(t("settings.sections.export.import.json.failed"));
} finally {
setBusy(false);
}
}
return (
<ImportCardShell
testId="import-card-json"
icon={FileJson}
title={t("settings.sections.export.import.json.title")}
description={t("settings.sections.export.import.json.description")}
>
<div className="space-y-1.5">
<Label htmlFor={textareaId} className="text-xs">
{t("settings.sections.export.import.json.pasteLabel")}
</Label>
<Textarea
id={textareaId}
data-testid="import-json-textarea"
value={text}
onChange={(e) => setText(e.target.value)}
rows={5}
maxLength={MAX_PASTE_CHARS}
spellCheck={false}
placeholder='{"measurements":[…],"moodEntries":[…]}'
className="font-mono text-xs"
/>
<p className="text-muted-foreground text-right text-xs tabular-nums">
{t("settings.sections.export.import.charCount", {
used: text.length,
max: MAX_PASTE_CHARS,
})}
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".json,application/json"
className="sr-only"
aria-label={t("settings.sections.export.import.json.fileInputLabel")}
onChange={onFileChange}
/>
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.import.json.schemaHint")}{" "}
<Link
href="/docs/integrations/data-import"
className="text-primary underline underline-offset-2"
>
{t("settings.sections.export.import.json.docsLink")}
</Link>
</p>
<div aria-live="polite" className="space-y-2">
{result && (
<p
data-testid="import-json-result"
className="text-foreground flex items-start gap-2 text-xs"
>
<CheckCircle2
className="text-success mt-0.5 h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<span>
{t("settings.sections.export.import.json.resultSummary", {
measurements: result.measurements,
moods: result.moodEntries,
skipped: result.skipped,
})}
</span>
</p>
)}
{error && (
<p
role="alert"
className="text-destructive flex items-start gap-2 text-sm"
>
<AlertCircle
className="mt-0.5 h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<span>{error}</span>
</p>
)}
</div>
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => fileInputRef.current?.click()}
data-testid="import-json-choose-file"
>
<Upload className="h-3.5 w-3.5" />
{t("settings.sections.export.import.json.uploadFile")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={downloadExample}
data-testid="import-json-download-example"
>
<Download className="h-3.5 w-3.5" />
{t("settings.sections.export.import.json.downloadExample")}
</Button>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={busy || text.trim().length === 0}
onClick={handleImport}
data-testid="import-action-json"
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<FileJson className="h-3.5 w-3.5" />
)}
{t("settings.sections.export.import.json.import")}
</Button>
</div>
</ImportCardShell>
);
}
// ─────────────────────────── CSV import ───────────────────────────
/**
* Downloadable CSV example. Header is order-independent server-side, but the
* example pins the documented order so the docs + the route schema never
* drift. Exported so a test can assert it stays a valid header.
*/
export const EXAMPLE_CSV = [
CSV_EXAMPLE_COLUMNS.join(","),
"WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,morning,",
"BLOOD_GLUCOSE,5.3,mmol/L,2026-05-01T08:05:00+02:00,FASTING,,meter-001",
"BLOOD_PRESSURE_SYS,120,mmHg,2026-05-01T08:05:00+02:00,,,",
].join("\n");
interface CsvRowResult {
line: number;
status: "inserted" | "updated" | "skipped";
reason?: string;
}
interface CsvImportResult {
inserted: number;
updated: number;
skipped: number;
total: number;
dryRun: boolean;
rows: CsvRowResult[];
}
function CsvImportCard() {
const { t } = useTranslations();
const fileInputRef = useRef<HTMLInputElement>(null);
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<CsvImportResult | null>(null);
const textareaId = useId();
async function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setError(null);
setResult(null);
try {
const content = await file.text();
setText(content);
} catch {
setError(t("settings.sections.export.import.csv.readFailed"));
}
}
function downloadExample() {
const blob = new Blob([EXAMPLE_CSV], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "healthlog-import-example.csv";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
async function send(dryRun: boolean) {
setError(null);
setResult(null);
if (text.trim().length === 0) {
setError(t("settings.sections.export.import.csv.empty"));
return;
}
setBusy(true);
try {
const res = await apiFetchRaw(
`/api/import/csv${dryRun ? "?dryRun=1" : ""}`,
{
method: "POST",
headers: { "Content-Type": "text/csv" },
credentials: "include",
body: text,
},
);
if (res.status === 429) {
setError(t("settings.sections.export.import.csv.rateLimited"));
return;
}
if (res.status === 413) {
setError(t("settings.sections.export.import.csv.tooLarge"));
return;
}
if (res.status === 422) {
const body = await res.json().catch(() => null);
setError(
body?.error ??
t("settings.sections.export.import.csv.invalidPayload"),
);
return;
}
if (!res.ok) {
setError(t("settings.sections.export.import.csv.failed"));
return;
}
const data = (await res.json()).data as CsvImportResult;
setResult(data);
} catch {
setError(t("settings.sections.export.import.csv.failed"));
} finally {
setBusy(false);
}
}
const errorRows = result?.rows.filter((r) => r.status === "skipped") ?? [];
return (
<ImportCardShell
testId="import-card-csv"
icon={FileSpreadsheet}
title={t("settings.sections.export.import.csv.title")}
description={t("settings.sections.export.import.csv.description")}
>
<div className="space-y-1.5">
<Label htmlFor={textareaId} className="text-xs">
{t("settings.sections.export.import.csv.pasteLabel")}
</Label>
<Textarea
id={textareaId}
data-testid="import-csv-textarea"
value={text}
onChange={(e) => setText(e.target.value)}
rows={5}
maxLength={MAX_PASTE_CHARS}
spellCheck={false}
placeholder="type,value,unit,measuredAt,…"
className="font-mono text-xs"
/>
<p className="text-muted-foreground text-right text-xs tabular-nums">
{t("settings.sections.export.import.charCount", {
used: text.length,
max: MAX_PASTE_CHARS,
})}
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".csv,text/csv"
className="sr-only"
aria-label={t("settings.sections.export.import.csv.fileInputLabel")}
onChange={onFileChange}
/>
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.import.csv.schemaHint")}{" "}
<Link
href="/docs/integrations/data-import"
className="text-primary underline underline-offset-2"
>
{t("settings.sections.export.import.csv.docsLink")}
</Link>
</p>
<div aria-live="polite" className="space-y-2">
{result && (
<div data-testid="import-csv-result" className="space-y-1.5">
<p className="text-foreground flex items-start gap-2 text-xs">
<CheckCircle2
className="text-success mt-0.5 h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<span>
{result.dryRun
? t("settings.sections.export.import.csv.previewSummary", {
inserted: result.inserted,
skipped: result.skipped,
})
: t("settings.sections.export.import.csv.resultSummary", {
inserted: result.inserted,
updated: result.updated,
skipped: result.skipped,
})}
</span>
</p>
{errorRows.length > 0 && (
<ul className="text-muted-foreground max-h-32 space-y-0.5 overflow-auto text-xs">
{errorRows.slice(0, 50).map((r) => (
<li key={r.line}>
{t("settings.sections.export.import.csv.rowError", {
line: r.line,
reason: r.reason ?? "skipped",
})}
</li>
))}
</ul>
)}
</div>
)}
{error && (
<p
role="alert"
className="text-destructive flex items-start gap-2 text-sm"
>
<AlertCircle
className="mt-0.5 h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<span>{error}</span>
</p>
)}
</div>
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => fileInputRef.current?.click()}
data-testid="import-csv-choose-file"
>
<Upload className="h-3.5 w-3.5" />
{t("settings.sections.export.import.csv.uploadFile")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={downloadExample}
data-testid="import-csv-download-example"
>
<Download className="h-3.5 w-3.5" />
{t("settings.sections.export.import.csv.downloadExample")}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={busy || text.trim().length === 0}
onClick={() => void send(true)}
data-testid="import-csv-preview"
>
{busy && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.sections.export.import.csv.preview")}
</Button>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={busy || text.trim().length === 0}
onClick={() => void send(false)}
data-testid="import-action-csv"
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<FileSpreadsheet className="h-3.5 w-3.5" />
)}
{t("settings.sections.export.import.csv.import")}
</Button>
</div>
</ImportCardShell>
);
}
@@ -0,0 +1,274 @@
"use client";
import { useCallback, useId, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { AlertCircle, CheckCircle2, Loader2, Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { ImportCardShell } from "./import-card-shell";
/** Terminal states the status poll stops on. */
const TERMINAL_STATES: readonly string[] = ["done", "failed"];
interface JobStatus {
jobId: string;
status: string;
progress: {
currentPhase?: string;
recordsRead?: number;
rowsUpserted?: number;
percent?: number | null;
} | null;
result: {
totals?: { recordsRead?: number; rowsUpserted?: number };
clinical?: { skipped?: number };
} | null;
failureReason: string | null;
}
export function AppleHealthImportCard() {
const { t } = useTranslations();
const fileInputRef = useRef<HTMLInputElement>(null);
const [jobId, setJobId] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [dragActive, setDragActive] = useState(false);
const dropDescId = useId();
const statusQuery = useQuery({
queryKey: queryKeys.importJobStatus(jobId ?? "none"),
enabled: jobId !== null,
refetchInterval: (query) => {
const data = query.state.data as JobStatus | undefined;
if (data && TERMINAL_STATES.includes(data.status)) return false;
return 2000;
},
queryFn: async (): Promise<JobStatus> => {
return apiGet<JobStatus>(
`/api/import/apple-health-export/${jobId}/status`,
{ credentials: "include" },
);
},
});
const upload = useCallback(
async (file: File) => {
setUploadError(null);
setUploading(true);
setJobId(null);
try {
const form = new FormData();
form.append("file", file);
const res = await apiFetchRaw("/api/import/apple-health-export", {
method: "POST",
credentials: "include",
body: form,
});
if (res.status === 429) {
setUploadError(
t("settings.sections.export.import.appleHealth.rateLimited"),
);
return;
}
if (res.status === 413) {
setUploadError(
t("settings.sections.export.import.appleHealth.tooLarge"),
);
return;
}
if (!res.ok) {
setUploadError(
t("settings.sections.export.import.appleHealth.uploadFailed"),
);
return;
}
const body = (await res.json()).data as {
jobId: string;
status: string;
};
if (!body?.jobId) {
setUploadError(
t("settings.sections.export.import.appleHealth.uploadFailed"),
);
return;
}
setJobId(body.jobId);
} catch {
setUploadError(
t("settings.sections.export.import.appleHealth.uploadFailed"),
);
} finally {
setUploading(false);
}
},
[t],
);
function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) void upload(file);
// Reset so picking the same file twice re-fires the change event.
e.target.value = "";
}
function onDrop(e: React.DragEvent<HTMLDivElement>) {
e.preventDefault();
setDragActive(false);
const file = e.dataTransfer.files?.[0];
if (file) void upload(file);
}
const status = statusQuery.data ?? null;
const isRunning = status !== null && !TERMINAL_STATES.includes(status.status);
const isDone = status?.status === "done";
const isFailed = status?.status === "failed";
const busy = uploading || isRunning;
const percent = status?.progress?.percent ?? null;
return (
<ImportCardShell
testId="import-card-apple-health"
icon={Upload}
title={t("settings.sections.export.import.appleHealth.title")}
description={t("settings.sections.export.import.appleHealth.description")}
>
{/* Keyboard-operable drop area. The hidden file input does the
actual file selection; the div forwards Enter/Space to it. */}
<div
role="button"
tabIndex={0}
aria-describedby={dropDescId}
aria-disabled={busy}
onClick={() => !busy && fileInputRef.current?.click()}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && !busy) {
e.preventDefault();
fileInputRef.current?.click();
}
}}
onDragOver={(e) => {
e.preventDefault();
if (!busy) setDragActive(true);
}}
onDragLeave={() => setDragActive(false)}
onDrop={(e) => !busy && onDrop(e)}
className={cn(
"border-border bg-muted/20 hover:bg-muted/40 focus-visible:ring-ring/50 flex min-h-24 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border border-dashed p-4 text-center transition-colors focus-visible:ring-2 focus-visible:outline-none",
dragActive && "border-primary bg-primary/5",
busy && "pointer-events-none opacity-60",
)}
>
<Upload className="text-muted-foreground h-5 w-5" aria-hidden="true" />
<span className="text-foreground text-sm font-medium">
{t("settings.sections.export.import.appleHealth.dropLabel")}
</span>
<span id={dropDescId} className="text-muted-foreground text-xs">
{t("settings.sections.export.import.appleHealth.dropHint")}
</span>
</div>
<input
ref={fileInputRef}
type="file"
accept=".zip,application/zip"
className="sr-only"
aria-label={t(
"settings.sections.export.import.appleHealth.fileInputLabel",
)}
onChange={onFileChange}
/>
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.import.appleHealth.idempotencyNote")}
</p>
{/* Live progress / outcome — announced to assistive tech. */}
<div aria-live="polite" className="space-y-2">
{uploading && (
<p className="text-muted-foreground flex items-center gap-2 text-xs">
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
{t("settings.sections.export.import.appleHealth.uploading")}
</p>
)}
{isRunning && (
<div
data-testid="import-apple-health-progress"
className="space-y-1.5"
>
<p className="text-muted-foreground flex items-center gap-2 text-xs">
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
{t(
`settings.sections.export.import.appleHealth.phase.${status?.status ?? "queued"}`,
)}
</p>
{typeof percent === "number" && <Progress value={percent} />}
{typeof status?.progress?.rowsUpserted === "number" && (
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.import.appleHealth.rowsImported", {
count: status.progress.rowsUpserted,
})}
</p>
)}
</div>
)}
{isDone && (
<div
data-testid="import-apple-health-result"
className="text-foreground flex items-start gap-2 text-xs"
>
<CheckCircle2
className="text-success mt-0.5 h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<span>
{t("settings.sections.export.import.appleHealth.doneSummary", {
imported: status?.result?.totals?.rowsUpserted ?? 0,
read: status?.result?.totals?.recordsRead ?? 0,
skipped: status?.result?.clinical?.skipped ?? 0,
})}
</span>
</div>
)}
{(isFailed || uploadError) && (
<p
role="alert"
className="text-destructive flex items-start gap-2 text-sm"
>
<AlertCircle
className="mt-0.5 h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<span>
{uploadError ??
status?.failureReason ??
t("settings.sections.export.import.appleHealth.failed")}
</span>
</p>
)}
</div>
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={busy}
onClick={() => fileInputRef.current?.click()}
data-testid="import-action-apple-health"
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Upload className="h-3.5 w-3.5" />
)}
{t("settings.sections.export.import.appleHealth.choose")}
</Button>
</div>
</ImportCardShell>
);
}
@@ -0,0 +1,6 @@
/**
* Upper bound for the paste textareas, mirroring the 16 MB server-side body
* ceiling on `/api/import` and `/api/import/csv`. Caps an accidental over-paste
* before it ever reaches the route and feeds the live character counter.
*/
export const MAX_PASTE_CHARS = 16 * 1024 * 1024;
@@ -0,0 +1,275 @@
"use client";
import { useId, useRef, useState } from "react";
import Link from "next/link";
import {
AlertCircle,
CheckCircle2,
Download,
FileSpreadsheet,
Loader2,
Upload,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "@/lib/i18n/context";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { ImportCardShell } from "./import-card-shell";
import { MAX_PASTE_CHARS } from "./constants";
import { EXAMPLE_CSV } from "./import-examples";
interface CsvRowResult {
line: number;
status: "inserted" | "updated" | "skipped";
reason?: string;
}
interface CsvImportResult {
inserted: number;
updated: number;
skipped: number;
total: number;
dryRun: boolean;
rows: CsvRowResult[];
}
export function CsvImportCard() {
const { t } = useTranslations();
const fileInputRef = useRef<HTMLInputElement>(null);
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<CsvImportResult | null>(null);
const textareaId = useId();
async function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setError(null);
setResult(null);
try {
const content = await file.text();
setText(content);
} catch {
setError(t("settings.sections.export.import.csv.readFailed"));
}
}
function downloadExample() {
const blob = new Blob([EXAMPLE_CSV], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "healthlog-import-example.csv";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
async function send(dryRun: boolean) {
setError(null);
setResult(null);
if (text.trim().length === 0) {
setError(t("settings.sections.export.import.csv.empty"));
return;
}
setBusy(true);
try {
const res = await apiFetchRaw(
`/api/import/csv${dryRun ? "?dryRun=1" : ""}`,
{
method: "POST",
headers: { "Content-Type": "text/csv" },
credentials: "include",
body: text,
},
);
if (res.status === 429) {
setError(t("settings.sections.export.import.csv.rateLimited"));
return;
}
if (res.status === 413) {
setError(t("settings.sections.export.import.csv.tooLarge"));
return;
}
if (res.status === 422) {
const body = await res.json().catch(() => null);
setError(
body?.error ??
t("settings.sections.export.import.csv.invalidPayload"),
);
return;
}
if (!res.ok) {
setError(t("settings.sections.export.import.csv.failed"));
return;
}
const data = (await res.json()).data as CsvImportResult;
setResult(data);
} catch {
setError(t("settings.sections.export.import.csv.failed"));
} finally {
setBusy(false);
}
}
const errorRows = result?.rows.filter((r) => r.status === "skipped") ?? [];
return (
<ImportCardShell
testId="import-card-csv"
icon={FileSpreadsheet}
title={t("settings.sections.export.import.csv.title")}
description={t("settings.sections.export.import.csv.description")}
>
<div className="space-y-1.5">
<Label htmlFor={textareaId} className="text-xs">
{t("settings.sections.export.import.csv.pasteLabel")}
</Label>
<Textarea
id={textareaId}
data-testid="import-csv-textarea"
value={text}
onChange={(e) => setText(e.target.value)}
rows={5}
maxLength={MAX_PASTE_CHARS}
spellCheck={false}
placeholder="type,value,unit,measuredAt,…"
className="font-mono text-xs"
/>
<p className="text-muted-foreground text-right text-xs tabular-nums">
{t("settings.sections.export.import.charCount", {
used: text.length,
max: MAX_PASTE_CHARS,
})}
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".csv,text/csv"
className="sr-only"
aria-label={t("settings.sections.export.import.csv.fileInputLabel")}
onChange={onFileChange}
/>
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.import.csv.schemaHint")}{" "}
<Link
href="/docs/integrations/data-import"
className="text-primary underline underline-offset-2"
>
{t("settings.sections.export.import.csv.docsLink")}
</Link>
</p>
<div aria-live="polite" className="space-y-2">
{result && (
<div data-testid="import-csv-result" className="space-y-1.5">
<p className="text-foreground flex items-start gap-2 text-xs">
<CheckCircle2
className="text-success mt-0.5 h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<span>
{result.dryRun
? t("settings.sections.export.import.csv.previewSummary", {
inserted: result.inserted,
skipped: result.skipped,
})
: t("settings.sections.export.import.csv.resultSummary", {
inserted: result.inserted,
updated: result.updated,
skipped: result.skipped,
})}
</span>
</p>
{errorRows.length > 0 && (
<ul className="text-muted-foreground max-h-32 space-y-0.5 overflow-auto text-xs">
{errorRows.slice(0, 50).map((r) => (
<li key={r.line}>
{t("settings.sections.export.import.csv.rowError", {
line: r.line,
reason: r.reason ?? "skipped",
})}
</li>
))}
</ul>
)}
</div>
)}
{error && (
<p
role="alert"
className="text-destructive flex items-start gap-2 text-sm"
>
<AlertCircle
className="mt-0.5 h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<span>{error}</span>
</p>
)}
</div>
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => fileInputRef.current?.click()}
data-testid="import-csv-choose-file"
>
<Upload className="h-3.5 w-3.5" />
{t("settings.sections.export.import.csv.uploadFile")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={downloadExample}
data-testid="import-csv-download-example"
>
<Download className="h-3.5 w-3.5" />
{t("settings.sections.export.import.csv.downloadExample")}
</Button>
<Button
type="button"
variant="secondary"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={busy || text.trim().length === 0}
onClick={() => void send(true)}
data-testid="import-csv-preview"
>
{busy && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.sections.export.import.csv.preview")}
</Button>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={busy || text.trim().length === 0}
onClick={() => void send(false)}
data-testid="import-action-csv"
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<FileSpreadsheet className="h-3.5 w-3.5" />
)}
{t("settings.sections.export.import.csv.import")}
</Button>
</div>
</ImportCardShell>
);
}
@@ -0,0 +1,31 @@
import { SettingsCard } from "@/components/settings/settings-card";
export interface ImportCardShellProps {
testId: string;
icon: React.ComponentType<{ className?: string }>;
title: string;
description: string;
children: React.ReactNode;
}
export function ImportCardShell({
testId,
icon: Icon,
title,
description,
children,
}: ImportCardShellProps) {
return (
<SettingsCard data-testid={testId} className="flex h-full flex-col">
<div className="flex items-center gap-2">
<Icon
className="text-muted-foreground h-5 w-5 shrink-0"
aria-hidden="true"
/>
<h3 className="text-base font-semibold">{title}</h3>
</div>
<p className="text-muted-foreground mt-1 text-xs">{description}</p>
<div className="mt-3 flex flex-1 flex-col gap-3">{children}</div>
</SettingsCard>
);
}
@@ -0,0 +1,68 @@
import { CSV_EXAMPLE_COLUMNS } from "@/lib/import/csv-measurements";
/**
* Small valid example payload, minted as a downloadable Blob by the
* "Download example" button and used by the docs. Exported so a test can
* assert it stays a valid import body — the button and the route schema
* must never drift.
*/
export const EXAMPLE_IMPORT = {
measurements: [
{
type: "WEIGHT",
value: 80.5,
unit: "kg",
measuredAt: "2026-05-01T08:00:00.000Z",
source: "manual",
notes: "morning",
},
{
type: "BLOOD_PRESSURE_SYS",
value: 120,
unit: "mmHg",
measuredAt: "2026-05-01T08:05:00.000Z",
},
{
type: "BLOOD_PRESSURE_DIA",
value: 80,
unit: "mmHg",
measuredAt: "2026-05-01T08:05:00.000Z",
},
],
moodEntries: [
{
date: "2026-05-01",
mood: "GUT",
score: 4,
tags: "work,exercise",
},
],
};
/**
* Client-side parse guard for the JSON-import textarea. Returns the
* parsed value when the text is valid JSON, otherwise a failure marker —
* we never POST an unparseable body. Exported for unit testing so the
* guard is covered without a browser.
*/
export function parseImportJson(
text: string,
): { ok: true; value: unknown } | { ok: false } {
try {
return { ok: true, value: JSON.parse(text) };
} catch {
return { ok: false };
}
}
/**
* Downloadable CSV example. Header is order-independent server-side, but the
* example pins the documented order so the docs + the route schema never
* drift. Exported so a test can assert it stays a valid header.
*/
export const EXAMPLE_CSV = [
CSV_EXAMPLE_COLUMNS.join(","),
"WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,morning,",
"BLOOD_GLUCOSE,5.3,mmol/L,2026-05-01T08:05:00+02:00,FASTING,,meter-001",
"BLOOD_PRESSURE_SYS,120,mmHg,2026-05-01T08:05:00+02:00,,,",
].join("\n");
@@ -0,0 +1,56 @@
"use client";
/**
* v1.15.7 — Settings → Export & Import → Import area.
*
* Web UI for the three import backends. Each source is a self-contained
* card under this directory:
*
* 1. Apple Health `export.zip` — `apple-health-import-card.tsx`
* (multipart POST + status poll).
* 2. Generic JSON import — `json-import-card.tsx`
* (file/paste, client-side parseability guard).
* 3. CSV import — `csv-import-card.tsx`
* (file/paste, dry-run preview).
*
* Both controls surface rate-limit (429) and error states cleanly and
* announce progress to assistive tech via `aria-live` regions.
*/
import { useTranslations } from "@/lib/i18n/context";
import { AppleHealthImportCard } from "./apple-health-import-card";
import { JsonImportCard } from "./json-import-card";
import { CsvImportCard } from "./csv-import-card";
export {
EXAMPLE_IMPORT,
EXAMPLE_CSV,
parseImportJson,
} from "./import-examples";
export function ImportPanel() {
const { t } = useTranslations();
return (
<section
aria-labelledby="settings-section-import-title"
className="space-y-3"
>
<div className="space-y-1">
<h2
id="settings-section-import-title"
className="text-base font-semibold tracking-tight"
>
{t("settings.sections.export.import.heading")}
</h2>
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.import.description")}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<AppleHealthImportCard />
<JsonImportCard />
<CsvImportCard />
</div>
</section>
);
}
@@ -0,0 +1,235 @@
"use client";
import { useId, useRef, useState } from "react";
import Link from "next/link";
import {
AlertCircle,
CheckCircle2,
Download,
FileJson,
Loader2,
Upload,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "@/lib/i18n/context";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import { ImportCardShell } from "./import-card-shell";
import { MAX_PASTE_CHARS } from "./constants";
import { EXAMPLE_IMPORT, parseImportJson } from "./import-examples";
interface JsonImportResult {
measurements: number;
moodEntries: number;
skipped: number;
}
export function JsonImportCard() {
const { t } = useTranslations();
const fileInputRef = useRef<HTMLInputElement>(null);
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<JsonImportResult | null>(null);
const textareaId = useId();
async function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setError(null);
setResult(null);
try {
const content = await file.text();
setText(content);
} catch {
setError(t("settings.sections.export.import.json.readFailed"));
}
}
function downloadExample() {
const blob = new Blob([JSON.stringify(EXAMPLE_IMPORT, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "healthlog-import-example.json";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
async function handleImport() {
setError(null);
setResult(null);
const parsed = parseImportJson(text);
if (!parsed.ok) {
setError(t("settings.sections.export.import.json.invalidJson"));
return;
}
const payload = parsed.value;
setBusy(true);
try {
const res = await apiFetchRaw("/api/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(payload),
});
if (res.status === 429) {
setError(t("settings.sections.export.import.json.rateLimited"));
return;
}
if (res.status === 422) {
const body = await res.json().catch(() => null);
setError(
body?.error ??
t("settings.sections.export.import.json.invalidPayload"),
);
return;
}
if (!res.ok) {
setError(t("settings.sections.export.import.json.failed"));
return;
}
const data = (await res.json()).data as JsonImportResult;
setResult({
measurements: data?.measurements ?? 0,
moodEntries: data?.moodEntries ?? 0,
skipped: data?.skipped ?? 0,
});
} catch {
setError(t("settings.sections.export.import.json.failed"));
} finally {
setBusy(false);
}
}
return (
<ImportCardShell
testId="import-card-json"
icon={FileJson}
title={t("settings.sections.export.import.json.title")}
description={t("settings.sections.export.import.json.description")}
>
<div className="space-y-1.5">
<Label htmlFor={textareaId} className="text-xs">
{t("settings.sections.export.import.json.pasteLabel")}
</Label>
<Textarea
id={textareaId}
data-testid="import-json-textarea"
value={text}
onChange={(e) => setText(e.target.value)}
rows={5}
maxLength={MAX_PASTE_CHARS}
spellCheck={false}
placeholder='{"measurements":[…],"moodEntries":[…]}'
className="font-mono text-xs"
/>
<p className="text-muted-foreground text-right text-xs tabular-nums">
{t("settings.sections.export.import.charCount", {
used: text.length,
max: MAX_PASTE_CHARS,
})}
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".json,application/json"
className="sr-only"
aria-label={t("settings.sections.export.import.json.fileInputLabel")}
onChange={onFileChange}
/>
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.import.json.schemaHint")}{" "}
<Link
href="/docs/integrations/data-import"
className="text-primary underline underline-offset-2"
>
{t("settings.sections.export.import.json.docsLink")}
</Link>
</p>
<div aria-live="polite" className="space-y-2">
{result && (
<p
data-testid="import-json-result"
className="text-foreground flex items-start gap-2 text-xs"
>
<CheckCircle2
className="text-success mt-0.5 h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<span>
{t("settings.sections.export.import.json.resultSummary", {
measurements: result.measurements,
moods: result.moodEntries,
skipped: result.skipped,
})}
</span>
</p>
)}
{error && (
<p
role="alert"
className="text-destructive flex items-start gap-2 text-sm"
>
<AlertCircle
className="mt-0.5 h-3.5 w-3.5 shrink-0"
aria-hidden="true"
/>
<span>{error}</span>
</p>
)}
</div>
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => fileInputRef.current?.click()}
data-testid="import-json-choose-file"
>
<Upload className="h-3.5 w-3.5" />
{t("settings.sections.export.import.json.uploadFile")}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={downloadExample}
data-testid="import-json-download-example"
>
<Download className="h-3.5 w-3.5" />
{t("settings.sections.export.import.json.downloadExample")}
</Button>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={busy || text.trim().length === 0}
onClick={handleImport}
data-testid="import-action-json"
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<FileJson className="h-3.5 w-3.5" />
)}
{t("settings.sections.export.import.json.import")}
</Button>
</div>
</ImportCardShell>
);
}
+65 -2
View File
@@ -1,11 +1,25 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// v1.18.11 (W3): capture wide-event warnings so the non-ok-status path can
// be asserted. `getEvent()` returns null outside a request context, so
// without this mock the `getEvent()?.addWarning(...)` call is a silent
// no-op and there is nothing to assert against.
const addWarningSpy = vi.fn();
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
return {
...actual,
getEvent: () => ({ addWarning: addWarningSpy }),
};
});
const ORIGINAL_ENV = { ...process.env };
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.IP_GEO_LOOKUP_URL;
delete process.env.IP_GEO_LOOKUP_DISABLED;
addWarningSpy.mockClear();
vi.resetModules();
});
@@ -55,11 +69,14 @@ describe("lookupIpLocation IP-geolocation HTTPS guard", () => {
);
}
it("uses HTTPS by default (ipwho.is)", async () => {
it("uses the ip-api.com HTTPS endpoint by default", async () => {
// v1.18.11 (W3): default provider is ip-api.com (ipwho.is 403s on
// datacentre egress). The wire URL must be the ip-api.com JSON path
// and the parser must accept its `status`/`countryCode` shape.
const fetchSpy = vi
.fn()
.mockResolvedValue(
jsonOk({ success: true, city: "Berlin", country_code: "DE" }),
jsonOk({ status: "success", city: "Berlin", countryCode: "DE" }),
);
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
@@ -67,6 +84,7 @@ describe("lookupIpLocation IP-geolocation HTTPS guard", () => {
expect(await lookupIpLocation("8.8.8.8")).toBe("Berlin, DE");
const url = fetchSpy.mock.calls[0]?.[0] as string;
expect(url.startsWith("https://")).toBe(true);
expect(url).toBe("https://ip-api.com/json/8.8.8.8");
});
it("refuses to call non-HTTPS configured providers", async () => {
@@ -104,6 +122,51 @@ describe("lookupIpLocation IP-geolocation HTTPS guard", () => {
});
});
// v1.18.11 (W3): a non-ok HTTP status (403 free-plan/CORS rejection, 429
// rate-limit, 5xx) must surface a wide-event warning instead of being
// swallowed as a clean miss. The ipwho.is 403 that produced the prod "—"
// never emitted a single signal; this guards against that regression.
describe("lookupIpLocation non-ok status warning (v1.18.11 W3)", () => {
function nonOk(status: number): Response {
return new Response("", {
status,
headers: { "content-type": "application/json" },
});
}
for (const status of [403, 429, 503]) {
it(`returns null and warns on HTTP ${status}`, async () => {
const fetchSpy = vi.fn().mockResolvedValue(nonOk(status));
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("8.8.8.8")).toBeNull();
expect(addWarningSpy).toHaveBeenCalledTimes(1);
expect(addWarningSpy.mock.calls[0]?.[0]).toContain(String(status));
});
}
it("does not warn on a clean 200 success", async () => {
const fetchSpy = vi.fn().mockResolvedValue(
new Response(
new TextEncoder().encode(
JSON.stringify({
status: "success",
city: "Berlin",
countryCode: "DE",
}),
).buffer as ArrayBuffer,
{ status: 200, headers: { "content-type": "application/json" } },
),
);
vi.stubGlobal("fetch", fetchSpy);
const { lookupIpLocation } = await import("../geo");
expect(await lookupIpLocation("8.8.8.8")).toBe("Berlin, DE");
expect(addWarningSpy).not.toHaveBeenCalled();
});
});
// v1.4.16 A8a: the maintainer spotted "Nrnberg" rendered in /admin/login-overview
// for an audit row that should have read "Nürnberg, DE". The geo source
// (ipwho.is) returns valid UTF-8, but `Response.json()` defers its
@@ -96,10 +96,12 @@ const EXPECTED_TYPES = [
// ── v1.17.1 — Oura coverage completion ──
"SLEEP_SCORE",
"BODY_TEMPERATURE_DEVIATION",
// ── v1.19.0 — Oura resilience (ordinal-encoded level) ──
"RESILIENCE",
] as const;
describe("measurementTypeEnum coverage", () => {
it("exposes the 65 canonical measurement types", () => {
it("exposes the 66 canonical measurement types", () => {
expect([...measurementTypeEnum.options].sort()).toEqual(
[...EXPECTED_TYPES].sort(),
);
@@ -236,6 +238,12 @@ describe("measurementTypeEnum coverage", () => {
// doctor-report-pdf-core.ts for the matching exclusion rationale.
"SLEEP_SCORE",
"BODY_TEMPERATURE_DEVIATION",
// v1.19.0 — Oura resilience. The resilience level is an ordinal-encoded
// categorical band (limited=1 … exceptional=5), a derived recovery
// composite, not a measured clinical vital — it surfaces on the
// Insights / measurement-list surfaces and never belongs in the clinical
// vitals PDF. See doctor-report-pdf-core.ts for the matching rationale.
"RESILIENCE",
]);
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
+62
View File
@@ -196,4 +196,66 @@ describe("CodexClient", () => {
).rejects.toThrow("Codex request failed (500)");
expect(onRefresh).not.toHaveBeenCalled();
});
it("emits input_image content blocks when images are present", async () => {
const mockFetch = vi
.fn()
.mockResolvedValue(
sseResponse([messageDoneEvent('{"summary":"ok"}'), completedEvent(50)]),
);
vi.stubGlobal("fetch", mockFetch);
const client = new CodexClient({
accessToken: "test-token",
accountId: "acct-test",
onTokenRefresh: vi
.fn()
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
});
await client.generateCompletion({
systemPrompt: "Transcribe.",
userPrompt: "Read this report.",
images: [{ mediaType: "image/png", dataBase64: "QUJD" }],
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
const content = body.input[0].content;
expect(content[0]).toEqual({
type: "input_text",
text: "Read this report.",
});
expect(content[1]).toEqual({
type: "input_image",
image_url: "data:image/png;base64,QUJD",
detail: "high",
});
});
it("keeps the text-only content shape when no images are present", async () => {
const mockFetch = vi
.fn()
.mockResolvedValue(
sseResponse([messageDoneEvent('{"summary":"ok"}'), completedEvent(50)]),
);
vi.stubGlobal("fetch", mockFetch);
const client = new CodexClient({
accessToken: "test-token",
accountId: "acct-test",
onTokenRefresh: vi
.fn()
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
});
await client.generateCompletion({
systemPrompt: "Answer.",
userPrompt: "Plain text only.",
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.input[0].content).toEqual([
{ type: "input_text", text: "Plain text only." },
]);
});
});
+21 -2
View File
@@ -54,8 +54,27 @@ describe("supportsVisionForConfig", () => {
expect(supportsVisionForConfig("local", null)).toBe(true);
});
it("never reports vision for codex or none", () => {
expect(supportsVisionForConfig("codex", "gpt-4o")).toBe(false);
it("reports vision for codex only on multimodal slugs", () => {
for (const model of [
"gpt-5.5",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-4o",
"gpt-4.1",
"o1",
]) {
expect(supportsVisionForConfig("codex", model)).toBe(true);
}
// Non-multimodal / unknown slugs and a missing slug → false.
expect(supportsVisionForConfig("codex", "gpt-4")).toBe(false);
expect(supportsVisionForConfig("codex", "gpt-3.5-turbo")).toBe(false);
expect(supportsVisionForConfig("codex", null)).toBe(false);
// The `-codex` specialist slugs are text-only despite the gpt-5 prefix.
expect(supportsVisionForConfig("codex", "gpt-5-codex")).toBe(false);
expect(supportsVisionForConfig("codex", "gpt-5.1-codex-mini")).toBe(false);
});
it("never reports vision for none", () => {
expect(supportsVisionForConfig("none", "gpt-4o")).toBe(false);
});
});
@@ -0,0 +1,171 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
labResult: { findMany: vi.fn() },
},
}));
import { buildLabsSnapshotBlock, sanitizeValueText } from "../labs-snapshot";
import { prisma } from "@/lib/db";
const prismaMock = prisma as unknown as {
labResult: { findMany: ReturnType<typeof vi.fn> };
};
const NOW = new Date("2026-06-21T12:00:00.000Z");
/** A linked numeric lab row (biomarker wins for name/unit/bounds). */
function numericRow(overrides: Record<string, unknown> = {}) {
return {
analyte: "ldl-legacy",
panel: "legacy-panel",
value: 140,
valueText: null,
unit: "legacy-unit",
referenceLow: 50,
referenceHigh: 200,
takenAt: new Date("2026-06-01T08:00:00.000Z"),
biomarkerId: "bm_ldl",
biomarker: {
id: "bm_ldl",
name: "LDL",
unit: "mg/dL",
lowerBound: 0,
upperBound: 130,
panel: "Lipids",
},
...overrides,
};
}
beforeEach(() => {
prismaMock.labResult.findMany.mockReset();
});
describe("buildLabsSnapshotBlock", () => {
it("returns null when the account has no readings", async () => {
prismaMock.labResult.findMany.mockResolvedValue([]);
const block = await buildLabsSnapshotBlock("user_1", NOW);
expect(block).toBeNull();
});
it("resolves name/unit/bounds from the linked biomarker and flags out-of-range", async () => {
prismaMock.labResult.findMany.mockResolvedValue([numericRow()]);
const block = await buildLabsSnapshotBlock("user_1", NOW);
expect(block).not.toBeNull();
expect(block!.recent).toHaveLength(1);
const r = block!.recent[0];
// Biomarker fields win over the stale per-row legacy columns.
expect(r.analyte).toBe("LDL");
expect(r.unit).toBe("mg/dL");
expect(r.referenceHigh).toBe(130);
expect(r.panel).toBe("Lipids");
// 140 > upperBound 130 → above.
expect(r.rangeStatus).toBe("above");
expect(r.value).toBe(140);
expect(r.valueText).toBeNull();
});
it("classifies an in-range numeric reading", async () => {
prismaMock.labResult.findMany.mockResolvedValue([
numericRow({ value: 100 }),
]);
const block = await buildLabsSnapshotBlock("user_1", NOW);
expect(block!.recent[0].rangeStatus).toBe("in-range");
});
it("includes a qualitative reading with unknown range status", async () => {
prismaMock.labResult.findMany.mockResolvedValue([
{
analyte: "Hepatitis B",
panel: null,
value: null,
valueText: "negativ",
unit: "",
referenceLow: null,
referenceHigh: null,
takenAt: new Date("2026-05-10T08:00:00.000Z"),
biomarkerId: "bm_hep",
biomarker: {
id: "bm_hep",
name: "Hepatitis B",
unit: "",
lowerBound: null,
upperBound: null,
panel: null,
},
},
]);
const block = await buildLabsSnapshotBlock("user_1", NOW);
const r = block!.recent[0];
expect(r.value).toBeNull();
expect(r.valueText).toBe("negativ");
expect(r.rangeStatus).toBe("unknown");
});
it("keeps only the most-recent reading per biomarker", async () => {
// findMany returns newest-first; the older duplicate must be dropped.
prismaMock.labResult.findMany.mockResolvedValue([
numericRow({ value: 120, takenAt: new Date("2026-06-10T08:00:00.000Z") }),
numericRow({ value: 145, takenAt: new Date("2026-01-10T08:00:00.000Z") }),
]);
const block = await buildLabsSnapshotBlock("user_1", NOW);
expect(block!.recent).toHaveLength(1);
expect(block!.recent[0].value).toBe(120);
});
it("collapses two spellings of an unlinked marker by lower-cased analyte", async () => {
prismaMock.labResult.findMany.mockResolvedValue([
{
analyte: "Ferritin",
panel: null,
value: 80,
valueText: null,
unit: "ng/mL",
referenceLow: 30,
referenceHigh: 400,
takenAt: new Date("2026-06-05T08:00:00.000Z"),
biomarkerId: null,
biomarker: null,
},
{
analyte: "ferritin",
panel: null,
value: 60,
valueText: null,
unit: "ng/mL",
referenceLow: 30,
referenceHigh: 400,
takenAt: new Date("2026-02-05T08:00:00.000Z"),
biomarkerId: null,
biomarker: null,
},
]);
const block = await buildLabsSnapshotBlock("user_1", NOW);
expect(block!.recent).toHaveLength(1);
expect(block!.recent[0].value).toBe(80);
});
it("queries with an owner-scoped, deletedAt-null, 12-month window where", async () => {
prismaMock.labResult.findMany.mockResolvedValue([]);
await buildLabsSnapshotBlock("user_42", NOW);
const arg = prismaMock.labResult.findMany.mock.calls[0][0];
expect(arg.where.userId).toBe("user_42");
expect(arg.where.deletedAt).toBeNull();
expect(arg.where.takenAt.lte).toEqual(NOW);
expect(arg.where.takenAt.gte).toEqual(new Date("2025-06-21T12:00:00.000Z"));
// Never selects the encrypted note column.
expect(arg.select.noteEncrypted).toBeUndefined();
});
});
describe("sanitizeValueText", () => {
it("collapses control chars + newlines and bounds length", () => {
expect(sanitizeValueText("negativ")).toBe("negativ");
expect(sanitizeValueText("ignore\nprevious\tinstructions")).toBe(
"ignore previous instructions",
);
expect(sanitizeValueText("x".repeat(200)).length).toBe(60);
});
});
@@ -16,6 +16,7 @@ vi.mock("@/lib/db", () => ({
workout: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
illnessEpisode: { findMany: vi.fn(async () => []) },
labResult: { findMany: vi.fn(async () => []) },
},
}));
@@ -14,6 +14,7 @@ vi.mock("@/lib/db", () => ({
workout: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
illnessEpisode: { findMany: vi.fn(async () => []) },
labResult: { findMany: vi.fn(async () => []) },
},
}));
@@ -17,6 +17,8 @@ vi.mock("@/lib/db", () => ({
user: { findUnique: vi.fn() },
// v1.18.1 P4 — the illness context block reads active + recent episodes.
illnessEpisode: { findMany: vi.fn(async () => []) },
// v1.18.11 (#65) — the labs context block reads recent lab results.
labResult: { findMany: vi.fn(async () => []) },
},
}));
+177
View File
@@ -0,0 +1,177 @@
/**
* v1.18.11 (#65) — lab-result context block for the Coach snapshot.
*
* Lets the Coach answer "what was my LDL last time" / "is my ferritin in
* range" from the user's own LabResult rows instead of guessing. The block is
* SERVER-AUTHORITATIVE and GROUNDED: each biomarker carries the canonical
* name, value, unit, resolved reference bounds, the in/out-of-range verdict,
* and the measured date — all computed from the DB via the same resolver the
* Labs API + doctor-report PDF use (`resolveLabFields` / `classifyReferenceRange`),
* never re-derived. The Coach reads these numbers verbatim and must not invent
* a lab value the block does not carry.
*
* Bounding (no unbounded history dump):
* - MOST RECENT reading per biomarker only (one row per analyte).
* - Last `LOOKBACK_MONTHS` months — stale panels do not enter the prompt.
* - At most `MAX_BIOMARKERS` biomarkers, newest reading first, so a heavy
* history can never balloon the prompt.
*
* Qualitative readings (`value` null, `valueText` "negativ" / …) are included
* with a neutral `"unknown"` range status — there is nothing to compare against
* the bounds, so no in/out verdict is fabricated. The free-text `valueText` is
* the one user-controlled string here, so it is sanitised (control chars +
* newlines collapsed, length bounded) before it can reach the prompt, mirroring
* the illness block's label handling. The encrypted `noteEncrypted` column is
* never selected — the decrypted note must not reach the prompt.
*
* `userId` is narrowed from the authenticated session by the caller and feeds
* the Prisma `where` field-by-field; it is never an input. Unlike the illness
* block, Labs is intentionally NOT module-gated (mirrors `/api/labs` — the data
* is always owner-scoped and safe to read), so the block is `null` only when the
* account has no recent readings.
*/
import { classifyReferenceRange } from "@/lib/labs/reference-range";
import { resolveLabFields } from "@/lib/labs/serialise";
import { prisma } from "@/lib/db";
/** Only readings within this many months enter the prompt. */
const LOOKBACK_MONTHS = 12;
/** Cap on distinct biomarkers in the block (newest reading first). */
const MAX_BIOMARKERS = 24;
/** Max chars of the user-supplied qualitative `valueText` that may enter the prompt. */
const MAX_VALUE_TEXT_CHARS = 60;
/**
* Cap + strip the user-supplied qualitative result text before it enters the
* Coach LLM prompt. `valueText` is the one user-controlled string in this
* block, so it is a (self-scoped) prompt-injection surface: collapse control
* chars + newlines to spaces so an embedded "ignore previous instructions\n…"
* can't reshape the prompt structure, then bound the length. Mirrors
* `sanitizeLabel` in `illness-snapshot.ts`.
*/
export function sanitizeValueText(text: string): string {
let out = "";
for (const ch of text) {
const code = ch.codePointAt(0) ?? 0;
out += code < 0x20 || (code >= 0x7f && code <= 0x9f) ? " " : ch;
}
return out.replace(/\s+/g, " ").trim().slice(0, MAX_VALUE_TEXT_CHARS);
}
/** One biomarker's most-recent resolved reading. */
export interface CoachLabReading {
/** Canonical analyte name (resolved from the linked biomarker when present). */
analyte: string;
/** Panel grouping, when the resolved marker carries one. */
panel: string | null;
/** Numeric reading; null for a qualitative row (see `valueText`). */
value: number | null;
/** Qualitative result text ("negativ" / …); null for a numeric row. */
valueText: string | null;
/** Resolved unit; empty for a qualitative marker. */
unit: string;
referenceLow: number | null;
referenceHigh: number | null;
/** in-range / below / above / unknown — computed from the resolved bounds. */
rangeStatus: "in-range" | "below" | "above" | "unknown";
/** Measured date (ISO). */
takenAt: string;
}
export interface CoachLabsBlock {
/** Most-recent reading per biomarker, newest first; bounded. */
recent: CoachLabReading[];
}
/**
* Build the labs context block, or `null` when the account has no readings in
* the lookback window. Selects label + value + resolved bounds + dates only —
* `noteEncrypted` is never selected.
*/
export async function buildLabsSnapshotBlock(
userId: string,
now: Date = new Date(),
): Promise<CoachLabsBlock | null> {
const cutoff = new Date(now);
cutoff.setMonth(cutoff.getMonth() - LOOKBACK_MONTHS);
// Newest reading first; we keep only the first row seen per biomarker so the
// block carries the MOST RECENT value per analyte. Bounded fetch: at most a
// reading per capped biomarker plus headroom for older duplicates of the same
// marker. Field-by-field `where` — never a spread.
const rows = await prisma.labResult.findMany({
where: {
userId,
deletedAt: null,
takenAt: { gte: cutoff, lte: now },
},
orderBy: { takenAt: "desc" },
take: MAX_BIOMARKERS * 8,
select: {
analyte: true,
panel: true,
value: true,
valueText: true,
unit: true,
referenceLow: true,
referenceHigh: true,
takenAt: true,
biomarkerId: true,
biomarker: {
select: {
id: true,
name: true,
unit: true,
lowerBound: true,
upperBound: true,
panel: true,
},
},
},
});
if (rows.length === 0) return null;
// De-dup to the most-recent reading per biomarker. Key on the linked
// biomarker id when present (the canonical identity); fall back to the
// lower-cased resolved analyte for any legacy unlinked row so two spellings
// of the same marker still collapse. Rows arrive newest-first, so the first
// key sighting wins.
const seen = new Set<string>();
const recent: CoachLabReading[] = [];
for (const row of rows) {
const resolved = resolveLabFields(row, row.biomarker);
const key = row.biomarkerId ?? `analyte:${resolved.analyte.toLowerCase()}`;
if (seen.has(key)) continue;
seen.add(key);
recent.push({
analyte: resolved.analyte,
panel: resolved.panel,
value: row.value,
valueText: row.valueText ? sanitizeValueText(row.valueText) : null,
unit: resolved.unit,
referenceLow: resolved.referenceLow,
referenceHigh: resolved.referenceHigh,
// A qualitative row has nothing to compare against the bounds → neutral
// "unknown", never a fabricated verdict. A numeric row classifies.
rangeStatus:
row.value === null
? "unknown"
: classifyReferenceRange(
row.value,
resolved.referenceLow,
resolved.referenceHigh,
),
takenAt: row.takenAt.toISOString(),
});
if (recent.length >= MAX_BIOMARKERS) break;
}
if (recent.length === 0) return null;
return { recent };
}
+25
View File
@@ -49,6 +49,7 @@ import { buildCoachMemoryBlock } from "./memory-snapshot";
import { buildTrajectorySnapshotBlock } from "./trajectory-snapshot";
import { buildCycleSnapshotBlock } from "./cycle-snapshot";
import { buildIllnessSnapshotBlock } from "./illness-snapshot";
import { buildLabsSnapshotBlock } from "./labs-snapshot";
import {
buildReferenceGroundingBlock,
type GroundingMetricInput,
@@ -414,6 +415,7 @@ async function buildCoarseTimelineTail(
userId: string,
type: MeasurementType,
now: Date,
tz: string,
): Promise<CoarseTimelineTail | undefined> {
// `buildTieredSeries` reads fall back on miss — a coverage miss yields empty
// bands, which collapse to `undefined` here. The try/catch keeps the coarse
@@ -428,6 +430,7 @@ async function buildCoarseTimelineTail(
// band reads it would otherwise discard.
series = await buildTieredSeries(userId, type, {
now: now.getTime(),
tz,
coarseOnly: true,
});
} catch {
@@ -883,6 +886,7 @@ async function buildCoachSnapshotImpl(
userId,
"BLOOD_PRESSURE_SYS" as MeasurementType,
now,
userTz,
);
}
if (wantsWeight) {
@@ -890,6 +894,7 @@ async function buildCoachSnapshotImpl(
userId,
"WEIGHT" as MeasurementType,
now,
userTz,
);
}
if (wantsPulse) {
@@ -897,6 +902,7 @@ async function buildCoachSnapshotImpl(
userId,
"PULSE" as MeasurementType,
now,
userTz,
);
}
const [bpCoarseTail, weightCoarseTail, pulseCoarseTail] = await Promise.all([
@@ -1255,6 +1261,14 @@ async function buildCoachSnapshotImpl(
// degrader never sheds it — the Coach needs to know about Rest Mode.
const illnessBlockPromise = buildIllnessSnapshotBlock(userId, now);
// v1.18.11 (#65) — lab-result context. Like illness it is CONTEXT, not a
// scope-gated metric: attached without a `registerBlock` so the budget
// degrader never sheds it, and Labs is intentionally not module-gated (the
// helper reads owner-scoped rows directly, mirroring `/api/labs`). The block
// carries the most-recent resolved reading per biomarker (last 12 months,
// capped) so the Coach can answer "what was my LDL" without re-deriving.
const labsBlockPromise = buildLabsSnapshotBlock(userId, now);
const [
moodRows,
complianceMeds,
@@ -1269,6 +1283,7 @@ async function buildCoachSnapshotImpl(
memoryBlock,
cycleBlock,
illnessBlock,
labsBlock,
] = await Promise.all([
moodRowsPromise,
complianceMedsPromise,
@@ -1283,6 +1298,7 @@ async function buildCoachSnapshotImpl(
memoryBlockPromise,
cycleBlockPromise,
illnessBlockPromise,
labsBlockPromise,
]);
const byType = (t: string) =>
@@ -2273,6 +2289,15 @@ async function buildCoachSnapshotImpl(
snapshot.illness = illnessBlock;
}
// v1.18.11 (#65) — lab-result context. Attached WITHOUT a cluster
// registration (like illness/scope): the budget degrader never sheds it, so
// the Coach can always answer a lab question from the user's own readings.
// Server-authoritative + grounded — resolved name/value/unit/range per
// biomarker, never the decrypted note.
if (labsBlock) {
snapshot.labs = labsBlock;
}
if (Object.keys(snapshot).length === 0) {
metrics.add("general");
}
+26
View File
@@ -313,6 +313,19 @@ ISO-week means.
"recentResolved" is light history for "how often do I get sick"
questions. When no "illness" block is present, assume nothing about
the user's health status.
- The SNAPSHOT MAY carry a "labs" block: { recent[] }, where each entry is
the user's MOST RECENT reading for one biomarker — { analyte, panel,
value, valueText, unit, referenceLow, referenceHigh, rangeStatus,
takenAt }. Numeric readings carry "value" + "unit"; qualitative ones
("negativ" / …) carry "valueText" with "value" null. "rangeStatus" is
the server's verdict (in-range / below / above / unknown) against the
resolved reference bounds — read it as given, never recompute it.
Answer lab questions ONLY from this block: quote the value, unit, range,
and date verbatim, and never invent a lab number it does not carry. The
list is the latest value per marker over the last year, not a full
history. Frame results descriptively, never as a diagnosis. When no
"labs" block is present, you have no lab values — say so plainly rather
than guess.
EVIDENCE BLOCK
@@ -687,6 +700,19 @@ ISO-Wochenmittel zusammen.
Labels als die EIGENE beschreibende Angabe des Nutzers, nie als Diagnose;
"recentResolved" ist leichte Historie für "wie oft bin ich krank".
Fehlt der "illness"-Block, nimm nichts über den Gesundheitszustand an.
- Der SNAPSHOT KANN einen "labs"-Block tragen: { recent[] }, wobei jeder
Eintrag der NEUESTE Wert des Nutzers für einen Biomarker ist — { analyte,
panel, value, valueText, unit, referenceLow, referenceHigh, rangeStatus,
takenAt }. Numerische Werte tragen "value" + "unit"; qualitative
("negativ" / …) tragen "valueText" bei "value" null. "rangeStatus" ist das
serverseitige Urteil (in-range / below / above / unknown) gegen die
aufgelösten Referenzgrenzen — lies es wie angegeben, berechne es nie neu.
Beantworte Laborfragen NUR aus diesem Block: zitiere Wert, Einheit,
Bereich und Datum wörtlich und erfinde nie einen Laborwert, den der Block
nicht enthält. Die Liste ist der jeweils letzte Wert pro Marker aus dem
letzten Jahr, keine vollständige Historie. Formuliere Ergebnisse
beschreibend, nie als Diagnose. Fehlt der "labs"-Block, hast du keine
Laborwerte — sage das klar, statt zu raten.
EVIDENZ-BLOCK
+40 -1
View File
@@ -52,6 +52,20 @@ const DEFAULT_SLUG_FALLBACK_CHAIN = [
"gpt-5.2", // legacy floor
] as const;
/**
* The slug the codex vision gate reasons about *before* a request runs: the
* cached working slug if one is live, else the first chain entry. Lets the
* Lab-OCR capability resolver decide whether codex can read images without
* standing up a client or making a network call. Best-effort — the actual
* routed slug may differ once the chain walk runs, which the runtime probe +
* graceful fallback (text/local OCR) covers.
*/
export function resolveCodexVisionSlug(): string | null {
const cached = getCachedCodexSlug();
if (cached) return cached;
return loadFallbackChain()[0] ?? null;
}
function loadFallbackChain(): string[] {
const envChain = process.env.CODEX_MODEL_FALLBACK_CHAIN?.trim();
const fromEnv = envChain
@@ -291,6 +305,31 @@ export class CodexClient implements AIProvider {
): Promise<Response> {
const sessionId = randomUUID();
const threadId = randomUUID();
// v1.18.11 — fold vision inputs (Lab-OCR) into the user turn when present.
// The Codex/ChatGPT-OAuth backend accepts the `input_image` content block
// on this same `codex/responses` endpoint (docs/codex-protocol-spec.md
// §2b: `image_url` accepts a `data:<mime>;base64,...` URL, `detail`
// defaults to "high"). Image INPUT consumes the ChatGPT plan allocation —
// no API key is required (an API key only matters for image GENERATION).
// When no images are present the content is byte-identical to the prior
// text-only wire, so every text caller (Coach, insights, status cards) is
// untouched. The image is UNTRUSTED data to transcribe, framed as such by
// the system prompt. Documents (PDF) are NOT folded in — `input_image` is
// image-only; the OCR route gates PDFs to Anthropic.
const images = params.images ?? [];
const content: Array<
| { type: "input_text"; text: string }
| { type: "input_image"; image_url: string; detail: "high" }
> = [{ type: "input_text", text: params.userPrompt }];
for (const img of images) {
content.push({
type: "input_image",
image_url: `data:${img.mediaType};base64,${img.dataBase64}`,
detail: "high",
});
}
return safeFetch(
CODEX_ENDPOINT,
{
@@ -314,7 +353,7 @@ export class CodexClient implements AIProvider {
{
type: "message",
role: "user",
content: [{ type: "input_text", text: params.userPrompt }],
content,
},
],
tools: [],
+34 -2
View File
@@ -56,6 +56,34 @@ function openaiSupportsVision(model: string): boolean {
);
}
/**
* Codex (ChatGPT-OAuth) vision models. Image INPUT is reachable over the
* `codex/responses` endpoint (docs/codex-protocol-spec.md §2b) on a multimodal
* slug — no API key needed. The accepted GPT-5.x line is text+vision; the older
* `-codex` specialist slugs and the legacy `gpt-4`-class non-multimodal pins
* are text-only. The slug we test is the codex client's resolved working slug
* (cached or chain head), so a slug rotation onto a non-vision model degrades
* to false rather than 400-ing the user at the image block.
*
* `gpt-4o` / `o*` are folded in for completeness (a future codex slug could
* land there), reusing the same multimodal families the OpenAI gate accepts.
*/
function codexSupportsVision(model: string): boolean {
const m = model.toLowerCase();
return (
// GPT-5.x line is multimodal (text + vision) — but the `-codex` specialist
// slugs (e.g. gpt-5-codex) are text-only, so they must not claim vision or
// every lab-OCR upload burns a doomed image attempt before falling back.
(m.startsWith("gpt-5") && !m.includes("-codex")) ||
m.startsWith("gpt-4o") ||
m.startsWith("gpt-4.1") ||
m.startsWith("gpt-4-turbo") ||
m.startsWith("o1") ||
m.startsWith("o3") ||
m.startsWith("o4")
);
}
/**
* Logical provider tags the OCR path reasons about. Mirrors the
* `ProviderChainType` vocabulary plus the legacy `admin-key` single-provider
@@ -79,8 +107,11 @@ export type VisionProviderType =
* configuring a self-hosted model and we cannot sniff
* its capabilities; a non-vision local model surfaces
* a clear extract failure rather than a silent gate.
* - codex / none → false (no vision over the Codex wire; nothing
* configured).
* - codex → vision when the resolved codex slug is a
* multimodal model (GPT-5.x / gpt-4o-class). Image
* input rides the ChatGPT plan — no API key. A
* non-multimodal slug (or unknown) → false.
* - none → false (nothing configured).
*/
export function supportsVisionForConfig(
providerType: VisionProviderType,
@@ -96,6 +127,7 @@ export function supportsVisionForConfig(
case "local":
return true;
case "codex":
return model ? codexSupportsVision(model) : false;
case "none":
default:
return false;
@@ -0,0 +1,59 @@
/**
* Unit pins for the source-rank SQL builders.
*
* `canonicalMeasurementsCte` (v1.18.11 perf#3a) was extracted so a statement
* that references the canonical-source set more than once can bind it as a
* single `WITH cm AS (…)` CTE and fold away the duplicate DISTINCT-ON
* self-join. These tests pin:
* 1. the alias-wrapped `canonicalMeasurementsFrom` is exactly the CTE body
* wrapped in `( … ) m` — so the single-reference call site is unchanged;
* 2. the canonical body carries exactly ONE DISTINCT-ON pick — so a fold
* that references the CTE twice runs the expensive pick once;
* 3. the interval whitelist still rejects an unsafe splice.
*/
import { describe, expect, it } from "vitest";
import {
canonicalMeasurementsCte,
canonicalMeasurementsFrom,
} from "../source-rank-sql";
const RANK = "90";
describe("canonicalMeasurementsCte / canonicalMeasurementsFrom", () => {
it("wraps the CTE body verbatim in the ( … ) m alias", () => {
const cte = canonicalMeasurementsCte(RANK, "90 days");
const from = canonicalMeasurementsFrom(RANK, "90 days");
// The alias form is the CTE body plus the closing `) m` wrapper. Pinning
// equality means the single-reference call sites keep byte-stable SQL
// while the CTE body can be reused for the folded double-reference case.
expect(from).toBe(`(${cte}\n ) m`);
});
it("runs exactly one DISTINCT-ON canonical pick in the body", () => {
const cte = canonicalMeasurementsCte(RANK, "90 days");
const matches = cte.match(/DISTINCT ON/g) ?? [];
expect(matches).toHaveLength(1);
});
it("binds the user id as $1 and scopes both the inner and outer windows", () => {
const cte = canonicalMeasurementsCte(RANK, "90 days");
// user id is always a bound parameter, never spliced.
expect(cte).toContain('"user_id" = $1');
// The trailing-window bound appears on both the inner pick and the outer
// filter so the folded references stay scoped identically.
const intervalMatches = cte.match(/INTERVAL '90 days'/g) ?? [];
expect(intervalMatches).toHaveLength(2);
});
it("omits the window clause entirely when no interval is given", () => {
const cte = canonicalMeasurementsCte(RANK);
expect(cte).not.toContain("INTERVAL");
});
it("rejects an unsafe interval splice", () => {
expect(() => canonicalMeasurementsCte(RANK, "90 days; DROP TABLE")).toThrow(
/unsafe interval/,
);
});
});
+35 -9
View File
@@ -107,19 +107,25 @@ function assertInterval(value: string): string {
}
/**
* Build a FROM-clause subquery (aliased `m`) that restricts raw `measurements`
* to the canonical-source rows per (type, day): the inner `DISTINCT ON` picks
* the ladder-winning source for each day, and the join keeps only that source's
* readings. Use it in place of `FROM measurements m` so a live aggregate over
* an overlapping vital never blends two devices — the same collapse the rollup
* readers apply, kept in lockstep for live/rollup parity.
* Build the canonical-source SELECT body — the raw `measurements` rows
* restricted to the ladder-winning source per (type, day) — WITHOUT the
* outer `( … ) m` alias wrapper. The inner `DISTINCT ON` picks the
* canonical source for each day and the join keeps only that source's
* readings.
*
* Use this when the same canonical set must be referenced more than once in
* one statement (e.g. a `window_stats` CTE plus the outer aggregate): bind it
* once via `WITH cm AS (${canonicalMeasurementsCte(...)})` and select
* `FROM cm m` everywhere, so the expensive DISTINCT-ON self-join runs once
* rather than per reference. For the single-reference case use
* `canonicalMeasurementsFrom`, which wraps this body in the alias.
*
* The user id is bound as `$1`. `rankUnqualified` must be a CASE built with
* unqualified `"type"`/`"source"` columns. `sinceInterval` (e.g. `"90 days"`)
* is whitelisted and, when given, scopes both the inner pick and the outer
* filter to a trailing window.
*/
export function canonicalMeasurementsFrom(
export function canonicalMeasurementsCte(
rankUnqualified: string,
sinceInterval?: string,
): string {
@@ -129,7 +135,7 @@ export function canonicalMeasurementsFrom(
const sinceOuter = sinceInterval
? `AND mm."measured_at" >= NOW() - INTERVAL '${assertInterval(sinceInterval)}'`
: "";
return `(
return `
SELECT mm.*
FROM measurements mm
JOIN (
@@ -148,6 +154,26 @@ export function canonicalMeasurementsFrom(
AND c.canon = mm."source"
WHERE mm."user_id" = $1
AND mm."deleted_at" IS NULL
${sinceOuter}
${sinceOuter}`;
}
/**
* Build a FROM-clause subquery (aliased `m`) that restricts raw `measurements`
* to the canonical-source rows per (type, day): the inner `DISTINCT ON` picks
* the ladder-winning source for each day, and the join keeps only that source's
* readings. Use it in place of `FROM measurements m` so a live aggregate over
* an overlapping vital never blends two devices — the same collapse the rollup
* readers apply, kept in lockstep for live/rollup parity.
*
* The user id is bound as `$1`. `rankUnqualified` must be a CASE built with
* unqualified `"type"`/`"source"` columns. `sinceInterval` (e.g. `"90 days"`)
* is whitelisted and, when given, scopes both the inner pick and the outer
* filter to a trailing window.
*/
export function canonicalMeasurementsFrom(
rankUnqualified: string,
sinceInterval?: string,
): string {
return `(${canonicalMeasurementsCte(rankUnqualified, sinceInterval)}
) m`;
}
+17
View File
@@ -484,10 +484,27 @@ export const caches = {
ttlMs: 60_000,
staleTtlMs: 600_000,
}),
/**
* v1.18.11 (W5 perf) — stale-while-revalidate window. The
* `AchievementUnlockNotifier` is mounted on every authenticated page and
* polls this endpoint every 2 minutes. Against a 60 s hard-TTL bucket
* that poll always landed on an expired entry, firing a cold ~400 ms
* rebuild on every session every 2 minutes. With a stale window the poll
* (and the inline `/achievements` page mount) serves the prior payload
* instantly while one coalesced background recompute refreshes it.
*
* SWR is safe here: the route persists `pendingUnlocks` OUTSIDE the
* cached factory (idempotent `createMany({ skipDuplicates: true })`), so
* serving a stale body never skips an unlock write. The 10-minute window
* mirrors the `medications` bucket; measurement / mood / medication /
* intake writes invalidate the `${userId}|` prefix so a user's own
* action still surfaces on the next read.
*/
achievements: new ServerCache<unknown>({
name: "achievements",
maxEntries: 1000,
ttlMs: 60_000,
staleTtlMs: 600_000,
}),
dashboardWidgets: new ServerCache<unknown>({
name: "dashboardWidgets",
+3
View File
@@ -128,6 +128,9 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
{ model: "Biomarker", field: "contextEncrypted", kind: "bytes" },
{ model: "IllnessEpisode", field: "noteEncrypted", kind: "bytes" },
{ model: "IllnessDayLog", field: "noteEncrypted", kind: "bytes" },
// ───── v1.19.0 ECG waveform (Bytes column) ─────
{ model: "EcgRecording", field: "waveformEncrypted", kind: "bytes" },
] as const;
/** Stable `Model.field` key for a registry entry. */
Loaded 100 of 185 files, more files were not shown because too many files have changed in this diff. Show more