kattoolbox/src/app/tools/cron-parser/page.tsx

1015 lines
35 KiB
TypeScript

"use client";
import { useState, useMemo, useCallback } from "react";
import ToolLayout from "@/components/ToolLayout";
interface FieldDefinition {
name: string;
key: string;
min: number;
max: number;
namesMap?: Record<string, number>;
reverseNamesMap?: Record<number, string>;
description: string;
}
const MONTH_NAMES: Record<string, number> = {
JAN: 1, FEB: 2, MAR: 3, APR: 4, MAY: 5, JUN: 6,
JUL: 7, AUG: 8, SEP: 9, OCT: 10, NOV: 11, DEC: 12,
};
const MONTH_NAMES_REV: Record<number, string> = {
1: "January", 2: "February", 3: "March", 4: "April", 5: "May", 6: "June",
7: "July", 8: "August", 9: "September", 10: "October", 11: "November", 12: "December",
};
const DOW_NAMES: Record<string, number> = {
SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6,
};
const DOW_NAMES_REV: Record<number, string> = {
0: "Sunday", 1: "Monday", 2: "Tuesday", 3: "Wednesday", 4: "Thursday", 5: "Friday", 6: "Saturday",
};
const FIELDS: FieldDefinition[] = [
{
name: "Minute",
key: "minute",
min: 0,
max: 59,
description: "Minutes past the hour (0 - 59)",
},
{
name: "Hour",
key: "hour",
min: 0,
max: 23,
description: "Hours of the day (0 - 23, 24-hour clock)",
},
{
name: "Day of Month",
key: "dom",
min: 1,
max: 31,
description: "Days of the month (1 - 31)",
},
{
name: "Month",
key: "month",
min: 1,
max: 12,
namesMap: MONTH_NAMES,
reverseNamesMap: MONTH_NAMES_REV,
description: "Months of the year (1 - 12 or JAN - DEC)",
},
{
name: "Day of Week",
key: "dow",
min: 0,
max: 7,
namesMap: DOW_NAMES,
reverseNamesMap: DOW_NAMES_REV,
description: "Days of the week (0 - 7 or SUN - SAT, 0 & 7 = Sunday)",
},
];
interface ParsedField {
raw: string;
fieldDef: FieldDefinition;
valid: boolean;
error?: string;
values: number[];
isWildcard: boolean;
isStep: boolean;
explanation: string;
}
interface ParsedCron {
valid: boolean;
error?: string;
fields: ParsedField[];
humanExplanation: string;
}
// Helper to format hours in 12-hour AM/PM format
function formatHour12(h: number): string {
const period = h >= 12 ? "PM" : "AM";
const hour12 = h % 12 === 0 ? 12 : h % 12;
return `${hour12}:00 ${period}`;
}
// Helper to format full time in 12-hour format
function formatTime12(h: number, m: number): string {
const period = h >= 12 ? "PM" : "AM";
const hour12 = h % 12 === 0 ? 12 : h % 12;
const padM = m.toString().padStart(2, "0");
return `${hour12}:${padM} ${period}`;
}
// Helper for relative time description
function getRelativeTime(targetDate: Date, now: Date): string {
const diffMs = targetDate.getTime() - now.getTime();
const diffSec = Math.floor(diffMs / 1000);
if (diffSec <= 0) return "just now";
if (diffSec < 60) return `in ${diffSec}s`;
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return `in ${diffMin} min${diffMin > 1 ? "s" : ""}`;
const diffHours = Math.floor(diffMin / 60);
const remainingMins = diffMin % 60;
if (diffHours < 24) {
return `in ${diffHours}h ${remainingMins > 0 ? `${remainingMins}m` : ""}`;
}
const diffDays = Math.floor(diffHours / 24);
const remainingHours = diffHours % 24;
if (diffDays === 1) return `tomorrow in ${diffHours}h`;
if (diffDays < 30) return `in ${diffDays} days ${remainingHours > 0 ? `${remainingHours}h` : ""}`;
const diffMonths = Math.floor(diffDays / 30);
return `in ${diffMonths} month${diffMonths > 1 ? "s" : ""}`;
}
// Parse a single cron field token
function parseCronField(raw: string, fieldDef: FieldDefinition): ParsedField {
const trimmed = raw.trim();
if (!trimmed) {
return {
raw,
fieldDef,
valid: false,
error: `Field "${fieldDef.name}" cannot be empty`,
values: [],
isWildcard: false,
isStep: false,
explanation: "",
};
}
const isWildcard = trimmed === "*";
const isStep = trimmed.includes("/");
const valuesSet = new Set<number>();
const convertTokenToNum = (tok: string): number | null => {
const upper = tok.trim().toUpperCase();
if (fieldDef.namesMap && fieldDef.namesMap[upper] !== undefined) {
return fieldDef.namesMap[upper];
}
const n = Number(tok);
if (!isNaN(n) && Number.isInteger(n)) {
return n;
}
return null;
};
const parts = trimmed.split(",");
for (const part of parts) {
const subParts = part.split("/");
if (subParts.length > 2) {
return {
raw,
fieldDef,
valid: false,
error: `Invalid step expression "${part}" in ${fieldDef.name}`,
values: [],
isWildcard,
isStep,
explanation: "",
};
}
let rangeStart = fieldDef.min;
let rangeEnd = fieldDef.max;
let step = 1;
if (subParts.length === 2) {
const stepNum = Number(subParts[1]);
if (isNaN(stepNum) || stepNum < 1 || !Number.isInteger(stepNum)) {
return {
raw,
fieldDef,
valid: false,
error: `Invalid step value "${subParts[1]}" in ${fieldDef.name}`,
values: [],
isWildcard,
isStep,
explanation: "",
};
}
step = stepNum;
}
const basePart = subParts[0].trim();
if (basePart === "*") {
rangeStart = fieldDef.min;
rangeEnd = fieldDef.max;
} else if (basePart.includes("-")) {
const rangeParts = basePart.split("-");
if (rangeParts.length !== 2) {
return {
raw,
fieldDef,
valid: false,
error: `Invalid range "${basePart}" in ${fieldDef.name}`,
values: [],
isWildcard,
isStep,
explanation: "",
};
}
const startNum = convertTokenToNum(rangeParts[0]);
const endNum = convertTokenToNum(rangeParts[1]);
if (startNum === null || endNum === null) {
return {
raw,
fieldDef,
valid: false,
error: `Invalid range bounds "${basePart}" in ${fieldDef.name}`,
values: [],
isWildcard,
isStep,
explanation: "",
};
}
if (startNum < fieldDef.min || endNum > fieldDef.max) {
return {
raw,
fieldDef,
valid: false,
error: `Range ${startNum}-${endNum} out of bounds (${fieldDef.min}-${fieldDef.max}) in ${fieldDef.name}`,
values: [],
isWildcard,
isStep,
explanation: "",
};
}
if (startNum > endNum) {
return {
raw,
fieldDef,
valid: false,
error: `Range start ${startNum} is greater than end ${endNum} in ${fieldDef.name}`,
values: [],
isWildcard,
isStep,
explanation: "",
};
}
rangeStart = startNum;
rangeEnd = endNum;
} else {
const singleNum = convertTokenToNum(basePart);
if (singleNum === null) {
return {
raw,
fieldDef,
valid: false,
error: `Invalid token "${basePart}" in ${fieldDef.name}`,
values: [],
isWildcard,
isStep,
explanation: "",
};
}
if (singleNum < fieldDef.min || singleNum > fieldDef.max) {
return {
raw,
fieldDef,
valid: false,
error: `Value ${singleNum} out of allowed range (${fieldDef.min}-${fieldDef.max}) in ${fieldDef.name}`,
values: [],
isWildcard,
isStep,
explanation: "",
};
}
if (subParts.length === 2) {
rangeStart = singleNum;
rangeEnd = fieldDef.max;
} else {
rangeStart = singleNum;
rangeEnd = singleNum;
}
}
for (let i = rangeStart; i <= rangeEnd; i += step) {
let val = i;
// In Day of Week, 7 is also Sunday (0)
if (fieldDef.key === "dow" && val === 7) {
val = 0;
}
valuesSet.add(val);
}
}
const values = Array.from(valuesSet).sort((a, b) => a - b);
// Build field-level explanation
let explanation = "";
if (isWildcard) {
explanation = `Every ${fieldDef.name.toLowerCase()}`;
} else if (trimmed.startsWith("*/")) {
const stepVal = trimmed.substring(2);
explanation = `Every ${stepVal} ${fieldDef.name.toLowerCase()}s`;
} else if (fieldDef.key === "dow") {
const dayNames = values.map((v) => DOW_NAMES_REV[v] || v.toString());
if (values.length === 5 && values.every((v, i) => v === i + 1)) {
explanation = "Monday through Friday (weekdays)";
} else if (values.length === 2 && values.includes(0) && values.includes(6)) {
explanation = "Saturday and Sunday (weekends)";
} else {
explanation = `On ${dayNames.join(", ")}`;
}
} else if (fieldDef.key === "month") {
const monthNames = values.map((v) => MONTH_NAMES_REV[v] || `Month ${v}`);
explanation = `In ${monthNames.join(", ")}`;
} else if (fieldDef.key === "hour") {
if (values.length === 1) {
explanation = `At ${formatHour12(values[0])} (${values[0].toString().padStart(2, "0")}:00)`;
} else {
explanation = `At hours ${values.map((v) => v.toString().padStart(2, "0")).join(", ")}`;
}
} else if (fieldDef.key === "minute") {
if (values.length === 1) {
explanation = `At minute ${values[0]}`;
} else {
explanation = `At minutes ${values.join(", ")}`;
}
} else if (fieldDef.key === "dom") {
if (values.length === 1) {
explanation = `On day ${values[0]} of the month`;
} else {
explanation = `On days ${values.join(", ")} of the month`;
}
}
return {
raw,
fieldDef,
valid: true,
values,
isWildcard,
isStep,
explanation,
};
}
// Generate overall human-readable explanation
function generateCronExplanation(fields: ParsedField[]): string {
if (fields.some((f) => !f.valid)) return "Invalid cron expression";
const [minF, hourF, domF, monthF, dowF] = fields;
const minRaw = minF.raw.trim();
const hourRaw = hourF.raw.trim();
const domRaw = domF.raw.trim();
const monthRaw = monthF.raw.trim();
const dowRaw = dowF.raw.trim();
// 1. Every minute (* * * * *)
if (minRaw === "*" && hourRaw === "*" && domRaw === "*" && monthRaw === "*" && dowRaw === "*") {
return "At every minute of every day";
}
// 2. Every N minutes (*/5 * * * *)
if (minRaw.startsWith("*/") && hourRaw === "*" && domRaw === "*" && monthRaw === "*" && dowRaw === "*") {
const step = minRaw.replace("*/", "");
return `Every ${step} minutes`;
}
// 3. Every hour on minute 0 (0 * * * *)
if (minRaw === "0" && hourRaw === "*" && domRaw === "*" && monthRaw === "*" && dowRaw === "*") {
return "At the start of every hour (minute 0)";
}
// 4. Every N hours (0 */2 * * *)
if (minRaw === "0" && hourRaw.startsWith("*/") && domRaw === "*" && monthRaw === "*" && dowRaw === "*") {
const step = hourRaw.replace("*/", "");
return `Every ${step} hours, at minute 0`;
}
// Build natural description
const timeDesc: string[] = [];
if (minF.values.length === 1 && hourF.values.length === 1) {
const h = hourF.values[0];
const m = minF.values[0];
timeDesc.push(`At ${formatTime12(h, m)} (${h.toString().padStart(2, "0")}:${m.toString().padStart(2, "0")})`);
} else if (minF.values.length === 1 && hourF.isWildcard) {
timeDesc.push(`At minute ${minF.values[0]} of every hour`);
} else if (minRaw.startsWith("*/") && hourF.values.length === 1) {
const step = minRaw.replace("*/", "");
timeDesc.push(`Every ${step} minutes, during hour ${hourF.values[0]}`);
} else if (minRaw.startsWith("*/") && hourF.isWildcard) {
const step = minRaw.replace("*/", "");
timeDesc.push(`Every ${step} minutes`);
} else {
timeDesc.push(`${minF.explanation}, ${hourF.explanation.toLowerCase()}`);
}
const dateDesc: string[] = [];
// Day of Month & Day of Week
if (!domF.isWildcard && dowF.isWildcard) {
dateDesc.push(`on day ${domF.values.join(", ")} of the month`);
} else if (domF.isWildcard && !dowF.isWildcard) {
if (dowF.values.length === 5 && [1, 2, 3, 4, 5].every((v) => dowF.values.includes(v))) {
dateDesc.push("Monday through Friday (weekdays)");
} else if (dowF.values.length === 2 && dowF.values.includes(0) && dowF.values.includes(6)) {
dateDesc.push("on weekends (Saturday and Sunday)");
} else {
const days = dowF.values.map((v) => DOW_NAMES_REV[v] || v.toString());
dateDesc.push(`every ${days.join(", ")}`);
}
} else if (!domF.isWildcard && !dowF.isWildcard) {
const days = dowF.values.map((v) => DOW_NAMES_REV[v] || v.toString());
dateDesc.push(`on day ${domF.values.join(", ")} of the month and on ${days.join(", ")}`);
}
// Month
if (!monthF.isWildcard) {
const months = monthF.values.map((v) => MONTH_NAMES_REV[v] || `Month ${v}`);
dateDesc.push(`in ${months.join(", ")}`);
}
if (dateDesc.length === 0) {
return `${timeDesc.join(", ")}, every day`;
}
return `${timeDesc.join(", ")}, ${dateDesc.join(", ")}`;
}
// Calculate next scheduled run times (up to count)
function computeNextRuns(fields: ParsedField[], count: number = 10, timezone: "local" | "utc" = "local"): Date[] {
if (fields.some((f) => !f.valid)) return [];
const [minF, hourF, domF, monthF, dowF] = fields;
const minSet = new Set(minF.values);
const hourSet = new Set(hourF.values);
const domSet = new Set(domF.values);
const monthSet = new Set(monthF.values);
const dowSet = new Set(dowF.values);
const results: Date[] = [];
const start = new Date();
// Start from next minute
const current = new Date(start.getTime());
current.setSeconds(0, 0);
current.setMinutes(current.getMinutes() + 1);
// Safety limit to avoid infinite loops (max 5 years or 500,000 steps)
let steps = 0;
const maxSteps = 500000;
while (results.length < count && steps < maxSteps) {
steps++;
const getYear = timezone === "utc" ? current.getUTCFullYear() : current.getFullYear();
const getMonth = (timezone === "utc" ? current.getUTCMonth() : current.getMonth()) + 1;
const getDate = timezone === "utc" ? current.getUTCDate() : current.getDate();
const getDay = timezone === "utc" ? current.getUTCDay() : current.getDay();
const getHour = timezone === "utc" ? current.getUTCHours() : current.getHours();
const getMin = timezone === "utc" ? current.getUTCMinutes() : current.getMinutes();
// Check Month
if (!monthSet.has(getMonth)) {
// Advance to start of next month
if (timezone === "utc") {
current.setUTCMonth(current.getUTCMonth() + 1, 1);
current.setUTCHours(0, 0, 0, 0);
} else {
current.setMonth(current.getMonth() + 1, 1);
current.setHours(0, 0, 0, 0);
}
continue;
}
// Check Day of Month & Day of Week
const domMatch = domSet.has(getDate);
const dowMatch = dowSet.has(getDay);
let dayMatch = false;
if (domF.isWildcard && dowF.isWildcard) {
dayMatch = true;
} else if (!domF.isWildcard && dowF.isWildcard) {
dayMatch = domMatch;
} else if (domF.isWildcard && !dowF.isWildcard) {
dayMatch = dowMatch;
} else {
// Both specified: standard cron OR matching
dayMatch = domMatch || dowMatch;
}
if (!dayMatch) {
// Advance to next day
if (timezone === "utc") {
current.setUTCDate(current.getUTCDate() + 1);
current.setUTCHours(0, 0, 0, 0);
} else {
current.setDate(current.getDate() + 1);
current.setHours(0, 0, 0, 0);
}
continue;
}
// Check Hour
if (!hourSet.has(getHour)) {
// Advance to next hour
if (timezone === "utc") {
current.setUTCHours(current.getUTCHours() + 1, 0, 0, 0);
} else {
current.setHours(current.getHours() + 1, 0, 0, 0);
}
continue;
}
// Check Minute
if (minSet.has(getMin)) {
results.push(new Date(current.getTime()));
}
// Advance 1 minute
if (timezone === "utc") {
current.setUTCMinutes(current.getUTCMinutes() + 1);
} else {
current.setMinutes(current.getMinutes() + 1);
}
}
return results;
}
const PRESETS = [
{ name: "Every minute", cron: "* * * * *", category: "Frequent" },
{ name: "Every 5 minutes", cron: "*/5 * * * *", category: "Frequent" },
{ name: "Every 15 minutes", cron: "*/15 * * * *", category: "Frequent" },
{ name: "Every 30 minutes", cron: "*/30 * * * *", category: "Frequent" },
{ name: "Every hour", cron: "0 * * * *", category: "Hourly" },
{ name: "Every 2 hours", cron: "0 */2 * * *", category: "Hourly" },
{ name: "Every 6 hours", cron: "0 */6 * * *", category: "Hourly" },
{ name: "Every day at midnight", cron: "0 0 * * *", category: "Daily" },
{ name: "Every day at 9:00 AM", cron: "0 9 * * *", category: "Daily" },
{ name: "Every weekday at 9:00 AM", cron: "0 9 * * 1-5", category: "Weekly" },
{ name: "Every Monday at midnight", cron: "0 0 * * 1", category: "Weekly" },
{ name: "Every Sunday at midnight", cron: "0 0 * * 0", category: "Weekly" },
{ name: "Twice daily (12am & 12pm)", cron: "0 0,12 * * *", category: "Daily" },
{ name: "1st of every month (midnight)", cron: "0 0 1 * *", category: "Monthly" },
{ name: "1st & 15th of every month", cron: "0 0 1,15 * *", category: "Monthly" },
{ name: "Quarterly (Jan, Apr, Jul, Oct 1st)", cron: "0 0 1 1,4,7,10 *", category: "Monthly" },
];
export default function CronParserPage() {
const [cronExpression, setCronExpression] = useState<string>("*/5 * * * *");
const [runCount, setRunCount] = useState<number>(8);
const [timezone, setTimezone] = useState<"local" | "utc">("local");
const [copiedCron, setCopiedCron] = useState<boolean>(false);
const [copiedSchedule, setCopiedSchedule] = useState<boolean>(false);
// Parse 5-part cron expression
const parsedCron = useMemo<ParsedCron>(() => {
const rawTrimmed = cronExpression.trim();
if (!rawTrimmed) {
return {
valid: false,
error: "Please enter a 5-part cron expression",
fields: [],
humanExplanation: "",
};
}
const tokens = rawTrimmed.split(/\s+/);
if (tokens.length !== 5) {
return {
valid: false,
error: `Expected 5 space-separated parts (Minute Hour Day-of-Month Month Day-of-Week), found ${tokens.length}`,
fields: [],
humanExplanation: "",
};
}
const fields = tokens.map((token, index) => {
return parseCronField(token, FIELDS[index]);
});
const invalidField = fields.find((f) => !f.valid);
if (invalidField) {
return {
valid: false,
error: invalidField.error || "Invalid cron field",
fields,
humanExplanation: "",
};
}
const humanExplanation = generateCronExplanation(fields);
return {
valid: true,
fields,
humanExplanation,
};
}, [cronExpression]);
// Compute next runs
const nextRuns = useMemo(() => {
if (!parsedCron.valid || parsedCron.fields.length !== 5) return [];
return computeNextRuns(parsedCron.fields, runCount, timezone);
}, [parsedCron, runCount, timezone]);
// Copy Cron String
const handleCopyCron = async () => {
try {
await navigator.clipboard.writeText(cronExpression.trim());
setCopiedCron(true);
setTimeout(() => setCopiedCron(false), 2000);
} catch {
// Fallback
}
};
// Copy Next Run Times
const handleCopySchedule = async () => {
if (nextRuns.length === 0) return;
const text = nextRuns
.map((d, i) => `${i + 1}. ${timezone === "utc" ? d.toUTCString() : d.toString()}`)
.join("\n");
try {
await navigator.clipboard.writeText(text);
setCopiedSchedule(true);
setTimeout(() => setCopiedSchedule(false), 2000);
} catch {
// Fallback
}
};
// Handle single field input update
const handleFieldChange = useCallback(
(index: number, val: string) => {
const tokens = cronExpression.trim().split(/\s+/);
while (tokens.length < 5) tokens.push("*");
tokens[index] = val || "*";
setCronExpression(tokens.join(" "));
},
[cronExpression]
);
const now = useMemo(() => new Date(), []);
return (
<ToolLayout
title="Cron Expression Parser"
description="Parse, validate, and convert 5-field cron expressions into plain English explanations and calculate the next scheduled execution times."
>
<title>Cron Expression Parser Online — DevToolbox</title>
<meta
name="description"
content="Free online cron expression parser and schedule calculator. Convert 5-part cron syntax (* * * * *) to human-readable English and calculate upcoming run dates."
/>
<div className="space-y-6">
{/* Top Preset Gallery */}
<div className=" border border-[#1a1a1a] bg-black/70 p-4 space-y-3 shadow-xs">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-[#1a1a1a]/80 pb-2">
<span className="text-xs font-bold uppercase tracking-wider text-gray-400">
Common Cron Presets
</span>
<span className="text-xs text-gray-400">Click any preset to load</span>
</div>
<div className="flex flex-wrap gap-2">
{PRESETS.map((p) => {
const isSelected = cronExpression.trim() === p.cron;
return (
<button
key={p.cron + p.name}
type="button"
onClick={() => setCronExpression(p.cron)}
className={`px-3 py-1.5 text-xs transition-all cursor-pointer flex items-center gap-2 border ${
isSelected
? "bg-blue-600 border-blue-600 text-white font-semibold shadow-xs"
: "bg-[#0a0a0a] border-[#1a1a1a] text-gray-400 hover:bg-[#0a0a1a] hover:border-blue-300"
}`}
>
<span>{p.name}</span>
<code
className={`font-mono text-[11px] px-1 py-0.5 ${
isSelected
? "bg-blue-700 text-blue-100"
: "bg-[#111] text-gray-600"
}`}
>
{p.cron}
</code>
</button>
);
})}
</div>
</div>
{/* Main Cron Input Bar */}
<div className=" border border-[#1a1a1a] bg-[#0a0a0a] p-5 shadow-xs space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<label htmlFor="cron-main-input" className="text-sm font-bold text-gray-200">
Enter Cron Expression (5 fields)
</label>
<div className="flex items-center gap-3">
<span className="text-xs text-gray-400 font-mono">Minute Hour Day Month Weekday</span>
<button
type="button"
onClick={handleCopyCron}
className="px-2.5 py-1 bg-[#111] hover:bg-[#1a1a1a] text-gray-400 text-xs font-medium transition-colors cursor-pointer flex items-center gap-1"
>
{copiedCron ? "Copied!" : "Copy Expression"}
</button>
</div>
</div>
<div className="relative">
<input
id="cron-main-input"
type="text"
value={cronExpression}
onChange={(e) => setCronExpression(e.target.value)}
placeholder="* * * * *"
className="w-full p-4 font-mono text-xl sm:text-2xl tracking-wider border border-[#1a1a1a] bg-[#0a0a0a] text-gray-200 focus:ring-1 focus:ring-blue-900 focus:outline-none shadow-inner"
/>
</div>
{/* Human Readable Explanation Banner */}
{parsedCron.valid ? (
<div className="flex items-start gap-3 p-4 border border-emerald-200 bg-emerald-50/70 text-emerald-900">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-none bg-emerald-500 text-white text-xs font-bold mt-0.5">
✓
</span>
<div>
<span className="text-xs font-bold uppercase tracking-wider text-emerald-700 block">
Human-Readable Schedule
</span>
<p className="mt-0.5 text-base sm:text-lg font-bold">
“{parsedCron.humanExplanation}”
</p>
</div>
</div>
) : (
<div className="flex items-start gap-3 p-4 border border-rose-200 bg-rose-50/80 text-rose-900">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-none bg-rose-500 text-white text-xs font-bold mt-0.5">
!
</span>
<div>
<span className="text-xs font-bold uppercase tracking-wider text-rose-700 block">
Syntax Error
</span>
<p className="mt-0.5 text-sm font-medium">{parsedCron.error}</p>
</div>
</div>
)}
</div>
{/* 5 Field Breakdown Interactive Cards */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-gray-200">
Cron Field Breakdown &amp; Structure
</h3>
<span className="text-xs text-gray-400">Edit individual fields directly below</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">
{FIELDS.map((fDef, idx) => {
const parsedF = parsedCron.fields[idx];
const rawToken = parsedF ? parsedF.raw : "";
const isValid = parsedF ? parsedF.valid : true;
return (
<div
key={fDef.key}
className={`flex flex-col justify-between p-3.5 border transition-all ${
isValid
? "border-[#1a1a1a] bg-[#0a0a0a]"
: "border-rose-300 bg-rose-50/40"
}`}
>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-gray-300 uppercase tracking-wide">
{fDef.name}
</span>
<span className="text-[10px] font-mono text-gray-400">
{fDef.min}-{fDef.max}
</span>
</div>
<input
type="text"
value={rawToken}
onChange={(e) => handleFieldChange(idx, e.target.value)}
placeholder="*"
className="w-full p-2 font-mono text-base font-bold text-center border border-[#1a1a1a] bg-black text-gray-200 focus:ring-1 focus:ring-blue-900 focus:outline-none"
/>
<p className="text-[11px] text-gray-500 leading-tight">
{fDef.description}
</p>
</div>
{parsedF && parsedF.valid && (
<div className="mt-3 pt-2 border-t border-gray-100">
<span className="text-[11px] font-medium text-blue-600 block truncate">
{parsedF.explanation}
</span>
<div className="text-[10px] text-gray-400 font-mono mt-0.5 truncate" title={parsedF.values.join(", ")}>
Matches {parsedF.values.length} {parsedF.values.length === 1 ? "value" : "values"}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
{/* Scheduled Runs Section */}
<div className=" border border-[#1a1a1a] bg-[#0a0a0a] p-5 shadow-xs space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-gray-100 pb-3">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-none bg-[#0a0a1a] text-blue-400 text-xs font-bold">
⏰
</span>
<h3 className="text-sm font-bold text-gray-200">
Upcoming Scheduled Runs (Next {runCount})
</h3>
</div>
<div className="flex flex-wrap items-center gap-3">
{/* Timezone Toggle */}
<div className="flex items-center gap-1.5 text-xs">
<span className="text-gray-500">Timezone:</span>
<div className="inline-flex border border-[#1a1a1a] p-0.5 bg-[#111]">
<button
type="button"
onClick={() => setTimezone("local")}
className={`px-2 py-0.5 text-xs font-medium transition-colors cursor-pointer ${
timezone === "local"
? "bg-[#0a0a0a] text-gray-200 shadow-xs"
: "text-gray-600 hover:text-gray-200"
}`}
>
Local Time
</button>
<button
type="button"
onClick={() => setTimezone("utc")}
className={`px-2 py-0.5 text-xs font-medium transition-colors cursor-pointer ${
timezone === "utc"
? "bg-[#0a0a0a] text-gray-200 shadow-xs"
: "text-gray-600 hover:text-gray-200"
}`}
>
UTC
</button>
</div>
</div>
{/* Count selector */}
<div className="flex items-center gap-1.5 text-xs">
<span className="text-gray-500">Count:</span>
<select
value={runCount}
onChange={(e) => setRunCount(Number(e.target.value))}
className="p-1 border border-[#1a1a1a] bg-[#0a0a0a] text-gray-200 text-xs focus:outline-none"
>
<option value={5}>5 runs</option>
<option value={8}>8 runs</option>
<option value={10}>10 runs</option>
<option value={15}>15 runs</option>
</select>
</div>
<button
type="button"
onClick={handleCopySchedule}
disabled={nextRuns.length === 0}
className="px-3 py-1 bg-[#111] hover:bg-[#1a1a1a] text-gray-400 disabled:opacity-40 text-xs font-medium transition-colors cursor-pointer"
>
{copiedSchedule ? "Copied List!" : "Copy Schedule"}
</button>
</div>
</div>
{nextRuns.length > 0 ? (
<div className="divide-y divide-gray-100 max-h-[420px] overflow-y-auto">
{nextRuns.map((date, idx) => {
const relative = getRelativeTime(date, now);
const isLocal = timezone === "local";
const dateStr = isLocal
? date.toLocaleDateString("en-US", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
})
: date.toUTCString().slice(0, 16);
const timeStr = isLocal
? date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: true,
})
: date.toUTCString().slice(17, 25) + " UTC";
return (
<div
key={idx}
className="flex flex-col sm:flex-row sm:items-center justify-between py-2.5 px-2 hover:bg-black transition-colors text-xs"
>
<div className="flex items-center gap-3">
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-none bg-[#111] text-[11px] font-mono font-bold text-gray-500">
{idx + 1}
</span>
<div>
<span className="font-semibold text-gray-200 mr-2">
{dateStr}
</span>
<span className="font-mono text-gray-600">
{timeStr}
</span>
</div>
</div>
<div className="mt-1 sm:mt-0 flex items-center gap-3">
<span className="px-2 py-0.5 rounded-none bg-[#0a0a1a] text-blue-400 font-medium">
{relative}
</span>
<span className="font-mono text-[11px] text-gray-400 hidden md:inline">
{date.toISOString()}
</span>
</div>
</div>
);
})}
</div>
) : (
<div className="p-8 text-center text-gray-400 text-sm">
No upcoming runs calculated. Please check your cron expression.
</div>
)}
</div>
{/* Cron Syntax Cheat Sheet */}
<div className="mt-8 pt-6 border-t border-[#1a1a1a] space-y-4">
<h3 className="text-sm font-bold text-gray-200">
Cron Syntax Reference &amp; Special Characters
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 text-xs">
<div className="p-3 bg-black border border-[#1a1a1a] space-y-1">
<div className="font-mono font-bold text-blue-600 text-sm">* (Asterisk)</div>
<p className="font-semibold text-gray-300">Wildcard (Any Value)</p>
<p className="text-gray-500">
Matches every possible value in the field. E.g. <code className="bg-[#1a1a1a] px-1 rounded">*</code> in hour means every hour.
</p>
</div>
<div className="p-3 bg-black border border-[#1a1a1a] space-y-1">
<div className="font-mono font-bold text-blue-600 text-sm">, (Comma)</div>
<p className="font-semibold text-gray-300">Value List</p>
<p className="text-gray-500">
Specifies discrete multiple values. E.g. <code className="bg-[#1a1a1a] px-1 rounded">1,15,30</code> in minute runs at minutes 1, 15, 30.
</p>
</div>
<div className="p-3 bg-black border border-[#1a1a1a] space-y-1">
<div className="font-mono font-bold text-blue-600 text-sm">- (Hyphen)</div>
<p className="font-semibold text-gray-300">Range of Values</p>
<p className="text-gray-500">
Specifies an inclusive range. E.g. <code className="bg-[#1a1a1a] px-1 rounded">1-5</code> in weekday means Monday through Friday.
</p>
</div>
<div className="p-3 bg-black border border-[#1a1a1a] space-y-1">
<div className="font-mono font-bold text-blue-600 text-sm">/ (Slash)</div>
<p className="font-semibold text-gray-300">Step Values</p>
<p className="text-gray-500">
Specifies step increments. E.g. <code className="bg-[#1a1a1a] px-1 rounded">*/15</code> in minute runs every 15 minutes.
</p>
</div>
</div>
</div>
</div>
</ToolLayout>
);
}