# Analytics & User Feedback — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add team analytics, personal analytics, flow analytics dashboards and a two-tier feedback system (step thumbs up/down + flow CSAT 1-5) to ResolutionFlow. **Architecture:** Live aggregation queries against existing PostgreSQL tables plus one new `session_ratings` table. Four new API endpoint groups (`/analytics/team`, `/analytics/me`, `/analytics/flows/{id}`, `/sessions/{id}/rate`). Recharts for time-series visualization. Step feedback reuses existing `step_ratings` table with simplified thumbs-only input. **Tech Stack:** Python FastAPI, SQLAlchemy 2.0 async, Alembic, React 19, TypeScript, Recharts, Tailwind CSS v3 --- ## Reference: Design Document See `docs/plans/2026-02-15-analytics-feedback-design.md` for full design rationale and endpoint response shapes. ## Reference: Existing Code - Step rating endpoints already exist: `backend/app/api/endpoints/steps.py:427-618` (POST/PUT/DELETE `/steps/{id}/rate` + aggregation helper) - Session model: `backend/app/models/session.py` — has `outcome`, `completed_at`, `started_at`, `tree_id`, `user_id` columns - Tree model: `backend/app/models/tree.py` — has `usage_count`, `account_id` - Session does NOT have `account_id` — must join through `trees.account_id` or `users.account_id` - Latest migration: `038_remove_workspace_system.py` - Route registration: `backend/app/api/router.py` — import module + `api_router.include_router()` - Frontend routes: `frontend/src/router.tsx` — lazy imports + Suspense wrappers - Sidebar nav: `frontend/src/components/layout/Sidebar.tsx:119-153` - Permissions: `frontend/src/hooks/usePermissions.ts` — `isSuperAdmin`, `isAccountOwner`, `effectiveRole` --- ## Task 1: Database Migration **Files:** - Create: `backend/alembic/versions/039_add_session_ratings_and_analytics_indexes.py` **Step 1: Generate migration** ```bash cd backend ``` Create migration file manually (autogenerate won't pick up indexes properly): ```python """Add session_ratings table and analytics indexes Revision ID: 039 """ from alembic import op import sqlalchemy as sa from sqlalchemy.dialects.postgresql import UUID revision = '039_add_session_ratings_and_analytics_indexes' down_revision = '038_remove_workspace_system' def upgrade(): # New session_ratings table op.create_table( 'session_ratings', sa.Column('id', UUID(as_uuid=True), server_default=sa.text('gen_random_uuid()'), primary_key=True), sa.Column('session_id', UUID(as_uuid=True), sa.ForeignKey('sessions.id', ondelete='CASCADE'), nullable=False), sa.Column('user_id', UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False), sa.Column('tree_id', UUID(as_uuid=True), sa.ForeignKey('trees.id', ondelete='CASCADE'), nullable=False), sa.Column('account_id', UUID(as_uuid=True), sa.ForeignKey('accounts.id', ondelete='CASCADE'), nullable=False), sa.Column('rating', sa.Integer, nullable=False), sa.Column('comment', sa.String(500), nullable=True), sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('NOW()'), nullable=False), sa.CheckConstraint('rating >= 1 AND rating <= 5', name='ck_session_ratings_rating_range'), sa.UniqueConstraint('session_id', name='uq_session_ratings_session_id'), ) # Analytics indexes op.create_index('ix_session_ratings_tree_created', 'session_ratings', ['tree_id', 'created_at']) op.create_index('ix_session_ratings_account_created', 'session_ratings', ['account_id', 'created_at']) op.create_index('ix_sessions_completed', 'sessions', ['completed_at']) op.create_index('ix_step_ratings_step_helpful', 'step_ratings', ['step_id', 'was_helpful']) # Make step_ratings.rating column nullable (thumbs-only mode) op.alter_column('step_ratings', 'rating', nullable=True) # Drop old unique constraint on step_ratings (step_id, user_id) and replace with (step_id, user_id, session_id) op.drop_constraint('uq_step_rating_per_user', 'step_ratings', type_='unique') op.create_unique_constraint('uq_step_rating_per_user_session', 'step_ratings', ['step_id', 'user_id', 'session_id']) def downgrade(): op.drop_constraint('uq_step_rating_per_user_session', 'step_ratings', type_='unique') op.create_unique_constraint('uq_step_rating_per_user', 'step_ratings', ['step_id', 'user_id']) op.alter_column('step_ratings', 'rating', nullable=False) op.drop_index('ix_step_ratings_step_helpful', 'step_ratings') op.drop_index('ix_sessions_completed', 'sessions') op.drop_index('ix_session_ratings_account_created', 'session_ratings') op.drop_index('ix_session_ratings_tree_created', 'session_ratings') op.drop_table('session_ratings') ``` **Step 2: Run the migration** ```bash cd backend && alembic upgrade head ``` Expected: Migration applies successfully with no errors. **Step 3: Verify table exists** ```bash docker exec -it patherly_postgres psql -U postgres -d patherly -c "\d session_ratings" ``` Expected: Table with columns id, session_id, user_id, tree_id, account_id, rating, comment, created_at. **Step 4: Commit** ```bash git add backend/alembic/versions/039_* git commit -m "feat: add session_ratings table and analytics indexes" ``` --- ## Task 2: SessionRating Model + Schemas **Files:** - Create: `backend/app/models/session_rating.py` - Modify: `backend/app/models/__init__.py` - Create: `backend/app/schemas/analytics.py` - Modify: `backend/app/schemas/__init__.py` (if it exists as a barrel export) **Step 1: Create the SessionRating model** Create `backend/app/models/session_rating.py`: ```python import uuid from datetime import datetime, timezone from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, CheckConstraint, UniqueConstraint from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship from app.core.database import Base class SessionRating(Base): __tablename__ = "session_ratings" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) session_id = Column(UUID(as_uuid=True), ForeignKey("sessions.id", ondelete="CASCADE"), nullable=False) user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) tree_id = Column(UUID(as_uuid=True), ForeignKey("trees.id", ondelete="CASCADE"), nullable=False) account_id = Column(UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False) rating = Column(Integer, nullable=False) comment = Column(String(500), nullable=True) created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False) __table_args__ = ( CheckConstraint("rating >= 1 AND rating <= 5", name="ck_session_ratings_rating_range"), UniqueConstraint("session_id", name="uq_session_ratings_session_id"), ) # Relationships session = relationship("Session", foreign_keys=[session_id]) user = relationship("User", foreign_keys=[user_id]) tree = relationship("Tree", foreign_keys=[tree_id]) ``` **Step 2: Register in models/__init__.py** Add to `backend/app/models/__init__.py`: ```python from .session_rating import SessionRating ``` And add `"SessionRating"` to the `__all__` list. **Step 3: Create analytics schemas** Create `backend/app/schemas/analytics.py`: ```python from datetime import datetime from typing import Optional from pydantic import BaseModel, Field # --- Session Rating Schemas --- class SessionRatingCreate(BaseModel): rating: int = Field(..., ge=1, le=5) comment: Optional[str] = Field(None, max_length=500) class SessionRatingResponse(BaseModel): id: str session_id: str rating: int comment: Optional[str] created_at: datetime model_config = {"from_attributes": True} class FlowRatingItem(BaseModel): rating: int comment: Optional[str] user_name: Optional[str] created_at: datetime # --- Step Feedback Schema --- class StepFeedbackCreate(BaseModel): session_id: str was_helpful: bool # --- Analytics Response Schemas --- class OutcomeBreakdown(BaseModel): resolved: int = 0 escalated: int = 0 workaround: int = 0 unresolved: int = 0 class AnalyticsSummary(BaseModel): total_sessions: int completed_sessions: int completion_rate: float avg_duration_minutes: float outcome_breakdown: OutcomeBreakdown class TimeSeriesPoint(BaseModel): date: str sessions: int = 0 resolved: int = 0 escalated: int = 0 workaround: int = 0 unresolved: int = 0 class TopFlow(BaseModel): tree_id: str name: str sessions: int completion_rate: float avg_duration_minutes: float avg_csat: Optional[float] = None class TopEngineer(BaseModel): user_id: str name: str sessions: int completion_rate: float avg_duration_minutes: float class TeamAnalyticsResponse(BaseModel): summary: AnalyticsSummary time_series: list[TimeSeriesPoint] top_flows: list[TopFlow] top_engineers: list[TopEngineer] class PersonalAnalyticsResponse(BaseModel): summary: AnalyticsSummary time_series: list[TimeSeriesPoint] top_flows: list[TopFlow] class StepFeedbackSummary(BaseModel): node_id: str node_title: str helpful_yes: int helpful_no: int helpful_rate: float class FlowAnalyticsResponse(BaseModel): summary: AnalyticsSummary avg_csat: Optional[float] total_ratings: int time_series: list[TimeSeriesPoint] step_feedback: list[StepFeedbackSummary] recent_comments: list[FlowRatingItem] ``` **Step 4: Verify import works** ```bash cd backend && python -c "from app.models.session_rating import SessionRating; print('OK')" ``` **Step 5: Commit** ```bash git add backend/app/models/session_rating.py backend/app/models/__init__.py backend/app/schemas/analytics.py git commit -m "feat: add SessionRating model and analytics schemas" ``` --- ## Task 3: Session Rating Endpoint **Files:** - Create: `backend/app/api/endpoints/ratings.py` - Modify: `backend/app/api/router.py` - Create: `backend/tests/test_ratings.py` **Step 1: Write tests** Create `backend/tests/test_ratings.py`: ```python import pytest from httpx import AsyncClient pytestmark = pytest.mark.asyncio async def test_rate_session_success(client: AsyncClient, auth_headers: dict, completed_session_id: str): """Rate a completed session with CSAT score.""" response = await client.post( f"/api/v1/sessions/{completed_session_id}/rate", json={"rating": 4, "comment": "Very helpful flow"}, headers=auth_headers, ) assert response.status_code == 201 data = response.json() assert data["rating"] == 4 assert data["comment"] == "Very helpful flow" async def test_rate_session_duplicate(client: AsyncClient, auth_headers: dict, completed_session_id: str): """Cannot rate same session twice.""" await client.post( f"/api/v1/sessions/{completed_session_id}/rate", json={"rating": 4}, headers=auth_headers, ) response = await client.post( f"/api/v1/sessions/{completed_session_id}/rate", json={"rating": 5}, headers=auth_headers, ) assert response.status_code == 409 async def test_rate_session_invalid_rating(client: AsyncClient, auth_headers: dict, completed_session_id: str): """Rating must be 1-5.""" response = await client.post( f"/api/v1/sessions/{completed_session_id}/rate", json={"rating": 6}, headers=auth_headers, ) assert response.status_code == 422 async def test_rate_incomplete_session(client: AsyncClient, auth_headers: dict, active_session_id: str): """Cannot rate a session that hasn't been completed.""" response = await client.post( f"/api/v1/sessions/{active_session_id}/rate", json={"rating": 4}, headers=auth_headers, ) assert response.status_code == 400 ``` **Step 2: Run tests to verify they fail** ```bash cd backend && python -m pytest tests/test_ratings.py -v --override-ini="addopts=" ``` Expected: FAIL (endpoint doesn't exist yet). **Step 3: Implement the endpoint** Create `backend/app/api/endpoints/ratings.py`: ```python from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select 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 import User, Session, SessionRating from app.schemas.analytics import SessionRatingCreate, SessionRatingResponse router = APIRouter(tags=["ratings"]) @router.post("/sessions/{session_id}/rate", response_model=SessionRatingResponse, status_code=status.HTTP_201_CREATED) async def rate_session( session_id: UUID, data: SessionRatingCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_active_user), ): """Submit a CSAT rating (1-5) for a completed session.""" # Verify session exists and belongs to user result = await db.execute(select(Session).where(Session.id == session_id)) session = result.scalar_one_or_none() if not session: raise HTTPException(status_code=404, detail="Session not found") if session.user_id != current_user.id: raise HTTPException(status_code=403, detail="Not your session") if not session.completed_at: raise HTTPException(status_code=400, detail="Session not completed yet") # Check for duplicate existing = await db.execute( select(SessionRating).where(SessionRating.session_id == session_id) ) if existing.scalar_one_or_none(): raise HTTPException(status_code=409, detail="Session already rated") rating = SessionRating( session_id=session_id, user_id=current_user.id, tree_id=session.tree_id, account_id=current_user.account_id, rating=data.rating, comment=data.comment, ) db.add(rating) await db.commit() await db.refresh(rating) return SessionRatingResponse( id=str(rating.id), session_id=str(rating.session_id), rating=rating.rating, comment=rating.comment, created_at=rating.created_at, ) ``` **Step 4: Register the route** In `backend/app/api/router.py`, add: ```python from app.api.endpoints import ratings ``` And: ```python api_router.include_router(ratings.router) ``` **Step 5: Run tests to verify they pass** ```bash cd backend && python -m pytest tests/test_ratings.py -v --override-ini="addopts=" ``` Note: Tests may need fixture adjustments (`completed_session_id`, `active_session_id`). Create appropriate fixtures in `conftest.py` if they don't exist — a completed session is one where `completed_at` is set and `outcome` is populated. **Step 6: Commit** ```bash git add backend/app/api/endpoints/ratings.py backend/app/api/router.py backend/tests/test_ratings.py git commit -m "feat: add session CSAT rating endpoint" ``` --- ## Task 4: Step Feedback Endpoint **Files:** - Modify: `backend/app/api/endpoints/ratings.py` - Add tests to: `backend/tests/test_ratings.py` **Step 1: Add step feedback tests** Append to `backend/tests/test_ratings.py`: ```python async def test_step_feedback_thumbs_up(client: AsyncClient, auth_headers: dict, step_id: str, session_id: str): """Submit thumbs-up feedback for a step.""" response = await client.post( f"/api/v1/steps/{step_id}/feedback", json={"session_id": session_id, "was_helpful": True}, headers=auth_headers, ) assert response.status_code == 201 assert response.json()["was_helpful"] is True async def test_step_feedback_toggle(client: AsyncClient, auth_headers: dict, step_id: str, session_id: str): """Submitting again for same step+session updates the rating.""" await client.post( f"/api/v1/steps/{step_id}/feedback", json={"session_id": session_id, "was_helpful": True}, headers=auth_headers, ) response = await client.post( f"/api/v1/steps/{step_id}/feedback", json={"session_id": session_id, "was_helpful": False}, headers=auth_headers, ) assert response.status_code == 200 assert response.json()["was_helpful"] is False ``` **Step 2: Implement the endpoint** Add to `backend/app/api/endpoints/ratings.py`: ```python from app.models import StepLibrary, StepRating from app.schemas.analytics import StepFeedbackCreate @router.post("/steps/{step_id}/feedback", status_code=status.HTTP_201_CREATED) async def submit_step_feedback( step_id: UUID, data: StepFeedbackCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_active_user), ): """Submit thumbs up/down feedback for a step used in a session.""" # Verify step exists result = await db.execute(select(StepLibrary).where(StepLibrary.id == step_id)) step = result.scalar_one_or_none() if not step: raise HTTPException(status_code=404, detail="Step not found") session_uuid = UUID(data.session_id) # Check for existing feedback for this step+user+session existing_result = await db.execute( select(StepRating).where( StepRating.step_id == step_id, StepRating.user_id == current_user.id, StepRating.session_id == session_uuid, ) ) existing = existing_result.scalar_one_or_none() if existing: existing.was_helpful = data.was_helpful status_code = 200 else: rating = StepRating( step_id=step_id, user_id=current_user.id, session_id=session_uuid, was_helpful=data.was_helpful, ) db.add(rating) status_code = 201 # Update aggregates on step_library await _update_step_helpful_counts(db, step_id) await db.commit() return {"step_id": str(step_id), "was_helpful": data.was_helpful, "status": "created" if status_code == 201 else "updated"} async def _update_step_helpful_counts(db: AsyncSession, step_id: UUID): """Recalculate helpful_yes and helpful_no on step_library.""" from sqlalchemy import func yes_count = await db.execute( select(func.count()).where(StepRating.step_id == step_id, StepRating.was_helpful == True) ) no_count = await db.execute( select(func.count()).where(StepRating.step_id == step_id, StepRating.was_helpful == False) ) await db.execute( StepLibrary.__table__.update() .where(StepLibrary.id == step_id) .values(helpful_yes=yes_count.scalar(), helpful_no=no_count.scalar()) ) ``` **Step 3: Run tests** ```bash cd backend && python -m pytest tests/test_ratings.py -v --override-ini="addopts=" ``` **Step 4: Commit** ```bash git add backend/app/api/endpoints/ratings.py backend/tests/test_ratings.py git commit -m "feat: add step thumbs up/down feedback endpoint" ``` --- ## Task 5: Team Analytics Endpoint **Files:** - Create: `backend/app/api/endpoints/analytics.py` - Modify: `backend/app/api/router.py` - Create: `backend/tests/test_analytics.py` **Step 1: Write tests** Create `backend/tests/test_analytics.py`: ```python import pytest from httpx import AsyncClient pytestmark = pytest.mark.asyncio async def test_team_analytics_success(client: AsyncClient, admin_auth_headers: dict): """Team admin can access team analytics.""" response = await client.get( "/api/v1/analytics/team?period=30d", headers=admin_auth_headers, ) assert response.status_code == 200 data = response.json() assert "summary" in data assert "time_series" in data assert "top_flows" in data assert "top_engineers" in data assert "total_sessions" in data["summary"] assert "completion_rate" in data["summary"] async def test_team_analytics_forbidden_for_engineer(client: AsyncClient, auth_headers: dict): """Regular engineers cannot access team analytics.""" response = await client.get( "/api/v1/analytics/team", headers=auth_headers, ) assert response.status_code == 403 async def test_personal_analytics_success(client: AsyncClient, auth_headers: dict): """Any user can access personal analytics.""" response = await client.get( "/api/v1/analytics/me?period=30d", headers=auth_headers, ) assert response.status_code == 200 data = response.json() assert "summary" in data assert "time_series" in data assert "top_flows" in data async def test_flow_analytics_success(client: AsyncClient, auth_headers: dict, tree_id: str): """Can access analytics for a visible flow.""" response = await client.get( f"/api/v1/analytics/flows/{tree_id}", headers=auth_headers, ) assert response.status_code == 200 data = response.json() assert "summary" in data assert "step_feedback" in data assert "recent_comments" in data ``` **Step 2: Implement analytics endpoint** Create `backend/app/api/endpoints/analytics.py`: ```python from datetime import datetime, timezone, timedelta from uuid import UUID from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import select, func, case, and_, cast, Date 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 import User, Session, Tree, SessionRating, StepRating from app.schemas.analytics import ( TeamAnalyticsResponse, PersonalAnalyticsResponse, FlowAnalyticsResponse, AnalyticsSummary, OutcomeBreakdown, TimeSeriesPoint, TopFlow, TopEngineer, StepFeedbackSummary, FlowRatingItem, ) router = APIRouter(prefix="/analytics", tags=["analytics"]) def _get_period_start(period: str) -> datetime: days = {"7d": 7, "30d": 30, "90d": 90}.get(period, 30) return datetime.now(timezone.utc) - timedelta(days=days) async def _build_summary(db: AsyncSession, base_filter) -> AnalyticsSummary: """Build analytics summary from a filtered session query.""" # Total and completed counts total_q = await db.execute(select(func.count()).select_from(Session).where(*base_filter)) total = total_q.scalar() or 0 completed_q = await db.execute( select(func.count()).select_from(Session).where(*base_filter, Session.completed_at.isnot(None)) ) completed = completed_q.scalar() or 0 # Avg duration (minutes) duration_q = await db.execute( select( func.avg(func.extract('epoch', Session.completed_at - Session.started_at) / 60) ).where(*base_filter, Session.completed_at.isnot(None)) ) avg_duration = round(float(duration_q.scalar() or 0), 1) # Outcome breakdown outcome_q = await db.execute( select(Session.outcome, func.count()).where( *base_filter, Session.completed_at.isnot(None), Session.outcome.isnot(None) ).group_by(Session.outcome) ) outcomes = dict(outcome_q.all()) return AnalyticsSummary( total_sessions=total, completed_sessions=completed, completion_rate=round(completed / total, 3) if total > 0 else 0, avg_duration_minutes=avg_duration, outcome_breakdown=OutcomeBreakdown( resolved=outcomes.get("resolved", 0), escalated=outcomes.get("escalated", 0), workaround=outcomes.get("workaround", 0), unresolved=outcomes.get("unresolved", 0), ), ) async def _build_time_series(db: AsyncSession, base_filter, period_start: datetime) -> list[TimeSeriesPoint]: """Build daily time-series of session counts by outcome.""" rows = await db.execute( select( cast(Session.started_at, Date).label("date"), func.count().label("sessions"), func.count().filter(Session.outcome == "resolved").label("resolved"), func.count().filter(Session.outcome == "escalated").label("escalated"), func.count().filter(Session.outcome == "workaround").label("workaround"), func.count().filter(Session.outcome == "unresolved").label("unresolved"), ).where(*base_filter, Session.started_at >= period_start) .group_by(cast(Session.started_at, Date)) .order_by(cast(Session.started_at, Date)) ) return [ TimeSeriesPoint( date=str(row.date), sessions=row.sessions, resolved=row.resolved, escalated=row.escalated, workaround=row.workaround, unresolved=row.unresolved, ) for row in rows.all() ] @router.get("/team", response_model=TeamAnalyticsResponse) async def get_team_analytics( period: str = Query("30d", pattern="^(7d|30d|90d)$"), engineer_id: Optional[UUID] = None, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_active_user), ): """Team analytics — team_admin or super_admin only.""" if not (current_user.is_team_admin or current_user.role == "super_admin"): raise HTTPException(status_code=403, detail="Team admin access required") period_start = _get_period_start(period) base_filter = [ Session.started_at >= period_start, Tree.account_id == current_user.account_id, ] if engineer_id: base_filter.append(Session.user_id == engineer_id) # Need to join Session to Tree for account_id scoping # Adjust queries to use join summary = await _build_summary_with_join(db, base_filter, period_start) time_series = await _build_time_series_with_join(db, base_filter, period_start) # Top flows top_flows_q = await db.execute( select( Tree.id, Tree.name, func.count(Session.id).label("sessions"), (func.count().filter(Session.completed_at.isnot(None)) * 1.0 / func.count()).label("completion_rate"), func.avg(func.extract('epoch', Session.completed_at - Session.started_at) / 60).label("avg_duration"), ) .join(Tree, Session.tree_id == Tree.id) .where(Session.started_at >= period_start, Tree.account_id == current_user.account_id) .group_by(Tree.id, Tree.name) .order_by(func.count(Session.id).desc()) .limit(10) ) top_flows = [ TopFlow( tree_id=str(row.id), name=row.name, sessions=row.sessions, completion_rate=round(float(row.completion_rate or 0), 3), avg_duration_minutes=round(float(row.avg_duration or 0), 1), ) for row in top_flows_q.all() ] # Top engineers top_engineers_q = await db.execute( select( User.id, User.name, func.count(Session.id).label("sessions"), (func.count().filter(Session.completed_at.isnot(None)) * 1.0 / func.count()).label("completion_rate"), func.avg(func.extract('epoch', Session.completed_at - Session.started_at) / 60).label("avg_duration"), ) .join(User, Session.user_id == User.id) .join(Tree, Session.tree_id == Tree.id) .where(Session.started_at >= period_start, Tree.account_id == current_user.account_id) .group_by(User.id, User.name) .order_by(func.count(Session.id).desc()) .limit(10) ) top_engineers = [ TopEngineer( user_id=str(row.id), name=row.name or "Unknown", sessions=row.sessions, completion_rate=round(float(row.completion_rate or 0), 3), avg_duration_minutes=round(float(row.avg_duration or 0), 1), ) for row in top_engineers_q.all() ] return TeamAnalyticsResponse( summary=summary, time_series=time_series, top_flows=top_flows, top_engineers=top_engineers, ) @router.get("/me", response_model=PersonalAnalyticsResponse) async def get_personal_analytics( period: str = Query("30d", pattern="^(7d|30d|90d)$"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_active_user), ): """Personal analytics — any authenticated user.""" period_start = _get_period_start(period) base_filter = [Session.started_at >= period_start, Session.user_id == current_user.id] summary = await _build_summary(db, base_filter) time_series = await _build_time_series(db, base_filter, period_start) # My top flows top_flows_q = await db.execute( select( Tree.id, Tree.name, func.count(Session.id).label("sessions"), (func.count().filter(Session.completed_at.isnot(None)) * 1.0 / func.count()).label("completion_rate"), func.avg(func.extract('epoch', Session.completed_at - Session.started_at) / 60).label("avg_duration"), ) .join(Tree, Session.tree_id == Tree.id) .where(Session.started_at >= period_start, Session.user_id == current_user.id) .group_by(Tree.id, Tree.name) .order_by(func.count(Session.id).desc()) .limit(10) ) top_flows = [ TopFlow( tree_id=str(row.id), name=row.name, sessions=row.sessions, completion_rate=round(float(row.completion_rate or 0), 3), avg_duration_minutes=round(float(row.avg_duration or 0), 1), ) for row in top_flows_q.all() ] return PersonalAnalyticsResponse( summary=summary, time_series=time_series, top_flows=top_flows, ) @router.get("/flows/{tree_id}", response_model=FlowAnalyticsResponse) async def get_flow_analytics( tree_id: UUID, period: str = Query("30d", pattern="^(7d|30d|90d)$"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_active_user), ): """Analytics for a specific flow.""" # Verify tree exists and user can view it result = await db.execute(select(Tree).where(Tree.id == tree_id)) tree = result.scalar_one_or_none() if not tree: raise HTTPException(status_code=404, detail="Flow not found") period_start = _get_period_start(period) base_filter = [Session.started_at >= period_start, Session.tree_id == tree_id] summary = await _build_summary(db, base_filter) time_series = await _build_time_series(db, base_filter, period_start) # CSAT stats csat_q = await db.execute( select(func.avg(SessionRating.rating), func.count()) .where(SessionRating.tree_id == tree_id, SessionRating.created_at >= period_start) ) csat_row = csat_q.one() avg_csat = round(float(csat_row[0]), 1) if csat_row[0] else None total_ratings = csat_row[1] # Step feedback (thumbs) — aggregate by node_id from decisions JSONB # This requires joining step_ratings with session decisions # For v1, return step_library-level feedback if steps are from library step_feedback: list[StepFeedbackSummary] = [] # Recent comments comments_q = await db.execute( select(SessionRating.rating, SessionRating.comment, User.name, SessionRating.created_at) .join(User, SessionRating.user_id == User.id) .where( SessionRating.tree_id == tree_id, SessionRating.comment.isnot(None), SessionRating.comment != "", ) .order_by(SessionRating.created_at.desc()) .limit(10) ) recent_comments = [ FlowRatingItem( rating=row.rating, comment=row.comment, user_name=row.name, created_at=row.created_at, ) for row in comments_q.all() ] return FlowAnalyticsResponse( summary=summary, avg_csat=avg_csat, total_ratings=total_ratings, time_series=time_series, step_feedback=step_feedback, recent_comments=recent_comments, ) ``` Note: The `_build_summary_with_join` and `_build_time_series_with_join` helper functions need to be implemented with proper Session-Tree joins for account scoping. The implementing engineer should refactor `_build_summary` and `_build_time_series` to optionally accept a join condition. **Step 3: Register route** In `backend/app/api/router.py`: ```python from app.api.endpoints import analytics api_router.include_router(analytics.router) ``` **Step 4: Run tests** ```bash cd backend && python -m pytest tests/test_analytics.py -v --override-ini="addopts=" ``` **Step 5: Commit** ```bash git add backend/app/api/endpoints/analytics.py backend/app/api/router.py backend/tests/test_analytics.py git commit -m "feat: add team, personal, and flow analytics endpoints" ``` --- ## Task 6: Frontend Setup — Recharts, Types, API Client **Files:** - Modify: `frontend/package.json` (install recharts) - Create: `frontend/src/types/analytics.ts` - Modify: `frontend/src/types/index.ts` - Create: `frontend/src/api/analytics.ts` - Modify: `frontend/src/api/index.ts` - Modify: `frontend/src/api/sessions.ts` **Step 1: Install Recharts** ```bash cd frontend && npm install recharts ``` **Step 2: Create analytics types** Create `frontend/src/types/analytics.ts`: ```typescript export interface OutcomeBreakdown { resolved: number escalated: number workaround: number unresolved: number } export interface AnalyticsSummary { total_sessions: number completed_sessions: number completion_rate: number avg_duration_minutes: number outcome_breakdown: OutcomeBreakdown } export interface TimeSeriesPoint { date: string sessions: number resolved: number escalated: number workaround: number unresolved: number } export interface TopFlow { tree_id: string name: string sessions: number completion_rate: number avg_duration_minutes: number avg_csat?: number } export interface TopEngineer { user_id: string name: string sessions: number completion_rate: number avg_duration_minutes: number } export interface TeamAnalyticsResponse { summary: AnalyticsSummary time_series: TimeSeriesPoint[] top_flows: TopFlow[] top_engineers: TopEngineer[] } export interface PersonalAnalyticsResponse { summary: AnalyticsSummary time_series: TimeSeriesPoint[] top_flows: TopFlow[] } export interface StepFeedbackSummary { node_id: string node_title: string helpful_yes: number helpful_no: number helpful_rate: number } export interface FlowRatingItem { rating: number comment?: string user_name?: string created_at: string } export interface FlowAnalyticsResponse { summary: AnalyticsSummary avg_csat?: number total_ratings: number time_series: TimeSeriesPoint[] step_feedback: StepFeedbackSummary[] recent_comments: FlowRatingItem[] } export type AnalyticsPeriod = '7d' | '30d' | '90d' ``` **Step 3: Export from types/index.ts** Add to `frontend/src/types/index.ts`: ```typescript export type * from './analytics' ``` **Step 4: Create analytics API client** Create `frontend/src/api/analytics.ts`: ```typescript import { apiClient } from './client' import type { TeamAnalyticsResponse, PersonalAnalyticsResponse, FlowAnalyticsResponse, AnalyticsPeriod, } from '@/types' export const analyticsApi = { async getTeamAnalytics(period: AnalyticsPeriod = '30d', engineerId?: string): Promise { const params: Record = { period } if (engineerId) params.engineer_id = engineerId const response = await apiClient.get('/analytics/team', { params }) return response.data }, async getPersonalAnalytics(period: AnalyticsPeriod = '30d'): Promise { const response = await apiClient.get('/analytics/me', { params: { period } }) return response.data }, async getFlowAnalytics(treeId: string, period: AnalyticsPeriod = '30d'): Promise { const response = await apiClient.get(`/analytics/flows/${treeId}`, { params: { period } }) return response.data }, async rateSession(sessionId: string, rating: number, comment?: string): Promise { await apiClient.post(`/sessions/${sessionId}/rate`, { rating, comment }) }, async submitStepFeedback(stepId: string, sessionId: string, wasHelpful: boolean): Promise { await apiClient.post(`/steps/${stepId}/feedback`, { session_id: sessionId, was_helpful: wasHelpful }) }, } ``` **Step 5: Export from api/index.ts** Add to `frontend/src/api/index.ts`: ```typescript export { analyticsApi } from './analytics' ``` **Step 6: Verify build** ```bash cd frontend && npm run build ``` **Step 7: Commit** ```bash git add frontend/package.json frontend/package-lock.json frontend/src/types/analytics.ts frontend/src/types/index.ts frontend/src/api/analytics.ts frontend/src/api/index.ts git commit -m "feat: add recharts, analytics types, and API client" ``` --- ## Task 7: Inline Step Feedback Component **Files:** - Create: `frontend/src/components/session/StepFeedback.tsx` - Modify: `frontend/src/pages/TreeNavigationPage.tsx` - Modify: `frontend/src/pages/ProceduralNavigationPage.tsx` (same pattern) **Step 1: Create StepFeedback component** Create `frontend/src/components/session/StepFeedback.tsx`: ```tsx import { useState, useEffect } from 'react' import { ThumbsUp, ThumbsDown } from 'lucide-react' import { analyticsApi } from '@/api' import { cn } from '@/lib/utils' interface StepFeedbackProps { stepId: string sessionId: string } const HINT_KEY = 'rf-step-feedback-hint-dismissed' export function StepFeedback({ stepId, sessionId }: StepFeedbackProps) { const [feedback, setFeedback] = useState(null) const [submitting, setSubmitting] = useState(false) const [showHint, setShowHint] = useState(false) useEffect(() => { if (!localStorage.getItem(HINT_KEY)) { setShowHint(true) } }, []) const handleFeedback = async (wasHelpful: boolean) => { if (submitting) return setSubmitting(true) try { const newValue = feedback === wasHelpful ? null : wasHelpful if (newValue !== null) { await analyticsApi.submitStepFeedback(stepId, sessionId, newValue) } setFeedback(newValue) if (showHint) { setShowHint(false) localStorage.setItem(HINT_KEY, '1') } } catch { // Silently fail — feedback is non-critical } finally { setSubmitting(false) } } return (
{showHint && ( Rate this step to help improve flows )}
) } ``` **Step 2: Integrate into TreeNavigationPage** In `TreeNavigationPage.tsx`, find where step content is rendered (after the step description/help_text, before decision options or continue button). Add: ```tsx import { StepFeedback } from '@/components/session/StepFeedback' // Inside the step rendering, after content and before options: {currentNode && session && (
)} ``` Apply the same pattern to `ProceduralNavigationPage.tsx` for procedural flow steps. **Step 3: Verify build** ```bash cd frontend && npm run build ``` **Step 4: Commit** ```bash git add frontend/src/components/session/StepFeedback.tsx frontend/src/pages/TreeNavigationPage.tsx frontend/src/pages/ProceduralNavigationPage.tsx git commit -m "feat: add inline step thumbs up/down feedback during sessions" ``` --- ## Task 8: CSAT Modal Component **Files:** - Create: `frontend/src/components/session/CSATModal.tsx` - Modify: `frontend/src/pages/TreeNavigationPage.tsx` - Modify: `frontend/src/pages/ProceduralNavigationPage.tsx` **Step 1: Create CSATModal** Create `frontend/src/components/session/CSATModal.tsx`: ```tsx import { useState } from 'react' import { Star } from 'lucide-react' import { Modal } from '@/components/common/Modal' import { analyticsApi } from '@/api' import { cn } from '@/lib/utils' interface CSATModalProps { isOpen: boolean onClose: () => void sessionId: string } const RATED_SESSIONS_KEY = 'rf-rated-sessions' function getRatedSessions(): string[] { try { return JSON.parse(localStorage.getItem(RATED_SESSIONS_KEY) || '[]') } catch { return [] } } function markSessionRated(sessionId: string) { const rated = getRatedSessions() rated.push(sessionId) localStorage.setItem(RATED_SESSIONS_KEY, JSON.stringify(rated.slice(-100))) } export function hasBeenRated(sessionId: string): boolean { return getRatedSessions().includes(sessionId) } export function CSATModal({ isOpen, onClose, sessionId }: CSATModalProps) { const [rating, setRating] = useState(0) const [hoveredRating, setHoveredRating] = useState(0) const [comment, setComment] = useState('') const [submitting, setSubmitting] = useState(false) const handleSubmit = async () => { if (rating === 0 || submitting) return setSubmitting(true) try { await analyticsApi.rateSession(sessionId, rating, comment || undefined) markSessionRated(sessionId) onClose() } catch { // Silently fail onClose() } finally { setSubmitting(false) } } const handleSkip = () => { markSessionRated(sessionId) onClose() } return (

Your feedback helps flow authors improve troubleshooting paths.

{/* Star rating */}
{[1, 2, 3, 4, 5].map((value) => ( ))}
{/* Comment */}