Initial commit

This commit is contained in:
2026-03-03 00:44:33 +00:00
commit 5297da485f
126 changed files with 54767 additions and 0 deletions

189
routers/time_budgets.py Normal file
View 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)