This commit is contained in:
Timothy Jaeryang Baek
2026-04-01 00:11:11 -05:00
parent 378673408e
commit 8a0794958d
3 changed files with 89 additions and 40 deletions
+19 -3
View File
@@ -38,10 +38,26 @@ AUTOMATION_POLL_INTERVAL = int(os.getenv('AUTOMATION_POLL_INTERVAL', '10'))
####################
def _parse_rule(s: str):
"""Parse RRULE with clock-aligned DTSTART for sub-daily frequencies.
MINUTELY/HOURLY rules use a fixed epoch DTSTART (2000-01-01 00:00)
so intervals snap to clock boundaries (e.g. every 5min = :00, :05, :10).
"""
raw = s.replace('RRULE:', '')
parts = dict(p.split('=', 1) for p in raw.split(';') if '=' in p)
freq = parts.get('FREQ', '')
if freq in ('MINUTELY', 'HOURLY'):
epoch = datetime(2000, 1, 1, 0, 0, 0)
return rrulestr(s, dtstart=epoch, ignoretz=True)
return rrulestr(s, ignoretz=True)
def validate_rrule(s: str) -> None:
"""Raise ValueError if the RRULE is malformed or exhausted."""
try:
rule = rrulestr(s, ignoretz=True)
rule = _parse_rule(s)
except Exception as e:
raise ValueError(f'Invalid RRULE: {e}')
if rule.after(datetime.now()) is None:
@@ -51,7 +67,7 @@ def validate_rrule(s: str) -> None:
def next_run_ns(s: str, tz: str = None) -> Optional[int]:
"""Next occurrence as epoch nanoseconds, respecting user timezone."""
now = datetime.now(ZoneInfo(tz)) if tz else datetime.now()
dt = rrulestr(s, ignoretz=True).after(now.replace(tzinfo=None))
dt = _parse_rule(s).after(now.replace(tzinfo=None))
if dt is None:
return None
if tz:
@@ -61,7 +77,7 @@ def next_run_ns(s: str, tz: str = None) -> Optional[int]:
def next_n_runs_ns(s: str, n: int = 5, tz: str = None) -> list[int]:
"""Compute next N occurrences for UI preview."""
rule = rrulestr(s, ignoretz=True)
rule = _parse_rule(s)
result = []
dt = datetime.now()
for _ in range(n):
+61 -37
View File
@@ -36,6 +36,8 @@
let minute = 0;
let selectedDays: string[] = [];
let monthDay = 1;
let onceDate = '';
let onceTime = '09:00';
let loading = false;
let showScheduleDropdown = false;
@@ -56,6 +58,7 @@
: $models;
const FREQUENCIES = [
{ key: 'ONCE', label: 'Once' },
{ key: 'HOURLY', label: 'Hourly' },
{ key: 'DAILY', label: 'Daily' },
{ key: 'WEEKLY', label: 'Weekly' },
@@ -74,6 +77,10 @@
];
const buildVisualRrule = (): string => {
if (lastVisualFrequency === 'ONCE') {
const dt = onceDate.replace(/-/g, '') + 'T' + onceTime.replace(/:/g, '') + '00';
return `DTSTART:${dt}\nRRULE:FREQ=DAILY;COUNT=1`;
}
let parts = [`FREQ=${lastVisualFrequency}`];
if (interval > 1) parts.push(`INTERVAL=${interval}`);
if (lastVisualFrequency === 'WEEKLY' && selectedDays.length) {
@@ -96,6 +103,12 @@
lastVisualFrequency = frequency;
}
$: if (frequency === 'ONCE' && !onceDate) {
const soon = new Date(Date.now() + 5 * 60_000);
onceDate = soon.toISOString().split('T')[0];
onceTime = `${String(soon.getHours()).padStart(2, '0')}:${String(soon.getMinutes()).padStart(2, '0')}`;
}
$: {
if (frequency === 'CUSTOM' && prevFrequency !== 'CUSTOM') {
customRrule = buildVisualRrule();
@@ -105,6 +118,10 @@
const buildRrule = (): string => {
if (frequency === 'CUSTOM') return customRrule;
if (frequency === 'ONCE') {
const dt = onceDate.replace(/-/g, '') + 'T' + onceTime.replace(/:/g, '') + '00';
return `DTSTART:${dt}\nRRULE:FREQ=DAILY;COUNT=1`;
}
let parts = [`FREQ=${frequency}`];
if (interval > 1) parts.push(`INTERVAL=${interval}`);
if (frequency === 'WEEKLY' && selectedDays.length) {
@@ -121,6 +138,16 @@
};
const parseRrule = (s: string) => {
// Detect ONCE (COUNT=1 with DTSTART)
if (s.includes('COUNT=1')) {
frequency = 'ONCE';
const match = s.match(/DTSTART:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/);
if (match) {
onceDate = `${match[1]}-${match[2]}-${match[3]}`;
onceTime = `${match[4]}:${match[5]}`;
}
return;
}
const parts: Record<string, string> = {};
s.replace('RRULE:', '')
.split(';')
@@ -143,11 +170,7 @@
};
const scheduleLabel = (freq, intv, h, min, days, mDay) => {
const h12 = h > 12 ? h - 12 : h === 0 ? 12 : h;
const ampm = h >= 12 ? 'PM' : 'AM';
const m = String(min).padStart(2, '0');
const time = `${h12}:${m} ${ampm}`;
if (freq === 'ONCE') return 'Once';
if (freq === 'HOURLY') return 'Hourly';
if (freq === 'DAILY') return 'Daily';
if (freq === 'WEEKLY') return 'Weekly';
@@ -161,6 +184,13 @@
toast.error($i18n.t('Name, prompt, and model are required'));
return;
}
if (frequency === 'ONCE') {
const scheduled = new Date(`${onceDate}T${onceTime}`);
if (scheduled <= new Date()) {
toast.error($i18n.t('Scheduled time must be in the future'));
return;
}
}
loading = true;
try {
const form: AutomationForm = {
@@ -203,6 +233,8 @@
is_active = true;
frequency = 'DAILY';
interval = 1;
onceDate = '';
onceTime = '09:00';
hour = 9;
minute = 0;
selectedDays = [];
@@ -287,24 +319,22 @@
<div
slot="content"
class="rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-72 p-1"
class="rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-48 p-1"
>
<div class="px-2 text-xs text-gray-500 py-1">
{$i18n.t('Schedule')}
</div>
<div class="flex gap-1 px-2 mb-2 overflow-x-auto scrollbar-none">
{#each FREQUENCIES as f}
<button
type="button"
class="px-2 py-1 rounded-xl text-xs transition shrink-0 {frequency === f.key
? 'bg-gray-50 dark:bg-gray-800 text-black dark:text-gray-100'
: 'text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-200'}"
on:click={() => (frequency = f.key)}
>
{f.label}
</button>
{/each}
<div class="px-1.5 mt-0.5 mb-2">
<select
class="w-full bg-transparent rounded-xl text-xs py-1.5 px-1.5 outline-hidden"
bind:value={frequency}
on:click={(e) => e.stopPropagation()}
>
{#each FREQUENCIES as f}
<option value={f.key}>{f.label}</option>
{/each}
</select>
</div>
{#if frequency === 'CUSTOM'}
@@ -317,31 +347,25 @@
on:click={(e) => e.stopPropagation()}
/>
</div>
{:else}
{:else if frequency !== 'HOURLY'}
<div class="flex gap-2 flex-wrap items-center px-3 pb-2 text-xs">
{#if frequency === 'HOURLY'}
{#if frequency === 'ONCE'}
<div class="flex items-center gap-1.5">
<span class="text-xs text-gray-500">{$i18n.t('Every')}</span>
<input
type="number"
bind:value={interval}
min={1}
max={60}
class="w-12 bg-gray-50 dark:bg-gray-800 rounded-lg text-center outline-hidden text-xs py-1 border border-gray-200 dark:border-gray-700"
type="date"
bind:value={onceDate}
min={new Date().toISOString().split('T')[0]}
class="bg-transparent outline-hidden text-xs dark:color-scheme-dark"
on:click={(e) => e.stopPropagation()}
/>
<span class="text-xs text-gray-500">hr</span>
<span class="text-xs text-gray-500">{$i18n.t('at')}</span>
<span class="text-gray-400">:</span>
</div>
<div class="flex items-center gap-1.5">
<input
type="number"
bind:value={minute}
min={0}
max={59}
class="w-12 bg-gray-50 dark:bg-gray-800 rounded-lg text-center outline-hidden text-xs py-1 border border-gray-200 dark:border-gray-700"
type="time"
bind:value={onceTime}
class="bg-transparent outline-hidden text-xs dark:color-scheme-dark"
on:click={(e) => e.stopPropagation()}
/>
<span class="text-xs text-gray-500">min</span>
</div>
{:else}
<div class="flex items-center gap-1.5">
@@ -354,7 +378,7 @@
hour = h;
minute = m;
}}
class="bg-gray-50 dark:bg-gray-800 rounded-lg text-center outline-hidden text-xs py-1 px-2 border border-gray-200 dark:border-gray-700 dark:color-scheme-dark"
class="bg-transparent text-center outline-hidden text-xs dark:color-scheme-dark"
on:click={(e) => e.stopPropagation()}
/>
</div>
@@ -368,7 +392,7 @@
bind:value={monthDay}
min={1}
max={31}
class="w-12 bg-gray-50 dark:bg-gray-800 rounded-lg text-center outline-hidden text-xs py-1 border border-gray-200 dark:border-gray-700"
class="w-8 bg-transparent text-center outline-hidden text-xs"
on:click={(e) => e.stopPropagation()}
/>
</div>
@@ -100,6 +100,15 @@
};
const formatRRule = (rrule: string): string => {
// Detect one-time schedule (ONCE)
if (rrule.includes('COUNT=1')) {
const match = rrule.match(/DTSTART:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/);
if (match) {
const d = new Date(`${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}`);
return `Once · ${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}`;
}
return 'Once';
}
const parts: Record<string, string> = {};
rrule
.replace('RRULE:', '')