Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/logic/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
29 changes: 25 additions & 4 deletions src/ui/pyqt/add_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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]:
Expand All @@ -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 ""
)
Expand Down
4 changes: 3 additions & 1 deletion src/ui/pyqt/board_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
12 changes: 12 additions & 0 deletions tests/test_board.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
54 changes: 53 additions & 1 deletion tests/test_pyqt_task_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
23 changes: 20 additions & 3 deletions tests/test_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down