UMBRA/backend/app/routers/dashboard.py
Kyle Pope bdfd8448b1 Remove upper date bound on starred events so future events always show
Starred events should appear in the countdown widget regardless of how
far in the future they are. The _not_parent_template filter still
excludes recurring parent templates while allowing starred children.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 01:59:11 +08:00

258 lines
9.6 KiB
Python

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
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 (owned + accepted shared memberships)
user_calendar_ids = await get_accessible_calendar_ids(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(
CalendarEvent.calendar_id.in_(user_calendar_ids),
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(
CalendarEvent.calendar_id.in_(user_calendar_ids),
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 (owned + accepted shared memberships)
user_calendar_ids = await get_accessible_calendar_ids(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(
CalendarEvent.calendar_id.in_(user_calendar_ids),
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()
}