1 Commits

Author SHA1 Message Date
serversdown
5da6e075b4 feat: add task blocker dependency graph + actionable now view
- New task_blockers join table (many-to-many, cross-project)
- Cycle detection prevents circular dependencies
- GET /api/tasks/{id}/blockers and /blocking endpoints
- POST/DELETE /api/tasks/{id}/blockers/{blocker_id}
- GET /api/actionable — all non-done tasks with no incomplete blockers
- BlockerPanel.jsx — search & manage blockers per task (via task menu)
- ActionableView.jsx — "what can I do right now?" dashboard grouped by project
- "Now" button in nav header routes to actionable view
- migrate_add_blockers.py migration script for existing databases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 16:08:55 -04:00
21 changed files with 1919 additions and 2879 deletions

4
.gitignore vendored
View File

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

View File

@@ -1,244 +0,0 @@
# 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.

View File

@@ -1,253 +1,283 @@
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 . import models, schemas
from datetime import datetime, timedelta
from . import models, schemas
# Project CRUD
def create_project(db: Session, project: schemas.ProjectCreate) -> models.Project:
# Project CRUD project_data = project.model_dump()
def create_project(db: Session, project: schemas.ProjectCreate) -> models.Project: # Ensure statuses has a default value if not provided
project_data = project.model_dump() if project_data.get("statuses") is None:
# Ensure statuses has a default value if not provided project_data["statuses"] = models.DEFAULT_STATUSES
if project_data.get("statuses") is None:
project_data["statuses"] = models.DEFAULT_STATUSES db_project = models.Project(**project_data)
db.add(db_project)
db_project = models.Project(**project_data) db.commit()
db.add(db_project) db.refresh(db_project)
db.commit() return db_project
db.refresh(db_project)
return db_project
def get_project(db: Session, project_id: int) -> Optional[models.Project]:
return db.query(models.Project).filter(models.Project.id == project_id).first()
def get_project(db: Session, project_id: int) -> Optional[models.Project]:
return db.query(models.Project).filter(models.Project.id == project_id).first()
def get_projects(db: Session, skip: int = 0, limit: int = 100, archived: Optional[bool] = None) -> List[models.Project]:
query = db.query(models.Project)
def get_projects(db: Session, skip: int = 0, limit: int = 100, archived: Optional[bool] = None) -> List[models.Project]:
query = db.query(models.Project) # Filter by archive status if specified
if archived is not None:
# Filter by archive status if specified query = query.filter(models.Project.is_archived == archived)
if archived is not None:
query = query.filter(models.Project.is_archived == archived) return query.offset(skip).limit(limit).all()
return query.offset(skip).limit(limit).all()
def update_project(
db: Session, project_id: int, project: schemas.ProjectUpdate
def update_project( ) -> Optional[models.Project]:
db: Session, project_id: int, project: schemas.ProjectUpdate db_project = get_project(db, project_id)
) -> Optional[models.Project]: if not db_project:
db_project = get_project(db, project_id) return None
if not db_project:
return None update_data = project.model_dump(exclude_unset=True)
for key, value in update_data.items():
update_data = project.model_dump(exclude_unset=True) setattr(db_project, key, value)
for key, value in update_data.items():
setattr(db_project, key, value) db.commit()
db.refresh(db_project)
db.commit() return db_project
db.refresh(db_project)
return db_project
def delete_project(db: Session, project_id: int) -> bool:
db_project = get_project(db, project_id)
def delete_project(db: Session, project_id: int) -> bool: if not db_project:
db_project = get_project(db, project_id) return False
if not db_project: db.delete(db_project)
return False db.commit()
db.delete(db_project) return True
db.commit()
return True
# Task CRUD
def create_task(db: Session, task: schemas.TaskCreate) -> models.Task:
# Task CRUD # Validate status against project's statuses
def create_task(db: Session, task: schemas.TaskCreate) -> models.Task: project = get_project(db, task.project_id)
# Validate status against project's statuses if project and task.status not in project.statuses:
project = get_project(db, task.project_id) raise ValueError(f"Invalid status '{task.status}'. Must be one of: {', '.join(project.statuses)}")
if project and task.status not in project.statuses:
raise ValueError(f"Invalid status '{task.status}'. Must be one of: {', '.join(project.statuses)}") # Get max sort_order for siblings
if task.parent_task_id:
# Get max sort_order for siblings max_order = db.query(models.Task).filter(
if task.parent_task_id: models.Task.parent_task_id == task.parent_task_id
max_order = db.query(models.Task).filter( ).count()
models.Task.parent_task_id == task.parent_task_id else:
).count() max_order = db.query(models.Task).filter(
else: models.Task.project_id == task.project_id,
max_order = db.query(models.Task).filter( models.Task.parent_task_id.is_(None)
models.Task.project_id == task.project_id, ).count()
models.Task.parent_task_id.is_(None)
).count() task_data = task.model_dump()
if "sort_order" not in task_data or task_data["sort_order"] == 0:
task_data = task.model_dump() task_data["sort_order"] = max_order
if "sort_order" not in task_data or task_data["sort_order"] == 0:
task_data["sort_order"] = max_order db_task = models.Task(**task_data)
db.add(db_task)
db_task = models.Task(**task_data) db.commit()
db.add(db_task) db.refresh(db_task)
db.commit() return db_task
db.refresh(db_task)
return db_task
def get_task(db: Session, task_id: int) -> Optional[models.Task]:
return db.query(models.Task).filter(models.Task.id == task_id).first()
def get_task(db: Session, task_id: int) -> Optional[models.Task]:
return db.query(models.Task).filter(models.Task.id == task_id).first()
def get_tasks_by_project(db: Session, project_id: int) -> List[models.Task]:
return db.query(models.Task).filter(models.Task.project_id == project_id).all()
def get_tasks_by_project(db: Session, project_id: int) -> List[models.Task]:
return db.query(models.Task).filter(models.Task.project_id == project_id).all()
def get_root_tasks(db: Session, project_id: int) -> List[models.Task]:
"""Get all root-level tasks (no parent) for a project"""
def get_root_tasks(db: Session, project_id: int) -> List[models.Task]: return db.query(models.Task).filter(
"""Get all root-level tasks (no parent) for a project""" models.Task.project_id == project_id,
return db.query(models.Task).filter( models.Task.parent_task_id.is_(None)
models.Task.project_id == project_id, ).order_by(models.Task.sort_order).all()
models.Task.parent_task_id.is_(None)
).order_by(models.Task.sort_order).all()
def get_task_with_subtasks(db: Session, task_id: int) -> Optional[models.Task]:
"""Recursively load a task with all its subtasks"""
def get_task_with_subtasks(db: Session, task_id: int) -> Optional[models.Task]: return db.query(models.Task).options(
"""Recursively load a task with all its subtasks""" joinedload(models.Task.subtasks)
return db.query(models.Task).options( ).filter(models.Task.id == task_id).first()
joinedload(models.Task.subtasks)
).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"""
def check_and_update_parent_status(db: Session, parent_id: int): # Get all children of this parent
"""Check if all children of a parent are done, and mark parent as done if so""" children = db.query(models.Task).filter(
# Get all children of this parent models.Task.parent_task_id == parent_id
children = db.query(models.Task).filter( ).all()
models.Task.parent_task_id == parent_id
).all() # If no children, nothing to do
if not children:
# If no children, nothing to do return
if not children:
return # Check if all children are done
all_done = all(child.status == "done" for child in children)
# Check if all children are done
all_done = all(child.status == "done" for child in children) if all_done:
# Mark parent as done
if all_done: parent = get_task(db, parent_id)
# Mark parent as done if parent and parent.status != "done":
parent = get_task(db, parent_id) parent.status = "done"
if parent and parent.status != "done": db.commit()
parent.status = "done"
db.commit() # Recursively check grandparent
if parent.parent_task_id:
# Recursively check grandparent check_and_update_parent_status(db, parent.parent_task_id)
if parent.parent_task_id:
check_and_update_parent_status(db, parent.parent_task_id)
def update_task(
db: Session, task_id: int, task: schemas.TaskUpdate
def update_task( ) -> Optional[models.Task]:
db: Session, task_id: int, task: schemas.TaskUpdate db_task = get_task(db, task_id)
) -> Optional[models.Task]: if not db_task:
db_task = get_task(db, task_id) return None
if not db_task:
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:
# Validate status against project's statuses if status is being updated project = get_project(db, db_task.project_id)
if "status" in update_data: if project and update_data["status"] not in project.statuses:
project = get_project(db, db_task.project_id) raise ValueError(f"Invalid status '{update_data['status']}'. Must be one of: {', '.join(project.statuses)}")
if project and update_data["status"] not in project.statuses: status_changed = True
raise ValueError(f"Invalid status '{update_data['status']}'. Must be one of: {', '.join(project.statuses)}") old_status = db_task.status
status_changed = True else:
old_status = db_task.status status_changed = False
else:
status_changed = False for key, value in update_data.items():
setattr(db_task, key, value)
for key, value in update_data.items():
setattr(db_task, key, value) db.commit()
db.refresh(db_task)
db.commit()
db.refresh(db_task) # If status changed to 'done' and this task has a parent, check if parent should auto-complete
if status_changed and db_task.status == "done" and db_task.parent_task_id:
# If status changed to 'done' and this task has a parent, check if parent should auto-complete check_and_update_parent_status(db, db_task.parent_task_id)
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
def delete_task(db: Session, task_id: int) -> bool:
db_task = get_task(db, task_id)
def delete_task(db: Session, task_id: int) -> bool: if not db_task:
db_task = get_task(db, task_id) return False
if not db_task: db.delete(db_task)
return False db.commit()
db.delete(db_task) return True
db.commit()
return True
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"""
def get_tasks_by_status(db: Session, project_id: int, status: str) -> List[models.Task]: # Validate status against project's statuses
"""Get all tasks for a project with a specific status""" project = get_project(db, project_id)
# Validate status against project's statuses if project and status not in project.statuses:
project = get_project(db, project_id) raise ValueError(f"Invalid status '{status}'. Must be one of: {', '.join(project.statuses)}")
if project and status not in project.statuses:
raise ValueError(f"Invalid status '{status}'. Must be one of: {', '.join(project.statuses)}") return db.query(models.Task).filter(
models.Task.project_id == project_id,
return db.query(models.Task).filter( models.Task.status == status
models.Task.project_id == project_id, ).all()
models.Task.status == status
).all()
# ========== BLOCKER CRUD ==========
# TimeLog CRUD def _has_cycle(db: Session, start_id: int, target_id: int) -> bool:
def create_time_log(db: Session, task_id: int, time_log: schemas.TimeLogCreate) -> models.TimeLog: """BFS from start_id following its blockers. Returns True if target_id is reachable,
db_log = models.TimeLog( which would mean adding target_id as a blocker of start_id creates a cycle."""
task_id=task_id, visited = set()
minutes=time_log.minutes, queue = [start_id]
note=time_log.note, while queue:
session_type=time_log.session_type, current = queue.pop(0)
) if current == target_id:
db.add(db_log) return True
db.commit() if current in visited:
db.refresh(db_log) continue
return db_log visited.add(current)
task = db.query(models.Task).filter(models.Task.id == current).first()
if task:
def get_time_logs_by_task(db: Session, task_id: int) -> List[models.TimeLog]: for b in task.blockers:
return db.query(models.TimeLog).filter( if b.id not in visited:
models.TimeLog.task_id == task_id queue.append(b.id)
).order_by(models.TimeLog.logged_at.desc()).all() return False
def get_project_time_summary(db: Session, project_id: int) -> dict: def add_blocker(db: Session, task_id: int, blocker_id: int) -> models.Task:
"""Aggregate time logged across all tasks in a project""" """Add blocker_id as a prerequisite of task_id.
project = get_project(db, project_id) Raises ValueError on self-reference or cycle."""
if task_id == blocker_id:
# Get all task IDs in this project raise ValueError("A task cannot block itself")
task_ids = db.query(models.Task.id).filter(
models.Task.project_id == project_id task = get_task(db, task_id)
).subquery() blocker = get_task(db, blocker_id)
# Total minutes logged if not task:
total = db.query(func.sum(models.TimeLog.minutes)).filter( raise ValueError("Task not found")
models.TimeLog.task_id.in_(task_ids) if not blocker:
).scalar() or 0 raise ValueError("Blocker task not found")
# Pomodoro minutes # Already linked — idempotent
pomodoro = db.query(func.sum(models.TimeLog.minutes)).filter( if any(b.id == blocker_id for b in task.blockers):
models.TimeLog.task_id.in_(task_ids), return task
models.TimeLog.session_type == "pomodoro"
).scalar() or 0 # Cycle detection: would blocker_id eventually depend on task_id?
if _has_cycle(db, blocker_id, task_id):
# Manual minutes raise ValueError("Adding this blocker would create a circular dependency")
manual = db.query(func.sum(models.TimeLog.minutes)).filter(
models.TimeLog.task_id.in_(task_ids), task.blockers.append(blocker)
models.TimeLog.session_type == "manual" db.commit()
).scalar() or 0 db.refresh(task)
return task
# Weekly minutes (past 7 days)
week_ago = datetime.utcnow() - timedelta(days=7)
weekly = db.query(func.sum(models.TimeLog.minutes)).filter( def remove_blocker(db: Session, task_id: int, blocker_id: int) -> bool:
models.TimeLog.task_id.in_(task_ids), """Remove blocker_id as a prerequisite of task_id."""
models.TimeLog.logged_at >= week_ago task = get_task(db, task_id)
).scalar() or 0 blocker = get_task(db, blocker_id)
return { if not task or not blocker:
"total_minutes": total, return False
"pomodoro_minutes": pomodoro,
"manual_minutes": manual, if not any(b.id == blocker_id for b in task.blockers):
"weekly_minutes": weekly, return False
"weekly_hours_goal": project.weekly_hours_goal if project else None,
"total_hours_goal": project.total_hours_goal if project else None, task.blockers.remove(blocker)
} db.commit()
return True
def get_task_with_blockers(db: Session, task_id: int) -> Optional[models.Task]:
"""Get a task including its blockers and blocking lists."""
return db.query(models.Task).filter(models.Task.id == task_id).first()
def get_actionable_tasks(db: Session) -> List[dict]:
"""Return all non-done tasks that have no incomplete blockers, with project name."""
tasks = db.query(models.Task).filter(
models.Task.status != "done"
).all()
result = []
for task in tasks:
incomplete_blockers = [b for b in task.blockers if b.status != "done"]
if not incomplete_blockers:
result.append({
"id": task.id,
"title": task.title,
"project_id": task.project_id,
"project_name": task.project.name,
"status": task.status,
"estimated_minutes": task.estimated_minutes,
"tags": task.tags,
"flag_color": task.flag_color,
})
return result

