From 03b63b9a121bc223b47789bedee9f76513424e20 Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Fri, 11 Sep 2026 08:17:08 +1000 Subject: [PATCH 01/12] docs: add v1.0 Kanban board design (#202) Record the approved board model and migration strategy for the Kanban planning view before any UI work: - Opt-in board: tasks appear only when deliberately added via an 'Add to Kanban' control at create/edit time; never auto-derived. - Task gains board_stage as NOT NULL DEFAULT '' (matching every other optional field); '' = off board. - Fixed stages backlog / in_progress / done for v1.0. - Stage is planning state, not completion: moving to done does not complete a task, and completing a task does not move a card. - Migration 7 appends the column with an '' default; existing tasks are off-board with no relocation or duplication. - Specifies validation, serialization (backup/export/import), undo via existing record_edit, PyQt surface, CLI --board, and tests. --- docs/kanban-board-design.md | 246 ++++++++++++++++++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 docs/kanban-board-design.md diff --git a/docs/kanban-board-design.md b/docs/kanban-board-design.md new file mode 100644 index 0000000..00f483d --- /dev/null +++ b/docs/kanban-board-design.md @@ -0,0 +1,246 @@ +# Kanban board design + +Status: **Approved — Session 1 of #192 complete** +Related: #192 (parent), #202 (design), #203 (UI), #204 (drag/drop + undo), #205 (search/a11y) +Applies to: PyQt workspace only (v1.0 scope) + +## 1. Purpose + +Add a Kanban planning view that complements the existing Tasks and Calendar +views for active planning. It is an **opt-in** surface: a task appears on the +board only when the user deliberately adds it, and each task sits in exactly +one planning stage. Nothing is derived automatically from task fields, and the +board never changes what a task means in Tasks or Calendar. + +## 2. Data model + +### 2.1 New field + +Add `board_stage` to the `Task` dataclass: + +```python +board_stage: str = "" # "" = not on the board; else one of BOARD_STAGES +``` + +**Decided:** the parent tracking text originally said "nullable `board_stage` +(`NULL` = off-board)". This codebase represents every optional field — `notes`, +`priority`, `duration`, `category`, `recurrence`, `reminder`, `completed` — as +`TEXT NOT NULL DEFAULT ''`, never `NULL`. A `NULL` column would be inconsistent +with every existing field and force guards throughout storage, serialization, +and the UI. Approved approach: store `""` for "not on the board". The semantic +("a task without a stage is not on the board") is unchanged. + +### 2.2 Canonical stages + +```python +# src/logic/board.py (new module, UI-independent) +BOARD_STAGES = ("backlog", "in_progress", "done") +``` + +- Values are lowercase, matching `priority` (`low/medium/high`) and + `recurrence` (`daily/weekly/monthly/yearly`) conventions. +- Display labels are a UI concern: **Backlog**, **In progress**, **Done**. +- No user-defined columns or multiple boards in v1.0 (#192 scope). + +### 2.3 Stage vs completion + +- A board stage is a planning state, **not** task completion. +- Moving a card to `done` does **not** complete the task. +- Completing a task does **not** move its card. +- A completed task stays in whatever stage it is in until the user moves it. + +## 3. Schema migration + +Append **migration 7** to `src/logic/storage/migrations.py` (never edit a +released migration): + +```python +def _migration_7(connection): + connection.execute( + "ALTER TABLE entries ADD COLUMN board_stage TEXT NOT NULL DEFAULT ''" + ) +``` + +- `CURRENT_SCHEMA_VERSION` becomes 7. +- The existing transactional `migrate_database()` already creates a + pre-migration backup for existing databases and migrates in order. +- Existing rows get `''` → automatically off-board. **No data is relocated, + hidden, or duplicated.** + +### Storage surface (`src/logic/storage/sqlite_storage.py`) + +Add `board_stage` to every SQL statement and row mapping: +`create_entry`, `update_entry`, `get_entry`, `list_entries`, +`list_completed_entries`, `replace_entries`, `import_entries`, +`_task_from_row` (index 15, keep the defensive `if len(row) > 15` guard), and +`_task_values`. + +## 4. Validation + +Extend `src/logic/validation.py`: + +```python +stages = ("backlog", "in_progress", "done") +def validate_board_stage(value: str) -> str: + value = value.strip() + if value and value not in stages: + raise ValidationError(f"Board stage must be one of {', '.join(stages)}.") + return value +``` + +- Normalize `" "`/`None`-like emptiness to `""`. +- Reject any value outside `BOARD_STAGES` so the database and UI can never + disagree about what a valid stage is. +- `validate_task()` calls `validate_board_stage` on `getattr(task, + "board_stage", "")` so every persistence path is covered. + +## 5. Serialization (backup / restore / export / import) + +`src/logic/data_transfer.py`: + +- Add `"board_stage"` to the `optional` set in `_parse_tasks`. +- Old files without the key load as `""` (off-board) — backward compatible. +- Export picks the field up automatically via `asdict(task)`. +- `FORMAT_VERSION` stays `1`: the field is additive/optional, exactly like + `notes`, `category`, and `reminder` were. + +## 6. Scheduler + +- `Scheduler.add_task(..., board_stage="")` — pass through to `Task`. +- `Scheduler.update_task(task)` — unchanged; it persists the whole object. +- Board moves are ordinary task edits. A move produces a modified `Task` and + calls `update_task`, so no new scheduler surface is required. + +## 7. Undo + +No new `UndoAction` type. A board move is recorded with the existing +`UndoManager.record_edit(snapshot_of_task_before_move)`; `Ctrl+Z` runs the +existing `edit` undo path and restores the prior `board_stage` (and any other +fields edited at the same time). This is the same mechanism the edit dialog +already uses (`window.open_edit_dialog`). + +## 8. Card content + +A card renders the existing task attributes — nothing stored on the card +beyond the task itself: + +- text (primary, wraps) +- date + time +- category badge +- priority (styling) +- duration +- recurrence +- reminder +- completion indicator (informational; does not govern board position) + +## 9. UI model + +### 9.1 Sidebar + +Add **Kanban** as a fourth navigation entry in `window.py`: + +- `_build_sidebar()`: new `_navigation_button("Kanban")`, consistent with the + existing Tasks/Calendar/Settings buttons. +- `self.pages.addWidget(board_page)` after the calendar page. +- `show_page("board")` switch in the same `QStackedWidget`. + +### 9.2 `BoardView(QWidget)` — new `src/ui/pyqt/board_view.py` + +- One column widget per stage, rendered from `scheduler.get_tasks()` grouped + by `task.board_stage`. Cards are only tasks whose `board_stage` matches. +- Signals mirror `TaskListWidget`: `add_requested`, `edit_requested`, + `delete_requested`, `complete_requested` — wired in `window.py` to the + existing handlers (`open_add_dialog`, `open_edit_dialog`, `delete_task`, + `complete_task`). +- `refresh()` rebuilds from `scheduler.get_tasks()` so board data is always + the task list itself (Task and Calendar remain the source of truth). +- Empty states: a whole-board empty state and a per-column "no tasks" spill. + +### 9.3 Create / edit dialog (`add_dialog.py`) + +- Add an **"Add to Kanban"** checkbox (unchecked by default on create). +- When checked, reveal a **Board column** combo (`backlog`, `in_progress`, + `done`) defaulting to `backlog`. +- On edit, the checkbox is pre-checked when `task.board_stage` is set, and the + combo preselected. Unchecking clears `board_stage` (removes from board). +- `get_values()` returns `board_stage`; update both callers in `window.py` and + the affected tests. + +### 9.4 Tasks page filter (`task_list.py`, optional for #205) + +Add an `On Board` filter option (`task_filter: "board"`) that shows only +tasks with a non-empty `board_stage`. Touches: `FILTERS` in +`settings_dialog.py`, `TaskFilterProxyModel.filterAcceptsRow`, and the +`task_filter` choice set in `data_transfer._validate_ui_preferences`. + +### 9.5 Startup view (non-goal for v1.0) + +`startup_view` stays `tasks | calendar`. A `board` startup option is possible +later; leaving it out keeps the settings surface unchanged. + +## 10. CLI + +Keep CLI surface minimal (parent constraint). Recommend only: + +```bash +schedplus add "Refactor auth" --date 2026-09-20 --board backlog +schedplus edit 7c94a2 --board done # also accepts --board "" to remove +``` + +- Invalid stage exits `2` (existing CLI validation path). +- `list` gains no new filter in v1.0. + +## 11. Migration & rollback + +- Upgrading adds one column with `''`; the existing pre-migration backup + mechanism already protects every upgrade. +- Rollback = restore the automatic pre-migration backup (same workflow as any + schema migration today). +- No data movement, no prompts, no hidden reassignment. + +## 12. Non-goals (v1.0) + +- User-defined columns; WIP limits; swimlanes; multi-board. +- Auto-derivation of board membership from priority/dates/completion. +- Card ordering within a column beyond stable insertion order. +- Web/online sync of board state. +- Theme/token work for cards (uses existing QSS tokens). + +## 13. Test plan + +| Area | Coverage | +| --- | --- | +| Schema | Migration 7 runs last; existing migrations 1–6 untouched; `_task_from_row` guards index 15 for pre-7 rows | +| Validation | `""` and each stage accepted; invalid/whitespace-cased values rejected; restored/imported tasks normalized | +| Serialization | Export includes `board_stage`; old import without it loads as `""`; round-trip preserves stage | +| Scheduler | `add_task(board_stage=...)`; `update_task` stage change persists | +| Board model | grouping is UI-independent; off-board tasks excluded | +| Undo | Board move → `Ctrl+Z` restores prior stage | +| PyQt (offscreen) | Sidebar shows Kanban; board lists only opted-in tasks; dialog controls set/clear stage; empty + populated states; `On Board` filter | +| CLI | `--board` on add/edit; invalid stage → exit 2 | + +## 14. Files touched (summary) + +- `src/logic/scheduler.py` — `Task.board_stage`, `add_task(board_stage=)`. +- `src/logic/board.py` — **new**: `BOARD_STAGES`, grouping helper. +- `src/logic/validation.py` — `validate_board_stage`. +- `src/logic/storage/migrations.py` — migration 7. +- `src/logic/storage/sqlite_storage.py` — column mapping. +- `src/logic/data_transfer.py` — `board_stage` optional field. +- `src/ui/pyqt/board_view.py` — **new**: board page. +- `src/ui/pyqt/window.py` — sidebar entry, wiring, dialog callers. +- `src/ui/pyqt/add_dialog.py` — "Add to Kanban" + column controls. +- `src/ui/pyqt/task_list.py` + `settings_dialog.py` — `On Board` filter. +- `src/cli/commands.py` — `--board` on add/edit. +- `tests/` — new `test_board.py`; updates to schema, data-transfer, CLI, + PyQt-workspace, and dialog tests. + +## 15. Approval checklist (matches #202 acceptance criteria) + +- [x] Opt-in model, column set (`backlog`/`in_progress`/`done`), and + stage-vs-completion semantics approved. +- [x] `""`-instead-of-`NULL` convention confirmed. +- [ ] Serialization, validation, and undo impact specified. +- [ ] Migration keeps existing tasks off-board by default (`''`), no + duplication, no hidden reassignment. +- [ ] Tasks and Calendar remain the source of truth. \ No newline at end of file From 087d665b5bc49cedf0fc44404deb270929194dd7 Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Fri, 11 Sep 2026 08:20:02 +1000 Subject: [PATCH 02/12] docs: record Kanban delivery model All Kanban sub-issue PRs integrate into the parent feature/kanban-board branch, never main; maintainers decide integrate-or-cut at the end. --- docs/kanban-board-design.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/kanban-board-design.md b/docs/kanban-board-design.md index 00f483d..5ada29f 100644 --- a/docs/kanban-board-design.md +++ b/docs/kanban-board-design.md @@ -243,4 +243,18 @@ schedplus edit 7c94a2 --board done # also accepts --board "" to remove - [ ] Serialization, validation, and undo impact specified. - [ ] Migration keeps existing tasks off-board by default (`''`), no duplication, no hidden reassignment. -- [ ] Tasks and Calendar remain the source of truth. \ No newline at end of file +- [ ] Tasks and Calendar remain the source of truth. + +## 16. Delivery model + +Kanban work never merges directly to `main`. All sub-issue PRs (#202–#205) +integrate into the parent branch **`feature/kanban-board`**, which is created +off the release point of `main`. When the feature is complete and reviewed, +the maintainer decides to either: + +- **integrate**: open a final PR from `feature/kanban-board` into `main`; or +- **cut**: close the parent branch and the sub-issues without merging, leaving + `main` untouched. + +This keeps `main` shippable at any time and lets the feature be reworked or +dropped without touching released code paths. \ No newline at end of file From 728a445f71fc2d01ca115baf4424b7596e41a86e Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Fri, 11 Sep 2026 08:30:03 +1000 Subject: [PATCH 03/12] feat: add Kanban board view with opt-in board_stage (#203) --- src/logic/board.py | 21 +++ src/logic/data_transfer.py | 1 + src/logic/scheduler.py | 3 + src/logic/storage/migrations.py | 8 + src/logic/storage/sqlite_storage.py | 38 ++--- src/logic/validation.py | 14 ++ src/ui/pyqt/add_dialog.py | 35 ++++- src/ui/pyqt/board_view.py | 228 ++++++++++++++++++++++++++++ src/ui/pyqt/theme.py | 36 +++++ src/ui/pyqt/window.py | 24 ++- tests/test_board.py | 120 +++++++++++++++ tests/test_data_transfer.py | 42 ++++- tests/test_pyqt_task_workspace.py | 112 +++++++++++++- tests/test_schema_migrations.py | 36 +++++ tests/test_validation.py | 45 +++++- 15 files changed, 736 insertions(+), 27 deletions(-) create mode 100644 src/logic/board.py create mode 100644 src/ui/pyqt/board_view.py create mode 100644 tests/test_board.py 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..f58c1b7 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() @@ -348,13 +350,11 @@ 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 + rows = _run(lambda connection: connection.execute(""" + 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() - ) + """).fetchall()) return [_task_from_row(row) for row in rows] @@ -364,8 +364,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 +379,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 @@ -436,15 +436,13 @@ def uncomplete_entry(task_id: str) -> None: def list_completed_entries() -> list[Task]: - rows = _run( - lambda connection: connection.execute(""" + 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 - """).fetchall() - ) + """).fetchall()) return [_task_from_row(row) for row in rows] @@ -465,6 +463,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 +484,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") + ) From eb3d36c3cf8ec448fe04847bbccf0ba7f82ec6fa Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Fri, 11 Sep 2026 08:44:20 +1000 Subject: [PATCH 04/12] style: satisfy ruff format gate --- src/logic/storage/sqlite_storage.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/logic/storage/sqlite_storage.py b/src/logic/storage/sqlite_storage.py index f58c1b7..28ed00a 100644 --- a/src/logic/storage/sqlite_storage.py +++ b/src/logic/storage/sqlite_storage.py @@ -350,11 +350,13 @@ def get_entry(task_id: str) -> Task | None: def list_entries() -> list[Task]: - rows = _run(lambda connection: connection.execute(""" + rows = _run( + lambda connection: connection.execute(""" 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()) + """).fetchall() + ) return [_task_from_row(row) for row in rows] @@ -436,13 +438,15 @@ def uncomplete_entry(task_id: str) -> None: def list_completed_entries() -> list[Task]: - rows = _run(lambda connection: connection.execute(""" + rows = _run( + lambda connection: connection.execute(""" SELECT id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder, board_stage FROM entries WHERE completed = 'true' ORDER BY completedAt DESC - """).fetchall()) + """).fetchall() + ) return [_task_from_row(row) for row in rows] From a7b5eecaa727f3c86585ab53deacae6d6b6ef0c6 Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Fri, 11 Sep 2026 08:55:05 +1000 Subject: [PATCH 05/12] feat: drag cards between Kanban columns with undo (#204) --- src/ui/pyqt/board_view.py | 67 ++++++++++++++++-- src/ui/pyqt/window.py | 19 ++++- tests/test_pyqt_task_workspace.py | 114 +++++++++++++++++++++++++++++- 3 files changed, 190 insertions(+), 10 deletions(-) diff --git a/src/ui/pyqt/board_view.py b/src/ui/pyqt/board_view.py index 18f3bc3..bb2f469 100644 --- a/src/ui/pyqt/board_view.py +++ b/src/ui/pyqt/board_view.py @@ -1,6 +1,7 @@ """Kanban board view for planning tasks.""" -from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtCore import QMimeData, Qt, pyqtSignal +from PyQt6.QtGui import QDrag from PyQt6.QtWidgets import ( QFrame, QHBoxLayout, @@ -13,13 +14,13 @@ from logic.board import BOARD_STAGES, group_by_stage -STAGE_LABELS = { +BOARD_STAGE_LABELS = { "backlog": "Backlog", "in_progress": "In progress", "done": "Done", } -CARD_ACTIONS = "Complete", "Edit", "Delete" +BOARD_MIME_TYPE = "application/x-schedplus-task" class BoardCard(QWidget): @@ -33,7 +34,7 @@ def __init__(self, task, parent=None): super().__init__(parent) self.task = task self.setObjectName("BoardCard") - self.setToolTip("Double-click to edit") + self.setToolTip("Drag to a column to move it; double-click to edit") self.setCursor(Qt.CursorShape.PointingHandCursor) layout = QVBoxLayout(self) @@ -103,6 +104,26 @@ def __init__(self, task, parent=None): self.edit_button.clicked.connect(lambda: self.edit_requested.emit(task)) self.delete_button.clicked.connect(lambda: self.delete_requested.emit(task)) + @staticmethod + def drag_mime(task) -> QMimeData: + mime = QMimeData() + mime.setData(BOARD_MIME_TYPE, task.id.encode("utf-8")) + return mime + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + drag = QDrag(self) + drag.setMimeData(self.drag_mime(self.task)) + drag.setPixmap(self.grab()) + drag.setHotSpot(drag.pixmap().rect().center()) + drag.exec(Qt.DropAction.MoveAction) + super().mousePressEvent(event) + + def mouseDoubleClickEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + self.edit_requested.emit(self.task) + super().mouseDoubleClickEvent(event) + class BoardColumn(QWidget): """A single stage column holding task cards.""" @@ -110,11 +131,16 @@ class BoardColumn(QWidget): edit_requested = pyqtSignal(object) delete_requested = pyqtSignal(object) complete_requested = pyqtSignal(object) + move_requested = pyqtSignal(str, str) - def __init__(self, title: str, parent=None): + def __init__(self, stage: str, parent=None): super().__init__(parent) + self.stage = stage self.setObjectName("BoardColumn") + self.setAcceptDrops(True) + self.setToolTip("Drop a card here to move it") + title = BOARD_STAGE_LABELS[stage] layout = QVBoxLayout(self) layout.setContentsMargins(10, 12, 10, 12) layout.setSpacing(8) @@ -155,6 +181,27 @@ def set_tasks(self, tasks): self.card_layout.insertWidget(self.card_layout.count() - 1, card) self.empty_label.setVisible(len(tasks) == 0) + def dragEnterEvent(self, event): + if event.mimeData().hasFormat(BOARD_MIME_TYPE): + event.acceptProposedAction() + else: + event.ignore() + + def dragMoveEvent(self, event): + if event.mimeData().hasFormat(BOARD_MIME_TYPE): + event.acceptProposedAction() + else: + event.ignore() + + def dropEvent(self, event): + mime = event.mimeData() + if not mime.hasFormat(BOARD_MIME_TYPE): + event.ignore() + return + task_id = bytes(mime.data(BOARD_MIME_TYPE)).decode("utf-8") + self.move_requested.emit(task_id, self.stage) + event.acceptProposedAction() + class BoardView(QWidget): """Kanban planning board: one column per stage.""" @@ -163,6 +210,7 @@ class BoardView(QWidget): edit_requested = pyqtSignal(object) delete_requested = pyqtSignal(object) complete_requested = pyqtSignal(object) + move_requested = pyqtSignal(object, str) def __init__(self, scheduler, parent=None): super().__init__(parent) @@ -192,10 +240,11 @@ def __init__(self, scheduler, parent=None): self.columns_row.setSpacing(14) self.columns: dict[str, BoardColumn] = {} for stage in BOARD_STAGES: - column = BoardColumn(STAGE_LABELS[stage]) + column = BoardColumn(stage) column.edit_requested.connect(self.edit_requested) column.delete_requested.connect(self.delete_requested) column.complete_requested.connect(self.complete_requested) + column.move_requested.connect(self._resolve_move) self.columns[stage] = column self.columns_row.addWidget(column, 1) layout.addWidget(self.columns_widget, 1) @@ -224,5 +273,11 @@ def refresh(self): f"{total_on_board} of {len(self.scheduler.get_tasks())} tasks on the board" ) + def _resolve_move(self, task_id: str, stage: str): + for task in self.scheduler.get_tasks(): + if task.id == task_id: + self.move_requested.emit(task, stage) + return + def focus_add(self): self.add_button.setFocus() diff --git a/src/ui/pyqt/window.py b/src/ui/pyqt/window.py index c5acd20..2561682 100644 --- a/src/ui/pyqt/window.py +++ b/src/ui/pyqt/window.py @@ -34,7 +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.board_view import BOARD_STAGE_LABELS, 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 @@ -123,6 +123,7 @@ def __init__(self, scheduler): 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.board_page.move_requested.connect(self.move_board_task) self._create_shortcuts() self.show_page(self.preferences.startup_view) @@ -307,6 +308,22 @@ def delete_task(self, task): except StorageError as exc: self._show_storage_error("Unable to delete task", exc) + def move_board_task(self, task, stage): + if task.board_stage == stage: + return + self.scheduler.undo_manager.record_edit(task) + draft = replace(task, board_stage=stage) + try: + self.scheduler.update_task(draft) + self.refresh_views() + self.show_status_message(f"Task moved to {BOARD_STAGE_LABELS[stage]}") + except ValidationError as exc: + self.refresh_views() + self._show_validation_error(exc) + except StorageError as exc: + self.refresh_views() + self._show_storage_error("Unable to move task", exc) + def complete_task(self, task): try: if task.completed == "true": diff --git a/tests/test_pyqt_task_workspace.py b/tests/test_pyqt_task_workspace.py index 86b2fad..0e059b7 100644 --- a/tests/test_pyqt_task_workspace.py +++ b/tests/test_pyqt_task_workspace.py @@ -5,13 +5,15 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +from PyQt6.QtCore import QMimeData, Qt from PyQt6.QtWidgets import QApplication from logic import local_time from logic.ical_import import ICSImportPlan, SkippedEvent -from logic.scheduler import Task +from logic.scheduler import Scheduler, Task +from logic.storage import sqlite_storage as storage from ui.pyqt.add_dialog import AddTaskDialog, EditTaskDialog -from ui.pyqt.board_view import BoardColumn, BoardView +from ui.pyqt.board_view import BOARD_MIME_TYPE, BoardCard, 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 @@ -38,6 +40,15 @@ def app(): yield application +@pytest.fixture +def task_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_theme_installs_brand_palette_and_covers_core_widgets(app): from PyQt6.QtGui import QPalette @@ -272,7 +283,7 @@ def test_board_empty_state(app): 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 = BoardColumn("backlog") column.set_tasks([task]) emitted = [] column.complete_requested.connect(emitted.append) @@ -299,6 +310,103 @@ def test_board_reflects_refreshed_scheduler(app): assert "1 of 1 tasks" in board.count_label.text() +def test_board_card_mime_carries_task_id(app): + task = Task(date="2026-09-11", time="09:00", text="Card") + card = BoardCard(task) + + assert card.drag_mime(task).hasFormat(BOARD_MIME_TYPE) + assert bytes(card.drag_mime(task).data(BOARD_MIME_TYPE)).decode("utf-8") == task.id + + +def test_board_column_accepts_task_drops(app): + from PyQt6.QtCore import QPoint, QPointF + from PyQt6.QtGui import QDragEnterEvent, QDropEvent + + column = BoardColumn("backlog") + moved = [] + column.move_requested.connect(lambda task_id, stage: moved.append((task_id, stage))) + + mime = QMimeData() + mime.setData(BOARD_MIME_TYPE, b"task-1") + enter = QDragEnterEvent( + QPoint(4, 4), + Qt.DropAction.MoveAction, + mime, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + column.dragEnterEvent(enter) + assert enter.isAccepted() + + drop = QDropEvent( + QPointF(4, 4), + Qt.DropAction.MoveAction, + mime, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + ) + column.dropEvent(drop) + + assert drop.isAccepted() + assert moved == [("task-1", "backlog")] + + +def test_board_view_resolves_column_move_to_task(app): + task = Task(date="2026-09-11", time="09:00", text="Card", board_stage="backlog") + board = BoardView(MemoryScheduler([task])) + emitted = [] + board.move_requested.connect(lambda moved, stage: emitted.append((moved, stage))) + + board.columns["backlog"].move_requested.emit(task.id, "done") + + assert emitted == [(task, "done")] + + +def test_window_board_move_persists_without_touching_completion(app, task_database): + storage.initialize_database() + scheduler = Scheduler() + task = scheduler.add_task( + "2026-09-11", "09:00", "Plan release", board_stage="backlog" + ) + window = SchedPlusWindow(scheduler) + + window.move_board_task(task, "done") + + persisted = scheduler.load_tasks()[0] + assert persisted.board_stage == "done" + assert persisted.completed == task.completed + assert window.board_page.columns["done"].card_layout.count() == 2 + + +def test_window_board_move_to_same_column_is_noop(app, task_database): + storage.initialize_database() + scheduler = Scheduler() + task = scheduler.add_task( + "2026-09-11", "09:00", "Plan release", board_stage="backlog" + ) + window = SchedPlusWindow(scheduler) + + window.move_board_task(task, "backlog") + + assert scheduler.load_tasks()[0].board_stage == "backlog" + + +def test_window_undo_reverts_board_move(app, task_database): + storage.initialize_database() + scheduler = Scheduler() + task = scheduler.add_task( + "2026-09-11", "09:00", "Plan release", board_stage="backlog" + ) + window = SchedPlusWindow(scheduler) + + window.move_board_task(task, "done") + assert scheduler.load_tasks()[0].board_stage == "done" + + scheduler.undo_manager.undo() + + assert scheduler.load_tasks()[0].board_stage == "backlog" + + def test_add_dialog_boards_task_when_checked(app): dialog = AddTaskDialog() From 3a4ee7cdff6ddd9128f5fffb0f4b777b82c69fbf Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Fri, 11 Sep 2026 10:34:07 +1000 Subject: [PATCH 06/12] feat: board search, On Board filter, and keyboard navigation (#205) --- src/logic/data_transfer.py | 4 +- src/ui/pyqt/board_view.py | 121 +++++++++++++++++++++++--- src/ui/pyqt/settings_dialog.py | 2 + src/ui/pyqt/task_list.py | 2 + tests/test_data_transfer.py | 12 +++ tests/test_pyqt_task_workspace.py | 138 +++++++++++++++++++++++++++++- 6 files changed, 265 insertions(+), 14 deletions(-) diff --git a/src/logic/data_transfer.py b/src/logic/data_transfer.py index baf49db..0d68372 100644 --- a/src/logic/data_transfer.py +++ b/src/logic/data_transfer.py @@ -201,8 +201,8 @@ def _validate_ui_preferences(value: object) -> dict[str, Any]: choices = { "sort_field": {"date", "time", "text", "status", "created"}, "sort_order": {"ascending", "descending"}, - "task_filter": {"all", "active", "completed", "today", "upcoming"}, - "startup_view": {"tasks", "calendar"}, + "task_filter": {"all", "active", "completed", "today", "upcoming", "board"}, + "startup_view": {"tasks", "calendar", "board"}, "calendar_view": {"month", "week", "day"}, "first_day_of_week": {"monday", "sunday"}, } diff --git a/src/ui/pyqt/board_view.py b/src/ui/pyqt/board_view.py index bb2f469..dfff75e 100644 --- a/src/ui/pyqt/board_view.py +++ b/src/ui/pyqt/board_view.py @@ -6,6 +6,7 @@ QFrame, QHBoxLayout, QLabel, + QLineEdit, QPushButton, QScrollArea, QVBoxLayout, @@ -33,9 +34,18 @@ class BoardCard(QWidget): def __init__(self, task, parent=None): super().__init__(parent) self.task = task + self._column = None self.setObjectName("BoardCard") - self.setToolTip("Drag to a column to move it; double-click to edit") + self.setToolTip( + "Drag to a column to move it; double-click to edit. " + "Arrow keys move between cards." + ) self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.setAccessibleName(f"Task: {task.text}") + self.setAccessibleDescription( + BOARD_STAGE_LABELS.get(task.board_stage, "Not on board") + ) layout = QVBoxLayout(self) layout.setContentsMargins(12, 10, 12, 10) @@ -124,6 +134,34 @@ def mouseDoubleClickEvent(self, event): self.edit_requested.emit(self.task) super().mouseDoubleClickEvent(event) + def keyPressEvent(self, event): + key = event.key() + board = self._column.board if self._column is not None else None + if key == Qt.Key.Key_Left: + if board is not None: + board.move_focus(self, column_delta=-1) + event.accept() + elif key == Qt.Key.Key_Right: + if board is not None: + board.move_focus(self, column_delta=1) + event.accept() + elif key == Qt.Key.Key_Up: + if board is not None: + board.move_focus(self, row_delta=-1) + event.accept() + elif key == Qt.Key.Key_Down: + if board is not None: + board.move_focus(self, row_delta=1) + event.accept() + elif key in (Qt.Key.Key_Return, Qt.Key.Key_Enter): + self.edit_requested.emit(self.task) + event.accept() + elif key == Qt.Key.Key_Delete: + self.delete_requested.emit(self.task) + event.accept() + else: + super().keyPressEvent(event) + class BoardColumn(QWidget): """A single stage column holding task cards.""" @@ -136,11 +174,13 @@ class BoardColumn(QWidget): def __init__(self, stage: str, parent=None): super().__init__(parent) self.stage = stage + self.board = None self.setObjectName("BoardColumn") self.setAcceptDrops(True) self.setToolTip("Drop a card here to move it") title = BOARD_STAGE_LABELS[stage] + self.setAccessibleName(title) layout = QVBoxLayout(self) layout.setContentsMargins(10, 12, 10, 12) layout.setSpacing(8) @@ -175,6 +215,7 @@ def set_tasks(self, tasks): widget.deleteLater() for task in tasks: card = BoardCard(task) + card._column = self card.edit_requested.connect(self.edit_requested) card.delete_requested.connect(self.delete_requested) card.complete_requested.connect(self.complete_requested) @@ -223,6 +264,11 @@ def __init__(self, scheduler, parent=None): heading_row = QHBoxLayout() heading = QLabel("Kanban") heading.setObjectName("PageHeading") + self.search_input = QLineEdit() + self.search_input.setPlaceholderText("Search cards…") + self.search_input.setClearButtonEnabled(True) + self.search_input.setAccessibleName("Search Kanban cards") + self.search_input.setMaximumWidth(230) self.count_label = QLabel() self.count_label.setObjectName("MutedLabel") self.add_button = QPushButton("+ Add task") @@ -230,6 +276,7 @@ def __init__(self, scheduler, parent=None): self.add_button.setAccessibleName("Add new task") heading_row.addWidget(heading) heading_row.addStretch() + heading_row.addWidget(self.search_input) heading_row.addWidget(self.count_label) heading_row.addWidget(self.add_button) layout.addLayout(heading_row) @@ -241,6 +288,7 @@ def __init__(self, scheduler, parent=None): self.columns: dict[str, BoardColumn] = {} for stage in BOARD_STAGES: column = BoardColumn(stage) + column.board = self column.edit_requested.connect(self.edit_requested) column.delete_requested.connect(self.delete_requested) column.complete_requested.connect(self.complete_requested) @@ -256,22 +304,73 @@ def __init__(self, scheduler, parent=None): layout.addWidget(self.empty_label, 1) self.add_button.clicked.connect(self.add_requested) + self.search_input.textChanged.connect(self._apply_search) + self.search_text = "" self.refresh() def refresh(self): - grouped = group_by_stage(self.scheduler.get_tasks()) - total_on_board = sum(len(tasks) for tasks in grouped.values()) + all_tasks = self.scheduler.get_tasks() + query = self.search_text + visible = [ + task for task in all_tasks if not query or query in task.text.casefold() + ] + grouped = group_by_stage(visible) + 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" + self.columns_widget.setVisible(on_board > 0) + self.empty_label.setVisible(on_board == 0) + if query and on_board == 0 and any(bool(t.board_stage) for t in all_tasks): + self.empty_label.setText("No cards match the search.") + else: + 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"{on_board} of {len(all_tasks)} tasks on the board") + + def _apply_search(self, text: str): + self.search_text = text.strip().casefold() + self.refresh() + + def _cards(self, stage: str) -> list[BoardCard]: + layout = self.columns[stage].card_layout + cards: list[BoardCard] = [] + for index in range(layout.count()): + item = layout.itemAt(index) + if item is None: + continue + widget = item.widget() + if isinstance(widget, BoardCard): + cards.append(widget) + return cards + + def _nav_target( + self, card: BoardCard, column_delta=0, row_delta=0 + ) -> BoardCard | None: + stages = list(BOARD_STAGES) + columns = {stage: self._cards(stage) for stage in stages} + current_col = next( + (index for index, stage in enumerate(stages) if card in columns[stage]), + None, ) + if current_col is None or not columns[stages[current_col]]: + return None + current_row = columns[stages[current_col]].index(card) + target_col = max(0, min(len(stages) - 1, current_col + column_delta)) + target_cards = columns[stages[target_col]] + if not target_cards: + return None + if column_delta: + target_row = min(current_row, len(target_cards) - 1) + else: + target_row = max(0, min(len(target_cards) - 1, current_row + row_delta)) + return target_cards[target_row] + + def move_focus(self, card: BoardCard, column_delta=0, row_delta=0): + target = self._nav_target(card, column_delta, row_delta) + if target is not None: + target.setFocus() def _resolve_move(self, task_id: str, stage: str): for task in self.scheduler.get_tasks(): diff --git a/src/ui/pyqt/settings_dialog.py b/src/ui/pyqt/settings_dialog.py index 155b333..35fa0e9 100644 --- a/src/ui/pyqt/settings_dialog.py +++ b/src/ui/pyqt/settings_dialog.py @@ -43,6 +43,7 @@ "completed": "Completed", "today": "Today", "upcoming": "Upcoming", + "board": "On board", } @@ -204,6 +205,7 @@ def _build_general_tab(self, preferences: UiPreferences) -> QWidget: self.startup_view = QComboBox() self.startup_view.addItem("Tasks", "tasks") self.startup_view.addItem("Calendar", "calendar") + self.startup_view.addItem("Kanban", "board") self.startup_view.setCurrentIndex( self.startup_view.findData(preferences.startup_view) ) diff --git a/src/ui/pyqt/task_list.py b/src/ui/pyqt/task_list.py index 2680411..927c9a9 100644 --- a/src/ui/pyqt/task_list.py +++ b/src/ui/pyqt/task_list.py @@ -104,6 +104,8 @@ def filterAcceptsRow(self, source_row, source_parent): return task.date == today and not is_completed if self.task_filter == "upcoming": return task.date >= today and not is_completed + if self.task_filter == "board": + return bool(task.board_stage) return True diff --git a/tests/test_data_transfer.py b/tests/test_data_transfer.py index 5e93bfd..122df95 100644 --- a/tests/test_data_transfer.py +++ b/tests/test_data_transfer.py @@ -70,6 +70,18 @@ def test_backup_and_restore_round_trip_tasks_and_preferences(data_environment): assert safety_document["tasks"][0]["id"] == "replacement" +def test_ui_preferences_accept_board_filter_and_startup_value(data_environment): + prefs = _ui_preferences() + prefs["task_filter"] = "board" + prefs["startup_view"] = "board" + backup = data_environment / "backup.json" + + data_transfer.create_backup(backup, ui_preferences=prefs) + result = data_transfer.restore_backup(backup) + + assert result.ui_preferences == prefs + + def test_malformed_restore_does_not_change_data_or_create_safety_backup( data_environment, ): diff --git a/tests/test_pyqt_task_workspace.py b/tests/test_pyqt_task_workspace.py index 0e059b7..2326231 100644 --- a/tests/test_pyqt_task_workspace.py +++ b/tests/test_pyqt_task_workspace.py @@ -17,7 +17,7 @@ from ui.pyqt.calendar_view import CalendarWorkspace from ui.pyqt.ics_import_dialog import IcsImportDialog from ui.pyqt.settings_dialog import SettingsDialog, UiPreferences -from ui.pyqt.task_list import TaskListWidget, TaskTableModel +from ui.pyqt.task_list import TaskFilterProxyModel, TaskListWidget, TaskTableModel from ui.pyqt.window import SchedPlusWindow from updater.config import BuildInfo from updater.preferences import UpdatePreferences @@ -407,6 +407,142 @@ def test_window_undo_reverts_board_move(app, task_database): assert scheduler.load_tasks()[0].board_stage == "backlog" +def test_task_proxy_filters_on_board_only(app): + model = TaskTableModel( + [ + Task(date="2026-09-11", time="09:00", text="On cards", board_stage="done"), + Task(date="2026-09-12", time="10:00", text="Plain row"), + ] + ) + proxy = TaskFilterProxyModel() + proxy.setSourceModel(model) + proxy.set_task_filter("board") + + assert proxy.rowCount() == 1 + assert proxy.index(0, 0).data(Qt.ItemDataRole.UserRole).text == "On cards" + + +def test_settings_offer_board_filter_and_kanban_startup(app): + dialog = SettingsDialog(UiPreferences()) + + assert dialog.task_filter.findData("board") != -1 + assert dialog.startup_view.findData("board") != -1 + + +def test_board_search_filters_cards(app): + scheduler = MemoryScheduler( + [ + Task( + date="2026-09-11", + time="09:00", + text="Plan release", + board_stage="backlog", + ), + Task( + date="2026-09-12", + time="10:00", + text="Write tests", + board_stage="in_progress", + ), + ] + ) + board = BoardView(scheduler) + + board.search_input.setText("release") + + assert board.columns["backlog"].card_layout.count() == 2 + assert board.columns["in_progress"].card_layout.count() == 1 + assert "1 of 2 tasks on the board" in board.count_label.text() + + board.search_input.setText("zzz") + + assert board.columns_widget.isHidden() + assert not board.empty_label.isHidden() + assert "No cards match the search." in board.empty_label.text() + + board.search_input.clear() + + assert not board.columns_widget.isHidden() + assert "2 of 2 tasks on the board" in board.count_label.text() + + +def test_board_card_keyboard_enter_edits_and_delete_deletes(app): + from PyQt6.QtTest import QTest + + task = Task(date="2026-09-11", time="09:00", text="Card", board_stage="backlog") + column = BoardColumn("backlog") + column.set_tasks([task]) + card = column.card_layout.itemAt(0).widget() + edited = [] + deleted = [] + card.edit_requested.connect(edited.append) + card.delete_requested.connect(deleted.append) + + QTest.keyClick(card, Qt.Key.Key_Return) + QTest.keyClick(card, Qt.Key.Key_Delete) + + assert edited == [task] + assert deleted == [task] + + +def test_board_arrow_keys_navigate_between_columns(app): + scheduler = MemoryScheduler( + [ + Task( + date="2026-09-11", + time="09:00", + text="Backlog card", + board_stage="backlog", + ), + Task( + date="2026-09-11", + time="09:30", + text="Second backlog", + 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", + ), + ] + ) + board = BoardView(scheduler) + backlog, backlog_two = board._cards("backlog") + doing = board._cards("in_progress")[0] + done = board._cards("done")[0] + + assert board._nav_target(backlog, column_delta=1) is doing + assert board._nav_target(backlog, column_delta=2) is done + assert board._nav_target(done, column_delta=-2) is backlog + assert board._nav_target(backlog_two, row_delta=-1) is backlog + assert board._nav_target(backlog, row_delta=1) is backlog_two + assert board._nav_target(backlog, row_delta=-1) is backlog + assert board._nav_target(backlog_two, row_delta=1) is backlog_two + + +def test_window_complete_reflects_on_board_and_task_list(app, task_database): + storage.initialize_database() + scheduler = Scheduler() + task = scheduler.add_task( + "2026-09-11", "09:00", "Plan release", board_stage="backlog" + ) + window = SchedPlusWindow(scheduler) + + window.complete_task(task) + + assert scheduler.load_tasks()[0].completed == "true" + assert window.board_page.columns["backlog"].card_layout.count() == 2 + assert window.task_list.model.tasks[0].completed == "true" + + def test_add_dialog_boards_task_when_checked(app): dialog = AddTaskDialog() From 752ed5dca3462e0fde5a3a73943283fe90714221 Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Fri, 11 Sep 2026 11:36:01 +1000 Subject: [PATCH 07/12] fix: evict stale board cards on rebuild, preserving column spacer --- src/ui/pyqt/board_view.py | 7 +++++-- tests/test_pyqt_task_workspace.py | 35 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/ui/pyqt/board_view.py b/src/ui/pyqt/board_view.py index dfff75e..0b12d30 100644 --- a/src/ui/pyqt/board_view.py +++ b/src/ui/pyqt/board_view.py @@ -208,11 +208,14 @@ def __init__(self, stage: str, parent=None): layout.addWidget(scroll, 1) def set_tasks(self, tasks): - while self.card_layout.count() > 1: - item = self.card_layout.takeAt(1) + while self.card_layout.count(): + item = self.card_layout.takeAt(0) widget = item.widget() if widget is not None: + widget.hide() + widget.setParent(None) widget.deleteLater() + self.card_layout.addStretch() for task in tasks: card = BoardCard(task) card._column = self diff --git a/tests/test_pyqt_task_workspace.py b/tests/test_pyqt_task_workspace.py index 2326231..65fd8a5 100644 --- a/tests/test_pyqt_task_workspace.py +++ b/tests/test_pyqt_task_workspace.py @@ -407,6 +407,41 @@ def test_window_undo_reverts_board_move(app, task_database): assert scheduler.load_tasks()[0].board_stage == "backlog" +def test_board_column_set_tasks_evicts_stale_cards(app): + column = BoardColumn("backlog") + column.set_tasks([Task(date="2026-09-11", time="09:00", text="Card")]) + stale = column.card_layout.itemAt(0).widget() + + column.set_tasks([]) + + assert not stale.isVisible() + assert column.card_layout.count() == 1 + for index in range(column.card_layout.count()): + assert column.card_layout.itemAt(index).widget() is None + + +def test_board_refresh_does_not_keep_moved_card_in_source_column(app): + task = Task(date="2026-09-11", time="09:00", text="Card", board_stage="backlog") + scheduler = MemoryScheduler([task]) + board = BoardView(scheduler) + stored = scheduler.load_tasks() + stored[0] = Task( + id=task.id, + date=task.date, + time=task.time, + text=task.text, + createdAt=task.createdAt, + updatedAt=task.updatedAt, + board_stage="done", + ) + + board.refresh() + + assert board.columns["done"].card_layout.count() == 2 + assert board.columns["backlog"].card_layout.count() == 1 + assert not board.columns["backlog"].empty_label.isHidden() + + def test_task_proxy_filters_on_board_only(app): model = TaskTableModel( [ From 997b15be77010b1f80eadc23ab33e1f7b74b2759 Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Fri, 11 Sep 2026 11:39:20 +1000 Subject: [PATCH 08/12] 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"): From 1139dadd42fc4f0eb799a3a34ef8e4a3372a486e Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Fri, 11 Sep 2026 11:50:13 +1000 Subject: [PATCH 09/12] chore: bump experimental branch to v0.10.1 and document Kanban work --- CHANGELOG.md | 44 ++++++++++++++++++++++++++ packaging/pyinstaller/version_info.txt | 8 ++--- pyproject.toml | 2 +- snap/snapcraft.yaml | 2 +- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61400f5..74aea5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog +## v0.10.1 (2026-09-23) + +### Info +> This branch is experimental and may never make it to production. + +### Added +- Kanban planning board with an opt-in **board stage** (backlog / in progress / + done): an "Add to Kanban" option in the create and edit dialogs, a board page + reachable from the sidebar with per-column counts and empty states, and + per-card Complete, Edit, and Delete actions [#203]. +- Drag-and-drop card movement between board columns, saved as a normal task + edit so Ctrl+Z undoes a move; moving a card between stages never changes its + completion state [#204]. +- Board search by card text, an **On board** filter in the Tasks view, + arrow-key navigation across columns and cards, double-click or Enter to edit, + Delete to remove, and accessible names and descriptions on cards and columns + [#205]. +- **Unscheduled** tasks for deadline-independent planning: tasks can be created + and edited without a date and time, board cards are labelled "Unscheduled", + and date-less tasks are kept out of the calendar and the Today/Upcoming + filters [#225]. + +### Fixed +- Dragging a card to another column no longer leaves a stale copy behind in the + original column; a board rebuild now evicts every card and restores the + column's spacer [#192]. +- Version-derived packaging metadata (Windows version resource and Snap + manifest) is kept in sync with the project version so release metadata no + longer lags the code version. + +### Docs +- Added `docs/kanban-board-design.md`, the approved design for the Kanban board + covering the data model, storage layer, user interface, and delivery model + [#202]. + +### Plan +- Add a `--board` stage option to `schedplus add` and `schedplus edit`, making + `--date`/`--time` optional so unscheduled planning cards can also be created + from the CLI [#225]. +- Publish Kanban board documentation through DocsHub once the feature's final + shape is confirmed [#192]. +- Decide the delivery outcome for the experimental branch (integrate to `main` + or cut) after further hardening, per the design's delivery model [#192]. + ## v0.9.3 (2026-09-09) ### Info diff --git a/packaging/pyinstaller/version_info.txt b/packaging/pyinstaller/version_info.txt index 6422e5f..eb39f77 100644 --- a/packaging/pyinstaller/version_info.txt +++ b/packaging/pyinstaller/version_info.txt @@ -2,8 +2,8 @@ # Keep filevers and prodvers aligned with pyproject.toml for each release. VSVersionInfo( ffi=FixedFileInfo( - filevers=(0, 9, 3, 0), - prodvers=(0, 9, 3, 0), + filevers=(0, 10, 1, 0), + prodvers=(0, 10, 1, 0), mask=0x3f, flags=0x0, OS=0x40004, @@ -16,12 +16,12 @@ VSVersionInfo( StringTable('040904B0', [ StringStruct('CompanyName', 'ZFordDev'), StringStruct('FileDescription', 'SchedPlus desktop scheduler'), - StringStruct('FileVersion', '0.9.3'), + StringStruct('FileVersion', '0.10.1'), StringStruct('InternalName', 'SchedPlus'), StringStruct('LegalCopyright', 'Copyright (C) ZFordDev'), StringStruct('OriginalFilename', 'SchedPlus.exe'), StringStruct('ProductName', 'SchedPlus'), - StringStruct('ProductVersion', '0.9.3') + StringStruct('ProductVersion', '0.10.1') ]) ]), VarFileInfo([VarStruct('Translation', [1033, 1200])]) diff --git a/pyproject.toml b/pyproject.toml index 6e02501..9c02584 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "schedplus" -version = "0.9.3" +version = "0.10.1" description = "Modern, local-first scheduling and productivity application" readme = "README.md" license = { text = "GPL-3.0-only" } diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 4507ff7..2957a5c 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: schedplus base: core24 -version: "0.9.3" +version: "0.10.1" platforms: amd64: summary: Plan tasks and time with a local-first scheduler From 02413d2f16aaafcb9831a0880cb040bb764011ef Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Fri, 11 Sep 2026 13:30:18 +1000 Subject: [PATCH 10/12] feat: sort and filter unscheduled tasks, disable repeat for unscheduled --- CHANGELOG.md | 6 +++- docs/kanban-board-design.md | 5 +++ src/logic/data_transfer.py | 2 +- src/ui/pyqt/add_dialog.py | 2 ++ src/ui/pyqt/board_view.py | 8 +++-- src/ui/pyqt/settings_dialog.py | 1 + src/ui/pyqt/task_list.py | 20 +++++++++++ tests/test_pyqt_task_workspace.py | 58 ++++++++++++++++++++++++++++++- 8 files changed, 97 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74aea5f..0e14081 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ and edited without a date and time, board cards are labelled "Unscheduled", and date-less tasks are kept out of the calendar and the Today/Upcoming filters [#225]. +- A **Scheduled only** task filter hides date-less tasks, and date-less tasks + now sort as if due now (nearest to today) in every order. ### Fixed - Dragging a card to another column no longer leaves a stale copy behind in the @@ -29,6 +31,8 @@ - Version-derived packaging metadata (Windows version resource and Snap manifest) is kept in sync with the project version so release metadata no longer lags the code version. +- The Repeat fields are disabled while a task is marked Unscheduled, since a + recurring task needs a due date. ### Docs - Added `docs/kanban-board-design.md`, the approved design for the Kanban board @@ -38,7 +42,7 @@ ### Plan - Add a `--board` stage option to `schedplus add` and `schedplus edit`, making `--date`/`--time` optional so unscheduled planning cards can also be created - from the CLI [#225]. + from the CLI [#228]. - Publish Kanban board documentation through DocsHub once the feature's final shape is confirmed [#192]. - Decide the delivery outcome for the experimental branch (integrate to `main` diff --git a/docs/kanban-board-design.md b/docs/kanban-board-design.md index 5ada29f..0816754 100644 --- a/docs/kanban-board-design.md +++ b/docs/kanban-board-design.md @@ -93,6 +93,11 @@ def validate_board_stage(value: str) -> str: disagree about what a valid stage is. - `validate_task()` calls `validate_board_stage` on `getattr(task, "board_stage", "")` so every persistence path is covered. +- Unscheduled (date-less, time-less) tasks are valid for planning; a task with + only one of date or time set is rejected. The create/edit dialog disables the + Repeat fields while `Unscheduled` is checked, since a recurring task is + meaningless without a due date. (Repeat values already present on a dateless + task are left untouched and ignored.) ## 5. Serialization (backup / restore / export / import) diff --git a/src/logic/data_transfer.py b/src/logic/data_transfer.py index 0d68372..7177439 100644 --- a/src/logic/data_transfer.py +++ b/src/logic/data_transfer.py @@ -201,7 +201,7 @@ def _validate_ui_preferences(value: object) -> dict[str, Any]: choices = { "sort_field": {"date", "time", "text", "status", "created"}, "sort_order": {"ascending", "descending"}, - "task_filter": {"all", "active", "completed", "today", "upcoming", "board"}, + "task_filter": {"all", "active", "completed", "today", "upcoming", "board", "scheduled"}, "startup_view": {"tasks", "calendar", "board"}, "calendar_view": {"month", "week", "day"}, "first_day_of_week": {"monday", "sunday"}, diff --git a/src/ui/pyqt/add_dialog.py b/src/ui/pyqt/add_dialog.py index 68f7eeb..b19fb8e 100644 --- a/src/ui/pyqt/add_dialog.py +++ b/src/ui/pyqt/add_dialog.py @@ -200,6 +200,8 @@ def _sync_schedule_controls(self): unscheduled = self.unscheduled_checkbox.isChecked() self.date_input.setEnabled(not unscheduled) self.time_input.setEnabled(not unscheduled) + self.recurrence_input.setEnabled(not unscheduled) + self.recurrence_end_input.setEnabled(not unscheduled) def get_values( self, diff --git a/src/ui/pyqt/board_view.py b/src/ui/pyqt/board_view.py index 509bf3a..0b72fd1 100644 --- a/src/ui/pyqt/board_view.py +++ b/src/ui/pyqt/board_view.py @@ -77,7 +77,7 @@ def __init__(self, task, parent=None): priority = QLabel("High priority") priority.setObjectName("PriorityHigh") meta.addWidget(priority) - for value, label in (("duration", "min"), ("recurrence", "")): + for value, label in (("duration", " min"), ("recurrence", "")): stored = getattr(task, value) or "" if not stored: continue @@ -332,7 +332,11 @@ def refresh(self): "No tasks on the board.\n" "Add a task and tick \u201cAdd to Kanban\u201d to start planning." ) - self.count_label.setText(f"{on_board} of {len(all_tasks)} tasks on the board") + self.count_label.setText( + f"{on_board} of {len(all_tasks)} tasks match on the board" + if query + else f"{on_board} of {len(all_tasks)} tasks on the board" + ) def _apply_search(self, text: str): self.search_text = text.strip().casefold() diff --git a/src/ui/pyqt/settings_dialog.py b/src/ui/pyqt/settings_dialog.py index 35fa0e9..165feaf 100644 --- a/src/ui/pyqt/settings_dialog.py +++ b/src/ui/pyqt/settings_dialog.py @@ -44,6 +44,7 @@ "today": "Today", "upcoming": "Upcoming", "board": "On board", + "scheduled": "Scheduled only", } diff --git a/src/ui/pyqt/task_list.py b/src/ui/pyqt/task_list.py index 927c9a9..31d9d78 100644 --- a/src/ui/pyqt/task_list.py +++ b/src/ui/pyqt/task_list.py @@ -87,6 +87,24 @@ def set_task_filter(self, task_filter: str): self.task_filter = task_filter self.invalidateFilter() + def _sort_key(self, task) -> tuple[str, str]: + if task.date and task.time: + return (task.date, task.time) + return (local_time.today().isoformat(), local_time.now().strftime("%H:%M")) + + def lessThan(self, left, right): + if not left.isValid() or not right.isValid(): + return super().lessThan(left, right) + column = int(left.column()) + if column not in (0, 1): + return super().lessThan(left, right) + model = self.sourceModel() + left_key = self._sort_key(model.tasks[left.row()]) + right_key = self._sort_key(model.tasks[right.row()]) + if column == 0: + return left_key < right_key + return left_key[1] < right_key[1] + def filterAcceptsRow(self, source_row, source_parent): model = self.sourceModel() task = model.tasks[source_row] @@ -106,6 +124,8 @@ def filterAcceptsRow(self, source_row, source_parent): return task.date >= today and not is_completed if self.task_filter == "board": return bool(task.board_stage) + if self.task_filter == "scheduled": + return bool(task.date and task.time) return True diff --git a/tests/test_pyqt_task_workspace.py b/tests/test_pyqt_task_workspace.py index a51540f..937ff56 100644 --- a/tests/test_pyqt_task_workspace.py +++ b/tests/test_pyqt_task_workspace.py @@ -487,7 +487,7 @@ def test_board_search_filters_cards(app): assert board.columns["backlog"].card_layout.count() == 2 assert board.columns["in_progress"].card_layout.count() == 1 - assert "1 of 2 tasks on the board" in board.count_label.text() + assert "1 of 2 tasks match on the board" in board.count_label.text() board.search_input.setText("zzz") @@ -636,6 +636,19 @@ def test_edit_dialog_preselects_unscheduled_for_dateless_task(app): assert dialog.get_values()[0] == "" +def test_unscheduled_dialogs_disable_repeat_controls(app): + task = Task(date="", time="", text="Planning card", board_stage="backlog") + dialog = EditTaskDialog(task) + + dialog.unscheduled_checkbox.setChecked(False) + assert dialog.recurrence_input.isEnabled() + assert dialog.recurrence_end_input.isEnabled() + + dialog.unscheduled_checkbox.setChecked(True) + assert not dialog.recurrence_input.isEnabled() + assert not dialog.recurrence_end_input.isEnabled() + + def test_board_card_marks_dateless_task_unscheduled(app): task = Task(text="Planning card", board_stage="backlog") column = BoardColumn("backlog") @@ -664,6 +677,49 @@ def test_task_proxy_filters_exclude_unscheduled_from_date_filters(app): assert proxy.rowCount() == 1 +def test_task_proxy_scheduled_filter_excludes_unscheduled(app): + model = TaskTableModel( + [ + Task(date="", time="", text="Planning card", board_stage="backlog"), + Task(date="2026-01-01", time="09:00", text="Dated task"), + ] + ) + proxy = TaskFilterProxyModel() + proxy.setSourceModel(model) + + proxy.set_task_filter("all") + assert proxy.rowCount() == 2 + + proxy.set_task_filter("scheduled") + assert proxy.rowCount() == 1 + assert proxy.index(0, 0).data(Qt.ItemDataRole.UserRole).text == "Dated task" + + +def test_task_proxy_sorts_unscheduled_as_now(app): + today = local_time.today() + yesterday = today - timedelta(days=1) + tomorrow = today + timedelta(days=1) + tasks = [ + Task(date=tomorrow.isoformat(), time="23:59", text="Future task"), + Task(date="", time="", text="Planning card", board_stage="backlog"), + Task(date=yesterday.isoformat(), time="00:01", text="Past task"), + Task(date=today.isoformat(), time="12:00", text="Today task"), + ] + model = TaskTableModel(tasks) + proxy = TaskFilterProxyModel() + proxy.setSourceModel(model) + proxy.sort(0, Qt.SortOrder.AscendingOrder) + + ordered = [ + proxy.index(row, 0).data(Qt.ItemDataRole.UserRole).text + for row in range(proxy.rowCount()) + ] + assert ordered.index("Planning card") > ordered.index("Past task") + assert ordered.index("Planning card") < ordered.index("Future task") + assert ordered[0] == "Past task" + assert ordered[-1] == "Future task" + + def test_native_calendar_renders_month_week_and_day(app): today = local_time.today() scheduler = MemoryScheduler( From d0c70c4c6084377a0692ade5f1f8774295da8b24 Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Fri, 11 Sep 2026 13:50:25 +1000 Subject: [PATCH 11/12] feat: refine board cards, add icon actions, and drop board delete --- CHANGELOG.md | 7 +++ docs/kanban-board-design.md | 11 +++-- src/logic/data_transfer.py | 10 +++- src/logic/scheduler.py | 1 + src/ui/pyqt/board_view.py | 78 +++++++++++++++++++++--------- src/ui/pyqt/theme.py | 18 +++++++ src/ui/pyqt/window.py | 1 - tests/test_pyqt_task_workspace.py | 29 +++++++++-- tests/test_scheduler_recurrence.py | 16 ++++++ 9 files changed, 138 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e14081..0c2e963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,11 @@ filters [#225]. - A **Scheduled only** task filter hides date-less tasks, and date-less tasks now sort as if due now (nearest to today) in every order. +- Board cards alternate plain/tinted backgrounds so adjacent cards read as + separate entries, and card actions are compact icon buttons (tick = + complete, pencil = edit) with tooltips. The board deliberately has **no delete + action**: a task can only be removed in the Tasks/Calendar views (or taken + off the board by clearing "Add to Kanban"). ### Fixed - Dragging a card to another column no longer leaves a stale copy behind in the @@ -33,6 +38,8 @@ longer lags the code version. - The Repeat fields are disabled while a task is marked Unscheduled, since a recurring task needs a due date. +- Completing a recurring task no longer duplicates it on the board: the next + occurrence is created off the board, so only the completed card remains. ### Docs - Added `docs/kanban-board-design.md`, the approved design for the Kanban board diff --git a/docs/kanban-board-design.md b/docs/kanban-board-design.md index 0816754..fca8a40 100644 --- a/docs/kanban-board-design.md +++ b/docs/kanban-board-design.md @@ -154,9 +154,14 @@ Add **Kanban** as a fourth navigation entry in `window.py`: - One column widget per stage, rendered from `scheduler.get_tasks()` grouped by `task.board_stage`. Cards are only tasks whose `board_stage` matches. - Signals mirror `TaskListWidget`: `add_requested`, `edit_requested`, - `delete_requested`, `complete_requested` — wired in `window.py` to the - existing handlers (`open_add_dialog`, `open_edit_dialog`, `delete_task`, - `complete_task`). + `complete_requested` — wired in `window.py` to the existing handlers + (`open_add_dialog`, `open_edit_dialog`, `complete_task`). The board has no + delete action: cards are never deleted from the board (the task list and + calendar are the only places that delete tasks). A card can be taken off the + board by clearing "Add to Kanban" in the edit dialog. +- Cards alternate a plain/tinted background so adjacent cards read as separate + entries, and carry small icon-only action buttons (tick = complete, + pencil = edit) with tooltips and accessible names. - `refresh()` rebuilds from `scheduler.get_tasks()` so board data is always the task list itself (Task and Calendar remain the source of truth). - Empty states: a whole-board empty state and a per-column "no tasks" spill. diff --git a/src/logic/data_transfer.py b/src/logic/data_transfer.py index 7177439..2a048bd 100644 --- a/src/logic/data_transfer.py +++ b/src/logic/data_transfer.py @@ -201,7 +201,15 @@ def _validate_ui_preferences(value: object) -> dict[str, Any]: choices = { "sort_field": {"date", "time", "text", "status", "created"}, "sort_order": {"ascending", "descending"}, - "task_filter": {"all", "active", "completed", "today", "upcoming", "board", "scheduled"}, + "task_filter": { + "all", + "active", + "completed", + "today", + "upcoming", + "board", + "scheduled", + }, "startup_view": {"tasks", "calendar", "board"}, "calendar_view": {"month", "week", "day"}, "first_day_of_week": {"monday", "sunday"}, diff --git a/src/logic/scheduler.py b/src/logic/scheduler.py index 3c64296..691ef94 100644 --- a/src/logic/scheduler.py +++ b/src/logic/scheduler.py @@ -186,6 +186,7 @@ def complete_task(self, task_id: str): completedAt="", createdAt=datetime.now(timezone.utc).isoformat(), updatedAt=datetime.now(timezone.utc).isoformat(), + board_stage="", ) db.create_entry(new_task) self.tasks.append(new_task) diff --git a/src/ui/pyqt/board_view.py b/src/ui/pyqt/board_view.py index 0b72fd1..a18ab76 100644 --- a/src/ui/pyqt/board_view.py +++ b/src/ui/pyqt/board_view.py @@ -1,7 +1,8 @@ """Kanban board view for planning tasks.""" from PyQt6.QtCore import QMimeData, Qt, pyqtSignal -from PyQt6.QtGui import QDrag +from PyQt6.QtGui import QDrag, QIcon, QPainter, QPixmap +from PyQt6.QtSvg import QSvgRenderer from PyQt6.QtWidgets import ( QFrame, QHBoxLayout, @@ -23,12 +24,46 @@ BOARD_MIME_TYPE = "application/x-schedplus-task" +_TICK_SVG = ( + '' + '' +) + +_UNCOMPLETE_SVG = ( + '' + '' + '' + '' +) + +_PENCIL_SVG = ( + '' + '' + '' + '' +) + + +def _icon_from_svg(svg: str, size: int = 16) -> QIcon: + renderer = QSvgRenderer() + if not renderer.load(bytes(svg, "utf-8")): + return QIcon() + pixmap = QPixmap(size, size) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + renderer.render(painter) + painter.end() + return QIcon(pixmap) + 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): @@ -94,27 +129,28 @@ def __init__(self, task, parent=None): 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.complete_button = QPushButton() + if task.completed == "true": + self.complete_button.setIcon(_icon_from_svg(_UNCOMPLETE_SVG)) + self.complete_button.setToolTip("Mark task incomplete") + self.complete_button.setAccessibleName("Mark task incomplete") + else: + self.complete_button.setIcon(_icon_from_svg(_TICK_SVG)) + self.complete_button.setToolTip("Complete task") + self.complete_button.setAccessibleName("Complete task") + self.complete_button.setObjectName("CardIconButton") + self.edit_button = QPushButton() + self.edit_button.setIcon(_icon_from_svg(_PENCIL_SVG)) + self.edit_button.setToolTip("Edit task") self.edit_button.setAccessibleName("Edit task") - self.delete_button = QPushButton("Delete") - self.delete_button.setObjectName("DangerButton") - self.delete_button.setAccessibleName("Delete task") + self.edit_button.setObjectName("CardIconButton") actions.addWidget(self.complete_button) actions.addWidget(self.edit_button) - actions.addWidget(self.delete_button) + actions.addStretch() 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)) @staticmethod def drag_mime(task) -> QMimeData: @@ -158,9 +194,6 @@ def keyPressEvent(self, event): elif key in (Qt.Key.Key_Return, Qt.Key.Key_Enter): self.edit_requested.emit(self.task) event.accept() - elif key == Qt.Key.Key_Delete: - self.delete_requested.emit(self.task) - event.accept() else: super().keyPressEvent(event) @@ -169,7 +202,6 @@ class BoardColumn(QWidget): """A single stage column holding task cards.""" edit_requested = pyqtSignal(object) - delete_requested = pyqtSignal(object) complete_requested = pyqtSignal(object) move_requested = pyqtSignal(str, str) @@ -218,11 +250,11 @@ def set_tasks(self, tasks): widget.setParent(None) widget.deleteLater() self.card_layout.addStretch() - for task in tasks: + for index, task in enumerate(tasks): card = BoardCard(task) card._column = self + card.setProperty("zebra", "tinted" if index % 2 else "plain") 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) @@ -254,7 +286,6 @@ class BoardView(QWidget): add_requested = pyqtSignal() edit_requested = pyqtSignal(object) - delete_requested = pyqtSignal(object) complete_requested = pyqtSignal(object) move_requested = pyqtSignal(object, str) @@ -295,7 +326,6 @@ def __init__(self, scheduler, parent=None): column = BoardColumn(stage) column.board = self column.edit_requested.connect(self.edit_requested) - column.delete_requested.connect(self.delete_requested) column.complete_requested.connect(self.complete_requested) column.move_requested.connect(self._resolve_move) self.columns[stage] = column diff --git a/src/ui/pyqt/theme.py b/src/ui/pyqt/theme.py index 4f17f04..7cf6436 100644 --- a/src/ui/pyqt/theme.py +++ b/src/ui/pyqt/theme.py @@ -402,6 +402,24 @@ QWidget#BoardCard:hover { border-color: #CBD5E1; } +QWidget#BoardCard[zebra="tinted"] { + background: #F1F5F9; +} +QPushButton#CardIconButton { + min-width: 26px; + max-width: 26px; + height: 26px; + padding: 0; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; +} +QPushButton#CardIconButton:hover { + background: #DBEAFE; +} +QPushButton#CardIconButton:pressed { + background: #BFDBFE; +} QLabel#CardText { color: #172033; font-weight: 600; diff --git a/src/ui/pyqt/window.py b/src/ui/pyqt/window.py index 2561682..367ebbe 100644 --- a/src/ui/pyqt/window.py +++ b/src/ui/pyqt/window.py @@ -121,7 +121,6 @@ def __init__(self, scheduler): 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.board_page.move_requested.connect(self.move_board_task) diff --git a/tests/test_pyqt_task_workspace.py b/tests/test_pyqt_task_workspace.py index 937ff56..3dd9cd3 100644 --- a/tests/test_pyqt_task_workspace.py +++ b/tests/test_pyqt_task_workspace.py @@ -501,7 +501,7 @@ def test_board_search_filters_cards(app): assert "2 of 2 tasks on the board" in board.count_label.text() -def test_board_card_keyboard_enter_edits_and_delete_deletes(app): +def test_board_card_keyboard_enter_edits(app): from PyQt6.QtTest import QTest task = Task(date="2026-09-11", time="09:00", text="Card", board_stage="backlog") @@ -509,15 +509,36 @@ def test_board_card_keyboard_enter_edits_and_delete_deletes(app): column.set_tasks([task]) card = column.card_layout.itemAt(0).widget() edited = [] - deleted = [] card.edit_requested.connect(edited.append) - card.delete_requested.connect(deleted.append) QTest.keyClick(card, Qt.Key.Key_Return) QTest.keyClick(card, Qt.Key.Key_Delete) assert edited == [task] - assert deleted == [task] + + +def test_board_cards_alternate_zebra_tint(app): + column = BoardColumn("backlog") + tasks = [ + Task(date="2026-09-11", time="09:00", text="One", board_stage="backlog"), + Task(date="2026-09-12", time="09:00", text="Two", board_stage="backlog"), + Task(date="2026-09-13", time="09:00", text="Three", board_stage="backlog"), + ] + column.set_tasks(tasks) + + zebras = [column.card_layout.itemAt(i).widget().property("zebra") for i in range(3)] + assert zebras == ["plain", "tinted", "plain"] + + +def test_board_card_has_no_delete_action(app): + task = Task(date="2026-09-11", time="09:00", text="Card", board_stage="backlog") + column = BoardColumn("backlog") + column.set_tasks([task]) + card = column.card_layout.itemAt(0).widget() + + assert not hasattr(card, "delete_button") + assert card.complete_button.icon().isNull() is False + assert card.edit_button.icon().isNull() is False def test_board_arrow_keys_navigate_between_columns(app): diff --git a/tests/test_scheduler_recurrence.py b/tests/test_scheduler_recurrence.py index 5e35ade..e88f342 100644 --- a/tests/test_scheduler_recurrence.py +++ b/tests/test_scheduler_recurrence.py @@ -129,6 +129,22 @@ def test_new_occurrence_is_uncompleted_and_persisted(scheduler, frozen_clock): assert storage.list_completed_entries()[0].id == task.id +def test_completed_board_task_successor_stays_off_board(scheduler, frozen_clock): + task = scheduler.add_task( + date="2026-08-20", time="09:00", text="Board chain", board_stage="backlog" + ) + _with_recurrence(scheduler, task, "weekly") + + scheduler.complete_task(task.id) + + original = next(t for t in scheduler.get_tasks() if t.id == task.id) + successor = next(t for t in scheduler.get_tasks() if t.date == "2026-08-27") + assert original.board_stage == "backlog" + assert original.completed == "true" + assert successor.board_stage == "" + assert successor.completed == "" + + def test_completed_entries_carry_optional_fields(scheduler): task = scheduler.add_task(date="2026-08-20", time="10:00", text="Rich task") rich = replace( From 93322d9b7d96c95df5edd49f115c79c75d920c4d Mon Sep 17 00:00:00 2001 From: ZFordDev Date: Fri, 11 Sep 2026 14:09:12 +1000 Subject: [PATCH 12/12] docs: finalize v0.10.1 notes and README for the Kanban rollout --- CHANGELOG.md | 13 ++++++------- README.md | 4 ++++ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c2e963..0bfa3af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,20 +3,21 @@ ## v0.10.1 (2026-09-23) ### Info -> This branch is experimental and may never make it to production. +> Rollout note: the Kanban board is new in this release. It is published to the +> Snap `edge` channel first so the Linux community can help surface issues +> before it is promoted to a stable channel. ### Added - Kanban planning board with an opt-in **board stage** (backlog / in progress / done): an "Add to Kanban" option in the create and edit dialogs, a board page reachable from the sidebar with per-column counts and empty states, and - per-card Complete, Edit, and Delete actions [#203]. + per-card Complete and Edit actions [#203]. - Drag-and-drop card movement between board columns, saved as a normal task edit so Ctrl+Z undoes a move; moving a card between stages never changes its completion state [#204]. - Board search by card text, an **On board** filter in the Tasks view, arrow-key navigation across columns and cards, double-click or Enter to edit, - Delete to remove, and accessible names and descriptions on cards and columns - [#205]. + and accessible names and descriptions on cards and columns [#205]. - **Unscheduled** tasks for deadline-independent planning: tasks can be created and edited without a date and time, board cards are labelled "Unscheduled", and date-less tasks are kept out of the calendar and the Today/Upcoming @@ -50,10 +51,8 @@ - Add a `--board` stage option to `schedplus add` and `schedplus edit`, making `--date`/`--time` optional so unscheduled planning cards can also be created from the CLI [#228]. -- Publish Kanban board documentation through DocsHub once the feature's final +- Publish Kanban board documentation through DocsHub now that the feature's shape is confirmed [#192]. -- Decide the delivery outcome for the experimental branch (integrate to `main` - or cut) after further hardening, per the design's delivery model [#192]. ## v0.9.3 (2026-09-09) diff --git a/README.md b/README.md index af4e4ad..ad24b6c 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,10 @@ control. - Create, edit, delete, search, filter, and sort scheduled tasks. - Plan through native month, week, and day calendar views. - Create tasks from the calendar and drag them to reschedule. +- Plan with a Kanban board: drag cards between Backlog, In progress, and Done + columns, search cards, and mark them complete (`docs/kanban-board-design.md`). +- Create unscheduled (date-less) tasks for planning, with a "Scheduled only" + filter when you need just dated tasks. - Choose the advanced PyQt workspace, lightweight Tkinter interface, or CLI. - Keep task data in a platform-appropriate local SQLite database. - Create versioned local backups and portable JSON task exports.