2 Commits

Author SHA1 Message Date
serversdwn
1c4cdf042c chore: create campaign mode branch, include sys spec, and update gitignore 2026-03-29 19:03:15 +00:00
serversdwn
2ee75f719b feat: add Pomodoro timer functionality with logging and project goals
- Implemented Pomodoro timer in the app, allowing users to start, pause, and stop sessions.
- Added context for managing Pomodoro state and actions.
- Integrated time logging for completed sessions to track productivity.
- Enhanced project settings to include time goals and Pomodoro settings.
- Created migration scripts to update the database schema for new project fields and time logs.
- Updated UI components to display Pomodoro controls and project time summaries.
- Added category filtering for projects in the project list view.
2026-02-18 06:49:04 +00:00
21 changed files with 2879 additions and 1919 deletions

4
.gitignore vendored
View File

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

244
SYSTEMSPEC_v0.2.0.md Normal file
View File

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

View File

@@ -1,283 +1,253 @@
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
from typing import List, Optional from sqlalchemy import func
from . import models, schemas from typing import List, Optional
from datetime import datetime, timedelta
from . import models, schemas
# Project CRUD
def create_project(db: Session, project: schemas.ProjectCreate) -> models.Project:
project_data = project.model_dump() # Project CRUD
# Ensure statuses has a default value if not provided def create_project(db: Session, project: schemas.ProjectCreate) -> models.Project:
if project_data.get("statuses") is None: project_data = project.model_dump()
project_data["statuses"] = models.DEFAULT_STATUSES # Ensure statuses has a default value if not provided
if project_data.get("statuses") is None:
db_project = models.Project(**project_data) project_data["statuses"] = models.DEFAULT_STATUSES
db.add(db_project)
db.commit() db_project = models.Project(**project_data)
db.refresh(db_project) db.add(db_project)
return db_project db.commit()
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]:
# Filter by archive status if specified query = db.query(models.Project)
if archived is not None:
query = query.filter(models.Project.is_archived == archived) # Filter by archive status if specified
if archived is not None:
return query.offset(skip).limit(limit).all() query = query.filter(models.Project.is_archived == archived)
return query.offset(skip).limit(limit).all()
def update_project(
db: Session, project_id: int, project: schemas.ProjectUpdate
) -> Optional[models.Project]: def update_project(
db_project = get_project(db, project_id) db: Session, project_id: int, project: schemas.ProjectUpdate
if not db_project: ) -> Optional[models.Project]:
return None db_project = get_project(db, project_id)
if not db_project:
update_data = project.model_dump(exclude_unset=True) return None
for key, value in update_data.items():
setattr(db_project, key, value) update_data = project.model_dump(exclude_unset=True)
for key, value in update_data.items():
db.commit() setattr(db_project, key, value)
db.refresh(db_project)
return db_project db.commit()
db.refresh(db_project)
return db_project
def delete_project(db: Session, project_id: int) -> bool:
db_project = get_project(db, project_id)
if not db_project: def delete_project(db: Session, project_id: int) -> bool:
return False db_project = get_project(db, project_id)
db.delete(db_project) if not db_project:
db.commit() return False
return True db.delete(db_project)
db.commit()
return True
# Task CRUD
def create_task(db: Session, task: schemas.TaskCreate) -> models.Task:
# Validate status against project's statuses # Task CRUD
project = get_project(db, task.project_id) def create_task(db: Session, task: schemas.TaskCreate) -> models.Task:
if project and task.status not in project.statuses: # Validate status against project's statuses
raise ValueError(f"Invalid status '{task.status}'. Must be one of: {', '.join(project.statuses)}") project = get_project(db, task.project_id)
if project and task.status not in project.statuses:
# Get max sort_order for siblings raise ValueError(f"Invalid status '{task.status}'. Must be one of: {', '.join(project.statuses)}")
if task.parent_task_id:
max_order = db.query(models.Task).filter( # Get max sort_order for siblings
models.Task.parent_task_id == task.parent_task_id if task.parent_task_id:
).count() max_order = db.query(models.Task).filter(
else: models.Task.parent_task_id == task.parent_task_id
max_order = db.query(models.Task).filter( ).count()
models.Task.project_id == task.project_id, else:
models.Task.parent_task_id.is_(None) max_order = db.query(models.Task).filter(
).count() models.Task.project_id == task.project_id,
models.Task.parent_task_id.is_(None)
task_data = task.model_dump() ).count()
if "sort_order" not in task_data or task_data["sort_order"] == 0:
task_data["sort_order"] = max_order task_data = task.model_dump()
if "sort_order" not in task_data or task_data["sort_order"] == 0:
db_task = models.Task(**task_data) task_data["sort_order"] = max_order
db.add(db_task)
db.commit() db_task = models.Task(**task_data)
db.refresh(db_task) db.add(db_task)
return db_task db.commit()
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"""
return db.query(models.Task).filter( def get_root_tasks(db: Session, project_id: int) -> List[models.Task]:
models.Task.project_id == project_id, """Get all root-level tasks (no parent) for a project"""
models.Task.parent_task_id.is_(None) return db.query(models.Task).filter(
).order_by(models.Task.sort_order).all() models.Task.project_id == project_id,
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"""
return db.query(models.Task).options( def get_task_with_subtasks(db: Session, task_id: int) -> Optional[models.Task]:
joinedload(models.Task.subtasks) """Recursively load a task with all its subtasks"""
).filter(models.Task.id == task_id).first() return db.query(models.Task).options(
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"""
# Get all children of this parent def check_and_update_parent_status(db: Session, parent_id: int):
children = db.query(models.Task).filter( """Check if all children of a parent are done, and mark parent as done if so"""
models.Task.parent_task_id == parent_id # Get all children of this parent
).all() children = db.query(models.Task).filter(
models.Task.parent_task_id == parent_id
# If no children, nothing to do ).all()
if not children:
return # If no children, nothing to do
if not children:
# Check if all children are done return
all_done = all(child.status == "done" for child in children)
# Check if all children are done
if all_done: all_done = all(child.status == "done" for child in children)
# Mark parent as done
parent = get_task(db, parent_id) if all_done:
if parent and parent.status != "done": # Mark parent as done
parent.status = "done" parent = get_task(db, parent_id)
db.commit() if parent and parent.status != "done":
parent.status = "done"
# Recursively check grandparent db.commit()
if parent.parent_task_id:
check_and_update_parent_status(db, parent.parent_task_id) # Recursively check grandparent
if parent.parent_task_id:
check_and_update_parent_status(db, parent.parent_task_id)
def update_task(
db: Session, task_id: int, task: schemas.TaskUpdate
) -> Optional[models.Task]: def update_task(
db_task = get_task(db, task_id) db: Session, task_id: int, task: schemas.TaskUpdate
if not db_task: ) -> Optional[models.Task]:
return None db_task = get_task(db, task_id)
if not db_task:
update_data = task.model_dump(exclude_unset=True) return None
# Validate status against project's statuses if status is being updated update_data = task.model_dump(exclude_unset=True)
if "status" in update_data:
project = get_project(db, db_task.project_id) # Validate status against project's statuses if status is being updated
if project and update_data["status"] not in project.statuses: if "status" in update_data:
raise ValueError(f"Invalid status '{update_data['status']}'. Must be one of: {', '.join(project.statuses)}") project = get_project(db, db_task.project_id)
status_changed = True if project and update_data["status"] not in project.statuses:
old_status = db_task.status raise ValueError(f"Invalid status '{update_data['status']}'. Must be one of: {', '.join(project.statuses)}")
else: status_changed = True
status_changed = False old_status = db_task.status
else:
for key, value in update_data.items(): status_changed = False
setattr(db_task, key, value)
for key, value in update_data.items():
db.commit() setattr(db_task, key, value)
db.refresh(db_task)
db.commit()
# If status changed to 'done' and this task has a parent, check if parent should auto-complete db.refresh(db_task)
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) # 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:
return db_task check_and_update_parent_status(db, db_task.parent_task_id)
return db_task
def delete_task(db: Session, task_id: int) -> bool:
db_task = get_task(db, task_id)
if not db_task: def delete_task(db: Session, task_id: int) -> bool:
return False db_task = get_task(db, task_id)
db.delete(db_task) if not db_task:
db.commit() return False
return True db.delete(db_task)
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"""
# Validate status against project's statuses def get_tasks_by_status(db: Session, project_id: int, status: str) -> List[models.Task]:
project = get_project(db, project_id) """Get all tasks for a project with a specific status"""
if project and status not in project.statuses: # Validate status against project's statuses
raise ValueError(f"Invalid status '{status}'. Must be one of: {', '.join(project.statuses)}") project = get_project(db, project_id)
if project and status not in project.statuses:
return db.query(models.Task).filter( raise ValueError(f"Invalid status '{status}'. Must be one of: {', '.join(project.statuses)}")
models.Task.project_id == project_id,
models.Task.status == status return db.query(models.Task).filter(
).all() models.Task.project_id == project_id,
models.Task.status == status
).all()
# ========== BLOCKER CRUD ==========
def _has_cycle(db: Session, start_id: int, target_id: int) -> bool: # TimeLog CRUD
"""BFS from start_id following its blockers. Returns True if target_id is reachable, def create_time_log(db: Session, task_id: int, time_log: schemas.TimeLogCreate) -> models.TimeLog:
which would mean adding target_id as a blocker of start_id creates a cycle.""" db_log = models.TimeLog(
visited = set() task_id=task_id,
queue = [start_id] minutes=time_log.minutes,
while queue: note=time_log.note,
current = queue.pop(0) session_type=time_log.session_type,
if current == target_id: )
return True db.add(db_log)
if current in visited: db.commit()
continue db.refresh(db_log)
visited.add(current) return db_log
task = db.query(models.Task).filter(models.Task.id == current).first()
if task:
for b in task.blockers: def get_time_logs_by_task(db: Session, task_id: int) -> List[models.TimeLog]:
if b.id not in visited: return db.query(models.TimeLog).filter(
queue.append(b.id) models.TimeLog.task_id == task_id
return False ).order_by(models.TimeLog.logged_at.desc()).all()
def add_blocker(db: Session, task_id: int, blocker_id: int) -> models.Task: def get_project_time_summary(db: Session, project_id: int) -> dict:
"""Add blocker_id as a prerequisite of task_id. """Aggregate time logged across all tasks in a project"""
Raises ValueError on self-reference or cycle.""" project = get_project(db, project_id)
if task_id == blocker_id:
raise ValueError("A task cannot block itself") # Get all task IDs in this project
task_ids = db.query(models.Task.id).filter(
task = get_task(db, task_id) models.Task.project_id == project_id
blocker = get_task(db, blocker_id) ).subquery()
if not task: # Total minutes logged
raise ValueError("Task not found") total = db.query(func.sum(models.TimeLog.minutes)).filter(
if not blocker: models.TimeLog.task_id.in_(task_ids)
raise ValueError("Blocker task not found") ).scalar() or 0
# Already linked — idempotent # Pomodoro minutes
if any(b.id == blocker_id for b in task.blockers): pomodoro = db.query(func.sum(models.TimeLog.minutes)).filter(
return task models.TimeLog.task_id.in_(task_ids),
models.TimeLog.session_type == "pomodoro"
# Cycle detection: would blocker_id eventually depend on task_id? ).scalar() or 0
if _has_cycle(db, blocker_id, task_id):
raise ValueError("Adding this blocker would create a circular dependency") # Manual minutes
manual = db.query(func.sum(models.TimeLog.minutes)).filter(
task.blockers.append(blocker) models.TimeLog.task_id.in_(task_ids),
db.commit() models.TimeLog.session_type == "manual"
db.refresh(task) ).scalar() or 0
return task
# Weekly minutes (past 7 days)
week_ago = datetime.utcnow() - timedelta(days=7)
def remove_blocker(db: Session, task_id: int, blocker_id: int) -> bool: weekly = db.query(func.sum(models.TimeLog.minutes)).filter(
"""Remove blocker_id as a prerequisite of task_id.""" models.TimeLog.task_id.in_(task_ids),
task = get_task(db, task_id) models.TimeLog.logged_at >= week_ago
blocker = get_task(db, blocker_id) ).scalar() or 0
if not task or not blocker: return {
return False "total_minutes": total,
"pomodoro_minutes": pomodoro,
if not any(b.id == blocker_id for b in task.blockers): "manual_minutes": manual,
return False "weekly_minutes": weekly,
"weekly_hours_goal": project.weekly_hours_goal if project else None,
task.blockers.remove(blocker) "total_hours_goal": project.total_hours_goal if project else None,
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,368 +1,350 @@
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
# ========== BLOCKER ENDPOINTS ========== # ========== TIME LOG ENDPOINTS ==========
@app.get("/api/tasks/{task_id}/blockers", response_model=List[schemas.BlockerInfo]) @app.post("/api/tasks/{task_id}/time-logs", response_model=schemas.TimeLog, status_code=201)
def get_task_blockers(task_id: int, db: Session = Depends(get_db)): def log_time(task_id: int, time_log: schemas.TimeLogCreate, db: Session = Depends(get_db)):
"""Get all tasks that are blocking a given task.""" """Log time spent on a task"""
task = crud.get_task_with_blockers(db, task_id) if not crud.get_task(db, task_id):
if not task: raise HTTPException(status_code=404, detail="Task not found")
raise HTTPException(status_code=404, detail="Task not found") return crud.create_time_log(db, task_id, time_log)
return task.blockers
@app.get("/api/tasks/{task_id}/time-logs", response_model=List[schemas.TimeLog])
@app.get("/api/tasks/{task_id}/blocking", response_model=List[schemas.BlockerInfo]) def get_time_logs(task_id: int, db: Session = Depends(get_db)):
def get_tasks_this_blocks(task_id: int, db: Session = Depends(get_db)): """Get all time logs for a task"""
"""Get all tasks that this task is currently blocking.""" 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.get_time_logs_by_task(db, task_id)
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)):
@app.post("/api/tasks/{task_id}/blockers/{blocker_id}", status_code=201) """Get aggregated time statistics for a project"""
def add_blocker(task_id: int, blocker_id: int, db: Session = Depends(get_db)): if not crud.get_project(db, project_id):
"""Add blocker_id as a prerequisite of task_id.""" raise HTTPException(status_code=404, detail="Project not found")
try: return crud.get_project_time_summary(db, project_id)
crud.add_blocker(db, task_id, blocker_id)
return {"status": "ok"}
except ValueError as e: # ========== SEARCH ENDPOINT ==========
raise HTTPException(status_code=400, detail=str(e))
@app.get("/api/search", response_model=List[schemas.Task])
def search_tasks(
@app.delete("/api/tasks/{task_id}/blockers/{blocker_id}", status_code=204) query: str,
def remove_blocker(task_id: int, blocker_id: int, db: Session = Depends(get_db)): project_ids: Optional[str] = None,
"""Remove blocker_id as a prerequisite of task_id.""" db: Session = Depends(get_db)
if not crud.remove_blocker(db, task_id, blocker_id): ):
raise HTTPException(status_code=404, detail="Blocker relationship not found") """
return None Search tasks across projects by title, description, and tags.
Args:
@app.get("/api/actionable", response_model=List[schemas.ActionableTask]) query: Search term to match against title, description, and tags
def get_actionable_tasks(db: Session = Depends(get_db)): project_ids: Comma-separated list of project IDs to search in (optional, searches all if not provided)
"""Get all non-done tasks with no incomplete blockers, across all projects.""" """
return crud.get_actionable_tasks(db) # Parse project IDs if provided
project_id_list = None
if project_ids:
# ========== SEARCH ENDPOINT ========== try:
project_id_list = [int(pid.strip()) for pid in project_ids.split(',') if pid.strip()]
@app.get("/api/search", response_model=List[schemas.Task]) except ValueError:
def search_tasks( raise HTTPException(status_code=400, detail="Invalid project_ids format")
query: str,
project_ids: Optional[str] = None, # Build query
db: Session = Depends(get_db) tasks_query = db.query(models.Task)
):
""" # Filter by project IDs if specified
Search tasks across projects by title, description, and tags. if project_id_list:
tasks_query = tasks_query.filter(models.Task.project_id.in_(project_id_list))
Args:
query: Search term to match against title, description, and tags # Search in title, description, and tags
project_ids: Comma-separated list of project IDs to search in (optional, searches all if not provided) search_term = f"%{query}%"
""" tasks = tasks_query.filter(
# Parse project IDs if provided (models.Task.title.ilike(search_term)) |
project_id_list = None (models.Task.description.ilike(search_term)) |
if project_ids: (models.Task.tags.contains([query])) # Exact tag match
try: ).all()
project_id_list = [int(pid.strip()) for pid in project_ids.split(',') if pid.strip()]
except ValueError: return tasks
raise HTTPException(status_code=400, detail="Invalid project_ids format")
# Build query # ========== JSON IMPORT ENDPOINT ==========
tasks_query = db.query(models.Task)
def _validate_task_statuses_recursive(
# Filter by project IDs if specified tasks: List[schemas.ImportSubtask],
if project_id_list: valid_statuses: List[str],
tasks_query = tasks_query.filter(models.Task.project_id.in_(project_id_list)) path: str = ""
) -> None:
# Search in title, description, and tags """Recursively validate all task statuses against the project's valid statuses"""
search_term = f"%{query}%" for idx, task_data in enumerate(tasks):
tasks = tasks_query.filter( task_path = f"{path}.tasks[{idx}]" if path else f"tasks[{idx}]"
(models.Task.title.ilike(search_term)) | if task_data.status not in valid_statuses:
(models.Task.description.ilike(search_term)) | raise ValueError(
(models.Task.tags.contains([query])) # Exact tag match f"Invalid status '{task_data.status}' at {task_path}. "
).all() f"Must be one of: {', '.join(valid_statuses)}"
)
return tasks if task_data.subtasks:
_validate_task_statuses_recursive(
task_data.subtasks,
# ========== JSON IMPORT ENDPOINT ========== valid_statuses,
f"{task_path}.subtasks"
def _validate_task_statuses_recursive( )
tasks: List[schemas.ImportSubtask],
valid_statuses: List[str],
path: str = "" def _import_tasks_recursive(
) -> None: db: Session,
"""Recursively validate all task statuses against the project's valid statuses""" project_id: int,
for idx, task_data in enumerate(tasks): tasks: List[schemas.ImportSubtask],
task_path = f"{path}.tasks[{idx}]" if path else f"tasks[{idx}]" parent_id: Optional[int] = None,
if task_data.status not in valid_statuses: count: int = 0
raise ValueError( ) -> int:
f"Invalid status '{task_data.status}' at {task_path}. " """Recursively import tasks and their subtasks"""
f"Must be one of: {', '.join(valid_statuses)}" for idx, task_data in enumerate(tasks):
) task = schemas.TaskCreate(
if task_data.subtasks: project_id=project_id,
_validate_task_statuses_recursive( parent_task_id=parent_id,
task_data.subtasks, title=task_data.title,
valid_statuses, description=task_data.description,
f"{task_path}.subtasks" status=task_data.status,
) estimated_minutes=task_data.estimated_minutes,
tags=task_data.tags,
flag_color=task_data.flag_color,
def _import_tasks_recursive( sort_order=idx
db: Session, )
project_id: int, db_task = crud.create_task(db, task)
tasks: List[schemas.ImportSubtask], count += 1
parent_id: Optional[int] = None,
count: int = 0 if task_data.subtasks:
) -> int: count = _import_tasks_recursive(
"""Recursively import tasks and their subtasks""" db, project_id, task_data.subtasks, db_task.id, count
for idx, task_data in enumerate(tasks): )
task = schemas.TaskCreate(
project_id=project_id, return count
parent_task_id=parent_id,
title=task_data.title,
description=task_data.description, @app.post("/api/import-json", response_model=schemas.ImportResult)
status=task_data.status, def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_db)):
estimated_minutes=task_data.estimated_minutes, """
tags=task_data.tags, Import a project with nested tasks from JSON.
flag_color=task_data.flag_color,
sort_order=idx Expected format:
) {
db_task = crud.create_task(db, task) "project": {
count += 1 "name": "Project Name",
"description": "Optional description",
if task_data.subtasks: "statuses": ["backlog", "in_progress", "on_hold", "done"] // Optional
count = _import_tasks_recursive( },
db, project_id, task_data.subtasks, db_task.id, count "tasks": [
) {
"title": "Task 1",
return count "description": "Optional",
"status": "backlog",
"subtasks": [
@app.post("/api/import-json", response_model=schemas.ImportResult) {
def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_db)): "title": "Subtask 1.1",
""" "status": "backlog",
Import a project with nested tasks from JSON. "subtasks": []
}
Expected format: ]
{ }
"project": { ]
"name": "Project Name", }
"description": "Optional description", """
"statuses": ["backlog", "in_progress", "on_hold", "done"] // Optional # Create the project with optional statuses
}, project = crud.create_project(
"tasks": [ db,
{ schemas.ProjectCreate(
"title": "Task 1", name=import_data.project.name,
"description": "Optional", description=import_data.project.description,
"status": "backlog", statuses=import_data.project.statuses
"subtasks": [ )
{ )
"title": "Subtask 1.1",
"status": "backlog", # Validate all task statuses before importing
"subtasks": [] if import_data.tasks:
} try:
] _validate_task_statuses_recursive(import_data.tasks, project.statuses)
} except ValueError as e:
] # Rollback the project creation if validation fails
} db.delete(project)
""" db.commit()
# Create the project with optional statuses raise HTTPException(status_code=400, detail=str(e))
project = crud.create_project(
db, # Recursively import tasks
schemas.ProjectCreate( tasks_created = _import_tasks_recursive(
name=import_data.project.name, db, project.id, import_data.tasks
description=import_data.project.description, )
statuses=import_data.project.statuses
) return schemas.ImportResult(
) project_id=project.id,
project_name=project.name,
# Validate all task statuses before importing tasks_created=tasks_created
if import_data.tasks: )
try:
_validate_task_statuses_recursive(import_data.tasks, project.statuses)
except ValueError as e: @app.get("/")
# Rollback the project creation if validation fails def root():
db.delete(project) """API health check"""
db.commit() return {
raise HTTPException(status_code=400, detail=str(e)) "status": "online",
"message": "Break It Down (BIT) API",
# Recursively import tasks "docs": "/docs"
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, Table from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Boolean
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from datetime import datetime from datetime import datetime
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"]
# Association table for task blocker relationships (many-to-many) class Project(Base):
task_blockers = Table( __tablename__ = "projects"
"task_blockers",
Base.metadata, id = Column(Integer, primary_key=True, index=True)
Column("task_id", Integer, ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True), name = Column(String(255), nullable=False)
Column("blocked_by_id", Integer, ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True), description = Column(Text, nullable=True)
) statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES)
is_archived = Column(Boolean, default=False, nullable=False)
category = Column(String(100), nullable=True)
class Project(Base): weekly_hours_goal = Column(Integer, nullable=True) # stored in minutes
__tablename__ = "projects" total_hours_goal = Column(Integer, nullable=True) # stored in minutes
pomodoro_work_minutes = Column(Integer, nullable=True, default=25)
id = Column(Integer, primary_key=True, index=True) pomodoro_break_minutes = Column(Integer, nullable=True, default=5)
name = Column(String(255), nullable=False) created_at = Column(DateTime, default=datetime.utcnow)
description = Column(Text, nullable=True) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES)
is_archived = Column(Boolean, default=False, nullable=False) tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan")
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class Task(Base):
tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan") __tablename__ = "tasks"
id = Column(Integer, primary_key=True, index=True)
class Task(Base): project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
__tablename__ = "tasks" parent_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True)
title = Column(String(500), nullable=False)
id = Column(Integer, primary_key=True, index=True) description = Column(Text, nullable=True)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False) status = Column(String(50), default="backlog", nullable=False)
parent_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True) sort_order = Column(Integer, default=0)
title = Column(String(500), nullable=False) estimated_minutes = Column(Integer, nullable=True)
description = Column(Text, nullable=True) tags = Column(JSON, nullable=True)
status = Column(String(50), default="backlog", nullable=False) flag_color = Column(String(50), nullable=True)
sort_order = Column(Integer, default=0) created_at = Column(DateTime, default=datetime.utcnow)
estimated_minutes = Column(Integer, nullable=True) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
tags = Column(JSON, nullable=True)
flag_color = Column(String(50), nullable=True) project = relationship("Project", back_populates="tasks")
created_at = Column(DateTime, default=datetime.utcnow) parent = relationship("Task", remote_side=[id], backref="subtasks")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) time_logs = relationship("TimeLog", back_populates="task", cascade="all, delete-orphan")
project = relationship("Project", back_populates="tasks")
parent = relationship("Task", remote_side=[id], backref="subtasks") class TimeLog(Base):
__tablename__ = "time_logs"
# blockers: tasks that must be done before this task can start
# blocking: tasks that this task is holding up id = Column(Integer, primary_key=True, index=True)
blockers = relationship( task_id = Column(Integer, ForeignKey("tasks.id"), nullable=False)
"Task", minutes = Column(Integer, nullable=False)
secondary=task_blockers, note = Column(Text, nullable=True)
primaryjoin=lambda: Task.id == task_blockers.c.task_id, session_type = Column(String(50), default="manual") # 'pomodoro' | 'manual'
secondaryjoin=lambda: Task.id == task_blockers.c.blocked_by_id, logged_at = Column(DateTime, default=datetime.utcnow)
backref="blocking",
) task = relationship("Task", back_populates="time_logs")

