2 Commits

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

4
.gitignore vendored
View File

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

244
SYSTEMSPEC_v0.2.0.md Normal file
View File

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

View File

@@ -1,5 +1,7 @@
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
@@ -186,3 +188,66 @@ def get_tasks_by_status(db: Session, project_id: int, status: str) -> List[model
models.Task.project_id == project_id, models.Task.project_id == project_id,
models.Task.status == status models.Task.status == status
).all() ).all()
# TimeLog CRUD
def create_time_log(db: Session, task_id: int, time_log: schemas.TimeLogCreate) -> models.TimeLog:
db_log = models.TimeLog(
task_id=task_id,
minutes=time_log.minutes,
note=time_log.note,
session_type=time_log.session_type,
)
db.add(db_log)
db.commit()
db.refresh(db_log)
return db_log
def get_time_logs_by_task(db: Session, task_id: int) -> List[models.TimeLog]:
return db.query(models.TimeLog).filter(
models.TimeLog.task_id == task_id
).order_by(models.TimeLog.logged_at.desc()).all()
def get_project_time_summary(db: Session, project_id: int) -> dict:
"""Aggregate time logged across all tasks in a project"""
project = get_project(db, project_id)
# Get all task IDs in this project
task_ids = db.query(models.Task.id).filter(
models.Task.project_id == project_id
).subquery()
# Total minutes logged
total = db.query(func.sum(models.TimeLog.minutes)).filter(
models.TimeLog.task_id.in_(task_ids)
).scalar() or 0
# Pomodoro minutes
pomodoro = db.query(func.sum(models.TimeLog.minutes)).filter(
models.TimeLog.task_id.in_(task_ids),
models.TimeLog.session_type == "pomodoro"
).scalar() or 0
# Manual minutes
manual = db.query(func.sum(models.TimeLog.minutes)).filter(
models.TimeLog.task_id.in_(task_ids),
models.TimeLog.session_type == "manual"
).scalar() or 0
# Weekly minutes (past 7 days)
week_ago = datetime.utcnow() - timedelta(days=7)
weekly = db.query(func.sum(models.TimeLog.minutes)).filter(
models.TimeLog.task_id.in_(task_ids),
models.TimeLog.logged_at >= week_ago
).scalar() or 0
return {
"total_minutes": total,
"pomodoro_minutes": pomodoro,
"manual_minutes": manual,
"weekly_minutes": weekly,
"weekly_hours_goal": project.weekly_hours_goal if project else None,
"total_hours_goal": project.total_hours_goal if project else None,
}

View File

@@ -159,6 +159,32 @@ def delete_task(task_id: int, db: Session = Depends(get_db)):
return None return None
# ========== TIME LOG ENDPOINTS ==========
@app.post("/api/tasks/{task_id}/time-logs", response_model=schemas.TimeLog, status_code=201)
def log_time(task_id: int, time_log: schemas.TimeLogCreate, db: Session = Depends(get_db)):
"""Log time spent on a task"""
if not crud.get_task(db, task_id):
raise HTTPException(status_code=404, detail="Task not found")
return crud.create_time_log(db, task_id, time_log)
@app.get("/api/tasks/{task_id}/time-logs", response_model=List[schemas.TimeLog])
def get_time_logs(task_id: int, db: Session = Depends(get_db)):
"""Get all time logs for a task"""
if not crud.get_task(db, task_id):
raise HTTPException(status_code=404, detail="Task not found")
return crud.get_time_logs_by_task(db, task_id)
@app.get("/api/projects/{project_id}/time-summary", response_model=schemas.ProjectTimeSummary)
def get_project_time_summary(project_id: int, db: Session = Depends(get_db)):
"""Get aggregated time statistics for a project"""
if not crud.get_project(db, project_id):
raise HTTPException(status_code=404, detail="Project not found")
return crud.get_project_time_summary(db, project_id)
# ========== SEARCH ENDPOINT ========== # ========== SEARCH ENDPOINT ==========
@app.get("/api/search", response_model=List[schemas.Task]) @app.get("/api/search", response_model=List[schemas.Task])

View File

