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 */} +
+ +
+ 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" + /> + hr + 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" + /> + min / week +
+
+ + {/* Total budget */} +
+ +
+ 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" + /> + hr + 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" + /> + min total +
+
+ + {/* Pomodoro settings */} +
+ +
+
+ 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" + /> + min work +
+
+ 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" + /> + min break +
+
+
+
+
{/* 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 */}
+ ) : ( + + )} - ) : ( - - )} - +
+ + {/* Category badge */} + {project.category && ( + + {project.category} + + )} + + {project.description && ( +

{project.description}

+ )} + + {/* Time summary */} + {summary && summary.total_minutes > 0 && ( +
+
+ + {formatTime(summary.total_minutes)} logged total + {summary.weekly_minutes > 0 && ( + ยท {formatTime(summary.weekly_minutes)} this week + )} +
+ + {/* Weekly goal bar */} + {weeklyPct !== null && ( +
+
+ Weekly goal + {weeklyPct}% +
+
+
= 100 ? 'bg-green-500' : 'bg-cyber-orange'}`} + style={{ width: `${weeklyPct}%` }} + /> +
+
+ )} + + {/* Total budget bar */} + {totalPct !== null && ( +
+
+ Total budget + {totalPct}% +
+
+
= 100 ? 'bg-red-500' : 'bg-cyber-orange/60'}`} + style={{ width: `${totalPct}%` }} + /> +
+
+ )} +
+ )} + +

+ Created {new Date(project.created_at).toLocaleDateString()} +

- {project.description && ( -

{project.description}

- )} -

- Created {new Date(project.created_at).toLocaleDateString()} -

-
- ))} + ) + })} )} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 5589ee2..a6e74f5 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -66,6 +66,14 @@ export const importJSON = (data) => fetchAPI('/import-json', { body: JSON.stringify(data), }); +// Time Logs +export const logTime = (taskId, data) => fetchAPI(`/tasks/${taskId}/time-logs`, { + method: 'POST', + body: JSON.stringify(data), +}); +export const getTimeLogs = (taskId) => fetchAPI(`/tasks/${taskId}/time-logs`); +export const getProjectTimeSummary = (projectId) => fetchAPI(`/projects/${projectId}/time-summary`); + // Search export const searchTasks = (query, projectIds = null) => { const params = new URLSearchParams({ query });