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:
2026-04-27 16:00:34 -04:00
parent 07d0db9579
commit 9f0bfd44f9
5 changed files with 162 additions and 2 deletions

View File

@@ -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

View 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>
)
}

View File

@@ -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'

View File

@@ -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>
)

View File

@@ -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
}