From 5da6e075b40ed5b8254c54f47e45827a96ca359a Mon Sep 17 00:00:00 2001 From: serversdown Date: Sun, 29 Mar 2026 16:08:55 -0400 Subject: [PATCH] feat: add task blocker dependency graph + actionable now view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New task_blockers join table (many-to-many, cross-project) - Cycle detection prevents circular dependencies - GET /api/tasks/{id}/blockers and /blocking endpoints - POST/DELETE /api/tasks/{id}/blockers/{blocker_id} - GET /api/actionable — all non-done tasks with no incomplete blockers - BlockerPanel.jsx — search & manage blockers per task (via task menu) - ActionableView.jsx — "what can I do right now?" dashboard grouped by project - "Now" button in nav header routes to actionable view - migrate_add_blockers.py migration script for existing databases Co-Authored-By: Claude Sonnet 4.6 --- backend/app/crud.py | 471 ++++++++----- backend/app/main.py | 692 ++++++++++--------- backend/app/models.py | 103 +-- backend/app/schemas.py | 245 ++++--- backend/migrate_add_blockers.py | 40 ++ frontend/src/App.jsx | 90 ++- frontend/src/components/BlockerPanel.jsx | 201 ++++++ frontend/src/components/TaskMenu.jsx | 834 ++++++++++++----------- frontend/src/pages/ActionableView.jsx | 190 ++++++ frontend/src/utils/api.js | 161 ++--- 10 files changed, 1851 insertions(+), 1176 deletions(-) create mode 100644 backend/migrate_add_blockers.py create mode 100644 frontend/src/components/BlockerPanel.jsx create mode 100644 frontend/src/pages/ActionableView.jsx diff --git a/backend/app/crud.py b/backend/app/crud.py index d3d183a..ed17a93 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -1,188 +1,283 @@ -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: - project_data = project.model_dump() - # Ensure statuses has a default value if not provided - if project_data.get("statuses") is None: - project_data["statuses"] = models.DEFAULT_STATUSES - - db_project = models.Project(**project_data) - 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, archived: Optional[bool] = None) -> List[models.Project]: - query = db.query(models.Project) - - # Filter by archive status if specified - if archived is not None: - query = query.filter(models.Project.is_archived == archived) - - return query.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: - # Validate status against project's statuses - project = get_project(db, task.project_id) - if project and task.status not in project.statuses: - raise ValueError(f"Invalid status '{task.status}'. Must be one of: {', '.join(project.statuses)}") - - # 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 check_and_update_parent_status(db: Session, parent_id: int): - """Check if all children of a parent are done, and mark parent as done if so""" - # Get all children of this parent - children = db.query(models.Task).filter( - models.Task.parent_task_id == parent_id - ).all() - - # If no children, nothing to do - if not children: - return - - # Check if all children are done - all_done = all(child.status == "done" for child in children) - - if all_done: - # Mark parent as done - parent = get_task(db, parent_id) - if parent and parent.status != "done": - parent.status = "done" - db.commit() - - # Recursively check grandparent - if parent.parent_task_id: - check_and_update_parent_status(db, parent.parent_task_id) - - -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) - - # Validate status against project's statuses if status is being updated - if "status" in update_data: - project = get_project(db, db_task.project_id) - if project and update_data["status"] not in project.statuses: - raise ValueError(f"Invalid status '{update_data['status']}'. Must be one of: {', '.join(project.statuses)}") - status_changed = True - old_status = db_task.status - else: - status_changed = False - - for key, value in update_data.items(): - setattr(db_task, key, value) - - db.commit() - db.refresh(db_task) - - # If status changed to 'done' and this task has a parent, check if parent should auto-complete - if status_changed and db_task.status == "done" and db_task.parent_task_id: - check_and_update_parent_status(db, db_task.parent_task_id) - - 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: str) -> List[models.Task]: - """Get all tasks for a project with a specific status""" - # Validate status against project's statuses - project = get_project(db, project_id) - if project and status not in project.statuses: - raise ValueError(f"Invalid status '{status}'. Must be one of: {', '.join(project.statuses)}") - - return db.query(models.Task).filter( - models.Task.project_id == project_id, - models.Task.status == status - ).all() +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: + project_data = project.model_dump() + # Ensure statuses has a default value if not provided + if project_data.get("statuses") is None: + project_data["statuses"] = models.DEFAULT_STATUSES + + db_project = models.Project(**project_data) + 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, archived: Optional[bool] = None) -> List[models.Project]: + query = db.query(models.Project) + + # Filter by archive status if specified + if archived is not None: + query = query.filter(models.Project.is_archived == archived) + + return query.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: + # Validate status against project's statuses + project = get_project(db, task.project_id) + if project and task.status not in project.statuses: + raise ValueError(f"Invalid status '{task.status}'. Must be one of: {', '.join(project.statuses)}") + + # 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 check_and_update_parent_status(db: Session, parent_id: int): + """Check if all children of a parent are done, and mark parent as done if so""" + # Get all children of this parent + children = db.query(models.Task).filter( + models.Task.parent_task_id == parent_id + ).all() + + # If no children, nothing to do + if not children: + return + + # Check if all children are done + all_done = all(child.status == "done" for child in children) + + if all_done: + # Mark parent as done + parent = get_task(db, parent_id) + if parent and parent.status != "done": + parent.status = "done" + db.commit() + + # Recursively check grandparent + if parent.parent_task_id: + check_and_update_parent_status(db, parent.parent_task_id) + + +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) + + # Validate status against project's statuses if status is being updated + if "status" in update_data: + project = get_project(db, db_task.project_id) + if project and update_data["status"] not in project.statuses: + raise ValueError(f"Invalid status '{update_data['status']}'. Must be one of: {', '.join(project.statuses)}") + status_changed = True + old_status = db_task.status + else: + status_changed = False + + for key, value in update_data.items(): + setattr(db_task, key, value) + + db.commit() + db.refresh(db_task) + + # If status changed to 'done' and this task has a parent, check if parent should auto-complete + if status_changed and db_task.status == "done" and db_task.parent_task_id: + check_and_update_parent_status(db, db_task.parent_task_id) + + 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: str) -> List[models.Task]: + """Get all tasks for a project with a specific status""" + # Validate status against project's statuses + project = get_project(db, project_id) + if project and status not in project.statuses: + raise ValueError(f"Invalid status '{status}'. Must be one of: {', '.join(project.statuses)}") + + return db.query(models.Task).filter( + models.Task.project_id == project_id, + models.Task.status == status + ).all() + + +# ========== BLOCKER CRUD ========== + +def _has_cycle(db: Session, start_id: int, target_id: int) -> bool: + """BFS from start_id following its blockers. Returns True if target_id is reachable, + which would mean adding target_id as a blocker of start_id creates a cycle.""" + visited = set() + queue = [start_id] + while queue: + current = queue.pop(0) + if current == target_id: + return True + if current in visited: + continue + visited.add(current) + task = db.query(models.Task).filter(models.Task.id == current).first() + if task: + for b in task.blockers: + if b.id not in visited: + queue.append(b.id) + return False + + +def add_blocker(db: Session, task_id: int, blocker_id: int) -> models.Task: + """Add blocker_id as a prerequisite of task_id. + Raises ValueError on self-reference or cycle.""" + if task_id == blocker_id: + raise ValueError("A task cannot block itself") + + task = get_task(db, task_id) + blocker = get_task(db, blocker_id) + + if not task: + raise ValueError("Task not found") + if not blocker: + raise ValueError("Blocker task not found") + + # Already linked — idempotent + if any(b.id == blocker_id for b in task.blockers): + return task + + # Cycle detection: would blocker_id eventually depend on task_id? + if _has_cycle(db, blocker_id, task_id): + raise ValueError("Adding this blocker would create a circular dependency") + + task.blockers.append(blocker) + db.commit() + db.refresh(task) + return task + + +def remove_blocker(db: Session, task_id: int, blocker_id: int) -> bool: + """Remove blocker_id as a prerequisite of task_id.""" + task = get_task(db, task_id) + blocker = get_task(db, blocker_id) + + if not task or not blocker: + return False + + if not any(b.id == blocker_id for b in task.blockers): + return False + + task.blockers.remove(blocker) + db.commit() + return True + + +def get_task_with_blockers(db: Session, task_id: int) -> Optional[models.Task]: + """Get a task including its blockers and blocking lists.""" + return db.query(models.Task).filter(models.Task.id == task_id).first() + + +def get_actionable_tasks(db: Session) -> List[dict]: + """Return all non-done tasks that have no incomplete blockers, with project name.""" + tasks = db.query(models.Task).filter( + models.Task.status != "done" + ).all() + + result = [] + for task in tasks: + incomplete_blockers = [b for b in task.blockers if b.status != "done"] + if not incomplete_blockers: + result.append({ + "id": task.id, + "title": task.title, + "project_id": task.project_id, + "project_name": task.project.name, + "status": task.status, + "estimated_minutes": task.estimated_minutes, + "tags": task.tags, + "flag_color": task.flag_color, + }) + + return result diff --git a/backend/app/main.py b/backend/app/main.py index 19a41e2..b323a25 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,324 +1,368 @@ -from fastapi import FastAPI, Depends, HTTPException, UploadFile, File -from fastapi.middleware.cors import CORSMiddleware -from sqlalchemy.orm import Session -from typing import List, Optional -import json - -from . import models, schemas, crud -from .database import engine, get_db -from .settings import settings - -# Create database tables -models.Base.metadata.create_all(bind=engine) - -app = FastAPI( - title=settings.api_title, - description=settings.api_description, - version=settings.api_version -) - -# CORS middleware for frontend -app.add_middleware( - CORSMiddleware, - allow_origins=settings.cors_origins_list, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - - -# ========== PROJECT ENDPOINTS ========== - -@app.get("/api/projects", response_model=List[schemas.Project]) -def list_projects( - skip: int = 0, - limit: int = 100, - archived: Optional[bool] = None, - db: Session = Depends(get_db) -): - """List all projects with optional archive filter""" - return crud.get_projects(db, skip=skip, limit=limit, archived=archived) - - -@app.post("/api/projects", response_model=schemas.Project, status_code=201) -def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db)): - """Create a new project""" - return crud.create_project(db, project) - - -@app.get("/api/projects/{project_id}", response_model=schemas.Project) -def get_project(project_id: int, db: Session = Depends(get_db)): - """Get a specific project""" - db_project = crud.get_project(db, project_id) - if not db_project: - raise HTTPException(status_code=404, detail="Project not found") - return db_project - - -@app.put("/api/projects/{project_id}", response_model=schemas.Project) -def update_project( - project_id: int, project: schemas.ProjectUpdate, db: Session = Depends(get_db) -): - """Update a project""" - db_project = crud.update_project(db, project_id, project) - if not db_project: - raise HTTPException(status_code=404, detail="Project not found") - return db_project - - -@app.delete("/api/projects/{project_id}", status_code=204) -def delete_project(project_id: int, db: Session = Depends(get_db)): - """Delete a project and all its tasks""" - if not crud.delete_project(db, project_id): - raise HTTPException(status_code=404, detail="Project not found") - return None - - -# ========== TASK ENDPOINTS ========== - -@app.get("/api/projects/{project_id}/tasks", response_model=List[schemas.Task]) -def list_project_tasks(project_id: int, db: Session = Depends(get_db)): - """List all tasks for a project""" - if not crud.get_project(db, project_id): - raise HTTPException(status_code=404, detail="Project not found") - return crud.get_tasks_by_project(db, project_id) - - -@app.get("/api/projects/{project_id}/tasks/tree", response_model=List[schemas.TaskWithSubtasks]) -def get_project_task_tree(project_id: int, db: Session = Depends(get_db)): - """Get the task tree (root tasks with nested subtasks) for a project""" - if not crud.get_project(db, project_id): - raise HTTPException(status_code=404, detail="Project not found") - - root_tasks = crud.get_root_tasks(db, project_id) - - def build_tree(task): - task_dict = schemas.TaskWithSubtasks.model_validate(task) - task_dict.subtasks = [build_tree(subtask) for subtask in task.subtasks] - return task_dict - - return [build_tree(task) for task in root_tasks] - - -@app.get("/api/projects/{project_id}/tasks/by-status/{status}", response_model=List[schemas.Task]) -def get_tasks_by_status( - project_id: int, - status: str, - db: Session = Depends(get_db) -): - """Get all tasks for a project filtered by status (for Kanban view)""" - if not crud.get_project(db, project_id): - raise HTTPException(status_code=404, detail="Project not found") - try: - return crud.get_tasks_by_status(db, project_id, status) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@app.post("/api/tasks", response_model=schemas.Task, status_code=201) -def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)): - """Create a new task""" - if not crud.get_project(db, task.project_id): - raise HTTPException(status_code=404, detail="Project not found") - - if task.parent_task_id and not crud.get_task(db, task.parent_task_id): - raise HTTPException(status_code=404, detail="Parent task not found") - - try: - return crud.create_task(db, task) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@app.get("/api/tasks/{task_id}", response_model=schemas.Task) -def get_task(task_id: int, db: Session = Depends(get_db)): - """Get a specific task""" - db_task = crud.get_task(db, task_id) - if not db_task: - raise HTTPException(status_code=404, detail="Task not found") - return db_task - - -@app.put("/api/tasks/{task_id}", response_model=schemas.Task) -def update_task(task_id: int, task: schemas.TaskUpdate, db: Session = Depends(get_db)): - """Update a task""" - try: - db_task = crud.update_task(db, task_id, task) - if not db_task: - raise HTTPException(status_code=404, detail="Task not found") - return db_task - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - - -@app.delete("/api/tasks/{task_id}", status_code=204) -def delete_task(task_id: int, db: Session = Depends(get_db)): - """Delete a task and all its subtasks""" - if not crud.delete_task(db, task_id): - raise HTTPException(status_code=404, detail="Task not found") - return None - - -# ========== SEARCH ENDPOINT ========== - -@app.get("/api/search", response_model=List[schemas.Task]) -def search_tasks( - query: str, - project_ids: Optional[str] = None, - db: Session = Depends(get_db) -): - """ - Search tasks across projects by title, description, and tags. - - Args: - query: Search term to match against title, description, and tags - project_ids: Comma-separated list of project IDs to search in (optional, searches all if not provided) - """ - # Parse project IDs if provided - project_id_list = None - if project_ids: - try: - project_id_list = [int(pid.strip()) for pid in project_ids.split(',') if pid.strip()] - except ValueError: - raise HTTPException(status_code=400, detail="Invalid project_ids format") - - # Build query - tasks_query = db.query(models.Task) - - # Filter by project IDs if specified - if project_id_list: - tasks_query = tasks_query.filter(models.Task.project_id.in_(project_id_list)) - - # Search in title, description, and tags - search_term = f"%{query}%" - tasks = tasks_query.filter( - (models.Task.title.ilike(search_term)) | - (models.Task.description.ilike(search_term)) | - (models.Task.tags.contains([query])) # Exact tag match - ).all() - - return tasks - - -# ========== JSON IMPORT ENDPOINT ========== - -def _validate_task_statuses_recursive( - tasks: List[schemas.ImportSubtask], - valid_statuses: List[str], - path: str = "" -) -> None: - """Recursively validate all task statuses against the project's valid statuses""" - for idx, task_data in enumerate(tasks): - task_path = f"{path}.tasks[{idx}]" if path else f"tasks[{idx}]" - if task_data.status not in valid_statuses: - raise ValueError( - f"Invalid status '{task_data.status}' at {task_path}. " - f"Must be one of: {', '.join(valid_statuses)}" - ) - if task_data.subtasks: - _validate_task_statuses_recursive( - task_data.subtasks, - valid_statuses, - f"{task_path}.subtasks" - ) - - -def _import_tasks_recursive( - db: Session, - project_id: int, - tasks: List[schemas.ImportSubtask], - parent_id: Optional[int] = None, - count: int = 0 -) -> int: - """Recursively import tasks and their subtasks""" - for idx, task_data in enumerate(tasks): - task = schemas.TaskCreate( - project_id=project_id, - parent_task_id=parent_id, - title=task_data.title, - description=task_data.description, - status=task_data.status, - estimated_minutes=task_data.estimated_minutes, - tags=task_data.tags, - flag_color=task_data.flag_color, - sort_order=idx - ) - db_task = crud.create_task(db, task) - count += 1 - - if task_data.subtasks: - count = _import_tasks_recursive( - db, project_id, task_data.subtasks, db_task.id, count - ) - - return count - - -@app.post("/api/import-json", response_model=schemas.ImportResult) -def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_db)): - """ - Import a project with nested tasks from JSON. - - Expected format: - { - "project": { - "name": "Project Name", - "description": "Optional description", - "statuses": ["backlog", "in_progress", "on_hold", "done"] // Optional - }, - "tasks": [ - { - "title": "Task 1", - "description": "Optional", - "status": "backlog", - "subtasks": [ - { - "title": "Subtask 1.1", - "status": "backlog", - "subtasks": [] - } - ] - } - ] - } - """ - # Create the project with optional statuses - project = crud.create_project( - db, - schemas.ProjectCreate( - name=import_data.project.name, - description=import_data.project.description, - statuses=import_data.project.statuses - ) - ) - - # Validate all task statuses before importing - if import_data.tasks: - try: - _validate_task_statuses_recursive(import_data.tasks, project.statuses) - except ValueError as e: - # Rollback the project creation if validation fails - db.delete(project) - db.commit() - raise HTTPException(status_code=400, detail=str(e)) - - # Recursively import tasks - tasks_created = _import_tasks_recursive( - db, project.id, import_data.tasks - ) - - return schemas.ImportResult( - project_id=project.id, - project_name=project.name, - tasks_created=tasks_created - ) - - -@app.get("/") -def root(): - """API health check""" - return { - "status": "online", - "message": "Break It Down (BIT) API", - "docs": "/docs" - } +from fastapi import FastAPI, Depends, HTTPException, UploadFile, File +from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy.orm import Session +from typing import List, Optional +import json + +from . import models, schemas, crud +from .database import engine, get_db +from .settings import settings + +# Create database tables +models.Base.metadata.create_all(bind=engine) + +app = FastAPI( + title=settings.api_title, + description=settings.api_description, + version=settings.api_version +) + +# CORS middleware for frontend +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# ========== PROJECT ENDPOINTS ========== + +@app.get("/api/projects", response_model=List[schemas.Project]) +def list_projects( + skip: int = 0, + limit: int = 100, + archived: Optional[bool] = None, + db: Session = Depends(get_db) +): + """List all projects with optional archive filter""" + return crud.get_projects(db, skip=skip, limit=limit, archived=archived) + + +@app.post("/api/projects", response_model=schemas.Project, status_code=201) +def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db)): + """Create a new project""" + return crud.create_project(db, project) + + +@app.get("/api/projects/{project_id}", response_model=schemas.Project) +def get_project(project_id: int, db: Session = Depends(get_db)): + """Get a specific project""" + db_project = crud.get_project(db, project_id) + if not db_project: + raise HTTPException(status_code=404, detail="Project not found") + return db_project + + +@app.put("/api/projects/{project_id}", response_model=schemas.Project) +def update_project( + project_id: int, project: schemas.ProjectUpdate, db: Session = Depends(get_db) +): + """Update a project""" + db_project = crud.update_project(db, project_id, project) + if not db_project: + raise HTTPException(status_code=404, detail="Project not found") + return db_project + + +@app.delete("/api/projects/{project_id}", status_code=204) +def delete_project(project_id: int, db: Session = Depends(get_db)): + """Delete a project and all its tasks""" + if not crud.delete_project(db, project_id): + raise HTTPException(status_code=404, detail="Project not found") + return None + + +# ========== TASK ENDPOINTS ========== + +@app.get("/api/projects/{project_id}/tasks", response_model=List[schemas.Task]) +def list_project_tasks(project_id: int, db: Session = Depends(get_db)): + """List all tasks for a project""" + if not crud.get_project(db, project_id): + raise HTTPException(status_code=404, detail="Project not found") + return crud.get_tasks_by_project(db, project_id) + + +@app.get("/api/projects/{project_id}/tasks/tree", response_model=List[schemas.TaskWithSubtasks]) +def get_project_task_tree(project_id: int, db: Session = Depends(get_db)): + """Get the task tree (root tasks with nested subtasks) for a project""" + if not crud.get_project(db, project_id): + raise HTTPException(status_code=404, detail="Project not found") + + root_tasks = crud.get_root_tasks(db, project_id) + + def build_tree(task): + task_dict = schemas.TaskWithSubtasks.model_validate(task) + task_dict.subtasks = [build_tree(subtask) for subtask in task.subtasks] + return task_dict + + return [build_tree(task) for task in root_tasks] + + +@app.get("/api/projects/{project_id}/tasks/by-status/{status}", response_model=List[schemas.Task]) +def get_tasks_by_status( + project_id: int, + status: str, + db: Session = Depends(get_db) +): + """Get all tasks for a project filtered by status (for Kanban view)""" + if not crud.get_project(db, project_id): + raise HTTPException(status_code=404, detail="Project not found") + try: + return crud.get_tasks_by_status(db, project_id, status) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@app.post("/api/tasks", response_model=schemas.Task, status_code=201) +def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)): + """Create a new task""" + if not crud.get_project(db, task.project_id): + raise HTTPException(status_code=404, detail="Project not found") + + if task.parent_task_id and not crud.get_task(db, task.parent_task_id): + raise HTTPException(status_code=404, detail="Parent task not found") + + try: + return crud.create_task(db, task) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@app.get("/api/tasks/{task_id}", response_model=schemas.Task) +def get_task(task_id: int, db: Session = Depends(get_db)): + """Get a specific task""" + db_task = crud.get_task(db, task_id) + if not db_task: + raise HTTPException(status_code=404, detail="Task not found") + return db_task + + +@app.put("/api/tasks/{task_id}", response_model=schemas.Task) +def update_task(task_id: int, task: schemas.TaskUpdate, db: Session = Depends(get_db)): + """Update a task""" + try: + db_task = crud.update_task(db, task_id, task) + if not db_task: + raise HTTPException(status_code=404, detail="Task not found") + return db_task + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@app.delete("/api/tasks/{task_id}", status_code=204) +def delete_task(task_id: int, db: Session = Depends(get_db)): + """Delete a task and all its subtasks""" + if not crud.delete_task(db, task_id): + raise HTTPException(status_code=404, detail="Task not found") + return None + + +# ========== BLOCKER ENDPOINTS ========== + +@app.get("/api/tasks/{task_id}/blockers", response_model=List[schemas.BlockerInfo]) +def get_task_blockers(task_id: int, db: Session = Depends(get_db)): + """Get all tasks that are blocking a given task.""" + task = crud.get_task_with_blockers(db, task_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + return task.blockers + + +@app.get("/api/tasks/{task_id}/blocking", response_model=List[schemas.BlockerInfo]) +def get_tasks_this_blocks(task_id: int, db: Session = Depends(get_db)): + """Get all tasks that this task is currently blocking.""" + task = crud.get_task_with_blockers(db, task_id) + if not task: + raise HTTPException(status_code=404, detail="Task not found") + return task.blocking + + +@app.post("/api/tasks/{task_id}/blockers/{blocker_id}", status_code=201) +def add_blocker(task_id: int, blocker_id: int, db: Session = Depends(get_db)): + """Add blocker_id as a prerequisite of task_id.""" + try: + crud.add_blocker(db, task_id, blocker_id) + return {"status": "ok"} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@app.delete("/api/tasks/{task_id}/blockers/{blocker_id}", status_code=204) +def remove_blocker(task_id: int, blocker_id: int, db: Session = Depends(get_db)): + """Remove blocker_id as a prerequisite of task_id.""" + if not crud.remove_blocker(db, task_id, blocker_id): + raise HTTPException(status_code=404, detail="Blocker relationship not found") + return None + + +@app.get("/api/actionable", response_model=List[schemas.ActionableTask]) +def get_actionable_tasks(db: Session = Depends(get_db)): + """Get all non-done tasks with no incomplete blockers, across all projects.""" + return crud.get_actionable_tasks(db) + + +# ========== SEARCH ENDPOINT ========== + +@app.get("/api/search", response_model=List[schemas.Task]) +def search_tasks( + query: str, + project_ids: Optional[str] = None, + db: Session = Depends(get_db) +): + """ + Search tasks across projects by title, description, and tags. + + Args: + query: Search term to match against title, description, and tags + project_ids: Comma-separated list of project IDs to search in (optional, searches all if not provided) + """ + # Parse project IDs if provided + project_id_list = None + if project_ids: + try: + project_id_list = [int(pid.strip()) for pid in project_ids.split(',') if pid.strip()] + except ValueError: + raise HTTPException(status_code=400, detail="Invalid project_ids format") + + # Build query + tasks_query = db.query(models.Task) + + # Filter by project IDs if specified + if project_id_list: + tasks_query = tasks_query.filter(models.Task.project_id.in_(project_id_list)) + + # Search in title, description, and tags + search_term = f"%{query}%" + tasks = tasks_query.filter( + (models.Task.title.ilike(search_term)) | + (models.Task.description.ilike(search_term)) | + (models.Task.tags.contains([query])) # Exact tag match + ).all() + + return tasks + + +# ========== JSON IMPORT ENDPOINT ========== + +def _validate_task_statuses_recursive( + tasks: List[schemas.ImportSubtask], + valid_statuses: List[str], + path: str = "" +) -> None: + """Recursively validate all task statuses against the project's valid statuses""" + for idx, task_data in enumerate(tasks): + task_path = f"{path}.tasks[{idx}]" if path else f"tasks[{idx}]" + if task_data.status not in valid_statuses: + raise ValueError( + f"Invalid status '{task_data.status}' at {task_path}. " + f"Must be one of: {', '.join(valid_statuses)}" + ) + if task_data.subtasks: + _validate_task_statuses_recursive( + task_data.subtasks, + valid_statuses, + f"{task_path}.subtasks" + ) + + +def _import_tasks_recursive( + db: Session, + project_id: int, + tasks: List[schemas.ImportSubtask], + parent_id: Optional[int] = None, + count: int = 0 +) -> int: + """Recursively import tasks and their subtasks""" + for idx, task_data in enumerate(tasks): + task = schemas.TaskCreate( + project_id=project_id, + parent_task_id=parent_id, + title=task_data.title, + description=task_data.description, + status=task_data.status, + estimated_minutes=task_data.estimated_minutes, + tags=task_data.tags, + flag_color=task_data.flag_color, + sort_order=idx + ) + db_task = crud.create_task(db, task) + count += 1 + + if task_data.subtasks: + count = _import_tasks_recursive( + db, project_id, task_data.subtasks, db_task.id, count + ) + + return count + + +@app.post("/api/import-json", response_model=schemas.ImportResult) +def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_db)): + """ + Import a project with nested tasks from JSON. + + Expected format: + { + "project": { + "name": "Project Name", + "description": "Optional description", + "statuses": ["backlog", "in_progress", "on_hold", "done"] // Optional + }, + "tasks": [ + { + "title": "Task 1", + "description": "Optional", + "status": "backlog", + "subtasks": [ + { + "title": "Subtask 1.1", + "status": "backlog", + "subtasks": [] + } + ] + } + ] + } + """ + # Create the project with optional statuses + project = crud.create_project( + db, + schemas.ProjectCreate( + name=import_data.project.name, + description=import_data.project.description, + statuses=import_data.project.statuses + ) + ) + + # Validate all task statuses before importing + if import_data.tasks: + try: + _validate_task_statuses_recursive(import_data.tasks, project.statuses) + except ValueError as e: + # Rollback the project creation if validation fails + db.delete(project) + db.commit() + raise HTTPException(status_code=400, detail=str(e)) + + # Recursively import tasks + tasks_created = _import_tasks_recursive( + db, project.id, import_data.tasks + ) + + return schemas.ImportResult( + project_id=project.id, + project_name=project.name, + tasks_created=tasks_created + ) + + +@app.get("/") +def root(): + """API health check""" + return { + "status": "online", + "message": "Break It Down (BIT) API", + "docs": "/docs" + } diff --git a/backend/app/models.py b/backend/app/models.py index a472d3d..1541503 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,42 +1,61 @@ -from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Boolean -from sqlalchemy.orm import relationship -from datetime import datetime -from .database import Base - - -# Default statuses for new projects -DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"] - - -class Project(Base): - __tablename__ = "projects" - - id = Column(Integer, primary_key=True, index=True) - name = Column(String(255), nullable=False) - description = Column(Text, nullable=True) - statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES) - is_archived = Column(Boolean, default=False, nullable=False) - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - - tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan") - - -class Task(Base): - __tablename__ = "tasks" - - id = Column(Integer, primary_key=True, index=True) - project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) - parent_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True) - title = Column(String(500), nullable=False) - description = Column(Text, nullable=True) - status = Column(String(50), default="backlog", nullable=False) - sort_order = Column(Integer, default=0) - estimated_minutes = Column(Integer, nullable=True) - tags = Column(JSON, nullable=True) - flag_color = Column(String(50), nullable=True) - created_at = Column(DateTime, default=datetime.utcnow) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) - - project = relationship("Project", back_populates="tasks") - parent = relationship("Task", remote_side=[id], backref="subtasks") +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Boolean, Table +from sqlalchemy.orm import relationship +from datetime import datetime +from .database import Base + + +# Default statuses for new projects +DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"] + + +# Association table for task blocker relationships (many-to-many) +task_blockers = Table( + "task_blockers", + Base.metadata, + Column("task_id", Integer, ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True), + Column("blocked_by_id", Integer, ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True), +) + + +class Project(Base): + __tablename__ = "projects" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES) + is_archived = Column(Boolean, default=False, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan") + + +class Task(Base): + __tablename__ = "tasks" + + id = Column(Integer, primary_key=True, index=True) + project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) + parent_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True) + title = Column(String(500), nullable=False) + description = Column(Text, nullable=True) + status = Column(String(50), default="backlog", nullable=False) + sort_order = Column(Integer, default=0) + estimated_minutes = Column(Integer, nullable=True) + tags = Column(JSON, nullable=True) + flag_color = Column(String(50), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + project = relationship("Project", back_populates="tasks") + parent = relationship("Task", remote_side=[id], backref="subtasks") + + # blockers: tasks that must be done before this task can start + # blocking: tasks that this task is holding up + blockers = relationship( + "Task", + secondary=task_blockers, + primaryjoin=lambda: Task.id == task_blockers.c.task_id, + secondaryjoin=lambda: Task.id == task_blockers.c.blocked_by_id, + backref="blocking", + ) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 417f0a0..4683893 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1,107 +1,138 @@ -from pydantic import BaseModel, ConfigDict -from typing import Optional, List -from datetime import datetime -from .models import DEFAULT_STATUSES - - -# Task Schemas -class TaskBase(BaseModel): - title: str - description: Optional[str] = None - status: str = "backlog" - parent_task_id: Optional[int] = None - sort_order: int = 0 - estimated_minutes: Optional[int] = None - tags: Optional[List[str]] = None - flag_color: Optional[str] = None - - -class TaskCreate(TaskBase): - project_id: int - - -class TaskUpdate(BaseModel): - title: Optional[str] = None - description: Optional[str] = None - status: Optional[str] = None - parent_task_id: Optional[int] = None - sort_order: Optional[int] = None - estimated_minutes: Optional[int] = None - tags: Optional[List[str]] = None - flag_color: Optional[str] = None - - -class Task(TaskBase): - id: int - project_id: int - created_at: datetime - updated_at: datetime - - model_config = ConfigDict(from_attributes=True) - - -class TaskWithSubtasks(Task): - subtasks: List['TaskWithSubtasks'] = [] - - model_config = ConfigDict(from_attributes=True) - - -# Project Schemas -class ProjectBase(BaseModel): - name: str - description: Optional[str] = None - - -class ProjectCreate(ProjectBase): - statuses: Optional[List[str]] = None - - -class ProjectUpdate(BaseModel): - name: Optional[str] = None - description: Optional[str] = None - statuses: Optional[List[str]] = None - is_archived: Optional[bool] = None - - -class Project(ProjectBase): - id: int - statuses: List[str] - is_archived: bool - created_at: datetime - updated_at: datetime - - model_config = ConfigDict(from_attributes=True) - - -class ProjectWithTasks(Project): - tasks: List[Task] = [] - - model_config = ConfigDict(from_attributes=True) - - -# JSON Import Schemas -class ImportSubtask(BaseModel): - title: str - description: Optional[str] = None - status: str = "backlog" - estimated_minutes: Optional[int] = None - tags: Optional[List[str]] = None - flag_color: Optional[str] = None - subtasks: List['ImportSubtask'] = [] - - -class ImportProject(BaseModel): - name: str - description: Optional[str] = None - statuses: Optional[List[str]] = None - - -class ImportData(BaseModel): - project: ImportProject - tasks: List[ImportSubtask] = [] - - -class ImportResult(BaseModel): - project_id: int - project_name: str - tasks_created: int +from pydantic import BaseModel, ConfigDict +from typing import Optional, List +from datetime import datetime +from .models import DEFAULT_STATUSES + + +# Task Schemas +class TaskBase(BaseModel): + title: str + description: Optional[str] = None + status: str = "backlog" + parent_task_id: Optional[int] = None + sort_order: int = 0 + estimated_minutes: Optional[int] = None + tags: Optional[List[str]] = None + flag_color: Optional[str] = None + + +class TaskCreate(TaskBase): + project_id: int + + +class TaskUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + status: Optional[str] = None + parent_task_id: Optional[int] = None + sort_order: Optional[int] = None + estimated_minutes: Optional[int] = None + tags: Optional[List[str]] = None + flag_color: Optional[str] = None + + +class Task(TaskBase): + id: int + project_id: int + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class TaskWithSubtasks(Task): + subtasks: List['TaskWithSubtasks'] = [] + + model_config = ConfigDict(from_attributes=True) + + +class BlockerInfo(BaseModel): + """Lightweight task info used when listing blockers/blocking relationships.""" + id: int + title: str + project_id: int + status: str + + model_config = ConfigDict(from_attributes=True) + + +class TaskWithBlockers(Task): + blockers: List[BlockerInfo] = [] + blocking: List[BlockerInfo] = [] + + model_config = ConfigDict(from_attributes=True) + + +class ActionableTask(BaseModel): + """A task that is ready to work on — not done, and all blockers are resolved.""" + id: int + title: str + project_id: int + project_name: str + status: str + estimated_minutes: Optional[int] = None + tags: Optional[List[str]] = None + flag_color: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) + + +# Project Schemas +class ProjectBase(BaseModel): + name: str + description: Optional[str] = None + + +class ProjectCreate(ProjectBase): + statuses: Optional[List[str]] = None + + +class ProjectUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + statuses: Optional[List[str]] = None + is_archived: Optional[bool] = None + + +class Project(ProjectBase): + id: int + statuses: List[str] + is_archived: bool + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class ProjectWithTasks(Project): + tasks: List[Task] = [] + + model_config = ConfigDict(from_attributes=True) + + +# JSON Import Schemas +class ImportSubtask(BaseModel): + title: str + description: Optional[str] = None + status: str = "backlog" + estimated_minutes: Optional[int] = None + tags: Optional[List[str]] = None + flag_color: Optional[str] = None + subtasks: List['ImportSubtask'] = [] + + +class ImportProject(BaseModel): + name: str + description: Optional[str] = None + statuses: Optional[List[str]] = None + + +class ImportData(BaseModel): + project: ImportProject + tasks: List[ImportSubtask] = [] + + +class ImportResult(BaseModel): + project_id: int + project_name: str + tasks_created: int diff --git a/backend/migrate_add_blockers.py b/backend/migrate_add_blockers.py new file mode 100644 index 0000000..503f00a --- /dev/null +++ b/backend/migrate_add_blockers.py @@ -0,0 +1,40 @@ +""" +Migration script to add the task_blockers association table. +Run this once if you have an existing database. + +Usage (from inside the backend container or with the venv active): + python migrate_add_blockers.py +""" +import sqlite3 +import os + +db_path = os.path.join(os.path.dirname(__file__), 'bit.db') + +if not os.path.exists(db_path): + print(f"Database not found at {db_path}") + print("No migration needed — new database will be created with the correct schema.") + exit(0) + +conn = sqlite3.connect(db_path) +cursor = conn.cursor() + +try: + # Check if the table already exists + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='task_blockers'") + if cursor.fetchone(): + print("Table 'task_blockers' already exists. Migration not needed.") + else: + cursor.execute(""" + CREATE TABLE task_blockers ( + task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + blocked_by_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + PRIMARY KEY (task_id, blocked_by_id) + ) + """) + conn.commit() + print("Successfully created 'task_blockers' table.") +except Exception as e: + print(f"Error during migration: {e}") + conn.rollback() +finally: + conn.close() diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ba62abc..0dcb6cd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,34 +1,56 @@ -import { Routes, Route } from 'react-router-dom' -import ProjectList from './pages/ProjectList' -import ProjectView from './pages/ProjectView' -import SearchBar from './components/SearchBar' - -function App() { - return ( -
-
-
-
-
-

