feat: analytics dashboards & two-tier feedback system (#78)
* docs: add analytics & user feedback design document Covers team analytics, personal analytics, flow analytics, step-level thumbs up/down feedback, and flow CSAT ratings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add analytics & feedback implementation plan 12-task TDD plan covering session ratings, step feedback, team/personal/flow analytics endpoints, and frontend pages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add session_ratings table and analytics indexes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add SessionRating model and analytics schemas Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add session CSAT rating endpoint with tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add step thumbs feedback and /ratings alias routes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add team, personal, and flow analytics endpoints Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add recharts, analytics types, and API client Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add inline step thumbs up/down feedback during sessions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add CSAT rating modal after session completion Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add Team Analytics page with charts and leaderboards Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add Flow Analytics panel with step dropoff and CSAT data Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add My Analytics page with personal stats and charts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #78.
This commit is contained in:
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 { 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 { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
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="/shares" icon={FileText} label="Exports" collapsed />
|
||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
|
||||
<NavItem href="/analytics" icon={BarChart3} label="Analytics" collapsed />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
@@ -150,6 +151,7 @@ export function Sidebar() {
|
||||
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} />
|
||||
<NavItem href="/shares" icon={FileText} label="Exports" />
|
||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" badge="dot" />
|
||||
<NavItem href="/analytics" icon={BarChart3} label="Analytics" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user