Compare commits

...

5 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
01e594b941 Merge pull request 'Main branch Resync on w/ gitea. v0.1.6' (#1) from claude/nested-todo-app-mvp-014vUjLpD8wNjkMhLMXS6Kd5 into main
Reviewed-on: http://10.0.0.2:9010/serversdown/break-it-down/pulls/1
2026-01-04 04:30:52 -05:00
serversdwn
3fc90063b4 rebranded to BIT 2026-01-04 09:24:06 +00:00
22 changed files with 2186 additions and 1198 deletions

160
ARCHIVE_FEATURE_README.md Normal file
View File

@@ -0,0 +1,160 @@
# Project Archive Feature
## Overview
The Break It Down (BIT) application now supports archiving projects! This helps you organize your workspace by hiding completed or inactive projects from the main view while keeping them accessible.
## Features Added
### 1. **Archive Status**
- Projects can be marked as archived or active
- Archived projects are kept in the database but hidden from the default view
- All tasks and data are preserved when archiving
### 2. **Tab Navigation**
The main Projects page now has three tabs:
- **Active** (default) - Shows only non-archived projects
- **Archived** - Shows only archived projects
- **All** - Shows all projects regardless of archive status
### 3. **Quick Actions**
Each project card now has two action buttons:
- **Archive/Unarchive** (📦/↩️) - Toggle archive status
- **Delete** (🗑️) - Permanently delete the project
### 4. **Visual Indicators**
- Archived projects appear with reduced opacity and gray border
- "(archived)" label appears next to archived project names
- Context-aware empty state messages
## Database Changes
A new column has been added to the `projects` table:
- `is_archived` (BOOLEAN) - Defaults to `False` for new projects
## Installation & Migration
### For New Installations
No action needed! The database will be created with the correct schema automatically.
### For Existing Databases
If you have an existing Break It Down database, run the migration script:
```bash
cd backend
python3 migrate_add_is_archived.py
```
This will add the `is_archived` column to your existing projects table and set all existing projects to `is_archived=False`.
### Restart the Backend
After migration, restart the backend server to load the updated models:
```bash
# Stop the current backend process
pkill -f "uvicorn.*backend.main:app"
# Start the backend again
cd /path/to/break-it-down
uvicorn backend.main:app --host 0.0.0.0 --port 8001
```
Or if using Docker:
```bash
docker-compose restart backend
```
## API Changes
### Updated Endpoint: GET /api/projects
New optional query parameter:
- `archived` (boolean, optional) - Filter projects by archive status
- `archived=false` - Returns only active projects
- `archived=true` - Returns only archived projects
- No parameter - Returns all projects
Examples:
```bash
# Get active projects
GET /api/projects?archived=false
# Get archived projects
GET /api/projects?archived=true
# Get all projects
GET /api/projects
```
### Updated Endpoint: PUT /api/projects/{id}
New field in request body:
```json
{
"name": "Project Name", // optional
"description": "Description", // optional
"statuses": [...], // optional
"is_archived": true // optional - new!
}
```
### New API Helper Functions
The frontend API client now includes:
- `archiveProject(id)` - Archive a project
- `unarchiveProject(id)` - Unarchive a project
## File Changes Summary
### Backend
- `backend/app/models.py` - Added `is_archived` Boolean column to Project model
- `backend/app/schemas.py` - Updated ProjectUpdate and Project schemas
- `backend/app/main.py` - Added `archived` query parameter to list_projects endpoint
- `backend/app/crud.py` - Updated get_projects to support archive filtering
- `backend/migrate_add_is_archived.py` - Migration script (new file)
### Frontend
- `frontend/src/pages/ProjectList.jsx` - Added tabs, archive buttons, and filtering logic
- `frontend/src/utils/api.js` - Added archive/unarchive functions and updated getProjects
## Usage Examples
### Archive a Project
1. Go to the Projects page
2. Click the archive icon (📦) on any project card
3. The project disappears from the Active view
4. Switch to the "Archived" tab to see it
### Unarchive a Project
1. Switch to the "Archived" tab
2. Click the unarchive icon (↩️) on the project card
3. The project returns to the Active view
### View All Projects
Click the "All" tab to see both active and archived projects together.
## Status vs Archive
**Important distinction:**
- **Task Status** (backlog, in_progress, on_hold, done) - Applied to individual tasks within a project
- **Project Archive** - Applied to entire projects to organize your workspace
The existing task status "on_hold" is still useful for pausing work on specific tasks, while archiving is for entire projects you want to hide from your main view.
## Backward Compatibility
All changes are backward compatible:
- Existing projects will default to `is_archived=false` (active)
- Old API calls without the `archived` parameter still work (returns all projects)
- The frontend gracefully handles projects with or without the archive field
## Future Enhancements
Potential additions:
- Archive date tracking
- Bulk archive operations
- Archive projects from the ProjectSettings modal
- Auto-archive projects after X days of inactivity
- Project status badges (active, archived, on-hold, completed)

View File

@@ -1,11 +1,11 @@
# Changelog
All notable changes to TESSERACT will be documented in this file.
All notable changes to Break It Down (BIT) will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.6] - 2025-01-25
## [0.1.6] - 2025-11-25
### Added
- **Dynamic Status Management System**
@@ -69,7 +69,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Helper functions for status formatting and color coding
- Prop drilling of projectStatuses through component hierarchy
## [0.1.5] - 2025-01-XX
## [0.1.5] - 2025-11-22
### Added
- **Nested Kanban View** - Major feature implementation
@@ -238,9 +238,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Project Information
**TESSERACT** - Task Decomposition Engine
**Break It Down (BIT)** - Task Decomposition Engine
A self-hosted web application for managing deeply nested todo trees with advanced time tracking and project planning capabilities.
**Repository**: https://github.com/serversdwn/tesseract
**Repository**: https://github.com/serversdwn/break-it-down
**License**: MIT
**Author**: serversdwn

