diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 774bfbb..4a23c0d 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -1587,7 +1587,7 @@ dependencies = [
[[package]]
name = "launchd-ui"
-version = "1.0.6"
+version = "1.0.9"
dependencies = [
"dirs",
"plist",
diff --git a/src/__tests__/calendar-utils.test.ts b/src/__tests__/calendar-utils.test.ts
new file mode 100644
index 0000000..2e2b9ec
--- /dev/null
+++ b/src/__tests__/calendar-utils.test.ts
@@ -0,0 +1,244 @@
+import { describe, it, expect } from "vitest"
+import {
+ detectHourRange,
+ expandHourRange,
+ getNextOccurrences,
+ getNextOccurrencesMulti,
+ formatCalendarIntervals,
+} from "@/lib/calendar-utils"
+import type { CalendarInterval } from "@/types"
+
+describe("detectHourRange", () => {
+ it("returns null for a single interval", () => {
+ const intervals: CalendarInterval[] = [
+ { minute: 0, hour: 9, day: null, weekday: null, month: null },
+ ]
+ expect(detectHourRange(intervals)).toBeNull()
+ })
+
+ it("detects contiguous hour range", () => {
+ const intervals: CalendarInterval[] = [
+ { minute: 0, hour: 7, day: null, weekday: null, month: null },
+ { minute: 0, hour: 8, day: null, weekday: null, month: null },
+ { minute: 0, hour: 9, day: null, weekday: null, month: null },
+ ]
+ const result = detectHourRange(intervals)
+ expect(result).toEqual({
+ base: { minute: 0, hour: null, day: null, weekday: null, month: null },
+ from: 7,
+ to: 9,
+ })
+ })
+
+ it("detects range even when intervals are unordered", () => {
+ const intervals: CalendarInterval[] = [
+ { minute: 30, hour: 10, day: null, weekday: 1, month: null },
+ { minute: 30, hour: 8, day: null, weekday: 1, month: null },
+ { minute: 30, hour: 9, day: null, weekday: 1, month: null },
+ ]
+ const result = detectHourRange(intervals)
+ expect(result).toEqual({
+ base: { minute: 30, hour: null, day: null, weekday: 1, month: null },
+ from: 8,
+ to: 10,
+ })
+ })
+
+ it("returns null for non-contiguous hours", () => {
+ const intervals: CalendarInterval[] = [
+ { minute: 0, hour: 7, day: null, weekday: null, month: null },
+ { minute: 0, hour: 9, day: null, weekday: null, month: null },
+ { minute: 0, hour: 10, day: null, weekday: null, month: null },
+ ]
+ expect(detectHourRange(intervals)).toBeNull()
+ })
+
+ it("returns null when base fields differ", () => {
+ const intervals: CalendarInterval[] = [
+ { minute: 0, hour: 7, day: null, weekday: null, month: null },
+ { minute: 30, hour: 8, day: null, weekday: null, month: null },
+ ]
+ expect(detectHourRange(intervals)).toBeNull()
+ })
+
+ it("returns null when hour is null", () => {
+ const intervals: CalendarInterval[] = [
+ { minute: 0, hour: null, day: null, weekday: null, month: null },
+ { minute: 0, hour: null, day: null, weekday: null, month: null },
+ ]
+ expect(detectHourRange(intervals)).toBeNull()
+ })
+})
+
+describe("expandHourRange", () => {
+ it("expands hour range into individual intervals", () => {
+ const base: CalendarInterval = {
+ minute: 0,
+ hour: null,
+ day: null,
+ weekday: null,
+ month: null,
+ }
+ const result = expandHourRange(base, 7, 9)
+ expect(result).toEqual([
+ { minute: 0, hour: 7, day: null, weekday: null, month: null },
+ { minute: 0, hour: 8, day: null, weekday: null, month: null },
+ { minute: 0, hour: 9, day: null, weekday: null, month: null },
+ ])
+ })
+
+ it("preserves weekday and other fields", () => {
+ const base: CalendarInterval = {
+ minute: 30,
+ hour: null,
+ day: null,
+ weekday: 1,
+ month: null,
+ }
+ const result = expandHourRange(base, 9, 10)
+ expect(result).toHaveLength(2)
+ expect(result[0]).toEqual({ minute: 30, hour: 9, day: null, weekday: 1, month: null })
+ expect(result[1]).toEqual({ minute: 30, hour: 10, day: null, weekday: 1, month: null })
+ })
+
+ it("handles single hour (from === to)", () => {
+ const base: CalendarInterval = {
+ minute: 0,
+ hour: null,
+ day: null,
+ weekday: null,
+ month: null,
+ }
+ const result = expandHourRange(base, 9, 9)
+ expect(result).toHaveLength(1)
+ expect(result[0].hour).toBe(9)
+ })
+
+ it("returns empty array when from > to", () => {
+ const base: CalendarInterval = {
+ minute: 0,
+ hour: null,
+ day: null,
+ weekday: null,
+ month: null,
+ }
+ const result = expandHourRange(base, 23, 7)
+ expect(result).toEqual([])
+ })
+})
+
+describe("getNextOccurrences", () => {
+ it("returns future occurrences for specific hour and minute", () => {
+ const ci: CalendarInterval = {
+ minute: 30,
+ hour: 14,
+ day: null,
+ weekday: null,
+ month: null,
+ }
+ const results = getNextOccurrences(ci, 3)
+ expect(results).toHaveLength(3)
+ for (const d of results) {
+ expect(d.getHours()).toBe(14)
+ expect(d.getMinutes()).toBe(30)
+ }
+ })
+
+ it("returns occurrences matching every hour when hour is null", () => {
+ const ci: CalendarInterval = {
+ minute: 0,
+ hour: null,
+ day: null,
+ weekday: null,
+ month: null,
+ }
+ const results = getNextOccurrences(ci, 3)
+ expect(results).toHaveLength(3)
+ for (const d of results) {
+ expect(d.getMinutes()).toBe(0)
+ }
+ })
+
+ it("returns occurrences for specific weekday", () => {
+ const ci: CalendarInterval = {
+ minute: 0,
+ hour: 9,
+ day: null,
+ weekday: 1, // Monday
+ month: null,
+ }
+ const results = getNextOccurrences(ci, 3)
+ expect(results).toHaveLength(3)
+ for (const d of results) {
+ expect(d.getDay()).toBe(1)
+ expect(d.getHours()).toBe(9)
+ }
+ })
+})
+
+describe("getNextOccurrencesMulti", () => {
+ it("merges and sorts occurrences from multiple intervals", () => {
+ const intervals: CalendarInterval[] = [
+ { minute: 0, hour: 9, day: null, weekday: null, month: null },
+ { minute: 0, hour: 10, day: null, weekday: null, month: null },
+ ]
+ const results = getNextOccurrencesMulti(intervals, 3)
+ expect(results).toHaveLength(3)
+ // Should be sorted chronologically
+ for (let i = 1; i < results.length; i++) {
+ expect(results[i].getTime()).toBeGreaterThan(results[i - 1].getTime())
+ }
+ })
+
+ it("deduplicates same timestamps", () => {
+ // Two identical intervals should not produce duplicate times
+ const intervals: CalendarInterval[] = [
+ { minute: 0, hour: 9, day: null, weekday: null, month: null },
+ { minute: 0, hour: 9, day: null, weekday: null, month: null },
+ ]
+ const results = getNextOccurrencesMulti(intervals, 3)
+ const times = results.map((d) => d.getTime())
+ const unique = new Set(times)
+ expect(unique.size).toBe(times.length)
+ })
+})
+
+describe("formatCalendarIntervals", () => {
+ it("formats hour range as summary", () => {
+ const intervals: CalendarInterval[] = expandHourRange(
+ { minute: 0, hour: null, day: null, weekday: null, month: null },
+ 7,
+ 23
+ )
+ const result = formatCalendarIntervals(intervals)
+ expect(result).toBe("Every day at :00 (7:00–23:00)")
+ })
+
+ it("formats hour range with weekday", () => {
+ const intervals: CalendarInterval[] = expandHourRange(
+ { minute: 30, hour: null, day: null, weekday: 1, month: null },
+ 9,
+ 17
+ )
+ const result = formatCalendarIntervals(intervals)
+ expect(result).toBe("Every Monday at :30 (9:00–17:00)")
+ })
+
+ it("formats hour range with month", () => {
+ const intervals: CalendarInterval[] = expandHourRange(
+ { minute: 0, hour: null, day: null, weekday: null, month: 3 },
+ 9,
+ 17
+ )
+ const result = formatCalendarIntervals(intervals)
+ expect(result).toBe("Month 3 at :00 (9:00–17:00)")
+ })
+
+ it("formats single interval normally", () => {
+ const intervals: CalendarInterval[] = [
+ { minute: 0, hour: 9, day: null, weekday: null, month: null },
+ ]
+ const result = formatCalendarIntervals(intervals)
+ expect(result).toBe("Every day at 09:00")
+ })
+})
diff --git a/src/components/JobDetail.tsx b/src/components/JobDetail.tsx
index 9b18533..6a97ad1 100644
--- a/src/components/JobDetail.tsx
+++ b/src/components/JobDetail.tsx
@@ -12,32 +12,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { LogViewer } from "@/components/LogViewer"
import type { LaunchdJob } from "@/types"
import { getJobDetail, revealInFinder } from "@/lib/invoke"
-import type { CalendarInterval } from "@/types"
import { FolderOpen } from "lucide-react"
-
-const weekdayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
-
-function formatCalendarInterval(ci: CalendarInterval): string {
- const parts: string[] = []
-
- // When
- if (ci.weekday !== null && ci.weekday !== undefined) {
- parts.push(`Every ${weekdayNames[ci.weekday]}`)
- } else if (ci.day !== null && ci.day !== undefined) {
- parts.push(`Day ${ci.day} of each month`)
- } else if (ci.month !== null && ci.month !== undefined) {
- parts.push(`Month ${ci.month}`)
- } else {
- parts.push("Every day")
- }
-
- // Time
- const hour = ci.hour ?? 0
- const minute = ci.minute ?? 0
- parts.push(`at ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`)
-
- return parts.join(" ")
-}
+import { formatCalendarIntervals } from "@/lib/calendar-utils"
type JobDetailProps = {
plistPath: string | null
@@ -190,11 +166,9 @@ export function JobDetail({ plistPath, open, onClose, onEdit }: JobDetailProps)
<>
Schedule
- {job.plist.start_calendar_interval.map((interval, i) => (
-
- {formatCalendarInterval(interval)}
-
- ))}
+
+ {formatCalendarIntervals(job.plist.start_calendar_interval)}
+
>
)}
diff --git a/src/components/JobForm.tsx b/src/components/JobForm.tsx
index 83e8793..f53fdc4 100644
--- a/src/components/JobForm.tsx
+++ b/src/components/JobForm.tsx
@@ -18,6 +18,13 @@ import {
} from "@/components/ui/select"
import type { PlistConfig, LaunchdJob, CalendarInterval } from "@/types"
import { getHomeDir } from "@/lib/invoke"
+import {
+ detectHourRange,
+ expandHourRange,
+ getNextOccurrences,
+ getNextOccurrencesMulti,
+ formatDateTime,
+} from "@/lib/calendar-utils"
type JobFormProps = {
open: boolean
@@ -79,49 +86,25 @@ function emptyConfig(): PlistConfig {
type ScheduleType = "none" | "interval" | "calendar"
+type HourMode = "specific" | "every" | "range"
+
function detectScheduleType(config: PlistConfig): ScheduleType {
if (config.start_interval) return "interval"
if (config.start_calendar_interval && config.start_calendar_interval.length > 0) return "calendar"
return "none"
}
-const weekdayLabels = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
-
-function getNextOccurrences(ci: CalendarInterval, count: number): Date[] {
- const results: Date[] = []
- const now = new Date()
- const candidate = new Date(now)
- candidate.setSeconds(0, 0)
-
- // Start from current minute + 1 to find future times
- candidate.setMinutes(candidate.getMinutes() + 1)
-
- // Search up to 400 days ahead to handle monthly schedules
- const limit = 400 * 24 * 60
- for (let i = 0; i < limit && results.length < count; i++) {
- const matches =
- (ci.month === null || ci.month === undefined || candidate.getMonth() + 1 === ci.month) &&
- (ci.day === null || ci.day === undefined || candidate.getDate() === ci.day) &&
- (ci.weekday === null || ci.weekday === undefined || candidate.getDay() === ci.weekday) &&
- (ci.hour === null || ci.hour === undefined || candidate.getHours() === ci.hour) &&
- (ci.minute === null || ci.minute === undefined || candidate.getMinutes() === ci.minute)
-
- if (matches) {
- results.push(new Date(candidate))
- }
- candidate.setMinutes(candidate.getMinutes() + 1)
+function detectHourMode(config: PlistConfig): HourMode {
+ if (config.start_calendar_interval && config.start_calendar_interval.length > 0) {
+ const range = detectHourRange(config.start_calendar_interval)
+ if (range) return "range"
+ const first = config.start_calendar_interval[0]
+ if (first.hour === null || first.hour === undefined) return "every"
}
- return results
+ return "specific"
}
-function formatDateTime(date: Date): string {
- const weekday = weekdayLabels[date.getDay()]
- const month = date.getMonth() + 1
- const day = date.getDate()
- const hour = String(date.getHours()).padStart(2, "0")
- const minute = String(date.getMinutes()).padStart(2, "0")
- return `${month}/${day} (${weekday}) ${hour}:${minute}`
-}
+const weekdayLabels = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
export function JobForm({ open, onClose, onSave, editingJob }: JobFormProps) {
const [config, setConfig] = useState(
@@ -132,17 +115,27 @@ export function JobForm({ open, onClose, onSave, editingJob }: JobFormProps) {
? formatArguments(editingJob.plist.program_arguments)
: ""
)
+ const initPlist = editingJob?.plist ?? emptyConfig()
const [scheduleType, setScheduleType] = useState(
- detectScheduleType(editingJob?.plist ?? emptyConfig())
+ detectScheduleType(initPlist)
)
+ const existingRange = editingJob?.plist.start_calendar_interval
+ ? detectHourRange(editingJob.plist.start_calendar_interval)
+ : null
const [calendarInterval, setCalendarInterval] = useState(
- editingJob?.plist.start_calendar_interval?.[0] ?? {
- minute: 0,
- hour: 9,
- day: null,
- weekday: null,
- month: null,
- }
+ existingRange
+ ? existingRange.base
+ : editingJob?.plist.start_calendar_interval?.[0] ?? {
+ minute: 0,
+ hour: 9,
+ day: null,
+ weekday: null,
+ month: null,
+ }
+ )
+ const [hourMode, setHourMode] = useState(detectHourMode(initPlist))
+ const [hourRange, setHourRange] = useState<{ from: number; to: number }>(
+ existingRange ? { from: existingRange.from, to: existingRange.to } : { from: 7, to: 23 }
)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
@@ -162,6 +155,10 @@ export function JobForm({ open, onClose, onSave, editingJob }: JobFormProps) {
setError("Label is required")
return
}
+ if (scheduleType === "calendar" && hourMode === "range" && hourRange.from > hourRange.to) {
+ setError("Hour range 'from' must be less than or equal to 'to'")
+ return
+ }
const parsedArgs = args.trim() ? parseArguments(args.trim()) : null
const finalConfig: PlistConfig = {
@@ -169,7 +166,11 @@ export function JobForm({ open, onClose, onSave, editingJob }: JobFormProps) {
program_arguments: parsedArgs,
program: parsedArgs ? parsedArgs[0] : config.program,
start_interval: scheduleType === "interval" ? (config.start_interval || null) : null,
- start_calendar_interval: scheduleType === "calendar" ? [calendarInterval] : null,
+ start_calendar_interval: scheduleType === "calendar"
+ ? hourMode === "range"
+ ? expandHourRange(calendarInterval, hourRange.from, hourRange.to)
+ : [calendarInterval]
+ : null,
standard_out_path: config.standard_out_path?.trim() || null,
standard_error_path: config.standard_error_path?.trim() || null,
working_directory: config.working_directory?.trim() || null,
@@ -348,9 +349,33 @@ export function JobForm({ open, onClose, onSave, editingJob }: JobFormProps) {
{scheduleType === "calendar" && (
-
+
+
+
+
+ {hourMode === "specific" && (
-
+ )}
+ {hourMode === "range" && (
+ )}
+
+
+
+ setCalendarInterval({
+ ...calendarInterval,
+ minute: e.target.value ? Number(e.target.value) : null,
+ })
+ }
+ />