diff --git a/backend/app/crud.py b/backend/app/crud.py
index d3d183a..7af414b 100644
--- a/backend/app/crud.py
+++ b/backend/app/crud.py
@@ -1,5 +1,7 @@
from sqlalchemy.orm import Session, joinedload
+from sqlalchemy import func
from typing import List, Optional
+from datetime import datetime, timedelta
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.status == status
).all()
+
+
+# TimeLog CRUD
+def create_time_log(db: Session, task_id: int, time_log: schemas.TimeLogCreate) -> models.TimeLog:
+ db_log = models.TimeLog(
+ task_id=task_id,
+ minutes=time_log.minutes,
+ note=time_log.note,
+ session_type=time_log.session_type,
+ )
+ db.add(db_log)
+ db.commit()
+ db.refresh(db_log)
+ return db_log
+
+
+def get_time_logs_by_task(db: Session, task_id: int) -> List[models.TimeLog]:
+ return db.query(models.TimeLog).filter(
+ models.TimeLog.task_id == task_id
+ ).order_by(models.TimeLog.logged_at.desc()).all()
+
+
+def get_project_time_summary(db: Session, project_id: int) -> dict:
+ """Aggregate time logged across all tasks in a project"""
+ project = get_project(db, project_id)
+
+ # Get all task IDs in this project
+ task_ids = db.query(models.Task.id).filter(
+ models.Task.project_id == project_id
+ ).subquery()
+
+ # Total minutes logged
+ total = db.query(func.sum(models.TimeLog.minutes)).filter(
+ models.TimeLog.task_id.in_(task_ids)
+ ).scalar() or 0
+
+ # Pomodoro minutes
+ pomodoro = db.query(func.sum(models.TimeLog.minutes)).filter(
+ models.TimeLog.task_id.in_(task_ids),
+ models.TimeLog.session_type == "pomodoro"
+ ).scalar() or 0
+
+ # Manual minutes
+ manual = db.query(func.sum(models.TimeLog.minutes)).filter(
+ models.TimeLog.task_id.in_(task_ids),
+ models.TimeLog.session_type == "manual"
+ ).scalar() or 0
+
+ # Weekly minutes (past 7 days)
+ week_ago = datetime.utcnow() - timedelta(days=7)
+ weekly = db.query(func.sum(models.TimeLog.minutes)).filter(
+ models.TimeLog.task_id.in_(task_ids),
+ models.TimeLog.logged_at >= week_ago
+ ).scalar() or 0
+
+ return {
+ "total_minutes": total,
+ "pomodoro_minutes": pomodoro,
+ "manual_minutes": manual,
+ "weekly_minutes": weekly,
+ "weekly_hours_goal": project.weekly_hours_goal if project else None,
+ "total_hours_goal": project.total_hours_goal if project else None,
+ }
diff --git a/backend/app/main.py b/backend/app/main.py
index 19a41e2..5239f15 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -159,6 +159,32 @@ def delete_task(task_id: int, db: Session = Depends(get_db)):
return None
+# ========== TIME LOG ENDPOINTS ==========
+
+@app.post("/api/tasks/{task_id}/time-logs", response_model=schemas.TimeLog, status_code=201)
+def log_time(task_id: int, time_log: schemas.TimeLogCreate, db: Session = Depends(get_db)):
+ """Log time spent on a task"""
+ if not crud.get_task(db, task_id):
+ raise HTTPException(status_code=404, detail="Task not found")
+ return crud.create_time_log(db, task_id, time_log)
+
+
+@app.get("/api/tasks/{task_id}/time-logs", response_model=List[schemas.TimeLog])
+def get_time_logs(task_id: int, db: Session = Depends(get_db)):
+ """Get all time logs for a task"""
+ if not crud.get_task(db, task_id):
+ raise HTTPException(status_code=404, detail="Task not found")
+ return crud.get_time_logs_by_task(db, task_id)
+
+
+@app.get("/api/projects/{project_id}/time-summary", response_model=schemas.ProjectTimeSummary)
+def get_project_time_summary(project_id: int, db: Session = Depends(get_db)):
+ """Get aggregated time statistics for a project"""
+ if not crud.get_project(db, project_id):
+ raise HTTPException(status_code=404, detail="Project not found")
+ return crud.get_project_time_summary(db, project_id)
+
+
# ========== SEARCH ENDPOINT ==========
@app.get("/api/search", response_model=List[schemas.Task])
diff --git a/backend/app/models.py b/backend/app/models.py
index a472d3d..c3b678d 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -16,6 +16,11 @@ class Project(Base):
description = Column(Text, nullable=True)
statuses = Column(JSON, nullable=False, default=DEFAULT_STATUSES)
is_archived = Column(Boolean, default=False, nullable=False)
+ category = Column(String(100), nullable=True)
+ weekly_hours_goal = Column(Integer, nullable=True) # stored in minutes
+ total_hours_goal = Column(Integer, nullable=True) # stored in minutes
+ pomodoro_work_minutes = Column(Integer, nullable=True, default=25)
+ pomodoro_break_minutes = Column(Integer, nullable=True, default=5)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -40,3 +45,17 @@ class Task(Base):
project = relationship("Project", back_populates="tasks")
parent = relationship("Task", remote_side=[id], backref="subtasks")
+ time_logs = relationship("TimeLog", back_populates="task", cascade="all, delete-orphan")
+
+
+class TimeLog(Base):
+ __tablename__ = "time_logs"
+
+ id = Column(Integer, primary_key=True, index=True)
+ task_id = Column(Integer, ForeignKey("tasks.id"), nullable=False)
+ minutes = Column(Integer, nullable=False)
+ note = Column(Text, nullable=True)
+ session_type = Column(String(50), default="manual") # 'pomodoro' | 'manual'
+ logged_at = Column(DateTime, default=datetime.utcnow)
+
+ task = relationship("Task", back_populates="time_logs")
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 417f0a0..47e4b68 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -54,6 +54,11 @@ class ProjectBase(BaseModel):
class ProjectCreate(ProjectBase):
statuses: Optional[List[str]] = None
+ category: Optional[str] = None
+ weekly_hours_goal: Optional[int] = None
+ total_hours_goal: Optional[int] = None
+ pomodoro_work_minutes: Optional[int] = None
+ pomodoro_break_minutes: Optional[int] = None
class ProjectUpdate(BaseModel):
@@ -61,12 +66,22 @@ class ProjectUpdate(BaseModel):
description: Optional[str] = None
statuses: Optional[List[str]] = None
is_archived: Optional[bool] = None
+ category: Optional[str] = None
+ weekly_hours_goal: Optional[int] = None
+ total_hours_goal: Optional[int] = None
+ pomodoro_work_minutes: Optional[int] = None
+ pomodoro_break_minutes: Optional[int] = None
class Project(ProjectBase):
id: int
statuses: List[str]
is_archived: bool
+ category: Optional[str] = None
+ weekly_hours_goal: Optional[int] = None
+ total_hours_goal: Optional[int] = None
+ pomodoro_work_minutes: Optional[int] = None
+ pomodoro_break_minutes: Optional[int] = None
created_at: datetime
updated_at: datetime
@@ -105,3 +120,30 @@ class ImportResult(BaseModel):
project_id: int
project_name: str
tasks_created: int
+
+
+# TimeLog Schemas
+class TimeLogCreate(BaseModel):
+ minutes: int
+ note: Optional[str] = None
+ session_type: str = "manual" # 'pomodoro' | 'manual'
+
+
+class TimeLog(BaseModel):
+ id: int
+ task_id: int
+ minutes: int
+ note: Optional[str] = None
+ session_type: str
+ logged_at: datetime
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class ProjectTimeSummary(BaseModel):
+ total_minutes: int
+ pomodoro_minutes: int
+ manual_minutes: int
+ weekly_minutes: int
+ weekly_hours_goal: Optional[int] = None
+ total_hours_goal: Optional[int] = None
diff --git a/backend/migrate_add_project_goals.py b/backend/migrate_add_project_goals.py
new file mode 100644
index 0000000..194aaf4
--- /dev/null
+++ b/backend/migrate_add_project_goals.py
@@ -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()
diff --git a/backend/migrate_add_time_logs.py b/backend/migrate_add_time_logs.py
new file mode 100644
index 0000000..19333fe
--- /dev/null
+++ b/backend/migrate_add_time_logs.py
@@ -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()
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index ba62abc..4879c76 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -2,32 +2,38 @@ import { Routes, Route } from 'react-router-dom'
import ProjectList from './pages/ProjectList'
import ProjectView from './pages/ProjectView'
import SearchBar from './components/SearchBar'
+import { PomodoroProvider } from './context/PomodoroContext'
+import PomodoroWidget from './components/PomodoroWidget'
function App() {
return (
-
-
-
-
-
-
- BIT
- Break It Down
- v{import.meta.env.VITE_APP_VERSION || '0.1.6'}
-
+
+
+
+
+
+
+
+ BIT
+ Break It Down
+ v{import.meta.env.VITE_APP_VERSION || '0.1.6'}
+
+
+
-
-
-
+
-
-
- } />
- } />
-
-
-
+
+
+ } />
+ } />
+
+
+
+
+
+
)
}
diff --git a/frontend/src/components/KanbanView.jsx b/frontend/src/components/KanbanView.jsx
index e1504cd..52370b8 100644
--- a/frontend/src/components/KanbanView.jsx
+++ b/frontend/src/components/KanbanView.jsx
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
-import { Plus, Check, X, Flag, Clock, ChevronDown, ChevronRight, ChevronsDown, ChevronsUp } from 'lucide-react'
+import { Plus, Check, X, Flag, Clock, ChevronDown, ChevronRight, ChevronsDown, ChevronsUp, Timer } from 'lucide-react'
import {
getProjectTasks,
createTask,
@@ -9,6 +9,7 @@ import {
import { formatTimeWithTotal } from '../utils/format'
import TaskMenu from './TaskMenu'
import TaskForm from './TaskForm'
+import { usePomodoro } from '../context/PomodoroContext'
// Helper to format status label
const formatStatusLabel = (status) => {
@@ -69,10 +70,12 @@ function hasDescendantsInStatus(taskId, allTasks, status) {
return getDescendantsInStatus(taskId, allTasks, status).length > 0
}
-function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatus, expandedCards, setExpandedCards, projectStatuses, projectId }) {
+function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatus, expandedCards, setExpandedCards, projectStatuses, projectId, pomodoroSettings }) {
const [isEditing, setIsEditing] = useState(false)
const [editTitle, setEditTitle] = useState(task.title)
const [showAddSubtask, setShowAddSubtask] = useState(false)
+ const { startPomodoro, activeTask, phase } = usePomodoro()
+ const isActiveTimer = activeTask?.id === task.id && phase !== 'idle'
// Use global expanded state
const isExpanded = expandedCards[task.id] || false
@@ -234,6 +237,20 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
+
@@ -291,7 +309,7 @@ function TaskCard({ task, allTasks, onUpdate, onDragStart, isParent, columnStatu
)
}
-function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOver, expandedCards, setExpandedCards, projectStatuses }) {
+function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOver, expandedCards, setExpandedCards, projectStatuses, pomodoroSettings }) {
const [showAddTask, setShowAddTask] = useState(false)
const handleAddTask = async (taskData) => {
@@ -384,6 +402,7 @@ function KanbanColumn({ status, allTasks, projectId, onUpdate, onDrop, onDragOve
setExpandedCards={setExpandedCards}
projectStatuses={projectStatuses}
projectId={projectId}
+ pomodoroSettings={pomodoroSettings}
/>
)
})}
@@ -400,6 +419,10 @@ function KanbanView({ projectId, project }) {
// Get statuses from project, or use defaults
const statuses = project?.statuses || ['backlog', 'in_progress', 'on_hold', 'done']
+ const pomodoroSettings = {
+ workMinutes: project?.pomodoro_work_minutes || 25,
+ breakMinutes: project?.pomodoro_break_minutes || 5,
+ }
const statusesWithMeta = statuses.map(status => ({
key: status,
label: formatStatusLabel(status),
@@ -507,6 +530,7 @@ function KanbanView({ projectId, project }) {
expandedCards={expandedCards}
setExpandedCards={setExpandedCards}
projectStatuses={statuses}
+ pomodoroSettings={pomodoroSettings}
/>
))}
diff --git a/frontend/src/components/PomodoroWidget.jsx b/frontend/src/components/PomodoroWidget.jsx
new file mode 100644
index 0000000..5e556a3
--- /dev/null
+++ b/frontend/src/components/PomodoroWidget.jsx
@@ -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 (
+
+ {/* Header */}
+
+
+
+ {phaseLabel}
+ {!isBreak && sessionCount > 0 && (
+ #{sessionCount + 1}
+ )}
+
+
+
+
+
+ {/* Task name */}
+ {activeTask && !isBreak && (
+
+
+ {activeTask.title}
+
+
+ )}
+
+ {/* Timer display */}
+
+
+ {formatCountdown(secondsLeft)}
+
+
+
+ {/* Progress bar */}
+
+
+ {/* Controls */}
+
+ {isBreak ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+ {isPaused ? (
+
+ ) : (
+
+ )}
+
+ >
+ )}
+
+
+ {/* Session count dots */}
+ {sessionCount > 0 && (
+
+ {Array.from({ length: Math.min(sessionCount, 8) }).map((_, i) => (
+
+ ))}
+ {sessionCount > 8 && (
+
+{sessionCount - 8}
+ )}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/ProjectSettings.jsx b/frontend/src/components/ProjectSettings.jsx
index ac03370..5bdc3f7 100644
--- a/frontend/src/components/ProjectSettings.jsx
+++ b/frontend/src/components/ProjectSettings.jsx
@@ -11,6 +11,23 @@ function ProjectSettings({ project, onClose, onUpdate }) {
const [taskCounts, setTaskCounts] = useState({})
const [deleteWarning, setDeleteWarning] = useState(null)
+ // Time & Goals fields
+ const [category, setCategory] = useState(project.category || '')
+ const [weeklyGoalHours, setWeeklyGoalHours] = useState(
+ project.weekly_hours_goal ? Math.floor(project.weekly_hours_goal / 60) : ''
+ )
+ const [weeklyGoalMins, setWeeklyGoalMins] = useState(
+ project.weekly_hours_goal ? project.weekly_hours_goal % 60 : ''
+ )
+ const [totalGoalHours, setTotalGoalHours] = useState(
+ project.total_hours_goal ? Math.floor(project.total_hours_goal / 60) : ''
+ )
+ const [totalGoalMins, setTotalGoalMins] = useState(
+ project.total_hours_goal ? project.total_hours_goal % 60 : ''
+ )
+ const [pomodoroWork, setPomodoroWork] = useState(project.pomodoro_work_minutes || 25)
+ const [pomodoroBreak, setPomodoroBreak] = useState(project.pomodoro_break_minutes || 5)
+
useEffect(() => {
loadTaskCounts()
}, [])
@@ -120,8 +137,20 @@ function ProjectSettings({ project, onClose, onUpdate }) {
return
}
+ const weeklyMinutes =
+ (parseInt(weeklyGoalHours) || 0) * 60 + (parseInt(weeklyGoalMins) || 0)
+ const totalMinutes =
+ (parseInt(totalGoalHours) || 0) * 60 + (parseInt(totalGoalMins) || 0)
+
try {
- await updateProject(project.id, { statuses })
+ await updateProject(project.id, {
+ statuses,
+ category: category.trim() || null,
+ weekly_hours_goal: weeklyMinutes > 0 ? weeklyMinutes : null,
+ total_hours_goal: totalMinutes > 0 ? totalMinutes : null,
+ pomodoro_work_minutes: parseInt(pomodoroWork) || 25,
+ pomodoro_break_minutes: parseInt(pomodoroBreak) || 5,
+ })
onUpdate()
onClose()
} catch (err) {
@@ -247,6 +276,106 @@ function ProjectSettings({ project, onClose, onUpdate }) {
Add Status
+
+ {/* Time & Goals */}
+
+
Time & Goals
+
+
+ {/* Category */}
+
+
+ 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"
+ />
+
+
+ {/* Weekly goal */}
+
+
+ {/* Total budget */}
+
+
+ {/* Pomodoro settings */}
+
+
+
{/* Footer */}
diff --git a/frontend/src/components/TreeView.jsx b/frontend/src/components/TreeView.jsx
index 2b608a5..a5b137e 100644
--- a/frontend/src/components/TreeView.jsx
+++ b/frontend/src/components/TreeView.jsx
@@ -6,7 +6,8 @@ import {
Check,
X,
Flag,
- Clock
+ Clock,
+ Timer
} from 'lucide-react'
import {
getProjectTaskTree,
@@ -17,6 +18,7 @@ import {
import { formatTimeWithTotal } from '../utils/format'
import TaskMenu from './TaskMenu'
import TaskForm from './TaskForm'
+import { usePomodoro } from '../context/PomodoroContext'
// Helper to format status label
const formatStatusLabel = (status) => {
@@ -44,12 +46,14 @@ const FLAG_COLORS = {
pink: 'bg-pink-500'
}
-function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses }) {
+function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses, pomodoroSettings }) {
const [isExpanded, setIsExpanded] = useState(true)
const [isEditing, setIsEditing] = useState(false)
const [editTitle, setEditTitle] = useState(task.title)
const [editStatus, setEditStatus] = useState(task.status)
const [showAddSubtask, setShowAddSubtask] = useState(false)
+ const { startPomodoro, activeTask, phase } = usePomodoro()
+ const isActiveTimer = activeTask?.id === task.id && phase !== 'idle'
const hasSubtasks = task.subtasks && task.subtasks.length > 0
@@ -200,6 +204,20 @@ function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses }) {
{/* Actions */}
+
@@ -252,6 +271,10 @@ function TaskNode({ task, projectId, onUpdate, level = 0, projectStatuses }) {
function TreeView({ projectId, project }) {
const projectStatuses = project?.statuses || ['backlog', 'in_progress', 'on_hold', 'done']
+ const pomodoroSettings = {
+ workMinutes: project?.pomodoro_work_minutes || 25,
+ breakMinutes: project?.pomodoro_break_minutes || 5,
+ }
const [tasks, setTasks] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
@@ -338,6 +361,7 @@ function TreeView({ projectId, project }) {
projectId={projectId}
onUpdate={loadTasks}
projectStatuses={projectStatuses}
+ pomodoroSettings={pomodoroSettings}
/>
))}
diff --git a/frontend/src/context/PomodoroContext.jsx b/frontend/src/context/PomodoroContext.jsx
new file mode 100644
index 0000000..1736362
--- /dev/null
+++ b/frontend/src/context/PomodoroContext.jsx
@@ -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 (
+
+ {children}
+
+ )
+}
+
+export function usePomodoro() {
+ const ctx = useContext(PomodoroContext)
+ if (!ctx) throw new Error('usePomodoro must be used inside PomodoroProvider')
+ return ctx
+}
diff --git a/frontend/src/pages/ProjectList.jsx b/frontend/src/pages/ProjectList.jsx
index b4b9b17..772487e 100644
--- a/frontend/src/pages/ProjectList.jsx
+++ b/frontend/src/pages/ProjectList.jsx
@@ -1,10 +1,12 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
-import { Plus, Upload, Trash2, Archive, ArchiveRestore } from 'lucide-react'
-import { getProjects, createProject, deleteProject, importJSON, archiveProject, unarchiveProject } from '../utils/api'
+import { Plus, Upload, Trash2, Archive, ArchiveRestore, Clock } from 'lucide-react'
+import { getProjects, createProject, deleteProject, importJSON, archiveProject, unarchiveProject, getProjectTimeSummary } from '../utils/api'
+import { formatTime } from '../utils/format'
function ProjectList() {
const [projects, setProjects] = useState([])
+ const [timeSummaries, setTimeSummaries] = useState({})
const [loading, setLoading] = useState(true)
const [showCreateModal, setShowCreateModal] = useState(false)
const [showImportModal, setShowImportModal] = useState(false)
@@ -13,6 +15,7 @@ function ProjectList() {
const [importJSON_Text, setImportJSONText] = useState('')
const [error, setError] = useState('')
const [activeTab, setActiveTab] = useState('active') // 'active', 'archived', 'all'
+ const [activeCategory, setActiveCategory] = useState(null)
const navigate = useNavigate()
useEffect(() => {
@@ -25,10 +28,22 @@ function ProjectList() {
let archivedFilter = null
if (activeTab === 'active') archivedFilter = false
if (activeTab === 'archived') archivedFilter = true
- // 'all' tab uses null to get all projects
const data = await getProjects(archivedFilter)
setProjects(data)
+
+ // Fetch time summaries in parallel for all projects
+ const summaryEntries = await Promise.all(
+ data.map(async (p) => {
+ try {
+ const summary = await getProjectTimeSummary(p.id)
+ return [p.id, summary]
+ } catch {
+ return [p.id, null]
+ }
+ })
+ )
+ setTimeSummaries(Object.fromEntries(summaryEntries))
} catch (err) {
setError(err.message)
} finally {
@@ -125,6 +140,39 @@ function ProjectList() {
+ {/* Category filter */}
+ {(() => {
+ const categories = [...new Set(projects.map(p => p.category).filter(Boolean))].sort()
+ if (categories.length === 0) return null
+ return (
+
+
+ {categories.map(cat => (
+
+ ))}
+
+ )
+ })()}
+
{/* Tabs */}