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
============================================ */
+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')
+1 -1
View File
@@ -140,7 +140,7 @@
{% endif %}
{% endblock %}
<script src="{% static 'js/app.js' %}?v=3"></script>
<script src="{% static 'js/app.js' %}?v=4"></script>
{% block extra_js %}{% endblock %}
</body>
</html>
+102
View File
@@ -0,0 +1,102 @@
<!-- Recurrence -->
{% with parsed=task.parsed_custom_recurrence %}
<div class="form-group">
<label class="form-label" for="{{ id_prefix }}recurrence">Recurrence</label>
<select class="form-select" id="{{ id_prefix }}recurrence" name="recurrence" onchange="toggleCustomRecurrenceBuilder(this)">
<option value="none" {% if not task or task.recurrence == 'none' %}selected{% endif %}>None</option>
<option value="daily" {% if task.recurrence == 'daily' %}selected{% endif %}>Daily</option>
<option value="weekly" {% if task.recurrence == 'weekly' %}selected{% endif %}>Weekly</option>
<option value="biweekly" {% if task.recurrence == 'biweekly' %}selected{% endif %}>Bi-weekly</option>
<option value="monthly" {% if task.recurrence == 'monthly' %}selected{% endif %}>Monthly</option>
<option value="yearly" {% if task.recurrence == 'yearly' %}selected{% endif %}>Yearly</option>
<option value="custom" {% if task.recurrence == 'custom' %}selected{% endif %}>Custom</option>
</select>
</div>
<input type="hidden" name="recurrence_rule" value="{{ task.recurrence_rule|default:'' }}">
<div class="custom-recurrence-builder form-group {% if not task or task.recurrence != 'custom' %}hidden{% endif %}" style="background: var(--bg); border-radius: var(--radius-sm); padding: var(--space-md);">
<div class="form-group">
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer; margin-bottom: var(--space-xs);">
<input type="radio" class="custom-freq-mode" value="weekly" onchange="toggleCustomFreqPanel(this)" {% if parsed.freq != 'monthly' %}checked{% endif %}>
Weekly
</label>
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="radio" class="custom-freq-mode" value="monthly" onchange="toggleCustomFreqPanel(this)" {% if parsed.freq == 'monthly' %}checked{% endif %}>
Monthly
</label>
</div>
<!-- Weekly panel -->
<div class="custom-weekly-panel {% if parsed.freq == 'monthly' %}hidden{% endif %}">
<div class="form-group">
<label class="form-label">Every
<input type="number" class="custom-interval" min="1" value="{{ parsed.interval|default:1 }}" style="width: 60px;">
week(s) on:
</label>
<div style="display: flex; flex-wrap: wrap; gap: var(--space-sm);">
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="checkbox" class="custom-weekday" value="SU" {% if "SU" in parsed.byweekday %}checked{% endif %}> Su
</label>
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="checkbox" class="custom-weekday" value="MO" {% if "MO" in parsed.byweekday %}checked{% endif %}> Mo
</label>
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="checkbox" class="custom-weekday" value="TU" {% if "TU" in parsed.byweekday %}checked{% endif %}> Tu
</label>
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="checkbox" class="custom-weekday" value="WE" {% if "WE" in parsed.byweekday %}checked{% endif %}> We
</label>
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="checkbox" class="custom-weekday" value="TH" {% if "TH" in parsed.byweekday %}checked{% endif %}> Th
</label>
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="checkbox" class="custom-weekday" value="FR" {% if "FR" in parsed.byweekday %}checked{% endif %}> Fr
</label>
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="checkbox" class="custom-weekday" value="SA" {% if "SA" in parsed.byweekday %}checked{% endif %}> Sa
</label>
</div>
</div>
</div>
<!-- Monthly panel -->
<div class="custom-monthly-panel {% if parsed.freq != 'monthly' %}hidden{% endif %}">
<div class="form-group">
<label class="form-label">Every
<input type="number" class="custom-interval" min="1" value="{{ parsed.interval|default:1 }}" style="width: 60px;">
month(s):
</label>
</div>
<div class="form-group">
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer; margin-bottom: var(--space-xs);">
<input type="radio" class="custom-monthly-mode" value="day" {% if parsed.monthly_mode != 'nth' %}checked{% endif %}>
On day
<input type="number" class="custom-bymonthday" min="1" max="31" value="{{ parsed.bymonthday|default:1 }}" style="width: 60px;">
of the month
</label>
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="radio" class="custom-monthly-mode" value="nth" {% if parsed.monthly_mode == 'nth' %}checked{% endif %}>
On the
<select class="custom-nth-ordinal form-select" style="width: auto;">
<option value="1" {% if parsed.nth_ordinal == 1 %}selected{% endif %}>First</option>
<option value="2" {% if parsed.nth_ordinal == 2 %}selected{% endif %}>Second</option>
<option value="3" {% if parsed.nth_ordinal == 3 %}selected{% endif %}>Third</option>
<option value="4" {% if parsed.nth_ordinal == 4 %}selected{% endif %}>Fourth</option>
<option value="-1" {% if parsed.nth_ordinal == -1 %}selected{% endif %}>Last</option>
</select>
<select class="custom-nth-weekday form-select" style="width: auto;">
<option value="SU" {% if parsed.nth_weekday == "SU" %}selected{% endif %}>Sunday</option>
<option value="MO" {% if parsed.nth_weekday == "MO" %}selected{% endif %}>Monday</option>
<option value="TU" {% if parsed.nth_weekday == "TU" %}selected{% endif %}>Tuesday</option>
<option value="WE" {% if parsed.nth_weekday == "WE" %}selected{% endif %}>Wednesday</option>
<option value="TH" {% if parsed.nth_weekday == "TH" %}selected{% endif %}>Thursday</option>
<option value="FR" {% if parsed.nth_weekday == "FR" %}selected{% endif %}>Friday</option>
<option value="SA" {% if parsed.nth_weekday == "SA" %}selected{% endif %}>Saturday</option>
</select>
</label>
</div>
</div>
</div>
{% endwith %}
+2 -13
View File
@@ -8,7 +8,7 @@
</button>
</div>
<form method="post" action="{% url 'task-detail' task.id %}" id="task-detail-form">
<form method="post" action="{% url 'task-detail' task.id %}" id="task-detail-form" onsubmit="assembleCustomRecurrenceRule(this)">
{% csrf_token %}
<input type="hidden" name="next" value="{% url 'dashboard' %}?selected={{ task.id }}{% if request.GET.filter %}&filter={{ request.GET.filter }}{% endif %}{% if request.GET.tag %}&tag={{ request.GET.tag }}{% endif %}">
@@ -75,18 +75,7 @@
</div>
{% endif %}
<!-- Recurrence -->
<div class="form-group">
<label class="form-label" for="detail-recurrence">Recurrence</label>
<select class="form-select" id="detail-recurrence" name="recurrence">
<option value="none" {% if task.recurrence == 'none' %}selected{% endif %}>None</option>
<option value="daily" {% if task.recurrence == 'daily' %}selected{% endif %}>Daily</option>
<option value="weekly" {% if task.recurrence == 'weekly' %}selected{% endif %}>Weekly</option>
<option value="biweekly" {% if task.recurrence == 'biweekly' %}selected{% endif %}>Bi-weekly</option>
<option value="monthly" {% if task.recurrence == 'monthly' %}selected{% endif %}>Monthly</option>
<option value="yearly" {% if task.recurrence == 'yearly' %}selected{% endif %}>Yearly</option>
</select>
</div>
{% include 'tasks/_recurrence_fields.html' with id_prefix='detail-' task=task %}
<!-- Save Button (moved inside form, before subtasks) -->
<div class="detail-actions" style="margin-bottom: var(--space-lg);">
+2 -12
View File
@@ -10,7 +10,7 @@
</div>
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-xl); max-width: 600px;">
<form method="post">
<form method="post" onsubmit="assembleCustomRecurrenceRule(this)">
{% csrf_token %}
<div class="form-group">
@@ -55,17 +55,7 @@
</div>
</div>
<div class="form-group">
<label class="form-label" for="recurrence">Recurrence</label>
<select class="form-select" id="recurrence" name="recurrence">
<option value="none" selected>None</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="biweekly">Bi-weekly</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
</select>
</div>
{% include 'tasks/_recurrence_fields.html' with id_prefix='' %}
{% if tags %}
<div class="form-group">
+2 -26
View File
@@ -19,7 +19,7 @@
<div style="display: grid; grid-template-columns: 1fr 350px; gap: var(--space-lg);">
<!-- Main Form -->
<div style="background: var(--surface); border-radius: var(--radius-md); padding: var(--space-xl);">
<form method="post">
<form method="post" onsubmit="assembleCustomRecurrenceRule(this)">
{% csrf_token %}
<div class="form-group">
@@ -81,31 +81,7 @@
</div>
{% endif %}
<div class="form-group">
<label class="form-label" for="recurrence">Recurrence</label>
<select class="form-select" id="recurrence" name="recurrence">
<option value="none" {% if task.recurrence == 'none' %}selected{% endif %}>None</option>
<option value="daily" {% if task.recurrence == 'daily' %}selected{% endif %}>Daily</option>
<option value="weekly" {% if task.recurrence == 'weekly' %}selected{% endif %}>Weekly</option>
<option value="biweekly" {% if task.recurrence == 'biweekly' %}selected{% endif %}>Bi-weekly</option>
<option value="monthly" {% if task.recurrence == 'monthly' %}selected{% endif %}>Monthly</option>
<option value="yearly" {% if task.recurrence == 'yearly' %}selected{% endif %}>Yearly</option>
</select>
</div>
{% if tags %}
<div class="form-group">
<label class="form-label">Tags</label>
<div style="display: flex; flex-wrap: wrap; gap: var(--space-sm);">
{% for tag in tags %}
<label style="display: flex; align-items: center; gap: var(--space-xs); cursor: pointer;">
<input type="checkbox" name="tags" value="{{ tag.id }}" {% if tag in task.tags.all %}checked{% endif %}>
<span style="color: {{ tag.color }}">{{ tag.name }}</span>
</label>
{% endfor %}
</div>
</div>
{% endif %}
{% include 'tasks/_recurrence_fields.html' with id_prefix='' task=task %}
<button type="submit" class="btn btn-primary">Save Changes</button>
</form>