返回 DeepSeek-Reasonix
heartbeat.presentation.ts
根目录 / desktop / frontend / src / custom / features / heartbeat / heartbeat.presentation.ts
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
444 lines TYPESCRIPT