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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
42
backend/migrate_add_project_goals.py
Normal file
42
backend/migrate_add_project_goals.py
Normal 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()
|
||||
34
backend/migrate_add_time_logs.py
Normal file
34
backend/migrate_add_time_logs.py
Normal 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()
|
||||
Reference in New Issue
Block a user