2026-03-04 11:30:30 +01:00
|
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
|
|
import { useState, useMemo, useCallback, useRef } from 'react';
|
|
|
|
|
|
import {
|
|
|
|
|
|
Copy, Check, BookmarkPlus, Clock, Trash2, ChevronRight,
|
|
|
|
|
|
AlertCircle, CalendarClock,
|
|
|
|
|
|
} from 'lucide-react';
|
|
|
|
|
|
import { toast } from 'sonner';
|
|
|
|
|
|
import { cn } from '@/lib/utils/cn';
|
|
|
|
|
|
import { cardBtn } from '@/lib/utils/styles';
|
|
|
|
|
|
import { MobileTabs } from '@/components/ui/mobile-tabs';
|
|
|
|
|
|
import { CronFieldEditor } from './CronFieldEditor';
|
|
|
|
|
|
import { CronPresets } from './CronPresets';
|
|
|
|
|
|
import { useCronStore } from '@/lib/cron/store';
|
|
|
|
|
|
import {
|
|
|
|
|
|
FIELD_CONFIGS,
|
|
|
|
|
|
splitCronFields,
|
|
|
|
|
|
buildCronExpression,
|
|
|
|
|
|
describeCronExpression,
|
|
|
|
|
|
validateCronExpression,
|
|
|
|
|
|
getNextOccurrences,
|
|
|
|
|
|
type FieldType,
|
|
|
|
|
|
type CronFields,
|
|
|
|
|
|
} from '@/lib/cron/cron-engine';
|
|
|
|
|
|
|
|
|
|
|
|
const FIELD_ORDER: FieldType[] = ['minute', 'hour', 'dom', 'month', 'dow'];
|
|
|
|
|
|
|
|
|
|
|
|
function getFieldValue(fields: CronFields, type: FieldType): string {
|
|
|
|
|
|
switch (type) {
|
|
|
|
|
|
case 'minute': return fields.minute;
|
|
|
|
|
|
case 'hour': return fields.hour;
|
|
|
|
|
|
case 'dom': return fields.dom;
|
|
|
|
|
|
case 'month': return fields.month;
|
|
|
|
|
|
case 'dow': return fields.dow;
|
|
|
|
|
|
case 'second': return fields.second ?? '*';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatOccurrence(d: Date): { relative: string; absolute: string; dow: string } {
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
const diffMs = d.getTime() - now.getTime();
|
|
|
|
|
|
const diffMins = Math.round(diffMs / 60_000);
|
|
|
|
|
|
const diffH = Math.round(diffMs / 3_600_000);
|
|
|
|
|
|
const diffD = Math.round(diffMs / 86_400_000);
|
|
|
|
|
|
|
|
|
|
|
|
let relative: string;
|
|
|
|
|
|
if (diffMins < 60) relative = `in ${diffMins}m`;
|
|
|
|
|
|
else if (diffH < 24) relative = `in ${diffH}h`;
|
|
|
|
|
|
else if (diffD === 1) relative = 'tomorrow';
|
|
|
|
|
|
else relative = `in ${diffD}d`;
|
|
|
|
|
|
|
|
|
|
|
|
const absolute = d.toLocaleString('en-US', {
|
|
|
|
|
|
month: 'short', day: 'numeric',
|
|
|
|
|
|
hour: 'numeric', minute: '2-digit', hour12: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
const dow = d.toLocaleDateString('en-US', { weekday: 'short' });
|
|
|
|
|
|
return { relative, absolute, dow };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Schedule list ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
function ScheduleList({ schedule, isValid }: { schedule: Date[]; isValid: boolean }) {
|
|
|
|
|
|
if (!isValid) return (
|
|
|
|
|
|
<p className="text-xs text-muted-foreground/40 text-center py-8 font-mono">
|
|
|
|
|
|
Fix the expression to see upcoming runs
|
|
|
|
|
|
</p>
|
|
|
|
|
|
);
|
|
|
|
|
|
if (schedule.length === 0) return (
|
|
|
|
|
|
<p className="text-xs text-muted-foreground/40 text-center py-8 font-mono">
|
|
|
|
|
|
No occurrences in the next 5 years
|
|
|
|
|
|
</p>
|
|
|
|
|
|
);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
|
{schedule.map((d, i) => {
|
|
|
|
|
|
const { relative, absolute, dow } = formatOccurrence(d);
|
|
|
|
|
|
const isFirst = i === 0;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={i}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'flex items-center gap-2.5 py-2.5 border-b border-border/10 last:border-0',
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className={cn(
|
|
|
|
|
|
'font-mono text-[10px] px-1.5 py-0.5 rounded border shrink-0 w-[36px] text-center',
|
|
|
|
|
|
isFirst
|
|
|
|
|
|
? 'bg-primary/20 text-primary border-primary/30'
|
|
|
|
|
|
: 'bg-muted/15 text-muted-foreground/50 border-border/10',
|
|
|
|
|
|
)}>
|
|
|
|
|
|
{dow}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className={cn(
|
|
|
|
|
|
'text-xs font-mono flex-1',
|
|
|
|
|
|
isFirst ? 'text-foreground font-medium' : 'text-muted-foreground',
|
|
|
|
|
|
)}>
|
|
|
|
|
|
{absolute}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="text-[10px] font-mono text-muted-foreground/35 shrink-0">
|
|
|
|
|
|
{relative}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Component ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
export function CronEditor() {
|
|
|
|
|
|
const { expression, setExpression, addToHistory, history, removeFromHistory, clearHistory } =
|
|
|
|
|
|
useCronStore();
|
|
|
|
|
|
|
|
|
|
|
|
const [activeField, setActiveField] = useState<FieldType>('minute');
|
|
|
|
|
|
const [mobileTab, setMobileTab] = useState<'editor' | 'preview'>('editor');
|
|
|
|
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
|
|
const [editingRaw, setEditingRaw] = useState(false);
|
|
|
|
|
|
const [rawExpr, setRawExpr] = useState('');
|
|
|
|
|
|
const rawInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const isValid = useMemo(() => validateCronExpression(expression).valid, [expression]);
|
|
|
|
|
|
const fields = useMemo(() => splitCronFields(expression), [expression]);
|
|
|
|
|
|
const description = useMemo(() => describeCronExpression(expression), [expression]);
|
|
|
|
|
|
const schedule = useMemo(
|
2026-03-04 11:41:05 +01:00
|
|
|
|
() => (isValid ? getNextOccurrences(expression, 7) : []),
|
2026-03-04 11:30:30 +01:00
|
|
|
|
[expression, isValid],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const handleFieldChange = useCallback(
|
|
|
|
|
|
(type: FieldType, value: string) => {
|
|
|
|
|
|
if (!fields) return;
|
|
|
|
|
|
const updated: CronFields = { ...fields, [type]: value };
|
|
|
|
|
|
setExpression(buildCronExpression(updated));
|
|
|
|
|
|
},
|
|
|
|
|
|
[fields, setExpression],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const handleCopy = async () => {
|
|
|
|
|
|
await navigator.clipboard.writeText(expression);
|
|
|
|
|
|
setCopied(true);
|
|
|
|
|
|
toast.success('Copied to clipboard');
|
|
|
|
|
|
setTimeout(() => setCopied(false), 2000);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleSave = () => {
|
|
|
|
|
|
addToHistory(expression);
|
|
|
|
|
|
toast.success('Saved to history');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleRawKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
|
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
|
if (validateCronExpression(rawExpr).valid) setExpression(rawExpr);
|
|
|
|
|
|
setEditingRaw(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (e.key === 'Escape') setEditingRaw(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const startEditRaw = () => {
|
|
|
|
|
|
setRawExpr(expression);
|
|
|
|
|
|
setEditingRaw(true);
|
|
|
|
|
|
setTimeout(() => rawInputRef.current?.focus(), 0);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// ── Expression bar (rendered inside right panel) ──────────────────────────
|
|
|
|
|
|
const expressionBar = (
|
|
|
|
|
|
<div className="glass rounded-xl border border-border/40 p-4">
|
|
|
|
|
|
{/* Row 1: Field chips + actions */}
|
|
|
|
|
|
<div className="flex items-center gap-1.5 flex-wrap mb-3">
|
|
|
|
|
|
{FIELD_ORDER.map((type) => {
|
|
|
|
|
|
const active = activeField === type;
|
|
|
|
|
|
const fValue = fields ? getFieldValue(fields, type) : '*';
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={type}
|
|
|
|
|
|
onClick={() => { setActiveField(type); setMobileTab('editor'); }}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'flex items-center gap-1.5 px-2 py-1 rounded-md border transition-all',
|
|
|
|
|
|
active
|
|
|
|
|
|
? 'bg-primary/15 border-primary/50 shadow-[0_0_8px_rgba(139,92,246,0.2)]'
|
|
|
|
|
|
: 'glass border-border/25 hover:border-primary/30 hover:bg-primary/5',
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className={cn(
|
|
|
|
|
|
'text-[8px] font-mono uppercase tracking-[0.1em]',
|
|
|
|
|
|
active ? 'text-primary/60' : 'text-muted-foreground/40',
|
|
|
|
|
|
)}>
|
|
|
|
|
|
{FIELD_CONFIGS[type].shortLabel}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className={cn(
|
|
|
|
|
|
'font-mono text-[10px] font-semibold',
|
|
|
|
|
|
active ? 'text-primary' : fValue === '*' ? 'text-muted-foreground/50' : 'text-foreground',
|
|
|
|
|
|
)}>
|
|
|
|
|
|
{fValue}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="ml-auto flex items-center gap-1.5">
|
|
|
|
|
|
<button onClick={handleCopy} className={cardBtn}>
|
|
|
|
|
|
{copied
|
|
|
|
|
|
? <><Check className="w-3 h-3" /> Copied</>
|
|
|
|
|
|
: <><Copy className="w-3 h-3" /> Copy</>}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button onClick={handleSave} className={cardBtn}>
|
|
|
|
|
|
<BookmarkPlus className="w-3 h-3" /> Save
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Row 2: Expression + description (stacked on mobile, inline on lg) */}
|
|
|
|
|
|
<div className="flex flex-col gap-1 min-w-0">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'cursor-text font-mono text-sm tracking-[0.15em] rounded px-1 -mx-1 py-0.5 transition-colors w-full',
|
|
|
|
|
|
!editingRaw && 'hover:bg-white/3',
|
|
|
|
|
|
!isValid && !editingRaw && 'text-destructive/70',
|
|
|
|
|
|
)}
|
|
|
|
|
|
onClick={!editingRaw ? startEditRaw : undefined}
|
|
|
|
|
|
>
|
|
|
|
|
|
{editingRaw ? (
|
|
|
|
|
|
<input
|
|
|
|
|
|
ref={rawInputRef}
|
|
|
|
|
|
value={rawExpr}
|
|
|
|
|
|
onChange={(e) => setRawExpr(e.target.value)}
|
|
|
|
|
|
onKeyDown={handleRawKey}
|
|
|
|
|
|
onBlur={() => setEditingRaw(false)}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'w-full bg-transparent font-mono text-sm tracking-[0.15em] focus:outline-none',
|
|
|
|
|
|
validateCronExpression(rawExpr).valid ? 'text-foreground' : 'text-destructive/80',
|
|
|
|
|
|
)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
expression
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
|
|
|
|
{isValid
|
|
|
|
|
|
? <CalendarClock className="w-3 h-3 text-muted-foreground/30 shrink-0" />
|
|
|
|
|
|
: <AlertCircle className="w-3 h-3 text-destructive/50 shrink-0" />}
|
|
|
|
|
|
<p className={cn(
|
|
|
|
|
|
'text-xs truncate',
|
|
|
|
|
|
isValid ? 'text-muted-foreground' : 'text-destructive/60',
|
|
|
|
|
|
)}>
|
|
|
|
|
|
{description}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Row 3: Presets select */}
|
|
|
|
|
|
<div className="mt-3 pt-3 border-t border-border/10">
|
|
|
|
|
|
<CronPresets onSelect={(expr) => setExpression(expr)} current={expression} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-04 11:41:05 +01:00
|
|
|
|
<div className="flex flex-col gap-4">
|
2026-03-04 11:30:30 +01:00
|
|
|
|
|
|
|
|
|
|
<MobileTabs
|
|
|
|
|
|
tabs={[{ value: 'editor', label: 'Editor' }, { value: 'preview', label: 'Preview' }]}
|
|
|
|
|
|
active={mobileTab}
|
|
|
|
|
|
onChange={(v) => setMobileTab(v as 'editor' | 'preview')}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
2026-03-04 11:41:05 +01:00
|
|
|
|
{/* Main layout — side-by-side on lg, tabbed on mobile */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="grid grid-cols-1 lg:grid-cols-5 gap-4"
|
|
|
|
|
|
style={{ height: 'calc(100svh - 120px)' }}
|
|
|
|
|
|
>
|
2026-03-04 11:30:30 +01:00
|
|
|
|
|
|
|
|
|
|
{/* Left: Field editor + Presets ──────────────────────────────── */}
|
|
|
|
|
|
<div className={cn(
|
|
|
|
|
|
'lg:col-span-3 flex flex-col gap-4',
|
|
|
|
|
|
mobileTab === 'preview' && 'hidden lg:flex',
|
|
|
|
|
|
)}>
|
|
|
|
|
|
{/* Field selector tabs */}
|
|
|
|
|
|
<div className="flex glass rounded-lg p-0.5 gap-0.5">
|
|
|
|
|
|
{FIELD_ORDER.map((type) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={type}
|
|
|
|
|
|
onClick={() => setActiveField(type)}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md text-xs font-medium transition-all',
|
|
|
|
|
|
activeField === type
|
|
|
|
|
|
? 'bg-primary text-primary-foreground shadow-sm'
|
|
|
|
|
|
: 'text-muted-foreground hover:text-foreground'
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{FIELD_CONFIGS[type].shortLabel}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Field editor panel */}
|
2026-03-04 11:41:05 +01:00
|
|
|
|
<div className="glass rounded-xl p-5 border border-border/40 flex-1 min-h-0 overflow-hidden">
|
2026-03-04 11:30:30 +01:00
|
|
|
|
{fields ? (
|
|
|
|
|
|
<CronFieldEditor
|
|
|
|
|
|
fieldType={activeField}
|
|
|
|
|
|
value={getFieldValue(fields, activeField)}
|
|
|
|
|
|
onChange={(v) => handleFieldChange(activeField, v)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p className="text-sm text-muted-foreground text-center py-8">
|
|
|
|
|
|
Invalid expression — fix it above to edit fields
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
2026-03-04 11:41:05 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Right: Expression bar + Schedule preview ───────────────────── */}
|
|
|
|
|
|
<div className={cn(
|
|
|
|
|
|
'lg:col-span-2 flex flex-col gap-4',
|
|
|
|
|
|
mobileTab === 'editor' && 'hidden lg:flex',
|
|
|
|
|
|
)}>
|
|
|
|
|
|
{expressionBar}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="glass rounded-xl p-4 border border-border/40 scrollbar-thin scrollbar-thumb-rounded scrollbar-thumb-border/30 overflow-auto">
|
|
|
|
|
|
<div className="flex items-center gap-2 mb-3">
|
|
|
|
|
|
<Clock className="w-3.5 h-3.5 text-muted-foreground/40" />
|
|
|
|
|
|
<span className="text-[9px] font-mono text-muted-foreground/50 uppercase tracking-widest">
|
|
|
|
|
|
Next Occurrences
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ScheduleList schedule={schedule} isValid={isValid} />
|
2026-03-04 11:30:30 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-04 11:41:05 +01:00
|
|
|
|
{/* Saved history */}
|
2026-03-04 11:30:30 +01:00
|
|
|
|
{history.length > 0 && (
|
2026-03-04 11:41:05 +01:00
|
|
|
|
<div className="glass rounded-xl p-4 border border-border/40 scrollbar-thin scrollbar-thumb-rounded scrollbar-thumb-border/30 overflow-auto">
|
2026-03-04 11:30:30 +01:00
|
|
|
|
<div className="flex items-center justify-between mb-3">
|
|
|
|
|
|
<span className="text-[9px] font-mono text-muted-foreground/40 uppercase tracking-widest">
|
|
|
|
|
|
Saved
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<button onClick={clearHistory} className={cardBtn}>
|
|
|
|
|
|
<Trash2 className="w-2.5 h-2.5" /> Clear
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex flex-col gap-1">
|
|
|
|
|
|
{history.slice(0, 8).map((entry) => (
|
|
|
|
|
|
<div key={entry.id} className="flex items-center gap-2 group">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => setExpression(entry.expression)}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
'flex-1 flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-all text-left',
|
|
|
|
|
|
entry.expression === expression
|
|
|
|
|
|
? 'bg-primary/10 border-primary/30 text-primary'
|
|
|
|
|
|
: 'glass border-border/20 text-muted-foreground hover:border-primary/30 hover:text-foreground',
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{entry.expression === expression && <ChevronRight className="w-3 h-3 shrink-0" />}
|
|
|
|
|
|
<span className="font-mono text-xs truncate">{entry.expression}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => removeFromHistory(entry.id)}
|
|
|
|
|
|
className="opacity-0 group-hover:opacity-100 w-6 h-6 flex items-center justify-center text-muted-foreground/40 hover:text-destructive transition-all rounded"
|
|
|
|
|
|
>
|
|
|
|
|
|
×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|