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.
+85 -1
View File
@@ -1,3 +1,87 @@
from datetime import date
from django.contrib.auth import get_user_model
from django.test import TestCase
# Create your tests here.
from tasks.models import Task
User = get_user_model()
class CustomRecurrenceTests(TestCase):
"""Tests for Task.calculate_next_due_date() with custom RRULE patterns."""
def setUp(self):
self.user = User.objects.create_user(
username='testuser',
email='testuser@example.com',
password='testpass123',
)
def make_task(self, **kwargs):
defaults = {
'user': self.user,
'title': 'Test task',
'recurrence': 'custom',
}
defaults.update(kwargs)
return Task.objects.create(**defaults)
def test_every_wednesday(self):
# 2026-01-07 is a Wednesday
task = self.make_task(due_date=date(2026, 1, 7), recurrence_rule='FREQ=WEEKLY;BYDAY=WE')
self.assertEqual(task.calculate_next_due_date(), date(2026, 1, 14))
def test_every_other_wednesday(self):
task = self.make_task(due_date=date(2026, 1, 7), recurrence_rule='FREQ=WEEKLY;INTERVAL=2;BYDAY=WE')
self.assertEqual(task.calculate_next_due_date(), date(2026, 1, 21))
def test_every_second_tuesday(self):
# 2026-01-13 is the second Tuesday of January 2026
task = self.make_task(due_date=date(2026, 1, 13), recurrence_rule='FREQ=MONTHLY;BYDAY=2TU')
self.assertEqual(task.calculate_next_due_date(), date(2026, 2, 10))
def test_every_15th(self):
task = self.make_task(due_date=date(2026, 1, 15), recurrence_rule='FREQ=MONTHLY;BYMONTHDAY=15')
self.assertEqual(task.calculate_next_due_date(), date(2026, 2, 15))
def test_respects_recurrence_end_date(self):
task = self.make_task(
due_date=date(2026, 1, 7),
recurrence_rule='FREQ=WEEKLY;BYDAY=WE',
recurrence_end_date=date(2026, 1, 10),
)
self.assertIsNone(task.calculate_next_due_date())
def test_malformed_rule_returns_none(self):
task = self.make_task(due_date=date(2026, 1, 7), recurrence_rule='not a valid rrule')
self.assertIsNone(task.calculate_next_due_date())
def test_create_next_recurrence(self):
task = self.make_task(due_date=date(2026, 1, 15), recurrence_rule='FREQ=MONTHLY;BYMONTHDAY=15')
new_task = task.create_next_recurrence()
self.assertIsNotNone(new_task)
self.assertEqual(new_task.due_date, date(2026, 2, 15))
self.assertEqual(new_task.recurrence_rule, 'FREQ=MONTHLY;BYMONTHDAY=15')
self.assertEqual(new_task.status, 'pending')
def test_parsed_custom_recurrence_weekly(self):
task = self.make_task(recurrence_rule='FREQ=WEEKLY;INTERVAL=2;BYDAY=WE')
parsed = task.parsed_custom_recurrence
self.assertEqual(parsed['freq'], 'weekly')
self.assertEqual(parsed['interval'], 2)
self.assertEqual(parsed['byweekday'], ['WE'])
def test_parsed_custom_recurrence_monthly_nth(self):
task = self.make_task(recurrence_rule='FREQ=MONTHLY;BYDAY=2TU')
parsed = task.parsed_custom_recurrence
self.assertEqual(parsed['freq'], 'monthly')
self.assertEqual(parsed['monthly_mode'], 'nth')
self.assertEqual(parsed['nth_ordinal'], 2)
self.assertEqual(parsed['nth_weekday'], 'TU')
def test_parsed_custom_recurrence_blank(self):
task = self.make_task(recurrence_rule='')
parsed = task.parsed_custom_recurrence
self.assertIsNone(parsed['freq'])
self.assertEqual(parsed['byweekday'], [])
+3
View File
@@ -479,6 +479,8 @@ class TaskDetailView(View):
else:
task.recurrence = 'none'
task.recurrence_rule = request.POST.get('recurrence_rule', '') if task.recurrence == 'custom' else ''
# Create next recurrence if task is being marked as completed
if old_status != 'completed' and task.status == 'completed' and task.recurrence != 'none':
task.create_next_recurrence()
@@ -541,6 +543,7 @@ class TaskCreateView(View):
due_date=request.POST.get('due_date') or None,
due_time=request.POST.get('due_time') or None,
recurrence=recurrence,
recurrence_rule=request.POST.get('recurrence_rule', '') if recurrence == 'custom' else '',
)
tag_ids = request.POST.getlist('tags')