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 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 = {
|
export const flowpilotAnalyticsApi = {
|
||||||
async getDashboard(period: string = '30d'): Promise<FlowPilotDashboard> {
|
async getDashboard(period: string = '30d'): Promise<FlowPilotDashboard> {
|
||||||
@@ -36,6 +43,13 @@ export const flowpilotAnalyticsApi = {
|
|||||||
})
|
})
|
||||||
return response.data
|
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
|
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 { SessionTicketCard } from './SessionTicketCard'
|
||||||
export { EscalateModal } from './EscalateModal'
|
export { EscalateModal } from './EscalateModal'
|
||||||
export { EscalationQueue } from './EscalationQueue'
|
export { EscalationQueue } from './EscalationQueue'
|
||||||
|
export { EscalationMetricCard } from './EscalationMetricCard'
|
||||||
export { SessionBriefing } from './SessionBriefing'
|
export { SessionBriefing } from './SessionBriefing'
|
||||||
export { ProposalCard } from './ProposalCard'
|
export { ProposalCard } from './ProposalCard'
|
||||||
export { ProposalDetail } from './ProposalDetail'
|
export { ProposalDetail } from './ProposalDetail'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { AlertTriangle } from 'lucide-react'
|
import { AlertTriangle } from 'lucide-react'
|
||||||
import { EscalationQueue } from '@/components/flowpilot'
|
import { EscalationQueue, EscalationMetricCard } from '@/components/flowpilot'
|
||||||
|
|
||||||
export default function EscalationQueuePage() {
|
export default function EscalationQueuePage() {
|
||||||
const [count, setCount] = useState<number | null>(null)
|
const [count, setCount] = useState<number | null>(null)
|
||||||
@@ -21,6 +21,8 @@ export default function EscalationQueuePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EscalationMetricCard period="30d" />
|
||||||
|
|
||||||
<EscalationQueue onCountChange={setCount} />
|
<EscalationQueue onCountChange={setCount} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -134,3 +134,16 @@ export interface EnhancedPsaMetrics {
|
|||||||
push_funnel: PsaFunnel
|
push_funnel: PsaFunnel
|
||||||
daily_trend: PsaDailyTrend[]
|
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