Implement complete nested todo tree web app MVP

This commit implements a fully functional self-hosted task decomposition engine with:

Backend (FastAPI + SQLite):
- RESTful API with full CRUD operations for projects and tasks
- Arbitrary-depth hierarchical task structure using self-referencing parent_task_id
- JSON import endpoint for seeding projects from LLM-generated breakdowns
- SQLAlchemy models with proper relationships and cascade deletes
- Status tracking (backlog, in_progress, blocked, done)
- Auto-generated OpenAPI documentation

Frontend (React + Vite + Tailwind):
- Dark cyberpunk theme with orange accents
- Project list page with create/import/delete functionality
- Dual view modes:
  * Tree View: Collapsible hierarchical display with inline editing
  * Kanban Board: Drag-and-drop status management
- Real-time CRUD operations for tasks and subtasks
- JSON import modal with validation
- Responsive design optimized for desktop

Infrastructure:
- Docker setup with multi-stage builds
- docker-compose for orchestration
- Nginx reverse proxy for production frontend
- Named volume for SQLite persistence
- CORS configuration for local development

Documentation:
- Comprehensive README with setup instructions
- Example JSON import file demonstrating nested structure
- API endpoint documentation
- Data model diagrams
This commit is contained in:
Claude
2025-11-19 22:51:42 +00:00
parent bac534ce94
commit 441f62023e
28 changed files with 1977 additions and 0 deletions

125
backend/app/crud.py Normal file
View File

@@ -0,0 +1,125 @@
from sqlalchemy.orm import Session, joinedload
from typing import List, Optional
from . import models, schemas
# Project CRUD
def create_project(db: Session, project: schemas.ProjectCreate) -> models.Project:
db_project = models.Project(**project.model_dump())
db.add(db_project)
db.commit()
db.refresh(db_project)
return db_project
def get_project(db: Session, project_id: int) -> Optional[models.Project]:
return db.query(models.Project).filter(models.Project.id == project_id).first()
def get_projects(db: Session, skip: int = 0, limit: int = 100) -> List[models.Project]:
return db.query(models.Project).offset(skip).limit(limit).all()
def update_project(
db: Session, project_id: int, project: schemas.ProjectUpdate
) -> Optional[models.Project]:
db_project = get_project(db, project_id)
if not db_project:
return None
update_data = project.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(db_project, key, value)
db.commit()
db.refresh(db_project)
return db_project
def delete_project(db: Session, project_id: int) -> bool:
db_project = get_project(db, project_id)
if not db_project:
return False
db.delete(db_project)
db.commit()
return True
# Task CRUD
def create_task(db: Session, task: schemas.TaskCreate) -> models.Task:
# Get max sort_order for siblings
if task.parent_task_id:
max_order = db.query(models.Task).filter(
models.Task.parent_task_id == task.parent_task_id
).count()
else:
max_order = db.query(models.Task).filter(
models.Task.project_id == task.project_id,
models.Task.parent_task_id.is_(None)
).count()
task_data = task.model_dump()
if "sort_order" not in task_data or task_data["sort_order"] == 0:
task_data["sort_order"] = max_order
db_task = models.Task(**task_data)
db.add(db_task)
db.commit()
db.refresh(db_task)
return db_task
def get_task(db: Session, task_id: int) -> Optional[models.Task]:
return db.query(models.Task).filter(models.Task.id == task_id).first()
def get_tasks_by_project(db: Session, project_id: int) -> List[models.Task]:
return db.query(models.Task).filter(models.Task.project_id == project_id).all()
def get_root_tasks(db: Session, project_id: int) -> List[models.Task]:
"""Get all root-level tasks (no parent) for a project"""
return db.query(models.Task).filter(
models.Task.project_id == project_id,
models.Task.parent_task_id.is_(None)
).order_by(models.Task.sort_order).all()
def get_task_with_subtasks(db: Session, task_id: int) -> Optional[models.Task]:
"""Recursively load a task with all its subtasks"""
return db.query(models.Task).options(
joinedload(models.Task.subtasks)
).filter(models.Task.id == task_id).first()
def update_task(
db: Session, task_id: int, task: schemas.TaskUpdate
) -> Optional[models.Task]:
db_task = get_task(db, task_id)
if not db_task:
return None
update_data = task.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(db_task, key, value)
db.commit()
db.refresh(db_task)
return db_task
def delete_task(db: Session, task_id: int) -> bool:
db_task = get_task(db, task_id)
if not db_task:
return False
db.delete(db_task)
db.commit()
return True
def get_tasks_by_status(db: Session, project_id: int, status: models.TaskStatus) -> List[models.Task]:
"""Get all tasks for a project with a specific status"""
return db.query(models.Task).filter(
models.Task.project_id == project_id,
models.Task.status == status
).all()