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") + )