diff --git a/ARCHIVE_FEATURE_README.md b/ARCHIVE_FEATURE_README.md
new file mode 100644
index 0000000..274d121
--- /dev/null
+++ b/ARCHIVE_FEATURE_README.md
@@ -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)
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..f5350e9
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,246 @@
+# Changelog
+
+All notable changes to Break It Down (BIT) will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [0.1.6] - 2025-11-25
+
+### Added
+- **Dynamic Status Management System**
+ - Per-project customizable status workflows (replacing hardcoded statuses)
+ - New default statuses: `backlog`, `in_progress`, `on_hold`, `done` (removed `blocked`)
+ - Project Settings modal with comprehensive status management:
+ - Drag-and-drop status reordering
+ - Add new custom statuses
+ - Rename existing statuses (inline editing)
+ - Delete unused statuses with validation
+ - Task count display per status
+ - Warning dialogs when attempting to delete statuses with tasks
+ - Settings button (âī¸) in project header to access configuration
+ - Status validation in backend CRUD operations
+ - Database migration for `projects.statuses` JSON column
+
+- **Enhanced Task Creation**
+ - Status selector dropdown in TaskForm component
+ - Select status when creating tasks (no longer defaults to "backlog" only)
+ - Works across Tree View and Kanban View
+ - Defaults to current column status in Kanban View
+ - Defaults to "backlog" in Tree View
+
+- **Subtask Creation in Kanban View**
+ - "+" button on task cards to add subtasks directly in Kanban
+ - Button appears on hover next to task menu
+ - Opens TaskForm with status defaulted to current column
+ - Automatically expands parent card after creating subtask
+ - Full parity with Tree View subtask functionality
+
+### Changed
+- Removed hardcoded status enums from backend (`TaskStatus` enum removed)
+- Task status field changed from Enum to String in database
+- All status references now dynamic (backend, frontend, Kanban UI)
+- Status formatting helpers added for consistent display
+- Status colors now pattern-matched based on status names
+- Existing tasks migrated from uppercase enum format to lowercase
+
+### Backend Changes
+- `models.py`: Added `DEFAULT_STATUSES` constant, `statuses` JSON column to Project, changed Task.status to String
+- `schemas.py`: Changed status from TaskStatus enum to str, added statuses to Project schemas
+- `crud.py`: Added status validation in create_task, update_task, get_tasks_by_status
+- `main.py`: Added status validation to endpoints and JSON import
+
+### Frontend Changes
+- `ProjectSettings.jsx`: New component for managing project status workflows
+- `TaskForm.jsx`: Added status selector with dynamic status list
+- `KanbanView.jsx`: Dynamic status columns, subtask creation, removed hardcoded STATUSES
+- `TaskMenu.jsx`: Dynamic status dropdown using projectStatuses prop
+- `TreeView.jsx`: Dynamic status helpers, passes statuses to forms
+- `ProjectView.jsx`: Integrated ProjectSettings modal with gear icon button
+
+### Fixed
+- Database schema migration for projects.statuses column
+- Task status migration from uppercase (BACKLOG, IN_PROGRESS, DONE) to lowercase format
+- 51 existing tasks successfully migrated to new status format
+
+### Technical Details
+- New database column: `projects.statuses` (JSON, default: ["backlog", "in_progress", "on_hold", "done"])
+- Status validation at multiple layers (CRUD, API endpoints, frontend)
+- Helper functions for status formatting and color coding
+- Prop drilling of projectStatuses through component hierarchy
+
+## [0.1.5] - 2025-11-22
+
+### Added
+- **Nested Kanban View** - Major feature implementation
+ - Parent tasks now appear in each column where they have subtasks
+ - Parent cards show "X of Y subtasks in this column" indicator
+ - Parent cards are expandable/collapsible to show children in that column
+ - Parent cards have distinct visual styling (thicker orange border, bold text)
+ - Only leaf tasks (tasks with no children) are draggable
+ - Parent cards automatically appear in multiple columns as children move
+- Helper functions for nested Kanban logic:
+ - `getDescendantsInStatus()` - Get all descendant tasks in a specific status
+ - `hasDescendantsInStatus()` - Check if parent has any descendants in a status
+
+### Changed
+- Kanban board now labeled "Kanban Board (Nested View)"
+- Parent task cards cannot be dragged (only leaf tasks)
+- Column task counts now include parent cards
+- Improved visual hierarchy with parent/child distinction
+
+### Improved
+- Better visualization of task distribution across statuses
+- Easier to see project structure while maintaining status-based organization
+- Parent tasks provide context for subtasks in each column
+
+## [0.1.4] - 2025-01-XX
+
+### Added
+- Strikethrough styling for time estimates when tasks are marked as "done"
+- Auto-complete parent tasks when all child tasks are marked as "done"
+ - Works recursively up the task hierarchy
+ - Parents automatically transition to "done" status when all children complete
+
+### Changed
+- Time estimates on completed tasks now display with strikethrough decoration
+- Parent task status automatically updates based on children completion state
+
+## [0.1.3] - 2025-01-XX
+
+### Added
+- Enhanced task creation forms with metadata fields
+ - Title field (required)
+ - Tags field (comma-separated input)
+ - Time estimate fields (hours and minutes)
+ - Flag color selector with 8 color options
+- TaskForm component for consistent task creation across views
+- Status change dropdown in TaskMenu (no longer requires Kanban view)
+- Leaf-based time calculation system
+ - Parent tasks show sum of descendant leaf task estimates
+ - Prevents double-counting when both parents and children have estimates
+ - Excludes "done" tasks from time calculations
+- Time format changed from decimal hours to hours + minutes (e.g., "1h 30m" instead of "1.5h")
+- CHANGELOG.md and README.md documentation
+
+### Changed
+- Task creation now includes all metadata fields upfront
+- Time estimates display remaining work (excludes completed tasks)
+- Time input uses separate hours/minutes fields instead of single minutes field
+- Parent task estimates calculated from leaf descendants only
+
+### Fixed
+- Time calculation now accurately represents remaining work
+- Time format more human-readable with hours and minutes
+
+## [0.1.2] - 2025-01-XX
+
+### Added
+- Metadata fields for tasks:
+ - `estimated_minutes` (Integer) - Time estimate stored in minutes
+ - `tags` (JSON Array) - Categorization tags
+ - `flag_color` (String) - Priority flag with 7 color options
+- TaskMenu component with three-dot dropdown
+ - Edit time estimates
+ - Edit tags (comma-separated)
+ - Set flag colors
+ - Edit task title
+ - Delete tasks
+- SearchBar component in header
+ - Real-time search with 300ms debounce
+ - Optional project filtering
+ - Click results to navigate to project
+ - Displays metadata in results
+- Time and tag display in TreeView and KanbanView
+- Flag color indicators on tasks
+- Backend search endpoint `/api/search` with project filtering
+
+### Changed
+- TreeView and KanbanView now display task metadata
+- Enhanced visual design with metadata badges
+
+## [0.1.1] - 2025-01-XX
+
+### Fixed
+- Tree view indentation now scales properly with nesting depth
+ - Changed from fixed `ml-6` to calculated `marginLeft: ${level * 1.5}rem`
+ - Each nesting level adds 1.5rem (24px) of indentation
+- Kanban view subtask handling
+ - All tasks (including subtasks) now appear as individual draggable cards
+ - Subtasks show parent context: "âŗ subtask of: [parent name]"
+ - Removed nested subtask list display
+
+### Changed
+- Improved visual hierarchy in tree view
+- Better subtask representation in Kanban board
+
+## [0.1.0] - 2025-01-XX
+
+### Added
+- Initial MVP release
+- Core Features:
+ - Arbitrary-depth nested task hierarchies
+ - Two view modes: Tree View and Kanban Board
+ - Self-hosted architecture with Docker deployment
+ - JSON import for LLM-generated task trees
+- Technology Stack:
+ - Backend: Python FastAPI with SQLAlchemy ORM
+ - Database: SQLite with self-referencing Task model
+ - Frontend: React + Tailwind CSS
+ - Deployment: Docker with nginx reverse proxy
+- Project Management:
+ - Create/read/update/delete projects
+ - Project-specific task trees
+- Task Management:
+ - Create tasks with title, description, status
+ - Four status types: Backlog, In Progress, Blocked, Done
+ - Hierarchical task nesting (task â subtask â sub-subtask â ...)
+ - Add subtasks to any task
+ - Delete tasks (cascading to all subtasks)
+- Tree View:
+ - Collapsible hierarchical display
+ - Expand/collapse subtasks
+ - Visual nesting indentation
+ - Inline editing
+ - Status display
+- Kanban Board:
+ - Four columns: Backlog, In Progress, Blocked, Done
+ - Drag-and-drop to change status
+ - All tasks shown as cards (including subtasks)
+- JSON Import:
+ - Bulk import task trees from JSON files
+ - Supports arbitrary nesting depth
+ - Example import file included
+- UI/UX:
+ - Dark cyberpunk theme
+ - Orange (#ff6b35) accent color
+ - Responsive design
+ - Real-time updates
+
+### Technical Details
+- Backend API endpoints:
+ - `/api/projects` - Project CRUD
+ - `/api/tasks` - Task CRUD
+ - `/api/projects/{id}/tree` - Hierarchical task tree
+ - `/api/projects/{id}/tasks` - Flat task list
+ - `/api/projects/{id}/import` - JSON import
+- Database Schema:
+ - `projects` table with id, name, description
+ - `tasks` table with self-referencing `parent_task_id`
+- Frontend Routing:
+ - `/` - Project list
+ - `/project/:id` - Project view with Tree/Kanban toggle
+- Docker Setup:
+ - Multi-stage builds for optimization
+ - Nginx reverse proxy configuration
+ - Named volumes for database persistence
+ - Development and production configurations
+
+## Project Information
+
+**Break It Down (BIT)** - Task Decomposition Engine
+A self-hosted web application for managing deeply nested todo trees with advanced time tracking and project planning capabilities.
+
+**Repository**: https://github.com/serversdwn/break-it-down
+**License**: MIT
+**Author**: serversdwn
diff --git a/README.md b/README.md
index 8907cf2..68a41f5 100644
Binary files a/README.md and b/README.md differ
diff --git a/backend/.env.example b/backend/.env.example
new file mode 100644
index 0000000..912826a
--- /dev/null
+++ b/backend/.env.example
@@ -0,0 +1,14 @@
+# Database Configuration
+DATABASE_URL=sqlite:///./bit.db
+
+# API Configuration
+API_TITLE=Break It Down (BIT) - Nested Todo Tree API
+API_DESCRIPTION=API for managing deeply nested todo trees
+API_VERSION=1.0.0
+
+# CORS Configuration (comma-separated list of allowed origins)
+CORS_ORIGINS=http://localhost:5173,http://localhost:3000
+
+# Server Configuration
+HOST=0.0.0.0
+PORT=8000
diff --git a/backend/app/crud.py b/backend/app/crud.py
index db0f379..d3d183a 100644
--- a/backend/app/crud.py
+++ b/backend/app/crud.py
@@ -5,7 +5,12 @@ from . import models, schemas
# Project CRUD
def create_project(db: Session, project: schemas.ProjectCreate) -> models.Project:
- db_project = models.Project(**project.model_dump())
+ project_data = project.model_dump()
+ # Ensure statuses has a default value if not provided
+ if project_data.get("statuses") is None:
+ project_data["statuses"] = models.DEFAULT_STATUSES
+
+ db_project = models.Project(**project_data)
db.add(db_project)
db.commit()
db.refresh(db_project)
@@ -16,8 +21,14 @@ def get_project(db: Session, project_id: int) -> Optional[models.Project]:
return db.query(models.Project).filter(models.Project.id == project_id).first()
-def get_projects(db: Session, skip: int = 0, limit: int = 100) -> List[models.Project]:
- return db.query(models.Project).offset(skip).limit(limit).all()
+def get_projects(db: Session, skip: int = 0, limit: int = 100, archived: Optional[bool] = None) -> List[models.Project]:
+ query = db.query(models.Project)
+
+ # Filter by archive status if specified
+ if archived is not None:
+ query = query.filter(models.Project.is_archived == archived)
+
+ return query.offset(skip).limit(limit).all()
def update_project(
@@ -47,6 +58,11 @@ def delete_project(db: Session, project_id: int) -> bool:
# Task CRUD
def create_task(db: Session, task: schemas.TaskCreate) -> models.Task:
+ # Validate status against project's statuses
+ project = get_project(db, task.project_id)
+ if project and task.status not in project.statuses:
+ raise ValueError(f"Invalid status '{task.status}'. Must be one of: {', '.join(project.statuses)}")
+
# Get max sort_order for siblings
if task.parent_task_id:
max_order = db.query(models.Task).filter(
@@ -92,6 +108,32 @@ def get_task_with_subtasks(db: Session, task_id: int) -> Optional[models.Task]:
).filter(models.Task.id == task_id).first()
+def check_and_update_parent_status(db: Session, parent_id: int):
+ """Check if all children of a parent are done, and mark parent as done if so"""
+ # Get all children of this parent
+ children = db.query(models.Task).filter(
+ models.Task.parent_task_id == parent_id
+ ).all()
+
+ # If no children, nothing to do
+ if not children:
+ return
+
+ # Check if all children are done
+ all_done = all(child.status == "done" for child in children)
+
+ if all_done:
+ # Mark parent as done
+ parent = get_task(db, parent_id)
+ if parent and parent.status != "done":
+ parent.status = "done"
+ db.commit()
+
+ # Recursively check grandparent
+ if parent.parent_task_id:
+ check_and_update_parent_status(db, parent.parent_task_id)
+
+
def update_task(
db: Session, task_id: int, task: schemas.TaskUpdate
) -> Optional[models.Task]:
@@ -100,11 +142,27 @@ def update_task(
return None
update_data = task.model_dump(exclude_unset=True)
+
+ # Validate status against project's statuses if status is being updated
+ if "status" in update_data:
+ project = get_project(db, db_task.project_id)
+ if project and update_data["status"] not in project.statuses:
+ raise ValueError(f"Invalid status '{update_data['status']}'. Must be one of: {', '.join(project.statuses)}")
+ status_changed = True
+ old_status = db_task.status
+ else:
+ status_changed = False
+
for key, value in update_data.items():
setattr(db_task, key, value)
db.commit()
db.refresh(db_task)
+
+ # If status changed to 'done' and this task has a parent, check if parent should auto-complete
+ if status_changed and db_task.status == "done" and db_task.parent_task_id:
+ check_and_update_parent_status(db, db_task.parent_task_id)
+
return db_task
@@ -117,8 +175,13 @@ def delete_task(db: Session, task_id: int) -> bool:
return True
-def get_tasks_by_status(db: Session, project_id: int, status: models.TaskStatus) -> List[models.Task]:
+def get_tasks_by_status(db: Session, project_id: int, status: str) -> List[models.Task]:
"""Get all tasks for a project with a specific status"""
+ # Validate status against project's statuses
+ project = get_project(db, project_id)
+ if project and status not in project.statuses:
+ raise ValueError(f"Invalid status '{status}'. Must be one of: {', '.join(project.statuses)}")
+
return db.query(models.Task).filter(
models.Task.project_id == project_id,
models.Task.status == status
diff --git a/backend/app/database.py b/backend/app/database.py
index 20a85c7..5e6f0b3 100644
--- a/backend/app/database.py
+++ b/backend/app/database.py
@@ -1,8 +1,9 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
+from .settings import settings
-SQLALCHEMY_DATABASE_URL = "sqlite:///./tesseract.db"
+SQLALCHEMY_DATABASE_URL = settings.database_url
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
diff --git a/backend/app/main.py b/backend/app/main.py
index 7640ff3..ba9dbca 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -6,20 +6,21 @@ import json
from . import models, schemas, crud
from .database import engine, get_db
+from .settings import settings
# Create database tables
models.Base.metadata.create_all(bind=engine)
app = FastAPI(
- title="Tesseract - Nested Todo Tree API",
- description="API for managing deeply nested todo trees",
- version="1.0.0"
+ title=settings.api_title,
+ description=settings.api_description,
+ version=settings.api_version
)
# CORS middleware for frontend
app.add_middleware(
CORSMiddleware,
- allow_origins=["http://localhost:5173", "http://localhost:3000"], # Vite default port
+ allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -29,9 +30,14 @@ app.add_middleware(
# ========== PROJECT ENDPOINTS ==========
@app.get("/api/projects", response_model=List[schemas.Project])
-def list_projects(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
- """List all projects"""
- return crud.get_projects(db, skip=skip, limit=limit)
+def list_projects(
+ skip: int = 0,
+ limit: int = 100,
+ archived: Optional[bool] = None,
+ db: Session = Depends(get_db)
+):
+ """List all projects with optional archive filter"""
+ return crud.get_projects(db, skip=skip, limit=limit, archived=archived)
@app.post("/api/projects", response_model=schemas.Project, status_code=201)
@@ -97,13 +103,16 @@ def get_project_task_tree(project_id: int, db: Session = Depends(get_db)):
@app.get("/api/projects/{project_id}/tasks/by-status/{status}", response_model=List[schemas.Task])
def get_tasks_by_status(
project_id: int,
- status: models.TaskStatus,
+ status: str,
db: Session = Depends(get_db)
):
"""Get all tasks for a project filtered by status (for Kanban view)"""
if not crud.get_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found")
- return crud.get_tasks_by_status(db, project_id, status)
+ try:
+ return crud.get_tasks_by_status(db, project_id, status)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/tasks", response_model=schemas.Task, status_code=201)
@@ -115,7 +124,10 @@ def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)):
if task.parent_task_id and not crud.get_task(db, task.parent_task_id):
raise HTTPException(status_code=404, detail="Parent task not found")
- return crud.create_task(db, task)
+ try:
+ return crud.create_task(db, task)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
@app.get("/api/tasks/{task_id}", response_model=schemas.Task)
@@ -130,10 +142,13 @@ def get_task(task_id: int, db: Session = Depends(get_db)):
@app.put("/api/tasks/{task_id}", response_model=schemas.Task)
def update_task(task_id: int, task: schemas.TaskUpdate, db: Session = Depends(get_db)):
"""Update a task"""
- db_task = crud.update_task(db, task_id, task)
- if not db_task:
- raise HTTPException(status_code=404, detail="Task not found")
- return db_task
+ try:
+ db_task = crud.update_task(db, task_id, task)
+ if not db_task:
+ raise HTTPException(status_code=404, detail="Task not found")
+ return db_task
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
@app.delete("/api/tasks/{task_id}", status_code=204)
@@ -187,6 +202,27 @@ def search_tasks(
# ========== JSON IMPORT ENDPOINT ==========
+def _validate_task_statuses_recursive(
+ tasks: List[schemas.ImportSubtask],
+ valid_statuses: List[str],
+ path: str = ""
+) -> None:
+ """Recursively validate all task statuses against the project's valid statuses"""
+ for idx, task_data in enumerate(tasks):
+ task_path = f"{path}.tasks[{idx}]" if path else f"tasks[{idx}]"
+ if task_data.status not in valid_statuses:
+ raise ValueError(
+ f"Invalid status '{task_data.status}' at {task_path}. "
+ f"Must be one of: {', '.join(valid_statuses)}"
+ )
+ if task_data.subtasks:
+ _validate_task_statuses_recursive(
+ task_data.subtasks,
+ valid_statuses,
+ f"{task_path}.subtasks"
+ )
+
+
def _import_tasks_recursive(
db: Session,
project_id: int,
@@ -227,7 +263,8 @@ def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_
{
"project": {
"name": "Project Name",
- "description": "Optional description"
+ "description": "Optional description",
+ "statuses": ["backlog", "in_progress", "on_hold", "done"] // Optional
},
"tasks": [
{
@@ -245,15 +282,26 @@ def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_
]
}
"""
- # Create the project
+ # Create the project with optional statuses
project = crud.create_project(
db,
schemas.ProjectCreate(
name=import_data.project.name,
- description=import_data.project.description
+ 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
@@ -271,6 +319,6 @@ def root():
"""API health check"""
return {
"status": "online",
- "message": "Tesseract API - Nested Todo Tree Manager",
+ "message": "Break It Down (BIT) API - Nested Todo Tree Manager",
"docs": "/docs"
}
diff --git a/backend/app/models.py b/backend/app/models.py
index 47455c5..a472d3d 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -1,15 +1,11 @@
-from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, Enum, JSON
+from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Boolean
from sqlalchemy.orm import relationship
from datetime import datetime
-import enum
from .database import Base
-class TaskStatus(str, enum.Enum):
- BACKLOG = "backlog"
- IN_PROGRESS = "in_progress"
- BLOCKED = "blocked"
- DONE = "done"
+# Default statuses for new projects
+DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
class Project(Base):
@@ -18,6 +14,8 @@ class Project(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
+ statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES)
+ is_archived = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -32,7 +30,7 @@ class Task(Base):
parent_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True)
title = Column(String(500), nullable=False)
description = Column(Text, nullable=True)
- status = Column(Enum(TaskStatus), default=TaskStatus.BACKLOG, nullable=False)
+ 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)
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 00aa35d..417f0a0 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -1,14 +1,14 @@
from pydantic import BaseModel, ConfigDict
from typing import Optional, List
from datetime import datetime
-from .models import TaskStatus
+from .models import DEFAULT_STATUSES
# Task Schemas
class TaskBase(BaseModel):
title: str
description: Optional[str] = None
- status: TaskStatus = TaskStatus.BACKLOG
+ status: str = "backlog"
parent_task_id: Optional[int] = None
sort_order: int = 0
estimated_minutes: Optional[int] = None
@@ -23,7 +23,7 @@ class TaskCreate(TaskBase):
class TaskUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
- status: Optional[TaskStatus] = None
+ status: Optional[str] = None
parent_task_id: Optional[int] = None
sort_order: Optional[int] = None
estimated_minutes: Optional[int] = None
@@ -53,16 +53,20 @@ class ProjectBase(BaseModel):
class ProjectCreate(ProjectBase):
- pass
+ statuses: Optional[List[str]] = None
class ProjectUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
+ statuses: Optional[List[str]] = None
+ is_archived: Optional[bool] = None
class Project(ProjectBase):
id: int
+ statuses: List[str]
+ is_archived: bool
created_at: datetime
updated_at: datetime
@@ -79,7 +83,7 @@ class ProjectWithTasks(Project):
class ImportSubtask(BaseModel):
title: str
description: Optional[str] = None
- status: TaskStatus = TaskStatus.BACKLOG
+ status: str = "backlog"
estimated_minutes: Optional[int] = None
tags: Optional[List[str]] = None
flag_color: Optional[str] = None
@@ -89,6 +93,7 @@ class ImportSubtask(BaseModel):
class ImportProject(BaseModel):
name: str
description: Optional[str] = None
+ statuses: Optional[List[str]] = None
class ImportData(BaseModel):
diff --git a/backend/app/settings.py b/backend/app/settings.py
new file mode 100644
index 0000000..ec9229e
--- /dev/null
+++ b/backend/app/settings.py
@@ -0,0 +1,37 @@
+from pydantic_settings import BaseSettings, SettingsConfigDict
+from typing import List
+
+
+class Settings(BaseSettings):
+ """Application settings loaded from environment variables"""
+
+ # Database Configuration
+ database_url: str = "sqlite:///./bit.db"
+
+ # API Configuration
+ api_title: str = "Break It Down (BIT) - Nested Todo Tree API"
+ api_description: str = "API for managing deeply nested todo trees"
+ api_version: str = "1.0.0"
+
+ # CORS Configuration
+ cors_origins: str = "http://localhost:5173,http://localhost:3000"
+
+ # Server Configuration
+ host: str = "0.0.0.0"
+ port: int = 8000
+
+ model_config = SettingsConfigDict(
+ env_file=".env",
+ env_file_encoding="utf-8",
+ case_sensitive=False,
+ extra="ignore"
+ )
+
+ @property
+ def cors_origins_list(self) -> List[str]:
+ """Parse comma-separated CORS origins into a list"""
+ return [origin.strip() for origin in self.cors_origins.split(",")]
+
+
+# Global settings instance
+settings = Settings()
diff --git a/backend/migrate_add_is_archived.py b/backend/migrate_add_is_archived.py
new file mode 100644
index 0000000..c0bb9de
--- /dev/null
+++ b/backend/migrate_add_is_archived.py
@@ -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()
diff --git a/backend/migrate_add_statuses.py b/backend/migrate_add_statuses.py
new file mode 100644
index 0000000..802339b
--- /dev/null
+++ b/backend/migrate_add_statuses.py
@@ -0,0 +1,62 @@
+"""
+Migration script to add statuses column to projects table
+Run this script once to update existing database
+"""
+import sqlite3
+import json
+
+# Default statuses for existing projects
+DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
+
+def migrate():
+ # Connect to the database
+ conn = sqlite3.connect('bit.db')
+ cursor = conn.cursor()
+
+ try:
+ # Check if statuses column already exists
+ cursor.execute("PRAGMA table_info(projects)")
+ columns = [column[1] for column in cursor.fetchall()]
+
+ if 'statuses' in columns:
+ print("â Column 'statuses' already exists in projects table")
+ return
+
+ # Add statuses column with default value
+ print("Adding 'statuses' column to projects table...")
+ cursor.execute("""
+ ALTER TABLE projects
+ ADD COLUMN statuses TEXT NOT NULL DEFAULT ?
+ """, (json.dumps(DEFAULT_STATUSES),))
+
+ # Update all existing projects with default statuses
+ cursor.execute("""
+ UPDATE projects
+ SET statuses = ?
+ WHERE statuses IS NULL OR statuses = ''
+ """, (json.dumps(DEFAULT_STATUSES),))
+
+ conn.commit()
+ print("â Successfully added 'statuses' column to projects table")
+ print(f"â Set default statuses for all existing projects: {DEFAULT_STATUSES}")
+
+ # Show count of updated projects
+ cursor.execute("SELECT COUNT(*) FROM projects")
+ count = cursor.fetchone()[0]
+ print(f"â Updated {count} project(s)")
+
+ except sqlite3.Error as e:
+ print(f"â Error during migration: {e}")
+ conn.rollback()
+ raise
+ finally:
+ conn.close()
+
+if __name__ == "__main__":
+ print("=" * 60)
+ print("Database Migration: Add statuses column to projects")
+ print("=" * 60)
+ migrate()
+ print("=" * 60)
+ print("Migration completed!")
+ print("=" * 60)
diff --git a/docker-compose.yml b/docker-compose.yml
index 6c081c6..2edb874 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,16 +1,16 @@
-version: '3.8'
-
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
- container_name: tesseract-backend
+ container_name: bit-backend
ports:
- - "8000:8000"
+ - "8002:8002"
volumes:
- ./backend/app:/app/app
- - tesseract-db:/app
+ - bit-db:/app
+ env_file:
+ - ./backend/.env
environment:
- PYTHONUNBUFFERED=1
restart: unless-stopped
@@ -19,12 +19,14 @@ services:
build:
context: ./frontend
dockerfile: Dockerfile
- container_name: tesseract-frontend
+ container_name: bit-frontend
ports:
- - "3000:80"
+ - "3002:80"
+ env_file:
+ - ./frontend/.env
depends_on:
- backend
restart: unless-stopped
volumes:
- tesseract-db:
+ bit-db:
diff --git a/frontend/.env.example b/frontend/.env.example
new file mode 100644
index 0000000..e73b3ba
--- /dev/null
+++ b/frontend/.env.example
@@ -0,0 +1,18 @@
+# API Configuration
+# Base URL for API requests (relative path used in production)
+VITE_API_BASE_URL=/api
+
+# Backend API URL (used for development proxy)
+VITE_API_URL=http://localhost:8000
+
+# Development Configuration
+# Port for Vite development server
+VITE_DEV_PORT=5173
+
+# Application Configuration
+# Application version displayed in UI
+VITE_APP_VERSION=0.1.5
+
+# UI/UX Configuration
+# Search input debounce delay in milliseconds
+VITE_SEARCH_DEBOUNCE_MS=300
diff --git a/frontend/index.html b/frontend/index.html
index 2418f35..0041ecf 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,7 +4,7 @@
-
Tesseract - Task Decomposition Engine
+ Break It Down - Task Decomposition Engine
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 583c5ee..5444760 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,11 +1,11 @@
{
- "name": "tesseract-frontend",
+ "name": "bit-frontend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "tesseract-frontend",
+ "name": "bit-frontend",
"version": "1.0.0",
"dependencies": {
"lucide-react": "^0.303.0",
diff --git a/frontend/package.json b/frontend/package.json
index 741b44d..8ffa86e 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,5 +1,5 @@
{
- "name": "tesseract-frontend",
+ "name": "bit-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 79d44d8..ad6b3f0 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -1,6 +1,7 @@
import { Routes, Route } from 'react-router-dom'
import ProjectList from './pages/ProjectList'
import ProjectView from './pages/ProjectView'
+import SearchBar from './components/SearchBar'
function App() {
return (
@@ -10,11 +11,12 @@ function App() {
- TESSERACT
- Task Decomposition Engine
- v0.1.3
+ Break It Down
+ BIT - Task Decomposition Engine
+ v{import.meta.env.VITE_APP_VERSION || '0.1.6'}
+
diff --git a/frontend/src/components/KanbanView.jsx b/frontend/src/components/KanbanView.jsx
index 9bc9f5f..e1504cd 100644
--- a/frontend/src/components/KanbanView.jsx
+++ b/frontend/src/components/KanbanView.jsx
@@ -1,27 +1,87 @@
import { useState, useEffect } from 'react'
-import { Plus, Edit2, Trash2, Check, X } from 'lucide-react'
+import { Plus, Check, X, Flag, Clock, ChevronDown, ChevronRight, ChevronsDown, ChevronsUp } from 'lucide-react'
import {
getProjectTasks,
createTask,
updateTask,
deleteTask
} from '../utils/api'
+import { formatTimeWithTotal } from '../utils/format'
+import TaskMenu from './TaskMenu'
+import TaskForm from './TaskForm'
-const STATUSES = [
- { key: 'backlog', label: 'Backlog', color: 'border-gray-600' },
- { key: 'in_progress', label: 'In Progress', color: 'border-blue-500' },
- { key: 'blocked', label: 'Blocked', color: 'border-red-500' },
- { key: 'done', label: 'Done', color: 'border-green-500' }
-]
+// Helper to format status label
+const formatStatusLabel = (status) => {
+ return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
+}
-function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
+// Helper to get status color based on common patterns
+const getStatusColor = (status) => {
+ const lowerStatus = status.toLowerCase()
+ if (lowerStatus === 'backlog') return 'border-gray-600'
+ if (lowerStatus === 'in_progress' || lowerStatus.includes('progress')) return 'border-blue-500'
+ if (lowerStatus === 'on_hold' || lowerStatus.includes('hold') || lowerStatus.includes('waiting')) return 'border-yellow-500'
+ if (lowerStatus === 'done' || lowerStatus.includes('complete')) return 'border-green-500'
+ if (lowerStatus.includes('blocked')) return 'border-red-500'
+ return 'border-purple-500' // default for custom statuses
+}
+
+const FLAG_COLORS = {
+ 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'
+}
+
+// Helper function to get all descendant tasks recursively
+function getAllDescendants(taskId, allTasks) {
+ const children = allTasks.filter(t => t.parent_task_id === taskId)
+ let descendants = [...children]
+
+ for (const child of children) {
+ descendants = descendants.concat(getAllDescendants(child.id, allTasks))
+ }
+
+ return descendants
+}
+
+// Helper function to get all descendant tasks of a parent in a specific status
+function getDescendantsInStatus(taskId, allTasks, status) {
+ const children = allTasks.filter(t => t.parent_task_id === taskId)
+ let descendants = []
+
+ for (const child of children) {
+ if (child.status === status) {
+ descendants.push(child)
+ }
+ // Recursively get descendants
+ descendants = descendants.concat(getDescendantsInStatus(child.id, allTasks, status))
+ }
+
+ return descendants
+}
+
+// Helper function to check if a task has any descendants in a status
+function hasDescendantsInStatus(taskId, allTasks, status) {
+ return getDescendantsInStatus(taskId, allTasks, status).length > 0
+}
+
+function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatus, expandedCards, setExpandedCards, projectStatuses, projectId }) {
const [isEditing, setIsEditing] = useState(false)
const [editTitle, setEditTitle] = useState(task.title)
+ const [showAddSubtask, setShowAddSubtask] = useState(false)
- // Find parent task if this is a subtask
- const parentTask = task.parent_task_id
- ? allTasks.find(t => t.id === task.parent_task_id)
- : null
+ // Use global expanded state
+ const isExpanded = expandedCards[task.id] || false
+ const toggleExpanded = () => {
+ setExpandedCards(prev => ({
+ ...prev,
+ [task.id]: !prev[task.id]
+ }))
+ }
const handleSave = async () => {
try {
@@ -43,85 +103,209 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
}
}
+ const handleAddSubtask = async (taskData) => {
+ try {
+ await createTask({
+ project_id: parseInt(projectId),
+ parent_task_id: task.id,
+ title: taskData.title,
+ description: taskData.description,
+ status: taskData.status,
+ tags: taskData.tags,
+ estimated_minutes: taskData.estimated_minutes,
+ flag_color: taskData.flag_color
+ })
+ setShowAddSubtask(false)
+ setExpandedCards(prev => ({ ...prev, [task.id]: true }))
+ onUpdate()
+ } catch (err) {
+ alert(`Error: ${err.message}`)
+ }
+ }
+
+ // For parent cards, get children in this column's status
+ const childrenInColumn = isParent ? getDescendantsInStatus(task.id, allTasks, columnStatus) : []
+ const totalChildren = isParent ? allTasks.filter(t => t.parent_task_id === task.id).length : 0
+
return (
- onDragStart(e, task)}
- className="bg-cyber-darkest border border-cyber-orange/30 rounded-lg p-3 mb-2 cursor-move hover:border-cyber-orange/60 transition-all group"
- >
- {isEditing ? (
-
- setEditTitle(e.target.value)}
- 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
- />
-
-
-
- {
- setIsEditing(false)
- setEditTitle(task.title)
- }}
- className="text-gray-400 hover:text-gray-300"
- >
-
-
-
- ) : (
- <>
-
-
-
{task.title}
- {parentTask && (
-
- âŗ subtask of: {parentTask.title}
-
- )}
-
-
- setIsEditing(true)}
- className="text-gray-400 hover:text-gray-200"
- >
-
-
-
-
-
-
+
+
onDragStart(e, task, isParent)}
+ className={`${
+ isParent
+ ? 'bg-cyber-darker border-2 border-cyber-orange/50'
+ : 'bg-cyber-darkest border border-cyber-orange/30'
+ } rounded-lg p-3 cursor-move hover:border-cyber-orange/60 transition-all group`}
+ >
+ {isEditing ? (
+
+ setEditTitle(e.target.value)}
+ 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
+ />
+
+
+
+ {
+ setIsEditing(false)
+ setEditTitle(task.title)
+ }}
+ className="text-gray-400 hover:text-gray-300"
+ >
+
+
- >
+ ) : (
+ <>
+
+
+
+ {/* Expand/collapse for parent cards */}
+ {isParent && childrenInColumn.length > 0 && (
+
+ {isExpanded ? : }
+
+ )}
+
+
+
+ {/* Flag indicator */}
+ {task.flag_color && FLAG_COLORS[task.flag_color] && (
+
+ )}
+
+ {task.title}
+
+
+
+ {/* Parent card info: show subtask count in this column */}
+ {isParent && (
+
+ {childrenInColumn.length} of {totalChildren} subtask{totalChildren !== 1 ? 's' : ''} in this column
+
+ )}
+
+ {/* Metadata row */}
+ {(formatTimeWithTotal(task, allTasks) || (task.tags && task.tags.length > 0)) && (
+
+ {/* Time estimate */}
+ {formatTimeWithTotal(task, allTasks) && (
+
+
+ {formatTimeWithTotal(task, allTasks)}
+
+ )}
+
+ {/* Tags */}
+ {task.tags && task.tags.length > 0 && (
+
+ {task.tags.map((tag, idx) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* Description */}
+ {task.description && (
+
+ {task.description}
+
+ )}
+
+
+
+
+
+
setShowAddSubtask(true)}
+ className="text-cyber-orange hover:text-cyber-orange-bright p-1"
+ title="Add subtask"
+ >
+
+
+
setIsEditing(true)}
+ projectStatuses={projectStatuses}
+ />
+
+
+ >
+ )}
+
+
+ {/* Add Subtask Form */}
+ {showAddSubtask && (
+
+ setShowAddSubtask(false)}
+ submitLabel="Add Subtask"
+ projectStatuses={projectStatuses}
+ defaultStatus={columnStatus}
+ />
+
+ )}
+
+ {/* Expanded children */}
+ {isParent && isExpanded && childrenInColumn.length > 0 && (
+
+ {childrenInColumn.map(child => (
+
+ ))}
+
)}
)
}
-function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, onDragOver }) {
+function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOver, expandedCards, setExpandedCards, projectStatuses }) {
const [showAddTask, setShowAddTask] = useState(false)
- const [newTaskTitle, setNewTaskTitle] = useState('')
-
- const handleAddTask = async (e) => {
- e.preventDefault()
- if (!newTaskTitle.trim()) return
+ const handleAddTask = async (taskData) => {
try {
await createTask({
project_id: parseInt(projectId),
parent_task_id: null,
- title: newTaskTitle,
- status: status.key
+ title: taskData.title,
+ description: taskData.description,
+ status: taskData.status,
+ tags: taskData.tags,
+ estimated_minutes: taskData.estimated_minutes,
+ flag_color: taskData.flag_color
})
- setNewTaskTitle('')
setShowAddTask(false)
onUpdate()
} catch (err) {
@@ -129,16 +313,37 @@ function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, on
}
}
+ // Get tasks to display in this column:
+ // 1. All leaf tasks (no children) with this status
+ // 2. All parent tasks that have at least one descendant with this status
+ const leafTasks = allTasks.filter(t => {
+ const hasChildren = allTasks.some(child => child.parent_task_id === t.id)
+ return !hasChildren && t.status === status.key
+ })
+
+ const parentTasks = allTasks.filter(t => {
+ const hasChildren = allTasks.some(child => child.parent_task_id === t.id)
+ return hasChildren && hasDescendantsInStatus(t.id, allTasks, status.key)
+ })
+
+ // Only show root-level parents (not nested parents)
+ const rootParents = parentTasks.filter(t => !t.parent_task_id)
+
+ // Only show root-level leaf tasks (leaf tasks without parents)
+ const rootLeafTasks = leafTasks.filter(t => !t.parent_task_id)
+
+ const displayTasks = [...rootParents, ...rootLeafTasks]
+
return (
onDrop(e, status.key)}
onDragOver={onDragOver}
>
{status.label}
- ({tasks.length})
+ ({displayTasks.length})
setShowAddTask(true)}
@@ -150,55 +355,56 @@ function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, on
{showAddTask && (
-
+
setShowAddTask(false)}
+ submitLabel="Add Task"
+ projectStatuses={projectStatuses}
+ defaultStatus={status.key}
+ />
)}
- {tasks.map(task => (
- {
- e.dataTransfer.setData('taskId', task.id.toString())
- }}
- />
- ))}
+ {displayTasks.map(task => {
+ const isParent = allTasks.some(t => t.parent_task_id === task.id)
+ return (
+ {
+ e.dataTransfer.setData('taskId', task.id.toString())
+ e.dataTransfer.setData('isParent', isParent.toString())
+ }}
+ isParent={isParent}
+ columnStatus={status.key}
+ expandedCards={expandedCards}
+ setExpandedCards={setExpandedCards}
+ projectStatuses={projectStatuses}
+ projectId={projectId}
+ />
+ )
+ })}
)
}
-function KanbanView({ projectId }) {
+function KanbanView({ projectId, project }) {
const [allTasks, setAllTasks] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
+ const [expandedCards, setExpandedCards] = useState({})
+
+ // Get statuses from project, or use defaults
+ const statuses = project?.statuses || ['backlog', 'in_progress', 'on_hold', 'done']
+ const statusesWithMeta = statuses.map(status => ({
+ key: status,
+ label: formatStatusLabel(status),
+ color: getStatusColor(status)
+ }))
useEffect(() => {
loadTasks()
@@ -216,6 +422,19 @@ function KanbanView({ projectId }) {
}
}
+ const handleExpandAll = () => {
+ const parentTasks = allTasks.filter(t => allTasks.some(child => child.parent_task_id === t.id))
+ const newExpandedState = {}
+ parentTasks.forEach(task => {
+ newExpandedState[task.id] = true
+ })
+ setExpandedCards(newExpandedState)
+ }
+
+ const handleCollapseAll = () => {
+ setExpandedCards({})
+ }
+
const handleDragOver = (e) => {
e.preventDefault()
}
@@ -223,11 +442,22 @@ function KanbanView({ projectId }) {
const handleDrop = async (e, newStatus) => {
e.preventDefault()
const taskId = parseInt(e.dataTransfer.getData('taskId'))
+ const isParent = e.dataTransfer.getData('isParent') === 'true'
if (!taskId) return
try {
+ // Update the dragged task
await updateTask(taskId, { status: newStatus })
+
+ // If it's a parent task, update all descendants
+ if (isParent) {
+ const descendants = getAllDescendants(taskId, allTasks)
+ for (const descendant of descendants) {
+ await updateTask(descendant.id, { status: newStatus })
+ }
+ }
+
loadTasks()
} catch (err) {
alert(`Error: ${err.message}`)
@@ -244,19 +474,39 @@ function KanbanView({ projectId }) {
return (
-
Kanban Board
+
+
Kanban Board (Nested View)
+
+
+
+ Expand All
+
+
+
+ Collapse All
+
+
+
- {STATUSES.map(status => (
+ {statusesWithMeta.map(status => (
t.status === status.key)}
allTasks={allTasks}
projectId={projectId}
onUpdate={loadTasks}
onDrop={handleDrop}
onDragOver={handleDragOver}
+ expandedCards={expandedCards}
+ setExpandedCards={setExpandedCards}
+ projectStatuses={statuses}
/>
))}
diff --git a/frontend/src/components/ProjectSettings.jsx b/frontend/src/components/ProjectSettings.jsx
new file mode 100644
index 0000000..ac03370
--- /dev/null
+++ b/frontend/src/components/ProjectSettings.jsx
@@ -0,0 +1,298 @@
+import { useState, useEffect } from 'react'
+import { X, GripVertical, Plus, Trash2, Check, AlertTriangle } from 'lucide-react'
+import { updateProject, getProjectTasks } from '../utils/api'
+
+function ProjectSettings({ project, onClose, onUpdate }) {
+ const [statuses, setStatuses] = useState(project.statuses || [])
+ const [editingIndex, setEditingIndex] = useState(null)
+ const [editingValue, setEditingValue] = useState('')
+ const [draggedIndex, setDraggedIndex] = useState(null)
+ const [error, setError] = useState('')
+ const [taskCounts, setTaskCounts] = useState({})
+ const [deleteWarning, setDeleteWarning] = useState(null)
+
+ useEffect(() => {
+ loadTaskCounts()
+ }, [])
+
+ const loadTaskCounts = async () => {
+ try {
+ const tasks = await getProjectTasks(project.id)
+ const counts = {}
+ statuses.forEach(status => {
+ counts[status] = tasks.filter(t => t.status === status).length
+ })
+ setTaskCounts(counts)
+ } catch (err) {
+ console.error('Failed to load task counts:', err)
+ }
+ }
+
+ const handleDragStart = (index) => {
+ setDraggedIndex(index)
+ }
+
+ const handleDragOver = (e, index) => {
+ e.preventDefault()
+ if (draggedIndex === null || draggedIndex === index) return
+
+ const newStatuses = [...statuses]
+ const draggedItem = newStatuses[draggedIndex]
+ newStatuses.splice(draggedIndex, 1)
+ newStatuses.splice(index, 0, draggedItem)
+
+ setStatuses(newStatuses)
+ setDraggedIndex(index)
+ }
+
+ const handleDragEnd = () => {
+ setDraggedIndex(null)
+ }
+
+ const handleAddStatus = () => {
+ const newStatus = `new_status_${Date.now()}`
+ setStatuses([...statuses, newStatus])
+ setEditingIndex(statuses.length)
+ setEditingValue(newStatus)
+ }
+
+ const handleStartEdit = (index) => {
+ setEditingIndex(index)
+ setEditingValue(statuses[index])
+ }
+
+ const handleSaveEdit = () => {
+ if (!editingValue.trim()) {
+ setError('Status name cannot be empty')
+ return
+ }
+
+ const trimmedValue = editingValue.trim().toLowerCase().replace(/\s+/g, '_')
+
+ if (statuses.some((s, i) => i !== editingIndex && s === trimmedValue)) {
+ setError('Status name already exists')
+ return
+ }
+
+ const newStatuses = [...statuses]
+ newStatuses[editingIndex] = trimmedValue
+ setStatuses(newStatuses)
+ setEditingIndex(null)
+ setError('')
+ }
+
+ const handleCancelEdit = () => {
+ // If it's a new status that was never saved, remove it
+ if (statuses[editingIndex].startsWith('new_status_')) {
+ const newStatuses = statuses.filter((_, i) => i !== editingIndex)
+ setStatuses(newStatuses)
+ }
+ setEditingIndex(null)
+ setError('')
+ }
+
+ const handleDeleteStatus = (index) => {
+ const statusToDelete = statuses[index]
+ const taskCount = taskCounts[statusToDelete] || 0
+
+ if (taskCount > 0) {
+ setDeleteWarning({ index, status: statusToDelete, count: taskCount })
+ return
+ }
+
+ if (statuses.length === 1) {
+ setError('Cannot delete the last status')
+ return
+ }
+
+ const newStatuses = statuses.filter((_, i) => i !== index)
+ setStatuses(newStatuses)
+ }
+
+ const handleSave = async () => {
+ if (statuses.length === 0) {
+ setError('Project must have at least one status')
+ return
+ }
+
+ if (editingIndex !== null) {
+ setError('Please save or cancel the status you are editing')
+ return
+ }
+
+ try {
+ await updateProject(project.id, { statuses })
+ onUpdate()
+ onClose()
+ } catch (err) {
+ setError(err.message)
+ }
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
Project Settings
+
+
+
+
+
+ {/* Content */}
+
+
+
Project Details
+
+
+ Name:
+ {project.name}
+
+ {project.description && (
+
+ Description:
+ {project.description}
+
+ )}
+
+
+
+
+
Status Workflow
+
+ Drag to reorder, click to rename. Tasks will appear in Kanban columns in this order.
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {statuses.map((status, index) => (
+
handleDragStart(index)}
+ onDragOver={(e) => handleDragOver(e, index)}
+ onDragEnd={handleDragEnd}
+ className={`flex items-center gap-3 p-3 bg-cyber-darker border border-cyber-orange/30 rounded ${
+ draggedIndex === index ? 'opacity-50' : ''
+ } ${editingIndex !== index ? 'cursor-move' : ''}`}
+ >
+ {editingIndex !== index && (
+
+ )}
+
+ {editingIndex === index ? (
+ <>
+ setEditingValue(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') handleSaveEdit()
+ if (e.key === 'Escape') handleCancelEdit()
+ }}
+ className="flex-1 px-2 py-1 bg-cyber-darkest border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
+ autoFocus
+ />
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+ handleStartEdit(index)}
+ className="flex-1 text-left text-gray-200 hover:text-cyber-orange"
+ >
+ {status.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}
+
+
+ {taskCounts[status] || 0} task{taskCounts[status] === 1 ? '' : 's'}
+
+ handleDeleteStatus(index)}
+ className="text-gray-400 hover:text-red-400"
+ disabled={statuses.length === 1}
+ >
+
+
+ >
+ )}
+
+ ))}
+
+
+
+
+ Add Status
+
+
+
+
+ {/* Footer */}
+
+
+ Cancel
+
+
+ Save Changes
+
+
+
+ {/* Delete Warning Dialog */}
+ {deleteWarning && (
+
+
+
+
+
+
Cannot Delete Status
+
+ The status "{deleteWarning.status}" has {deleteWarning.count} task{deleteWarning.count === 1 ? '' : 's'}.
+ Please move or delete those tasks first.
+
+
+
+
+ setDeleteWarning(null)}
+ className="px-4 py-2 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright font-semibold"
+ >
+ OK
+
+
+
+
+ )}
+
+
+ )
+}
+
+export default ProjectSettings
diff --git a/frontend/src/components/SearchBar.jsx b/frontend/src/components/SearchBar.jsx
new file mode 100644
index 0000000..74714ce
--- /dev/null
+++ b/frontend/src/components/SearchBar.jsx
@@ -0,0 +1,263 @@
+import { useState, useEffect, useRef } from 'react'
+import { Search, X, Flag } from 'lucide-react'
+import { searchTasks, getProjects } from '../utils/api'
+import { formatTime } from '../utils/format'
+import { useNavigate } from 'react-router-dom'
+
+const FLAG_COLORS = {
+ red: 'text-red-500',
+ orange: 'text-orange-500',
+ yellow: 'text-yellow-500',
+ green: 'text-green-500',
+ blue: 'text-blue-500',
+ purple: 'text-purple-500',
+ pink: 'text-pink-500'
+}
+
+function SearchBar() {
+ const [query, setQuery] = useState('')
+ const [results, setResults] = useState([])
+ const [projects, setProjects] = useState([])
+ const [selectedProjects, setSelectedProjects] = useState([])
+ const [isSearching, setIsSearching] = useState(false)
+ const [showResults, setShowResults] = useState(false)
+ const [showProjectFilter, setShowProjectFilter] = useState(false)
+ const searchRef = useRef(null)
+ const navigate = useNavigate()
+
+ useEffect(() => {
+ loadProjects()
+ }, [])
+
+ useEffect(() => {
+ function handleClickOutside(event) {
+ if (searchRef.current && !searchRef.current.contains(event.target)) {
+ setShowResults(false)
+ setShowProjectFilter(false)
+ }
+ }
+
+ document.addEventListener('mousedown', handleClickOutside)
+ return () => document.removeEventListener('mousedown', handleClickOutside)
+ }, [])
+
+ const loadProjects = async () => {
+ try {
+ const data = await getProjects()
+ setProjects(data)
+ } catch (err) {
+ console.error('Failed to load projects:', err)
+ }
+ }
+
+ const handleSearch = async (searchQuery) => {
+ if (!searchQuery.trim()) {
+ setResults([])
+ setShowResults(false)
+ return
+ }
+
+ setIsSearching(true)
+ try {
+ const projectIds = selectedProjects.length > 0 ? selectedProjects : null
+ const data = await searchTasks(searchQuery, projectIds)
+ setResults(data)
+ setShowResults(true)
+ } catch (err) {
+ console.error('Search failed:', err)
+ setResults([])
+ } finally {
+ setIsSearching(false)
+ }
+ }
+
+ const handleQueryChange = (e) => {
+ const newQuery = e.target.value
+ setQuery(newQuery)
+ }
+
+ // Debounced search effect
+ useEffect(() => {
+ if (!query.trim()) {
+ setResults([])
+ setShowResults(false)
+ return
+ }
+
+ const debounceMs = parseInt(import.meta.env.VITE_SEARCH_DEBOUNCE_MS || '300')
+ const timeoutId = setTimeout(() => {
+ handleSearch(query)
+ }, debounceMs)
+
+ return () => clearTimeout(timeoutId)
+ }, [query, selectedProjects])
+
+ const toggleProjectFilter = (projectId) => {
+ setSelectedProjects(prev => {
+ if (prev.includes(projectId)) {
+ return prev.filter(id => id !== projectId)
+ } else {
+ return [...prev, projectId]
+ }
+ })
+ }
+
+ const handleTaskClick = (task) => {
+ navigate(`/project/${task.project_id}`)
+ setShowResults(false)
+ setQuery('')
+ }
+
+ const clearSearch = () => {
+ setQuery('')
+ setResults([])
+ setShowResults(false)
+ }
+
+ return (
+
+
+ {/* Search Input */}
+
+
+ query && setShowResults(true)}
+ placeholder="Search tasks..."
+ className="w-64 pl-9 pr-8 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange placeholder-gray-500"
+ />
+ {query && (
+
+
+
+ )}
+
+
+ {/* Project Filter Button */}
+ {projects.length > 1 && (
+
setShowProjectFilter(!showProjectFilter)}
+ className={`px-3 py-2 text-sm rounded border ${
+ selectedProjects.length > 0
+ ? 'bg-cyber-orange/20 border-cyber-orange text-cyber-orange'
+ : 'bg-cyber-darker border-cyber-orange/30 text-gray-400'
+ } hover:border-cyber-orange transition-colors`}
+ >
+ {selectedProjects.length > 0 ? `${selectedProjects.length} Project(s)` : 'All Projects'}
+
+ )}
+
+
+ {/* Project Filter Dropdown */}
+ {showProjectFilter && (
+
+
+
Filter by projects:
+ {projects.map(project => (
+
+ toggleProjectFilter(project.id)}
+ className="rounded border-cyber-orange/50 bg-cyber-darker text-cyber-orange focus:ring-cyber-orange focus:ring-offset-0"
+ />
+ {project.name}
+
+ ))}
+ {selectedProjects.length > 0 && (
+
{
+ setSelectedProjects([])
+ if (query) handleSearch(query)
+ }}
+ className="w-full mt-2 px-2 py-1 text-xs text-gray-400 hover:text-gray-200 border border-gray-600 rounded"
+ >
+ Clear Filter
+
+ )}
+
+
+ )}
+
+ {/* Search Results */}
+ {showResults && (
+
+ {isSearching ? (
+
Searching...
+ ) : results.length === 0 ? (
+
No results found
+ ) : (
+
+
+ {results.length} result{results.length !== 1 ? 's' : ''}
+
+ {results.map(task => {
+ const project = projects.find(p => p.id === task.project_id)
+ return (
+
handleTaskClick(task)}
+ className="w-full text-left px-2 py-2 hover:bg-cyber-darker rounded transition-colors"
+ >
+
+ {/* Flag */}
+ {task.flag_color && FLAG_COLORS[task.flag_color] && (
+
+ )}
+
+
+ {/* Title */}
+
{task.title}
+
+ {/* Project name */}
+ {project && (
+
+ in: {project.name}
+
+ )}
+
+ {/* Metadata */}
+ {(task.estimated_minutes || (task.tags && task.tags.length > 0)) && (
+
+ {task.estimated_minutes && (
+
{formatTime(task.estimated_minutes)}
+ )}
+ {task.tags && task.tags.length > 0 && (
+
+ {task.tags.slice(0, 3).map((tag, idx) => (
+
+ {tag}
+
+ ))}
+ {task.tags.length > 3 && (
+ +{task.tags.length - 3}
+ )}
+
+ )}
+
+ )}
+
+
+
+ )
+ })}
+
+ )}
+
+ )}
+
+ )
+}
+
+export default SearchBar
diff --git a/frontend/src/components/TaskForm.jsx b/frontend/src/components/TaskForm.jsx
new file mode 100644
index 0000000..1efb441
--- /dev/null
+++ b/frontend/src/components/TaskForm.jsx
@@ -0,0 +1,182 @@
+import { useState } from 'react'
+import { Flag } from 'lucide-react'
+
+const FLAG_COLORS = [
+ { name: null, label: 'None', color: 'bg-gray-700' },
+ { name: 'red', label: 'Red', color: 'bg-red-500' },
+ { name: 'orange', label: 'Orange', color: 'bg-orange-500' },
+ { name: 'yellow', label: 'Yellow', color: 'bg-yellow-500' },
+ { name: 'green', label: 'Green', color: 'bg-green-500' },
+ { name: 'blue', label: 'Blue', color: 'bg-blue-500' },
+ { name: 'purple', label: 'Purple', color: 'bg-purple-500' },
+ { name: 'pink', label: 'Pink', color: 'bg-pink-500' }
+]
+
+// Helper to format status label
+const formatStatusLabel = (status) => {
+ return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
+}
+
+function TaskForm({ onSubmit, onCancel, submitLabel = "Add", projectStatuses = null, defaultStatus = "backlog" }) {
+ const [title, setTitle] = useState('')
+ const [description, setDescription] = useState('')
+ const [tags, setTags] = useState('')
+ const [hours, setHours] = useState('')
+ const [minutes, setMinutes] = useState('')
+ const [flagColor, setFlagColor] = useState(null)
+ const [status, setStatus] = useState(defaultStatus)
+
+ // Use provided statuses or fall back to defaults
+ const statuses = projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']
+
+ const handleSubmit = (e) => {
+ e.preventDefault()
+ if (!title.trim()) return
+
+ // Convert hours and minutes to total minutes
+ const totalMinutes = (parseInt(hours) || 0) * 60 + (parseInt(minutes) || 0)
+
+ // Parse tags
+ const tagList = tags
+ ? tags.split(',').map(t => t.trim()).filter(t => t.length > 0)
+ : null
+
+ const taskData = {
+ title: title.trim(),
+ description: description.trim() || null,
+ tags: tagList && tagList.length > 0 ? tagList : null,
+ estimated_minutes: totalMinutes > 0 ? totalMinutes : null,
+ flag_color: flagColor,
+ status: status
+ }
+
+ onSubmit(taskData)
+ }
+
+ return (
+
+ )
+}
+
+export default TaskForm
diff --git a/frontend/src/components/TaskMenu.jsx b/frontend/src/components/TaskMenu.jsx
new file mode 100644
index 0000000..ad5a906
--- /dev/null
+++ b/frontend/src/components/TaskMenu.jsx
@@ -0,0 +1,405 @@
+import { useState, useRef, useEffect } from 'react'
+import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo, FileText } from 'lucide-react'
+import { updateTask } from '../utils/api'
+
+const FLAG_COLORS = [
+ { name: 'red', color: 'bg-red-500' },
+ { name: 'orange', color: 'bg-orange-500' },
+ { name: 'yellow', color: 'bg-yellow-500' },
+ { name: 'green', color: 'bg-green-500' },
+ { name: 'blue', color: 'bg-blue-500' },
+ { name: 'purple', color: 'bg-purple-500' },
+ { name: 'pink', color: 'bg-pink-500' }
+]
+
+// Helper to format status label
+const formatStatusLabel = (status) => {
+ return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
+}
+
+// Helper to get status color
+const getStatusTextColor = (status) => {
+ const lowerStatus = status.toLowerCase()
+ if (lowerStatus === 'backlog') return 'text-gray-400'
+ if (lowerStatus === 'in_progress' || lowerStatus.includes('progress')) return 'text-blue-400'
+ if (lowerStatus === 'on_hold' || lowerStatus.includes('hold') || lowerStatus.includes('waiting')) return 'text-yellow-400'
+ if (lowerStatus === 'done' || lowerStatus.includes('complete')) return 'text-green-400'
+ if (lowerStatus.includes('blocked')) return 'text-red-400'
+ return 'text-purple-400' // default for custom statuses
+}
+
+function TaskMenu({ task, onUpdate, onDelete, onEdit, projectStatuses }) {
+ const [isOpen, setIsOpen] = useState(false)
+ const [showTimeEdit, setShowTimeEdit] = useState(false)
+ const [showDescriptionEdit, setShowDescriptionEdit] = useState(false)
+ const [showTagsEdit, setShowTagsEdit] = useState(false)
+ const [showFlagEdit, setShowFlagEdit] = useState(false)
+ const [showStatusEdit, setShowStatusEdit] = useState(false)
+
+ // Calculate hours and minutes from task.estimated_minutes
+ const initialHours = task.estimated_minutes ? Math.floor(task.estimated_minutes / 60) : ''
+ const initialMinutes = task.estimated_minutes ? task.estimated_minutes % 60 : ''
+
+ const [editHours, setEditHours] = useState(initialHours)
+ const [editMinutes, setEditMinutes] = useState(initialMinutes)
+ const [editDescription, setEditDescription] = useState(task.description || '')
+ const [editTags, setEditTags] = useState(task.tags ? task.tags.join(', ') : '')
+ const menuRef = useRef(null)
+
+ useEffect(() => {
+ function handleClickOutside(event) {
+ if (menuRef.current && !menuRef.current.contains(event.target)) {
+ setIsOpen(false)
+ setShowTimeEdit(false)
+ setShowDescriptionEdit(false)
+ setShowTagsEdit(false)
+ setShowFlagEdit(false)
+ setShowStatusEdit(false)
+ }
+ }
+
+ if (isOpen) {
+ document.addEventListener('mousedown', handleClickOutside)
+ return () => document.removeEventListener('mousedown', handleClickOutside)
+ }
+ }, [isOpen])
+
+ const handleUpdateTime = async () => {
+ try {
+ const totalMinutes = (parseInt(editHours) || 0) * 60 + (parseInt(editMinutes) || 0)
+ const minutes = totalMinutes > 0 ? totalMinutes : null
+ await updateTask(task.id, { estimated_minutes: minutes })
+ setShowTimeEdit(false)
+ setIsOpen(false)
+ onUpdate()
+ } catch (err) {
+ alert(`Error: ${err.message}`)
+ }
+ }
+
+ const handleUpdateDescription = async () => {
+ try {
+ const description = editDescription.trim() || null
+ await updateTask(task.id, { description })
+ setShowDescriptionEdit(false)
+ setIsOpen(false)
+ onUpdate()
+ } catch (err) {
+ alert(`Error: ${err.message}`)
+ }
+ }
+
+ const handleUpdateTags = async () => {
+ try {
+ const tags = editTags
+ ? editTags.split(',').map(t => t.trim()).filter(t => t.length > 0)
+ : null
+ await updateTask(task.id, { tags })
+ setShowTagsEdit(false)
+ setIsOpen(false)
+ onUpdate()
+ } catch (err) {
+ alert(`Error: ${err.message}`)
+ }
+ }
+
+ const handleUpdateFlag = async (color) => {
+ try {
+ await updateTask(task.id, { flag_color: color })
+ setShowFlagEdit(false)
+ setIsOpen(false)
+ onUpdate()
+ } catch (err) {
+ alert(`Error: ${err.message}`)
+ }
+ }
+
+ const handleClearFlag = async () => {
+ try {
+ await updateTask(task.id, { flag_color: null })
+ setShowFlagEdit(false)
+ setIsOpen(false)
+ onUpdate()
+ } catch (err) {
+ alert(`Error: ${err.message}`)
+ }
+ }
+
+ const handleUpdateStatus = async (newStatus) => {
+ try {
+ await updateTask(task.id, { status: newStatus })
+ setShowStatusEdit(false)
+ setIsOpen(false)
+ onUpdate()
+ } catch (err) {
+ alert(`Error: ${err.message}`)
+ }
+ }
+
+ return (
+
+
{
+ e.stopPropagation()
+ setIsOpen(!isOpen)
+ }}
+ className="text-gray-400 hover:text-gray-200 p-1"
+ title="More options"
+ >
+
+
+
+ {isOpen && (
+
+ {/* Time Edit */}
+ {showTimeEdit ? (
+
+
+
+ Time Estimate
+
+
+ setEditHours(e.target.value)}
+ placeholder="Hours"
+ className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
+ autoFocus
+ onClick={(e) => e.stopPropagation()}
+ />
+ setEditMinutes(e.target.value)}
+ placeholder="Minutes"
+ className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
+ onClick={(e) => e.stopPropagation()}
+ />
+
+
+
+ Save
+
+ setShowTimeEdit(false)}
+ className="px-2 py-1 text-sm text-gray-400 hover:text-gray-300"
+ >
+ Cancel
+
+
+
+ ) : (
+
{
+ e.stopPropagation()
+ setShowTimeEdit(true)
+ }}
+ className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
+ >
+
+ Set Time Estimate
+
+ )}
+
+ {/* Description Edit */}
+ {showDescriptionEdit ? (
+
+
+
+ Description
+
+
+
setEditDescription(e.target.value)}
+ placeholder="Task description..."
+ rows="4"
+ className="w-full px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange resize-y"
+ autoFocus
+ onClick={(e) => e.stopPropagation()}
+ />
+
+
+ Save
+
+ setShowDescriptionEdit(false)}
+ className="px-2 py-1 text-sm text-gray-400 hover:text-gray-300"
+ >
+ Cancel
+
+
+
+
+ ) : (
+
{
+ e.stopPropagation()
+ setShowDescriptionEdit(true)
+ }}
+ className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
+ >
+
+ Edit Description
+
+ )}
+
+ {/* Tags Edit */}
+ {showTagsEdit ? (
+
+
+
+ Tags (comma-separated)
+
+
+ setEditTags(e.target.value)}
+ placeholder="coding, bug-fix"
+ className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
+ autoFocus
+ onClick={(e) => e.stopPropagation()}
+ />
+
+
+
+ setShowTagsEdit(false)}
+ className="text-gray-400 hover:text-gray-300"
+ >
+
+
+
+
+ ) : (
+
{
+ e.stopPropagation()
+ setShowTagsEdit(true)
+ }}
+ className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
+ >
+
+ Edit Tags
+
+ )}
+
+ {/* Flag Color Edit */}
+ {showFlagEdit ? (
+
+
+
+ Flag Color
+
+
+ {FLAG_COLORS.map(({ name, color }) => (
+ handleUpdateFlag(name)}
+ className={`w-6 h-6 ${color} rounded hover:ring-2 hover:ring-cyber-orange transition-all`}
+ title={name}
+ />
+ ))}
+
+
+ Clear Flag
+
+
+ ) : (
+
{
+ e.stopPropagation()
+ setShowFlagEdit(true)
+ }}
+ className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
+ >
+
+ Set Flag Color
+
+ )}
+
+ {/* Status Change */}
+ {showStatusEdit ? (
+
+
+
+ Change Status
+
+
+ {(projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']).map((status) => (
+ handleUpdateStatus(status)}
+ className={`w-full text-left px-2 py-1.5 rounded text-sm ${
+ task.status === status
+ ? 'bg-cyber-orange/20 border border-cyber-orange/40'
+ : 'hover:bg-cyber-darker border border-transparent'
+ } ${getStatusTextColor(status)} transition-all`}
+ >
+ {formatStatusLabel(status)} {task.status === status && 'â'}
+
+ ))}
+
+
+ ) : (
+
{
+ e.stopPropagation()
+ setShowStatusEdit(true)
+ }}
+ className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
+ >
+
+ Change Status
+
+ )}
+
+ {/* Edit Title */}
+
{
+ e.stopPropagation()
+ onEdit()
+ setIsOpen(false)
+ }}
+ className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
+ >
+
+ Edit Title
+
+
+ {/* Delete */}
+
{
+ 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"
+ >
+
+ Delete Task
+
+
+ )}
+
+ )
+}
+
+export default TaskMenu
diff --git a/frontend/src/components/TreeView.jsx b/frontend/src/components/TreeView.jsx
index 0c3a9b9..2b608a5 100644
--- a/frontend/src/components/TreeView.jsx
+++ b/frontend/src/components/TreeView.jsx
@@ -3,10 +3,10 @@ import {
ChevronDown,
ChevronRight,
Plus,
- Edit2,
- Trash2,
Check,
- X
+ X,
+ Flag,
+ Clock
} from 'lucide-react'
import {
getProjectTaskTree,
@@ -14,28 +14,42 @@ import {
updateTask,
deleteTask
} from '../utils/api'
+import { formatTimeWithTotal } from '../utils/format'
+import TaskMenu from './TaskMenu'
+import TaskForm from './TaskForm'
-const STATUS_COLORS = {
- backlog: 'text-gray-400',
- in_progress: 'text-blue-400',
- blocked: 'text-red-400',
- done: 'text-green-400'
+// Helper to format status label
+const formatStatusLabel = (status) => {
+ return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
-const STATUS_LABELS = {
- backlog: 'Backlog',
- in_progress: 'In Progress',
- blocked: 'Blocked',
- done: 'Done'
+// Helper to get status color
+const getStatusColor = (status) => {
+ const lowerStatus = status.toLowerCase()
+ if (lowerStatus === 'backlog') return 'text-gray-400'
+ if (lowerStatus === 'in_progress' || lowerStatus.includes('progress')) return 'text-blue-400'
+ if (lowerStatus === 'on_hold' || lowerStatus.includes('hold') || lowerStatus.includes('waiting')) return 'text-yellow-400'
+ if (lowerStatus === 'done' || lowerStatus.includes('complete')) return 'text-green-400'
+ if (lowerStatus.includes('blocked')) return 'text-red-400'
+ return 'text-purple-400' // default for custom statuses
}
-function TaskNode({ task, projectId, onUpdate, level = 0 }) {
+const FLAG_COLORS = {
+ 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'
+}
+
+function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses }) {
const [isExpanded, setIsExpanded] = useState(true)
const [isEditing, setIsEditing] = useState(false)
const [editTitle, setEditTitle] = useState(task.title)
const [editStatus, setEditStatus] = useState(task.status)
const [showAddSubtask, setShowAddSubtask] = useState(false)
- const [newSubtaskTitle, setNewSubtaskTitle] = useState('')
const hasSubtasks = task.subtasks && task.subtasks.length > 0
@@ -62,18 +76,18 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
}
}
- const handleAddSubtask = async (e) => {
- e.preventDefault()
- if (!newSubtaskTitle.trim()) return
-
+ const handleAddSubtask = async (taskData) => {
try {
await createTask({
project_id: parseInt(projectId),
parent_task_id: task.id,
- title: newSubtaskTitle,
- status: 'backlog'
+ title: taskData.title,
+ description: taskData.description,
+ status: taskData.status,
+ tags: taskData.tags,
+ estimated_minutes: taskData.estimated_minutes,
+ flag_color: taskData.flag_color
})
- setNewSubtaskTitle('')
setShowAddSubtask(false)
setIsExpanded(true)
onUpdate()
@@ -114,10 +128,9 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
onChange={(e) => setEditStatus(e.target.value)}
className="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"
>
-
Backlog
-
In Progress
-
Blocked
-
Done
+ {(projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']).map(status => (
+
{formatStatusLabel(status)}
+ ))}
-
{task.title}
-
- {STATUS_LABELS[task.status]}
-
+
+ {/* Flag indicator */}
+ {task.flag_color && FLAG_COLORS[task.flag_color] && (
+
+ )}
+ {task.title}
+
+ {formatStatusLabel(task.status)}
+
+
+
+ {/* Metadata row */}
+ {(formatTimeWithTotal(task) || (task.tags && task.tags.length > 0)) && (
+
+ {/* Time estimate */}
+ {formatTimeWithTotal(task) && (
+
+
+ {formatTimeWithTotal(task)}
+
+ )}
+
+ {/* Tags */}
+ {task.tags && task.tags.length > 0 && (
+
+ {task.tags.map((tag, idx) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* Description */}
+ {task.description && (
+
+ {task.description}
+
+ )}
{/* Actions */}
@@ -154,20 +207,13 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
>
-
setIsEditing(true)}
- className="text-gray-400 hover:text-gray-200"
- title="Edit"
- >
-
-
-
-
-
+
setIsEditing(true)}
+ projectStatuses={projectStatuses}
+ />
>
)}
@@ -176,29 +222,12 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
{/* Add Subtask Form */}
{showAddSubtask && (
-
- setNewSubtaskTitle(e.target.value)}
- placeholder="New subtask title..."
- className="flex-1 px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
- autoFocus
- />
-
- Add
-
- setShowAddSubtask(false)}
- className="px-3 py-2 text-gray-400 hover:text-gray-200 text-sm"
- >
- Cancel
-
-
+ setShowAddSubtask(false)}
+ submitLabel="Add Subtask"
+ projectStatuses={projectStatuses}
+ />
)}
@@ -212,6 +241,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
projectId={projectId}
onUpdate={onUpdate}
level={level + 1}
+ projectStatuses={projectStatuses}
/>
))}
@@ -220,12 +250,12 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
)
}
-function TreeView({ projectId }) {
+function TreeView({ projectId, project }) {
+ const projectStatuses = project?.statuses || ['backlog', 'in_progress', 'on_hold', 'done']
const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showAddRoot, setShowAddRoot] = useState(false)
- const [newTaskTitle, setNewTaskTitle] = useState('')
useEffect(() => {
loadTasks()
@@ -243,18 +273,18 @@ function TreeView({ projectId }) {
}
}
- const handleAddRootTask = async (e) => {
- e.preventDefault()
- if (!newTaskTitle.trim()) return
-
+ const handleAddRootTask = async (taskData) => {
try {
await createTask({
project_id: parseInt(projectId),
parent_task_id: null,
- title: newTaskTitle,
- status: 'backlog'
+ title: taskData.title,
+ description: taskData.description,
+ status: taskData.status,
+ tags: taskData.tags,
+ estimated_minutes: taskData.estimated_minutes,
+ flag_color: taskData.flag_color
})
- setNewTaskTitle('')
setShowAddRoot(false)
loadTasks()
} catch (err) {
@@ -285,29 +315,12 @@ function TreeView({ projectId }) {
{showAddRoot && (
-
- setNewTaskTitle(e.target.value)}
- placeholder="New task title..."
- className="flex-1 px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 focus:outline-none focus:border-cyber-orange"
- autoFocus
- />
-
- Add
-
- setShowAddRoot(false)}
- className="px-4 py-2 text-gray-400 hover:text-gray-200"
- >
- Cancel
-
-
+ setShowAddRoot(false)}
+ submitLabel="Add Task"
+ projectStatuses={projectStatuses}
+ />
)}
@@ -324,6 +337,7 @@ function TreeView({ projectId }) {
task={task}
projectId={projectId}
onUpdate={loadTasks}
+ projectStatuses={projectStatuses}
/>
))}
diff --git a/frontend/src/pages/ProjectList.jsx b/frontend/src/pages/ProjectList.jsx
index ed17c83..b4b9b17 100644
--- a/frontend/src/pages/ProjectList.jsx
+++ b/frontend/src/pages/ProjectList.jsx
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
-import { Plus, Upload, Trash2 } from 'lucide-react'
-import { getProjects, createProject, deleteProject, importJSON } from '../utils/api'
+import { Plus, Upload, Trash2, Archive, ArchiveRestore } from 'lucide-react'
+import { getProjects, createProject, deleteProject, importJSON, archiveProject, unarchiveProject } from '../utils/api'
function ProjectList() {
const [projects, setProjects] = useState([])
@@ -12,15 +12,22 @@ function ProjectList() {
const [newProjectDesc, setNewProjectDesc] = useState('')
const [importJSON_Text, setImportJSONText] = useState('')
const [error, setError] = useState('')
+ const [activeTab, setActiveTab] = useState('active') // 'active', 'archived', 'all'
const navigate = useNavigate()
useEffect(() => {
loadProjects()
- }, [])
+ }, [activeTab])
const loadProjects = async () => {
try {
- const data = await getProjects()
+ setLoading(true)
+ let archivedFilter = null
+ if (activeTab === 'active') archivedFilter = false
+ if (activeTab === 'archived') archivedFilter = true
+ // 'all' tab uses null to get all projects
+
+ const data = await getProjects(archivedFilter)
setProjects(data)
} catch (err) {
setError(err.message)
@@ -72,13 +79,33 @@ function ProjectList() {
}
}
+ const handleArchiveProject = async (projectId, e) => {
+ e.stopPropagation()
+ try {
+ await archiveProject(projectId)
+ setProjects(projects.filter(p => p.id !== projectId))
+ } catch (err) {
+ setError(err.message)
+ }
+ }
+
+ const handleUnarchiveProject = async (projectId, e) => {
+ e.stopPropagation()
+ try {
+ await unarchiveProject(projectId)
+ setProjects(projects.filter(p => p.id !== projectId))
+ } catch (err) {
+ setError(err.message)
+ }
+ }
+
if (loading) {
return
Loading...
}
return (
-
+
Projects
+ {/* Tabs */}
+
+ 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
+
+ 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
+
+ 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
+
+
+
{error && (
{error}
@@ -106,8 +167,14 @@ function ProjectList() {
{projects.length === 0 ? (
-
No projects yet
-
Create a new project or import from JSON
+
+ {activeTab === 'archived' ? 'No archived projects' : 'No projects yet'}
+
+
+ {activeTab === 'archived'
+ ? 'Archive projects to keep them out of your active workspace'
+ : 'Create a new project or import from JSON'}
+
) : (
@@ -115,18 +182,45 @@ function ProjectList() {
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'
+ }`}
>
{project.name}
+ {project.is_archived && (
+ (archived)
+ )}
-
handleDeleteProject(project.id, e)}
- className="text-gray-600 hover:text-red-400 transition-colors"
- >
-
-
+
+ {project.is_archived ? (
+
handleUnarchiveProject(project.id, e)}
+ className="text-gray-600 hover:text-cyber-orange transition-colors"
+ title="Unarchive project"
+ >
+
+
+ ) : (
+
handleArchiveProject(project.id, e)}
+ className="text-gray-600 hover:text-yellow-400 transition-colors"
+ title="Archive project"
+ >
+
+
+ )}
+
handleDeleteProject(project.id, e)}
+ className="text-gray-600 hover:text-red-400 transition-colors"
+ title="Delete project"
+ >
+
+
+
{project.description && (
{project.description}
diff --git a/frontend/src/pages/ProjectView.jsx b/frontend/src/pages/ProjectView.jsx
index c1ce7fc..630d8f8 100644
--- a/frontend/src/pages/ProjectView.jsx
+++ b/frontend/src/pages/ProjectView.jsx
@@ -1,9 +1,10 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
-import { ArrowLeft, LayoutList, LayoutGrid } from 'lucide-react'
+import { ArrowLeft, LayoutList, LayoutGrid, Settings } from 'lucide-react'
import { getProject } from '../utils/api'
import TreeView from '../components/TreeView'
import KanbanView from '../components/KanbanView'
+import ProjectSettings from '../components/ProjectSettings'
function ProjectView() {
const { projectId } = useParams()
@@ -12,6 +13,7 @@ function ProjectView() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [view, setView] = useState('tree') // 'tree' or 'kanban'
+ const [showSettings, setShowSettings] = useState(false)
useEffect(() => {
loadProject()
@@ -65,37 +67,55 @@ function ProjectView() {
)}
-
+
+
+ setView('tree')}
+ className={`flex items-center gap-2 px-4 py-2 rounded transition-colors ${
+ view === 'tree'
+ ? 'bg-cyber-orange text-cyber-darkest font-semibold'
+ : 'text-gray-400 hover:text-gray-200'
+ }`}
+ >
+
+ Tree View
+
+ setView('kanban')}
+ className={`flex items-center gap-2 px-4 py-2 rounded transition-colors ${
+ view === 'kanban'
+ ? 'bg-cyber-orange text-cyber-darkest font-semibold'
+ : 'text-gray-400 hover:text-gray-200'
+ }`}
+ >
+
+ Kanban
+
+
+
setView('tree')}
- className={`flex items-center gap-2 px-4 py-2 rounded transition-colors ${
- view === 'tree'
- ? 'bg-cyber-orange text-cyber-darkest font-semibold'
- : 'text-gray-400 hover:text-gray-200'
- }`}
+ onClick={() => setShowSettings(true)}
+ className="p-2 text-gray-400 hover:text-cyber-orange transition-colors border border-cyber-orange/30 rounded-lg hover:border-cyber-orange/60"
+ title="Project Settings"
>
-
- Tree View
-
-
setView('kanban')}
- className={`flex items-center gap-2 px-4 py-2 rounded transition-colors ${
- view === 'kanban'
- ? 'bg-cyber-orange text-cyber-darkest font-semibold'
- : 'text-gray-400 hover:text-gray-200'
- }`}
- >
-
- Kanban
+
{view === 'tree' ? (
-
+
) : (
-
+
+ )}
+
+ {showSettings && (
+
setShowSettings(false)}
+ onUpdate={loadProject}
+ />
)}
)
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index 4805c54..5589ee2 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -1,4 +1,4 @@
-const API_BASE = '/api';
+const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api';
async function fetchAPI(endpoint, options = {}) {
const response = await fetch(`${API_BASE}${endpoint}`, {
@@ -22,7 +22,14 @@ async function fetchAPI(endpoint, options = {}) {
}
// Projects
-export const getProjects = () => fetchAPI('/projects');
+export const getProjects = (archived = null) => {
+ const params = new URLSearchParams();
+ if (archived !== null) {
+ params.append('archived', archived);
+ }
+ const queryString = params.toString();
+ return fetchAPI(`/projects${queryString ? `?${queryString}` : ''}`);
+};
export const getProject = (id) => fetchAPI(`/projects/${id}`);
export const createProject = (data) => fetchAPI('/projects', {
method: 'POST',
@@ -33,6 +40,8 @@ export const updateProject = (id, data) => fetchAPI(`/projects/${id}`, {
body: JSON.stringify(data),
});
export const deleteProject = (id) => fetchAPI(`/projects/${id}`, { method: 'DELETE' });
+export const archiveProject = (id) => updateProject(id, { is_archived: true });
+export const unarchiveProject = (id) => updateProject(id, { is_archived: false });
// Tasks
export const getProjectTasks = (projectId) => fetchAPI(`/projects/${projectId}/tasks`);
diff --git a/frontend/src/utils/format.js b/frontend/src/utils/format.js
index 65a30dc..eb8373e 100644
--- a/frontend/src/utils/format.js
+++ b/frontend/src/utils/format.js
@@ -1,4 +1,4 @@
-// Format minutes into display string
+// Format minutes into display string (e.g., "1h 30m" or "45m")
export function formatTime(minutes) {
if (!minutes || minutes === 0) return null;
@@ -6,8 +6,14 @@ export function formatTime(minutes) {
return `${minutes}m`;
}
- const hours = minutes / 60;
- return `${hours.toFixed(1)}h`;
+ const hours = Math.floor(minutes / 60);
+ const mins = minutes % 60;
+
+ if (mins === 0) {
+ return `${hours}h`;
+ }
+
+ return `${hours}h ${mins}m`;
}
// Format tags as comma-separated string
@@ -15,3 +21,66 @@ export function formatTags(tags) {
if (!tags || tags.length === 0) return null;
return tags.join(', ');
}
+
+// Calculate sum of all LEAF descendant estimates (hierarchical structure)
+// Excludes tasks marked as "done"
+export function calculateLeafTime(task) {
+ // If no subtasks, this is a leaf - return its own estimate if not done
+ if (!task.subtasks || task.subtasks.length === 0) {
+ return (task.status !== 'done' && task.estimated_minutes) ? task.estimated_minutes : 0;
+ }
+
+ // Has subtasks, so sum up all leaf descendants (excluding done tasks)
+ let total = 0;
+ for (const subtask of task.subtasks) {
+ total += calculateLeafTime(subtask);
+ }
+
+ return total;
+}
+
+// Calculate sum of all LEAF descendant estimates (flat task list)
+// Excludes tasks marked as "done"
+export function calculateLeafTimeFlat(task, allTasks) {
+ // Find direct children
+ const children = allTasks.filter(t => t.parent_task_id === task.id);
+
+ // If no children, this is a leaf - return its own estimate if not done
+ if (children.length === 0) {
+ return (task.status !== 'done' && task.estimated_minutes) ? task.estimated_minutes : 0;
+ }
+
+ // Has children, so sum up all leaf descendants (excluding done tasks)
+ let total = 0;
+ for (const child of children) {
+ total += calculateLeafTimeFlat(child, allTasks);
+ }
+
+ return total;
+}
+
+// Format time display based on leaf calculation logic
+export function formatTimeWithTotal(task, allTasks = null) {
+ // Check if task has subtasks
+ const hasSubtasks = allTasks
+ ? allTasks.some(t => t.parent_task_id === task.id)
+ : (task.subtasks && task.subtasks.length > 0);
+
+ // Leaf task: use own estimate
+ if (!hasSubtasks) {
+ return formatTime(task.estimated_minutes);
+ }
+
+ // Parent task: calculate sum of leaf descendants
+ const leafTotal = allTasks
+ ? calculateLeafTimeFlat(task, allTasks)
+ : calculateLeafTime(task);
+
+ // If no leaf estimates exist, fall back to own estimate
+ if (leafTotal === 0) {
+ return formatTime(task.estimated_minutes);
+ }
+
+ // Show leaf total
+ return formatTime(leafTotal);
+}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 460f68a..57b2151 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -1,15 +1,19 @@
-import { defineConfig } from 'vite'
+import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
-export default defineConfig({
- plugins: [react()],
- server: {
- host: '0.0.0.0',
- port: 5173,
- proxy: {
- '/api': {
- target: 'http://localhost:8000',
- changeOrigin: true,
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd(), '')
+
+ return {
+ plugins: [react()],
+ server: {
+ host: '0.0.0.0',
+ port: parseInt(env.VITE_DEV_PORT || '5173'),
+ proxy: {
+ '/api': {
+ target: env.VITE_API_URL || 'http://localhost:8000',
+ changeOrigin: true,
+ }
}
}
}
diff --git a/import-template.json b/import-template.json
new file mode 100644
index 0000000..a2bd50f
--- /dev/null
+++ b/import-template.json
@@ -0,0 +1,24 @@
+{
+ "project": {
+ "name": "New Project",
+ "description": "Describe the project here."
+ },
+ "tasks": [
+ {
+ "title": "Root Task Example",
+ "description": "Optional description.",
+ "status": "backlog",
+ "estimated_minutes": 60,
+ "tags": ["example"],
+ "flag_color": "orange",
+ "subtasks": [
+ {
+ "title": "Subtask Example",
+ "status": "backlog",
+ "estimated_minutes": 30,
+ "tags": []
+ }
+ ]
+ }
+ ]
+}