303 lines
7.9 KiB
Python
303 lines
7.9 KiB
Python
from dataclasses import dataclass
|
|
from datetime import date, datetime, time, timedelta, timezone
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
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, Task.cleared_at.is_(None))
|
|
.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 clear_completed_tasks(user_id: int) -> tuple[str | None, int]:
|
|
tasks = list(
|
|
db.session.scalars(
|
|
select(Task).where(
|
|
Task.user_id == user_id,
|
|
Task.status == "done",
|
|
Task.cleared_at.is_(None),
|
|
)
|
|
)
|
|
)
|
|
if not tasks:
|
|
return None, 0
|
|
|
|
batch_id = str(uuid4())
|
|
cleared_at = datetime.now(timezone.utc)
|
|
for task in tasks:
|
|
task.cleared_at = cleared_at
|
|
task.clear_batch_id = batch_id
|
|
db.session.commit()
|
|
return batch_id, len(tasks)
|
|
|
|
|
|
def undo_clear_completed(user_id: int, batch_id: str) -> int:
|
|
tasks = list(
|
|
db.session.scalars(
|
|
select(Task).where(
|
|
Task.user_id == user_id,
|
|
Task.clear_batch_id == batch_id,
|
|
Task.cleared_at.is_not(None),
|
|
)
|
|
)
|
|
)
|
|
for task in tasks:
|
|
task.cleared_at = None
|
|
task.clear_batch_id = None
|
|
db.session.commit()
|
|
return len(tasks)
|
|
|
|
|
|
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}
|