diff --git a/src/logic/board.py b/src/logic/board.py new file mode 100644 index 0000000..e1ceaad --- /dev/null +++ b/src/logic/board.py @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""UI-independent Kanban board model for SchedPlus.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .scheduler import Task + +BOARD_STAGES: tuple[str, ...] = ("backlog", "in_progress", "done") + + +def group_by_stage(tasks: list[Task]) -> dict[str, list[Task]]: + """Group opted-in tasks by stage; off-board tasks are excluded.""" + staged: dict[str, list[Task]] = {stage: [] for stage in BOARD_STAGES} + for task in tasks: + if task.board_stage in staged: + staged[task.board_stage].append(task) + return staged diff --git a/src/logic/data_transfer.py b/src/logic/data_transfer.py index 4381451..baf49db 100644 --- a/src/logic/data_transfer.py +++ b/src/logic/data_transfer.py @@ -167,6 +167,7 @@ def _parse_tasks(value: object) -> list[Task]: "recurrence", "recurrenceEnd", "reminder", + "board_stage", } for index, item in enumerate(value, start=1): if not isinstance(item, dict): diff --git a/src/logic/scheduler.py b/src/logic/scheduler.py index cc8bc2e..3c64296 100644 --- a/src/logic/scheduler.py +++ b/src/logic/scheduler.py @@ -40,6 +40,7 @@ class Task: recurrence: str = "" recurrenceEnd: str = "" reminder: str = "" + board_stage: str = "" class Scheduler: @@ -75,6 +76,7 @@ def add_task( recurrence: str = "", recurrenceEnd: str = "", reminder: str = "", + board_stage: str = "", ): from .storage import sqlite_storage as db @@ -90,6 +92,7 @@ def add_task( recurrence=recurrence, recurrenceEnd=recurrenceEnd, reminder=reminder, + board_stage=board_stage, ) ) db.create_entry(task) diff --git a/src/logic/storage/migrations.py b/src/logic/storage/migrations.py index 13da1c7..585345e 100644 --- a/src/logic/storage/migrations.py +++ b/src/logic/storage/migrations.py @@ -75,6 +75,13 @@ def _migration_6(connection: sqlite3.Connection) -> None: ) +def _migration_7(connection: sqlite3.Connection) -> None: + """Add Kanban planning stage column (empty = not on the board).""" + connection.execute( + "ALTER TABLE entries ADD COLUMN board_stage TEXT NOT NULL DEFAULT ''" + ) + + # Never edit or reorder a released migration. Add the next numbered callable. MIGRATIONS: tuple[Migration, ...] = ( _migration_1, @@ -83,6 +90,7 @@ def _migration_6(connection: sqlite3.Connection) -> None: _migration_4, _migration_5, _migration_6, + _migration_7, ) CURRENT_SCHEMA_VERSION = len(MIGRATIONS) diff --git a/src/logic/storage/sqlite_storage.py b/src/logic/storage/sqlite_storage.py index deb4f89..28ed00a 100644 --- a/src/logic/storage/sqlite_storage.py +++ b/src/logic/storage/sqlite_storage.py @@ -273,8 +273,8 @@ def create_entry(task: Task) -> None: _run( lambda connection: connection.execute( """ - INSERT INTO entries (id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO entries (id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder, board_stage) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( task.id, @@ -292,6 +292,7 @@ def create_entry(task: Task) -> None: task.recurrence, task.recurrenceEnd, task.reminder, + task.board_stage, ), ) ) @@ -304,7 +305,7 @@ def update_entry(task: Task) -> None: """ UPDATE entries SET date = ?, time = ?, text = ?, updatedAt = ?, completed = ?, completedAt = ?, - notes = ?, priority = ?, duration = ?, category = ?, recurrence = ?, recurrenceEnd = ?, reminder = ? + notes = ?, priority = ?, duration = ?, category = ?, recurrence = ?, recurrenceEnd = ?, reminder = ?, board_stage = ? WHERE id = ? """, ( @@ -321,6 +322,7 @@ def update_entry(task: Task) -> None: task.recurrence, task.recurrenceEnd, task.reminder, + task.board_stage, task.id, ), ) @@ -339,7 +341,7 @@ def delete_entry(task_id: str) -> None: def get_entry(task_id: str) -> Task | None: row = _run( lambda connection: connection.execute( - "SELECT id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder " + "SELECT id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder, board_stage " "FROM entries WHERE id = ?", (task_id,), ).fetchone() @@ -350,7 +352,7 @@ def get_entry(task_id: str) -> Task | None: def list_entries() -> list[Task]: rows = _run( lambda connection: connection.execute(""" - SELECT id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder + SELECT id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder, board_stage FROM entries ORDER BY date ASC, time ASC """).fetchall() @@ -364,8 +366,8 @@ def replace_entries(tasks: list[Task]) -> None: def replace(connection: sqlite3.Connection) -> None: connection.execute("DELETE FROM entries") connection.executemany( - "INSERT INTO entries (id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO entries (id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder, board_stage) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [_task_values(task) for task in tasks], ) @@ -379,14 +381,14 @@ def merge(connection: sqlite3.Connection) -> tuple[int, int, int]: imported = duplicates = conflicts = 0 for task in tasks: row = connection.execute( - "SELECT id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder " + "SELECT id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder, board_stage " "FROM entries WHERE id = ?", (task.id,), ).fetchone() if row is None: connection.execute( - "INSERT INTO entries (id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO entries (id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder, board_stage) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", _task_values(task), ) imported += 1 @@ -439,7 +441,7 @@ def list_completed_entries() -> list[Task]: rows = _run( lambda connection: connection.execute(""" SELECT id, date, time, text, createdAt, updatedAt, completed, completedAt, - notes, priority, duration, category, recurrence, recurrenceEnd, reminder + notes, priority, duration, category, recurrence, recurrenceEnd, reminder, board_stage FROM entries WHERE completed = 'true' ORDER BY completedAt DESC @@ -465,6 +467,7 @@ def _task_from_row(row: tuple) -> Task: recurrence=row[12] if len(row) > 12 else "", recurrenceEnd=row[13] if len(row) > 13 else "", reminder=row[14] if len(row) > 14 else "", + board_stage=row[15] if len(row) > 15 else "", ) @@ -485,4 +488,5 @@ def _task_values(task: Task) -> tuple: task.recurrence, task.recurrenceEnd, task.reminder, + task.board_stage, ) diff --git a/src/logic/validation.py b/src/logic/validation.py index ad6f7ab..dc26ec4 100644 --- a/src/logic/validation.py +++ b/src/logic/validation.py @@ -6,6 +6,8 @@ from datetime import time as time_value from typing import Protocol, TypeVar +from .board import BOARD_STAGES + class ValidationError(ValueError): """Raised when a task does not meet the persistence requirements.""" @@ -15,6 +17,7 @@ class TaskLike(Protocol): date: str time: str text: str + board_stage: str TaskType = TypeVar("TaskType", bound=TaskLike) @@ -56,7 +59,18 @@ def validate_task(task: TaskType) -> TaskType: if not text: raise ValidationError("Task text cannot be empty.") + task.board_stage = validate_board_stage(getattr(task, "board_stage", "")) + task.date = date task.time = time task.text = text return task + + +def validate_board_stage(value: str) -> str: + """Normalize and validate a Kanban planning stage ('' = off the board).""" + normalized = value.strip() if isinstance(value, str) else "" + + if normalized and normalized not in BOARD_STAGES: + raise ValidationError(f"Board stage must be one of {', '.join(BOARD_STAGES)}.") + return normalized diff --git a/src/ui/pyqt/add_dialog.py b/src/ui/pyqt/add_dialog.py index 726ac2f..91158a9 100644 --- a/src/ui/pyqt/add_dialog.py +++ b/src/ui/pyqt/add_dialog.py @@ -2,6 +2,7 @@ from PyQt6.QtCore import QDate, Qt, QTime from PyQt6.QtWidgets import ( + QCheckBox, QComboBox, QDateEdit, QDialog, @@ -14,6 +15,8 @@ QVBoxLayout, ) +from logic.board import BOARD_STAGES + class TaskDialog(QDialog): def __init__( @@ -98,6 +101,17 @@ def __init__( self.reminder_input.setToolTip("Minutes before due time to notify (0 = off)") self.reminder_input.setAccessibleName("Reminder lead time in minutes") + self.board_checkbox = QCheckBox("Add to Kanban") + self.board_checkbox.setAccessibleName("Add to Kanban board") + self.board_checkbox.setToolTip("Place this task on the Kanban planning board") + + self.board_combo = QComboBox() + self.board_combo.addItem("Backlog", "backlog") + self.board_combo.addItem("In progress", "in_progress") + self.board_combo.addItem("Done", "done") + self.board_combo.setAccessibleName("Board column") + self.board_combo.setEnabled(False) + form.addRow("Task", self.text_input) form.addRow("Date", self.date_input) form.addRow("Time", self.time_input) @@ -108,6 +122,8 @@ def __init__( form.addRow("Repeat", self.recurrence_input) form.addRow("Repeat until", self.recurrence_end_input) form.addRow("Remind before", self.reminder_input) + form.addRow("", self.board_checkbox) + form.addRow("Board column", self.board_combo) layout.addLayout(form) buttons = QDialogButtonBox( @@ -145,6 +161,12 @@ def __init__( self.reminder_input.setValue(int(reminder)) except (ValueError, TypeError): self.reminder_input.setValue(0) + board_stage = getattr(task, "board_stage", "") or "" + self.board_combo.setCurrentIndex( + max(0, self.board_combo.findData(board_stage)) + ) + self.board_checkbox.setChecked(board_stage in BOARD_STAGES) + self._sync_board_controls() else: selected_date = QDate.fromString(initial_date or "", "yyyy-MM-dd") selected_time = QTime.fromString(initial_time or "", "HH:mm") @@ -154,10 +176,17 @@ def __init__( self.time_input.setTime( selected_time if selected_time.isValid() else QTime.currentTime() ) + self.board_combo.setCurrentIndex(0) + self.board_checkbox.toggled.connect(self._sync_board_controls) self.text_input.setFocus() - def get_values(self) -> tuple[str, str, str, str, str, str, str, str, str, str]: + def _sync_board_controls(self): + self.board_combo.setEnabled(self.board_checkbox.isChecked()) + + def get_values( + self, + ) -> tuple[str, str, str, str, str, str, str, str, str, str, str]: duration = self.duration_input.value() recurrence = self.recurrence_input.currentText() recurrence_end = "" @@ -166,6 +195,9 @@ def get_values(self) -> tuple[str, str, str, str, str, str, str, str, str, str]: reminder = self.reminder_input.value() 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 "" + ) return ( date_str, time_str, @@ -177,6 +209,7 @@ def get_values(self) -> tuple[str, str, str, str, str, str, str, str, str, str]: recurrence, recurrence_end, str(reminder) if reminder > 0 else "", + board_stage, ) diff --git a/src/ui/pyqt/board_view.py b/src/ui/pyqt/board_view.py new file mode 100644 index 0000000..18f3bc3 --- /dev/null +++ b/src/ui/pyqt/board_view.py @@ -0,0 +1,228 @@ +"""Kanban board view for planning tasks.""" + +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QPushButton, + QScrollArea, + QVBoxLayout, + QWidget, +) + +from logic.board import BOARD_STAGES, group_by_stage + +STAGE_LABELS = { + "backlog": "Backlog", + "in_progress": "In progress", + "done": "Done", +} + +CARD_ACTIONS = "Complete", "Edit", "Delete" + + +class BoardCard(QWidget): + """A single task card rendered on the board.""" + + edit_requested = pyqtSignal(object) + delete_requested = pyqtSignal(object) + complete_requested = pyqtSignal(object) + + def __init__(self, task, parent=None): + super().__init__(parent) + self.task = task + self.setObjectName("BoardCard") + self.setToolTip("Double-click to edit") + self.setCursor(Qt.CursorShape.PointingHandCursor) + + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 10, 12, 10) + layout.setSpacing(6) + + if task.completed == "true": + done_badge = QLabel("Done") + done_badge.setObjectName("CategoryBadge") + layout.addWidget(done_badge, alignment=Qt.AlignmentFlag.AlignLeft) + + text = QLabel(task.text) + text.setObjectName("CardText") + text.setWordWrap(True) + layout.addWidget(text) + + schedule = QLabel(f"{task.date} {task.time}".strip()) + schedule.setObjectName("MutedLabel") + layout.addWidget(schedule) + + meta = QHBoxLayout() + meta.setSpacing(6) + if task.category: + category = QLabel(task.category) + category.setObjectName("CategoryBadge") + meta.addWidget(category) + if task.priority == "high": + priority = QLabel("High priority") + priority.setObjectName("PriorityHigh") + meta.addWidget(priority) + for value, label in (("duration", "min"), ("recurrence", "")): + stored = getattr(task, value) or "" + if not stored: + continue + hint = QLabel(f"{stored}{label}" if label else stored) + hint.setObjectName("MutedLabel") + meta.addWidget(hint) + if task.reminder: + reminder = QLabel(f"-{task.reminder} min") + reminder.setObjectName("MutedLabel") + meta.addWidget(reminder) + meta.addStretch() + if meta.count() > 1: + layout.addLayout(meta) + + actions = QHBoxLayout() + actions.setSpacing(6) + self.complete_button = QPushButton( + "Uncomplete" if task.completed == "true" else "Complete" + ) + self.complete_button.setObjectName("SecondaryButton") + self.complete_button.setAccessibleName( + "Mark task incomplete" if task.completed == "true" else "Complete task" + ) + self.edit_button = QPushButton("Edit") + self.edit_button.setObjectName("SecondaryButton") + self.edit_button.setAccessibleName("Edit task") + self.delete_button = QPushButton("Delete") + self.delete_button.setObjectName("DangerButton") + self.delete_button.setAccessibleName("Delete task") + actions.addWidget(self.complete_button) + actions.addWidget(self.edit_button) + actions.addWidget(self.delete_button) + layout.addLayout(actions) + + self.complete_button.clicked.connect(lambda: self.complete_requested.emit(task)) + self.edit_button.clicked.connect(lambda: self.edit_requested.emit(task)) + self.delete_button.clicked.connect(lambda: self.delete_requested.emit(task)) + + +class BoardColumn(QWidget): + """A single stage column holding task cards.""" + + edit_requested = pyqtSignal(object) + delete_requested = pyqtSignal(object) + complete_requested = pyqtSignal(object) + + def __init__(self, title: str, parent=None): + super().__init__(parent) + self.setObjectName("BoardColumn") + + layout = QVBoxLayout(self) + layout.setContentsMargins(10, 12, 10, 12) + layout.setSpacing(8) + + self.title_label = QLabel(title) + self.title_label.setObjectName("BoardColumnTitle") + self.title_label.setAccessibleName(title) + layout.addWidget(self.title_label) + + self.empty_label = QLabel("No tasks") + self.empty_label.setObjectName("MutedLabel") + layout.addWidget(self.empty_label) + + self.cards = QWidget() + self.card_layout = QVBoxLayout(self.cards) + self.card_layout.setContentsMargins(0, 0, 0, 0) + self.card_layout.setSpacing(8) + self.card_layout.addStretch() + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setWidget(self.cards) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setAccessibleName(title) + layout.addWidget(scroll, 1) + + def set_tasks(self, tasks): + while self.card_layout.count() > 1: + item = self.card_layout.takeAt(1) + widget = item.widget() + if widget is not None: + widget.deleteLater() + for task in tasks: + card = BoardCard(task) + card.edit_requested.connect(self.edit_requested) + card.delete_requested.connect(self.delete_requested) + card.complete_requested.connect(self.complete_requested) + self.card_layout.insertWidget(self.card_layout.count() - 1, card) + self.empty_label.setVisible(len(tasks) == 0) + + +class BoardView(QWidget): + """Kanban planning board: one column per stage.""" + + add_requested = pyqtSignal() + edit_requested = pyqtSignal(object) + delete_requested = pyqtSignal(object) + complete_requested = pyqtSignal(object) + + def __init__(self, scheduler, parent=None): + super().__init__(parent) + self.scheduler = scheduler + + layout = QVBoxLayout(self) + layout.setContentsMargins(24, 22, 24, 20) + layout.setSpacing(14) + + heading_row = QHBoxLayout() + heading = QLabel("Kanban") + heading.setObjectName("PageHeading") + self.count_label = QLabel() + self.count_label.setObjectName("MutedLabel") + self.add_button = QPushButton("+ Add task") + self.add_button.setObjectName("PrimaryButton") + self.add_button.setAccessibleName("Add new task") + heading_row.addWidget(heading) + heading_row.addStretch() + heading_row.addWidget(self.count_label) + heading_row.addWidget(self.add_button) + layout.addLayout(heading_row) + + self.columns_widget = QWidget() + self.columns_row = QHBoxLayout(self.columns_widget) + self.columns_row.setContentsMargins(0, 0, 0, 0) + self.columns_row.setSpacing(14) + self.columns: dict[str, BoardColumn] = {} + for stage in BOARD_STAGES: + column = BoardColumn(STAGE_LABELS[stage]) + column.edit_requested.connect(self.edit_requested) + column.delete_requested.connect(self.delete_requested) + column.complete_requested.connect(self.complete_requested) + self.columns[stage] = column + self.columns_row.addWidget(column, 1) + layout.addWidget(self.columns_widget, 1) + + self.empty_label = QLabel() + self.empty_label.setObjectName("EmptyState") + self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.empty_label.setMinimumHeight(180) + layout.addWidget(self.empty_label, 1) + + self.add_button.clicked.connect(self.add_requested) + self.refresh() + + def refresh(self): + grouped = group_by_stage(self.scheduler.get_tasks()) + total_on_board = sum(len(tasks) for tasks in grouped.values()) + for stage, column in self.columns.items(): + column.set_tasks(grouped[stage]) + self.columns_widget.setVisible(total_on_board > 0) + self.empty_label.setVisible(total_on_board == 0) + self.empty_label.setText( + "No tasks on the board.\n" + "Add a task and tick \u201cAdd to Kanban\u201d to start planning." + ) + self.count_label.setText( + f"{total_on_board} of {len(self.scheduler.get_tasks())} tasks on the board" + ) + + def focus_add(self): + self.add_button.setFocus() diff --git a/src/ui/pyqt/theme.py b/src/ui/pyqt/theme.py index b6ada73..4f17f04 100644 --- a/src/ui/pyqt/theme.py +++ b/src/ui/pyqt/theme.py @@ -384,6 +384,42 @@ selection-background-color: #DBEAFE; selection-color: #172033; } +QWidget#BoardColumn { + background: #EEF2F7; + border: 1px solid #D8DEE8; + border-radius: 8px; +} +QLabel#BoardColumnTitle { + color: #172033; + font-weight: 600; + padding: 2px 4px; +} +QWidget#BoardCard { + background: #FFFFFF; + border: 1px solid #D8DEE8; + border-radius: 6px; +} +QWidget#BoardCard:hover { + border-color: #CBD5E1; +} +QLabel#CardText { + color: #172033; + font-weight: 600; +} +QLabel#CategoryBadge { + background: #DBEAFE; + color: #1D4ED8; + border-radius: 4px; + padding: 2px 6px; + font-size: 9pt; +} +QLabel#PriorityHigh { + background: #FFF1F2; + color: #BE123C; + border-radius: 4px; + padding: 2px 6px; + font-size: 9pt; +} """ # Palette mirrors the QSS tokens above so any widget class without explicit diff --git a/src/ui/pyqt/window.py b/src/ui/pyqt/window.py index d51759f..c5acd20 100644 --- a/src/ui/pyqt/window.py +++ b/src/ui/pyqt/window.py @@ -34,6 +34,7 @@ from logic.validation import ValidationError from schedplus.identity import get_application_identity from ui.pyqt.add_dialog import AddTaskDialog, EditTaskDialog +from ui.pyqt.board_view import BoardView from ui.pyqt.calendar_view import CalendarWorkspace from ui.pyqt.ics_import_dialog import IcsImportDialog from ui.pyqt.settings_dialog import SettingsDialog, SettingsStore, UiPreferences @@ -103,8 +104,10 @@ def __init__(self, scheduler): self.pages = QStackedWidget() self.task_list = TaskListWidget(scheduler, self.preferences) self.calendar_page = CalendarWorkspace(scheduler, self.preferences) + self.board_page = BoardView(scheduler) self.pages.addWidget(self.task_list) self.pages.addWidget(self.calendar_page) + self.pages.addWidget(self.board_page) layout.addWidget(self.pages, 1) self.setCentralWidget(root) @@ -116,6 +119,10 @@ def __init__(self, scheduler): self.calendar_page.edit_requested.connect(self.open_edit_dialog) self.calendar_page.delete_requested.connect(self.delete_task) self.calendar_page.reschedule_requested.connect(self.reschedule_task) + self.board_page.add_requested.connect(self.open_add_dialog) + self.board_page.edit_requested.connect(self.open_edit_dialog) + self.board_page.delete_requested.connect(self.delete_task) + self.board_page.complete_requested.connect(self.complete_task) self._create_shortcuts() self.show_page(self.preferences.startup_view) @@ -148,15 +155,19 @@ def _build_sidebar(self): self.tasks_nav = self._navigation_button("Tasks") self.calendar_nav = self._navigation_button("Calendar") + self.board_nav = self._navigation_button("Kanban") self.settings_button = self._navigation_button("Settings") self.tasks_nav.setAccessibleName("Switch to Tasks view") self.calendar_nav.setAccessibleName("Switch to Calendar view") + self.board_nav.setAccessibleName("Switch to Kanban view") self.settings_button.setAccessibleName("Open settings") self.tasks_nav.clicked.connect(lambda: self.show_page("tasks")) self.calendar_nav.clicked.connect(lambda: self.show_page("calendar")) + self.board_nav.clicked.connect(lambda: self.show_page("board")) self.settings_button.clicked.connect(self.open_settings) layout.addWidget(self.tasks_nav) layout.addWidget(self.calendar_nav) + layout.addWidget(self.board_nav) layout.addStretch() layout.addWidget(self.settings_button) self.version_label = QLabel(self.identity.version_label) @@ -191,10 +202,11 @@ def _create_shortcuts(self): self.shortcuts.append(shortcut) def show_page(self, page): - calendar = page == "calendar" - self.pages.setCurrentIndex(1 if calendar else 0) - self.tasks_nav.setChecked(not calendar) - self.calendar_nav.setChecked(calendar) + index = {"tasks": 0, "calendar": 1, "board": 2}.get(page, 0) + self.pages.setCurrentIndex(index) + self.tasks_nav.setChecked(index == 0) + self.calendar_nav.setChecked(index == 1) + self.board_nav.setChecked(index == 2) def open_add_dialog(self, initial_date=None, initial_time=None): dialog = AddTaskDialog( @@ -216,6 +228,7 @@ def open_add_dialog(self, initial_date=None, initial_time=None): recurrence, recurrence_end, reminder, + board_stage, ) = dialog.get_values() try: task = self.scheduler.add_task( @@ -229,6 +242,7 @@ def open_add_dialog(self, initial_date=None, initial_time=None): recurrence=recurrence, recurrenceEnd=recurrence_end, reminder=reminder, + board_stage=board_stage, ) self.scheduler.undo_manager.record_add(task.id) self.refresh_views() @@ -260,6 +274,7 @@ def open_edit_dialog(self, task): draft.recurrence, draft.recurrenceEnd, draft.reminder, + draft.board_stage, ) = dialog.get_values() try: self.scheduler.undo_manager.record_edit(task) @@ -559,6 +574,7 @@ def reschedule_task(self, task, date, time): def refresh_views(self): self.task_list.refresh() self.calendar_page.refresh() + self.board_page.refresh() def show_status_message(self, message, duration=3500): self.statusBar().showMessage(f" {message}") diff --git a/tests/test_board.py b/tests/test_board.py new file mode 100644 index 0000000..5b36d58 --- /dev/null +++ b/tests/test_board.py @@ -0,0 +1,120 @@ +import pytest + +from logic.board import BOARD_STAGES, group_by_stage +from logic.scheduler import Scheduler, Task +from logic.storage import sqlite_storage as storage + + +@pytest.fixture +def database(monkeypatch, tmp_path): + path = tmp_path / "data" / "tasks.db" + path.parent.mkdir() + monkeypatch.setattr(storage, "prepare_database", lambda: path) + monkeypatch.setattr(storage, "_configure_logging", lambda _directory: None) + return path + + +def test_board_stages_are_canonical(): + assert BOARD_STAGES == ("backlog", "in_progress", "done") + + +def test_group_by_stage_excludes_off_board_tasks(): + tasks = [ + Task( + date="2026-09-11", time="09:00", text="Backlog item", board_stage="backlog" + ), + Task( + date="2026-09-12", time="10:00", text="Doing it", board_stage="in_progress" + ), + Task(date="2026-09-13", time="11:00", text="Shipped", board_stage="done"), + Task(date="2026-09-14", time="12:00", text="Not on the board"), + ] + + grouped = group_by_stage(tasks) + + assert grouped == { + "backlog": [tasks[0]], + "in_progress": [tasks[1]], + "done": [tasks[2]], + } + + +def test_group_by_stage_always_returns_every_stage(): + grouped = group_by_stage([Task(text="Anything")]) + + assert set(grouped) == set(BOARD_STAGES) + assert all(grouped[stage] == [] for stage in BOARD_STAGES) + + +def test_group_by_stage_preserves_stable_insertion_order(): + text = [Task(text=f"Card {index}", board_stage="backlog") for index in range(3)] + + assert [task.text for task in group_by_stage(text)["backlog"]] == [ + "Card 0", + "Card 1", + "Card 2", + ] + + +def test_add_task_persists_board_stage(database): + storage.initialize_database() + scheduler = Scheduler() + + scheduler.add_task("2026-09-11", "09:00", "Plan release", board_stage="in_progress") + + assert scheduler.load_tasks()[0].board_stage == "in_progress" + + +def test_update_task_changes_and_clears_board_stage(database): + storage.initialize_database() + scheduler = Scheduler() + task = scheduler.add_task( + "2026-09-11", "09:00", "Plan release", board_stage="backlog" + ) + + moved = Task( + id=task.id, + date=task.date, + time=task.time, + text=task.text, + createdAt=task.createdAt, + updatedAt=task.updatedAt, + board_stage="done", + ) + scheduler.update_task(moved) + assert scheduler.load_tasks()[0].board_stage == "done" + + cleared = Task( + id=task.id, + date=task.date, + time=task.time, + text=task.text, + createdAt=task.createdAt, + updatedAt=task.updatedAt, + board_stage="", + ) + scheduler.update_task(cleared) + assert scheduler.load_tasks()[0].board_stage == "" + + +def test_undo_restores_prior_board_stage(database): + storage.initialize_database() + scheduler = Scheduler() + task = scheduler.add_task( + "2026-09-11", "09:00", "Plan release", board_stage="backlog" + ) + moved = Task( + id=task.id, + date=task.date, + time=task.time, + text=task.text, + createdAt=task.createdAt, + updatedAt=task.updatedAt, + board_stage="done", + ) + + scheduler.undo_manager.record_edit(task) + scheduler.update_task(moved) + scheduler.undo_manager.undo() + + assert scheduler.load_tasks()[0].board_stage == "backlog" diff --git a/tests/test_data_transfer.py b/tests/test_data_transfer.py index fba8a79..5e93bfd 100644 --- a/tests/test_data_transfer.py +++ b/tests/test_data_transfer.py @@ -22,7 +22,7 @@ def data_environment(monkeypatch, tmp_path): return data_dir -def _task(identifier="task-1", text="Original"): +def _task(identifier="task-1", text="Original", board_stage="in_progress"): return Task( id=identifier, date="2026-08-15", @@ -30,6 +30,7 @@ def _task(identifier="task-1", text="Original"): text=text, createdAt="2026-08-15T00:00:00", updatedAt="2026-08-15T00:00:00", + board_stage=board_stage, ) @@ -130,6 +131,45 @@ def test_cli_exposes_backup_restore_export_and_import(data_environment): assert scheduler.get_tasks() == [_task()] +def test_export_includes_board_stage(data_environment): + original = _task(board_stage="done") + sqlite_storage.create_entry(original) + export = data_environment / "tasks.json" + + data_transfer.export_tasks(export) + + document = json.loads(export.read_text(encoding="utf-8")) + assert document["tasks"][0]["board_stage"] == "done" + + +def test_import_without_board_stage_loads_off_board(data_environment): + source = data_environment / "legacy.json" + source.write_text( + json.dumps( + { + "format": "schedplus-task-export", + "format_version": 1, + "tasks": [ + { + "id": "task-1", + "date": "2026-08-15", + "time": "09:30", + "text": "Legacy task", + "createdAt": "2026-08-15T00:00:00", + "updatedAt": "2026-08-15T00:00:00", + } + ], + } + ), + encoding="utf-8", + ) + + result = data_transfer.import_tasks(source) + + assert result == data_transfer.ImportResult(1, 0, 0) + assert sqlite_storage.list_entries()[0].board_stage == "" + + @pytest.mark.parametrize( "document", [ diff --git a/tests/test_pyqt_task_workspace.py b/tests/test_pyqt_task_workspace.py index a1f020d..86b2fad 100644 --- a/tests/test_pyqt_task_workspace.py +++ b/tests/test_pyqt_task_workspace.py @@ -11,6 +11,7 @@ from logic.ical_import import ICSImportPlan, SkippedEvent from logic.scheduler import Task from ui.pyqt.add_dialog import AddTaskDialog, EditTaskDialog +from ui.pyqt.board_view import BoardColumn, BoardView from ui.pyqt.calendar_view import CalendarWorkspace from ui.pyqt.ics_import_dialog import IcsImportDialog from ui.pyqt.settings_dialog import SettingsDialog, UiPreferences @@ -216,11 +217,120 @@ def test_update_preference_remains_editable_for_managed_builds(app, monkeypatch) def test_window_has_navigation_and_shortcuts(app): window = SchedPlusWindow(MemoryScheduler()) - assert window.pages.count() == 2 + assert window.pages.count() == 3 assert len(window.shortcuts) == 10 assert window.windowTitle() == "SchedPlus — Advanced" assert window.version_label.text().startswith("SchedPlus v") assert window.about_action.text() == "About SchedPlus" + assert window.board_nav.text() == "Kanban" + assert window.board_nav.accessibleName() == "Switch to Kanban view" + + window.show_page("board") + + assert window.pages.currentIndex() == 2 + assert window.board_nav.isChecked() + assert not window.tasks_nav.isChecked() + + +def test_board_lists_only_opted_in_tasks(app): + scheduler = MemoryScheduler( + [ + Task( + date="2026-09-11", + time="09:00", + text="Backlog card", + board_stage="backlog", + ), + Task( + date="2026-09-12", + time="10:00", + text="Doing card", + board_stage="in_progress", + ), + Task(date="2026-09-13", time="11:00", text="Done card", board_stage="done"), + Task(date="2026-09-14", time="12:00", text="Off board card"), + ] + ) + board = BoardView(scheduler) + + assert board.columns["backlog"].card_layout.count() == 2 + assert board.columns["in_progress"].card_layout.count() == 2 + assert board.columns["done"].card_layout.count() == 2 + assert board.empty_label.isHidden() + assert not board.columns_widget.isHidden() + assert board.columns["backlog"].empty_label.isHidden() + assert "3 of 4 tasks" in board.count_label.text() + + +def test_board_empty_state(app): + board = BoardView(MemoryScheduler()) + + assert not board.empty_label.isHidden() + assert board.columns_widget.isHidden() + assert "No tasks on the board" in board.empty_label.text() + + +def test_board_column_cards_forward_signals(app): + task = Task(date="2026-09-11", time="09:00", text="Card", board_stage="backlog") + column = BoardColumn("Backlog") + column.set_tasks([task]) + emitted = [] + column.complete_requested.connect(emitted.append) + + card_layout = column.card_layout + card = card_layout.itemAt(0).widget() + card.complete_button.click() + + assert emitted == [task] + + +def test_board_reflects_refreshed_scheduler(app): + scheduler = MemoryScheduler() + board = BoardView(scheduler) + assert board.columns_widget.isHidden() + + scheduler.tasks.append( + Task(date="2026-09-11", time="09:00", text="New card", board_stage="done") + ) + board.refresh() + + assert not board.columns_widget.isHidden() + assert board.columns["done"].card_layout.count() == 2 + assert "1 of 1 tasks" in board.count_label.text() + + +def test_add_dialog_boards_task_when_checked(app): + dialog = AddTaskDialog() + + assert dialog.get_values()[10] == "" + assert not dialog.board_combo.isEnabled() + + dialog.board_checkbox.setChecked(True) + dialog.board_combo.setCurrentIndex(dialog.board_combo.findData("in_progress")) + + assert dialog.board_combo.isEnabled() + assert dialog.get_values()[10] == "in_progress" + + +def test_edit_dialog_preselects_and_clears_board_stage(app): + task = Task(date="2026-09-11", time="09:00", text="Board task", board_stage="done") + dialog = EditTaskDialog(task) + + assert dialog.board_checkbox.isChecked() + assert dialog.get_values()[10] == "done" + + dialog.board_checkbox.setChecked(False) + + assert not dialog.board_combo.isEnabled() + assert dialog.get_values()[10] == "" + + +def test_edit_dialog_defaults_unchecked_for_off_board_tasks(app): + task = Task(date="2026-09-11", time="09:00", text="Plain task") + dialog = EditTaskDialog(task) + + assert not dialog.board_checkbox.isChecked() + assert dialog.get_values()[10] == "" def test_native_calendar_renders_month_week_and_day(app): diff --git a/tests/test_schema_migrations.py b/tests/test_schema_migrations.py index 76cb6ab..5508a97 100644 --- a/tests/test_schema_migrations.py +++ b/tests/test_schema_migrations.py @@ -139,6 +139,42 @@ def failing_migration(connection): ) +def test_migration_7_keeps_existing_rows_off_board(database): + _create_legacy_database(database, "0.8.0") + + storage.initialize_database() + + with sqlite3.connect(database) as connection: + columns = [row[1] for row in connection.execute("PRAGMA table_info(entries)")] + assert "board_stage" in columns + assert connection.execute("SELECT board_stage FROM entries").fetchone() == ("",) + assert storage.get_entry("from-0.8.0").board_stage == "" + + +def test_task_from_row_guards_missing_board_stage_column(): + row = ( + "task-1", + "2026-08-15", + "09:30", + "Pre-board row", + "2026-08-15T00:00:00", + "2026-08-15T00:00:00", + "", + "", + "", + "", + "", + "", + "", + "", + "", + ) + + task = storage._task_from_row(row) + + assert task.board_stage == "" + + def test_newer_schema_is_refused_without_modification_or_backup(database): _create_legacy_database(database, "future") with sqlite3.connect(database) as connection: diff --git a/tests/test_validation.py b/tests/test_validation.py index 96dd486..34c05c6 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -2,7 +2,7 @@ from logic.scheduler import Scheduler, Task from logic.storage import sqlite_storage -from logic.validation import ValidationError, validate_task +from logic.validation import ValidationError, validate_board_stage, validate_task def test_validate_task_accepts_and_normalizes_valid_values(): @@ -67,3 +67,46 @@ def test_scheduler_does_not_update_invalid_task(monkeypatch): Scheduler().update_task(task) assert persisted == [] + + +def test_validate_board_stage_accepts_each_stage(): + for stage in ("backlog", "in_progress", "done"): + assert validate_board_stage(stage) == stage + + +@pytest.mark.parametrize("value", ["", " ", "\t\n"]) +def test_validate_board_stage_normalizes_emptiness(value): + assert validate_board_stage(value) == "" + + +def test_validate_board_stage_accepts_non_strings_as_off_board(): + assert validate_board_stage(None) == "" + + +@pytest.mark.parametrize("stage", ["someday", "released", "BACKLOG"]) +def test_validate_board_stage_rejects_unknown_stages(stage): + with pytest.raises(ValidationError, match="Board stage"): + validate_board_stage(stage) + + +def test_validate_task_normalizes_board_stage(): + task = Task(date="2026-08-12", time="09:05", text="Plan", board_stage=" backlog ") + + validate_task(task) + + assert task.board_stage == "backlog" + + +def test_validate_task_normalize_off_board_task(): + task = Task(date="2026-08-12", time="09:05", text="Plan", board_stage=" ") + + validate_task(task) + + assert task.board_stage == "" + + +def test_validate_task_rejects_invalid_board_stage(): + with pytest.raises(ValidationError, match="Board stage"): + validate_task( + Task(date="2026-08-12", time="09:05", text="Plan", board_stage="later") + )