1import { MIN_SCHEDULED_TASK_INTERVAL_MS, type ScheduledTaskSchedule } from "@t3tools/contracts";
2import * as DateTime from "effect/DateTime";
3import * as Option from "effect/Option";
4
5const MINUTE_MS = 60_000;
6
7function parseTimeOfDay(value: string): { hour: number; minute: number } | null {
8 const match = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(value.trim());
9 if (!match) return null;
10 return { hour: Number(match[1]), minute: Number(match[2]) };
11}
12
13export function nextScheduledRunAt(
14 schedule: ScheduledTaskSchedule,
15 from: DateTime.DateTime,
16): DateTime.DateTime | null {
17 if (schedule.type === "webhook") return null;
18 if (schedule.type === "once") {
19 const runAt = DateTime.make(schedule.runAt);
20 return Option.isSome(runAt) &&
21 DateTime.toEpochMillis(runAt.value) > DateTime.toEpochMillis(from)
22 ? runAt.value
23 : null;
24 }
25 if (schedule.type === "interval") {
26 // Intervals advance elapsed time, including across daylight-saving changes.
27 return DateTime.addDuration(from, Math.max(schedule.everyMs, MIN_SCHEDULED_TASK_INTERVAL_MS));
28 }
29
30 const time = parseTimeOfDay(schedule.timeOfDay);
31 if (time === null) return null;
32 const weekdays =
33 schedule.weekdays && schedule.weekdays.length > 0 ? new Set(schedule.weekdays) : null;
34 for (let offset = 0; offset <= 7; offset += 1) {
35 const candidate = DateTime.setParts(DateTime.add(from, { days: offset }), {
36 hour: time.hour,
37 minute: time.minute,
38 second: 0,
39 millisecond: 0,
40 });
41 if (DateTime.toEpochMillis(candidate) <= DateTime.toEpochMillis(from)) continue;
42 if (weekdays !== null && !weekdays.has(DateTime.toParts(candidate).weekDay)) continue;
43 return candidate;
44 }
45 return null;
46}
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
54export function isSameSchedule(a: ScheduledTaskSchedule, b: ScheduledTaskSchedule): boolean {
55 if (a.type === "interval") {
56 return b.type === "interval" && a.everyMs === b.everyMs;
57 }
58 if (a.type === "webhook") return b.type === "webhook";
59 if (a.type === "once") return b.type === "once" && Date.parse(a.runAt) === Date.parse(b.runAt);
60 if (b.type !== "fixed_time") return false;
61 // Compare parsed times so padded-hour edits do not reschedule pending runs.
62 const aTime = parseTimeOfDay(a.timeOfDay);
63 const bTime = parseTimeOfDay(b.timeOfDay);
64 return (
65 aTime !== null &&
66 bTime !== null &&
67 aTime.hour === bTime.hour &&
68 aTime.minute === bTime.minute &&
69 weekdayKey(a.weekdays) === weekdayKey(b.weekdays)
70 );
71}
72
73const MISSED_FIXED_TIME_GRACE_MS = 10 * MINUTE_MS;
74
75export function isMissedFixedTimeRun(
76 schedule: ScheduledTaskSchedule,
77 dueAt: DateTime.DateTime,
78 now: DateTime.DateTime,
79): boolean {
80 if (schedule.type !== "fixed_time") return false;
81 return DateTime.toEpochMillis(now) - DateTime.toEpochMillis(dueAt) > MISSED_FIXED_TIME_GRACE_MS;
82}