diff --git a/src/tasks/models/task.py b/src/tasks/models/task.py index baa8e6e..41a3b4a 100644 --- a/src/tasks/models/task.py +++ b/src/tasks/models/task.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Iterator from datetime import datetime import peewee as pw @@ -56,7 +57,14 @@ def category_name(self) -> str: ) @staticmethod - def group_by_status() -> dict[str, list[Task]]: + def iter_status_indices() -> Iterator[int]: + """Convenient method to iterate over the statuses indices""" + return ( + status_index for status_index, _ in enumerate(settings.statuses) + ) + + @staticmethod + def group_by_status() -> dict[int, list[Task]]: """List all existing tasks by status and sorted by creation date""" tasks = ( Task.select() diff --git a/src/tasks/presenters/view_all_presenter.py b/src/tasks/presenters/view_all_presenter.py index 6c208fd..2508e31 100644 --- a/src/tasks/presenters/view_all_presenter.py +++ b/src/tasks/presenters/view_all_presenter.py @@ -1,69 +1,121 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -from rich.console import Console, Group -from rich.layout import Layout +from rich.align import Align +from rich.console import Console from rich.markup import escape -from rich.panel import Panel from rich.rule import Rule +from rich.table import Table from config import settings - -if TYPE_CHECKING: - from src.tasks.models.task import Task +from src.tasks.models.task import Task class ViewAllPresenter: """A class to display a summary list of tasks""" NO_CATEGORY_STR = "" + EMPTY_STR = "" def __init__(self, console: Console | None = None) -> None: self._console = console or Console() - def present(self, tasks_by_status: dict[str, list[Task]]) -> None: - """Display a table-like view with all existing tasks + def present(self, tasks_by_status: dict[int, list[Task]]) -> None: + """Display a table view with all existing tasks The tasks are grouped by status, each one in a column. + The rows should be viewed as follows: + + Status 1 | Status 2 | Status 3 + ============================== + task 11 | task 12 | task 13 + ------- | | ------- + task 21 | | task 23 + ------- | | + task 31 | | + + Notice, for each status column, if a given task has an upcoming task, + they are separated by rules, otherwise we get an empty string. """ - layout = Layout(name="root") - tasks_presentation = [ - self._present_tasks_in_status(tasks_by_status[status_id]) - for status_id, _ in enumerate(settings.statuses) - ] - layout.split_row( - *( - Layout(Panel(tasks, title=status)) - for tasks, status in zip(tasks_presentation, settings.statuses) + table = Table(expand=True, title="Tasks") + for status in settings.statuses: + table.add_column( + Align(status, align="center"), justify="left", ratio=1 ) + + tasks_rows = self._build_tasks_rows(tasks_by_status) + rules_rows = self._build_rules_rows(tasks_rows) + rows = self._build_all_rows(tasks_rows, rules_rows) + for row in rows: + table.add_row(*row) + + self._console.print(table) + + def _build_tasks_rows( + self, tasks_by_status: dict[int, list[Task]] + ) -> list[list[str]]: + """Build a table with the tasks in a presentation-like view + + This method groups only the rows with tasks. + If, for the i-th status, the j-th row has no task, the EMPTY_STR is + used instead. + """ + max_num_tasks_in_status = max( + len(tasks) for tasks in tasks_by_status.values() ) - self._console.print("\n") - self._console.print(layout) + def get_ith_task_or_empty(status_index: int, i: int) -> str: + tasks = tasks_by_status[status_index] + if i >= len(tasks): + return self.EMPTY_STR + + task = tasks[i] + return self._present_task(task) - def _present_tasks_in_status(self, tasks: list[Task]) -> Group: - """Writes a list of tasks in a status column + return [ + [ + get_ith_task_or_empty(status_index, i) + for status_index in Task.iter_status_indices() + ] # build each column of a row + for i in range(max_num_tasks_in_status) + ] - The tasks should be presented like: + def _build_rules_rows( + self, task_rows: list[list[str]] + ) -> list[list[str | Rule]]: + """Build rules rows - - --- - - --- - - ... + The rules separate one task from its subsequent one. In case a given + task has no next one, the EMPTY_STR is used instead. """ - if not tasks: - return Group("") - group_content = [ - el for task in tasks for el in (self._present_task(task), Rule()) + def get_ith_rule_or_empty( + task_row: list[str], status_index: int + ) -> Rule | str: + if task_row[status_index] == self.EMPTY_STR: + return self.EMPTY_STR + + return Rule() + + return [ + [ + get_ith_rule_or_empty(next_task_row, status_index) + for status_index in Task.iter_status_indices() + ] + for next_task_row in task_rows[1:] ] - group_content.pop() # remove last `Rule` - return Group(*group_content) + def _build_all_rows( + self, tasks_rows: list[list[str]], rules_rows: list[list[str | Rule]] + ) -> list[list[str | Rule]]: + """Combine the tasks and rules rows into a full table""" + rows = [] + for task_row, rule_row in zip(tasks_rows, rules_rows): + rows.append(task_row) + rows.append(rule_row) + + rows.append(tasks_rows[-1]) + return rows def _present_task(self, task: Task) -> str: """Display a single task diff --git a/tests/tasks/models/test_task.py b/tests/tasks/models/test_task.py index 2b8debf..a90d8a0 100644 --- a/tests/tasks/models/test_task.py +++ b/tests/tasks/models/test_task.py @@ -85,6 +85,14 @@ def test_category_name__nonexisting_category(tmp_db): assert task.category_name == Task.NO_CATEGORY_STR +def test_iter_status_indices(tmp_db): + expected_status_indices = [ + status_index for status_index, _ in enumerate(settings.statuses) + ] + + assert list(Task.iter_status_indices()) == expected_status_indices + + def test_group_by_status(tmp_db): """Tasks must be grouped by status and sorted by creation date""" cat1 = Category.create(name="cat1")