View File

@@ -1,4 +1,4 @@
# TESSERACT
# Break It Down - BIT
**Task Decomposition Engine** - A self-hosted web application for managing deeply nested todo trees with advanced time tracking and project planning capabilities.
@@ -7,7 +7,7 @@
## Overview
TESSERACT is designed for complex project management where tasks naturally decompose into hierarchical structures. Whether you're breaking down software projects, research tasks, or multi-phase initiatives, TESSERACT helps you visualize, track, and manage work at any level of granularity.
Break It Down is designed for complex project management where tasks naturally decompose into hierarchical structures. Whether you're breaking down software projects, research tasks, or multi-phase initiatives, BIT helps you visualize, track, and manage work at any level of granularity.
### Key Features
@@ -33,7 +33,6 @@ TESSERACT is designed for complex project management where tasks naturally decom
- **LLM Integration**: Import JSON task trees generated by AI assistants
- **Real-Time Search**: Find tasks across projects with filtering
- **Self-Hosted**: Full data ownership and privacy
- **Dark Cyberpunk UI**: Orange-accented dark theme optimized for focus
## Tech Stack
@@ -55,8 +54,8 @@ TESSERACT is designed for complex project management where tasks naturally decom
1. **Clone the repository**
```bash
git clone https://github.com/serversdwn/tesseract.git
cd tesseract
git clone https://github.com/serversdwn/break-it-down.git
cd break-it-down
```
2. **Start the application**
@@ -166,7 +165,7 @@ The Kanban board displays tasks in a nested hierarchy while maintaining status-b
### Understanding Time Estimates
TESSERACT uses **leaf-based time calculation** for accurate project planning:
Break It Down uses **leaf-based time calculation** for accurate project planning:
- **Leaf Tasks** (no subtasks): Display shows their own time estimate
- **Parent Tasks** (have subtasks): Display shows sum of ALL descendant leaf tasks
@@ -202,7 +201,7 @@ Remaining work: 3h 30m (not 10h!)
### JSON Import
TESSERACT can import task hierarchies from JSON files, making it perfect for LLM-generated project breakdowns.
Break It Down can import task hierarchies from JSON files, making it perfect for LLM-generated project breakdowns.
1. Click "Import JSON" on a project
2. Upload a file matching this structure:
@@ -307,7 +306,7 @@ tasks
### Project Structure
```
tesseract/
break-it-down/
├─ backend/
│ ├─ app/
│ │ ├─ main.py # FastAPI application
@@ -370,10 +369,10 @@ Frontend will be available at `http://localhost:5173` (Vite default)
**Backup:**
```bash
# Docker
docker cp tesseract-backend:/app/tesseract.db ./backup.db
docker cp bit-backend:/app/bit.db ./backup.db
# Local
cp backend/tesseract.db ./backup.db
cp backend/bit.db ./backup.db
```
**Schema Changes:**
@@ -456,8 +455,8 @@ MIT License - see LICENSE file for details
## Support
- **Issues**: https://github.com/serversdwn/tesseract/issues
- **Discussions**: https://github.com/serversdwn/tesseract/discussions
- **Issues**: https://github.com/serversdwn/break-it-down/issues
- **Discussions**: https://github.com/serversdwn/break-it-down/discussions
## Acknowledgments
@@ -467,4 +466,4 @@ MIT License - see LICENSE file for details
---
**TESSERACT** - Decompose complexity, achieve clarity.
**Break It Down (BIT)** - Decompose complexity, achieve clarity.

