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

View File

@@ -1,368 +1,350 @@
from fastapi import FastAPI, Depends, HTTPException, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from typing import List, Optional
import json
from . import models, schemas, crud
from .database import engine, get_db
from .settings import settings
# Create database tables
models.Base.metadata.create_all(bind=engine)
app = FastAPI(
title=settings.api_title,
description=settings.api_description,
version=settings.api_version
)
# CORS middleware for frontend
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ========== PROJECT ENDPOINTS ==========
@app.get("/api/projects", response_model=List[schemas.Project])
def list_projects(
skip: int = 0,
limit: int = 100,
archived: Optional[bool] = None,
db: Session = Depends(get_db)
):
"""List all projects with optional archive filter"""
return crud.get_projects(db, skip=skip, limit=limit, archived=archived)
@app.post("/api/projects", response_model=schemas.Project, status_code=201)
def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db)):
"""Create a new project"""
return crud.create_project(db, project)
@app.get("/api/projects/{project_id}", response_model=schemas.Project)
def get_project(project_id: int, db: Session = Depends(get_db)):
"""Get a specific project"""
db_project = crud.get_project(db, project_id)
if not db_project:
raise HTTPException(status_code=404, detail="Project not found")
return db_project
@app.put("/api/projects/{project_id}", response_model=schemas.Project)
def update_project(
project_id: int, project: schemas.ProjectUpdate, db: Session = Depends(get_db)
):
"""Update a project"""
db_project = crud.update_project(db, project_id, project)
if not db_project:
raise HTTPException(status_code=404, detail="Project not found")
return db_project
@app.delete("/api/projects/{project_id}", status_code=204)
def delete_project(project_id: int, db: Session = Depends(get_db)):
"""Delete a project and all its tasks"""
if not crud.delete_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found")
return None
# ========== TASK ENDPOINTS ==========
@app.get("/api/projects/{project_id}/tasks", response_model=List[schemas.Task])
def list_project_tasks(project_id: int, db: Session = Depends(get_db)):
"""List all tasks for a project"""
if not crud.get_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found")
return crud.get_tasks_by_project(db, project_id)
@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)):
"""Get the task tree (root tasks with nested subtasks) for a project"""
if not crud.get_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found")
root_tasks = crud.get_root_tasks(db, project_id)
def build_tree(task):
task_dict = schemas.TaskWithSubtasks.model_validate(task)
task_dict.subtasks = [build_tree(subtask) for subtask in task.subtasks]
return task_dict
return [build_tree(task) for task in root_tasks]
@app.get("/api/projects/{project_id}/tasks/by-status/{status}", response_model=List[schemas.Task])
def get_tasks_by_status(
project_id: int,
status: str,
db: Session = Depends(get_db)
):
"""Get all tasks for a project filtered by status (for Kanban view)"""
if not crud.get_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found")
try:
return crud.get_tasks_by_status(db, project_id, status)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/tasks", response_model=schemas.Task, status_code=201)
def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)):
"""Create a new task"""
if not crud.get_project(db, task.project_id):
raise HTTPException(status_code=404, detail="Project not found")
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")
try:
return crud.create_task(db, task)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/api/tasks/{task_id}", response_model=schemas.Task)
def get_task(task_id: int, db: Session = Depends(get_db)):
"""Get a specific task"""
db_task = crud.get_task(db, task_id)
if not db_task:
raise HTTPException(status_code=404, detail="Task not found")
return db_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)):
"""Update a task"""
try:
db_task = crud.update_task(db, task_id, task)
if not db_task:
raise HTTPException(status_code=404, detail="Task not found")
return db_task
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.delete("/api/tasks/{task_id}", status_code=204)
def delete_task(task_id: int, db: Session = Depends(get_db)):
"""Delete a task and all its subtasks"""
if not crud.delete_task(db, task_id):
raise HTTPException(status_code=404, detail="Task not found")
return None
# ========== BLOCKER ENDPOINTS ==========
@app.get("/api/tasks/{task_id}/blockers", response_model=List[schemas.BlockerInfo])
def get_task_blockers(task_id: int, db: Session = Depends(get_db)):
"""Get all tasks that are blocking a given task."""
task = crud.get_task_with_blockers(db, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task.blockers
@app.get("/api/tasks/{task_id}/blocking", response_model=List[schemas.BlockerInfo])
def get_tasks_this_blocks(task_id: int, db: Session = Depends(get_db)):
"""Get all tasks that this task is currently blocking."""
task = crud.get_task_with_blockers(db, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task.blocking
@app.post("/api/tasks/{task_id}/blockers/{blocker_id}", status_code=201)
def add_blocker(task_id: int, blocker_id: int, db: Session = Depends(get_db)):
"""Add blocker_id as a prerequisite of task_id."""
try:
crud.add_blocker(db, task_id, blocker_id)
return {"status": "ok"}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.delete("/api/tasks/{task_id}/blockers/{blocker_id}", status_code=204)
def remove_blocker(task_id: int, blocker_id: int, db: Session = Depends(get_db)):
"""Remove blocker_id as a prerequisite of task_id."""
if not crud.remove_blocker(db, task_id, blocker_id):
raise HTTPException(status_code=404, detail="Blocker relationship not found")
return None
@app.get("/api/actionable", response_model=List[schemas.ActionableTask])
def get_actionable_tasks(db: Session = Depends(get_db)):
"""Get all non-done tasks with no incomplete blockers, across all projects."""
return crud.get_actionable_tasks(db)
# ========== SEARCH ENDPOINT ==========
@app.get("/api/search", response_model=List[schemas.Task])
def search_tasks(
query: str,
project_ids: Optional[str] = None,
db: Session = Depends(get_db)
):
"""
Search tasks across projects by title, description, and tags.
Args:
query: Search term to match against title, description, and tags
project_ids: Comma-separated list of project IDs to search in (optional, searches all if not provided)
"""
# Parse project IDs if provided
project_id_list = None
if project_ids:
try:
project_id_list = [int(pid.strip()) for pid in project_ids.split(',') if pid.strip()]
except ValueError:
raise HTTPException(status_code=400, detail="Invalid project_ids format")
# Build query
tasks_query = db.query(models.Task)
# Filter by project IDs if specified
if project_id_list:
tasks_query = tasks_query.filter(models.Task.project_id.in_(project_id_list))
# Search in title, description, and tags
search_term = f"%{query}%"
tasks = tasks_query.filter(
(models.Task.title.ilike(search_term)) |
(models.Task.description.ilike(search_term)) |
(models.Task.tags.contains([query])) # Exact tag match
).all()
return tasks
# ========== JSON IMPORT ENDPOINT ==========
def _validate_task_statuses_recursive(
tasks: List[schemas.ImportSubtask],
valid_statuses: List[str],
path: str = ""
) -> None:
"""Recursively validate all task statuses against the project's valid statuses"""
for idx, task_data in enumerate(tasks):
task_path = f"{path}.tasks[{idx}]" if path else f"tasks[{idx}]"
if task_data.status not in valid_statuses:
raise ValueError(
f"Invalid status '{task_data.status}' at {task_path}. "
f"Must be one of: {', '.join(valid_statuses)}"
)
if task_data.subtasks:
_validate_task_statuses_recursive(
task_data.subtasks,
valid_statuses,
f"{task_path}.subtasks"
)
def _import_tasks_recursive(
db: Session,
project_id: int,
tasks: List[schemas.ImportSubtask],
parent_id: Optional[int] = None,
count: int = 0
) -> int:
"""Recursively import tasks and their subtasks"""
for idx, task_data in enumerate(tasks):
task = schemas.TaskCreate(
project_id=project_id,
parent_task_id=parent_id,
title=task_data.title,
description=task_data.description,
status=task_data.status,
estimated_minutes=task_data.estimated_minutes,
tags=task_data.tags,
flag_color=task_data.flag_color,
sort_order=idx
)
db_task = crud.create_task(db, task)
count += 1
if task_data.subtasks:
count = _import_tasks_recursive(
db, project_id, task_data.subtasks, db_task.id, count
)
return count
@app.post("/api/import-json", response_model=schemas.ImportResult)
def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_db)):
"""
Import a project with nested tasks from JSON.
Expected format:
{
"project": {
"name": "Project Name",
"description": "Optional description",
"statuses": ["backlog", "in_progress", "on_hold", "done"] // Optional
},
"tasks": [
{
"title": "Task 1",
"description": "Optional",
"status": "backlog",
"subtasks": [
{
"title": "Subtask 1.1",
"status": "backlog",
"subtasks": []
}
]
}
]
}
"""
# Create the project with optional statuses
project = crud.create_project(
db,
schemas.ProjectCreate(
name=import_data.project.name,
description=import_data.project.description,
statuses=import_data.project.statuses
)
)
# Validate all task statuses before importing
if import_data.tasks:
try:
_validate_task_statuses_recursive(import_data.tasks, project.statuses)
except ValueError as e:
# Rollback the project creation if validation fails
db.delete(project)
db.commit()
raise HTTPException(status_code=400, detail=str(e))
# Recursively import tasks
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"
}
from fastapi import FastAPI, Depends, HTTPException, UploadFile, File
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from typing import List, Optional
import json
from . import models, schemas, crud
from .database import engine, get_db
from .settings import settings
# Create database tables
models.Base.metadata.create_all(bind=engine)
app = FastAPI(
title=settings.api_title,
description=settings.api_description,
version=settings.api_version
)
# CORS middleware for frontend
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ========== PROJECT ENDPOINTS ==========
@app.get("/api/projects", response_model=List[schemas.Project])
def list_projects(
skip: int = 0,
limit: int = 100,
archived: Optional[bool] = None,
db: Session = Depends(get_db)
):
"""List all projects with optional archive filter"""
return crud.get_projects(db, skip=skip, limit=limit, archived=archived)
@app.post("/api/projects", response_model=schemas.Project, status_code=201)
def create_project(project: schemas.ProjectCreate, db: Session = Depends(get_db)):
"""Create a new project"""
return crud.create_project(db, project)
@app.get("/api/projects/{project_id}", response_model=schemas.Project)
def get_project(project_id: int, db: Session = Depends(get_db)):
"""Get a specific project"""
db_project = crud.get_project(db, project_id)
if not db_project:
raise HTTPException(status_code=404, detail="Project not found")
return db_project
@app.put("/api/projects/{project_id}", response_model=schemas.Project)
def update_project(
project_id: int, project: schemas.ProjectUpdate, db: Session = Depends(get_db)
):
"""Update a project"""
db_project = crud.update_project(db, project_id, project)
if not db_project:
raise HTTPException(status_code=404, detail="Project not found")
return db_project
@app.delete("/api/projects/{project_id}", status_code=204)
def delete_project(project_id: int, db: Session = Depends(get_db)):
"""Delete a project and all its tasks"""
if not crud.delete_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found")
return None
# ========== TASK ENDPOINTS ==========
@app.get("/api/projects/{project_id}/tasks", response_model=List[schemas.Task])
def list_project_tasks(project_id: int, db: Session = Depends(get_db)):
"""List all tasks for a project"""
if not crud.get_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found")
return crud.get_tasks_by_project(db, project_id)
@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)):
"""Get the task tree (root tasks with nested subtasks) for a project"""
if not crud.get_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found")
root_tasks = crud.get_root_tasks(db, project_id)
def build_tree(task):
task_dict = schemas.TaskWithSubtasks.model_validate(task)
task_dict.subtasks = [build_tree(subtask) for subtask in task.subtasks]
return task_dict
return [build_tree(task) for task in root_tasks]
@app.get("/api/projects/{project_id}/tasks/by-status/{status}", response_model=List[schemas.Task])
def get_tasks_by_status(
project_id: int,
status: str,
db: Session = Depends(get_db)
):
"""Get all tasks for a project filtered by status (for Kanban view)"""
if not crud.get_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found")
try:
return crud.get_tasks_by_status(db, project_id, status)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/tasks", response_model=schemas.Task, status_code=201)
def create_task(task: schemas.TaskCreate, db: Session = Depends(get_db)):
"""Create a new task"""
if not crud.get_project(db, task.project_id):
raise HTTPException(status_code=404, detail="Project not found")
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")
try:
return crud.create_task(db, task)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/api/tasks/{task_id}", response_model=schemas.Task)
def get_task(task_id: int, db: Session = Depends(get_db)):
"""Get a specific task"""
db_task = crud.get_task(db, task_id)
if not db_task:
raise HTTPException(status_code=404, detail="Task not found")
return db_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)):
"""Update a task"""
try:
db_task = crud.update_task(db, task_id, task)
if not db_task:
raise HTTPException(status_code=404, detail="Task not found")
return db_task
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.delete("/api/tasks/{task_id}", status_code=204)
def delete_task(task_id: int, db: Session = Depends(get_db)):
"""Delete a task and all its subtasks"""
if not crud.delete_task(db, task_id):
raise HTTPException(status_code=404, detail="Task not found")
return None
# ========== TIME LOG ENDPOINTS ==========
@app.post("/api/tasks/{task_id}/time-logs", response_model=schemas.TimeLog, status_code=201)
def log_time(task_id: int, time_log: schemas.TimeLogCreate, db: Session = Depends(get_db)):
"""Log time spent on a task"""
if not crud.get_task(db, task_id):
raise HTTPException(status_code=404, detail="Task not found")
return crud.create_time_log(db, task_id, time_log)
@app.get("/api/tasks/{task_id}/time-logs", response_model=List[schemas.TimeLog])
def get_time_logs(task_id: int, db: Session = Depends(get_db)):
"""Get all time logs for a task"""
if not crud.get_task(db, task_id):
raise HTTPException(status_code=404, detail="Task not found")
return crud.get_time_logs_by_task(db, task_id)
@app.get("/api/projects/{project_id}/time-summary", response_model=schemas.ProjectTimeSummary)
def get_project_time_summary(project_id: int, db: Session = Depends(get_db)):
"""Get aggregated time statistics for a project"""
if not crud.get_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found")
return crud.get_project_time_summary(db, project_id)
# ========== SEARCH ENDPOINT ==========
@app.get("/api/search", response_model=List[schemas.Task])
def search_tasks(
query: str,
project_ids: Optional[str] = None,
db: Session = Depends(get_db)
):
"""
Search tasks across projects by title, description, and tags.
Args:
query: Search term to match against title, description, and tags
project_ids: Comma-separated list of project IDs to search in (optional, searches all if not provided)
"""
# Parse project IDs if provided
project_id_list = None
if project_ids:
try:
project_id_list = [int(pid.strip()) for pid in project_ids.split(',') if pid.strip()]
except ValueError:
raise HTTPException(status_code=400, detail="Invalid project_ids format")
# Build query
tasks_query = db.query(models.Task)
# Filter by project IDs if specified
if project_id_list:
tasks_query = tasks_query.filter(models.Task.project_id.in_(project_id_list))
# Search in title, description, and tags
search_term = f"%{query}%"
tasks = tasks_query.filter(
(models.Task.title.ilike(search_term)) |
(models.Task.description.ilike(search_term)) |
(models.Task.tags.contains([query])) # Exact tag match
).all()
return tasks
# ========== JSON IMPORT ENDPOINT ==========
def _validate_task_statuses_recursive(
tasks: List[schemas.ImportSubtask],
valid_statuses: List[str],
path: str = ""
) -> None:
"""Recursively validate all task statuses against the project's valid statuses"""
for idx, task_data in enumerate(tasks):
task_path = f"{path}.tasks[{idx}]" if path else f"tasks[{idx}]"
if task_data.status not in valid_statuses:
raise ValueError(
f"Invalid status '{task_data.status}' at {task_path}. "
f"Must be one of: {', '.join(valid_statuses)}"
)
if task_data.subtasks:
_validate_task_statuses_recursive(
task_data.subtasks,
valid_statuses,
f"{task_path}.subtasks"
)
def _import_tasks_recursive(
db: Session,
project_id: int,
tasks: List[schemas.ImportSubtask],
parent_id: Optional[int] = None,
count: int = 0
) -> int:
"""Recursively import tasks and their subtasks"""
for idx, task_data in enumerate(tasks):
task = schemas.TaskCreate(
project_id=project_id,
parent_task_id=parent_id,
title=task_data.title,
description=task_data.description,
status=task_data.status,
estimated_minutes=task_data.estimated_minutes,
tags=task_data.tags,
flag_color=task_data.flag_color,
sort_order=idx
)
db_task = crud.create_task(db, task)
count += 1
if task_data.subtasks:
count = _import_tasks_recursive(
db, project_id, task_data.subtasks, db_task.id, count
)
return count
@app.post("/api/import-json", response_model=schemas.ImportResult)
def import_from_json(import_data: schemas.ImportData, db: Session = Depends(get_db)):
"""
Import a project with nested tasks from JSON.
Expected format:
{
"project": {
"name": "Project Name",
"description": "Optional description",
"statuses": ["backlog", "in_progress", "on_hold", "done"] // Optional
},
"tasks": [
{
"title": "Task 1",
"description": "Optional",
"status": "backlog",
"subtasks": [
{
"title": "Subtask 1.1",
"status": "backlog",
"subtasks": []
}
]
}
]
}
"""
# Create the project with optional statuses
project = crud.create_project(
db,
schemas.ProjectCreate(
name=import_data.project.name,
description=import_data.project.description,
statuses=import_data.project.statuses
)
)
# Validate all task statuses before importing
if import_data.tasks:
try:
_validate_task_statuses_recursive(import_data.tasks, project.statuses)
except ValueError as e:
# Rollback the project creation if validation fails
db.delete(project)
db.commit()
raise HTTPException(status_code=400, detail=str(e))
# Recursively import tasks
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.orm import relationship
from datetime import datetime
from .database import Base
# Default statuses for new projects
DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
# Association table for task blocker relationships (many-to-many)
task_blockers = Table(
"task_blockers",
Base.metadata,
Column("task_id", Integer, ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True),
Column("blocked_by_id", Integer, ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True),
)
class Project(Base):
__tablename__ = "projects"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES)
is_archived = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan")
class Task(Base):
__tablename__ = "tasks"
id = Column(Integer, primary_key=True, index=True)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
parent_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True)
title = Column(String(500), nullable=False)
description = Column(Text, nullable=True)
status = Column(String(50), default="backlog", nullable=False)
sort_order = Column(Integer, default=0)
estimated_minutes = Column(Integer, nullable=True)
tags = Column(JSON, nullable=True)
flag_color = Column(String(50), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
project = relationship("Project", back_populates="tasks")
parent = relationship("Task", remote_side=[id], backref="subtasks")
# blockers: tasks that must be done before this task can start
# blocking: tasks that this task is holding up
blockers = relationship(
"Task",
secondary=task_blockers,
primaryjoin=lambda: Task.id == task_blockers.c.task_id,
secondaryjoin=lambda: Task.id == task_blockers.c.blocked_by_id,
backref="blocking",
)
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Boolean
from sqlalchemy.orm import relationship
from datetime import datetime
from .database import Base
# Default statuses for new projects
DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
class Project(Base):
__tablename__ = "projects"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES)
is_archived = Column(Boolean, default=False, nullable=False)
category = Column(String(100), nullable=True)
weekly_hours_goal = Column(Integer, nullable=True) # stored in minutes
total_hours_goal = Column(Integer, nullable=True) # stored in minutes
pomodoro_work_minutes = Column(Integer, nullable=True, default=25)
pomodoro_break_minutes = Column(Integer, nullable=True, default=5)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan")
class Task(Base):
__tablename__ = "tasks"
id = Column(Integer, primary_key=True, index=True)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
parent_task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True)
title = Column(String(500), nullable=False)
description = Column(Text, nullable=True)
status = Column(String(50), default="backlog", nullable=False)
sort_order = Column(Integer, default=0)
estimated_minutes = Column(Integer, nullable=True)
tags = Column(JSON, nullable=True)
flag_color = Column(String(50), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
project = relationship("Project", back_populates="tasks")
parent = relationship("Task", remote_side=[id], backref="subtasks")
time_logs = relationship("TimeLog", back_populates="task", cascade="all, delete-orphan")
class TimeLog(Base):
__tablename__ = "time_logs"
id = Column(Integer, primary_key=True, index=True)
task_id = Column(Integer, ForeignKey("tasks.id"), nullable=False)
minutes = Column(Integer, nullable=False)
note = Column(Text, nullable=True)
session_type = Column(String(50), default="manual") # 'pomodoro' | 'manual'
logged_at = Column(DateTime, default=datetime.utcnow)
task = relationship("Task", back_populates="time_logs")

View File

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

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 { Zap } from 'lucide-react'
import ProjectList from './pages/ProjectList'
import ProjectView from './pages/ProjectView'
import ActionableView from './pages/ActionableView'
import SearchBar from './components/SearchBar'
function App() {
const navigate = useNavigate()
const location = useLocation()
const isActionable = location.pathname === '/actionable'
return (
<div className="min-h-screen bg-cyber-dark">
<header className="border-b border-cyber-orange/30 bg-cyber-darkest">
<div className="container mx-auto px-4 py-4">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<h1
className="text-2xl font-bold text-cyber-orange cursor-pointer"
onClick={() => navigate('/')}
>
BIT
<span className="ml-3 text-sm text-gray-500">Break It Down</span>
<span className="ml-2 text-xs text-gray-600">v{import.meta.env.VITE_APP_VERSION || '0.1.6'}</span>
</h1>
<button
onClick={() => navigate(isActionable ? '/' : '/actionable')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded text-sm font-medium transition-all ${
isActionable
? 'bg-cyber-orange text-cyber-darkest'
: 'border border-cyber-orange/40 text-cyber-orange hover:bg-cyber-orange/10'
}`}
title="What can I do right now?"
>
<Zap size={14} />
Now
</button>
</div>
<SearchBar />
</div>
</div>
</header>
<main className="container mx-auto px-4 py-8">
<Routes>
<Route path="/" element={<ProjectList />} />
<Route path="/project/:projectId" element={<ProjectView />} />
<Route path="/actionable" element={<ActionableView />} />
</Routes>
</main>
</div>
)
}
export default App
import { Routes, Route } from 'react-router-dom'
import ProjectList from './pages/ProjectList'
import ProjectView from './pages/ProjectView'
import SearchBar from './components/SearchBar'
import { PomodoroProvider } from './context/PomodoroContext'
import PomodoroWidget from './components/PomodoroWidget'
function App() {
return (
<PomodoroProvider>
<div className="min-h-screen bg-cyber-dark">
<header className="border-b border-cyber-orange/30 bg-cyber-darkest">
<div className="container mx-auto px-4 py-4">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-cyber-orange">
BIT
<span className="ml-3 text-sm text-gray-500">Break It Down</span>
<span className="ml-2 text-xs text-gray-600">v{import.meta.env.VITE_APP_VERSION || '0.1.6'}</span>
</h1>
</div>
<SearchBar />
</div>
</div>
</header>
<main className="container mx-auto px-4 py-8">
<Routes>
<Route path="/" element={<ProjectList />} />
<Route path="/project/:projectId" element={<ProjectView />} />
</Routes>
</main>
<PomodoroWidget />
</div>
</PomodoroProvider>
)
}
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 { 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 {
getProjectTasks,
createTask,
@@ -9,6 +9,7 @@ import {
import { formatTimeWithTotal } from '../utils/format'
import TaskMenu from './TaskMenu'
import TaskForm from './TaskForm'
import { usePomodoro } from '../context/PomodoroContext'
// Helper to format status label
const formatStatusLabel = (status) => {
@@ -69,10 +70,12 @@ function hasDescendantsInStatus(taskId, allTasks, status) {
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 [editTitle, setEditTitle] = useState(task.title)
const [showAddSubtask, setShowAddSubtask] = useState(false)
const { startPomodoro, activeTask, phase } = usePomodoro()
const isActiveTimer = activeTask?.id === task.id && phase !== 'idle'
// Use global expanded state
const isExpanded = expandedCards[task.id] || false
@@ -234,6 +237,20 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
</div>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation()
startPomodoro(
{ id: task.id, title: task.title, estimatedMinutes: task.estimated_minutes },
pomodoroSettings?.workMinutes || 25,
pomodoroSettings?.breakMinutes || 5
)
}}
className={`p-1 transition-colors ${isActiveTimer ? 'text-cyber-orange' : 'text-gray-400 hover:text-cyber-orange'}`}
title={isActiveTimer ? 'Timer running' : 'Start Pomodoro'}
>
<Timer size={14} />
</button>
<button
onClick={() => setShowAddSubtask(true)}
className="text-cyber-orange hover:text-cyber-orange-bright p-1"
@@ -283,6 +300,7 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
setExpandedCards={setExpandedCards}
projectStatuses={projectStatuses}
projectId={projectId}
pomodoroSettings={pomodoroSettings}
/>
))}
</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 handleAddTask = async (taskData) => {
@@ -384,6 +402,7 @@ function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOve
setExpandedCards={setExpandedCards}
projectStatuses={projectStatuses}
projectId={projectId}
pomodoroSettings={pomodoroSettings}
/>
)
})}
@@ -400,6 +419,10 @@ function KanbanView({ projectId, project }) {
// Get statuses from project, or use defaults
const statuses = project?.statuses || ['backlog', 'in_progress', 'on_hold', 'done']
const pomodoroSettings = {
workMinutes: project?.pomodoro_work_minutes || 25,
breakMinutes: project?.pomodoro_break_minutes || 5,
}
const statusesWithMeta = statuses.map(status => ({
key: status,
label: formatStatusLabel(status),
@@ -507,6 +530,7 @@ function KanbanView({ projectId, project }) {
expandedCards={expandedCards}
setExpandedCards={setExpandedCards}
projectStatuses={statuses}
pomodoroSettings={pomodoroSettings}
/>
))}
</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 [deleteWarning, setDeleteWarning] = useState(null)
// Time & Goals fields
const [category, setCategory] = useState(project.category || '')
const [weeklyGoalHours, setWeeklyGoalHours] = useState(
project.weekly_hours_goal ? Math.floor(project.weekly_hours_goal / 60) : ''
)
const [weeklyGoalMins, setWeeklyGoalMins] = useState(
project.weekly_hours_goal ? project.weekly_hours_goal % 60 : ''
)
const [totalGoalHours, setTotalGoalHours] = useState(
project.total_hours_goal ? Math.floor(project.total_hours_goal / 60) : ''
)
const [totalGoalMins, setTotalGoalMins] = useState(
project.total_hours_goal ? project.total_hours_goal % 60 : ''
)
const [pomodoroWork, setPomodoroWork] = useState(project.pomodoro_work_minutes || 25)
const [pomodoroBreak, setPomodoroBreak] = useState(project.pomodoro_break_minutes || 5)
useEffect(() => {
loadTaskCounts()
}, [])
@@ -120,8 +137,20 @@ function ProjectSettings({ project, onClose, onUpdate }) {
return
}
const weeklyMinutes =
(parseInt(weeklyGoalHours) || 0) * 60 + (parseInt(weeklyGoalMins) || 0)
const totalMinutes =
(parseInt(totalGoalHours) || 0) * 60 + (parseInt(totalGoalMins) || 0)
try {
await updateProject(project.id, { statuses })
await updateProject(project.id, {
statuses,
category: category.trim() || null,
weekly_hours_goal: weeklyMinutes > 0 ? weeklyMinutes : null,
total_hours_goal: totalMinutes > 0 ? totalMinutes : null,
pomodoro_work_minutes: parseInt(pomodoroWork) || 25,
pomodoro_break_minutes: parseInt(pomodoroBreak) || 5,
})
onUpdate()
onClose()
} catch (err) {
@@ -247,6 +276,106 @@ function ProjectSettings({ project, onClose, onUpdate }) {
Add Status
</button>
</div>
{/* Time & Goals */}
<div className="mt-6 pt-6 border-t border-cyber-orange/20">
<h3 className="text-lg font-semibold text-gray-200 mb-4">Time & Goals</h3>
<div className="space-y-4">
{/* Category */}
<div>
<label className="block text-sm text-gray-400 mb-1">Category</label>
<input
type="text"
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="e.g. home, programming, hobby"
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
/>
</div>
{/* Weekly goal */}
<div>
<label className="block text-sm text-gray-400 mb-1">Weekly Time Goal</label>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
value={weeklyGoalHours}
onChange={(e) => setWeeklyGoalHours(e.target.value)}
placeholder="0"
className="w-20 px-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
/>
<span className="text-gray-500 text-sm">hr</span>
<input
type="number"
min="0"
max="59"
value={weeklyGoalMins}
onChange={(e) => setWeeklyGoalMins(e.target.value)}
placeholder="0"
className="w-20 px-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
/>
<span className="text-gray-500 text-sm">min / week</span>
</div>
</div>
{/* Total budget */}
<div>
<label className="block text-sm text-gray-400 mb-1">Total Time Budget</label>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
value={totalGoalHours}
onChange={(e) => setTotalGoalHours(e.target.value)}
placeholder="0"
className="w-20 px-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
/>
<span className="text-gray-500 text-sm">hr</span>
<input
type="number"
min="0"
max="59"
value={totalGoalMins}
onChange={(e) => setTotalGoalMins(e.target.value)}
placeholder="0"
className="w-20 px-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
/>
<span className="text-gray-500 text-sm">min total</span>
</div>
</div>
{/* Pomodoro settings */}
<div>
<label className="block text-sm text-gray-400 mb-1">Pomodoro Settings</label>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<input
type="number"
min="1"
max="120"
value={pomodoroWork}
onChange={(e) => setPomodoroWork(e.target.value)}
className="w-16 px-2 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange text-center"
/>
<span className="text-gray-500 text-sm">min work</span>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
max="60"
value={pomodoroBreak}
onChange={(e) => setPomodoroBreak(e.target.value)}
className="w-16 px-2 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange text-center"
/>
<span className="text-gray-500 text-sm">min break</span>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Footer */}

View File

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

View File

@@ -6,7 +6,8 @@ import {
Check,
X,
Flag,
Clock
Clock,
Timer
} from 'lucide-react'
import {
getProjectTaskTree,
@@ -17,6 +18,7 @@ import {
import { formatTimeWithTotal } from '../utils/format'
import TaskMenu from './TaskMenu'
import TaskForm from './TaskForm'
import { usePomodoro } from '../context/PomodoroContext'
// Helper to format status label
const formatStatusLabel = (status) => {
@@ -44,12 +46,14 @@ const FLAG_COLORS = {
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 [isEditing, setIsEditing] = useState(false)
const [editTitle, setEditTitle] = useState(task.title)
const [editStatus, setEditStatus] = useState(task.status)
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
@@ -200,6 +204,20 @@ function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses }) {
{/* Actions */}
<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
onClick={() => setShowAddSubtask(true)}
className="text-cyber-orange hover:text-cyber-orange-bright"
@@ -242,6 +260,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses }) {
onUpdate={onUpdate}
level={level + 1}
projectStatuses={projectStatuses}
pomodoroSettings={pomodoroSettings}
/>
))}
</div>
@@ -252,6 +271,10 @@ function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses }) {
function TreeView({ projectId, project }) {
const projectStatuses = project?.statuses || ['backlog', 'in_progress', 'on_hold', 'done']
const pomodoroSettings = {
workMinutes: project?.pomodoro_work_minutes || 25,
breakMinutes: project?.pomodoro_break_minutes || 5,
}
const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@@ -338,6 +361,7 @@ function TreeView({ projectId, project }) {
projectId={projectId}
onUpdate={loadTasks}
projectStatuses={projectStatuses}
pomodoroSettings={pomodoroSettings}
/>
))}
</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 { useNavigate } from 'react-router-dom'
import { Plus, Upload, Trash2, Archive, ArchiveRestore } from 'lucide-react'
import { getProjects, createProject, deleteProject, importJSON, archiveProject, unarchiveProject } from '../utils/api'
import { Plus, Upload, Trash2, Archive, ArchiveRestore, Clock } from 'lucide-react'
import { getProjects, createProject, deleteProject, importJSON, archiveProject, unarchiveProject, getProjectTimeSummary } from '../utils/api'
import { formatTime } from '../utils/format'
function ProjectList() {
const [projects, setProjects] = useState([])
const [timeSummaries, setTimeSummaries] = useState({})
const [loading, setLoading] = useState(true)
const [showCreateModal, setShowCreateModal] = useState(false)
const [showImportModal, setShowImportModal] = useState(false)
@@ -13,6 +15,7 @@ function ProjectList() {
const [importJSON_Text, setImportJSONText] = useState('')
const [error, setError] = useState('')
const [activeTab, setActiveTab] = useState('active') // 'active', 'archived', 'all'
const [activeCategory, setActiveCategory] = useState(null)
const navigate = useNavigate()
useEffect(() => {
@@ -25,10 +28,22 @@ function ProjectList() {
let archivedFilter = null
if (activeTab === 'active') archivedFilter = false
if (activeTab === 'archived') archivedFilter = true
// 'all' tab uses null to get all projects
const data = await getProjects(archivedFilter)
setProjects(data)
// 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) {
setError(err.message)
} finally {
@@ -125,6 +140,39 @@ function ProjectList() {
</div>
</div>
{/* Category filter */}
{(() => {
const categories = [...new Set(projects.map(p => p.category).filter(Boolean))].sort()
if (categories.length === 0) return null
return (
<div className="flex gap-2 flex-wrap mb-4">
<button
onClick={() => setActiveCategory(null)}
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
activeCategory === null
? 'bg-cyber-orange/20 border-cyber-orange text-cyber-orange'
: 'border-cyber-orange/30 text-gray-400 hover:border-cyber-orange/60'
}`}
>
All
</button>
{categories.map(cat => (
<button
key={cat}
onClick={() => setActiveCategory(activeCategory === cat ? null : cat)}
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
activeCategory === cat
? 'bg-cyber-orange/20 border-cyber-orange text-cyber-orange'
: 'border-cyber-orange/30 text-gray-400 hover:border-cyber-orange/60'
}`}
>
{cat}
</button>
))}
</div>
)
})()}
{/* Tabs */}
<div className="flex gap-1 mb-6 border-b border-cyber-orange/20">
<button
@@ -165,71 +213,142 @@ function ProjectList() {
</div>
)}
{projects.length === 0 ? (
<div className="text-center py-16 text-gray-500">
<p className="text-xl mb-2">
{activeTab === 'archived' ? 'No archived projects' : 'No projects yet'}
</p>
<p className="text-sm">
{activeTab === 'archived'
? 'Archive projects to keep them out of your active workspace'
: 'Create a new project or import from JSON'}
</p>
</div>
) : (
{(() => {
const visibleProjects = activeCategory
? projects.filter(p => p.category === activeCategory)
: projects
if (visibleProjects.length === 0) return (
<div className="text-center py-16 text-gray-500">
<p className="text-xl mb-2">
{activeTab === 'archived' ? 'No archived projects' : 'No projects yet'}
</p>
<p className="text-sm">
{activeTab === 'archived'
? 'Archive projects to keep them out of your active workspace'
: 'Create a new project or import from JSON'}
</p>
</div>
)
return null
})()}
{projects.length > 0 && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projects.map(project => (
<div
key={project.id}
onClick={() => navigate(`/project/${project.id}`)}
className={`p-6 bg-cyber-darkest border rounded-lg hover:border-cyber-orange hover:shadow-cyber transition-all cursor-pointer group ${
project.is_archived
? 'border-gray-700 opacity-75'
: 'border-cyber-orange/30'
}`}
>
<div className="flex justify-between items-start mb-2">
<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 ? (
{(activeCategory ? projects.filter(p => p.category === activeCategory) : projects).map(project => {
const summary = timeSummaries[project.id]
const weeklyPct = summary && summary.weekly_hours_goal
? Math.min(100, Math.round((summary.weekly_minutes / summary.weekly_hours_goal) * 100))
: null
const totalPct = summary && summary.total_hours_goal
? Math.min(100, Math.round((summary.total_minutes / summary.total_hours_goal) * 100))
: null
return (
<div
key={project.id}
onClick={() => navigate(`/project/${project.id}`)}
className={`p-6 bg-cyber-darkest border rounded-lg hover:border-cyber-orange hover:shadow-cyber transition-all cursor-pointer group ${
project.is_archived
? 'border-gray-700 opacity-75'
: 'border-cyber-orange/30'
}`}
>
<div className="flex justify-between items-start mb-2">
<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
onClick={(e) => handleUnarchiveProject(project.id, e)}
className="text-gray-600 hover:text-cyber-orange transition-colors"
title="Unarchive project"
onClick={(e) => handleDeleteProject(project.id, e)}
className="text-gray-600 hover:text-red-400 transition-colors"
title="Delete project"
>
<ArchiveRestore size={18} />
<Trash2 size={18} />
</button>
) : (
<button
onClick={(e) => handleArchiveProject(project.id, e)}
className="text-gray-600 hover:text-yellow-400 transition-colors"
title="Archive project"
>
<Archive size={18} />
</button>
)}
<button
onClick={(e) => handleDeleteProject(project.id, e)}
className="text-gray-600 hover:text-red-400 transition-colors"
title="Delete project"
>
<Trash2 size={18} />
</button>
</div>
</div>
{/* 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>
{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>
)}

View File

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