feat: unified calendar view and eisenhower matrix view
This commit is contained in:
@@ -34,6 +34,7 @@ TRASH_ENTITIES = [
|
||||
{"table": "daily_focus", "label": "Focus Items", "name_col": "id", "url": "/focus"},
|
||||
{"table": "processes", "label": "Processes", "name_col": "name", "url": "/processes/{id}"},
|
||||
{"table": "process_runs", "label": "Process Runs", "name_col": "title", "url": "/processes/runs/{id}"},
|
||||
{"table": "time_budgets", "label": "Time Budgets", "name_col": "id", "url": "/time-budgets"},
|
||||
]
|
||||
|
||||
|
||||
|
||||
138
routers/calendar.py
Normal file
138
routers/calendar.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Calendar: unified read-only month view of appointments, meetings, and tasks."""
|
||||
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import text
|
||||
from datetime import date, timedelta
|
||||
import calendar
|
||||
|
||||
from core.database import get_db
|
||||
from core.sidebar import get_sidebar_data
|
||||
|
||||
router = APIRouter(prefix="/calendar", tags=["calendar"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def calendar_view(
|
||||
request: Request,
|
||||
year: int = None,
|
||||
month: int = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
today = date.today()
|
||||
year = year or today.year
|
||||
month = month or today.month
|
||||
|
||||
# Clamp to valid range
|
||||
if month < 1 or month > 12:
|
||||
month = today.month
|
||||
if year < 2000 or year > 2100:
|
||||
year = today.year
|
||||
|
||||
first_day = date(year, month, 1)
|
||||
last_day = date(year, month, calendar.monthrange(year, month)[1])
|
||||
|
||||
# Prev/next month
|
||||
prev_month = first_day - timedelta(days=1)
|
||||
next_month = last_day + timedelta(days=1)
|
||||
|
||||
sidebar = await get_sidebar_data(db)
|
||||
|
||||
# Appointments in this month (by start_at)
|
||||
appt_result = await db.execute(text("""
|
||||
SELECT id, title, start_at, end_at, all_day, location
|
||||
FROM appointments
|
||||
WHERE is_deleted = false
|
||||
AND start_at::date >= :first AND start_at::date <= :last
|
||||
ORDER BY start_at
|
||||
"""), {"first": first_day, "last": last_day})
|
||||
appointments = [dict(r._mapping) for r in appt_result]
|
||||
|
||||
# Meetings in this month (by meeting_date)
|
||||
meet_result = await db.execute(text("""
|
||||
SELECT id, title, meeting_date, start_at, location, status
|
||||
FROM meetings
|
||||
WHERE is_deleted = false
|
||||
AND meeting_date >= :first AND meeting_date <= :last
|
||||
ORDER BY meeting_date, start_at
|
||||
"""), {"first": first_day, "last": last_day})
|
||||
meetings = [dict(r._mapping) for r in meet_result]
|
||||
|
||||
# Tasks with due dates in this month (open/in_progress only)
|
||||
task_result = await db.execute(text("""
|
||||
SELECT t.id, t.title, t.due_date, t.priority, t.status,
|
||||
p.name as project_name
|
||||
FROM tasks t
|
||||
LEFT JOIN projects p ON t.project_id = p.id
|
||||
WHERE t.is_deleted = false
|
||||
AND t.status IN ('open', 'in_progress')
|
||||
AND t.due_date >= :first AND t.due_date <= :last
|
||||
ORDER BY t.due_date, t.priority
|
||||
"""), {"first": first_day, "last": last_day})
|
||||
tasks = [dict(r._mapping) for r in task_result]
|
||||
|
||||
# Build day-indexed event map
|
||||
days_map = {}
|
||||
for a in appointments:
|
||||
day = a["start_at"].date().day if a["start_at"] else None
|
||||
if day:
|
||||
days_map.setdefault(day, []).append({
|
||||
"type": "appointment",
|
||||
"id": a["id"],
|
||||
"title": a["title"],
|
||||
"time": None if a["all_day"] else a["start_at"].strftime("%-I:%M %p"),
|
||||
"url": f"/appointments/{a['id']}",
|
||||
"all_day": a["all_day"],
|
||||
})
|
||||
|
||||
for m in meetings:
|
||||
day = m["meeting_date"].day if m["meeting_date"] else None
|
||||
if day:
|
||||
time_str = m["start_at"].strftime("%-I:%M %p") if m["start_at"] else None
|
||||
days_map.setdefault(day, []).append({
|
||||
"type": "meeting",
|
||||
"id": m["id"],
|
||||
"title": m["title"],
|
||||
"time": time_str,
|
||||
"url": f"/meetings/{m['id']}",
|
||||
"all_day": False,
|
||||
})
|
||||
|
||||
for t in tasks:
|
||||
day = t["due_date"].day if t["due_date"] else None
|
||||
if day:
|
||||
days_map.setdefault(day, []).append({
|
||||
"type": "task",
|
||||
"id": t["id"],
|
||||
"title": t["title"],
|
||||
"time": None,
|
||||
"url": f"/tasks/{t['id']}",
|
||||
"priority": t["priority"],
|
||||
"project_name": t.get("project_name"),
|
||||
"all_day": False,
|
||||
})
|
||||
|
||||
# Build calendar grid (weeks of days)
|
||||
# Monday=0, Sunday=6
|
||||
cal = calendar.Calendar(firstweekday=6) # Sunday start
|
||||
weeks = cal.monthdayscalendar(year, month)
|
||||
|
||||
return templates.TemplateResponse("calendar.html", {
|
||||
"request": request,
|
||||
"sidebar": sidebar,
|
||||
"year": year,
|
||||
"month": month,
|
||||
"month_name": calendar.month_name[month],
|
||||
"weeks": weeks,
|
||||
"days_map": days_map,
|
||||
"today": today,
|
||||
"first_day": first_day,
|
||||
"prev_year": prev_month.year,
|
||||
"prev_month": prev_month.month,
|
||||
"next_year": next_month.year,
|
||||
"next_month": next_month.month,
|
||||
"page_title": f"Calendar - {calendar.month_name[month]} {year}",
|
||||
"active_nav": "calendar",
|
||||
})
|
||||
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