Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 67 additions & 9 deletions src/islands/dev/CronExplainer.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { useState, useMemo } from 'react';
import { CalendarClock } from 'lucide-react';
import { useState, useMemo, useRef } from 'react';
import { CalendarClock, ArrowDown } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { CopyButton } from '@/components/ui/CopyButton';
import { Alert } from '@/components/ui/Alert';
import {
parseCron, explainCron, nextRuns,
FIELD_LABELS, CRON_PRESETS,
} from '@/tools/dev/cron.lib';
import { naturalToCron, NATURAL_EXAMPLES } from '@/tools/dev/cron-natural.lib';
import type { Lang } from '@/i18n/config';

const TR: Record<Lang, {
Expand All @@ -15,29 +16,38 @@ const TR: Record<Lang, {
nextRuns: string;
presets: string;
invalidExpr: string;
every: string;
noRuns: string;
fieldHints: string;
describeLabel: string;
describePlaceholder: string;
convertBtn: string;
convertError: string;
dividerOr: string;
}> = {
en: {
exprLabel: 'Cron expression',
description: 'Description',
nextRuns: 'Next 10 run times',
presets: 'Presets',
invalidExpr: 'Invalid expression',
every: 'Every minute',
noRuns: 'No scheduled runs found in the next 4 years.',
fieldHints: 'Field hints',
describeLabel: 'Describe your schedule',
describePlaceholder: 'e.g. every weekday at 9am, every 15 minutes, midnight on the 1st…',
convertBtn: 'Convert to cron',
convertError: 'Could not parse — try "every 15 minutes", "weekdays at 9am", "1st of every month"',
dividerOr: 'or type a cron expression directly',
},
id: {
exprLabel: 'Ekspresi cron',
description: 'Deskripsi',
nextRuns: '10 waktu eksekusi berikutnya',
presets: 'Preset',
invalidExpr: 'Ekspresi tidak valid',
every: 'Setiap menit',
noRuns: 'Tidak ada jadwal yang ditemukan dalam 4 tahun ke depan.',
fieldHints: 'Panduan field',
describeLabel: 'Deskripsikan jadwal Anda',
describePlaceholder: 'cth. setiap hari kerja pukul 9 pagi, setiap 15 menit, tengah malam tanggal 1…',
convertBtn: 'Konversi ke cron',
convertError: 'Tidak dapat diurai — coba "every 15 minutes", "weekdays at 9am", "1st of every month"',
dividerOr: 'atau ketik ekspresi cron secara langsung',
},
};

Expand All @@ -51,6 +61,9 @@ function formatDate(d: Date): string {
export default function CronExplainer({ lang = 'en' }: { lang?: Lang }) {
const t = TR[lang] ?? TR.en;
const [expr, setExpr] = useState('* * * * *');
const [nlInput, setNlInput] = useState('');
const [nlError, setNlError] = useState('');
const exprRef = useRef<HTMLInputElement>(null);

const parsed = useMemo(() => parseCron(expr), [expr]);
const explanation = useMemo(() => {
Expand All @@ -65,15 +78,60 @@ export default function CronExplainer({ lang = 'en' }: { lang?: Lang }) {
const parts = expr.trim().split(/\s+/);
const fieldParts = parts.length === 5 ? parts : ['*', '*', '*', '*', '*'];

const handleConvert = () => {
setNlError('');
const result = naturalToCron(nlInput);
if (result.ok) {
setExpr(result.expr);
exprRef.current?.focus();
} else {
setNlError(result.error);
}
};

// Cycle through example hints on placeholder click (accessibility: shows examples)
const exampleHint = NATURAL_EXAMPLES[Math.floor((Date.now() / 4000)) % NATURAL_EXAMPLES.length];

return (
<div className="space-y-4">
{/* Main input */}
{/* Reverse: natural language → cron */}
<div className="space-y-2">
<label className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">
{t.describeLabel}
</label>
<div className="flex gap-2">
<input
value={nlInput}
onChange={e => { setNlInput(e.target.value); setNlError(''); }}
onKeyDown={e => { if (e.key === 'Enter') handleConvert(); }}
placeholder={t.describePlaceholder}
className="flex-1 border-2 border-border bg-background px-3 py-2 text-sm outline-none focus:shadow-brutal-sm"
aria-label={t.describeLabel}
/>
<Button variant="primary" onClick={handleConvert} disabled={!nlInput.trim()}>
<ArrowDown className="h-4 w-4" />
{t.convertBtn}
</Button>
</div>
{nlError && <Alert variant="error">{nlError}</Alert>}
<p className="text-xs text-muted-foreground">e.g. {exampleHint}</p>
</div>

{/* Divider */}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<div className="h-px flex-1 bg-border" />
<span>{t.dividerOr}</span>
<div className="h-px flex-1 bg-border" />
</div>

{/* Main expression input */}
<div className="space-y-2">
<label className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">
{t.exprLabel}
</label>
<div className="flex items-center gap-2">
<input
ref={exprRef}
value={expr}
onChange={e => setExpr(e.target.value)}
spellCheck={false}
Expand Down
217 changes: 217 additions & 0 deletions src/tools/dev/cron-natural.lib.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { describe, it, expect } from 'vitest';
import { naturalToCron, extractTime, extractDow, extractDom, extractMonth } from './cron-natural.lib';

function expr(input: string): string {
const r = naturalToCron(input);
if (!r.ok) throw new Error(`Parse failed for "${input}": ${r.error}`);
return r.expr;
}

// ─── naturalToCron — interval patterns ──────────────────────────────────────

describe('naturalToCron — intervals', () => {
it.each([
['every minute', '* * * * *'],
['Every Minute', '* * * * *'],
['every 5 minutes', '*/5 * * * *'],
['every 5 min', '*/5 * * * *'],
['every 15 minutes', '*/15 * * * *'],
['every 30 minutes', '*/30 * * * *'],
['every hour', '0 * * * *'],
['hourly', '0 * * * *'],
['every 2 hours', '0 */2 * * *'],
['every 6 hours', '0 */6 * * *'],
['every 12 hours', '0 */12 * * *'],
])('%s → %s', (input, expected) => {
expect(expr(input)).toBe(expected);
});

it('rejects minute step out of range', () => {
const r = naturalToCron('every 60 minutes');
expect(r.ok).toBe(false);
});

it('rejects hour step out of range', () => {
const r = naturalToCron('every 24 hours');
expect(r.ok).toBe(false);
});
});

// ─── naturalToCron — daily patterns ─────────────────────────────────────────

describe('naturalToCron — daily', () => {
it.each([
['daily at midnight', '0 0 * * *'],
['every day at midnight','0 0 * * *'],
['midnight daily', '0 0 * * *'],
['noon every day', '0 12 * * *'],
['daily at noon', '0 12 * * *'],
['every day at 9am', '0 9 * * *'],
['every day at 9:30am', '30 9 * * *'],
['daily at 14:00', '0 14 * * *'],
['at 3pm every day', '0 15 * * *'],
['at 9', '0 9 * * *'],
['at midnight', '0 0 * * *'],
])('%s → %s', (input, expected) => {
expect(expr(input)).toBe(expected);
});
});

// ─── naturalToCron — weekday / weekend ──────────────────────────────────────

describe('naturalToCron — weekdays/weekends', () => {
it.each([
['every weekday at 9am', '0 9 * * 1-5'],
['weekdays at 9am', '0 9 * * 1-5'],
['monday through friday at 8', '0 8 * * 1-5'],
['every weekend at 10am', '0 10 * * 0,6'],
['weekends at midnight', '0 0 * * 0,6'],
])('%s → %s', (input, expected) => {
expect(expr(input)).toBe(expected);
});
});

// ─── naturalToCron — specific days ──────────────────────────────────────────

describe('naturalToCron — specific weekdays', () => {
it.each([
['every monday at 9am', '0 9 * * 1'],
['every Monday', '0 0 * * 1'],
['every friday at noon', '0 12 * * 5'],
['every sunday at midnight', '0 0 * * 0'],
['every monday and wednesday at 8am','0 8 * * 1,3'],
['every tuesday thursday at 6pm', '0 18 * * 2,4'],
['every mon wed fri at 9am', '0 9 * * 1,3,5'],
])('%s → %s', (input, expected) => {
expect(expr(input)).toBe(expected);
});
});

// ─── naturalToCron — monthly ─────────────────────────────────────────────────

describe('naturalToCron — monthly / dom', () => {
it.each([
['monthly', '0 0 1 * *'],
['every month', '0 0 1 * *'],
['monthly on the 1st', '0 0 1 * *'],
['every month on the 15th', '0 0 15 * *'],
['on the 1st at midnight', '0 0 1 * *'],
['on the 15th at noon', '0 12 15 * *'],
])('%s → %s', (input, expected) => {
expect(expr(input)).toBe(expected);
});
});

// ─── naturalToCron — yearly ──────────────────────────────────────────────────

describe('naturalToCron — yearly', () => {
it.each([
['yearly', '0 0 1 1 *'],
['annually', '0 0 1 1 *'],
['every year', '0 0 1 1 *'],
['every year on January 1st', '0 0 1 1 *'],
['every January 1st at midnight', '0 0 1 1 *'],
['on January 15th at 9am', '0 9 15 1 *'],
['every december 25th', '0 0 25 12 *'],
])('%s → %s', (input, expected) => {
expect(expr(input)).toBe(expected);
});
});

// ─── naturalToCron — weekly ───────────────────────────────────────────────────

describe('naturalToCron — weekly', () => {
it.each([
['weekly', '0 0 * * 0'],
['every week', '0 0 * * 0'],
['every week at noon', '0 12 * * 0'],
])('%s → %s', (input, expected) => {
expect(expr(input)).toBe(expected);
});
});

// ─── naturalToCron — error cases ─────────────────────────────────────────────

describe('naturalToCron — errors', () => {
it('returns ok:false for empty input', () => {
expect(naturalToCron('').ok).toBe(false);
});

it('returns ok:false for unrecognisable text', () => {
expect(naturalToCron('random gibberish xyz').ok).toBe(false);
});
});

// ─── Sub-extractors ──────────────────────────────────────────────────────────

describe('extractTime', () => {
it.each([
['midnight', { hour: 0, minute: 0 }],
['noon', { hour: 12, minute: 0 }],
['midday', { hour: 12, minute: 0 }],
['at 9am', { hour: 9, minute: 0 }],
['at 9:30am', { hour: 9, minute: 30 }],
['at 3pm', { hour: 15, minute: 0 }],
['at 12:00pm', { hour: 12, minute: 0 }],
['at 12:00am', { hour: 0, minute: 0 }],
['14:30', { hour: 14, minute: 30 }],
['at 0', { hour: 0, minute: 0 }],
])('%s → %j', (text, expected) => {
expect(extractTime(text)).toEqual(expected);
});

it('returns null when no time is present', () => {
expect(extractTime('every monday')).toBeNull();
});
});

describe('extractDow', () => {
it.each([
['weekdays', '1-5'],
['weekends', '0,6'],
['monday', '1'],
['sunday', '0'],
['monday and wednesday', '1,3'],
['tue thu', '2,4'],
['mon wed fri', '1,3,5'],
])('%s → %s', (text, expected) => {
expect(extractDow(text)).toBe(expected);
});

it('returns null with no day names', () => {
expect(extractDow('at 9am daily')).toBeNull();
});
});

describe('extractDom', () => {
it.each([
['on the 1st', 1],
['on the 15th', 15],
['on the 31st', 31],
['on the first', 1],
['fifteenth', 15],
['day 10', 10],
])('%s → %d', (text, expected) => {
expect(extractDom(text)).toBe(expected);
});

it('returns null with no ordinal', () => {
expect(extractDom('every day at 9am')).toBeNull();
});
});

describe('extractMonth', () => {
it.each([
['january', 1],
['jan', 1],
['december', 12],
['dec', 12],
['august', 8],
])('%s → %d', (text, expected) => {
expect(extractMonth(text)).toBe(expected);
});

it('returns null with no month', () => {
expect(extractMonth('every monday')).toBeNull();
});
});
Loading
Loading