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 - /> - - -
- ) : ( - <> -
-
-
{task.title}
- {parentTask && ( -
- â†ŗ subtask of: {parentTask.title} -
- )} -
-
- - -
+
+
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 + /> + +
- + ) : ( + <> +
+
+
+ {/* Expand/collapse for parent cards */} + {isParent && childrenInColumn.length > 0 && ( + + )} + +
+
+ {/* 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} +
+ )} +
+
+
+ +
+ + 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(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)

+
+ + +
+
- {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 + /> + + + + ) : ( + <> + + + {taskCounts[status] || 0} task{taskCounts[status] === 1 ? '' : 's'} + + + + )} +
+ ))} +
+ + +
+
+ + {/* Footer */} +
+ + +
+ + {/* 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. +

+
+
+
+ +
+
+
+ )} +
+
+ ) +} + +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 && ( + + )} +
+ + {/* Project Filter Dropdown */} + {showProjectFilter && ( +
+
+
Filter by projects:
+ {projects.map(project => ( + + ))} + {selectedProjects.length > 0 && ( + + )} +
+
+ )} + + {/* 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 ( + + ) + })} +
+ )} +
+ )} +
+ ) +} + +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 ( +
+ {/* Title */} +
+ + setTitle(e.target.value)} + placeholder="Enter task title..." + className="w-full 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 + /> +
+ + {/* Description */} +
+ +