Compare commits

17 Commits

Author SHA1 Message Date
serversdwn
1c4cdf042c chore: create campaign mode branch, include sys spec, and update gitignore 2026-03-29 19:03:15 +00:00
serversdwn
2ee75f719b feat: add Pomodoro timer functionality with logging and project goals
- Implemented Pomodoro timer in the app, allowing users to start, pause, and stop sessions.
- Added context for managing Pomodoro state and actions.
- Integrated time logging for completed sessions to track productivity.
- Enhanced project settings to include time goals and Pomodoro settings.
- Created migration scripts to update the database schema for new project fields and time logs.
- Updated UI components to display Pomodoro controls and project time summaries.
- Added category filtering for projects in the project list view.
2026-02-18 06:49:04 +00:00
serversdwn
c6ed57342c Merge remote branch and resolve conflicts with BIT rename
Kept remote's pydantic-settings, env_file, SearchBar, and new components.
Applied BIT/Break It Down naming throughout conflicted files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-17 22:56:08 +00:00
serversdwn
5d5cec048f chore: reband cleanup, changed names from tesseract 2026-02-17 22:50:57 +00:00
01e594b941 Merge pull request 'Main branch Resync on w/ gitea. v0.1.6' (#1) from claude/nested-todo-app-mvp-014vUjLpD8wNjkMhLMXS6Kd5 into main
Reviewed-on: http://10.0.0.2:9010/serversdown/break-it-down/pulls/1
2026-01-04 04:30:52 -05:00
serversdwn
3fc90063b4 rebranded to BIT 2026-01-04 09:24:06 +00:00
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
36 changed files with 3422 additions and 291 deletions

4
.gitignore vendored
View File

@@ -41,3 +41,7 @@ Thumbs.db
# Docker # Docker
docker-compose.override.yml docker-compose.override.yml
#dev stuff
/.claude/
/.vscode/

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.

244
SYSTEMSPEC_v0.2.0.md Normal file
View File

@@ -0,0 +1,244 @@
# Break It Down (BIT)
# SYSTEM SPECIFICATION
## Version: v0.2.0
## Feature Theme: Campaign Mode + Entropy Engine + Project Wizard
------------------------------------------------------------------------
# 1. Overview
v0.2.0 introduces **Campaign Mode**, an operations layer on top of
Break-It-Down's planning engine.
BIT remains the HQ planning system (tree + kanban). Campaign Mode is the
execution layer focused on:
- Low-friction session starts
- Visual progress (photo proof)
- Entropy-based decay for recurring physical spaces
- Gentle nudges to trigger action
- A fast project-building Wizard
The goal is to reduce startup friction and help users act without
overthinking.
------------------------------------------------------------------------
# 2. Core Architectural Principles
1. Existing planning functionality must remain unchanged for standard
projects.
2. Campaign functionality activates only when
`project_mode == "entropy_space"`.
3. Planning (HQ) and Execution (Campaign) are separate UI routes.
4. Session start must require no pre-forms.
5. Entropy simulation must feel like physics, not guilt.
------------------------------------------------------------------------
# 3. New Core Concepts
## 3.1 Project Modes
Add enum to projects:
- `standard` (default, existing behavior)
- `entropy_space` (physical space / territory model)
Future modes may include: - `pipeline_chores` - `deep_work`
------------------------------------------------------------------------
## 3.2 Campaign Types
Only meaningful when `project_mode == "entropy_space"`:
- `finite` --- one-off liberation (garage purge)
- `background` --- ongoing maintenance (bathroom, kitchen)
------------------------------------------------------------------------
## 3.3 Zones (v1 Implementation)
In `entropy_space` projects:
- Top-level tasks (`parent_task_id IS NULL`)
- `is_zone = true`
No separate zones table in v0.2.0.
------------------------------------------------------------------------
## 3.4 Work Sessions
Generic sessions stored in backend. Campaign UI refers to them as
"Strikes".
Session kinds:
- `strike`
- `pomodoro` (future)
- `run` (future)
- `freeform`
------------------------------------------------------------------------
# 4. Database Schema Changes
## 4.1 Projects Table Additions
project_mode TEXT NOT NULL DEFAULT 'standard'
campaign_type TEXT NULL
campaign_active BOOLEAN NOT NULL DEFAULT 0
nudge_enabled BOOLEAN NOT NULL DEFAULT 0
nudge_window_start TEXT NULL
nudge_window_end TEXT NULL
nudge_min_interval_minutes INTEGER NULL
nudge_max_per_day INTEGER NULL
photo_proof_enabled BOOLEAN NOT NULL DEFAULT 0
default_session_kind TEXT NOT NULL DEFAULT 'freeform'
default_session_minutes INTEGER NOT NULL DEFAULT 12
------------------------------------------------------------------------
## 4.2 Tasks Table Additions (Zones Only)
is_zone BOOLEAN NOT NULL DEFAULT 0
stability_base INTEGER NULL
decay_rate_per_day REAL NULL
last_stability_update_at DATETIME NULL
last_strike_at DATETIME NULL
zone_preset TEXT NULL
------------------------------------------------------------------------
## 4.3 New Table: work_sessions
id INTEGER PRIMARY KEY
project_id INTEGER NOT NULL
task_id INTEGER NULL
kind TEXT NOT NULL
started_at DATETIME NOT NULL
ended_at DATETIME NULL
duration_seconds INTEGER NULL
note TEXT NULL
------------------------------------------------------------------------
## 4.4 New Table: session_photos
id INTEGER PRIMARY KEY
session_id INTEGER NOT NULL
phase TEXT NOT NULL -- 'before' or 'after'
path TEXT NOT NULL
taken_at DATETIME NOT NULL
Images stored in Docker volume under:
/data/photos/{project_id}/{session_id}/...
------------------------------------------------------------------------
# 5. Entropy Engine (v1)
## 5.1 Stability Model
Fields used: - stability_base - last_stability_update_at -
decay_rate_per_day
Derived:
days_elapsed = (now - last_stability_update_at)
stability_now = max(0, stability_base - decay_rate_per_day * days_elapsed)
------------------------------------------------------------------------
## 5.2 Strike Effect
On strike completion:
Option A (fixed):
boost = 20
Option B (duration-based):
boost = clamp(10, 35, round(duration_minutes * 1.5))
Update:
stability_base = min(100, stability_now + boost)
last_stability_update_at = now
last_strike_at = now
------------------------------------------------------------------------
## 5.3 Stability Color Mapping
- 80--100 → green
- 55--79 → yellow
- 30--54 → orange
- 0--29 → red
------------------------------------------------------------------------
# 6. API Additions
## Campaign
POST /api/projects/{id}/launch_campaign
GET /api/projects/{id}/zones
## Sessions
POST /api/sessions/start
POST /api/sessions/{id}/stop
GET /api/projects/{id}/sessions
## Photos
POST /api/sessions/{id}/photos
GET /api/sessions/{id}/photos
## Wizard
POST /api/wizard/build_project
------------------------------------------------------------------------
# 7. Frontend Routes
/projects
/projects/:id
/projects/:id/campaign
------------------------------------------------------------------------
# 8. Build Phases
Phase 0: Core campaign + stability\
Phase 1: Photo proof + compare slider\
Phase 2: Nudges\
Phase 3: Hex grid expansion
------------------------------------------------------------------------
# 9. Success Criteria
- Wizard creates entropy project in \<30 seconds\
- Strike starts in 1 tap\
- Stability increases after strike\
- Stability decreases over time\
- No regression in standard projects
------------------------------------------------------------------------
**Break It Down v0.2.0**\
Planning in HQ. Liberation on the front lines.

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

@@ -1,11 +1,18 @@
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from sqlalchemy import func
from typing import List, Optional from typing import List, Optional
from datetime import datetime, timedelta
from . import models, schemas from . import models, schemas
# Project CRUD # Project CRUD
def create_project(db: Session, project: schemas.ProjectCreate) -> models.Project: def create_project(db: Session, project: schemas.ProjectCreate) -> models.Project:
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 +23,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 +60,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 +110,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 +144,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,9 +177,77 @@ 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
).all() ).all()
# TimeLog CRUD
def create_time_log(db: Session, task_id: int, time_log: schemas.TimeLogCreate) -> models.TimeLog:
db_log = models.TimeLog(
task_id=task_id,
minutes=time_log.minutes,
note=time_log.note,
session_type=time_log.session_type,
)
db.add(db_log)
db.commit()
db.refresh(db_log)
return db_log
def get_time_logs_by_task(db: Session, task_id: int) -> List[models.TimeLog]:
return db.query(models.TimeLog).filter(
models.TimeLog.task_id == task_id
).order_by(models.TimeLog.logged_at.desc()).all()
def get_project_time_summary(db: Session, project_id: int) -> dict:
"""Aggregate time logged across all tasks in a project"""
project = get_project(db, project_id)
# Get all task IDs in this project
task_ids = db.query(models.Task.id).filter(
models.Task.project_id == project_id
).subquery()
# Total minutes logged
total = db.query(func.sum(models.TimeLog.minutes)).filter(
models.TimeLog.task_id.in_(task_ids)
).scalar() or 0
# Pomodoro minutes
pomodoro = db.query(func.sum(models.TimeLog.minutes)).filter(
models.TimeLog.task_id.in_(task_ids),
models.TimeLog.session_type == "pomodoro"
).scalar() or 0
# Manual minutes
manual = db.query(func.sum(models.TimeLog.minutes)).filter(
models.TimeLog.task_id.in_(task_ids),
models.TimeLog.session_type == "manual"
).scalar() or 0
# Weekly minutes (past 7 days)
week_ago = datetime.utcnow() - timedelta(days=7)
weekly = db.query(func.sum(models.TimeLog.minutes)).filter(
models.TimeLog.task_id.in_(task_ids),
models.TimeLog.logged_at >= week_ago
).scalar() or 0
return {
"total_minutes": total,
"pomodoro_minutes": pomodoro,
"manual_minutes": manual,
"weekly_minutes": weekly,
"weekly_hours_goal": project.weekly_hours_goal if project else None,
"total_hours_goal": project.total_hours_goal if project else None,
}

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)
@@ -144,6 +159,32 @@ def delete_task(task_id: int, db: Session = Depends(get_db)):
return None return None
# ========== TIME LOG ENDPOINTS ==========
@app.post("/api/tasks/{task_id}/time-logs", response_model=schemas.TimeLog, status_code=201)
def log_time(task_id: int, time_log: schemas.TimeLogCreate, db: Session = Depends(get_db)):
"""Log time spent on a task"""
if not crud.get_task(db, task_id):
raise HTTPException(status_code=404, detail="Task not found")
return crud.create_time_log(db, task_id, time_log)
@app.get("/api/tasks/{task_id}/time-logs", response_model=List[schemas.TimeLog])
def get_time_logs(task_id: int, db: Session = Depends(get_db)):
"""Get all time logs for a task"""
if not crud.get_task(db, task_id):
raise HTTPException(status_code=404, detail="Task not found")
return crud.get_time_logs_by_task(db, task_id)
@app.get("/api/projects/{project_id}/time-summary", response_model=schemas.ProjectTimeSummary)
def get_project_time_summary(project_id: int, db: Session = Depends(get_db)):
"""Get aggregated time statistics for a project"""
if not crud.get_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found")
return crud.get_project_time_summary(db, project_id)
# ========== SEARCH ENDPOINT ========== # ========== SEARCH ENDPOINT ==========
@app.get("/api/search", response_model=List[schemas.Task]) @app.get("/api/search", response_model=List[schemas.Task])
@@ -187,6 +228,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 +289,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 +308,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 +345,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",
"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,13 @@ 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)
category = Column(String(100), nullable=True)
weekly_hours_goal = Column(Integer, nullable=True) # stored in minutes
total_hours_goal = Column(Integer, nullable=True) # stored in minutes
pomodoro_work_minutes = Column(Integer, nullable=True, default=25)
pomodoro_break_minutes = Column(Integer, nullable=True, default=5)
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 +35,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)
@@ -42,3 +45,17 @@ class Task(Base):
project = relationship("Project", back_populates="tasks") project = relationship("Project", back_populates="tasks")
parent = relationship("Task", remote_side=[id], backref="subtasks") parent = relationship("Task", remote_side=[id], backref="subtasks")
time_logs = relationship("TimeLog", back_populates="task", cascade="all, delete-orphan")
class TimeLog(Base):
__tablename__ = "time_logs"
id = Column(Integer, primary_key=True, index=True)
task_id = Column(Integer, ForeignKey("tasks.id"), nullable=False)
minutes = Column(Integer, nullable=False)
note = Column(Text, nullable=True)
session_type = Column(String(50), default="manual") # 'pomodoro' | 'manual'
logged_at = Column(DateTime, default=datetime.utcnow)
task = relationship("Task", back_populates="time_logs")

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,35 @@ class ProjectBase(BaseModel):
class ProjectCreate(ProjectBase): class ProjectCreate(ProjectBase):
pass statuses: Optional[List[str]] = None
category: Optional[str] = None
weekly_hours_goal: Optional[int] = None
total_hours_goal: Optional[int] = None
pomodoro_work_minutes: Optional[int] = None
pomodoro_break_minutes: Optional[int] = 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
category: Optional[str] = None
weekly_hours_goal: Optional[int] = None
total_hours_goal: Optional[int] = None
pomodoro_work_minutes: Optional[int] = None
pomodoro_break_minutes: Optional[int] = None
class Project(ProjectBase): class Project(ProjectBase):
id: int id: int
statuses: List[str]
is_archived: bool
category: Optional[str] = None
weekly_hours_goal: Optional[int] = None
total_hours_goal: Optional[int] = None
pomodoro_work_minutes: Optional[int] = None
pomodoro_break_minutes: Optional[int] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -79,7 +98,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 +108,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):
@@ -100,3 +120,30 @@ class ImportResult(BaseModel):
project_id: int project_id: int
project_name: str project_name: str
tasks_created: int tasks_created: int
# TimeLog Schemas
class TimeLogCreate(BaseModel):
minutes: int
note: Optional[str] = None
session_type: str = "manual" # 'pomodoro' | 'manual'
class TimeLog(BaseModel):
id: int
task_id: int
minutes: int
note: Optional[str] = None
session_type: str
logged_at: datetime
model_config = ConfigDict(from_attributes=True)
class ProjectTimeSummary(BaseModel):
total_minutes: int
pomodoro_minutes: int
manual_minutes: int
weekly_minutes: int
weekly_hours_goal: Optional[int] = None
total_hours_goal: Optional[int] = None

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,42 @@
"""
Migration script to add time goals, category, and pomodoro settings to projects table.
Run this once if you have an existing database.
"""
import sqlite3
import os
db_path = os.path.join(os.path.dirname(__file__), 'bit.db')
if not os.path.exists(db_path):
print("No migration needed - new database will be created with the correct schema.")
exit(0)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
cursor.execute("PRAGMA table_info(projects)")
columns = [column[1] for column in cursor.fetchall()]
new_columns = [
("category", "TEXT DEFAULT NULL"),
("weekly_hours_goal", "INTEGER DEFAULT NULL"),
("total_hours_goal", "INTEGER DEFAULT NULL"),
("pomodoro_work_minutes", "INTEGER DEFAULT 25"),
("pomodoro_break_minutes", "INTEGER DEFAULT 5"),
]
for col_name, col_def in new_columns:
if col_name in columns:
print(f"Column '{col_name}' already exists. Skipping.")
else:
cursor.execute(f"ALTER TABLE projects ADD COLUMN {col_name} {col_def}")
print(f"Successfully added '{col_name}'.")
conn.commit()
print("Migration complete.")
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

