feat(products-ui): run-detail images section + progress poll, image-problems notice band, history images column (SD-0002 §5.2/§5.5)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 00:37:45 -07:00
parent 7f51f5242f
commit d81215be1d
5 changed files with 133 additions and 3 deletions
+7 -1
View File
@@ -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;
+10 -1
View File
@@ -12,6 +12,7 @@ import {
} from "../../productsApi";
import { Banner } from "../../ui/kit";
import { isExportEnabled } from "./exportMenu";
import { historyImageCell } from "./runImages";
const STATUS_LABELS: Record<string, string> = {
applying: "Importing…",
@@ -96,6 +97,13 @@ export default function ProductsPage() {
</a>
</div>
</header>
{summary.image_problem_count > 0 && (
<div className="notice" aria-live="polite">
<a href={`#/products/imports/runs/${summary.latest_run_id}`}>
{summary.image_problem_count} products have image problems
</a>
</div>
)}
{empty ? (
<div className="empty">
<p className="empty__copy">No products yet. Bulk import is how product data gets in.</p>
@@ -119,7 +127,7 @@ export default function ProductsPage() {
<table className="datatable">
<thead>
<tr>
<th>Date</th><th>File</th><th>Dialect</th><th>Added</th><th>Updated</th><th>Errors</th><th>Status</th>
<th>Date</th><th>File</th><th>Dialect</th><th>Added</th><th>Updated</th><th>Errors</th><th>Images</th><th>Status</th>
</tr>
</thead>
<tbody>
@@ -142,6 +150,7 @@ export default function ProductsPage() {
<td>{r.products_added}</td>
<td>{r.products_updated}</td>
<td>{r.rows_errored}</td>
<td>{historyImageCell(r.image_counts)}</td>
<td>{STATUS_LABELS[r.status] ?? r.status}</td>
</tr>
))}
+59 -1
View File
@@ -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<string, string> = {
@@ -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 (
<Banner tone="attn" title="No such import run">
@@ -88,6 +109,43 @@ export default function RunDetail({ runId }: { runId: number }) {
</tbody>
</table>
)}
{!isRunTerminal(run.status) ? (
<p className="note" aria-live="polite">
{imageProgressLabel(run.image_progress)}
</p>
) : (
run.image_progress.total > 0 && (
<>
<p className="note">{imageOutcomeSummary(run.image_counts)}</p>
{run.image_outcomes.length > 0 ? (
<table className="errortable">
<thead>
<tr>
<th>Handle</th>
<th>Variant</th>
<th>Image URL</th>
<th>Outcome</th>
<th>What to do</th>
</tr>
</thead>
<tbody>
{run.image_outcomes.map((o, i) => (
<tr key={i}>
<td>{o.handle}</td>
<td>{o.variant ?? "—"}</td>
<td>{o.url}</td>
<td>{outcomeLabel(o.outcome)}</td>
<td>Correct the URL and re-import.</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="note">All images fetched.</p>
)}
</>
)
)}
</div>
);
}
@@ -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/);
});
});
@@ -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;
}