From 87f1d176993ce6992627bc83953fab9315077327 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sun, 15 Mar 2026 15:12:19 -0400 Subject: [PATCH] feat: add sidebar stats endpoint with daily stats and activity feed Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/endpoints/sidebar.py | 177 +++++++++++++++++++++++++++ backend/app/api/router.py | 3 +- 2 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/endpoints/sidebar.py diff --git a/backend/app/api/endpoints/sidebar.py b/backend/app/api/endpoints/sidebar.py new file mode 100644 index 00000000..f3d5fdff --- /dev/null +++ b/backend/app/api/endpoints/sidebar.py @@ -0,0 +1,177 @@ +"""Sidebar stats and activity feed endpoint.""" + +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, select, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.api.deps import get_current_active_user +from app.models.session import Session +from app.models.tree import Tree +from app.models.user import User +from app.schemas.sidebar import ( + SidebarActiveSession, + SidebarRecentSession, + SidebarStatsResponse, + SidebarTreeCounts, +) + +router = APIRouter(prefix="/sessions", tags=["sidebar"]) + + +@router.get("/sidebar-stats", response_model=SidebarStatsResponse) +async def get_sidebar_stats( + tz_offset: int = Query(..., description="Client UTC offset in minutes (e.g. -300 for EST)"), + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +) -> SidebarStatsResponse: + """Get sidebar stats and activity feed for the current user. + + Returns daily stats (resolved count, active count, time in session) + and lists of active + recently completed sessions. + """ + # Compute "today" start in user's timezone, then convert to UTC + now_utc = datetime.now(timezone.utc) + user_tz = timezone(timedelta(minutes=-tz_offset)) + now_local = now_utc.astimezone(user_tz) + today_start_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0) + today_start_utc = today_start_local.astimezone(timezone.utc) + + user_filter = Session.user_id == current_user.id + + # --- Resolved today --- + resolved_result = await db.execute( + select(func.count()).where( + and_( + user_filter, + Session.completed_at >= today_start_utc, + Session.outcome == "resolved", + ) + ) + ) + resolved_today = resolved_result.scalar() or 0 + + # --- Active count (all time, not just today) --- + active_result = await db.execute( + select(func.count()).where( + and_( + user_filter, + Session.started_at.isnot(None), + Session.completed_at.is_(None), + ) + ) + ) + active_count = active_result.scalar() or 0 + + # --- Total session minutes today --- + duration_expr = func.extract( + "epoch", + func.coalesce(Session.completed_at, now_utc) - Session.started_at, + ) / 60.0 + duration_result = await db.execute( + select(func.coalesce(func.sum(duration_expr), 0)).where( + and_( + user_filter, + Session.started_at.isnot(None), + Session.started_at >= today_start_utc, + ) + ) + ) + total_minutes = int(duration_result.scalar() or 0) + + # --- Active sessions (max 5, most recent first) --- + active_sessions_result = await db.execute( + select( + Session.id, + Session.tree_id, + Session.started_at, + Session.ticket_number, + Session.psa_ticket_id, + Session.tree_snapshot["name"].as_string().label("tree_name"), + Session.tree_snapshot["tree_type"].as_string().label("tree_type"), + ) + .where( + and_( + user_filter, + Session.started_at.isnot(None), + Session.completed_at.is_(None), + ) + ) + .order_by(Session.started_at.desc()) + .limit(5) + ) + active_sessions = [ + SidebarActiveSession( + session_id=row.id, + tree_name=row.tree_name or "Unknown Flow", + tree_id=row.tree_id, + tree_type=row.tree_type or "troubleshooting", + started_at=row.started_at, + ticket_number=row.ticket_number, + psa_ticket_id=row.psa_ticket_id, + ) + for row in active_sessions_result.all() + ] + + # --- Recent completions (max 3, completed today, most recent first) --- + recent_result = await db.execute( + select( + Session.id, + Session.tree_id, + Session.completed_at, + Session.tree_snapshot["name"].as_string().label("tree_name"), + Session.tree_snapshot["tree_type"].as_string().label("tree_type"), + ) + .where( + and_( + user_filter, + Session.completed_at.isnot(None), + Session.completed_at >= today_start_utc, + ) + ) + .order_by(Session.completed_at.desc()) + .limit(3) + ) + recent_completions = [ + SidebarRecentSession( + session_id=row.id, + tree_name=row.tree_name or "Unknown Flow", + tree_id=row.tree_id, + tree_type=row.tree_type or "troubleshooting", + completed_at=row.completed_at, + ) + for row in recent_result.all() + ] + + # --- Tree counts (for All Flows sub-items) --- + tree_counts_result = await db.execute( + select( + func.count().label("total"), + func.count().filter(Tree.tree_type == "troubleshooting").label("troubleshooting"), + func.count().filter(Tree.tree_type == "procedural").label("procedural"), + func.count().filter(Tree.tree_type == "maintenance").label("maintenance"), + ).where( + and_( + Tree.account_id == current_user.account_id, + Tree.is_active.is_(True), + Tree.deleted_at.is_(None), + ) + ) + ) + tc = tree_counts_result.one() + + return SidebarStatsResponse( + resolved_today=resolved_today, + active_count=active_count, + total_session_minutes_today=total_minutes, + tree_counts=SidebarTreeCounts( + total=tc.total, + troubleshooting=tc.troubleshooting, + procedural=tc.procedural, + maintenance=tc.maintenance, + ), + active_sessions=active_sessions, + recent_completions=recent_completions, + ) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 0e656178..3bc9afde 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown +from app.api.endpoints import auth, trees, sessions, sidebar, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown from app.api.endpoints import admin_dashboard, admin_audit, admin_plan_limits, admin_feature_flags, admin_settings, admin_categories from app.api.endpoints import ratings, analytics from app.api.endpoints import target_lists @@ -23,6 +23,7 @@ api_router = APIRouter() api_router.include_router(auth.router) api_router.include_router(trees.router) +api_router.include_router(sidebar.router) api_router.include_router(sessions.router) api_router.include_router(invite.router) api_router.include_router(categories.router)