@@ -16,6 +16,11 @@ 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)
@@ -40,3 +45,17 @@ class Task(Base):
project = relationship("Project", back_populates="tasks") project = relationship("Project", back_populates="tasks")
parent = relationship("Task", remote_side=[id], backref="subtasks") parent = relationship("Task", remote_side=[id], backref="subtasks")
time_logs = relationship("TimeLog", back_populates="task", cascade="all, delete-orphan")
class TimeLog(Base):
__tablename__ = "time_logs"
id = Column(Integer, primary_key=True, index=True)
task_id = Column(Integer, ForeignKey("tasks.id"), nullable=False)
minutes = Column(Integer, nullable=False)
note = Column(Text, nullable=True)
session_type = Column(String(50), default="manual") # 'pomodoro' | 'manual'
logged_at = Column(DateTime, default=datetime.utcnow)
task = relationship("Task", back_populates="time_logs")

View File

@@ -54,6 +54,11 @@ 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):
@@ -61,12 +66,22 @@ 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
@@ -105,3 +120,30 @@ class ImportResult(BaseModel):
project_id: int project_id: int
project_name: str project_name: str
tasks_created: int tasks_created: int
# TimeLog Schemas
class TimeLogCreate(BaseModel):
minutes: int
note: Optional[str] = None
session_type: str = "manual" # 'pomodoro' | 'manual'
class TimeLog(BaseModel):
id: int
task_id: int
minutes: int
note: Optional[str] = None
session_type: str
logged_at: datetime
model_config = ConfigDict(from_attributes=True)
class ProjectTimeSummary(BaseModel):
total_minutes: int
pomodoro_minutes: int
manual_minutes: int
weekly_minutes: int
weekly_hours_goal: Optional[int] = None
total_hours_goal: Optional[int] = None

View File

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

View File

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

View File

@@ -2,32 +2,38 @@ import { Routes, Route } from 'react-router-dom'
import ProjectList from './pages/ProjectList' import ProjectList from './pages/ProjectList'
import ProjectView from './pages/ProjectView' import ProjectView from './pages/ProjectView'
import SearchBar from './components/SearchBar' import SearchBar from './components/SearchBar'
import { PomodoroProvider } from './context/PomodoroContext'
import PomodoroWidget from './components/PomodoroWidget'
function App() { function App() {
return ( return (
<div className="min-h-screen bg-cyber-dark"> <PomodoroProvider>
<header className="border-b border-cyber-orange/30 bg-cyber-darkest"> <div className="min-h-screen bg-cyber-dark">
<div className="container mx-auto px-4 py-4"> <header className="border-b border-cyber-orange/30 bg-cyber-darkest">
<div className="flex justify-between items-center"> <div className="container mx-auto px-4 py-4">
<div> <div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-cyber-orange"> <div>
BIT <h1 className="text-2xl font-bold text-cyber-orange">
<span className="ml-3 text-sm text-gray-500">Break It Down</span> BIT
<span className="ml-2 text-xs text-gray-600">v{import.meta.env.VITE_APP_VERSION || '0.1.6'}</span> <span className="ml-3 text-sm text-gray-500">Break It Down</span>
</h1> <span className="ml-2 text-xs text-gray-600">v{import.meta.env.VITE_APP_VERSION || '0.1.6'}</span>
</h1>
</div>
<SearchBar />
</div> </div>
<SearchBar />
</div> </div>
</div> </header>
</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> </Routes>
</main> </main>
</div>
<PomodoroWidget />
</div>
</PomodoroProvider>
) )
} }

View File

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

View File

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

View File

