返回 DeepSeek-Reasonix
heartbeat.schedule.ts
根目录 / desktop / frontend / src / custom / features / heartbeat / heartbeat.schedule.ts
1 import type { HeartbeatTask } from "./heartbeat.types";
2
3 const INTERVAL_MS: Record<"s" | "m" | "h", number> = {
4 s: 1000,
5 m: 60_000,
6 h: 3_600_000,
7 };
8
9 const WEEKDAYS: Record<string, number> = {
10 sun: 0,
11 mon: 1,
12 tue: 2,
13 wed: 3,
14 thu: 4,
15 fri: 5,
16 sat: 6,
17 };
18
19 interface CalendarSchedule {
20 kind: "daily" | "weekly" | "biweekly" | "monthly" | "yearly";
21 days: number[];
22 month: number;
23 day: number;
24 hour: number;
25 minute: number;
26 }
27
28 function heartbeatIntervalMs(interval?: string): number | null {
29 const clean = (interval || "").replace(/\|.*$/, "");
30 const match = clean.match(/^(\d+)([smh])$/);
31 if (!match) return null;
32 return parseInt(match[1], 10) * INTERVAL_MS[match[2] as "s" | "m" | "h"];
33 }
34
35 function heartbeatClockMinutes(value?: string): number | null {
36 const match = (value || "").match(/^(\d{2}):(\d{2})$/);
37 if (!match) return null;
38 const hour = parseInt(match[1], 10);
39 const minute = parseInt(match[2], 10);
40 if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
41 return hour * 60 + minute;
42 }
43
44 function dateAtMinutes(base: Date, minutes: number): Date {
45 const date = new Date(base);
46 date.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
47 return date;
48 }
49
50 function heartbeatWithinWindow(date: Date, start: number | null, end: number | null): boolean {
51 if (start === null && end === null) return true;
52 const minutes = date.getHours() * 60 + date.getMinutes();
53 if (start !== null && end === null) return minutes >= start;
54 if (start === null && end !== null) return minutes < end;
55 if (start === end) return true;
56 if (start! < end!) return minutes >= start! && minutes < end!;
57 return minutes >= start! || minutes < end!;
58 }
59
60 function nextHeartbeatWindowTime(from: Date, start: number | null, end: number | null): Date {
61 if (heartbeatWithinWindow(from, start, end)) return from;
62 if (start !== null && end === null) return dateAtMinutes(from, start);
63 if (start === null && end !== null) {
64 const next = new Date(from);
65 next.setDate(next.getDate() + 1);
66 next.setHours(0, 0, 0, 0);
67 return next;
68 }
69 const minutes = from.getHours() * 60 + from.getMinutes();
70 if (start! < end! && minutes < start!) return dateAtMinutes(from, start!);
71 if (start! > end! && minutes < start! && minutes >= end!) return dateAtMinutes(from, start!);
72 const next = dateAtMinutes(from, start!);
73 next.setDate(next.getDate() + 1);
74 return next;
75 }
76
77 function positiveInt(value: string, fallback: number): number {
78 return /^\d+$/.test(value.trim()) ? parseInt(value, 10) : fallback;
79 }
80
81 function parseCalendarScheduleImpl(interval?: string): CalendarSchedule | null {
82 const separator = (interval || "").indexOf("|");
83 if (separator < 0) return null;
84 const suffix = interval!.slice(separator + 1).trim();
85 const clockSeparator = suffix.indexOf("@");
86 const rulePart = clockSeparator < 0 ? suffix : suffix.slice(0, clockSeparator);
87 const clock = clockSeparator < 0 ? "09:00" : suffix.slice(clockSeparator + 1);
88 const clockMinutes = heartbeatClockMinutes(clock);
89 if (clockMinutes === null) return null;
90 const ruleSeparator = rulePart.indexOf(":");
91 const kind = ruleSeparator < 0 ? rulePart : rulePart.slice(0, ruleSeparator);
92 const rule = ruleSeparator < 0 ? "" : rulePart.slice(ruleSeparator + 1);
93 if (!(["daily", "weekly", "biweekly", "monthly", "yearly"] as const).includes(kind as CalendarSchedule["kind"])) {
94 return null;
95 }
96 const schedule: CalendarSchedule = {
97 kind: kind as CalendarSchedule["kind"],
98 days: [],
99 month: 1,
100 day: 1,
101 hour: Math.floor(clockMinutes / 60),
102 minute: clockMinutes % 60,
103 };
104 if (schedule.kind === "weekly" || schedule.kind === "biweekly") {
105 schedule.days = rule.split(",").map((day) => WEEKDAYS[day.trim().toLowerCase()]).filter((day) => day !== undefined);
106 if (schedule.days.length === 0) return null;
107 } else if (schedule.kind === "monthly") {
108 schedule.day = positiveInt(rule, 1);
109 } else if (schedule.kind === "yearly") {
110 const [month, day] = rule.split("-", 2);
111 schedule.month = Math.min(12, Math.max(1, positiveInt(month, 1)));
112 schedule.day = positiveInt(day || "", 1);
113 }
114 return schedule;
115 }
116
117 function calendarDate(year: number, month: number, day: number, schedule: CalendarSchedule): Date {
118 const maxDay = new Date(year, month + 1, 0).getDate();
119 return new Date(year, month, Math.min(Math.max(day, 1), maxDay), schedule.hour, schedule.minute, 0, 0);
120 }
121
122 function weekStart(date: Date): Date {
123 const start = new Date(date.getFullYear(), date.getMonth(), date.getDate());
124 start.setDate(start.getDate() - ((start.getDay() + 6) % 7));
125 return start;
126 }
127
128 function civilDayNumber(date: Date): number {
129 return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000);
130 }
131
132 // Exported for the panel's cycle interval math (nextCycleRunAt) so both frontend
133 // paths share one calendar implementation and cannot drift from each other.
134 export function parseCalendarSchedule(interval?: string): CalendarSchedule | null {
135 return parseCalendarScheduleImpl(interval);
136 }
137
138 export function calendarDateFor(year: number, month: number, day: number, schedule: CalendarSchedule): Date {
139 return calendarDate(year, month, day, schedule);
140 }
141
142 export function nextCalendarRunImpl(schedule: CalendarSchedule, after: Date, anchor: Date): Date {
143 return nextCalendarRun(schedule, after, anchor);
144 }
145
146 function nextCalendarRun(schedule: CalendarSchedule, after: Date, anchor: Date): Date {
147 if (schedule.kind === "daily") {
148 const candidate = calendarDate(after.getFullYear(), after.getMonth(), after.getDate(), schedule);
149 if (candidate.getTime() <= after.getTime()) candidate.setDate(candidate.getDate() + 1);
150 return candidate;
151 }
152 if (schedule.kind === "weekly" || schedule.kind === "biweekly") {
153 const searchDays = schedule.kind === "biweekly" ? 21 : 7;
154 for (let offset = 0; offset <= searchDays; offset += 1) {
155 const day = new Date(after.getFullYear(), after.getMonth(), after.getDate());
156 day.setDate(day.getDate() + offset);
157 if (!schedule.days.includes(day.getDay())) continue;
158 const candidate = calendarDate(day.getFullYear(), day.getMonth(), day.getDate(), schedule);
159 if (candidate.getTime() <= after.getTime()) continue;
160 if (schedule.kind === "biweekly") {
161 const weeks = Math.floor(Math.abs(civilDayNumber(weekStart(candidate)) - civilDayNumber(weekStart(anchor))) / 7);
162 if (weeks % 2 !== 0) continue;
163 }
164 return candidate;
165 }
166 }
167 if (schedule.kind === "monthly") {
168 let candidate = calendarDate(after.getFullYear(), after.getMonth(), schedule.day, schedule);
169 if (candidate.getTime() <= after.getTime()) {
170 const nextMonth = new Date(after.getFullYear(), after.getMonth() + 1, 1);
171 candidate = calendarDate(nextMonth.getFullYear(), nextMonth.getMonth(), schedule.day, schedule);
172 }
173 return candidate;
174 }
175 if (schedule.kind === "yearly") {
176 let candidate = calendarDate(after.getFullYear(), schedule.month - 1, schedule.day, schedule);
177 if (candidate.getTime() <= after.getTime()) candidate = calendarDate(after.getFullYear() + 1, schedule.month - 1, schedule.day, schedule);
178 return candidate;
179 }
180 return after;
181 }
182
183 export function heartbeatNextRunAt(
184 task: Pick<HeartbeatTask, "interval" | "lastRunAt" | "createdAt" | "timeWindowStart" | "timeWindowEnd">,
185 now = Date.now(),
186 ): number | null {
187 if (!task.lastRunAt) return null;
188 const intervalMs = heartbeatIntervalMs(task.interval);
189 if (intervalMs === null) return null;
190 const schedule = parseCalendarSchedule(task.interval);
191 if (schedule) {
192 const after = new Date(task.lastRunAt);
193 const anchor = new Date(task.createdAt || task.lastRunAt);
194 return nextCalendarRun(schedule, after, anchor).getTime();
195 }
196 const rawNext = task.lastRunAt + intervalMs;
197 const start = heartbeatClockMinutes(task.timeWindowStart);
198 const end = heartbeatClockMinutes(task.timeWindowEnd);
199 if (start === null && end === null) return rawNext;
200 const candidate = new Date(Math.max(rawNext, now));
201 return nextHeartbeatWindowTime(candidate, start, end).getTime();
202 }
203
203 lines TYPESCRIPT