diff --git a/src/components/medications/intake-import-result.tsx b/src/components/medications/intake-import-result.tsx index 4493fc219..d0945bc04 100644 --- a/src/components/medications/intake-import-result.tsx +++ b/src/components/medications/intake-import-result.tsx @@ -8,9 +8,9 @@ * the thing that has to be right is the sentence at the end of the run. * * The rules are the ones the settings import panel already follows, reused - * rather than restated: `classifyImportOutcome` decides the tone from the + * rather than restated: `classifyWrittenOutcome` decides the tone from the * counts, so an import that wrote nothing can never carry the success tick, - * and `ImportOutcomeLine` owns the tick and the success colour. The skips are + * and `WrittenOutcomeLine` owns the tick and the success colour. The skips are * grouped by reason with a count, because a file that trips one rule trips it * on every row and a per-row list is the same sentence a hundred times. * @@ -18,11 +18,11 @@ * cards; the medication intake import is the third import surface and it now * answers the same way. */ +import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; import { - classifyImportOutcome, - type ImportOutcome, -} from "@/components/settings/import-panel/import-result-state"; -import { ImportOutcomeLine } from "@/components/settings/import-panel/import-result-view"; + classifyWrittenOutcome, + type WrittenOutcome, +} from "@/lib/outcome/written-outcome"; import { useTranslations } from "@/lib/i18n/context"; /** A skip group exactly as the import job reports it. */ @@ -132,7 +132,7 @@ export function IntakeImportResultView({ ); } - const outcome: ImportOutcome = classifyImportOutcome({ + const outcome: WrittenOutcome = classifyWrittenOutcome({ written: result.imported, skipped: result.skipped, }); @@ -153,7 +153,7 @@ export function IntakeImportResultView({ return (
- = { + success: { Icon: CheckCircle2, className: "text-success" }, + partial: { Icon: TriangleAlert, className: "text-warning" }, + failed: { Icon: AlertCircle, className: "text-destructive" }, + empty: { Icon: Info, className: "text-muted-foreground" }, +}; + +/** + * The headline line of a write result. Sits inside the caller's existing + * `aria-live` region where there is one, so the outcome is announced as it + * lands; a `failed` or `partial` line carries `role="alert"` itself, because + * a result that did not write is the case a user must not scroll past. + */ +export function WrittenOutcomeLine({ + outcome, + message, + testId, +}: { + outcome: WrittenOutcome; + message: string; + testId: string; +}) { + const { Icon, className } = OUTCOME_PRESENTATION[outcome]; + return ( +

+

+ ); +} 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 cfeb60c3f..1d8d12af8 100644 --- a/src/components/settings/import-panel/apple-health-import-card.tsx +++ b/src/components/settings/import-panel/apple-health-import-card.tsx @@ -11,8 +11,8 @@ import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; import { ImportCardShell } from "./import-card-shell"; -import { classifyImportOutcome } from "./import-result-state"; -import { ImportOutcomeLine } from "./import-result-view"; +import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; +import { classifyWrittenOutcome } from "@/lib/outcome/written-outcome"; /** Terminal states the status poll stops on. */ const TERMINAL_STATES: readonly string[] = ["done", "failed"]; @@ -239,8 +239,8 @@ export function AppleHealthImportCard() {
)} {isDone && ( - 0 ? "failed" : "empty"; - return skipped > 0 ? "partial" : "success"; -} - -export interface SkipReasonGroup { - /** The machine-readable reason as the route reported it. */ - reason: string; - count: number; -} - -/** A row of the per-row envelope, narrowed to what the grouping needs. */ -export interface ImportRowResult { - line: number; - status: string; - reason?: string; -} - -const UNKNOWN_REASON = "unknown"; - -/** - * Collapse the per-row envelope into one entry per skip reason. - * - * A sensor export that trips one validation trips it on every row, so the - * raw list is the same sentence a few thousand times. Grouping turns "scroll - * fifty identical lines" into one fact with a count. Ordered by count - * descending, then by reason, so the same result always renders the same way. - */ -export function groupSkipReasons( - rows: readonly ImportRowResult[], -): SkipReasonGroup[] { - const counts = new Map(); - for (const row of rows) { - if (row.status !== "skipped") continue; - const reason = row.reason ?? UNKNOWN_REASON; - counts.set(reason, (counts.get(reason) ?? 0) + 1); - } - return [...counts.entries()] - .map(([reason, count]) => ({ reason, count })) - .sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason)); -} diff --git a/src/components/settings/import-panel/import-result-view.tsx b/src/components/settings/import-panel/import-result-view.tsx index 78ac1fe44..b30b8fb47 100644 --- a/src/components/settings/import-panel/import-result-view.tsx +++ b/src/components/settings/import-panel/import-result-view.tsx @@ -1,61 +1,12 @@ "use client"; -import { AlertCircle, CheckCircle2, Info, TriangleAlert } from "lucide-react"; -import type { LucideIcon } from "lucide-react"; - +import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; import { useTranslations } from "@/lib/i18n/context"; import { - classifyImportOutcome, + classifyWrittenOutcome, groupSkipReasons, - type ImportOutcome, - type ImportRowResult, -} from "./import-result-state"; - -/** - * The one mapping from an import outcome to how it looks. - * - * `CheckCircle2` and `text-success` appear exactly once in the import panel, - * on the `success` key, so no future edit can attach a tick to a result that - * wrote nothing. `import-result-guard.test.ts` holds that line. - */ -const OUTCOME_PRESENTATION: Record< - ImportOutcome, - { Icon: LucideIcon; className: string } -> = { - success: { Icon: CheckCircle2, className: "text-success" }, - partial: { Icon: TriangleAlert, className: "text-warning" }, - failed: { Icon: AlertCircle, className: "text-destructive" }, - empty: { Icon: Info, className: "text-muted-foreground" }, -}; - -/** - * The headline line of an import result. Sits inside each card's existing - * `aria-live="polite"` region, so the outcome is announced as it lands. - */ -export function ImportOutcomeLine({ - outcome, - message, - testId, -}: { - outcome: ImportOutcome; - message: string; - testId: string; -}) { - const { Icon, className } = OUTCOME_PRESENTATION[outcome]; - return ( -

-

- ); -} + type WrittenRowResult, +} from "@/lib/outcome/written-outcome"; export interface CsvImportResult { inserted: number; @@ -63,7 +14,7 @@ export interface CsvImportResult { skipped: number; total: number; dryRun: boolean; - rows: ImportRowResult[]; + rows: WrittenRowResult[]; } /** @@ -118,7 +69,7 @@ export function CsvImportResultView({ result }: { result: CsvImportResult }) { const labelFor = useSkipReasonLabel(); const written = result.inserted + result.updated; - const outcome = classifyImportOutcome({ written, skipped: result.skipped }); + const outcome = classifyWrittenOutcome({ written, skipped: result.skipped }); const groups = groupSkipReasons(result.rows); const skippedRows = result.rows.filter((row) => row.status === "skipped"); @@ -162,7 +113,7 @@ export function CsvImportResultView({ result }: { result: CsvImportResult }) { return (
- - classifyImportOutcome({ + classifyWrittenOutcome({ written: r.measurements + r.moodEntries, skipped: r.skipped, }); @@ -191,7 +191,7 @@ export function JsonImportCard() {
{result && ( - { +describe("classifyWrittenOutcome", () => { it("calls the reported production result a failure", () => { // The audit envelope from the report: 1 597 rows in, nothing written. - expect(classifyImportOutcome({ written: 0, skipped: 1597 })).toBe("failed"); + expect(classifyWrittenOutcome({ written: 0, skipped: 1597 })).toBe( + "failed", + ); }); it("calls a file with no data rows empty, not a success", () => { - expect(classifyImportOutcome({ written: 0, skipped: 0 })).toBe("empty"); + expect(classifyWrittenOutcome({ written: 0, skipped: 0 })).toBe("empty"); }); it("calls a mixed result partial", () => { - expect(classifyImportOutcome({ written: 3, skipped: 2 })).toBe("partial"); + expect(classifyWrittenOutcome({ written: 3, skipped: 2 })).toBe("partial"); }); it("calls a clean result a success", () => { - expect(classifyImportOutcome({ written: 5, skipped: 0 })).toBe("success"); + expect(classifyWrittenOutcome({ written: 5, skipped: 0 })).toBe("success"); }); it("never reaches success while nothing was written", () => { for (let written = 0; written <= 4; written++) { for (let skipped = 0; skipped <= 4; skipped++) { - const outcome = classifyImportOutcome({ written, skipped }); + const outcome = classifyWrittenOutcome({ written, skipped }); if (written === 0) { expect(outcome).not.toBe("success"); expect(outcome).not.toBe("partial"); @@ -50,6 +53,43 @@ describe("classifyImportOutcome", () => { }); }); +describe("resolveSyncOutcome", () => { + it("calls a run that wrote nothing and failed a failure, not an empty run", () => { + // The defect: every row refused by a constraint, count 0, HTTP 200. + expect(resolveSyncOutcome({ imported: 0, failed: true })).toEqual({ + imported: 0, + failed: true, + outcome: "failed", + }); + }); + + it("calls a run that found nothing new empty", () => { + expect(resolveSyncOutcome({ imported: 0, failed: false }).outcome).toBe( + "empty", + ); + }); + + it("calls a run with a failed leg partial even when rows landed", () => { + expect(resolveSyncOutcome({ imported: 12, failed: true }).outcome).toBe( + "partial", + ); + }); + + it("calls a clean run a success", () => { + expect(resolveSyncOutcome({ imported: 12, failed: false }).outcome).toBe( + "success", + ); + }); + + it("never reaches success while the run reported a failure", () => { + for (const imported of [0, 1, 500]) { + expect(resolveSyncOutcome({ imported, failed: true }).outcome).not.toBe( + "success", + ); + } + }); +}); + describe("groupSkipReasons", () => { it("collapses a repeated reason to one entry with a count", () => { const rows = Array.from({ length: 1597 }, (_, i) => ({ diff --git a/src/lib/outcome/written-outcome.ts b/src/lib/outcome/written-outcome.ts new file mode 100644 index 000000000..786d42869 --- /dev/null +++ b/src/lib/outcome/written-outcome.ts @@ -0,0 +1,122 @@ +/** + * Written-outcome classification — the one place that decides whether a write + * reads as a success. + * + * The rule started in the import panel (`import-result-state.ts`, #640/#650) + * and is unchanged; only its reach is wider. A per-row importer answers HTTP + * 200 for a file where every single row was refused, and a provider sync + * answers HTTP 200 for a run where every reading failed a constraint. The + * transport succeeded, the write did not. Rendering either as a green tick is + * how a self-hoster's sensor export could write nothing and still look + * finished. So the counts, not the status code, pick the state: + * + * - nothing written, nothing refused → `empty` (there was nothing to write) + * - nothing written, rows refused → `failed` (never a success tone) + * - some written, some refused → `partial` (a warning, not a tick) + * - everything written → `success` + * + * `empty` is deliberately its own state rather than a failure: an import of a + * file with no data rows and a sync that finds nothing new are both honest + * non-events, and both would be a lie under a tick. + * + * Every surface that reports a write routes through this, and + * `success-affordance-guard.test.ts` holds the line that the tick and the + * success colour live only in the presentation module this feeds. + */ + +export type WrittenOutcome = "empty" | "failed" | "partial" | "success"; + +export interface WriteCounts { + /** Rows that reached the database — inserted + updated. */ + written: number; + /** Rows the write refused, for any reason. */ + skipped: number; +} + +export function classifyWrittenOutcome({ + written, + skipped, +}: WriteCounts): WrittenOutcome { + if (written <= 0) return skipped > 0 ? "failed" : "empty"; + return skipped > 0 ? "partial" : "success"; +} + +/** + * What a provider sync reports back to its route. + * + * Three providers (Fitbit, Google Health, WHOOP) already answered in this + * shape; the rest returned a bare count that narrowed their failures away — + * a Nightscout run whose every SGV row failed to write returned the same `0` + * as a run that found nothing new. The boolean is the honest floor: a sync + * knows a leg / collection / row did not land, but not always how many. + */ +export interface SyncWriteResult { + /** Rows that reached the database this run. */ + imported: number; + /** True when any part of the run did not land — a row, a collection, a leg. */ + failed: boolean; +} + +/** The sync envelope a route answers with: the counts plus the resolved tone. */ +export interface ResolvedSyncOutcome extends SyncWriteResult { + outcome: WrittenOutcome; +} + +/** + * Resolve a sync's write result to the outcome its card renders. + * + * Server-side on purpose: the verdict is the server's to compute, so the web + * card and the native client cannot drift on how the same run reads. A failed + * leg counts as one refusal — enough to move `success` to `partial` and + * `empty` to `failed`, which is the whole point. + */ +export function resolveSyncOutcome({ + imported, + failed, +}: SyncWriteResult): ResolvedSyncOutcome { + return { + imported, + failed, + outcome: classifyWrittenOutcome({ + written: imported, + skipped: failed ? 1 : 0, + }), + }; +} + +export interface SkipReasonGroup { + /** The machine-readable reason as the route reported it. */ + reason: string; + count: number; +} + +/** A row of the per-row envelope, narrowed to what the grouping needs. */ +export interface WrittenRowResult { + line: number; + status: string; + reason?: string; +} + +const UNKNOWN_REASON = "unknown"; + +/** + * Collapse the per-row envelope into one entry per skip reason. + * + * A sensor export that trips one validation trips it on every row, so the + * raw list is the same sentence a few thousand times. Grouping turns "scroll + * fifty identical lines" into one fact with a count. Ordered by count + * descending, then by reason, so the same result always renders the same way. + */ +export function groupSkipReasons( + rows: readonly WrittenRowResult[], +): SkipReasonGroup[] { + const counts = new Map(); + for (const row of rows) { + if (row.status !== "skipped") continue; + const reason = row.reason ?? UNKNOWN_REASON; + counts.set(reason, (counts.get(reason) ?? 0) + 1); + } + return [...counts.entries()] + .map(([reason, count]) => ({ reason, count })) + .sort((a, b) => b.count - a.count || a.reason.localeCompare(b.reason)); +}