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.
This commit is contained in:
serversdwn
2026-02-18 06:49:04 +00:00
parent c6ed57342c
commit 2ee75f719b
14 changed files with 964 additions and 88 deletions

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 });