import { spinnerFrame } from "./spinner.ts"; import type { LocaleStrings } from "./locale.ts"; import type { ScheduleSnapshot, ScheduleState } from "./types.ts"; const CURRENT_SCOPE_ICON = "◉"; const SHARED_SCOPE_ICON = "◇"; const SCHEDULE_ICON = "⏱"; const PAUSED_ICON = "⏸"; const OVERDUE_ICON = "⚠"; export type StatusTone = "accent" | "success" | "warning" | "muted"; export type StatusStyler = (tone: StatusTone, text: string) => string; function styled(styler: StatusStyler | undefined, tone: StatusTone, text: string): string { return styler ? styler(tone, text) : text; } function styledScope(scope: ScheduleSnapshot["scope"], styler: StatusStyler | undefined): string { return styled(styler, scope === "current" ? "accent" : "muted", scopeIcon(scope)); } /** * 根据作用域返回状态栏图标. * * @param scope Schedule 作用域 * @returns 作用域图标 */ export function scopeIcon(scope: ScheduleSnapshot["scope"]): string { return scope === "current" ? CURRENT_SCOPE_ICON : SHARED_SCOPE_ICON; } /** * 将毫秒时长格式化为 HH:mm:ss 或 mm:ss. * * @param milliseconds 时长毫秒数 * @returns 格式化后的时长 */ export function formatDuration(milliseconds: number): string { const totalSeconds = Math.max(0, Math.floor(milliseconds / 1_000)); const hours = Math.floor(totalSeconds / 3_600); const minutes = Math.floor((totalSeconds % 3_600) / 60); const seconds = totalSeconds % 60; if (hours > 0) return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; } /** * 将计划时间格式化为本地时间. * * 一小时内显示到秒, 更远时间显示日期和分钟, 过期时间仍保留计划时刻. * * @param timestamp 时间戳 * @param now 当前时间 * @param strings 当前语言文案 * @returns 本地时间文本 */ export function formatScheduleTime(timestamp: number, now: Date, strings: LocaleStrings): string { const difference = Math.abs(timestamp - now.getTime()); const options: Intl.DateTimeFormatOptions = difference <= 3_600_000 ? { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false } : { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }; return new Intl.DateTimeFormat(strings.locale, options).format(new Date(timestamp)); } /** * 将 Schedule 的固定间隔格式化为当前语言. * * @param schedule Schedule 快照 * @param strings 当前语言文案 * @returns 间隔文本 */ export function formatCadence(schedule: ScheduleSnapshot, strings: LocaleStrings): string { return schedule.triggerKind === "interval" && schedule.every ? strings.every(schedule.every) : strings.once; } function effectiveState(schedule: ScheduleSnapshot, now: Date): ScheduleState { if (schedule.state === "paused") return "paused"; if (schedule.state === "running") return "running"; if (schedule.nextRunAt !== undefined && schedule.nextRunAt <= now.getTime()) return "overdue"; return "scheduled"; } function formatSingle(schedule: ScheduleSnapshot, strings: LocaleStrings, now: Date, frame: number, styler?: StatusStyler): string { const state = effectiveState(schedule, now); const scope = styledScope(schedule.scope, styler); if (state === "running") { const duration = schedule.startedAt === undefined ? "" : ` ${formatDuration(now.getTime() - schedule.startedAt)}`; const planned = schedule.nextRunAt === undefined ? "" : ` · ${strings.planned} ${formatScheduleTime(schedule.nextRunAt, now, strings)}`; return `${styled(styler, "accent", spinnerFrame(frame))} ${scope} ${schedule.name} · ${styled(styler, "accent", strings.running)}${duration}${planned}`; } if (state === "paused") return `${styled(styler, "warning", PAUSED_ICON)} ${scope} ${schedule.name} · ${styled(styler, "warning", strings.off)} · ${formatCadence(schedule, strings)}`; if (state === "overdue") { const delay = schedule.nextRunAt === undefined ? "" : ` ${formatDuration(now.getTime() - schedule.nextRunAt)}`; const planned = schedule.nextRunAt === undefined ? "" : ` · ${strings.planned} ${formatScheduleTime(schedule.nextRunAt, now, strings)}`; return `${styled(styler, "warning", OVERDUE_ICON)} ${scope} ${schedule.name} · ${styled(styler, "warning", strings.overdue)}${delay}${planned}`; } const next = schedule.nextRunAt === undefined ? "" : ` · ${strings.next} ${formatScheduleTime(schedule.nextRunAt, now, strings)}`; return `${styled(styler, "success", SCHEDULE_ICON)} ${scope} ${schedule.name} · ${styled(styler, "success", strings.on)} · ${formatCadence(schedule, strings)}${next}`; } /** * 将当前会话可见的 Schedule 渲染为 Footer 状态栏文本. * * @param schedules 可见 Schedule 快照 * @param strings 当前语言文案 * @param now 当前时间 * @param frame spinner 帧索引 * @param styler 可选的主题着色器 * @returns 状态栏文本, 没有可见任务时返回 undefined */ export function formatStatus( schedules: readonly ScheduleSnapshot[], strings: LocaleStrings, now = new Date(), frame = 0, styler?: StatusStyler, ): string | undefined { if (schedules.length === 0) return undefined; if (schedules.length === 1) return formatSingle(schedules[0]!, strings, now, frame, styler); const states = schedules.map((schedule) => effectiveState(schedule, now)); const running = states.filter((state) => state === "running").length; const overdue = states.filter((state) => state === "overdue").length; const paused = states.filter((state) => state === "paused").length; const current = schedules.filter((schedule) => schedule.scope === "current").length; const shared = schedules.length - current; const scopeParts = [ current > 0 ? styled(styler, "accent", `${CURRENT_SCOPE_ICON} ${current}`) : undefined, shared > 0 ? styled(styler, "muted", `${SHARED_SCOPE_ICON} ${shared}`) : undefined, ].filter((part): part is string => part !== undefined); const stateTone: StatusTone = running > 0 ? "accent" : overdue > 0 || paused === schedules.length ? "warning" : "success"; const stateText = running > 0 ? strings.runningCount(running) : overdue > 0 ? strings.overdueCount(overdue) : paused === schedules.length ? strings.pausedCount(paused) : strings.on; const nextRunAt = schedules.map((schedule) => schedule.nextRunAt).filter((value): value is number => value !== undefined).sort((left, right) => left - right)[0]; const overdueRunAt = schedules .filter((schedule, index) => states[index] === "overdue") .map((schedule) => schedule.nextRunAt) .filter((value): value is number => value !== undefined) .sort((left, right) => left - right)[0]; const timeText = overdueRunAt !== undefined ? ` · ${strings.planned} ${formatScheduleTime(overdueRunAt, now, strings)}` : nextRunAt === undefined ? "" : ` · ${strings.next} ${formatScheduleTime(nextRunAt, now, strings)}`; const rawIcon = running > 0 ? spinnerFrame(frame) : overdue > 0 ? OVERDUE_ICON : paused === schedules.length ? PAUSED_ICON : SCHEDULE_ICON; return `${styled(styler, stateTone, rawIcon)} ${strings.tasks(schedules.length)} · ${scopeParts.join(" · ")} · ${styled(styler, stateTone, stateText)}${timeText}`; }