/** * Agent Schedule Helper * Computes the next run date for an agent based on its frequency, scheduled time, and timezone. */ interface ScheduleParams { frequency: string // manual, hourly, daily, weekly, monthly scheduledTime?: string | null // "HH:mm" in user's local time scheduledDay?: number | null // 0-6 (Mon-Sun) for weekly, 1-31 for monthly timezone?: string | null // IANA timezone, e.g. "Europe/Paris" } /** * Parse "HH:mm" into hours and minutes. */ function parseTime(time: string): { hours: number; minutes: number } { const [h, m] = time.split(':').map(Number) return { hours: h, minutes: m || 0 } } /** * Calculate the next run date for an agent. * Returns a UTC Date, or null for manual frequency. */ export function calculateNextRun(params: ScheduleParams): Date | null { const { frequency, scheduledTime, scheduledDay, timezone } = params if (frequency === 'manual') return null const tz = timezone || 'UTC' const time = scheduledTime || '08:00' const { hours, minutes } = parseTime(time) const now = new Date() if (frequency === 'hourly') { return new Date(now.getTime() + 60 * 60 * 1000) } // For daily/weekly/monthly, build the target in the user's timezone then convert to UTC const fmt = (d: Date) => new Intl.DateTimeFormat('en-US', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: tz, }).format(d) // Helper: create a Date in the user's timezone for a given local date/time const makeDateInTZ = (year: number, month: number, day: number, h: number, m: number): Date => { // Build a string like "2026-04-26 09:00" in the user's timezone const localStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}T${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00` // Parse as if it's in the user's timezone by using Intl to find the UTC offset // We use a trick: format the date in the TZ, then find the offset const fakeUtc = new Date(localStr + 'Z') // Get the UTC offset for this time in the timezone const offsetMs = getTimezoneOffset(tz, fakeUtc) return new Date(fakeUtc.getTime() - offsetMs) } if (frequency === 'daily') { // Try today first, if past → tomorrow const todayStr = fmt(now) const todayParts = todayStr.match(/(\d{2})\/(\d{2})\/(\d{4}), (\d{2}):(\d{2}):(\d{2})/) if (!todayParts) return new Date(now.getTime() + 24 * 60 * 60 * 1000) const todayMonth = parseInt(todayParts[1]) - 1 const todayDay = parseInt(todayParts[2]) const todayYear = parseInt(todayParts[3]) const target = makeDateInTZ(todayYear, todayMonth, todayDay, hours, minutes) if (target > now) return target // Tomorrow const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000) const tmStr = fmt(tomorrow) const tmParts = tmStr.match(/(\d{2})\/(\d{2})\/(\d{4}),/) if (!tmParts) return new Date(now.getTime() + 24 * 60 * 60 * 1000) return makeDateInTZ(parseInt(tmParts[3]), parseInt(tmParts[1]) - 1, parseInt(tmParts[2]), hours, minutes) } if (frequency === 'weekly') { const targetDay = scheduledDay ?? 0 // 0=Monday // JS getDay(): 0=Sun, 1=Mon, ... 6=Sat // Our convention: 0=Mon, 1=Tue, ... 6=Sun const jsDay = targetDay === 6 ? 0 : targetDay + 1 // Start from today, scan forward up to 7 days for (let i = 0; i <= 7; i++) { const candidate = new Date(now.getTime() + i * 24 * 60 * 60 * 1000) const candidateStr = fmt(candidate) const parts = candidateStr.match(/(\d{2})\/(\d{2})\/(\d{4}),/) if (!parts) continue const candidateMonth = parseInt(parts[1]) - 1 const candidateDay = parseInt(parts[2]) const candidateYear = parseInt(parts[3]) // Get day of week in the user's timezone const dayOfWeek = new Intl.DateTimeFormat('en-US', { weekday: 'short', timeZone: tz }).format(candidate) // Map to our convention const dayMap: Record = { Mon: 0, Tue: 1, Wed: 2, Thu: 3, Fri: 4, Sat: 5, Sun: 6 } const thisDayIndex = dayMap[dayOfWeek] if (thisDayIndex === targetDay) { const target = makeDateInTZ(candidateYear, candidateMonth, candidateDay, hours, minutes) if (target > now) return target } } // Fallback: next week same day return new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000) } if (frequency === 'monthly') { const targetDayOfMonth = scheduledDay ?? 1 // Try this month, then next month, then the month after for (let offset = 0; offset <= 2; offset++) { const base = new Date(now) base.setMonth(base.getMonth() + offset) const year = base.getFullYear() const month = base.getMonth() // Clamp to max days in month const maxDays = new Date(year, month + 1, 0).getDate() const day = Math.min(targetDayOfMonth, maxDays) const target = makeDateInTZ(year, month, day, hours, minutes) if (target > now) return target } // Fallback return new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000) } return null } /** * Get the timezone offset in ms for a given timezone at a specific UTC time. * Positive offset means the timezone is ahead of UTC. */ function getTimezoneOffset(tz: string, utcDate: Date): number { // Get local time in the target timezone const localStr = new Intl.DateTimeFormat('en-US', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZone: tz, }).format(utcDate) // Parse the local time const parts = localStr.match(/(\d{2})\/(\d{2})\/(\d{4}), (\d{2}):(\d{2}):(\d{2})/) if (!parts) return 0 const localDate = new Date( parseInt(parts[3]), parseInt(parts[1]) - 1, parseInt(parts[2]), parseInt(parts[4]) === 24 ? 0 : parseInt(parts[4]), parseInt(parts[5]), parseInt(parts[6]) ) // Offset = local - UTC (in ms) return localDate.getTime() - utcDate.getTime() }