HuggingClaw-MissionControl / src /lib /cron-occurrences.ts
Nyk
fix: resolve cron calendar and auth regressions from open issues
250a974
Raw
History Blame Contribute Delete
4.08 kB
export interface CronOccurrence {
atMs: number
dayKey: string
}
interface ParsedField {
any: boolean
matches: (value: number) => boolean
}
interface ParsedCron {
minute: ParsedField
hour: ParsedField
dayOfMonth: ParsedField
month: ParsedField
dayOfWeek: ParsedField
}
function normalizeCronExpression(raw: string): string {
const trimmed = raw.trim()
const tzSuffixMatch = trimmed.match(/^(.*)\s+\([^)]+\)$/)
return (tzSuffixMatch?.[1] || trimmed).trim()
}
function parseToken(token: string, min: number, max: number): { any: boolean; values: Set<number> } {
const valueSet = new Set<number>()
const trimmed = token.trim()
if (trimmed === '*') {
for (let i = min; i <= max; i += 1) valueSet.add(i)
return { any: true, values: valueSet }
}
for (const part of trimmed.split(',')) {
const section = part.trim()
if (!section) continue
const [rangePart, stepPart] = section.split('/')
const step = stepPart ? Number(stepPart) : 1
if (!Number.isFinite(step) || step <= 0) continue
if (rangePart === '*') {
for (let i = min; i <= max; i += step) valueSet.add(i)
continue
}
if (rangePart.includes('-')) {
const [fromRaw, toRaw] = rangePart.split('-')
const from = Number(fromRaw)
const to = Number(toRaw)
if (!Number.isFinite(from) || !Number.isFinite(to)) continue
const start = Math.max(min, Math.min(max, from))
const end = Math.max(min, Math.min(max, to))
for (let i = start; i <= end; i += step) valueSet.add(i)
continue
}
const single = Number(rangePart)
if (!Number.isFinite(single)) continue
if (single >= min && single <= max) valueSet.add(single)
}
return { any: false, values: valueSet }
}
function parseField(token: string, min: number, max: number): ParsedField {
const parsed = parseToken(token, min, max)
return {
any: parsed.any,
matches: (value: number) => parsed.values.has(value),
}
}
function parseCron(raw: string): ParsedCron | null {
const normalized = normalizeCronExpression(raw)
const parts = normalized.split(/\s+/).filter(Boolean)
if (parts.length !== 5) return null
return {
minute: parseField(parts[0], 0, 59),
hour: parseField(parts[1], 0, 23),
dayOfMonth: parseField(parts[2], 1, 31),
month: parseField(parts[3], 1, 12),
dayOfWeek: parseField(parts[4], 0, 6),
}
}
function matchesDay(parsed: ParsedCron, date: Date): boolean {
const dayOfMonthMatches = parsed.dayOfMonth.matches(date.getDate())
const dayOfWeekMatches = parsed.dayOfWeek.matches(date.getDay())
if (parsed.dayOfMonth.any && parsed.dayOfWeek.any) return true
if (parsed.dayOfMonth.any) return dayOfWeekMatches
if (parsed.dayOfWeek.any) return dayOfMonthMatches
return dayOfMonthMatches || dayOfWeekMatches
}
export function buildDayKey(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export function getCronOccurrences(
schedule: string,
rangeStartMs: number,
rangeEndMs: number,
max = 1000
): CronOccurrence[] {
if (!schedule || !Number.isFinite(rangeStartMs) || !Number.isFinite(rangeEndMs)) return []
if (rangeEndMs <= rangeStartMs || max <= 0) return []
const parsed = parseCron(schedule)
if (!parsed) return []
const occurrences: CronOccurrence[] = []
const cursor = new Date(rangeStartMs)
cursor.setSeconds(0, 0)
if (cursor.getTime() < rangeStartMs) {
cursor.setMinutes(cursor.getMinutes() + 1, 0, 0)
}
while (cursor.getTime() < rangeEndMs && occurrences.length < max) {
if (
parsed.month.matches(cursor.getMonth() + 1) &&
matchesDay(parsed, cursor) &&
parsed.hour.matches(cursor.getHours()) &&
parsed.minute.matches(cursor.getMinutes())
) {
occurrences.push({
atMs: cursor.getTime(),
dayKey: buildDayKey(cursor),
})
}
cursor.setMinutes(cursor.getMinutes() + 1, 0, 0)
}
return occurrences
}