Compare commits

..

3 Commits

Author SHA1 Message Date
serversdown
5da6e075b4 feat: add task blocker dependency graph + actionable now view
- 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 <noreply@anthropic.com>
2026-03-29 16:08:55 -04:00
serversdwn
c6ed57342c Merge remote branch and resolve conflicts with BIT rename
Kept remote's pydantic-settings, env_file, SearchBar, and new components.
Applied BIT/Break It Down naming throughout conflicted files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-17 22:56:08 +00:00
serversdwn
5d5cec048f chore: reband cleanup, changed names from tesseract 2026-02-17 22:50:57 +00:00
11 changed files with 1852 additions and 1177 deletions

View File

@@ -1,188 +1,283 @@
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from typing import List, Optional from typing import List, Optional
from . import models, schemas from . import models, schemas
# Project CRUD # Project CRUD
def create_project(db: Session, project: schemas.ProjectCreate) -> models.Project: def create_project(db: Session, project: schemas.ProjectCreate) -> models.Project:
project_data = project.model_dump() project_data = project.model_dump()
# Ensure statuses has a default value if not provided # Ensure statuses has a default value if not provided
if project_data.get("statuses") is None: if project_data.get("statuses") is None:
project_data["statuses"] = models.DEFAULT_STATUSES project_data["statuses"] = models.DEFAULT_STATUSES
db_project = models.Project(**project_data) db_project = models.Project(**project_data)
db.add(db_project) db.add(db_project)
db.commit() db.commit()
db.refresh(db_project) db.refresh(db_project)
return db_project return db_project
def get_project(db: Session, project_id: int) -> Optional[models.Project]: def get_project(db: Session, project_id: int) -> Optional[models.Project]:
return db.query(models.Project).filter(models.Project.id == project_id).first() 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]: def get_projects(db: Session, skip: int = 0, limit: int = 100, archived: Optional[bool] = None) -> List[models.Project]:
query = db.query(models.Project) query = db.query(models.Project)
# Filter by archive status if specified # Filter by archive status if specified
if archived is not None: if archived is not None:
query = query.filter(models.Project.is_archived == archived) query = query.filter(models.Project.is_archived == archived)
return query.offset(skip).limit(limit).all() return query.offset(skip).limit(limit).all()
def update_project( def update_project(
db: Session, project_id: int, project: schemas.ProjectUpdate db: Session, project_id: int, project: schemas.ProjectUpdate
) -> Optional[models.Project]: ) -> Optional[models.Project]:
db_project = get_project(db, project_id) db_project = get_project(db, project_id)
if not db_project: if not db_project:
return None return None
update_data = project.model_dump(exclude_unset=True) update_data = project.model_dump(exclude_unset=True)
for key, value in update_data.items(): for key, value in update_data.items():
setattr(db_project, key, value) setattr(db_project, key, value)
db.commit() db.commit()
db.refresh(db_project) db.refresh(db_project)
return db_project return db_project
def delete_project(db: Session, project_id: int) -> bool: def delete_project(db: Session, project_id: int) -> bool:
db_project = get_project(db, project_id) db_project = get_project(db, project_id)
if not db_project: if not db_project:
return False return False
db.delete(db_project) db.delete(db_project)
db.commit() db.commit()
return True return True
# Task CRUD # Task CRUD
def create_task(db: Session, task: schemas.TaskCreate) -> models.Task: def create_task(db: Session, task: schemas.TaskCreate) -> models.Task:
# Validate status against project's statuses # Validate status against project's statuses
project = get_project(db, task.project_id) project = get_project(db, task.project_id)
if project and task.status not in project.statuses: if project and task.status not in project.statuses:
raise ValueError(f"Invalid status '{task.status}'. Must be one of: {', '.join(project.statuses)}") raise ValueError(f"Invalid status '{task.status}'. Must be one of: {', '.join(project.statuses)}")
# Get max sort_order for siblings # Get max sort_order for siblings
if task.parent_task_id: if task.parent_task_id:
max_order = db.query(models.Task).filter( max_order = db.query(models.Task).filter(
models.Task.parent_task_id == task.parent_task_id models.Task.parent_task_id == task.parent_task_id
).count() ).count()
else: else:
max_order = db.query(models.Task).filter( max_order = db.query(models.Task).filter(
models.Task.project_id == task.project_id, models.Task.project_id == task.project_id,
models.Task.parent_task_id.is_(None) models.Task.parent_task_id.is_(None)
).count() ).count()
task_data = task.model_dump() task_data = task.model_dump()
if "sort_order" not in task_data or task_data["sort_order"] == 0: if "sort_order" not in task_data or task_data["sort_order"] == 0:
task_data["sort_order"] = max_order task_data["sort_order"] = max_order
db_task = models.Task(**task_data) db_task = models.Task(**task_data)
db.add(db_task) db.add(db_task)
db.commit() db.commit()
db.refresh(db_task) db.refresh(db_task)
return db_task return db_task
def get_task(db: Session, task_id: int) -> Optional[models.Task]: def get_task(db: Session, task_id: int) -> Optional[models.Task]:
return db.query(models.Task).filter(models.Task.id == task_id).first() 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]: 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() 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]: def get_root_tasks(db: Session, project_id: int) -> List[models.Task]:
"""Get all root-level tasks (no parent) for a project""" """Get all root-level tasks (no parent) for a project"""
return db.query(models.Task).filter( return db.query(models.Task).filter(
models.Task.project_id == project_id, models.Task.project_id == project_id,
models.Task.parent_task_id.is_(None) models.Task.parent_task_id.is_(None)
).order_by(models.Task.sort_order).all() ).order_by(models.Task.sort_order).all()
def get_task_with_subtasks(db: Session, task_id: int) -> Optional[models.Task]: def get_task_with_subtasks(db: Session, task_id: int) -> Optional[models.Task]:
"""Recursively load a task with all its subtasks""" """Recursively load a task with all its subtasks"""
return db.query(models.Task).options( return db.query(models.Task).options(
joinedload(models.Task.subtasks) joinedload(models.Task.subtasks)
).filter(models.Task.id == task_id).first() ).filter(models.Task.id == task_id).first()
def check_and_update_parent_status(db: Session, parent_id: int): 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""" """Check if all children of a parent are done, and mark parent as done if so"""
# Get all children of this parent # Get all children of this parent
children = db.query(models.Task).filter( children = db.query(models.Task).filter(
models.Task.parent_task_id == parent_id models.Task.parent_task_id == parent_id
).all() ).all()
# If no children, nothing to do # If no children, nothing to do
if not children: if not children:
return return
# Check if all children are done # Check if all children are done
all_done = all(child.status == "done" for child in children) all_done = all(child.status == "done" for child in children)
if all_done: if all_done:
# Mark parent as done # Mark parent as done
parent = get_task(db, parent_id) parent = get_task(db, parent_id)
if parent and parent.status != "done": if parent and parent.status != "done":
parent.status = "done" parent.status = "done"
db.commit() db.commit()
# Recursively check grandparent # Recursively check grandparent
if parent.parent_task_id: if parent.parent_task_id:
check_and_update_parent_status(db, parent.parent_task_id) check_and_update_parent_status(db, parent.parent_task_id)
def update_task( def update_task(
db: Session, task_id: int, task: schemas.TaskUpdate db: Session, task_id: int, task: schemas.TaskUpdate
) -> Optional[models.Task]: ) -> Optional[models.Task]:
db_task = get_task(db, task_id) db_task = get_task(db, task_id)
if not db_task: if not db_task:
return None return None
update_data = task.model_dump(exclude_unset=True) update_data = task.model_dump(exclude_unset=True)
# Validate status against project's statuses if status is being updated # Validate status against project's statuses if status is being updated
if "status" in update_data: if "status" in update_data:
project = get_project(db, db_task.project_id) project = get_project(db, db_task.project_id)
if project and update_data["status"] not in project.statuses: 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)}") raise ValueError(f"Invalid status '{update_data['status']}'. Must be one of: {', '.join(project.statuses)}")
status_changed = True status_changed = True
old_status = db_task.status old_status = db_task.status
else: else:
status_changed = False status_changed = False
for key, value in update_data.items(): for key, value in update_data.items():
setattr(db_task, key, value) setattr(db_task, key, value)
db.commit() db.commit()
db.refresh(db_task) db.refresh(db_task)
# If status changed to 'done' and this task has a parent, check if parent should auto-complete # 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: 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) check_and_update_parent_status(db, db_task.parent_task_id)
return db_task return db_task
def delete_task(db: Session, task_id: int) -> bool: def delete_task(db: Session, task_id: int) -> bool:
db_task = get_task(db, task_id) db_task = get_task(db, task_id)
if not db_task: if not db_task:
return False return False
db.delete(db_task) db.delete(db_task)
db.commit() db.commit()
return True return True
def get_tasks_by_status(db: Session, project_id: int, status: str) -> List[models.Task]: 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""" """Get all tasks for a project with a specific status"""
# Validate status against project's statuses # Validate status against project's statuses
project = get_project(db, project_id) project = get_project(db, project_id)
if project and status not in project.statuses: if project and status not in project.statuses:
raise ValueError(f"Invalid status '{status}'. Must be one of: {', '.join(project.statuses)}") raise ValueError(f"Invalid status '{status}'. Must be one of: {', '.join(project.statuses)}")
return db.query(models.Task).filter( return db.query(models.Task).filter(
models.Task.project_id == project_id, models.Task.project_id == project_id,
models.Task.status == status models.Task.status == status
).all() ).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

