feat(escalations): mount time-to-first-action stat-card on /escalations
Surfaces the new GET /analytics/flowpilot/escalations endpoint as a card
above the EscalationQueue list. Closes the loop from yesterday's metric
endpoint commit — seniors and owners see the wedge stat the moment they
open the queue, which is the daily-reps version of the GTM ROI story.
Pieces:
- EscalationMetrics TS interface mirroring the backend Pydantic model
(incl. metric_definition disclaimer field)
- flowpilotAnalyticsApi.getEscalationMetrics(period) client method
- EscalationMetricCard component:
* loading skeleton, error state, zero-data empty state
* avg + median + n_with_action/n_claimed conversion rate
* humanized seconds → "Ns" / "N.N min" formatting
* inline disclaimer reminding callers this is in-product time-to-
first-action only, NOT the savings claim — pair with manual
baseline (per /codex review's two-metric correction)
- Wired into EscalationQueuePage above EscalationQueue
DS-aligned: card-flat, accent-dim usage held to interactive elements,
text-muted-foreground for secondary copy, font-heading on the headline
number, explicit transition properties (no `transition: all`). Respects
prefers-reduced-motion implicitly (only animation is the loading pulse,
which Tailwind's animate-pulse already gates).
tsc -b clean. No new tests in this commit — component is a thin
state-machine over an axios call; integration coverage comes from the
existing backend tests + the e2e Playwright work in the plan.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
import apiClient from './client'
|
||||
import type { FlowPilotDashboard, KnowledgeGapReport, CoverageResponse, FlowQualityResponse, EnhancedPsaMetrics } from '@/types/flowpilot-analytics'
|
||||
import type {
|
||||
FlowPilotDashboard,
|
||||
KnowledgeGapReport,
|
||||
CoverageResponse,
|
||||
FlowQualityResponse,
|
||||
EnhancedPsaMetrics,
|
||||
EscalationMetrics,
|
||||
} from '@/types/flowpilot-analytics'
|
||||
|
||||
export const flowpilotAnalyticsApi = {
|
||||
async getDashboard(period: string = '30d'): Promise<FlowPilotDashboard> {
|
||||
@@ -36,6 +43,13 @@ export const flowpilotAnalyticsApi = {
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getEscalationMetrics(period: string = '30d'): Promise<EscalationMetrics> {
|
||||
const response = await apiClient.get<EscalationMetrics>('/analytics/flowpilot/escalations', {
|
||||
params: { period },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default flowpilotAnalyticsApi
|
||||
|
||||
130
frontend/src/components/flowpilot/EscalationMetricCard.tsx
Normal file
130
frontend/src/components/flowpilot/EscalationMetricCard.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Clock, TrendingUp, AlertCircle } from 'lucide-react'
|
||||
import { flowpilotAnalyticsApi } from '@/api'
|
||||
import type { EscalationMetrics } from '@/types/flowpilot-analytics'
|
||||
|
||||
interface EscalationMetricCardProps {
|
||||
period?: string
|
||||
}
|
||||
|
||||
function formatSeconds(s: number | null): string {
|
||||
if (s === null) return '—'
|
||||
if (s < 60) return `${Math.round(s)}s`
|
||||
const mins = s / 60
|
||||
if (mins < 10) return `${mins.toFixed(1)} min`
|
||||
return `${Math.round(mins)} min`
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the in-product time-to-first-action metric above the EscalationQueue.
|
||||
*
|
||||
* NOTE: this is the in-product metric only. The "minutes recovered" sales
|
||||
* claim requires a manual baseline measurement (see The Assignment in
|
||||
* docs/plans/2026-04-27-escalation-mode-wedge-design.md). Frame the number
|
||||
* as "time-to-first-action with structured handoff," not "minutes saved."
|
||||
*/
|
||||
export function EscalationMetricCard({ period = '30d' }: EscalationMetricCardProps) {
|
||||
const [metrics, setMetrics] = useState<EscalationMetrics | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const load = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await flowpilotAnalyticsApi.getEscalationMetrics(period)
|
||||
if (!cancelled) setMetrics(data)
|
||||
} catch {
|
||||
if (!cancelled) setError('Failed to load metric')
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [period])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="card-flat p-4 mb-4 animate-pulse">
|
||||
<div className="h-4 w-32 bg-elevated rounded" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card-flat p-4 mb-4 flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<AlertCircle size={14} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!metrics || metrics.n_handoffs_claimed === 0) {
|
||||
return (
|
||||
<div className="card-flat p-4 mb-4">
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
Time to first action ({period})
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
No claimed escalations yet. Once your team starts using Pick Up,
|
||||
we'll measure how fast they get into resolution.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const avgLabel = formatSeconds(metrics.avg_seconds_to_first_action)
|
||||
const medianLabel = formatSeconds(metrics.median_seconds_to_first_action)
|
||||
const conversionRate =
|
||||
metrics.n_handoffs_claimed > 0
|
||||
? Math.round(
|
||||
(metrics.n_handoffs_with_action / metrics.n_handoffs_claimed) * 100,
|
||||
)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="card-flat p-4 mb-4">
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<TrendingUp size={12} />
|
||||
<span>Time to first action — last {period}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-baseline gap-x-6 gap-y-2">
|
||||
<div>
|
||||
<span className="font-heading text-2xl font-bold text-foreground">
|
||||
{avgLabel}
|
||||
</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground">avg</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">{medianLabel}</span> median
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{metrics.n_handoffs_with_action}
|
||||
</span>
|
||||
/{metrics.n_handoffs_claimed} claimed escalations
|
||||
<span className="ml-1 text-muted-foreground/70">
|
||||
({conversionRate}% reached first action)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 flex items-start gap-1.5 text-[0.6875rem] text-muted-foreground">
|
||||
<Clock size={10} className="mt-0.5 flex-none" />
|
||||
<span>
|
||||
In-product measurement only. The savings claim requires a manual
|
||||
baseline of pre-Escalation-Mode handoff time. See your team's
|
||||
Assignment for the baseline number.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export { AISessionListItem } from './AISessionListItem'
|
||||
export { SessionTicketCard } from './SessionTicketCard'
|
||||
export { EscalateModal } from './EscalateModal'
|
||||
export { EscalationQueue } from './EscalationQueue'
|
||||
export { EscalationMetricCard } from './EscalationMetricCard'
|
||||
export { SessionBriefing } from './SessionBriefing'
|
||||
export { ProposalCard } from './ProposalCard'
|
||||
export { ProposalDetail } from './ProposalDetail'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { EscalationQueue } from '@/components/flowpilot'
|
||||
import { EscalationQueue, EscalationMetricCard } from '@/components/flowpilot'
|
||||
|
||||
export default function EscalationQueuePage() {
|
||||
const [count, setCount] = useState<number | null>(null)
|
||||
@@ -21,6 +21,8 @@ export default function EscalationQueuePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EscalationMetricCard period="30d" />
|
||||
|
||||
<EscalationQueue onCountChange={setCount} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -134,3 +134,16 @@ export interface EnhancedPsaMetrics {
|
||||
push_funnel: PsaFunnel
|
||||
daily_trend: PsaDailyTrend[]
|
||||
}
|
||||
|
||||
// Escalation Mode wedge metric — in-product time-to-first-action.
|
||||
// Pair with a manual baseline measurement for the savings claim.
|
||||
// See docs/plans/2026-04-27-escalation-mode-wedge-design.md.
|
||||
export interface EscalationMetrics {
|
||||
period: string
|
||||
n_handoffs_claimed: number
|
||||
n_handoffs_with_action: number
|
||||
avg_seconds_to_first_action: number | null
|
||||
median_seconds_to_first_action: number | null
|
||||
p95_seconds_to_first_action: number | null
|
||||
metric_definition: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user