'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 (

Fix the expression to see upcoming runs

); if (schedule.length === 0) return (

No occurrences in the next 5 years

); return (
{schedule.map((d, i) => { const { relative, absolute, dow } = formatOccurrence(d); const isFirst = i === 0; return (
{dow} {absolute} {relative}
); })}
); } // ── Component ───────────────────────────────────────────────────────────────── export function CronEditor() { const { expression, setExpression, addToHistory, history, removeFromHistory, clearHistory } = useCronStore(); const [activeField, setActiveField] = useState('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(null); const isValid = useMemo(() => validateCronExpression(expression).valid, [expression]); const fields = useMemo(() => splitCronFields(expression), [expression]); const description = useMemo(() => describeCronExpression(expression), [expression]); const schedule = useMemo( () => (isValid ? getNextOccurrences(expression, 7) : []), [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) => { 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 = (
{/* Row 1: Field chips + actions */}
{FIELD_ORDER.map((type) => { const active = activeField === type; const fValue = fields ? getFieldValue(fields, type) : '*'; return ( ); })}
{/* Row 2: Expression + description (stacked on mobile, inline on lg) */}
{editingRaw ? ( 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 )}
{isValid ? : }

{description}

{/* Row 3: Presets select */}
setExpression(expr)} current={expression} />
); return (
setMobileTab(v as 'editor' | 'preview')} /> {/* Main layout — side-by-side on lg, tabbed on mobile */}
{/* Left: Field editor + Presets ──────────────────────────────── */}
{/* Field selector tabs */}
{FIELD_ORDER.map((type) => ( ))}
{/* Field editor panel */}
{fields ? ( handleFieldChange(activeField, v)} /> ) : (

Invalid expression — fix it above to edit fields

)}
{/* Right: Expression bar + Schedule preview ───────────────────── */}
{expressionBar}
Next Occurrences
{/* Saved history */} {history.length > 0 && (
Saved
{history.slice(0, 8).map((entry) => (
))}
)}
); }