diff --git a/messages/de.json b/messages/de.json index f04f2afba..86d3f10c9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index ce5b28d1b..2c23472a0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 2aca2bcad..43ad3e707 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 5022654d2..1b5f8edcb 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 7449aeba6..ea4102092 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 5a9e73f54..8ac4c118b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/components/settings/__tests__/import-panel.test.tsx b/src/components/settings/__tests__/import-panel.test.tsx index c2a1be65d..96ae81dbc 100644 --- a/src/components/settings/__tests__/import-panel.test.tsx +++ b/src/components/settings/__tests__/import-panel.test.tsx @@ -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("", () => { // 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 + * `` 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("", () => { + it("renders every populated skip class", () => { + const html = render( + , + ); + 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( + , + ); + 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( + , + ); + expect(html).toBe(""); + }); +}); + describe("appleHealthFailureKind", () => { it("maps the reconcile code onto translated copy", () => { expect(appleHealthFailureKind("interrupted_by_restart")).toBe( diff --git a/src/components/settings/import-panel/apple-health-import-card.tsx b/src/components/settings/import-panel/apple-health-import-card.tsx index fd4e13d97..4bbf87135 100644 --- a/src/components/settings/import-panel/apple-health-import-card.tsx +++ b/src/components/settings/import-panel/apple-health-import-card.tsx @@ -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; + /** Per-key drop counters — plain HK types are unsupported-by-design, + * `key::reason`-tagged entries are genuine refusals. */ + unknown?: Record; + 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["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 ( +
+ {cycleDays > 0 && ( +

+ {t("settings.sections.export.import.appleHealth.cycleSummary", { + samples: cycleSamples, + days: cycleDays, + })} +

+ )} + {cycleModuleOff > 0 && ( +

+ {t("settings.sections.export.import.appleHealth.cycleModuleOff", { + count: cycleModuleOff, + })} +

+ )} + {cycleFailed > 0 && ( +

+ {t("settings.sections.export.import.appleHealth.cycleDaysFailed", { + count: cycleFailed, + reason: cycle?.firstFailureReason ?? "—", + })} +

+ )} + {skips.unsupported > 0 && ( +

+ {t("settings.sections.export.import.appleHealth.skipUnsupported", { + count: skips.unsupported, + })} +

+ )} + {skips.refused > 0 && ( +

+ {t("settings.sections.export.import.appleHealth.skipRefused", { + count: skips.refused, + })} +

+ )} + {skips.breakdown.length > 0 && ( +
+ + {t("settings.sections.export.import.appleHealth.skipBreakdown", { + count: skips.breakdown.length, + })} + +
    + {shown.map((entry) => ( +
  • + {entry.key}: {entry.count} +
  • + ))} +
+ {omitted > 0 && ( +

+ {t( + "settings.sections.export.import.appleHealth.skipBreakdownMore", + { count: omitted }, + )} +

+ )} +
+ )} +
+ ); +} + export function AppleHealthImportCard() { const { t } = useTranslations(); const fileInputRef = useRef(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 ( 0 @@ -307,6 +477,7 @@ export function AppleHealthImportCard() { testId="import-apple-health-result" /> )} + {isDone && } {isDone && (ecg?.discovered ?? 0) > 0 && (

({ + 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, + }); + }); +}); diff --git a/src/lib/cycle/import-accumulator.ts b/src/lib/cycle/import-accumulator.ts index d1b1c1939..b6e4bf31a 100644 --- a/src/lib/cycle/import-accumulator.ts +++ b/src/lib/cycle/import-accumulator.ts @@ -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 { 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). diff --git a/src/lib/measurements/__tests__/import-apple-health-export.test.ts b/src/lib/measurements/__tests__/import-apple-health-export.test.ts index 030f94be0..a2a018d1b 100644 --- a/src/lib/measurements/__tests__/import-apple-health-export.test.ts +++ b/src/lib/measurements/__tests__/import-apple-health-export.test.ts @@ -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 ` + + + + +`; + } + + 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); + }); +}); diff --git a/src/lib/measurements/import-apple-health-export.ts b/src/lib/measurements/import-apple-health-export.ts index 5b03a0169..df9ae28e6 100644 --- a/src/lib/measurements/import-apple-health-export.ts +++ b/src/lib/measurements/import-apple-health-export.ts @@ -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(), + }; } }