MEGAINFRA-WEBAPP/foreman/mod/hooks/schedule.mjs
DEls e97bf93c7d chore: add foreman scheduler mod; keep it out of the app type-check
foreman/ is a Claude Code mod (job runner + hooks) that imports the
claude-code runtime types, so tsc over the app failed on it. Excluding
it from tsconfig keeps the Next build green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:05:50 +02:00

166 lines
6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Foreman's schedule language, shared by the pane (mod) and the runner (bin/runner.mjs).
//
// manual only when you press Run now
// mon-fri 08:00 11:00 14:00 set times on set days
// daily 07:30 every day
// mon,wed,fri 09:00 a list of days
// every 2h every two hours, all day
// every 30m mon-fri 07:00-17:00 every 30 minutes inside a window
//
// Times are the Mac's local time.
const DAY_NAMES = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
const DAY_LABEL = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6]
const MIN_EVERY = 5
const dayIndex = name => DAY_NAMES.indexOf(name.slice(0, 3))
function parseTime(token) {
let m = /^(\d{1,2}):(\d{2})$/.exec(token)
if (m) {
const h = Number(m[1])
const min = Number(m[2])
return h < 24 && min < 60 ? h * 60 + min : null
}
m = /^(\d{1,2})(?::(\d{2}))?(am|pm)$/.exec(token)
if (m) {
let h = Number(m[1]) % 12
if (m[3] === 'pm') h += 12
const min = Number(m[2] ?? 0)
return min < 60 ? h * 60 + min : null
}
return null
}
function parseDays(token) {
if (token === 'daily' || token === 'everyday' || token === 'every-day') return ALL_DAYS
if (token === 'weekdays') return [1, 2, 3, 4, 5]
if (token === 'weekends' || token === 'weekend') return [0, 6]
const range = /^([a-z]{3,9})-([a-z]{3,9})$/.exec(token)
if (range) {
const a = dayIndex(range[1])
const b = dayIndex(range[2])
if (a < 0 || b < 0) return null
const out = []
for (let d = a; ; d = (d + 1) % 7) {
out.push(d)
if (d === b) break
}
return out
}
const list = token.split(',').filter(Boolean).map(dayIndex)
return list.length && list.every(d => d >= 0) ? list : null
}
/** @returns {import('./schedule.d.mts').Schedule} */
export function parseSchedule(text) {
const t = String(text ?? '').trim().toLowerCase()
if (t === '' || t === 'manual' || t === 'off') return { kind: 'manual' }
const tokens = t.split(/\s+/)
let days = null
const times = []
let everyMin = null
let window = null
for (let i = 0; i < tokens.length; i++) {
const tok = tokens[i]
if (tok === 'at' || tok === 'and') continue
if (tok === 'every') {
const amount = tokens[++i] ?? ''
const m = /^(\d+)\s*(m|min|mins|minutes?|h|hr|hrs|hours?)$/.exec(amount)
if (!m) return { kind: 'error', error: `After "every" put an amount like 2h or 30m (got "${amount}").` }
everyMin = Number(m[1]) * (m[2].startsWith('h') ? 60 : 1)
if (everyMin < MIN_EVERY) return { kind: 'error', error: `The shortest interval is every ${MIN_EVERY}m.` }
continue
}
const win = /^(\S+)-(\d{1,2}:\d{2}(?:am|pm)?)$/.exec(tok)
if (win && parseTime(win[1]) !== null && parseTime(win[2]) !== null) {
window = [parseTime(win[1]), parseTime(win[2])]
continue
}
const time = parseTime(tok)
if (time !== null) {
times.push(time)
continue
}
const d = parseDays(tok)
if (d) {
days = [...new Set([...(days ?? []), ...d])].sort()
continue
}
return { kind: 'error', error: `Didn't understand "${tok}". Try "mon-fri 08:00 14:00" or "every 2h".` }
}
const useDays = days ?? ALL_DAYS
if (everyMin !== null) return { kind: 'every', days: useDays, everyMin, window }
if (times.length) return { kind: 'times', days: useDays, times: [...new Set(times)].sort((a, b) => a - b) }
return { kind: 'error', error: 'Add a time (08:00), an interval (every 2h), or "manual".' }
}
function slotsOn(s, dayStart) {
if (s.kind !== 'times' && s.kind !== 'every') return []
if (!s.days.includes(dayStart.getDay())) return []
const mins =
s.kind === 'times'
? s.times
: (() => {
const [from, to] = s.window ?? [0, 24 * 60 - 1]
const out = []
for (let m = from; m <= to; m += s.everyMin) out.push(m)
return out
})()
return mins.map(m => new Date(dayStart.getFullYear(), dayStart.getMonth(), dayStart.getDate(), Math.floor(m / 60), m % 60))
}
const startOfDay = (d, offset) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + offset)
/** The latest scheduled moment at or before `now`, or null. */
export function lastSlot(s, now = new Date()) {
for (let back = 0; back <= 7; back++) {
const slots = slotsOn(s, startOfDay(now, -back)).filter(x => x <= now)
if (slots.length) return slots[slots.length - 1]
}
return null
}
/** The next scheduled moment after `now`, or null. */
export function nextSlot(s, now = new Date()) {
for (let ahead = 0; ahead <= 7; ahead++) {
const slot = slotsOn(s, startOfDay(now, ahead)).find(x => x > now)
if (slot) return slot
}
return null
}
/** Every slot between from and to (inclusive), oldest first. */
export function slotsBetween(s, from, to) {
const out = []
for (let d = startOfDay(from, 0); d <= to; d = startOfDay(d, 1)) {
for (const x of slotsOn(s, d)) if (x >= from && x <= to) out.push(x)
}
return out
}
const hhmm = m => `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`
function daysLabel(days) {
const key = [...days].sort().join(',')
if (key === '0,1,2,3,4,5,6') return 'every day'
if (key === '1,2,3,4,5') return 'Mon–Fri'
if (key === '0,6') return 'weekends'
return days.map(d => DAY_LABEL[d]).join(', ')
}
/** A short human description. */
export function describeSchedule(s) {
if (s.kind === 'manual') return 'Manual only'
if (s.kind === 'error') return `⚠ ${s.error}`
if (s.kind === 'times') return `${daysLabel(s.days)} at ${s.times.map(hhmm).join(', ')}`
const every = s.everyMin % 60 === 0 ? `${s.everyMin / 60}h` : `${s.everyMin}m`
return `Every ${every}, ${daysLabel(s.days)}${s.window ? `, ${hhmm(s.window[0])}–${hhmm(s.window[1])}` : ''}`
}
/** How late a missed slot may still run (the Mac was asleep, say). */
export function graceMs(s) {
const twoHours = 2 * 60 * 60 * 1000
return s.kind === 'every' ? Math.min(twoHours, s.everyMin * 60 * 1000) : twoHours
}