feat: unified calendar view and eisenhower matrix view

This commit is contained in:
2026-03-01 22:17:23 +00:00
parent c21cbf5e9b
commit d792f89fe6
12 changed files with 715 additions and 1 deletions

View File

@@ -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
View 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
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)