feat: add Cron Editor tool

Visual cron expression editor with field-by-field builder, presets
select, human-readable description, and live schedule preview showing
next occurrences. Registered in tools registry with CronIcon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 11:30:30 +01:00
parent e9927bf0f5
commit df4db515d8
8 changed files with 1278 additions and 1 deletions

19
app/(app)/cron/page.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { AppPage } from '@/components/layout/AppPage';
import { CronEditor } from '@/components/cron/CronEditor';
import { getToolByHref } from '@/lib/tools';
import { Metadata } from 'next';
const tool = getToolByHref('/cron')!;
export const metadata: Metadata = {
title: tool.title,
description: tool.summary,
};
export default function CronPage() {
return (
<AppPage>
<CronEditor />
</AppPage>
);
}

View File

@@ -78,6 +78,20 @@ export const RandomIcon = (props: React.SVGProps<SVGSVGElement>) => (
</svg> </svg>
); );
export const CronIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
{/* Clock face */}
<circle cx="12" cy="12" r="8.5" strokeWidth={2} />
{/* Center */}
<circle cx="12" cy="12" r="1" fill="currentColor" stroke="none" />
{/* Clock hands */}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 7.5V12l3 2" />
{/* Repeat arrow arcing around the top */}
<path strokeLinecap="round" strokeWidth={1.5} d="M18.5 6.5a10.5 10.5 0 0 0-7-3.5" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M18.5 6.5l2-2M18.5 6.5l-1.5 2.5" />
</svg>
);
export const CalculateIcon = (props: React.SVGProps<SVGSVGElement>) => ( export const CalculateIcon = (props: React.SVGProps<SVGSVGElement>) => (
<svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg {...props} fill="none" stroke="currentColor" viewBox="0 0 24 24">
{/* Y-axis */} {/* Y-axis */}

View File

@@ -0,0 +1,370 @@
'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(
() => (isValid ? getNextOccurrences(expression, 10) : []),
[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 (
<div className="flex flex-col gap-4 animate-fade-in">
{/* ── Mobile tabs ─────────────────────────────────────────────────── */}
<MobileTabs
tabs={[{ value: 'editor', label: 'Editor' }, { value: 'preview', label: 'Preview' }]}
active={mobileTab}
onChange={(v) => setMobileTab(v as 'editor' | 'preview')}
/>
{/* ── Main content ────────────────────────────────────────────────── */}
<div className="grid lg:grid-cols-5 gap-4">
{/* 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 */}
<div className="glass rounded-xl p-5 border border-border/40">
{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>
)}
</div>
{/* Saved history */}
{history.length > 0 && (
<div className="glass rounded-xl p-4 border border-border/40">
<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>
{/* 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">
<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} />
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,262 @@
'use client';
import { useState, useMemo } from 'react';
import { cn } from '@/lib/utils/cn';
import {
parseField,
rebuildFieldFromValues,
validateCronField,
FIELD_CONFIGS,
MONTH_SHORT_NAMES,
DOW_SHORT_NAMES,
type FieldType,
} from '@/lib/cron/cron-engine';
// ── Per-field presets ─────────────────────────────────────────────────────────
interface Preset { label: string; value: string }
const FIELD_PRESETS: Record<FieldType, Preset[]> = {
second: [
{ label: 'Any (*)', value: '*' },
{ label: '*/5', value: '*/5' },
{ label: '*/10', value: '*/10' },
{ label: '*/15', value: '*/15' },
{ label: '*/30', value: '*/30' },
],
minute: [
{ label: 'Any (*)', value: '*' },
{ label: ':00', value: '0' },
{ label: ':30', value: '30' },
{ label: '*/5', value: '*/5' },
{ label: '*/10', value: '*/10' },
{ label: '*/15', value: '*/15' },
{ label: '*/30', value: '*/30' },
],
hour: [
{ label: 'Any (*)', value: '*' },
{ label: 'Midnight', value: '0' },
{ label: '6 AM', value: '6' },
{ label: '9 AM', value: '9' },
{ label: 'Noon', value: '12' },
{ label: '6 PM', value: '18' },
{ label: 'Every 4h', value: '*/4' },
{ label: 'Every 6h', value: '*/6' },
{ label: '917', value: '9-17' },
],
dom: [
{ label: 'Any (*)', value: '*' },
{ label: '1st', value: '1' },
{ label: '10th', value: '10' },
{ label: '15th', value: '15' },
{ label: '20th', value: '20' },
{ label: '1,15', value: '1,15' },
{ label: '17', value: '1-7' },
],
month: [
{ label: 'Any (*)', value: '*' },
{ label: 'Q1', value: '1-3' },
{ label: 'Q2', value: '4-6' },
{ label: 'Q3', value: '7-9' },
{ label: 'Q4', value: '10-12' },
{ label: 'H1', value: '1-6' },
{ label: 'H2', value: '7-12' },
],
dow: [
{ label: 'Any (*)', value: '*' },
{ label: 'Weekdays', value: '1-5' },
{ label: 'Weekends', value: '0,6' },
{ label: 'Mon', value: '1' },
{ label: 'Wed', value: '3' },
{ label: 'Fri', value: '5' },
{ label: 'Sun', value: '0' },
],
};
// ── Grid configuration ────────────────────────────────────────────────────────
const GRID_COLS: Record<FieldType, string> = {
second: 'grid-cols-10',
minute: 'grid-cols-10',
hour: 'grid-cols-8',
dom: 'grid-cols-7',
month: 'grid-cols-4',
dow: 'grid-cols-7',
};
// ── Component ─────────────────────────────────────────────────────────────────
interface CronFieldEditorProps {
fieldType: FieldType;
value: string;
onChange: (value: string) => void;
}
export function CronFieldEditor({ fieldType, value, onChange }: CronFieldEditorProps) {
const [rawInput, setRawInput] = useState('');
const [showRaw, setShowRaw] = useState(false);
const [rawError, setRawError] = useState('');
const config = FIELD_CONFIGS[fieldType];
const parsed = useMemo(() => parseField(value, config), [value, config]);
const presets = FIELD_PRESETS[fieldType];
const isWildcard = parsed?.isWildcard ?? false;
const isSelected = (v: number) => parsed?.values.has(v) ?? false;
const cellLabel = (v: number): string => {
if (fieldType === 'month') return MONTH_SHORT_NAMES[v - 1];
if (fieldType === 'dow') return DOW_SHORT_NAMES[v];
return String(v).padStart(fieldType === 'second' || fieldType === 'minute' ? 2 : 1, '0');
};
const handleCellClick = (v: number) => {
if (!parsed) return;
if (isWildcard) { onChange(String(v)); return; }
const next = new Set(parsed.values);
if (next.has(v)) {
next.delete(v);
if (next.size === 0) { onChange('*'); return; }
} else {
next.add(v);
if (next.size === config.max - config.min + 1) { onChange('*'); return; }
}
onChange(rebuildFieldFromValues(next, config));
};
const handleRawSubmit = () => {
const { valid, error } = validateCronField(rawInput, fieldType);
if (valid) {
onChange(rawInput);
setShowRaw(false);
setRawInput('');
setRawError('');
} else {
setRawError(error ?? 'Invalid');
}
};
const cells = Array.from({ length: config.max - config.min + 1 }, (_, i) => i + config.min);
// Pad to complete rows for DOM (31 cells, 7 cols → pad to 35)
const colCount = parseInt(GRID_COLS[fieldType].replace('grid-cols-', ''), 10);
const rem = cells.length % colCount;
const padded: (number | null)[] = [...cells, ...(rem === 0 ? [] : Array<null>(colCount - rem).fill(null))];
return (
<div className="flex flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-baseline gap-2">
<span className="text-xs font-mono text-muted-foreground uppercase tracking-widest">
{config.label}
</span>
<span className="text-[10px] text-muted-foreground/50 font-mono">
{config.min}{config.max}
</span>
</div>
<div className="flex items-center gap-2">
{isWildcard && (
<span className="text-[10px] font-mono text-primary/60 bg-primary/5 px-2 py-0.5 rounded border border-primary/15">
any value
</span>
)}
<span className="font-mono text-sm text-primary bg-primary/10 px-2.5 py-1 rounded-lg border border-primary/25">
{value}
</span>
</div>
</div>
{/* Presets */}
<div className="flex flex-wrap gap-1.5">
{presets.map((preset) => (
<button
key={preset.value}
onClick={() => onChange(preset.value)}
className={cn(
'px-2.5 py-1 text-[11px] font-mono rounded-lg border transition-all',
value === preset.value
? 'bg-primary/20 border-primary/50 text-primary shadow-[0_0_8px_rgba(139,92,246,0.2)]'
: 'glass border-border/30 text-muted-foreground hover:border-primary/40 hover:text-foreground hover:bg-primary/5',
)}
>
{preset.label}
</button>
))}
</div>
{/* Value grid */}
<div className={cn('grid gap-1', GRID_COLS[fieldType])}>
{padded.map((v, i) => {
if (v === null) return <div key={`pad-${i}`} />;
const selected = isSelected(v);
return (
<button
key={v}
onClick={() => handleCellClick(v)}
title={fieldType === 'month' ? MONTH_SHORT_NAMES[v - 1] : fieldType === 'dow' ? DOW_SHORT_NAMES[v] : String(v)}
className={cn(
'flex items-center justify-center text-[10px] font-mono rounded-md border transition-all',
fieldType === 'month' || fieldType === 'dow'
? 'py-2 px-1'
: 'aspect-square',
isWildcard
? 'bg-primary/8 border-primary/20 text-primary/50 hover:bg-primary/15 hover:border-primary/40 hover:text-primary'
: selected
? 'bg-primary/25 border-primary/55 text-primary font-semibold shadow-[0_0_6px_rgba(139,92,246,0.25)]'
: 'glass border-border/20 text-muted-foreground/50 hover:border-primary/35 hover:text-foreground hover:bg-primary/5',
)}
>
{cellLabel(v)}
</button>
);
})}
</div>
{/* Custom raw input */}
<div className="pt-1 border-t border-border/10">
{showRaw ? (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<input
value={rawInput}
onChange={(e) => { setRawInput(e.target.value); setRawError(''); }}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRawSubmit();
if (e.key === 'Escape') { setShowRaw(false); setRawError(''); }
}}
placeholder={`e.g. ${fieldType === 'minute' ? '*/15 or 0,30' : fieldType === 'hour' ? '9-17' : fieldType === 'dow' ? '1-5' : '*'}`}
className={cn(
'flex-1 px-3 py-1.5 text-xs font-mono bg-muted/20 border rounded-lg focus:outline-none transition-colors',
rawError ? 'border-destructive/50 focus:border-destructive' : 'border-border/30 focus:border-primary/50',
)}
autoFocus
/>
<button
onClick={handleRawSubmit}
className="px-3 py-1.5 text-xs font-mono bg-primary/20 text-primary border border-primary/30 rounded-lg hover:bg-primary/30 transition-all"
>
Set
</button>
<button
onClick={() => { setShowRaw(false); setRawError(''); }}
className="px-3 py-1.5 text-xs font-mono glass border-border/30 text-muted-foreground rounded-lg hover:text-foreground transition-all"
>
Cancel
</button>
</div>
{rawError && (
<p className="text-[10px] text-destructive font-mono">{rawError}</p>
)}
</div>
) : (
<button
onClick={() => { setRawInput(value); setShowRaw(true); }}
className="text-[11px] font-mono text-muted-foreground/40 hover:text-primary/70 transition-colors"
>
Enter custom expression
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,91 @@
'use client';
import { ChevronDown } from 'lucide-react';
interface Preset {
label: string;
expr: string;
}
interface PresetGroup {
label: string;
items: Preset[];
}
const PRESET_GROUPS: PresetGroup[] = [
{
label: 'Common',
items: [
{ label: 'Every minute', expr: '* * * * *' },
{ label: 'Every 5 min', expr: '*/5 * * * *' },
{ label: 'Every 15 min', expr: '*/15 * * * *' },
{ label: 'Every 30 min', expr: '*/30 * * * *' },
{ label: 'Every hour', expr: '0 * * * *' },
{ label: 'Every 6 hours', expr: '0 */6 * * *' },
],
},
{
label: 'Daily',
items: [
{ label: 'Midnight', expr: '0 0 * * *' },
{ label: '6 AM', expr: '0 6 * * *' },
{ label: '9 AM', expr: '0 9 * * *' },
{ label: 'Noon', expr: '0 12 * * *' },
{ label: 'Twice daily', expr: '0 6,18 * * *' },
],
},
{
label: 'Weekly',
items: [
{ label: 'Weekdays 9 AM', expr: '0 9 * * 1-5' },
{ label: 'Monday 9 AM', expr: '0 9 * * 1' },
{ label: 'Friday 5 PM', expr: '0 17 * * 5' },
{ label: 'Sunday 0 AM', expr: '0 0 * * 0' },
{ label: 'Weekends 9 AM', expr: '0 9 * * 0,6' },
],
},
{
label: 'Periodic',
items: [
{ label: 'Monthly 1st', expr: '0 0 1 * *' },
{ label: '1st & 15th', expr: '0 0 1,15 * *' },
{ label: 'Quarterly', expr: '0 0 1 */3 *' },
{ label: 'Bi-annual', expr: '0 0 1 1,7 *' },
{ label: 'January 1st', expr: '0 0 1 1 *' },
],
},
];
interface CronPresetsProps {
onSelect: (expr: string) => void;
current: string;
}
export function CronPresets({ onSelect, current }: CronPresetsProps) {
const allExprs = PRESET_GROUPS.flatMap(g => g.items.map(i => i.expr));
const isPreset = allExprs.includes(current);
return (
<div className="relative">
<select
value={isPreset ? current : ''}
onChange={(e) => { if (e.target.value) onSelect(e.target.value); }}
className="w-full appearance-none bg-muted/20 border border-border/30 rounded-lg px-3 py-1.5 pr-8 text-xs font-mono text-muted-foreground focus:border-primary/50 focus:outline-none transition-colors cursor-pointer hover:border-border/50"
>
<option value="" disabled>
{isPreset ? '' : 'Quick preset…'}
</option>
{PRESET_GROUPS.map((group) => (
<optgroup key={group.label} label={group.label}>
{group.items.map((preset) => (
<option key={preset.expr} value={preset.expr}>
{preset.label}
</option>
))}
</optgroup>
))}
</select>
<ChevronDown className="absolute right-2.5 top-1/2 -translate-y-1/2 w-3 h-3 pointer-events-none text-muted-foreground/40" />
</div>
);
}

463
lib/cron/cron-engine.ts Normal file
View File

@@ -0,0 +1,463 @@
// Cron expression parser, scheduler, and describer
export type FieldType = 'second' | 'minute' | 'hour' | 'dom' | 'month' | 'dow';
export interface CronFieldConfig {
min: number;
max: number;
label: string;
shortLabel: string;
names?: readonly string[];
aliases?: Record<string, number>;
}
export const FIELD_CONFIGS: Record<FieldType, CronFieldConfig> = {
second: { min: 0, max: 59, label: 'Second', shortLabel: 'SEC' },
minute: { min: 0, max: 59, label: 'Minute', shortLabel: 'MIN' },
hour: { min: 0, max: 23, label: 'Hour', shortLabel: 'HOUR' },
dom: { min: 1, max: 31, label: 'Day of Month', shortLabel: 'DOM' },
month: {
min: 1, max: 12, label: 'Month', shortLabel: 'MON',
names: ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'],
aliases: { JAN:1,FEB:2,MAR:3,APR:4,MAY:5,JUN:6,JUL:7,AUG:8,SEP:9,OCT:10,NOV:11,DEC:12 },
},
dow: {
min: 0, max: 6, label: 'Day of Week', shortLabel: 'DOW',
names: ['SUN','MON','TUE','WED','THU','FRI','SAT'],
aliases: { SUN:0,MON:1,TUE:2,WED:3,THU:4,FRI:5,SAT:6 },
},
};
export const MONTH_FULL_NAMES = [
'January','February','March','April','May','June',
'July','August','September','October','November','December',
];
export const DOW_FULL_NAMES = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
export const MONTH_SHORT_NAMES = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
export const DOW_SHORT_NAMES = ['SUN','MON','TUE','WED','THU','FRI','SAT'];
export interface ParsedCronField {
raw: string;
values: Set<number>;
isWildcard: boolean;
}
export interface ParsedCron {
hasSeconds: boolean;
fields: {
second?: ParsedCronField;
minute: ParsedCronField;
hour: ParsedCronField;
dom: ParsedCronField;
month: ParsedCronField;
dow: ParsedCronField;
};
}
export interface CronFields {
second?: string;
minute: string;
hour: string;
dom: string;
month: string;
dow: string;
hasSeconds: boolean;
}
// ── Special expressions ───────────────────────────────────────────────────────
const SPECIAL_EXPRESSIONS: Record<string, string | null> = {
'@yearly': '0 0 1 1 *',
'@annually': '0 0 1 1 *',
'@monthly': '0 0 1 * *',
'@weekly': '0 0 * * 0',
'@daily': '0 0 * * *',
'@midnight': '0 0 * * *',
'@hourly': '0 * * * *',
'@reboot': null,
};
// ── Low-level field parser ────────────────────────────────────────────────────
function resolveAlias(val: string, config: CronFieldConfig): number {
const n = parseInt(val, 10);
if (!isNaN(n)) return n;
if (config.aliases) {
const upper = val.toUpperCase();
if (upper in config.aliases) return config.aliases[upper];
}
return NaN;
}
function parsePart(part: string, config: CronFieldConfig, values: Set<number>): boolean {
// Step: */5 or 0-30/5 or 5/15
const stepMatch = part.match(/^(.+)\/(\d+)$/);
if (stepMatch) {
const step = parseInt(stepMatch[2], 10);
if (isNaN(step) || step < 1) return false;
let start: number, end: number;
if (stepMatch[1] === '*') {
start = config.min; end = config.max;
} else {
const rm = stepMatch[1].match(/^(.+)-(.+)$/);
if (rm) {
start = resolveAlias(rm[1], config);
end = resolveAlias(rm[2], config);
} else {
start = resolveAlias(stepMatch[1], config);
end = config.max;
}
}
if (isNaN(start) || isNaN(end) || start < config.min || end > config.max) return false;
for (let i = start; i <= end; i += step) values.add(i);
return true;
}
// Range: 1-5
const rangeMatch = part.match(/^(.+)-(.+)$/);
if (rangeMatch) {
const start = resolveAlias(rangeMatch[1], config);
const end = resolveAlias(rangeMatch[2], config);
if (isNaN(start) || isNaN(end) || start > end || start < config.min || end > config.max) return false;
for (let i = start; i <= end; i++) values.add(i);
return true;
}
// Single
const n = resolveAlias(part, config);
if (isNaN(n)) return false;
const adjusted = (config === FIELD_CONFIGS.dow && n === 7) ? 0 : n;
if (adjusted < config.min || adjusted > config.max) return false;
values.add(adjusted);
return true;
}
export function parseField(expr: string, config: CronFieldConfig): ParsedCronField | null {
if (!expr) return null;
const values = new Set<number>();
if (expr === '*') {
for (let i = config.min; i <= config.max; i++) values.add(i);
return { raw: expr, values, isWildcard: true };
}
for (const part of expr.split(',')) {
if (!parsePart(part.trim(), config, values)) return null;
}
return { raw: expr, values, isWildcard: false };
}
// ── Expression parser ─────────────────────────────────────────────────────────
export function parseCronExpression(expr: string): ParsedCron | null {
expr = expr.trim();
const lower = expr.toLowerCase();
if (lower.startsWith('@')) {
const resolved = SPECIAL_EXPRESSIONS[lower];
if (resolved === undefined) return null;
if (resolved === null) return null;
expr = resolved;
}
const parts = expr.split(/\s+/);
if (parts.length < 5 || parts.length > 6) return null;
const hasSeconds = parts.length === 6;
const o = hasSeconds ? 1 : 0;
let secondField: ParsedCronField | undefined;
if (hasSeconds) {
const f = parseField(parts[0], FIELD_CONFIGS.second);
if (!f) return null;
secondField = f;
}
const minute = parseField(parts[o + 0], FIELD_CONFIGS.minute);
const hour = parseField(parts[o + 1], FIELD_CONFIGS.hour);
const dom = parseField(parts[o + 2], FIELD_CONFIGS.dom);
const month = parseField(parts[o + 3], FIELD_CONFIGS.month);
const dow = parseField(parts[o + 4], FIELD_CONFIGS.dow);
if (!minute || !hour || !dom || !month || !dow) return null;
return { hasSeconds, fields: { second: secondField, minute, hour, dom, month, dow } };
}
// ── Field value reconstruction ────────────────────────────────────────────────
export function rebuildFieldFromValues(values: Set<number>, config: CronFieldConfig): string {
const sorted = [...values].sort((a, b) => a - b);
if (sorted.length === 0) return '*';
if (sorted.length === config.max - config.min + 1) return '*';
// Regular step from min → */N
if (sorted.length > 1) {
const step = sorted[1] - sorted[0];
if (step > 0 && sorted.every((v, i) => v === sorted[0] + i * step)) {
if (sorted[0] === config.min) return `*/${step}`;
return `${sorted[0]}-${sorted[sorted.length - 1]}/${step}`;
}
// Consecutive range
if (sorted.every((v, i) => i === 0 || v === sorted[i - 1] + 1)) {
return `${sorted[0]}-${sorted[sorted.length - 1]}`;
}
}
return sorted.join(',');
}
// ── Split / build ─────────────────────────────────────────────────────────────
export function splitCronFields(expr: string): CronFields | null {
const lower = expr.trim().toLowerCase();
const resolved = SPECIAL_EXPRESSIONS[lower];
if (resolved !== undefined) {
if (resolved === null) return null;
expr = resolved;
}
const parts = expr.trim().split(/\s+/);
if (parts.length === 5) {
return { minute: parts[0], hour: parts[1], dom: parts[2], month: parts[3], dow: parts[4], hasSeconds: false };
}
if (parts.length === 6) {
return { second: parts[0], minute: parts[1], hour: parts[2], dom: parts[3], month: parts[4], dow: parts[5], hasSeconds: true };
}
return null;
}
export function buildCronExpression(fields: CronFields): string {
const base = `${fields.minute} ${fields.hour} ${fields.dom} ${fields.month} ${fields.dow}`;
return fields.hasSeconds && fields.second ? `${fields.second} ${base}` : base;
}
// ── Day matching ──────────────────────────────────────────────────────────────
function checkDay(d: Date, parsed: ParsedCron): boolean {
const domWild = parsed.fields.dom.isWildcard;
const dowWild = parsed.fields.dow.isWildcard;
if (domWild && dowWild) return true;
if (domWild) return parsed.fields.dow.values.has(d.getDay());
if (dowWild) return parsed.fields.dom.values.has(d.getDate());
return parsed.fields.dom.values.has(d.getDate()) || parsed.fields.dow.values.has(d.getDay());
}
// ── Smart advance algorithm ───────────────────────────────────────────────────
function advanceToNext(date: Date, parsed: ParsedCron): Date | null {
const d = new Date(date);
const maxDate = new Date(date.getTime() + 5 * 366 * 24 * 60 * 60 * 1000);
let guard = 0;
while (d < maxDate && guard++ < 200_000) {
// Month
const m = d.getMonth() + 1;
if (!parsed.fields.month.values.has(m)) {
const sorted = [...parsed.fields.month.values].sort((a, b) => a - b);
const next = sorted.find(v => v > m);
if (next !== undefined) {
d.setMonth(next - 1, 1);
} else {
d.setFullYear(d.getFullYear() + 1, sorted[0] - 1, 1);
}
d.setHours(0, 0, 0, 0);
continue;
}
// Day
if (!checkDay(d, parsed)) {
d.setDate(d.getDate() + 1);
d.setHours(0, 0, 0, 0);
continue;
}
// Hour
const h = d.getHours();
const sortedH = [...parsed.fields.hour.values].sort((a, b) => a - b);
if (!parsed.fields.hour.values.has(h)) {
const next = sortedH.find(v => v > h);
if (next !== undefined) {
d.setHours(next, 0, 0, 0);
} else {
d.setDate(d.getDate() + 1);
d.setHours(sortedH[0], 0, 0, 0);
}
continue;
}
// Minute
const min = d.getMinutes();
const sortedM = [...parsed.fields.minute.values].sort((a, b) => a - b);
if (!parsed.fields.minute.values.has(min)) {
const next = sortedM.find(v => v > min);
if (next !== undefined) {
d.setMinutes(next, 0, 0);
} else {
const nextH = sortedH.find(v => v > h);
if (nextH !== undefined) {
d.setHours(nextH, sortedM[0], 0, 0);
} else {
d.setDate(d.getDate() + 1);
d.setHours(sortedH[0], sortedM[0], 0, 0);
}
}
continue;
}
return new Date(d);
}
return null;
}
export function getNextOccurrences(
expr: string,
count: number = 8,
from: Date = new Date(),
): Date[] {
const parsed = parseCronExpression(expr);
if (!parsed) return [];
const results: Date[] = [];
let current = new Date(from);
current.setSeconds(0, 0);
current.setTime(current.getTime() + 60_000); // start from next minute
for (let i = 0; i < count; i++) {
const next = advanceToNext(current, parsed);
if (!next) break;
results.push(next);
current = new Date(next.getTime() + 60_000);
}
return results;
}
// ── Human-readable description ────────────────────────────────────────────────
function isStepRaw(raw: string): boolean {
return /^(\*|\d+)\/\d+$/.test(raw);
}
function stepValue(raw: string): number | null {
const m = raw.match(/\/(\d+)$/);
return m ? parseInt(m[1], 10) : null;
}
function ordinal(n: number): string {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return n + (s[(v - 20) % 10] || s[v] || s[0]);
}
function formatTime12(hour: number, minute: number): string {
const ampm = hour < 12 ? 'AM' : 'PM';
const h = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
return `${h}:${String(minute).padStart(2, '0')} ${ampm}`;
}
function formatHour(h: number): string {
const ampm = h < 12 ? 'AM' : 'PM';
const d = h === 0 ? 12 : h > 12 ? h - 12 : h;
return `${d}:00 ${ampm}`;
}
function formatDowList(vals: number[]): string {
if (vals.length === 1) return DOW_FULL_NAMES[vals[0]];
if (vals.length === 7) return 'every day';
return vals.map(v => DOW_FULL_NAMES[v]).join(', ');
}
export function describeCronExpression(expr: string): string {
const lower = expr.trim().toLowerCase();
const specialDescs: Record<string, string> = {
'@yearly': 'Every year on January 1st at midnight',
'@annually': 'Every year on January 1st at midnight',
'@monthly': 'Every month on the 1st at midnight',
'@weekly': 'Every week on Sunday at midnight',
'@daily': 'Every day at midnight',
'@midnight': 'Every day at midnight',
'@hourly': 'Every hour at :00',
'@reboot': 'Once at system reboot',
};
if (lower in specialDescs) return specialDescs[lower];
const parsed = parseCronExpression(expr);
if (!parsed) return 'Invalid cron expression';
const { fields } = parsed;
const mVals = [...fields.minute.values].sort((a, b) => a - b);
const hVals = [...fields.hour.values].sort((a, b) => a - b);
const domVals = [...fields.dom.values].sort((a, b) => a - b);
const monVals = [...fields.month.values].sort((a, b) => a - b);
const dowVals = [...fields.dow.values].sort((a, b) => a - b);
const mWild = fields.minute.isWildcard;
const hWild = fields.hour.isWildcard;
const domWild = fields.dom.isWildcard;
const monWild = fields.month.isWildcard;
const dowWild = fields.dow.isWildcard;
// Time
let when = '';
if (mWild && hWild) {
when = 'Every minute';
} else if (hWild && isStepRaw(fields.minute.raw)) {
const s = stepValue(fields.minute.raw);
when = s === 1 ? 'Every minute' : `Every ${s} minutes`;
} else if (mWild && isStepRaw(fields.hour.raw)) {
const s = stepValue(fields.hour.raw);
when = s === 1 ? 'Every hour' : `Every ${s} hours`;
} else if (!mWild && hWild) {
if (isStepRaw(fields.minute.raw)) {
const s = stepValue(fields.minute.raw);
when = `Every ${s} minutes`;
} else if (mVals.length === 1 && mVals[0] === 0) {
when = 'Every hour at :00';
} else if (mVals.length === 1) {
when = `Every hour at :${String(mVals[0]).padStart(2, '0')}`;
} else {
when = `Every hour at minutes ${mVals.join(', ')}`;
}
} else if (mWild && !hWild) {
if (hVals.length === 1) when = `Every minute of ${formatHour(hVals[0])}`;
else when = `Every minute of hours ${hVals.join(', ')}`;
} else {
if (hVals.length === 1 && mVals.length === 1) {
when = `At ${formatTime12(hVals[0], mVals[0])}`;
} else if (hVals.length === 1) {
when = `${formatHour(hVals[0])}, at minutes ${mVals.join(', ')}`;
} else if (mVals.length === 1 && mVals[0] === 0) {
when = `At ${hVals.map(formatHour).join(' and ')}`;
} else {
when = `At hours ${hVals.join(', ')}, minutes ${mVals.join(', ')}`;
}
}
// Day
let day = '';
if (!domWild || !dowWild) {
if (!domWild && !dowWild) {
day = `on day ${domVals.map(ordinal).join(', ')} or ${formatDowList(dowVals)}`;
} else if (!domWild) {
day = domVals.length === 1 ? `on the ${ordinal(domVals[0])}` : `on days ${domVals.map(ordinal).join(', ')}`;
} else {
const isWeekdays = dowVals.length === 5 && [1,2,3,4,5].every(v => dowVals.includes(v));
const isWeekends = dowVals.length === 2 && dowVals.includes(0) && dowVals.includes(6);
if (isWeekdays) day = 'on weekdays';
else if (isWeekends) day = 'on weekends';
else day = `on ${formatDowList(dowVals)}`;
}
}
// Month
let month = '';
if (!monWild) {
month = `in ${monVals.map(v => MONTH_FULL_NAMES[v - 1]).join(', ')}`;
}
let result = when;
if (day) result += `, ${day}`;
if (month) result += `, ${month}`;
return result;
}
export function validateCronExpression(expr: string): { valid: boolean; error?: string } {
const parsed = parseCronExpression(expr);
if (!parsed) return { valid: false, error: 'Invalid cron expression' };
return { valid: true };
}
export function validateCronField(value: string, type: FieldType): { valid: boolean; error?: string } {
if (!value.trim()) return { valid: false, error: 'Required' };
const field = parseField(value, FIELD_CONFIGS[type]);
if (!field) return { valid: false, error: `Invalid ${type} expression` };
return { valid: true };
}

47
lib/cron/store.ts Normal file
View File

@@ -0,0 +1,47 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface CronHistoryEntry {
id: string;
expression: string;
label?: string;
savedAt: number;
}
interface CronStore {
expression: string;
history: CronHistoryEntry[];
setExpression: (expr: string) => void;
addToHistory: (expr: string, label?: string) => void;
removeFromHistory: (id: string) => void;
clearHistory: () => void;
}
export const useCronStore = create<CronStore>()(
persist(
(set) => ({
expression: '0 9 * * 1-5',
history: [],
setExpression: (expression) => set({ expression }),
addToHistory: (expression, label) =>
set((state) => {
const entry: CronHistoryEntry = {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
expression,
label,
savedAt: Date.now(),
};
const filtered = state.history.filter((h) => h.expression !== expression);
return { history: [entry, ...filtered].slice(0, 30) };
}),
removeFromHistory: (id) =>
set((state) => ({ history: state.history.filter((h) => h.id !== id) })),
clearHistory: () => set({ history: [] }),
}),
{ name: 'kit-cron-v1' },
),
);

View File

@@ -1,4 +1,4 @@
import { ColorIcon, UnitsIcon, ASCIIIcon, MediaIcon, FaviconIcon, QRCodeIcon, AnimateIcon, CalculateIcon, RandomIcon } from '@/components/AppIcons'; import { ColorIcon, UnitsIcon, ASCIIIcon, MediaIcon, FaviconIcon, QRCodeIcon, AnimateIcon, CalculateIcon, RandomIcon, CronIcon } from '@/components/AppIcons';
export interface Tool { export interface Tool {
/** Short display name (e.g. "Color") */ /** Short display name (e.g. "Color") */
@@ -108,6 +108,17 @@ export const tools: Tool[] = [
icon: RandomIcon, icon: RandomIcon,
badges: ['Web Crypto', 'Passwords', 'UUID', 'Hashes'], badges: ['Web Crypto', 'Passwords', 'UUID', 'Hashes'],
}, },
{
shortTitle: 'Cron',
title: 'Cron Editor',
navTitle: 'Cron Editor',
href: '/cron',
description: 'Visual editor for cron expressions with live preview.',
summary:
'Build and validate cron expressions with an intuitive visual field editor. Get a human-readable description and preview the next upcoming scheduled runs.',
icon: CronIcon,
badges: ['Cron', 'Scheduler', 'Visual'],
},
{ {
shortTitle: 'Calculate', shortTitle: 'Calculate',
title: 'Calculator', title: 'Calculator',