diff --git a/app/(app)/cron/page.tsx b/app/(app)/cron/page.tsx new file mode 100644 index 0000000..d0b04ac --- /dev/null +++ b/app/(app)/cron/page.tsx @@ -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 ( + + + + ); +} diff --git a/components/AppIcons.tsx b/components/AppIcons.tsx index c8a1551..9dc8835 100644 --- a/components/AppIcons.tsx +++ b/components/AppIcons.tsx @@ -78,6 +78,20 @@ export const RandomIcon = (props: React.SVGProps) => ( ); +export const CronIcon = (props: React.SVGProps) => ( + + {/* Clock face */} + + {/* Center */} + + {/* Clock hands */} + + {/* Repeat arrow arcing around the top */} + + + +); + export const CalculateIcon = (props: React.SVGProps) => ( {/* Y-axis */} diff --git a/components/cron/CronEditor.tsx b/components/cron/CronEditor.tsx new file mode 100644 index 0000000..5213682 --- /dev/null +++ b/components/cron/CronEditor.tsx @@ -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 ( +

+ 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, 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) => { + 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 ( +
+ + {/* ── Mobile tabs ─────────────────────────────────────────────────── */} + setMobileTab(v as 'editor' | 'preview')} + /> + + {/* ── Main content ────────────────────────────────────────────────── */} +
+ + {/* 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 +

+ )} +
+ + {/* Saved history */} + {history.length > 0 && ( +
+
+ + Saved + + +
+
+ {history.slice(0, 8).map((entry) => ( +
+ + +
+ ))} +
+
+ )} +
+ + {/* Right: Expression bar + Schedule preview ───────────────────── */} +
+ {expressionBar} + +
+
+ + + Next Occurrences + +
+ +
+
+ +
+
+ ); +} diff --git a/components/cron/CronFieldEditor.tsx b/components/cron/CronFieldEditor.tsx new file mode 100644 index 0000000..63c1ebc --- /dev/null +++ b/components/cron/CronFieldEditor.tsx @@ -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 = { + 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: '9–17', 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: '1–7', 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 = { + 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(colCount - rem).fill(null))]; + + return ( +
+ {/* Header */} +
+
+ + {config.label} + + + {config.min}–{config.max} + +
+
+ {isWildcard && ( + + any value + + )} + + {value} + +
+
+ + {/* Presets */} +
+ {presets.map((preset) => ( + + ))} +
+ + {/* Value grid */} +
+ {padded.map((v, i) => { + if (v === null) return
; + const selected = isSelected(v); + return ( + + ); + })} +
+ + {/* Custom raw input */} +
+ {showRaw ? ( +
+
+ { 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 + /> + + +
+ {rawError && ( +

{rawError}

+ )} +
+ ) : ( + + )} +
+
+ ); +} diff --git a/components/cron/CronPresets.tsx b/components/cron/CronPresets.tsx new file mode 100644 index 0000000..feec56c --- /dev/null +++ b/components/cron/CronPresets.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/lib/cron/cron-engine.ts b/lib/cron/cron-engine.ts new file mode 100644 index 0000000..88b2be3 --- /dev/null +++ b/lib/cron/cron-engine.ts @@ -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; +} + +export const FIELD_CONFIGS: Record = { + 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; + 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 = { + '@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): 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(); + 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, 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 = { + '@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 }; +} diff --git a/lib/cron/store.ts b/lib/cron/store.ts new file mode 100644 index 0000000..5e1a687 --- /dev/null +++ b/lib/cron/store.ts @@ -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()( + 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' }, + ), +); diff --git a/lib/tools.tsx b/lib/tools.tsx index 2ddc4ca..2ac34b5 100644 --- a/lib/tools.tsx +++ b/lib/tools.tsx @@ -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 { /** Short display name (e.g. "Color") */ @@ -108,6 +108,17 @@ export const tools: Tool[] = [ icon: RandomIcon, 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', title: 'Calculator',