"""Tasks: core work items with full filtering and hierarchy.""" from fastapi import APIRouter, Request, Form, Depends from fastapi.templating import Jinja2Templates from fastapi.responses import RedirectResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import text from typing import Optional from datetime import datetime, timezone from core.database import get_db from core.base_repository import BaseRepository from core.sidebar import get_sidebar_data router = APIRouter(prefix="/tasks", tags=["tasks"]) templates = Jinja2Templates(directory="templates") @router.get("/") async def list_tasks( request: Request, domain_id: Optional[str] = None, project_id: Optional[str] = None, status: Optional[str] = None, priority: Optional[str] = None, context: Optional[str] = None, sort: str = "sort_order", db: AsyncSession = Depends(get_db), ): sidebar = await get_sidebar_data(db) where_clauses = ["t.is_deleted = false"] params = {} if domain_id: where_clauses.append("t.domain_id = :domain_id") params["domain_id"] = domain_id if project_id: where_clauses.append("t.project_id = :project_id") params["project_id"] = project_id if status: where_clauses.append("t.status = :status") params["status"] = status if priority: where_clauses.append("t.priority = :priority") params["priority"] = int(priority) if context: where_clauses.append("t.context = :context") params["context"] = context where_sql = " AND ".join(where_clauses) sort_map = { "sort_order": "t.sort_order, t.created_at", "priority": "t.priority ASC, t.due_date ASC NULLS LAST", "due_date": "t.due_date ASC NULLS LAST, t.priority ASC", "created_at": "t.created_at DESC", "title": "t.title ASC", } order_sql = sort_map.get(sort, sort_map["sort_order"]) result = await db.execute(text(f""" SELECT t.*, d.name as domain_name, d.color as domain_color, p.name as project_name FROM tasks t LEFT JOIN domains d ON t.domain_id = d.id LEFT JOIN projects p ON t.project_id = p.id WHERE {where_sql} ORDER BY CASE WHEN t.status = 'done' THEN 1 WHEN t.status = 'cancelled' THEN 2 ELSE 0 END, {order_sql} """), params) items = [dict(r._mapping) for r in result] # Get filter options domains_repo = BaseRepository("domains", db) domains = await domains_repo.list() projects_repo = BaseRepository("projects", db) projects = await projects_repo.list() result = await db.execute(text( "SELECT value, label FROM context_types WHERE is_deleted = false ORDER BY sort_order" )) context_types = [dict(r._mapping) for r in result] return templates.TemplateResponse("tasks.html", { "request": request, "sidebar": sidebar, "items": items, "domains": domains, "projects": projects, "context_types": context_types, "current_domain_id": domain_id or "", "current_project_id": project_id or "", "current_status": status or "", "current_priority": priority or "", "current_context": context or "", "current_sort": sort, "page_title": "All Tasks", "active_nav": "tasks", }) @router.get("/create") async def create_form( request: Request, domain_id: Optional[str] = None, project_id: Optional[str] = None, parent_id: Optional[str] = None, db: AsyncSession = Depends(get_db), ): sidebar = await get_sidebar_data(db) domains_repo = BaseRepository("domains", db) domains = await domains_repo.list() projects_repo = BaseRepository("projects", db) projects = await projects_repo.list() result = await db.execute(text( "SELECT value, label FROM context_types WHERE is_deleted = false ORDER BY sort_order" )) context_types = [dict(r._mapping) for r in result] return templates.TemplateResponse("task_form.html", { "request": request, "sidebar": sidebar, "domains": domains, "projects": projects, "context_types": context_types, "page_title": "New Task", "active_nav": "tasks", "item": None, "prefill_domain_id": domain_id or "", "prefill_project_id": project_id or "", "prefill_parent_id": parent_id or "", }) @router.post("/create") async def create_task( request: Request, title: str = Form(...), domain_id: str = Form(...), project_id: Optional[str] = Form(None), parent_id: Optional[str] = Form(None), description: Optional[str] = Form(None), priority: int = Form(3), status: str = Form("open"), due_date: Optional[str] = Form(None), deadline: Optional[str] = Form(None), context: Optional[str] = Form(None), tags: Optional[str] = Form(None), estimated_minutes: Optional[str] = Form(None), energy_required: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): repo = BaseRepository("tasks", db) data = { "title": title, "domain_id": domain_id, "description": description, "priority": priority, "status": status, } if project_id and project_id.strip(): data["project_id"] = project_id if parent_id and parent_id.strip(): data["parent_id"] = parent_id if due_date and due_date.strip(): data["due_date"] = due_date if deadline and deadline.strip(): data["deadline"] = deadline if context and context.strip(): data["context"] = context if tags and tags.strip(): data["tags"] = [t.strip() for t in tags.split(",") if t.strip()] if estimated_minutes and estimated_minutes.strip(): data["estimated_minutes"] = int(estimated_minutes) if energy_required and energy_required.strip(): data["energy_required"] = energy_required task = await repo.create(data) # Redirect back to project if created from project context if data.get("project_id"): return RedirectResponse(url=f"/projects/{data['project_id']}?tab=tasks", status_code=303) return RedirectResponse(url="/tasks", status_code=303) @router.get("/{task_id}") async def task_detail(task_id: str, request: Request, db: AsyncSession = Depends(get_db)): repo = BaseRepository("tasks", db) sidebar = await get_sidebar_data(db) item = await repo.get(task_id) if not item: return RedirectResponse(url="/tasks", status_code=303) # Domain and project info domain = None if item.get("domain_id"): result = await db.execute(text("SELECT name, color FROM domains WHERE id = :id"), {"id": str(item["domain_id"])}) row = result.first() domain = dict(row._mapping) if row else None project = None if item.get("project_id"): result = await db.execute(text("SELECT id, name FROM projects WHERE id = :id"), {"id": str(item["project_id"])}) row = result.first() project = dict(row._mapping) if row else None parent = None if item.get("parent_id"): result = await db.execute(text("SELECT id, title FROM tasks WHERE id = :id"), {"id": str(item["parent_id"])}) row = result.first() parent = dict(row._mapping) if row else None # Subtasks result = await db.execute(text(""" SELECT * FROM tasks WHERE parent_id = :tid AND is_deleted = false ORDER BY sort_order, created_at """), {"tid": task_id}) subtasks = [dict(r._mapping) for r in result] return templates.TemplateResponse("task_detail.html", { "request": request, "sidebar": sidebar, "item": item, "domain": domain, "project": project, "parent": parent, "subtasks": subtasks, "page_title": item["title"], "active_nav": "tasks", }) @router.get("/{task_id}/edit") async def edit_form(task_id: str, request: Request, db: AsyncSession = Depends(get_db)): repo = BaseRepository("tasks", db) sidebar = await get_sidebar_data(db) item = await repo.get(task_id) if not item: return RedirectResponse(url="/tasks", status_code=303) domains_repo = BaseRepository("domains", db) domains = await domains_repo.list() projects_repo = BaseRepository("projects", db) projects = await projects_repo.list() result = await db.execute(text( "SELECT value, label FROM context_types WHERE is_deleted = false ORDER BY sort_order" )) context_types = [dict(r._mapping) for r in result] return templates.TemplateResponse("task_form.html", { "request": request, "sidebar": sidebar, "domains": domains, "projects": projects, "context_types": context_types, "page_title": f"Edit Task", "active_nav": "tasks", "item": item, "prefill_domain_id": "", "prefill_project_id": "", "prefill_parent_id": "", }) @router.post("/{task_id}/edit") async def update_task( task_id: str, title: str = Form(...), domain_id: str = Form(...), project_id: Optional[str] = Form(None), parent_id: Optional[str] = Form(None), description: Optional[str] = Form(None), priority: int = Form(3), status: str = Form("open"), due_date: Optional[str] = Form(None), deadline: Optional[str] = Form(None), context: Optional[str] = Form(None), tags: Optional[str] = Form(None), estimated_minutes: Optional[str] = Form(None), energy_required: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): repo = BaseRepository("tasks", db) data = { "title": title, "domain_id": domain_id, "description": description, "priority": priority, "status": status, "project_id": project_id if project_id and project_id.strip() else None, "parent_id": parent_id if parent_id and parent_id.strip() else None, "due_date": due_date if due_date and due_date.strip() else None, "deadline": deadline if deadline and deadline.strip() else None, "context": context if context and context.strip() else None, } if tags and tags.strip(): data["tags"] = [t.strip() for t in tags.split(",") if t.strip()] else: data["tags"] = None if estimated_minutes and estimated_minutes.strip(): data["estimated_minutes"] = int(estimated_minutes) if energy_required and energy_required.strip(): data["energy_required"] = energy_required # Handle completion old = await repo.get(task_id) if old and old["status"] != "done" and status == "done": data["completed_at"] = datetime.now(timezone.utc) elif status != "done": data["completed_at"] = None await repo.update(task_id, data) return RedirectResponse(url=f"/tasks/{task_id}", status_code=303) @router.post("/{task_id}/complete") async def complete_task(task_id: str, request: Request, db: AsyncSession = Depends(get_db)): """Quick complete from list view.""" repo = BaseRepository("tasks", db) await repo.update(task_id, { "status": "done", "completed_at": datetime.now(timezone.utc), }) return RedirectResponse(url=request.headers.get("referer", "/tasks"), status_code=303) @router.post("/{task_id}/toggle") async def toggle_task(task_id: str, request: Request, db: AsyncSession = Depends(get_db)): """Toggle task done/open from list view.""" repo = BaseRepository("tasks", db) task = await repo.get(task_id) if not task: return RedirectResponse(url="/tasks", status_code=303) if task["status"] == "done": await repo.update(task_id, {"status": "open", "completed_at": None}) else: await repo.update(task_id, {"status": "done", "completed_at": datetime.now(timezone.utc)}) referer = request.headers.get("referer", "/tasks") return RedirectResponse(url=referer, status_code=303) @router.post("/{task_id}/delete") async def delete_task(task_id: str, request: Request, db: AsyncSession = Depends(get_db)): repo = BaseRepository("tasks", db) await repo.soft_delete(task_id) referer = request.headers.get("referer", "/tasks") return RedirectResponse(url=referer, status_code=303) # Quick add from any task list @router.post("/quick-add") async def quick_add( request: Request, title: str = Form(...), domain_id: Optional[str] = Form(None), project_id: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): repo = BaseRepository("tasks", db) data = {"title": title, "status": "open", "priority": 3} if domain_id and domain_id.strip(): data["domain_id"] = domain_id if project_id and project_id.strip(): data["project_id"] = project_id # If no domain, use first domain if "domain_id" not in data: result = await db.execute(text( "SELECT id FROM domains WHERE is_deleted = false ORDER BY sort_order LIMIT 1" )) row = result.first() if row: data["domain_id"] = str(row[0]) await repo.create(data) referer = request.headers.get("referer", "/tasks") return RedirectResponse(url=referer, status_code=303)