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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/logic/board.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions src/logic/data_transfer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
3 changes: 3 additions & 0 deletions src/logic/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class Task:
recurrence: str = ""
recurrenceEnd: str = ""
reminder: str = ""
board_stage: str = ""


class Scheduler:
Expand Down Expand Up @@ -75,6 +76,7 @@ def add_task(
recurrence: str = "",
recurrenceEnd: str = "",
reminder: str = "",
board_stage: str = "",
):
from .storage import sqlite_storage as db

Expand All @@ -90,6 +92,7 @@ def add_task(
recurrence=recurrence,
recurrenceEnd=recurrenceEnd,
reminder=reminder,
board_stage=board_stage,
)
)
db.create_entry(task)
Expand Down
8 changes: 8 additions & 0 deletions src/logic/storage/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -83,6 +90,7 @@ def _migration_6(connection: sqlite3.Connection) -> None:
_migration_4,
_migration_5,
_migration_6,
_migration_7,
)
CURRENT_SCHEMA_VERSION = len(MIGRATIONS)

Expand Down
26 changes: 15 additions & 11 deletions src/logic/storage/sqlite_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -292,6 +292,7 @@ def create_entry(task: Task) -> None:
task.recurrence,
task.recurrenceEnd,
task.reminder,
task.board_stage,
),
)
)
Expand All @@ -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 = ?
""",
(
Expand All @@ -321,6 +322,7 @@ def update_entry(task: Task) -> None:
task.recurrence,
task.recurrenceEnd,
task.reminder,
task.board_stage,
task.id,
),
)
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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],
)

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 "",
)


Expand All @@ -485,4 +488,5 @@ def _task_values(task: Task) -> tuple:
task.recurrence,
task.recurrenceEnd,
task.reminder,
task.board_stage,
)
14 changes: 14 additions & 0 deletions src/logic/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -15,6 +17,7 @@ class TaskLike(Protocol):
date: str
time: str
text: str
board_stage: str


TaskType = TypeVar("TaskType", bound=TaskLike)
Expand Down Expand Up @@ -56,7 +59,18 @@ def validate_task(task: TaskType) -> TaskType:
if not text:
raise ValidationError("Task text cannot be empty.")

task.board_stage = validate_board_stage(getattr(task, "board_stage", ""))

task.date = date
task.time = time
task.text = text
return task


def validate_board_stage(value: str) -> str:
"""Normalize and validate a Kanban planning stage ('' = off the board)."""
normalized = value.strip() if isinstance(value, str) else ""

if normalized and normalized not in BOARD_STAGES:
raise ValidationError(f"Board stage must be one of {', '.join(BOARD_STAGES)}.")
return normalized
35 changes: 34 additions & 1 deletion src/ui/pyqt/add_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from PyQt6.QtCore import QDate, Qt, QTime
from PyQt6.QtWidgets import (
QCheckBox,
QComboBox,
QDateEdit,
QDialog,
Expand All @@ -14,6 +15,8 @@
QVBoxLayout,
)

from logic.board import BOARD_STAGES


class TaskDialog(QDialog):
def __init__(
Expand Down Expand Up @@ -98,6 +101,17 @@ def __init__(
self.reminder_input.setToolTip("Minutes before due time to notify (0 = off)")
self.reminder_input.setAccessibleName("Reminder lead time in minutes")

self.board_checkbox = QCheckBox("Add to Kanban")
self.board_checkbox.setAccessibleName("Add to Kanban board")
self.board_checkbox.setToolTip("Place this task on the Kanban planning board")

self.board_combo = QComboBox()
self.board_combo.addItem("Backlog", "backlog")
self.board_combo.addItem("In progress", "in_progress")
self.board_combo.addItem("Done", "done")
self.board_combo.setAccessibleName("Board column")
self.board_combo.setEnabled(False)

form.addRow("Task", self.text_input)
form.addRow("Date", self.date_input)
form.addRow("Time", self.time_input)
Expand All @@ -108,6 +122,8 @@ def __init__(
form.addRow("Repeat", self.recurrence_input)
form.addRow("Repeat until", self.recurrence_end_input)
form.addRow("Remind before", self.reminder_input)
form.addRow("", self.board_checkbox)
form.addRow("Board column", self.board_combo)
layout.addLayout(form)

buttons = QDialogButtonBox(
Expand Down Expand Up @@ -145,6 +161,12 @@ def __init__(
self.reminder_input.setValue(int(reminder))
except (ValueError, TypeError):
self.reminder_input.setValue(0)
board_stage = getattr(task, "board_stage", "") or ""
self.board_combo.setCurrentIndex(
max(0, self.board_combo.findData(board_stage))
)
self.board_checkbox.setChecked(board_stage in BOARD_STAGES)
self._sync_board_controls()
else:
selected_date = QDate.fromString(initial_date or "", "yyyy-MM-dd")
selected_time = QTime.fromString(initial_time or "", "HH:mm")
Expand All @@ -154,10 +176,17 @@ def __init__(
self.time_input.setTime(
selected_time if selected_time.isValid() else QTime.currentTime()
)
self.board_combo.setCurrentIndex(0)

self.board_checkbox.toggled.connect(self._sync_board_controls)
self.text_input.setFocus()

def get_values(self) -> tuple[str, str, str, str, str, str, str, str, str, str]:
def _sync_board_controls(self):
self.board_combo.setEnabled(self.board_checkbox.isChecked())

def get_values(
self,
) -> tuple[str, str, str, str, str, str, str, str, str, str, str]:
duration = self.duration_input.value()
recurrence = self.recurrence_input.currentText()
recurrence_end = ""
Expand All @@ -166,6 +195,9 @@ def get_values(self) -> tuple[str, str, str, str, str, str, str, str, str, str]:
reminder = self.reminder_input.value()
date_str = self.date_input.date().toString("yyyy-MM-dd")
time_str = self.time_input.time().toString("HH:mm")
board_stage = (
self.board_combo.currentData() if self.board_checkbox.isChecked() else ""
)
return (
date_str,
time_str,
Expand All @@ -177,6 +209,7 @@ def get_values(self) -> tuple[str, str, str, str, str, str, str, str, str, str]:
recurrence,
recurrence_end,
str(reminder) if reminder > 0 else "",
board_stage,
)


Expand Down
Loading