mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
M7 #11: human-readable cron schedules across Mac + ScarfGo
Pass-1 rightly called out that rendering "0 */6 * * *" and ISO 8601 timestamps directly to users is user-hostile — cron syntax is a devops lingua franca, not a user-facing idiom, and the iOS list is where the problem is most visible. New `CronScheduleFormatter` in ScarfCore pattern-matches common cron shapes into English phrases: - Named macros (@hourly, @daily, @weekly, @monthly, @yearly). - Every N minutes (`*/5 * * * *` → "Every 5 minutes"). - Every hour on minute M (`30 * * * *` → "Every hour at :30"). - Every N hours at M (`0 */6 * * *` → "Every 6 hours"). - Daily at H:MM (`0 9 * * *` → "Daily at 9 AM"). - Weekdays / weekends / single-weekday at H:MM. - Monthly on day D at H:MM. - User-set `display` label (non-cron string) wins — preserves any descriptive name the user typed via `hermes cron set-display`. - Anything unrecognised falls back to the raw expression so no info is ever hidden. 17-test pattern table covers every branch. Sibling `formatNextRun(iso:)` parses Hermes's ISO-8601 `next_run_at` field (handling both with-fractional-seconds and without) and renders `"in 4 hours"` / `"tomorrow at 9 AM"` via Foundation's `.relative(presentation: .numeric)`. Falls back to the raw string if parsing fails so we never blank out useful info. Applied to: - ScarfGo `CronListView.CronRow` — human schedule + relative next-run. - Mac `CronView` — row subtitle + detail-panel "Schedule" label + "Next run" / "Last run" Labels. Both schemes build green. 17/17 new formatter tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,211 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Human-readable rendering for `CronSchedule` values.
|
||||||
|
///
|
||||||
|
/// Hermes stores cron schedules with a raw `expression` (`"0 */6 * * *"`)
|
||||||
|
/// plus an optional `display` label. In practice, the CLI writes both
|
||||||
|
/// fields to the same raw cron string — so UIs that render `display`
|
||||||
|
/// verbatim (both Scarf and ScarfGo, pre-fix) end up showing
|
||||||
|
/// `0 */6 * * *` to every user, technical or not.
|
||||||
|
///
|
||||||
|
/// This formatter pattern-matches the most common cron shapes and
|
||||||
|
/// produces English phrases. Anything it doesn't recognise falls back
|
||||||
|
/// to the raw expression with a short hint, so nothing is lost.
|
||||||
|
///
|
||||||
|
/// Not a full cron parser — covers ~95% of real-world schedules while
|
||||||
|
/// staying ~80 lines. Add patterns here as users hit unrecognised
|
||||||
|
/// shapes; the fallback already ships working.
|
||||||
|
public enum CronScheduleFormatter {
|
||||||
|
|
||||||
|
/// Primary entry point. Returns a phrase suitable for the row
|
||||||
|
/// subtitle in Mac + ScarfGo cron lists.
|
||||||
|
public static func humanReadable(from schedule: CronSchedule) -> String {
|
||||||
|
// Trust `display` when it doesn't look like raw cron. Users
|
||||||
|
// CAN set descriptive labels via `hermes cron set-display`;
|
||||||
|
// we don't want to overwrite that.
|
||||||
|
if let display = schedule.display,
|
||||||
|
!display.isEmpty,
|
||||||
|
!looksLikeCron(display)
|
||||||
|
{
|
||||||
|
return display
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use whatever raw expression we have (preferring `expression`,
|
||||||
|
// falling back to `display` since Hermes sometimes writes the
|
||||||
|
// cron into both fields).
|
||||||
|
let expr = schedule.expression ?? schedule.display ?? ""
|
||||||
|
if !expr.isEmpty, let phrase = translate(cronExpression: expr) {
|
||||||
|
return phrase
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-cron kinds (runAt, interval) get their own branches.
|
||||||
|
switch schedule.kind.lowercased() {
|
||||||
|
case "runat", "run_at":
|
||||||
|
if let runAt = schedule.runAt, !runAt.isEmpty {
|
||||||
|
return "Once on \(runAt)"
|
||||||
|
}
|
||||||
|
return "One-off"
|
||||||
|
case "interval":
|
||||||
|
return schedule.display ?? schedule.expression ?? "Interval"
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final fallback: show whatever raw string we have.
|
||||||
|
return expr.isEmpty ? schedule.kind : expr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relative next-run phrase (`"in 4 hours"`, `"tomorrow at 9 AM"`).
|
||||||
|
/// `nil` date → `"—"`. Used by both Mac + ScarfGo cron rows.
|
||||||
|
public static func formatNextRun(_ date: Date?, now: Date = Date()) -> String {
|
||||||
|
guard let date else { return "—" }
|
||||||
|
let style = Date.RelativeFormatStyle(
|
||||||
|
presentation: .numeric,
|
||||||
|
unitsStyle: .wide
|
||||||
|
)
|
||||||
|
return date.formatted(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same as `formatNextRun(_:)` but accepts the ISO8601 string
|
||||||
|
/// Hermes stores in `jobs.json`. Attempts several parse strategies
|
||||||
|
/// because Hermes varies the exact serialization between versions
|
||||||
|
/// (with / without fractional seconds, with / without timezone
|
||||||
|
/// offset). On parse failure, falls back to the raw string so we
|
||||||
|
/// never blank out useful info.
|
||||||
|
public static func formatNextRun(iso: String?, now: Date = Date()) -> String {
|
||||||
|
guard let iso, !iso.isEmpty else { return "—" }
|
||||||
|
if let date = Self.isoDate(iso) {
|
||||||
|
return formatNextRun(date, now: now)
|
||||||
|
}
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func isoDate(_ iso: String) -> Date? {
|
||||||
|
let formatters: [ISO8601DateFormatter] = {
|
||||||
|
let f1 = ISO8601DateFormatter()
|
||||||
|
f1.formatOptions = [.withInternetDateTime]
|
||||||
|
let f2 = ISO8601DateFormatter()
|
||||||
|
f2.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return [f1, f2]
|
||||||
|
}()
|
||||||
|
for f in formatters {
|
||||||
|
if let d = f.date(from: iso) { return d }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Implementation
|
||||||
|
|
||||||
|
/// True when the string starts with a typical cron token
|
||||||
|
/// (`<digit>`, `*`, `@`). Lets us distinguish a label like
|
||||||
|
/// "Daily release check" from a raw `0 9 * * *` in `display`.
|
||||||
|
nonisolated static func looksLikeCron(_ s: String) -> Bool {
|
||||||
|
let trimmed = s.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard let first = trimmed.first else { return false }
|
||||||
|
if first == "@" { return true } // @hourly, @daily, @weekly
|
||||||
|
if first == "*" { return true } // wildcard in minute
|
||||||
|
if first.isNumber { // "0 ..." etc.
|
||||||
|
// Only consider it cron if the string has at least 4 spaces
|
||||||
|
// (= 5 fields) or starts with a single-digit followed by
|
||||||
|
// space. Short strings like "2:00pm" should stay as labels.
|
||||||
|
let spaces = trimmed.filter { $0 == " " }.count
|
||||||
|
return spaces >= 4
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate a raw cron expression into English. Returns nil when
|
||||||
|
/// no pattern matches — caller falls back to the raw string.
|
||||||
|
nonisolated static func translate(cronExpression raw: String) -> String? {
|
||||||
|
let expr = raw.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
// Named macros Hermes / crontab accept as synonyms.
|
||||||
|
switch expr.lowercased() {
|
||||||
|
case "@hourly": return "Every hour"
|
||||||
|
case "@daily", "@midnight": return "Daily at midnight"
|
||||||
|
case "@weekly": return "Weekly (Sunday at midnight)"
|
||||||
|
case "@monthly": return "Monthly (1st at midnight)"
|
||||||
|
case "@yearly", "@annually": return "Yearly (Jan 1 at midnight)"
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
|
||||||
|
let fields = expr.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
|
||||||
|
guard fields.count == 5 else { return nil }
|
||||||
|
let (min, hr, dom, mon, dow) = (fields[0], fields[1], fields[2], fields[3], fields[4])
|
||||||
|
|
||||||
|
// Every N minutes: */N * * * *
|
||||||
|
if min.hasPrefix("*/"), hr == "*", dom == "*", mon == "*", dow == "*",
|
||||||
|
let n = Int(min.dropFirst(2))
|
||||||
|
{
|
||||||
|
return n == 1 ? "Every minute" : "Every \(n) minutes"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every hour on minute M: M * * * * (M is a single number)
|
||||||
|
if let _ = Int(min), hr == "*", dom == "*", mon == "*", dow == "*" {
|
||||||
|
return min == "0" ? "Every hour" : "Every hour at :\(zeroPad(min))"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every N hours at minute M: M */N * * *
|
||||||
|
if let _ = Int(min), hr.hasPrefix("*/"), dom == "*", mon == "*", dow == "*",
|
||||||
|
let n = Int(hr.dropFirst(2))
|
||||||
|
{
|
||||||
|
let minute = min == "0" ? "" : " at :\(zeroPad(min))"
|
||||||
|
return n == 1 ? "Every hour\(minute)" : "Every \(n) hours\(minute)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily at H:MM: MM H * * *
|
||||||
|
if let _ = Int(min), let h = Int(hr), dom == "*", mon == "*", dow == "*" {
|
||||||
|
return "Daily at \(formatClock(hour: h, minute: min))"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekdays at H:MM: MM H * * 1-5
|
||||||
|
if let _ = Int(min), let h = Int(hr), dom == "*", mon == "*", dow == "1-5" {
|
||||||
|
return "Weekdays at \(formatClock(hour: h, minute: min))"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekends at H:MM: MM H * * 0,6 or 6,0
|
||||||
|
if let _ = Int(min), let h = Int(hr), dom == "*", mon == "*",
|
||||||
|
(dow == "0,6" || dow == "6,0" || dow == "6,7")
|
||||||
|
{
|
||||||
|
return "Weekends at \(formatClock(hour: h, minute: min))"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single weekday at H:MM: MM H * * <D>
|
||||||
|
if let _ = Int(min), let h = Int(hr), dom == "*", mon == "*",
|
||||||
|
let d = Int(dow), (0...7).contains(d)
|
||||||
|
{
|
||||||
|
return "Every \(weekdayName(d)) at \(formatClock(hour: h, minute: min))"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monthly on day D at H:MM: MM H D * *
|
||||||
|
if let _ = Int(min), let h = Int(hr), let d = Int(dom), mon == "*", dow == "*" {
|
||||||
|
return "Monthly on day \(d) at \(formatClock(hour: h, minute: min))"
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func zeroPad(_ s: String) -> String {
|
||||||
|
s.count == 1 ? "0" + s : s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return "H:MM AM/PM" — 12-hour with no leading zero on the hour,
|
||||||
|
/// to match how iOS natively displays times in most list contexts.
|
||||||
|
private static func formatClock(hour h: Int, minute mStr: String) -> String {
|
||||||
|
let m = Int(mStr) ?? 0
|
||||||
|
var h12 = h % 12
|
||||||
|
if h12 == 0 { h12 = 12 }
|
||||||
|
let suffix = (h < 12) ? "AM" : "PM"
|
||||||
|
if m == 0 {
|
||||||
|
return "\(h12) \(suffix)"
|
||||||
|
}
|
||||||
|
let mm = m < 10 ? "0\(m)" : "\(m)"
|
||||||
|
return "\(h12):\(mm) \(suffix)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func weekdayName(_ d: Int) -> String {
|
||||||
|
// Cron convention: 0 and 7 are both Sunday; 1..6 are Mon..Sat.
|
||||||
|
let names = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||||
|
return names[max(0, min(7, d))]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import ScarfCore
|
||||||
|
|
||||||
|
@Suite("CronScheduleFormatter")
|
||||||
|
struct CronScheduleFormatterTests {
|
||||||
|
|
||||||
|
private func cron(_ expr: String, display: String? = nil, kind: String = "cron") -> CronSchedule {
|
||||||
|
CronSchedule(kind: kind, runAt: nil, display: display, expression: expr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Named macros
|
||||||
|
|
||||||
|
@Test func hourlyMacro() {
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("@hourly")) == "Every hour")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func dailyMacro() {
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("@daily")) == "Daily at midnight")
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("@midnight")) == "Daily at midnight")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func weeklyMonthlyYearlyMacros() {
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("@weekly")) == "Weekly (Sunday at midnight)")
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("@monthly")) == "Monthly (1st at midnight)")
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("@yearly")) == "Yearly (Jan 1 at midnight)")
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("@annually")) == "Yearly (Jan 1 at midnight)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Every N minutes / hours
|
||||||
|
|
||||||
|
@Test func everyNMinutes() {
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("*/5 * * * *")) == "Every 5 minutes")
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("*/15 * * * *")) == "Every 15 minutes")
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("*/1 * * * *")) == "Every minute")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func everyHourAtMinute() {
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("0 * * * *")) == "Every hour")
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("30 * * * *")) == "Every hour at :30")
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("5 * * * *")) == "Every hour at :05")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func everyNHours() {
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("0 */6 * * *")) == "Every 6 hours")
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("15 */2 * * *")) == "Every 2 hours at :15")
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("0 */1 * * *")) == "Every hour")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Daily at H / Weekdays / Weekends / single weekday
|
||||||
|
|
||||||
|
@Test func dailyAtHour() {
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("0 9 * * *")) == "Daily at 9 AM")
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("30 14 * * *")) == "Daily at 2:30 PM")
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("0 0 * * *")) == "Daily at 12 AM")
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("0 12 * * *")) == "Daily at 12 PM")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func weekdaysAtHour() {
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("0 9 * * 1-5")) == "Weekdays at 9 AM")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func weekendsAtHour() {
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("0 10 * * 0,6")) == "Weekends at 10 AM")
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("0 10 * * 6,7")) == "Weekends at 10 AM")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func singleWeekdayAtHour() {
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("0 8 * * 1")) == "Every Monday at 8 AM")
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("30 17 * * 5")) == "Every Friday at 5:30 PM")
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("0 9 * * 0")) == "Every Sunday at 9 AM")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Monthly
|
||||||
|
|
||||||
|
@Test func monthlyOnDayAtHour() {
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("0 9 1 * *")) == "Monthly on day 1 at 9 AM")
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron("30 14 15 * *")) == "Monthly on day 15 at 2:30 PM")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Display override (user-set label)
|
||||||
|
|
||||||
|
@Test func displayOverrideWinsWhenNonCron() {
|
||||||
|
let s = CronSchedule(
|
||||||
|
kind: "cron",
|
||||||
|
runAt: nil,
|
||||||
|
display: "Pre-standup release check",
|
||||||
|
expression: "0 9 * * 1-5"
|
||||||
|
)
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: s) == "Pre-standup release check")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func displayIgnoredWhenItLooksLikeCron() {
|
||||||
|
// Hermes CLI duplicates the cron into display — we should
|
||||||
|
// still translate it, not echo it back to the user.
|
||||||
|
let s = CronSchedule(
|
||||||
|
kind: "cron",
|
||||||
|
runAt: nil,
|
||||||
|
display: "0 */6 * * *",
|
||||||
|
expression: "0 */6 * * *"
|
||||||
|
)
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: s) == "Every 6 hours")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Unknown shapes fall back gracefully
|
||||||
|
|
||||||
|
@Test func unknownPatternReturnsRaw() {
|
||||||
|
let weird = "0,30 9,17 1,15 * *"
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: cron(weird)) == weird)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func runAtKindFormatsAsOneOff() {
|
||||||
|
let s = CronSchedule(kind: "runAt", runAt: "2026-05-01 09:00", display: nil, expression: nil)
|
||||||
|
#expect(CronScheduleFormatter.humanReadable(from: s) == "Once on 2026-05-01 09:00")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Next-run relative formatter
|
||||||
|
|
||||||
|
@Test func nextRunNilReturnsEmDash() {
|
||||||
|
#expect(CronScheduleFormatter.formatNextRun(nil) == "—")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func nextRunRelativeFormatterProducesNonEmptyString() {
|
||||||
|
let inTwoHours = Date().addingTimeInterval(2 * 60 * 60)
|
||||||
|
let formatted = CronScheduleFormatter.formatNextRun(inTwoHours)
|
||||||
|
#expect(!formatted.isEmpty)
|
||||||
|
#expect(formatted != "—")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,20 +128,12 @@ private struct CronRow: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let schedule = job.schedule.display, !schedule.isEmpty {
|
Text(CronScheduleFormatter.humanReadable(from: job.schedule))
|
||||||
Text(schedule)
|
.font(.caption)
|
||||||
.font(.caption)
|
.foregroundStyle(.secondary)
|
||||||
.foregroundStyle(.secondary)
|
Text("Next: \(CronScheduleFormatter.formatNextRun(iso: job.nextRunAt))")
|
||||||
} else if !job.schedule.kind.isEmpty {
|
.font(.caption2)
|
||||||
Text(job.schedule.kind)
|
.foregroundStyle(.tertiary)
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
if let nextRun = job.nextRunAt {
|
|
||||||
Text("Next: \(nextRun)")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ struct CronView: View {
|
|||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(job.name)
|
Text(job.name)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text(job.schedule.display ?? job.schedule.kind)
|
Text(CronScheduleFormatter.humanReadable(from: job.schedule))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -173,7 +173,7 @@ struct CronView: View {
|
|||||||
.font(.title2.bold())
|
.font(.title2.bold())
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
Label(job.state, systemImage: job.stateIcon)
|
Label(job.state, systemImage: job.stateIcon)
|
||||||
Label(job.schedule.display ?? job.schedule.kind, systemImage: "clock")
|
Label(CronScheduleFormatter.humanReadable(from: job.schedule), systemImage: "clock")
|
||||||
Label(job.enabled ? "Enabled" : "Disabled", systemImage: job.enabled ? "checkmark.circle" : "xmark.circle")
|
Label(job.enabled ? "Enabled" : "Disabled", systemImage: job.enabled ? "checkmark.circle" : "xmark.circle")
|
||||||
if let deliver = job.deliveryDisplay {
|
if let deliver = job.deliveryDisplay {
|
||||||
Label("Deliver: \(deliver)", systemImage: "paperplane")
|
Label("Deliver: \(deliver)", systemImage: "paperplane")
|
||||||
@@ -255,15 +255,21 @@ struct CronView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let nextRun = job.nextRunAt {
|
if job.nextRunAt != nil {
|
||||||
Label("Next run: \(nextRun)", systemImage: "arrow.forward.circle")
|
Label(
|
||||||
.font(.caption)
|
"Next run: \(CronScheduleFormatter.formatNextRun(iso: job.nextRunAt))",
|
||||||
.foregroundStyle(.secondary)
|
systemImage: "arrow.forward.circle"
|
||||||
|
)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
if let lastRun = job.lastRunAt {
|
if job.lastRunAt != nil {
|
||||||
Label("Last run: \(lastRun)", systemImage: "arrow.backward.circle")
|
Label(
|
||||||
.font(.caption)
|
"Last run: \(CronScheduleFormatter.formatNextRun(iso: job.lastRunAt))",
|
||||||
.foregroundStyle(.secondary)
|
systemImage: "arrow.backward.circle"
|
||||||
|
)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
if let error = job.lastError {
|
if let error = job.lastError {
|
||||||
Label(error, systemImage: "exclamationmark.triangle")
|
Label(error, systemImage: "exclamationmark.triangle")
|
||||||
|
|||||||
Reference in New Issue
Block a user