From 997b15be77010b1f80eadc23ab33e1f7b74b2759 Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Fri, 11 Sep 2026 11:39:20 +1000 Subject: [PATCH] feat: support unscheduled tasks for board planning (#225) --- src/logic/validation.py | 15 +++++++++ src/ui/pyqt/add_dialog.py | 29 ++++++++++++++--- src/ui/pyqt/board_view.py | 4 ++- tests/test_board.py | 12 +++++++ tests/test_pyqt_task_workspace.py | 54 ++++++++++++++++++++++++++++++- tests/test_validation.py | 23 +++++++++++-- 6 files changed, 128 insertions(+), 9 deletions(-) diff --git a/src/logic/validation.py b/src/logic/validation.py index dc26ec4..7ee98ce 100644 --- a/src/logic/validation.py +++ b/src/logic/validation.py @@ -36,6 +36,21 @@ def validate_task(task: TaskType) -> TaskType: time = task.time.strip() text = task.text.strip() + if not date and not time: + # Unscheduled planning task (e.g. a Kanban card with no due date). + if not text: + raise ValidationError("Task text cannot be empty.") + task.board_stage = validate_board_stage(getattr(task, "board_stage", "")) + task.date = "" + task.time = "" + task.text = text + return task + + if not date or not time: + raise ValidationError( + "A task must have both a date and a time, or neither (unscheduled)." + ) + try: parsed_date = date_value.fromisoformat(date) except (TypeError, ValueError) as exc: diff --git a/src/ui/pyqt/add_dialog.py b/src/ui/pyqt/add_dialog.py index 91158a9..68f7eeb 100644 --- a/src/ui/pyqt/add_dialog.py +++ b/src/ui/pyqt/add_dialog.py @@ -61,6 +61,12 @@ def __init__( self.time_input.setDisplayFormat(time_format) self.time_input.setAccessibleName("Due time") + self.unscheduled_checkbox = QCheckBox("Unscheduled") + self.unscheduled_checkbox.setAccessibleName("Mark task unscheduled") + self.unscheduled_checkbox.setToolTip( + "Planning-only task without a due date (used by the Kanban board)" + ) + self.notes_input = QLineEdit() self.notes_input.setPlaceholderText("Optional notes") self.notes_input.setClearButtonEnabled(True) @@ -115,6 +121,7 @@ def __init__( form.addRow("Task", self.text_input) form.addRow("Date", self.date_input) form.addRow("Time", self.time_input) + form.addRow("", self.unscheduled_checkbox) form.addRow("Notes", self.notes_input) form.addRow("Priority", self.priority_input) form.addRow("Duration", self.duration_input) @@ -136,8 +143,11 @@ def __init__( if task: self.text_input.setText(task.text) - self.date_input.setDate(QDate.fromString(task.date, "yyyy-MM-dd")) - self.time_input.setTime(QTime.fromString(task.time, "HH:mm")) + if task.date: + self.date_input.setDate(QDate.fromString(task.date, "yyyy-MM-dd")) + self.time_input.setTime(QTime.fromString(task.time, "HH:mm")) + else: + self.unscheduled_checkbox.setChecked(True) self.notes_input.setText(getattr(task, "notes", "") or "") priority = getattr(task, "priority", "") or "" idx = self.priority_input.findText(priority) @@ -179,11 +189,18 @@ def __init__( self.board_combo.setCurrentIndex(0) self.board_checkbox.toggled.connect(self._sync_board_controls) + self.unscheduled_checkbox.toggled.connect(self._sync_schedule_controls) + self._sync_schedule_controls() self.text_input.setFocus() def _sync_board_controls(self): self.board_combo.setEnabled(self.board_checkbox.isChecked()) + def _sync_schedule_controls(self): + unscheduled = self.unscheduled_checkbox.isChecked() + self.date_input.setEnabled(not unscheduled) + self.time_input.setEnabled(not unscheduled) + def get_values( self, ) -> tuple[str, str, str, str, str, str, str, str, str, str, str]: @@ -193,8 +210,12 @@ def get_values( if recurrence: recurrence_end = self.recurrence_end_input.date().toString("yyyy-MM-dd") reminder = self.reminder_input.value() - date_str = self.date_input.date().toString("yyyy-MM-dd") - time_str = self.time_input.time().toString("HH:mm") + if self.unscheduled_checkbox.isChecked(): + date_str = "" + time_str = "" + else: + date_str = self.date_input.date().toString("yyyy-MM-dd") + time_str = self.time_input.time().toString("HH:mm") board_stage = ( self.board_combo.currentData() if self.board_checkbox.isChecked() else "" ) diff --git a/src/ui/pyqt/board_view.py b/src/ui/pyqt/board_view.py index dfff75e..75cfdda 100644 --- a/src/ui/pyqt/board_view.py +++ b/src/ui/pyqt/board_view.py @@ -61,7 +61,9 @@ def __init__(self, task, parent=None): text.setWordWrap(True) layout.addWidget(text) - schedule = QLabel(f"{task.date} {task.time}".strip()) + schedule = QLabel( + f"{task.date} {task.time}".strip() if task.date else "Unscheduled" + ) schedule.setObjectName("MutedLabel") layout.addWidget(schedule) diff --git a/tests/test_board.py b/tests/test_board.py index 5b36d58..9cab98e 100644 --- a/tests/test_board.py +++ b/tests/test_board.py @@ -65,6 +65,18 @@ def test_add_task_persists_board_stage(database): assert scheduler.load_tasks()[0].board_stage == "in_progress" +def test_unscheduled_task_roundtrips_without_date(database): + storage.initialize_database() + scheduler = Scheduler() + + task = scheduler.add_task("", "", "Brainstorm ideas", board_stage="backlog") + + persisted = scheduler.load_tasks()[0] + assert persisted.id == task.id + assert (persisted.date, persisted.time) == ("", "") + assert persisted.board_stage == "backlog" + + def test_update_task_changes_and_clears_board_stage(database): storage.initialize_database() scheduler = Scheduler() diff --git a/tests/test_pyqt_task_workspace.py b/tests/test_pyqt_task_workspace.py index 2326231..fe4a2e8 100644 --- a/tests/test_pyqt_task_workspace.py +++ b/tests/test_pyqt_task_workspace.py @@ -6,7 +6,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") from PyQt6.QtCore import QMimeData, Qt -from PyQt6.QtWidgets import QApplication +from PyQt6.QtWidgets import QApplication, QLabel from logic import local_time from logic.ical_import import ICSImportPlan, SkippedEvent @@ -577,6 +577,58 @@ def test_edit_dialog_defaults_unchecked_for_off_board_tasks(app): assert dialog.get_values()[10] == "" +def test_add_dialog_unscheduled_yields_empty_date_and_time(app): + dialog = AddTaskDialog() + + assert not dialog.unscheduled_checkbox.isChecked() + assert dialog.date_input.isEnabled() + + dialog.unscheduled_checkbox.setChecked(True) + + assert not dialog.date_input.isEnabled() + assert not dialog.time_input.isEnabled() + values = dialog.get_values() + assert values[0] == "" + assert values[1] == "" + + +def test_edit_dialog_preselects_unscheduled_for_dateless_task(app): + task = Task(date="", time="", text="Planning card", board_stage="backlog") + dialog = EditTaskDialog(task) + + assert dialog.unscheduled_checkbox.isChecked() + assert not dialog.date_input.isEnabled() + assert dialog.get_values()[0] == "" + + +def test_board_card_marks_dateless_task_unscheduled(app): + task = Task(text="Planning card", board_stage="backlog") + column = BoardColumn("backlog") + column.set_tasks([task]) + card = column.card_layout.itemAt(0).widget() + + assert any(label.text() == "Unscheduled" for label in card.findChildren(QLabel)) + + +def test_task_proxy_filters_exclude_unscheduled_from_date_filters(app): + today = local_time.today().isoformat() + model = TaskTableModel( + [ + Task(date="", time="", text="Planning card", board_stage="backlog"), + Task(date=today, time="09:00", text="Today task"), + ] + ) + proxy = TaskFilterProxyModel() + proxy.setSourceModel(model) + + proxy.set_task_filter("today") + assert proxy.rowCount() == 1 + assert proxy.index(0, 0).data(Qt.ItemDataRole.UserRole).text == "Today task" + + proxy.set_task_filter("upcoming") + assert proxy.rowCount() == 1 + + def test_native_calendar_renders_month_week_and_day(app): today = local_time.today() scheduler = MemoryScheduler( diff --git a/tests/test_validation.py b/tests/test_validation.py index 34c05c6..2c846df 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -16,18 +16,35 @@ def test_validate_task_accepts_and_normalizes_valid_values(): ) -@pytest.mark.parametrize("date", ["", "12-08-2026", "2026-8-12", "2026-02-30"]) +@pytest.mark.parametrize("date", ["12-08-2026", "2026-8-12", "2026-02-30"]) def test_validate_task_rejects_invalid_dates(date): - with pytest.raises(ValidationError, match="YYYY-MM-DD"): + with pytest.raises(ValidationError): validate_task(Task(date=date, time="09:05", text="Plan release")) -@pytest.mark.parametrize("time", ["", "9:05", "09.05", "24:00", "12:60"]) +@pytest.mark.parametrize("time", ["9:05", "09.05", "24:00", "12:60"]) def test_validate_task_rejects_invalid_times(time): with pytest.raises(ValidationError, match="HH:MM"): validate_task(Task(date="2026-08-12", time=time, text="Plan release")) +@pytest.mark.parametrize( + "date,time", + [("2026-08-12", ""), ("", "09:05")], +) +def test_validate_task_requires_date_and_time_together(date, time): + with pytest.raises(ValidationError, match="both a date and a time"): + validate_task(Task(date=date, time=time, text="Plan release")) + + +def test_validate_task_accepts_unscheduled_task(): + task = Task(date="", time="", text=" Brainstorm ideas ", board_stage="backlog") + + assert validate_task(task) is task + assert (task.date, task.time, task.text) == ("", "", "Brainstorm ideas") + assert task.board_stage == "backlog" + + @pytest.mark.parametrize("text", ["", " ", "\t\n"]) def test_validate_task_rejects_empty_text(text): with pytest.raises(ValidationError, match="cannot be empty"):