feat(web): three-phase progress with live download % on The Floor
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<article className={`job ${rowClass(kind)}`}>
|
||||
@@ -41,11 +34,7 @@ export function JobRow({
|
||||
{album} <span className="artist">· {artist}</span>
|
||||
</h3>
|
||||
{meta ? <div className="meta">{meta}</div> : null}
|
||||
{note ? (
|
||||
<div className="note">{note}</div>
|
||||
) : kind !== "attention" ? (
|
||||
<ProgressBar kind={kind} step={step} total={total} indeterminate={indeterminate} stageLabel={stageLabel} />
|
||||
) : null}
|
||||
{note ? <div className="note">{note}</div> : kind !== "attention" ? bar : null}
|
||||
</div>
|
||||
<StatusChip kind={kind} label={label} />
|
||||
</article>
|
||||
|
||||
@@ -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({
|
||||
<div className="bar indet">
|
||||
<span />
|
||||
</div>
|
||||
<div className="pct muted">searching</div>
|
||||
<div className="pct muted">{caption ?? "…"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const pct = total > 0 ? Math.round((step / total) * 100) : 0;
|
||||
const pct = Math.min(100, Math.max(0, percent ?? 0));
|
||||
return (
|
||||
<div className="prog">
|
||||
<div className="bar">
|
||||
<span style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<div className="pct">{stageLabel ? `${stageLabel} · step ${step} of ${total}` : `${step}/${total}`}</div>
|
||||
<div className="pct">{caption ?? `${Math.round(percent ?? 0)}%`}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+13
-19
@@ -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<string, { label: string; kind: Kind }> = {
|
||||
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",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
+24
-9
@@ -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 = <ProgressBar kind="working" indeterminate caption="searching" />;
|
||||
} 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 = (
|
||||
<ProgressBar
|
||||
kind="working"
|
||||
percent={pct}
|
||||
caption={total ? `${pct}% · ${done}/${total} tracks` : `${pct}%`}
|
||||
/>
|
||||
);
|
||||
} else if (j.state === "tagging") {
|
||||
bar = <ProgressBar kind="verify" indeterminate caption="finishing" />;
|
||||
} else {
|
||||
bar = undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<JobRow
|
||||
@@ -176,12 +194,9 @@ export function Queue() {
|
||||
album={r.album}
|
||||
kind={d.kind}
|
||||
label={d.label}
|
||||
step={d.step}
|
||||
total={d.totalSteps}
|
||||
indeterminate={j.state === "matching" || j.state === "matched"}
|
||||
meta={meta}
|
||||
note={note}
|
||||
stageLabel={stageLabel}
|
||||
bar={bar}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user