@@ -11,6 +11,23 @@ function ProjectSettings({ project, onClose, onUpdate }) {
const [taskCounts, setTaskCounts] = useState({}) const [taskCounts, setTaskCounts] = useState({})
const [deleteWarning, setDeleteWarning] = useState(null) const [deleteWarning, setDeleteWarning] = useState(null)
// Time & Goals fields
const [category, setCategory] = useState(project.category || '')
const [weeklyGoalHours, setWeeklyGoalHours] = useState(
project.weekly_hours_goal ? Math.floor(project.weekly_hours_goal / 60) : ''
)
const [weeklyGoalMins, setWeeklyGoalMins] = useState(
project.weekly_hours_goal ? project.weekly_hours_goal % 60 : ''
)
const [totalGoalHours, setTotalGoalHours] = useState(
project.total_hours_goal ? Math.floor(project.total_hours_goal / 60) : ''
)
const [totalGoalMins, setTotalGoalMins] = useState(
project.total_hours_goal ? project.total_hours_goal % 60 : ''
)
const [pomodoroWork, setPomodoroWork] = useState(project.pomodoro_work_minutes || 25)
const [pomodoroBreak, setPomodoroBreak] = useState(project.pomodoro_break_minutes || 5)
useEffect(() => { useEffect(() => {
loadTaskCounts() loadTaskCounts()
}, []) }, [])
@@ -120,8 +137,20 @@ function ProjectSettings({ project, onClose, onUpdate }) {
return return
} }
const weeklyMinutes =
(parseInt(weeklyGoalHours) || 0) * 60 + (parseInt(weeklyGoalMins) || 0)
const totalMinutes =
(parseInt(totalGoalHours) || 0) * 60 + (parseInt(totalGoalMins) || 0)
try { try {
await updateProject(project.id, { statuses }) await updateProject(project.id, {
statuses,
category: category.trim() || null,
weekly_hours_goal: weeklyMinutes > 0 ? weeklyMinutes : null,
total_hours_goal: totalMinutes > 0 ? totalMinutes : null,
pomodoro_work_minutes: parseInt(pomodoroWork) || 25,
pomodoro_break_minutes: parseInt(pomodoroBreak) || 5,
})
onUpdate() onUpdate()
onClose() onClose()
} catch (err) { } catch (err) {
@@ -247,6 +276,106 @@ function ProjectSettings({ project, onClose, onUpdate }) {
Add Status Add Status
</button> </button>
</div> </div>
{/* Time & Goals */}
<div className="mt-6 pt-6 border-t border-cyber-orange/20">
<h3 className="text-lg font-semibold text-gray-200 mb-4">Time & Goals</h3>
<div className="space-y-4">
{/* Category */}
<div>
<label className="block text-sm text-gray-400 mb-1">Category</label>
<input
type="text"
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="e.g. home, programming, hobby"
className="w-full px-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
/>
</div>
{/* Weekly goal */}
<div>
<label className="block text-sm text-gray-400 mb-1">Weekly Time Goal</label>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
value={weeklyGoalHours}
onChange={(e) => setWeeklyGoalHours(e.target.value)}
placeholder="0"
className="w-20 px-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
/>
<span className="text-gray-500 text-sm">hr</span>
<input
type="number"
min="0"
max="59"
value={weeklyGoalMins}
onChange={(e) => setWeeklyGoalMins(e.target.value)}
placeholder="0"
className="w-20 px-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
/>
<span className="text-gray-500 text-sm">min / week</span>
</div>
</div>
{/* Total budget */}
<div>
<label className="block text-sm text-gray-400 mb-1">Total Time Budget</label>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
value={totalGoalHours}
onChange={(e) => setTotalGoalHours(e.target.value)}
placeholder="0"
className="w-20 px-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
/>
<span className="text-gray-500 text-sm">hr</span>
<input
type="number"
min="0"
max="59"
value={totalGoalMins}
onChange={(e) => setTotalGoalMins(e.target.value)}
placeholder="0"
className="w-20 px-3 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange"
/>
<span className="text-gray-500 text-sm">min total</span>
</div>
</div>
{/* Pomodoro settings */}
<div>
<label className="block text-sm text-gray-400 mb-1">Pomodoro Settings</label>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<input
type="number"
min="1"
max="120"
value={pomodoroWork}
onChange={(e) => setPomodoroWork(e.target.value)}
className="w-16 px-2 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange text-center"
/>
<span className="text-gray-500 text-sm">min work</span>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
max="60"
value={pomodoroBreak}
onChange={(e) => setPomodoroBreak(e.target.value)}
className="w-16 px-2 py-2 bg-cyber-darker border border-cyber-orange/30 rounded text-gray-100 text-sm focus:outline-none focus:border-cyber-orange text-center"
/>
<span className="text-gray-500 text-sm">min break</span>
</div>
</div>
</div>
</div>
</div>
</div> </div>
{/* Footer */} {/* Footer */}

View File

@@ -6,7 +6,8 @@ import {
Check, Check,
X, X,
Flag, Flag,
Clock Clock,
Timer
} from 'lucide-react' } from 'lucide-react'
import { import {
getProjectTaskTree, getProjectTaskTree,
@@ -17,6 +18,7 @@ import {
import { formatTimeWithTotal } from '../utils/format' import { formatTimeWithTotal } from '../utils/format'
import TaskMenu from './TaskMenu' import TaskMenu from './TaskMenu'
import TaskForm from './TaskForm' import TaskForm from './TaskForm'
import { usePomodoro } from '../context/PomodoroContext'
// Helper to format status label // Helper to format status label
const formatStatusLabel = (status) => { const formatStatusLabel = (status) => {
@@ -44,12 +46,14 @@ const FLAG_COLORS = {
pink: 'bg-pink-500' pink: 'bg-pink-500'
} }
function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses }) { function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses, pomodoroSettings }) {
const [isExpanded, setIsExpanded] = useState(true) const [isExpanded, setIsExpanded] = useState(true)
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false)
const [editTitle, setEditTitle] = useState(task.title) const [editTitle, setEditTitle] = useState(task.title)
const [editStatus, setEditStatus] = useState(task.status) const [editStatus, setEditStatus] = useState(task.status)
const [showAddSubtask, setShowAddSubtask] = useState(false) const [showAddSubtask, setShowAddSubtask] = useState(false)
const { startPomodoro, activeTask, phase } = usePomodoro()
const isActiveTimer = activeTask?.id === task.id && phase !== 'idle'
const hasSubtasks = task.subtasks && task.subtasks.length > 0 const hasSubtasks = task.subtasks && task.subtasks.length > 0
@@ -200,6 +204,20 @@ function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses }) {
{/* Actions */} {/* Actions */}
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation()
startPomodoro(
{ id: task.id, title: task.title, estimatedMinutes: task.estimated_minutes },
pomodoroSettings?.workMinutes || 25,
pomodoroSettings?.breakMinutes || 5
)
}}
className={`transition-colors ${isActiveTimer ? 'text-cyber-orange' : 'text-gray-400 hover:text-cyber-orange'}`}
title={isActiveTimer ? 'Timer running' : 'Start Pomodoro'}
>
<Timer size={16} />
</button>
<button <button
onClick={() => setShowAddSubtask(true)} onClick={() => setShowAddSubtask(true)}
className="text-cyber-orange hover:text-cyber-orange-bright" className="text-cyber-orange hover:text-cyber-orange-bright"
@@ -242,6 +260,7 @@ function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses }) {
onUpdate={onUpdate} onUpdate={onUpdate}
level={level + 1} level={level + 1}
projectStatuses={projectStatuses} projectStatuses={projectStatuses}
pomodoroSettings={pomodoroSettings}
/> />
))} ))}
</div> </div>
@@ -252,6 +271,10 @@ function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses }) {
function TreeView({ projectId, project }) { function TreeView({ projectId, project }) {
const projectStatuses = project?.statuses || ['backlog', 'in_progress', 'on_hold', 'done'] const projectStatuses = project?.statuses || ['backlog', 'in_progress', 'on_hold', 'done']
const pomodoroSettings = {
workMinutes: project?.pomodoro_work_minutes || 25,
breakMinutes: project?.pomodoro_break_minutes || 5,
}
const [tasks, setTasks] = useState([]) const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -338,6 +361,7 @@ function TreeView({ projectId, project }) {
projectId={projectId} projectId={projectId}
onUpdate={loadTasks} onUpdate={loadTasks}
projectStatuses={projectStatuses} projectStatuses={projectStatuses}
pomodoroSettings={pomodoroSettings}
/> />
))} ))}
</div> </div>

