from dataclasses import dataclass from datetime import date, datetime, time, timedelta, timezone from typing import Any from sqlalchemy import case, or_, select from sqlalchemy.orm import selectinload from app.extensions import db from .models import TASK_PRIORITIES, TASK_STATUSES, Subtask, Task class TaskNotFoundError(LookupError): pass @dataclass(frozen=True) class ForgivingStreak: days: int freeze_used: bool def _task_query(user_id: int): return ( select(Task) .where(Task.user_id == user_id) .options(selectinload(Task.subtasks)) ) def _task_order(): priority_order = case( {"urgent": 0, "important": 1, "normal": 2}, value=Task.priority, else_=3, ) return ( case((Task.status == "done", 1), else_=0), priority_order, Task.due_date.is_(None), Task.due_date, Task.created_at, ) def get_task(task_id: int, user_id: int) -> Task: task = db.session.scalar(_task_query(user_id).where(Task.id == task_id)) if task is None: raise TaskNotFoundError return task def list_tasks(user_id: int, status: str | None = None) -> list[Task]: query = _task_query(user_id) if status is not None: if status not in TASK_STATUSES: raise ValueError("Unknown task status.") query = query.where(Task.status == status) return list(db.session.scalars(query.order_by(*_task_order()))) def list_today_tasks( user_id: int, *, now: datetime | None = None, limit: int = 7, ) -> list[Task]: local_now = now or datetime.now().astimezone() end_of_today = datetime.combine(local_now.date(), time.max) query = ( _task_query(user_id) .where(Task.status != "done") .where(or_(Task.due_date.is_(None), Task.due_date <= end_of_today)) .order_by(*_task_order()) .limit(limit) ) return list(db.session.scalars(query)) def create_task( user_id: int, *, title: str, description: str | None, status: str, priority: str, due_date: datetime | None, ) -> Task: _validate_choices(status, priority) task = Task( user_id=user_id, title=title.strip(), description=_clean_optional(description), status=status, priority=priority, due_date=due_date, ) if status == "done": task.completed_at = datetime.now(timezone.utc) db.session.add(task) db.session.commit() return task def update_task( task: Task, *, title: str, description: str | None, status: str, priority: str, due_date: datetime | None, ) -> Task: _validate_choices(status, priority) was_done = task.status == "done" task.title = title.strip() task.description = _clean_optional(description) task.status = status task.priority = priority task.due_date = due_date if status == "done" and not was_done: task.completed_at = datetime.now(timezone.utc) elif status != "done": task.completed_at = None db.session.commit() return task def delete_task(task: Task) -> None: db.session.delete(task) db.session.commit() def add_subtask(task: Task, title: str) -> Subtask: subtask = Subtask(task=task, title=title.strip()) db.session.add(subtask) db.session.commit() return subtask def toggle_subtask(task: Task, subtask_id: int) -> Subtask: subtask = next( (item for item in task.subtasks if item.id == subtask_id), None, ) if subtask is None: raise TaskNotFoundError subtask.completed = not subtask.completed db.session.commit() return subtask def configure_focus( task: Task, *, timer_minutes: int | None, context: str | None, chunk_minutes: list[int], ) -> Task: existing_sessions = [ _normalize_chunk_session(session) for session in task.chunk_sessions ] sessions = [] for index, minutes in enumerate(chunk_minutes): completed = ( index < len(existing_sessions) and existing_sessions[index]["minutes"] == minutes and existing_sessions[index]["completed"] ) sessions.append({"minutes": minutes, "completed": completed}) task.timer_seconds = (timer_minutes or 0) * 60 task.context = _clean_optional(context) task.chunk_sessions = sessions db.session.commit() return task def toggle_chunk_session(task: Task, chunk_index: int) -> Task: sessions = [ _normalize_chunk_session(session) for session in task.chunk_sessions ] if chunk_index < 0 or chunk_index >= len(sessions): raise TaskNotFoundError sessions[chunk_index]["completed"] = not sessions[chunk_index]["completed"] task.chunk_sessions = sessions db.session.commit() return task def complete_task(task: Task, *, completed_at: datetime | None = None) -> Task: task.status = "done" task.completed_at = completed_at or datetime.now(timezone.utc) db.session.commit() return task def calculate_forgiving_streak( user_id: int, *, today: date | None = None, ) -> ForgivingStreak: completion_dates = { completed_at.date() for completed_at in db.session.scalars( select(Task.completed_at).where( Task.user_id == user_id, Task.completed_at.is_not(None), ) ) if completed_at is not None } current_day = today or datetime.now().astimezone().date() # The current day is never treated as missed while it is still in progress. cursor = current_day if current_day in completion_dates else current_day - timedelta(days=1) if cursor not in completion_dates: return ForgivingStreak(days=0, freeze_used=False) days = 0 freeze_used = False while True: if cursor in completion_dates: days += 1 cursor -= timedelta(days=1) continue previous_day = cursor - timedelta(days=1) if not freeze_used and previous_day in completion_dates: freeze_used = True cursor = previous_day continue break return ForgivingStreak(days=days, freeze_used=freeze_used) def _validate_choices(status: str, priority: str) -> None: if status not in TASK_STATUSES: raise ValueError("Unknown task status.") if priority not in TASK_PRIORITIES: raise ValueError("Unknown task priority.") def _clean_optional(value: str | None) -> str | None: cleaned = value.strip() if value else "" return cleaned or None def _normalize_chunk_session(session: Any) -> dict[str, int | bool]: if isinstance(session, dict): return { "minutes": int(session.get("minutes", 0)), "completed": bool(session.get("completed", False)), } return {"minutes": int(session), "completed": False}