From a61248b67d5c6f33810508cb8335ed74de392480 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 4 Mar 2026 02:14:31 +0000 Subject: [PATCH] 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 --- core/base_repository.py | 1 + routers/focus.py | 195 ++++++++++++++++++++++++++++++++-- routers/projects.py | 16 +++ templates/focus.html | 18 +++- templates/focus_edit.html | 85 +++++++++++++++ templates/project_detail.html | 15 +++ 6 files changed, 319 insertions(+), 11 deletions(-) create mode 100644 templates/focus_edit.html diff --git a/core/base_repository.py b/core/base_repository.py index a5e4c05..79e5ecb 100644 --- a/core/base_repository.py +++ b/core/base_repository.py @@ -162,6 +162,7 @@ class BaseRepository: "contact_id", "started_at", "weekly_hours", "effective_from", "task_id", "meeting_id", "list_item_id", + "domain_id", "title", } clean_data = {} for k, v in data.items(): diff --git a/routers/focus.py b/routers/focus.py index 4b4a088..6c7b3bd 100644 --- a/routers/focus.py +++ b/routers/focus.py @@ -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) diff --git a/routers/projects.py b/routers/projects.py index 8dbd6d2..977a0ee 100644 --- a/routers/projects.py +++ b/routers/projects.py @@ -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 diff --git a/templates/focus.html b/templates/focus.html index 669b02e..b69db59 100644 --- a/templates/focus.html +++ b/templates/focus.html @@ -7,6 +7,18 @@ + +
+ + + +
+ {% if items %}
@@ -39,10 +51,10 @@ {{ item.due_date or '' }} - {% if item.task_id %}{% elif item.list_item_id %}{% endif %} - {% if item.task_id %}{% if item.title %}{{ item.title }}{% else %}[Deleted]{% endif %}{% elif item.list_item_id %}{% if item.list_item_content %}{{ item.list_item_content }}{% else %}[Deleted]{% endif %}{% endif %} + {% if item.task_id %}{% elif item.list_item_id %}{% else %}{% endif %} + {% if item.task_id %}{% if item.task_title %}{{ item.task_title }}{% else %}[Deleted]{% endif %}{% elif item.list_item_id %}{% if item.list_item_content %}{{ item.list_item_content }}{% else %}[Deleted]{% endif %}{% else %}{{ item.title }}{% endif %} {% if item.area_name %}{{ item.area_name }}{% endif %} - {% if item.task_id and item.project_name %}{{ item.project_name }}{% elif item.list_item_id and item.list_name %}{{ item.list_name }}{% endif %} + {% if item.project_name %}{{ item.project_name }}{% elif item.list_item_id and item.list_name %}{{ item.list_name }}{% endif %} {{ '~%smin'|format(item.estimated_minutes) if item.estimated_minutes else '' }}
diff --git a/templates/focus_edit.html b/templates/focus_edit.html new file mode 100644 index 0000000..4c38d5a --- /dev/null +++ b/templates/focus_edit.html @@ -0,0 +1,85 @@ +{% extends "base.html" %} +{% block content %} + + + + +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + Cancel +
+
+ +
+ +
+
+

Convert to...

+
+
+ +
+
+ +
+
+ +
+
+ + +
+
+
+
+ +
+
+ +
+
+{% endblock %} diff --git a/templates/project_detail.html b/templates/project_detail.html index e0e1cab..b707769 100644 --- a/templates/project_detail.html +++ b/templates/project_detail.html @@ -40,6 +40,7 @@ Lists{% if counts.lists %} ({{ counts.lists }}){% endif %} Decisions{% if counts.decisions %} ({{ counts.decisions }}){% endif %} Meetings{% if counts.meetings %} ({{ counts.meetings }}){% endif %} + Focus{% if counts.focus %} ({{ counts.focus }}){% endif %} Processes Contacts{% if counts.contacts %} ({{ counts.contacts }}){% endif %}
@@ -162,6 +163,20 @@
No meetings linked to this project
{% endfor %} +{% elif tab == 'focus' %} +{% for f in tab_data %} +
+ {{ f.title }} + {% if f.domain_name %}{{ f.domain_name }}{% endif %} + {{ f.created_at.strftime('%Y-%m-%d') if f.created_at else '' }} +
+ Edit +
+
+{% else %} +
No focus items assigned to this project
+{% endfor %} + {% elif tab == 'processes' %}
Process management coming soon