Merge branch 'main' of https://github.com/patherly/patherly
This commit is contained in:
@@ -0,0 +1,45 @@
|
|||||||
|
"""Add session_ratings table and analytics indexes
|
||||||
|
|
||||||
|
Revision ID: 039
|
||||||
|
Revises: 038
|
||||||
|
Create Date: 2026-02-16
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
revision = '039'
|
||||||
|
down_revision = '038'
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# New session_ratings table for flow CSAT ratings
|
||||||
|
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)
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
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')
|
||||||
404
backend/app/api/endpoints/analytics.py
Normal file
404
backend/app/api/endpoints/analytics.py
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
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, 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
|
||||||
|
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_filters: list,
|
||||||
|
join_tree: bool = False,
|
||||||
|
) -> AnalyticsSummary:
|
||||||
|
"""Build analytics summary. If join_tree=True, joins Session->Tree for account scoping."""
|
||||||
|
|
||||||
|
def _base_query():
|
||||||
|
q = select(func.count()).select_from(Session)
|
||||||
|
if join_tree:
|
||||||
|
q = q.join(Tree, Session.tree_id == Tree.id)
|
||||||
|
return q
|
||||||
|
|
||||||
|
# Total sessions
|
||||||
|
total_q = await db.execute(_base_query().where(*base_filters))
|
||||||
|
total = total_q.scalar() or 0
|
||||||
|
|
||||||
|
# Completed sessions
|
||||||
|
completed_q = await db.execute(
|
||||||
|
_base_query().where(*base_filters, Session.completed_at.isnot(None))
|
||||||
|
)
|
||||||
|
completed = completed_q.scalar() or 0
|
||||||
|
|
||||||
|
# Median duration (minutes) using percentile_cont
|
||||||
|
duration_base = select(
|
||||||
|
func.percentile_cont(0.5).within_group(
|
||||||
|
func.extract('epoch', Session.completed_at - Session.started_at) / 60
|
||||||
|
)
|
||||||
|
).select_from(Session)
|
||||||
|
if join_tree:
|
||||||
|
duration_base = duration_base.join(Tree, Session.tree_id == Tree.id)
|
||||||
|
duration_q = await db.execute(
|
||||||
|
duration_base.where(*base_filters, Session.completed_at.isnot(None))
|
||||||
|
)
|
||||||
|
raw_median = duration_q.scalar()
|
||||||
|
median_duration = round(float(raw_median), 1) if raw_median is not None else 0.0
|
||||||
|
|
||||||
|
# Active engineers (distinct users)
|
||||||
|
active_base = select(func.count(func.distinct(Session.user_id))).select_from(Session)
|
||||||
|
if join_tree:
|
||||||
|
active_base = active_base.join(Tree, Session.tree_id == Tree.id)
|
||||||
|
active_q = await db.execute(active_base.where(*base_filters))
|
||||||
|
active_engineers = active_q.scalar() or 0
|
||||||
|
|
||||||
|
# Outcome breakdown
|
||||||
|
outcome_base = select(Session.outcome, func.count()).select_from(Session)
|
||||||
|
if join_tree:
|
||||||
|
outcome_base = outcome_base.join(Tree, Session.tree_id == Tree.id)
|
||||||
|
outcome_q = await db.execute(
|
||||||
|
outcome_base.where(
|
||||||
|
*base_filters, 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.0,
|
||||||
|
median_duration_minutes=median_duration,
|
||||||
|
active_engineers=active_engineers,
|
||||||
|
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_filters: list,
|
||||||
|
join_tree: bool = False,
|
||||||
|
) -> list[TimeSeriesPoint]:
|
||||||
|
"""Build daily time-series using CASE expressions for outcome counting."""
|
||||||
|
q = select(
|
||||||
|
cast(Session.started_at, Date).label("date"),
|
||||||
|
func.count().label("sessions"),
|
||||||
|
func.sum(case((Session.outcome == "resolved", 1), else_=0)).label("resolved"),
|
||||||
|
func.sum(case((Session.outcome == "escalated", 1), else_=0)).label("escalated"),
|
||||||
|
func.sum(case((Session.outcome == "workaround", 1), else_=0)).label("workaround"),
|
||||||
|
func.sum(case((Session.outcome == "unresolved", 1), else_=0)).label("unresolved"),
|
||||||
|
).select_from(Session)
|
||||||
|
if join_tree:
|
||||||
|
q = q.join(Tree, Session.tree_id == Tree.id)
|
||||||
|
|
||||||
|
rows = await db.execute(
|
||||||
|
q.where(*base_filters)
|
||||||
|
.group_by(cast(Session.started_at, Date))
|
||||||
|
.order_by(cast(Session.started_at, Date))
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
TimeSeriesPoint(
|
||||||
|
date=str(row.date), sessions=row.sessions,
|
||||||
|
resolved=int(row.resolved or 0), escalated=int(row.escalated or 0),
|
||||||
|
workaround=int(row.workaround or 0), unresolved=int(row.unresolved or 0),
|
||||||
|
)
|
||||||
|
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.is_super_admin):
|
||||||
|
raise HTTPException(status_code=403, detail="Team admin access required")
|
||||||
|
|
||||||
|
period_start = _get_period_start(period)
|
||||||
|
base_filters = [
|
||||||
|
Session.started_at >= period_start,
|
||||||
|
Tree.account_id == current_user.account_id,
|
||||||
|
]
|
||||||
|
if engineer_id:
|
||||||
|
base_filters.append(Session.user_id == engineer_id)
|
||||||
|
|
||||||
|
summary = await _build_summary(db, base_filters, join_tree=True)
|
||||||
|
time_series = await _build_time_series(db, base_filters, join_tree=True)
|
||||||
|
|
||||||
|
# Top flows (join Session->Tree)
|
||||||
|
top_flows_q = await db.execute(
|
||||||
|
select(
|
||||||
|
Tree.id, Tree.name,
|
||||||
|
func.count(Session.id).label("sessions"),
|
||||||
|
func.percentile_cont(0.5).within_group(
|
||||||
|
func.extract('epoch', Session.completed_at - Session.started_at) / 60
|
||||||
|
).label("median_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 = []
|
||||||
|
for row in top_flows_q.all():
|
||||||
|
# Compute completion rate separately to avoid division by zero
|
||||||
|
completed_count_q = await db.execute(
|
||||||
|
select(func.count()).select_from(Session)
|
||||||
|
.where(
|
||||||
|
Session.tree_id == row.id,
|
||||||
|
Session.started_at >= period_start,
|
||||||
|
Session.completed_at.isnot(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
completed_count = completed_count_q.scalar() or 0
|
||||||
|
completion_rate = round(completed_count / row.sessions, 3) if row.sessions > 0 else 0.0
|
||||||
|
top_flows.append(
|
||||||
|
TopFlow(
|
||||||
|
tree_id=str(row.id), name=row.name, sessions=row.sessions,
|
||||||
|
completion_rate=completion_rate,
|
||||||
|
median_duration_minutes=round(float(row.median_duration or 0), 1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Top engineers (join Session->User + Session->Tree)
|
||||||
|
top_engineers_q = await db.execute(
|
||||||
|
select(
|
||||||
|
User.id, User.name,
|
||||||
|
func.count(Session.id).label("sessions"),
|
||||||
|
func.percentile_cont(0.5).within_group(
|
||||||
|
func.extract('epoch', Session.completed_at - Session.started_at) / 60
|
||||||
|
).label("median_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 = []
|
||||||
|
for row in top_engineers_q.all():
|
||||||
|
completed_count_q = await db.execute(
|
||||||
|
select(func.count()).select_from(Session)
|
||||||
|
.join(Tree, Session.tree_id == Tree.id)
|
||||||
|
.where(
|
||||||
|
Session.user_id == row.id,
|
||||||
|
Session.started_at >= period_start,
|
||||||
|
Tree.account_id == current_user.account_id,
|
||||||
|
Session.completed_at.isnot(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
completed_count = completed_count_q.scalar() or 0
|
||||||
|
completion_rate = round(completed_count / row.sessions, 3) if row.sessions > 0 else 0.0
|
||||||
|
top_engineers.append(
|
||||||
|
TopEngineer(
|
||||||
|
user_id=str(row.id), name=row.name or "Unknown", sessions=row.sessions,
|
||||||
|
completion_rate=completion_rate,
|
||||||
|
median_duration_minutes=round(float(row.median_duration or 0), 1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
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_filters = [Session.started_at >= period_start, Session.user_id == current_user.id]
|
||||||
|
|
||||||
|
summary = await _build_summary(db, base_filters, join_tree=False)
|
||||||
|
# Override active_engineers=1 for personal view
|
||||||
|
summary.active_engineers = 1
|
||||||
|
time_series = await _build_time_series(db, base_filters, join_tree=False)
|
||||||
|
|
||||||
|
# Top flows
|
||||||
|
top_flows_q = await db.execute(
|
||||||
|
select(
|
||||||
|
Tree.id, Tree.name,
|
||||||
|
func.count(Session.id).label("sessions"),
|
||||||
|
func.percentile_cont(0.5).within_group(
|
||||||
|
func.extract('epoch', Session.completed_at - Session.started_at) / 60
|
||||||
|
).label("median_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 = []
|
||||||
|
for row in top_flows_q.all():
|
||||||
|
completed_count_q = await db.execute(
|
||||||
|
select(func.count()).select_from(Session)
|
||||||
|
.where(
|
||||||
|
Session.tree_id == row.id,
|
||||||
|
Session.user_id == current_user.id,
|
||||||
|
Session.started_at >= period_start,
|
||||||
|
Session.completed_at.isnot(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
completed_count = completed_count_q.scalar() or 0
|
||||||
|
completion_rate = round(completed_count / row.sessions, 3) if row.sessions > 0 else 0.0
|
||||||
|
top_flows.append(
|
||||||
|
TopFlow(
|
||||||
|
tree_id=str(row.id), name=row.name, sessions=row.sessions,
|
||||||
|
completion_rate=completion_rate,
|
||||||
|
median_duration_minutes=round(float(row.median_duration or 0), 1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
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_filters = [Session.started_at >= period_start, Session.tree_id == tree_id]
|
||||||
|
|
||||||
|
summary = await _build_summary(db, base_filters, join_tree=False)
|
||||||
|
time_series = await _build_time_series(db, base_filters, join_tree=False)
|
||||||
|
|
||||||
|
# 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 - compute from step_ratings for steps used in this tree's sessions
|
||||||
|
step_feedback: list[StepFeedbackSummary] = []
|
||||||
|
|
||||||
|
# Step dropoff analysis from session decisions JSONB
|
||||||
|
sessions_q = await db.execute(
|
||||||
|
select(Session.decisions, Session.completed_at)
|
||||||
|
.where(Session.tree_id == tree_id, Session.started_at >= period_start)
|
||||||
|
)
|
||||||
|
sessions_data = sessions_q.all()
|
||||||
|
|
||||||
|
node_visits: dict[str, int] = {}
|
||||||
|
node_dropoffs: dict[str, int] = {}
|
||||||
|
|
||||||
|
for sess in sessions_data:
|
||||||
|
decisions = sess.decisions or []
|
||||||
|
for decision in decisions:
|
||||||
|
node_id = decision.get("node_id", "")
|
||||||
|
if node_id:
|
||||||
|
node_visits[node_id] = node_visits.get(node_id, 0) + 1
|
||||||
|
|
||||||
|
# If session not completed, last decision node is a dropoff
|
||||||
|
if not sess.completed_at and decisions:
|
||||||
|
last_decision = decisions[-1]
|
||||||
|
last_node = last_decision.get("node_id", "")
|
||||||
|
if last_node:
|
||||||
|
node_dropoffs[last_node] = node_dropoffs.get(last_node, 0) + 1
|
||||||
|
|
||||||
|
# Build node title map from tree structure
|
||||||
|
node_title_map = _extract_node_titles(tree.tree_structure)
|
||||||
|
|
||||||
|
# Build step feedback with dropoff data
|
||||||
|
for node_id in sorted(node_visits.keys()):
|
||||||
|
visits = node_visits.get(node_id, 0)
|
||||||
|
dropoffs = node_dropoffs.get(node_id, 0)
|
||||||
|
step_feedback.append(StepFeedbackSummary(
|
||||||
|
node_id=node_id,
|
||||||
|
node_title=node_title_map.get(node_id, "Unknown Step"),
|
||||||
|
helpful_yes=0,
|
||||||
|
helpful_no=0,
|
||||||
|
helpful_rate=0.0,
|
||||||
|
visit_count=visits,
|
||||||
|
dropoff_count=dropoffs,
|
||||||
|
dropoff_rate=round(dropoffs / visits, 3) if visits > 0 else 0.0,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Sort by dropoff_rate descending
|
||||||
|
step_feedback.sort(key=lambda x: x.dropoff_rate, reverse=True)
|
||||||
|
|
||||||
|
# Recent comments - ANONYMOUS (no user_name join)
|
||||||
|
comments_q = await db.execute(
|
||||||
|
select(SessionRating.rating, SessionRating.comment, SessionRating.created_at)
|
||||||
|
.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,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_node_titles(tree_structure: dict) -> dict[str, str]:
|
||||||
|
"""Recursively extract node_id -> title/question from tree structure."""
|
||||||
|
titles = {}
|
||||||
|
|
||||||
|
def walk(node):
|
||||||
|
if not isinstance(node, dict):
|
||||||
|
return
|
||||||
|
node_id = node.get("id", "")
|
||||||
|
title = node.get("title") or node.get("question") or "Unnamed"
|
||||||
|
if node_id:
|
||||||
|
titles[node_id] = title
|
||||||
|
for child in node.get("children", []):
|
||||||
|
walk(child)
|
||||||
|
|
||||||
|
walk(tree_structure)
|
||||||
|
return titles
|
||||||
124
backend/app/api/endpoints/ratings.py
Normal file
124
backend/app/api/endpoints/ratings.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
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.models.step_library import StepLibrary, StepRating
|
||||||
|
from app.schemas.analytics import SessionRatingCreate, SessionRatingResponse, StepFeedbackCreate
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
resp_status = "updated"
|
||||||
|
else:
|
||||||
|
new_rating = StepRating(
|
||||||
|
step_id=step_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
session_id=session_uuid,
|
||||||
|
was_helpful=data.was_helpful,
|
||||||
|
# rating is nullable now — thumbs-only mode
|
||||||
|
)
|
||||||
|
db.add(new_rating)
|
||||||
|
resp_status = "created"
|
||||||
|
|
||||||
|
# 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": resp_status}
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_step_helpful_counts(db: AsyncSession, step_id: UUID):
|
||||||
|
"""Recalculate helpful_yes and helpful_no on step_library."""
|
||||||
|
yes_q = await db.execute(
|
||||||
|
select(func.count()).select_from(StepRating).where(
|
||||||
|
StepRating.step_id == step_id, StepRating.was_helpful == True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
no_q = await db.execute(
|
||||||
|
select(func.count()).select_from(StepRating).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_q.scalar() or 0, helpful_no=no_q.scalar() or 0)
|
||||||
|
)
|
||||||
@@ -554,6 +554,42 @@ async def delete_rating(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# Backward-compatible /ratings alias routes
|
||||||
|
@router.post("/{step_id}/ratings", response_model=StepRatingResponse, status_code=201,
|
||||||
|
include_in_schema=False)
|
||||||
|
async def rate_step_alias(
|
||||||
|
step_id: UUID,
|
||||||
|
rating_data: StepRatingCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Alias for POST /{step_id}/rate."""
|
||||||
|
return await rate_step(step_id, rating_data, db, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{step_id}/ratings", response_model=StepRatingResponse,
|
||||||
|
include_in_schema=False)
|
||||||
|
async def update_rating_alias(
|
||||||
|
step_id: UUID,
|
||||||
|
rating_data: StepRatingUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Alias for PUT /{step_id}/rate."""
|
||||||
|
return await update_rating(step_id, rating_data, db, current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{step_id}/ratings", status_code=204,
|
||||||
|
include_in_schema=False)
|
||||||
|
async def delete_rating_alias(
|
||||||
|
step_id: UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user)
|
||||||
|
):
|
||||||
|
"""Alias for DELETE /{step_id}/rate."""
|
||||||
|
return await delete_rating(step_id, db, current_user)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{step_id}/reviews", response_model=list[StepRatingResponse])
|
@router.get("/{step_id}/reviews", response_model=list[StepRatingResponse])
|
||||||
async def get_reviews(
|
async def get_reviews(
|
||||||
step_id: UUID,
|
step_id: UUID,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown
|
from app.api.endpoints import auth, trees, sessions, invite, categories, tags, folders, step_categories, steps, admin, accounts, webhooks, shares, shared, tree_markdown
|
||||||
from app.api.endpoints import admin_dashboard, admin_audit, admin_plan_limits, admin_feature_flags, admin_settings, admin_categories
|
from app.api.endpoints import admin_dashboard, admin_audit, admin_plan_limits, admin_feature_flags, admin_settings, admin_categories
|
||||||
|
from app.api.endpoints import ratings, analytics
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@@ -25,3 +26,5 @@ api_router.include_router(webhooks.router)
|
|||||||
api_router.include_router(shares.router)
|
api_router.include_router(shares.router)
|
||||||
api_router.include_router(shared.router) # Public endpoints (no auth)
|
api_router.include_router(shared.router) # Public endpoints (no auth)
|
||||||
api_router.include_router(tree_markdown.router)
|
api_router.include_router(tree_markdown.router)
|
||||||
|
api_router.include_router(ratings.router)
|
||||||
|
api_router.include_router(analytics.router)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from .refresh_token import RefreshToken
|
|||||||
from .audit_log import AuditLog
|
from .audit_log import AuditLog
|
||||||
from .password_reset_token import PasswordResetToken
|
from .password_reset_token import PasswordResetToken
|
||||||
from .session_share import SessionShare, SessionShareView
|
from .session_share import SessionShare, SessionShareView
|
||||||
|
from .session_rating import SessionRating
|
||||||
from .account_limit_override import AccountLimitOverride
|
from .account_limit_override import AccountLimitOverride
|
||||||
from .feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride
|
from .feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride
|
||||||
from .platform_setting import PlatformSetting
|
from .platform_setting import PlatformSetting
|
||||||
@@ -47,6 +48,7 @@ __all__ = [
|
|||||||
"PasswordResetToken",
|
"PasswordResetToken",
|
||||||
"SessionShare",
|
"SessionShare",
|
||||||
"SessionShareView",
|
"SessionShareView",
|
||||||
|
"SessionRating",
|
||||||
"AccountLimitOverride",
|
"AccountLimitOverride",
|
||||||
"FeatureFlag",
|
"FeatureFlag",
|
||||||
"PlanFeatureDefault",
|
"PlanFeatureDefault",
|
||||||
|
|||||||
46
backend/app/models/session_rating.py
Normal file
46
backend/app/models/session_rating.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional, TYPE_CHECKING
|
||||||
|
from sqlalchemy import String, DateTime, Integer, CheckConstraint, UniqueConstraint, ForeignKey
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.session import Session
|
||||||
|
from app.models.tree import Tree
|
||||||
|
|
||||||
|
|
||||||
|
class SessionRating(Base):
|
||||||
|
__tablename__ = "session_ratings"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("rating >= 1 AND rating <= 5", name="ck_session_ratings_rating_range"),
|
||||||
|
UniqueConstraint("session_id", name="uq_session_ratings_session_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
session_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("sessions.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
tree_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("trees.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
rating: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
comment: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
session: Mapped["Session"] = relationship("Session", foreign_keys=[session_id])
|
||||||
|
user: Mapped["User"] = relationship("User", foreign_keys=[user_id])
|
||||||
|
tree: Mapped["Tree"] = relationship("Tree", foreign_keys=[tree_id])
|
||||||
@@ -125,7 +125,7 @@ class StepRating(Base):
|
|||||||
ForeignKey("users.id", ondelete="CASCADE"),
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
nullable=False
|
nullable=False
|
||||||
)
|
)
|
||||||
rating: Mapped[int] = mapped_column(Integer, nullable=False)
|
rating: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||||
was_helpful: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
|
was_helpful: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
|
||||||
review_text: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
review_text: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||||
is_verified_use: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
is_verified_use: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|||||||
111
backend/app/schemas/analytics.py
Normal file
111
backend/app/schemas/analytics.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
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):
|
||||||
|
"""Anonymous feedback item — no user_name for privacy."""
|
||||||
|
rating: int
|
||||||
|
comment: 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
|
||||||
|
median_duration_minutes: float
|
||||||
|
active_engineers: int = 0
|
||||||
|
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
|
||||||
|
median_duration_minutes: float
|
||||||
|
avg_csat: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TopEngineer(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
name: str
|
||||||
|
sessions: int
|
||||||
|
completion_rate: float
|
||||||
|
median_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
|
||||||
|
visit_count: int = 0
|
||||||
|
dropoff_count: int = 0
|
||||||
|
dropoff_rate: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
class FlowAnalyticsResponse(BaseModel):
|
||||||
|
summary: AnalyticsSummary
|
||||||
|
avg_csat: Optional[float]
|
||||||
|
total_ratings: int
|
||||||
|
time_series: list[TimeSeriesPoint]
|
||||||
|
step_feedback: list[StepFeedbackSummary]
|
||||||
|
recent_comments: list[FlowRatingItem]
|
||||||
137
backend/tests/test_analytics.py
Normal file
137
backend/tests/test_analytics.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def team_admin(client: AsyncClient, test_db):
|
||||||
|
"""Create a team admin user (gets own account via registration)."""
|
||||||
|
from uuid import UUID as PyUUID
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"email": "teamadmin@example.com",
|
||||||
|
"password": "TeamAdmin123!",
|
||||||
|
"name": "Team Admin"
|
||||||
|
}
|
||||||
|
response = await client.post("/api/v1/auth/register", json=data)
|
||||||
|
assert response.status_code in (200, 201), f"Failed: {response.text}"
|
||||||
|
|
||||||
|
user_id = PyUUID(response.json()["id"])
|
||||||
|
result = await test_db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one()
|
||||||
|
user.is_team_admin = True
|
||||||
|
await test_db.commit()
|
||||||
|
|
||||||
|
return {"email": data["email"], "password": data["password"], "user_data": response.json()}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def team_admin_headers(client: AsyncClient, team_admin: dict):
|
||||||
|
"""Auth headers for team admin."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/auth/login/json",
|
||||||
|
json={"email": team_admin["email"], "password": team_admin["password"]},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
return {"Authorization": f"Bearer {response.json()['access_token']}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_team_analytics_success(client: AsyncClient, admin_auth_headers: dict):
|
||||||
|
"""Super 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"]
|
||||||
|
assert "median_duration_minutes" in data["summary"]
|
||||||
|
assert "active_engineers" in data["summary"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_team_analytics_team_admin(client: AsyncClient, team_admin_headers: dict):
|
||||||
|
"""Team admin can also access team analytics."""
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/analytics/team",
|
||||||
|
headers=team_admin_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
assert "median_duration_minutes" in data["summary"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_personal_analytics_empty(client: AsyncClient, auth_headers: dict):
|
||||||
|
"""Personal analytics with no sessions returns zeroes."""
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/analytics/me?period=7d",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["summary"]["total_sessions"] == 0
|
||||||
|
assert data["summary"]["completion_rate"] == 0.0
|
||||||
|
assert data["summary"]["median_duration_minutes"] == 0.0
|
||||||
|
assert data["summary"]["active_engineers"] == 1 # always 1 for personal
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flow_analytics_success(client: AsyncClient, auth_headers: dict, test_tree: dict):
|
||||||
|
"""Can access analytics for a visible flow."""
|
||||||
|
response = await client.get(
|
||||||
|
f"/api/v1/analytics/flows/{test_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
|
||||||
|
assert "avg_csat" in data
|
||||||
|
assert "total_ratings" in data
|
||||||
|
|
||||||
|
|
||||||
|
async def test_flow_analytics_404(client: AsyncClient, auth_headers: dict):
|
||||||
|
"""Non-existent flow returns 404."""
|
||||||
|
import uuid
|
||||||
|
response = await client.get(
|
||||||
|
f"/api/v1/analytics/flows/{uuid.uuid4()}",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_invalid_period_rejected(client: AsyncClient, auth_headers: dict):
|
||||||
|
"""Invalid period values are rejected."""
|
||||||
|
response = await client.get(
|
||||||
|
"/api/v1/analytics/me?period=1y",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
215
backend/tests/test_ratings.py
Normal file
215
backend/tests/test_ratings.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def test_session(client: AsyncClient, auth_headers: dict, test_tree: dict):
|
||||||
|
"""Create a test session from the test tree."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/sessions",
|
||||||
|
json={"tree_id": test_tree["id"]},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 201, f"Failed to create session: {response.text}"
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def completed_session(client: AsyncClient, auth_headers: dict, test_session: dict):
|
||||||
|
"""Complete a session so it can be rated."""
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/v1/sessions/{test_session['id']}/complete",
|
||||||
|
json={"outcome": "resolved", "outcome_notes": "Test resolved"},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, f"Failed to complete session: {response.text}"
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rate_session_success(client: AsyncClient, auth_headers: dict, completed_session: dict):
|
||||||
|
"""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_no_comment(client: AsyncClient, auth_headers: dict, completed_session: dict):
|
||||||
|
"""Rate without a comment."""
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/v1/sessions/{completed_session['id']}/rate",
|
||||||
|
json={"rating": 5},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["rating"] == 5
|
||||||
|
assert data["comment"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rate_session_duplicate(client: AsyncClient, auth_headers: dict, completed_session: dict):
|
||||||
|
"""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: dict):
|
||||||
|
"""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, test_session: dict):
|
||||||
|
"""Cannot rate a session that hasn't been completed."""
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/v1/sessions/{test_session['id']}/rate",
|
||||||
|
json={"rating": 4},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# --- Step Feedback Tests ---
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def test_step(client: AsyncClient, auth_headers: dict):
|
||||||
|
"""Create a step in the step library."""
|
||||||
|
response = await client.post(
|
||||||
|
"/api/v1/steps",
|
||||||
|
json={
|
||||||
|
"title": "Test Step",
|
||||||
|
"step_type": "action",
|
||||||
|
"content": {
|
||||||
|
"instructions": "Run the diagnostic command",
|
||||||
|
"commands": [{"label": "Echo test", "command": "echo hello"}]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 201, f"Failed to create step: {response.text}"
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_step_feedback_thumbs_up(client: AsyncClient, auth_headers: dict, test_step: dict, test_session: dict):
|
||||||
|
"""Submit thumbs-up feedback for a step."""
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/v1/steps/{test_step['id']}/feedback",
|
||||||
|
json={"session_id": test_session["id"], "was_helpful": True},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["was_helpful"] is True
|
||||||
|
assert data["status"] == "created"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_step_feedback_thumbs_down(client: AsyncClient, auth_headers: dict, test_step: dict, test_session: dict):
|
||||||
|
"""Submit thumbs-down feedback for a step."""
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/v1/steps/{test_step['id']}/feedback",
|
||||||
|
json={"session_id": test_session["id"], "was_helpful": False},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["was_helpful"] is False
|
||||||
|
assert data["status"] == "created"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_step_feedback_toggle(client: AsyncClient, auth_headers: dict, test_step: dict, test_session: dict):
|
||||||
|
"""Submitting again for same step+session updates the rating."""
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/steps/{test_step['id']}/feedback",
|
||||||
|
json={"session_id": test_session["id"], "was_helpful": True},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/v1/steps/{test_step['id']}/feedback",
|
||||||
|
json={"session_id": test_session["id"], "was_helpful": False},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["was_helpful"] is False
|
||||||
|
assert data["status"] == "updated"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_step_feedback_not_found(client: AsyncClient, auth_headers: dict, test_session: dict):
|
||||||
|
"""Feedback on non-existent step returns 404."""
|
||||||
|
import uuid
|
||||||
|
fake_id = str(uuid.uuid4())
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/v1/steps/{fake_id}/feedback",
|
||||||
|
json={"session_id": test_session["id"], "was_helpful": True},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# --- /ratings Alias Route Tests ---
|
||||||
|
|
||||||
|
async def test_ratings_alias_post(client: AsyncClient, auth_headers: dict, test_step: dict):
|
||||||
|
"""POST /steps/{id}/ratings works as alias for /rate."""
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/v1/steps/{test_step['id']}/ratings",
|
||||||
|
json={"rating": 4, "was_helpful": True},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["rating"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ratings_alias_put(client: AsyncClient, auth_headers: dict, test_step: dict):
|
||||||
|
"""PUT /steps/{id}/ratings works as alias for /rate."""
|
||||||
|
# First create a rating
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/steps/{test_step['id']}/rate",
|
||||||
|
json={"rating": 3, "was_helpful": True},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
# Update via alias
|
||||||
|
response = await client.put(
|
||||||
|
f"/api/v1/steps/{test_step['id']}/ratings",
|
||||||
|
json={"rating": 5},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["rating"] == 5
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ratings_alias_delete(client: AsyncClient, auth_headers: dict, test_step: dict):
|
||||||
|
"""DELETE /steps/{id}/ratings works as alias for /rate."""
|
||||||
|
# First create a rating
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/steps/{test_step['id']}/rate",
|
||||||
|
json={"rating": 4, "was_helpful": True},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
# Delete via alias
|
||||||
|
response = await client.delete(
|
||||||
|
f"/api/v1/steps/{test_step['id']}/ratings",
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert response.status_code == 204
|
||||||
1626
docs/plans/2026-02-15-analytics-feedback-implementation.md
Normal file
1626
docs/plans/2026-02-15-analytics-feedback-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
377
frontend/package-lock.json
generated
377
frontend/package-lock.json
generated
@@ -27,6 +27,7 @@
|
|||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.0",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zundo": "^2.3.0",
|
"zundo": "^2.3.0",
|
||||||
@@ -1401,6 +1402,32 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||||
|
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.53",
|
"version": "1.0.0-beta.53",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
|
||||||
@@ -1762,7 +1789,12 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
"dev": true,
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@stripe/stripe-js": {
|
"node_modules/@stripe/stripe-js": {
|
||||||
@@ -1928,6 +1960,69 @@
|
|||||||
"assertion-error": "^2.0.1"
|
"assertion-error": "^2.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/debug": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||||
@@ -2038,6 +2133,12 @@
|
|||||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.54.0",
|
"version": "8.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz",
|
||||||
@@ -3126,6 +3227,127 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/data-urls": {
|
"node_modules/data-urls": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||||
@@ -3180,6 +3402,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/decode-named-character-reference": {
|
"node_modules/decode-named-character-reference": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
||||||
@@ -3365,6 +3593,16 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.44.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
|
||||||
|
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.2",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||||
@@ -3634,6 +3872,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/expect-type": {
|
"node_modules/expect-type": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
@@ -4207,6 +4451,15 @@
|
|||||||
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
|
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-alphabetical": {
|
"node_modules/is-alphabetical": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
||||||
@@ -5903,7 +6156,6 @@
|
|||||||
"version": "17.0.2",
|
"version": "17.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
@@ -5934,6 +6186,29 @@
|
|||||||
"react": ">=18"
|
"react": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||||
@@ -6018,6 +6293,46 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||||
|
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/recharts/node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/redent": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||||
@@ -6032,6 +6347,21 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/remark-parse": {
|
"node_modules/remark-parse": {
|
||||||
"version": "11.0.0",
|
"version": "11.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
|
||||||
@@ -6085,6 +6415,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -6575,6 +6911,12 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
@@ -6930,6 +7272,15 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
@@ -6965,6 +7316,28 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.0",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zundo": "^2.3.0",
|
"zundo": "^2.3.0",
|
||||||
|
|||||||
36
frontend/src/api/analytics.ts
Normal file
36
frontend/src/api/analytics.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
import type {
|
||||||
|
TeamAnalyticsResponse,
|
||||||
|
PersonalAnalyticsResponse,
|
||||||
|
FlowAnalyticsResponse,
|
||||||
|
AnalyticsPeriod,
|
||||||
|
} from '@/types'
|
||||||
|
|
||||||
|
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 })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default analyticsApi
|
||||||
@@ -12,3 +12,4 @@ export { default as accountsApi } from './accounts'
|
|||||||
export { default as adminApi } from './admin'
|
export { default as adminApi } from './admin'
|
||||||
export { treeMarkdownApi } from './treeMarkdown'
|
export { treeMarkdownApi } from './treeMarkdown'
|
||||||
export { default as pinnedFlowsApi } from './pinnedFlows'
|
export { default as pinnedFlowsApi } from './pinnedFlows'
|
||||||
|
export { default as analyticsApi } from './analytics'
|
||||||
|
|||||||
308
frontend/src/components/analytics/FlowAnalyticsPanel.tsx
Normal file
308
frontend/src/components/analytics/FlowAnalyticsPanel.tsx
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Loader2, Star } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts'
|
||||||
|
import { analyticsApi } from '@/api'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { FlowAnalyticsResponse, AnalyticsPeriod } from '@/types'
|
||||||
|
|
||||||
|
const CHART_COLORS = {
|
||||||
|
resolved: '#34d399',
|
||||||
|
escalated: '#f87171',
|
||||||
|
workaround: '#fbbf24',
|
||||||
|
unresolved: '#94a3b8',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERIOD_OPTIONS: { value: AnalyticsPeriod; label: string }[] = [
|
||||||
|
{ value: '7d', label: 'Last 7 days' },
|
||||||
|
{ value: '30d', label: 'Last 30 days' },
|
||||||
|
{ value: '90d', label: 'Last 90 days' },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface FlowAnalyticsPanelProps {
|
||||||
|
treeId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlowAnalyticsPanel({ treeId }: FlowAnalyticsPanelProps) {
|
||||||
|
const [period, setPeriod] = useState<AnalyticsPeriod>('30d')
|
||||||
|
const [data, setData] = useState<FlowAnalyticsResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(false)
|
||||||
|
analyticsApi
|
||||||
|
.getFlowAnalytics(treeId, period)
|
||||||
|
.then(setData)
|
||||||
|
.catch(() => {
|
||||||
|
setError(true)
|
||||||
|
setData(null)
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [treeId, period])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
|
No analytics data available for this flow.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { summary, avg_csat, total_ratings, time_series, step_feedback, recent_comments } = data
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Period selector */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">Flow Analytics</h3>
|
||||||
|
<select
|
||||||
|
value={period}
|
||||||
|
onChange={(e) => setPeriod(e.target.value as AnalyticsPeriod)}
|
||||||
|
className="rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
>
|
||||||
|
{PERIOD_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary stat cards */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<StatCard label="Sessions" value={summary.total_sessions.toLocaleString()} />
|
||||||
|
<StatCard
|
||||||
|
label="Completion"
|
||||||
|
value={`${(summary.completion_rate * 100).toFixed(0)}%`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Median Time"
|
||||||
|
value={`${summary.median_duration_minutes} min`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="CSAT"
|
||||||
|
value={avg_csat != null ? `${avg_csat.toFixed(1)}/5` : 'N/A'}
|
||||||
|
subtitle={total_ratings > 0 ? `${total_ratings} rating${total_ratings !== 1 ? 's' : ''}` : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Area chart - Sessions over time */}
|
||||||
|
{time_series.length > 0 && (
|
||||||
|
<div className="bg-card border border-border rounded-xl p-4">
|
||||||
|
<p className="text-sm font-semibold text-foreground mb-3">Sessions Over Time</p>
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<AreaChart data={time_series}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
stroke="hsl(var(--border))"
|
||||||
|
vertical={false}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fill: 'hsl(var(--muted-foreground))', fontSize: 11 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: 'hsl(var(--border))' }}
|
||||||
|
tickFormatter={(value) => {
|
||||||
|
const d = new Date(String(value))
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fill: 'hsl(var(--muted-foreground))', fontSize: 11 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: 'hsl(var(--card))',
|
||||||
|
border: '1px solid hsl(var(--border))',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: 'hsl(var(--foreground))',
|
||||||
|
fontSize: '13px',
|
||||||
|
}}
|
||||||
|
labelFormatter={(value) => {
|
||||||
|
const d = new Date(String(value))
|
||||||
|
return d.toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="resolved"
|
||||||
|
stackId="1"
|
||||||
|
stroke={CHART_COLORS.resolved}
|
||||||
|
fill={CHART_COLORS.resolved}
|
||||||
|
fillOpacity={0.3}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="escalated"
|
||||||
|
stackId="1"
|
||||||
|
stroke={CHART_COLORS.escalated}
|
||||||
|
fill={CHART_COLORS.escalated}
|
||||||
|
fillOpacity={0.3}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="workaround"
|
||||||
|
stackId="1"
|
||||||
|
stroke={CHART_COLORS.workaround}
|
||||||
|
fill={CHART_COLORS.workaround}
|
||||||
|
fillOpacity={0.3}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="unresolved"
|
||||||
|
stackId="1"
|
||||||
|
stroke={CHART_COLORS.unresolved}
|
||||||
|
fill={CHART_COLORS.unresolved}
|
||||||
|
fillOpacity={0.3}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
|
||||||
|
{/* Chart legend */}
|
||||||
|
<div className="flex items-center gap-4 mt-2 justify-center">
|
||||||
|
{Object.entries(CHART_COLORS).map(([key, color]) => (
|
||||||
|
<div key={key} className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 rounded-full"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground capitalize">
|
||||||
|
{key}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step Feedback Table with Dropoff Metrics */}
|
||||||
|
{step_feedback.length > 0 && (
|
||||||
|
<div className="bg-card border border-border rounded-xl p-4">
|
||||||
|
<p className="text-sm font-semibold text-foreground mb-3">Step Performance</p>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<th className="text-left py-2 pr-4 text-foreground font-medium">Step</th>
|
||||||
|
<th className="text-right py-2 pr-4 text-foreground font-medium">Visits</th>
|
||||||
|
<th className="text-right py-2 pr-4 text-foreground font-medium">Dropoffs</th>
|
||||||
|
<th className="text-right py-2 text-foreground font-medium">Dropoff Rate</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{step_feedback.map((step) => (
|
||||||
|
<tr
|
||||||
|
key={step.node_id}
|
||||||
|
className={cn(
|
||||||
|
'border-b border-border last:border-0',
|
||||||
|
step.dropoff_rate > 0.2 && 'bg-red-400/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td className="py-2 pr-4 text-muted-foreground truncate max-w-[200px]">
|
||||||
|
{step.node_title}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-right text-muted-foreground">
|
||||||
|
{step.visit_count}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-right text-muted-foreground">
|
||||||
|
{step.dropoff_count}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={cn(
|
||||||
|
'py-2 text-right font-medium',
|
||||||
|
step.dropoff_rate > 0.2 ? 'text-red-400' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(step.dropoff_rate * 100).toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recent Comments (Anonymous) */}
|
||||||
|
{recent_comments.length > 0 && (
|
||||||
|
<div className="bg-card border border-border rounded-xl p-4">
|
||||||
|
<p className="text-sm font-semibold text-foreground mb-3">Recent Feedback</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{recent_comments.map((item, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-start gap-3 border-b border-border/50 pb-3 last:border-0 last:pb-0"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-0.5 shrink-0 pt-0.5">
|
||||||
|
{[1, 2, 3, 4, 5].map((v) => (
|
||||||
|
<Star
|
||||||
|
key={v}
|
||||||
|
size={12}
|
||||||
|
className={cn(
|
||||||
|
v <= item.rating
|
||||||
|
? 'fill-yellow-400 text-yellow-400'
|
||||||
|
: 'fill-none text-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
{item.comment && (
|
||||||
|
<p className="text-sm text-foreground">{item.comment}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{new Date(item.created_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
subtitle,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
subtitle?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card border border-border rounded-xl p-4">
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p className="text-xl font-bold text-foreground mt-1">{value}</p>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, Users, Settings, PanelLeftClose, PanelLeftOpen } from 'lucide-react'
|
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Users, Settings, PanelLeftClose, PanelLeftOpen } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||||
import { CategoryList } from '@/components/sidebar/CategoryList'
|
import { CategoryList } from '@/components/sidebar/CategoryList'
|
||||||
@@ -123,6 +123,7 @@ export function Sidebar() {
|
|||||||
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} collapsed />
|
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} collapsed />
|
||||||
<NavItem href="/shares" icon={FileText} label="Exports" collapsed />
|
<NavItem href="/shares" icon={FileText} label="Exports" collapsed />
|
||||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
|
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
|
||||||
|
<NavItem href="/analytics" icon={BarChart3} label="Analytics" collapsed />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -150,6 +151,7 @@ export function Sidebar() {
|
|||||||
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} />
|
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} />
|
||||||
<NavItem href="/shares" icon={FileText} label="Exports" />
|
<NavItem href="/shares" icon={FileText} label="Exports" />
|
||||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" badge="dot" />
|
<NavItem href="/step-library" icon={Bookmark} label="Step Library" badge="dot" />
|
||||||
|
<NavItem href="/analytics" icon={BarChart3} label="Analytics" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-b border-[hsl(var(--border-subtle))]" />
|
<div className="border-b border-[hsl(var(--border-subtle))]" />
|
||||||
|
|||||||
118
frontend/src/components/session/CSATModal.tsx
Normal file
118
frontend/src/components/session/CSATModal.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
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 — still close
|
||||||
|
onClose()
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSkip = () => {
|
||||||
|
markSessionRated(sessionId)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={handleSkip} title="How was this flow?" size="sm">
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
frontend/src/components/session/StepFeedback.tsx
Normal file
77
frontend/src/components/session/StepFeedback.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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">Was this step helpful?</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
348
frontend/src/pages/MyAnalyticsPage.tsx
Normal file
348
frontend/src/pages/MyAnalyticsPage.tsx
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { BarChart3, Loader2, Target, Clock, TrendingUp, CheckCircle } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts'
|
||||||
|
import { analyticsApi } from '@/api'
|
||||||
|
import { usePermissions } from '@/hooks/usePermissions'
|
||||||
|
import type { PersonalAnalyticsResponse, AnalyticsPeriod } from '@/types'
|
||||||
|
|
||||||
|
const OUTCOME_COLORS: Record<string, string> = {
|
||||||
|
resolved: '#34d399',
|
||||||
|
escalated: '#f87171',
|
||||||
|
workaround: '#fbbf24',
|
||||||
|
unresolved: '#94a3b8',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERIOD_OPTIONS: { value: AnalyticsPeriod; label: string }[] = [
|
||||||
|
{ value: '7d', label: 'Last 7 days' },
|
||||||
|
{ value: '30d', label: 'Last 30 days' },
|
||||||
|
{ value: '90d', label: 'Last 90 days' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function MyAnalyticsPage() {
|
||||||
|
const { isAccountOwner, isSuperAdmin } = usePermissions()
|
||||||
|
const [period, setPeriod] = useState<AnalyticsPeriod>('30d')
|
||||||
|
const [data, setData] = useState<PersonalAnalyticsResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true)
|
||||||
|
analyticsApi
|
||||||
|
.getPersonalAnalytics(period)
|
||||||
|
.then(setData)
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [period])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<Loader2 size={32} className="animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<p className="text-muted-foreground">Failed to load analytics data.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { summary, time_series, top_flows } = data
|
||||||
|
const outcomeBreakdown = summary.outcome_breakdown
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6 max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span title="My Analytics">
|
||||||
|
<BarChart3 size={24} className="text-foreground" />
|
||||||
|
</span>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">My Analytics</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{(isAccountOwner || isSuperAdmin) && (
|
||||||
|
<Link
|
||||||
|
to="/analytics"
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Team Analytics
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
value={period}
|
||||||
|
onChange={(e) => setPeriod(e.target.value as AnalyticsPeriod)}
|
||||||
|
className="rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
>
|
||||||
|
{PERIOD_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stat Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
icon={TrendingUp}
|
||||||
|
label="My Sessions"
|
||||||
|
value={summary.total_sessions.toLocaleString()}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Target}
|
||||||
|
label="My Completion Rate"
|
||||||
|
value={`${(summary.completion_rate * 100).toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Clock}
|
||||||
|
label="Median Duration"
|
||||||
|
value={`${summary.median_duration_minutes} min`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={CheckCircle}
|
||||||
|
label="Outcomes Resolved"
|
||||||
|
value={outcomeBreakdown.resolved.toLocaleString()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Area Chart — Sessions over Time */}
|
||||||
|
<div className="bg-card border border-border rounded-xl p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-foreground mb-4">
|
||||||
|
My Sessions Over Time
|
||||||
|
</h2>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<AreaChart data={time_series}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
stroke="hsl(var(--border))"
|
||||||
|
vertical={false}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fill: 'hsl(var(--muted-foreground))', fontSize: 12 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: 'hsl(var(--border))' }}
|
||||||
|
tickFormatter={(value: string) => {
|
||||||
|
const d = new Date(value)
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fill: 'hsl(var(--muted-foreground))', fontSize: 12 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: 'hsl(var(--card))',
|
||||||
|
border: '1px solid hsl(var(--border))',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: 'hsl(var(--foreground))',
|
||||||
|
fontSize: '13px',
|
||||||
|
}}
|
||||||
|
labelFormatter={(value) => {
|
||||||
|
const d = new Date(String(value))
|
||||||
|
return d.toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="resolved"
|
||||||
|
stackId="1"
|
||||||
|
stroke={OUTCOME_COLORS.resolved}
|
||||||
|
fill={OUTCOME_COLORS.resolved}
|
||||||
|
fillOpacity={0.3}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="escalated"
|
||||||
|
stackId="1"
|
||||||
|
stroke={OUTCOME_COLORS.escalated}
|
||||||
|
fill={OUTCOME_COLORS.escalated}
|
||||||
|
fillOpacity={0.3}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="workaround"
|
||||||
|
stackId="1"
|
||||||
|
stroke={OUTCOME_COLORS.workaround}
|
||||||
|
fill={OUTCOME_COLORS.workaround}
|
||||||
|
fillOpacity={0.3}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="unresolved"
|
||||||
|
stackId="1"
|
||||||
|
stroke={OUTCOME_COLORS.unresolved}
|
||||||
|
fill={OUTCOME_COLORS.unresolved}
|
||||||
|
fillOpacity={0.3}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
|
||||||
|
{/* Chart legend */}
|
||||||
|
<div className="flex items-center gap-6 mt-3 justify-center">
|
||||||
|
{Object.entries(OUTCOME_COLORS).map(([key, color]) => (
|
||||||
|
<div key={key} className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className="h-2.5 w-2.5 rounded-full"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground capitalize">
|
||||||
|
{key}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two-Column: Top Flows & Outcome Distribution */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* My Top Flows */}
|
||||||
|
<div className="bg-card border border-border rounded-xl p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-foreground mb-4">
|
||||||
|
My Top Flows
|
||||||
|
</h2>
|
||||||
|
{top_flows.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No flow data for this period.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<th className="text-left py-2 text-foreground font-medium">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-2 text-foreground font-medium">
|
||||||
|
Sessions
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-2 text-foreground font-medium">
|
||||||
|
Completion
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-2 text-foreground font-medium">
|
||||||
|
Median
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{top_flows.map((flow) => (
|
||||||
|
<tr
|
||||||
|
key={flow.tree_id}
|
||||||
|
className="border-b border-border last:border-0"
|
||||||
|
>
|
||||||
|
<td className="py-2 text-muted-foreground truncate max-w-[200px]">
|
||||||
|
{flow.name}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right text-muted-foreground">
|
||||||
|
{flow.sessions}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right text-muted-foreground">
|
||||||
|
{(flow.completion_rate * 100).toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right text-muted-foreground">
|
||||||
|
{flow.median_duration_minutes} min
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Outcome Distribution */}
|
||||||
|
<div className="bg-card border border-border rounded-xl p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-foreground mb-4">
|
||||||
|
Outcome Distribution
|
||||||
|
</h2>
|
||||||
|
{summary.total_sessions === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No session data for this period.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(
|
||||||
|
Object.entries(outcomeBreakdown) as [string, number][]
|
||||||
|
).map(([outcome, count]) => {
|
||||||
|
const total = Object.values(outcomeBreakdown).reduce(
|
||||||
|
(sum, v) => sum + v,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const pct = total > 0 ? (count / total) * 100 : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={outcome}>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="h-2.5 w-2.5 rounded-full flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
OUTCOME_COLORS[outcome] ?? '#94a3b8',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground capitalize">
|
||||||
|
{outcome}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-foreground font-medium">
|
||||||
|
{count} ({pct.toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-border overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
width: `${pct}%`,
|
||||||
|
backgroundColor:
|
||||||
|
OUTCOME_COLORS[outcome] ?? '#94a3b8',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ size?: number; className?: string }>
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card border border-border rounded-xl p-6">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Icon size={16} className="text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground">{label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-foreground">{value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import { ProgressBar } from '@/components/procedural/ProgressBar'
|
|||||||
import { CompletionSummary } from '@/components/procedural/CompletionSummary'
|
import { CompletionSummary } from '@/components/procedural/CompletionSummary'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
|
import { StepFeedback } from '@/components/session/StepFeedback'
|
||||||
|
import { CSATModal, hasBeenRated } from '@/components/session/CSATModal'
|
||||||
|
|
||||||
interface StepState {
|
interface StepState {
|
||||||
notes: string
|
notes: string
|
||||||
@@ -35,6 +37,7 @@ export function ProceduralNavigationPage() {
|
|||||||
const [completedAt, setCompletedAt] = useState<string>('')
|
const [completedAt, setCompletedAt] = useState<string>('')
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||||
const [paramsOpen, setParamsOpen] = useState(false)
|
const [paramsOpen, setParamsOpen] = useState(false)
|
||||||
|
const [showCsatModal, setShowCsatModal] = useState(false)
|
||||||
const [elapsedMinutes, setElapsedMinutes] = useState(0)
|
const [elapsedMinutes, setElapsedMinutes] = useState(0)
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
@@ -244,6 +247,9 @@ export function ProceduralNavigationPage() {
|
|||||||
})
|
})
|
||||||
setCompletedAt(completedTime)
|
setCompletedAt(completedTime)
|
||||||
setIsComplete(true)
|
setIsComplete(true)
|
||||||
|
if (!hasBeenRated(session.id)) {
|
||||||
|
setShowCsatModal(true)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setCurrentStepIndex(currentStepIndex + 1)
|
setCurrentStepIndex(currentStepIndex + 1)
|
||||||
}
|
}
|
||||||
@@ -274,6 +280,10 @@ export function ProceduralNavigationPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCsatClose = () => {
|
||||||
|
setShowCsatModal(false)
|
||||||
|
}
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -406,9 +416,23 @@ export function ProceduralNavigationPage() {
|
|||||||
isLast={currentStepIndex === procedureSteps.length - 1}
|
isLast={currentStepIndex === procedureSteps.length - 1}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{session && currentStep && (
|
||||||
|
<div className="mt-3 flex justify-end">
|
||||||
|
<StepFeedback stepId={currentStep.id} sessionId={session.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* CSAT Modal */}
|
||||||
|
{session && (
|
||||||
|
<CSATModal
|
||||||
|
isOpen={showCsatModal}
|
||||||
|
onClose={handleCsatClose}
|
||||||
|
sessionId={session.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Parameters popover */}
|
{/* Parameters popover */}
|
||||||
{paramsOpen && (
|
{paramsOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
|||||||
366
frontend/src/pages/TeamAnalyticsPage.tsx
Normal file
366
frontend/src/pages/TeamAnalyticsPage.tsx
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { BarChart3, Loader2, Users, Target, Clock, TrendingUp, ShieldX } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts'
|
||||||
|
import { analyticsApi } from '@/api'
|
||||||
|
import { usePermissions } from '@/hooks/usePermissions'
|
||||||
|
import type { TeamAnalyticsResponse, AnalyticsPeriod } from '@/types'
|
||||||
|
|
||||||
|
const CHART_COLORS = {
|
||||||
|
resolved: '#34d399',
|
||||||
|
escalated: '#f87171',
|
||||||
|
workaround: '#fbbf24',
|
||||||
|
unresolved: '#94a3b8',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PERIOD_OPTIONS: { value: AnalyticsPeriod; label: string }[] = [
|
||||||
|
{ value: '7d', label: 'Last 7 days' },
|
||||||
|
{ value: '30d', label: 'Last 30 days' },
|
||||||
|
{ value: '90d', label: 'Last 90 days' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function TeamAnalyticsPage() {
|
||||||
|
const { isAccountOwner, isSuperAdmin } = usePermissions()
|
||||||
|
const [period, setPeriod] = useState<AnalyticsPeriod>('30d')
|
||||||
|
const [data, setData] = useState<TeamAnalyticsResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAccountOwner && !isSuperAdmin) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
analyticsApi
|
||||||
|
.getTeamAnalytics(period)
|
||||||
|
.then(setData)
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [period, isAccountOwner, isSuperAdmin])
|
||||||
|
|
||||||
|
// Permission guard
|
||||||
|
if (!isAccountOwner && !isSuperAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4 p-6">
|
||||||
|
<ShieldX size={48} className="text-muted-foreground" />
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">Access Denied</h2>
|
||||||
|
<p className="text-muted-foreground text-center max-w-md">
|
||||||
|
Team Analytics is only available to account owners and administrators.
|
||||||
|
You can view your personal stats instead.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/analytics/me"
|
||||||
|
className="mt-2 inline-flex items-center gap-2 rounded-lg bg-white text-black px-4 py-2 text-sm font-medium hover:bg-white/90 transition-colors"
|
||||||
|
>
|
||||||
|
<TrendingUp size={16} />
|
||||||
|
View My Stats
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<Loader2 size={32} className="animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
|
<p className="text-muted-foreground">Failed to load analytics data.</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { summary, time_series, top_flows, top_engineers } = data
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6 max-w-7xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span title="Team Analytics">
|
||||||
|
<BarChart3 size={24} className="text-foreground" />
|
||||||
|
</span>
|
||||||
|
<h1 className="text-2xl font-bold text-foreground">Team Analytics</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
to="/analytics/me"
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
My Stats
|
||||||
|
</Link>
|
||||||
|
<select
|
||||||
|
value={period}
|
||||||
|
onChange={(e) => setPeriod(e.target.value as AnalyticsPeriod)}
|
||||||
|
className="rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
>
|
||||||
|
{PERIOD_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stat Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
icon={TrendingUp}
|
||||||
|
label="Total Sessions"
|
||||||
|
value={summary.total_sessions.toLocaleString()}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Target}
|
||||||
|
label="Completion Rate"
|
||||||
|
value={`${(summary.completion_rate * 100).toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Clock}
|
||||||
|
label="Median Duration"
|
||||||
|
value={`${summary.median_duration_minutes} min`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Users}
|
||||||
|
label="Active Engineers"
|
||||||
|
value={summary.active_engineers.toLocaleString()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Area Chart — Sessions over Time */}
|
||||||
|
<div className="bg-card border border-border rounded-xl p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-foreground mb-4">
|
||||||
|
Sessions Over Time
|
||||||
|
</h2>
|
||||||
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
|
<AreaChart data={time_series}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
stroke="hsl(var(--border))"
|
||||||
|
vertical={false}
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fill: 'hsl(var(--muted-foreground))', fontSize: 12 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: 'hsl(var(--border))' }}
|
||||||
|
tickFormatter={(value: string) => {
|
||||||
|
const d = new Date(value)
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fill: 'hsl(var(--muted-foreground))', fontSize: 12 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: 'hsl(var(--card))',
|
||||||
|
border: '1px solid hsl(var(--border))',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: 'hsl(var(--foreground))',
|
||||||
|
fontSize: '13px',
|
||||||
|
}}
|
||||||
|
labelFormatter={(value) => {
|
||||||
|
const d = new Date(String(value))
|
||||||
|
return d.toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="resolved"
|
||||||
|
stackId="1"
|
||||||
|
stroke={CHART_COLORS.resolved}
|
||||||
|
fill={CHART_COLORS.resolved}
|
||||||
|
fillOpacity={0.3}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="escalated"
|
||||||
|
stackId="1"
|
||||||
|
stroke={CHART_COLORS.escalated}
|
||||||
|
fill={CHART_COLORS.escalated}
|
||||||
|
fillOpacity={0.3}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="workaround"
|
||||||
|
stackId="1"
|
||||||
|
stroke={CHART_COLORS.workaround}
|
||||||
|
fill={CHART_COLORS.workaround}
|
||||||
|
fillOpacity={0.3}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="unresolved"
|
||||||
|
stackId="1"
|
||||||
|
stroke={CHART_COLORS.unresolved}
|
||||||
|
fill={CHART_COLORS.unresolved}
|
||||||
|
fillOpacity={0.3}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
|
||||||
|
{/* Chart legend */}
|
||||||
|
<div className="flex items-center gap-6 mt-3 justify-center">
|
||||||
|
{Object.entries(CHART_COLORS).map(([key, color]) => (
|
||||||
|
<div key={key} className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className="h-2.5 w-2.5 rounded-full"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground capitalize">
|
||||||
|
{key}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two-Column: Top Flows & Top Engineers */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Top Flows */}
|
||||||
|
<div className="bg-card border border-border rounded-xl p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-foreground mb-4">
|
||||||
|
Top Flows
|
||||||
|
</h2>
|
||||||
|
{top_flows.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No flow data for this period.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<th className="text-left py-2 text-foreground font-medium">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-2 text-foreground font-medium">
|
||||||
|
Sessions
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-2 text-foreground font-medium">
|
||||||
|
Completion
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-2 text-foreground font-medium">
|
||||||
|
Median
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{top_flows.map((flow) => (
|
||||||
|
<tr
|
||||||
|
key={flow.tree_id}
|
||||||
|
className="border-b border-border last:border-0"
|
||||||
|
>
|
||||||
|
<td className="py-2 text-muted-foreground truncate max-w-[200px]">
|
||||||
|
{flow.name}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right text-muted-foreground">
|
||||||
|
{flow.sessions}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right text-muted-foreground">
|
||||||
|
{(flow.completion_rate * 100).toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right text-muted-foreground">
|
||||||
|
{flow.median_duration_minutes} min
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Engineers */}
|
||||||
|
<div className="bg-card border border-border rounded-xl p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-foreground mb-4">
|
||||||
|
Top Engineers
|
||||||
|
</h2>
|
||||||
|
{top_engineers.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No engineer data for this period.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<th className="text-left py-2 text-foreground font-medium">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-2 text-foreground font-medium">
|
||||||
|
Sessions
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-2 text-foreground font-medium">
|
||||||
|
Completion
|
||||||
|
</th>
|
||||||
|
<th className="text-right py-2 text-foreground font-medium">
|
||||||
|
Median
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{top_engineers.map((eng) => (
|
||||||
|
<tr
|
||||||
|
key={eng.user_id}
|
||||||
|
className="border-b border-border last:border-0"
|
||||||
|
>
|
||||||
|
<td className="py-2 text-muted-foreground truncate max-w-[200px]">
|
||||||
|
{eng.name}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right text-muted-foreground">
|
||||||
|
{eng.sessions}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right text-muted-foreground">
|
||||||
|
{(eng.completion_rate * 100).toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right text-muted-foreground">
|
||||||
|
{eng.median_duration_minutes} min
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ size?: number; className?: string }>
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card border border-border rounded-xl p-6">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Icon size={16} className="text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground">{label}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold text-foreground">{value}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import { useParams, useNavigate, useBlocker } from 'react-router-dom'
|
import { useParams, useNavigate, useBlocker } from 'react-router-dom'
|
||||||
import { useStore } from 'zustand'
|
import { useStore } from 'zustand'
|
||||||
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText, Code2, LayoutList } from 'lucide-react'
|
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText, Code2, LayoutList, BarChart3 } from 'lucide-react'
|
||||||
import { getMonacoEditor } from '@/components/tree-editor/code-mode'
|
import { getMonacoEditor } from '@/components/tree-editor/code-mode'
|
||||||
import { treesApi } from '@/api/trees'
|
import { treesApi } from '@/api/trees'
|
||||||
import { treeMarkdownApi } from '@/api/treeMarkdown'
|
import { treeMarkdownApi } from '@/api/treeMarkdown'
|
||||||
@@ -13,6 +13,7 @@ import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
|
|||||||
import { usePermissions } from '@/hooks/usePermissions'
|
import { usePermissions } from '@/hooks/usePermissions'
|
||||||
import { cn, safeGetItem } from '@/lib/utils'
|
import { cn, safeGetItem } from '@/lib/utils'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
|
import { FlowAnalyticsPanel } from '@/components/analytics/FlowAnalyticsPanel'
|
||||||
|
|
||||||
export function TreeEditorPage() {
|
export function TreeEditorPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
@@ -46,6 +47,7 @@ export function TreeEditorPage() {
|
|||||||
|
|
||||||
const [showDraftPrompt, setShowDraftPrompt] = useState(false)
|
const [showDraftPrompt, setShowDraftPrompt] = useState(false)
|
||||||
const [treeStatus, setTreeStatus] = useState<TreeStatus>('draft')
|
const [treeStatus, setTreeStatus] = useState<TreeStatus>('draft')
|
||||||
|
const [showAnalytics, setShowAnalytics] = useState(false)
|
||||||
|
|
||||||
// Mobile detection
|
// Mobile detection
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
@@ -538,6 +540,23 @@ export function TreeEditorPage() {
|
|||||||
|
|
||||||
<div className="mx-2 h-6 w-px bg-border" />
|
<div className="mx-2 h-6 w-px bg-border" />
|
||||||
|
|
||||||
|
{/* Analytics toggle (only for existing trees) */}
|
||||||
|
{isEditMode && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAnalytics(!showAnalytics)}
|
||||||
|
title="Toggle flow analytics panel"
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
showAnalytics
|
||||||
|
? 'bg-accent text-foreground'
|
||||||
|
: 'bg-card text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
Analytics
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Validate */}
|
{/* Validate */}
|
||||||
<button
|
<button
|
||||||
onClick={handleManualValidate}
|
onClick={handleManualValidate}
|
||||||
@@ -594,6 +613,13 @@ export function TreeEditorPage() {
|
|||||||
|
|
||||||
{/* Main Editor */}
|
{/* Main Editor */}
|
||||||
<TreeEditorLayout isMobile={isMobile} />
|
<TreeEditorLayout isMobile={isMobile} />
|
||||||
|
|
||||||
|
{/* Flow Analytics Panel (collapsible) */}
|
||||||
|
{showAnalytics && id && (
|
||||||
|
<div className="border-t border-border p-6 overflow-y-auto max-h-[50vh]">
|
||||||
|
<FlowAnalyticsPanel treeId={id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { Plus, CheckCircle, ArrowRight, Clock, Terminal, Clipboard, Check, Copy,
|
|||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
import { Modal } from '@/components/common/Modal'
|
import { Modal } from '@/components/common/Modal'
|
||||||
import { ShareSessionModal } from '@/components/session/ShareSessionModal'
|
import { ShareSessionModal } from '@/components/session/ShareSessionModal'
|
||||||
|
import { CSATModal, hasBeenRated } from '@/components/session/CSATModal'
|
||||||
|
import { StepFeedback } from '@/components/session/StepFeedback'
|
||||||
import { buildSessionShareUrl, getLatestActiveShareForSession } from '@/lib/sessionShare'
|
import { buildSessionShareUrl, getLatestActiveShareForSession } from '@/lib/sessionShare'
|
||||||
|
|
||||||
interface LocationState {
|
interface LocationState {
|
||||||
@@ -52,6 +54,7 @@ export function TreeNavigationPage() {
|
|||||||
const [isCopyingForTicket, setIsCopyingForTicket] = useState(false)
|
const [isCopyingForTicket, setIsCopyingForTicket] = useState(false)
|
||||||
const [showSharePopover, setShowSharePopover] = useState(false)
|
const [showSharePopover, setShowSharePopover] = useState(false)
|
||||||
const [showShareModal, setShowShareModal] = useState(false)
|
const [showShareModal, setShowShareModal] = useState(false)
|
||||||
|
const [showCsatModal, setShowCsatModal] = useState(false)
|
||||||
const [copiedShareLink, setCopiedShareLink] = useState(false)
|
const [copiedShareLink, setCopiedShareLink] = useState(false)
|
||||||
const [isCopyingShareLink, setIsCopyingShareLink] = useState(false)
|
const [isCopyingShareLink, setIsCopyingShareLink] = useState(false)
|
||||||
const sharePopoverRef = useRef<HTMLDivElement>(null)
|
const sharePopoverRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -195,6 +198,13 @@ export function TreeNavigationPage() {
|
|||||||
setCompletionSource('standard')
|
setCompletionSource('standard')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCsatClose = () => {
|
||||||
|
setShowCsatModal(false)
|
||||||
|
if (session) {
|
||||||
|
navigate(`/sessions/${session.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Custom step flow (creation, post-step actions, continuation, branching, forking)
|
// Custom step flow (creation, post-step actions, continuation, branching, forking)
|
||||||
const customStepFlow = useCustomStepFlow({
|
const customStepFlow = useCustomStepFlow({
|
||||||
tree,
|
tree,
|
||||||
@@ -450,6 +460,8 @@ export function TreeNavigationPage() {
|
|||||||
setPendingCompletionDecision(null)
|
setPendingCompletionDecision(null)
|
||||||
if (completionSource === 'custom' && customStepFlow.customSteps.length > 0) {
|
if (completionSource === 'custom' && customStepFlow.customSteps.length > 0) {
|
||||||
customStepFlow.setShowForkModal(true)
|
customStepFlow.setShowForkModal(true)
|
||||||
|
} else if (!hasBeenRated(session.id)) {
|
||||||
|
setShowCsatModal(true)
|
||||||
} else {
|
} else {
|
||||||
navigate(`/sessions/${session.id}`)
|
navigate(`/sessions/${session.id}`)
|
||||||
}
|
}
|
||||||
@@ -1089,6 +1101,13 @@ export function TreeNavigationPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Step Feedback */}
|
||||||
|
{session && (currentNode || currentCustomStep) && (
|
||||||
|
<div className="mt-3 flex justify-end border-t border-border pt-3">
|
||||||
|
<StepFeedback stepId={currentCustomStep?.id || currentNodeId} sessionId={session.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<div className="mt-6 border-t border-border pt-4">
|
<div className="mt-6 border-t border-border pt-4">
|
||||||
<label className="block text-sm font-medium text-foreground">
|
<label className="block text-sm font-medium text-foreground">
|
||||||
@@ -1180,6 +1199,14 @@ export function TreeNavigationPage() {
|
|||||||
isSubmitting={isCompleting}
|
isSubmitting={isCompleting}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{session && (
|
||||||
|
<CSATModal
|
||||||
|
isOpen={showCsatModal}
|
||||||
|
onClose={handleCsatClose}
|
||||||
|
sessionId={session.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Keyboard Shortcuts Modal */}
|
{/* Keyboard Shortcuts Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={shortcutsModalOpen}
|
isOpen={shortcutsModalOpen}
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ const ProceduralNavigationPage = lazy(() => import('@/pages/ProceduralNavigation
|
|||||||
const SessionHistoryPage = lazy(() => import('@/pages/SessionHistoryPage'))
|
const SessionHistoryPage = lazy(() => import('@/pages/SessionHistoryPage'))
|
||||||
const SessionDetailPage = lazy(() => import('@/pages/SessionDetailPage'))
|
const SessionDetailPage = lazy(() => import('@/pages/SessionDetailPage'))
|
||||||
const MySharesPage = lazy(() => import('@/pages/MySharesPage'))
|
const MySharesPage = lazy(() => import('@/pages/MySharesPage'))
|
||||||
|
const TeamAnalyticsPage = lazy(() => import('@/pages/TeamAnalyticsPage'))
|
||||||
|
const MyAnalyticsPage = lazy(() => import('@/pages/MyAnalyticsPage'))
|
||||||
const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage'))
|
const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage'))
|
||||||
// Admin pages
|
// Admin pages
|
||||||
const AdminLayout = lazy(() => import('@/components/admin/AdminLayout'))
|
const AdminLayout = lazy(() => import('@/components/admin/AdminLayout'))
|
||||||
@@ -198,6 +200,22 @@ export const router = createBrowserRouter([
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'analytics',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<TeamAnalyticsPage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'analytics/me',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<MyAnalyticsPage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
// Admin routes
|
// Admin routes
|
||||||
{
|
{
|
||||||
path: 'admin',
|
path: 'admin',
|
||||||
|
|||||||
82
frontend/src/types/analytics.ts
Normal file
82
frontend/src/types/analytics.ts
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
export interface OutcomeBreakdown {
|
||||||
|
resolved: number
|
||||||
|
escalated: number
|
||||||
|
workaround: number
|
||||||
|
unresolved: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsSummary {
|
||||||
|
total_sessions: number
|
||||||
|
completed_sessions: number
|
||||||
|
completion_rate: number
|
||||||
|
median_duration_minutes: number
|
||||||
|
active_engineers: 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
|
||||||
|
median_duration_minutes: number
|
||||||
|
avg_csat?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopEngineer {
|
||||||
|
user_id: string
|
||||||
|
name: string
|
||||||
|
sessions: number
|
||||||
|
completion_rate: number
|
||||||
|
median_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
|
||||||
|
visit_count: number
|
||||||
|
dropoff_count: number
|
||||||
|
dropoff_rate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowRatingItem {
|
||||||
|
rating: number
|
||||||
|
comment?: 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'
|
||||||
@@ -9,6 +9,7 @@ export * from './folder'
|
|||||||
export * from './step'
|
export * from './step'
|
||||||
export type { Account, Subscription, PlanLimits, SubscriptionDetails, AccountInvite, AccountMember } from './account'
|
export type { Account, Subscription, PlanLimits, SubscriptionDetails, AccountInvite, AccountMember } from './account'
|
||||||
export * from './admin'
|
export * from './admin'
|
||||||
|
export * from './analytics'
|
||||||
|
|
||||||
// API response wrapper types
|
// API response wrapper types
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
|
|||||||
Reference in New Issue
Block a user