From 4fd2c915b6ddb86913289604f1781612d7834760 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 2 Mar 2026 22:00:44 -0500 Subject: [PATCH] Improve project/task/sub-task workflows across backend and web Backend: - notes.py: add parent_id filter to list_notes() - routes/notes.py: expose project_id, milestone_id, parent_id, type query params on GET /api/notes - milestones.py: add find_milestone_by_title() (cross-project search) - tools.py: list_notes project filter + updated_at; list_tasks milestone without project; tag_mode default add; fail-fast project resolution Web frontend: - TaskEditorView: add sub-tasks section (fetch, toggle, create inline); pre-fill projectId/milestoneId/parentId from URL query params on new task - ProjectView: add + button in Todo kanban column header linking to /tasks/new?projectId=X&milestoneId=Y for quick task creation Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/views/ProjectView.vue | 15 ++ frontend/src/views/TaskEditorView.vue | 195 ++++++++++++++++++++- src/fabledassistant/routes/notes.py | 15 +- src/fabledassistant/services/milestones.py | 12 ++ src/fabledassistant/services/notes.py | 5 + src/fabledassistant/services/tools.py | 28 ++- 6 files changed, 266 insertions(+), 4 deletions(-) diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue index 34509a2..4409972 100644 --- a/frontend/src/views/ProjectView.vue +++ b/frontend/src/views/ProjectView.vue @@ -474,6 +474,11 @@ function formatDate(dateStr?: string | null): string {
Todo {{ group.tasks.filter(t => t.status === 'todo').length }} + +
| null = null; const dirty = ref(false); const saving = ref(false); + +// Sub-tasks +interface SubTask { id: number; title: string; status: string } +const subTasks = ref([]); +const subTasksLoading = ref(false); +const addingSubTask = ref(false); +const newSubTaskTitle = ref(""); + +async function loadSubTasks() { + if (!taskId.value) return; + subTasksLoading.value = true; + try { + const data = await apiGet<{ notes: SubTask[] }>( + `/api/notes?parent_id=${taskId.value}&is_task=true&limit=100` + ); + subTasks.value = data.notes; + } catch { + // silent + } finally { + subTasksLoading.value = false; + } +} + +async function createSubTask() { + const title = newSubTaskTitle.value.trim(); + if (!title || !taskId.value) return; + try { + const created = await apiPost("/api/notes", { + title, + status: "todo", + priority: "none", + project_id: projectId.value, + parent_id: taskId.value, + }); + subTasks.value = [...subTasks.value, created]; + newSubTaskTitle.value = ""; + addingSubTask.value = false; + } catch { + toast.show("Failed to create sub-task", "error"); + } +} + +async function toggleSubTask(sub: SubTask) { + const newStatus = sub.status === "done" ? "todo" : "done"; + try { + await apiPatch(`/api/notes/${sub.id}`, { status: newStatus }); + sub.status = newStatus; + } catch { + toast.show("Failed to update sub-task", "error"); + } +} const showPreview = ref(false); const editorRef = ref | null>(null); const tiptapEditor = computed(() => { @@ -255,6 +306,12 @@ onMounted(async () => { savedMilestoneId = milestoneId.value; savedParentId = parentId.value; } + loadSubTasks(); + } else { + // Pre-fill from query params when creating a new task + if (route.query.projectId) projectId.value = Number(route.query.projectId); + if (route.query.milestoneId) milestoneId.value = Number(route.query.milestoneId); + if (route.query.parentId) parentId.value = Number(route.query.parentId); } }); @@ -479,6 +536,41 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
+ +
+
+ Sub-tasks + +
+
Loading...
+ +
+ window.removeEventListener("beforeunload", onBeforeUnload)); cursor: default; } .parent-empty:hover { background: none; } + +/* Sub-tasks */ +.subtasks-section { + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: 0.6rem 0.75rem; + background: var(--color-bg-secondary); +} +.subtasks-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.4rem; +} +.subtasks-label { + font-size: 0.8rem; + font-weight: 600; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.btn-add-subtask { + background: none; + border: none; + cursor: pointer; + color: var(--color-primary); + font-size: 0.8rem; + font-family: inherit; + padding: 0.1rem 0.3rem; +} +.btn-add-subtask:hover { opacity: 0.8; } +.subtasks-loading { + font-size: 0.8rem; + color: var(--color-text-muted); + padding: 0.25rem 0; +} +.subtask-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.25rem 0; +} +.subtask-checkbox { flex-shrink: 0; cursor: pointer; } +.subtask-title { + font-size: 0.875rem; + color: var(--color-text); + text-decoration: none; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.subtask-title:hover { color: var(--color-primary); } +.subtask-title.done { + text-decoration: line-through; + color: var(--color-text-muted); +} +.subtasks-empty { + font-size: 0.8rem; + color: var(--color-text-muted); + margin: 0; + padding: 0.25rem 0; +} +.subtask-add-row { + display: flex; + align-items: center; + gap: 0.35rem; + margin-top: 0.35rem; +} +.subtask-input { + flex: 1; + padding: 0.3rem 0.5rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-bg); + color: var(--color-text); + font-size: 0.875rem; + font-family: inherit; +} +.subtask-input:focus { outline: none; border-color: var(--color-primary); } +.btn-subtask-confirm { + padding: 0.3rem 0.6rem; + background: var(--color-primary); + color: #fff; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.8rem; + font-family: inherit; +} +.btn-subtask-confirm:disabled { opacity: 0.5; cursor: default; } +.btn-subtask-cancel { + padding: 0.3rem 0.6rem; + background: none; + border: 1px solid var(--color-border); + color: var(--color-text-secondary); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.8rem; + font-family: inherit; +} \ No newline at end of file diff --git a/src/fabledassistant/routes/notes.py b/src/fabledassistant/routes/notes.py index d52f88a..b507359 100644 --- a/src/fabledassistant/routes/notes.py +++ b/src/fabledassistant/routes/notes.py @@ -54,8 +54,21 @@ async def list_notes_route(): elif request.args.get("is_task", "").lower() == "true": is_task = True + project_id = request.args.get("project_id", type=int) + milestone_id = request.args.get("milestone_id", type=int) + parent_id = request.args.get("parent_id", type=int) + + # type= shorthand used by web frontend (?type=task or ?type=note) + type_param = request.args.get("type") + if type_param == "task": + is_task = True + elif type_param == "note": + is_task = False + notes, total = await list_notes( - uid, q=q, tags=tag or None, is_task=is_task, sort=sort, order=order, limit=limit, offset=offset + uid, q=q, tags=tag or None, is_task=is_task, sort=sort, order=order, + limit=limit, offset=offset, + project_id=project_id, milestone_id=milestone_id, parent_id=parent_id, ) return jsonify({"notes": [n.to_dict() for n in notes], "total": total}) diff --git a/src/fabledassistant/services/milestones.py b/src/fabledassistant/services/milestones.py index bc5de4b..f4d2ebf 100644 --- a/src/fabledassistant/services/milestones.py +++ b/src/fabledassistant/services/milestones.py @@ -53,6 +53,18 @@ async def get_milestone_by_title(user_id: int, project_id: int, title: str) -> M return result.scalars().first() +async def find_milestone_by_title(user_id: int, title: str) -> Milestone | None: + """Find a milestone by title across ALL projects for this user (case-insensitive).""" + async with async_session() as session: + result = await session.execute( + select(Milestone).where( + Milestone.user_id == user_id, + func.lower(Milestone.title) == func.lower(title.strip()), + ).order_by(Milestone.id).limit(1) + ) + return result.scalars().first() + + async def get_or_create_milestone(user_id: int, project_id: int, title: str) -> Milestone: milestone = await get_milestone_by_title(user_id, project_id, title) if milestone: diff --git a/src/fabledassistant/services/notes.py b/src/fabledassistant/services/notes.py index d8894e1..547d66d 100644 --- a/src/fabledassistant/services/notes.py +++ b/src/fabledassistant/services/notes.py @@ -71,6 +71,7 @@ async def list_notes( due_after: date | None = None, project_id: int | None = None, milestone_id: int | None = None, + parent_id: int | None = None, sort: str = "updated_at", order: str = "desc", limit: int = 50, @@ -131,6 +132,10 @@ async def list_notes( query = query.where(Note.milestone_id == milestone_id) count_query = count_query.where(Note.milestone_id == milestone_id) + if parent_id is not None: + query = query.where(Note.parent_id == parent_id) + count_query = count_query.where(Note.parent_id == parent_id) + sort_col = getattr(Note, sort, Note.updated_at) if order == "asc": query = query.order_by(sort_col.asc()) diff --git a/src/fabledassistant/services/tools.py b/src/fabledassistant/services/tools.py index 25d83ff..8f06c78 100644 --- a/src/fabledassistant/services/tools.py +++ b/src/fabledassistant/services/tools.py @@ -216,7 +216,7 @@ _CORE_TOOLS = [ "name": "list_notes", "description": ( "Browse the user's notes (not tasks). Use this when the user asks to see, list, or browse " - "their notes — by recency, keyword, or tag. For tasks, use list_tasks instead." + "their notes — by recency, keyword, tag, or project. For tasks, use list_tasks instead." ), "parameters": { "type": "object", @@ -230,6 +230,10 @@ _CORE_TOOLS = [ "items": {"type": "string"}, "description": "Filter notes that have ALL of these tags", }, + "project": { + "type": "string", + "description": "Filter notes by project name", + }, "sort": { "type": "string", "enum": ["updated_at", "created_at", "title"], @@ -904,6 +908,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict: project_id = None milestone_id = None project_name = arguments.get("project") + milestone_name = arguments.get("milestone") if project_name: from fabledassistant.services.projects import get_project_by_title, list_projects as _lp proj = await get_project_by_title(user_id, project_name) @@ -914,12 +919,17 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict: proj = matches[0] if proj: project_id = proj.id - milestone_name = arguments.get("milestone") if milestone_name: from fabledassistant.services.milestones import get_milestone_by_title ms = await get_milestone_by_title(user_id, proj.id, milestone_name) if ms: milestone_id = ms.id + elif milestone_name: + # No project specified — search milestone by name across all projects + from fabledassistant.services.milestones import find_milestone_by_title + ms = await find_milestone_by_title(user_id, milestone_name) + if ms: + milestone_id = ms.id notes, total = await list_notes( user_id=user_id, is_task=True, @@ -1097,11 +1107,24 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict: elif tool_name == "list_notes": tags_raw = arguments.get("tags") tags = tags_raw if isinstance(tags_raw, list) else None + project_id = None + project_name = arguments.get("project") + if project_name: + from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp + proj = await _gpbt(user_id, project_name) + if proj is None: + all_p = await _lp(user_id) + matches = [p for p in all_p if project_name.lower() in p.title.lower()] + proj = matches[0] if matches else None + if proj is None: + return {"success": False, "error": f"Project '{project_name}' not found."} + project_id = proj.id notes, total = await list_notes( user_id=user_id, q=arguments.get("q") or arguments.get("query"), tags=tags, is_task=False, + project_id=project_id, sort=arguments.get("sort", "updated_at"), order="desc", limit=int(arguments.get("limit", 10)), @@ -1111,6 +1134,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict: "id": n.id, "title": n.title, "tags": n.tags or [], + "updated_at": n.updated_at.isoformat(), "preview": (n.body[:200] + "...") if n.body and len(n.body) > 200 else (n.body or ""), } for n in notes