Merge pull request #1178 from MBombeck/release/v1.42.0

Release v1.42.0
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-10-09 20:46:02 +02:00
commit 90050815eb
1316 files changed
+120338 -15044

No files matched your search

+23
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"
# -----------------------------------------------------------------------------
@@ -592,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
+13
View File
@@ -10,6 +10,19 @@ 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"
- 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
+1 -1
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
+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
+4 -4
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
@@ -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
+2 -2
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
@@ -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 }}
+1 -1
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.
+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:
+2 -2
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
@@ -95,7 +95,7 @@ jobs:
published-image:
name: Published image
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: read
+254
View File
@@ -1,5 +1,259 @@
# Changelog
## [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
+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": 4155,
"totalClientKbGz": 4760,
"maxRechartsChunks": 1
}
+21
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
@@ -533,6 +543,17 @@ 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
# 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
+5469 -2071
View File
File diff suppressed because it is too large. Load diff
+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
+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.
+155
View File
@@ -0,0 +1,155 @@
# 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;
```
## 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.
+11
View File
@@ -188,6 +188,17 @@ 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 and `work_mem` 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 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
+3 -3
View File
@@ -33,7 +33,7 @@ import {
* 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
* 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.
@@ -435,7 +435,7 @@ test.describe("Coach live trail", () => {
await expect(settled).toHaveAttribute("data-state", "done");
await expect(
settled.locator('[data-slot="coach-turn-steps-done"]'),
).toHaveText("Thought process · 4 steps");
).toHaveText("Thought process (4 steps)");
await expect(settled.locator(".text-shimmer")).toHaveCount(0);
const settledToggle = settled.locator(
'[data-slot="coach-turn-steps-toggle"]',
@@ -506,7 +506,7 @@ test.describe("Coach live trail", () => {
await expect(reloaded).toHaveAttribute("data-state", "done");
await expect(
reloaded.locator('[data-slot="coach-turn-steps-done"]'),
).toHaveText("Thought process · 4 steps");
).toHaveText("Thought process (4 steps)");
const reloadedToggle = reloaded.locator(
'[data-slot="coach-turn-steps-toggle"]',
);
+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();
}
});
});
+292 -155
View File
@@ -4,25 +4,29 @@ import type { Locator, Page, Request, 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";
import { mockDay } from "./setup/day-mock";
/**
* The Coach page frame: the conversations panel on the right, its toggle in
* the top bar, the settings gear in the panel header, and New chat at the
* panel's bottom right.
* The Coach page frame: the conversation, the conversations panel right of
* it, and the day right of that; New conversation floating in the
* conversation itself.
*
* Contracts under test, at 1440, 1280 and 390 px:
* Contracts under test, at 1920, 1440, 1280 and 390 px:
*
* 1. From 1280 px the panel docks beside the thread, open on a first visit,
* and the reading column (thread and composer) is exactly as wide with
* the panel open as with it closed. The choice survives a reload. The
* panel is flush with the viewport's right edge (no scrollbar gutter
* beside it) and runs from the top of the window to the bottom; the
* top bar ends at its left edge and the panel's header border is the
* top bar's. The toggle is the bar's last item, within 16 px of its
* right edge, open and shut; shut, no pixel of the panel shows and the
* bar runs to the window's edge. New chat is a round 48 px plus.
* 2. Escape inside the docked panel closes it and returns focus to the
* toggle.
* 1. From 1280 px the panel docks beside the thread, open on a first visit.
* From 1440 px the reading column (thread and composer) is exactly as
* wide with the panel open as folded; at 1280 the two strips leave the
* open column below its cap, so folding widens it up to the cap. Its 40 px strip stays at the right edge,
* left of the day's, open or not, and never moves: a click opens the
* panel left of the strips, a second click shuts it; the choice
* survives a reload. The panel is flush with the strips and runs from
* the top of the window to the bottom; the top bar ends at its left
* edge and the panel's header border is the top bar's. Focus moves
* between the fold control and the strip, and Escape inside the open
* panel folds it.
* 2. New conversation is a round 56 px button in the conversation column,
* never over the panel, never over the composer's field, and absent
* from the panel's header.
* 3. A row's menu renames by keyboard (Escape cancels without closing the
* panel, Enter saves once) and deletes with an Undo that keeps the
* DELETE from ever leaving; a delete left alone commits once.
@@ -31,10 +35,13 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
* can see" and drops the param from the URL.
* 5. axe finds nothing on the open panel or the open settings, in the
* light and the dark theme.
* 6. At 390 px the panel is a dialog sheet with New chat 16 px from the
* right edge (24 px in the docked panel); Escape closes it and focus returns to the toggle. The gear
* opens a bottom sheet that closes on Escape back to the gear. Nothing
* scrolls sideways.
* 6. The day docks right of the panel. At 1440 only one of the two is
* open: a day folds the panel, opening the panel folds the day. At 1920
* both stay open and the conversation keeps at least 560 px.
* 7. At 390 px the panel is a dialog sheet opened from the top bar's
* toggle; Escape closes it and focus returns to the toggle. The gear
* opens a bottom sheet that closes on Escape back to the gear. New
* conversation sits above the composer. Nothing scrolls sideways.
*
* Every Coach route is stubbed; the provider chain and provider config are
* stubbed too, so the model section renders the same on every machine.
@@ -173,9 +180,6 @@ async function mockCoach(page: Page): Promise<CoachMock> {
await page.route("**/api/coach/about-me/questions*", (route) =>
fulfilJson(route, { questions: [] }),
);
await page.route("**/api/insights/coach/seeded-question*", (route) =>
fulfilJson(route, { signal: null }),
);
await page.route("**/api/insights/provider-chain", (route) =>
route.request().method() === "GET"
? fulfilJson(route, {
@@ -212,6 +216,14 @@ async function mockCoach(page: Page): Promise<CoachMock> {
}
const toggle = (page: Page) => page.locator('[data-slot="coach-panel-toggle"]');
const collapse = (page: Page) =>
page.locator('[data-slot="coach-panel-collapse"]');
const expand = (page: Page) =>
page.locator('[data-slot="coach-panel-strip-toggle"]');
const strip = (page: Page) => page.locator('[data-slot="coach-panel-strip"]');
const dayStrip = (page: Page) => page.locator('[data-slot="day-strip"]');
const fab = (page: Page) => page.locator('[data-slot="coach-new-chat-fab"]');
const dayPanel = (page: Page) => page.locator('[data-slot="day-panel"]');
const panel = (page: Page) =>
page.locator('[data-slot="coach-conversations-panel"]');
@@ -240,95 +252,106 @@ async function expectNoSidewaysScroll(page: Page) {
}
/**
* The docked panel is flush with the window: its right edge is the
* viewport's (no scrollbar gutter beside it), it runs from the top of the
* window to the bottom, the top bar ends at its left edge, its header row's
* bottom border is the top bar's, and its own border is the left one only.
* The docked panel is flush with its strips: the conversations' strip sits
* right of the panel and the day's right of that, against the viewport's
* right edge (no scrollbar gutter beside them). The panel runs from the top
* of the window to the bottom, the top bar ends at its left edge, an open
* panel's header row has the top bar's bottom border, and its own border
* is the left one only. Both strips start below the top bar's band.
*/
async function expectPanelFlush(page: Page, width: number, height: number) {
const geometry = await page.evaluate(() => {
const aside = document.querySelector(
const box = (selector: string) =>
document.querySelector(selector)?.getBoundingClientRect() ?? null;
const outer = document.querySelector(
'[data-slot="coach-conversations-panel"]',
)!;
const inner = aside.firstElementChild as HTMLElement;
const header = aside.querySelector(
'[data-slot="coach-conversations-panel-header"]',
)!;
const bar = document.querySelector('[data-slot="top-bar"]')!;
const inner = outer.querySelector("aside");
const style = inner ? getComputedStyle(inner) : null;
const main = document.getElementById("main-content")!;
const style = getComputedStyle(inner);
const box = aside.getBoundingClientRect();
const barBox = bar.getBoundingClientRect();
const panel = outer.getBoundingClientRect();
return {
right: box.right,
top: box.top,
bottom: box.bottom,
left: box.left,
headerBottom: header.getBoundingClientRect().bottom,
barBottom: barBox.bottom,
barRight: barBox.right,
panel: {
left: panel.left,
right: panel.right,
top: panel.top,
bottom: panel.bottom,
},
header:
box('[data-slot="coach-conversations-panel-header"]')?.bottom ?? null,
bar: box('[data-slot="top-bar"]')!,
list: box('[data-slot="coach-panel-strip"]')!,
listButton: box('[data-slot="coach-panel-strip-toggle"]')!,
day: box('[data-slot="day-strip"]')!,
dayDock: box("#day-docked-panel")!,
gutter: main.offsetWidth - main.clientWidth,
borders: [
style.borderTopWidth,
style.borderRightWidth,
style.borderBottomWidth,
style.borderLeftWidth,
],
borders: style
? [
style.borderTopWidth,
style.borderRightWidth,
style.borderBottomWidth,
style.borderLeftWidth,
]
: null,
};
});
expect(geometry.right).toBe(width);
expect(geometry.top).toBe(0);
expect(geometry.bottom).toBe(height);
expect(geometry.barRight).toBe(geometry.left);
expect(geometry.headerBottom).toBe(geometry.barBottom);
expect(geometry.day.right).toBe(width);
expect(geometry.list.right).toBe(geometry.day.left);
expect(Math.round(geometry.list.width)).toBe(40);
expect(Math.round(geometry.day.width)).toBe(40);
// Conversations, then the day (open or slid to nothing), then the strips.
expect(geometry.panel.right).toBe(geometry.dayDock.left);
expect(geometry.dayDock.right).toBe(geometry.list.left);
expect(geometry.panel.top).toBe(0);
expect(geometry.panel.bottom).toBe(height);
expect(geometry.bar.right).toBe(geometry.panel.left);
// The strips start below the band; the palette's corner stays the bar's.
expect(geometry.listButton.top).toBe(geometry.bar.bottom);
if (geometry.header !== null) {
expect(geometry.header).toBe(geometry.bar.bottom);
expect(geometry.borders).toEqual(["0px", "0px", "0px", "1px"]);
}
expect(geometry.gutter).toBe(0);
expect(geometry.borders).toEqual(["0px", "0px", "0px", "1px"]);
}
/** Where a strip stands on screen. */
async function stripBox(locator: Locator) {
const box = (await locator.boundingBox())!;
return { x: Math.round(box.x), width: Math.round(box.width) };
}
/**
* The toggle is the last item of the top bar (the account menu that follows
* it on phones is not rendered from `md`), and its right edge is within
* 16 px of the bar's right edge.
* New conversation floats in the conversation column: a round 56 px button
* with a plus and its name, inside the column (never over a docked panel
* right of it) and clear of the composer's field. The panel's header has no
* such control.
*/
async function expectToggleAtBarEnd(page: Page) {
const at = await page.evaluate(() => {
const bar = document.querySelector('[data-slot="top-bar"]')!;
const shown = [...bar.querySelectorAll("*")].filter(
(el) => el.getClientRects().length > 0,
);
const toggle = bar.querySelector('[data-slot="coach-panel-toggle"]')!;
const rightmost = Math.max(
...shown.map((el) => el.getBoundingClientRect().right),
);
return {
gap:
bar.getBoundingClientRect().right -
toggle.getBoundingClientRect().right,
last: toggle.getBoundingClientRect().right >= rightmost,
lastChild:
toggle.closest('[data-slot="top-bar-actions"]')?.lastElementChild ===
toggle,
};
});
expect(at.last, "nothing in the bar stands right of the toggle").toBe(true);
expect(at.lastChild).toBe(true);
expect(at.gap).toBeGreaterThanOrEqual(0);
expect(at.gap).toBeLessThanOrEqual(16);
}
/** New chat: a round 48 px button with the plus glyph. */
async function expectFab(page: Page) {
const fab = panel(page).locator('[data-slot="coach-panel-new-chat"]');
const box = (await fab.boundingBox())!;
expect([Math.round(box.width), Math.round(box.height)]).toEqual([48, 48]);
expect(
await fab.evaluate((el) =>
parseFloat(getComputedStyle(el).borderTopLeftRadius),
),
"round",
).toBeGreaterThanOrEqual(24);
await expect(fab.locator("svg.lucide-plus")).toHaveCount(1);
await expect(fab).toHaveAttribute("aria-label", "New chat");
async function expectNewChatFab(page: Page) {
await expect(fab(page)).toHaveCount(1);
await expect(fab(page)).toHaveAttribute("aria-label", "New conversation");
await expect(fab(page).locator("svg.lucide-plus")).toHaveCount(1);
await expect(
panel(page).locator('[data-slot="coach-panel-new-chat"]'),
).toHaveCount(0);
const box = (await fab(page).boundingBox())!;
expect(Math.round(box.width)).toBe(56);
expect(Math.round(box.height)).toBe(56);
const main = (await page
.locator('[data-slot="coach-page-main"]')
.boundingBox())!;
expect(box.x).toBeGreaterThanOrEqual(main.x);
expect(box.x + box.width).toBeLessThanOrEqual(main.x + main.width);
const field = (await page
.locator(
'[data-slot="coach-page-composer"] [data-slot="coach-input-textarea"]',
)
.boundingBox())!;
const overlaps =
box.x < field.x + field.width &&
box.x + box.width > field.x &&
box.y < field.y + field.height &&
box.y + box.height > field.y;
expect(overlaps, "the button leaves the composer's field free").toBe(false);
}
/** Rendered heights of the docked panel's header buttons and first row. */
@@ -341,6 +364,7 @@ async function panelControlHeights(page: Page) {
.boundingBox())!.height,
);
return {
collapse: await height("coach-panel-collapse"),
plans: await height("coach-panel-plans"),
gear: await height("coach-settings"),
row: await height("coach-history-select"),
@@ -425,21 +449,24 @@ test.describe("Coach page frame", () => {
// First visit: open.
await expect(panel(page)).toHaveAttribute("data-state", "open");
await waitForWidth(panel(page), 288);
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
await expect(toggle(page)).toHaveAttribute(
// Docked, the fold control lives in the panel; the top bar has none.
await expect(toggle(page)).toHaveCount(0);
await expect(collapse(page)).toHaveAttribute("aria-expanded", "true");
await expect(collapse(page)).toHaveAttribute(
"aria-controls",
"coach-conversations-panel",
);
const open = await columnWidths(page);
expect(open.thread).toBeCloseTo(672, 0);
// The full 672 px column at 1440; at 1280 the two strips take 80 px
// of it and the column stays well above the 560 px floor.
if (width === 1440) expect(open.thread).toBeCloseTo(672, 0);
else expect(open.thread).toBeGreaterThanOrEqual(560);
expect(Math.abs(open.composer - open.thread)).toBeLessThanOrEqual(1);
// The panel sits right of the column, never over it.
const aside = (await panel(page).boundingBox())!;
expect(aside.x).toBeGreaterThanOrEqual(open.threadX + open.thread);
await expectPanelFlush(page, width, 900);
// Open, the toggle is the top bar's last item, against the panel.
await expectToggleAtBarEnd(page);
await expectFab(page);
await expectNewChatFab(page);
// The bar says where the reader is: the page, then the conversation.
await expect(
page.locator('[data-slot="coach-top-bar-trail"]'),
@@ -447,37 +474,37 @@ test.describe("Coach page frame", () => {
await expectNoSidewaysScroll(page);
await shot(page, testInfo, `coach-frame-${width}-open`);
await toggle(page).click();
// The strip stands still through a fold and an open.
const stripOpen = await stripBox(strip(page));
await expect(expand(page)).toHaveAttribute("aria-expanded", "true");
// Folded: the panel slides to nothing, the strip takes the focus, and
// the top bar ends at the strips.
await collapse(page).click();
await expect(panel(page)).toHaveAttribute("data-state", "closed");
await waitForWidth(panel(page), 0);
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
const closed = await columnWidths(page);
// Closed, the panel is entirely off-screen, the top bar runs to the
// window's edge and the toggle is still its last item.
await expect
.poll(() =>
page.evaluate(
() =>
document
.querySelector('[data-slot="top-bar"]')!
.getBoundingClientRect().right,
),
)
.toBe(width);
expect(
await panel(page).evaluate((el) => {
const box = el.getBoundingClientRect();
return box.width === 0 || box.left >= window.innerWidth;
}),
"no pixel of the shut panel shows",
).toBe(true);
await expectToggleAtBarEnd(page);
expect(Math.abs(closed.thread - open.thread)).toBeLessThanOrEqual(1);
expect(Math.abs(closed.composer - open.composer)).toBeLessThanOrEqual(1);
// Closed, nothing inside the panel is reachable.
expect(await stripBox(strip(page))).toEqual(stripOpen);
await expect(expand(page)).toBeFocused();
await expect(expand(page)).toHaveAttribute("aria-expanded", "false");
await expect(expand(page)).toHaveAttribute(
"aria-label",
"Show conversations",
);
await expect(
panel(page).locator('[data-slot="coach-history-select"]').first(),
).not.toBeInViewport();
panel(page).locator('[data-slot="coach-history-select"]'),
).toHaveCount(0);
await expectPanelFlush(page, width, 900);
const closed = await columnWidths(page);
if (width === 1440) {
expect(Math.abs(closed.thread - open.thread)).toBeLessThanOrEqual(1);
} else {
// At 1280 the strips leave the open column below its 672 px cap, so
// folding the list gives the column back what it lacked, and no more.
expect(closed.thread).toBeGreaterThanOrEqual(open.thread);
expect(closed.thread).toBeLessThanOrEqual(672 + 1);
}
expect(Math.abs(closed.composer - closed.thread)).toBeLessThanOrEqual(1);
await expectNewChatFab(page);
await expectNoSidewaysScroll(page);
await shot(page, testInfo, `coach-frame-${width}-closed`);
@@ -485,19 +512,129 @@ test.describe("Coach page frame", () => {
await openCoach(page);
await expect(panel(page)).toHaveAttribute("data-state", "closed");
// Open again, then Escape from inside closes it, focus to the toggle.
await toggle(page).click();
// A click on the strip opens it, a second click shuts it, and the
// strip does not move either time.
await expand(page).click();
await expect(panel(page)).toHaveAttribute("data-state", "open");
expect(await stripBox(strip(page))).toEqual(stripOpen);
await expand(page).click();
await expect(panel(page)).toHaveAttribute("data-state", "closed");
await expect(expand(page)).toBeFocused();
expect(await stripBox(strip(page))).toEqual(stripOpen);
// Opened from the keyboard, focus lands on the fold control; Escape
// from inside folds it again and focus goes back to the strip.
await expand(page).focus();
await page.keyboard.press("Enter");
await expect(panel(page)).toHaveAttribute("data-state", "open");
await expect(collapse(page)).toBeFocused();
await panel(page)
.locator('[data-slot="coach-history-select"]')
.first()
.focus();
await page.keyboard.press("Escape");
await expect(panel(page)).toHaveAttribute("data-state", "closed");
await expect(toggle(page)).toBeFocused();
await expect(expand(page)).toBeFocused();
});
}
test("1440: the day and the conversations take turns, focus included", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockCoach(page);
await mockDay(page);
const day = "2026-06-01";
await page.goto(`/coach?c=frame-bp&day=${day}`, {
waitUntil: "domcontentloaded",
});
await expect(
page.locator('[data-slot="coach-message-thread"]').getByText(ANSWER),
).toBeVisible({ timeout: 15_000 });
// The day opens docked, left of the strips, and the conversations
// close for it; both strips stay.
await expect(dayPanel(page)).toHaveAttribute("data-shell", "docked");
await expect(panel(page)).toHaveAttribute("data-state", "closed");
await waitForWidth(panel(page), 0);
const dayBox = (await dayPanel(page).boundingBox())!;
const listStrip = (await strip(page).boundingBox())!;
expect(Math.round(dayBox.x + dayBox.width)).toBe(Math.round(listStrip.x));
expect(Math.round(listStrip.x + listStrip.width)).toBe(1400);
await expect(dayStrip(page)).toHaveAttribute("data-state", "open");
await expectPanelFlush(page, 1440, 900);
await expectNewChatFab(page);
expect(
(await page.locator('[data-slot="coach-page-main"]').boundingBox())!
.width,
).toBeGreaterThanOrEqual(540);
await expectNoSidewaysScroll(page);
await shot(page, testInfo, "coach-frame-1440-day");
// Opening the conversations closes the day; its strip keeps the day,
// focus goes to the conversations' fold control, the day is out of the
// URL, and the conversation stays the same.
await expand(page).click();
await expect(panel(page)).toHaveAttribute("data-state", "open");
await expect(dayPanel(page)).toHaveCount(0);
await expect(dayStrip(page)).toHaveAttribute("data-day", day);
await expect(dayStrip(page)).toHaveAttribute("data-state", "closed");
await expect(collapse(page)).toBeFocused();
await expect(page).toHaveURL(/\/coach\?c=frame-bp$/);
await expectNewChatFab(page);
await shot(page, testInfo, "coach-frame-1440-list");
// And back: the day's strip opens the day, the conversations close.
await page.locator('[data-slot="day-strip-toggle"]').click();
await expect(dayPanel(page)).toHaveAttribute("data-shell", "docked");
await expect(panel(page)).toHaveAttribute("data-state", "closed");
await expect(page).toHaveURL(new RegExp(`c=frame-bp&day=${day}$`));
// Switching conversations keeps the day open.
await expand(page).click();
await page.locator('[data-slot="day-strip-toggle"]').click();
await expect(dayPanel(page)).toBeVisible();
await fab(page).click();
await expect(page).toHaveURL(new RegExp(`/coach\\?day=${day}$`));
await expect(dayPanel(page)).toBeVisible();
});
test("1920: the conversations and the day stay open side by side", async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1920, height: 1000 });
await mockCoach(page);
await mockDay(page);
await page.goto("/coach?c=frame-bp&day=2026-06-01", {
waitUntil: "domcontentloaded",
});
await expect(
page.locator('[data-slot="coach-message-thread"]').getByText(ANSWER),
).toBeVisible({ timeout: 15_000 });
await expect(dayPanel(page)).toHaveAttribute("data-shell", "docked");
await expect(panel(page)).toHaveAttribute("data-state", "open");
await waitForWidth(panel(page), 288);
const list = (await panel(page).boundingBox())!;
const dayBox = (await dayPanel(page).boundingBox())!;
expect(list.x + list.width).toBeLessThanOrEqual(dayBox.x + 1);
await expectPanelFlush(page, 1920, 1000);
expect(
(await page.locator('[data-slot="coach-page-main"]').boundingBox())!
.width,
).toBeGreaterThanOrEqual(560);
await expectNewChatFab(page);
await expectNoSidewaysScroll(page);
await shot(page, testInfo, "coach-frame-1920-both");
// Folding one leaves the other alone.
await collapse(page).click();
await expect(panel(page)).toHaveAttribute("data-state", "closed");
await expect(dayPanel(page)).toBeVisible();
await expand(page).click();
await expect(panel(page)).toHaveAttribute("data-state", "open");
await expect(dayPanel(page)).toBeVisible();
});
test("1280 fine pointer: compact header buttons and rows", async ({
page,
}) => {
@@ -505,6 +642,7 @@ test.describe("Coach page frame", () => {
await mockCoach(page);
await openCoach(page);
expect(await panelControlHeights(page)).toEqual({
collapse: 28,
plans: 28,
gear: 28,
row: 36,
@@ -524,6 +662,7 @@ test.describe("Coach page frame", () => {
await openCoach(page);
await expect(panel(page)).toHaveAttribute("data-state", "open");
expect(await panelControlHeights(page)).toEqual({
collapse: 44,
plans: 44,
gear: 44,
row: 44,
@@ -552,18 +691,7 @@ test.describe("Coach page frame", () => {
await expect(page).toHaveURL(/\/coach\?c=frame-sleep$/);
await expect(rows.nth(1)).toHaveAttribute("aria-current", "true");
const fab = panel(page).locator('[data-slot="coach-panel-new-chat"]');
const fabBox = (await fab.boundingBox())!;
const asideBox = (await panel(page).boundingBox())!;
// 24 px in from the panel's corner from `md`, 16 px on a phone.
expect(asideBox.x + asideBox.width - (fabBox.x + fabBox.width)).toBeCloseTo(
24,
0,
);
expect(
asideBox.y + asideBox.height - (fabBox.y + fabBox.height),
).toBeCloseTo(24, 0);
await fab.click();
await fab(page).click();
await expect(page).toHaveURL(/\/coach$/);
await expect(
page.locator('[data-slot="coach-input-textarea"]'),
@@ -715,6 +843,7 @@ test.describe("Coach page frame", () => {
new RegExp(`(^|\\s)${theme}(\\s|$)`),
);
await expect(panel(page)).toHaveAttribute("data-state", "open");
await shot(page, testInfo, `coach-frame-1440-${theme}`);
const scan = async (selector: string, label: string) => {
const result = await new AxeBuilder({ page })
@@ -781,6 +910,13 @@ test.describe("Coach page frame", () => {
// Below 1280 px the panel waits to be asked.
await expect(panel(page)).toHaveCount(0);
await expect(toggle(page)).toHaveAttribute("aria-expanded", "false");
// New conversation sits above the composer, clear of its field.
await expectNewChatFab(page);
const fabBox = (await fab(page).boundingBox())!;
const composerBox = (await page
.locator('[data-slot="coach-page-composer"]')
.boundingBox())!;
expect(fabBox.y + fabBox.height).toBeLessThanOrEqual(composerBox.y);
await expectNoSidewaysScroll(page);
await shot(page, testInfo, "coach-frame-390-thread");
@@ -788,16 +924,17 @@ test.describe("Coach page frame", () => {
const sheet = page.getByRole("dialog", { name: "Conversations" });
await expect(sheet).toBeVisible();
await expect(toggle(page)).toHaveAttribute("aria-expanded", "true");
const fab = sheet.locator('[data-slot="coach-panel-new-chat"]');
// Measured once the sheet has slid in.
await expect
.poll(async () => {
const box = (await fab.boundingBox())!;
return Math.round(390 - (box.x + box.width));
})
.toBe(16);
expect((await fab.boundingBox())!.width).toBe(48);
await expect(fab.locator("svg.lucide-plus")).toHaveCount(1);
await expect(
sheet.locator('[data-slot="coach-panel-new-chat"]'),
).toHaveCount(0);
// The title keeps its whole word beside four header controls.
const title = sheet.locator(
'[data-slot="coach-conversations-panel-header"] h2',
);
expect(
await title.evaluate((el) => el.scrollWidth <= el.clientWidth),
"the sheet title is not truncated",
).toBe(true);
await shot(page, testInfo, "coach-frame-390-panel");
await page.keyboard.press("Escape");
+206
View File
@@ -0,0 +1,206 @@
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",
});
const thread = page.locator('[data-slot="coach-message-thread"]');
await expect(thread).toBeVisible({ timeout: 15_000 });
// The thread scrolls at all: otherwise "at the end" proves nothing.
expect(
await 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();
});
}
});
+157
View File
@@ -0,0 +1,157 @@
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 the search field on a wide desktop and a
* magnifier on a phone, which opens a full-height sheet with the field
* focused;
* - the open palette passes axe.
*
* Stable hooks only: `command-palette` (+ `data-variant`),
* `command-palette-input`, `command-palette-trigger` (+ `data-variant`),
* options by `data-entry`.
*/
test.use({ storageState: STORAGE_STATE_PATH });
const palette = (page: Page) => page.locator('[data-slot="command-palette"]');
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("the top bar: a search field on a wide desktop", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
await shellReady(page);
const field = page.locator(
'[data-slot="command-palette-trigger"][data-variant="field"]',
);
await expect(field).toBeVisible();
await expect(
page.locator(
'[data-slot="command-palette-trigger"][data-variant="icon"]',
),
).toBeHidden();
await field.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"][data-variant="icon"]',
);
await expect(magnifier).toBeVisible();
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();
});
});
+325
View File
@@ -0,0 +1,325 @@
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 caption and 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(
page.locator('[data-slot="chart-day-caption"]').first(),
).toBeVisible();
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.
await page.locator('[data-slot="day-close"]').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-close"]').click();
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/);
});
});
+380
View File
@@ -0,0 +1,380 @@
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 collapses to a narrow edge that brings it back on any
* page; a sheet closes for good;
* - 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");
await page.locator('[data-slot="day-close"]').click();
await expect(panel(page)).toHaveCount(0);
await expect(page).toHaveURL(/\/mood$/);
// The strip stays, holding the day, and focus waits on it.
const rail = page.locator('[data-slot="day-strip"]');
const expand = page.locator('[data-slot="day-strip-toggle"]');
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);
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("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);
+1 -1
View File
@@ -89,7 +89,7 @@ test.describe("document vault — procedure filter", () => {
const knee = menu.getByRole("menuitemcheckbox", {
name: /Knee arthroscopy/,
});
await expect(knee).toContainText("Knee · Left");
await expect(knee).toContainText("Knee (Left)");
await expect(
menu.getByRole("menuitemcheckbox", { name: /Appendectomy/ }),
).toBeVisible();
+58 -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 ({
@@ -413,6 +468,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 +771,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();
+216
View File
@@ -0,0 +1,216 @@
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.
*
* 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",
]);
// 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}`));
});
});
+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).
+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 -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);
}
});
});
+365
View File
@@ -0,0 +1,365 @@
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 card's region, present whenever the card is switched on. What it
// 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 on
// the region and not on its content.
const hero = page.locator('[data-slot="today-card"]');
await page.goto("/");
await expect(hero).toHaveCount(1);
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(page.locator("main").first()).toBeVisible();
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(hero).toHaveCount(1);
});
});
+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 }) => {
+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 }),
});
});
}
+144
View File
@@ -33,6 +33,7 @@ import {
} from "@playwright/test";
import pg from "pg";
import { seedDayDoorsRecord } from "./day-doors-fixture";
import { seedMobileRoutesRecord } from "./mobile-routes-fixture";
// Resolve relative to the project root rather than __dirname/import.meta.url —
@@ -229,6 +230,65 @@ export const E2E_AI_OPTIONAL = {
role: "USER",
} as const;
/**
* The timeline journey's own account (`timeline.spec.ts`).
*
* It switches the opt-in timeline module on and off, which changes the
* navigation, the add menus and Settings for the whole account; on the shared
* account a parallel spec would see them move underneath it. The module
* starts off here, the way every account starts.
*/
export const E2E_TIMELINE = {
email: "e2e-timeline@healthlog.test",
username: "e2e-timeline",
password: "Tl6!Wq3zRm8vXn2K",
role: "USER",
} as const;
export const TIMELINE_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateTimeline.json",
);
/**
* The day-doors journey's own account (`day-doors.spec.ts`).
*
* It turns a chart's comparison overlay on, which is a per-account
* preference; on a shared account a parallel spec would find its chart
* drawing a prior period it never asked for. Its record carries the series
* the journey clicks (recovery, sleep, mood, weight, a lab marker).
*/
export const E2E_DAY_DOORS = {
email: "e2e-day-doors@healthlog.test",
username: "e2e-day-doors",
password: "Dy4!Rk8wQm2vTz6P",
role: "USER",
} as const;
export const DAY_DOORS_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateDayDoors.json",
);
/**
* The score-history journey's own account (`score-history.spec.ts`).
*
* It switches the dashboard's top card off and back on and remembers a range
* tab per score page, both per-account preferences; on a shared account a
* parallel spec would find its dashboard without the card it asserts.
*/
export const E2E_SCORE_HISTORY = {
email: "e2e-score-history@healthlog.test",
username: "e2e-score-history",
password: "Sh7!Vn2qLp9xWc4R",
role: "USER",
} as const;
export const SCORE_HISTORY_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateScoreHistory.json",
);
export const AI_OPTIONAL_STORAGE_STATE_PATH = resolve(
process.cwd(),
"e2e/setup/storageStateAiOptional.json",
@@ -906,6 +966,80 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
],
);
// The timeline journey's account. The module starts off; the journey
// switches it through the real Settings switch.
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed,
module_preferences_json)
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, NULL)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
updated_at = EXCLUDED.updated_at,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
module_preferences_json = NULL`,
[
cuid(),
E2E_TIMELINE.username,
E2E_TIMELINE.email,
await hashPassword(E2E_TIMELINE.password),
now,
],
);
// The day-doors journey's account. Every module the journey reaches is
// on; its record is written after the login capture.
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed,
module_preferences_json)
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, NULL)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
updated_at = EXCLUDED.updated_at,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
module_preferences_json = NULL`,
[
cuid(),
E2E_DAY_DOORS.username,
E2E_DAY_DOORS.email,
await hashPassword(E2E_DAY_DOORS.password),
now,
],
);
// The score-history journey's account. Every module on, a fresh layout:
// the journey moves its top card and range tabs, so a re-run starts from
// the defaults.
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed,
module_preferences_json)
VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, NULL)
ON CONFLICT (username) DO UPDATE SET
email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash,
updated_at = EXCLUDED.updated_at,
onboarding_completed_at = EXCLUDED.onboarding_completed_at,
onboarding_tour_completed = EXCLUDED.onboarding_tour_completed,
module_preferences_json = NULL,
dashboard_widgets_json = NULL`,
[
cuid(),
E2E_SCORE_HISTORY.username,
E2E_SCORE_HISTORY.email,
await hashPassword(E2E_SCORE_HISTORY.password),
now,
],
);
// The notification-dispatch journey's account. Seeded like the others;
// its channels, devices, ledger rows and preferences are reset by
// `e2e/setup/notification-fixture.ts` before every test, because the
@@ -1509,6 +1643,16 @@ export default async function globalSetup(config: FullConfig): Promise<void> {
// The AI-optional journeys' jar — see `E2E_AI_OPTIONAL`.
await capture(E2E_AI_OPTIONAL, AI_OPTIONAL_STORAGE_STATE_PATH);
// The timeline journey's jar — see `E2E_TIMELINE`.
await capture(E2E_TIMELINE, TIMELINE_STORAGE_STATE_PATH);
// The day-doors journey's jar, then its record — see `E2E_DAY_DOORS`.
await capture(E2E_DAY_DOORS, DAY_DOORS_STORAGE_STATE_PATH);
await seedDayDoorsRecord(baseURL, DAY_DOORS_STORAGE_STATE_PATH);
// The score-history journey's jar — see `E2E_SCORE_HISTORY`.
await capture(E2E_SCORE_HISTORY, SCORE_HISTORY_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH);
await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH);
+36
View File
@@ -133,7 +133,43 @@ test.describe("sidebar width transition", () => {
});
// With room to spare there is no edge: the default rail is unchanged.
// No native scrollbar in the rail (a classic one with arrow buttons
// read as a foreign body); a fade marks the edge that has more.
expect(
await nav.evaluate((el) => getComputedStyle(el).scrollbarWidth),
).toBe("none");
expect(
await nav.evaluate(
(el) => (el as HTMLElement).offsetWidth - el.clientWidth,
),
).toBe(0);
await expect(nav).toHaveAttribute("data-fade-top", "true");
await expect(nav).not.toHaveAttribute("data-fade-bottom", "true");
await nav.evaluate((el) => {
el.scrollTop = 0;
});
await expect(nav).toHaveAttribute("data-fade-bottom", "true");
await expect(nav).not.toHaveAttribute("data-fade-top", "true");
await page.setViewportSize({ width: 1280, height: 1400 });
await expect(footer).not.toHaveAttribute("data-nav-overflows", "true");
await expect(nav).not.toHaveAttribute("data-fade-bottom", "true");
});
test("a 1366 × 768 window fits the rail without scrolling, collapsed and expanded", async ({
page,
}) => {
await page.setViewportSize({ width: 1366, height: 768 });
await page.goto("/", { waitUntil: "domcontentloaded" });
const nav = page.locator("aside nav").first();
const toggle = page.locator('[data-slot="sidebar-collapse-toggle"]');
await expect(nav).toBeVisible();
for (let i = 0; i < 2; i++) {
await expect
.poll(() => nav.evaluate((el) => el.scrollHeight - el.clientHeight))
.toBeLessThanOrEqual(0);
await toggle.click();
await page.waitForTimeout(300);
}
});
});
+879
View File
@@ -0,0 +1,879 @@
/**
* The timeline journey (v1.42, #613), on its own account (`E2E_TIMELINE`).
*
* 1. Off by default: no navigation entry, and a deep link to /timeline
* lands on the module notice, not the page.
* 2. Switching it on in Settings opens the readiness inventory; "Open
* timeline" goes there and the navigation entry appears.
* 3. The timeline hands off to the day: "Open 3 Jan." and Enter on the
* chart both put `?day=` in the URL; an incoming `?day=` selects that
* day.
* 4. A life event is entered through the sheet and posted in the
* contract's shape.
* 5. The value lines: quarterly means over the years, a missing quarter
* left out, a short gap bridged dashed, a thin quarter hollow, and up
* to six lines.
* 6. On a phone the chronicle replaces the chart, says its empty months
* out loud, names each quarter's means once, and a row opens its day.
*
* The timeline's server routes are mocked with the contract's shapes
* (`utils/mock-timeline.ts`); the module switch is the real one. Every
* assertion is addressed to a stable `data-*` attribute. The journey moves
* its account's module switch, so it runs serially and in one project.
*/
import type { Page } from "@playwright/test";
import { TIMELINE_STORAGE_STATE_PATH } from "./setup/global-setup";
import { expect, test } from "./setup/test";
import { SYS_MISSING, SYS_THIN, mockTimeline } from "./utils/mock-timeline";
test.use({ storageState: TIMELINE_STORAGE_STATE_PATH });
test.describe.configure({ mode: "serial" });
/** Today in the zone the browser and the account run in. */
const TODAY = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
async function setTimelineModule(page: Page, on: boolean): Promise<void> {
const status = await page.evaluate(async (on) => {
const res = await fetch("/api/auth/me/modules", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ timeline: on }),
});
return res.status;
}, on);
expect(status).toBe(200);
}
const navEntry = '[data-tour-id="nav-timeline"]';
test("is off by default: no navigation entry, a deep link meets the notice", async ({
page,
}) => {
await page.goto("/settings/modules");
await setTimelineModule(page, false);
await page.goto("/timeline");
await expect(
page.locator(
'[data-slot="module-disabled-notice"][data-module="timeline"]',
),
).toBeVisible({ timeout: 20_000 });
await expect(page.locator('[data-slot="timeline-page"]')).toHaveCount(0);
await expect(page.locator(navEntry)).toHaveCount(0);
});
test("switching it on opens the readiness inventory, which leads to the timeline", async ({
page,
}) => {
await mockTimeline(page, "ready", TODAY);
await page.goto("/settings/modules");
const toggle = page.locator("#module-toggle-timeline");
await expect(toggle).toHaveAttribute("aria-checked", "false", {
timeout: 20_000,
});
await toggle.click();
const sheet = page.locator('[data-slot="timeline-readiness-sheet"]');
await expect(sheet).toBeVisible();
await expect(
page.locator(
'[data-slot="timeline-readiness-verdict"][data-verdict="carries"]',
),
).toBeVisible();
await expect(
page.locator('[data-slot="timeline-readiness-lane"][data-status="thin"]'),
).toHaveCount(1);
await expect(
page.locator(
'[data-slot="timeline-readiness-gap"][data-gap="lifeEventsEmpty"]',
),
).toHaveAttribute("href", "/timeline?add=lifeEvent");
await page.locator('[data-slot="timeline-readiness-open"]').click();
await expect(page).toHaveURL(/\/timeline$/);
await expect(page.locator('[data-slot="timeline-page"]')).toBeVisible();
await expect(page.locator(navEntry).first()).toBeAttached();
// The card carries the open gaps onto the page until it is closed.
await expect(
page.locator('[data-slot="timeline-readiness-card"]'),
).toBeVisible();
await page.locator('[data-slot="timeline-readiness-card-dismiss"]').click();
await expect(
page.locator('[data-slot="timeline-readiness-card"]'),
).toHaveCount(0);
});
test("the timeline hands the selected day to the day view through ?day=", async ({
page,
}) => {
await setTimelineModuleOn(page);
const log = await mockTimeline(page, "full", TODAY);
await page.goto("/timeline");
const chart = page.locator('[data-slot="timeline-chart"]');
await expect(chart).toBeVisible({ timeout: 20_000 });
// Empty lanes are not drawn: the record has no labs.
await expect(page.locator('svg [data-lane="labs"]')).toHaveCount(0);
await expect(page.locator('svg [data-lane="illness"]')).toHaveCount(1);
await expect(
page.locator('[data-slot="timeline-legend-causality"]'),
).toHaveCount(0);
const asked = log.timelineQueries.at(-1)?.get("values")?.split(",") ?? [];
expect(asked.length).toBeGreaterThan(0);
// Before anyone picks a day the bar opens on the newest bucket it has
// something for, says so in its head, and says how to pick another.
const bar = page.locator('[data-slot="timeline-selection-bar"]');
// It sits on the card's own surface under a hairline, not in a grey box,
// and the legend under it is set off by a hairline of its own.
const surface = await bar.evaluate((el) => {
const style = getComputedStyle(el);
return {
background: style.backgroundColor,
borderTop: style.borderTopWidth,
};
});
expect(surface.background).toBe("rgba(0, 0, 0, 0)");
expect(surface.borderTop).toBe("1px");
await expect(page.locator('[data-slot="timeline-legend"]')).toHaveCSS(
"border-top-width",
"1px",
);
await expect(bar).toHaveAttribute("data-bucket", "quarter");
await expect(bar).toHaveAttribute(
"data-period-from",
/^\d{4}-(01|04|07|10)-01$/,
);
await expect(
page.locator('[data-slot="timeline-selection-empty"]'),
).toHaveCount(0);
await expect(
page.locator('[data-slot="timeline-selection-hint"]'),
).toBeVisible();
// The keyboard: one step back and Enter opens that day.
const before = await page
.locator('[data-slot="timeline-selection"]')
.getAttribute("data-date");
await chart.focus();
await page.keyboard.press("ArrowLeft");
const after = await page
.locator('[data-slot="timeline-selection"]')
.getAttribute("data-date");
expect(after).not.toBe(before);
// A day is picked now: the hint has done its job.
await expect(
page.locator('[data-slot="timeline-selection-hint"]'),
).toHaveCount(0);
await page.keyboard.press("Enter");
await expect(page).toHaveURL(new RegExp(`[?&]day=${after}`));
// An incoming ?day= selects that day; the selection bar has no separate
// "Open <day>" link, a chip opens the day it touches.
await page.goto("/timeline?day=2026-01-03");
await expect(
page.locator('[data-slot="timeline-selection"]'),
).toHaveAttribute("data-date", "2026-01-03", { timeout: 20_000 });
await expect(
page.locator('[data-slot="timeline-zoom"] [data-value="quarter"]'),
).toHaveAttribute("aria-checked", "true");
await expect(page.locator('[data-slot="timeline-open-day"]')).toHaveCount(0);
const chip = page.locator(
'[data-slot="timeline-selection-chip"][data-item="ill-5"]',
);
await expect(chip).toBeVisible();
await chip.click();
await expect(page).toHaveURL(/[?&]day=\d{4}-\d{2}-\d{2}/);
});
test("value lines: quarterly means, gaps kept, short ones bridged, as many as the record has", async ({
page,
}) => {
await setTimelineModuleOn(page);
const log = await mockTimeline(page, "full", TODAY);
await page.goto("/timeline");
const sys = page.locator('svg [data-series="BLOOD_PRESSURE_SYS"]');
await expect(sys).toBeVisible({ timeout: 20_000 });
await expect(
page.locator('[data-slot="timeline-legend-mean"]'),
).toHaveAttribute("data-bucket", "quarter");
const point = (t: string) =>
sys.locator(`[data-slot="timeline-series-point"][data-t="${t}"]`);
// A missing quarter stays missing: no point is drawn for it.
for (const t of SYS_MISSING) await expect(point(t)).toHaveCount(0);
// The quarters around Q2 2023 are joined by a dashed bridge; the three
// missing quarters of 2021 are not (one bridge in the whole line).
await expect(sys.locator('[data-slot="timeline-series-bridge"]')).toHaveCount(
1,
);
await expect(point("2023-01-01")).toHaveCount(1);
await expect(point("2023-07-01")).toHaveCount(1);
// Two readings: drawn hollow; four and more: filled.
await expect(point(SYS_THIN)).toHaveAttribute("data-thin", "true");
await expect(point("2023-01-01")).toHaveAttribute("data-thin", "false");
// The value table keeps the missing quarter as a row of its own.
await expect(
page.locator(
'[data-slot="timeline-series-table"] [data-bucket="2023-04-01"]',
),
).toBeAttached();
// The legend explains the dashed stroke and the hollow point.
await expect(
page.locator('[data-slot="timeline-legend-gap"] svg'),
).toBeVisible();
await expect(
page.locator('[data-slot="timeline-legend-thin"] svg'),
).toBeVisible();
// The picker is one button that counts the lines; the chosen ones are
// not listed beside it.
const trigger = page.locator('[data-slot="timeline-values-trigger"]');
await expect(trigger).toHaveAttribute("data-count", "3");
await expect(trigger).toHaveText(/\(3\)/);
const toolbarHeight = await trigger.evaluate(
(el) => el.parentElement!.getBoundingClientRect().height,
);
// No cap: every series the record has can be chosen, fifteen here.
await trigger.click();
const options = page.locator('[data-slot="timeline-values-option"]');
await expect(options.first()).toBeVisible();
const count = await options.count();
expect(count).toBe(15);
await expect(
page.locator('[data-slot="timeline-values-option"][data-disabled]'),
).toHaveCount(0);
for (let i = 0; i < count; i++) {
const option = options.nth(i);
if ((await option.getAttribute("aria-checked")) === "true") continue;
await option.click();
}
await expect(
page.locator('[data-slot="timeline-values-option"][aria-checked="true"]'),
).toHaveCount(15);
// The menu marks each chosen line with its colour.
const dots = await page
.locator(
'[data-slot="timeline-values-option"][aria-checked="true"] [data-slot="timeline-values-dot"]',
)
.evaluateAll((els) =>
els.map((el) => getComputedStyle(el).backgroundColor),
);
expect(dots).toHaveLength(15);
await page.keyboard.press("Escape");
await expect
.poll(() => log.timelineQueries.at(-1)?.get("values")?.split(",").length)
.toBe(15);
await expect(page.locator("svg [data-series]")).toHaveCount(15);
await expect(trigger).toHaveAttribute("data-count", "15");
// The toolbar did not grow with the choice.
expect(
await trigger.evaluate(
(el) => el.parentElement!.getBoundingClientRect().height,
),
).toBe(toolbarHeight);
// Each line in one colour, every mark of a line in that colour, the five
// data colours repeating past the fifth line.
const colours = await page
.locator("svg [data-series]")
.evaluateAll((groups) =>
groups.map((g) => ({
colour: getComputedStyle(g).color,
marks: [
...g.querySelectorAll(
'[data-slot="timeline-series-line"], [data-slot="timeline-series-swatch"]',
),
].map((m) => getComputedStyle(m).stroke),
})),
);
expect(new Set(colours.map((c) => c.colour)).size).toBe(5);
for (const { colour, marks } of colours) {
expect(marks.length).toBeGreaterThan(0);
for (const stroke of marks) expect(stroke).toBe(colour);
}
expect(new Set(dots)).toEqual(new Set(colours.map((c) => c.colour)));
// The rows run on down the page, which scrolls; the last one is reached.
const last = page.locator("svg [data-series]").last();
await last.scrollIntoViewIfNeeded();
await expect(last).toBeInViewport();
});
test("one medication is one row: its doses cut the bar, its pause is a gap in it", async ({
page,
}) => {
await setTimelineModuleOn(page);
await mockTimeline(page, "full", TODAY);
await page.goto("/timeline");
const lane = page.locator('svg [data-lane="medications"]');
await expect(lane).toBeVisible({ timeout: 20_000 });
// The medication's own span is not drawn beside its course.
await expect(lane.locator('[data-item="mj"]')).toHaveCount(0);
const pieces = lane.locator('[data-item="mj-course"]');
await expect(pieces).toHaveCount(3);
expect(
await pieces.evaluateAll((els) => els.map((el) => el.dataset.dose)),
).toEqual(["2,5 mg", "5 mg", "7,5 mg"]);
const rowOf = (selector: string) =>
lane
.locator(selector)
.evaluateAll((els) => [...new Set(els.map((el) => el.dataset.row))]);
const mounjaroRow = await rowOf('[data-item="mj-course"]');
expect(mounjaroRow).toHaveLength(1);
expect(await rowOf('[data-kind="pause"][data-item="mj-pause"]')).toEqual(
mounjaroRow,
);
// Three winter courses of one medication share one row of their own.
const vitamin = await rowOf('[data-item^="c-"]');
expect(vitamin).toHaveLength(1);
expect(vitamin).not.toEqual(mounjaroRow);
// Four medications, four rows.
const rows = await lane
.locator("[data-row]")
.evaluateAll((els) => new Set(els.map((el) => el.dataset.row)).size);
expect(rows).toBe(4);
// A zoom change redraws the pieces: none is left behind from the last
// window (each piece has a key of its own).
const zoom = (value: string) =>
page.locator(`[data-slot="timeline-zoom"] [data-value="${value}"]`);
await zoom("year").click();
await expect(page).toHaveURL(/zoom=year/);
await zoom("all").click();
await expect(page).toHaveURL(/zoom=all/);
await expect(pieces).toHaveCount(3);
});
/** `days` calendar days before `key`. */
function daysBefore(key: string, days: number): string {
const [y, m, d] = key.split("-").map(Number);
return new Date(Date.UTC(y, m - 1, d - days)).toISOString().slice(0, 10);
}
test("a chosen range lives in the URL: reload keeps it, Back returns to it", async ({
page,
}) => {
await setTimelineModuleOn(page);
const log = await mockTimeline(page, "full", TODAY);
await page.goto("/timeline");
const chart = page.locator('[data-slot="timeline-chart"]');
await expect(chart).toBeVisible({ timeout: 20_000 });
const desktop = page.locator('[data-slot="timeline-desktop"]');
const zoom = (value: string) =>
page.locator(`[data-slot="timeline-zoom"] [data-value="${value}"]`);
// Choosing "Range" opens its fields over the page, anchored to the
// segment: the chart does not move, and the URL waits for "Apply".
const before = await chart.boundingBox();
await zoom("range").click();
const popover = page.locator('[data-slot="timeline-range-popover"]');
await expect(popover).toBeVisible();
const fields = popover.locator('[data-slot="timeline-range"]');
await expect(fields).toBeVisible();
expect(await chart.boundingBox()).toEqual(before);
await expect(desktop.locator('[data-slot="timeline-range"]')).toHaveCount(0);
expect(page.url()).not.toContain("zoom=range");
const anchor = await zoom("range").boundingBox();
const box = await popover.boundingBox();
expect(box!.y).toBeGreaterThanOrEqual(anchor!.y + anchor!.height);
expect(Math.abs(box!.x - anchor!.x)).toBeLessThan(24);
// Starts from what is on screen; "Apply" writes it down.
await popover.locator('[data-slot="timeline-range-apply"]').click();
await expect(popover).toHaveCount(0);
await expect(page).toHaveURL(
/[?&]zoom=range&from=\d{4}-\d{2}-\d{2}&to=\d{4}-\d{2}-\d{2}/,
);
// Three weeks: daily means, the bar headed by one day.
const from = daysBefore(TODAY, 20);
await page.goto(`/timeline?zoom=range&from=${from}&to=${TODAY}`);
await expect(desktop).toHaveAttribute("data-from", from, {
timeout: 20_000,
});
await expect(desktop).toHaveAttribute("data-to", TODAY);
await expect(zoom("range")).toHaveAttribute("aria-checked", "true");
await expect(
page.locator('[data-slot="timeline-legend-mean"]'),
).toHaveAttribute("data-bucket", "day");
await expect(
page.locator('[data-slot="timeline-selection-bar"]'),
).toHaveAttribute("data-bucket", "day");
const asked = log.timelineQueries.at(-1)!;
expect(asked.get("zoom")).toBe("range");
expect(asked.get("from")).toBe(from);
expect(asked.get("to")).toBe(TODAY);
// A reload keeps it.
await page.reload();
await expect(desktop).toHaveAttribute("data-from", from, {
timeout: 20_000,
});
// A fixed zoom replaces it; Back brings it back.
await zoom("year").click();
await expect(page).toHaveURL(/[?&]zoom=year$/);
await expect(desktop).not.toHaveAttribute("data-from", from);
await page.goBack();
await expect(page).toHaveURL(new RegExp(`zoom=range&from=${from}`));
await expect(desktop).toHaveAttribute("data-from", from);
// Typing a start in the popover moves the range once it is applied.
const later = daysBefore(TODAY, 10);
await zoom("range").click();
const start = popover.locator('[data-testid="timeline-range-from"]');
await start.fill(later);
await start.press("Enter");
expect(page.url()).toContain(`from=${from}`);
await popover.locator('[data-slot="timeline-range-apply"]').click();
await expect(page).toHaveURL(new RegExp(`from=${later}&to=${TODAY}`));
// A parameter it cannot read opens the whole record.
await page.goto("/timeline?zoom=range&from=nonsense&to=2026-01-01");
await expect(zoom("all")).toHaveAttribute("aria-checked", "true", {
timeout: 20_000,
});
await expect(desktop).toHaveAttribute("data-zoom", "all");
});
async function setTimelineModuleOn(page: Page) {
await page.goto("/settings/modules");
await setTimelineModule(page, true);
}
test("a life event is entered through the sheet, in the contract's shape", async ({
page,
}) => {
await setTimelineModuleOn(page);
const log = await mockTimeline(page, "full", TODAY);
await page.goto("/timeline?add=lifeEvent");
const form = page.locator('[data-slot="life-event-form"]');
await expect(form).toBeVisible({ timeout: 20_000 });
// Saving an empty form says what is missing and sends nothing.
await page.locator('[data-slot="life-event-save"]').click();
await expect(form.locator('[aria-invalid="true"]').first()).toBeVisible();
expect(log.lifeEventPosts).toHaveLength(0);
await page
.locator('[data-slot="life-event-title"]')
.fill("Umzug nach Bochum");
await page
.locator('[data-slot="life-event-category"] [data-value="HOME"]')
.click();
await page
.locator('[data-slot="life-event-precision"] [data-value="MONTH"]')
.click();
await expect(page.locator('[data-slot="life-event-privacy"]')).toBeVisible();
await page.locator('[data-slot="life-event-save"]').click();
await expect(form).toHaveCount(0);
expect(log.lifeEventPosts).toHaveLength(1);
const posted = log.lifeEventPosts[0] as Record<string, string | null>;
expect(posted).toMatchObject({
title: "Umzug nach Bochum",
category: "HOME",
precision: "MONTH",
endDate: null,
note: null,
});
expect(posted.startDate).toMatch(/^\d{4}-\d{2}-01$/);
// The parameter left the URL with the sheet.
await expect(page).toHaveURL(/\/timeline$/);
});
test("on a phone the chronicle replaces the chart and opens a day per row", async ({
page,
}) => {
await setTimelineModuleOn(page);
await mockTimeline(page, "full", TODAY);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/timeline");
const chronicle = page.locator('[data-slot="timeline-chronicle"]');
await expect(chronicle).toBeVisible({ timeout: 20_000 });
await expect(page.locator('[data-slot="timeline-chart"]')).toBeHidden();
await expect(page.locator('[data-slot="timeline-standing"]')).toBeVisible();
await expect(
page.locator('[data-slot="timeline-chronicle-gap"]').first(),
).toBeVisible();
// Quarterly means, each quarter named once, at its newest listed month.
const means = page.locator('[data-slot="timeline-chronicle-means"]');
await expect(means.first()).toBeVisible();
const buckets = await means.evaluateAll((els) =>
els.map((el) => el.getAttribute("data-bucket") ?? ""),
);
expect(buckets.length).toBeGreaterThan(0);
expect(new Set(buckets).size).toBe(buckets.length);
for (const b of buckets) expect(b).toMatch(/^\d{4}-(01|04|07|10)-01$/);
// Each mean carries its line's colour dot.
await expect(
means.first().locator('[data-slot="timeline-series-dot"]').first(),
).toBeVisible();
// One medication, one start: its course and its own span are one row,
// with the dose taken then.
const started = chronicle.locator(
'[data-slot="timeline-chronicle-row"][data-date="2025-01-06"]',
);
await expect(started).toHaveCount(1);
await expect(started).toContainText("Mounjaro");
await expect(started).toContainText("2,5 mg");
await expect(chronicle).not.toContainText("·");
// A range is chosen in a sheet; the list underneath does not move.
const listTop = (await chronicle.boundingBox())!.y;
await page.locator('[data-slot="timeline-range-toggle"]').click();
const sheet = page.locator('[data-slot="responsive-sheet-content"]');
await expect(sheet.locator('[data-slot="timeline-range"]')).toBeVisible();
expect((await chronicle.boundingBox())!.y).toBe(listTop);
await sheet.locator('[data-slot="timeline-range-apply"]').click();
await expect(page).toHaveURL(/[?&]zoom=range&from=\d{4}-\d{2}-\d{2}/);
await page.goto("/timeline");
await expect(chronicle).toBeVisible({ timeout: 20_000 });
await page
.locator(
'[data-slot="timeline-chronicle-row"][data-date="2026-01-03"] button',
)
.first()
.click();
await expect(page).toHaveURL(/[?&]day=2026-01-03/);
});
/** A calendar date `days` before today, in the account's zone. */
function daysAgo(days: number): string {
const at = new Date(`${TODAY}T12:00:00Z`);
at.setUTCDate(at.getUTCDate() - days);
return at.toISOString().slice(0, 10);
}
test("every medication still taken is drawn, from the account's real record", async ({
page,
}) => {
await setTimelineModuleOn(page);
// Written through the app's own routes, once: a medication with a start
// date (it opens a course), one without (its first intake is its start).
const list = await page.request.get("/api/medications");
const held = JSON.stringify(await list.json());
const create = async (body: Record<string, unknown>) => {
const res = await page.request.post("/api/medications", { data: body });
expect(res.status()).toBeLessThan(300);
return ((await res.json()) as { data: { id: string } }).data.id;
};
if (!held.includes("E2E Ramipril")) {
await create({
name: "E2E Ramipril",
dose: "5 mg",
startsOn: daysAgo(40),
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
});
}
if (!held.includes("E2E Vitamin D3")) {
const id = await create({
name: "E2E Vitamin D3",
dose: "2000 IU",
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
});
const intake = await page.request.post(`/api/medications/${id}/intake`, {
data: { takenAt: new Date(Date.now() - 20 * 86_400_000).toISOString() },
});
expect(intake.status()).toBeLessThan(300);
}
await page.goto("/timeline?zoom=quarter");
const lane = page.locator('svg [data-lane="medications"]');
await expect(lane).toHaveCount(1, { timeout: 20_000 });
// One row each, and every one of them reaches today.
for (const name of ["E2E Ramipril", "E2E Vitamin D3"]) {
const bars = lane.locator("g[data-row]", {
has: page.locator(`title:text-matches("^${name}")`),
});
await expect(bars.first()).toBeAttached();
const rows = await bars.evaluateAll((els) =>
els.map((el) => el.getAttribute("data-row")),
);
expect(new Set(rows).size).toBe(1);
}
});
test("an open day stays open from the timeline to another page", async ({
page,
}) => {
await setTimelineModuleOn(page);
await mockTimeline(page, "full", TODAY);
await page.setViewportSize({ width: 1440, height: 900 });
const day = daysAgo(3);
await page.goto(`/timeline?day=${day}`);
const panel = page.locator('[data-slot="day-panel"]');
await expect(panel).toBeVisible({ timeout: 20_000 });
await page.locator('a[href="/labs"]').first().click();
await expect(page).toHaveURL(new RegExp(`/labs\\?(.*&)?day=${day}`));
await expect(panel).toBeVisible();
await expect(page.locator('[data-slot="day-view"]')).toHaveAttribute(
"data-day",
day,
);
// Folded by the person, it stays folded on the next page.
await page.locator('[data-slot="day-close"]').click();
await expect(panel).toHaveCount(0);
await page.locator('a[href="/timeline"]').first().click();
await expect(page).toHaveURL(/\/timeline$/);
await expect(page.locator('[data-slot="day-strip"]')).toHaveAttribute(
"data-state",
"closed",
);
await expect(panel).toHaveCount(0);
});
test("a value point reads on its own, by pointer and by keyboard", async ({
page,
}) => {
await setTimelineModuleOn(page);
await mockTimeline(page, "full", TODAY);
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/timeline");
const targets = page.locator('[data-slot="timeline-point-target"]');
await expect(targets.first()).toBeAttached({ timeout: 20_000 });
const target = targets.last();
const series = await target.getAttribute("data-series");
const t = await target.getAttribute("data-t");
await target.hover();
const tip = page.locator('[data-slot="timeline-point-tip"]');
await expect(tip).toBeVisible();
await expect(tip).toHaveAttribute("data-series", series!);
await expect(tip).toHaveAttribute("data-t", t!);
// One line only, never the whole bucket.
await expect(tip.locator('[data-slot="rich-chart-tooltip-row"]')).toHaveCount(
1,
);
// Inside the chart, and the page does not scroll sideways for it.
const chart = (await page
.locator('[data-slot="timeline-chart"]')
.boundingBox())!;
const box = (await tip.boundingBox())!;
expect(box.x).toBeGreaterThanOrEqual(chart.x - 1);
expect(box.x + box.width).toBeLessThanOrEqual(chart.x + chart.width + 1);
// The keyboard: focus shows the card, the arrows walk the line.
await page.mouse.move(0, 0);
await target.focus();
await expect(tip).toHaveAttribute("data-t", t!);
await page.keyboard.press("ArrowLeft");
await expect(tip).not.toHaveAttribute("data-t", t!);
await expect(tip).toHaveAttribute("data-series", series!);
});
test("the timeline page ends where its content does, at every desktop width", async ({
page,
}) => {
await setTimelineModuleOn(page);
await mockTimeline(page, "full", TODAY);
for (const width of [1280, 1440, 1920]) {
for (const day of [null, daysAgo(3)]) {
await page.setViewportSize({ width, height: 900 });
await page.goto(day ? `/timeline?day=${day}` : "/timeline");
await expect(
page.locator('[data-slot="timeline-chart"] > svg'),
).toBeVisible({ timeout: 20_000 });
const m = await page.evaluate(() => {
const main = document.getElementById("main-content")!;
const page = document.querySelector('[data-slot="timeline-page"]')!;
const doc = document.scrollingElement!;
return {
mainSw: main.scrollWidth,
mainCw: main.clientWidth,
docSw: doc.scrollWidth,
docCw: doc.clientWidth,
docSh: doc.scrollHeight,
docCh: doc.clientHeight,
mainSh: main.scrollHeight,
contentBottom:
page.getBoundingClientRect().bottom -
main.getBoundingClientRect().top +
main.scrollTop,
};
});
const where = `${width}px ${day ? "with" : "without"} the day`;
expect(m.mainSw, where).toBeLessThanOrEqual(m.mainCw + 1);
expect(m.docSw, where).toBeLessThanOrEqual(m.docCw + 1);
expect(m.docSh, where).toBeLessThanOrEqual(m.docCh + 1);
// The shell's own buffer below the last card (pb-20 and the top
// padding), nothing more.
expect(m.mainSh, where).toBeLessThanOrEqual(m.contentBottom + 104 + 2);
}
}
});
test("listing all of a day's values scrolls the panel, never the page", async ({
page,
}) => {
await setTimelineModuleOn(page);
await mockTimeline(page, "full", TODAY);
const day = daysAgo(3);
const types = [
"WEIGHT",
"BODY_FAT",
"PULSE",
"RESTING_HEART_RATE",
"HEART_RATE_VARIABILITY",
"RESPIRATORY_RATE",
"OXYGEN_SATURATION",
"BODY_TEMPERATURE",
"MUSCLE_MASS",
"BONE_MASS",
"FAT_MASS",
"VISCERAL_FAT",
"BLOOD_GLUCOSE",
"ACTIVITY_STEPS",
];
await page.route(`**/api/day/${day}`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
date: day,
tz: "Europe/Berlin",
counts: { values: types.length, entries: 0 },
running: [],
values: types.map((type, i) => ({
type,
value: 10 + i,
unit: "u",
at: `${day}T0${i % 10}:00:00.000Z`,
source: "MANUAL",
band: null,
})),
events: [],
notable: [],
scores: [],
sections: {},
},
error: null,
}),
}),
);
await page.setViewportSize({ width: 1440, height: 800 });
await page.goto(`/timeline?day=${day}`);
const all = page.locator('[data-slot="day-values-all"]');
await expect(all).toBeVisible({ timeout: 20_000 });
const heights = () =>
page.evaluate(() => ({
doc: document.scrollingElement!.scrollHeight,
main: document.getElementById("main-content")!.scrollHeight,
}));
const before = await heights();
await all.click();
await expect(all).toHaveAttribute("aria-expanded", "true");
const after = await heights();
expect(Math.abs(after.doc - before.doc)).toBeLessThanOrEqual(2);
expect(Math.abs(after.main - before.main)).toBeLessThanOrEqual(2);
});
test("the day's strip stays at the right edge, opens and closes the day, and never moves", async ({
page,
}, testInfo) => {
await setTimelineModuleOn(page);
await mockTimeline(page, "full", TODAY);
const strip = page.locator('[data-slot="day-strip"]');
const toggle = page.locator('[data-slot="day-strip-toggle"]');
const panel = page.locator('[data-slot="day-panel"]');
const where = async () => {
const box = (await strip.boundingBox())!;
return [box.x, box.y, box.width, box.height].map(Math.round);
};
const measure = () =>
page.evaluate(() => {
const main = document.getElementById("main-content")!;
const doc = document.scrollingElement!;
return {
docSw: doc.scrollWidth - doc.clientWidth,
docSh: doc.scrollHeight - doc.clientHeight,
mainSw: main.scrollWidth - main.clientWidth,
bar: document
.querySelector('[data-slot="top-bar"]')!
.getBoundingClientRect().bottom,
};
});
for (const width of [1280, 1440, 1920]) {
await page.setViewportSize({ width, height: 900 });
await page.goto("/timeline");
await expect(
page.locator('[data-slot="timeline-chart"] > svg'),
).toBeVisible({ timeout: 20_000 });
// Closed: the strip is there, at the right edge, below the top bar's
// band, holding today.
await expect(strip).toHaveAttribute("data-state", "closed");
await expect(toggle).toHaveAttribute("aria-expanded", "false");
const closed = await where();
expect(closed[0] + closed[2], `${width}px right edge`).toBe(width);
const before = await measure();
expect(
Math.round((await toggle.boundingBox())!.y),
`${width}px below the band`,
).toBe(Math.round(before.bar));
expect(before.docSw).toBeLessThanOrEqual(0);
expect(before.mainSw).toBeLessThanOrEqual(0);
// A click opens the day left of the strip; the strip stays put.
await toggle.click();
await expect(panel).toHaveAttribute("data-shell", "docked");
await expect(strip).toHaveAttribute("data-state", "open");
await expect(page.locator("#day-docked-panel")).toHaveAttribute(
"data-state",
"open",
);
await expect
.poll(async () => {
const box = (await panel.boundingBox())!;
return Math.round(box.x + box.width);
})
.toBe(closed[0]);
expect(await where()).toEqual(closed);
await expect(panel.locator("h2").first()).toBeFocused();
const open = await measure();
expect(open.docSw, `${width}px open, sideways`).toBeLessThanOrEqual(0);
expect(open.mainSw, `${width}px open, sideways`).toBeLessThanOrEqual(0);
expect(open.docSh, `${width}px open, page height`).toBeLessThanOrEqual(
before.docSh,
);
await testInfo.attach(`day-strip-${width}-open`, {
body: await page.screenshot(),
contentType: "image/png",
});
// A second click closes it; focus stays on the strip, which stays put.
await toggle.click();
await expect(panel).toHaveCount(0);
await expect(strip).toHaveAttribute("data-state", "closed");
await expect(toggle).toBeFocused();
expect(await where()).toEqual(closed);
await expect(page).toHaveURL(/\/timeline$/);
}
// Both themes, open at 1440.
await page.setViewportSize({ width: 1440, height: 900 });
for (const colorScheme of ["light", "dark"] as const) {
await page.emulateMedia({ colorScheme });
await page.goto("/timeline");
await toggle.click();
await expect(panel).toHaveAttribute("data-shell", "docked");
await testInfo.attach(`day-strip-1440-${colorScheme}`, {
body: await page.screenshot(),
contentType: "image/png",
});
await toggle.click();
await expect(panel).toHaveCount(0);
}
});
+12
View File
@@ -162,8 +162,17 @@ test("a fresh arrival flips the day in place", async ({ page }) => {
// swept by the same `e2e-just-in-` purge on the next run.
const priorNight = new Date(Date.now() - 3 * 24 * 60 * 60_000);
const priorWoke = new Date(priorNight.getTime() + 7 * 60 * 60_000);
//
// Tagged `foreground`, the trigger an open app sends. Since v1.42 a batch
// without it only marks the dashboard snapshot stale, and `cachedSwr`
// serves the stale cell once while it rebuilds: a snapshot an earlier
// spec left for this account (no sleep at all, so no sleep expected)
// would then paint the BEFORE state as `final`, depending on which spec
// happened to run first in the shard. `foreground` evicts, so the first
// read below is built from the rows this spec just wrote.
const priorBatch = await page.request.post("/api/measurements/batch", {
data: {
syncTrigger: "foreground",
entries: [
{
hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
@@ -229,8 +238,11 @@ test("a fresh arrival flips the day in place", async ({ page }) => {
const wokeAt = new Date(now.getTime() - 60 * 60_000);
const fellAsleepAt = new Date(wokeAt.getTime() - 7 * 60 * 60_000);
// `foreground` for the same reason as the prior night above: the page
// is open while it lands.
const batch = await page.request.post("/api/measurements/batch", {
data: {
syncTrigger: "foreground",
entries: [
{
hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis",
+889
View File
@@ -0,0 +1,889 @@
import type { Page, Route } from "@playwright/test";
/**
* Route mocks for the timeline (v1.42, #613): `GET /api/timeline`,
* `GET /api/timeline/readiness` and `/api/life-events`, answered in the
* shapes of `src/lib/day/contract.ts` from an invented record. Two records:
* `full` (years of history in every lane) and `ready` (a thin record that
* starts in May 2024, a medication without a start date, no life events, no
* vaccinations), matching the two states the design was approved in.
*
* Every value here is made up.
*/
type Item = {
id: string;
kind: string;
start: string;
end: string | null;
open: boolean;
precision: "DAY" | "MONTH" | "YEAR";
startKnown: boolean;
label: string;
sub: string | null;
href: string | null;
/** The medication an item belongs to; one row per group. */
group: string | null;
};
function item(over: Partial<Item> & Pick<Item, "id" | "kind" | "start">): Item {
return {
end: null,
open: false,
precision: "DAY",
startKnown: true,
label: over.id,
sub: null,
href: null,
group: null,
...over,
};
}
/** A small deterministic generator, so every run draws the same lines. */
function rng(seed: number) {
let s = seed;
return () => {
s = (s * 16807) % 2147483647;
return (s - 1) / 2147483646;
};
}
type Bucket = "quarter" | "month" | "week" | "day";
/** The bucket the server picks for a zoom (`timelineBucket`). */
export function bucketFor(
zoom: string,
from?: string | null,
to?: string | null,
): Bucket {
if (zoom === "range" && from && to) {
// `rangeBucket`: by the range's length in days.
const days = dayNum(to) - dayNum(from) + 1;
return days >= 730
? "quarter"
: days >= 120
? "month"
: days >= 42
? "week"
: "day";
}
return zoom === "quarter" ? "week" : zoom === "year" ? "month" : "quarter";
}
function dayNum(key: string): number {
const [y, m, d] = key.split("-").map(Number);
return Math.round(Date.UTC(y, m - 1, d) / 86_400_000);
}
function dayStr(n: number): string {
return new Date(n * 86_400_000).toISOString().slice(0, 10);
}
/** Bucket starts from the one holding `from` to the one holding `to`. */
function bucketStarts(from: string, to: string, bucket: Bucket): string[] {
const out: string[] = [];
if (bucket === "day") {
for (let d = dayNum(from); d <= dayNum(to); d++) out.push(dayStr(d));
return out;
}
if (bucket === "week") {
const n = dayNum(from);
// 1970-01-01 was a Thursday: day 4 is the first Monday.
for (let d = n - ((((n - 4) % 7) + 7) % 7); d <= dayNum(to); d += 7) {
out.push(dayStr(d));
}
return out;
}
const step = bucket === "quarter" ? 3 : 1;
let [y, m] = from.split("-").map(Number);
m -= (m - 1) % step;
const [ty, tm] = to.split("-").map(Number);
while (y < ty || (y === ty && m <= tm)) {
out.push(`${y}-${String(m).padStart(2, "0")}-01`);
m += step;
if (m > 12) {
m -= 12;
y++;
}
}
return out;
}
/**
* Systolic pressure leaves gaps the chart has to show honestly: in the
* quarterly view one missing quarter (Q2 2023, bridged dashed) and three
* (2021, the line breaks). Q1 2022 rests on two readings in every series.
*/
export const SYS_MISSING = new Set([
"2021-01-01",
"2021-04-01",
"2021-07-01",
"2023-04-01",
]);
export const SYS_THIN = "2022-01-01";
function points(
from: string,
to: string,
bucket: Bucket,
value: (i: number, key: string) => number,
skip: (key: string) => boolean = () => false,
) {
const starts = bucketStarts(from, to, bucket);
return starts
.map((key, i) => ({
t: key,
mean: Math.round(value(i / starts.length, key) * 10) / 10,
count: key === SYS_THIN ? 2 : 4 + (i % 9),
}))
.filter((p) => !skip(p.t));
}
function series(dataFrom: string, today: string, bucket: Bucket) {
const r = rng(11);
return [
{
key: "BLOOD_PRESSURE_SYS",
unit: "mmHg",
points: points(
dataFrom,
today,
bucket,
(f) => 147 - 18 * f + (r() - 0.5) * 4,
(t) => bucket === "quarter" && SYS_MISSING.has(t),
),
},
{
key: "BLOOD_PRESSURE_DIA",
unit: "mmHg",
points: points(
dataFrom,
today,
bucket,
(f) => 92 - 10 * f + (r() - 0.5) * 3,
),
},
{
key: "WEIGHT",
unit: "kg",
points: points(
dataFrom,
today,
bucket,
(f) => 89 - 6.5 * f + (r() - 0.5) * 0.6,
),
},
{
key: "RESTING_HEART_RATE",
unit: "bpm",
points: points(dataFrom, today, bucket, (f, k) => {
const month = Number(k.slice(5, 7));
return (
68 - 5 * f + Math.sin((month / 12) * 6.28) * 1.2 + (r() - 0.5) * 1.2
);
}),
},
{
key: "PULSE",
unit: "bpm",
points: points(dataFrom, today, bucket, (f) => 74 - 4 * f + r()),
},
{
key: "BODY_FAT",
unit: "%",
points: points(dataFrom, today, bucket, (f) => 24 - 3 * f + r() * 0.4),
},
{
key: "ACTIVITY_STEPS",
unit: "steps",
points: points(
dataFrom,
today,
bucket,
(f) => 7200 + 900 * f + r() * 400,
),
},
];
}
/**
* Every series the `full` record can show: fifteen, past the five colours
* and past the six lines the chart used to stop at.
*/
export const FULL_AVAILABLE = [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"BODY_FAT",
"SLEEP_DURATION",
"ACTIVITY_STEPS",
"BLOOD_GLUCOSE",
"OXYGEN_SATURATION",
"HEART_RATE_VARIABILITY",
"RESTING_HEART_RATE",
"VO2_MAX",
"BODY_TEMPERATURE",
"RESPIRATORY_RATE",
"MOOD",
];
export function fullTimeline(today: string) {
const vaccinations = [
"2020-06-10",
"2021-05-20",
"2021-07-01",
"2021-12-10",
"2022-10-15",
"2023-10-20",
"2024-03-20",
"2024-04-25",
"2024-10-18",
"2025-10-10",
];
const visits = [
"2019-03-10",
"2020-03-05",
"2021-09-14",
"2022-09-20",
"2023-09-12",
"2024-09-17",
"2025-09-18",
"2026-01-03",
"2026-06-22",
];
const documents = [
"2019-03-12",
"2020-03-06",
"2021-09-15",
"2022-09-21",
"2022-11-09",
"2023-02-20",
"2023-09-13",
"2024-09-18",
"2025-03-14",
"2025-09-19",
"2026-01-03",
"2026-06-23",
];
return {
zoom: "all",
range: {
from: "2019-01-01",
to: `${today.slice(0, 4)}-12-31`,
dataFrom: "2019-01-01",
},
lanes: [
{
key: "life",
items: [
item({
id: "le-1",
kind: "lifeEvent",
start: "2021-05-14",
label: "Geburt Tochter",
}),
item({
id: "le-2",
kind: "lifeEvent",
start: "2023-09-01",
precision: "MONTH",
label: "Umzug",
}),
item({
id: "le-3",
kind: "lifeEvent",
start: "2024-10-01",
label: "Neue Stelle",
}),
],
},
{
key: "illness",
items: [
item({
id: "ill-c",
kind: "chronic",
start: "2019-03-10",
open: true,
label: "Bluthochdruck",
sub: "chronisch",
href: "/illness",
}),
item({
id: "ill-1",
kind: "episode",
start: "2020-09-01",
end: "2020-10-20",
label: "Schulter",
}),
item({
id: "ill-2",
kind: "episode",
start: "2022-03-08",
end: "2022-03-24",
label: "COVID-19",
}),
item({
id: "ill-3",
kind: "episode",
start: "2022-11-02",
end: "2023-02-15",
label: "Bandscheibe L4/L5",
}),
item({
id: "ill-4",
kind: "episode",
start: "2024-08-10",
end: "2024-08-16",
label: "Magen-Darm",
}),
item({
id: "ill-5",
kind: "episode",
start: "2025-12-31",
end: "2026-01-08",
label: "Erkältung",
}),
],
},
{
key: "allergies",
items: [
item({
id: "al-1",
kind: "allergy",
start: "2018-01-01",
open: true,
label: "Gräserpollen",
sub: "seit 2008",
}),
item({
id: "al-2",
kind: "allergy",
start: "2020-07-15",
open: true,
label: "Penicillin",
}),
],
},
{
key: "medications",
items: [
item({
id: "med-1",
group: "med-1",
kind: "medication",
start: "2019-04-02",
open: true,
label: "Ramipril",
sub: "2,5 mg",
}),
item({
id: "dose-1",
group: "med-1",
kind: "doseChange",
start: "2020-08-12",
label: "Ramipril",
sub: "5 mg",
}),
item({
id: "med-2",
group: "med-2",
kind: "medication",
start: "2022-11-03",
end: "2022-12-10",
label: "Ibuprofen",
}),
// One medication sent as its own span, a course, three dose
// changes and a pause: the case the beta drew as two medications.
item({
id: "mj",
group: "mj",
kind: "medication",
start: "2025-01-06",
open: true,
label: "Mounjaro",
sub: "7,5 mg",
}),
item({
id: "mj-course",
group: "mj",
kind: "course",
start: "2025-01-06",
open: true,
label: "Mounjaro",
}),
...(
[
["mj-25", "2025-01-06", "2,5 mg"],
["mj-5", "2025-03-03", "5 mg"],
["mj-75", "2025-06-02", "7,5 mg"],
] as const
).map(([id, start, sub]) =>
item({
id,
group: "mj",
kind: "doseChange",
start,
label: "Mounjaro",
sub,
}),
),
item({
id: "mj-pause",
group: "mj",
kind: "pause",
start: "2025-08-01",
end: "2025-08-20",
label: "Mounjaro",
}),
item({
id: "c-1",
group: "vit-d",
kind: "course",
start: "2023-11-01",
end: "2024-03-31",
label: "Vitamin D (Winter)",
}),
item({
id: "c-2",
group: "vit-d",
kind: "course",
start: "2024-11-01",
end: "2025-03-31",
label: "Vitamin D (Winter)",
}),
item({
id: "c-3",
group: "vit-d",
kind: "course",
start: "2025-11-01",
end: "2026-03-31",
label: "Vitamin D (Winter)",
}),
],
},
{
key: "vaccinations",
items: vaccinations.map((d, i) =>
item({
id: `vac-${i}`,
kind: "vaccination",
start: d,
label: "Impfung",
}),
),
},
{
key: "visits",
items: [
...visits.map((d, i) =>
item({
id: `v-${i}`,
kind: "visit",
start: d,
label: "Hausarztpraxis",
}),
),
item({
id: "p-1",
kind: "procedure",
start: "2022-11-08",
label: "MRT Lendenwirbelsäule",
}),
item({
id: "p-2",
kind: "procedure",
start: "2025-03-12",
label: "Darmspiegelung",
}),
],
},
{
key: "documents",
items: documents.map((d, i) =>
item({ id: `doc-${i}`, kind: "document", start: d, label: "Befund" }),
),
},
],
standing: [],
bucket: "quarter" as Bucket,
series: series("2019-01-01", today, "quarter"),
availableSeries: FULL_AVAILABLE,
notable: [{ date: "2026-01-04", kind: "extremeHigh" }],
};
}
export function readyTimeline(today: string) {
return {
zoom: "all",
range: {
from: "2024-01-01",
to: `${today.slice(0, 4)}-12-31`,
dataFrom: "2024-05-01",
},
lanes: [
{
key: "illness",
items: [
item({
id: "ill-c",
kind: "chronic",
start: "2019-03-10",
open: true,
label: "Bluthochdruck",
sub: "chronisch",
}),
item({
id: "ill-4",
kind: "episode",
start: "2024-08-10",
end: "2024-08-16",
label: "Magen-Darm",
}),
item({
id: "ill-5",
kind: "episode",
start: "2025-12-31",
end: "2026-01-08",
label: "Erkältung",
}),
],
},
{
key: "medications",
items: [
item({
id: "med-1",
group: "med-1",
kind: "medication",
start: "2024-05-01",
open: true,
startKnown: false,
label: "Ramipril",
sub: "5 mg",
}),
item({
id: "c-2",
group: "vit-d",
kind: "course",
start: "2024-11-01",
end: "2025-03-31",
label: "Vitamin D (Winter)",
}),
item({
id: "c-3",
group: "vit-d",
kind: "course",
start: "2025-11-01",
end: "2026-03-31",
label: "Vitamin D (Winter)",
}),
],
},
{
key: "visits",
items: [
item({
id: "v-1",
kind: "visit",
start: "2024-09-17",
label: "Hausarztpraxis",
}),
item({
id: "p-2",
kind: "procedure",
start: "2025-03-12",
label: "Darmspiegelung",
}),
item({
id: "v-2",
kind: "visit",
start: "2025-09-18",
label: "Hausarztpraxis",
}),
item({
id: "v-3",
kind: "visit",
start: "2026-01-03",
label: "Hausarztpraxis",
}),
item({
id: "v-4",
kind: "visit",
start: "2026-06-22",
label: "Hausarztpraxis",
}),
],
},
{
key: "documents",
items: [
"2024-09-18",
"2025-03-14",
"2025-09-19",
"2026-01-03",
"2026-06-23",
].map((d, i) =>
item({ id: `doc-${i}`, kind: "document", start: d, label: "Befund" }),
),
},
],
standing: [],
bucket: "quarter" as Bucket,
series: series("2024-05-01", today, "quarter").filter(
(s) => s.key !== "RESTING_HEART_RATE",
),
availableSeries: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "WEIGHT"],
notable: [],
};
}
export function fullReadiness() {
return {
verdict: "carries",
since: "2019-03-01",
lanes: [
{
key: "values",
status: "carries",
count: 3,
detail: { key: "values", params: { count: 3, since: "2019-03-01" } },
gaps: [],
},
{
key: "illness",
status: "carries",
count: 6,
detail: { key: "illness", params: { count: 6, chronic: 1 } },
gaps: [],
},
{
key: "visits",
status: "carries",
count: 11,
detail: { key: "visits", params: { count: 11, procedures: 2 } },
gaps: [],
},
{
key: "documents",
status: "carries",
count: 14,
detail: { key: "documents", params: { count: 14 } },
gaps: [],
},
{
key: "labs",
status: "carries",
count: 5,
detail: { key: "labs", params: { count: 5 } },
gaps: [],
},
{
key: "medications",
status: "thin",
count: 5,
detail: { key: "medications", params: { count: 5, missingStart: 3 } },
gaps: [
{ key: "medicationsWithoutStart", count: 3, href: "/medications" },
],
},
{
key: "vaccinations",
status: "empty",
count: 0,
detail: { key: "empty", params: {} },
gaps: [{ key: "vaccinationsEmpty", count: 0, href: "/vaccinations" }],
},
{
key: "life",
status: "empty",
count: 0,
detail: null,
gaps: [
{ key: "lifeEventsEmpty", count: 0, href: "/timeline?add=lifeEvent" },
],
},
],
};
}
export function readyReadiness() {
return {
verdict: "carries",
since: "2024-05-01",
lanes: [
{
key: "values",
status: "carries",
count: 2,
detail: { key: "values", params: { count: 2, since: "2024-05-01" } },
gaps: [],
},
{
key: "illness",
status: "carries",
count: 3,
detail: { key: "illness", params: { count: 3, chronic: 1 } },
gaps: [],
},
{
key: "visits",
status: "carries",
count: 5,
detail: { key: "visits", params: { count: 5, procedures: 1 } },
gaps: [],
},
{
key: "documents",
status: "carries",
count: 5,
detail: { key: "documents", params: { count: 5 } },
gaps: [],
},
{
key: "medications",
status: "thin",
count: 3,
detail: { key: "medications", params: { count: 3, missingStart: 3 } },
gaps: [
{ key: "medicationsWithoutStart", count: 3, href: "/medications" },
],
},
{
key: "life",
status: "empty",
count: 0,
detail: null,
gaps: [
{ key: "lifeEventsEmpty", count: 0, href: "/timeline?add=lifeEvent" },
],
},
{
key: "vaccinations",
status: "empty",
count: 0,
detail: { key: "empty", params: {} },
gaps: [{ key: "vaccinationsEmpty", count: 0, href: "/vaccinations" }],
},
],
};
}
const LIFE_EVENTS = [
{
id: "le-1",
category: "FAMILY",
startDate: "2021-05-14",
endDate: null,
precision: "DAY",
title: "Geburt Tochter",
note: null,
},
{
id: "le-2",
category: "HOME",
startDate: "2023-09-01",
endDate: null,
precision: "MONTH",
title: "Umzug",
note: null,
},
{
id: "le-3",
category: "WORK",
startDate: "2024-10-01",
endDate: null,
precision: "DAY",
title: "Neue Stelle",
note: null,
},
].map((e) => ({
...e,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
}));
function json(route: Route, status: number, data: unknown) {
return route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify({ data, error: null }),
});
}
export interface TimelineMockLog {
timelineQueries: URLSearchParams[];
lifeEventPosts: unknown[];
}
/**
* Answer the timeline routes from the `full` or `ready` record. Returns a
* log of what the page asked for and posted, for assertions.
*/
export async function mockTimeline(
page: Page,
state: "full" | "ready",
today: string,
): Promise<TimelineMockLog> {
const log: TimelineMockLog = { timelineQueries: [], lifeEventPosts: [] };
await page.route(/\/api\/timeline\/readiness(\?|$)/, (route) =>
json(route, 200, state === "full" ? fullReadiness() : readyReadiness()),
);
await page.route(/\/api\/timeline(\?|$)/, (route) => {
const url = new URL(route.request().url());
log.timelineQueries.push(url.searchParams);
const body = state === "full" ? fullTimeline(today) : readyTimeline(today);
const values = (url.searchParams.get("values") ?? "")
.split(",")
.filter(Boolean);
const zoom = url.searchParams.get("zoom") ?? "all";
const from = url.searchParams.get("from");
const to = url.searchParams.get("to");
const bucket = bucketFor(zoom, from, to);
const dataFrom = state === "full" ? "2019-01-01" : "2024-05-01";
// A chosen range answers its own window, as the server does.
const chosen = zoom === "range" && from && to;
const all =
!chosen && bucket === body.bucket
? body.series
: series(chosen ? from : dataFrom, chosen ? to : today, bucket);
return json(route, 200, {
...body,
zoom,
bucket,
...(chosen ? { range: { from, to, dataFrom } } : {}),
// Any other value the menu offers answers a flat line of its own.
series: values.map(
(key) =>
all.find((s) => s.key === key) ?? {
key,
unit: null,
points: points(dataFrom, today, bucket, (f) => 50 + 10 * f),
},
),
});
});
await page.route(/\/api\/life-events(\/[^/?]+)?(\?|$)/, async (route) => {
const method = route.request().method();
if (method === "GET") {
return json(route, 200, { events: state === "full" ? LIFE_EVENTS : [] });
}
if (method === "POST") {
const posted = route.request().postDataJSON() as Record<string, unknown>;
log.lifeEventPosts.push(posted);
return json(route, 201, {
id: "le-new",
endDate: null,
note: null,
...posted,
createdAt: "2026-10-01T00:00:00.000Z",
updatedAt: "2026-10-01T00:00:00.000Z",
});
}
return json(route, 200, { deleted: true });
});
return log;
}
+9 -2
View File
@@ -503,13 +503,20 @@ test.describe.serial("scoped sharing browser journeys", () => {
await expect(banner).toHaveAttribute("data-record-kind", "shared");
await page.goto("/measurements");
await expect(page.locator('[data-slot="measurement-add"]')).toBeVisible();
// An empty list carries its add action in the empty state rather than
// the header, so the write grant shows whichever of the two applies.
const addControl = page
.locator(
'[data-slot="measurement-add"], [data-slot="measurement-add-first"]',
)
.first();
await expect(addControl).toBeVisible();
const post = page.waitForResponse(
(response) =>
response.request().method() === "POST" &&
new URL(response.url()).pathname === "/api/measurements",
);
await page.locator('[data-slot="measurement-add"]').click();
await addControl.click();
await page.locator("#sys").fill("124");
await page.locator("#dia").fill("78");
await page.getByRole("button", { name: /^save$/i }).click();
+29 -3
View File
@@ -26,6 +26,7 @@
* the same ones, for the same reasons written there.
*/
import type { Page } from "@playwright/test";
import pg from "pg";
import { expect, test } from "./setup/test";
import {
@@ -111,15 +112,38 @@ test.describe.serial("a guardian's controls inside a managed profile", () => {
if (!managed) throw new Error("managed record fixture is missing");
test("offers the mood add control and lands the entry", async ({ page }) => {
// The profile starts every run without mood entries. The form stamps an
// entry to the minute and `(user, date, moodLoggedAt)` is unique, so a
// second run inside the same minute (a retry, a repeat) met a 409 for the
// entry the first one wrote. An empty record also fixes which control the
// page offers: the empty state's add, not the header's.
const dbUrl = process.env.DATABASE_URL;
test.skip(!dbUrl, "DATABASE_URL is required to reset the profile's mood");
const pool = new pg.Pool({ connectionString: dbUrl });
try {
await pool.query(
`DELETE FROM mood_entries
WHERE user_id = (SELECT id FROM users WHERE username = $1)`,
[managed.username],
);
} finally {
await pool.end();
}
await openRecord(page, managed.username);
const banner = page.locator('[data-slot="shared-record-banner"]');
await expect(banner).toHaveAttribute("data-record-kind", "managed");
await expect(banner).toHaveAttribute("data-access-level", "manage");
await page.goto("/mood");
const add = page.locator('[data-slot="mood-add-entry"]');
await expect(add).toBeVisible({ timeout: 30_000 });
await add.click();
// Since v1.42 an empty list carries the one add control in its empty
// state and the header drops its own; the header's returns once there is
// an entry to list. Both are asserted on their data-slots.
const addFirst = page.locator('[data-slot="mood-add-first"]');
const addHeader = page.locator('[data-slot="mood-add-entry"]');
await expect(addFirst).toBeVisible({ timeout: 30_000 });
await expect(addHeader).toHaveCount(0);
await addFirst.click();
const face = page.locator('[data-slot="mood-face"][data-mood="GUT"]');
await expect(face).toBeVisible();
@@ -139,6 +163,8 @@ test.describe.serial("a guardian's controls inside a managed profile", () => {
await expect(page.locator('[data-slot="mood-rows"]').first()).toBeVisible({
timeout: 30_000,
});
await expect(addHeader).toBeVisible();
await expect(addFirst).toHaveCount(0);
await leaveRecord(page);
});
+258
View File
@@ -0,0 +1,258 @@
/**
* v1.42 (#959) — a managed profile handed over to the person it describes,
* end to end in a browser: the Guardian mints the link, the person opens it in
* a browser that has never signed in, chooses a sign-in, lands on the decision
* about their former Guardian, and decides.
*
* The link is never mailed (by design: it is shown once and handed over in
* person or scanned). This journey "mails" it by reading it off the panel's
* `data-handover-url` and opening it in a second, cookie-less browser context,
* which is exactly the hop a person makes with a phone.
*
* Its own Guardian account, per project, seeded here: the shared
* `e2e-guardian` belongs to the managed-profile journey, which clears every
* profile that account looks after, and the two would delete each other's
* records mid-run. Every assertion addresses a stable `data-*` attribute or
* the database, never rendered copy.
*/
import AxeBuilder from "@axe-core/playwright";
import { hash } from "@node-rs/argon2";
import type { Page } from "@playwright/test";
import pg from "pg";
import { expect, test } from "./setup/test";
const PASSWORD = "Handover!Journey-2042";
function connect(): pg.Pool {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("[handover-e2e] DATABASE_URL is not set");
return new pg.Pool({ connectionString: url });
}
/** WCAG 2.1 AA on the screen as it stands. */
async function expectNoAxeViolations(page: Page): Promise<void> {
const result = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(result.violations).toEqual([]);
}
function names(project: string) {
const slug = project
.replace(/[^a-z0-9]/gi, "")
.toLowerCase()
.slice(0, 12);
return {
guardian: `e2e-hg-${slug}`,
claimant: `e2e-hc-${slug}`,
};
}
/**
* A Guardian with a password, signed in through the real login route, then
* given a second factor and a session that has just proved it — the state
* `requireFreshMfa` needs and the only state in which a handover can be made.
*/
async function seedGuardian(
pool: pg.Pool,
username: string,
claimant: string,
): Promise<void> {
// Whatever a previous run left: the claimed account (no longer a managed
// profile, so it does not cascade with the guardian) and the guardian.
await pool.query(`DELETE FROM users WHERE username = $1 OR email = $2`, [
claimant,
`${claimant}@healthlog.test`,
]);
await pool.query(
`DELETE FROM users WHERE managed_profile_at IS NOT NULL AND id IN (
SELECT g.grantor_id FROM account_grants g JOIN users u ON u.id = g.grantee_id
WHERE u.username = $1)`,
[username],
);
await pool.query(`DELETE FROM users WHERE username = $1`, [username]);
const passwordHash = await hash(PASSWORD, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
await pool.query(
`INSERT INTO users
(id, username, email, password_hash, role, created_at, updated_at,
onboarding_completed_at, onboarding_tour_completed,
disclaimer_acknowledged_at, disclaimer_acknowledged_version)
VALUES ($1, $2, $3, $4, 'USER', NOW(), NOW(), NOW(), true, NOW(), '1')`,
[
`c${Date.now().toString(36)}${username}`,
username,
`${username}@healthlog.test`,
passwordHash,
],
);
// The claim and login buckets for this address, so a local re-run inside the
// window meets the product rather than its rate limit.
await pool.query(
`DELETE FROM rate_limits WHERE key LIKE 'auth:claim%' OR key LIKE 'managed-profile:%'`,
);
}
async function stampSecondFactor(pool: pg.Pool, username: string) {
await pool.query(
`UPDATE users SET totp_confirmed_at = NOW() WHERE username = $1`,
[username],
);
await pool.query(
`UPDATE sessions SET mfa_verified_at = NOW()
WHERE user_id = (SELECT id FROM users WHERE username = $1)`,
[username],
);
}
test.describe("managed profile handover", () => {
test("guardian hands over, the person claims and decides", async ({
page,
browser,
baseURL,
}, testInfo) => {
test.setTimeout(120_000);
const { guardian, claimant } = names(testInfo.project.name);
const pool = connect();
try {
await seedGuardian(pool, guardian, claimant);
// ── The Guardian ─────────────────────────────────────────────────
const login = await page.request.post("/api/auth/login", {
data: { email: guardian, password: PASSWORD },
});
expect(login.status()).toBe(200);
await stampSecondFactor(pool, guardian);
const created = await page.request.post("/api/managed-profiles", {
data: {
displayName: "Robin",
dateOfBirth: "2012-03-04",
locale: "en",
timezone: "Europe/Berlin",
gender: null,
},
});
expect(created.status()).toBe(201);
const profileId = ((await created.json()) as { data: { id: string } })
.data.id;
await page.goto("/settings/access");
const row = page.locator(
`[data-slot="managed-profile-row"][data-managed-profile-id="${profileId}"]`,
);
await expect(row).toBeVisible();
await row
.locator('[data-slot="managed-profile-handover-toggle"]')
.click();
const form = row.locator('[data-slot="managed-profile-handover-form"]');
await expect(form).toBeVisible();
// Born 2012: the minor hint is shown, the choice stays the Guardian's.
await expect(
row.locator('[data-slot="managed-profile-handover-minor"]'),
).toBeVisible();
// The Guardian proposes to keep managing.
await form
.locator('[data-slot="managed-profile-handover-proposal"]')
.first()
.selectOption("manage");
await form
.locator('[data-slot="managed-profile-handover-create"]')
.click();
const linkPanel = row.locator(
'[data-slot="managed-profile-handover-link"]',
);
await expect(linkPanel).toBeVisible();
await expect(
row.locator('[data-slot="managed-profile-handover-qr"]'),
).toBeVisible();
const url = await linkPanel.getAttribute("data-handover-url");
expect(url).toMatch(/\/claim\/hlp_[0-9a-f]{64}$/);
await expectNoAxeViolations(page);
// ── The person, on a browser that never signed in ────────────────
const person = await browser.newContext({ baseURL });
try {
const claimPage = await person.newPage();
const path = new URL(url as string).pathname;
await claimPage.goto(path);
await expect(claimPage).toHaveURL(/\/auth\/claim\?token=hlp_/);
await expect(
claimPage.locator('[data-testid="claim-form-card"]'),
).toBeVisible();
await expect(
claimPage.locator(
'[data-slot="claim-guardian"][data-proposal="manage"]',
),
).toHaveCount(1);
await expectNoAxeViolations(claimPage);
await claimPage.locator("#claim-username").fill(claimant);
await claimPage
.locator("#claim-email")
.fill(`${claimant}@healthlog.test`);
await claimPage.locator("#claim-password").fill(PASSWORD);
await claimPage.locator('[data-slot="claim-submit"]').click();
await expect(claimPage).toHaveURL(/\/onboarding\/handover$/, {
timeout: 30_000,
});
const decision = claimPage.locator(
'[data-slot="handover-decision-guardian"][data-decidable="true"]',
);
await expect(decision).toHaveCount(1);
await expect(
claimPage.locator('[data-slot="handover-notification-hint"]'),
).toBeVisible();
await expectNoAxeViolations(claimPage);
// The person decides: view only, not managing.
await decision
.locator('[data-slot="handover-decision-choice"]')
.selectOption("read");
await claimPage
.locator('[data-slot="handover-decision-submit"]')
.click();
await expect(claimPage).toHaveURL(/\/onboarding(\?|$)/, {
timeout: 30_000,
});
// The used link is dead for anyone after.
const reused = await person.request.post("/api/auth/claim/preview", {
data: { token: (url as string).split("/claim/")[1] },
});
// A signed-in browser is refused before the token is read; a fresh one
// would get the uniform 404. Either way, nothing is handed over twice.
expect([404, 409]).toContain(reused.status());
} finally {
await person.close();
}
// ── What the database says ───────────────────────────────────────
const account = await pool.query(
`SELECT managed_profile_at, username, email, password_hash IS NOT NULL AS has_password
FROM users WHERE id = $1`,
[profileId],
);
expect(account.rows[0]).toMatchObject({
managed_profile_at: null,
username: claimant,
has_password: true,
});
const live = await pool.query(
`SELECT g.access FROM account_grants g JOIN users u ON u.id = g.grantee_id
WHERE g.grantor_id = $1 AND u.username = $2 AND g.revoked_at IS NULL`,
[profileId, guardian],
);
expect(live.rows).toEqual([{ access: "READ" }]);
} finally {
await pool.end();
}
});
});
+384
View File
@@ -0,0 +1,384 @@
/**
* v1.42 — three surfaces made easier to live with.
*
* - Documents: the display choice behind the wrench (cards or list, months
* stacked or flowing) persists per account; every delete asks first; the
* selection is cleared with an X on the count line.
* - Wellness scores: every score tile shows its course; a score's detail
* page carries its history right under the score card and no longer the
* warning-coloured "not clinical" line.
* - Mood insights: the calendar and the line chart appear together without
* moving the page, and logging is the header plus every Insights page
* uses.
*
* Everything is addressed through stable data attributes, never copy.
*/
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { ensureVaultFixture, seedNamespaceDocs } from "./setup/vault-fixture";
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
test.use({ storageState: STORAGE_STATE_PATH });
async function resetDocumentsLayout(page: Page): Promise<void> {
const res = await page.request.put("/api/documents/inbound/layout", {
data: { version: 1, view: "cards", arrangement: "stacked" },
});
expect(res.status()).toBe(200);
}
test.describe("documents", () => {
test.beforeAll(async () => {
await ensureVaultFixture();
});
test("the display choice behind the wrench persists across visits", async ({
page,
isMobile,
}) => {
// Both projects share one account; the layout is account state, so one
// project owns this journey.
test.skip(isMobile, "account-level preference; one project owns it");
await resetDocumentsLayout(page);
try {
await page.goto("/documents");
const timeline = page.locator('[data-slot="document-timeline"]');
await expect(timeline).toHaveAttribute("data-view", "cards");
await expect(timeline).toHaveAttribute("data-arrangement", "stacked");
await page.locator('[data-slot="documents-customize"]').click();
await expect(page).toHaveURL(/\/settings\/layout\/documents$/);
const settings = page.locator('[data-slot="documents-layout-settings"]');
await settings.locator('[data-slot="module-view-list"]').click();
await expect(
settings.locator('[data-slot="module-view-list"]'),
).toHaveAttribute("aria-pressed", "true");
const flow = settings.locator('[data-slot="documents-arrangement-flow"]');
// Wait for the write to land, so the reload below reads it back.
const saved = page.waitForResponse(
(r) =>
r.url().includes("/api/documents/inbound/layout") &&
r.request().method() === "PUT",
);
await flow.click();
expect((await saved).status()).toBe(200);
await page.goto("/documents");
await expect(timeline).toHaveAttribute("data-view", "list");
await expect(timeline).toHaveAttribute("data-arrangement", "flow");
await expect(
timeline
.locator('[data-slot="document-card"][data-variant="row"]')
.first(),
).toBeVisible();
// Flowing: no month gets a row of its own, the name rides inline.
await expect(
timeline.locator('[data-slot="document-flow-month"]').first(),
).toBeVisible();
// A fresh visit in a fresh page — the choice is the account's, not
// this tab's.
const second = await page.context().newPage();
await second.goto("/documents");
await expect(
second.locator('[data-slot="document-timeline"]'),
).toHaveAttribute("data-arrangement", "flow");
await second.close();
} finally {
await resetDocumentsLayout(page);
}
});
test("bulk delete asks first, and cancelling deletes nothing", async ({
page,
isMobile,
}) => {
test.skip(isMobile, "selection by checkbox is a pointer flow");
await seedNamespaceDocs("qoldel", 2);
await page.goto("/documents?q=qoldel");
const box = page.getByRole("checkbox", { name: "Select qoldel 002" });
await expect(box).toBeVisible();
await box.click();
let deletes = 0;
page.on("request", (req) => {
if (
req.method() === "POST" &&
req.url().includes("/api/documents/inbound/bulk") &&
(req.postData() ?? "").includes('"delete"')
) {
deletes += 1;
}
});
const bar = page.locator('[data-slot="document-bulk-bar"]');
await bar.locator('[data-slot="document-bulk-delete"]').click();
const confirm = page.locator('[data-slot="documents-delete-confirm"]');
await expect(confirm).toBeVisible();
await page
.getByRole("alertdialog")
.getByRole("button", { name: "Cancel" })
.click();
await expect(confirm).toBeHidden();
expect(deletes).toBe(0);
await expect(
page.getByRole("button", { name: "Open qoldel 002" }),
).toBeVisible();
await bar.locator('[data-slot="document-bulk-delete"]').click();
await confirm.click();
await expect(
page.getByRole("button", { name: "Open qoldel 002" }),
).toBeHidden();
expect(deletes).toBe(1);
});
test("the Delete key on a focused card asks first too", async ({
page,
isMobile,
}) => {
test.skip(isMobile, "keyboard flow");
await seedNamespaceDocs("qolkey", 1);
await page.goto("/documents?q=qolkey");
const open = page.getByRole("button", { name: "Open qolkey 001" });
await expect(open).toBeVisible();
await open.focus();
await page.keyboard.press("Delete");
await expect(
page.locator('[data-slot="documents-delete-confirm"]'),
).toBeVisible();
await page.keyboard.press("Escape");
await expect(open).toBeVisible();
});
test("the selection clears with the X on the count line", async ({
page,
isMobile,
}) => {
test.skip(isMobile, "selection by checkbox is a pointer flow");
await seedNamespaceDocs("qolclear", 2);
await page.goto("/documents?q=qolclear");
await page.getByRole("checkbox", { name: "Select qolclear 001" }).click();
const bar = page.locator('[data-slot="document-bulk-bar"]');
await expect(bar).toBeVisible();
const count = bar.locator('[data-slot="document-bulk-count"]');
const clear = bar.locator('[data-slot="document-bulk-clear"]');
const actions = bar.locator('[data-slot="document-bulk-actions"]');
// The X sits on the count line, at its right edge, above the action row,
// and the action row follows the count line without a gap of its own.
const [countBox, clearBox, actionsBox, barBox] = await Promise.all([
count.boundingBox(),
clear.boundingBox(),
actions.boundingBox(),
bar.boundingBox(),
]);
expect(countBox && clearBox && actionsBox && barBox).toBeTruthy();
const mid = (b: { y: number; height: number }) => b.y + b.height / 2;
expect(Math.abs(mid(clearBox!) - mid(countBox!))).toBeLessThan(6);
expect(
barBox!.x + barBox!.width - (clearBox!.x + clearBox!.width),
).toBeLessThan(20);
expect(
actionsBox!.y - (clearBox!.y + clearBox!.height),
).toBeLessThanOrEqual(10);
// One action row: every verb shares a top edge.
const tops = await actions
.locator("button")
.evaluateAll((els) =>
els.map((el) => Math.round(el.getBoundingClientRect().top)),
);
expect(new Set(tops).size).toBe(1);
await clear.click();
await expect(bar).toBeHidden();
});
});
test.describe("wellness scores", () => {
test("every score tile shows its course", async ({ page }) => {
await mockPopulatedInsights(page);
await page.goto("/insights");
for (const metric of ["RECOVERY_SCORE", "STRESS_SCORE"]) {
const tile = page.locator(
`[data-slot="wellness-score-tile"][data-metric="${metric}"]`,
);
await tile.scrollIntoViewIfNeeded();
await expect(
tile.locator('[data-slot="wellness-score-history"]'),
).toBeVisible();
}
});
test("a score's detail page carries its history under the card, without the caveat line", async ({
page,
}) => {
// The e2e account has no nightly score; serve one so the page has a
// score to draw the history under.
await page.route(
/\/api\/insights\/derived\?metric=RECOVERY_SCORE/,
(route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
metric: "RECOVERY_SCORE",
status: "ok",
value: {
score: 66,
band: "yellow",
trendDelta: 2,
daysInWindow: 5,
asOf: new Date().toISOString(),
series: [58, 61, 64, 60, 66],
anchor: null,
components: null,
},
coverage: {
requiredInputs: 1,
presentInputs: 1,
historyDays: 5,
missing: [],
},
confidence: { score: 80, band: "high" },
provenance: {
inputs: ["RECOVERY_SCORE"],
source: "live",
windowDays: 14,
computedAt: new Date().toISOString(),
},
reason: null,
assessment: null,
},
error: null,
}),
}),
);
await page.goto("/insights/scores/recovery");
const anatomy = page.locator('[data-slot="score-anatomy-view"]');
await expect(anatomy).toBeVisible();
const history = page.locator('[data-slot="score-history-chart"]');
await expect(history).toBeAttached();
const [cardBox, historyBox] = await Promise.all([
anatomy.boundingBox(),
history.boundingBox(),
]);
expect(historyBox!.y).toBeGreaterThan(cardBox!.y + cardBox!.height - 1);
await expect(anatomy.locator(".text-warning")).toHaveCount(0);
});
});
test.describe("mood insights", () => {
test.beforeAll(async ({ browser }) => {
// A short, real history, so the calendar and the chart both have days.
const context = await browser.newContext({
storageState: STORAGE_STATE_PATH,
});
const now = Date.now();
for (let day = 1; day <= 12; day++) {
const res = await context.request.post("/api/mood-entries", {
data: {
mood: day % 3 === 0 ? "OKAY" : "GUT",
moodLoggedAt: new Date(now - day * 86_400_000).toISOString(),
source: "MANUAL",
externalId: `e2e-qol-mood-${day}`,
},
});
expect([200, 201]).toContain(res.status());
}
await context.close();
});
test("the calendar and the chart appear together without moving the page", async ({
page,
}) => {
// The calendar's read is the slower one in production; slow it down here
// so the old order (chart first, calendar pushing it down) would show.
await page.route(/\/api\/mood\/insights(\?|$)/, async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1_200));
await route.continue();
});
await page.addInitScript(() => {
const w = window as unknown as {
__cls: number;
__seen: Record<string, number>;
};
w.__cls = 0;
w.__seen = {};
new PerformanceObserver((list) => {
for (const entry of list.getEntries() as Array<
PerformanceEntry & { value: number; hadRecentInput: boolean }
>) {
if (!entry.hadRecentInput) w.__cls += entry.value;
}
}).observe({ type: "layout-shift", buffered: true });
const watch = () => {
if (
!w.__seen.heatmap &&
document.querySelector(
'[data-slot="mood-heatmap-body"] svg[role="img"]',
)
)
w.__seen.heatmap = performance.now();
if (
!w.__seen.chart &&
document.querySelector(
'[data-slot="insights-mood-chart"] .recharts-surface',
) &&
!document.querySelector(
'[data-slot="insights-mood-chart"] .invisible',
)
)
w.__seen.chart = performance.now();
requestAnimationFrame(watch);
};
requestAnimationFrame(watch);
});
await page.goto("/insights/mood");
await expect(
page.locator('[data-slot="mood-heatmap-body"] svg[role="img"]'),
).toBeAttached({
timeout: 15_000,
});
await expect(
page
.locator('[data-slot="insights-mood-chart"] .recharts-surface')
.first(),
).toBeVisible();
await page.waitForTimeout(1_500);
const { cls, seen } = await page.evaluate(() => {
const w = window as unknown as {
__cls: number;
__seen: Record<string, number>;
};
return { cls: w.__cls, seen: w.__seen };
});
expect(cls).toBeLessThan(0.02);
expect(seen.heatmap).toBeDefined();
expect(seen.chart).toBeDefined();
// Together: within a couple of frames of each other.
expect(Math.abs(seen.heatmap - seen.chart)).toBeLessThan(100);
});
test("logging is the header plus, not a text link", async ({ page }) => {
await page.goto("/insights/mood");
const add = page.locator(
'[data-slot="insights-mood-add"][data-header-add]',
);
await expect(add).toBeVisible();
await expect(
page.locator('[data-slot="insights-mood-log-link"]'),
).toHaveCount(0);
await expect(
page.locator('[data-slot="insights-mood-mental-wellbeing-link"]'),
).toHaveCount(0);
await add.click();
await expect(page.getByRole("dialog")).toBeVisible();
});
});
+19 -6
View File
@@ -84,11 +84,19 @@ async function captureDose(
booster: "confirm" | "decline" | "none";
},
): Promise<void> {
// The header Add renders whether or not the list is empty, so clicking it
// works in both states. click() auto-waits for the button to be actionable,
// which avoids racing an instant visibility read against the client render
// (an empty-state-only branch times out once a record already exists).
await page.locator('[data-slot="vaccination-add"]').first().click();
// While the Impfpass is empty its empty state carries the add action and
// the header drops its own, so wait for the list to settle (a record or
// the empty state) and click whichever add control is then on screen.
await expect(
page.locator('[data-vaccination-id], [data-slot="empty-state"]').first(),
).toBeVisible();
await page
.locator(
'[data-slot="vaccination-add"], [data-slot="vaccination-add-empty"]',
)
.filter({ visible: true })
.first()
.click();
await expect(page.locator('[data-slot="vaccination-form"]')).toBeVisible();
@@ -321,7 +329,12 @@ test.describe("vaccinations", () => {
).toBeVisible({ timeout: 15_000 });
await expectNoSeriousAxe(page);
await page.locator('[data-slot="vaccination-add"]').first().click();
await page
.locator(
'[data-slot="vaccination-add"], [data-slot="vaccination-add-empty"]',
)
.last()
.click();
await expect(page.locator('[data-slot="vaccination-form"]')).toBeVisible();
await expectNoSeriousAxe(page);
});
+1
View File
@@ -153,6 +153,7 @@ const eslintConfig = defineConfig([
"src/components/charts/workout-hr-chart.tsx",
"src/components/charts/workout-elevation-chart.tsx",
"src/components/insights/coach-panel/result-chart.tsx",
"src/components/insights/derived/score-trend-chart.tsx",
],
rules: {
"no-restricted-imports": [
+8
View File
@@ -121,6 +121,14 @@
"**/__mocks__/**",
"e2e/setup/test-helpers.ts",
"src/lib/logging/index.ts",
// v1.42 release-branch contract stubs: each is created ahead of its
// consumers so the packages built in parallel share one signature. Drop
// a line the moment its first consumer lands; the release integration
// checklist removes whatever is left before the tag.
"src/lib/environment/air-quality-contract.ts",
// The day view and timeline contract (#613): constants and wire types the
// server, the two views and the Coach/MCP tool build against.
"src/lib/day/contract.ts",
],
"ignoreDependencies": [
"@types/node",
+1367 -665
View File
File diff suppressed because it is too large. Load diff
+1378 -676
View File
File diff suppressed because it is too large. Load diff
+1100 -398
View File
File diff suppressed because it is too large. Load diff
+1251 -549
View File
File diff suppressed because it is too large. Load diff
+1192 -490
View File
File diff suppressed because it is too large. Load diff
+1281 -579
View File
File diff suppressed because it is too large. Load diff
+1327 -625
View File
File diff suppressed because it is too large. Load diff
+24
View File
@@ -1,8 +1,31 @@
import { createHash } from "node:crypto";
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { NextConfig } from "next";
import bundleAnalyzer from "@next/bundle-analyzer";
import { version as PKG_VERSION } from "./package.json";
/**
* The message catalogs' content hash, the cache key of the boot script
* (`/i18n/<locale>.js?v=…`, `src/app/layout.tsx`). The service worker serves
* that URL cache-first, so a key that does not move with the catalog keeps
* an old catalog alive: keyed by the package version, every build of one
* release (a beta, a redeploy) shared one URL, and a key added since the
* first of them read as its raw name.
*/
function catalogVersion(): string {
const dir = join(process.cwd(), "messages");
const hash = createHash("sha256");
for (const file of readdirSync(dir)
.filter((f) => f.endsWith(".json"))
.sort()) {
hash.update(file).update(readFileSync(join(dir, file)));
}
return hash.digest("hex").slice(0, 16);
}
const nextConfig: NextConfig = {
output: "standalone",
// `next build` type-checks the whole tsconfig program since 16.3, which
@@ -20,6 +43,7 @@ const nextConfig: NextConfig = {
// had to discover "pull-to-refresh" themselves after every release.
env: {
NEXT_PUBLIC_APP_VERSION: PKG_VERSION,
NEXT_PUBLIC_I18N_CATALOG_VERSION: catalogVersion(),
},
// v1.4.33 IW2 — strip `console.*` calls from the production bundle
// (keep `console.error` + `console.warn` so the GlitchTip reporter
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.41.2",
"version": "1.42.0",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+22 -4
View File
@@ -110,7 +110,10 @@ export default defineConfig({
name: "chromium-desktop",
// The disk-layer spec needs a live service worker, which the shared
// `use` block blocks for every other spec. It runs in its own project.
testIgnore: ["v137-record-session-fence-offline.spec.ts"],
testIgnore: [
"v137-record-session-fence-offline.spec.ts",
"pwa-offline.spec.ts",
],
use: {
...devices["Desktop Chrome"],
viewport: { width: 1280, height: 720 },
@@ -130,10 +133,14 @@ export default defineConfig({
// cannot pass is worse than no check, so the spec gets a project rather
// than a caveat.
//
// It uses no route mocks for exactly this reason, and it is the only
// spec in this project.
// It uses no route mocks for exactly this reason. v1.42 — the offline
// page journey (`pwa-offline.spec.ts`) joined it on the same terms:
// what the worker itself serves without a connection, no mocks.
name: "chromium-service-worker",
testMatch: ["v137-record-session-fence-offline.spec.ts"],
testMatch: [
"v137-record-session-fence-offline.spec.ts",
"pwa-offline.spec.ts",
],
use: {
...devices["Desktop Chrome"],
viewport: { width: 1280, height: 720 },
@@ -185,6 +192,11 @@ export default defineConfig({
// through stable data-slots, not a mobile layout, so it runs in one
// project.
"vaccinations.spec.ts",
// The timeline journey moves its account's timeline module on and
// off; two projects on that one account would flip it under each
// other. It checks the phone chronicle itself by resizing inside the
// desktop project.
"timeline.spec.ts",
// The blood-glucose journey writes readings to the one shared account
// and then reads GLOBAL verdicts off it — three rows in the list, one
// tile per meal-time context — while also moving that account's
@@ -215,6 +227,7 @@ export default defineConfig({
"doctor-report-delegate.spec.ts",
// Runs only in the service-worker project.
"v137-record-session-fence-offline.spec.ts",
"pwa-offline.spec.ts",
// The Apple Health import journey uploads an archive into the one
// shared account and its refusal control asserts that the account's
// Apple-Health row count did not move. A second project running the
@@ -276,6 +289,11 @@ export default defineConfig({
// account under the first. They prove server state through stable
// data-slots, not a mobile layout, so they run in one project.
"ai-optional-account.spec.ts",
// The score-history journey moves its account's top card and range
// tabs; two projects on that one account would flip them under each
// other. It checks the phone width by resizing inside the desktop
// project.
"score-history.spec.ts",
],
use: {
// Pixel 5 — Chromium-based mobile profile so CI only needs
+14 -17
View File
@@ -15,6 +15,9 @@ overrides:
'@hono/node-server@<1.19.15': ^1.19.15
picomatch@<4.0.4: ^4.0.4
body-parser@<2.3.0: ^2.3.0
source-map-js@<1.2.2: ^1.2.2
postcss-selector-parser@<7.1.6: ^7.1.6
smol-toml@<1.9.0: ^1.9.0
qs@<6.16.0: ^6.16.0
fast-uri@<3.1.8: ^3.1.8
flatted@<3.4.2: ^3.4.2
@@ -5721,8 +5724,8 @@ packages:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
postcss-selector-parser@7.1.4:
resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==}
postcss-selector-parser@7.1.6:
resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==}
engines: {node: '>=4'}
postcss@8.5.23:
@@ -6267,8 +6270,8 @@ packages:
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
smol-toml@1.8.0:
resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==}
smol-toml@1.9.0:
resolution: {integrity: sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==}
engines: {node: '>= 18'}
sonner@2.0.8:
@@ -6281,10 +6284,6 @@ packages:
'@types/react':
optional: true
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
source-map-js@1.2.2:
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
engines: {node: '>=0.10.0'}
@@ -9489,7 +9488,7 @@ snapshots:
jiti: 2.7.0
lightningcss: 1.32.0
magic-string: 0.30.21
source-map-js: 1.2.1
source-map-js: 1.2.2
tailwindcss: 4.3.3
'@tailwindcss/oxide-android-arm64@4.3.3':
@@ -11946,7 +11945,7 @@ snapshots:
oxc-parser: 0.143.0
oxc-resolver: 11.24.2
picomatch: 4.0.7
smol-toml: 1.8.0
smol-toml: 1.9.0
strip-json-comments: 5.0.3
tinyglobby: 0.2.17
unbash: 4.0.10
@@ -12634,7 +12633,7 @@ snapshots:
possible-typed-array-names@1.1.0: {}
postcss-selector-parser@7.1.4:
postcss-selector-parser@7.1.6:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
@@ -12649,13 +12648,13 @@ snapshots:
dependencies:
nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
source-map-js: 1.2.2
postcss@8.5.26:
dependencies:
nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
source-map-js: 1.2.2
postgres-array@2.0.0: {}
@@ -13215,7 +13214,7 @@ snapshots:
open: 11.0.0
ora: 8.2.0
postcss: 8.5.25
postcss-selector-parser: 7.1.4
postcss-selector-parser: 7.1.6
prompts: 2.4.2
recast: 0.23.19
stringify-object: 5.0.0
@@ -13314,7 +13313,7 @@ snapshots:
sisteransi@1.0.5: {}
smol-toml@1.8.0: {}
smol-toml@1.9.0: {}
sonner@2.0.8(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0):
dependencies:
@@ -13323,8 +13322,6 @@ snapshots:
optionalDependencies:
'@types/react': 19.3.0
source-map-js@1.2.1: {}
source-map-js@1.2.2: {}
source-map@0.6.1: {}
+7
View File
@@ -27,6 +27,13 @@ overrides:
"@hono/node-server@<1.19.15": "^1.19.15"
"picomatch@<4.0.4": "^4.0.4"
"body-parser@<2.3.0": "^2.3.0"
# CVE-2026-93749 (source-map-js < 1.2.2, HIGH, under postcss and the
# Tailwind build), CVE-2026-104844 (postcss-selector-parser < 7.1.6, under
# shadcn) and GHSA-r4xh-jqrq-34v2 (smol-toml < 1.9.0, under knip). All three
# are quadratic-time parsers reached at build or dev time only.
"source-map-js@<1.2.2": "^1.2.2"
"postcss-selector-parser@<7.1.6": "^7.1.6"
"smol-toml@<1.9.0": "^1.9.0"
# Array-limit bypass through bracket-key comma parsing, and a denial of
# service through an attacker-controlled isBuffer. 6.16.0 covers both.
"qs@<6.16.0": "^6.16.0"
@@ -0,0 +1,14 @@
-- Android Health Connect import provenance (#972).
--
-- Rows written by the Health Connect import worker carry their own
-- `MeasurementSource` value, so they stay distinguishable from the Apple
-- Health and generic IMPORT rows and get an isolated idempotency namespace
-- `(userId, type, HEALTH_CONNECT, externalId)`.
--
-- Purely additive enum extension, alone in its migration: Postgres forbids
-- USING a freshly added value in the same transaction, and nothing here uses
-- it. `ADD VALUE IF NOT EXISTS` makes a rerun safe. Mirrors 0346 (EXTERNAL).
--
-- Reversibility: Postgres cannot remove an enum value, so `HEALTH_CONNECT`
-- stays (inert with no rows).
ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'HEALTH_CONNECT';
@@ -0,0 +1,9 @@
-- Which importer owns an `import_jobs` row (#972).
--
-- Until now every row was an Apple Health export; the Health Connect importer
-- shares the table. The reconcile cron and the staged-file sweep scope by
-- `kind` so one importer never fails or deletes the other's running job.
-- Every existing row is an Apple Health job, which the default records.
ALTER TABLE "import_jobs" ADD COLUMN "kind" TEXT NOT NULL DEFAULT 'apple_health';
CREATE INDEX "import_jobs_user_id_kind_started_at_idx" ON "import_jobs"("user_id", "kind", "started_at");
@@ -0,0 +1,40 @@
-- Air quality, pollen and UV per environment day (#615).
--
-- Twenty-two nullable columns filled from the Open-Meteo air-quality feed
-- (CAMS Europe / global). Nullable throughout: a partial feed stores what it
-- has, and a day the feed never covered stays NULL rather than zero.
-- `aq_fetched_at` NULL marks a day whose air-quality part has not been
-- fetched yet; the partial gap index serves the nightly fill's lookup.
--
-- `users.environment_air_quality_enabled` is the per-user switch, on by
-- default; the operator switch `ENVIRONMENT_AIR_QUALITY_DISABLED` wins.
ALTER TABLE "environment_contexts"
ADD COLUMN "apparent_max" DOUBLE PRECISION,
ADD COLUMN "pm25_mean" DOUBLE PRECISION,
ADD COLUMN "pm25_max" DOUBLE PRECISION,
ADD COLUMN "pm10_mean" DOUBLE PRECISION,
ADD COLUMN "no2_mean" DOUBLE PRECISION,
ADD COLUMN "so2_mean" DOUBLE PRECISION,
ADD COLUMN "co_mean" DOUBLE PRECISION,
ADD COLUMN "o3_max_8h" DOUBLE PRECISION,
ADD COLUMN "eaqi_max" DOUBLE PRECISION,
ADD COLUMN "usaqi_max" DOUBLE PRECISION,
ADD COLUMN "uv_index_max" DOUBLE PRECISION,
ADD COLUMN "dust_max" DOUBLE PRECISION,
ADD COLUMN "aod_max" DOUBLE PRECISION,
ADD COLUMN "pollen_alder_max" DOUBLE PRECISION,
ADD COLUMN "pollen_birch_max" DOUBLE PRECISION,
ADD COLUMN "pollen_grass_max" DOUBLE PRECISION,
ADD COLUMN "pollen_mugwort_max" DOUBLE PRECISION,
ADD COLUMN "pollen_olive_max" DOUBLE PRECISION,
ADD COLUMN "pollen_ragweed_max" DOUBLE PRECISION,
ADD COLUMN "aq_domain" TEXT,
ADD COLUMN "aq_hours" INTEGER,
ADD COLUMN "aq_fetched_at" TIMESTAMP(3);
CREATE INDEX "environment_contexts_aq_gap_idx"
ON "environment_contexts"("user_id", "date")
WHERE "aq_fetched_at" IS NULL;
ALTER TABLE "users"
ADD COLUMN "environment_air_quality_enabled" BOOLEAN NOT NULL DEFAULT true;
@@ -0,0 +1,36 @@
-- Environment locations sealed at rest (#615).
--
-- The home location, each dated location period and the per-day resolved
-- location gain a sealed copy (lat, lon, label under AAD
-- `healthlog/environment-location/v1`). The free-text encryption backfill
-- fills it from the plaintext and then clears the plaintext; the plaintext
-- columns therefore become nullable here and drop in a later release, once
-- the backfill reports zero remaining rows everywhere.
--
-- The partial indexes serve the backfill's discovery ("rows still holding
-- plaintext") and empty themselves as it runs.
ALTER TABLE "users" ADD COLUMN "home_location_encrypted" BYTEA;
ALTER TABLE "environment_travel_locations"
ADD COLUMN "location_encrypted" BYTEA,
ALTER COLUMN "lat" DROP NOT NULL,
ALTER COLUMN "lon" DROP NOT NULL,
ALTER COLUMN "label" DROP NOT NULL;
ALTER TABLE "environment_contexts"
ADD COLUMN "location_encrypted" BYTEA,
ALTER COLUMN "lat" DROP NOT NULL,
ALTER COLUMN "lon" DROP NOT NULL,
ALTER COLUMN "location_label" DROP NOT NULL;
CREATE INDEX "users_home_location_plain_idx"
ON "users"("id")
WHERE "home_lat" IS NOT NULL;
CREATE INDEX "environment_travel_locations_plain_location_idx"
ON "environment_travel_locations"("id")
WHERE "lat" IS NOT NULL;
CREATE INDEX "environment_contexts_plain_location_idx"
ON "environment_contexts"("id")
WHERE "lat" IS NOT NULL;
@@ -0,0 +1,32 @@
-- Managed profile handover links (#959).
--
-- A guardian mints a one-time link that hands a managed profile over to the
-- person it describes. Only the HMAC-SHA256 hash of the token is stored.
-- `proposals_json` carries the guardian-side proposal per guardian (end,
-- read, manage) that the new owner confirms when claiming. The partial
-- unique index allows at most one open (unused, unrevoked) link per profile.
CREATE TABLE "managed_profile_handovers" (
"id" TEXT NOT NULL,
"profile_id" TEXT NOT NULL,
"created_by_id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"proposals_json" JSONB NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3) NOT NULL,
"used_at" TIMESTAMP(3),
"revoked_at" TIMESTAMP(3),
CONSTRAINT "managed_profile_handovers_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "managed_profile_handovers_token_hash_key" ON "managed_profile_handovers"("token_hash");
CREATE INDEX "managed_profile_handovers_profile_id_idx" ON "managed_profile_handovers"("profile_id");
CREATE UNIQUE INDEX "managed_profile_handovers_open_idx"
ON "managed_profile_handovers"("profile_id")
WHERE "used_at" IS NULL AND "revoked_at" IS NULL;
ALTER TABLE "managed_profile_handovers" ADD CONSTRAINT "managed_profile_handovers_profile_id_fkey" FOREIGN KEY ("profile_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "managed_profile_handovers" ADD CONSTRAINT "managed_profile_handovers_created_by_id_fkey" FOREIGN KEY ("created_by_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,16 @@
-- Per-HealthKit-type arrival ledger (#1173).
--
-- One row per (user, type), upserted by the measurement batch route: when the
-- type last arrived, under which `syncTrigger`, and when it last brought a new
-- or updated sample. Diagnostic state the next sync rebuilds; not backed up.
CREATE TABLE "healthkit_type_syncs" (
"user_id" TEXT NOT NULL,
"type" "measurement_type" NOT NULL,
"last_received_at" TIMESTAMP(3) NOT NULL,
"last_trigger" TEXT,
"last_new_sample_at" TIMESTAMP(3),
CONSTRAINT "healthkit_type_syncs_pkey" PRIMARY KEY ("user_id","type")
);
ALTER TABLE "healthkit_type_syncs" ADD CONSTRAINT "healthkit_type_syncs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,12 @@
-- Lab unit conversion provenance (#1095).
--
-- `biomarkers.analyte_key` names the catalogue analyte a marker resolves to,
-- which selects its unit-conversion table. `lab_results.source_value` /
-- `source_unit` keep the value and unit as printed on the report when a write
-- converted the reading into the marker's canonical unit. All nullable; no
-- existing row changes.
ALTER TABLE "biomarkers" ADD COLUMN "analyte_key" TEXT;
ALTER TABLE "lab_results"
ADD COLUMN "source_value" DOUBLE PRECISION,
ADD COLUMN "source_unit" TEXT;
@@ -0,0 +1,29 @@
-- User-defined vaccines (#1005).
--
-- A vaccine the user defines for a product the shipped catalogue does not
-- list, in the same shape a catalogue entry resolves to. A dose links to it
-- through `vaccination_records.custom_vaccine_id`; deleting the definition
-- sets the link to NULL and the dose stays on its `vaccine_name`.
CREATE TABLE "custom_vaccines" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"components" TEXT[],
"typical_series_doses" INTEGER,
"booster_interval_months" INTEGER,
"deleted_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "custom_vaccines_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "custom_vaccines_user_id_name_key" ON "custom_vaccines"("user_id", "name");
ALTER TABLE "custom_vaccines" ADD CONSTRAINT "custom_vaccines_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "vaccination_records" ADD COLUMN "custom_vaccine_id" TEXT;
CREATE INDEX "vaccination_records_custom_vaccine_id_idx" ON "vaccination_records"("custom_vaccine_id");
ALTER TABLE "vaccination_records" ADD CONSTRAINT "vaccination_records_custom_vaccine_id_fkey" FOREIGN KEY ("custom_vaccine_id") REFERENCES "custom_vaccines"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,31 @@
-- Life events for the timeline (#613).
--
-- A dated anchor in the person's life: a move, a birth, a new job, a loss.
-- Dates are calendar strings (`YYYY-MM-DD`) without a time zone, and
-- `precision` says how much of the date is known; at MONTH or YEAR the stored
-- date is the first day of that month or year. Title and note are encrypted
-- at rest. Soft-deleted like the other record tables; the index serves the
-- per-user live list in date order.
CREATE TYPE "life_event_category" AS ENUM ('FAMILY', 'HOME', 'WORK', 'LOSS', 'OTHER');
CREATE TYPE "life_event_precision" AS ENUM ('DAY', 'MONTH', 'YEAR');
CREATE TABLE "life_events" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"category" "life_event_category" NOT NULL,
"start_date" TEXT NOT NULL,
"end_date" TEXT,
"precision" "life_event_precision" NOT NULL,
"title_encrypted" BYTEA NOT NULL,
"note_encrypted" BYTEA,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "life_events_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "life_events_user_id_deleted_at_start_date_idx" ON "life_events"("user_id", "deleted_at", "start_date");
ALTER TABLE "life_events" ADD CONSTRAINT "life_events_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
Loaded 100 of 1316 files, more files were not shown because too many files have changed in this diff. Show more