diff --git a/CHANGELOG.md b/CHANGELOG.md
index 61400f5..0bfa3af 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,59 @@
# Changelog
+## v0.10.1 (2026-09-23)
+
+### Info
+> 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 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,
+ 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].
+- 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
+ 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.
+- 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
+ 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 [#228].
+- Publish Kanban board documentation through DocsHub now that the feature's
+ shape is confirmed [#192].
+
## v0.9.3 (2026-09-09)
### Info
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.
diff --git a/docs/kanban-board-design.md b/docs/kanban-board-design.md
new file mode 100644
index 0000000..fca8a40
--- /dev/null
+++ b/docs/kanban-board-design.md
@@ -0,0 +1,270 @@
+# 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.
+- 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)
+
+`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`,
+ `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.
+
+### 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.
+
+## 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
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
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..2a048bd 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):
@@ -200,8 +201,16 @@ 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",
+ "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 cc8bc2e..691ef94 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)
@@ -183,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/logic/storage/migrations.py b/src/logic/storage/migrations.py
index 13da1c7..585345e 100644
--- a/src/logic/storage/migrations.py
+++ b/src/logic/storage/migrations.py
@@ -75,6 +75,13 @@ def _migration_6(connection: sqlite3.Connection) -> None:
)
+def _migration_7(connection: sqlite3.Connection) -> None:
+ """Add Kanban planning stage column (empty = not on the board)."""
+ connection.execute(
+ "ALTER TABLE entries ADD COLUMN board_stage TEXT NOT NULL DEFAULT ''"
+ )
+
+
# Never edit or reorder a released migration. Add the next numbered callable.
MIGRATIONS: tuple[Migration, ...] = (
_migration_1,
@@ -83,6 +90,7 @@ def _migration_6(connection: sqlite3.Connection) -> None:
_migration_4,
_migration_5,
_migration_6,
+ _migration_7,
)
CURRENT_SCHEMA_VERSION = len(MIGRATIONS)
diff --git a/src/logic/storage/sqlite_storage.py b/src/logic/storage/sqlite_storage.py
index deb4f89..28ed00a 100644
--- a/src/logic/storage/sqlite_storage.py
+++ b/src/logic/storage/sqlite_storage.py
@@ -273,8 +273,8 @@ def create_entry(task: Task) -> None:
_run(
lambda connection: connection.execute(
"""
- INSERT INTO entries (id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO entries (id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder, board_stage)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
task.id,
@@ -292,6 +292,7 @@ def create_entry(task: Task) -> None:
task.recurrence,
task.recurrenceEnd,
task.reminder,
+ task.board_stage,
),
)
)
@@ -304,7 +305,7 @@ def update_entry(task: Task) -> None:
"""
UPDATE entries
SET date = ?, time = ?, text = ?, updatedAt = ?, completed = ?, completedAt = ?,
- notes = ?, priority = ?, duration = ?, category = ?, recurrence = ?, recurrenceEnd = ?, reminder = ?
+ notes = ?, priority = ?, duration = ?, category = ?, recurrence = ?, recurrenceEnd = ?, reminder = ?, board_stage = ?
WHERE id = ?
""",
(
@@ -321,6 +322,7 @@ def update_entry(task: Task) -> None:
task.recurrence,
task.recurrenceEnd,
task.reminder,
+ task.board_stage,
task.id,
),
)
@@ -339,7 +341,7 @@ def delete_entry(task_id: str) -> None:
def get_entry(task_id: str) -> Task | None:
row = _run(
lambda connection: connection.execute(
- "SELECT id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder "
+ "SELECT id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder, board_stage "
"FROM entries WHERE id = ?",
(task_id,),
).fetchone()
@@ -350,7 +352,7 @@ def get_entry(task_id: str) -> Task | None:
def list_entries() -> list[Task]:
rows = _run(
lambda connection: connection.execute("""
- SELECT id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder
+ SELECT id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder, board_stage
FROM entries
ORDER BY date ASC, time ASC
""").fetchall()
@@ -364,8 +366,8 @@ def replace_entries(tasks: list[Task]) -> None:
def replace(connection: sqlite3.Connection) -> None:
connection.execute("DELETE FROM entries")
connection.executemany(
- "INSERT INTO entries (id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder) "
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO entries (id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder, board_stage) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[_task_values(task) for task in tasks],
)
@@ -379,14 +381,14 @@ def merge(connection: sqlite3.Connection) -> tuple[int, int, int]:
imported = duplicates = conflicts = 0
for task in tasks:
row = connection.execute(
- "SELECT id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder "
+ "SELECT id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder, board_stage "
"FROM entries WHERE id = ?",
(task.id,),
).fetchone()
if row is None:
connection.execute(
- "INSERT INTO entries (id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder) "
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+ "INSERT INTO entries (id, date, time, text, createdAt, updatedAt, completed, completedAt, notes, priority, duration, category, recurrence, recurrenceEnd, reminder, board_stage) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
_task_values(task),
)
imported += 1
@@ -439,7 +441,7 @@ def list_completed_entries() -> list[Task]:
rows = _run(
lambda connection: connection.execute("""
SELECT id, date, time, text, createdAt, updatedAt, completed, completedAt,
- notes, priority, duration, category, recurrence, recurrenceEnd, reminder
+ notes, priority, duration, category, recurrence, recurrenceEnd, reminder, board_stage
FROM entries
WHERE completed = 'true'
ORDER BY completedAt DESC
@@ -465,6 +467,7 @@ def _task_from_row(row: tuple) -> Task:
recurrence=row[12] if len(row) > 12 else "",
recurrenceEnd=row[13] if len(row) > 13 else "",
reminder=row[14] if len(row) > 14 else "",
+ board_stage=row[15] if len(row) > 15 else "",
)
@@ -485,4 +488,5 @@ def _task_values(task: Task) -> tuple:
task.recurrence,
task.recurrenceEnd,
task.reminder,
+ task.board_stage,
)
diff --git a/src/logic/validation.py b/src/logic/validation.py
index ad6f7ab..7ee98ce 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)
@@ -33,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:
@@ -56,7 +74,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..b19fb8e 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__(
@@ -58,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)
@@ -98,9 +107,21 @@ 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)
+ form.addRow("", self.unscheduled_checkbox)
form.addRow("Notes", self.notes_input)
form.addRow("Priority", self.priority_input)
form.addRow("Duration", self.duration_input)
@@ -108,6 +129,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(
@@ -120,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)
@@ -145,6 +171,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,18 +186,41 @@ 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.unscheduled_checkbox.toggled.connect(self._sync_schedule_controls)
+ self._sync_schedule_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 _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,
+ ) -> tuple[str, str, str, str, str, str, str, str, str, str, str]:
duration = self.duration_input.value()
recurrence = self.recurrence_input.currentText()
recurrence_end = ""
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 ""
+ )
return (
date_str,
time_str,
@@ -177,6 +232,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..a18ab76
--- /dev/null
+++ b/src/ui/pyqt/board_view.py
@@ -0,0 +1,421 @@
+"""Kanban board view for planning tasks."""
+
+from PyQt6.QtCore import QMimeData, Qt, pyqtSignal
+from PyQt6.QtGui import QDrag, QIcon, QPainter, QPixmap
+from PyQt6.QtSvg import QSvgRenderer
+from PyQt6.QtWidgets import (
+ QFrame,
+ QHBoxLayout,
+ QLabel,
+ QLineEdit,
+ QPushButton,
+ QScrollArea,
+ QVBoxLayout,
+ QWidget,
+)
+
+from logic.board import BOARD_STAGES, group_by_stage
+
+BOARD_STAGE_LABELS = {
+ "backlog": "Backlog",
+ "in_progress": "In progress",
+ "done": "Done",
+}
+
+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)
+ complete_requested = pyqtSignal(object)
+
+ 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. "
+ "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)
+ 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() if task.date else "Unscheduled"
+ )
+ 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()
+ 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.edit_button.setObjectName("CardIconButton")
+ actions.addWidget(self.complete_button)
+ actions.addWidget(self.edit_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))
+
+ @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)
+
+ 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()
+ else:
+ super().keyPressEvent(event)
+
+
+class BoardColumn(QWidget):
+ """A single stage column holding task cards."""
+
+ edit_requested = pyqtSignal(object)
+ complete_requested = pyqtSignal(object)
+ move_requested = pyqtSignal(str, str)
+
+ 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)
+
+ 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():
+ 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 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.complete_requested.connect(self.complete_requested)
+ 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."""
+
+ add_requested = pyqtSignal()
+ edit_requested = pyqtSignal(object)
+ complete_requested = pyqtSignal(object)
+ move_requested = pyqtSignal(object, str)
+
+ 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.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")
+ self.add_button.setObjectName("PrimaryButton")
+ 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)
+
+ 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)
+ column.board = self
+ column.edit_requested.connect(self.edit_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)
+
+ 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.search_input.textChanged.connect(self._apply_search)
+ self.search_text = ""
+ self.refresh()
+
+ def refresh(self):
+ 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(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 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()
+ 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():
+ 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/settings_dialog.py b/src/ui/pyqt/settings_dialog.py
index 155b333..165feaf 100644
--- a/src/ui/pyqt/settings_dialog.py
+++ b/src/ui/pyqt/settings_dialog.py
@@ -43,6 +43,8 @@
"completed": "Completed",
"today": "Today",
"upcoming": "Upcoming",
+ "board": "On board",
+ "scheduled": "Scheduled only",
}
@@ -204,6 +206,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..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]
@@ -104,6 +122,10 @@ 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)
+ if self.task_filter == "scheduled":
+ return bool(task.date and task.time)
return True
diff --git a/src/ui/pyqt/theme.py b/src/ui/pyqt/theme.py
index b6ada73..7cf6436 100644
--- a/src/ui/pyqt/theme.py
+++ b/src/ui/pyqt/theme.py
@@ -384,6 +384,60 @@
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;
+}
+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;
+}
+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..367ebbe 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 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
@@ -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.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)
@@ -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)
@@ -292,6 +307,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":
@@ -559,6 +590,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..9cab98e
--- /dev/null
+++ b/tests/test_board.py
@@ -0,0 +1,132 @@
+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_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()
+ 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..122df95 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,
)
@@ -69,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,
):
@@ -130,6 +143,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..3dd9cd3 100644
--- a/tests/test_pyqt_task_workspace.py
+++ b/tests/test_pyqt_task_workspace.py
@@ -5,16 +5,19 @@
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
-from PyQt6.QtWidgets import QApplication
+from PyQt6.QtCore import QMimeData, Qt
+from PyQt6.QtWidgets import QApplication, QLabel
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 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
-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
@@ -37,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
@@ -216,11 +228,517 @@ 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_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_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(
+ [
+ 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 match 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(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 = []
+ card.edit_requested.connect(edited.append)
+
+ QTest.keyClick(card, Qt.Key.Key_Return)
+ QTest.keyClick(card, Qt.Key.Key_Delete)
+
+ assert edited == [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):
+ 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()
+
+ 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_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_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")
+ 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_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):
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(
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..2c846df 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():
@@ -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"):
@@ -67,3 +84,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")
+ )