Compare commits

..

20 Commits

Author SHA1 Message Date
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
serversdwn
342f8e8d76 Merge pull request #4 from serversdwn/claude/nested-todo-app-mvp-014vUjLpD8wNjkMhLMXS6Kd5
0.1.3 Tweaks.
2025-11-20 04:53:13 -05:00
Claude
718e5acbe2 Exclude 'done' tasks from parent time estimates
When a leaf task is marked as 'done', it no longer contributes to its parent's
time estimate. This shows remaining work rather than total estimated work.

Example:
- Parent with 2 subtasks (30m each) shows 1h
- Mark one subtask as done → parent now shows 30m
- Mark both done → parent shows 0m (or own estimate if set)

This provides accurate tracking of remaining time across the task hierarchy.
2025-11-20 09:46:17 +00:00
Claude
c9555737d8 Add enhanced task creation forms and leaf-based time calculation
Features:

1. Enhanced Task Creation Forms:
   - New TaskForm component with all metadata fields
   - Title, tags (comma-separated), time estimate (hours + minutes), flag color
   - Used in TreeView (root tasks and subtasks) and KanbanView (all columns)
   - Replace simple title-only inputs with full metadata forms

2. Time Format Changes:
   - Display: "1h 30m" instead of "1.5h"
   - Input: Separate hours and minutes fields
   - Storage: Still integer minutes in backend
   - Updated formatTime() utility
   - Updated TaskMenu time editor with hours/minutes inputs

3. Leaf-Based Time Calculation:
   - Leaf tasks (no subtasks): Show user-entered estimate
   - Parent tasks (has subtasks): Show sum of all descendant LEAF tasks
   - Exception: Parent with no leaf estimates shows own estimate as fallback
   - New functions: calculateLeafTime(), calculateLeafTimeFlat()
   - Replaces old aggregation that summed all tasks including parents

This allows accurate project planning where parent estimates are calculated from leaf tasks,
preventing double-counting when both parent and children have estimates.
2025-11-20 09:37:16 +00:00
Claude
3f309163b6 Add status change dropdown and aggregated time estimates
Features:
- Add "Change Status" option to TaskMenu dropdown
  - Allows changing task status (backlog/in progress/blocked/done) from tree view
  - Shows current status with checkmark
  - No longer need to switch to Kanban view to change status

- Implement recursive time aggregation for subtasks
  - Tasks now show total time including all descendant subtasks
  - Display format varies based on estimates:
    - "1.5h" - only task's own estimate
    - "(2h from subtasks)" - only subtask estimates
    - "1h (3h total)" - both own and subtask estimates
  - Works in both TreeView (hierarchical) and KanbanView (flat list)
  - New utility functions: calculateTotalTime, calculateTotalTimeFlat, formatTimeWithTotal

This allows better project planning by showing total time investment for tasks with subtasks.
2025-11-20 09:01:45 +00:00
Claude
444f2744b3 Fix missing Clock icon import causing crash with time estimates 2025-11-20 08:45:21 +00:00
Claude
b395ee8103 Add v0.1.3 UI features: metadata display, task menu, and search
- Display time estimates, tags, and flag colors in TreeView and KanbanView
- Add TaskMenu component with three-dot dropdown for editing metadata
  - Edit time estimates (stored as minutes)
  - Edit tags (comma-separated input)
  - Set flag colors (red, orange, yellow, green, blue, purple, pink)
- Add SearchBar component in header
  - Real-time search with 300ms debounce
  - Optional project filtering
  - Click results to navigate to project
