feat: unified calendar view and eisenhower matrix view
This commit is contained in:
189
routers/time_budgets.py
Normal file
189
routers/time_budgets.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user