Add subtasks feature to project tasks

Backend:
- Add self-referencing parent_task_id FK on project_tasks with CASCADE delete
- Add Alembic migration 002 for parent_task_id column + index
- Update schemas with parent_task_id in create, nested subtasks in response
- Chain selectinload for subtasks on all project queries
- Validate parent must be top-level task (single nesting level only)

Frontend:
- Add parent_task_id and subtasks[] to ProjectTask type
- ProjectDetail: expand/collapse chevrons, subtask progress bars, inline
  subtask rendering with accent left border, add/edit/delete subtask buttons
- TaskForm: accept parentTaskId prop, include in create payload, context-aware
  dialog title (New Task vs New Subtask)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-02-16 01:31:46 +08:00
parent 47baf2529c
commit ccfbf6df96
7 changed files with 318 additions and 74 deletions

View File

@ -0,0 +1,37 @@
"""Add subtask support with parent_task_id
Revision ID: 002
Revises: 001
Create Date: 2026-02-15 00:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '002'
down_revision: Union[str, None] = '001'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('project_tasks', sa.Column('parent_task_id', sa.Integer(), nullable=True))
op.create_foreign_key(
'fk_project_tasks_parent_task_id',
'project_tasks',
'project_tasks',
['parent_task_id'],
['id'],
ondelete='CASCADE'
)
op.create_index('ix_project_tasks_parent_task_id', 'project_tasks', ['parent_task_id'])
def downgrade() -> None:
op.drop_index('ix_project_tasks_parent_task_id', table_name='project_tasks')
op.drop_constraint('fk_project_tasks_parent_task_id', 'project_tasks', type_='foreignkey')
op.drop_column('project_tasks', 'parent_task_id')

View File

@ -1,7 +1,7 @@
from sqlalchemy import String, Text, Integer, Date, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm import Mapped, mapped_column, relationship as sa_relationship
from datetime import datetime, date
from typing import Optional
from typing import Optional, List
from app.database import Base
@ -10,6 +10,9 @@ class ProjectTask(Base):
id: Mapped[int] = mapped_column(primary_key=True, index=True)
project_id: Mapped[int] = mapped_column(Integer, ForeignKey("projects.id"), nullable=False)
parent_task_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("project_tasks.id", ondelete="CASCADE"), nullable=True
)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(20), default="pending")
@ -21,5 +24,13 @@ class ProjectTask(Base):
updated_at: Mapped[datetime] = mapped_column(default=func.now(), onupdate=func.now())
# Relationships
project: Mapped["Project"] = relationship(back_populates="tasks")
person: Mapped[Optional["Person"]] = relationship(back_populates="assigned_tasks")
project: Mapped["Project"] = sa_relationship(back_populates="tasks")
person: Mapped[Optional["Person"]] = sa_relationship(back_populates="assigned_tasks")
parent_task: Mapped[Optional["ProjectTask"]] = sa_relationship(
back_populates="subtasks",
remote_side=[id],
)
subtasks: Mapped[List["ProjectTask"]] = sa_relationship(
back_populates="parent_task",
cascade="all, delete-orphan",
)

View File

@ -15,13 +15,18 @@ from app.models.settings import Settings
router = APIRouter()
def _project_with_tasks():
"""Eager load projects with tasks and their subtasks."""
return selectinload(Project.tasks).selectinload(ProjectTask.subtasks)
@router.get("/", response_model=List[ProjectResponse])
async def get_projects(
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get all projects with their tasks."""
query = select(Project).options(selectinload(Project.tasks)).order_by(Project.created_at.desc())
query = select(Project).options(_project_with_tasks()).order_by(Project.created_at.desc())
result = await db.execute(query)
projects = result.scalars().all()
@ -40,7 +45,7 @@ async def create_project(
await db.commit()
# Re-fetch with eagerly loaded tasks for response serialization
query = select(Project).options(selectinload(Project.tasks)).where(Project.id == new_project.id)
query = select(Project).options(_project_with_tasks()).where(Project.id == new_project.id)
result = await db.execute(query)
return result.scalar_one()
@ -52,7 +57,7 @@ async def get_project(
current_user: Settings = Depends(get_current_session)
):
"""Get a specific project by ID with its tasks."""
query = select(Project).options(selectinload(Project.tasks)).where(Project.id == project_id)
query = select(Project).options(_project_with_tasks()).where(Project.id == project_id)
result = await db.execute(query)
project = result.scalar_one_or_none()
@ -84,7 +89,7 @@ async def update_project(
await db.commit()
# Re-fetch with eagerly loaded tasks for response serialization
query = select(Project).options(selectinload(Project.tasks)).where(Project.id == project_id)
query = select(Project).options(_project_with_tasks()).where(Project.id == project_id)
result = await db.execute(query)
return result.scalar_one()
@ -114,14 +119,22 @@ async def get_project_tasks(
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Get all tasks for a specific project."""
"""Get top-level tasks for a specific project (subtasks are nested)."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
query = select(ProjectTask).where(ProjectTask.project_id == project_id).order_by(ProjectTask.sort_order.asc())
query = (
select(ProjectTask)
.options(selectinload(ProjectTask.subtasks))
.where(
ProjectTask.project_id == project_id,
ProjectTask.parent_task_id.is_(None),
)
.order_by(ProjectTask.sort_order.asc())
)
result = await db.execute(query)
tasks = result.scalars().all()
@ -135,21 +148,43 @@ async def create_project_task(
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Create a new task for a project."""
"""Create a new task or subtask for a project."""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Validate parent_task_id if creating a subtask
if task.parent_task_id is not None:
parent_result = await db.execute(
select(ProjectTask).where(
ProjectTask.id == task.parent_task_id,
ProjectTask.project_id == project_id,
ProjectTask.parent_task_id.is_(None), # Parent must be top-level
)
)
parent_task = parent_result.scalar_one_or_none()
if not parent_task:
raise HTTPException(
status_code=400,
detail="Parent task not found or is itself a subtask",
)
task_data = task.model_dump()
task_data["project_id"] = project_id
new_task = ProjectTask(**task_data)
db.add(new_task)
await db.commit()
await db.refresh(new_task)
return new_task
# Re-fetch with subtasks loaded
query = (
select(ProjectTask)
.options(selectinload(ProjectTask.subtasks))
.where(ProjectTask.id == new_task.id)
)
result = await db.execute(query)
return result.scalar_one()
@router.put("/{project_id}/tasks/{task_id}", response_model=ProjectTaskResponse)
@ -178,9 +213,15 @@ async def update_project_task(
setattr(task, key, value)
await db.commit()
await db.refresh(task)
return task
# Re-fetch with subtasks loaded
query = (
select(ProjectTask)
.options(selectinload(ProjectTask.subtasks))
.where(ProjectTask.id == task_id)
)
result = await db.execute(query)
return result.scalar_one()
@router.delete("/{project_id}/tasks/{task_id}", status_code=204)
@ -190,7 +231,7 @@ async def delete_project_task(
db: AsyncSession = Depends(get_db),
current_user: Settings = Depends(get_current_session)
):
"""Delete a project task."""
"""Delete a project task (cascades to subtasks)."""
result = await db.execute(
select(ProjectTask).where(
ProjectTask.id == task_id,

View File

@ -1,6 +1,6 @@
from pydantic import BaseModel, ConfigDict
from datetime import datetime, date
from typing import Optional
from typing import Optional, List
class ProjectTaskCreate(BaseModel):
@ -11,6 +11,7 @@ class ProjectTaskCreate(BaseModel):
due_date: Optional[date] = None
person_id: Optional[int] = None
sort_order: int = 0
parent_task_id: Optional[int] = None
class ProjectTaskUpdate(BaseModel):
@ -26,6 +27,7 @@ class ProjectTaskUpdate(BaseModel):
class ProjectTaskResponse(BaseModel):
id: int
project_id: int
parent_task_id: Optional[int] = None
title: str
description: Optional[str]
status: str
@ -35,5 +37,9 @@ class ProjectTaskResponse(BaseModel):
sort_order: int
created_at: datetime
updated_at: datetime
subtasks: List["ProjectTaskResponse"] = []
model_config = ConfigDict(from_attributes=True)
ProjectTaskResponse.model_rebuild()

View File

@ -2,7 +2,7 @@ import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { ArrowLeft, Plus, Trash2, ListChecks } from 'lucide-react';
import { ArrowLeft, Plus, Trash2, ListChecks, ChevronRight, Pencil } from 'lucide-react';
import api from '@/lib/api';
import type { Project, ProjectTask } from '@/types';
import { Button } from '@/components/ui/button';
@ -20,18 +20,25 @@ const statusColors = {
completed: 'bg-green-500/10 text-green-500 border-green-500/20',
};
const taskStatusColors = {
const taskStatusColors: Record<string, string> = {
pending: 'bg-gray-500/10 text-gray-500 border-gray-500/20',
in_progress: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
completed: 'bg-green-500/10 text-green-500 border-green-500/20',
};
const priorityColors = {
const priorityColors: Record<string, string> = {
low: 'bg-green-500/10 text-green-500 border-green-500/20',
medium: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20',
high: 'bg-red-500/10 text-red-500 border-red-500/20',
};
function getSubtaskProgress(task: ProjectTask) {
if (!task.subtasks || task.subtasks.length === 0) return null;
const completed = task.subtasks.filter((s) => s.status === 'completed').length;
const total = task.subtasks.length;
return { completed, total, percent: (completed / total) * 100 };
}
export default function ProjectDetail() {
const { id } = useParams();
const navigate = useNavigate();
@ -39,6 +46,20 @@ export default function ProjectDetail() {
const [showTaskForm, setShowTaskForm] = useState(false);
const [showProjectForm, setShowProjectForm] = useState(false);
const [editingTask, setEditingTask] = useState<ProjectTask | null>(null);
const [subtaskParentId, setSubtaskParentId] = useState<number | null>(null);
const [expandedTasks, setExpandedTasks] = useState<Set<number>>(new Set());
const toggleExpand = (taskId: number) => {
setExpandedTasks((prev) => {
const next = new Set(prev);
if (next.has(taskId)) {
next.delete(taskId);
} else {
next.add(taskId);
}
return next;
});
};
const { data: project, isLoading } = useQuery({
queryKey: ['projects', id],
@ -105,6 +126,21 @@ export default function ProjectDetail() {
return <div className="p-6 text-center text-muted-foreground">Project not found</div>;
}
// Filter to top-level tasks only (subtasks are nested inside their parent)
const topLevelTasks = project.tasks?.filter((t) => !t.parent_task_id) || [];
const openTaskForm = (task: ProjectTask | null, parentId: number | null) => {
setEditingTask(task);
setSubtaskParentId(parentId);
setShowTaskForm(true);
};
const closeTaskForm = () => {
setShowTaskForm(false);
setEditingTask(null);
setSubtaskParentId(null);
};
return (
<div className="flex flex-col h-full">
<div className="border-b bg-card px-6 py-4">
@ -128,69 +164,178 @@ export default function ProjectDetail() {
{project.description && (
<p className="text-muted-foreground mb-4">{project.description}</p>
)}
<Button onClick={() => setShowTaskForm(true)}>
<Button onClick={() => openTaskForm(null, null)}>
<Plus className="mr-2 h-4 w-4" />
Add Task
</Button>
</div>
<div className="flex-1 overflow-y-auto p-6">
{!project.tasks || project.tasks.length === 0 ? (
{topLevelTasks.length === 0 ? (
<EmptyState
icon={ListChecks}
title="No tasks yet"
description="Break this project down into tasks to track your progress."
actionLabel="Add Task"
onAction={() => setShowTaskForm(true)}
onAction={() => openTaskForm(null, null)}
/>
) : (
<div className="space-y-2">
{project.tasks.map((task) => (
<Card key={task.id}>
<CardHeader>
<div className="flex items-start gap-3">
<Checkbox
checked={task.status === 'completed'}
onChange={() =>
toggleTaskMutation.mutate({ taskId: task.id, status: task.status })
}
disabled={toggleTaskMutation.isPending}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<CardTitle className="text-lg">{task.title}</CardTitle>
{task.description && (
<CardDescription className="mt-1">{task.description}</CardDescription>
)}
<div className="flex items-center gap-2 mt-2">
<Badge className={taskStatusColors[task.status]}>
{task.status.replace('_', ' ')}
</Badge>
<Badge className={priorityColors[task.priority]}>{task.priority}</Badge>
{topLevelTasks.map((task) => {
const progress = getSubtaskProgress(task);
const hasSubtasks = task.subtasks && task.subtasks.length > 0;
const isExpanded = expandedTasks.has(task.id);
return (
<div key={task.id}>
<Card>
<CardHeader>
<div className="flex items-start gap-3">
{/* Expand/collapse chevron */}
<button
onClick={() => hasSubtasks && toggleExpand(task.id)}
className={`mt-1 transition-colors ${hasSubtasks ? 'text-muted-foreground hover:text-foreground cursor-pointer' : 'text-transparent cursor-default'}`}
>
<ChevronRight
className={`h-4 w-4 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
/>
</button>
<Checkbox
checked={task.status === 'completed'}
onChange={() =>
toggleTaskMutation.mutate({ taskId: task.id, status: task.status })
}
disabled={toggleTaskMutation.isPending}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<CardTitle className="text-lg">{task.title}</CardTitle>
{task.description && (
<CardDescription className="mt-1">{task.description}</CardDescription>
)}
<div className="flex items-center gap-2 mt-2">
<Badge className={taskStatusColors[task.status]}>
{task.status.replace('_', ' ')}
</Badge>
<Badge className={priorityColors[task.priority]}>{task.priority}</Badge>
</div>
{/* Subtask progress bar */}
{progress && (
<div className="mt-3">
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">Subtasks</span>
<span className="font-medium">
{progress.completed}/{progress.total}
</span>
</div>
<div className="h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-accent rounded-full transition-all duration-300"
style={{ width: `${progress.percent}%` }}
/>
</div>
</div>
)}
</div>
{/* Add subtask */}
<Button
variant="ghost"
size="icon"
onClick={() => openTaskForm(null, task.id)}
title="Add subtask"
>
<Plus className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => openTaskForm(task, null)}
title="Edit task"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteTaskMutation.mutate(task.id)}
disabled={deleteTaskMutation.isPending}
title="Delete task"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
</Card>
{/* Subtasks - shown when expanded */}
{isExpanded && hasSubtasks && (
<div className="ml-9 mt-1 space-y-1">
{task.subtasks.map((subtask) => (
<Card key={subtask.id} className="border-l-2 border-accent/30">
<CardHeader className="py-3 px-4">
<div className="flex items-start gap-3">
<Checkbox
checked={subtask.status === 'completed'}
onChange={() =>
toggleTaskMutation.mutate({
taskId: subtask.id,
status: subtask.status,
})
}
disabled={toggleTaskMutation.isPending}
className="mt-0.5"
/>
<div className="flex-1 min-w-0">
<CardTitle className="text-sm font-medium">
{subtask.title}
</CardTitle>
{subtask.description && (
<CardDescription className="mt-0.5 text-xs">
{subtask.description}
</CardDescription>
)}
<div className="flex items-center gap-2 mt-1.5">
<Badge
className={`text-xs ${taskStatusColors[subtask.status]}`}
>
{subtask.status.replace('_', ' ')}
</Badge>
<Badge
className={`text-xs ${priorityColors[subtask.priority]}`}
>
{subtask.priority}
</Badge>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => openTaskForm(subtask, task.id)}
title="Edit subtask"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteTaskMutation.mutate(subtask.id)}
disabled={deleteTaskMutation.isPending}
title="Delete subtask"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</CardHeader>
</Card>
))}
</div>
<Button
variant="ghost"
size="icon"
onClick={() => {
setEditingTask(task);
setShowTaskForm(true);
}}
>
Edit
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteTaskMutation.mutate(task.id)}
disabled={deleteTaskMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
</Card>
))}
)}
</div>
);
})}
</div>
)}
</div>
@ -199,10 +344,8 @@ export default function ProjectDetail() {
<TaskForm
projectId={parseInt(id!)}
task={editingTask}
onClose={() => {
setShowTaskForm(false);
setEditingTask(null);
}}
parentTaskId={subtaskParentId}
onClose={closeTaskForm}
/>
)}

View File

@ -20,10 +20,11 @@ import { Button } from '@/components/ui/button';
interface TaskFormProps {
projectId: number;
task: ProjectTask | null;
parentTaskId?: number | null;
onClose: () => void;
}
export default function TaskForm({ projectId, task, onClose }: TaskFormProps) {
export default function TaskForm({ projectId, task, parentTaskId, onClose }: TaskFormProps) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
title: task?.title || '',
@ -44,7 +45,7 @@ export default function TaskForm({ projectId, task, onClose }: TaskFormProps) {
const mutation = useMutation({
mutationFn: async (data: typeof formData) => {
const payload = {
const payload: Record<string, unknown> = {
...data,
person_id: data.person_id ? parseInt(data.person_id) : null,
};
@ -52,6 +53,9 @@ export default function TaskForm({ projectId, task, onClose }: TaskFormProps) {
const response = await api.put(`/projects/${projectId}/tasks/${task.id}`, payload);
return response.data;
} else {
if (parentTaskId) {
payload.parent_task_id = parentTaskId;
}
const response = await api.post(`/projects/${projectId}/tasks`, payload);
return response.data;
}
@ -76,7 +80,7 @@ export default function TaskForm({ projectId, task, onClose }: TaskFormProps) {
<DialogContent>
<DialogClose onClick={onClose} />
<DialogHeader>
<DialogTitle>{task ? 'Edit Task' : 'New Task'}</DialogTitle>
<DialogTitle>{task ? 'Edit Task' : parentTaskId ? 'New Subtask' : 'New Task'}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">

View File

@ -62,6 +62,7 @@ export interface Project {
export interface ProjectTask {
id: number;
project_id: number;
parent_task_id?: number | null;
title: string;
description?: string;
status: 'pending' | 'in_progress' | 'completed';
@ -71,6 +72,7 @@ export interface ProjectTask {
sort_order: number;
created_at: string;
updated_at: string;
subtasks: ProjectTask[];
}
export interface Person {