- Integrate TaskMenu into both TreeView and KanbanView
- Format time display: "30m" for <60 min, "1.5h" for >=60 min
2025-11-20 08:23:07 +00:00
Claude
70b64d276f Merge branch 'main' of http://127.0.0.1:56401/git/serversdwn/tesseract 2025-11-20 07:12:05 +00:00
Claude
fc43241833 Merge branch 'mvp2' 2025-11-20 05:53:18 +00:00
30 changed files with 2646 additions and 321 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")
try:
return crud.get_tasks_by_status(db, project_id, status) 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")
try:
return crud.create_task(db, task) 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"""
try:
db_task = crud.update_task(db, task_id, task) db_task = crud.update_task(db, task_id, task)
if not db_task: if not db_task:
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
return db_task 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

@@ -1,6 +1,7 @@
import { Routes, Route } from 'react-router-dom' import { Routes, Route } from 'react-router-dom'
import ProjectList from './pages/ProjectList' import ProjectList from './pages/ProjectList'
import ProjectView from './pages/ProjectView' import ProjectView from './pages/ProjectView'
import SearchBar from './components/SearchBar'
function App() { function App() {
return ( return (
@@ -10,11 +11,12 @@ 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 />
</div> </div>
</div> </div>
</header> </header>

View File

@@ -1,27 +1,87 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Plus, Edit2, Trash2, Check, X } from 'lucide-react' import { Plus, Check, X, Flag, Clock, ChevronDown, ChevronRight, ChevronsDown, ChevronsUp } from 'lucide-react'
import { import {
getProjectTasks, getProjectTasks,
createTask, createTask,
updateTask, updateTask,
deleteTask deleteTask
} from '../utils/api' } from '../utils/api'
import { formatTimeWithTotal } from '../utils/format'
import TaskMenu from './TaskMenu'
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' }
]
function TaskCard({ task, allTasks, onUpdate, onDragStart }) { // Helper to get status color based on common patterns
const getStatusColor = (status) => {
const lowerStatus = status.toLowerCase()
if (lowerStatus === 'backlog') return 'border-gray-600'
if (lowerStatus === 'in_progress' || lowerStatus.includes('progress')) return 'border-blue-500'
if (lowerStatus === 'on_hold' || lowerStatus.includes('hold') || lowerStatus.includes('waiting')) return 'border-yellow-500'
if (lowerStatus === 'done' || lowerStatus.includes('complete')) return 'border-green-500'
if (lowerStatus.includes('blocked')) return 'border-red-500'
return 'border-purple-500' // default for custom statuses
}
const FLAG_COLORS = {
red: 'bg-red-500',
orange: 'bg-orange-500',
yellow: 'bg-yellow-500',
green: 'bg-green-500',
blue: 'bg-blue-500',
purple: 'bg-purple-500',
pink: 'bg-pink-500'
}
// Helper function to get all descendant tasks recursively
function getAllDescendants(taskId, allTasks) {
const children = allTasks.filter(t => t.parent_task_id === taskId)
let descendants = [...children]
for (const child of children) {
descendants = descendants.concat(getAllDescendants(child.id, allTasks))
}
return descendants
}
// Helper function to get all descendant tasks of a parent in a specific status
function getDescendantsInStatus(taskId, allTasks, status) {
const children = allTasks.filter(t => t.parent_task_id === taskId)
let descendants = []
for (const child of children) {
if (child.status === status) {
descendants.push(child)
}
// Recursively get descendants
descendants = descendants.concat(getDescendantsInStatus(child.id, allTasks, status))
}
return descendants
}
// Helper function to check if a task has any descendants in a status
function hasDescendantsInStatus(taskId, allTasks, status) {
return getDescendantsInStatus(taskId, allTasks, status).length > 0
}
function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatus, expandedCards, setExpandedCards, projectStatuses, projectId }) {
const [isEditing, setIsEditing] = useState(false) const [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 {
@@ -43,11 +103,40 @@ 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 className="mb-2">
<div <div
draggable={!isEditing} draggable={!isEditing}
onDragStart={(e) => onDragStart(e, task)} onDragStart={(e) => onDragStart(e, task, isParent)}
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" className={`${
isParent
? 'bg-cyber-darker border-2 border-cyber-orange/50'
: 'bg-cyber-darkest border border-cyber-orange/30'
} rounded-lg p-3 cursor-move hover:border-cyber-orange/60 transition-all group`}
> >
{isEditing ? ( {isEditing ? (
<div className="flex gap-2"> <div className="flex gap-2">
@@ -78,50 +167,145 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
<> <>
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div className="flex-1"> <div className="flex-1">
<div className="text-gray-200 text-sm">{task.title}</div> <div className="flex items-center gap-2">
{parentTask && ( {/* 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"> <div className="text-xs text-gray-500 mt-1">
subtask of: <span className="text-cyber-orange">{parentTask.title}</span> {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>
)} )}
</div> </div>
)}
{/* Description */}
{task.description && (
<div className="mt-2 text-xs text-gray-400 italic">
{task.description}
</div>
)}
</div>
</div>
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button <button
onClick={() => setIsEditing(true)} onClick={() => setShowAddSubtask(true)}
className="text-gray-400 hover:text-gray-200" className="text-cyber-orange hover:text-cyber-orange-bright p-1"
title="Add subtask"
> >
<Edit2 size={14} /> <Plus size={14} />
</button>
<button
onClick={handleDelete}
className="text-gray-600 hover:text-red-400"
>
<Trash2 size={14} />
</button> </button>
<TaskMenu
task={task}
onUpdate={onUpdate}
onDelete={handleDelete}
onEdit={() => setIsEditing(true)}
projectStatuses={projectStatuses}
/>
</div> </div>
</div> </div>
</> </>
)} )}
</div> </div>
{/* Add Subtask Form */}
{showAddSubtask && (
<div className="ml-6 mt-2">
<TaskForm
onSubmit={handleAddSubtask}
onCancel={() => setShowAddSubtask(false)}
submitLabel="Add Subtask"
projectStatuses={projectStatuses}
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>
) )
} }
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 [newTaskTitle, setNewTaskTitle] = useState('')
const handleAddTask = async (e) => {
e.preventDefault()
if (!newTaskTitle.trim()) return
const handleAddTask = async (taskData) => {
try { try {
await createTask({ await createTask({
project_id: parseInt(projectId), project_id: parseInt(projectId),
parent_task_id: null, parent_task_id: null,
title: newTaskTitle, title: taskData.title,
status: status.key description: taskData.description,
status: taskData.status,
tags: taskData.tags,
estimated_minutes: taskData.estimated_minutes,
flag_color: taskData.flag_color
}) })
setNewTaskTitle('')
setShowAddTask(false) setShowAddTask(false)
onUpdate() onUpdate()
} catch (err) { } catch (err) {
@@ -129,16 +313,37 @@ function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, on
} }
} }
// Get tasks to display in this column:
// 1. All leaf tasks (no children) with this status
// 2. All parent tasks that have at least one descendant with this status
const leafTasks = allTasks.filter(t => {
const hasChildren = allTasks.some(child => child.parent_task_id === t.id)
return !hasChildren && t.status === status.key
})
const parentTasks = allTasks.filter(t => {
const hasChildren = allTasks.some(child => child.parent_task_id === t.id)
return hasChildren && hasDescendantsInStatus(t.id, allTasks, status.key)
})
// Only show root-level parents (not nested parents)
const rootParents = parentTasks.filter(t => !t.parent_task_id)
// Only show root-level leaf tasks (leaf tasks without parents)
const rootLeafTasks = leafTasks.filter(t => !t.parent_task_id)
const displayTasks = [...rootParents, ...rootLeafTasks]
return ( 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)}
@@ -150,55 +355,56 @@ function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, on
{showAddTask && ( {showAddTask && (
<div className="mb-3"> <div className="mb-3">
<form onSubmit={handleAddTask}> <TaskForm
<input onSubmit={handleAddTask}
type="text" onCancel={() => setShowAddTask(false)}
value={newTaskTitle} submitLabel="Add Task"
onChange={(e) => setNewTaskTitle(e.target.value)} projectStatuses={projectStatuses}
placeholder="Task title..." defaultStatus={status.key}
className="w-full px-2 py-2 bg-cyber-darkest border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange mb-2"
autoFocus
/> />
<div className="flex gap-2">
<button
type="submit"
className="px-3 py-1 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright text-sm font-semibold"
>
Add
</button>
<button
type="button"
onClick={() => setShowAddTask(false)}
className="px-3 py-1 text-gray-400 hover:text-gray-200 text-sm"
>
Cancel
</button>
</div>
</form>
</div> </div>
)} )}
<div className="space-y-2"> <div className="space-y-2">
{tasks.map(task => ( {displayTasks.map(task => {
const isParent = allTasks.some(t => t.parent_task_id === task.id)
return (
<TaskCard <TaskCard
key={task.id} key={task.id}
task={task} task={task}
allTasks={allTasks} allTasks={allTasks}
onUpdate={onUpdate} onUpdate={onUpdate}
onDragStart={(e, task) => { onDragStart={(e, task, isParent) => {
e.dataTransfer.setData('taskId', task.id.toString()) 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()
@@ -216,6 +422,19 @@ function KanbanView({ projectId }) {
} }
} }
const handleExpandAll = () => {
const parentTasks = allTasks.filter(t => allTasks.some(child => child.parent_task_id === t.id))
const newExpandedState = {}
parentTasks.forEach(task => {
newExpandedState[task.id] = true
})
setExpandedCards(newExpandedState)
}
const handleCollapseAll = () => {
setExpandedCards({})
}
const handleDragOver = (e) => { const handleDragOver = (e) => {
e.preventDefault() e.preventDefault()
} }
@@ -223,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}`)
@@ -244,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

@@ -0,0 +1,263 @@
import { useState, useEffect, useRef } from 'react'
import { Search, X, Flag } from 'lucide-react'
import { searchTasks, getProjects } from '../utils/api'
import { formatTime } from '../utils/format'
import { useNavigate } from 'react-router-dom'
const FLAG_COLORS = {
red: 'text-red-500',
orange: 'text-orange-500',
yellow: 'text-yellow-500',
green: 'text-green-500',
blue: 'text-blue-500',
purple: 'text-purple-500',
pink: 'text-pink-500'
}
function SearchBar() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [projects, setProjects] = useState([])
const [selectedProjects, setSelectedProjects] = useState([])
const [isSearching, setIsSearching] = useState(false)
const [showResults, setShowResults] = useState(false)
const [showProjectFilter, setShowProjectFilter] = useState(false)
const searchRef = useRef(null)
const navigate = useNavigate()
useEffect(() => {
loadProjects()
}, [])
useEffect(() => {
function handleClickOutside(event) {
if (searchRef.current && !searchRef.current.contains(event.target)) {
setShowResults(false)
setShowProjectFilter(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const loadProjects = async () => {
try {
const data = await getProjects()
setProjects(data)
} catch (err) {
console.error('Failed to load projects:', err)
}
}
const handleSearch = async (searchQuery) => {
if (!searchQuery.trim()) {
setResults([])
setShowResults(false)
return
}
setIsSearching(true)
try {
const projectIds = selectedProjects.length > 0 ? selectedProjects : null
const data = await searchTasks(searchQuery, projectIds)
setResults(data)
setShowResults(true)
} catch (err) {
console.error('Search failed:', err)
setResults([])
} finally {
setIsSearching(false)
}
}
const handleQueryChange = (e) => {
const newQuery = e.target.value
setQuery(newQuery)
}
// Debounced search effect
useEffect(() => {
if (!query.trim()) {
setResults([])
setShowResults(false)
return
}
const debounceMs = parseInt(import.meta.env.VITE_SEARCH_DEBOUNCE_MS || '300')
const timeoutId = setTimeout(() => {
handleSearch(query)
}, debounceMs)
return () => clearTimeout(timeoutId)
}, [query, selectedProjects])
const toggleProjectFilter = (projectId) => {
setSelectedProjects(prev => {
if (prev.includes(projectId)) {
return prev.filter(id => id !== projectId)
} else {
return [...prev, projectId]
}
})
}
const handleTaskClick = (task) => {
navigate(`/project/${task.project_id}`)
setShowResults(false)
setQuery('')
}
const clearSearch = () => {
setQuery('')
setResults([])
setShowResults(false)
}
return (
<div className="relative" ref={searchRef}>
<div className="flex items-center gap-2">
{/* Search Input */}
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
<input
type="text"
value={query}
onChange={handleQueryChange}
onFocus={() => query && setShowResults(true)}
placeholder="Search tasks..."
className="w-64 pl-9 pr-8 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange placeholder-gray-500"
/>
{query && (
<button
onClick={clearSearch}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300"
>
<X size={14} />
</button>
)}
</div>
{/* Project Filter Button */}
{projects.length > 1 && (
<button
onClick={() => setShowProjectFilter(!showProjectFilter)}
className={`px-3 py-2 text-sm rounded border ${
selectedProjects.length > 0
? 'bg-cyber-orange/20 border-cyber-orange text-cyber-orange'
: 'bg-cyber-darker border-cyber-orange/30 text-gray-400'
} hover:border-cyber-orange transition-colors`}
>
{selectedProjects.length > 0 ? `${selectedProjects.length} Project(s)` : 'All Projects'}
</button>
)}
</div>
{/* Project Filter Dropdown */}
{showProjectFilter && (
<div className="absolute top-12 right-0 z-50 w-64 bg-cyber-darkest border border-cyber-orange/30 rounded-lg shadow-lg max-h-80 overflow-y-auto">
<div className="p-2">
<div className="text-xs text-gray-400 px-2 py-1 mb-1">Filter by projects:</div>
{projects.map(project => (
<label
key={project.id}
className="flex items-center gap-2 px-2 py-2 hover:bg-cyber-darker rounded cursor-pointer"
>
<input
type="checkbox"
checked={selectedProjects.includes(project.id)}
onChange={() => toggleProjectFilter(project.id)}
className="rounded border-cyber-orange/50 bg-cyber-darker text-cyber-orange focus:ring-cyber-orange focus:ring-offset-0"
/>
<span className="text-sm text-gray-300">{project.name}</span>
</label>
))}
{selectedProjects.length > 0 && (
<button
onClick={() => {
setSelectedProjects([])
if (query) handleSearch(query)
}}
className="w-full mt-2 px-2 py-1 text-xs text-gray-400 hover:text-gray-200 border border-gray-600 rounded"
>
Clear Filter
</button>
)}
</div>
</div>
)}
{/* Search Results */}
{showResults && (
<div className="absolute top-12 left-0 z-50 w-96 bg-cyber-darkest border border-cyber-orange/30 rounded-lg shadow-lg max-h-96 overflow-y-auto">
{isSearching ? (
<div className="p-4 text-center text-gray-400 text-sm">Searching...</div>
) : results.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">No results found</div>
) : (
<div className="p-2">
<div className="text-xs text-gray-400 px-2 py-1 mb-1">
{results.length} result{results.length !== 1 ? 's' : ''}
</div>
{results.map(task => {
const project = projects.find(p => p.id === task.project_id)
return (
<button
key={task.id}
onClick={() => handleTaskClick(task)}
className="w-full text-left px-2 py-2 hover:bg-cyber-darker rounded transition-colors"
>
<div className="flex items-start gap-2">
{/* Flag */}
{task.flag_color && FLAG_COLORS[task.flag_color] && (
<Flag size={12} className={`mt-0.5 ${FLAG_COLORS[task.flag_color]}`} fill="currentColor" />
)}
<div className="flex-1 min-w-0">
{/* Title */}
<div className="text-sm text-gray-200 truncate">{task.title}</div>
{/* Project name */}
{project && (
<div className="text-xs text-gray-500 mt-0.5">
in: <span className="text-cyber-orange">{project.name}</span>
</div>
)}
{/* Metadata */}
{(task.estimated_minutes || (task.tags && task.tags.length > 0)) && (
<div className="flex items-center gap-2 mt-1">
{task.estimated_minutes && (
<span className="text-xs text-gray-500">{formatTime(task.estimated_minutes)}</span>
)}
{task.tags && task.tags.length > 0 && (
<div className="flex gap-1 flex-wrap">
{task.tags.slice(0, 3).map((tag, idx) => (
<span
key={idx}
className="inline-block px-1 py-0.5 text-xs bg-cyber-orange/20 text-cyber-orange border border-cyber-orange/30 rounded"
>
{tag}
</span>
))}
{task.tags.length > 3 && (
<span className="text-xs text-gray-500">+{task.tags.length - 3}</span>
)}
</div>
)}
</div>
)}
</div>
</div>
</button>
)
})}
</div>
)}
</div>
)}
</div>
)
}
export default SearchBar

View File

@@ -0,0 +1,182 @@
import { useState } from 'react'
import { Flag } from 'lucide-react'
const FLAG_COLORS = [
{ name: null, label: 'None', color: 'bg-gray-700' },
{ name: 'red', label: 'Red', color: 'bg-red-500' },
{ name: 'orange', label: 'Orange', color: 'bg-orange-500' },
{ name: 'yellow', label: 'Yellow', color: 'bg-yellow-500' },
{ name: 'green', label: 'Green', color: 'bg-green-500' },
{ name: 'blue', label: 'Blue', color: 'bg-blue-500' },
{ name: 'purple', label: 'Purple', color: 'bg-purple-500' },
{ name: 'pink', label: 'Pink', color: 'bg-pink-500' }
]
// Helper to format status label
const formatStatusLabel = (status) => {
return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
function TaskForm({ onSubmit, onCancel, submitLabel = "Add", projectStatuses = null, defaultStatus = "backlog" }) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [tags, setTags] = useState('')
const [hours, setHours] = useState('')
const [minutes, setMinutes] = useState('')
const [flagColor, setFlagColor] = useState(null)
const [status, setStatus] = useState(defaultStatus)
// Use provided statuses or fall back to defaults
const statuses = projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']
const handleSubmit = (e) => {
e.preventDefault()
if (!title.trim()) return
// Convert hours and minutes to total minutes
const totalMinutes = (parseInt(hours) || 0) * 60 + (parseInt(minutes) || 0)
// Parse tags
const tagList = tags
? tags.split(',').map(t => t.trim()).filter(t => t.length > 0)
: null
const taskData = {
title: title.trim(),
description: description.trim() || null,
tags: tagList && tagList.length > 0 ? tagList : null,
estimated_minutes: totalMinutes > 0 ? totalMinutes : null,
flag_color: flagColor,
status: status
}
onSubmit(taskData)
}
return (
<form onSubmit={handleSubmit} className="bg-cyber-darkest border border-cyber-orange/30 rounded-lg p-4 space-y-3">
{/* Title */}
<div>
<label className="block text-xs text-gray-400 mb-1">Task Title *</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter task title..."
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
autoFocus
/>
</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 */}
<div>
<label className="block text-xs text-gray-400 mb-1">Tags (comma-separated)</label>
<input
type="text"
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="coding, bug-fix, frontend"
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"
/>
</div>
{/* Time Estimate */}
<div>
<label className="block text-xs text-gray-400 mb-1">Time Estimate</label>
<div className="flex gap-2">
<div className="flex-1">
<input
type="number"
min="0"
value={hours}
onChange={(e) => setHours(e.target.value)}
placeholder="Hours"
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"
/>
</div>
<div className="flex-1">
<input
type="number"
min="0"
max="59"
value={minutes}
onChange={(e) => setMinutes(e.target.value)}
placeholder="Minutes"
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"
/>
</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 */}
<div>
<label className="block text-xs text-gray-400 mb-1">Flag Color</label>
<div className="flex gap-2 flex-wrap">
{FLAG_COLORS.map(({ name, label, color }) => (
<button
key={name || 'none'}
type="button"
onClick={() => setFlagColor(name)}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-all ${
flagColor === name
? 'bg-cyber-orange/20 border-2 border-cyber-orange'
: 'border-2 border-transparent hover:border-cyber-orange/40'
}`}
title={label}
>
<div className={`w-4 h-4 ${color} rounded`} />
{flagColor === name && '✓'}
</button>
))}
</div>
</div>
{/* Buttons */}
<div className="flex gap-2 pt-2">
<button
type="submit"
className="flex-1 px-4 py-2 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright font-semibold text-sm transition-colors"
>
{submitLabel}
</button>
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-gray-400 hover:text-gray-200 text-sm transition-colors"
>
Cancel
</button>
</div>
</form>
)
}
export default TaskForm

View File

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

View File

@@ -3,10 +3,10 @@ import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Plus, Plus,
Edit2,
Trash2,
Check, Check,
X X,
Flag,
Clock
} from 'lucide-react' } from 'lucide-react'
import { import {
getProjectTaskTree, getProjectTaskTree,
@@ -14,28 +14,42 @@ import {
updateTask, updateTask,
deleteTask deleteTask
} from '../utils/api' } from '../utils/api'
import { formatTimeWithTotal } from '../utils/format'
import TaskMenu from './TaskMenu'
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
} }
function TaskNode({ task, projectId, onUpdate, level = 0 }) { const FLAG_COLORS = {
red: 'bg-red-500',
orange: 'bg-orange-500',
yellow: 'bg-yellow-500',
green: 'bg-green-500',
blue: 'bg-blue-500',
purple: 'bg-purple-500',
pink: 'bg-pink-500'
}
function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses }) {
const [isExpanded, setIsExpanded] = useState(true) const [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)
const [editStatus, setEditStatus] = useState(task.status) const [editStatus, setEditStatus] = useState(task.status)
const [showAddSubtask, setShowAddSubtask] = useState(false) const [showAddSubtask, setShowAddSubtask] = useState(false)
const [newSubtaskTitle, setNewSubtaskTitle] = useState('')
const hasSubtasks = task.subtasks && task.subtasks.length > 0 const hasSubtasks = task.subtasks && task.subtasks.length > 0
@@ -62,18 +76,18 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
} }
} }
const handleAddSubtask = async (e) => { const handleAddSubtask = async (taskData) => {
e.preventDefault()
if (!newSubtaskTitle.trim()) return
try { try {
await createTask({ await createTask({
project_id: parseInt(projectId), project_id: parseInt(projectId),
parent_task_id: task.id, parent_task_id: task.id,
title: newSubtaskTitle, title: taskData.title,
status: 'backlog' description: taskData.description,
status: taskData.status,
tags: taskData.tags,
estimated_minutes: taskData.estimated_minutes,
flag_color: taskData.flag_color
}) })
setNewSubtaskTitle('')
setShowAddSubtask(false) setShowAddSubtask(false)
setIsExpanded(true) setIsExpanded(true)
onUpdate() onUpdate()
@@ -114,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}
@@ -139,12 +152,52 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
) : ( ) : (
<> <>
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2">
{/* Flag indicator */}
{task.flag_color && FLAG_COLORS[task.flag_color] && (
<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-3 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>
{/* Metadata row */}
{(formatTimeWithTotal(task) || (task.tags && task.tags.length > 0)) && (
<div className="flex items-center gap-3 mt-1">
{/* Time estimate */}
{formatTimeWithTotal(task) && (
<div className={`flex items-center gap-1 text-xs text-gray-500 ${task.status === 'done' ? 'line-through' : ''}`}>
<Clock size={12} />
<span>{formatTimeWithTotal(task)}</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 gap-1 px-2 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>
{/* Actions */} {/* Actions */}
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button <button
@@ -154,20 +207,13 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
> >
<Plus size={16} /> <Plus size={16} />
</button> </button>
<button <TaskMenu
onClick={() => setIsEditing(true)} task={task}
className="text-gray-400 hover:text-gray-200" onUpdate={onUpdate}
title="Edit" onDelete={handleDelete}
> onEdit={() => setIsEditing(true)}
<Edit2 size={16} /> projectStatuses={projectStatuses}
</button> />
<button
onClick={handleDelete}
className="text-gray-600 hover:text-red-400"
title="Delete"
>
<Trash2 size={16} />
</button>
</div> </div>
</> </>
)} )}
@@ -176,29 +222,12 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
{/* Add Subtask Form */} {/* Add Subtask Form */}
{showAddSubtask && ( {showAddSubtask && (
<div style={{ marginLeft: `${level * 1.5}rem` }} className="mt-2"> <div style={{ marginLeft: `${level * 1.5}rem` }} className="mt-2">
<form onSubmit={handleAddSubtask} className="flex gap-2"> <TaskForm
<input onSubmit={handleAddSubtask}
type="text" onCancel={() => setShowAddSubtask(false)}
value={newSubtaskTitle} submitLabel="Add Subtask"
onChange={(e) => setNewSubtaskTitle(e.target.value)} projectStatuses={projectStatuses}
placeholder="New subtask title..."
className="flex-1 px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
autoFocus
/> />
<button
type="submit"
className="px-3 py-2 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright text-sm font-semibold"
>
Add
</button>
<button
type="button"
onClick={() => setShowAddSubtask(false)}
className="px-3 py-2 text-gray-400 hover:text-gray-200 text-sm"
>
Cancel
</button>
</form>
</div> </div>
)} )}
@@ -212,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>
@@ -220,12 +250,12 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
) )
} }
function TreeView({ projectId }) { function TreeView({ projectId, project }) {
const projectStatuses = project?.statuses || ['backlog', 'in_progress', 'on_hold', 'done']
const [tasks, setTasks] = useState([]) const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [showAddRoot, setShowAddRoot] = useState(false) const [showAddRoot, setShowAddRoot] = useState(false)
const [newTaskTitle, setNewTaskTitle] = useState('')
useEffect(() => { useEffect(() => {
loadTasks() loadTasks()
@@ -243,18 +273,18 @@ function TreeView({ projectId }) {
} }
} }
const handleAddRootTask = async (e) => { const handleAddRootTask = async (taskData) => {
e.preventDefault()
if (!newTaskTitle.trim()) return
try { try {
await createTask({ await createTask({
project_id: parseInt(projectId), project_id: parseInt(projectId),
parent_task_id: null, parent_task_id: null,
title: newTaskTitle, title: taskData.title,
status: 'backlog' description: taskData.description,
status: taskData.status,
tags: taskData.tags,
estimated_minutes: taskData.estimated_minutes,
flag_color: taskData.flag_color
}) })
setNewTaskTitle('')
setShowAddRoot(false) setShowAddRoot(false)
loadTasks() loadTasks()
} catch (err) { } catch (err) {
@@ -285,29 +315,12 @@ function TreeView({ projectId }) {
{showAddRoot && ( {showAddRoot && (
<div className="mb-4"> <div className="mb-4">
<form onSubmit={handleAddRootTask} className="flex gap-2"> <TaskForm
<input onSubmit={handleAddRootTask}
type="text" onCancel={() => setShowAddRoot(false)}
value={newTaskTitle} submitLabel="Add Task"
onChange={(e) => setNewTaskTitle(e.target.value)} projectStatuses={projectStatuses}
placeholder="New task title..."
className="flex-1 px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 focus:outline-none focus:border-cyber-orange"
autoFocus
/> />
<button
type="submit"
className="px-4 py-2 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright font-semibold"
>
Add
</button>
<button
type="button"
onClick={() => setShowAddRoot(false)}
className="px-4 py-2 text-gray-400 hover:text-gray-200"
>
Cancel
</button>
</form>
</div> </div>
)} )}
@@ -324,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,19 +182,46 @@ function ProjectList() {
<div <div
key={project.id} key={project.id}
onClick={() => navigate(`/project/${project.id}`)} onClick={() => navigate(`/project/${project.id}`)}
className="p-6 bg-cyber-darkest border border-cyber-orange/30 rounded-lg hover:border-cyber-orange hover:shadow-cyber transition-all cursor-pointer group" className={`p-6 bg-cyber-darkest border rounded-lg hover:border-cyber-orange hover:shadow-cyber transition-all cursor-pointer group ${
project.is_archived
? 'border-gray-700 opacity-75'
: 'border-cyber-orange/30'
}`}
> >
<div className="flex justify-between items-start mb-2"> <div className="flex justify-between items-start mb-2">
<h3 className="text-xl font-semibold text-gray-100 group-hover:text-cyber-orange transition-colors"> <h3 className="text-xl font-semibold text-gray-100 group-hover:text-cyber-orange transition-colors">
{project.name} {project.name}
{project.is_archived && (
<span className="ml-2 text-xs text-gray-500">(archived)</span>
)}
</h3> </h3>
<div className="flex gap-2">
{project.is_archived ? (
<button
onClick={(e) => handleUnarchiveProject(project.id, e)}
className="text-gray-600 hover:text-cyber-orange transition-colors"
title="Unarchive project"
>
<ArchiveRestore size={18} />
</button>
) : (
<button
onClick={(e) => handleArchiveProject(project.id, e)}
className="text-gray-600 hover:text-yellow-400 transition-colors"
title="Archive project"
>
<Archive size={18} />
</button>
)}
<button <button
onClick={(e) => handleDeleteProject(project.id, e)} onClick={(e) => handleDeleteProject(project.id, e)}
className="text-gray-600 hover:text-red-400 transition-colors" className="text-gray-600 hover:text-red-400 transition-colors"
title="Delete project"
> >
<Trash2 size={18} /> <Trash2 size={18} />
</button> </button>
</div> </div>
</div>
{project.description && ( {project.description && (
<p className="text-gray-400 text-sm line-clamp-2">{project.description}</p> <p className="text-gray-400 text-sm line-clamp-2">{project.description}</p>
)} )}

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,6 +67,7 @@ function ProjectView() {
)} )}
</div> </div>
<div className="flex gap-3 items-center">
<div className="flex gap-2 bg-cyber-darkest rounded-lg p-1 border border-cyber-orange/30"> <div className="flex gap-2 bg-cyber-darkest rounded-lg p-1 border border-cyber-orange/30">
<button <button
onClick={() => setView('tree')} onClick={() => setView('tree')}
@@ -89,13 +92,30 @@ function ProjectView() {
Kanban Kanban
</button> </button>
</div> </div>
<button
onClick={() => setShowSettings(true)}
className="p-2 text-gray-400 hover:text-cyber-orange transition-colors border border-cyber-orange/30 rounded-lg hover:border-cyber-orange/60"
title="Project Settings"
>
<Settings size={20} />
</button>
</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,4 +1,4 @@
// Format minutes into display string // Format minutes into display string (e.g., "1h 30m" or "45m")
export function formatTime(minutes) { export function formatTime(minutes) {
if (!minutes || minutes === 0) return null; if (!minutes || minutes === 0) return null;
@@ -6,8 +6,14 @@ export function formatTime(minutes) {
return `${minutes}m`; return `${minutes}m`;
} }
const hours = minutes / 60; const hours = Math.floor(minutes / 60);
return `${hours.toFixed(1)}h`; const mins = minutes % 60;
if (mins === 0) {
return `${hours}h`;
}
return `${hours}h ${mins}m`;
} }
// Format tags as comma-separated string // Format tags as comma-separated string
@@ -15,3 +21,66 @@ export function formatTags(tags) {
if (!tags || tags.length === 0) return null; if (!tags || tags.length === 0) return null;
return tags.join(', '); return tags.join(', ');
} }
// Calculate sum of all LEAF descendant estimates (hierarchical structure)
// Excludes tasks marked as "done"
export function calculateLeafTime(task) {
// If no subtasks, this is a leaf - return its own estimate if not done
if (!task.subtasks || task.subtasks.length === 0) {
return (task.status !== 'done' && task.estimated_minutes) ? task.estimated_minutes : 0;
}
// Has subtasks, so sum up all leaf descendants (excluding done tasks)
let total = 0;
for (const subtask of task.subtasks) {
total += calculateLeafTime(subtask);
}
return total;
}
// Calculate sum of all LEAF descendant estimates (flat task list)
// Excludes tasks marked as "done"
export function calculateLeafTimeFlat(task, allTasks) {
// Find direct children
const children = allTasks.filter(t => t.parent_task_id === task.id);
// If no children, this is a leaf - return its own estimate if not done
if (children.length === 0) {
return (task.status !== 'done' && task.estimated_minutes) ? task.estimated_minutes : 0;
}
// Has children, so sum up all leaf descendants (excluding done tasks)
let total = 0;
for (const child of children) {
total += calculateLeafTimeFlat(child, allTasks);
}
return total;
}
// Format time display based on leaf calculation logic
export function formatTimeWithTotal(task, allTasks = null) {
// Check if task has subtasks
const hasSubtasks = allTasks
? allTasks.some(t => t.parent_task_id === task.id)
: (task.subtasks && task.subtasks.length > 0);
// Leaf task: use own estimate
if (!hasSubtasks) {
return formatTime(task.estimated_minutes);
}
// Parent task: calculate sum of leaf descendants
const leafTotal = allTasks
? calculateLeafTimeFlat(task, allTasks)
: calculateLeafTime(task);
// If no leaf estimates exist, fall back to own estimate
if (leafTotal === 0) {
return formatTime(task.estimated_minutes);
}
// Show leaf total
return formatTime(leafTotal);
}

View File

@@ -1,16 +1,20 @@
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 }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [react()], plugins: [react()],
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: 5173, port: parseInt(env.VITE_DEV_PORT || '5173'),
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:8000', target: env.VITE_API_URL || 'http://localhost:8000',
changeOrigin: true, 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": []
}
]
}
]
}