mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +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
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user