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
+70 -3
View File
@@ -213,9 +213,17 @@ class Task(models.Model):
elif self.recurrence == 'yearly':
next_date = base_date + relativedelta(years=1)
elif self.recurrence == 'custom' and self.recurrence_rule:
# TODO: Implement RRULE parsing for custom recurrence
# For now, default to weekly
next_date = base_date + timedelta(weeks=1)
from dateutil.rrule import rrulestr
from datetime import datetime, time as dt_time
dtstart = datetime.combine(base_date, dt_time.min)
try:
next_occurrence = rrulestr(self.recurrence_rule, dtstart=dtstart).after(dtstart, inc=False)
except Exception:
return None
if not next_occurrence:
return None
next_date = next_occurrence.date()
else:
return None
@@ -225,6 +233,65 @@ class Task(models.Model):
return next_date
@property
def parsed_custom_recurrence(self):
"""
Decompose recurrence_rule (an RRULE string) into simple fields for
prepopulating the custom recurrence builder UI. Never raises; returns
safe defaults for a blank or malformed rule.
"""
result = {
'freq': None,
'interval': 1,
'byweekday': [],
'monthly_mode': None,
'bymonthday': None,
'nth_ordinal': None,
'nth_weekday': None,
}
if not self.recurrence_rule:
return result
import re
params = {}
for part in self.recurrence_rule.split(';'):
if '=' in part:
key, value = part.split('=', 1)
params[key.strip().upper()] = value.strip()
freq = params.get('FREQ', '').upper()
if freq not in ('WEEKLY', 'MONTHLY'):
return result
result['freq'] = freq.lower()
try:
result['interval'] = int(params.get('INTERVAL', '1'))
except ValueError:
result['interval'] = 1
byday = params.get('BYDAY', '')
if freq == 'WEEKLY':
if byday:
result['byweekday'] = [d.strip() for d in byday.split(',') if d.strip()]
elif freq == 'MONTHLY':
bymonthday = params.get('BYMONTHDAY')
if bymonthday:
try:
result['bymonthday'] = int(bymonthday)
result['monthly_mode'] = 'day'
except ValueError:
pass
elif byday:
match = re.match(r'^(-?\d+)([A-Z]{2})$', byday)
if match:
result['nth_ordinal'] = int(match.group(1))
result['nth_weekday'] = match.group(2)
result['monthly_mode'] = 'nth'
return result
def create_next_recurrence(self):
"""
Create the next instance of this recurring task.