diff --git a/web/src/app/api/discover/run/route.test.ts b/web/src/app/api/discover/run/route.test.ts index 5b8682a..99a9ec4 100644 --- a/web/src/app/api/discover/run/route.test.ts +++ b/web/src/app/api/discover/run/route.test.ts @@ -12,10 +12,20 @@ describe("discover run API", () => { const body = await (await GET()).json(); expect(body.requested).toBe(true); expect(body.result).toBeNull(); + expect(body.inProgress).toBe(false); + expect(body.lastRunAt).toBeNull(); }); - it("GET returns discover.result when the worker has written it", async () => { + it("GET returns discover.result + lastRunAt when the worker has written it", async () => { await prisma.config.create({ data: { key: "discover.result", value: "artists 3, albums 1" } }); - expect((await (await GET()).json()).result).toBe("artists 3, albums 1"); + const body = await (await GET()).json(); + expect(body.result).toBe("artists 3, albums 1"); + expect(body.lastRunAt).not.toBeNull(); + expect(Number.isNaN(Date.parse(body.lastRunAt))).toBe(false); // a real ISO timestamp + }); + + it("GET reports inProgress from discover.inProgress", async () => { + await prisma.config.create({ data: { key: "discover.inProgress", value: "true" } }); + expect((await (await GET()).json()).inProgress).toBe(true); }); }); diff --git a/web/src/app/api/discover/run/route.ts b/web/src/app/api/discover/run/route.ts index 8aadfad..d6c9e05 100644 --- a/web/src/app/api/discover/run/route.ts +++ b/web/src/app/api/discover/run/route.ts @@ -10,12 +10,19 @@ export async function POST(_request: Request) { } export async function GET() { - const [requested, result] = await Promise.all([ + const [requested, inProgress, result] = await Promise.all([ prisma.config.findUnique({ where: { key: "discover.requested" } }), + prisma.config.findUnique({ where: { key: "discover.inProgress" } }), prisma.config.findUnique({ where: { key: "discover.result" } }), ]); return Response.json({ + // a sweep is either queued (requested) or actively running (inProgress) — the UI treats + // both as "running" so "Discovering…" persists for the whole sweep, not just until the + // worker claims the request. requested: requested?.value === "true", + inProgress: inProgress?.value === "true", result: result?.value ?? null, + // when the last completed sweep wrote its result — the "last discovered N ago" signal. + lastRunAt: result?.updatedAt?.toISOString() ?? null, }); } diff --git a/web/src/app/discover/discover-client.tsx b/web/src/app/discover/discover-client.tsx index 9085a49..4e890f9 100644 --- a/web/src/app/discover/discover-client.tsx +++ b/web/src/app/discover/discover-client.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { PageHead } from "../_ui/page-head"; +import { timeAgo } from "../_ui/status"; import { SectionHeader } from "../_ui/section-header"; import { ArtistModal } from "../_ui/artist-modal"; import { AlbumModal } from "../_ui/album-modal"; @@ -19,6 +20,12 @@ type Suggestion = { sources: string[]; }; +/** "just now" / "3h ago" / "2d ago" — timeAgo has no suffix, so add one (except "just now"). */ +function agoLabel(value: string): string { + const a = timeAgo(value, Date.now()); + return !a || a === "just now" ? a || "recently" : `${a} ago`; +} + type Feed = { artists: Suggestion[]; albums: Suggestion[] }; type SearchResult = { seed: { mbid: string; name: string } | null; @@ -28,6 +35,7 @@ type SearchResult = { export function DiscoverClient() { const [feed, setFeed] = useState({ artists: [], albums: [] }); const [result, setResult] = useState(null); + const [lastRunAt, setLastRunAt] = useState(null); const [running, setRunning] = useState(false); const [query, setQuery] = useState(""); const [search, setSearch] = useState(null); @@ -44,9 +52,10 @@ export function DiscoverClient() { loadFeed(); fetch("/api/discover/run") .then((r) => r.json()) - .then((s: { result: string | null; requested: boolean }) => { + .then((s: { result: string | null; requested: boolean; inProgress: boolean; lastRunAt: string | null }) => { setResult(s.result); - setRunning(s.requested); + setLastRunAt(s.lastRunAt); + setRunning(s.requested || s.inProgress); }); }, [loadFeed]); @@ -55,7 +64,9 @@ export function DiscoverClient() { const id = setInterval(async () => { const s = await (await fetch("/api/discover/run")).json(); setResult(s.result); - if (!s.requested) { + setLastRunAt(s.lastRunAt); + if (!s.requested && !s.inProgress) { + // the sweep finished (neither queued nor in progress) — refresh the feed setRunning(false); loadFeed(); } @@ -109,7 +120,13 @@ export function DiscoverClient() { - {result ? last run · {result} : null} + + {running + ? "Sweeping your followed artists for new suggestions…" + : lastRunAt + ? `Last discovered ${agoLabel(lastRunAt)}${result ? ` · ${result}` : ""}` + : "Not yet run — press Discover now to sweep your followed artists"} +