From 381426082eff1eae0fc020956a6a904e7b9b0b64 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Mon, 13 Jul 2026 22:05:07 +0200 Subject: [PATCH] feat(web): richer active-job rows on The Floor (source/format/tracks, stage, elapsed, error) Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/app/_ui/job-row.tsx | 4 +- web/src/app/_ui/progress-bar.tsx | 6 +- web/src/app/_ui/status.test.ts | 28 ++++++- web/src/app/_ui/status.ts | 14 ++++ web/src/app/api/requests/route.test.ts | 55 ++++++++++++++ web/src/app/api/requests/route.ts | 22 +++++- web/src/app/queue.tsx | 101 +++++++++++++++++++------ 7 files changed, 198 insertions(+), 32 deletions(-) diff --git a/web/src/app/_ui/job-row.tsx b/web/src/app/_ui/job-row.tsx index e667c27..c67196b 100644 --- a/web/src/app/_ui/job-row.tsx +++ b/web/src/app/_ui/job-row.tsx @@ -20,6 +20,7 @@ export function JobRow({ meta, note, indeterminate, + stageLabel, }: { artist: string; album: string; @@ -30,6 +31,7 @@ export function JobRow({ meta?: ReactNode; note?: ReactNode; indeterminate?: boolean; + stageLabel?: string; }) { return (
@@ -42,7 +44,7 @@ export function JobRow({ {note ? (
{note}
) : kind !== "attention" ? ( - + ) : null} diff --git a/web/src/app/_ui/progress-bar.tsx b/web/src/app/_ui/progress-bar.tsx index 069819d..a264c4e 100644 --- a/web/src/app/_ui/progress-bar.tsx +++ b/web/src/app/_ui/progress-bar.tsx @@ -7,11 +7,13 @@ export function ProgressBar({ step, total, indeterminate, + stageLabel, }: { kind: Kind; step: number; total: number; indeterminate?: boolean; + stageLabel?: string; }) { if (indeterminate) { return ( @@ -29,9 +31,7 @@ export function ProgressBar({
-
- {step}/{total} -
+
{stageLabel ? `${stageLabel} · step ${step} of ${total}` : `${step}/${total}`}
); } diff --git a/web/src/app/_ui/status.test.ts b/web/src/app/_ui/status.test.ts index 9a85ba5..0b65f53 100644 --- a/web/src/app/_ui/status.test.ts +++ b/web/src/app/_ui/status.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { describeJob } from "./status"; +import { describeJob, timeAgo } from "./status"; describe("describeJob", () => { it("maps pipeline states to literal labels + severity kind", () => { @@ -22,3 +22,29 @@ describe("describeJob", () => { 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(""); + }); +}); diff --git a/web/src/app/_ui/status.ts b/web/src/app/_ui/status.ts index 0252ebc..82878db 100644 --- a/web/src/app/_ui/status.ts +++ b/web/src/app/_ui/status.ts @@ -15,6 +15,20 @@ const STATE_MAP: Record = { 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( state: string, stage: string, diff --git a/web/src/app/api/requests/route.test.ts b/web/src/app/api/requests/route.test.ts index 753b76f..26dc6cd 100644 --- a/web/src/app/api/requests/route.test.ts +++ b/web/src/app/api/requests/route.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { POST, GET } from "./route"; +import { prisma } from "@/lib/db"; function postReq(body: unknown) { 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].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(); + }); }); diff --git a/web/src/app/api/requests/route.ts b/web/src/app/api/requests/route.ts index bb2d339..d7ac3a4 100644 --- a/web/src/app/api/requests/route.ts +++ b/web/src/app/api/requests/route.ts @@ -36,7 +36,10 @@ export async function POST(request: Request) { export async function GET() { 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 } }), ]); const rgByKey = new Map(releases.map((mr) => [`${mr.artistName} ${mr.album}`, mr.rgMbid])); @@ -49,7 +52,22 @@ export async function GET() { status: r.status, createdAt: r.createdAt, 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, })), }); } diff --git a/web/src/app/queue.tsx b/web/src/app/queue.tsx index 779ebc3..ec7b55a 100644 --- a/web/src/app/queue.tsx +++ b/web/src/app/queue.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { describeJob } from "./_ui/status"; +import { describeJob, timeAgo } from "./_ui/status"; import { StatTiles } from "./_ui/stat-tiles"; import { SectionHeader } from "./_ui/section-header"; import { JobRow } from "./_ui/job-row"; @@ -15,11 +15,31 @@ type Row = { status: string; createdAt?: string; 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) { - 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 { @@ -101,28 +121,59 @@ export function Queue() {

The press is quiet. Queue a release above, or send one over from Wanted or Discover.

) : (
- {active.map((r) => { - const j = jobOf(r); - const d = describeJob(j.state, j.currentStage); - const attention = d.kind === "attention"; - return ( - - ); - })} + {(() => { + const now = Date.now(); + return active.map((r) => { + const j = jobOf(r); + const d = describeJob(j.state, j.currentStage); + const attention = d.kind === "attention"; + + 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`); + const elapsed = timeAgo(j.claimedAt ?? j.updatedAt, now); + if (elapsed) parts.push(elapsed); + meta = parts.join(" · ") || undefined; + } else if (j.state === "matching" || j.state === "matched") { + meta = + j.candidateCount > 0 + ? `${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 ( + + ); + }); + })()}
)}