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.
|
||||
|
||||
+85
-1
@@ -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'], [])
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user