"""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", })