| 1 | import type { HeartbeatTask } from "./heartbeat.types"; |
| 2 | import type { HeartbeatTranslator } from "./heartbeat.i18n"; |
| 3 | import { heartbeatNextRunAt as calendarHeartbeatNextRunAt, parseCalendarSchedule, nextCalendarRunImpl } from "./heartbeat.schedule"; |
| 4 | |
| 5 | const WEEKDAYS = [ |
| 6 | { key: "mon", labelKey: "heartbeat.weekdayMon" }, |
| 7 | { key: "tue", labelKey: "heartbeat.weekdayTue" }, |
| 8 | { key: "wed", labelKey: "heartbeat.weekdayWed" }, |
| 9 | { key: "thu", labelKey: "heartbeat.weekdayThu" }, |
| 10 | { key: "fri", labelKey: "heartbeat.weekdayFri" }, |
| 11 | { key: "sat", labelKey: "heartbeat.weekdaySat" }, |
| 12 | { key: "sun", labelKey: "heartbeat.weekdaySun" }, |
| 13 | ] as const; |
| 14 | |
| 15 | // 日历调度计算(interval 窗口 / daily / weekly / biweekly / monthly / yearly / |
| 16 | // 月末 clamp / 闰日 / 双周锚点 / DST)统一委托给 heartbeat.schedule.ts,面板只 |
| 17 | // 负责 cron 分支与展示格式化,避免两套日历逻辑漂移。 |
| 18 | export function heartbeatNextRunAt(task: Pick<HeartbeatTask, "interval" | "lastRunAt" | "createdAt" | "timeWindowStart" | "timeWindowEnd">, now = Date.now()): number | null { |
| 19 | if (isCronExpr(task.interval || "")) { |
| 20 | return nextCronRunAt(task.interval || "", now); |
| 21 | } |
| 22 | return calendarHeartbeatNextRunAt(task, now); |
| 23 | } |
| 24 | |
| 25 | // 周期任务("24h|daily@22:00" 等)从 `from` 起的下一次触发时刻。calendar |
| 26 | // schedule 的 nextCalendarRun 语义就是 "after 之后的下一次",与后端 |
| 27 | // previousHeartbeatScheduleAt 对齐(月末 clamp、闰日、周一制双周锚点)。 |
| 28 | export function nextCycleRunAt(interval: string, from = Date.now(), createdAt?: number): number | null { |
| 29 | const schedule = parseCalendarSchedule(interval); |
| 30 | if (!schedule) return null; |
| 31 | const after = new Date(from); |
| 32 | const anchor = createdAt ? new Date(createdAt) : after; |
| 33 | return nextCalendarRunImpl(schedule, after, anchor).getTime(); |
| 34 | } |
| 35 | |
| 36 | export function formatInterval(interval: string, t: HeartbeatTranslator): string { |
| 37 | const cycleMatch = interval.match(/^(\d+)[smh]\|(daily|weekly|biweekly|monthly|yearly)(?::([^@]*))?(?:@(\d{2}:\d{2}))?$/); |
| 38 | if (cycleMatch) { |
| 39 | const [, , type, days, time] = cycleMatch; |
| 40 | // 格式参考:周一(时间:9:00);每天直接「每天 22:00」,不套(时间:)包装 |
| 41 | const timeStr = time ? t("heartbeat.cycleTimeAt", { time }) : ""; |
| 42 | const dayNames = (d: string) => { |
| 43 | const wd = WEEKDAYS.find((w) => w.key === d); |
| 44 | return wd ? t(wd.labelKey) : d; |
| 45 | }; |
| 46 | if (type === "daily") return time ? `${t("heartbeat.cycleDaily")} ${time}` : t("heartbeat.cycleDaily"); |
| 47 | if (type === "weekly") { |
| 48 | const list = (days || "").split(",").filter(Boolean).map(dayNames).join(t("heartbeat.joinComma")); |
| 49 | return `${list || t("heartbeat.cycleWeekly")}${timeStr}`; |
| 50 | } |
| 51 | if (type === "biweekly") { |
| 52 | const list = (days || "").split(",").filter(Boolean).map(dayNames).join(t("heartbeat.joinComma")); |
| 53 | return `${t("heartbeat.cycleBiweekly")}${list ? ` ${list}` : ""}${timeStr}`; |
| 54 | } |
| 55 | if (type === "monthly") return `${t("heartbeat.cycleMonthly")}${days ? ` ${days}${t("heartbeat.monthDay")}` : ""}${timeStr}`; |
| 56 | if (type === "yearly") { |
| 57 | const parts = (days || "").split("-"); |
| 58 | return `${t("heartbeat.cycleYearly")} ${parts[0] || "1"}/${parts[1] || "1"}${timeStr}`; |
| 59 | } |
| 60 | } |
| 61 | const simple = interval.match(/^(\d+)([smh])$/); |
| 62 | if (simple) { |
| 63 | const unitLabels: Record<string, string> = { |
| 64 | s: t("heartbeat.unitSec"), |
| 65 | m: t("heartbeat.unitMin"), |
| 66 | h: t("heartbeat.unitHour"), |
| 67 | }; |
| 68 | return `${t("heartbeat.freqEvery")} ${simple[1]}${unitLabels[simple[2]] || simple[2]}`; |
| 69 | } |
| 70 | return interval; |
| 71 | } |
| 72 | |
| 73 | // ── Cron expressions ───────────────────────────────────────────────────────── |
| 74 | |
| 75 | // isCronExpr returns true when s looks like a 5-field cron expression |
| 76 | // (e.g. "0 * * * *", "*/15 * * * *", "0 9 * * 1-5"). |
| 77 | // formatRelativeTime renders how long ago something happened: "just now", |
| 78 | // "N minutes ago", "N hours ago", "N days ago". |
| 79 | export function formatRelativeTime(at: number, now: number, t: HeartbeatTranslator): string { |
| 80 | const diff = Math.max(0, now - at); |
| 81 | const min = Math.floor(diff / 60000); |
| 82 | if (min < 1) return t("heartbeat.justNow"); |
| 83 | if (min < 60) return t("heartbeat.minutesAgo", { n: min }); |
| 84 | const hr = Math.floor(min / 60); |
| 85 | if (hr < 24) return t("heartbeat.hoursAgo", { n: hr }); |
| 86 | const d = Math.floor(hr / 24); |
| 87 | return t("heartbeat.daysAgo", { n: d }); |
| 88 | } |
| 89 | |
| 90 | export function isCronExpr(s: string): boolean { |
| 91 | const fields = s.trim().split(/\s+/); |
| 92 | if (fields.length !== 5) return false; |
| 93 | if (!fields.every((f) => f !== "" && /^[0-9*/\-,]+$/.test(f))) return false; |
| 94 | // Reject out-of-range values (e.g. "99 * * * *"), zero/empty steps |
| 95 | // ("*/0" never fires: value % 0 is NaN), and descending ranges ("5-1" |
| 96 | // never matches). dom/month are 1-based (0 can never match getDate()/ |
| 97 | // getMonth()); dow is 0-7 with 7 accepted as the Sunday alias — mirror the |
| 98 | // Go engine exactly. |
| 99 | const limits = [59, 23, 31, 12, 7]; // min, hour, dom, month, dow |
| 100 | const mins = [0, 0, 1, 1, 0]; |
| 101 | return fields.every((f, i) => |
| 102 | f.split(",").every((part) => { |
| 103 | const slashIdx = part.indexOf("/"); |
| 104 | const base = slashIdx >= 0 ? part.slice(0, slashIdx) : part; |
| 105 | if (slashIdx >= 0) { |
| 106 | const step = Number(part.slice(slashIdx + 1)); |
| 107 | if (!Number.isInteger(step) || step < 1) return false; |
| 108 | } |
| 109 | if (base === "*") return true; |
| 110 | if (base.includes("-")) { |
| 111 | const [lo, hi] = base.split("-").map(Number); |
| 112 | return Number.isInteger(lo) && Number.isInteger(hi) |
| 113 | && lo >= mins[i] && hi <= limits[i] && lo <= hi; |
| 114 | } |
| 115 | const v = Number(base); |
| 116 | return Number.isInteger(v) && v >= mins[i] && v <= limits[i]; |
| 117 | }) |
| 118 | ); |
| 119 | } |
| 120 | |
| 121 | function cronFieldMatch(pattern: string, value: number, minValue: number, maxValue: number): boolean { |
| 122 | for (const part of pattern.split(",")) { |
| 123 | const p = part.trim(); |
| 124 | let base = p; |
| 125 | let step = 1; |
| 126 | const slashIdx = p.indexOf("/"); |
| 127 | if (slashIdx >= 0) { |
| 128 | base = p.slice(0, slashIdx); |
| 129 | step = parseInt(p.slice(slashIdx + 1)) || 1; |
| 130 | } |
| 131 | if (step <= 0) continue; |
| 132 | if (base === "*") { |
| 133 | if (value >= minValue && value <= maxValue && (value - minValue) % step === 0) return true; |
| 134 | continue; |
| 135 | } |
| 136 | const dashIdx = base.indexOf("-"); |
| 137 | let low = parseInt(base); |
| 138 | let high = low; |
| 139 | if (dashIdx >= 0) { |
| 140 | low = parseInt(base.slice(0, dashIdx)); |
| 141 | high = parseInt(base.slice(dashIdx + 1)); |
| 142 | } else if (slashIdx >= 0) { |
| 143 | high = maxValue; |
| 144 | } |
| 145 | if (isNaN(low) || isNaN(high)) continue; |
| 146 | if (value < low || value > high) continue; |
| 147 | if ((value - low) % step === 0) return true; |
| 148 | } |
| 149 | return false; |
| 150 | } |
| 151 | |
| 152 | // nextCronRunAt returns the timestamp of the next time the 5-field cron |
| 153 | // expression matches, starting from `from` (default now). Returns null when |
| 154 | // the expression is invalid or nothing matches within the search horizon. |
| 155 | export function nextCronRunAt(expr: string, from = Date.now()): number | null { |
| 156 | if (!isCronExpr(expr)) return null; |
| 157 | const fields = expr.trim().split(/\s+/); |
| 158 | if (fields.length !== 5) return null; |
| 159 | const [minP, hourP, domP, monP, dowP] = fields; |
| 160 | if (![minP, hourP, domP, monP, dowP].every((f) => /^[0-9*/\-,]+$/.test(f))) return null; |
| 161 | const base = new Date(from); |
| 162 | base.setSeconds(0, 0); |
| 163 | for (let day = 0; day <= 366 * 8; day++) { |
| 164 | const d = new Date(base); |
| 165 | d.setDate(d.getDate() + day); |
| 166 | if (!cronFieldMatch(monP, d.getMonth() + 1, 1, 12)) continue; |
| 167 | // Standard cron: dom & dow are OR-ed when both are restricted. |
| 168 | const domRestricted = domP !== "*"; |
| 169 | const dowRestricted = dowP !== "*"; |
| 170 | const domMatch = cronFieldMatch(domP, d.getDate(), 1, 31); |
| 171 | // 7 is the standard Sunday alias in the dow field (getDay() is 0-6). |
| 172 | const dowMatch = cronFieldMatch(dowP, d.getDay(), 0, 7) || (d.getDay() === 0 && cronFieldMatch(dowP, 7, 0, 7)); |
| 173 | const dayMatch = domRestricted && dowRestricted ? domMatch || dowMatch |
| 174 | : domRestricted ? domMatch |
| 175 | : dowRestricted ? dowMatch |
| 176 | : true; |
| 177 | if (!dayMatch) continue; |
| 178 | const hStart = day === 0 ? d.getHours() : 0; |
| 179 | for (let h = hStart; h < 24; h++) { |
| 180 | if (!cronFieldMatch(hourP, h, 0, 23)) continue; |
| 181 | const mStart = day === 0 && h === hStart ? d.getMinutes() + 1 : 0; |
| 182 | for (let m = mStart; m < 60; m++) { |
| 183 | if (!cronFieldMatch(minP, m, 0, 59)) continue; |
| 184 | return new Date(d.getFullYear(), d.getMonth(), d.getDate(), h, m, 0, 0).getTime(); |
| 185 | } |
| 186 | } |
| 187 | } |
| 188 | return null; |
| 189 | } |
| 190 | |
| 191 | export function formatCronNext(ts: number | null): string { |
| 192 | if (ts === null) return ""; |
| 193 | const d = new Date(ts); |
| 194 | return `${(d.getMonth() + 1).toString().padStart(2, "0")}/${d.getDate().toString().padStart(2, "0")} ${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`; |
| 195 | } |
| 196 | |
| 197 | // intervalToCron converts a cycle ("24h|daily@09:00") or simple ("30m", "1h") |
| 198 | // interval into a 5-field cron expression. Already-cron values pass through. |
| 199 | export function intervalToCron(interval: string, timeWindowStart?: string, timeWindowEnd?: string): string | null { |
| 200 | // Guard: biweekly cannot be losslessly expressed in 5-field cron (DOM/DOW |
| 201 | // are OR-ed, so a biweekly rule like "1-15 * 1" becomes "1st-15th OR Monday", |
| 202 | // doubling the actual frequency). Seconds cannot be expressed either (cron |
| 203 | // has no seconds field). Cross-midnight windows (22:00–06:00) produce a |
| 204 | // descending range "22-6" that no matcher handles. Non-top-of-hour windows |
| 205 | // (09:30–17:30) would be truncated to whole hours. Return null for these so |
| 206 | // callers can refuse the conversion instead of silently corrupting semantics. |
| 207 | const windowTopOfHour = !timeWindowStart && !timeWindowEnd |
| 208 | || (!timeWindowStart || timeWindowStart.endsWith(":00")) |
| 209 | && (!timeWindowEnd || timeWindowEnd.endsWith(":00")); |
| 210 | if (!windowTopOfHour) return null; |
| 211 | const cycleMatch = interval.match(/^\d+[smh]\|(daily|weekly|biweekly|monthly|yearly)(?::([^@]*))?(?:@(\d{2}:\d{2}))?$/); |
| 212 | if (cycleMatch) { |
| 213 | const kind = cycleMatch[1]; |
| 214 | if (kind === "biweekly") return null; |
| 215 | const days = cycleMatch[2] || ""; |
| 216 | const time = cycleMatch[3] || "09:00"; |
| 217 | const [h, m] = time.split(":").map(Number); |
| 218 | // Cycle tasks schedule on their own clock (@09:00 etc); the engine ignores |
| 219 | // interval-style time windows for them (see heartbeatTaskDueAt), so any |
| 220 | // stale window must not be folded into the cron hour field — that would |
| 221 | // turn "daily@12:00" into "every hour 9-16". |
| 222 | const dayMap: Record<string, number> = { mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6, sun: 0 }; |
| 223 | switch (kind) { |
| 224 | case "daily": return `${m} ${h} * * *`; |
| 225 | case "weekly": { |
| 226 | const d = days.split(",").map((x) => dayMap[x.toLowerCase()] ?? "*").join(","); |
| 227 | return `${m} ${h} * * ${d}`; |
| 228 | } |
| 229 | case "monthly": return `${m} ${h} ${days || "1"} * *`; |
| 230 | case "yearly": { |
| 231 | const [mo, dy] = days.split("-"); |
| 232 | return `${m} ${h} ${dy || "1"} ${mo || "1"} *`; |
| 233 | } |
| 234 | } |
| 235 | } |
| 236 | const simple = interval.match(/^(\d+)([smh])$/); |
| 237 | if (simple) { |
| 238 | const n = parseInt(simple[1]); |
| 239 | const unit = simple[2]; |
| 240 | if (timeWindowStart && timeWindowEnd |
| 241 | && parseInt(timeWindowStart.split(":")[0]) > parseInt(timeWindowEnd.split(":")[0])) { |
| 242 | return null; // cross-midnight window: not expressible |
| 243 | } |
| 244 | const hExpr = timeWindowStart && timeWindowEnd |
| 245 | // End is exclusive: "09:00–17:00" → hour range 9-16 (17:00 excluded). |
| 246 | ? `${Math.max(0, parseInt(timeWindowStart.split(":")[0]))}-${Math.max(0, Math.min(23, parseInt(timeWindowEnd.split(":")[0]) - 1))}` |
| 247 | : "*"; |
| 248 | if (unit === "m") return `*/${n} ${hExpr} * * *`; |
| 249 | // 5-field cron: minute hour dom mon dow. Hourly tasks run at minute 0 of |
| 250 | // every n-th hour (`0 */n * * *`). With a time window the hour field can |
| 251 | // only carry "all hours in the range" (`0 9-16 * * *`), which is lossless |
| 252 | // for 1h but would silently change 2h+ windows from "every N hours" to |
| 253 | // "every hour" — refuse those. |
| 254 | if (unit === "h") { |
| 255 | if (timeWindowStart && timeWindowEnd && n > 1) return null; |
| 256 | const hourField = timeWindowStart && timeWindowEnd ? hExpr : `*/${n}`; |
| 257 | return `0 ${hourField} * * *`; |
| 258 | } |
| 259 | if (unit === "s") return null; // seconds cannot be expressed in cron |
| 260 | } |
| 261 | if (isCronExpr(interval)) return interval.trim(); |
| 262 | return null; |
| 263 | } |
| 264 | |
| 265 | // cronToInterval reverse-converts a cron expression back to a simple interval. |
| 266 | // Returns null when the expression cannot be expressed as a plain "every N |
| 267 | // minutes/hours" interval without changing semantics (dom/dow/month-restricted |
| 268 | // or fixed-time schedules) — callers must keep the cron instead of silently |
| 269 | // rewriting e.g. a weekly "0 9 * * 1" into "1h". |
| 270 | export function cronToInterval(cron: string): string | null { |
| 271 | const f = cron.trim().split(/\s+/); |
| 272 | if (f.length !== 5) return null; |
| 273 | // Only pure every-N minute/hour schedules round-trip losslessly. |
| 274 | if (f[2] !== "*" || f[3] !== "*" || f[4] !== "*") return null; |
| 275 | const min = f[0], hour = f[1]; |
| 276 | if (min.startsWith("*/") && hour === "*") return `${min.slice(2)}m`; |
| 277 | if (min === "0" && hour.startsWith("*/")) return `${hour.slice(2)}h`; |
| 278 | return null; |
| 279 | } |
| 280 | |
| 281 | export type HeartbeatFrequencyType = "interval" | "daily" | "weekly" | "biweekly" | "monthly" | "yearly" | "cron"; |
| 282 | |
| 283 | export function changeHeartbeatFrequency(task: HeartbeatTask, frequency: HeartbeatFrequencyType): HeartbeatTask | null { |
| 284 | const current = task.interval || ""; |
| 285 | if (frequency === "daily") return { ...task, interval: "24h|daily:mon,tue,wed,thu,fri,sat,sun@09:00" }; |
| 286 | if (frequency === "weekly") return { ...task, interval: "168h|weekly:mon@09:00" }; |
| 287 | if (frequency === "biweekly") return { ...task, interval: "336h|biweekly:mon@09:00" }; |
| 288 | if (frequency === "monthly") return { ...task, interval: "720h|monthly:1@09:00" }; |
| 289 | if (frequency === "yearly") return { ...task, interval: "8760h|yearly:1-1@09:00" }; |
| 290 | if (frequency === "cron") { |
| 291 | if (isCronExpr(current)) return task; |
| 292 | const converted = intervalToCron(current, task.timeWindowStart, task.timeWindowEnd); |
| 293 | return converted === null ? null : { ...task, interval: converted }; |
| 294 | } |
| 295 | if (isCronExpr(current)) { |
| 296 | const converted = cronToInterval(current); |
| 297 | return converted === null ? null : { ...task, interval: converted }; |
| 298 | } |
| 299 | if (current.includes("|")) return { ...task, interval: current.replace(/\|.*$/, "") }; |
| 300 | return task; |
| 301 | } |
| 302 | |
| 303 | // describeCron renders a human-readable description of a 5-field cron |
| 304 | // expression, localized via t(). |
| 305 | export function describeCron(expr: string, t: HeartbeatTranslator): string { |
| 306 | const f = expr.trim().split(/\s+/); |
| 307 | if (f.length !== 5) return ""; |
| 308 | const min = f[0], hour = f[1], dom = f[2], mon = f[3], dow = f[4]; |
| 309 | |
| 310 | const hourRange = (h: string): string => { |
| 311 | if (!h || h === "*") return ""; |
| 312 | if (h.includes("/")) { |
| 313 | const base = h.split("/")[0]; |
| 314 | if (base.includes("-")) { |
| 315 | const parts = base.split("-"); |
| 316 | return `${parts[0].padStart(2, "0")}:00-${parts[1].padStart(2, "0")}:00`; |
| 317 | } |
| 318 | return ""; |
| 319 | } |
| 320 | if (h.includes("-")) { |
| 321 | const parts = h.split("-"); |
| 322 | return `${parts[0].padStart(2, "0")}:00-${parts[1].padStart(2, "0")}:00`; |
| 323 | } |
| 324 | return ""; |
| 325 | }; |
| 326 | const wd = hourRange(hour); |
| 327 | |
| 328 | if (min.startsWith("*/") && hour !== "*" && hour.includes("-")) { |
| 329 | return `${t("heartbeat.cronEveryMin", { n: min.slice(2) })} (${wd})`; |
| 330 | } |
| 331 | if (min.startsWith("*/") && hour === "*") return t("heartbeat.cronEveryMin", { n: min.slice(2) }); |
| 332 | if (min.startsWith("*/") && hour !== "*") return `${t("heartbeat.cronEveryMin", { n: min.slice(2) })} ${wd}`; |
| 333 | if (min === "0" && hour !== "*" && dom === "*" && mon === "*" && dow === "*") { |
| 334 | if (hour.includes("/")) return t("heartbeat.cronEveryHour", { n: hour.replace("*/", "") }); |
| 335 | if (hour.includes("-")) return `${t("heartbeat.cronHourly")} (${wd})`; |
| 336 | return t("heartbeat.cronAt", { time: `${hour.padStart(2, "0")}:00` }); |
| 337 | } |
| 338 | if (min === "0" && hour === "*" && dom === "*" && mon === "*" && dow === "*") return t("heartbeat.cronHourly"); |
| 339 | if (min !== "*" && !min.includes("/") && hour === "*" && dom === "*" && mon === "*" && dow === "*") { |
| 340 | return t("heartbeat.cronOnHour", { n: min }); |
| 341 | } |
| 342 | if (dow !== "*" && dow !== "") { |
| 343 | const weekdays: Record<string, string> = { |
| 344 | "0": t("heartbeat.cronWeekdaySun"), "1": t("heartbeat.cronWeekdayMon"), |
| 345 | "2": t("heartbeat.cronWeekdayTue"), "3": t("heartbeat.cronWeekdayWed"), |
| 346 | "4": t("heartbeat.cronWeekdayThu"), "5": t("heartbeat.cronWeekdayFri"), |
| 347 | "6": t("heartbeat.cronWeekdaySat"), "7": t("heartbeat.cronWeekdaySun"), |
| 348 | }; |
| 349 | const days = dow.split(",").map((d) => weekdays[d] || d).join(t("heartbeat.joinComma")); |
| 350 | const suffix = wd ? ` (${wd})` : ""; |
| 351 | return `${days} ${hour.padStart(2, "0")}:${min.padStart(2, "0")}${suffix}`; |
| 352 | } |
| 353 | const suffix = wd ? ` (${wd})` : ""; |
| 354 | return `${hour.padStart(2, "0")}:${min.padStart(2, "0")}${suffix}`; |
| 355 | } |
| 356 | |
| 357 | // 周期 next-run 计算统一在文件头部通过 heartbeat.schedule.ts 的 |
| 358 | // parseCalendarSchedule + nextCalendarRunImpl 实现(见 nextCycleRunAt), |
| 359 | // 与新实现的意图一致:镜像后端 previousHeartbeatScheduleAt 语义、月末 |
| 360 | // clamp、周一制双周锚点。此处不再保留旧的行内实现。 |
| 361 | |
| 362 | export function taskNextRunAt(task: HeartbeatTask, now = Date.now()): number | null { |
| 363 | if (!task.enabled) return null; |
| 364 | const interval = task.interval || ""; |
| 365 | let next: number | null = null; |
| 366 | // 周期任务("24h|daily@22:00" / "168h|weekly:fri@16:00"):按调度语义计算 |
| 367 | // 下一个匹配时刻。已运行过(有 lastRunAt)的任务基于 lastRunAt 求下一时刻 |
| 368 | // (heartbeatNextRunAt → heartbeat.schedule 的 nextCalendarRun,含月末 |
| 369 | // clamp/闰日/双周锚点/DST,与后端 previousHeartbeatScheduleAt 对齐);离线 |
| 370 | // 期间早该运行的任务,next 会落在过去 → 显示 dueSoon(当前应执行),而不是 |
| 371 | // 跳到下一周期。从未运行的任务从创建时刻起算首次运行。 |
| 372 | const cycleMatch = interval.match(/^\d+[smh]\|(daily|weekly|biweekly|monthly|yearly)(?::([^@]*))?(?:@(\d{2}:\d{2}))?$/); |
| 373 | if (cycleMatch) { |
| 374 | next = task.lastRunAt |
| 375 | ? heartbeatNextRunAt(task, now) |
| 376 | : nextCycleRunAt(interval, task.createdAt || now, task.createdAt); |
| 377 | } else { |
| 378 | const cleaned = interval.replace(/\|.*$/, ""); |
| 379 | const m = cleaned.match(/^(\d+)([smh])$/); |
| 380 | if (m) { |
| 381 | // Plain interval with a time window: use the window-aware helper so the |
| 382 | // displayed next run matches the backend (defers to the next opening |
| 383 | // instead of naively showing lastRunAt + interval outside the window). |
| 384 | if (task.timeWindowStart || task.timeWindowEnd) { |
| 385 | next = heartbeatNextRunAt(task, now); |
| 386 | } else { |
| 387 | if (!task.lastRunAt) return null; |
| 388 | const ms = parseInt(m[1]) * { s: 1000, m: 60000, h: 3600000 }[m[2] as "s" | "m" | "h"]; |
| 389 | next = task.lastRunAt + ms; |
| 390 | } |
| 391 | } else if (isCronExpr(cleaned)) { |
| 392 | next = nextCronRunAt(cleaned, now); |
| 393 | } else { |
| 394 | return null; |
| 395 | } |
| 396 | } |
| 397 | return next; |
| 398 | } |
| 399 | |
| 400 | export interface HeartbeatTaskNextRun { |
| 401 | task: HeartbeatTask; |
| 402 | nextRunAt: number | null; |
| 403 | } |
| 404 | |
| 405 | type TaskNextRunResolver = (task: HeartbeatTask, now: number) => number | null; |
| 406 | |
| 407 | // Compute each task's next run once so an eight-year cron search never runs |
| 408 | // repeatedly inside Array.sort or again while rendering the same row. |
| 409 | export function prepareTasksByNextRun( |
| 410 | tasks: HeartbeatTask[], |
| 411 | now = Date.now(), |
| 412 | resolveNextRun: TaskNextRunResolver = taskNextRunAt, |
| 413 | ): HeartbeatTaskNextRun[] { |
| 414 | return tasks |
| 415 | .map((task) => ({ task, nextRunAt: resolveNextRun(task, now) })) |
| 416 | .sort((a, b) => { |
| 417 | if (a.task.enabled !== b.task.enabled) return a.task.enabled ? -1 : 1; |
| 418 | const sortAt = (entry: HeartbeatTaskNextRun) => ( |
| 419 | entry.task.enabled && !entry.task.lastRunAt ? now : entry.nextRunAt |
| 420 | ) ?? Number.POSITIVE_INFINITY; |
| 421 | return sortAt(a) - sortAt(b); |
| 422 | }); |
| 423 | } |
| 424 | |
| 425 | export function formatTaskNextRun(next: number | null, now: number, t: HeartbeatTranslator): string | null { |
| 426 | if (next === null) return null; |
| 427 | if (next <= now) return t("heartbeat.dueSoon"); |
| 428 | const diff = next - now; |
| 429 | // 剩余时间:如「下次运行 26 分钟后」/「下次运行 2 小时后」/「下次运行 2天3小时后」/「即将触发」 |
| 430 | const days = Math.floor(diff / 86400000); |
| 431 | const hours = Math.floor((diff % 86400000) / 3600000); |
| 432 | const minutes = Math.floor((diff % 3600000) / 60000); |
| 433 | const prefix = t("heartbeat.nextRun"); |
| 434 | if (days > 0) return `${prefix} ${days}${t("heartbeat.unitDay")}${hours}${t("heartbeat.unitHour")}${t("heartbeat.later")}`; |
| 435 | if (hours > 0) return `${prefix} ${hours}${t("heartbeat.unitHour")}${minutes}${t("heartbeat.unitMin")}${t("heartbeat.later")}`; |
| 436 | if (minutes > 0) return `${prefix} ${minutes}${t("heartbeat.unitMin")}${t("heartbeat.later")}`; |
| 437 | return t("heartbeat.dueSoon"); |
| 438 | } |
| 439 | |
| 440 | export function taskNextRun(task: HeartbeatTask, t: HeartbeatTranslator): string | null { |
| 441 | const now = Date.now(); |
| 442 | return formatTaskNextRun(taskNextRunAt(task, now), now, t); |
| 443 | } |
| 444 |