@houston-ai/routines 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,158 @@
1
+ /**
2
+ * RoutineEditForm — Editable fields for a routine's details.
3
+ * Pure presentational component: data and callbacks via props.
4
+ */
5
+ import type { TriggerType, Skill } from "./types"
6
+ import type { RoutineFormState } from "./types"
7
+ import { cn } from "@houston-ai/core"
8
+ import { ScheduleBuilder } from "./schedule-builder"
9
+
10
+ export type { RoutineFormState }
11
+
12
+ export interface RoutineEditFormProps {
13
+ form: RoutineFormState
14
+ skills: Skill[]
15
+ onChange: (patch: Partial<RoutineFormState>) => void
16
+ /** Current trigger config (cron expression) for schedule builder */
17
+ triggerConfig?: string
18
+ /** Called when schedule builder changes the cron expression */
19
+ onTriggerConfigChange?: (cronExpression: string) => void
20
+ }
21
+
22
+ const TRIGGER_OPTIONS: { value: TriggerType; label: string }[] = [
23
+ { value: "manual", label: "Manual" },
24
+ { value: "scheduled", label: "Scheduled" },
25
+ { value: "periodic", label: "Periodic" },
26
+ { value: "on_approval", label: "On Approval" },
27
+ ]
28
+
29
+ const inputClass = cn(
30
+ "w-full px-3 py-2 rounded-xl border border-border bg-background",
31
+ "text-sm text-foreground placeholder:text-muted-foreground/60",
32
+ "focus:outline-none focus:border-border/80 transition-colors",
33
+ )
34
+
35
+ const labelClass = "text-xs font-medium text-muted-foreground mb-1.5 block"
36
+
37
+ const SCHEDULE_TRIGGERS: TriggerType[] = ["scheduled", "periodic"]
38
+
39
+ export function RoutineEditForm({
40
+ form,
41
+ skills,
42
+ onChange,
43
+ triggerConfig,
44
+ onTriggerConfigChange,
45
+ }: RoutineEditFormProps) {
46
+ const showScheduleBuilder =
47
+ SCHEDULE_TRIGGERS.includes(form.triggerType) &&
48
+ onTriggerConfigChange !== undefined
49
+ return (
50
+ <div className="space-y-5">
51
+ {/* Name */}
52
+ <div>
53
+ <label className={labelClass}>Name</label>
54
+ <input
55
+ type="text"
56
+ value={form.name}
57
+ onChange={(e) => onChange({ name: e.target.value })}
58
+ placeholder="Routine name"
59
+ className={inputClass}
60
+ />
61
+ </div>
62
+
63
+ {/* Description */}
64
+ <div>
65
+ <label className={labelClass}>Description</label>
66
+ <textarea
67
+ value={form.description}
68
+ onChange={(e) => onChange({ description: e.target.value })}
69
+ placeholder="What does this routine do?"
70
+ rows={2}
71
+ className={cn(inputClass, "resize-none")}
72
+ />
73
+ </div>
74
+
75
+ {/* Prompt / Context */}
76
+ <div>
77
+ <label className={labelClass}>Prompt</label>
78
+ <textarea
79
+ value={form.context}
80
+ onChange={(e) => onChange({ context: e.target.value })}
81
+ placeholder="Instructions for the agent when running this routine..."
82
+ rows={4}
83
+ className={cn(inputClass, "resize-none")}
84
+ />
85
+ </div>
86
+
87
+ {/* Two-column: Trigger + Skill */}
88
+ <div className="grid grid-cols-2 gap-4">
89
+ <div>
90
+ <label className={labelClass}>Trigger</label>
91
+ <select
92
+ value={form.triggerType}
93
+ onChange={(e) =>
94
+ onChange({ triggerType: e.target.value as TriggerType })
95
+ }
96
+ className={inputClass}
97
+ >
98
+ {TRIGGER_OPTIONS.map((opt) => (
99
+ <option key={opt.value} value={opt.value}>
100
+ {opt.label}
101
+ </option>
102
+ ))}
103
+ </select>
104
+ </div>
105
+
106
+ <div>
107
+ <label className={labelClass}>Skill</label>
108
+ <select
109
+ value={form.skillId ?? ""}
110
+ onChange={(e) =>
111
+ onChange({ skillId: e.target.value || null })
112
+ }
113
+ className={inputClass}
114
+ >
115
+ <option value="">None</option>
116
+ {skills.map((s) => (
117
+ <option key={s.id} value={s.id}>
118
+ {s.name}
119
+ </option>
120
+ ))}
121
+ </select>
122
+ </div>
123
+ </div>
124
+
125
+ {/* Schedule builder (shown for scheduled/periodic triggers) */}
126
+ {showScheduleBuilder && (
127
+ <div>
128
+ <label className={labelClass}>Schedule</label>
129
+ <ScheduleBuilder
130
+ value={triggerConfig ?? "0 9 * * *"}
131
+ onChange={onTriggerConfigChange!}
132
+ />
133
+ </div>
134
+ )}
135
+
136
+ {/* Approval mode */}
137
+ <div>
138
+ <label className={labelClass}>Approval</label>
139
+ <div className="flex gap-2">
140
+ {(["manual", "auto_approve"] as RoutineFormState["approvalMode"][]).map((mode) => (
141
+ <button
142
+ key={mode}
143
+ onClick={() => onChange({ approvalMode: mode })}
144
+ className={cn(
145
+ "h-9 px-4 rounded-full text-sm font-medium transition-colors",
146
+ form.approvalMode === mode
147
+ ? "bg-primary text-primary-foreground"
148
+ : "border border-border text-muted-foreground hover:bg-secondary",
149
+ )}
150
+ >
151
+ {mode === "manual" ? "Manual Review" : "Auto-approve"}
152
+ </button>
153
+ ))}
154
+ </div>
155
+ </div>
156
+ </div>
157
+ )
158
+ }
@@ -0,0 +1,87 @@
1
+ import type { RoutineRun } from "./types"
2
+ import {
3
+ Check,
4
+ AlertCircle,
5
+ Clock,
6
+ Loader2,
7
+ } from "lucide-react"
8
+
9
+ export interface RunHistoryProps {
10
+ runs: RoutineRun[]
11
+ onSelectRun: (id: string) => void
12
+ }
13
+
14
+ export function RunHistory({ runs, onSelectRun }: RunHistoryProps) {
15
+ if (runs.length === 0) {
16
+ return (
17
+ <div>
18
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
19
+ Runs
20
+ </p>
21
+ <p className="text-sm text-muted-foreground">No runs yet.</p>
22
+ </div>
23
+ )
24
+ }
25
+
26
+ const sorted = [...runs].sort(
27
+ (a, b) =>
28
+ new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
29
+ )
30
+
31
+ return (
32
+ <div>
33
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
34
+ Run History
35
+ </p>
36
+ <div className="space-y-1">
37
+ {sorted.map((run) => (
38
+ <RunRow key={run.id} run={run} onClick={() => onSelectRun(run.id)} />
39
+ ))}
40
+ </div>
41
+ </div>
42
+ )
43
+ }
44
+
45
+ function RunRow({ run, onClick }: { run: RoutineRun; onClick: () => void }) {
46
+ const date = new Date(run.created_at)
47
+ const dateStr = date.toLocaleDateString("en-US", {
48
+ month: "short",
49
+ day: "numeric",
50
+ })
51
+ const timeStr = date.toLocaleTimeString("en-US", {
52
+ hour: "numeric",
53
+ minute: "2-digit",
54
+ })
55
+
56
+ const icon = (() => {
57
+ switch (run.status) {
58
+ case "running":
59
+ return <Loader2 className="size-3.5 text-blue-500 animate-spin" />
60
+ case "needs_you":
61
+ return <Clock className="size-3.5 text-amber-500" />
62
+ case "done":
63
+ case "approved":
64
+ return <Check className="size-3.5 text-emerald-500" />
65
+ case "error":
66
+ case "failed":
67
+ return <AlertCircle className="size-3.5 text-destructive" />
68
+ default:
69
+ return <Clock className="size-3.5 text-muted-foreground" />
70
+ }
71
+ })()
72
+
73
+ return (
74
+ <button
75
+ onClick={onClick}
76
+ className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-left hover:bg-accent/30 transition-colors"
77
+ >
78
+ {icon}
79
+ <span className="text-sm text-foreground truncate flex-1">
80
+ {run.output_title || `${dateStr} ${timeStr}`}
81
+ </span>
82
+ <span className="text-xs text-muted-foreground shrink-0">
83
+ {dateStr} {timeStr}
84
+ </span>
85
+ </button>
86
+ )
87
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * RoutineRunPage — Full-page view for a single routine run.
3
+ * Shows the agent conversation header and delegates chat rendering
4
+ * to a consumer-provided component via the `renderChat` prop.
5
+ */
6
+ import type { Routine, RoutineRun } from "./types"
7
+ import { ArrowLeft, Loader2 } from "lucide-react"
8
+ import { cn } from "@houston-ai/core"
9
+
10
+ export interface RoutineRunPageProps {
11
+ routine: Routine | undefined
12
+ run: RoutineRun | undefined
13
+ onBack: () => void
14
+ /** Render the chat feed area. Receives run status context. */
15
+ renderChat: (context: {
16
+ runId: string
17
+ isRunning: boolean
18
+ isNeedsYou: boolean
19
+ }) => React.ReactNode
20
+ }
21
+
22
+ const RUN_STATUS_LABEL: Record<string, string> = {
23
+ running: "Running",
24
+ needs_you: "Needs You",
25
+ done: "Done",
26
+ approved: "Approved",
27
+ completed: "Completed",
28
+ error: "Error",
29
+ failed: "Failed",
30
+ }
31
+
32
+ export function RoutineRunPage({
33
+ routine,
34
+ run,
35
+ onBack,
36
+ renderChat,
37
+ }: RoutineRunPageProps) {
38
+ const isRunning = run?.status === "running"
39
+ const isNeedsYou = run?.status === "needs_you"
40
+
41
+ const displayName = routine?.name || "Routine"
42
+ const statusLabel = run ? (RUN_STATUS_LABEL[run.status] ?? run.status) : ""
43
+ const runDate = run
44
+ ? new Date(run.created_at).toLocaleDateString("en-US", {
45
+ month: "short",
46
+ day: "numeric",
47
+ hour: "numeric",
48
+ minute: "2-digit",
49
+ })
50
+ : ""
51
+
52
+ return (
53
+ <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
54
+ {/* Header */}
55
+ <div className="shrink-0 px-6 py-3 border-b border-border">
56
+ <div className="max-w-3xl mx-auto flex items-center gap-3">
57
+ <button
58
+ onClick={onBack}
59
+ className="size-7 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
60
+ >
61
+ <ArrowLeft className="size-4" strokeWidth={1.75} />
62
+ </button>
63
+ <div className="min-w-0 flex-1">
64
+ <p className="text-sm font-medium text-foreground truncate">
65
+ {displayName}
66
+ </p>
67
+ <p className="text-[11px] text-muted-foreground">
68
+ {runDate}
69
+ <span className="mx-1">&middot;</span>
70
+ <span className={cn(isRunning && "text-blue-500")}>
71
+ {statusLabel}
72
+ </span>
73
+ </p>
74
+ </div>
75
+ {isRunning && (
76
+ <Loader2 className="size-4 animate-spin text-blue-500 shrink-0" />
77
+ )}
78
+ </div>
79
+ </div>
80
+
81
+ {/* Chat feed — delegated to consumer */}
82
+ {run ? (
83
+ renderChat({
84
+ runId: run.id,
85
+ isRunning,
86
+ isNeedsYou,
87
+ })
88
+ ) : (
89
+ <div className="flex-1 flex items-center justify-center">
90
+ <p className="text-sm text-muted-foreground">Run not found</p>
91
+ </div>
92
+ )}
93
+ </div>
94
+ )
95
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * RoutinesGrid — Card grid view for routines.
3
+ * Accepts data and callbacks via props (no Zustand, no Tauri).
4
+ */
5
+ import { useMemo } from "react"
6
+ import {
7
+ Empty, EmptyHeader, EmptyTitle, EmptyDescription,
8
+ } from "@houston-ai/core"
9
+ import type { Routine } from "./types"
10
+ import { RoutineCard } from "./routine-card"
11
+
12
+ export interface RoutinesGridProps {
13
+ routines: Routine[]
14
+ loading?: boolean
15
+ onSelectRoutine: (routineId: string) => void
16
+ }
17
+
18
+ const STATUS_ORDER: Record<string, number> = {
19
+ active: 0,
20
+ needs_setup: 1,
21
+ error: 2,
22
+ paused: 3,
23
+ }
24
+
25
+ export function RoutinesGrid({
26
+ routines,
27
+ loading,
28
+ onSelectRoutine,
29
+ }: RoutinesGridProps) {
30
+ const sorted = useMemo(() => {
31
+ return [...routines].sort((a, b) => {
32
+ const sa = STATUS_ORDER[a.status] ?? 9
33
+ const sb = STATUS_ORDER[b.status] ?? 9
34
+ if (sa !== sb) return sa - sb
35
+ const nameA = a.name.toLowerCase()
36
+ const nameB = b.name.toLowerCase()
37
+ return nameA.localeCompare(nameB)
38
+ })
39
+ }, [routines])
40
+
41
+ if (loading && routines.length === 0) {
42
+ return (
43
+ <div className="flex-1 flex items-center justify-center">
44
+ <p className="text-sm text-muted-foreground animate-pulse">
45
+ Loading routines...
46
+ </p>
47
+ </div>
48
+ )
49
+ }
50
+
51
+ if (sorted.length === 0) {
52
+ return (
53
+ <Empty className="flex-1 border-0">
54
+ <EmptyHeader>
55
+ <EmptyTitle>Automate recurring work</EmptyTitle>
56
+ <EmptyDescription>
57
+ Routines run on a schedule — daily reports, weekly research, and
58
+ more.
59
+ </EmptyDescription>
60
+ </EmptyHeader>
61
+ </Empty>
62
+ )
63
+ }
64
+
65
+ return (
66
+ <div className="flex-1 overflow-y-auto">
67
+ <div className="max-w-4xl mx-auto px-6 py-6">
68
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
69
+ {sorted.map((routine) => (
70
+ <RoutineCard
71
+ key={routine.id}
72
+ routine={routine}
73
+ onClick={() => onSelectRoutine(routine.id)}
74
+ />
75
+ ))}
76
+ </div>
77
+ </div>
78
+ </div>
79
+ )
80
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * ScheduleBuilder — Visual cron schedule builder with preset buttons.
3
+ * Supports presets (daily, weekly, etc.) and custom cron expressions.
4
+ */
5
+ import { useState, useEffect, useRef } from "react"
6
+ import { cn } from "@houston-ai/core"
7
+ import type { SchedulePreset } from "./types"
8
+ import { SCHEDULE_PRESET_LABELS } from "./types"
9
+ import { TimePicker, DayOfWeekPicker, DayOfMonthPicker, CronInput } from "./schedule-picker-fields"
10
+ import {
11
+ presetToCron,
12
+ presetSummary,
13
+ cronToPreset,
14
+ cronToOptions,
15
+ type ScheduleOptions,
16
+ } from "./schedule-cron-utils"
17
+
18
+ export interface ScheduleBuilderProps {
19
+ value: string
20
+ onChange: (cronExpression: string) => void
21
+ presets?: SchedulePreset[]
22
+ }
23
+
24
+ const DEFAULT_PRESETS: SchedulePreset[] = [
25
+ "every_30min", "hourly", "daily", "weekdays", "weekly", "monthly", "custom",
26
+ ]
27
+
28
+ const DEFAULT_OPTIONS: ScheduleOptions = {
29
+ time: "09:00",
30
+ dayOfWeek: 1,
31
+ dayOfMonth: 1,
32
+ }
33
+
34
+ const NEEDS_TIME: SchedulePreset[] = ["daily", "weekdays", "weekly", "monthly"]
35
+
36
+ export function ScheduleBuilder({
37
+ value,
38
+ onChange,
39
+ presets = DEFAULT_PRESETS,
40
+ }: ScheduleBuilderProps) {
41
+ // Detect initial preset from incoming cron
42
+ const detectedPreset = cronToPreset(value)
43
+ const detectedOptions = cronToOptions(value)
44
+
45
+ const [activePreset, setActivePreset] = useState<SchedulePreset>(
46
+ detectedPreset ?? "daily",
47
+ )
48
+ const [options, setOptions] = useState<ScheduleOptions>({
49
+ ...DEFAULT_OPTIONS,
50
+ ...detectedOptions,
51
+ })
52
+ const [customCron, setCustomCron] = useState(
53
+ detectedPreset === null ? value : "",
54
+ )
55
+
56
+ // Stable ref for onChange to avoid infinite effect loops
57
+ const onChangeRef = useRef(onChange)
58
+ onChangeRef.current = onChange
59
+
60
+ // Emit cron when preset or options change
61
+ useEffect(() => {
62
+ if (activePreset === "custom") {
63
+ if (customCron.trim()) onChangeRef.current(customCron.trim())
64
+ return
65
+ }
66
+ const cron = presetToCron(activePreset, options)
67
+ onChangeRef.current(cron)
68
+ }, [activePreset, options, customCron])
69
+
70
+ const updateOption = (patch: Partial<ScheduleOptions>) => {
71
+ setOptions((prev) => ({ ...prev, ...patch }))
72
+ }
73
+
74
+ const showTime = NEEDS_TIME.includes(activePreset)
75
+ const summary = activePreset === "custom"
76
+ ? (customCron.trim() ? "Custom cron schedule" : "Enter a cron expression")
77
+ : presetSummary(activePreset, options)
78
+ const cronDisplay = activePreset === "custom"
79
+ ? customCron
80
+ : presetToCron(activePreset, options)
81
+
82
+ return (
83
+ <div className="space-y-4">
84
+ {/* Preset buttons */}
85
+ <div className="flex flex-wrap gap-1.5">
86
+ {presets.map((preset) => (
87
+ <button
88
+ key={preset}
89
+ onClick={() => setActivePreset(preset)}
90
+ className={cn(
91
+ "h-8 px-3 rounded-full text-xs font-medium transition-colors",
92
+ activePreset === preset
93
+ ? "bg-primary text-primary-foreground"
94
+ : "border border-border text-muted-foreground hover:bg-secondary",
95
+ )}
96
+ >
97
+ {SCHEDULE_PRESET_LABELS[preset]}
98
+ </button>
99
+ ))}
100
+ </div>
101
+
102
+ {/* Summary */}
103
+ <p className="text-sm text-foreground">{summary}</p>
104
+
105
+ {/* Preset-specific fields */}
106
+ <div className="space-y-3">
107
+ {showTime && (
108
+ <TimePicker
109
+ value={options.time}
110
+ onChange={(time) => updateOption({ time })}
111
+ />
112
+ )}
113
+
114
+ {activePreset === "weekly" && (
115
+ <DayOfWeekPicker
116
+ value={options.dayOfWeek}
117
+ onChange={(dayOfWeek) => updateOption({ dayOfWeek })}
118
+ />
119
+ )}
120
+
121
+ {activePreset === "monthly" && (
122
+ <DayOfMonthPicker
123
+ value={options.dayOfMonth}
124
+ onChange={(dayOfMonth) => updateOption({ dayOfMonth })}
125
+ />
126
+ )}
127
+
128
+ {activePreset === "custom" && (
129
+ <CronInput
130
+ value={customCron}
131
+ onChange={setCustomCron}
132
+ />
133
+ )}
134
+ </div>
135
+
136
+ {/* Cron expression display */}
137
+ {cronDisplay && (
138
+ <p className="text-[11px] text-muted-foreground font-mono">
139
+ cron: {cronDisplay}
140
+ </p>
141
+ )}
142
+ </div>
143
+ )
144
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Cron expression utilities for ScheduleBuilder.
3
+ * Converts preset + options into cron expressions and generates summaries.
4
+ */
5
+ import type { SchedulePreset } from "./types"
6
+
7
+ const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
8
+
9
+ export interface ScheduleOptions {
10
+ time: string // "09:00"
11
+ dayOfWeek: number // 0-6
12
+ dayOfMonth: number // 1-31
13
+ }
14
+
15
+ /** Parse "HH:MM" into { hour, minute } */
16
+ function parseTime(time: string): { hour: number; minute: number } {
17
+ const [h, m] = time.split(":").map(Number)
18
+ return { hour: h ?? 9, minute: m ?? 0 }
19
+ }
20
+
21
+ /** Format hour:minute into human-readable time */
22
+ function formatTime(time: string): string {
23
+ const { hour, minute } = parseTime(time)
24
+ const ampm = hour >= 12 ? "PM" : "AM"
25
+ const h12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour
26
+ const mm = String(minute).padStart(2, "0")
27
+ return `${h12}:${mm} ${ampm}`
28
+ }
29
+
30
+ /** Build a cron expression from preset and options */
31
+ export function presetToCron(
32
+ preset: SchedulePreset,
33
+ options: ScheduleOptions,
34
+ ): string {
35
+ const { hour, minute } = parseTime(options.time)
36
+
37
+ switch (preset) {
38
+ case "every_30min":
39
+ return "*/30 * * * *"
40
+ case "hourly":
41
+ return "0 * * * *"
42
+ case "daily":
43
+ return `${minute} ${hour} * * *`
44
+ case "weekdays":
45
+ return `${minute} ${hour} * * 1-5`
46
+ case "weekly":
47
+ return `${minute} ${hour} * * ${options.dayOfWeek}`
48
+ case "monthly":
49
+ return `${minute} ${hour} ${options.dayOfMonth} * *`
50
+ case "custom":
51
+ return "" // caller provides raw cron
52
+ }
53
+ }
54
+
55
+ /** Generate a human-readable summary of a schedule preset */
56
+ export function presetSummary(
57
+ preset: SchedulePreset,
58
+ options: ScheduleOptions,
59
+ ): string {
60
+ const t = formatTime(options.time)
61
+
62
+ switch (preset) {
63
+ case "every_30min":
64
+ return "Runs every 30 minutes"
65
+ case "hourly":
66
+ return "Runs at the start of every hour"
67
+ case "daily":
68
+ return `Runs every day at ${t}`
69
+ case "weekdays":
70
+ return `Runs Monday through Friday at ${t}`
71
+ case "weekly":
72
+ return `Runs every ${DAY_NAMES[options.dayOfWeek]} at ${t}`
73
+ case "monthly":
74
+ return `Runs on the ${ordinal(options.dayOfMonth)} of every month at ${t}`
75
+ case "custom":
76
+ return "Custom cron schedule"
77
+ }
78
+ }
79
+
80
+ function ordinal(n: number): string {
81
+ const s = ["th", "st", "nd", "rd"]
82
+ const v = n % 100
83
+ return n + (s[(v - 20) % 10] || s[v] || s[0])
84
+ }
85
+
86
+ /** Detect a preset from a cron expression (best-effort) */
87
+ export function cronToPreset(cron: string): SchedulePreset | null {
88
+ const trimmed = cron.trim()
89
+ if (trimmed === "*/30 * * * *") return "every_30min"
90
+ if (trimmed === "0 * * * *") return "hourly"
91
+ if (/^\d+ \d+ \* \* \*$/.test(trimmed)) return "daily"
92
+ if (/^\d+ \d+ \* \* 1-5$/.test(trimmed)) return "weekdays"
93
+ if (/^\d+ \d+ \* \* [0-6]$/.test(trimmed)) return "weekly"
94
+ if (/^\d+ \d+ \d+ \* \*$/.test(trimmed)) return "monthly"
95
+ return null
96
+ }
97
+
98
+ /** Extract time/day options from a cron expression (best-effort) */
99
+ export function cronToOptions(cron: string): Partial<ScheduleOptions> {
100
+ const parts = cron.trim().split(/\s+/)
101
+ if (parts.length !== 5) return {}
102
+ const [min, hr, dom, , dow] = parts
103
+ const result: Partial<ScheduleOptions> = {}
104
+
105
+ const minute = Number(min)
106
+ const hour = Number(hr)
107
+ if (!isNaN(minute) && !isNaN(hour)) {
108
+ result.time = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`
109
+ }
110
+
111
+ const dayOfWeek = Number(dow)
112
+ if (!isNaN(dayOfWeek) && dayOfWeek >= 0 && dayOfWeek <= 6) {
113
+ result.dayOfWeek = dayOfWeek
114
+ }
115
+
116
+ const dayOfMonth = Number(dom)
117
+ if (!isNaN(dayOfMonth) && dayOfMonth >= 1 && dayOfMonth <= 31) {
118
+ result.dayOfMonth = dayOfMonth
119
+ }
120
+
121
+ return result
122
+ }