mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
Rename the recurring-jobs feature scheduler->cron to disambiguate from the legacy ScheduledRun system (/api/scheduled), which is left untouched: - ScheduledJob->CronJob, SchedulerService->CronService - /api/scheduler/jobs -> /api/cron/jobs; SSE scheduler:* -> cron:* - state keys cronJobs/cronJobRuns - files moved to src/cron/, cron-routes.ts, cron-port.ts, types/cron.ts - frontend cron-ui.js, #cronModal, menu "Cron" - docs moved to docs/cron-discovery.md + docs/cron-build-brief.md, README guides - new tests: cron-service.test.ts, cron-time.test.ts Green: tsc, lint, frontend-syntax, format, 30 cron + 9 legacy scheduled-runs tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PmvZR12aX2v8K7YhqxPUAU
83 lines
2.9 KiB
TypeScript
83 lines
2.9 KiB
TypeScript
/**
|
|
* @fileoverview Pure next-run-time calculations for the cron.
|
|
*
|
|
* All functions are pure and take an explicit `after` timestamp (epoch ms) so
|
|
* they are deterministic and unit-testable. Times use the SERVER'S LOCAL
|
|
* timezone for v0.1 (per the build brief) — daily/weekly wall-clock times are
|
|
* interpreted via the host's local time.
|
|
*/
|
|
|
|
import type { CronJob } from '../types/cron.js';
|
|
|
|
/** Parse an 'HH:MM' (24-hour) string into hours/minutes, or null if invalid. */
|
|
export function parseHHMM(value: string | undefined): { hours: number; minutes: number } | null {
|
|
if (!value) return null;
|
|
const m = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
|
|
if (!m) return null;
|
|
const hours = Number(m[1]);
|
|
const minutes = Number(m[2]);
|
|
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null;
|
|
return { hours, minutes };
|
|
}
|
|
|
|
/**
|
|
* Returns the epoch-ms timestamp for `hours:minutes` (local time) on the day of
|
|
* `base`, shifted by `dayOffset` days.
|
|
*/
|
|
function atLocalTime(base: number, hours: number, minutes: number, dayOffset: number): number {
|
|
const d = new Date(base);
|
|
d.setHours(hours, minutes, 0, 0);
|
|
d.setDate(d.getDate() + dayOffset);
|
|
return d.getTime();
|
|
}
|
|
|
|
/**
|
|
* Compute the next fire time strictly relevant to `after`, or null if the job
|
|
* has no future run (e.g. a completed one-time job, or invalid config).
|
|
*
|
|
* For `once`, returns the absolute `runAt` (even if already in the past, so a
|
|
* missed one-time job still fires once) until it has `completedOnce`.
|
|
*/
|
|
export function computeNextRunAt(job: CronJob, after: number): number | null {
|
|
switch (job.scheduleType) {
|
|
case 'once': {
|
|
if (job.completedOnce) return null;
|
|
return typeof job.runAt === 'number' ? job.runAt : null;
|
|
}
|
|
case 'interval': {
|
|
const minutes = job.intervalMinutes;
|
|
if (!minutes || minutes <= 0) return null;
|
|
return after + minutes * 60_000;
|
|
}
|
|
case 'daily': {
|
|
const t = parseHHMM(job.dailyTime);
|
|
if (!t) return null;
|
|
let next = atLocalTime(after, t.hours, t.minutes, 0);
|
|
if (next <= after) next = atLocalTime(after, t.hours, t.minutes, 1);
|
|
return next;
|
|
}
|
|
case 'weekly': {
|
|
const t = parseHHMM(job.weeklyTime);
|
|
if (!t) return null;
|
|
const days = (job.weeklyDays ?? []).filter((d) => d >= 0 && d <= 6);
|
|
if (days.length === 0) return null;
|
|
for (let offset = 0; offset <= 7; offset++) {
|
|
const cand = atLocalTime(after, t.hours, t.minutes, offset);
|
|
if (cand > after && days.includes(new Date(cand).getDay())) return cand;
|
|
}
|
|
return null;
|
|
}
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Duplicate-launch guard key: identifies a specific due time for a job. The
|
|
* cron records the key it last consumed so an overlapping or restarted
|
|
* loop will not launch the same due time twice.
|
|
*/
|
|
export function dueKeyFor(jobId: string, fireTime: number): string {
|
|
return `${jobId}:${fireTime}`;
|
|
}
|