diff --git a/src/tt/cli.py b/src/tt/cli.py index 38a34f3..2a6478e 100644 --- a/src/tt/cli.py +++ b/src/tt/cli.py @@ -2,7 +2,7 @@ import typer import httpx import lxml.html from typing_extensions import Annotated -from .controller import add_task +from .controller.tasks import add_task from .import_csv import import_tasks_from_csv from .db import initialize_db from .tui import tasks diff --git a/src/tt/controller/summaries.py b/src/tt/controller/summaries.py new file mode 100644 index 0000000..22ddf4a --- /dev/null +++ b/src/tt/controller/summaries.py @@ -0,0 +1,180 @@ +from datetime import datetime, timedelta +from peewee import fn, JOIN +from .db import Task, Category, TaskStatus + + +def get_category_summary(num: int = 5) -> list[dict]: + """ + Returns summary of top categories with task counts by status and due dates. + + Args: + num: Number of categories to return, ordered by total task count + + Returns: + List of dicts containing category name and task statistics + """ + now = datetime.now() + week_from_now = now + timedelta(days=7) + + # Subquery for overdue tasks + overdue_count = ( + Task.select(Task.category, fn.COUNT(Task.id).alias("overdue")) + .where( + (~Task.deleted) & (Task.due < now) & (Task.status != TaskStatus.DONE.value) + ) + .group_by(Task.category) + ) + + # Subquery for tasks due in next 7 days + due_soon_count = ( + Task.select(Task.category, fn.COUNT(Task.id).alias("due_soon")) + .where( + (~Task.deleted) + & (Task.due >= now) + & (Task.due <= week_from_now) + & (Task.status != TaskStatus.DONE.value) + ) + .group_by(Task.category) + ) + + # Main query joining all the information + query = ( + Category.select( + Category.name, + fn.COALESCE(fn.SUM(Task.status == TaskStatus.ZERO.value), 0).alias( + "zero_count" + ), + fn.COALESCE(fn.SUM(Task.status == TaskStatus.WIP.value), 0).alias( + "wip_count" + ), + fn.COALESCE(fn.SUM(Task.status == TaskStatus.BLOCKED.value), 0).alias( + "blocked_count" + ), + fn.COALESCE(fn.SUM(Task.status == TaskStatus.DONE.value), 0).alias( + "done_count" + ), + fn.COALESCE(overdue_count.c.overdue, 0).alias("overdue"), + fn.COALESCE(due_soon_count.c.due_soon, 0).alias("due_soon"), + ) + .join(Task, JOIN.LEFT_OUTER) + .join( + overdue_count, + JOIN.LEFT_OUTER, + on=(Category.id == overdue_count.c.category_id), + ) + .join( + due_soon_count, + JOIN.LEFT_OUTER, + on=(Category.id == due_soon_count.c.category_id), + ) + .where(~Task.deleted) + .group_by(Category.id) + .order_by(fn.COUNT(Task.id).desc()) + .limit(num) + ) + + return [ + { + "category": cat.name, + "tasks": { + "zero": cat.zero_count, + "wip": cat.wip_count, + "blocked": cat.blocked_count, + "done": cat.done_count, + "overdue": cat.overdue, + "due_soon": cat.due_soon, + }, + } + for cat in query + ] + + +def get_recently_active(num: int = 5, category: str | None = None) -> list[dict]: + """ + Returns most recently active tasks, optionally filtered by category. + + Args: + num: Number of tasks to return + category: Optional category name to filter by + + Returns: + List of tasks ordered by last activity (updated_at) + """ + query = ( + Task.select(Task, Category.name.alias("category_name")) + .join(Category, JOIN.LEFT_OUTER) + .where(~Task.deleted) + ) + + if category: + query = query.where(Category.name == category) + + query = query.order_by(Task.updated_at.desc()).limit(num) + + return [ + { + "id": task.id, + "text": task.text, + "status": task.status, + "type": task.type, + "category": task.category_name, + "due": task.due, + "updated_at": task.updated_at, + } + for task in query + ] + + +def get_due_soon( + num: int = 5, all_overdue: bool = True, category: str | None = None +) -> list[dict]: + """ + Returns tasks ordered by due date, optionally including all overdue tasks. + + Args: + num: Number of non-overdue tasks to return + all_overdue: If True, returns all overdue tasks plus num more tasks + If False, includes overdue tasks in the count + category: Optional category name to filter by + + Returns: + List of tasks ordered by due date + """ + now = datetime.now() + + # Base query + query = ( + Task.select(Task, Category.name.alias("category_name")) + .join(Category, JOIN.LEFT_OUTER) + .where( + (~Task.deleted) + & (Task.due.is_null(False)) + & (Task.status != TaskStatus.DONE.value) + ) + ) + + if category: + query = query.where(Category.name == category) + + # Handle overdue tasks based on all_overdue parameter + if all_overdue: + overdue_tasks = list(query.where(Task.due < now).order_by(Task.due)) + upcoming_tasks = list( + query.where(Task.due >= now).order_by(Task.due).limit(num) + ) + tasks = overdue_tasks + upcoming_tasks + else: + tasks = list(query.order_by(Task.due).limit(num)) + + return [ + { + "id": task.id, + "text": task.text, + "status": task.status, + "type": task.type, + "category": task.category_name, + "due": task.due, + "days_until_due": (task.due - now).days if task.due else None, + } + for task in tasks + ] diff --git a/src/tt/controller.py b/src/tt/controller/tasks.py similarity index 100% rename from src/tt/controller.py rename to src/tt/controller/tasks.py diff --git a/src/tt/tui/tasks.py b/src/tt/tui/tasks.py index 86ba3ba..e2c7949 100644 --- a/src/tt/tui/tasks.py +++ b/src/tt/tui/tasks.py @@ -2,7 +2,7 @@ import json from textual.widgets import Input from datetime import datetime -from ..controller import ( +from ..controller.tasks import ( get_task, get_tasks, add_task,