Files
dockhand/lib/components/cron-editor.svelte
Jarek Krochmalski 62e3c6439e Initial commit
2025-12-28 21:16:03 +01:00

309 lines
8.4 KiB
Svelte

<script lang="ts">
import * as Select from '$lib/components/ui/select';
import { Input } from '$lib/components/ui/input';
import { Calendar, CalendarDays, Clock } from 'lucide-svelte';
import { appSettings } from '$lib/stores/settings';
import cronstrue from 'cronstrue';
// Reactive time format from settings
let is12Hour = $derived($appSettings.timeFormat === '12h');
interface Props {
value: string;
onchange: (cron: string) => void;
disabled?: boolean;
}
let { value, onchange, disabled = false }: Props = $props();
// Detect schedule type from cron expression
function detectScheduleType(cron: string): 'daily' | 'weekly' | 'custom' {
const parts = cron.split(' ');
if (parts.length < 5) return 'custom';
const [, , day, month, dow] = parts;
// Weekly: specific day of week (0-6), day and month are wildcards
if (dow !== '*' && day === '*' && month === '*') {
return 'weekly';
}
// Daily: all wildcards except minute and hour
if (day === '*' && month === '*' && dow === '*') {
return 'daily';
}
return 'custom';
}
// Parse cron into components for UI
let minute = $state('0');
let hour = $state('3');
let dayOfWeek = $state('1'); // Monday
let scheduleType = $state<'daily' | 'weekly' | 'custom'>('daily');
// Track if component has been initialized
let initialized = $state(false);
let previousScheduleType = $state<'daily' | 'weekly' | 'custom'>('daily');
let isTypingCustom = $state(false); // Track if user is actively typing in custom mode
// Update UI when value (cron expression) changes externally
$effect(() => {
if (value) {
const parts = value.split(' ');
if (parts.length >= 5) {
minute = parts[0] || '0';
hour = parts[1] || '3';
dayOfWeek = parts[4] !== '*' ? parts[4] : '1'; // Default to Monday
// Only update schedule type if not actively typing in custom mode
if (!isTypingCustom) {
scheduleType = detectScheduleType(value);
}
}
}
// Mark as initialized after first parse
if (!initialized) {
initialized = true;
previousScheduleType = scheduleType;
}
});
// Generate cron expression from UI inputs
function updateCronExpression() {
let newCron = '';
if (scheduleType === 'daily') {
newCron = `${minute} ${hour} * * *`;
} else if (scheduleType === 'weekly') {
newCron = `${minute} ${hour} * * ${dayOfWeek}`;
} else {
// For custom, keep the current value
return;
}
onchange(newCron);
}
// Handle schedule type change
function handleScheduleTypeChange(newType: string) {
const type = newType as 'daily' | 'weekly' | 'custom';
scheduleType = type;
// Set flag when switching to custom mode
if (type === 'custom') {
isTypingCustom = true;
} else {
isTypingCustom = false;
}
// Only reset to defaults if schedule type actually changed after initialization
if (initialized && type !== previousScheduleType) {
if (type === 'daily') {
minute = '0';
hour = '3';
onchange('0 3 * * *');
} else if (type === 'weekly') {
minute = '0';
hour = '3';
dayOfWeek = '1'; // Monday
onchange('0 3 * * 1');
}
previousScheduleType = type;
}
}
function handleMinuteChange(value: string) {
minute = value;
updateCronExpression();
}
function handleHourChange(value: string) {
hour = value;
updateCronExpression();
}
function handleDayOfWeekChange(value: string) {
dayOfWeek = value;
updateCronExpression();
}
function handleCustomCronInput(e: Event) {
const newValue = (e.currentTarget as HTMLInputElement).value;
onchange(newValue);
}
// Validate cron expression
function isValidCron(cron: string): boolean {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return false;
const [min, hr, day, month, dow] = parts;
// Basic pattern validation (number, *, */n, range, list)
const cronFieldPattern = /^(\*|(\*\/\d+)|\d+(-\d+)?(,\d+(-\d+)?)*)$/;
return (
cronFieldPattern.test(min) &&
cronFieldPattern.test(hr) &&
cronFieldPattern.test(day) &&
cronFieldPattern.test(month) &&
cronFieldPattern.test(dow)
);
}
// Human-readable description using cronstrue
let humanReadable = $derived(() => {
if (!value) return '';
if (!value.trim()) return '';
// Validate first
if (!isValidCron(value)) {
return 'Invalid';
}
try {
// Use cronstrue to parse the cron expression
// Configure it to use the user's time format preference
const description = cronstrue.toString(value, {
use24HourTimeFormat: !is12Hour,
throwExceptionOnParseError: true,
locale: 'en' // You can add user locale preference here if needed
});
return description;
} catch (error) {
return 'Invalid';
}
});
// Generate hours array based on time format preference
const hours = $derived(
Array.from({ length: 24 }, (_, i) => ({
value: String(i),
label: is12Hour
? i === 0 ? '12 AM' : i < 12 ? `${i} AM` : i === 12 ? '12 PM' : `${i - 12} PM`
: i.toString().padStart(2, '0') + ':00'
}))
);
const minutes = [
{ value: '0', label: ':00' },
{ value: '15', label: ':15' },
{ value: '30', label: ':30' },
{ value: '45', label: ':45' }
];
const daysOfWeek = [
{ value: '1', label: 'Monday' },
{ value: '2', label: 'Tuesday' },
{ value: '3', label: 'Wednesday' },
{ value: '4', label: 'Thursday' },
{ value: '5', label: 'Friday' },
{ value: '6', label: 'Saturday' },
{ value: '0', label: 'Sunday' }
];
</script>
<div class="flex items-center gap-2 flex-wrap">
<!-- Schedule Type Selector -->
<Select.Root type="single" value={scheduleType} onValueChange={handleScheduleTypeChange} {disabled}>
<Select.Trigger class="w-[140px] h-9">
<div class="flex items-center gap-2">
{#if scheduleType === 'daily'}
<Calendar class="w-4 h-4" />
<span>Daily</span>
{:else if scheduleType === 'weekly'}
<CalendarDays class="w-4 h-4" />
<span>Weekly</span>
{:else}
<Clock class="w-4 h-4" />
<span>Custom</span>
{/if}
</div>
</Select.Trigger>
<Select.Content>
<Select.Item value="daily">
<div class="flex items-center gap-2">
<Calendar class="w-4 h-4" />
<span>Daily</span>
</div>
</Select.Item>
<Select.Item value="weekly">
<div class="flex items-center gap-2">
<CalendarDays class="w-4 h-4" />
<span>Weekly</span>
</div>
</Select.Item>
<Select.Item value="custom">
<div class="flex items-center gap-2">
<Clock class="w-4 h-4" />
<span>Custom</span>
</div>
</Select.Item>
</Select.Content>
</Select.Root>
{#if scheduleType === 'daily' || scheduleType === 'weekly'}
<!-- Time Selectors -->
<span class="text-sm text-muted-foreground">at</span>
<Select.Root type="single" value={hour} onValueChange={handleHourChange} {disabled}>
<Select.Trigger class="w-[100px] h-9">
<span>{hours.find((h: { value: string; label: string }) => h.value === hour)?.label || hour}</span>
</Select.Trigger>
<Select.Content>
{#each hours as h}
<Select.Item value={h.value} label={h.label} />
{/each}
</Select.Content>
</Select.Root>
<Select.Root type="single" value={minute} onValueChange={handleMinuteChange} {disabled}>
<Select.Trigger class="w-[70px] h-9">
<span>{minutes.find(m => m.value === minute)?.label || `:${minute}`}</span>
</Select.Trigger>
<Select.Content>
{#each minutes as m}
<Select.Item value={m.value} label={m.label} />
{/each}
</Select.Content>
</Select.Root>
{#if scheduleType === 'weekly'}
<span class="text-sm text-muted-foreground">on</span>
<Select.Root type="single" value={dayOfWeek} onValueChange={handleDayOfWeekChange} {disabled}>
<Select.Trigger class="w-[110px] h-9">
<span>{daysOfWeek.find(d => d.value === dayOfWeek)?.label || dayOfWeek}</span>
</Select.Trigger>
<Select.Content>
{#each daysOfWeek as d}
<Select.Item value={d.value} label={d.label} />
{/each}
</Select.Content>
</Select.Root>
{/if}
{:else}
<!-- Custom cron input -->
{@const readable = humanReadable()}
{@const isInvalid = readable === 'Invalid'}
<Input
value={value}
oninput={handleCustomCronInput}
placeholder="0 3 * * *"
class="h-9 font-mono flex-1 min-w-[200px] {isInvalid ? 'border-destructive focus-visible:ring-destructive' : ''}"
{disabled}
/>
{/if}
</div>
<!-- Description area with fixed height -->
<div class="min-h-[20px] mt-1">
{#if value}
{@const readable = humanReadable()}
{@const isInvalid = readable === 'Invalid'}
<p class="text-xs {isInvalid ? 'text-destructive' : 'text-muted-foreground'}">
{readable}
</p>
{/if}
</div>