@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.
- package/README.md +42 -0
- package/package.json +22 -0
- package/src/heartbeat-config.tsx +184 -0
- package/src/index.ts +42 -0
- package/src/routine-card.tsx +87 -0
- package/src/routine-detail-actions.tsx +104 -0
- package/src/routine-detail-page.tsx +112 -0
- package/src/routine-edit-form.tsx +158 -0
- package/src/routine-run-history.tsx +87 -0
- package/src/routine-run-page.tsx +95 -0
- package/src/routines-grid.tsx +80 -0
- package/src/schedule-builder.tsx +144 -0
- package/src/schedule-cron-utils.ts +122 -0
- package/src/schedule-picker-fields.tsx +118 -0
- package/src/styles.css +1 -0
- package/src/types.ts +105 -0
|
@@ -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">·</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
|
+
}
|