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 %} diff --git a/templates/tasks/_recurrence_fields.html b/templates/tasks/_recurrence_fields.html new file mode 100644 index 0000000..871a82a --- /dev/null +++ b/templates/tasks/_recurrence_fields.html @@ -0,0 +1,102 @@ + +{% with parsed=task.parsed_custom_recurrence %} +
+ + +
+ + + +
+
+ + +
+ + +
+
+ +
+ + + + + + + +
+
+
+ + +
+
+ +
+ +
+ + +
+
+
+{% endwith %} diff --git a/templates/tasks/_task_detail.html b/templates/tasks/_task_detail.html index aa31028..53f08f8 100644 --- a/templates/tasks/_task_detail.html +++ b/templates/tasks/_task_detail.html @@ -8,7 +8,7 @@ -
+ {% csrf_token %} @@ -75,18 +75,7 @@ {% endif %} - -
- - -
+ {% include 'tasks/_recurrence_fields.html' with id_prefix='detail-' task=task %}
diff --git a/templates/tasks/task_create.html b/templates/tasks/task_create.html index e5dccb6..5970867 100644 --- a/templates/tasks/task_create.html +++ b/templates/tasks/task_create.html @@ -10,7 +10,7 @@
- + {% csrf_token %}
@@ -55,17 +55,7 @@
-
- - -
+ {% include 'tasks/_recurrence_fields.html' with id_prefix='' %} {% if tags %}
diff --git a/templates/tasks/task_detail.html b/templates/tasks/task_detail.html index 37378dd..3c64797 100644 --- a/templates/tasks/task_detail.html +++ b/templates/tasks/task_detail.html @@ -19,7 +19,7 @@
- + {% csrf_token %}
@@ -81,31 +81,7 @@
{% endif %} -
- - -
- - {% if tags %} -
- -
- {% for tag in tags %} - - {% endfor %} -
-
- {% endif %} + {% include 'tasks/_recurrence_fields.html' with id_prefix='' task=task %}