View File

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

View File

@@ -1,10 +1,12 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Plus, Upload, Trash2, Archive, ArchiveRestore } from 'lucide-react' import { Plus, Upload, Trash2, Archive, ArchiveRestore, Clock } from 'lucide-react'
import { getProjects, createProject, deleteProject, importJSON, archiveProject, unarchiveProject } from '../utils/api' import { getProjects, createProject, deleteProject, importJSON, archiveProject, unarchiveProject, getProjectTimeSummary } from '../utils/api'
import { formatTime } from '../utils/format'
function ProjectList() { function ProjectList() {
const [projects, setProjects] = useState([]) const [projects, setProjects] = useState([])
const [timeSummaries, setTimeSummaries] = useState({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [showCreateModal, setShowCreateModal] = useState(false) const [showCreateModal, setShowCreateModal] = useState(false)
const [showImportModal, setShowImportModal] = useState(false) const [showImportModal, setShowImportModal] = useState(false)
@@ -13,6 +15,7 @@ function ProjectList() {
const [importJSON_Text, setImportJSONText] = useState('') const [importJSON_Text, setImportJSONText] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [activeTab, setActiveTab] = useState('active') // 'active', 'archived', 'all' const [activeTab, setActiveTab] = useState('active') // 'active', 'archived', 'all'
const [activeCategory, setActiveCategory] = useState(null)
const navigate = useNavigate() const navigate = useNavigate()
useEffect(() => { useEffect(() => {
@@ -25,10 +28,22 @@ function ProjectList() {
let archivedFilter = null let archivedFilter = null
if (activeTab === 'active') archivedFilter = false if (activeTab === 'active') archivedFilter = false
if (activeTab === 'archived') archivedFilter = true if (activeTab === 'archived') archivedFilter = true
// 'all' tab uses null to get all projects
const data = await getProjects(archivedFilter) const data = await getProjects(archivedFilter)
setProjects(data) setProjects(data)
// Fetch time summaries in parallel for all projects
const summaryEntries = await Promise.all(
data.map(async (p) => {
try {
const summary = await getProjectTimeSummary(p.id)
return [p.id, summary]
} catch {
return [p.id, null]
}
})
)
setTimeSummaries(Object.fromEntries(summaryEntries))
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@@ -125,6 +140,39 @@ function ProjectList() {
</div> </div>
</div> </div>
{/* Category filter */}
{(() => {
const categories = [...new Set(projects.map(p => p.category).filter(Boolean))].sort()
if (categories.length === 0) return null
return (
<div className="flex gap-2 flex-wrap mb-4">
<button
onClick={() => setActiveCategory(null)}
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
activeCategory === null
? 'bg-cyber-orange/20 border-cyber-orange text-cyber-orange'
: 'border-cyber-orange/30 text-gray-400 hover:border-cyber-orange/60'
}`}
>
All
</button>
{categories.map(cat => (
<button
key={cat}
onClick={() => setActiveCategory(activeCategory === cat ? null : cat)}
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
activeCategory === cat
? 'bg-cyber-orange/20 border-cyber-orange text-cyber-orange'
: 'border-cyber-orange/30 text-gray-400 hover:border-cyber-orange/60'
}`}
>
{cat}
</button>
))}
</div>
)
})()}
{/* Tabs */} {/* Tabs */}
<div className="flex gap-1 mb-6 border-b border-cyber-orange/20"> <div className="flex gap-1 mb-6 border-b border-cyber-orange/20">
<button <button
@@ -165,71 +213,142 @@ function ProjectList() {
</div> </div>
)} )}
{projects.length === 0 ? ( {(() => {
<div className="text-center py-16 text-gray-500"> const visibleProjects = activeCategory
<p className="text-xl mb-2"> ? projects.filter(p => p.category === activeCategory)
{activeTab === 'archived' ? 'No archived projects' : 'No projects yet'} : projects
</p> if (visibleProjects.length === 0) return (
<p className="text-sm"> <div className="text-center py-16 text-gray-500">
{activeTab === 'archived' <p className="text-xl mb-2">
? 'Archive projects to keep them out of your active workspace' {activeTab === 'archived' ? 'No archived projects' : 'No projects yet'}
: 'Create a new project or import from JSON'} </p>
</p> <p className="text-sm">
</div> {activeTab === 'archived'
) : ( ? 'Archive projects to keep them out of your active workspace'
: 'Create a new project or import from JSON'}
</p>
</div>
)
return null
})()}
{projects.length > 0 && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{projects.map(project => ( {(activeCategory ? projects.filter(p => p.category === activeCategory) : projects).map(project => {
<div const summary = timeSummaries[project.id]
key={project.id} const weeklyPct = summary && summary.weekly_hours_goal
onClick={() => navigate(`/project/${project.id}`)} ? Math.min(100, Math.round((summary.weekly_minutes / summary.weekly_hours_goal) * 100))
className={`p-6 bg-cyber-darkest border rounded-lg hover:border-cyber-orange hover:shadow-cyber transition-all cursor-pointer group ${ : null
project.is_archived const totalPct = summary && summary.total_hours_goal
? 'border-gray-700 opacity-75' ? Math.min(100, Math.round((summary.total_minutes / summary.total_hours_goal) * 100))
: 'border-cyber-orange/30' : null
}`}
> return (
<div className="flex justify-between items-start mb-2"> <div
<h3 className="text-xl font-semibold text-gray-100 group-hover:text-cyber-orange transition-colors"> key={project.id}
{project.name} onClick={() => navigate(`/project/${project.id}`)}
{project.is_archived && ( className={`p-6 bg-cyber-darkest border rounded-lg hover:border-cyber-orange hover:shadow-cyber transition-all cursor-pointer group ${
<span className="ml-2 text-xs text-gray-500">(archived)</span> project.is_archived
)} ? 'border-gray-700 opacity-75'
</h3> : 'border-cyber-orange/30'
<div className="flex gap-2"> }`}
{project.is_archived ? ( >
<div className="flex justify-between items-start mb-2">
<h3 className="text-xl font-semibold text-gray-100 group-hover:text-cyber-orange transition-colors">
{project.name}
{project.is_archived && (
<span className="ml-2 text-xs text-gray-500">(archived)</span>
)}
</h3>
<div className="flex gap-2">
{project.is_archived ? (
<button
onClick={(e) => handleUnarchiveProject(project.id, e)}
className="text-gray-600 hover:text-cyber-orange transition-colors"
title="Unarchive project"
>
<ArchiveRestore size={18} />
</button>
) : (
<button
onClick={(e) => handleArchiveProject(project.id, e)}
className="text-gray-600 hover:text-yellow-400 transition-colors"
title="Archive project"
>
<Archive size={18} />
</button>
)}
<button <button
onClick={(e) => handleUnarchiveProject(project.id, e)} onClick={(e) => handleDeleteProject(project.id, e)}
className="text-gray-600 hover:text-cyber-orange transition-colors" className="text-gray-600 hover:text-red-400 transition-colors"
title="Unarchive project" title="Delete project"
> >
<ArchiveRestore size={18} /> <Trash2 size={18} />
</button> </button>
) : ( </div>
<button
onClick={(e) => handleArchiveProject(project.id, e)}
className="text-gray-600 hover:text-yellow-400 transition-colors"
title="Archive project"
>
<Archive size={18} />
</button>
)}
<button
onClick={(e) => handleDeleteProject(project.id, e)}
className="text-gray-600 hover:text-red-400 transition-colors"
title="Delete project"
>
<Trash2 size={18} />
</button>
</div> </div>
{/* Category badge */}
{project.category && (
<span className="inline-block px-2 py-0.5 text-xs bg-cyber-orange/10 text-cyber-orange/70 border border-cyber-orange/20 rounded-full mb-2">
{project.category}
</span>
)}
{project.description && (
<p className="text-gray-400 text-sm line-clamp-2">{project.description}</p>
)}
{/* Time summary */}
{summary && summary.total_minutes > 0 && (
<div className="mt-3 space-y-2">
<div className="flex items-center gap-2 text-xs text-gray-500">
<Clock size={11} />
<span>{formatTime(summary.total_minutes)} logged total</span>
{summary.weekly_minutes > 0 && (
<span className="text-gray-600">· {formatTime(summary.weekly_minutes)} this week</span>
)}
</div>
{/* Weekly goal bar */}
{weeklyPct !== null && (
<div>
<div className="flex justify-between text-xs text-gray-600 mb-0.5">
<span>Weekly goal</span>
<span>{weeklyPct}%</span>
</div>
<div className="h-1.5 bg-cyber-darker rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${weeklyPct >= 100 ? 'bg-green-500' : 'bg-cyber-orange'}`}
style={{ width: `${weeklyPct}%` }}
/>
</div>
</div>
)}
{/* Total budget bar */}
{totalPct !== null && (
<div>
<div className="flex justify-between text-xs text-gray-600 mb-0.5">
<span>Total budget</span>
<span>{totalPct}%</span>
</div>
<div className="h-1.5 bg-cyber-darker rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${totalPct >= 100 ? 'bg-red-500' : 'bg-cyber-orange/60'}`}
style={{ width: `${totalPct}%` }}
/>
</div>
</div>
)}
</div>
)}
<p className="text-xs text-gray-600 mt-3">
Created {new Date(project.created_at).toLocaleDateString()}
</p>
</div> </div>
{project.description && ( )
<p className="text-gray-400 text-sm line-clamp-2">{project.description}</p> })}
)}
<p className="text-xs text-gray-600 mt-3">
Created {new Date(project.created_at).toLocaleDateString()}
</p>
</div>
))}
</div> </div>
)} )}

View File

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

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

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