commit 67c7f85de076c0fc4c5070e4deb57ac8380a6626
parent eda647537d08237a39a0e27b9a41db81af240212
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 14 Sep 2026 17:08:40 -0400
views: the sync schedule is a projection, not a reader
`buildSchedulerStatusPayload` was four reads and one projection in one
function, which is why it could only ever be called from a request: it opened
with getPaths()/getSettings(), awaited the scheduler state and the channel
list, read the clock, and only then folded the five into a payload.
The fold moves to `common/views/schedulerStatus.ts` and takes all five as
arguments. Nothing about the payload changes — same type name, same fields,
same values, `channels` is still exactly `buildScheduleView`'s output (which
stays in jobs/, where the due-date rule belongs).
`editor/app/scheduler/status.ts` keeps the old path, the old exported names and
the same values, and is now the reads alone. It is also where
`resolveHeartbeatSeconds` has to stay: it lives beside `runTick`, a RUNNER, and
a view that named one would be a view that could start work.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 149 insertions(+), 36 deletions(-)
diff --git a/common/views/schedulerStatus.test.ts b/common/views/schedulerStatus.test.ts
@@ -0,0 +1,70 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import { defaultSyncScheduler } from "../lib/settings";
+import { defaultChannelPriority } from "../lib/channelPriority";
+import { buildScheduleView } from "../jobs/syncScheduler";
+import { emptySchedulerState } from "../jobs/syncSchedulerState";
+import type { ChannelConfig } from "../lib/channelConfig";
+import { buildSchedulerStatusPayload } from "./schedulerStatus";
+
+// The payload is four pass-throughs and one projection, and that is the whole
+// point: everything it reports is either handed to it or is `buildScheduleView`
+// applied to what was handed to it. A second copy of the due-date rule here is
+// exactly what this shape exists to prevent.
+
+const config = (over: Partial<ChannelConfig> = {}): ChannelConfig =>
+ ({ name: "a channel", url: "https://example.com/c", ...over }) as ChannelConfig;
+
+function inputs() {
+ const settings = {
+ syncScheduler: { ...defaultSyncScheduler(), enabled: true },
+ channelPriority: defaultChannelPriority(),
+ };
+ const state = emptySchedulerState();
+ state.runs = [{ at: 1_000, queued: ["alpha"], skipped: [] }];
+ const channels = [
+ { slug: "alpha", config: config({ lastSyncedAt: "2026-01-01T00:00:00Z" }) },
+ { slug: "beta", config: config() },
+ ];
+ return {
+ settings,
+ now: 1_700_000_000_000,
+ state,
+ channels,
+ heartbeatSeconds: 45,
+ };
+}
+
+test("now, scheduler, runs and heartbeatSeconds pass straight through", () => {
+ const i = inputs();
+ const payload = buildSchedulerStatusPayload(i);
+ assert.equal(payload.now, i.now);
+ assert.equal(payload.scheduler, i.settings.syncScheduler);
+ assert.equal(payload.runs, i.state.runs);
+ // The env override is resolved by the shell, beside the heartbeat runner —
+ // whatever it says arrives here verbatim, including 0 ("external cron only").
+ assert.equal(payload.heartbeatSeconds, 45);
+ assert.equal(buildSchedulerStatusPayload({ ...i, heartbeatSeconds: 0 }).heartbeatSeconds, 0);
+});
+
+test("channels is buildScheduleView's output, not a second projection", () => {
+ const i = inputs();
+ const payload = buildSchedulerStatusPayload(i);
+ assert.deepEqual(
+ payload.channels,
+ buildScheduleView({
+ channels: i.channels,
+ scheduler: i.settings.syncScheduler,
+ state: i.state,
+ now: i.now,
+ priority: i.settings.channelPriority,
+ }),
+ );
+ assert.deepEqual(
+ payload.channels.map((c) => c.slug),
+ ["alpha", "beta"],
+ );
+ // And it really is the scheduler's own verdict: a channel that has never
+ // synced is due now.
+ assert.equal(payload.channels[1].overdue, true);
+});
diff --git a/common/views/schedulerStatus.ts b/common/views/schedulerStatus.ts
@@ -0,0 +1,64 @@
+import type { SyncSchedulerSettings } from "../lib/settings";
+import {
+ buildScheduleView,
+ type ChannelEntry,
+ type ChannelScheduleView,
+} from "../jobs/syncScheduler";
+import type {
+ SchedulerRun,
+ SchedulerState,
+} from "../jobs/syncSchedulerState";
+import type { ChannelPriority } from "../lib/channelPriority";
+
+// THE READ-ONLY "SYNC SCHEDULE" VIEW.
+//
+// Shared by the SSR page and the /api/scheduler/status poll so the two can
+// never drift. Everything it needs arrives as an argument: the settings, the
+// scheduler state read off disk, the channel list, the clock and the effective
+// heartbeat cadence. `buildScheduleView` is a pure projection in jobs/ and is
+// the one definition of "when is this channel next due" — the console asks the
+// scheduler rather than re-deriving it.
+
+export type SchedulerStatusPayload = {
+ now: number;
+ scheduler: SyncSchedulerSettings;
+ channels: ChannelScheduleView[];
+ runs: SchedulerRun[];
+ // Effective internal-heartbeat cadence in seconds (env override applied), so
+ // the UI can show whether ticks are internally driven. 0 = no internal timer
+ // (awaiting an external cron heartbeat).
+ heartbeatSeconds: number;
+};
+
+export type SchedulerStatusInputs = {
+ settings: {
+ syncScheduler: SyncSchedulerSettings;
+ channelPriority: ChannelPriority;
+ };
+ now: number;
+ state: SchedulerState;
+ channels: ReadonlyArray<ChannelEntry>;
+ // Resolved by the shell: it reads the env override and imports the heartbeat
+ // module, which owns a RUNNER. A view never names a runner.
+ heartbeatSeconds: number;
+};
+
+export function buildSchedulerStatusPayload(
+ inputs: SchedulerStatusInputs,
+): SchedulerStatusPayload {
+ const { settings, now, state, channels, heartbeatSeconds } = inputs;
+ const view = buildScheduleView({
+ channels,
+ scheduler: settings.syncScheduler,
+ state,
+ now,
+ priority: settings.channelPriority,
+ });
+ return {
+ now,
+ scheduler: settings.syncScheduler,
+ channels: view,
+ runs: state.runs,
+ heartbeatSeconds,
+ };
+}
diff --git a/editor/app/scheduler/status.ts b/editor/app/scheduler/status.ts
@@ -1,53 +1,32 @@
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { listChannelConfigs } from "yt-dlp-transcript-common/controller/channels";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import {
- getSettings,
- type SyncSchedulerSettings,
-} from "yt-dlp-transcript-common/lib/settings";
-import {
- buildScheduleView,
- type ChannelScheduleView,
-} from "yt-dlp-transcript-common/jobs/syncScheduler";
-import {
- readSchedulerState,
- type SchedulerRun,
-} from "yt-dlp-transcript-common/jobs/syncSchedulerState";
+ buildSchedulerStatusPayload as build,
+ type SchedulerStatusPayload,
+} from "yt-dlp-transcript-common/views/schedulerStatus";
+import { readSchedulerState } from "yt-dlp-transcript-common/jobs/syncSchedulerState";
import { resolveHeartbeatSeconds } from "./heartbeat";
-export type SchedulerStatusPayload = {
- now: number;
- scheduler: SyncSchedulerSettings;
- channels: ChannelScheduleView[];
- runs: SchedulerRun[];
- // Effective internal-heartbeat cadence in seconds (env override applied), so
- // the UI can show whether ticks are internally driven. 0 = no internal timer
- // (awaiting an external cron heartbeat).
- heartbeatSeconds: number;
-};
+export type { SchedulerStatusPayload };
-// Assemble the read-only "Sync schedule" view. Shared by the SSR page and the
-// /api/scheduler/status poll so they never drift.
+// THE SHELL. The payload is `common/views/schedulerStatus.ts`; this reads the
+// four things it takes and nothing else. `resolveHeartbeatSeconds` is why the
+// split lands here rather than one file further down: it lives beside
+// `runTick`, a RUNNER, and a view may not name one.
export async function buildSchedulerStatusPayload(): Promise<SchedulerStatusPayload> {
const paths = getPaths();
const settings = getSettings();
const state = await readSchedulerState(paths);
- const now = Date.now();
const channels = (await listChannelConfigs(paths)).map((c) => ({
slug: c.slug,
config: c.config,
}));
- const view = buildScheduleView({
- channels,
- scheduler: settings.syncScheduler,
+ return build({
+ settings,
+ now: Date.now(),
state,
- now,
- priority: settings.channelPriority,
- });
- return {
- now,
- scheduler: settings.syncScheduler,
- channels: view,
- runs: state.runs,
+ channels,
heartbeatSeconds: resolveHeartbeatSeconds(),
- };
+ });
}