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 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,
}

View File

@@ -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])

View File

@@ -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")

View File

@@ -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