commit e314a77e0343147a7ea68089ca4423c45f076b90
parent 3aac3716b16db8a0f62a2932826589fa8febd357
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 28 Aug 2026 03:02:58 -0400
channels: the report column and its refresh live with the channels; cleanup: the two cleanup tables live with the bytes
Two homes, one commit, because they are the same move: the sections /actionable
carried that were never about an operation.
/channels gets the reports. Every count and every band on a channel row is
projected from that channel's last report, so the report's age is the caveat on
all of them — it belongs in the row, not on a page listing the channels whose
caveat applies. New "Report" column after "Last sync", showing `stale` /
`missing` in warning colour or the generation time when current, sortable
(ascending by default, missing sorts oldest, so the untrustworthy rows come
first). The row's Actions cell gains "Refresh report" and the page header gains
"Update all reports" beside "Sync all" — the action a row's marker asks for,
next to the marker. `RefreshAllReportsButton` and
`refreshAllChannelSnapshotsAction` move to `channels/`; the per-row
`refreshChannelSnapshotAction` now revalidates `/channels` and the operation
pages too, so the marker flips where it was clicked. `reportStateOf` is the new
three-state helper in the census; `isStaleOrMissing` is it with the two
non-current states collapsed, which is what it always was.
/cleanup gets the two cleanup tables — `clean-transcribed-audio` and
`clean-extra-formats`, the sections whose `operation` is null because what they
are about is bytes. They render below the per-channel cards: the page is an
argument in order (hero, sieve, release ledger, cards) and these are the "do
it" step at the end of it. Deliberately NOT merged into ChannelCleanupCard, and
the comment says why. The card's own "Clean audio" is a StreamActionLog whose
button carries no aria-label, so the table's `clean audio <slug>` button is
still the only match for that name.
The two "Refresh reports" links (the cleanup footnote and HoldSieve's) point at
/channels. The text is unchanged — cleanup-holds.spec asserts it.
Specs follow their sections: `channel-work.spec.ts`'s stale-report, update-all
and refresh-report cases go to /channels (the stale case now reads the Report
cell by `report age for <slug>`, and the refresh case drops its `.first()` —
there is one such button per row now, not one per section). `cleanup-actionable`
goes to /cleanup and keeps its name. `site-scope`'s pool-view case re-homes
there too: /cleanup lists every channel snapshot or not, and reads no `site`
searchParam, which is the property that case is actually about. And the
sidebar case inverts — Actionable is asserted GONE from the nav.
Nothing under transcripts/ was read or written for this commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
14 files changed, 290 insertions(+), 200 deletions(-)
diff --git a/editor/app/actionable/components/RefreshAllReportsButton.tsx b/editor/app/actionable/components/RefreshAllReportsButton.tsx
@@ -1,74 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import { useState } from "react";
-import {
- refreshAllChannelSnapshotsAction,
- type RefreshAllResult,
-} from "../../lib/actionable/actions";
-
-type Status =
- | { kind: "idle" }
- | { kind: "running" }
- | { kind: "done"; result: RefreshAllResult }
- | { kind: "error"; message: string };
-
-export function RefreshAllReportsButton() {
- const [status, setStatus] = useState<Status>({ kind: "idle" });
-
- async function handleClick() {
- setStatus({ kind: "running" });
- try {
- const result = await refreshAllChannelSnapshotsAction();
- setStatus({ kind: "done", result });
- } catch (e) {
- setStatus({ kind: "error", message: (e as Error).message });
- }
- }
-
- const running = status.kind === "running";
- return (
- <div className="flex items-center gap-2">
- <button
- type="button"
- onClick={handleClick}
- disabled={running}
- aria-label="update all reports"
- className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50"
- >
- {running ? "Updating…" : "Update all reports"}
- </button>
- {status.kind === "done" && (
- <span
- aria-label="update all reports result"
- className="text-xs text-muted-foreground"
- title={
- status.result.skipped.length === 0
- ? undefined
- : status.result.skipped
- .map((s) => `${s.slug}: ${s.reason}`)
- .join("\n")
- }
- >
- Queued {status.result.queued.length} · skipped{" "}
- {status.result.skipped.length} ·{" "}
- <Link
- href="/jobs"
- className="underline hover:text-foreground"
- >
- view jobs
- </Link>
- </span>
- )}
- {status.kind === "error" && (
- <span
- role="alert"
- aria-label="update all reports error"
- className="text-xs text-destructive"
- >
- {status.message}
- </span>
- )}
- </div>
- );
-}
diff --git a/editor/app/actionable/page.tsx b/editor/app/actionable/page.tsx
@@ -18,7 +18,7 @@ import {
} from "../components/channelWork/sections";
import { ChannelWorkTable } from "../components/channelWork/ChannelWorkTable";
import { InlineActionButton } from "../components/actions/InlineActionButton";
-import { RefreshAllReportsButton } from "./components/RefreshAllReportsButton";
+import { RefreshAllReportsButton } from "../channels/components/RefreshAllReportsButton";
import { RunDuplicateDetectionButton } from "./components/RunDuplicateDetectionButton";
import { RunMediaScanButton } from "./components/RunMediaScanButton";
import { MediaScanFindingRow } from "./components/MediaScanFindingRow";
diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts
@@ -21,9 +21,14 @@ import {
writeChannelConfig,
} from "yt-dlp-transcript-common/controller/channels";
import { renameChannel } from "yt-dlp-transcript-common/controller/renameChannel";
-import { generateChannelSnapshot } from "yt-dlp-transcript-common/controller/channelSnapshot";
+import {
+ excludedDownloadIdSet,
+ generateChannelSnapshot,
+} from "yt-dlp-transcript-common/controller/channelSnapshot";
import { requestChannelSnapshot } from "yt-dlp-transcript-common/jobs/snapshotScheduler";
import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
+import { runManagedFunction } from "yt-dlp-transcript-common/jobs/streamCommand";
+import { drainStream } from "yt-dlp-transcript-common/jobs/drainStream";
import type { Site } from "yt-dlp-transcript-common/lib/site";
import { activeSyncSlugs } from "yt-dlp-transcript-common/jobs/syncJobs";
import {
@@ -300,9 +305,93 @@ export async function refreshChannelSnapshotAction(
}
await generateChannelSnapshot(paths, slug);
revalidatePath(`/channels/${slug}`);
+ // The Report column on /channels is read off this snapshot, and the row
+ // action sits next to the marker it flips — so revalidate the list too, not
+ // just the channel page. The operation pages draw the same census.
+ revalidatePath("/channels");
+ revalidatePath("/operations/[id]", "page");
return undefined;
}
+// EVERY CHANNEL'S REPORT, from the page that owns the reports. Lives here
+// rather than beside the actionable census because it is a /channels control:
+// the header button next to "Sync all", and the per-row refresh under it.
+export type RefreshAllResult = {
+ queued: string[];
+ skipped: { slug: string; reason: string }[];
+};
+
+export async function refreshAllChannelSnapshotsAction(): Promise<RefreshAllResult> {
+ const paths = getPaths();
+ const channels = await listChannelConfigs(paths);
+ const active = new Set(
+ getRegistry()
+ .list()
+ .filter(
+ (j) =>
+ j.kind === "refresh-report" &&
+ (j.status === "queued" || j.status === "running") &&
+ j.channelSlug,
+ )
+ .map((j) => j.channelSlug as string),
+ );
+ const queued: string[] = [];
+ const skipped: { slug: string; reason: string }[] = [];
+ const streams: ReadableStream<string>[] = [];
+ for (const c of channels) {
+ if (active.has(c.slug)) {
+ skipped.push({ slug: c.slug, reason: "already running" });
+ continue;
+ }
+ const result = await runManagedFunction({
+ kind: "refresh-report",
+ // Empty queueKey: bypass queue serialization. Snapshot regen is a
+ // local filesystem scan that never touches the platform, so there's
+ // no reason for it to wait behind sync/download work. See
+ // registry.ts:69-72 for the documented escape hatch.
+ queueKey: "",
+ paths,
+ channelSlug: c.slug,
+ fn: async (onLog) => {
+ onLog(`Regenerating report for ${c.slug}…`);
+ const snap = await generateChannelSnapshot(paths, c.slug);
+ const excluded = excludedDownloadIdSet(snap);
+ const awaitingTranscription = excluded.size
+ ? snap.buckets.downloadedNoTranscript.filter(
+ (id) => !excluded.has(id),
+ ).length
+ : snap.buckets.downloadedNoTranscript.length;
+ onLog(
+ `Done. ${snap.totals.videos} videos · ` +
+ `${snap.undownloadedIds.length} undownloaded · ` +
+ `${awaitingTranscription} awaiting transcription.`,
+ );
+ // Deliberately no revalidatePath here — calling it from a
+ // background fn races with the in-flight re-render that the action's
+ // own revalidatePath triggers. The action's single revalidate at the
+ // end picks up every fresh snapshot.
+ },
+ });
+ if (!result.ok) {
+ skipped.push({ slug: c.slug, reason: result.error });
+ continue;
+ }
+ queued.push(c.slug);
+ streams.push(result.stream);
+ }
+ // Wait for all snapshots to finish writing before revalidating so the
+ // pages that read the snapshots read fresh counts. With queueKey === "" the
+ // jobs all run in parallel, so this waits roughly the time of the
+ // slowest snapshot, not the sum.
+ await Promise.all(streams.map(drainStream));
+ revalidatePath("/actionable");
+ revalidatePath("/channels");
+ revalidatePath("/operations/[id]", "page");
+ revalidatePath("/cleanup");
+ revalidatePath("/");
+ return { queued, skipped };
+}
+
export async function toggleChannelBuildInclusionAction(
slug: string,
): Promise<ActionResult> {
diff --git a/editor/app/channels/components/ChannelsTable.tsx b/editor/app/channels/components/ChannelsTable.tsx
@@ -17,12 +17,17 @@ import { ChannelAvailabilityButton } from "./ChannelAvailabilityButton";
import { ChannelBuildToggle } from "./ChannelBuildToggle";
import { ChannelSyncButton } from "./ChannelSyncButton";
import { ChannelSyncToggle } from "./ChannelSyncToggle";
+import { InlineActionButton } from "../../components/actions/InlineActionButton";
// A row is a stat plus its pipeline bands, in column order. The bands are
// projected on the server from the same snapshot the counts come from, so a
// figure in a band and the count beside it cannot disagree.
export type ChannelRow = ChannelStat & {
pipelines: OperationBand[];
+ // How old this channel's report is. Every count and every band on this row is
+ // projected from that report, so its age is the caveat on all of them — which
+ // is why it belongs beside them rather than on a page of its own.
+ report: { generatedAt: string | null; state: "current" | "stale" | "missing" };
};
// A column heading for one pipeline. Comes off the operation registry on the
@@ -54,6 +59,7 @@ type SortKey =
| "sync"
| "playlist"
| "lastSync"
+ | "report"
| `op:${string}`;
type SortDir = "asc" | "desc";
@@ -68,6 +74,9 @@ const DEFAULT_DIR: Record<string, SortDir> = {
sync: "asc",
playlist: "desc",
lastSync: "asc",
+ // Ascending, and missing dates sort as oldest: the first click puts the
+ // channels whose numbers cannot be trusted at the top.
+ report: "asc",
};
// A pipeline column defaults to `reachable` descending: the first click puts the
@@ -143,6 +152,11 @@ function cmp(a: ChannelRow, b: ChannelRow, key: SortKey): number {
return compareNumbers(a.playlistCount, b.playlistCount);
case "lastSync":
return compareDates(a.config.lastSyncedAt, b.config.lastSyncedAt);
+ case "report":
+ return compareDates(
+ a.report.generatedAt ?? undefined,
+ b.report.generatedAt ?? undefined,
+ );
default:
return 0;
}
@@ -192,8 +206,8 @@ export function ChannelsTable({
[channels],
);
const showSections = grouped && !!sections && sections.length > 0 && !!siteId;
- // Seven fixed columns, one per pipeline, then Actions.
- const colSpan = 8 + columns.length;
+ // Eight fixed columns, one per pipeline, then Actions.
+ const colSpan = 9 + columns.length;
function onHeaderClick(key: SortKey) {
setSort((prev) => {
@@ -264,6 +278,14 @@ export function ChannelsTable({
onClick={onHeaderClick}
className="whitespace-nowrap"
/>
+ <SortableTh
+ label="Report"
+ sortKey="report"
+ sort={sort}
+ onClick={onHeaderClick}
+ className="whitespace-nowrap"
+ title="When this channel's report was last generated. Every count and band on the row is read from it — stale or missing means those numbers may be wrong."
+ />
{columns.map((col) => (
<SortableTh
key={col.id}
@@ -418,6 +440,18 @@ function ChannelTableRow({
? new Date(c.config.lastSyncedAt).toLocaleString()
: ""}
</Td>
+ <Td
+ ariaLabel={`report age for ${c.slug}`}
+ className={`text-xs whitespace-nowrap ${
+ c.report.state === "current" ? "text-muted-foreground" : "text-warning"
+ }`}
+ >
+ {c.report.state === "current"
+ ? c.report.generatedAt
+ ? new Date(c.report.generatedAt).toLocaleString()
+ : ""
+ : c.report.state}
+ </Td>
{columns.map((col) => (
<PipelineCell
key={col.id}
@@ -437,6 +471,7 @@ function ChannelTableRow({
<div className="flex items-center gap-2">
<ChannelSyncButton slug={c.slug} disabled={!c.config.url} />
<ChannelAvailabilityButton slug={c.slug} disabled={!c.config.url} />
+ <InlineActionButton variant={{ kind: "refreshReport", slug: c.slug }} />
</div>
</Td>
</tr>
diff --git a/editor/app/channels/components/RefreshAllReportsButton.tsx b/editor/app/channels/components/RefreshAllReportsButton.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import Link from "next/link";
+import { useState } from "react";
+import {
+ refreshAllChannelSnapshotsAction,
+ type RefreshAllResult,
+} from "../actions";
+
+type Status =
+ | { kind: "idle" }
+ | { kind: "running" }
+ | { kind: "done"; result: RefreshAllResult }
+ | { kind: "error"; message: string };
+
+export function RefreshAllReportsButton() {
+ const [status, setStatus] = useState<Status>({ kind: "idle" });
+
+ async function handleClick() {
+ setStatus({ kind: "running" });
+ try {
+ const result = await refreshAllChannelSnapshotsAction();
+ setStatus({ kind: "done", result });
+ } catch (e) {
+ setStatus({ kind: "error", message: (e as Error).message });
+ }
+ }
+
+ const running = status.kind === "running";
+ return (
+ <div className="flex items-center gap-2">
+ <button
+ type="button"
+ onClick={handleClick}
+ disabled={running}
+ aria-label="update all reports"
+ className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50"
+ >
+ {running ? "Updating…" : "Update all reports"}
+ </button>
+ {status.kind === "done" && (
+ <span
+ aria-label="update all reports result"
+ className="text-xs text-muted-foreground"
+ title={
+ status.result.skipped.length === 0
+ ? undefined
+ : status.result.skipped
+ .map((s) => `${s.slug}: ${s.reason}`)
+ .join("\n")
+ }
+ >
+ Queued {status.result.queued.length} · skipped{" "}
+ {status.result.skipped.length} ·{" "}
+ <Link
+ href="/jobs"
+ className="underline hover:text-foreground"
+ >
+ view jobs
+ </Link>
+ </span>
+ )}
+ {status.kind === "error" && (
+ <span
+ role="alert"
+ aria-label="update all reports error"
+ className="text-xs text-destructive"
+ >
+ {status.message}
+ </span>
+ )}
+ </div>
+ );
+}
diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx
@@ -27,6 +27,8 @@ import {
} from "./components/ChannelsTable";
import { buildChannelGroupSections } from "./lib/channelGroupSections";
import { SyncAllChannelsButton } from "./components/SyncAllChannelsButton";
+import { RefreshAllReportsButton } from "./components/RefreshAllReportsButton";
+import { reportStateOf } from "../lib/actionable/loadActionable";
import { resolveActiveSite } from "../lib/activeSite";
export const dynamic = "force-dynamic";
@@ -131,10 +133,18 @@ export default async function ChannelsPage({
// another channel's bands is exactly the kind of silent wrongness this whole
// change exists to remove.
const snapshots = new Map(briefs.map((b) => [b.slug, b.snapshot]));
- const all: ChannelRow[] = stats.map((stat) => ({
- ...stat,
- pipelines: buildChannelBands(snapshots.get(stat.slug) ?? null, ids),
- }));
+ const briefBySlug = new Map(briefs.map((b) => [b.slug, b]));
+ const all: ChannelRow[] = stats.map((stat) => {
+ const brief = briefBySlug.get(stat.slug);
+ return {
+ ...stat,
+ pipelines: buildChannelBands(snapshots.get(stat.slug) ?? null, ids),
+ report: {
+ generatedAt: brief?.snapshot?.generatedAt ?? null,
+ state: brief ? reportStateOf(brief) : ("missing" as const),
+ },
+ };
+ });
// Scope to the active site's membership; "all sites" shows the full pool.
//
// Groups PARTITION one site's channels, so they can only be rendered when a
@@ -157,6 +167,7 @@ export default async function ChannelsPage({
<h1 className="text-2xl font-semibold">Channels</h1>
<div className="flex items-center gap-2">
<SyncAllChannelsButton />
+ <RefreshAllReportsButton />
<Link
href="/channels/new"
className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90"
@@ -197,8 +208,8 @@ export default async function ChannelsPage({
) : (
<>
No channel on this page has a generated report yet, so every
- pipeline reads empty. Run <em>Refresh report</em> from a channel
- to populate them.
+ pipeline reads empty. Run <em>Refresh report</em> from a row
+ here, or <em>Update all reports</em> above, to populate them.
</>
)}
{freshness.missing.length > 0 && freshness.oldest && (
diff --git a/editor/app/cleanup/components/HoldSieve.tsx b/editor/app/cleanup/components/HoldSieve.tsx
@@ -304,7 +304,7 @@ function Eyebrow({ children }: { children: React.ReactNode }) {
function RefreshLink() {
return (
- <Link href="/actionable" className="underline hover:text-foreground">
+ <Link href="/channels" className="underline hover:text-foreground">
Refresh reports
</Link>
);
diff --git a/editor/app/cleanup/page.tsx b/editor/app/cleanup/page.tsx
@@ -7,6 +7,9 @@ import { buildCleanupLedger, loadCleanupSummary } from "./lib/loadCleanup";
import { ChannelCleanupCard } from "./components/ChannelCleanupCard";
import { HoldSieve } from "./components/HoldSieve";
import { ReleaseLedger } from "./components/ReleaseLedger";
+import { ChannelWorkTable } from "../components/channelWork/ChannelWorkTable";
+import { sectionsFor } from "../components/channelWork/sections";
+import { getActionableSummary } from "../lib/actionable/loadActionable";
export const dynamic = "force-dynamic";
@@ -15,6 +18,10 @@ export const metadata: Metadata = { title: "Cleanup" };
export default async function CleanupPage() {
const paths = getPaths();
const summary = await loadCleanupSummary(paths);
+ // The same request-cached briefs loadCleanupSummary just read, folded a
+ // second way. The two cleanup sections are the only channel-work sections
+ // that belong to no operation: they are bytes, and bytes are this page.
+ const actionable = await getActionableSummary(paths);
const {
rows,
includedReclaimBytes,
@@ -138,9 +145,19 @@ export default async function CleanupPage() {
</section>
)}
+ {/* BELOW the cards, because the page is an argument in order: hero, then
+ the sieve, then the release ledger, then the per-channel cards — and
+ these tables are the "do it" step at the end of it.
+
+ NOT merged into ChannelCleanupCard, deliberately, and out of scope for
+ this slice: the card is per-channel and these are per-bucket lists
+ across channels, and collapsing one into the other is a redesign of
+ the card, not a move. */}
+ <ChannelWorkTable sections={sectionsFor(null)} summary={actionable} />
+
<p className="text-xs text-muted-foreground">
Estimates come from each channel's last report — run{" "}
- <Link href="/actionable" className="underline hover:text-foreground">
+ <Link href="/channels" className="underline hover:text-foreground">
Refresh reports
</Link>{" "}
to recompute. Whole-channel and per-video deletion stay on the{" "}
diff --git a/editor/app/components/CommandPalette.tsx b/editor/app/components/CommandPalette.tsx
@@ -23,11 +23,11 @@ import {
} from "yt-dlp-transcript-common/components/ui/command";
import { toast } from "yt-dlp-transcript-common/components/ui/sonner";
import { NAV_GROUPS } from "../lib/nav";
-import { syncAllChannelsAction } from "../channels/actions";
import {
refreshAllChannelSnapshotsAction,
- runDuplicateDetectionAction,
-} from "../lib/actionable/actions";
+ syncAllChannelsAction,
+} from "../channels/actions";
+import { runDuplicateDetectionAction } from "../lib/actionable/actions";
import { retryAllFailedAction, drainAllAction } from "../jobs/actions";
import {
pauseAllWorkersAction,
diff --git a/editor/app/lib/actionable/actions.ts b/editor/app/lib/actionable/actions.ts
@@ -2,12 +2,6 @@
import { revalidatePath } from "next/cache";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { listChannelConfigs } from "yt-dlp-transcript-common/controller/channels";
-import {
- excludedDownloadIdSet,
- generateChannelSnapshot,
-} from "yt-dlp-transcript-common/controller/channelSnapshot";
-import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
import { runManagedFunction } from "yt-dlp-transcript-common/jobs/streamCommand";
import { drainStream } from "yt-dlp-transcript-common/jobs/drainStream";
import {
@@ -28,88 +22,12 @@ import {
} from "../../channels/[slug]/incompleteTranscriptActions";
import { loadActionableSummary } from "./loadActionable";
-export type RefreshAllResult = {
- queued: string[];
- skipped: { slug: string; reason: string }[];
-};
-
export type DuplicateScope = "shorts" | "all";
export type RunDuplicateDetectionResult =
| { ok: true; clusters: number; videosInClusters: number }
| { ok: false; error: string };
-export async function refreshAllChannelSnapshotsAction(): Promise<RefreshAllResult> {
- const paths = getPaths();
- const channels = await listChannelConfigs(paths);
- const active = new Set(
- getRegistry()
- .list()
- .filter(
- (j) =>
- j.kind === "refresh-report" &&
- (j.status === "queued" || j.status === "running") &&
- j.channelSlug,
- )
- .map((j) => j.channelSlug as string),
- );
- const queued: string[] = [];
- const skipped: { slug: string; reason: string }[] = [];
- const streams: ReadableStream<string>[] = [];
- for (const c of channels) {
- if (active.has(c.slug)) {
- skipped.push({ slug: c.slug, reason: "already running" });
- continue;
- }
- const result = await runManagedFunction({
- kind: "refresh-report",
- // Empty queueKey: bypass queue serialization. Snapshot regen is a
- // local filesystem scan that never touches the platform, so there's
- // no reason for it to wait behind sync/download work. See
- // registry.ts:69-72 for the documented escape hatch.
- queueKey: "",
- paths,
- channelSlug: c.slug,
- fn: async (onLog) => {
- onLog(`Regenerating report for ${c.slug}…`);
- const snap = await generateChannelSnapshot(paths, c.slug);
- const excluded = excludedDownloadIdSet(snap);
- const awaitingTranscription = excluded.size
- ? snap.buckets.downloadedNoTranscript.filter(
- (id) => !excluded.has(id),
- ).length
- : snap.buckets.downloadedNoTranscript.length;
- onLog(
- `Done. ${snap.totals.videos} videos · ` +
- `${snap.undownloadedIds.length} undownloaded · ` +
- `${awaitingTranscription} awaiting transcription.`,
- );
- // Deliberately no revalidatePath here — calling it from a
- // background fn races with the in-flight re-render that the action's
- // own revalidatePath triggers. The action's single revalidate at the
- // end picks up every fresh snapshot.
- },
- });
- if (!result.ok) {
- skipped.push({ slug: c.slug, reason: result.error });
- continue;
- }
- queued.push(c.slug);
- streams.push(result.stream);
- }
- // Wait for all snapshots to finish writing before revalidating so the
- // pages that read the snapshots read fresh counts. With queueKey === "" the
- // jobs all run in parallel, so this waits roughly the time of the
- // slowest snapshot, not the sum.
- await Promise.all(streams.map(drainStream));
- revalidatePath("/actionable");
- revalidatePath("/channels");
- revalidatePath("/operations/[id]", "page");
- revalidatePath("/cleanup");
- revalidatePath("/");
- return { queued, skipped };
-}
-
// Runs the global cross-platform duplicate-shorts pass. `scope: "all"` removes
// the duration cutoff (a heavier one-off run that also surfaces clip-of-longer
// containment). Reads the cues + statsByPath written by build:index/build:stats,
diff --git a/editor/app/lib/actionable/loadActionable.ts b/editor/app/lib/actionable/loadActionable.ts
@@ -33,11 +33,27 @@ export type ActionableSummary = {
backfill: ActionableRow[];
};
+// How old a channel's report is, in the three states /channels draws:
+// "missing" (never generated), "stale" (older than the last sync — so every
+// count read off it may be wrong) and "current".
+//
+// Takes a brief rather than an ActionableRow because /channels holds briefs and
+// the two carry the same two fields; `isStaleOrMissing` is this function with
+// the two non-current states collapsed.
+export function reportStateOf(
+ brief: Pick<ChannelBrief, "config" | "snapshot">,
+): "current" | "stale" | "missing" {
+ if (!brief.snapshot) return "missing";
+ const synced = brief.config.lastSyncedAt;
+ if (!synced) return "current";
+ return new Date(synced).getTime() >
+ new Date(brief.snapshot.generatedAt).getTime()
+ ? "stale"
+ : "current";
+}
+
export function isStaleOrMissing(row: ActionableRow): boolean {
- if (!row.snapshot) return true;
- const synced = row.channel.config.lastSyncedAt;
- if (!synced) return false;
- return new Date(synced).getTime() > new Date(row.snapshot.generatedAt).getTime();
+ return reportStateOf(row.channel) !== "current";
}
// Counts that drive the actionable lists exclude IDs that the availability
diff --git a/editor/e2e/channel-work.spec.ts b/editor/e2e/channel-work.spec.ts
@@ -32,12 +32,12 @@ test("a section with no work says so on its operation's page", async ({
);
});
-test("Actionable nav link is wired", async ({ page }) => {
+test("Actionable is gone from the sidebar", async ({ page }) => {
await resetData("empty");
await page.goto("/");
await expect(
page.locator("aside").getByRole("link", { name: /^Actionable$/ }),
- ).toHaveAttribute("href", "/actionable");
+ ).toHaveCount(0);
});
test("populates the undownloaded section after a snapshot exists", async ({
@@ -106,22 +106,17 @@ test("flags channels whose report is older than the last sync", async ({
undownloadedIds: [],
});
- await page.goto("/actionable");
- const section = page.getByRole("region", {
- name: "stale-reports",
- exact: true,
- });
- await expect(section).toBeVisible();
- await expect(
- section.getByLabel("stale-reports row test-youtube"),
- ).toContainText("stale");
+ await page.goto("/channels");
+ await expect(page.getByLabel("report age for test-youtube")).toContainText(
+ "stale",
+ );
});
test("'Update all reports' queues a refresh-report job per channel", async ({
page,
}) => {
await resetData("one-youtube-channel");
- await page.goto("/actionable");
+ await page.goto("/channels");
await page.getByRole("button", { name: "update all reports" }).click();
await expect(page.getByLabel("update all reports result")).toContainText(
/Queued 1/,
@@ -165,10 +160,10 @@ test("syncing a channel auto-regenerates its report", async ({ page }) => {
const first = await readJson<{ generatedAt: string }>(TR_SNAPSHOT_REL);
await new Promise((r) => setTimeout(r, 50)); // ensure a distinct ISO timestamp
- await page.goto("/actionable");
- const refresh = page
- .getByRole("button", { name: "refresh report test-transcribe" })
- .first();
+ await page.goto("/channels");
+ const refresh = page.getByRole("button", {
+ name: "refresh report test-transcribe",
+ });
await refresh.waitFor({ state: "visible" });
// Retried, and the timeout is generous: a click landing before React hydrates
// fires NOTHING — no request, no job, no error — which is the long-standing
diff --git a/editor/e2e/cleanup-actionable.spec.ts b/editor/e2e/cleanup-actionable.spec.ts
@@ -16,6 +16,10 @@ async function writeJson(relPath: string, value: unknown): Promise<void> {
// Seed a snapshot with the two cleanup buckets populated. transcribedWithAudio
// drives the "cleanable transcribed audio" section; multipleAudioFormats drives
// the "extra audio formats" section.
+//
+// Both sections render on /cleanup now — they are the only channel-work
+// sections that belong to no operation, because what they are about is bytes.
+// The file keeps its name: it is still the cleanup-actionable pair.
async function seedCleanupSnapshot(): Promise<void> {
await writeJson(SNAPSHOT_REL, {
generatedAt: "2026-05-27T12:00:00.000Z",
@@ -49,7 +53,7 @@ test("surfaces the two cleanup sections with per-channel counts", async ({
}) => {
await resetData("one-transcribe-channel-with-audio");
await seedCleanupSnapshot();
- await page.goto("/actionable");
+ await page.goto("/cleanup");
const transcribed = page.getByRole("region", {
name: "clean-transcribed-audio",
@@ -80,7 +84,7 @@ test("inline 'Clean audio' queues a clean-audio-transcribed job when confirmed",
}) => {
await resetData("one-transcribe-channel-with-audio");
await seedCleanupSnapshot();
- await page.goto("/actionable");
+ await page.goto("/cleanup");
page.on("dialog", (d) => d.accept());
await page.getByRole("button", { name: `clean audio ${SLUG}` }).click();
@@ -99,7 +103,7 @@ test("inline 'Clean extra formats' queues a clean-extra-audio-formats job when c
}) => {
await resetData("one-transcribe-channel-with-audio");
await seedCleanupSnapshot();
- await page.goto("/actionable");
+ await page.goto("/cleanup");
page.on("dialog", (d) => d.accept());
await page
@@ -123,7 +127,7 @@ test("dismissing the confirm dialog does not queue a cleanup job", async ({
}) => {
await resetData("one-transcribe-channel-with-audio");
await seedCleanupSnapshot();
- await page.goto("/actionable");
+ await page.goto("/cleanup");
page.on("dialog", (d) => d.dismiss());
await page.getByRole("button", { name: `clean audio ${SLUG}` }).click();
diff --git a/editor/e2e/site-scope.spec.ts b/editor/e2e/site-scope.spec.ts
@@ -96,11 +96,16 @@ test("pool views show every site regardless of the selector", async ({
page,
}) => {
await twoSites();
- // Actionable is a shared-pool view: both channels appear even when a single
- // site is active (slow-a/slow-b have no snapshot → stale-or-missing rows).
- await page.goto("/actionable?site=alpha");
- await expect(page.getByLabel("stale-reports row slow-a")).toBeVisible();
- await expect(page.getByLabel("stale-reports row slow-b")).toBeVisible();
+ // /cleanup is a shared-pool view and lists every channel, snapshot or not:
+ // both appear even when a single site is active. It reads no `site`
+ // searchParam at all, which is the property under test.
+ await page.goto("/cleanup?site=alpha");
+ await expect(
+ page.getByRole("region", { name: "cleanup slow-a", exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("region", { name: "cleanup slow-b", exact: true }),
+ ).toBeVisible();
});
test("creating a channel under a site adds it to that site's membership", async ({