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

@@ -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"