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 # 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

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. **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.

View File

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

View File

@@ -1,182 +1,283 @@
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from typing import List, Optional from typing import List, Optional
from . import models, schemas from . import models, schemas
# Project CRUD # Project CRUD
def create_project(db: Session, project: schemas.ProjectCreate) -> models.Project: def create_project(db: Session, project: schemas.ProjectCreate) -> models.Project:
project_data = project.model_dump() project_data = project.model_dump()
# Ensure statuses has a default value if not provided # Ensure statuses has a default value if not provided
if project_data.get("statuses") is None: if project_data.get("statuses") is None:
project_data["statuses"] = models.DEFAULT_STATUSES project_data["statuses"] = models.DEFAULT_STATUSES
db_project = models.Project(**project_data) db_project = models.Project(**project_data)
db.add(db_project) db.add(db_project)
db.commit() db.commit()
db.refresh(db_project) db.refresh(db_project)
return db_project return db_project
def get_project(db: Session, project_id: int) -> Optional[models.Project]: def get_project(db: Session, project_id: int) -> Optional[models.Project]:
return db.query(models.Project).filter(models.Project.id == project_id).first() return db.query(models.Project).filter(models.Project.id == project_id).first()
def get_projects(db: Session, skip: int = 0, limit: int = 100) -> 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
def update_project( if archived is not None:
db: Session, project_id: int, project: schemas.ProjectUpdate query = query.filter(models.Project.is_archived == archived)
) -> Optional[models.Project]:
db_project = get_project(db, project_id) return query.offset(skip).limit(limit).all()
if not db_project:
return None
def update_project(
update_data = project.model_dump(exclude_unset=True) db: Session, project_id: int, project: schemas.ProjectUpdate
for key, value in update_data.items(): ) -> Optional[models.Project]:
setattr(db_project, key, value) db_project = get_project(db, project_id)
if not db_project:
db.commit() return None
db.refresh(db_project)
return db_project update_data = project.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(db_project, key, value)
def delete_project(db: Session, project_id: int) -> bool:
db_project = get_project(db, project_id) db.commit()
if not db_project: db.refresh(db_project)
return False return db_project
db.delete(db_project)
db.commit()
return True def delete_project(db: Session, project_id: int) -> bool:
db_project = get_project(db, project_id)
if not db_project:
# Task CRUD return False
def create_task(db: Session, task: schemas.TaskCreate) -> models.Task: db.delete(db_project)
# Validate status against project's statuses db.commit()
project = get_project(db, task.project_id) return True
if project and task.status not in project.statuses:
raise ValueError(f"Invalid status '{task.status}'. Must be one of: {', '.join(project.statuses)}")
# Task CRUD
# Get max sort_order for siblings def create_task(db: Session, task: schemas.TaskCreate) -> models.Task:
if task.parent_task_id: # Validate status against project's statuses
max_order = db.query(models.Task).filter( project = get_project(db, task.project_id)
models.Task.parent_task_id == task.parent_task_id if project and task.status not in project.statuses:
).count() raise ValueError(f"Invalid status '{task.status}'. Must be one of: {', '.join(project.statuses)}")
else:
max_order = db.query(models.Task).filter( # Get max sort_order for siblings
models.Task.project_id == task.project_id, if task.parent_task_id:
models.Task.parent_task_id.is_(None) max_order = db.query(models.Task).filter(
).count() models.Task.parent_task_id == task.parent_task_id
).count()
task_data = task.model_dump() else:
if "sort_order" not in task_data or task_data["sort_order"] == 0: max_order = db.query(models.Task).filter(
task_data["sort_order"] = max_order models.Task.project_id == task.project_id,
models.Task.parent_task_id.is_(None)
db_task = models.Task(**task_data) ).count()
db.add(db_task)
db.commit() task_data = task.model_dump()
db.refresh(db_task) if "sort_order" not in task_data or task_data["sort_order"] == 0:
return db_task task_data["sort_order"] = max_order
db_task = models.Task(**task_data)
def get_task(db: Session, task_id: int) -> Optional[models.Task]: db.add(db_task)
return db.query(models.Task).filter(models.Task.id == task_id).first() db.commit()
db.refresh(db_task)
return db_task
def get_tasks_by_project(db: Session, project_id: int) -> List[models.Task]:
return db.query(models.Task).filter(models.Task.project_id == project_id).all()
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_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( def get_tasks_by_project(db: Session, project_id: int) -> List[models.Task]:
models.Task.project_id == project_id, return db.query(models.Task).filter(models.Task.project_id == project_id).all()
models.Task.parent_task_id.is_(None)
).order_by(models.Task.sort_order).all()
def get_root_tasks(db: Session, project_id: int) -> List[models.Task]:
"""Get all root-level tasks (no parent) for a project"""
def get_task_with_subtasks(db: Session, task_id: int) -> Optional[models.Task]: return db.query(models.Task).filter(
"""Recursively load a task with all its subtasks""" models.Task.project_id == project_id,
return db.query(models.Task).options( models.Task.parent_task_id.is_(None)
joinedload(models.Task.subtasks) ).order_by(models.Task.sort_order).all()
).filter(models.Task.id == task_id).first()
def get_task_with_subtasks(db: Session, task_id: int) -> Optional[models.Task]:
def check_and_update_parent_status(db: Session, parent_id: int): """Recursively load a task with all its subtasks"""
"""Check if all children of a parent are done, and mark parent as done if so""" return db.query(models.Task).options(
# Get all children of this parent joinedload(models.Task.subtasks)
children = db.query(models.Task).filter( ).filter(models.Task.id == task_id).first()
models.Task.parent_task_id == parent_id
).all()
def check_and_update_parent_status(db: Session, parent_id: int):
# If no children, nothing to do """Check if all children of a parent are done, and mark parent as done if so"""
if not children: # Get all children of this parent
return children = db.query(models.Task).filter(
models.Task.parent_task_id == parent_id
# Check if all children are done ).all()
all_done = all(child.status == "done" for child in children)
# If no children, nothing to do
if all_done: if not children:
# Mark parent as done return
parent = get_task(db, parent_id)
if parent and parent.status != "done": # Check if all children are done
parent.status = "done" all_done = all(child.status == "done" for child in children)
db.commit()
if all_done:
# Recursively check grandparent # Mark parent as done
if parent.parent_task_id: parent = get_task(db, parent_id)
check_and_update_parent_status(db, parent.parent_task_id) if parent and parent.status != "done":
parent.status = "done"
db.commit()
def update_task(
db: Session, task_id: int, task: schemas.TaskUpdate # Recursively check grandparent
) -> Optional[models.Task]: if parent.parent_task_id:
db_task = get_task(db, task_id) check_and_update_parent_status(db, parent.parent_task_id)
if not db_task:
return None
def update_task(
update_data = task.model_dump(exclude_unset=True) db: Session, task_id: int, task: schemas.TaskUpdate
) -> Optional[models.Task]:
# Validate status against project's statuses if status is being updated db_task = get_task(db, task_id)
if "status" in update_data: if not db_task:
project = get_project(db, db_task.project_id) return None
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)}") update_data = task.model_dump(exclude_unset=True)
status_changed = True
old_status = db_task.status # Validate status against project's statuses if status is being updated
else: if "status" in update_data:
status_changed = False project = get_project(db, db_task.project_id)
if project and update_data["status"] not in project.statuses:
for key, value in update_data.items(): raise ValueError(f"Invalid status '{update_data['status']}'. Must be one of: {', '.join(project.statuses)}")
setattr(db_task, key, value) status_changed = True
old_status = db_task.status
db.commit() else:
db.refresh(db_task) status_changed = False
# If status changed to 'done' and this task has a parent, check if parent should auto-complete for key, value in update_data.items():
if status_changed and db_task.status == "done" and db_task.parent_task_id: setattr(db_task, key, value)
check_and_update_parent_status(db, db_task.parent_task_id)
db.commit()
return db_task db.refresh(db_task)
# If status changed to 'done' and this task has a parent, check if parent should auto-complete
def delete_task(db: Session, task_id: int) -> bool: if status_changed and db_task.status == "done" and db_task.parent_task_id:
db_task = get_task(db, task_id) check_and_update_parent_status(db, db_task.parent_task_id)
if not db_task:
return False return db_task
db.delete(db_task)
db.commit()
return True def delete_task(db: Session, task_id: int) -> bool:
db_task = get_task(db, task_id)
if not db_task:
def get_tasks_by_status(db: Session, project_id: int, status: str) -> List[models.Task]: return False
"""Get all tasks for a project with a specific status""" db.delete(db_task)
# Validate status against project's statuses db.commit()
project = get_project(db, project_id) return True
if project and status not in project.statuses:
raise ValueError(f"Invalid status '{status}'. Must be one of: {', '.join(project.statuses)}")
def get_tasks_by_status(db: Session, project_id: int, status: str) -> List[models.Task]:
return db.query(models.Task).filter( """Get all tasks for a project with a specific status"""
models.Task.project_id == project_id, # Validate status against project's statuses
models.Task.status == status project = get_project(db, project_id)
).all() 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 import FastAPI, Depends, HTTPException, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Optional from typing import List, Optional
import json import json
from . import models, schemas, crud from . import models, schemas, crud
from .database import engine, get_db from .database import engine, get_db
from .settings import settings from .settings import settings
# Create database tables # Create database tables
models.Base.metadata.create_all(bind=engine) models.Base.metadata.create_all(bind=engine)
app = FastAPI( app = FastAPI(
title=settings.api_title, title=settings.api_title,
description=settings.api_description, description=settings.api_description,
version=settings.api_version version=settings.api_version
) )
# CORS middleware for frontend # CORS middleware for frontend
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origins_list, allow_origins=settings.cors_origins_list,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# ========== PROJECT ENDPOINTS ========== # ========== PROJECT ENDPOINTS ==========
@app.get("/api/projects", response_model=List[schemas.Project]) @app.get("/api/projects", response_model=List[schemas.Project])
def list_projects(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)
@app.post("/api/projects", response_model=schemas.Project, status_code=201) ):
def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db)): """List all projects with optional archive filter"""
"""Create a new project""" return crud.get_projects(db, skip=skip, limit=limit, archived=archived)
return crud.create_project(db, project)
@app.post("/api/projects", response_model=schemas.Project, status_code=201)
@app.get("/api/projects/{project_id}", response_model=schemas.Project) def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db)):
def get_project(project_id: int, db: Session = Depends(get_db)): """Create a new project"""
"""Get a specific project""" return crud.create_project(db, project)
db_project = crud.get_project(db, project_id)
if not db_project:
raise HTTPException(status_code=404, detail="Project not found") @app.get("/api/projects/{project_id}", response_model=schemas.Project)
return db_project def get_project(project_id: int, db: Session = Depends(get_db)):
"""Get a specific project"""
db_project = crud.get_project(db, project_id)
@app.put("/api/projects/{project_id}", response_model=schemas.Project) if not db_project:
def update_project( raise HTTPException(status_code=404, detail="Project not found")
project_id: int, project: schemas.ProjectUpdate, db: Session = Depends(get_db) return db_project
):
"""Update a project"""
db_project = crud.update_project(db, project_id, project) @app.put("/api/projects/{project_id}", response_model=schemas.Project)
if not db_project: def update_project(
raise HTTPException(status_code=404, detail="Project not found") project_id: int, project: schemas.ProjectUpdate, db: Session = Depends(get_db)
return db_project ):
"""Update a project"""
db_project = crud.update_project(db, project_id, project)
@app.delete("/api/projects/{project_id}", status_code=204) if not db_project:
def delete_project(project_id: int, db: Session = Depends(get_db)): raise HTTPException(status_code=404, detail="Project not found")
"""Delete a project and all its tasks""" return db_project
if not crud.delete_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found")
return None @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"""
# ========== TASK ENDPOINTS ========== if not crud.delete_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found")
@app.get("/api/projects/{project_id}/tasks", response_model=List[schemas.Task]) return None
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): # ========== TASK ENDPOINTS ==========
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", response_model=List[schemas.Task])
def list_project_tasks(project_id: int, db: Session = Depends(get_db)):
"""List all tasks for a project"""
@app.get("/api/projects/{project_id}/tasks/tree", response_model=List[schemas.TaskWithSubtasks]) if not crud.get_project(db, project_id):
def get_project_task_tree(project_id: int, db: Session = Depends(get_db)): raise HTTPException(status_code=404, detail="Project not found")
"""Get the task tree (root tasks with nested subtasks) for a project""" return crud.get_tasks_by_project(db, project_id)
if not crud.get_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found")
@app.get("/api/projects/{project_id}/tasks/tree", response_model=List[schemas.TaskWithSubtasks])
root_tasks = crud.get_root_tasks(db, project_id) 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"""
def build_tree(task): if not crud.get_project(db, project_id):
task_dict = schemas.TaskWithSubtasks.model_validate(task) raise HTTPException(status_code=404, detail="Project not found")
task_dict.subtasks = [build_tree(subtask) for subtask in task.subtasks]
return task_dict root_tasks = crud.get_root_tasks(db, project_id)
return [build_tree(task) for task in root_tasks] def build_tree(task):
task_dict = schemas.TaskWithSubtasks.model_validate(task)
task_dict.subtasks = [build_tree(subtask) for subtask in task.subtasks]
@app.get("/api/projects/{project_id}/tasks/by-status/{status}", response_model=List[schemas.Task]) return task_dict
def get_tasks_by_status(
project_id: int, return [build_tree(task) for task in root_tasks]
status: str,
db: Session = Depends(get_db)
): @app.get("/api/projects/{project_id}/tasks/by-status/{status}", response_model=List[schemas.Task])
"""Get all tasks for a project filtered by status (for Kanban view)""" def get_tasks_by_status(
if not crud.get_project(db, project_id): project_id: int,
raise HTTPException(status_code=404, detail="Project not found") status: str,
try: db: Session = Depends(get_db)
return crud.get_tasks_by_status(db, project_id, status) ):
except ValueError as e: """Get all tasks for a project filtered by status (for Kanban view)"""
raise HTTPException(status_code=400, detail=str(e)) if not crud.get_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found")
try:
@app.post("/api/tasks", response_model=schemas.Task, status_code=201) return crud.get_tasks_by_status(db, project_id, status)
def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)): except ValueError as e:
"""Create a new task""" raise HTTPException(status_code=400, detail=str(e))
if not crud.get_project(db, task.project_id):
raise HTTPException(status_code=404, detail="Project not found")
@app.post("/api/tasks", response_model=schemas.Task, status_code=201)
if task.parent_task_id and not crud.get_task(db, task.parent_task_id): def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail="Parent task not found") """Create a new task"""
if not crud.get_project(db, task.project_id):
try: raise HTTPException(status_code=404, detail="Project not found")
return crud.create_task(db, task)
except ValueError as e: if task.parent_task_id and not crud.get_task(db, task.parent_task_id):
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=404, detail="Parent task not found")
try:
@app.get("/api/tasks/{task_id}", response_model=schemas.Task) return crud.create_task(db, task)
def get_task(task_id: int, db: Session = Depends(get_db)): except ValueError as e:
"""Get a specific task""" raise HTTPException(status_code=400, detail=str(e))
db_task = crud.get_task(db, task_id)
if not db_task:
raise HTTPException(status_code=404, detail="Task not found") @app.get("/api/tasks/{task_id}", response_model=schemas.Task)
return db_task def get_task(task_id: int, db: Session = Depends(get_db)):
"""Get a specific task"""
db_task = crud.get_task(db, task_id)
@app.put("/api/tasks/{task_id}", response_model=schemas.Task) if not db_task:
def update_task(task_id: int, task: schemas.TaskUpdate, db: Session = Depends(get_db)): raise HTTPException(status_code=404, detail="Task not found")
"""Update a task""" return db_task
try:
db_task = crud.update_task(db, task_id, task)
if not db_task: @app.put("/api/tasks/{task_id}", response_model=schemas.Task)
raise HTTPException(status_code=404, detail="Task not found") def update_task(task_id: int, task: schemas.TaskUpdate, db: Session = Depends(get_db)):
return db_task """Update a task"""
except ValueError as e: try:
raise HTTPException(status_code=400, detail=str(e)) db_task = crud.update_task(db, task_id, task)
if not db_task:
raise HTTPException(status_code=404, detail="Task not found")
@app.delete("/api/tasks/{task_id}", status_code=204) return db_task
def delete_task(task_id: int, db: Session = Depends(get_db)): except ValueError as e:
"""Delete a task and all its subtasks""" raise HTTPException(status_code=400, detail=str(e))
if not crud.delete_task(db, task_id):
raise HTTPException(status_code=404, detail="Task not found")
return None @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"""
# ========== SEARCH ENDPOINT ========== if not crud.delete_task(db, task_id):
raise HTTPException(status_code=404, detail="Task not found")
@app.get("/api/search", response_model=List[schemas.Task]) return None
def search_tasks(
query: str,
project_ids: Optional[str] = None, # ========== BLOCKER ENDPOINTS ==========
db: Session = Depends(get_db)
): @app.get("/api/tasks/{task_id}/blockers", response_model=List[schemas.BlockerInfo])
""" def get_task_blockers(task_id: int, db: Session = Depends(get_db)):
Search tasks across projects by title, description, and tags. """Get all tasks that are blocking a given task."""
task = crud.get_task_with_blockers(db, task_id)
Args: if not task:
query: Search term to match against title, description, and tags raise HTTPException(status_code=404, detail="Task not found")
project_ids: Comma-separated list of project IDs to search in (optional, searches all if not provided) return task.blockers
"""
# Parse project IDs if provided
project_id_list = None @app.get("/api/tasks/{task_id}/blocking", response_model=List[schemas.BlockerInfo])
if project_ids: def get_tasks_this_blocks(task_id: int, db: Session = Depends(get_db)):
try: """Get all tasks that this task is currently blocking."""
project_id_list = [int(pid.strip()) for pid in project_ids.split(',') if pid.strip()] task = crud.get_task_with_blockers(db, task_id)
except ValueError: if not task:
raise HTTPException(status_code=400, detail="Invalid project_ids format") raise HTTPException(status_code=404, detail="Task not found")
return task.blocking
# Build query
tasks_query = db.query(models.Task)
@app.post("/api/tasks/{task_id}/blockers/{blocker_id}", status_code=201)
# Filter by project IDs if specified def add_blocker(task_id: int, blocker_id: int, db: Session = Depends(get_db)):
if project_id_list: """Add blocker_id as a prerequisite of task_id."""
tasks_query = tasks_query.filter(models.Task.project_id.in_(project_id_list)) try:
crud.add_blocker(db, task_id, blocker_id)
# Search in title, description, and tags return {"status": "ok"}
search_term = f"%{query}%" except ValueError as e:
tasks = tasks_query.filter( raise HTTPException(status_code=400, detail=str(e))
(models.Task.title.ilike(search_term)) |
(models.Task.description.ilike(search_term)) |
(models.Task.tags.contains([query])) # Exact tag match @app.delete("/api/tasks/{task_id}/blockers/{blocker_id}", status_code=204)
).all() def remove_blocker(task_id: int, blocker_id: int, db: Session = Depends(get_db)):
"""Remove blocker_id as a prerequisite of task_id."""
return tasks if not crud.remove_blocker(db, task_id, blocker_id):
raise HTTPException(status_code=404, detail="Blocker relationship not found")
return None
# ========== JSON IMPORT ENDPOINT ==========
def _validate_task_statuses_recursive( @app.get("/api/actionable", response_model=List[schemas.ActionableTask])
tasks: List[schemas.ImportSubtask], def get_actionable_tasks(db: Session = Depends(get_db)):
valid_statuses: List[str], """Get all non-done tasks with no incomplete blockers, across all projects."""
path: str = "" return crud.get_actionable_tasks(db)
) -> None:
"""Recursively validate all task statuses against the project's valid statuses"""
for idx, task_data in enumerate(tasks): # ========== SEARCH ENDPOINT ==========
task_path = f"{path}.tasks[{idx}]" if path else f"tasks[{idx}]"
if task_data.status not in valid_statuses: @app.get("/api/search", response_model=List[schemas.Task])
raise ValueError( def search_tasks(
f"Invalid status '{task_data.status}' at {task_path}. " query: str,
f"Must be one of: {', '.join(valid_statuses)}" project_ids: Optional[str] = None,
) db: Session = Depends(get_db)
if task_data.subtasks: ):
_validate_task_statuses_recursive( """
task_data.subtasks, Search tasks across projects by title, description, and tags.
valid_statuses,
f"{task_path}.subtasks" 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)
"""
def _import_tasks_recursive( # Parse project IDs if provided
db: Session, project_id_list = None
project_id: int, if project_ids:
tasks: List[schemas.ImportSubtask], try:
parent_id: Optional[int] = None, project_id_list = [int(pid.strip()) for pid in project_ids.split(',') if pid.strip()]
count: int = 0 except ValueError:
) -> int: raise HTTPException(status_code=400, detail="Invalid project_ids format")
"""Recursively import tasks and their subtasks"""
for idx, task_data in enumerate(tasks): # Build query
task = schemas.TaskCreate( tasks_query = db.query(models.Task)
project_id=project_id,
parent_task_id=parent_id, # Filter by project IDs if specified
title=task_data.title, if project_id_list:
description=task_data.description, tasks_query = tasks_query.filter(models.Task.project_id.in_(project_id_list))
status=task_data.status,
estimated_minutes=task_data.estimated_minutes, # Search in title, description, and tags
tags=task_data.tags, search_term = f"%{query}%"
flag_color=task_data.flag_color, tasks = tasks_query.filter(
sort_order=idx (models.Task.title.ilike(search_term)) |
) (models.Task.description.ilike(search_term)) |
db_task = crud.create_task(db, task) (models.Task.tags.contains([query])) # Exact tag match
count += 1 ).all()
if task_data.subtasks: return tasks
count = _import_tasks_recursive(
db, project_id, task_data.subtasks, db_task.id, count
) # ========== JSON IMPORT ENDPOINT ==========
return count def _validate_task_statuses_recursive(
tasks: List[schemas.ImportSubtask],
valid_statuses: List[str],
@app.post("/api/import-json", response_model=schemas.ImportResult) path: str = ""
def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_db)): ) -> None:
""" """Recursively validate all task statuses against the project's valid statuses"""
Import a project with nested tasks from JSON. for idx, task_data in enumerate(tasks):
task_path = f"{path}.tasks[{idx}]" if path else f"tasks[{idx}]"
Expected format: if task_data.status not in valid_statuses:
{ raise ValueError(
"project": { f"Invalid status '{task_data.status}' at {task_path}. "
"name": "Project Name", f"Must be one of: {', '.join(valid_statuses)}"
"description": "Optional description", )
"statuses": ["backlog", "in_progress", "on_hold", "done"] // Optional if task_data.subtasks:
}, _validate_task_statuses_recursive(
"tasks": [ task_data.subtasks,
{ valid_statuses,
"title": "Task 1", f"{task_path}.subtasks"
"description": "Optional", )
"status": "backlog",
"subtasks": [
{ def _import_tasks_recursive(
"title": "Subtask 1.1", db: Session,
"status": "backlog", project_id: int,
"subtasks": [] 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(
# Create the project with optional statuses project_id=project_id,
project = crud.create_project( parent_task_id=parent_id,
db, title=task_data.title,
schemas.ProjectCreate( description=task_data.description,
name=import_data.project.name, status=task_data.status,
description=import_data.project.description, estimated_minutes=task_data.estimated_minutes,
statuses=import_data.project.statuses tags=task_data.tags,
) flag_color=task_data.flag_color,
) sort_order=idx
)
# Validate all task statuses before importing db_task = crud.create_task(db, task)
if import_data.tasks: count += 1
try:
_validate_task_statuses_recursive(import_data.tasks, project.statuses) if task_data.subtasks:
except ValueError as e: count = _import_tasks_recursive(
# Rollback the project creation if validation fails db, project_id, task_data.subtasks, db_task.id, count
db.delete(project) )
db.commit()
raise HTTPException(status_code=400, detail=str(e)) return count
# Recursively import tasks
tasks_created = _import_tasks_recursive( @app.post("/api/import-json", response_model=schemas.ImportResult)
db, project.id, import_data.tasks def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_db)):
) """
Import a project with nested tasks from JSON.
return schemas.ImportResult(
project_id=project.id, Expected format:
project_name=project.name, {
tasks_created=tasks_created "project": {
) "name": "Project Name",
"description": "Optional description",
"statuses": ["backlog", "in_progress", "on_hold", "done"] // Optional
@app.get("/") },
def root(): "tasks": [
"""API health check""" {
return { "title": "Task 1",
"status": "online", "description": "Optional",
"message": "Tesseract API - Nested Todo Tree Manager", "status": "backlog",
"docs": "/docs" "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 import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Boolean, Table
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from datetime import datetime from datetime import datetime
from .database import Base from .database import Base
# Default statuses for new projects # Default statuses for new projects
DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"] DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
class Project(Base): # Association table for task blocker relationships (many-to-many)
__tablename__ = "projects" task_blockers = Table(
"task_blockers",
id = Column(Integer, primary_key=True, index=True) Base.metadata,
name = Column(String(255), nullable=False) Column("task_id", Integer, ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True),
description = Column(Text, nullable=True) Column("blocked_by_id", Integer, ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True),
statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES) )
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class Project(Base):
tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan") __tablename__ = "projects"
id = Column(Integer, primary_key=True, index=True)
class Task(Base): name = Column(String(255), nullable=False)
__tablename__ = "tasks" description = Column(Text, nullable=True)
statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES)
id = Column(Integer, primary_key=True, index=True) is_archived = Column(Boolean, default=False, nullable=False)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) created_at = Column(DateTime, default=datetime.utcnow)
parent_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
title = Column(String(500), nullable=False)
description = Column(Text, nullable=True) tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan")
status = Column(String(50), default="backlog", nullable=False)
sort_order = Column(Integer, default=0)
estimated_minutes = Column(Integer, nullable=True) class Task(Base):
tags = Column(JSON, nullable=True) __tablename__ = "tasks"
flag_color = Column(String(50), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow) id = Column(Integer, primary_key=True, index=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
parent_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True)
project = relationship("Project", back_populates="tasks") title = Column(String(500), nullable=False)
parent = relationship("Task", remote_side=[id], backref="subtasks") 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 pydantic import BaseModel, ConfigDict
from typing import Optional, List from typing import Optional, List
from datetime import datetime from datetime import datetime
from .models import DEFAULT_STATUSES from .models import DEFAULT_STATUSES
# Task Schemas # Task Schemas
class TaskBase(BaseModel): class TaskBase(BaseModel):
title: str title: str
description: Optional[str] = None description: Optional[str] = None
status: str = "backlog" status: str = "backlog"
parent_task_id: Optional[int] = None parent_task_id: Optional[int] = None
sort_order: int = 0 sort_order: int = 0
estimated_minutes: Optional[int] = None estimated_minutes: Optional[int] = None
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
flag_color: Optional[str] = None flag_color: Optional[str] = None
class TaskCreate(TaskBase): class TaskCreate(TaskBase):
project_id: int project_id: int
class TaskUpdate(BaseModel): class TaskUpdate(BaseModel):
title: Optional[str] = None title: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
status: Optional[str] = None status: Optional[str] = None
parent_task_id: Optional[int] = None parent_task_id: Optional[int] = None
sort_order: Optional[int] = None sort_order: Optional[int] = None
estimated_minutes: Optional[int] = None estimated_minutes: Optional[int] = None
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
flag_color: Optional[str] = None flag_color: Optional[str] = None
class Task(TaskBase): class Task(TaskBase):
id: int id: int
project_id: int project_id: int
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class TaskWithSubtasks(Task): class TaskWithSubtasks(Task):
subtasks: List['TaskWithSubtasks'] = [] subtasks: List['TaskWithSubtasks'] = []
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# Project Schemas class BlockerInfo(BaseModel):
class ProjectBase(BaseModel): """Lightweight task info used when listing blockers/blocking relationships."""
name: str id: int
description: Optional[str] = None title: str
project_id: int
status: str
class ProjectCreate(ProjectBase):
statuses: Optional[List[str]] = None model_config = ConfigDict(from_attributes=True)
class ProjectUpdate(BaseModel): class TaskWithBlockers(Task):
name: Optional[str] = None blockers: List[BlockerInfo] = []
description: Optional[str] = None blocking: List[BlockerInfo] = []
statuses: Optional[List[str]] = None
model_config = ConfigDict(from_attributes=True)
class Project(ProjectBase):
id: int class ActionableTask(BaseModel):
statuses: List[str] """A task that is ready to work on — not done, and all blockers are resolved."""
created_at: datetime id: int
updated_at: datetime title: str
project_id: int
model_config = ConfigDict(from_attributes=True) project_name: str
status: str
estimated_minutes: Optional[int] = None
class ProjectWithTasks(Project): tags: Optional[List[str]] = None
tasks: List[Task] = [] flag_color: Optional[str] = None
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# JSON Import Schemas # Project Schemas
class ImportSubtask(BaseModel): class ProjectBase(BaseModel):
title: str name: str
description: Optional[str] = None description: Optional[str] = None
status: str = "backlog"
estimated_minutes: Optional[int] = None
tags: Optional[List[str]] = None class ProjectCreate(ProjectBase):
flag_color: Optional[str] = None statuses: Optional[List[str]] = None
subtasks: List['ImportSubtask'] = []
class ProjectUpdate(BaseModel):
class ImportProject(BaseModel): name: Optional[str] = None
name: str description: Optional[str] = None
description: Optional[str] = None statuses: Optional[List[str]] = None
statuses: Optional[List[str]] = None is_archived: Optional[bool] = None
class ImportData(BaseModel): class Project(ProjectBase):
project: ImportProject id: int
tasks: List[ImportSubtask] = [] statuses: List[str]
is_archived: bool
created_at: datetime
class ImportResult(BaseModel): updated_at: datetime
project_id: int
project_name: str model_config = ConfigDict(from_attributes=True)
tasks_created: int
class ProjectWithTasks(Project):
tasks: List[Task] = []
model_config = ConfigDict(from_attributes=True)
# JSON Import Schemas
class ImportSubtask(BaseModel):
title: str
description: Optional[str] = None
status: str = "backlog"
estimated_minutes: Optional[int] = None
tags: Optional[List[str]] = None
flag_color: Optional[str] = None
subtasks: List['ImportSubtask'] = []
class ImportProject(BaseModel):
name: str
description: Optional[str] = None
statuses: Optional[List[str]] = None
class ImportData(BaseModel):
project: ImportProject
tasks: List[ImportSubtask] = []
class ImportResult(BaseModel):
project_id: int
project_name: str
tasks_created: int

View File

@@ -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"

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

View File

@@ -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:

View File

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

View File

@@ -1,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",

View File

@@ -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",

View File

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

View File

@@ -0,0 +1,201 @@
import { useState, useEffect, useRef } from 'react'
import { X, Search, Lock, Unlock, AlertTriangle } from 'lucide-react'
import { getTaskBlockers, addBlocker, removeBlocker, searchTasks } from '../utils/api'
function BlockerPanel({ task, onClose, onUpdate }) {
const [blockers, setBlockers] = useState([])
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState([])
const [searching, setSearching] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const searchTimeout = useRef(null)
useEffect(() => {
loadBlockers()
}, [task.id])
const loadBlockers = async () => {
try {
setLoading(true)
const data = await getTaskBlockers(task.id)
setBlockers(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handleSearch = (query) => {
setSearchQuery(query)
setError('')
if (searchTimeout.current) clearTimeout(searchTimeout.current)
if (!query.trim()) {
setSearchResults([])
return
}
searchTimeout.current = setTimeout(async () => {
try {
setSearching(true)
const results = await searchTasks(query)
// Filter out the current task and tasks already blocking this one
const blockerIds = new Set(blockers.map(b => b.id))
const filtered = results.filter(t => t.id !== task.id && !blockerIds.has(t.id))
setSearchResults(filtered.slice(0, 8))
} catch (err) {
setError(err.message)
} finally {
setSearching(false)
}
}, 300)
}
const handleAddBlocker = async (blocker) => {
try {
setError('')
await addBlocker(task.id, blocker.id)
setBlockers(prev => [...prev, blocker])
setSearchResults(prev => prev.filter(t => t.id !== blocker.id))
setSearchQuery('')
setSearchResults([])
onUpdate()
} catch (err) {
setError(err.message)
}
}
const handleRemoveBlocker = async (blockerId) => {
try {
setError('')
await removeBlocker(task.id, blockerId)
setBlockers(prev => prev.filter(b => b.id !== blockerId))
onUpdate()
} catch (err) {
setError(err.message)
}
}
const incompleteBlockers = blockers.filter(b => b.status !== 'done')
const isBlocked = incompleteBlockers.length > 0
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50" onClick={onClose}>
<div
className="bg-cyber-darkest border border-cyber-orange/50 rounded-lg w-full max-w-lg shadow-xl"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-cyber-orange/20">
<div className="flex items-center gap-2">
{isBlocked
? <Lock size={16} className="text-red-400" />
: <Unlock size={16} className="text-green-400" />
}
<span className="font-semibold text-gray-100 text-sm">Blockers</span>
<span className="text-xs text-gray-500 ml-1 truncate max-w-[200px]" title={task.title}>
{task.title}
</span>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-200 transition-colors">
<X size={18} />
</button>
</div>
<div className="p-5 space-y-4">
{/* Status banner */}
{isBlocked ? (
<div className="flex items-center gap-2 px-3 py-2 bg-red-900/20 border border-red-500/30 rounded text-red-300 text-sm">
<AlertTriangle size={14} />
<span>{incompleteBlockers.length} incomplete blocker{incompleteBlockers.length !== 1 ? 's' : ''} this task is locked</span>
</div>
) : (
<div className="flex items-center gap-2 px-3 py-2 bg-green-900/20 border border-green-500/30 rounded text-green-300 text-sm">
<Unlock size={14} />
<span>No active blockers this task is ready to work on</span>
</div>
)}
{/* Current blockers list */}
{loading ? (
<div className="text-gray-500 text-sm text-center py-2">Loading...</div>
) : blockers.length > 0 ? (
<div className="space-y-1">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Blocked by</p>
{blockers.map(b => (
<div
key={b.id}
className="flex items-center justify-between px-3 py-2 bg-cyber-darker rounded border border-cyber-orange/10"
>
<div className="flex items-center gap-2 min-w-0">
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${b.status === 'done' ? 'bg-green-400' : 'bg-red-400'}`} />
<span className="text-sm text-gray-200 truncate">{b.title}</span>
<span className="text-xs text-gray-500 flex-shrink-0">#{b.id}</span>
</div>
<button
onClick={() => handleRemoveBlocker(b.id)}
className="text-gray-600 hover:text-red-400 transition-colors flex-shrink-0 ml-2"
title="Remove blocker"
>
<X size={14} />
</button>
</div>
))}
</div>
) : (
<p className="text-xs text-gray-600 text-center py-1">No blockers added yet</p>
)}
{/* Search to add blocker */}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Add a blocker</p>
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
<input
type="text"
value={searchQuery}
onChange={e => handleSearch(e.target.value)}
placeholder="Search tasks across all projects..."
className="w-full pl-9 pr-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
autoFocus
/>
</div>
{/* Search results */}
{(searchResults.length > 0 || searching) && (
<div className="mt-1 border border-cyber-orange/20 rounded overflow-hidden">
{searching && (
<div className="px-3 py-2 text-xs text-gray-500">Searching...</div>
)}
{searchResults.map(result => (
<button
key={result.id}
onClick={() => handleAddBlocker(result)}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-left transition-colors border-b border-cyber-orange/10 last:border-0"
>
<span className="text-sm text-gray-200 truncate flex-1">{result.title}</span>
<span className="text-xs text-gray-600 flex-shrink-0">project #{result.project_id}</span>
</button>
))}
{!searching && searchResults.length === 0 && searchQuery && (
<div className="px-3 py-2 text-xs text-gray-500">No matching tasks found</div>
)}
</div>
)}
</div>
{error && (
<div className="px-3 py-2 bg-red-900/30 border border-red-500/40 rounded text-red-300 text-sm">
{error}
</div>
)}
</div>
</div>
</div>
)
}
export default BlockerPanel

View File

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

View File

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

View File

@@ -1,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,18 +182,45 @@ 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>
<button <div className="flex gap-2">
onClick={(e) => handleDeleteProject(project.id, e)} {project.is_archived ? (
className="text-gray-600 hover:text-red-400 transition-colors" <button
> onClick={(e) => handleUnarchiveProject(project.id, e)}
<Trash2 size={18} /> className="text-gray-600 hover:text-cyber-orange transition-colors"
</button> 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> </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>

View File

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