Internal
Public Access
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:
co-authored by
Claude Sonnet 5
parent
4d88d10382
commit
70dcc1f001
+70
-3
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user