From 9561e6e196370e2706d0a2dfa8644c62e7dbae71 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Mon, 13 Jul 2026 23:29:33 +0200 Subject: [PATCH] feat(web): three-phase progress with live download % on The Floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the misleading 6-micro-step progress bar with three honest phases (Searching -> Downloading -> Finishing). Downloading now shows a live determinate percentage sourced from job.downloadProgress (Slice A), with a "NN% · done/total tracks" caption when the track count is known. Searching and Finishing stay indeterminate. - status.ts: describeJob collapses to {label, kind}; drop step/totalSteps and the STAGES array. - progress-bar.tsx: ProgressBar takes {kind, indeterminate?, percent?, caption?} instead of step/total/stageLabel. - job-row.tsx: JobRow takes a `bar?: ReactNode` slot instead of step/total/indeterminate/stageLabel props. - queue.tsx: builds the per-state bar + meta; downloading meta drops the track count (now shown in the bar). Retry button + error note from the retry feature preserved exactly. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/app/_ui/job-row.tsx | 17 +++------------- web/src/app/_ui/progress-bar.tsx | 20 +++++++++---------- web/src/app/_ui/status.test.ts | 11 +++-------- web/src/app/_ui/status.ts | 32 +++++++++++++------------------ web/src/app/queue.tsx | 33 +++++++++++++++++++++++--------- 5 files changed, 52 insertions(+), 61 deletions(-) diff --git a/web/src/app/_ui/job-row.tsx b/web/src/app/_ui/job-row.tsx index c67196b..4a8d5d6 100644 --- a/web/src/app/_ui/job-row.tsx +++ b/web/src/app/_ui/job-row.tsx @@ -1,6 +1,5 @@ import type { ReactNode } from "react"; import type { Kind } from "./status"; -import { ProgressBar } from "./progress-bar"; import { StatusChip } from "./status-chip"; // Map the severity kind to the row's visual class (stripe/bar color). @@ -15,23 +14,17 @@ export function JobRow({ album, kind, label, - step, - total, meta, note, - indeterminate, - stageLabel, + bar, }: { artist: string; album: string; kind: Kind; label: string; - step: number; - total: number; meta?: ReactNode; note?: ReactNode; - indeterminate?: boolean; - stageLabel?: string; + bar?: ReactNode; }) { return (
@@ -41,11 +34,7 @@ export function JobRow({ {album} · {artist} {meta ?
{meta}
: null} - {note ? ( -
{note}
- ) : kind !== "attention" ? ( - - ) : null} + {note ?
{note}
: kind !== "attention" ? bar : null}
diff --git a/web/src/app/_ui/progress-bar.tsx b/web/src/app/_ui/progress-bar.tsx index a264c4e..e5d4183 100644 --- a/web/src/app/_ui/progress-bar.tsx +++ b/web/src/app/_ui/progress-bar.tsx @@ -1,19 +1,17 @@ import type { Kind } from "./status"; -/** A thin ruled progress bar. `indeterminate` for open-ended stages (searching); - * otherwise a discrete step/total fill with a step readout. */ +/** A thin ruled progress bar. `indeterminate` for open-ended phases (searching, + * finishing); otherwise a determinate fill to `percent` with a live readout. */ export function ProgressBar({ kind, - step, - total, indeterminate, - stageLabel, + percent, + caption, }: { kind: Kind; - step: number; - total: number; indeterminate?: boolean; - stageLabel?: string; + percent?: number; + caption?: string; }) { if (indeterminate) { return ( @@ -21,17 +19,17 @@ export function ProgressBar({
-
searching
+
{caption ?? "…"}
); } - const pct = total > 0 ? Math.round((step / total) * 100) : 0; + const pct = Math.min(100, Math.max(0, percent ?? 0)); return (
-
{stageLabel ? `${stageLabel} · step ${step} of ${total}` : `${step}/${total}`}
+
{caption ?? `${Math.round(percent ?? 0)}%`}
); } diff --git a/web/src/app/_ui/status.test.ts b/web/src/app/_ui/status.test.ts index 0b65f53..5dc72c0 100644 --- a/web/src/app/_ui/status.test.ts +++ b/web/src/app/_ui/status.test.ts @@ -5,21 +5,16 @@ describe("describeJob", () => { it("maps pipeline states to literal labels + severity kind", () => { expect(describeJob("requested", "intake")).toMatchObject({ label: "Queued", kind: "idle" }); expect(describeJob("matching", "match")).toMatchObject({ label: "Searching", kind: "working" }); + expect(describeJob("matched", "match")).toMatchObject({ label: "Searching", kind: "working" }); expect(describeJob("downloading", "download")).toMatchObject({ label: "Downloading", kind: "working" }); - expect(describeJob("tagging", "tag")).toMatchObject({ label: "Tagging", kind: "verify" }); + expect(describeJob("tagging", "tag")).toMatchObject({ label: "Finishing", kind: "verify" }); expect(describeJob("imported", "import")).toMatchObject({ label: "Pressed", kind: "done" }); expect(describeJob("needs_attention", "download")).toMatchObject({ label: "Needs attention", kind: "attention" }); }); - it("derives a stepped progress from the pipeline stage", () => { - expect(describeJob("downloading", "download")).toMatchObject({ step: 4, totalSteps: 6 }); - expect(describeJob("matching", "intake")).toMatchObject({ step: 1, totalSteps: 6 }); - }); - - it("falls back gracefully for unknown states/stages", () => { + it("falls back gracefully for unknown states", () => { expect(describeJob("weird", "intake").kind).toBe("idle"); expect(describeJob("weird", "intake").label).toBe("Weird"); - expect(describeJob("downloading", "???").step).toBe(0); }); }); diff --git a/web/src/app/_ui/status.ts b/web/src/app/_ui/status.ts index 82878db..51d357b 100644 --- a/web/src/app/_ui/status.ts +++ b/web/src/app/_ui/status.ts @@ -1,16 +1,14 @@ export type Kind = "idle" | "working" | "verify" | "done" | "attention"; -// Pipeline stage order (worker Job.currentStage), used to derive a stepped progress. -const STAGES = ["intake", "match", "rank", "download", "tag", "import"]; - -// Worker Job.state -> a literal, legible label + severity kind. The record-label -// metaphor stays in the chrome; these labels never obscure what's happening. +// Worker Job.state -> a literal, legible label + severity kind, collapsed to three +// honest phases: Searching -> Downloading -> Finishing. The record-label metaphor +// stays in the chrome; these labels never obscure what's happening. const STATE_MAP: Record = { requested: { label: "Queued", kind: "idle" }, matching: { label: "Searching", kind: "working" }, - matched: { label: "Matched", kind: "working" }, + matched: { label: "Searching", kind: "working" }, downloading: { label: "Downloading", kind: "working" }, - tagging: { label: "Tagging", kind: "verify" }, + tagging: { label: "Finishing", kind: "verify" }, imported: { label: "Pressed", kind: "done" }, needs_attention: { label: "Needs attention", kind: "attention" }, }; @@ -29,16 +27,12 @@ export function timeAgo(value: string | Date | null | undefined, now: number): s return `${Math.floor(h / 24)}d`; } -export function describeJob( - state: string, - stage: string, -): { label: string; kind: Kind; step: number; totalSteps: number } { - const base = - STATE_MAP[state] ?? - ({ label: state ? state[0].toUpperCase() + state.slice(1) : "Unknown", kind: "idle" } as { - label: string; - kind: Kind; - }); - const idx = STAGES.indexOf(stage); - return { ...base, step: idx >= 0 ? idx + 1 : 0, totalSteps: STAGES.length }; +export function describeJob(state: string, stage: string): { label: string; kind: Kind } { + void stage; // stage no longer drives the label; kept for call-site compatibility + return ( + STATE_MAP[state] ?? { + label: state ? state[0].toUpperCase() + state.slice(1) : "Unknown", + kind: "idle", + } + ); } diff --git a/web/src/app/queue.tsx b/web/src/app/queue.tsx index 5fb2890..0499a9f 100644 --- a/web/src/app/queue.tsx +++ b/web/src/app/queue.tsx @@ -5,6 +5,7 @@ import { describeJob, timeAgo } from "./_ui/status"; import { StatTiles } from "./_ui/stat-tiles"; import { SectionHeader } from "./_ui/section-header"; import { JobRow } from "./_ui/job-row"; +import { ProgressBar } from "./_ui/progress-bar"; import { PressedList } from "./_ui/pressed-list"; import { toast } from "./_ui/toast"; @@ -24,6 +25,7 @@ type Row = { updatedAt: string | null; candidateCount: number; chosen: { source: string; format: string; trackCount: number } | null; + downloadProgress: number; } | null; }; @@ -38,6 +40,7 @@ function jobOf(r: Row) { updatedAt: null, candidateCount: 0, chosen: null, + downloadProgress: 0, } ); } @@ -139,7 +142,7 @@ export function Queue() { let meta: string | undefined; if (j.state === "downloading" || j.state === "tagging") { const parts: string[] = []; - if (j.chosen) parts.push(`${j.chosen.source} · ${j.chosen.format} · ${j.chosen.trackCount} tracks`); + if (j.chosen) parts.push(`${j.chosen.source} · ${j.chosen.format}`); const elapsed = timeAgo(j.claimedAt ?? j.updatedAt, now); if (elapsed) parts.push(elapsed); meta = parts.join(" · ") || undefined; @@ -151,8 +154,6 @@ export function Queue() { } else if (j.state === "requested") { const elapsed = timeAgo(r.createdAt, now); meta = elapsed ? `In the queue · ${elapsed}` : "In the queue"; - } else if (j.state === "needs_attention") { - meta = undefined; } else { meta = undefined; } @@ -166,8 +167,25 @@ export function Queue() { ) : undefined; - const stageLabel = - attention || j.state === "matching" || j.state === "matched" ? undefined : j.currentStage; + let bar: React.ReactNode; + if (j.state === "matching" || j.state === "matched") { + bar = ; + } else if (j.state === "downloading") { + const pct = Math.round((j.downloadProgress || 0) * 100); + const total = j.chosen?.trackCount ?? 0; + const done = total ? Math.round((pct / 100) * total) : 0; + bar = ( + + ); + } else if (j.state === "tagging") { + bar = ; + } else { + bar = undefined; + } return ( ); });