View File

@@ -1,350 +1,368 @@
from fastapi import FastAPI, Depends, HTTPException, UploadFile, File from fastapi import FastAPI, Depends, HTTPException, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Optional from typing import List, Optional
import json 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 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=settings.api_title, title=settings.api_title,
description=settings.api_description, description=settings.api_description,
version=settings.api_version version=settings.api_version
) )
# CORS middleware for frontend # CORS middleware for frontend
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=settings.cors_origins_list, allow_origins=settings.cors_origins_list,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# ========== 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( def list_projects(
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
archived: Optional[bool] = None, archived: Optional[bool] = None,
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""List all projects with optional archive filter""" """List all projects with optional archive filter"""
return crud.get_projects(db, skip=skip, limit=limit, archived=archived) 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)
def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db)): def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db)):
"""Create a new project""" """Create a new project"""
return crud.create_project(db, project) return crud.create_project(db, project)
@app.get("/api/projects/{project_id}", response_model=schemas.Project) @app.get("/api/projects/{project_id}", response_model=schemas.Project)
def get_project(project_id: int, db: Session = Depends(get_db)): def get_project(project_id: int, db: Session = Depends(get_db)):
"""Get a specific project""" """Get a specific project"""
db_project = crud.get_project(db, project_id) db_project = crud.get_project(db, project_id)
if not db_project: if not db_project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
return db_project return db_project
@app.put("/api/projects/{project_id}", response_model=schemas.Project) @app.put("/api/projects/{project_id}", response_model=schemas.Project)
def update_project( def update_project(
project_id: int, project: schemas.ProjectUpdate, db: Session = Depends(get_db) project_id: int, project: schemas.ProjectUpdate, db: Session = Depends(get_db)
): ):
"""Update a project""" """Update a project"""
db_project = crud.update_project(db, project_id, project) db_project = crud.update_project(db, project_id, project)
if not db_project: if not db_project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
return db_project return db_project
@app.delete("/api/projects/{project_id}", status_code=204) @app.delete("/api/projects/{project_id}", status_code=204)
def delete_project(project_id: int, db: Session = Depends(get_db)): def delete_project(project_id: int, db: Session = Depends(get_db)):
"""Delete a project and all its tasks""" """Delete a project and all its tasks"""
if not crud.delete_project(db, project_id): if not crud.delete_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
return None return None
# ========== TASK ENDPOINTS ========== # ========== TASK ENDPOINTS ==========
@app.get("/api/projects/{project_id}/tasks", response_model=List[schemas.Task]) @app.get("/api/projects/{project_id}/tasks", response_model=List[schemas.Task])
def list_project_tasks(project_id: int, db: Session = Depends(get_db)): def list_project_tasks(project_id: int, db: Session = Depends(get_db)):
"""List all tasks for a project""" """List all tasks for a project"""
if not crud.get_project(db, project_id): if not crud.get_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
return crud.get_tasks_by_project(db, project_id) return crud.get_tasks_by_project(db, project_id)
@app.get("/api/projects/{project_id}/tasks/tree", response_model=List[schemas.TaskWithSubtasks]) @app.get("/api/projects/{project_id}/tasks/tree", response_model=List[schemas.TaskWithSubtasks])
def get_project_task_tree(project_id: int, db: Session = Depends(get_db)): def get_project_task_tree(project_id: int, db: Session = Depends(get_db)):
"""Get the task tree (root tasks with nested subtasks) for a project""" """Get the task tree (root tasks with nested subtasks) for a project"""
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")
root_tasks = crud.get_root_tasks(db, project_id) root_tasks = crud.get_root_tasks(db, project_id)
def build_tree(task): def build_tree(task):
task_dict = schemas.TaskWithSubtasks.model_validate(task) task_dict = schemas.TaskWithSubtasks.model_validate(task)
task_dict.subtasks = [build_tree(subtask) for subtask in task.subtasks] task_dict.subtasks = [build_tree(subtask) for subtask in task.subtasks]
return task_dict return task_dict
return [build_tree(task) for task in root_tasks] return [build_tree(task) for task in root_tasks]
@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: str, 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: 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: except ValueError as e:
raise HTTPException(status_code=400, detail=str(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)
def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)): def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)):
"""Create a new task""" """Create a new task"""
if not crud.get_project(db, task.project_id): if not crud.get_project(db, task.project_id):
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
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: try:
return crud.create_task(db, task) return crud.create_task(db, task)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(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)
def get_task(task_id: int, db: Session = Depends(get_db)): def get_task(task_id: int, db: Session = Depends(get_db)):
"""Get a specific task""" """Get a specific task"""
db_task = crud.get_task(db, task_id) db_task = crud.get_task(db, task_id)
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
@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: 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: except ValueError as e:
raise HTTPException(status_code=400, detail=str(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)
def delete_task(task_id: int, db: Session = Depends(get_db)): def delete_task(task_id: int, db: Session = Depends(get_db)):
"""Delete a task and all its subtasks""" """Delete a task and all its subtasks"""
if not crud.delete_task(db, task_id): if not crud.delete_task(db, task_id):
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
return None return None
# ========== TIME LOG ENDPOINTS ========== # ========== BLOCKER ENDPOINTS ==========
@app.post("/api/tasks/{task_id}/time-logs", response_model=schemas.TimeLog, status_code=201) @app.get("/api/tasks/{task_id}/blockers", response_model=List[schemas.BlockerInfo])
def log_time(task_id: int, time_log: schemas.TimeLogCreate, db: Session = Depends(get_db)): def get_task_blockers(task_id: int, db: Session = Depends(get_db)):
"""Log time spent on a task""" """Get all tasks that are blocking a given task."""
if not crud.get_task(db, task_id): task = crud.get_task_with_blockers(db, task_id)
raise HTTPException(status_code=404, detail="Task not found") if not task:
return crud.create_time_log(db, task_id, time_log) raise HTTPException(status_code=404, detail="Task not found")
return task.blockers
@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)): @app.get("/api/tasks/{task_id}/blocking", response_model=List[schemas.BlockerInfo])
"""Get all time logs for a task""" def get_tasks_this_blocks(task_id: int, db: Session = Depends(get_db)):
if not crud.get_task(db, task_id): """Get all tasks that this task is currently blocking."""
raise HTTPException(status_code=404, detail="Task not found") task = crud.get_task_with_blockers(db, task_id)
return crud.get_time_logs_by_task(db, task_id) if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task.blocking
@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""" @app.post("/api/tasks/{task_id}/blockers/{blocker_id}", status_code=201)
if not crud.get_project(db, project_id): def add_blocker(task_id: int, blocker_id: int, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail="Project not found") """Add blocker_id as a prerequisite of task_id."""
return crud.get_project_time_summary(db, project_id) try:
crud.add_blocker(db, task_id, blocker_id)
return {"status": "ok"}
# ========== SEARCH ENDPOINT ========== except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/api/search", response_model=List[schemas.Task])
def search_tasks(
query: str, @app.delete("/api/tasks/{task_id}/blockers/{blocker_id}", status_code=204)
project_ids: Optional[str] = None, def remove_blocker(task_id: int, blocker_id: int, db: Session = Depends(get_db)):
db: Session = Depends(get_db) """Remove blocker_id as a prerequisite of task_id."""
): if not crud.remove_blocker(db, task_id, blocker_id):
""" raise HTTPException(status_code=404, detail="Blocker relationship not found")
Search tasks across projects by title, description, and tags. return None
Args:
query: Search term to match against title, description, and tags @app.get("/api/actionable", response_model=List[schemas.ActionableTask])
project_ids: Comma-separated list of project IDs to search in (optional, searches all if not provided) def get_actionable_tasks(db: Session = Depends(get_db)):
""" """Get all non-done tasks with no incomplete blockers, across all projects."""
# Parse project IDs if provided return crud.get_actionable_tasks(db)
project_id_list = None
if project_ids:
try: # ========== SEARCH ENDPOINT ==========
project_id_list = [int(pid.strip()) for pid in project_ids.split(',') if pid.strip()]
except ValueError: @app.get("/api/search", response_model=List[schemas.Task])
raise HTTPException(status_code=400, detail="Invalid project_ids format") def search_tasks(
query: str,
# Build query project_ids: Optional[str] = None,
tasks_query = db.query(models.Task) db: Session = Depends(get_db)
):
# Filter by project IDs if specified """
if project_id_list: Search tasks across projects by title, description, and tags.
tasks_query = tasks_query.filter(models.Task.project_id.in_(project_id_list))
Args:
# Search in title, description, and tags query: Search term to match against title, description, and tags
search_term = f"%{query}%" project_ids: Comma-separated list of project IDs to search in (optional, searches all if not provided)
tasks = tasks_query.filter( """
(models.Task.title.ilike(search_term)) | # Parse project IDs if provided
(models.Task.description.ilike(search_term)) | project_id_list = None
(models.Task.tags.contains([query])) # Exact tag match if project_ids:
).all() try:
project_id_list = [int(pid.strip()) for pid in project_ids.split(',') if pid.strip()]
return tasks except ValueError:
raise HTTPException(status_code=400, detail="Invalid project_ids format")
# ========== JSON IMPORT ENDPOINT ========== # Build query
tasks_query = db.query(models.Task)
def _validate_task_statuses_recursive(
tasks: List[schemas.ImportSubtask], # Filter by project IDs if specified
valid_statuses: List[str], if project_id_list:
path: str = "" tasks_query = tasks_query.filter(models.Task.project_id.in_(project_id_list))
) -> None:
"""Recursively validate all task statuses against the project's valid statuses""" # Search in title, description, and tags
for idx, task_data in enumerate(tasks): search_term = f"%{query}%"
task_path = f"{path}.tasks[{idx}]" if path else f"tasks[{idx}]" tasks = tasks_query.filter(
if task_data.status not in valid_statuses: (models.Task.title.ilike(search_term)) |
raise ValueError( (models.Task.description.ilike(search_term)) |
f"Invalid status '{task_data.status}' at {task_path}. " (models.Task.tags.contains([query])) # Exact tag match
f"Must be one of: {', '.join(valid_statuses)}" ).all()
)
if task_data.subtasks: return tasks
_validate_task_statuses_recursive(
task_data.subtasks,
valid_statuses, # ========== JSON IMPORT ENDPOINT ==========
f"{task_path}.subtasks"
) def _validate_task_statuses_recursive(
tasks: List[schemas.ImportSubtask],
valid_statuses: List[str],
def _import_tasks_recursive( path: str = ""
db: Session, ) -> None:
project_id: int, """Recursively validate all task statuses against the project's valid statuses"""
tasks: List[schemas.ImportSubtask], for idx, task_data in enumerate(tasks):
parent_id: Optional[int] = None, task_path = f"{path}.tasks[{idx}]" if path else f"tasks[{idx}]"
count: int = 0 if task_data.status not in valid_statuses:
) -> int: raise ValueError(
"""Recursively import tasks and their subtasks""" f"Invalid status '{task_data.status}' at {task_path}. "
for idx, task_data in enumerate(tasks): f"Must be one of: {', '.join(valid_statuses)}"
task = schemas.TaskCreate( )
project_id=project_id, if task_data.subtasks:
parent_task_id=parent_id, _validate_task_statuses_recursive(
title=task_data.title, task_data.subtasks,
description=task_data.description, valid_statuses,
status=task_data.status, f"{task_path}.subtasks"
estimated_minutes=task_data.estimated_minutes, )
tags=task_data.tags,
flag_color=task_data.flag_color,
sort_order=idx def _import_tasks_recursive(
) db: Session,
db_task = crud.create_task(db, task) project_id: int,
count += 1 tasks: List[schemas.ImportSubtask],
parent_id: Optional[int] = None,
if task_data.subtasks: count: int = 0
count = _import_tasks_recursive( ) -> int:
db, project_id, task_data.subtasks, db_task.id, count """Recursively import tasks and their subtasks"""
) for idx, task_data in enumerate(tasks):
task = schemas.TaskCreate(
return count project_id=project_id,
parent_task_id=parent_id,
title=task_data.title,
@app.post("/api/import-json", response_model=schemas.ImportResult) description=task_data.description,
def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_db)): status=task_data.status,
""" estimated_minutes=task_data.estimated_minutes,
Import a project with nested tasks from JSON. tags=task_data.tags,
flag_color=task_data.flag_color,
Expected format: sort_order=idx
{ )
"project": { db_task = crud.create_task(db, task)
"name": "Project Name", count += 1
"description": "Optional description",
"statuses": ["backlog", "in_progress", "on_hold", "done"] // Optional if task_data.subtasks:
}, count = _import_tasks_recursive(
"tasks": [ db, project_id, task_data.subtasks, db_task.id, count
{ )
"title": "Task 1",
"description": "Optional", return count
"status": "backlog",
"subtasks": [
{ @app.post("/api/import-json", response_model=schemas.ImportResult)
"title": "Subtask 1.1", def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_db)):
"status": "backlog", """
"subtasks": [] Import a project with nested tasks from JSON.
}
] Expected format:
} {
] "project": {
} "name": "Project Name",
""" "description": "Optional description",
# Create the project with optional statuses "statuses": ["backlog", "in_progress", "on_hold", "done"] // Optional
project = crud.create_project( },
db, "tasks": [
schemas.ProjectCreate( {
name=import_data.project.name, "title": "Task 1",
description=import_data.project.description, "description": "Optional",
statuses=import_data.project.statuses "status": "backlog",
) "subtasks": [
) {
"title": "Subtask 1.1",
# Validate all task statuses before importing "status": "backlog",
if import_data.tasks: "subtasks": []
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)) # Create the project with optional statuses
project = crud.create_project(
# Recursively import tasks db,
tasks_created = _import_tasks_recursive( schemas.ProjectCreate(
db, project.id, import_data.tasks name=import_data.project.name,
) description=import_data.project.description,
statuses=import_data.project.statuses
return schemas.ImportResult( )
project_id=project.id, )
project_name=project.name,
tasks_created=tasks_created # Validate all task statuses before importing
) if import_data.tasks:
try:
_validate_task_statuses_recursive(import_data.tasks, project.statuses)
@app.get("/") except ValueError as e:
def root(): # Rollback the project creation if validation fails
"""API health check""" db.delete(project)
return { db.commit()
"status": "online", raise HTTPException(status_code=400, detail=str(e))
"message": "Break It Down (BIT) API",
"docs": "/docs" # Recursively import tasks
} tasks_created = _import_tasks_recursive(
db, project.id, import_data.tasks
)
return schemas.ImportResult(
project_id=project.id,
project_name=project.name,
tasks_created=tasks_created
)
@app.get("/")
def root():
"""API health check"""
return {
"status": "online",
"message": "Break It Down (BIT) API",
"docs": "/docs"
}

View File

@@ -1,61 +1,61 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Boolean from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Boolean, Table
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from datetime import datetime from datetime import datetime
from .database import Base from .database import Base
# Default statuses for new projects # Default statuses for new projects
DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"] DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
class Project(Base): # Association table for task blocker relationships (many-to-many)
__tablename__ = "projects" task_blockers = Table(
"task_blockers",
id = Column(Integer, primary_key=True, index=True) Base.metadata,
name = Column(String(255), nullable=False) Column("task_id", Integer, ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True),
description = Column(Text, nullable=True) Column("blocked_by_id", Integer, ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=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 class Project(Base):
total_hours_goal = Column(Integer, nullable=True) # stored in minutes __tablename__ = "projects"
pomodoro_work_minutes = Column(Integer, nullable=True, default=25)
pomodoro_break_minutes = Column(Integer, nullable=True, default=5) id = Column(Integer, primary_key=True, index=True)
created_at = Column(DateTime, default=datetime.utcnow) name = Column(String(255), nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) description = Column(Text, nullable=True)
statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES)
tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan") is_archived = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class Task(Base):
__tablename__ = "tasks" tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan")
id = Column(Integer, primary_key=True, index=True)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) class Task(Base):
parent_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True) __tablename__ = "tasks"
title = Column(String(500), nullable=False)
description = Column(Text, nullable=True) id = Column(Integer, primary_key=True, index=True)
status = Column(String(50), default="backlog", nullable=False) project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
sort_order = Column(Integer, default=0) parent_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True)
estimated_minutes = Column(Integer, nullable=True) title = Column(String(500), nullable=False)
tags = Column(JSON, nullable=True) description = Column(Text, nullable=True)
flag_color = Column(String(50), nullable=True) status = Column(String(50), default="backlog", nullable=False)
created_at = Column(DateTime, default=datetime.utcnow) sort_order = Column(Integer, default=0)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) estimated_minutes = Column(Integer, nullable=True)
tags = Column(JSON, nullable=True)
project = relationship("Project", back_populates="tasks") flag_color = Column(String(50), nullable=True)
parent = relationship("Task", remote_side=[id], backref="subtasks") created_at = Column(DateTime, default=datetime.utcnow)
time_logs = relationship("TimeLog", back_populates="task", cascade="all, delete-orphan") updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
project = relationship("Project", back_populates="tasks")
class TimeLog(Base): parent = relationship("Task", remote_side=[id], backref="subtasks")
__tablename__ = "time_logs"
# blockers: tasks that must be done before this task can start
id = Column(Integer, primary_key=True, index=True) # blocking: tasks that this task is holding up
task_id = Column(Integer, ForeignKey("tasks.id"), nullable=False) blockers = relationship(
minutes = Column(Integer, nullable=False) "Task",
note = Column(Text, nullable=True) secondary=task_blockers,
session_type = Column(String(50), default="manual") # 'pomodoro' | 'manual' primaryjoin=lambda: Task.id == task_blockers.c.task_id,
logged_at = Column(DateTime, default=datetime.utcnow) secondaryjoin=lambda: Task.id == task_blockers.c.blocked_by_id,
backref="blocking",
task = relationship("Task", back_populates="time_logs") )

View File

@@ -1,149 +1,138 @@
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 DEFAULT_STATUSES 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: str = "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
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
flag_color: Optional[str] = None flag_color: Optional[str] = None
class TaskCreate(TaskBase): class TaskCreate(TaskBase):
project_id: int project_id: int
class TaskUpdate(BaseModel): class TaskUpdate(BaseModel):
title: Optional[str] = None title: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
status: Optional[str] = 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
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
flag_color: Optional[str] = None flag_color: Optional[str] = None
class Task(TaskBase): class Task(TaskBase):
id: int id: int
project_id: int project_id: int
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class TaskWithSubtasks(Task): class TaskWithSubtasks(Task):
subtasks: List['TaskWithSubtasks'] = [] subtasks: List['TaskWithSubtasks'] = []
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# Project Schemas class BlockerInfo(BaseModel):
class ProjectBase(BaseModel): """Lightweight task info used when listing blockers/blocking relationships."""
name: str id: int
description: Optional[str] = None title: str
project_id: int
status: str
class ProjectCreate(ProjectBase):
statuses: Optional[List[str]] = None model_config = ConfigDict(from_attributes=True)
category: Optional[str] = None
weekly_hours_goal: Optional[int] = None
total_hours_goal: Optional[int] = None class TaskWithBlockers(Task):
pomodoro_work_minutes: Optional[int] = None blockers: List[BlockerInfo] = []
pomodoro_break_minutes: Optional[int] = None blocking: List[BlockerInfo] = []
model_config = ConfigDict(from_attributes=True)
class ProjectUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None class ActionableTask(BaseModel):
statuses: Optional[List[str]] = None """A task that is ready to work on — not done, and all blockers are resolved."""
is_archived: Optional[bool] = None id: int
category: Optional[str] = None title: str
weekly_hours_goal: Optional[int] = None project_id: int
total_hours_goal: Optional[int] = None project_name: str
pomodoro_work_minutes: Optional[int] = None status: str
pomodoro_break_minutes: Optional[int] = None estimated_minutes: Optional[int] = None
tags: Optional[List[str]] = None
flag_color: Optional[str] = None
class Project(ProjectBase):
id: int model_config = ConfigDict(from_attributes=True)
statuses: List[str]
is_archived: bool
category: Optional[str] = None # Project Schemas
weekly_hours_goal: Optional[int] = None class ProjectBase(BaseModel):
total_hours_goal: Optional[int] = None name: str
pomodoro_work_minutes: Optional[int] = None description: Optional[str] = None
pomodoro_break_minutes: Optional[int] = None
created_at: datetime
updated_at: datetime class ProjectCreate(ProjectBase):
statuses: Optional[List[str]] = None
model_config = ConfigDict(from_attributes=True)
class ProjectUpdate(BaseModel):
class ProjectWithTasks(Project): name: Optional[str] = None
tasks: List[Task] = [] description: Optional[str] = None
statuses: Optional[List[str]] = None
model_config = ConfigDict(from_attributes=True) is_archived: Optional[bool] = None
# JSON Import Schemas class Project(ProjectBase):
class ImportSubtask(BaseModel): id: int
title: str statuses: List[str]
description: Optional[str] = None is_archived: bool
status: str = "backlog" created_at: datetime
estimated_minutes: Optional[int] = None updated_at: datetime
tags: Optional[List[str]] = None
flag_color: Optional[str] = None model_config = ConfigDict(from_attributes=True)
subtasks: List['ImportSubtask'] = []
class ProjectWithTasks(Project):
class ImportProject(BaseModel): tasks: List[Task] = []
name: str
description: Optional[str] = None model_config = ConfigDict(from_attributes=True)
statuses: Optional[List[str]] = None
# JSON Import Schemas
class ImportData(BaseModel): class ImportSubtask(BaseModel):
project: ImportProject title: str
tasks: List[ImportSubtask] = [] description: Optional[str] = None
status: str = "backlog"
estimated_minutes: Optional[int] = None
class ImportResult(BaseModel): tags: Optional[List[str]] = None
project_id: int flag_color: Optional[str] = None
project_name: str subtasks: List['ImportSubtask'] = []
tasks_created: int
class ImportProject(BaseModel):
# TimeLog Schemas name: str
class TimeLogCreate(BaseModel): description: Optional[str] = None
minutes: int statuses: Optional[List[str]] = None
note: Optional[str] = None
session_type: str = "manual" # 'pomodoro' | 'manual'
class ImportData(BaseModel):
project: ImportProject
class TimeLog(BaseModel): tasks: List[ImportSubtask] = []
id: int
task_id: int
minutes: int class ImportResult(BaseModel):
note: Optional[str] = None project_id: int
session_type: str project_name: str
logged_at: datetime tasks_created: int
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

View File

@@ -0,0 +1,40 @@
"""
Migration script to add the task_blockers association table.
Run this once if you have an existing database.
Usage (from inside the backend container or with the venv active):
python migrate_add_blockers.py
"""
import sqlite3
import os
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)
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
# Check if the table already exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='task_blockers'")
if cursor.fetchone():
print("Table 'task_blockers' already exists. Migration not needed.")
else:
cursor.execute("""
CREATE TABLE task_blockers (
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
blocked_by_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
PRIMARY KEY (task_id, blocked_by_id)
)
""")
conn.commit()
print("Successfully created 'task_blockers' table.")
except Exception as e:
print(f"Error during migration: {e}")
conn.rollback()
finally:
conn.close()

View File

@@ -1,42 +0,0 @@
"""
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

