Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5da6e075b4 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -41,7 +41,3 @@ Thumbs.db
|
|||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|
||||||
#dev stuff
|
|
||||||
/.claude/
|
|
||||||
/.vscode/
|
|
||||||
@@ -1,244 +0,0 @@
|
|||||||
# Break It Down (BIT)
|
|
||||||
|
|
||||||
# SYSTEM SPECIFICATION
|
|
||||||
|
|
||||||
## Version: v0.2.0
|
|
||||||
|
|
||||||
## Feature Theme: Campaign Mode + Entropy Engine + Project Wizard
|
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# 1. Overview
|
|
||||||
|
|
||||||
v0.2.0 introduces **Campaign Mode**, an operations layer on top of
|
|
||||||
Break-It-Down's planning engine.
|
|
||||||
|
|
||||||
BIT remains the HQ planning system (tree + kanban). Campaign Mode is the
|
|
||||||
execution layer focused on:
|
|
||||||
|
|
||||||
- Low-friction session starts
|
|
||||||
- Visual progress (photo proof)
|
|
||||||
- Entropy-based decay for recurring physical spaces
|
|
||||||
- Gentle nudges to trigger action
|
|
||||||
- A fast project-building Wizard
|
|
||||||
|
|
||||||
The goal is to reduce startup friction and help users act without
|
|
||||||
overthinking.
|
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# 2. Core Architectural Principles
|
|
||||||
|
|
||||||
1. Existing planning functionality must remain unchanged for standard
|
|
||||||
projects.
|
|
||||||
2. Campaign functionality activates only when
|
|
||||||
`project_mode == "entropy_space"`.
|
|
||||||
3. Planning (HQ) and Execution (Campaign) are separate UI routes.
|
|
||||||
4. Session start must require no pre-forms.
|
|
||||||
5. Entropy simulation must feel like physics, not guilt.
|
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# 3. New Core Concepts
|
|
||||||
|
|
||||||
## 3.1 Project Modes
|
|
||||||
|
|
||||||
Add enum to projects:
|
|
||||||
|
|
||||||
- `standard` (default, existing behavior)
|
|
||||||
- `entropy_space` (physical space / territory model)
|
|
||||||
|
|
||||||
Future modes may include: - `pipeline_chores` - `deep_work`
|
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
|
||||||
|
|
||||||
## 3.2 Campaign Types
|
|
||||||
|
|
||||||
Only meaningful when `project_mode == "entropy_space"`:
|
|
||||||
|
|
||||||
- `finite` --- one-off liberation (garage purge)
|
|
||||||
- `background` --- ongoing maintenance (bathroom, kitchen)
|
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
|
||||||
|
|
||||||
## 3.3 Zones (v1 Implementation)
|
|
||||||
|
|
||||||
In `entropy_space` projects:
|
|
||||||
|
|
||||||
- Top-level tasks (`parent_task_id IS NULL`)
|
|
||||||
- `is_zone = true`
|
|
||||||
|
|
||||||
No separate zones table in v0.2.0.
|
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
|
||||||
|
|
||||||
## 3.4 Work Sessions
|
|
||||||
|
|
||||||
Generic sessions stored in backend. Campaign UI refers to them as
|
|
||||||
"Strikes".
|
|
||||||
|
|
||||||
Session kinds:
|
|
||||||
|
|
||||||
- `strike`
|
|
||||||
- `pomodoro` (future)
|
|
||||||
- `run` (future)
|
|
||||||
- `freeform`
|
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# 4. Database Schema Changes
|
|
||||||
|
|
||||||
## 4.1 Projects Table Additions
|
|
||||||
|
|
||||||
project_mode TEXT NOT NULL DEFAULT 'standard'
|
|
||||||
campaign_type TEXT NULL
|
|
||||||
campaign_active BOOLEAN NOT NULL DEFAULT 0
|
|
||||||
|
|
||||||
nudge_enabled BOOLEAN NOT NULL DEFAULT 0
|
|
||||||
nudge_window_start TEXT NULL
|
|
||||||
nudge_window_end TEXT NULL
|
|
||||||
nudge_min_interval_minutes INTEGER NULL
|
|
||||||
nudge_max_per_day INTEGER NULL
|
|
||||||
|
|
||||||
photo_proof_enabled BOOLEAN NOT NULL DEFAULT 0
|
|
||||||
default_session_kind TEXT NOT NULL DEFAULT 'freeform'
|
|
||||||
default_session_minutes INTEGER NOT NULL DEFAULT 12
|
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
|
||||||
|
|
||||||
## 4.2 Tasks Table Additions (Zones Only)
|
|
||||||
|
|
||||||
is_zone BOOLEAN NOT NULL DEFAULT 0
|
|
||||||
|
|
||||||
stability_base INTEGER NULL
|
|
||||||
decay_rate_per_day REAL NULL
|
|
||||||
last_stability_update_at DATETIME NULL
|
|
||||||
last_strike_at DATETIME NULL
|
|
||||||
|
|
||||||
zone_preset TEXT NULL
|
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
|
||||||
|
|
||||||
## 4.3 New Table: work_sessions
|
|
||||||
|
|
||||||
id INTEGER PRIMARY KEY
|
|
||||||
project_id INTEGER NOT NULL
|
|
||||||
task_id INTEGER NULL
|
|
||||||
kind TEXT NOT NULL
|
|
||||||
started_at DATETIME NOT NULL
|
|
||||||
ended_at DATETIME NULL
|
|
||||||
duration_seconds INTEGER NULL
|
|
||||||
note TEXT NULL
|
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
|
||||||
|
|
||||||
## 4.4 New Table: session_photos
|
|
||||||
|
|
||||||
id INTEGER PRIMARY KEY
|
|
||||||
session_id INTEGER NOT NULL
|
|
||||||
phase TEXT NOT NULL -- 'before' or 'after'
|
|
||||||
path TEXT NOT NULL
|
|
||||||
taken_at DATETIME NOT NULL
|
|
||||||
|
|
||||||
Images stored in Docker volume under:
|
|
||||||
|
|
||||||
/data/photos/{project_id}/{session_id}/...
|
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# 5. Entropy Engine (v1)
|
|
||||||
|
|
||||||
## 5.1 Stability Model
|
|
||||||
|
|
||||||
Fields used: - stability_base - last_stability_update_at -
|
|
||||||
decay_rate_per_day
|
|
||||||
|
|
||||||
Derived:
|
|
||||||
|
|
||||||
days_elapsed = (now - last_stability_update_at)
|
|
||||||
stability_now = max(0, stability_base - decay_rate_per_day * days_elapsed)
|
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
|
||||||
|
|
||||||
## 5.2 Strike Effect
|
|
||||||
|
|
||||||
On strike completion:
|
|
||||||
|
|
||||||
Option A (fixed):
|
|
||||||
|
|
||||||
boost = 20
|
|
||||||
|
|
||||||
Option B (duration-based):
|
|
||||||
|
|
||||||
boost = clamp(10, 35, round(duration_minutes * 1.5))
|
|
||||||
|
|
||||||
Update:
|
|
||||||
|
|
||||||
stability_base = min(100, stability_now + boost)
|
|
||||||
last_stability_update_at = now
|
|
||||||
last_strike_at = now
|
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
|
||||||
|
|
||||||
## 5.3 Stability Color Mapping
|
|
||||||
|
|
||||||
- 80--100 → green
|
|
||||||
- 55--79 → yellow
|
|
||||||
- 30--54 → orange
|
|
||||||
- 0--29 → red
|
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# 6. API Additions
|
|
||||||
|
|
||||||
## Campaign
|
|
||||||
|
|
||||||
POST /api/projects/{id}/launch_campaign
|
|
||||||
GET /api/projects/{id}/zones
|
|
||||||
|
|
||||||
## Sessions
|
|
||||||
|
|
||||||
POST /api/sessions/start
|
|
||||||
POST /api/sessions/{id}/stop
|
|
||||||
GET /api/projects/{id}/sessions
|
|
||||||
|
|
||||||
## Photos
|
|
||||||
|
|
||||||
POST /api/sessions/{id}/photos
|
|
||||||
GET /api/sessions/{id}/photos
|
|
||||||
|
|
||||||
## Wizard
|
|
||||||
|
|
||||||
POST /api/wizard/build_project
|
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# 7. Frontend Routes
|
|
||||||
|
|
||||||
/projects
|
|
||||||
/projects/:id
|
|
||||||
/projects/:id/campaign
|
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# 8. Build Phases
|
|
||||||
|
|
||||||
Phase 0: Core campaign + stability\
|
|
||||||
Phase 1: Photo proof + compare slider\
|
|
||||||
Phase 2: Nudges\
|
|
||||||
Phase 3: Hex grid expansion
|
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# 9. Success Criteria
|
|
||||||
|
|
||||||
- Wizard creates entropy project in \<30 seconds\
|
|
||||||
- Strike starts in 1 tap\
|
|
||||||
- Stability increases after strike\
|
|
||||||
- Stability decreases over time\
|
|
||||||
- No regression in standard projects
|
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
|
||||||
|
|
||||||
**Break It Down v0.2.0**\
|
|
||||||
Planning in HQ. Liberation on the front lines.
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
from sqlalchemy import func
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from . import models, schemas
|
from . import models, schemas
|
||||||
|
|
||||||
|
|
||||||
@@ -190,64 +188,96 @@ def get_tasks_by_status(db: Session, project_id: int, status: str) -> List[model
|
|||||||
).all()
|
).all()
|
||||||
|
|
||||||
|
|
||||||
# TimeLog CRUD
|
# ========== BLOCKER CRUD ==========
|
||||||
def create_time_log(db: Session, task_id: int, time_log: schemas.TimeLogCreate) -> models.TimeLog:
|
|
||||||
db_log = models.TimeLog(
|
def _has_cycle(db: Session, start_id: int, target_id: int) -> bool:
|
||||||
task_id=task_id,
|
"""BFS from start_id following its blockers. Returns True if target_id is reachable,
|
||||||
minutes=time_log.minutes,
|
which would mean adding target_id as a blocker of start_id creates a cycle."""
|
||||||
note=time_log.note,
|
visited = set()
|
||||||
session_type=time_log.session_type,
|
queue = [start_id]
|
||||||
)
|
while queue:
|
||||||
db.add(db_log)
|
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.commit()
|
||||||
db.refresh(db_log)
|
db.refresh(task)
|
||||||
return db_log
|
return task
|
||||||
|
|
||||||
|
|
||||||
def get_time_logs_by_task(db: Session, task_id: int) -> List[models.TimeLog]:
|
def remove_blocker(db: Session, task_id: int, blocker_id: int) -> bool:
|
||||||
return db.query(models.TimeLog).filter(
|
"""Remove blocker_id as a prerequisite of task_id."""
|
||||||
models.TimeLog.task_id == task_id
|
task = get_task(db, task_id)
|
||||||
).order_by(models.TimeLog.logged_at.desc()).all()
|
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_project_time_summary(db: Session, project_id: int) -> dict:
|
def get_task_with_blockers(db: Session, task_id: int) -> Optional[models.Task]:
|
||||||
"""Aggregate time logged across all tasks in a project"""
|
"""Get a task including its blockers and blocking lists."""
|
||||||
project = get_project(db, project_id)
|
return db.query(models.Task).filter(models.Task.id == task_id).first()
|
||||||
|
|
||||||
# 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
|
def get_actionable_tasks(db: Session) -> List[dict]:
|
||||||
total = db.query(func.sum(models.TimeLog.minutes)).filter(
|
"""Return all non-done tasks that have no incomplete blockers, with project name."""
|
||||||
models.TimeLog.task_id.in_(task_ids)
|
tasks = db.query(models.Task).filter(
|
||||||
).scalar() or 0
|
models.Task.status != "done"
|
||||||
|
).all()
|
||||||
|
|
||||||
# Pomodoro minutes
|
result = []
|
||||||
pomodoro = db.query(func.sum(models.TimeLog.minutes)).filter(
|
for task in tasks:
|
||||||
models.TimeLog.task_id.in_(task_ids),
|
incomplete_blockers = [b for b in task.blockers if b.status != "done"]
|
||||||
models.TimeLog.session_type == "pomodoro"
|
if not incomplete_blockers:
|
||||||
).scalar() or 0
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
# Manual minutes
|
return result
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -159,30 +159,48 @@ def delete_task(task_id: int, db: Session = Depends(get_db)):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ========== TIME LOG ENDPOINTS ==========
|
# ========== BLOCKER ENDPOINTS ==========
|
||||||
|
|
||||||
@app.post("/api/tasks/{task_id}/time-logs", response_model=schemas.TimeLog, status_code=201)
|
@app.get("/api/tasks/{task_id}/blockers", response_model=List[schemas.BlockerInfo])
|
||||||
def log_time(task_id: int, time_log: schemas.TimeLogCreate, db: Session = Depends(get_db)):
|
def get_task_blockers(task_id: int, db: Session = Depends(get_db)):
|
||||||
"""Log time spent on a task"""
|
"""Get all tasks that are blocking a given task."""
|
||||||
if not crud.get_task(db, task_id):
|
task = crud.get_task_with_blockers(db, task_id)
|
||||||
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
return crud.create_time_log(db, task_id, time_log)
|
return task.blockers
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/tasks/{task_id}/time-logs", response_model=List[schemas.TimeLog])
|
@app.get("/api/tasks/{task_id}/blocking", response_model=List[schemas.BlockerInfo])
|
||||||
def get_time_logs(task_id: int, db: Session = Depends(get_db)):
|
def get_tasks_this_blocks(task_id: int, db: Session = Depends(get_db)):
|
||||||
"""Get all time logs for a task"""
|
"""Get all tasks that this task is currently blocking."""
|
||||||
if not crud.get_task(db, task_id):
|
task = crud.get_task_with_blockers(db, task_id)
|
||||||
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="Task not found")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
return crud.get_time_logs_by_task(db, task_id)
|
return task.blocking
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/projects/{project_id}/time-summary", response_model=schemas.ProjectTimeSummary)
|
@app.post("/api/tasks/{task_id}/blockers/{blocker_id}", status_code=201)
|
||||||
def get_project_time_summary(project_id: int, db: Session = Depends(get_db)):
|
def add_blocker(task_id: int, blocker_id: int, db: Session = Depends(get_db)):
|
||||||
"""Get aggregated time statistics for a project"""
|
"""Add blocker_id as a prerequisite of task_id."""
|
||||||
if not crud.get_project(db, project_id):
|
try:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
crud.add_blocker(db, task_id, blocker_id)
|
||||||
return crud.get_project_time_summary(db, project_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 ==========
|
# ========== SEARCH ENDPOINT ==========
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Boolean
|
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Boolean, Table
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from .database import Base
|
from .database import Base
|
||||||
@@ -8,6 +8,15 @@ from .database import Base
|
|||||||
DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
|
DEFAULT_STATUSES = ["backlog", "in_progress", "on_hold", "done"]
|
||||||
|
|
||||||
|
|
||||||
|
# Association table for task blocker relationships (many-to-many)
|
||||||
|
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):
|
class Project(Base):
|
||||||
__tablename__ = "projects"
|
__tablename__ = "projects"
|
||||||
|
|
||||||
@@ -16,11 +25,6 @@ class Project(Base):
|
|||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES)
|
statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES)
|
||||||
is_archived = Column(Boolean, default=False, nullable=False)
|
is_archived = Column(Boolean, default=False, nullable=False)
|
||||||
category = Column(String(100), nullable=True)
|
|
||||||
weekly_hours_goal = Column(Integer, nullable=True) # stored in minutes
|
|
||||||
total_hours_goal = Column(Integer, nullable=True) # stored in minutes
|
|
||||||
pomodoro_work_minutes = Column(Integer, nullable=True, default=25)
|
|
||||||
pomodoro_break_minutes = Column(Integer, nullable=True, default=5)
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
@@ -45,17 +49,13 @@ class Task(Base):
|
|||||||
|
|
||||||
project = relationship("Project", back_populates="tasks")
|
project = relationship("Project", back_populates="tasks")
|
||||||
parent = relationship("Task", remote_side=[id], backref="subtasks")
|
parent = relationship("Task", remote_side=[id], backref="subtasks")
|
||||||
time_logs = relationship("TimeLog", back_populates="task", cascade="all, delete-orphan")
|
|
||||||
|
|
||||||
|
# blockers: tasks that must be done before this task can start
|
||||||
class TimeLog(Base):
|
# blocking: tasks that this task is holding up
|
||||||
__tablename__ = "time_logs"
|
blockers = relationship(
|
||||||
|
"Task",
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
secondary=task_blockers,
|
||||||
task_id = Column(Integer, ForeignKey("tasks.id"), nullable=False)
|
primaryjoin=lambda: Task.id == task_blockers.c.task_id,
|
||||||
minutes = Column(Integer, nullable=False)
|
secondaryjoin=lambda: Task.id == task_blockers.c.blocked_by_id,
|
||||||
note = Column(Text, nullable=True)
|
backref="blocking",
|
||||||
session_type = Column(String(50), default="manual") # 'pomodoro' | 'manual'
|
)
|
||||||
logged_at = Column(DateTime, default=datetime.utcnow)
|
|
||||||
|
|
||||||
task = relationship("Task", back_populates="time_logs")
|
|
||||||
|
|||||||
@@ -46,6 +46,37 @@ class TaskWithSubtasks(Task):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
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
|
# Project Schemas
|
||||||
class ProjectBase(BaseModel):
|
class ProjectBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
@@ -54,11 +85,6 @@ class ProjectBase(BaseModel):
|
|||||||
|
|
||||||
class ProjectCreate(ProjectBase):
|
class ProjectCreate(ProjectBase):
|
||||||
statuses: Optional[List[str]] = None
|
statuses: Optional[List[str]] = None
|
||||||
category: Optional[str] = None
|
|
||||||
weekly_hours_goal: Optional[int] = None
|
|
||||||
total_hours_goal: Optional[int] = None
|
|
||||||
pomodoro_work_minutes: Optional[int] = None
|
|
||||||
pomodoro_break_minutes: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdate(BaseModel):
|
class ProjectUpdate(BaseModel):
|
||||||
@@ -66,22 +92,12 @@ class ProjectUpdate(BaseModel):
|
|||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
statuses: Optional[List[str]] = None
|
statuses: Optional[List[str]] = None
|
||||||
is_archived: Optional[bool] = None
|
is_archived: Optional[bool] = None
|
||||||
category: Optional[str] = None
|
|
||||||
weekly_hours_goal: Optional[int] = None
|
|
||||||
total_hours_goal: Optional[int] = None
|
|
||||||
pomodoro_work_minutes: Optional[int] = None
|
|
||||||
pomodoro_break_minutes: Optional[int] = None
|
|
||||||
|
|
||||||
|
|
||||||
class Project(ProjectBase):
|
class Project(ProjectBase):
|
||||||
id: int
|
id: int
|
||||||
statuses: List[str]
|
statuses: List[str]
|
||||||
is_archived: bool
|
is_archived: bool
|
||||||
category: Optional[str] = None
|
|
||||||
weekly_hours_goal: Optional[int] = None
|
|
||||||
total_hours_goal: Optional[int] = None
|
|
||||||
pomodoro_work_minutes: Optional[int] = None
|
|
||||||
pomodoro_break_minutes: Optional[int] = None
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -120,30 +136,3 @@ class ImportResult(BaseModel):
|
|||||||
project_id: int
|
project_id: int
|
||||||
project_name: str
|
project_name: str
|
||||||
tasks_created: int
|
tasks_created: int
|
||||||
|
|
||||||
|
|
||||||
# TimeLog Schemas
|
|
||||||
class TimeLogCreate(BaseModel):
|
|
||||||
minutes: int
|
|
||||||
note: Optional[str] = None
|
|
||||||
session_type: str = "manual" # 'pomodoro' | 'manual'
|
|
||||||
|
|
||||||
|
|
||||||
class TimeLog(BaseModel):
|
|
||||||
id: int
|
|
||||||
task_id: int
|
|
||||||
minutes: int
|
|
||||||
note: Optional[str] = None
|
|
||||||
session_type: str
|
|
||||||
logged_at: datetime
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectTimeSummary(BaseModel):
|
|
||||||
total_minutes: int
|
|
||||||
pomodoro_minutes: int
|
|
||||||
manual_minutes: int
|
|
||||||
weekly_minutes: int
|
|
||||||
weekly_hours_goal: Optional[int] = None
|
|
||||||
total_hours_goal: Optional[int] = None
|
|
||||||
|
|||||||
40
backend/migrate_add_blockers.py
Normal file
40
backend/migrate_add_blockers.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""
|
||||||
|
Migration script to add the task_blockers association table.
|
||||||
|
Run this once if you have an existing database.
|
||||||
|
|
||||||
|
Usage (from inside the backend container or with the venv active):
|
||||||
|
python migrate_add_blockers.py
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
db_path = os.path.join(os.path.dirname(__file__), 'bit.db')
|
||||||
|
|
||||||
|
if not os.path.exists(db_path):
|
||||||
|
print(f"Database not found at {db_path}")
|
||||||
|
print("No migration needed — new database will be created with the correct schema.")
|
||||||
|
exit(0)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if the table already exists
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='task_blockers'")
|
||||||
|
if cursor.fetchone():
|
||||||
|
print("Table 'task_blockers' already exists. Migration not needed.")
|
||||||
|
else:
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE task_blockers (
|
||||||
|
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
blocked_by_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (task_id, blocked_by_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
print("Successfully created 'task_blockers' table.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during migration: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
"""
|
|
||||||
Migration script to add time goals, category, and pomodoro settings to projects table.
|
|
||||||
Run this once if you have an existing database.
|
|
||||||
"""
|
|
||||||
import sqlite3
|
|
||||||
import os
|
|
||||||
|
|
||||||
db_path = os.path.join(os.path.dirname(__file__), 'bit.db')
|
|
||||||
|
|
||||||
if not os.path.exists(db_path):
|
|
||||||
print("No migration needed - new database will be created with the correct schema.")
|
|
||||||
exit(0)
|
|
||||||
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
try:
|
|
||||||
cursor.execute("PRAGMA table_info(projects)")
|
|
||||||
columns = [column[1] for column in cursor.fetchall()]
|
|
||||||
|
|
||||||
new_columns = [
|
|
||||||
("category", "TEXT DEFAULT NULL"),
|
|
||||||
("weekly_hours_goal", "INTEGER DEFAULT NULL"),
|
|
||||||
("total_hours_goal", "INTEGER DEFAULT NULL"),
|
|
||||||
("pomodoro_work_minutes", "INTEGER DEFAULT 25"),
|
|
||||||
("pomodoro_break_minutes", "INTEGER DEFAULT 5"),
|
|
||||||
]
|
|
||||||
|
|
||||||
for col_name, col_def in new_columns:
|
|
||||||
if col_name in columns:
|
|
||||||
print(f"Column '{col_name}' already exists. Skipping.")
|
|
||||||
else:
|
|
||||||
cursor.execute(f"ALTER TABLE projects ADD COLUMN {col_name} {col_def}")
|
|
||||||
print(f"Successfully added '{col_name}'.")
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
print("Migration complete.")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error during migration: {e}")
|
|
||||||
conn.rollback()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
"""
|
|
||||||
Migration script to create the time_logs table.
|
|
||||||
Run this once if you have an existing database.
|
|
||||||
"""
|
|
||||||
import sqlite3
|
|
||||||
import os
|
|
||||||
|
|
||||||
db_path = os.path.join(os.path.dirname(__file__), 'bit.db')
|
|
||||||
|
|
||||||
if not os.path.exists(db_path):
|
|
||||||
print("No migration needed - new database will be created with the correct schema.")
|
|
||||||
exit(0)
|
|
||||||
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
try:
|
|
||||||
cursor.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS time_logs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
|
||||||
minutes INTEGER NOT NULL,
|
|
||||||
note TEXT,
|
|
||||||
session_type TEXT DEFAULT 'manual',
|
|
||||||
logged_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
conn.commit()
|
|
||||||
print("Successfully created 'time_logs' table (or it already existed).")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error during migration: {e}")
|
|
||||||
conn.rollback()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
@@ -1,39 +1,55 @@
|
|||||||
import { Routes, Route } from 'react-router-dom'
|
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import { Zap } from 'lucide-react'
|
||||||
import ProjectList from './pages/ProjectList'
|
import ProjectList from './pages/ProjectList'
|
||||||
import ProjectView from './pages/ProjectView'
|
import ProjectView from './pages/ProjectView'
|
||||||
|
import ActionableView from './pages/ActionableView'
|
||||||
import SearchBar from './components/SearchBar'
|
import SearchBar from './components/SearchBar'
|
||||||
import { PomodoroProvider } from './context/PomodoroContext'
|
|
||||||
import PomodoroWidget from './components/PomodoroWidget'
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const isActionable = location.pathname === '/actionable'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PomodoroProvider>
|
<div className="min-h-screen bg-cyber-dark">
|
||||||
<div className="min-h-screen bg-cyber-dark">
|
<header className="border-b border-cyber-orange/30 bg-cyber-darkest">
|
||||||
<header className="border-b border-cyber-orange/30 bg-cyber-darkest">
|
<div className="container mx-auto px-4 py-4">
|
||||||
<div className="container mx-auto px-4 py-4">
|
<div className="flex justify-between items-center">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex items-center gap-4">
|
||||||
<div>
|
<h1
|
||||||
<h1 className="text-2xl font-bold text-cyber-orange">
|
className="text-2xl font-bold text-cyber-orange cursor-pointer"
|
||||||
BIT
|
onClick={() => navigate('/')}
|
||||||
<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>
|
BIT
|
||||||
</h1>
|
<span className="ml-3 text-sm text-gray-500">Break It Down</span>
|
||||||
</div>
|
<span className="ml-2 text-xs text-gray-600">v{import.meta.env.VITE_APP_VERSION || '0.1.6'}</span>
|
||||||
<SearchBar />
|
</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>
|
</div>
|
||||||
|
<SearchBar />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<main className="container mx-auto px-4 py-8">
|
<main className="container mx-auto px-4 py-8">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<ProjectList />} />
|
<Route path="/" element={<ProjectList />} />
|
||||||
<Route path="/project/:projectId" element={<ProjectView />} />
|
<Route path="/project/:projectId" element={<ProjectView />} />
|
||||||
</Routes>
|
<Route path="/actionable" element={<ActionableView />} />
|
||||||
</main>
|
</Routes>
|
||||||
|
</main>
|
||||||
<PomodoroWidget />
|
</div>
|
||||||
</div>
|
|
||||||
</PomodoroProvider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
201
frontend/src/components/BlockerPanel.jsx
Normal file
201
frontend/src/components/BlockerPanel.jsx
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { X, Search, Lock, Unlock, AlertTriangle } from 'lucide-react'
|
||||||
|
import { getTaskBlockers, addBlocker, removeBlocker, searchTasks } from '../utils/api'
|
||||||
|
|
||||||
|
function BlockerPanel({ task, onClose, onUpdate }) {
|
||||||
|
const [blockers, setBlockers] = useState([])
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [searchResults, setSearchResults] = useState([])
|
||||||
|
const [searching, setSearching] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const searchTimeout = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadBlockers()
|
||||||
|
}, [task.id])
|
||||||
|
|
||||||
|
const loadBlockers = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const data = await getTaskBlockers(task.id)
|
||||||
|
setBlockers(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = (query) => {
|
||||||
|
setSearchQuery(query)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
if (searchTimeout.current) clearTimeout(searchTimeout.current)
|
||||||
|
|
||||||
|
if (!query.trim()) {
|
||||||
|
setSearchResults([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTimeout.current = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
setSearching(true)
|
||||||
|
const results = await searchTasks(query)
|
||||||
|
// Filter out the current task and tasks already blocking this one
|
||||||
|
const blockerIds = new Set(blockers.map(b => b.id))
|
||||||
|
const filtered = results.filter(t => t.id !== task.id && !blockerIds.has(t.id))
|
||||||
|
setSearchResults(filtered.slice(0, 8))
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setSearching(false)
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddBlocker = async (blocker) => {
|
||||||
|
try {
|
||||||
|
setError('')
|
||||||
|
await addBlocker(task.id, blocker.id)
|
||||||
|
setBlockers(prev => [...prev, blocker])
|
||||||
|
setSearchResults(prev => prev.filter(t => t.id !== blocker.id))
|
||||||
|
setSearchQuery('')
|
||||||
|
setSearchResults([])
|
||||||
|
onUpdate()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveBlocker = async (blockerId) => {
|
||||||
|
try {
|
||||||
|
setError('')
|
||||||
|
await removeBlocker(task.id, blockerId)
|
||||||
|
setBlockers(prev => prev.filter(b => b.id !== blockerId))
|
||||||
|
onUpdate()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const incompleteBlockers = blockers.filter(b => b.status !== 'done')
|
||||||
|
const isBlocked = incompleteBlockers.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-cyber-darkest border border-cyber-orange/50 rounded-lg w-full max-w-lg shadow-xl"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-cyber-orange/20">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isBlocked
|
||||||
|
? <Lock size={16} className="text-red-400" />
|
||||||
|
: <Unlock size={16} className="text-green-400" />
|
||||||
|
}
|
||||||
|
<span className="font-semibold text-gray-100 text-sm">Blockers</span>
|
||||||
|
<span className="text-xs text-gray-500 ml-1 truncate max-w-[200px]" title={task.title}>
|
||||||
|
— {task.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-200 transition-colors">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
{/* Status banner */}
|
||||||
|
{isBlocked ? (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 bg-red-900/20 border border-red-500/30 rounded text-red-300 text-sm">
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
<span>{incompleteBlockers.length} incomplete blocker{incompleteBlockers.length !== 1 ? 's' : ''} — this task is locked</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 bg-green-900/20 border border-green-500/30 rounded text-green-300 text-sm">
|
||||||
|
<Unlock size={14} />
|
||||||
|
<span>No active blockers — this task is ready to work on</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Current blockers list */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-gray-500 text-sm text-center py-2">Loading...</div>
|
||||||
|
) : blockers.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Blocked by</p>
|
||||||
|
{blockers.map(b => (
|
||||||
|
<div
|
||||||
|
key={b.id}
|
||||||
|
className="flex items-center justify-between px-3 py-2 bg-cyber-darker rounded border border-cyber-orange/10"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${b.status === 'done' ? 'bg-green-400' : 'bg-red-400'}`} />
|
||||||
|
<span className="text-sm text-gray-200 truncate">{b.title}</span>
|
||||||
|
<span className="text-xs text-gray-500 flex-shrink-0">#{b.id}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveBlocker(b.id)}
|
||||||
|
className="text-gray-600 hover:text-red-400 transition-colors flex-shrink-0 ml-2"
|
||||||
|
title="Remove blocker"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-600 text-center py-1">No blockers added yet</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search to add blocker */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Add a blocker</p>
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => handleSearch(e.target.value)}
|
||||||
|
placeholder="Search tasks across all projects..."
|
||||||
|
className="w-full pl-9 pr-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search results */}
|
||||||
|
{(searchResults.length > 0 || searching) && (
|
||||||
|
<div className="mt-1 border border-cyber-orange/20 rounded overflow-hidden">
|
||||||
|
{searching && (
|
||||||
|
<div className="px-3 py-2 text-xs text-gray-500">Searching...</div>
|
||||||
|
)}
|
||||||
|
{searchResults.map(result => (
|
||||||
|
<button
|
||||||
|
key={result.id}
|
||||||
|
onClick={() => handleAddBlocker(result)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-cyber-darker text-left transition-colors border-b border-cyber-orange/10 last:border-0"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-gray-200 truncate flex-1">{result.title}</span>
|
||||||
|
<span className="text-xs text-gray-600 flex-shrink-0">project #{result.project_id}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!searching && searchResults.length === 0 && searchQuery && (
|
||||||
|
<div className="px-3 py-2 text-xs text-gray-500">No matching tasks found</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="px-3 py-2 bg-red-900/30 border border-red-500/40 rounded text-red-300 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BlockerPanel
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Plus, Check, X, Flag, Clock, ChevronDown, ChevronRight, ChevronsDown, ChevronsUp, Timer } from 'lucide-react'
|
import { Plus, Check, X, Flag, Clock, ChevronDown, ChevronRight, ChevronsDown, ChevronsUp } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
getProjectTasks,
|
getProjectTasks,
|
||||||
createTask,
|
createTask,
|
||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
import { formatTimeWithTotal } from '../utils/format'
|
import { formatTimeWithTotal } from '../utils/format'
|
||||||
import TaskMenu from './TaskMenu'
|
import TaskMenu from './TaskMenu'
|
||||||
import TaskForm from './TaskForm'
|
import TaskForm from './TaskForm'
|
||||||
import { usePomodoro } from '../context/PomodoroContext'
|
|
||||||
|
|
||||||
// Helper to format status label
|
// Helper to format status label
|
||||||
const formatStatusLabel = (status) => {
|
const formatStatusLabel = (status) => {
|
||||||
@@ -70,12 +69,10 @@ function hasDescendantsInStatus(taskId, allTasks, status) {
|
|||||||
return getDescendantsInStatus(taskId, allTasks, status).length > 0
|
return getDescendantsInStatus(taskId, allTasks, status).length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatus, expandedCards, setExpandedCards, projectStatuses, projectId, pomodoroSettings }) {
|
function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatus, expandedCards, setExpandedCards, projectStatuses, projectId }) {
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [editTitle, setEditTitle] = useState(task.title)
|
const [editTitle, setEditTitle] = useState(task.title)
|
||||||
const [showAddSubtask, setShowAddSubtask] = useState(false)
|
const [showAddSubtask, setShowAddSubtask] = useState(false)
|
||||||
const { startPomodoro, activeTask, phase } = usePomodoro()
|
|
||||||
const isActiveTimer = activeTask?.id === task.id && phase !== 'idle'
|
|
||||||
|
|
||||||
// Use global expanded state
|
// Use global expanded state
|
||||||
const isExpanded = expandedCards[task.id] || false
|
const isExpanded = expandedCards[task.id] || false
|
||||||
@@ -237,20 +234,6 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
startPomodoro(
|
|
||||||
{ id: task.id, title: task.title, estimatedMinutes: task.estimated_minutes },
|
|
||||||
pomodoroSettings?.workMinutes || 25,
|
|
||||||
pomodoroSettings?.breakMinutes || 5
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
className={`p-1 transition-colors ${isActiveTimer ? 'text-cyber-orange' : 'text-gray-400 hover:text-cyber-orange'}`}
|
|
||||||
title={isActiveTimer ? 'Timer running' : 'Start Pomodoro'}
|
|
||||||
>
|
|
||||||
<Timer size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddSubtask(true)}
|
onClick={() => setShowAddSubtask(true)}
|
||||||
className="text-cyber-orange hover:text-cyber-orange-bright p-1"
|
className="text-cyber-orange hover:text-cyber-orange-bright p-1"
|
||||||
@@ -300,7 +283,6 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
|
|||||||
setExpandedCards={setExpandedCards}
|
setExpandedCards={setExpandedCards}
|
||||||
projectStatuses={projectStatuses}
|
projectStatuses={projectStatuses}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
pomodoroSettings={pomodoroSettings}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -309,7 +291,7 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOver, expandedCards, setExpandedCards, projectStatuses, pomodoroSettings }) {
|
function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOver, expandedCards, setExpandedCards, projectStatuses }) {
|
||||||
const [showAddTask, setShowAddTask] = useState(false)
|
const [showAddTask, setShowAddTask] = useState(false)
|
||||||
|
|
||||||
const handleAddTask = async (taskData) => {
|
const handleAddTask = async (taskData) => {
|
||||||
@@ -402,7 +384,6 @@ function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOve
|
|||||||
setExpandedCards={setExpandedCards}
|
setExpandedCards={setExpandedCards}
|
||||||
projectStatuses={projectStatuses}
|
projectStatuses={projectStatuses}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
pomodoroSettings={pomodoroSettings}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -419,10 +400,6 @@ function KanbanView({ projectId, project }) {
|
|||||||
|
|
||||||
// Get statuses from project, or use defaults
|
// Get statuses from project, or use defaults
|
||||||
const statuses = project?.statuses || ['backlog', 'in_progress', 'on_hold', 'done']
|
const statuses = project?.statuses || ['backlog', 'in_progress', 'on_hold', 'done']
|
||||||
const pomodoroSettings = {
|
|
||||||
workMinutes: project?.pomodoro_work_minutes || 25,
|
|
||||||
breakMinutes: project?.pomodoro_break_minutes || 5,
|
|
||||||
}
|
|
||||||
const statusesWithMeta = statuses.map(status => ({
|
const statusesWithMeta = statuses.map(status => ({
|
||||||
key: status,
|
key: status,
|
||||||
label: formatStatusLabel(status),
|
label: formatStatusLabel(status),
|
||||||
@@ -530,7 +507,6 @@ function KanbanView({ projectId, project }) {
|
|||||||
expandedCards={expandedCards}
|
expandedCards={expandedCards}
|
||||||
setExpandedCards={setExpandedCards}
|
setExpandedCards={setExpandedCards}
|
||||||
projectStatuses={statuses}
|
projectStatuses={statuses}
|
||||||
pomodoroSettings={pomodoroSettings}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
import { X, Pause, Play, Square, SkipForward } from 'lucide-react'
|
|
||||||
import { usePomodoro } from '../context/PomodoroContext'
|
|
||||||
|
|
||||||
function formatCountdown(seconds) {
|
|
||||||
const m = Math.floor(seconds / 60)
|
|
||||||
const s = seconds % 60
|
|
||||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PomodoroWidget() {
|
|
||||||
const { activeTask, phase, secondsLeft, sessionCount, workMinutes, breakMinutes, pause, resume, stop, skipBreak } = usePomodoro()
|
|
||||||
|
|
||||||
if (phase === 'idle') return null
|
|
||||||
|
|
||||||
const isBreak = phase === 'break'
|
|
||||||
const isPaused = phase === 'paused'
|
|
||||||
|
|
||||||
const totalSecs = isBreak ? breakMinutes * 60 : workMinutes * 60
|
|
||||||
const progress = totalSecs > 0 ? (totalSecs - secondsLeft) / totalSecs : 0
|
|
||||||
|
|
||||||
const phaseLabel = isBreak ? '☕ BREAK' : '🍅 WORK'
|
|
||||||
const phaseColor = isBreak ? 'text-green-400' : 'text-cyber-orange'
|
|
||||||
const borderColor = isBreak ? 'border-green-500/50' : 'border-cyber-orange/50'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`fixed bottom-4 right-4 z-50 w-72 bg-cyber-darkest border ${borderColor} rounded-lg shadow-cyber-lg`}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 pt-3 pb-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`text-xs font-bold tracking-wider ${phaseColor}`}>
|
|
||||||
{phaseLabel}
|
|
||||||
{!isBreak && sessionCount > 0 && (
|
|
||||||
<span className="ml-2 text-gray-500 font-normal">#{sessionCount + 1}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={stop}
|
|
||||||
className="text-gray-500 hover:text-gray-300 transition-colors"
|
|
||||||
title="Stop and close"
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Task name */}
|
|
||||||
{activeTask && !isBreak && (
|
|
||||||
<div className="px-4 pb-1">
|
|
||||||
<p className="text-sm text-gray-300 truncate" title={activeTask.title}>
|
|
||||||
{activeTask.title}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Timer display */}
|
|
||||||
<div className="px-4 py-2 text-center">
|
|
||||||
<span className={`text-4xl font-mono font-bold tabular-nums ${isPaused ? 'text-gray-500' : phaseColor}`}>
|
|
||||||
{formatCountdown(secondsLeft)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress bar */}
|
|
||||||
<div className="px-4 pb-2">
|
|
||||||
<div className="h-1 bg-cyber-darker rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full rounded-full transition-all duration-1000 ${isBreak ? 'bg-green-500' : 'bg-cyber-orange'}`}
|
|
||||||
style={{ width: `${progress * 100}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controls */}
|
|
||||||
<div className="flex gap-2 px-4 pb-3">
|
|
||||||
{isBreak ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={skipBreak}
|
|
||||||
className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 text-xs bg-cyber-darker border border-cyber-orange/30 text-gray-300 rounded hover:border-cyber-orange/60 hover:text-gray-100 transition-colors"
|
|
||||||
>
|
|
||||||
<SkipForward size={13} />
|
|
||||||
Skip Break
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={stop}
|
|
||||||
className="flex items-center justify-center gap-1 px-3 py-1.5 text-xs bg-cyber-darker border border-red-500/30 text-red-400 rounded hover:border-red-500/60 transition-colors"
|
|
||||||
>
|
|
||||||
<Square size={13} />
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{isPaused ? (
|
|
||||||
<button
|
|
||||||
onClick={resume}
|
|
||||||
className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 text-xs bg-cyber-orange/20 border border-cyber-orange text-cyber-orange rounded hover:bg-cyber-orange/30 transition-colors"
|
|
||||||
>
|
|
||||||
<Play size={13} />
|
|
||||||
Resume
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={pause}
|
|
||||||
className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 text-xs bg-cyber-darker border border-cyber-orange/30 text-gray-300 rounded hover:border-cyber-orange/60 hover:text-gray-100 transition-colors"
|
|
||||||
>
|
|
||||||
<Pause size={13} />
|
|
||||||
Pause
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={stop}
|
|
||||||
className="flex items-center justify-center gap-1 px-3 py-1.5 text-xs bg-cyber-darker border border-red-500/30 text-red-400 rounded hover:border-red-500/60 transition-colors"
|
|
||||||
>
|
|
||||||
<Square size={13} />
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Session count dots */}
|
|
||||||
{sessionCount > 0 && (
|
|
||||||
<div className="flex justify-center gap-1 pb-3">
|
|
||||||
{Array.from({ length: Math.min(sessionCount, 8) }).map((_, i) => (
|
|
||||||
<div key={i} className="w-1.5 h-1.5 rounded-full bg-cyber-orange/60" />
|
|
||||||
))}
|
|
||||||
{sessionCount > 8 && (
|
|
||||||
<span className="text-xs text-gray-500 ml-1">+{sessionCount - 8}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -11,23 +11,6 @@ function ProjectSettings({ project, onClose, onUpdate }) {
|
|||||||
const [taskCounts, setTaskCounts] = useState({})
|
const [taskCounts, setTaskCounts] = useState({})
|
||||||
const [deleteWarning, setDeleteWarning] = useState(null)
|
const [deleteWarning, setDeleteWarning] = useState(null)
|
||||||
|
|
||||||
// Time & Goals fields
|
|
||||||
const [category, setCategory] = useState(project.category || '')
|
|
||||||
const [weeklyGoalHours, setWeeklyGoalHours] = useState(
|
|
||||||
project.weekly_hours_goal ? Math.floor(project.weekly_hours_goal / 60) : ''
|
|
||||||
)
|
|
||||||
const [weeklyGoalMins, setWeeklyGoalMins] = useState(
|
|
||||||
project.weekly_hours_goal ? project.weekly_hours_goal % 60 : ''
|
|
||||||
)
|
|
||||||
const [totalGoalHours, setTotalGoalHours] = useState(
|
|
||||||
project.total_hours_goal ? Math.floor(project.total_hours_goal / 60) : ''
|
|
||||||
)
|
|
||||||
const [totalGoalMins, setTotalGoalMins] = useState(
|
|
||||||
project.total_hours_goal ? project.total_hours_goal % 60 : ''
|
|
||||||
)
|
|
||||||
const [pomodoroWork, setPomodoroWork] = useState(project.pomodoro_work_minutes || 25)
|
|
||||||
const [pomodoroBreak, setPomodoroBreak] = useState(project.pomodoro_break_minutes || 5)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTaskCounts()
|
loadTaskCounts()
|
||||||
}, [])
|
}, [])
|
||||||
@@ -137,20 +120,8 @@ function ProjectSettings({ project, onClose, onUpdate }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const weeklyMinutes =
|
|
||||||
(parseInt(weeklyGoalHours) || 0) * 60 + (parseInt(weeklyGoalMins) || 0)
|
|
||||||
const totalMinutes =
|
|
||||||
(parseInt(totalGoalHours) || 0) * 60 + (parseInt(totalGoalMins) || 0)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateProject(project.id, {
|
await updateProject(project.id, { statuses })
|
||||||
statuses,
|
|
||||||
category: category.trim() || null,
|
|
||||||
weekly_hours_goal: weeklyMinutes > 0 ? weeklyMinutes : null,
|
|
||||||
total_hours_goal: totalMinutes > 0 ? totalMinutes : null,
|
|
||||||
pomodoro_work_minutes: parseInt(pomodoroWork) || 25,
|
|
||||||
pomodoro_break_minutes: parseInt(pomodoroBreak) || 5,
|
|
||||||
})
|
|
||||||
onUpdate()
|
onUpdate()
|
||||||
onClose()
|
onClose()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -276,106 +247,6 @@ function ProjectSettings({ project, onClose, onUpdate }) {
|
|||||||
Add Status
|
Add Status
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time & Goals */}
|
|
||||||
<div className="mt-6 pt-6 border-t border-cyber-orange/20">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-200 mb-4">Time & Goals</h3>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Category */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-gray-400 mb-1">Category</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={category}
|
|
||||||
onChange={(e) => setCategory(e.target.value)}
|
|
||||||
placeholder="e.g. home, programming, hobby"
|
|
||||||
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Weekly goal */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-gray-400 mb-1">Weekly Time Goal</label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
value={weeklyGoalHours}
|
|
||||||
onChange={(e) => setWeeklyGoalHours(e.target.value)}
|
|
||||||
placeholder="0"
|
|
||||||
className="w-20 px-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
|
||||||
/>
|
|
||||||
<span className="text-gray-500 text-sm">hr</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="59"
|
|
||||||
value={weeklyGoalMins}
|
|
||||||
onChange={(e) => setWeeklyGoalMins(e.target.value)}
|
|
||||||
placeholder="0"
|
|
||||||
className="w-20 px-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
|
||||||
/>
|
|
||||||
<span className="text-gray-500 text-sm">min / week</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Total budget */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-gray-400 mb-1">Total Time Budget</label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
value={totalGoalHours}
|
|
||||||
onChange={(e) => setTotalGoalHours(e.target.value)}
|
|
||||||
placeholder="0"
|
|
||||||
className="w-20 px-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
|
||||||
/>
|
|
||||||
<span className="text-gray-500 text-sm">hr</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="59"
|
|
||||||
value={totalGoalMins}
|
|
||||||
onChange={(e) => setTotalGoalMins(e.target.value)}
|
|
||||||
placeholder="0"
|
|
||||||
className="w-20 px-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
|
|
||||||
/>
|
|
||||||
<span className="text-gray-500 text-sm">min total</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pomodoro settings */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm text-gray-400 mb-1">Pomodoro Settings</label>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="120"
|
|
||||||
value={pomodoroWork}
|
|
||||||
onChange={(e) => setPomodoroWork(e.target.value)}
|
|
||||||
className="w-16 px-2 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange text-center"
|
|
||||||
/>
|
|
||||||
<span className="text-gray-500 text-sm">min work</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="60"
|
|
||||||
value={pomodoroBreak}
|
|
||||||
onChange={(e) => setPomodoroBreak(e.target.value)}
|
|
||||||
className="w-16 px-2 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange text-center"
|
|
||||||
/>
|
|
||||||
<span className="text-gray-500 text-sm">min break</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo, FileText } from 'lucide-react'
|
import { MoreVertical, Clock, Tag, Flag, Edit2, Trash2, X, Check, ListTodo, FileText, Lock } from 'lucide-react'
|
||||||
import { updateTask } from '../utils/api'
|
import { updateTask } from '../utils/api'
|
||||||
|
import BlockerPanel from './BlockerPanel'
|
||||||
|
|
||||||
const FLAG_COLORS = [
|
const FLAG_COLORS = [
|
||||||
{ name: 'red', color: 'bg-red-500' },
|
{ name: 'red', color: 'bg-red-500' },
|
||||||
@@ -35,6 +36,7 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit, projectStatuses }) {
|
|||||||
const [showTagsEdit, setShowTagsEdit] = useState(false)
|
const [showTagsEdit, setShowTagsEdit] = useState(false)
|
||||||
const [showFlagEdit, setShowFlagEdit] = useState(false)
|
const [showFlagEdit, setShowFlagEdit] = useState(false)
|
||||||
const [showStatusEdit, setShowStatusEdit] = useState(false)
|
const [showStatusEdit, setShowStatusEdit] = useState(false)
|
||||||
|
const [showBlockerPanel, setShowBlockerPanel] = useState(false)
|
||||||
|
|
||||||
// Calculate hours and minutes from task.estimated_minutes
|
// Calculate hours and minutes from task.estimated_minutes
|
||||||
const initialHours = task.estimated_minutes ? Math.floor(task.estimated_minutes / 60) : ''
|
const initialHours = task.estimated_minutes ? Math.floor(task.estimated_minutes / 60) : ''
|
||||||
@@ -371,6 +373,19 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit, projectStatuses }) {
|
|||||||
</button>
|
</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 */}
|
{/* Edit Title */}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -398,6 +413,15 @@ function TaskMenu({ task, onUpdate, onDelete, onEdit, projectStatuses }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Blocker panel modal */}
|
||||||
|
{showBlockerPanel && (
|
||||||
|
<BlockerPanel
|
||||||
|
task={task}
|
||||||
|
onClose={() => setShowBlockerPanel(false)}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
X,
|
X,
|
||||||
Flag,
|
Flag,
|
||||||
Clock,
|
Clock
|
||||||
Timer
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
getProjectTaskTree,
|
getProjectTaskTree,
|
||||||
@@ -18,7 +17,6 @@ import {
|
|||||||
import { formatTimeWithTotal } from '../utils/format'
|
import { formatTimeWithTotal } from '../utils/format'
|
||||||
import TaskMenu from './TaskMenu'
|
import TaskMenu from './TaskMenu'
|
||||||
import TaskForm from './TaskForm'
|
import TaskForm from './TaskForm'
|
||||||
import { usePomodoro } from '../context/PomodoroContext'
|
|
||||||
|
|
||||||
// Helper to format status label
|
// Helper to format status label
|
||||||
const formatStatusLabel = (status) => {
|
const formatStatusLabel = (status) => {
|
||||||
@@ -46,14 +44,12 @@ const FLAG_COLORS = {
|
|||||||
pink: 'bg-pink-500'
|
pink: 'bg-pink-500'
|
||||||
}
|
}
|
||||||
|
|
||||||
function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses, pomodoroSettings }) {
|
function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses }) {
|
||||||
const [isExpanded, setIsExpanded] = useState(true)
|
const [isExpanded, setIsExpanded] = useState(true)
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [editTitle, setEditTitle] = useState(task.title)
|
const [editTitle, setEditTitle] = useState(task.title)
|
||||||
const [editStatus, setEditStatus] = useState(task.status)
|
const [editStatus, setEditStatus] = useState(task.status)
|
||||||
const [showAddSubtask, setShowAddSubtask] = useState(false)
|
const [showAddSubtask, setShowAddSubtask] = useState(false)
|
||||||
const { startPomodoro, activeTask, phase } = usePomodoro()
|
|
||||||
const isActiveTimer = activeTask?.id === task.id && phase !== 'idle'
|
|
||||||
|
|
||||||
const hasSubtasks = task.subtasks && task.subtasks.length > 0
|
const hasSubtasks = task.subtasks && task.subtasks.length > 0
|
||||||
|
|
||||||
@@ -204,20 +200,6 @@ function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses, pomod
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
startPomodoro(
|
|
||||||
{ id: task.id, title: task.title, estimatedMinutes: task.estimated_minutes },
|
|
||||||
pomodoroSettings?.workMinutes || 25,
|
|
||||||
pomodoroSettings?.breakMinutes || 5
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
className={`transition-colors ${isActiveTimer ? 'text-cyber-orange' : 'text-gray-400 hover:text-cyber-orange'}`}
|
|
||||||
title={isActiveTimer ? 'Timer running' : 'Start Pomodoro'}
|
|
||||||
>
|
|
||||||
<Timer size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddSubtask(true)}
|
onClick={() => setShowAddSubtask(true)}
|
||||||
className="text-cyber-orange hover:text-cyber-orange-bright"
|
className="text-cyber-orange hover:text-cyber-orange-bright"
|
||||||
@@ -260,7 +242,6 @@ function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses, pomod
|
|||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
level={level + 1}
|
level={level + 1}
|
||||||
projectStatuses={projectStatuses}
|
projectStatuses={projectStatuses}
|
||||||
pomodoroSettings={pomodoroSettings}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -271,10 +252,6 @@ function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses, pomod
|
|||||||
|
|
||||||
function TreeView({ projectId, project }) {
|
function TreeView({ projectId, project }) {
|
||||||
const projectStatuses = project?.statuses || ['backlog', 'in_progress', 'on_hold', 'done']
|
const projectStatuses = project?.statuses || ['backlog', 'in_progress', 'on_hold', 'done']
|
||||||
const pomodoroSettings = {
|
|
||||||
workMinutes: project?.pomodoro_work_minutes || 25,
|
|
||||||
breakMinutes: project?.pomodoro_break_minutes || 5,
|
|
||||||
}
|
|
||||||
const [tasks, setTasks] = useState([])
|
const [tasks, setTasks] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -361,7 +338,6 @@ function TreeView({ projectId, project }) {
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onUpdate={loadTasks}
|
onUpdate={loadTasks}
|
||||||
projectStatuses={projectStatuses}
|
projectStatuses={projectStatuses}
|
||||||
pomodoroSettings={pomodoroSettings}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
import { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react'
|
|
||||||
import { logTime } from '../utils/api'
|
|
||||||
|
|
||||||
const PomodoroContext = createContext(null)
|
|
||||||
|
|
||||||
export function PomodoroProvider({ children }) {
|
|
||||||
const [activeTask, setActiveTask] = useState(null) // { id, title, estimatedMinutes }
|
|
||||||
const [phase, setPhase] = useState('idle') // 'idle' | 'work' | 'break' | 'paused'
|
|
||||||
const [secondsLeft, setSecondsLeft] = useState(0)
|
|
||||||
const [sessionCount, setSessionCount] = useState(0)
|
|
||||||
const [workMinutes, setWorkMinutes] = useState(25)
|
|
||||||
const [breakMinutes, setBreakMinutes] = useState(5)
|
|
||||||
const [pausedPhase, setPausedPhase] = useState(null) // which phase we paused from
|
|
||||||
const [secondsWhenPaused, setSecondsWhenPaused] = useState(0)
|
|
||||||
|
|
||||||
// Track how many seconds actually elapsed in the current work session
|
|
||||||
// (for partial sessions when user stops early)
|
|
||||||
const elapsedWorkSeconds = useRef(0)
|
|
||||||
const intervalRef = useRef(null)
|
|
||||||
|
|
||||||
const playBeep = useCallback((frequency = 440, duration = 0.4) => {
|
|
||||||
try {
|
|
||||||
const ctx = new AudioContext()
|
|
||||||
const osc = ctx.createOscillator()
|
|
||||||
const gain = ctx.createGain()
|
|
||||||
osc.connect(gain)
|
|
||||||
gain.connect(ctx.destination)
|
|
||||||
osc.frequency.value = frequency
|
|
||||||
gain.gain.setValueAtTime(0.3, ctx.currentTime)
|
|
||||||
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration)
|
|
||||||
osc.start(ctx.currentTime)
|
|
||||||
osc.stop(ctx.currentTime + duration)
|
|
||||||
} catch (e) {
|
|
||||||
// AudioContext not available (e.g. in tests)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const sendNotification = useCallback((title, body) => {
|
|
||||||
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
|
||||||
new Notification(title, { body, icon: '/vite.svg' })
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const requestNotificationPermission = useCallback(() => {
|
|
||||||
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
|
|
||||||
Notification.requestPermission()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const autoLogCompletedSession = useCallback(async (taskId, minutes, sessionType) => {
|
|
||||||
if (minutes <= 0) return
|
|
||||||
try {
|
|
||||||
await logTime(taskId, { minutes, session_type: sessionType })
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to auto-log time:', e)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Tick logic — runs every second when active
|
|
||||||
useEffect(() => {
|
|
||||||
if (phase !== 'work' && phase !== 'break') {
|
|
||||||
clearInterval(intervalRef.current)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
intervalRef.current = setInterval(() => {
|
|
||||||
setSecondsLeft(prev => {
|
|
||||||
if (prev <= 1) {
|
|
||||||
// Time's up
|
|
||||||
clearInterval(intervalRef.current)
|
|
||||||
|
|
||||||
if (phase === 'work') {
|
|
||||||
// Auto-log the completed pomodoro
|
|
||||||
autoLogCompletedSession(
|
|
||||||
activeTask.id,
|
|
||||||
workMinutes,
|
|
||||||
'pomodoro'
|
|
||||||
)
|
|
||||||
elapsedWorkSeconds.current = 0
|
|
||||||
|
|
||||||
// Play double beep + notification
|
|
||||||
playBeep(523, 0.3)
|
|
||||||
setTimeout(() => playBeep(659, 0.4), 350)
|
|
||||||
sendNotification('BIT — Pomodoro done!', `Time for a break. Session ${sessionCount + 1} complete.`)
|
|
||||||
|
|
||||||
setSessionCount(c => c + 1)
|
|
||||||
|
|
||||||
if (breakMinutes > 0) {
|
|
||||||
setPhase('break')
|
|
||||||
return breakMinutes * 60
|
|
||||||
} else {
|
|
||||||
// No break configured — go straight back to work
|
|
||||||
setPhase('work')
|
|
||||||
return workMinutes * 60
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Break over
|
|
||||||
playBeep(440, 0.3)
|
|
||||||
setTimeout(() => playBeep(523, 0.4), 350)
|
|
||||||
sendNotification('BIT — Break over!', 'Ready for the next pomodoro?')
|
|
||||||
setPhase('work')
|
|
||||||
return workMinutes * 60
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (phase === 'work') {
|
|
||||||
elapsedWorkSeconds.current += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return prev - 1
|
|
||||||
})
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
return () => clearInterval(intervalRef.current)
|
|
||||||
}, [phase, activeTask, workMinutes, breakMinutes, sessionCount, autoLogCompletedSession, playBeep, sendNotification])
|
|
||||||
|
|
||||||
const startPomodoro = useCallback((task, workMins = 25, breakMins = 5) => {
|
|
||||||
// If a session is active, log partial time first
|
|
||||||
if (phase === 'work' && activeTask && elapsedWorkSeconds.current > 0) {
|
|
||||||
const partialMinutes = Math.round(elapsedWorkSeconds.current / 60)
|
|
||||||
if (partialMinutes > 0) {
|
|
||||||
autoLogCompletedSession(activeTask.id, partialMinutes, 'pomodoro')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearInterval(intervalRef.current)
|
|
||||||
elapsedWorkSeconds.current = 0
|
|
||||||
|
|
||||||
requestNotificationPermission()
|
|
||||||
|
|
||||||
setActiveTask(task)
|
|
||||||
setWorkMinutes(workMins)
|
|
||||||
setBreakMinutes(breakMins)
|
|
||||||
setSessionCount(0)
|
|
||||||
setPhase('work')
|
|
||||||
setSecondsLeft(workMins * 60)
|
|
||||||
}, [phase, activeTask, autoLogCompletedSession, requestNotificationPermission])
|
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
|
||||||
if (phase !== 'work' && phase !== 'break') return
|
|
||||||
clearInterval(intervalRef.current)
|
|
||||||
setPausedPhase(phase)
|
|
||||||
setSecondsWhenPaused(secondsLeft)
|
|
||||||
setPhase('paused')
|
|
||||||
}, [phase, secondsLeft])
|
|
||||||
|
|
||||||
const resume = useCallback(() => {
|
|
||||||
if (phase !== 'paused') return
|
|
||||||
setPhase(pausedPhase)
|
|
||||||
setSecondsLeft(secondsWhenPaused)
|
|
||||||
}, [phase, pausedPhase, secondsWhenPaused])
|
|
||||||
|
|
||||||
const stop = useCallback(async () => {
|
|
||||||
clearInterval(intervalRef.current)
|
|
||||||
|
|
||||||
// Log partial work time if we were mid-session
|
|
||||||
const currentPhase = phase === 'paused' ? pausedPhase : phase
|
|
||||||
if (currentPhase === 'work' && activeTask && elapsedWorkSeconds.current > 0) {
|
|
||||||
const partialMinutes = Math.round(elapsedWorkSeconds.current / 60)
|
|
||||||
if (partialMinutes > 0) {
|
|
||||||
await autoLogCompletedSession(activeTask.id, partialMinutes, 'pomodoro')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
elapsedWorkSeconds.current = 0
|
|
||||||
setActiveTask(null)
|
|
||||||
setPhase('idle')
|
|
||||||
setSecondsLeft(0)
|
|
||||||
setSessionCount(0)
|
|
||||||
setPausedPhase(null)
|
|
||||||
}, [phase, pausedPhase, activeTask, autoLogCompletedSession])
|
|
||||||
|
|
||||||
const skipBreak = useCallback(() => {
|
|
||||||
if (phase !== 'break') return
|
|
||||||
clearInterval(intervalRef.current)
|
|
||||||
setPhase('work')
|
|
||||||
setSecondsLeft(workMinutes * 60)
|
|
||||||
elapsedWorkSeconds.current = 0
|
|
||||||
}, [phase, workMinutes])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PomodoroContext.Provider value={{
|
|
||||||
activeTask,
|
|
||||||
phase,
|
|
||||||
secondsLeft,
|
|
||||||
sessionCount,
|
|
||||||
workMinutes,
|
|
||||||
breakMinutes,
|
|
||||||
startPomodoro,
|
|
||||||
pause,
|
|
||||||
resume,
|
|
||||||
stop,
|
|
||||||
skipBreak,
|
|
||||||
}}>
|
|
||||||
{children}
|
|
||||||
</PomodoroContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePomodoro() {
|
|
||||||
const ctx = useContext(PomodoroContext)
|
|
||||||
if (!ctx) throw new Error('usePomodoro must be used inside PomodoroProvider')
|
|
||||||
return ctx
|
|
||||||
}
|
|
||||||
190
frontend/src/pages/ActionableView.jsx
Normal file
190
frontend/src/pages/ActionableView.jsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { Zap, Clock, RefreshCw, ChevronRight, Check } from 'lucide-react'
|
||||||
|
import { getActionableTasks, updateTask } from '../utils/api'
|
||||||
|
|
||||||
|
const FLAG_DOT = {
|
||||||
|
red: 'bg-red-500',
|
||||||
|
orange: 'bg-orange-500',
|
||||||
|
yellow: 'bg-yellow-500',
|
||||||
|
green: 'bg-green-500',
|
||||||
|
blue: 'bg-blue-500',
|
||||||
|
purple: 'bg-purple-500',
|
||||||
|
pink: 'bg-pink-500',
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (minutes) => {
|
||||||
|
if (!minutes) return null
|
||||||
|
const h = Math.floor(minutes / 60)
|
||||||
|
const m = minutes % 60
|
||||||
|
if (h && m) return `${h}h ${m}m`
|
||||||
|
if (h) return `${h}h`
|
||||||
|
return `${m}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatStatusLabel = (status) =>
|
||||||
|
status.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')
|
||||||
|
|
||||||
|
const getStatusColor = (status) => {
|
||||||
|
const s = status.toLowerCase()
|
||||||
|
if (s === 'in_progress' || s.includes('progress')) return 'text-blue-400'
|
||||||
|
if (s === 'on_hold' || s.includes('hold')) return 'text-yellow-400'
|
||||||
|
return 'text-gray-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionableView() {
|
||||||
|
const [tasks, setTasks] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [completingId, setCompletingId] = useState(null)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTasks()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadTasks = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
const data = await getActionableTasks()
|
||||||
|
setTasks(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMarkDone = async (task) => {
|
||||||
|
try {
|
||||||
|
setCompletingId(task.id)
|
||||||
|
await updateTask(task.id, { status: 'done' })
|
||||||
|
// Remove from list and reload to surface newly unblocked tasks
|
||||||
|
setTasks(prev => prev.filter(t => t.id !== task.id))
|
||||||
|
// Reload after a short beat so the user sees the removal first
|
||||||
|
setTimeout(() => loadTasks(), 600)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setCompletingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by project
|
||||||
|
const byProject = tasks.reduce((acc, task) => {
|
||||||
|
const key = task.project_id
|
||||||
|
if (!acc[key]) acc[key] = { name: task.project_name, tasks: [] }
|
||||||
|
acc[key].tasks.push(task)
|
||||||
|
return acc
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
const projectGroups = Object.entries(byProject)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Zap size={24} className="text-cyber-orange" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-100">What can I do right now?</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
|
{loading ? '...' : `${tasks.length} task${tasks.length !== 1 ? 's' : ''} ready across all projects`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={loadTasks}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-400 hover:text-gray-200 border border-cyber-orange/20 hover:border-cyber-orange/40 rounded transition-colors"
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 px-4 py-3 bg-red-900/30 border border-red-500/40 rounded text-red-300 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && tasks.length === 0 && (
|
||||||
|
<div className="text-center py-20 text-gray-600">
|
||||||
|
<Zap size={40} className="mx-auto mb-4 opacity-30" />
|
||||||
|
<p className="text-lg">Nothing actionable right now.</p>
|
||||||
|
<p className="text-sm mt-1">All tasks are either done or blocked.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Project groups */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
{projectGroups.map(([projectId, group]) => (
|
||||||
|
<div key={projectId}>
|
||||||
|
{/* Project header */}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/project/${projectId}`)}
|
||||||
|
className="flex items-center gap-2 mb-3 text-cyber-orange hover:text-cyber-orange-bright transition-colors group"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold uppercase tracking-wider">{group.name}</span>
|
||||||
|
<ChevronRight size={14} className="opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Task cards */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{group.tasks.map(task => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 bg-cyber-darkest border border-cyber-orange/20 rounded-lg hover:border-cyber-orange/40 transition-all ${
|
||||||
|
completingId === task.id ? 'opacity-50' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Done button */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleMarkDone(task)}
|
||||||
|
disabled={completingId === task.id}
|
||||||
|
className="flex-shrink-0 w-6 h-6 rounded-full border-2 border-cyber-orange/40 hover:border-green-400 hover:bg-green-400/10 flex items-center justify-center transition-all group"
|
||||||
|
title="Mark as done"
|
||||||
|
>
|
||||||
|
<Check size={12} className="text-transparent group-hover:text-green-400 transition-colors" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Flag dot */}
|
||||||
|
{task.flag_color && (
|
||||||
|
<span className={`flex-shrink-0 w-2 h-2 rounded-full ${FLAG_DOT[task.flag_color] || 'bg-gray-500'}`} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title + meta */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-sm text-gray-100">{task.title}</span>
|
||||||
|
<div className="flex items-center gap-3 mt-0.5 flex-wrap">
|
||||||
|
{task.status !== 'backlog' && (
|
||||||
|
<span className={`text-xs ${getStatusColor(task.status)}`}>
|
||||||
|
{formatStatusLabel(task.status)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{task.estimated_minutes && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-gray-600">
|
||||||
|
<Clock size={10} />
|
||||||
|
{formatTime(task.estimated_minutes)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{task.tags && task.tags.map(tag => (
|
||||||
|
<span key={tag} className="text-xs text-cyber-orange/60 bg-cyber-orange/5 px-1.5 py-0.5 rounded">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ActionableView
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Plus, Upload, Trash2, Archive, ArchiveRestore, Clock } from 'lucide-react'
|
import { Plus, Upload, Trash2, Archive, ArchiveRestore } from 'lucide-react'
|
||||||
import { getProjects, createProject, deleteProject, importJSON, archiveProject, unarchiveProject, getProjectTimeSummary } from '../utils/api'
|
import { getProjects, createProject, deleteProject, importJSON, archiveProject, unarchiveProject } from '../utils/api'
|
||||||
import { formatTime } from '../utils/format'
|
|
||||||
|
|
||||||
function ProjectList() {
|
function ProjectList() {
|
||||||
const [projects, setProjects] = useState([])
|
const [projects, setProjects] = useState([])
|
||||||
const [timeSummaries, setTimeSummaries] = useState({})
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
const [showImportModal, setShowImportModal] = useState(false)
|
const [showImportModal, setShowImportModal] = useState(false)
|
||||||
@@ -15,7 +13,6 @@ function ProjectList() {
|
|||||||
const [importJSON_Text, setImportJSONText] = useState('')
|
const [importJSON_Text, setImportJSONText] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [activeTab, setActiveTab] = useState('active') // 'active', 'archived', 'all'
|
const [activeTab, setActiveTab] = useState('active') // 'active', 'archived', 'all'
|
||||||
const [activeCategory, setActiveCategory] = useState(null)
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -28,22 +25,10 @@ function ProjectList() {
|
|||||||
let archivedFilter = null
|
let archivedFilter = null
|
||||||
if (activeTab === 'active') archivedFilter = false
|
if (activeTab === 'active') archivedFilter = false
|
||||||
if (activeTab === 'archived') archivedFilter = true
|
if (activeTab === 'archived') archivedFilter = true
|
||||||
|
// 'all' tab uses null to get all projects
|
||||||
|
|
||||||
const data = await getProjects(archivedFilter)
|
const data = await getProjects(archivedFilter)
|
||||||
setProjects(data)
|
setProjects(data)
|
||||||
|
|
||||||
// Fetch time summaries in parallel for all projects
|
|
||||||
const summaryEntries = await Promise.all(
|
|
||||||
data.map(async (p) => {
|
|
||||||
try {
|
|
||||||
const summary = await getProjectTimeSummary(p.id)
|
|
||||||
return [p.id, summary]
|
|
||||||
} catch {
|
|
||||||
return [p.id, null]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
setTimeSummaries(Object.fromEntries(summaryEntries))
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -140,39 +125,6 @@ function ProjectList() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category filter */}
|
|
||||||
{(() => {
|
|
||||||
const categories = [...new Set(projects.map(p => p.category).filter(Boolean))].sort()
|
|
||||||
if (categories.length === 0) return null
|
|
||||||
return (
|
|
||||||
<div className="flex gap-2 flex-wrap mb-4">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveCategory(null)}
|
|
||||||
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
|
|
||||||
activeCategory === null
|
|
||||||
? 'bg-cyber-orange/20 border-cyber-orange text-cyber-orange'
|
|
||||||
: 'border-cyber-orange/30 text-gray-400 hover:border-cyber-orange/60'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
All
|
|
||||||
</button>
|
|
||||||
{categories.map(cat => (
|
|
||||||
<button
|
|
||||||
key={cat}
|
|
||||||
onClick={() => setActiveCategory(activeCategory === cat ? null : cat)}
|
|
||||||
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
|
|
||||||
activeCategory === cat
|
|
||||||
? 'bg-cyber-orange/20 border-cyber-orange text-cyber-orange'
|
|
||||||
: 'border-cyber-orange/30 text-gray-400 hover:border-cyber-orange/60'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{cat}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex gap-1 mb-6 border-b border-cyber-orange/20">
|
<div className="flex gap-1 mb-6 border-b border-cyber-orange/20">
|
||||||
<button
|
<button
|
||||||
@@ -213,142 +165,71 @@ function ProjectList() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(() => {
|
{projects.length === 0 ? (
|
||||||
const visibleProjects = activeCategory
|
<div className="text-center py-16 text-gray-500">
|
||||||
? projects.filter(p => p.category === activeCategory)
|
<p className="text-xl mb-2">
|
||||||
: projects
|
{activeTab === 'archived' ? 'No archived projects' : 'No projects yet'}
|
||||||
if (visibleProjects.length === 0) return (
|
</p>
|
||||||
<div className="text-center py-16 text-gray-500">
|
<p className="text-sm">
|
||||||
<p className="text-xl mb-2">
|
{activeTab === 'archived'
|
||||||
{activeTab === 'archived' ? 'No archived projects' : 'No projects yet'}
|
? 'Archive projects to keep them out of your active workspace'
|
||||||
</p>
|
: 'Create a new project or import from JSON'}
|
||||||
<p className="text-sm">
|
</p>
|
||||||
{activeTab === 'archived'
|
</div>
|
||||||
? 'Archive projects to keep them out of your active workspace'
|
) : (
|
||||||
: 'Create a new project or import from JSON'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
})()}
|
|
||||||
{projects.length > 0 && (
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{(activeCategory ? projects.filter(p => p.category === activeCategory) : projects).map(project => {
|
{projects.map(project => (
|
||||||
const summary = timeSummaries[project.id]
|
<div
|
||||||
const weeklyPct = summary && summary.weekly_hours_goal
|
key={project.id}
|
||||||
? Math.min(100, Math.round((summary.weekly_minutes / summary.weekly_hours_goal) * 100))
|
onClick={() => navigate(`/project/${project.id}`)}
|
||||||
: null
|
className={`p-6 bg-cyber-darkest border rounded-lg hover:border-cyber-orange hover:shadow-cyber transition-all cursor-pointer group ${
|
||||||
const totalPct = summary && summary.total_hours_goal
|
project.is_archived
|
||||||
? Math.min(100, Math.round((summary.total_minutes / summary.total_hours_goal) * 100))
|
? 'border-gray-700 opacity-75'
|
||||||
: null
|
: 'border-cyber-orange/30'
|
||||||
|
}`}
|
||||||
return (
|
>
|
||||||
<div
|
<div className="flex justify-between items-start mb-2">
|
||||||
key={project.id}
|
<h3 className="text-xl font-semibold text-gray-100 group-hover:text-cyber-orange transition-colors">
|
||||||
onClick={() => navigate(`/project/${project.id}`)}
|
{project.name}
|
||||||
className={`p-6 bg-cyber-darkest border rounded-lg hover:border-cyber-orange hover:shadow-cyber transition-all cursor-pointer group ${
|
{project.is_archived && (
|
||||||
project.is_archived
|
<span className="ml-2 text-xs text-gray-500">(archived)</span>
|
||||||
? 'border-gray-700 opacity-75'
|
)}
|
||||||
: 'border-cyber-orange/30'
|
</h3>
|
||||||
}`}
|
<div className="flex gap-2">
|
||||||
>
|
{project.is_archived ? (
|
||||||
<div className="flex justify-between items-start mb-2">
|
|
||||||
<h3 className="text-xl font-semibold text-gray-100 group-hover:text-cyber-orange transition-colors">
|
|
||||||
{project.name}
|
|
||||||
{project.is_archived && (
|
|
||||||
<span className="ml-2 text-xs text-gray-500">(archived)</span>
|
|
||||||
)}
|
|
||||||
</h3>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{project.is_archived ? (
|
|
||||||
<button
|
|
||||||
onClick={(e) => handleUnarchiveProject(project.id, e)}
|
|
||||||
className="text-gray-600 hover:text-cyber-orange transition-colors"
|
|
||||||
title="Unarchive project"
|
|
||||||
>
|
|
||||||
<ArchiveRestore size={18} />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={(e) => handleArchiveProject(project.id, e)}
|
|
||||||
className="text-gray-600 hover:text-yellow-400 transition-colors"
|
|
||||||
title="Archive project"
|
|
||||||
>
|
|
||||||
<Archive size={18} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleDeleteProject(project.id, e)}
|
onClick={(e) => handleUnarchiveProject(project.id, e)}
|
||||||
className="text-gray-600 hover:text-red-400 transition-colors"
|
className="text-gray-600 hover:text-cyber-orange transition-colors"
|
||||||
title="Delete project"
|
title="Unarchive project"
|
||||||
>
|
>
|
||||||
<Trash2 size={18} />
|
<ArchiveRestore size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleArchiveProject(project.id, e)}
|
||||||
|
className="text-gray-600 hover:text-yellow-400 transition-colors"
|
||||||
|
title="Archive project"
|
||||||
|
>
|
||||||
|
<Archive size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleDeleteProject(project.id, e)}
|
||||||
|
className="text-gray-600 hover:text-red-400 transition-colors"
|
||||||
|
title="Delete project"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category badge */}
|
|
||||||
{project.category && (
|
|
||||||
<span className="inline-block px-2 py-0.5 text-xs bg-cyber-orange/10 text-cyber-orange/70 border border-cyber-orange/20 rounded-full mb-2">
|
|
||||||
{project.category}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{project.description && (
|
|
||||||
<p className="text-gray-400 text-sm line-clamp-2">{project.description}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Time summary */}
|
|
||||||
{summary && summary.total_minutes > 0 && (
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
|
||||||
<Clock size={11} />
|
|
||||||
<span>{formatTime(summary.total_minutes)} logged total</span>
|
|
||||||
{summary.weekly_minutes > 0 && (
|
|
||||||
<span className="text-gray-600">· {formatTime(summary.weekly_minutes)} this week</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Weekly goal bar */}
|
|
||||||
{weeklyPct !== null && (
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between text-xs text-gray-600 mb-0.5">
|
|
||||||
<span>Weekly goal</span>
|
|
||||||
<span>{weeklyPct}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-1.5 bg-cyber-darker rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full rounded-full transition-all ${weeklyPct >= 100 ? 'bg-green-500' : 'bg-cyber-orange'}`}
|
|
||||||
style={{ width: `${weeklyPct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Total budget bar */}
|
|
||||||
{totalPct !== null && (
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between text-xs text-gray-600 mb-0.5">
|
|
||||||
<span>Total budget</span>
|
|
||||||
<span>{totalPct}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-1.5 bg-cyber-darker rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full rounded-full transition-all ${totalPct >= 100 ? 'bg-red-500' : 'bg-cyber-orange/60'}`}
|
|
||||||
style={{ width: `${totalPct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-xs text-gray-600 mt-3">
|
|
||||||
Created {new Date(project.created_at).toLocaleDateString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
{project.description && (
|
||||||
})}
|
<p className="text-gray-400 text-sm line-clamp-2">{project.description}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-gray-600 mt-3">
|
||||||
|
Created {new Date(project.created_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -66,13 +66,14 @@ export const importJSON = (data) => fetchAPI('/import-json', {
|
|||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Time Logs
|
// Blockers
|
||||||
export const logTime = (taskId, data) => fetchAPI(`/tasks/${taskId}/time-logs`, {
|
export const getTaskBlockers = (taskId) => fetchAPI(`/tasks/${taskId}/blockers`);
|
||||||
method: 'POST',
|
export const getTaskBlocking = (taskId) => fetchAPI(`/tasks/${taskId}/blocking`);
|
||||||
body: JSON.stringify(data),
|
export const addBlocker = (taskId, blockerId) => fetchAPI(`/tasks/${taskId}/blockers/${blockerId}`, { method: 'POST' });
|
||||||
});
|
export const removeBlocker = (taskId, blockerId) => fetchAPI(`/tasks/${taskId}/blockers/${blockerId}`, { method: 'DELETE' });
|
||||||
export const getTimeLogs = (taskId) => fetchAPI(`/tasks/${taskId}/time-logs`);
|
|
||||||
export const getProjectTimeSummary = (projectId) => fetchAPI(`/projects/${projectId}/time-summary`);
|
// Actionable tasks (no incomplete blockers, not done)
|
||||||
|
export const getActionableTasks = () => fetchAPI('/actionable');
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
export const searchTasks = (query, projectIds = null) => {
|
export const searchTasks = (query, projectIds = null) => {
|
||||||
|
|||||||
@@ -1,511 +0,0 @@
|
|||||||
Break It Down (BIT) — Campaign Mode + Entropy + Wizard Build Spec
|
|
||||||
0) Product idea in one sentence
|
|
||||||
|
|
||||||
BIT is the planning HQ (deep task decomposition). You can “Launch Campaign” on a project to switch into a low-friction operations mode that nudges you, runs timeboxed sessions, captures before/after photos, and visualizes “entropy” creeping back into recurring home zones via a strategy-game-style stability map.
|
|
||||||
|
|
||||||
1) Non-negotiable design principles
|
|
||||||
|
|
||||||
“Start a session” must be 1 tap from Campaign view. No forms before action.
|
|
||||||
|
|
||||||
Entropy features only apply to projects in entropy_space mode; standard projects remain unchanged.
|
|
||||||
|
|
||||||
Planning (Tree/Kanban) and Doing (Campaign) are separate routes and separate UI skins.
|
|
||||||
|
|
||||||
Wizard must generate “good enough” structure in <30 seconds and optionally auto-start the first session.
|
|
||||||
|
|
||||||
2) Core Concepts & Modes
|
|
||||||
2.1 Project Modes (enum)
|
|
||||||
|
|
||||||
standard: current BIT behavior (Tree + Kanban).
|
|
||||||
|
|
||||||
entropy_space: physical-space / recurring maintenance / territory reclaiming.
|
|
||||||
|
|
||||||
(future) pipeline_chores: chores with stages/recurrence (laundry/dishes).
|
|
||||||
|
|
||||||
(future) deep_work: focus sessions (pomodoro defaults).
|
|
||||||
|
|
||||||
2.2 Campaign Types (enum)
|
|
||||||
|
|
||||||
finite: one-off liberation (garage purge). Optional/slow entropy decay.
|
|
||||||
|
|
||||||
background: ongoing maintenance (bathroom/kitchen). Entropy decay enabled.
|
|
||||||
|
|
||||||
2.3 Zone (v1 implementation)
|
|
||||||
|
|
||||||
In entropy_space projects, a Zone is a top-level task (parent_task_id IS NULL).
|
|
||||||
|
|
||||||
2.4 Session (generic), with “Strike” as a label
|
|
||||||
|
|
||||||
Backend stores generic sessions. Campaign UI calls them “strikes” for entropy projects.
|
|
||||||
|
|
||||||
Session kinds (enum):
|
|
||||||
|
|
||||||
strike (entropy_space)
|
|
||||||
|
|
||||||
pomodoro (future deep_work)
|
|
||||||
|
|
||||||
run (future pipeline chores)
|
|
||||||
|
|
||||||
freeform (optional)
|
|
||||||
|
|
||||||
3) User Flows
|
|
||||||
3.1 HQ Planning Flow (existing, with additions)
|
|
||||||
|
|
||||||
Create project (standard or via Wizard).
|
|
||||||
|
|
||||||
In entropy projects, create top-level “zones” + subtasks.
|
|
||||||
|
|
||||||
Hit Launch Campaign button → opens Campaign route.
|
|
||||||
|
|
||||||
3.2 Campaign Operations Flow (new)
|
|
||||||
|
|
||||||
Campaign view shows only:
|
|
||||||
|
|
||||||
Zone cards (stability color + bar + last worked)
|
|
||||||
|
|
||||||
Recommended micro-missions
|
|
||||||
|
|
||||||
Big Start Strike button per zone
|
|
||||||
|
|
||||||
Flow:
|
|
||||||
|
|
||||||
Tap “Start Strike” on a Zone (1 tap)
|
|
||||||
|
|
||||||
Timer begins immediately (default length buttons: 8 / 12 / 25 min)
|
|
||||||
|
|
||||||
Optional prompt: “Snap BEFORE?” (default enabled for entropy_space)
|
|
||||||
|
|
||||||
End Strike → prompt “Snap AFTER”
|
|
||||||
|
|
||||||
Immediately show Before/After compare slider (dopamine)
|
|
||||||
|
|
||||||
Save: session record + optional quick note
|
|
||||||
|
|
||||||
Zone stability increases; decay continues over time
|
|
||||||
|
|
||||||
3.3 Wizard Flow (new)
|
|
||||||
|
|
||||||
Goal: generate project + zones + micro-missions + defaults quickly.
|
|
||||||
|
|
||||||
Wizard inputs:
|
|
||||||
|
|
||||||
Area template (Garage / Bathroom / Kitchen / Whole House / Office)
|
|
||||||
|
|
||||||
Campaign type (Finite vs Background)
|
|
||||||
|
|
||||||
Intensity (Light / Normal / Disaster)
|
|
||||||
|
|
||||||
Nudge window (e.g. 9am–9pm) + 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.
|
|
||||||
|
|
||||||
Don’t 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”; 0–100)
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
80–100: green
|
|
||||||
|
|
||||||
55–79: yellow
|
|
||||||
|
|
||||||
30–54: orange
|
|
||||||
|
|
||||||
0–29: 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 don’t 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 1–3 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 1–2)
|
|
||||||
|
|
||||||
“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.”
|
|
||||||
Reference in New Issue
Block a user