"""Time tracking: start/stop timer per task, manual time entries, time log view.""" from fastapi import APIRouter, Request, Depends, Form, Query from fastapi.templating import Jinja2Templates from fastapi.responses import RedirectResponse, JSONResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import text from typing import Optional from datetime import datetime, timezone from core.database import get_db from core.sidebar import get_sidebar_data router = APIRouter(prefix="/time", tags=["time"]) templates = Jinja2Templates(directory="templates") async def get_running_timer(db: AsyncSession) -> dict | None: """Get the currently running timer (end_at IS NULL), if any.""" result = await db.execute(text(""" SELECT te.*, t.title as task_title, t.id as task_id, p.name as project_name, d.name as domain_name FROM time_entries te JOIN tasks t ON te.task_id = t.id LEFT JOIN projects p ON t.project_id = p.id LEFT JOIN domains d ON t.domain_id = d.id WHERE te.end_at IS NULL AND te.is_deleted = false ORDER BY te.start_at DESC LIMIT 1 """)) row = result.first() return dict(row._mapping) if row else None @router.get("/") async def time_log( request: Request, task_id: Optional[str] = None, days: int = Query(7, ge=1, le=90), db: AsyncSession = Depends(get_db), ): """Time entries log view.""" sidebar = await get_sidebar_data(db) running = await get_running_timer(db) params = {"days": days} task_filter = "" if task_id: task_filter = "AND te.task_id = :task_id" params["task_id"] = task_id result = await db.execute(text(f""" SELECT te.*, t.title as task_title, t.id as task_id, p.name as project_name, d.name as domain_name, d.color as domain_color FROM time_entries te JOIN tasks t ON te.task_id = t.id LEFT JOIN projects p ON t.project_id = p.id LEFT JOIN domains d ON t.domain_id = d.id WHERE te.is_deleted = false AND te.start_at >= CURRENT_DATE - INTERVAL ':days days' {task_filter} ORDER BY te.start_at DESC LIMIT 200 """.replace(":days days", f"{days} days")), params) entries = [dict(r._mapping) for r in result] # Calculate totals total_minutes = sum(e.get("duration_minutes") or 0 for e in entries) # Daily breakdown daily_totals = {} for e in entries: if e.get("start_at"): day = e["start_at"].strftime("%Y-%m-%d") daily_totals[day] = daily_totals.get(day, 0) + (e.get("duration_minutes") or 0) return templates.TemplateResponse("time_entries.html", { "request": request, "sidebar": sidebar, "entries": entries, "running": running, "total_minutes": total_minutes, "daily_totals": daily_totals, "days": days, "task_id": task_id or "", "page_title": "Time Log", "active_nav": "time", }) @router.post("/start") async def start_timer( request: Request, task_id: str = Form(...), db: AsyncSession = Depends(get_db), ): """Start a timer for a task. Auto-stops any running timer first.""" now = datetime.now(timezone.utc) # Stop any currently running timer running = await get_running_timer(db) if running: duration = int((now - running["start_at"]).total_seconds() / 60) await db.execute(text(""" UPDATE time_entries SET end_at = :now, duration_minutes = :dur WHERE id = :id """), {"now": now, "dur": max(duration, 1), "id": str(running["id"])}) # Start new timer await db.execute(text(""" INSERT INTO time_entries (task_id, start_at, is_deleted, created_at) VALUES (:task_id, :now, false, :now) """), {"task_id": task_id, "now": now}) # Redirect back to where they came from referer = request.headers.get("referer", "/tasks") return RedirectResponse(url=referer, status_code=303) @router.post("/stop") async def stop_timer( request: Request, entry_id: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): """Stop the running timer (or a specific entry).""" now = datetime.now(timezone.utc) if entry_id: # Stop specific entry result = await db.execute(text( "SELECT * FROM time_entries WHERE id = :id AND end_at IS NULL" ), {"id": entry_id}) entry = result.first() else: # Stop whatever is running result = await db.execute(text( "SELECT * FROM time_entries WHERE end_at IS NULL AND is_deleted = false ORDER BY start_at DESC LIMIT 1" )) entry = result.first() if entry: entry = dict(entry._mapping) duration = int((now - entry["start_at"]).total_seconds() / 60) await db.execute(text(""" UPDATE time_entries SET end_at = :now, duration_minutes = :dur WHERE id = :id """), {"now": now, "dur": max(duration, 1), "id": str(entry["id"])}) referer = request.headers.get("referer", "/time") return RedirectResponse(url=referer, status_code=303) @router.get("/running") async def running_timer_api(db: AsyncSession = Depends(get_db)): """JSON endpoint for the topbar timer pill to poll.""" running = await get_running_timer(db) if not running: return JSONResponse({"running": False}) elapsed_seconds = int((datetime.now(timezone.utc) - running["start_at"]).total_seconds()) return JSONResponse({ "running": True, "entry_id": str(running["id"]), "task_id": str(running["task_id"]), "task_title": running["task_title"], "project_name": running.get("project_name"), "start_at": running["start_at"].isoformat(), "elapsed_seconds": elapsed_seconds, }) @router.post("/manual") async def manual_entry( request: Request, task_id: str = Form(...), date: str = Form(...), duration_minutes: int = Form(...), notes: Optional[str] = Form(None), db: AsyncSession = Depends(get_db), ): """Add a manual time entry (no start/stop, just duration).""" start_at = datetime.fromisoformat(f"{date}T12:00:00+00:00") await db.execute(text(""" INSERT INTO time_entries (task_id, start_at, end_at, duration_minutes, notes, is_deleted, created_at) VALUES (:task_id, :start_at, :start_at, :dur, :notes, false, now()) """), { "task_id": task_id, "start_at": start_at, "dur": duration_minutes, "notes": notes or None, }) referer = request.headers.get("referer", "/time") return RedirectResponse(url=referer, status_code=303) @router.post("/{entry_id}/delete") async def delete_entry( request: Request, entry_id: str, db: AsyncSession = Depends(get_db), ): # Direct SQL because time_entries has no updated_at column await db.execute(text(""" UPDATE time_entries SET is_deleted = true, deleted_at = now() WHERE id = :id AND is_deleted = false """), {"id": entry_id}) referer = request.headers.get("referer", "/time") return RedirectResponse(url=referer, status_code=303)