Files
lifeos-dev/routers/focus.py

281 lines
11 KiB
Python

"""Daily Focus: date-scoped task/list-item commitment list."""
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
router = APIRouter(prefix="/focus", tags=["focus"])
templates = Jinja2Templates(directory="templates")
@router.get("/")
async def focus_view(
request: Request,
focus_date: Optional[str] = None,
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)
target_date = date.fromisoformat(focus_date) if focus_date else date.today()
if not source_type:
source_type = "tasks"
# --- Focus items (both tasks and list items) ---
result = await db.execute(text("""
SELECT df.*,
t.title as title, t.priority, t.status as task_status,
t.project_id, t.due_date, t.estimated_minutes,
COALESCE(p.name, lp.name) as project_name,
COALESCE(t.project_id, l.project_id) as effective_project_id,
COALESCE(d.name, ld.name) as domain_name,
COALESCE(d.color, ld.color) as domain_color,
COALESCE(d.id, ld.id) as effective_domain_id,
COALESCE(a.name, la.name) as area_name,
COALESCE(a.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 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
WHERE df.focus_date = :target_date AND df.is_deleted = false
ORDER BY df.sort_order, df.created_at
"""), {"target_date": target_date})
items = [dict(r._mapping) for r in result]
# Build Domain > Area > Project hierarchy
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] = {"label": dl, "color": dc, "areas": OrderedDict()}
ak = item.get("effective_area_id") or "__none__"
al = item.get("area_name") or ""
area_map = domain_map[dk]["areas"]
if ak not in area_map:
area_map[ak] = {"label": al, "projects": OrderedDict()}
pk = item.get("effective_project_id") or "__none__"
pl = item.get("project_name") or ""
proj_map = area_map[ak]["projects"]
if pk not in proj_map:
proj_map[pk] = {"label": pl, "rows": []}
proj_map[pk]["rows"].append(item)
# Convert to nested lists for Jinja
hierarchy = []
for dk, dv in domain_map.items():
domain_group = {"label": dv["label"], "color": dv["color"], "areas": []}
for ak, av in dv["areas"].items():
area_group = {"label": av["label"], "projects": []}
for pk, pv in av["projects"].items():
area_group["projects"].append({"label": pv["label"], "rows": pv["rows"]})
domain_group["areas"].append(area_group)
hierarchy.append(domain_group)
# --- 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 focus_date = :target_date AND is_deleted = false AND task_id IS NOT NULL)",
]
avail_params = {"target_date": target_date}
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 focus_date = :target_date AND is_deleted = false AND list_item_id IS NOT NULL)",
]
li_params = {"target_date": target_date}
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,
"focus_date": target_date,
"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": "Daily Focus", "active_nav": "focus",
})
@router.post("/add")
async def add_to_focus(
request: Request,
focus_date: str = Form(...),
task_id: Optional[str] = Form(None),
list_item_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("daily_focus", db)
parsed_date = date.fromisoformat(focus_date)
# Get next sort order
result = await db.execute(text("""
SELECT COALESCE(MAX(sort_order), 0) + 10 FROM daily_focus
WHERE focus_date = :fd AND is_deleted = false
"""), {"fd": parsed_date})
next_order = result.scalar()
data = {
"focus_date": parsed_date,
"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=f"/focus?focus_date={focus_date}", status_code=303)
@router.post("/reorder")
async def reorder_focus(
request: Request,
item_id: str = Form(...),
direction: str = Form(...),
focus_date: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("daily_focus", db)
parsed_date = date.fromisoformat(focus_date)
await repo.move_in_order(item_id, direction, filters={"focus_date": parsed_date})
return RedirectResponse(url=f"/focus?focus_date={focus_date}", 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()
focus_date = item["focus_date"] if item else date.today()
return RedirectResponse(url=f"/focus?focus_date={focus_date}", 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)
item = await repo.get(focus_id)
await repo.soft_delete(focus_id)
focus_date = item["focus_date"] if item else date.today()
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)