1import { MIN_SCHEDULED_TASK_INTERVAL_MS, type ScheduledTaskSchedule } from "@t3tools/contracts";
2import * as DateTime from "effect/DateTime";
3
4const MINUTE_MS = 60_000;
5
6export 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
12export 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 */
48function 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. */
55export 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 */
81const 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 */
89export 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
98function 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}