@@ -1,34 +0,0 @@
"""
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,40 +1,56 @@
import { Routes, Route } from 'react-router-dom' import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'
import ProjectList from './pages/ProjectList' import { Zap } from 'lucide-react'
import ProjectView from './pages/ProjectView' import ProjectList from './pages/ProjectList'
import SearchBar from './components/SearchBar' import ProjectView from './pages/ProjectView'
import { PomodoroProvider } from './context/PomodoroContext' import ActionableView from './pages/ActionableView'
import PomodoroWidget from './components/PomodoroWidget' import SearchBar from './components/SearchBar'
function App() { function App() {
return ( const navigate = useNavigate()
<PomodoroProvider> const location = useLocation()
<div className="min-h-screen bg-cyber-dark"> const isActionable = location.pathname === '/actionable'
<header className="border-b border-cyber-orange/30 bg-cyber-darkest">
<div className="container mx-auto px-4 py-4"> return (
<div className="flex justify-between items-center"> <div className="min-h-screen bg-cyber-dark">
<div> <header className="border-b border-cyber-orange/30 bg-cyber-darkest">
<h1 className="text-2xl font-bold text-cyber-orange"> <div className="container mx-auto px-4 py-4">
BIT <div className="flex justify-between items-center">
<span className="ml-3 text-sm text-gray-500">Break It Down</span> <div className="flex items-center gap-4">
<span className="ml-2 text-xs text-gray-600">v{import.meta.env.VITE_APP_VERSION || '0.1.6'}</span> <h1
</h1> className="text-2xl font-bold text-cyber-orange cursor-pointer"
</div> onClick={() => navigate('/')}
<SearchBar /> >
</div> BIT
</div> <span className="ml-3 text-sm text-gray-500">Break It Down</span>
</header> <span className="ml-2 text-xs text-gray-600">v{import.meta.env.VITE_APP_VERSION || '0.1.6'}</span>
</h1>
<main className="container mx-auto px-4 py-8"> <button
<Routes> onClick={() => navigate(isActionable ? '/' : '/actionable')}
<Route path="/" element={<ProjectList />} /> className={`flex items-center gap-1.5 px-3 py-1.5 rounded text-sm font-medium transition-all ${
<Route path="/project/:projectId" element={<ProjectView />} /> isActionable
</Routes> ? 'bg-cyber-orange text-cyber-darkest'
</main> : 'border border-cyber-orange/40 text-cyber-orange hover:bg-cyber-orange/10'
}`}
<PomodoroWidget /> title="What can I do right now?"
</div> >
</PomodoroProvider> <Zap size={14} />
) Now
} </button>
</div>
export default App <SearchBar />
</div>
</div>
</header>
<main className="container mx-auto px-4 py-8">
<Routes>
<Route path="/" element={<ProjectList />} />
<Route path="/project/:projectId" element={<ProjectView />} />
<Route path="/actionable" element={<ActionableView />} />
</Routes>
</main>
</div>
)
}
export default App

View File

@@ -0,0 +1,201 @@
import { useState, useEffect, useRef } from 'react'
import { X, Search, Lock, Unlock, AlertTriangle } from 'lucide-react'
import { getTaskBlockers, addBlocker, removeBlocker, searchTasks } from '../utils/api'
function BlockerPanel({ task, onClose, onUpdate }) {
const [blockers, setBlockers] = useState([])
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState([])
const [searching, setSearching] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const searchTimeout = useRef(null)
useEffect(() => {
loadBlockers()
}, [task.id])
const loadBlockers = async () => {
try {
setLoading(true)
const data = await getTaskBlockers(task.id)
setBlockers(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handleSearch = (query) => {
setSearchQuery(query)
setError('')
if (searchTimeout.current) clearTimeout(searchTimeout.current)
if (!query.trim()) {
setSearchResults([])
return
}
searchTimeout.current = setTimeout(async () => {
try {
setSearching(true)
const results = await searchTasks(query)
// Filter out the current task and tasks already blocking this one
const blockerIds = new Set(blockers.map(b => b.id))
const filtered = results.filter(t => t.id !== task.id && !blockerIds.has(t.id))
setSearchResults(filtered.slice(0, 8))
} catch (err) {
setError(err.message)
} finally {
setSearching(false)
}
}, 300)
}
const handleAddBlocker = async (blocker) => {
try {
setError('')
await addBlocker(task.id, blocker.id)
setBlockers(prev => [...prev, blocker])
setSearchResults(prev => prev.filter(t => t.id !== blocker.id))
setSearchQuery('')
setSearchResults([])
onUpdate()
} catch (err) {
setError(err.message)
}
}
const handleRemoveBlocker = async (blockerId) => {
try {
setError('')
await removeBlocker(task.id, blockerId)
setBlockers(prev => prev.filter(b => b.id !== blockerId))
onUpdate()
} catch (err) {
setError(err.message)
}
}
const incompleteBlockers = blockers.filter(b => b.status !== 'done')
const isBlocked = incompleteBlockers.length > 0
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50" onClick={onClose}>
<div
className="bg-cyber-darkest border border-cyber-orange/50 rounded-lg w-full max-w-lg shadow-xl"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-cyber-orange/20">
<div className="flex items-center gap-2">
{isBlocked
? <Lock size={16} className="text-red-400" />
: <Unlock size={16} className="text-green-400" />
}
<span className="font-semibold text-gray-100 text-sm">Blockers</span>
<span className="text-xs text-gray-500 ml-1 truncate max-w-[200px]" title={task.title}>
{task.title}
</span>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-200 transition-colors">
<X size={18} />
</button>
</div>
<div className="p-5 space-y-4">
{/* Status banner */}
{isBlocked ? (
<div className="flex items-center gap-2 px-3 py-2 bg-red-900/20 border border-red-500/30 rounded text-red-300 text-sm">
<AlertTriangle size={14} />
<span>{incompleteBlockers.length} incomplete blocker{incompleteBlockers.length !== 1 ? 's' : ''} this task is locked</span>
</div>
) : (
<div className="flex items-center gap-2 px-3 py-2 bg-green-900/20 border border-green-500/30 rounded text-green-300 text-sm">
<Unlock size={14} />
<span>No active blockers this task is ready to work on</span>
</div>
)}
{/* Current blockers list */}
{loading ? (
<div className="text-gray-500 text-sm text-center py-2">Loading...</div>
) : blockers.length > 0 ? (
<div className="space-y-1">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Blocked by</p>
{blockers.map(b => (
<div
key={b.id}
className="flex items-center justify-between px-3 py-2 bg-cyber-darker rounded border border-cyber-orange/10"
>
<div className="flex items-center gap-2 min-w-0">
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${b.status === 'done' ? 'bg-green-400' : 'bg-red-400'}`} />
<span className="text-sm text-gray-200 truncate">{b.title}</span>
<span className="text-xs text-gray-500 flex-shrink-0">#{b.id}</span>
</div>
<button
onClick={() => handleRemoveBlocker(b.id)}
className="text-gray-600 hover:text-red-400 transition-colors flex-shrink-0 ml-2"
title="Remove blocker"
>
<X size={14} />
</button>
</div>
))}
</div>
) : (
<p className="text-xs text-gray-600 text-center py-1">No blockers added yet</p>
)}
{/* Search to add blocker */}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Add a blocker</p>
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
<input
type="text"
value={searchQuery}
onChange={e => handleSearch(e.target.value)}
placeholder="Search tasks across all projects..."
className="w-full pl-9 pr-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
autoFocus
/>
</div>
{/* Search results */}
{(searchResults.length > 0 || searching) && (
<div className="mt-1 border border-cyber-orange/20 rounded overflow-hidden">
{searching && (
<div className="px-3 py-2 text-xs text-gray-500">Searching...</div>
)}
{searchResults.map(result => (
<button
key={result.id}
onClick={() => handleAddBlocker(result)}
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-left transition-colors border-b border-cyber-orange/10 last:border-0"
>
<span className="text-sm text-gray-200 truncate flex-1">{result.title}</span>
<span className="text-xs text-gray-600 flex-shrink-0">project #{result.project_id}</span>
</button>
))}
{!searching && searchResults.length === 0 && searchQuery && (
<div className="px-3 py-2 text-xs text-gray-500">No matching tasks found</div>
)}
</div>
)}
</div>
{error && (
<div className="px-3 py-2 bg-red-900/30 border border-red-500/40 rounded text-red-300 text-sm">
{error}
</div>
)}
</div>
</div>
</div>
)
}
export default BlockerPanel

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Plus, Check, X, Flag, Clock, ChevronDown, ChevronRight, ChevronsDown, ChevronsUp, Timer } from 'lucide-react' import { Plus, Check, X, Flag, Clock, ChevronDown, ChevronRight, ChevronsDown, ChevronsUp } from 'lucide-react'
import { import {
getProjectTasks, getProjectTasks,
createTask, createTask,
@@ -9,7 +9,6 @@ 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'
// Helper to format status label // Helper to format status label
const formatStatusLabel = (status) => { const formatStatusLabel = (status) => {
@@ -70,12 +69,10 @@ function hasDescendantsInStatus(taskId, allTasks, status) {
return getDescendantsInStatus(taskId, allTasks, status).length > 0 return getDescendantsInStatus(taskId, allTasks, status).length > 0
} }
function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatus, expandedCards, setExpandedCards, projectStatuses, projectId, pomodoroSettings }) { function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatus, expandedCards, setExpandedCards, projectStatuses, projectId }) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [editTitle, setEditTitle] = useState(task.title) const [editTitle, setEditTitle] = useState(task.title)
const [showAddSubtask, setShowAddSubtask] = useState(false) const [showAddSubtask, setShowAddSubtask] = useState(false)
const { startPomodoro, activeTask, phase } = usePomodoro()
const isActiveTimer = activeTask?.id === task.id && phase !== 'idle'
// Use global expanded state // Use global expanded state
const isExpanded = expandedCards[task.id] || false const isExpanded = expandedCards[task.id] || false
@@ -237,20 +234,6 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
</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 <button
onClick={() => setShowAddSubtask(true)} onClick={() => setShowAddSubtask(true)}
className="text-cyber-orange hover:text-cyber-orange-bright p-1" className="text-cyber-orange hover:text-cyber-orange-bright p-1"
@@ -300,7 +283,6 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
setExpandedCards={setExpandedCards} setExpandedCards={setExpandedCards}
projectStatuses={projectStatuses} projectStatuses={projectStatuses}
projectId={projectId} projectId={projectId}
pomodoroSettings={pomodoroSettings}
/> />
))} ))}
</div> </div>
@@ -309,7 +291,7 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
) )
} }
function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOver, expandedCards, setExpandedCards, projectStatuses, pomodoroSettings }) { function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOver, expandedCards, setExpandedCards, projectStatuses }) {
const [showAddTask, setShowAddTask] = useState(false) const [showAddTask, setShowAddTask] = useState(false)
const handleAddTask = async (taskData) => { const handleAddTask = async (taskData) => {
@@ -402,7 +384,6 @@ function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOve
setExpandedCards={setExpandedCards} setExpandedCards={setExpandedCards}
projectStatuses={projectStatuses} projectStatuses={projectStatuses}
projectId={projectId} projectId={projectId}
pomodoroSettings={pomodoroSettings}
/> />
) )
})} })}
@@ -419,10 +400,6 @@ function KanbanView({ projectId, project }) {
// Get statuses from project, or use defaults // Get statuses from project, or use defaults
const statuses = project?.statuses || ['backlog', 'in_progress', 'on_hold', 'done'] 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 => ({ const statusesWithMeta = statuses.map(status => ({
key: status, key: status,
label: formatStatusLabel(status), label: formatStatusLabel(status),
@@ -530,7 +507,6 @@ function KanbanView({ projectId, project }) {
expandedCards={expandedCards} expandedCards={expandedCards}
setExpandedCards={setExpandedCards} setExpandedCards={setExpandedCards}
projectStatuses={statuses} projectStatuses={statuses}
pomodoroSettings={pomodoroSettings}
/> />
))} ))}
</div> </div>

View File

@@ -1,134 +0,0 @@
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

@@ -11,23 +11,6 @@ function ProjectSettings({ project, onClose, onUpdate }) {
const [taskCounts, setTaskCounts] = useState({}) const [taskCounts, setTaskCounts] = useState({})
const [deleteWarning, setDeleteWarning] = useState(null) 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(() => { useEffect(() => {
loadTaskCounts() loadTaskCounts()
}, []) }, [])
@@ -137,20 +120,8 @@ function ProjectSettings({ project, onClose, onUpdate }) {
return return
} }
const weeklyMinutes =
(parseInt(weeklyGoalHours) || 0) * 60 + (parseInt(weeklyGoalMins) || 0)
const totalMinutes =
(parseInt(totalGoalHours) || 0) * 60 + (parseInt(totalGoalMins) || 0)
try { try {
await updateProject(project.id, { await updateProject(project.id, { statuses })
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() onUpdate()
onClose() onClose()
} catch (err) { } catch (err) {
@@ -276,106 +247,6 @@ function ProjectSettings({ project, onClose, onUpdate }) {
Add Status Add Status
</button> </button>
</div> </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> </div>
{/* Footer */} {/* Footer */}

View File

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

View File

@@ -6,8 +6,7 @@ import {
Check, Check,
X, X,
Flag, Flag,
Clock, Clock
Timer
} from 'lucide-react' } from 'lucide-react'
import { import {
getProjectTaskTree, getProjectTaskTree,
@@ -18,7 +17,6 @@ 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'
// Helper to format status label // Helper to format status label
const formatStatusLabel = (status) => { const formatStatusLabel = (status) => {
@@ -46,14 +44,12 @@ const FLAG_COLORS = {
pink: 'bg-pink-500' pink: 'bg-pink-500'
} }
function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses, pomodoroSettings }) { function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses }) {
const [isExpanded, setIsExpanded] = useState(true) const [isExpanded, setIsExpanded] = useState(true)
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [editTitle, setEditTitle] = useState(task.title) const [editTitle, setEditTitle] = useState(task.title)
const [editStatus, setEditStatus] = useState(task.status) const [editStatus, setEditStatus] = useState(task.status)
const [showAddSubtask, setShowAddSubtask] = useState(false) const [showAddSubtask, setShowAddSubtask] = useState(false)
const { 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
@@ -204,20 +200,6 @@ function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses, pomod
{/* 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"
@@ -260,7 +242,6 @@ function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses, pomod
onUpdate={onUpdate} onUpdate={onUpdate}
level={level + 1} level={level + 1}
projectStatuses={projectStatuses} projectStatuses={projectStatuses}
pomodoroSettings={pomodoroSettings}
/> />
))} ))}
</div> </div>
@@ -271,10 +252,6 @@ function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses, pomod
function TreeView({ projectId, project }) { function TreeView({ projectId, project }) {
const projectStatuses = project?.statuses || ['backlog', 'in_progress', 'on_hold', 'done'] 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('')
@@ -361,7 +338,6 @@ function TreeView({ projectId, project }) {
projectId={projectId} projectId={projectId}
onUpdate={loadTasks} onUpdate={loadTasks}
projectStatuses={projectStatuses} projectStatuses={projectStatuses}
pomodoroSettings={pomodoroSettings}
/> />
))} ))}
</div> </div>

View File

@@ -1,204 +0,0 @@
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

@@ -0,0 +1,190 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Zap, Clock, RefreshCw, ChevronRight, Check } from 'lucide-react'
import { getActionableTasks, updateTask } from '../utils/api'
const FLAG_DOT = {
red: 'bg-red-500',
orange: 'bg-orange-500',
yellow: 'bg-yellow-500',
green: 'bg-green-500',
blue: 'bg-blue-500',
purple: 'bg-purple-500',
pink: 'bg-pink-500',
}
const formatTime = (minutes) => {
if (!minutes) return null
const h = Math.floor(minutes / 60)
const m = minutes % 60
if (h && m) return `${h}h ${m}m`
if (h) return `${h}h`
return `${m}m`
}
const formatStatusLabel = (status) =>
status.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
const getStatusColor = (status) => {
const s = status.toLowerCase()
if (s === 'in_progress' || s.includes('progress')) return 'text-blue-400'
if (s === 'on_hold' || s.includes('hold')) return 'text-yellow-400'
return 'text-gray-500'
}
function ActionableView() {
const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [completingId, setCompletingId] = useState(null)
const navigate = useNavigate()
useEffect(() => {
loadTasks()
}, [])
const loadTasks = async () => {
try {
setLoading(true)
setError('')
const data = await getActionableTasks()
setTasks(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handleMarkDone = async (task) => {
try {
setCompletingId(task.id)
await updateTask(task.id, { status: 'done' })
// Remove from list and reload to surface newly unblocked tasks
setTasks(prev => prev.filter(t => t.id !== task.id))
// Reload after a short beat so the user sees the removal first
setTimeout(() => loadTasks(), 600)
} catch (err) {
setError(err.message)
} finally {
setCompletingId(null)
}
}
// Group by project
const byProject = tasks.reduce((acc, task) => {
const key = task.project_id
if (!acc[key]) acc[key] = { name: task.project_name, tasks: [] }
acc[key].tasks.push(task)
return acc
}, {})
const projectGroups = Object.entries(byProject)
return (
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center gap-3">
<Zap size={24} className="text-cyber-orange" />
<div>
<h2 className="text-2xl font-bold text-gray-100">What can I do right now?</h2>
<p className="text-sm text-gray-500 mt-0.5">
{loading ? '...' : `${tasks.length} task${tasks.length !== 1 ? 's' : ''} ready across all projects`}
</p>
</div>
</div>
<button
onClick={loadTasks}
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-400 hover:text-gray-200 border border-cyber-orange/20 hover:border-cyber-orange/40 rounded transition-colors"
title="Refresh"
>
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
Refresh
</button>
</div>
{error && (
<div className="mb-6 px-4 py-3 bg-red-900/30 border border-red-500/40 rounded text-red-300 text-sm">
{error}
</div>
)}
{!loading && tasks.length === 0 && (
<div className="text-center py-20 text-gray-600">
<Zap size={40} className="mx-auto mb-4 opacity-30" />
<p className="text-lg">Nothing actionable right now.</p>
<p className="text-sm mt-1">All tasks are either done or blocked.</p>
</div>
)}
{/* Project groups */}
<div className="space-y-8">
{projectGroups.map(([projectId, group]) => (
<div key={projectId}>
{/* Project header */}
<button
onClick={() => navigate(`/project/${projectId}`)}
className="flex items-center gap-2 mb-3 text-cyber-orange hover:text-cyber-orange-bright transition-colors group"
>
<span className="text-sm font-semibold uppercase tracking-wider">{group.name}</span>
<ChevronRight size={14} className="opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
{/* Task cards */}
<div className="space-y-2">
{group.tasks.map(task => (
<div
key={task.id}
className={`flex items-center gap-3 px-4 py-3 bg-cyber-darkest border border-cyber-orange/20 rounded-lg hover:border-cyber-orange/40 transition-all ${
completingId === task.id ? 'opacity-50' : ''
}`}
>
{/* Done button */}
<button
onClick={() => handleMarkDone(task)}
disabled={completingId === task.id}
className="flex-shrink-0 w-6 h-6 rounded-full border-2 border-cyber-orange/40 hover:border-green-400 hover:bg-green-400/10 flex items-center justify-center transition-all group"
title="Mark as done"
>
<Check size={12} className="text-transparent group-hover:text-green-400 transition-colors" />
</button>
{/* Flag dot */}
{task.flag_color && (
<span className={`flex-shrink-0 w-2 h-2 rounded-full ${FLAG_DOT[task.flag_color] || 'bg-gray-500'}`} />
)}
{/* Title + meta */}
<div className="flex-1 min-w-0">
<span className="text-sm text-gray-100">{task.title}</span>
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
{task.status !== 'backlog' && (
<span className={`text-xs ${getStatusColor(task.status)}`}>
{formatStatusLabel(task.status)}
</span>
)}
{task.estimated_minutes && (
<span className="flex items-center gap-1 text-xs text-gray-600">
<Clock size={10} />
{formatTime(task.estimated_minutes)}
</span>
)}
{task.tags && task.tags.map(tag => (
<span key={tag} className="text-xs text-cyber-orange/60 bg-cyber-orange/5 px-1.5 py-0.5 rounded">
{tag}
</span>
))}
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
</div>
)
}
export default ActionableView

View File

@@ -1,12 +1,10 @@
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, Archive, ArchiveRestore, Clock } from 'lucide-react' import { Plus, Upload, Trash2, Archive, ArchiveRestore } from 'lucide-react'
import { getProjects, createProject, deleteProject, importJSON, archiveProject, unarchiveProject, getProjectTimeSummary } from '../utils/api' import { getProjects, createProject, deleteProject, importJSON, archiveProject, unarchiveProject } 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)
@@ -15,7 +13,6 @@ function ProjectList() {
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 [activeTab, setActiveTab] = useState('active') // 'active', 'archived', 'all'
const [activeCategory, setActiveCategory] = useState(null)
const navigate = useNavigate() const navigate = useNavigate()
useEffect(() => { useEffect(() => {
@@ -28,22 +25,10 @@ function ProjectList() {
let archivedFilter = null let archivedFilter = null
if (activeTab === 'active') archivedFilter = false if (activeTab === 'active') archivedFilter = false
if (activeTab === 'archived') archivedFilter = true if (activeTab === 'archived') archivedFilter = true
// 'all' tab uses null to get all projects
const data = await getProjects(archivedFilter) 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 {
@@ -140,39 +125,6 @@ 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 */} {/* Tabs */}
<div className="flex gap-1 mb-6 border-b border-cyber-orange/20"> <div className="flex gap-1 mb-6 border-b border-cyber-orange/20">
<button <button
@@ -213,142 +165,71 @@ function ProjectList() {
</div> </div>
)} )}
{(() => { {projects.length === 0 ? (
const visibleProjects = activeCategory <div className="text-center py-16 text-gray-500">
? projects.filter(p => p.category === activeCategory) <p className="text-xl mb-2">
: projects {activeTab === 'archived' ? 'No archived projects' : 'No projects yet'}
if (visibleProjects.length === 0) return ( </p>
<div className="text-center py-16 text-gray-500"> <p className="text-sm">
<p className="text-xl mb-2"> {activeTab === 'archived'
{activeTab === 'archived' ? 'No archived projects' : 'No projects yet'} ? 'Archive projects to keep them out of your active workspace'
</p> : 'Create a new project or import from JSON'}
<p className="text-sm"> </p>
{activeTab === 'archived' </div>
? 'Archive projects to keep them out of your active workspace' ) : (
: 'Create a new project or import from JSON'}
</p>
</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">
{(activeCategory ? projects.filter(p => p.category === activeCategory) : projects).map(project => { {projects.map(project => (
const summary = timeSummaries[project.id] <div
const weeklyPct = summary && summary.weekly_hours_goal key={project.id}
? Math.min(100, Math.round((summary.weekly_minutes / summary.weekly_hours_goal) * 100)) onClick={() => navigate(`/project/${project.id}`)}
: null className={`p-6 bg-cyber-darkest border rounded-lg hover:border-cyber-orange hover:shadow-cyber transition-all cursor-pointer group ${
const totalPct = summary && summary.total_hours_goal project.is_archived
? Math.min(100, Math.round((summary.total_minutes / summary.total_hours_goal) * 100)) ? 'border-gray-700 opacity-75'
: null : 'border-cyber-orange/30'
}`}
return ( >
<div <div className="flex justify-between items-start mb-2">
key={project.id} <h3 className="text-xl font-semibold text-gray-100 group-hover:text-cyber-orange transition-colors">
onClick={() => navigate(`/project/${project.id}`)} {project.name}
className={`p-6 bg-cyber-darkest border rounded-lg hover:border-cyber-orange hover:shadow-cyber transition-all cursor-pointer group ${ {project.is_archived && (
project.is_archived <span className="ml-2 text-xs text-gray-500">(archived)</span>
? 'border-gray-700 opacity-75' )}
: 'border-cyber-orange/30' </h3>
}`} <div className="flex gap-2">
> {project.is_archived ? (
<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">
{project.name}
{project.is_archived && (
<span className="ml-2 text-xs text-gray-500">(archived)</span>
)}
</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) => handleUnarchiveProject(project.id, e)}
className="text-gray-600 hover:text-red-400 transition-colors" className="text-gray-600 hover:text-cyber-orange transition-colors"
title="Delete project" title="Unarchive project"
> >
<Trash2 size={18} /> <ArchiveRestore size={18} />
</button> </button>
</div> ) : (
<button
onClick={(e) => handleArchiveProject(project.id, e)}
className="text-gray-600 hover:text-yellow-400 transition-colors"
title="Archive project"
>
<Archive size={18} />
</button>
)}
<button
onClick={(e) => handleDeleteProject(project.id, e)}
className="text-gray-600 hover:text-red-400 transition-colors"
title="Delete project"
>
<Trash2 size={18} />
</button>
</div> </div>
{/* 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 && (
<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">
Created {new Date(project.created_at).toLocaleDateString()}
</p>
</div> </div>
) {project.description && (
})} <p className="text-gray-400 text-sm line-clamp-2">{project.description}</p>
)}
<p className="text-xs text-gray-600 mt-3">
Created {new Date(project.created_at).toLocaleDateString()}
</p>
</div>
))}
</div> </div>
)} )}

View File

@@ -1,84 +1,85 @@
const API_BASE = import.meta.env.VITE_API_BASE_URL || '/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}`, {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options.headers, ...options.headers,
}, },
...options, ...options,
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Request failed' })); const error = await response.json().catch(() => ({ detail: 'Request failed' }));
throw new Error(error.detail || `HTTP ${response.status}`); throw new Error(error.detail || `HTTP ${response.status}`);
} }
if (response.status === 204) { if (response.status === 204) {
return null; return null;
} }
return response.json(); return response.json();
} }
// Projects // Projects
export const getProjects = (archived = null) => { export const getProjects = (archived = null) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (archived !== null) { if (archived !== null) {
params.append('archived', archived); params.append('archived', archived);
} }
const queryString = params.toString(); const queryString = params.toString();
return fetchAPI(`/projects${queryString ? `?${queryString}` : ''}`); 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',
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
export const updateProject = (id, data) => fetchAPI(`/projects/${id}`, { export const updateProject = (id, data) => fetchAPI(`/projects/${id}`, {
method: 'PUT', method: 'PUT',
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 archiveProject = (id) => updateProject(id, { is_archived: true });
export const unarchiveProject = (id) => updateProject(id, { is_archived: false }); 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`);
export const getProjectTaskTree = (projectId) => fetchAPI(`/projects/${projectId}/tasks/tree`); export const getProjectTaskTree = (projectId) => fetchAPI(`/projects/${projectId}/tasks/tree`);
export const getTasksByStatus = (projectId, status) => export const getTasksByStatus = (projectId, status) =>
fetchAPI(`/projects/${projectId}/tasks/by-status/${status}`); fetchAPI(`/projects/${projectId}/tasks/by-status/${status}`);
export const getTask = (id) => fetchAPI(`/tasks/${id}`); export const getTask = (id) => fetchAPI(`/tasks/${id}`);
export const createTask = (data) => fetchAPI('/tasks', { export const createTask = (data) => fetchAPI('/tasks', {
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
export const updateTask = (id, data) => fetchAPI(`/tasks/${id}`, { export const updateTask = (id, data) => fetchAPI(`/tasks/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
export const deleteTask = (id) => fetchAPI(`/tasks/${id}`, { method: 'DELETE' }); export const deleteTask = (id) => fetchAPI(`/tasks/${id}`, { method: 'DELETE' });
// JSON Import // JSON Import
export const importJSON = (data) => fetchAPI('/import-json', { export const importJSON = (data) => fetchAPI('/import-json', {
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
// Time Logs // Blockers
export const logTime = (taskId, data) => fetchAPI(`/tasks/${taskId}/time-logs`, { export const getTaskBlockers = (taskId) => fetchAPI(`/tasks/${taskId}/blockers`);
method: 'POST', export const getTaskBlocking = (taskId) => fetchAPI(`/tasks/${taskId}/blocking`);
body: JSON.stringify(data), export const addBlocker = (taskId, blockerId) => fetchAPI(`/tasks/${taskId}/blockers/${blockerId}`, { method: 'POST' });
}); export const removeBlocker = (taskId, blockerId) => fetchAPI(`/tasks/${taskId}/blockers/${blockerId}`, { method: 'DELETE' });
export const getTimeLogs = (taskId) => fetchAPI(`/tasks/${taskId}/time-logs`);
export const getProjectTimeSummary = (projectId) => fetchAPI(`/projects/${projectId}/time-summary`); // Actionable tasks (no incomplete blockers, not done)
export const getActionableTasks = () => fetchAPI('/actionable');
// Search
export const searchTasks = (query, projectIds = null) => { // Search
const params = new URLSearchParams({ query }); export const searchTasks = (query, projectIds = null) => {
if (projectIds && projectIds.length > 0) { const params = new URLSearchParams({ query });
params.append('project_ids', projectIds.join(',')); if (projectIds && projectIds.length > 0) {
} params.append('project_ids', projectIds.join(','));
return fetchAPI(`/search?${params.toString()}`); }
}; return fetchAPI(`/search?${params.toString()}`);
};

View File

@@ -1,511 +0,0 @@
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.”