"""Time Budgets: weekly hour allocations per domain with actual vs budgeted comparison.""" from fastapi import APIRouter, Request, Depends, Form from fastapi.templating import Jinja2Templates from fastapi.responses import RedirectResponse from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import text from typing import Optional from core.database import get_db from core.base_repository import BaseRepository from core.sidebar import get_sidebar_data router = APIRouter(prefix="/time-budgets", tags=["time_budgets"]) templates = Jinja2Templates(directory="templates") @router.get("/") async def list_time_budgets( request: Request, db: AsyncSession = Depends(get_db), ): sidebar = await get_sidebar_data(db) # Get current budget per domain (most recent effective_from <= today) result = await db.execute(text(""" SELECT DISTINCT ON (tb.domain_id) tb.*, d.name as domain_name, d.color as domain_color FROM time_budgets tb JOIN domains d ON tb.domain_id = d.id WHERE tb.is_deleted = false AND d.is_deleted = false AND tb.effective_from <= CURRENT_DATE ORDER BY tb.domain_id, tb.effective_from DESC """)) current_budgets = [dict(r._mapping) for r in result] # Get actual hours per domain this week (Mon-Sun) result = await db.execute(text(""" SELECT t.domain_id, COALESCE(SUM( CASE WHEN te.duration_minutes IS NOT NULL THEN te.duration_minutes WHEN te.end_at IS NOT NULL THEN EXTRACT(EPOCH FROM (te.end_at - te.start_at)) / 60 ELSE 0 END ), 0) / 60.0 as actual_hours FROM time_entries te JOIN tasks t ON te.task_id = t.id WHERE te.is_deleted = false AND te.start_at >= date_trunc('week', CURRENT_DATE) AND te.start_at < date_trunc('week', CURRENT_DATE) + INTERVAL '7 days' AND t.domain_id IS NOT NULL GROUP BY t.domain_id """)) actual_map = {str(r._mapping["domain_id"]): float(r._mapping["actual_hours"]) for r in result} # Attach actual hours to budgets for b in current_budgets: b["actual_hours"] = round(actual_map.get(str(b["domain_id"]), 0), 1) b["weekly_hours_float"] = float(b["weekly_hours"]) if b["weekly_hours_float"] > 0: b["pct"] = round(b["actual_hours"] / b["weekly_hours_float"] * 100) else: b["pct"] = 0 total_budgeted = sum(float(b["weekly_hours"]) for b in current_budgets) overcommitted = total_budgeted > 168 # Also get all budgets (including future / historical) for full list result = await db.execute(text(""" SELECT tb.*, d.name as domain_name, d.color as domain_color FROM time_budgets tb JOIN domains d ON tb.domain_id = d.id WHERE tb.is_deleted = false AND d.is_deleted = false ORDER BY tb.effective_from DESC, d.name """)) all_budgets = [dict(r._mapping) for r in result] return templates.TemplateResponse("time_budgets.html", { "request": request, "sidebar": sidebar, "current_budgets": current_budgets, "all_budgets": all_budgets, "total_budgeted": total_budgeted, "overcommitted": overcommitted, "count": len(all_budgets), "page_title": "Time Budgets", "active_nav": "time_budgets", }) @router.get("/create") async def create_form( request: Request, db: AsyncSession = Depends(get_db), ): sidebar = await get_sidebar_data(db) result = await db.execute(text(""" SELECT id, name, color FROM domains WHERE is_deleted = false ORDER BY sort_order, name """)) domains = [dict(r._mapping) for r in result] return templates.TemplateResponse("time_budgets_form.html", { "request": request, "sidebar": sidebar, "budget": None, "domains": domains, "page_title": "New Time Budget", "active_nav": "time_budgets", }) @router.post("/create") async def create_budget( request: Request, domain_id: str = Form(...), weekly_hours: str = Form(...), effective_from: str = Form(...), db: AsyncSession = Depends(get_db), ): repo = BaseRepository("time_budgets", db) data = { "domain_id": domain_id, "weekly_hours": float(weekly_hours), "effective_from": effective_from, } budget = await repo.create(data) return RedirectResponse(url="/time-budgets", status_code=303) @router.get("/{budget_id}/edit") async def edit_form( request: Request, budget_id: str, db: AsyncSession = Depends(get_db), ): sidebar = await get_sidebar_data(db) repo = BaseRepository("time_budgets", db) budget = await repo.get(budget_id) if not budget: return RedirectResponse(url="/time-budgets", status_code=303) result = await db.execute(text(""" SELECT id, name, color FROM domains WHERE is_deleted = false ORDER BY sort_order, name """)) domains = [dict(r._mapping) for r in result] return templates.TemplateResponse("time_budgets_form.html", { "request": request, "sidebar": sidebar, "budget": budget, "domains": domains, "page_title": "Edit Time Budget", "active_nav": "time_budgets", }) @router.post("/{budget_id}/edit") async def update_budget( request: Request, budget_id: str, domain_id: str = Form(...), weekly_hours: str = Form(...), effective_from: str = Form(...), db: AsyncSession = Depends(get_db), ): repo = BaseRepository("time_budgets", db) data = { "domain_id": domain_id, "weekly_hours": float(weekly_hours), "effective_from": effective_from, } await repo.update(budget_id, data) return RedirectResponse(url="/time-budgets", status_code=303) @router.post("/{budget_id}/delete") async def delete_budget( request: Request, budget_id: str, db: AsyncSession = Depends(get_db), ): repo = BaseRepository("time_budgets", db) await repo.soft_delete(budget_id) return RedirectResponse(url="/time-budgets", status_code=303)