diff --git a/src/fabledassistant/services/dashboard.py b/src/fabledassistant/services/dashboard.py new file mode 100644 index 0000000..f853b1e --- /dev/null +++ b/src/fabledassistant/services/dashboard.py @@ -0,0 +1,171 @@ +"""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 fabledassistant.models import async_session +from fabledassistant.models.note import Note +from fabledassistant.models.project import Project +from fabledassistant.models.milestone import Milestone +from fabledassistant.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, "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 fabledassistant.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]} diff --git a/tests/test_services_dashboard.py b/tests/test_services_dashboard.py new file mode 100644 index 0000000..e7fbdc8 --- /dev/null +++ b/tests/test_services_dashboard.py @@ -0,0 +1,66 @@ +"""build_dashboard composition + helpers (services/dashboard.py). + +Query semantics (ranking/caps/owner-scope) are exercised by manual smoke — +the repo's unit tests mock the DB, so SQL isn't executed here. These cover the +pure helpers and the section-isolation contract. +""" +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +def test_task_row_maps_fields(): + from fabledassistant.services.dashboard import _task_row + n = MagicMock() + n.id = 5 + n.title = "Wire reminders" + n.status = "in_progress" + n.priority = None + assert _task_row(n) == { + "id": 5, "title": "Wire reminders", "status": "in_progress", "priority": "none", + } + + +@pytest.mark.asyncio +async def test_safe_returns_value_then_empty_on_error(): + from fabledassistant.services.dashboard import _safe + + async def ok(): + return [1, 2, 3] + + async def boom(): + raise RuntimeError("section blew up") + + assert await _safe(ok(), []) == [1, 2, 3] + # a failing section returns the supplied empty default, not an exception + assert await _safe(boom(), []) == [] + assert await _safe(boom(), {}) == {} + + +@pytest.mark.asyncio +async def test_build_dashboard_composes_sections(): + import fabledassistant.services.dashboard as dash + with patch.object(dash, "_active_projects", AsyncMock(return_value=["P"])), \ + patch.object(dash, "_recently_completed", AsyncMock(return_value=["done"])), \ + patch.object(dash, "_upcoming_events", AsyncMock(return_value=["evt"])), \ + patch.object(dash, "_week_stats", AsyncMock(return_value={"open_total": 4})): + out = await dash.build_dashboard(user_id=1) + assert out == { + "active_projects": ["P"], + "recently_completed": ["done"], + "upcoming_events": ["evt"], + "week_stats": {"open_total": 4}, + } + + +@pytest.mark.asyncio +async def test_build_dashboard_isolates_failing_section(): + import fabledassistant.services.dashboard as dash + with patch.object(dash, "_active_projects", AsyncMock(side_effect=RuntimeError("db down"))), \ + patch.object(dash, "_recently_completed", AsyncMock(return_value=["done"])), \ + patch.object(dash, "_upcoming_events", AsyncMock(return_value=[])), \ + patch.object(dash, "_week_stats", AsyncMock(return_value={})): + out = await dash.build_dashboard(user_id=1) + # failing section degrades to its empty default; others still populate + assert out["active_projects"] == [] + assert out["recently_completed"] == ["done"]