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, - }) - } - /> +
+ + setHourRange({ ...hourRange, from: Number(e.target.value) }) + } + /> + to + + setHourRange({ ...hourRange, to: Number(e.target.value) }) + } + /> +
+

+ Runs every hour within this range (e.g. 7 to 23 = runs at 7:00, 8:00, ... 23:00). +

+ )} +
+ + + setCalendarInterval({ + ...calendarInterval, + minute: e.target.value ? Number(e.target.value) : null, + }) + } + />