"""Focus: persistent commitment list — items stay until removed.""" 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 date, datetime, timezone from core.database import get_db from core.base_repository import BaseRepository from core.sidebar import get_sidebar_data from core.template_filters import autolink router = APIRouter(prefix="/focus", tags=["focus"]) templates = Jinja2Templates(directory="templates") templates.env.filters["autolink"] = autolink @router.get("/") async def focus_view( request: Request, domain_id: Optional[str] = None, area_id: Optional[str] = None, project_id: Optional[str] = None, search: Optional[str] = None, source_type: Optional[str] = None, db: AsyncSession = Depends(get_db), ): sidebar = await get_sidebar_data(db) if not source_type: source_type = "tasks" # --- All active focus items --- result = await db.execute(text(""" SELECT df.*, t.title as task_title, t.priority, t.status as task_status, t.project_id as task_project_id, t.due_date, t.estimated_minutes, COALESCE(p.name, lp.name, sp.name) as project_name, COALESCE(t.project_id, l.project_id, df.project_id) as effective_project_id, COALESCE(d.name, ld.name, sd.name) as domain_name, COALESCE(d.color, ld.color, sd.color) as domain_color, COALESCE(d.id, ld.id, df.domain_id) as effective_domain_id, COALESCE(a.name, pa.name, la.name) as area_name, COALESCE(a.id, pa.id, la.id) as effective_area_id, li.content as list_item_content, li.list_id as list_item_list_id, li.completed as list_item_completed, l.name as list_name FROM daily_focus df LEFT JOIN tasks t ON df.task_id = t.id LEFT JOIN projects p ON t.project_id = p.id LEFT JOIN domains d ON t.domain_id = d.id LEFT JOIN areas a ON t.area_id = a.id LEFT JOIN areas pa ON p.area_id = pa.id LEFT JOIN list_items li ON df.list_item_id = li.id LEFT JOIN lists l ON li.list_id = l.id LEFT JOIN projects lp ON l.project_id = lp.id LEFT JOIN domains ld ON l.domain_id = ld.id LEFT JOIN areas la ON l.area_id = la.id LEFT JOIN domains sd ON df.domain_id = sd.id LEFT JOIN projects sp ON df.project_id = sp.id WHERE df.is_deleted = false AND (t.id IS NULL OR t.is_deleted = false) AND (li.id IS NULL OR li.is_deleted = false) ORDER BY df.sort_order, df.created_at """)) items = [dict(r._mapping) for r in result] # Group items by domain only — area/project shown as inline columns from collections import OrderedDict domain_map = OrderedDict() for item in items: dk = item.get("effective_domain_id") or "__none__" dl = item.get("domain_name") or "General" dc = item.get("domain_color") or "" if dk not in domain_map: domain_map[dk] = {"key": dk, "label": dl, "color": dc, "rows": []} domain_map[dk]["rows"].append(item) # Sort: General first, then by domain sort_order domain_order = {str(d["id"]): d.get("sort_order", 0) for d in await BaseRepository("domains", db).list()} hierarchy = sorted(domain_map.values(), key=lambda g: (-1 if g["key"] == "__none__" else domain_order.get(str(g["key"]), 999))) # --- Available tasks --- available_tasks = [] if source_type == "tasks": avail_where = [ "t.is_deleted = false", "t.status NOT IN ('done', 'cancelled')", "t.id NOT IN (SELECT task_id FROM daily_focus WHERE is_deleted = false AND task_id IS NOT NULL)", ] avail_params = {} if search: avail_where.append("t.title ILIKE :search") avail_params["search"] = f"%{search}%" if domain_id: avail_where.append("t.domain_id = :domain_id") avail_params["domain_id"] = domain_id if area_id: avail_where.append("t.area_id = :area_id") avail_params["area_id"] = area_id if project_id: avail_where.append("t.project_id = :project_id") avail_params["project_id"] = project_id avail_sql = " AND ".join(avail_where) result = await db.execute(text(f""" SELECT t.id, t.title, t.priority, t.due_date, p.name as project_name, d.name as domain_name FROM tasks t LEFT JOIN projects p ON t.project_id = p.id LEFT JOIN domains d ON t.domain_id = d.id WHERE {avail_sql} ORDER BY t.priority ASC, t.due_date ASC NULLS LAST LIMIT 200 """), avail_params) available_tasks = [dict(r._mapping) for r in result] # --- Available list items --- available_list_items = [] if source_type == "list_items": li_where = [ "li.is_deleted = false", "li.completed = false", "l.is_deleted = false", "li.id NOT IN (SELECT list_item_id FROM daily_focus WHERE is_deleted = false AND list_item_id IS NOT NULL)", ] li_params = {} if search: li_where.append("li.content ILIKE :search") li_params["search"] = f"%{search}%" if domain_id: li_where.append("l.domain_id = :domain_id") li_params["domain_id"] = domain_id if area_id: li_where.append("l.area_id = :area_id") li_params["area_id"] = area_id if project_id: li_where.append("l.project_id = :project_id") li_params["project_id"] = project_id li_sql = " AND ".join(li_where) result = await db.execute(text(f""" SELECT li.id, li.content, li.list_id, l.name as list_name, d.name as domain_name FROM list_items li JOIN lists l ON li.list_id = l.id LEFT JOIN domains d ON l.domain_id = d.id WHERE {li_sql} ORDER BY l.name ASC, li.sort_order ASC LIMIT 200 """), li_params) available_list_items = [dict(r._mapping) for r in result] # Estimated total minutes total_est = sum(i.get("estimated_minutes") or 0 for i in items) # Filter options domains_repo = BaseRepository("domains", db) domains = await domains_repo.list() areas_repo = BaseRepository("areas", db) areas = await areas_repo.list() projects_repo = BaseRepository("projects", db) projects = await projects_repo.list() return templates.TemplateResponse("focus.html", { "request": request, "sidebar": sidebar, "items": items, "hierarchy": hierarchy, "available_tasks": available_tasks, "available_list_items": available_list_items, "total_estimated": total_est, "domains": domains, "areas": areas, "projects": projects, "current_domain_id": domain_id or "", "current_area_id": area_id or "", "current_project_id": project_id or "", "current_search": search or "", "current_source_type": source_type, "page_title": "Focus", "active_nav": "focus", }) @router.post("/add") async def add_to_focus( request: Request, task_id: Optional[str] = Form(None), list_item_id: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): repo = BaseRepository("daily_focus", db) # Get next sort order result = await db.execute(text(""" SELECT COALESCE(MAX(sort_order), 0) + 10 FROM daily_focus WHERE is_deleted = false """)) next_order = result.scalar() data = { "focus_date": date.today(), "sort_order": next_order, "completed": False, } if task_id: data["task_id"] = task_id if list_item_id: data["list_item_id"] = list_item_id await repo.create(data) return RedirectResponse(url="/focus", status_code=303) @router.post("/quick-add") async def quick_add_focus( request: Request, title: str = Form(...), quick_domain_id: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): repo = BaseRepository("daily_focus", db) result = await db.execute(text(""" SELECT COALESCE(MAX(sort_order), 0) + 10 FROM daily_focus WHERE is_deleted = false """)) next_order = result.scalar() data = { "focus_date": date.today(), "sort_order": next_order, "completed": False, "title": title.strip(), } if quick_domain_id: data["domain_id"] = quick_domain_id await repo.create(data) return RedirectResponse(url="/focus", status_code=303) @router.post("/reorder") async def reorder_focus( request: Request, item_id: str = Form(...), direction: str = Form(...), db: AsyncSession = Depends(get_db), ): repo = BaseRepository("daily_focus", db) await repo.move_in_order(item_id, direction) return RedirectResponse(url="/focus", status_code=303) @router.post("/reorder-all") async def reorder_all_focus( request: Request, item_ids: str = Form(...), db: AsyncSession = Depends(get_db), ): repo = BaseRepository("daily_focus", db) ids = [i.strip() for i in item_ids.split(",") if i.strip()] if ids: await repo.reorder(ids) return RedirectResponse(url="/focus", status_code=303) @router.get("/{focus_id}/edit") async def edit_focus_item(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)): repo = BaseRepository("daily_focus", db) item = await repo.get(focus_id) if not item or item.get("task_id") or item.get("list_item_id"): return RedirectResponse(url="/focus", status_code=303) sidebar = await get_sidebar_data(db) domains = await BaseRepository("domains", db).list() projects = await BaseRepository("projects", db).list() lists = await BaseRepository("lists", db).list() return templates.TemplateResponse("focus_edit.html", { "request": request, "sidebar": sidebar, "item": item, "domains": domains, "projects": projects, "lists": lists, "page_title": "Edit Focus Item", "active_nav": "focus", }) @router.post("/{focus_id}/edit") async def update_focus_item( focus_id: str, request: Request, title: str = Form(...), domain_id: Optional[str] = Form(None), project_id: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): repo = BaseRepository("daily_focus", db) await repo.update(focus_id, { "title": title.strip(), "domain_id": domain_id or None, "project_id": project_id or None, }) return RedirectResponse(url="/focus", status_code=303) @router.post("/{focus_id}/convert-to-task") async def convert_focus_to_task(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)): repo = BaseRepository("daily_focus", db) item = await repo.get(focus_id) if not item or not item.get("title"): return RedirectResponse(url="/focus", status_code=303) # Look up default domain if none set item_domain_id = item.get("domain_id") if not item_domain_id: result = await db.execute(text( "SELECT id FROM domains WHERE is_deleted = false ORDER BY sort_order LIMIT 1" )) item_domain_id = str(result.scalar()) # Create the task task_repo = BaseRepository("tasks", db) task = await task_repo.create({ "title": item["title"], "domain_id": item_domain_id, "project_id": item.get("project_id"), "status": "open", "priority": 3, }) # Update focus item to point to new task, clear standalone fields await db.execute(text(""" UPDATE daily_focus SET task_id = :task_id, title = NULL, domain_id = NULL, project_id = NULL, updated_at = now() WHERE id = :id """), {"task_id": task["id"], "id": focus_id}) await db.commit() return RedirectResponse(url=f"/tasks/{task['id']}/edit", status_code=303) @router.post("/{focus_id}/convert-to-note") async def convert_focus_to_note(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)): repo = BaseRepository("daily_focus", db) item = await repo.get(focus_id) if not item or not item.get("title"): return RedirectResponse(url="/focus", status_code=303) note_repo = BaseRepository("notes", db) note = await note_repo.create({ "title": item["title"], "domain_id": item.get("domain_id"), "project_id": item.get("project_id"), "body": "", "content_format": "rich", }) await repo.soft_delete(focus_id) return RedirectResponse(url=f"/notes/{note['id']}/edit", status_code=303) @router.post("/{focus_id}/convert-to-link") async def convert_focus_to_link(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)): repo = BaseRepository("daily_focus", db) item = await repo.get(focus_id) if not item or not item.get("title"): return RedirectResponse(url="/focus", status_code=303) link_repo = BaseRepository("links", db) link = await link_repo.create({ "label": item["title"], "url": "", "domain_id": item.get("domain_id"), "project_id": item.get("project_id"), }) await repo.soft_delete(focus_id) return RedirectResponse(url=f"/links/{link['id']}/edit", status_code=303) @router.post("/{focus_id}/convert-to-list-item") async def convert_focus_to_list_item( focus_id: str, request: Request, list_id: str = Form(...), db: AsyncSession = Depends(get_db), ): repo = BaseRepository("daily_focus", db) item = await repo.get(focus_id) if not item or not item.get("title"): return RedirectResponse(url="/focus", status_code=303) # Get next sort order in the list result = await db.execute(text(""" SELECT COALESCE(MAX(sort_order), 0) + 10 FROM list_items WHERE list_id = :list_id AND is_deleted = false """), {"list_id": list_id}) next_order = result.scalar() li_repo = BaseRepository("list_items", db) list_item = await li_repo.create({ "list_id": list_id, "content": item["title"], "completed": False, "sort_order": next_order, }) # Update focus item to point to the new list item await db.execute(text(""" UPDATE daily_focus SET list_item_id = :li_id, title = NULL, domain_id = NULL, project_id = NULL, updated_at = now() WHERE id = :id """), {"li_id": list_item["id"], "id": focus_id}) await db.commit() return RedirectResponse(url=f"/lists/{list_id}", status_code=303) @router.post("/{focus_id}/toggle") async def toggle_focus(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)): repo = BaseRepository("daily_focus", db) item = await repo.get(focus_id) if item: await repo.update(focus_id, {"completed": not item["completed"]}) if item["task_id"]: # Sync task status task_repo = BaseRepository("tasks", db) if not item["completed"]: await task_repo.update(item["task_id"], {"status": "done", "completed_at": datetime.now(timezone.utc)}) else: await task_repo.update(item["task_id"], {"status": "open", "completed_at": None}) elif item["list_item_id"]: # Sync list item completed status now = datetime.now(timezone.utc) if not item["completed"]: await db.execute(text( "UPDATE list_items SET completed = true, completed_at = :now, updated_at = :now WHERE id = :id" ), {"id": item["list_item_id"], "now": now}) else: await db.execute(text( "UPDATE list_items SET completed = false, completed_at = NULL, updated_at = :now WHERE id = :id" ), {"id": item["list_item_id"], "now": now}) await db.commit() return RedirectResponse(url="/focus", status_code=303) @router.post("/{focus_id}/remove") async def remove_from_focus(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)): repo = BaseRepository("daily_focus", db) await repo.soft_delete(focus_id) return RedirectResponse(url="/focus", status_code=303)