13 Commits

Author SHA1 Message Date
01e594b941 Merge pull request 'Main branch Resync on w/ gitea. v0.1.6' (#1) from claude/nested-todo-app-mvp-014vUjLpD8wNjkMhLMXS6Kd5 into main
Reviewed-on: http://10.0.0.2:9010/serversdown/break-it-down/pulls/1
2026-01-04 04:30:52 -05:00
serversdwn
3fc90063b4 rebranded to BIT 2026-01-04 09:24:06 +00:00
serversdwn
bd0ac0d95b updated docs 2025-11-25 23:26:47 +00:00
serversdwn
1a6c8cf98c v0.1.6 changes 2025-11-25 23:22:44 +00:00
serversdwn
8d5ad6a809 Reworked variable system, no longer hardcoded 2025-11-25 20:41:11 +00:00
serversdwn
d692de8f5d Merge pull request #8 from serversdwn/main
Syncing main to dev
2025-11-21 03:07:08 -05:00
serversdwn
41b5b1bc1c json schema template added
Made a little json schema template
2025-11-21 03:06:08 -05:00
serversdwn
cd8cb658e7 Made a little json schema template 2025-11-21 08:03:46 +00:00
serversdwn
fa25cc593a v0.1.5
Fixed nesting, added draggble parent cards, collapse all, text field
2025-11-20 19:07:03 -05:00
Claude
6302ce4036 Add three new features to v0.1.5
1. Make parent cards draggable with all subtasks
   - Parent cards can now be dragged between columns
   - All descendants are automatically moved with the parent
   - Added isParent flag to drag/drop dataTransfer

2. Add Expand All / Collapse All buttons to Kanban view
   - Added global expandedCards state management
   - New buttons in Kanban header with ChevronsDown/Up icons
   - Allows quick expansion/collapse of all parent cards

3. Add description field to tasks
   - Added description textarea to TaskForm component
   - Added description edit option to TaskMenu component
   - Description displays in both TreeView and KanbanView
   - Shows below metadata in italic gray text
   - Backend already supported description field
2025-11-20 18:12:42 +00:00
Claude
66b019c60b Release v0.1.5: Nested Kanban View
Major Feature: Nested Kanban Board
- Parent tasks now appear in each column where they have subtasks
- Provides hierarchical context while maintaining status-based organization
- Eliminates need to choose between hierarchy and status views

Parent Card Features:
1. Multi-Column Presence
   - Parent card appears in every column containing its descendants
   - Shows "X of Y subtasks in this column" counter
   - Automatically updates as children move between columns

2. Expandable/Collapsible
   - Click chevron to show/hide children in that specific column
   - Each parent instance independently expandable
   - Children displayed nested with indentation

3. Visual Distinction
   - Thicker orange border (border-2 vs border)
   - Bold text styling
   - "bg-cyber-darker" background instead of "bg-cyber-darkest"
   - Non-draggable (only leaf tasks can be moved)

4. Recursive Display
   - getDescendantsInStatus() finds all descendants (not just direct children)
   - Handles arbitrary nesting depth
   - Works with sub-subtasks and beyond

Technical Implementation:
- Added helper functions:
  - getDescendantsInStatus(taskId, allTasks, status)
  - hasDescendantsInStatus(taskId, allTasks, status)
- Modified TaskCard component with isParent and columnStatus props
- Updated KanbanColumn to show both parent and leaf tasks
- Only root-level tasks shown (nested children appear when parent expanded)

Display Logic:
- Each column shows:
  1. Root parent tasks with descendants in that status
  2. Root leaf tasks with that status
- Leaf tasks: tasks with no children
- Parent tasks: tasks with at least one child

Example Usage:
Project "Build Feature"
├─ Backend (2 subtasks in backlog, 1 in progress)
└─ Frontend (1 subtask in done)

Result: Project card appears in 3 columns:
- Backlog: "2 of 3 subtasks in this column"
- In Progress: "1 of 3 subtasks in this column"
- Done: "1 of 3 subtasks in this column"

Documentation:
- Updated README with nested Kanban explanation
- Added v0.1.5 section to CHANGELOG
- Updated version to v0.1.5 in App.jsx
- Moved "Nested Kanban" from roadmap to completed features

This completes the hierarchical task management vision for TESSERACT,
allowing users to see both project structure and status distribution
simultaneously without switching views.
2025-11-20 17:59:53 +00:00
serversdwn
ae5656babd Merge pull request #5 from serversdwn/claude/nested-todo-app-mvp-014vUjLpD8wNjkMhLMXS6Kd5
Release v0.1.4: Auto-complete parents and done task strikethrough
2025-11-20 12:52:11 -05:00
Claude
8000a464c9 Release v0.1.4: Auto-complete parents and done task strikethrough
New Features:
1. Auto-Complete Parent Tasks
   - When all child tasks are marked as "done", parent automatically becomes "done"
   - Works recursively up the task hierarchy
   - Implemented in backend crud.py with check_and_update_parent_status()
   - Prevents manual status management for completed branches

2. Strikethrough for Done Tasks
   - Time estimates crossed out when task status is "done"
   - Visual indicator that work is completed
   - Applied in both TreeView and KanbanView

3. Updated Version
   - Bumped to v0.1.4 in App.jsx header

4. Documentation
   - Added comprehensive CHANGELOG.md
   - Updated README.md with v0.1.4 features
   - Documented all versions from v0.1.0 to v0.1.4
   - Added usage examples, architecture diagrams, troubleshooting

Technical Changes:
- backend/app/crud.py: Added check_and_update_parent_status() recursive function
- frontend/src/components/TreeView.jsx: Added line-through styling for done tasks
- frontend/src/components/KanbanView.jsx: Added line-through styling for done tasks
- frontend/src/App.jsx: Version updated to v0.1.4

This release completes the intelligent time tracking and auto-completion features,
making TESSERACT a fully-featured hierarchical task management system.
2025-11-20 16:13:00 +00:00
29 changed files with 1748 additions and 252 deletions

160
ARCHIVE_FEATURE_README.md Normal file
View File

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

246
CHANGELOG.md Normal file
View File

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

BIN
README.md

Binary file not shown.

14
backend/.env.example Normal file
View File

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

View File

@@ -5,7 +5,12 @@ from . import models, schemas
# Project CRUD # Project CRUD
def create_project(db: Session, project: schemas.ProjectCreate) -> models.Project: def create_project(db: Session, project: schemas.ProjectCreate) -> models.Project:
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.add(db_project)
db.commit() db.commit()
db.refresh(db_project) 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() return db.query(models.Project).filter(models.Project.id == project_id).first()
def get_projects(db: Session, skip: int = 0, limit: int = 100) -> List[models.Project]: def get_projects(db: Session, skip: int = 0, limit: int = 100, archived: Optional[bool] = None) -> List[models.Project]:
return db.query(models.Project).offset(skip).limit(limit).all() query = db.query(models.Project)
# Filter by archive status if specified
if archived is not None:
query = query.filter(models.Project.is_archived == archived)
return query.offset(skip).limit(limit).all()
def update_project( def update_project(
@@ -47,6 +58,11 @@ def delete_project(db: Session, project_id: int) -> bool:
# Task CRUD # Task CRUD
def create_task(db: Session, task: schemas.TaskCreate) -> models.Task: 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 # Get max sort_order for siblings
if task.parent_task_id: if task.parent_task_id:
max_order = db.query(models.Task).filter( 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() ).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( def update_task(
db: Session, task_id: int, task: schemas.TaskUpdate db: Session, task_id: int, task: schemas.TaskUpdate
) -> Optional[models.Task]: ) -> Optional[models.Task]:
@@ -100,11 +142,27 @@ def update_task(
return None return None
update_data = task.model_dump(exclude_unset=True) 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(): for key, value in update_data.items():
setattr(db_task, key, value) setattr(db_task, key, value)
db.commit() db.commit()
db.refresh(db_task) 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 return db_task
@@ -117,8 +175,13 @@ def delete_task(db: Session, task_id: int) -> bool:
return True 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""" """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( return db.query(models.Task).filter(
models.Task.project_id == project_id, models.Task.project_id == project_id,
models.Task.status == status models.Task.status == status

View File

@@ -1,8 +1,9 @@
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from .settings import settings
SQLALCHEMY_DATABASE_URL = "sqlite:///./tesseract.db" SQLALCHEMY_DATABASE_URL = settings.database_url
engine = create_engine( engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}

View File

@@ -6,20 +6,21 @@ import json
from . import models, schemas, crud from . import models, schemas, crud
from .database import engine, get_db from .database import engine, get_db
from .settings import settings
# Create database tables # Create database tables
models.Base.metadata.create_all(bind=engine) models.Base.metadata.create_all(bind=engine)
app = FastAPI( app = FastAPI(
title="Tesseract - Nested Todo Tree API", title=settings.api_title,
description="API for managing deeply nested todo trees", description=settings.api_description,
version="1.0.0" version=settings.api_version
) )
# CORS middleware for frontend # CORS middleware for frontend
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:5173", "http://localhost:3000"], # Vite default port allow_origins=settings.cors_origins_list,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
@@ -29,9 +30,14 @@ app.add_middleware(
# ========== PROJECT ENDPOINTS ========== # ========== PROJECT ENDPOINTS ==========
@app.get("/api/projects", response_model=List[schemas.Project]) @app.get("/api/projects", response_model=List[schemas.Project])
def list_projects(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): def list_projects(
"""List all projects""" skip: int = 0,
return crud.get_projects(db, skip=skip, limit=limit) limit: int = 100,
archived: Optional[bool] = None,
db: Session = Depends(get_db)
):
"""List all projects with optional archive filter"""
return crud.get_projects(db, skip=skip, limit=limit, archived=archived)
@app.post("/api/projects", response_model=schemas.Project, status_code=201) @app.post("/api/projects", response_model=schemas.Project, status_code=201)
@@ -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]) @app.get("/api/projects/{project_id}/tasks/by-status/{status}", response_model=List[schemas.Task])
def get_tasks_by_status( def get_tasks_by_status(
project_id: int, project_id: int,
status: models.TaskStatus, status: str,
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""Get all tasks for a project filtered by status (for Kanban view)""" """Get all tasks for a project filtered by status (for Kanban view)"""
if not crud.get_project(db, project_id): if not crud.get_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found") 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) @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): 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") 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) @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) @app.put("/api/tasks/{task_id}", response_model=schemas.Task)
def update_task(task_id: int, task: schemas.TaskUpdate, db: Session = Depends(get_db)): def update_task(task_id: int, task: schemas.TaskUpdate, db: Session = Depends(get_db)):
"""Update a task""" """Update a task"""
db_task = crud.update_task(db, task_id, task) try:
if not db_task: db_task = crud.update_task(db, task_id, task)
raise HTTPException(status_code=404, detail="Task not found") if not db_task:
return 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) @app.delete("/api/tasks/{task_id}", status_code=204)
@@ -187,6 +202,27 @@ def search_tasks(
# ========== JSON IMPORT ENDPOINT ========== # ========== 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( def _import_tasks_recursive(
db: Session, db: Session,
project_id: int, project_id: int,
@@ -227,7 +263,8 @@ def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_
{ {
"project": { "project": {
"name": "Project Name", "name": "Project Name",
"description": "Optional description" "description": "Optional description",
"statuses": ["backlog", "in_progress", "on_hold", "done"] // Optional
}, },
"tasks": [ "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( project = crud.create_project(
db, db,
schemas.ProjectCreate( schemas.ProjectCreate(
name=import_data.project.name, 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 # Recursively import tasks
tasks_created = _import_tasks_recursive( tasks_created = _import_tasks_recursive(
db, project.id, import_data.tasks db, project.id, import_data.tasks
@@ -271,6 +319,6 @@ def root():
"""API health check""" """API health check"""
return { return {
"status": "online", "status": "online",
"message": "Tesseract API - Nested Todo Tree Manager", "message": "Break It Down (BIT) API - Nested Todo Tree Manager",
"docs": "/docs" "docs": "/docs"
} }

View File

@@ -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 sqlalchemy.orm import relationship
from datetime import datetime from datetime import datetime
import enum
from .database import Base from .database import Base
class TaskStatus(str, enum.Enum): # Default statuses for new projects
BACKLOG = "backlog" DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
IN_PROGRESS = "in_progress"
BLOCKED = "blocked"
DONE = "done"
class Project(Base): class Project(Base):
@@ -18,6 +14,8 @@ class Project(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False) name = Column(String(255), nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES)
is_archived = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -32,7 +30,7 @@ class Task(Base):
parent_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True) parent_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True)
title = Column(String(500), nullable=False) title = Column(String(500), nullable=False)
description = Column(Text, nullable=True) 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) sort_order = Column(Integer, default=0)
estimated_minutes = Column(Integer, nullable=True) estimated_minutes = Column(Integer, nullable=True)
tags = Column(JSON, nullable=True) tags = Column(JSON, nullable=True)

View File

@@ -1,14 +1,14 @@
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from typing import Optional, List from typing import Optional, List
from datetime import datetime from datetime import datetime
from .models import TaskStatus from .models import DEFAULT_STATUSES
# Task Schemas # Task Schemas
class TaskBase(BaseModel): class TaskBase(BaseModel):
title: str title: str
description: Optional[str] = None description: Optional[str] = None
status: TaskStatus = TaskStatus.BACKLOG status: str = "backlog"
parent_task_id: Optional[int] = None parent_task_id: Optional[int] = None
sort_order: int = 0 sort_order: int = 0
estimated_minutes: Optional[int] = None estimated_minutes: Optional[int] = None
@@ -23,7 +23,7 @@ class TaskCreate(TaskBase):
class TaskUpdate(BaseModel): class TaskUpdate(BaseModel):
title: Optional[str] = None title: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
status: Optional[TaskStatus] = None status: Optional[str] = None
parent_task_id: Optional[int] = None parent_task_id: Optional[int] = None
sort_order: Optional[int] = None sort_order: Optional[int] = None
estimated_minutes: Optional[int] = None estimated_minutes: Optional[int] = None
@@ -53,16 +53,20 @@ class ProjectBase(BaseModel):
class ProjectCreate(ProjectBase): class ProjectCreate(ProjectBase):
pass statuses: Optional[List[str]] = None
class ProjectUpdate(BaseModel): class ProjectUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
statuses: Optional[List[str]] = None
is_archived: Optional[bool] = None
class Project(ProjectBase): class Project(ProjectBase):
id: int id: int
statuses: List[str]
is_archived: bool
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -79,7 +83,7 @@ class ProjectWithTasks(Project):
class ImportSubtask(BaseModel): class ImportSubtask(BaseModel):
title: str title: str
description: Optional[str] = None description: Optional[str] = None
status: TaskStatus = TaskStatus.BACKLOG status: str = "backlog"
estimated_minutes: Optional[int] = None estimated_minutes: Optional[int] = None
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
flag_color: Optional[str] = None flag_color: Optional[str] = None
@@ -89,6 +93,7 @@ class ImportSubtask(BaseModel):
class ImportProject(BaseModel): class ImportProject(BaseModel):
name: str name: str
description: Optional[str] = None description: Optional[str] = None
statuses: Optional[List[str]] = None
class ImportData(BaseModel): class ImportData(BaseModel):

37
backend/app/settings.py Normal file
View File

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

View File

@@ -0,0 +1,37 @@
"""
Migration script to add is_archived column to existing projects.
Run this once if you have an existing database.
"""
import sqlite3
import os
# Get the database path
db_path = os.path.join(os.path.dirname(__file__), 'bit.db')
if not os.path.exists(db_path):
print(f"Database not found at {db_path}")
print("No migration needed - new database will be created with the correct schema.")
exit(0)
# Connect to the database
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
# Check if the column already exists
cursor.execute("PRAGMA table_info(projects)")
columns = [column[1] for column in cursor.fetchall()]
if 'is_archived' in columns:
print("Column 'is_archived' already exists. Migration not needed.")
else:
# Add the is_archived column
cursor.execute("ALTER TABLE projects ADD COLUMN is_archived BOOLEAN NOT NULL DEFAULT 0")
conn.commit()
print("Successfully added 'is_archived' column to projects table.")
print("All existing projects have been set to is_archived=False (0).")
except Exception as e:
print(f"Error during migration: {e}")
conn.rollback()
finally:
conn.close()

View File

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

View File

@@ -1,16 +1,16 @@
version: '3.8'
services: services:
backend: backend:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: tesseract-backend container_name: bit-backend
ports: ports:
- "8000:8000" - "8002:8002"
volumes: volumes:
- ./backend/app:/app/app - ./backend/app:/app/app
- tesseract-db:/app - bit-db:/app
env_file:
- ./backend/.env
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
restart: unless-stopped restart: unless-stopped
@@ -19,12 +19,14 @@ services:
build: build:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: tesseract-frontend container_name: bit-frontend
ports: ports:
- "3000:80" - "3002:80"
env_file:
- ./frontend/.env
depends_on: depends_on:
- backend - backend
restart: unless-stopped restart: unless-stopped
volumes: volumes:
tesseract-db: bit-db:

18
frontend/.env.example Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -11,9 +11,9 @@ function App() {
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div> <div>
<h1 className="text-2xl font-bold text-cyber-orange"> <h1 className="text-2xl font-bold text-cyber-orange">
TESSERACT Break It Down
<span className="ml-3 text-sm text-gray-500">Task Decomposition Engine</span> <span className="ml-3 text-sm text-gray-500">BIT - Task Decomposition Engine</span>
<span className="ml-2 text-xs text-gray-600">v0.1.3</span> <span className="ml-2 text-xs text-gray-600">v{import.meta.env.VITE_APP_VERSION || '0.1.6'}</span>
</h1> </h1>
</div> </div>
<SearchBar /> <SearchBar />

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Plus, Check, X, Flag, Clock } from 'lucide-react' import { Plus, Check, X, Flag, Clock, ChevronDown, ChevronRight, ChevronsDown, ChevronsUp } from 'lucide-react'
import { import {
getProjectTasks, getProjectTasks,
createTask, createTask,
@@ -10,12 +10,21 @@ import { formatTimeWithTotal } from '../utils/format'
import TaskMenu from './TaskMenu' import TaskMenu from './TaskMenu'
import TaskForm from './TaskForm' import TaskForm from './TaskForm'
const STATUSES = [ // Helper to format status label
{ key: 'backlog', label: 'Backlog', color: 'border-gray-600' }, const formatStatusLabel = (status) => {
{ key: 'in_progress', label: 'In Progress', color: 'border-blue-500' }, return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
{ key: 'blocked', label: 'Blocked', color: 'border-red-500' }, }
{ key: 'done', label: 'Done', color: 'border-green-500' }
] // 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 = { const FLAG_COLORS = {
red: 'bg-red-500', red: 'bg-red-500',
@@ -27,14 +36,52 @@ const FLAG_COLORS = {
pink: 'bg-pink-500' pink: 'bg-pink-500'
} }
function TaskCard({ task, allTasks, onUpdate, onDragStart }) { // 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 [isEditing, setIsEditing] = useState(false)
const [editTitle, setEditTitle] = useState(task.title) const [editTitle, setEditTitle] = useState(task.title)
const [showAddSubtask, setShowAddSubtask] = useState(false)
// Find parent task if this is a subtask // Use global expanded state
const parentTask = task.parent_task_id const isExpanded = expandedCards[task.id] || false
? allTasks.find(t => t.id === task.parent_task_id) const toggleExpanded = () => {
: null setExpandedCards(prev => ({
...prev,
[task.id]: !prev[task.id]
}))
}
const handleSave = async () => { const handleSave = async () => {
try { try {
@@ -56,100 +103,195 @@ 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 ( return (
<div <div className="mb-2">
draggable={!isEditing} <div
onDragStart={(e) => onDragStart(e, task)} draggable={!isEditing}
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" onDragStart={(e) => onDragStart(e, task, isParent)}
> className={`${
{isEditing ? ( isParent
<div className="flex gap-2"> ? 'bg-cyber-darker border-2 border-cyber-orange/50'
<input : 'bg-cyber-darkest border border-cyber-orange/30'
type="text" } rounded-lg p-3 cursor-move hover:border-cyber-orange/60 transition-all group`}
value={editTitle} >
onChange={(e) => setEditTitle(e.target.value)} {isEditing ? (
className="flex-1 px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" <div className="flex gap-2">
autoFocus <input
/> type="text"
<button value={editTitle}
onClick={handleSave} onChange={(e) => setEditTitle(e.target.value)}
className="text-green-400 hover:text-green-300" 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
<Check size={16} /> />
</button> <button
<button onClick={handleSave}
onClick={() => { className="text-green-400 hover:text-green-300"
setIsEditing(false) >
setEditTitle(task.title) <Check size={16} />
}} </button>
className="text-gray-400 hover:text-gray-300" <button
> onClick={() => {
<X size={16} /> setIsEditing(false)
</button> setEditTitle(task.title)
</div> }}
) : ( className="text-gray-400 hover:text-gray-300"
<> >
<div className="flex justify-between items-start"> <X size={16} />
<div className="flex-1"> </button>
<div className="flex items-center gap-2 text-sm"> </div>
{/* Flag indicator */} ) : (
{task.flag_color && FLAG_COLORS[task.flag_color] && ( <>
<Flag size={12} className={`${FLAG_COLORS[task.flag_color].replace('bg-', 'text-')}`} fill="currentColor" /> <div className="flex justify-between items-start">
)} <div className="flex-1">
<span className="text-gray-200">{task.title}</span> <div className="flex items-center gap-2">
{/* Expand/collapse for parent cards */}
{isParent && childrenInColumn.length > 0 && (
<button
onClick={toggleExpanded}
className="text-cyber-orange hover:text-cyber-orange-bright"
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
)}
<div className="flex-1">
<div className="flex items-center gap-2 text-sm">
{/* Flag indicator */}
{task.flag_color && FLAG_COLORS[task.flag_color] && (
<Flag size={12} className={`${FLAG_COLORS[task.flag_color].replace('bg-', 'text-')}`} fill="currentColor" />
)}
<span className={`${isParent ? 'font-semibold text-cyber-orange' : 'text-gray-200'}`}>
{task.title}
</span>
</div>
{/* Parent card info: show subtask count in this column */}
{isParent && (
<div className="text-xs text-gray-500 mt-1">
{childrenInColumn.length} of {totalChildren} subtask{totalChildren !== 1 ? 's' : ''} in this column
</div>
)}
{/* Metadata row */}
{(formatTimeWithTotal(task, allTasks) || (task.tags && task.tags.length > 0)) && (
<div className="flex items-center gap-2 mt-2">
{/* Time estimate */}
{formatTimeWithTotal(task, allTasks) && (
<div className={`flex items-center gap-1 text-xs text-gray-500 ${task.status === 'done' ? 'line-through' : ''}`}>
<Clock size={11} />
<span>{formatTimeWithTotal(task, allTasks)}</span>
</div>
)}
{/* Tags */}
{task.tags && task.tags.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
{task.tags.map((tag, idx) => (
<span
key={idx}
className="inline-flex items-center px-1.5 py-0.5 text-xs bg-cyber-orange/20 text-cyber-orange border border-cyber-orange/30 rounded"
>
{tag}
</span>
))}
</div>
)}
</div>
)}
{/* Description */}
{task.description && (
<div className="mt-2 text-xs text-gray-400 italic">
{task.description}
</div>
)}
</div>
</div>
</div> </div>
{/* Parent task context */} <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
{parentTask && ( <button
<div className="text-xs text-gray-500 mt-1"> onClick={() => setShowAddSubtask(true)}
subtask of: <span className="text-cyber-orange">{parentTask.title}</span> className="text-cyber-orange hover:text-cyber-orange-bright p-1"
</div> title="Add subtask"
)} >
<Plus size={14} />
{/* Metadata row */} </button>
{(formatTimeWithTotal(task, allTasks) || (task.tags && task.tags.length > 0)) && ( <TaskMenu
<div className="flex items-center gap-2 mt-2"> task={task}
{/* Time estimate */} onUpdate={onUpdate}
{formatTimeWithTotal(task, allTasks) && ( onDelete={handleDelete}
<div className="flex items-center gap-1 text-xs text-gray-500"> onEdit={() => setIsEditing(true)}
<Clock size={11} /> projectStatuses={projectStatuses}
<span>{formatTimeWithTotal(task, allTasks)}</span> />
</div> </div>
)}
{/* Tags */}
{task.tags && task.tags.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
{task.tags.map((tag, idx) => (
<span
key={idx}
className="inline-flex items-center px-1.5 py-0.5 text-xs bg-cyber-orange/20 text-cyber-orange border border-cyber-orange/30 rounded"
>
{tag}
</span>
))}
</div>
)}
</div>
)}
</div> </div>
</>
)}
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> {/* Add Subtask Form */}
<TaskMenu {showAddSubtask && (
task={task} <div className="ml-6 mt-2">
onUpdate={onUpdate} <TaskForm
onDelete={handleDelete} onSubmit={handleAddSubtask}
onEdit={() => setIsEditing(true)} onCancel={() => setShowAddSubtask(false)}
/> submitLabel="Add Subtask"
</div> projectStatuses={projectStatuses}
</div> defaultStatus={columnStatus}
</> />
</div>
)}
{/* Expanded children */}
{isParent && isExpanded && childrenInColumn.length > 0 && (
<div className="ml-6 mt-2 space-y-2">
{childrenInColumn.map(child => (
<TaskCard
key={child.id}
task={child}
allTasks={allTasks}
onUpdate={onUpdate}
onDragStart={onDragStart}
isParent={false}
columnStatus={columnStatus}
expandedCards={expandedCards}
setExpandedCards={setExpandedCards}
projectStatuses={projectStatuses}
projectId={projectId}
/>
))}
</div>
)} )}
</div> </div>
) )
} }
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 [showAddTask, setShowAddTask] = useState(false)
const handleAddTask = async (taskData) => { const handleAddTask = async (taskData) => {
@@ -158,7 +300,8 @@ function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, on
project_id: parseInt(projectId), project_id: parseInt(projectId),
parent_task_id: null, parent_task_id: null,
title: taskData.title, title: taskData.title,
status: status.key, description: taskData.description,
status: taskData.status,
tags: taskData.tags, tags: taskData.tags,
estimated_minutes: taskData.estimated_minutes, estimated_minutes: taskData.estimated_minutes,
flag_color: taskData.flag_color flag_color: taskData.flag_color
@@ -170,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 ( return (
<div <div
className="flex-1 min-w-[280px] bg-cyber-darker rounded-lg p-4 border-t-4 ${status.color}" className={`flex-1 min-w-[280px] bg-cyber-darker rounded-lg p-4 border-t-4 ${status.color}`}
onDrop={(e) => onDrop(e, status.key)} onDrop={(e) => onDrop(e, status.key)}
onDragOver={onDragOver} onDragOver={onDragOver}
> >
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h3 className="font-semibold text-gray-200"> <h3 className="font-semibold text-gray-200">
{status.label} {status.label}
<span className="ml-2 text-xs text-gray-500">({tasks.length})</span> <span className="ml-2 text-xs text-gray-500">({displayTasks.length})</span>
</h3> </h3>
<button <button
onClick={() => setShowAddTask(true)} onClick={() => setShowAddTask(true)}
@@ -195,31 +359,52 @@ function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, on
onSubmit={handleAddTask} onSubmit={handleAddTask}
onCancel={() => setShowAddTask(false)} onCancel={() => setShowAddTask(false)}
submitLabel="Add Task" submitLabel="Add Task"
projectStatuses={projectStatuses}
defaultStatus={status.key}
/> />
</div> </div>
)} )}
<div className="space-y-2"> <div className="space-y-2">
{tasks.map(task => ( {displayTasks.map(task => {
<TaskCard const isParent = allTasks.some(t => t.parent_task_id === task.id)
key={task.id} return (
task={task} <TaskCard
allTasks={allTasks} key={task.id}
onUpdate={onUpdate} task={task}
onDragStart={(e, task) => { allTasks={allTasks}
e.dataTransfer.setData('taskId', task.id.toString()) onUpdate={onUpdate}
}} onDragStart={(e, task, isParent) => {
/> 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}
/>
)
})}
</div> </div>
</div> </div>
) )
} }
function KanbanView({ projectId }) { function KanbanView({ projectId, project }) {
const [allTasks, setAllTasks] = useState([]) const [allTasks, setAllTasks] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') 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(() => { useEffect(() => {
loadTasks() loadTasks()
@@ -237,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) => { const handleDragOver = (e) => {
e.preventDefault() e.preventDefault()
} }
@@ -244,11 +442,22 @@ function KanbanView({ projectId }) {
const handleDrop = async (e, newStatus) => { const handleDrop = async (e, newStatus) => {
e.preventDefault() e.preventDefault()
const taskId = parseInt(e.dataTransfer.getData('taskId')) const taskId = parseInt(e.dataTransfer.getData('taskId'))
const isParent = e.dataTransfer.getData('isParent') === 'true'
if (!taskId) return if (!taskId) return
try { try {
// Update the dragged task
await updateTask(taskId, { status: newStatus }) 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() loadTasks()
} catch (err) { } catch (err) {
alert(`Error: ${err.message}`) alert(`Error: ${err.message}`)
@@ -265,19 +474,39 @@ function KanbanView({ projectId }) {
return ( return (
<div> <div>
<h3 className="text-xl font-semibold text-gray-300 mb-4">Kanban Board</h3> <div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-semibold text-gray-300">Kanban Board (Nested View)</h3>
<div className="flex gap-2">
<button
onClick={handleExpandAll}
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-cyber-darker border border-cyber-orange/30 text-gray-300 rounded hover:border-cyber-orange/60 hover:bg-cyber-dark transition-colors"
>
<ChevronsDown size={16} />
Expand All
</button>
<button
onClick={handleCollapseAll}
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-cyber-darker border border-cyber-orange/30 text-gray-300 rounded hover:border-cyber-orange/60 hover:bg-cyber-dark transition-colors"
>
<ChevronsUp size={16} />
Collapse All
</button>
</div>
</div>
<div className="flex gap-4 overflow-x-auto pb-4"> <div className="flex gap-4 overflow-x-auto pb-4">
{STATUSES.map(status => ( {statusesWithMeta.map(status => (
<KanbanColumn <KanbanColumn
key={status.key} key={status.key}
status={status} status={status}
tasks={allTasks.filter(t => t.status === status.key)}
allTasks={allTasks} allTasks={allTasks}
projectId={projectId} projectId={projectId}
onUpdate={loadTasks} onUpdate={loadTasks}
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={handleDragOver} onDragOver={handleDragOver}
expandedCards={expandedCards}
setExpandedCards={setExpandedCards}
projectStatuses={statuses}
/> />
))} ))}
</div> </div>

View File

@@ -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 (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-cyber-darkest border border-cyber-orange/30 rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex justify-between items-center p-6 border-b border-cyber-orange/20">
<h2 className="text-2xl font-bold text-gray-100">Project Settings</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-200"
>
<X size={24} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
<div className="mb-6">
<h3 className="text-lg font-semibold text-gray-200 mb-2">Project Details</h3>
<div className="space-y-2">
<div>
<span className="text-sm text-gray-400">Name:</span>
<span className="ml-2 text-gray-200">{project.name}</span>
</div>
{project.description && (
<div>
<span className="text-sm text-gray-400">Description:</span>
<span className="ml-2 text-gray-200">{project.description}</span>
</div>
)}
</div>
</div>
<div>
<h3 className="text-lg font-semibold text-gray-200 mb-2">Status Workflow</h3>
<p className="text-sm text-gray-400 mb-4">
Drag to reorder, click to rename. Tasks will appear in Kanban columns in this order.
</p>
{error && (
<div className="mb-4 p-3 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-sm">
{error}
</div>
)}
<div className="space-y-2 mb-4">
{statuses.map((status, index) => (
<div
key={index}
draggable={editingIndex !== index}
onDragStart={() => 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 && (
<GripVertical size={18} className="text-gray-500" />
)}
{editingIndex === index ? (
<>
<input
type="text"
value={editingValue}
onChange={(e) => 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
/>
<button
onClick={handleSaveEdit}
className="text-green-400 hover:text-green-300"
>
<Check size={18} />
</button>
<button
onClick={handleCancelEdit}
className="text-gray-400 hover:text-gray-300"
>
<X size={18} />
</button>
</>
) : (
<>
<button
onClick={() => 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(' ')}
</button>
<span className="text-xs text-gray-500">
{taskCounts[status] || 0} task{taskCounts[status] === 1 ? '' : 's'}
</span>
<button
onClick={() => handleDeleteStatus(index)}
className="text-gray-400 hover:text-red-400"
disabled={statuses.length === 1}
>
<Trash2 size={18} />
</button>
</>
)}
</div>
))}
</div>
<button
onClick={handleAddStatus}
className="flex items-center gap-2 px-4 py-2 text-sm bg-cyber-darker border border-cyber-orange/30 text-gray-300 rounded hover:border-cyber-orange/60 hover:bg-cyber-dark transition-colors"
>
<Plus size={16} />
Add Status
</button>
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 p-6 border-t border-cyber-orange/20">
<button
onClick={onClose}
className="px-4 py-2 text-gray-400 hover:text-gray-200"
>
Cancel
</button>
<button
onClick={handleSave}
className="px-4 py-2 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright font-semibold transition-colors"
>
Save Changes
</button>
</div>
{/* Delete Warning Dialog */}
{deleteWarning && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<div className="bg-cyber-darkest border border-red-500/50 rounded-lg p-6 max-w-md">
<div className="flex items-start gap-3 mb-4">
<AlertTriangle className="text-red-400 flex-shrink-0" size={24} />
<div>
<h3 className="text-lg font-semibold text-gray-100 mb-2">Cannot Delete Status</h3>
<p className="text-sm text-gray-300">
The status "{deleteWarning.status}" has {deleteWarning.count} task{deleteWarning.count === 1 ? '' : 's'}.
Please move or delete those tasks first.
</p>
</div>
</div>
<div className="flex justify-end">
<button
onClick={() => setDeleteWarning(null)}
className="px-4 py-2 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright font-semibold"
>
OK
</button>
</div>
</div>
</div>
)}
</div>
</div>
)
}
export default ProjectSettings

View File

@@ -84,9 +84,10 @@ function SearchBar() {
return return
} }
const debounceMs = parseInt(import.meta.env.VITE_SEARCH_DEBOUNCE_MS || '300')
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
handleSearch(query) handleSearch(query)
}, 300) }, debounceMs)
return () => clearTimeout(timeoutId) return () => clearTimeout(timeoutId)
}, [query, selectedProjects]) }, [query, selectedProjects])

View File

@@ -12,12 +12,22 @@ const FLAG_COLORS = [
{ name: 'pink', label: 'Pink', color: 'bg-pink-500' } { name: 'pink', label: 'Pink', color: 'bg-pink-500' }
] ]
function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) { // 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 [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [tags, setTags] = useState('') const [tags, setTags] = useState('')
const [hours, setHours] = useState('') const [hours, setHours] = useState('')
const [minutes, setMinutes] = useState('') const [minutes, setMinutes] = useState('')
const [flagColor, setFlagColor] = useState(null) 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) => { const handleSubmit = (e) => {
e.preventDefault() e.preventDefault()
@@ -33,9 +43,11 @@ function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) {
const taskData = { const taskData = {
title: title.trim(), title: title.trim(),
description: description.trim() || null,
tags: tagList && tagList.length > 0 ? tagList : null, tags: tagList && tagList.length > 0 ? tagList : null,
estimated_minutes: totalMinutes > 0 ? totalMinutes : null, estimated_minutes: totalMinutes > 0 ? totalMinutes : null,
flag_color: flagColor flag_color: flagColor,
status: status
} }
onSubmit(taskData) onSubmit(taskData)
@@ -56,6 +68,18 @@ function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) {
/> />
</div> </div>
{/* Description */}
<div>
<label className="block text-xs text-gray-400 mb-1">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional task description..."
rows="3"
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 resize-y"
/>
</div>
{/* Tags */} {/* Tags */}
<div> <div>
<label className="block text-xs text-gray-400 mb-1">Tags (comma-separated)</label> <label className="block text-xs text-gray-400 mb-1">Tags (comma-separated)</label>
@@ -96,6 +120,22 @@ function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) {
</div> </div>
</div> </div>
{/* Status */}
<div>
<label className="block text-xs text-gray-400 mb-1">Status</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
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"
>
{statuses.map((s) => (
<option key={s} value={s}>
{formatStatusLabel(s)}
</option>
))}
</select>
</div>
{/* Flag Color */} {/* Flag Color */}
<div> <div>
<label className="block text-xs text-gray-400 mb-1">Flag Color</label> <label className="block text-xs text-gray-400 mb-1">Flag Color</label>

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react'
import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo } from 'lucide-react' import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo, FileText } from 'lucide-react'
import { updateTask } from '../utils/api' import { updateTask } from '../utils/api'
const FLAG_COLORS = [ const FLAG_COLORS = [
@@ -12,16 +12,26 @@ const FLAG_COLORS = [
{ name: 'pink', color: 'bg-pink-500' } { name: 'pink', color: 'bg-pink-500' }
] ]
const STATUSES = [ // Helper to format status label
{ key: 'backlog', label: 'Backlog', color: 'text-gray-400' }, const formatStatusLabel = (status) => {
{ key: 'in_progress', label: 'In Progress', color: 'text-blue-400' }, return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
{ key: 'blocked', label: 'Blocked', color: 'text-red-400' }, }
{ key: 'done', label: 'Done', color: 'text-green-400' }
]
function TaskMenu({ task, onUpdate, onDelete, onEdit }) { // 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 [isOpen, setIsOpen] = useState(false)
const [showTimeEdit, setShowTimeEdit] = useState(false) const [showTimeEdit, setShowTimeEdit] = useState(false)
const [showDescriptionEdit, setShowDescriptionEdit] = useState(false)
const [showTagsEdit, setShowTagsEdit] = useState(false) const [showTagsEdit, setShowTagsEdit] = useState(false)
const [showFlagEdit, setShowFlagEdit] = useState(false) const [showFlagEdit, setShowFlagEdit] = useState(false)
const [showStatusEdit, setShowStatusEdit] = useState(false) const [showStatusEdit, setShowStatusEdit] = useState(false)
@@ -32,6 +42,7 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
const [editHours, setEditHours] = useState(initialHours) const [editHours, setEditHours] = useState(initialHours)
const [editMinutes, setEditMinutes] = useState(initialMinutes) const [editMinutes, setEditMinutes] = useState(initialMinutes)
const [editDescription, setEditDescription] = useState(task.description || '')
const [editTags, setEditTags] = useState(task.tags ? task.tags.join(', ') : '') const [editTags, setEditTags] = useState(task.tags ? task.tags.join(', ') : '')
const menuRef = useRef(null) const menuRef = useRef(null)
@@ -40,6 +51,7 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
if (menuRef.current && !menuRef.current.contains(event.target)) { if (menuRef.current && !menuRef.current.contains(event.target)) {
setIsOpen(false) setIsOpen(false)
setShowTimeEdit(false) setShowTimeEdit(false)
setShowDescriptionEdit(false)
setShowTagsEdit(false) setShowTagsEdit(false)
setShowFlagEdit(false) setShowFlagEdit(false)
setShowStatusEdit(false) setShowStatusEdit(false)
@@ -65,6 +77,18 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
} }
} }
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 () => { const handleUpdateTags = async () => {
try { try {
const tags = editTags const tags = editTags
@@ -184,6 +208,52 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
</button> </button>
)} )}
{/* Description Edit */}
{showDescriptionEdit ? (
<div className="p-3 border-b border-cyber-orange/20">
<div className="flex items-center gap-2 mb-2">
<FileText size={14} className="text-cyber-orange" />
<span className="text-sm text-gray-300">Description</span>
</div>
<div className="space-y-2">
<textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Task description..."
rows="4"
className="w-full px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange resize-y"
autoFocus
onClick={(e) => e.stopPropagation()}
/>
<div className="flex gap-2">
<button
onClick={handleUpdateDescription}
className="flex-1 px-2 py-1 text-sm bg-cyber-orange/20 text-cyber-orange rounded hover:bg-cyber-orange/30"
>
Save
</button>
<button
onClick={() => setShowDescriptionEdit(false)}
className="px-2 py-1 text-sm text-gray-400 hover:text-gray-300"
>
Cancel
</button>
</div>
</div>
</div>
) : (
<button
onClick={(e) => {
e.stopPropagation()
setShowDescriptionEdit(true)
}}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-gray-300 text-sm"
>
<FileText size={14} />
<span>Edit Description</span>
</button>
)}
{/* Tags Edit */} {/* Tags Edit */}
{showTagsEdit ? ( {showTagsEdit ? (
<div className="p-3 border-b border-cyber-orange/20"> <div className="p-3 border-b border-cyber-orange/20">
@@ -273,17 +343,17 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit }) {
<span className="text-sm text-gray-300">Change Status</span> <span className="text-sm text-gray-300">Change Status</span>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
{STATUSES.map(({ key, label, color }) => ( {(projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']).map((status) => (
<button <button
key={key} key={status}
onClick={() => handleUpdateStatus(key)} onClick={() => handleUpdateStatus(status)}
className={`w-full text-left px-2 py-1.5 rounded text-sm ${ className={`w-full text-left px-2 py-1.5 rounded text-sm ${
task.status === key task.status === status
? 'bg-cyber-orange/20 border border-cyber-orange/40' ? 'bg-cyber-orange/20 border border-cyber-orange/40'
: 'hover:bg-cyber-darker border border-transparent' : 'hover:bg-cyber-darker border border-transparent'
} ${color} transition-all`} } ${getStatusTextColor(status)} transition-all`}
> >
{label} {task.status === key && '✓'} {formatStatusLabel(status)} {task.status === status && '✓'}
</button> </button>
))} ))}
</div> </div>

View File

@@ -18,18 +18,20 @@ import { formatTimeWithTotal } from '../utils/format'
import TaskMenu from './TaskMenu' import TaskMenu from './TaskMenu'
import TaskForm from './TaskForm' import TaskForm from './TaskForm'
const STATUS_COLORS = { // Helper to format status label
backlog: 'text-gray-400', const formatStatusLabel = (status) => {
in_progress: 'text-blue-400', return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
blocked: 'text-red-400',
done: 'text-green-400'
} }
const STATUS_LABELS = { // Helper to get status color
backlog: 'Backlog', const getStatusColor = (status) => {
in_progress: 'In Progress', const lowerStatus = status.toLowerCase()
blocked: 'Blocked', if (lowerStatus === 'backlog') return 'text-gray-400'
done: 'Done' 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
} }
const FLAG_COLORS = { const FLAG_COLORS = {
@@ -42,7 +44,7 @@ const FLAG_COLORS = {
pink: 'bg-pink-500' pink: 'bg-pink-500'
} }
function TaskNode({ task, projectId, onUpdate, level = 0 }) { function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses }) {
const [isExpanded, setIsExpanded] = useState(true) const [isExpanded, setIsExpanded] = useState(true)
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [editTitle, setEditTitle] = useState(task.title) const [editTitle, setEditTitle] = useState(task.title)
@@ -80,7 +82,8 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
project_id: parseInt(projectId), project_id: parseInt(projectId),
parent_task_id: task.id, parent_task_id: task.id,
title: taskData.title, title: taskData.title,
status: 'backlog', description: taskData.description,
status: taskData.status,
tags: taskData.tags, tags: taskData.tags,
estimated_minutes: taskData.estimated_minutes, estimated_minutes: taskData.estimated_minutes,
flag_color: taskData.flag_color flag_color: taskData.flag_color
@@ -125,10 +128,9 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
onChange={(e) => setEditStatus(e.target.value)} 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" 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"
> >
<option value="backlog">Backlog</option> {(projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']).map(status => (
<option value="in_progress">In Progress</option> <option key={status} value={status}>{formatStatusLabel(status)}</option>
<option value="blocked">Blocked</option> ))}
<option value="done">Done</option>
</select> </select>
<button <button
onClick={handleSave} onClick={handleSave}
@@ -156,8 +158,8 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
<Flag size={14} className={`${FLAG_COLORS[task.flag_color].replace('bg-', 'text-')}`} fill="currentColor" /> <Flag size={14} className={`${FLAG_COLORS[task.flag_color].replace('bg-', 'text-')}`} fill="currentColor" />
)} )}
<span className="text-gray-200">{task.title}</span> <span className="text-gray-200">{task.title}</span>
<span className={`ml-2 text-xs ${STATUS_COLORS[task.status]}`}> <span className={`ml-2 text-xs ${getStatusColor(task.status)}`}>
{STATUS_LABELS[task.status]} {formatStatusLabel(task.status)}
</span> </span>
</div> </div>
@@ -166,7 +168,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
<div className="flex items-center gap-3 mt-1"> <div className="flex items-center gap-3 mt-1">
{/* Time estimate */} {/* Time estimate */}
{formatTimeWithTotal(task) && ( {formatTimeWithTotal(task) && (
<div className="flex items-center gap-1 text-xs text-gray-500"> <div className={`flex items-center gap-1 text-xs text-gray-500 ${task.status === 'done' ? 'line-through' : ''}`}>
<Clock size={12} /> <Clock size={12} />
<span>{formatTimeWithTotal(task)}</span> <span>{formatTimeWithTotal(task)}</span>
</div> </div>
@@ -187,6 +189,13 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
)} )}
</div> </div>
)} )}
{/* Description */}
{task.description && (
<div className="mt-2 text-xs text-gray-400 italic">
{task.description}
</div>
)}
</div> </div>
{/* Actions */} {/* Actions */}
@@ -203,6 +212,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
onUpdate={onUpdate} onUpdate={onUpdate}
onDelete={handleDelete} onDelete={handleDelete}
onEdit={() => setIsEditing(true)} onEdit={() => setIsEditing(true)}
projectStatuses={projectStatuses}
/> />
</div> </div>
</> </>
@@ -216,6 +226,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
onSubmit={handleAddSubtask} onSubmit={handleAddSubtask}
onCancel={() => setShowAddSubtask(false)} onCancel={() => setShowAddSubtask(false)}
submitLabel="Add Subtask" submitLabel="Add Subtask"
projectStatuses={projectStatuses}
/> />
</div> </div>
)} )}
@@ -230,6 +241,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
projectId={projectId} projectId={projectId}
onUpdate={onUpdate} onUpdate={onUpdate}
level={level + 1} level={level + 1}
projectStatuses={projectStatuses}
/> />
))} ))}
</div> </div>
@@ -238,7 +250,8 @@ 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 [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -266,7 +279,8 @@ function TreeView({ projectId }) {
project_id: parseInt(projectId), project_id: parseInt(projectId),
parent_task_id: null, parent_task_id: null,
title: taskData.title, title: taskData.title,
status: 'backlog', description: taskData.description,
status: taskData.status,
tags: taskData.tags, tags: taskData.tags,
estimated_minutes: taskData.estimated_minutes, estimated_minutes: taskData.estimated_minutes,
flag_color: taskData.flag_color flag_color: taskData.flag_color
@@ -305,6 +319,7 @@ function TreeView({ projectId }) {
onSubmit={handleAddRootTask} onSubmit={handleAddRootTask}
onCancel={() => setShowAddRoot(false)} onCancel={() => setShowAddRoot(false)}
submitLabel="Add Task" submitLabel="Add Task"
projectStatuses={projectStatuses}
/> />
</div> </div>
)} )}
@@ -322,6 +337,7 @@ function TreeView({ projectId }) {
task={task} task={task}
projectId={projectId} projectId={projectId}
onUpdate={loadTasks} onUpdate={loadTasks}
projectStatuses={projectStatuses}
/> />
))} ))}
</div> </div>

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Plus, Upload, Trash2 } from 'lucide-react' import { Plus, Upload, Trash2, Archive, ArchiveRestore } from 'lucide-react'
import { getProjects, createProject, deleteProject, importJSON } from '../utils/api' import { getProjects, createProject, deleteProject, importJSON, archiveProject, unarchiveProject } from '../utils/api'
function ProjectList() { function ProjectList() {
const [projects, setProjects] = useState([]) const [projects, setProjects] = useState([])
@@ -12,15 +12,22 @@ function ProjectList() {
const [newProjectDesc, setNewProjectDesc] = useState('') const [newProjectDesc, setNewProjectDesc] = useState('')
const [importJSON_Text, setImportJSONText] = useState('') const [importJSON_Text, setImportJSONText] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [activeTab, setActiveTab] = useState('active') // 'active', 'archived', 'all'
const navigate = useNavigate() const navigate = useNavigate()
useEffect(() => { useEffect(() => {
loadProjects() loadProjects()
}, []) }, [activeTab])
const loadProjects = async () => { const loadProjects = async () => {
try { try {
const data = await getProjects() setLoading(true)
let archivedFilter = null
if (activeTab === 'active') archivedFilter = false
if (activeTab === 'archived') archivedFilter = true
// 'all' tab uses null to get all projects
const data = await getProjects(archivedFilter)
setProjects(data) setProjects(data)
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
@@ -72,13 +79,33 @@ function ProjectList() {
} }
} }
const handleArchiveProject = async (projectId, e) => {
e.stopPropagation()
try {
await archiveProject(projectId)
setProjects(projects.filter(p => p.id !== projectId))
} catch (err) {
setError(err.message)
}
}
const handleUnarchiveProject = async (projectId, e) => {
e.stopPropagation()
try {
await unarchiveProject(projectId)
setProjects(projects.filter(p => p.id !== projectId))
} catch (err) {
setError(err.message)
}
}
if (loading) { if (loading) {
return <div className="text-center text-gray-400 py-12">Loading...</div> return <div className="text-center text-gray-400 py-12">Loading...</div>
} }
return ( return (
<div> <div>
<div className="flex justify-between items-center mb-8"> <div className="flex justify-between items-center mb-6">
<h2 className="text-3xl font-bold text-gray-100">Projects</h2> <h2 className="text-3xl font-bold text-gray-100">Projects</h2>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
@@ -98,6 +125,40 @@ function ProjectList() {
</div> </div>
</div> </div>
{/* Tabs */}
<div className="flex gap-1 mb-6 border-b border-cyber-orange/20">
<button
onClick={() => setActiveTab('active')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'active'
? 'text-cyber-orange border-b-2 border-cyber-orange'
: 'text-gray-400 hover:text-gray-200'
}`}
>
Active
</button>
<button
onClick={() => setActiveTab('archived')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'archived'
? 'text-cyber-orange border-b-2 border-cyber-orange'
: 'text-gray-400 hover:text-gray-200'
}`}
>
Archived
</button>
<button
onClick={() => setActiveTab('all')}
className={`px-4 py-2 font-medium transition-colors ${
activeTab === 'all'
? 'text-cyber-orange border-b-2 border-cyber-orange'
: 'text-gray-400 hover:text-gray-200'
}`}
>
All
</button>
</div>
{error && ( {error && (
<div className="mb-4 p-4 bg-red-900/30 border border-red-500/50 rounded text-red-300"> <div className="mb-4 p-4 bg-red-900/30 border border-red-500/50 rounded text-red-300">
{error} {error}
@@ -106,8 +167,14 @@ function ProjectList() {
{projects.length === 0 ? ( {projects.length === 0 ? (
<div className="text-center py-16 text-gray-500"> <div className="text-center py-16 text-gray-500">
<p className="text-xl mb-2">No projects yet</p> <p className="text-xl mb-2">
<p className="text-sm">Create a new project or import from JSON</p> {activeTab === 'archived' ? 'No archived projects' : 'No projects yet'}
</p>
<p className="text-sm">
{activeTab === 'archived'
? 'Archive projects to keep them out of your active workspace'
: 'Create a new project or import from JSON'}
</p>
</div> </div>
) : ( ) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
@@ -115,18 +182,45 @@ function ProjectList() {
<div <div
key={project.id} key={project.id}
onClick={() => navigate(`/project/${project.id}`)} onClick={() => navigate(`/project/${project.id}`)}
className="p-6 bg-cyber-darkest border border-cyber-orange/30 rounded-lg hover:border-cyber-orange hover:shadow-cyber transition-all cursor-pointer group" className={`p-6 bg-cyber-darkest border rounded-lg hover:border-cyber-orange hover:shadow-cyber transition-all cursor-pointer group ${
project.is_archived
? 'border-gray-700 opacity-75'
: 'border-cyber-orange/30'
}`}
> >
<div className="flex justify-between items-start mb-2"> <div className="flex justify-between items-start mb-2">
<h3 className="text-xl font-semibold text-gray-100 group-hover:text-cyber-orange transition-colors"> <h3 className="text-xl font-semibold text-gray-100 group-hover:text-cyber-orange transition-colors">
{project.name} {project.name}
{project.is_archived && (
<span className="ml-2 text-xs text-gray-500">(archived)</span>
)}
</h3> </h3>
<button <div className="flex gap-2">
onClick={(e) => handleDeleteProject(project.id, e)} {project.is_archived ? (
className="text-gray-600 hover:text-red-400 transition-colors" <button
> onClick={(e) => handleUnarchiveProject(project.id, e)}
<Trash2 size={18} /> className="text-gray-600 hover:text-cyber-orange transition-colors"
</button> title="Unarchive project"
>
<ArchiveRestore size={18} />
</button>
) : (
<button
onClick={(e) => handleArchiveProject(project.id, e)}
className="text-gray-600 hover:text-yellow-400 transition-colors"
title="Archive project"
>
<Archive size={18} />
</button>
)}
<button
onClick={(e) => handleDeleteProject(project.id, e)}
className="text-gray-600 hover:text-red-400 transition-colors"
title="Delete project"
>
<Trash2 size={18} />
</button>
</div>
</div> </div>
{project.description && ( {project.description && (
<p className="text-gray-400 text-sm line-clamp-2">{project.description}</p> <p className="text-gray-400 text-sm line-clamp-2">{project.description}</p>

View File

@@ -1,9 +1,10 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom' 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 { getProject } from '../utils/api'
import TreeView from '../components/TreeView' import TreeView from '../components/TreeView'
import KanbanView from '../components/KanbanView' import KanbanView from '../components/KanbanView'
import ProjectSettings from '../components/ProjectSettings'
function ProjectView() { function ProjectView() {
const { projectId } = useParams() const { projectId } = useParams()
@@ -12,6 +13,7 @@ function ProjectView() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [view, setView] = useState('tree') // 'tree' or 'kanban' const [view, setView] = useState('tree') // 'tree' or 'kanban'
const [showSettings, setShowSettings] = useState(false)
useEffect(() => { useEffect(() => {
loadProject() loadProject()
@@ -65,37 +67,55 @@ function ProjectView() {
)} )}
</div> </div>
<div className="flex gap-2 bg-cyber-darkest rounded-lg p-1 border border-cyber-orange/30"> <div className="flex gap-3 items-center">
<div className="flex gap-2 bg-cyber-darkest rounded-lg p-1 border border-cyber-orange/30">
<button
onClick={() => 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'
}`}
>
<LayoutList size={18} />
Tree View
</button>
<button
onClick={() => 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'
}`}
>
<LayoutGrid size={18} />
Kanban
</button>
</div>
<button <button
onClick={() => setView('tree')} onClick={() => setShowSettings(true)}
className={`flex items-center gap-2 px-4 py-2 rounded transition-colors ${ className="p-2 text-gray-400 hover:text-cyber-orange transition-colors border border-cyber-orange/30 rounded-lg hover:border-cyber-orange/60"
view === 'tree' title="Project Settings"
? 'bg-cyber-orange text-cyber-darkest font-semibold'
: 'text-gray-400 hover:text-gray-200'
}`}
> >
<LayoutList size={18} /> <Settings size={20} />
Tree View
</button>
<button
onClick={() => 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'
}`}
>
<LayoutGrid size={18} />
Kanban
</button> </button>
</div> </div>
</div> </div>
</div> </div>
{view === 'tree' ? ( {view === 'tree' ? (
<TreeView projectId={projectId} /> <TreeView projectId={projectId} project={project} />
) : ( ) : (
<KanbanView projectId={projectId} /> <KanbanView projectId={projectId} project={project} />
)}
{showSettings && (
<ProjectSettings
project={project}
onClose={() => setShowSettings(false)}
onUpdate={loadProject}
/>
)} )}
</div> </div>
) )

View File

@@ -1,4 +1,4 @@
const API_BASE = '/api'; const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api';
async function fetchAPI(endpoint, options = {}) { async function fetchAPI(endpoint, options = {}) {
const response = await fetch(`${API_BASE}${endpoint}`, { const response = await fetch(`${API_BASE}${endpoint}`, {
@@ -22,7 +22,14 @@ async function fetchAPI(endpoint, options = {}) {
} }
// Projects // Projects
export const getProjects = () => fetchAPI('/projects'); export const getProjects = (archived = null) => {
const params = new URLSearchParams();
if (archived !== null) {
params.append('archived', archived);
}
const queryString = params.toString();
return fetchAPI(`/projects${queryString ? `?${queryString}` : ''}`);
};
export const getProject = (id) => fetchAPI(`/projects/${id}`); export const getProject = (id) => fetchAPI(`/projects/${id}`);
export const createProject = (data) => fetchAPI('/projects', { export const createProject = (data) => fetchAPI('/projects', {
method: 'POST', method: 'POST',
@@ -33,6 +40,8 @@ export const updateProject = (id, data) => fetchAPI(`/projects/${id}`, {
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
export const deleteProject = (id) => fetchAPI(`/projects/${id}`, { method: 'DELETE' }); export const deleteProject = (id) => fetchAPI(`/projects/${id}`, { method: 'DELETE' });
export const archiveProject = (id) => updateProject(id, { is_archived: true });
export const unarchiveProject = (id) => updateProject(id, { is_archived: false });
// Tasks // Tasks
export const getProjectTasks = (projectId) => fetchAPI(`/projects/${projectId}/tasks`); export const getProjectTasks = (projectId) => fetchAPI(`/projects/${projectId}/tasks`);

View File

@@ -1,15 +1,19 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig(({ mode }) => {
plugins: [react()], const env = loadEnv(mode, process.cwd(), '')
server: {
host: '0.0.0.0', return {
port: 5173, plugins: [react()],
proxy: { server: {
'/api': { host: '0.0.0.0',
target: 'http://localhost:8000', port: parseInt(env.VITE_DEV_PORT || '5173'),
changeOrigin: true, proxy: {
'/api': {
target: env.VITE_API_URL || 'http://localhost:8000',
changeOrigin: true,
}
} }
} }
} }

24
import-template.json Normal file
View File

@@ -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": []
}
]
}
]
}