Files
lifeos-dev/routers/focus.py
Michael 590f019ca7 feat: focus priority, focus links, task list assignment, lists drag-and-drop, URL display fixes
- Focus page: turn sequence number into persistent editable priority (focus_priority column)
- Focus detail: add links section (add existing, create new, unlink) via focus_links junction table
- Focus detail: add copy and inline edit for checklist items
- Task detail lists tab: add existing list assignment and unlink actions
- Lists page: add drag-and-drop reorder support
- Links/bookmarks pages: remove artificial URL truncation, use CSS ellipsis

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:19:06 +00:00

679 lines
25 KiB
Python

"""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.focus_priority ASC NULLS LAST, 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)
async def _ensure_focus_note_and_list(focus_id: str, item: dict, db: AsyncSession):
"""Find or create the single note + list attached to a focus item."""
note_repo = BaseRepository("notes", db)
list_repo = BaseRepository("lists", db)
# Find existing note
result = await db.execute(text(
"SELECT id FROM notes WHERE focus_id = :fid AND is_deleted = false LIMIT 1"
), {"fid": focus_id})
note_id = result.scalar()
if not note_id:
note = await note_repo.create({
"title": f'Notes: {item["title"]}',
"focus_id": focus_id,
"domain_id": item.get("domain_id"),
"project_id": item.get("project_id"),
"body": "",
"content_format": "rich",
})
note_id = note["id"]
# Find existing list
result = await db.execute(text(
"SELECT id FROM lists WHERE focus_id = :fid AND is_deleted = false LIMIT 1"
), {"fid": focus_id})
list_id = result.scalar()
if not list_id:
lst = await list_repo.create({
"name": f'List: {item["title"]}',
"focus_id": focus_id,
"domain_id": item.get("domain_id"),
"project_id": item.get("project_id"),
"list_type": "checklist",
})
list_id = lst["id"]
return str(note_id), str(list_id)
@router.get("/{focus_id}")
async def focus_detail(
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)
# Domain / 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
# Lists for convert-to-list-item
all_lists = await BaseRepository("lists", db).list()
# Ensure note + list exist
note_id, list_id = await _ensure_focus_note_and_list(focus_id, item, db)
# Load note
note = await BaseRepository("notes", db).get(note_id)
# Load list items
result = await db.execute(text("""
SELECT * FROM list_items
WHERE list_id = :lid AND is_deleted = false
ORDER BY sort_order, created_at
"""), {"lid": list_id})
list_items = [dict(r._mapping) for r in result]
# Load linked links
result = await db.execute(text("""
SELECT l.*, fl.role
FROM links l JOIN focus_links fl ON fl.link_id = l.id
WHERE fl.focus_id = :fid AND l.is_deleted = false
ORDER BY fl.created_at
"""), {"fid": focus_id})
linked_links = [dict(r._mapping) for r in result]
# All links for the "add existing" dropdown
all_links = await BaseRepository("links", db).list()
return templates.TemplateResponse("focus_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"domain": domain, "project": project, "all_lists": all_lists,
"note": note, "list_id": list_id, "list_items": list_items,
"linked_links": linked_links, "all_links": all_links,
"page_title": item.get("title", "Focus Item"), "active_nav": "focus",
})
@router.post("/{focus_id}/save-note")
async def save_focus_note(
focus_id: str, request: Request,
note_id: str = Form(...),
body: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
note_repo = BaseRepository("notes", db)
await note_repo.update(note_id, {"body": body or ""})
return RedirectResponse(url="/focus", status_code=303)
@router.post("/{focus_id}/list-item/add")
async def add_focus_list_item(
focus_id: str, request: Request,
list_id: str = Form(...),
content: str = Form(...),
db: AsyncSession = Depends(get_db),
):
li_repo = BaseRepository("list_items", db)
await li_repo.create({"list_id": list_id, "content": content, "completed": False})
return RedirectResponse(url=f"/focus/{focus_id}", status_code=303)
@router.post("/{focus_id}/list-item/{item_id}/toggle")
async def toggle_focus_list_item(
focus_id: str, item_id: str, request: Request,
db: AsyncSession = Depends(get_db),
):
li_repo = BaseRepository("list_items", db)
item = await li_repo.get(item_id)
if item:
now = datetime.now(timezone.utc)
if item["completed"]:
await li_repo.update(item_id, {"completed": False, "completed_at": None})
else:
await li_repo.update(item_id, {"completed": True, "completed_at": now})
return RedirectResponse(url=f"/focus/{focus_id}", status_code=303)
@router.post("/{focus_id}/list-item/{item_id}/edit")
async def edit_focus_list_item(
focus_id: str, item_id: str, request: Request,
content: str = Form(...),
db: AsyncSession = Depends(get_db),
):
li_repo = BaseRepository("list_items", db)
await li_repo.update(item_id, {"content": content.strip()})
return RedirectResponse(url=f"/focus/{focus_id}", status_code=303)
@router.post("/{focus_id}/list-item/{item_id}/delete")
async def delete_focus_list_item(
focus_id: str, item_id: str, request: Request,
db: AsyncSession = Depends(get_db),
):
li_repo = BaseRepository("list_items", db)
await li_repo.soft_delete(item_id)
return RedirectResponse(url=f"/focus/{focus_id}", status_code=303)
@router.post("/{focus_id}/list-item/reorder-all")
async def reorder_focus_list_items(
focus_id: str, request: Request,
item_ids: str = Form(...),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("list_items", db)
ids = [i.strip() for i in item_ids.split(",") if i.strip()]
if ids:
await repo.reorder(ids)
return RedirectResponse(url=f"/focus/{focus_id}", status_code=303)
@router.get("/{focus_id}/edit")
async def edit_focus_item(focus_id: str, request: Request, from_project: Optional[str] = None, 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",
"from_project": from_project or "",
})
@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),
from_project: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("daily_focus", db)
new_project = project_id if project_id and project_id.strip() else None
await repo.update(focus_id, {
"title": title.strip(),
"domain_id": domain_id or None,
"project_id": new_project,
})
if from_project and from_project.strip():
if new_project and new_project != from_project:
return RedirectResponse(url=f"/projects/{new_project}?tab=focus", status_code=303)
return RedirectResponse(url=f"/projects/{from_project}?tab=focus", status_code=303)
return RedirectResponse(url=f"/focus/{focus_id}", 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()
referer = request.headers.get("referer", "/focus")
return RedirectResponse(url=referer, status_code=303)
@router.post("/{focus_id}/set-priority")
async def set_focus_priority(focus_id: str, request: Request, focus_priority: Optional[str] = Form(None), db: AsyncSession = Depends(get_db)):
repo = BaseRepository("daily_focus", db)
val = None
if focus_priority and focus_priority.strip():
try:
val = int(focus_priority.strip())
except ValueError:
pass
await repo.update(focus_id, {"focus_priority": val})
return RedirectResponse(url="/focus", status_code=303)
@router.post("/{focus_id}/links/add")
async def add_focus_link(
focus_id: str,
link_id: str = Form(...),
role: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
INSERT INTO focus_links (focus_id, link_id, role)
VALUES (:fid, :lid, :role) ON CONFLICT DO NOTHING
"""), {"fid": focus_id, "lid": link_id, "role": role if role and role.strip() else None})
return RedirectResponse(url=f"/focus/{focus_id}", status_code=303)
@router.post("/{focus_id}/links/{link_id}/remove")
async def remove_focus_link(
focus_id: str, link_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text(
"DELETE FROM focus_links WHERE focus_id = :fid AND link_id = :lid"
), {"fid": focus_id, "lid": link_id})
return RedirectResponse(url=f"/focus/{focus_id}", status_code=303)
@router.post("/{focus_id}/toggle-critical")
async def toggle_critical(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, {"critical": not item.get("critical", False)})
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)