feat(web): richer active-job rows on The Floor (source/format/tracks, stage, elapsed, error)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-13 22:05:07 +02:00
parent 3ec1547e10
commit 381426082e
7 changed files with 198 additions and 32 deletions
+3 -1
View File
@@ -20,6 +20,7 @@ export function JobRow({
meta, meta,
note, note,
indeterminate, indeterminate,
stageLabel,
}: { }: {
artist: string; artist: string;
album: string; album: string;
@@ -30,6 +31,7 @@ export function JobRow({
meta?: ReactNode; meta?: ReactNode;
note?: ReactNode; note?: ReactNode;
indeterminate?: boolean; indeterminate?: boolean;
stageLabel?: string;
}) { }) {
return ( return (
<article className={`job ${rowClass(kind)}`}> <article className={`job ${rowClass(kind)}`}>
@@ -42,7 +44,7 @@ export function JobRow({
{note ? ( {note ? (
<div className="note">{note}</div> <div className="note">{note}</div>
) : kind !== "attention" ? ( ) : kind !== "attention" ? (
<ProgressBar kind={kind} step={step} total={total} indeterminate={indeterminate} /> <ProgressBar kind={kind} step={step} total={total} indeterminate={indeterminate} stageLabel={stageLabel} />
) : null} ) : null}
</div> </div>
<StatusChip kind={kind} label={label} /> <StatusChip kind={kind} label={label} />
+3 -3
View File
@@ -7,11 +7,13 @@ export function ProgressBar({
step, step,
total, total,
indeterminate, indeterminate,
stageLabel,
}: { }: {
kind: Kind; kind: Kind;
step: number; step: number;
total: number; total: number;
indeterminate?: boolean; indeterminate?: boolean;
stageLabel?: string;
}) { }) {
if (indeterminate) { if (indeterminate) {
return ( return (
@@ -29,9 +31,7 @@ export function ProgressBar({
<div className="bar"> <div className="bar">
<span style={{ width: `${pct}%` }} /> <span style={{ width: `${pct}%` }} />
</div> </div>
<div className="pct"> <div className="pct">{stageLabel ? `${stageLabel} · step ${step} of ${total}` : `${step}/${total}`}</div>
{step}/{total}
</div>
</div> </div>
); );
} }
+27 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { describeJob } from "./status"; import { describeJob, timeAgo } from "./status";
describe("describeJob", () => { describe("describeJob", () => {
it("maps pipeline states to literal labels + severity kind", () => { it("maps pipeline states to literal labels + severity kind", () => {
@@ -22,3 +22,29 @@ describe("describeJob", () => {
expect(describeJob("downloading", "???").step).toBe(0); expect(describeJob("downloading", "???").step).toBe(0);
}); });
}); });
describe("timeAgo", () => {
const now = new Date("2026-07-13T12:00:00Z").getTime();
it("returns 'just now' for deltas under 60s", () => {
expect(timeAgo(new Date(now - 30_000).toISOString(), now)).toBe("just now");
});
it("returns minutes for sub-hour deltas", () => {
expect(timeAgo(new Date(now - 5 * 60_000).toISOString(), now)).toBe("5m");
});
it("returns hours for sub-day deltas", () => {
expect(timeAgo(new Date(now - 2 * 3600_000).toISOString(), now)).toBe("2h");
});
it("returns days for multi-day deltas", () => {
expect(timeAgo(new Date(now - 3 * 86_400_000).toISOString(), now)).toBe("3d");
});
it("returns empty string for null/undefined/invalid input", () => {
expect(timeAgo(null, now)).toBe("");
expect(timeAgo(undefined, now)).toBe("");
expect(timeAgo("not-a-date", now)).toBe("");
});
});
+14
View File
@@ -15,6 +15,20 @@ const STATE_MAP: Record<string, { label: string; kind: Kind }> = {
needs_attention: { label: "Needs attention", kind: "attention" }, needs_attention: { label: "Needs attention", kind: "attention" },
}; };
/** Compact elapsed label from an ISO/Date string to `now` (ms since epoch). */
export function timeAgo(value: string | Date | null | undefined, now: number): string {
if (!value) return "";
const t = new Date(value).getTime();
if (Number.isNaN(t)) return "";
const s = Math.max(0, Math.floor((now - t) / 1000));
if (s < 60) return "just now";
const m = Math.floor(s / 60);
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
return `${Math.floor(h / 24)}d`;
}
export function describeJob( export function describeJob(
state: string, state: string,
stage: string, stage: string,
+55
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { POST, GET } from "./route"; import { POST, GET } from "./route";
import { prisma } from "@/lib/db";
function postReq(body: unknown) { function postReq(body: unknown) {
return new Request("http://localhost/api/requests", { return new Request("http://localhost/api/requests", {
@@ -55,4 +56,58 @@ describe("requests API", () => {
expect(body.requests[0].artist).toBe("B"); expect(body.requests[0].artist).toBe("B");
expect(body.requests[0].job.state).toBe("requested"); expect(body.requests[0].job.state).toBe("requested");
}); });
it("expands job detail with candidate summary and chosen candidate", async () => {
const created = await prisma.request.create({
data: {
artist: "Chosen Artist",
album: "Chosen Album",
job: { create: { state: "downloading", currentStage: "download", attempts: 2, error: "boom" } },
},
include: { job: true },
});
await prisma.candidate.create({
data: {
jobId: created.job!.id,
source: "qobuz",
format: "flac",
qualityClass: 1,
trackCount: 12,
confidence: 0.9,
sourceRef: "ref-1",
chosen: true,
},
});
await prisma.candidate.create({
data: {
jobId: created.job!.id,
source: "slskd",
format: "mp3",
qualityClass: 2,
trackCount: 12,
confidence: 0.5,
sourceRef: "ref-2",
chosen: false,
},
});
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json();
const found = body.requests.find((r: { id: string }) => r.id === created.id);
expect(found.job.candidateCount).toBe(2);
expect(found.job.chosen).toEqual({ source: "qobuz", format: "flac", trackCount: 12 });
expect(found.job.error).toBe("boom");
expect(found.job.attempts).toBe(2);
});
it("reports no chosen candidate and zero count when a job has none", async () => {
const res = await POST(postReq({ artist: "NoCand", album: "Album" }));
const created = await res.json();
const listRes = await GET();
const body = await listRes.json();
const found = body.requests.find((r: { id: string }) => r.id === created.id);
expect(found.job.candidateCount).toBe(0);
expect(found.job.chosen).toBeNull();
});
}); });
+20 -2
View File
@@ -36,7 +36,10 @@ export async function POST(request: Request) {
export async function GET() { export async function GET() {
const [requests, releases] = await Promise.all([ const [requests, releases] = await Promise.all([
prisma.request.findMany({ orderBy: { createdAt: "desc" }, include: { job: true } }), prisma.request.findMany({
orderBy: { createdAt: "desc" },
include: { job: { include: { candidates: { select: { source: true, format: true, trackCount: true, chosen: true } } } } },
}),
prisma.monitoredRelease.findMany({ select: { artistName: true, album: true, rgMbid: true } }), prisma.monitoredRelease.findMany({ select: { artistName: true, album: true, rgMbid: true } }),
]); ]);
const rgByKey = new Map(releases.map((mr) => [`${mr.artistName} ${mr.album}`, mr.rgMbid])); const rgByKey = new Map(releases.map((mr) => [`${mr.artistName} ${mr.album}`, mr.rgMbid]));
@@ -49,7 +52,22 @@ export async function GET() {
status: r.status, status: r.status,
createdAt: r.createdAt, createdAt: r.createdAt,
rgMbid: rgByKey.get(`${r.artist} ${r.album}`) ?? null, rgMbid: rgByKey.get(`${r.artist} ${r.album}`) ?? null,
job: r.job ? { state: r.job.state, currentStage: r.job.currentStage } : null, job: r.job
? (() => {
const cands = r.job.candidates;
const chosen = cands.find((c) => c.chosen) ?? null;
return {
state: r.job.state,
currentStage: r.job.currentStage,
attempts: r.job.attempts,
error: r.job.error,
claimedAt: r.job.claimedAt,
updatedAt: r.job.updatedAt,
candidateCount: cands.length,
chosen: chosen ? { source: chosen.source, format: chosen.format, trackCount: chosen.trackCount } : null,
};
})()
: null,
})), })),
}); });
} }
+76 -25
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { describeJob } from "./_ui/status"; import { describeJob, timeAgo } from "./_ui/status";
import { StatTiles } from "./_ui/stat-tiles"; import { StatTiles } from "./_ui/stat-tiles";
import { SectionHeader } from "./_ui/section-header"; import { SectionHeader } from "./_ui/section-header";
import { JobRow } from "./_ui/job-row"; import { JobRow } from "./_ui/job-row";
@@ -15,11 +15,31 @@ type Row = {
status: string; status: string;
createdAt?: string; createdAt?: string;
rgMbid?: string | null; rgMbid?: string | null;
job: { state: string; currentStage: string } | null; job: {
state: string;
currentStage: string;
attempts: number;
error: string | null;
claimedAt: string | null;
updatedAt: string | null;
candidateCount: number;
chosen: { source: string; format: string; trackCount: number } | null;
} | null;
}; };
function jobOf(r: Row) { function jobOf(r: Row) {
return r.job ?? { state: "requested", currentStage: "intake" }; return (
r.job ?? {
state: "requested",
currentStage: "intake",
attempts: 0,
error: null,
claimedAt: null,
updatedAt: null,
candidateCount: 0,
chosen: null,
}
);
} }
function pressedMark(r: Row): string { function pressedMark(r: Row): string {
@@ -101,28 +121,59 @@ export function Queue() {
<p className="empty">The press is quiet. Queue a release above, or send one over from Wanted or Discover.</p> <p className="empty">The press is quiet. Queue a release above, or send one over from Wanted or Discover.</p>
) : ( ) : (
<section className="floor"> <section className="floor">
{active.map((r) => { {(() => {
const j = jobOf(r); const now = Date.now();
const d = describeJob(j.state, j.currentStage); return active.map((r) => {
const attention = d.kind === "attention"; const j = jobOf(r);
return ( const d = describeJob(j.state, j.currentStage);
<JobRow const attention = d.kind === "attention";
key={r.id}
artist={r.artist} let meta: string | undefined;
album={r.album} if (j.state === "downloading" || j.state === "tagging") {
kind={d.kind} const parts: string[] = [];
label={d.label} if (j.chosen) parts.push(`${j.chosen.source} · ${j.chosen.format} · ${j.chosen.trackCount} tracks`);
step={d.step} const elapsed = timeAgo(j.claimedAt ?? j.updatedAt, now);
total={d.totalSteps} if (elapsed) parts.push(elapsed);
indeterminate={j.state === "matching" || j.state === "matched"} meta = parts.join(" · ") || undefined;
note={ } else if (j.state === "matching" || j.state === "matched") {
attention meta =
? "No source met the quality cutoff, or the pipeline stalled. Retry the request, or lower the cutoff for this release." j.candidateCount > 0
: undefined ? `${j.candidateCount} sources${j.chosen ? ` · best: ${j.chosen.source} ${j.chosen.format}` : ""}`
} : "Searching sources…";
/> } 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;
}
const note = attention
? j.error?.trim() ||
"No source met the quality cutoff, or the pipeline stalled. Retry the request, or lower the cutoff for this release."
: undefined;
const stageLabel =
attention || j.state === "matching" || j.state === "matched" ? undefined : j.currentStage;
return (
<JobRow
key={r.id}
artist={r.artist}
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}
/>
);
});
})()}
</section> </section>
)} )}