From 448ce4b53f793e1f94342236d2a3ab0f27b1f754 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sun, 15 Feb 2026 23:43:36 -0500 Subject: [PATCH 01/13] docs: add analytics & user feedback design document Covers team analytics, personal analytics, flow analytics, step-level thumbs up/down feedback, and flow CSAT ratings. Co-Authored-By: Claude Opus 4.6 --- .../2026-02-15-analytics-feedback-design.md | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 docs/plans/2026-02-15-analytics-feedback-design.md diff --git a/docs/plans/2026-02-15-analytics-feedback-design.md b/docs/plans/2026-02-15-analytics-feedback-design.md new file mode 100644 index 00000000..71b8e7a5 --- /dev/null +++ b/docs/plans/2026-02-15-analytics-feedback-design.md @@ -0,0 +1,294 @@ +# Analytics & User Feedback — Design Document + +> **Date:** February 15, 2026 +> **Status:** Approved +> **Audience:** Team admins + individual engineers (both equally) + +--- + +## Goal + +Add analytics dashboards and user feedback systems to ResolutionFlow so MSP managers can measure team productivity and flow effectiveness, engineers can track their own performance, and flow authors get actionable feedback to improve their troubleshooting trees. + +## Architecture + +Live queries against existing PostgreSQL tables (sessions, trees, step_library) with one new table (session_ratings). No materialized views or external analytics services — direct aggregation queries with proper indexes. Time-series data returned as daily-bucketed arrays for Recharts visualization on the frontend. + +## Tech Stack Additions + +- **Backend:** New endpoint modules (`analytics.py`, `ratings.py`), one Alembic migration +- **Frontend:** Recharts (charting), two new pages, one new modal, inline step feedback components +- **No new infrastructure** — runs on existing PostgreSQL + Railway deployment + +--- + +## 1. Feedback System Design + +### Step-Level Feedback: Thumbs Up / Thumbs Down + +**When:** Inline during session navigation, always visible on each step. + +**UX:** +- Small thumb-up and thumb-down icons displayed on each step in TreeNavigationPage and ProceduralNavigationPage +- Non-intrusive: muted icons that highlight on selection +- First-time tooltip: "Rate this step to help improve flows" (dismissible, shown once) +- After rating: selected thumb highlights (green for up, red for down), other thumb dims +- Tapping the same thumb again un-rates (toggle behavior) + +**Data model:** +- Uses existing `step_ratings` table with `was_helpful` boolean +- One rating per user per step per session (unique constraint on step_id + user_id + session_id) +- Updates `step_library.helpful_yes` / `helpful_no` aggregate counts + +**Endpoint:** +- `POST /steps/{step_id}/feedback` — body: `{ session_id, was_helpful: true|false }` +- `DELETE /steps/{step_id}/feedback/{session_id}` — un-rate + +### Flow-Level Feedback: CSAT 1-5 + Optional Comment + +**When:** After session completion, prompted after the SessionOutcomeModal. + +**UX:** +- Modal with 1-5 star selector (or numbered buttons) +- Optional comment textarea (500 char max) +- "Skip" button to dismiss without rating +- Shown once per completed session + +**Data model:** +- New `session_ratings` table: + - `id` UUID PK + - `session_id` FK unique (one rating per session) + - `user_id` FK + - `tree_id` FK (denormalized for aggregation) + - `account_id` FK (denormalized for team scoping) + - `rating` Integer 1-5 (CHECK constraint) + - `comment` String(500), nullable + - `created_at` DateTime(timezone=True) + +**Endpoint:** +- `POST /sessions/{session_id}/rate` — body: `{ rating: 1-5, comment?: string }` +- `GET /trees/{tree_id}/ratings` — paginated list of ratings/comments for flow authors + +--- + +## 2. Analytics Endpoints + +All analytics endpoints accept `?period=7d|30d|90d` (default 30d) and return data scoped to the user's account. + +### Team Analytics — `GET /analytics/team` + +**Access:** team_admin or super_admin only. + +**Response:** +```json +{ + "summary": { + "total_sessions": 247, + "completed_sessions": 198, + "completion_rate": 0.801, + "avg_duration_minutes": 12.4, + "active_engineers": 8, + "outcome_breakdown": { + "resolved": 142, + "escalated": 31, + "workaround": 18, + "unresolved": 7 + } + }, + "time_series": [ + { "date": "2026-02-01", "sessions": 12, "resolved": 8, "escalated": 2, "workaround": 1, "unresolved": 1 }, + ... + ], + "top_flows": [ + { "tree_id": "...", "name": "DNS Resolution", "sessions": 42, "completion_rate": 0.88, "avg_duration_minutes": 8.2, "avg_csat": 4.1 }, + ... + ], + "top_engineers": [ + { "user_id": "...", "name": "Jane Smith", "sessions": 34, "completion_rate": 0.91, "avg_duration_minutes": 10.1 }, + ... + ] +} +``` + +**Optional filter:** `?engineer_id=` to scope to one engineer. + +### Personal Analytics — `GET /analytics/me` + +**Access:** Any authenticated user. + +**Response:** Same shape as team analytics but scoped to the requesting user only. No engineer leaderboard — replaced with "my top flows" list. + +### Flow Analytics — `GET /analytics/flows/{tree_id}` + +**Access:** Anyone who can view the flow. + +**Response:** +```json +{ + "summary": { + "total_sessions": 42, + "completion_rate": 0.88, + "avg_duration_minutes": 8.2, + "avg_csat": 4.1, + "total_ratings": 28, + "outcome_breakdown": { ... } + }, + "time_series": [ + { "date": "2026-02-01", "sessions": 3, "avg_duration_minutes": 7.5 }, + ... + ], + "step_feedback": [ + { "node_id": "abc", "node_title": "Check DNS Settings", "helpful_yes": 18, "helpful_no": 2, "helpful_rate": 0.9 }, + ... + ], + "recent_comments": [ + { "rating": 5, "comment": "Very helpful flow", "user_name": "John", "created_at": "..." }, + ... + ] +} +``` + +--- + +## 3. Frontend Pages + +### Team Analytics Page — `/analytics` + +**Access:** team_admin+ (hidden from engineers/viewers in nav). + +**Layout:** +- Page header: "Team Analytics" + period dropdown (7d / 30d / 90d) +- Row 1: Stat cards — Total Sessions, Completion Rate, Avg Duration, Active Engineers +- Row 2: Time-series chart (sessions per day, stacked by outcome) — Recharts AreaChart +- Row 3: Two columns: + - Left: Flow Leaderboard table (top flows by usage, with completion rate + avg duration + CSAT) + - Right: Engineer Leaderboard table (top engineers by session count, with success rate + avg duration) + +### My Analytics Page — `/analytics/me` + +**Access:** Any authenticated user. + +**Layout:** +- Page header: "My Analytics" + period dropdown +- Row 1: Stat cards — My Sessions, My Completion Rate, My Avg Duration, My Outcome Split +- Row 2: Sessions-per-day line chart +- Row 3: Two columns: + - Left: My Top Flows table (most-used flows with personal stats) + - Right: Outcome distribution donut chart (Recharts PieChart) + +### Flow Analytics Panel + +**Location:** New tab or expandable section on tree detail/editor views. + +**Layout:** +- Summary stat cards: Usage, Completion Rate, Avg Duration, CSAT +- Session trend mini-chart (sparkline or small area chart) +- Step feedback table: each step with helpful rate bar + thumbs count +- Recent CSAT comments list (latest 5-10) + +### Navigation + +- Sidebar: Add "Analytics" nav item with BarChart3 icon between "Sessions" and "Exports" +- Team admins see `/analytics` (team view) as default +- Engineers see `/analytics/me` as default +- Both can navigate between views if they have permission + +--- + +## 4. Database Migration + +**New table: `session_ratings`** + +```sql +CREATE TABLE session_ratings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tree_id UUID NOT NULL REFERENCES trees(id) ON DELETE CASCADE, + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), + comment VARCHAR(500), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (session_id) +); +``` + +**New indexes for analytics queries:** + +```sql +CREATE INDEX ix_session_ratings_tree_created ON session_ratings(tree_id, created_at); +CREATE INDEX ix_session_ratings_account_created ON session_ratings(account_id, created_at); +CREATE INDEX ix_sessions_account_completed ON sessions(account_id, completed_at); +CREATE INDEX ix_sessions_account_tree_completed ON sessions(account_id, tree_id, completed_at); +CREATE INDEX ix_step_ratings_step_helpful ON step_ratings(step_id, was_helpful); +``` + +**Modify `step_ratings` usage:** +- Existing `rating` (1-5) and `review_text` columns remain in DB but are no longer populated +- Only `was_helpful` boolean is used going forward +- Add `session_id` to unique constraint if not already present: `UNIQUE(step_id, user_id, session_id)` + +--- + +## 5. Charting Library + +**Recharts** (`recharts` npm package) +- React-native, composable components +- Supports: AreaChart (time-series), BarChart (comparisons), PieChart (outcome donut), LineChart (trends) +- Lightweight (~45KB gzipped) +- Dark theme compatible via custom colors matching our design tokens + +**Chart color palette** (matching design system): +- Primary: `hsl(243, 75%, 59%)` (purple — matches `--primary`) +- Resolved: `#34d399` (emerald-400) +- Escalated: `#f87171` (red-400) +- Workaround: `#fbbf24` (yellow-400) +- Unresolved: `#94a3b8` (slate-400) + +--- + +## 6. Step Feedback UX Detail + +**Inline thumbs placement:** +- Positioned at the bottom of each step card, right-aligned +- Two icons: ThumbsUp and ThumbsDown from Lucide +- Default state: `text-muted-foreground` (subtle, not distracting) +- Hover: icon scales slightly, tooltip appears +- Selected up: `text-emerald-400` with subtle fill +- Selected down: `text-red-400` with subtle fill +- Toggle: clicking selected thumb un-selects it + +**First-time hint:** +- On the first session where thumbs are available, show a subtle inline note below the first step: "New: Rate steps with thumbs to help improve flows" +- Store dismissal in localStorage +- Auto-dismisses after first thumb interaction + +**CSAT Modal (post-completion):** +- Appears after SessionOutcomeModal closes +- Five numbered buttons (1-5) or star icons in a row +- Label: "How would you rate this flow?" +- Sublabel: "Your feedback helps flow authors improve" +- Optional textarea: "Any comments? (optional)" +- Buttons: "Submit" (primary) + "Skip" (text link) +- localStorage tracks which sessions have been rated to prevent re-prompting + +--- + +## 7. Scope & Non-Goals + +**In scope:** +- Team analytics dashboard with time-series charts +- Personal analytics dashboard +- Flow-level analytics panel +- Step thumbs up/down inline feedback +- Flow CSAT 1-5 + comment at session end +- Period filtering (7d/30d/90d) + +**Not in scope (future):** +- Real-time streaming analytics +- Export analytics to CSV/PDF +- Custom date range picker (just preset periods for v1) +- Comparison mode (this period vs. last period) +- Automation adoption metrics +- Client/ticket correlation analytics +- Email digest/reports -- 2.49.1 From 8e0ad7498679a237db2d25adb4fdd502a345b4f7 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Sun, 15 Feb 2026 23:50:47 -0500 Subject: [PATCH 02/13] docs: add analytics & feedback implementation plan 12-task TDD plan covering session ratings, step feedback, team/personal/flow analytics endpoints, and frontend pages. Co-Authored-By: Claude Opus 4.6 --- ...02-15-analytics-feedback-implementation.md | 1626 +++++++++++++++++ 1 file changed, 1626 insertions(+) create mode 100644 docs/plans/2026-02-15-analytics-feedback-implementation.md diff --git a/docs/plans/2026-02-15-analytics-feedback-implementation.md b/docs/plans/2026-02-15-analytics-feedback-implementation.md new file mode 100644 index 00000000..6bd710ee --- /dev/null +++ b/docs/plans/2026-02-15-analytics-feedback-implementation.md @@ -0,0 +1,1626 @@ +# 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 */} +