mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-04-07 13:38:33 +03:00
feat: custom sync start time and frequency scheduling (#241)
* feat: add custom sync start time scheduling * Updated UI * docs: add updated issue 240 UI screenshot * fix: improve schedule UI with client-side next run calc and timezone handling - Compute next scheduled run client-side via useMemo to avoid permanent "Calculating..." state when server hasn't set nextRun yet - Default to browser timezone when enabling syncing (not UTC) - Show actual saved timezone in badge, use it consistently in all handlers - Match time input background to select trigger in dark mode - Add clock icon to time picker with hidden native indicator
This commit is contained in:
@@ -2,6 +2,7 @@ import { db, configs } from "@/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { encrypt } from "@/lib/utils/encryption";
|
||||
import { getNextScheduledRun, normalizeTimezone } from "@/lib/utils/schedule-utils";
|
||||
|
||||
export interface DefaultConfigOptions {
|
||||
userId: string;
|
||||
@@ -13,7 +14,7 @@ export interface DefaultConfigOptions {
|
||||
giteaToken?: string;
|
||||
giteaUsername?: string;
|
||||
scheduleEnabled?: boolean;
|
||||
scheduleInterval?: number;
|
||||
scheduleInterval?: number | string;
|
||||
cleanupEnabled?: boolean;
|
||||
cleanupRetentionDays?: number;
|
||||
};
|
||||
@@ -47,8 +48,17 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
|
||||
// Schedule config from env - default to ENABLED
|
||||
const scheduleEnabled = envOverrides.scheduleEnabled ??
|
||||
(process.env.SCHEDULE_ENABLED === "false" ? false : true); // Default: ENABLED
|
||||
const scheduleInterval = envOverrides.scheduleInterval ??
|
||||
(process.env.SCHEDULE_INTERVAL ? parseInt(process.env.SCHEDULE_INTERVAL, 10) : 86400); // Default: daily
|
||||
const scheduleInterval = envOverrides.scheduleInterval ??
|
||||
(process.env.SCHEDULE_INTERVAL || 86400); // Default: daily
|
||||
const scheduleTimezone = normalizeTimezone(process.env.SCHEDULE_TIMEZONE || "UTC");
|
||||
let scheduleNextRun: Date | null = null;
|
||||
if (scheduleEnabled) {
|
||||
try {
|
||||
scheduleNextRun = getNextScheduledRun(scheduleInterval, new Date(), scheduleTimezone);
|
||||
} catch {
|
||||
scheduleNextRun = new Date(Date.now() + 86400 * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup config from env - default to ENABLED
|
||||
const cleanupEnabled = envOverrides.cleanupEnabled ??
|
||||
@@ -104,11 +114,12 @@ export async function createDefaultConfig({ userId, envOverrides = {} }: Default
|
||||
exclude: [],
|
||||
scheduleConfig: {
|
||||
enabled: scheduleEnabled,
|
||||
interval: scheduleInterval,
|
||||
interval: String(scheduleInterval),
|
||||
timezone: scheduleTimezone,
|
||||
concurrent: false,
|
||||
batchSize: 5, // Reduced from 10 to be more conservative with GitHub API limits
|
||||
lastRun: null,
|
||||
nextRun: scheduleEnabled ? new Date(Date.now() + scheduleInterval * 1000) : null,
|
||||
nextRun: scheduleNextRun,
|
||||
},
|
||||
cleanupConfig: {
|
||||
enabled: cleanupEnabled,
|
||||
|
||||
36
src/lib/utils/config-mapper.test.ts
Normal file
36
src/lib/utils/config-mapper.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { mapDbScheduleToUi, mapUiScheduleToDb } from "./config-mapper";
|
||||
import { scheduleConfigSchema } from "@/lib/db/schema";
|
||||
|
||||
test("mapUiScheduleToDb - builds cron from start time + frequency", () => {
|
||||
const existing = scheduleConfigSchema.parse({});
|
||||
const mapped = mapUiScheduleToDb(
|
||||
{
|
||||
enabled: true,
|
||||
scheduleMode: "clock",
|
||||
clockFrequencyHours: 24,
|
||||
startTime: "22:00",
|
||||
timezone: "Asia/Kolkata",
|
||||
},
|
||||
existing
|
||||
);
|
||||
|
||||
expect(mapped.enabled).toBe(true);
|
||||
expect(mapped.interval).toBe("0 22 * * *");
|
||||
expect(mapped.timezone).toBe("Asia/Kolkata");
|
||||
});
|
||||
|
||||
test("mapDbScheduleToUi - infers clock mode for generated cron", () => {
|
||||
const mapped = mapDbScheduleToUi(
|
||||
scheduleConfigSchema.parse({
|
||||
enabled: true,
|
||||
interval: "15 22,6,14 * * *",
|
||||
timezone: "Asia/Kolkata",
|
||||
})
|
||||
);
|
||||
|
||||
expect(mapped.scheduleMode).toBe("clock");
|
||||
expect(mapped.clockFrequencyHours).toBe(8);
|
||||
expect(mapped.startTime).toBe("22:15");
|
||||
expect(mapped.timezone).toBe("Asia/Kolkata");
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
import { z } from "zod";
|
||||
import { githubConfigSchema, giteaConfigSchema, scheduleConfigSchema, cleanupConfigSchema } from "@/lib/db/schema";
|
||||
import { parseInterval } from "@/lib/utils/duration-parser";
|
||||
import { buildClockCronExpression, normalizeTimezone, parseClockCronExpression } from "@/lib/utils/schedule-utils";
|
||||
|
||||
// Use the actual database schema types
|
||||
type DbGitHubConfig = z.infer<typeof githubConfigSchema>;
|
||||
@@ -197,15 +198,42 @@ export function mapUiScheduleToDb(uiSchedule: any, existing?: DbScheduleConfig):
|
||||
? { ...(existing as unknown as DbScheduleConfig) }
|
||||
: (scheduleConfigSchema.parse({}) as unknown as DbScheduleConfig);
|
||||
|
||||
// Store interval as seconds string to avoid lossy cron conversion
|
||||
const intervalSeconds = typeof uiSchedule.interval === 'number' && uiSchedule.interval > 0
|
||||
? String(uiSchedule.interval)
|
||||
: (typeof base.interval === 'string' ? base.interval : String(86400));
|
||||
const baseInterval = typeof base.interval === "string"
|
||||
? base.interval
|
||||
: String(base.interval ?? 86400);
|
||||
|
||||
const timezone = normalizeTimezone(
|
||||
typeof uiSchedule.timezone === "string"
|
||||
? uiSchedule.timezone
|
||||
: base.timezone || "UTC"
|
||||
);
|
||||
|
||||
let intervalExpression = baseInterval;
|
||||
|
||||
if (uiSchedule.scheduleMode === "clock") {
|
||||
const cronExpression = buildClockCronExpression(
|
||||
uiSchedule.startTime || "22:00",
|
||||
Number(uiSchedule.clockFrequencyHours || 24)
|
||||
);
|
||||
if (cronExpression) {
|
||||
intervalExpression = cronExpression;
|
||||
}
|
||||
} else if (typeof uiSchedule.intervalExpression === "string" && uiSchedule.intervalExpression.trim().length > 0) {
|
||||
intervalExpression = uiSchedule.intervalExpression.trim();
|
||||
} else if (typeof uiSchedule.interval === "number" && Number.isFinite(uiSchedule.interval) && uiSchedule.interval > 0) {
|
||||
intervalExpression = String(Math.floor(uiSchedule.interval));
|
||||
} else if (typeof uiSchedule.interval === "string" && uiSchedule.interval.trim().length > 0) {
|
||||
intervalExpression = uiSchedule.interval.trim();
|
||||
}
|
||||
|
||||
const scheduleChanged = baseInterval !== intervalExpression || normalizeTimezone(base.timezone || "UTC") !== timezone;
|
||||
|
||||
return {
|
||||
...base,
|
||||
enabled: !!uiSchedule.enabled,
|
||||
interval: intervalSeconds,
|
||||
interval: intervalExpression,
|
||||
timezone,
|
||||
nextRun: scheduleChanged ? undefined : base.nextRun,
|
||||
} as DbScheduleConfig;
|
||||
}
|
||||
|
||||
@@ -218,11 +246,21 @@ export function mapDbScheduleToUi(dbSchedule: DbScheduleConfig): any {
|
||||
return {
|
||||
enabled: false,
|
||||
interval: 86400, // Default to daily (24 hours)
|
||||
intervalExpression: "86400",
|
||||
scheduleMode: "interval",
|
||||
clockFrequencyHours: 24,
|
||||
startTime: "22:00",
|
||||
timezone: "UTC",
|
||||
lastRun: null,
|
||||
nextRun: null,
|
||||
};
|
||||
}
|
||||
|
||||
const intervalExpression = typeof dbSchedule.interval === "string"
|
||||
? dbSchedule.interval
|
||||
: String(dbSchedule.interval ?? 86400);
|
||||
const parsedClockSchedule = parseClockCronExpression(intervalExpression);
|
||||
|
||||
// Parse interval supporting numbers (seconds), duration strings, and cron
|
||||
let intervalSeconds = 86400; // Default to daily (24 hours)
|
||||
try {
|
||||
@@ -240,6 +278,11 @@ export function mapDbScheduleToUi(dbSchedule: DbScheduleConfig): any {
|
||||
return {
|
||||
enabled: dbSchedule.enabled || false,
|
||||
interval: intervalSeconds,
|
||||
intervalExpression,
|
||||
scheduleMode: parsedClockSchedule ? "clock" : "interval",
|
||||
clockFrequencyHours: parsedClockSchedule?.frequencyHours ?? 24,
|
||||
startTime: parsedClockSchedule?.startTime ?? "22:00",
|
||||
timezone: normalizeTimezone(dbSchedule.timezone || "UTC"),
|
||||
lastRun: dbSchedule.lastRun || null,
|
||||
nextRun: dbSchedule.nextRun || null,
|
||||
};
|
||||
|
||||
65
src/lib/utils/schedule-utils.test.ts
Normal file
65
src/lib/utils/schedule-utils.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
buildClockCronExpression,
|
||||
getNextCronOccurrence,
|
||||
getNextScheduledRun,
|
||||
isCronExpression,
|
||||
normalizeTimezone,
|
||||
parseClockCronExpression,
|
||||
} from "./schedule-utils";
|
||||
|
||||
test("isCronExpression - detects 5-part cron expressions", () => {
|
||||
expect(isCronExpression("0 22 * * *")).toBe(true);
|
||||
expect(isCronExpression("8h")).toBe(false);
|
||||
expect(isCronExpression(3600)).toBe(false);
|
||||
});
|
||||
|
||||
test("buildClockCronExpression - creates daily and hourly expressions", () => {
|
||||
expect(buildClockCronExpression("22:00", 24)).toBe("0 22 * * *");
|
||||
expect(buildClockCronExpression("22:15", 8)).toBe("15 22,6,14 * * *");
|
||||
expect(buildClockCronExpression("10:30", 1)).toBe("30 * * * *");
|
||||
expect(buildClockCronExpression("10:30", 7)).toBeNull();
|
||||
});
|
||||
|
||||
test("parseClockCronExpression - parses generated expressions", () => {
|
||||
expect(parseClockCronExpression("0 22 * * *")).toEqual({
|
||||
startTime: "22:00",
|
||||
frequencyHours: 24,
|
||||
});
|
||||
expect(parseClockCronExpression("15 22,6,14 * * *")).toEqual({
|
||||
startTime: "22:15",
|
||||
frequencyHours: 8,
|
||||
});
|
||||
expect(parseClockCronExpression("30 * * * *")).toEqual({
|
||||
startTime: "00:30",
|
||||
frequencyHours: 1,
|
||||
});
|
||||
expect(parseClockCronExpression("0 3 * * 1-5")).toBeNull();
|
||||
});
|
||||
|
||||
test("getNextCronOccurrence - computes next run in UTC", () => {
|
||||
const from = new Date("2026-03-18T15:20:00.000Z");
|
||||
const next = getNextCronOccurrence("0 22 * * *", from, "UTC");
|
||||
expect(next.toISOString()).toBe("2026-03-18T22:00:00.000Z");
|
||||
});
|
||||
|
||||
test("getNextCronOccurrence - respects timezone", () => {
|
||||
const from = new Date("2026-03-18T15:20:00.000Z");
|
||||
// 22:00 IST equals 16:30 UTC
|
||||
const next = getNextCronOccurrence("0 22 * * *", from, "Asia/Kolkata");
|
||||
expect(next.toISOString()).toBe("2026-03-18T16:30:00.000Z");
|
||||
});
|
||||
|
||||
test("getNextScheduledRun - handles interval and cron schedules", () => {
|
||||
const from = new Date("2026-03-18T00:00:00.000Z");
|
||||
const intervalNext = getNextScheduledRun("8h", from, "UTC");
|
||||
expect(intervalNext.toISOString()).toBe("2026-03-18T08:00:00.000Z");
|
||||
|
||||
const cronNext = getNextScheduledRun("0 */6 * * *", from, "UTC");
|
||||
expect(cronNext.toISOString()).toBe("2026-03-18T06:00:00.000Z");
|
||||
});
|
||||
|
||||
test("normalizeTimezone - falls back to UTC for invalid values", () => {
|
||||
expect(normalizeTimezone("Invalid/Zone")).toBe("UTC");
|
||||
expect(normalizeTimezone("Asia/Kolkata")).toBe("Asia/Kolkata");
|
||||
});
|
||||
420
src/lib/utils/schedule-utils.ts
Normal file
420
src/lib/utils/schedule-utils.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import { parseInterval } from "@/lib/utils/duration-parser";
|
||||
|
||||
const WEEKDAY_INDEX: Record<string, number> = {
|
||||
sun: 0,
|
||||
mon: 1,
|
||||
tue: 2,
|
||||
wed: 3,
|
||||
thu: 4,
|
||||
fri: 5,
|
||||
sat: 6,
|
||||
};
|
||||
|
||||
const MONTH_INDEX: Record<string, number> = {
|
||||
jan: 1,
|
||||
feb: 2,
|
||||
mar: 3,
|
||||
apr: 4,
|
||||
may: 5,
|
||||
jun: 6,
|
||||
jul: 7,
|
||||
aug: 8,
|
||||
sep: 9,
|
||||
oct: 10,
|
||||
nov: 11,
|
||||
dec: 12,
|
||||
};
|
||||
|
||||
interface ParsedCronField {
|
||||
wildcard: boolean;
|
||||
values: Set<number>;
|
||||
}
|
||||
|
||||
interface ZonedDateParts {
|
||||
minute: number;
|
||||
hour: number;
|
||||
dayOfMonth: number;
|
||||
month: number;
|
||||
dayOfWeek: number;
|
||||
}
|
||||
|
||||
interface ParsedCronExpression {
|
||||
minute: ParsedCronField;
|
||||
hour: ParsedCronField;
|
||||
dayOfMonth: ParsedCronField;
|
||||
month: ParsedCronField;
|
||||
dayOfWeek: ParsedCronField;
|
||||
}
|
||||
|
||||
const zonedPartsFormatterCache = new Map<string, Intl.DateTimeFormat>();
|
||||
const zonedWeekdayFormatterCache = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
function pad2(value: number): string {
|
||||
return value.toString().padStart(2, "0");
|
||||
}
|
||||
|
||||
export function isCronExpression(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().split(/\s+/).length === 5;
|
||||
}
|
||||
|
||||
export function normalizeTimezone(timezone?: string): string {
|
||||
const candidate = timezone?.trim() || "UTC";
|
||||
try {
|
||||
// Validate timezone eagerly.
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: candidate });
|
||||
return candidate;
|
||||
} catch {
|
||||
return "UTC";
|
||||
}
|
||||
}
|
||||
|
||||
function getZonedPartsFormatter(timezone: string): Intl.DateTimeFormat {
|
||||
const cacheKey = normalizeTimezone(timezone);
|
||||
const cached = zonedPartsFormatterCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: cacheKey,
|
||||
hour12: false,
|
||||
hourCycle: "h23",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
zonedPartsFormatterCache.set(cacheKey, formatter);
|
||||
return formatter;
|
||||
}
|
||||
|
||||
function getZonedWeekdayFormatter(timezone: string): Intl.DateTimeFormat {
|
||||
const cacheKey = normalizeTimezone(timezone);
|
||||
const cached = zonedWeekdayFormatterCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const formatter = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: cacheKey,
|
||||
weekday: "short",
|
||||
});
|
||||
|
||||
zonedWeekdayFormatterCache.set(cacheKey, formatter);
|
||||
return formatter;
|
||||
}
|
||||
|
||||
function getZonedDateParts(date: Date, timezone: string): ZonedDateParts {
|
||||
const safeTimezone = normalizeTimezone(timezone);
|
||||
const parts = getZonedPartsFormatter(safeTimezone).formatToParts(date);
|
||||
|
||||
const month = Number(parts.find((part) => part.type === "month")?.value);
|
||||
const dayOfMonth = Number(parts.find((part) => part.type === "day")?.value);
|
||||
const hour = Number(parts.find((part) => part.type === "hour")?.value);
|
||||
const minute = Number(parts.find((part) => part.type === "minute")?.value);
|
||||
|
||||
const weekdayLabel = getZonedWeekdayFormatter(safeTimezone)
|
||||
.format(date)
|
||||
.toLowerCase()
|
||||
.slice(0, 3);
|
||||
const dayOfWeek = WEEKDAY_INDEX[weekdayLabel];
|
||||
|
||||
if (
|
||||
Number.isNaN(month) ||
|
||||
Number.isNaN(dayOfMonth) ||
|
||||
Number.isNaN(hour) ||
|
||||
Number.isNaN(minute) ||
|
||||
typeof dayOfWeek !== "number"
|
||||
) {
|
||||
throw new Error("Unable to extract timezone-aware date parts");
|
||||
}
|
||||
|
||||
return {
|
||||
month,
|
||||
dayOfMonth,
|
||||
hour,
|
||||
minute,
|
||||
dayOfWeek,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCronAtom(
|
||||
atom: string,
|
||||
min: number,
|
||||
max: number,
|
||||
aliases?: Record<string, number>,
|
||||
allowSevenAsSunday = false
|
||||
): number {
|
||||
const normalized = atom.trim().toLowerCase();
|
||||
if (normalized.length === 0) {
|
||||
throw new Error("Empty cron atom");
|
||||
}
|
||||
|
||||
const aliasValue = aliases?.[normalized];
|
||||
const parsed = aliasValue ?? Number(normalized);
|
||||
if (!Number.isInteger(parsed)) {
|
||||
throw new Error(`Invalid cron value: "${atom}"`);
|
||||
}
|
||||
|
||||
const normalizedDowValue = allowSevenAsSunday && parsed === 7 ? 0 : parsed;
|
||||
if (normalizedDowValue < min || normalizedDowValue > max) {
|
||||
throw new Error(
|
||||
`Cron value "${atom}" out of range (${min}-${max})`
|
||||
);
|
||||
}
|
||||
|
||||
return normalizedDowValue;
|
||||
}
|
||||
|
||||
function addRangeValues(
|
||||
target: Set<number>,
|
||||
start: number,
|
||||
end: number,
|
||||
step: number,
|
||||
min: number,
|
||||
max: number
|
||||
): void {
|
||||
if (step <= 0) {
|
||||
throw new Error(`Invalid cron step: ${step}`);
|
||||
}
|
||||
if (start < min || end > max || start > end) {
|
||||
throw new Error(`Invalid cron range: ${start}-${end}`);
|
||||
}
|
||||
|
||||
for (let value = start; value <= end; value += step) {
|
||||
target.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
function parseCronField(
|
||||
field: string,
|
||||
min: number,
|
||||
max: number,
|
||||
aliases?: Record<string, number>,
|
||||
allowSevenAsSunday = false
|
||||
): ParsedCronField {
|
||||
const raw = field.trim();
|
||||
if (raw === "*") {
|
||||
const values = new Set<number>();
|
||||
for (let i = min; i <= max; i += 1) values.add(i);
|
||||
return { wildcard: true, values };
|
||||
}
|
||||
|
||||
const values = new Set<number>();
|
||||
const segments = raw.split(",");
|
||||
for (const segment of segments) {
|
||||
const trimmedSegment = segment.trim();
|
||||
if (!trimmedSegment) {
|
||||
throw new Error(`Invalid cron field "${field}"`);
|
||||
}
|
||||
|
||||
const [basePart, stepPart] = trimmedSegment.split("/");
|
||||
const step = stepPart ? Number(stepPart) : 1;
|
||||
if (!Number.isInteger(step) || step <= 0) {
|
||||
throw new Error(`Invalid cron step "${stepPart}"`);
|
||||
}
|
||||
|
||||
if (basePart === "*") {
|
||||
addRangeValues(values, min, max, step, min, max);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (basePart.includes("-")) {
|
||||
const [startRaw, endRaw] = basePart.split("-");
|
||||
const start = parseCronAtom(
|
||||
startRaw,
|
||||
min,
|
||||
max,
|
||||
aliases,
|
||||
allowSevenAsSunday
|
||||
);
|
||||
const end = parseCronAtom(
|
||||
endRaw,
|
||||
min,
|
||||
max,
|
||||
aliases,
|
||||
allowSevenAsSunday
|
||||
);
|
||||
addRangeValues(values, start, end, step, min, max);
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = parseCronAtom(
|
||||
basePart,
|
||||
min,
|
||||
max,
|
||||
aliases,
|
||||
allowSevenAsSunday
|
||||
);
|
||||
values.add(value);
|
||||
}
|
||||
|
||||
return { wildcard: false, values };
|
||||
}
|
||||
|
||||
function parseCronExpression(expression: string): ParsedCronExpression {
|
||||
const parts = expression.trim().split(/\s+/);
|
||||
if (parts.length !== 5) {
|
||||
throw new Error(
|
||||
'Cron expression must have 5 parts: "minute hour day month weekday"'
|
||||
);
|
||||
}
|
||||
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
|
||||
return {
|
||||
minute: parseCronField(minute, 0, 59),
|
||||
hour: parseCronField(hour, 0, 23),
|
||||
dayOfMonth: parseCronField(dayOfMonth, 1, 31),
|
||||
month: parseCronField(month, 1, 12, MONTH_INDEX),
|
||||
dayOfWeek: parseCronField(dayOfWeek, 0, 6, WEEKDAY_INDEX, true),
|
||||
};
|
||||
}
|
||||
|
||||
function matchesCron(
|
||||
cron: ParsedCronExpression,
|
||||
parts: ZonedDateParts
|
||||
): boolean {
|
||||
if (!cron.minute.values.has(parts.minute)) return false;
|
||||
if (!cron.hour.values.has(parts.hour)) return false;
|
||||
if (!cron.month.values.has(parts.month)) return false;
|
||||
|
||||
const dayOfMonthWildcard = cron.dayOfMonth.wildcard;
|
||||
const dayOfWeekWildcard = cron.dayOfWeek.wildcard;
|
||||
const dayOfMonthMatches = cron.dayOfMonth.values.has(parts.dayOfMonth);
|
||||
const dayOfWeekMatches = cron.dayOfWeek.values.has(parts.dayOfWeek);
|
||||
|
||||
if (dayOfMonthWildcard && dayOfWeekWildcard) return true;
|
||||
if (dayOfMonthWildcard) return dayOfWeekMatches;
|
||||
if (dayOfWeekWildcard) return dayOfMonthMatches;
|
||||
return dayOfMonthMatches || dayOfWeekMatches;
|
||||
}
|
||||
|
||||
export function getNextCronOccurrence(
|
||||
expression: string,
|
||||
fromDate: Date,
|
||||
timezone = "UTC",
|
||||
maxLookaheadMinutes = 2 * 365 * 24 * 60
|
||||
): Date {
|
||||
const cron = parseCronExpression(expression);
|
||||
const safeTimezone = normalizeTimezone(timezone);
|
||||
|
||||
const base = new Date(fromDate);
|
||||
base.setSeconds(0, 0);
|
||||
const firstCandidateMs = base.getTime() + 60_000;
|
||||
|
||||
for (let offset = 0; offset <= maxLookaheadMinutes; offset += 1) {
|
||||
const candidate = new Date(firstCandidateMs + offset * 60_000);
|
||||
const candidateParts = getZonedDateParts(candidate, safeTimezone);
|
||||
if (matchesCron(cron, candidateParts)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Could not find next cron occurrence for "${expression}" within ${maxLookaheadMinutes} minutes`
|
||||
);
|
||||
}
|
||||
|
||||
export function getNextScheduledRun(
|
||||
schedule: string | number,
|
||||
fromDate: Date,
|
||||
timezone = "UTC"
|
||||
): Date {
|
||||
if (isCronExpression(schedule)) {
|
||||
return getNextCronOccurrence(schedule, fromDate, timezone);
|
||||
}
|
||||
|
||||
const intervalMs = parseInterval(schedule);
|
||||
return new Date(fromDate.getTime() + intervalMs);
|
||||
}
|
||||
|
||||
export function buildClockCronExpression(
|
||||
startTime: string,
|
||||
frequencyHours: number
|
||||
): string | null {
|
||||
const parsed = startTime.match(/^([01]\d|2[0-3]):([0-5]\d)$/);
|
||||
if (!parsed) return null;
|
||||
|
||||
if (!Number.isInteger(frequencyHours) || frequencyHours <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hour = Number(parsed[1]);
|
||||
const minute = Number(parsed[2]);
|
||||
|
||||
if (frequencyHours === 24) {
|
||||
return `${minute} ${hour} * * *`;
|
||||
}
|
||||
|
||||
if (frequencyHours === 1) {
|
||||
return `${minute} * * * *`;
|
||||
}
|
||||
|
||||
if (24 % frequencyHours !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hourCount = 24 / frequencyHours;
|
||||
const hours: number[] = [];
|
||||
for (let i = 0; i < hourCount; i += 1) {
|
||||
hours.push((hour + i * frequencyHours) % 24);
|
||||
}
|
||||
|
||||
return `${minute} ${hours.join(",")} * * *`;
|
||||
}
|
||||
|
||||
export function parseClockCronExpression(
|
||||
expression: string
|
||||
): { startTime: string; frequencyHours: number } | null {
|
||||
const parts = expression.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return null;
|
||||
|
||||
const [minuteRaw, hourRaw, dayRaw, monthRaw, weekdayRaw] = parts;
|
||||
if (dayRaw !== "*" || monthRaw !== "*" || weekdayRaw !== "*") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const minute = Number(minuteRaw);
|
||||
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hourRaw === "*") {
|
||||
return {
|
||||
startTime: `00:${pad2(minute)}`,
|
||||
frequencyHours: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const hourTokens = hourRaw.split(",");
|
||||
if (hourTokens.length === 0) return null;
|
||||
|
||||
const hours = hourTokens.map((token) => Number(token));
|
||||
if (hours.some((hour) => !Number.isInteger(hour) || hour < 0 || hour > 23)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (hours.length === 1) {
|
||||
return {
|
||||
startTime: `${pad2(hours[0])}:${pad2(minute)}`,
|
||||
frequencyHours: 24,
|
||||
};
|
||||
}
|
||||
|
||||
// Verify evenly spaced circular sequence to infer "every N hours".
|
||||
const deltas: number[] = [];
|
||||
for (let i = 0; i < hours.length; i += 1) {
|
||||
const current = hours[i];
|
||||
const next = i === hours.length - 1 ? hours[0] : hours[i + 1];
|
||||
const delta = (next - current + 24) % 24;
|
||||
deltas.push(delta);
|
||||
}
|
||||
|
||||
const expectedDelta = deltas[0];
|
||||
const uniform = deltas.every((delta) => delta === expectedDelta && delta > 0);
|
||||
if (!uniform || expectedDelta <= 0 || 24 % expectedDelta !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
startTime: `${pad2(hours[0])}:${pad2(minute)}`,
|
||||
frequencyHours: expectedDelta,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user