- BIT - Break It Down - v{import.meta.env.VITE_APP_VERSION || '0.1.6'} -

-
- -
-
-
- -
- - } /> - } /> - -
-
- ) -} - -export default App +import { Routes, Route, useNavigate, useLocation } from 'react-router-dom' +import { Zap } from 'lucide-react' +import ProjectList from './pages/ProjectList' +import ProjectView from './pages/ProjectView' +import ActionableView from './pages/ActionableView' +import SearchBar from './components/SearchBar' + +function App() { + const navigate = useNavigate() + const location = useLocation() + const isActionable = location.pathname === '/actionable' + + return ( +
+
+
+
+
+

navigate('/')} + > + BIT + Break It Down + v{import.meta.env.VITE_APP_VERSION || '0.1.6'} +

+ +
+ +
+
+
+ +
+ + } /> + } /> + } /> + +
+
+ ) +} + +export default App diff --git a/frontend/src/components/BlockerPanel.jsx b/frontend/src/components/BlockerPanel.jsx new file mode 100644 index 0000000..ccf328d --- /dev/null +++ b/frontend/src/components/BlockerPanel.jsx @@ -0,0 +1,201 @@ +import { useState, useEffect, useRef } from 'react' +import { X, Search, Lock, Unlock, AlertTriangle } from 'lucide-react' +import { getTaskBlockers, addBlocker, removeBlocker, searchTasks } from '../utils/api' + +function BlockerPanel({ task, onClose, onUpdate }) { + const [blockers, setBlockers] = useState([]) + const [searchQuery, setSearchQuery] = useState('') + const [searchResults, setSearchResults] = useState([]) + const [searching, setSearching] = useState(false) + const [error, setError] = useState('') + const [loading, setLoading] = useState(true) + const searchTimeout = useRef(null) + + useEffect(() => { + loadBlockers() + }, [task.id]) + + const loadBlockers = async () => { + try { + setLoading(true) + const data = await getTaskBlockers(task.id) + setBlockers(data) + } catch (err) { + setError(err.message) + } finally { + setLoading(false) + } + } + + const handleSearch = (query) => { + setSearchQuery(query) + setError('') + + if (searchTimeout.current) clearTimeout(searchTimeout.current) + + if (!query.trim()) { + setSearchResults([]) + return + } + + searchTimeout.current = setTimeout(async () => { + try { + setSearching(true) + const results = await searchTasks(query) + // Filter out the current task and tasks already blocking this one + const blockerIds = new Set(blockers.map(b => b.id)) + const filtered = results.filter(t => t.id !== task.id && !blockerIds.has(t.id)) + setSearchResults(filtered.slice(0, 8)) + } catch (err) { + setError(err.message) + } finally { + setSearching(false) + } + }, 300) + } + + const handleAddBlocker = async (blocker) => { + try { + setError('') + await addBlocker(task.id, blocker.id) + setBlockers(prev => [...prev, blocker]) + setSearchResults(prev => prev.filter(t => t.id !== blocker.id)) + setSearchQuery('') + setSearchResults([]) + onUpdate() + } catch (err) { + setError(err.message) + } + } + + const handleRemoveBlocker = async (blockerId) => { + try { + setError('') + await removeBlocker(task.id, blockerId) + setBlockers(prev => prev.filter(b => b.id !== blockerId)) + onUpdate() + } catch (err) { + setError(err.message) + } + } + + const incompleteBlockers = blockers.filter(b => b.status !== 'done') + const isBlocked = incompleteBlockers.length > 0 + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+ {isBlocked + ? + : + } + Blockers + + — {task.title} + +
+ +
+ +
+ {/* Status banner */} + {isBlocked ? ( +
+ + {incompleteBlockers.length} incomplete blocker{incompleteBlockers.length !== 1 ? 's' : ''} — this task is locked +
+ ) : ( +
+ + No active blockers — this task is ready to work on +
+ )} + + {/* Current blockers list */} + {loading ? ( +
Loading...
+ ) : blockers.length > 0 ? ( +
+

Blocked by

+ {blockers.map(b => ( +
+
+ + {b.title} + #{b.id} +
+ +
+ ))} +
+ ) : ( +

No blockers added yet

+ )} + + {/* Search to add blocker */} +
+

Add a blocker

+
+ + handleSearch(e.target.value)} + placeholder="Search tasks across all projects..." + className="w-full pl-9 pr-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" + autoFocus + /> +
+ + {/* Search results */} + {(searchResults.length > 0 || searching) && ( +
+ {searching && ( +
Searching...
+ )} + {searchResults.map(result => ( + + ))} + {!searching && searchResults.length === 0 && searchQuery && ( +
No matching tasks found
+ )} +
+ )} +
+ + {error && ( +
+ {error} +
+ )} +
+
+
+ ) +} + +export default BlockerPanel diff --git a/frontend/src/components/TaskMenu.jsx b/frontend/src/components/TaskMenu.jsx index ad5a906..8702840 100644 --- a/frontend/src/components/TaskMenu.jsx +++ b/frontend/src/components/TaskMenu.jsx @@ -1,405 +1,429 @@ -import { useState, useRef, useEffect } from 'react' -import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo, FileText } from 'lucide-react' -import { updateTask } from '../utils/api' - -const FLAG_COLORS = [ - { name: 'red', color: 'bg-red-500' }, - { name: 'orange', color: 'bg-orange-500' }, - { name: 'yellow', color: 'bg-yellow-500' }, - { name: 'green', color: 'bg-green-500' }, - { name: 'blue', color: 'bg-blue-500' }, - { name: 'purple', color: 'bg-purple-500' }, - { name: 'pink', color: 'bg-pink-500' } -] - -// Helper to format status label -const formatStatusLabel = (status) => { - return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') -} - -// Helper to get status color -const getStatusTextColor = (status) => { - const lowerStatus = status.toLowerCase() - if (lowerStatus === 'backlog') return 'text-gray-400' - if (lowerStatus === 'in_progress' || lowerStatus.includes('progress')) return 'text-blue-400' - if (lowerStatus === 'on_hold' || lowerStatus.includes('hold') || lowerStatus.includes('waiting')) return 'text-yellow-400' - if (lowerStatus === 'done' || lowerStatus.includes('complete')) return 'text-green-400' - if (lowerStatus.includes('blocked')) return 'text-red-400' - return 'text-purple-400' // default for custom statuses -} - -function TaskMenu({ task, onUpdate, onDelete, onEdit, projectStatuses }) { - const [isOpen, setIsOpen] = useState(false) - const [showTimeEdit, setShowTimeEdit] = useState(false) - const [showDescriptionEdit, setShowDescriptionEdit] = useState(false) - const [showTagsEdit, setShowTagsEdit] = useState(false) - const [showFlagEdit, setShowFlagEdit] = useState(false) - const [showStatusEdit, setShowStatusEdit] = useState(false) - - // Calculate hours and minutes from task.estimated_minutes - const initialHours = task.estimated_minutes ? Math.floor(task.estimated_minutes / 60) : '' - const initialMinutes = task.estimated_minutes ? task.estimated_minutes % 60 : '' - - const [editHours, setEditHours] = useState(initialHours) - const [editMinutes, setEditMinutes] = useState(initialMinutes) - const [editDescription, setEditDescription] = useState(task.description || '') - const [editTags, setEditTags] = useState(task.tags ? task.tags.join(', ') : '') - const menuRef = useRef(null) - - useEffect(() => { - function handleClickOutside(event) { - if (menuRef.current && !menuRef.current.contains(event.target)) { - setIsOpen(false) - setShowTimeEdit(false) - setShowDescriptionEdit(false) - setShowTagsEdit(false) - setShowFlagEdit(false) - setShowStatusEdit(false) - } - } - - if (isOpen) { - document.addEventListener('mousedown', handleClickOutside) - return () => document.removeEventListener('mousedown', handleClickOutside) - } - }, [isOpen]) - - const handleUpdateTime = async () => { - try { - const totalMinutes = (parseInt(editHours) || 0) * 60 + (parseInt(editMinutes) || 0) - const minutes = totalMinutes > 0 ? totalMinutes : null - await updateTask(task.id, { estimated_minutes: minutes }) - setShowTimeEdit(false) - setIsOpen(false) - onUpdate() - } catch (err) { - alert(`Error: ${err.message}`) - } - } - - const handleUpdateDescription = async () => { - try { - const description = editDescription.trim() || null - await updateTask(task.id, { description }) - setShowDescriptionEdit(false) - setIsOpen(false) - onUpdate() - } catch (err) { - alert(`Error: ${err.message}`) - } - } - - const handleUpdateTags = async () => { - try { - const tags = editTags - ? editTags.split(',').map(t => t.trim()).filter(t => t.length > 0) - : null - await updateTask(task.id, { tags }) - setShowTagsEdit(false) - setIsOpen(false) - onUpdate() - } catch (err) { - alert(`Error: ${err.message}`) - } - } - - const handleUpdateFlag = async (color) => { - try { - await updateTask(task.id, { flag_color: color }) - setShowFlagEdit(false) - setIsOpen(false) - onUpdate() - } catch (err) { - alert(`Error: ${err.message}`) - } - } - - const handleClearFlag = async () => { - try { - await updateTask(task.id, { flag_color: null }) - setShowFlagEdit(false) - setIsOpen(false) - onUpdate() - } catch (err) { - alert(`Error: ${err.message}`) - } - } - - const handleUpdateStatus = async (newStatus) => { - try { - await updateTask(task.id, { status: newStatus }) - setShowStatusEdit(false) - setIsOpen(false) - onUpdate() - } catch (err) { - alert(`Error: ${err.message}`) - } - } - - return ( -
- - - {isOpen && ( -
- {/* Time Edit */} - {showTimeEdit ? ( -
-
- - Time Estimate -
-
- setEditHours(e.target.value)} - placeholder="Hours" - className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" - autoFocus - onClick={(e) => e.stopPropagation()} - /> - setEditMinutes(e.target.value)} - placeholder="Minutes" - className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" - onClick={(e) => e.stopPropagation()} - /> -
-
- - -
-
- ) : ( - - )} - - {/* Description Edit */} - {showDescriptionEdit ? ( -
-
- - Description -
-
-