Add custom recurrence patterns (day-of-week, nth-weekday, day-of-month)

Recurring tasks were locked to fixed daily/weekly/biweekly/monthly/yearly
intervals. The `custom` recurrence type and `recurrence_rule` field already
existed in the model and API docs, but RRULE evaluation was a TODO stub
that silently fell back to weekly, and no UI exposed the option.

Implements real RRULE parsing via dateutil.rrule, and adds a builder UI
(day-of-week checkboxes for weekly, day-of-month or Nth-weekday for
monthly) so users can express patterns like "every other Wednesday" or
"every second Tuesday" without hand-writing RRULE strings.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Keith Smith
2026-08-31 18:12:15 -06:00
co-authored by Claude Sonnet 5
parent 4d88d10382
commit 70dcc1f001
9 changed files with 335 additions and 56 deletions
+68
View File
@@ -162,6 +162,74 @@ function closeDetail() {
closeMobileMenus();
}
/* ============================================
Custom Recurrence Builder
============================================ */
function toggleCustomRecurrenceBuilder(selectEl) {
const form = selectEl.closest('form');
const builder = form.querySelector('.custom-recurrence-builder');
if (builder) {
builder.classList.toggle('hidden', selectEl.value !== 'custom');
}
}
function toggleCustomFreqPanel(radioEl) {
const builder = radioEl.closest('.custom-recurrence-builder');
const weeklyPanel = builder.querySelector('.custom-weekly-panel');
const monthlyPanel = builder.querySelector('.custom-monthly-panel');
weeklyPanel.classList.toggle('hidden', radioEl.value !== 'weekly');
monthlyPanel.classList.toggle('hidden', radioEl.value !== 'monthly');
}
function assembleCustomRecurrenceRule(formEl) {
const recurrenceSelect = formEl.querySelector('[name="recurrence"]');
const ruleInput = formEl.querySelector('[name="recurrence_rule"]');
if (!recurrenceSelect || !ruleInput) {
return true;
}
if (recurrenceSelect.value !== 'custom') {
ruleInput.value = '';
return true;
}
const builder = formEl.querySelector('.custom-recurrence-builder');
if (!builder) {
return true;
}
const freqMode = builder.querySelector('.custom-freq-mode:checked');
const freq = freqMode ? freqMode.value : 'weekly';
if (freq === 'weekly') {
const panel = builder.querySelector('.custom-weekly-panel');
const interval = parseInt(panel.querySelector('.custom-interval').value, 10) || 1;
const days = Array.from(panel.querySelectorAll('.custom-weekday:checked')).map(cb => cb.value);
if (days.length === 0) {
ruleInput.value = '';
return true;
}
ruleInput.value = `FREQ=WEEKLY;INTERVAL=${interval};BYDAY=${days.join(',')}`;
} else {
const panel = builder.querySelector('.custom-monthly-panel');
const interval = parseInt(panel.querySelector('.custom-interval').value, 10) || 1;
const monthlyMode = panel.querySelector('.custom-monthly-mode:checked');
const mode = monthlyMode ? monthlyMode.value : 'day';
if (mode === 'day') {
const day = parseInt(panel.querySelector('.custom-bymonthday').value, 10) || 1;
ruleInput.value = `FREQ=MONTHLY;INTERVAL=${interval};BYMONTHDAY=${day}`;
} else {
const ordinal = panel.querySelector('.custom-nth-ordinal').value;
const weekday = panel.querySelector('.custom-nth-weekday').value;
ruleInput.value = `FREQ=MONTHLY;INTERVAL=${interval};BYDAY=${ordinal}${weekday}`;
}
}
return true;
}
/* ============================================
Mobile Navigation
============================================ */