feat: focus item detail page with inline note + checklist

Each standalone focus item now auto-creates a linked note and checklist.
Clicking a focus item opens a detail page with side-by-side note editor
(left) and checklist (right) with drag-to-reorder. Save & Return writes
the note and goes back to the focus list. Added focus_id FK to notes and
lists tables, made domain optional when creating from focus context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 20:02:27 +00:00
parent a2183af6e2
commit 6abef336c4
9 changed files with 365 additions and 17 deletions

View File

@@ -263,6 +263,158 @@ async def reorder_all_focus(
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]
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,
"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}/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, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("daily_focus", db)
@@ -294,7 +446,7 @@ async def update_focus_item(
"domain_id": domain_id or None,
"project_id": project_id or None,
})
return RedirectResponse(url="/focus", status_code=303)
return RedirectResponse(url=f"/focus/{focus_id}", status_code=303)
@router.post("/{focus_id}/convert-to-task")
@@ -436,7 +588,8 @@ async def toggle_focus(focus_id: str, request: Request, db: AsyncSession = Depen
"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)
referer = request.headers.get("referer", "/focus")
return RedirectResponse(url=referer, status_code=303)
@router.post("/{focus_id}/remove")

View File

@@ -74,6 +74,7 @@ async def create_form(
project_id: Optional[str] = None,
task_id: Optional[str] = None,
meeting_id: Optional[str] = None,
focus_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
@@ -93,6 +94,7 @@ async def create_form(
"prefill_project_id": project_id or "",
"prefill_task_id": task_id or "",
"prefill_meeting_id": meeting_id or "",
"prefill_focus_id": focus_id or "",
})
@@ -100,11 +102,12 @@ async def create_form(
async def create_list(
request: Request,
name: str = Form(...),
domain_id: str = Form(...),
domain_id: Optional[str] = Form(None),
area_id: Optional[str] = Form(None),
project_id: Optional[str] = Form(None),
task_id: Optional[str] = Form(None),
meeting_id: Optional[str] = Form(None),
focus_id: Optional[str] = Form(None),
list_type: str = Form("checklist"),
description: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
@@ -112,10 +115,12 @@ async def create_list(
):
repo = BaseRepository("lists", db)
data = {
"name": name, "domain_id": domain_id,
"name": name,
"list_type": list_type,
"description": description,
}
if domain_id and domain_id.strip():
data["domain_id"] = domain_id
if area_id and area_id.strip():
data["area_id"] = area_id
if project_id and project_id.strip():
@@ -124,6 +129,8 @@ async def create_list(
data["task_id"] = task_id
if meeting_id and meeting_id.strip():
data["meeting_id"] = meeting_id
if focus_id and focus_id.strip():
data["focus_id"] = focus_id
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
@@ -132,6 +139,8 @@ async def create_list(
return RedirectResponse(url=f"/tasks/{task_id}?tab=lists", status_code=303)
if meeting_id and meeting_id.strip():
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=lists", status_code=303)
if focus_id and focus_id.strip():
return RedirectResponse(url=f"/focus/{focus_id}?tab=lists", status_code=303)
return RedirectResponse(url=f"/lists/{new_list['id']}", status_code=303)

View File

@@ -63,6 +63,7 @@ async def create_form(
project_id: Optional[str] = None,
task_id: Optional[str] = None,
meeting_id: Optional[str] = None,
focus_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
@@ -79,6 +80,7 @@ async def create_form(
"prefill_project_id": project_id or "",
"prefill_task_id": task_id or "",
"prefill_meeting_id": meeting_id or "",
"prefill_focus_id": focus_id or "",
})
@@ -86,10 +88,11 @@ async def create_form(
async def create_note(
request: Request,
title: str = Form(...),
domain_id: str = Form(...),
domain_id: Optional[str] = Form(None),
project_id: Optional[str] = Form(None),
task_id: Optional[str] = Form(None),
meeting_id: Optional[str] = Form(None),
focus_id: Optional[str] = Form(None),
body: Optional[str] = Form(None),
content_format: str = Form("rich"),
tags: Optional[str] = Form(None),
@@ -97,15 +100,19 @@ async def create_note(
):
repo = BaseRepository("notes", db)
data = {
"title": title, "domain_id": domain_id,
"title": title,
"body": body, "content_format": content_format,
}
if domain_id and domain_id.strip():
data["domain_id"] = domain_id
if project_id and project_id.strip():
data["project_id"] = project_id
if task_id and task_id.strip():
data["task_id"] = task_id
if meeting_id and meeting_id.strip():
data["meeting_id"] = meeting_id
if focus_id and focus_id.strip():
data["focus_id"] = focus_id
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
note = await repo.create(data)
@@ -113,6 +120,8 @@ async def create_note(
return RedirectResponse(url=f"/tasks/{task_id}?tab=notes", status_code=303)
if meeting_id and meeting_id.strip():
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=notes", status_code=303)
if focus_id and focus_id.strip():
return RedirectResponse(url=f"/focus/{focus_id}?tab=notes", status_code=303)
return RedirectResponse(url=f"/notes/{note['id']}", status_code=303)