diff --git a/frontend/src/productsApi.ts b/frontend/src/productsApi.ts index 981f5c4..03ca4c8 100644 --- a/frontend/src/productsApi.ts +++ b/frontend/src/productsApi.ts @@ -26,15 +26,21 @@ export interface DraftRecord { errors?: RowErrorDetail[]; }; } +export interface ImageOutcome { + handle: string; url: string; outcome: string; reason: string | null; variant: string | null; +} +export interface ImageCounts { fetched: number; rejected: number; failed: number; } export interface RunSummary { id: number; file_name: string; dialect: string; created_at: string; completed_at: string | null; status: string; by: string; products_added: number; products_updated: number; rows_errored: number; + image_counts?: ImageCounts; } export interface RunDetail extends RunSummary { errors: RowErrorDetail[]; image_progress: { done: number; total: number }; - image_outcomes: unknown[]; + image_counts: ImageCounts; + image_outcomes: ImageOutcome[]; } export interface ProductsSummary { product_count: number; image_problem_count: number; latest_run_id: number | null; diff --git a/frontend/src/screens/products/ProductsPage.tsx b/frontend/src/screens/products/ProductsPage.tsx index 61ed96e..7aff087 100644 --- a/frontend/src/screens/products/ProductsPage.tsx +++ b/frontend/src/screens/products/ProductsPage.tsx @@ -12,6 +12,7 @@ import { } from "../../productsApi"; import { Banner } from "../../ui/kit"; import { isExportEnabled } from "./exportMenu"; +import { historyImageCell } from "./runImages"; const STATUS_LABELS: Record = { applying: "Importing…", @@ -96,6 +97,13 @@ export default function ProductsPage() { + {summary.image_problem_count > 0 && ( +
+ + {summary.image_problem_count} products have image problems + +
+ )} {empty ? (

No products yet. Bulk import is how product data gets in.

@@ -119,7 +127,7 @@ export default function ProductsPage() { - + @@ -142,6 +150,7 @@ export default function ProductsPage() { + ))} diff --git a/frontend/src/screens/products/RunDetail.tsx b/frontend/src/screens/products/RunDetail.tsx index c0a8194..cb578ef 100644 --- a/frontend/src/screens/products/RunDetail.tsx +++ b/frontend/src/screens/products/RunDetail.tsx @@ -1,7 +1,14 @@ // Run detail (SD-0002 §5.5) — report card for a completed or in-progress import run. -// NO images section this slice (SLICE-7). PUC-4/5/8. +// The durable record of the import's image-fetch outcome: live progress while +// fetching (polled, no websockets — deliberate), then per-image problem rows. PUC-4/5/8. import { useEffect, useState } from "react"; import { dialectLabel, getRun, type RunDetail as RunDetailType } from "../../productsApi"; +import { + imageOutcomeSummary, + imageProgressLabel, + isRunTerminal, + outcomeLabel, +} from "./runImages"; import { Banner } from "../../ui/kit"; const STATUS_LABELS: Record = { @@ -29,6 +36,20 @@ export default function RunDetail({ runId }: { runId: number }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [runId]); + // While the run is still applying / fetching images, poll the endpoint so the + // "Fetching images: X of Y" line advances (no websockets — deliberate, §5.5). + // The interval is cleared on terminal status and on unmount. + useEffect(() => { + if (!run || isRunTerminal(run.status)) return; + const id = setInterval(() => { + void (async () => { + const resp = await getRun(runId); + if (resp.ok) setRun(resp.value); + })(); + }, 2000); + return () => clearInterval(id); + }, [run, runId]); + if (loadFail === "gone") { return ( @@ -88,6 +109,43 @@ export default function RunDetail({ runId }: { runId: number }) {
DateFileDialectAddedUpdatedErrorsStatusDateFileDialectAddedUpdatedErrorsImagesStatus
{r.products_added} {r.products_updated} {r.rows_errored}{historyImageCell(r.image_counts)} {STATUS_LABELS[r.status] ?? r.status}
)} + {!isRunTerminal(run.status) ? ( +

+ {imageProgressLabel(run.image_progress)} +

+ ) : ( + run.image_progress.total > 0 && ( + <> +

{imageOutcomeSummary(run.image_counts)}

+ {run.image_outcomes.length > 0 ? ( + + + + + + + + + + + + {run.image_outcomes.map((o, i) => ( + + + + + + + + ))} + +
HandleVariantImage URLOutcomeWhat to do
{o.handle}{o.variant ?? "—"}{o.url}{outcomeLabel(o.outcome)}Correct the URL and re-import.
+ ) : ( +

All images fetched.

+ )} + + ) + )}
); } diff --git a/frontend/src/screens/products/runImages.test.ts b/frontend/src/screens/products/runImages.test.ts new file mode 100644 index 0000000..6065207 --- /dev/null +++ b/frontend/src/screens/products/runImages.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { historyImageCell, imageOutcomeSummary, imageProgressLabel, isRunTerminal, outcomeLabel } from "./runImages"; + +describe("runImages helpers", () => { + it("progress label", () => { + expect(imageProgressLabel({ done: 312, total: 4950 })).toBe("Fetching images: 312 of 4,950"); + }); + it("outcome summary", () => { + expect(imageOutcomeSummary({ fetched: 4938, rejected: 9, failed: 3 })) + .toBe("4,938 fetched · 9 rejected · 3 failed"); + }); + it("history cell collapses clean / shows problems / dashes empty", () => { + expect(historyImageCell({ fetched: 10, rejected: 0, failed: 0 })).toBe("10 ✓"); + expect(historyImageCell({ fetched: 8, rejected: 1, failed: 1 })).toBe("8 ✓ · 2 ✗"); + expect(historyImageCell(undefined)).toBe("—"); + }); + it("terminal status", () => { + expect(isRunTerminal("fetching_images")).toBe(false); + expect(isRunTerminal("complete_with_problems")).toBe(true); + }); + it("outcome labels", () => { + expect(outcomeLabel("rejected_low_res")).toMatch(/resolution bar/); + expect(outcomeLabel("failed")).toMatch(/unreachable/); + }); +}); diff --git a/frontend/src/screens/products/runImages.ts b/frontend/src/screens/products/runImages.ts new file mode 100644 index 0000000..3026ac7 --- /dev/null +++ b/frontend/src/screens/products/runImages.ts @@ -0,0 +1,32 @@ +// Pure logic behind the image-fetch surfaces (SD-0002 §5.5): the run-detail +// progress/outcome labels + the history "Images" cell + terminal-status test +// that drives polling. Mirrors exportMenu.ts: pure helpers unit-tested here, +// the JSX verified by the E2E suite (SLICE-2/3 pattern). +import type { ImageCounts } from "../../productsApi"; + +export function imageProgressLabel(p: { done: number; total: number }): string { + return `Fetching images: ${p.done.toLocaleString()} of ${p.total.toLocaleString()}`; +} + +export function imageOutcomeSummary(c: ImageCounts): string { + return `${c.fetched.toLocaleString()} fetched · ${c.rejected.toLocaleString()} rejected · ${c.failed.toLocaleString()} failed`; +} + +export function historyImageCell(c: ImageCounts | undefined): string { + if (!c || c.fetched + c.rejected + c.failed === 0) return "—"; + const bad = c.rejected + c.failed; + return bad === 0 + ? `${c.fetched.toLocaleString()} ✓` + : `${c.fetched.toLocaleString()} ✓ · ${bad.toLocaleString()} ✗`; +} + +export function isRunTerminal(status: string): boolean { + return status === "complete" || status === "complete_with_problems"; +} + +export function outcomeLabel(outcome: string): string { + if (outcome === "rejected_low_res") return "Rejected — below the resolution bar"; + if (outcome === "rejected_not_image") return "Rejected — not a supported image"; + if (outcome === "failed") return "Failed — unreachable"; + return outcome; +}