diff --git a/static/js/app.js b/static/js/app.js
index a8ff0df..b05984a 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -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
============================================ */
diff --git a/tasks/models.py b/tasks/models.py
index 142d019..ccc6f69 100644
--- a/tasks/models.py
+++ b/tasks/models.py
@@ -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.
diff --git a/tasks/tests.py b/tasks/tests.py
index 7ce503c..6bd8a97 100644
--- a/tasks/tests.py
+++ b/tasks/tests.py
@@ -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'], [])
diff --git a/tasks/views.py b/tasks/views.py
index af651cd..9a033bb 100644
--- a/tasks/views.py
+++ b/tasks/views.py
@@ -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')
diff --git a/templates/base.html b/templates/base.html
index 40c6495..e999d8b 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -140,7 +140,7 @@
{% endif %}
{% endblock %}
-
+
{% block extra_js %}{% endblock %}