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 (
);
});