from fastapi import APIRouter, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import false as sa_false, select, func, or_, case from datetime import datetime, date, timedelta from typing import Optional, List, Dict, Any from app.database import get_db from app.models.settings import Settings from app.models.todo import Todo from app.models.calendar_event import CalendarEvent from app.models.reminder import Reminder from app.models.project import Project from app.models.user import User from app.routers.auth import get_current_user, get_current_settings from app.models.event_invitation import EventInvitation from app.services.calendar_sharing import get_accessible_calendar_ids, get_accessible_event_scope router = APIRouter() # Reusable filter: exclude parent template events (they have a real recurrence_rule). # Some legacy events have "" instead of NULL, so allow both. _not_parent_template = or_( CalendarEvent.recurrence_rule == None, CalendarEvent.recurrence_rule == "", ) @router.get("/dashboard") async def get_dashboard( client_date: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), current_settings: Settings = Depends(get_current_settings), ): """Get aggregated dashboard data.""" today = client_date or date.today() upcoming_cutoff = today + timedelta(days=current_settings.upcoming_days) # Fetch all accessible calendar IDs + invited event IDs user_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db) # Today's events (exclude parent templates — they are hidden, children are shown) today_start = datetime.combine(today, datetime.min.time()) today_end = datetime.combine(today, datetime.max.time()) events_query = select(CalendarEvent).where( or_( CalendarEvent.calendar_id.in_(user_calendar_ids), CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(), CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(), ), CalendarEvent.start_datetime >= today_start, CalendarEvent.start_datetime <= today_end, _not_parent_template, ) events_result = await db.execute(events_query) todays_events = events_result.scalars().all() # Build invitation lookup for today's events invited_event_id_set = set(invited_event_ids) today_inv_map: dict[int, tuple[str, int | None]] = {} today_event_ids = [e.id for e in todays_events] parent_ids_in_today = [e.parent_event_id for e in todays_events if e.parent_event_id and e.parent_event_id in invited_event_id_set] inv_lookup_ids = list(set(today_event_ids + parent_ids_in_today) & invited_event_id_set) if inv_lookup_ids: inv_result = await db.execute( select(EventInvitation.event_id, EventInvitation.status, EventInvitation.display_calendar_id).where( EventInvitation.user_id == current_user.id, EventInvitation.event_id.in_(inv_lookup_ids), ) ) for eid, status, disp_cal_id in inv_result.all(): today_inv_map[eid] = (status, disp_cal_id) # Upcoming todos (not completed, with due date from today through upcoming_days) todos_query = select(Todo).where( Todo.user_id == current_user.id, Todo.completed == False, Todo.due_date.isnot(None), Todo.due_date >= today, Todo.due_date <= upcoming_cutoff ).order_by(Todo.due_date.asc()) todos_result = await db.execute(todos_query) upcoming_todos = todos_result.scalars().all() # Active reminders (not dismissed, is_active = true, from today onward) reminders_query = select(Reminder).where( Reminder.user_id == current_user.id, Reminder.is_active == True, Reminder.is_dismissed == False, Reminder.remind_at >= today_start ).order_by(Reminder.remind_at.asc()) reminders_result = await db.execute(reminders_query) active_reminders = reminders_result.scalars().all() # Project stats — single GROUP BY query, derive total in Python projects_by_status_result = await db.execute( select( Project.status, func.count(Project.id).label("count"), ).where(Project.user_id == current_user.id).group_by(Project.status) ) projects_by_status = {row[0]: row[1] for row in projects_by_status_result} total_projects = sum(projects_by_status.values()) # Todo counts: total and incomplete in a single query todo_counts_result = await db.execute( select( func.count(Todo.id).label("total"), func.count(case((Todo.completed == False, Todo.id))).label("incomplete"), ).where(Todo.user_id == current_user.id) ) todo_row = todo_counts_result.one() total_todos = todo_row.total total_incomplete_todos = todo_row.incomplete # Starred events — no upper date bound so future events always appear in countdown. # _not_parent_template excludes recurring parent templates (children still show). starred_query = select(CalendarEvent).where( or_( CalendarEvent.calendar_id.in_(user_calendar_ids), CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(), CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(), ), CalendarEvent.is_starred == True, CalendarEvent.start_datetime > today_start, _not_parent_template, ).order_by(CalendarEvent.start_datetime.asc()).limit(5) starred_result = await db.execute(starred_query) starred_events = starred_result.scalars().all() starred_events_data = [ { "id": e.id, "title": e.title, "start_datetime": e.start_datetime } for e in starred_events ] return { "todays_events": [ { "id": event.id, "title": event.title, "start_datetime": event.start_datetime, "end_datetime": event.end_datetime, "all_day": event.all_day, "color": event.color, "is_starred": event.is_starred, "is_invited": (event.parent_event_id or event.id) in invited_event_id_set, "invitation_status": today_inv_map.get(event.parent_event_id or event.id, (None,))[0], "display_calendar_id": today_inv_map.get(event.parent_event_id or event.id, (None, None))[1], } for event in todays_events ], "upcoming_todos": [ { "id": todo.id, "title": todo.title, "due_date": todo.due_date, "priority": todo.priority, "category": todo.category } for todo in upcoming_todos ], "active_reminders": [ { "id": reminder.id, "title": reminder.title, "remind_at": reminder.remind_at } for reminder in active_reminders ], "project_stats": { "total": total_projects, "by_status": projects_by_status }, "total_incomplete_todos": total_incomplete_todos, "total_todos": total_todos, "starred_events": starred_events_data } @router.get("/upcoming") async def get_upcoming( days: int = Query(default=7, ge=1, le=90), client_date: Optional[date] = Query(None, ge=date(2020, 1, 1), le=date(2099, 12, 31)), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), current_settings: Settings = Depends(get_current_settings), ): """Get unified list of upcoming items (todos, events, reminders) sorted by date.""" today = client_date or date.today() cutoff_date = today + timedelta(days=days) cutoff_datetime = datetime.combine(cutoff_date, datetime.max.time()) today_start = datetime.combine(today, datetime.min.time()) overdue_floor = today - timedelta(days=30) overdue_floor_dt = datetime.combine(overdue_floor, datetime.min.time()) # Fetch all accessible calendar IDs + invited event IDs user_calendar_ids, invited_event_ids = await get_accessible_event_scope(current_user.id, db) # Build queries — include overdue todos (up to 30 days back) and snoozed reminders todos_query = select(Todo).where( Todo.user_id == current_user.id, Todo.completed == False, Todo.due_date.isnot(None), Todo.due_date >= overdue_floor, Todo.due_date <= cutoff_date ) events_query = select(CalendarEvent).where( or_( CalendarEvent.calendar_id.in_(user_calendar_ids), CalendarEvent.id.in_(invited_event_ids) if invited_event_ids else sa_false(), CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else sa_false(), ), CalendarEvent.start_datetime >= today_start, CalendarEvent.start_datetime <= cutoff_datetime, _not_parent_template, ) reminders_query = select(Reminder).where( Reminder.user_id == current_user.id, Reminder.is_active == True, Reminder.is_dismissed == False, Reminder.remind_at >= overdue_floor_dt, Reminder.remind_at <= cutoff_datetime ) # Execute queries sequentially (single session cannot run concurrent queries) todos_result = await db.execute(todos_query) todos = todos_result.scalars().all() events_result = await db.execute(events_query) events = events_result.scalars().all() reminders_result = await db.execute(reminders_query) reminders = reminders_result.scalars().all() # Build invitation lookup for upcoming events invited_event_id_set_up = set(invited_event_ids) upcoming_inv_map: dict[int, tuple[str, int | None]] = {} up_parent_ids = list({e.parent_event_id or e.id for e in events} & invited_event_id_set_up) if up_parent_ids: up_inv_result = await db.execute( select(EventInvitation.event_id, EventInvitation.status, EventInvitation.display_calendar_id).where( EventInvitation.user_id == current_user.id, EventInvitation.event_id.in_(up_parent_ids), ) ) for eid, status, disp_cal_id in up_inv_result.all(): upcoming_inv_map[eid] = (status, disp_cal_id) # Combine into unified list upcoming_items: List[Dict[str, Any]] = [] for todo in todos: upcoming_items.append({ "type": "todo", "id": todo.id, "title": todo.title, "date": todo.due_date.isoformat() if todo.due_date else None, "datetime": None, "priority": todo.priority, "category": todo.category, "is_overdue": todo.due_date < today if todo.due_date else False, }) for event in events: end_dt = event.end_datetime parent_id = event.parent_event_id or event.id is_inv = parent_id in invited_event_id_set_up upcoming_items.append({ "type": "event", "id": event.id, "title": event.title, "date": event.start_datetime.date().isoformat(), "datetime": event.start_datetime.isoformat(), "end_datetime": end_dt.isoformat() if end_dt else None, "all_day": event.all_day, "color": event.color, "is_starred": event.is_starred, "is_invited": is_inv, "invitation_status": upcoming_inv_map.get(parent_id, (None,))[0] if is_inv else None, "display_calendar_id": upcoming_inv_map.get(parent_id, (None, None))[1] if is_inv else None, }) for reminder in reminders: remind_at_date = reminder.remind_at.date() if reminder.remind_at else None upcoming_items.append({ "type": "reminder", "id": reminder.id, "title": reminder.title, "date": remind_at_date.isoformat() if remind_at_date else None, "datetime": reminder.remind_at.isoformat() if reminder.remind_at else None, "snoozed_until": reminder.snoozed_until.isoformat() if reminder.snoozed_until else None, "is_overdue": remind_at_date < today if remind_at_date else False, }) # Sort by date/datetime upcoming_items.sort(key=lambda x: x["datetime"] if x["datetime"] else x["date"]) return { "items": upcoming_items, "days": days, "cutoff_date": cutoff_date.isoformat() }