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>
166 lines
6 KiB
JavaScript
166 lines
6 KiB
JavaScript
// 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
|
||
}
|