@@ -0,0 +1,34 @@
"""
Migration script to create the time_logs table.
Run this once if you have an existing database.
"""
import sqlite3
import os
db_path = os.path.join(os.path.dirname(__file__), 'bit.db')
if not os.path.exists(db_path):
print("No migration needed - new database will be created with the correct schema.")
exit(0)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
cursor.execute("""
CREATE TABLE IF NOT EXISTS time_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
minutes INTEGER NOT NULL,
note TEXT,
session_type TEXT DEFAULT 'manual',
logged_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
print("Successfully created 'time_logs' table (or it already existed).")
except Exception as e:
print(f"Error during migration: {e}")
conn.rollback()
finally:
conn.close()

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>BIT - Break It Down</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

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

View File

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

View File

@@ -2,18 +2,21 @@ 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' import SearchBar from './components/SearchBar'
import { PomodoroProvider } from './context/PomodoroContext'
import PomodoroWidget from './components/PomodoroWidget'
function App() { function App() {
return ( return (
<PomodoroProvider>
<div className="min-h-screen bg-cyber-dark"> <div className="min-h-screen bg-cyber-dark">
<header className="border-b border-cyber-orange/30 bg-cyber-darkest"> <header className="border-b border-cyber-orange/30 bg-cyber-darkest">
<div className="container mx-auto px-4 py-4"> <div className="container mx-auto px-4 py-4">
<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 BIT
<span className="ml-3 text-sm text-gray-500">Task Decomposition Engine</span> <span className="ml-3 text-sm text-gray-500">Break It Down</span>
<span className="ml-2 text-xs text-gray-600">v0.1.3</span> <span className="ml-2 text-xs text-gray-600">v{import.meta.env.VITE_APP_VERSION || '0.1.6'}</span>
</h1> </h1>
</div> </div>
<SearchBar /> <SearchBar />
@@ -27,7 +30,10 @@ function App() {
<Route path="/project/:projectId" element={<ProjectView />} /> <Route path="/project/:projectId" element={<ProjectView />} />
</Routes> </Routes>
</main> </main>
<PomodoroWidget />
</div> </div>
</PomodoroProvider>
) )
} }

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Plus, Check, X, Flag, Clock } from 'lucide-react' import { Plus, Check, X, Flag, Clock, ChevronDown, ChevronRight, ChevronsDown, ChevronsUp, Timer } from 'lucide-react'
import { import {
getProjectTasks, getProjectTasks,
createTask, createTask,
@@ -9,13 +9,23 @@ import {
import { formatTimeWithTotal } from '../utils/format' import { formatTimeWithTotal } from '../utils/format'
import TaskMenu from './TaskMenu' import TaskMenu from './TaskMenu'
import TaskForm from './TaskForm' import TaskForm from './TaskForm'
import { usePomodoro } from '../context/PomodoroContext'
const STATUSES = [ // Helper to format status label
{ key: 'backlog', label: 'Backlog', color: 'border-gray-600' }, const formatStatusLabel = (status) => {
{ key: 'in_progress', label: 'In Progress', color: 'border-blue-500' }, return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
{ key: 'blocked', label: 'Blocked', color: 'border-red-500' }, }
{ key: 'done', label: 'Done', color: 'border-green-500' }
] // Helper to get status color based on common patterns
const getStatusColor = (status) => {
const lowerStatus = status.toLowerCase()
if (lowerStatus === 'backlog') return 'border-gray-600'
if (lowerStatus === 'in_progress' || lowerStatus.includes('progress')) return 'border-blue-500'
if (lowerStatus === 'on_hold' || lowerStatus.includes('hold') || lowerStatus.includes('waiting')) return 'border-yellow-500'
if (lowerStatus === 'done' || lowerStatus.includes('complete')) return 'border-green-500'
if (lowerStatus.includes('blocked')) return 'border-red-500'
return 'border-purple-500' // default for custom statuses
}
const FLAG_COLORS = { const FLAG_COLORS = {
red: 'bg-red-500', red: 'bg-red-500',
@@ -27,14 +37,54 @@ const FLAG_COLORS = {
pink: 'bg-pink-500' pink: 'bg-pink-500'
} }
function TaskCard({ task, allTasks, onUpdate, onDragStart }) { // Helper function to get all descendant tasks recursively
function getAllDescendants(taskId, allTasks) {
const children = allTasks.filter(t => t.parent_task_id === taskId)
let descendants = [...children]
for (const child of children) {
descendants = descendants.concat(getAllDescendants(child.id, allTasks))
}
return descendants
}
// Helper function to get all descendant tasks of a parent in a specific status
function getDescendantsInStatus(taskId, allTasks, status) {
const children = allTasks.filter(t => t.parent_task_id === taskId)
let descendants = []
for (const child of children) {
if (child.status === status) {
descendants.push(child)
}
// Recursively get descendants
descendants = descendants.concat(getDescendantsInStatus(child.id, allTasks, status))
}
return descendants
}
// Helper function to check if a task has any descendants in a status
function hasDescendantsInStatus(taskId, allTasks, status) {
return getDescendantsInStatus(taskId, allTasks, status).length > 0
}
function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatus, expandedCards, setExpandedCards, projectStatuses, projectId, pomodoroSettings }) {
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)
const { startPomodoro, activeTask, phase } = usePomodoro()
const isActiveTimer = activeTask?.id === task.id && phase !== 'idle'
// Find parent task if this is a subtask // Use global expanded state
const parentTask = task.parent_task_id const isExpanded = expandedCards[task.id] || false
? allTasks.find(t => t.id === task.parent_task_id) const toggleExpanded = () => {
: null setExpandedCards(prev => ({
...prev,
[task.id]: !prev[task.id]
}))
}
const handleSave = async () => { const handleSave = async () => {
try { try {
@@ -56,11 +106,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">
@@ -90,19 +169,33 @@ 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 items-center gap-2">
{/* Expand/collapse for parent cards */}
{isParent && childrenInColumn.length > 0 && (
<button
onClick={toggleExpanded}
className="text-cyber-orange hover:text-cyber-orange-bright"
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
)}
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
{/* Flag indicator */} {/* Flag indicator */}
{task.flag_color && FLAG_COLORS[task.flag_color] && ( {task.flag_color && FLAG_COLORS[task.flag_color] && (
<Flag size={12} className={`${FLAG_COLORS[task.flag_color].replace('bg-', 'text-')}`} fill="currentColor" /> <Flag size={12} className={`${FLAG_COLORS[task.flag_color].replace('bg-', 'text-')}`} fill="currentColor" />
)} )}
<span className="text-gray-200">{task.title}</span> <span className={`${isParent ? 'font-semibold text-cyber-orange' : 'text-gray-200'}`}>
{task.title}
</span>
</div> </div>
{/* Parent task context */} {/* Parent card info: show subtask count in this column */}
{parentTask && ( {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> </div>
)} )}
@@ -111,7 +204,7 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
{/* Time estimate */} {/* Time estimate */}
{formatTimeWithTotal(task, allTasks) && ( {formatTimeWithTotal(task, allTasks) && (
<div className="flex items-center gap-1 text-xs text-gray-500"> <div className={`flex items-center gap-1 text-xs text-gray-500 ${task.status === 'done' ? 'line-through' : ''}`}>
<Clock size={11} /> <Clock size={11} />
<span>{formatTimeWithTotal(task, allTasks)}</span> <span>{formatTimeWithTotal(task, allTasks)}</span>
</div> </div>
@@ -132,24 +225,91 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart }) {
)} )}
</div> </div>
)} )}
{/* Description */}
{task.description && (
<div className="mt-2 text-xs text-gray-400 italic">
{task.description}
</div>
)}
</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
onClick={(e) => {
e.stopPropagation()
startPomodoro(
{ id: task.id, title: task.title, estimatedMinutes: task.estimated_minutes },
pomodoroSettings?.workMinutes || 25,
pomodoroSettings?.breakMinutes || 5
)
}}
className={`p-1 transition-colors ${isActiveTimer ? 'text-cyber-orange' : 'text-gray-400 hover:text-cyber-orange'}`}
title={isActiveTimer ? 'Timer running' : 'Start Pomodoro'}
>
<Timer size={14} />
</button>
<button
onClick={() => setShowAddSubtask(true)}
className="text-cyber-orange hover:text-cyber-orange-bright p-1"
title="Add subtask"
>
<Plus size={14} />
</button>
<TaskMenu <TaskMenu
task={task} task={task}
onUpdate={onUpdate} onUpdate={onUpdate}
onDelete={handleDelete} onDelete={handleDelete}
onEdit={() => setIsEditing(true)} 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}
pomodoroSettings={pomodoroSettings}
/>
))}
</div>
)}
</div>
) )
} }
function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, onDragOver }) { function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOver, expandedCards, setExpandedCards, projectStatuses, pomodoroSettings }) {
const [showAddTask, setShowAddTask] = useState(false) const [showAddTask, setShowAddTask] = useState(false)
const handleAddTask = async (taskData) => { const handleAddTask = async (taskData) => {
@@ -158,7 +318,8 @@ function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, on
project_id: parseInt(projectId), project_id: parseInt(projectId),
parent_task_id: null, parent_task_id: null,
title: taskData.title, title: taskData.title,
status: status.key, description: taskData.description,
status: taskData.status,
tags: taskData.tags, tags: taskData.tags,
estimated_minutes: taskData.estimated_minutes, estimated_minutes: taskData.estimated_minutes,
flag_color: taskData.flag_color flag_color: taskData.flag_color
@@ -170,16 +331,37 @@ function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, on
} }
} }
// Get tasks to display in this column:
// 1. All leaf tasks (no children) with this status
// 2. All parent tasks that have at least one descendant with this status
const leafTasks = allTasks.filter(t => {
const hasChildren = allTasks.some(child => child.parent_task_id === t.id)
return !hasChildren && t.status === status.key
})
const parentTasks = allTasks.filter(t => {
const hasChildren = allTasks.some(child => child.parent_task_id === t.id)
return hasChildren && hasDescendantsInStatus(t.id, allTasks, status.key)
})
// Only show root-level parents (not nested parents)
const rootParents = parentTasks.filter(t => !t.parent_task_id)
// Only show root-level leaf tasks (leaf tasks without parents)
const rootLeafTasks = leafTasks.filter(t => !t.parent_task_id)
const displayTasks = [...rootParents, ...rootLeafTasks]
return ( return (
<div <div
className="flex-1 min-w-[280px] bg-cyber-darker rounded-lg p-4 border-t-4 ${status.color}" className={`flex-1 min-w-[280px] bg-cyber-darker rounded-lg p-4 border-t-4 ${status.color}`}
onDrop={(e) => onDrop(e, status.key)} onDrop={(e) => onDrop(e, status.key)}
onDragOver={onDragOver} onDragOver={onDragOver}
> >
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h3 className="font-semibold text-gray-200"> <h3 className="font-semibold text-gray-200">
{status.label} {status.label}
<span className="ml-2 text-xs text-gray-500">({tasks.length})</span> <span className="ml-2 text-xs text-gray-500">({displayTasks.length})</span>
</h3> </h3>
<button <button
onClick={() => setShowAddTask(true)} onClick={() => setShowAddTask(true)}
@@ -195,31 +377,57 @@ function KanbanColumn({ status, tasks, allTasks, projectId, onUpdate, onDrop, on
onSubmit={handleAddTask} onSubmit={handleAddTask}
onCancel={() => setShowAddTask(false)} onCancel={() => setShowAddTask(false)}
submitLabel="Add Task" submitLabel="Add Task"
projectStatuses={projectStatuses}
defaultStatus={status.key}
/> />
</div> </div>
)} )}
<div className="space-y-2"> <div className="space-y-2">
{tasks.map(task => ( {displayTasks.map(task => {
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}
pomodoroSettings={pomodoroSettings}
/> />
))} )
})}
</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 pomodoroSettings = {
workMinutes: project?.pomodoro_work_minutes || 25,
breakMinutes: project?.pomodoro_break_minutes || 5,
}
const statusesWithMeta = statuses.map(status => ({
key: status,
label: formatStatusLabel(status),
color: getStatusColor(status)
}))
useEffect(() => { useEffect(() => {
loadTasks() loadTasks()
@@ -237,6 +445,19 @@ function KanbanView({ projectId }) {
} }
} }
const handleExpandAll = () => {
const parentTasks = allTasks.filter(t => allTasks.some(child => child.parent_task_id === t.id))
const newExpandedState = {}
parentTasks.forEach(task => {
newExpandedState[task.id] = true
})
setExpandedCards(newExpandedState)
}
const handleCollapseAll = () => {
setExpandedCards({})
}
const handleDragOver = (e) => { const handleDragOver = (e) => {
e.preventDefault() e.preventDefault()
} }
@@ -244,11 +465,22 @@ function KanbanView({ projectId }) {
const handleDrop = async (e, newStatus) => { const handleDrop = async (e, newStatus) => {
e.preventDefault() e.preventDefault()
const taskId = parseInt(e.dataTransfer.getData('taskId')) const taskId = parseInt(e.dataTransfer.getData('taskId'))
const isParent = e.dataTransfer.getData('isParent') === 'true'
if (!taskId) return if (!taskId) return
try { try {
// Update the dragged task
await updateTask(taskId, { status: newStatus }) await updateTask(taskId, { status: newStatus })
// If it's a parent task, update all descendants
if (isParent) {
const descendants = getAllDescendants(taskId, allTasks)
for (const descendant of descendants) {
await updateTask(descendant.id, { status: newStatus })
}
}
loadTasks() loadTasks()
} catch (err) { } catch (err) {
alert(`Error: ${err.message}`) alert(`Error: ${err.message}`)
@@ -265,19 +497,40 @@ 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}
pomodoroSettings={pomodoroSettings}
/> />
))} ))}
</div> </div>

View File

@@ -0,0 +1,134 @@
import { X, Pause, Play, Square, SkipForward } from 'lucide-react'
import { usePomodoro } from '../context/PomodoroContext'
function formatCountdown(seconds) {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
}
export default function PomodoroWidget() {
const { activeTask, phase, secondsLeft, sessionCount, workMinutes, breakMinutes, pause, resume, stop, skipBreak } = usePomodoro()
if (phase === 'idle') return null
const isBreak = phase === 'break'
const isPaused = phase === 'paused'
const totalSecs = isBreak ? breakMinutes * 60 : workMinutes * 60
const progress = totalSecs > 0 ? (totalSecs - secondsLeft) / totalSecs : 0
const phaseLabel = isBreak ? '☕ BREAK' : '🍅 WORK'
const phaseColor = isBreak ? 'text-green-400' : 'text-cyber-orange'
const borderColor = isBreak ? 'border-green-500/50' : 'border-cyber-orange/50'
return (
<div className={`fixed bottom-4 right-4 z-50 w-72 bg-cyber-darkest border ${borderColor} rounded-lg shadow-cyber-lg`}>
{/* Header */}
<div className="flex items-center justify-between px-4 pt-3 pb-1">
<div className="flex items-center gap-2">
<span className={`text-xs font-bold tracking-wider ${phaseColor}`}>
{phaseLabel}
{!isBreak && sessionCount > 0 && (
<span className="ml-2 text-gray-500 font-normal">#{sessionCount + 1}</span>
)}
</span>
</div>
<button
onClick={stop}
className="text-gray-500 hover:text-gray-300 transition-colors"
title="Stop and close"
>
<X size={16} />
</button>
</div>
{/* Task name */}
{activeTask && !isBreak && (
<div className="px-4 pb-1">
<p className="text-sm text-gray-300 truncate" title={activeTask.title}>
{activeTask.title}
</p>
</div>
)}
{/* Timer display */}
<div className="px-4 py-2 text-center">
<span className={`text-4xl font-mono font-bold tabular-nums ${isPaused ? 'text-gray-500' : phaseColor}`}>
{formatCountdown(secondsLeft)}
</span>
</div>
{/* Progress bar */}
<div className="px-4 pb-2">
<div className="h-1 bg-cyber-darker rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-1000 ${isBreak ? 'bg-green-500' : 'bg-cyber-orange'}`}
style={{ width: `${progress * 100}%` }}
/>
</div>
</div>
{/* Controls */}
<div className="flex gap-2 px-4 pb-3">
{isBreak ? (
<>
<button
onClick={skipBreak}
className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 text-xs bg-cyber-darker border border-cyber-orange/30 text-gray-300 rounded hover:border-cyber-orange/60 hover:text-gray-100 transition-colors"
>
<SkipForward size={13} />
Skip Break
</button>
<button
onClick={stop}
className="flex items-center justify-center gap-1 px-3 py-1.5 text-xs bg-cyber-darker border border-red-500/30 text-red-400 rounded hover:border-red-500/60 transition-colors"
>
<Square size={13} />
Stop
</button>
</>
) : (
<>
{isPaused ? (
<button
onClick={resume}
className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 text-xs bg-cyber-orange/20 border border-cyber-orange text-cyber-orange rounded hover:bg-cyber-orange/30 transition-colors"
>
<Play size={13} />
Resume
</button>
) : (
<button
onClick={pause}
className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 text-xs bg-cyber-darker border border-cyber-orange/30 text-gray-300 rounded hover:border-cyber-orange/60 hover:text-gray-100 transition-colors"
>
<Pause size={13} />
Pause
</button>
)}
<button
onClick={stop}
className="flex items-center justify-center gap-1 px-3 py-1.5 text-xs bg-cyber-darker border border-red-500/30 text-red-400 rounded hover:border-red-500/60 transition-colors"
>
<Square size={13} />
Stop
</button>
</>
)}
</div>
{/* Session count dots */}
{sessionCount > 0 && (
<div className="flex justify-center gap-1 pb-3">
{Array.from({ length: Math.min(sessionCount, 8) }).map((_, i) => (
<div key={i} className="w-1.5 h-1.5 rounded-full bg-cyber-orange/60" />
))}
{sessionCount > 8 && (
<span className="text-xs text-gray-500 ml-1">+{sessionCount - 8}</span>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,427 @@
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)
// Time & Goals fields
const [category, setCategory] = useState(project.category || '')
const [weeklyGoalHours, setWeeklyGoalHours] = useState(
project.weekly_hours_goal ? Math.floor(project.weekly_hours_goal / 60) : ''
)
const [weeklyGoalMins, setWeeklyGoalMins] = useState(
project.weekly_hours_goal ? project.weekly_hours_goal % 60 : ''
)
const [totalGoalHours, setTotalGoalHours] = useState(
project.total_hours_goal ? Math.floor(project.total_hours_goal / 60) : ''
)
const [totalGoalMins, setTotalGoalMins] = useState(
project.total_hours_goal ? project.total_hours_goal % 60 : ''
)
const [pomodoroWork, setPomodoroWork] = useState(project.pomodoro_work_minutes || 25)
const [pomodoroBreak, setPomodoroBreak] = useState(project.pomodoro_break_minutes || 5)
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
}
const weeklyMinutes =
(parseInt(weeklyGoalHours) || 0) * 60 + (parseInt(weeklyGoalMins) || 0)
const totalMinutes =
(parseInt(totalGoalHours) || 0) * 60 + (parseInt(totalGoalMins) || 0)
try {
await updateProject(project.id, {
statuses,
category: category.trim() || null,
weekly_hours_goal: weeklyMinutes > 0 ? weeklyMinutes : null,
total_hours_goal: totalMinutes > 0 ? totalMinutes : null,
pomodoro_work_minutes: parseInt(pomodoroWork) || 25,
pomodoro_break_minutes: parseInt(pomodoroBreak) || 5,
})
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>
{/* Time & Goals */}
<div className="mt-6 pt-6 border-t border-cyber-orange/20">
<h3 className="text-lg font-semibold text-gray-200 mb-4">Time & Goals</h3>
<div className="space-y-4">
{/* Category */}
<div>
<label className="block text-sm text-gray-400 mb-1">Category</label>
<input
type="text"
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="e.g. home, programming, hobby"
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
/>
</div>
{/* Weekly goal */}
<div>
<label className="block text-sm text-gray-400 mb-1">Weekly Time Goal</label>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
value={weeklyGoalHours}
onChange={(e) => setWeeklyGoalHours(e.target.value)}
placeholder="0"
className="w-20 px-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
/>
<span className="text-gray-500 text-sm">hr</span>
<input
type="number"
min="0"
max="59"
value={weeklyGoalMins}
onChange={(e) => setWeeklyGoalMins(e.target.value)}
placeholder="0"
className="w-20 px-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
/>
<span className="text-gray-500 text-sm">min / week</span>
</div>
</div>
{/* Total budget */}
<div>
<label className="block text-sm text-gray-400 mb-1">Total Time Budget</label>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
value={totalGoalHours}
onChange={(e) => setTotalGoalHours(e.target.value)}
placeholder="0"
className="w-20 px-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
/>
<span className="text-gray-500 text-sm">hr</span>
<input
type="number"
min="0"
max="59"
value={totalGoalMins}
onChange={(e) => setTotalGoalMins(e.target.value)}
placeholder="0"
className="w-20 px-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
/>
<span className="text-gray-500 text-sm">min total</span>
</div>
</div>
{/* Pomodoro settings */}
<div>
<label className="block text-sm text-gray-400 mb-1">Pomodoro Settings</label>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<input
type="number"
min="1"
max="120"
value={pomodoroWork}
onChange={(e) => setPomodoroWork(e.target.value)}
className="w-16 px-2 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange text-center"
/>
<span className="text-gray-500 text-sm">min work</span>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
max="60"
value={pomodoroBreak}
onChange={(e) => setPomodoroBreak(e.target.value)}
className="w-16 px-2 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange text-center"
/>
<span className="text-gray-500 text-sm">min break</span>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-3 p-6 border-t border-cyber-orange/20">
<button
onClick={onClose}
className="px-4 py-2 text-gray-400 hover:text-gray-200"
>
Cancel
</button>
<button
onClick={handleSave}
className="px-4 py-2 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright font-semibold transition-colors"
>
Save Changes
</button>
</div>
{/* Delete Warning Dialog */}
{deleteWarning && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<div className="bg-cyber-darkest border border-red-500/50 rounded-lg p-6 max-w-md">
<div className="flex items-start gap-3 mb-4">
<AlertTriangle className="text-red-400 flex-shrink-0" size={24} />
<div>
<h3 className="text-lg font-semibold text-gray-100 mb-2">Cannot Delete Status</h3>
<p className="text-sm text-gray-300">
The status "{deleteWarning.status}" has {deleteWarning.count} task{deleteWarning.count === 1 ? '' : 's'}.
Please move or delete those tasks first.
</p>
</div>
</div>
<div className="flex justify-end">
<button
onClick={() => setDeleteWarning(null)}
className="px-4 py-2 bg-cyber-orange text-cyber-darkest rounded hover:bg-cyber-orange-bright font-semibold"
>
OK
</button>
</div>
</div>
</div>
)}
</div>
</div>
)
}
export default ProjectSettings

View File

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

View File

@@ -12,12 +12,22 @@ const FLAG_COLORS = [
{ name: 'pink', label: 'Pink', color: 'bg-pink-500' } { name: 'pink', label: 'Pink', color: 'bg-pink-500' }
] ]
function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) { // Helper to format status label
const formatStatusLabel = (status) => {
return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
}
function TaskForm({ onSubmit, onCancel, submitLabel = "Add", projectStatuses = null, defaultStatus = "backlog" }) {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [tags, setTags] = useState('') const [tags, setTags] = useState('')
const [hours, setHours] = useState('') const [hours, setHours] = useState('')
const [minutes, setMinutes] = useState('') const [minutes, setMinutes] = useState('')
const [flagColor, setFlagColor] = useState(null) const [flagColor, setFlagColor] = useState(null)
const [status, setStatus] = useState(defaultStatus)
// Use provided statuses or fall back to defaults
const statuses = projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']
const handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault() e.preventDefault()
@@ -33,9 +43,11 @@ function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) {
const taskData = { const taskData = {
title: title.trim(), title: title.trim(),
description: description.trim() || null,
tags: tagList && tagList.length > 0 ? tagList : null, tags: tagList && tagList.length > 0 ? tagList : null,
estimated_minutes: totalMinutes > 0 ? totalMinutes : null, estimated_minutes: totalMinutes > 0 ? totalMinutes : null,
flag_color: flagColor flag_color: flagColor,
status: status
} }
onSubmit(taskData) onSubmit(taskData)
@@ -56,6 +68,18 @@ function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) {
/> />
</div> </div>
{/* Description */}
<div>
<label className="block text-xs text-gray-400 mb-1">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional task description..."
rows="3"
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange resize-y"
/>
</div>
{/* Tags */} {/* Tags */}
<div> <div>
<label className="block text-xs text-gray-400 mb-1">Tags (comma-separated)</label> <label className="block text-xs text-gray-400 mb-1">Tags (comma-separated)</label>
@@ -96,6 +120,22 @@ function TaskForm({ onSubmit, onCancel, submitLabel = "Add" }) {
</div> </div>
</div> </div>
{/* Status */}
<div>
<label className="block text-xs text-gray-400 mb-1">Status</label>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
>
{statuses.map((s) => (
<option key={s} value={s}>
{formatStatusLabel(s)}
</option>
))}
</select>
</div>
{/* Flag Color */} {/* Flag Color */}
<div> <div>
<label className="block text-xs text-gray-400 mb-1">Flag Color</label> <label className="block text-xs text-gray-400 mb-1">Flag Color</label>

View File

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

View File

@@ -6,7 +6,8 @@ import {
Check, Check,
X, X,
Flag, Flag,
Clock Clock,
Timer
} from 'lucide-react' } from 'lucide-react'
import { import {
getProjectTaskTree, getProjectTaskTree,
@@ -17,19 +18,22 @@ import {
import { formatTimeWithTotal } from '../utils/format' import { formatTimeWithTotal } from '../utils/format'
import TaskMenu from './TaskMenu' import TaskMenu from './TaskMenu'
import TaskForm from './TaskForm' import TaskForm from './TaskForm'
import { usePomodoro } from '../context/PomodoroContext'
const STATUS_COLORS = { // Helper to format status label
backlog: 'text-gray-400', const formatStatusLabel = (status) => {
in_progress: 'text-blue-400', return status.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
blocked: 'text-red-400',
done: 'text-green-400'
} }
const STATUS_LABELS = { // Helper to get status color
backlog: 'Backlog', const getStatusColor = (status) => {
in_progress: 'In Progress', const lowerStatus = status.toLowerCase()
blocked: 'Blocked', if (lowerStatus === 'backlog') return 'text-gray-400'
done: 'Done' if (lowerStatus === 'in_progress' || lowerStatus.includes('progress')) return 'text-blue-400'
if (lowerStatus === 'on_hold' || lowerStatus.includes('hold') || lowerStatus.includes('waiting')) return 'text-yellow-400'
if (lowerStatus === 'done' || lowerStatus.includes('complete')) return 'text-green-400'
if (lowerStatus.includes('blocked')) return 'text-red-400'
return 'text-purple-400' // default for custom statuses
} }
const FLAG_COLORS = { const FLAG_COLORS = {
@@ -42,12 +46,14 @@ const FLAG_COLORS = {
pink: 'bg-pink-500' pink: 'bg-pink-500'
} }
function TaskNode({ task, projectId, onUpdate, level = 0 }) { function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses, pomodoroSettings }) {
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 { startPomodoro, activeTask, phase } = usePomodoro()
const isActiveTimer = activeTask?.id === task.id && phase !== 'idle'
const hasSubtasks = task.subtasks && task.subtasks.length > 0 const hasSubtasks = task.subtasks && task.subtasks.length > 0
@@ -80,7 +86,8 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
project_id: parseInt(projectId), project_id: parseInt(projectId),
parent_task_id: task.id, parent_task_id: task.id,
title: taskData.title, title: taskData.title,
status: 'backlog', description: taskData.description,
status: taskData.status,
tags: taskData.tags, tags: taskData.tags,
estimated_minutes: taskData.estimated_minutes, estimated_minutes: taskData.estimated_minutes,
flag_color: taskData.flag_color flag_color: taskData.flag_color
@@ -125,10 +132,9 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
onChange={(e) => setEditStatus(e.target.value)} onChange={(e) => setEditStatus(e.target.value)}
className="px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange" className="px-2 py-1 bg-cyber-darker border border-cyber-orange/50 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
> >
<option value="backlog">Backlog</option> {(projectStatuses || ['backlog', 'in_progress', 'on_hold', 'done']).map(status => (
<option value="in_progress">In Progress</option> <option key={status} value={status}>{formatStatusLabel(status)}</option>
<option value="blocked">Blocked</option> ))}
<option value="done">Done</option>
</select> </select>
<button <button
onClick={handleSave} onClick={handleSave}
@@ -156,8 +162,8 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
<Flag size={14} className={`${FLAG_COLORS[task.flag_color].replace('bg-', 'text-')}`} fill="currentColor" /> <Flag size={14} className={`${FLAG_COLORS[task.flag_color].replace('bg-', 'text-')}`} fill="currentColor" />
)} )}
<span className="text-gray-200">{task.title}</span> <span className="text-gray-200">{task.title}</span>
<span className={`ml-2 text-xs ${STATUS_COLORS[task.status]}`}> <span className={`ml-2 text-xs ${getStatusColor(task.status)}`}>
{STATUS_LABELS[task.status]} {formatStatusLabel(task.status)}
</span> </span>
</div> </div>
@@ -166,7 +172,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
<div className="flex items-center gap-3 mt-1"> <div className="flex items-center gap-3 mt-1">
{/* Time estimate */} {/* Time estimate */}
{formatTimeWithTotal(task) && ( {formatTimeWithTotal(task) && (
<div className="flex items-center gap-1 text-xs text-gray-500"> <div className={`flex items-center gap-1 text-xs text-gray-500 ${task.status === 'done' ? 'line-through' : ''}`}>
<Clock size={12} /> <Clock size={12} />
<span>{formatTimeWithTotal(task)}</span> <span>{formatTimeWithTotal(task)}</span>
</div> </div>
@@ -187,10 +193,31 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
)} )}
</div> </div>
)} )}
{/* Description */}
{task.description && (
<div className="mt-2 text-xs text-gray-400 italic">
{task.description}
</div>
)}
</div> </div>
{/* Actions */} {/* Actions */}
<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
onClick={(e) => {
e.stopPropagation()
startPomodoro(
{ id: task.id, title: task.title, estimatedMinutes: task.estimated_minutes },
pomodoroSettings?.workMinutes || 25,
pomodoroSettings?.breakMinutes || 5
)
}}
className={`transition-colors ${isActiveTimer ? 'text-cyber-orange' : 'text-gray-400 hover:text-cyber-orange'}`}
title={isActiveTimer ? 'Timer running' : 'Start Pomodoro'}
>
<Timer size={16} />
</button>
<button <button
onClick={() => setShowAddSubtask(true)} onClick={() => setShowAddSubtask(true)}
className="text-cyber-orange hover:text-cyber-orange-bright" className="text-cyber-orange hover:text-cyber-orange-bright"
@@ -203,6 +230,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
onUpdate={onUpdate} onUpdate={onUpdate}
onDelete={handleDelete} onDelete={handleDelete}
onEdit={() => setIsEditing(true)} onEdit={() => setIsEditing(true)}
projectStatuses={projectStatuses}
/> />
</div> </div>
</> </>
@@ -216,6 +244,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
onSubmit={handleAddSubtask} onSubmit={handleAddSubtask}
onCancel={() => setShowAddSubtask(false)} onCancel={() => setShowAddSubtask(false)}
submitLabel="Add Subtask" submitLabel="Add Subtask"
projectStatuses={projectStatuses}
/> />
</div> </div>
)} )}
@@ -230,6 +259,8 @@ function TaskNode({ task, projectId, onUpdate, level = 0 }) {
projectId={projectId} projectId={projectId}
onUpdate={onUpdate} onUpdate={onUpdate}
level={level + 1} level={level + 1}
projectStatuses={projectStatuses}
pomodoroSettings={pomodoroSettings}
/> />
))} ))}
</div> </div>
@@ -238,7 +269,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 pomodoroSettings = {
workMinutes: project?.pomodoro_work_minutes || 25,
breakMinutes: project?.pomodoro_break_minutes || 5,
}
const [tasks, setTasks] = useState([]) const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -266,7 +302,8 @@ function TreeView({ projectId }) {
project_id: parseInt(projectId), project_id: parseInt(projectId),
parent_task_id: null, parent_task_id: null,
title: taskData.title, title: taskData.title,
status: 'backlog', description: taskData.description,
status: taskData.status,
tags: taskData.tags, tags: taskData.tags,
estimated_minutes: taskData.estimated_minutes, estimated_minutes: taskData.estimated_minutes,
flag_color: taskData.flag_color flag_color: taskData.flag_color
@@ -305,6 +342,7 @@ function TreeView({ projectId }) {
onSubmit={handleAddRootTask} onSubmit={handleAddRootTask}
onCancel={() => setShowAddRoot(false)} onCancel={() => setShowAddRoot(false)}
submitLabel="Add Task" submitLabel="Add Task"
projectStatuses={projectStatuses}
/> />
</div> </div>
)} )}
@@ -322,6 +360,8 @@ function TreeView({ projectId }) {
task={task} task={task}
projectId={projectId} projectId={projectId}
onUpdate={loadTasks} onUpdate={loadTasks}
projectStatuses={projectStatuses}
pomodoroSettings={pomodoroSettings}
/> />
))} ))}
</div> </div>

View File

@@ -0,0 +1,204 @@
import { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react'
import { logTime } from '../utils/api'
const PomodoroContext = createContext(null)
export function PomodoroProvider({ children }) {
const [activeTask, setActiveTask] = useState(null) // { id, title, estimatedMinutes }
const [phase, setPhase] = useState('idle') // 'idle' | 'work' | 'break' | 'paused'
const [secondsLeft, setSecondsLeft] = useState(0)
const [sessionCount, setSessionCount] = useState(0)
const [workMinutes, setWorkMinutes] = useState(25)
const [breakMinutes, setBreakMinutes] = useState(5)
const [pausedPhase, setPausedPhase] = useState(null) // which phase we paused from
const [secondsWhenPaused, setSecondsWhenPaused] = useState(0)
// Track how many seconds actually elapsed in the current work session
// (for partial sessions when user stops early)
const elapsedWorkSeconds = useRef(0)
const intervalRef = useRef(null)
const playBeep = useCallback((frequency = 440, duration = 0.4) => {
try {
const ctx = new AudioContext()
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.connect(gain)
gain.connect(ctx.destination)
osc.frequency.value = frequency
gain.gain.setValueAtTime(0.3, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration)
osc.start(ctx.currentTime)
osc.stop(ctx.currentTime + duration)
} catch (e) {
// AudioContext not available (e.g. in tests)
}
}, [])
const sendNotification = useCallback((title, body) => {
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
new Notification(title, { body, icon: '/vite.svg' })
}
}, [])
const requestNotificationPermission = useCallback(() => {
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
Notification.requestPermission()
}
}, [])
const autoLogCompletedSession = useCallback(async (taskId, minutes, sessionType) => {
if (minutes <= 0) return
try {
await logTime(taskId, { minutes, session_type: sessionType })
} catch (e) {
console.error('Failed to auto-log time:', e)
}
}, [])
// Tick logic — runs every second when active
useEffect(() => {
if (phase !== 'work' && phase !== 'break') {
clearInterval(intervalRef.current)
return
}
intervalRef.current = setInterval(() => {
setSecondsLeft(prev => {
if (prev <= 1) {
// Time's up
clearInterval(intervalRef.current)
if (phase === 'work') {
// Auto-log the completed pomodoro
autoLogCompletedSession(
activeTask.id,
workMinutes,
'pomodoro'
)
elapsedWorkSeconds.current = 0
// Play double beep + notification
playBeep(523, 0.3)
setTimeout(() => playBeep(659, 0.4), 350)
sendNotification('BIT — Pomodoro done!', `Time for a break. Session ${sessionCount + 1} complete.`)
setSessionCount(c => c + 1)
if (breakMinutes > 0) {
setPhase('break')
return breakMinutes * 60
} else {
// No break configured — go straight back to work
setPhase('work')
return workMinutes * 60
}
} else {
// Break over
playBeep(440, 0.3)
setTimeout(() => playBeep(523, 0.4), 350)
sendNotification('BIT — Break over!', 'Ready for the next pomodoro?')
setPhase('work')
return workMinutes * 60
}
}
if (phase === 'work') {
elapsedWorkSeconds.current += 1
}
return prev - 1
})
}, 1000)
return () => clearInterval(intervalRef.current)
}, [phase, activeTask, workMinutes, breakMinutes, sessionCount, autoLogCompletedSession, playBeep, sendNotification])
const startPomodoro = useCallback((task, workMins = 25, breakMins = 5) => {
// If a session is active, log partial time first
if (phase === 'work' && activeTask && elapsedWorkSeconds.current > 0) {
const partialMinutes = Math.round(elapsedWorkSeconds.current / 60)
if (partialMinutes > 0) {
autoLogCompletedSession(activeTask.id, partialMinutes, 'pomodoro')
}
}
clearInterval(intervalRef.current)
elapsedWorkSeconds.current = 0
requestNotificationPermission()
setActiveTask(task)
setWorkMinutes(workMins)
setBreakMinutes(breakMins)
setSessionCount(0)
setPhase('work')
setSecondsLeft(workMins * 60)
}, [phase, activeTask, autoLogCompletedSession, requestNotificationPermission])
const pause = useCallback(() => {
if (phase !== 'work' && phase !== 'break') return
clearInterval(intervalRef.current)
setPausedPhase(phase)
setSecondsWhenPaused(secondsLeft)
setPhase('paused')
}, [phase, secondsLeft])
const resume = useCallback(() => {
if (phase !== 'paused') return
setPhase(pausedPhase)
setSecondsLeft(secondsWhenPaused)
}, [phase, pausedPhase, secondsWhenPaused])
const stop = useCallback(async () => {
clearInterval(intervalRef.current)
// Log partial work time if we were mid-session
const currentPhase = phase === 'paused' ? pausedPhase : phase
if (currentPhase === 'work' && activeTask && elapsedWorkSeconds.current > 0) {
const partialMinutes = Math.round(elapsedWorkSeconds.current / 60)
if (partialMinutes > 0) {
await autoLogCompletedSession(activeTask.id, partialMinutes, 'pomodoro')
}
}
elapsedWorkSeconds.current = 0
setActiveTask(null)
setPhase('idle')
setSecondsLeft(0)
setSessionCount(0)
setPausedPhase(null)
}, [phase, pausedPhase, activeTask, autoLogCompletedSession])
const skipBreak = useCallback(() => {
if (phase !== 'break') return
clearInterval(intervalRef.current)
setPhase('work')
setSecondsLeft(workMinutes * 60)
elapsedWorkSeconds.current = 0
}, [phase, workMinutes])
return (
<PomodoroContext.Provider value={{
activeTask,
phase,
secondsLeft,
sessionCount,
workMinutes,
breakMinutes,
startPomodoro,
pause,
resume,
stop,
skipBreak,
}}>
{children}
</PomodoroContext.Provider>
)
}
export function usePomodoro() {
const ctx = useContext(PomodoroContext)
if (!ctx) throw new Error('usePomodoro must be used inside PomodoroProvider')
return ctx
}

View File

@@ -1,10 +1,12 @@
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, Clock } from 'lucide-react'
import { getProjects, createProject, deleteProject, importJSON } from '../utils/api' import { getProjects, createProject, deleteProject, importJSON, archiveProject, unarchiveProject, getProjectTimeSummary } from '../utils/api'
import { formatTime } from '../utils/format'
function ProjectList() { function ProjectList() {
const [projects, setProjects] = useState([]) const [projects, setProjects] = useState([])
const [timeSummaries, setTimeSummaries] = useState({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showCreateModal, setShowCreateModal] = useState(false) const [showCreateModal, setShowCreateModal] = useState(false)
const [showImportModal, setShowImportModal] = useState(false) const [showImportModal, setShowImportModal] = useState(false)
@@ -12,16 +14,36 @@ 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 [activeCategory, setActiveCategory] = useState(null)
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
const data = await getProjects(archivedFilter)
setProjects(data) setProjects(data)
// Fetch time summaries in parallel for all projects
const summaryEntries = await Promise.all(
data.map(async (p) => {
try {
const summary = await getProjectTimeSummary(p.id)
return [p.id, summary]
} catch {
return [p.id, null]
}
})
)
setTimeSummaries(Object.fromEntries(summaryEntries))
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@@ -72,13 +94,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,44 +140,215 @@ function ProjectList() {
</div> </div>
</div> </div>
{/* Category filter */}
{(() => {
const categories = [...new Set(projects.map(p => p.category).filter(Boolean))].sort()
if (categories.length === 0) return null
return (
<div className="flex gap-2 flex-wrap mb-4">
<button
onClick={() => setActiveCategory(null)}
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
activeCategory === null
? 'bg-cyber-orange/20 border-cyber-orange text-cyber-orange'
: 'border-cyber-orange/30 text-gray-400 hover:border-cyber-orange/60'
}`}
>
All
</button>
{categories.map(cat => (
<button
key={cat}
onClick={() => setActiveCategory(activeCategory === cat ? null : cat)}
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
activeCategory === cat
? 'bg-cyber-orange/20 border-cyber-orange text-cyber-orange'
: 'border-cyber-orange/30 text-gray-400 hover:border-cyber-orange/60'
}`}
>
{cat}
</button>
))}
</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}
</div> </div>
)} )}
{projects.length === 0 ? ( {(() => {
const visibleProjects = activeCategory
? projects.filter(p => p.category === activeCategory)
: projects
if (visibleProjects.length === 0) return (
<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>
) : ( )
return null
})()}
{projects.length > 0 && (
<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">
{projects.map(project => ( {(activeCategory ? projects.filter(p => p.category === activeCategory) : projects).map(project => {
const summary = timeSummaries[project.id]
const weeklyPct = summary && summary.weekly_hours_goal
? Math.min(100, Math.round((summary.weekly_minutes / summary.weekly_hours_goal) * 100))
: null
const totalPct = summary && summary.total_hours_goal
? Math.min(100, Math.round((summary.total_minutes / summary.total_hours_goal) * 100))
: null
return (
<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>
{/* Category badge */}
{project.category && (
<span className="inline-block px-2 py-0.5 text-xs bg-cyber-orange/10 text-cyber-orange/70 border border-cyber-orange/20 rounded-full mb-2">
{project.category}
</span>
)}
{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>
)} )}
{/* Time summary */}
{summary && summary.total_minutes > 0 && (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2 text-xs text-gray-500">
<Clock size={11} />
<span>{formatTime(summary.total_minutes)} logged total</span>
{summary.weekly_minutes > 0 && (
<span className="text-gray-600">· {formatTime(summary.weekly_minutes)} this week</span>
)}
</div>
{/* Weekly goal bar */}
{weeklyPct !== null && (
<div>
<div className="flex justify-between text-xs text-gray-600 mb-0.5">
<span>Weekly goal</span>
<span>{weeklyPct}%</span>
</div>
<div className="h-1.5 bg-cyber-darker rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${weeklyPct >= 100 ? 'bg-green-500' : 'bg-cyber-orange'}`}
style={{ width: `${weeklyPct}%` }}
/>
</div>
</div>
)}
{/* Total budget bar */}
{totalPct !== null && (
<div>
<div className="flex justify-between text-xs text-gray-600 mb-0.5">
<span>Total budget</span>
<span>{totalPct}%</span>
</div>
<div className="h-1.5 bg-cyber-darker rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${totalPct >= 100 ? 'bg-red-500' : 'bg-cyber-orange/60'}`}
style={{ width: `${totalPct}%` }}
/>
</div>
</div>
)}
</div>
)}
<p className="text-xs text-gray-600 mt-3"> <p className="text-xs text-gray-600 mt-3">
Created {new Date(project.created_at).toLocaleDateString()} Created {new Date(project.created_at).toLocaleDateString()}
</p> </p>
</div> </div>
))} )
})}
</div> </div>
)} )}

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`);
@@ -57,6 +66,14 @@ export const importJSON = (data) => fetchAPI('/import-json', {
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
// Time Logs
export const logTime = (taskId, data) => fetchAPI(`/tasks/${taskId}/time-logs`, {
method: 'POST',
body: JSON.stringify(data),
});
export const getTimeLogs = (taskId) => fetchAPI(`/tasks/${taskId}/time-logs`);
export const getProjectTimeSummary = (projectId) => fetchAPI(`/projects/${projectId}/time-summary`);
// Search // Search
export const searchTasks = (query, projectIds = null) => { export const searchTasks = (query, projectIds = null) => {
const params = new URLSearchParams({ query }); const params = new URLSearchParams({ query });

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

511
v0_2_0-build-spec.md Normal file
View File

@@ -0,0 +1,511 @@
Break It Down (BIT) — Campaign Mode + Entropy + Wizard Build Spec
0) Product idea in one sentence
BIT is the planning HQ (deep task decomposition). You can “Launch Campaign” on a project to switch into a low-friction operations mode that nudges you, runs timeboxed sessions, captures before/after photos, and visualizes “entropy” creeping back into recurring home zones via a strategy-game-style stability map.
1) Non-negotiable design principles
“Start a session” must be 1 tap from Campaign view. No forms before action.
Entropy features only apply to projects in entropy_space mode; standard projects remain unchanged.
Planning (Tree/Kanban) and Doing (Campaign) are separate routes and separate UI skins.
Wizard must generate “good enough” structure in <30 seconds and optionally auto-start the first session.
2) Core Concepts & Modes
2.1 Project Modes (enum)
standard: current BIT behavior (Tree + Kanban).
entropy_space: physical-space / recurring maintenance / territory reclaiming.
(future) pipeline_chores: chores with stages/recurrence (laundry/dishes).
(future) deep_work: focus sessions (pomodoro defaults).
2.2 Campaign Types (enum)
finite: one-off liberation (garage purge). Optional/slow entropy decay.
background: ongoing maintenance (bathroom/kitchen). Entropy decay enabled.
2.3 Zone (v1 implementation)
In entropy_space projects, a Zone is a top-level task (parent_task_id IS NULL).
2.4 Session (generic), with “Strike” as a label
Backend stores generic sessions. Campaign UI calls them “strikes” for entropy projects.
Session kinds (enum):
strike (entropy_space)
pomodoro (future deep_work)
run (future pipeline chores)
freeform (optional)
3) User Flows
3.1 HQ Planning Flow (existing, with additions)
Create project (standard or via Wizard).
In entropy projects, create top-level “zones” + subtasks.
Hit Launch Campaign button → opens Campaign route.
3.2 Campaign Operations Flow (new)
Campaign view shows only:
Zone cards (stability color + bar + last worked)
Recommended micro-missions
Big Start Strike button per zone
Flow:
Tap “Start Strike” on a Zone (1 tap)
Timer begins immediately (default length buttons: 8 / 12 / 25 min)
Optional prompt: “Snap BEFORE?” (default enabled for entropy_space)
End Strike → prompt “Snap AFTER”
Immediately show Before/After compare slider (dopamine)
Save: session record + optional quick note
Zone stability increases; decay continues over time
3.3 Wizard Flow (new)
Goal: generate project + zones + micro-missions + defaults quickly.
Wizard inputs:
Area template (Garage / Bathroom / Kitchen / Whole House / Office)
Campaign type (Finite vs Background)
Intensity (Light / Normal / Disaster)
Nudge window (e.g. 9am9pm) + max nudges/day
Default strike buttons (8/12/25 or 10/20/30)
Wizard outputs:
Project with project_mode=entropy_space
Top-level zone tasks
Subtasks (micro-missions)
Decay defaults per zone (background campaigns)
Option: auto-launch campaign and auto-start first strike
4) Data Model Changes (SQLite + SQLAlchemy)
4.1 projects table additions
project_mode TEXT NOT NULL DEFAULT 'standard'
campaign_type TEXT NULL (only meaningful if project_mode=entropy_space)
campaign_active BOOLEAN NOT NULL DEFAULT 0
nudge_enabled BOOLEAN NOT NULL DEFAULT 0
nudge_window_start TEXT NULL (HH:MM)
nudge_window_end TEXT NULL
nudge_min_interval_minutes INTEGER NULL
nudge_max_per_day INTEGER NULL
photo_proof_enabled BOOLEAN NOT NULL DEFAULT 0
default_session_kind TEXT NOT NULL DEFAULT 'freeform'
default_session_minutes INTEGER NOT NULL DEFAULT 12
Notes:
Keep these nullable/ignored for standard projects.
Dont add auth/user tables yet unless you already have them.
4.2 tasks table additions (only used for entropy zones in v1)
stability_base INTEGER NULL (stored “as of last_update”; 0100)
decay_rate_per_day REAL NULL
last_stability_update_at DATETIME NULL
last_strike_at DATETIME NULL
is_zone BOOLEAN NOT NULL DEFAULT 0 (set true for top-level zone tasks in entropy projects; keeps logic explicit)
(optional) zone_preset TEXT NULL (bathroom/kitchen/garage for defaults)
4.3 New table: work_sessions
id PK
project_id FK
task_id FK NULL (zone task)
kind TEXT NOT NULL
started_at DATETIME NOT NULL
ended_at DATETIME NULL
duration_seconds INTEGER NULL (set on stop)
note TEXT NULL
4.4 New table: session_photos
id PK
session_id FK
phase TEXT NOT NULL (before, after)
path TEXT NOT NULL
taken_at DATETIME NOT NULL
(optional) width, height, hash
4.5 Storage for images
Store files on disk in a Docker volume: /data/photos/...
DB stores only paths/keys
Provide /api/media/... endpoint to serve images (or configure nginx static)
5) Entropy / Stability Logic (v1)
5.1 Stability computation approach
Zones track stability via a base value plus decay since last update.
Fields used:
stability_base
last_stability_update_at
decay_rate_per_day
Derived:
stability_now = max(0, stability_base - decay_rate_per_day * days_elapsed)
On “strike completed”:
compute stability_now
apply boost: stability_base = min(100, stability_now + boost)
set last_stability_update_at = now
set last_strike_at = now
Boost rule (v1 simple):
boost = 20 for any completed strike
OR
boost = clamp(10, 35, round(duration_minutes * 1.5))
Pick one (fixed boost is easiest to reason about).
5.2 Color mapping (no raw numbers by default)
80100: green
5579: yellow
3054: orange
029: red
5.3 Nudges (v1 simple scheduler)
A background job runs every X minutes (or on app open for v0):
For each entropy_space project with nudge_enabled:
for each zone:
compute stability_now
if stability_now <= threshold AND not nudged too recently AND within time window:
create “nudge event” (v0: show in UI inbox; v1: web push/email)
Start with in-app “Nudge Inbox” so you dont need push infra immediately.
6) API Spec (FastAPI)
6.1 Projects
POST /api/projects (add project_mode + campaign settings fields)
PUT /api/projects/{id} (update campaign settings and campaign_active)
POST /api/projects/{id}/launch_campaign (sets campaign_active true; returns campaign URL)
6.2 Tasks
Existing endpoints unchanged, but:
POST /api/tasks should allow is_zone, decay_rate_per_day, etc. when project_mode=entropy_space.
New:
GET /api/projects/{id}/zones → list top-level zone tasks with computed stability_now and color
6.3 Sessions
POST /api/sessions/start
body: {project_id, task_id?, kind?, planned_minutes?}
returns: {session_id, started_at}
POST /api/sessions/{id}/stop
body: {note?}
server sets ended_at + duration_seconds; updates zone stability if task_id references a zone
GET /api/projects/{id}/sessions (filters by kind/date optional)
GET /api/tasks/{id}/sessions
6.4 Photos
POST /api/sessions/{id}/photos (multipart: file + phase)
GET /api/sessions/{id}/photos → returns URLs/paths
(optional) GET /api/media/{path} or nginx static mapping
6.5 Wizard
POST /api/wizard/build_project
input: {template_id, intensity, campaign_type, nudge_settings, strike_defaults, auto_launch, auto_start}
output: {project_id, campaign_url, session_id?}
7) Frontend Spec (React + Tailwind)
7.1 Routes
/projects (existing)
/projects/:id (HQ planning view: Tree/Kanban)
/projects/:id/campaign (Campaign view)
7.2 Campaign View Components
CampaignHeader
project name
“Back to HQ” link
campaign settings shortcut
ZoneGrid
list of ZoneCards
ZoneCard
zone name
stability bar (color only)
“Start Strike” button
quick duration buttons (8/12/25)
last strike text (“3d ago”)
recommended micro-missions (top 13 subtasks)
StrikeModal (or full-screen on mobile)
timer running
optional “Snap Before”
“End Strike”
PhotoCapture
file input / camera capture on mobile
BeforeAfterCompare
slider compare (immediate reward)
NudgeInbox (v0 notifications)
list of pending nudges; tap takes you to zone
7.3 Wizard UI
NewProjectWizard
choose template card
choose campaign type + intensity
choose nudge window + max/day
review zones generated (editable list)
“Create + Launch Campaign” button
8) Templates (Wizard v1)
Store templates as JSON files in repo (e.g. backend/app/templates/*.json) or in frontend.
Template structure (conceptual):
project: name, description, mode=entropy_space
zones: list with zone_preset, default_decay, starter_subtasks (micro-missions)
intensity_modifiers: disaster adds extra subtasks + higher starting entropy or lower initial stability
Starter templates:
Garage (finite default)
Bathroom (background default)
Kitchen (background default)
Whole House Reset (finite, multiple zones)
Office/Desk (background or finite selectable)
9) Phased Build (so you actually ship it)
Phase 0: “Usable Tonight”
Add project_mode + campaign route
Zones list with stability bar (computed simply)
Start/stop session (strike) updates stability
No photos yet, no nudges
Phase 1: “Dopamine Injector”
Before/after photos tied to sessions
Compare slider immediately after session
Campaign view defaults for entropy projects
Phase 2: “Gentle Push”
Nudge Inbox + simple scheduler (no push)
Nudge thresholds + per-project window settings
Phase 3: “Grand Strategy”
Optional hex-grid visualization (C)
Zone tiles table + aggregation
Light “liberation” progress summary per project
Phase 4: “AI Wizard”
Replace/augment templates with LLM-generated breakdowns
Maintain wizard output contract (same JSON schema)
10) Codex Work Instructions (copy/paste to Codex)
Prompt 1 — Backend schema + models
“Implement Phase 0 backend changes for Break-It-Down:
Add fields to projects and tasks as specified (project_mode, campaign fields; zone stability fields).
Add new tables work_sessions and session_photos.
Update SQLAlchemy models and Pydantic schemas.
Add endpoints: /api/projects/{id}/zones, /api/sessions/start, /api/sessions/{id}/stop.
Implement stability calculation and strike boost on stop.
Ensure existing endpoints still work for standard projects.
Provide migration approach for SQLite (simple ALTER TABLE + create tables).”
Prompt 2 — Campaign frontend (Phase 0)
“Implement Campaign view route /projects/:id/campaign:
Fetch zones via /api/projects/{id}/zones
Render ZoneCards with stability bar color mapping.
Add Start Strike flow (start session, timer UI, stop session).
Ensure 1-tap Start Strike from ZoneCard starts session immediately.
Keep HQ view unchanged.”
Prompt 3 — Photos + compare (Phase 1)
“Add session photo upload and compare slider:
Backend: /api/sessions/{id}/photos multipart upload; store files under /data/photos with unique paths; return URLs.
Frontend: After stopping strike, prompt for after photo; show before/after compare slider.
Add optional before photo prompt at strike start.
Make it mobile-friendly (use capture attribute).”
Prompt 4 — Wizard (Phase 12)
“Add New Project Wizard:
Use local JSON templates to generate project + zones + subtasks.
Implement backend endpoint /api/wizard/build_project that creates project and tasks from template.
Wizard should optionally auto-launch campaign and auto-start first strike.”
Prompt 5 — Nudges (Phase 2)
“Implement in-app Nudges:
Backend job that evaluates stability and writes nudge events to DB.
Frontend Nudge Inbox list and click-through to zone.
Respect per-project windows, min interval, max/day.”