212 lines
7.1 KiB
Python
212 lines
7.1 KiB
Python
"""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 = 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)
|