Account for every dropped Apple Health sample the result used to hide

Three silent gaps in the import accounting, one commit:

The reproductive samples of an export land in recordsRead but were
discarded without a counter when cycle tracking is off — the result now
names them (samplesSkippedModuleDisabled). A cycle flush that failed
partway zeroed its stats AFTER day-by-day writes had landed; the flush
is now fault-isolated per day and reports daysFailed plus the first
failure reason instead of claiming nothing happened. And the import
card hardcoded skipped: 0 while the worker has computed, persisted and
served per-class skip counters since v1.15 — it now renders them,
splitting deliberate exclusions (unsupported and deferred types, a
disabled cycle module) from genuine refusals (unparseable, out of
range, failed writes), with a per-type breakdown. Only refusals tip
the outcome tone.
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 02:06:19 +02:00
1 parent a45dde2039
commit db6465c17d
12 files changed
+530 -31

No files matched your search

+8 -1
View File
@@ -5001,7 +5001,14 @@
"unpacking": "Archiv wird entpackt…",
"parsing": "Export wird gelesen…",
"upserting": "Datensätze werden importiert…"
}
},
"skipUnsupported": "{count} Samples übersprungen — Datentypen, die HealthLog nicht importiert.",
"skipRefused": "{count} Samples konnten nicht geschrieben werden (unlesbar, außerhalb des Wertebereichs oder Speichern fehlgeschlagen).",
"skipBreakdown": "Aufschlüsselung anzeigen ({count} Typen)",
"skipBreakdownMore": "+ {count} weitere Typen",
"cycleSummary": "{samples} Zyklus-Samples zu {days} Tagesprotokollen zusammengeführt.",
"cycleModuleOff": "{count} Zyklus-Samples übersprungen — Zyklus-Tracking ist für dieses Konto deaktiviert.",
"cycleDaysFailed": "{count} Zyklustage konnten nicht geschrieben werden: {reason}"
},
"json": {
"title": "JSON-Datei",
+8 -1
View File
@@ -5001,7 +5001,14 @@
"unpacking": "Unpacking the archive…",
"parsing": "Reading the export…",
"upserting": "Importing records…"
}
},
"skipUnsupported": "{count} samples skipped — data types HealthLog doesn't import.",
"skipRefused": "{count} samples couldn't be written (unreadable, out of range, or the save failed).",
"skipBreakdown": "Show breakdown ({count} types)",
"skipBreakdownMore": "+ {count} more types",
"cycleSummary": "{samples} cycle samples merged into {days} day logs.",
"cycleModuleOff": "{count} cycle samples skipped — cycle tracking is off for this account.",
"cycleDaysFailed": "{count} cycle days couldn't be written: {reason}"
},
"json": {
"title": "JSON file",
+8 -1
View File
@@ -5001,7 +5001,14 @@
"unpacking": "Descomprimiendo el archivo…",
"parsing": "Leyendo la exportación…",
"upserting": "Importando registros…"
}
},
"skipUnsupported": "{count} muestras omitidas: tipos de datos que HealthLog no importa.",
"skipRefused": "{count} muestras no se pudieron escribir (ilegibles, fuera de rango o falló el guardado).",
"skipBreakdown": "Mostrar desglose ({count} tipos)",
"skipBreakdownMore": "+ {count} tipos más",
"cycleSummary": "{samples} muestras de ciclo combinadas en {days} registros diarios.",
"cycleModuleOff": "{count} muestras de ciclo omitidas: el seguimiento del ciclo está desactivado en esta cuenta.",
"cycleDaysFailed": "{count} días de ciclo no se pudieron escribir: {reason}"
},
"json": {
"title": "Archivo JSON",
+8 -1
View File
@@ -5001,7 +5001,14 @@
"unpacking": "Décompression de l'archive…",
"parsing": "Lecture de l'export…",
"upserting": "Import des enregistrements…"
}
},
"skipUnsupported": "{count} échantillons ignorés — des types de données que HealthLog n'importe pas.",
"skipRefused": "{count} échantillons n'ont pas pu être écrits (illisibles, hors plage ou échec de l'enregistrement).",
"skipBreakdown": "Afficher le détail ({count} types)",
"skipBreakdownMore": "+ {count} autres types",
"cycleSummary": "{samples} échantillons de cycle fusionnés en {days} journaux quotidiens.",
"cycleModuleOff": "{count} échantillons de cycle ignorés — le suivi du cycle est désactivé pour ce compte.",
"cycleDaysFailed": "{count} jours de cycle n'ont pas pu être écrits : {reason}"
},
"json": {
"title": "Fichier JSON",
+8 -1
View File
@@ -5001,7 +5001,14 @@
"unpacking": "Estrazione dell'archivio…",
"parsing": "Lettura dell'esportazione…",
"upserting": "Importazione dei record…"
}
},
"skipUnsupported": "{count} campioni saltati — tipi di dati che HealthLog non importa.",
"skipRefused": "{count} campioni non sono stati scritti (illeggibili, fuori intervallo o salvataggio non riuscito).",
"skipBreakdown": "Mostra dettaglio ({count} tipi)",
"skipBreakdownMore": "+ {count} altri tipi",
"cycleSummary": "{samples} campioni del ciclo uniti in {days} registri giornalieri.",
"cycleModuleOff": "{count} campioni del ciclo saltati — il monitoraggio del ciclo è disattivato per questo account.",
"cycleDaysFailed": "{count} giorni del ciclo non sono stati scritti: {reason}"
},
"json": {
"title": "File JSON",
+8 -1
View File
@@ -5001,7 +5001,14 @@
"unpacking": "Rozpakowywanie archiwum…",
"parsing": "Odczytywanie eksportu…",
"upserting": "Importowanie rekordów…"
}
},
"skipUnsupported": "{count} próbek pominięto — typy danych, których HealthLog nie importuje.",
"skipRefused": "{count} próbek nie udało się zapisać (nieczytelne, poza zakresem lub zapis się nie powiódł).",
"skipBreakdown": "Pokaż zestawienie ({count} typów)",
"skipBreakdownMore": "+ {count} kolejnych typów",
"cycleSummary": "{samples} próbek cyklu połączono w {days} dzienników dziennych.",
"cycleModuleOff": "{count} próbek cyklu pominięto — śledzenie cyklu jest wyłączone dla tego konta.",
"cycleDaysFailed": "{count} dni cyklu nie udało się zapisać: {reason}"
},
"json": {
"title": "Plik JSON",
@@ -29,7 +29,9 @@ vi.mock("next/navigation", () => ({
import type { Locale } from "@/lib/i18n/config";
import {
AppleHealthEstimateWarning,
AppleHealthSkipLines,
appleHealthFailureKind,
summarizeAppleHealthSkips,
} from "../import-panel/apple-health-import-card";
import { I18nProvider } from "@/lib/i18n/context";
import {
@@ -124,6 +126,94 @@ describe("<AppleHealthEstimateWarning>", () => {
// preflight's `insufficient_memory` prefix). The card must translate
// them instead of echoing a raw code at the user; honest free-text
// reasons keep passing through verbatim.
/**
* A6 — the worker has computed, persisted and served per-class skip
* counters since v1.15; the card hardcoded `skipped: 0` and rendered
* none of them. These pin the fold and the render.
*
* Watched red: with `summarizeAppleHealthSkips` gutted to return zeros
* (the pre-fix card behaviour) the fold tests fail; with the
* `<AppleHealthSkipLines>` mount removed the render test fails on the
* missing test ids.
*/
describe("summarizeAppleHealthSkips", () => {
it("splits deliberate exclusions from genuine refusals", () => {
const summary = summarizeAppleHealthSkips({
unknown: {
HKQuantityTypeIdentifierFutureTypeXYZ: 40, // unsupported type
"HKQuantityTypeIdentifierBodyMass::unparseable": 3, // refusal
"WEIGHT::upsert_failed": 2, // refusal
"element::UnknownElement": 99, // structural, not a sample
},
deferred: { HKQuantityTypeIdentifierDietaryWater: 10 },
});
expect(summary.unsupported).toBe(50);
expect(summary.refused).toBe(5);
// Largest first; the structural element never appears.
expect(summary.breakdown.map((entry) => entry.key)).toEqual([
"HKQuantityTypeIdentifierFutureTypeXYZ",
"HKQuantityTypeIdentifierDietaryWater",
"HKQuantityTypeIdentifierBodyMass::unparseable",
"WEIGHT::upsert_failed",
]);
});
it("returns zeros for an empty / missing result", () => {
expect(summarizeAppleHealthSkips(null)).toEqual({
unsupported: 0,
refused: 0,
breakdown: [],
});
});
});
describe("<AppleHealthSkipLines>", () => {
it("renders every populated skip class", () => {
const html = render(
<AppleHealthSkipLines
skips={summarizeAppleHealthSkips({
unknown: { "HKQuantityTypeIdentifierBodyMass::out_of_range": 2 },
deferred: { HKQuantityTypeIdentifierDietaryWater: 7 },
})}
cycle={{
samplesConsumed: 12,
samplesSkippedModuleDisabled: 0,
daysUpserted: 4,
daysFailed: 1,
firstFailureReason: "colliding day",
}}
/>,
);
expect(html).toContain('data-testid="apple-health-skip-unsupported"');
expect(html).toContain('data-testid="apple-health-skip-refused"');
expect(html).toContain('data-testid="apple-health-cycle-summary"');
expect(html).toContain('data-testid="apple-health-cycle-days-failed"');
expect(html).toContain("colliding day");
expect(html).toContain('data-testid="apple-health-skip-breakdown"');
});
it("names the module-disabled cycle drop", () => {
const html = render(
<AppleHealthSkipLines
skips={summarizeAppleHealthSkips(null)}
cycle={{ samplesSkippedModuleDisabled: 21 }}
/>,
);
expect(html).toContain('data-testid="apple-health-cycle-module-off"');
expect(html).toContain("21");
});
it("renders nothing when there is nothing to explain", () => {
const html = render(
<AppleHealthSkipLines
skips={summarizeAppleHealthSkips(null)}
cycle={undefined}
/>,
);
expect(html).toBe("");
});
});
describe("appleHealthFailureKind", () => {
it("maps the reconcile code onto translated copy", () => {
expect(appleHealthFailureKind("interrupted_by_restart")).toBe(
@@ -31,6 +31,18 @@ interface JobStatus {
totals?: { recordsRead?: number; rowsUpserted?: number };
clinical?: { skipped?: number };
cumulativeEstimates?: { days?: number; rows?: number };
/** HK types deferred by design — read but not (yet) imported. */
deferred?: Record<string, number>;
/** Per-key drop counters — plain HK types are unsupported-by-design,
* `key::reason`-tagged entries are genuine refusals. */
unknown?: Record<string, number>;
cycle?: {
samplesConsumed?: number;
samplesSkippedModuleDisabled?: number;
daysUpserted?: number;
daysFailed?: number;
firstFailureReason?: string | null;
};
ecg?: {
discovered?: number;
imported?: number;
@@ -42,6 +54,56 @@ interface JobStatus {
failureReason: string | null;
}
/** One skip line for the result breakdown. */
export interface AppleHealthSkipEntry {
key: string;
count: number;
}
export interface AppleHealthSkipSummary {
/** Samples of data types HealthLog does not import (deliberate). */
unsupported: number;
/** Samples that SHOULD have landed but were refused (unreadable value,
* out-of-range, upsert failure, unmapped cycle value, …). */
refused: number;
/** Per-key counts behind both totals, largest first. */
breakdown: AppleHealthSkipEntry[];
}
/**
* Fold the worker's drop counters into the three numbers the card renders.
*
* The split is deliberate: an Apple export always carries types HealthLog
* does not import, so "unsupported" is information, not a warning — while a
* tagged refusal (`::unparseable`, `::out_of_range`, `::upsert_failed`, …)
* means a sample that SHOULD have landed did not, and that is what tips the
* outcome tone. `element::` counters are structural XML elements, not
* samples, and are excluded entirely. Exported for the unit contract.
*/
export function summarizeAppleHealthSkips(
result: JobStatus["result"],
): AppleHealthSkipSummary {
const unknown = result?.unknown ?? {};
const deferred = result?.deferred ?? {};
let unsupported = 0;
let refused = 0;
const breakdown: AppleHealthSkipEntry[] = [];
for (const [key, count] of Object.entries(unknown)) {
if (typeof count !== "number" || count <= 0) continue;
if (key.startsWith("element::")) continue;
if (key.includes("::")) refused += count;
else unsupported += count;
breakdown.push({ key, count });
}
for (const [key, count] of Object.entries(deferred)) {
if (typeof count !== "number" || count <= 0) continue;
unsupported += count;
breakdown.push({ key, count });
}
breakdown.sort((a, b) => b.count - a.count);
return { unsupported, refused, breakdown };
}
/**
* Classify a worker `failureReason` for display. The worker writes two
* machine-readable reasons — the reconcile's bare `interrupted_by_restart`
@@ -80,6 +142,111 @@ export function AppleHealthEstimateWarning({ days }: { days: number }) {
);
}
/** How many per-type lines the breakdown shows before folding the rest. */
const SKIP_BREAKDOWN_MAX_LINES = 15;
/**
* The grouped skip lines under the outcome sentence — modeled on the
* medication intake import's result: every drop class the worker counts
* is rendered, none is silently swallowed into an unexplained
* read-vs-imported gap.
*/
export function AppleHealthSkipLines({
skips,
cycle,
}: {
skips: AppleHealthSkipSummary;
cycle: NonNullable<JobStatus["result"]>["cycle"] | undefined;
}) {
const { t } = useTranslations();
const cycleDays = cycle?.daysUpserted ?? 0;
const cycleSamples = cycle?.samplesConsumed ?? 0;
const cycleModuleOff = cycle?.samplesSkippedModuleDisabled ?? 0;
const cycleFailed = cycle?.daysFailed ?? 0;
const hasAnything =
skips.unsupported > 0 ||
skips.refused > 0 ||
cycleDays > 0 ||
cycleModuleOff > 0 ||
cycleFailed > 0;
if (!hasAnything) return null;
const shown = skips.breakdown.slice(0, SKIP_BREAKDOWN_MAX_LINES);
const omitted = skips.breakdown.length - shown.length;
return (
<div
data-testid="apple-health-skip-lines"
className="text-muted-foreground space-y-0.5 text-xs"
>
{cycleDays > 0 && (
<p data-testid="apple-health-cycle-summary">
{t("settings.sections.export.import.appleHealth.cycleSummary", {
samples: cycleSamples,
days: cycleDays,
})}
</p>
)}
{cycleModuleOff > 0 && (
<p data-testid="apple-health-cycle-module-off">
{t("settings.sections.export.import.appleHealth.cycleModuleOff", {
count: cycleModuleOff,
})}
</p>
)}
{cycleFailed > 0 && (
<p
data-testid="apple-health-cycle-days-failed"
className="text-destructive"
>
{t("settings.sections.export.import.appleHealth.cycleDaysFailed", {
count: cycleFailed,
reason: cycle?.firstFailureReason ?? "—",
})}
</p>
)}
{skips.unsupported > 0 && (
<p data-testid="apple-health-skip-unsupported">
{t("settings.sections.export.import.appleHealth.skipUnsupported", {
count: skips.unsupported,
})}
</p>
)}
{skips.refused > 0 && (
<p data-testid="apple-health-skip-refused">
{t("settings.sections.export.import.appleHealth.skipRefused", {
count: skips.refused,
})}
</p>
)}
{skips.breakdown.length > 0 && (
<details data-testid="apple-health-skip-breakdown">
<summary className="cursor-pointer">
{t("settings.sections.export.import.appleHealth.skipBreakdown", {
count: skips.breakdown.length,
})}
</summary>
<ul className="mt-1 max-h-48 space-y-0.5 overflow-y-auto pl-4">
{shown.map((entry) => (
<li key={entry.key}>
{entry.key}: {entry.count}
</li>
))}
</ul>
{omitted > 0 && (
<p className="mt-1">
{t(
"settings.sections.export.import.appleHealth.skipBreakdownMore",
{ count: omitted },
)}
</p>
)}
</details>
)}
</div>
);
}
export function AppleHealthImportCard() {
const { t } = useTranslations();
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -197,6 +364,8 @@ export function AppleHealthImportCard() {
})();
const ecg = status?.result?.ecg;
const ecgWritten = (ecg?.imported ?? 0) + (ecg?.updated ?? 0);
const skips = summarizeAppleHealthSkips(status?.result ?? null);
const cycleStats = status?.result?.cycle;
return (
<ImportCardShell
@@ -287,11 +456,12 @@ export function AppleHealthImportCard() {
<WrittenOutcomeLine
outcome={classifyWrittenOutcome({
written: (status?.result?.totals?.rowsUpserted ?? 0) + ecgWritten,
// 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,
// Deliberate exclusions (clinical records, unsupported /
// deferred types, a disabled cycle module) are information
// and must not tip the archive into a warning. Genuine
// refusals — samples that SHOULD have landed (unparseable,
// out of range, upsert failure) plus failed cycle days — do.
skipped: skips.refused + (cycleStats?.daysFailed ?? 0),
})}
message={
(status?.result?.totals?.rowsUpserted ?? 0) > 0
@@ -307,6 +477,7 @@ export function AppleHealthImportCard() {
testId="import-apple-health-result"
/>
)}
{isDone && <AppleHealthSkipLines skips={skips} cycle={cycleStats} />}
{isDone && (ecg?.discovered ?? 0) > 0 && (
<p
data-testid="import-apple-health-ecg-result"
@@ -0,0 +1,85 @@
/**
* Cycle-import accumulator — flush fault isolation (A6).
*
* The pre-fix flush let one colliding day throw out of the loop; the
* importer's catch then zeroed the stats, so a partial write reported
* `samplesConsumed: 0` over rows already in the table. The flush is now
* fault-isolated per day and its stats stay honest: what landed counts,
* what failed is named.
*
* Watched red: with the per-day try/catch removed from `flush()` the
* partial-failure test throws instead of reporting
* `{ daysUpserted: 1, daysFailed: 1 }`.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
upsert: vi.fn(),
findManyCycles: vi.fn(async () => []),
findUniqueProfile: vi.fn(async () => null),
updateManyProfile: vi.fn(async () => ({ count: 0 })),
}));
vi.mock("@/lib/db", () => ({
prisma: {
menstrualCycle: { findMany: mocks.findManyCycles },
cycleProfile: {
findUnique: mocks.findUniqueProfile,
updateMany: mocks.updateManyProfile,
},
},
}));
vi.mock("@/lib/cycle/day-log-write", () => ({
upsertCycleDayLog: (...args: unknown[]) => mocks.upsert(...args),
}));
vi.mock("@/lib/cycle/gate", () => ({
isCycleAvailableForUser: vi.fn(async () => true),
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
import { CycleImportAccumulator } from "../import-accumulator";
const FLOW = "HKCategoryTypeIdentifierMenstrualFlow";
beforeEach(() => {
mocks.upsert.mockReset();
});
describe("CycleImportAccumulator.flush — per-day fault isolation", () => {
it("keeps counting after a failed day and names the failure", async () => {
const acc = new CycleImportAccumulator("u1", "Europe/Berlin");
expect(
acc.consume(FLOW, "2026-05-10", "HKCategoryValueMenstrualFlowMedium"),
).toBe(true);
expect(
acc.consume(FLOW, "2026-05-11", "HKCategoryValueMenstrualFlowLight"),
).toBe(true);
mocks.upsert
.mockRejectedValueOnce(new Error("colliding day"))
.mockResolvedValueOnce({ existed: false });
const stats = await acc.flush();
expect(stats.samplesConsumed).toBe(2);
expect(stats.daysUpserted).toBe(1);
expect(stats.daysInserted).toBe(1);
expect(stats.daysFailed).toBe(1);
expect(stats.firstFailureReason).toBe("colliding day");
});
it("reports clean stats when every day lands", async () => {
const acc = new CycleImportAccumulator("u1", "Europe/Berlin");
acc.consume(FLOW, "2026-05-10", "3");
mocks.upsert.mockResolvedValue({ existed: false });
const stats = await acc.flush();
expect(stats).toMatchObject({
samplesConsumed: 1,
daysUpserted: 1,
daysFailed: 0,
firstFailureReason: null,
samplesSkippedModuleDisabled: 0,
});
});
});
+50 -9
View File
@@ -37,8 +37,11 @@ export const HK_CYCLE_DAY_EXTERNAL_PREFIX = "hkcycle:";
/** A zeroed cycle-import result — the no-cycle / aborted-flush fallback. */
export const EMPTY_CYCLE_IMPORT_STATS: CycleImportStats = {
samplesConsumed: 0,
samplesSkippedModuleDisabled: 0,
daysUpserted: 0,
daysInserted: 0,
daysFailed: 0,
firstFailureReason: null,
goalNudged: false,
};
@@ -54,10 +57,24 @@ interface DayBucket {
export interface CycleImportStats {
/** Reproductive samples consumed into a day-bucket. */
samplesConsumed: number;
/**
* Reproductive samples the export carried but the fold dropped because
* cycle tracking is disabled for the account. Named so the "read 900k /
* imported 880k" gap stops being unexplained.
*/
samplesSkippedModuleDisabled: number;
/** Day-logs upserted at flush. */
daysUpserted: number;
/** Day-logs that the write created (vs refreshed). */
daysInserted: number;
/**
* Day-logs whose write FAILED at flush. The flush is fault-isolated per
* day, so `daysUpserted` counts what actually landed rather than being
* zeroed by one colliding day.
*/
daysFailed: number;
/** The first per-day failure's message, for the operator-facing line. */
firstFailureReason: string | null;
/** Whether a CycleProfile goal nudge fired. */
goalNudged: boolean;
}
@@ -87,6 +104,16 @@ export class CycleImportAccumulator {
return this.byDay.size > 0;
}
/** Samples consumed so far — for the module-disabled skip accounting. */
sampleCount(): number {
return this.samplesConsumed;
}
/** Accumulated day-buckets — for the whole-flush-failed accounting. */
dayCount(): number {
return this.byDay.size;
}
/**
* Fold one reproductive HK sample into its day-bucket. `dayKey` is the
* already-resolved user-local `YYYY-MM-DD`. `rawValue` is the sample's
@@ -177,8 +204,11 @@ export class CycleImportAccumulator {
async flush(): Promise<CycleImportStats> {
const stats: CycleImportStats = {
samplesConsumed: this.samplesConsumed,
samplesSkippedModuleDisabled: 0,
daysUpserted: 0,
daysInserted: 0,
daysFailed: 0,
firstFailureReason: null,
goalNudged: false,
};
if (this.byDay.size === 0) return stats;
@@ -230,15 +260,26 @@ export class CycleImportAccumulator {
};
const cycleId = owningCycleId(dayKey);
const result = await upsertCycleDayLog(
this.userId,
entry,
this.userTimezone,
cycleId,
encryptSensitive,
);
stats.daysUpserted += 1;
if (!result.existed) stats.daysInserted += 1;
// Fault-isolated per day: one colliding day must not zero the stats
// for the days that DID land (the pre-fix shape reported
// `samplesConsumed: 0` after partial writes — a false "nothing
// happened" over rows already in the table).
try {
const result = await upsertCycleDayLog(
this.userId,
entry,
this.userTimezone,
cycleId,
encryptSensitive,
);
stats.daysUpserted += 1;
if (!result.existed) stats.daysInserted += 1;
} catch (err: unknown) {
stats.daysFailed += 1;
stats.firstFailureReason ??=
err instanceof Error ? err.message : String(err);
continue;
}
// The most-recent contraceptive method across the import drives the
// single goal nudge (applied once after the day loop).
@@ -15,6 +15,14 @@ vi.mock("@/lib/arrivals/emit-shared", () => ({
emitDataArrival: vi.fn().mockResolvedValue(undefined),
}));
// The cycle-fold gate — mutable so the module-off drop accounting can be
// exercised without a DB. Default OFF, matching every pre-existing fixture
// (none carries reproductive records, so the value was never read before).
const cycleGate = vi.hoisted(() => ({ enabled: false }));
vi.mock("@/lib/cycle/gate", () => ({
isCycleAvailableForUser: vi.fn(async () => cycleGate.enabled),
}));
import { Prisma, type PrismaClient } from "@/generated/prisma/client";
import {
hashSampleKey,
@@ -1172,3 +1180,49 @@ describe("streamParseExportXml — memory ceiling", () => {
expect(heapDelta).toBeLessThan(100 * 1024 * 1024);
});
});
/**
* A6 — reproductive samples with cycle tracking OFF must be counted, not
* silently discarded. The pre-fix importer read them into `recordsRead`,
* dropped the accumulator, and returned zeroed cycle stats — the
* "read 900k / imported 880k" unexplained gap.
*
* Watched red: with the module-off branch removed from the importer
* (cycle reset to EMPTY_CYCLE_IMPORT_STATS when the gate is off) the
* first assertion fails with `samplesSkippedModuleDisabled: 0`.
*/
describe("streamParseExportXml — cycle module off", () => {
function cycleExportXml(): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<HealthData locale="en_US">
<ExportDate value="2026-05-15 14:32:01 +0200"/>
<Record type="HKCategoryTypeIdentifierMenstrualFlow" value="HKCategoryValueMenstrualFlowMedium" startDate="2026-05-10 08:00:00 +0200" endDate="2026-05-10 08:00:00 +0200"/>
<Record type="HKQuantityTypeIdentifierBodyMass" value="81.5" unit="kg" startDate="2026-05-10 07:00:00 +0200" endDate="2026-05-10 07:00:00 +0200"/>
</HealthData>`;
}
it("names the dropped reproductive samples in the cycle stats", async () => {
cycleGate.enabled = false;
const tmp = mkdtempSync(join(tmpdir(), "healthlog-parser-test-"));
const xmlPath = join(tmp, "export.xml");
writeFileSync(xmlPath, cycleExportXml());
const prisma = makeFakePrisma();
const result = await streamParseExportXml({
xmlPath,
userId: "user-1",
userTimezone: "Europe/Berlin",
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma: prisma as any,
});
// The dropped sample is named, so recordsRead minus the per-type
// reads stays explained.
expect(result.cycle.samplesSkippedModuleDisabled).toBe(1);
expect(result.cycle.samplesConsumed).toBe(0);
expect(result.cycle.daysUpserted).toBe(0);
// The sibling weight record is unaffected.
expect(result.perType.WEIGHT?.inserted).toBe(1);
expect(result.totals.recordsRead).toBe(2);
});
});
@@ -1105,17 +1105,33 @@ export async function streamParseExportXml(
// reproductive samples AND the account has cycle tracking enabled. The
// empty-accumulator short-circuit also keeps the no-cycle import path
// (and its unit tests) free of any cycle DB round-trip. A flush failure
// (e.g. a single colliding day) must never abort the whole import — fold
// the error into zeroed cycle stats and continue.
if (cycleAccumulator.hasSamples() && (await cycleAccumulator.isEnabled())) {
try {
cycle = await cycleAccumulator.flush();
rowsUpserted += cycle.daysUpserted;
} catch (err: unknown) {
cycle = { ...EMPTY_CYCLE_IMPORT_STATS };
console.warn(
`cycle import flush failed: ${err instanceof Error ? err.message : String(err)}`,
);
// must never abort the whole import — per-day failures are isolated
// inside `flush()` (its stats stay honest); a whole-flush failure (the
// hoisted lookups) is folded into stats that still name the consumed
// samples and the reason instead of reading as "nothing happened".
if (cycleAccumulator.hasSamples()) {
if (await cycleAccumulator.isEnabled()) {
try {
cycle = await cycleAccumulator.flush();
rowsUpserted += cycle.daysUpserted;
} catch (err: unknown) {
const reason = err instanceof Error ? err.message : String(err);
cycle = {
...EMPTY_CYCLE_IMPORT_STATS,
samplesConsumed: cycleAccumulator.sampleCount(),
daysFailed: cycleAccumulator.dayCount(),
firstFailureReason: reason,
};
console.warn(`cycle import flush failed: ${reason}`);
}
} else {
// Cycle tracking is off for the account: the reproductive samples
// are DROPPED, and the drop is named so the recordsRead-vs-imported
// gap stays explained in the result the user sees.
cycle = {
...EMPTY_CYCLE_IMPORT_STATS,
samplesSkippedModuleDisabled: cycleAccumulator.sampleCount(),
};
}
}