feat: add Flow Analytics panel with step dropoff and CSAT data
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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,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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user