Compare commits
5 Commits
bd0ac0d95b
...
blockers
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5da6e075b4 | ||
|
|
c6ed57342c | ||
|
|
5d5cec048f | ||
| 01e594b941 | |||
|
|
3fc90063b4 |
160
ARCHIVE_FEATURE_README.md
Normal file
160
ARCHIVE_FEATURE_README.md
Normal 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)
|
||||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -1,11 +1,11 @@
|
|||||||
# Changelog
|
# 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/),
|
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).
|
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
|
### Added
|
||||||
- **Dynamic Status Management System**
|
- **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
|
- Helper functions for status formatting and color coding
|
||||||
- Prop drilling of projectStatuses through component hierarchy
|
- Prop drilling of projectStatuses through component hierarchy
|
||||||
|
|
||||||
## [0.1.5] - 2025-01-XX
|
## [0.1.5] - 2025-11-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Nested Kanban View** - Major feature implementation
|
- **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
|
## 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.
|
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
|
**License**: MIT
|
||||||
**Author**: serversdwn
|
**Author**: serversdwn
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -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.
|
**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
|
## 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
|
### 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
|
- **LLM Integration**: Import JSON task trees generated by AI assistants
|
||||||
- **Real-Time Search**: Find tasks across projects with filtering
|
- **Real-Time Search**: Find tasks across projects with filtering
|
||||||
- **Self-Hosted**: Full data ownership and privacy
|
- **Self-Hosted**: Full data ownership and privacy
|
||||||
- **Dark Cyberpunk UI**: Orange-accented dark theme optimized for focus
|
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
@@ -55,8 +54,8 @@ TESSERACT is designed for complex project management where tasks naturally decom
|
|||||||
|
|
||||||
1. **Clone the repository**
|
1. **Clone the repository**
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/serversdwn/tesseract.git
|
git clone https://github.com/serversdwn/break-it-down.git
|
||||||
cd tesseract
|
cd break-it-down
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Start the application**
|
2. **Start the application**
|
||||||
@@ -166,7 +165,7 @@ The Kanban board displays tasks in a nested hierarchy while maintaining status-b
|
|||||||
|
|
||||||
### Understanding Time Estimates
|
### 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
|
- **Leaf Tasks** (no subtasks): Display shows their own time estimate
|
||||||
- **Parent Tasks** (have subtasks): Display shows sum of ALL descendant leaf tasks
|
- **Parent Tasks** (have subtasks): Display shows sum of ALL descendant leaf tasks
|
||||||
@@ -202,7 +201,7 @@ Remaining work: 3h 30m (not 10h!)
|
|||||||
|
|
||||||
### JSON Import
|
### 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
|
1. Click "Import JSON" on a project
|
||||||
2. Upload a file matching this structure:
|
2. Upload a file matching this structure:
|
||||||
@@ -307,7 +306,7 @@ tasks
|
|||||||
### Project Structure
|
### Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
tesseract/
|
break-it-down/
|
||||||
├─ backend/
|
├─ backend/
|
||||||
│ ├─ app/
|
│ ├─ app/
|
||||||
│ │ ├─ main.py # FastAPI application
|
│ │ ├─ main.py # FastAPI application
|
||||||
@@ -370,10 +369,10 @@ Frontend will be available at `http://localhost:5173` (Vite default)
|
|||||||
**Backup:**
|
**Backup:**
|
||||||
```bash
|
```bash
|
||||||
# Docker
|
# Docker
|
||||||
docker cp tesseract-backend:/app/tesseract.db ./backup.db
|
docker cp bit-backend:/app/bit.db ./backup.db
|
||||||
|
|
||||||
# Local
|
# Local
|
||||||
cp backend/tesseract.db ./backup.db
|
cp backend/bit.db ./backup.db
|
||||||
```
|
```
|
||||||
|
|
||||||
**Schema Changes:**
|
**Schema Changes:**
|
||||||
@@ -456,8 +455,8 @@ MIT License - see LICENSE file for details
|
|||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
- **Issues**: https://github.com/serversdwn/tesseract/issues
|
- **Issues**: https://github.com/serversdwn/break-it-down/issues
|
||||||
- **Discussions**: https://github.com/serversdwn/tesseract/discussions
|
- **Discussions**: https://github.com/serversdwn/break-it-down/discussions
|
||||||
|
|
||||||
## Acknowledgments
|
## 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.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Database Configuration
|
# Database Configuration
|
||||||
DATABASE_URL=sqlite:///./tesseract.db
|
DATABASE_URL=sqlite:///./bit.db
|
||||||
|
|
||||||
# API Configuration
|
# 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_DESCRIPTION=API for managing deeply nested todo trees
|
||||||
API_VERSION=1.0.0
|
API_VERSION=1.0.0
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,14 @@ def get_project(db: Session, project_id: int) -> Optional[models.Project]:
|
|||||||
return db.query(models.Project).filter(models.Project.id == project_id).first()
|
return db.query(models.Project).filter(models.Project.id == project_id).first()
|
||||||
|
|
||||||
|
|
||||||
def get_projects(db: Session, skip: int = 0, limit: int = 100) -> List[models.Project]:
|
def get_projects(db: Session, skip: int = 0, limit: int = 100, archived: Optional[bool] = None) -> List[models.Project]:
|
||||||
return db.query(models.Project).offset(skip).limit(limit).all()
|
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(
|
def update_project(
|
||||||
@@ -180,3 +186,98 @@ def get_tasks_by_status(db: Session, project_id: int, status: str) -> List[model
|
|||||||
models.Task.project_id == project_id,
|
models.Task.project_id == project_id,
|
||||||
models.Task.status == status
|
models.Task.status == status
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
|
||||||
|
# ========== BLOCKER CRUD ==========
|
||||||
|
|
||||||
|
def _has_cycle(db: Session, start_id: int, target_id: int) -> bool:
|
||||||
|
"""BFS from start_id following its blockers. Returns True if target_id is reachable,
|
||||||
|
which would mean adding target_id as a blocker of start_id creates a cycle."""
|
||||||
|
visited = set()
|
||||||
|
queue = [start_id]
|
||||||
|
while queue:
|
||||||
|
current = queue.pop(0)
|
||||||
|
if current == target_id:
|
||||||
|
return True
|
||||||
|
if current in visited:
|
||||||
|
continue
|
||||||
|
visited.add(current)
|
||||||
|
task = db.query(models.Task).filter(models.Task.id == current).first()
|
||||||
|
if task:
|
||||||
|
for b in task.blockers:
|
||||||
|
if b.id not in visited:
|
||||||
|
queue.append(b.id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def add_blocker(db: Session, task_id: int, blocker_id: int) -> models.Task:
|
||||||
|
"""Add blocker_id as a prerequisite of task_id.
|
||||||
|
Raises ValueError on self-reference or cycle."""
|
||||||
|
if task_id == blocker_id:
|
||||||
|
raise ValueError("A task cannot block itself")
|
||||||
|
|
||||||
|
task = get_task(db, task_id)
|
||||||
|
blocker = get_task(db, blocker_id)
|
||||||
|
|
||||||
|
if not task:
|
||||||
|
raise ValueError("Task not found")
|
||||||
|
if not blocker:
|
||||||
|
raise ValueError("Blocker task not found")
|
||||||
|
|
||||||
|
# Already linked — idempotent
|
||||||
|
if any(b.id == blocker_id for b in task.blockers):
|
||||||
|
return task
|
||||||
|
|
||||||
|
# Cycle detection: would blocker_id eventually depend on task_id?
|
||||||
|
if _has_cycle(db, blocker_id, task_id):
|
||||||
|
raise ValueError("Adding this blocker would create a circular dependency")
|
||||||
|
|
||||||
|
task.blockers.append(blocker)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
def remove_blocker(db: Session, task_id: int, blocker_id: int) -> bool:
|
||||||
|
"""Remove blocker_id as a prerequisite of task_id."""
|
||||||
|
task = get_task(db, task_id)
|
||||||
|
blocker = get_task(db, blocker_id)
|
||||||
|
|
||||||
|
if not task or not blocker:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not any(b.id == blocker_id for b in task.blockers):
|
||||||
|
return False
|
||||||
|
|
||||||
|
task.blockers.remove(blocker)
|
||||||
|
db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_with_blockers(db: Session, task_id: int) -> Optional[models.Task]:
|
||||||
|
"""Get a task including its blockers and blocking lists."""
|
||||||
|
return db.query(models.Task).filter(models.Task.id == task_id).first()
|
||||||
|
|
||||||
|
|
||||||
|
def get_actionable_tasks(db: Session) -> List[dict]:
|
||||||
|
"""Return all non-done tasks that have no incomplete blockers, with project name."""
|
||||||
|
tasks = db.query(models.Task).filter(
|
||||||
|
models.Task.status != "done"
|
||||||
|
).all()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for task in tasks:
|
||||||
|
incomplete_blockers = [b for b in task.blockers if b.status != "done"]
|
||||||
|
if not incomplete_blockers:
|
||||||
|
result.append({
|
||||||
|
"id": task.id,
|
||||||
|
"title": task.title,
|
||||||
|
"project_id": task.project_id,
|
||||||
|
"project_name": task.project.name,
|
||||||
|
"status": task.status,
|
||||||
|
"estimated_minutes": task.estimated_minutes,
|
||||||
|
"tags": task.tags,
|
||||||
|
"flag_color": task.flag_color,
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|||||||
@@ -30,9 +30,14 @@ app.add_middleware(
|
|||||||
# ========== PROJECT ENDPOINTS ==========
|
# ========== PROJECT ENDPOINTS ==========
|
||||||
|
|
||||||
@app.get("/api/projects", response_model=List[schemas.Project])
|
@app.get("/api/projects", response_model=List[schemas.Project])
|
||||||
def list_projects(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
def list_projects(
|
||||||
"""List all projects"""
|
skip: int = 0,
|
||||||
return crud.get_projects(db, skip=skip, limit=limit)
|
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)
|
@app.post("/api/projects", response_model=schemas.Project, status_code=201)
|
||||||
@@ -154,6 +159,50 @@ def delete_task(task_id: int, db: Session = Depends(get_db)):
|
|||||||
return None
|
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 ==========
|
# ========== SEARCH ENDPOINT ==========
|
||||||
|
|
||||||
@app.get("/api/search", response_model=List[schemas.Task])
|
@app.get("/api/search", response_model=List[schemas.Task])
|
||||||
@@ -314,6 +363,6 @@ def root():
|
|||||||
"""API health check"""
|
"""API health check"""
|
||||||
return {
|
return {
|
||||||
"status": "online",
|
"status": "online",
|
||||||
"message": "Tesseract API - Nested Todo Tree Manager",
|
"message": "Break It Down (BIT) API",
|
||||||
"docs": "/docs"
|
"docs": "/docs"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON
|
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Boolean, Table
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from .database import Base
|
from .database import Base
|
||||||
@@ -8,6 +8,15 @@ from .database import Base
|
|||||||
DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
|
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):
|
class Project(Base):
|
||||||
__tablename__ = "projects"
|
__tablename__ = "projects"
|
||||||
|
|
||||||
@@ -15,6 +24,7 @@ class Project(Base):
|
|||||||
name = Column(String(255), nullable=False)
|
name = Column(String(255), nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES)
|
statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES)
|
||||||
|
is_archived = Column(Boolean, default=False, nullable=False)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
@@ -39,3 +49,13 @@ class Task(Base):
|
|||||||
|
|
||||||
project = relationship("Project", back_populates="tasks")
|
project = relationship("Project", back_populates="tasks")
|
||||||
parent = relationship("Task", remote_side=[id], backref="subtasks")
|
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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -46,6 +46,37 @@ class TaskWithSubtasks(Task):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
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
|
# Project Schemas
|
||||||
class ProjectBase(BaseModel):
|
class ProjectBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
@@ -60,11 +91,13 @@ class ProjectUpdate(BaseModel):
|
|||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
statuses: Optional[List[str]] = None
|
statuses: Optional[List[str]] = None
|
||||||
|
is_archived: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class Project(ProjectBase):
|
class Project(ProjectBase):
|
||||||
id: int
|
id: int
|
||||||
statuses: List[str]
|
statuses: List[str]
|
||||||
|
is_archived: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ class Settings(BaseSettings):
|
|||||||
"""Application settings loaded from environment variables"""
|
"""Application settings loaded from environment variables"""
|
||||||
|
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
database_url: str = "sqlite:///./tesseract.db"
|
database_url: str = "sqlite:///./bit.db"
|
||||||
|
|
||||||
# API Configuration
|
# 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_description: str = "API for managing deeply nested todo trees"
|
||||||
api_version: str = "1.0.0"
|
api_version: str = "1.0.0"
|
||||||
|
|
||||||
|
|||||||
40
backend/migrate_add_blockers.py
Normal file
40
backend/migrate_add_blockers.py
Normal 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()
|
||||||
37
backend/migrate_add_is_archived.py
Normal file
37
backend/migrate_add_is_archived.py
Normal 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()
|
||||||
@@ -10,7 +10,7 @@ DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
|
|||||||
|
|
||||||
def migrate():
|
def migrate():
|
||||||
# Connect to the database
|
# Connect to the database
|
||||||
conn = sqlite3.connect('tesseract.db')
|
conn = sqlite3.connect('bit.db')
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: tesseract-backend
|
container_name: bit-backend
|
||||||
ports:
|
ports:
|
||||||
- "8002:8002"
|
- "8002:8002"
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/app:/app/app
|
- ./backend/app:/app/app
|
||||||
- tesseract-db:/app
|
- bit-db:/app
|
||||||
env_file:
|
env_file:
|
||||||
- ./backend/.env
|
- ./backend/.env
|
||||||
environment:
|
environment:
|
||||||
@@ -19,7 +19,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: tesseract-frontend
|
container_name: bit-frontend
|
||||||
ports:
|
ports:
|
||||||
- "3002:80"
|
- "3002:80"
|
||||||
env_file:
|
env_file:
|
||||||
@@ -29,4 +29,4 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
tesseract-db:
|
bit-db:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Tesseract - Task Decomposition Engine</title>
|
<title>BIT - Break It Down</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "tesseract-frontend",
|
"name": "bit-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "tesseract-frontend",
|
"name": "bit-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lucide-react": "^0.303.0",
|
"lucide-react": "^0.303.0",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "tesseract-frontend",
|
"name": "bit-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,20 +1,41 @@
|
|||||||
import { Routes, Route } from 'react-router-dom'
|
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import { Zap } from 'lucide-react'
|
||||||
import ProjectList from './pages/ProjectList'
|
import ProjectList from './pages/ProjectList'
|
||||||
import ProjectView from './pages/ProjectView'
|
import ProjectView from './pages/ProjectView'
|
||||||
|
import ActionableView from './pages/ActionableView'
|
||||||
import SearchBar from './components/SearchBar'
|
import SearchBar from './components/SearchBar'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const isActionable = location.pathname === '/actionable'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-cyber-dark">
|
<div className="min-h-screen bg-cyber-dark">
|
||||||
<header className="border-b border-cyber-orange/30 bg-cyber-darkest">
|
<header className="border-b border-cyber-orange/30 bg-cyber-darkest">
|
||||||
<div className="container mx-auto px-4 py-4">
|
<div className="container mx-auto px-4 py-4">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div className="flex items-center gap-4">
|
||||||
<h1 className="text-2xl font-bold text-cyber-orange">
|
<h1
|
||||||
TESSERACT
|
className="text-2xl font-bold text-cyber-orange cursor-pointer"
|
||||||
<span className="ml-3 text-sm text-gray-500">Task Decomposition Engine</span>
|
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>
|
<span className="ml-2 text-xs text-gray-600">v{import.meta.env.VITE_APP_VERSION || '0.1.6'}</span>
|
||||||
</h1>
|
</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>
|
</div>
|
||||||
<SearchBar />
|
<SearchBar />
|
||||||
</div>
|
</div>
|
||||||
@@ -25,6 +46,7 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<ProjectList />} />
|
<Route path="/" element={<ProjectList />} />
|
||||||
<Route path="/project/:projectId" element={<ProjectView />} />
|
<Route path="/project/:projectId" element={<ProjectView />} />
|
||||||
|
<Route path="/actionable" element={<ActionableView />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
201
frontend/src/components/BlockerPanel.jsx
Normal file
201
frontend/src/components/BlockerPanel.jsx
Normal 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
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo, FileText } from 'lucide-react'
|
import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo, FileText, Lock } from 'lucide-react'
|
||||||
import { updateTask } from '../utils/api'
|
import { updateTask } from '../utils/api'
|
||||||
|
import BlockerPanel from './BlockerPanel'
|
||||||
|
|
||||||
const FLAG_COLORS = [
|
const FLAG_COLORS = [
|
||||||
{ name: 'red', color: 'bg-red-500' },
|
{ name: 'red', color: 'bg-red-500' },
|
||||||
@@ -35,6 +36,7 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit, projectStatuses }) {
|
|||||||
const [showTagsEdit, setShowTagsEdit] = useState(false)
|
const [showTagsEdit, setShowTagsEdit] = useState(false)
|
||||||
const [showFlagEdit, setShowFlagEdit] = useState(false)
|
const [showFlagEdit, setShowFlagEdit] = useState(false)
|
||||||
const [showStatusEdit, setShowStatusEdit] = useState(false)
|
const [showStatusEdit, setShowStatusEdit] = useState(false)
|
||||||
|
const [showBlockerPanel, setShowBlockerPanel] = useState(false)
|
||||||
|
|
||||||
// Calculate hours and minutes from task.estimated_minutes
|
// Calculate hours and minutes from task.estimated_minutes
|
||||||
const initialHours = task.estimated_minutes ? Math.floor(task.estimated_minutes / 60) : ''
|
const initialHours = task.estimated_minutes ? Math.floor(task.estimated_minutes / 60) : ''
|
||||||
@@ -371,6 +373,19 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit, projectStatuses }) {
|
|||||||
</button>
|
</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 */}
|
{/* Edit Title */}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -398,6 +413,15 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit, projectStatuses }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Blocker panel modal */}
|
||||||
|
{showBlockerPanel && (
|
||||||
|
<BlockerPanel
|
||||||
|
task={task}
|
||||||
|
onClose={() => setShowBlockerPanel(false)}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
190
frontend/src/pages/ActionableView.jsx
Normal file
190
frontend/src/pages/ActionableView.jsx
Normal 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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Plus, Upload, Trash2 } from 'lucide-react'
|
import { Plus, Upload, Trash2, Archive, ArchiveRestore } from 'lucide-react'
|
||||||
import { getProjects, createProject, deleteProject, importJSON } from '../utils/api'
|
import { getProjects, createProject, deleteProject, importJSON, archiveProject, unarchiveProject } from '../utils/api'
|
||||||
|
|
||||||
function ProjectList() {
|
function ProjectList() {
|
||||||
const [projects, setProjects] = useState([])
|
const [projects, setProjects] = useState([])
|
||||||
@@ -12,15 +12,22 @@ function ProjectList() {
|
|||||||
const [newProjectDesc, setNewProjectDesc] = useState('')
|
const [newProjectDesc, setNewProjectDesc] = useState('')
|
||||||
const [importJSON_Text, setImportJSONText] = useState('')
|
const [importJSON_Text, setImportJSONText] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [activeTab, setActiveTab] = useState('active') // 'active', 'archived', 'all'
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadProjects()
|
loadProjects()
|
||||||
}, [])
|
}, [activeTab])
|
||||||
|
|
||||||
const loadProjects = async () => {
|
const loadProjects = async () => {
|
||||||
try {
|
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)
|
setProjects(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
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) {
|
if (loading) {
|
||||||
return <div className="text-center text-gray-400 py-12">Loading...</div>
|
return <div className="text-center text-gray-400 py-12">Loading...</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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>
|
<h2 className="text-3xl font-bold text-gray-100">Projects</h2>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
@@ -98,6 +125,40 @@ function ProjectList() {
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{error && (
|
||||||
<div className="mb-4 p-4 bg-red-900/30 border border-red-500/50 rounded text-red-300">
|
<div className="mb-4 p-4 bg-red-900/30 border border-red-500/50 rounded text-red-300">
|
||||||
{error}
|
{error}
|
||||||
@@ -106,8 +167,14 @@ function ProjectList() {
|
|||||||
|
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
<div className="text-center py-16 text-gray-500">
|
<div className="text-center py-16 text-gray-500">
|
||||||
<p className="text-xl mb-2">No projects yet</p>
|
<p className="text-xl mb-2">
|
||||||
<p className="text-sm">Create a new project or import from JSON</p>
|
{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>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
@@ -115,19 +182,46 @@ function ProjectList() {
|
|||||||
<div
|
<div
|
||||||
key={project.id}
|
key={project.id}
|
||||||
onClick={() => navigate(`/project/${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">
|
<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">
|
<h3 className="text-xl font-semibold text-gray-100 group-hover:text-cyber-orange transition-colors">
|
||||||
{project.name}
|
{project.name}
|
||||||
|
{project.is_archived && (
|
||||||
|
<span className="ml-2 text-xs text-gray-500">(archived)</span>
|
||||||
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
|
<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
|
<button
|
||||||
onClick={(e) => handleDeleteProject(project.id, e)}
|
onClick={(e) => handleDeleteProject(project.id, e)}
|
||||||
className="text-gray-600 hover:text-red-400 transition-colors"
|
className="text-gray-600 hover:text-red-400 transition-colors"
|
||||||
|
title="Delete project"
|
||||||
>
|
>
|
||||||
<Trash2 size={18} />
|
<Trash2 size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{project.description && (
|
{project.description && (
|
||||||
<p className="text-gray-400 text-sm line-clamp-2">{project.description}</p>
|
<p className="text-gray-400 text-sm line-clamp-2">{project.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -22,7 +22,14 @@ async function fetchAPI(endpoint, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Projects
|
// Projects
|
||||||
export const getProjects = () => fetchAPI('/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 getProject = (id) => fetchAPI(`/projects/${id}`);
|
||||||
export const createProject = (data) => fetchAPI('/projects', {
|
export const createProject = (data) => fetchAPI('/projects', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -33,6 +40,8 @@ export const updateProject = (id, data) => fetchAPI(`/projects/${id}`, {
|
|||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
export const deleteProject = (id) => fetchAPI(`/projects/${id}`, { method: 'DELETE' });
|
export const deleteProject = (id) => fetchAPI(`/projects/${id}`, { method: 'DELETE' });
|
||||||
|
export const archiveProject = (id) => updateProject(id, { is_archived: true });
|
||||||
|
export const unarchiveProject = (id) => updateProject(id, { is_archived: false });
|
||||||
|
|
||||||
// Tasks
|
// Tasks
|
||||||
export const getProjectTasks = (projectId) => fetchAPI(`/projects/${projectId}/tasks`);
|
export const getProjectTasks = (projectId) => fetchAPI(`/projects/${projectId}/tasks`);
|
||||||
@@ -57,6 +66,15 @@ export const importJSON = (data) => fetchAPI('/import-json', {
|
|||||||
body: JSON.stringify(data),
|
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
|
// Search
|
||||||
export const searchTasks = (query, projectIds = null) => {
|
export const searchTasks = (query, projectIds = null) => {
|
||||||
const params = new URLSearchParams({ query });
|
const params = new URLSearchParams({ query });
|
||||||
|
|||||||
Reference in New Issue
Block a user