View File

@@ -1,8 +1,8 @@
# Database Configuration
DATABASE_URL=sqlite:///./tesseract.db
DATABASE_URL=sqlite:///./bit.db
# API Configuration
API_TITLE=Tesseract - Nested Todo Tree API
API_TITLE=Break It Down (BIT) - Nested Todo Tree API
API_DESCRIPTION=API for managing deeply nested todo trees
API_VERSION=1.0.0

View File

@@ -1,182 +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) -> List[models.Project]:
return db.query(models.Project).offset(skip).limit(limit).all()
def update_project(
db: Session, project_id: int, project: schemas.ProjectUpdate
) -> Optional[models.Project]:
db_project = get_project(db, project_id)
if not db_project:
return None
update_data = project.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(db_project, key, value)
db.commit()
db.refresh(db_project)
return db_project
def delete_project(db: Session, project_id: int) -> bool:
db_project = get_project(db, project_id)
if not db_project:
return False
db.delete(db_project)
db.commit()
return True
# Task CRUD
def create_task(db: Session, task: schemas.TaskCreate) -> models.Task:
# 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

View File

@@ -1,319 +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, db: Session = Depends(get_db)):
"""List all projects"""
return crud.get_projects(db, skip=skip, limit=limit)
@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": "Tesseract API - Nested Todo Tree Manager",
"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"
}

View File

@@ -1,41 +1,61 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON
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)
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",
)

View File

@@ -1,105 +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
class Project(ProjectBase):
id: int
statuses: List[str]
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

View File

@@ -6,10 +6,10 @@ class Settings(BaseSettings):
"""Application settings loaded from environment variables"""
# Database Configuration
database_url: str = "sqlite:///./tesseract.db"
database_url: str = "sqlite:///./bit.db"
# API Configuration
api_title: str = "Tesseract - Nested Todo Tree API"
api_title: str = "Break It Down (BIT) - Nested Todo Tree API"
api_description: str = "API for managing deeply nested todo trees"
api_version: str = "1.0.0"

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

@@ -0,0 +1,37 @@
"""
Migration script to add is_archived column to existing projects.
Run this once if you have an existing database.
"""
import sqlite3
import os
# Get the database path
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)
# Connect to the database
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
# Check if the column already exists
cursor.execute("PRAGMA table_info(projects)")
columns = [column[1] for column in cursor.fetchall()]
if 'is_archived' in columns:
print("Column 'is_archived' already exists. Migration not needed.")
else:
# Add the is_archived column
cursor.execute("ALTER TABLE projects ADD COLUMN is_archived BOOLEAN NOT NULL DEFAULT 0")
conn.commit()
print("Successfully added 'is_archived' column to projects table.")
print("All existing projects have been set to is_archived=False (0).")
except Exception as e:
print(f"Error during migration: {e}")
conn.rollback()
finally:
conn.close()

