diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e658029f..7d5dd6562 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,47 @@ ## [Unreleased] +## [1.32.38] — 2026-07-25 + +Blood-glucose readings from anything other than the manual entry form were +never saved. + +A rule added years ago required every blood-glucose reading to carry a +context: fasting, after a meal, random, or bedtime. That is a fair thing to +ask of someone typing a reading in by hand. It is impossible for a sensor, +which measures every few minutes and cannot know what you were doing. + +So every automated path failed. A CSV import reported the rows as skipped and +still showed a green tick. Nightscout counted them as failed rows and carried +on. Apple Health has no context field at all, so not one of its readings could +ever have been stored. The same for readings written through the automation +interface, through Telegram, and through the JSON importer. None of it was +visible: no error reached anyone, and the affected charts and panels simply +stayed empty as though nothing had been measured. + +A reading with no context is now stored with no context, which is what it is. +Where a context is given it is still checked. Manual entry still asks for one, +because the form offers the choices and nothing is lost by asking. + +Absence is now shown as absence: a reading without a context is left out of the +per-context breakdowns rather than being counted as one of them. + +**Imports no longer claim to have worked when they did not.** A run that wrote +nothing is shown as a failure. A run that wrote some rows and skipped others is +shown as a partial result. Only a run that actually landed something shows as +done. Skipped rows are grouped by reason with a count instead of listing the +first fifty identical lines, and the per-row detail is still there behind a +disclosure. This covers the CSV, JSON and Apple Health cards alike. + +Reported by @lutzkind, who traced it to the parser, ran it in isolation to rule +out the timestamp and unit handling, and checked the database afterwards to +prove nothing had been written. Files that were rejected can simply be imported +again. + +**For operators.** Migration `0274` relaxes the constraint. It adds no column +and rewrites no row. Readings that were refused in the past were never stored, +so they need a fresh import from the source. + ## [1.32.37] — 2026-07-25 Two ways a record could quietly lose data, both closed. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 227c94387..d9448a580 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.32.37 + version: 1.32.38 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -8004,9 +8004,11 @@ paths: 10 000 valid rows). Header (order-independent): `type,value,unit,measuredAt[,glucoseContext,notes,externalId]`. `measuredAt` must carry an explicit ISO-8601 offset and is bounded (no future beyond a 5-min skew, no instant before 1900). Glucose accepts `mmol/L` (converted to canonical `mg/dL`); weight accepts `lb` (converted to - `kg`). An `externalId` column makes re-upload idempotent (upsert on `(userId, type, source=IMPORT, - externalId)`); without it a re-upload duplicates. `?dryRun=1` validates + previews without writing. Shares the - 5/hour `import:` rate bucket." + `kg`). `glucoseContext` is optional on a `BLOOD_GLUCOSE` row — a blank cell stores no context, which is the + normal shape of a continuous-sensor export; a non-empty value is still checked against the enum, and the column + is refused on any other type. An `externalId` column makes re-upload idempotent (upsert on `(userId, type, + source=IMPORT, externalId)`); without it a re-upload duplicates. `?dryRun=1` validates + previews without + writing. Shares the 5/hour `import:` rate bucket." parameters: - name: dryRun in: query @@ -8027,6 +8029,7 @@ paths: type,value,unit,measuredAt,glucoseContext,notes,externalId WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,morning, BLOOD_GLUCOSE,5.3,mmol/L,2026-05-01T08:05:00+02:00,FASTING,,meter-001 + BLOOD_GLUCOSE,5.9,mmol/L,2026-05-01T08:20:00+02:00,,,sensor-001 responses: "200": description: Per-row import outcome. @@ -17199,7 +17202,8 @@ components: - type: string - type: "null" glucoseContext: - description: Set only on BLOOD_GLUCOSE rows. + description: Set only on BLOOD_GLUCOSE rows, and null there too when the writer recorded no classification (a sensor + export, a device sync). Never invent one. anyOf: - type: string - type: "null" @@ -18015,7 +18019,8 @@ components: - type: string - type: "null" glucoseContext: - description: Set only on BLOOD_GLUCOSE rows. + description: Set only on BLOOD_GLUCOSE rows, and null there too when the writer recorded no classification (a sensor + export, a device sync). Never invent one. anyOf: - type: string - type: "null" diff --git a/docs/integrations/data-import.md b/docs/integrations/data-import.md index e01d4247d..bd4d13097 100644 --- a/docs/integrations/data-import.md +++ b/docs/integrations/data-import.md @@ -1,6 +1,6 @@ # Data import -HealthLog has two ways to bring data in, both reachable from +HealthLog has three ways to bring data in, all reachable from **Settings → Export & Import**: 1. An **Apple Health `export.zip`** upload — the full archive from the @@ -10,10 +10,18 @@ HealthLog has two ways to bring data in, both reachable from 2. A **generic JSON import** — a small, explicit payload of measurements and mood entries, useful for restoring a partial export or migrating from another tracker. This page documents that format. +3. A **CSV import** — one measurement per row, for a spreadsheet or a + meter / sensor export. Documented in section 3 below. -Both paths skip rows that already exist, so re-running an import is +All three paths skip rows that already exist, so re-running an import is safe — it merges rather than duplicating. +Every import reports what it actually wrote. A file where nothing was +written is shown as a failure, a file where some rows landed and some +were refused is shown as a partial result, and the refused rows are +grouped by reason so one bad column reads as one line rather than a +scroll. + ## 1. Apple Health `export.zip` On the iPhone: **Health app → profile picture → Export All Health @@ -56,7 +64,28 @@ skipped and counted under `skipped` in the response. | `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. | | `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. | | `notes` | no | string | Optional free text. | -| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE` (e.g. fasting / post-meal). | +| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE`. See [Glucose context](#glucose-context). | + +### Glucose context + +`glucoseContext` is one of `FASTING`, `POSTPRANDIAL`, `RANDOM`, +`BEDTIME`, and it is genuinely optional on a `BLOOD_GLUCOSE` row. Leave +it out (JSON) or leave the cell blank (CSV) and the reading is stored +with no context. + +That is the normal case for a continuous sensor or meter export, which +records a value and a timestamp per reading and classifies nothing. A +reading with no context still counts everywhere a reading counts: the +chart, the all-time summary, and the trailing-30-day clinical panel +(time in range, GMI, CV%, estimated A1c) are all context-agnostic. It is +left out only of the per-context breakdowns — the doctor report's +fasting / post-meal rows, the matching FHIR observations, the per-context +targets — because no classification was recorded and none is invented. + +Setting the column on any type other than `BLOOD_GLUCOSE` is rejected. +Setting it to a value outside the four above is rejected. Entering a +reading by hand in the app still asks for a context, because at that +moment you know it. ### Mood entry @@ -195,8 +224,48 @@ The response is a count envelope: } ``` +## 3. CSV import + +`POST /api/import/csv` takes the raw file as `text/csv` — one measurement +per row, no JSON wrapper. The **CSV file** control in +**Settings → Export & Import** posts to it, and **Download example** +mints a file with the right header. + +A header row is required; column order does not matter and header +matching is case-insensitive. + +| Column | Required | Notes | +| ---------------- | -------- | --------------------------------------------------------------------------------------------------------- | +| `type` | yes | A `MeasurementType` from the table above. | +| `value` | yes | A number, in the row's `unit`. | +| `unit` | yes | The canonical unit, or a recognised alias: `mmol/L` → `mg/dL`, `lb` → `kg`, `in` → `cm`, `°F` → `°C`. | +| `measuredAt` | yes | ISO-8601 **with an explicit offset** (`Z` or `±HH:MM`, and `±HHMM` is accepted). No offset, no import. | +| `glucoseContext` | no | Only for `BLOOD_GLUCOSE`; blank means no context. See [Glucose context](#glucose-context). | +| `notes` | no | Free text, up to 200 characters. | +| `externalId` | no | A source-stable id. With it, a re-upload updates the same row; without it, a re-upload creates a new one. | + +A sensor export therefore needs no glucose column at all: + +```csv +type,value,unit,measuredAt,glucoseContext,notes,externalId +BLOOD_GLUCOSE,5.3,mmol/L,2024-04-03T13:15:00+1100,,,sensor-1 +BLOOD_GLUCOSE,5.9,mmol/L,2024-04-03T13:30:00+1100,,,sensor-2 +``` + +Limits: 16 MB per upload, 10 000 valid rows per import, 5 imports per +hour (shared with the JSON import). `?dryRun=1` validates and previews +without writing anything — the **Preview** button uses it. + +The response carries per-row outcomes alongside the counts, and the card +renders the counts, not the status code: rows written and none skipped is +a success, a mixed result is a warning, and nothing written is a failure +with the reasons grouped by count. + ## Converting a CSV into the import JSON +The CSV import above covers measurements. Mood entries have no CSV +route, so a mood history still goes through the JSON payload. + A spreadsheet export is the most common starting point. The shape is small enough to convert by hand for a few rows, or with a one-off script for many. The steps are the same either way: diff --git a/e2e/README.md b/e2e/README.md index fa2e35d96..4cf2a7ef8 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -12,10 +12,12 @@ The Playwright config (`../playwright.config.ts`) builds the app (`next build`) ## Layout -- **`setup/`** — `global-setup.ts` (auth + DB seeding), `test-helpers.ts` (shared login + navigation helpers). +- **`setup/`** — `test.ts` (the suite's `test` + `expect` entry point), `global-setup.ts` (auth + DB seeding), `test-helpers.ts` (shared login + navigation helpers). - **`utils/`** — fixtures such as `mock-dashboard-snapshot.ts`. - **`*.spec.ts`** — one spec per flow: auth/login/redirect, dashboard, charts, the medication-wizard cadences (`medications-wizard-*`), insights generation + scroll restoration, doctor report, settings, mobile-viewport consistency, locale switch, accessibility (`a11y.spec.ts`), and the `v1427-*` regression set. -## Convention +## Conventions + +Import `test` and `expect` from `./setup/test`, never from `@playwright/test` — types (`Page`, `Route`, …) still come from the package. The entry point extends Playwright's `test` with an automatic fixture that drops route handlers and page listeners after every test, so a handler left in flight cannot fail the test it belonged to once the context closes. `src/__tests__/e2e-route-cleanup-guard.test.ts` enforces the import boundary. Assert against stable `data-*` attributes (`data-slot`, `data-display-step`, root display attrs), not viewport-dependent visible text. Responsive `sm:hidden` UI changes break `getByText` on desktop while the underlying element is still present — anchoring on data attributes keeps the specs stable across breakpoints. See [`../CLAUDE.md`](../CLAUDE.md). diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index 1fc1382d3..bcce6030e 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -1,11 +1,6 @@ import AxeBuilder from "@axe-core/playwright"; -import { - expect, - test, - type Locator, - type Page, - type Route, -} from "@playwright/test"; +import type { Locator, Page, Route } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { POPULATED_SUMMARIES } from "./utils/mock-dashboard-snapshot"; diff --git a/e2e/achievements.spec.ts b/e2e/achievements.spec.ts index 9786df03c..9dacf0042 100644 --- a/e2e/achievements.spec.ts +++ b/e2e/achievements.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/admin-api-tokens-mobile.spec.ts b/e2e/admin-api-tokens-mobile.spec.ts index 08bef6dd2..3f82cdb2d 100644 --- a/e2e/admin-api-tokens-mobile.spec.ts +++ b/e2e/admin-api-tokens-mobile.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/ai-provider-dropdown.spec.ts b/e2e/ai-provider-dropdown.spec.ts index f12dd9b05..ea2c00bb2 100644 --- a/e2e/ai-provider-dropdown.spec.ts +++ b/e2e/ai-provider-dropdown.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/auth-redirect.spec.ts b/e2e/auth-redirect.spec.ts index 931cb32d9..196373969 100644 --- a/e2e/auth-redirect.spec.ts +++ b/e2e/auth-redirect.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; /** * The proxy at src/proxy.ts is the single gate enforcing auth on every diff --git a/e2e/central-codex.spec.ts b/e2e/central-codex.spec.ts index dec89459b..8d2e8a4ee 100644 --- a/e2e/central-codex.spec.ts +++ b/e2e/central-codex.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/chart-overlay-controls.spec.ts b/e2e/chart-overlay-controls.spec.ts index 2c4d5f8ef..84d7f2cc8 100644 --- a/e2e/chart-overlay-controls.spec.ts +++ b/e2e/chart-overlay-controls.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { diff --git a/e2e/charts-mobile.spec.ts b/e2e/charts-mobile.spec.ts index a5e86cf17..3e933b4d4 100644 --- a/e2e/charts-mobile.spec.ts +++ b/e2e/charts-mobile.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot"; diff --git a/e2e/coach-conversation-rename.spec.ts b/e2e/coach-conversation-rename.spec.ts index 97551287e..1a8677e9f 100644 --- a/e2e/coach-conversation-rename.spec.ts +++ b/e2e/coach-conversation-rename.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { setTimeout as delay } from "node:timers/promises"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/coach-guided-questions.spec.ts b/e2e/coach-guided-questions.spec.ts index 2b3599965..638d9c74e 100644 --- a/e2e/coach-guided-questions.spec.ts +++ b/e2e/coach-guided-questions.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/codex-flow.spec.ts b/e2e/codex-flow.spec.ts index 5e929066e..ce5883886 100644 --- a/e2e/codex-flow.spec.ts +++ b/e2e/codex-flow.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/cycle-insights-mobile.spec.ts b/e2e/cycle-insights-mobile.spec.ts index cb278f5d0..d45dfbbf2 100644 --- a/e2e/cycle-insights-mobile.spec.ts +++ b/e2e/cycle-insights-mobile.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts index 017051510..ff07c9902 100644 --- a/e2e/dashboard.spec.ts +++ b/e2e/dashboard.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot"; diff --git a/e2e/documents-ai.spec.ts b/e2e/documents-ai.spec.ts index f0f8abbbc..2e75854b0 100644 --- a/e2e/documents-ai.spec.ts +++ b/e2e/documents-ai.spec.ts @@ -16,7 +16,8 @@ * seeds a real `document_content_index` row (`ensureVaultAiFixture`) and drives * the real list union. */ -import { expect, test, type Page } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { diff --git a/e2e/documents-auto-read.spec.ts b/e2e/documents-auto-read.spec.ts index 5583b7320..d341fbed6 100644 --- a/e2e/documents-auto-read.spec.ts +++ b/e2e/documents-auto-read.spec.ts @@ -15,7 +15,8 @@ * AI e2e, so it is pinned here too. Assertions target stable data-slots, never * viewport-dependent text. */ -import { expect, test, type Page } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { diff --git a/e2e/documents-chat.spec.ts b/e2e/documents-chat.spec.ts index e57fc0b78..85afe9b79 100644 --- a/e2e/documents-chat.spec.ts +++ b/e2e/documents-chat.spec.ts @@ -22,7 +22,8 @@ * the specs time out. The seeded demo user has no AI provider, so the * no-provider state is the default. */ -import { expect, test, type Page } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts index 470a67405..0232fd2fc 100644 --- a/e2e/documents.spec.ts +++ b/e2e/documents.spec.ts @@ -17,7 +17,8 @@ * they create, never on the shared doctor-flow corpus. */ import AxeBuilder from "@axe-core/playwright"; -import { expect, test, type Page } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { diff --git a/e2e/insights-generate.spec.ts b/e2e/insights-generate.spec.ts index 19deb147c..1bce560a7 100644 --- a/e2e/insights-generate.spec.ts +++ b/e2e/insights-generate.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/insights-scroll-restoration.spec.ts b/e2e/insights-scroll-restoration.spec.ts index b5be7995a..8dd6e160f 100644 --- a/e2e/insights-scroll-restoration.spec.ts +++ b/e2e/insights-scroll-restoration.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/ipad-viewport.spec.ts b/e2e/ipad-viewport.spec.ts index dde8b9369..8667d8c27 100644 --- a/e2e/ipad-viewport.spec.ts +++ b/e2e/ipad-viewport.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/locale-switch.spec.ts b/e2e/locale-switch.spec.ts index fc231d80d..e66191744 100644 --- a/e2e/locale-switch.spec.ts +++ b/e2e/locale-switch.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; /** * Locale-key drift smoke check — switch the cookie between EN and DE diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts index 23a29bf63..4a0b4ea49 100644 --- a/e2e/login.spec.ts +++ b/e2e/login.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; /** * Smoke checks on the login surface — must render, must wire the diff --git a/e2e/measurement-flow.spec.ts b/e2e/measurement-flow.spec.ts index 904095a1f..8457cf0e1 100644 --- a/e2e/measurement-flow.spec.ts +++ b/e2e/measurement-flow.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/medications-wizard-biweekly.spec.ts b/e2e/medications-wizard-biweekly.spec.ts index 68cc6b024..d687051fa 100644 --- a/e2e/medications-wizard-biweekly.spec.ts +++ b/e2e/medications-wizard-biweekly.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STUB_MEDICATION_ID, diff --git a/e2e/medications-wizard-compose.spec.ts b/e2e/medications-wizard-compose.spec.ts index ddba23f37..d31b51f14 100644 --- a/e2e/medications-wizard-compose.spec.ts +++ b/e2e/medications-wizard-compose.spec.ts @@ -1,4 +1,5 @@ -import { expect, test, type Route } from "@playwright/test"; +import type { Route } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STUB_MEDICATION_ID, diff --git a/e2e/medications-wizard-daily.spec.ts b/e2e/medications-wizard-daily.spec.ts index e6d773dd4..fb6b1dd7d 100644 --- a/e2e/medications-wizard-daily.spec.ts +++ b/e2e/medications-wizard-daily.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STUB_MEDICATION_ID, diff --git a/e2e/medications-wizard-monthly.spec.ts b/e2e/medications-wizard-monthly.spec.ts index 7e7a21373..885500859 100644 --- a/e2e/medications-wizard-monthly.spec.ts +++ b/e2e/medications-wizard-monthly.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STUB_MEDICATION_ID, diff --git a/e2e/medications-wizard-oneshot.spec.ts b/e2e/medications-wizard-oneshot.spec.ts index d851f9a4a..abe36b10a 100644 --- a/e2e/medications-wizard-oneshot.spec.ts +++ b/e2e/medications-wizard-oneshot.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STUB_MEDICATION_ID, diff --git a/e2e/medications-wizard-rolling.spec.ts b/e2e/medications-wizard-rolling.spec.ts index 02ecafc8e..c2ecef158 100644 --- a/e2e/medications-wizard-rolling.spec.ts +++ b/e2e/medications-wizard-rolling.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STUB_MEDICATION_ID, diff --git a/e2e/medications-wizard-weekdays.spec.ts b/e2e/medications-wizard-weekdays.spec.ts index 5386c6666..5ea977aaa 100644 --- a/e2e/medications-wizard-weekdays.spec.ts +++ b/e2e/medications-wizard-weekdays.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STUB_MEDICATION_ID, diff --git a/e2e/mobile-density.spec.ts b/e2e/mobile-density.spec.ts index af0251581..ebe54672a 100644 --- a/e2e/mobile-density.spec.ts +++ b/e2e/mobile-density.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { diff --git a/e2e/mobile-viewport.spec.ts b/e2e/mobile-viewport.spec.ts index af1e46888..200bf4c28 100644 --- a/e2e/mobile-viewport.spec.ts +++ b/e2e/mobile-viewport.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { diff --git a/e2e/mood-card-mobile.spec.ts b/e2e/mood-card-mobile.spec.ts index 9b74d77d1..e3ffa57fd 100644 --- a/e2e/mood-card-mobile.spec.ts +++ b/e2e/mood-card-mobile.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/mood-timestamp.spec.ts b/e2e/mood-timestamp.spec.ts index 3aeca210c..28daa3df8 100644 --- a/e2e/mood-timestamp.spec.ts +++ b/e2e/mood-timestamp.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/onboarding-flicker.spec.ts b/e2e/onboarding-flicker.spec.ts index d69f78d61..bd0ee08ac 100644 --- a/e2e/onboarding-flicker.spec.ts +++ b/e2e/onboarding-flicker.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { diff --git a/e2e/onboarding-tour-passthrough.spec.ts b/e2e/onboarding-tour-passthrough.spec.ts index 558967f53..003bd5e01 100644 --- a/e2e/onboarding-tour-passthrough.spec.ts +++ b/e2e/onboarding-tour-passthrough.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/refetch-nav.spec.ts b/e2e/refetch-nav.spec.ts index ce2f52e84..fb0f16934 100644 --- a/e2e/refetch-nav.spec.ts +++ b/e2e/refetch-nav.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/settings-export.spec.ts b/e2e/settings-export.spec.ts index b116e6a16..e2de1e22b 100644 --- a/e2e/settings-export.spec.ts +++ b/e2e/settings-export.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/settings-integrations-mobile.spec.ts b/e2e/settings-integrations-mobile.spec.ts index 55ab67f28..92397f634 100644 --- a/e2e/settings-integrations-mobile.spec.ts +++ b/e2e/settings-integrations-mobile.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/settings-mobile-consistency.spec.ts b/e2e/settings-mobile-consistency.spec.ts index a75a456d0..4ba303cf1 100644 --- a/e2e/settings-mobile-consistency.spec.ts +++ b/e2e/settings-mobile-consistency.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/settings-overscroll.spec.ts b/e2e/settings-overscroll.spec.ts index 544ecfb7b..b55eb33cc 100644 --- a/e2e/settings-overscroll.spec.ts +++ b/e2e/settings-overscroll.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/setup/test.ts b/e2e/setup/test.ts new file mode 100644 index 000000000..8893f5aa5 --- /dev/null +++ b/e2e/setup/test.ts @@ -0,0 +1,90 @@ +/** + * The E2E suite's `test` / `expect` entry point. + * + * Every spec imports from here instead of from `@playwright/test`, and the + * structural guard `src/__tests__/e2e-route-cleanup-guard.test.ts` keeps it + * that way. + * + * ## Why + * + * A spec that registers `page.route(...)` in a `beforeEach` leaves the handler + * armed until the context closes. If the handler is async — `route.fetch()`, + * an `await` before `route.fulfill()` — teardown can close the context while + * one invocation is still in flight. The callback then rejects against a dead + * target, Playwright attributes the rejection to the test that just finished, + * and a test that had already passed is reported as failed. With `retries: 2` + * plus `failOnFlakyTests` on CI, that is a release blocker whose trigger is + * timing, not code. + * + * The remedy Playwright names is `unrouteAll({ behavior: "ignoreErrors" })` + * before the test ends. Written per spec that is 36 copies of the same three + * lines and one forgotten copy away from the same failure. Written as an auto + * fixture it happens for every test in the suite, including specs nobody has + * written yet, and a spec author never has to know it exists. + * + * ## What it covers + * + * - `page.route(...)` on every page in the test's context. + * - `context.route(...)` on the context itself. + * - Page event listeners for the interactive event types a spec subscribes to. + * A listener that awaits something (a `dialog` handler, a `response` handler + * that reads a body) can reject after teardown for exactly the same reason a + * route handler can. + * + * ## What it does not cover + * + * A page that lives in a context the spec created itself — `browser.newContext()` + * inside a helper — is invisible here, because this fixture can only see the + * context Playwright built for the test. `share-documents.spec.ts` is the one + * spec that does this; it owns those contexts and has to clean them up itself. + */ +import { test as base, expect } from "@playwright/test"; + +/** + * Page event types cleared at teardown. Deliberately not the lifecycle events + * (`close`, `crash`, `frameattached`, …): those describe the teardown we are + * standing in, and nothing in the suite hangs work off them. These are the + * types a spec attaches a handler to, and the ones whose handler can still be + * running when the context goes away. + */ +const OBSERVED_PAGE_EVENTS = [ + "console", + "dialog", + "download", + "filechooser", + "pageerror", + "popup", + "request", + "requestfailed", + "requestfinished", + "response", + "websocket", + "worker", +] as const; + +export const test = base.extend<{ routeCleanup: void }>({ + routeCleanup: [ + async ({ context }, use) => { + await use(); + + // Best-effort: a spec is free to close its own context (or the browser + // can die under it), and a teardown helper must never be the thing that + // turns a green run red. Everything below is a cleanup, so a failure to + // clean up has nothing left to protect. + try { + for (const page of context.pages()) { + await page.unrouteAll({ behavior: "ignoreErrors" }); + for (const event of OBSERVED_PAGE_EVENTS) { + await page.removeAllListeners(event, { behavior: "ignoreErrors" }); + } + } + await context.unrouteAll({ behavior: "ignoreErrors" }); + } catch { + // Target already gone — nothing left to unroute. + } + }, + { auto: true }, + ], +}); + +export { expect }; diff --git a/e2e/share-documents.spec.ts b/e2e/share-documents.spec.ts index d33cfe1de..28d00a7d6 100644 --- a/e2e/share-documents.spec.ts +++ b/e2e/share-documents.spec.ts @@ -24,7 +24,8 @@ * broader corpus (`ensureVaultFixture`) supplies the ≥50 documents the picker * cap test and the not-attached foreign-id probe need. */ -import { expect, test, type Browser, type Page } from "@playwright/test"; +import type { Browser, Page } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { diff --git a/e2e/sidebar-admin-no-expand.spec.ts b/e2e/sidebar-admin-no-expand.spec.ts index 97051932c..2c914934e 100644 --- a/e2e/sidebar-admin-no-expand.spec.ts +++ b/e2e/sidebar-admin-no-expand.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/timezone-profile.spec.ts b/e2e/timezone-profile.spec.ts index 50dd311d4..f4865aa1d 100644 --- a/e2e/timezone-profile.spec.ts +++ b/e2e/timezone-profile.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/today-just-in.spec.ts b/e2e/today-just-in.spec.ts index 843783ce5..c4f0c61ca 100644 --- a/e2e/today-just-in.spec.ts +++ b/e2e/today-just-in.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import pg from "pg"; import { E2E_USER, STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/v1427-admin-login-overview.spec.ts b/e2e/v1427-admin-login-overview.spec.ts index 8dcaad9bf..0446eacd0 100644 --- a/e2e/v1427-admin-login-overview.spec.ts +++ b/e2e/v1427-admin-login-overview.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/v1427-coach-launch.spec.ts b/e2e/v1427-coach-launch.spec.ts index 90e0a3765..9c976824c 100644 --- a/e2e/v1427-coach-launch.spec.ts +++ b/e2e/v1427-coach-launch.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/v1427-insights-empty-state.spec.ts b/e2e/v1427-insights-empty-state.spec.ts index 64c306a3e..62bbcff16 100644 --- a/e2e/v1427-insights-empty-state.spec.ts +++ b/e2e/v1427-insights-empty-state.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/v1427-measurements-add-param.spec.ts b/e2e/v1427-measurements-add-param.spec.ts index daf5096b3..e922945a6 100644 --- a/e2e/v1427-measurements-add-param.spec.ts +++ b/e2e/v1427-measurements-add-param.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/v1427-public-pages.spec.ts b/e2e/v1427-public-pages.spec.ts index 356f635f5..74eeac6db 100644 --- a/e2e/v1427-public-pages.spec.ts +++ b/e2e/v1427-public-pages.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; /** * v1.4.27 MB6 — public surfaces. diff --git a/e2e/v1427-responsive-sheet.spec.ts b/e2e/v1427-responsive-sheet.spec.ts index 1e7a35b86..762df1d6f 100644 --- a/e2e/v1427-responsive-sheet.spec.ts +++ b/e2e/v1427-responsive-sheet.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/e2e/version.spec.ts b/e2e/version.spec.ts index 9dc11eddc..98e5d5963 100644 --- a/e2e/version.spec.ts +++ b/e2e/version.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; /** * /api/version is the public, unauthenticated endpoint that drives diff --git a/e2e/water-capture.spec.ts b/e2e/water-capture.spec.ts index d45761495..7c9e99bf4 100644 --- a/e2e/water-capture.spec.ts +++ b/e2e/water-capture.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { diff --git a/e2e/workouts-infinite-history.spec.ts b/e2e/workouts-infinite-history.spec.ts index c4417320c..2fa882484 100644 --- a/e2e/workouts-infinite-history.spec.ts +++ b/e2e/workouts-infinite-history.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; diff --git a/messages/de.json b/messages/de.json index bfaf09acd..0457825b4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4472,6 +4472,7 @@ "failed": "Der Import ist fehlgeschlagen.", "rowsImported": "Bisher {count} Einträge importiert", "doneSummary": "Fertig — {imported} von {read} Datensätzen importiert, {skipped} klinische Datensätze übersprungen.", + "doneNothing": "Aus diesem Archiv wurde nichts importiert: 0 von {read} Datensätzen sind angekommen.", "estimateWarning": "Kumulative Summen für {count} Tag(e) wurden anhand der größten Exportquelle geschätzt. Native Apple-Health-Statistiken bleiben maßgeblich.", "phase": { "queued": "In Warteschlange…", @@ -4495,7 +4496,10 @@ "rateLimited": "Zu viele Importe in dieser Stunde (Grenze 5). Bitte später erneut versuchen.", "failed": "Der Import ist fehlgeschlagen. Bitte erneut versuchen.", "readFailed": "Diese Datei konnte nicht gelesen werden.", - "resultSummary": "{measurements} Messwerte und {moods} Stimmungseinträge importiert, {skipped} übersprungen." + "resultSummary": "{measurements} Messwerte und {moods} Stimmungseinträge importiert, {skipped} übersprungen.", + "resultSuccess": "{measurements} Messwerte und {moods} Stimmungseinträge importiert.", + "resultNothing": "Nichts importiert. Alle {skipped} Einträge wurden übersprungen.", + "resultEmpty": "Diese Datei enthielt weder Messwerte noch Stimmungseinträge." }, "csv": { "title": "CSV-Datei", @@ -4516,7 +4520,30 @@ "readFailed": "Diese Datei konnte nicht gelesen werden.", "previewSummary": "Vorschau: {inserted} würden importiert, {skipped} übersprungen.", "resultSummary": "{inserted} neu importiert und {updated} aktualisiert, {skipped} übersprungen.", - "rowError": "Zeile {line}: {reason}" + "rowError": "Zeile {line}: {reason}", + "resultSuccess": "{inserted} neu importiert und {updated} aktualisiert.", + "resultNothing": "Nichts importiert. Alle {skipped} Zeilen wurden übersprungen.", + "resultEmpty": "Diese Datei hat eine Kopfzeile, aber keine Datenzeilen.", + "previewSuccess": "Vorschau: {inserted} würden importiert.", + "previewNothing": "Vorschau: nichts würde importiert. Alle {skipped} Zeilen würden übersprungen.", + "skipGroupOne": "{count} Zeile übersprungen: {reason}", + "skipGroupFew": "{count} Zeilen übersprungen: {reason}", + "skipGroupOther": "{count} Zeilen übersprungen: {reason}", + "skipDetails": "Alle übersprungenen Zeilen anzeigen", + "reasonUnknownType": "unbekannter Messwerttyp", + "reasonInvalidValue": "Wert ist keine Zahl", + "reasonValueOutOfRange": "Wert außerhalb des plausiblen Bereichs", + "reasonUnknownUnit": "Einheit für diesen Typ nicht erkannt", + "reasonMissingTimezoneOffset": "Zeitstempel ohne Zeitzonen-Offset", + "reasonInvalidTimestamp": "Zeitstempel nicht lesbar", + "reasonImplausibleTimestamp": "Zeitstempel liegt in der Zukunft oder vor 1900", + "reasonUnexpectedGlucoseContext": "Glukose-Kontext in einer Zeile, die kein Blutzucker ist", + "reasonInvalidGlucoseContext": "Glukose-Kontext ist kein zulässiger Wert", + "reasonNotesTooLong": "Notiz ist zu lang", + "reasonExternalIdTooLong": "Externe ID ist zu lang", + "reasonUnstableExternalId": "Externe ID ist über Exporte hinweg nicht stabil", + "reasonDuplicate": "bereits importiert", + "reasonUnknown": "übersprungen" } }, "subtitle": "Exportiere deine Daten oder importiere aus einer Sicherung." diff --git a/messages/en.json b/messages/en.json index 6a16a1816..37e2e2186 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4462,6 +4462,7 @@ "failed": "The import failed.", "rowsImported": "{count} rows imported so far", "doneSummary": "Done — {imported} of {read} records imported, {skipped} clinical records skipped.", + "doneNothing": "Nothing was imported from that archive: 0 of {read} records landed.", "estimateWarning": "Cumulative totals for {count} day(s) were estimated from the largest export source. Native Apple Health statistics remain authoritative.", "phase": { "queued": "Queued…", @@ -4485,7 +4486,10 @@ "rateLimited": "Too many imports this hour (limit 5). Please try again later.", "failed": "The import failed. Please try again.", "readFailed": "Couldn't read that file.", - "resultSummary": "Imported {measurements} measurements and {moods} mood entries, {skipped} skipped." + "resultSummary": "Imported {measurements} measurements and {moods} mood entries, {skipped} skipped.", + "resultSuccess": "Imported {measurements} measurements and {moods} mood entries.", + "resultNothing": "Nothing was imported. All {skipped} entries were skipped.", + "resultEmpty": "That file carried no measurements and no mood entries." }, "csv": { "title": "CSV file", @@ -4506,7 +4510,30 @@ "readFailed": "Couldn't read that file.", "previewSummary": "Preview: {inserted} would import, {skipped} skipped.", "resultSummary": "Imported {inserted} new and updated {updated}, {skipped} skipped.", - "rowError": "Line {line}: {reason}" + "rowError": "Line {line}: {reason}", + "resultSuccess": "Imported {inserted} new and updated {updated}.", + "resultNothing": "Nothing was imported. All {skipped} rows were skipped.", + "resultEmpty": "That file has a header but no data rows.", + "previewSuccess": "Preview: {inserted} would import.", + "previewNothing": "Preview: nothing would import. All {skipped} rows would be skipped.", + "skipGroupOne": "{count} row skipped: {reason}", + "skipGroupFew": "{count} rows skipped: {reason}", + "skipGroupOther": "{count} rows skipped: {reason}", + "skipDetails": "Show every skipped row", + "reasonUnknownType": "unknown measurement type", + "reasonInvalidValue": "value is not a number", + "reasonValueOutOfRange": "value outside the plausible range", + "reasonUnknownUnit": "unit not recognised for this type", + "reasonMissingTimezoneOffset": "timestamp has no timezone offset", + "reasonInvalidTimestamp": "timestamp could not be read", + "reasonImplausibleTimestamp": "timestamp is in the future or before 1900", + "reasonUnexpectedGlucoseContext": "glucose context set on a row that is not blood glucose", + "reasonInvalidGlucoseContext": "glucose context is not one of the allowed values", + "reasonNotesTooLong": "note is too long", + "reasonExternalIdTooLong": "external ID is too long", + "reasonUnstableExternalId": "external ID is not stable across exports", + "reasonDuplicate": "already imported", + "reasonUnknown": "skipped" } }, "subtitle": "Export your data or import from a backup." diff --git a/messages/es.json b/messages/es.json index 61b9bbad9..5430a002a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4474,6 +4474,7 @@ "failed": "La importación falló.", "rowsImported": "{count} filas importadas hasta ahora", "doneSummary": "Listo: {imported} de {read} registros importados, {skipped} registros clínicos omitidos.", + "doneNothing": "No se importó nada de ese archivo: llegaron 0 de {read} registros.", "estimateWarning": "Los totales acumulados de {count} día(s) se estimaron a partir de la mayor fuente de la exportación. Las estadísticas nativas de Apple Health siguen siendo la referencia.", "phase": { "queued": "En cola…", @@ -4497,7 +4498,10 @@ "rateLimited": "Demasiadas importaciones esta hora (límite 5). Inténtalo más tarde.", "failed": "La importación falló. Inténtalo de nuevo.", "readFailed": "No se pudo leer ese archivo.", - "resultSummary": "{measurements} mediciones y {moods} entradas de ánimo importadas, {skipped} omitidas." + "resultSummary": "{measurements} mediciones y {moods} entradas de ánimo importadas, {skipped} omitidas.", + "resultSuccess": "{measurements} mediciones y {moods} entradas de ánimo importadas.", + "resultNothing": "No se importó nada. Se omitieron las {skipped} entradas.", + "resultEmpty": "Ese archivo no contenía mediciones ni entradas de ánimo." }, "csv": { "title": "Archivo CSV", @@ -4518,7 +4522,30 @@ "readFailed": "No se pudo leer ese archivo.", "previewSummary": "Vista previa: {inserted} se importarían, {skipped} omitidas.", "resultSummary": "{inserted} nuevas importadas y {updated} actualizadas, {skipped} omitidas.", - "rowError": "Línea {line}: {reason}" + "rowError": "Línea {line}: {reason}", + "resultSuccess": "{inserted} nuevas importadas y {updated} actualizadas.", + "resultNothing": "No se importó nada. Se omitieron las {skipped} filas.", + "resultEmpty": "Ese archivo tiene encabezado pero ninguna fila de datos.", + "previewSuccess": "Vista previa: {inserted} se importarían.", + "previewNothing": "Vista previa: no se importaría nada. Se omitirían las {skipped} filas.", + "skipGroupOne": "{count} fila omitida: {reason}", + "skipGroupFew": "{count} filas omitidas: {reason}", + "skipGroupOther": "{count} filas omitidas: {reason}", + "skipDetails": "Mostrar todas las filas omitidas", + "reasonUnknownType": "tipo de medición desconocido", + "reasonInvalidValue": "el valor no es un número", + "reasonValueOutOfRange": "valor fuera del rango plausible", + "reasonUnknownUnit": "unidad no reconocida para este tipo", + "reasonMissingTimezoneOffset": "marca de tiempo sin desfase de zona horaria", + "reasonInvalidTimestamp": "no se pudo leer la marca de tiempo", + "reasonImplausibleTimestamp": "la marca de tiempo está en el futuro o es anterior a 1900", + "reasonUnexpectedGlucoseContext": "contexto de glucosa en una fila que no es de glucosa en sangre", + "reasonInvalidGlucoseContext": "el contexto de glucosa no es un valor permitido", + "reasonNotesTooLong": "la nota es demasiado larga", + "reasonExternalIdTooLong": "el ID externo es demasiado largo", + "reasonUnstableExternalId": "el ID externo no es estable entre exportaciones", + "reasonDuplicate": "ya importada", + "reasonUnknown": "omitida" } }, "subtitle": "Exporta tus datos o importa desde una copia de seguridad." diff --git a/messages/fr.json b/messages/fr.json index 73294c491..0bc0a7a47 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4476,6 +4476,7 @@ "failed": "L'import a échoué.", "rowsImported": "{count} lignes importées jusqu'ici", "doneSummary": "Terminé — {imported} enregistrements sur {read} importés, {skipped} enregistrements cliniques ignorés.", + "doneNothing": "Rien n'a été importé de cette archive : 0 enregistrement sur {read} est arrivé.", "estimateWarning": "Les totaux cumulés de {count} jour(s) ont été estimés à partir de la source d’export la plus élevée. Les statistiques Apple Santé natives restent la référence.", "phase": { "queued": "En file d'attente…", @@ -4499,7 +4500,10 @@ "rateLimited": "Trop d'imports cette heure-ci (limite 5). Réessayez plus tard.", "failed": "L'import a échoué. Veuillez réessayer.", "readFailed": "Impossible de lire ce fichier.", - "resultSummary": "{measurements} mesures et {moods} entrées d'humeur importées, {skipped} ignorées." + "resultSummary": "{measurements} mesures et {moods} entrées d'humeur importées, {skipped} ignorées.", + "resultSuccess": "{measurements} mesures et {moods} entrées d'humeur importées.", + "resultNothing": "Rien n'a été importé. Les {skipped} entrées ont toutes été ignorées.", + "resultEmpty": "Ce fichier ne contenait ni mesures ni entrées d'humeur." }, "csv": { "title": "Fichier CSV", @@ -4520,7 +4524,30 @@ "readFailed": "Impossible de lire ce fichier.", "previewSummary": "Aperçu : {inserted} seraient importées, {skipped} ignorées.", "resultSummary": "{inserted} nouvelles importées et {updated} mises à jour, {skipped} ignorées.", - "rowError": "Ligne {line} : {reason}" + "rowError": "Ligne {line} : {reason}", + "resultSuccess": "{inserted} nouvelles importées et {updated} mises à jour.", + "resultNothing": "Rien n'a été importé. Les {skipped} lignes ont toutes été ignorées.", + "resultEmpty": "Ce fichier a un en-tête mais aucune ligne de données.", + "previewSuccess": "Aperçu : {inserted} seraient importées.", + "previewNothing": "Aperçu : rien ne serait importé. Les {skipped} lignes seraient toutes ignorées.", + "skipGroupOne": "{count} ligne ignorée : {reason}", + "skipGroupFew": "{count} lignes ignorées : {reason}", + "skipGroupOther": "{count} lignes ignorées : {reason}", + "skipDetails": "Afficher toutes les lignes ignorées", + "reasonUnknownType": "type de mesure inconnu", + "reasonInvalidValue": "la valeur n'est pas un nombre", + "reasonValueOutOfRange": "valeur hors de la plage plausible", + "reasonUnknownUnit": "unité non reconnue pour ce type", + "reasonMissingTimezoneOffset": "horodatage sans décalage horaire", + "reasonInvalidTimestamp": "horodatage illisible", + "reasonImplausibleTimestamp": "horodatage dans le futur ou antérieur à 1900", + "reasonUnexpectedGlucoseContext": "contexte glycémique sur une ligne qui n'est pas une glycémie", + "reasonInvalidGlucoseContext": "le contexte glycémique n'est pas une valeur autorisée", + "reasonNotesTooLong": "la note est trop longue", + "reasonExternalIdTooLong": "l'identifiant externe est trop long", + "reasonUnstableExternalId": "l'identifiant externe n'est pas stable d'un export à l'autre", + "reasonDuplicate": "déjà importée", + "reasonUnknown": "ignorée" } }, "subtitle": "Exportez vos données ou importez depuis une sauvegarde." diff --git a/messages/it.json b/messages/it.json index ec0ae461c..8064f1679 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4466,6 +4466,7 @@ "failed": "L'importazione non è riuscita.", "rowsImported": "{count} righe importate finora", "doneSummary": "Fatto: {imported} di {read} record importati, {skipped} record clinici saltati.", + "doneNothing": "Da questo archivio non è stato importato nulla: sono arrivati 0 record su {read}.", "estimateWarning": "I totali cumulativi di {count} giorno/i sono stati stimati dalla fonte di esportazione più alta. Le statistiche native di Apple Salute restano autorevoli.", "phase": { "queued": "In coda…", @@ -4489,7 +4490,10 @@ "rateLimited": "Troppe importazioni in questa ora (limite 5). Riprova più tardi.", "failed": "L'importazione non è riuscita. Riprova.", "readFailed": "Impossibile leggere il file.", - "resultSummary": "{measurements} misurazioni e {moods} voci dell'umore importate, {skipped} saltate." + "resultSummary": "{measurements} misurazioni e {moods} voci dell'umore importate, {skipped} saltate.", + "resultSuccess": "{measurements} misurazioni e {moods} voci dell'umore importate.", + "resultNothing": "Non è stato importato nulla. Tutte le {skipped} voci sono state saltate.", + "resultEmpty": "Questo file non conteneva né misurazioni né voci dell'umore." }, "csv": { "title": "File CSV", @@ -4510,7 +4514,30 @@ "readFailed": "Impossibile leggere questo file.", "previewSummary": "Anteprima: {inserted} verrebbero importate, {skipped} saltate.", "resultSummary": "{inserted} nuove importate e {updated} aggiornate, {skipped} saltate.", - "rowError": "Riga {line}: {reason}" + "rowError": "Riga {line}: {reason}", + "resultSuccess": "{inserted} nuove importate e {updated} aggiornate.", + "resultNothing": "Non è stato importato nulla. Tutte le {skipped} righe sono state saltate.", + "resultEmpty": "Questo file ha un'intestazione ma nessuna riga di dati.", + "previewSuccess": "Anteprima: {inserted} verrebbero importate.", + "previewNothing": "Anteprima: non verrebbe importato nulla. Tutte le {skipped} righe verrebbero saltate.", + "skipGroupOne": "{count} riga saltata: {reason}", + "skipGroupFew": "{count} righe saltate: {reason}", + "skipGroupOther": "{count} righe saltate: {reason}", + "skipDetails": "Mostra tutte le righe saltate", + "reasonUnknownType": "tipo di misurazione sconosciuto", + "reasonInvalidValue": "il valore non è un numero", + "reasonValueOutOfRange": "valore fuori dall'intervallo plausibile", + "reasonUnknownUnit": "unità non riconosciuta per questo tipo", + "reasonMissingTimezoneOffset": "marca temporale senza offset del fuso orario", + "reasonInvalidTimestamp": "marca temporale illeggibile", + "reasonImplausibleTimestamp": "marca temporale nel futuro o precedente al 1900", + "reasonUnexpectedGlucoseContext": "contesto glicemico su una riga che non è glicemia", + "reasonInvalidGlucoseContext": "il contesto glicemico non è un valore consentito", + "reasonNotesTooLong": "la nota è troppo lunga", + "reasonExternalIdTooLong": "l'ID esterno è troppo lungo", + "reasonUnstableExternalId": "l'ID esterno non è stabile tra un export e l'altro", + "reasonDuplicate": "già importata", + "reasonUnknown": "saltata" } }, "subtitle": "Esporta i tuoi dati o importa da un backup." diff --git a/messages/pl.json b/messages/pl.json index 7b852c5ef..9d18cea87 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4460,6 +4460,7 @@ "failed": "Import nie powiódł się.", "rowsImported": "Zaimportowano dotąd {count} wierszy", "doneSummary": "Gotowe — zaimportowano {imported} z {read} rekordów, pominięto {skipped} rekordów klinicznych.", + "doneNothing": "Z tego archiwum nic nie zaimportowano: dotarło 0 z {read} rekordów.", "estimateWarning": "Łączne wartości dla {count} dni oszacowano na podstawie największego źródła w eksporcie. Natywne statystyki Apple Health pozostają nadrzędne.", "phase": { "queued": "W kolejce…", @@ -4483,7 +4484,10 @@ "rateLimited": "Zbyt wiele importów w tej godzinie (limit 5). Spróbuj później.", "failed": "Import nie powiódł się. Spróbuj ponownie.", "readFailed": "Nie można odczytać tego pliku.", - "resultSummary": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju, pominięto {skipped}." + "resultSummary": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju, pominięto {skipped}.", + "resultSuccess": "Zaimportowano {measurements} pomiarów i {moods} wpisów nastroju.", + "resultNothing": "Nic nie zaimportowano. Pominięto wszystkie {skipped} wpisów.", + "resultEmpty": "Ten plik nie zawierał pomiarów ani wpisów nastroju." }, "csv": { "title": "Plik CSV", @@ -4504,7 +4508,30 @@ "readFailed": "Nie udało się odczytać tego pliku.", "previewSummary": "Podgląd: {inserted} zostanie zaimportowanych, pominięto {skipped}.", "resultSummary": "Zaimportowano {inserted} nowych i zaktualizowano {updated}, pominięto {skipped}.", - "rowError": "Wiersz {line}: {reason}" + "rowError": "Wiersz {line}: {reason}", + "resultSuccess": "Zaimportowano {inserted} nowych i zaktualizowano {updated}.", + "resultNothing": "Nic nie zaimportowano. Pominięto wszystkie {skipped} wierszy.", + "resultEmpty": "Ten plik ma nagłówek, ale nie zawiera wierszy danych.", + "previewSuccess": "Podgląd: {inserted} zostanie zaimportowanych.", + "previewNothing": "Podgląd: nic nie zostanie zaimportowane. Pominięte zostaną wszystkie {skipped} wierszy.", + "skipGroupOne": "Pominięto {count} wiersz: {reason}", + "skipGroupFew": "Pominięto {count} wiersze: {reason}", + "skipGroupOther": "Pominięto {count} wierszy: {reason}", + "skipDetails": "Pokaż wszystkie pominięte wiersze", + "reasonUnknownType": "nieznany typ pomiaru", + "reasonInvalidValue": "wartość nie jest liczbą", + "reasonValueOutOfRange": "wartość poza wiarygodnym zakresem", + "reasonUnknownUnit": "jednostka nierozpoznana dla tego typu", + "reasonMissingTimezoneOffset": "znacznik czasu bez przesunięcia strefy czasowej", + "reasonInvalidTimestamp": "nie udało się odczytać znacznika czasu", + "reasonImplausibleTimestamp": "znacznik czasu w przyszłości lub sprzed 1900 roku", + "reasonUnexpectedGlucoseContext": "kontekst glukozy w wierszu, który nie jest pomiarem glukozy", + "reasonInvalidGlucoseContext": "kontekst glukozy nie jest dozwoloną wartością", + "reasonNotesTooLong": "notatka jest zbyt długa", + "reasonExternalIdTooLong": "identyfikator zewnętrzny jest zbyt długi", + "reasonUnstableExternalId": "identyfikator zewnętrzny nie jest stabilny między eksportami", + "reasonDuplicate": "już zaimportowano", + "reasonUnknown": "pominięto" } }, "subtitle": "Wyeksportuj dane lub zaimportuj z kopii zapasowej." diff --git a/package.json b/package.json index 374ad5de4..1f50c9823 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.32.37", + "version": "1.32.38", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/prisma/migrations/0274_glucose_context_optional/migration.sql b/prisma/migrations/0274_glucose_context_optional/migration.sql new file mode 100644 index 000000000..088d8db6a --- /dev/null +++ b/prisma/migrations/0274_glucose_context_optional/migration.sql @@ -0,0 +1,31 @@ +-- v1.32.x — a blood-glucose reading may carry no context. +-- +-- Migration 0021 introduced `measurements.glucose_context` with a CHECK that +-- read both ways at once: a BLOOD_GLUCOSE row MUST carry a context, and every +-- other type MUST NOT. The second half is right and stays. The first half was +-- written when the only glucose writer was the manual entry form, where a +-- person picks fasting / post-meal / random / bedtime at the moment of the +-- reading. +-- +-- Every machine writer that arrived later has no such classification per +-- sample: a continuous sensor export, the Nightscout SGV pull, the MCP +-- `log_measurement` tool and the Telegram numeric capture all build their row +-- without a context, so the insert failed against this constraint — a CSV +-- import of a sensor history wrote nothing, a Nightscout tick counted every +-- reading as a failed row. +-- +-- A missing context is missing. It stores as NULL, never as a fifth enum +-- member: every per-context reader (the doctor report, the FHIR per-context +-- Observations, the analytics + dashboard breakdowns, the target builder) +-- selects by equality against the four members, so NULL falls out of each +-- rather than being presented as a classification that nobody recorded. The +-- context-agnostic readers (the clinical panel, the charts, the all-time +-- summary) keep the row in full. +ALTER TABLE "measurements" + DROP CONSTRAINT IF EXISTS "measurements_glucose_context_requires_type"; + +ALTER TABLE "measurements" + ADD CONSTRAINT "measurements_glucose_context_requires_type" + CHECK ( + type = 'BLOOD_GLUCOSE' OR glucose_context IS NULL + ); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fd31c7cf6..3b31a9a02 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1637,7 +1637,13 @@ model Measurement { // explicitly LEGACY_UNKNOWN until a native sync or archive reprocess repairs // them. aggregationProvenance MeasurementAggregationProvenance? @map("aggregation_provenance") - // Only set for BLOOD_GLUCOSE measurements. NULL for all others. + // Only set for BLOOD_GLUCOSE measurements. NULL for all others, and NULL + // for a BLOOD_GLUCOSE row whose writer has no classification to record — + // a sensor export, the Nightscout pull, the MCP tool, the Telegram capture. + // A CHECK constraint (migrations 0021 + 0274) enforces the type pairing in + // one direction only: a non-glucose row can never carry a context. Every + // per-context reader selects by equality against the four enum members, so + // a NULL context stays absent rather than reading as a classification. glucoseContext GlucoseContext? @map("glucose_context") // v1.4.23 — per-stage sleep label. NULL for non-sleep rows; non-NULL // only for `type = SLEEP_DURATION`. Mirrors the glucoseContext diff --git a/public/sw.js b/public/sw.js index 90b4ee924..0a394fe63 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.32.37"; + /* @sw-version-fallback */ "v1.32.38"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/__tests__/e2e-route-cleanup-guard.test.ts b/src/__tests__/e2e-route-cleanup-guard.test.ts new file mode 100644 index 000000000..0e7613914 --- /dev/null +++ b/src/__tests__/e2e-route-cleanup-guard.test.ts @@ -0,0 +1,156 @@ +/** + * Structural guard on the E2E suite's route-cleanup boundary. + * + * A spec that arms `page.route(...)` in a `beforeEach` and never drops the + * handler races its own teardown: the context closes, an in-flight handler + * rejects, and Playwright reports a test that had already passed as failed. + * `e2e/setup/test.ts` removes the possibility with an auto fixture, but only + * for specs that take their `test` from there — a spec that imports straight + * from `@playwright/test` silently opts out and looks identical in review. + * + * So the entry point is the invariant, and this file is what keeps it true. + * It is a tripwire, not a proof: it shows nobody has changed the import + * boundary without editing this file. That the fixture actually cleans up is + * a runtime property, proven by the suite itself. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, globSync } from "node:fs"; +import { join, sep } from "node:path"; + +const E2E = join(process.cwd(), "e2e"); + +/** The shared entry point, relative to `e2e/`. */ +const ENTRY = "setup/test.ts"; + +/** + * Files allowed to import `test` from `@playwright/test` directly, each with + * the reason it has to. + */ +const DIRECT_IMPORT_ALLOWLIST: Record = { + // The entry point itself — it extends Playwright's base `test`, which it + // cannot do without importing it. + [ENTRY]: "defines the extended `test` every other file imports", +}; + +function e2eFiles(): string[] { + return globSync("**/*.ts", { cwd: E2E }) + .map((p) => p.split(sep).join("/")) + .sort(); +} + +function read(rel: string): string { + return readFileSync(join(E2E, rel), "utf8"); +} + +/** + * The file with its comments removed. The entry point documents the very API + * calls it makes, so a naive substring match would be satisfied by the prose + * alone — a fixture gutted to a no-op would still read as compliant. + */ +function stripComments(src: string): string { + return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); +} + +/** + * Every `from ""` import statement in a file, as + * `{ module, specifiers }` — `specifiers` is the raw brace body, or `"*"` for + * a namespace import. + */ +function imports(src: string): Array<{ module: string; specifiers: string }> { + const found: Array<{ module: string; specifiers: string }> = []; + for (const m of src.matchAll( + /import\s+(type\s+)?\{([\s\S]*?)\}\s*from\s*"([^"]+)"/g, + )) { + // `import type { … }` imports no runtime binding at all. + found.push({ module: m[3], specifiers: m[1] ? "" : m[2] }); + } + for (const m of src.matchAll(/import\s+\*\s+as\s+\w+\s+from\s*"([^"]+)"/g)) { + found.push({ module: m[1], specifiers: "*" }); + } + return found; +} + +/** Does this file import the runtime `test` binding from `module`? */ +function importsTestFrom(src: string, module: string): boolean { + return imports(src) + .filter((i) => i.module === module) + .some( + (i) => + i.specifiers === "*" || + i.specifiers + .split(",") + .map((s) => s.trim()) + .filter((s) => s && !s.startsWith("type ")) + .some((s) => /^test(\s+as\s+\w+)?$/.test(s)), + ); +} + +describe("the E2E suite takes `test` from the shared entry point", () => { + it("no file outside the allowlist imports `test` from @playwright/test", () => { + const offenders = e2eFiles().filter( + (rel) => + !(rel in DIRECT_IMPORT_ALLOWLIST) && + importsTestFrom(read(rel), "@playwright/test"), + ); + + expect(offenders).toEqual([]); + }); + + it("the allowlist names only files that exist and still need the exemption", () => { + for (const rel of Object.keys(DIRECT_IMPORT_ALLOWLIST)) { + expect( + importsTestFrom(read(rel), "@playwright/test"), + `${rel} is allowlisted but no longer imports \`test\` directly — drop the entry`, + ).toBe(true); + } + }); + + it("every spec imports `test` from the entry point", () => { + const specs = e2eFiles().filter((rel) => rel.endsWith(".spec.ts")); + // Sanity: the sweep found the suite, not an empty directory. + expect(specs.length).toBeGreaterThan(40); + + const missing = specs.filter((rel) => { + const src = read(rel); + // Specs sit at `e2e/.spec.ts`, so the entry resolves as + // `./setup/test`. A nested spec would need a different prefix; none + // exists, and this assertion is what would surface one. + return !importsTestFrom(src, "./setup/test"); + }); + + expect(missing).toEqual([]); + }); +}); + +describe("the entry point still cleans up", () => { + const src = stripComments(read(ENTRY)); + + it("registers the cleanup as an automatic fixture", () => { + // `auto: true` is the whole point: a spec author cannot forget it and does + // not have to declare it. Without the flag the fixture is dead code. + expect(src).toMatch(/\{\s*auto:\s*true\s*\}/); + }); + + it("drops route handlers on both the page and the context", () => { + expect(src).toMatch(/page\.unrouteAll\(\{\s*behavior:\s*"ignoreErrors"/); + expect(src).toMatch(/context\.unrouteAll\(\{\s*behavior:\s*"ignoreErrors"/); + }); + + it("drops page event listeners", () => { + expect(src).toMatch(/removeAllListeners\(/); + }); + + it("cleans up after the test body, not before it", () => { + // `await use()` first, cleanup after — a fixture that cleaned up before + // the test would unroute the handlers a `beforeEach` had just armed. + const useAt = src.indexOf("await use()"); + const unrouteAt = src.indexOf("unrouteAll("); + expect(useAt).toBeGreaterThan(-1); + expect(unrouteAt).toBeGreaterThan(useAt); + }); + + it("exports both `test` and `expect`, so a spec needs one import", () => { + expect(src).toMatch(/export const test\b/); + expect(src).toMatch(/export \{ expect \}/); + }); +}); diff --git a/src/app/api/import/csv/__tests__/route.test.ts b/src/app/api/import/csv/__tests__/route.test.ts index 5fae65083..70e47ed3e 100644 --- a/src/app/api/import/csv/__tests__/route.test.ts +++ b/src/app/api/import/csv/__tests__/route.test.ts @@ -339,3 +339,87 @@ describe("POST /api/import/csv — batched write + per-row envelope", () => { ); }); }); + +describe("POST /api/import/csv — contextless blood glucose", () => { + // #640 — the reported file. A sensor export leaves `glucoseContext` blank + // on every row; the route must build a row with the column NULL rather + // than refusing the reading. + it("writes a contextless sensor reading with the canonical shape", async () => { + const res = await POST( + csvRequest( + [ + HEADER, + "BLOOD_GLUCOSE,5.3,mmol/L,2024-04-03T13:15:00+1100,,Sensor,sensor-1", + ].join("\n"), + ), + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as CsvEnvelope; + expect(body.data?.inserted).toBe(1); + expect(body.data?.skipped).toBe(0); + expect(body.data?.rows).toEqual([{ line: 2, status: "inserted" }]); + + const row = mMeasurement().createManyAndReturn.mock.calls[0][0].data[0]; + expect(row).toMatchObject({ + userId: "u-1", + type: "BLOOD_GLUCOSE", + unit: "mg/dL", + source: "IMPORT", + externalId: "sensor-1", + glucoseContext: null, + }); + expect(row.value).toBeCloseTo(95.4848, 3); + expect((row.measuredAt as Date).toISOString()).toBe( + "2024-04-03T02:15:00.000Z", + ); + }); + + it("keeps a re-upload of the same external id idempotent", async () => { + // The key already exists under (userId, type, source=IMPORT, externalId). + mMeasurement().findMany.mockResolvedValue([ + { type: "BLOOD_GLUCOSE", externalId: "sensor-1" }, + ]); + + const res = await POST( + csvRequest( + [ + HEADER, + "BLOOD_GLUCOSE,5.3,mmol/L,2024-04-03T13:15:00+1100,,Sensor,sensor-1", + ].join("\n"), + ), + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as CsvEnvelope; + expect(body.data?.inserted).toBe(0); + expect(body.data?.updated).toBe(1); + expect(body.data?.rows).toEqual([{ line: 2, status: "updated" }]); + // No second row minted — the existing one is updated in place. + expect(mMeasurement().createManyAndReturn).not.toHaveBeenCalled(); + const update = mMeasurement().updateMany.mock.calls[0][0]; + expect(update.where).toMatchObject({ + userId: "u-1", + source: "IMPORT", + type: "BLOOD_GLUCOSE", + externalId: "sensor-1", + }); + expect(update.data).toMatchObject({ glucoseContext: null, unit: "mg/dL" }); + }); + + it("still refuses a context that is not one of the four", async () => { + const res = await POST( + csvRequest( + [HEADER, "BLOOD_GLUCOSE,95,mg/dL,2026-05-01T08:00:00Z,LUNCH,,"].join( + "\n", + ), + ), + ); + const body = (await res.json()) as CsvEnvelope; + expect(body.data?.inserted).toBe(0); + expect(body.data?.rows[0]).toMatchObject({ + status: "skipped", + reason: "invalid_glucose_context", + }); + }); +}); diff --git a/src/components/settings/__tests__/import-panel.test.tsx b/src/components/settings/__tests__/import-panel.test.tsx index 8b55cb2d3..31df66cae 100644 --- a/src/components/settings/__tests__/import-panel.test.tsx +++ b/src/components/settings/__tests__/import-panel.test.tsx @@ -152,6 +152,18 @@ describe("EXAMPLE_CSV", () => { expect(out.rows.length).toBeGreaterThan(0); expect(out.rows.every((r) => r.status === "ok")).toBe(true); }); + + it("carries a contextless glucose reading, the shape a sensor export has", () => { + const out = parseCsvMeasurements(EXAMPLE_CSV, { + now: new Date("2026-06-01T00:00:00Z").getTime(), + }); + const glucose = out.rows + .map((r) => r.row) + .filter((row) => row?.type === "BLOOD_GLUCOSE"); + expect(glucose.length).toBeGreaterThanOrEqual(2); + expect(glucose.some((row) => row?.glucoseContext === undefined)).toBe(true); + expect(glucose.some((row) => row?.glucoseContext === "FASTING")).toBe(true); + }); }); describe("parseImportJson guard", () => { diff --git a/src/components/settings/import-panel/__tests__/import-result-guard.test.ts b/src/components/settings/import-panel/__tests__/import-result-guard.test.ts new file mode 100644 index 000000000..ee5f100df --- /dev/null +++ b/src/components/settings/import-panel/__tests__/import-result-guard.test.ts @@ -0,0 +1,75 @@ +/** + * #640 structural guard — "wrote nothing, looked successful" cannot come back. + * + * The classifier property in `import-result-state.test.ts` proves the rule. + * This proves the rule is the only route to the affordance: the success icon + * and the success colour exist in exactly one file in the import panel, on + * exactly one key of the outcome table, and no card reaches for them + * directly. A future card that hand-rolls a green tick fails here rather than + * shipping and lying to a self-hoster about their sensor history. + */ +import { describe, it, expect } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +const PANEL_DIR = join(__dirname, ".."); +const PRESENTATION_FILE = "import-result-view.tsx"; + +/** The success affordance: the tick component and the success colour token. */ +const SUCCESS_MARKERS = ["CheckCircle2", "text-success"] as const; + +function panelSourceFiles(): string[] { + return readdirSync(PANEL_DIR).filter( + (name) => name.endsWith(".ts") || name.endsWith(".tsx"), + ); +} + +/** Code only — a comment naming the marker is prose, not an affordance. */ +function readCode(name: string): string { + return readFileSync(join(PANEL_DIR, name), "utf8") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/^\s*\/\/.*$/gm, ""); +} + +describe("import panel — success affordance is owned by the outcome table", () => { + it("mentions the success markers in the presentation module only", () => { + const offenders = panelSourceFiles() + .filter((name) => name !== PRESENTATION_FILE) + .filter((name) => { + const source = readCode(name); + return SUCCESS_MARKERS.some((marker) => source.includes(marker)); + }); + expect(offenders).toEqual([]); + }); + + it("binds each success marker to the success outcome inside that module", () => { + const source = readCode(PRESENTATION_FILE); + for (const marker of SUCCESS_MARKERS) { + // Once as the lucide import (the tick only), once on the table row. + const uses = source.split(marker).length - 1; + expect(uses).toBe(marker === "CheckCircle2" ? 2 : 1); + } + // The table row itself: `success:` carries both markers, and no other + // outcome key does. + const successRow = source.match(/success:\s*\{[^}]*\}/)?.[0] ?? ""; + expect(successRow).toContain("CheckCircle2"); + expect(successRow).toContain("text-success"); + for (const other of ["partial", "failed", "empty"]) { + const row = source.match(new RegExp(`${other}:\\s*\\{[^}]*\\}`))?.[0]; + expect(row).toBeTruthy(); + for (const marker of SUCCESS_MARKERS) { + expect(row).not.toContain(marker); + } + } + }); + + it("routes every card's result line through the shared outcome line", () => { + for (const name of [ + "csv-import-card.tsx", + "json-import-card.tsx", + "apple-health-import-card.tsx", + ]) { + expect(readCode(name)).toMatch(/CsvImportResultView|ImportOutcomeLine/); + } + }); +}); diff --git a/src/components/settings/import-panel/__tests__/import-result-state.test.ts b/src/components/settings/import-panel/__tests__/import-result-state.test.ts new file mode 100644 index 000000000..7870b680e --- /dev/null +++ b/src/components/settings/import-panel/__tests__/import-result-state.test.ts @@ -0,0 +1,94 @@ +/** + * #640 — an import that wrote nothing must never read as a success. + * + * The reported failure was not the parser refusing rows; it was the card + * answering that refusal with a green tick, which is what turned a + * recoverable problem into a silent one. These pin the classifier that both + * import cards render from, and the structural guard that keeps the success + * affordance attached to the success outcome alone lives alongside in + * `import-result-guard.test.ts`. + */ +import { describe, it, expect } from "vitest"; + +import { + classifyImportOutcome, + groupSkipReasons, +} from "../import-result-state"; + +describe("classifyImportOutcome", () => { + it("calls the reported production result a failure", () => { + // The audit envelope from the report: 1 597 rows in, nothing written. + expect(classifyImportOutcome({ written: 0, skipped: 1597 })).toBe("failed"); + }); + + it("calls a file with no data rows empty, not a success", () => { + expect(classifyImportOutcome({ written: 0, skipped: 0 })).toBe("empty"); + }); + + it("calls a mixed result partial", () => { + expect(classifyImportOutcome({ written: 3, skipped: 2 })).toBe("partial"); + }); + + it("calls a clean result a success", () => { + expect(classifyImportOutcome({ written: 5, skipped: 0 })).toBe("success"); + }); + + it("never reaches success while nothing was written", () => { + for (let written = 0; written <= 4; written++) { + for (let skipped = 0; skipped <= 4; skipped++) { + const outcome = classifyImportOutcome({ written, skipped }); + if (written === 0) { + expect(outcome).not.toBe("success"); + expect(outcome).not.toBe("partial"); + } + if (outcome === "success") { + expect(written).toBeGreaterThan(0); + expect(skipped).toBe(0); + } + } + } + }); +}); + +describe("groupSkipReasons", () => { + it("collapses a repeated reason to one entry with a count", () => { + const rows = Array.from({ length: 1597 }, (_, i) => ({ + line: i + 2, + status: "skipped", + reason: "invalid_glucose_context", + })); + expect(groupSkipReasons(rows)).toEqual([ + { reason: "invalid_glucose_context", count: 1597 }, + ]); + }); + + it("ignores rows that were written", () => { + expect( + groupSkipReasons([ + { line: 2, status: "inserted" }, + { line: 3, status: "updated" }, + { line: 4, status: "skipped", reason: "unknown_unit" }, + ]), + ).toEqual([{ reason: "unknown_unit", count: 1 }]); + }); + + it("orders by count descending, then by reason", () => { + const rows = [ + { line: 2, status: "skipped", reason: "unknown_unit" }, + { line: 3, status: "skipped", reason: "duplicate" }, + { line: 4, status: "skipped", reason: "duplicate" }, + { line: 5, status: "skipped", reason: "notes_too_long" }, + ]; + expect(groupSkipReasons(rows)).toEqual([ + { reason: "duplicate", count: 2 }, + { reason: "notes_too_long", count: 1 }, + { reason: "unknown_unit", count: 1 }, + ]); + }); + + it("buckets a skipped row that carries no reason", () => { + expect(groupSkipReasons([{ line: 2, status: "skipped" }])).toEqual([ + { reason: "unknown", count: 1 }, + ]); + }); +}); diff --git a/src/components/settings/import-panel/__tests__/import-result-view.test.tsx b/src/components/settings/import-panel/__tests__/import-result-view.test.tsx new file mode 100644 index 000000000..de6506f2f --- /dev/null +++ b/src/components/settings/import-panel/__tests__/import-result-view.test.tsx @@ -0,0 +1,180 @@ +/** + * #640 — the CSV import result renders what actually happened. + * + * Project convention is SSR-only component tests (vitest runs `node`; + * `@testing-library/react` is not installed), so the view takes the result as + * a prop and each state is rendered directly. + * + * The states pinned here are the ones the report separated: a file where + * everything was refused must not carry the success icon or success wording, + * a mixed file reads as a warning, a clean file reads as a success, and a + * file with no data rows gets its own message rather than either. Skip + * reasons render grouped with a count, in plain language, inside the card's + * `aria-live` region so a screen reader hears the outcome too. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/settings/export", +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { + CsvImportResultView, + type CsvImportResult, +} from "../import-result-view"; +import { CsvImportCard } from "../csv-import-card"; + +function render(node: React.ReactElement) { + return renderToStaticMarkup( + {node}, + ); +} + +function result(over: Partial = {}): CsvImportResult { + return { + inserted: 0, + updated: 0, + skipped: 0, + total: 0, + dryRun: false, + rows: [], + ...over, + }; +} + +/** The production envelope from the report: nothing written, everything refused. */ +const ALL_SKIPPED = result({ + skipped: 1597, + total: 1597, + rows: Array.from({ length: 1597 }, (_, i) => ({ + line: i + 2, + status: "skipped", + reason: "invalid_glucose_context", + })), +}); + +describe(" — result states", () => { + it("renders an all-skipped import as a failure, not a success", () => { + const html = render(); + expect(html).toContain('data-outcome="failed"'); + expect(html).not.toContain('data-outcome="success"'); + expect(html).not.toContain("text-success"); + expect(html).toContain("Nothing was imported"); + expect(html).toContain("All 1597 rows were skipped"); + }); + + it("renders a mixed import as a warning", () => { + const html = render( + , + ); + expect(html).toContain('data-outcome="partial"'); + expect(html).toContain("text-warning"); + expect(html).not.toContain("text-success"); + }); + + it("renders a clean import as a success", () => { + const html = render( + , + ); + expect(html).toContain('data-outcome="success"'); + expect(html).toContain("text-success"); + expect(html).toContain("Imported 4 new and updated 1."); + expect(html).not.toContain("skipped"); + }); + + it("gives a file with no data rows its own message", () => { + const html = render(); + expect(html).toContain('data-outcome="empty"'); + expect(html).not.toContain("text-success"); + expect(html).toContain("no data rows"); + }); + + it("reports a preview that would import nothing as a failure", () => { + const html = render( + , + ); + expect(html).toContain('data-outcome="failed"'); + expect(html).toContain("nothing would import"); + }); +}); + +describe(" — skip reasons", () => { + it("groups a repeated reason into one counted line, in plain language", () => { + const html = render(); + expect(html).toContain( + "1597 rows skipped: glucose context is not one of the allowed values", + ); + // One grouped line, not 1 597 (and not the old first-fifty list). + const groups = html.split('data-testid="import-csv-skip-groups"')[1] ?? ""; + expect(groups.split("").length - 1).toBeGreaterThan(0); + expect(html.match(/1597 rows skipped/g)).toHaveLength(1); + }); + + it("keeps the per-line detail behind a disclosure", () => { + const html = render( + , + ); + expect(html).toContain(" { + const html = render( + , + ); + expect(html).not.toContain("brand_new_reason"); + expect(html).toContain("1 row skipped: skipped"); + }); +}); + +describe(" — announcement", () => { + it("keeps the result slot inside the polite live region", () => { + const html = render(); + expect(html).toContain('aria-live="polite"'); + // No result yet: the region is present and empty, so the first outcome to + // land is announced rather than being read as initial content. + expect(html).not.toContain('data-testid="import-csv-result"'); + }); +}); diff --git a/src/components/settings/import-panel/apple-health-import-card.tsx b/src/components/settings/import-panel/apple-health-import-card.tsx index ce22accec..cfeb60c3f 100644 --- a/src/components/settings/import-panel/apple-health-import-card.tsx +++ b/src/components/settings/import-panel/apple-health-import-card.tsx @@ -2,7 +2,7 @@ import { useCallback, useId, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { AlertCircle, CheckCircle2, Loader2, Upload } from "lucide-react"; +import { AlertCircle, Loader2, Upload } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; @@ -11,6 +11,8 @@ import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; import { ImportCardShell } from "./import-card-shell"; +import { classifyImportOutcome } from "./import-result-state"; +import { ImportOutcomeLine } from "./import-result-view"; /** Terminal states the status poll stops on. */ const TERMINAL_STATES: readonly string[] = ["done", "failed"]; @@ -237,22 +239,28 @@ export function AppleHealthImportCard() { )} {isDone && ( -
-
+ 0 + ? t("settings.sections.export.import.appleHealth.doneSummary", { + imported: status?.result?.totals?.rowsUpserted ?? 0, + read: status?.result?.totals?.recordsRead ?? 0, + skipped: status?.result?.clinical?.skipped ?? 0, + }) + : t("settings.sections.export.import.appleHealth.doneNothing", { + read: status?.result?.totals?.recordsRead ?? 0, + }) + } + testId="import-apple-health-result" + /> )} {isDone && (status?.result?.cumulativeEstimates?.days ?? 0) > 0 && ( r.status === "skipped") ?? []; - return (
- {result && ( -
-

-

- {errorRows.length > 0 && ( -
    - {errorRows.slice(0, 50).map((r) => ( -
  • - {t("settings.sections.export.import.csv.rowError", { - line: r.line, - reason: r.reason ?? "skipped", - })} -
  • - ))} -
- )} -
- )} + {result && } {error && (

0 ? "failed" : "empty"; + return skipped > 0 ? "partial" : "success"; +} + +export interface SkipReasonGroup { + /** The machine-readable reason as the route reported it. */ + reason: string; + count: number; +} + +/** A row of the per-row envelope, narrowed to what the grouping needs. */ +export interface ImportRowResult { + line: number; + status: string; + reason?: string; +} + +const UNKNOWN_REASON = "unknown"; + +/** + * Collapse the per-row envelope into one entry per skip reason. + * + * A sensor export that trips one validation trips it on every row, so the + * raw list is the same sentence a few thousand times. Grouping turns "scroll + * fifty identical lines" into one fact with a count. Ordered by count + * descending, then by reason, so the same result always renders the same way. + */ +export function groupSkipReasons( + rows: readonly ImportRowResult[], +): SkipReasonGroup[] { + const counts = new Map(); + for (const row of rows) { + if (row.status !== "skipped") continue; + const reason = row.reason ?? UNKNOWN_REASON; + counts.set(reason, (counts.get(reason) ?? 0) + 1); + } + return [...counts.entries()] + .map(([reason, count]) => ({ reason, count })) + .sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason)); +} diff --git a/src/components/settings/import-panel/import-result-view.tsx b/src/components/settings/import-panel/import-result-view.tsx new file mode 100644 index 000000000..78ac1fe44 --- /dev/null +++ b/src/components/settings/import-panel/import-result-view.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { AlertCircle, CheckCircle2, Info, TriangleAlert } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { + classifyImportOutcome, + groupSkipReasons, + type ImportOutcome, + type ImportRowResult, +} from "./import-result-state"; + +/** + * The one mapping from an import outcome to how it looks. + * + * `CheckCircle2` and `text-success` appear exactly once in the import panel, + * on the `success` key, so no future edit can attach a tick to a result that + * wrote nothing. `import-result-guard.test.ts` holds that line. + */ +const OUTCOME_PRESENTATION: Record< + ImportOutcome, + { Icon: LucideIcon; className: string } +> = { + success: { Icon: CheckCircle2, className: "text-success" }, + partial: { Icon: TriangleAlert, className: "text-warning" }, + failed: { Icon: AlertCircle, className: "text-destructive" }, + empty: { Icon: Info, className: "text-muted-foreground" }, +}; + +/** + * The headline line of an import result. Sits inside each card's existing + * `aria-live="polite"` region, so the outcome is announced as it lands. + */ +export function ImportOutcomeLine({ + outcome, + message, + testId, +}: { + outcome: ImportOutcome; + message: string; + testId: string; +}) { + const { Icon, className } = OUTCOME_PRESENTATION[outcome]; + return ( +

+

+ ); +} + +export interface CsvImportResult { + inserted: number; + updated: number; + skipped: number; + total: number; + dryRun: boolean; + rows: ImportRowResult[]; +} + +/** + * Plain-language label per machine-readable skip reason. + * + * Written as literal `t(...)` calls rather than a key lookup so the i18n + * call-site coverage guard sees every one of them. + */ +function useSkipReasonLabel(): (reason: string) => string { + const { t } = useTranslations(); + const labels: Record = { + unknown_type: t("settings.sections.export.import.csv.reasonUnknownType"), + invalid_value: t("settings.sections.export.import.csv.reasonInvalidValue"), + value_out_of_range: t( + "settings.sections.export.import.csv.reasonValueOutOfRange", + ), + unknown_unit: t("settings.sections.export.import.csv.reasonUnknownUnit"), + missing_timezone_offset: t( + "settings.sections.export.import.csv.reasonMissingTimezoneOffset", + ), + invalid_timestamp: t( + "settings.sections.export.import.csv.reasonInvalidTimestamp", + ), + implausible_timestamp: t( + "settings.sections.export.import.csv.reasonImplausibleTimestamp", + ), + unexpected_glucose_context: t( + "settings.sections.export.import.csv.reasonUnexpectedGlucoseContext", + ), + invalid_glucose_context: t( + "settings.sections.export.import.csv.reasonInvalidGlucoseContext", + ), + notes_too_long: t("settings.sections.export.import.csv.reasonNotesTooLong"), + external_id_too_long: t( + "settings.sections.export.import.csv.reasonExternalIdTooLong", + ), + unstable_external_id: t( + "settings.sections.export.import.csv.reasonUnstableExternalId", + ), + duplicate: t("settings.sections.export.import.csv.reasonDuplicate"), + }; + const fallback = t("settings.sections.export.import.csv.reasonUnknown"); + return (reason: string) => labels[reason] ?? fallback; +} + +/** + * The CSV import result: an honest headline, then the skipped rows grouped by + * reason with a count, with the per-line detail kept one disclosure away. + */ +export function CsvImportResultView({ result }: { result: CsvImportResult }) { + const { t, tCount } = useTranslations(); + const labelFor = useSkipReasonLabel(); + + const written = result.inserted + result.updated; + const outcome = classifyImportOutcome({ written, skipped: result.skipped }); + const groups = groupSkipReasons(result.rows); + const skippedRows = result.rows.filter((row) => row.status === "skipped"); + + const message = (() => { + if (outcome === "empty") { + return t("settings.sections.export.import.csv.resultEmpty"); + } + if (result.dryRun) { + if (outcome === "failed") { + return t("settings.sections.export.import.csv.previewNothing", { + skipped: result.skipped, + }); + } + if (outcome === "partial") { + return t("settings.sections.export.import.csv.previewSummary", { + inserted: result.inserted, + skipped: result.skipped, + }); + } + return t("settings.sections.export.import.csv.previewSuccess", { + inserted: result.inserted, + }); + } + if (outcome === "failed") { + return t("settings.sections.export.import.csv.resultNothing", { + skipped: result.skipped, + }); + } + if (outcome === "partial") { + return t("settings.sections.export.import.csv.resultSummary", { + inserted: result.inserted, + updated: result.updated, + skipped: result.skipped, + }); + } + return t("settings.sections.export.import.csv.resultSuccess", { + inserted: result.inserted, + updated: result.updated, + }); + })(); + + return ( +
+ + {groups.length > 0 && ( +
    + {groups.map((group) => ( +
  • + {tCount( + "settings.sections.export.import.csv.skipGroup", + group.count, + { count: group.count, reason: labelFor(group.reason) }, + )} +
  • + ))} +
+ )} + {skippedRows.length > 0 && ( +
+ + {t("settings.sections.export.import.csv.skipDetails")} + +
    + {skippedRows.map((row) => ( +
  • + {t("settings.sections.export.import.csv.rowError", { + line: row.line, + reason: labelFor(row.reason ?? "unknown"), + })} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/settings/import-panel/json-import-card.tsx b/src/components/settings/import-panel/json-import-card.tsx index 0c48645f0..128b2a7c4 100644 --- a/src/components/settings/import-panel/json-import-card.tsx +++ b/src/components/settings/import-panel/json-import-card.tsx @@ -2,14 +2,7 @@ import { useId, useRef, useState } from "react"; import Link from "next/link"; -import { - AlertCircle, - CheckCircle2, - Download, - FileJson, - Loader2, - Upload, -} from "lucide-react"; +import { AlertCircle, Download, FileJson, Loader2, Upload } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; @@ -19,6 +12,8 @@ import { apiFetchRaw } from "@/lib/api/api-fetch"; import { ImportCardShell } from "./import-card-shell"; import { MAX_PASTE_CHARS } from "./constants"; import { EXAMPLE_IMPORT, parseImportJson } from "./import-examples"; +import { classifyImportOutcome } from "./import-result-state"; +import { ImportOutcomeLine } from "./import-result-view"; interface JsonImportResult { measurements: number; @@ -28,6 +23,38 @@ interface JsonImportResult { export function JsonImportCard() { const { t } = useTranslations(); + + // Same reasoning as the CSV card: the JSON route answers 200 with a per- + // concept count envelope, so a payload where every entry was refused used + // to render the success tick. The counts decide the tone. + const jsonOutcome = (r: JsonImportResult) => + classifyImportOutcome({ + written: r.measurements + r.moodEntries, + skipped: r.skipped, + }); + const jsonMessage = (r: JsonImportResult) => { + const outcome = jsonOutcome(r); + if (outcome === "empty") { + return t("settings.sections.export.import.json.resultEmpty"); + } + if (outcome === "failed") { + return t("settings.sections.export.import.json.resultNothing", { + skipped: r.skipped, + }); + } + if (outcome === "partial") { + return t("settings.sections.export.import.json.resultSummary", { + measurements: r.measurements, + moods: r.moodEntries, + skipped: r.skipped, + }); + } + return t("settings.sections.export.import.json.resultSuccess", { + measurements: r.measurements, + moods: r.moodEntries, + }); + }; + const fileInputRef = useRef(null); const [text, setText] = useState(""); const [busy, setBusy] = useState(false); @@ -164,22 +191,11 @@ export function JsonImportCard() {
{result && ( -

-

+ )} {error && (

{ }); describe("parseCsvMeasurements — glucose context", () => { - it("requires a context for BLOOD_GLUCOSE", () => { + // #640 — a continuous-sensor export carries a value and a timestamp per + // reading and classifies nothing. Refusing those rows rejected the normal + // case; the reading is accepted and the context stays absent. + it("accepts a BLOOD_GLUCOSE row with no context", () => { const out = parse(["BLOOD_GLUCOSE,95,mg/dL,2026-05-01T08:00:00Z,,,"]); - expect(out.rows[0]).toMatchObject({ - status: "skipped", - reason: "missing_glucose_context", - }); + expect(out.rows[0]).toMatchObject({ status: "ok" }); + expect(out.rows[0].row).toBeDefined(); + expect(out.rows[0].row).not.toHaveProperty("glucoseContext"); + }); + + it("accepts a contextless mmol/L reading at a +HHMM offset", () => { + const out = parse([ + "BLOOD_GLUCOSE,5.3,mmol/L,2024-04-03T13:15:00+1100,,Sensor,sensor-1", + "BLOOD_GLUCOSE,5.3,mmol/L,2024-04-17T08:06:00+1000,,Sensor,sensor-2", + ]); + expect(out.rows.map((r) => r.status)).toEqual(["ok", "ok"]); + for (const result of out.rows) { + expect(result.row).not.toHaveProperty("glucoseContext"); + expect(result.row?.unit).toBe("mg/dL"); + expect(result.row?.value).toBeCloseTo(95.4848, 3); + } + expect(out.rows[0].row?.measuredAt.toISOString()).toBe( + "2024-04-03T02:15:00.000Z", + ); + expect(out.rows[1].row?.measuredAt.toISOString()).toBe( + "2024-04-16T22:06:00.000Z", + ); + }); + + it("still accepts every enum context", () => { + const out = parse( + ["FASTING", "POSTPRANDIAL", "RANDOM", "BEDTIME"].map( + (ctx) => `BLOOD_GLUCOSE,95,mg/dL,2026-05-01T08:00:00Z,${ctx},,`, + ), + ); + expect(out.rows.map((r) => r.row?.glucoseContext)).toEqual([ + "FASTING", + "POSTPRANDIAL", + "RANDOM", + "BEDTIME", + ]); }); it("rejects a context on a non-glucose row", () => { diff --git a/src/lib/import/csv-measurements.ts b/src/lib/import/csv-measurements.ts index 923e2173d..b4f276a3d 100644 --- a/src/lib/import/csv-measurements.ts +++ b/src/lib/import/csv-measurements.ts @@ -27,7 +27,11 @@ * without an offset is skipped — the importer never guesses * a timezone. The instant is bounded by `validateEntryInstant` * (no future beyond a 5-min skew, no instant before 1900). - * - `glucoseContext` — required for BLOOD_GLUCOSE, forbidden otherwise. + * - `glucoseContext` — optional on BLOOD_GLUCOSE, forbidden otherwise. A + * blank cell stores NULL: a continuous sensor export + * classifies nothing per reading, and an absent context + * must read as absent rather than as a guessed one. A + * non-empty cell is still checked against the enum. * - `notes` — free text, ≤ 200 chars. * - `externalId` — optional source-stable id. When present, the write loop * upserts on `(userId, type, source=IMPORT, externalId)` so a @@ -64,13 +68,11 @@ export type CsvRowReason = | "missing_timezone_offset" | "invalid_timestamp" | "implausible_timestamp" - | "missing_glucose_context" | "unexpected_glucose_context" | "invalid_glucose_context" | "notes_too_long" | "external_id_too_long" - | "unstable_external_id" - | "wrong_column_count"; + | "unstable_external_id"; export type CsvRowStatus = "ok" | "skipped"; @@ -278,18 +280,16 @@ export function parseCsvMeasurements( continue; } - // Glucose context: required for BLOOD_GLUCOSE, forbidden otherwise. + // Glucose context: optional on BLOOD_GLUCOSE, forbidden otherwise. A + // blank cell leaves the column NULL — the row is a reading either way, + // and a sensor export carries no fasting / post-meal classification. let glucoseContext: string | undefined; if (type === "BLOOD_GLUCOSE") { - if (!rawContext) { - skip("missing_glucose_context"); - continue; - } - if (!KNOWN_GLUCOSE_CONTEXTS.has(rawContext)) { + if (rawContext && !KNOWN_GLUCOSE_CONTEXTS.has(rawContext)) { skip("invalid_glucose_context"); continue; } - glucoseContext = rawContext; + glucoseContext = rawContext || undefined; } else if (rawContext) { skip("unexpected_glucose_context"); continue; diff --git a/src/lib/openapi/routes/import.ts b/src/lib/openapi/routes/import.ts index e7fa609f0..040e6c103 100644 --- a/src/lib/openapi/routes/import.ts +++ b/src/lib/openapi/routes/import.ts @@ -53,7 +53,7 @@ export const importPaths: NonNullable = { tags: ["Import"], summary: "Import measurements from CSV", description: - "Bulk-import measurements from a CSV file (web cold-start escape hatch). Body is raw `text/csv` (≤ 16 MB, ≤ 10 000 valid rows). Header (order-independent): `type,value,unit,measuredAt[,glucoseContext,notes,externalId]`. `measuredAt` must carry an explicit ISO-8601 offset and is bounded (no future beyond a 5-min skew, no instant before 1900). Glucose accepts `mmol/L` (converted to canonical `mg/dL`); weight accepts `lb` (converted to `kg`). An `externalId` column makes re-upload idempotent (upsert on `(userId, type, source=IMPORT, externalId)`); without it a re-upload duplicates. `?dryRun=1` validates + previews without writing. Shares the 5/hour `import:` rate bucket.", + "Bulk-import measurements from a CSV file (web cold-start escape hatch). Body is raw `text/csv` (≤ 16 MB, ≤ 10 000 valid rows). Header (order-independent): `type,value,unit,measuredAt[,glucoseContext,notes,externalId]`. `measuredAt` must carry an explicit ISO-8601 offset and is bounded (no future beyond a 5-min skew, no instant before 1900). Glucose accepts `mmol/L` (converted to canonical `mg/dL`); weight accepts `lb` (converted to `kg`). `glucoseContext` is optional on a `BLOOD_GLUCOSE` row — a blank cell stores no context, which is the normal shape of a continuous-sensor export; a non-empty value is still checked against the enum, and the column is refused on any other type. An `externalId` column makes re-upload idempotent (upsert on `(userId, type, source=IMPORT, externalId)`); without it a re-upload duplicates. `?dryRun=1` validates + previews without writing. Shares the 5/hour `import:` rate bucket.", parameters: [ { name: "dryRun", @@ -71,7 +71,7 @@ export const importPaths: NonNullable = { schema: { type: "string", example: - "type,value,unit,measuredAt,glucoseContext,notes,externalId\nWEIGHT,80.5,kg,2026-05-01T08:00:00Z,,morning,\nBLOOD_GLUCOSE,5.3,mmol/L,2026-05-01T08:05:00+02:00,FASTING,,meter-001", + "type,value,unit,measuredAt,glucoseContext,notes,externalId\nWEIGHT,80.5,kg,2026-05-01T08:00:00Z,,morning,\nBLOOD_GLUCOSE,5.3,mmol/L,2026-05-01T08:05:00+02:00,FASTING,,meter-001\nBLOOD_GLUCOSE,5.9,mmol/L,2026-05-01T08:20:00+02:00,,,sensor-001", }, }, }, diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 2621db8c3..4c4660d36 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -256,7 +256,9 @@ export const measurementResource = z .string() .nullable() .optional() - .describe("Set only on BLOOD_GLUCOSE rows."), + .describe( + "Set only on BLOOD_GLUCOSE rows, and null there too when the writer recorded no classification (a sensor export, a device sync). Never invent one.", + ), sleepStage: z .string() .nullable() diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 090d510c4..a3eeaca0a 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -70,7 +70,7 @@ loginPasswordSchema.meta({ createMeasurementSchema.meta({ id: "CreateMeasurementRequest", description: - "Single-measurement ingest body. Plausibility-range guard runs server-side; out-of-range values fail 422.", + "Single-measurement ingest body. Plausibility-range guard runs server-side; out-of-range values fail 422. `glucoseContext` stays REQUIRED on a `BLOOD_GLUCOSE` row here: this is the hand-entry surface, where the person taking the reading knows whether it was fasting or after a meal. The bulk ingest paths (CSV import, JSON import, device sync) accept a contextless reading, because a sensor export classifies nothing per sample.", }); listMeasurementsSchema.meta({ diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 7c9e3fa22..32dbc045f 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -648,8 +648,10 @@ export const createMeasurementSchema = z // `APPLE_HEALTH`; `WITHINGS` / `IMPORT` / `COMPUTED` are server-owned and // forging them would pollute the per-source canonical picker. source: writableMeasurementSourceEnum.optional().default("MANUAL"), - // Only applies when type === BLOOD_GLUCOSE. Mirrored by a CHECK - // constraint in Postgres (see migration 0021). + // Only applies when type === BLOOD_GLUCOSE. The "not on any other type" + // half is mirrored by a CHECK constraint in Postgres (migrations 0021 + + // 0274). The "required on a glucose row" half below is a rule of THIS + // schema only — see the refine. glucoseContext: glucoseContextEnum.optional(), // v1.4.25 W10 reconcile (code-review M4): the single-entry POST // dropped `deviceType` silently because the column existed on @@ -669,6 +671,14 @@ export const createMeasurementSchema = z .refine((data) => validateMeasurementRange(data.type, data.value) === null, { message: "Value out of plausible range", }) + // Deliberately asymmetric with the bulk ingest paths. This schema backs the + // hand-entry surface, where a person is present at the reading and knows + // whether it was fasting, after a meal, random or at bedtime — the form + // always offers the choice, so requiring it costs nothing and keeps the + // per-context breakdowns meaningful. A sensor or meter export has no such + // answer per sample, so the CSV import, the JSON import and the device sync + // paths accept a contextless reading and store NULL rather than guessing. + // Absence stays absence; it is never widened into a classification. .refine( (data) => (data.type === "BLOOD_GLUCOSE" && data.glucoseContext !== undefined) || diff --git a/tests/integration/csv-import-contextless-glucose.test.ts b/tests/integration/csv-import-contextless-glucose.test.ts new file mode 100644 index 000000000..dad86ab24 --- /dev/null +++ b/tests/integration/csv-import-contextless-glucose.test.ts @@ -0,0 +1,216 @@ +/** + * #640 — a contextless blood-glucose reading reaches Postgres. + * + * The reported import wrote nothing. Two layers had to agree that a reading + * without a fasting / post-meal classification is a reading: the CSV parser, + * and the CHECK constraint from migration 0021 that demanded a context on + * every `BLOOD_GLUCOSE` row. Only a real database proves the second half, so + * this runs the route end to end and reads the row back. + * + * Also pinned here: the constraint's surviving half (a context on any other + * type is still refused), the re-upload idempotency the importer promises, + * and the writers that were failing silently against the old constraint + * (the CGM sync shape and the single-value tool shape) now landing. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER = "user-csv-glucose"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const HEADER = "type,value,unit,measuredAt,glucoseContext,notes,externalId"; + +async function signIn() { + await getPrismaClient().user.create({ + data: { + id: USER, + username: "csv-glucose", + email: "csv-glucose@example.test", + timezone: "Europe/Berlin", + }, + }); + const session = await getPrismaClient().session.create({ + data: { + userId: USER, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); +} + +function csvRequest(rows: string[]): NextRequest { + return new NextRequest("http://localhost/api/import/csv", { + method: "POST", + headers: { "content-type": "text/csv" }, + body: [HEADER, ...rows].join("\n"), + }); +} + +interface CsvEnvelope { + data: { + inserted: number; + updated: number; + skipped: number; + total: number; + rows: Array<{ line: number; status: string; reason?: string }>; + } | null; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); +}); + +describe("POST /api/import/csv — contextless glucose (real Postgres)", () => { + it("persists a sensor reading with no context, in the canonical shape", async () => { + await signIn(); + const { POST } = await import("@/app/api/import/csv/route"); + + const res = await POST( + csvRequest([ + "BLOOD_GLUCOSE,5.3,mmol/L,2024-04-03T13:15:00+1100,,Sensor,sensor-1", + ]), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as CsvEnvelope; + expect(body.data?.inserted).toBe(1); + expect(body.data?.skipped).toBe(0); + + const rows = await getPrismaClient().measurement.findMany({ + where: { userId: USER }, + }); + expect(rows).toHaveLength(1); + expect(rows[0].type).toBe("BLOOD_GLUCOSE"); + expect(rows[0].glucoseContext).toBeNull(); + expect(rows[0].source).toBe("IMPORT"); + expect(rows[0].unit).toBe("mg/dL"); + expect(rows[0].value).toBeCloseTo(95.4848, 3); + expect(rows[0].externalId).toBe("sensor-1"); + expect(rows[0].measuredAt.toISOString()).toBe("2024-04-03T02:15:00.000Z"); + }); + + it("stays idempotent when the same external id is re-uploaded", async () => { + await signIn(); + const { POST } = await import("@/app/api/import/csv/route"); + + const first = await POST( + csvRequest([ + "BLOOD_GLUCOSE,5.3,mmol/L,2024-04-03T13:15:00+1100,,Sensor,sensor-1", + ]), + ); + expect(((await first.json()) as CsvEnvelope).data?.inserted).toBe(1); + + const second = await POST( + csvRequest([ + "BLOOD_GLUCOSE,5.9,mmol/L,2024-04-03T13:15:00+1100,,Sensor,sensor-1", + ]), + ); + const secondBody = (await second.json()) as CsvEnvelope; + expect(secondBody.data?.inserted).toBe(0); + expect(secondBody.data?.updated).toBe(1); + + const rows = await getPrismaClient().measurement.findMany({ + where: { userId: USER }, + }); + expect(rows).toHaveLength(1); + expect(rows[0].glucoseContext).toBeNull(); + expect(rows[0].value).toBeCloseTo(106.2944, 3); + }); + + it("keeps a named context, and still refuses one that is not in the enum", async () => { + await signIn(); + const { POST } = await import("@/app/api/import/csv/route"); + + const res = await POST( + csvRequest([ + "BLOOD_GLUCOSE,95,mg/dL,2026-05-01T08:00:00Z,FASTING,,meter-1", + "BLOOD_GLUCOSE,95,mg/dL,2026-05-01T09:00:00Z,LUNCH,,meter-2", + ]), + ); + const body = (await res.json()) as CsvEnvelope; + expect(body.data?.inserted).toBe(1); + expect(body.data?.rows[1]).toMatchObject({ + status: "skipped", + reason: "invalid_glucose_context", + }); + + const rows = await getPrismaClient().measurement.findMany({ + where: { userId: USER }, + }); + expect(rows).toHaveLength(1); + expect(rows[0].glucoseContext).toBe("FASTING"); + }); +}); + +describe("measurements.glucose_context — surviving CHECK half", () => { + it("still refuses a context on a row that is not blood glucose", async () => { + await signIn(); + await expect( + getPrismaClient().measurement.create({ + data: { + userId: USER, + type: "WEIGHT", + value: 80, + unit: "kg", + source: "IMPORT", + measuredAt: new Date("2026-05-01T08:00:00.000Z"), + glucoseContext: "FASTING", + }, + }), + ).rejects.toThrow(); + }); + + it("accepts the contextless shape the device writers build", async () => { + await signIn(); + // The Nightscout SGV upsert, the HealthKit batch mapping, and the MCP / + // Telegram single-value captures all build a BLOOD_GLUCOSE row with no + // context. Under the old constraint every one of those inserts was + // refused by the database. + const sources = ["NIGHTSCOUT", "APPLE_HEALTH", "MCP", "TELEGRAM"] as const; + for (const [index, source] of sources.entries()) { + await getPrismaClient().measurement.create({ + data: { + userId: USER, + type: "BLOOD_GLUCOSE", + value: 95, + unit: "mg/dL", + source, + measuredAt: new Date(`2026-05-01T0${index}:00:00.000Z`), + externalId: `${source}-1`, + }, + }); + } + const rows = await getPrismaClient().measurement.findMany({ + where: { userId: USER, type: "BLOOD_GLUCOSE" }, + }); + expect(rows).toHaveLength(sources.length); + expect(rows.every((row) => row.glucoseContext === null)).toBe(true); + }); +});