commit 4e09aa50f8ff5e62df1db2037e1e873ae7a8e6ac
parent 7544c6c10d4fa873579b4484f7d21e4c4060fee0
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 22 Aug 2026 19:39:29 -0400
pipelines: keep the pure band readings off the client side
FlowStation is a SERVER component and was calling bandSentence() out of
StateBand.tsx, which is `"use client"`. A plain function exported from a client
module cannot be CALLED from the server — only rendered — so every channel page
500'd at request time with "attempted to call bandSentence() from the server".
`pnpm build` passes clean on this: the route is force-dynamic, so nothing
prerenders it and the error only appears on a request. e2e found it; the build
could not. band.ts's header already documented this hazard in the OTHER
direction (a client component importing the server-only builder) and the same
file is the right home for the fix.
So band.ts now carries the type AND every pure reading — SEGMENTS,
segmentValue, bandDenominator, bandSentence, bandHeadline — and StateBand.tsx
exports only components.
Guarded, because the failure is invisible to tsc and to build: a unit test
asserts band.ts takes no value import and carries no directive, and that
StateBand.tsx exports nothing callable. Verified to actually fail by
reintroducing a lowercase export, not just observed green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat:
6 files changed, 197 insertions(+), 119 deletions(-)
diff --git a/editor/app/auto-queue/components/OperationRail.tsx b/editor/app/auto-queue/components/OperationRail.tsx
@@ -1,9 +1,11 @@
"use client";
-import type { OperationBand } from "../../components/pipelines/band";
import {
bandCoverage,
bandDenominator,
+ type OperationBand,
+} from "../../components/pipelines/band";
+import {
BandLegend,
StateBand,
} from "../../components/pipelines/StateBand";
diff --git a/editor/app/channels/[slug]/components/flow/FlowStation.tsx b/editor/app/channels/[slug]/components/flow/FlowStation.tsx
@@ -4,11 +4,16 @@ import type {
FlowStation as Station,
StationOperation,
} from "../../lib/channelFlow";
+// THE SPLIT MATTERS HERE. This is a SERVER component, and StateBand.tsx is
+// `"use client"` — a plain function exported from a client module cannot be
+// CALLED from the server ("attempted to call bandSentence() from the server"),
+// only rendered as a component. So the readings come from band.ts, which
+// carries no directive, and only the component itself comes from StateBand.
import {
bandHeadline,
bandSentence,
- StateBand,
-} from "../../../../components/pipelines/StateBand";
+} from "../../../../components/pipelines/band";
+import { StateBand } from "../../../../components/pipelines/StateBand";
import { formatCount, formatCoverage, STATION_DOT } from "./tone";
// One station on the line, emitted as THREE siblings so the parent grid can put
diff --git a/editor/app/channels/components/ChannelsTable.tsx b/editor/app/channels/components/ChannelsTable.tsx
@@ -3,9 +3,11 @@
import Link from "next/link";
import { useMemo, useState } from "react";
import type { ChannelStat } from "yt-dlp-transcript-common/controller/channels";
-import type { OperationBand } from "../../components/pipelines/band";
import {
bandSentence,
+ type OperationBand,
+} from "../../components/pipelines/band";
+import {
BandLegend,
StateBand,
} from "../../components/pipelines/StateBand";
diff --git a/editor/app/components/pipelines/StateBand.tsx b/editor/app/components/pipelines/StateBand.tsx
@@ -1,6 +1,11 @@
"use client";
-import { bandCoverage, type OperationBand } from "./band";
+import {
+ bandDenominator,
+ SEGMENTS,
+ segmentValue,
+ type OperationBand,
+} from "./band";
// THE INSTRUMENT. One component, three scales, one vocabulary.
//
@@ -24,65 +29,6 @@ import { bandCoverage, type OperationBand } from "./band";
// EVERY FILL IS A TOKEN from common/styles/tokens.css. No new colour ships with
// this instrument, and that is load-bearing rather than frugal.
-// The five populations, in the order they are laid down. `present` first so a
-// band reads left-to-right as "done → can do → cannot do yet → cannot do at
-// all", which is the same direction the channel transit line runs.
-export const SEGMENTS = [
- {
- key: "present",
- label: "done",
- // Solid, muted. The artifact exists; it is not where attention goes.
- className: "bg-muted-foreground/45",
- },
- {
- key: "reachable",
- label: "can run now",
- // THE one saturated segment.
- className: "bg-info",
- },
- {
- key: "blocked",
- label: "blocked upstream",
- // Hatched — waiting on an operation this system produces, so it will clear
- // itself without anyone doing anything.
- className:
- "bg-warning/25 [background-image:repeating-linear-gradient(45deg,currentColor_0_1px,transparent_1px_4px)] text-warning/70",
- },
- {
- key: "deferred",
- label: "held by a gate",
- // Dotted — a gate (stale cues, a duration window) is holding it.
- className:
- "bg-transparent [background-image:radial-gradient(currentColor_0.5px,transparent_0.5px)] [background-size:3px_3px] text-muted-foreground",
- },
- {
- key: "missingInput",
- label: "media gone",
- // Hollow outline — there is nothing to do. An opt-in re-download is the
- // only thing that would ever change it, and it must never look like work.
- className: "bg-transparent ring-1 ring-inset ring-border",
- },
-] as const;
-
-export type SegmentKey = (typeof SEGMENTS)[number]["key"];
-
-export function segmentValue(band: OperationBand, key: SegmentKey): number {
- if (key === "present") return band.present ?? 0;
- return band[key];
-}
-
-// The denominator a band's segments are drawn against.
-//
-// `eligible` when it is known. When it is NOT — a snapshot can predate the
-// field, and snapshots lapse — falling back to the sum of the work counts would
-// silently redraw the band as "100% accounted for", so instead the band renders
-// as an un-filled outline and says `coverage unknown`. Unknown is not zero, and
-// it is not full either.
-export function bandDenominator(band: OperationBand): number | null {
- if (band.eligible != null && band.eligible > 0) return band.eligible;
- return null;
-}
-
// The three scales. Height and minimum width only — the fills, the order and
// the meanings are identical, which is the entire point.
//
@@ -98,59 +44,6 @@ const SIZES = {
export type BandSize = keyof typeof SIZES;
-// The sentence a band carries in its tooltip and its accessible name.
-//
-// THE FIGURES ARE STATED SEPARATELY AND NEVER SUMMED — the same rule the rail's
-// figure row holds, moved somewhere it applies to every scale at once. On the
-// measured corpus `reachable` and `missingInput` are four orders of magnitude
-// apart on diarization; one "remaining" number would say the same thing about a
-// lane that is finished and a lane that cannot start.
-export function bandSentence(band: OperationBand): string {
- const parts: string[] = [`${band.reachable.toLocaleString()} can run now`];
- if (band.blocked > 0) {
- parts.push(`${band.blocked.toLocaleString()} blocked upstream`);
- }
- if (band.deferred > 0) {
- parts.push(`${band.deferred.toLocaleString()} held by a gate`);
- }
- if (band.missingInput > 0) {
- parts.push(`${band.missingInput.toLocaleString()} have no media left`);
- }
- const denominator = bandDenominator(band);
- parts.push(
- band.present == null || denominator == null
- ? "coverage unknown"
- : `${band.present.toLocaleString()} done of ${denominator.toLocaleString()}`,
- );
- return parts.join(" · ");
-}
-
-// THE ONE POPULATION THAT DOMINATES WHAT IS LEFT, in three words.
-//
-// Not a total and not a percentage — the shape of the remainder is the thing
-// that varies across this corpus, and naming its largest part is the shortest
-// true sentence about a pipeline. "11,333 no media" and "11,329 to do" are the
-// same size of number and mean opposite things; a single "remaining" figure
-// would render them identically.
-export function bandHeadline(band: OperationBand): string {
- const candidates: Array<[number, string]> = [
- [band.reachable, "to do"],
- [band.blocked, "blocked"],
- [band.missingInput, "no media"],
- [band.deferred, "held"],
- ];
- let best: [number, string] | null = null;
- for (const c of candidates) {
- if (c[0] > (best?.[0] ?? 0)) best = c;
- }
- if (!best) {
- // Nothing outstanding. Whether that is "finished" depends on a denominator
- // we may not have, so say what is known and no more.
- return bandDenominator(band) === null ? "coverage unknown" : "nothing to do";
- }
- return `${best[0].toLocaleString()} ${best[1]}`;
-}
-
export function StateBand({
band,
size,
@@ -224,5 +117,3 @@ export function BandLegend({ className = "" }: { className?: string }) {
</ul>
);
}
-
-export { bandCoverage };
diff --git a/editor/app/components/pipelines/band.ts b/editor/app/components/pipelines/band.ts
@@ -83,3 +83,129 @@ export function bandCoverage(band: OperationBand): number | null {
}
return Math.min(1, band.present / band.eligible);
}
+
+// ── THE FILL VOCABULARY, AND THE PURE READINGS OF A BAND ────────────────────
+//
+// These live HERE, beside the type, and not beside the component that draws
+// them — for the same client/server reason the file header gives, in the other
+// direction. StateBand.tsx is `"use client"`, and a plain function exported
+// from a client module CANNOT BE CALLED by a server component: Next throws
+// "attempted to call bandSentence() from the server". The channel page's
+// station foot is a server component and needs the sentence and the headline,
+// so the pure functions belong on the shared, directive-free side.
+//
+// `pnpm build` does not catch this. The route is force-dynamic, so nothing
+// prerenders it and the error only appears on a request — which is what e2e
+// found and the build did not.
+
+// The five populations, in the order they are laid down. `present` first so a
+// band reads left-to-right as "done → can do → cannot do yet → cannot do at
+// all", which is the same direction the channel transit line runs.
+export const SEGMENTS = [
+ {
+ key: "present",
+ label: "done",
+ // Solid, muted. The artifact exists; it is not where attention goes.
+ className: "bg-muted-foreground/45",
+ },
+ {
+ key: "reachable",
+ label: "can run now",
+ // THE one saturated segment.
+ className: "bg-info",
+ },
+ {
+ key: "blocked",
+ label: "blocked upstream",
+ // Hatched — waiting on an operation this system produces, so it will clear
+ // itself without anyone doing anything.
+ className:
+ "bg-warning/25 [background-image:repeating-linear-gradient(45deg,currentColor_0_1px,transparent_1px_4px)] text-warning/70",
+ },
+ {
+ key: "deferred",
+ label: "held by a gate",
+ // Dotted — a gate (stale cues, a duration window) is holding it.
+ className:
+ "bg-transparent [background-image:radial-gradient(currentColor_0.5px,transparent_0.5px)] [background-size:3px_3px] text-muted-foreground",
+ },
+ {
+ key: "missingInput",
+ label: "media gone",
+ // Hollow outline — there is nothing to do. An opt-in re-download is the
+ // only thing that would ever change it, and it must never look like work.
+ className: "bg-transparent ring-1 ring-inset ring-border",
+ },
+] as const;
+
+export type SegmentKey = (typeof SEGMENTS)[number]["key"];
+
+export function segmentValue(band: OperationBand, key: SegmentKey): number {
+ if (key === "present") return band.present ?? 0;
+ return band[key];
+}
+
+// The denominator a band's segments are drawn against.
+//
+// `eligible` when it is known. When it is NOT — a snapshot can predate the
+// field, and snapshots lapse — falling back to the sum of the work counts would
+// silently redraw the band as "100% accounted for", so instead the band renders
+// as an un-filled outline and says `coverage unknown`. Unknown is not zero, and
+// it is not full either.
+export function bandDenominator(band: OperationBand): number | null {
+ if (band.eligible != null && band.eligible > 0) return band.eligible;
+ return null;
+}
+
+// The sentence a band carries in its tooltip and its accessible name.
+//
+// THE FIGURES ARE STATED SEPARATELY AND NEVER SUMMED — the same rule the rail's
+// figure row holds, moved somewhere it applies to every scale at once. On the
+// measured corpus `reachable` and `missingInput` are four orders of magnitude
+// apart on diarization; one "remaining" number would say the same thing about a
+// lane that is finished and a lane that cannot start.
+export function bandSentence(band: OperationBand): string {
+ const parts: string[] = [`${band.reachable.toLocaleString()} can run now`];
+ if (band.blocked > 0) {
+ parts.push(`${band.blocked.toLocaleString()} blocked upstream`);
+ }
+ if (band.deferred > 0) {
+ parts.push(`${band.deferred.toLocaleString()} held by a gate`);
+ }
+ if (band.missingInput > 0) {
+ parts.push(`${band.missingInput.toLocaleString()} have no media left`);
+ }
+ const denominator = bandDenominator(band);
+ parts.push(
+ band.present == null || denominator == null
+ ? "coverage unknown"
+ : `${band.present.toLocaleString()} done of ${denominator.toLocaleString()}`,
+ );
+ return parts.join(" · ");
+}
+
+// THE ONE POPULATION THAT DOMINATES WHAT IS LEFT, in three words.
+//
+// Not a total and not a percentage — the shape of the remainder is the thing
+// that varies across this corpus, and naming its largest part is the shortest
+// true sentence about a pipeline. "11,333 no media" and "11,329 to do" are the
+// same size of number and mean opposite things; a single "remaining" figure
+// would render them identically.
+export function bandHeadline(band: OperationBand): string {
+ const candidates: Array<[number, string]> = [
+ [band.reachable, "to do"],
+ [band.blocked, "blocked"],
+ [band.missingInput, "no media"],
+ [band.deferred, "held"],
+ ];
+ let best: [number, string] | null = null;
+ for (const c of candidates) {
+ if (c[0] > (best?.[0] ?? 0)) best = c;
+ }
+ if (!best) {
+ // Nothing outstanding. Whether that is "finished" depends on a denominator
+ // we may not have, so say what is known and no more.
+ return bandDenominator(band) === null ? "coverage unknown" : "nothing to do";
+ }
+ return `${best[0].toLocaleString()} ${best[1]}`;
+}
diff --git a/editor/app/components/pipelines/buildBands.test.ts b/editor/app/components/pipelines/buildBands.test.ts
@@ -1,5 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
import type { ChannelSnapshot } from "yt-dlp-transcript-common/controller/channelSnapshot";
import type { BackfillSnapshotEntry } from "yt-dlp-transcript-common/lib/backfillKinds";
import {
@@ -326,3 +327,54 @@ test("a channel with no snapshot at all is every band empty", () => {
assert.equal(bandCoverage(band), null);
}
});
+
+// ── THE CLIENT/SERVER SPLIT, GUARDED ────────────────────────────────────────
+
+test("band.ts stays directive-free, so a server component can call it", () => {
+ // band.ts carries the TYPE and every pure reading of a band. The channel
+ // page's station foot is a SERVER component and calls bandSentence() and
+ // bandHeadline() directly; adding "use client" here would break it at request
+ // time with "attempted to call bandSentence() from the server".
+ const src = readFileSync(new URL("./band.ts", import.meta.url), "utf8");
+ // A DIRECTIVE, not the string — this file discusses "use client" in prose.
+ // A directive is a bare expression statement before any other code.
+ assert.ok(
+ !/^\s*(?:"use client"|'use client');?\s*$/m.test(src),
+ "band.ts must not be a client module",
+ );
+ // And it must import nothing but types — buildBands.ts pulls in
+ // channelSnapshot → execa, which would put node:child_process in the browser
+ // bundle and fail `next build`.
+ const valueImports = [...src.matchAll(/^import\s+(?!type\b)/gm)];
+ assert.equal(
+ valueImports.length,
+ 0,
+ "band.ts must not take a value import — see its header",
+ );
+});
+
+test("StateBand.tsx exports only components, never callable helpers", () => {
+ // THE BUG THIS CAUGHT, ONCE. A plain function exported from a `"use client"`
+ // module cannot be CALLED by a server component — only rendered. bandSentence
+ // lived here, the server-rendered station foot called it, and every channel
+ // page 500'd at request time.
+ //
+ // `pnpm build` does NOT catch this: the route is force-dynamic, so nothing
+ // prerenders it and the error only appears on a request. e2e found it and the
+ // build did not, which is why this guard is a unit test and not a build step.
+ const src = readFileSync(new URL("./StateBand.tsx", import.meta.url), "utf8");
+ assert.ok(
+ /^\s*(?:"use client"|'use client');?\s*$/m.test(src),
+ "StateBand.tsx is the client half",
+ );
+ const exported = [...src.matchAll(/^export\s+(?:function|const)\s+(\w+)/gm)].map(
+ (m) => m[1],
+ );
+ assert.ok(exported.length > 0, "found no exports to check — regex drifted");
+ for (const name of exported) {
+ assert.ok(
+ /^[A-Z]/.test(name),
+ `${name} is exported from a client module but is not a component — a server component that calls it throws at request time. Move it to band.ts.`,
+ );
+ }
+});