diff --git a/backend/app/api/endpoints/sidebar.py b/backend/app/api/endpoints/sidebar.py new file mode 100644 index 00000000..a473433b --- /dev/null +++ b/backend/app/api/endpoints/sidebar.py @@ -0,0 +1,178 @@ +"""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 + + # --- Completed session minutes today (active session time computed client-side) --- + duration_expr = func.extract( + "epoch", + Session.completed_at - 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.completed_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) diff --git a/backend/app/schemas/sidebar.py b/backend/app/schemas/sidebar.py new file mode 100644 index 00000000..95d92291 --- /dev/null +++ b/backend/app/schemas/sidebar.py @@ -0,0 +1,45 @@ +"""Schemas for sidebar stats endpoint.""" + +from datetime import datetime +from typing import Optional +from uuid import UUID + +from pydantic import BaseModel + + +class SidebarActiveSession(BaseModel): + """An active or paused session for the activity feed.""" + session_id: UUID + tree_name: str + tree_id: UUID + tree_type: str + started_at: datetime + ticket_number: Optional[str] = None + psa_ticket_id: Optional[str] = None + + +class SidebarRecentSession(BaseModel): + """A recently completed session for the activity feed.""" + session_id: UUID + tree_name: str + tree_id: UUID + tree_type: str + completed_at: datetime + + +class SidebarTreeCounts(BaseModel): + """Tree counts for All Flows sub-items.""" + total: int + troubleshooting: int + procedural: int + maintenance: int + + +class SidebarStatsResponse(BaseModel): + """Response for GET /sessions/sidebar-stats.""" + resolved_today: int + active_count: int + total_session_minutes_today: int + tree_counts: SidebarTreeCounts + active_sessions: list[SidebarActiveSession] + recent_completions: list[SidebarRecentSession] diff --git a/backend/tests/test_sidebar_stats.py b/backend/tests/test_sidebar_stats.py new file mode 100644 index 00000000..dc5ec100 --- /dev/null +++ b/backend/tests/test_sidebar_stats.py @@ -0,0 +1,142 @@ +"""Integration tests for sidebar stats endpoint.""" + +import pytest +from httpx import AsyncClient + + +class TestSidebarStats: + """Tests for GET /sessions/sidebar-stats.""" + + @pytest.mark.asyncio + async def test_sidebar_stats_no_sessions( + self, client: AsyncClient, auth_headers: dict + ): + """Empty stats when user has no sessions.""" + response = await client.get( + "/api/v1/sessions/sidebar-stats?tz_offset=0", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["resolved_today"] == 0 + assert data["active_count"] == 0 + assert data["total_session_minutes_today"] == 0 + assert data["active_sessions"] == [] + assert data["recent_completions"] == [] + assert "tree_counts" in data + + @pytest.mark.asyncio + async def test_sidebar_stats_with_active_session( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Active session appears in activity feed.""" + create_resp = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"], "ticket_number": "TK-100"}, + headers=auth_headers, + ) + assert create_resp.status_code == 201 + + response = await client.get( + "/api/v1/sessions/sidebar-stats?tz_offset=0", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["active_count"] == 1 + assert len(data["active_sessions"]) == 1 + assert data["active_sessions"][0]["ticket_number"] == "TK-100" + assert data["active_sessions"][0]["tree_id"] == test_tree["id"] + + @pytest.mark.asyncio + async def test_sidebar_stats_resolved_today( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Resolved session counts in resolved_today.""" + create_resp = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"]}, + headers=auth_headers, + ) + session_id = create_resp.json()["id"] + + # Complete with resolved outcome + await client.post( + f"/api/v1/sessions/{session_id}/complete", + json={"outcome": "resolved", "outcome_notes": "Fixed it"}, + headers=auth_headers, + ) + + response = await client.get( + "/api/v1/sessions/sidebar-stats?tz_offset=0", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["resolved_today"] >= 1 + assert data["active_count"] == 0 + assert len(data["recent_completions"]) >= 1 + + @pytest.mark.asyncio + async def test_sidebar_stats_max_active_sessions( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Active sessions capped at 5.""" + for i in range(7): + await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"], "ticket_number": f"TK-{i}"}, + headers=auth_headers, + ) + + response = await client.get( + "/api/v1/sessions/sidebar-stats?tz_offset=0", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["active_count"] == 7 + assert len(data["active_sessions"]) == 5 + + @pytest.mark.asyncio + async def test_sidebar_stats_recent_completions_max_3( + self, client: AsyncClient, auth_headers: dict, test_tree: dict + ): + """Recent completions capped at 3.""" + for i in range(5): + create_resp = await client.post( + "/api/v1/sessions", + json={"tree_id": test_tree["id"]}, + headers=auth_headers, + ) + session_id = create_resp.json()["id"] + await client.post( + f"/api/v1/sessions/{session_id}/complete", + json={"outcome": "resolved", "outcome_notes": "Done"}, + headers=auth_headers, + ) + + response = await client.get( + "/api/v1/sessions/sidebar-stats?tz_offset=0", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert len(data["recent_completions"]) == 3 + + @pytest.mark.asyncio + async def test_sidebar_stats_requires_auth(self, client: AsyncClient): + """Endpoint requires authentication.""" + response = await client.get("/api/v1/sessions/sidebar-stats?tz_offset=0") + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_sidebar_stats_requires_tz_offset( + self, client: AsyncClient, auth_headers: dict + ): + """tz_offset query param is required.""" + response = await client.get( + "/api/v1/sessions/sidebar-stats", + headers=auth_headers, + ) + assert response.status_code == 422 diff --git a/docs/plans/Frontend/sidebar-grouping-concepts.html b/docs/plans/Frontend/sidebar-grouping-concepts.html new file mode 100644 index 00000000..f3026901 --- /dev/null +++ b/docs/plans/Frontend/sidebar-grouping-concepts.html @@ -0,0 +1,630 @@ + + +
+ + ++ Reorganizing the navigation around workflow stages — what engineers do when they're working a ticket vs + building flows vs reviewing results. Also explores splitting AI Assistant into two + distinct tools with clearer purpose. +
+ + +Three clear workflow stages. "Resolve" is front and center because that's what engineers spend most time doing. AI is split: copilot lives in Resolve, builder lives in Build.
+Only two groups instead of three. Dashboard top, then "Work" (active troubleshooting) and "Build" (authoring + review). Exports and Analytics move into Build since they're about improving process, not resolving tickets.
+Used during active sessions. Suggests next steps, explains errors, provides context from ticket data and knowledge base.
+Used when building new flows from scratch. Conversational interface to generate decision trees, procedural steps, and intake forms.
++ Concept 1 (Semantic Colors) and Concept 2 (Tinted Pills) shown with 3 different icon sets each. + Current = existing Lucide icons, Set A = more descriptive/metaphorical icons, + Set B = minimal/geometric alternatives. Click any nav item to toggle active state. +
+ + +Always-on color per icon. No pill background — just the stroke color creates landmarks.
+Colored icon inside a soft tinted rounded-square. Creates visual weight — feels like an app dock.
+| Nav Item | +Current | +Set A (Descriptive) | +Set B (Minimal) | +Color | +
|---|---|---|---|---|
| Dashboard | +LayoutGrid — 4 squares | +Gauge — speedometer, "command center" | +Compass — navigation hub | +Cyan | +
| All Flows | +Box — generic 3D cube | +GitFork — branching paths (literally what flows are) | +Network — connected nodes | +Violet | +
| Flow Editor | +PenLine — pen tip | +Wrench — builder tool | +PenTool — vector/design pen | +Amber | +
| Sessions | +Clock — time-based | +Zap — active energy, live sessions | +Radio — broadcasting/live signal | +Emerald | +
| Exports | +FileText — generic doc | +Share2 — share network nodes | +FileOutput — file with arrow out | +Blue | +
| AI Assistant | +BotMessageSquare — robot chat | +Brain — intelligence, organic feel | +Wand2 — magic wand with sparkles | +Fuchsia | +
| Step Library | +Bookmark — generic ribbon | +Layers — stacked layers = reusable steps | +Library — book spines | +Orange | +
| Script Library | +Terminal — prompt cursor | +Code2 — angle brackets with slash | +ScrollText — script/scroll with text | +Teal | +
| KB Accelerator | +Sparkles — magic star | +Rocket — acceleration/speed | +Lightbulb — ideas/insight | +Rose | +
| Analytics | +BarChart3 — vertical bars | +TrendingUp — growth line with arrow | +PieChart — pie segments | +Sky | +
+ No activity today +
+ ) : ( ++ Build flows from natural language — describe what you need and Flow Assist will generate the decision tree or procedural steps. +
++ Flow Assist will be available here as a dedicated conversational flow builder. + In the meantime, use the AI panel in the Flow Editor to generate flows. +
+