mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Merge pull request #642 from MBombeck/fix/csv-import-silent-skip
v1.32.38 — accept a blood-glucose reading that carries no context
This commit is contained in:
88 files changed
+1730
-201
No files matched your search
@@ -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.
|
||||
|
||||
+11
-6
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
+4
-2
@@ -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).
|
||||
+2
-7
@@ -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";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import {
|
||||
STUB_MEDICATION_ID,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import {
|
||||
STUB_MEDICATION_ID,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import {
|
||||
STUB_MEDICATION_ID,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import {
|
||||
STUB_MEDICATION_ID,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import {
|
||||
STUB_MEDICATION_ID,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import {
|
||||
STUB_MEDICATION_ID,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
/**
|
||||
* v1.4.27 MB6 — public surfaces.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
|
||||
|
||||
+29
-2
@@ -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."
|
||||
|
||||
+29
-2
@@ -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."
|
||||
|
||||
+29
-2
@@ -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."
|
||||
|
||||
+29
-2
@@ -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."
|
||||
|
||||
+29
-2
@@ -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."
|
||||
|
||||
+29
-2
@@ -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."
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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/*`
|
||||
|
||||
@@ -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<string, string> = {
|
||||
// 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 "<module>"` 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/<name>.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 \}/);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function result(over: Partial<CsvImportResult> = {}): 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("<CsvImportResultView> — result states", () => {
|
||||
it("renders an all-skipped import as a failure, not a success", () => {
|
||||
const html = render(<CsvImportResultView result={ALL_SKIPPED} />);
|
||||
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(
|
||||
<CsvImportResultView
|
||||
result={result({
|
||||
inserted: 3,
|
||||
skipped: 2,
|
||||
total: 5,
|
||||
rows: [
|
||||
{ line: 2, status: "inserted" },
|
||||
{ line: 3, status: "inserted" },
|
||||
{ line: 4, status: "inserted" },
|
||||
{ line: 5, status: "skipped", reason: "unknown_unit" },
|
||||
{ line: 6, status: "skipped", reason: "duplicate" },
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<CsvImportResultView
|
||||
result={result({
|
||||
inserted: 4,
|
||||
updated: 1,
|
||||
total: 5,
|
||||
rows: [
|
||||
{ line: 2, status: "inserted" },
|
||||
{ line: 3, status: "inserted" },
|
||||
{ line: 4, status: "inserted" },
|
||||
{ line: 5, status: "inserted" },
|
||||
{ line: 6, status: "updated" },
|
||||
],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
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(<CsvImportResultView result={result()} />);
|
||||
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(
|
||||
<CsvImportResultView result={{ ...ALL_SKIPPED, dryRun: true }} />,
|
||||
);
|
||||
expect(html).toContain('data-outcome="failed"');
|
||||
expect(html).toContain("nothing would import");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<CsvImportResultView> — skip reasons", () => {
|
||||
it("groups a repeated reason into one counted line, in plain language", () => {
|
||||
const html = render(<CsvImportResultView result={ALL_SKIPPED} />);
|
||||
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("</li>").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(
|
||||
<CsvImportResultView
|
||||
result={result({
|
||||
skipped: 1,
|
||||
total: 1,
|
||||
rows: [{ line: 2, status: "skipped", reason: "unknown_unit" }],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("<details");
|
||||
expect(html).toContain("Show every skipped row");
|
||||
expect(html).toContain("Line 2: unit not recognised for this type");
|
||||
});
|
||||
|
||||
it("labels a reason it does not know rather than leaking the raw token", () => {
|
||||
const html = render(
|
||||
<CsvImportResultView
|
||||
result={result({
|
||||
skipped: 1,
|
||||
total: 1,
|
||||
rows: [{ line: 2, status: "skipped", reason: "brand_new_reason" }],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain("brand_new_reason");
|
||||
expect(html).toContain("1 row skipped: skipped");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<CsvImportCard> — announcement", () => {
|
||||
it("keeps the result slot inside the polite live region", () => {
|
||||
const html = render(<CsvImportCard />);
|
||||
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"');
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
</div>
|
||||
)}
|
||||
{isDone && (
|
||||
<div
|
||||
data-testid="import-apple-health-result"
|
||||
className="text-foreground flex items-start gap-2 text-xs"
|
||||
>
|
||||
<CheckCircle2
|
||||
className="text-success mt-0.5 h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{t("settings.sections.export.import.appleHealth.doneSummary", {
|
||||
imported: status?.result?.totals?.rowsUpserted ?? 0,
|
||||
read: status?.result?.totals?.recordsRead ?? 0,
|
||||
skipped: status?.result?.clinical?.skipped ?? 0,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<ImportOutcomeLine
|
||||
outcome={classifyImportOutcome({
|
||||
written: status?.result?.totals?.rowsUpserted ?? 0,
|
||||
// The clinical count is a documented, deliberate exclusion
|
||||
// (electrocardiograms, lab documents), not a refused row — it
|
||||
// must not tip the archive into a warning. Only "nothing
|
||||
// landed" changes the tone here.
|
||||
skipped: 0,
|
||||
})}
|
||||
message={
|
||||
(status?.result?.totals?.rowsUpserted ?? 0) > 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 && (
|
||||
<AppleHealthEstimateWarning
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useId, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
FileSpreadsheet,
|
||||
Loader2,
|
||||
@@ -19,21 +18,10 @@ import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { ImportCardShell } from "./import-card-shell";
|
||||
import { MAX_PASTE_CHARS } from "./constants";
|
||||
import { EXAMPLE_CSV } from "./import-examples";
|
||||
|
||||
interface CsvRowResult {
|
||||
line: number;
|
||||
status: "inserted" | "updated" | "skipped";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface CsvImportResult {
|
||||
inserted: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
dryRun: boolean;
|
||||
rows: CsvRowResult[];
|
||||
}
|
||||
import {
|
||||
CsvImportResultView,
|
||||
type CsvImportResult,
|
||||
} from "./import-result-view";
|
||||
|
||||
export function CsvImportCard() {
|
||||
const { t } = useTranslations();
|
||||
@@ -117,8 +105,6 @@ export function CsvImportCard() {
|
||||
}
|
||||
}
|
||||
|
||||
const errorRows = result?.rows.filter((r) => r.status === "skipped") ?? [];
|
||||
|
||||
return (
|
||||
<ImportCardShell
|
||||
testId="import-card-csv"
|
||||
@@ -169,40 +155,7 @@ export function CsvImportCard() {
|
||||
</p>
|
||||
|
||||
<div aria-live="polite" className="space-y-2">
|
||||
{result && (
|
||||
<div data-testid="import-csv-result" className="space-y-1.5">
|
||||
<p className="text-foreground flex items-start gap-2 text-xs">
|
||||
<CheckCircle2
|
||||
className="text-success mt-0.5 h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{result.dryRun
|
||||
? t("settings.sections.export.import.csv.previewSummary", {
|
||||
inserted: result.inserted,
|
||||
skipped: result.skipped,
|
||||
})
|
||||
: t("settings.sections.export.import.csv.resultSummary", {
|
||||
inserted: result.inserted,
|
||||
updated: result.updated,
|
||||
skipped: result.skipped,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
{errorRows.length > 0 && (
|
||||
<ul className="text-muted-foreground max-h-32 space-y-0.5 overflow-auto text-xs">
|
||||
{errorRows.slice(0, 50).map((r) => (
|
||||
<li key={r.line}>
|
||||
{t("settings.sections.export.import.csv.rowError", {
|
||||
line: r.line,
|
||||
reason: r.reason ?? "skipped",
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{result && <CsvImportResultView result={result} />}
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
|
||||
@@ -64,5 +64,9 @@ export const EXAMPLE_CSV = [
|
||||
CSV_EXAMPLE_COLUMNS.join(","),
|
||||
"WEIGHT,80.5,kg,2026-05-01T08:00:00Z,,morning,",
|
||||
"BLOOD_GLUCOSE,5.3,mmol/L,2026-05-01T08:05:00+02:00,FASTING,,meter-001",
|
||||
// A sensor export has no fasting / post-meal answer per reading, so the
|
||||
// context cell is blank. The example carries that shape on purpose: it is
|
||||
// the one people copy, and it is the case that used to be refused.
|
||||
"BLOOD_GLUCOSE,5.9,mmol/L,2026-05-01T08:20:00+02:00,,,sensor-001",
|
||||
"BLOOD_PRESSURE_SYS,120,mmHg,2026-05-01T08:05:00+02:00,,,",
|
||||
].join("\n");
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Import result classification — the one place that decides whether an
|
||||
* import reads as a success.
|
||||
*
|
||||
* A per-row importer answers HTTP 200 for a file where every single row was
|
||||
* refused; the transport succeeded, the import did not. Rendering that as a
|
||||
* green tick is how a self-hoster's 1 597-reading sensor export could write
|
||||
* nothing and still look finished. So the counts, not the status code, pick
|
||||
* the state:
|
||||
*
|
||||
* - nothing accepted, nothing skipped → `empty` (the file carried no data)
|
||||
* - nothing accepted, rows skipped → `failed` (never a success tone)
|
||||
* - some accepted, some skipped → `partial` (a warning, not a tick)
|
||||
* - all accepted → `success`
|
||||
*
|
||||
* Both import cards render from this, and `import-result-state.test.ts` pins
|
||||
* the invariant that `success` is unreachable while `written === 0`.
|
||||
*/
|
||||
|
||||
export type ImportOutcome = "empty" | "failed" | "partial" | "success";
|
||||
|
||||
export interface ImportCounts {
|
||||
/** Rows that reached the database — inserted + updated. */
|
||||
written: number;
|
||||
/** Rows the importer refused, for any reason. */
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
export function classifyImportOutcome({
|
||||
written,
|
||||
skipped,
|
||||
}: ImportCounts): ImportOutcome {
|
||||
if (written <= 0) return skipped > 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<string, number>();
|
||||
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));
|
||||
}
|
||||
@@ -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 (
|
||||
<p
|
||||
data-testid={testId}
|
||||
data-outcome={outcome}
|
||||
className="text-foreground flex items-start gap-2 text-xs"
|
||||
>
|
||||
<Icon
|
||||
className={`${className} mt-0.5 h-3.5 w-3.5 shrink-0`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{message}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<div data-testid="import-csv-result" className="space-y-1.5">
|
||||
<ImportOutcomeLine
|
||||
outcome={outcome}
|
||||
message={message}
|
||||
testId="import-csv-outcome"
|
||||
/>
|
||||
{groups.length > 0 && (
|
||||
<ul
|
||||
data-testid="import-csv-skip-groups"
|
||||
className="text-muted-foreground space-y-0.5 text-xs"
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<li key={group.reason}>
|
||||
{tCount(
|
||||
"settings.sections.export.import.csv.skipGroup",
|
||||
group.count,
|
||||
{ count: group.count, reason: labelFor(group.reason) },
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{skippedRows.length > 0 && (
|
||||
<details className="text-muted-foreground text-xs">
|
||||
<summary className="cursor-pointer">
|
||||
{t("settings.sections.export.import.csv.skipDetails")}
|
||||
</summary>
|
||||
<ul className="mt-1 max-h-32 space-y-0.5 overflow-auto">
|
||||
{skippedRows.map((row) => (
|
||||
<li key={row.line}>
|
||||
{t("settings.sections.export.import.csv.rowError", {
|
||||
line: row.line,
|
||||
reason: labelFor(row.reason ?? "unknown"),
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
const [text, setText] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -164,22 +191,11 @@ export function JsonImportCard() {
|
||||
|
||||
<div aria-live="polite" className="space-y-2">
|
||||
{result && (
|
||||
<p
|
||||
data-testid="import-json-result"
|
||||
className="text-foreground flex items-start gap-2 text-xs"
|
||||
>
|
||||
<CheckCircle2
|
||||
className="text-success mt-0.5 h-3.5 w-3.5 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{t("settings.sections.export.import.json.resultSummary", {
|
||||
measurements: result.measurements,
|
||||
moods: result.moodEntries,
|
||||
skipped: result.skipped,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<ImportOutcomeLine
|
||||
outcome={jsonOutcome(result)}
|
||||
message={jsonMessage(result)}
|
||||
testId="import-json-result"
|
||||
/>
|
||||
)}
|
||||
{error && (
|
||||
<p
|
||||
|
||||
@@ -157,12 +157,47 @@ describe("parseCsvMeasurements — timezone + entry-instant bound", () => {
|
||||
});
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -53,7 +53,7 @@ export const importPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
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<ZodOpenApiObject["paths"]> = {
|
||||
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user