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 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 22:00:44 -05:00
parent 6580d16942
commit 4fd2c915b6
6 changed files with 266 additions and 4 deletions
+15
View File
@@ -474,6 +474,11 @@ function formatDate(dateStr?: string | null): string {
<div class="kanban-col-header">
<span>Todo</span>
<span class="col-count">{{ group.tasks.filter(t => t.status === 'todo').length }}</span>
<router-link
:to="`/tasks/new?projectId=${projectId}${group.milestone ? '&milestoneId=' + group.milestone.id : ''}`"
class="col-add-btn"
title="Add task"
>+</router-link>
</div>
<div class="kanban-cards">
<router-link
@@ -969,6 +974,16 @@ function formatDate(dateStr?: string | null): string {
color: var(--color-text-muted);
font-weight: 600;
}
.col-add-btn {
margin-left: auto;
color: var(--color-text-muted);
text-decoration: none;
font-size: 1rem;
line-height: 1;
padding: 0 0.15rem;
border-radius: var(--radius-sm);
}
.col-add-btn:hover { color: var(--color-primary); }
.kanban-cards {
display: flex;
flex-direction: column;
+194 -1
View File
@@ -6,7 +6,7 @@ import { useNotesStore } from "@/stores/notes";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
import { useAssist } from "@/composables/useAssist";
import { apiPost, apiGet } from "@/api/client";
import { apiPost, apiGet, apiPatch } from "@/api/client";
import type { TaskStatus, TaskPriority } from "@/types/task";
import type { Editor } from "@tiptap/vue-3";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
@@ -38,6 +38,57 @@ const showParentDropdown = ref(false);
let parentSearchTimer: ReturnType<typeof setTimeout> | null = null;
const dirty = ref(false);
const saving = ref(false);
// Sub-tasks
interface SubTask { id: number; title: string; status: string }
const subTasks = ref<SubTask[]>([]);
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<SubTask>("/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<InstanceType<typeof TiptapEditor> | null>(null);
const tiptapEditor = computed<Editor | null>(() => {
@@ -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));
</div>
</div>
<!-- Sub-tasks (only when editing an existing task) -->
<div v-if="isEditing" class="subtasks-section">
<div class="subtasks-header">
<span class="subtasks-label">Sub-tasks</span>
<button class="btn-add-subtask" @click="addingSubTask = !addingSubTask">+ Add</button>
</div>
<div v-if="subTasksLoading" class="subtasks-loading">Loading...</div>
<template v-else>
<div v-for="sub in subTasks" :key="sub.id" class="subtask-row">
<input
type="checkbox"
:checked="sub.status === 'done'"
@change="toggleSubTask(sub)"
class="subtask-checkbox"
/>
<router-link :to="`/tasks/${sub.id}`" :class="['subtask-title', { done: sub.status === 'done' }]">
{{ sub.title || "Untitled" }}
</router-link>
</div>
<p v-if="!subTasks.length && !addingSubTask" class="subtasks-empty">No sub-tasks yet.</p>
<div v-if="addingSubTask" class="subtask-add-row">
<input
v-model="newSubTaskTitle"
class="subtask-input"
placeholder="Sub-task title"
@keydown.enter="createSubTask"
@keydown.escape="addingSubTask = false; newSubTaskTitle = ''"
autofocus
/>
<button class="btn-subtask-confirm" @click="createSubTask" :disabled="!newSubTaskTitle.trim()">Add</button>
<button class="btn-subtask-cancel" @click="addingSubTask = false; newSubTaskTitle = ''">Cancel</button>
</div>
</template>
</div>
<TagInput
v-model="tags"
:fetchTags="(q: string) => notesStore.fetchAllTags(q)"
@@ -729,4 +821,105 @@ onUnmounted(() => 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;
}
</style>