All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m9s
- Add scheduledTime, scheduledDay, timezone fields to Agent schema - Create calculateNextRun() helper with timezone-aware scheduling - Add POST /api/cron/agents endpoint for external scheduler - Calculate nextRun on agent create, update, and after execution - Add time/day picker in agent form (daily/weekly/monthly) - Show "Next run" countdown in agent card - Add i18n keys for schedule UI (FR + EN) External scheduler (N8N, Vercel Cron) should call /api/cron/agents every 5-15 min. Requires `prisma db push` to apply schema changes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
163 lines
5.9 KiB
TypeScript
163 lines
5.9 KiB
TypeScript
/**
|
|
* 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 todayDay = parseInt(todayParts[1])
|
|
const todayMonth = parseInt(todayParts[2]) - 1
|
|
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[2]) - 1, parseInt(tmParts[1]), 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 candidateDay = parseInt(parts[1])
|
|
const candidateMonth = parseInt(parts[2]) - 1
|
|
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<string, number> = { 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()
|
|
}
|