from fastapi import APIRouter, Depends, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import 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.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 False, CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else 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() # 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 False, CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else 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 } 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 False, CalendarEvent.parent_event_id.in_(invited_event_ids) if invited_event_ids else 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() # 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 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, }) 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() }