feat: standalone focus items with edit, convert, project tab, domain ordering

- Add standalone text line items to focus (quick-add with optional domain)
- Edit page for standalone items (title, domain, project)
- Convert standalone items to task, note, link, or list item
- Focus tab on project detail page showing assigned focus items
- Sort domain groups: General first, then by domain sort_order
- Add domain_id and title to nullable_fields in BaseRepository

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 02:14:31 +00:00
parent 6aa36c570e
commit a61248b67d
6 changed files with 319 additions and 11 deletions

View File

@@ -35,13 +35,13 @@ async def focus_view(
# --- All active focus 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,
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,
@@ -58,6 +58,8 @@ async def focus_view(
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)
@@ -76,7 +78,9 @@ async def focus_view(
domain_map[dk] = {"key": dk, "label": dl, "color": dc, "rows": []}
domain_map[dk]["rows"].append(item)
hierarchy = list(domain_map.values())
# 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 = []
@@ -207,6 +211,33 @@ async def add_to_focus(
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,
@@ -232,6 +263,154 @@ async def reorder_all_focus(
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)

View File

@@ -251,6 +251,21 @@ async def project_detail(
"""))
all_meetings = [dict(r._mapping) for r in result]
elif tab == "focus":
result = await db.execute(text("""
SELECT df.*,
COALESCE(d.name, sd.name) as domain_name,
COALESCE(d.color, sd.color) as domain_color
FROM daily_focus df
LEFT JOIN domains d ON df.domain_id = d.id
LEFT JOIN domains sd ON df.domain_id = sd.id
WHERE df.is_deleted = false
AND df.title IS NOT NULL
AND df.project_id = :pid
ORDER BY df.sort_order, df.created_at
"""), {"pid": project_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "contacts":
result = await db.execute(text("""
SELECT c.*, cp.role, cp.created_at as linked_at
@@ -276,6 +291,7 @@ async def project_detail(
("decisions", "SELECT count(*) FROM decisions d JOIN decision_projects dp ON dp.decision_id = d.id WHERE dp.project_id = :pid AND d.is_deleted = false"),
("meetings", "SELECT count(*) FROM meetings m JOIN project_meetings pm ON pm.meeting_id = m.id WHERE pm.project_id = :pid AND m.is_deleted = false"),
("contacts", "SELECT count(*) FROM contacts c JOIN contact_projects cp ON cp.contact_id = c.id WHERE cp.project_id = :pid AND c.is_deleted = false"),
("focus", "SELECT count(*) FROM daily_focus WHERE project_id = :pid AND title IS NOT NULL AND is_deleted = false"),
]:
result = await db.execute(text(count_sql), {"pid": project_id})
counts[count_tab] = result.scalar() or 0