View File

@@ -1,324 +1,368 @@
from fastapi import FastAPI, Depends, HTTPException, UploadFile, File from fastapi import FastAPI, Depends, HTTPException, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Optional from typing import List, Optional
import json import json
from . import models, schemas, crud from . import models, schemas, crud
from .database import engine, get_db from .database import engine, get_db
from .settings import settings from .settings import settings
# Create database tables # Create database tables
models.Base.metadata.create_all(bind=engine) models.Base.metadata.create_all(bind=engine)
app = FastAPI( app = FastAPI(
title=settings.api_title, title=settings.api_title,
description=settings.api_description, description=settings.api_description,
version=settings.api_version version=settings.api_version
) )
# CORS middleware for frontend # CORS middleware for frontend
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origins_list, allow_origins=settings.cors_origins_list,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# ========== PROJECT ENDPOINTS ========== # ========== PROJECT ENDPOINTS ==========
@app.get("/api/projects", response_model=List[schemas.Project]) @app.get("/api/projects", response_model=List[schemas.Project])
def list_projects( def list_projects(
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
archived: Optional[bool] = None, archived: Optional[bool] = None,
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""List all projects with optional archive filter""" """List all projects with optional archive filter"""
return crud.get_projects(db, skip=skip, limit=limit, archived=archived) return crud.get_projects(db, skip=skip, limit=limit, archived=archived)
@app.post("/api/projects", response_model=schemas.Project, status_code=201) @app.post("/api/projects", response_model=schemas.Project, status_code=201)
def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db)): def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db)):
"""Create a new project""" """Create a new project"""
return crud.create_project(db, project) return crud.create_project(db, project)
@app.get("/api/projects/{project_id}", response_model=schemas.Project) @app.get("/api/projects/{project_id}", response_model=schemas.Project)
def get_project(project_id: int, db: Session = Depends(get_db)): def get_project(project_id: int, db: Session = Depends(get_db)):
"""Get a specific project""" """Get a specific project"""
db_project = crud.get_project(db, project_id) db_project = crud.get_project(db, project_id)
if not db_project: if not db_project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
return db_project return db_project
@app.put("/api/projects/{project_id}", response_model=schemas.Project) @app.put("/api/projects/{project_id}", response_model=schemas.Project)
def update_project( def update_project(
project_id: int, project: schemas.ProjectUpdate, db: Session = Depends(get_db) project_id: int, project: schemas.ProjectUpdate, db: Session = Depends(get_db)
): ):
"""Update a project""" """Update a project"""
db_project = crud.update_project(db, project_id, project) db_project = crud.update_project(db, project_id, project)
if not db_project: if not db_project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
return db_project return db_project
@app.delete("/api/projects/{project_id}", status_code=204) @app.delete("/api/projects/{project_id}", status_code=204)
def delete_project(project_id: int, db: Session = Depends(get_db)): def delete_project(project_id: int, db: Session = Depends(get_db)):
"""Delete a project and all its tasks""" """Delete a project and all its tasks"""
if not crud.delete_project(db, project_id): if not crud.delete_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
return None return None
# ========== TASK ENDPOINTS ========== # ========== TASK ENDPOINTS ==========
@app.get("/api/projects/{project_id}/tasks", response_model=List[schemas.Task]) @app.get("/api/projects/{project_id}/tasks", response_model=List[schemas.Task])
def list_project_tasks(project_id: int, db: Session = Depends(get_db)): def list_project_tasks(project_id: int, db: Session = Depends(get_db)):
"""List all tasks for a project""" """List all tasks for a project"""
if not crud.get_project(db, project_id): if not crud.get_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
return crud.get_tasks_by_project(db, project_id) return crud.get_tasks_by_project(db, project_id)
@app.get("/api/projects/{project_id}/tasks/tree", response_model=List[schemas.TaskWithSubtasks]) @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)): 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""" """Get the task tree (root tasks with nested subtasks) for a project"""
if not crud.get_project(db, project_id): if not crud.get_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
root_tasks = crud.get_root_tasks(db, project_id) root_tasks = crud.get_root_tasks(db, project_id)
def build_tree(task): def build_tree(task):
task_dict = schemas.TaskWithSubtasks.model_validate(task) task_dict = schemas.TaskWithSubtasks.model_validate(task)
task_dict.subtasks = [build_tree(subtask) for subtask in task.subtasks] task_dict.subtasks = [build_tree(subtask) for subtask in task.subtasks]
return task_dict return task_dict
return [build_tree(task) for task in root_tasks] return [build_tree(task) for task in root_tasks]
@app.get("/api/projects/{project_id}/tasks/by-status/{status}", response_model=List[schemas.Task]) @app.get("/api/projects/{project_id}/tasks/by-status/{status}", response_model=List[schemas.Task])
def get_tasks_by_status( def get_tasks_by_status(
project_id: int, project_id: int,
status: str, status: str,
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""Get all tasks for a project filtered by status (for Kanban view)""" """Get all tasks for a project filtered by status (for Kanban view)"""
if not crud.get_project(db, project_id): if not crud.get_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
try: try:
return crud.get_tasks_by_status(db, project_id, status) return crud.get_tasks_by_status(db, project_id, status)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/tasks", response_model=schemas.Task, status_code=201) @app.post("/api/tasks", response_model=schemas.Task, status_code=201)
def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)): def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)):
"""Create a new task""" """Create a new task"""
if not crud.get_project(db, task.project_id): if not crud.get_project(db, task.project_id):
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
if task.parent_task_id and not crud.get_task(db, task.parent_task_id): 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") raise HTTPException(status_code=404, detail="Parent task not found")
try: try:
return crud.create_task(db, task) return crud.create_task(db, task)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@app.get("/api/tasks/{task_id}", response_model=schemas.Task) @app.get("/api/tasks/{task_id}", response_model=schemas.Task)
def get_task(task_id: int, db: Session = Depends(get_db)): def get_task(task_id: int, db: Session = Depends(get_db)):
"""Get a specific task""" """Get a specific task"""
db_task = crud.get_task(db, task_id) db_task = crud.get_task(db, task_id)
if not db_task: if not db_task:
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
return db_task return db_task
@app.put("/api/tasks/{task_id}", response_model=schemas.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)): def update_task(task_id: int, task: schemas.TaskUpdate, db: Session = Depends(get_db)):
"""Update a task""" """Update a task"""
try: try:
db_task = crud.update_task(db, task_id, task) db_task = crud.update_task(db, task_id, task)
if not db_task: if not db_task:
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
return db_task return db_task
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@app.delete("/api/tasks/{task_id}", status_code=204) @app.delete("/api/tasks/{task_id}", status_code=204)
def delete_task(task_id: int, db: Session = Depends(get_db)): def delete_task(task_id: int, db: Session = Depends(get_db)):
"""Delete a task and all its subtasks""" """Delete a task and all its subtasks"""
if not crud.delete_task(db, task_id): if not crud.delete_task(db, task_id):
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
return None return None
# ========== SEARCH ENDPOINT ========== # ========== BLOCKER ENDPOINTS ==========
@app.get("/api/search", response_model=List[schemas.Task]) @app.get("/api/tasks/{task_id}/blockers", response_model=List[schemas.BlockerInfo])
def search_tasks( def get_task_blockers(task_id: int, db: Session = Depends(get_db)):
query: str, """Get all tasks that are blocking a given task."""
project_ids: Optional[str] = None, task = crud.get_task_with_blockers(db, task_id)
db: Session = Depends(get_db) if not task:
): raise HTTPException(status_code=404, detail="Task not found")
""" return task.blockers
Search tasks across projects by title, description, and tags.
Args: @app.get("/api/tasks/{task_id}/blocking", response_model=List[schemas.BlockerInfo])
query: Search term to match against title, description, and tags def get_tasks_this_blocks(task_id: int, db: Session = Depends(get_db)):
project_ids: Comma-separated list of project IDs to search in (optional, searches all if not provided) """Get all tasks that this task is currently blocking."""
""" task = crud.get_task_with_blockers(db, task_id)
# Parse project IDs if provided if not task:
project_id_list = None raise HTTPException(status_code=404, detail="Task not found")
if project_ids: return task.blocking
try:
project_id_list = [int(pid.strip()) for pid in project_ids.split(',') if pid.strip()]
except ValueError: @app.post("/api/tasks/{task_id}/blockers/{blocker_id}", status_code=201)
raise HTTPException(status_code=400, detail="Invalid project_ids format") def add_blocker(task_id: int, blocker_id: int, db: Session = Depends(get_db)):
"""Add blocker_id as a prerequisite of task_id."""
# Build query try:
tasks_query = db.query(models.Task) crud.add_blocker(db, task_id, blocker_id)
return {"status": "ok"}
# Filter by project IDs if specified except ValueError as e:
if project_id_list: raise HTTPException(status_code=400, detail=str(e))
tasks_query = tasks_query.filter(models.Task.project_id.in_(project_id_list))
# Search in title, description, and tags @app.delete("/api/tasks/{task_id}/blockers/{blocker_id}", status_code=204)
search_term = f"%{query}%" def remove_blocker(task_id: int, blocker_id: int, db: Session = Depends(get_db)):
tasks = tasks_query.filter( """Remove blocker_id as a prerequisite of task_id."""
(models.Task.title.ilike(search_term)) | if not crud.remove_blocker(db, task_id, blocker_id):
(models.Task.description.ilike(search_term)) | raise HTTPException(status_code=404, detail="Blocker relationship not found")
(models.Task.tags.contains([query])) # Exact tag match return None
).all()
return tasks @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."""
# ========== JSON IMPORT ENDPOINT ========== return crud.get_actionable_tasks(db)
def _validate_task_statuses_recursive(
tasks: List[schemas.ImportSubtask], # ========== SEARCH ENDPOINT ==========
valid_statuses: List[str],
path: str = "" @app.get("/api/search", response_model=List[schemas.Task])
) -> None: def search_tasks(
"""Recursively validate all task statuses against the project's valid statuses""" query: str,
for idx, task_data in enumerate(tasks): project_ids: Optional[str] = None,
task_path = f"{path}.tasks[{idx}]" if path else f"tasks[{idx}]" db: Session = Depends(get_db)
if task_data.status not in valid_statuses: ):
raise ValueError( """
f"Invalid status '{task_data.status}' at {task_path}. " Search tasks across projects by title, description, and tags.
f"Must be one of: {', '.join(valid_statuses)}"
) Args:
if task_data.subtasks: query: Search term to match against title, description, and tags
_validate_task_statuses_recursive( project_ids: Comma-separated list of project IDs to search in (optional, searches all if not provided)
task_data.subtasks, """
valid_statuses, # Parse project IDs if provided
f"{task_path}.subtasks" project_id_list = None
) if project_ids:
try:
project_id_list = [int(pid.strip()) for pid in project_ids.split(',') if pid.strip()]
def _import_tasks_recursive( except ValueError:
db: Session, raise HTTPException(status_code=400, detail="Invalid project_ids format")
project_id: int,
tasks: List[schemas.ImportSubtask], # Build query
parent_id: Optional[int] = None, tasks_query = db.query(models.Task)
count: int = 0
) -> int: # Filter by project IDs if specified
"""Recursively import tasks and their subtasks""" if project_id_list:
for idx, task_data in enumerate(tasks): tasks_query = tasks_query.filter(models.Task.project_id.in_(project_id_list))
task = schemas.TaskCreate(
project_id=project_id, # Search in title, description, and tags
parent_task_id=parent_id, search_term = f"%{query}%"
title=task_data.title, tasks = tasks_query.filter(
description=task_data.description, (models.Task.title.ilike(search_term)) |
status=task_data.status, (models.Task.description.ilike(search_term)) |
estimated_minutes=task_data.estimated_minutes, (models.Task.tags.contains([query])) # Exact tag match
tags=task_data.tags, ).all()
flag_color=task_data.flag_color,
sort_order=idx return tasks
)
db_task = crud.create_task(db, task)
count += 1 # ========== JSON IMPORT ENDPOINT ==========
if task_data.subtasks: def _validate_task_statuses_recursive(
count = _import_tasks_recursive( tasks: List[schemas.ImportSubtask],
db, project_id, task_data.subtasks, db_task.id, count valid_statuses: List[str],
) path: str = ""
) -> None:
return count """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}]"
@app.post("/api/import-json", response_model=schemas.ImportResult) if task_data.status not in valid_statuses:
def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_db)): raise ValueError(
""" f"Invalid status '{task_data.status}' at {task_path}. "
Import a project with nested tasks from JSON. f"Must be one of: {', '.join(valid_statuses)}"
)
Expected format: if task_data.subtasks:
{ _validate_task_statuses_recursive(
"project": { task_data.subtasks,
"name": "Project Name", valid_statuses,
"description": "Optional description", f"{task_path}.subtasks"
"statuses": ["backlog", "in_progress", "on_hold", "done"] // Optional )
},
"tasks": [
{ def _import_tasks_recursive(
"title": "Task 1", db: Session,
"description": "Optional", project_id: int,
"status": "backlog", tasks: List[schemas.ImportSubtask],
"subtasks": [ parent_id: Optional[int] = None,
{ count: int = 0
"title": "Subtask 1.1", ) -> int:
"status": "backlog", """Recursively import tasks and their subtasks"""
"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,
# Create the project with optional statuses estimated_minutes=task_data.estimated_minutes,
project = crud.create_project( tags=task_data.tags,
db, flag_color=task_data.flag_color,
schemas.ProjectCreate( sort_order=idx
name=import_data.project.name, )
description=import_data.project.description, db_task = crud.create_task(db, task)
statuses=import_data.project.statuses count += 1
)
) if task_data.subtasks:
count = _import_tasks_recursive(
# Validate all task statuses before importing db, project_id, task_data.subtasks, db_task.id, count
if import_data.tasks: )
try:
_validate_task_statuses_recursive(import_data.tasks, project.statuses) return count
except ValueError as e:
# Rollback the project creation if validation fails
db.delete(project) @app.post("/api/import-json", response_model=schemas.ImportResult)
db.commit() def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_db)):
raise HTTPException(status_code=400, detail=str(e)) """
Import a project with nested tasks from JSON.
# Recursively import tasks
tasks_created = _import_tasks_recursive( Expected format:
db, project.id, import_data.tasks {
) "project": {
"name": "Project Name",
return schemas.ImportResult( "description": "Optional description",
project_id=project.id, "statuses": ["backlog", "in_progress", "on_hold", "done"] // Optional
project_name=project.name, },
tasks_created=tasks_created "tasks": [
) {
"title": "Task 1",
"description": "Optional",
@app.get("/") "status": "backlog",
def root(): "subtasks": [
"""API health check""" {
return { "title": "Subtask 1.1",
"status": "online", "status": "backlog",
"message": "Break It Down (BIT) API - Nested Todo Tree Manager", "subtasks": []
"docs": "/docs" }
} ]
}
]
}
"""
# 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"
}

View File

@@ -1,42 +1,61 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Boolean from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Boolean, Table
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from datetime import datetime from datetime import datetime
from .database import Base from .database import Base
# Default statuses for new projects # Default statuses for new projects
DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"] DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
class Project(Base): # Association table for task blocker relationships (many-to-many)
__tablename__ = "projects" task_blockers = Table(
"task_blockers",
id = Column(Integer, primary_key=True, index=True) Base.metadata,
name = Column(String(255), nullable=False) Column("task_id", Integer, ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True),
description = Column(Text, nullable=True) Column("blocked_by_id", Integer, ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=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) class Project(Base):
__tablename__ = "projects"
tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan")
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
class Task(Base): description = Column(Text, nullable=True)
__tablename__ = "tasks" statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES)
is_archived = Column(Boolean, default=False, nullable=False)
id = Column(Integer, primary_key=True, index=True) created_at = Column(DateTime, default=datetime.utcnow)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
parent_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True)
title = Column(String(500), nullable=False) tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan")
description = Column(Text, nullable=True)
status = Column(String(50), default="backlog", nullable=False)
sort_order = Column(Integer, default=0) class Task(Base):
estimated_minutes = Column(Integer, nullable=True) __tablename__ = "tasks"
tags = Column(JSON, nullable=True)
flag_color = Column(String(50), nullable=True) id = Column(Integer, primary_key=True, index=True)
created_at = Column(DateTime, default=datetime.utcnow) project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) parent_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True)
title = Column(String(500), nullable=False)
project = relationship("Project", back_populates="tasks") description = Column(Text, nullable=True)
parent = relationship("Task", remote_side=[id], backref="subtasks") 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",
)

View File

@@ -1,107 +1,138 @@
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from typing import Optional, List from typing import Optional, List
from datetime import datetime from datetime import datetime
from .models import DEFAULT_STATUSES from .models import DEFAULT_STATUSES
# Task Schemas # Task Schemas
class TaskBase(BaseModel): class TaskBase(BaseModel):
title: str title: str
description: Optional[str] = None description: Optional[str] = None
status: str = "backlog" status: str = "backlog"
parent_task_id: Optional[int] = None parent_task_id: Optional[int] = None
sort_order: int = 0 sort_order: int = 0
estimated_minutes: Optional[int] = None estimated_minutes: Optional[int] = None
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
flag_color: Optional[str] = None flag_color: Optional[str] = None
class TaskCreate(TaskBase): class TaskCreate(TaskBase):
project_id: int project_id: int
class TaskUpdate(BaseModel): class TaskUpdate(BaseModel):
title: Optional[str] = None title: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
status: Optional[str] = None status: Optional[str] = None
parent_task_id: Optional[int] = None parent_task_id: Optional[int] = None
sort_order: Optional[int] = None sort_order: Optional[int] = None
estimated_minutes: Optional[int] = None estimated_minutes: Optional[int] = None
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
flag_color: Optional[str] = None flag_color: Optional[str] = None
class Task(TaskBase): class Task(TaskBase):
id: int id: int
project_id: int project_id: int
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class TaskWithSubtasks(Task): class TaskWithSubtasks(Task):
subtasks: List['TaskWithSubtasks'] = [] subtasks: List['TaskWithSubtasks'] = []
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# Project Schemas class BlockerInfo(BaseModel):
class ProjectBase(BaseModel): """Lightweight task info used when listing blockers/blocking relationships."""
name: str id: int
description: Optional[str] = None title: str
project_id: int
status: str
class ProjectCreate(ProjectBase):
statuses: Optional[List[str]] = None model_config = ConfigDict(from_attributes=True)
class ProjectUpdate(BaseModel): class TaskWithBlockers(Task):
name: Optional[str] = None blockers: List[BlockerInfo] = []
description: Optional[str] = None blocking: List[BlockerInfo] = []
statuses: Optional[List[str]] = None
is_archived: Optional[bool] = None model_config = ConfigDict(from_attributes=True)
class Project(ProjectBase): class ActionableTask(BaseModel):
id: int """A task that is ready to work on — not done, and all blockers are resolved."""
statuses: List[str] id: int
is_archived: bool title: str
created_at: datetime project_id: int
updated_at: datetime project_name: str
status: str
model_config = ConfigDict(from_attributes=True) estimated_minutes: Optional[int] = None
tags: Optional[List[str]] = None
flag_color: Optional[str] = None
class ProjectWithTasks(Project):
tasks: List[Task] = [] model_config = ConfigDict(from_attributes=True)
model_config = ConfigDict(from_attributes=True)
# Project Schemas
class ProjectBase(BaseModel):
# JSON Import Schemas name: str
class ImportSubtask(BaseModel): description: Optional[str] = None
title: str
description: Optional[str] = None
status: str = "backlog" class ProjectCreate(ProjectBase):
estimated_minutes: Optional[int] = None statuses: Optional[List[str]] = None
tags: Optional[List[str]] = None
flag_color: Optional[str] = None
subtasks: List['ImportSubtask'] = [] class ProjectUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
class ImportProject(BaseModel): statuses: Optional[List[str]] = None
name: str is_archived: Optional[bool] = None
description: Optional[str] = None
statuses: Optional[List[str]] = None
class Project(ProjectBase):
id: int
class ImportData(BaseModel): statuses: List[str]
project: ImportProject is_archived: bool
tasks: List[ImportSubtask] = [] created_at: datetime
updated_at: datetime
class ImportResult(BaseModel): model_config = ConfigDict(from_attributes=True)
project_id: int
project_name: str
tasks_created: int 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

View File

@@ -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()

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Break It Down - Task Decomposition Engine</title> <title>BIT - Break It Down</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -1,34 +1,56 @@
import { Routes, Route } from 'react-router-dom' import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'
import ProjectList from './pages/ProjectList' import { Zap } from 'lucide-react'
import ProjectView from './pages/ProjectView' import ProjectList from './pages/ProjectList'
import SearchBar from './components/SearchBar' import ProjectView from './pages/ProjectView'
import ActionableView from './pages/ActionableView'
function App() { import SearchBar from './components/SearchBar'
return (
<div className="min-h-screen bg-cyber-dark"> function App() {
<header className="border-b border-cyber-orange/30 bg-cyber-darkest"> const navigate = useNavigate()
<div className="container mx-auto px-4 py-4"> const location = useLocation()
<div className="flex justify-between items-center"> const isActionable = location.pathname === '/actionable'
<div>
<h1 className="text-2xl font-bold text-cyber-orange"> return (
Break It Down <div className="min-h-screen bg-cyber-dark">
<span className="ml-3 text-sm text-gray-500">BIT - Task Decomposition Engine</span> <header className="border-b border-cyber-orange/30 bg-cyber-darkest">
<span className="ml-2 text-xs text-gray-600">v{import.meta.env.VITE_APP_VERSION || '0.1.6'}</span> <div className="container mx-auto px-4 py-4">
</h1> <div className="flex justify-between items-center">
</div> <div className="flex items-center gap-4">
<SearchBar /> <h1
</div> className="text-2xl font-bold text-cyber-orange cursor-pointer"
</div> onClick={() => navigate('/')}
</header> >
BIT
<main className="container mx-auto px-4 py-8"> <span className="ml-3 text-sm text-gray-500">Break It Down</span>
<Routes> <span className="ml-2 text-xs text-gray-600">v{import.meta.env.VITE_APP_VERSION || '0.1.6'}</span>
<Route path="/" element={<ProjectList />} /> </h1>
<Route path="/project/:projectId" element={<ProjectView />} /> <button
</Routes> onClick={() => navigate(isActionable ? '/' : '/actionable')}
</main> className={`flex items-center gap-1.5 px-3 py-1.5 rounded text-sm font-medium transition-all ${
</div> isActionable
) ? 'bg-cyber-orange text-cyber-darkest'
} : 'border border-cyber-orange/40 text-cyber-orange hover:bg-cyber-orange/10'
}`}
export default App title="What can I do right now?"
>
<Zap size={14} />
Now
</button>
</div>
<SearchBar />
</div>
</div>
</header>
<main className="container mx-auto px-4 py-8">
<Routes>
<Route path="/" element={<ProjectList />} />
<Route path="/project/:projectId" element={<ProjectView />} />
<Route path="/actionable" element={<ActionableView />} />
</Routes>
</main>
</div>
)
}
export default App

View File

@@ -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 (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50" onClick={onClose}>
<div
className="bg-cyber-darkest border border-cyber-orange/50 rounded-lg w-full max-w-lg shadow-xl"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-cyber-orange/20">
<div className="flex items-center gap-2">
{isBlocked
? <Lock size={16} className="text-red-400" />
: <Unlock size={16} className="text-green-400" />
}
<span className="font-semibold text-gray-100 text-sm">Blockers</span>
<span className="text-xs text-gray-500 ml-1 truncate max-w-[200px]" title={task.title}>
{task.title}
</span>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-200 transition-colors">
<X size={18} />
</button>
</div>
<div className="p-5 space-y-4">
{/* Status banner */}
{isBlocked ? (
<div className="flex items-center gap-2 px-3 py-2 bg-red-900/20 border border-red-500/30 rounded text-red-300 text-sm">
<AlertTriangle size={14} />
<span>{incompleteBlockers.length} incomplete blocker{incompleteBlockers.length !== 1 ? 's' : ''} this task is locked</span>
</div>
) : (
<div className="flex items-center gap-2 px-3 py-2 bg-green-900/20 border border-green-500/30 rounded text-green-300 text-sm">
<Unlock size={14} />
<span>No active blockers this task is ready to work on</span>
</div>
)}
{/* Current blockers list */}
{loading ? (
<div className="text-gray-500 text-sm text-center py-2">Loading...</div>
) : blockers.length > 0 ? (
<div className="space-y-1">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Blocked by</p>
{blockers.map(b => (
<div
key={b.id}
className="flex items-center justify-between px-3 py-2 bg-cyber-darker rounded border border-cyber-orange/10"
>
<div className="flex items-center gap-2 min-w-0">
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${b.status === 'done' ? 'bg-green-400' : 'bg-red-400'}`} />
<span className="text-sm text-gray-200 truncate">{b.title}</span>
<span className="text-xs text-gray-500 flex-shrink-0">#{b.id}</span>
</div>
<button
onClick={() => handleRemoveBlocker(b.id)}
className="text-gray-600 hover:text-red-400 transition-colors flex-shrink-0 ml-2"
title="Remove blocker"
>
<X size={14} />
</button>
</div>
))}
</div>
) : (
<p className="text-xs text-gray-600 text-center py-1">No blockers added yet</p>
)}
{/* Search to add blocker */}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Add a blocker</p>
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
<input
type="text"
value={searchQuery}
onChange={e => 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
/>
</div>
{/* Search results */}
{(searchResults.length > 0 || searching) && (
<div className="mt-1 border border-cyber-orange/20 rounded overflow-hidden">
{searching && (
<div className="px-3 py-2 text-xs text-gray-500">Searching...</div>
)}
{searchResults.map(result => (
<button
key={result.id}
onClick={() => handleAddBlocker(result)}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-left transition-colors border-b border-cyber-orange/10 last:border-0"
>
<span className="text-sm text-gray-200 truncate flex-1">{result.title}</span>
<span className="text-xs text-gray-600 flex-shrink-0">project #{result.project_id}</span>
</button>
))}
{!searching && searchResults.length === 0 && searchQuery && (
<div className="px-3 py-2 text-xs text-gray-500">No matching tasks found</div>
)}
</div>
)}
</div>
{error && (
<div className="px-3 py-2 bg-red-900/30 border border-red-500/40 rounded text-red-300 text-sm">
{error}
</div>
)}
</div>
</div>
</div>
)
}
export default BlockerPanel

View File

@@ -1,405 +1,429 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react'
import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo, FileText } from 'lucide-react' import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo, FileText, Lock } from 'lucide-react'
import { updateTask } from '../utils/api' import { updateTask } from '../utils/api'
import BlockerPanel from './BlockerPanel'
const FLAG_COLORS = [
{ name: 'red', color: 'bg-red-500' }, const FLAG_COLORS = [
{ name: 'orange', color: 'bg-orange-500' }, { name: 'red', color: 'bg-red-500' },
{ name: 'yellow', color: 'bg-yellow-500' }, { name: 'orange', color: 'bg-orange-500' },
{ name: 'green', color: 'bg-green-500' }, { name: 'yellow', color: 'bg-yellow-500' },
{ name: 'blue', color: 'bg-blue-500' }, { name: 'green', color: 'bg-green-500' },
{ name: 'purple', color: 'bg-purple-500' }, { name: 'blue', color: 'bg-blue-500' },
{ name: 'pink', color: 'bg-pink-500' } { name: 'purple', color: 'bg-purple-500' },
] { name: 'pink', color: 'bg-pink-500' }
]
// Helper to format status label
const formatStatusLabel = (status) => { // Helper to format status label
return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') const formatStatusLabel = (status) => {
} return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
// Helper to get status color
const getStatusTextColor = (status) => { // Helper to get status color
const lowerStatus = status.toLowerCase() const getStatusTextColor = (status) => {
if (lowerStatus === 'backlog') return 'text-gray-400' const lowerStatus = status.toLowerCase()
if (lowerStatus === 'in_progress' || lowerStatus.includes('progress')) return 'text-blue-400' if (lowerStatus === 'backlog') return 'text-gray-400'
if (lowerStatus === 'on_hold' || lowerStatus.includes('hold') || lowerStatus.includes('waiting')) return 'text-yellow-400' if (lowerStatus === 'in_progress' || lowerStatus.includes('progress')) return 'text-blue-400'
if (lowerStatus === 'done' || lowerStatus.includes('complete')) return 'text-green-400' if (lowerStatus === 'on_hold' || lowerStatus.includes('hold') || lowerStatus.includes('waiting')) return 'text-yellow-400'
if (lowerStatus.includes('blocked')) return 'text-red-400' if (lowerStatus === 'done' || lowerStatus.includes('complete')) return 'text-green-400'
return 'text-purple-400' // default for custom statuses 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) function TaskMenu({ task, onUpdate, onDelete, onEdit, projectStatuses }) {
const [showTimeEdit, setShowTimeEdit] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [showDescriptionEdit, setShowDescriptionEdit] = useState(false) const [showTimeEdit, setShowTimeEdit] = useState(false)
const [showTagsEdit, setShowTagsEdit] = useState(false) const [showDescriptionEdit, setShowDescriptionEdit] = useState(false)
const [showFlagEdit, setShowFlagEdit] = useState(false) const [showTagsEdit, setShowTagsEdit] = useState(false)
const [showStatusEdit, setShowStatusEdit] = useState(false) const [showFlagEdit, setShowFlagEdit] = useState(false)
const [showStatusEdit, setShowStatusEdit] = useState(false)
// Calculate hours and minutes from task.estimated_minutes const [showBlockerPanel, setShowBlockerPanel] = useState(false)
const initialHours = task.estimated_minutes ? Math.floor(task.estimated_minutes / 60) : ''
const initialMinutes = task.estimated_minutes ? task.estimated_minutes % 60 : '' // Calculate hours and minutes from task.estimated_minutes
const initialHours = task.estimated_minutes ? Math.floor(task.estimated_minutes / 60) : ''
const [editHours, setEditHours] = useState(initialHours) const initialMinutes = task.estimated_minutes ? task.estimated_minutes % 60 : ''
const [editMinutes, setEditMinutes] = useState(initialMinutes)
const [editDescription, setEditDescription] = useState(task.description || '') const [editHours, setEditHours] = useState(initialHours)
const [editTags, setEditTags] = useState(task.tags ? task.tags.join(', ') : '') const [editMinutes, setEditMinutes] = useState(initialMinutes)
const menuRef = useRef(null) const [editDescription, setEditDescription] = useState(task.description || '')
const [editTags, setEditTags] = useState(task.tags ? task.tags.join(', ') : '')
useEffect(() => { const menuRef = useRef(null)
function handleClickOutside(event) {
if (menuRef.current && !menuRef.current.contains(event.target)) { useEffect(() => {
setIsOpen(false) function handleClickOutside(event) {
setShowTimeEdit(false) if (menuRef.current && !menuRef.current.contains(event.target)) {
setShowDescriptionEdit(false) setIsOpen(false)
setShowTagsEdit(false) setShowTimeEdit(false)
setShowFlagEdit(false) setShowDescriptionEdit(false)
setShowStatusEdit(false) setShowTagsEdit(false)
} setShowFlagEdit(false)
} setShowStatusEdit(false)
}
if (isOpen) { }
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside) if (isOpen) {
} document.addEventListener('mousedown', handleClickOutside)
}, [isOpen]) return () => document.removeEventListener('mousedown', handleClickOutside)
}
const handleUpdateTime = async () => { }, [isOpen])
try {
const totalMinutes = (parseInt(editHours) || 0) * 60 + (parseInt(editMinutes) || 0) const handleUpdateTime = async () => {
const minutes = totalMinutes > 0 ? totalMinutes : null try {
await updateTask(task.id, { estimated_minutes: minutes }) const totalMinutes = (parseInt(editHours) || 0) * 60 + (parseInt(editMinutes) || 0)
setShowTimeEdit(false) const minutes = totalMinutes > 0 ? totalMinutes : null
setIsOpen(false) await updateTask(task.id, { estimated_minutes: minutes })
onUpdate() setShowTimeEdit(false)
} catch (err) { setIsOpen(false)
alert(`Error: ${err.message}`) onUpdate()
} } catch (err) {
} alert(`Error: ${err.message}`)
}
const handleUpdateDescription = async () => { }
try {
const description = editDescription.trim() || null const handleUpdateDescription = async () => {
await updateTask(task.id, { description }) try {
setShowDescriptionEdit(false) const description = editDescription.trim() || null
setIsOpen(false) await updateTask(task.id, { description })
onUpdate() setShowDescriptionEdit(false)
} catch (err) { setIsOpen(false)
alert(`Error: ${err.message}`) onUpdate()
} } catch (err) {
} alert(`Error: ${err.message}`)
}
const handleUpdateTags = async () => { }
try {
const tags = editTags const handleUpdateTags = async () => {
? editTags.split(',').map(t => t.trim()).filter(t => t.length > 0) try {
: null const tags = editTags
await updateTask(task.id, { tags }) ? editTags.split(',').map(t => t.trim()).filter(t => t.length > 0)
setShowTagsEdit(false) : null
setIsOpen(false) await updateTask(task.id, { tags })
onUpdate() setShowTagsEdit(false)
} catch (err) { setIsOpen(false)
alert(`Error: ${err.message}`) onUpdate()
} } catch (err) {
} alert(`Error: ${err.message}`)
}
const handleUpdateFlag = async (color) => { }
try {
await updateTask(task.id, { flag_color: color }) const handleUpdateFlag = async (color) => {
setShowFlagEdit(false) try {
setIsOpen(false) await updateTask(task.id, { flag_color: color })
onUpdate() setShowFlagEdit(false)
} catch (err) { setIsOpen(false)
alert(`Error: ${err.message}`) onUpdate()
} } catch (err) {
} alert(`Error: ${err.message}`)
}
const handleClearFlag = async () => { }
try {
await updateTask(task.id, { flag_color: null }) const handleClearFlag = async () => {
setShowFlagEdit(false) try {
setIsOpen(false) await updateTask(task.id, { flag_color: null })
onUpdate() setShowFlagEdit(false)
} catch (err) { setIsOpen(false)
alert(`Error: ${err.message}`) onUpdate()
} } catch (err) {
} alert(`Error: ${err.message}`)
}
const handleUpdateStatus = async (newStatus) => { }
try {
await updateTask(task.id, { status: newStatus }) const handleUpdateStatus = async (newStatus) => {
setShowStatusEdit(false) try {
setIsOpen(false) await updateTask(task.id, { status: newStatus })
onUpdate() setShowStatusEdit(false)
} catch (err) { setIsOpen(false)
alert(`Error: ${err.message}`) onUpdate()
} } catch (err) {
} alert(`Error: ${err.message}`)
}
return ( }
<div className="relative" ref={menuRef}>
<button return (
onClick={(e) => { <div className="relative" ref={menuRef}>
e.stopPropagation() <button
setIsOpen(!isOpen) onClick={(e) => {
}} e.stopPropagation()
className="text-gray-400 hover:text-gray-200 p-1" setIsOpen(!isOpen)
title="More options" }}
> className="text-gray-400 hover:text-gray-200 p-1"
<MoreVertical size={16} /> title="More options"
</button> >
<MoreVertical size={16} />
{isOpen && ( </button>
<div className="absolute right-0 top-8 z-50 w-64 bg-cyber-darkest border border-cyber-orange/30 rounded-lg shadow-lg overflow-hidden">
{/* Time Edit */} {isOpen && (
{showTimeEdit ? ( <div className="absolute right-0 top-8 z-50 w-64 bg-cyber-darkest border border-cyber-orange/30 rounded-lg shadow-lg overflow-hidden">
<div className="p-3 border-b border-cyber-orange/20"> {/* Time Edit */}
<div className="flex items-center gap-2 mb-2"> {showTimeEdit ? (
<Clock size={14} className="text-cyber-orange" /> <div className="p-3 border-b border-cyber-orange/20">
<span className="text-sm text-gray-300">Time Estimate</span> <div className="flex items-center gap-2 mb-2">
</div> <Clock size={14} className="text-cyber-orange" />
<div className="flex gap-2 mb-2"> <span className="text-sm text-gray-300">Time Estimate</span>
<input </div>
type="number" <div className="flex gap-2 mb-2">
min="0" <input
value={editHours} type="number"
onChange={(e) => setEditHours(e.target.value)} min="0"
placeholder="Hours" value={editHours}
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" onChange={(e) => setEditHours(e.target.value)}
autoFocus placeholder="Hours"
onClick={(e) => e.stopPropagation()} 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
<input onClick={(e) => e.stopPropagation()}
type="number" />
min="0" <input
max="59" type="number"
value={editMinutes} min="0"
onChange={(e) => setEditMinutes(e.target.value)} max="59"
placeholder="Minutes" value={editMinutes}
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" onChange={(e) => setEditMinutes(e.target.value)}
onClick={(e) => e.stopPropagation()} 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"
</div> onClick={(e) => e.stopPropagation()}
<div className="flex gap-2"> />
<button </div>
onClick={handleUpdateTime} <div className="flex gap-2">
className="flex-1 px-2 py-1 text-sm bg-cyber-orange/20 text-cyber-orange rounded hover:bg-cyber-orange/30" <button
> onClick={handleUpdateTime}
Save className="flex-1 px-2 py-1 text-sm bg-cyber-orange/20 text-cyber-orange rounded hover:bg-cyber-orange/30"
</button> >
<button Save
onClick={() => setShowTimeEdit(false)} </button>
className="px-2 py-1 text-sm text-gray-400 hover:text-gray-300" <button
> onClick={() => setShowTimeEdit(false)}
Cancel className="px-2 py-1 text-sm text-gray-400 hover:text-gray-300"
</button> >
</div> Cancel
</div> </button>
) : ( </div>
<button </div>
onClick={(e) => { ) : (
e.stopPropagation() <button
setShowTimeEdit(true) onClick={(e) => {
}} e.stopPropagation()
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm" setShowTimeEdit(true)
> }}
<Clock size={14} /> className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
<span>Set Time Estimate</span> >
</button> <Clock size={14} />
)} <span>Set Time Estimate</span>
</button>
{/* Description Edit */} )}
{showDescriptionEdit ? (
<div className="p-3 border-b border-cyber-orange/20"> {/* Description Edit */}
<div className="flex items-center gap-2 mb-2"> {showDescriptionEdit ? (
<FileText size={14} className="text-cyber-orange" /> <div className="p-3 border-b border-cyber-orange/20">
<span className="text-sm text-gray-300">Description</span> <div className="flex items-center gap-2 mb-2">
</div> <FileText size={14} className="text-cyber-orange" />
<div className="space-y-2"> <span className="text-sm text-gray-300">Description</span>
<textarea </div>
value={editDescription} <div className="space-y-2">
onChange={(e) => setEditDescription(e.target.value)} <textarea
placeholder="Task description..." value={editDescription}
rows="4" onChange={(e) => setEditDescription(e.target.value)}
className="w-full 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 resize-y" placeholder="Task description..."
autoFocus rows="4"
onClick={(e) => e.stopPropagation()} className="w-full 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 resize-y"
/> autoFocus
<div className="flex gap-2"> onClick={(e) => e.stopPropagation()}
<button />
onClick={handleUpdateDescription} <div className="flex gap-2">
className="flex-1 px-2 py-1 text-sm bg-cyber-orange/20 text-cyber-orange rounded hover:bg-cyber-orange/30" <button
> onClick={handleUpdateDescription}
Save className="flex-1 px-2 py-1 text-sm bg-cyber-orange/20 text-cyber-orange rounded hover:bg-cyber-orange/30"
</button> >
<button Save
onClick={() => setShowDescriptionEdit(false)} </button>
className="px-2 py-1 text-sm text-gray-400 hover:text-gray-300" <button
> onClick={() => setShowDescriptionEdit(false)}
Cancel className="px-2 py-1 text-sm text-gray-400 hover:text-gray-300"
</button> >
</div> Cancel
</div> </button>
</div> </div>
) : ( </div>
<button </div>
onClick={(e) => { ) : (
e.stopPropagation() <button
setShowDescriptionEdit(true) onClick={(e) => {
}} e.stopPropagation()
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm" setShowDescriptionEdit(true)
> }}
<FileText size={14} /> className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
<span>Edit Description</span> >
</button> <FileText size={14} />
)} <span>Edit Description</span>
</button>
{/* Tags Edit */} )}
{showTagsEdit ? (
<div className="p-3 border-b border-cyber-orange/20"> {/* Tags Edit */}
<div className="flex items-center gap-2 mb-2"> {showTagsEdit ? (
<Tag size={14} className="text-cyber-orange" /> <div className="p-3 border-b border-cyber-orange/20">
<span className="text-sm text-gray-300">Tags (comma-separated)</span> <div className="flex items-center gap-2 mb-2">
</div> <Tag size={14} className="text-cyber-orange" />
<div className="flex gap-2"> <span className="text-sm text-gray-300">Tags (comma-separated)</span>
<input </div>
type="text" <div className="flex gap-2">
value={editTags} <input
onChange={(e) => setEditTags(e.target.value)} type="text"
placeholder="coding, bug-fix" value={editTags}
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" onChange={(e) => setEditTags(e.target.value)}
autoFocus placeholder="coding, bug-fix"
onClick={(e) => e.stopPropagation()} 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
<button onClick={(e) => e.stopPropagation()}
onClick={handleUpdateTags} />
className="text-green-400 hover:text-green-300" <button
> onClick={handleUpdateTags}
<Check size={16} /> className="text-green-400 hover:text-green-300"
</button> >
<button <Check size={16} />
onClick={() => setShowTagsEdit(false)} </button>
className="text-gray-400 hover:text-gray-300" <button
> onClick={() => setShowTagsEdit(false)}
<X size={16} /> className="text-gray-400 hover:text-gray-300"
</button> >
</div> <X size={16} />
</div> </button>
) : ( </div>
<button </div>
onClick={(e) => { ) : (
e.stopPropagation() <button
setShowTagsEdit(true) onClick={(e) => {
}} e.stopPropagation()
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm" setShowTagsEdit(true)
> }}
<Tag size={14} /> className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
<span>Edit Tags</span> >
</button> <Tag size={14} />
)} <span>Edit Tags</span>
</button>
{/* Flag Color Edit */} )}
{showFlagEdit ? (
<div className="p-3 border-b border-cyber-orange/20"> {/* Flag Color Edit */}
<div className="flex items-center gap-2 mb-2"> {showFlagEdit ? (
<Flag size={14} className="text-cyber-orange" /> <div className="p-3 border-b border-cyber-orange/20">
<span className="text-sm text-gray-300">Flag Color</span> <div className="flex items-center gap-2 mb-2">
</div> <Flag size={14} className="text-cyber-orange" />
<div className="flex gap-2 flex-wrap"> <span className="text-sm text-gray-300">Flag Color</span>
{FLAG_COLORS.map(({ name, color }) => ( </div>
<button <div className="flex gap-2 flex-wrap">
key={name} {FLAG_COLORS.map(({ name, color }) => (
onClick={() => handleUpdateFlag(name)} <button
className={`w-6 h-6 ${color} rounded hover:ring-2 hover:ring-cyber-orange transition-all`} key={name}
title={name} onClick={() => handleUpdateFlag(name)}
/> className={`w-6 h-6 ${color} rounded hover:ring-2 hover:ring-cyber-orange transition-all`}
))} title={name}
</div> />
<button ))}
onClick={handleClearFlag} </div>
className="w-full mt-2 px-2 py-1 text-xs text-gray-400 hover:text-gray-200 border border-gray-600 rounded" <button
> onClick={handleClearFlag}
Clear Flag className="w-full mt-2 px-2 py-1 text-xs text-gray-400 hover:text-gray-200 border border-gray-600 rounded"
</button> >
</div> Clear Flag
) : ( </button>
<button </div>
onClick={(e) => { ) : (
e.stopPropagation() <button
setShowFlagEdit(true) onClick={(e) => {
}} e.stopPropagation()
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm" setShowFlagEdit(true)
> }}
<Flag size={14} /> className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
<span>Set Flag Color</span> >
</button> <Flag size={14} />
)} <span>Set Flag Color</span>
</button>
{/* Status Change */} )}
{showStatusEdit ? (
<div className="p-3 border-b border-cyber-orange/20"> {/* Status Change */}
<div className="flex items-center gap-2 mb-2"> {showStatusEdit ? (
<ListTodo size={14} className="text-cyber-orange" /> <div className="p-3 border-b border-cyber-orange/20">
<span className="text-sm text-gray-300">Change Status</span> <div className="flex items-center gap-2 mb-2">
</div> <ListTodo size={14} className="text-cyber-orange" />
<div className="space-y-1"> <span className="text-sm text-gray-300">Change Status</span>
{(projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']).map((status) => ( </div>
<button <div className="space-y-1">
key={status} {(projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']).map((status) => (
onClick={() => handleUpdateStatus(status)} <button
className={`w-full text-left px-2 py-1.5 rounded text-sm ${ key={status}
task.status === status onClick={() => handleUpdateStatus(status)}
? 'bg-cyber-orange/20 border border-cyber-orange/40' className={`w-full text-left px-2 py-1.5 rounded text-sm ${
: 'hover:bg-cyber-darker border border-transparent' task.status === status
} ${getStatusTextColor(status)} transition-all`} ? 'bg-cyber-orange/20 border border-cyber-orange/40'
> : 'hover:bg-cyber-darker border border-transparent'
{formatStatusLabel(status)} {task.status === status && '✓'} } ${getStatusTextColor(status)} transition-all`}
</button> >
))} {formatStatusLabel(status)} {task.status === status && '✓'}
</div> </button>
</div> ))}
) : ( </div>
<button </div>
onClick={(e) => { ) : (
e.stopPropagation() <button
setShowStatusEdit(true) onClick={(e) => {
}} e.stopPropagation()
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm" setShowStatusEdit(true)
> }}
<ListTodo size={14} /> className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
<span>Change Status</span> >
</button> <ListTodo size={14} />
)} <span>Change Status</span>
</button>
{/* Edit Title */} )}
<button
onClick={(e) => { {/* Manage Blockers */}
e.stopPropagation() <button
onEdit() onClick={(e) => {
setIsOpen(false) e.stopPropagation()
}} setShowBlockerPanel(true)
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm" setIsOpen(false)
> }}
<Edit2 size={14} /> className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
<span>Edit Title</span> >
</button> <Lock size={14} />
<span>Manage Blockers</span>
{/* Delete */} </button>
<button
onClick={(e) => { {/* Edit Title */}
e.stopPropagation() <button
onDelete() onClick={(e) => {
setIsOpen(false) e.stopPropagation()
}} onEdit()
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-red-400 hover:text-red-300 text-sm border-t border-cyber-orange/20" setIsOpen(false)
> }}
<Trash2 size={14} /> className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
<span>Delete Task</span> >
</button> <Edit2 size={14} />
</div> <span>Edit Title</span>
)} </button>
</div>
) {/* Delete */}
} <button
onClick={(e) => {
export default TaskMenu e.stopPropagation()
onDelete()
setIsOpen(false)
}}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-red-400 hover:text-red-300 text-sm border-t border-cyber-orange/20"
>
<Trash2 size={14} />
<span>Delete Task</span>
</button>
</div>
)}
{/* Blocker panel modal */}
{showBlockerPanel && (
<BlockerPanel
task={task}
onClose={() => setShowBlockerPanel(false)}
onUpdate={onUpdate}
/>
)}
</div>
)
}
export default TaskMenu

View File

@@ -0,0 +1,190 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Zap, Clock, RefreshCw, ChevronRight, Check } from 'lucide-react'
import { getActionableTasks, updateTask } from '../utils/api'
const FLAG_DOT = {
red: 'bg-red-500',
orange: 'bg-orange-500',
yellow: 'bg-yellow-500',
green: 'bg-green-500',
blue: 'bg-blue-500',
purple: 'bg-purple-500',
pink: 'bg-pink-500',
}
const formatTime = (minutes) => {
if (!minutes) return null
const h = Math.floor(minutes / 60)
const m = minutes % 60
if (h && m) return `${h}h ${m}m`
if (h) return `${h}h`
return `${m}m`
}
const formatStatusLabel = (status) =>
status.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
const getStatusColor = (status) => {
const s = status.toLowerCase()
if (s === 'in_progress' || s.includes('progress')) return 'text-blue-400'
if (s === 'on_hold' || s.includes('hold')) return 'text-yellow-400'
return 'text-gray-500'
}
function ActionableView() {
const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [completingId, setCompletingId] = useState(null)
const navigate = useNavigate()
useEffect(() => {
loadTasks()
}, [])
const loadTasks = async () => {
try {
setLoading(true)
setError('')
const data = await getActionableTasks()
setTasks(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handleMarkDone = async (task) => {
try {
setCompletingId(task.id)
await updateTask(task.id, { status: 'done' })
// Remove from list and reload to surface newly unblocked tasks
setTasks(prev => prev.filter(t => t.id !== task.id))
// Reload after a short beat so the user sees the removal first
setTimeout(() => loadTasks(), 600)
} catch (err) {
setError(err.message)
} finally {
setCompletingId(null)
}
}
// Group by project
const byProject = tasks.reduce((acc, task) => {
const key = task.project_id
if (!acc[key]) acc[key] = { name: task.project_name, tasks: [] }
acc[key].tasks.push(task)
return acc
}, {})
const projectGroups = Object.entries(byProject)
return (
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<Zap size={24} className="text-cyber-orange" />
<div>
<h2 className="text-2xl font-bold text-gray-100">What can I do right now?</h2>
<p className="text-sm text-gray-500 mt-0.5">
{loading ? '...' : `${tasks.length} task${tasks.length !== 1 ? 's' : ''} ready across all projects`}
</p>
</div>
</div>
<button
onClick={loadTasks}
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-400 hover:text-gray-200 border border-cyber-orange/20 hover:border-cyber-orange/40 rounded transition-colors"
title="Refresh"
>
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{error && (
<div className="mb-6 px-4 py-3 bg-red-900/30 border border-red-500/40 rounded text-red-300 text-sm">
{error}
</div>
)}
{!loading && tasks.length === 0 && (
<div className="text-center py-20 text-gray-600">
<Zap size={40} className="mx-auto mb-4 opacity-30" />
<p className="text-lg">Nothing actionable right now.</p>
<p className="text-sm mt-1">All tasks are either done or blocked.</p>
</div>
)}
{/* Project groups */}
<div className="space-y-8">
{projectGroups.map(([projectId, group]) => (
<div key={projectId}>
{/* Project header */}
<button
onClick={() => navigate(`/project/${projectId}`)}
className="flex items-center gap-2 mb-3 text-cyber-orange hover:text-cyber-orange-bright transition-colors group"
>
<span className="text-sm font-semibold uppercase tracking-wider">{group.name}</span>
<ChevronRight size={14} className="opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
{/* Task cards */}
<div className="space-y-2">
{group.tasks.map(task => (
<div
key={task.id}
className={`flex items-center gap-3 px-4 py-3 bg-cyber-darkest border border-cyber-orange/20 rounded-lg hover:border-cyber-orange/40 transition-all ${
completingId === task.id ? 'opacity-50' : ''
}`}
>
{/* Done button */}
<button
onClick={() => handleMarkDone(task)}
disabled={completingId === task.id}
className="flex-shrink-0 w-6 h-6 rounded-full border-2 border-cyber-orange/40 hover:border-green-400 hover:bg-green-400/10 flex items-center justify-center transition-all group"
title="Mark as done"
>
<Check size={12} className="text-transparent group-hover:text-green-400 transition-colors" />
</button>
{/* Flag dot */}
{task.flag_color && (
<span className={`flex-shrink-0 w-2 h-2 rounded-full ${FLAG_DOT[task.flag_color] || 'bg-gray-500'}`} />
)}
{/* Title + meta */}
<div className="flex-1 min-w-0">
<span className="text-sm text-gray-100">{task.title}</span>
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
{task.status !== 'backlog' && (
<span className={`text-xs ${getStatusColor(task.status)}`}>
{formatStatusLabel(task.status)}
</span>
)}
{task.estimated_minutes && (
<span className="flex items-center gap-1 text-xs text-gray-600">
<Clock size={10} />
{formatTime(task.estimated_minutes)}
</span>
)}
{task.tags && task.tags.map(tag => (
<span key={tag} className="text-xs text-cyber-orange/60 bg-cyber-orange/5 px-1.5 py-0.5 rounded">
{tag}
</span>
))}
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
)
}
export default ActionableView

View File

@@ -1,76 +1,85 @@
const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api'; const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api';
async function fetchAPI(endpoint, options = {}) { async function fetchAPI(endpoint, options = {}) {
const response = await fetch(`${API_BASE}${endpoint}`, { const response = await fetch(`${API_BASE}${endpoint}`, {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options.headers, ...options.headers,
}, },
...options, ...options,
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Request failed' })); const error = await response.json().catch(() => ({ detail: 'Request failed' }));
throw new Error(error.detail || `HTTP ${response.status}`); throw new Error(error.detail || `HTTP ${response.status}`);
} }
if (response.status === 204) { if (response.status === 204) {
return null; return null;
} }
return response.json(); return response.json();
} }
// Projects // Projects
export const getProjects = (archived = null) => { export const getProjects = (archived = null) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (archived !== null) { if (archived !== null) {
params.append('archived', archived); params.append('archived', archived);
} }
const queryString = params.toString(); const queryString = params.toString();
return fetchAPI(`/projects${queryString ? `?${queryString}` : ''}`); return fetchAPI(`/projects${queryString ? `?${queryString}` : ''}`);
}; };
export const getProject = (id) => fetchAPI(`/projects/${id}`); export const getProject = (id) => fetchAPI(`/projects/${id}`);
export const createProject = (data) => fetchAPI('/projects', { export const createProject = (data) => fetchAPI('/projects', {
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
export const updateProject = (id, data) => fetchAPI(`/projects/${id}`, { export const updateProject = (id, data) => fetchAPI(`/projects/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
export const deleteProject = (id) => fetchAPI(`/projects/${id}`, { method: 'DELETE' }); export const deleteProject = (id) => fetchAPI(`/projects/${id}`, { method: 'DELETE' });
export const archiveProject = (id) => updateProject(id, { is_archived: true }); export const archiveProject = (id) => updateProject(id, { is_archived: true });
export const unarchiveProject = (id) => updateProject(id, { is_archived: false }); export const unarchiveProject = (id) => updateProject(id, { is_archived: false });
// Tasks // Tasks
export const getProjectTasks = (projectId) => fetchAPI(`/projects/${projectId}/tasks`); export const getProjectTasks = (projectId) => fetchAPI(`/projects/${projectId}/tasks`);
export const getProjectTaskTree = (projectId) => fetchAPI(`/projects/${projectId}/tasks/tree`); export const getProjectTaskTree = (projectId) => fetchAPI(`/projects/${projectId}/tasks/tree`);
export const getTasksByStatus = (projectId, status) => export const getTasksByStatus = (projectId, status) =>
fetchAPI(`/projects/${projectId}/tasks/by-status/${status}`); fetchAPI(`/projects/${projectId}/tasks/by-status/${status}`);
export const getTask = (id) => fetchAPI(`/tasks/${id}`); export const getTask = (id) => fetchAPI(`/tasks/${id}`);
export const createTask = (data) => fetchAPI('/tasks', { export const createTask = (data) => fetchAPI('/tasks', {
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
export const updateTask = (id, data) => fetchAPI(`/tasks/${id}`, { export const updateTask = (id, data) => fetchAPI(`/tasks/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
export const deleteTask = (id) => fetchAPI(`/tasks/${id}`, { method: 'DELETE' }); export const deleteTask = (id) => fetchAPI(`/tasks/${id}`, { method: 'DELETE' });
// JSON Import // JSON Import
export const importJSON = (data) => fetchAPI('/import-json', { export const importJSON = (data) => fetchAPI('/import-json', {
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
// Search // Blockers
export const searchTasks = (query, projectIds = null) => { export const getTaskBlockers = (taskId) => fetchAPI(`/tasks/${taskId}/blockers`);
const params = new URLSearchParams({ query }); export const getTaskBlocking = (taskId) => fetchAPI(`/tasks/${taskId}/blocking`);
if (projectIds && projectIds.length > 0) { export const addBlocker = (taskId, blockerId) => fetchAPI(`/tasks/${taskId}/blockers/${blockerId}`, { method: 'POST' });
params.append('project_ids', projectIds.join(',')); export const removeBlocker = (taskId, blockerId) => fetchAPI(`/tasks/${taskId}/blockers/${blockerId}`, { method: 'DELETE' });
}
return fetchAPI(`/search?${params.toString()}`); // Actionable tasks (no incomplete blockers, not done)
}; export const getActionableTasks = () => fetchAPI('/actionable');
// Search
export const searchTasks = (query, projectIds = null) => {
const params = new URLSearchParams({ query });
if (projectIds && projectIds.length > 0) {
params.append('project_ids', projectIds.join(','));
}
return fetchAPI(`/search?${params.toString()}`);
};