View File

@@ -10,7 +10,7 @@ DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
def migrate():
# Connect to the database
conn = sqlite3.connect('tesseract.db')
conn = sqlite3.connect('bit.db')
cursor = conn.cursor()
try:

View File

@@ -3,12 +3,12 @@ services:
build:
context: ./backend
dockerfile: Dockerfile
container_name: tesseract-backend
container_name: bit-backend
ports:
- "8002:8002"
volumes:
- ./backend/app:/app/app
- tesseract-db:/app
- bit-db:/app
env_file:
- ./backend/.env
environment:
@@ -19,7 +19,7 @@ services:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: tesseract-frontend
container_name: bit-frontend
ports:
- "3002:80"
env_file:
@@ -29,4 +29,4 @@ services:
restart: unless-stopped
volumes:
tesseract-db:
bit-db:

View File

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

View File

@@ -1,11 +1,11 @@
{
"name": "tesseract-frontend",
"name": "bit-frontend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tesseract-frontend",
"name": "bit-frontend",
"version": "1.0.0",
"dependencies": {
"lucide-react": "^0.303.0",

View File

@@ -1,5 +1,5 @@
{
"name": "tesseract-frontend",
"name": "bit-frontend",
"private": true,
"version": "1.0.0",
"type": "module",

View File

@@ -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 (
<div className="min-h-screen bg-cyber-dark">
<header className="border-b border-cyber-orange/30 bg-cyber-darkest">
<div className="container mx-auto px-4 py-4">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-cyber-orange">
TESSERACT
<span className="ml-3 text-sm text-gray-500">Task Decomposition Engine</span>
<span className="ml-2 text-xs text-gray-600">v{import.meta.env.VITE_APP_VERSION || '0.1.6'}</span>
</h1>
</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 />} />
</Routes>
</main>
</div>
)
}
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 (
<div className="min-h-screen bg-cyber-dark">
<header className="border-b border-cyber-orange/30 bg-cyber-darkest">
<div className="container mx-auto px-4 py-4">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<h1
className="text-2xl font-bold text-cyber-orange cursor-pointer"
onClick={() => navigate('/')}
>
BIT
<span className="ml-3 text-sm text-gray-500">Break It Down</span>
<span className="ml-2 text-xs text-gray-600">v{import.meta.env.VITE_APP_VERSION || '0.1.6'}</span>
</h1>
<button
onClick={() => navigate(isActionable ? '/' : '/actionable')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded text-sm font-medium transition-all ${
isActionable
? 'bg-cyber-orange text-cyber-darkest'
: 'border border-cyber-orange/40 text-cyber-orange hover:bg-cyber-orange/10'
}`}
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 { 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 (
<div className="relative" ref={menuRef}>
<button
onClick={(e) => {
e.stopPropagation()
setIsOpen(!isOpen)
}}
className="text-gray-400 hover:text-gray-200 p-1"
title="More options"
>
<MoreVertical size={16} />
</button>
{isOpen && (
<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 */}
{showTimeEdit ? (
<div className="p-3 border-b border-cyber-orange/20">
<div className="flex items-center gap-2 mb-2">
<Clock size={14} className="text-cyber-orange" />
<span className="text-sm text-gray-300">Time Estimate</span>
</div>
<div className="flex gap-2 mb-2">
<input
type="number"
min="0"
value={editHours}
onChange={(e) => 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()}
/>
<input
type="number"
min="0"
max="59"
value={editMinutes}
onChange={(e) => 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()}
/>
</div>
<div className="flex gap-2">
<button
onClick={handleUpdateTime}
className="flex-1 px-2 py-1 text-sm bg-cyber-orange/20 text-cyber-orange rounded hover:bg-cyber-orange/30"
>
Save
</button>
<button
onClick={() => setShowTimeEdit(false)}
className="px-2 py-1 text-sm text-gray-400 hover:text-gray-300"
>
Cancel
</button>
</div>
</div>
) : (
<button
onClick={(e) => {
e.stopPropagation()
setShowTimeEdit(true)
}}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
>
<Clock size={14} />
<span>Set Time Estimate</span>
</button>
)}
{/* Description Edit */}
{showDescriptionEdit ? (
<div className="p-3 border-b border-cyber-orange/20">
<div className="flex items-center gap-2 mb-2">
<FileText size={14} className="text-cyber-orange" />
<span className="text-sm text-gray-300">Description</span>
</div>
<div className="space-y-2">
<textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Task description..."
rows="4"
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
onClick={(e) => e.stopPropagation()}
/>
<div className="flex gap-2">
<button
onClick={handleUpdateDescription}
className="flex-1 px-2 py-1 text-sm bg-cyber-orange/20 text-cyber-orange rounded hover:bg-cyber-orange/30"
>
Save
</button>
<button
onClick={() => setShowDescriptionEdit(false)}
className="px-2 py-1 text-sm text-gray-400 hover:text-gray-300"
>
Cancel
</button>
</div>
</div>
</div>
) : (
<button
onClick={(e) => {
e.stopPropagation()
setShowDescriptionEdit(true)
}}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
>
<FileText size={14} />
<span>Edit Description</span>
</button>
)}
{/* Tags Edit */}
{showTagsEdit ? (
<div className="p-3 border-b border-cyber-orange/20">
<div className="flex items-center gap-2 mb-2">
<Tag size={14} className="text-cyber-orange" />
<span className="text-sm text-gray-300">Tags (comma-separated)</span>
</div>
<div className="flex gap-2">
<input
type="text"
value={editTags}
onChange={(e) => setEditTags(e.target.value)}
placeholder="coding, bug-fix"
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()}
/>
<button
onClick={handleUpdateTags}
className="text-green-400 hover:text-green-300"
>
<Check size={16} />
</button>
<button
onClick={() => setShowTagsEdit(false)}
className="text-gray-400 hover:text-gray-300"
>
<X size={16} />
</button>
</div>
</div>
) : (
<button
onClick={(e) => {
e.stopPropagation()
setShowTagsEdit(true)
}}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
>
<Tag size={14} />
<span>Edit Tags</span>
</button>
)}
{/* Flag Color Edit */}
{showFlagEdit ? (
<div className="p-3 border-b border-cyber-orange/20">
<div className="flex items-center gap-2 mb-2">
<Flag size={14} className="text-cyber-orange" />
<span className="text-sm text-gray-300">Flag Color</span>
</div>
<div className="flex gap-2 flex-wrap">
{FLAG_COLORS.map(({ name, color }) => (
<button
key={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}
className="w-full mt-2 px-2 py-1 text-xs text-gray-400 hover:text-gray-200 border border-gray-600 rounded"
>
Clear Flag
</button>
</div>
) : (
<button
onClick={(e) => {
e.stopPropagation()
setShowFlagEdit(true)
}}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
>
<Flag size={14} />
<span>Set Flag Color</span>
</button>
)}
{/* Status Change */}
{showStatusEdit ? (
<div className="p-3 border-b border-cyber-orange/20">
<div className="flex items-center gap-2 mb-2">
<ListTodo size={14} className="text-cyber-orange" />
<span className="text-sm text-gray-300">Change Status</span>
</div>
<div className="space-y-1">
{(projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']).map((status) => (
<button
key={status}
onClick={() => handleUpdateStatus(status)}
className={`w-full text-left px-2 py-1.5 rounded text-sm ${
task.status === status
? 'bg-cyber-orange/20 border border-cyber-orange/40'
: 'hover:bg-cyber-darker border border-transparent'
} ${getStatusTextColor(status)} transition-all`}
>
{formatStatusLabel(status)} {task.status === status && '✓'}
</button>
))}
</div>
</div>
) : (
<button
onClick={(e) => {
e.stopPropagation()
setShowStatusEdit(true)
}}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
>
<ListTodo size={14} />
<span>Change Status</span>
</button>
)}
{/* Edit Title */}
<button
onClick={(e) => {
e.stopPropagation()
onEdit()
setIsOpen(false)
}}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
>
<Edit2 size={14} />
<span>Edit Title</span>
</button>
{/* Delete */}
<button
onClick={(e) => {
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>
)}
</div>
)
}
export default TaskMenu
import { useState, useRef, useEffect } from 'react'
import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo, FileText, Lock } from 'lucide-react'
import { updateTask } from '../utils/api'
import BlockerPanel from './BlockerPanel'
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)
const [showBlockerPanel, setShowBlockerPanel] = 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 (
<div className="relative" ref={menuRef}>
<button
onClick={(e) => {
e.stopPropagation()
setIsOpen(!isOpen)
}}
className="text-gray-400 hover:text-gray-200 p-1"
title="More options"
>
<MoreVertical size={16} />
</button>
{isOpen && (
<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 */}
{showTimeEdit ? (
<div className="p-3 border-b border-cyber-orange/20">
<div className="flex items-center gap-2 mb-2">
<Clock size={14} className="text-cyber-orange" />
<span className="text-sm text-gray-300">Time Estimate</span>
</div>
<div className="flex gap-2 mb-2">
<input
type="number"
min="0"
value={editHours}
onChange={(e) => 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()}
/>
<input
type="number"
min="0"
max="59"
value={editMinutes}
onChange={(e) => 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()}
/>
</div>
<div className="flex gap-2">
<button
onClick={handleUpdateTime}
className="flex-1 px-2 py-1 text-sm bg-cyber-orange/20 text-cyber-orange rounded hover:bg-cyber-orange/30"
>
Save
</button>
<button
onClick={() => setShowTimeEdit(false)}
className="px-2 py-1 text-sm text-gray-400 hover:text-gray-300"
>
Cancel
</button>
</div>
</div>
) : (
<button
onClick={(e) => {
e.stopPropagation()
setShowTimeEdit(true)
}}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
>
<Clock size={14} />
<span>Set Time Estimate</span>
</button>
)}
{/* Description Edit */}
{showDescriptionEdit ? (
<div className="p-3 border-b border-cyber-orange/20">
<div className="flex items-center gap-2 mb-2">
<FileText size={14} className="text-cyber-orange" />
<span className="text-sm text-gray-300">Description</span>
</div>
<div className="space-y-2">
<textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Task description..."
rows="4"
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
onClick={(e) => e.stopPropagation()}
/>
<div className="flex gap-2">
<button
onClick={handleUpdateDescription}
className="flex-1 px-2 py-1 text-sm bg-cyber-orange/20 text-cyber-orange rounded hover:bg-cyber-orange/30"
>
Save
</button>
<button
onClick={() => setShowDescriptionEdit(false)}
className="px-2 py-1 text-sm text-gray-400 hover:text-gray-300"
>
Cancel
</button>
</div>
</div>
</div>
) : (
<button
onClick={(e) => {
e.stopPropagation()
setShowDescriptionEdit(true)
}}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
>
<FileText size={14} />
<span>Edit Description</span>
</button>
)}
{/* Tags Edit */}
{showTagsEdit ? (
<div className="p-3 border-b border-cyber-orange/20">
<div className="flex items-center gap-2 mb-2">
<Tag size={14} className="text-cyber-orange" />
<span className="text-sm text-gray-300">Tags (comma-separated)</span>
</div>
<div className="flex gap-2">
<input
type="text"
value={editTags}
onChange={(e) => setEditTags(e.target.value)}
placeholder="coding, bug-fix"
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()}
/>
<button
onClick={handleUpdateTags}
className="text-green-400 hover:text-green-300"
>
<Check size={16} />
</button>
<button
onClick={() => setShowTagsEdit(false)}
className="text-gray-400 hover:text-gray-300"
>
<X size={16} />
</button>
</div>
</div>
) : (
<button
onClick={(e) => {
e.stopPropagation()
setShowTagsEdit(true)
}}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
>
<Tag size={14} />
<span>Edit Tags</span>
</button>
)}
{/* Flag Color Edit */}
{showFlagEdit ? (
<div className="p-3 border-b border-cyber-orange/20">
<div className="flex items-center gap-2 mb-2">
<Flag size={14} className="text-cyber-orange" />
<span className="text-sm text-gray-300">Flag Color</span>
</div>
<div className="flex gap-2 flex-wrap">
{FLAG_COLORS.map(({ name, color }) => (
<button
key={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}
className="w-full mt-2 px-2 py-1 text-xs text-gray-400 hover:text-gray-200 border border-gray-600 rounded"
>
Clear Flag
</button>
</div>
) : (
<button
onClick={(e) => {
e.stopPropagation()
setShowFlagEdit(true)
}}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
>
<Flag size={14} />
<span>Set Flag Color</span>
</button>
)}
{/* Status Change */}
{showStatusEdit ? (
<div className="p-3 border-b border-cyber-orange/20">
<div className="flex items-center gap-2 mb-2">
<ListTodo size={14} className="text-cyber-orange" />
<span className="text-sm text-gray-300">Change Status</span>
</div>
<div className="space-y-1">
{(projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']).map((status) => (
<button
key={status}
onClick={() => handleUpdateStatus(status)}
className={`w-full text-left px-2 py-1.5 rounded text-sm ${
task.status === status
? 'bg-cyber-orange/20 border border-cyber-orange/40'
: 'hover:bg-cyber-darker border border-transparent'
} ${getStatusTextColor(status)} transition-all`}
>
{formatStatusLabel(status)} {task.status === status && '✓'}
</button>
))}
</div>
</div>
) : (
<button
onClick={(e) => {
e.stopPropagation()
setShowStatusEdit(true)
}}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
>
<ListTodo size={14} />
<span>Change Status</span>
</button>
)}
{/* Manage Blockers */}
<button
onClick={(e) => {
e.stopPropagation()
setShowBlockerPanel(true)
setIsOpen(false)
}}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
>
<Lock size={14} />
<span>Manage Blockers</span>
</button>
{/* Edit Title */}
<button
onClick={(e) => {
e.stopPropagation()
onEdit()
setIsOpen(false)
}}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
>
<Edit2 size={14} />
<span>Edit Title</span>
</button>
{/* Delete */}
<button
onClick={(e) => {
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,7 +1,7 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Plus, Upload, Trash2 } from 'lucide-react'
import { getProjects, createProject, deleteProject, importJSON } from '../utils/api'
import { Plus, Upload, Trash2, Archive, ArchiveRestore } from 'lucide-react'
import { getProjects, createProject, deleteProject, importJSON, archiveProject, unarchiveProject } from '../utils/api'
function ProjectList() {
const [projects, setProjects] = useState([])
@@ -12,15 +12,22 @@ function ProjectList() {
const [newProjectDesc, setNewProjectDesc] = useState('')
const [importJSON_Text, setImportJSONText] = useState('')
const [error, setError] = useState('')
const [activeTab, setActiveTab] = useState('active') // 'active', 'archived', 'all'
const navigate = useNavigate()
useEffect(() => {
loadProjects()
}, [])
}, [activeTab])
const loadProjects = async () => {
try {
const data = await getProjects()
setLoading(true)
let archivedFilter = null
if (activeTab === 'active') archivedFilter = false
if (activeTab === 'archived') archivedFilter = true
// 'all' tab uses null to get all projects
const data = await getProjects(archivedFilter)
setProjects(data)
} catch (err) {
setError(err.message)
@@ -72,13 +79,33 @@ function ProjectList() {
}
}
const handleArchiveProject = async (projectId, e) => {
e.stopPropagation()
try {
await archiveProject(projectId)
setProjects(projects.filter(p => p.id !== projectId))
} catch (err) {
setError(err.message)
}
}
const handleUnarchiveProject = async (projectId, e) => {
e.stopPropagation()
try {
await unarchiveProject(projectId)
setProjects(projects.filter(p => p.id !== projectId))
} catch (err) {
setError(err.message)
}
}
if (loading) {
return <div className="text-center text-gray-400 py-12">Loading...</div>
}
return (
<div>
<div className="flex justify-between items-center mb-8">
<div className="flex justify-between items-center mb-6">
<h2 className="text-3xl font-bold text-gray-100">Projects</h2>
<div className="flex gap-3">
<button
@@ -98,6 +125,40 @@ function ProjectList() {
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 mb-6 border-b border-cyber-orange/20">
<button
onClick={() => setActiveTab('active')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'active'
? 'text-cyber-orange border-b-2 border-cyber-orange'
: 'text-gray-400 hover:text-gray-200'
}`}
>
Active
</button>
<button
onClick={() => setActiveTab('archived')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'archived'
? 'text-cyber-orange border-b-2 border-cyber-orange'
: 'text-gray-400 hover:text-gray-200'
}`}
>
Archived
</button>
<button
onClick={() => setActiveTab('all')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'all'
? 'text-cyber-orange border-b-2 border-cyber-orange'
: 'text-gray-400 hover:text-gray-200'
}`}
>
All
</button>
</div>
{error && (
<div className="mb-4 p-4 bg-red-900/30 border border-red-500/50 rounded text-red-300">
{error}
@@ -106,8 +167,14 @@ function ProjectList() {
{projects.length === 0 ? (
<div className="text-center py-16 text-gray-500">
<p className="text-xl mb-2">No projects yet</p>
<p className="text-sm">Create a new project or import from JSON</p>
<p className="text-xl mb-2">
{activeTab === 'archived' ? 'No archived projects' : 'No projects yet'}
</p>
<p className="text-sm">
{activeTab === 'archived'
? 'Archive projects to keep them out of your active workspace'
: 'Create a new project or import from JSON'}
</p>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
@@ -115,18 +182,45 @@ function ProjectList() {
<div
key={project.id}
onClick={() => navigate(`/project/${project.id}`)}
className="p-6 bg-cyber-darkest border border-cyber-orange/30 rounded-lg hover:border-cyber-orange hover:shadow-cyber transition-all cursor-pointer group"
className={`p-6 bg-cyber-darkest border rounded-lg hover:border-cyber-orange hover:shadow-cyber transition-all cursor-pointer group ${
project.is_archived
? 'border-gray-700 opacity-75'
: 'border-cyber-orange/30'
}`}
>
<div className="flex justify-between items-start mb-2">
<h3 className="text-xl font-semibold text-gray-100 group-hover:text-cyber-orange transition-colors">
{project.name}
{project.is_archived && (
<span className="ml-2 text-xs text-gray-500">(archived)</span>
)}
</h3>
<button
onClick={(e) => handleDeleteProject(project.id, e)}
className="text-gray-600 hover:text-red-400 transition-colors"
>
<Trash2 size={18} />
</button>
<div className="flex gap-2">
{project.is_archived ? (
<button
onClick={(e) => handleUnarchiveProject(project.id, e)}
className="text-gray-600 hover:text-cyber-orange transition-colors"
title="Unarchive project"
>
<ArchiveRestore size={18} />
</button>
) : (
<button
onClick={(e) => handleArchiveProject(project.id, e)}
className="text-gray-600 hover:text-yellow-400 transition-colors"
title="Archive project"
>
<Archive size={18} />
</button>
)}
<button
onClick={(e) => handleDeleteProject(project.id, e)}
className="text-gray-600 hover:text-red-400 transition-colors"
title="Delete project"
>
<Trash2 size={18} />
</button>
</div>
</div>
{project.description && (
<p className="text-gray-400 text-sm line-clamp-2">{project.description}</p>

View File

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