mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
a45dde2039
commit
db6465c17d
12 files changed
+530
-31
No files matched your search
+8
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user