Files
dockhand-upstream/src/lib/components/cron-editor.svelte
T
2026-07-25 08:41:01 +02:00

333 lines
9.7 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;
compact?: boolean;
invalid?: boolean;
}
let { value = $bindable(), onchange, disabled = false, compact = false, invalid = $bindable(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 [min, hr, day, month, dow] = parts;
// Simple minute and hour: plain numbers only (not */n, ranges, or lists)
const isSimpleNumber = (s: string) => /^\d+$/.test(s);
// Weekly: specific single day of week (0-6), day and month are wildcards, simple min/hour
if (dow !== '*' && /^\d$/.test(dow) && day === '*' && month === '*' && isSimpleNumber(min) && isSimpleNumber(hr)) {
return 'weekly';
}
// Daily: all wildcards except simple minute and hour
if (day === '*' && month === '*' && dow === '*' && isSimpleNumber(min) && isSimpleNumber(hr)) {
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;
}
value = newCron;
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';
value = '0 3 * * *';
onchange?.(value);
} else if (type === 'weekly') {
minute = '0';
hour = '3';
dayOfWeek = '1'; // Monday
value = '0 3 * * 1';
onchange?.(value);
}
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;
value = newValue;
onchange?.(newValue);
}
// Validate cron expression (supports 5-field and 6-field with seconds)
function isValidCron(cron: string): boolean {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5 && parts.length !== 6) return false;
// Basic pattern validation (number, *, */n, range, list)
const cronFieldPattern = /^(\*|(\*\/\d+)|\d+(-\d+)?(,\d+(-\d+)?)*)$/;
return parts.every((part) => cronFieldPattern.test(part));
}
// Sync validity to bindable prop so parents can gate Save buttons
$effect(() => {
const next = !value || !value.trim() ? true : !isValidCron(value);
if (invalid !== next) invalid = next;
});
// 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. 24h shows just the
// 2-digit hour so the combined picker reads "at 03 :15" rather than
// the confusing "at 03:00 :15" (#1198).
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')
}))
);
// 5-minute granularity — fine enough to stagger notifications without
// drowning the dropdown in 60 entries (#1198).
const minutes = Array.from({ length: 12 }, (_, i) => ({
value: String(i * 5),
label: ':' + (i * 5).toString().padStart(2, '0')
}));
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>
{#if compact}
<!-- Compact mode: single-line cron input with preview -->
<div>
<Input
{value}
oninput={(e) => { value = e.currentTarget.value; onchange?.(e.currentTarget.value); }}
placeholder="0 2 * * *"
class="h-7 text-xs font-mono {humanReadable() === 'Invalid' ? 'border-destructive' : ''}"
{disabled}
/>
{#if value}
{@const readable = humanReadable()}
<p class="text-[9px] mt-0.5 {readable === 'Invalid' ? 'text-destructive' : 'text-muted-foreground/60'}">{readable}</p>
{/if}
</div>
{:else}
<div class="flex items-center gap-2">
<!-- Schedule Type Selector -->
<Select.Root type="single" value={scheduleType} onValueChange={handleScheduleTypeChange} {disabled}>
<Select.Trigger class="w-[120px] h-9 flex-shrink-0">
<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-[85px] h-9 flex-shrink-0">
<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-[60px] h-9 flex-shrink-0">
<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-[100px] h-9 flex-shrink-0">
<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 -->
{#if value}
{@const readable = humanReadable()}
{@const isInvalid = readable === 'Invalid'}
<p class="text-[10px] mt-0.5 {isInvalid ? 'text-destructive' : 'text-muted-foreground/60'}">
{readable}
</p>
{/if}
{/if}