Merge remote-tracking branch 'origin/main' into feat/lab-analyte-key-fill

This commit is contained in:
kilian committed 2026-10-11 19:48:56 +02:00
commit c2be166f72
2097 files changed
+214819 -26009

No files matched your search

+29
View File
@@ -450,6 +450,19 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# for the heaviest legitimate read (the live-aggregate analytics fallback on a
# large account). Set "0" to disable it and restore unbounded behaviour.
# DATABASE_STATEMENT_TIMEOUT_MS="60000"
#
# Postgres `work_mem` applied to every app session, as a Postgres size string.
# Default 16MB. Large sorts and aggregates that exceed it spill to disk; each
# sort step of each pooled connection may use this much memory, so raise it
# with the pool size in mind.
# DATABASE_WORK_MEM="16MB"
#
# Both settings above reach Postgres as the libpq `options` startup
# parameter. PgBouncer refuses that parameter ("unsupported startup
# parameter: options") unless it is listed in its ignore_startup_parameters.
# Behind such a pooler, set this to 1, true or yes to send no session
# settings at all; the pooler's or the database's own defaults then apply.
# DATABASE_SESSION_OPTIONS_DISABLED="1"
# -----------------------------------------------------------------------------
@@ -499,6 +512,12 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# TRUST_PROXY_HOPS="1"
# TRUST_CF_CONNECTING_IP="1"
# Memory cap of the app container (read by docker-compose.yml, not by the app).
# Node sizes its heap from it: 1536m (the default) gives 792 MB, 1g only 524 MB,
# which a long-used account fills in normal operation. Lower it on a host with
# 2 GB or less, raise it on a busy instance.
# APP_MEMORY_LIMIT="1536m"
# -----------------------------------------------------------------------------
# Outbound requests HealthLog makes on its own -- optional switches
# -----------------------------------------------------------------------------
@@ -586,6 +605,16 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# force the public-host guard, so an internal instance is not blocked).
# OPENMETEO_BASE_URL="https://archive-api.open-meteo.com"
# OPENMETEO_GEOCODING_URL="https://geocoding-api.open-meteo.com"
#
# Air quality, pollen and UV come from the Open-Meteo air-quality API, which
# serves the Copernicus Atmosphere Monitoring Service (CAMS) models. CAMS data
# is free to use with attribution, which the app shows next to the values.
# Like the URLs above this one is optional and can point at a self-hosted
# Open-Meteo instance. Set ENVIRONMENT_AIR_QUALITY_DISABLED to 1, true or yes
# to stop the air-quality requests for the whole instance; weather and
# daylight keep working, and no user can turn air quality back on.
# OPENMETEO_AIR_QUALITY_URL="https://air-quality-api.open-meteo.com"
# ENVIRONMENT_AIR_QUALITY_DISABLED="1"
# ── Native canvas renderer (document thumbnails, scanned-PDF rasterization) ──
# The bundled renderer's x64 build uses AVX2 CPU instructions. On x86-64 hosts
+37
View File
@@ -10,6 +10,43 @@ updates:
applies-to: security-updates
update-types:
- "patch"
# One weekly pull request per dependency kind instead of a trickle of
# single bumps: patch and minor updates travel together and pass the
# same checks once. Majors stay single so each gets its own review.
production-weekly:
dependency-type: "production"
update-types:
- "patch"
- "minor"
development-weekly:
dependency-type: "development"
update-types:
- "patch"
- "minor"
ignore:
# zod 4.5 made `z.iso.datetime()` require seconds, so a request carrying
# `2026-01-01T06:15Z` that passes today would be refused with a 422 on
# every route that validates a timestamp, and 4.6 rewrites nullable and
# primitive unions in the generated JSON Schema from `anyOf` to `type`
# arrays, which reshapes the OpenAPI document and every MCP tool schema.
# Both are contract changes for the web client, the iOS app and MCP
# callers, not a routine bump, so the move past 4.4 gets its own change
# with the timestamp producers audited. Patch releases on 4.4 still
# arrive.
- dependency-name: "zod"
versions: [">=4.5.0"]
# Next 16.4.0 breaks URL state after history traversal: with a sheet
# opened through `?doc=`, Back leaves it open, and `[` / `]` sometimes
# do not move `?day=`. Reproduced in three end-to-end journeys (3 of 40
# repeated runs on 16.4.0, 0 of 80 on 16.3.8 with everything else
# equal). 16.3.8 carries every published security fix, so the line
# waits for a 16.4 patch; the two packages that move with it wait too.
- dependency-name: "next"
versions: ["16.4.0"]
- dependency-name: "eslint-config-next"
versions: ["16.4.0"]
- dependency-name: "@next/bundle-analyzer"
versions: ["16.4.0"]
- package-ecosystem: "github-actions"
directory: "/"
schedule:
+1 -1
View File
@@ -26,7 +26,7 @@ permissions:
jobs:
coach-eval:
name: Coach eval — deterministic floor (always) + live judge (opt-in)
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 15
# Map the opt-in secret to a job-level env so the live-judge steps can gate
+3 -3
View File
@@ -36,7 +36,7 @@ concurrency:
jobs:
analyze:
name: Analyse javascript-typescript
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 30
permissions:
contents: read
@@ -60,7 +60,7 @@ jobs:
# minutes and can produce nothing actionable, because nobody edits
# it and the fix for anything found there is `pnpm prisma generate`.
- name: Initialise CodeQL
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
languages: javascript-typescript
build-mode: none
@@ -75,6 +75,6 @@ jobs:
- public
- name: Analyse
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
category: "/language:javascript-typescript"
+1 -1
View File
@@ -11,7 +11,7 @@ permissions:
jobs:
auto-merge:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
if: github.actor == 'dependabot[bot]'
permissions:
contents: write
+5 -5
View File
@@ -39,7 +39,7 @@ concurrency:
jobs:
release-policy:
name: Validate release policy
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
actions: read
@@ -121,7 +121,7 @@ jobs:
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
runner: ubuntu-24.04
cache_scope: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
@@ -296,7 +296,7 @@ jobs:
- name: Upload digest artifact
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: digest-${{ matrix.cache_scope }}
path: /tmp/digests/*
@@ -313,7 +313,7 @@ jobs:
# the registry instead of on one cross-emulated runner.
merge:
name: Merge multi-arch manifest
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
if: github.event_name != 'pull_request'
needs: [build]
timeout-minutes: 10
@@ -449,7 +449,7 @@ jobs:
promote:
name: Promote verified image to Coolify
needs: [merge, verify-published-image]
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
+4 -4
View File
@@ -24,7 +24,7 @@ jobs:
# mobile route sweep landed, and a slow runner tipped them over; three
# shards with a 30-minute cap leave room for both.
shard:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 30
strategy:
fail-fast: false
@@ -106,7 +106,7 @@ jobs:
- name: Upload Playwright HTML report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: playwright-report-shard-${{ matrix.shard }}
path: playwright-report/
@@ -117,7 +117,7 @@ jobs:
# job below. Only uploaded when the shard ran to completion, so a missing
# artifact there means a shard that did not finish, not an empty suite.
- name: Upload Playwright JSON report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: playwright-json-shard-${{ matrix.shard }}
path: playwright-json/results.json
@@ -128,7 +128,7 @@ jobs:
# that name and answers for both shards: it fails when any shard failed or
# was cancelled, so a half-green matrix cannot satisfy the gate.
e2e:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
needs: shard
if: always()
timeout-minutes: 5
+1 -1
View File
@@ -21,7 +21,7 @@ permissions:
jobs:
env-check:
name: pnpm check-env against .env.production.example
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Checkout
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
# job timeout only needs to catch a container that never boots. Each shard
# boots its own Postgres testcontainer.
shard:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 40
strategy:
fail-fast: false
@@ -67,7 +67,7 @@ jobs:
# carries that name and answers for every shard: it fails when any shard
# failed or was cancelled, so a half-green matrix cannot satisfy the gate.
integration:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
needs: shard
if: always()
timeout-minutes: 5
+1 -1
View File
@@ -12,7 +12,7 @@ permissions:
jobs:
knip:
name: Dead-code gate
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
# future contributor reintroduces the pattern.
grep:
name: Forbidden marker grep
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+1 -1
View File
@@ -45,7 +45,7 @@ permissions:
jobs:
verify:
name: Pull and smoke-test exact published image
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
env:
IMAGE_REF: ${{ inputs.image_ref }}
+2 -2
View File
@@ -25,7 +25,7 @@ permissions: read-all
jobs:
analysis:
name: Score supply-chain practices
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
# Write the findings into the repository's code-scanning alerts.
@@ -54,7 +54,7 @@ jobs:
publish_results: true
- name: Upload results
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
sarif_file: scorecard-results.sarif
category: scorecard
+4 -4
View File
@@ -17,7 +17,7 @@ permissions:
jobs:
quality:
name: Lint, Typecheck & Test
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
if: github.event_name != 'schedule'
steps:
@@ -100,7 +100,7 @@ jobs:
# published image daily.
dependency-audit:
name: Dependency Audit
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
# Runs on pull requests too, but advisory: an advisory published against a
# dependency the branch did not touch must not block the branch. The push
# run on main and the daily schedule stay blocking, and a tag is only ever
@@ -130,7 +130,7 @@ jobs:
secret-scan:
name: Secret Scanning
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
if: github.event_name != 'schedule'
steps:
@@ -145,7 +145,7 @@ jobs:
docker-security:
name: Container Security
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
+1 -1
View File
@@ -45,7 +45,7 @@ permissions:
jobs:
journey:
name: Sign in, write a reading, read it back
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
+4 -4
View File
@@ -30,7 +30,7 @@ concurrency:
jobs:
filesystem:
name: Lockfile (including dev dependencies)
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: read
@@ -88,14 +88,14 @@ jobs:
skip-dirs: src/generated
- name: Upload results
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
sarif_file: trivy-filesystem.sarif
category: trivy-filesystem
published-image:
name: Published image
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: read
@@ -145,7 +145,7 @@ jobs:
exit-code: "0"
- name: Upload results
uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/upload-sarif@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
sarif_file: trivy-published-image.sarif
category: trivy-published-image
+760
View File
@@ -1,5 +1,765 @@
# Changelog
## [1.42.2] — 2026-10-11
Actionable notifications, record search in the command palette, automatic
intake recording, and fixes for Oura, non-UTC databases, documents and
Google Health backfills.
### Added
- **Notification tap targets (#1203).** One map resolves a target path per
event type; ntfy carries it as `Click`, Telegram as an "Open in HealthLog"
button on public https (text link on plain http), web push as `url`,
webhook and Gotify as a URL field, email as a link line, APNs as `url`.
No target for guardian deliveries, discreet cycle mode or the silent sync
push. ntfy titles and action labels outside ASCII are sent RFC 2047
encoded.
- **Notification content preference.** `notificationPrefs.content.detail`
(`generic` default, `detailed`) on `PATCH /api/auth/me/notification-prefs`;
with `detailed` the morning briefing title carries the score.
- **Mood quick answers on ntfy.** Three actions post to
`POST /api/notifications/action/[token]`: an HMAC token bound to user and
answer, valid six hours, single use per reminder, rate limited before
verification, recorded once in the idempotency table so a purged entry
cannot be answered again. Mood entries record source `NOTIFICATION`. A
reminder tapped while acting in another record opens the mood page
without the entry sheet. ntfy messages are published as JSON.
- **Record search (#1117).** `POST /api/search` (query in the body, so it
never appears in a URL or proxy log; read-level access, one search at a
time per account, 20 a minute) searches visits and
procedures, conditions, documents, vaccinations, lab results, medications,
symptoms, workouts and notes, decrypting encrypted text in bounded batches
with a shared time budget, and reports `truncated` when a bound stopped a
source. Matching is case-, accent- and umlaut-spelling-blind and shared
with the MCP `search` tool. The palette lists hits under "In your records";
each hit deep-links to its entry. Migration 0397 adds a partial index on
live readings that carry a note.
- **Open in new tab (#1202)** for PDF and image originals in the document
vault.
- **Record doses automatically.** `Medication.autoMarkTaken` (with
`autoMarkTakenSince`, migration 0395): a job every 15 minutes records each
due dose as taken from the moment the option is switched on, through the
schedule engine, never over a dose taken, skipped or deleted by hand, with
the same stock and rollup follow-ups as a manual take. Intakes carry
`autoRecorded`; no reminders are sent for such a medication. List and
detail responses carry the resolved `remindersActive`
(false also once a course has ended). Rolling cadences step past slots the
job will not record, and the card shows the same next slot.
### Fixed
- **Oura token exchange (#1204).** Codes are exchanged at
`moi.ouraring.com/oauth/v2/ext/oauth-token`; a code issued by that server is
never sent to the legacy endpoint. Refresh falls back to
`api.ouraring.com/oauth/token` once. The callback validates `iss` (RFC 9207).
- **Non-UTC database time zone (#1200).** Every connection (Prisma, pg-boss,
lock and maintenance clients) runs with `TimeZone=UTC`, with a `SET`
fallback when session options are disabled; raw SQL reads the clock as
`NOW() AT TIME ZONE 'UTC'`; boot warns when the session zone is not UTC.
- **Google Health backfill (#1194).** A slice that saved progress enqueues
the next slice and ends without `job.run.failed`; a slice without saved
progress still fails, and the chain stops after collections + 2 slices.
- **Admin AI spend card** sums today's spend on each account's local day.
- **Daily AI budget** is keyed on the account's local day.
- **Day view:** Escape inside the date picker no longer closes the day.
### Changed
- Docked strips run the full height, the header search is a magnifier icon,
`g p` toggles the day panel.
- Without a zoom in the URL the timeline fits its window to the record: three
months, clamped to the first entry (at least 28 days) for a young record,
and everything when the last three months are empty. An empty window offers
"Show everything" when older entries exist.
- The explanatory line under day-linked charts is gone.
### API
- New: `POST /api/search`, `POST /api/notifications/action/{token}`.
- Medication: `autoMarkTaken`, `autoMarkTakenSince`, `remindersActive`;
intakes and sync intake upserts: `autoRecorded`.
- `notificationPrefs.content.detail` on the notification preferences.
- APNs payloads carry `url` on every visible push.
## [1.42.1] — 2026-10-10
Fixes for Google Health syncing and the measurement maintenance run.
### Fixed
- **Google Health parked after a reconnect (#1194).** A backfill walk used
one access token throughout; after Google's one-hour expiry the walk saw
401s and the connection was parked at `error_reauth` although the refresh
token was valid. Resources now take a token source that renews before
expiry and once more on a mid-walk 401; a 429 is waited out (Retry-After,
RetryInfo or backoff) and stays transient; parking follows only a refused
grant at the token endpoint, and an account parked by a data-call 401
recovers on its next run. One sync per account at a time: the hourly run
skips, a manual sync answers 409 `sync_in_progress`, backfill and sleep
repair wait. An interrupted backfill resumes from `backfill_progress`
(migration 0393).
- **Overlapping Google Health sleep segments (#1195).** A batch keeps one
reading per natural key (the longer segment), a remaining collision is
settled through `reconcileExternalMeasurement`, and overlapping sessions
no longer clear each other's rows. Night totals use the union of segments.
- **Measurement maintenance on Docker (#1192).** The maintenance connection
sets `max_parallel_maintenance_workers=0` and needs no `/dev/shm` segment;
the bundled `docker-compose.yml` sets `shm_size: 256mb` for the database.
- **Dashboard spacing.** The tile row sat flush against the top card; a
`display: contents` wrapper had swallowed the gap.
### API
- `POST /api/google-health/sync` may answer 409 `sync_in_progress`.
## [1.42.0] — 2026-10-09
A day view behind every dated value, an optional timeline with life events,
air quality and pollen per day, a Health Connect import, and the handover of
a managed profile to the person it describes. The measurement table drops
the samples the nightly fold used to keep, after a one-time repair of means
an earlier fold computed from part of a day.
### Added
- **Day view (#613).** `?day=YYYY-MM-DD` opens one local day over the
current page: docked from 1280 px beside a fixed strip at the right edge
that opens and closes it and keeps it, with the last day, across pages; a
side sheet below that and a bottom sheet on a phone. On `/coach` it sits
next to the conversation list, each with its own strip. It shows the value
it was opened from, what ran through the day (one entry per medication
with the day's dose, the cycle day and phase), eight curated readings
against the 30-day usual range (the rest behind "All values"), the day's
scores (health score, readiness, recovery, sleep score, strain) and the
day's entries in clock order. Readings and scores link to their pages, and
the date in the header opens a calendar with Today. Every chart drawn in days opens it (on touch the tooltip offers the
day first), as do data tables, the measurement, mood and intake lists,
heatmaps, lab readings, documents, workouts, symptom and illness days and
the days a Coach answer read. Back and Escape close it, Alt with an arrow
steps a day. A planned visit shows what changed since the last one from
`GET /api/day/notable`. Not offered: the dashboard's today area, tile
charts, the cycle calendar, model text, future dates, the clinician share
link and the doctor-report PDF.
- **Timeline module (#613).** Opt-in. One SVG time axis from 768 px with
lanes for life events, conditions, allergies, medications, vaccinations,
visits and procedures, labs and documents (one row per medication, dose
changes and pauses as segments of its bar), a year, month or week grid or
any range, and as many value lines as the record has types, as quarterly,
monthly, weekly or daily means by span (a short gap dashed, a longer one
left open, a mean from fewer than three readings drawn hollow, each point
with its own tooltip); a chronicle, newest first, on a phone. Switching the module on opens a readiness inventory that rates
each lane and links each gap; it stays available as Data coverage.
- **Life events.** Title, kind (family, home, work, loss, other), a start
at day, month or year precision, optional end and note; title and note
encrypted at rest (migration 0387). Owner only in this release: no share
level or MANAGE grant reads or writes them, and no model ever receives
them. They travel in both backup purposes and are wiped with the account.
- **Air quality, pollen and UV (#615).** The environment fetch adds the
Open-Meteo air-quality feed (CAMS, `domains=auto`): PM2.5, PM10, NO2,
SO2, CO, the ozone 8-hour high, European and US AQI, UV, dust, aerosol
depth and six pollen groups, folded into local days (means and maxima need
18 of 24 hours) and stored as null where the feed has no value
(migration 0381). Pollen exists only in the European domain. A nightly
gap fill covers up to 90 days per account. PM2.5, the ozone high and the
highest pollen join the correlation channels. An account switch under
Location & weather (on by default), attributions beside the values, and a
dashboard chip on a day with high pollen, a warm night or very poor air.
- **Air quality for the past.** A background job fills air quality, pollen
and UV for earlier local days with an entry: after the update, when the
home is set and when air quality is switched on. A day inside a location
period is fetched at that place, a day from the home's effective date on
at the home; a day before it with no period stays empty rather than
borrowing a place the person was not at. The source reaches back to 2013
in Europe and to August 2022 elsewhere, pollen from 2021 in Europe only;
older days stay empty. Nearby days share one
request of up to 90 days, a run holds itself to 300 of the account's 400
daily calls and half of each instance window, and a long history spreads
over several nights. Location & weather shows the progress (migration
0391).
- **`get_environment` and `get_day`** for the Coach and MCP.
`get_environment` is module-gated and carries no coordinate or place name;
`get_day` leaves out life events, notes and document names and honours
the person's Coach exclusions.
- **Health Connect import (#972).** Upload the ZIP Android's Health Connect
exports under Export & Import. It is read in the background (read-only
SQLite, schema version 9 or later, 1 GiB upload cap, one import per
account): daily totals from the top-priority app, per-minute heart rate
for 90 days and hourly means before that, sleep stages, workouts, cycle
days, water and nutrients, under the new source `HEALTH_CONNECT`
(migrations 0379, 0380). Apps already connected directly are skipped and a
second import of the same file writes nothing.
- **Managed profile handover (#959).** A guardian with a fresh second factor
creates a one-time link (1, 7 or 14 days, shown once with a QR code) and
proposes no access, view only or manage per guardian. The person opens
`/claim/<token>`, sets a username, email and password, and decides each
guardian's access after signing in. Pending invitations are withdrawn;
disclaimer, onboarding and AI consent reset. Refused on `OIDC_ONLY`
instances (migrations 0383, 0390).
- **Custom vaccines (#1005).** A record defines its own vaccine with the
antigens it covers, series length and booster interval; its doses count
into each antigen's series and settle boosters (migration 0386).
- **Workout editing (#1162).** A hand-entered workout opens its form from
the detail page and saves as an overwrite of the same row.
- **HRV RMSSD from Apple Health (#1110).** iOS 27's
`HKQuantityTypeIdentifierHeartRateVariabilityRMSSD` is stored as
`HRV_RMSSD` (1 to 300 ms) with its own source-priority ladder, charted
apart from SDNN.
- **Apple Health arrival per type (#1173).** The Apple Health card shows
when each type last arrived, by which trigger (including the new
`manual` for Sync all) and when it last brought a new value
(migration 0384).
- **Documents layout.** Preview cards or a compact list, months flowing
side by side (the default) or stacked, per account (migration 0388). Only
a field the person picked is stored, so an untouched one follows the
default.
- **Measurement maintenance.** An admin card and
`POST /api/admin/maintenance/measurements` run `VACUUM (ANALYZE)` and a
`REINDEX INDEX CONCURRENTLY` per index, largest first.
- **Query statistics.** The bundled database loads `pg_stat_statements`;
`CREATE EXTENSION` once to read it (`docs/ops/query-statistics.md`).
- **Score history.** `/insights/health-score` charts the daily health score
with its usual band, broken where the score's recipe changed, from
`GET /api/insights/score-history`; readiness, recovery, sleep score,
stress and strain share one 7, 30, 90 days or all range picker.
- **Top card switch.** The dashboard layout settings can hide the top card
(`todayCardVisible`).
- **Keyboard shortcuts.** `g` then `d`, `m`, `l`, `t`, `i`, `c` or `s` opens
a page, `n` the add menu, `[` and `]` step the open day, and `?` (or the
account menu) lists them; ignored while typing in a field.
- **Command palette.** Cmd+K (Ctrl+K) or the search field in the top bar
finds pages, Insights pages, every Settings card by its anchor, module
switches and actions (new entry, the Coach, a backup, today, the
shortcuts), filtered by modules, AI availability, role and a shared
record's grant; typo-tolerant, with recent places when empty.
### Changed
- **Correlations remove season and trend.** Every daily pair is correlated
on residuals after a linear trend and one annual harmonic are taken out
of both series (trend only under 120 days), with p-values on the
Pyper-Peterman effective sample size. Weather channels compare the mean
of the day before and the day itself with the same day's outcome. Some
earlier findings no longer qualify.
- **Fold leftovers.** The nightly folds delete the raw samples they fold
instead of keeping them for 75 days. A re-sent raw sample whose hour or
day a live `stats:` row covers is a duplicate with reason `folded_window`,
in the batch route, the export import and the Health Connect import. A
background purge removes the backlog in batches of 5,000 per account; the
sync feed and a restore leave these deletions out. Health Connect heart
rate is folded like Apple Health's.
- **Duplicate batches.** A batch of samples the server already holds is
answered without a write and queues no record detection or reminder
sweep; caches are evicted only for a foreground or manual sync or a
hand-entered reading, and marked stale otherwise.
- **Locations encrypted.** The home location, travel periods and stored
environment days keep their coarse location sealed (migration 0382); a
background job seals existing rows.
- **Lighter client.** Settings sections load on their own (611 to 403 KB
gz) and the shared shell no longer carries Zod (199 to 136 KB gz).
- **Wellness scores** show their course on each tile and page; the sleep
score and readiness reads add `series`. The proxy caveat line is gone.
- **Empty states and add actions.** An empty list carries the one add
action; the Insights pages add through the header plus.
- **Touch and small screens.** 44 px targets on touch screens, a sidebar
that fits short windows without a native scrollbar, the phone shell on a
phone held sideways, 16 px fields, safe areas in the installed app and
sheets that stay above the on-screen keyboard.
- **Provider pause.** From five consecutive hard failures a provider is
skipped for a window that starts at 30 minutes and doubles, capped at a
day; a dead Codex refresh token reads as a sign-in to renew.
- **`work_mem`** is set per app connection from `DATABASE_WORK_MEM`
(16MB); `DATABASE_SESSION_OPTIONS_DISABLED` sends no session options for
hosts behind PgBouncer.
- Volume queues keep finished jobs for 96 hours instead of seven days.
### Fixed
- **Means folded from part of a day.** The folds cut at a fixed offset
instead of the local day boundary, so a second run replaced a mean-type
day, and the boundary hour of heart rate, HRV and SpO2, with the mean of
its later part. Folds now stop at the local day boundary, and a one-time
repair per account recomputes each mean from its samples and writes only
values that differ (migration 0389). It covers the last 75 days of daily
means and the 75 days before the 90-day raw window of hourly means, where
every sample of the day is still kept. Older days stay as they are: their
remaining samples can be a fragment of the day, and a mean over them would
be worse than the stored one. The nightly folds follow the same rule for a
day or hour that already has a mean. The purge waits for the repair.
- **Push after an update.** Reload on the new-version hint unregistered the
service worker, which ended the Web Push subscription; it now updates the
worker instead.
- **Discord and Slack webhooks.** A Discord webhook URL answered the test
and every reminder with "Cannot send an empty message", and a Slack
incoming webhook refused the body too: the generic format carried neither
Discord's `content` nor Slack's `text`. It now sends both as plain text
(title and message, cut to Discord's 2000 characters), tells Discord to
resolve no mentions, and keeps `title`, `message`, `eventType` and
`priority` unchanged for Home Assistant and n8n rules. Discreet mode
covers the new fields.
- **MCP deep links.** `/insights?metric=` and `/labs?analyte=` open the
metric's page and the marker's page.
- **Apple Health status** no longer reads "waiting for first data" after an
export import.
- **Strain** falls back to the device's day strain when no computed value
exists.
- Failed briefing runs name each account's outcome, a briefing held back
for a causal claim gets one repair attempt instead of hourly retries, and
the tiered context converts stored times to the reader's zone.
- Google Health re-reads two hours of intraday heart rate per sync, and the
first cycle of each local day a full day (#1023).
- The capture sheet opens from the dashboard's add button on phones; the
installed app follows its own theme in the status bar and toasts.
- `source-map-js` 1.2.2, `postcss-selector-parser` 7.1.6 and `smol-toml`
1.9.0 close CVE-2026-93749, CVE-2026-104844 and GHSA-r4xh-jqrq-34v2 in the
build and development toolchain.
### Removed
- The "took it twice" and "skipped a dose" achievements.
### API
All changes are additive.
- New routes: `/api/day/{date}`, `/api/day/index`, `/api/day/notable`,
`/api/timeline`, `/api/timeline/readiness`, `/api/life-events`,
`/api/vaccinations/custom`, `/api/environment/preferences`,
`/api/documents/inbound/layout`, `/api/import/health-connect-export`.
- The measurement source enum gains `HEALTH_CONNECT`.
- `/api/auth/me` adds `timezone`, `glucoseUnit` and `features.trackIntake`;
module availability lists `timeline`.
- `POST /api/measurements/batch` accepts `syncTrigger: "manual"`; a re-sent
sample whose hour or day is already condensed returns `duplicate` with
reason `folded_window`.
- `GET /api/integrations/healthkit` adds `lastReceivedAt`, `lastTrigger` and
`lastNewSampleAt` per type.
- The sync feed lists only deletions a person makes, not those of the
condensing step.
- `GET /api/environment` adds `airQuality` (with `history`, the progress of
the past-days backfill), `latestDay` and `attributions`.
- `GET`/`PUT /api/auth/me/source-priority` add `inUse`.
- A dose for a record-only medication is refused with 422
`medication.intake.notTracked`; failed passkey sign-ins answer with
`passkey.*` codes instead of a 500.
- `/api/medications/compliance` adds `meta.aggregate` and `?days=`;
`/api/mood/insights` takes `?days=` and returns `stabilityWindowDays`.
- Coach steps can name the tools `get_day` and `get_environment`, the
domains `day` and `environment`, and a `day` date.
- Vaccination doses carry `customVaccineId`, correlation findings `nEff`,
workouts `storedAvgHr` and `storedMaxHr`, and the sleep score and
readiness reads a `series` of past values.
- `/api/life-events*` answer only the record's owner; a delegate gets 403,
the day's `lifeEvents` section reads `not_shared` and the timeline has no
`life` lane.
- `POST /api/environment/backfill` answers 429
`environment.backfill_rate_limited` (three an hour per account) and 409
`environment.backfill_pending` while one is queued.
- `GET /api/environment` may return `lat` and `lon` as `null`.
- The handover claim preview no longer carries `grantId`; a guardian's
`displayName` may be `null`.
- The restore report adds the catalog `environmentLocationCiphertext`.
- `get_day` names documents and visits by kind only.
### Thanks
@lutzkind for #613, #615 and #586, @CritLoren for #972 and #1005, @ckaotik
for #959, @BernhardBuckel for #1110, @mills1975 for #1173 and @NebuPookins
for #1162.
## [1.41.2] — 2026-10-07
More room for a Coach turn, prompt caching that holds across its rounds, and
MCP tool results that match their schemas.
### Changed
- **Turn budget.** A turn on the person's own provider gets 165k tokens,
200 s and 16 rounds (was 120k, 150 s, 12); a turn on the operator's budget
55k tokens, 120 s and 8 rounds (was 40k, 90 s, 6). The final answer's
reserve keeps its share of each (1,650 tokens), the concurrent-turn slot
follows the longer wall time, and a turn shows up to 64 steps.
### Fixed
- **Cached input in the turn budget.** Codex requests carry a stable
`prompt_cache_key` and session id per conversation, as the official client
does, so later rounds of a turn read the prefix from the provider's cache
instead of paying for it again; `api.openai.com` gets the same key, gateways
do not. Anthropic usage now counts cache reads and cache writes in the
turn's total (they are reported beside `input_tokens`), so a cached round is
weighted at a tenth instead of almost nothing and a cache write in full.
Local and OpenAI-compatible servers that report
`prompt_tokens_details.cached_tokens` have it counted.
- **MCP output schemas (#1170).** `list_metrics` declares the `availability`
its rows already returned, the Coach-backed reads declare `searchedWindow`
and `available`, and `get_intraday_pulse` declares each bucket's `min` and
`max`. The advertised
schemas close every object, so a strict client refused these results. Every
tool's real output is now validated against its advertised schema under a
strict 2020-12 validator. Thanks to @RyRyMason for #1170.
- **Deleted readings in MCP discovery.** The `list_metrics` discovery rows and
the `search` clinical-signal probe count live rows only: a measurement type
whose readings were all deleted is no longer listed as present, and the
probes use the live-rows index instead of walking every deleted row.
## [1.41.1] — 2026-10-07
A calmer thinking row in the Coach.
### Changed
- **One thinking row.** While the Coach works, a single row shows a brain
icon and the current step in body text with a soft moving highlight and
animated dots ("Reading blood pressure…"); no running seconds. The interim
table line under it is gone; tables appear with the answer. Afterwards the
row reads "Thought process · N steps", stays closed unless opened, and
closes again when a turn finishes. Opened, the steps stand on a thin line,
each with its own icon and the reasoning summary under its title.
### Fixed
- **Coach in the demo.** A refused chat on a demo instance says that the
Coach doesn't answer in the demo instead of reporting an unreachable
provider; demo refusals carry `meta.errorCode: "demo.readOnly"`.
## [1.41.0] — 2026-10-07
The Coach reasons before it answers, shows what it is doing while it works,
asks when a question is ambiguous, and remembers what it is told.
### Added
- **Reasoning.** Codex, OpenAI, Anthropic, OpenRouter, gateways and local
servers receive the requested reasoning in their documented dialect;
reasoning summaries stream to the Coach, signed or encrypted reasoning state
is carried between tool rounds and never stored, and a model that rejects a
reasoning parameter is retried without it (the downgrade is learned per
provider, endpoint and model for an hour, only from a 400 that names the
parameter). Thinking depth per person under Coach settings (Off, Low,
Medium, High; Medium by default); an operator switch and maximum under
Admin, Assistant; turns on the operator's budget are capped at Medium.
`/api/auth/me` publishes the resolved `coachReasoning`.
- **Budgeted tool loop.** A turn runs against a token, time and round budget
(own provider 120k tokens, 150 s, 12 rounds; operator 40k, 90 s, 6 rounds)
with a no-progress brake and a reserved final round that keeps the tool
definitions with `tool_choice: none`. Every round is reserved against the
daily budget beforehand and settled afterwards. At most two concurrent turns
per person.
- **Live trail.** One quiet status line per answer (`activity` frames), never
expanded by itself; the screened reasoning text is stored encrypted
(`coach_messages.trail_encrypted`) and served lazily by
`GET /api/insights/chat/{id}/messages/{messageId}/trail`.
- **Comparisons.** `compare_series` (two periods or two metrics) and a
`compare` chart; tables arrive as interim results before the answer.
- **Clarifications** as suggested replies in the thread, at most one in six
turns and three a day; a declined or limited question becomes a stated
assumption with a reply that changes it.
- **Memory and plans.** Every turn carries an egress-checked memory block;
`remember_fact` saves preferences, goals and context with a visible note and
undo, health facts only after the person agrees; `propose_plan` suggests a
plan from a stated goal. Both are written only when the answer that shows
them is stored. Background extraction runs once a conversation is quiet for
30 minutes; plan progress reaches the daily briefing. The memory list in
settings shows each fact's source and accepts edits.
- **Background reasoning** for the daily briefing (scheduled and on request)
and the weekly and monthly narratives; status cards never reason.
### Fixed
- **Dose-change screen.** The outbound screen blocks the dose-change class
across all seven languages, including the person's own medication names,
drug classes, spelled-out amounts and "extra / one more"; plan texts go
through the same screen.
- **Glucose by time of day.** The usual-range line, the MCP baseline and the
signals of the day compare glucose with the same hours of earlier days
instead of setting a morning reading against whole-day means.
- **Usual-range line** names the date of a latest reading that is not from
today, and a steady value as one figure.
- **Memory refresh** asks the Coach capability before deferring, so a
switched-off Coach does not re-queue it.
- **Dependencies.** The MCP SDK moves to 1.32 (GHSA-6qxp-vccf-f47h) and
`proxy-addr` to 2.0.8 (GHSA-jqcg-44mw-7w3h).
### Changed
- **Answer voice.** The first sentence answers; 40 to 120 words; at most one
next step.
- **Keyboard focus** is drawn in a neutral tone everywhere.
### Migrations
- `0376_ai_reasoning_controls`, `0377_coach_message_trail`,
`0378_coach_fact_source`: additive columns with constant defaults or
nullable; no table rewrite.
## [1.40.6] — 2026-10-05
The Coach reads long series again; "today" only from today's readings; the
dashboard card fits a phone; text fields lose the purple focus ring.
### Fixed
- **Coach series without numbers.** Since 1.21.0 every Coach tool read one
shared per-turn snapshot capped at 24,000 characters; on accounts with many
series whole blocks became a placeholder and the tool still answered
`present: true`. A trimmed block is now re-read for the tool's own source,
a Coach tool's read of one source is condensed instead of emptied (a
summary from the daily values with the latest week against the first,
the newest days, monthly means, each block in its own fields), a block
without numbers
answers `retrieval_failed` to the Coach, and the Coach calls the table tool
itself for long histories. MCP reads are unchanged. The step list shows
the reading count; no stray comma on copy.
- **"Today" from yesterday's readings.** Signals of the day, the vitals fact,
the lead line, the health status and the morning push only speak about
today from readings taken today in the profile timezone; a stored briefing
from an earlier day is no longer served.
- **MCP schemas for strict clients (#1170).** `tools/list` advertises every
input and output schema as JSON Schema 2020-12 without a `$schema` key, so
clients that only accept 2020-12 load the tools; tool results are unchanged.
- **Briefing for the local day.** The first read of a local day and the first
reading of a signal metric that day warm the briefing once (within the
existing daily limit), so users outside Europe and readings taken in the
morning get a current briefing; every surface resolves it the same way.
- **Deltas** use each metric's display precision ("+34 bpm").
### Changed
- **Dashboard card on a phone.** A compact score ring at the top right beside
the lead; Today facts as label over value, full width. Desktop unchanged.
- **Text-field focus.** No purple ring on inputs, textareas, selects and date
fields; focus shows as a neutral border (`--input-focus`). Invalid fields
keep their red border.
- **Coach panel** no longer has a search field (the full conversation list
keeps its search).
## [1.40.5] — 2026-10-05
The Coach's conversation panel runs the full height of the window.
### Changed
- **Coach panel.** Docked, the panel runs from the top of the window to the
bottom, beside the top bar instead of below it, and its header line
continues the top bar's. Shut, nothing of it remains on screen.
- **Top bar on the Coach.** It names the page and the open conversation, and
the panel toggle sits at its right end, against the panel.
- **New chat.** A round button with a plus at the panel's bottom right.
## [1.40.4] — 2026-10-05
The Coach's conversation panel docks to the right edge; model and tokens
move behind an info icon; no view chip for a result that already toggles.
### Changed
- **Coach panel.** Flush against the right edge, full height below the top
bar, its own scroll area; the shell renders `/coach` without its container
gutter. The toggle uses mirrored panel icons and moves with the panel.
Its buttons and rows go compact only beside a mouse or trackpad; with
touch they keep the 44 px target at any width, a tablet included.
- **Answer row.** Copy, read aloud, retry, an info icon and the time in one
left-aligned group; the info icon shows model and token count on hover,
focus or tap, and Enter or Space keeps it open.
- **Follow-ups.** No "show as chart/table" chip when the answer's result
already offers that toggle; also for stored answers.
## [1.40.3] — 2026-10-05
Small fixes to the Today overview and the numbers behind it.
### Added
- **`signalLine` on the daily digest.** The muted line under the lead is now
decided on the server: when the lead already talks about the top signal's
metric, only the delta remains, and the line is null when nothing is left.
Every client shows the same line.
- **`basis` on health-status deviations.** `day` for the usual whole-day
range, `sameHours` for the usual range at this time of day.
### Fixed
- The Today lead is shown as whole sentences. A long sentence is no longer
cut mid-word; the headline or the deterministic sentence takes its place,
and only as a last resort is text shortened, at a word boundary.
- Chart trend lines, legends and tooltips name every metric in the reader's
language instead of showing its internal name (resting heart rate among
them).
- A personal usual range never collapses to a single value ("61–61 bpm"):
it keeps at least the measurement's own day-to-day spread, so the next
ordinary reading is not counted as outside it.
- Glucose today is compared with the same hours of earlier days, up to the
time of today's latest reading. A fasting morning no longer reads as below
the range of whole days, and the range named is the usual one for this
time of day.
- Encrypting stored text in place or rotating the encryption key no longer
makes old Coach conversations look recently active, and the Coach memory
summary no longer moves a conversation either. Measurements, mood entries
and every other encrypted record keep their sync position through the
same kind of rewrite.
- Conversations an earlier release made look recently active move back to
their last message (migration 0375).
## [1.40.2] — 2026-10-05
A calmer Coach with conversations in a side panel and settings in place; the
top of the dashboard says what matters today; documents filter by procedure.
### Added
- **Coach panel.** Conversations in a collapsible right panel (a sheet on a
phone), grouped by day, with rename, delete with undo and a new-chat
button; a gear opens quick settings (provider, model and reasoning where
they are the user's, and the lookback). `/coach?settings=data` opens it.
- **Coach lookback as a real limit.** 7, 30 or 90 days, 12 months
(`lastYear`, new) or all; it limits the snapshot and every Coach tool, a
read beyond it answers `outside_reach`. MCP keeps its own windows.
- **Today overview.** The digest gains `lead`, `today[]`, `restMode` and
`score.steadyWeeks`/`steadyAtLeast`: a lead line with content (deterministic
without AI), up to five facts (medications, an appointment today or
tomorrow, last night, vitals, cycle, Rest Mode), the ring with how long the
score has been steady. The morning push line carries no vital values.
- **Procedure filter in the document archive (#1154).** `linkedProcedures` on
the usage endpoint; the list already accepted `encounterId`.
### Changed
- **Coach answers.** One action row (copy, read aloud, retry, time; tokens and
model); web thumbs and the evidence block removed; remember is an icon;
follow-up chips in the answer column; a scroll-to-bottom button; the
composer's "+" menu removed.
- **Settings → Coach** summarises what the Coach sees and links into the
overlay.
- Coach preferences parse field by field, so one invalid value no longer
drops the others.
### Fixed
- The medications fact counts the whole day on a 25-hour DST day.
- Providers without credentials are no longer offered in the Coach overlay;
an operator-managed provider shows as such.
- Document filter controls match the search field's height on a phone.
## [1.40.1] — 2026-10-04
Restore opens older encrypted values again; faster pages on a long-used
server; a day of pulse is the mean of its hours; Apple Health imports leave
out HealthLog's own samples; more memory for the app by default.
### Fixed
- **Restore key probe.** A legacy unprefixed ciphertext was read as a base64
string and refused a restore the key opens; legacy values open as strings,
probes come only from sections that count, binary probes use their AAD.
The drill checks every account of the newest date.
- **Daily pulse (#1107, migration 0372).** A day is the mean of its local
hours' means and a window the mean of its days, in every reader; stored
pulse rollups are recomputed. Count, min and max stay over every reading.
- **Apple Health import (#1109).** Records carrying HealthLog's origin
marker or a HealthLog id are left out and counted; cycle samples too.
- **Blood pressure series.** One source per day for both values.
- **Backups.** Heartbeat on both backup jobs; per-account attempts recorded
(migration 0373), interrupted accounts go last; the preview is built from
the writer's counts.
- **Document previews.** A permanent render failure is recorded (migration 0374) instead of re-queued at every start.
- **Umami.** Events from a tracker built with a custom endpoint are proxied
to the configured Umami host.
- **Display.** Rest Mode shows its start date; the favicon is transparent;
the sidebar no longer flashes scrollbars while it expands.
### Changed
- **Performance.** One Prisma client per process instead of one per bundle;
the rollup coverage probe uses a loose index scan and concurrent readers
share one probe; timezone offsets are memoised per hour; chart text is
measured outside `<body>`; Insights mounts lower sections near the
viewport; a nav prefetch is not re-sent on mount; MCP single-metric reads
build only the requested metric; the backup schema loads on first use.
- **Lab list (#1057).** The range bar and name use a wide card.
- **Compose.** `APP_MEMORY_LIMIT` defaults to `1536m` (792 MB heap).
- Dependencies: next 16.3.8, eslint-config-next 16.3.8, zod-openapi 6.0.2,
testcontainers 12.2.0, p-limit 7.3.3.
## [1.40.0] — 2026-10-04
Back up the encryption key and refuse a key that cannot open the data;
several courses and custom categories for medications; custom symptoms;
document reads in the background; sick notes; lab unit refusal and charts
that follow the source priority; blood pressure no longer loses its
diastolic value; document previews work again.
### Added
- **Encryption key backup step (migration 0365).** Admin step with key id,
fingerprint and per-platform guidance (`HEALTHLOG_PLATFORM`), an optional
"check my copy" that compares and discards the pasted key, a reminder that
returns when the key changes.
- **Boot key check.** A canary per key id, probed oldest values first across
string and Bytes columns; a proven mismatch answers 503
`encryption.key_mismatch` everywhere except `/api/health` and
`/api/version`, MCP included, and starts no worker; an unproven result
serves, warns and re-checks. `ENCRYPTION_KEY_CHECK=warn` as a last resort.
- **Medication courses and custom categories (#1024, #1041, migration
0368).** Several courses per medication; compliance, streak, cadence and
dose history count every course and never the gaps; existing dated
medications become one course each. Own categories as `custom:<id>` with
`categoryLabel`; the runtime category table is now in the schema.
- **Custom symptoms (#981, migration 0369).** Up to eight definitions, events
with a 0 to 10 intensity, a capture kind, one correlation channel per
symptom, a Coach block; gated on the Illness module.
- **Document AI as background runs (#1090, migrations 0366 and 0370).**
Index, summary, suggestions and extraction accept `Prefer: respond-async`;
the lab scan always runs in the background; `GET /api/ai-runs/{id}`.
- **`SICK_NOTE` document kind.**
- **Natural durations (#1101)** in the sleep field.
- **Reasoning effort per provider (#1126).** The Local and OpenAI-compatible
forms gain a Reasoning select (Default, Off, Low, Medium, High), stored on
the provider's chain entry; Default sends nothing, Off sends
`reasoning_effort: "none"`. A rejection of that field no longer switches
the endpoint's JSON mode off.
### Fixed
- **Blood pressure saved without its diastolic value (iOS #15, migration
0371).** The iPhone app sends the two halves of a manual reading as two
requests under one `Idempotency-Key`, and the server answered the second
with the first one's cached response. The replay cache now stores a SHA-256
fingerprint of method, path and body (JSON canonicalised, a multipart
boundary normalised) and replays only the same request; a different body
under the same key is saved. Readings saved before this stay incomplete;
`docs/ops/blood-pressure-missing-diastolic.md` lists them.
- **Document previews and scanned PDFs (#1124).** The image hoisted an older
`@napi-rs/canvas` binary next to the pinned loader, so every thumbnail
failed; the build now hoists the pinned version and fails on a mismatch.
PDFs render with pdfjs font, cmap and wasm data and a registered fallback
font instead of absent system fonts, and an all-blank render reports
`raster-failed`. The thumbnail job logs why a preview is missing.
- **Portable backups.** Cycle free text, custom mood labels, visit notes
and vaccination notes are carried readable and re-encrypted on restore
instead of as the source server's ciphertext; custom mood tags restore
without mood entries; a round-trip guard fails on any ciphertext in a
portable file. A medication update and its course share one transaction.
- **Lab unit mismatch (#1094).** A reading in another unit than its marker is
refused (`labs.unit.mismatch`) on every write path; a marker's unit is
locked while it has readings (`biomarkers.unit.locked`).
- **Source priority in series (#1108).** Pulse, HRV and SpO2 series read the
ladder's source per day; unranked types keep every source.
- **Sign-in over plain http (#1097).** Refused with
`auth.session.insecure_transport` before a password, code or challenge is
used; the sign-in page says so first.
- **Chart window (#1102).** Ends at the end of today in the profile time zone.
- **Workouts.** The insight cap survives a delete (migration 0367); a changed
`manual:` resubmit updates the workout and its paragraph.
- **Display.** Clinician decimals in the owner's locale, stated dates east of
UTC+12, archive month grouping, lab list columns without a range, icon
picker touch targets and labels, symptom entry load errors.
### Changed
- Quick-entry forms load on demand; the four eager route baselines are
lowered to the new measurements.
## [1.39.9] — 2026-10-03
Workouts can be logged by hand, the body fat tile opens its own page, and
+38 -15
View File
@@ -54,6 +54,9 @@ RUN NODE_OPTIONS=--max-old-space-size=8192 pnpm build
# name; see the note there for why guessing was wrong.
RUN node -p "require('/app/node_modules/pdfjs-dist/package.json').version" \
> /app/.pdfjs-version
# The same for @napi-rs/canvas, whose native binary must match its JS loader.
RUN node -p "require('/app/node_modules/@napi-rs/canvas/package.json').version" \
> /app/.canvas-version
# ── Stage 3: Production runner ─────────────────────────────
FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2 AS runner
@@ -120,21 +123,41 @@ RUN node -e "require.resolve('pg-boss'); require.resolve('pg')"
# @napi-rs/canvas" → "Cannot polyfill DOMMatrix" → rasterize ReferenceError);
# (2) @napi-rs/canvas's own loader then resolves its platform binary. Hoist BOTH
# the canvas package and the musl binary package to /app/node_modules/@napi-rs
# via symlink into the .pnpm store, and also drop the prebuilt .node into the
# canvas dir so the loader's local-file fallback (`require('./skia.<triple>.node')`)
# is belt-and-suspenders. Per-arch buildx installs only the matching musl triple.
# via symlink into the .pnpm store, and drop the prebuilt .node into the canvas
# dir: the loader tries that local file (`require('./skia.<triple>.node')`)
# before the platform package, so it decides which binary runs.
#
# Hoist the version the app pins, by name, exactly like pdfjs-dist below. The
# store holds a second, older canvas that pdf-parse's own pdfjs-dist pulls in,
# and this step used to take whichever `find | head -1` returned first. It
# returned the old binary, which then sat next to the new loader: rendering
# still worked, but `loadImage` called an `Image.decode` the old binary does
# not have, and every preview thumbnail failed with "image.decode is not a
# function". Name the version, and fail the build when the binary that lands
# in the canvas dir is not that version's.
COPY --from=builder /app/.canvas-version /tmp/.canvas-version
RUN set -e; \
CANVAS_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path '*@napi-rs+canvas@*/node_modules/@napi-rs/canvas' 2>/dev/null | head -1)"; \
MUSL_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path '*@napi-rs+canvas-linux-*-musl@*/node_modules/@napi-rs/canvas-linux-*-musl' 2>/dev/null | head -1)"; \
NODE_BIN="$(find /app/node_modules/.pnpm -maxdepth 5 -name 'skia.linux-*-musl.node' 2>/dev/null | head -1)"; \
if [ -n "$CANVAS_DIR" ]; then \
mkdir -p /app/node_modules/@napi-rs; \
ln -sfn "$CANVAS_DIR" /app/node_modules/@napi-rs/canvas; \
[ -n "$MUSL_DIR" ] && ln -sfn "$MUSL_DIR" "/app/node_modules/@napi-rs/$(basename "$MUSL_DIR")"; \
[ -n "$NODE_BIN" ] && cp "$NODE_BIN" "$CANVAS_DIR/"; \
chown -R nextjs:nodejs /app/node_modules/@napi-rs; \
echo "canvas hoisted for pdfjs: $CANVAS_DIR (musl=$MUSL_DIR)"; \
else echo "WARN: @napi-rs/canvas not found; PDF rasterization will degrade to local text"; fi
CANVAS_VERSION="$(cat /tmp/.canvas-version)"; \
rm -f /tmp/.canvas-version; \
CANVAS_DIR="/app/node_modules/.pnpm/@napi-rs+canvas@${CANVAS_VERSION}/node_modules/@napi-rs/canvas"; \
MUSL_DIR="$(find /app/node_modules/.pnpm -maxdepth 4 -type d -path "*@napi-rs+canvas-linux-*-musl@${CANVAS_VERSION}/node_modules/@napi-rs/canvas-linux-*-musl" 2>/dev/null | head -1)"; \
if [ ! -d "$CANVAS_DIR" ] || [ -z "$MUSL_DIR" ]; then \
echo "ERROR: pinned @napi-rs/canvas@${CANVAS_VERSION} or its musl binary did not reach the traced image."; \
echo "Present instead:"; \
find /app/node_modules/.pnpm -maxdepth 1 -name '@napi-rs+canvas*' 2>/dev/null; \
exit 1; \
fi; \
NODE_BIN="$(find "$MUSL_DIR" -maxdepth 1 -name 'skia.linux-*-musl.node' | head -1)"; \
[ -n "$NODE_BIN" ] || { echo "ERROR: no skia binary in $MUSL_DIR"; exit 1; }; \
mkdir -p /app/node_modules/@napi-rs; \
ln -sfn "$CANVAS_DIR" /app/node_modules/@napi-rs/canvas; \
ln -sfn "$MUSL_DIR" "/app/node_modules/@napi-rs/$(basename "$MUSL_DIR")"; \
cp "$NODE_BIN" "$CANVAS_DIR/"; \
chown -R nextjs:nodejs /app/node_modules/@napi-rs; \
BIN_VERSION="$(node -p "require('$MUSL_DIR/package.json').version")"; \
[ "$BIN_VERSION" = "$CANVAS_VERSION" ] || { echo "ERROR: canvas binary is $BIN_VERSION, loader is $CANVAS_VERSION"; exit 1; }; \
cmp -s "$NODE_BIN" "$CANVAS_DIR/$(basename "$NODE_BIN")" || { echo "ERROR: the canvas dir holds a different binary"; exit 1; }; \
echo "canvas hoisted for pdfjs: $CANVAS_DIR (binary $BIN_VERSION from $MUSL_DIR)"
# pdfjs-dist is `serverExternalPackages` (NOT bundled — the bundled copy's render
# path breaks in the standalone image), so the server does a runtime bare
@@ -224,7 +247,7 @@ RUN mkdir -p /opt/prisma-cli && \
npm pkg set 'overrides.mysql2=^3.22.0' && \
npm pkg set 'overrides.@hono/node-server=^1.19.15' && \
npm pkg set 'overrides.valibot=^1.4.2' && \
npm install --omit=dev prisma@7.10.0 @prisma/engines@7.10.0 tsx@4.23.1 dotenv@17.4.2 && \
npm install --omit=dev prisma@7.10.0 @prisma/engines@7.10.0 tsx@4.23.1 dotenv@18.0.6 && \
ln -sfn /opt/prisma-cli/node_modules/.bin/tsx /usr/local/bin/healthlog-tsx && \
ln -sfn /opt/prisma-cli/node_modules/dotenv /app/node_modules/dotenv && \
ln -sfn /opt/prisma-cli/node_modules/prisma /app/node_modules/prisma && \
+3 -3
View File
@@ -24,7 +24,7 @@
<a href="https://testflight.apple.com/join/bucuTBpa">iOS TestFlight</a>
</p>
<img width="2434" height="1247" alt="HealthLog dashboard: health metric tiles with 7-day trends, blood-pressure and weight charts, dark theme" src="docs/assets/dashboard-hero.jpeg" />
<img width="2434" height="1247" alt="HealthLog dashboard in dark theme: the Today overview with a lead sentence, the health score ring, Today facts and worth-a-look cards, above metric tiles with 7-day trends" src="docs/assets/dashboard-hero.jpeg" />
<p align="center">
<img src="docs/assets/Insights.png"
alt="Insights"
@@ -129,9 +129,9 @@ Actively developed — new releases roughly weekly, issue reports and PRs welcom
HealthLog started as something I built for myself. That it has turned into a project other people use, care about and help shape still surprises me, and I am grateful for every part of it.
Thank you to everyone who sent code that shipped: [@TimonBed](https://github.com/TimonBed), [@Antiheld86](https://github.com/Antiheld86), [@BernhardBuckel](https://github.com/BernhardBuckel), [@balajiv113](https://github.com/balajiv113), [@aucun6352](https://github.com/aucun6352), [@mathewcsims](https://github.com/mathewcsims) and [@muhdusama](https://github.com/muhdusama). A special thanks to [@BernhardBuckel](https://github.com/BernhardBuckel), who has been working through the lab screens one careful pull request at a time, with tests and a plan before every change.
Thank you to everyone who sent code that shipped: [@TimonBed](https://github.com/TimonBed), [@Antiheld86](https://github.com/Antiheld86), [@BernhardBuckel](https://github.com/BernhardBuckel), [@balajiv113](https://github.com/balajiv113), [@aucun6352](https://github.com/aucun6352), [@mathewcsims](https://github.com/mathewcsims) and [@muhdusama](https://github.com/muhdusama).
Thank you to everyone who opened an issue, asked a question in the discussions or wrote me an email. Most of what got better in the last months started with someone describing what went wrong for them, often with a log, a screenshot and a second try when the first fix was not enough.
Thank you to everyone who opened an issue, asked a question in the discussions or wrote me an email, and especially to [@lutzkind](https://github.com/lutzkind), [@HercoGC](https://github.com/HercoGC), [@mills1975](https://github.com/mills1975), [@sreeramachandramurthy](https://github.com/sreeramachandramurthy), [@CritLoren](https://github.com/CritLoren), [@mbreitkreuz](https://github.com/mbreitkreuz), [@k-nacion](https://github.com/k-nacion), [@NebuPookins](https://github.com/NebuPookins), [@Nazza01](https://github.com/Nazza01), [@Cnote43](https://github.com/Cnote43) and [@juliaanvaneijndt](https://github.com/juliaanvaneijndt), whose reports shaped HealthLog again and again. Most of what got better in the last months started with someone describing what went wrong for them, often with a log, a screenshot and a second try when the first fix was not enough.
And thank you to everyone who supported the project with a coffee or by buying the iPhone app. I don't name anyone here, but I read every message that comes with it. It pays for the servers and the Apple developer account, and it tells me that HealthLog is worth the evenings.
+5 -4
View File
@@ -1,12 +1,13 @@
{
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
"$comment": "Client-bundle budgets, enforced by scripts/check-bundle-budget.mjs (--check) after `next build` in CI. Values are KB gzip and include every emitted lazy route and locale chunk, while route budgets include the shared rootMainFiles baseline. Re-measured at v1.37.2: / 455, /insights 443, /measurements 441, /insights/mood 453, total 3328, baseline 130, lazy catalogs 849. Every per-route ceiling is unchanged. The /insights overview crept a kilobyte over its 445 ceiling when the ECG teaser began importing its overview cap from the classification module, dragging that module's label map and helpers into the eager page; the cap now lives in its own value-only module, and the Health Score's single provenance entry is split out of the full metric-provenance map, so the overview imports the bare number and citation it renders rather than every metric's label map behind them. Both are back under 445 with room to spare. The 42 KB aggregate rise from 3286 (v1.35.0) is all locale-catalog strings: v1.36 and v1.37 added the encounter, mood-context, mood-prognosis, practitioner, workout-batch and account-sharing surfaces, and their copy lands across the six locale catalogs the whole-build sum counts independently (804 to 849 KB). The non-catalog client chunks are flat against v1.35.0, so nothing a person downloads on a given route grew beyond its own ceiling. The aggregate cap therefore moves from 3310 to 3352 (24 KB headroom, matching the previous margin); per-route ceilings, the static-catalog guard and the one-Recharts-chunk guard remain unchanged. v1.37.4's honest-error-state charge then added recoverable read-failure handling to thirteen surfaces (the shared QueryErrorCard and QueryErrorRow primitives plus an isError branch per surface) and rewrote the MCP scope note and the managed-record error copy across the six locales; the measured total rose to 3404 while every per-route ceiling stayed met (/page 457, /insights 445, /measurements 443, /insights/mood 455). The aggregate cap moves from 3352 to 3428, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.15's async-truth charge then added the refresh settling state with its two-locale-key copy, the index-outcome plain-words line (seven keys across the six catalogs) and the reindex-count wiring; the measured total rose to 3429 while every per-route ceiling stayed met (/page 456, /insights 445, /measurements 442, /insights/mood 455). The aggregate cap moves from 3428 to 3452, keeping the same margin; nothing a person downloads on a given route grew beyond its ceiling. v1.37.27 found /insights sitting a few hundred bytes under 445 on main and a few hundred over it on the release branch — the page had been at its ceiling for three releases and the release only had to breathe on it. The weight it was carrying was not its own: the authenticated shell imported one destination predicate from the `@/lib/record-settings` barrel, and the barrel also re-exports the managed settings-patch schemas, so Zod, the module registry and three validation modules rode into the eager bundle of every authenticated route for a function that reads a URL slug. The shell imports the `classification` leaf now. Measured across all 585 client-rendered routes: none grew by a byte, 100 fell, by 4.7 KB at the median and 8.1 KB at the most; /insights 444 to 437, /measurements 442 to 435, / 456 to 450, /insights/mood 455 to 450. The whole-build sum moved the other way, 3451 to 3476, and that direction is the metric rather than the change: the aggregate counts each emitted chunk once, so a graph that leaves the chunk group every route shares gets counted again wherever it lands — here inside the deferred settings chunks that actually use it. Bytes on disk rose 25 KB; bytes a reader downloads only ever fell. The aggregate cap moves from 3452 to 3500, the same 24 KB headroom as the four re-measurements before it. Per-route ceilings keep their old values rather than tightening to the new floor: the CI runner measures each route about 1.5 KB heavier than a local arm64 build does, and a ceiling set from a local number would fail on a build that had not regressed. The static-catalog guard and the one-Recharts-chunk guard remain unchanged. Raised to 3700 at v1.37.32 for the seventh interface language (Korean): the lazy message catalogs went 6->7 chunks (1026 KB gz measured), pushing the aggregate to 3631 against the old 3500. The raise is entirely the new catalog; no route budget moved, and the four eager route ceilings are unchanged and still passing. Most of the aggregate is the dynamic locale-switch and fallback path that a first paint never loads (see issue #253), so this ceiling guards bytes almost no visitor requests; 3700 leaves ~69 KB for ordinary drift until that double-count is cleaned up. v1.38.14 re-measures the four eager routes and changes what the number means. The ceilings had been re-stated but never re-based since v1.37.4, so /inLine truncated
"routeBaselineKbGz": {
"/page": 429,
"/insights/page": 368,
"/insights/page": 304,
"/measurements/page": 411,
"/insights/mood/page": 379
"/insights/mood/page": 379,
"/settings/[section]/page": 403
},
"routeDriftAllowanceKbGz": 20,
"totalClientKbGz": 4100,
"totalClientKbGz": 4760,
"maxRechartsChunks": 1
}
+35 -8
View File
@@ -72,6 +72,11 @@ x-healthlog-env: &healthlog_env
# (default 60000; 0 disables it and restores the unbounded behaviour).
# Raise it if a very large account's analytics fallback is being cut off.
DATABASE_STATEMENT_TIMEOUT_MS: "${DATABASE_STATEMENT_TIMEOUT_MS:-}"
# Postgres work_mem for every app session (default 16MB).
DATABASE_WORK_MEM: "${DATABASE_WORK_MEM:-}"
# 1 sends neither of the two settings above (they ride the libpq `options`
# startup parameter, which PgBouncer refuses by default).
DATABASE_SESSION_OPTIONS_DISABLED: "${DATABASE_SESSION_OPTIONS_DISABLED:-}"
WITHINGS_WEBHOOK_SECRET: "${WITHINGS_WEBHOOK_SECRET:-}"
# Overrides the derived `${NEXT_PUBLIC_APP_URL}/api/withings/callback`;
# set it when the URL registered with Withings differs from the app's own
@@ -244,6 +249,11 @@ x-healthlog-env: &healthlog_env
# the coordinate lookups off a third party.
OPENMETEO_BASE_URL: "${OPENMETEO_BASE_URL:-}"
OPENMETEO_GEOCODING_URL: "${OPENMETEO_GEOCODING_URL:-}"
# Air quality, pollen and UV (CAMS via Open-Meteo). Same hosted default and
# self-host option as above; ENVIRONMENT_AIR_QUALITY_DISABLED=1 stops the
# air-quality requests for the whole instance.
OPENMETEO_AIR_QUALITY_URL: "${OPENMETEO_AIR_QUALITY_URL:-}"
ENVIRONMENT_AIR_QUALITY_DISABLED: "${ENVIRONMENT_AIR_QUALITY_DISABLED:-}"
# OIDC SSO login (self-hosted IdP: Authentik/Keycloak/Authelia/etc).
# All three of ISSUER_URL/CLIENT_ID/CLIENT_SECRET together enable the
# "Sign in with SSO" button; set none to leave it disabled. OIDC_ONLY
@@ -467,13 +477,15 @@ services:
options:
max-size: "10m"
max-file: "3"
# Conservative memory ceiling for a small host. HEALTHLOG_PROCESS_TYPE=all
# bundles the AI/rollup worker into this same process, and unbounded that
# workload can grow large enough under load to starve Postgres of memory
# on a shared host. 1g is a starting point, not a measured ceiling — raise
# it with APP_MEMORY_LIMIT in .env if the app gets OOM-killed under real
# load.
mem_limit: "${APP_MEMORY_LIMIT:-1g}"
# Memory ceiling for the app. HEALTHLOG_PROCESS_TYPE=all bundles the
# AI/rollup/backup worker into this same process, and unbounded that
# workload can grow large enough to starve Postgres on a shared host.
# Node sizes its heap from this limit: 1g gives a 524 MB heap, which a
# long-used account fills in normal operation, so garbage collection
# stalls requests for seconds and a weekly backup can crash the process.
# 1536m gives 792 MB. On a host with 2 GB or less, lower it with
# APP_MEMORY_LIMIT in .env; on a busy instance, raise it.
mem_limit: "${APP_MEMORY_LIMIT:-1536m}"
healthcheck:
test:
[
@@ -511,7 +523,7 @@ services:
# options:
# max-size: "10m"
# max-file: "3"
# mem_limit: "${WORKER_MEMORY_LIMIT:-1g}"
# mem_limit: "${WORKER_MEMORY_LIMIT:-1536m}"
db:
# Pinned to the concrete minor instead of the floating `16-alpine`
@@ -531,6 +543,21 @@ services:
# (concrete pin, active watch) from being dropped.
image: postgres:16.15-alpine
container_name: healthlog-db
# Loads pg_stat_statements so slow queries can be found from measurements
# instead of guesses. Loading the library only collects statistics; the
# view appears after a one-time `CREATE EXTENSION pg_stat_statements;`
# (docs/ops/query-statistics.md). Changing this line restarts the
# database once on the next `compose up`.
command:
- postgres
- -c
- shared_preload_libraries=pg_stat_statements
- -c
- pg_stat_statements.track=top
# Docker gives a container 64 MB of /dev/shm by default. Postgres keeps
# the shared memory of parallel queries and parallel maintenance there,
# and a larger plan fails with "could not resize shared memory segment".
shm_size: 256mb
# cap_drop: [ALL] is deliberately NOT applied here (unlike `app`): the
# official Postgres entrypoint starts as root, chowns the data
# directory, and drops privileges to the `postgres` user itself, which
+1 -1
View File
@@ -19,7 +19,7 @@ Every JSON route answers the same shape: `{ data, error }`, with `meta` on the e
- Refresh tokens rotate per device and are one-time-use. Reusing an old one revokes that device's whole token family, on purpose. Persist the newest pair atomically and never retry a refresh with a token that already succeeded once.
- Bearer token scopes are fail-closed. A token minted for one purpose does not open unrelated routes, and a route that does not declare a scope refuses narrow tokens outright. Expect `403` to be a normal, recoverable answer, not an anomaly.
- Admin surfaces are cookie-session only, by construction. A native client cannot be an admin client, whatever scopes its token carries. Do not build admin features.
- Mutations accept an `Idempotency-Key` header. Replays answer with the original result plus `X-Idempotent-Replay`, so retrying on a dropped connection is safe. Use it for anything a user would notice twice.
- Mutations accept an `Idempotency-Key` header. A repeat of the same key with the same body answers with the original result plus `X-Idempotent-Replay`, so retrying on a dropped connection is safe. A different body under the same key is treated as a new request, not a replay; still, give each distinct write its own key. Use it for anything a user would notice twice.
## Data semantics worth knowing before you sync
+4
View File
@@ -34,6 +34,10 @@ notes, and machine-readable specs that ship alongside the source:
managed profiles for somebody with no login and the guardians who look
after them, what revocation does and does not undo, and which trust
properties of the instance are unchanged by any of it.
- [`using/`](./using/) — notes on using the app that sit close to the
code. [`using/search.md`](./using/search.md) covers the command palette's
record search: opening it, what it finds, how matching works, and its
limits on very large and shared records.
- [`migration/`](./migration/) — migration notes. Currently only
v1.3 to v1.4; later releases are covered by `CHANGELOG.md` and
<https://docs.healthlog.dev>.
+7576 -2123
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 344 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 831 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 461 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 778 KiB

After

Width:  |  Height:  |  Size: 100 KiB

+11
View File
@@ -120,6 +120,14 @@ The HK quantity types that map to HealthLog metric types live in
mapped set (for example workout routes) is counted as unsupported and
not stored.
Heart-rate variability comes in two measures, and they stay two types.
`HeartRateVariabilitySDNN` becomes `HEART_RATE_VARIABILITY`;
`HeartRateVariabilityRMSSD`, which iOS and watchOS 27 added, becomes
`HRV_RMSSD`, the same type WHOOP, Oura and Polar write. RMSSD readings
up to 300 ms are accepted. When both arrive, the HRV page charts each on
its own and the Coach reads them apart, because one is not a stand-in for
the other.
### ECG archive safety, privacy, and idempotency
ECG support is source-agnostic: if HealthKit includes a valid
@@ -284,6 +292,9 @@ Concrete consequences:
source tag — the lowest rank — is reserved for legacy CSV/JSON
imports that pre-date the Apple Health passthrough.
RMSSD HRV has its own ladder, **WHOOP ≻ OURA ≻ POLAR ≻ APPLE_HEALTH**,
separate from the SDNN one.
Override per-user via Settings → Sources (`/settings/sources`).
## Failure modes
+18
View File
@@ -120,6 +120,24 @@ GOOGLE_HEALTH_REDIRECT_URI="https://your-instance.example.com/api/google-health/
The Google Cloud client id/secret are per-user (Settings), not env vars.
## Backfill, the hourly sync and Google's rate limit
Only one sync runs per account at a time. While the history backfill that
follows a connect is running, the hourly sync leaves that account alone, and
**Sync now** reports that a sync is already in progress and shows its progress
instead of starting a second one.
Google limits how many requests one account may make per minute. When a sync
reaches that limit, Google answers `429`, and HealthLog waits and repeats the
request; the connection is never marked as needing a reconnect because of it.
Access tokens last an hour, and a long backfill renews its token as it goes.
A backfill that stops part-way (a restart, the job's time budget) picks up
with the data types it has not finished yet on its next attempt.
If an older version left your Google Health card asking for a reconnect after
a backfill, the first sync after updating checks the stored grant and resumes
on its own when the grant is still valid.
## The 7-day re-consent caveat
While the OAuth consent screen stays in **Testing** publishing mode, Google
+166
View File
@@ -0,0 +1,166 @@
# Health Connect import
HealthLog reads the export file of Android's Health Connect and folds
it into the same timeline as every other source. There is no Android
app and no live sync: you export from the phone, upload the file, and
a background job imports it. Rows from this path carry the source
`HEALTH_CONNECT`.
## What you need
- An Android phone with Health Connect and its export feature. The
export arrived with Android 15 and reaches Android 14 through a
system update; if you do not see it, update Health Connect first.
- A cloud storage app on the phone (Google Drive, Dropbox, Nextcloud
or any other app that offers itself as a storage location). Health
Connect refuses to save its export to the phone's own storage or the
Downloads folder, so the file always goes through a cloud first.
- A HealthLog account in a browser. The upload uses your normal
session; there is no token to create.
## 1. Export on the phone
1. Open Health Connect (in the system settings, or the Health Connect
app).
2. Go to **Manage data**, then **Export and import**, then
**Scheduled export** or **Export now**, depending on the version.
3. Pick the cloud storage app as the destination. You can choose how
often Health Connect exports again; HealthLog only ever reads the
file you upload.
The result is a ZIP file named `Health Connect.zip` with a single
entry, `health_connect_export.db`. That entry is a copy of the phone's
Health Connect database (a SQLite file). Health Connect does not
encrypt it, so treat the file like any other export of your health
data.
## 2. Upload to HealthLog
Download the ZIP from the cloud to the computer or phone you use
HealthLog on, open **Settings → Export & Import** and drop the file
onto the **Health Connect export** card.
The endpoint is `POST /api/import/health-connect-export`
(`multipart/form-data`, field `file`). It:
1. Allows three uploads per minute per account.
2. Refuses anything above **1 GiB**, once against the declared
`Content-Length` and again while the body streams to disk.
3. Streams the body to a temp file and hashes it with SHA-256 on the
way. Uploading the same bytes again returns the earlier job
(`idempotent: true`) instead of importing twice; a failed job never
blocks a retry.
4. Runs one import per account at a time, Apple Health and Health
Connect together. A second upload while one runs answers `409` and
the file is discarded.
5. Records the upload's size and hash in the audit log, and nothing
from inside the file.
`GET /api/import/health-connect-export/status` answers the account's
latest Health Connect job, or `null`. The card polls it while a job
runs.
## 3. What the import does
The worker extracts `health_connect_export.db` (at most 4 GiB, with
the same zip-bomb checks as the Apple Health importer) and opens it
read-only. The file is treated as untrusted input:
- It is opened with SQLite's `immutable` flag, so a database last
written in WAL mode opens without its `-wal` and `-shm` files, and
nothing is ever written next to it.
- Views, triggers and generated columns in the file cannot call
functions with side effects (`trusted_schema=OFF`), and a view in
place of one of the record tables refuses the whole file.
- Columns are looked up by name. Health Connect adds columns as its
schema version moves, so their order differs between phones.
Database versions below 9 are refused; versions above 27 (the newest
known when this was written) are read, with a warning on the job.
A table that lacks a column the importer needs is skipped and named
in the job's `warnings`.
### What becomes what
| Health Connect | HealthLog |
| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| Weight, lean body mass (grams) | `WEIGHT`, `LEAN_BODY_MASS` in kg |
| Body fat | `BODY_FAT` in % |
| Blood pressure | `BLOOD_PRESSURE_SYS` and `BLOOD_PRESSURE_DIA`, one pair per reading |
| Resting heart rate, heart-rate variability (RMSSD) | `RESTING_HEART_RATE`, `HRV_RMSSD` |
| Oxygen saturation, respiratory rate, body temperature, VO2 max | the matching types |
| Blood glucose (mmol/L) | `BLOOD_GLUCOSE` in mg/dL, fasting and after-meal context kept |
| Heart rate | per minute for the last 90 days; older history as hourly means with the hour's minimum and maximum |
| Steps, active calories, distance | one total per day |
| Sleep sessions and stages | one row per stage; light sleep becomes core sleep, both awake stages awake time; a session without stages counts as asleep |
| Exercise sessions | workouts, with the Health Connect exercise type kept on the workout |
| Menstruation flow and periods | cycle day logs (needs cycle tracking switched on) |
| Hydration and vitamins and minerals | daily nutrient totals (needs the Nutrients module) |
Left out on purpose: food energy and macronutrients, exercise routes,
planned workouts, Health Connect's access logs, the "out of bed" sleep
stage, and an "unknown" sleep stage inside a session that also has real
stages.
### When several apps write the same thing
The export holds every app's data side by side. Summing them would
count a step the phone and the watch both saw twice, so:
- A day total (steps, calories, distance, water, nutrients) comes from
one app per day: the first one your Health Connect app priority
list names for that category, otherwise the app with the largest
total.
- Heart rate comes from one app per hour, by the same rule.
- Of two overlapping sleep sessions or workouts from different apps,
the one from the higher-ranked app is kept.
- Two apps writing the same type at the same instant keep the
higher-ranked app's value.
### Apps HealthLog already connects to
If your account has the Withings, Fitbit, Google Health, Oura, Polar
or WHOOP integration connected, that app's records in the export are
left out: HealthLog already receives them directly, and importing them
again would put every reading in twice. As a further safety net, a
reading that matches one already stored under another source (same
type and value within two seconds) is left out too. The job result
lists how many records each app contributed and which apps were left
out.
### Importing again
Every row is keyed by the record's Health Connect ID (`hc:<uuid>`),
day totals and hourly means by their `stats:` ID. Importing the same
export again writes nothing; a newer export that overlaps an older one
adds only what is new. A value that changed on the phone is updated;
a value you deleted in HealthLog stays deleted.
Heart rate imported per minute ages the way Apple Health heart rate
does: once a minute is older than 90 days, the nightly pass folds its
hour into one hourly mean and removes the minutes. A later import of
an export that still holds those minutes leaves them out, so an hour
is never counted twice.
## Privacy
- The uploaded ZIP and the extracted database are deleted as soon as
the job ends, whether it succeeded or failed. A periodic sweep
removes anything a crash left behind.
- The job result holds counts only: per measurement type and per app.
No value, no timestamp and no text from the file (notes, titles)
is stored on the job.
## Operator notes
- The worker uses Node's built-in `node:sqlite` module. It needs no
flag on Node 22 and prints one `ExperimentalWarning` per process.
- Memory stays bounded: the database is read in batches of 500 rows
and aggregated inside SQLite, so what the import keeps alive does not
grow with the length of the history. Measured under a 792 MB heap,
one and three years of minute-by-minute heart rate peaked at about
160 MB of heap in total.
- Split web and worker containers need a shared staging directory,
exactly as for the Apple Health import.
- The status of a running job shows up under **Settings → Export &
Import**; a job interrupted by a restart is marked failed by the
same reconcile pass that watches Apple Health imports.
+3 -2
View File
@@ -171,8 +171,9 @@ tells you which rows to retry.
## Retries and duplicates
Send an `Idempotency-Key` header with a value your automation can reproduce for
the same reading. A retry with the same key returns the original response
instead of writing a second row:
the same reading. A retry with the same key and the same body returns the
original response instead of writing a second row. A different body under the
same key is a different reading and is written:
```yaml
headers:
+30 -10
View File
@@ -210,8 +210,8 @@ let a new model land without one.
This is the part that bites, and it bit the nightly job a release after it bit
the weekly one. The job runs inside the app process, so V8's heap limit is the
app's heap limit, and a container capped at 1 GB gives Node a 524 MB old-space
limit by default. A long-lived Next.js server is already holding a large share
app's heap limit. Node sizes it from the container's memory cap: 1 GB gives a
524 MB old-space limit, the compose default of 1536m (since 1.40.1) gives 792 MB. A long-lived Next.js server is already holding a large share
of that before the job starts.
The uploader used to build the whole backup JSON as one string, gzipped
@@ -252,6 +252,22 @@ A run where SOME account got a copy still succeeds, with the rest counted in
`offhost_backup_failed`. Failing the whole queue over one account's object
would re-upload everybody's on every retry.
### A run the process died under
Both backup jobs carry a pg-boss heartbeat of five minutes. While a run is
alive the worker refreshes it; a run whose process died (a restart, or a
container killed for memory) stops refreshing, and pg-boss fails the job with
`job heartbeat timeout` within minutes instead of leaving it `active` until its
two- or four-hour expiry. The retries the queue already allows then pick it up.
Each pass also notes when it started and when it got past every account
(`backup_pass_attempts`). An account whose last attempt started and never
finished is the one the process died under, and the next run takes it last, so
one record too large for the container cannot keep every account after it from
a copy. **Admin console → Backups** shows when the run in progress started and
names such an account under both the weekly and the off-host card. Seeing the
same account there twice means it needs more memory than the container has.
### Which account has no copy
The counts above say how many accounts were uploaded, never which. An account
@@ -305,7 +321,7 @@ writer produces the JSON a page at a time, gzips it as it goes and seals each
megabyte of gzip output on its own with AES-256-GCM. Reading it back (restore,
preview, download) takes the pieces one at a time. Nothing on either path holds
the whole copy, so the size of a copy has nothing to do with the app's memory.
Measured on an account of 2.6 million readings in the default 1 GB container,
Measured on an account of 2.6 million readings in a 1 GB container,
with the web server and the worker in one process: the backup took 87 seconds
and stored 97 MB in 96 pieces, the preview 20 seconds, the download of the
1.4 GB file 25 seconds, and the restore 264 seconds, which gave back every
@@ -441,10 +457,12 @@ the restart.
Since v1.16.4 a pg-boss job (`data-restore-drill`, cron `11 4 1 * *` —
04:11 on the 1st of each month) exercises the read path end-to-end:
fetch the most recent backup object from the bucket, decrypt it under
the current `BACKUP_ENCRYPTION_KEY`, JSON-parse it, and sanity-check
the payload shape. It performs **no database restore** — it validates
the artefact, not the import path.
fetch every backup object of the most recent date from the bucket (one per
account), decrypt each under the current `BACKUP_ENCRYPTION_KEY`, JSON-parse
it, and sanity-check the payload shape. It performs **no database restore** —
it validates the artefacts, not the import path. Each account is checked and
reported on its own: one object that fails is named, and the others are still
read.
Outcomes:
@@ -454,14 +472,16 @@ Outcomes:
uploader has stalled (or the lifecycle rule is too aggressive). The
drill pages via the worker error reporter (stderr + GlitchTip).
- **Failure** — empty bucket, fetch error, decryption failure (wrong or
rotated key), malformed JSON: pages the same way. A decryption
rotated key), malformed JSON: pages the same way, once per failing object,
with the object key and how many accounts were checked. A decryption
failure right after a `BACKUP_ENCRYPTION_KEY` change means the new
key cannot read the existing objects: put the old key into
`BACKUP_ENCRYPTION_PREVIOUS_KEYS` (see "Rotating the off-host key").
- **Content under a missing application key** — the object opens, but its
content was written under an `ENCRYPTION_KEYS` entry this server no longer
has, or under an id whose key material changed. The drill decrypts one value
per key id it finds, and pages naming the key. A restore of that object
has, or under an id whose key material changed. The drill decrypts the
shortest value of each section under every key id it finds, and pages naming
the key when none of them opens. A restore of that object
would write back rows nobody can open; put the key back before restoring.
- **Not configured** — deployments without the `BACKUP_S3_*` vars skip
silently (wide-event warning only).
@@ -0,0 +1,60 @@
# Blood pressure readings missing their diastolic half
## What happened
The iPhone app (every App Store build up to 1.0.3 and TestFlight 1.1.0)
saves a manually entered blood pressure as two requests to
`POST /api/measurements`: one `BLOOD_PRESSURE_SYS`, one
`BLOOD_PRESSURE_DIA`, both carrying the same `Idempotency-Key`. Until the
replay cache learned to compare request bodies, the server treated the second
request as a retry of the first. It answered with the first request's cached
`201` (header `X-Idempotent-Replay: true`) and never wrote the diastolic
value. The app showed success, so nobody noticed.
The fix makes the cache replay only a request with the same body. A different
body under the same key now runs normally. Nothing recovers the values that
were never sent to the database: they exist only on the person's phone, if at
all.
## Finding the affected readings
Read-only. Run it against the HealthLog database (with the bundled compose
file: `docker compose exec db psql -U healthlog healthlog`). It lists every live systolic reading that has no
live diastolic reading for the same account at the same instant.
<!-- bp-missing-diastolic:start -->
```sql
SELECT u.username,
m.id,
m.measured_at,
m.value AS systolic,
m.source
FROM measurements m
JOIN users u ON u.id = m.user_id
WHERE m.type = 'BLOOD_PRESSURE_SYS'
AND m.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1
FROM measurements d
WHERE d.user_id = m.user_id
AND d.measured_at = m.measured_at
AND d.type = 'BLOOD_PRESSURE_DIA'
AND d.deleted_at IS NULL
)
ORDER BY u.username, m.measured_at;
```
<!-- bp-missing-diastolic:end -->
Readings from the phone app carry `source = 'MANUAL'`. Rows from a synced
source (Withings, Apple Health) that show up here are a different gap, not
this one: those sources write both halves themselves.
## What to tell users
There is nothing the server can repair: the diastolic value never reached
it. A person whose reading appears in the list can re-enter it: delete the
incomplete reading, then add the blood pressure again with both values and
the original date and time. From this release on, both halves are saved on
the first try, from any app version.
+6
View File
@@ -78,6 +78,12 @@ roll back:
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
commit, and re-deploy. Never `migrate resolve --rolled-back` against
production unless you've taken a fresh `pg_dump` first.
4. Rolling back from v1.40.2 or later to v1.40.1 or earlier: an account
that saved the Coach lookback "Last year" reads its whole Coach
preference blob as defaults on the older version, metric exclusions
included, until the setting is saved again. Ask affected accounts to
pick another lookback before the rollback, or re-save their exclusions
after it.
## Catalog templates
+165
View File
@@ -0,0 +1,165 @@
# Measurement table maintenance
Since v1.42 the nightly folds of Apple Health data delete the raw samples they
fold into hourly or daily rows, instead of keeping them as deleted rows for 75
days. On an instance that has synced Apple Health for a while, those leftovers
("compaction tombstones") were most of the `measurements` table and of its
indexes. Three pieces deal with them, in this order:
1. **The fold repair** runs by itself, once per account, before anything is
deleted. Up to v1.42 the folds could fold a day in two runs, and the
second run stored the mean of the later part of the day only. The repair
recomputes those hourly and daily means from all of the day's samples,
including the compaction tombstones, and corrects a stored mean only when
it differs. It does so only where every sample of the day is provably
still in the table: the last 75 days for daily means, and for hourly
means (folded 90 days late) the 75 days before that. Older days stay as
they are, because the deleted rows of their samples have passed the
75-day retention and what is left can be a fragment of the day. When it
has finished an account it records that in `measurement_fold_repairs`.
2. **The backlog purge** runs by itself, after the repair. Every worker boot queues one
`compaction-tombstone-purge` job. It deletes the leftovers 5,000 rows at a
time, one account at a time, pausing briefly between batches, and stops
after 40 batches; a run that stopped with work left queues the next one a
minute later. Rows a person deleted are never touched by it: they keep the
75-day retention of the nightly tombstone cleanup. The purge leaves an
account alone until the repair has finished it, and a boot queues no purge
run while the repair has finished no account.
3. **The table maintenance** is yours to start, after the purge. It runs
`VACUUM (ANALYZE)` on `measurements` and then rebuilds each of its indexes
with `REINDEX INDEX CONCURRENTLY`, largest first. The purge leaves the
indexes full of empty pages, and only a rebuild gives that space back.
## Repair before purge
The order matters because the purge deletes what the repair reads. Do not
delete compaction tombstones by hand, and do not insert rows into
`measurement_fold_repairs` to hurry the purge along: a mean the repair has not
seen keeps its wrong value for good once its tombstones are gone.
The repair reports every run on the wide event `job.measurement_fold_repair`
(`fold_repair_means_checked`, `fold_repair_means_corrected`,
`fold_repair_resting_corrected`, `fold_repair_skipped_beyond_horizon` for the
older windows it left as they are, `fold_repair_samples_absorbed` for live
samples it took into a checked mean, and `fold_repair_by_type` with the same
counts per type; counts only, never a value). A second run over an account
corrects nothing. It is finished when every account has a row:
```sql
SELECT count(*) AS accounts_left
FROM users u
LEFT JOIN measurement_fold_repairs r ON r.user_id = u.id
WHERE r.user_id IS NULL;
```
Each run works on one account, a day at a time, and stops on its time budget
with a follow-up that resumes at the next day; an account under restore is
tried again five minutes later. While accounts are left, purge runs report
them as `compaction_purge_awaiting_repair_accounts`.
A restore of a backup exported before the account's repair finished writes
the compaction tombstones back, removes the account's row and queues the
repair again; the purge follows once the repair is through.
## Is the purge finished?
The purge reports every run on the wide event `job.compaction_tombstone_purge`
(`compaction_purge_deleted`, `compaction_purge_drained`,
`compaction_purge_deferred_accounts`). It is finished when a run reports
`drained: true` and deletes nothing. From `psql`:
```sql
SELECT state, count(*)
FROM pgboss.job
WHERE name = 'compaction-tombstone-purge'
GROUP BY state;
```
No `created`, `retry` or `active` rows means nothing is queued or running. The
maintenance refuses to start while one is.
## Before you start
- **Pick a quiet window.** Reads and writes keep working during the rebuild,
but each rebuild reads the whole table twice and writes a full new copy of
the index.
- **Free disk.** A concurrent rebuild needs room for one more copy of the
index it is rebuilding, plus the WAL it writes. Check the largest index:
```sql
SELECT c.relname, pg_size_pretty(pg_relation_size(c.oid))
FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
WHERE i.indrelid = 'measurements'::regclass
ORDER BY pg_relation_size(c.oid) DESC
LIMIT 3;
```
Have at least twice the largest size free on the database volume.
- **Take a backup** (Settings, Data, Backups), as before any operation on the
whole table.
## Starting it
In the admin area, or with a signed-in admin session:
```sh
curl -X POST https://<your-instance>/api/admin/maintenance/measurements \
-H 'content-type: application/json' \
--cookie 'healthlog_session=<session>' \
-d '{"vacuum": true, "reindex": true}'
```
The answer is `202` with `{ "enqueued": true }`, or `enqueued: false` when a
run is already queued or running. A Bearer token is refused: the route is
cookie-only, like every admin route. Without a background worker the route
answers `503`.
Either step can be left out (`"reindex": false` runs only the vacuum).
## What it does, and what stops it
The run uses a connection of its own: no statement timeout (a rebuild of a
large index takes longer than the app's 60 seconds), `maintenance_work_mem`
of 128 MB for this session only, and a lock timeout of five minutes, so a
rebuild that cannot get its lock gives up instead of holding writers behind
it.
- It refuses to start while the purge is queued or running.
- Before the first rebuild it drops any invalid `<index>_ccnew` copy an
interrupted earlier run left behind.
- If a rebuild fails, most likely for lack of disk, it drops that rebuild's
half-built copy at once and stops. The remaining indexes keep working as
they are. Free some space and start it again; finished indexes are simply
rebuilt once more.
- One run at a time. It is never retried automatically.
## Reading the result
The run's wide event `job.measurement_maintenance` carries
`maintenance_outcome` (`completed`, `refused_purge_running`, `failed`), the
table and index sizes before and after, and `maintenance_steps`, one entry
per step with its target, size before and after, and duration. The same
sizes from `psql`:
```sql
SELECT pg_size_pretty(pg_total_relation_size('measurements')) AS total,
pg_size_pretty(pg_indexes_size('measurements')) AS indexes;
```
## "could not resize shared memory segment"
Up to v1.42.0 the run could stop at once with `could not resize shared memory
segment ... No space left on device`. Postgres ran the vacuum in parallel and
kept its working memory in `/dev/shm`, which Docker limits to 64 MB per
container unless `shm_size` is set. From v1.42.1 the maintenance connection
runs single-process and needs no shared segment, and the bundled
`docker-compose.yml` gives the database `shm_size: 256mb`. On v1.42.0, add
`shm_size: 256mb` to the `db` service and restart the database container.
## Not part of this
`VACUUM FULL` would shrink the table file as well, but it locks the table for
reads and writes while it runs. After the purge the remaining table is small
and the space inside it is reused by new rows, so it is not needed. If you
want it anyway, run it yourself in a maintenance window with the app stopped.
+29
View File
@@ -0,0 +1,29 @@
# Query statistics (pg_stat_statements)
From v1.42 the bundled database loads the `pg_stat_statements` library, so the time spent in each query shape can be read from the database itself. Loading the library only collects statistics in shared memory; nothing is sent anywhere.
## Enable the view
The library is loaded through the `command:` of the `db` service in `docker-compose.yml`. The first `docker compose up` after updating restarts the database once. Then create the extension once:
```sh
docker compose exec db psql -U healthlog -d healthlog -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;"
```
## Read it
The slowest query shapes by total time:
```sh
docker compose exec db psql -U healthlog -d healthlog -c "SELECT calls, round(total_exec_time) AS total_ms, round(mean_exec_time, 1) AS mean_ms, left(query, 120) AS query FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 15;"
```
The statements are stored with their parameters replaced by placeholders, so values from your records do not appear in the view.
## Reset
```sh
docker compose exec db psql -U healthlog -d healthlog -c "SELECT pg_stat_statements_reset();"
```
If you run your own PostgreSQL instead of the bundled one, add `pg_stat_statements` to `shared_preload_libraries` in its configuration and restart it before creating the extension.
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

+110
View File
@@ -0,0 +1,110 @@
# Weather, air quality and pollen
The environment module stores, for each day, the weather and the outdoor air
at a coarse location: temperature, sunshine, daylight, rain, pressure,
humidity, and since v1.42 fine particles, ozone, nitrogen dioxide, the
European and US air-quality indices, UV, Saharan dust and six kinds of
pollen. Those days sit beside the person's own data: the correlations look
for patterns between them and sleep, heart-rate variability, blood pressure,
mood and symptoms, the Coach and connected MCP clients can read them, and the
dashboard shows a quiet note on a day with high pollen, a warm night or very
poor air.
The module is on by default. Nothing leaves the host until a person sets a
home location in Settings → Location & weather.
## What leaves the host
Three hosted Open-Meteo APIs, all keyless:
| Host | What for | Variable to self-host it |
| -------------------------------- | ------------------------------------ | --------------------------- |
| `archive-api.open-meteo.com` | daily weather (ERA5 reanalysis) | `OPENMETEO_BASE_URL` |
| `air-quality-api.open-meteo.com` | hourly air quality and pollen (CAMS) | `OPENMETEO_AIR_QUALITY_URL` |
| `geocoding-api.open-meteo.com` | the city search in settings | `OPENMETEO_GEOCODING_URL` |
A request carries the coordinates rounded to one decimal (about 11 km, the
size of a town), a date range and a timezone. No account id, no name, no
health value. Open-Meteo is open source; an operator who wants no egress at
all can run it and point the three variables at their own instance (a host
on the LAN is allowed).
## The data, and what it is not
- Weather comes from the ERA5 reanalysis on a grid of 9 to 25 kilometres. It
settles over a few days, so the newest stored day is usually yesterday or
the day before. Nothing here is a forecast.
- Air quality comes from the CAMS models: hourly on about 11 kilometres in
Europe, about 45 kilometres elsewhere. The feed starts in 2013; pollen, UV,
dust and aerosol depth only from about mid 2022, and pollen only in Europe.
A value the feed did not cover is stored as empty, never as zero.
- Hourly values become a day by fixed rules: means and maxima only for a day
with at least 18 of 24 hours, ozone as the highest 8-hour running mean,
UV only when the midday hours are all there.
- These are modelled outdoor conditions for an area, not anyone's personal
exposure. Every surface that shows them says so, and none of them says the
weather caused anything: a pattern is described as something that occurred
together.
## Switches
- **Per account:** Settings → Location & weather → Air quality and pollen.
On by default. Off stops the air-quality requests for that account and
leaves the air-quality values out of every surface; the days already
stored keep theirs.
- **For the whole instance:** `ENVIRONMENT_AIR_QUALITY_DISABLED=1` turns the
air-quality part off for every account (`1`, `true`, `yes` or `on`). The
weather keeps working. Settings then says the operator turned it off.
- The whole module is a normal module switch (Settings → Modules), and an
operator can make it unavailable like any other module.
All four variables are on the compose `environment:` whitelist.
## Request budget
The hosted APIs are free for non-commercial use up to 600 calls a minute,
5,000 an hour and 10,000 a day per instance, where a request for many
variables or many days counts as several calls. HealthLog keeps its own
counter in Postgres, shared by the web process and the worker, and stays
below 500 a minute, 4,000 an hour and 8,000 a day. A request the counter
refuses is not sent; the nightly run ends early and continues the next night.
For scale: a full two-year backfill of one account is about 150 calls, the
nightly refresh about 3 per account.
## Nightly fetch and catching up
The `environment-fetch` job runs at 02:10. For each account with a home it
refreshes the last seven days (weather and air quality together), then fills
up to 90 stored days that do not have air quality yet, newest first. An
instance that upgrades to v1.42 with two years of weather per account
catches up on air quality in about nine nights without any action. The
backfill button in settings fetches both for the range it is given.
## Locations are encrypted
Since v1.42 the home, each dated location period and each stored day keep
their coarse location sealed with the instance's encryption key (one value
per row, `{ lat, lon, label }`, under the label
`healthlog/environment-location/v1`). The boot-time encryption backfill seals
rows written by older releases and clears their readable columns; the
readable columns are dropped in a later release once the backfill reports
zero rows everywhere. Key rotation (`scripts/rotate-encryption-key.ts`, or the
in-app rotation) covers all three.
What the person sees is unchanged: the settings page and
`GET /api/environment` answer with the readable location, opened on the
server. The Coach and MCP read (`get_environment`) carry no coordinate and no
place name at all, only how many days were spent away from home.
Backups follow the usual split: a portable export carries the location
readable, a disaster-recovery backup carries it sealed, and a restore of
either seals it under the receiving host's key.
## Attribution
Open-Meteo data is CC BY 4.0, and CAMS data asks for the notice "Contains
modified Copernicus Atmosphere Monitoring Service information" (modified,
because the hourly values are folded into days). The settings page lists
both lines under the values, and the Coach and MCP read return them with
every result.
+26 -1
View File
@@ -13,7 +13,12 @@ already installed, longer if a TLS-fronted public hostname is in scope
unchanged.
- **2 GB RAM, 10 GB free disk.** The Postgres data volume grows with
measurement history; the Apple Health import worker can briefly
hold a 1.5 GB upload in `/tmp` while it parses.
hold a 1.5 GB upload in `/tmp` while it parses. The app container is
capped at `APP_MEMORY_LIMIT`, `1536m` by default (Node then sizes its
heap at about 792 MB). On a 2 GB host that leaves too little for
Postgres and the system, so set `APP_MEMORY_LIMIT=1g` in `.env`
(a heap of about 524 MB); a long-used account then runs closer to
that heap, and the weekly backup with it.
- **Outbound HTTPS** to `ghcr.io` (image pulls) plus whichever
integration endpoints you plan to enable (Withings, OpenAI, etc.).
The opt-in environmental-context module additionally reaches
@@ -72,6 +77,26 @@ process (`pnpm dev` against a host Postgres) or a **custom compose** that
does not derive it — in that case uncomment and set `DATABASE_URL`
explicitly in `.env` (see the commented example there).
### Using an existing or shared Postgres
HealthLog can live in a database on a Postgres instance you already run.
It stores every time as UTC and pins each connection it opens to UTC
(`TimeZone=UTC`, sent with the libpq `options` startup parameter), so the
server's or the database's own `timezone` setting does not matter: an
instance set to `Europe/Berlin` works the same as one set to UTC. The app
checks the setting at startup and logs a warning naming the zone if a
connection still reports something else.
Behind PgBouncer with `DATABASE_SESSION_OPTIONS_DISABLED=1` (see
[Scaling](./scaling.md#postgres-connection-pool-sizing)) the app pins the
zone with `SET TIME ZONE 'UTC'` on each new connection instead; PgBouncer
keeps that setting per client in transaction pooling too. If the startup
warning still appears, set the database default as well:
```sql
ALTER DATABASE healthlog SET timezone TO 'UTC';
```
## 3. Bring the stack up
```bash
+114
View File
@@ -179,6 +179,12 @@ HealthLog publishes to that topic.
**Server URL** (defaults to `https://ntfy.sh`; point it at your own
server if you self-host ntfy) and **Topic**, then save.
HealthLog publishes each message as JSON to the server's root URL (the topic
travels in the body, see ntfy's
[publish as JSON](https://docs.ntfy.sh/publish/#publish-as-json)), so titles
and button labels in any language arrive as written. A reverse proxy in front
of your own ntfy has to pass `POST /` through, not only `POST /<topic>`.
If your ntfy server requires auth, an access token can be attached; the
server URL is validated at save time and dialled through the egress guard
(`src/lib/notifications/senders/ntfy.ts`). A self-hosted ntfy on your own
@@ -234,6 +240,8 @@ choice existed keeps sending:
message are masked as they are on every other channel.
- `priority` is a word: `urgent` for an event flagged urgent, `high` for
medication reminders, `default` for everything else.
- `url`, when `APP_URL` is set, is the page the notification leads to (see
[Tap targets](#what-tapping-a-notification-opens)). Absent otherwise.
**Gotify** sends the body Gotify's `POST /message` endpoint expects:
@@ -450,6 +458,112 @@ Two gotchas worth knowing before you blame a stale token:
> in the web UI. If you only run the PWA, ignore it entirely — your
> medication reminders come from the server cron over the channels above.
## Tap targets, content and quick answers
### What tapping a notification opens
Every notification leads somewhere in the app. The server decides where,
once per event type, and every channel carries the same target:
| Notification | Opens |
| -------------------------------------------- | ---------------------------------------------------------------------------------- |
| Mood reminder | `/mood`, with the mood entry sheet already open |
| Morning briefing | the dashboard |
| Medication reminder, low supply, shared dose | `/medications` (low supply opens the medication) |
| Check-up and measurement reminders | `/checkups` |
| Proactive Coach nudge | the Coach conversation it started |
| Cycle reminders | `/cycle` (nothing with discreet notifications on) |
| New sign-in | Settings, Security |
| Integration sync failing | Settings, Integrations (an admin alert about another account opens the admin page) |
| Illness red flag | the illness episode |
| Other system alerts | the dashboard, or the admin status page for operator alerts |
How each channel carries it:
- **ntfy**: the message's `click` field, so tapping the notification opens
the page.
- **Telegram**: an **Open in HealthLog** button under the message, below any
action buttons. Telegram only accepts `https` button links on a public
host, so a plain-HTTP instance gets a text link instead, and an instance
on `localhost` gets no link.
- **Web Push**: the service worker opens the page, or focuses an open
HealthLog tab and navigates it there.
- **APNs**: the path rides the payload as `url`.
- **Webhook**: the generic body gains a `url` field; the Gotify body gains
`extras["client::notification"].click.url`, which the Gotify app opens.
- **Email**: a link line under the message.
ntfy, Telegram, the webhook and email need an absolute link, built from
`APP_URL` (or `NEXT_PUBLIC_APP_URL`). Without one those channels deliver as
before, just without a link. A notification about a profile you look after
as a Guardian carries no deep link, because your session opens on your own
record.
### Health figures in notifications
Settings, Notifications, **Health figures in notifications** decides what a
notification may say on a lock screen:
- **Off** (the default): a notification says what it is about and shows no
health figure. The morning briefing reads "Your morning briefing".
- **On**: notifications add their figures. The morning briefing becomes
"Morning score 78" with the day's one-line summary underneath.
Tap targets and quick answers work the same either way. The setting is
stored as `content.detail` (`generic` or `detailed`) in the notification
preferences (`PATCH /api/auth/me/notification-prefs`) and travels with the
account backup.
### Quick answers on the mood reminder
On **ntfy** the mood reminder carries three buttons, **Good**, **Okay** and
**Bad**. Tapping one logs a mood entry straight from the notification,
without opening the app, and clears the notification. They map onto the
five-point mood scale as Good = 4, Okay = 3, Bad = 2; the outer points
stay in the app. The entry is marked with the source **Notification**.
Each button calls `POST <APP_URL>/api/notifications/action/<token>`. The
token is the only credential, so it is built to be harmless on its own:
- it is signed with `API_TOKEN_HMAC_KEY` and names one account and one
answer, so it cannot be moved to another account or another answer;
- it expires six hours after the reminder was sent;
- the three answers of one reminder are one use between them: the first
tap logs, every later tap on that reminder is refused, and that stays
true when the logged entry is deleted and purged from the trash before
the six hours are up;
- the route is rate-limited before it reads the token, refuses when the
mood module has been turned off, and the token is scrubbed from logs.
The buttons need `APP_URL`, because the ntfy app calls the URL from your
phone. Your phone has to reach the instance at that address.
What the token cannot protect against is the topic itself. The button URLs,
token included, travel through your ntfy server inside the message, and the
message is delivered to everyone subscribed to the topic. For the six hours
a token is valid, anyone who can read the topic can log that one answer on
your behalf. On the public `ntfy.sh` the topic name is the only access
control, so:
- use a long, random topic name and do not share it;
- better, run your own ntfy server with access control and give HealthLog
an access token that may only publish to your topic;
- to invalidate every quick-answer token at once, rotate
`API_TOKEN_HMAC_KEY`. The same key also hashes API tokens and session
cookies, so rotating it invalidates every issued API token and signs out
every browser session and the iOS app as well (see
[getting started](./getting-started.md)).
**Telegram** keeps its own keyboard on the mood reminder: the full 1 to 5
scale, a note and a "later" button.
**Web Push** offers no buttons. Most browsers show at most two actions,
which cannot carry a three-way answer, and Safari on iPhone shows none.
Tapping the notification opens the mood entry sheet instead.
**APNs**: the iOS app draws its own buttons from the notification category
it registers for the mood reminder.
## Which channel should I pick?
| Your setup | Recommended channel |
+19 -1
View File
@@ -163,7 +163,11 @@ the knob as `DB_CONNECTION_LIMIT`, baked into `DATABASE_URL` as
`connection_limit`; `DATABASE_POOL_MAX` remains a working explicit
override. Resolution order: `DB_CONNECTION_LIMIT`, then
`DATABASE_POOL_MAX`, then the `connection_limit` URL parameter, then
the default of 20.
the default of 20. In the bundled single-container setup
(`HEALTHLOG_PROCESS_TYPE=all`) the web requests and the background
worker run in one process and share that one Prisma pool, so a budget
sized for the web traffic alone leaves the worker's jobs queueing for
the same connections.
Plan total Postgres slots as **container_count × budget**.
A stock Postgres 16 container ships with `max_connections = 100`, so
@@ -184,6 +188,20 @@ already`. Either raise `max_connections` on the Postgres side, lower
`DATABASE_POOL_MAX`, or front the database with PgBouncer in
transaction-pooling mode.
The app sets its statement timeout, `work_mem` and `TimeZone=UTC` on every
connection through the libpq `options` startup parameter. PgBouncer refuses that
parameter (`unsupported startup parameter: options`) unless it is listed
in `ignore_startup_parameters`. Either list it there, or set
`DATABASE_SESSION_OPTIONS_DISABLED=1` so the app sends no session
settings; set the timeout and `work_mem` on the database role instead
(`ALTER ROLE healthlog SET work_mem = '16MB'`) if you still want them.
The time zone is the exception: with the options off the app pins it with
`SET TIME ZONE 'UTC'` on each new connection, which PgBouncer tracks per
client, and warns at startup if a connection still reports another zone.
The measurement maintenance job (Admin, System status) then applies its own
settings with `SET` after connecting. Its concurrent index rebuild needs a
session, so run it through a session-mode pool or a direct connection.
### Overriding the per-container pool ceiling
With the bundled compose, set `DB_CONNECTION_LIMIT` in `.env`; it
+55
View File
@@ -0,0 +1,55 @@
# Searching your record
The command palette finds pages and settings, and it searches what you have
recorded.
## Opening it
Press **⌘K** on a Mac or **Ctrl K** elsewhere, or tap the magnifier at the
top right, and start typing.
## What it finds
Below the pages and settings, a section **In your records** lists the
entries that match what you typed:
- visits (procedures included)
- conditions
- documents, by title, file name and the text read from them
- vaccinations
- lab results
- medications
- symptoms
- workouts
- notes on mood entries, readings and illness days
Each hit opens where the entry lives: a visit on the check-ups page, a lab
result on its analyte, a note on a mood entry or a reading on its day, a note
on an illness day in its illness.
## How matching works
Case and accents do not matter, so "schulter", "Schulter" and "Schülter"
find the same entries. Umlauts can be typed as `ae`, `oe` and `ue` (and `ß`
as `ss`), which helps on a keyboard without them.
## Very large records
The search reads each kind of entry newest first and stops after a fixed
number of entries and a short time limit, so it stays quick on a record that
holds years of data. When it stopped before reaching the oldest entries, the
list says so. A more specific search term usually finds an older entry.
## Shared records
When someone has shared their whole record with you, the search works inside
that record too. An account shared with you section by section cannot be
searched: the search lays every section side by side, which a partial share
does not open.
## Privacy
What you type is sent to your instance in the body of the request, not in the
address, so it does not end up in a reverse proxy's access log or in the
browser history. The server does not log the search text either, only its
length and how many hits it found.
+15 -8
View File
@@ -6,6 +6,7 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
import { openMenu } from "./open-menu";
import { POPULATED_SUMMARIES } from "./utils/mock-dashboard-snapshot";
import { revealDeferredSections } from "./utils/deferred-sections";
type AxeViolation = Awaited<
ReturnType<AxeBuilder["analyze"]>
@@ -41,6 +42,8 @@ type RouteCase = {
name: string;
path: string;
painted: (page: Page) => Locator;
/** Runs after navigation, before the painted gate (e.g. mount deferred sections). */
prepare?: (page: Page) => Promise<void>;
};
const THEMES: readonly Theme[] = ["light", "dark"];
@@ -325,6 +328,7 @@ async function visitAndScan(
): Promise<AxeViolation[]> {
return test.step(`${theme} ${routeCase.name}`, async () => {
await page.goto(routeCase.path, { waitUntil: "domcontentloaded" });
await routeCase.prepare?.(page);
return scanPaintedState(
page,
theme,
@@ -773,6 +777,8 @@ const INSIGHTS_ROUTES: readonly RouteCase[] = [
{
name: "/insights overview",
path: "/insights",
// Scan the whole overview, the sections that mount near the viewport too.
prepare: revealDeferredSections,
// The vitals grid, settled. The scores strip is not a stable gate: it
// unmounts for an account with no score yet, and since v1.39 it no longer
// keeps an empty cell for a cycle ring that has no dial to draw.
@@ -1041,23 +1047,24 @@ test.describe("axe-core authenticated route and state matrix", () => {
);
});
await test.step(`${theme} /coach open evidence disclosure and source chips`, async () => {
await test.step(`${theme} /coach open the areas an older answer looked at`, async () => {
await page.goto(`/coach?c=${A11Y_CONVERSATION_ID}`, {
waitUntil: "domcontentloaded",
});
const evidence = page.locator('[data-slot="coach-evidence"]').first();
await expect(evidence).toBeVisible({ timeout: 15_000 });
await evidence.locator("summary").click();
await expect(evidence).toHaveAttribute("open", "");
const steps = page.locator('[data-slot="coach-turn-steps"]').first();
await expect(steps).toBeVisible({ timeout: 15_000 });
const toggle = steps.locator('[data-slot="coach-turn-steps-toggle"]');
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(
evidence.locator('[data-slot="coach-source-chips"]'),
steps.locator('[data-slot="coach-turn-areas"]'),
).toBeVisible();
blocking.push(
...(await scanPaintedState(
page,
theme,
"/coach open evidence disclosure and source chips",
evidence,
"/coach open the areas an older answer looked at",
steps,
)),
);
});
+12 -5
View File
@@ -25,6 +25,7 @@ import {
seedStoredBriefing,
} from "./setup/ai-optional-fixture";
import { expect, test } from "./setup/test";
import { revealDeferredSectionsSettled } from "./utils/deferred-sections";
test.use({ storageState: AI_OPTIONAL_STORAGE_STATE_PATH });
test.describe.configure({ mode: "serial" });
@@ -36,12 +37,18 @@ test.afterAll(async () => {
await resetAiOptionalAccount();
});
/**
* The overview with every section mounted. Sections past the first three
* render as an empty sentinel until they near the viewport, so a count of
* zero taken before they mount would pass whatever they paint.
*/
async function openOverview(page: Page): Promise<void> {
await page.goto("/insights");
await expect(page.locator('[data-slot="insights-hero-strip"]')).toBeVisible({
timeout: 20_000,
});
await page.waitForLoadState("networkidle");
await revealDeferredSectionsSettled(page);
}
test("J5: withdrawing AI consent deletes the notes written under it", async ({
@@ -80,12 +87,12 @@ test("J4: the Coach memory stays readable and deletable with the Coach hidden",
const title = `Stored conversation ${Date.now()}`;
await seedHiddenCoachWithConversation(title);
// Hide Coach takes the Coach page and its launcher away...
await page.goto("/insights");
await expect(page.locator('[data-slot="insights-hero-strip"]')).toBeVisible({
timeout: 20_000,
});
// Hide Coach takes the Coach page, its launcher and every per-card
// hand-off away...
await openOverview(page);
await expect(page.locator('[data-slot="coach-fab"]')).toHaveCount(0);
await expect(page.locator('[data-slot="ask-coach-action"]')).toHaveCount(0);
await expect(page.locator('[data-slot="ask-coach-icon"]')).toHaveCount(0);
// ...and Settings, AI lists what the Coach stored.
await page.goto("/settings/ai");
+13
View File
@@ -21,9 +21,19 @@ import type { Page, Request, Response } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockUnavailable, serveAiBlock } from "./setup/ai-capabilities";
import { expect, test } from "./setup/test";
import { revealDeferredSectionsSettled } from "./utils/deferred-sections";
test.use({ storageState: STORAGE_STATE_PATH });
/**
* Mount every overview section, then let their reads land. Sections past the
* first three render as an empty sentinel until they near the viewport, so a
* count of zero taken before this would pass whatever those sections paint.
*/
async function mountWholeOverview(page: Page): Promise<void> {
await revealDeferredSectionsSettled(page);
}
/** Routes that call a model or serve model-written text. */
const AI_ROUTE = new RegExp(
[
@@ -89,6 +99,7 @@ test.describe("J1: AI switched off by the operator", () => {
);
// Let every section's reads land before judging what painted.
await page.waitForLoadState("networkidle");
await mountWholeOverview(page);
// A read refused for want of AI paints one of these on the overview:
// the section boundary, the error card, or the dense error row.
@@ -137,6 +148,7 @@ test.describe("J3: no AI provider", () => {
timeout: 20_000,
});
await page.waitForLoadState("networkidle");
await mountWholeOverview(page);
await expect(page.locator('[data-slot="coach-fab"]')).toHaveCount(0);
await expect(page.locator('[data-slot="daily-briefing"]')).toHaveCount(0);
// The per-card Coach hand-offs paint only while the Coach is available.
@@ -163,6 +175,7 @@ test.describe("J3: no AI provider", () => {
{ timeout: 20_000 },
);
await page.waitForLoadState("networkidle");
await mountWholeOverview(page);
await expect(page.locator('[data-slot="ai-setup-hint"]')).toHaveCount(0);
expect(network.aiRequests).toEqual([]);
+112
View File
@@ -339,4 +339,116 @@ test.describe("Settings → AI provider dropdown UX (B2)", () => {
.locator('button[type="submit"]'),
).toBeEnabled();
});
// #1126 — the Reasoning select on the Local and OpenAI-compatible forms
// saves with the form, and at phone width it sits in the same column as
// the model field without pushing the page sideways.
test("reasoning setting saves from the Local and gateway forms at 390 px", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
const patchBodies: Array<Record<string, unknown>> = [];
let providerState: Record<string, unknown> = {
provider: "LOCAL",
model: null,
baseUrl: "http://localhost:11434/v1",
responseTimeoutSeconds: null,
hasAnthropicKey: false,
anthropicKeyPreview: null,
hasLocalKey: false,
hasOpenaiKey: false,
openaiKeyPreview: null,
compatBaseUrl: "https://gateway.example.org/v1",
compatModel: "qwen3",
hasCompatKey: false,
localReasoningEffort: null,
compatReasoningEffort: null,
};
await page.route("**/api/insights/settings", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
codexStatus: "disconnected",
codexConnectedAt: null,
hasAdminKey: false,
codexOauthConfigured: true,
privacyMode: "aggregated",
lastInsightAt: null,
},
error: null,
}),
}),
);
await page.route("**/api/user/ai-provider", async (route) => {
if (route.request().method() === "PATCH") {
const body = route.request().postDataJSON() as Record<string, unknown>;
patchBodies.push(body);
providerState = { ...providerState, ...body };
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: providerState, error: null }),
});
});
await page.route("**/api/insights/provider-chain", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
activeProvider: "local",
cachedActiveProvider: null,
configuredChain: [
{ providerType: "local", enabled: true, available: true },
],
},
error: null,
}),
}),
);
const providerHydrated = page.waitForResponse("**/api/user/ai-provider");
await page.goto("/settings/ai?provider=local", {
waitUntil: "domcontentloaded",
});
await providerHydrated;
const localForm = page.getByTestId("ai-provider-config-local");
const localReasoning = page.locator("#ai-local-reasoning");
await expect(localReasoning).toHaveValue("");
// Same column, same width as the model select above it.
const [modelBox, reasoningBox] = await Promise.all([
page.locator("#ai-local-model").boundingBox(),
localReasoning.boundingBox(),
]);
expect(reasoningBox?.x).toBe(modelBox?.x);
expect(reasoningBox?.width).toBe(modelBox?.width);
expect(
await page.evaluate(
() =>
document.documentElement.scrollWidth <=
document.documentElement.clientWidth,
),
).toBe(true);
await localReasoning.selectOption("none");
await localForm.locator('button[type="submit"]').click();
await expect.poll(() => patchBodies.length).toBe(1);
expect(patchBodies[0].localReasoningEffort).toBe("none");
await page
.getByTestId("ai-active-provider-select")
.selectOption("openai-compatible");
const compatForm = page.getByTestId("ai-provider-config-openai-compatible");
const compatReasoning = page.locator("#ai-compat-reasoning");
await expect(compatReasoning).toHaveValue("");
await compatReasoning.selectOption("high");
await compatForm.locator('button[type="submit"]').click();
await expect.poll(() => patchBodies.length).toBe(2);
expect(patchBodies[1].compatReasoningEffort).toBe("high");
expect(patchBodies[1]).not.toHaveProperty("localReasoningEffort");
});
});
+104
View File
@@ -0,0 +1,104 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Recharts measures every tick label in one shared off-screen span
* (`#recharts_measurement_span`). Under `<body>` each read cost a
* document-wide style recalc, because the stylesheet's descendant-subject
* `:has()` rules leave Chromium no way to scope the invalidation; on a
* seeded dashboard that was about 1.8 s of main-thread time under a 4x CPU
* slowdown. The chart runtime now parks the span in a contained host under
* `<html>` (`src/components/charts/text-measurement-host.ts`).
*
* Pinned here, in a real engine:
* - the span Recharts uses is the one in the host, not a second one it
* created under `<body>`, including after a client-side round trip;
* - every string the charts drew measures exactly as it does under
* `<body>`, so the move cannot change a single tick or label.
*/
test.describe("chart text measurement host", () => {
test.use({ storageState: STORAGE_STATE_PATH });
async function assertHostInPlace(page: import("@playwright/test").Page) {
const placement = await page.evaluate(() => {
const spans = document.querySelectorAll("#recharts_measurement_span");
const span = spans[0] as HTMLElement | undefined;
return {
count: spans.length,
hostSlot: span?.parentElement?.dataset.slot ?? null,
hostParent: span?.parentElement?.parentElement?.tagName ?? null,
};
});
expect(placement).toEqual({
count: 1,
hostSlot: "chart-text-measurement-host",
hostParent: "HTML",
});
}
test("the dashboard charts measure text outside <body>, with identical results", async ({
page,
}) => {
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.locator(".recharts-wrapper").first()).toBeVisible({
timeout: 20_000,
});
await assertHostInPlace(page);
const parity = await page.evaluate(() => {
const host = document.getElementById(
"recharts_measurement_span",
) as HTMLElement;
const reference = document.createElement("span");
document.body.appendChild(reference);
// Recharts' own measurement style (`getStringSize`).
const base = {
position: "absolute",
top: "-20000px",
left: "0",
padding: "0",
margin: "0",
border: "none",
whiteSpace: "pre",
};
const strings = [
...new Set(
[...document.querySelectorAll(".recharts-wrapper text")]
.map((el) => el.textContent ?? "")
.filter(Boolean),
),
];
const mismatches: string[] = [];
for (const fontSize of ["10px", "12px", "14px"]) {
for (const text of strings) {
Object.assign(host.style, base, { fontSize });
Object.assign(reference.style, base, { fontSize });
host.textContent = text;
reference.textContent = text;
const a = host.getBoundingClientRect();
const b = reference.getBoundingClientRect();
if (a.width !== b.width || a.height !== b.height) {
mismatches.push(`${text}@${fontSize}`);
}
}
}
reference.remove();
return { measured: strings.length, mismatches };
});
// A chart with no labels would make the parity check vacuous.
expect(parity.measured).toBeGreaterThan(0);
expect(parity.mismatches).toEqual([]);
// A client-side round trip re-renders the shell; the host must survive
// it, or Recharts quietly recreates its span under <body>.
await page.locator('a[href="/insights"]:visible').first().click();
await page.waitForURL((url) => url.pathname === "/insights");
await page.locator('a[href="/"]:visible').first().click();
await page.waitForURL((url) => url.pathname === "/");
await expect(page.locator(".recharts-wrapper").first()).toBeVisible({
timeout: 20_000,
});
await assertHostInPlace(page);
});
});
+524
View File
@@ -0,0 +1,524 @@
import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
import {
FIXED_NOW,
done,
end,
feed,
installLiveCoachStream,
posts,
serveConversation,
tokens,
applyTheme,
type StubConversation,
} from "./setup/coach-live-stream";
/**
* The thinking row of a Coach turn: one quiet row at the top of the answer.
*
* Contracts under test, at 1440 and 390 px, light and dark:
*
* 1. While the turn runs the row says what is happening right now and
* moves with the frames: Thinking… → Fetching blood pressure… →
* Summarising 214 readings… → Writing the answer…, with the words
* shimmering and no clock.
* 2. A table the turn has read shows nothing of its own while the turn
* runs: no second row, no preview, no sparkline.
* 3. Nothing opens by itself: the row stays closed (`aria-expanded`
* false) in every state, after the answer and after a reload; a row
* opened during the run closes when the answer is done.
* 4. After the answer the row reads "Thought process (4 steps)" and the
* table the answer points at renders as its chart.
* 5. A tap opens the steps; a reasoning round shows its summary under
* its title.
* 6. No horizontal scroll, a 44 px tap target on phones, axe clean.
*
* The chat stream is fed frame by frame from the spec
* (`installLiveCoachStream`), so every live state is observed rather than
* flashing by. Set COACH_SCREENSHOTS_DIR to also write screenshots of each
* state; those runs keep motion on so the shimmer shows mid-sweep.
*/
const CONVERSATION_ID = "coach-activity-e2e";
const QUESTION = "How was my blood pressure this month?";
const ANSWER =
"Your blood pressure held a steady band this month, around 126/82 mmHg on most days. Nothing stands out against your usual range.";
const BP_VALUES: Array<[number, number]> = [
[128, 84],
[125, 82],
[131, 85],
[122, 80],
[124, 81],
[127, 83],
[121, 79],
[126, 82],
[123, 80],
[126, 81],
];
const BP_TABLE = {
ref: "r1",
source: {
tool: "get_metric_table",
domain: "bp",
window: "last30days",
period: "current",
granularity: "day",
},
shape: "timeSeries",
titleKey: "coach.result.title.byDay",
title: "Blood pressure by day",
rowCount: BP_VALUES.length,
chartKind: "line",
displayed: false,
columns: [
{
key: "day",
kind: "period",
labelKey: "coach.result.column.day",
label: "Day",
},
{
key: "systolic",
kind: "number",
labelKey: "coach.result.column.systolic",
label: "Systolic",
unit: "mmHg",
decimals: 0,
},
{
key: "diastolic",
kind: "number",
labelKey: "coach.result.column.diastolic",
label: "Diastolic",
unit: "mmHg",
decimals: 0,
},
{
key: "readings",
kind: "count",
labelKey: "coach.result.column.readings",
label: "Readings",
},
],
rows: BP_VALUES.map(([sys, dia], i) => [
new Date(Date.UTC(2026, 8, 7 + i)).toISOString().slice(0, 10),
sys,
dia,
i === 0 ? 205 : 1,
]),
truncated: false,
chart: { kind: "line", x: "day", series: ["systolic", "diastolic"] },
};
const BP_SHOWN = { ...BP_TABLE, displayed: true };
const BP_META = {
ref: "r1",
source: BP_TABLE.source,
shape: BP_TABLE.shape,
titleKey: BP_TABLE.titleKey,
title: BP_TABLE.title,
rowCount: BP_TABLE.rowCount,
chartKind: "line",
displayed: true,
};
const STEP = {
id: "s1",
tool: "get_metric_table",
labelKey: "coach.step.readWindow",
label: "Checking: Blood pressure, last 30 days",
domain: "bp",
window: "last30days",
granularity: "day",
};
function entry(
id: string,
phase: string,
status: string,
label: string,
extra: Record<string, unknown> = {},
) {
return {
type: "activity",
activity: {
id,
phase,
status,
round: phase === "answer" ? 2 : 1,
labelKey: `insights.coach.activity.${phase}`,
label,
...extra,
},
};
}
const THINK_TITLE = "Looking at the whole month";
const THINK_TEXT =
"The month has daily readings; a daily table answers this best.";
const ACTIVITY_META = [
{
id: "a1",
phase: "thinking",
status: "done",
round: 1,
labelKey: "insights.coach.activity.thinking",
label: "Thinking…",
durationMs: 2_400,
},
{
id: "a2",
phase: "fetch",
status: "done",
round: 1,
labelKey: "insights.coach.activity.fetching",
label: "Fetching blood pressure, last 30 days…",
stepRef: "s1",
count: 214,
durationMs: 900,
},
{
id: "a3",
phase: "digest",
status: "done",
round: 1,
labelKey: "insights.coach.activity.digestDone",
label: "214 readings from 1 area",
count: 214,
durationMs: 400,
},
{
id: "a4",
phase: "answer",
status: "done",
round: 2,
labelKey: "insights.coach.activity.answer",
label: "Writing the answer…",
durationMs: 3_000,
},
];
const METRIC_SOURCE = {
windows: ["last30days"],
metrics: ["bp"],
counts: { bp: 214 },
steps: [{ ...STEP, status: "done", count: 214, resultRef: "r1" }],
activity: ACTIVITY_META,
results: [BP_META],
method: {
entries: [
{
domain: "bp",
window: "last30days",
granularity: "day",
count: 214,
aggregation: "mean",
},
],
text: "Blood pressure, last 30 days, 214 readings, daily averages.",
},
};
function conversation(): StubConversation {
return {
id: CONVERSATION_ID,
title: "Blood pressure this month",
messages: [],
results: { "coach-activity-e2e-a1": [BP_SHOWN] },
trails: {
"coach-activity-e2e-a1": {
entries: [{ id: "a1", title: THINK_TITLE, text: THINK_TEXT }],
},
},
};
}
const VIEWPORTS = [
{ name: "1440", width: 1440, height: 900 },
{ name: "390", width: 390, height: 844 },
] as const;
const SHOTS = process.env.COACH_SCREENSHOTS_DIR;
async function shot(page: Page, name: string) {
if (!SHOTS) return;
mkdirSync(SHOTS, { recursive: true });
// The pointer off the row and the open/rotate transitions done, so the
// picture shows the resting state; the shimmer keeps running.
await page.mouse.move(0, 0);
await page.waitForTimeout(400);
await page.screenshot({ path: join(SHOTS, `${name}.png`) });
}
async function noHorizontalScroll(page: Page) {
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - window.innerWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
}
test.describe("Coach live trail", () => {
test.use({ storageState: STORAGE_STATE_PATH });
for (const viewport of VIEWPORTS) {
for (const theme of ["light", "dark"] as const) {
test(`${viewport.name} px, ${theme}: one quiet line that never opens by itself`, async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"sets its own viewports; one project suffices",
);
const label = `${viewport.name}-${theme}`;
await page.setViewportSize(viewport);
await page.clock.setFixedTime(FIXED_NOW);
await serveAiBlock(page, aiBlockAvailable());
const stub = conversation();
await serveConversation(page, stub);
await installLiveCoachStream(page);
await applyTheme(page, theme);
if (SHOTS) await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/coach", { waitUntil: "domcontentloaded" });
await page.locator('[data-slot="coach-input-textarea"]').fill(QUESTION);
await page.locator('[data-slot="coach-input-send"]').click();
const turn = page.locator(
'[role="log"] [data-slot="coach-bubble-assistant"]',
);
const line = turn.locator('[data-slot="coach-turn-steps"]');
const toggle = line.locator('[data-slot="coach-turn-steps-toggle"]');
const current = line.locator('[data-slot="coach-turn-steps-active"]');
const chevron = line.locator('[data-slot="coach-turn-steps-chevron"]');
const column = turn.locator('[data-slot="coach-answer-column"]');
// 1. Before any frame: Thinking…, shimmering, nothing to open yet.
await expect(line).toHaveAttribute("data-state", "running");
await expect(current).toHaveAttribute("data-text", "Thinking…");
await expect(current.locator(".text-shimmer")).toHaveText("Thinking");
await expect(current.locator(".waiting-dot")).toHaveCount(3);
await expect(chevron).toHaveCount(0);
await expect(toggle).toHaveCount(0);
await feed(page, [entry("a1", "thinking", "running", "Thinking…")]);
await feed(page, [
entry("a1", "thinking", "running", "Thinking…", {
title: THINK_TITLE,
}),
]);
await expect(current).toHaveAttribute("data-text", `${THINK_TITLE}…`);
await expect(chevron).toBeVisible();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await feed(page, [
entry("a1", "thinking", "done", "Thinking…", {
title: THINK_TITLE,
text: THINK_TEXT,
durationMs: 2_400,
}),
{ type: "step", step: { ...STEP, status: "running" } },
entry(
"a2",
"fetch",
"running",
"Fetching blood pressure, last 30 days…",
{
stepRef: "s1",
},
),
]);
await expect(current).toHaveAttribute(
"data-text",
"Fetching blood pressure, last 30 days…",
);
// 2. The table it read shows nothing of its own while the turn runs.
await feed(page, [
{ type: "result", result: BP_TABLE, interim: true },
{ type: "step", step: { ...STEP, status: "done", count: 214 } },
entry(
"a2",
"fetch",
"done",
"Fetching blood pressure, last 30 days…",
{
stepRef: "s1",
count: 214,
durationMs: 900,
},
),
entry("a3", "digest", "running", "Summarising 214 readings…", {
count: 214,
}),
]);
await expect(current).toHaveAttribute(
"data-text",
"Summarising 214 readings…",
);
// One row and nothing else in the answer's column: no table title,
// no sparkline, no clock.
await expect(column.locator(":scope > *")).toHaveCount(1);
await expect(turn).not.toContainText("Blood pressure by day");
await expect(turn.locator("svg polyline")).toHaveCount(0);
await expect(line).not.toContainText(/\d\s?s\b/);
await expect(
turn.locator('[data-slot="coach-answer-bubble"]'),
).toHaveCount(0);
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await noHorizontalScroll(page);
await shot(page, `${label}-1-running`);
// A tap opens the steps while the turn runs...
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(line.locator('[data-slot="coach-turn-step"]')).toHaveCount(
3,
);
await expect(
line.locator('[data-slot="coach-turn-step"]').nth(2),
).toHaveAttribute("data-status", "running");
await noHorizontalScroll(page);
await shot(page, `${label}-2-running-open`);
await feed(page, [
entry("a3", "digest", "done", "214 readings from 1 area", {
count: 214,
durationMs: 400,
}),
entry("a4", "answer", "running", "Writing the answer…"),
...tokens(ANSWER),
]);
await expect(current).toHaveAttribute(
"data-text",
"Writing the answer…",
);
await expect(
turn.locator('[data-slot="coach-answer-bubble"]'),
).toContainText(ANSWER);
// ...and stays as the person left it until the turn ends.
await expect(toggle).toHaveAttribute("aria-expanded", "true");
// The persisted copy lands with the refetch after `done`.
stub.messages.push(
{ id: "coach-activity-e2e-u1", role: "user", content: QUESTION },
{
id: "coach-activity-e2e-a1",
role: "assistant",
content: ANSWER,
metricSource: METRIC_SOURCE,
},
);
await feed(page, [
entry("a4", "answer", "done", "Writing the answer…", {
durationMs: 3_000,
}),
{ type: "provenance", metricSource: METRIC_SOURCE },
{ type: "result", result: BP_SHOWN },
done(CONVERSATION_ID, "coach-activity-e2e-a1"),
]);
await end(page);
// 4. The thought process and its steps, closed again; the table the
// answer points at is its chart.
const bubble = page
.locator('[data-slot="coach-bubble-assistant"]')
.filter({ hasText: ANSWER });
const settled = bubble.locator('[data-slot="coach-turn-steps"]');
await expect(settled).toHaveAttribute("data-state", "done");
await expect(
settled.locator('[data-slot="coach-turn-steps-done"]'),
).toHaveText("Thought process (4 steps)");
await expect(settled.locator(".text-shimmer")).toHaveCount(0);
const settledToggle = settled.locator(
'[data-slot="coach-turn-steps-toggle"]',
);
await expect(settledToggle).toHaveAttribute("aria-expanded", "false");
await expect(
settled.locator('[data-slot="coach-turn-steps-panel"]'),
).toHaveCount(0);
await expect(
bubble.locator('[data-slot="coach-result-chart"][data-ref="r1"]'),
).toBeVisible();
await expect(
bubble.locator('[data-slot="coach-result-chart-plot"]'),
).toBeVisible();
expect((await posts(page))[0]).toMatchObject({ message: QUESTION });
await noHorizontalScroll(page);
await shot(page, `${label}-3-settled`);
// 6. A 44 px tap target on phones.
if (viewport.width < 640) {
const box = await settledToggle.boundingBox();
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
}
// 5. A tap opens the steps; the reasoning round shows its summary
// under its title.
await settledToggle.click();
await expect(settledToggle).toHaveAttribute("aria-expanded", "true");
const panel = settled.locator('[data-slot="coach-turn-steps-panel"]');
await expect(panel).toBeVisible();
const rows = panel.locator('[data-slot="coach-turn-step"]');
await expect(rows).toHaveCount(4);
await expect(rows.nth(0)).toHaveAttribute("data-phase", "thinking");
await expect(rows.nth(1)).toHaveAttribute("data-phase", "fetch");
await expect(
panel.locator('[data-slot="coach-method-line"]'),
).toContainText(METRIC_SOURCE.method.text);
await expect(rows.nth(0)).toContainText(THINK_TITLE);
await expect(
rows.nth(0).locator('[data-slot="coach-turn-step-detail"]'),
).toHaveText(THINK_TEXT);
await expect(
panel.locator('[data-slot="coach-turn-step-connector"]'),
).toHaveCount(3);
await noHorizontalScroll(page);
await shot(page, `${label}-4-settled-open`);
const axe = await new AxeBuilder({ page })
.include('[data-slot="coach-bubble-assistant"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(
axe.violations.map((v) => ({
id: v.id,
nodes: v.nodes.map((n) => n.target.join(" ")),
})),
).toEqual([]);
// 3. After a reload the trail is closed again, and opening it reads
// the stored titles and texts.
await page.goto(`/coach?c=${CONVERSATION_ID}`, {
waitUntil: "domcontentloaded",
});
const reloaded = page
.locator('[data-slot="coach-bubble-assistant"]')
.filter({ hasText: ANSWER })
.locator('[data-slot="coach-turn-steps"]');
await expect(reloaded).toHaveAttribute("data-state", "done");
await expect(
reloaded.locator('[data-slot="coach-turn-steps-done"]'),
).toHaveText("Thought process (4 steps)");
const reloadedToggle = reloaded.locator(
'[data-slot="coach-turn-steps-toggle"]',
);
await expect(reloadedToggle).toHaveAttribute("aria-expanded", "false");
await expect(
reloaded.locator('[data-slot="coach-turn-steps-panel"]'),
).toHaveCount(0);
await reloadedToggle.click();
const first = reloaded.locator('[data-slot="coach-turn-step"]').nth(0);
await expect(first).toContainText(THINK_TITLE);
await expect(first).toContainText(THINK_TEXT);
});
}
}
});
+382
View File
@@ -0,0 +1,382 @@
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import type { Locator, Page, Route, TestInfo } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
/**
* The geometry of a Coach answer, at 1440 x 900 (desktop project) and
* 390 x 844 (mobile project).
*
* Contracts under test:
*
* 1. Under an answer there is exactly one action row, left-aligned on the
* bubble's left edge and no taller than one 44 px target: copy, read
* aloud, try again, details, then the time. The model and the tokens
* are not text on the row; the details icon shows them in a tooltip on
* keyboard focus (desktop) and on a tap (phone); Enter and Space keep it
* open, and a second tap or Escape closes it.
* 2. The follow-up chips sit in the answer's column, right under the
* answer and above the action row (the row is the last thing in the
* column): their left edge is the bubble's, their text is the
* answer's size, and a chip is at most 36 px tall beside a pointer and
* at least 44 px on a phone.
* 3. Under a question, copy and remember share one row; nothing stacks
* below it.
* 4. Scrolled away from the end, a round button appears; it brings the
* thread back to the end and goes away again.
* 5. No thumbs, no evidence disclosure, no horizontal overflow.
*
* Screenshots are attached to the report (no pixel baselines) and, with
* `COACH_SHOT_DIR` set, written there too.
*/
const CONVERSATION_ID = "coach-answer-layout-e2e";
const TITLE = "Blood pressure and sleep";
const ANSWERS = [
"Your blood pressure stayed in a steady band over the last month. The morning readings sat a little higher than the evening ones, which is common, and none of the weeks stands out from the others.",
"Sleep looked regular on most nights. The weekends ran about forty minutes later at both ends, so the total stayed much the same while the timing moved.",
"Your resting heart rate held steady across the same weeks. Nothing in it lines up with the later weekend nights, so the two seem to move independently for now.",
"The period before looked much the same for blood pressure. Two readings in the second week were higher than the rest, and both were taken shortly after a workout.",
"Taken together, the month reads calm: steady blood pressure, regular sleep and a resting heart rate without surprises. Measuring at the same time each morning keeps the trend easy to read.",
];
const QUESTIONS = [
"How was my blood pressure this month?",
"And my sleep?",
"What about my resting heart rate?",
"Compare blood pressure with the period before",
"Sum it up for me",
];
const STEPS = [
{
id: "s1",
tool: "get_metric_series",
labelKey: "coach.step.readWindow",
label: "Checking: Blood pressure, last 30 days",
domain: "bp",
window: "last30days",
status: "done",
count: 28,
},
{
id: "s2",
tool: "get_sleep",
labelKey: "coach.step.readWindow",
label: "Checking: Sleep, last 30 days",
domain: "sleep",
window: "last30days",
status: "done",
count: 30,
},
];
const FOLLOW_UPS = [
{
id: "f1",
kind: "previous_period",
labelKey: "coach.followUp.previousPeriod",
label: "Compare with the period before",
reuse: false,
origin: "server",
},
{
id: "f2",
kind: "related_metric",
labelKey: "coach.followUp.relatedMetric",
label: "See also: Resting HR",
reuse: false,
origin: "server",
},
];
function messages() {
// Today, a few minutes apart, so the time reads as a bare clock.
const base = Date.now() - 60 * 60 * 1000;
const at = (minute: number) =>
new Date(base + minute * 60 * 1000).toISOString();
return ANSWERS.flatMap((answer, index) => [
{
id: `${CONVERSATION_ID}-u${index + 1}`,
role: "user",
content: QUESTIONS[index],
createdAt: at(index * 2),
metricSource: null,
providerType: null,
promptVersion: null,
tokensUsed: null,
model: null,
},
{
id: `${CONVERSATION_ID}-a${index + 1}`,
role: "assistant",
content: answer,
createdAt: at(index * 2 + 1),
metricSource: {
windows: ["last30days"],
metrics: ["bp", "sleep"],
steps: STEPS,
...(index === ANSWERS.length - 1 ? { followUps: FOLLOW_UPS } : {}),
},
providerType: "mock",
promptVersion: "e2e",
tokensUsed: 1234,
model: "gpt-4o-mini-2024-07-18",
},
]);
}
function fulfilJson(route: Route, data: unknown) {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data, error: null }),
});
}
async function mockConversation(page: Page) {
const summary = {
id: CONVERSATION_ID,
title: TITLE,
createdAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
updatedAt: new Date().toISOString(),
messageCount: ANSWERS.length * 2,
fenced: false,
attachments: [],
documentTitle: null,
};
await page.route(
/\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/,
async (route) => {
const url = new URL(route.request().url());
if (url.pathname.startsWith(`/api/insights/chat/${CONVERSATION_ID}/`)) {
return fulfilJson(route, { results: [] });
}
if (url.pathname === `/api/insights/chat/${CONVERSATION_ID}`) {
return fulfilJson(route, {
...summary,
attachmentCount: 0,
summary: null,
messages: messages(),
});
}
return fulfilJson(route, { conversations: [summary], nextCursor: null });
},
);
await page.route("**/api/insights/coach/nudge-status*", (route) =>
fulfilJson(route, { nudgedAt: null, unread: false }),
);
await page.route("**/api/coach/about-me/questions*", (route) =>
fulfilJson(route, { questions: [] }),
);
}
async function box(locator: Locator) {
const b = await locator.boundingBox();
expect(b, "element has a box").not.toBeNull();
return b!;
}
async function shoot(page: Page, testInfo: TestInfo, name: string) {
const body = await page.screenshot({ fullPage: false });
await testInfo.attach(name, { body, contentType: "image/png" });
const dir = process.env.COACH_SHOT_DIR;
if (dir) {
mkdirSync(dir, { recursive: true });
await page.screenshot({ path: join(dir, `${name}.png`) });
}
}
const VIEWPORTS = {
"chromium-desktop": { width: 1440, height: 900, label: "1440" },
"chromium-mobile": { width: 390, height: 844, label: "390" },
} as const;
test.describe("Coach answer layout", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("one action row, chips in the answer column, jump to the latest", async ({
page,
}, testInfo) => {
const viewport = VIEWPORTS[testInfo.project.name as keyof typeof VIEWPORTS];
test.skip(!viewport, "measured in the desktop and the mobile project");
const desktop = viewport.width >= 640;
await page.setViewportSize({
width: viewport.width,
height: viewport.height,
});
await page.emulateMedia({ reducedMotion: "reduce" });
await serveAiBlock(page, aiBlockAvailable());
await mockConversation(page);
await page.goto(`/coach?c=${CONVERSATION_ID}`, {
waitUntil: "domcontentloaded",
});
const thread = page.locator('[data-slot="coach-message-thread"]');
const answers = page.locator('[data-slot="coach-bubble-assistant"]');
await expect(answers).toHaveCount(ANSWERS.length, { timeout: 15_000 });
const last = answers.last();
await expect(last).toContainText(ANSWERS.at(-1)!.slice(0, 40));
// The thread opens at its end.
await expect
.poll(() =>
thread.evaluate(
(el) => el.scrollHeight - el.scrollTop - el.clientHeight,
),
)
.toBeLessThanOrEqual(2);
if (desktop) await last.hover();
await shoot(page, testInfo, `answer-${viewport.label}`);
// 5. Nothing of the old furniture.
await expect(
page.locator('[data-slot^="coach-message-feedback"]'),
).toHaveCount(0);
await expect(page.locator('[data-slot^="coach-evidence"]')).toHaveCount(0);
await expect(page.locator('[data-slot="coach-token-footer"]')).toHaveCount(
0,
);
for (const el of [page.locator("#main-content"), thread]) {
const overflow = await el.evaluate((n) => n.scrollWidth - n.clientWidth);
expect(overflow, "no horizontal overflow").toBeLessThanOrEqual(0);
}
// 1. One action row under the answer.
const bubble = last.locator('[data-slot="coach-answer-bubble"]');
const column = last.locator('[data-slot="coach-answer-column"]');
const row = last.locator('[data-slot="coach-answer-actions"]');
const info = row.locator('[data-slot="coach-answer-info"]');
const time = row.locator('[data-slot="coach-message-time"]');
await expect(row).toHaveCount(1);
await expect(row).toHaveCSS("opacity", "1");
await expect(row).not.toContainText("tokens");
await expect(row.locator('[data-slot="coach-answer-meta"]')).toHaveCount(0);
await expect(info).toHaveAttribute("aria-label", "Answer details");
const [bubbleBox, columnBox, rowBox, answerCopyBox, infoBox, timeBox] =
await Promise.all(
[
bubble,
column,
row,
row.locator('[data-slot="coach-copy-message"]'),
info,
time,
].map(box),
);
expect(rowBox.height, "the row is one line").toBeLessThanOrEqual(44);
expect(Math.abs(answerCopyBox.x - bubbleBox.x)).toBeLessThanOrEqual(1);
expect(infoBox.x, "details follows the other icons").toBeGreaterThan(
answerCopyBox.x,
);
expect(timeBox.x, "the time comes last").toBeGreaterThan(infoBox.x);
expect(timeBox.x + timeBox.width).toBeLessThanOrEqual(
columnBox.x + columnBox.width + 1,
);
for (const [b, name] of [
[infoBox, "details"],
[timeBox, "time"],
] as const) {
expect(
Math.abs(
b.y + b.height / 2 - (answerCopyBox.y + answerCopyBox.height / 2),
),
`${name} shares the row's line`,
).toBeLessThanOrEqual(2);
}
// The details tooltip: focus opens it on a desktop, a tap on a phone.
const tip = page.locator('[data-slot="coach-answer-info-content"]');
await expect(tip).toHaveCount(0);
if (desktop) await info.focus();
else await info.tap();
await expect(tip).toBeVisible();
await expect(tip).toContainText("Model: gpt-4o-mini-2024-07-18");
await expect(tip).toContainText("1,234 tokens");
await expect(info).toHaveAttribute("aria-describedby", /.+/);
await shoot(page, testInfo, `answer-info-${viewport.label}`);
if (desktop) {
// Enter and Space on the focused icon keep it open; Escape closes it.
for (const key of ["Enter", "Space"]) {
await page.keyboard.press(key);
await expect(tip).toBeVisible();
}
await page.keyboard.press("Escape");
} else await info.tap();
await expect(tip).toHaveCount(0);
// 2. The chips in the answer's column, at the answer's size.
const chips = last.locator('[data-slot="coach-follow-up-chips"]');
await expect(chips.locator("[data-follow-up-id]")).toHaveCount(2);
const chipsBox = await box(chips);
expect(Math.abs(chipsBox.x - bubbleBox.x)).toBeLessThanOrEqual(1);
// No empty band between the answer and its replies: the column's own
// gap, whatever the hover state of the row below.
const chipsGap = chipsBox.y - (bubbleBox.y + bubbleBox.height);
expect(chipsGap, "the chips follow the answer").toBeGreaterThanOrEqual(0);
expect(chipsGap, "the chips follow the answer").toBeLessThanOrEqual(12);
expect(rowBox.y, "the action row comes last").toBeGreaterThanOrEqual(
chipsBox.y + chipsBox.height - 1,
);
const answerSize = await bubble.evaluate(
(el) => getComputedStyle(el).fontSize,
);
for (const chip of await chips.locator("[data-follow-up-id]").all()) {
await expect(chip).toHaveCSS("font-size", answerSize);
const chipBox = await box(chip);
if (desktop) expect(chipBox.height).toBeLessThanOrEqual(36);
else expect(chipBox.height).toBeGreaterThanOrEqual(44);
}
// 3. Copy and remember share the question's one row.
const question = page.locator('[data-slot="coach-bubble-user"]').last();
if (desktop) await question.hover();
const userRow = question.locator('[data-slot="coach-user-actions"]');
const remember = userRow.locator('[data-slot="coach-remember-message"]');
const copy = userRow.locator('[data-slot="coach-copy-message"]');
await expect(remember).toHaveAttribute("aria-label", "Remember");
const [rememberBox, copyBox] = await Promise.all([remember, copy].map(box));
expect(
Math.abs(
rememberBox.y +
rememberBox.height / 2 -
(copyBox.y + copyBox.height / 2),
),
).toBeLessThanOrEqual(1);
// The question's column holds the bubble and its one row, nothing else.
expect(
await userRow.evaluate((el) => el.parentElement?.childElementCount),
).toBe(2);
// 4. Away from the end, the button appears and brings the thread back.
const jump = page.locator('[data-slot="coach-scroll-to-bottom"]');
await expect(jump).toHaveAttribute("data-visible", "false");
await expect(jump).toHaveCSS("opacity", "0");
await thread.evaluate((el) => el.scrollTo({ top: 0, behavior: "auto" }));
await expect(jump).toHaveAttribute("data-visible", "true");
await expect(jump).toHaveCSS("opacity", "1");
await shoot(page, testInfo, `scrolled-up-${viewport.label}`);
const jumpBox = await box(jump);
const threadBox = await box(thread);
expect(
Math.abs(
jumpBox.x + jumpBox.width / 2 - (threadBox.x + threadBox.width / 2),
),
"centred over the thread",
).toBeLessThanOrEqual(1);
await jump.click();
await expect
.poll(() =>
thread.evaluate(
(el) => el.scrollHeight - el.scrollTop - el.clientHeight,
),
)
.toBeLessThanOrEqual(2);
await expect(jump).toHaveAttribute("data-visible", "false");
await expect(jump).toHaveAttribute("tabindex", "-1");
await expect(page.locator("#coach-composer-textarea")).toBeFocused();
});
});
+267
View File
@@ -0,0 +1,267 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
import {
FIXED_NOW,
done,
end,
feed,
installLiveCoachStream,
posts,
serveConversation,
tokens,
applyTheme,
type StubConversation,
} from "./setup/coach-live-stream";
/**
* A clarifying question is a conversation, not a form.
*
* 1. The question is a normal Coach message; its choices are quiet reply
* pills under it, the assumed one first. There is no card.
* 2. A pill sends its label as the person's own message, with
* `clarification: { messageId, choiceId }`.
* 3. A typed answer works too, with `clarification: { messageId }`.
* 4. The trail above the question stays closed.
*/
const CONVERSATION_ID = "coach-clarify-e2e";
const QUESTION = "Is my heart rate okay?";
const ASKS =
"Do you mean your resting heart rate or your heart rate while walking? Otherwise I'll look at your resting heart rate.";
const ANSWER =
"Your walking heart rate sat around 98 bpm over the last 30 days, close to your usual level.";
const CLARIFICATION = {
kind: "metric",
freeText: true,
assumption: "c1",
choices: [
{
id: "c1",
labelKey: "insights.coach.metric.resting_hr",
label: "Resting HR",
value: { metric: "resting_hr" },
},
{
id: "c2",
labelKey: "insights.coach.metric.walking_hr",
label: "Walking HR",
value: { metric: "walking_hr" },
},
],
};
const ASK_SOURCE = {
windows: ["last30days"],
metrics: ["resting_hr"],
activity: [
{
id: "a1",
phase: "thinking",
status: "done",
round: 1,
labelKey: "insights.coach.activity.thinking",
label: "Thinking…",
durationMs: 1_200,
},
{
id: "a2",
phase: "asking",
status: "done",
round: 1,
labelKey: "insights.coach.activity.asking",
label: "Asking you…",
durationMs: 300,
},
],
clarification: CLARIFICATION,
};
async function ask(
page: import("@playwright/test").Page,
stub: StubConversation,
) {
await page.locator('[data-slot="coach-input-textarea"]').fill(QUESTION);
await page.locator('[data-slot="coach-input-send"]').click();
const live = page.locator(
'[role="log"] [data-slot="coach-turn-steps-active"]',
);
await feed(page, [
{
type: "activity",
activity: {
...ASK_SOURCE.activity[0],
status: "running",
durationMs: undefined,
},
},
{
type: "activity",
activity: {
...ASK_SOURCE.activity[1],
status: "running",
durationMs: undefined,
},
},
]);
await expect(live).toHaveAttribute("data-text", "Asking you…");
stub.messages.push(
{ id: "coach-clarify-e2e-u1", role: "user", content: QUESTION },
{
id: "coach-clarify-e2e-a1",
role: "assistant",
content: ASKS,
metricSource: ASK_SOURCE,
},
);
await feed(page, [
...tokens(ASKS),
{ type: "activity", activity: ASK_SOURCE.activity[1] },
{ type: "provenance", metricSource: ASK_SOURCE },
{ type: "clarification", clarification: CLARIFICATION },
done(CONVERSATION_ID, "coach-clarify-e2e-a1"),
]);
await end(page);
}
test.describe("Coach clarifying question", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("pills in the conversation, the assumed one first, and a tap answers", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"the flow is viewport-independent; the activity spec covers 390 px",
);
await page.clock.setFixedTime(FIXED_NOW);
await serveAiBlock(page, aiBlockAvailable());
const stub: StubConversation = {
id: CONVERSATION_ID,
title: "Heart rate",
messages: [],
results: {},
trails: {},
};
await serveConversation(page, stub);
await installLiveCoachStream(page);
await applyTheme(page, "light");
await page.goto("/coach", { waitUntil: "domcontentloaded" });
await ask(page, stub);
const asked = page
.locator('[data-slot="coach-bubble-assistant"]')
.filter({ hasText: ASKS });
await expect(asked).toHaveCount(1);
// 1. No card anywhere; the pills sit in the answer's own column.
await expect(
page.locator('[data-slot="coach-clarification-card"]'),
).toHaveCount(0);
const pills = asked.locator('[data-slot="coach-suggested-replies"]');
await expect(pills).toBeVisible();
await expect(pills).toHaveAttribute(
"data-message-id",
"coach-clarify-e2e-a1",
);
const choices = pills.locator("[data-choice-id]");
await expect(choices).toHaveCount(2);
await expect(choices.nth(0)).toHaveAttribute("data-choice-id", "c1");
// 4. The row says an answer is needed, and stays closed.
const row = asked.locator('[data-slot="coach-turn-steps"]');
await expect(row).toHaveAttribute("data-state", "awaiting");
await expect(row.locator('[data-slot="coach-turn-steps-done"]')).toHaveText(
"Answer needed",
);
await expect(row.locator(".text-shimmer")).toHaveCount(0);
await expect(
asked.locator('[data-slot="coach-turn-steps-toggle"]'),
).toHaveAttribute("aria-expanded", "false");
// 2. A tap sends the label as the person's message.
const walking = choices.nth(1);
const label = (await walking.textContent())?.trim() ?? "";
await walking.click();
await expect(
page
.locator('[data-slot="coach-bubble-user"]')
.filter({ hasText: label }),
).toBeVisible();
await expect(pills).toHaveCount(0);
const sent = await posts(page);
expect(sent[1]).toMatchObject({
conversationId: CONVERSATION_ID,
message: label,
clarification: { messageId: "coach-clarify-e2e-a1", choiceId: "c2" },
});
stub.messages.push(
{ id: "coach-clarify-e2e-u2", role: "user", content: label },
{
id: "coach-clarify-e2e-a2",
role: "assistant",
content: ANSWER,
metricSource: { windows: ["last30days"], metrics: ["walking_hr"] },
},
);
await feed(page, [
...tokens(ANSWER),
done(CONVERSATION_ID, "coach-clarify-e2e-a2"),
]);
await end(page);
await expect(
page
.locator('[data-slot="coach-bubble-assistant"]')
.filter({ hasText: ANSWER }),
).toBeVisible();
await expect(
page.locator('[data-slot="coach-suggested-replies"]'),
).toHaveCount(0);
});
test("a typed answer answers the question too", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"viewport-independent; desktop run suffices",
);
await page.clock.setFixedTime(FIXED_NOW);
await serveAiBlock(page, aiBlockAvailable());
const stub: StubConversation = {
id: CONVERSATION_ID,
title: "Heart rate",
messages: [],
results: {},
trails: {},
};
await serveConversation(page, stub);
await installLiveCoachStream(page);
await applyTheme(page, "dark");
await page.goto("/coach", { waitUntil: "domcontentloaded" });
await ask(page, stub);
await expect(
page.locator('[data-slot="coach-suggested-replies"]'),
).toBeVisible();
await page
.locator('[data-slot="coach-input-textarea"]')
.fill("the walking one");
await page.locator('[data-slot="coach-input-send"]').click();
await expect(
page
.locator('[data-slot="coach-bubble-user"]')
.filter({ hasText: "the walking one" }),
).toBeVisible();
const sent = await posts(page);
expect(sent[1]).toMatchObject({
message: "the walking one",
clarification: { messageId: "coach-clarify-e2e-a1" },
});
expect(
(sent[1].clarification as Record<string, unknown>).choiceId,
).toBeUndefined();
await end(page);
});
});
+188
View File
@@ -0,0 +1,188 @@
import { expect, test } from "./setup/test";
import type { Route } from "@playwright/test";
import { randomBytes } from "node:crypto";
import { setTimeout as delay } from "node:timers/promises";
import { hash } from "@node-rs/argon2";
import pg from "pg";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
const KEEP_ID = "conversation-delete-keep";
const DROP_ID = "conversation-delete-drop";
function row(id: string, title: string) {
return {
id,
title,
createdAt: "2026-07-20T10:00:00.000Z",
updatedAt: "2026-07-20T10:00:00.000Z",
messageCount: 2,
fenced: false,
attachments: [],
documentTitle: null,
};
}
/**
* A deleted conversation must stay deleted. The delete waits out an undo
* window before it is sent; a reload inside that window used to drop it, and
* once the window ran out the row showed again until the refetch landed.
*/
test.describe("Coach conversation delete", () => {
test.beforeEach(async ({ page }) => {
await serveAiBlock(page, aiBlockAvailable());
});
test.use({ storageState: STORAGE_STATE_PATH });
async function serveConversations(
page: import("@playwright/test").Page,
deleteDelayMs: number,
) {
const state = {
rows: [row(KEEP_ID, "Kept thread"), row(DROP_ID, "Dropped thread")],
deletes: [] as string[],
};
// Context-level, so a `keepalive` request sent while the page unloads is
// still answered here.
await page
.context()
.route(
/\/api\/insights\/chat(?:\/[^?]+)?(?:\?|$)/,
async (route: Route) => {
const request = route.request();
const url = new URL(request.url());
if (request.method() === "DELETE") {
const id = decodeURIComponent(url.pathname.split("/").pop() ?? "");
state.deletes.push(id);
state.rows = state.rows.filter((r) => r.id !== id);
if (deleteDelayMs > 0) await delay(deleteDelayMs);
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: { deleted: true }, error: null }),
});
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { conversations: state.rows, nextCursor: null },
error: null,
}),
});
},
);
return state;
}
test("the row does not come back when the window runs out", async ({
page,
}) => {
// A slow DELETE holds the refetch back, which is when the row used to
// reappear.
const state = await serveConversations(page, 2_000);
await page.goto("/coach/conversations", { waitUntil: "domcontentloaded" });
const items = page.locator('[data-slot="coach-conversations-item"]');
await expect(items).toHaveCount(2, { timeout: 10_000 });
await items
.filter({ hasText: "Dropped thread" })
.locator('[data-slot="coach-conversations-delete"]')
.click();
await expect(items).toHaveCount(1);
await expect
.poll(() => state.deletes, { timeout: 15_000 })
.toEqual([DROP_ID]);
// Sampled while the DELETE is still in flight.
for (let i = 0; i < 6; i += 1) {
expect(await items.count()).toBe(1);
await delay(250);
}
await expect(items).toContainText("Kept thread");
});
});
/**
* The real server and database, not a mock: the request that matters here is
* sent with `keepalive` while the page unloads, which Playwright's routing
* never sees. The account is the spec's own, so the conversation it seeds
* cannot surface in another spec's Coach.
*/
test.describe("Coach conversation delete against the database", () => {
test.use({ storageState: { cookies: [], origins: [] } });
test("a reload inside the undo window still deletes", async ({
page,
}, testInfo) => {
const dbUrl = process.env.DATABASE_URL;
test.skip(!dbUrl, "DATABASE_URL is required to seed the conversation");
const suffix = `${testInfo.project.name}-${randomBytes(4).toString("hex")}`;
const username = `e2e-coach-delete-${suffix}`;
const password = "Cd5!Vq8nTz3wLp7K";
const userId = `e2e-coach-delete-user-${suffix}`;
const conversationId = `e2e-coach-delete-${suffix}`;
const pool = new pg.Pool({ connectionString: dbUrl });
try {
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed)
VALUES ($1, $2, $3, $4, 'USER', now(), now(), now(), true)`,
[
userId,
username,
`${username}@healthlog.test`,
await hash(password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
}),
],
);
// A legacy readable title keeps the seed free of the encryption key.
await pool.query(
`INSERT INTO coach_conversations (id, user_id, title, updated_at)
VALUES ($1, $2, 'Dropped thread', now())`,
[conversationId, userId],
);
const login = await page.request.post("/api/auth/login", {
data: { email: username, password },
});
expect(login.status()).toBe(200);
await page.goto("/coach/conversations", {
waitUntil: "domcontentloaded",
});
const items = page.locator('[data-slot="coach-conversations-item"]');
await expect(items).toHaveCount(1, { timeout: 15_000 });
await items.locator('[data-slot="coach-conversations-delete"]').click();
await expect(items).toHaveCount(0);
// Well inside the undo window.
await page.reload({ waitUntil: "domcontentloaded" });
await expect
.poll(async () => {
const { rowCount } = await pool.query(
"SELECT 1 FROM coach_conversations WHERE id = $1",
[conversationId],
);
return rowCount;
})
.toBe(0);
await expect(
page.locator('[data-slot="coach-conversations-empty"]'),
).toBeVisible({ timeout: 15_000 });
await expect(items).toHaveCount(0);
} finally {
await pool.query("DELETE FROM users WHERE id = $1", [userId]);
await pool.end();
}
});
});
+25 -18
View File
@@ -17,12 +17,13 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
* puts the table on the clipboard.
* 3. A follow-up chip under the latest reply sends its label with
* `followUp: { messageId, id }`.
* 4. A reply that ends in a clarifying question shows the choices card
* and no chips (as the server sends it); a choice sends its label with
* `clarification: { messageId, choiceId }`, and the card leaves once a
* 4. A reply that ends in a clarifying question offers its choices as
* reply pills under it and no chips (as the server sends it); there is
* no card. A choice sends its label with
* `clarification: { messageId, choiceId }`, and the pills leave once a
* plain answer is the latest reply.
* 5. axe finds nothing on the answer, the chips and the clarification
* card, in the light and the dark theme.
* 5. axe finds nothing on the answer, the chips and the reply pills, in
* the light and the dark theme.
*
* The chat POST is a stubbed event stream in the documented frame order,
* and the conversation detail it refetches after `done` returns the same
@@ -441,7 +442,7 @@ async function expectNoAxeViolations(page: Page, label: string) {
const result = await new AxeBuilder({ page })
.include('[data-slot="coach-bubble-assistant"]')
.include('[data-slot="coach-follow-up-chips"]')
.include('[data-slot="coach-clarification-card"]')
.include('[data-slot="coach-suggested-replies"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(
@@ -550,10 +551,11 @@ test.describe("Coach dialog", () => {
await expect(figure).toBeVisible();
await expect(table).toBeHidden();
// The method line sits in the evidence disclosure.
await firstBubble.locator('[data-slot="coach-evidence-summary"]').click();
// The method line closes the open steps list.
await expect(
firstBubble.locator('[data-slot="coach-method-line"]'),
steps.locator(
'[data-slot="coach-turn-steps-panel"] [data-slot="coach-method-line"]',
),
).toContainText(TURN_ONE_METHOD.text);
// 3. A follow-up chip sends which chip it was.
@@ -580,26 +582,31 @@ test.describe("Coach dialog", () => {
});
expect(posts[1].clarification).toBeUndefined();
// 4. The reply asks; the card offers the choices, and the chips of
// the earlier reply are gone.
const card = page.locator('[data-slot="coach-clarification-card"]');
await expect(card).toBeVisible();
await expect(card).toHaveAttribute(
// 4. The reply asks; pills under it offer the choices, and the chips
// of the earlier reply are gone. No card.
await expect(
page.locator('[data-slot="coach-clarification-card"]'),
).toHaveCount(0);
const pills = secondBubble.locator(
'[data-slot="coach-suggested-replies"]',
);
await expect(pills).toBeVisible();
await expect(pills).toHaveAttribute(
"data-message-id",
TURNS[1].assistantId,
);
await expect(card.locator("[data-choice-id]")).toHaveCount(2);
await expect(pills.locator("[data-choice-id]")).toHaveCount(2);
await expect(chips).toHaveCount(0);
// The earlier result still renders under its own answer.
await expect(figure).toBeVisible();
// 5. axe over the answers, the result in both views and the card.
// 5. axe over the answers, the result in both views and the pills.
await expectNoAxeViolations(page, `${theme} chart view with a question`);
await figure.locator('[data-slot="coach-result-view-table"]').click();
await expect(table).toBeVisible();
await expectNoAxeViolations(page, `${theme} table view with a question`);
await card.locator('[data-choice-id="c2"]').click();
await pills.locator('[data-choice-id="c2"]').click();
const thirdBubble = page
.locator('[data-slot="coach-bubble-assistant"]')
@@ -615,7 +622,7 @@ test.describe("Coach dialog", () => {
expect(posts[2].followUp).toBeUndefined();
// A plain answer closes the question and offers no chips.
await expect(card).toHaveCount(0);
await expect(pills).toHaveCount(0);
await expect(chips).toHaveCount(0);
await expect(
thirdBubble.locator('[data-slot="coach-turn-steps"]'),
File diff suppressed because it is too large. Load diff
+436
View File
@@ -0,0 +1,436 @@
import type { Page, Route } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
import {
FIXED_NOW,
done,
end,
feed,
installLiveCoachStream,
posts,
serveConversation,
tokens,
applyTheme,
type StubConversation,
} from "./setup/coach-live-stream";
/**
* What the Coach keeps, in the same quiet language as everything else in
* the thread, and the thinking depth in its quick settings.
*
* 1. A fact it saved: one meta line under the answer, "Remembered: …",
* with Undo, which forgets it.
* 2. A fact it proposes (health): no line; "Yes, remember it" / "No" as
* reply pills, a tap answers with `memoryDecision`.
* 3. A plan it proposes: "Take on this plan" / "Not now", with
* `planDecision`. No plan card in the thread.
* 4. The memory list in Settings → Coach: an entry is edited in place.
* 5. The thinking depth: levels above the operator's cap are disabled and
* say so; with reasoning switched off the field is locked with one
* sentence.
*/
const CONVERSATION_ID = "coach-memory-e2e";
function fulfilJson(route: Route, data: unknown, status = 200) {
return route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify({ data, error: null }),
});
}
interface Fact {
id: string;
category: string;
text: string;
confidence: number;
source: string;
createdAt: string;
}
async function serveFacts(page: Page, facts: Fact[]) {
const calls: Array<{ method: string; path: string; body: unknown }> = [];
await page.route(
/\/api\/insights\/coach\/facts(?:\/[^?]+)?(?:\?.*)?$/,
async (route) => {
const request = route.request();
const url = new URL(request.url());
const method = request.method();
calls.push({
method,
path: url.pathname,
body: method === "GET" ? null : request.postDataJSON(),
});
const id = url.pathname.split("/").pop() ?? "";
if (method === "DELETE") {
const at = facts.findIndex((f) => f.id === id);
if (at !== -1) facts.splice(at, 1);
return fulfilJson(route, { deleted: true });
}
if (method === "PATCH") {
const fact = facts.find((f) => f.id === id);
const next = (request.postDataJSON() as { fact: string }).fact;
if (fact) fact.text = next;
return fulfilJson(route, { fact });
}
return fulfilJson(route, { facts });
},
);
return calls;
}
function stub(): StubConversation {
return {
id: CONVERSATION_ID,
title: "Goals",
messages: [],
results: {},
trails: {},
};
}
async function send(page: Page, text: string) {
await page.locator('[data-slot="coach-input-textarea"]').fill(text);
await page.locator('[data-slot="coach-input-send"]').click();
}
async function answer(
page: Page,
conversation: StubConversation,
turn: {
userId: string;
userText: string;
id: string;
text: string;
metricSource: Record<string, unknown>;
frames: unknown[];
providerType?: string;
},
) {
conversation.messages.push(
{ id: turn.userId, role: "user", content: turn.userText },
{
id: turn.id,
role: "assistant",
content: turn.text,
metricSource: turn.metricSource,
providerType: turn.providerType,
},
);
await feed(page, [
...tokens(turn.text),
{ type: "provenance", metricSource: turn.metricSource },
...turn.frames,
done(CONVERSATION_ID, turn.id),
]);
await end(page);
await expect(
page
.locator('[data-slot="coach-bubble-assistant"]')
.filter({ hasText: turn.text }),
).toBeVisible();
}
test.describe("Coach memory and plans", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"viewport-independent; desktop run suffices",
);
await page.clock.setFixedTime(FIXED_NOW);
await applyTheme(page, "light");
});
test("a saved fact is one quiet line, and Undo forgets it", async ({
page,
}) => {
await serveAiBlock(page, aiBlockAvailable());
const facts: Fact[] = [
{
id: "fact-1",
category: "goal",
text: "You want to reach 75 kg by December",
confidence: 1,
source: "coach",
createdAt: "2026-10-06T09:00:00.000Z",
},
];
const calls = await serveFacts(page, facts);
const conversation = stub();
await serveConversation(page, conversation);
await installLiveCoachStream(page);
await page.goto("/coach", { waitUntil: "domcontentloaded" });
await send(page, "I want to reach 75 kg by December.");
await answer(page, conversation, {
userId: "coach-memory-e2e-u1",
userText: "I want to reach 75 kg by December.",
id: "coach-memory-e2e-a1",
text: "That is about 3 kg from where you are now, a steady pace over ten weeks.",
metricSource: {
windows: [],
metrics: ["weight"],
memoryNote: { proposal: false, factId: "fact-1", category: "goal" },
},
frames: [
{
type: "memoryNote",
note: {
proposal: false,
factId: "fact-1",
category: "goal",
fact: "You want to reach 75 kg by December",
},
},
],
});
const note = page.locator('[data-slot="coach-memory-note"]');
await expect(note).toHaveText(
/Remembered: You want to reach 75 kg by December\.\s*Undo/,
);
// No card, no accent: a meta line.
await expect(note).toHaveClass(/text-muted-foreground/);
await note.locator('[data-slot="coach-memory-note-undo"]').click();
await expect(note).toHaveCount(0);
expect(
calls.some((c) => c.method === "DELETE" && c.path.endsWith("/fact-1")),
).toBe(true);
});
test("a proposed fact and a proposed plan are answered with reply pills", async ({
page,
}) => {
await serveAiBlock(page, aiBlockAvailable());
await serveFacts(page, []);
const conversation = stub();
await serveConversation(page, conversation);
await installLiveCoachStream(page);
await page.goto("/coach", { waitUntil: "domcontentloaded" });
// A health fact is proposed, never saved by itself.
await send(page, "I started taking ramipril in May.");
await answer(page, conversation, {
userId: "coach-memory-e2e-u1",
userText: "I started taking ramipril in May.",
id: "coach-memory-e2e-a1",
text: "Your readings since May sit a little lower. Should I remember that you take ramipril since May?",
metricSource: {
windows: [],
metrics: ["bp"],
memoryNote: {
proposal: true,
proposalId: "p1",
category: "medication",
},
},
frames: [
{
type: "memoryNote",
note: {
proposal: true,
proposalId: "p1",
category: "medication",
fact: "Takes ramipril since May",
},
},
],
});
await expect(page.locator('[data-slot="coach-memory-note"]')).toHaveCount(
0,
);
const pills = page.locator('[data-slot="coach-suggested-replies"]');
await expect(pills.locator("[data-reply-id]")).toHaveText([
"Yes, remember it",
"No",
]);
await pills.locator('[data-reply-id="memory-accept"]').click();
await expect(
page
.locator('[data-slot="coach-bubble-user"]')
.filter({ hasText: "Yes, remember it" }),
).toBeVisible();
expect((await posts(page))[1]).toMatchObject({
message: "Yes, remember it",
memoryDecision: {
messageId: "coach-memory-e2e-a1",
proposalId: "p1",
accept: true,
},
});
await answer(page, conversation, {
userId: "coach-memory-e2e-u2",
userText: "Yes, remember it",
id: "coach-memory-e2e-a2",
text: "Got it, I'll remember.",
providerType: "decision",
metricSource: { windows: [], metrics: [] },
frames: [],
});
await expect(pills).toHaveCount(0);
// A plan is one sentence in the answer, then two pills. No card.
await send(page, "How do I stop snacking late?");
await answer(page, conversation, {
userId: "coach-memory-e2e-u3",
userText: "How do I stop snacking late?",
id: "coach-memory-e2e-a3",
text: "When you finish dinner, then brush your teeth right away. I'll check in with you in 14 days.",
metricSource: {
windows: [],
metrics: [],
planProposal: { planId: "plan-1", metric: "WEIGHT", reviewInDays: 14 },
},
frames: [
{
type: "planProposal",
proposal: {
planId: "plan-1",
metric: "WEIGHT",
reviewInDays: 14,
ifCue: "When you finish dinner",
thenAction: "brush your teeth right away",
},
},
],
});
await expect(
page.locator('[data-slot="coach-plan-proposal-card"]'),
).toHaveCount(0);
await expect(pills.locator("[data-reply-id]")).toHaveText([
"Take on this plan",
"Not now",
]);
await pills.locator('[data-reply-id="plan-decline"]').click();
expect((await posts(page))[3]).toMatchObject({
message: "Not now",
planDecision: {
messageId: "coach-memory-e2e-a3",
planId: "plan-1",
accept: false,
},
});
await end(page);
});
test("an entry in the memory list is edited in place", async ({ page }) => {
await serveAiBlock(page, aiBlockAvailable());
const calls = await serveFacts(page, [
{
id: "fact-1",
category: "goal",
text: "Wants to reach 80 kg by autumn",
confidence: 1,
source: "user",
createdAt: "2026-10-01T09:00:00.000Z",
},
{
id: "fact-2",
category: "preference",
text: "Prefers morning workouts",
confidence: 0.8,
source: "coach",
createdAt: "2026-10-02T09:00:00.000Z",
},
]);
await page.goto("/settings/coach#coach-memory", {
waitUntil: "domcontentloaded",
});
const card = page.locator("#coach-memory");
const rows = card.locator('[data-testid="settings-coach-memory-fact"]');
await expect(rows).toHaveCount(2);
await expect(rows.nth(0)).toContainText("from you");
await expect(rows.nth(1)).toContainText("from the Coach");
await rows
.nth(0)
.locator('[data-slot="settings-coach-memory-edit"]')
.click();
const input = rows
.nth(0)
.locator('[data-slot="settings-coach-memory-edit-input"]');
await input.fill("Wants to reach 78 kg by December");
await rows
.nth(0)
.locator('[data-slot="settings-coach-memory-edit-save"]')
.click();
await expect(rows.nth(0)).toContainText("Wants to reach 78 kg by December");
expect(
calls.find((c) => c.method === "PATCH" && c.path.endsWith("/fact-1"))
?.body,
).toEqual({ fact: "Wants to reach 78 kg by December" });
});
for (const scenario of [
{
name: "capped at Medium",
block: {
level: "medium",
preference: "medium",
maxLevel: "medium",
available: true,
offIsReal: true,
source: "admin_cap",
},
},
{
name: "switched off by the operator",
block: {
level: "off",
preference: "medium",
maxLevel: "off",
available: false,
offIsReal: true,
source: "admin_off",
},
},
]) {
test(`thinking depth in the quick settings, ${scenario.name}`, async ({
page,
}) => {
await page.route("**/api/auth/me", async (route) => {
const response = await route.fetch();
const body = (await response.json()) as {
data: Record<string, unknown> | null;
};
if (body.data) {
body.data.ai = aiBlockAvailable();
body.data.coachReasoning = scenario.block;
}
await route.fulfill({ response, json: body });
});
await serveFacts(page, []);
await serveConversation(page, stub());
await page.goto("/coach", { waitUntil: "domcontentloaded" });
await page.locator('[data-slot="coach-settings"]').first().click();
const select = page.locator('[data-slot="coach-reasoning-select"]');
await expect(select).toBeVisible();
await expect(select.locator("option")).toHaveText([
"Off",
"Low",
"Medium",
scenario.block.available ? "High (limited by the admin)" : "High",
]);
const field = page.locator('[data-slot="coach-reasoning-field"]');
if (scenario.block.available) {
await expect(select).toBeEnabled();
await expect(select.locator('option[value="high"]')).toBeDisabled();
await expect(field).toContainText("a little more waiting");
} else {
await expect(select).toBeDisabled();
await expect(field).toContainText(
"The admin has turned deeper thinking off.",
);
}
// The memory link names how much the Coach keeps.
await expect(
page.locator('[data-slot="coach-settings-memory-link"]'),
).toHaveText("Memory (0)");
});
}
});
+220
View File
@@ -0,0 +1,220 @@
import type { Page, Route } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
/**
* A saved conversation opens at its last answer, and stays there after that
* answer's chart arrives.
*
* Before: on a wide screen the thread opened at the top (scroll 0), and on a
* phone it stopped about 70 px short of the end, because the answer's chart
* loaded after the jump. The conversation here is long enough to scroll on
* both widths, and the stored table under the last answer is served late on
* purpose, so the end moves after the thread first paints.
*/
const CONVERSATION_ID = "coach-open-at-end-e2e";
const LAST_ID = "coach-open-at-end-last";
const TABLE = {
ref: "r1",
source: {
tool: "get_metric_table",
domain: "pulse",
window: "last30days",
period: "current",
granularity: "day",
},
shape: "timeSeries",
titleKey: "coach.result.title.byDay",
title: "Pulse by day",
rowCount: 14,
chartKind: "line",
displayed: true,
columns: [
{
key: "day",
kind: "period",
labelKey: "coach.result.column.day",
label: "Day",
},
{
key: "pulse",
kind: "number",
labelKey: "coach.result.column.value",
label: "Pulse",
unit: "bpm",
decimals: 0,
},
],
rows: Array.from({ length: 14 }, (_, i) => [
new Date(Date.UTC(2026, 8, 10 + i)).toISOString().slice(0, 10),
60 + (i % 5),
]),
truncated: false,
chart: { kind: "line", x: "day", series: ["pulse"] },
};
const PARAGRAPH =
"Your resting pulse held steady across the fortnight, with the usual small dip after the longer nights and a slight rise on the two days you logged a cold.";
function messages() {
const out: Array<Record<string, unknown>> = [];
for (let i = 0; i < 8; i += 1) {
const at = `2026-09-24T09:${String(i * 2).padStart(2, "0")}:00.000Z`;
out.push({
id: `u-${i}`,
role: "user",
content: `Question ${i + 1}: how did my pulse look?`,
createdAt: at,
metricSource: null,
providerType: null,
promptVersion: null,
tokensUsed: null,
model: null,
});
const last = i === 7;
out.push({
id: last ? LAST_ID : `a-${i}`,
role: "assistant",
content: `${PARAGRAPH} ${PARAGRAPH}`,
createdAt: at,
metricSource: last
? {
windows: ["last30days"],
metrics: ["pulse"],
results: [
{
ref: TABLE.ref,
source: TABLE.source,
shape: TABLE.shape,
titleKey: TABLE.titleKey,
title: TABLE.title,
rowCount: TABLE.rowCount,
chartKind: TABLE.chartKind,
displayed: TABLE.displayed,
},
],
}
: null,
providerType: "mock",
promptVersion: "e2e",
tokensUsed: 42,
model: "mock",
});
}
return out;
}
function fulfilJson(route: Route, data: unknown) {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data, error: null }),
});
}
async function mockConversation(page: Page) {
await page.route(
/\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/,
async (route) => {
const url = new URL(route.request().url());
if (
url.pathname ===
`/api/insights/chat/${CONVERSATION_ID}/messages/${LAST_ID}/results`
) {
// Late on purpose: the chart lands after the thread has painted.
await new Promise((resolve) => setTimeout(resolve, 900));
return fulfilJson(route, { results: [TABLE] });
}
if (url.pathname.startsWith(`/api/insights/chat/${CONVERSATION_ID}/`)) {
return fulfilJson(route, { results: [] });
}
if (url.pathname === `/api/insights/chat/${CONVERSATION_ID}`) {
return fulfilJson(route, {
id: CONVERSATION_ID,
title: "Pulse over two weeks",
createdAt: "2026-09-24T09:00:00.000Z",
updatedAt: "2026-09-24T09:14:00.000Z",
messageCount: 16,
fenced: false,
attachments: [],
documentTitle: null,
attachmentCount: 0,
summary: null,
messages: messages(),
});
}
return fulfilJson(route, { conversations: [], nextCursor: null });
},
);
await page.route("**/api/insights/coach/nudge-status*", (route) =>
fulfilJson(route, { nudgedAt: null, unread: false }),
);
await page.route("**/api/coach/about-me/questions*", (route) =>
fulfilJson(route, { questions: [] }),
);
}
/** Distance from the thread's current view to its end, in px. */
async function distanceFromEnd(page: Page): Promise<number> {
return page
.locator('[data-slot="coach-message-thread"]')
.evaluate((el) => el.scrollHeight - el.scrollTop - el.clientHeight);
}
test.describe("Coach: a saved conversation opens at its end", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }) => {
await serveAiBlock(page, aiBlockAvailable());
await mockConversation(page);
});
for (const width of [390, 1440] as const) {
test(`at ${width} px it lands on the last answer and stays after its chart loads`, async ({
page,
}) => {
await page.setViewportSize({
width,
height: width === 390 ? 844 : 900,
});
await page.goto(`/coach?c=${CONVERSATION_ID}`, {
waitUntil: "domcontentloaded",
});
// The empty state carries the same `coach-message-thread` slot, so the
// thread being visible says nothing about the conversation having
// arrived; the height used to be read from the empty state on a slow
// run. Wait for the frame only a loaded thread renders, and for every
// stored answer in it.
await expect(
page.locator('[data-slot="coach-message-thread-frame"]'),
).toBeVisible({ timeout: 15_000 });
await expect(
page.locator('[data-slot="coach-assistant-turn"]'),
).toHaveCount(8, { timeout: 15_000 });
const thread = page.locator(
'[data-slot="coach-message-thread-frame"] [data-slot="coach-message-thread"]',
);
// The thread scrolls at all: otherwise "at the end" proves nothing.
await expect
.poll(() =>
thread.evaluate((el) => el.scrollHeight > el.clientHeight + 200),
)
.toBe(true);
const chart = page.locator(
'[data-slot="coach-result-chart"][data-ref="r1"]',
);
await expect(chart).toBeVisible({ timeout: 15_000 });
// The chart grew the thread after the first jump; the view followed.
await expect.poll(() => distanceFromEnd(page)).toBeLessThanOrEqual(2);
await expect(
page.locator(`[data-slot="coach-assistant-turn"]`).last(),
).toBeInViewport();
});
}
});
+10 -4
View File
@@ -289,7 +289,7 @@ test.describe("Coach result tables and charts", () => {
});
for (const theme of ["light", "dark"] as const) {
test(`theme=${theme}: chart first, table on toggle, data used in the evidence`, async ({
test(`theme=${theme}: chart first, table on toggle, data used in the steps list`, async ({
page,
}) => {
await useTheme(page, theme);
@@ -342,12 +342,18 @@ test.describe("Coach result tables and charts", () => {
await table.locator('[data-slot="coach-result-view-chart"]').click();
await expect(figure).toBeVisible();
// The table the answer only used waits in the evidence disclosure.
// The table the answer only used waits in the open steps list.
await expect(
bubble.locator('[data-slot="coach-result-table"][data-ref="r2"]'),
).toBeHidden();
await bubble.locator('[data-slot="coach-evidence-summary"]').click();
const dataUsed = bubble.locator('[data-slot="coach-data-used"]');
const stepsToggle = bubble.locator(
'[data-slot="coach-turn-steps-toggle"]',
);
await expect(stepsToggle).toHaveAttribute("aria-expanded", "false");
await stepsToggle.click();
const dataUsed = bubble.locator(
'[data-slot="coach-turn-steps-panel"] [data-slot="coach-data-used"]',
);
await expect(dataUsed).toContainText("Data used (1)");
const workouts = dataUsed.locator(
'[data-slot="coach-result-table"][data-ref="r2"]',
+275
View File
@@ -0,0 +1,275 @@
import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { expect, test } from "./setup/test";
/**
* v1.42 — the command palette.
*
* - Cmd/Ctrl+K, "sleep", Enter lands on the sleep page;
* - a settings card is a link with its anchor, and the page scrolls to it;
* - a module's entry is a switch: Enter sends the module write and the
* palette stays open (the write is answered by a route mock, so the
* shared account's modules never move);
* - the top bar carries a magnifier at every width, named "Search",
* with a tooltip that gives the shortcut; on a phone it opens a
* full-height sheet with the field focused;
* - the open palette passes axe;
* - a hit from the record search ("In your records", answered by a route
* mock so the shared account's records never move) lists after the local
* results, Enter on it opens its page, and a visit lands on the visits
* tab; on a phone the hit fits the width.
*
* Stable hooks only: `command-palette` (+ `data-variant`),
* `command-palette-input`, `command-palette-trigger`,
* `command-palette-tooltip`,
* options by `data-entry`.
*/
test.use({ storageState: STORAGE_STATE_PATH });
const palette = (page: Page) => page.locator('[data-slot="command-palette"]');
/** Answer `POST /api/search` with one visit hit, and count the requests. */
async function mockRecordSearch(page: Page): Promise<string[]> {
const queries: string[] = [];
await page.route("**/api/search", async (route) => {
// The query rides the JSON body, never the URL.
expect(route.request().method()).toBe("POST");
expect(route.request().url()).not.toContain("?");
const body = route.request().postDataJSON() as { q?: string } | null;
queries.push(body?.q ?? "");
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
groups: [
{
kind: "visit",
more: false,
hits: [
{
id: "visit:e2e-visit",
kind: "visit",
title: { text: "Arthroscopy", marks: [] },
context: [
{ text: "Shoulder (left)", marks: [[0, 8]] },
{ text: "A practitioner with a long name", marks: [] },
],
snippet: {
text: "Follow-up for the shoulder after the operation, physiotherapy twice a week",
marks: [[18, 26]],
},
day: "2026-03-02",
path: "/checkups?visit=e2e-visit",
source: null,
},
],
},
],
truncated: false,
},
error: null,
}),
});
});
return queries;
}
const input = (page: Page) =>
page.locator('[data-slot="command-palette-input"]');
async function shellReady(page: Page): Promise<void> {
await expect(page.locator("#main-content")).toBeVisible({ timeout: 20_000 });
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible();
}
test.describe("command palette", () => {
test.beforeEach(async ({ context }, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"palette journeys run on the desktop project (the phone case resizes)",
);
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{ name: "healthlog-locale", value: "en", url: baseURL },
]);
});
test("Cmd/Ctrl+K, sleep, Enter opens the sleep page", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
await shellReady(page);
await page.keyboard.press("ControlOrMeta+k");
await expect(palette(page)).toHaveAttribute("data-variant", "dialog");
await expect(input(page)).toBeFocused();
await input(page).fill("sleep");
// The best hit is the highlighted option.
const active = await input(page).getAttribute("aria-activedescendant");
await expect(page.locator(`[id="${active}"]`)).toHaveAttribute(
"data-entry",
"insights:sleep",
);
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/insights\/sleep$/);
await expect(palette(page)).toHaveCount(0);
});
test("a settings card opens at its anchor", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
await shellReady(page);
await page.keyboard.press("ControlOrMeta+k");
await input(page).fill("passkeys");
await page.locator('[data-entry="settings:security#passkeys"]').click();
await expect(page).toHaveURL(/\/settings\/security#passkeys$/);
await expect(page.locator("#passkeys")).toBeInViewport({ timeout: 10_000 });
});
test("a module is a switch; Enter flips it and the palette stays", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
const writes: unknown[] = [];
await page.route("**/api/auth/me/modules", async (route) => {
if (route.request().method() !== "PATCH") return route.fallback();
writes.push(route.request().postDataJSON());
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
modules: { timeline: true },
updatedAt: new Date().toISOString(),
},
error: null,
}),
});
});
await page.goto("/");
await shellReady(page);
await page.keyboard.press("ControlOrMeta+k");
await input(page).fill("timeline");
const option = page.locator('[data-entry="module:timeline"]');
await expect(option).toHaveAttribute("aria-checked", "false");
await option.hover();
await page.keyboard.press("Enter");
await expect.poll(() => writes.length).toBe(1);
expect(writes[0]).toMatchObject({ timeline: true });
await expect(palette(page)).toBeVisible();
await expect(input(page)).toHaveValue("timeline");
});
test("a hit from the record opens its page", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
const queries = await mockRecordSearch(page);
// Start on the page the hit opens, on another tab: the link has to move
// the open page, not only a fresh one.
await page.goto("/checkups");
await shellReady(page);
await expect(
page.locator('[data-testid="checkups-tab-visits"]'),
).toHaveAttribute("aria-selected", "false");
await page.keyboard.press("ControlOrMeta+k");
await input(page).fill("shoulder");
const hit = page.locator('[data-entry="record:visit:e2e-visit"]');
await expect(hit).toBeVisible();
// Asked once the typing paused, not once per letter.
expect(queries).toEqual(["shoulder"]);
await expect(
page.locator('[data-group="records"] mark').first(),
).toHaveText("Shoulder");
const axe = await new AxeBuilder({ page })
.include('[data-slot="command-palette"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(axe.violations).toEqual([]);
// The arrows reach it after the local options, and Enter opens it.
for (let i = 0; i < 40; i++) {
const active = await input(page).getAttribute("aria-activedescendant");
if (active && (await hit.getAttribute("id")) === active) break;
await page.keyboard.press("ArrowDown");
}
await expect(hit).toHaveAttribute("aria-selected", "true");
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/checkups\?visit=e2e-visit$/);
await expect(palette(page)).toHaveCount(0);
await expect(
page.locator('[data-testid="checkups-tab-visits"]'),
).toHaveAttribute("aria-selected", "true");
});
test("the top bar: a magnifier on a wide desktop, with the shortcut in its tooltip", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
await shellReady(page);
const magnifier = page.locator('[data-slot="command-palette-trigger"]');
await expect(magnifier).toHaveCount(1);
await expect(magnifier).toBeVisible();
await expect(magnifier).toHaveAttribute("aria-label", "Search");
await expect(magnifier).toHaveAttribute(
"aria-keyshortcuts",
/^(Meta|Control)\+K$/,
);
// An icon, not a field: no visible words in the button.
expect((await magnifier.innerText()).trim()).toBe("");
await magnifier.hover();
await expect(
page.locator('[data-slot="command-palette-tooltip"]').first(),
).toHaveText(/^Search \((⌘K|Ctrl K)\)$/);
await magnifier.click();
await expect(palette(page)).toBeVisible();
await expect(input(page)).toBeFocused();
const axe = await new AxeBuilder({ page })
.include('[data-slot="command-palette"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(axe.violations).toEqual([]);
await page.keyboard.press("Escape");
await expect(palette(page)).toHaveCount(0);
});
test("on a phone: the magnifier opens a full-height sheet", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/");
await shellReady(page);
const magnifier = page.locator('[data-slot="command-palette-trigger"]');
await expect(magnifier).toBeVisible();
await expect(magnifier).toHaveAttribute("aria-label", "Search");
await magnifier.click();
await expect(palette(page)).toHaveAttribute("data-variant", "sheet");
await expect(input(page)).toBeFocused();
const box = await palette(page).boundingBox();
expect(box?.height ?? 0).toBeGreaterThanOrEqual(844 - 1);
await input(page).fill("weight");
await expect(page.locator('[data-entry="insights:weight"]')).toBeVisible();
});
test("on a phone: a hit from the record fits the width", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockRecordSearch(page);
await page.goto("/");
await shellReady(page);
await page.locator('[data-slot="command-palette-trigger"]').click();
await input(page).fill("shoulder");
const hit = page.locator('[data-entry="record:visit:e2e-visit"]');
await expect(hit).toBeVisible();
const box = await hit.boundingBox();
expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(390);
const overflow = await page
.locator('[data-slot="command-palette-results"]')
.evaluate((el) => el.scrollWidth - el.clientWidth);
expect(overflow).toBeLessThanOrEqual(0);
await hit.click();
await expect(page).toHaveURL(/\/checkups\?visit=e2e-visit$/);
});
});
+3
View File
@@ -115,6 +115,9 @@ test.describe("authenticated dashboard render", () => {
score: { value: 82, band: "green", delta: 2 },
topSignal: null,
briefingLead: "Your week is trending steady.",
lead: { text: "Your week is trending steady.", source: "briefing" },
today: [],
restMode: null,
line: "Your week is trending steady.",
worthALook: [
{
+330
View File
@@ -0,0 +1,330 @@
import type { Locator, Page, TestInfo } from "@playwright/test";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
import { DAY_DOORS_ANALYTE } from "./setup/day-doors-fixture";
import { mockDay } from "./setup/day-mock";
import {
DAY_DOORS_STORAGE_STATE_PATH,
MOBILE_ROUTES_STORAGE_STATE_PATH,
} from "./setup/global-setup";
import { expect, test } from "./setup/test";
/**
* v1.42 — every chart drawn in days opens its days, the same way.
*
* The day view first reached six metric pages; the recovery page beside them
* offered no day at all. These journeys walk the surfaces that came after,
* each through the same doors: on a fine pointer a click on a point (or a
* calendar cell) opens the day beside the page; on a touch screen the first
* tap shows the value and the tooltip's "View the whole day" opens it as a
* bottom sheet, the tapped value on top. A date in a list opens in one tap.
* The dashed line (or outline) marks the open day, the row of dots and the
* caption sit under every chart, and the data table's dates are the
* keyboard way to the same days.
*
* - recovery (the page the beta reported), sleep, a lab marker, a chart
* with its comparison period on and a dashboard chart: line charts;
* - the mood calendar: a heatmap;
* - a metric's "all values" list: a date in a row.
*
* The day itself is a route mock in the contract's shape, as in
* `day-view.spec.ts`: these journeys pin the client's doors, not the day.
* Stable data attributes only.
*/
const panel = (page: Page) => page.locator('[data-slot="day-panel"]');
const isPhone = (testInfo: TestInfo) =>
testInfo.project.name === "chromium-mobile";
/** Open the day of the point at 80 % across a day-linked plot. */
async function openFromPlot(page: Page, plot: Locator, testInfo: TestInfo) {
await plot.scrollIntoViewIfNeeded();
await expect(plot).toHaveAttribute("data-day-links", "true");
const box = (await plot.boundingBox())!;
const x = box.x + box.width * 0.8;
const y = box.y + box.height / 2;
if (isPhone(testInfo)) {
await page.touchscreen.tap(x, y);
const open = page.locator('[data-slot="chart-tooltip-open-day"]:visible');
await expect(open).toBeVisible();
// A tap alone never opens a sheet: a point is a small target.
await expect(panel(page)).toHaveCount(0);
// The button is a calm, full 44 px row.
expect((await open.boundingBox())!.height).toBeGreaterThanOrEqual(44);
await open.tap();
await expect(panel(page)).toHaveAttribute("data-shell", "bottom");
} else {
await page.mouse.move(x, y);
await page.mouse.click(x, y);
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
}
await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/);
await expect(page.locator('[data-slot="day-view"]')).toBeVisible();
}
test.describe("every chart drawn in days opens its days", () => {
test.use({ storageState: DAY_DOORS_STORAGE_STATE_PATH });
test.beforeEach(async ({ page, context }, testInfo) => {
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{ name: "healthlog-locale", value: "en", url: baseURL },
]);
await mockDay(page, { index: "full" });
});
test("recovery: a point opens its day, and the open day is marked", async ({
page,
}, testInfo) => {
if (!isPhone(testInfo)) {
await page.setViewportSize({ width: 1440, height: 900 });
}
await page.goto("/insights/recovery");
const plot = page
.locator('[data-slot="chart-plot"][data-day-links="true"]')
.first();
await expect(plot).toBeVisible();
// Under the chart: the row of day dots, which draws the
// days the index names, one of them ringed as notable.
const rug = page.locator('[data-slot="day-rug"]').first();
await expect(rug.locator("[data-day]").first()).toBeAttached();
await expect(rug.locator('[data-notable="true"]')).toHaveCount(1);
await openFromPlot(page, plot, testInfo);
const day = await page
.locator('[data-slot="day-view"]')
.getAttribute("data-day");
// The value the person came from heads the day.
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
// The open day's dot is the filled one.
await expect(rug.locator(`[data-day="${day}"]`)).toHaveAttribute(
"data-open",
"true",
);
});
test("recovery: the data table's dates are the keyboard way to a day", async ({
page,
}, testInfo) => {
test.skip(isPhone(testInfo), "keyboard path");
await page.goto("/insights/recovery");
const toggle = page
.locator('[data-slot="chart-data-table-toggle"]')
.first();
await toggle.scrollIntoViewIfNeeded();
await toggle.focus();
await page.keyboard.press("Enter");
const link = page
.locator('[data-slot="chart-data-table-region"] [data-slot="day-link"]')
.first();
await link.focus();
const day = await link.getAttribute("data-day");
await page.keyboard.press("Enter");
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
"data-day",
day!,
);
// Escape closes the day and gives the keyboard back to the date.
await page.keyboard.press("Escape");
await expect(panel(page)).toHaveCount(0);
});
test("sleep: a night's point opens its day", async ({ page }, testInfo) => {
await page.goto("/insights/sleep");
const plot = page
.locator('[data-slot="chart-plot"][data-day-links="true"]')
.first();
await expect(plot).toBeVisible();
await openFromPlot(page, plot, testInfo);
});
test("the mood calendar: a logged day's cell opens it", async ({
page,
}, testInfo) => {
await page.goto("/insights/mood");
// A logged, openable cell: it carries the pointer cursor.
const cell = page.locator("rect.cursor-pointer[data-day]").last();
await cell.scrollIntoViewIfNeeded();
await expect(cell).toBeVisible();
const day = await cell.getAttribute("data-day");
if (isPhone(testInfo)) {
await cell.tap();
const open = page.locator(
'[data-slot="heatmap-tooltip"] [data-slot="chart-tooltip-open-day"]',
);
await expect(open).toBeVisible();
await expect(panel(page)).toHaveCount(0);
await open.tap();
await expect(panel(page)).toHaveAttribute("data-shell", "bottom");
} else {
await cell.click();
}
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
"data-day",
day!,
);
// The open day's cell is outlined.
await expect(page.locator('[data-slot="heatmap-open-day"]')).toHaveCount(1);
});
test("a lab marker's reading opens the day it was taken", async ({
page,
}, testInfo) => {
await page.goto("/labs");
await page
.locator('[data-slot="lab-list-analyte"]')
.filter({ hasText: DAY_DOORS_ANALYTE })
.first()
.click();
const plot = page
.locator('[data-slot="chart-plot"][data-day-links="true"]')
.first();
await expect(plot).toBeVisible();
await openFromPlot(page, plot, testInfo);
// The reading on top, with the one before it beside it.
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
});
test("a chart with its comparison period on still opens its days", async ({
page,
}, testInfo) => {
await page.goto("/insights/weight");
const plot = page
.locator('[data-slot="chart-plot"][data-day-links="true"]')
.first();
await expect(plot).toBeVisible();
await page
.locator('[data-slot="chart-overlay-controls-trigger"]')
.first()
.click();
await page
.locator('[data-slot="chart-overlay-comparison-lastMonth"]')
.click();
await page.keyboard.press("Escape");
// The overlay menu is closed before the chart is touched, so the tap
// lands on the chart and not on the menu's outside-dismiss.
await expect(
page.locator('[data-slot="chart-overlay-controls-content"]'),
).toHaveCount(0);
await expect(
page.locator('[data-slot="chart-compare-caption"]').first(),
).toBeAttached();
await openFromPlot(page, plot, testInfo);
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
});
});
test.describe("the dashboard and the list of all values", () => {
test.use({ storageState: DAY_DOORS_STORAGE_STATE_PATH });
test.beforeEach(async ({ page, context }, testInfo) => {
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{ name: "healthlog-locale", value: "en", url: baseURL },
]);
await mockDay(page, { index: "full" });
});
test("a dashboard chart opens its day; the today hero offers none", async ({
page,
}, testInfo) => {
if (!isPhone(testInfo)) {
await page.setViewportSize({ width: 1440, height: 900 });
}
await page.goto("/");
await expect(
page.locator(
'[data-slot^="today-hero"] [data-slot="day-link"], [data-slot^="today-hero"] [data-day-links="true"]',
),
).toHaveCount(0);
const plot = page
.locator('[data-slot="chart-plot"][data-day-links="true"]')
.first();
await expect(plot).toBeVisible({ timeout: 20_000 });
await openFromPlot(page, plot, testInfo);
// Closing leaves the dashboard on today: a sheet from its close button,
// the docked day from its strip, the only control it has.
await page
.locator(
isPhone(testInfo)
? '[data-slot="day-close"]'
: '[data-slot="day-strip-toggle"]',
)
.click();
await expect(panel(page)).toHaveCount(0);
await expect(page).toHaveURL(/\/$/);
});
test("a date in a metric's list of all values opens its day", async ({
page,
}, testInfo) => {
await page.goto("/insights/values/WEIGHT");
const link = page.locator('[data-slot="day-link"]:visible').first();
await expect(link).toBeVisible();
const day = await link.getAttribute("data-day");
if (isPhone(testInfo)) await link.tap();
else await link.click();
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
"data-day",
day!,
);
});
test("the docked day, open or collapsed, moves the Coach button off it", async ({
page,
}, testInfo) => {
test.skip(isPhone(testInfo), "docked from 1280 px");
await page.setViewportSize({ width: 1440, height: 900 });
// The launcher follows the `coach` capability, which needs a provider
// the suite cannot reach, so the capability reads available here; this
// journey is about where the button sits, not what it opens.
await serveAiBlock(page, aiBlockAvailable());
await page.goto("/insights/weight");
const fab = page.locator('[data-slot="coach-fab"]');
await expect(fab).toBeVisible();
const plot = page
.locator('[data-slot="chart-plot"][data-day-links="true"]')
.first();
await openFromPlot(page, plot, testInfo);
const panelBox = (await panel(page).boundingBox())!;
// Wait out the 150 ms move, then the launcher sits left of the column.
await expect
.poll(async () => {
const b = (await fab.boundingBox())!;
return b.x + b.width;
})
.toBeLessThanOrEqual(panelBox.x);
// Folded, the day keeps its strip; the launcher stays clear of it.
await page.locator('[data-slot="day-strip-toggle"]').click();
await expect(panel(page)).toHaveCount(0);
const edge = (await page.locator('[data-slot="day-strip"]').boundingBox())!;
await expect
.poll(async () => {
const b = (await fab.boundingBox())!;
return b.x + b.width;
})
.toBeLessThanOrEqual(edge.x);
});
});
test.describe("the readiness link into a medication's edit form", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test("?edit=1 opens the medication's edit form and leaves the URL", async ({
page,
}) => {
const res = await page.request.get("/api/medications");
const body = (await res.json()) as {
data: Array<{ id: string }> | { medications?: Array<{ id: string }> };
};
const list = Array.isArray(body.data)
? body.data
: (body.data.medications ?? []);
expect(list.length).toBeGreaterThan(0);
await page.goto(`/medications/${list[0]!.id}?edit=1`);
await expect(page.getByRole("dialog")).toBeVisible();
// A reload after closing the form must not open it again.
await expect(page).not.toHaveURL(/[?&]edit=1/);
});
});
+466
View File
@@ -0,0 +1,466 @@
import type { Page, TestInfo } from "@playwright/test";
import { mockDay } from "./setup/day-mock";
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
/**
* v1.42 — the day view and its doors.
*
* The record is the phone-sweep account `globalSetup` seeds: a week of blood
* pressure, two lab readings, mood and an episode. The day itself
* (`GET /api/day/{date}`, `GET /api/day/index`) is answered by a route mock in
* the contract's shape, so the journeys pin the client: which door opens which
* day, what the history does, and where no door is.
*
* - a chart point opens the day beside the chart (desktop);
* - on a touch screen the first tap shows the value and the tooltip offers
* the day, the second opens it as a bottom sheet;
* - a date in a list opens the day while the row keeps its own target;
* - `?day=` opens the layer on any page, Back and Escape close it, a date in
* the future is dropped;
* - stepping to the neighbouring days never adds history entries;
* - docked, the day folds to its strip, which runs the full height of the
* window right of the top bar, is the panel's only open and close
* control and brings the day back on any page; the search magnifier at
* the end of the top bar is never covered by it; a sheet closes for good
* from its close button;
* - the dashboard's today area offers no door; its charts below do.
*
* Stable data attributes only: `day-panel` (+ `data-shell`), `day-link`,
* the header's `day-date-button` and its `day-date-picker` (days keyed by
* `data-date-key`, dotted by `data-entries`, and `day-date-today`),
* `chart-plot[data-day-links]`, `chart-tooltip-open-day`, `day-prev` /
* `day-next` / `day-close`, and the docked day's strip `day-strip`
* (+ `data-day`, `data-state`) with its `day-strip-toggle`.
*/
const panel = (page: Page) => page.locator('[data-slot="day-panel"]');
async function shot(page: Page, testInfo: TestInfo, name: string) {
const path = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path });
await testInfo.attach(name, { path, contentType: "image/png" });
const dir = process.env.DAY_SHOTS_DIR;
if (dir) {
await page.screenshot({
path: `${dir}/${name}-${testInfo.project.name}.png`,
});
}
}
function isoDaysAgo(days: number): string {
const d = new Date(Date.now() - days * 86_400_000);
return d.toISOString().slice(0, 10);
}
test.describe("the day view", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test.beforeEach(async ({ page, context }, testInfo) => {
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{ name: "healthlog-locale", value: "en", url: baseURL },
]);
await mockDay(page);
});
test("a chart point opens its day beside the chart; Back closes it", async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-desktop", "desktop click");
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/insights/blood-pressure");
const plot = page.locator(
'[data-slot="chart-plot"][data-day-links="true"]',
);
await expect(plot).toBeVisible();
// The plot's right half holds the most recent points.
const box = (await plot.boundingBox())!;
await page.mouse.move(box.x + box.width * 0.8, box.y + box.height / 2);
await page.mouse.click(box.x + box.width * 0.8, box.y + box.height / 2);
await expect(panel(page)).toBeVisible();
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
// A landmark beside the page, named by its date.
await expect(panel(page)).toHaveJSProperty("tagName", "ASIDE");
await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/);
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
// The chart beside it stays usable: the panel is not modal.
await expect(page.locator('[data-slot="sheet-overlay"]')).toHaveCount(0);
await page.goBack();
await expect(panel(page)).toHaveCount(0);
await expect(page).not.toHaveURL(/[?&]day=/);
});
test("a date in a list opens the day; the row keeps its own target", async ({
page,
}) => {
await page.goto("/labs");
await page
.locator('[data-slot="lab-list-analyte"]')
.filter({ hasText: MOBILE_ROUTES_ANALYTE })
.first()
.click();
// The marker's readings, each dated.
await page.locator('a[href$="/values"]').first().click();
const link = page.locator('[data-slot="day-link"]:visible').first();
await expect(link).toBeVisible();
const day = await link.getAttribute("data-day");
const before = page.url();
await link.click();
await expect(panel(page)).toBeVisible();
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
"data-day",
day!,
);
// Same page underneath: the click opened the day, not the reading.
expect(new URL(page.url()).pathname).toBe(new URL(before).pathname);
// The reading the person came from sits on top, with the one before it.
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
});
test("?day= opens the layer on any page; Escape closes it", async ({
page,
}) => {
const day = isoDaysAgo(3);
await page.goto(`/mood?day=${day}`);
await expect(panel(page)).toBeVisible();
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
"data-day",
day,
);
await page.keyboard.press("Escape");
await expect(panel(page)).toHaveCount(0);
await expect(page).toHaveURL(/\/mood$/);
});
test("the day's scores sit below its values, each opening its own page", async ({
page,
}) => {
await page.goto(`/mood?day=${isoDaysAgo(3)}`);
await expect(panel(page)).toBeVisible();
const scores = page.locator('[data-slot="day-scores"]');
await expect(scores).toBeVisible();
const tiles = scores.locator('[data-slot="day-score"]');
await expect(tiles).toHaveCount(2);
await expect(
scores.locator('[data-slot="day-score"][data-score="healthScore"]'),
).toHaveAttribute("href", "/insights/health-score");
await expect(
scores.locator('[data-slot="day-score"][data-score="strain"]'),
).toHaveAttribute("href", "/insights/scores/strain");
// A score the day does not hold has no tile.
await expect(
scores.locator('[data-slot="day-score"][data-score="readiness"]'),
).toHaveCount(0);
// Below the values, above what happened.
const order = await page
.locator(
'[data-slot="day-values"], [data-slot="day-scores"], [data-slot="day-events"]',
)
.evaluateAll((nodes) =>
nodes.map((node) => node.getAttribute("data-slot")),
);
expect(order).toEqual(["day-values", "day-scores", "day-events"]);
});
test("the docked day folds beside its strip and comes back on another page", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"docked from 1280 px",
);
const day = isoDaysAgo(3);
await page.goto(`/mood?day=${day}`);
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
// Docked, the header has no fold button: the strip is the toggle.
await expect(page.locator('[data-slot="day-close"]')).toHaveCount(0);
const rail = page.locator('[data-slot="day-strip"]');
const expand = page.locator('[data-slot="day-strip-toggle"]');
await expect(expand).toHaveAttribute("aria-expanded", "true");
await expand.click();
await expect(panel(page)).toHaveCount(0);
await expect(page).toHaveURL(/\/mood$/);
// The strip stays, holding the day, and focus waits on it.
await expect(rail).toHaveAttribute("data-day", day);
await expect(rail).toHaveAttribute("data-state", "closed");
await expect(expand).toBeFocused();
await expect(expand).toHaveAttribute("aria-expanded", "false");
await expect(expand).toHaveAttribute("aria-controls", "day-docked-panel");
expect(await expand.getAttribute("aria-label")).toMatch(/^Show /);
expect((await rail.boundingBox())!.width).toBeLessThanOrEqual(40);
// Another page: the edge is still there and brings the same day back,
// from the keyboard too.
await page.goto("/labs");
await expect(rail).toHaveAttribute("data-day", day);
await expand.focus();
await page.keyboard.press("Enter");
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
"data-day",
day,
);
await expect(page).toHaveURL(new RegExp(`/labs\\?day=${day}$`));
await expect(panel(page).locator("h2").first()).toBeFocused();
// Open, the strip stays where it was and says so.
await expect(rail).toHaveAttribute("data-state", "open");
await expect(expand).toHaveAttribute("aria-expanded", "true");
});
test("the date opens a calendar; a picked day opens, Today goes to today", async ({
page,
}, testInfo) => {
const desktop = testInfo.project.name === "chromium-desktop";
// The days of the month that hold anything carry a dot.
await mockDay(page, { index: "full" });
const day = isoDaysAgo(5);
await page.goto(`/mood?day=${day}`);
await expect(panel(page)).toHaveAttribute(
"data-shell",
desktop ? "docked" : "bottom",
);
const date = panel(page).locator('[data-slot="day-date-button"]');
const picker = page.locator('[data-slot="day-date-picker"]');
// The date stays one line in the header, the arrows beside it.
await expect(date).toHaveAttribute("aria-expanded", "false");
await expect(panel(page).locator('[data-slot="day-prev"]')).toBeVisible();
await shot(page, testInfo, "day-date-closed");
await date.click();
await expect(picker).toBeVisible();
await expect(date).toHaveAttribute("aria-expanded", "true");
await expect(picker.locator(`[data-date-key="${day}"]`)).toHaveAttribute(
"data-selected-single",
"true",
);
await expect(picker.locator('[data-entries="true"]').first()).toBeVisible();
// Nothing past today can be picked.
const tomorrow = picker.locator(`[data-date-key="${isoDaysAgo(-2)}"]`);
if ((await tomorrow.count()) > 0) await expect(tomorrow).toBeDisabled();
await shot(page, testInfo, "day-date-open");
// Another day of the same month: the 1st, or the 2nd when the open day
// is the 1st itself.
const first = `${day.slice(0, 8)}01`;
const target = first === day ? isoDaysAgo(4) : first;
const start = await page.evaluate(() => window.history.length);
await picker.locator(`[data-date-key="${target}"]`).click();
await expect(picker).toHaveCount(0);
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
"data-day",
target,
);
await expect(page).toHaveURL(new RegExp(`[?&]day=${target}`));
// Like a step: no new history entry, and focus back on the date.
expect(await page.evaluate(() => window.history.length)).toBe(start);
await expect(date).toBeFocused();
// The keyboard: Enter opens, Escape closes the calendar only.
await page.keyboard.press("Enter");
await expect(picker).toBeVisible();
// The calendar has the keyboard before Escape is pressed.
await expect
.poll(() =>
page.evaluate(
() =>
document.activeElement?.closest(
'[role="dialog"]:not([data-slot="day-panel"])',
) != null,
),
)
.toBe(true);
// On a phone the calendar is a sheet over the day's own sheet. Focus
// arrives before Radix has handed it the Escape key: in between, the day
// underneath still holds it. The handover shows as the day's layer
// turning inert (`pointer-events: none`) once the calendar is the top
// layer; wait for that, not for a fixed time. The docked column is no
// dialog and has no such handover.
if (!desktop)
await expect
.poll(() =>
panel(page).evaluate(
(el) =>
new Promise<string>((resolve) =>
requestAnimationFrame(() =>
setTimeout(() => resolve(getComputedStyle(el).pointerEvents)),
),
),
),
)
.toBe("none");
await page.keyboard.press("Escape");
await expect(picker).toHaveCount(0);
await expect(panel(page)).toBeVisible();
await expect(date).toBeFocused();
// Today.
await page.keyboard.press("Space");
await expect(picker).toBeVisible();
await picker.locator('[data-slot="day-date-today"]').click();
await expect(picker).toHaveCount(0);
await expect(panel(page).locator('[data-slot="day-next"]')).toBeDisabled();
});
test("a sheet closes for good and leaves no strip behind", async ({
page,
}, testInfo) => {
const desktop = testInfo.project.name === "chromium-desktop";
// Below 1280 px the day is a sheet from the right; on a phone, from the
// bottom. Neither collapses.
if (desktop) await page.setViewportSize({ width: 1024, height: 800 });
const day = isoDaysAgo(3);
await page.goto(`/mood?day=${day}`);
await expect(panel(page)).toHaveAttribute(
"data-shell",
desktop ? "sheet" : "bottom",
);
await expect(page.locator('[data-slot="day-close"]')).toHaveAttribute(
"aria-label",
"Close day",
);
await page.locator('[data-slot="day-close"]').click();
await expect(panel(page)).toHaveCount(0);
await expect(page).toHaveURL(/\/mood$/);
await expect(page.locator('[data-slot="day-strip"]')).toHaveCount(0);
});
test("the strip runs the full height; the search magnifier stays clear of it", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"docked from 1280 px",
);
const day = isoDaysAgo(3);
const strip = page.locator('[data-slot="day-strip"]');
const search = page.locator('[data-slot="command-palette-trigger"]');
const topBar = page.locator('[data-slot="top-bar"]');
for (const width of [1280, 1440, 1920]) {
await page.setViewportSize({ width, height: 900 });
for (const url of ["/mood", `/mood?day=${day}`]) {
await page.goto(url);
await expect(strip).toBeVisible();
if (url.includes("day=")) {
await expect(panel(page)).toHaveAttribute("data-shell", "docked");
// The open panel runs the full height beside its strip.
await expect
.poll(async () => (await panel(page).boundingBox())?.width)
.toBeGreaterThan(400);
const p = (await panel(page).boundingBox())!;
expect(p.y).toBe(0);
expect(Math.round(p.y + p.height)).toBe(900);
}
const s = (await strip.boundingBox())!;
// Top edge at the very top of the window, down to the bottom.
expect(s.y).toBe(0);
expect(Math.round(s.height)).toBe(900);
expect(Math.round(s.x + s.width)).toBe(width);
// The top bar ends left of the strips, so the magnifier sits fully
// inside it, in front of them.
const bar = (await topBar.boundingBox())!;
expect(bar.x + bar.width).toBeLessThanOrEqual(s.x + 0.5);
await expect(search).toBeVisible();
const m = (await search.boundingBox())!;
expect(m.x).toBeGreaterThanOrEqual(0);
expect(m.y).toBeGreaterThanOrEqual(0);
expect(m.x + m.width).toBeLessThanOrEqual(s.x);
expect(m.y + m.height).toBeLessThanOrEqual(bar.y + bar.height);
// Nothing paints over its centre: the hit test lands on it.
const hit = await page.evaluate(
([x, y]) =>
document
.elementFromPoint(x, y)
?.closest('[data-slot="command-palette-trigger"]') != null,
[m.x + m.width / 2, m.y + m.height / 2],
);
expect(hit).toBe(true);
// No horizontal scroll anywhere on the page.
const overflow = await page.evaluate(
() =>
document.documentElement.scrollWidth -
document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
}
}
});
test("a future or malformed ?day= is dropped without a word", async ({
page,
}) => {
await page.goto("/mood?day=2999-01-01");
await expect(page).toHaveURL(/\/mood$/);
await expect(panel(page)).toHaveCount(0);
await page.goto("/mood?day=2026-02-30");
await expect(page).toHaveURL(/\/mood$/);
await expect(panel(page)).toHaveCount(0);
});
test("stepping between days never adds a history entry", async ({ page }) => {
await page.goto("/mood");
await page.locator('[data-slot="day-link"]:visible').first().click();
await expect(panel(page)).toBeVisible();
const start = await page.evaluate(() => window.history.length);
const first = await page
.locator('[data-slot="day-view"]')
.getAttribute("data-day");
for (let i = 0; i < 3; i += 1) {
await page.locator('[data-slot="day-prev"]').click();
}
await expect(page.locator('[data-slot="day-view"]')).not.toHaveAttribute(
"data-day",
first!,
);
expect(await page.evaluate(() => window.history.length)).toBe(start);
// One Back leaves the layer, however far it stepped.
await page.goBack();
await expect(panel(page)).toHaveCount(0);
await expect(page).toHaveURL(/\/mood$/);
});
test("on a phone the first tap shows the value, the second opens the day", async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "touch only");
await page.goto("/insights/blood-pressure");
const plot = page.locator(
'[data-slot="chart-plot"][data-day-links="true"]',
);
await plot.scrollIntoViewIfNeeded();
await expect(plot).toBeVisible();
const box = (await plot.boundingBox())!;
await page.touchscreen.tap(box.x + box.width * 0.8, box.y + box.height / 2);
const open = page.locator('[data-slot="chart-tooltip-open-day"]');
await expect(open).toBeVisible();
// A tap alone never opens a sheet.
await expect(panel(page)).toHaveCount(0);
await open.tap();
await expect(panel(page)).toBeVisible();
await expect(panel(page)).toHaveAttribute("data-shell", "bottom");
// The tapped value heads the day.
await expect(page.locator('[data-slot="day-focus"]')).toBeVisible();
});
test("the dashboard's today area offers no day door", async ({ page }) => {
await page.goto("/");
await expect(
page.locator('[data-slot="main-content-wrapper"]'),
).toBeVisible();
// The hero is today: nothing in it opens another day. (The charts
// further down do; `day-doors.spec.ts` walks one.)
await expect(
page.locator('[data-slot^="today-hero"]').first(),
).toBeVisible();
await expect(
page.locator(
'[data-slot^="today-hero"] [data-slot="day-link"], [data-slot^="today-hero"] [data-day-links="true"]',
),
).toHaveCount(0);
});
});
+8 -1
View File
@@ -221,7 +221,14 @@ test.describe("delegated writes", () => {
// reads "Add") and then for a `value` input (the form opens on blood
// pressure, which has three). Both were wrong from the day they were
// written and nobody found out, because the whole file was skipping.
await page.locator('[data-slot="measurement-add"]').click();
// The owner's list may be empty, in which case its empty state carries
// the add action and the header drops its own.
await page
.locator(
'[data-slot="measurement-add"], [data-slot="measurement-add-first"]',
)
.first()
.click();
await page.locator("#sys").fill("124");
await page.locator("#dia").fill("78");
await page.getByRole("button", { name: /^save$/i }).click();
+6 -4
View File
@@ -70,13 +70,15 @@ const DIALOGS: DialogCase[] = [
{
name: "measurements add",
path: "/measurements",
trigger: /^add$/i,
// While a list is empty the header drops its add button and the empty
// state carries the action, so either one opens the dialog.
trigger: /^(add|add your first measurement)$/i,
ready: '[data-slot="date-time-field"]',
},
{
name: "mood add",
path: "/mood",
trigger: /^add$/i,
trigger: /^(add|log your first mood)$/i,
// The mood sheet opens on the five-face quick check; the timestamp row
// (and everything else) appears once a face is picked. Measuring the
// quick check alone would measure a sheet that has almost nothing in it.
@@ -92,7 +94,7 @@ const DIALOGS: DialogCase[] = [
{
name: "labs add",
path: "/labs",
trigger: /^add$/i,
trigger: /^(add|add your first result)$/i,
ready: '[data-slot="date-time-field"]',
},
// The labs sheet's footer holds three buttons (Cancel, Save & add another,
@@ -101,7 +103,7 @@ const DIALOGS: DialogCase[] = [
{
name: "labs add (German)",
path: "/labs",
trigger: /^hinzufügen$/i,
trigger: /^(hinzufügen|ersten wert erfassen)$/i,
ready: '[data-slot="date-time-field"]',
locale: "de",
},
+1 -1
View File
@@ -427,7 +427,7 @@ test.describe("document vault — AI assist + content search", () => {
// The PDF is refused locally (tesseract reads images only) — the error
// shows and the server suggest route is never called.
await expect(
sheet.getByText("Local OCR reads images only — not this file."),
sheet.getByText("Local OCR reads images only, not this file."),
).toBeVisible();
await page.waitForTimeout(300);
expect(suggestCalls).toBe(0);
+131
View File
@@ -0,0 +1,131 @@
/**
* Document vault — the procedure filter.
*
* The facet beside the condition filter: it lists the procedures that hold a
* document, labelled the way the visits page heads them, narrows the list to
* one procedure's documents through the real `encounterId` filter, writes the
* choice to the URL (`?encounter=`), comes back from a deep link, and clears
* with the one clear control. Run at a 1440 px desktop and a 390 px phone,
* where the filter row must stay a single line with no sideways scroll.
*
* Fixture: `ensureProcedureFixture` (two procedures over a namespaced trio of
* documents), upserted before every test because the visits spec clears the
* account's visits; the dates sit far outside that spec's ±7-day window.
*/
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
ensureProcedureFixture,
ensureVaultFixture,
PROCEDURE_DOC_PREFIX,
PROCEDURE_KNEE_ID,
} from "./setup/vault-fixture";
function card(page: Page, n: number) {
return page
.getByRole("button", {
name: `Open ${PROCEDURE_DOC_PREFIX} ${String(n).padStart(3, "0")}`,
})
.first();
}
/** The filter row is one line and never scrolls sideways. */
async function expectOneRow(page: Page) {
const bar = page.locator('[data-slot="document-filter-bar"]');
const row = bar.locator("> div").first();
const box = await row.boundingBox();
expect(box).not.toBeNull();
expect(box!.height).toBeLessThan(48);
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - window.innerWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
}
async function shot(page: Page, name: string, isMobile: boolean) {
const dir = process.env.E2E_SHOT_DIR;
if (!dir) return;
await page.screenshot({
path: `${dir}/${name}-${isMobile ? "390" : "1440"}.png`,
animations: "disabled",
});
}
test.describe("document vault — procedure filter", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeAll(async () => {
await ensureVaultFixture();
});
test.beforeEach(async ({ page, isMobile }) => {
await ensureProcedureFixture();
await page.setViewportSize(
isMobile ? { width: 390, height: 844 } : { width: 1440, height: 900 },
);
});
test("picks a procedure, narrows to its documents, and clears", async ({
page,
isMobile,
}) => {
await page.goto(`/documents?q=${PROCEDURE_DOC_PREFIX}`);
await expect(card(page, 3)).toBeVisible();
await expect(card(page, 1)).toBeVisible();
const trigger = page.locator('[data-slot="document-procedure-filter"]');
await expect(trigger).toBeVisible();
await expect(trigger).toHaveAttribute("aria-label", "All procedures");
await expectOneRow(page);
await shot(page, "procedure-filter-idle", isMobile);
await trigger.click();
const menu = page.getByRole("menu");
await expect(menu).toBeVisible();
await expect(menu).toContainText("Procedures and surgeries");
// Headed like the visits page, with the body site and side beneath.
const knee = menu.getByRole("menuitemcheckbox", {
name: /Knee arthroscopy/,
});
await expect(knee).toContainText("Knee (Left)");
await expect(
menu.getByRole("menuitemcheckbox", { name: /Appendectomy/ }),
).toBeVisible();
await shot(page, "procedure-filter-menu", isMobile);
await knee.click();
await expect(page).toHaveURL(new RegExp(`encounter=${PROCEDURE_KNEE_ID}`));
await expect(card(page, 1)).toBeVisible();
await expect(card(page, 2)).toBeVisible();
await expect(card(page, 3)).toHaveCount(0);
await expect(trigger).toHaveAttribute("aria-label", "Knee arthroscopy");
// Picked: the label shows at every width, phone included.
await expect(trigger).toContainText("Knee arthroscopy");
await expectOneRow(page);
// The search field never collapses under the facets.
const search = await page
.getByRole("searchbox", { name: "Search documents" })
.boundingBox();
expect(search!.width).toBeGreaterThanOrEqual(48);
await shot(page, "procedure-filter-active", isMobile);
await page.getByRole("button", { name: "Clear filters" }).click();
await expect(page).not.toHaveURL(/encounter=/);
await expect(trigger).toHaveAttribute("aria-label", "All procedures");
});
test("a deep link lands filtered and the facet names the procedure", async ({
page,
}) => {
await page.goto(
`/documents?q=${PROCEDURE_DOC_PREFIX}&encounter=${PROCEDURE_KNEE_ID}`,
);
await expect(card(page, 2)).toBeVisible();
await expect(card(page, 3)).toHaveCount(0);
await expect(
page.locator('[data-slot="document-procedure-filter"]'),
).toHaveAttribute("aria-label", "Knee arthroscopy");
});
});
+97 -1
View File
@@ -151,6 +151,61 @@ test.describe("document vault", () => {
await ensureVaultFixture();
});
// ── v1.42 — the archive as a month grid of preview tiles ──────────────
for (const [width, columns] of [
[390, 2],
[1440, 4],
] as const) {
test(`the archive is a month grid of preview tiles, ${columns} across at ${width} px`, async ({
page,
}) => {
await page.setViewportSize({ width, height: width === 390 ? 844 : 900 });
await page.goto("/documents");
const firstRow = page.locator('[data-slot="document-month-row"]').first();
await expect(firstRow).toBeVisible({ timeout: 15_000 });
// The months run side by side by default: a month is marked above
// its first document instead of taking a heading row of its own.
await expect(
page.locator('[data-slot="document-timeline"]'),
).toHaveAttribute("data-arrangement", "flow");
await expect(
page.locator('[data-slot="document-flow-month"]').first(),
).toBeVisible();
// Tiles with the preview on top, and at most `columns` side by side:
// the fullest mounted row holds exactly that many, on one line.
const tiles = page.locator(
'[data-slot="document-month-row"] [data-slot="document-card"]',
);
await expect(tiles.first()).toHaveAttribute("data-variant", "tile");
await expect(
tiles.first().locator('[data-slot="document-preview"]'),
).toBeVisible();
const rows = await page
.locator('[data-slot="document-month-row"]')
.evaluateAll((els) =>
els.map((row) =>
Array.from(row.querySelectorAll('[data-slot="document-card"]')).map(
(el) => Math.round(el.getBoundingClientRect().top),
),
),
);
const fullest = rows.reduce((a, b) => (b.length > a.length ? b : a), []);
expect(fullest.length).toBe(columns);
expect(new Set(fullest).size).toBe(1);
// Nothing on the page scrolls sideways.
const overflow = await page.evaluate(
() =>
document.documentElement.scrollWidth -
document.documentElement.clientWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
});
}
// ── §1A — the 30-second doctor flow, three independent routes ─────────
test("doctor flow A: the type filter reaches the MRT report", async ({
@@ -177,6 +232,45 @@ test.describe("document vault", () => {
await expect(sheet.locator("iframe")).toBeVisible({ timeout: 5_000 });
});
test("a PDF opens in a new browser tab from the fetched bytes", async ({
page,
}) => {
// Headless Chromium has no PDF viewer and turns a PDF tab into a
// download, so the tab itself is not observable here. Record what the
// control hands to `window.open` instead, and read the bytes behind it.
await page.addInitScript(() => {
const calls: unknown[][] = [];
(window as unknown as { __opened: unknown[][] }).__opened = calls;
window.open = (...args: unknown[]) => {
calls.push(args);
return null;
};
});
await page.goto(`/documents?episode=${KNIE_EPISODE_ID}&view=compact&q=MRT`);
await openButton(page, "MRT Knie").click();
const sheet = page.getByRole("dialog", { name: "MRT Knie" });
await expect(sheet.locator("iframe")).toBeVisible({ timeout: 5_000 });
const open = sheet.locator('[data-slot="document-open-in-tab"]');
// Enabled once the preview's bytes are in hand.
await expect(open).toBeEnabled();
if (process.env.E2E_SHOT_DIR) {
await page.screenshot({
path: `${process.env.E2E_SHOT_DIR}/open-in-tab-${test.info().project.name}.png`,
});
}
await open.click();
const opened = await page.evaluate(
() => (window as unknown as { __opened: string[][] }).__opened,
);
// The object URL the preview already shows, not the serve route: a bare
// navigation would carry no record-session assertion.
const previewSrc = await sheet.locator("iframe").getAttribute("src");
expect(previewSrc).toMatch(/^blob:/);
expect(opened).toEqual([[previewSrc, "_blank", "noopener"]]);
// The sheet stays where it was.
await expect(sheet).toBeVisible();
});
test("doctor flow B: the condition filter reaches the MRT report", async ({
page,
}) => {
@@ -413,6 +507,8 @@ test.describe("document vault", () => {
const sheet = page.getByRole("dialog", { name: `${title}.pdf` });
await expect(sheet).toBeVisible();
await sheet.getByRole("button", { name: "Delete" }).click();
// v1.42 — the sheet's Delete asks first, like every vault delete.
await page.locator('[data-slot="document-detail-delete-confirm"]').click();
// Undo toast → restore → the card returns.
//
@@ -714,7 +810,7 @@ test.describe("document vault", () => {
await uploadViaPicker(page, `${marker}-copy.pdf`, bytes);
await expect(
page.getByText("Already stored — highlighting the existing document."),
page.getByText("Already stored. The existing document is highlighted."),
).toBeVisible();
// One row, not two — the copy never landed.
await expect(openButton(page, `${marker}-copy.pdf`)).not.toBeVisible();
+310
View File
@@ -0,0 +1,310 @@
import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { mockDay } from "./setup/day-mock";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { expect, test } from "./setup/test";
import { mockTimeline } from "./utils/mock-timeline";
/**
* v1.42 — the keyboard shortcuts.
*
* - `g t` opens the timeline while its module is on, and does nothing while
* it is off;
* - keys typed into a field stay in the field;
* - `?` opens the list: a titled dialog that keeps focus inside and closes
* on Escape; the account menu opens the same list;
* - `n` opens the add menu;
* - `[` and `]` step the open day, never past today;
* - `g p` opens and closes the day like a click on its strip (focus into
* the panel, back to the strip); below the docking width it opens the
* sheet on the last day and closes it again.
*
* The module map is the account's real `/api/auth/me` with only `timeline`
* rewritten, so the switch is never moved on a shared account. Desktop only:
* a phone has no keyboard to press these on.
*/
test.use({ storageState: STORAGE_STATE_PATH });
const TODAY = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
function shiftDay(key: string, delta: number): string {
const d = new Date(`${key}T12:00:00Z`);
d.setUTCDate(d.getUTCDate() + delta);
return d.toISOString().slice(0, 10);
}
async function withTimelineModule(page: Page, on: boolean): Promise<void> {
await page.route("**/api/auth/me", async (route) => {
const res = await route.fetch();
const json = await res.json().catch(() => null);
if (!json?.data?.modules) return route.fulfill({ response: res });
json.data.modules.timeline = on;
return route.fulfill({
response: res,
body: JSON.stringify(json),
headers: { ...res.headers(), "content-type": "application/json" },
});
});
}
/** The shell is up and its listener registered. */
async function shellReady(page: Page): Promise<void> {
await expect(page.locator("#main-content")).toBeVisible({ timeout: 20_000 });
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible();
}
const helpDialog = (page: Page) =>
page.getByRole("dialog", { name: "Keyboard shortcuts" });
test.describe("keyboard shortcuts", () => {
test.beforeEach(async ({ context }, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"keyboard journeys run on the desktop project",
);
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{ name: "healthlog-locale", value: "en", url: baseURL },
]);
});
test("g t opens the timeline while its module is on", async ({ page }) => {
await withTimelineModule(page, true);
await mockTimeline(page, "ready", TODAY);
await page.goto("/");
await shellReady(page);
await page.keyboard.press("g");
await page.keyboard.press("t");
await expect(page).toHaveURL(/\/timeline$/);
});
test("g t does nothing while the timeline module is off", async ({
page,
}) => {
await withTimelineModule(page, false);
// Every client navigation, in order, so "nothing happened" is a fact
// rather than a wait.
await page.addInitScript(() => {
const log: string[] = [];
(window as unknown as { __pushed: string[] }).__pushed = log;
const push = history.pushState.bind(history);
history.pushState = (state, unused, url) => {
if (url != null) log.push(String(url));
push(state, unused, url);
};
});
await page.goto("/insights");
await shellReady(page);
await page.keyboard.press("g");
await page.keyboard.press("t");
// The positive control: the same reader opens a page that is on.
await page.keyboard.press("g");
await page.keyboard.press("d");
await expect(page).toHaveURL(/\/$/);
const pushed = await page.evaluate(
() => (window as unknown as { __pushed: string[] }).__pushed,
);
expect(pushed.some((url) => url.includes("/timeline"))).toBe(false);
});
test("keys typed into a field stay in the field", async ({ page }) => {
await withTimelineModule(page, true);
await page.goto("/");
const main = page.locator("#main-content");
await expect(main).toBeVisible({ timeout: 20_000 });
// A plain field in the page: the rule is about the focused element, not
// about which page carries it.
await page.evaluate(() => {
const field = document.createElement("input");
field.setAttribute("data-testid", "probe-field");
field.setAttribute("aria-label", "Probe");
document.getElementById("main-content")?.prepend(field);
});
const field = page.getByTestId("probe-field");
await expect(field).toBeVisible();
await field.focus();
await page.keyboard.type("gt n ? [");
await expect(field).toHaveValue("gt n ? [");
await expect(page).toHaveURL(/\/$/);
await expect(helpDialog(page)).toHaveCount(0);
await expect(page.getByTestId("capture-picker-options")).toHaveCount(0);
});
test("? opens a titled list that keeps focus and closes on Escape", async ({
page,
}) => {
await page.goto("/");
await shellReady(page);
await page.keyboard.press("Shift+?");
const dialog = helpDialog(page);
await expect(dialog).toBeVisible();
await expect(
dialog.locator('[data-slot="keyboard-shortcuts"] [data-group]'),
).toHaveCount(3);
await expect(dialog.locator('[data-shortcut="go-d"] kbd')).toHaveText([
"g",
"d",
]);
await expect(
dialog.locator('[data-group="day"] [data-shortcut="day-toggle"] kbd'),
).toHaveText(["g", "p"]);
// Focus trap: Tab and Shift+Tab never leave the dialog.
for (const step of ["Tab", "Tab", "Tab", "Shift+Tab", "Shift+Tab"]) {
await page.keyboard.press(step);
expect(
await dialog.evaluate((el) => el.contains(document.activeElement)),
).toBe(true);
}
// While the list is open, the page's shortcuts are off.
await page.keyboard.press("g");
await page.keyboard.press("i");
await expect(page).toHaveURL(/\/$/);
const axe = await new AxeBuilder({ page })
.include('[role="dialog"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(axe.violations).toEqual([]);
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
});
test("the account menu opens the same list", async ({ page }) => {
await page.goto("/");
await shellReady(page);
await page
.getByRole("button", { name: "User menu" })
.locator("visible=true")
.first()
.click();
await page.locator('[data-slot="open-keyboard-shortcuts"]').click();
await expect(helpDialog(page)).toBeVisible();
});
test("n opens the add menu", async ({ page }) => {
await page.goto("/insights");
await shellReady(page);
await page.keyboard.press("n");
await expect(page.getByTestId("capture-picker-options")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("capture-picker-options")).toHaveCount(0);
});
test("[ and ] step the open day, never past today", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockDay(page);
const yesterday = shiftDay(TODAY, -1);
await page.goto(`/insights?day=${yesterday}`);
await shellReady(page);
const panel = page.locator('[data-slot="day-panel"]');
await expect(panel).toHaveAttribute("data-shell", "docked");
// Focus on the page, not in the panel: docked, the keys still reach it.
await page.locator("#main-content").focus();
await page.keyboard.press("[");
await expect(page).toHaveURL(new RegExp(`[?&]day=${shiftDay(TODAY, -2)}`));
await page.keyboard.press("]");
await page.keyboard.press("]");
await expect(page).toHaveURL(new RegExp(`[?&]day=${TODAY}`));
await page.keyboard.press("]");
await expect(page).toHaveURL(new RegExp(`[?&]day=${TODAY}`));
});
test("g p opens and closes the docked day, focus like its strip", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockDay(page);
await page.goto("/insights");
await shellReady(page);
const panel = page.locator('[data-slot="day-panel"]');
const strip = page.locator('[data-slot="day-strip-toggle"]');
await expect(strip).toHaveAttribute("aria-expanded", "false");
await expect(panel).toHaveCount(0);
await page.keyboard.press("g");
await page.keyboard.press("p");
await expect(panel).toHaveAttribute("data-shell", "docked");
await expect(page).toHaveURL(new RegExp(`[?&]day=${TODAY}`));
await expect(strip).toHaveAttribute("aria-expanded", "true");
// Into the panel, on its heading, the way the strip opens it.
await expect(panel.locator("h2").first()).toBeFocused();
await page.keyboard.press("g");
await page.keyboard.press("p");
await expect(panel).toHaveCount(0);
await expect(page).toHaveURL(/\/insights$/);
await expect(strip).toHaveAttribute("aria-expanded", "false");
// Back on the strip, which brings the day back.
await expect(strip).toBeFocused();
});
test("below the docking width g p opens the sheet on the last day and closes it", async ({
page,
}) => {
await page.setViewportSize({ width: 1024, height: 800 });
await mockDay(page);
const day = shiftDay(TODAY, -3);
// Open and close a day once, so it is the one remembered.
await page.goto(`/insights?day=${day}`);
await shellReady(page);
const panel = page.locator('[data-slot="day-panel"]');
await expect(panel).toHaveAttribute("data-shell", "sheet");
await page.keyboard.press("Escape");
await expect(panel).toHaveCount(0);
await page.locator("#main-content").focus();
await page.keyboard.press("g");
await page.keyboard.press("p");
await expect(panel).toHaveAttribute("data-shell", "sheet");
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
"data-day",
day,
);
// Over the sheet, the same keys close it.
await page.keyboard.press("g");
await page.keyboard.press("p");
await expect(panel).toHaveCount(0);
await expect(page).toHaveURL(/\/insights$/);
});
test("g p stays out of a field and out of another dialog", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockDay(page);
await page.goto("/insights");
await shellReady(page);
const panel = page.locator('[data-slot="day-panel"]');
await expect(page.locator('[data-slot="day-strip"]')).toBeVisible();
await page.evaluate(() => {
const field = document.createElement("input");
field.setAttribute("data-testid", "probe-field");
field.setAttribute("aria-label", "Probe");
document.getElementById("main-content")?.prepend(field);
});
await page.getByTestId("probe-field").focus();
await page.keyboard.type("gp");
await expect(page.getByTestId("probe-field")).toHaveValue("gp");
await expect(panel).toHaveCount(0);
await page.locator("#main-content").focus();
await page.keyboard.press("Shift+?");
await expect(helpDialog(page)).toBeVisible();
await page.keyboard.press("g");
await page.keyboard.press("p");
await expect(panel).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(helpDialog(page)).toHaveCount(0);
});
});
+140
View File
@@ -0,0 +1,140 @@
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
import {
expectNoHorizontalOverflow,
settleForOverflowMeasurement,
} from "./utils/horizontal-overflow";
/**
* The lab list uses the width it has, at every width, inside a frame that does
* not move.
*
* The signed-in frame is 1280 px wide on purpose, so a page never jumps
* sideways when you move between routes. Inside it the compact list was one
* five-column row whose only flexible column was the name: on a wide window the
* name column sat mostly empty while the range bar and the trend stayed small
* at the right edge. The columns now follow the width of the list's own card
* (a container query), not the window's:
*
* - below 64 rem of card the row is the `lg` table it always was, and the
* range bar keeps its 12 rem;
* - from 64 rem the bar follows its column up to 18 rem, and the trend
* column stays as wide as its 72 px sparkline.
*
* From `lg` a long name wraps instead of being cut off. The tiers are measured
* on the card's width and not the viewport's, so the spec reads the card and
* asserts the tier that width belongs to; a width at which no card ever reaches
* 64 rem would otherwise pass the wide check by never running it.
*
* The account is the one `globalSetup` seeds for the mobile route sweep: a long
* analyte name, readings with reference ranges. Desktop only; the phone layout
* has its own specs.
*/
const WIDTHS = [1280, 1920, 3440] as const;
const REM = 16;
const BAR = '[data-slot="lab-reference-range-bar"]';
test.describe("the lab list uses its width", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test.beforeEach(async ({ context }, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"desktop-only spec",
);
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{ name: "healthlog-locale", value: "de", url: baseURL },
]);
});
for (const width of WIDTHS) {
test(`at ${width} px: no sideways scroll, names read in full, the bar follows its tier`, async ({
page,
}) => {
await page.setViewportSize({ width, height: 1000 });
await page.goto("/labs", { waitUntil: "domcontentloaded" });
const names = page.locator('[data-slot="lab-list-analyte"]');
await expect(names.first()).toBeVisible();
await settleForOverflowMeasurement(page);
await expectNoHorizontalOverflow(page, `/labs at ${width}px`);
// The long name is the point of the wrap: it is fully on screen.
const long = page
.locator('[data-slot="lab-list-analyte"]')
.filter({ hasText: MOBILE_ROUTES_ANALYTE });
await expect(long.first()).toBeVisible();
const clipped = await names.evaluateAll(
(nodes) =>
nodes.filter((node) => node.scrollWidth > node.clientWidth + 1)
.length,
);
expect(clipped, "an analyte name is cut off").toBe(0);
const cardWidth = await page
.locator('[data-slot="lab-list"] [data-slot="card"]')
.first()
.evaluate((node) => node.getBoundingClientRect().width);
const bars = page.locator(BAR);
expect(await bars.count()).toBeGreaterThan(0);
const barWidths = await bars.evaluateAll((nodes) =>
nodes.map((node) => node.getBoundingClientRect().width),
);
if (cardWidth < 64 * REM) {
// The narrow tier: exactly what the list had, 12 rem.
for (const w of barWidths) expect(Math.round(w)).toBe(12 * REM);
} else {
// The wide tier: wider than 12 rem, no wider than its column's cap.
for (const w of barWidths) {
expect(w).toBeGreaterThan(12 * REM);
expect(w).toBeLessThanOrEqual(18 * REM + 1);
}
}
// The trend column is exactly as wide as the sparkline it holds, in both
// tiers. The sparkline is a fixed 72 px glyph; a wider column would only
// open an empty strip between it and the chevron.
const tracks = await page
.locator('[data-slot="lab-list"] [data-slot="card-content"]')
.first()
.evaluate((node) => getComputedStyle(node).gridTemplateColumns);
const trendTrack = tracks.split(" ")[3];
expect(trendTrack, `trend column in "${tracks}"`).toBe("72px");
// The widest windows must actually reach the wide tier, or the branch
// above never ran.
if (width >= 1920) {
expect(
cardWidth,
"the card never reached 64 rem",
).toBeGreaterThanOrEqual(64 * REM);
}
});
}
test("the frame is the same width on /labs, /measurements and /mood at every viewport", async ({
page,
}) => {
for (const width of WIDTHS) {
await page.setViewportSize({ width, height: 1000 });
const frameWidths: Record<string, number> = {};
for (const path of ["/labs", "/measurements", "/mood"]) {
await page.goto(path, { waitUntil: "domcontentloaded" });
const frame = page.locator('[data-slot="main-content-wrapper"]');
await expect(frame).toBeVisible();
frameWidths[path] = Math.round(
await frame.evaluate((node) => node.getBoundingClientRect().width),
);
}
const widths = Object.values(frameWidths);
expect(
new Set(widths).size,
`the frame differs between routes at ${width}px: ${JSON.stringify(frameWidths)}`,
).toBe(1);
}
});
});
+7 -1
View File
@@ -135,7 +135,13 @@ test.describe("add measurement flow", () => {
// v1.5 phase-5: the menu items now have distinct labels — the
// measurement entry says "Measurement" / "Messung" instead of "Add",
// so we can target it directly.
await page.getByRole("menuitem", { name: /measurement|messung/i }).click();
// The desktop rail opens a dropdown, the phone shell the capture sheet
// the bottom nav's plus opens; the measurement entry is in either.
await page
.getByRole("menuitem", { name: /measurement|messung/i })
.or(page.getByRole("button", { name: /^(measurement|messung)\b/i }))
.first()
.click();
// Form should be visible inside the dialog. Switch type to WEIGHT
// (combo-box defaults to BLOOD_PRESSURE).
+7
View File
@@ -9,6 +9,7 @@ import {
PHONE_WIDTHS,
settleForOverflowMeasurement,
} from "./utils/horizontal-overflow";
import { revealDeferredSections } from "./utils/deferred-sections";
/**
* The content-heavy routes must not scroll sideways at any phone width.
@@ -73,6 +74,9 @@ test.describe("content-heavy routes have no horizontal page scroll at phone widt
for (const width of PHONE_WIDTHS) {
await page.setViewportSize({ width, height: 851 });
await page.goto(routeCase.path, { waitUntil: "domcontentloaded" });
// Measure the whole page, the overview sections that wait for the
// viewport included, or a wide card far down would go unmeasured.
if (routeCase.path === "/insights") await revealDeferredSections(page);
await expect(page.locator(routeCase.ready).first()).toBeVisible();
await settleForOverflowMeasurement(page);
await expectNoHorizontalOverflow(page, `${routeCase.path} @${width}px`);
@@ -93,6 +97,7 @@ test.describe("content-heavy routes have no horizontal page scroll at phone widt
await mockPopulatedInsights(page);
await page.setViewportSize({ width: 390, height: 851 });
await page.goto("/insights", { waitUntil: "domcontentloaded" });
await revealDeferredSections(page);
const signals = page.locator('[data-slot="coincident-deviation-section"]');
await expect(signals).toBeVisible();
@@ -129,6 +134,7 @@ test.describe("content-heavy routes have no horizontal page scroll at phone widt
await mockPopulatedInsights(page);
await page.setViewportSize({ width: 390, height: 851 });
await page.goto("/insights", { waitUntil: "domcontentloaded" });
await revealDeferredSections(page);
// Wait on the CAPTION, not the heading: the card first paints a skeleton
// whose heading carries no action slot, then swaps to the resolved shell.
@@ -179,6 +185,7 @@ test.describe("content-heavy routes have no horizontal page scroll at phone widt
await mockPopulatedInsights(page);
await page.setViewportSize({ width: 390, height: 851 });
await page.goto("/insights", { waitUntil: "domcontentloaded" });
await revealDeferredSections(page);
// Wait for the same readiness signal and settle step every route case
// above uses. This was the one test in the file that measured and injected
// straight after `domcontentloaded`, against a tree that was still
+2
View File
@@ -48,6 +48,8 @@ const NOT_SIGNED_IN: Record<string, string> = {
"/invite/[token]":
"an invitation landing page for somebody without an account",
"/c/[token]": "the clinician share-link page, opened without a session",
"/claim/[token]":
"the managed-profile handover link, opened by the person without a session (covered by v142-managed-profile-handover.spec.ts)",
"/confirm-access": "the re-proof page, reached only from a stale session",
"/enroll-mfa": "the forced second-factor enrolment of a policy-bound account",
"/onboarding": "the first-run setup flow; covered by setup-flow-*.spec.ts",
+3
View File
@@ -25,6 +25,7 @@ import type { Page } from "@playwright/test";
import { MODULES_STORAGE_STATE_PATH } from "./setup/global-setup";
import { modulesSeedState } from "./setup/modules-fixture";
import { expect, test } from "./setup/test";
import { revealDeferredSections } from "./utils/deferred-sections";
test.use({ storageState: MODULES_STORAGE_STATE_PATH });
test.describe.configure({ mode: "serial" });
@@ -130,6 +131,8 @@ test("mood and medications, switched off, leave every surface they own", async (
await expect(
page.locator('[data-slot="insights-tab-strip-pill"][href="/insights"]'),
).toBeVisible({ timeout: 20_000 });
// The trends row sits below the sections that mount with the page.
await revealDeferredSections(page);
await expect(page.locator('[data-slot="trends-row"]')).toBeVisible();
await expect(page.locator(moodPill)).toHaveCount(0);
await expect(page.locator(medsPill)).toHaveCount(0);
+3 -1
View File
@@ -32,7 +32,9 @@ test.describe("mood timestamp capture", () => {
});
await page.goto("/mood", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "Add", exact: true }).click();
// The list is mocked empty, so its empty state carries the add action
// (the header drops its own while there is nothing to list).
await page.getByRole("button", { name: "Log your first mood" }).click();
await page.getByRole("radio", { name: "Amazing" }).click();
const details = page.getByRole("button", { name: "Note & details" });
+17 -5
View File
@@ -141,7 +141,10 @@ test.describe("v1.4.33 F2 — onboarding tour passes clicks through to the page"
// so a naive "dim everything outside the spotlight rect" overlay
// covers it. The fix makes the entire tour layer pointer-events:
// none — every dim panel and the spotlight ring are visual only.
const quickAddButton = page.locator('[data-tour-id="dashboard-quick-add"]');
// One add button per shell carries the tour id; the other is hidden.
const quickAddButton = page
.locator('[data-tour-id="dashboard-quick-add"]')
.filter({ visible: true });
await expect(quickAddButton).toBeVisible();
// Forensic guard — at the quick-add button's centre, every element
@@ -153,9 +156,11 @@ test.describe("v1.4.33 F2 — onboarding tour passes clicks through to the page"
// the stack is blocking, instead of just observing the symptom
// ("button click did not toggle aria-expanded").
const tourBlockers = await page.evaluate(() => {
const btn = document.querySelector<HTMLElement>(
'[data-tour-id="dashboard-quick-add"]',
);
const btn = [
...document.querySelectorAll<HTMLElement>(
'[data-tour-id="dashboard-quick-add"]',
),
].find((el) => el.getClientRects().length > 0);
if (!btn) return ["no-button"];
const rect = btn.getBoundingClientRect();
const x = rect.left + rect.width / 2;
@@ -192,7 +197,14 @@ test.describe("v1.4.33 F2 — onboarding tour passes clicks through to the page"
await quickAddButton.click();
await expect(quickAddButton).toHaveAttribute("aria-expanded", "true");
await expect(page.getByRole("menuitem").first()).toBeVisible();
// The desktop rail opens a dropdown; the phone shell opens the capture
// sheet the bottom nav's plus opens. Either proves the click landed.
await expect(
page
.getByRole("menuitem")
.or(page.locator('[role="dialog"][data-state="open"]'))
.first(),
).toBeVisible();
// The tour stays mounted — the click went to the underlying button,
// not anything in the tour layer.
+239
View File
@@ -0,0 +1,239 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* The installed-app behaviours a phone depends on, measured in the Pixel 5
* project (a touch screen: `pointer: coarse`).
*
* 1. The "new version" hint stands above the bottom bar instead of on it,
* and its Reload action reloads the page.
* 2. With the on-screen keyboard open, a bottom sheet stands on the
* keyboard (its Save button stays visible) and the bottom bar steps
* aside. Playwright has no on-screen keyboard, so the visual viewport is
* replaced by a stand-in whose height the test shrinks the way a
* keyboard does; what is asserted is the page's answer to that signal.
* 3. No form field a phone can focus is set below 16 px, in portrait or
* held sideways, because iOS Safari zooms the page into such a field.
* 4. Held sideways, the sign-in card is not centred out of reach.
*
* The account is the populated mobile-sweep account; nothing here writes.
*/
const VISUAL_VIEWPORT_STAND_IN = () => {
const stand = new EventTarget() as EventTarget & {
height: number;
width: number;
offsetTop: number;
offsetLeft: number;
pageTop: number;
pageLeft: number;
scale: number;
};
stand.height = window.innerHeight;
stand.width = window.innerWidth;
stand.offsetTop = 0;
stand.offsetLeft = 0;
stand.pageTop = 0;
stand.pageLeft = 0;
stand.scale = 1;
Object.defineProperty(window, "visualViewport", {
configurable: true,
get: () => stand,
});
(window as unknown as { __viewport: typeof stand }).__viewport = stand;
};
async function setKeyboard(page: Page, covered: number) {
await page.evaluate((px) => {
const stand = (
window as unknown as {
__viewport: EventTarget & { height: number };
}
).__viewport;
stand.height = window.innerHeight - px;
stand.dispatchEvent(new Event("resize"));
}, covered);
}
async function openCaptureForm(page: Page) {
await page.goto("/");
await page.getByTestId("bottom-nav-capture").click();
const picker = page.locator('[data-slot="responsive-sheet-content"]');
await expect(picker).toBeVisible();
await picker
.locator('[data-slot="responsive-sheet-body"] button')
.first()
.click();
const form = page
.locator('[data-slot="responsive-sheet-content"]')
.filter({ has: page.locator('[data-slot="responsive-sheet-footer"]') });
await expect(form).toBeVisible();
return form;
}
/** Every visible field a phone can focus, with its computed font size. */
async function fieldFontSizes(page: Page) {
return page.evaluate(() =>
Array.from(
document.querySelectorAll<HTMLElement>(
"input:not([type=hidden]):not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]),textarea,select",
),
)
.filter((el) => {
const r = el.getBoundingClientRect();
return r.width > 2 && r.height > 2;
})
.map((el) => ({
field:
el.getAttribute("name") ||
el.id ||
el.getAttribute("aria-label") ||
el.tagName,
px: parseFloat(getComputedStyle(el).fontSize),
})),
);
}
test.describe("installed app on a phone", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test.beforeEach(({}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
});
test("the new-version hint stands above the bottom bar and reloads", async ({
page,
}) => {
await page.route("**/api/version", (route) =>
route.fulfill({
json: {
data: { version: "999.0.0", buildSha: "e2e" },
error: null,
},
}),
);
await page.goto("/");
const toast = page.locator("[data-sonner-toast]").filter({
has: page.getByRole("button", { name: "Reload" }),
});
// The poller's first check runs five seconds after mount.
await expect(toast).toBeVisible({ timeout: 15_000 });
const nav = page.locator('[data-slot="bottom-nav"]');
const navBox = (await nav.boundingBox())!;
// Polled: the toast slides in from below, so an early sample is
// mid-animation.
await expect
.poll(async () => {
const b = (await toast.boundingBox())!;
return b.y + b.height;
})
.toBeLessThanOrEqual(navBox.y);
// The bottom bar's controls are still reachable with the hint up.
await expect(page.getByTestId("bottom-nav-more")).toBeVisible();
const reloaded = page.waitForEvent("load");
await toast.getByRole("button", { name: "Reload" }).click();
await reloaded;
});
test("a bottom sheet stands on the on-screen keyboard and the bottom bar steps aside", async ({
page,
}) => {
await page.addInitScript(VISUAL_VIEWPORT_STAND_IN);
const form = await openCaptureForm(page);
const save = form
.locator('[data-slot="responsive-sheet-footer"] button')
.last();
const viewportHeight = page.viewportSize()!.height;
const bottomEdge = async () => {
const b = (await form.boundingBox())!;
return Math.round(b.y + b.height);
};
// Closed keyboard: the sheet sits on the bottom edge (polled: it slides
// in from below).
await expect.poll(bottomEdge).toBe(viewportHeight);
const keyboard = 300;
await setKeyboard(page, keyboard);
await expect(page.locator("html")).toHaveAttribute("data-keyboard", "open");
await expect.poll(bottomEdge).toBe(viewportHeight - keyboard);
const box = (await form.boundingBox())!;
expect(box.y).toBeGreaterThanOrEqual(0);
const saveBox = (await save.boundingBox())!;
expect(saveBox.y + saveBox.height).toBeLessThanOrEqual(
viewportHeight - keyboard,
);
await expect(page.locator('[data-slot="bottom-nav"]')).toBeHidden();
// A toolbar-sized change is not a keyboard.
await setKeyboard(page, 60);
await expect(page.locator("html")).not.toHaveAttribute("data-keyboard");
await expect.poll(bottomEdge).toBe(viewportHeight);
});
for (const viewport of [
{ width: 390, height: 844 },
{ width: 844, height: 390 },
]) {
test(`no focusable field is below 16 px at ${viewport.width}x${viewport.height} (iOS focus zoom)`, async ({
page,
}) => {
await page.setViewportSize(viewport);
const offenders: string[] = [];
for (const path of [
"/settings",
"/documents",
"/settings/notifications",
]) {
await page.goto(path);
await page.waitForLoadState("networkidle");
const fields = await fieldFontSizes(page);
expect(fields.length, `${path} renders no field`).toBeGreaterThan(0);
for (const f of fields)
if (f.px < 16) offenders.push(`${path} ${f.field} ${f.px}px`);
}
if (viewport.width < 768) {
const form = await openCaptureForm(page);
await expect(form.locator("input").first()).toBeVisible();
for (const f of await fieldFontSizes(page))
if (f.px < 16) offenders.push(`capture ${f.field} ${f.px}px`);
}
expect(offenders).toEqual([]);
});
}
});
test.describe("signed out, held sideways", () => {
test.use({ viewport: { width: 844, height: 390 } });
test.beforeEach(({}, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
});
test("the sign-in card stays reachable from its top edge", async ({
page,
}) => {
await page.goto("/auth/login");
const card = page.locator("main#main-content > *").first();
await expect(card).toBeVisible();
// Open the password form, the tallest state of the card.
await page
.getByRole("button", { name: /password|passwort/i })
.first()
.click();
await expect(page.locator("input[type=password]").first()).toBeVisible();
const box = (await card.boundingBox())!;
expect(box.y).toBeGreaterThanOrEqual(0);
// And the document scrolls to reach whatever lies below the fold.
const scrollable = await page.evaluate(
() =>
document.documentElement.scrollHeight >= window.innerHeight &&
getComputedStyle(document.documentElement).overflowY !== "hidden",
);
expect(scrollable).toBe(true);
});
});
+64
View File
@@ -0,0 +1,64 @@
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* What the installed app shows without a connection, with the service worker
* actually running.
*
* Runs in the `chromium-service-worker` project, the one that lets the worker
* register (every other project blocks it; see `playwright.config.ts`), and
* like the other spec there it uses no route mocks: the claim is about what
* the worker itself serves.
*
* 1. A page that is open when the connection drops stays the app and says
* so (the offline strip).
* 2. Opening a page without a connection gets the worker's offline page:
* the word "Offline" and a working retry control, never the browser's
* error. (Signed-in pages are never cached, by design: the proxy pins
* `private, no-store` on them because some carry the record in the
* HTML. So this is what an offline launch of the installed app shows.)
* 3. When the connection returns, that page reloads into the app by
* itself.
*
* The account is the populated mobile-sweep account; nothing here writes.
*/
test.describe("offline, with the service worker", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test("an open page says it is offline, a new one opens the offline page, and both recover", async ({
page,
context,
}) => {
await page.goto("/medications");
await page.waitForFunction(
() => navigator.serviceWorker?.controller !== null,
undefined,
{ timeout: 20_000 },
);
await expect(page.locator("#main-content")).toBeVisible();
await page.waitForLoadState("networkidle");
try {
await context.setOffline(true);
await expect(page.locator('[data-slot="offline-banner"]')).toBeVisible();
const response = await page.goto("/timeline");
expect(response?.status()).toBe(503);
await expect(page.getByText("Offline", { exact: true })).toBeVisible();
const retry = page.getByRole("link", { name: "Reload" });
await expect(retry).toBeVisible();
const box = (await retry.boundingBox())!;
expect(box.width).toBeGreaterThanOrEqual(44);
expect(box.height).toBeGreaterThanOrEqual(44);
// Back online: the offline page reloads itself into the app.
const reloaded = page.waitForEvent("load");
await context.setOffline(false);
await reloaded;
await expect(page.locator("#main-content")).toBeVisible();
expect(new URL(page.url()).pathname).toBe("/timeline");
} finally {
await context.setOffline(false);
}
});
});
+366
View File
@@ -0,0 +1,366 @@
import type { Page, Route } from "@playwright/test";
import { mockDay } from "./setup/day-mock";
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
import { SCORE_HISTORY_STORAGE_STATE_PATH } from "./setup/global-setup";
import { expect, test } from "./setup/test";
import type { DailyDigest } from "@/lib/daily/digest";
import { DIGEST_AI_AVAILABLE } from "@/__tests__/helpers/ai-capability-fixtures";
/**
* v1.42 — the score histories and the dashboard's top card switch.
*
* - The health score's page charts its daily course with the range tabs
* every chart offers; a tab asks the server for its window, the choice
* survives a reload, and a point opens its day.
* - Readiness, recovery, the sleep score and strain carry the same four
* tabs in the same component.
* - The top card goes away when the layout settings switch it off, and
* comes back when they switch it on.
*
* The score history and the derived scores are route mocks in the
* contract's shape (the server's answers are pinned by the integration
* suite); the range preference and the layout are this account's own, saved
* for real. Stable data attributes only.
*/
test.use({ storageState: SCORE_HISTORY_STORAGE_STATE_PATH });
// Every test writes this one account's layout blob, a Serializable
// read-modify-write; run in parallel they would abort each other's write.
test.describe.configure({ mode: "serial" });
const RANGES = ["7", "30", "90", "0"];
function dayKey(offset: number): string {
const now = new Date();
const local = new Date(
now.toLocaleString("en-US", { timeZone: "Europe/Berlin" }),
);
local.setDate(local.getDate() + offset);
const y = local.getFullYear();
const m = String(local.getMonth() + 1).padStart(2, "0");
const d = String(local.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
/** Answer the score history with one point per day of the window, max 40. */
async function mockScoreHistory(page: Page): Promise<number[]> {
const asked: number[] = [];
await page.route(/\/api\/insights\/score-history\?/, (route: Route) => {
const url = new URL(route.request().url());
const days = Number(url.searchParams.get("days"));
asked.push(days);
const count = Math.min(days, 40);
const points = Array.from({ length: count }, (_, i) => ({
day: dayKey(i - count + 1),
value: 60 + ((i * 7) % 25),
seamBreak: false,
}));
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
score: url.searchParams.get("score"),
days,
points,
band: { lo: 64, hi: 76, n: 30 },
},
error: null,
}),
});
});
return asked;
}
/** An `ok` derived score, so the score page draws its history under it. */
async function mockDerived(page: Page) {
await page.route(/\/api\/insights\/derived\?/, (route: Route) => {
const metric = new URL(route.request().url()).searchParams.get("metric");
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
metric,
status: "ok",
value: {
score: 72,
band: "green",
components: [],
subScores: [],
night: dayKey(0),
asleepMinutes: 420,
inBedMinutes: 450,
windowNights: 20,
},
coverage: {
requiredInputs: 1,
presentInputs: 1,
historyDays: 30,
missing: [],
},
confidence: "high",
provenance: {
inputs: [],
source: "rollup",
windowDays: 30,
computedAt: new Date().toISOString(),
},
reason: null,
assessment: null,
},
error: null,
}),
});
});
}
async function resetRange(page: Page, chartKey: string) {
const res = await page.request.put("/api/dashboard/chart-overlay-prefs", {
data: {
chartKey,
prefs: {
showTrendIndicator: false,
showTrendArrow: false,
showTargetRange: false,
comparisonBaseline: "none",
rangePoints: 30,
},
},
});
expect(res.status()).toBe(200);
}
async function expectSharedTabs(page: Page, chart: string, pressed: string) {
const tabs = page.locator(`${chart} [data-slot="chart-range-tab"]`);
await expect(tabs).toHaveCount(4);
expect(
await tabs.evaluateAll((nodes) =>
nodes.map((node) => node.getAttribute("data-range")),
),
).toEqual(RANGES);
await expect(
page.locator(`${chart} [data-slot="chart-range-tab"][aria-pressed="true"]`),
).toHaveAttribute("data-range", pressed);
}
test.describe("score histories", () => {
test.beforeEach(async ({ page, context }, testInfo) => {
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{ name: "healthlog-locale", value: "en", url: baseURL },
]);
await mockDay(page, { index: "full" });
});
test("the health score's range tabs switch the window and are remembered", async ({
page,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await resetRange(page, "scoreHealth");
const asked = await mockScoreHistory(page);
await page.goto("/insights/health-score");
const chart = page.locator('[data-slot="score-trend-chart"]');
await expect(chart).toHaveAttribute("data-score", "HEALTH_SCORE");
await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30");
await expect(chart.locator('[data-slot="chart-plot"]')).toBeVisible();
await expect(chart.locator('[data-slot="score-trend-band"]')).toBeVisible();
expect(asked).toContain(30);
await chart
.locator('[data-slot="chart-range-tab"][data-range="7"]')
.click();
await expect(chart).toHaveAttribute("data-range", "7");
await expect.poll(() => asked.at(-1)).toBe(7);
// The table lists exactly the window's days.
await chart.locator('[data-slot="chart-data-table-toggle"]').click();
await expect(
chart.locator('[data-slot="chart-data-table-row"]'),
).toHaveCount(7);
// Remembered: a reload opens on seven days again.
await page.reload();
await expect(chart).toHaveAttribute("data-range", "7");
await chart
.locator('[data-slot="chart-range-tab"][data-range="0"]')
.click();
await expect(chart).toHaveAttribute("data-range", "0");
await expect.poll(() => asked.at(-1)).toBe(3650);
// A point opens its day beside the page.
const plot = chart.locator('[data-slot="chart-plot"]');
await expect(plot).toHaveAttribute("data-day-links", "true");
const box = (await plot.boundingBox())!;
await page.mouse.click(box.x + box.width * 0.8, box.y + box.height / 2);
await expect(page.locator('[data-slot="day-panel"]')).toHaveAttribute(
"data-shell",
"docked",
);
await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/);
await resetRange(page, "scoreHealth");
});
test("the score panel leads to the health score page and back", async ({
page,
}) => {
await mockPopulatedInsights(page);
await mockScoreHistory(page);
await page.goto("/insights");
const link = page.locator('[data-slot="health-score-history-link"]');
await expect(link).toHaveAttribute("href", "/insights/health-score");
await link.click();
await expect(page).toHaveURL(/\/insights\/health-score$/);
await expect(
page.locator('[data-slot="health-score-back"]'),
).toHaveAttribute("href", "/insights");
});
test("readiness and the sleep score offer the same tabs, and switching asks for the window", async ({
page,
}) => {
await resetRange(page, "scoreReadiness");
await mockDerived(page);
const asked = await mockScoreHistory(page);
await page.goto("/insights/scores/readiness");
const chart = page.locator('[data-slot="score-trend-chart"]');
await expect(chart).toHaveAttribute("data-score", "READINESS");
await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30");
await chart
.locator('[data-slot="chart-range-tab"][data-range="90"]')
.click();
await expect(chart).toHaveAttribute("data-range", "90");
await expect.poll(() => asked.at(-1)).toBe(90);
await resetRange(page, "scoreReadiness");
await page.goto("/insights/scores/sleep");
await expect(
page.locator('[data-slot="score-trend-chart"]'),
).toHaveAttribute("data-score", "SLEEP_SCORE");
await expectSharedTabs(page, '[data-slot="score-trend-chart"]', "30");
});
test("recovery and strain carry the same tabs on their stored-score chart", async ({
page,
}) => {
await resetRange(page, "scoreRecovery");
await mockDerived(page);
await page.goto("/insights/scores/recovery");
const chart = '[data-slot="score-history-chart"]';
await expect(page.locator(chart)).toHaveAttribute(
"data-type",
"RECOVERY_SCORE",
);
await expectSharedTabs(page, chart, "30");
// A score history remembers its range, and mounts no overlay dropdown.
await expect(
page.locator(`${chart} [data-slot="chart-overlay-controls-trigger"]`),
).toHaveCount(0);
await page
.locator(`${chart} [data-slot="chart-range-tab"][data-range="7"]`)
.click();
await expectSharedTabs(page, chart, "7");
await page.reload();
await expectSharedTabs(page, chart, "7");
await resetRange(page, "scoreRecovery");
await page.goto("/insights/scores/strain");
await expectSharedTabs(page, chart, "30");
});
});
const DIGEST: DailyDigest = {
generatedAt: new Date().toISOString(),
ai: DIGEST_AI_AVAILABLE,
phase: "final",
sleepPending: false,
score: {
value: 82,
band: "green",
delta: null,
deltaReason: "first_eligibility_window",
steadyWeeks: 2,
},
topSignal: null,
signalLine: null,
briefingLead: null,
lead: null,
today: [],
restMode: null,
line: "Your health score today is 82.",
worthALook: [],
justIn: null,
reactionLine: null,
};
test.describe("the dashboard's top card", () => {
test.afterEach(async ({ page }) => {
// Leave the account's layout at its defaults for the next run.
await page.request.delete("/api/dashboard/widgets");
});
test("switches off and on from the layout settings", async ({ page }) => {
await page.request.delete("/api/dashboard/widgets");
await page.route(/\/api\/daily\/digest(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: DIGEST, error: null }),
}),
);
// The dashboard states the switch on its root. What the card shows
// depends on the day's digest, which the server renders into the page
// before any route mock can answer, so the switch is asserted there and
// not on the card's content.
const dashboard = page.locator("[data-today-card]");
const hero = page.locator('[data-slot="today-hero"]');
await page.goto("/");
await expect(dashboard).toHaveAttribute("data-today-card", "on");
async function flip(expected: "checked" | "unchecked") {
await page.goto("/settings/layout/dashboard");
const toggle = page.locator('[data-slot="today-card-switch"]');
await expect(toggle).toBeVisible();
await toggle.click();
await expect(toggle).toHaveAttribute("data-state", expected);
const saved = page.waitForResponse(
(res) =>
res.url().includes("/api/dashboard/widgets") &&
res.request().method() === "PUT",
);
await page
.locator('[data-slot="settings-dashboard-layout-save"]')
.click();
const response = await saved;
expect(response.status()).toBe(200);
const body = response.request().postDataJSON() as {
todayCardVisible?: boolean;
};
expect(body.todayCardVisible).toBe(expected === "checked");
}
await flip("unchecked");
await page.goto("/");
await expect(dashboard).toHaveAttribute("data-today-card", "off");
await expect(hero).toHaveCount(0);
await expect(page.locator('[data-slot="today-hero-skeleton"]')).toHaveCount(
0,
);
// A phone width shows no card either.
await page.setViewportSize({ width: 390, height: 844 });
await page.reload();
await expect(hero).toHaveCount(0);
await page.setViewportSize({ width: 1280, height: 720 });
await flip("checked");
await page.goto("/");
await expect(dashboard).toHaveAttribute("data-today-card", "on");
});
});
+24 -20
View File
@@ -149,38 +149,42 @@ test.describe("Settings mobile consistency (Pixel 5)", () => {
}
});
test("/settings/account: Sprache select shares one grid row with date-of-birth", async ({
test("/settings/account: the language select sits in the display card, outside the profile form", async ({
page,
}) => {
await page.goto("/settings/account", { waitUntil: "networkidle" });
// Gate on the two elements this test is about, not on the network. An idle
// network says nothing about whether React has rendered the profile form,
// and the single `evaluate` below does not retry — so without this the
// test reads an empty document and reports "language + dob fields must
// exist", which sounds like a missing feature and is really a race.
// Gate on the elements this test is about, not on the network: the
// single `evaluate` below does not retry.
await expect(page.locator("#language-select")).toBeAttached();
await expect(page.locator("#dob")).toBeAttached();
// The v1.4.27 R1 settings audit pairs date-of-birth with language
// in a single `grid sm:grid-cols-2` row so the profile form keeps
// a uniform two-column rhythm and the language field no longer
// sits alone at the bottom with a `sm:max-w-xs` clamp.
const sharedGrid = await page.evaluate(() => {
// The language, unit and format selects save the moment they change, so
// they live in their own card with no save button. Inside the profile
// form they read as covered by its Save. Each card keeps a two-column
// grid: the language shares one with the units, the date of birth one
// with the timezone.
const layout = await page.evaluate(() => {
const lang = document.getElementById("language-select");
const dob = document.getElementById("dob");
if (!lang || !dob) return { found: false, sharedGrid: false };
const langGrid = lang.closest('[class*="grid"]');
const dobGrid = dob.closest('[class*="grid"]');
if (!lang || !dob) return null;
const displayCard = document.querySelector(
'[data-testid="settings-display-card"]',
);
return {
found: true,
sharedGrid: langGrid !== null && langGrid === dobGrid,
langInDisplayCard: !!displayCard && displayCard.contains(lang),
langInForm: lang.closest("form") !== null,
dobInForm: dob.closest("form") !== null,
langHasGrid: lang.closest('[class*="grid"]') !== null,
dobHasGrid: dob.closest('[class*="grid"]') !== null,
};
});
expect(sharedGrid.found, "language + dob fields must exist").toBe(true);
expect(sharedGrid.sharedGrid, "language + dob must share a grid").toBe(
true,
);
expect(layout, "language + dob fields must exist").not.toBeNull();
expect(layout!.langInDisplayCard).toBe(true);
expect(layout!.langInForm).toBe(false);
expect(layout!.dobInForm).toBe(true);
expect(layout!.langHasGrid).toBe(true);
expect(layout!.dobHasGrid).toBe(true);
});
test("/: Compare-to trigger renders at 44 px", async ({ page }) => {
+2
View File
@@ -77,6 +77,8 @@ export async function resetAiOptionalAccount(): Promise<void> {
export async function seedStoredBriefing(marker: string): Promise<void> {
const insights = {
dailyBriefing: { paragraph: marker, keyFindings: [] },
// Generated now: only text written today is served as today's read.
briefingGeneratedAt: new Date().toISOString(),
};
await withAccount(async (pool, userId) => {
await pool.query(
+220
View File
@@ -0,0 +1,220 @@
import type { Page, Request, Route } from "@playwright/test";
/**
* A Coach chat stream the spec drives frame by frame.
*
* `route.fulfill` hands the browser the whole event stream at once, so the
* live states of a turn (the status line moving from "Thinking…" to
* "Fetching…" to "Summarising…", an interim table before the first token)
* would flash by unobserved. This replaces `fetch` in the page for the chat
* POST only: the response body is a stream the spec feeds with
* `feed(page, frames)` and closes with `end(page)`. Every other request goes
* through untouched, so `page.route` mocks keep working.
*
* The POST bodies are recorded in the page (`posts(page)`).
*/
export async function installLiveCoachStream(page: Page): Promise<void> {
await page.addInitScript(() => {
const w = window as unknown as {
__coachPosts: unknown[];
__coachFeed: (frames: unknown[]) => void;
__coachEnd: () => void;
};
const original = window.fetch.bind(window);
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
const encoder = new TextEncoder();
w.__coachPosts = [];
w.__coachFeed = (frames) => {
for (const frame of frames) {
controller?.enqueue(
encoder.encode(`data: ${JSON.stringify(frame)}\n\n`),
);
}
};
w.__coachEnd = () => {
controller?.close();
controller = null;
};
window.fetch = async (input, init) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
const method = (
init?.method ?? (input instanceof Request ? input.method : "GET")
).toUpperCase();
if (
method === "POST" &&
new URL(url, window.location.href).pathname === "/api/insights/chat"
) {
w.__coachPosts.push(JSON.parse(String(init?.body ?? "{}")));
const body = new ReadableStream<Uint8Array>({
start(c) {
controller = c;
},
});
init?.signal?.addEventListener("abort", () => {
try {
controller?.error(new DOMException("aborted", "AbortError"));
} catch {
// already closed
}
});
return new Response(body, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
return original(input, init);
};
});
}
export async function feed(page: Page, frames: unknown[]): Promise<void> {
await page.evaluate((f) => {
(window as unknown as { __coachFeed: (x: unknown[]) => void }).__coachFeed(
f,
);
}, frames);
}
export async function end(page: Page): Promise<void> {
await page.evaluate(() => {
(window as unknown as { __coachEnd: () => void }).__coachEnd();
});
}
export async function posts(
page: Page,
): Promise<Array<Record<string, unknown>>> {
return page.evaluate(
() =>
(window as unknown as { __coachPosts: Array<Record<string, unknown>> })
.__coachPosts,
);
}
export function tokens(text: string) {
return text.split(/(?<= )/).map((token) => ({ type: "token", token }));
}
function fulfilJson(route: Route, data: unknown) {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data, error: null }),
});
}
export interface StoredMessage {
id: string;
role: "user" | "assistant";
content: string;
metricSource?: Record<string, unknown> | null;
providerType?: string | null;
}
/**
* The conversation as the stubbed server holds it. The spec pushes the
* persisted turn before it sends `done`, so the refetch after `done` finds
* the copy that replaces the streamed reply.
*/
export interface StubConversation {
id: string;
title: string;
messages: StoredMessage[];
/** `…/messages/{id}/results`, by message id. */
results: Record<string, unknown[]>;
/** `…/messages/{id}/trail`, by message id. */
trails: Record<string, unknown>;
}
export async function serveConversation(
page: Page,
conversation: StubConversation,
): Promise<void> {
const at = (index: number) =>
new Date(Date.UTC(2026, 9, 6, 9, 0, index)).toISOString();
const summary = () => ({
id: conversation.id,
title: conversation.title,
createdAt: at(0),
updatedAt: at(conversation.messages.length),
messageCount: conversation.messages.length,
fenced: false,
attachments: [],
documentTitle: null,
});
await page.route(
/\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/,
async (route: Route, request: Request) => {
const url = new URL(request.url());
const base = `/api/insights/chat/${conversation.id}`;
const message = url.pathname.match(
/\/messages\/([^/]+)\/(results|trail)$/,
);
if (message) {
const [, id, kind] = message;
return kind === "results"
? fulfilJson(route, { results: conversation.results[id] ?? [] })
: fulfilJson(route, {
trail: conversation.trails[id] ?? null,
ai: { available: true, reason: null },
});
}
if (url.pathname === base) {
return fulfilJson(route, {
...summary(),
attachmentCount: 0,
summary: null,
messages: conversation.messages.map((m, index) => ({
id: m.id,
role: m.role,
content: m.content,
createdAt: at(index + 1),
metricSource: m.metricSource ?? null,
providerType:
m.role === "assistant" ? (m.providerType ?? "mock") : null,
promptVersion: null,
tokensUsed: m.role === "assistant" ? 420 : null,
model: m.role === "assistant" ? "mock" : null,
})),
});
}
if (url.pathname === "/api/insights/chat" && request.method() === "GET") {
return fulfilJson(route, {
conversations: conversation.messages.length > 0 ? [summary()] : [],
nextCursor: null,
});
}
return route.fallback();
},
);
await page.route("**/api/insights/coach/nudge-status*", (route) =>
fulfilJson(route, { nudgedAt: null, unread: false }),
);
await page.route("**/api/coach/about-me/questions*", (route) =>
fulfilJson(route, { questions: [] }),
);
}
export function done(conversationId: string, messageId: string) {
return {
type: "done",
conversationId,
messageId,
usage: { totalTokens: 420 },
};
}
export async function applyTheme(page: Page, theme: "light" | "dark") {
await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" });
await page.addInitScript((value: string) => {
window.localStorage.setItem("healthlog-theme", value);
}, theme);
}
/** Midday, so no "today / yesterday" boundary moves a label. */
export const FIXED_NOW = new Date("2026-10-06T12:00:00+02:00");
+90
View File
@@ -0,0 +1,90 @@
/**
* The record `day-doors.spec.ts` opens days from.
*
* Every chart the journey clicks needs points to click: ten days of the
* autonomic charge (the recovery page), of sleep and of mood (the sleep
* chart, the mood line and the mood calendar), weight over the last ten days
* and the same ten days a month earlier (the comparison chart draws a prior
* period only where one exists), and two readings of one lab marker.
*
* Written through the app's own routes, once, from `globalSetup`. An account
* that already holds the lab marker is taken as seeded, so a local re-run
* against a reused database does not double every row.
*/
import { request as playwrightRequest } from "@playwright/test";
export const DAY_DOORS_ANALYTE = "Ferritin";
export async function seedDayDoorsRecord(
baseURL: string,
storageStatePath: string,
): Promise<void> {
const ctx = await playwrightRequest.newContext({
baseURL,
storageState: storageStatePath,
});
try {
const labs = await ctx.get("/api/labs");
const body = (await labs.json()) as { data: unknown };
if (JSON.stringify(body.data ?? null).includes(DAY_DOORS_ANALYTE)) return;
const post = async (path: string, data: unknown): Promise<void> => {
const res = await ctx.post(path, { data });
if (res.status() === 409 || res.ok()) return;
throw new Error(
`[day-doors-fixture] ${path} answered ${res.status()}: ${(await res.text()).slice(0, 200)}`,
);
};
const daysAgo = (days: number, hour = 8) => {
const at = new Date(Date.now() - days * 86_400_000);
at.setUTCHours(hour, 0, 0, 0);
return at.toISOString();
};
for (let day = 1; day <= 10; day += 1) {
await post("/api/measurements", {
type: "ANS_CHARGE",
value: 5 + day,
measuredAt: daysAgo(day, 5),
});
await post("/api/measurements", {
type: "SLEEP_DURATION",
value: 400 + day * 4,
measuredAt: daysAgo(day, 6),
});
await post("/api/measurements", {
type: "WEIGHT",
value: 72 + day / 10,
measuredAt: daysAgo(day, 7),
});
// The same ten days, a month before: the prior period the comparison
// draws beside them.
await post("/api/measurements", {
type: "WEIGHT",
value: 73 + day / 10,
measuredAt: daysAgo(day + 30, 7),
});
await post("/api/mood-entries", {
mood: day % 2 ? "GUT" : "OKAY",
moodLoggedAt: daysAgo(day, 19),
});
}
for (const [days, value] of [
[60, 41],
[4, 58],
] as const) {
await post("/api/labs", {
analyte: DAY_DOORS_ANALYTE,
panel: "Eisenstoffwechsel",
value,
unit: "ng/mL",
referenceLow: 30,
referenceHigh: 300,
takenAt: daysAgo(days),
});
}
} finally {
await ctx.dispose();
}
}
+125
View File
@@ -0,0 +1,125 @@
/**
* The day routes, answered in the contract's shape.
*
* The day itself (`GET /api/day/{date}`) and the index (`GET /api/day/index`)
* are route mocks, so a journey pins the client: which door opens which day,
* what the history does, where no door is. The server's own answers are
* pinned by the integration suite.
*/
import type { Page, Route } from "@playwright/test";
function dayFixture(date: string) {
return {
date,
tz: "Europe/Berlin",
counts: { values: 2, entries: 1 },
running: [
{
kind: "illness",
section: "illness",
id: "ep1",
title: "Common cold",
sub: null,
since: date,
until: null,
dayIndex: 1,
dayCount: null,
href: "/illness",
},
],
values: [
{
type: "BLOOD_PRESSURE_SYS",
value: 124,
unit: "mmHg",
at: `${date}T07:00:00.000Z`,
source: "MANUAL",
band: { lo: 118, hi: 128, n: 20 },
},
{
type: "BLOOD_PRESSURE_DIA",
value: 79,
unit: "mmHg",
at: `${date}T07:00:00.000Z`,
source: "MANUAL",
band: { lo: 76, hi: 82, n: 20 },
},
],
events: [
{
at: `${date}T07:00:00.000Z`,
kind: "mood",
section: "mood",
id: "m1",
title: "Mood: good",
meta: null,
note: null,
docs: [],
href: "/mood",
},
],
notable: [],
scores: [
{
key: "healthScore",
value: 74,
max: 100,
source: "COMPUTED",
band: { lo: 66, hi: 78, n: 30 },
},
{ key: "strain", value: 12.4, max: 21, source: "WHOOP", band: null },
],
sections: {},
};
}
/**
* Every day from `from` to `to`, each holding values: what the row of day
* dots under a chart reads when the whole window has entries.
*/
function everyDay(from: string, to: string): Record<string, string[]> {
const days: Record<string, string[]> = {};
if (!from || !to) return days;
const at = new Date(`${from}T12:00:00.000Z`);
const end = new Date(`${to}T12:00:00.000Z`);
while (at <= end) {
days[at.toISOString().slice(0, 10)] = ["values"];
at.setUTCDate(at.getUTCDate() + 1);
}
return days;
}
export async function mockDay(
page: Page,
options: { index?: "empty" | "full" } = {},
) {
await page.route("**/api/day/**", async (route: Route) => {
const url = new URL(route.request().url());
if (url.pathname === "/api/day/index") {
const from = url.searchParams.get("from") ?? "";
const to = url.searchParams.get("to") ?? "";
const days = options.index === "full" ? everyDay(from, to) : {};
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
from,
to,
days,
// The first day of the window stands out: the rug draws a ring.
notable: Object.keys(days).slice(0, 1),
},
error: null,
}),
});
return;
}
const date = url.pathname.split("/").pop() ?? "";
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: dayFixture(date), error: null }),
});
});
}
Loaded 100 of 2097 files, more files were not shown because too many files have changed in this diff. Show more