Move completed plan docs to docs/plans/archive/. Add survey migration 046 and reference HTML/plan files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1627 lines
50 KiB
Markdown
1627 lines
50 KiB
Markdown
# 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<TeamAnalyticsResponse> {
|
|
const params: Record<string, string> = { period }
|
|
if (engineerId) params.engineer_id = engineerId
|
|
const response = await apiClient.get<TeamAnalyticsResponse>('/analytics/team', { params })
|
|
return response.data
|
|
},
|
|
|
|
async getPersonalAnalytics(period: AnalyticsPeriod = '30d'): Promise<PersonalAnalyticsResponse> {
|
|
const response = await apiClient.get<PersonalAnalyticsResponse>('/analytics/me', { params: { period } })
|
|
return response.data
|
|
},
|
|
|
|
async getFlowAnalytics(treeId: string, period: AnalyticsPeriod = '30d'): Promise<FlowAnalyticsResponse> {
|
|
const response = await apiClient.get<FlowAnalyticsResponse>(`/analytics/flows/${treeId}`, { params: { period } })
|
|
return response.data
|
|
},
|
|
|
|
async rateSession(sessionId: string, rating: number, comment?: string): Promise<void> {
|
|
await apiClient.post(`/sessions/${sessionId}/rate`, { rating, comment })
|
|
},
|
|
|
|
async submitStepFeedback(stepId: string, sessionId: string, wasHelpful: boolean): Promise<void> {
|
|
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<boolean | null>(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 (
|
|
<div className="flex items-center gap-2">
|
|
{showHint && (
|
|
<span className="text-xs text-muted-foreground">Rate this step to help improve flows</span>
|
|
)}
|
|
<button
|
|
onClick={() => handleFeedback(true)}
|
|
disabled={submitting}
|
|
className={cn(
|
|
'rounded-md p-1.5 transition-colors',
|
|
feedback === true
|
|
? 'text-emerald-400 bg-emerald-400/10'
|
|
: 'text-muted-foreground hover:text-emerald-400 hover:bg-accent'
|
|
)}
|
|
title="Helpful"
|
|
>
|
|
<ThumbsUp size={14} />
|
|
</button>
|
|
<button
|
|
onClick={() => handleFeedback(false)}
|
|
disabled={submitting}
|
|
className={cn(
|
|
'rounded-md p-1.5 transition-colors',
|
|
feedback === false
|
|
? 'text-red-400 bg-red-400/10'
|
|
: 'text-muted-foreground hover:text-red-400 hover:bg-accent'
|
|
)}
|
|
title="Not helpful"
|
|
>
|
|
<ThumbsDown size={14} />
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
**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 && (
|
|
<div className="mt-3 flex justify-end">
|
|
<StepFeedback stepId={currentNode.id} sessionId={session.id} />
|
|
</div>
|
|
)}
|
|
```
|
|
|
|
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 (
|
|
<Modal isOpen={isOpen} onClose={handleSkip} title="How was this flow?">
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Your feedback helps flow authors improve troubleshooting paths.
|
|
</p>
|
|
|
|
{/* Star rating */}
|
|
<div className="flex items-center justify-center gap-1">
|
|
{[1, 2, 3, 4, 5].map((value) => (
|
|
<button
|
|
key={value}
|
|
onClick={() => setRating(value)}
|
|
onMouseEnter={() => setHoveredRating(value)}
|
|
onMouseLeave={() => setHoveredRating(0)}
|
|
className="p-1 transition-colors"
|
|
>
|
|
<Star
|
|
size={28}
|
|
className={cn(
|
|
'transition-colors',
|
|
(hoveredRating || rating) >= value
|
|
? 'fill-yellow-400 text-yellow-400'
|
|
: 'fill-none text-muted-foreground'
|
|
)}
|
|
/>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Comment */}
|
|
<textarea
|
|
value={comment}
|
|
onChange={(e) => setComment(e.target.value)}
|
|
placeholder="Any comments? (optional)"
|
|
maxLength={500}
|
|
rows={3}
|
|
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20 resize-none"
|
|
/>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center justify-between">
|
|
<button onClick={handleSkip} className="text-sm text-muted-foreground hover:text-foreground transition-colors">
|
|
Skip
|
|
</button>
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={rating === 0 || submitting}
|
|
className="rounded-lg bg-gradient-brand px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-primary/20 hover:opacity-90 transition-opacity disabled:opacity-50"
|
|
>
|
|
{submitting ? 'Submitting...' : 'Submit'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
)
|
|
}
|
|
```
|
|
|
|
**Step 2: Integrate into session completion flow**
|
|
|
|
In `TreeNavigationPage.tsx`, after the `SessionOutcomeModal` submit handler resolves:
|
|
|
|
```tsx
|
|
import { CSATModal, hasBeenRated } from '@/components/session/CSATModal'
|
|
|
|
// Add state
|
|
const [csatOpen, setCsatOpen] = useState(false)
|
|
|
|
// In the outcome submit handler, after successful completion:
|
|
const handleSubmitOutcome = async (data) => {
|
|
await sessionsApi.complete(session.id, data)
|
|
// ... existing logic ...
|
|
if (!hasBeenRated(session.id)) {
|
|
setCsatOpen(true)
|
|
}
|
|
}
|
|
|
|
// In JSX, after SessionOutcomeModal:
|
|
{session && (
|
|
<CSATModal
|
|
isOpen={csatOpen}
|
|
onClose={() => setCsatOpen(false)}
|
|
sessionId={session.id}
|
|
/>
|
|
)}
|
|
```
|
|
|
|
Apply the same pattern to `ProceduralNavigationPage.tsx`.
|
|
|
|
**Step 3: Verify build**
|
|
|
|
```bash
|
|
cd frontend && npm run build
|
|
```
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/components/session/CSATModal.tsx frontend/src/pages/TreeNavigationPage.tsx frontend/src/pages/ProceduralNavigationPage.tsx
|
|
git commit -m "feat: add CSAT rating modal after session completion"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Team Analytics Page
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/pages/TeamAnalyticsPage.tsx`
|
|
- Modify: `frontend/src/router.tsx`
|
|
- Modify: `frontend/src/components/layout/Sidebar.tsx`
|
|
|
|
**Step 1: Create the page**
|
|
|
|
Create `frontend/src/pages/TeamAnalyticsPage.tsx`:
|
|
|
|
Build a page with:
|
|
- Header: "Team Analytics" + period dropdown (`<select>` with 7d/30d/90d)
|
|
- Stat cards row: Total Sessions, Completion Rate (%), Avg Duration, Active Engineers
|
|
- Time-series area chart using Recharts `<AreaChart>` with stacked outcomes (resolved=emerald, escalated=red, workaround=yellow, unresolved=slate)
|
|
- Two-column layout below:
|
|
- Left: Top Flows table (name, sessions, completion rate, avg duration)
|
|
- Right: Top Engineers table (name, sessions, completion rate, avg duration)
|
|
|
|
Use the `analyticsApi.getTeamAnalytics()` method. Loading state with `<Loader2>` spinner.
|
|
|
|
Guard with permissions: if not team_admin/super_admin, show "You don't have access to team analytics" message with a link to `/analytics/me`.
|
|
|
|
**Recharts imports needed:**
|
|
|
|
```tsx
|
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
|
```
|
|
|
|
**Chart color constants:**
|
|
|
|
```tsx
|
|
const CHART_COLORS = {
|
|
resolved: '#34d399',
|
|
escalated: '#f87171',
|
|
workaround: '#fbbf24',
|
|
unresolved: '#94a3b8',
|
|
}
|
|
```
|
|
|
|
**Step 2: Add route**
|
|
|
|
In `frontend/src/router.tsx`, add lazy import and route:
|
|
|
|
```tsx
|
|
const TeamAnalyticsPage = lazy(() => import('@/pages/TeamAnalyticsPage'))
|
|
|
|
// Inside AppLayout children:
|
|
{ path: 'analytics', element: <Suspense fallback={<PageLoader />}><TeamAnalyticsPage /></Suspense> },
|
|
```
|
|
|
|
**Step 3: Add nav item**
|
|
|
|
In `frontend/src/components/layout/Sidebar.tsx`, add between Sessions and Exports (in both collapsed and expanded branches):
|
|
|
|
```tsx
|
|
import { BarChart3 } from 'lucide-react'
|
|
|
|
<NavItem href="/analytics" icon={BarChart3} label="Analytics" />
|
|
```
|
|
|
|
**Step 4: Verify build**
|
|
|
|
```bash
|
|
cd frontend && npm run build
|
|
```
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/pages/TeamAnalyticsPage.tsx frontend/src/router.tsx frontend/src/components/layout/Sidebar.tsx
|
|
git commit -m "feat: add Team Analytics page with charts and leaderboards"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Personal Analytics Page
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/pages/MyAnalyticsPage.tsx`
|
|
- Modify: `frontend/src/router.tsx`
|
|
|
|
**Step 1: Create the page**
|
|
|
|
Create `frontend/src/pages/MyAnalyticsPage.tsx`:
|
|
|
|
Similar structure to TeamAnalyticsPage but:
|
|
- Header: "My Analytics"
|
|
- Stat cards: My Sessions, My Completion Rate, My Avg Duration, My Outcomes
|
|
- Sessions-per-day line chart (Recharts `<LineChart>`)
|
|
- Two-column layout:
|
|
- Left: My Top Flows table
|
|
- Right: Outcome distribution donut chart (Recharts `<PieChart>` with `<Pie>`)
|
|
|
|
Use `analyticsApi.getPersonalAnalytics()`. No permission guard — any user can access.
|
|
|
|
**Step 2: Add route**
|
|
|
|
```tsx
|
|
const MyAnalyticsPage = lazy(() => import('@/pages/MyAnalyticsPage'))
|
|
|
|
{ path: 'analytics/me', element: <Suspense fallback={<PageLoader />}><MyAnalyticsPage /></Suspense> },
|
|
```
|
|
|
|
**Step 3: Update TeamAnalyticsPage** — add a link/tab to switch between Team and Personal views.
|
|
|
|
**Step 4: Verify build**
|
|
|
|
```bash
|
|
cd frontend && npm run build
|
|
```
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/pages/MyAnalyticsPage.tsx frontend/src/router.tsx
|
|
git commit -m "feat: add My Analytics page with personal stats and charts"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Flow Analytics Panel
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/components/analytics/FlowAnalyticsPanel.tsx`
|
|
- Modify: `frontend/src/pages/TreeEditorPage.tsx` or tree detail view
|
|
|
|
**Step 1: Create the panel**
|
|
|
|
Create `frontend/src/components/analytics/FlowAnalyticsPanel.tsx`:
|
|
|
|
A collapsible or tabbed panel showing:
|
|
- Summary stat row: Usage, Completion Rate, Avg Duration, CSAT score
|
|
- Mini area chart for sessions-over-time (compact, 200px height)
|
|
- Step feedback table: step name, helpful rate bar (green fill proportional to %), thumbs counts
|
|
- Recent comments list: star rating, comment text, user name, date
|
|
|
|
Use `analyticsApi.getFlowAnalytics(treeId)`.
|
|
|
|
**Step 2: Integrate into tree views**
|
|
|
|
Add as a tab or expandable section in the tree editor or tree detail page. The exact integration point depends on the current page structure — look for a natural place to add an "Analytics" tab alongside existing content.
|
|
|
|
**Step 3: Verify build**
|
|
|
|
```bash
|
|
cd frontend && npm run build
|
|
```
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/components/analytics/FlowAnalyticsPanel.tsx frontend/src/pages/TreeEditorPage.tsx
|
|
git commit -m "feat: add Flow Analytics panel with step feedback and CSAT"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Final Integration & Polish
|
|
|
|
**Step 1: Run full backend test suite**
|
|
|
|
```bash
|
|
cd backend && python -m pytest --override-ini="addopts=" -v
|
|
```
|
|
|
|
Fix any failures.
|
|
|
|
**Step 2: Run frontend build**
|
|
|
|
```bash
|
|
cd frontend && npm run build
|
|
```
|
|
|
|
Fix any type errors.
|
|
|
|
**Step 3: Test end-to-end manually**
|
|
|
|
1. Start a session, verify inline thumbs appear on steps
|
|
2. Complete a session, verify CSAT modal appears
|
|
3. Submit a rating, verify it persists
|
|
4. Visit `/analytics` as team admin, verify charts render
|
|
5. Visit `/analytics/me` as engineer, verify personal stats
|
|
|
|
**Step 4: Final commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "fix: analytics integration polish and test fixes"
|
|
```
|