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:
Alan Wizemann
2026-04-24 13:29:59 +02:00
parent f2f6c4e50b
commit 42c0f683bd
4 changed files with 362 additions and 24 deletions
@@ -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 != "")
}
}
+6 -14
View File
@@ -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)
} }
+16 -10
View File
@@ -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")