| 1 | import { MIN_SCHEDULED_TASK_INTERVAL_MS, type ScheduledTaskSchedule } from "@t3tools/contracts"; |
| 2 | import * as DateTime from "effect/DateTime"; |
| 3 | |
| 4 | const MINUTE_MS = 60_000; |
| 5 | |
| 6 | export function parseTimeOfDay(value: string): { hour: number; minute: number } | null { |
| 7 | const match = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(value.trim()); |
| 8 | if (!match) return null; |
| 9 | return { hour: Number(match[1]), minute: Number(match[2]) }; |
| 10 | } |
| 11 | |
| 12 | export function nextScheduledRunAt( |
| 13 | schedule: ScheduledTaskSchedule, |
| 14 | from: DateTime.DateTime, |
| 15 | ): DateTime.DateTime | null { |
| 16 | if (schedule.type === "webhook") return null; |
| 17 | if (schedule.type === "interval") { |
| 18 | // Persisted rows created before the one-minute floor remain readable, but |
| 19 | // they must not retain their old high-frequency execution rate. |
| 20 | return DateTime.add(from, { |
| 21 | milliseconds: Math.max(schedule.everyMs, MIN_SCHEDULED_TASK_INTERVAL_MS), |
| 22 | }); |
| 23 | } |
| 24 | |
| 25 | const time = parseTimeOfDay(schedule.timeOfDay); |
| 26 | if (time === null) return null; |
| 27 | const weekdays = |
| 28 | schedule.weekdays && schedule.weekdays.length > 0 ? new Set(schedule.weekdays) : null; |
| 29 | for (let offset = 0; offset <= 7; offset += 1) { |
| 30 | const candidate = DateTime.setParts(DateTime.add(from, { days: offset }), { |
| 31 | hour: time.hour, |
| 32 | minute: time.minute, |
| 33 | second: 0, |
| 34 | millisecond: 0, |
| 35 | }); |
| 36 | if (DateTime.toEpochMillis(candidate) <= DateTime.toEpochMillis(from)) continue; |
| 37 | if (weekdays !== null && !weekdays.has(DateTime.toParts(candidate).weekDay)) continue; |
| 38 | return candidate; |
| 39 | } |
| 40 | return null; |
| 41 | } |
| 42 | |
| 43 | /** |
| 44 | * Canonical form of a weekday mask, mirroring how `nextScheduledRunAt` reads |
| 45 | * it: order and duplicates are irrelevant, and an empty/omitted mask means the |
| 46 | * same as explicitly listing all seven days — daily. |
| 47 | */ |
| 48 | function weekdayKey(weekdays: ReadonlyArray<number> | undefined): string { |
| 49 | const unique = [...new Set(weekdays ?? [])].toSorted((x, y) => x - y); |
| 50 | if (unique.length === 0 || unique.length === 7) return "daily"; |
| 51 | return unique.join(","); |
| 52 | } |
| 53 | |
| 54 | /** Semantic equality for schedules: true iff both fire at the same times. */ |
| 55 | export function isSameSchedule(a: ScheduledTaskSchedule, b: ScheduledTaskSchedule): boolean { |
| 56 | if (a.type === "interval") { |
| 57 | return b.type === "interval" && a.everyMs === b.everyMs; |
| 58 | } |
| 59 | // Webhook tasks never have a next run, whatever their signature settings. |
| 60 | if (a.type === "webhook") return b.type === "webhook"; |
| 61 | if (b.type !== "fixed_time") return false; |
| 62 | // The contract accepts padded and unpadded hours ("9:00" and "09:00"), so |
| 63 | // compare the parsed time — string equality would treat a format-only edit |
| 64 | // as a schedule change and recompute the pending run. |
| 65 | const aTime = parseTimeOfDay(a.timeOfDay); |
| 66 | const bTime = parseTimeOfDay(b.timeOfDay); |
| 67 | return ( |
| 68 | aTime !== null && |
| 69 | bTime !== null && |
| 70 | aTime.hour === bTime.hour && |
| 71 | aTime.minute === bTime.minute && |
| 72 | weekdayKey(a.weekdays) === weekdayKey(b.weekdays) |
| 73 | ); |
| 74 | } |
| 75 | |
| 76 | /** |
| 77 | * How late a fixed-time run may fire before it counts as missed. Covers poll |
| 78 | * jitter and short sleeps, while a server booted hours after the slot skips |
| 79 | * to the next occurrence instead of firing stale work at a random time. |
| 80 | */ |
| 81 | const MISSED_FIXED_TIME_GRACE_MS = 10 * MINUTE_MS; |
| 82 | |
| 83 | /** |
| 84 | * True when a due fixed-time run was missed by more than the grace window and |
| 85 | * should be rescheduled to its next occurrence instead of firing now. |
| 86 | * Interval schedules are never considered missed: an overdue interval task |
| 87 | * catching up with a single run is the desired behaviour. |
| 88 | */ |
| 89 | export function isMissedFixedTimeRun( |
| 90 | schedule: ScheduledTaskSchedule, |
| 91 | dueAt: DateTime.DateTime, |
| 92 | now: DateTime.DateTime, |
| 93 | ): boolean { |
| 94 | if (schedule.type !== "fixed_time") return false; |
| 95 | return DateTime.toEpochMillis(now) - DateTime.toEpochMillis(dueAt) > MISSED_FIXED_TIME_GRACE_MS; |
| 96 | } |
| 97 | |
| 98 | function describeSchedule(schedule: ScheduledTaskSchedule): string { |
| 99 | if (schedule.type === "webhook") return "On webhook"; |
| 100 | if (schedule.type === "interval") { |
| 101 | const minutes = schedule.everyMs / MINUTE_MS; |
| 102 | if (Number.isInteger(minutes)) { |
| 103 | return `Every ${minutes === 1 ? "minute" : `${minutes} minutes`}`; |
| 104 | } |
| 105 | return `Every ${Math.round(schedule.everyMs / 1000)} seconds`; |
| 106 | } |
| 107 | |
| 108 | const weekdayCount = schedule.weekdays?.length ?? 0; |
| 109 | const days = |
| 110 | weekdayCount === 0 |
| 111 | ? "day" |
| 112 | : weekdayCount === 5 && schedule.weekdays?.every((day) => day >= 1 && day <= 5) |
| 113 | ? "weekday" |
| 114 | : "selected day"; |
| 115 | return `At ${schedule.timeOfDay} every ${days}`; |
| 116 | } |