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:
Marc-André Bombeck authored and GitHub committed 2026-07-25 23:16:22 +02:00
commit b32d1be607
88 files changed
+1730 -201

No files matched your search

+41
View File
@@ -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
View File
@@ -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"
+72 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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";
+2 -1
View File
@@ -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 {
+2 -1
View File
@@ -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 {
+2 -1
View File
@@ -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 {
+2 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+2 -1
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+90
View File
@@ -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 };
+2 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
/**
* v1.4.27 MB6 — public surfaces.
+1 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
);
+7 -1
View File
@@ -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
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.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", () => {
+11 -11
View File
@@ -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;
+2 -2
View File
@@ -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",
},
},
},
+3 -1
View File
@@ -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()
+1 -1
View File
@@ -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({
+12 -2
View File
@@ -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);
});
});