"""Dashboard aggregation — assembles the /dashboard landing payload. One call: most-recently-active projects (each -> active milestones -> open tasks), recently-completed tasks, upcoming events, week stats. Owner-scoped, trashed rows excluded. Each section is independent — a failure returns its empty value rather than blanking the page. """ from __future__ import annotations import logging from datetime import datetime, timedelta, timezone from sqlalchemy import case, func, select from scribe.models import async_session from scribe.models.note import Note from scribe.models.project import Project from scribe.models.milestone import Milestone from scribe.services import milestones as milestones_svc logger = logging.getLogger(__name__) N_PROJECTS = 3 # most-recently-active projects shown TASKS_PER_GROUP = 5 # open-task cap per milestone / no-milestone group RECENT_DONE_LIMIT = 8 # recently-completed tasks shown WINDOW_DAYS = 7 # look-back (done) / look-ahead (events) window _OPEN = ["todo", "in_progress"] def _open_order(): """in-progress first -> priority high..none -> most-recently-updated.""" status_rank = case((Note.status == "in_progress", 0), else_=1) priority_rank = case( (Note.priority == "high", 0), (Note.priority == "medium", 1), (Note.priority == "low", 2), else_=3, ) return status_rank, priority_rank, Note.updated_at.desc() def _task_row(n: Note) -> dict: return {"id": n.id, "title": n.title, "status": n.status, "priority": n.priority or "none"} async def _safe(coro, empty): try: return await coro except Exception: logger.warning("dashboard section failed", exc_info=True) return empty async def build_dashboard(user_id: int) -> dict: return { "active_projects": await _safe(_active_projects(user_id), []), "recently_completed": await _safe(_recently_completed(user_id), []), "upcoming_events": await _safe(_upcoming_events(user_id), []), "week_stats": await _safe(_week_stats(user_id), {}), } async def _active_projects(user_id: int) -> list[dict]: so, po, ro = _open_order() async with async_session() as session: recency = ( select(Note.project_id, func.max(Note.updated_at).label("last")) .where(Note.user_id == user_id, Note.deleted_at.is_(None), Note.project_id.isnot(None)) .group_by(Note.project_id).subquery() ) prows = (await session.execute( select(Project, recency.c.last) .outerjoin(recency, Project.id == recency.c.project_id) .where(Project.user_id == user_id, Project.status == "active", Project.deleted_at.is_(None)) .order_by(func.coalesce(recency.c.last, Project.updated_at).desc()) .limit(N_PROJECTS) )).all() out = [] for project, last in prows: counts = (await session.execute( select( func.count(Note.id).filter(Note.status.in_(_OPEN)), func.count(Note.id).filter(Note.status == "done"), func.count(Note.id).filter(Note.status.in_(_OPEN + ["done"])), ).where(Note.user_id == user_id, Note.project_id == project.id, Note.deleted_at.is_(None)) )).one() open_count, done_count, resolved_total = counts progress_pct = round(done_count / resolved_total * 100, 1) if resolved_total else 0.0 mrows = (await session.execute( select(Milestone).where( Milestone.user_id == user_id, Milestone.project_id == project.id, Milestone.status == "active", Milestone.deleted_at.is_(None)) .order_by(Milestone.order_index.asc()) )).scalars().all() milestones = [] for m in mrows: tasks = (await session.execute( select(Note).where( Note.user_id == user_id, Note.milestone_id == m.id, Note.status.in_(_OPEN), Note.deleted_at.is_(None)) .order_by(so, po, ro).limit(TASKS_PER_GROUP) )).scalars().all() if not tasks: continue prog = await milestones_svc.get_milestone_progress(m.id) milestones.append({ "id": m.id, "title": m.title, "progress_pct": prog["pct"], "open_tasks": [_task_row(t) for t in tasks], }) no_ms = (await session.execute( select(Note).where( Note.user_id == user_id, Note.project_id == project.id, Note.milestone_id.is_(None), Note.status.in_(_OPEN), Note.deleted_at.is_(None)) .order_by(so, po, ro).limit(TASKS_PER_GROUP) )).scalars().all() out.append({ "id": project.id, "title": project.title, "color": project.color, "last_activity": (last or project.updated_at).isoformat(), "open_count": open_count, "done_count": done_count, "progress_pct": progress_pct, "milestones": milestones, "no_milestone": [_task_row(t) for t in no_ms], }) return out async def _recently_completed(user_id: int) -> list[dict]: cutoff = datetime.now(timezone.utc) - timedelta(days=WINDOW_DAYS) async with async_session() as session: rows = (await session.execute( select(Note, Project.title) .outerjoin(Project, Note.project_id == Project.id) .where(Note.user_id == user_id, Note.status == "done", Note.deleted_at.is_(None), Note.completed_at.isnot(None), Note.completed_at >= cutoff) .order_by(Note.completed_at.desc()).limit(RECENT_DONE_LIMIT) )).all() return [{"id": n.id, "title": n.title, "project_title": ptitle, "completed_at": n.completed_at.isoformat()} for n, ptitle in rows] async def _upcoming_events(user_id: int) -> list[dict]: from scribe.services import events as events_svc now = datetime.now(timezone.utc) rows = await events_svc.list_events(user_id, now, now + timedelta(days=WINDOW_DAYS)) out = [] for e in rows: d = e if isinstance(e, dict) else e.to_dict() out.append({"id": d["id"], "title": d["title"], "start_dt": d.get("start_dt"), "all_day": d.get("all_day", False)}) return out async def _week_stats(user_id: int) -> dict: cutoff = datetime.now(timezone.utc) - timedelta(days=WINDOW_DAYS) async with async_session() as session: row = (await session.execute( select( func.count(Note.id).filter(Note.status == "done", Note.completed_at >= cutoff), func.count(Note.id).filter(Note.status.in_(_OPEN)), func.count(Note.id).filter(Note.status == "in_progress"), func.count(Note.id).filter(Note.task_kind == "plan", Note.status.in_(_OPEN)), ).where(Note.user_id == user_id, Note.deleted_at.is_(None)) )).one() return {"completed_this_week": row[0], "open_total": row[1], "in_progress": row[2], "active_plans": row[3]}