View File

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

View File

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

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

View File

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

View File

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

@@ -1,201 +0,0 @@
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 } from 'lucide-react' import { Plus, Check, X, Flag, Clock, ChevronDown, ChevronRight, ChevronsDown, ChevronsUp, Timer } from 'lucide-react'
import { import {
getProjectTasks, getProjectTasks,
createTask, createTask,
@@ -9,6 +9,7 @@ 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) => {
@@ -69,10 +70,12 @@ 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 }) { function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatus, expandedCards, setExpandedCards, projectStatuses, projectId, pomodoroSettings }) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [editTitle, setEditTitle] = useState(task.title) const [editTitle, setEditTitle] = useState(task.title)
const [showAddSubtask, setShowAddSubtask] = useState(false) const [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
@@ -234,6 +237,20 @@ 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"
@@ -283,6 +300,7 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
setExpandedCards={setExpandedCards} setExpandedCards={setExpandedCards}
projectStatuses={projectStatuses} projectStatuses={projectStatuses}
projectId={projectId} projectId={projectId}
pomodoroSettings={pomodoroSettings}
/> />
))} ))}
</div> </div>
@@ -291,7 +309,7 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
) )
} }
function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOver, expandedCards, setExpandedCards, projectStatuses }) { function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOver, expandedCards, setExpandedCards, projectStatuses, pomodoroSettings }) {
const [showAddTask, setShowAddTask] = useState(false) const [showAddTask, setShowAddTask] = useState(false)
const handleAddTask = async (taskData) => { const handleAddTask = async (taskData) => {
@@ -384,6 +402,7 @@ function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOve
setExpandedCards={setExpandedCards} setExpandedCards={setExpandedCards}
projectStatuses={projectStatuses} projectStatuses={projectStatuses}
projectId={projectId} projectId={projectId}
pomodoroSettings={pomodoroSettings}
/> />
) )
})} })}
@@ -400,6 +419,10 @@ 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),
@@ -507,6 +530,7 @@ function KanbanView({ projectId, project }) {
expandedCards={expandedCards} expandedCards={expandedCards}
setExpandedCards={setExpandedCards} setExpandedCards={setExpandedCards}
projectStatuses={statuses} projectStatuses={statuses}
pomodoroSettings={pomodoroSettings}
/> />
))} ))}
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -1,190 +0,0 @@
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,10 +1,12 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Plus, Upload, Trash2, Archive, ArchiveRestore } from 'lucide-react' import { Plus, Upload, Trash2, Archive, ArchiveRestore, Clock } from 'lucide-react'
import { getProjects, createProject, deleteProject, importJSON, archiveProject, unarchiveProject } from '../utils/api' import { getProjects, createProject, deleteProject, importJSON, archiveProject, unarchiveProject, getProjectTimeSummary } from '../utils/api'
import { formatTime } from '../utils/format'
function ProjectList() { function ProjectList() {
const [projects, setProjects] = useState([]) const [projects, setProjects] = useState([])
const [timeSummaries, setTimeSummaries] = useState({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showCreateModal, setShowCreateModal] = useState(false) const [showCreateModal, setShowCreateModal] = useState(false)
const [showImportModal, setShowImportModal] = useState(false) const [showImportModal, setShowImportModal] = useState(false)
@@ -13,6 +15,7 @@ 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(() => {
@@ -25,10 +28,22 @@ 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 {
@@ -125,6 +140,39 @@ 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
@@ -165,71 +213,142 @@ function ProjectList() {
</div> </div>
)} )}
{projects.length === 0 ? ( {(() => {
<div className="text-center py-16 text-gray-500"> const visibleProjects = activeCategory
<p className="text-xl mb-2"> ? projects.filter(p => p.category === activeCategory)
{activeTab === 'archived' ? 'No archived projects' : 'No projects yet'} : projects
</p> if (visibleProjects.length === 0) return (
<p className="text-sm"> <div className="text-center py-16 text-gray-500">
{activeTab === 'archived' <p className="text-xl mb-2">
? 'Archive projects to keep them out of your active workspace' {activeTab === 'archived' ? 'No archived projects' : 'No projects yet'}
: 'Create a new project or import from JSON'} </p>
</p> <p className="text-sm">
</div> {activeTab === 'archived'
) : ( ? '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">
{projects.map(project => ( {(activeCategory ? projects.filter(p => p.category === activeCategory) : projects).map(project => {
<div const summary = timeSummaries[project.id]
key={project.id} const weeklyPct = summary && summary.weekly_hours_goal
onClick={() => navigate(`/project/${project.id}`)} ? Math.min(100, Math.round((summary.weekly_minutes / summary.weekly_hours_goal) * 100))
className={`p-6 bg-cyber-darkest border rounded-lg hover:border-cyber-orange hover:shadow-cyber transition-all cursor-pointer group ${ : null
project.is_archived const totalPct = summary && summary.total_hours_goal
? 'border-gray-700 opacity-75' ? Math.min(100, Math.round((summary.total_minutes / summary.total_hours_goal) * 100))
: 'border-cyber-orange/30' : null
}`}
> return (
<div className="flex justify-between items-start mb-2"> <div
<h3 className="text-xl font-semibold text-gray-100 group-hover:text-cyber-orange transition-colors"> key={project.id}
{project.name} onClick={() => navigate(`/project/${project.id}`)}
{project.is_archived && ( className={`p-6 bg-cyber-darkest border rounded-lg hover:border-cyber-orange hover:shadow-cyber transition-all cursor-pointer group ${
<span className="ml-2 text-xs text-gray-500">(archived)</span> project.is_archived
)} ? 'border-gray-700 opacity-75'
</h3> : 'border-cyber-orange/30'
<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) => handleUnarchiveProject(project.id, e)} onClick={(e) => handleDeleteProject(project.id, e)}
className="text-gray-600 hover:text-cyber-orange transition-colors" className="text-gray-600 hover:text-red-400 transition-colors"
title="Unarchive project" title="Delete project"
> >
<ArchiveRestore size={18} /> <Trash2 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,85 +1,84 @@
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),
}); });
// Blockers // Time Logs
export const getTaskBlockers = (taskId) => fetchAPI(`/tasks/${taskId}/blockers`); export const logTime = (taskId, data) => fetchAPI(`/tasks/${taskId}/time-logs`, {
export const getTaskBlocking = (taskId) => fetchAPI(`/tasks/${taskId}/blocking`); method: 'POST',
export const addBlocker = (taskId, blockerId) => fetchAPI(`/tasks/${taskId}/blockers/${blockerId}`, { method: 'POST' }); body: JSON.stringify(data),
export const removeBlocker = (taskId, blockerId) => fetchAPI(`/tasks/${taskId}/blockers/${blockerId}`, { method: 'DELETE' }); });
export const getTimeLogs = (taskId) => fetchAPI(`/tasks/${taskId}/time-logs`);
// Actionable tasks (no incomplete blockers, not done) export const getProjectTimeSummary = (projectId) => fetchAPI(`/projects/${projectId}/time-summary`);
export const getActionableTasks = () => fetchAPI('/actionable');
// Search
// Search export const searchTasks = (query, projectIds = null) => {
export const searchTasks = (query, projectIds = null) => { const params = new URLSearchParams({ query });
const params = new URLSearchParams({ query }); if (projectIds && projectIds.length > 0) {
if (projectIds && projectIds.length > 0) { params.append('project_ids', projectIds.join(','));
params.append('project_ids', projectIds.join(',')); }
} return fetchAPI(`/search?${params.toString()}`);
return fetchAPI(`/search?${params.toString()}`); };
};

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

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