feat(web): surface Discover freshness ("last discovered N ago") + true in-progress
/discover felt "stale until I hit Discover now": it showed the result string but
never WHEN the sweep last ran, and "Discovering…" ended the moment the worker
claimed the request (requested→false) rather than when the sweep finished.
- /api/discover/run GET now also returns `inProgress` (discover.inProgress) and
`lastRunAt` (the discover.result Config row's updatedAt = last completion).
- The client treats requested||inProgress as "running", so "Discovering…"
persists for the whole sweep, and polls until both clear before refreshing.
- The tool row shows "Last discovered {N ago} · {result}", a sweeping message
while running, and a clear "Not yet run" empty state.
web 151 tests (inProgress + lastRunAt asserted), tsc + build clean. No worker
change (signals already written); NOT a page cache (would worsen staleness).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,10 +12,20 @@ describe("discover run API", () => {
|
|||||||
const body = await (await GET()).json();
|
const body = await (await GET()).json();
|
||||||
expect(body.requested).toBe(true);
|
expect(body.requested).toBe(true);
|
||||||
expect(body.result).toBeNull();
|
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" } });
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,12 +10,19 @@ export async function POST(_request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GET() {
|
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.requested" } }),
|
||||||
|
prisma.config.findUnique({ where: { key: "discover.inProgress" } }),
|
||||||
prisma.config.findUnique({ where: { key: "discover.result" } }),
|
prisma.config.findUnique({ where: { key: "discover.result" } }),
|
||||||
]);
|
]);
|
||||||
return Response.json({
|
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",
|
requested: requested?.value === "true",
|
||||||
|
inProgress: inProgress?.value === "true",
|
||||||
result: result?.value ?? null,
|
result: result?.value ?? null,
|
||||||
|
// when the last completed sweep wrote its result — the "last discovered N ago" signal.
|
||||||
|
lastRunAt: result?.updatedAt?.toISOString() ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { PageHead } from "../_ui/page-head";
|
import { PageHead } from "../_ui/page-head";
|
||||||
|
import { timeAgo } from "../_ui/status";
|
||||||
import { SectionHeader } from "../_ui/section-header";
|
import { SectionHeader } from "../_ui/section-header";
|
||||||
import { ArtistModal } from "../_ui/artist-modal";
|
import { ArtistModal } from "../_ui/artist-modal";
|
||||||
import { AlbumModal } from "../_ui/album-modal";
|
import { AlbumModal } from "../_ui/album-modal";
|
||||||
@@ -19,6 +20,12 @@ type Suggestion = {
|
|||||||
sources: string[];
|
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 Feed = { artists: Suggestion[]; albums: Suggestion[] };
|
||||||
type SearchResult = {
|
type SearchResult = {
|
||||||
seed: { mbid: string; name: string } | null;
|
seed: { mbid: string; name: string } | null;
|
||||||
@@ -28,6 +35,7 @@ type SearchResult = {
|
|||||||
export function DiscoverClient() {
|
export function DiscoverClient() {
|
||||||
const [feed, setFeed] = useState<Feed>({ artists: [], albums: [] });
|
const [feed, setFeed] = useState<Feed>({ artists: [], albums: [] });
|
||||||
const [result, setResult] = useState<string | null>(null);
|
const [result, setResult] = useState<string | null>(null);
|
||||||
|
const [lastRunAt, setLastRunAt] = useState<string | null>(null);
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [search, setSearch] = useState<SearchResult | null>(null);
|
const [search, setSearch] = useState<SearchResult | null>(null);
|
||||||
@@ -44,9 +52,10 @@ export function DiscoverClient() {
|
|||||||
loadFeed();
|
loadFeed();
|
||||||
fetch("/api/discover/run")
|
fetch("/api/discover/run")
|
||||||
.then((r) => r.json())
|
.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);
|
setResult(s.result);
|
||||||
setRunning(s.requested);
|
setLastRunAt(s.lastRunAt);
|
||||||
|
setRunning(s.requested || s.inProgress);
|
||||||
});
|
});
|
||||||
}, [loadFeed]);
|
}, [loadFeed]);
|
||||||
|
|
||||||
@@ -55,7 +64,9 @@ export function DiscoverClient() {
|
|||||||
const id = setInterval(async () => {
|
const id = setInterval(async () => {
|
||||||
const s = await (await fetch("/api/discover/run")).json();
|
const s = await (await fetch("/api/discover/run")).json();
|
||||||
setResult(s.result);
|
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);
|
setRunning(false);
|
||||||
loadFeed();
|
loadFeed();
|
||||||
}
|
}
|
||||||
@@ -109,7 +120,13 @@ export function DiscoverClient() {
|
|||||||
<button className="btn accent" onClick={discoverNow} disabled={running}>
|
<button className="btn accent" onClick={discoverNow} disabled={running}>
|
||||||
{running ? "Discovering…" : "Discover now"}
|
{running ? "Discovering…" : "Discover now"}
|
||||||
</button>
|
</button>
|
||||||
{result ? <span className="rmeta">last run · {result}</span> : null}
|
<span className="rmeta">
|
||||||
|
{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"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SectionHeader title="Find similar" />
|
<SectionHeader title="Find similar" />
|
||||||
|
|||||||
Reference in New Issue
Block a user